修复: aichat 切换/新建对话缺陷(队列串会话/回切丢消息/审批误终态化/删除复活/切换链路)
This commit is contained in:
+168
-120
@@ -24,7 +24,7 @@ import { t } from '@/i18n/i18n-helpers'
|
||||
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
import { startListener, flushCurrentText } from './useAiEvents'
|
||||
import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang, convStates } from './aiShared'
|
||||
import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang, convStates, setConvCurrentText, setConvStreaming } from './aiShared'
|
||||
|
||||
/// 待发送队列上限(超过抛错提示用户)
|
||||
const QUEUE_LIMIT = 10
|
||||
@@ -60,67 +60,75 @@ const modelOverride = ref<string | null>(null)
|
||||
// ── 内核发送:推送气泡+调 IPC,被 normal/force 两条路径共用 ──
|
||||
|
||||
/**
|
||||
* F-260614-05 Phase 2b: parts 仅影响本地 push 的 user 消息(渲染多模态图)。
|
||||
* 内核发送:推送气泡 + 调 IPC,被 normal/force 两条路径共用。
|
||||
*
|
||||
* 后端 ai_chat_send IPC 当前不接 parts(Phase 2c 接入);本轮 doSend 仅把 parts 写入本地 push 的
|
||||
* user 消息让用户气泡可见图,后端请求仍走 content 文本路径(无图)。
|
||||
* Phase 2c 后端接 parts 后,本函数透传给 aiApi.sendMessage/forceSend 即可全链路生效。
|
||||
*
|
||||
* Input Augmentation: spans 透传给后端 ai_chat_send 的 mention_spans 参数(后端 resolve
|
||||
* 投影成 Augmentation 注入)+ 写入本地 push 的 user 消息(MessageList 据此渲染 chip)。
|
||||
* undefined/空数组 → 本地 user 消息 mentionSpans 置 undefined(纯文本气泡零回归)+ 后端不传。
|
||||
* @param convId 目标会话 id(显式传入优先,缺省当前活跃会话)。后台会话续发(drainQueue)场景
|
||||
* 目标会话 ≠ 当前活跃视图,此时不 push 本地气泡、不碰当前视图流式态,避免污染当前视图。
|
||||
*/
|
||||
async function doSend(text: string, skill?: string, force = false, parts?: ContentPart[], spans?: MentionSpan[]) {
|
||||
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;
|
||||
// 异常时序下可防止上一轮文本被 state.currentText='' 误清)
|
||||
flushCurrentText()
|
||||
// 异常时序下可防止上一轮文本被清空误伤)。仅当前视图会话发送才 flush,后台续发不碰当前视图流式文本。
|
||||
if (isViewTarget) flushCurrentText()
|
||||
|
||||
const userMsgId = `user-${nextMsgId()}`
|
||||
state.messages.push({
|
||||
id: userMsgId as MessageId,
|
||||
role: 'user',
|
||||
content: text.trim(),
|
||||
// 仅在非空时挂 parts(纯文本消息保持 undefined,渲染走原 content 路径零回归)
|
||||
parts: parts && parts.length > 0 ? parts : undefined,
|
||||
// Input Augmentation: 仅在非空时挂 mentionSpans(供 MessageList chip 渲染;空则 undefined 零回归)
|
||||
mentionSpans: spans && spans.length > 0 ? spans : undefined,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
const aiMsgId = `ai-${nextMsgId()}`
|
||||
state.messages.push({
|
||||
id: aiMsgId as MessageId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
setStreaming(true, { convId: state.activeConversationId, reason: 'doSend' })
|
||||
state.currentText = ''
|
||||
resetStreamWatchdog() // 启动流式看门狗,无数据超时兜底
|
||||
if (isViewTarget) {
|
||||
state.messages.push({
|
||||
id: userMsgId as MessageId,
|
||||
role: 'user',
|
||||
content: 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 startListener()
|
||||
const lang = resolveAiLang()
|
||||
try {
|
||||
// force=true 走 force_send IPC(复位残留 generating),否则走正常 send
|
||||
// F-01 阶段6: 透传 modelOverride(主对话专用,后端兜底校验池内才用)
|
||||
const override = modelOverride.value
|
||||
if (force) {
|
||||
// F-05 Phase 2c: 透传 parts(图片)给后端 ai_chat_force_send,多模态全链生效
|
||||
// F-260616-09 B 批4(决策 e):传 activeConversationId,操作指定 conv 的 per_conv。
|
||||
// Input Augmentation: 透传 mentionSpans(spans 非空时后端 resolve_and_inject 注入)。
|
||||
await aiApi.forceSend(text.trim(), lang, skill, override, parts, state.activeConversationId, spans)
|
||||
// 透传 parts(图片) + mentionSpans + 目标会话 id
|
||||
await aiApi.forceSend(text.trim(), lang, skill, override, parts, targetConvId, spans)
|
||||
} else {
|
||||
await aiApi.sendMessage(text.trim(), lang, skill, override, parts, state.activeConversationId, spans)
|
||||
await aiApi.sendMessage(text.trim(), lang, skill, override, parts, targetConvId, spans)
|
||||
}
|
||||
} catch (e) {
|
||||
// IPC 失败(spawn 前/provider 配置错等):回滚 streaming 防光标卡死 + 移除 user 消息与空气泡占位;
|
||||
// 重新抛出由 handleSend 回填输入框,用户可重试
|
||||
// BUG-2026-07-17:彻底清理 convStates,避免按钮永久红色
|
||||
const convId = state.activeConversationId
|
||||
setStreaming(false, { convId, reason: 'doSend-ipc-fail' })
|
||||
clearStreamWatchdog(convId || undefined)
|
||||
if (convId) convStates.delete(convId)
|
||||
state.messages = state.messages.filter(m => m.id !== aiMsgId && m.id !== userMsgId)
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -255,44 +263,51 @@ async function editMessage(newMessage: string) {
|
||||
/**
|
||||
* 取出队首并发送(AiCompleted 触发,此时 streaming 已 false)。
|
||||
*
|
||||
* B-260617-02: 原 void sendMessage(...) fire-and-forget 吞没了 doSend IPC 失败 throw,
|
||||
* 既无 AiCompleted 触发下次 drain(队列永久卡死),也无任何用户反馈。
|
||||
* 现显式 catch:复位 streaming + 推错误气泡(复用 AiError 的 isError 气泡模式) +
|
||||
* 回填失败消息到队首并停 drain(不静默丢用户输入,保留剩余队列供手动重试/编辑/取消)。
|
||||
* 成功路径不受影响——doSend 成功后端会再 emit AiCompleted 续 drain 链路。
|
||||
* 原 fire-and-forget 吞没了 doSend IPC 失败 throw,既无 AiCompleted 触发下次 drain(队列永久
|
||||
* 卡死),也无任何用户反馈。现显式 catch:复位 streaming + 推错误气泡(后台会话不推,避免污染
|
||||
* 当前视图)+ 回填失败消息到该会话队首并停 drain(不静默丢用户输入,保留剩余队列供手动重试/
|
||||
* 编辑/取消)。成功路径不受影响——doSend 成功后端会再 emit AiCompleted 续 drain 链路。
|
||||
*/
|
||||
export function drainQueue(convId?: string | null) {
|
||||
if (state.queue.length === 0) return
|
||||
// 找队首属于目标 conv 的消息;若穿越(非当前 conv 的项堵在队首)则跳过整条队列
|
||||
// 找队首属于目标会话的消息;若穿越(非目标会话的项堵在队首)则跳过整条队列
|
||||
const targetId = convId ?? state.activeConversationId
|
||||
if (!targetId) return
|
||||
const idx = state.queue.findIndex(q => q.conversationId === targetId)
|
||||
if (idx === -1) return
|
||||
const next = state.queue.splice(idx, 1)[0]!
|
||||
const activeConvId = state.activeConversationId
|
||||
void (async () => {
|
||||
try {
|
||||
await sendMessage(next.text, next.skill, false, next.parts)
|
||||
// 用目标会话 id 发送(而非当前活跃会话)——若已切到别的会话,原实现会把 A 的队首消息
|
||||
// 误发到当前活跃会话 B。队列项无 mentionSpans 字段,传 undefined。
|
||||
await sendMessage(next.text, next.skill, false, next.parts, undefined, targetId)
|
||||
} catch (e) {
|
||||
// BUG-2026-07-17: 失败时彻底收尾,带 convId 精准清理 per-conv watchdog + convStates
|
||||
setStreaming(false, { convId: activeConvId, reason: 'drainQueue-fail' })
|
||||
clearStreamWatchdog(activeConvId || undefined)
|
||||
if (activeConvId) convStates.delete(activeConvId)
|
||||
// 失败时彻底收尾:带目标会话 id 精准清理 per-conv 看门狗 + 会话状态
|
||||
setConvStreaming(targetId, false)
|
||||
clearStreamWatchdog(targetId || undefined)
|
||||
if (targetId) convStates.delete(targetId)
|
||||
const errMsg = e instanceof Error ? e.message : String(e)
|
||||
state.queue.unshift(next) // 回填失败消息到队首,保留剩余队列(不静默丢用户输入)
|
||||
state.messages.push({
|
||||
id: `err-${nextMsgId()}`,
|
||||
role: 'assistant',
|
||||
content: t('ai.queuedSendFailed', { error: errMsg }),
|
||||
isError: true,
|
||||
timestamp: Date.now(),
|
||||
} as AiMessage)
|
||||
// 回填失败消息到该会话队首,保留剩余队列(不静默丢用户输入)
|
||||
const reIdx = state.queue.findIndex(q => q.conversationId === targetId)
|
||||
state.queue.splice(reIdx === -1 ? state.queue.length : reIdx, 0, next)
|
||||
// 仅目标会话为当前视图才推错误气泡(后台会话续发失败不污染当前视图;错误消息留在队列
|
||||
// 供切回后编辑/重试/取消)
|
||||
if (targetId === state.activeConversationId) {
|
||||
state.messages.push({
|
||||
id: `err-${nextMsgId()}`,
|
||||
role: 'assistant',
|
||||
content: t('ai.queuedSendFailed', { error: errMsg }),
|
||||
isError: true,
|
||||
timestamp: Date.now(),
|
||||
} as AiMessage)
|
||||
}
|
||||
console.error('[AI] drainQueue 续发失败:', e)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息 — 三级降级(B-260616-02 L2 发送韧性):
|
||||
* 发送消息 — 三级降级(L0 直接发 / L1 入队 / L2 强制):
|
||||
*
|
||||
* L0 normal: 后端 idle → 直接 doSend()
|
||||
* L1 queued: 后端 busy → 入队等 AiCompleted 续发
|
||||
@@ -301,25 +316,27 @@ export function drainQueue(convId?: string | null) {
|
||||
*
|
||||
* forceMode=true 时跳过入队,直接走 ai_chat_force_send。
|
||||
*
|
||||
* @param convId 目标会话 id(显式传入优先,缺省当前活跃会话)。后台会话续发(drainQueue)必须
|
||||
* 显式传目标会话,否则切走后会用当前活跃会话发错消息。
|
||||
*
|
||||
* 注:入队条件仅用 backendGenerating(IPC 查后端真实态),不再检查 state.streaming。
|
||||
* streaming 是渲染态(文本是否在屏幕上输出),不能作为「后端能否接收消息」的代理。
|
||||
* 文本显示完但后端在落库时,streaming 仍 true——此时按钮已白(基于 delta 时间戳),
|
||||
* 用户点击发送,若后端还在忙则入队短暂等待(~1s),AiCompleted 后自动续发。
|
||||
*/
|
||||
async function sendMessage(text: string, skill?: string, forceMode = false, parts?: ContentPart[], spans?: MentionSpan[]) {
|
||||
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)
|
||||
await doSend(text, skill, true, parts, spans, targetConvId)
|
||||
return
|
||||
}
|
||||
|
||||
// B-260615-22(方案 A):发送前 IPC 查后端真实 generating。
|
||||
// F-260616-09 B 批4(决策 e):传 activeConversationId 精确查当前 conv(不读全局单值)。
|
||||
// 发送前 IPC 查后端真实 generating(精确查目标会话,不读全局单值)。
|
||||
let backendGenerating = false
|
||||
try {
|
||||
backendGenerating = await invoke<boolean>('ai_is_generating', { conversationId: state.activeConversationId || null })
|
||||
backendGenerating = await invoke<boolean>('ai_is_generating', { conversationId: targetConvId || null })
|
||||
} catch {
|
||||
backendGenerating = false
|
||||
}
|
||||
@@ -335,51 +352,55 @@ async function sendMessage(text: string, skill?: string, forceMode = false, part
|
||||
skill: skill || undefined,
|
||||
enqueuedAt: Date.now(),
|
||||
parts: parts && parts.length > 0 ? parts : undefined,
|
||||
conversationId: state.activeConversationId,
|
||||
conversationId: targetConvId,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ── L0:normal 直接发送 ──
|
||||
await doSend(text, skill, false, parts, spans)
|
||||
await doSend(text, skill, false, parts, spans, targetConvId)
|
||||
}
|
||||
|
||||
/** 排队是否已超时(任一条超即算,因队首阻塞导致后续全堵) */
|
||||
export function isQueueTimedOut(): boolean {
|
||||
const first = state.queue[0]
|
||||
/** 排队是否已超时(任一条超即算,因队首阻塞导致后续全堵;未传 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 重发队首消息;取消则保持排队。
|
||||
* 超时时弹 confirm,用户确认后以 force_mode 重发目标会话队首消息;取消则保持排队。
|
||||
*
|
||||
* B-260617-04: force_send 失败时,原实现队首已 shift 致消息静默丢失。
|
||||
* 现失败把队首 unshift 回队列(保留 UI 队列可见,用户可改普通发送/编辑/取消),
|
||||
* 并复位 streaming/clearStreamWatchdog(对齐 doSend 失败回填模式)让 UI 脱卡死态。
|
||||
* 不自动重试 force(避免循环),交用户决定下一步。
|
||||
* force_send 失败时,队首已移除,此处回填到该会话队首(原全局 unshift 在跨会话队列下会错位),
|
||||
* 并复位目标会话 streaming / 清看门狗,让 UI 脱卡死态。不自动重试 force(避免循环),交用户决定。
|
||||
*/
|
||||
export async function tryForceSend(confirmFn: (msg: string) => Promise<boolean>): Promise<boolean> {
|
||||
const first = state.queue[0]
|
||||
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.shift()
|
||||
// 清看门狗:doSend 内 force_send 路径会重设 streaming=true 并重启 watchdog,
|
||||
// 此处无需前置 false(前置 false 会触发 AiChat watch 清流式块再重建,产生瞬态抖动)
|
||||
// 从队列移除该会话队首,以 force_mode 发送
|
||||
state.queue.splice(idx, 1)
|
||||
// 清看门狗:doSend 内 force_send 路径会重设 streaming=true 并重启看门狗,
|
||||
// 此处无需前置 false(前置 false 会触发消息列表 watch 清流式块再重建,产生瞬态抖动)
|
||||
clearStreamWatchdog()
|
||||
try {
|
||||
await sendMessage(first.text, first.skill, true, first.parts)
|
||||
await sendMessage(first.text, first.skill, true, first.parts, undefined, first.conversationId)
|
||||
return true
|
||||
} catch (e) {
|
||||
// force_send 也失败:回填队首保消息不丢,复位 streaming 让 UI 可继续操作
|
||||
state.queue.unshift({
|
||||
// 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,
|
||||
conversationId: first.conversationId,
|
||||
})
|
||||
setStreaming(false, { convId: state.activeConversationId, reason: 'tryForceSend-fail' })
|
||||
setStreaming(false, { convId: targetConv ?? state.activeConversationId, reason: 'tryForceSend-fail' })
|
||||
clearStreamWatchdog()
|
||||
console.error('[AI] tryForceSend force_send 失败,消息已回填队首:', e)
|
||||
return false
|
||||
@@ -388,52 +409,79 @@ export async function tryForceSend(confirmFn: (msg: string) => Promise<boolean>)
|
||||
|
||||
/** 批量审批已拆分到 useAiApproval.ts(fe-arch P0-1) */
|
||||
|
||||
/** 取消队列中指定位置的消息 */
|
||||
function cancelQueued(index: number) {
|
||||
state.queue.splice(index, 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 clearQueue() {
|
||||
state.queue = []
|
||||
/** 取消队列中指定位置的消息(仅当前活跃会话视图内的队列项) */
|
||||
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 = []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UX-260616-06: 编辑队列项的 text(决策只编 text)。
|
||||
* 编辑队列项的 text(决策只编 text)。
|
||||
*
|
||||
* 设计:
|
||||
* - 只改 text,skill 编辑态不暴露(skill 来自发送时技能联想,编辑态改 skill 复杂化交互无收益)。
|
||||
* - 边界:index 越界 / newText 空白 → 直接忽略(no-op),不抛错(UI 不会触发,防御)。
|
||||
* - queue item 类型 { text, skill?, enqueuedAt } 不改。
|
||||
*/
|
||||
function editQueued(index: number, newText: string) {
|
||||
if (index < 0 || index >= state.queue.length) return
|
||||
const realIdx = activeQueueRealIndex(index)
|
||||
if (realIdx === -1) return
|
||||
const trimmed = newText.trim()
|
||||
if (!trimmed) return
|
||||
state.queue[index].text = trimmed
|
||||
state.queue[realIdx].text = trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* UX-260616-07: 队列项立即发送(决策 a:插队=打断当前+发本条,复用 UX-05 stop 链路)。
|
||||
* 队列项立即发送(插队=打断当前+发本条,复用停止链路)。
|
||||
*
|
||||
* 流程(顺序关键):
|
||||
* 1. 先 splice 出该项(记下 {text, skill})——必须在 stopChat 之前,
|
||||
* 否则 stopChat→AiCompleted→drainQueue 续发时该项还在队列致重复发。
|
||||
* 2. 调 stopChat() 打断当前生成(stopChat UX-05 后已不清队列,故 splice 步骤前置必要)。
|
||||
* 3. 直接 sendMessage(splicedItem.text, skill) 立即发这条(不等 drainQueue 轮到)。
|
||||
* 当前未完成回复已由 stop 截断保留(agentic/mod.rs save_conversation),无需额外处理。
|
||||
* 顺序关键:
|
||||
* 1. 先 splice 出该项——必须在 stopChat 之前,否则 stopChat→AiCompleted→drainQueue 续发时
|
||||
* 该项还在队列致重复发。
|
||||
* 2. 调 stopChat() 打断当前生成(停止后已不清队列,故 splice 步骤前置必要)。
|
||||
* 3. 直接 sendMessage(..., forceMode=true) 立即发这条(不等 drainQueue 轮到)。
|
||||
* 当前未完成回复已由停止截断保留,无需额外处理。
|
||||
*/
|
||||
async function sendQueuedNow(index: number) {
|
||||
if (index < 0 || index >= state.queue.length) return
|
||||
const spliced = state.queue.splice(index, 1)[0]
|
||||
const realIdx = activeQueueRealIndex(index)
|
||||
if (realIdx === -1) return
|
||||
const spliced = state.queue.splice(realIdx, 1)[0]
|
||||
await stopChat()
|
||||
// B-260617-01: forceMode=true 跳过 L1 预检(ai_is_generating)直走 force_send。
|
||||
// stopChat() 仅 await stop IPC 发出(置 stop_flag=true),不等后端 loop 跑到
|
||||
// agentic/mod.rs guard 检测点+guard.reset()(generating 才复位)。紧接 sendMessage
|
||||
// 命中 L1 的 backendGenerating(后端真值仍 true)→ spliced 被入队而非立即发,
|
||||
// 与"立即发送"语义不符。force_send(commands/chat.rs ai_chat_force_send)原子复位 generating=false
|
||||
// 再发,无竞态窗口;stopChat 的 stop_flag 让旧 loop 在下个检测点退出,不冲突。
|
||||
await sendMessage(spliced.text, spliced.skill, true, spliced.parts)
|
||||
// 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, undefined, spliced.conversationId)
|
||||
}
|
||||
|
||||
/** 停止当前生成:本地先复位 streaming,再发停止信号。
|
||||
|
||||
Reference in New Issue
Block a user