fix(command-suggestion): support custom wake-up words & hover information (#8353)

* 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

* fix(command-suggestion): support custom wake-up words & hover information

* ruff

---------

Co-authored-by: Soulter <905617992@qq.com>
This commit is contained in:
elecvoid243
2026-05-29 01:17:32 +08:00
committed by GitHub
parent e087b9def3
commit adae1f3598
4 changed files with 130 additions and 14 deletions

View File

@@ -18,8 +18,9 @@ from .route import Response, Route, RouteContext
class CommandRoute(Route):
def __init__(self, context: RouteContext) -> None:
def __init__(self, context: RouteContext, core_lifecycle=None) -> None:
super().__init__(context)
self.core_lifecycle = core_lifecycle
self.routes = {
"/commands": ("GET", self.get_commands),
"/commands/conflicts": ("GET", self.get_conflicts),
@@ -36,7 +37,18 @@ class CommandRoute(Route):
"disabled": len([cmd for cmd in commands if not cmd["enabled"]]),
"conflicts": len([cmd for cmd in commands if cmd.get("has_conflict")]),
}
return Response().ok({"items": commands, "summary": summary}).__dict__
# 优先从指定 config_id 的配置中读取唤醒词,否则使用默认配置
config_id = request.args.get("config_id", "").strip()
wake_prefix = self.config.get("wake_prefix", ["/"])
if config_id and self.core_lifecycle:
acm = getattr(self.core_lifecycle, "astrbot_config_mgr", None)
if acm and config_id in acm.confs:
wake_prefix = acm.confs[config_id].get("wake_prefix", wake_prefix)
return (
Response()
.ok({"items": commands, "summary": summary, "wake_prefix": wake_prefix})
.__dict__
)
async def get_conflicts(self):
conflicts = await list_command_conflicts()

View File

@@ -154,7 +154,7 @@ class AstrBotDashboard:
core_lifecycle,
core_lifecycle.plugin_manager,
)
self.command_route = CommandRoute(self.context)
self.command_route = CommandRoute(self.context, core_lifecycle)
self.cr = ConfigRoute(self.context, core_lifecycle)
self.lr = LogRoute(self.context, core_lifecycle.log_broker)
self.sfr = StaticFileRoute(self.context)

View File

