596 lines
28 KiB
TypeScript
596 lines
28 KiB
TypeScript
//! 发送与队列管理 — sendMessage/regenerate/editMessage + 队列(drain/cancel/clear) + stopChat
|
||
//!
|
||
//! 审批链(approveToolCall/batchApprove)已拆分到 useAiApproval.ts(fe-arch P0-1)。
|
||
//!
|
||
//! L2 发送韧性:三级降级
|
||
//! L0 normal:后端 idle → 直接发送
|
||
//! L1 queued: 后端 busy → 入队等待(≤30s 正常排队)
|
||
//! L2 force: 排队>30s → 弹 confirm → 用户确认后调 ai_chat_force_send 复位残留并强制发出
|
||
//!
|
||
//! 耦合:
|
||
//! - sendMessage 调 useAiEvents.startListener(useAiEvents 导出但不在解构集中暴露给组件,
|
||
//! 故 startListener 同时经 useAiEvents 导出,sendMessage 直接 import 调用)
|
||
//! - sendMessage 调 useAiStream.resetStreamWatchdog
|
||
//! - drainQueue 由 handleEvent(AiCompleted) 经 ai-drain-queue 事件总线触发(非直接调用,
|
||
//! 打破 events↔send 循环依赖);本模块模块级监听该事件
|
||
|
||
import { listen, emit } from '@tauri-apps/api/event'
|
||
import { invoke } from '@tauri-apps/api/core'
|
||
import { ref } from 'vue'
|
||
import { aiApi } from '@/api'
|
||
import { state } from '@/stores/ai'
|
||
import type { ContentPart, AiMessage, MentionSpan, MessageId } from '@/api/types'
|
||
import { t } from '@/i18n/i18n-helpers'
|
||
import { resetStreamWatchdog, clearStreamWatchdog, clearResumeAttempts } from './useAiStream'
|
||
import { setStreaming } from './streamingGuard'
|
||
import { ensureListenerStarted } from './useAiEvents'
|
||
import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang, convStates, setConvCurrentText, setConvStreaming, flushCurrentText, notifyQueueCleared, classifySendError, type SendErrorKind } from './aiShared'
|
||
|
||
/// UX-P2-A5:drainQueue 失败气泡的错误分类文案映射(分类逻辑 aiShared.classifySendError,
|
||
/// 键复用 ChatInput 发送失败 toast 同一批 aiChat.sendErr* 文案,原始错误经 { msg } 细节保留)
|
||
const QUEUED_SEND_ERR_KEY: Record<SendErrorKind, string> = {
|
||
rate_limit: 'aiChat.sendErrRateLimit',
|
||
quota: 'aiChat.sendErrQuota',
|
||
network: 'aiChat.sendErrNetwork',
|
||
other: 'aiChat.toastSendFail',
|
||
}
|
||
|
||
/// 待发送队列上限(超过抛错提示用户)
|
||
const QUEUE_LIMIT = 10
|
||
|
||
/// 排队超时阈值(ms) — 超过后弹 confirm 让用户选择"强制发送"或继续等
|
||
const QUEUE_TIMEOUT_MS = 30_000
|
||
|
||
/**
|
||
* 用户指定模型 override(主对话专用,模块级单例)。
|
||
*
|
||
* null=自动模式(路由器选);非空字符串=用户从下拉指定的 model_id。
|
||
* 仅主对话(agentic)生效:doSend/sendMessage/regenerate/editMessage 透传给后端,
|
||
* 标题/扫描/灵感等内部调用不读此字段。
|
||
*
|
||
* 后端兜底(agentic.rs):override 非空且在该 provider model_configs 池中才用,
|
||
* 否则落回路由结果——绝不让 override 导致无模型。
|
||
*
|
||
* 模块级 ref(非组件级):AiChat 顶部下拉读写,发送链路读此 ref 透传,单实例不需 props/inject。
|
||
* 切换会话后端会清自身 session.model_override;前端保留用户手选(每会话默认权重最高
|
||
* 由 TopBar 兜底:仅当 override 空/对目标模型池无效时才重置),避免切会话往返丢失手选模型。
|
||
*/
|
||
const modelOverride = ref<string | null>(null)
|
||
|
||
// 审批计时器(startApprovalTimer/clearApprovalTimer/clearAllApprovalTimers)已下沉到 aiShared.ts
|
||
// 打破 useAiEvents ↔ useAiSend 循环依赖。本模块 re-export 供组件使用。
|
||
|
||
/**
|
||
* 入队时记录时间戳,供 UI 展示排队时长和触发超时提示。
|
||
* queue item 扩展: 原 { text, skill? } → { text, skill?, enqueuedAt, parts? }
|
||
* 兼容 drainQueue/cancelQueued/clearQueue 等已有消费方(enqueuedAt/parts 仅读不写)。
|
||
*/
|
||
|
||
// ── 内核发送:推送气泡+调 IPC,被 normal/force 两条路径共用 ──
|
||
|
||
/**
|
||
* 内核发送:推送气泡 + 调 IPC,被 normal/force 两条路径共用。
|
||
*
|
||
* @param convId 目标会话 id(显式传入优先,缺省当前活跃会话)。后台会话续发(drainQueue)场景
|
||
* 目标会话 ≠ 当前活跃视图,此时不 push 本地气泡、不碰当前视图流式态,避免污染当前视图。
|
||
*/
|
||
async function doSend(text: string, skill?: string, force = false, parts?: ContentPart[], spans?: MentionSpan[], convId?: string | null) {
|
||
// 目标会话:显式传入优先,缺省当前活跃会话。
|
||
const targetConvId = convId ?? state.activeConversationId
|
||
// state.messages 是当前活跃会话的全局视图;后台会话续发不 push 本地气泡,
|
||
// 切回目标会话时由切换加载从 DB/事件恢复完整历史,避免把 A 的消息污染 B 的视图。
|
||
const isViewTarget = !targetConvId || targetConvId === state.activeConversationId
|
||
// 防御:先 flush 残留 currentText(正常情况已是空,flushCurrentText 直接 return;
|
||
// 异常时序下可防止上一轮文本被清空误伤)。仅当前视图会话发送才 flush,后台续发不碰当前视图流式文本。
|
||
if (isViewTarget) flushCurrentText()
|
||
|
||
const userMsgId = `user-${nextMsgId()}`
|
||
const aiMsgId = `ai-${nextMsgId()}`
|
||
if (isViewTarget) {
|
||
state.messages.push({
|
||
id: userMsgId as MessageId,
|
||
role: 'user',
|
||
// 技能调用留痕(问题3,与后端 chat.rs user_content 同规则):带技能时本地气泡即刻补
|
||
// /技能名 前缀,与落库文本一致——否则发送瞬间无痕、切回会话才见前缀,前后显示不一。
|
||
content: skill ? `/${skill} ${text.trim()}` : text.trim(),
|
||
// 仅在非空时挂 parts(纯文本消息保持 undefined,渲染走原 content 路径零回归)
|
||
parts: parts && parts.length > 0 ? parts : undefined,
|
||
// Input Augmentation: 仅在非空时挂 mentionSpans(供消息列表 chip 渲染;空则 undefined 零回归)
|
||
mentionSpans: spans && spans.length > 0 ? spans : undefined,
|
||
timestamp: Date.now(),
|
||
})
|
||
|
||
state.messages.push({
|
||
id: aiMsgId as MessageId,
|
||
role: 'assistant',
|
||
content: '',
|
||
timestamp: Date.now(),
|
||
})
|
||
setStreaming(true, { convId: targetConvId, reason: 'doSend' })
|
||
state.currentText = ''
|
||
// 启动流式看门狗(per-conv,无数据超时兜底)
|
||
resetStreamWatchdog(targetConvId || undefined)
|
||
} else {
|
||
// 后台会话续发:直接写其 per-conv 流式态(setStreaming 写当前活跃视图,后台场景会写错会话);
|
||
// 不 arm 看门狗(目标会话流式态暂由切换加载时按会话状态重算,后台不干扰当前视图)。
|
||
setConvStreaming(targetConvId, true)
|
||
setConvCurrentText(targetConvId, '')
|
||
}
|
||
|
||
await ensureListenerStarted()
|
||
const lang = resolveAiLang()
|
||
try {
|
||
// force=true 走 force_send IPC(复位残留 generating),否则走正常 send
|
||
const override = modelOverride.value
|
||
if (force) {
|
||
// 透传 parts(图片) + mentionSpans + 目标会话 id
|
||
await aiApi.forceSend(text.trim(), lang, skill, override, parts, targetConvId, spans)
|
||
} else {
|
||
await aiApi.sendMessage(text.trim(), lang, skill, override, parts, targetConvId, spans)
|
||
}
|
||
} catch (e) {
|
||
// IPC 失败(spawn 前/provider 配置错等):回滚目标会话 streaming 防光标卡死 + 移除本地气泡占位;
|
||
// 重新抛出由调用方回填输入框,用户可重试。彻底清理会话状态,避免按钮永久红色。
|
||
const failConvId = targetConvId
|
||
if (isViewTarget) {
|
||
setStreaming(false, { convId: failConvId, reason: 'doSend-ipc-fail' })
|
||
state.messages = state.messages.filter(m => m.id !== aiMsgId && m.id !== userMsgId)
|
||
} else {
|
||
setConvStreaming(failConvId, false)
|
||
}
|
||
clearStreamWatchdog(failConvId || undefined)
|
||
if (failConvId) convStates.delete(failConvId)
|
||
throw e
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 重新生成最后一条 AI 回复。
|
||
*
|
||
* 与 doSend 的区别:
|
||
* - 不 push 新 user 消息(触发它的 user 消息已在历史末尾)
|
||
* - 仅 push 一个空 AI 气泡占位 + 置 streaming,后端弹出旧 AI 回复后重跑 loop
|
||
* - 失败回滚只移除占位气泡(不移除 user 消息)
|
||
*
|
||
* 后端 ai_regenerate 占用 generating 防并发双发,此处不再额外入队/预检。
|
||
*/
|
||
async function regenerate() {
|
||
// 末尾须有 AI 回复可重生成(后端也会校验,前端先挡避免空跑)
|
||
const msgs = state.messages
|
||
const last = msgs[msgs.length - 1]
|
||
if (!last || last.role !== 'assistant') {
|
||
throw new Error(t('aiChat.regenerateUnavailable'))
|
||
}
|
||
|
||
// 移除末尾旧 AI 气泡(后端也弹 history;前端先弹避免占位前残留旧文),push 空气泡占位
|
||
state.messages.pop()
|
||
const aiMsgId = `ai-${nextMsgId()}`
|
||
state.messages.push({
|
||
id: aiMsgId as MessageId,
|
||
role: 'assistant',
|
||
content: '',
|
||
timestamp: Date.now(),
|
||
})
|
||
|
||
setStreaming(true, { convId: state.activeConversationId, reason: 'regenerate' })
|
||
state.currentText = ''
|
||
// 只清当前会话的排队消息,不误伤其他会话;
|
||
// UX-P2-A2:清队从静默改 toast 提示条数(排队消息无声消失原无任何反馈)
|
||
const regenConvId = state.activeConversationId
|
||
const droppedQueued = state.queue.filter(q => q.conversationId === regenConvId).length
|
||
state.queue = state.queue.filter(q => q.conversationId !== regenConvId)
|
||
notifyQueueCleared(droppedQueued)
|
||
// 会话态走 per-conv 键(与 doSend 一致),不再落 legacy 单 timer fallback
|
||
resetStreamWatchdog(regenConvId || undefined)
|
||
|
||
await ensureListenerStarted()
|
||
const lang = resolveAiLang()
|
||
const convId = state.activeConversationId
|
||
if (!convId) {
|
||
// 无活跃对话:回滚占位,报错
|
||
setStreaming(false, { reason: 'regenerate-no-conv' })
|
||
clearStreamWatchdog(regenConvId || undefined)
|
||
state.messages = state.messages.filter(m => m.id !== aiMsgId)
|
||
throw new Error(t('aiChat.regenerateUnavailable'))
|
||
}
|
||
try {
|
||
await aiApi.regenerate(convId, lang, modelOverride.value)
|
||
} catch (e) {
|
||
// IPC 失败:回滚 streaming + 移除占位气泡(旧回复后端已弹但前端先弹了,保持弹后态)
|
||
setStreaming(false, { convId, reason: 'regenerate-ipc-fail' })
|
||
clearStreamWatchdog(convId || undefined)
|
||
state.messages = state.messages.filter(m => m.id !== aiMsgId)
|
||
throw e
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 编辑最后一条 user 消息并重新生成。
|
||
*
|
||
* 与 regenerate 的区别:
|
||
* - regenerate:末条须是 assistant,pop 旧 AI 回复后重跑
|
||
* - editMessage:末条须是 user(中间编辑被后端拒绝),就地改其 content,截断其后所有消息,
|
||
* push 空气泡占位后重跑。被截断消息后端标 truncated(软删保留 DB),前端视图层同步移除。
|
||
*
|
||
* 后端 ai_chat_edit 占用 generating 防并发双发,此处不再额外入队/预检。
|
||
*
|
||
* @param newMessage 编辑后的新内容(替换末条 user 消息 content)
|
||
*/
|
||
async function editMessage(newMessage: string) {
|
||
if (!newMessage.trim()) return
|
||
|
||
// 末条须是 user(中间编辑语义复杂,拒绝);后端也会校验,前端先挡避免空跑
|
||
const msgs = state.messages
|
||
const last = msgs[msgs.length - 1]
|
||
if (!last || last.role !== 'user') {
|
||
throw new Error(t('aiChat.editNotLastUser'))
|
||
}
|
||
|
||
// 截断末条 user 之后的消息(此时 last 即末条 user,其后应已有 assistant 回复或为空)
|
||
// 找到末条 user 的下标,移除其后所有消息
|
||
let lastUserIdx = -1
|
||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||
if (msgs[i].role === 'user') { lastUserIdx = i; break }
|
||
}
|
||
if (lastUserIdx < 0) {
|
||
throw new Error(t('aiChat.editNotLastUser'))
|
||
}
|
||
// 截断其后(保留末条 user 本身,改其 content)
|
||
state.messages.splice(lastUserIdx + 1)
|
||
msgs[lastUserIdx].content = newMessage.trim()
|
||
|
||
// push 空气泡占位(新 AI 回复将流入)
|
||
const aiMsgId = `ai-${nextMsgId()}`
|
||
state.messages.push({
|
||
id: aiMsgId as MessageId,
|
||
role: 'assistant',
|
||
content: '',
|
||
timestamp: Date.now(),
|
||
})
|
||
|
||
setStreaming(true, { convId: state.activeConversationId, reason: 'editMessage' })
|
||
state.currentText = ''
|
||
// 只清当前会话的排队消息,不误伤其他会话;
|
||
// UX-P2-A2:清队从静默改 toast 提示条数(排队消息无声消失原无任何反馈)
|
||
const editConvId = state.activeConversationId
|
||
const droppedQueued = state.queue.filter(q => q.conversationId === editConvId).length
|
||
state.queue = state.queue.filter(q => q.conversationId !== editConvId)
|
||
notifyQueueCleared(droppedQueued)
|
||
// 会话态走 per-conv 键(与 doSend 一致),不再落 legacy 单 timer fallback
|
||
resetStreamWatchdog(editConvId || undefined)
|
||
|
||
await ensureListenerStarted()
|
||
const lang = resolveAiLang()
|
||
const convId = state.activeConversationId
|
||
if (!convId) {
|
||
setStreaming(false, { reason: 'editMessage-no-conv' })
|
||
clearStreamWatchdog(editConvId || undefined)
|
||
state.messages = state.messages.filter(m => m.id !== aiMsgId)
|
||
throw new Error(t('aiChat.editNotLastUser'))
|
||
}
|
||
try {
|
||
await aiApi.editMessage(convId, newMessage.trim(), lang, modelOverride.value)
|
||
} catch (e) {
|
||
// IPC 失败:回滚 streaming + 移除占位气泡(被截断的旧回复后端已标 truncated,前端已移除)
|
||
setStreaming(false, { convId, reason: 'editMessage-ipc-fail' })
|
||
clearStreamWatchdog(convId || undefined)
|
||
state.messages = state.messages.filter(m => m.id !== aiMsgId)
|
||
throw e
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 取出队首并发送(AiCompleted 触发,此时 streaming 已 false)。
|
||
*
|
||
* 原 fire-and-forget 吞没了 doSend IPC 失败 throw,既无 AiCompleted 触发下次 drain(队列永久
|
||
* 卡死),也无任何用户反馈。现显式 catch:复位 streaming + 推错误气泡(后台会话不推,避免污染
|
||
* 当前视图)+ 回填失败消息到该会话队首并停 drain(不静默丢用户输入,保留剩余队列供手动重试/
|
||
* 编辑/取消)。成功路径不受影响——doSend 成功后端会再 emit AiCompleted 续 drain 链路。
|
||
*
|
||
* 并发互斥 — 每个会话维护 _draining 标志,防止 AiCompleted 并发触发两次 drainQueue
|
||
* 导致 splice 两条消息同时发送,顺序错乱。已在 drain 中则跳过,等待下一次 AiCompleted 续发。
|
||
*/
|
||
let _drainingConvs = new Set<string>()
|
||
export function drainQueue(convId?: string | null) {
|
||
if (state.queue.length === 0) return
|
||
// 找队首属于目标会话的消息;若穿越(非目标会话的项堵在队首)则跳过整条队列
|
||
const targetId = convId ?? state.activeConversationId
|
||
if (!targetId) return
|
||
// 并发互斥:该会话已在 drain 中则跳过,由下一轮 AiCompleted 续发
|
||
if (_drainingConvs.has(targetId)) return
|
||
const idx = state.queue.findIndex(q => q.conversationId === targetId)
|
||
if (idx === -1) return
|
||
_drainingConvs.add(targetId)
|
||
const next = state.queue.splice(idx, 1)[0]!
|
||
void (async () => {
|
||
try {
|
||
// 用目标会话 id 发送(而非当前活跃会话),避免切走后把 A 的队首消息误发到 B;spans 一并透传
|
||
await sendMessage(next.text, next.skill, false, next.parts, next.spans, targetId)
|
||
} catch (e) {
|
||
// 失败时彻底收尾:带目标会话 id 精准清理 per-conv 看门狗 + 会话状态
|
||
setConvStreaming(targetId, false)
|
||
clearStreamWatchdog(targetId || undefined)
|
||
if (targetId) convStates.delete(targetId)
|
||
const errMsg = e instanceof Error ? e.message : String(e)
|
||
// 回填失败消息到该会话队首,保留剩余队列(不静默丢用户输入)
|
||
const reIdx = state.queue.findIndex(q => q.conversationId === targetId)
|
||
state.queue.splice(reIdx === -1 ? state.queue.length : reIdx, 0, next)
|
||
// 仅目标会话为当前视图才推错误气泡(后台会话续发失败不污染当前视图;错误消息留在队列
|
||
// 供切回后编辑/重试/取消)
|
||
if (targetId === state.activeConversationId) {
|
||
// UX-P2-A5:失败气泡接入 classifySendError 分类文案(对齐 ChatInput 发送失败 toast 四类),
|
||
// 替代原 raw 错误直出的 ai.queuedSendFailed;原始错误串经 { msg } 细节保留
|
||
state.messages.push({
|
||
id: `err-${nextMsgId()}`,
|
||
role: 'assistant',
|
||
content: t(QUEUED_SEND_ERR_KEY[classifySendError(errMsg)], { msg: errMsg }),
|
||
isError: true,
|
||
timestamp: Date.now(),
|
||
} as AiMessage)
|
||
}
|
||
console.error('[AI] drainQueue 续发失败:', e)
|
||
} finally {
|
||
_drainingConvs.delete(targetId)
|
||
}
|
||
})()
|
||
}
|
||
|
||
/**
|
||
* 发送消息 — 三级降级(L0 直接发 / L1 入队 / L2 强制):
|
||
*
|
||
* L0 normal: 后端 idle → 直接 doSend()
|
||
* L1 queued: 后端 busy → 入队等 AiCompleted 续发
|
||
* L2 force: 排队>30s → 返回特殊标记,由调用方(handleSend)弹 confirm;
|
||
* 用户确认后重新进 sendMessage(forceMode=true)走 forceSend IPC
|
||
*
|
||
* forceMode=true 时跳过入队,直接走 ai_chat_force_send。
|
||
*
|
||
* @param convId 目标会话 id(显式传入优先,缺省当前活跃会话)。后台会话续发(drainQueue)必须
|
||
* 显式传目标会话,否则切走后会用当前活跃会话发错消息。
|
||
*
|
||
* 注:入队条件仅用 backendGenerating(IPC 查后端真实态),不再检查 state.streaming。
|
||
* streaming 是渲染态(文本是否在屏幕上输出),不能作为「后端能否接收消息」的代理。
|
||
*/
|
||
async function sendMessage(text: string, skill?: string, forceMode = false, parts?: ContentPart[], spans?: MentionSpan[], convId?: string | null) {
|
||
if (!text.trim() && !(parts && parts.length)) return
|
||
// 目标会话:显式传入优先(后台会话续发场景),缺省当前活跃会话
|
||
const targetConvId = convId ?? state.activeConversationId
|
||
|
||
// ── L2 强制模式:跳过所有预检,直接 force_send ──
|
||
if (forceMode) {
|
||
await doSend(text, skill, true, parts, spans, targetConvId)
|
||
return
|
||
}
|
||
|
||
// 发送前 IPC 查后端真实 generating(精确查目标会话,不读全局单值)。
|
||
let backendGenerating = false
|
||
try {
|
||
backendGenerating = await invoke<boolean>('ai_is_generating', { conversationId: targetConvId || null })
|
||
} catch {
|
||
backendGenerating = false
|
||
}
|
||
|
||
// ── L1:后端 busy → 入队(消息已在本地显示,待后端空闲后自动发送)──
|
||
// 仅用 backendGenerating,不用 state.streaming。streaming 是渲染态,不是后端忙的代理。
|
||
if (backendGenerating) {
|
||
if (state.queue.length >= QUEUE_LIMIT) {
|
||
throw new Error(t('ai.queueFull', { limit: QUEUE_LIMIT }))
|
||
}
|
||
state.queue.push({
|
||
text: text.trim(),
|
||
skill: skill || undefined,
|
||
enqueuedAt: Date.now(),
|
||
parts: parts && parts.length > 0 ? parts : undefined,
|
||
spans: spans && spans.length > 0 ? spans : undefined,
|
||
conversationId: targetConvId,
|
||
})
|
||
return
|
||
}
|
||
|
||
// ── L0:normal 直接发送 ──
|
||
await doSend(text, skill, false, parts, spans, targetConvId)
|
||
}
|
||
|
||
/** 排队是否已超时(任一条超即算,因队首阻塞导致后续全堵;未传 convId 时仅看当前活跃会话队列) */
|
||
export function isQueueTimedOut(convId?: string | null): boolean {
|
||
const targetConv = convId ?? state.activeConversationId
|
||
const q = targetConv ? state.queue.filter(x => x.conversationId === targetConv) : state.queue
|
||
const first = q[0]
|
||
return first !== undefined && (Date.now() - first.enqueuedAt > QUEUE_TIMEOUT_MS)
|
||
}
|
||
|
||
/**
|
||
* 超时时弹 confirm,用户确认后以 force_mode 重发目标会话队首消息;取消则保持排队。
|
||
*
|
||
* force_send 失败时,队首已移除,此处回填到该会话队首(原全局 unshift 在跨会话队列下会错位),
|
||
* 并复位目标会话 streaming / 清看门狗,让 UI 脱卡死态。不自动重试 force(避免循环),交用户决定。
|
||
*/
|
||
export async function tryForceSend(confirmFn: (msg: string) => Promise<boolean>, convId?: string | null): Promise<boolean> {
|
||
const targetConv = convId ?? state.activeConversationId
|
||
const idx = targetConv ? state.queue.findIndex(q => q.conversationId === targetConv) : 0
|
||
const first = idx === -1 ? undefined : state.queue[idx]
|
||
if (!first) return false
|
||
const confirmed = await confirmFn(t('ai.forceSendConfirm'))
|
||
if (!confirmed) return false
|
||
// 从队列移除该会话队首,以 force_mode 发送
|
||
state.queue.splice(idx, 1)
|
||
// 清看门狗:doSend 内 force_send 路径会重设 streaming=true 并重启看门狗,
|
||
// 此处无需前置 false(前置 false 会触发消息列表 watch 清流式块再重建,产生瞬态抖动)
|
||
clearStreamWatchdog(targetConv || undefined)
|
||
try {
|
||
await sendMessage(first.text, first.skill, true, first.parts, first.spans, first.conversationId)
|
||
return true
|
||
} catch (e) {
|
||
// force_send 也失败:回填该会话队首保消息不丢,复位 streaming 让 UI 可继续操作
|
||
const reIdx = targetConv ? state.queue.findIndex(q => q.conversationId === targetConv) : 0
|
||
state.queue.splice(reIdx === -1 ? state.queue.length : reIdx, 0, {
|
||
text: first.text,
|
||
skill: first.skill,
|
||
enqueuedAt: first.enqueuedAt,
|
||
parts: first.parts,
|
||
spans: first.spans,
|
||
conversationId: first.conversationId,
|
||
})
|
||
setStreaming(false, { convId: targetConv ?? state.activeConversationId, reason: 'tryForceSend-fail' })
|
||
clearStreamWatchdog(targetConv || undefined)
|
||
console.error('[AI] tryForceSend force_send 失败,消息已回填队首:', e)
|
||
return false
|
||
}
|
||
}
|
||
|
||
/** 批量审批已拆分到 useAiApproval.ts(fe-arch P0-1) */
|
||
|
||
/**
|
||
* 把「当前活跃会话视图内的队列下标」映射到全局队列真实下标。
|
||
*
|
||
* 队列区在 AiChat.vue 按 activeConversationId 过滤渲染(activeQueue),UI 上的编辑/删除/立即发送
|
||
* 传的是过滤视图内的 idx。此处按当前活跃会话在全局队列中定位真实下标;越界(项已被并发 drain
|
||
* 移除等)返 -1,调用方据此 no-op。
|
||
*/
|
||
function activeQueueRealIndex(idx: number): number {
|
||
const targetConv = state.activeConversationId
|
||
if (!targetConv) return -1
|
||
let seen = -1
|
||
for (let i = 0; i < state.queue.length; i++) {
|
||
if (state.queue[i].conversationId === targetConv) {
|
||
seen++
|
||
if (seen === idx) return i
|
||
}
|
||
}
|
||
return -1
|
||
}
|
||
|
||
/** 取消队列中指定位置的消息(仅当前活跃会话视图内的队列项) */
|
||
function cancelQueued(index: number) {
|
||
const realIdx = activeQueueRealIndex(index)
|
||
if (realIdx === -1) return
|
||
state.queue.splice(realIdx, 1)
|
||
}
|
||
|
||
/** 清空待发送队列(未传 convId 时仅清当前活跃会话;空 active 则全清兜底) */
|
||
function clearQueue(convId?: string | null) {
|
||
const targetConv = convId ?? state.activeConversationId
|
||
if (targetConv) {
|
||
state.queue = state.queue.filter(q => q.conversationId !== targetConv)
|
||
} else {
|
||
state.queue = []
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 编辑队列项的 text(决策只编 text)。
|
||
*
|
||
* 设计:
|
||
* - 只改 text,skill 编辑态不暴露(skill 来自发送时技能联想,编辑态改 skill 复杂化交互无收益)。
|
||
* - 边界:index 越界 / newText 空白 → 直接忽略(no-op),不抛错(UI 不会触发,防御)。
|
||
*/
|
||
function editQueued(index: number, newText: string) {
|
||
const realIdx = activeQueueRealIndex(index)
|
||
if (realIdx === -1) return
|
||
const trimmed = newText.trim()
|
||
if (!trimmed) return
|
||
state.queue[realIdx].text = trimmed
|
||
}
|
||
|
||
/**
|
||
* 队列项立即发送(插队=打断当前+发本条,复用停止链路)。
|
||
*
|
||
* 顺序关键:
|
||
* 1. 先 splice 出该项——必须在 stopChat 之前,否则 stopChat→AiCompleted→drainQueue 续发时
|
||
* 该项还在队列致重复发。
|
||
* 2. 调 stopChat() 打断当前生成(停止后已不清队列,故 splice 步骤前置必要)。
|
||
* 3. 直接 sendMessage(..., forceMode=true) 立即发这条(不等 drainQueue 轮到)。
|
||
* 当前未完成回复已由停止截断保留,无需额外处理。
|
||
*/
|
||
async function sendQueuedNow(index: number) {
|
||
const realIdx = activeQueueRealIndex(index)
|
||
if (realIdx === -1) return
|
||
const spliced = state.queue.splice(realIdx, 1)[0]
|
||
await stopChat()
|
||
// forceMode=true 跳过 L1 预检(后端 generating 判定)直走 force_send:stopChat 仅发出停止信号
|
||
// (置 stop_flag),不等后端 loop 跑到检测点复位 generating,紧接的普通发送会命中 L1 的
|
||
// backendGenerating(后端真值仍 true)→ spliced 被入队而非立即发,与"立即发送"语义不符。
|
||
// force_send 原子复位 generating=false 再发,无竞态窗口;stop_flag 让旧 loop 在下个检测点退出。
|
||
// 显式传 spliced 所属会话 id(当前活跃视图内操作,即当前会话)。
|
||
await sendMessage(spliced.text, spliced.skill, true, spliced.parts, spliced.spans, spliced.conversationId)
|
||
}
|
||
|
||
/** 停止当前生成:本地先复位 streaming,再发停止信号。
|
||
* 不依赖后端 AiCompleted 收尾——审批态看门狗已 clear(useAiEvents),若 AiCompleted 竞态丢失则 streaming 永久 true 卡死,故本地兜底。
|
||
*
|
||
* stop IPC 失败时的兜底。本地已先置 streaming=false,但若 stop 信号未送达后端,
|
||
* 后端会继续生成并发回 delta/Completed 事件,与用户已感知的「已停止」语义冲突;更坏情况是
|
||
* 后端 hang 既不收尾也不发事件(stop_flag 未置位、无 watchdog 兜底则无恢复路径)。
|
||
* 故 stop 失败时不 clearStreamWatchdog,反而重启看门狗:后端若继续生成,活跃 delta 会不断重置它
|
||
* 不误触发;后端若真 hang,130s 后 onStreamTimeout 兜底复位 streaming+清队列+推错误气泡。
|
||
* 同时推一条 stopLoopFailed 错误消息让用户知晓停止未生效(可再点或等待收尾)。
|
||
*/
|
||
async function stopChat() {
|
||
// 本地复位前先回填已流出文本:streaming 翻 false 后流式分块即清,迟到的 AiCompleted
|
||
// 又会因 flushCurrentText 的非流式守卫跳过回填,不在此处 flush 则部分回复随停止丢失。
|
||
flushCurrentText()
|
||
setStreaming(false, { convId: state.activeConversationId, reason: 'stopChat' }) // 本地先复位,不依赖后端 AiCompleted(审批态看门狗已 clear,竞态丢失则卡死)
|
||
// 停止属正常收尾:按目标会话清 per-conv 看门狗与自动续跑计数(计数只放收尾路径清,
|
||
// 不在发消息时清,否则会掩盖单轮内的真实重试次数)
|
||
clearStreamWatchdog(state.activeConversationId || undefined)
|
||
clearResumeAttempts(state.activeConversationId || undefined)
|
||
// 决策 a(逐条续发):不再清队列,保留供 AiCompleted 链式触发 drainQueue 续发。
|
||
// 后端 agentic/mod.rs emit AiCompleted 注释明确「保证前端收事件时后端已可接下一条,队列续发不被拒」;
|
||
// 当前未完成回复已入库(agentic/mod.rs save_conversation)保留为截断回复。
|
||
// I8(2026-10-05):停止≠清队,用户预期常是「全部停下」——队列非空时一次性提示
|
||
// 「停止后 N 条排队消息仍将自动发送」,给用户明确预期(要全停可逐条取消/清队列)。
|
||
const pendingQueued = state.queue.filter(q => q.conversationId === state.activeConversationId).length
|
||
if (pendingQueued > 0) {
|
||
void emit('ai-toast', { msg: t('ai.queueWillContinue', { n: pendingQueued }), type: 'info' })
|
||
}
|
||
try {
|
||
// F-260616-09 B 批4(决策 e):传 activeConversationId,停止仅作用于当前 conv。
|
||
await aiApi.stopChat(state.activeConversationId)
|
||
} catch (e) {
|
||
// stop 信号未送达后端:重启看门狗(后端继续生成时活跃事件会重置它,真 hang 则 130s 后兜底收尾),
|
||
// 避免后端仍在跑却无任何恢复路径(原 clearStreamWatchdog 已把兜底关掉)。
|
||
resetStreamWatchdog(state.activeConversationId || undefined)
|
||
const errMsg = e instanceof Error ? e.message : String(e)
|
||
state.messages.push({
|
||
id: `stop-err-${nextMsgId()}`,
|
||
role: 'assistant',
|
||
content: t('aiChat.stopLoopFailed', { msg: errMsg }),
|
||
isError: true,
|
||
timestamp: Date.now(),
|
||
} as AiMessage)
|
||
console.error('[AI] stopChat IPC 失败,已重启看门狗兜底:', e)
|
||
}
|
||
}
|
||
|
||
export function useAiSend() {
|
||
return {
|
||
sendMessage,
|
||
regenerate,
|
||
editMessage,
|
||
drainQueue,
|
||
cancelQueued,
|
||
clearQueue,
|
||
editQueued, //
|
||
sendQueuedNow, //
|
||
stopChat,
|
||
isQueueTimedOut,
|
||
tryForceSend,
|
||
// F-01 阶段6: 模型 override(主对话专用),顶部下拉读写
|
||
modelOverride,
|
||
// 审批计时器已下沉到 aiShared.ts,此处 re-export 供组件使用
|
||
startApprovalTimer,
|
||
clearApprovalTimer,
|
||
clearAllApprovalTimers,
|
||
}
|
||
}
|
||
|
||
// 模块级监听 ai-drain-queue 事件(由 useAiEvents AiCompleted case 发射)
|
||
// 打破 useAiEvents ↔ useAiSend 循环依赖:events 不再直接 import 调用 drainQueue
|
||
let _drainUnlisten: (() => void) | null = null
|
||
export async function initDrainQueueListener(): Promise<void> {
|
||
if (_drainUnlisten) return
|
||
_drainUnlisten = await listen('ai-drain-queue', (event: any) => {
|
||
drainQueue(event?.payload?.conversationId ?? null)
|
||
})
|
||
}
|