diff --git a/src/api/types.ts b/src/api/types.ts index 4d56118..c47c3c0 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -36,11 +36,17 @@ export interface CreateIdeaInput { // 项目 // ============================================================ +// ProjectRecord.status union 类型 — 值集对齐后端 ProjectStatus serde (df-types/src/types.rs:90) +// snake_case: planning | in_progress | testing | releasing | completed | paused | cancelled +// 注:前端 constants/dashboard 的 'active' 是 UI 虚拟派生态(非 record 落库值),不进此 union。 +export type ProjectStatus = + | 'planning' | 'in_progress' | 'testing' | 'releasing' | 'completed' | 'paused' | 'cancelled' + export interface ProjectRecord { id: string name: string description: string - status: string // planning | in_progress | testing | releasing | completed | paused + status: ProjectStatus idea_id: string | null /** 绑定的本地代码目录绝对路径(null=未绑定) */ path: string | null @@ -84,12 +90,19 @@ export interface PromotionResult { // 任务 // ============================================================ +// TaskRecord.status union 类型 — 值集对齐后端 TaskStatus serde (df-types/src/types.rs:131) +// snake_case: todo | in_progress | in_review | testing | done | blocked | cancelled +// 注:constants/project.ts 的 mapLegacyStatus 兜底老 DB 旧态(completed/review_ready/merged/abandoned), +// 仅在 taskStatusLabel/taskStatusClass 辅助函数内映射,record.status 本身经后端 is_valid 拦截无旧态。 +export type TaskStatus = + | 'todo' | 'in_progress' | 'in_review' | 'testing' | 'done' | 'blocked' | 'cancelled' + export interface TaskRecord { id: string project_id: string title: string description: string - status: string // todo | in_progress | in_review | testing | done | blocked | cancelled + status: TaskStatus priority: number // 0=critical, 1=high, 2=medium, 3=low branch_name: string | null assignee: string | null @@ -121,11 +134,16 @@ export interface CreateTaskInput { // 工作流 // ============================================================ +// WorkflowRecord.status union 类型 — 值集对齐后端 WorkflowStatus serde (df-types/src/types.rs:195) +// snake_case: pending | running | paused | completed | failed | cancelled +export type WorkflowStatus = + | 'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled' + export interface WorkflowRecord { id: string name: string dag_json: string - status: string // running | completed | failed | cancelled + status: WorkflowStatus triggered_by: string | null project_id: string | null task_id: string | null @@ -320,12 +338,17 @@ export interface AiMessage { timestamp: number } +// AiToolCallInfo.status union 类型 — 前端构建(后端无对应 struct,经 audit log/stream event 组装)。 +// 值集核验:grep src/composables/ai + src/components 实际使用点 = {running,pending_approval,completed,rejected}。 +// 前端为权威源(无后端 enum 约束),保持现状仅抽 alias 便于复用。 +export type AiToolCallStatus = 'running' | 'pending_approval' | 'completed' | 'rejected' + /** 工具调用信息 */ export interface AiToolCallInfo { id: string name: string args: unknown - status: 'running' | 'pending_approval' | 'completed' | 'rejected' + status: AiToolCallStatus result?: unknown /** 审批风险说明(AiApprovalRequired 时填充) */ reason?: string diff --git a/src/composables/ai/useAiApproval.ts b/src/composables/ai/useAiApproval.ts new file mode 100644 index 0000000..13b5051 --- /dev/null +++ b/src/composables/ai/useAiApproval.ts @@ -0,0 +1,83 @@ +//! 工具审批 — approveToolCall(单条) + batchApprove(批量遍历) +//! +//! 原 useAiSend.ts 拆分(fe-arch P0-1):审批链与发送/队列链职责分离。 +//! 审批不调发送核心,仅依赖 aiShared(findToolCall)/aiApi.approve/state, +//! 是 useAiSend 三条链中最独立的闭包,故抽出独立模块。 +//! +//! 耦合: +//! - findToolCall 来自 aiShared(原 useAiEvents↔useAiSend 循环依赖已由 aiShared 打破) +//! - resetStreamWatchdog/clearStreamWatchdog 来自 useAiStream(审批执行→续生成窗口覆盖) +//! - state.queue 清空:IPC 失败即对话结束(B-32 防生成中入队消息静默丢失) +//! +//! 模块级私有: +//! - _pendingApprovalIds:审批按钮防抖守卫(同 useAiSend 原实现) + +import { aiApi } from '@/api' +import { state } from '@/stores/ai' +import { t } from '@/i18n/i18n-helpers' +import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream' +import { findToolCall } from './aiShared' + +/** + * F-260616-06: 审批按钮防抖守卫——同一 id 短期多次点击只发一次 IPC。 + * IPC 往返 <100ms,300ms 窗口足够覆盖竞态。 + */ +const _pendingApprovalIds = new Set() + +/** 工具审批:保持 pending_approval 让审批卡片可见(按钮 loading 由 ToolCard 本地 ref 持有), + * IPC 失败时改 completed+错误文案。后端回事件后由 useAiEvents 转 completed/rejected。 + * B-260616-08:不再乐观置 running——原写法让 .ai-tool-approval 整块消失(切骨架屏), + * 按钮无 loading 中间态、用户无重审入口;loading 现下沉到 ToolCard 局部 ref,语义正确。 */ +async function approveToolCall(toolCallId: string, approved: boolean) { + // F-260616-06: 防抖——同一 id 仍在处理中则跳过(竞态点击/网络慢重试) + if (_pendingApprovalIds.has(toolCallId)) return + _pendingApprovalIds.add(toolCallId) + try { + // 复用 findToolCall(反向扫描)避免重复实现查找逻辑;仅缓存引用用于 IPC 失败回滚 + const tc = findToolCall(toolCallId) + // 重启看门狗覆盖审批执行→续生成窗口(useAiEvents.ts:162 AiApprovalRequired 已 clear, + // 审批态无心跳兜底;approve 后后端要跑工具+续生成,期间任何后端异常不回则按钮永久 loading。 + // 复用通用 watchdog 而非加审批专用超时:复用同一收尾路径(onStreamTimeout 兜底复位 streaming), + // 避免新增独立计时器与状态分支) + resetStreamWatchdog() + try { + await aiApi.approve(toolCallId, approved) + } catch (e) { + // IPC 未送达:用户已点过按钮,清看门狗(避免 130s 后误触发假错误)再回滚到失败态 + clearStreamWatchdog() + state.queue = [] // B-32:IPC 失败即对话结束,清队列防生成中入队消息静默丢失 + // 仅 IPC 真失败(审批已处理/网络断)走到这里:后端工具失败已改走 emit completed,不进此分支 + // 不回滚 pending_approval(会卡死按钮),改设 completed + 错误提示,让用户知晓失败 + console.error('[AI] 审批操作未送达后端:', e) + if (tc) { + tc.status = 'completed' + tc.result = t('ai.approvalNotDelivered', { error: e instanceof Error ? e.message : String(e) }) + } + throw e + } + } finally { + _pendingApprovalIds.delete(toolCallId) + } +} + +/** 批量审批:遍历 pendingApprovals 逐个调用 approveToolCall */ +async function batchApprove(decision: 'approve' | 'reject') { + const approved = decision === 'approve' + // 快照当前待审批列表(遍历中 state.pendingApprovals 会因事件回调而缩短) + const ids = [...state.pendingApprovals].map(p => p.id) + for (const id of ids) { + try { + await approveToolCall(id, approved) + } catch { + // 单条失败不中断批量操作(已由 approveToolCall 内部回滚该条状态) + // 继续处理剩余项 + } + } +} + +export function useAiApproval() { + return { + approveToolCall, + batchApprove, + } +} diff --git a/src/composables/ai/useAiEvents.ts b/src/composables/ai/useAiEvents.ts index 26576b8..717530a 100644 --- a/src/composables/ai/useAiEvents.ts +++ b/src/composables/ai/useAiEvents.ts @@ -4,6 +4,14 @@ //! - _unlistenAiEvent / _unlistenConvChanged: 已注册的 unlistener //! - _startPromise: startListener 并发去重(防 onMounted 与 sendMessage 首发竞态重复注册) //! +//! handleEvent 结构(fe-arch P0-2 拆分后): +//! - 外围逻辑(convId 同步/路由/看门狗)留在 handleEvent +//! - 14 个 case 按域分派到 3 个 handler: +//! - handleStreamingEvent:AiTextDelta/AiAgentRound/AiHeartbeat/AiStreamRetry/AiMaxRoundsReached +//! - handleToolEvent:AiToolCallStarted/AiToolCallCompleted/AiToolAutoApproved/AiApprovalRequired/AiApprovalResult +//! - handleLifecycleEvent:AiCompleted/AiError +//! 各 handler 共享模块级 helper(state/aiShared/审批计时器/工具慢执行计时器)。 +//! //! 耦合: //! - handleEvent 调 useAiConversations.loadConversations、 //! useAiStream.{resetStreamWatchdog,clearStreamWatchdog}、本模块 flushCurrentText/findToolCall/notifyConversationChanged @@ -115,36 +123,28 @@ function isShowTokenUsage(): boolean { return appSettings.get('df-show-token-usage', false) } -/** 后端事件分发:按 conversation_id 路由,流式累积文本,工具状态流转,看门狗联动 */ -export function handleEvent(event: AiChatEvent) { - const convId = event.conversation_id - // 首次收到事件时同步当前对话 id(后端自动建对话的场景) - // 同步写 appSettings(SQLite):刷新页面后 loadConversations 据此恢复上次会话 - if (convId && !state.activeConversationId) { - state.activeConversationId = convId - void appSettings.set('df-ai-active-conv', convId) - } - // 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误时刷新侧边栏 - const isCurrent = !convId || convId === state.activeConversationId - if (!isCurrent) { - if (event.type === 'AiCompleted' || event.type === 'AiError') { - state.generatingConvId = null - void loadConversations() - } - return - } - // 标记正在生成的对话(完成/错误事件除外) - if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError') { - state.generatingConvId = convId - } - // 流式看门狗:活跃事件(delta/工具/新轮/审批结果)重置;审批等待/完成/错误在 case 内 clear - if (!NO_RESET_WATCHDOG.has(event.type)) { - resetStreamWatchdog() - } +// ─── 事件域 handler(从原 handleEvent switch 抽出,按域分组)──────────────────────────── +// +// 拆分策略:策略 C(抽 case 体为函数)+ 按域分组(streaming/tool/lifecycle)。 +// handleEvent 保留外围逻辑(convId 同步/路由/看门狗),switch 改为按 event.type 分派到 +// 对应域 handler。各 handler 共享模块级 helper(state/aiShared/审批计时器/工具慢执行计时器)。 +// 零行为变更:逻辑等价搬运,case 体一字未改。 +// +// 域分组: +// - streaming 流式:AiTextDelta / AiAgentRound / AiHeartbeat / AiStreamRetry / AiMaxRoundsReached +// - tool 工具:AiToolCallStarted / AiToolCallCompleted / AiToolAutoApproved / AiApprovalRequired / AiApprovalResult +// - lifecycle 生命周期:AiCompleted / AiError +// +// 返回值:true=已处理(命中域内 case);false=未命中(供 handleEvent 兜底/未来扩展) +// 例外:AiMaxRoundsReached 在 streaming 域(语义属流式中断信号),但 lifecycle 域收尾时 +// 需置 pendingMaxRounds=false —— 该 ref 为模块级,跨域共享无碍。 + +/** streaming 域:流式文本累积/新轮/心跳/重试/max 轮暂停 */ +function handleStreamingEvent(event: AiChatEvent): boolean { switch (event.type) { case 'AiTextDelta': state.currentText += event.delta - break + return true case 'AiAgentRound': { // Agent 循环新一轮:保存当前文本到上一条 assistant 消息,新建空 assistant 消息 @@ -162,12 +162,12 @@ export function handleEvent(event: AiChatEvent) { // 此时实际轮次尚未推进(run_agentic_loop 入口 iteration=0),不覆盖,避免审批通过瞬间 // 进度条 2→0 闪烁;下一轮真正的 round>0 事件到达时再更新。 if (event.round > 0) state.agentRound = event.round - break + return true } case 'AiHeartbeat': // 心跳事件:仅维持看门狗(已在上方 reset),无需额外处理;显式 case 防 switch 穿透 - break + return true case 'AiStreamRetry': { // UX-260618-15: 流前失败重试中——后端 emit AiStreamRetry 携带 attempt/max_attempts。 @@ -188,7 +188,7 @@ export function handleEvent(event: AiChatEvent) { timestamp: Date.now(), } as AiMessage) } - break + return true } case 'AiMaxRoundsReached': @@ -196,8 +196,16 @@ export function handleEvent(event: AiChatEvent) { // 后端已 save 落库,这里仅翻 pendingMaxRounds=true 驱动 UI 卡片;看门狗已由 // NO_RESET_WATCHDOG 跳过 reset(达 max 不计整流超时)。 pendingMaxRounds.value = true - break + return true + default: + return false + } +} + +/** tool 域:工具卡片状态流转/会话级信任/审批 */ +function handleToolEvent(event: AiChatEvent): boolean { + switch (event.type) { case 'AiToolCallStarted': { const info: AiToolCallInfo = { id: event.id, @@ -207,7 +215,7 @@ export function handleEvent(event: AiChatEvent) { } // B-260616-21: 同 tool_call_id 重复 emit Started 时(GLM anthropic_compat id 不稳), // 仅挂计时器不重复 push(对齐 startToolSlowTimer 守卫风格),防残留 running 空卡(0 行·N KB)。 - // 详 docs/02-架构设计/B-260616-21排查方案-2026-06-16.md 方案①治标(后端治本待取证)。 + // 详 docs/02-架构设计/已编号方案/B-260616-21排查方案-2026-06-16.md 方案①治标(后端治本待取证)。 if (!findToolCall(event.id)) { const lastMsg = state.messages[state.messages.length - 1] if (lastMsg && lastMsg.role === 'assistant') { @@ -217,7 +225,7 @@ export function handleEvent(event: AiChatEvent) { } // B-260616-12: 工具开始执行即挂慢执行计时器(超时仅 toast,不动 running 态) startToolSlowTimer(event.id, event.name) - break + return true } case 'AiToolCallCompleted': { @@ -230,7 +238,7 @@ export function handleEvent(event: AiChatEvent) { clearToolSlowTimer(event.id) // AE-2025-06: 工具结束(无论审批通过后执行还是被拒),清审批超时计时器 clearApprovalTimer(event.id) - break + return true } case 'AiToolAutoApproved': { @@ -238,7 +246,7 @@ export function handleEvent(event: AiChatEvent) { // 仅经事件总线桥接到 AiChat.vue 弹本地 toast(composable 无组件上下文,与 // ai-tool-slow-toast 同款中转模式)。主窗口与分离窗口各挂一份 AiChat,各自消费。 void emit('ai-tool-auto-approved-toast', { tool: event.tool, dir: event.dir }) - break + return true } case 'AiApprovalRequired': { @@ -261,7 +269,7 @@ export function handleEvent(event: AiChatEvent) { clearToolSlowTimer(event.id) // AE-2025-06: 审批等待开始→启动审批超时计时器(5min 不处理自动拒绝) startApprovalTimer(event.id, event.name) - break + return true } case 'AiApprovalResult': { @@ -281,9 +289,17 @@ export function handleEvent(event: AiChatEvent) { // B-260616-12: 审批通过→工具重新进入执行态,重启慢执行计时器 startToolSlowTimer(event.id, findToolCall(event.id)?.name || '') } - break + return true } + default: + return false + } +} + +/** lifecycle 域:整轮收尾(AiCompleted)与异常中断(AiError) */ +function handleLifecycleEvent(event: AiChatEvent): boolean { + switch (event.type) { case 'AiCompleted': { clearStreamWatchdog() clearAllToolSlowTimers() // B-260616-12: 整轮结束清全部工具慢执行计时器与已提示集合 @@ -326,7 +342,7 @@ export function handleEvent(event: AiChatEvent) { notifyConversationChanged() // 队列续发:当前完成后自动发下一条(经事件总线桥接,避免 import useAiSend 构成循环依赖) emit('ai-drain-queue', {}) - break + return true } case 'AiError': { @@ -355,11 +371,46 @@ export function handleEvent(event: AiChatEvent) { errorType: event.error_type, timestamp: Date.now(), } as AiMessage) - break + return true } + + default: + return false } } +/** 后端事件分发:按 conversation_id 路由,流式累积文本,工具状态流转,看门狗联动 */ +export function handleEvent(event: AiChatEvent) { + const convId = event.conversation_id + // 首次收到事件时同步当前对话 id(后端自动建对话的场景) + // 同步写 appSettings(SQLite):刷新页面后 loadConversations 据此恢复上次会话 + if (convId && !state.activeConversationId) { + state.activeConversationId = convId + void appSettings.set('df-ai-active-conv', convId) + } + // 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误时刷新侧边栏 + const isCurrent = !convId || convId === state.activeConversationId + if (!isCurrent) { + if (event.type === 'AiCompleted' || event.type === 'AiError') { + state.generatingConvId = null + void loadConversations() + } + return + } + // 标记正在生成的对话(完成/错误事件除外) + if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError') { + state.generatingConvId = convId + } + // 流式看门狗:活跃事件(delta/工具/新轮/审批结果)重置;审批等待/完成/错误在 case 内 clear + if (!NO_RESET_WATCHDOG.has(event.type)) { + resetStreamWatchdog() + } + // 按域分派(streaming → tool → lifecycle);未命中任一域 → 无操作(保留原 switch 无 default 的语义) + if (handleStreamingEvent(event)) return + if (handleToolEvent(event)) return + void handleLifecycleEvent(event) +} + /** 启动事件监听(幂等 + 并发去重,onMounted 与 sendMessage 首发竞态不会重复注册) */ export async function startListener() { // 已注册 → 直接复用(幂等;sendMessage 每次调用不重复注册) diff --git a/src/composables/ai/useAiSend.ts b/src/composables/ai/useAiSend.ts index 8dc4731..ec8200c 100644 --- a/src/composables/ai/useAiSend.ts +++ b/src/composables/ai/useAiSend.ts @@ -1,13 +1,12 @@ -//! 发送与队列管理 — sendMessage/approveToolCall + 队列(drain/cancel/clear) + stopChat +//! 发送与队列管理 — sendMessage/regenerate/editMessage + 队列(drain/cancel/clear) + stopChat +//! +//! 审批链(approveToolCall/batchApprove)已拆分到 useAiApproval.ts(fe-arch P0-1)。 //! //! B-260616-02 L2 发送韧性:三级降级 //! L0 normal:后端 idle → 直接发送 //! L1 queued: 后端 busy → 入队等待(≤30s 正常排队) //! L2 force: 排队>30s → 弹 confirm → 用户确认后调 ai_chat_force_send 复位残留并强制发出 //! -//! 模块级私有: -//! - _pendingApprovalIds:审批按钮防抖守卫 -//! //! 耦合: //! - sendMessage 调 useAiEvents.startListener(useAiEvents 导出但不在解构集中暴露给组件, //! 故 startListener 同时经 useAiEvents 导出,sendMessage 直接 import 调用) @@ -24,7 +23,7 @@ import type { ContentPart, AiMessage } from '@/api/types' import { t } from '@/i18n/i18n-helpers' import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream' import { startListener } from './useAiEvents' -import { nextMsgId, findToolCall, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang } from './aiShared' +import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang } from './aiShared' /// 待发送队列上限(超过抛错提示用户) const QUEUE_LIMIT = 10 @@ -51,12 +50,6 @@ const modelOverride = ref(null) // 审批计时器(startApprovalTimer/clearApprovalTimer/clearAllApprovalTimers)已下沉到 aiShared.ts // 打破 useAiEvents ↔ useAiSend 循环依赖。本模块 re-export 供组件使用。 -/** - * F-260616-06: 审批按钮防抖守卫——同一 id 短期多次点击只发一次 IPC。 - * IPC 往返 <100ms,300ms 窗口足够覆盖竞态。 - */ -const _pendingApprovalIds = new Set() - /** * 入队时记录时间戳,供 UI 展示排队时长和触发超时提示。 * queue item 扩展: 原 { text, skill? } → { text, skill?, enqueuedAt, parts? } @@ -363,56 +356,7 @@ export async function tryForceSend(confirmFn: (msg: string) => Promise) } } -/** 工具审批:保持 pending_approval 让审批卡片可见(按钮 loading 由 ToolCard 本地 ref 持有), - * IPC 失败时改 completed+错误文案。后端回事件后由 useAiEvents 转 completed/rejected。 - * B-260616-08:不再乐观置 running——原写法让 .ai-tool-approval 整块消失(切骨架屏), - * 按钮无 loading 中间态、用户无重审入口;loading 现下沉到 ToolCard 局部 ref,语义正确。 */ -async function approveToolCall(toolCallId: string, approved: boolean) { - // F-260616-06: 防抖——同一 id 仍在处理中则跳过(竞态点击/网络慢重试) - if (_pendingApprovalIds.has(toolCallId)) return - _pendingApprovalIds.add(toolCallId) - try { - // 复用 findToolCall(反向扫描)避免重复实现查找逻辑;仅缓存引用用于 IPC 失败回滚 - const tc = findToolCall(toolCallId) - // 重启看门狗覆盖审批执行→续生成窗口(useAiEvents.ts:162 AiApprovalRequired 已 clear, - // 审批态无心跳兜底;approve 后后端要跑工具+续生成,期间任何后端异常不回则按钮永久 loading。 - // 复用通用 watchdog 而非加审批专用超时:复用同一收尾路径(onStreamTimeout 兜底复位 streaming), - // 避免新增独立计时器与状态分支) - resetStreamWatchdog() - try { - await aiApi.approve(toolCallId, approved) - } catch (e) { - // IPC 未送达:用户已点过按钮,清看门狗(避免 130s 后误触发假错误)再回滚到失败态 - clearStreamWatchdog() - state.queue = [] // B-32:IPC 失败即对话结束,清队列防生成中入队消息静默丢失 - // 仅 IPC 真失败(审批已处理/网络断)走到这里:后端工具失败已改走 emit completed,不进此分支 - // 不回滚 pending_approval(会卡死按钮),改设 completed + 错误提示,让用户知晓失败 - console.error('[AI] 审批操作未送达后端:', e) - if (tc) { - tc.status = 'completed' - tc.result = t('ai.approvalNotDelivered', { error: e instanceof Error ? e.message : String(e) }) - } - throw e - } - } finally { - _pendingApprovalIds.delete(toolCallId) - } -} - -/** 批量审批:遍历 pendingApprovals 逐个调用 approveToolCall */ -async function batchApprove(decision: 'approve' | 'reject') { - const approved = decision === 'approve' - // 快照当前待审批列表(遍历中 state.pendingApprovals 会因事件回调而缩短) - const ids = [...state.pendingApprovals].map(p => p.id) - for (const id of ids) { - try { - await approveToolCall(id, approved) - } catch { - // 单条失败不中断批量操作(已由 approveToolCall 内部回滚该条状态) - // 继续处理剩余项 - } - } -} +/** 批量审批已拆分到 useAiApproval.ts(fe-arch P0-1) */ /** 取消队列中指定位置的消息 */ function cancelQueued(index: number) { @@ -502,8 +446,6 @@ export function useAiSend() { sendMessage, regenerate, editMessage, - approveToolCall, - batchApprove, drainQueue, cancelQueued, clearQueue, diff --git a/src/composables/ai/useToolCard.ts b/src/composables/ai/useToolCard.ts index 536f304..3963fdd 100644 --- a/src/composables/ai/useToolCard.ts +++ b/src/composables/ai/useToolCard.ts @@ -329,7 +329,132 @@ export function formatFileInfoResult(r: ToolResult): string { * 通用工具结果格式化(非文件类工具)。 * UX-260616-04:统一 switch 覆盖全部工具产出人类可读摘要,不再走裸 JSON 兜底。 * read_file/list_directory/write_file 有模板特化分支,不进此函数。 + * + * P0-3: switch 17 case 拆为策略表 TOOL_FORMATTERS(name → 纯函数),formatToolResult 查表分发。 + * 新增工具只在此表加一行,零改 formatToolResult 主体。逻辑与原 switch 等价(零行为变更)。 */ + +// list_* 系列(list_projects/list_tasks/list_ideas/list_trash)共享格式化: +// 渲染结构化列表替代裸 JSON,头行按 name 选 i18n key,条目取 name/title/id + status/stack 标注。 +function formatListResult(r: ToolResult, name: string): string { + // UX-260618-12: list_* 后端返 {items,total,has_more} 对象(非数组) + const items = Array.isArray(r) ? r : (Array.isArray(r.items) ? r.items : []) + const total = typeof r.total === 'number' ? r.total : items.length + const head = name === 'list_projects' ? t('aiTool.projectCount', { n: total }) + : name === 'list_tasks' ? t('aiTool.taskCount', { n: total }) + : name === 'list_ideas' ? t('aiTool.ideaCount', { n: total }) + : t('aiTool.trashCount', { n: total }) + const lines = items.slice(0, 10).map((it: Record) => { + const label = typeof it.name === 'string' ? it.name + : typeof it.title === 'string' ? it.title + : typeof it.id === 'string' ? it.id + : '' + const status = typeof it.status === 'string' ? ` [${it.status}]` : '' + const stack = typeof it.stack === 'string' ? ` (${it.stack})` : '' + return label ? `- ${label}${status}${stack}` : '' + }).filter(Boolean) + const parts = [head, ...lines] + if (r.has_more) parts.push(t('aiTool.listHasMore', { n: items.length })) + return parts.join('\n') +} + +/** advance_task 共享格式化(body + summary 一致): review_rounds>0 显退回累加轮数,否则首推原文案 */ +function formatAdvanceTask(r: ToolResult): string { + // UX-260618-09: review_rounds>0 显退回累加轮数,=0 回退原文案(首推无噪音) + // UX-260618-14: 后端返 TaskRecord(含 title),优先显 title,无则回退 id(防裸 UUID) + return typeof r.review_rounds === 'number' && r.review_rounds > 0 + ? t('aiTool.advancedTaskWithRounds', { title: r.title || r.id || '', status: r.status || '', n: r.review_rounds }) + : t('aiTool.advancedTask', { title: r.title || r.id || '', status: r.status || '' }) +} + +/** update_task 共享格式化: updated!==false 显字段更新,否则通用 updated */ +function formatUpdateTask(r: ToolResult): string { + return r.updated !== false + ? t('aiTool.updatedTaskField', { id: r.id || '', field: r.field || '' }) + : t('aiTool.updated') +} + +/** delete_task/delete_project 共享格式化: deleted 标志 */ +function formatDelete(r: ToolResult): string { + return r.deleted ? t('aiTool.deleted') : t('aiTool.deleteIneffective') +} + +/** delete_file 共享格式化: permanent 走永久删,否则软删带 backup_path */ +function formatDeleteFile(r: ToolResult): string { + return r.permanent + ? t('aiTool.deleteFilePermanent', { path: r.path || '' }) + : t('aiTool.deleteFileSoft', { path: r.path || '', backup_path: r.backup_path || '' }) +} + +/** patch_file 共享格式化: changed===false 显无变化,否则显 size_diff */ +function formatPatchFile(r: ToolResult): string { + return r.changed === false + ? t('aiTool.patchedNoChange', { path: r.path || '' }) + : t('aiTool.patchedFile', { path: r.path || '', diff: formatSizeDiff(r.size_diff) }) +} + +/** append_file 共享格式化: path + bytes_written */ +function formatAppendFile(r: ToolResult): string { + return t('aiTool.appendedTo', { path: r.path || '', bytes: formatBytes(r.bytes_written) }) +} + +/** search_files 共享格式化: total/results.length 取命中数 */ +function formatSearchFiles(r: ToolResult): string { + const n = typeof r.total === 'number' ? r.total : (Array.isArray(r.results) ? r.results.length : 0) + return t('aiTool.foundN', { n }) +} + +/** + * rename_file 格式化(body 版): renamed===false 走 formatJson 兜底(summary 版走空串)。 + * 故 body 与 summary 分别登记不同 formatter。 + */ +function formatRenameFile(r: ToolResult): string { + return r.renamed + ? (r.cross_volume + ? t('aiTool.renamedCrossVolume', { from: r.from || '', to: r.to || '', bytes: formatBytes(r.bytes_moved) }) + : t('aiTool.renamedWithBytes', { from: r.from || '', to: r.to || '', bytes: formatBytes(r.bytes_moved) })) + : formatJson(r) +} + +/** bind_directory 格式化(body 版): bound===false 走 formatJson 兜底(summary 版走空串) */ +function formatBindDir(r: ToolResult): string { + return r.bound + ? t('aiTool.boundDir', { path: r.path || '', stack: r.stack || '' }) + : formatJson(r) +} + +/** run_workflow 格式化(body 版):无 execution_id 走 formatJson,否则显完整三要素(task_id/target_status/exec) */ +function formatRunWorkflow(r: ToolResult): string { + // UX-260618-05: 后端返 {task_id,target_status,execution_id,status,note},显 execution_id 对应执行实例 + const execId = typeof r.execution_id === 'string' && r.execution_id + ? r.execution_id + : (typeof r.id === 'string' ? r.id : '') + if (!execId) return formatJson(r) + return t('aiTool.workflowTriggered', { id: r.task_id || '', status: r.target_status || '', exec: execId }) +} + +/** 工具结果格式化策略表(name → 纯函数)。新增工具在此加一行,不动 formatToolResult 主体。 */ +const TOOL_FORMATTERS: Record string> = { + list_projects: r => formatListResult(r, 'list_projects'), + list_tasks: r => formatListResult(r, 'list_tasks'), + list_ideas: r => formatListResult(r, 'list_ideas'), + list_trash: r => formatListResult(r, 'list_trash'), + write_file: r => `${r.path} (${formatBytes(r.bytes_written)})`, + update_task: formatUpdateTask, + advance_task: formatAdvanceTask, + delete_task: formatDelete, + delete_file: formatDeleteFile, + run_command: formatCommandResult, + patch_file: formatPatchFile, + append_file: formatAppendFile, + rename_file: formatRenameFile, + search_files: formatSearchFiles, + file_info: formatFileInfoResult, + bind_directory: formatBindDir, + run_workflow: formatRunWorkflow, + http_request: formatHttpResult, +} + export function formatToolResult(tc: AiToolCallInfo): string { const r = parseResult(tc.result) if (!r) { @@ -345,86 +470,8 @@ export function formatToolResult(tc: AiToolCallInfo): string { } return raw } - switch (tc.name) { - case 'list_projects': - case 'list_tasks': - case 'list_ideas': - case 'list_trash': { - // UX-260618-12: list_* 后端返 {items,total,has_more} 对象(非数组),渲染结构化列表替代裸 JSON - const items = Array.isArray(r) ? r : (Array.isArray(r.items) ? r.items : []) - const total = typeof r.total === 'number' ? r.total : items.length - const head = tc.name === 'list_projects' ? t('aiTool.projectCount', { n: total }) - : tc.name === 'list_tasks' ? t('aiTool.taskCount', { n: total }) - : tc.name === 'list_ideas' ? t('aiTool.ideaCount', { n: total }) - : t('aiTool.trashCount', { n: total }) - const lines = items.slice(0, 10).map((it: Record) => { - const label = typeof it.name === 'string' ? it.name - : typeof it.title === 'string' ? it.title - : typeof it.id === 'string' ? it.id - : '' - const status = typeof it.status === 'string' ? ` [${it.status}]` : '' - const stack = typeof it.stack === 'string' ? ` (${it.stack})` : '' - return label ? `- ${label}${status}${stack}` : '' - }).filter(Boolean) - const parts = [head, ...lines] - if (r.has_more) parts.push(t('aiTool.listHasMore', { n: items.length })) - return parts.join('\n') - } - case 'write_file': - return `${r.path} (${formatBytes(r.bytes_written)})` - case 'update_task': - return r.updated !== false - ? t('aiTool.updatedTaskField', { id: r.id || '', field: r.field || '' }) - : t('aiTool.updated') - case 'advance_task': - // UX-260618-09: review_rounds>0 显退回累加轮数,=0 回退原文案(首推无噪音) - // UX-260618-14: 后端返 TaskRecord(含 title),优先显 title,无则回退 id(防裸 UUID) - return typeof r.review_rounds === 'number' && r.review_rounds > 0 - ? t('aiTool.advancedTaskWithRounds', { title: r.title || r.id || '', status: r.status || '', n: r.review_rounds }) - : t('aiTool.advancedTask', { title: r.title || r.id || '', status: r.status || '' }) - case 'delete_task': - return r.deleted ? t('aiTool.deleted') : t('aiTool.deleteIneffective') - case 'delete_file': - return r.permanent - ? t('aiTool.deleteFilePermanent', { path: r.path || '' }) - : t('aiTool.deleteFileSoft', { path: r.path || '', backup_path: r.backup_path || '' }) - case 'run_command': - return formatCommandResult(r) - case 'patch_file': - return r.changed === false - ? t('aiTool.patchedNoChange', { path: r.path || '' }) - : t('aiTool.patchedFile', { path: r.path || '', diff: formatSizeDiff(r.size_diff) }) - case 'append_file': - return t('aiTool.appendedTo', { path: r.path || '', bytes: formatBytes(r.bytes_written) }) - case 'rename_file': - return r.renamed - ? (r.cross_volume - ? t('aiTool.renamedCrossVolume', { from: r.from || '', to: r.to || '', bytes: formatBytes(r.bytes_moved) }) - : t('aiTool.renamedWithBytes', { from: r.from || '', to: r.to || '', bytes: formatBytes(r.bytes_moved) })) - : formatJson(r) - case 'search_files': { - const n = typeof r.total === 'number' ? r.total : (Array.isArray(r.results) ? r.results.length : 0) - return t('aiTool.foundN', { n }) - } - case 'file_info': - return formatFileInfoResult(r) - case 'bind_directory': - return r.bound - ? t('aiTool.boundDir', { path: r.path || '', stack: r.stack || '' }) - : formatJson(r) - case 'run_workflow': { - // UX-260618-05: 后端返 {task_id,target_status,execution_id,status,note},显 execution_id 对应执行实例 - const execId = typeof r.execution_id === 'string' && r.execution_id - ? r.execution_id - : (typeof r.id === 'string' ? r.id : '') - if (!execId) return formatJson(r) - return t('aiTool.workflowTriggered', { id: r.task_id || '', status: r.target_status || '', exec: execId }) - } - case 'http_request': - return formatHttpResult(r) - default: - return formatJson(r) - } + const fn = TOOL_FORMATTERS[tc.name] + return fn ? fn(r) : formatJson(r) } /** @@ -465,85 +512,110 @@ export function formatHttpResult(r: ToolResult): string { * completed 工具结果的一句话摘要(折叠态 header 也能看到,避免光秃秃)。 * UX-260616-04:覆盖范围与 formatToolResult 对齐(header+body 一致); * read_file/list_directory/write_file 有模板特化分支,header 摘要仍显示关键计数。 + * + * P0-3: switch 拆为策略表 TOOL_SUMMARIES(name → 纯函数),toolResultSummary 查表分发。 + * 与 TOOL_FORMATTERS 不共用:summary 版部分 case 输出更短(create/update_project/restore/purge 仅 summary 有; + * rename/bind 的 false 分支返空串而非 formatJson;run_workflow 用 workflowTriggeredShort;list_* 多 has_more 判断差异)。 + * 共享语义的纯函数(formatAdvanceTask/formatUpdateTask/formatDelete/formatDeleteFile/formatPatchFile/ + * formatAppendFile/formatSearchFiles)两个表共用,DRY。零行为变更。 */ + +/** list_* summary 版:n=0 返空串(折叠态无噪音),按 name 选 i18n key */ +function summaryList(r: ToolResult, name: string): string { + // UX-260618-10/12: 后端返 {items,total,has_more} 对象(非数组),原 Array.isArray(r) 恒 false→折叠态无计数 + const n = Array.isArray(r) ? r.length + : (typeof r.total === 'number' ? r.total + : (Array.isArray(r.items) ? r.items.length : 0)) + if (!n) return '' + const key = name === 'list_projects' ? 'projectCount' + : name === 'list_tasks' ? 'taskCount' + : name === 'list_ideas' ? 'ideaCount' + : 'trashCount' + return t(`aiTool.${key}`, { n }) +} + +/** run_command summary 版:仅 exit_code + duration_ms 一行(不带输出) */ +function summaryRunCommand(r: ToolResult): string { + const code = typeof r.exit_code === 'number' ? r.exit_code : -1 + const ms = typeof r.duration_ms === 'number' ? r.duration_ms : 0 + return code === 0 ? t('aiTool.commandOk', { ms }) : t('aiTool.commandFailed', { code }) +} + +/** file_info summary 版:存在性 + 大小(不带 modified/lines/binary extras) */ +function summaryFileInfo(r: ToolResult): string { + return r.exists === false + ? t('aiTool.fileInfoMissing', { path: r.path || '' }) + : t('aiTool.fileInfoSize', { path: r.path || '', size: formatBytes(typeof r.size === 'number' ? r.size : 0) }) +} + +/** rename_file summary 版:renamed===false 返空串(body 版走 formatJson) */ +function summaryRenameFile(r: ToolResult): string { + return r.renamed + ? (r.cross_volume + ? t('aiTool.renamedCrossVolume', { from: r.from || '', to: r.to || '', bytes: formatBytes(r.bytes_moved) }) + : t('aiTool.renamedWithBytes', { from: r.from || '', to: r.to || '', bytes: formatBytes(r.bytes_moved) })) + : '' +} + +/** bind_directory summary 版:bound===false 返空串(body 版走 formatJson) */ +function summaryBindDir(r: ToolResult): string { + return r.bound + ? t('aiTool.boundDir', { path: r.path || '', stack: r.stack || '' }) + : '' +} + +/** run_workflow summary 版:无 execution_id 返空串,有则显 workflowTriggeredShort(body 版显完整三要素) */ +function summaryRunWorkflow(r: ToolResult): string { + // UX-260618-05: header 副标题显 execution_id 概要(原 workflowHint「请到工作流页面运行」误导——工作流已触发) + const execId = typeof r.execution_id === 'string' && r.execution_id + ? r.execution_id + : (typeof r.id === 'string' ? r.id : '') + return execId ? t('aiTool.workflowTriggeredShort', { exec: execId }) : '' +} + +/** http_request summary 版:仅状态码标题(method + status,不带 url/body) */ +function summaryHttpRequest(r: ToolResult): string { + const status = typeof r.status === 'number' ? r.status : 0 + const method = typeof r.method === 'string' ? r.method : 'HTTP' + const ms = typeof r.elapsed_ms === 'number' ? r.elapsed_ms : 0 + const ok = status >= 200 && status < 300 + return ok + ? t('aiTool.httpOk', { method, status, ms }) + : t('aiTool.httpFailed', { method, status }) +} + +/** 工具结果摘要策略表(name → 纯函数)。仅 summary 独有的工具(create/update_project/restore/purge)在此登记。 */ +const TOOL_SUMMARIES: Record string> = { + list_projects: r => summaryList(r, 'list_projects'), + list_tasks: r => summaryList(r, 'list_tasks'), + list_ideas: r => summaryList(r, 'list_ideas'), + list_trash: r => summaryList(r, 'list_trash'), + create_project: r => r.name ? t('aiTool.createdWithName', { name: r.name }) : t('aiTool.created'), + create_idea: r => r.title ? t('aiTool.createdWithName', { name: r.title }) : t('aiTool.created'), + create_task: r => r.title ? t('aiTool.createdWithName', { name: r.title }) : t('aiTool.created'), + update_project: r => r.field ? t('aiTool.updatedField', { field: r.field }) : t('aiTool.updated'), + update_task: formatUpdateTask, + advance_task: formatAdvanceTask, + delete_project: formatDelete, + delete_task: formatDelete, + delete_file: formatDeleteFile, + restore_project: r => r.restored ? t('aiTool.restored') : t('aiTool.restoreIneffective'), + purge_project: r => r.purged ? t('aiTool.purged') : t('aiTool.purgeIneffective'), + run_command: summaryRunCommand, + patch_file: formatPatchFile, + append_file: formatAppendFile, + rename_file: summaryRenameFile, + search_files: formatSearchFiles, + file_info: summaryFileInfo, + bind_directory: summaryBindDir, + run_workflow: summaryRunWorkflow, + http_request: summaryHttpRequest, +} + export function toolResultSummary(tc: AiToolCallInfo): string { if (tc.status !== 'completed') return '' const r = parseResult(tc.result) if (!r) return '' - switch (tc.name) { - case 'list_tasks': - case 'list_projects': - case 'list_ideas': - case 'list_trash': { - // UX-260618-10/12: 后端返 {items,total,has_more} 对象(非数组),原 Array.isArray(r) 恒 false→折叠态无计数 - const n = Array.isArray(r) ? r.length - : (typeof r.total === 'number' ? r.total - : (Array.isArray(r.items) ? r.items.length : 0)) - if (!n) return '' - const key = tc.name === 'list_projects' ? 'projectCount' - : tc.name === 'list_tasks' ? 'taskCount' - : tc.name === 'list_ideas' ? 'ideaCount' - : 'trashCount' - return t(`aiTool.${key}`, { n }) - } - case 'create_project': return r.name ? t('aiTool.createdWithName', { name: r.name }) : t('aiTool.created') - case 'create_idea': - case 'create_task': return r.title ? t('aiTool.createdWithName', { name: r.title }) : t('aiTool.created') - case 'update_project': return r.field ? t('aiTool.updatedField', { field: r.field }) : t('aiTool.updated') - case 'update_task': return r.updated !== false - ? t('aiTool.updatedTaskField', { id: r.id || '', field: r.field || '' }) - : t('aiTool.updated') - case 'advance_task': return typeof r.review_rounds === 'number' && r.review_rounds > 0 - // UX-260618-14: 优先显 title(后端返 TaskRecord),无则回退 id,防裸 UUID - ? t('aiTool.advancedTaskWithRounds', { title: r.title || r.id || '', status: r.status || '', n: r.review_rounds }) - : t('aiTool.advancedTask', { title: r.title || r.id || '', status: r.status || '' }) - case 'delete_project': - case 'delete_task': return r.deleted ? t('aiTool.deleted') : t('aiTool.deleteIneffective') - case 'delete_file': return r.permanent - ? t('aiTool.deleteFilePermanent', { path: r.path || '' }) - : t('aiTool.deleteFileSoft', { path: r.path || '', backup_path: r.backup_path || '' }) - case 'restore_project': return r.restored ? t('aiTool.restored') : t('aiTool.restoreIneffective') - case 'purge_project': return r.purged ? t('aiTool.purged') : t('aiTool.purgeIneffective') - case 'run_command': { - const code = typeof r.exit_code === 'number' ? r.exit_code : -1 - const ms = typeof r.duration_ms === 'number' ? r.duration_ms : 0 - return code === 0 ? t('aiTool.commandOk', { ms }) : t('aiTool.commandFailed', { code }) - } - case 'patch_file': return r.changed === false - ? t('aiTool.patchedNoChange', { path: r.path || '' }) - : t('aiTool.patchedFile', { path: r.path || '', diff: formatSizeDiff(r.size_diff) }) - case 'append_file': return t('aiTool.appendedTo', { path: r.path || '', bytes: formatBytes(r.bytes_written) }) - case 'rename_file': return r.renamed - ? (r.cross_volume - ? t('aiTool.renamedCrossVolume', { from: r.from || '', to: r.to || '', bytes: formatBytes(r.bytes_moved) }) - : t('aiTool.renamedWithBytes', { from: r.from || '', to: r.to || '', bytes: formatBytes(r.bytes_moved) })) - : '' - case 'search_files': { - const n = typeof r.total === 'number' ? r.total : (Array.isArray(r.results) ? r.results.length : 0) - return t('aiTool.foundN', { n }) - } - case 'file_info': return r.exists === false - ? t('aiTool.fileInfoMissing', { path: r.path || '' }) - : t('aiTool.fileInfoSize', { path: r.path || '', size: formatBytes(typeof r.size === 'number' ? r.size : 0) }) - case 'bind_directory': return r.bound - ? t('aiTool.boundDir', { path: r.path || '', stack: r.stack || '' }) - : '' - case 'run_workflow': { - // UX-260618-05: header 副标题显 execution_id 概要(原 workflowHint「请到工作流页面运行」误导——工作流已触发) - const execId = typeof r.execution_id === 'string' && r.execution_id - ? r.execution_id - : (typeof r.id === 'string' ? r.id : '') - return execId ? t('aiTool.workflowTriggeredShort', { exec: execId }) : '' - } - case 'http_request': { - const status = typeof r.status === 'number' ? r.status : 0 - const method = typeof r.method === 'string' ? r.method : 'HTTP' - const ms = typeof r.elapsed_ms === 'number' ? r.elapsed_ms : 0 - const ok = status >= 200 && status < 300 - return ok - ? t('aiTool.httpOk', { method, status, ms }) - : t('aiTool.httpFailed', { method, status }) - } - default: return '' - } + const fn = TOOL_SUMMARIES[tc.name] + return fn ? fn(r) : '' } diff --git a/src/stores/ai.ts b/src/stores/ai.ts index 80cc670..08d14f6 100644 --- a/src/stores/ai.ts +++ b/src/stores/ai.ts @@ -51,6 +51,7 @@ const PARTS_SIZE_BUDGET = 50 * 1024 * 1024 // 50MB(base64 char 计) import { useAiEvents } from '@/composables/ai/useAiEvents' import { useAiStream } from '@/composables/ai/useAiStream' import { useAiSend, initDrainQueueListener } from '@/composables/ai/useAiSend' +import { useAiApproval } from '@/composables/ai/useAiApproval' import { useAiConversations } from '@/composables/ai/useAiConversations' import { useAiWindow } from '@/composables/ai/useAiWindow' import { useAiPanel } from '@/composables/ai/useAiPanel' @@ -199,6 +200,7 @@ export function useAiStore() { ...useAiEvents(), ...useAiStream(), ...useAiSend(), + ...useAiApproval(), ...useAiConversations(), ...useAiWindow(), ...useAiPanel(),