优化: aichat 体验收尾(useAiEvents拆分4子域 + AIC-FIX P1队列/mutex/缓存 + i18n残留抽取)
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
//! 流式事件域 — handleStreamingEvent
|
||||
//!
|
||||
//! 处理:AiTextDelta / AiAgentRound / AiHeartbeat / AiStreamRetry / AiMaxRoundsReached / AiDirAuthRequired。
|
||||
//! 依赖方向:本模块 → 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 } from './aiShared'
|
||||
import { resetTextIdleTimer, clearTextIdleTimer, pendingMaxRounds, 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 域:流式文本累积/新轮/心跳/重试/max 轮暂停。返回 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()
|
||||
state.completedTools = 0 // 新轮重置工具计数器
|
||||
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
|
||||
return true
|
||||
}
|
||||
|
||||
case 'AiHeartbeat':
|
||||
// 心跳仅维持看门狗(已在上方 reset),显式 case 防 switch 穿透
|
||||
return true
|
||||
|
||||
case 'AiStreamRetry': {
|
||||
// 重试中——首条重试推新错误气泡,后续仅更新其 content(单气泡聚合,根治 N+1 气泡)
|
||||
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
|
||||
} else {
|
||||
state.messages.push({
|
||||
id: `err-${nextMsgId()}` as MessageId,
|
||||
role: 'assistant',
|
||||
content: retryText,
|
||||
isError: true,
|
||||
timestamp: Date.now(),
|
||||
} as AiMessage)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
case 'AiMaxRoundsReached': {
|
||||
// 达 max 暂停态(后端仍 generating=true):看门狗不计整流超时(clear),挂起 convId 驱动操作卡。
|
||||
// 无 convId 时保留原值兜底(达 max 事件理论上必带 convId,缺失默认当前活跃会话)。
|
||||
clearStreamWatchdog(event.conversation_id || undefined)
|
||||
pendingMaxRounds.value = event.conversation_id || state.activeConversationId || pendingMaxRounds.value
|
||||
return true
|
||||
}
|
||||
|
||||
case 'AiDirAuthRequired': {
|
||||
// 路径授权挂起(后端 generating 保持 true,等用户决策):看门狗跳过 reset,由本 case clear。
|
||||
clearStreamWatchdog(event.conversation_id || undefined)
|
||||
clearToolSlowTimer(event.id)
|
||||
// 统一审批模型开:path 挂起归一进 pendingApprovals(kind='path'),ToolCard 内联审批。
|
||||
// 同 id 幂等(provider id 不稳或重发时不重复 push 防残留卡)。
|
||||
if (isUnifiedApproval()) {
|
||||
if (!state.pendingApprovals.some(p => p.id === event.id)) {
|
||||
state.pendingApprovals.push({
|
||||
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 }),
|
||||
})
|
||||
// 同步改对应工具卡的 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user