Skip to content

Commit 1d4fe9d

Browse files
committed
fix(command-suggestion): support custom wake-up words & hover information
1 parent 5004f3a commit 1d4fe9d

4 files changed

Lines changed: 128 additions & 14 deletions

File tree

astrbot/dashboard/routes/command.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@
1818

1919

2020
class CommandRoute(Route):
21-
def __init__(self, context: RouteContext) -> None:
21+
def __init__(self, context: RouteContext, core_lifecycle=None) -> None:
2222
super().__init__(context)
23+
self.core_lifecycle = core_lifecycle
2324
self.routes = {
2425
"/commands": ("GET", self.get_commands),
2526
"/commands/conflicts": ("GET", self.get_conflicts),
@@ -36,7 +37,16 @@ async def get_commands(self):
3637
"disabled": len([cmd for cmd in commands if not cmd["enabled"]]),
3738
"conflicts": len([cmd for cmd in commands if cmd.get("has_conflict")]),
3839
}
39-
return Response().ok({"items": commands, "summary": summary}).__dict__
40+
# 优先从指定 config_id 的配置中读取唤醒词,否则使用默认配置
41+
config_id = request.args.get("config_id", "").strip()
42+
wake_prefix = self.config.get("wake_prefix", ["/"])
43+
if config_id and self.core_lifecycle:
44+
acm = getattr(self.core_lifecycle, "astrbot_config_mgr", None)
45+
if acm and config_id in acm.confs:
46+
wake_prefix = acm.confs[config_id].get("wake_prefix", wake_prefix)
47+
return Response().ok(
48+
{"items": commands, "summary": summary, "wake_prefix": wake_prefix}
49+
).__dict__
4050

4151
async def get_conflicts(self):
4252
conflicts = await list_command_conflicts()

astrbot/dashboard/server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ def __init__(
154154
core_lifecycle,
155155
core_lifecycle.plugin_manager,
156156
)
157-
self.command_route = CommandRoute(self.context)
157+
self.command_route = CommandRoute(self.context, core_lifecycle)
158158
self.cr = ConfigRoute(self.context, core_lifecycle)
159159
self.lr = LogRoute(self.context, core_lifecycle.log_broker)
160160
self.sfr = StaticFileRoute(self.context)

