重构: AI流式断线保文StreamResult三分支+重试对齐决策a1,推进链落df-nodes
This commit is contained in:
@@ -5,8 +5,10 @@
|
||||
//! - _startPromise: startListener 并发去重(防 onMounted 与 sendMessage 首发竞态重复注册)
|
||||
//!
|
||||
//! 耦合:
|
||||
//! - handleEvent 调 useAiConversations.loadConversations、useAiSend.drainQueue、
|
||||
//! - handleEvent 调 useAiConversations.loadConversations、
|
||||
//! useAiStream.{resetStreamWatchdog,clearStreamWatchdog}、本模块 flushCurrentText/findToolCall/notifyConversationChanged
|
||||
//! - drainQueue 经 ai-drain-queue 事件总线桥接(原直接 import useAiSend 构成循环依赖,已破除)
|
||||
//! - 审批计时器(startApprovalTimer/clearApprovalTimer/clearAllApprovalTimers)已下沉到 aiShared(同上)
|
||||
//! - nextMsgId 已下沉到 aiShared(原为本模块导出,useAiStream 亦依赖之构成循环依赖,故抽出)
|
||||
|
||||
import { listen, emit } from '@tauri-apps/api/event'
|
||||
@@ -15,15 +17,15 @@ import { aiApi } from '@/api'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { state } from '@/stores/ai'
|
||||
import i18n from '@/i18n'
|
||||
import { nextMsgId } from './aiShared'
|
||||
import { nextMsgId, findToolCall, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers } from './aiShared'
|
||||
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
|
||||
import { drainQueue, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers } from './useAiSend'
|
||||
import { loadConversations } from './useAiConversations'
|
||||
import type { AiChatEvent, AiMessage, AiToolCallInfo } from '@/api/types'
|
||||
|
||||
// composable 内非组件上下文(无 setup),用 vue-i18n 全局实例的 t 而非 useI18n()。
|
||||
// 通过 any 中转规避 vue-i18n 深度 message schema 泛型导致的 TS2589(类型实例化过深)。
|
||||
const t = ((i18n as any).global.t as (key: string) => string).bind((i18n as any).global)
|
||||
// CR-30-2: 签名放宽支持插值参数(复用现有 t('ai.xxx', {...}) 模式,如 aiShared.ts:75)。
|
||||
const t = ((i18n as any).global.t as (key: string, params?: Record<string, unknown>) => string).bind((i18n as any).global)
|
||||
|
||||
let _unlistenAiEvent: (() => void) | null = null
|
||||
let _unlistenConvChanged: (() => void) | null = null
|
||||
@@ -113,31 +115,6 @@ export function flushCurrentText() {
|
||||
if (last && last.role === 'assistant') last.content = state.currentText
|
||||
}
|
||||
|
||||
/**
|
||||
* 在全部消息中查找指定 id 的工具调用卡片(用于状态流转)
|
||||
*
|
||||
* 扫描策略:从尾部反向遍历并提前退出。tool_result/approval/completed
|
||||
* 通常对应最近触发的 tool_use(agent 顺序执行工具并尽快回填结果),
|
||||
* 反向扫描使其命中落在尾部 → 平均 O(1)、最坏 O(n)。(原正向全量扫描为 O(n²),
|
||||
* 每条 tool_result 都从头扫整条历史。)
|
||||
*
|
||||
* 注:未引入 Map<id, tc> 索引。state.messages 在 newConversation /
|
||||
* switchConversation / deleteConversation 等处被整体替换(且这些操作散落在
|
||||
* useAiEvents 之外),独立索引极易与响应式数组失配形成"陈旧引用"——状态卡
|
||||
* 片读到已不存在的对象。反向扫描零额外状态、改动最小且行为完全一致。
|
||||
*/
|
||||
export function findToolCall(id: string): AiToolCallInfo | undefined {
|
||||
for (let i = state.messages.length - 1; i >= 0; i--) {
|
||||
const toolCalls = state.messages[i].toolCalls
|
||||
if (!toolCalls) continue
|
||||
// 同一消息内工具调用通常很少,沿用正向扫描;命中即返回
|
||||
for (let j = 0; j < toolCalls.length; j++) {
|
||||
if (toolCalls[j].id === id) return toolCalls[j]
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** token 用量展示开关(读 appSettings,与 Settings.vue 共享 key `df-show-token-usage`) */
|
||||
function isShowTokenUsage(): boolean {
|
||||
return appSettings.get<boolean>('df-show-token-usage', false)
|
||||
@@ -197,6 +174,17 @@ export function handleEvent(event: AiChatEvent) {
|
||||
// 心跳事件:仅维持看门狗(已在上方 reset),无需额外处理;显式 case 防 switch 穿透
|
||||
break
|
||||
|
||||
case 'AiStreamRetry': {
|
||||
// CR-30-2 / F-260616-07(d) / 决策 a1: 流前失败重试中——后端 emit AiError(Init Err)
|
||||
// 后再 emit 此事件。前端更新错误气泡内容为「重试 n/m」,对齐决策"错误气泡内更新"。
|
||||
// 看门狗已在上方 NO_RESET_WATCHDOG 之外(此事件不在集合内)reset,不卡整流超时。
|
||||
const lastErr = state.messages[state.messages.length - 1]
|
||||
if (lastErr && lastErr.isError) {
|
||||
lastErr.content = t('ai.aiStreamRetry', { attempt: event.attempt, max: event.max_attempts })
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiMaxRoundsReached':
|
||||
// F-260616-03: 达 max_iterations 暂停态(后端仍 generating=true),前端展示操作卡询问。
|
||||
// 后端已 save 落库,这里仅翻 pendingMaxRounds=true 驱动 UI 卡片;看门狗已由
|
||||
@@ -284,6 +272,17 @@ export function handleEvent(event: AiChatEvent) {
|
||||
state.generatingConvId = null
|
||||
state.agentRound = 0 // AE-2025-07: agentic 结束,复位轮次(隐藏进度条)
|
||||
pendingMaxRounds.value = false // F-260616-03: 收尾(停止/续跑后新一轮达 max 才会再 set),清操作卡
|
||||
// UX-2025-04 / CR-30-2 / 决策 a1: 流中途失败保文——后端 emit AiCompleted(incomplete=true),
|
||||
// 前端追加系统提示气泡(镜像后端 session.messages 的 system 提示)。
|
||||
// 注:此系统提示仅前端展示,后端已独立 push 到 session.messages 落库。
|
||||
if (event.incomplete) {
|
||||
state.messages.push({
|
||||
id: `incomplete-${nextMsgId()}`,
|
||||
role: 'assistant',
|
||||
content: t('ai.responseIncomplete'),
|
||||
timestamp: Date.now(),
|
||||
} as AiMessage)
|
||||
}
|
||||
// token 用量记录(开关开时):lastTokenUsage 供当前回复展示,convTokenTotal 累加对话总量
|
||||
if (isShowTokenUsage()) {
|
||||
state.lastTokenUsage = {
|
||||
@@ -304,8 +303,8 @@ export function handleEvent(event: AiChatEvent) {
|
||||
localStorage.removeItem('df-ai-text')
|
||||
void loadConversations()
|
||||
notifyConversationChanged()
|
||||
// 队列续发:当前完成后自动发下一条(后端 generating 已复位,不会被"正在生成中"拒绝)
|
||||
drainQueue()
|
||||
// 队列续发:当前完成后自动发下一条(经事件总线桥接,避免 import useAiSend 构成循环依赖)
|
||||
emit('ai-drain-queue', {})
|
||||
break
|
||||
}
|
||||
|
||||
@@ -379,7 +378,6 @@ export function useAiEvents() {
|
||||
stopListener,
|
||||
handleEvent,
|
||||
flushCurrentText,
|
||||
findToolCall,
|
||||
friendlyError,
|
||||
notifyConversationChanged,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user