Files
DevFlow/src/composables/ai/useAiSend.ts
T

596 lines
28 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/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)
})
}