From 4f5b27f57c18735e34f6cde44639d48488ebb892 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Thu, 13 Aug 2026 17:38:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(input):=20slash=20=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20//=20=E7=9B=B4=E8=BE=BE=20skill=20?= =?UTF-8?q?=E4=B8=8E=E4=BA=94=E4=B8=AA=E5=8A=9F=E8=83=BD=E9=A1=B9=EF=BC=8C?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E5=8E=8B=E7=BC=A9=E4=BA=8C=E6=AC=A1=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - // 键入直达 skills 子菜单(Escape 退回 root 不自动重进) - 新增:新建对话(/new /clear 均可匹配)、网络权限、工作模式、 切换工作区(host)/切换项目(docker)、对话类型(仅新对话可切) - 压缩对话点击即执行,去掉 confirmAction 二次确认 --- static/src/App.vue | 7 + static/src/app/methods/message/chat.ts | 10 - static/src/components/input/InputComposer.vue | 181 +++++++++++++++++- 3 files changed, 182 insertions(+), 16 deletions(-) diff --git a/static/src/App.vue b/static/src/App.vue index 403f4821..b0509720 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -310,6 +310,10 @@ :current-work-mode="currentWorkMode" :work-mode-menu-open="workModeMenuOpen" :work-mode-options="workModeOptions" + :host-workspaces="hostWorkspaces" + :current-host-workspace-id="currentHostWorkspaceId" + :host-workspace-switching="hostWorkspaceSwitching" + :workspace-kind="versioningHostMode ? 'workspace' : (dockerProjectMode ? 'project' : null)" :main-chat-idle="mainChatIdle" :active-sub-agent-count="activeSubAgentCount" :avatar-status="showStatusAvatar && messages.length > 0 ? avatarStatus : null" @@ -357,6 +361,9 @@ @select-new-conversation-type="handleSelectNewConversationType" @toggle-work-mode-menu="toggleWorkModeMenu" @change-work-mode="changeWorkMode" + @new-conversation="createNewConversation" + @switch-host-workspace="handleHostWorkspaceSwitch" + @fetch-host-workspaces="fetchHostWorkspaces" @open-goal-dialog="goalDialogOpen = true" @open-git-changes-panel="openGitChangesPanel" /> diff --git a/static/src/app/methods/message/chat.ts b/static/src/app/methods/message/chat.ts index 4eab3e62..3e778815 100644 --- a/static/src/app/methods/message/chat.ts +++ b/static/src/app/methods/message/chat.ts @@ -32,16 +32,6 @@ export const chatMethods = { return; } - const confirmed = await this.confirmAction({ - title: '压缩对话', - message: '确定要压缩当前对话记录吗?较早的消息会被折叠并生成压缩摘要,当前对话 ID 保持不变。', - confirmText: '压缩', - cancelText: '取消' - }); - if (!confirmed) { - return; - } - this.compressing = true; this.compressionInProgress = true; this.compressionConversationId = this.currentConversationId; diff --git a/static/src/components/input/InputComposer.vue b/static/src/components/input/InputComposer.vue index bba0131c..3e7fb9fe 100644 --- a/static/src/components/input/InputComposer.vue +++ b/static/src/components/input/InputComposer.vue @@ -701,6 +701,9 @@ const emit = defineEmits([ 'select-new-conversation-type', 'toggle-work-mode-menu', 'change-work-mode', + 'new-conversation', + 'switch-host-workspace', + 'fetch-host-workspaces', 'stop-all-sub-agents' ]); @@ -783,6 +786,20 @@ const props = defineProps<{ workModeMenuOpen?: boolean; /** 运行模式选项 */ workModeOptions?: Array<{ value: string; label: string; description: string }>; + /** host/docker 项目模式下的工作区列表(/菜单「切换工作区」数据源) */ + hostWorkspaces?: Array<{ + workspace_id: string; + label: string; + path?: string; + is_current?: boolean; + running_task_count?: number; + }>; + /** 当前工作区 id */ + currentHostWorkspaceId?: string; + /** 工作区切换进行中 */ + hostWorkspaceSwitching?: boolean; + /** 工作区文案类型:workspace=host 工作区;project=docker 项目;null=不显示工作区切换项 */ + workspaceKind?: 'workspace' | 'project' | null; /** * 主对话是否空闲(composerBusy 可能是 true,但只是因为后台子智能体在跑)。 * 多智能体模式下,若主对话空闲,输入栏应处于“正常发送”状态,不是“停止”状态。 @@ -818,7 +835,11 @@ type SlashMenuMode = | 'permission' | 'execution' | 'model' - | 'run-mode'; + | 'run-mode' + | 'network' + | 'work-mode' + | 'workspace' + | 'conversation-type'; type SlashMenuItem = { id: string; label: string; @@ -842,6 +863,10 @@ const slashMenuTransitioning = ref(false); const slashHighlightTop = ref(4); /** 手动滚动后切到鼠标 hover 模式,隐藏 JS 高亮条 */ const slashManualScrolled = ref(false); +/** skills 子菜单进入来源:typed=键入 // 直达(跟随斜杠数退回 root);menu=root 菜单点入(不跟随) */ +const slashSkillsEntrySource = ref<'typed' | 'menu' | null>(null); +/** Escape 从 // 直达的 skills 退回 root 后,抑制本次 token 存续期间的自动重进 */ +const slashDoubleSlashSuppressed = ref(false); let slashAnimId: number | null = null; /** 动画真正进行中(比 animId 更可靠,可覆盖 rAF 结束后的延迟 scroll 事件) */ let slashAnimating = false; @@ -1096,15 +1121,17 @@ const findSlashToken = () => { const { $from } = state.selection; const lineStart = $from.start(); const beforeInLine = $from.parent.textBetween(0, $from.parentOffset, '\n', '\n'); - const match = /(^|[ \n])\/([^\s/]*)$/.exec(beforeInLine); + const match = /(^|[ \n])(\/{1,2})([^\s/]*)$/.exec(beforeInLine); if (!match) return null; - const query = match[2] || ''; - const start = $from.pos - query.length - 1; + const slashes = match[2] || '/'; + const query = match[3] || ''; + const start = $from.pos - query.length - slashes.length; const end = $from.pos; return { start, end, - query + query, + doubleSlash: slashes.length === 2 }; }; @@ -1142,6 +1169,8 @@ const closeSlashMenu = () => { slashMenuParentIndex.value = 0; slashHighlightTop.value = 4; slashManualScrolled.value = false; + slashSkillsEntrySource.value = null; + slashDoubleSlashSuppressed.value = false; }; const closeFileAtMenu = () => { @@ -1435,6 +1464,13 @@ const executeSlashAction = (action: () => void) => { const rootSlashMenuItems = computed(() => { const items: SlashMenuItem[] = [ + { + id: 'new', + label: '新建对话', + description: '开始一个新的对话(new / clear)', + disabled: !props.isConnected, + action: () => emit('new-conversation') + }, { id: 'upload', label: '上传文件', @@ -1470,9 +1506,23 @@ const rootSlashMenuItems = computed(() => { { id: 'skills', label: '选择 AgentSkill', - description: '插入一个 AgentSkill 引用', + description: '插入一个 AgentSkill 引用(// 快捷直达)', mode: 'skills' }, + { + id: 'conversation-type', + label: '对话类型', + description: 'agent / multi-agent · 智能体 / 多智能体(仅新对话可切换)', + disabled: !props.isConnected, + mode: 'conversation-type' + }, + { + id: 'work-mode', + label: '切换工作模式', + description: 'plan / ask / execute · 计划 / 询问 / 执行', + disabled: !props.isConnected || props.streamingMessage, + mode: 'work-mode' + }, { id: 'theme', label: '切换主题', @@ -1540,6 +1590,15 @@ const rootSlashMenuItems = computed(() => { disabled: !props.isConnected || props.streamingMessage, mode: 'permission' }, + { + id: 'network', + label: '网络权限', + description: props.networkPermissionEnabled + ? 'restricted / full · 受限 / 完整' + : '当前环境不支持网络权限切换', + disabled: !props.isConnected || props.streamingMessage || !props.networkPermissionEnabled, + mode: 'network' + }, ...(props.executionModeEnabled ? [ { @@ -1551,6 +1610,17 @@ const rootSlashMenuItems = computed(() => { } as SlashMenuItem ] : []), + ...(props.workspaceKind + ? [ + { + id: 'workspace', + label: props.workspaceKind === 'project' ? '切换项目' : '切换工作区', + description: 'workspace / project · 工作区 / 项目', + disabled: !props.isConnected || props.streamingMessage, + mode: 'workspace' + } as SlashMenuItem + ] + : []), { id: 'versioning', label: '版本控制', @@ -1686,6 +1756,56 @@ const runModeSlashMenuItems = computed(() => { })); }); +const networkSlashMenuItems = computed(() => { + const options = props.networkPermissionOptions || []; + const current = String(props.currentNetworkPermission || ''); + return options.map((opt) => ({ + id: `network:${opt.value}`, + label: opt.label, + description: `${opt.value === current ? '当前 · ' : ''}${opt.description || ''}`, + action: () => emit('change-network-permission', opt.value) + })); +}); + +const workModeSlashMenuItems = computed(() => { + const options = props.workModeOptions || []; + const current = String(props.currentWorkMode || ''); + return options.map((opt) => ({ + id: `workmode:${opt.value}`, + label: opt.label, + description: `${opt.value === current ? '当前 · ' : ''}${opt.description || ''}`, + action: () => emit('change-work-mode', opt.value) + })); +}); + +const workspaceSlashMenuItems = computed(() => { + const list = Array.isArray(props.hostWorkspaces) ? props.hostWorkspaces : []; + const current = String(props.currentHostWorkspaceId || ''); + const switching = !!props.hostWorkspaceSwitching; + return list.map((ws) => ({ + id: `workspace:${ws.workspace_id}`, + label: ws.label, + description: `${ws.workspace_id === current ? '当前 · ' : ''}${ws.path || ''}`, + disabled: switching || ws.workspace_id === current, + action: () => emit('switch-host-workspace', ws.workspace_id) + })); +}); + +const conversationTypeSlashMenuItems = computed(() => { + // 已打开对话时类型不可变(类型是创建时确定的对话属性),子菜单项整体锁定 + const locked = agentTypeLocked.value; + const current = String(props.newConversationType || 'agent'); + return agentTypeOptions.map((opt) => ({ + id: `convtype:${opt.value}`, + label: opt.label, + description: locked + ? '对话类型创建后不可变,仅新对话可切换' + : `${opt.value === current ? '当前 · ' : ''}${opt.description || ''}`, + disabled: locked, + action: () => emit('select-new-conversation-type', opt.value as 'agent' | 'multi_agent') + })); +}); + const skillSlashMenuItems = computed(() => filteredSkills.value.map((skill) => ({ id: `skill:${skill.path}`, @@ -1714,6 +1834,18 @@ const activeSlashMenuItems = computed(() => { if (slashMenuMode.value === 'run-mode') { return runModeSlashMenuItems.value; } + if (slashMenuMode.value === 'network') { + return networkSlashMenuItems.value; + } + if (slashMenuMode.value === 'work-mode') { + return workModeSlashMenuItems.value; + } + if (slashMenuMode.value === 'workspace') { + return workspaceSlashMenuItems.value; + } + if (slashMenuMode.value === 'conversation-type') { + return conversationTypeSlashMenuItems.value; + } return rootSlashMenuItems.value; }); @@ -1724,6 +1856,11 @@ const slashMenuAriaLabel = computed(() => { if (slashMenuMode.value === 'execution') return '执行环境选项'; if (slashMenuMode.value === 'model') return '模型选项'; if (slashMenuMode.value === 'run-mode') return '思考模式选项'; + if (slashMenuMode.value === 'network') return '网络权限选项'; + if (slashMenuMode.value === 'work-mode') return '工作模式选项'; + if (slashMenuMode.value === 'workspace') + return props.workspaceKind === 'project' ? '项目选项' : '工作区选项'; + if (slashMenuMode.value === 'conversation-type') return '对话类型选项'; return '快捷指令'; }); @@ -1733,6 +1870,11 @@ const slashMenuEmptyText = computed(() => { if (slashMenuMode.value === 'permission') return '无可用权限模式'; if (slashMenuMode.value === 'execution') return '无可用执行环境'; if (slashMenuMode.value === 'model') return '无可用模型'; + if (slashMenuMode.value === 'network') return '无可用网络权限选项'; + if (slashMenuMode.value === 'work-mode') return '无可用工作模式'; + if (slashMenuMode.value === 'workspace') + return props.workspaceKind === 'project' ? '暂无其他项目' : '暂无其他工作区'; + if (slashMenuMode.value === 'conversation-type') return '无可用对话类型'; return '没有匹配的指令'; }); @@ -1782,6 +1924,24 @@ const refreshSkillSlashState = () => { skillSlashActiveIndex.value = 0; } skillSlashQuery.value = token.query; + // 「//」直达 skills 子菜单;仅键入来源跟随斜杠数量变化(菜单点入不受影响) + if ( + token.doubleSlash && + slashMenuMode.value === 'root' && + !slashDoubleSlashSuppressed.value + ) { + slashMenuMode.value = 'skills'; + slashSkillsEntrySource.value = 'typed'; + skillSlashActiveIndex.value = 0; + } else if ( + !token.doubleSlash && + slashMenuMode.value === 'skills' && + slashSkillsEntrySource.value === 'typed' + ) { + slashMenuMode.value = 'root'; + slashSkillsEntrySource.value = null; + skillSlashActiveIndex.value = 0; + } if (slashMenuMode.value === 'skills') { void loadSkills(); } @@ -1837,9 +1997,13 @@ const selectSlashMenuItem = (index = skillSlashActiveIndex.value) => { slashMenuParentIndex.value = index; slashMenuMode.value = item.mode; skillSlashActiveIndex.value = 0; + slashSkillsEntrySource.value = item.mode === 'skills' ? 'menu' : null; if (item.mode === 'skills') { void loadSkills(); } + if (item.mode === 'workspace') { + emit('fetch-host-workspaces'); + } scrollSkillSlashSelectionIntoMiddle(); return; } @@ -1889,6 +2053,11 @@ const handleSlashMenuKeydown = (event: KeyboardEvent): boolean => { if (event.key === 'Escape') { event.preventDefault(); if (slashMenuMode.value !== 'root') { + // 从 // 直达的 skills 退回 root 时,抑制本次 token 的自动重进(否则下一帧又跳回 skills) + if (slashMenuMode.value === 'skills' && slashSkillsEntrySource.value === 'typed') { + slashDoubleSlashSuppressed.value = true; + } + slashSkillsEntrySource.value = null; slashMenuMode.value = 'root'; skillSlashActiveIndex.value = Math.min( slashMenuParentIndex.value,