diff --git a/mateclaw-ui/src/components/chat/ChatInput.vue b/mateclaw-ui/src/components/chat/ChatInput.vue index cfc5ab08..4314d60e 100644 --- a/mateclaw-ui/src/components/chat/ChatInput.vue +++ b/mateclaw-ui/src/components/chat/ChatInput.vue @@ -133,6 +133,13 @@
+ @@ -232,7 +240,8 @@ import { ref, computed, nextTick, watch } from 'vue' import { useI18n } from 'vue-i18n' import { ArrowDown, CloseBold, MagicStick, Microphone, Paperclip, Promotion, Select, Timer, WarningFilled } from '@element-plus/icons-vue' import { useToolLabel } from '@/composables/useToolLabel' -import type { ChatAttachment, PendingApprovalMeta, StreamPhase, QueuedMessage } from '@/types' +import SkillSlashMenu from '@/components/chat/SkillSlashMenu.vue' +import type { ChatAttachment, PendingApprovalMeta, StreamPhase, QueuedMessage, Skill } from '@/types' interface Props { /** 输入值 */ @@ -272,6 +281,12 @@ interface Props { * 不响应点击,tooltip 提示当前模型不支持深度思考。默认 true 以保持向后兼容。 */ thinkingSupported?: boolean + /** + * Whether the current agent can use skills. When false the skill slash menu + * is suppressed — a skills-disabled agent has no `load_skill` tool, so naming + * a skill would be a dead end. + */ + skillsEnabled?: boolean } const props = withDefaults(defineProps(), { @@ -291,6 +306,7 @@ const props = withDefaults(defineProps(), { enableTalkMode: false, thinkingEnabled: false, thinkingSupported: true, + skillsEnabled: true, }) const emit = defineEmits<{ @@ -324,6 +340,102 @@ const fileInputRef = ref(null) const isFocused = ref(false) const isComposing = ref(false) +// ---- Skill slash-command menu ---- +// The menu opens when the whole input is a single "/" token (no spaces +// yet). Picking a skill rewrites the input to a directive that names the skill, +// which the agent recognises and loads via `load_skill`. +const slashMenuRef = ref | null>(null) +const slashDismissed = ref(false) +const slashMatch = computed(() => { + const m = /^\/([^\s/]*)$/.exec(props.modelValue) + return m ? m[1] : null +}) +const slashQuery = computed(() => slashMatch.value ?? '') +const slashActive = computed( + () => + slashMatch.value !== null && + !slashDismissed.value && + !props.disabled && + !props.pendingApproval && + props.skillsEnabled, +) +// Leaving slash mode (cleared the "/" token) re-arms the menu for next time. +watch(slashMatch, (val) => { + if (val === null) slashDismissed.value = false +}) + +function handleSlashKeydown(e: KeyboardEvent) { + if (!slashActive.value) return + const menu = slashMenuRef.value + if (!menu) return + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + menu.next() + break + case 'ArrowUp': + e.preventDefault() + menu.prev() + break + case 'Enter': + if (!isComposing.value && menu.count() > 0) { + e.preventDefault() + menu.confirm() + } + break + case 'Tab': + if (menu.count() > 0) { + e.preventDefault() + menu.confirm() + } + break + case 'Escape': + e.preventDefault() + slashDismissed.value = true + break + } +} + +function handleSkillSelect(skill: Skill) { + inputValue.value = t('chat.useSkillDirective', { name: skill.name }) + slashDismissed.value = false + nextTick(() => { + const el = textareaRef.value + if (el) { + el.focus() + const end = el.value.length + el.setSelectionRange(end, end) + } + autoResize() + }) +} + +// On open, the menu autofocuses its search box, which blurs the textarea. That +// is expected focus movement — keep the menu open. Only dismiss when focus +// actually leaves the input+menu (clicked elsewhere). The relatedTarget check +// covers direct focus moves; the deferred activeElement check covers browsers +// that report a null relatedTarget for programmatic focus. +function onTextareaBlur(e: FocusEvent) { + isFocused.value = false + const next = e.relatedTarget as HTMLElement | null + if (next && next.closest && next.closest('.skill-slash-menu')) return + setTimeout(() => { + const ae = document.activeElement as HTMLElement | null + if (ae && ae.closest && ae.closest('.skill-slash-menu')) return + slashDismissed.value = true + }, 0) +} + +function onTextareaFocus() { + isFocused.value = true +} + +// The menu asked to close (Escape, or focus left the menu). Suppress it until +// the "/" token is cleared and retyped. +function handleSlashClose() { + slashDismissed.value = true +} + // Always-approve dropdown — collapsed by default; opens on the chevron click, // closes on outside click or after the user picks a scope. const alwaysApproveOpen = ref(false) @@ -394,6 +506,9 @@ const sendBtnClass = computed(() => ({ // 处理回车键 const handleEnter = () => { if (isComposing.value) return + // When the slash menu is showing matches, Enter confirms the highlighted + // skill (handled in handleSlashKeydown) instead of submitting the message. + if (slashActive.value && (slashMenuRef.value?.count() ?? 0) > 0) return handleSubmit() } @@ -570,6 +685,7 @@ defineExpose({ /* 输入区域 */ .input-area { + position: relative; display: flex; gap: 10px; align-items: flex-end; diff --git a/mateclaw-ui/src/components/chat/SkillSlashMenu.vue b/mateclaw-ui/src/components/chat/SkillSlashMenu.vue new file mode 100644 index 00000000..25a7d82b --- /dev/null +++ b/mateclaw-ui/src/components/chat/SkillSlashMenu.vue @@ -0,0 +1,342 @@ + + + + + diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index df0bddb6..f383e704 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -303,6 +303,12 @@ export default { thinkingOn: 'Deep thinking enabled', thinkingOff: 'Click to enable deep thinking', thinkingUnsupported: 'Current model does not support deep thinking', + slashMenuTitle: 'Skills', + slashMenuHint: '↑↓ navigate · Enter select · Esc dismiss', + slashMenuLoading: 'Loading skills…', + slashMenuEmpty: 'No matching skills', + slashMenuSearchPlaceholder: 'Search skills…', + useSkillDirective: 'Use the "{name}" skill: ', subtitle: 'Memory · Knowledge base · Skills · Automation — your personal AI operating surface', // Queue related queuedSending: 'Sending queued message...', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index b225ef10..4739f9d8 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -303,6 +303,12 @@ export default { thinkingOn: '深度思考已开启', thinkingOff: '点击开启深度思考', thinkingUnsupported: '当前模型不支持深度思考', + slashMenuTitle: '技能', + slashMenuHint: '↑↓ 选择 · Enter 确认 · Esc 关闭', + slashMenuLoading: '加载技能中…', + slashMenuEmpty: '没有匹配的技能', + slashMenuSearchPlaceholder: '搜索技能…', + useSkillDirective: '使用「{name}」技能:', subtitle: '记忆 · 知识库 · 技能 · 自动化 —— 你的个人 AI 工作面', // 排队相关 queuedSending: '正在发送排队消息...', diff --git a/mateclaw-ui/src/views/ChatConsole.vue b/mateclaw-ui/src/views/ChatConsole.vue index 0f892536..fcac0eaf 100644 --- a/mateclaw-ui/src/views/ChatConsole.vue +++ b/mateclaw-ui/src/views/ChatConsole.vue @@ -217,6 +217,7 @@ v-model="inputText" :loading="isGenerating && !hasPendingApproval" :disabled="blockingPrompt || !currentAgent" + :skills-enabled="!!currentAgent && !currentAgent.skillsDisabled" :placeholder="$t('chat.messagePlaceholder')" :hint="currentRuntimeModel" :attachments="pendingAttachments"