⑦ 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
139 lines
5.3 KiB
TypeScript
139 lines
5.3 KiB
TypeScript
//! 发送与队列管理 — 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,
|
||
}
|
||
}
|