Skip to content

Commit df0576a

Browse files
committed
feature: add command suggestion for ChatUI
1 parent 0711172 commit df0576a

5 files changed

Lines changed: 384 additions & 1 deletion

File tree

dashboard/src/components/chat/ChatInput.vue

Lines changed: 165 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,14 +108,23 @@
108108
</div>
109109
</transition>
110110

111+
<CommandSuggestion
112+
:visible="showCommandSuggestion"
113+
:commands="filteredCommands"
114+
:selected-index="selectedCommandIndex"
115+
:is-dark="isDark"
116+
@select="handleCommandSelect"
117+
@update-selected-index="selectedCommandIndex = $event"
118+
/>
111119
<textarea
112120
ref="inputField"
113121
v-model="localPrompt"
114122
@keydown="handleKeyDown"
123+
@input="handleInput"
115124
@compositionstart="handleCompositionStart"
116125
@compositionend="handleCompositionEnd"
117126
@compositioncancel="handleCompositionEnd"
118-
@blur="clearCompositionState()"
127+
@blur="handleBlur"
119128
:disabled="disabled"
120129
placeholder="Ask AstrBot..."
121130
class="chat-textarea"
@@ -312,10 +321,14 @@ import { useDisplay } from "vuetify";
312321
import { useModuleI18n } from "@/i18n/composables";
313322
import { useCustomizerStore } from "@/stores/customizer";
314323
import { isComposingEnter } from "@/utils/imeInput.mjs";
324+
import axios from "axios";
325+
import type { CommandItem } from "@/components/extension/componentPanel/types";
315326
import ConfigSelector from "./ConfigSelector.vue";
316327
import ProviderModelMenu from "./ProviderModelMenu.vue";
317328
import StyledMenu from "@/components/shared/StyledMenu.vue";
329+
import CommandSuggestion from "./CommandSuggestion.vue";
318330
import type { Session } from "@/composables/useSessions";
331+
import type { SuggestionCommand } from "./CommandSuggestion.vue";
319332
320333
interface StagedFileInfo {
321334
attachment_id: string;
@@ -388,6 +401,74 @@ const isComposing = ref(false);
388401
const lastCompositionEndAt = ref<number | null>(null);
389402
let dragLeaveTimeout: number | null = null;
390403
404+
// 命令提示相关状态
405+
const allCommands = ref<CommandItem[]>([]);
406+
const showCommandSuggestion = ref(false);
407+
const selectedCommandIndex = ref(0);
408+
const commandSuggestionLoading = ref(false);
409+
410+
/** 从所有指令中展平获取启用的普通指令和子指令 */
411+
const enabledCommands = computed(() => {
412+
const result: SuggestionCommand[] = [];
413+
const seen = new Set<string>();
414+
415+
function addCommand(cmd: CommandItem) {
416+
if (!cmd.enabled) return;
417+
if (cmd.type === "group") {
418+
// 指令组本身不加入,但其子指令加入
419+
cmd.sub_commands?.forEach(addCommand);
420+
return;
421+
}
422+
// 统一添加 / 前缀(子命令的 effective_command 如 "music play" 需要变成 "/music play")
423+
const displayCmd = cmd.effective_command.startsWith("/")
424+
? cmd.effective_command
425+
: `/${cmd.effective_command}`;
426+
if (!seen.has(displayCmd)) {
427+
seen.add(displayCmd);
428+
result.push({
429+
handler_full_name: cmd.handler_full_name,
430+
effective_command: displayCmd,
431+
description: cmd.description,
432+
plugin_display_name: cmd.plugin_display_name,
433+
enabled: cmd.enabled,
434+
});
435+
}
436+
// 同时加入别名(别名也需要加上 / 前缀)
437+
cmd.aliases?.forEach((alias) => {
438+
const aliasBase = cmd.parent_signature
439+
? `${cmd.parent_signature} ${alias}`
440+
: alias;
441+
const aliasKey = aliasBase.startsWith("/")
442+
? aliasBase
443+
: `/${aliasBase}`;
444+
if (!seen.has(aliasKey)) {
445+
seen.add(aliasKey);
446+
result.push({
447+
handler_full_name: cmd.handler_full_name,
448+
effective_command: aliasKey,
449+
description: cmd.description,
450+
plugin_display_name: cmd.plugin_display_name,
451+
enabled: cmd.enabled,
452+
});
453+
}
454+
});
455+
}
456+
457+
allCommands.value.forEach(addCommand);
458+
return result;
459+
});
460+
461+
/** 根据当前输入过滤候选指令 */
462+
const filteredCommands = computed(() => {
463+
const text = props.prompt;
464+
if (!text || !text.startsWith("/")) return [];
465+
466+
const prefix = text.toLowerCase();
467+
return enabledCommands.value
468+
.filter((cmd) => cmd.effective_command.toLowerCase().startsWith(prefix))
469+
.slice(0, 8); // 最多显示8条
470+
});
471+
391472
const localPrompt = computed({
392473
get: () => props.prompt,
393474
set: (value) => emit("update:prompt", value),
@@ -504,6 +585,36 @@ watch(localPrompt, () => {
504585
});
505586
506587
function handleKeyDown(e: KeyboardEvent) {
588+
// 命令提示激活时,拦截方向键和 Enter/Esc
589+
if (showCommandSuggestion.value && filteredCommands.value.length > 0) {
590+
if (e.key === "ArrowDown") {
591+
e.preventDefault();
592+
selectedCommandIndex.value =
593+
(selectedCommandIndex.value + 1) % filteredCommands.value.length;
594+
return;
595+
}
596+
if (e.key === "ArrowUp") {
597+
e.preventDefault();
598+
selectedCommandIndex.value =
599+
(selectedCommandIndex.value - 1 + filteredCommands.value.length) %
600+
filteredCommands.value.length;
601+
return;
602+
}
603+
if (e.key === "Enter") {
604+
e.preventDefault();
605+
const cmd = filteredCommands.value[selectedCommandIndex.value];
606+
if (cmd) {
607+
handleCommandSelect(cmd);
608+
}
609+
return;
610+
}
611+
if (e.key === "Escape") {
612+
e.preventDefault();
613+
showCommandSuggestion.value = false;
614+
return;
615+
}
616+
}
617+
507618
const isEnter = e.key === "Enter";
508619
if (!isEnter) {
509620
// Ctrl+B 录音
@@ -544,6 +655,57 @@ function handleKeyDown(e: KeyboardEvent) {
544655
}
545656
}
546657
658+
/** 处理输入变化,控制命令提示显示 */
659+
function handleInput() {
660+
const text = props.prompt;
661+
if (text && text.startsWith("/") && !isComposing.value) {
662+
const prefix = text.toLowerCase();
663+
const hasMatch = enabledCommands.value.some((cmd) =>
664+
cmd.effective_command.toLowerCase().startsWith(prefix),
665+
);
666+
showCommandSuggestion.value = hasMatch;
667+
selectedCommandIndex.value = 0;
668+
} else {
669+
showCommandSuggestion.value = false;
670+
}
671+
}
672+
673+
/** 处理 blur 事件,延迟关闭命令提示以允许点击 */
674+
function handleBlur() {
675+
clearCompositionState();
676+
// 延迟关闭,避免点击候选项时面板已消失
677+
setTimeout(() => {
678+
showCommandSuggestion.value = false;
679+
}, 200);
680+
}
681+
682+
/** 选择命令,填入输入框 */
683+
function handleCommandSelect(cmd: SuggestionCommand) {
684+
localPrompt.value = cmd.effective_command + " ";
685+
showCommandSuggestion.value = false;
686+
nextTick(() => {
687+
inputField.value?.focus();
688+
autoResize();
689+
});
690+
}
691+
692+
/** 获取指令列表 */
693+
async function fetchCommands() {
694+
if (commandSuggestionLoading.value) return;
695+
commandSuggestionLoading.value = true;
696+
try {
697+
const res = await axios.get("/api/commands");
698+
if (res.data.status === "ok") {
699+
allCommands.value = res.data.data.items || [];
700+
}
701+
} catch (err) {
702+
// 静默失败,不影响聊天功能
703+
console.warn("Failed to fetch commands for suggestion:", err);
704+
} finally {
705+
commandSuggestionLoading.value = false;
706+
}
707+
}
708+
547709
function handleCompositionStart() {
548710
isComposing.value = true;
549711
lastCompositionEndAt.value = null;
@@ -656,6 +818,8 @@ onMounted(() => {
656818
inputField.value.addEventListener("paste", handlePaste);
657819
}
658820
document.addEventListener("keyup", handleKeyUp);
821+
// 预加载指令列表
822+
fetchCommands();
659823
});
660824
661825
onBeforeUnmount(() => {

0 commit comments

Comments
 (0)