Resolve conflicts in dashboard files
This commit is contained in:
@@ -50,7 +50,7 @@ jobs:
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4.3.0
|
||||
uses: pnpm/action-setup@v4.4.0
|
||||
with:
|
||||
version: 10.28.2
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ For users who want to quickly experience AstrBot, are familiar with command-line
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
astrbot init # Only execute this command for the first time to initialize the environment
|
||||
astrbot
|
||||
astrbot run
|
||||
```
|
||||
|
||||
> Requires [uv](https://docs.astral.sh/uv/) to be installed.
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ Pour les utilisateurs qui veulent découvrir AstrBot rapidement, qui sont famili
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
astrbot init # Exécutez cette commande uniquement la première fois pour initialiser l'environnement
|
||||
astrbot
|
||||
astrbot run
|
||||
```
|
||||
|
||||
> [uv](https://docs.astral.sh/uv/) doit être installé.
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ AstrBot を素早く試したいユーザーで、コマンドラインに慣れ
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
astrbot init # 初回のみ実行して環境を初期化します
|
||||
astrbot
|
||||
astrbot run
|
||||
```
|
||||
|
||||
> [uv](https://docs.astral.sh/uv/) のインストールが必要です。
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ AstrBot — это универсальная платформа Agent-чатб
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
astrbot init # Выполните эту команду только при первом запуске для инициализации окружения
|
||||
astrbot
|
||||
astrbot run
|
||||
```
|
||||
|
||||
> Требуется установленный [uv](https://docs.astral.sh/uv/).
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
astrbot init # 僅首次執行此命令以初始化環境
|
||||
astrbot
|
||||
astrbot run
|
||||
```
|
||||
|
||||
> 需要安裝 [uv](https://docs.astral.sh/uv/)。
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
astrbot init # 仅首次执行此命令以初始化环境
|
||||
astrbot
|
||||
astrbot run
|
||||
```
|
||||
|
||||
> 需要安装 [uv](https://docs.astral.sh/uv/)。
|
||||
|
||||
@@ -778,9 +778,14 @@ def _plugin_tool_fix(event: AstrMessageEvent, req: ProviderRequest) -> None:
|
||||
continue
|
||||
mp = tool.handler_module_path
|
||||
if not mp:
|
||||
# 没有 plugin 归属信息的工具(如 subagent transfer_to_*)
|
||||
# 不应受到会话插件过滤影响。
|
||||
new_tool_set.add_tool(tool)
|
||||
continue
|
||||
plugin = star_map.get(mp)
|
||||
if not plugin:
|
||||
# 无法解析插件归属时,保守保留工具,避免误过滤。
|
||||
new_tool_set.add_tool(tool)
|
||||
continue
|
||||
if plugin.name in event.plugins_name or plugin.reserved:
|
||||
new_tool_set.add_tool(tool)
|
||||
|
||||
@@ -332,9 +332,9 @@ class CronJobManager:
|
||||
cron_job=cron_job_str
|
||||
)
|
||||
req.prompt = (
|
||||
"You are now responding to a scheduled task"
|
||||
"You are now responding to a scheduled task. "
|
||||
"Proceed according to your system instructions. "
|
||||
"Output using same language as previous conversation."
|
||||
"Output using same language as previous conversation. "
|
||||
"After completing your task, summarize and output your actions and results."
|
||||
)
|
||||
if not req.func_tool:
|
||||
|
||||
@@ -647,6 +647,13 @@ class BaseDatabase(abc.ABC):
|
||||
"""Get a Platform session by its ID."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_platform_sessions_by_ids(
|
||||
self, session_ids: list[str]
|
||||
) -> list[PlatformSession]:
|
||||
"""Get platform sessions by IDs."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_platform_sessions_by_creator(
|
||||
self,
|
||||
|
||||
@@ -1417,6 +1417,21 @@ class SQLiteDatabase(BaseDatabase):
|
||||
result = await session.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_platform_sessions_by_ids(
|
||||
self, session_ids: list[str]
|
||||
) -> list[PlatformSession]:
|
||||
"""Get platform sessions by IDs."""
|
||||
if not session_ids:
|
||||
return []
|
||||
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
query = select(PlatformSession).where(
|
||||
col(PlatformSession.session_id).in_(session_ids)
|
||||
)
|
||||
result = await session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_platform_sessions_by_creator(
|
||||
self,
|
||||
creator: str,
|
||||
|
||||
@@ -96,10 +96,10 @@ class Plain(BaseMessageComponent):
|
||||
def __init__(self, text: str, convert: bool = True, **_) -> None:
|
||||
super().__init__(text=text, convert=convert, **_)
|
||||
|
||||
def toDict(self):
|
||||
return {"type": "text", "data": {"text": self.text.strip()}}
|
||||
def toDict(self) -> dict:
|
||||
return {"type": "text", "data": {"text": self.text}}
|
||||
|
||||
async def to_dict(self):
|
||||
async def to_dict(self) -> dict:
|
||||
return {"type": "text", "data": {"text": self.text}}
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from aiocqhttp import CQHttp, Event
|
||||
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.message_components import (
|
||||
At,
|
||||
BaseMessageComponent,
|
||||
File,
|
||||
Image,
|
||||
@@ -70,11 +71,19 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
|
||||
"""解析成 OneBot json 格式"""
|
||||
ret = []
|
||||
for segment in message_chain.chain:
|
||||
if isinstance(segment, Plain):
|
||||
if isinstance(segment, At):
|
||||
# At 组件后插入一个空格,避免与后续文本粘连
|
||||
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
|
||||
ret.append(d)
|
||||
ret.append({"type": "text", "data": {"text": " "}})
|
||||
elif isinstance(segment, Plain):
|
||||
if not segment.text.strip():
|
||||
continue
|
||||
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
|
||||
ret.append(d)
|
||||
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
|
||||
ret.append(d)
|
||||
else:
|
||||
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
|
||||
ret.append(d)
|
||||
return ret
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -289,8 +289,8 @@ class TelegramPlatformAdapter(Platform):
|
||||
else:
|
||||
message.type = MessageType.GROUP_MESSAGE
|
||||
message.group_id = str(update.message.chat.id)
|
||||
if update.message.message_thread_id:
|
||||
# Topic Group
|
||||
if update.message.is_topic_message and update.message.message_thread_id:
|
||||
# Telegram Topic Group: include thread id to isolate per-topic sessions.
|
||||
message.group_id += "#" + str(update.message.message_thread_id)
|
||||
message.session_id = message.group_id
|
||||
message.message_id = str(update.message.message_id)
|
||||
|
||||
@@ -629,7 +629,8 @@ class ProviderOpenAIOfficial(Provider):
|
||||
# 最后一次不等待
|
||||
if retry_cnt < max_retries - 1:
|
||||
await asyncio.sleep(1)
|
||||
available_api_keys.remove(chosen_key)
|
||||
if chosen_key in available_api_keys:
|
||||
available_api_keys.remove(chosen_key)
|
||||
if len(available_api_keys) > 0:
|
||||
chosen_key = random.choice(available_api_keys)
|
||||
return (
|
||||
|
||||
@@ -82,7 +82,8 @@ class AuthRoute(Route):
|
||||
def generate_jwt(self, username):
|
||||
payload = {
|
||||
"username": username,
|
||||
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=7),
|
||||
"exp": datetime.datetime.now(datetime.timezone.utc)
|
||||
+ datetime.timedelta(days=7),
|
||||
}
|
||||
jwt_token = self.config["dashboard"].get("jwt_secret", None)
|
||||
if not jwt_token:
|
||||
|
||||
@@ -51,6 +51,7 @@ class ChatRoute(Route):
|
||||
"/chat/get_session": ("GET", self.get_session),
|
||||
"/chat/stop": ("POST", self.stop_session),
|
||||
"/chat/delete_session": ("GET", self.delete_webchat_session),
|
||||
"/chat/batch_delete_sessions": ("POST", self.batch_delete_sessions),
|
||||
"/chat/update_session_display_name": (
|
||||
"POST",
|
||||
self.update_session_display_name,
|
||||
@@ -578,19 +579,9 @@ class ChatRoute(Route):
|
||||
|
||||
return Response().ok(data={"stopped_count": stopped_count}).__dict__
|
||||
|
||||
async def delete_webchat_session(self):
|
||||
"""Delete a Platform session and all its related data."""
|
||||
session_id = request.args.get("session_id")
|
||||
if not session_id:
|
||||
return Response().error("Missing key: session_id").__dict__
|
||||
username = g.get("username", "guest")
|
||||
|
||||
# 验证会话是否存在且属于当前用户
|
||||
session = await self.db.get_platform_session_by_id(session_id)
|
||||
if not session:
|
||||
return Response().error(f"Session {session_id} not found").__dict__
|
||||
if session.creator != username:
|
||||
return Response().error("Permission denied").__dict__
|
||||
async def _delete_session_internal(self, session, username: str) -> None:
|
||||
"""Delete a single session and all its related data."""
|
||||
session_id = session.session_id
|
||||
|
||||
# 删除该会话下的所有对话
|
||||
message_type = "GroupMessage" if session.is_group else "FriendMessage"
|
||||
@@ -632,8 +623,70 @@ class ChatRoute(Route):
|
||||
# 删除会话
|
||||
await self.db.delete_platform_session(session_id)
|
||||
|
||||
async def delete_webchat_session(self):
|
||||
"""Delete a Platform session and all its related data."""
|
||||
session_id = request.args.get("session_id")
|
||||
if not session_id:
|
||||
return Response().error("Missing key: session_id").__dict__
|
||||
username = g.get("username", "guest")
|
||||
|
||||
session = await self.db.get_platform_session_by_id(session_id)
|
||||
if not session:
|
||||
return Response().error(f"Session {session_id} not found").__dict__
|
||||
if session.creator != username:
|
||||
return Response().error("Permission denied").__dict__
|
||||
|
||||
await self._delete_session_internal(session, username)
|
||||
|
||||
return Response().ok().__dict__
|
||||
|
||||
async def batch_delete_sessions(self):
|
||||
"""Batch delete multiple Platform sessions."""
|
||||
post_data = await request.json
|
||||
if post_data is None:
|
||||
return Response().error("Missing JSON body").__dict__
|
||||
if not isinstance(post_data, dict):
|
||||
return Response().error("Invalid JSON body: expected object").__dict__
|
||||
|
||||
session_ids = post_data.get("session_ids")
|
||||
if not session_ids or not isinstance(session_ids, list):
|
||||
return Response().error("Missing or invalid key: session_ids").__dict__
|
||||
|
||||
username = g.get("username", "guest")
|
||||
sessions = await self.db.get_platform_sessions_by_ids(session_ids)
|
||||
sessions_by_id = {session.session_id: session for session in sessions}
|
||||
deleted_count = 0
|
||||
failed_items = []
|
||||
|
||||
for sid in session_ids:
|
||||
session = sessions_by_id.get(sid)
|
||||
if not session:
|
||||
failed_items.append({"session_id": sid, "reason": "not found"})
|
||||
continue
|
||||
if session.creator != username:
|
||||
failed_items.append({"session_id": sid, "reason": "permission denied"})
|
||||
continue
|
||||
|
||||
try:
|
||||
await self._delete_session_internal(session, username)
|
||||
deleted_count += 1
|
||||
sessions_by_id.pop(sid, None)
|
||||
except Exception:
|
||||
logger.warning("Failed to delete session %s", sid)
|
||||
failed_items.append({"session_id": sid, "reason": "internal_error"})
|
||||
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
data={
|
||||
"deleted_count": deleted_count,
|
||||
"failed_count": len(failed_items),
|
||||
"failed_items": failed_items,
|
||||
}
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
|
||||
def _extract_attachment_ids(self, history_list) -> list[str]:
|
||||
"""从消息历史中提取所有 attachment_id"""
|
||||
attachment_ids = []
|
||||
|
||||
@@ -37,6 +37,7 @@ services:
|
||||
- DEFAULT_SHIP_MEMORY=512m
|
||||
volumes:
|
||||
- ${PWD}/data/shipyard/bay_data:/app/data
|
||||
- ${PWD}/data/temp:/AstrBot/data/temp # Bind the local temp directory to the sandbox so that the uploaded file can be accessed in the sandbox
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
networks:
|
||||
- astrbot_network
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
:currSessionId="currSessionId"
|
||||
:selectedProjectId="selectedProjectId"
|
||||
:transportMode="transportMode"
|
||||
:sendShortcut="sendShortcut"
|
||||
:isDark="isDark"
|
||||
:chatboxMode="chatboxMode"
|
||||
:isMobile="isMobile"
|
||||
@@ -20,6 +21,7 @@
|
||||
@selectConversation="handleSelectConversation"
|
||||
@editTitle="showEditTitleDialog"
|
||||
@deleteConversation="handleDeleteConversation"
|
||||
@batchDeleteConversations="handleBatchDeleteConversations"
|
||||
@closeMobileSidebar="closeMobileSidebar"
|
||||
@toggleTheme="toggleTheme"
|
||||
@toggleFullscreen="toggleFullscreen"
|
||||
@@ -28,6 +30,7 @@
|
||||
@editProject="showEditProjectDialog"
|
||||
@deleteProject="handleDeleteProject"
|
||||
@updateTransportMode="setTransportMode"
|
||||
@updateSendShortcut="setSendShortcut"
|
||||
/>
|
||||
|
||||
<!-- 右侧聊天内容区域 -->
|
||||
@@ -78,6 +81,7 @@
|
||||
:session-id="currSessionId || null"
|
||||
:current-session="getCurrentSession"
|
||||
:replyTo="replyTo"
|
||||
:send-shortcut="sendShortcut"
|
||||
@send="handleSendMessage"
|
||||
@stop="handleStopMessage"
|
||||
@toggleStreaming="toggleStreaming"
|
||||
@@ -109,6 +113,7 @@
|
||||
:session-id="currSessionId || null"
|
||||
:current-session="getCurrentSession"
|
||||
:replyTo="replyTo"
|
||||
:send-shortcut="sendShortcut"
|
||||
@send="handleSendMessage"
|
||||
@stop="handleStopMessage"
|
||||
@toggleStreaming="toggleStreaming"
|
||||
@@ -139,6 +144,7 @@
|
||||
:session-id="currSessionId || null"
|
||||
:current-session="getCurrentSession"
|
||||
:replyTo="replyTo"
|
||||
:send-shortcut="sendShortcut"
|
||||
@send="handleSendMessage"
|
||||
@stop="handleStopMessage"
|
||||
@toggleStreaming="toggleStreaming"
|
||||
@@ -220,10 +226,13 @@ import { useMediaHandling } from '@/composables/useMediaHandling';
|
||||
import { useProjects } from '@/composables/useProjects';
|
||||
import type { Project } from '@/components/chat/ProjectList.vue';
|
||||
import { useRecording } from '@/composables/useRecording';
|
||||
import { useToast } from '@/utils/toast';
|
||||
|
||||
interface Props {
|
||||
chatboxMode?: boolean;
|
||||
}
|
||||
type SendShortcut = 'enter' | 'shift_enter';
|
||||
const SEND_SHORTCUT_STORAGE_KEY = 'chat_send_shortcut';
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
chatboxMode: false
|
||||
@@ -233,6 +242,7 @@ const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const { tm } = useModuleI18n('features/chat');
|
||||
const { warning: toastWarning } = useToast();
|
||||
const theme = useTheme();
|
||||
const customizer = useCustomizerStore();
|
||||
|
||||
@@ -257,6 +267,7 @@ const {
|
||||
getSessions,
|
||||
newSession,
|
||||
deleteSession: deleteSessionFn,
|
||||
batchDeleteSessions,
|
||||
showEditTitleDialog,
|
||||
saveTitle,
|
||||
updateSessionTitle,
|
||||
@@ -330,6 +341,12 @@ interface ReplyInfo {
|
||||
const replyTo = ref<ReplyInfo | null>(null);
|
||||
|
||||
const isDark = computed(() => useCustomizerStore().uiTheme === 'PurpleThemeDark');
|
||||
const sendShortcut = ref<SendShortcut>('shift_enter');
|
||||
|
||||
function setSendShortcut(mode: SendShortcut) {
|
||||
sendShortcut.value = mode;
|
||||
localStorage.setItem(SEND_SHORTCUT_STORAGE_KEY, mode);
|
||||
}
|
||||
|
||||
// 检测是否为手机端
|
||||
function checkMobile() {
|
||||
@@ -510,6 +527,33 @@ async function handleDeleteConversation(sessionId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchDeleteConversations(sessionIds: string[]) {
|
||||
try {
|
||||
const result = await batchDeleteSessions(sessionIds);
|
||||
|
||||
// 仅在当前会话成功删除时清除信息
|
||||
if (result.currentSessionDeleted) {
|
||||
messages.value = [];
|
||||
}
|
||||
|
||||
// 失败处理
|
||||
if (result.failed_count > 0) {
|
||||
toastWarning(
|
||||
tm('batch.partialFailure', { failed: result.failed_count, total: sessionIds.length })
|
||||
);
|
||||
}
|
||||
|
||||
// 如果在项目视图中,刷新项目会话列表
|
||||
if (selectedProjectId.value) {
|
||||
const sessions = await getProjectSessions(selectedProjectId.value);
|
||||
projectSessions.value = sessions;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Batch delete sessions failed:', err);
|
||||
toastWarning(tm('batch.requestFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelectProject(projectId: string) {
|
||||
selectedProjectId.value = projectId;
|
||||
const sessions = await getProjectSessions(projectId);
|
||||
@@ -694,6 +738,10 @@ watch(sessions, (newSessions) => {
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
const storedShortcut = localStorage.getItem(SEND_SHORTCUT_STORAGE_KEY);
|
||||
if (storedShortcut === 'enter' || storedShortcut === 'shift_enter') {
|
||||
sendShortcut.value = storedShortcut;
|
||||
}
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
getSessions();
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<transition name="fade">
|
||||
<div v-if="isDragging" class="drop-overlay">
|
||||
<div class="drop-overlay-content">
|
||||
<v-icon size="48" color="deep-purple">mdi-cloud-upload</v-icon>
|
||||
<v-icon size="48" color="primary">mdi-cloud-upload</v-icon>
|
||||
<span class="drop-text">{{ tm('input.dropToUpload') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,7 +41,7 @@
|
||||
<!-- Settings Menu -->
|
||||
<StyledMenu offset="8" location="top start" :close-on-content-click="false">
|
||||
<template v-slot:activator="{ props: activatorProps }">
|
||||
<v-btn v-bind="activatorProps" icon="mdi-plus" variant="text" color="deep-purple" />
|
||||
<v-btn v-bind="activatorProps" icon="mdi-plus" variant="text" color="primary" />
|
||||
</template>
|
||||
|
||||
<!-- Upload Files -->
|
||||
@@ -87,7 +87,7 @@
|
||||
{{ tm('voice.liveMode') }}
|
||||
</v-tooltip>
|
||||
</v-btn> -->
|
||||
<v-btn @click="handleRecordClick" icon variant="text" :color="isRecording ? 'error' : 'deep-purple'"
|
||||
<v-btn @click="handleRecordClick" icon variant="text" :color="isRecording ? 'error' : 'primary'"
|
||||
class="record-btn">
|
||||
<v-icon :icon="isRecording ? 'mdi-stop-circle' : 'mdi-microphone'" variant="text"
|
||||
plain></v-icon>
|
||||
@@ -95,13 +95,13 @@
|
||||
{{ isRecording ? tm('voice.speaking') : tm('voice.startRecording') }}
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn icon v-if="isRunning" @click="$emit('stop')" variant="tonal" color="deep-purple" class="send-btn">
|
||||
<v-btn icon v-if="isRunning" @click="$emit('stop')" variant="tonal" color="primary" class="send-btn">
|
||||
<v-icon icon="mdi-stop" variant="text" plain></v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
{{ tm('input.stopGenerating') }}
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn v-else @click="$emit('send')" icon="mdi-send" variant="tonal" color="deep-purple"
|
||||
<v-btn v-else @click="$emit('send')" icon="mdi-send" variant="tonal" color="primary"
|
||||
:disabled="!canSend" class="send-btn" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,7 +117,7 @@
|
||||
</div>
|
||||
|
||||
<div v-if="stagedAudioUrl" class="audio-preview">
|
||||
<v-chip color="deep-purple-lighten-4" class="audio-chip">
|
||||
<v-chip color="primary" variant="tonal" class="audio-chip">
|
||||
<v-icon start icon="mdi-microphone" size="small"></v-icon>
|
||||
{{ tm('voice.recording') }}
|
||||
</v-chip>
|
||||
@@ -126,7 +126,7 @@
|
||||
</div>
|
||||
|
||||
<div v-for="(file, index) in stagedFiles" :key="'file-' + index" class="file-preview">
|
||||
<v-chip color="blue-grey-lighten-4" class="file-chip">
|
||||
<v-chip color="primary" variant="tonal" class="file-chip">
|
||||
<v-icon start icon="mdi-file-document-outline" size="small"></v-icon>
|
||||
<span class="file-name-preview">{{ file.original_name }}</span>
|
||||
</v-chip>
|
||||
@@ -173,6 +173,7 @@ interface Props {
|
||||
currentSession?: Session | null;
|
||||
configId?: string | null;
|
||||
replyTo?: ReplyInfo | null;
|
||||
sendShortcut?: 'enter' | 'shift_enter';
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -180,7 +181,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
currentSession: null,
|
||||
configId: null,
|
||||
stagedFiles: () => [],
|
||||
replyTo: null
|
||||
replyTo: null,
|
||||
sendShortcut: 'shift_enter'
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -253,9 +255,29 @@ watch(localPrompt, () => {
|
||||
});
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
// Enter 插入换行(桌面和手机端均如此,发送通过右下角发送按鈕)
|
||||
// Shift+Enter 发送(Ctrl+Enter / Cmd+Enter 也保留)
|
||||
if (e.keyCode === 13 && (e.shiftKey || e.ctrlKey || e.metaKey)) {
|
||||
const isEnter = e.key === 'Enter';
|
||||
if (!isEnter) {
|
||||
// Ctrl+B 录音
|
||||
if (e.ctrlKey && e.keyCode === 66) {
|
||||
e.preventDefault();
|
||||
if (ctrlKeyDown.value) return;
|
||||
|
||||
ctrlKeyDown.value = true;
|
||||
ctrlKeyTimer.value = window.setTimeout(() => {
|
||||
if (ctrlKeyDown.value && !props.isRecording) {
|
||||
emit('startRecording');
|
||||
}
|
||||
}, ctrlKeyLongPressThreshold);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const isSendHotkey =
|
||||
e.ctrlKey ||
|
||||
e.metaKey ||
|
||||
(props.sendShortcut === 'enter' ? !e.shiftKey : e.shiftKey);
|
||||
|
||||
if (isSendHotkey) {
|
||||
e.preventDefault();
|
||||
if (localPrompt.value.trim() === '/astr_live_dev') {
|
||||
emit('openLiveMode');
|
||||
@@ -267,19 +289,6 @@ function handleKeyDown(e: KeyboardEvent) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+B 录音
|
||||
if (e.ctrlKey && e.keyCode === 66) {
|
||||
e.preventDefault();
|
||||
if (ctrlKeyDown.value) return;
|
||||
|
||||
ctrlKeyDown.value = true;
|
||||
ctrlKeyTimer.value = window.setTimeout(() => {
|
||||
if (ctrlKeyDown.value && !props.isRecording) {
|
||||
emit('startRecording');
|
||||
}
|
||||
}, ctrlKeyLongPressThreshold);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyUp(e: KeyboardEvent) {
|
||||
@@ -399,8 +408,8 @@ defineExpose({
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(103, 58, 183, 0.15);
|
||||
border: 2px dashed rgba(103, 58, 183, 0.5);
|
||||
background-color: rgba(var(--v-theme-primary), 0.12);
|
||||
border: 2px dashed rgba(var(--v-theme-primary), 0.45);
|
||||
border-radius: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -419,7 +428,7 @@ defineExpose({
|
||||
.drop-text {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #673ab7;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
/* Fade transition for drop overlay */
|
||||
@@ -439,7 +448,7 @@ defineExpose({
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
margin: 8px 8px 0 8px;
|
||||
background-color: rgba(103, 58, 183, 0.06);
|
||||
background-color: rgba(var(--v-theme-primary), 0.06);
|
||||
border-radius: 12px;
|
||||
gap: 8px;
|
||||
max-height: 500px;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
'mobile-sidebar-open': isMobile && mobileMenuOpen,
|
||||
'mobile-sidebar': isMobile
|
||||
}"
|
||||
:style="{ 'background-color': isDark ? sidebarCollapsed ? '#1e1e1e' : '#2d2d2d' : sidebarCollapsed ? '#ffffff' : '#f1f4f9' }">
|
||||
:style="{ backgroundColor: sidebarCollapsed && !isMobile ? 'rgb(var(--v-theme-surface))' : 'rgb(var(--v-theme-mcpCardBg))' }">
|
||||
|
||||
<div class="sidebar-collapse-btn-container" v-if="!isMobile">
|
||||
<v-btn icon class="sidebar-collapse-btn" @click="toggleSidebar" variant="text" color="deep-purple">
|
||||
@@ -21,12 +21,31 @@
|
||||
</div>
|
||||
|
||||
<div style="padding: 8px; opacity: 0.6;">
|
||||
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
|
||||
v-if="!sidebarCollapsed || isMobile" prepend-icon="mdi-square-edit-outline">{{ tm('actions.newChat') }}</v-btn>
|
||||
<v-btn icon="mdi-square-edit-outline" rounded="xl" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
|
||||
<div class="new-chat-row" v-if="!sidebarCollapsed || isMobile">
|
||||
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
|
||||
prepend-icon="mdi-square-edit-outline">{{ tm('actions.newChat') }}</v-btn>
|
||||
<v-btn v-if="sessions.length > 0" icon size="small" variant="text" @click="toggleBatchMode"
|
||||
:color="batchMode ? 'primary' : undefined">
|
||||
<v-icon>mdi-checkbox-multiple-marked-outline</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
<v-btn icon="mdi-square-edit-outline" rounded="xl" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
|
||||
v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn>
|
||||
</div>
|
||||
|
||||
<!-- Batch action bar -->
|
||||
<div v-if="batchMode && (!sidebarCollapsed || isMobile)" class="batch-action-bar">
|
||||
<v-btn size="x-small" variant="text" @click="toggleSelectAll">
|
||||
{{ isAllSelected ? tm('batch.deselectAll') : tm('batch.selectAll') }}
|
||||
</v-btn>
|
||||
<span class="batch-selected-count">{{ tm('batch.selected', { count: batchSelected.length }) }}</span>
|
||||
<v-spacer />
|
||||
<v-btn size="x-small" variant="text" color="error" :disabled="batchSelected.length === 0"
|
||||
@click="handleBatchDelete">
|
||||
{{ tm('batch.delete') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- 项目列表组件 -->
|
||||
<ProjectList
|
||||
v-if="!sidebarCollapsed || isMobile"
|
||||
@@ -41,19 +60,34 @@
|
||||
v-if="!sidebarCollapsed || isMobile">
|
||||
<v-card v-if="sessions.length > 0" flat style="background-color: transparent;">
|
||||
<v-list density="compact" nav class="conversation-list"
|
||||
style="background-color: transparent;" :selected="selectedSessions"
|
||||
@update:selected="$emit('selectConversation', $event)">
|
||||
style="background-color: transparent;" :selected="batchMode ? [] : selectedSessions"
|
||||
@update:selected="handleListSelect">
|
||||
<v-list-item v-for="item in sessions" :key="item.session_id" :value="item.session_id"
|
||||
rounded="lg" class="conversation-item" active-color="secondary">
|
||||
rounded="lg" class="conversation-item" active-color="secondary"
|
||||
@click="batchMode ? toggleBatchItem(item.session_id) : undefined">
|
||||
|
||||
<template v-slot:prepend>
|
||||
<div class="batch-checkbox-slot" :class="{ 'batch-checkbox-slot--active': batchMode }">
|
||||
<v-checkbox-btn
|
||||
:model-value="batchSelected.includes(item.session_id)"
|
||||
@update:model-value="toggleBatchItem(item.session_id)"
|
||||
@click.stop
|
||||
density="compact"
|
||||
hide-details
|
||||
class="batch-checkbox"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<v-list-item-title v-if="!sidebarCollapsed || isMobile" class="conversation-title"
|
||||
:style="{ color: isDark ? '#ffffff' : '#000000' }">
|
||||
:style="{ color: 'rgb(var(--v-theme-primaryText))' }">
|
||||
{{ item.display_name || tm('conversation.newConversation') }}
|
||||
</v-list-item-title>
|
||||
<!-- <v-list-item-subtitle v-if="!sidebarCollapsed || isMobile" class="timestamp">
|
||||
{{ new Date(item.updated_at).toLocaleString() }}
|
||||
</v-list-item-subtitle> -->
|
||||
|
||||
<template v-if="!sidebarCollapsed || isMobile" v-slot:append>
|
||||
<template v-if="!batchMode && (!sidebarCollapsed || isMobile)" v-slot:append>
|
||||
<div class="conversation-actions">
|
||||
<v-btn icon="mdi-pencil" size="x-small" variant="text"
|
||||
class="edit-title-btn"
|
||||
@@ -98,16 +132,52 @@
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<!-- 语言切换 -->
|
||||
<v-list-item class="styled-menu-item">
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-translate</v-icon>
|
||||
<!-- 语言切换(分组) -->
|
||||
<v-menu
|
||||
:open-on-hover="!isMobile"
|
||||
:open-on-click="isMobile"
|
||||
:open-delay="!isMobile ? 60 : 0"
|
||||
:close-delay="!isMobile ? 120 : 0"
|
||||
:location="isMobile ? 'bottom' : 'end center'"
|
||||
offset="8"
|
||||
close-on-content-click
|
||||
>
|
||||
<template v-slot:activator="{ props: languageMenuProps }">
|
||||
<v-list-item
|
||||
v-bind="languageMenuProps"
|
||||
class="styled-menu-item chat-settings-group-trigger"
|
||||
rounded="md"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-translate</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ t('core.common.language') }}</v-list-item-title>
|
||||
<template v-slot:append>
|
||||
<span class="chat-settings-group-current">{{ currentLanguage?.flag }}</span>
|
||||
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
<v-list-item-title>{{ t('core.common.language') }}</v-list-item-title>
|
||||
<template v-slot:append>
|
||||
<LanguageSwitcher variant="chatbox" />
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<v-card class="styled-menu-card" style="min-width: 180px;" elevation="8" rounded="lg">
|
||||
<v-list density="compact" class="styled-menu-list pa-1">
|
||||
<v-list-item
|
||||
v-for="lang in languages"
|
||||
:key="lang.code"
|
||||
:value="lang.code"
|
||||
@click="changeLanguage(lang.code)"
|
||||
:class="{ 'styled-menu-item-active': currentLocale === lang.code }"
|
||||
class="styled-menu-item"
|
||||
rounded="md"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<span class="language-flag">{{ lang.flag }}</span>
|
||||
</template>
|
||||
<v-list-item-title>{{ lang.name }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
|
||||
<!-- 主题切换 -->
|
||||
<v-list-item class="styled-menu-item" @click="$emit('toggleTheme')">
|
||||
@@ -117,26 +187,93 @@
|
||||
<v-list-item-title>{{ isDark ? tm('modes.lightMode') : tm('modes.darkMode') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<!-- 通信传输模式 -->
|
||||
<v-list-item class="styled-menu-item">
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-lan-connect</v-icon>
|
||||
<!-- 通信传输模式(分组) -->
|
||||
<v-menu
|
||||
:open-on-hover="!isMobile"
|
||||
:open-on-click="isMobile"
|
||||
:open-delay="!isMobile ? 60 : 0"
|
||||
:close-delay="!isMobile ? 120 : 0"
|
||||
:location="isMobile ? 'bottom' : 'end center'"
|
||||
offset="8"
|
||||
close-on-content-click
|
||||
>
|
||||
<template v-slot:activator="{ props: transportMenuProps }">
|
||||
<v-list-item
|
||||
v-bind="transportMenuProps"
|
||||
class="styled-menu-item chat-settings-group-trigger"
|
||||
rounded="md"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-lan-connect</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ tm('transport.title') }}</v-list-item-title>
|
||||
<template v-slot:append>
|
||||
<span class="chat-settings-group-current chat-settings-transport-current">{{ currentTransportLabel }}</span>
|
||||
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
<v-list-item-title>{{ tm('transport.title') }}</v-list-item-title>
|
||||
<template v-slot:append>
|
||||
<v-select
|
||||
:model-value="transportMode"
|
||||
:items="transportOptions"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
density="compact"
|
||||
variant="underlined"
|
||||
hide-details
|
||||
class="transport-mode-select"
|
||||
@update:model-value="handleTransportModeChange"
|
||||
/>
|
||||
|
||||
<v-card class="styled-menu-card" style="min-width: 220px;" elevation="8" rounded="lg">
|
||||
<v-list density="compact" class="styled-menu-list pa-1">
|
||||
<v-list-item
|
||||
v-for="opt in transportOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
@click="handleTransportModeChange(opt.value)"
|
||||
:class="{ 'styled-menu-item-active': transportMode === opt.value }"
|
||||
class="styled-menu-item"
|
||||
rounded="md"
|
||||
>
|
||||
<v-list-item-title>{{ opt.label }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
|
||||
<!-- 发送快捷键(分组) -->
|
||||
<v-menu
|
||||
:open-on-hover="!isMobile"
|
||||
:open-on-click="isMobile"
|
||||
:open-delay="!isMobile ? 60 : 0"
|
||||
:close-delay="!isMobile ? 120 : 0"
|
||||
:location="isMobile ? 'bottom' : 'end center'"
|
||||
offset="8"
|
||||
close-on-content-click
|
||||
>
|
||||
<template v-slot:activator="{ props: sendShortcutMenuProps }">
|
||||
<v-list-item
|
||||
v-bind="sendShortcutMenuProps"
|
||||
class="styled-menu-item chat-settings-group-trigger"
|
||||
rounded="md"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-keyboard-outline</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ tm('shortcuts.sendKey.title') }}</v-list-item-title>
|
||||
<template v-slot:append>
|
||||
<span class="chat-settings-group-current chat-settings-transport-current">{{ currentSendShortcutLabel }}</span>
|
||||
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<v-card class="styled-menu-card" style="min-width: 220px;" elevation="8" rounded="lg">
|
||||
<v-list density="compact" class="styled-menu-list pa-1">
|
||||
<v-list-item
|
||||
v-for="opt in sendShortcutOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
@click="handleSendShortcutChange(opt.value)"
|
||||
:class="{ 'styled-menu-item-active': props.sendShortcut === opt.value }"
|
||||
class="styled-menu-item"
|
||||
rounded="md"
|
||||
>
|
||||
<v-list-item-title>{{ opt.label }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
|
||||
<!-- 全屏/退出全屏 -->
|
||||
<v-list-item class="styled-menu-item" @click="$emit('toggleFullscreen')">
|
||||
@@ -162,15 +299,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||
import type { Session } from '@/composables/useSessions';
|
||||
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog';
|
||||
import LanguageSwitcher from '@/components/shared/LanguageSwitcher.vue';
|
||||
import StyledMenu from '@/components/shared/StyledMenu.vue';
|
||||
import ProviderConfigDialog from '@/components/chat/ProviderConfigDialog.vue';
|
||||
import ProjectList from '@/components/chat/ProjectList.vue';
|
||||
import type { Project } from '@/components/chat/ProjectList.vue';
|
||||
import { useLanguageSwitcher } from '@/i18n/composables';
|
||||
import type { Locale } from '@/i18n/types';
|
||||
|
||||
interface Props {
|
||||
sessions: Session[];
|
||||
@@ -183,6 +321,7 @@ interface Props {
|
||||
isMobile: boolean;
|
||||
mobileMenuOpen: boolean;
|
||||
projects?: Project[];
|
||||
sendShortcut: 'enter' | 'shift_enter';
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -194,6 +333,7 @@ const emit = defineEmits<{
|
||||
selectConversation: [sessionIds: string[]];
|
||||
editTitle: [sessionId: string, title: string];
|
||||
deleteConversation: [sessionId: string];
|
||||
batchDeleteConversations: [sessionIds: string[]];
|
||||
closeMobileSidebar: [];
|
||||
toggleTheme: [];
|
||||
toggleFullscreen: [];
|
||||
@@ -202,6 +342,7 @@ const emit = defineEmits<{
|
||||
editProject: [project: Project];
|
||||
deleteProject: [projectId: string];
|
||||
updateTransportMode: [mode: 'sse' | 'websocket'];
|
||||
updateSendShortcut: [mode: 'enter' | 'shift_enter'];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -211,10 +352,84 @@ const confirmDialog = useConfirmDialog();
|
||||
|
||||
const sidebarCollapsed = ref(true);
|
||||
const showProviderConfigDialog = ref(false);
|
||||
|
||||
// Batch mode state
|
||||
const batchMode = ref(false);
|
||||
const batchSelected = ref<string[]>([]);
|
||||
|
||||
const isAllSelected = computed(() =>
|
||||
props.sessions.length > 0 && batchSelected.value.length === props.sessions.length
|
||||
);
|
||||
|
||||
function toggleBatchMode() {
|
||||
batchMode.value = !batchMode.value;
|
||||
batchSelected.value = [];
|
||||
}
|
||||
|
||||
function toggleBatchItem(sessionId: string) {
|
||||
const idx = batchSelected.value.indexOf(sessionId);
|
||||
if (idx >= 0) {
|
||||
batchSelected.value.splice(idx, 1);
|
||||
} else {
|
||||
batchSelected.value.push(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (isAllSelected.value) {
|
||||
batchSelected.value = [];
|
||||
} else {
|
||||
batchSelected.value = props.sessions.map(s => s.session_id);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
const count = batchSelected.value.length;
|
||||
if (count === 0) return;
|
||||
const message = tm('batch.confirmDelete', { count });
|
||||
if (await askForConfirmation(message, confirmDialog)) {
|
||||
emit('batchDeleteConversations', [...batchSelected.value]);
|
||||
batchSelected.value = [];
|
||||
batchMode.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleListSelect(sessionIds: string[]) {
|
||||
if (!batchMode.value) {
|
||||
emit('selectConversation', sessionIds);
|
||||
}
|
||||
}
|
||||
const transportOptions = [
|
||||
{ label: tm('transport.sse'), value: 'sse' as const },
|
||||
{ label: tm('transport.websocket'), value: 'websocket' as const }
|
||||
];
|
||||
const sendShortcutOptions = [
|
||||
{ label: tm('shortcuts.sendKey.enterToSend'), value: 'enter' as const },
|
||||
{ label: tm('shortcuts.sendKey.shiftEnterToSend'), value: 'shift_enter' as const }
|
||||
];
|
||||
|
||||
// Language switcher
|
||||
const { languageOptions, currentLanguage, switchLanguage, locale } = useLanguageSwitcher();
|
||||
const languages = computed(() =>
|
||||
languageOptions.value.map(lang => ({
|
||||
code: lang.value,
|
||||
name: lang.label,
|
||||
flag: lang.flag
|
||||
}))
|
||||
);
|
||||
const currentLocale = computed(() => locale.value);
|
||||
const changeLanguage = async (langCode: string) => {
|
||||
await switchLanguage(langCode as Locale);
|
||||
};
|
||||
|
||||
const currentTransportLabel = computed(() => {
|
||||
const found = transportOptions.find(opt => opt.value === props.transportMode);
|
||||
return found?.label ?? '';
|
||||
});
|
||||
const currentSendShortcutLabel = computed(() => {
|
||||
const found = sendShortcutOptions.find(opt => opt.value === props.sendShortcut);
|
||||
return found?.label ?? '';
|
||||
});
|
||||
|
||||
// 从 localStorage 读取侧边栏折叠状态
|
||||
const savedCollapsedState = localStorage.getItem('sidebarCollapsed');
|
||||
@@ -242,6 +457,12 @@ function handleTransportModeChange(mode: string | null) {
|
||||
emit('updateTransportMode', mode);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSendShortcutChange(mode: string | null) {
|
||||
if (mode === 'enter' || mode === 'shift_enter') {
|
||||
emit('updateSendShortcut', mode);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -310,7 +531,7 @@ function handleTransportModeChange(mode: string | null) {
|
||||
}
|
||||
|
||||
.conversation-item:hover {
|
||||
background-color: rgba(103, 58, 183, 0.05);
|
||||
background-color: rgba(var(--v-theme-primary), 0.05);
|
||||
}
|
||||
|
||||
.conversation-item:hover .conversation-actions {
|
||||
@@ -402,7 +623,74 @@ function handleTransportModeChange(mode: string | null) {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.transport-mode-select {
|
||||
min-width: 120px;
|
||||
.chat-settings-group-trigger :deep(.v-list-item__append) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-settings-group-current {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.chat-settings-transport-current {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chat-settings-group-arrow {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.language-flag {
|
||||
font-size: 16px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.new-chat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.new-chat-row .new-chat-btn {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.batch-action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.batch-selected-count {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.batch-checkbox {
|
||||
flex: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.batch-checkbox-slot {
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
transform: translateX(-8px);
|
||||
transition: width 0.2s ease, opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.batch-checkbox-slot--active {
|
||||
width: 28px;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateX(0);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<v-chip v-if="domain" class="ref-chip" size="x-small" variant="flat"
|
||||
:style="{ backgroundColor: isDark ? '#303030' : '#f4f4f4', color: isDark ? '#999' : '#666' }" :href="url"
|
||||
:style="chipStyle" :href="url"
|
||||
target="_blank" clickable>
|
||||
<v-icon start size="x-small" color>mdi-link-variant</v-icon>
|
||||
<span>{{ domain }}</span>
|
||||
|
||||
</v-chip>
|
||||
<span v-else class="ref-fallback" :style="{ color: isDark ? '#999' : '#666' }">{{ 'site' }}</span>
|
||||
<span v-else class="ref-fallback" :style="fallbackStyle">{{ 'site' }}</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -46,6 +46,15 @@ const domain = computed(() => {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
const chipStyle = computed(() => ({
|
||||
backgroundColor: isDark ? 'rgba(var(--v-theme-on-surface), 0.08)' : 'rgba(var(--v-theme-on-surface), 0.04)',
|
||||
color: isDark ? 'rgba(var(--v-theme-on-surface), 0.62)' : 'rgba(var(--v-theme-on-surface), 0.72)'
|
||||
}))
|
||||
|
||||
const fallbackStyle = computed(() => ({
|
||||
color: isDark ? 'rgba(var(--v-theme-on-surface), 0.62)' : 'rgba(var(--v-theme-on-surface), 0.72)'
|
||||
}))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
>
|
||||
<v-icon
|
||||
size="18"
|
||||
:color="props.variant === 'default' ? (useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa') : undefined"
|
||||
:color="props.variant === 'default' ? 'rgb(var(--v-theme-primary))' : undefined"
|
||||
>
|
||||
mdi-translate
|
||||
</v-icon>
|
||||
@@ -42,7 +42,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n, useLanguageSwitcher } from '@/i18n/composables'
|
||||
import { useCustomizerStore } from '@/stores/customizer'
|
||||
import type { Locale } from '@/i18n/types'
|
||||
import StyledMenu from '@/components/shared/StyledMenu.vue'
|
||||
|
||||
@@ -90,7 +89,7 @@ const changeLanguage = async (langCode: string) => {
|
||||
|
||||
.language-switcher--default:hover {
|
||||
transform: scale(1.05);
|
||||
background: rgba(94, 53, 177, 0.08) !important;
|
||||
background: rgba(var(--v-theme-primary), 0.08) !important;
|
||||
}
|
||||
|
||||
/* Header变体样式 - 完全继承Vuetify和action-btn的默认样式 */
|
||||
@@ -103,8 +102,4 @@ const changeLanguage = async (langCode: string) => {
|
||||
/* 继承action-btn样式,与工具栏主题按钮保持一致 */
|
||||
}
|
||||
|
||||
/* 深色模式下的悬停效果(仅对default变体) */
|
||||
:deep(.v-theme--PurpleThemeDark) .language-switcher--default:hover {
|
||||
background: rgba(114, 46, 209, 0.12) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -6,11 +6,11 @@
|
||||
</div>
|
||||
<div class="logo-text">
|
||||
<h2
|
||||
:style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa'}"
|
||||
:style="{ color: 'rgb(var(--v-theme-primary))' }"
|
||||
v-html="formatTitle(title || t('core.header.logoTitle'))"
|
||||
></h2>
|
||||
<!-- 父子组件传递css变量可能会出错,暂时使用十六进制颜色值 -->
|
||||
<h4 :style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#000000aa' : '#ffffffcc'}"
|
||||
<h4 :style="{ color: 'rgba(var(--v-theme-on-surface), 0.72)' }"
|
||||
class="hint-text">{{ subtitle || t('core.header.accountDialog.title') }}</h4>
|
||||
</div>
|
||||
</div>
|
||||
@@ -18,7 +18,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useCustomizerStore } from "@/stores/customizer";
|
||||
import { useI18n } from '@/i18n/composables';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -24,12 +24,12 @@ withDefaults(defineProps<{
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style>
|
||||
.styled-menu-card {
|
||||
min-width: 100px;
|
||||
width: fit-content;
|
||||
border: 1px solid rgba(94, 53, 177, 0.15) !important;
|
||||
background: #f8f6fc !important;
|
||||
border: 1px solid rgba(var(--v-theme-primary), 0.15) !important;
|
||||
background: rgba(var(--v-theme-surface), 0.98) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
@@ -37,43 +37,41 @@ withDefaults(defineProps<{
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
:deep(.styled-menu-item) {
|
||||
.styled-menu-item {
|
||||
margin: 2px 0;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
:deep(.styled-menu-item:hover) {
|
||||
background: rgba(94, 53, 177, 0.08) !important;
|
||||
.styled-menu-item:hover {
|
||||
background: rgba(var(--v-theme-primary), 0.08) !important;
|
||||
}
|
||||
|
||||
:deep(.styled-menu-item-active) {
|
||||
background: rgba(94, 53, 177, 0.15) !important;
|
||||
.styled-menu-item-active {
|
||||
background: rgba(var(--v-theme-primary), 0.15) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.styled-menu-item-active:hover) {
|
||||
background: rgba(94, 53, 177, 0.2) !important;
|
||||
.styled-menu-item-active:hover {
|
||||
background: rgba(var(--v-theme-primary), 0.2) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 深色模式下的下拉框样式 - 需要全局样式才能检测主题 */
|
||||
.v-theme--PurpleThemeDark .styled-menu-card {
|
||||
background: #2a2733 !important;
|
||||
border: 1px solid rgba(110, 60, 180, 0.692) !important;
|
||||
background: rgba(var(--v-theme-surface), 0.98) !important;
|
||||
border: 1px solid rgba(var(--v-theme-primary), 0.2) !important;
|
||||
}
|
||||
|
||||
/* 深色模式下的列表项悬停效果 */
|
||||
.v-theme--PurpleThemeDark .styled-menu-item:hover {
|
||||
background: rgba(114, 46, 209, 0.12) !important;
|
||||
background: rgba(var(--v-theme-primary), 0.12) !important;
|
||||
}
|
||||
|
||||
.v-theme--PurpleThemeDark .styled-menu-item-active {
|
||||
background: rgba(114, 46, 209, 0.2) !important;
|
||||
background: rgba(var(--v-theme-primary), 0.2) !important;
|
||||
}
|
||||
|
||||
.v-theme--PurpleThemeDark .styled-menu-item-active:hover {
|
||||
background: rgba(114, 46, 209, 0.25) !important;
|
||||
background: rgba(var(--v-theme-primary), 0.25) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -590,9 +590,11 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
|
||||
async function testProvider(provider: any) {
|
||||
testingProviders.value.push(provider.id)
|
||||
try {
|
||||
const startTime = performance.now()
|
||||
const response = await axios.get('/api/config/provider/check_one', { params: { id: provider.id } })
|
||||
if (response.data.status === 'ok' && response.data.data.error === null) {
|
||||
showMessage(tm('models.testSuccess', { id: provider.id }))
|
||||
const latency = Math.max(0, Math.round(performance.now() - startTime))
|
||||
showMessage(tm('models.testSuccessWithLatency', { id: provider.id, latency }))
|
||||
} else {
|
||||
throw new Error(response.data.data.error || tm('models.testError'))
|
||||
}
|
||||
|
||||
@@ -109,6 +109,73 @@ export function useSessions(chatboxMode: boolean = false) {
|
||||
}
|
||||
}
|
||||
|
||||
interface BatchDeleteFailedItem {
|
||||
session_id: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface BatchDeleteResult {
|
||||
deleted_count: number;
|
||||
failed_count: number;
|
||||
failed_items: BatchDeleteFailedItem[];
|
||||
currentSessionDeleted: boolean;
|
||||
}
|
||||
|
||||
function isBatchDeleteResponseData(data: unknown): data is {
|
||||
deleted_count: number;
|
||||
failed_count: number;
|
||||
failed_items: BatchDeleteFailedItem[];
|
||||
} {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const payload = data as Record<string, unknown>;
|
||||
return (
|
||||
typeof payload.deleted_count === 'number' &&
|
||||
typeof payload.failed_count === 'number' &&
|
||||
Array.isArray(payload.failed_items)
|
||||
);
|
||||
}
|
||||
|
||||
async function batchDeleteSessions(sessionIds: string[]): Promise<BatchDeleteResult> {
|
||||
try {
|
||||
const currentSessionId = currSessionId.value;
|
||||
const response = await axios.post('/api/chat/batch_delete_sessions', { session_ids: sessionIds });
|
||||
if (response.data?.status !== 'ok') {
|
||||
throw new Error(response.data?.message || 'Failed to batch delete sessions');
|
||||
}
|
||||
|
||||
const data = response.data?.data;
|
||||
if (!isBatchDeleteResponseData(data)) {
|
||||
throw new Error('Invalid batch delete response payload');
|
||||
}
|
||||
|
||||
const failedItems = data.failed_items;
|
||||
const failedSessionIds = new Set(failedItems.map(item => item.session_id));
|
||||
const currentSessionDeleted = Boolean(
|
||||
currentSessionId &&
|
||||
sessionIds.includes(currentSessionId) &&
|
||||
!failedSessionIds.has(currentSessionId)
|
||||
);
|
||||
|
||||
if (currentSessionDeleted) {
|
||||
currSessionId.value = '';
|
||||
selectedSessions.value = [];
|
||||
}
|
||||
await getSessions();
|
||||
|
||||
return {
|
||||
deleted_count: data.deleted_count,
|
||||
failed_count: data.failed_count,
|
||||
failed_items: failedItems,
|
||||
currentSessionDeleted,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function showEditTitleDialog(sessionId: string, title: string) {
|
||||
editingSessionId.value = sessionId;
|
||||
editingTitle.value = title || '';
|
||||
@@ -167,6 +234,7 @@ export function useSessions(chatboxMode: boolean = false) {
|
||||
getSessions,
|
||||
newSession,
|
||||
deleteSession,
|
||||
batchDeleteSessions,
|
||||
showEditTitleDialog,
|
||||
saveTitle,
|
||||
updateSessionTitle,
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
"save": "Save",
|
||||
"livePreview": "Live Preview (may differ)",
|
||||
"refreshPreview": "Refresh Preview",
|
||||
"previewText": "This is a sample text used to preview the template output.\n\nIt can contain multiple lines and various formatting.",
|
||||
"syntaxHint": "Supports jinja2 syntax. Available variables: text | safe (text to render), version (AstrBot version)",
|
||||
"saveAndApply": "Save and Apply Current Template",
|
||||
"confirmReset": "Confirm Reset",
|
||||
|
||||
@@ -71,10 +71,16 @@
|
||||
"modes": {
|
||||
"darkMode": "Switch to Dark Mode",
|
||||
"lightMode": "Switch to Light Mode"
|
||||
}, "shortcuts": {
|
||||
},
|
||||
"shortcuts": {
|
||||
"help": "Get Help",
|
||||
"voiceRecord": "Record Voice",
|
||||
"pasteImage": "Paste Image"
|
||||
"pasteImage": "Paste Image",
|
||||
"sendKey": {
|
||||
"title": "Send Shortcut",
|
||||
"enterToSend": "Enter to send",
|
||||
"shiftEnterToSend": "Shift+Enter to send"
|
||||
}
|
||||
},
|
||||
"streaming": {
|
||||
"enabled": "Streaming enabled",
|
||||
@@ -141,5 +147,15 @@
|
||||
"errors": {
|
||||
"sendMessageFailed": "Failed to send message, please try again",
|
||||
"createSessionFailed": "Failed to create session, please refresh the page"
|
||||
},
|
||||
"batch": {
|
||||
"selected": "{count} selected",
|
||||
"confirmDelete": "Are you sure you want to delete {count} conversation(s)? This action cannot be undone.",
|
||||
"selectAll": "Select All",
|
||||
"deselectAll": "Deselect All",
|
||||
"delete": "Delete",
|
||||
"exit": "Exit",
|
||||
"partialFailure": "{failed} of {total} conversations failed to delete",
|
||||
"requestFailed": "Failed to delete conversations. Please try again."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@
|
||||
"deleteSuccess": "Model deleted successfully",
|
||||
"deleteError": "Failed to delete model",
|
||||
"testSuccess": "Model {id} test passed",
|
||||
"testSuccessWithLatency": "Model {id} test passed, latency {latency} ms",
|
||||
"testError": "Model test failed",
|
||||
"searchPlaceholder": "Search models or ID",
|
||||
"manualAddButton": "Custom Model",
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
"save": "Сохранить",
|
||||
"livePreview": "Предпросмотр (может отличаться)",
|
||||
"refreshPreview": "Обновить",
|
||||
"previewText": "Это пример текста для предпросмотра результата шаблона.\n\nОн может содержать несколько строк и различные форматы.",
|
||||
"syntaxHint": "Поддерживается синтаксис jinja2. Переменные: text | safe (текст для рендеринга), version (версия AstrBot)",
|
||||
"saveAndApply": "Сохранить и применить текущий шаблон",
|
||||
"confirmReset": "Подтверждение сброса",
|
||||
|
||||
@@ -75,7 +75,12 @@
|
||||
"shortcuts": {
|
||||
"help": "Справка",
|
||||
"voiceRecord": "Запись голоса",
|
||||
"pasteImage": "Вставить изображение"
|
||||
"pasteImage": "Вставить изображение",
|
||||
"sendKey": {
|
||||
"title": "Клавиша отправки",
|
||||
"enterToSend": "Enter для отправки",
|
||||
"shiftEnterToSend": "Shift+Enter для отправки"
|
||||
}
|
||||
},
|
||||
"streaming": {
|
||||
"enabled": "Потоковый ответ включен",
|
||||
@@ -143,4 +148,4 @@
|
||||
"sendMessageFailed": "Ошибка отправки сообщения, попробуйте еще раз",
|
||||
"createSessionFailed": "Ошибка создания сессии, обновите страницу"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
"save": "保存",
|
||||
"livePreview": "实时预览(可能有差异)",
|
||||
"refreshPreview": "刷新预览",
|
||||
"previewText": "这是一个示例文本,用于预览模板效果。\n\n这里可以包含多行文本,支持换行和各种格式。",
|
||||
"syntaxHint": "支持 jinja2 语法。可用变量:text | safe(要渲染的文本), version(AstrBot 版本)",
|
||||
"saveAndApply": "保存应用当前编辑模板",
|
||||
"confirmReset": "确认重置",
|
||||
|
||||
@@ -71,10 +71,16 @@
|
||||
"modes": {
|
||||
"darkMode": "切换到夜间模式",
|
||||
"lightMode": "切换到日间模式"
|
||||
}, "shortcuts": {
|
||||
},
|
||||
"shortcuts": {
|
||||
"help": "获取帮助",
|
||||
"voiceRecord": "录制语音",
|
||||
"pasteImage": "粘贴图片"
|
||||
"pasteImage": "粘贴图片",
|
||||
"sendKey": {
|
||||
"title": "发送快捷键",
|
||||
"enterToSend": "Enter 发送",
|
||||
"shiftEnterToSend": "Shift+Enter 发送"
|
||||
}
|
||||
},
|
||||
"streaming": {
|
||||
"enabled": "流式响应已开启",
|
||||
@@ -141,5 +147,15 @@
|
||||
"errors": {
|
||||
"sendMessageFailed": "发送消息失败,请重试",
|
||||
"createSessionFailed": "创建会话失败,请刷新页面重试"
|
||||
},
|
||||
"batch": {
|
||||
"selected": "已选择 {count} 个",
|
||||
"confirmDelete": "确定要删除 {count} 个对话吗?此操作无法撤销。",
|
||||
"selectAll": "全选",
|
||||
"deselectAll": "取消全选",
|
||||
"delete": "删除",
|
||||
"exit": "退出",
|
||||
"partialFailure": "{total} 个对话中有 {failed} 个删除失败",
|
||||
"requestFailed": "删除对话失败,请重试。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"deleteSuccess": "模型删除成功",
|
||||
"deleteError": "模型删除失败",
|
||||
"testSuccess": "模型 {id} 测试通过",
|
||||
"testSuccessWithLatency": "模型 {id} 测试通过,延迟 {latency} ms",
|
||||
"testError": "模型测试失败",
|
||||
"searchPlaceholder": "搜索模型或 ID",
|
||||
"manualAddButton": "自定义模型",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -288,7 +288,7 @@ function openChangelogDialog() {
|
||||
:rail="customizer.mini_sidebar"
|
||||
>
|
||||
<div class="sidebar-container">
|
||||
<v-list class="pa-4 listitem flex-grow-1" v-model:opened="openedItems" :open-strategy="'multiple'">
|
||||
<v-list :class="['pa-4', 'listitem', 'flex-grow-1', { 'hidden-scrollbar': customizer.mini_sidebar }]" v-model:opened="openedItems" :open-strategy="'multiple'">
|
||||
<template v-for="(item, i) in sidebarMenu" :key="item.title || item.to || `sidebar-item-${i}`">
|
||||
<NavItem :item="item" class="leftPadding" />
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
/* 自定义滚动条样式 - 紫色主题 */
|
||||
/* 自定义滚动条样式 - 跟随主题 */
|
||||
|
||||
:root {
|
||||
--astrbot-scrollbar-track: rgba(var(--v-theme-primary), 0.08);
|
||||
--astrbot-scrollbar-thumb: rgba(var(--v-theme-primary), 0.72);
|
||||
--astrbot-scrollbar-thumb-hover: rgba(var(--v-theme-primary), 0.84);
|
||||
--astrbot-scrollbar-thumb-active: rgba(var(--v-theme-primary), 0.94);
|
||||
--astrbot-scrollbar-thumb-border: rgba(var(--v-theme-surface), 0.5);
|
||||
--astrbot-scrollbar-thumb-shadow: rgba(var(--v-theme-primary), 0.32);
|
||||
}
|
||||
|
||||
/* 全局滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
@@ -7,52 +16,31 @@
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
background: var(--astrbot-scrollbar-track);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(160, 60, 254, 0.75);
|
||||
background: var(--astrbot-scrollbar-thumb);
|
||||
border-radius: 5px;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid var(--astrbot-scrollbar-thumb-border);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(147, 51, 234, 0.85);
|
||||
background: var(--astrbot-scrollbar-thumb-hover);
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 2px 8px rgba(147, 51, 234, 0.3);
|
||||
box-shadow: 0 2px 8px var(--astrbot-scrollbar-thumb-shadow);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:active {
|
||||
background: rgba(147, 51, 234, 0.95);
|
||||
background: var(--astrbot-scrollbar-thumb-active);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 深色主题滚动条样式 */
|
||||
.v-theme--PurpleThemeDark {
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(192, 132, 252, 0.75);
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(192, 132, 252, 0.85);
|
||||
box-shadow: 0 2px 8px rgba(192, 132, 252, 0.4);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:active {
|
||||
background: rgba(192, 132, 252, 0.95);
|
||||
}
|
||||
}
|
||||
|
||||
/* 细滚动条变体 */
|
||||
.thin-scrollbar {
|
||||
::-webkit-scrollbar {
|
||||
@@ -61,17 +49,11 @@
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(147, 51, 234, 0.75);
|
||||
background: var(--astrbot-scrollbar-thumb);
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.v-theme--PurpleThemeDark .thin-scrollbar {
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(192, 132, 252, 0.75);
|
||||
}
|
||||
}
|
||||
|
||||
/* 聊天区域滚动条 */
|
||||
.chat-scrollbar {
|
||||
::-webkit-scrollbar {
|
||||
@@ -79,33 +61,18 @@
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(147, 51, 234, 0.08);
|
||||
background: var(--astrbot-scrollbar-track);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(147, 51, 234, 0.75);
|
||||
background: var(--astrbot-scrollbar-thumb);
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid var(--astrbot-scrollbar-thumb-border);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(147, 51, 234, 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
.v-theme--PurpleThemeDark .chat-scrollbar {
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(192, 132, 252, 0.08);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(192, 132, 252, 0.75);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(192, 132, 252, 0.85);
|
||||
background: var(--astrbot-scrollbar-thumb-hover);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,11 +90,7 @@
|
||||
/* Firefox 兼容性 */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(147, 51, 234, 0.75) rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.v-theme--PurpleThemeDark * {
|
||||
scrollbar-color: rgba(192, 132, 252, 0.75) rgba(255, 255, 255, 0.05);
|
||||
scrollbar-color: var(--astrbot-scrollbar-thumb) var(--astrbot-scrollbar-track);
|
||||
}
|
||||
|
||||
/* 平滑滚动 */
|
||||
|
||||
@@ -28,27 +28,27 @@
|
||||
.v-list-group__items .v-list-item,
|
||||
.v-list-item {
|
||||
&:hover {
|
||||
color: #b794f6 !important;
|
||||
color: rgb(var(--v-theme-primary)) !important;
|
||||
|
||||
.v-list-item-title {
|
||||
color: #b794f6 !important;
|
||||
color: rgb(var(--v-theme-primary)) !important;
|
||||
}
|
||||
|
||||
.v-icon {
|
||||
color: #b794f6 !important;
|
||||
color: rgb(var(--v-theme-primary)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 选中状态的样式
|
||||
&.v-list-item--active {
|
||||
color: #b794f6 !important;
|
||||
color: rgb(var(--v-theme-primary)) !important;
|
||||
|
||||
.v-list-item-title {
|
||||
color: #b794f6 !important;
|
||||
color: rgb(var(--v-theme-primary)) !important;
|
||||
}
|
||||
|
||||
.v-icon {
|
||||
color: #b794f6 !important;
|
||||
color: rgb(var(--v-theme-primary)) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,9 +56,6 @@
|
||||
.v-list-item--density-default.v-list-item--one-line {
|
||||
min-height: 40px;
|
||||
}
|
||||
.leftPadding {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
.v-navigation-drawer--rail {
|
||||
.scrollnavbar .v-list .v-list-group__items,
|
||||
|
||||
@@ -4,26 +4,26 @@ const PurpleThemeDark: ThemeTypes = {
|
||||
name: 'PurpleThemeDark',
|
||||
dark: true,
|
||||
variables: {
|
||||
'border-color': '#1677ff',
|
||||
'border-color': '#3c96ca',
|
||||
'carousel-control-size': 10
|
||||
},
|
||||
colors: {
|
||||
primary: '#1677ff',
|
||||
secondary: '#722ed1',
|
||||
primary: '#3c96ca',
|
||||
secondary: '#4ea4d8',
|
||||
info: '#03c9d7',
|
||||
success: '#52c41a',
|
||||
accent: '#FFAB91',
|
||||
warning: '#faad14',
|
||||
error: '#ff4d4f',
|
||||
lightprimary: '#eef2f6',
|
||||
lightsecondary: '#ede7f6',
|
||||
lightprimary: '#e8f3fa',
|
||||
lightsecondary: '#e8f3fa',
|
||||
lightsuccess: '#b9f6ca',
|
||||
lighterror: '#f9d8d8',
|
||||
lightwarning: '#fff8e1',
|
||||
primaryText: '#ffffff',
|
||||
secondaryText: '#ffffffcc',
|
||||
darkprimary: '#1565c0',
|
||||
darksecondary: '#4527a0',
|
||||
darkprimary: '#2f86bd',
|
||||
darksecondary: '#2f86bd',
|
||||
borderLight: '#d0d0d0',
|
||||
border: '#333333ee',
|
||||
inputBorder: '#787878',
|
||||
@@ -34,8 +34,8 @@ const PurpleThemeDark: ThemeTypes = {
|
||||
twitter: '#1da1f2',
|
||||
linkedin: '#0e76a8',
|
||||
gray100: '#cccccccc',
|
||||
primary200: '#90caf9',
|
||||
secondary200: '#b39ddb',
|
||||
primary200: '#84c9ea',
|
||||
secondary200: '#8cc4e1',
|
||||
background: '#1d1d1d',
|
||||
overlay: '#111111aa',
|
||||
codeBg: '#282833',
|
||||
|
||||
@@ -9,21 +9,21 @@ const PurpleTheme: ThemeTypes = {
|
||||
},
|
||||
colors: {
|
||||
primary: '#3c96ca',
|
||||
secondary: '#2288b7',
|
||||
secondary: '#2f86bd',
|
||||
info: '#03c9d7',
|
||||
success: '#00c853',
|
||||
accent: '#FFAB91',
|
||||
warning: '#ffc107',
|
||||
error: '#f44336',
|
||||
lightprimary: '#eef2f6',
|
||||
lightsecondary: '#ede7f6',
|
||||
lightsecondary: '#e8f3fa',
|
||||
lightsuccess: '#b9f6ca',
|
||||
lighterror: '#f9d8d8',
|
||||
lightwarning: '#fff8e1',
|
||||
primaryText: '#1b1c1d',
|
||||
secondaryText: '#000000aa',
|
||||
darkprimary: '#1565c0',
|
||||
darksecondary: '#4527a0',
|
||||
darksecondary: '#236b99',
|
||||
borderLight: '#d0d0d0',
|
||||
border: '#d0d0d0',
|
||||
inputBorder: '#787878',
|
||||
@@ -35,7 +35,7 @@ const PurpleTheme: ThemeTypes = {
|
||||
linkedin: '#0e76a8',
|
||||
gray100: '#fafafacc',
|
||||
primary200: '#90caf9',
|
||||
secondary200: '#b39ddb',
|
||||
secondary200: '#8cc4e1',
|
||||
background: '#ffffff',
|
||||
overlay: '#ffffffaa',
|
||||
codeBg: '#ececec',
|
||||
|
||||
@@ -602,12 +602,15 @@ async function testSingleProvider(provider) {
|
||||
return
|
||||
}
|
||||
|
||||
const startTime = performance.now()
|
||||
const res = await axios.get(`/api/config/provider/check_one?id=${provider.id}`)
|
||||
if (res.data && res.data.status === 'ok') {
|
||||
const index = providerStatuses.value.findIndex(s => s.id === provider.id)
|
||||
if (index !== -1) {
|
||||
providerStatuses.value.splice(index, 1, res.data.data)
|
||||
}
|
||||
const latency = Math.max(0, Math.round(performance.now() - startTime))
|
||||
showMessage(tm('models.testSuccessWithLatency', { id: provider.id, latency }))
|
||||
} else {
|
||||
throw new Error(res.data?.message || `Failed to check status for ${provider.id}`)
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ onMounted(() => {
|
||||
height: 24px !important;
|
||||
opacity: 0.9 !important;
|
||||
align-self: center !important;
|
||||
border-color: rgba(180, 148, 246, 0.8) !important;
|
||||
border-color: rgba(var(--v-theme-primary), 0.45) !important;
|
||||
"
|
||||
></v-divider>
|
||||
|
||||
@@ -98,14 +98,7 @@ onMounted(() => {
|
||||
variant="text"
|
||||
size="small"
|
||||
>
|
||||
<v-icon
|
||||
size="18"
|
||||
:color="
|
||||
useCustomizerStore().uiTheme === 'PurpleTheme'
|
||||
? '#5e35b1'
|
||||
: '#d7c5fa'
|
||||
"
|
||||
>
|
||||
<v-icon size="18" :color="'rgb(var(--v-theme-primary))'">
|
||||
mdi-server
|
||||
</v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
@@ -120,14 +113,7 @@ onMounted(() => {
|
||||
variant="text"
|
||||
size="small"
|
||||
>
|
||||
<v-icon
|
||||
size="18"
|
||||
:color="
|
||||
useCustomizerStore().uiTheme === 'PurpleTheme'
|
||||
? '#5e35b1'
|
||||
: '#d7c5fa'
|
||||
"
|
||||
>
|
||||
<v-icon size="18" :color="'rgb(var(--v-theme-primary))'">
|
||||
mdi-white-balance-sunny
|
||||
</v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
进入 AstrBot WebUI 的插件市场,搜索 `astrbot_plugin_matrix_adapter`,点击安装。
|
||||
|
||||
安装完成后,前往 消息平台 → 新增适配器 → 选择 Matrix(若选项缺失,尝试重启 AstrBot 或检查插件安装状态)。
|
||||
安装完成后,前往 机器人(旧版本为 `消息平台`) → 新增适配器 → 选择 Matrix(若选项缺失,尝试重启 AstrBot 或检查插件安装状态)。
|
||||
|
||||
在弹出的配置对话框中点击 `启用`。
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
## 配置 AstrBot
|
||||
|
||||
1. 进入 AstrBot 的管理面板,点击左侧栏 `消息平台`,然后在右侧的界面中,点击 `+ 新增适配器`,选择 `企业微信智能机器人`,进入配置页面。
|
||||
1. 进入 AstrBot 的管理面板,点击左侧栏 `机器人`(旧版本为 `消息平台`),然后在右侧的界面中,点击 `+ 新增适配器`,选择 `企业微信智能机器人`,进入配置页面。
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
aiocqhttp>=1.4.4
|
||||
aiodocker>=0.24.0
|
||||
aiohttp>=3.11.18
|
||||
aiocqhttp>=1.4.4
|
||||
aiodocker>=0.24.0
|
||||
aiohttp>=3.11.18
|
||||
aiosqlite>=0.21.0
|
||||
anthropic>=0.51.0
|
||||
apscheduler>=3.11.0
|
||||
|
||||
@@ -106,6 +106,109 @@ async def test_get_stat(app: Quart, authenticated_header: dict):
|
||||
assert data["status"] == "ok" and "platform" in data["data"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("payload", [[], "x"])
|
||||
async def test_batch_delete_sessions_rejects_non_object_payload(
|
||||
app: Quart, authenticated_header: dict, payload
|
||||
):
|
||||
test_client = app.test_client()
|
||||
response = await test_client.post(
|
||||
"/api/chat/batch_delete_sessions",
|
||||
json=payload,
|
||||
headers=authenticated_header,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data["status"] == "error"
|
||||
assert data["message"] == "Invalid JSON body: expected object"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_delete_sessions_masks_internal_error(
|
||||
app: Quart, authenticated_header: dict, monkeypatch
|
||||
):
|
||||
test_client = app.test_client()
|
||||
|
||||
create_session_response = await test_client.get(
|
||||
"/api/chat/new_session", headers=authenticated_header
|
||||
)
|
||||
assert create_session_response.status_code == 200
|
||||
create_session_data = await create_session_response.get_json()
|
||||
session_id = create_session_data["data"]["session_id"]
|
||||
|
||||
async def _raise_error(*args, **kwargs):
|
||||
raise RuntimeError("secret-internal-error")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"astrbot.dashboard.routes.chat.ChatRoute._delete_session_internal",
|
||||
_raise_error,
|
||||
)
|
||||
|
||||
response = await test_client.post(
|
||||
"/api/chat/batch_delete_sessions",
|
||||
json={"session_ids": [session_id]},
|
||||
headers=authenticated_header,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["data"]["deleted_count"] == 0
|
||||
assert data["data"]["failed_count"] == 1
|
||||
assert data["data"]["failed_items"][0]["session_id"] == session_id
|
||||
assert data["data"]["failed_items"][0]["reason"] == "internal_error"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_delete_sessions_uses_batch_lookup(
|
||||
app: Quart,
|
||||
authenticated_header: dict,
|
||||
core_lifecycle_td: AstrBotCoreLifecycle,
|
||||
monkeypatch,
|
||||
):
|
||||
test_client = app.test_client()
|
||||
db = core_lifecycle_td.db
|
||||
|
||||
create_session_response = await test_client.get(
|
||||
"/api/chat/new_session", headers=authenticated_header
|
||||
)
|
||||
assert create_session_response.status_code == 200
|
||||
create_session_data = await create_session_response.get_json()
|
||||
session_id = create_session_data["data"]["session_id"]
|
||||
|
||||
original_batch_lookup = db.get_platform_sessions_by_ids
|
||||
called = {"batch_lookup_count": 0}
|
||||
|
||||
async def _wrapped_batch_lookup(session_ids: list[str]):
|
||||
called["batch_lookup_count"] += 1
|
||||
return await original_batch_lookup(session_ids)
|
||||
|
||||
# 不应单个查询
|
||||
async def _should_not_call_single_lookup(session_id: str):
|
||||
raise AssertionError(
|
||||
f"single-session lookup should not be called: {session_id}"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(db, "get_platform_sessions_by_ids", _wrapped_batch_lookup)
|
||||
monkeypatch.setattr(
|
||||
db, "get_platform_session_by_id", _should_not_call_single_lookup
|
||||
)
|
||||
|
||||
response = await test_client.post(
|
||||
"/api/chat/batch_delete_sessions",
|
||||
json={"session_ids": [session_id]},
|
||||
headers=authenticated_header,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["data"]["deleted_count"] == 1
|
||||
assert data["data"]["failed_count"] == 0
|
||||
assert called["batch_lookup_count"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugins(
|
||||
app: Quart,
|
||||
|
||||
@@ -804,6 +804,28 @@ class TestPluginToolFix:
|
||||
|
||||
assert "mcp_tool" in req.func_tool.names()
|
||||
|
||||
def test_plugin_tool_fix_preserves_tools_without_plugin_origin(self, mock_event):
|
||||
"""Tools without handler_module_path should not be filtered out."""
|
||||
module = ama
|
||||
handoff_tool = FunctionTool(
|
||||
name="transfer_to_demo_agent",
|
||||
description="Delegate to demo agent",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
handler_module_path=None,
|
||||
active=True,
|
||||
)
|
||||
|
||||
tool_set = ToolSet()
|
||||
tool_set.add_tool(handoff_tool)
|
||||
|
||||
req = ProviderRequest(func_tool=tool_set)
|
||||
mock_event.plugins_name = ["other_plugin"]
|
||||
|
||||
with patch("astrbot.core.astr_main_agent.star_map"):
|
||||
module._plugin_tool_fix(mock_event, req)
|
||||
|
||||
assert "transfer_to_demo_agent" in req.func_tool.names()
|
||||
|
||||
|
||||
class TestBuildMainAgent:
|
||||
"""Tests for build_main_agent function."""
|
||||
|
||||
Reference in New Issue
Block a user