Skip to content

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

Merged
Soulter merged 10 commits into
AstrBotDevs:masterfrom
elecvoid243:cmd_suggestion
May 28, 2026
Merged

fix(command-suggestion): support custom wake-up words & hover information#8353
Soulter merged 10 commits into
AstrBotDevs:masterfrom
elecvoid243:cmd_suggestion

Conversation

@elecvoid243
Copy link
Copy Markdown
Contributor

@elecvoid243 elecvoid243 commented May 26, 2026

Modifications / 改动点

这是对 #8279 的小修复,此前的实现中,所有指令都以默认"/"开头,为硬编码,但没有考虑到用户自定义唤醒词的问题
此外,添加了鼠标在候选框上悬停时,显示完整指令用途的气泡,防止显示被截断

改动文件

文件 类型 改动
astrbot/dashboard/routes/command.py 后端 (Python) 支持 config_id 参数,返回 wake_prefix
astrbot/dashboard/server.py 后端 (Python) CommandRoute 注入 core_lifecycle
dashboard/src/components/chat/ChatInput.vue 前端 (Vue 3) 核心逻辑:唤醒词检测、前缀剥离、动态加载
dashboard/src/components/chat/CommandSuggestion.vue 前端 (Vue 3) UI 增强:Tooltip 悬停气泡

详细改动说明

1. 自定义唤醒词支持(后端)

astrbot/dashboard/routes/command.py

  • CommandRoute.__init__ 新增 core_lifecycle 参数,用于访问 astrbot_config_mgr,读取不同 config_id 下的 wake_prefix 配置。
  • get_commands 方法重构:
    • 接受前端传入的 config_id 查询参数。
    • 优先从指定 config_id 对应的配置中读取 wake_prefix
    • 若未提供 config_id 或找不到对应配置,则回退到默认配置的 wake_prefix(默认值为 ["/"])。
    • 响应中新增 wake_prefix 字段,与 itemssummary 一同返回给前端。

astrbot/dashboard/server.py

  • 创建 CommandRoute 时传入 core_lifecycle 实例,使 CommandRoute 能够访问配置管理器。

2. 自定义唤醒词支持(前端)

dashboard/src/components/chat/ChatInput.vue

  • 新增状态变量

    • wakePrefixes (ref<string[]>): 存储从后端获取的唤醒词列表,默认 ["/"]
    • currentConfigId (ref<string>): 跟踪当前活动的配置 ID。
  • 新增辅助函数

    • hasWakePrefix(text): 检查输入文本是否以任意一个唤醒词前缀开头。
    • stripWakePrefix(text): 剥离文本开头匹配的第一个唤醒词前缀,用于搜索匹配。
  • 修改 normalizeCommandSearchText: 原来使用硬编码正则 /^\/+/ 去除前缀,现在改为调用 stripWakePrefix() 动态剥离。

  • 修改 enabledCommands computed: 指令展示前缀(display prefix)不再硬编码为 "/",而是从 wakePrefixes[0] 获取。

  • 修改 filteredCommands computedhandleInput: 原来判断 text.startsWith("/") 的硬编码逻辑,改为调用 hasWakePrefix(text)

  • 修改 fetchCommands:

    • 请求 /api/commands 时携带 config_id 参数。
    • 从响应中读取 wake_prefix 并更新 wakePrefixes
  • 修改 handleConfigChange: 配置切换时(configId 变化),自动重新调用 fetchCommands() 刷新指令列表和唤醒词。

3. 悬停气泡 Tooltip(前端)

dashboard/src/components/chat/CommandSuggestion.vue

  • 新增响应式状态 (tooltip): 包含 visibletextxy 四个字段,控制气泡的显示/隐藏和位置。
  • 新增事件处理:
    • @mousemove="handleMouseMove": 实时更新鼠标坐标。
    • @mouseleave="handleMouseLeave": 鼠标离开时隐藏气泡。
    • 原有的 @mouseenter="handleMouseEnter": 鼠标进入时显示指令描述。
  • 新增 Teleport 组件: 将 tooltip DOM 渲染到 <body> 下,避免被父容器裁剪。
  • 新增非 scoped 样式:
    • .command-tooltip: 浅色主题下的气泡样式(白色背景、灰色边框、圆角阴影)。
    • .command-tooltip.is-dark: 深色主题下的气泡样式(深色背景、适配的边框和文字颜色)。
    • 设置 pointer-events: none 确保气泡不阻挡鼠标事件。

  • This is NOT a breaking change. / 这不是一个破坏性变更。

Screenshots or Test Results / 运行截图或测试结果

custom_wake_up_words

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.txt and pyproject.toml.
    / 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到 requirements.txtpyproject.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:

  • Allow the commands API to return configuration-specific wake-up prefixes and expose them to the frontend.
  • Enable chat input to detect and strip configurable wake-up prefixes when triggering and displaying command suggestions.
  • Add a hover tooltip to command suggestions to show full command descriptions without truncation.

Enhancements:

  • Wire core lifecycle configuration access into the command routes so command metadata reflects the active configuration.
  • Refresh command suggestions and wake-up prefixes automatically when the active chat configuration changes.

@auto-assign auto-assign Bot requested review from Raven95676 and advent259141 May 26, 2026 09:52
@dosubot dosubot Bot added the size:L This PR changes 100-499 lines, ignoring generated files. label May 26, 2026
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread astrbot/dashboard/routes/command.py Outdated
Comment on lines +40 to +49
# 优先从指定 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__
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
# 优先从指定 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__

Comment on lines +91 to +96
const tooltipStyle = computed(() => ({
position: "fixed" as const,
left: `${tooltip.x + 12}px`,
top: `${tooltip.y + 12}px`,
zIndex: 10000,
}));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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;
    }
  }
);

Comment on lines +417 to +427
/** 去掉文本开头匹配的任意唤醒词前缀,返回剥离后的文本 */
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;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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;
}

Comment on lines +754 to +757
const prefixes: string[] = res.data.data.wake_prefix;
if (prefixes && prefixes.length > 0) {
wakePrefixes.value = prefixes;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Import watch from "vue" to support watching the panel's visibility state.

import { computed, reactive, watch } from "vue";

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

  • 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.
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>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +418 to +423
function stripWakePrefix(text: string): string {
let result = text;
for (const p of wakePrefixes.value) {
if (result.startsWith(p)) {
result = result.slice(p.length);
break; // 只剥离第一个匹配的前缀
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
  }
}

@dosubot dosubot Bot added the area:webui The bug / feature is about webui(dashboard) of astrbot. label May 26, 2026
@Soulter Soulter force-pushed the master branch 3 times, most recently from a4c4a7d to 9bd38ca Compare May 28, 2026 16:55
@dosubot dosubot Bot added the lgtm This PR has been approved by a maintainer label May 28, 2026
@Soulter Soulter merged commit adae1f3 into AstrBotDevs:master May 28, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:webui The bug / feature is about webui(dashboard) of astrbot. lgtm This PR has been approved by a maintainer size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants