Skip to content

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

Open
elecvoid243 wants to merge 1 commit into
AstrBotDevs:masterfrom
elecvoid243:cmd_suggestion
Open

feat: 为ChatUI添加指令候选功能#8279
elecvoid243 wants to merge 1 commit into
AstrBotDevs:masterfrom
elecvoid243:cmd_suggestion

Conversation

@elecvoid243
Copy link
Copy Markdown
Contributor

@elecvoid243 elecvoid243 commented May 21, 2026

Modifications / 改动点

resolve #8277

一、需求背景

在 AstrBot Dashboard 的 ChatUI 聊天界面中,当用户在输入框输入以 / 开头的命令时,希望能够自动弹出候选指令列表,帮助用户快速补全命令。该功能需要:

  • 根据当前键入的字符前缀匹配所有已启用的命令
  • 支持方向键导航和鼠标点击选择
  • 选择后仅将命令填入输入框,不自动发送消息
  • 包含普通指令和子指令
  • 显示指令描述和所属插件信息

二、设计方案

2.1 基本设计

ChatInput.vue 内部集成命令提示逻辑:

  • 组件挂载时通过 axios.get('/api/commands') 获取并缓存指令列表
  • 监听输入变化,以 / 开头时触发匹配
  • 使用浮动面板展示候选列表

优点:改动范围最小,无需修改父组件,减少跨组件耦合
缺点:每个 ChatInput 实例独立请求 API(但实际场景中通常只有一个实例)

2.2 组件架构

ChatInput.vue
├── CommandSuggestion.vue (新增子组件)
│   ├── 候选列表渲染
│   ├── 键盘事件处理
│   └── 点击选择处理
└── 原有 ChatInput 逻辑
    ├── 输入监听 → 触发命令提示
    ├── 键盘事件路由 → 命令提示激活时拦截方向键/Enter
    └── API 请求 & 缓存

2.3 数据流

用户输入 "/re"
    ↓
提取前缀 "/re"
    ↓
从缓存的指令列表中过滤匹配项
    ↓
渲染 CommandSuggestion 浮动面板
    ↓
用户按 ↓ 选择 "/reset"
    ↓
按 Enter → localPrompt = "/reset "
    ↓
关闭提示面板,焦点保持在输入框

三、实现详情

3.1 新增文件

dashboard/src/components/chat/CommandSuggestion.vue

命令提示浮动面板子组件,负责:

  • 接收 commands(候选列表)、selectedIndex(当前选中项)、isDark(暗色模式)
  • 渲染浮动面板,显示指令名、描述、插件名
  • 支持键盘导航高亮和鼠标点击选择
  • 底部显示操作提示(↑↓ 选择 / Enter 确认 / Esc 关闭)
  • 样式适配 dark/light 模式

关键 Props

interface Props {
  visible: boolean;                    // 是否显示
  commands: SuggestionCommand[];       // 候选指令列表
  selectedIndex: number;               // 当前选中索引
  isDark: boolean;                     // 是否暗色模式
}

export interface SuggestionCommand {
  handler_full_name: string;
  effective_command: string;
  description: string;
  plugin_display_name: string | null;
  enabled: boolean;
}

关键 Events

const emit = defineEmits<{
  select: [command: SuggestionCommand];        // 用户选择某条指令
  updateSelectedIndex: [index: number];        // 更新选中索引
}>();

3.2 修改文件

dashboard/src/components/chat/ChatInput.vue

新增导入

import axios from "axios";
import type { CommandItem } from "@/components/extension/componentPanel/types";
import CommandSuggestion from "./CommandSuggestion.vue";
import type { SuggestionCommand } from "./CommandSuggestion.vue";

新增状态

// 命令提示相关状态
const allCommands = ref<CommandItem[]>([]);
const showCommandSuggestion = ref(false);
const selectedCommandIndex = ref(0);
const commandSuggestionLoading = ref(false);

核心计算属性 - enabledCommands

