From 50310a1bf85b3379d69da00be22606f57a53cb1f Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sun, 31 May 2026 00:06:48 +0800 Subject: [PATCH] feat(web): expand slash command menu --- static/src/components/input/InputComposer.vue | 375 ++++++++++++++++-- .../styles/components/input/_composer.scss | 39 +- 2 files changed, 374 insertions(+), 40 deletions(-) diff --git a/static/src/components/input/InputComposer.vue b/static/src/components/input/InputComposer.vue index 51f8621..d70c2aa 100644 --- a/static/src/components/input/InputComposer.vue +++ b/static/src/components/input/InputComposer.vue @@ -54,30 +54,43 @@ -
- -
- 没有匹配的 skill + +
+ {{ slashMenuEmptyText }} +
-
+
(null); const fileUploadInput = ref(null); const baselineComposerVisualHeight = ref(0); type SkillItem = { name: string; description: string; path: string }; +type SlashMenuMode = 'root' | 'skills' | 'theme'; +type SlashMenuItem = { + id: string; + label: string; + description: string; + disabled?: boolean; + action?: () => void; + mode?: SlashMenuMode; + skill?: SkillItem; +}; const availableSkills = ref([]); const selectedSkillRefs = ref([]); const skillsLoaded = ref(false); @@ -436,6 +459,9 @@ const skillSlashOpen = ref(false); const skillSlashQuery = ref(''); const skillSlashActiveIndex = ref(0); const skillSlashList = ref(null); +const slashMenuMode = ref('root'); +const slashMenuParentIndex = ref(0); +const slashMenuTransitioning = ref(false); let composerResizeObserver: ResizeObserver | null = null; let syncingEditorFromProps = false; @@ -476,7 +502,7 @@ const findSlashToken = () => { const { $from } = state.selection; const lineStart = $from.start(); const beforeInLine = $from.parent.textBetween(0, $from.parentOffset, '\n', '\n'); - const match = /(^|[ \n])\/([A-Za-z0-9_-]*)$/.exec(beforeInLine); + const match = /(^|[ \n])\/([^\s/]*)$/.exec(beforeInLine); if (!match) return null; const query = match[2] || ''; const start = $from.pos - query.length - 1; @@ -488,6 +514,30 @@ const findSlashToken = () => { }; }; +const closeSlashMenu = () => { + skillSlashOpen.value = false; + skillSlashQuery.value = ''; + skillSlashActiveIndex.value = 0; + slashMenuMode.value = 'root'; + slashMenuParentIndex.value = 0; +}; + +const deleteSlashToken = () => { + const token = findSlashToken(); + const instance = editor.value; + if (!token || !instance) { + return; + } + const { state, view } = instance; + let tr = state.tr.delete(token.start, token.end); + tr = tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(0, token.start)))); + view.dispatch(tr); + view.focus(); + nextTick(() => { + adjustTextareaSize(); + }); +}; + const scrollSkillSlashSelectionIntoMiddle = () => { nextTick(() => { const list = skillSlashList.value; @@ -510,7 +560,186 @@ const filteredSkills = computed(() => { return filtered.slice(0, 100); }); -const skillSlashMenuOpen = computed(() => skillSlashOpen.value && (skillsLoading.value || filteredSkills.value.length >= 0)); +const currentModelSupportsImage = computed(() => { + const found = props.modelOptions?.find((m) => m.key === props.currentModelKey) as any; + return !!found?.supportsImage; +}); + +const currentModelSupportsVideo = computed(() => { + const found = props.modelOptions?.find((m) => m.key === props.currentModelKey) as any; + return !!found?.supportsVideo; +}); + +const applySlashTheme = async (theme: 'classic' | 'light' | 'dark') => { + personalizationStore.applyTheme(theme); + personalizationStore.updateField({ key: 'theme', value: theme }); + try { + const resp = await fetch('/api/personalization', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ theme }) + }); + const result = await resp.json().catch(() => ({})); + if (!resp.ok || !result.success) { + console.warn('保存主题到后端失败:', result?.error); + } + } catch (error) { + console.warn('保存主题到后端失败:', error); + } +}; + +const executeSlashAction = (action: () => void) => { + deleteSlashToken(); + closeSlashMenu(); + action(); +}; + +const rootSlashMenuItems = computed(() => { + const items: SlashMenuItem[] = [ + { + id: 'upload', + label: '上传文件', + description: props.uploading ? '上传中...' : '选择本地文件上传', + disabled: !props.isConnected || props.uploading || props.blockUpload, + action: () => emit('quick-upload') + }, + { + id: 'image', + label: '发送图片', + description: currentModelSupportsImage.value ? '选择图片并附加到消息' : '当前模型不支持图片', + disabled: !props.isConnected || props.streamingMessage || !currentModelSupportsImage.value, + action: () => emit('pick-images') + }, + { + id: 'video', + label: '发送视频', + description: currentModelSupportsVideo.value ? '选择视频并附加到消息' : '当前模型不支持视频', + disabled: !props.isConnected || props.streamingMessage || !currentModelSupportsVideo.value, + action: () => emit('pick-video') + }, + { + id: 'compress', + label: '压缩对话', + description: props.compressing ? '压缩中...' : '手动压缩当前对话上下文', + disabled: + !props.isConnected || + props.compressing || + props.streamingMessage || + props.blockCompressConversation, + action: () => emit('compress-conversation') + }, + { + id: 'skills', + label: '选择 AgentSkill', + description: '插入一个 AgentSkill 引用', + mode: 'skills' + }, + { + id: 'theme', + label: '切换主题', + description: '经典 / 明亮 / 夜间', + mode: 'theme' + }, + { + id: 'review', + label: '对话回顾', + description: '打开当前对话回顾', + disabled: !props.isConnected || props.streamingMessage || props.blockConversationReview, + action: () => emit('open-review') + }, + { + id: 'tokens', + label: '用量统计', + description: '打开上下文与 token 用量面板', + disabled: !props.currentConversationId || props.blockTokenPanel, + action: () => emit('toggle-token-panel', true) + }, + { + id: 'goal', + label: '目标模式', + description: props.goalRunning ? '目标模式运行中' : props.goalModeArmed ? '目标模式已就绪' : '切换目标模式', + disabled: !props.isConnected, + action: () => emit('toggle-goal-mode') + }, + { + id: 'personalization', + label: '个人设置', + description: '打开个人空间设置', + action: () => { + void personalizationStore.openDrawer(); + } + } + ]; + const query = skillSlashQuery.value.trim().toLowerCase(); + if (!query) { + return items; + } + return items.filter((item) => { + const haystack = `${item.label} ${item.description} ${item.id}`.toLowerCase(); + return haystack.includes(query); + }); +}); + +const themeSlashMenuItems = computed(() => { + const themes: Array = [ + { + id: 'theme-classic', + label: '经典', + description: '米色质感,柔和高对比', + theme: 'classic' + }, + { + id: 'theme-light', + label: '明亮', + description: '纯白底色 + 优雅灰,简洁清爽', + theme: 'light' + }, + { + id: 'theme-dark', + label: '夜间', + description: '深灰 + 黑,低亮度显示', + theme: 'dark' + } + ]; + return themes.map((item) => ({ + ...item, + action: () => { + void applySlashTheme(item.theme); + } + })); +}); + +const skillSlashMenuItems = computed(() => + filteredSkills.value.map((skill) => ({ + id: `skill:${skill.path}`, + label: skill.name, + description: skill.description, + skill + })) +); + +const activeSlashMenuItems = computed(() => { + if (slashMenuMode.value === 'skills') { + return skillSlashMenuItems.value; + } + if (slashMenuMode.value === 'theme') { + return themeSlashMenuItems.value; + } + return rootSlashMenuItems.value; +}); + +const slashMenuAriaLabel = computed(() => { + if (slashMenuMode.value === 'skills') return '可用 AgentSkills'; + if (slashMenuMode.value === 'theme') return '主题选项'; + return '快捷指令'; +}); + +const slashMenuEmptyText = computed(() => { + if (slashMenuMode.value === 'skills') return skillsLoading.value ? '正在加载 skills...' : '没有匹配的 skill'; + return '没有匹配的指令'; +}); + +const skillSlashMenuOpen = computed(() => skillSlashOpen.value && (skillsLoading.value || activeSlashMenuItems.value.length >= 0)); const loadSkills = async () => { if (skillsLoaded.value || skillsLoading.value) { @@ -545,9 +774,7 @@ const loadSkills = async () => { const refreshSkillSlashState = () => { const token = findSlashToken(); if (!token || !props.isConnected || props.inputLocked) { - skillSlashOpen.value = false; - skillSlashQuery.value = ''; - skillSlashActiveIndex.value = 0; + closeSlashMenu(); return; } skillSlashOpen.value = true; @@ -555,9 +782,11 @@ const refreshSkillSlashState = () => { skillSlashActiveIndex.value = 0; } skillSlashQuery.value = token.query; - skillSlashActiveIndex.value = Math.min(skillSlashActiveIndex.value, Math.max(0, filteredSkills.value.length - 1)); + if (slashMenuMode.value === 'skills') { + void loadSkills(); + } + skillSlashActiveIndex.value = Math.min(skillSlashActiveIndex.value, Math.max(0, activeSlashMenuItems.value.length - 1)); scrollSkillSlashSelectionIntoMiddle(); - void loadSkills(); }; const selectSkillSlashItem = (index = skillSlashActiveIndex.value) => { @@ -590,14 +819,37 @@ const selectSkillSlashItem = (index = skillSlashActiveIndex.value) => { tr = tr.setSelection(TextSelection.near(tr.doc.resolve(afterMention + 1))); view.dispatch(tr); view.focus(); - skillSlashOpen.value = false; - skillSlashQuery.value = ''; - skillSlashActiveIndex.value = 0; + closeSlashMenu(); nextTick(() => { adjustTextareaSize(); }); }; +const selectSlashMenuItem = (index = skillSlashActiveIndex.value) => { + const item = activeSlashMenuItems.value[index]; + if (!item || item.disabled) { + return; + } + if (item.mode) { + slashMenuParentIndex.value = index; + slashMenuMode.value = item.mode; + skillSlashActiveIndex.value = 0; + if (item.mode === 'skills') { + void loadSkills(); + } + scrollSkillSlashSelectionIntoMiddle(); + return; + } + if (item.skill) { + const skillIndex = filteredSkills.value.findIndex((skill) => skill.path === item.skill?.path); + selectSkillSlashItem(skillIndex >= 0 ? skillIndex : index); + return; + } + if (item.action) { + executeSlashAction(item.action); + } +}; + const handleSlashMenuKeydown = (event: KeyboardEvent): boolean => { if (event.ctrlKey && event.key === 'Enter') { return false; @@ -605,7 +857,7 @@ const handleSlashMenuKeydown = (event: KeyboardEvent): boolean => { if (skillSlashOpen.value) { if (event.key === 'ArrowDown') { event.preventDefault(); - const count = filteredSkills.value.length; + const count = activeSlashMenuItems.value.length; if (count > 0) { skillSlashActiveIndex.value = (skillSlashActiveIndex.value + 1) % count; scrollSkillSlashSelectionIntoMiddle(); @@ -614,7 +866,7 @@ const handleSlashMenuKeydown = (event: KeyboardEvent): boolean => { } if (event.key === 'ArrowUp') { event.preventDefault(); - const count = filteredSkills.value.length; + const count = activeSlashMenuItems.value.length; if (count > 0) { skillSlashActiveIndex.value = (skillSlashActiveIndex.value - 1 + count) % count; scrollSkillSlashSelectionIntoMiddle(); @@ -622,16 +874,25 @@ const handleSlashMenuKeydown = (event: KeyboardEvent): boolean => { return true; } if (event.key === 'Enter') { - const count = filteredSkills.value.length; + const count = activeSlashMenuItems.value.length; if (count > 0) { event.preventDefault(); - selectSkillSlashItem(skillSlashActiveIndex.value); + selectSlashMenuItem(skillSlashActiveIndex.value); } return true; } if (event.key === 'Escape') { event.preventDefault(); - skillSlashOpen.value = false; + if (slashMenuMode.value !== 'root') { + slashMenuMode.value = 'root'; + skillSlashActiveIndex.value = Math.min( + slashMenuParentIndex.value, + Math.max(0, activeSlashMenuItems.value.length - 1) + ); + scrollSkillSlashSelectionIntoMiddle(); + } else { + closeSlashMenu(); + } return true; } } @@ -649,7 +910,7 @@ const onKeydown = (event: KeyboardEvent): boolean => { const onInputBlur = () => { emit('input-blur'); window.setTimeout(() => { - skillSlashOpen.value = false; + closeSlashMenu(); }, 120); }; @@ -772,7 +1033,9 @@ const runtimeQueuePrevElements = new Map(); const runtimeQueueGhosts = new Map(); const runtimeQueueActiveAnimations = new WeakMap(); const runtimeQueueFallbackTimers = new WeakMap(); +const runtimeQueueTransitioning = ref(false); let runtimeQueueAnimationReady = false; +let runtimeQueueTransitionTimer: number | null = null; const bindRuntimeQueueItemRef = (id: string) => (el: Element | null) => { if (!id) { @@ -805,6 +1068,18 @@ const stopRuntimeQueueAnimation = (el: HTMLElement) => { el.style.removeProperty('pointer-events'); }; +const keepRuntimeQueueTransitionCollapsed = () => { + runtimeQueueTransitioning.value = true; + if (runtimeQueueTransitionTimer !== null) { + window.clearTimeout(runtimeQueueTransitionTimer); + } + runtimeQueueTransitionTimer = window.setTimeout(() => { + runtimeQueueTransitioning.value = false; + runtimeQueueTransitionTimer = null; + emitComposerHeight(); + }, RUNTIME_QUEUE_ANIM_DURATION + 40); +}; + const playRuntimeQueueTransform = ( el: HTMLElement, fromY: number, @@ -818,6 +1093,7 @@ const playRuntimeQueueTransform = ( done?.(); return; } + keepRuntimeQueueTransitionCollapsed(); stopRuntimeQueueAnimation(el); const from = `translateY(${fromY}px)`; const to = `translateY(${toY}px)`; @@ -1034,10 +1310,28 @@ const hasRuntimeLayoutExpansion = computed(() => { const goalCompleted = computed(() => String(props.goalProgress?.status || '').toLowerCase() === 'done'); +const handleSlashMenuTransitionStart = () => { + slashMenuTransitioning.value = true; +}; + +const handleSlashMenuAfterEnter = () => { + slashMenuTransitioning.value = false; + emitComposerHeight(); +}; + +const handleSlashMenuAfterLeave = () => { + slashMenuTransitioning.value = false; + emitComposerHeight(); +}; + // 当消息队列非空或 skill 选择菜单展开时,把目标横幅收起为仅圆点, // 让队列/菜单紧贴输入框,不再被横幅顶高。队列清空且无 skill 选择时自动恢复。 const goalBannerCollapsed = computed( - () => runtimeQueuedMessagesForRender.value.length > 0 || skillSlashMenuOpen.value + () => + runtimeQueuedMessagesForRender.value.length > 0 || + runtimeQueueTransitioning.value || + skillSlashMenuOpen.value || + slashMenuTransitioning.value ); const goalBannerTitle = computed(() => { @@ -1342,6 +1636,11 @@ onBeforeUnmount(() => { runtimeQueuePrevRects.clear(); runtimeQueuePrevElements.clear(); runtimeQueueAnimationReady = false; + if (runtimeQueueTransitionTimer !== null) { + window.clearTimeout(runtimeQueueTransitionTimer); + runtimeQueueTransitionTimer = null; + } + runtimeQueueTransitioning.value = false; if (composerResizeObserver) { composerResizeObserver.disconnect(); composerResizeObserver = null; diff --git a/static/src/styles/components/input/_composer.scss b/static/src/styles/components/input/_composer.scss index 29b3114..9034fb3 100644 --- a/static/src/styles/components/input/_composer.scss +++ b/static/src/styles/components/input/_composer.scss @@ -28,7 +28,7 @@ --runtime-queue-bg: var(--theme-surface-soft); --runtime-queue-divider: rgba(15, 23, 42, 0.12); position: absolute; - left: 50%; + left: calc(50% - 7px); bottom: calc(100% - 2px); transform: translateX(-50%); z-index: 1; @@ -125,9 +125,11 @@ .skill-slash-menu { --skill-slash-row-height: 38px; --skill-slash-gap: 3px; + --skill-slash-motion-duration: 300ms; + --skill-slash-motion-easing: cubic-bezier(0.25, 0.8, 0.25, 1); --skill-slash-visible-rows: 5; position: absolute; - left: 50%; + left: calc(50% - 7px); bottom: calc(100% - 2px); transform: translateX(-50%); z-index: 1; @@ -152,6 +154,27 @@ gap: var(--skill-slash-gap); } +.skill-slash-menu-motion-enter-active, +.skill-slash-menu-motion-leave-active { + transition: + transform var(--skill-slash-motion-duration, 300ms) var(--skill-slash-motion-easing, cubic-bezier(0.25, 0.8, 0.25, 1)), + clip-path var(--skill-slash-motion-duration, 300ms) var(--skill-slash-motion-easing, cubic-bezier(0.25, 0.8, 0.25, 1)); + transform-origin: bottom center; + will-change: transform, clip-path; +} + +.skill-slash-menu-motion-enter-from, +.skill-slash-menu-motion-leave-to { + transform: translateX(-50%) translateY(100%); + clip-path: inset(0 0 100% 0 round 12px 12px 0 0); +} + +.skill-slash-menu-motion-enter-to, +.skill-slash-menu-motion-leave-from { + transform: translateX(-50%) translateY(0); + clip-path: inset(0 0 0 0 round 12px 12px 0 0); +} + .skill-slash-menu::-webkit-scrollbar { width: 0; height: 0; @@ -180,6 +203,18 @@ box-shadow: 0 1px 2px rgba(61, 57, 41, 0.08); } +.skill-slash-item--disabled, +.skill-slash-item:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.skill-slash-item--disabled:hover, +.skill-slash-item:disabled:hover { + background: transparent; + box-shadow: none; +} + .skill-slash-item__name { flex: 0 0 auto; max-width: 210px;