From f1773dde4f898099b14ec4d43afd94b3773086f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sun, 2 Aug 2026 10:43:30 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E8=B5=B0=E6=9F=A5=20P2+UX(status=20union=20+=20ApprovalPopup?= =?UTF-8?q?=20hack=E6=B6=88=E9=99=A4/=E6=8B=96=E5=8A=A8JS=20API/Esc)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - status union: AiMessage 加 status 字面量联合,删 AiChat/MessageList AiMessageWithStatus 重复+cast - ApprovalPopup: 删 _placeholder hack(toolDisplayName 提纯函数) + dispatchApprovalIPC 共享分派消除三套重复 + always 按钮语义对齐 + closeWithFallback 兜底 + 拖动 JS API startDragging(避子元素覆盖) + Esc 快捷键 --- src/api/types.ts | 9 ++ src/components/AiChat.vue | 8 +- src/components/ai/MessageList.vue | 9 +- src/composables/ai/useAiApproval.ts | 10 +- src/composables/ai/useToolApproval.ts | 29 +++++ src/composables/ai/useToolCardHeader.ts | 161 +++++++++++++----------- src/views/ApprovalPopup.vue | 144 ++++++++++++++------- 7 files changed, 234 insertions(+), 136 deletions(-) diff --git a/src/api/types.ts b/src/api/types.ts index cc7d23b..7ae5dbe 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -466,6 +466,15 @@ export interface AiMessage { role: 'user' | 'assistant' | 'tool' | 'system' content: string isError?: boolean + /** + * 消息上下文状态(F-15 手动上下文管理)。 + * null | undefined | 'active' → 正常消息 + * 'archived_segment' → 清空上下文归档标记(前端折叠成"已归档 N 条"分隔条) + * 'compressed' → 压缩标记(后端不再发给 LLM;前端照常展开) + * 'truncated' → switchConversation 已过滤,前端不渲染 + * 字面量联合:改 status 取值时编译器拦截,避免散布 string cast 失去类型校验。 + */ + status?: 'active' | 'archived_segment' | 'compressed' | 'truncated' | null toolCalls?: AiToolCallInfo[] /** 生成该消息的 model(仅 assistant 消息,历史消息从 DB 读) */ model?: string diff --git a/src/components/AiChat.vue b/src/components/AiChat.vue index 9140810..1037610 100644 --- a/src/components/AiChat.vue +++ b/src/components/AiChat.vue @@ -548,19 +548,15 @@ async function toggleAlwaysOnTop() { // (零行为变更,store 单例 + pendingMaxRounds 模块级 ref 共享,toast 经 emit 转父)。 // ── F-15 阶段2: 手动上下文管理(清空 / 压缩) ── -// ChatMessage.status 渲染语义(types.ts 未含 status 字段,前端经 cast 读写闭环): +// ChatMessage.status 渲染语义(types.ts AiMessage.status 字面量联合定义): // null | undefined | 'active' → 正常消息(现状) // 'archived_segment' → 清空上下文归档标记 → 折叠成"已归档 N 条"分隔条 // 'compressed' → 压缩标记 → 折叠成"已压缩(展开看摘要)"块 // 'truncated' → switchConversation 已过滤,不到这里 // 连续同 status 合并一条分隔条(避免每条都折叠条 → N 条归档只显 1 条分隔条)。 -interface AiMessageWithStatus extends AiMessage { - status?: string | null -} // 第四批抽离: 消息列表分段(messageSegments/renderItems)+ 折叠段展开(expandedSegmentIds/ // toggleSegment)+ MessageSegment/RenderItem 类型 全部随 MessageList 子组件迁移(零行为变更)。 -// AiMessageWithStatus 保留:hasActiveMessages(清空/压缩按钮启用判定,TopBar 用)仍需 cast 读 status。 /** * 是否存在"活跃消息"(可清空/可压缩)— 控制清空/压缩按钮启用。 @@ -569,7 +565,7 @@ interface AiMessageWithStatus extends AiMessage { */ const hasActiveMessages = computed(() => store.state.messages.some(m => { - const s = (m as AiMessageWithStatus).status + const s = m.status return s !== 'archived_segment' && s !== 'compressed' }), ) diff --git a/src/components/ai/MessageList.vue b/src/components/ai/MessageList.vue index 72565c8..0ef5ba4 100644 --- a/src/components/ai/MessageList.vue +++ b/src/components/ai/MessageList.vue @@ -510,15 +510,12 @@ async function onInstallObscura(): Promise { } // ── F-15 阶段2: 消息分段(归档段折叠) ── -// ChatMessage.status 渲染语义(types.ts 未含 status 字段,前端经 cast 读写闭环): +// ChatMessage.status 渲染语义(types.ts AiMessage.status 字面量联合定义): // null | undefined | 'active' → 正常消息(现状) // 'archived_segment' → 清空上下文归档标记 → 折叠成"已归档 N 条"分隔条 // 'compressed' → 压缩标记 → 走 normal 照常展开(界面不藏消息) // 'truncated' → switchConversation 已过滤,不到这里 // 连续同 status 合并一条分隔条(避免每条都折叠条 → N 条归档只显 1 条分隔条)。 -interface AiMessageWithStatus extends AiMessage { - status?: string | null -} type MessageSegment = | { kind: 'normal'; msg: AiMessage } @@ -531,7 +528,7 @@ const messageSegments = computed(() => { let i = 0 while (i < msgs.length) { const m = msgs[i] - const status = (m as AiMessageWithStatus).status + const status = m.status // F-15 改(2026-06-18):compressed 消息不再折叠,走 normal 照常展开渲染(用户诉求:压缩 // 只影响后端 LLM context 省 token,界面不藏消息)。仅 archived_segment(清空归档)折叠。 // 摘要由后端插首位 system 消息携带,模板 role==='system' 分支置顶渲染。 @@ -539,7 +536,7 @@ const messageSegments = computed(() => { // 连续 archived_segment 合并 const group: AiMessage[] = [] const key = `seg-${m.id}` - while (i < msgs.length && (msgs[i] as AiMessageWithStatus).status === 'archived_segment') { + while (i < msgs.length && msgs[i].status === 'archived_segment') { group.push(msgs[i]) i++ } diff --git a/src/composables/ai/useAiApproval.ts b/src/composables/ai/useAiApproval.ts index aad6e15..a446ffa 100644 --- a/src/composables/ai/useAiApproval.ts +++ b/src/composables/ai/useAiApproval.ts @@ -18,6 +18,7 @@ import { state } from '@/stores/ai' import { t } from '@/i18n/i18n-helpers' import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream' import { findToolCall } from './aiShared' +import { dispatchApprovalIPC } from './useToolApproval' /** * F-260616-06: 审批按钮防抖守卫——同一 id 短期多次点击只发一次 IPC。 @@ -65,9 +66,12 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?: const approvalConvId = state.activeConversationId || undefined resetStreamWatchdog(approvalConvId) try { - if (isPathKind) { - // path 类:decision 缺省兜底 deny(调用方未传时安全侧拒绝,不静默放行)。 - await aiApi.authorizeDir(toolCallId, decision ?? 'deny') + // IPC 分派规则(path→authorizeDir / risk→approve)抽离至 dispatchApprovalIPC(useToolApproval.ts), + // 与 ApprovalPopup.runApproval 同源,消除「同语义两套逻辑」漂移。 + // tc 为 null(findToolCall 未找到,时序竞态)时 isPathKind=false,走 risk 兜底 approve 分支 + // (与原实现等价:!!tc && tc.kind==='path' 短路)。 + if (tc) { + await dispatchApprovalIPC(tc, approved, decision) } else { await aiApi.approve(toolCallId, approved) } diff --git a/src/composables/ai/useToolApproval.ts b/src/composables/ai/useToolApproval.ts index cd40973..6530471 100644 --- a/src/composables/ai/useToolApproval.ts +++ b/src/composables/ai/useToolApproval.ts @@ -10,6 +10,7 @@ import { ref, reactive, computed, watch, onBeforeUnmount, type Ref } from 'vue' import { useI18n } from 'vue-i18n' import { useConfirm } from '@/composables/useConfirm' +import { aiApi } from '@/api' import type { AiToolCallInfo } from '@/api/types' /** @@ -26,6 +27,34 @@ const HIGH_RISK_TOOLS = new Set([ 'http_request', ]) +/** + * 审批 IPC 分派(共享):按 tc.kind 决定调 aiApi.authorizeDir(path 类)或 aiApi.approve(risk 类)。 + * + * 抽离自 useAiApproval.approveToolCall 与 ApprovalPopup.onApprove/onReject 两处重复实现, + * 供两调用方共用同一分派规则,消除「同语义两套逻辑」漂移风险。 + * + * - kind='path'(路径授权挂起):调 aiApi.authorizeDir(toolCallId, decision)。decision 必传 + * ('once'|'session'|'always'|'deny'),由调用方按按钮语义给出;缺省兜底 'deny'(安全侧拒绝)。 + * - 其余(risk/缺省,普通 RiskLevel 审批):调 aiApi.approve(toolCallId, approved)。 + * approved=true 放行,false 拒绝。 + * + * 不含状态机(loading/防抖/超时兜底)——那些是调用方各自的责任(ToolCard 走 useToolApproval + * 状态机 + emit;ApprovalPopup 走 processingId 局部 ref;useAiApproval 走 _pendingApprovalIds 防抖)。 + * 本函数仅做「kind → IPC」一层映射,纯 IPC 调用,无 store 依赖,可在任意窗口/上下文调用。 + * + * @returns invoke 的 Promise(调用方可 await 接力错误处理) + */ +export function dispatchApprovalIPC( + tc: AiToolCallInfo, + approved: boolean, + decision?: 'once' | 'session' | 'always' | 'deny', +): Promise { + if (tc.kind === 'path') { + return aiApi.authorizeDir(tc.id, decision ?? 'deny') + } + return aiApi.approve(tc.id, approved) +} + export function useToolApproval(tc: Ref, emit: any) { const { t } = useI18n() const { confirmState, confirmDialog, answerConfirm } = useConfirm() diff --git a/src/composables/ai/useToolCardHeader.ts b/src/composables/ai/useToolCardHeader.ts index 79585e8..ee66474 100644 --- a/src/composables/ai/useToolCardHeader.ts +++ b/src/composables/ai/useToolCardHeader.ts @@ -2,13 +2,19 @@ * ToolCard 头部显示逻辑(从 ToolCard.vue 抽离)。 * * 职责:工具显示名 + 审批参数值的「语义化回显」(裸 id → 项目名/任务标题), - * 以及 http url → host 精简。这些依赖 project store(响应式),故以 composable 形式封装, - * 传入 tc 即得 { toolDisplayName, displayArgValue }。 + * 以及 http url → host 精简。 + * + * 两层导出: + * - `toolDisplayName(tc)` 模块级纯函数(i18n 走 i18n-helpers 全局 t,project store 走单例): + * 无 setup 上下文依赖,可被 ToolCard(经 composable 包装)与 ApprovalPopup(独立 WebviewWindow, + * 无 composable 上下文)共用。抽离自原 composable 内部函数,消除 ApprovalPopup 的 _placeholder hack。 + * - `useToolCardHeader(getTc)` composable:封装 displayArgValue/argsEntries(依赖 getTc 取当前 tc, + * 响应式由 store 保证),返回 { toolDisplayName(转发模块级), displayArgValue, argsEntries }。 * * 不含响应式状态(纯函数 + store 读),逻辑与原 ToolCard.vue 内联实现一字等价。 */ import { useProjectStore } from '@/stores/project' -import { useI18n } from 'vue-i18n' +import { t as tGlobal } from '@/i18n/i18n-helpers' import { formatToolName, shortCmd, @@ -38,32 +44,83 @@ const TASK_ID_TOOL_ARG: Record = { run_workflow: 'task_id', } +/** + * 按 id 查项目名(覆盖活跃 + 回收站):审批 delete/restore/purge 各阶段都可能引用项目, + * 故先查 projects 再查 deletedProjects,找到即返回(提前退出),无则返回 undefined(调用方走 fallback)。 + * 复用 project store 已加载列表(useProjectStore 单例缓存,每次调用廉价),不新增网络请求;响应式由 store 数组保证。 + * 函数体内调 useProjectStore()(非模块级缓存)以保留原 lazy-init 时机,避免模块 import 时创建 store 改变初始化顺序。 + */ +function projectNameById(id: string): string | undefined { + const projectStore = useProjectStore() + return projectStore.projects.find(p => p.id === id)?.name + ?? projectStore.deletedProjects.find(p => p.id === id)?.name +} + +/** + * 按 id 查任务标题:审批 advance_task/run_workflow 的 task_id 时,把裸 UUID 转标题。 + * 复用 project store 已加载的 state.tasks(仅当前项目范围,useProjectStore 单例),不新增网络请求。 + */ +function taskNameById(id: string): string | undefined { + return useProjectStore().tasks.find(tk => tk.id === id)?.title +} + +/** + * 工具显示名称(含目标摘要);文件类按 path,项目类按 id 解析项目名,create_* 用固定文案。 + * + * 模块级纯函数:无 setup 依赖(i18n 走 i18n-helpers 的全局 t,project store 走单例), + * 故可被 ToolCard(经 useToolCardHeader 包装)与 ApprovalPopup(独立 WebviewWindow,无 store/emit + * 上下文)共用,消除 ApprovalPopup 原 `_placeholder` 占位 hack(构造空 AiToolCallInfo 喂 composable)。 + */ +export function toolDisplayName(tc: AiToolCallInfo): string { + const p = argString(tc.args, 'path') + switch (tc.name) { + case 'read_file': return p ? `${tGlobal('aiTool.readPrefix')} ${shortPath(p)}` : tGlobal('aiTool.readFallback') + case 'list_directory': return p ? `${tGlobal('aiTool.dirPrefix')} ${shortPath(p)}` : tGlobal('aiTool.dirFallback') + case 'write_file': return p ? `${tGlobal('aiTool.writePrefix')} ${shortPath(p)}` : tGlobal('aiTool.writeFallback') + case 'grep': { + // grep 头部:搜索根 + pattern(折叠态可见搜索什么) + const pat = argString(tc.args, 'pattern') + const head = p ? `${tGlobal('aiTool.grepPrefix')} ${shortPath(p)}` : tGlobal('aiTool.grepFallback') + return pat ? `${head} 「${pat}」` : head + } + case 'delete_project': + case 'restore_project': + case 'purge_project': + case 'update_project': { + const idKey = PROJECT_ID_TOOL_ARG[tc.name] + const id = idKey ? argString(tc.args, idKey) : '' + const name = id ? projectNameById(id) : '' + const prefixKey = `${tc.name.replace('_project', '')}Prefix` as 'deletePrefix' | 'restorePrefix' | 'purgePrefix' | 'updatePrefix' + const fallbackKey = `${tc.name.replace('_project', '')}Fallback` as 'deleteFallback' | 'restoreFallback' | 'purgeFallback' | 'updateFallback' + return name ? `${tGlobal('aiTool.' + prefixKey)}「${name}」` : tGlobal('aiTool.' + fallbackKey) + } + case 'create_task': return tGlobal('aiTool.createTask') + case 'create_project': return tGlobal('aiTool.createProject') + case 'run_command': { + const cmd = argString(tc.args, 'command') + return cmd ? `$ ${shortCmd(cmd)}` : formatToolName(tc.name) + } + case 'http_request': { + const method = argString(tc.args, 'method') || 'GET' + const url = argString(tc.args, 'url') + const host = httpHost(url) + return host ? `${method} ${host}` : tGlobal('aiTool.httpRequestFallback') + } + case 'fetch_url': { + // fetch_url 头部:获取 host(对齐 http_request 精简模式,折叠态一眼知抓哪个站点) + const url = argString(tc.args, 'url') + const host = httpHost(url) + return host ? `${tGlobal('aiTool.fetchUrlPrefix')} ${host}` : tGlobal('aiTool.fetchUrlFallback') + } + default: return formatToolName(tc.name) + } +} + /** * ToolCard 头部/审批参数显示辅助。 * @param getTc 取当前 tc 的函数(避免 props.tc 在 composable 调用时机脱节,由调用方透传) */ export function useToolCardHeader(getTc: () => AiToolCallInfo) { - const t = useI18n().t - const projectStore = useProjectStore() - - /** - * 按 id 查项目名(覆盖活跃 + 回收站):审批 delete/restore/purge 各阶段都可能引用项目, - * 故先查 projects 再查 deletedProjects,找到即返回(提前退出),无则返回 undefined(调用方走 fallback)。 - * 复用 project store 已加载列表,不新增网络请求;响应式由 store 数组保证。 - */ - function projectNameById(id: string): string | undefined { - return projectStore.projects.find(p => p.id === id)?.name - ?? projectStore.deletedProjects.find(p => p.id === id)?.name - } - - /** - * 按 id 查任务标题:审批 advance_task/run_workflow 的 task_id 时,把裸 UUID 转标题。 - * 复用 project store 已加载的 state.tasks(仅当前项目范围),不新增网络请求。 - */ - function taskNameById(id: string): string | undefined { - return projectStore.tasks.find(tk => tk.id === id)?.title - } - /** * 审批参数值展示(AR-3):对项目类工具的 id/project_id 特化——优先回显项目名,查不到则显示 * 「项目已不存在」提示 + 原 id。非项目工具或其他 key 走通用 formatArgValue。 @@ -75,8 +132,8 @@ export function useToolCardHeader(getTc: () => AiToolCallInfo) { const id = typeof arg.val === 'string' ? arg.val : '' if (id) { const name = projectNameById(id) - if (name) return t('aiTool.projectLabel', { name }) - return t('aiTool.projectIdNotFound', { id }) + if (name) return tGlobal('aiTool.projectLabel', { name }) + return tGlobal('aiTool.projectIdNotFound', { id }) } } // UX-260618-14:advance_task/run_workflow 的 task_id → 任务标题 @@ -85,61 +142,13 @@ export function useToolCardHeader(getTc: () => AiToolCallInfo) { const id = typeof arg.val === 'string' ? arg.val : '' if (id) { const name = taskNameById(id) - if (name) return t('aiTool.taskLabel', { name }) - return t('aiTool.taskIdNotFound', { id }) + if (name) return tGlobal('aiTool.taskLabel', { name }) + return tGlobal('aiTool.taskIdNotFound', { id }) } } return formatArgValue(arg.val) } - /** - * 工具显示名称(含目标摘要);文件类按 path,项目类按 id 解析项目名,create_* 用固定文案。 - */ - function toolDisplayName(tc: AiToolCallInfo): string { - const p = argString(tc.args, 'path') - switch (tc.name) { - case 'read_file': return p ? `${t('aiTool.readPrefix')} ${shortPath(p)}` : t('aiTool.readFallback') - case 'list_directory': return p ? `${t('aiTool.dirPrefix')} ${shortPath(p)}` : t('aiTool.dirFallback') - case 'write_file': return p ? `${t('aiTool.writePrefix')} ${shortPath(p)}` : t('aiTool.writeFallback') - case 'grep': { - // grep 头部:搜索根 + pattern(折叠态可见搜索什么) - const pat = argString(tc.args, 'pattern') - const head = p ? `${t('aiTool.grepPrefix')} ${shortPath(p)}` : t('aiTool.grepFallback') - return pat ? `${head} 「${pat}」` : head - } - case 'delete_project': - case 'restore_project': - case 'purge_project': - case 'update_project': { - const idKey = PROJECT_ID_TOOL_ARG[tc.name] - const id = idKey ? argString(tc.args, idKey) : '' - const name = id ? projectNameById(id) : '' - const prefixKey = `${tc.name.replace('_project', '')}Prefix` as 'deletePrefix' | 'restorePrefix' | 'purgePrefix' | 'updatePrefix' - const fallbackKey = `${tc.name.replace('_project', '')}Fallback` as 'deleteFallback' | 'restoreFallback' | 'purgeFallback' | 'updateFallback' - return name ? `${t('aiTool.' + prefixKey)}「${name}」` : t('aiTool.' + fallbackKey) - } - case 'create_task': return t('aiTool.createTask') - case 'create_project': return t('aiTool.createProject') - case 'run_command': { - const cmd = argString(tc.args, 'command') - return cmd ? `$ ${shortCmd(cmd)}` : formatToolName(tc.name) - } - case 'http_request': { - const method = argString(tc.args, 'method') || 'GET' - const url = argString(tc.args, 'url') - const host = httpHost(url) - return host ? `${method} ${host}` : t('aiTool.httpRequestFallback') - } - case 'fetch_url': { - // fetch_url 头部:获取 host(对齐 http_request 精简模式,折叠态一眼知抓哪个站点) - const url = argString(tc.args, 'url') - const host = httpHost(url) - return host ? `${t('aiTool.fetchUrlPrefix')} ${host}` : t('aiTool.fetchUrlFallback') - } - default: return formatToolName(tc.name) - } - } - /** 审批参数键值对(头部 toolArgsEntries 透传,本 composable 仅暴露便于复用) */ function argsEntries() { return toolArgsEntries(getTc().args) diff --git a/src/views/ApprovalPopup.vue b/src/views/ApprovalPopup.vue index 1eecd47..8548617 100644 --- a/src/views/ApprovalPopup.vue +++ b/src/views/ApprovalPopup.vue @@ -4,7 +4,7 @@ - 无边框(decorations:false) + alwaysOnTop + skipTaskbar → 系统级可见 - 紫色主题自定义样式(非 OS notification 默认) - 哑渲染器:监听主窗口推送的 approval-popup-update 事件,不重复监听 AI 事件 - - 操作按钮直调 aiApi.approve/authorizeDir(主窗口 listener 收后端 emit → 推新快照) + - 操作按钮走 dispatchApprovalIPC(按 tc.kind 分派 aiApi.approve/authorizeDir;主窗口 listener 收后端 emit → 推新快照) - 点击「打开审批面板」emit approval-popup-activate → 主窗口聚焦 + togglePanel - 拖动条:.popup-drag(region CSS,WebviewWindow dataDrag 守卫)实现窗口拖动 - 自动定位屏幕右上角(同 ApprovalOverlay 视觉一致) --> @@ -42,7 +42,7 @@ >