feat: 为ChatUI添加指令候选功能 (#8279)

* feature: add command suggestion for ChatUI

* feat(chat): add focus functionality to chat input after sending messages

* feat(llm): add error handling for LLM provider selection failures

* feat(command-suggestion): enhance command filtering and sorting logic

---------

Co-authored-by: Soulter <905617992@qq.com>
This commit is contained in:
elecvoid243
2026-05-22 20:57:19 +08:00
committed by GitHub
parent fd4fe84310
commit dcc99e6b9b
10 changed files with 496 additions and 7 deletions

View File

@@ -114,6 +114,8 @@ from astrbot.core.utils.quoted_message_parser import (
)
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:

View File

@@ -14,6 +14,7 @@ from astrbot.core.agent.message import (
)
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 @@ class InternalAgentSubStage(Stage):
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 @@ class InternalAgentSubStage(Stage):
)
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 @@ class InternalAgentSubStage(Stage):
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 = (

View File

@@ -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 {

View File

@@ -108,14 +108,23 @@
</div>
</transition>
<CommandSuggestion
:visible="showCommandSuggestion"
:commands="filteredCommands"
:selected-index="selectedCommandIndex"
:is-dark="isDark"
@select="handleCommandSelect"
@update-selected-index="selectedCommandIndex = $event"
/>
<textarea
ref="inputField"
v-model="localPrompt"
@keydown="handleKeyDown"
@input="handleInput"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
@compositioncancel="handleCompositionEnd"
@blur="clearCompositionState()"
@blur="handleBlur"
:disabled="disabled"
placeholder="Ask AstrBot..."
class="chat-textarea"
@@ -312,10 +321,14 @@ import { useDisplay } from "vuetify";
import { useModuleI18n } from "@/i18n/composables";
import { useCustomizerStore } from "@/stores/customizer";
import { isComposingEnter } from "@/utils/imeInput.mjs";
import axios from "axios";
import type { CommandItem } from "@/components/extension/componentPanel/types";
import ConfigSelector from "./ConfigSelector.vue";
import ProviderModelMenu from "./ProviderModelMenu.vue";
import StyledMenu from "@/components/shared/StyledMenu.vue";
import CommandSuggestion from "./CommandSuggestion.vue";
import type { Session } from "@/composables/useSessions";
import type { SuggestionCommand } from "./CommandSuggestion.vue";
interface StagedFileInfo {
attachment_id: string;
@@ -388,6 +401,105 @@ const isComposing = ref(false);
const lastCompositionEndAt = ref<number | null>(null);
let dragLeaveTimeout: number | null = null;
// 命令提示相关状态
const allCommands = ref<CommandItem[]>([]);
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<string>();
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(() => {

View File

@@ -0,0 +1,205 @@
<template>
<div
v-if="visible && filteredCommands.length > 0"
class="command-suggestion-panel"
:class="{ 'is-dark': isDark }"
:style="panelStyle"
>
<div class="command-suggestion-list">
<div
v-for="(cmd, index) in filteredCommands"
:key="`${cmd.handler_full_name}:${cmd.effective_command}`"
class="command-suggestion-item"
:class="{ active: index === selectedIndex }"
@click="handleSelect(index)"
@mouseenter="handleMouseEnter(index)"
>
<div class="command-suggestion-main">
<span class="command-name">{{ cmd.effective_command }}</span>
<span v-if="cmd.plugin_display_name" class="command-plugin">
{{ cmd.plugin_display_name }}
</span>
</div>
<div v-if="cmd.description" class="command-description">
{{ cmd.description }}
</div>
</div>
</div>
<div class="command-suggestion-hint">
<span> {{ tm("commandSuggestion.navigate") }}</span>
<span>Enter {{ tm("commandSuggestion.select") }}</span>
<span>Esc {{ tm("commandSuggestion.close") }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useModuleI18n } from "@/i18n/composables";
export interface SuggestionCommand {
handler_full_name: string;
effective_command: string;
description: string;
plugin_display_name: string | null;
enabled: boolean;
reserved: boolean;
}
interface Props {
visible: boolean;
commands: SuggestionCommand[];
selectedIndex: number;
isDark: boolean;
caretPosition?: { left: number; top: number } | null;
}
const props = withDefaults(defineProps<Props>(), {
caretPosition: null,
});
const emit = defineEmits<{
select: [command: SuggestionCommand];
updateSelectedIndex: [index: number];
}>();
const { tm } = useModuleI18n("features/chat");
const filteredCommands = computed(() => props.commands);
const panelStyle = computed(() => {
if (props.caretPosition) {
return {
position: "absolute" as const,
left: `${props.caretPosition.left}px`,
bottom: `${props.caretPosition.top + 8}px`,
zIndex: 1000,
};
}
return {
position: "absolute" as const,
left: "18px",
bottom: "100%",
marginBottom: "8px",
zIndex: 1000,
};
});
function handleSelect(index: number) {
const cmd = props.commands[index];
if (cmd) {
emit("select", cmd);
}
}
function handleMouseEnter(index: number) {
emit("updateSelectedIndex", index);
}
</script>
<style scoped>
.command-suggestion-panel {
background: #ffffff;
border: 1px solid #e0e0e0;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
max-height: 280px;
width: 320px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.command-suggestion-panel.is-dark {
background: #1e1e1e;
border-color: #404040;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}
.command-suggestion-list {
overflow-y: auto;
flex: 1;
padding: 4px;
}
.command-suggestion-item {
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.15s ease;
}
.command-suggestion-item:hover,
.command-suggestion-item.active {
background-color: rgba(var(--v-theme-primary), 0.1);
}
.is-dark .command-suggestion-item:hover,
.is-dark .command-suggestion-item.active {
background-color: rgba(255, 255, 255, 0.08);
}
.command-suggestion-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.command-name {
font-family: "Fira Code", "Consolas", monospace;
font-size: 13px;
font-weight: 600;
color: rgb(var(--v-theme-primary));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.command-plugin {
font-size: 11px;
color: #888;
background: rgba(0, 0, 0, 0.05);
padding: 1px 6px;
border-radius: 4px;
white-space: nowrap;
flex-shrink: 0;
}
.is-dark .command-plugin {
color: #aaa;
background: rgba(255, 255, 255, 0.08);
}
.command-description {
font-size: 12px;
color: #666;
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.is-dark .command-description {
color: #999;
}
.command-suggestion-hint {
display: flex;
gap: 12px;
padding: 6px 12px;
border-top: 1px solid #f0f0f0;
font-size: 11px;
color: #aaa;
flex-shrink: 0;
}
.is-dark .command-suggestion-hint {
border-top-color: #333;
color: #666;
}
.command-suggestion-hint span {
white-space: nowrap;
}
</style>

View File

@@ -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)) {

View File

@@ -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"

View File

@@ -162,6 +162,11 @@
"failed": "Ошибка подключения, обновите страницу"
}
},
"commandSuggestion": {
"navigate": "Выбор",
"select": "Подтвердить",
"close": "Закрыть"
},
"errors": {
"sendMessageFailed": "Ошибка отправки сообщения, попробуйте еще раз",
"createSessionFailed": "Ошибка создания сессии, обновите страницу"

View File

@@ -162,6 +162,11 @@
"failed": "连接失败,请刷新页面重试"
}
},
"commandSuggestion": {
"navigate": "选择",
"select": "确认",
"close": "关闭"
},
"errors": {
"sendMessageFailed": "发送消息失败,请重试",
"createSessionFailed": "创建会话失败,请刷新页面重试"

View File

@@ -184,6 +184,10 @@ class TestSelectProvider:
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 @@ class TestSelectProvider:
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 @@ class TestSelectProvider:
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: