Files
DevFlow/src/composables/ai/useAiEvents.ts
T
lxy a30f599fe2 优化: 上下文管理与提示词链路批
- T1 usage 口径: anthropic usage 两层容错+cache 三口径归一(prompt=input+read+creation)
- T2 压缩水位判据: compress_should_trigger 双模式+ratio 钳位,开关关逐字节等价
- T3 跨会话检索: history_search 工具(两跳式)+message_repo 关键词检索+prompt 指引
- T4 代语义闭环: status 终局补写+飞书停靠/换代推卡+交付引导+watcher 兜底补漏
- 段序缓存重排: build_runtime_prompt 恒静段上移/工作上下文压尾(前缀缓存失效面收窄)
- 智谱缓存解析: anthropic 臂补 prompt_tokens_details.cached_tokens 嵌套回退
- 工时禁令: 提示词双语加「禁止预估工时,AI Coding 工时无意义」
- 拖拽文件落盘: 非图片附件存引用行,防整段文本灌入输入框
- 事实修正: 提示词「40+ 工具」→「80+」(基线 81);tool_failure_memory 测试串行锁除竞态
- UI 拆分: TopBar/ToolResultBody 壳层化(top-bar/tool-result-body 子件目录)
2026-10-26 01:36:00 +08:00

370 lines
20 KiB
TypeScript

