//! 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 { aiApi } from '@/api' import { state } from '@/stores/ai' import { useAppSettingsStore } from '@/stores/appSettings' import { t } from '@/i18n/i18n-helpers' import type { AiToolCallInfo, ConvState } from '@/api/types' const appSettings = useAppSettingsStore() /** * 解析 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 raw = appSettings.get('df-ai-language', 'auto') return raw === 'auto' ? appSettings.get('df-language', 'zh-CN') : raw } /** 客户端消息自增 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 { 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 } // ── 审批超时计时器(原 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 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 → 审批超时计时器 */ 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) }) state.messages.push({ id: `approval-timeout-${nextMsgId()}`, role: 'assistant', content: t('ai.approvalTimeout', { toolName }), isError: true, timestamp: Date.now(), }) }, timeoutMs) _approvalTimers.set(toolCallId, timer) } /** 清除单个审批计时器 */ export function clearApprovalTimer(toolCallId: string): void { const timer = _approvalTimers.get(toolCallId) if (timer) { clearTimeout(timer) _approvalTimers.delete(toolCallId) } } /** 清除全部审批计时器(整流超时收尾/组件卸载时调用) */ export function clearAllApprovalTimers(): void { for (const timer of _approvalTimers.values()) clearTimeout(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) ── // // 背景(DEC-07a 父④):原 `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 则保留项)。 * * @param convId 目标会话 id * @param value currentText 目标值(空串=清流式累积,但会话可能仍在 streaming) */ export function setConvCurrentText(convId: string | null | undefined, value: string): void { if (!convId) return ensureConvStreamState(convId).currentText = value } /** 清某 conv 的 stream state(会话删除/收尾彻底清,删整个 Map 项)。 * 注:日常 streaming=false/currentText='' 收敛走 setConvStreaming 内联删,本函数用于会话级删除。 */ export function clearConvStreamState(convId: string | null | undefined): void { if (!convId) return convStreamStates.delete(convId) }