Files
DevFlow/src/composables/ai/useAiSend.ts
绝尘 fddca9daf1 重构: CR-11 composables+ToolCard 健壮性 6 子项
⑦ useAiConversations JSON.parse 逐条 try/catch 容错(坏条降级不清空整对话);⑧ ToolCard 去 as any + argString 安全取字段;⑨ useAiSend approveToolCall 复用 findToolCall;⑩ useAiEvents 补 AiHeartbeat case 防穿透;⑪ approveHumanApproval 签名收敛(decisions:string[] + select_type 判单/多选)+ ProjectDetail 调用方同步(单选包数组/多选直传);⑫ ToolCard formatBytes/formatToolName/argString 兜底。批5 ww1mh6mry,vue-tsc 0
2026-06-15 05:45:04 +08:00

139 lines
5.3 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.
//! 发送与队列管理 — sendMessage/approveToolCall + 队列(drain/cancel/clear) + stopChat
//!
//! 模块级私有:
//! - 无(队列存在 state.queue 中,看门狗/审批状态机依赖 events 与 stream)
//!
//! 耦合:
//! - sendMessage 调 useAiEvents.startListener(useAiEvents 导出但不在解构集中暴露给组件,
//! 故 startListener 同时经 useAiEvents 导出,sendMessage 直接 import 调用)
//! - sendMessage 调 useAiStream.resetStreamWatchdog
//! - drainQueue 在 sendMessage 完成后由 handleEvent(AiCompleted) 调用 — 故 drainQueue 必须为模块级 export
import { aiApi } from '../../api'
import { useAppSettingsStore } from '../../stores/appSettings'
import { state } from '../../stores/ai'
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
import { startListener, findToolCall } from './useAiEvents'
import { nextMsgId } from './aiShared'
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)
}
/** 发送消息:生成中入队,否则推送 user+air 气泡并触发后端流式 */
async function sendMessage(text: string, skill?: string) {
if (!text.trim()) return
// 生成中:进入待发送队列,当前对话完成后(AiCompleted)由 drainQueue 自动续发
if (state.streaming) {
if (state.queue.length >= QUEUE_LIMIT) {
throw new Error(`待发送队列已满(最多 ${QUEUE_LIMIT} 条)`)
}
state.queue.push({ text: text.trim(), skill: skill || undefined })
return
}
const userMsgId = `user-${nextMsgId()}`
state.messages.push({
id: userMsgId,
role: 'user',
content: text.trim(),
timestamp: Date.now(),
})
const aiMsgId = `ai-${nextMsgId()}`
state.messages.push({
id: aiMsgId,
role: 'assistant',
content: '',
timestamp: Date.now(),
})
state.streaming = true
state.currentText = ''
resetStreamWatchdog() // 启动流式看门狗,无数据超时兜底
await startListener()
const raw = appSettings.get<string>('df-ai-language', 'auto')
const lang = raw === 'auto'
? appSettings.get<string>('df-language', 'zh-CN')
: raw
try {
await aiApi.sendMessage(text.trim(), lang, skill)
} catch (e) {
// IPC 失败(spawn 前/provider 配置错等):回滚 streaming 防光标卡死 + 移除 user 消息与空气泡占位;
// 重新抛出由 handleSend 回填输入框,用户可重试
state.streaming = false
clearStreamWatchdog() // 清 :61 启动的看门狗,防 130s 后 onStreamTimeout 误推错误气泡
state.messages = state.messages.filter(m => m.id !== aiMsgId && m.id !== userMsgId)
throw e
}
}
/** 工具审批:乐观置 running,IPC 失败时改 completed+错误文案(不回滚避免卡按钮) */
async function approveToolCall(toolCallId: string, approved: boolean) {
// 乐观置运行中,禁用审批按钮防重复点击(后端事件回来后转 completed/rejected)
// 复用 findToolCall(反向扫描)避免重复实现查找逻辑
const tc = findToolCall(toolCallId)
if (tc) tc.status = 'running'
// 重启看门狗覆盖审批执行→续生成窗口(useAiEvents.ts:162 AiApprovalRequired 已 clear,
// 审批态无心跳兜底;approve 后后端要跑工具+续生成,期间任何后端异常不回则按钮永久 running。
// 复用通用 watchdog 而非加审批专用超时:复用同一收尾路径(onStreamTimeout 兜底复位 streaming),
// 避免新增独立计时器与状态分支)
resetStreamWatchdog()
try {
await aiApi.approve(toolCallId, approved)
} catch (e) {
// IPC 未送达:用户已点过按钮,清看门狗(避免 130s 后误触发假错误)再回滚到失败态
clearStreamWatchdog()
state.queue = [] // B-32:IPC 失败即对话结束,清队列防生成中入队消息静默丢失
// 仅 IPC 真失败(审批已处理/网络断)走到这里:后端工具失败已改走 emit completed不进此分支
// 不回滚 pending_approval(会卡死按钮),改设 completed + 错误提示,让用户知晓失败
console.error('[AI] 审批操作未送达后端:', e)
if (tc) {
tc.status = 'completed'
tc.result = `审批操作未送达后端:${e instanceof Error ? e.message : String(e)}`
}
throw e
}
}
/** 取消队列中指定位置的消息 */
function cancelQueued(index: number) {
state.queue.splice(index, 1)
}
/** 清空整个待发送队列 */
function clearQueue() {
state.queue = []
}
/** 停止当前生成:本地先复位 streaming再发停止信号。
* 不依赖后端 AiCompleted 收尾——审批态看门狗已 clear(useAiEvents),若 AiCompleted 竞态丢失则 streaming 永久 true 卡死,故本地兜底。
*/
async function stopChat() {
state.streaming = false // 本地先复位,不依赖后端 AiCompleted审批态看门狗已 clear竞态丢失则卡死
clearStreamWatchdog() // 清残留看门狗
state.queue = [] // B-32:用户主动停止,清队列防生成中入队的消息被静默丢弃
await aiApi.stopChat()
}
export function useAiSend() {
return {
sendMessage,
approveToolCall,
drainQueue,
cancelQueued,
clearQueue,
stopChat,
}
}