feat(web): expand slash command menu

This commit is contained in:
JOJO 2026-05-31 00:06:48 +08:00
parent 39328cf6a9
commit 50310a1bf8
2 changed files with 374 additions and 40 deletions

View File

@ -54,30 +54,43 @@
</button>
</div>
</div>
<div
v-if="skillSlashMenuOpen"
ref="skillSlashList"
class="skill-slash-menu"
role="listbox"
aria-label="可用 Skills"
<transition
name="skill-slash-menu-motion"
@before-enter="handleSlashMenuTransitionStart"
@before-leave="handleSlashMenuTransitionStart"
@after-enter="handleSlashMenuAfterEnter"
@after-leave="handleSlashMenuAfterLeave"
>
<button
v-for="(skill, index) in filteredSkills"
:key="skill.path"
type="button"
class="skill-slash-item"
:class="{ 'skill-slash-item--active': index === skillSlashActiveIndex }"
role="option"
:aria-selected="index === skillSlashActiveIndex"
@mousedown.prevent="selectSkillSlashItem(index)"
<div
v-if="skillSlashMenuOpen"
:key="slashMenuMode"
ref="skillSlashList"
class="skill-slash-menu"
role="listbox"
:aria-label="slashMenuAriaLabel"
>
<span class="skill-slash-item__name">{{ skill.name }}</span>
<span class="skill-slash-item__description">{{ skill.description }}</span>
</button>
<div v-if="!filteredSkills.length" class="skill-slash-empty">
没有匹配的 skill
<button
v-for="(item, index) in activeSlashMenuItems"
:key="item.id"
type="button"
class="skill-slash-item"
:class="{
'skill-slash-item--active': index === skillSlashActiveIndex,
'skill-slash-item--disabled': item.disabled
}"
role="option"
:aria-selected="index === skillSlashActiveIndex"
:disabled="item.disabled"
@mousedown.prevent="selectSlashMenuItem(index)"
>
<span class="skill-slash-item__name">{{ item.label }}</span>
<span class="skill-slash-item__description">{{ item.description }}</span>
</button>
<div v-if="!activeSlashMenuItems.length" class="skill-slash-empty">
{{ slashMenuEmptyText }}
</div>
</div>
</div>
</transition>
<div
class="stadium-shell"
ref="compactInputShell"
@ -428,6 +441,16 @@ const stadiumInput = ref<HTMLTextAreaElement | null>(null);
const fileUploadInput = ref<HTMLInputElement | null>(null);
const baselineComposerVisualHeight = ref<number>(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<SkillItem[]>([]);
const selectedSkillRefs = ref<SkillItem[]>([]);
const skillsLoaded = ref(false);
@ -436,6 +459,9 @@ const skillSlashOpen = ref(false);
const skillSlashQuery = ref('');
const skillSlashActiveIndex = ref(0);
const skillSlashList = ref<HTMLElement | null>(null);
const slashMenuMode = ref<SlashMenuMode>('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<SlashMenuItem[]>(() => {
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<SlashMenuItem[]>(() => {
const themes: Array<SlashMenuItem & { theme: 'classic' | 'light' | 'dark' }> = [
{
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<SlashMenuItem[]>(() =>
filteredSkills.value.map((skill) => ({
id: `skill:${skill.path}`,
label: skill.name,
description: skill.description,
skill
}))
);
const activeSlashMenuItems = computed<SlashMenuItem[]>(() => {
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<string, HTMLElement>();
const runtimeQueueGhosts = new Map<string, HTMLElement>();
const runtimeQueueActiveAnimations = new WeakMap<HTMLElement, Animation>();
const runtimeQueueFallbackTimers = new WeakMap<HTMLElement, number>();
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;

View File

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