Files
DevFlow/src/composables/ai/useAiToolEvents.ts
T
lxy 39e0eac39d 优化: AI Chat 渲染与无障碍 + 文件查看 + 同步 IO 收口
- 渲染: 结果/过程分离(气泡只放最终结果,过程轮叙述+工具卡收进轨迹卡默认收起可展开——治『流式后正文消失』且不再 N 气泡打扰)
- 渲染 P0: v-memo 定向 dep(只有真正渲染流式块的 item 失效,其余取占位) + renderItems 桶 memo(签名覆盖全消费输入,item 引用稳定) + 指纹 watch 降为 O(1) toolEpoch
- TopBar 三处 O(n) messages computed 合并单次遍历;md 缓存改有界 LRU(除满即全清抖动);useTurnTrace 补 never 穷尽 + default;MessageItem 模板去重
- 无障碍(四批累计): 可点容器键盘化(含 role/tabindex/keydown/aria-expanded) + icon-only aria-label 约 60 处 + 装饰图标 aria-hidden + 52 个滚动容器 overscroll-behavior:contain + 设置开关聚焦可见环 + outline 零补偿站点补 :focus-visible
- UX 走查项: 灵感 IDEA-10/11、知识 KNOW-6/7/9、项目详情 O8、审计 SA-4、设置 SA-6/26/27/28(脏表单/冗余 save/clamp/删除二次确认)
- 文件查看专项: 切工程状态残留 F5/F6、FileTree 去重与展开反馈 F7/F15/F17、切文件头部残留 F18、R 状态着色 F19;FilePreview diff 文件头(---/+++)误当增删渲染修复 + 大纲行高亮死样式修复 + 图片失败文案 i18n 化
- 同步 IO 收口(异步上下文内阻塞): unattended 看板读写改 tokio::fs、fetch_url 探活改 spawn_blocking、http/generate_image 清理改 tokio::fs
- 子代理 P3 批量: 任务书钉扎上限/截断文案区分父子语境/环境画像日期/temperature 可覆盖/model 回退上报/lang 透传
- crate 清理: df-execute·df-types 注释与格式、df-ideas 过时注释、df-nodes subflow 夸大描述、df-project 死变量
2026-10-10 08:09:45 +08:00

146 lines
6.5 KiB
TypeScript

