优化: AI模块(provider重试兼容+aichat交互+工具卡片可读化+diff高亮)
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user