@@ -406,15 +406,36 @@ const allCommands = ref<CommandItem[]>([]);
const showCommandSuggestion = ref(false);
const selectedCommandIndex = ref(0);
const commandSuggestionLoading = ref(false);
const wakePrefixes = ref<string[]>(["/"]);
const currentConfigId = ref((props.configId as string) || "default");
/** 检查文本是否以任意一个唤醒词前缀开头 */
function hasWakePrefix(text: string): boolean {
return wakePrefixes.value.some((p) => text.startsWith(p));
}
/** 去掉文本开头匹配的任意唤醒词前缀,返回剥离后的文本 */
function stripWakePrefix(text: string): string {
let result = text;
for (const p of wakePrefixes.value) {
if (result.startsWith(p)) {
result = result.slice(p.length);
break; // 只剥离第一个匹配的前缀
}
}
return result;
}
function normalizeCommandSearchText(value: string) {
return value.trim().replace(/^\/+/, "").toLowerCase();
return stripWakePrefix(value.trim()).toLowerCase();
}
/** 从所有指令中展平获取启用的普通指令和子指令 */
const enabledCommands = computed(() => {
const result: SuggestionCommand[] = [];
const seen = new Set<string>();
// 使用第一个唤醒词前缀作为指令的展示前缀
const displayPrefix = wakePrefixes.value[0] || "/";
function addCommand(cmd: CommandItem) {
if (!cmd.enabled) return;
@@ -423,10 +444,10 @@ const enabledCommands = computed(() => {
cmd.sub_commands?.forEach(addCommand);
return;
}
// 统一添加 / 前缀(子命令的 effective_command 如 "music play" 需要变成 "/music play"
const displayCmd = cmd.effective_command.startsWith("/")
// 统一添加唤醒词前缀(子命令的 effective_command 如 "music play" 需要变成 "/music play"
const displayCmd = hasWakePrefix(cmd.effective_command)
? cmd.effective_command
: `/${cmd.effective_command}`;
: `${displayPrefix}${cmd.effective_command}`;
if (!seen.has(displayCmd)) {
seen.add(displayCmd);
result.push({
@@ -438,14 +459,14 @@ const enabledCommands = computed(() => {
reserved: cmd.reserved,
});
}
// 同时加入别名(别名也需要加上 / 前缀)
// 同时加入别名(别名也需要加上唤醒词前缀)
cmd.aliases?.forEach((alias) => {
const aliasBase = cmd.parent_signature
? `${cmd.parent_signature} ${alias}`
: alias;
const aliasKey = aliasBase.startsWith("/")
const aliasKey = hasWakePrefix(aliasBase)
? aliasBase
: `/${aliasBase}`;
: `${displayPrefix}${aliasBase}`;
if (!seen.has(aliasKey)) {
seen.add(aliasKey);
result.push({
@@ -471,7 +492,7 @@ function sortSystemPluginCommandsFirst(commands: SuggestionCommand[]) {
/** 根据当前输入过滤候选指令 */
const filteredCommands = computed(() => {
const text = props.prompt;
if (!text || !text.startsWith("/")) return [];
if (!text || !hasWakePrefix(text)) return [];
const query = normalizeCommandSearchText(text);
if (!query) return sortSystemPluginCommandsFirst(enabledCommands.value);
@@ -689,7 +710,7 @@ function handleKeyDown(e: KeyboardEvent) {
/** 处理输入变化,控制命令提示显示 */
function handleInput() {
const text = props.prompt;
if (text && text.startsWith("/") && !isComposing.value) {
if (text && hasWakePrefix(text) && !isComposing.value) {
showCommandSuggestion.value = filteredCommands.value.length > 0;
selectedCommandIndex.value = 0;
} else {
@@ -721,9 +742,19 @@ async function fetchCommands() {
if (commandSuggestionLoading.value) return;
commandSuggestionLoading.value = true;
try {
const res = await axios.get("/api/commands");
const params: Record<string, string> = {};
const cid = currentConfigId.value;
if (cid && cid !== "default") {
params.config_id = cid;
}
const res = await axios.get("/api/commands", { params });
if (res.data.status === "ok") {
allCommands.value = res.data.data.items || [];
// 读取当前配置的唤醒词列表,用于指令候选的触发前缀
const prefixes: string[] = res.data.data.wake_prefix;
if (prefixes && prefixes.length > 0) {
wakePrefixes.value = prefixes;
}
}
} catch (err) {
// 静默失败,不影响聊天功能
@@ -826,6 +857,11 @@ function handleConfigChange(payload: {
const runnerType = (payload.agentRunnerType || "").toLowerCase();
const isInternal = runnerType === "internal" || runnerType === "local";
showProviderSelector.value = isInternal;
// 配置切换后重新获取指令列表和唤醒词
if (payload.configId && payload.configId !== currentConfigId.value) {
currentConfigId.value = payload.configId;
fetchCommands();
}
}
function getCurrentSelection() {

View File

@@ -13,6 +13,8 @@
:class="{ active: index === selectedIndex }"
@click="handleSelect(index)"
@mouseenter="handleMouseEnter(index)"
@mousemove="handleMouseMove"
@mouseleave="handleMouseLeave"
>
<div class="command-suggestion-main">
<span class="command-name">{{ cmd.effective_command }}</span>
@@ -31,10 +33,21 @@
<span>Esc {{ tm("commandSuggestion.close") }}</span>
</div>
</div>
<!-- Tooltip: 鼠标悬停时显示完整用途 -->
<Teleport to="body">
<div
v-if="tooltip.visible"
class="command-tooltip"
:class="{ 'is-dark': isDark }"
:style="tooltipStyle"
>
{{ tooltip.text }}
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { computed, reactive } from "vue";
import { useModuleI18n } from "@/i18n/composables";
export interface SuggestionCommand {
@@ -67,6 +80,21 @@ const { tm } = useModuleI18n("features/chat");
const filteredCommands = computed(() => props.commands);
// Tooltip 状态:鼠标悬停在指令上时显示完整用途
const tooltip = reactive({
visible: false,
text: "",
x: 0,
y: 0,
});
const tooltipStyle = computed(() => ({
position: "fixed" as const,
left: `${tooltip.x + 12}px`,
top: `${tooltip.y + 12}px`,
zIndex: 10000,
}));
const panelStyle = computed(() => {
if (props.caretPosition) {
return {
@@ -94,6 +122,21 @@ function handleSelect(index: number) {
function handleMouseEnter(index: number) {
emit("updateSelectedIndex", index);
// 显示 tooltip
const cmd = props.commands[index];
if (cmd?.description) {
tooltip.text = cmd.description;
tooltip.visible = true;
}
}
function handleMouseMove(e: MouseEvent) {
tooltip.x = e.clientX;
tooltip.y = e.clientY;
}
function handleMouseLeave() {
tooltip.visible = false;
}
</script>
@@ -203,3 +246,28 @@ function handleMouseEnter(index: number) {
white-space: nowrap;
}
</style>
<!-- scoped 样式tooltip 通过 Teleport 渲染到 bodyscoped 无法生效 -->
<style>
.command-tooltip {
max-width: 360px;
padding: 8px 12px;
background: #ffffff;
border: 1px solid #e0e0e0;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
font-size: 13px;
color: #333;
line-height: 1.5;
word-break: break-word;
pointer-events: none;
white-space: normal;
}
.command-tooltip.is-dark {
background: #2d2d2d;
border-color: #404040;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
color: #e0e0e0;
}
</style>