diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index fd1a9aeb8c..e522ce5453 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -114,6 +114,8 @@ ) from astrbot.core.utils.string_utils import normalize_and_dedupe_strings +LLM_ERROR_MESSAGE_EXTRA_KEY = "_llm_error_message" + @dataclass(slots=True) class MainAgentBuildConfig: @@ -183,6 +185,10 @@ class MainAgentBuildResult: reset_coro: Coroutine | None = None +def _set_llm_error_message(event: AstrMessageEvent, message: str) -> None: + event.set_extra(LLM_ERROR_MESSAGE_EXTRA_KEY, message) + + def _select_provider( event: AstrMessageEvent, plugin_context: Context ) -> Provider | None: @@ -190,18 +196,28 @@ def _select_provider( sel_provider = event.get_extra("selected_provider") if sel_provider and isinstance(sel_provider, str): provider = plugin_context.get_provider_by_id(sel_provider) - if not provider: + if provider is None: logger.error("未找到指定的提供商: %s。", sel_provider) + _set_llm_error_message( + event, + f"LLM 请求失败:未找到指定的提供商 `{sel_provider}`。请检查提供商配置或重新选择可用模型。", + ) + return None if not isinstance(provider, Provider): logger.error( "选择的提供商类型无效(%s),跳过 LLM 请求处理。", type(provider) ) + _set_llm_error_message( + event, + f"LLM 请求失败:选择的提供商类型无效({type(provider).__name__}),已跳过本次请求。", + ) return None return provider try: return plugin_context.get_using_provider(umo=event.unified_msg_origin) except ValueError as exc: logger.error("Error occurred while selecting provider: %s", exc) + _set_llm_error_message(event, f"LLM 请求失败:{exc}") return None @@ -1192,6 +1208,11 @@ async def build_main_agent( provider = provider or _select_provider(event, plugin_context) if provider is None: logger.info("未找到任何对话模型(提供商),跳过 LLM 请求处理。") + if not event.get_extra(LLM_ERROR_MESSAGE_EXTRA_KEY): + _set_llm_error_message( + event, + "LLM 请求失败:未找到任何可用的对话模型(提供商)。请先在 WebUI 中配置并启用可用模型。", + ) return None if req is None: diff --git a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py index fee641c192..2c200ec262 100644 --- a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +++ b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py @@ -14,6 +14,7 @@ ) from astrbot.core.agent.response import AgentStats from astrbot.core.astr_main_agent import ( + LLM_ERROR_MESSAGE_EXTRA_KEY, MainAgentBuildConfig, MainAgentBuildResult, build_main_agent, @@ -151,6 +152,11 @@ async def initialize(self, ctx: PipelineContext) -> None: max_quoted_fallback_images=settings.get("max_quoted_fallback_images", 20), ) + async def _send_llm_error_message( + self, event: AstrMessageEvent, message: object + ) -> None: + await event.send(MessageChain().message(str(message))) + async def process( self, event: AstrMessageEvent, provider_wake_prefix: str ) -> AsyncGenerator[None, None]: @@ -219,6 +225,13 @@ async def process( ) if build_result is None: + if llm_error_message := event.get_extra( + LLM_ERROR_MESSAGE_EXTRA_KEY + ): + await self._send_llm_error_message( + event, + llm_error_message, + ) return agent_runner = build_result.agent_runner @@ -229,10 +242,12 @@ async def process( api_base = provider.provider_config.get("api_base", "") for host in decoded_blocked: if host in api_base: - logger.error( - "Provider API base %s is blocked due to security reasons. Please use another ai provider.", - api_base, + error_message = ( + f"LLM 请求失败:Provider API base `{api_base}` " + "因安全原因被拦截,请更换可用的 AI 提供商。" ) + logger.error(error_message) + await self._send_llm_error_message(event, error_message) return stream_to_general = ( diff --git a/dashboard/src/components/chat/Chat.vue b/dashboard/src/components/chat/Chat.vue index e5cae3da3b..7e88480f1c 100644 --- a/dashboard/src/components/chat/Chat.vue +++ b/dashboard/src/components/chat/Chat.vue @@ -841,6 +841,7 @@ async function startNewChat() { replyTarget.value = null; newChat(); closeMobileSidebar(); + await focusChatInput(); } function openCreateProjectDialog() { @@ -975,6 +976,7 @@ async function selectSession(sessionId: string, pushRoute = true) { } scrollToBottom(); closeMobileSidebar(); + await focusChatInput(); } async function sendCurrentMessage() { @@ -1032,6 +1034,7 @@ async function sendCurrentMessage() { console.error("Failed to send message:", error); } finally { sending.value = false; + await focusChatInput(); } } @@ -1326,6 +1329,13 @@ function scrollToBottom() { }); } +async function focusChatInput() { + await nextTick(); + window.requestAnimationFrame(() => { + inputRef.value?.focusInput(); + }); +} + async function stopCurrentSession() { if (!currSessionId.value) return; try { @@ -1487,6 +1497,9 @@ function toggleTheme() { align-items: center; gap: 8px; padding: 8px 12px; + padding-right: 68px; + position: relative; + box-sizing: border-box; cursor: pointer; text-align: left; } @@ -1511,15 +1524,24 @@ function toggleTheme() { } .session-actions { - display: none; + display: flex; align-items: center; gap: 2px; flex-shrink: 0; + opacity: 0; + pointer-events: none; + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + visibility: hidden; } .session-item:hover .session-actions, .session-item:focus-within .session-actions { - display: flex; + opacity: 1; + pointer-events: auto; + visibility: visible; } .session-action-btn { diff --git a/dashboard/src/components/chat/ChatInput.vue b/dashboard/src/components/chat/ChatInput.vue index c966d13777..e4657c6f6c 100644 --- a/dashboard/src/components/chat/ChatInput.vue +++ b/dashboard/src/components/chat/ChatInput.vue @@ -108,14 +108,23 @@ + (null); let dragLeaveTimeout: number | null = null; +// 命令提示相关状态 +const allCommands = ref([]); +const showCommandSuggestion = ref(false); +const selectedCommandIndex = ref(0); +const commandSuggestionLoading = ref(false); + +function normalizeCommandSearchText(value: string) { + return value.trim().replace(/^\/+/, "").toLowerCase(); +} + +/** 从所有指令中展平获取启用的普通指令和子指令 */ +const enabledCommands = computed(() => { + const result: SuggestionCommand[] = []; + const seen = new Set(); + + function addCommand(cmd: CommandItem) { + if (!cmd.enabled) return; + if (cmd.type === "group") { + // 指令组本身不加入,但其子指令加入 + cmd.sub_commands?.forEach(addCommand); + return; + } + // 统一添加 / 前缀(子命令的 effective_command 如 "music play" 需要变成 "/music play") + const displayCmd = cmd.effective_command.startsWith("/") + ? cmd.effective_command + : `/${cmd.effective_command}`; + if (!seen.has(displayCmd)) { + seen.add(displayCmd); + result.push({ + handler_full_name: cmd.handler_full_name, + effective_command: displayCmd, + description: cmd.description, + plugin_display_name: cmd.plugin_display_name, + enabled: cmd.enabled, + reserved: cmd.reserved, + }); + } + // 同时加入别名(别名也需要加上 / 前缀) + cmd.aliases?.forEach((alias) => { + const aliasBase = cmd.parent_signature + ? `${cmd.parent_signature} ${alias}` + : alias; + const aliasKey = aliasBase.startsWith("/") + ? aliasBase + : `/${aliasBase}`; + if (!seen.has(aliasKey)) { + seen.add(aliasKey); + result.push({ + handler_full_name: cmd.handler_full_name, + effective_command: aliasKey, + description: cmd.description, + plugin_display_name: cmd.plugin_display_name, + enabled: cmd.enabled, + reserved: cmd.reserved, + }); + } + }); + } + + allCommands.value.forEach(addCommand); + return result; +}); + +function sortSystemPluginCommandsFirst(commands: SuggestionCommand[]) { + return [...commands].sort((a, b) => Number(b.reserved) - Number(a.reserved)); +} + +/** 根据当前输入过滤候选指令 */ +const filteredCommands = computed(() => { + const text = props.prompt; + if (!text || !text.startsWith("/")) return []; + + const query = normalizeCommandSearchText(text); + if (!query) return sortSystemPluginCommandsFirst(enabledCommands.value); + + const startsWithMatches: SuggestionCommand[] = []; + const containsMatches: SuggestionCommand[] = []; + + for (const cmd of enabledCommands.value) { + const commandText = normalizeCommandSearchText(cmd.effective_command); + const pluginText = normalizeCommandSearchText(cmd.plugin_display_name || ""); + const descriptionText = normalizeCommandSearchText(cmd.description || ""); + const matchesCommand = commandText.includes(query); + const matchesMetadata = + pluginText.includes(query) || descriptionText.includes(query); + + if (commandText.startsWith(query)) { + startsWithMatches.push(cmd); + } else if (matchesCommand || matchesMetadata) { + containsMatches.push(cmd); + } + } + + return [ + ...sortSystemPluginCommandsFirst(startsWithMatches), + ...sortSystemPluginCommandsFirst(containsMatches), + ]; +}); + const localPrompt = computed({ get: () => props.prompt, set: (value) => emit("update:prompt", value), @@ -504,6 +616,36 @@ watch(localPrompt, () => { }); function handleKeyDown(e: KeyboardEvent) { + // 命令提示激活时,拦截方向键和 Enter/Esc + if (showCommandSuggestion.value && filteredCommands.value.length > 0) { + if (e.key === "ArrowDown") { + e.preventDefault(); + selectedCommandIndex.value = + (selectedCommandIndex.value + 1) % filteredCommands.value.length; + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + selectedCommandIndex.value = + (selectedCommandIndex.value - 1 + filteredCommands.value.length) % + filteredCommands.value.length; + return; + } + if (e.key === "Enter") { + e.preventDefault(); + const cmd = filteredCommands.value[selectedCommandIndex.value]; + if (cmd) { + handleCommandSelect(cmd); + } + return; + } + if (e.key === "Escape") { + e.preventDefault(); + showCommandSuggestion.value = false; + return; + } + } + const isEnter = e.key === "Enter"; if (!isEnter) { // Ctrl+B 录音 @@ -544,6 +686,53 @@ function handleKeyDown(e: KeyboardEvent) { } } +/** 处理输入变化,控制命令提示显示 */ +function handleInput() { + const text = props.prompt; + if (text && text.startsWith("/") && !isComposing.value) { + showCommandSuggestion.value = filteredCommands.value.length > 0; + selectedCommandIndex.value = 0; + } else { + showCommandSuggestion.value = false; + } +} + +/** 处理 blur 事件,延迟关闭命令提示以允许点击 */ +function handleBlur() { + clearCompositionState(); + // 延迟关闭,避免点击候选项时面板已消失 + setTimeout(() => { + showCommandSuggestion.value = false; + }, 200); +} + +/** 选择命令,填入输入框 */ +function handleCommandSelect(cmd: SuggestionCommand) { + localPrompt.value = cmd.effective_command + " "; + showCommandSuggestion.value = false; + nextTick(() => { + inputField.value?.focus(); + autoResize(); + }); +} + +/** 获取指令列表 */ +async function fetchCommands() { + if (commandSuggestionLoading.value) return; + commandSuggestionLoading.value = true; + try { + const res = await axios.get("/api/commands"); + if (res.data.status === "ok") { + allCommands.value = res.data.data.items || []; + } + } catch (err) { + // 静默失败,不影响聊天功能 + console.warn("Failed to fetch commands for suggestion:", err); + } finally { + commandSuggestionLoading.value = false; + } +} + function handleCompositionStart() { isComposing.value = true; lastCompositionEndAt.value = null; @@ -656,6 +845,8 @@ onMounted(() => { inputField.value.addEventListener("paste", handlePaste); } document.addEventListener("keyup", handleKeyUp); + // 预加载指令列表 + fetchCommands(); }); onBeforeUnmount(() => { diff --git a/dashboard/src/components/chat/CommandSuggestion.vue b/dashboard/src/components/chat/CommandSuggestion.vue new file mode 100644 index 0000000000..02bfaaf537 --- /dev/null +++ b/dashboard/src/components/chat/CommandSuggestion.vue @@ -0,0 +1,205 @@ + + + + + + {{ cmd.effective_command }} + + {{ cmd.plugin_display_name }} + + + + {{ cmd.description }} + + + + + ↑↓ {{ tm("commandSuggestion.navigate") }} + Enter {{ tm("commandSuggestion.select") }} + Esc {{ tm("commandSuggestion.close") }} + + + + + + + diff --git a/dashboard/src/components/chat/StandaloneChat.vue b/dashboard/src/components/chat/StandaloneChat.vue index f36e6cf2af..b754af50a5 100644 --- a/dashboard/src/components/chat/StandaloneChat.vue +++ b/dashboard/src/components/chat/StandaloneChat.vue @@ -317,6 +317,7 @@ async function sendCurrentMessage() { draft.value = ""; clearStaged({ revokeUrls: false }); scrollToBottom(); + await focusChatInput(); sendMessageStream({ sessionId, @@ -393,6 +394,13 @@ function scrollToBottom() { }); } +async function focusChatInput() { + await nextTick(); + window.requestAnimationFrame(() => { + inputRef.value?.focusInput(); + }); +} + function messageRefs(message: ChatRecord) { const refs = messageContent(message).refs; if (refs && typeof refs === "object" && Array.isArray(refs.used)) { diff --git a/dashboard/src/i18n/locales/en-US/features/chat.json b/dashboard/src/i18n/locales/en-US/features/chat.json index a026532c51..fbe4fb31cd 100644 --- a/dashboard/src/i18n/locales/en-US/features/chat.json +++ b/dashboard/src/i18n/locales/en-US/features/chat.json @@ -162,6 +162,11 @@ "failed": "Connection failed, please refresh the page" } }, + "commandSuggestion": { + "navigate": "Navigate", + "select": "Select", + "close": "Close" + }, "errors": { "sendMessageFailed": "Failed to send message, please try again", "createSessionFailed": "Failed to create session, please refresh the page" diff --git a/dashboard/src/i18n/locales/ru-RU/features/chat.json b/dashboard/src/i18n/locales/ru-RU/features/chat.json index b033cedaec..0837d9d551 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/chat.json +++ b/dashboard/src/i18n/locales/ru-RU/features/chat.json @@ -162,6 +162,11 @@ "failed": "Ошибка подключения, обновите страницу" } }, + "commandSuggestion": { + "navigate": "Выбор", + "select": "Подтвердить", + "close": "Закрыть" + }, "errors": { "sendMessageFailed": "Ошибка отправки сообщения, попробуйте еще раз", "createSessionFailed": "Ошибка создания сессии, обновите страницу" diff --git a/dashboard/src/i18n/locales/zh-CN/features/chat.json b/dashboard/src/i18n/locales/zh-CN/features/chat.json index 9ce2c6b81f..59cdaad979 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/chat.json +++ b/dashboard/src/i18n/locales/zh-CN/features/chat.json @@ -162,6 +162,11 @@ "failed": "连接失败,请刷新页面重试" } }, + "commandSuggestion": { + "navigate": "选择", + "select": "确认", + "close": "关闭" + }, "errors": { "sendMessageFailed": "发送消息失败,请重试", "createSessionFailed": "创建会话失败,请刷新页面重试" diff --git a/tests/unit/test_astr_main_agent.py b/tests/unit/test_astr_main_agent.py index bf6adb38ef..fdd06d34a0 100644 --- a/tests/unit/test_astr_main_agent.py +++ b/tests/unit/test_astr_main_agent.py @@ -184,6 +184,10 @@ def test_select_provider_not_found(self, mock_event, mock_context): result = module._select_provider(mock_event, mock_context) assert result is None + mock_event.set_extra.assert_called_with( + module.LLM_ERROR_MESSAGE_EXTRA_KEY, + "LLM 请求失败:未找到指定的提供商 `non-existent`。请检查提供商配置或重新选择可用模型。", + ) def test_select_provider_invalid_type(self, mock_event, mock_context): """Test selecting provider when result is not a Provider instance.""" @@ -196,6 +200,10 @@ def test_select_provider_invalid_type(self, mock_event, mock_context): result = module._select_provider(mock_event, mock_context) assert result is None + mock_event.set_extra.assert_called_with( + module.LLM_ERROR_MESSAGE_EXTRA_KEY, + "LLM 请求失败:选择的提供商类型无效(str),已跳过本次请求。", + ) def test_select_provider_fallback(self, mock_event, mock_context, mock_provider): """Test provider selection fallback to using provider.""" @@ -219,6 +227,10 @@ def test_select_provider_fallback_error(self, mock_event, mock_context): result = module._select_provider(mock_event, mock_context) assert result is None + mock_event.set_extra.assert_called_with( + module.LLM_ERROR_MESSAGE_EXTRA_KEY, + "LLM 请求失败:Test error", + ) class TestGetSessionConv: