@@ -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
}
}