dashboard/src/components/chat/ChatInput.vue

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -406,15 +406,36 @@ const allCommands = ref<CommandItem[]>([]);
406406
const showCommandSuggestion = ref(false);
407407
const selectedCommandIndex = ref(0);
408408
const commandSuggestionLoading = ref(false);
409+
const wakePrefixes = ref<string[]>(["/"]);
410+
const currentConfigId = ref((props.configId as string) || "default");
411+
412+
/** 检查文本是否以任意一个唤醒词前缀开头 */
413+
function hasWakePrefix(text: string): boolean {
414+
return wakePrefixes.value.some((p) => text.startsWith(p));
415+
}
416+
417+
/** 去掉文本开头匹配的任意唤醒词前缀,返回剥离后的文本 */
418+
function stripWakePrefix(text: string): string {
419+
let result = text;
420+
for (const p of wakePrefixes.value) {
421+
if (result.startsWith(p)) {
422+
result = result.slice(p.length);
423+
break; // 只剥离第一个匹配的前缀
424+
}
425+
}
426+
return result;
427+
}
409428
410429
function normalizeCommandSearchText(value: string) {
411-
return value.trim().replace(/^\/+/, "").toLowerCase();
430+
return stripWakePrefix(value.trim()).toLowerCase();
412431
}
413432
414433
/** 从所有指令中展平获取启用的普通指令和子指令 */
415434
const enabledCommands = computed(() => {
416435
const result: SuggestionCommand[] = [];
417436
const seen = new Set<string>();
437+
// 使用第一个唤醒词前缀作为指令的展示前缀
438+
const displayPrefix = wakePrefixes.value[0] || "/";
418439
419440
function addCommand(cmd: CommandItem) {
420441
if (!cmd.enabled) return;
@@ -423,10 +444,10 @@ const enabledCommands = computed(() => {
423444
cmd.sub_commands?.forEach(addCommand);
424445
return;
425446
}
426-
// 统一添加 / 前缀(子命令的 effective_command 如 "music play" 需要变成 "/music play")
427-
const displayCmd = cmd.effective_command.startsWith("/")
447+
// 统一添加唤醒词前缀(子命令的 effective_command 如 "music play" 需要变成 "/music play")
448+
const displayCmd = hasWakePrefix(cmd.effective_command)
428449
? cmd.effective_command
429-
: `/${cmd.effective_command}`;
450+
: `${displayPrefix}${cmd.effective_command}`;
430451
if (!seen.has(displayCmd)) {
431452
seen.add(displayCmd);
432453
result.push({
@@ -438,14 +459,14 @@ const enabledCommands = computed(() => {
438459
reserved: cmd.reserved,
439460
});
440461
}
441-
// 同时加入别名(别名也需要加上 / 前缀
462+
// 同时加入别名(别名也需要加上唤醒词前缀
442463
cmd.aliases?.forEach((alias) => {
443464
const aliasBase = cmd.parent_signature
444465
? `${cmd.parent_signature} ${alias}`
445466
: alias;
446-
const aliasKey = aliasBase.startsWith("/")
467+
const aliasKey = hasWakePrefix(aliasBase)
447468
? aliasBase
448-
: `/${aliasBase}`;
469+
: `${displayPrefix}${aliasBase}`;
449470
if (!seen.has(aliasKey)) {
450471
seen.add(aliasKey);
451472
result.push({
@@ -471,7 +492,7 @@ function sortSystemPluginCommandsFirst(commands: SuggestionCommand[]) {
471492
/** 根据当前输入过滤候选指令 */
472493
const filteredCommands = computed(() => {
473494
const text = props.prompt;
474-
if (!text || !text.startsWith("/")) return [];
495+
if (!text || !hasWakePrefix(text)) return [];
475496
476497
const query = normalizeCommandSearchText(text);
477498
if (!query) return sortSystemPluginCommandsFirst(enabledCommands.value);
@@ -689,7 +710,7 @@ function handleKeyDown(e: KeyboardEvent) {
689710
/** 处理输入变化,控制命令提示显示 */
690711
function handleInput() {
691712
const text = props.prompt;
692-
if (text && text.startsWith("/") && !isComposing.value) {
713+
if (text && hasWakePrefix(text) && !isComposing.value) {
693714
showCommandSuggestion.value = filteredCommands.value.length > 0;
694715
selectedCommandIndex.value = 0;
695716
} else {
@@ -721,9 +742,19 @@ async function fetchCommands() {
721742
if (commandSuggestionLoading.value) return;
722743
commandSuggestionLoading.value = true;
723744
try {
724-
const res = await axios.get("/api/commands");
745+
const params: Record<string, string> = {};
746+
const cid = currentConfigId.value;
747+
if (cid && cid !== "default") {
748+
params.config_id = cid;
749+
}
750+
const res = await axios.get("/api/commands", { params });
725751
if (res.data.status === "ok") {
726752
allCommands.value = res.data.data.items || [];
753+
// 读取当前配置的唤醒词列表,用于指令候选的触发前缀
754+
const prefixes: string[] = res.data.data.wake_prefix;
755+
if (prefixes && prefixes.length > 0) {
756+
wakePrefixes.value = prefixes;
757+
}
727758
}
728759
} catch (err) {
729760
// 静默失败,不影响聊天功能
@@ -826,6 +857,11 @@ function handleConfigChange(payload: {
826857
const runnerType = (payload.agentRunnerType || "").toLowerCase();
827858
const isInternal = runnerType === "internal" || runnerType === "local";
828859
showProviderSelector.value = isInternal;
860+
// 配置切换后重新获取指令列表和唤醒词
861+
if (payload.configId && payload.configId !== currentConfigId.value) {
862+
currentConfigId.value = payload.configId;
863+
fetchCommands();
864+
}
829865
}
830866
831867
function getCurrentSelection() {

dashboard/src/components/chat/CommandSuggestion.vue

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
:class="{ active: index === selectedIndex }"
1414
@click="handleSelect(index)"
1515
@mouseenter="handleMouseEnter(index)"
16+
@mousemove="handleMouseMove"
17+
@mouseleave="handleMouseLeave"
1618
>
1719
<div class="command-suggestion-main">
1820
<span class="command-name">{{ cmd.effective_command }}</span>
@@ -31,10 +33,21 @@
3133
<span>Esc {{ tm("commandSuggestion.close") }}</span>
3234
</div>
3335
</div>
36+
<!-- Tooltip: 鼠标悬停时显示完整用途 -->
37+
<Teleport to="body">
38+
<div
39+
v-if="tooltip.visible"
40+
class="command-tooltip"
41+
:class="{ 'is-dark': isDark }"
42+
:style="tooltipStyle"
43+
>
44+
{{ tooltip.text }}
45+
</div>
46+
</Teleport>
3447
</template>
3548

3649
<script setup lang="ts">
37-
import { computed } from "vue";
50+
import { computed, reactive } from "vue";
3851
import { useModuleI18n } from "@/i18n/composables";
3952
4053
export interface SuggestionCommand {
@@ -67,6 +80,21 @@ const { tm } = useModuleI18n("features/chat");
6780
6881
const filteredCommands = computed(() => props.commands);
6982
83+
// Tooltip 状态:鼠标悬停在指令上时显示完整用途
84+
const tooltip = reactive({
85+
visible: false,
86+
text: "",
87+
x: 0,
88+
y: 0,
89+
});
90+
91+
const tooltipStyle = computed(() => ({
92+
position: "fixed" as const,
93+
left: `${tooltip.x + 12}px`,
94+
top: `${tooltip.y + 12}px`,
95+
zIndex: 10000,
96+
}));
97+
7098
const panelStyle = computed(() => {
7199
if (props.caretPosition) {
72100
return {
@@ -94,6 +122,21 @@ function handleSelect(index: number) {
94122
95123
function handleMouseEnter(index: number) {
96124
emit("updateSelectedIndex", index);
125+
// 显示 tooltip
126+
const cmd = props.commands[index];
127+
if (cmd?.description) {
128+
tooltip.text = cmd.description;
129+
tooltip.visible = true;
130+
}
131+
}
132+
133+
function handleMouseMove(e: MouseEvent) {
134+
tooltip.x = e.clientX;
135+
tooltip.y = e.clientY;
136+
}
137+
138+
function handleMouseLeave() {
139+
tooltip.visible = false;
97140
}
98141
</script>
99142

@@ -203,3 +246,28 @@ function handleMouseEnter(index: number) {
203246
white-space: nowrap;
204247
}
205248
</style>
249+
250+
<!-- 非 scoped 样式:tooltip 通过 Teleport 渲染到 body,scoped 无法生效 -->
251+
<style>
252+
.command-tooltip {
253+
max-width: 360px;
254+
padding: 8px 12px;
255+
background: #ffffff;
256+
border: 1px solid #e0e0e0;
257+
border-radius: 8px;
258+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
259+
font-size: 13px;
260+
color: #333;
261+
line-height: 1.5;
262+
word-break: break-word;
263+
pointer-events: none;
264+
white-space: normal;
265+
}
266+
267+
.command-tooltip.is-dark {
268+
background: #2d2d2d;
269+
border-color: #404040;
270+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
271+
color: #e0e0e0;
272+
}
273+
</style>

0 commit comments

Comments
 (0)