Files
DevFlow/src/composables/ai/aiShared.ts
T
lxy 01d231c39d 优化: 子代理体验与纪律注入
- 工具纪律段抽共享同源注入
- 子代理轨迹浮层与完成卡身份
- 分离窗会话切换修复
- 960s 统一出口报告不再蒸发
2026-10-04 16:27:31 +08:00

751 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 → 审批超时计时器 + 起点信息(startedAt/timeoutMs 供前端到期倒计时读剩余) */
interface ApprovalTimerInfo {
timer: ReturnType<typeof setTimeout>
/** 计时器启动时刻(ms)——即本笔审批超时窗口的真实起点 */
startedAt: number
/** 本计时器生效的超时阈值(ms) */
timeoutMs: number
}
const _approvalTimers = new Map<string, ApprovalTimerInfo>()
/**
* 启动审批超时计时器(幂等:同 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<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)
}
// ── per-conv 待审批集合(审批挂起按 conversation_id 归会话,跨会话互不覆盖) ──
//
// 背景:state.pendingApprovals 是全局单数组,switchConversation 恢复挂起时对它整体替换
// (= restored / = []),后台会话经事件到达的挂起会被切换动作整组覆盖丢失(全局单值覆盖语义)。
// 改为双层结构:convPendingApprovals Map 是跨会话真相源(各会话独立数组,互不覆盖);
// state.pendingApprovals 退化为「当前活跃会话」的视图投影,组件消费方(徽标/浮层/审批浮窗)
// 仍只读该数组,零改动保持视图按当前会话展示。
//
// 注:Map 本体不建响应式(仅切换/收尾时由本组函数读写,渲染只依赖 state.pendingApprovals)。
export const convPendingApprovals = new Map<string, AiToolCallInfo[]>()
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<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
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<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
}
// 非流式守卫: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<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()
}
// ── V8 per-conv 输入草稿 ──
//
// 背景:输入框草稿原先只在 ChatInput 组件内持单份 ref,切会话后 A 会话未发送的草稿直接
// 显示在 B 会话输入框(串稿)。本 Map 是跨会话草稿的内存真相源,ChatInput 在
// activeConversationId 变化时写旧会话文本、读新会话文本。内存态,不落 localStorage。
//
// 放本模块的原因:沿用 convStreamStates 同款 per-conv reactive Map 模式集中管理,
// 消费方(ChatInput)与潜在的其他输入域消费方都可读,且不新增依赖环。
export const convDrafts = reactive(new Map<string, string>())
/** 读某会话草稿;无记录返回空串(调用方直接回填输入框即可) */
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<string, AgentFinishedCard[]>())
/** 读某会话的完成卡数组;未出现过子代理返回空数组(渲染方零特判) */
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<AgentFinishedCard, 'finishedAt'>,
): 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<ReturnType<typeof aiApi.listSubAgents>>[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<void> {
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' })
}