468 lines
20 KiB
TypeScript
468 lines
20 KiB
TypeScript
//! 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<string>('df-ai-language', 'auto')
|
|
return raw === 'auto'
|
|
? appSettings.get<string>('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<id, tc> 索引。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<number>(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<string, ReturnType<typeof setTimeout>>()
|
|
|
|
/**
|
|
* 启动审批超时计时器(幂等:同 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)
|
|
})
|
|
getMessages().push({
|
|
id: `approval-timeout-${nextMsgId()}` as MessageId,
|
|
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<string, ConvState>())
|
|
|
|
/**
|
|
* 取某 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<string, ConvStreamState>())
|
|
|
|
/**
|
|
* 取某 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)
|
|
}
|
|
|
|
// ── 切换中缓冲 ──
|
|
//
|
|
// 背景:后台会话(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<string>()
|
|
|
|
// ── 流式文本回填 + delta 去重(useAiEvents 拆分后跨域共享) ──
|
|
//
|
|
// flushCurrentText 被 streaming(新轮)/lifecycle(收尾)/send(发送前)三域共用,且需写
|
|
// _lastDeltas 去重 Map,故下沉到本共享层。为规避 aiShared ↔ stores/ai 顶层循环引用(TDZ),
|
|
// 沿用 __bindMessages 的 getter 注入模式:stores/ai 在 state 就绪后注入 getter,
|
|
// 本模块函数运行时惰性取最新 state。
|
|
export interface AiStateShape {
|
|
currentText: string
|
|
messages: AiMessage[]
|
|
activeConversationId: string | null
|
|
}
|
|
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<string, string>()
|
|
|
|
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
|
|
}
|
|
// 从末尾向前找最后一个非 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<string, ReturnType<typeof setTimeout>>()
|
|
const _toolSlowNotified = new Set<string>()
|
|
|
|
/** 启动工具慢执行计时器(幂等:同 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()
|
|
}
|