//! AI composables 共享层 — 多 composable 共用的模块级状态与纯函数 //! //! 存在意义:打破 composable 间循环依赖。 //! - 原环1(useAiEvents ↔ useAiStream):nextMsgId 下沉到本模块消除 //! - 原环2(useAiEvents ↔ useAiSend):findToolCall + 审批计时器 下沉到本模块消除 //! //! 下沉范围(严格限定为「无依赖、被多个 composable 共用」的成员): //! - nextMsgId / _msgCounter:events/stream/send/window 四个 composable 共用同一计数器 //! - findToolCall:events/send 两个 composable 共用的纯查找函数(仅读 state.messages) //! - 审批计时器(startApprovalTimer/clearApprovalTimer/clearAllApprovalTimers): //! events 模块触发(start/clear/allClear),send 模块也需导出给组件,故下沉到共享层 import { reactive } from 'vue' import { emit } from '@tauri-apps/api/event' import { aiApi } from '@/api' import { useAppSettingsStore } from '@/stores/appSettings' import { t } from '@/i18n/i18n-helpers' import type { AiMessage, AiToolCallInfo, ConvState, MessageId } from '@/api/types' /** 通知会话列表刷新(newConversation/deleteConversation/rename 等触发侧栏更新) */ export function notifyConversationChanged(payload?: { deletedConvId?: string }): void { emit('ai-conversation-changed', payload ?? {}) } /** 后端原始错误转用户友好提示 */ export function friendlyError(raw: string): string { if (/404|not\s*found/i.test(raw)) return t('ai.errorNotFound') if (/401|403|unauthorized|api[_\s-]?key/i.test(raw)) return t('ai.errorAuth') if (/timeout|超时/i.test(raw)) return t('ai.errorTimeout') if (/network|connection|ECONN|网络|连接/i.test(raw)) return t('ai.errorNetwork') return raw } /** * 解析 AI 回复语言(useAiSend.sendMessage / useAiContext.compressContext 共用)。 * * df-ai-language='auto' → 回落到 df-language(应用主语言);否则用 df-ai-language 指定值; * 兜底 'zh-CN'。 * * 原分别在 useAiSend.resolveLang(三元)与 useAiContext.resolveLanguage(if/else)各一份, * 语义相同仅写法不同,提取至此共用。 */ export function resolveAiLang(): string { const appSettings = useAppSettingsStore() const raw = appSettings.get('df-ai-language', 'auto') return raw === 'auto' ? appSettings.get('df-language', 'zh-CN') : raw } // ── Lazy messages getter(破 stores/ai ↔ aiShared 循环依赖) ── // // stores/ai.ts import aiShared.ts,而 aiShared.ts 若在模块顶层 import // stores/ai.ts 的 `state`,Vite 打包后可能因循环引用导致 TDZ 崩溃 // (ReferenceError: Cannot access 'u' before initialization)。 // 策略:stores/ai.ts 在 state 就绪后注入 getter 函数, // aiShared 函数在运行时通过 getMessages() 惰性调用 getter 获取最新数组。 // // 重要:getter 而非缓存引用 —— state.messages 在 switchConversation/newConversation // 时被整体替换(state.messages = []),缓存引用会指向陈旧数组(findToolCall 找不到 // 新数组中的工具卡片 → AiToolCallCompleted 无法更新 status → 卡片永远 running)。 let _messagesGetter: (() => AiMessage[]) | null = null /** * @internal 由 stores/ai.ts 在 state 创建后调用,注入 getter 函数。 * 本模块内 findToolCall / startApprovalTimer 通过 getMessages() 访问。 * 用 getter 而非缓存数组引用,确保 switchConversation 等整体替换 messages 后仍可读到最新数组。 */ export function __bindMessages(getter: (() => AiMessage[]) | AiMessage[]): void { // 兼容旧 API:传数组则包装为 getter(避免破坏既有调用方) if (typeof getter === 'function') { _messagesGetter = getter } else { _messagesGetter = () => getter } } function getMessages(): AiMessage[] { return _messagesGetter?.() ?? [] } /** 客户端消息自增 id(全局唯一,多个 composable 共用 nextMsgId) */ let _msgCounter = 0 /** 全局消息 id 自增(供 events/stream/send/window 各 composable 共享同一计数器) */ export function nextMsgId(): number { return ++_msgCounter } /** * 在全部消息中查找指定 id 的工具调用卡片(用于状态流转) * * 扫描策略:从尾部反向遍历并提前退出。tool_result/approval/completed * 通常对应最近触发的 tool_use(agent 顺序执行工具并尽快回填结果), * 反向扫描使其命中落在尾部 → 平均 O(1)、最坏 O(n)。(原正向全量扫描为 O(n²), * 每条 tool_result 都从头扫整条历史。) * * 注:未引入 Map 索引。state.messages 在 newConversation / * switchConversation / deleteConversation 等处被整体替换(且这些操作散落在 * useAiEvents 之外),独立索引极易与响应式数组失配形成"陈旧引用"——状态卡 * 片读到已不存在的对象。反向扫描零额外状态、改动最小且行为完全一致。 */ export function findToolCall(id: string): AiToolCallInfo | undefined { const messages = getMessages() for (let i = messages.length - 1; i >= 0; i--) { const toolCalls = messages[i].toolCalls if (!toolCalls) continue // 同一消息内工具调用通常很少,沿用正向扫描;命中即返回 for (let j = 0; j < toolCalls.length; j++) { if (toolCalls[j].id === id) return toolCalls[j] } } return undefined } // ── 审批超时计时器(原 useAiSend,下沉打破 events↔send 循环依赖) ── /// 审批超时阈值来源的 appSettings key(运行期可调,默认 15min)。 /// 值语义:正整数=毫秒超时;0=不限时(跳过计时器);缺省=900000(15min)。 const APPROVAL_TIMEOUT_KEY = 'df-approval-timeout' const APPROVAL_TIMEOUT_DEFAULT_MS = 900_000 // 15 分钟 /** * 读取当前审批超时阈值(ms):从 appSettings 取 `df-approval-timeout`, * 缺省 900000(15min),返回 0 表示用户配置为「不限时」(调用方应跳过计时器)。 * * 每次 startApprovalTimer 调用时取最新值 —— 用户在 Settings 页调整后,下一笔审批即生效 * (已在跑的计时器沿用旧值,符合「不影响已发起审批」的预期)。 */ function getApprovalTimeoutMs(): number { const appSettings = useAppSettingsStore() const raw = appSettings.get(APPROVAL_TIMEOUT_KEY, APPROVAL_TIMEOUT_DEFAULT_MS) // 防御:负数 / NaN 视为默认值;0 保留(语义=不限时) if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 0) { return APPROVAL_TIMEOUT_DEFAULT_MS } return raw } /** toolCallId → 审批超时计时器 + 起点信息(startedAt/timeoutMs 供前端到期倒计时读剩余) */ interface ApprovalTimerInfo { timer: ReturnType /** 计时器启动时刻(ms)——即本笔审批超时窗口的真实起点 */ startedAt: number /** 本计时器生效的超时阈值(ms) */ timeoutMs: number } const _approvalTimers = new Map() /** * 启动审批超时计时器(幂等:同 id 重复启动不重建)。 * 到点回调按 kind 分派拒绝路径 + push 系统错误消息: * - kind='risk'(risk 类审批,如 write_file):调 aiApi.approve(id, false) → 后端 ai_approve(kind==Risk)。 * - kind='path'(路径授权挂起):调 aiApi.authorizeDir(id, 'deny') → 后端 ai_authorize_dir 续 loop 返 Err。 * 修复回归:path 类若错调 ai_approve,后端 ai_approve 校验 kind==Risk 直接拒,IPC 不进续 loop → 卡死。 * memory: aiShared.startApprovalTimer 回调按 kind 分派(risk→approve / path→authorizeDir('deny')), * path 类错误复用 risk 的 ai_approve 致后端 kind 校验拒、续 loop 不触发 → 卡死。 */ export function startApprovalTimer( toolCallId: string, toolName: string, kind: 'risk' | 'path' = 'risk', ): void { if (_approvalTimers.has(toolCallId)) return // 0 = 用户配置为「不限时」,跳过计时器(对齐旧 Infinity 行为) const timeoutMs = getApprovalTimeoutMs() if (timeoutMs === 0) return const timer = setTimeout(() => { _approvalTimers.delete(toolCallId) // 按 kind 分派拒绝路径,避免 path 类错调 ai_approve 致后端 kind 校验拒而卡死 const denyP = kind === 'path' ? aiApi.authorizeDir(toolCallId, 'deny') : aiApi.approve(toolCallId, false) denyP.catch(e => { console.error('[AI] 审批超时自动拒绝 IPC 未送达:', e) }) // UX-P2-A3:气泡推送改经 per-conv 守卫(函数追加在本文件尾部),后台会话超时不串当前视图 pushApprovalTimeoutBubble(toolName, toolCallId) }, timeoutMs) _approvalTimers.set(toolCallId, { timer, startedAt: Date.now(), timeoutMs }) } /** * 取某审批距前端自动拒绝的剩余毫秒。 * 审批超时是前端本计时器驱动的(到点才调 IPC 拒绝),故剩余值与实际拒绝时刻精确对齐, * 优于后端 created_at(不序列化透传)或 ToolCard 本地到达时间的近似。 * 返回 null = 无活跃计时器(未挂起/已到期/用户配置不限时),调用方应回退其他展示。 */ export function getApprovalRemainingMs(toolCallId: string): number | null { const info = _approvalTimers.get(toolCallId) if (!info) return null return info.timeoutMs - (Date.now() - info.startedAt) } /** 清除单个审批计时器 */ export function clearApprovalTimer(toolCallId: string): void { const info = _approvalTimers.get(toolCallId) if (info) { clearTimeout(info.timer) _approvalTimers.delete(toolCallId) } } /** 清除全部审批计时器(整流超时收尾/组件卸载时调用) */ export function clearAllApprovalTimers(): void { for (const info of _approvalTimers.values()) clearTimeout(info.timer) _approvalTimers.clear() } // ── L2 统一状态机 convStates(下沉自 useAiEvents,批4 双轨收口) ── // // 下沉原因:批4 退役 generatingConvs(bool 轨)后,stores/ai.ts(isGenerating)/ // useAiStream.ts(onStreamTimeout stillGenerating)/useAiConversations.ts(switchConversation 重算) // 等读点均需 import getConvState。若 getConvState 留在 useAiEvents,这些模块反向 import // useAiEvents 会与 useAiEvents→useAiStream/useAiEvents←stores 的现有依赖构成环。 // 故将 convStates Map + getConvState + setConvState 全部下沉到本共享层(与 nextMsgId/findToolCall/ // 审批计时器同位置,沿用既有破环模式)。useAiEvents 改为从本模块 import 它们。 /** * per-conv conv_state 真相源(消费后端 AiConvStateChanged 事件)。 * * 模块级 reactive Map(convId → ConvState),不进 store.state(与生成生命周期语义独立于 * 消息/会话列表,同 pendingMaxRounds/pendingHelp 模式)。批4 收口后,本 Map 是「会话是否生成中」 * 的唯一真相源,generatingConvs bool 轨已退役。 */ export const convStates = reactive(new Map()) /** * 取某 conv 的 conv_state;未追踪过返回 null。 * * 桥接语义(批4 收口):bool 轨 generatingConvs.has(id) 等价于本函数返回值 ∈ * {'generating','stopping','compressed'}(非终止三态);idle/error/null 对应 bool 轨 has()=false。 * 依据:handleConvStateEvent 原同步逻辑将三态都 add,终止态 delete。 */ export function getConvState(convId: string | null | undefined): ConvState | null { if (!convId) return null return convStates.get(convId) ?? null } /** * 写入 conv_state(useAiEvents.handleConvStateEvent 的 AiConvStateChanged case 唯一写入口, * 及活跃事件兜底写 generating 态)。 * * idle 为终态收敛:写后删 Map 项(与后端 generating=false 语义对齐,Map 不持陈旧 idle 项)。 */ export function setConvState(convId: string | null | undefined, s: ConvState): void { if (!convId) return if (s === 'idle') { convStates.delete(convId) } else { convStates.set(convId, s) } } // ── F-09 B 路线 per-conv 流式态(streaming/currentText) ── // // 背景(a 父④):原 `state.streaming`(bool)/`state.currentText`(string)是 store 全局单例, // F-09 多会话并发下:A 后台生成中切到 B 会话,B 末条 AI 气泡命中 `isLastAi && streaming && // currentText` 会渲染 A 残留 currentText(BUG-260624-01 同源根因)。本批改 per-conv Map,各会话 // 独立累积流式文本/流式开关,切会话即切到该会话的 stream state,真并发不串话。 // // 铁律:单会话回归零变化。per-conv Map 在单会话场景只有 activeConversationId 一项,等价原全局 // 单例。state.streaming/currentText 改为委派本 Map 的 accessor(stores/ai.ts),所有现有消费方 // (streamingGuard/useAiEvents/useAiSend/useAiConversations/useAiWindow/useAiPanel/MessageList/ // ChatInput/TopBar/AiChat)读写 `store.state.streaming` / `store.state.currentText` 零改动透明继承。 // // 下沉到 aiShared.ts(与 convStates 同位置)的原因:stores/ai.ts 已 import aiShared(getConvState), // 把 per-conv Map 放这里不新增依赖环;且与 convStates 同款 per-conv 模式集中管理,语义聚合。 // // 类型:streaming=boolean(该会话是否正在流式输出),currentText=string(该会话累积的流式文本)。 export interface ConvStreamState { streaming: boolean currentText: string } /** * per-conv 流式态真相源:convId → {streaming, currentText}。 * * 模块级 reactive Map(对齐 convStates 模式)。stores/ai.ts 的 state.streaming/currentText * accessor 委派本 Map 按 activeConversationId 索引读写,响应式依赖经 Vue reactive Map proxy 跟踪。 * * 收敛:streaming=false 时删 Map 项(对齐 convStates idle 收敛,Map 不持陈旧 false 项); * 写 true 时按需 set。currentText 写空串等同收敛(下次写非空重新 set)。 */ export const convStreamStates = reactive(new Map()) /** * 取某 conv 的 stream state;未追踪过返回 null。 * * 单会话场景:activeConversationId 恒定,Map 仅此一项,等价原全局单例。 */ export function getConvStreamState(convId: string | null | undefined): ConvStreamState | null { if (!convId) return null return convStreamStates.get(convId) ?? null } /** * 取某 conv 的 stream state,不存在则惰性创建(供 streaming/currentText 写入路径建项)。 * 惰性建对齐 per-conv「写时建」语义——读路径(get)不建,仅写入路径(set)建。 */ function ensureConvStreamState(convId: string): ConvStreamState { let s = convStreamStates.get(convId) if (!s) { s = { streaming: false, currentText: '' } convStreamStates.set(convId, s) } return s } /** * 写某 conv 的 streaming 态(false 时收敛删 Map 项,对齐 convStates idle 语义)。 * * @param convId 目标会话 id * @param value streaming 目标值 */ export function setConvStreaming(convId: string | null | undefined, value: boolean): void { if (!convId) return if (value) { ensureConvStreamState(convId).streaming = value } else { // false 收敛:若该 conv 已无任何活跃态(streaming=false 且 currentText 空),删 Map 项避免陈旧。 const cur = convStreamStates.get(convId) if (cur) { cur.streaming = false if (!cur.currentText) convStreamStates.delete(convId) } } } /** * 写某 conv 的 currentText(空串时仅清字段,streaming 仍 true 则保留项)。 * 收敛对称——当 streaming=false 且 currentText 被清空时,删 Map 项。 * * @param convId 目标会话 id * @param value currentText 目标值(空串=清流式累积,但会话可能仍在 streaming) */ export function setConvCurrentText(convId: string | null | undefined, value: string): void { if (!convId) return if (value) { ensureConvStreamState(convId).currentText = value } else { // 空串收敛:若 streaming=false 则删项,否则仅清字段(保留项) const cur = convStreamStates.get(convId) if (cur) { cur.currentText = '' if (!cur.streaming) convStreamStates.delete(convId) } } } /** 清某 conv 的 stream state(会话删除/收尾彻底清,删整个 Map 项)。 * 注:日常 streaming=false/currentText='' 收敛走 setConvStreaming 内联删,本函数用于会话级删除。 */ export function clearConvStreamState(convId: string | null | undefined): void { if (!convId) return convStreamStates.delete(convId) } // ── per-conv 待审批集合(审批挂起按 conversation_id 归会话,跨会话互不覆盖) ── // // 背景:state.pendingApprovals 是全局单数组,switchConversation 恢复挂起时对它整体替换 // (= restored / = []),后台会话经事件到达的挂起会被切换动作整组覆盖丢失(全局单值覆盖语义)。 // 改为双层结构:convPendingApprovals Map 是跨会话真相源(各会话独立数组,互不覆盖); // state.pendingApprovals 退化为「当前活跃会话」的视图投影,组件消费方(徽标/浮层/审批浮窗) // 仍只读该数组,零改动保持视图按当前会话展示。 // // 注:Map 本体不建响应式(仅切换/收尾时由本组函数读写,渲染只依赖 state.pendingApprovals)。 export const convPendingApprovals = new Map() function pendingActiveKey(): string { return getState()?.activeConversationId ?? '' } /** * 追加一条待审批:按 conversationId 归入所属会话集合(缺省归当前活跃会话); * 属当前活跃会话时同步投影进 state.pendingApprovals。同 id 幂等,返回是否真正新增 * (调用方据此决定是否做工具卡改写/计时器启动/滚动通知等一次性副作用)。 */ export function addPendingApproval(info: AiToolCallInfo): boolean { const key = info.conversationId || pendingActiveKey() let arr = convPendingApprovals.get(key) if (!arr) { arr = [] convPendingApprovals.set(key, arr) } let added = false if (!arr.some(p => p.id === info.id)) { arr.push(info) added = true } if (key === pendingActiveKey()) { const view = getState()?.pendingApprovals if (view && !view.some(p => p.id === info.id)) view.push(info) } return added } /** 按 id 移除一条待审批(全会话集合扫描;审批结果不依赖当前视图是哪个会话) */ export function removePendingApprovalById(id: string): void { for (const [k, arr] of convPendingApprovals) { if (arr.some(p => p.id === id)) { convPendingApprovals.set(k, arr.filter(p => p.id !== id)) } } const view = getState()?.pendingApprovals if (view) { const idx = view.findIndex(p => p.id === id) if (idx >= 0) view.splice(idx, 1) } } /** * 整体替换某会话的待审批集合(切换恢复后端审计挂起 / 新建清空用); * 目标为当前活跃会话时同步投影进视图,否则只写集合不动视图(跨会话互不覆盖的关键)。 */ export function setConvPendingApprovals(convId: string | null | undefined, items: AiToolCallInfo[]): void { if (!convId) return convPendingApprovals.set(convId, items) if (convId === pendingActiveKey()) { const view = getState()?.pendingApprovals if (view) view.splice(0, view.length, ...items) } } /** 仅删除某会话的集合项,不动视图数组(会话终止/删除时用;视图侧无法归属的旧项由调用方自定保留策略) */ export function discardConvPendingApprovals(convId: string | null | undefined): void { if (!convId) return convPendingApprovals.delete(convId) } // ── 切换中缓冲 ── // // 背景:后台会话(A)生成中,用户点击切换到 A(switchConversation await 往返期间),A 的 // AiTextDelta 事件因 `convId !== activeConversationId` 被 handleEvent 的 isCurrent 守卫丢弃 → // 切换完成后回复缺前缀(切换窗口 token 丢失)。 // // 本集合标记「正在被 switchConversation 拉取的目标 conv」:useAiEvents.handleEvent 对非当前 // 会话的 AiTextDelta 若命中此集合,则累积到该 conv 的 per-conv 流式态(而非 drop); // 切换成功后由 switchConversation 恢复该流式文本续显,失败/过期路径丢弃(防陈旧重复)。 // // 普通 Set(仅事件处理器读,无渲染追踪需求);生命周期由 switchConversation 的 finally 管理。 export const switchingConvs = new Set() // ── 流式文本回填 + delta 去重(useAiEvents 拆分后跨域共享) ── // // flushCurrentText 被 streaming(新轮)/lifecycle(收尾)/send(发送前)三域共用,且需写 // _lastDeltas 去重 Map,故下沉到本共享层。为规避 aiShared ↔ stores/ai 顶层循环引用(TDZ), // 沿用 __bindMessages 的 getter 注入模式:stores/ai 在 state 就绪后注入 getter, // 本模块函数运行时惰性取最新 state。 export interface AiStateShape { currentText: string streaming: boolean messages: AiMessage[] activeConversationId: string | null pendingApprovals: AiToolCallInfo[] } let _stateGetter: (() => AiStateShape) | null = null /** @internal 由 stores/ai.ts 在 state 创建后注入 getter(flushCurrentText 惰性读 state) */ export function __bindState(getter: () => AiStateShape): void { _stateGetter = getter } function getState(): AiStateShape | null { return _stateGetter?.() ?? null } /** per-conv delta 去重 Map(convId → 上一次 delta 内容,根治跨会话撞值误丢) */ const _lastDeltas = new Map() function lastDeltaKey(convId: string | null | undefined): string { return convId || getState()?.activeConversationId || '' } /** 读某会话上一次 delta(去重检查);无记录返回 undefined */ export function getLastDelta(convId: string | null | undefined): string | undefined { return _lastDeltas.get(lastDeltaKey(convId)) } /** 记录某会话当前 delta */ export function setLastDelta(convId: string | null | undefined, delta: string): void { _lastDeltas.set(lastDeltaKey(convId), delta) } /** 删除某会话的 delta 记录(会话删除/收尾时调用,防 Map 无限增长) */ export function clearLastDelta(convId: string | null | undefined): void { _lastDeltas.delete(lastDeltaKey(convId)) } /** * 把流式累积的 currentText 回填到最后一条 assistant 消息(AiAgentRound/AiCompleted/AiError 收尾共用)。 * * 只 guard 空串会漏 whitespace —— LLM 工具调用前常推 `\n`/空格,累积成 whitespace-only 后写进 * 占位 content 会渲染带边框空气泡,故 trim 兜底空白不回填。回填后立即自清 currentText, * 消除对调用方清空顺序的依赖(防残留文本渲到新气泡)。 */ export function flushCurrentText(): void { const s = getState() if (!s) return const resetDelta = () => clearLastDelta(s.activeConversationId) if (!s.currentText || !s.currentText.trim()) { s.currentText = '' resetDelta() return } // 非流式守卫:currentText 仅在流式进行中有意义。终态收尾后的迟到 delta、后台会话 // 完成后切回残留的缓冲,都会让 streaming=false 时 currentText 仍非空;此刻回填会把 // 上一轮已完成回复整条覆盖为残片(用户实测「空白内容覆盖了之前一大堆内容」), // 故非流式一律只清不写。合法回填点(收尾/新轮/停止/超时)均在 streaming 翻 false 前调用。 if (!s.streaming) { s.currentText = '' resetDelta() return } // 从末尾向前找最后一个非 isError assistant 气泡写入(跳过重试错误气泡,保留部分回复) for (let i = s.messages.length - 1; i >= 0; i--) { const m = s.messages[i] if (m.role !== 'assistant') break if (!m.isError) { m.content = s.currentText break } } s.currentText = '' resetDelta() } // ── 工具慢执行计时器(useAiEvents 拆分后跨域共享:tool 域 + lifecycle 收尾共用) ── // // 工具执行超时提示(纯前端降级,后端无工具级取消 IPC):每个 running 工具一个独立 setTimeout, // 到时若仍未收到 Completed/Approval,经 Tauri 事件 ai-tool-slow-toast 弹 warning toast(仅提示一次, // 不动 running 态——慢工具如 read_file 大文件/run_workflow 长任务不可误杀)。 const TOOL_SLOW_MS = 30000 const _toolTimers = new Map>() const _toolSlowNotified = new Set() /** 启动工具慢执行计时器(幂等:同 id 重复 Started 不重建;超时仅弹 toast,不改 status) */ export function startToolSlowTimer(callId: string, toolName: string): void { if (_toolTimers.has(callId)) return const timer = setTimeout(() => { _toolTimers.delete(callId) if (_toolSlowNotified.has(callId)) return _toolSlowNotified.add(callId) // 经 Tauri 事件总线广播(composable 无组件上下文),AiChat.vue listen 后弹本地 toast void emit('ai-tool-slow-toast', { name: toolName }) }, TOOL_SLOW_MS) _toolTimers.set(callId, timer) } /** 清除单个工具的慢执行计时器(收到 Completed/Approval 时调用) */ export function clearToolSlowTimer(callId: string): void { const timer = _toolTimers.get(callId) if (timer) { clearTimeout(timer) _toolTimers.delete(callId) } } /** 清除全部工具慢执行计时器(stopListener/整流超时收尾时调用) */ export function clearAllToolSlowTimers(): void { for (const timer of _toolTimers.values()) clearTimeout(timer) _toolTimers.clear() _toolSlowNotified.clear() } // ── V8 per-conv 输入草稿 ── // // 背景:输入框草稿原先只在 ChatInput 组件内持单份 ref,切会话后 A 会话未发送的草稿直接 // 显示在 B 会话输入框(串稿)。本 Map 是跨会话草稿的内存真相源,ChatInput 在 // activeConversationId 变化时写旧会话文本、读新会话文本。内存态,不落 localStorage。 // // 放本模块的原因:沿用 convStreamStates 同款 per-conv reactive Map 模式集中管理, // 消费方(ChatInput)与潜在的其他输入域消费方都可读,且不新增依赖环。 export const convDrafts = reactive(new Map()) /** 读某会话草稿;无记录返回空串(调用方直接回填输入框即可) */ export function getConvDraft(convId: string | null | undefined): string { if (!convId) return '' return convDrafts.get(convId) ?? '' } /** 写某会话草稿;空串收敛删 Map 项(对齐 convStates idle 收敛语义,Map 不持陈旧空项) */ export function setConvDraft(convId: string | null | undefined, text: string): void { if (!convId) return if (text) { convDrafts.set(convId, text) } else { convDrafts.delete(convId) } } // ── I5 发送失败错误分类 ── // // 背景:发送失败错误串原样透出到 toast,后端/上游错误原文(429 Too Many Requests、 // insufficient_quota、ECONNREFUSED 等)对用户不友好。按内容归类四类,消费方 // (ChatInput 发送失败 toast)据类别映射 i18n 文案,原始错误串作为 {msg} 细节保留不丢。 export type SendErrorKind = 'rate_limit' | 'quota' | 'network' | 'other' /** * 按错误串内容分类。顺序敏感:429/限流先判(部分上游限流文案同时含网络相关词), * 其次额度/余额,再网络/超时,兜底 other。 */ export function classifySendError(raw: string): SendErrorKind { const s = String(raw ?? '') if (/\b429\b|rate[_\s-]?limit|too\s*many\s*requests|限流|请求过于频繁/i.test(s)) return 'rate_limit' if (/\b402\b|quota|insufficient|balance|billing|credit|额度|余额|欠费|配额/i.test(s)) return 'quota' if (/timeout|timed?[_\s-]?out|network|connection|ECONN|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|\bDNS\b|超时|网络|连接/i.test(s)) return 'network' return 'other' } // ── S1 子代理完成卡(per-conv 归集,消费后端 AiAgentFinished 事件) ── // // 背景:spawn_agent 每个子循环结束经统一出口挂父会话 emit 一条 AiAgentFinished // (status/summary/rounds/tokens,后端 sub_agent.rs;status 字面量对齐后端 SubAgentStatus::as_str)。 // 本 Map 是跨会话完成卡的内存真相源,沿用 convStreamStates 同款 per-conv reactive Map 模式: // 后台会话的完成事件也归集,切会话重进仍可见;MessageList 只读当前活跃会话的数组,不串视图。 // 内存态不落库(与待审批/流式态同口径),会话删除即清。 export type AgentFinishedStatus = 'completed' | 'truncated' | 'failed' export interface AgentFinishedCard { /** 子会话 id(形态 {parent}::agent-{n}),同会话多子代理各占一卡按此键控 */ agentId: string summary: string status: AgentFinishedStatus rounds: number tokens: number /** 被拒工具清单("工具名×次数",如 "write_file×3",后端 format_rejected_tools 产出)。 * 字段名保持事件 wire 格式 snake_case(summary 是后端拼好的全文,结构化清单只此一份, * 卡面 160 字截断裁掉 summary 尾部时渲染端仍可扫描被拒事实);缺省=零拒绝。 */ rejected_tools?: string[] /** 终止原因结构化字段(后端 AiAgentFinished.stop_kind,cancelled/wall_clock/max_rounds/ * token_budget/empty_convergence/error/converged)。缺省=旧后端未发,渲染端回退 * summary 启发式(向后兼容);实时事件到达时以此覆盖启发式判断。 */ stop_kind?: string finishedAt: number } export const convAgentFinished = reactive(new Map()) /** 读某会话的完成卡数组;未出现过子代理返回空数组(渲染方零特判) */ export function getConvAgentFinished(convId: string | null | undefined): AgentFinishedCard[] { if (!convId) return [] return convAgentFinished.get(convId) ?? [] } /** * 追加一条完成卡(按 agentId 幂等覆盖)。后端单一出口每子循环只 emit 一次,覆盖仅为防 * 监听重放双卡,不改变「同一会话多子代理多卡共存」语义;追加序即完成序。 */ export function addConvAgentFinished( convId: string | null | undefined, card: Omit, ): void { if (!convId || !card.agentId) return let arr = convAgentFinished.get(convId) if (!arr) { arr = [] convAgentFinished.set(convId, arr) } const withTs: AgentFinishedCard = { ...card, finishedAt: Date.now() } const idx = arr.findIndex(c => c.agentId === card.agentId) if (idx >= 0) arr.splice(idx, 1, withTs) else arr.push(withTs) } /** 清某会话完成卡(会话删除时调用,防 Map 持已删会话条目无限增长) */ export function clearConvAgentFinished(convId: string | null | undefined): void { if (!convId) return convAgentFinished.delete(convId) } /** ai_sub_agent_list 的单条轨迹元数据(wire shape 归口 api 层返回类型,此处只投影用) */ type SubAgentRef = Awaited>[number] /** * P2-2 完成卡回补:重启/分离窗回收后内存 Map 清零,子代理轨迹成无 UI 入口孤岛。 * 按父 conv 经 ai_sub_agent_list 反查子代理轨迹,重建已完成卡(轨迹存在即视为 completed; * rejected_tools/rounds/tokens 后端该 IPC 不回,缺省=零拒绝/0,卡面隐藏 0 值 meta)。 * * 幂等/去重/降级三保证: * - 幂等:挂载恢复与每次 switchConversation 都会触发,agentId 键控 insert-only,重复调用收敛; * - 去重:同 agentId 已有卡(实时事件先到/上轮回补过)跳过,不覆盖已有数据; * 实时事件走 addConvAgentFinished 的 splice 覆盖语义,晚到仍会覆盖回补卡(实时数据优先)。 * - 降级:IPC 失败仅 console.warn 返回,调用方 fire-and-forget,不阻塞会话加载。 */ export async function backfillConvAgentFinished(convId: string | null | undefined): Promise { if (!convId) return let refs: SubAgentRef[] try { refs = await aiApi.listSubAgents(convId) } catch (e) { console.warn('[AI] 完成卡回补失败(静默降级):', convId, e) return } if (refs.length === 0) return let arr = convAgentFinished.get(convId) if (!arr) { arr = [] convAgentFinished.set(convId, arr) } for (const ref of refs) { const agentId = ref.conversation_id // agentId 即子会话 conv id({parent}::agent-{n} 全形,与实时事件 agent_id 同源), // 与既有卡天然同键;无 id 的脏行跳过防空键卡 if (!agentId || arr.some(c => c.agentId === agentId)) continue arr.push({ agentId, // title 懒创建常为 None,回退显 agent-{n} 尾段(对齐轨迹浮层身份行切分逻辑) summary: (ref.title || '').trim() || agentId.split('::').pop() || agentId, status: 'completed', rounds: 0, tokens: 0, finishedAt: Number(ref.created_at) || Date.now(), }) } } // ── UX-P2-A3 审批超时错误气泡 per-conv 守卫 ── // // 背景:startApprovalTimer 到点把「审批超时已自动拒绝」错误气泡直接推进 getMessages() // (当前活跃视图),后台会话 A 的审批超时,气泡落在用户正查看的会话 B 里(跨会话串扰)。 // 本守卫按审批所属 conv 过滤:仅当审批所属会话是当前活跃会话(或无法归属)才推视图气泡; // 后台会话的超时拒绝 IPC 照常执行,只是不串视图。 // 归属反查:到点时刻从 per-conv 待审批集合(convPendingApprovals)按 toolCallId 反查 convId—— // 此时拒绝 IPC 才刚发出,审批结果事件未到,集合项必在;查不到(旧数据/已被清)保守回落为推, // 不改变无归属场景的原行为。 export function pushApprovalTimeoutBubble(toolName: string, toolCallId: string): void { let convId: string | null = null for (const [k, arr] of convPendingApprovals) { if (arr.some(p => p.id === toolCallId)) { convId = k break } } const active = getState()?.activeConversationId ?? null if (convId && convId !== active) return getMessages().push({ id: `approval-timeout-${nextMsgId()}` as MessageId, role: 'assistant', content: t('ai.approvalTimeout', { toolName }), isError: true, timestamp: Date.now(), }) } /** 队列被清空的统一提示(UX-P2-A2:AiError/regenerate/editMessage 清队从静默改 toast, * 清 0 条不提示;经 ai-toast 事件总线,AiChat 本地 toast 消费) */ export function notifyQueueCleared(n: number): void { if (n > 0) void emit('ai-toast', { msg: t('ai.queueCleared', { n }), type: 'warning' }) }