Files
DevFlow/src/composables/ai/useAiStreamingEvents.ts
T
lxy 2441101978 新增: 挂机模式 P1 总装: 设置面板+顶栏徽章+边界卡精简+监控告警推送
- 新增 UnattendedPanel.vue 挂机配置面板(引擎自动认领看板任务,Settings KV 热读即改即生效)
- Settings.vue 接入挂机面板;searchIndex.ts 补挂机搜索词条
- TopBar.vue 新增挂机进行中徽章(脉冲动效+透明度过渡)
- TaskBoundaryCard.vue 精简边界状态判定(wallState 合并比对,删冗余 watch)
- i18n zh-CN/en: settings/ai/aiChat 六文件补挂机词条双语
- unattended.rs 新增 push_monitor_alert 监控告警推送(异常快照+告警头直发 monitor_chat)
- feishu_notify.rs push/push_card 出口重构收敛
- lib.rs 注册 unattended_status/unattended_set_enabled 两 IPC
- aiShared.ts/useAiStreamingEvents.ts 挂机状态流接入
2026-10-07 22:03:41 +08:00

153 lines
7.4 KiB
TypeScript

//! 流式事件域 — handleStreamingEvent
//!
//! 处理:AiTextDelta / AiAgentRound / AiHeartbeat / AiStreamRetry / AiDirAuthRequired。
//! AiMaxRoundsReached 不处理:后端达 max 已改 AiCompleted(incomplete=true) 自动完成(暂停态
//! 语义已废),该事件后端零 emit;pendingMaxRounds/MaxRoundsCard 续停操作卡为遗留死链待清理。
//! 依赖方向:本模块 → aiShared / useAiPendingState(叶子)/ useAiStream,不导入任何同级子 composable。
//! _lastDelta 去重 Map 与 flushCurrentText 因跨域共用已下沉 aiShared,这里仅经 accessor 读写。
import { emit } from '@tauri-apps/api/event'
import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai'
import { t } from '@/i18n/i18n-helpers'
import { nextMsgId, findToolCall, startApprovalTimer, clearToolSlowTimer, getLastDelta, setLastDelta, flushCurrentText, addPendingApproval, setConvRound } from './aiShared'
import { resetTextIdleTimer, clearTextIdleTimer, pendingDirAuths, pushPendingDirAuth } from './useAiPendingState'
import { clearStreamWatchdog } from './useAiStream'
import type { AiChatEvent, AiMessage, MessageId } from '@/api/types'
const appSettings = useAppSettingsStore()
// path_auth 统一审批模型开关(appSettings key `df-ai-unified-approval`):开=path 挂起归一进
// pendingApprovals(kind='path',ToolCard 内联审批);关=走 pendingDirAuths 独立弹窗(DirAuthDialog)。
function isUnifiedApproval(): boolean {
return appSettings.get<boolean>('df-ai-unified-approval', true)
}
/** streaming 域:流式文本累积/新轮/心跳/重试。返回 true=已处理。 */
export function handleStreamingEvent(event: AiChatEvent): boolean {
switch (event.type) {
case 'AiTextDelta': {
// 连续完全相同 delta(且非空)丢弃第二次(双窗口 listener 或 provider 异常重复,留诊断证据)
const convRef = event.conversation_id || state.activeConversationId
const lastDelta = getLastDelta(convRef)
if (event.delta && lastDelta === event.delta && event.delta.length > 1) {
if (appSettings.get('df-ai-trace-delta', true)) {
console.warn('[AiTextDelta] 重复 delta 已丢弃(疑似双窗口 listener 或 provider 异常):', JSON.stringify(event.delta))
}
return true
}
setLastDelta(convRef, event.delta)
state.currentText += event.delta
// 重置文本空闲定时器:每次 delta 重置 300ms,无新 delta 到 300ms → textIdle=true → 按钮白
resetTextIdleTimer()
return true
}
case 'AiAgentRound': {
// 新一轮:保存当前文本到上一条 assistant 消息,新建空 assistant 消息,清空闲定时器。
// 不设 streaming=false(streaming 管渲染,多轮间需持续 true 让 MessageList 渲染后续 deltas)。
flushCurrentText()
state.currentText = ''
clearTextIdleTimer()
// 防空气泡叠罗汉(BUG-2026-10-07):flush 后若末条仍是「空 content 且非 isError 的
// assistant 气泡」,说明上一轮 LLM 只发 tool_calls 无文本(工具结果在 toolCalls 桶不在
// content),直接复用该气泡承接本轮,不再新推——否则纯工具轮每轮推一个空气泡,
// 折叠后尾泡渲染成「空气泡+光标」(BUG-260624 表症根因之二)。
const tail = state.messages[state.messages.length - 1]
const reusableTail = tail && tail.role === 'assistant' && !tail.isError && !tail.content?.trim()
if (!reusableTail) {
state.messages.push({
id: `ai-${nextMsgId()}` as MessageId,
role: 'assistant',
content: '',
timestamp: Date.now(),
})
}
// round>0 才更新进度条(round=0 是审批通过后「隔开新一轮」的占位,实际轮次未推进,防闪烁)
if (event.round > 0) state.agentRound = event.round
// #14 桌面任务卡:per-conv 轮次追踪,前台会话在此写(取 max 防事件乱序回退);
// 后台会话在 useAiEvents 非 current 分支的 return 前补写(彼处 AiAgentRound
// 原本直接穿透丢弃,任务卡需要后台轮次才能显示「进行中 N 轮」)
if (event.round > 0) setConvRound(event.conversation_id || state.activeConversationId, event.round)
return true
}
case 'AiHeartbeat':
// 心跳仅维持看门狗(已在上方 reset),显式 case 防 switch 穿透
return true
case 'AiStreamRetry': {
// 重试中——首条重试推过渡气泡(isRetry 标记,MessageList 渲染为琥珀细条而非错误红卡),
// 后续仅更新其 content(单气泡聚合,根治 N+1 气泡)。isError 保持 true:
// flushCurrentText 靠它跳过本气泡,部分流式回复仍回填前一条 assistant 消息。
const lastMsg = state.messages[state.messages.length - 1]
const retryText = t('ai.aiStreamRetry', { attempt: event.attempt, max: event.max_attempts })
if (lastMsg && lastMsg.isError) {
lastMsg.content = retryText
lastMsg.isRetry = true
} else {
state.messages.push({
id: `err-${nextMsgId()}` as MessageId,
role: 'assistant',
content: retryText,
isError: true,
isRetry: true,
timestamp: Date.now(),
} as AiMessage)
}
return true
}
case 'AiDirAuthRequired': {
// 路径授权挂起(后端 generating 保持 true,等用户决策):看门狗跳过 reset,由本 case clear。
clearStreamWatchdog(event.conversation_id || undefined)
clearToolSlowTimer(event.id)
// 统一审批模型开:path 挂起归一进待审批集合(kind='path'),ToolCard 内联审批。
// per-conv 归会话追加(同 id 幂等):跨会话事件互不覆盖;首次到达才做工具卡改写等一次性副作用。
if (isUnifiedApproval()) {
const added = addPendingApproval({
id: event.id,
name: event.tool,
args: { path: event.path },
status: 'pending_approval',
kind: 'path',
dir: event.dir,
path: event.path,
conversationId: event.conversation_id ?? state.activeConversationId ?? undefined,
reason: t('aiChat.dirAuthHint', { tool: event.tool, path: event.path }),
})
if (added) {
// 同步改对应工具卡的 tc,让 ToolCard 显 path 类审批按钮(once/always/deny)
const tc = findToolCall(event.id)
if (tc) {
tc.status = 'pending_approval'
tc.kind = 'path'
tc.dir = event.dir
tc.path = event.path
tc.reason = t('aiChat.dirAuthHint', { tool: event.tool, path: event.path })
}
// path 类挂起同样启动审批超时计时器(到点回调调 authorizeDir(id,'deny'))
startApprovalTimer(event.id, event.tool, 'path')
}
// 通知 ToolCardList 自动展开折叠组 + scroll(挂起卡落折叠分组 display:none 用户不可见)
void emit('ai-pending-arrived', { toolCallId: event.id })
return true
}
// 统一审批模型关(兜底):走 DirAuthDialog 独立弹窗(push pendingDirAuths,开关控多挂起并存)
if (!pendingDirAuths.value.some(p => p.id === event.id)) {
pushPendingDirAuth({
id: event.id,
tool: event.tool,
path: event.path,
dir: event.dir,
conversationId: event.conversation_id,
})
}
return true
}
default:
return false
}
}