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 @@
-
+
(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;