从所有指令中展平获取启用的普通指令和子指令,统一添加 / 前缀:

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,
      });
    }
    // 同时加入别名(别名也需要加上 / 前缀)
    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,
        });
      }
    });
  }

  allCommands.value.forEach(addCommand);
  return result;
});

过滤计算属性 - filteredCommands

const filteredCommands = computed(() => {
  const text = props.prompt;
  if (!text || !text.startsWith("/")) return [];

  const prefix = text.toLowerCase();
  return enabledCommands.value
    .filter((cmd) => cmd.effective_command.toLowerCase().startsWith(prefix))
    .slice(0, 8); // 最多显示8条
});

输入监听 - handleInput

function handleInput() {
  const text = props.prompt;
  if (text && text.startsWith("/") && !isComposing.value) {
    const prefix = text.toLowerCase();
    const hasMatch = enabledCommands.value.some((cmd) =>
      cmd.effective_command.toLowerCase().startsWith(prefix),
    );
    showCommandSuggestion.value = hasMatch;
    selectedCommandIndex.value = 0;
  } else {
    showCommandSuggestion.value = false;
  }
}

键盘事件处理 - handleKeyDown(命令提示激活时拦截):

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;
    }
  }
  // ... 原有键盘事件处理
}

命令选择 - handleCommandSelect

function handleCommandSelect(cmd: SuggestionCommand) {
  localPrompt.value = cmd.effective_command + " ";
  showCommandSuggestion.value = false;
  nextTick(() => {
    inputField.value?.focus();
    autoResize();
  });
}

API 请求 - fetchCommands

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

组件挂载时预加载

onMounted(() => {
  if (inputField.value) {
    inputField.value.addEventListener("paste", handlePaste);
  }
  document.addEventListener("keyup", handleKeyUp);
  // 预加载指令列表
  fetchCommands();
});

模板集成

<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"
  @blur="handleBlur"
  <!-- ... -->
/>

3.3 国际化翻译

dashboard/src/i18n/locales/zh-CN/features/chat.json

{
  "commandSuggestion": {
    "navigate": "选择",
    "select": "确认",
    "close": "关闭"
  }
}

dashboard/src/i18n/locales/en-US/features/chat.json

{
  "commandSuggestion": {
    "navigate": "Navigate",
    "select": "Select",
    "close": "Close"
  }
}

dashboard/src/i18n/locales/ru-RU/features/chat.json

{
  "commandSuggestion": {
    "navigate": "Выбор",
    "select": "Подтвердить",
    "close": "Закрыть"
  }
}

四、关键设计决策

4.1 子命令前缀处理

AstrBot 中子命令的 effective_command 格式为 "music play"(不带 /),而普通命令为 "/reset"(带 /)。

处理方式:在 enabledCommands 计算属性中统一为所有命令添加 / 前缀,确保用户输入 / 时能匹配到所有类型的命令。

const displayCmd = cmd.effective_command.startsWith("/")
  ? cmd.effective_command
  : `/${cmd.effective_command}`;

4.2 别名处理

别名需要结合 parent_signature 拼接完整命令名:

const aliasBase = cmd.parent_signature
  ? `${cmd.parent_signature} ${alias}`
  : alias;
const aliasKey = aliasBase.startsWith("/")
  ? aliasBase
  : `/${aliasBase}`;

4.3 指令组过滤

指令组(type === "group")本身不可执行,因此不加入候选列表,仅递归处理其子命令。

4.4 去重机制

使用 Set<string>effective_command 进行去重,避免同一命令因别名或重复注册而多次出现。

4.5 Blur 延迟关闭

为避免点击候选项时面板已消失,handleBlur 使用 200ms 延迟关闭:

function handleBlur() {
  clearCompositionState();
  setTimeout(() => {
    showCommandSuggestion.value = false;
  }, 200);
}

4.6 错误处理

API 请求失败时静默处理,不影响聊天核心功能:

try {
  const res = await axios.get("/api/commands");
  // ...
} catch (err) {
  console.warn("Failed to fetch commands for suggestion:", err);
}

五、功能特性清单

