重构: P0前端架构(useAiSend/useAiEvents/useToolCard拆分+status union全)

squash合并:
- P0-1 useAiSend审批抽离(useAiApproval)
- P0-2 useAiEvents switch拆3域handler
- P0-3 useToolCard formatToolResult策略表
- P0-4 status union(Idea+Task+Project+Workflow+AiToolCall)
This commit is contained in:
2026-06-19 15:04:04 +08:00
parent 998a2f243d
commit 5feeaa6cb0
6 changed files with 435 additions and 262 deletions

View File

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

View File

@@ -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 往返 <100ms300ms 窗口足够覆盖竞态。
*/
const _pendingApprovalIds = new Set<string>()
/** 工具审批:保持 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,
}
}

View File

@@ -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<boolean>('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 每次调用不重复注册)

View File

@@ -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<string | null>(null)
// 审批计时器(startApprovalTimer/clearApprovalTimer/clearAllApprovalTimers)已下沉到 aiShared.ts
// 打破 useAiEvents ↔ useAiSend 循环依赖。本模块 re-export 供组件使用。
/**
* F-260616-06: 审批按钮防抖守卫——同一 id 短期多次点击只发一次 IPC。
* IPC 往返 <100ms300ms 窗口足够覆盖竞态。
*/
const _pendingApprovalIds = new Set<string>()
/**
* 入队时记录时间戳,供 UI 展示排队时长和触发超时提示。
* queue item 扩展: 原 { text, skill? } → { text, skill?, enqueuedAt, parts? }
@@ -363,56 +356,7 @@ export async function tryForceSend(confirmFn: (msg: string) => Promise<boolean>)
}
}
/** 工具审批:保持 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,

View File

@@ -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<string, unknown>) => {
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, (r: ToolResult) => 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<string, unknown>) => {
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, (r: ToolResult) => 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) : ''
}

View File

@@ -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(),