//! AI 事件监听与分发(分派器)— startListener(租约)/ ensureListenerStarted(幂等确保)/ handleEvent
//!
//! 拆分(批次 A-B1):14 个事件 case 按域拆到 3 个叶子 handler:
//! - useAiStreamingEvents.handleStreamingEvent(流式)
//! - useAiToolEvents.handleToolEvent(工具)
//! - useAiLifecycleEvents.handleLifecycleEvent(生命周期)
//! 跨域共享的 helper/状态(textIdle/pendingMaxRounds/pendingHelp、工具慢执行
//! 计时器、delta 去重 Map、flushCurrentText/friendlyError/notifyConversationChanged)已下沉到
//! aiShared 或 useAiPendingState(叶子)。依赖方向:分派器 → 各叶子 handler → aiShared / 叶子(无环)。
//!
//! handleEvent 保留外围逻辑(convId 同步/路由/看门狗)与 L2 状态机、跨端用户消息两个域内 handler。
//! useAiEvents() 返回 shape 保持不变(stores/ai.ts spread 形状不变 → 组件零改动)。
import { listen, emit } from '@tauri-apps/api/event'
import { t } from '@/i18n/i18n-helpers'
import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai'
import { nextMsgId, setConvState, convStates, switchingConvs, getConvStreamState, setConvCurrentText, setConvStreaming, clearConvStreamState, clearAllApprovalTimers, clearAllToolSlowTimers, flushCurrentText, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals, addConvAgentFinished, clearConvAgentFinished, setConvRound, clearConvRound, type AgentFinishedStatus } from './aiShared'
import { resetStreamWatchdog, clearAllStreamWatchdogs, clearResumeAttempts } from './useAiStream'
import { setStreaming } from './streamingGuard'
import { scheduleConversationsRefresh, activeConvKvKey } from './useAiConversations'
import { pendingTaskParked, pendingTaskProgress } from './useAiPendingState'
import { clearConvBoundaryCache } from './useTaskBoundary'
import { handleStreamingEvent } from './useAiStreamingEvents'
import { handleToolEvent } from './useAiToolEvents'
import { handleLifecycleEvent, recordTaskParked } from './useAiLifecycleEvents'
import type { AiChatEvent, MessageId } from '@/api/types'
// 跨域共享 helper re-export(原为 useAiEvents 导出,拆分后从 aiShared/叶子透传,消费方 import 路径不变)
export { getConvState } from './aiShared'
export { flushCurrentText, friendlyError, notifyConversationChanged }
export { setContextCallbacks } from './useAiLifecycleEvents'
export type { ContextCallbacks } from './useAiLifecycleEvents'
let _unlistenAiEvent: (() => void) | null = null
let _unlistenConvChanged: (() => void) | null = null
// onStreamTimeout 广播的审批计时器清理事件 unlistener
let _unlistenApprovalClear: (() => void) | null = null
// startListener 并发去重:防 onMounted 与 sendMessage 首发竞态下重复注册 listener
// (两回调写同一 state.currentText → 流式文字双倍)
let _startPromise: Promise<void> | null = null
// 租约计数(所有权模型,2026-10-05 根本修):仅组件挂载级 startListener 借/还,
// send 等运行时路径走 ensureListenerStarted 不计数——计数只由对称生命周期驱动,不漂移。
let _leaseCount = 0
const appSettings = useAppSettingsStore()
// 看门狗不重置的事件集合(审批等待/完成/错误/达 max/求助/停靠由各自 case 内 clear)。
// 模块级 Set 复用,避免每事件(delta/token 高频)新建数组字面量做 includes。
const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached', 'AiHelpRequired', 'AiTaskParked'])
// 后台会话完成 toast 的摘要截长(轻提醒只够判断「做完了/值不值得切回去看」)
const AGENT_FINISHED_TOAST_SUMMARY_MAX = 40
/** 全局审批类事件:跨会话放行,不因非当前会话被过滤。审批挂起是等用户决策的跨会话资产,
* 丢弃后用户在切回该会话前没有任何感知与处理入口;归口仍按 conversation_id 写 per-conv
* 待审批集合(aiShared.addPendingApproval),不污染当前视图。 */
function isGlobalApprovalEvent(event: AiChatEvent): boolean {
return event.type === 'AiApprovalRequired'
}
/** L2 状态机域:消费 AiConvStateChanged,写 convStates(per-conv conv_state 真相源)。
* 返回 true=已处理;无 convId 无法路由时忽略(防误写全局态)。 */
function handleConvStateEvent(event: AiChatEvent): boolean {
if (event.type !== 'AiConvStateChanged') return false
const convId = event.conversation_id || state.activeConversationId
if (!convId) return true
setConvState(convId, event.conv_state)
return true
}
/** S1 子代理完成域:消费 AiAgentFinished,写 per-conv 完成卡集合(aiShared.convAgentFinished)。
* AiChatEvent 联合类型(types.ts 归口)暂未收编该事件,经局部 shape 窄化取字段,不改 types。
* 归集挂在 isCurrent 守卫之前(同 AiConvStateChanged 范式):后台会话的子代理结束也要落卡,
* 切回仍可见;「不串当前视图」由消费侧只读当前会话数组天然保证,无需在此按活跃会话过滤。 */
function handleAgentFinishedEvent(event: AiChatEvent): boolean {
if ((event as { type?: string }).type !== 'AiAgentFinished') return false
const ev = event as unknown as {
agent_id: string
summary: string
status: AgentFinishedStatus
rounds: number
tokens: number
rejected_tools?: string[] | null
stop_kind?: string | null
}
const convId = event.conversation_id
if (!convId || !ev.agent_id) return true
addConvAgentFinished(convId, {
agentId: ev.agent_id,
summary: ev.summary,
status: ev.status,
rounds: ev.rounds,
tokens: ev.tokens,
// 后端 Option→null/缺省,卡片语义统一为缺省=零拒绝
rejected_tools: ev.rejected_tools ?? undefined,
// 结构化终止原因(可选,旧后端不发):存卡供卡面状态词优先消费,替代 summary 启发式
stop_kind: ev.stop_kind ?? undefined,
})
// P3-6:后台会话完成轻提醒(当前会话卡已在消息流可见,不弹)。
// composable 无组件上下文,复用 ai-toast 事件总线先例(ai-tool-slow-toast),AiChat listen 弹本地 toast;
// 多个子代理连发时 toast 单槽自然合并,无风暴面。
if (convId !== state.activeConversationId) {
// 未知 status 兜底按 completed 语气(防 t 回显键名);failed 用 warning 色与完成区分
const statusKey = ev.status === 'failed' ? 'failed' : ev.status === 'truncated' ? 'truncated' : 'completed'
const collapsed = ev.summary.replace(/\s+/g, ' ').trim()
const summary = collapsed.length > AGENT_FINISHED_TOAST_SUMMARY_MAX
? collapsed.slice(0, AGENT_FINISHED_TOAST_SUMMARY_MAX) + '…'
: collapsed
void emit('ai-toast', {
msg: t('ai.agentFinishedToast', { status: t(`ai.agentFinished.${statusKey}`), summary }),
type: ev.status === 'failed' ? 'warning' : 'info',
})
}
// 子代理执行是该会话的真实活跃信号(父 loop 在 spawn_agent 内等待):此前该事件穿透到
// 活跃事件分支会重置看门狗,现提前归集后显式补上,保持既有不误判停滞的语义。
resetStreamWatchdog(convId)
return true
}
/** 无人值守停靠域:消费 AiTaskParked(per-conv 落卡 + 后台会话收敛,挂起家族终局)。
* 返回 true=已处理。仅拦截后台会话:当前会话放行到 lifecycle 域统一收尾(看门狗/流式复位
* 在 cleanupTerminatedConversation,后台分支此处的收敛集合与其对齐但不重复触发两次)。
* 停靠卡必须 per-conv 落卡:多会话并发各自可停靠,后台会话的卡丢了用户切回前无从得知。 */
function handleTaskParkedEvent(event: AiChatEvent): boolean {
if (event.type !== 'AiTaskParked') return false
const convId = event.conversation_id || state.activeConversationId
if (convId && convId !== state.activeConversationId) {
recordTaskParked(convId, event.reason, event.stop_kind ?? undefined)
// 对齐 AiCompleted 家族后台分支的收敛:删状态项回不在生成、清 per-conv 流式态/去重记录/
// 挂起审批集合/自动续跑计数/进度快照,再刷新侧栏(防抖调度)
convStates.delete(convId)
clearConvStreamState(convId)
clearLastDelta(convId)
discardConvPendingApprovals(convId)
clearResumeAttempts(convId || undefined)
pendingTaskProgress.delete(convId)
scheduleConversationsRefresh()
return true
}
return false
}
/** 无人值守进度域:消费 AiTaskProgress(per-conv 快照,静默期「在干活」可见)。
* 返回 true=已处理。isCurrent 守卫之前归集(同停靠卡范式):后台会话的进度也要落 Map,
* 切回仍可见。进度事件即活体信号,顺带 resetStreamWatchdog(子代理长跑/重入退避期间
* 后端心跳之外的第二重存活证明,防看门狗误判停滞)。 */
function handleTaskProgressEvent(event: AiChatEvent): boolean {
if (event.type !== 'AiTaskProgress') return false
const convId = event.conversation_id || state.activeConversationId
if (!convId) return true
pendingTaskProgress.set(convId, {
round: event.round,
maxRounds: event.max_rounds,
tokensUsed: event.tokens_used,
updatedAt: Date.now(),
})
resetStreamWatchdog(convId)
return true
}
/** 跨端用户消息域:miniapp→桌面 user 气泡同步(仅处理 AiUserMessage)。
* 去重:末条已是同 content 的 user 则跳过(防本地乐观 push + 事件回灌双气泡)。 */
function handleUserMessageEvent(event: AiChatEvent): boolean {
if (event.type !== 'AiUserMessage') return false
// 多会话并行隔离:AiUserMessage 必带 conversation_id;非当前会话不污染当前视图,仅刷新列表
const uc = event.conversation_id ?? null
if (uc && state.activeConversationId && uc !== state.activeConversationId) {
// FE1:非当前会话的用户消息只刷新列表(走防抖调度,与收尾 notify 合并为一次)。
scheduleConversationsRefresh()
return true
}
const last = state.messages[state.messages.length - 1]
if (last && last.role === 'user' && last.content === event.message) {
return true
}
state.messages.push({
id: `user-${nextMsgId()}` as MessageId,
role: 'user',
content: event.message,
timestamp: Date.now(),
})
return true
}
/** 后端事件分发:按 conversation_id 路由,流式累积文本,工具状态流转,看门狗联动 */
export function handleEvent(event: AiChatEvent) {
const convId = event.conversation_id
// 首次收到事件时同步当前对话 id(后端自动建对话的场景)并持久化
if (convId && !state.activeConversationId) {
state.activeConversationId = convId
void appSettings.set(activeConvKvKey(), convId)
}
// L2 状态机:AiConvStateChanged 是 per-conv 状态信号,在 isCurrent 守卫之前处理
// (切走会话时也须更新 conv_state,防停止按钮/MaxRoundsCard 错态)
if (handleConvStateEvent(event)) return
// 跨端用户消息同步:在 isCurrent 守卫之前处理(补 user 气泡信号,非流式活跃事件)
if (handleUserMessageEvent(event)) return
// S1 子代理完成卡归集:在 isCurrent 守卫之前处理(per-conv 落卡,后台会话不丢卡)
if (handleAgentFinishedEvent(event)) return
// 无人值守停靠卡归集:在 isCurrent 守卫之前处理(per-conv 落卡,后台会话不丢卡)
if (handleTaskParkedEvent(event)) return
// 无人值守进度快照归集:在 isCurrent 守卫之前处理(per-conv 落 Map,后台会话不丢进度)
if (handleTaskProgressEvent(event)) return
// 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误/求助时刷新侧边栏;
// 全局审批类事件例外(isGlobalApprovalEvent),放行到下方按域分派
const isCurrent = !convId || convId === state.activeConversationId
if (!isCurrent && !isGlobalApprovalEvent(event)) {
if (event.type === 'AiCompleted' || event.type === 'AiError' || event.type === 'AiHelpRequired') {
// 非当前会话终止:收敛会话状态(删 Map 项回不在生成)
convStates.delete(convId || '')
// 同步清该会话 per-conv 流式态。残留的 streaming/currentText 是死数据:切回时会被
// switchConversation 恢复进 currentText,下轮发送的防御 flush 又把它写进上一轮气泡,
// 把已完成回复覆盖为残片;回复正文以切换时 DB 加载为准,流式态无需保留。
clearConvStreamState(convId || '')
// 对齐前台 cleanupTerminatedConversation 的 per-conv 收敛:清 delta 去重记录(残留记录
// 会在切回后把下一轮首个与上轮末个相同内容的 delta 误判重复丢弃)、待审批集合(终止即作废,
// 切回时由 pendingToolCalls 从后端审计表按需重建)、自动续跑计数(终止即清,不带入新轮)
clearLastDelta(convId || '')
discardConvPendingApprovals(convId || '')
clearResumeAttempts(convId || undefined)
// 终态即清进度快照(事件即逝态,会话已终止不留陈旧「进行中」)
pendingTaskProgress.delete(convId || '')
// #14 桌面任务卡:终态即清轮次(任务卡据 convRounds 渲染在途项,终止不留陈旧项)
clearConvRound(convId || '')
// FE1:后台会话终止只刷新列表(防抖调度,避免与 AiCompleted 收尾的 notify 双拉)。
scheduleConversationsRefresh()
// 后台会话终止也触发队列续发/清队(完成续发;错误清该会话队列)
if (event.type === 'AiCompleted') {
emit('ai-drain-queue', { conversationId: event.conversation_id })
} else if (event.type === 'AiError') {
// 9-28#5 收口:无 convId 的 AiError 属异常防御路径(后端 emit 必带),清全部队列
// 会误伤其他会话排队消息,改保留 + console.warn 留痕
if (event.conversation_id) {
state.queue = state.queue.filter(q => q.conversationId !== event.conversation_id)
} else {
console.warn('[ai] AiError 缺 conversation_id,跳过清队列(防误伤其他会话排队)')
}
}
} else if (event.type === 'AiTextDelta' && convId && switchingConvs.has(convId)) {
// 切换中缓冲:目标会话正被 switchConversation 拉取,其 delta 累积到 per-conv 流式态,
// 切换完成后由渲染层续显,避免切换窗口丢可见内容
setConvStreaming(convId, true)
setConvCurrentText(convId, (getConvStreamState(convId)?.currentText ?? '') + (event.delta ?? ''))
} else if (event.type === 'AiAgentRound' && convId && event.round > 0) {
// #14 桌面任务卡:后台会话轮次追踪(isCurrent=false 原本直接丢弃本事件,
// 任务卡需要后台轮次显示「进行中 N 轮」;取 max 防事件乱序回退)
setConvRound(convId, event.round)
}
return
}
// 无目标会话(事件缺 convId 且当前无活跃会话):流式 delta 无处累积,跳过写防 null 键无意义写入
if (!convId && !state.activeConversationId && event.type === 'AiTextDelta') return
// 活跃事件(delta/工具/新轮/审批结果)到达即标记该会话生成中(enum 兜底写入口;
// 后端 AiConvStateChanged{generating} 已先到时同值 set 幂等覆盖,无副作用)。
// 终态家族(AiCompleted/AiError/AiHelpRequired/AiTaskParked)除外:标记生成中会与
// 收尾的删项收敛打架(停靠后端已复位 generating,不能被兜底写回)
if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError' && event.type !== 'AiHelpRequired' && event.type !== 'AiTaskParked') {
setConvState(convId, 'generating')
}
// 流式看门狗:活跃事件重置;审批等待/完成/错误在 case 内 clear(per-conv,各会话独立计时)
if (!NO_RESET_WATCHDOG.has(event.type)) {
resetStreamWatchdog(convId || undefined)
}
// 按域分派(streaming → tool → lifecycle);未命中任一域 → 无操作
if (handleStreamingEvent(event)) return
if (handleToolEvent(event)) return
void handleLifecycleEvent(event)
}
/** 启动事件监听(幂等确保注册,无所有权——send 等运行时路径调用,不计入租约)。
* 所有权模型(2026-10-05 根本修):
* - ensureListenerStarted:仅保证 listener 存在,谁都可调、任意次,不持租约;
* - startListener:组件挂载用,返回 release 租约句柄,unmount 时调用归还;
* 最后一份租约归还才真拆 listener + 清全局计时器。
* 两者分离根治「send 裸调 start 无配对 stop → 裸计数只涨不跌 → 永不真拆」的结构性漂移:
* 计数只由组件挂载/卸载这一对对称生命周期驱动,不变量可静态审计。 */
export async function startListener(): Promise<() => void> {
await ensureListenerStarted()
_leaseCount++
let released = false
return () => {
if (released) {
// 双重归还是调用方 bug(组件卸载钩子重复执行等),dev 下即刻暴露防静默漂移
if (import.meta.env.DEV) console.error('[useAiEvents] listener 租约重复归还(调用方 bug)')
return
}
released = true
releaseLease()
}
}
/** 幂等确保 listener 已注册(并发去重;不持租约,不触发拆卸)。 */
export async function ensureListenerStarted(): Promise<void> {
if (_unlistenAiEvent && _unlistenConvChanged && _unlistenApprovalClear) {
return
}
if (_startPromise) return _startPromise
_startPromise = (async () => {
try {
_unlistenAiEvent = await aiApi.onEvent(handleEvent)
_unlistenConvChanged = await listen('ai-conversation-changed', (e) => {
// FE1:notify 自触发的刷新走防抖调度,同窗口内多次 notify 合并为一次列表拉取。
scheduleConversationsRefresh()
// 主窗口删除了本窗口当前展示的会话:清空视图防陈旧幽灵(分离窗口独立 JS context,经事件同步)。
const deleted = (e.payload as { deletedConvId?: string } | undefined)?.deletedConvId
if (deleted && state.activeConversationId === deleted) {
state.activeConversationId = null
state.messages = []
clearConvStreamState(deleted)
setStreaming(false, { convId: deleted, reason: 'conv-deleted' })
convStates.delete(deleted)
}
// S1:会话删除连带清其完成卡(Map 不持已删会话条目;存在即清,不限当前视图)
if (deleted) {
clearConvAgentFinished(deleted)
// 无人值守三件随会话同亡:停靠卡挂起 + 进度快照 + 边界镜像(边界生命周期=会话存续,设计 §二)
pendingTaskParked.delete(deleted)
pendingTaskProgress.delete(deleted)
clearConvBoundaryCache(deleted)
}
})
_unlistenApprovalClear = await listen('ai-approval-clear-timers', () => { clearAllApprovalTimers() })
emit('ai-client-ready', { lastConvId: state.activeConversationId })
} catch (err) {
console.error('[useAiEvents] startListener 注册失败,事件监听未生效:', err)
throw err
}
})()
try {
await _startPromise
} finally {
_startPromise = null
}
}
/** 归还一份租约:计数归零才真拆(多实例挂载时先退出的实例不误拆他人在用的 listener)。 */
function releaseLease() {
if (_leaseCount === 0) {
if (import.meta.env.DEV) console.error('[useAiEvents] 租约下溢(归还多于借出,调用方 bug)')
return
}
_leaseCount--
if (_leaseCount > 0) return
_unlistenAiEvent?.()
_unlistenConvChanged?.()
_unlistenApprovalClear?.()
_unlistenAiEvent = null
_unlistenConvChanged = null
_unlistenApprovalClear = null
// stop 清零去重 promise:极速 mount/unmount/mount(HMR)时,首 mount 的 _startPromise 可能仍 pending,
// 再 mount 若命中并发去重分支会返回这个已代表「旧注册」的过期 promise,新 listener 实际未注册
_startPromise = null
clearAllStreamWatchdogs()
clearAllToolSlowTimers()
clearAllApprovalTimers()
}
export function useAiEvents() {
return {
startListener,
ensureListenerStarted,
handleEvent,
flushCurrentText,
friendlyError,
notifyConversationChanged,
}
}