特性 状态
输入 / 前缀触发命令提示
实时前缀匹配过滤候选
仅显示已启用(enabled=true)的指令
包含普通指令和子指令
指令组本身不显示
显示指令描述信息
显示所属插件名
支持别名匹配
方向键(↑↓)导航
Enter 选择填入输入框
Esc 关闭面板
鼠标点击选择
选择后不发送消息
暗色/亮色主题自适应
中/英/俄三语国际化
最多显示 8 条候选
IME 输入法兼容
  • This is NOT a breaking change. / 这不是一个破坏性变更。

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

支持内置和插件指令提示
command_suggestion

支持二级命令提示
plugin_commands

支持暗色模式
black


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

Add slash-command suggestion support to the chat input UI, including a floating suggestion panel backed by a preloaded commands list and integrated keyboard navigation.

New Features:

  • Introduce a CommandSuggestion subcomponent to display matching commands with descriptions and plugin information when typing slash-prefixed input.
  • Enable real-time filtering of enabled commands and aliases (including subcommands) based on the current input prefix, with up to eight suggestions shown.
  • Support keyboard and mouse interaction to navigate, select, and insert suggested commands into the chat input without sending the message.
  • Add localized hint texts for command suggestion controls in Chinese, English, and Russian chat feature translations.

@auto-assign auto-assign Bot requested review from advent259141 and anka-afk May 21, 2026 15:22
@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. feature:chatui The bug / feature is about astrbot's chatui, webchat labels May 21, 2026
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 2 issues, and left some high level feedback:

  • In handleInput and filteredCommands, consider using localPrompt instead of props.prompt so the suggestion logic always runs against the latest textarea value rather than relying on the parent to have already propagated the prop update.
  • In handleBlur, the setTimeout closing the suggestion panel isn’t stored or cleared; tracking the timeout ID and cancelling it when focus returns quickly (or on unmount) would avoid potential delayed hiding after the user refocuses.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `handleInput` and `filteredCommands`, consider using `localPrompt` instead of `props.prompt` so the suggestion logic always runs against the latest textarea value rather than relying on the parent to have already propagated the prop update.
- In `handleBlur`, the `setTimeout` closing the suggestion panel isn’t stored or cleared; tracking the timeout ID and cancelling it when focus returns quickly (or on unmount) would avoid potential delayed hiding after the user refocuses.

## Individual Comments

### Comment 1
<location path="dashboard/src/components/chat/ChatInput.vue" line_range="117" />
<code_context>
+        :selected-index="selectedCommandIndex"
+        :is-dark="isDark"
+        @select="handleCommandSelect"
+        @update-selected-index="selectedCommandIndex = $event"
+      />
       <textarea
</code_context>
<issue_to_address>
**issue (bug_risk):** The emitted event name from `CommandSuggestion` likely won't match this listener, so the selected index may never update.

In `CommandSuggestion.vue`, `defineEmits` uses `updateSelectedIndex` (camelCase), but this parent listens for `@update-selected-index` (kebab-case). In Vue 3, custom events are case-sensitive and not normalized, so emitting `updateSelectedIndex` will not trigger `@update-selected-index`, and the index won't update. Please either:
- Switch the emit to kebab-case (`emit('update-selected-index', index)` and `defineEmits<{ 'update-selected-index': [index: number] }>()`), or
- Change the listener to camelCase (`@updateSelectedIndex="selectedCommandIndex = $event"`).
Kebab-case in templates is generally preferred.
</issue_to_address>

### Comment 2
<location path="dashboard/src/components/chat/CommandSuggestion.vue" line_range="2-10" />
<code_context>
+<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"
+        class="command-suggestion-item"
</code_context>
<issue_to_address>
**issue (bug_risk):** Using `handler_full_name` as the v-for key can cause duplicate keys when a command has aliases.

Because the list expands each base command into all of its aliases, multiple entries can share the same `handler_full_name`, which will trigger Vue duplicate-key warnings and may cause unstable rendering. Use a composite key that’s unique per suggestion, for example:

```vue
:key="cmd.handler_full_name + '::' + cmd.effective_command"
```