//! 工具事件域 — handleToolEvent
//!
//! 处理:AiToolCallStarted / AiToolCallCompleted / AiToolAutoApproved / AiApprovalRequired / AiApprovalResult。
//! 依赖方向:本模块 → aiShared / useAiPendingState(叶子)/ useAiStream,不导入任何同级子 composable。
import { emit } from '@tauri-apps/api/event'
import { state } from '@/stores/ai'
import { t } from '@/i18n/i18n-helpers'
import { findToolCall, clearApprovalTimer, startApprovalTimer, startToolSlowTimer, clearToolSlowTimer, addPendingApproval, removePendingApprovalById, bumpToolEpoch } from './aiShared'
import { removePendingDirAuth } from './useAiPendingState'
import { clearStreamWatchdog } from './useAiStream'
import type { AiChatEvent, AiToolCallInfo } from '@/api/types'
/** tool 域:工具卡片状态流转/会话级信任/审批。返回 true=已处理。 */
export function handleToolEvent(event: AiChatEvent): boolean {
switch (event.type) {
case 'AiToolCallStarted': {
const info: AiToolCallInfo = {
id: event.id,
name: event.name,
args: event.args,
status: 'running',
// 后端事件不带耗时,前端记开始时刻,Completed 时差值即单卡耗时
startedAt: Date.now(),
}
// 同 id 重复 Started 不重复 push(provider id 不稳,防残留 running 空卡)
if (!findToolCall(event.id)) {
// #15(2026-10-05):末条非 assistant(如 system 占位/思考 pill/完成轨迹折叠重排)时
// 原直接丢弃工具卡 → running 卡消失但工具仍在执行。兜底向前找最近 assistant 落卡;
// 全程无 assistant(极端:首轮工具前无任何回复)才真丢弃(与原行为一致)。
const lastMsg = state.messages[state.messages.length - 1]
let target = (lastMsg && lastMsg.role === 'assistant') ? lastMsg : null
if (!target) {
for (let i = state.messages.length - 1; i >= 0; i--) {
if (state.messages[i].role === 'assistant') { target = state.messages[i]; break }
}
}
if (target) {
target.toolCalls = target.toolCalls || []
target.toolCalls.push(info)
// 工具态变更 → 版本号自增(MessageList 收起联动 O(1) 检测;完成/待审批/拒绝经
// aiShared 的 pending 出入口自增,此处仅覆盖「新卡 push」这一不经 pending 的路径)
bumpToolEpoch()
}
}
// 工具开始执行即挂慢执行计时器(超时仅 toast,不动 running 态)
startToolSlowTimer(event.id, event.name)
return true
}
case 'AiToolCallCompleted': {
const tc = findToolCall(event.id)
if (tc) {
tc.status = 'completed'
tc.result = event.result
tc.completedAt = Date.now()
// 清流式缓冲:结果由 result 渲染,流式行是执行中间态,留着会双显
tc.streamLines = undefined
}
// 移除待审批(含 per-conv 集合,后台会话集合里的同 id 挂起一并清)
removePendingApprovalById(event.id)
clearToolSlowTimer(event.id)
// 工具结束(无论审批通过后执行还是被拒),清审批超时计时器
clearApprovalTimer(event.id)
return true
}
case 'AiToolAutoApproved': {
// 会话级信任:经事件总线桥接弹本地 toast(composable 无组件上下文)
void emit('ai-tool-auto-approved-toast', { tool: event.tool, dir: event.dir })
return true
}
case 'AiCommandOutput': {
// 工具执行实时流式(run_command shell 行级 / ask_uclaw 进度块级):
// 按 id 路由到对应工具卡,streamLines 追加(上限 200 行防长任务刷爆响应式数组,
// 超限丢头保尾——最新进度更有价值)。Completed 时清空(结果由 result 渲染,防双显)。
// 找不到工具卡(极早到/已收尾)静默忽略。
const tc = findToolCall(event.id)
if (tc) {
if (!tc.streamLines) tc.streamLines = []
tc.streamLines.push(event.line)
if (tc.streamLines.length > 200) tc.streamLines.splice(0, tc.streamLines.length - 200)
}
return true
}
case 'AiApprovalRequired': {
clearStreamWatchdog(event.conversation_id || undefined) // 等用户审批,不计整流超时
const info: AiToolCallInfo = {
id: event.id,
name: event.name,
args: event.args,
status: 'pending_approval',
kind: event.kind ?? 'risk',
// reason 必须写入 info,否则浮窗 ApprovalPopup 的 tc.reason 永远空(审批说明丢失)
reason: event.reason,
conversationId: event.conversation_id ?? state.activeConversationId ?? undefined,
}
// per-conv 归会话追加(同 id 幂等):跨会话事件不再互相覆盖;首次到达才做后续副作用
if (addPendingApproval(info)) {
const tc = findToolCall(event.id)
if (tc) {
tc.status = 'pending_approval'
tc.reason = event.reason
// write_file 审批注入行级 diff(旧文件 vs 新内容),前端审批卡预览
if (event.diff) tc.diff = event.diff
}
// 进入审批等待 → 取消该工具慢执行计时器(审批耗时由用户主导,非执行慢),再启动审批超时计时器
clearToolSlowTimer(event.id)
startApprovalTimer(event.id, event.name, 'risk')
// 通知 ToolCardList 展开折叠组 + scroll(pending 卡落折叠分组不可见时)
void emit('ai-pending-arrived', { toolCallId: event.id })
}
return true
}
case 'AiApprovalResult': {
if (!event.approved) {
const tc = findToolCall(event.id)
if (tc) {
tc.status = 'rejected'
tc.result = t('aiTool.rejectedHint')
}
// 移除待审批(含 per-conv 集合)
removePendingApprovalById(event.id)
clearToolSlowTimer(event.id)
clearApprovalTimer(event.id)
} else {
const tc = findToolCall(event.id)
// 后端对整批工具先批量发 Started 再走审批,审批等待不算执行耗时:通过时重置开始时刻
if (tc) tc.startedAt = Date.now()
// 审批通过 → 工具重回执行态,重启慢执行计时器;对称清审批超时计时器防到点误拒
startToolSlowTimer(event.id, tc?.name || '')
clearApprovalTimer(event.id)
}
// 路径授权挂起的工具收到 ApprovalResult → 按 id 移除该条(数组化后只清这一条)
removePendingDirAuth(event.id)
return true
}
default:
return false
}
}