优化: AI模块(provider重试兼容+aichat交互+工具卡片可读化+diff高亮)
This commit is contained in:
@@ -16,7 +16,7 @@ import { state } from '@/stores/ai'
|
||||
import i18n from '@/i18n'
|
||||
import { nextMsgId } from './aiShared'
|
||||
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
|
||||
import { drainQueue } from './useAiSend'
|
||||
import { drainQueue, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers } from './useAiSend'
|
||||
import { loadConversations } from './useAiConversations'
|
||||
import type { AiChatEvent, AiToolCallInfo } from '@/api/types'
|
||||
|
||||
@@ -26,12 +26,61 @@ const t = ((i18n as any).global.t as (key: string) => string).bind((i18n as any)
|
||||
|
||||
let _unlistenAiEvent: (() => void) | null = null
|
||||
let _unlistenConvChanged: (() => void) | null = null
|
||||
// AE-2025-06: onStreamTimeout 广播的审批计时器清理事件 unlistener
|
||||
let _unlistenApprovalClear: (() => void) | null = null
|
||||
// startListener 并发去重:防 onMounted 与 sendMessage 首发竞态下重复注册 listener
|
||||
// (两回调写同一 state.currentText → 流式文字双倍)
|
||||
let _startPromise: Promise<void> | null = null
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
// B-260616-17: 看门狗不重置的事件集合(审批等待/完成/错误由各自 case 内 clear)。
|
||||
// 模块级 Set 复用,避免 handleEvent 每事件(delta/token 高频)新建数组字面量做 includes。
|
||||
const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError'])
|
||||
|
||||
// B-260616-12: 工具执行超时提示(纯前端降级,后端无工具级取消 IPC)。
|
||||
// 每个 running 工具一个独立 setTimeout;到时若仍未收到 Completed/Approval,
|
||||
// 经 Tauri 事件 ai-tool-slow-toast 通知 AiChat.vue 弹 warning toast(仅提示一次,
|
||||
// 不动 running 态——慢工具如 read_file 大文件/run_workflow 长任务不可误杀)。
|
||||
// 真正取消需后端开 ai_cancel_tool IPC + 工具执行 select 改造,单独立项。
|
||||
const TOOL_SLOW_MS = 30000
|
||||
// callId → timer;记录所有已挂载的工具级计时器
|
||||
const _toolTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
// 已提示过的 callId(同一工具只弹一次 toast,即便仍 running 到本轮结束)
|
||||
const _toolSlowNotified = new Set<string>()
|
||||
|
||||
/** 启动工具级慢执行计时器(超时仅弹 toast,不修改 status) */
|
||||
function startToolSlowTimer(callId: string, toolName: string): void {
|
||||
if (_toolTimers.has(callId)) return // 幂等:同 id 重复 Started 不重建
|
||||
const timer = setTimeout(() => {
|
||||
_toolTimers.delete(callId)
|
||||
if (_toolSlowNotified.has(callId)) return
|
||||
_toolSlowNotified.add(callId)
|
||||
// 经 Tauri 事件总线广播(composable 无组件上下文,无法直接调 toast)。
|
||||
// AiChat.vue(主窗口与分离窗口各挂一份)listen 后弹本地 toast。
|
||||
// toolName 来自后端事件,经 i18n key 查不到翻译时回退原值——这里原样透传,
|
||||
// 由消费方 i18n 插值展示。
|
||||
void emit('ai-tool-slow-toast', { name: toolName })
|
||||
}, TOOL_SLOW_MS)
|
||||
_toolTimers.set(callId, timer)
|
||||
}
|
||||
|
||||
/** 清除单个工具的慢执行计时器(收到 Completed/Approval 时调用) */
|
||||
function clearToolSlowTimer(callId: string): void {
|
||||
const timer = _toolTimers.get(callId)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
_toolTimers.delete(callId)
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除全部工具慢执行计时器(stopListener/整流超时收尾时调用) */
|
||||
function clearAllToolSlowTimers(): void {
|
||||
for (const timer of _toolTimers.values()) clearTimeout(timer)
|
||||
_toolTimers.clear()
|
||||
_toolSlowNotified.clear()
|
||||
}
|
||||
|
||||
/** 通知会话列表发生变化(供 newConversation/deleteConversation/rename/archive 等触发刷新侧栏) */
|
||||
export function notifyConversationChanged() {
|
||||
emit('ai-conversation-changed', {})
|
||||
@@ -106,7 +155,7 @@ export function handleEvent(event: AiChatEvent) {
|
||||
state.generatingConvId = convId
|
||||
}
|
||||
// 流式看门狗:活跃事件(delta/工具/新轮/审批结果)重置;审批等待/完成/错误在 case 内 clear
|
||||
if (!['AiApprovalRequired', 'AiCompleted', 'AiError'].includes(event.type)) {
|
||||
if (!NO_RESET_WATCHDOG.has(event.type)) {
|
||||
resetStreamWatchdog()
|
||||
}
|
||||
switch (event.type) {
|
||||
@@ -124,6 +173,12 @@ export function handleEvent(event: AiChatEvent) {
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
// AE-2025-07: 记录当前轮次供进度条展示。
|
||||
// event.round>0 = run_agentic_loop 内 iteration+1(第几轮工具→LLM 循环);
|
||||
// event.round==0 = try_continue_agent_loop 审批通过后"隔开新一轮"的占位事件,
|
||||
// 此时实际轮次尚未推进(run_agentic_loop 入口 iteration=0),不覆盖,避免审批通过瞬间
|
||||
// 进度条 2→0 闪烁;下一轮真正的 round>0 事件到达时再更新。
|
||||
if (event.round > 0) state.agentRound = event.round
|
||||
break
|
||||
}
|
||||
|
||||
@@ -143,6 +198,8 @@ export function handleEvent(event: AiChatEvent) {
|
||||
lastMsg.toolCalls = lastMsg.toolCalls || []
|
||||
lastMsg.toolCalls.push(info)
|
||||
}
|
||||
// B-260616-12: 工具开始执行即挂慢执行计时器(超时仅 toast,不动 running 态)
|
||||
startToolSlowTimer(event.id, event.name)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -153,6 +210,9 @@ export function handleEvent(event: AiChatEvent) {
|
||||
tc.result = event.result
|
||||
}
|
||||
state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id)
|
||||
clearToolSlowTimer(event.id)
|
||||
// AE-2025-06: 工具结束(无论审批通过后执行还是被拒),清审批超时计时器
|
||||
clearApprovalTimer(event.id)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -170,6 +230,10 @@ export function handleEvent(event: AiChatEvent) {
|
||||
tc.status = 'pending_approval'
|
||||
tc.reason = event.reason
|
||||
}
|
||||
// B-260616-12: 进入审批等待→取消该工具慢执行计时器(审批耗时由用户主导,非执行慢)
|
||||
clearToolSlowTimer(event.id)
|
||||
// AE-2025-06: 审批等待开始→启动审批超时计时器(5min 不处理自动拒绝)
|
||||
startApprovalTimer(event.id, event.name)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -180,19 +244,27 @@ export function handleEvent(event: AiChatEvent) {
|
||||
tc.status = 'rejected'
|
||||
// CR-260615-08(P1-1):走 i18n —— 原硬编码中文使 en locale 拒绝提示恒中文,
|
||||
// 且废掉已存在的 aiTool.rejectedHint(en: 'User rejected this action')翻译
|
||||
tc.result = t('aiTool.rejectedHint')
|
||||
tc.result = t('ai.aiTool.rejectedHint')
|
||||
}
|
||||
state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id)
|
||||
clearToolSlowTimer(event.id)
|
||||
// AE-2025-06: 用户已拒绝(状态离开 pending_approval)→清审批超时计时器
|
||||
clearApprovalTimer(event.id)
|
||||
} else {
|
||||
// B-260616-12: 审批通过→工具重新进入执行态,重启慢执行计时器
|
||||
startToolSlowTimer(event.id, findToolCall(event.id)?.name || '')
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiCompleted': {
|
||||
clearStreamWatchdog()
|
||||
clearAllToolSlowTimers() // B-260616-12: 整轮结束清全部工具慢执行计时器与已提示集合
|
||||
flushCurrentText()
|
||||
state.currentText = ''
|
||||
state.streaming = false
|
||||
state.generatingConvId = null
|
||||
state.agentRound = 0 // AE-2025-07: agentic 结束,复位轮次(隐藏进度条)
|
||||
// token 用量记录(开关开时):lastTokenUsage 供当前回复展示,convTokenTotal 累加对话总量
|
||||
if (isShowTokenUsage()) {
|
||||
state.lastTokenUsage = {
|
||||
@@ -220,9 +292,11 @@ export function handleEvent(event: AiChatEvent) {
|
||||
|
||||
case 'AiError': {
|
||||
clearStreamWatchdog()
|
||||
clearAllToolSlowTimers() // B-260616-12: 错误收尾清全部工具慢执行计时器与已提示集合
|
||||
state.streaming = false
|
||||
state.generatingConvId = null
|
||||
state.currentText = ''
|
||||
state.agentRound = 0 // AE-2025-07: agentic 异常中断,复位轮次
|
||||
state.queue = [] // B-32:错误收尾清队列,防生成中入队的消息被静默丢失(drainQueue 仅 AiCompleted 触发)
|
||||
localStorage.removeItem('df-ai-gen')
|
||||
localStorage.removeItem('df-ai-text')
|
||||
@@ -241,12 +315,17 @@ export function handleEvent(event: AiChatEvent) {
|
||||
/** 启动事件监听(幂等 + 并发去重,onMounted 与 sendMessage 首发竞态不会重复注册) */
|
||||
export async function startListener() {
|
||||
// 已注册 → 直接复用(幂等;sendMessage 每次调用不重复注册)
|
||||
if (_unlistenAiEvent && _unlistenConvChanged) return
|
||||
if (_unlistenAiEvent && _unlistenConvChanged && _unlistenApprovalClear) return
|
||||
// 并发去重:防 onMounted 与 sendMessage 首发竞态重复注册 listener → 文字双倍
|
||||
if (_startPromise) return _startPromise
|
||||
_startPromise = (async () => {
|
||||
_unlistenAiEvent = await aiApi.onEvent(handleEvent)
|
||||
_unlistenConvChanged = await listen('ai-conversation-changed', () => { void loadConversations() })
|
||||
// AE-2025-06: onStreamTimeout(流式整流超时收尾)广播的清理事件 → 清全部审批超时计时器。
|
||||
// useAiStream 不 import useAiSend(避循环依赖),经事件总线中转。
|
||||
_unlistenApprovalClear = await listen('ai-approval-clear-timers', () => { clearAllApprovalTimers() })
|
||||
// B-260616-01: L0 握手 — 通知后端前端已就绪,后端据此清除 HMR/刷新导致的残留 generating 状态
|
||||
emit('ai-client-ready', { lastConvId: state.activeConversationId })
|
||||
})()
|
||||
try {
|
||||
await _startPromise
|
||||
@@ -261,9 +340,13 @@ export async function startListener() {
|
||||
function stopListener() {
|
||||
_unlistenAiEvent?.()
|
||||
_unlistenConvChanged?.()
|
||||
_unlistenApprovalClear?.()
|
||||
_unlistenAiEvent = null
|
||||
_unlistenConvChanged = null
|
||||
_unlistenApprovalClear = null
|
||||
clearStreamWatchdog()
|
||||
clearAllToolSlowTimers() // B-260616-12: 卸载时释放所有工具慢执行计时器,防回调改 state 触发已卸载组件
|
||||
clearAllApprovalTimers() // AE-2025-06: 卸载时释放所有审批超时计时器,防回调 push 消息触发已卸载组件
|
||||
}
|
||||
|
||||
export function useAiEvents() {
|
||||
|
||||
@@ -15,30 +15,40 @@ import { state } from '@/stores/ai'
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
// 启动恢复:从 appSettings(SQLite)读取上次的 UI 布局(模块级单例 state)
|
||||
// detached/docked 不恢复 — 重启后分离窗口必然不存在,必须回 false
|
||||
export function restoreUiState() {
|
||||
const s = appSettings.get<any>('df-ai-ui', null)
|
||||
/** 侧栏宽度可拖拽范围(UX-2025-16)。120=窄屏友好,280=宽屏舒适 */
|
||||
export const SIDEBAR_WIDTH_MIN = 120
|
||||
export const SIDEBAR_WIDTH_MAX = 280
|
||||
export const SIDEBAR_WIDTH_DEFAULT = 200
|
||||
|
||||
/** clamp 侧栏宽度到合法区间;非法/缺省值回默认(防 NaN/0/负数污染 state) */
|
||||
function clampSidebarWidth(v: any): number {
|
||||
const n = typeof v === 'number' && Number.isFinite(v) ? v : SIDEBAR_WIDTH_DEFAULT
|
||||
return Math.min(SIDEBAR_WIDTH_MAX, Math.max(SIDEBAR_WIDTH_MIN, n))
|
||||
}
|
||||
|
||||
/**
|
||||
* 将缓存的 UI 布局对象安全应用到模块级 state(仅 boolean 字段覆盖,其余忽略)。
|
||||
* restoreUiState 与 watch 回调共用,消除重复赋值。
|
||||
* detached/docked 不恢复 — 重启后分离窗口必然不存在,必须回 false
|
||||
*/
|
||||
function applyUiState(s: any) {
|
||||
if (!s) return
|
||||
if (typeof s.panelOpen === 'boolean') state.panelOpen = s.panelOpen
|
||||
if (typeof s.maximized === 'boolean') state.maximized = s.maximized
|
||||
if (typeof s.sidebarOpen === 'boolean') state.sidebarOpen = s.sidebarOpen
|
||||
if (typeof s.archivedCollapsed === 'boolean') state.archivedCollapsed = s.archivedCollapsed
|
||||
// 侧栏宽度(UX-2025-16):数值字段,clamp 后写入(与上方 boolean 同级兜底)
|
||||
state.sidebarWidth = clampSidebarWidth(s.sidebarWidth)
|
||||
}
|
||||
|
||||
// 启动恢复:从 appSettings(SQLite)读取上次的 UI 布局(模块级单例 state)
|
||||
export function restoreUiState() {
|
||||
applyUiState(appSettings.get<any>('df-ai-ui', null))
|
||||
}
|
||||
restoreUiState()
|
||||
|
||||
// loadAll() 异步填充缓存后,再次应用 UI 布局(模块级 state,watch 仅触发一次)
|
||||
watch(
|
||||
() => appSettings.cache['df-ai-ui'],
|
||||
(v) => {
|
||||
if (!v) return
|
||||
const s = v as any
|
||||
if (typeof s.panelOpen === 'boolean') state.panelOpen = s.panelOpen
|
||||
if (typeof s.maximized === 'boolean') state.maximized = s.maximized
|
||||
if (typeof s.sidebarOpen === 'boolean') state.sidebarOpen = s.sidebarOpen
|
||||
if (typeof s.archivedCollapsed === 'boolean') state.archivedCollapsed = s.archivedCollapsed
|
||||
},
|
||||
)
|
||||
watch(() => appSettings.cache['df-ai-ui'], (v) => applyUiState(v as any))
|
||||
|
||||
/** 持久化 UI 布局到 appSettings(debounced 写 SQLite) */
|
||||
export function persistUiState() {
|
||||
@@ -47,9 +57,20 @@ export function persistUiState() {
|
||||
maximized: state.maximized,
|
||||
sidebarOpen: state.sidebarOpen,
|
||||
archivedCollapsed: state.archivedCollapsed,
|
||||
// 侧栏宽度(UX-2025-16):拖拽 mouseup 时由 AiChat 调 persistUiState 落盘
|
||||
sidebarWidth: state.sidebarWidth,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置侧栏宽度(clamp 后写 state,不持久化)。
|
||||
* 拖拽 mousemove 高频调用,持久化由调用方在 mouseup 时一次 persistUiState() 完成,
|
||||
* 避免每像素触发 debounced SQLite 写。
|
||||
*/
|
||||
export function setSidebarWidth(px: number) {
|
||||
state.sidebarWidth = clampSidebarWidth(px)
|
||||
}
|
||||
|
||||
/** 切换面板展开/收起(收起时同步退出最大化,避免 v-show 卡死 main 交互) */
|
||||
function togglePanel() {
|
||||
state.panelOpen = !state.panelOpen
|
||||
@@ -103,5 +124,8 @@ export function useAiPanel() {
|
||||
loadSkills,
|
||||
setProvider,
|
||||
clearChat,
|
||||
// UX-2025-16:侧栏宽度拖拽
|
||||
setSidebarWidth,
|
||||
persistUiState,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
//! 发送与队列管理 — sendMessage/approveToolCall + 队列(drain/cancel/clear) + stopChat
|
||||
//!
|
||||
//! B-260616-02 L2 发送韧性:三级降级
|
||||
//! L0 normal:后端 idle → 直接发送
|
||||
//! L1 queued: 后端 busy → 入队等待(≤30s 正常排队)
|
||||
//! L2 force: 排队>30s → 弹 confirm → 用户确认后调 ai_chat_force_send 复位残留并强制发出
|
||||
//!
|
||||
//! 模块级私有:
|
||||
//! - 无(队列存在 state.queue 中,看门狗/审批状态机依赖 events 与 stream)
|
||||
//!
|
||||
@@ -25,52 +30,74 @@ const appSettings = useAppSettingsStore()
|
||||
/// 待发送队列上限(超过抛错提示用户)
|
||||
const QUEUE_LIMIT = 10
|
||||
|
||||
/** 取出队首并发送(AiCompleted 触发,此时 streaming 已 false) */
|
||||
export function drainQueue() {
|
||||
if (state.queue.length === 0) return
|
||||
const next = state.queue.shift()!
|
||||
void sendMessage(next.text, next.skill)
|
||||
/// 排队超时阈值(ms) — 超过后弹 confirm 让用户选择"强制发送"或继续等(B-260616-02)
|
||||
const QUEUE_TIMEOUT_MS = 30_000
|
||||
|
||||
/// 审批等待超时阈值(ms) — 用户 5 分钟内不处理则自动拒绝,防 pending_approval 永久卡死对话。
|
||||
/// 纯前端定时器:审批依赖页面交互,页面关闭/不处理即超时 auto-reject 合理(后端无超时)。
|
||||
/// TODO: 未来接 Settings 可配(df-ai-approval-timeout-ms),当前写死减范围(避免改 Settings.vue god file)
|
||||
const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000
|
||||
|
||||
/**
|
||||
* 审批超时计时器:toolCallId → timer。
|
||||
* 在 AiApprovalRequired(审批开始等待)启动,AiApprovalResult/AiToolCallCompleted(状态离开 pending_approval)、
|
||||
* onStreamTimeout(整流超时收尾)、stopListener(组件卸载)时清除。
|
||||
* 与 useAiEvents._toolTimers(慢执行 toast)同模式,但本计时器到点会改状态(auto-reject),
|
||||
* 故与 running 态 toast 解耦——审批态不应被 toast 计时器误触发。
|
||||
*/
|
||||
const _approvalTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
/**
|
||||
* 启动审批超时计时器(幂等:同 id 重复启动不重建,沿用现有计时器)。
|
||||
* 到点回调:调 ai_approve(id, false) 自动拒绝 + push 一条系统错误消息到 state.messages
|
||||
* (参照 onStreamTimeout push 模式;composable 无 UI 上下文,toast 跨层复杂,系统消息更简单闭环)。
|
||||
* TODO: toast 跨层接线(目前仅 push 系统消息,无即时强提示)
|
||||
*/
|
||||
export function startApprovalTimer(toolCallId: string, toolName: string): void {
|
||||
if (_approvalTimers.has(toolCallId)) return
|
||||
const timer = setTimeout(() => {
|
||||
_approvalTimers.delete(toolCallId)
|
||||
// 自动拒绝:调 ai_approve(false) 通知后端不执行该工具
|
||||
aiApi.approve(toolCallId, false).catch(e => {
|
||||
// IPC 失败(网络断/后端已关):后端状态已乱,前端仍 push 提示让用户知晓
|
||||
console.error('[AI] 审批超时自动拒绝 IPC 未送达:', e)
|
||||
})
|
||||
// push 系统错误消息(参照 onStreamTimeout push 模式)
|
||||
state.messages.push({
|
||||
id: `approval-timeout-${nextMsgId()}`,
|
||||
role: 'assistant',
|
||||
content: t('ai.approvalTimeout', { toolName }),
|
||||
isError: true,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}, APPROVAL_TIMEOUT_MS)
|
||||
_approvalTimers.set(toolCallId, timer)
|
||||
}
|
||||
|
||||
/** 发送消息:生成中入队,否则推送 user+air 气泡并触发后端流式 */
|
||||
async function sendMessage(text: string, skill?: string) {
|
||||
if (!text.trim()) return
|
||||
|
||||
// B-260615-22(方案 A):发送前 IPC 查后端真实 generating。
|
||||
// 前端 state.streaming 与后端 AiSession.generating 各自维护:后端 loop 异常退出/
|
||||
// AiError 已复位 generating=false 时,本地 streaming 可能仍为 false(状态不同步),
|
||||
// 仅查本地预检会放行,随后撞 ai_chat_send:45 的 generating 拦截。此处对齐后端真值。
|
||||
let backendGenerating = false
|
||||
try {
|
||||
backendGenerating = await invoke<boolean>('ai_is_generating')
|
||||
} catch {
|
||||
// IPC 失败不阻断发送:退化到仅靠本地 streaming 预检(原行为),让 ai_chat_send 兜底。
|
||||
backendGenerating = false
|
||||
}
|
||||
if (backendGenerating) {
|
||||
if (state.queue.length >= QUEUE_LIMIT) {
|
||||
throw new Error(t('ai.queueFull', { limit: QUEUE_LIMIT }))
|
||||
}
|
||||
// 后端真在生成但本地 streaming 已复位(false):状态不同步——对齐本地 streaming=true
|
||||
// 让用户感知(禁用输入框等),并入队等待 AiCompleted 续发;不入队则消息静默丢失。
|
||||
if (!state.streaming) {
|
||||
state.streaming = true
|
||||
}
|
||||
state.queue.push({ text: text.trim(), skill: skill || undefined })
|
||||
return
|
||||
/** 清除单个审批计时器(审批完成/拒绝时调用) */
|
||||
export function clearApprovalTimer(toolCallId: string): void {
|
||||
const timer = _approvalTimers.get(toolCallId)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
_approvalTimers.delete(toolCallId)
|
||||
}
|
||||
}
|
||||
|
||||
// 本地 streaming=true 但后端 generating=false(看门狗/AiError 已复位本地,或反向不同步):
|
||||
// 后端可接收,本地直接进入发送(无需复位 streaming)。
|
||||
// 生成中:进入待发送队列,当前对话完成后(AiCompleted)由 drainQueue 自动续发
|
||||
if (state.streaming) {
|
||||
if (state.queue.length >= QUEUE_LIMIT) {
|
||||
throw new Error(t('ai.queueFull', { limit: QUEUE_LIMIT }))
|
||||
}
|
||||
state.queue.push({ text: text.trim(), skill: skill || undefined })
|
||||
return
|
||||
}
|
||||
/** 清除全部审批计时器(onStreamTimeout/stopListener 整流收尾时调用) */
|
||||
export function clearAllApprovalTimers(): void {
|
||||
for (const timer of _approvalTimers.values()) clearTimeout(timer)
|
||||
_approvalTimers.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 入队时记录时间戳,供 UI 展示排队时长和触发超时提示。
|
||||
* queue item 扩展: 原 { text, skill? } → { text, skill?, enqueuedAt }
|
||||
* 兼容 drainQueue/cancelQueued/clearQueue 等已有消费方(enqueuedAt 仅读不写)。
|
||||
*/
|
||||
|
||||
// ── 内核发送:推送气泡+调 IPC,被 normal/force 两条路径共用 ──
|
||||
|
||||
async function doSend(text: string, skill?: string, force = false) {
|
||||
const userMsgId = `user-${nextMsgId()}`
|
||||
state.messages.push({
|
||||
id: userMsgId,
|
||||
@@ -97,25 +124,105 @@ async function sendMessage(text: string, skill?: string) {
|
||||
? appSettings.get<string>('df-language', 'zh-CN')
|
||||
: raw
|
||||
try {
|
||||
await aiApi.sendMessage(text.trim(), lang, skill)
|
||||
// force=true 走 force_send IPC(复位残留 generating),否则走正常 send
|
||||
if (force) {
|
||||
await aiApi.forceSend(text.trim(), lang, skill)
|
||||
} else {
|
||||
await aiApi.sendMessage(text.trim(), lang, skill)
|
||||
}
|
||||
} catch (e) {
|
||||
// IPC 失败(spawn 前/provider 配置错等):回滚 streaming 防光标卡死 + 移除 user 消息与空气泡占位;
|
||||
// 重新抛出由 handleSend 回填输入框,用户可重试
|
||||
state.streaming = false
|
||||
clearStreamWatchdog() // 清 :61 启动的看门狗,防 130s 后 onStreamTimeout 误推错误气泡
|
||||
clearStreamWatchdog() // 清看门狗,防 130s 后 onStreamTimeout 误推错误气泡
|
||||
state.messages = state.messages.filter(m => m.id !== aiMsgId && m.id !== userMsgId)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/** 工具审批:乐观置 running,IPC 失败时改 completed+错误文案(不回滚避免卡按钮) */
|
||||
/** 取出队首并发送(AiCompleted 触发,此时 streaming 已 false) */
|
||||
export function drainQueue() {
|
||||
if (state.queue.length === 0) return
|
||||
const next = state.queue.shift()!
|
||||
void sendMessage(next.text, next.skill)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息 — 三级降级(B-260616-02 L2 发送韧性):
|
||||
*
|
||||
* L0 normal: 后端 idle → 直接 doSend()
|
||||
* L1 queued: 后端 busy → 入队等 AiCompleted 续发(≤30s 正常等待)
|
||||
* L2 force: 排队>30s → 返回特殊标记,由调用方(handleSend)弹 confirm;
|
||||
* 用户确认后重新进 sendMessage(forceMode=true)走 forceSend IPC
|
||||
*
|
||||
* forceMode=true 时跳过入队,直接走 ai_chat_force_send。
|
||||
*/
|
||||
async function sendMessage(text: string, skill?: string, forceMode = false) {
|
||||
if (!text.trim()) return
|
||||
|
||||
// ── L2 强制模式:跳过所有预检,直接 force_send ──
|
||||
if (forceMode) {
|
||||
await doSend(text, skill, true)
|
||||
return
|
||||
}
|
||||
|
||||
// B-260615-22(方案 A):发送前 IPC 查后端真实 generating。
|
||||
let backendGenerating = false
|
||||
try {
|
||||
backendGenerating = await invoke<boolean>('ai_is_generating')
|
||||
} catch {
|
||||
backendGenerating = false
|
||||
}
|
||||
|
||||
// ── L1:后端 busy → 入队 ──
|
||||
if (backendGenerating || state.streaming) {
|
||||
if (state.queue.length >= QUEUE_LIMIT) {
|
||||
throw new Error(t('ai.queueFull', { limit: QUEUE_LIMIT }))
|
||||
}
|
||||
state.streaming = true
|
||||
state.queue.push({ text: text.trim(), skill: skill || undefined, enqueuedAt: Date.now() })
|
||||
return
|
||||
}
|
||||
|
||||
// ── L0:normal 直接发送 ──
|
||||
await doSend(text, skill, false)
|
||||
}
|
||||
|
||||
/** 排队是否已超时(任一条超即算,因队首阻塞导致后续全堵) */
|
||||
export function isQueueTimedOut(): boolean {
|
||||
const first = state.queue[0]
|
||||
return first !== undefined && (Date.now() - first.enqueuedAt > QUEUE_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
/** 超时时弹 confirm,用户确认后以 force_mode 重发队首消息;取消则保持排队 */
|
||||
export async function tryForceSend(confirmFn: (msg: string) => Promise<boolean>): Promise<boolean> {
|
||||
const first = state.queue[0]
|
||||
if (!first) return false
|
||||
const confirmed = await confirmFn(t('ai.forceSendConfirm'))
|
||||
if (!confirmed) return false
|
||||
// 从队列移除队首,以 force_mode 发送
|
||||
state.queue.shift()
|
||||
// 清看门狗:doSend 内 force_send 路径会重设 streaming=true 并重启 watchdog,
|
||||
// 此处无需前置 false(前置 false 会触发 AiChat watch 清流式块再重建,产生瞬态抖动)
|
||||
clearStreamWatchdog()
|
||||
try {
|
||||
await sendMessage(first.text, first.skill, true)
|
||||
return true
|
||||
} catch {
|
||||
// force_send 也失败:消息不回队列(避免循环),让用户看到错误
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 工具审批:保持 pending_approval 让审批卡片可见(按钮 loading 由 ToolCard 本地 ref 持有),
|
||||
* IPC 失败时改 completed+错误文案。后端回事件后由 useAiEvents 转 completed/rejected。
|
||||
* B-260616-08:不再乐观置 running——原写法让 .ai-tool-approval 整块消失(切骨架屏),
|
||||
* 按钮无 loading 中间态、用户无重审入口;loading 现下沉到 ToolCard 局部 ref,语义正确。 */
|
||||
async function approveToolCall(toolCallId: string, approved: boolean) {
|
||||
// 乐观置运行中,禁用审批按钮防重复点击(后端事件回来后转 completed/rejected)
|
||||
// 复用 findToolCall(反向扫描)避免重复实现查找逻辑
|
||||
// 复用 findToolCall(反向扫描)避免重复实现查找逻辑;仅缓存引用用于 IPC 失败回滚
|
||||
const tc = findToolCall(toolCallId)
|
||||
if (tc) tc.status = 'running'
|
||||
// 重启看门狗覆盖审批执行→续生成窗口(useAiEvents.ts:162 AiApprovalRequired 已 clear,
|
||||
// 审批态无心跳兜底;approve 后后端要跑工具+续生成,期间任何后端异常不回则按钮永久 running。
|
||||
// 审批态无心跳兜底;approve 后后端要跑工具+续生成,期间任何后端异常不回则按钮永久 loading。
|
||||
// 复用通用 watchdog 而非加审批专用超时:复用同一收尾路径(onStreamTimeout 兜底复位 streaming),
|
||||
// 避免新增独立计时器与状态分支)
|
||||
resetStreamWatchdog()
|
||||
@@ -136,6 +243,21 @@ async function approveToolCall(toolCallId: string, approved: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 批量审批:遍历 pendingApprovals 逐个调用 approveToolCall */
|
||||
async function batchApprove(decision: 'approve' | 'reject') {
|
||||
const approved = decision === 'approve'
|
||||
// 快照当前待审批列表(遍历中 state.pendingApprovals 会因事件回调而缩短)
|
||||
const ids = [...state.pendingApprovals].map(p => p.id)
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await approveToolCall(id, approved)
|
||||
} catch {
|
||||
// 单条失败不中断批量操作(已由 approveToolCall 内部回滚该条状态)
|
||||
// 继续处理剩余项
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 取消队列中指定位置的消息 */
|
||||
function cancelQueued(index: number) {
|
||||
state.queue.splice(index, 1)
|
||||
@@ -160,9 +282,15 @@ export function useAiSend() {
|
||||
return {
|
||||
sendMessage,
|
||||
approveToolCall,
|
||||
batchApprove,
|
||||
drainQueue,
|
||||
cancelQueued,
|
||||
clearQueue,
|
||||
stopChat,
|
||||
isQueueTimedOut,
|
||||
tryForceSend,
|
||||
startApprovalTimer,
|
||||
clearApprovalTimer,
|
||||
clearAllApprovalTimers,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import { state } from '@/stores/ai'
|
||||
import i18n from '@/i18n'
|
||||
import { nextMsgId } from './aiShared'
|
||||
import { emit } from '@tauri-apps/api/event'
|
||||
|
||||
// composable 内非组件上下文(无 setup),用 vue-i18n 全局实例的 t 而非 useI18n()。
|
||||
// 通过 any 中转规避 vue-i18n 深度 message schema 泛型导致的 TS2589(类型实例化过深)。
|
||||
@@ -32,13 +33,19 @@ export function onStreamTimeout() {
|
||||
state.currentText = ''
|
||||
state.queue = [] // B-32:超时收尾同步清队列,防生成中入队的消息静默丢失
|
||||
clearStreamWatchdog()
|
||||
// AE-2025-06: 整流超时收尾 → 广播清全部审批超时计时器。
|
||||
// 本模块不 import useAiSend(会构成 useAiSend↔useAiStream 循环依赖,与现有破环原则冲突),
|
||||
// 复用 ai-tool-slow-toast 同款事件总线模式:emit 由 useAiEvents.startListener listen 后调
|
||||
// clearAllApprovalTimers。流式已中断,审批继续挂定时器无意义(用户也会随之停止交互)。
|
||||
void emit('ai-approval-clear-timers', {})
|
||||
// 一次性遍历同时做两件事(单次 O(n),不再分两遍扫):
|
||||
// 1) 回滚 running 态工具调用 → rejected:approveToolCall 乐观置 running,后端 hang 时
|
||||
// 看门狗触发本回调,若不回滚则卡片永久骨架屏(ToolCard:24 渲染 running 无审批按钮,无重审入口)。
|
||||
// B-260615-07 残留。
|
||||
// 1) 回滚 running 态工具调用 → rejected:approveToolCall 已不乐观置 running(B-260616-08),
|
||||
// 此分支兜底后端事件先转 running 再 hang 的卡片,避免永久骨架屏。B-260615-07/B-33 残留。
|
||||
// (approve 后无 running 转换而 hang 的卡片由 ToolCard 本地 approving 计时器兜底复位,
|
||||
// 不在此全局回滚 pending_approval——会误伤其他真正待审批的卡片。)
|
||||
// 2) 探测任一 completed 卡片以区分错误文案(工具已执行完后续回复中断 vs 纯流式中断)。
|
||||
// 反向扫尾部提前退出(同 findToolCall 策略);不复用 useAiEvents.findToolCall:本模块
|
||||
// 不依赖 useAiEvents(已下沉 nextMsgId 到 aiShared 破环),且此处需全量回滚 running 而非按 id 定位。
|
||||
// 不依赖 useAiEvents(已下沉 nextMsgId 到 aiShared 破环),且此处需全量回滚而非按 id 定位。
|
||||
let hasCompletedTool = false
|
||||
for (let i = state.messages.length - 1; i >= 0; i--) {
|
||||
const toolCalls = state.messages[i].toolCalls
|
||||
|
||||
Reference in New Issue
Block a user