fix(command-suggestion): support custom wake-up words & hover information#8353
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces support for dynamic, configuration-specific command wake prefixes in the chat dashboard, replacing the hardcoded "/" prefix. It also adds a tooltip to the command suggestion panel to display full command descriptions on hover. The review feedback highlights several important robustness improvements: normalizing the wake_prefix to an array on both the backend and frontend to prevent runtime type errors, sorting prefixes by length during stripping to ensure correct matching, and watching the suggestion panel's visibility to prevent the tooltip from getting stuck on the screen when the panel closes.
| # 优先从指定 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__ |
There was a problem hiding this comment.
If the user configures wake_prefix as a single string (e.g., wake_prefix: "/") instead of a list of strings in their configuration file, wake_prefix will be returned as a string. When the frontend receives a string instead of an array, calling wakePrefixes.value.some(...) will throw a TypeError: wakePrefixes.value.some is not a function, completely breaking the chat input component.
We should defensively normalize wake_prefix to a list of strings on the backend before returning it.
| # 优先从指定 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__ | |
| # 优先从指定 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) | |
| if isinstance(wake_prefix, str): | |
| wake_prefix = [wake_prefix] | |
| elif not isinstance(wake_prefix, list): | |
| wake_prefix = ["/"] | |
| return Response().ok( | |
| {"items": commands, "summary": summary, "wake_prefix": wake_prefix} | |
| ).__dict__ |
| const tooltipStyle = computed(() => ({ | ||
| position: "fixed" as const, | ||
| left: `${tooltip.x + 12}px`, | ||
| top: `${tooltip.y + 12}px`, | ||
| zIndex: 10000, | ||
| })); |
There was a problem hiding this comment.
When the command suggestion panel is closed (either because props.visible becomes false or because filteredCommands becomes empty), the suggestion items are unmounted from the DOM. Because they are unmounted, the @mouseleave event is never fired on the hovered item, which leaves tooltip.visible as true. This causes the tooltip to remain stuck floating on the screen indefinitely.
To fix this, we should watch the active state of the suggestion panel and automatically hide the tooltip when the panel is closed or hidden.
const tooltipStyle = computed(() => ({
position: "fixed" as const,
left: `${tooltip.x + 12}px`,
top: `${tooltip.y + 12}px`,
zIndex: 10000,
}));
watch(
() => props.visible && filteredCommands.value.length > 0,
(active) => {
if (!active) {
tooltip.visible = false;
}
}
);
| /** 去掉文本开头匹配的任意唤醒词前缀,返回剥离后的文本 */ | ||
| 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; | ||
| } |
There was a problem hiding this comment.
When stripping the wake prefix, if wakePrefixes contains multiple prefixes where one is a prefix of another (for example, ["/", "/!"]), iterating over them in their default order can lead to incorrect stripping. If the user types "/!help", matching "/" first will strip "/" and leave "!help", which is incorrect because the actual intended prefix was "/!".
To prevent this, we should sort the wake prefixes by length in descending order before matching, ensuring that the longest matching prefix is always stripped first.
/** 去掉文本开头匹配的任意唤醒词前缀,返回剥离后的文本 */
function stripWakePrefix(text: string): string {
let result = text;
const sortedPrefixes = [...wakePrefixes.value].sort((a, b) => b.length - a.length);
for (const p of sortedPrefixes) {
if (result.startsWith(p)) {
result = result.slice(p.length);
break; // 只剥离第一个匹配的前缀
}
}
return result;
}
| const prefixes: string[] = res.data.data.wake_prefix; | ||
| if (prefixes && prefixes.length > 0) { | ||
| wakePrefixes.value = prefixes; | ||
| } |
There was a problem hiding this comment.
To ensure frontend robustness, we should defensively handle the case where wake_prefix returned from the backend is a string instead of an array. If it is a string, we can wrap it in an array to prevent runtime errors like TypeError: wakePrefixes.value.some is not a function.
const prefixes = res.data.data.wake_prefix;
if (Array.isArray(prefixes) && prefixes.length > 0) {
wakePrefixes.value = prefixes;
} else if (typeof prefixes === "string" && prefixes.trim()) {
wakePrefixes.value = [prefixes.trim()];
}
|
|
||
| <script setup lang="ts"> | ||
| import { computed } from "vue"; | ||
| import { computed, reactive } from "vue"; |
There was a problem hiding this comment.
Hey - I've found 1 issue, and left some high level feedback:
- In
ChatInput.vue,hasWakePrefix/stripWakePrefixiteratewakePrefixesin insertion order, which can behave unexpectedly if you ever support overlapping prefixes (e.g./and/bot); consider normalizing and sorting prefixes by length (longest first) before checking to ensure the most specific prefix is matched. - In
CommandRoute.get_commands,wake_prefixis taken directly from the config without normalization; if a single string or incorrectly typed value is provided in config, the frontend logic expecting an array will behave oddly—consider coercingwake_prefixto a list of strings and falling back cleanly when the type is not as expected.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- In `ChatInput.vue`, `hasWakePrefix`/`stripWakePrefix` iterate `wakePrefixes` in insertion order, which can behave unexpectedly if you ever support overlapping prefixes (e.g. `/` and `/bot`); consider normalizing and sorting prefixes by length (longest first) before checking to ensure the most specific prefix is matched.
- In `CommandRoute.get_commands`, `wake_prefix` is taken directly from the config without normalization; if a single string or incorrectly typed value is provided in config, the frontend logic expecting an array will behave oddly—consider coercing `wake_prefix` to a list of strings and falling back cleanly when the type is not as expected.
## Individual Comments
### Comment 1
<location path="dashboard/src/components/chat/ChatInput.vue" line_range="418-423" />
<code_context>
+}
+
+/** 去掉文本开头匹配的任意唤醒词前缀,返回剥离后的文本 */
+function stripWakePrefix(text: string): string {
+ let result = text;
+ for (const p of wakePrefixes.value) {
+ if (result.startsWith(p)) {
+ result = result.slice(p.length);
+ break; // 只剥离第一个匹配的前缀
+ }
+ }
</code_context>
<issue_to_address>
**suggestion:** Consider ordering or matching strategy when multiple wake prefixes overlap.
Right now, the loop strips the first `startsWith` match and exits. If the backend ever sends overlapping prefixes like `['/', '/g']` or `['!', '!!']`, the shorter prefix always wins, which may be incorrect. Either sort prefixes by descending length before matching, or clearly document that overlapping prefixes will never be provided.
```ts
const prefixes = [...wakePrefixes.value].sort((a, b) => b.length - a.length);
for (const p of prefixes) {
if (result.startsWith(p)) {
result = result.slice(p.length);
break;
}
}
```
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| function stripWakePrefix(text: string): string { | ||
| let result = text; | ||
| for (const p of wakePrefixes.value) { | ||
| if (result.startsWith(p)) { | ||
| result = result.slice(p.length); | ||
| break; // 只剥离第一个匹配的前缀 |
There was a problem hiding this comment.
suggestion: Consider ordering or matching strategy when multiple wake prefixes overlap.
Right now, the loop strips the first startsWith match and exits. If the backend ever sends overlapping prefixes like ['/', '/g'] or ['!', '!!'], the shorter prefix always wins, which may be incorrect. Either sort prefixes by descending length before matching, or clearly document that overlapping prefixes will never be provided.
const prefixes = [...wakePrefixes.value].sort((a, b) => b.length - a.length);
for (const p of prefixes) {
if (result.startsWith(p)) {
result = result.slice(p.length);
break;
}
}… into cmd_suggestion
a4c4a7d to
9bd38ca
Compare
Modifications / 改动点
这是对 #8279 的小修复,此前的实现中,所有指令都以默认"/"开头,为硬编码,但没有考虑到用户自定义唤醒词的问题
此外,添加了鼠标在候选框上悬停时,显示完整指令用途的气泡,防止显示被截断
改动文件
astrbot/dashboard/routes/command.pyconfig_id参数,返回wake_prefixastrbot/dashboard/server.pyCommandRoute注入core_lifecycledashboard/src/components/chat/ChatInput.vuedashboard/src/components/chat/CommandSuggestion.vue详细改动说明
1. 自定义唤醒词支持(后端)
astrbot/dashboard/routes/command.pyCommandRoute.__init__新增core_lifecycle参数,用于访问astrbot_config_mgr,读取不同config_id下的wake_prefix配置。get_commands方法重构:config_id查询参数。config_id对应的配置中读取wake_prefix。config_id或找不到对应配置,则回退到默认配置的wake_prefix(默认值为["/"])。wake_prefix字段,与items、summary一同返回给前端。astrbot/dashboard/server.pyCommandRoute时传入core_lifecycle实例,使CommandRoute能够访问配置管理器。2. 自定义唤醒词支持(前端)
dashboard/src/components/chat/ChatInput.vue新增状态变量:
wakePrefixes(ref<string[]>): 存储从后端获取的唤醒词列表,默认["/"]。currentConfigId(ref<string>): 跟踪当前活动的配置 ID。新增辅助函数:
hasWakePrefix(text): 检查输入文本是否以任意一个唤醒词前缀开头。stripWakePrefix(text): 剥离文本开头匹配的第一个唤醒词前缀,用于搜索匹配。修改
normalizeCommandSearchText: 原来使用硬编码正则/^\/+/去除前缀,现在改为调用stripWakePrefix()动态剥离。修改
enabledCommandscomputed: 指令展示前缀(display prefix)不再硬编码为"/",而是从wakePrefixes[0]获取。修改
filteredCommandscomputed 和handleInput: 原来判断text.startsWith("/")的硬编码逻辑,改为调用hasWakePrefix(text)。修改
fetchCommands:/api/commands时携带config_id参数。wake_prefix并更新wakePrefixes。修改
handleConfigChange: 配置切换时(configId变化),自动重新调用fetchCommands()刷新指令列表和唤醒词。3. 悬停气泡 Tooltip(前端)
dashboard/src/components/chat/CommandSuggestion.vuetooltip): 包含visible、text、x、y四个字段,控制气泡的显示/隐藏和位置。@mousemove="handleMouseMove": 实时更新鼠标坐标。@mouseleave="handleMouseLeave": 鼠标离开时隐藏气泡。@mouseenter="handleMouseEnter": 鼠标进入时显示指令描述。<body>下,避免被父容器裁剪。.command-tooltip: 浅色主题下的气泡样式(白色背景、灰色边框、圆角阴影)。.command-tooltip.is-dark: 深色主题下的气泡样式(深色背景、适配的边框和文字颜色)。pointer-events: none确保气泡不阻挡鼠标事件。Screenshots or Test Results / 运行截图或测试结果
Checklist / 检查清单
😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
/ 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。
👀 My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
/ 我的更改经过了良好的测试,并已在上方提供了“验证步骤”和“运行截图”。
🤓 I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in
requirements.txtandpyproject.toml./ 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到
requirements.txt和pyproject.toml文件相应位置。😮 My changes do not introduce malicious code.
/ 我的更改没有引入恶意代码。
Summary by Sourcery
Support configuration-specific wake-up prefixes for command suggestions and add hover tooltips showing full command descriptions in the chat UI.
New Features:
Enhancements: