修复: 队列发送失败回填不丢消息+池权重revert+lang解析DRY+注释变量名修正

This commit is contained in:
2026-06-17 03:55:30 +08:00
parent 6b2dd8fb5d
commit a622bdca61
9 changed files with 111 additions and 32 deletions

View File

@@ -318,6 +318,7 @@ async function onPoolToggle(p: AiProviderConfig, enabled: boolean) {
function onPoolWeightChange(p: AiProviderConfig, raw: number) {
// NaN 兜底(清空输入):回退 50,防 IPC 传 NaN 后端 serde u32 失败
const clamped = Math.min(100, Math.max(0, Number.isFinite(raw) ? Math.trunc(raw) : 50))
const prev = p.weight
p.weight = clamped
if (_poolTimer) clearTimeout(_poolTimer)
_poolTimer = setTimeout(async () => {
@@ -325,6 +326,7 @@ function onPoolWeightChange(p: AiProviderConfig, raw: number) {
await aiApi.updateProviderPool(p.id, p.enabled !== false, clamped)
emit('toast', t('settings.toastPoolUpdateOk'), 'info')
} catch (e) {
p.weight = prev // revert
console.error(t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), e)
emit('toast', t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), 'error')
}

View File

@@ -21,7 +21,7 @@ import { ref } from 'vue'
import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai'
import type { ContentPart } from '@/api/types'
import type { ContentPart, AiMessage } from '@/api/types'
import i18n from '@/i18n'
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
import { startListener } from './useAiEvents'
@@ -31,6 +31,19 @@ const t = ((i18n as any).global.t as (k: string, named?: Record<string, unknown>
const appSettings = useAppSettingsStore()
/**
* B-260617-03 DRY: 解析 AI 回复语言。
*
* df-ai-language='auto' → 回落到 df-language;否则用 df-ai-language 指定值。
* doSend/regenerate/editMessage 三处复用(原各内联同一段表达式)。
*/
function resolveLang(): string {
const raw = appSettings.get<string>('df-ai-language', 'auto')
return raw === 'auto'
? appSettings.get<string>('df-language', 'zh-CN')
: raw
}
/// 待发送队列上限(超过抛错提示用户)
const QUEUE_LIMIT = 10
@@ -101,10 +114,7 @@ async function doSend(text: string, skill?: string, force = false, parts?: Conte
resetStreamWatchdog() // 启动流式看门狗,无数据超时兜底
await startListener()
const raw = appSettings.get<string>('df-ai-language', 'auto')
const lang = raw === 'auto'
? appSettings.get<string>('df-language', 'zh-CN')
: raw
const lang = resolveLang()
try {
// force=true 走 force_send IPC(复位残留 generating),否则走正常 send
// F-01 阶段6: 透传 modelOverride(主对话专用,后端兜底校验池内才用)
@@ -159,10 +169,7 @@ async function regenerate() {
resetStreamWatchdog()
await startListener()
const raw = appSettings.get<string>('df-ai-language', 'auto')
const lang = raw === 'auto'
? appSettings.get<string>('df-language', 'zh-CN')
: raw
const lang = resolveLang()
const convId = state.activeConversationId
if (!convId) {
// 无活跃对话:回滚占位,报错
@@ -232,10 +239,7 @@ async function editMessage(newMessage: string) {
resetStreamWatchdog()
await startListener()
const raw = appSettings.get<string>('df-ai-language', 'auto')
const lang = raw === 'auto'
? appSettings.get<string>('df-language', 'zh-CN')
: raw
const lang = resolveLang()
const convId = state.activeConversationId
if (!convId) {
state.streaming = false
@@ -254,11 +258,36 @@ async function editMessage(newMessage: string) {
}
}
/** 取出队首并发送AiCompleted 触发,此时 streaming 已 false */
/**
* 取出队首并发送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 链路。
*/
export function drainQueue() {
if (state.queue.length === 0) return
const next = state.queue.shift()!
void sendMessage(next.text, next.skill, false, next.parts)
void (async () => {
try {
await sendMessage(next.text, next.skill, false, next.parts)
} catch (e) {
state.streaming = false
clearStreamWatchdog()
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)
console.error('[AI] drainQueue 续发失败:', e)
}
})()
}
/**
@@ -314,7 +343,14 @@ export function isQueueTimedOut(): boolean {
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(避免循环),交用户决定下一步。
*/
export async function tryForceSend(confirmFn: (msg: string) => Promise<boolean>): Promise<boolean> {
const first = state.queue[0]
if (!first) return false
@@ -328,8 +364,17 @@ export async function tryForceSend(confirmFn: (msg: string) => Promise<boolean>)
try {
await sendMessage(first.text, first.skill, true, first.parts)
return true
} catch {
// force_send 也失败:消息不回队列(避免循环),让用户看到错误
} catch (e) {
// force_send 也失败:回填队首保消息不丢,复位 streaming 让 UI 可继续操作
state.queue.unshift({
text: first.text,
skill: first.skill,
enqueuedAt: first.enqueuedAt,
parts: first.parts,
})
state.streaming = false
clearStreamWatchdog()
console.error('[AI] tryForceSend force_send 失败,消息已回填队首:', e)
return false
}
}

View File

@@ -36,6 +36,7 @@ export default {
forceSendBtn: 'Force Send',
queueTimedOut: 'Queue timed out',
approvalNotDelivered: 'Approval operation failed to reach the backend: {error}',
queuedSendFailed: 'Failed to send queued message: {error}',
approvalTimeout: '⚰ Approval timed out, automatically rejected: {toolName}',
// AE-2025-07: Agentic loop progress bar copy
// Note: max round / completed tool count are not surfaced by backend this round;

View File

@@ -36,6 +36,7 @@ export default {
forceSendBtn: '强制发送',
queueTimedOut: '排队超时',
approvalNotDelivered: '审批操作未送达后端:{error}',
queuedSendFailed: '队列消息发送失败:{error}',
approvalTimeout: '⚰ 审批超时,已自动拒绝:{toolName}',
// AE-2025-07: Agentic 循环进度条文案
// 注:max 轮次/已完成工具数本轮后端暂不透传,模板条件渲染省略;后端补全后新增 *WithMax key