重构: 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:
@@ -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 {
|
export interface ProjectRecord {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
status: string // planning | in_progress | testing | releasing | completed | paused
|
status: ProjectStatus
|
||||||
idea_id: string | null
|
idea_id: string | null
|
||||||
/** 绑定的本地代码目录绝对路径(null=未绑定) */
|
/** 绑定的本地代码目录绝对路径(null=未绑定) */
|
||||||
path: string | 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 {
|
export interface TaskRecord {
|
||||||
id: string
|
id: string
|
||||||
project_id: string
|
project_id: string
|
||||||
title: string
|
title: string
|
||||||
description: 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
|
priority: number // 0=critical, 1=high, 2=medium, 3=low
|
||||||
branch_name: string | null
|
branch_name: string | null
|
||||||
assignee: 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 {
|
export interface WorkflowRecord {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
dag_json: string
|
dag_json: string
|
||||||
status: string // running | completed | failed | cancelled
|
status: WorkflowStatus
|
||||||
triggered_by: string | null
|
triggered_by: string | null
|
||||||
project_id: string | null
|
project_id: string | null
|
||||||
task_id: string | null
|
task_id: string | null
|
||||||
@@ -320,12 +338,17 @@ export interface AiMessage {
|
|||||||
timestamp: number
|
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 {
|
export interface AiToolCallInfo {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
args: unknown
|
args: unknown
|
||||||
status: 'running' | 'pending_approval' | 'completed' | 'rejected'
|
status: AiToolCallStatus
|
||||||
result?: unknown
|
result?: unknown
|
||||||
/** 审批风险说明(AiApprovalRequired 时填充) */
|
/** 审批风险说明(AiApprovalRequired 时填充) */
|
||||||
reason?: string
|
reason?: string
|
||||||
|
|||||||
83
src/composables/ai/useAiApproval.ts
Normal file
83
src/composables/ai/useAiApproval.ts
Normal 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 往返 <100ms,300ms 窗口足够覆盖竞态。
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,14 @@
|
|||||||
//! - _unlistenAiEvent / _unlistenConvChanged: 已注册的 unlistener
|
//! - _unlistenAiEvent / _unlistenConvChanged: 已注册的 unlistener
|
||||||
//! - _startPromise: startListener 并发去重(防 onMounted 与 sendMessage 首发竞态重复注册)
|
//! - _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、
|
//! - handleEvent 调 useAiConversations.loadConversations、
|
||||||
//! useAiStream.{resetStreamWatchdog,clearStreamWatchdog}、本模块 flushCurrentText/findToolCall/notifyConversationChanged
|
//! useAiStream.{resetStreamWatchdog,clearStreamWatchdog}、本模块 flushCurrentText/findToolCall/notifyConversationChanged
|
||||||
@@ -115,36 +123,28 @@ function isShowTokenUsage(): boolean {
|
|||||||
return appSettings.get<boolean>('df-show-token-usage', false)
|
return appSettings.get<boolean>('df-show-token-usage', false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 后端事件分发:按 conversation_id 路由,流式累积文本,工具状态流转,看门狗联动 */
|
// ─── 事件域 handler(从原 handleEvent switch 抽出,按域分组)────────────────────────────
|
||||||
export function handleEvent(event: AiChatEvent) {
|
//
|
||||||
const convId = event.conversation_id
|
// 拆分策略:策略 C(抽 case 体为函数)+ 按域分组(streaming/tool/lifecycle)。
|
||||||
// 首次收到事件时同步当前对话 id(后端自动建对话的场景)
|
// handleEvent 保留外围逻辑(convId 同步/路由/看门狗),switch 改为按 event.type 分派到
|
||||||
// 同步写 appSettings(SQLite):刷新页面后 loadConversations 据此恢复上次会话
|
// 对应域 handler。各 handler 共享模块级 helper(state/aiShared/审批计时器/工具慢执行计时器)。
|
||||||
if (convId && !state.activeConversationId) {
|
// 零行为变更:逻辑等价搬运,case 体一字未改。
|
||||||
state.activeConversationId = convId
|
//
|
||||||
void appSettings.set('df-ai-active-conv', convId)
|
// 域分组:
|
||||||
}
|
// - streaming 流式:AiTextDelta / AiAgentRound / AiHeartbeat / AiStreamRetry / AiMaxRoundsReached
|
||||||
// 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误时刷新侧边栏
|
// - tool 工具:AiToolCallStarted / AiToolCallCompleted / AiToolAutoApproved / AiApprovalRequired / AiApprovalResult
|
||||||
const isCurrent = !convId || convId === state.activeConversationId
|
// - lifecycle 生命周期:AiCompleted / AiError
|
||||||
if (!isCurrent) {
|
//
|
||||||
if (event.type === 'AiCompleted' || event.type === 'AiError') {
|
// 返回值:true=已处理(命中域内 case);false=未命中(供 handleEvent 兜底/未来扩展)
|
||||||
state.generatingConvId = null
|
// 例外:AiMaxRoundsReached 在 streaming 域(语义属流式中断信号),但 lifecycle 域收尾时
|
||||||
void loadConversations()
|
// 需置 pendingMaxRounds=false —— 该 ref 为模块级,跨域共享无碍。
|
||||||
}
|
|
||||||
return
|
/** streaming 域:流式文本累积/新轮/心跳/重试/max 轮暂停 */
|
||||||
}
|
function handleStreamingEvent(event: AiChatEvent): boolean {
|
||||||
// 标记正在生成的对话(完成/错误事件除外)
|
|
||||||
if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError') {
|
|
||||||
state.generatingConvId = convId
|
|
||||||
}
|
|
||||||
// 流式看门狗:活跃事件(delta/工具/新轮/审批结果)重置;审批等待/完成/错误在 case 内 clear
|
|
||||||
if (!NO_RESET_WATCHDOG.has(event.type)) {
|
|
||||||
resetStreamWatchdog()
|
|
||||||
}
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'AiTextDelta':
|
case 'AiTextDelta':
|
||||||
state.currentText += event.delta
|
state.currentText += event.delta
|
||||||
break
|
return true
|
||||||
|
|
||||||
case 'AiAgentRound': {
|
case 'AiAgentRound': {
|
||||||
// Agent 循环新一轮:保存当前文本到上一条 assistant 消息,新建空 assistant 消息
|
// Agent 循环新一轮:保存当前文本到上一条 assistant 消息,新建空 assistant 消息
|
||||||
@@ -162,12 +162,12 @@ export function handleEvent(event: AiChatEvent) {
|
|||||||
// 此时实际轮次尚未推进(run_agentic_loop 入口 iteration=0),不覆盖,避免审批通过瞬间
|
// 此时实际轮次尚未推进(run_agentic_loop 入口 iteration=0),不覆盖,避免审批通过瞬间
|
||||||
// 进度条 2→0 闪烁;下一轮真正的 round>0 事件到达时再更新。
|
// 进度条 2→0 闪烁;下一轮真正的 round>0 事件到达时再更新。
|
||||||
if (event.round > 0) state.agentRound = event.round
|
if (event.round > 0) state.agentRound = event.round
|
||||||
break
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'AiHeartbeat':
|
case 'AiHeartbeat':
|
||||||
// 心跳事件:仅维持看门狗(已在上方 reset),无需额外处理;显式 case 防 switch 穿透
|
// 心跳事件:仅维持看门狗(已在上方 reset),无需额外处理;显式 case 防 switch 穿透
|
||||||
break
|
return true
|
||||||
|
|
||||||
case 'AiStreamRetry': {
|
case 'AiStreamRetry': {
|
||||||
// UX-260618-15: 流前失败重试中——后端 emit AiStreamRetry 携带 attempt/max_attempts。
|
// UX-260618-15: 流前失败重试中——后端 emit AiStreamRetry 携带 attempt/max_attempts。
|
||||||
@@ -188,7 +188,7 @@ export function handleEvent(event: AiChatEvent) {
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
} as AiMessage)
|
} as AiMessage)
|
||||||
}
|
}
|
||||||
break
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'AiMaxRoundsReached':
|
case 'AiMaxRoundsReached':
|
||||||
@@ -196,8 +196,16 @@ export function handleEvent(event: AiChatEvent) {
|
|||||||
// 后端已 save 落库,这里仅翻 pendingMaxRounds=true 驱动 UI 卡片;看门狗已由
|
// 后端已 save 落库,这里仅翻 pendingMaxRounds=true 驱动 UI 卡片;看门狗已由
|
||||||
// NO_RESET_WATCHDOG 跳过 reset(达 max 不计整流超时)。
|
// NO_RESET_WATCHDOG 跳过 reset(达 max 不计整流超时)。
|
||||||
pendingMaxRounds.value = true
|
pendingMaxRounds.value = true
|
||||||
break
|
return true
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** tool 域:工具卡片状态流转/会话级信任/审批 */
|
||||||
|
function handleToolEvent(event: AiChatEvent): boolean {
|
||||||
|
switch (event.type) {
|
||||||
case 'AiToolCallStarted': {
|
case 'AiToolCallStarted': {
|
||||||
const info: AiToolCallInfo = {
|
const info: AiToolCallInfo = {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
@@ -207,7 +215,7 @@ export function handleEvent(event: AiChatEvent) {
|
|||||||
}
|
}
|
||||||
// B-260616-21: 同 tool_call_id 重复 emit Started 时(GLM anthropic_compat id 不稳),
|
// B-260616-21: 同 tool_call_id 重复 emit Started 时(GLM anthropic_compat id 不稳),
|
||||||
// 仅挂计时器不重复 push(对齐 startToolSlowTimer 守卫风格),防残留 running 空卡(0 行·N KB)。
|
// 仅挂计时器不重复 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)) {
|
if (!findToolCall(event.id)) {
|
||||||
const lastMsg = state.messages[state.messages.length - 1]
|
const lastMsg = state.messages[state.messages.length - 1]
|
||||||
if (lastMsg && lastMsg.role === 'assistant') {
|
if (lastMsg && lastMsg.role === 'assistant') {
|
||||||
@@ -217,7 +225,7 @@ export function handleEvent(event: AiChatEvent) {
|
|||||||
}
|
}
|
||||||
// B-260616-12: 工具开始执行即挂慢执行计时器(超时仅 toast,不动 running 态)
|
// B-260616-12: 工具开始执行即挂慢执行计时器(超时仅 toast,不动 running 态)
|
||||||
startToolSlowTimer(event.id, event.name)
|
startToolSlowTimer(event.id, event.name)
|
||||||
break
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'AiToolCallCompleted': {
|
case 'AiToolCallCompleted': {
|
||||||
@@ -230,7 +238,7 @@ export function handleEvent(event: AiChatEvent) {
|
|||||||
clearToolSlowTimer(event.id)
|
clearToolSlowTimer(event.id)
|
||||||
// AE-2025-06: 工具结束(无论审批通过后执行还是被拒),清审批超时计时器
|
// AE-2025-06: 工具结束(无论审批通过后执行还是被拒),清审批超时计时器
|
||||||
clearApprovalTimer(event.id)
|
clearApprovalTimer(event.id)
|
||||||
break
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'AiToolAutoApproved': {
|
case 'AiToolAutoApproved': {
|
||||||
@@ -238,7 +246,7 @@ export function handleEvent(event: AiChatEvent) {
|
|||||||
// 仅经事件总线桥接到 AiChat.vue 弹本地 toast(composable 无组件上下文,与
|
// 仅经事件总线桥接到 AiChat.vue 弹本地 toast(composable 无组件上下文,与
|
||||||
// ai-tool-slow-toast 同款中转模式)。主窗口与分离窗口各挂一份 AiChat,各自消费。
|
// ai-tool-slow-toast 同款中转模式)。主窗口与分离窗口各挂一份 AiChat,各自消费。
|
||||||
void emit('ai-tool-auto-approved-toast', { tool: event.tool, dir: event.dir })
|
void emit('ai-tool-auto-approved-toast', { tool: event.tool, dir: event.dir })
|
||||||
break
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'AiApprovalRequired': {
|
case 'AiApprovalRequired': {
|
||||||
@@ -261,7 +269,7 @@ export function handleEvent(event: AiChatEvent) {
|
|||||||
clearToolSlowTimer(event.id)
|
clearToolSlowTimer(event.id)
|
||||||
// AE-2025-06: 审批等待开始→启动审批超时计时器(5min 不处理自动拒绝)
|
// AE-2025-06: 审批等待开始→启动审批超时计时器(5min 不处理自动拒绝)
|
||||||
startApprovalTimer(event.id, event.name)
|
startApprovalTimer(event.id, event.name)
|
||||||
break
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'AiApprovalResult': {
|
case 'AiApprovalResult': {
|
||||||
@@ -281,9 +289,17 @@ export function handleEvent(event: AiChatEvent) {
|
|||||||
// B-260616-12: 审批通过→工具重新进入执行态,重启慢执行计时器
|
// B-260616-12: 审批通过→工具重新进入执行态,重启慢执行计时器
|
||||||
startToolSlowTimer(event.id, findToolCall(event.id)?.name || '')
|
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': {
|
case 'AiCompleted': {
|
||||||
clearStreamWatchdog()
|
clearStreamWatchdog()
|
||||||
clearAllToolSlowTimers() // B-260616-12: 整轮结束清全部工具慢执行计时器与已提示集合
|
clearAllToolSlowTimers() // B-260616-12: 整轮结束清全部工具慢执行计时器与已提示集合
|
||||||
@@ -326,7 +342,7 @@ export function handleEvent(event: AiChatEvent) {
|
|||||||
notifyConversationChanged()
|
notifyConversationChanged()
|
||||||
// 队列续发:当前完成后自动发下一条(经事件总线桥接,避免 import useAiSend 构成循环依赖)
|
// 队列续发:当前完成后自动发下一条(经事件总线桥接,避免 import useAiSend 构成循环依赖)
|
||||||
emit('ai-drain-queue', {})
|
emit('ai-drain-queue', {})
|
||||||
break
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'AiError': {
|
case 'AiError': {
|
||||||
@@ -355,9 +371,44 @@ export function handleEvent(event: AiChatEvent) {
|
|||||||
errorType: event.error_type,
|
errorType: event.error_type,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
} as AiMessage)
|
} 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 首发竞态不会重复注册) */
|
/** 启动事件监听(幂等 + 并发去重,onMounted 与 sendMessage 首发竞态不会重复注册) */
|
||||||
|
|||||||
@@ -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 发送韧性:三级降级
|
//! B-260616-02 L2 发送韧性:三级降级
|
||||||
//! L0 normal:后端 idle → 直接发送
|
//! L0 normal:后端 idle → 直接发送
|
||||||
//! L1 queued: 后端 busy → 入队等待(≤30s 正常排队)
|
//! L1 queued: 后端 busy → 入队等待(≤30s 正常排队)
|
||||||
//! L2 force: 排队>30s → 弹 confirm → 用户确认后调 ai_chat_force_send 复位残留并强制发出
|
//! L2 force: 排队>30s → 弹 confirm → 用户确认后调 ai_chat_force_send 复位残留并强制发出
|
||||||
//!
|
//!
|
||||||
//! 模块级私有:
|
|
||||||
//! - _pendingApprovalIds:审批按钮防抖守卫
|
|
||||||
//!
|
|
||||||
//! 耦合:
|
//! 耦合:
|
||||||
//! - sendMessage 调 useAiEvents.startListener(useAiEvents 导出但不在解构集中暴露给组件,
|
//! - sendMessage 调 useAiEvents.startListener(useAiEvents 导出但不在解构集中暴露给组件,
|
||||||
//! 故 startListener 同时经 useAiEvents 导出,sendMessage 直接 import 调用)
|
//! 故 startListener 同时经 useAiEvents 导出,sendMessage 直接 import 调用)
|
||||||
@@ -24,7 +23,7 @@ import type { ContentPart, AiMessage } from '@/api/types'
|
|||||||
import { t } from '@/i18n/i18n-helpers'
|
import { t } from '@/i18n/i18n-helpers'
|
||||||
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
|
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
|
||||||
import { startListener } from './useAiEvents'
|
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
|
const QUEUE_LIMIT = 10
|
||||||
@@ -51,12 +50,6 @@ const modelOverride = ref<string | null>(null)
|
|||||||
// 审批计时器(startApprovalTimer/clearApprovalTimer/clearAllApprovalTimers)已下沉到 aiShared.ts
|
// 审批计时器(startApprovalTimer/clearApprovalTimer/clearAllApprovalTimers)已下沉到 aiShared.ts
|
||||||
// 打破 useAiEvents ↔ useAiSend 循环依赖。本模块 re-export 供组件使用。
|
// 打破 useAiEvents ↔ useAiSend 循环依赖。本模块 re-export 供组件使用。
|
||||||
|
|
||||||
/**
|
|
||||||
* F-260616-06: 审批按钮防抖守卫——同一 id 短期多次点击只发一次 IPC。
|
|
||||||
* IPC 往返 <100ms,300ms 窗口足够覆盖竞态。
|
|
||||||
*/
|
|
||||||
const _pendingApprovalIds = new Set<string>()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 入队时记录时间戳,供 UI 展示排队时长和触发超时提示。
|
* 入队时记录时间戳,供 UI 展示排队时长和触发超时提示。
|
||||||
* queue item 扩展: 原 { text, skill? } → { text, skill?, enqueuedAt, parts? }
|
* 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 持有),
|
/** 批量审批已拆分到 useAiApproval.ts(fe-arch P0-1) */
|
||||||
* 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 内部回滚该条状态)
|
|
||||||
// 继续处理剩余项
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 取消队列中指定位置的消息 */
|
/** 取消队列中指定位置的消息 */
|
||||||
function cancelQueued(index: number) {
|
function cancelQueued(index: number) {
|
||||||
@@ -502,8 +446,6 @@ export function useAiSend() {
|
|||||||
sendMessage,
|
sendMessage,
|
||||||
regenerate,
|
regenerate,
|
||||||
editMessage,
|
editMessage,
|
||||||
approveToolCall,
|
|
||||||
batchApprove,
|
|
||||||
drainQueue,
|
drainQueue,
|
||||||
cancelQueued,
|
cancelQueued,
|
||||||
clearQueue,
|
clearQueue,
|
||||||
|
|||||||
@@ -329,7 +329,132 @@ export function formatFileInfoResult(r: ToolResult): string {
|
|||||||
* 通用工具结果格式化(非文件类工具)。
|
* 通用工具结果格式化(非文件类工具)。
|
||||||
* UX-260616-04:统一 switch 覆盖全部工具产出人类可读摘要,不再走裸 JSON 兜底。
|
* UX-260616-04:统一 switch 覆盖全部工具产出人类可读摘要,不再走裸 JSON 兜底。
|
||||||
* read_file/list_directory/write_file 有模板特化分支,不进此函数。
|
* 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 {
|
export function formatToolResult(tc: AiToolCallInfo): string {
|
||||||
const r = parseResult(tc.result)
|
const r = parseResult(tc.result)
|
||||||
if (!r) {
|
if (!r) {
|
||||||
@@ -345,86 +470,8 @@ export function formatToolResult(tc: AiToolCallInfo): string {
|
|||||||
}
|
}
|
||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
switch (tc.name) {
|
const fn = TOOL_FORMATTERS[tc.name]
|
||||||
case 'list_projects':
|
return fn ? fn(r) : formatJson(r)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -465,77 +512,69 @@ export function formatHttpResult(r: ToolResult): string {
|
|||||||
* completed 工具结果的一句话摘要(折叠态 header 也能看到,避免光秃秃)。
|
* completed 工具结果的一句话摘要(折叠态 header 也能看到,避免光秃秃)。
|
||||||
* UX-260616-04:覆盖范围与 formatToolResult 对齐(header+body 一致);
|
* UX-260616-04:覆盖范围与 formatToolResult 对齐(header+body 一致);
|
||||||
* read_file/list_directory/write_file 有模板特化分支,header 摘要仍显示关键计数。
|
* 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。零行为变更。
|
||||||
*/
|
*/
|
||||||
export function toolResultSummary(tc: AiToolCallInfo): string {
|
|
||||||
if (tc.status !== 'completed') return ''
|
/** list_* summary 版:n=0 返空串(折叠态无噪音),按 name 选 i18n key */
|
||||||
const r = parseResult(tc.result)
|
function summaryList(r: ToolResult, name: string): string {
|
||||||
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→折叠态无计数
|
// UX-260618-10/12: 后端返 {items,total,has_more} 对象(非数组),原 Array.isArray(r) 恒 false→折叠态无计数
|
||||||
const n = Array.isArray(r) ? r.length
|
const n = Array.isArray(r) ? r.length
|
||||||
: (typeof r.total === 'number' ? r.total
|
: (typeof r.total === 'number' ? r.total
|
||||||
: (Array.isArray(r.items) ? r.items.length : 0))
|
: (Array.isArray(r.items) ? r.items.length : 0))
|
||||||
if (!n) return ''
|
if (!n) return ''
|
||||||
const key = tc.name === 'list_projects' ? 'projectCount'
|
const key = name === 'list_projects' ? 'projectCount'
|
||||||
: tc.name === 'list_tasks' ? 'taskCount'
|
: name === 'list_tasks' ? 'taskCount'
|
||||||
: tc.name === 'list_ideas' ? 'ideaCount'
|
: name === 'list_ideas' ? 'ideaCount'
|
||||||
: 'trashCount'
|
: 'trashCount'
|
||||||
return t(`aiTool.${key}`, { n })
|
return t(`aiTool.${key}`, { n })
|
||||||
}
|
}
|
||||||
case 'create_project': return r.name ? t('aiTool.createdWithName', { name: r.name }) : t('aiTool.created')
|
|
||||||
case 'create_idea':
|
/** run_command summary 版:仅 exit_code + duration_ms 一行(不带输出) */
|
||||||
case 'create_task': return r.title ? t('aiTool.createdWithName', { name: r.title }) : t('aiTool.created')
|
function summaryRunCommand(r: ToolResult): string {
|
||||||
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 code = typeof r.exit_code === 'number' ? r.exit_code : -1
|
||||||
const ms = typeof r.duration_ms === 'number' ? r.duration_ms : 0
|
const ms = typeof r.duration_ms === 'number' ? r.duration_ms : 0
|
||||||
return code === 0 ? t('aiTool.commandOk', { ms }) : t('aiTool.commandFailed', { code })
|
return code === 0 ? t('aiTool.commandOk', { ms }) : t('aiTool.commandFailed', { code })
|
||||||
}
|
}
|
||||||
case 'patch_file': return r.changed === false
|
|
||||||
? t('aiTool.patchedNoChange', { path: r.path || '' })
|
/** file_info summary 版:存在性 + 大小(不带 modified/lines/binary extras) */
|
||||||
: t('aiTool.patchedFile', { path: r.path || '', diff: formatSizeDiff(r.size_diff) })
|
function summaryFileInfo(r: ToolResult): string {
|
||||||
case 'append_file': return t('aiTool.appendedTo', { path: r.path || '', bytes: formatBytes(r.bytes_written) })
|
return r.exists === false
|
||||||
case 'rename_file': return r.renamed
|
? 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
|
? (r.cross_volume
|
||||||
? t('aiTool.renamedCrossVolume', { from: r.from || '', to: r.to || '', bytes: formatBytes(r.bytes_moved) })
|
? 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) }))
|
: 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 || '' })
|
/** bind_directory summary 版:bound===false 返空串(body 版走 formatJson) */
|
||||||
: t('aiTool.fileInfoSize', { path: r.path || '', size: formatBytes(typeof r.size === 'number' ? r.size : 0) })
|
function summaryBindDir(r: ToolResult): string {
|
||||||
case 'bind_directory': return r.bound
|
return r.bound
|
||||||
? t('aiTool.boundDir', { path: r.path || '', stack: r.stack || '' })
|
? t('aiTool.boundDir', { path: r.path || '', stack: r.stack || '' })
|
||||||
: ''
|
: ''
|
||||||
case 'run_workflow': {
|
}
|
||||||
|
|
||||||
|
/** run_workflow summary 版:无 execution_id 返空串,有则显 workflowTriggeredShort(body 版显完整三要素) */
|
||||||
|
function summaryRunWorkflow(r: ToolResult): string {
|
||||||
// UX-260618-05: header 副标题显 execution_id 概要(原 workflowHint「请到工作流页面运行」误导——工作流已触发)
|
// UX-260618-05: header 副标题显 execution_id 概要(原 workflowHint「请到工作流页面运行」误导——工作流已触发)
|
||||||
const execId = typeof r.execution_id === 'string' && r.execution_id
|
const execId = typeof r.execution_id === 'string' && r.execution_id
|
||||||
? r.execution_id
|
? r.execution_id
|
||||||
: (typeof r.id === 'string' ? r.id : '')
|
: (typeof r.id === 'string' ? r.id : '')
|
||||||
return execId ? t('aiTool.workflowTriggeredShort', { exec: execId }) : ''
|
return execId ? t('aiTool.workflowTriggeredShort', { exec: execId }) : ''
|
||||||
}
|
}
|
||||||
case 'http_request': {
|
|
||||||
|
/** http_request summary 版:仅状态码标题(method + status,不带 url/body) */
|
||||||
|
function summaryHttpRequest(r: ToolResult): string {
|
||||||
const status = typeof r.status === 'number' ? r.status : 0
|
const status = typeof r.status === 'number' ? r.status : 0
|
||||||
const method = typeof r.method === 'string' ? r.method : 'HTTP'
|
const method = typeof r.method === 'string' ? r.method : 'HTTP'
|
||||||
const ms = typeof r.elapsed_ms === 'number' ? r.elapsed_ms : 0
|
const ms = typeof r.elapsed_ms === 'number' ? r.elapsed_ms : 0
|
||||||
@@ -544,6 +583,39 @@ export function toolResultSummary(tc: AiToolCallInfo): string {
|
|||||||
? t('aiTool.httpOk', { method, status, ms })
|
? t('aiTool.httpOk', { method, status, ms })
|
||||||
: t('aiTool.httpFailed', { method, status })
|
: t('aiTool.httpFailed', { method, status })
|
||||||
}
|
}
|
||||||
default: return ''
|
|
||||||
|
/** 工具结果摘要策略表(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 ''
|
||||||
|
const fn = TOOL_SUMMARIES[tc.name]
|
||||||
|
return fn ? fn(r) : ''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ const PARTS_SIZE_BUDGET = 50 * 1024 * 1024 // 50MB(base64 char 计)
|
|||||||
import { useAiEvents } from '@/composables/ai/useAiEvents'
|
import { useAiEvents } from '@/composables/ai/useAiEvents'
|
||||||
import { useAiStream } from '@/composables/ai/useAiStream'
|
import { useAiStream } from '@/composables/ai/useAiStream'
|
||||||
import { useAiSend, initDrainQueueListener } from '@/composables/ai/useAiSend'
|
import { useAiSend, initDrainQueueListener } from '@/composables/ai/useAiSend'
|
||||||
|
import { useAiApproval } from '@/composables/ai/useAiApproval'
|
||||||
import { useAiConversations } from '@/composables/ai/useAiConversations'
|
import { useAiConversations } from '@/composables/ai/useAiConversations'
|
||||||
import { useAiWindow } from '@/composables/ai/useAiWindow'
|
import { useAiWindow } from '@/composables/ai/useAiWindow'
|
||||||
import { useAiPanel } from '@/composables/ai/useAiPanel'
|
import { useAiPanel } from '@/composables/ai/useAiPanel'
|
||||||
@@ -199,6 +200,7 @@ export function useAiStore() {
|
|||||||
...useAiEvents(),
|
...useAiEvents(),
|
||||||
...useAiStream(),
|
...useAiStream(),
|
||||||
...useAiSend(),
|
...useAiSend(),
|
||||||
|
...useAiApproval(),
|
||||||
...useAiConversations(),
|
...useAiConversations(),
|
||||||
...useAiWindow(),
|
...useAiWindow(),
|
||||||
...useAiPanel(),
|
...useAiPanel(),
|
||||||
|
|||||||
Reference in New Issue
Block a user