- R4 代码块语言角标:MessageList decorateCodeHtml 捕获 language-xxx 输出 data-lang+CSS ::before 左上角标,hover 显,无语言裸块不渲染 - R6 工具结果路径可复制:ToolResultBody 五处 file-bar 的 path span→button(read_file/list_directory/search_files/grep/patch_file),点击复制完整路径+✓/✕ 1.2s 反馈+execCommand 降级链 - I6 重新生成 UI 入口:MessageItem user 气泡 hover 补重新生成按钮(canRegenerate=isLastUser&&非流式),复用 handleRetry→store.regenerate 链路 - V4 浮窗审批 High 风险二次确认:useToolApproval 导出 isHighRiskTool/highRiskConfirmText,ApprovalPopup.onApprove 前置 ConfirmDialog,数字键守卫不绕确认 - 9-28#5 两处全局清队列残留:useAiEvents/useAiLifecycleEvents 无 convId 的 AiError 分支改保留队列+console.warn(防误伤其他会话排队消息) - i18n zh/en 补 clickToCopy 键;裸 @ 改 @ 转义(vue-i18n linked-message 前缀吞噬)
280 lines
15 KiB
TypeScript
280 lines
15 KiB
TypeScript
//! 生命周期事件域 — handleLifecycleEvent + cleanupTerminatedConversation
|
|
//!
|
|
//! 处理:AiCompleted / AiError / AiHelpRequired / AiTaskParked + 上下文生命周期事件(AiCompressing/
|
|
//! AiManualCompressed/AiAutoCompressed/AiContextCleared)。
|
|
//! 依赖方向:本模块 → aiShared / useAiPendingState(叶子)/ useAiStream / streamingGuard /
|
|
//! useAiConversations,不导入任何同级子 composable。
|
|
|
|
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, convStates, clearApprovalTimer, flushCurrentText, clearAllToolSlowTimers, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals, notifyQueueCleared } from './aiShared'
|
|
import { clearTextIdleTimer, pendingMaxRounds, pendingHelp, pendingDirAuths, pendingTaskParked, pendingTaskProgress } from './useAiPendingState'
|
|
import { clearStreamWatchdog, clearResumeAttempts } from './useAiStream'
|
|
import { setStreaming } from './streamingGuard'
|
|
import type { AiChatEvent, AiMessage, MessageId } from '@/api/types'
|
|
|
|
const appSettings = useAppSettingsStore()
|
|
|
|
// 上下文管理生命周期事件回调(useAiContext.initContextListener 注入,本域统一分发)
|
|
export interface ContextCallbacks {
|
|
/** 压缩开始(AiCompressing):置 loading=true */
|
|
onCompressing?: () => void
|
|
/** 手动压缩成功(AiManualCompressed):置 loading=false + toast + 回填摘要 */
|
|
onManualCompressed?: (conversationId: string, summary: string) => void
|
|
/** 自动压缩成功(AiAutoCompressed):静默置 loading=false(不弹 toast) */
|
|
onAutoCompressed?: () => void
|
|
/** 上下文已清空(AiContextCleared):toast + 刷新消息列表 */
|
|
onCleared?: (conversationId: string) => void
|
|
/** 错误(AiError,含压缩失败):仅在 loading 时置 false + toast */
|
|
onError?: (conversationId: string, message: string) => void
|
|
}
|
|
let _contextCallbacks: ContextCallbacks = {}
|
|
|
|
/** 注入上下文生命周期回调(useAiContext.initContextListener 调用) */
|
|
export function setContextCallbacks(callbacks: ContextCallbacks): void {
|
|
_contextCallbacks = callbacks
|
|
}
|
|
|
|
/** token 用量展示开关(读 appSettings,与 Settings.vue 共享 key `df-show-token-usage`) */
|
|
function isShowTokenUsage(): boolean {
|
|
return appSettings.get<boolean>('df-show-token-usage', false)
|
|
}
|
|
|
|
/**
|
|
* 公共会话终止收尾(AiCompleted/AiError/AiHelpRequired/AiTaskParked 共用):清看门狗/计时器/流式态/挂起。
|
|
*
|
|
* 语义差异(调用方自行处理):
|
|
* - AiCompleted: 调用后追加 incomplete 气泡 / token 用量 / 队列 drain
|
|
* - AiError: 调用后追加错误气泡 + 清 queue
|
|
* - AiHelpRequired: 调用后翻 pendingHelp 驱动求助卡
|
|
* - AiTaskParked: 调用后登记 pendingTaskParked 驱动停靠卡(摘旧卡;不清 pendingHelp,
|
|
* 熔断并存场景见下方 pendingHelp 分支注释)
|
|
*/
|
|
function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | 'AiError' | 'AiHelpRequired' | 'AiTaskParked') {
|
|
clearStreamWatchdog(convId || undefined)
|
|
// 正常收尾路径清该会话的 delta 去重记录:flushCurrentText 只清活跃会话的,这里按 convId
|
|
// 显式清一遍兜住 convId 与 active 不一致或事件缺 convId 的时序,防残留记录误丢下轮同内容 delta
|
|
clearLastDelta(convId || undefined)
|
|
// 正常收尾清自动续跑计数:计数只服务于单轮内连续超时的风暴防护,终态后必须归零,
|
|
// 否则陈旧计数会让下一轮的正常超时提前触发强制收敛(清零只放收尾路径,不在发消息时清)
|
|
clearResumeAttempts(convId || undefined)
|
|
clearAllToolSlowTimers() // 整轮/错误/求助结束清全部工具慢执行计时器与已提示集合
|
|
clearTextIdleTimer() // 清文本空闲定时器 + 置 textIdle=true(整轮结束不该有活跃信号残留)
|
|
flushCurrentText()
|
|
state.currentText = ''
|
|
// 清「重试中」过渡气泡(成功后残留根治;AiStreamRetry 推的 retry 气泡是过渡态,终态不清会永久残留)。
|
|
// 按 isRetry 标记匹配,替代旧中文文案子串匹配(en locale 文案同样生效,文案改版不再漏清)
|
|
for (let i = state.messages.length - 1; i >= 0; i--) {
|
|
const m = state.messages[i]
|
|
if (m.isRetry === true) {
|
|
state.messages.splice(i, 1)
|
|
}
|
|
}
|
|
setStreaming(false, { convId: convId || null, reason })
|
|
state.agentRound = 0 // 结束/中断/求助,复位轮次(隐藏进度条)
|
|
// convStates 收敛:AiError 保留 error 项(停止按钮显「重试」态),其余删项回 idle
|
|
if (reason === 'AiError') {
|
|
if (convId) convStates.set(convId, 'error')
|
|
} else {
|
|
convStates.delete(convId)
|
|
}
|
|
// per-conv 挂起仅清本 conv(避免清错其他会话的挂起)
|
|
if (pendingMaxRounds.value && pendingMaxRounds.value === convId) {
|
|
pendingMaxRounds.value = null
|
|
}
|
|
if (pendingHelp.value && pendingHelp.value.conversationId === convId) {
|
|
// 熔断场景后端并存发求助卡+停靠卡(agentic/mod.rs 熔断分支先 emit 求助卡再「补发」
|
|
// AiTaskParked,注释自称并存意图):停靠收尾不清求助卡,help options 三选项不丢,
|
|
// 两卡并存供用户任选入口。其余终态(AiCompleted/AiError/AiHelpRequired)照旧清。
|
|
if (reason !== 'AiTaskParked') pendingHelp.value = null
|
|
}
|
|
// 无人值守停靠卡:会话有任何终态(含停靠本身)即摘旧卡;停靠 case 在收尾后重新登记,
|
|
// 其余终态(AiCompleted/AiError,点继续续跑后的正常收敛/出错)摘卡即消失,语义对齐 pendingHelp
|
|
pendingTaskParked.delete(convId)
|
|
// 无人值守进度快照:终态即清(事件即逝态,终止后不留陈旧「进行中」;下次 loop 每 5 轮重建)
|
|
pendingTaskProgress.delete(convId)
|
|
// 路径授权挂起仅清本 conv(终止只清本会话弹窗,不连累并发会话;无归属旧项保守保留)
|
|
pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId !== convId)
|
|
// 审批收尾仅清目标 conv 的待审批项 + 对称清其超时计时器(其他会话的审批卡/计时器不受影响;
|
|
// conversationId 缺失的旧项无法归属,保守保留防误清正在审批的卡)
|
|
const removedApprovals = state.pendingApprovals.filter(p => p.conversationId === convId)
|
|
state.pendingApprovals = state.pendingApprovals.filter(p => !p.conversationId || p.conversationId !== convId)
|
|
for (const p of removedApprovals) clearApprovalTimer(p.id)
|
|
// 本会话已终止,其 per-conv 挂起集合作废(仅删集合项,视图保留上面无法归属的旧项;
|
|
// 切回该会话时由 pendingToolCalls 从后端审计表按需重建)
|
|
discardConvPendingApprovals(convId || undefined)
|
|
// 清分离窗口生成态快照(per-conv key,清本会话;兼容旧单 key)
|
|
if (convId) {
|
|
localStorage.removeItem(`df-ai-gen-${convId}`)
|
|
localStorage.removeItem(`df-ai-text-${convId}`)
|
|
}
|
|
localStorage.removeItem('df-ai-gen')
|
|
localStorage.removeItem('df-ai-text')
|
|
}
|
|
|
|
/**
|
|
* 登记无人值守停靠卡(幂等,convId 键控覆盖)。lifecycle 主路径与 useAiEvents 后台会话
|
|
* 分支共用唯一写入口,防止两处字段口径漂移。convId 缺失兜底当前活跃会话(优于丢卡)。
|
|
*/
|
|
export function recordTaskParked(convId: string | null | undefined, reason: string, stopKind?: string): void {
|
|
const id = convId || state.activeConversationId
|
|
if (!id) return
|
|
pendingTaskParked.set(id, {
|
|
reason,
|
|
stopKind: stopKind || undefined,
|
|
conversationId: id,
|
|
parkedAt: Date.now(),
|
|
})
|
|
}
|
|
|
|
/** lifecycle 域:整轮收尾(AiCompleted)与异常中断(AiError)。返回 true=已处理。 */
|
|
export function handleLifecycleEvent(event: AiChatEvent): boolean {
|
|
switch (event.type) {
|
|
case 'AiCompleted': {
|
|
console.warn('[FE-AICOMPLETED] 收到 conv=', event.conversation_id, 'streaming=', state.streaming)
|
|
cleanupTerminatedConversation(event.conversation_id || '', 'AiCompleted')
|
|
console.warn('[FE-AICOMPLETED] cleanup 后 streaming=', state.streaming)
|
|
// 流中途失败保文:后端 emit AiCompleted(incomplete=true),前端追加系统提示气泡。
|
|
// incomplete_reason 细分文案:"max_rounds"=达上限自动收尾(非网络中断,语义不同),
|
|
// 其他/缺省=网络中断等异常走原提示。
|
|
if (event.incomplete) {
|
|
state.messages.push({
|
|
id: `incomplete-${nextMsgId()}` as MessageId,
|
|
role: 'assistant',
|
|
content: event.incomplete_reason === 'max_rounds' ? t('ai.responseIncompleteMaxRounds') : t('ai.responseIncomplete'),
|
|
timestamp: Date.now(),
|
|
})
|
|
}
|
|
// 直接从事件更新 pinned_goals,不等 loadConversations 异步刷新
|
|
if (event.pinned_goals && state.activeConversationId) {
|
|
const conv = state.conversations.find(c => c.id === state.activeConversationId)
|
|
if (conv) conv.pinned_goals = event.pinned_goals
|
|
}
|
|
// FE1(AC-EFF-F1-1/2/3):去掉显式 loadConversations(曾与下方 notify 自触发双拉),
|
|
// 列表刷新统一收敛到 ai-conversation-changed listener 的防抖调度,一次收尾只拉一次。
|
|
// token 用量记录(开关开时):lastTokenUsage 供当前回复展示,convTokenTotal 累加对话总量
|
|
if (isShowTokenUsage()) {
|
|
state.lastTokenUsage = {
|
|
prompt: event.prompt_tokens,
|
|
completion: event.completion_tokens,
|
|
total: event.total_tokens,
|
|
cache_hit: event.prompt_cache_hit_tokens,
|
|
cache_miss: event.prompt_cache_miss_tokens,
|
|
reasoning: event.reasoning_tokens,
|
|
}
|
|
if (state.convTokenTotal) {
|
|
state.convTokenTotal.prompt += event.prompt_tokens
|
|
state.convTokenTotal.completion += event.completion_tokens
|
|
state.convTokenTotal.total += event.total_tokens
|
|
} else {
|
|
state.convTokenTotal = { prompt: event.prompt_tokens, completion: event.completion_tokens, total: event.total_tokens }
|
|
}
|
|
// 每轮 token 写入最后一条 AI 消息,供 MessageList 逐条显示
|
|
// 口径说明:后端 AiCompleted 每 loop 只发一次,携带本 loop 累计用量,故 live 态
|
|
// 聚合到「最后一条 assistant」;reload(switchConversation)从 DB 逐条回显各轮本轮量
|
|
// (消息级 token)。两者展示层级不同(live=loop 聚合,reload=单轮),属已接受口径。
|
|
for (let i = state.messages.length - 1; i >= 0; i--) {
|
|
const m = state.messages[i]
|
|
if (m.role === 'assistant' && !m.isError) {
|
|
m.tokenUsage = {
|
|
prompt: event.prompt_tokens,
|
|
completion: event.completion_tokens,
|
|
cache_hit: event.prompt_cache_hit_tokens,
|
|
cache_miss: event.prompt_cache_miss_tokens,
|
|
reasoning: event.reasoning_tokens,
|
|
is_estimated: event.is_estimated,
|
|
}
|
|
m.prompt_cache_hit_tokens = event.prompt_cache_hit_tokens
|
|
m.prompt_cache_miss_tokens = event.prompt_cache_miss_tokens
|
|
m.reasoning_tokens = event.reasoning_tokens
|
|
break
|
|
}
|
|
}
|
|
}
|
|
notifyConversationChanged()
|
|
// 队列续发:当前完成后自动发下一条(经事件总线桥接,避免 import useAiSend 循环依赖)
|
|
emit('ai-drain-queue', { conversationId: event.conversation_id })
|
|
return true
|
|
}
|
|
|
|
case 'AiError': {
|
|
cleanupTerminatedConversation(event.conversation_id || '', 'AiError')
|
|
// 只清除出错会话的队列项,不误伤其他会话的排队消息;
|
|
// UX-P2-A2:清队从静默改 toast 提示条数(用户排队的消息无声消失原无任何反馈)
|
|
if (event.conversation_id) {
|
|
const dropped = state.queue.filter(q => q.conversationId === event.conversation_id).length
|
|
state.queue = state.queue.filter(q => q.conversationId !== event.conversation_id)
|
|
notifyQueueCleared(dropped)
|
|
} else {
|
|
// 9-28#5 收口:无 convId 的 AiError 属异常防御路径(后端 emit 必带),清全部队列
|
|
// 会误伤其他会话排队消息,改保留 + console.warn 留痕(不再弹「已清 N 条」误导用户)
|
|
console.warn('[ai] AiError 缺 conversation_id,跳过清队列(防误伤其他会话排队)')
|
|
}
|
|
state.messages.push({
|
|
id: `err-${nextMsgId()}` as MessageId,
|
|
role: 'assistant',
|
|
content: friendlyError(event.error),
|
|
isError: true,
|
|
errorType: event.error_type,
|
|
timestamp: Date.now(),
|
|
} as AiMessage)
|
|
// AiError 也用于压缩 IPC 失败:通知上下文回调复位 loading + 弹错误 toast(内部判 isCompressing 守卫)
|
|
_contextCallbacks.onError?.(event.conversation_id || '', event.error)
|
|
return true
|
|
}
|
|
|
|
case 'AiHelpRequired': {
|
|
// 求助协议:后端断路器熔断已 guard.reset(loop 终止)。按终止态收尾但不创建错误气泡,
|
|
// 改为翻 pendingHelp 驱动求助卡(reason + options 按钮供用户选)。
|
|
cleanupTerminatedConversation(event.conversation_id || '', 'AiHelpRequired')
|
|
// 用 convId 兜底(后端必带,无时默认当前活跃会话,优于丢卡片)
|
|
pendingHelp.value = {
|
|
reason: event.reason,
|
|
context: event.context,
|
|
options: event.options,
|
|
conversationId: event.conversation_id || state.activeConversationId || null,
|
|
}
|
|
// FE1:去掉显式 loadConversations(与下行 notify 双拉),由 listener 防抖调度统一收口。
|
|
notifyConversationChanged()
|
|
return true
|
|
}
|
|
|
|
case 'AiTaskParked': {
|
|
// 无人值守安全停靠(设计 §四):五路非正常终局统一出口,后端已保存现场并复位
|
|
// generating(guard 复位非死等)。对齐 AiHelpRequired 家族:终止态收尾不建错误气泡,
|
|
// 改登记 pendingTaskParked 驱动停靠卡(reason + stop_kind 标签 + 继续/停止归档)。
|
|
// 队列不动:停靠等用户拍板,排队消息不自动续发不静默清(继续/停止由用户决定)。
|
|
cleanupTerminatedConversation(event.conversation_id || '', 'AiTaskParked')
|
|
recordTaskParked(event.conversation_id, event.reason, event.stop_kind ?? undefined)
|
|
// FE1:停靠刷新侧栏(更新活跃时间排序),由 listener 防抖调度统一收口。
|
|
notifyConversationChanged()
|
|
return true
|
|
}
|
|
|
|
case 'AiCompressing':
|
|
_contextCallbacks.onCompressing?.()
|
|
return true
|
|
|
|
case 'AiManualCompressed': {
|
|
// 手动 IPC 压缩:复位 loading + 弹 toast + 刷新视图回填摘要
|
|
_contextCallbacks.onManualCompressed?.(event.conversation_id || '', event.summary || '')
|
|
return true
|
|
}
|
|
|
|
case 'AiAutoCompressed': {
|
|
// loop 自动压缩对桌面静默——仅复位 loading(不弹 toast、不打断 LLM 流)
|
|
_contextCallbacks.onAutoCompressed?.()
|
|
return true
|
|
}
|
|
|
|
case 'AiContextCleared':
|
|
_contextCallbacks.onCleared?.(event.conversation_id || '')
|
|
return true
|
|
|
|
default:
|
|
return false
|
|
}
|
|
}
|