or another reliably unique combination for each item.
</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.

:selected-index="selectedCommandIndex"
:is-dark="isDark"
@select="handleCommandSelect"
@update-selected-index="selectedCommandIndex = $event"
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.

issue (bug_risk): The emitted event name from CommandSuggestion likely won't match this listener, so the selected index may never update.

In CommandSuggestion.vue, defineEmits uses updateSelectedIndex (camelCase), but this parent listens for @update-selected-index (kebab-case). In Vue 3, custom events are case-sensitive and not normalized, so emitting updateSelectedIndex will not trigger @update-selected-index, and the index won't update. Please either:

  • Switch the emit to kebab-case (emit('update-selected-index', index) and defineEmits<{ 'update-selected-index': [index: number] }>()), or
  • Change the listener to camelCase (@updateSelectedIndex="selectedCommandIndex = $event").
    Kebab-case in templates is generally preferred.

Comment on lines +2 to +10
<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"
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.

issue (bug_risk): Using handler_full_name as the v-for key can cause duplicate keys when a command has aliases.

Because the list expands each base command into all of its aliases, multiple entries can share the same handler_full_name, which will trigger Vue duplicate-key warnings and may cause unstable rendering. Use a composite key that’s unique per suggestion, for example:

:key="cmd.handler_full_name + '::' + cmd.effective_command"

or another reliably unique combination for each item.

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 implements a command suggestion feature for the chat interface, enabling users to discover and select commands via a new suggestion panel. The implementation includes fetching commands from the backend, filtering them based on user input, and providing keyboard navigation. Review feedback highlights a bug regarding duplicate keys in the suggestion list when aliases are present, identifies redundant logic and dead code for removal, and suggests a more robust approach for handling input focus transitions compared to the current timeout-based implementation.

<div class="command-suggestion-list">
<div
v-for="(cmd, index) in filteredCommands"
:key="cmd.handler_full_name"
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

v-forkey 使用了 cmd.handler_full_name。当指令存在别名时,多个候选项会共享同一个 handler_full_name(即同一个处理函数),这会导致 Vue 报重复 key 错误并可能引起渲染异常。建议改用 cmd.effective_command,因为它在父组件的去重逻辑中已经保证了唯一性。

        :key="cmd.effective_command"


const { tm } = useModuleI18n("features/chat");

const filteredCommands = computed(() => props.commands);
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

计算属性 filteredCommands 是多余的,因为它只是简单地返回了 props.commands。建议直接在模板中使用 props.commands 并移除此计算属性,以减少不必要的响应式开销并简化代码。

Comment on lines +53 to +57
caretPosition?: { left: number; top: number } | null;
}

const props = withDefaults(defineProps<Props>(), {
caretPosition: null,
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

caretPosition 属性及其在 panelStyle 中的相关逻辑目前是死代码,因为在 ChatInput.vue 中并未传递或计算该位置。如果目前不打算实现跟随光标的定位功能,建议移除这些代码以保持组件简洁。

Comment on lines +663 to +665
const hasMatch = enabledCommands.value.some((cmd) =>
cmd.effective_command.toLowerCase().startsWith(prefix),
);
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

这里的 hasMatch 检查逻辑与 filteredCommands 计算属性中的逻辑重复。建议直接使用 filteredCommands.value.length > 0 来判断,这样可以复用已有的计算结果,避免在每次输入时都重新遍历完整的指令列表。

    const hasMatch = filteredCommands.value.length > 0;

Comment on lines +677 to +679
setTimeout(() => {
showCommandSuggestion.value = false;
}, 200);
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

使用 setTimeout 来延迟关闭面板以允许点击建议项是一种比较脆弱的处理方式。更优雅的方案是在 CommandSuggestion.vue 的建议项上添加 @mousedown.prevent。这可以防止 textarea 失去焦点,从而不会触发 blur 事件,这样就不再需要 200ms 的延迟关闭逻辑,用户体验也会更流畅。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature:chatui The bug / feature is about astrbot's chatui, webchat size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] 为ChatUI添加命令提示功能

1 participant