|
108 | 108 | </div> |
109 | 109 | </transition> |
110 | 110 |
|
| 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 | + /> |
111 | 119 | <textarea |
112 | 120 | ref="inputField" |
113 | 121 | v-model="localPrompt" |
114 | 122 | @keydown="handleKeyDown" |
| 123 | + @input="handleInput" |
115 | 124 | @compositionstart="handleCompositionStart" |
116 | 125 | @compositionend="handleCompositionEnd" |
117 | 126 | @compositioncancel="handleCompositionEnd" |
118 | | - @blur="clearCompositionState()" |
| 127 | + @blur="handleBlur" |
119 | 128 | :disabled="disabled" |
120 | 129 | placeholder="Ask AstrBot..." |
121 | 130 | class="chat-textarea" |
@@ -312,10 +321,14 @@ import { useDisplay } from "vuetify"; |
312 | 321 | import { useModuleI18n } from "@/i18n/composables"; |
313 | 322 | import { useCustomizerStore } from "@/stores/customizer"; |
314 | 323 | import { isComposingEnter } from "@/utils/imeInput.mjs"; |
| 324 | +import axios from "axios"; |
| 325 | +import type { CommandItem } from "@/components/extension/componentPanel/types"; |
315 | 326 | import ConfigSelector from "./ConfigSelector.vue"; |
316 | 327 | import ProviderModelMenu from "./ProviderModelMenu.vue"; |
317 | 328 | import StyledMenu from "@/components/shared/StyledMenu.vue"; |
| 329 | +import CommandSuggestion from "./CommandSuggestion.vue"; |
318 | 330 | import type { Session } from "@/composables/useSessions"; |
| 331 | +import type { SuggestionCommand } from "./CommandSuggestion.vue"; |
319 | 332 |
|
320 | 333 | interface StagedFileInfo { |
321 | 334 | attachment_id: string; |
@@ -388,6 +401,74 @@ const isComposing = ref(false); |
388 | 401 | const lastCompositionEndAt = ref<number | null>(null); |
389 | 402 | let dragLeaveTimeout: number | null = null; |
390 | 403 |
|
| 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 | +
|
391 | 472 | const localPrompt = computed({ |
392 | 473 | get: () => props.prompt, |
393 | 474 | set: (value) => emit("update:prompt", value), |
@@ -504,6 +585,36 @@ watch(localPrompt, () => { |
504 | 585 | }); |
505 | 586 |
|
506 | 587 | 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 | +
|
507 | 618 | const isEnter = e.key === "Enter"; |
508 | 619 | if (!isEnter) { |
509 | 620 | // Ctrl+B 录音 |
@@ -544,6 +655,57 @@ function handleKeyDown(e: KeyboardEvent) { |
544 | 655 | } |
545 | 656 | } |
546 | 657 |
|
| 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 | +
|
547 | 709 | function handleCompositionStart() { |
548 | 710 | isComposing.value = true; |
549 | 711 | lastCompositionEndAt.value = null; |
@@ -656,6 +818,8 @@ onMounted(() => { |
656 | 818 | inputField.value.addEventListener("paste", handlePaste); |
657 | 819 | } |
658 | 820 | document.addEventListener("keyup", handleKeyUp); |
| 821 | + // 预加载指令列表 |
| 822 | + fetchCommands(); |
659 | 823 | }); |
660 | 824 |
|
661 | 825 | onBeforeUnmount(() => { |
|
0 commit comments