重构: AI流式断线保文StreamResult三分支+重试对齐决策a1,推进链落df-nodes

This commit is contained in:
lxy
2026-06-16 19:11:19 +08:00
parent 7d5cd4c89a
commit ba7f35552b
30 changed files with 1325 additions and 283 deletions
+5
View File
@@ -115,6 +115,11 @@ export const aiApi = {
return invoke('ai_set_agent_max_iterations', { value })
},
/** 设置流式对话失败自动重试次数(F-260616-07:只重试流前失败,0=不重试,默认3,上限10) */
setAgentMaxRetries(value: number): Promise<void> {
return invoke('ai_set_agent_max_retries', { value })
},
/** 列出本机 Claude 技能(skills + commands + plugins */
listSkills(): Promise<SkillInfo[]> {
return invoke('ai_list_skills')
+6 -1
View File
@@ -199,7 +199,9 @@ export type AiChatEvent = ({
} | {
type: 'AiApprovalResult'; id: string; approved: boolean
} | {
type: 'AiCompleted'; total_tokens: number; prompt_tokens: number; completion_tokens: number
// UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1: incomplete 标记流中途失败保文(网络中断),
// 前端据此差异化展示(如系统提示「⚠ 响应因网络中断不完整」)。正常完成/停止均为 undefined。
type: 'AiCompleted'; total_tokens: number; prompt_tokens: number; completion_tokens: number; incomplete?: boolean
} | {
type: 'AiError'; error: string; error_type?: AiErrorType
} | {
@@ -209,6 +211,9 @@ export type AiChatEvent = ({
} | {
// F-260616-03: 达 max_iterations 暂停态(后端仍 generating=true),前端展示操作卡询问继续/停止
type: 'AiMaxRoundsReached'
} | {
// F-260616-07: 流式调用自动重试提示(前端可在错误气泡内显示「重试 n/m」)
type: 'AiStreamRetry'; attempt: number; max_attempts: number
}) & {
conversation_id?: string
}
+5 -2
View File
@@ -1180,8 +1180,11 @@ const isViewingGenerating = computed(() =>
function cycleProvider() {
const ps = store.state.providers
if (ps.length < 2) return
const idx = ps.findIndex(p => p.id === store.state.activeProvider)
const next = ps[(idx + 1) % ps.length]
// F-260616-10: activeProvider 为 null 时从 DB 默认(is_default)起算,避免 findIndex=-1 跳到 providers[0]
const startId = store.state.activeProvider || ps.find(p => p.is_default)?.id || null
const idx = startId ? ps.findIndex(p => p.id === startId) : -1
// idx=-1 且无默认 provider 时 fallback 到 0(极端边界,正常路径不会走到)
const next = ps[(Math.max(idx, 0) + 1) % ps.length]
store.setProvider(next.id)
// UX-2025-14: 切换反馈 — toast 提示当前 model + 临时 bar 展示 model 名(2s 后淡出)。
// toast 机制:复用本组件自管 reactive showToast(分离窗口无 App.vue 根 toast)。
+83 -5
View File
@@ -1,13 +1,21 @@
//! AI composables 共享层 — 多 composable 共用的模块级状态与纯函数
//!
//! 存在意义:打破 useAiEvents ↔ useAiStream 之间的循环依赖。
//! - useAiStream.onStreamTimeout 调用 nextMsgId
//! - useAiEvents.handleEvent 调用 useAiStream 的 resetStreamWatchdog/clearStreamWatchdog
//! 把两者互相依赖的 nextMsgId(全局消息 id 自增计数器,无其他依赖的纯函数 + 模块级状态)
//! 下沉到本模块,使 useAiStream 改为依赖 aiShared 而非 useAiEvents,环即消除。
//! 存在意义:打破 composable 间循环依赖。
//! - 原环1(useAiEvents ↔ useAiStream):nextMsgId 下沉到本模块消除
//! - 原环2(useAiEvents ↔ useAiSend):findToolCall + 审批计时器 下沉到本模块消除
//!
//! 下沉范围(严格限定为「无依赖、被多个 composable 共用」的成员):
//! - nextMsgId / _msgCounter:events/stream/send/window 四个 composable 共用同一计数器
//! - findToolCall:events/send 两个 composable 共用的纯查找函数(仅读 state.messages)
//! - 审批计时器(startApprovalTimer/clearApprovalTimer/clearAllApprovalTimers):
//! events 模块触发(start/clear/allClear),send 模块也需导出给组件,故下沉到共享层
import { aiApi } from '@/api'
import { state } from '@/stores/ai'
import i18n from '@/i18n'
import type { AiToolCallInfo } from '@/api/types'
const t = ((i18n as any).global.t as (k: string, named?: Record<string, unknown>) => string).bind((i18n as any).global)
/** 客户端消息自增 id(全局唯一,多个 composable 共用 nextMsgId) */
let _msgCounter = 0
@@ -16,3 +24,73 @@ let _msgCounter = 0
export function nextMsgId(): number {
return ++_msgCounter
}
/**
* id ()
*
* 扫描策略:从尾部反向遍历并提前退出tool_result/approval/completed
* tool_use(agent ),
* 使 O(1) O(n)( O(n²),
* tool_result )
*
* :未引入 Map<id, tc> state.messages newConversation /
* switchConversation / deleteConversation (
* useAiEvents ),"陈旧引用"
*
*/
export function findToolCall(id: string): AiToolCallInfo | undefined {
for (let i = state.messages.length - 1; i >= 0; i--) {
const toolCalls = state.messages[i].toolCalls
if (!toolCalls) continue
// 同一消息内工具调用通常很少,沿用正向扫描;命中即返回
for (let j = 0; j < toolCalls.length; j++) {
if (toolCalls[j].id === id) return toolCalls[j]
}
}
return undefined
}
// ── 审批超时计时器(原 useAiSend,下沉打破 events↔send 循环依赖) ──
/// 审批等待超时阈值(ms) — 用户 5 分钟内不处理则自动拒绝
const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000
/** toolCallId → 审批超时计时器 */
const _approvalTimers = new Map<string, ReturnType<typeof setTimeout>>()
/**
* (幂等: id )
* 到点回调: ai_approve(id, false) + push
*/
export function startApprovalTimer(toolCallId: string, toolName: string): void {
if (_approvalTimers.has(toolCallId)) return
const timer = setTimeout(() => {
_approvalTimers.delete(toolCallId)
aiApi.approve(toolCallId, false).catch(e => {
console.error('[AI] 审批超时自动拒绝 IPC 未送达:', e)
})
state.messages.push({
id: `approval-timeout-${nextMsgId()}`,
role: 'assistant',
content: t('ai.approvalTimeout', { toolName }),
isError: true,
timestamp: Date.now(),
})
}, APPROVAL_TIMEOUT_MS)
_approvalTimers.set(toolCallId, timer)
}
/** 清除单个审批计时器 */
export function clearApprovalTimer(toolCallId: string): void {
const timer = _approvalTimers.get(toolCallId)
if (timer) {
clearTimeout(timer)
_approvalTimers.delete(toolCallId)
}
}
/** 清除全部审批计时器(整流超时收尾/组件卸载时调用) */
export function clearAllApprovalTimers(): void {
for (const timer of _approvalTimers.values()) clearTimeout(timer)
_approvalTimers.clear()
}
+30 -32
View File
@@ -5,8 +5,10 @@
//! - _startPromise: startListener 并发去重(防 onMounted 与 sendMessage 首发竞态重复注册)
//!
//! 耦合:
//! - handleEvent 调 useAiConversations.loadConversations、useAiSend.drainQueue、
//! - handleEvent 调 useAiConversations.loadConversations、
//! useAiStream.{resetStreamWatchdog,clearStreamWatchdog}、本模块 flushCurrentText/findToolCall/notifyConversationChanged
//! - drainQueue 经 ai-drain-queue 事件总线桥接(原直接 import useAiSend 构成循环依赖,已破除)
//! - 审批计时器(startApprovalTimer/clearApprovalTimer/clearAllApprovalTimers)已下沉到 aiShared(同上)
//! - nextMsgId 已下沉到 aiShared(原为本模块导出,useAiStream 亦依赖之构成循环依赖,故抽出)
import { listen, emit } from '@tauri-apps/api/event'
@@ -15,15 +17,15 @@ import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai'
import i18n from '@/i18n'
import { nextMsgId } from './aiShared'
import { nextMsgId, findToolCall, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers } from './aiShared'
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
import { drainQueue, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers } from './useAiSend'
import { loadConversations } from './useAiConversations'
import type { AiChatEvent, AiMessage, AiToolCallInfo } from '@/api/types'
// composable 内非组件上下文(无 setup),用 vue-i18n 全局实例的 t 而非 useI18n()。
// 通过 any 中转规避 vue-i18n 深度 message schema 泛型导致的 TS2589(类型实例化过深)。
const t = ((i18n as any).global.t as (key: string) => string).bind((i18n as any).global)
// CR-30-2: 签名放宽支持插值参数(复用现有 t('ai.xxx', {...}) 模式,如 aiShared.ts:75)。
const t = ((i18n as any).global.t as (key: string, params?: Record<string, unknown>) => string).bind((i18n as any).global)
let _unlistenAiEvent: (() => void) | null = null
let _unlistenConvChanged: (() => void) | null = null
@@ -113,31 +115,6 @@ export function flushCurrentText() {
if (last && last.role === 'assistant') last.content = state.currentText
}
/**
* id ()
*
* 扫描策略:从尾部反向遍历并提前退出tool_result/approval/completed
* tool_use(agent ),
* 使 O(1) O(n)( O(n²),
* tool_result )
*
* :未引入 Map<id, tc> state.messages newConversation /
* switchConversation / deleteConversation (
* useAiEvents ),"陈旧引用"
*
*/
export function findToolCall(id: string): AiToolCallInfo | undefined {
for (let i = state.messages.length - 1; i >= 0; i--) {
const toolCalls = state.messages[i].toolCalls
if (!toolCalls) continue
// 同一消息内工具调用通常很少,沿用正向扫描;命中即返回
for (let j = 0; j < toolCalls.length; j++) {
if (toolCalls[j].id === id) return toolCalls[j]
}
}
return undefined
}
/** token 用量展示开关(读 appSettings,与 Settings.vue 共享 key `df-show-token-usage`) */
function isShowTokenUsage(): boolean {
return appSettings.get<boolean>('df-show-token-usage', false)
@@ -197,6 +174,17 @@ export function handleEvent(event: AiChatEvent) {
// 心跳事件:仅维持看门狗(已在上方 reset),无需额外处理;显式 case 防 switch 穿透
break
case 'AiStreamRetry': {
// CR-30-2 / F-260616-07(d) / 决策 a1: 流前失败重试中——后端 emit AiError(Init Err)
// 后再 emit 此事件。前端更新错误气泡内容为「重试 n/m」,对齐决策"错误气泡内更新"。
// 看门狗已在上方 NO_RESET_WATCHDOG 之外(此事件不在集合内)reset,不卡整流超时。
const lastErr = state.messages[state.messages.length - 1]
if (lastErr && lastErr.isError) {
lastErr.content = t('ai.aiStreamRetry', { attempt: event.attempt, max: event.max_attempts })
}
break
}
case 'AiMaxRoundsReached':
// F-260616-03: 达 max_iterations 暂停态(后端仍 generating=true),前端展示操作卡询问。
// 后端已 save 落库,这里仅翻 pendingMaxRounds=true 驱动 UI 卡片;看门狗已由
@@ -284,6 +272,17 @@ export function handleEvent(event: AiChatEvent) {
state.generatingConvId = null
state.agentRound = 0 // AE-2025-07: agentic 结束,复位轮次(隐藏进度条)
pendingMaxRounds.value = false // F-260616-03: 收尾(停止/续跑后新一轮达 max 才会再 set),清操作卡
// UX-2025-04 / CR-30-2 / 决策 a1: 流中途失败保文——后端 emit AiCompleted(incomplete=true),
// 前端追加系统提示气泡(镜像后端 session.messages 的 system 提示)。
// 注:此系统提示仅前端展示,后端已独立 push 到 session.messages 落库。
if (event.incomplete) {
state.messages.push({
id: `incomplete-${nextMsgId()}`,
role: 'assistant',
content: t('ai.responseIncomplete'),
timestamp: Date.now(),
} as AiMessage)
}
// token 用量记录(开关开时):lastTokenUsage 供当前回复展示,convTokenTotal 累加对话总量
if (isShowTokenUsage()) {
state.lastTokenUsage = {
@@ -304,8 +303,8 @@ export function handleEvent(event: AiChatEvent) {
localStorage.removeItem('df-ai-text')
void loadConversations()
notifyConversationChanged()
// 队列续发:当前完成后自动发下一条(后端 generating 已复位,不会被"正在生成中"拒绝
drainQueue()
// 队列续发:当前完成后自动发下一条(经事件总线桥接,避免 import useAiSend 构成循环依赖
emit('ai-drain-queue', {})
break
}
@@ -379,7 +378,6 @@ export function useAiEvents() {
stopListener,
handleEvent,
flushCurrentText,
findToolCall,
friendlyError,
notifyConversationChanged,
}
+18 -3
View File
@@ -12,6 +12,7 @@ import { watch } from 'vue'
import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai'
import type { AiProviderConfig } from '@/api/types'
const appSettings = useAppSettingsStore()
@@ -47,6 +48,11 @@ export function restoreUiState() {
}
restoreUiState()
// F-260616-10: 启动恢复 activeProvider(对齐 df-ai-active-conv 模式)。
// loadProviders 后还会二次校验 DB is_default(双保险:localStorage 可能与 DB 不同步时以 DB 为准)。
const savedProvider = appSettings.get<string | null>('df-ai-active-provider', null)
if (savedProvider) state.activeProvider = savedProvider
// loadAll() 异步填充缓存后,再次应用 UI 布局(模块级 state,watch 仅触发一次)
watch(() => appSettings.cache['df-ai-ui'], (v) => applyUiState(v as any))
@@ -87,9 +93,16 @@ function toggleMaximize() {
// ── Providers / Skills / 清屏 ──
/** 拉取 provider 列表(用于顶部 bar 展示与切换) */
/** 拉取 provider 列表(用于顶部 bar 展示与切换) + 同步 DB 默认 provider 到 activeProvider */
async function loadProviders() {
state.providers = await aiApi.listProviders()
const list = await aiApi.listProviders()
state.providers = list
// F-260616-10: 重启后 activeProvider=null,从 DB is_default 恢复(后端链路正确,仅前端缺同步)
// CR-34-1: 守卫加有效性检查 — savedProvider 指向已删除 provider 时残留无效 id
if (!state.activeProvider || !list.some((p: AiProviderConfig) => p.id === state.activeProvider)) {
const def = list.find((p: AiProviderConfig) => p.is_default)
if (def) state.activeProvider = def.id
}
}
/** 拉取本机技能列表(用于 `/` 联想,静默失败) */
@@ -101,10 +114,12 @@ async function loadSkills() {
}
}
/** 切换活跃 provider(后端 + 本地 state) */
/** 切换活跃 provider(后端 + 本地 state + 持久化) */
async function setProvider(providerId: string) {
await aiApi.setProvider(providerId)
state.activeProvider = providerId
// F-260616-10: 持久化到 appSettings,重启后恢复(对齐 df-ai-active-conv 模式)
void appSettings.set('df-ai-active-provider', providerId)
}
/** 清空当前会话消息(后端 + 本地 state) */
+29 -58
View File
@@ -6,22 +6,24 @@
//! L2 force: 排队>30s → 弹 confirm → 用户确认后调 ai_chat_force_send 复位残留并强制发出
//!
//! 模块级私有:
//! - 无(队列存在 state.queue 中,看门狗/审批状态机依赖 events 与 stream)
//! - _pendingApprovalIds:审批按钮防抖守卫
//!
//! 耦合:
//! - sendMessage 调 useAiEvents.startListener(useAiEvents 导出但不在解构集中暴露给组件,
//! 故 startListener 同时经 useAiEvents 导出,sendMessage 直接 import 调用)
//! - sendMessage 调 useAiStream.resetStreamWatchdog
//! - drainQueue 在 sendMessage 完成后由 handleEvent(AiCompleted) 调用 — 故 drainQueue 必须为模块级 export
//! - drainQueue 由 handleEvent(AiCompleted) 经 ai-drain-queue 事件总线触发(非直接调用,
//! 打破 events↔send 循环依赖);本模块模块级监听该事件
import { listen } from '@tauri-apps/api/event'
import { invoke } from '@tauri-apps/api/core'
import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai'
import i18n from '@/i18n'
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
import { startListener, findToolCall } from './useAiEvents'
import { nextMsgId } from './aiShared'
import { startListener } from './useAiEvents'
import { nextMsgId, findToolCall, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers } from './aiShared'
const t = ((i18n as any).global.t as (k: string, named?: Record<string, unknown>) => string).bind((i18n as any).global)
@@ -33,61 +35,14 @@ const QUEUE_LIMIT = 10
/// 排队超时阈值(ms) — 超过后弹 confirm 让用户选择"强制发送"或继续等(B-260616-02)
const QUEUE_TIMEOUT_MS = 30_000
/// 审批等待超时阈值(ms) — 用户 5 分钟内不处理则自动拒绝,防 pending_approval 永久卡死对话。
/// 纯前端定时器:审批依赖页面交互,页面关闭/不处理即超时 auto-reject 合理(后端无超时)
/// TODO: 未来接 Settings 可配(df-ai-approval-timeout-ms),当前写死减范围(避免改 Settings.vue god file)
const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000
// 审批计时器(startApprovalTimer/clearApprovalTimer/clearAllApprovalTimers)已下沉到 aiShared.ts
// 打破 useAiEvents ↔ useAiSend 循环依赖。本模块 re-export 供组件使用
/**
* 审批超时计时器:toolCallId timer
* AiApprovalRequired(),AiApprovalResult/AiToolCallCompleted( pending_approval)
* onStreamTimeout()stopListener()
* useAiEvents._toolTimers( toast),(auto-reject),
* running toast toast
* F-260616-06: 审批按钮防抖守卫 id IPC
* IPC <100ms300ms 窗口足够覆盖竞态
*/
const _approvalTimers = new Map<string, ReturnType<typeof setTimeout>>()
/**
* (幂等: id ,沿)
* 到点回调: ai_approve(id, false) + push state.messages
* ( onStreamTimeout push ;composable UI ,toast ,)
* TODO: toast 线( push ,)
*/
export function startApprovalTimer(toolCallId: string, toolName: string): void {
if (_approvalTimers.has(toolCallId)) return
const timer = setTimeout(() => {
_approvalTimers.delete(toolCallId)
// 自动拒绝:调 ai_approve(false) 通知后端不执行该工具
aiApi.approve(toolCallId, false).catch(e => {
// IPC 失败(网络断/后端已关):后端状态已乱,前端仍 push 提示让用户知晓
console.error('[AI] 审批超时自动拒绝 IPC 未送达:', e)
})
// push 系统错误消息(参照 onStreamTimeout push 模式)
state.messages.push({
id: `approval-timeout-${nextMsgId()}`,
role: 'assistant',
content: t('ai.approvalTimeout', { toolName }),
isError: true,
timestamp: Date.now(),
})
}, APPROVAL_TIMEOUT_MS)
_approvalTimers.set(toolCallId, timer)
}
/** 清除单个审批计时器(审批完成/拒绝时调用) */
export function clearApprovalTimer(toolCallId: string): void {
const timer = _approvalTimers.get(toolCallId)
if (timer) {
clearTimeout(timer)
_approvalTimers.delete(toolCallId)
}
}
/** 清除全部审批计时器(onStreamTimeout/stopListener 整流收尾时调用) */
export function clearAllApprovalTimers(): void {
for (const timer of _approvalTimers.values()) clearTimeout(timer)
_approvalTimers.clear()
}
const _pendingApprovalIds = new Set<string>()
/**
* , UI
@@ -348,8 +303,12 @@ export async function tryForceSend(confirmFn: (msg: string) => Promise<boolean>)
* B-260616-08:不再乐观置 running .ai-tool-approval (),
* loading ;loading ToolCard ref, */
async function approveToolCall(toolCallId: string, approved: boolean) {
// 复用 findToolCall(反向扫描)避免重复实现查找逻辑;仅缓存引用用于 IPC 失败回滚
const tc = findToolCall(toolCallId)
// F-260616-06: 防抖——同一 id 仍在处理中则跳过(竞态点击/网络慢重试)
if (_pendingApprovalIds.has(toolCallId)) return
_pendingApprovalIds.add(toolCallId)
try {
// 复用 findToolCall(反向扫描)避免重复实现查找逻辑;仅缓存引用用于 IPC 失败回滚
const tc = findToolCall(toolCallId)
// 重启看门狗覆盖审批执行→续生成窗口(useAiEvents.ts:162 AiApprovalRequired 已 clear,
// 审批态无心跳兜底;approve 后后端要跑工具+续生成,期间任何后端异常不回则按钮永久 loading。
// 复用通用 watchdog 而非加审批专用超时:复用同一收尾路径(onStreamTimeout 兜底复位 streaming),
@@ -370,6 +329,9 @@ async function approveToolCall(toolCallId: string, approved: boolean) {
}
throw e
}
} finally {
_pendingApprovalIds.delete(toolCallId)
}
}
/** 批量审批:遍历 pendingApprovals 逐个调用 approveToolCall */
@@ -420,8 +382,17 @@ export function useAiSend() {
stopChat,
isQueueTimedOut,
tryForceSend,
// 审批计时器已下沉到 aiShared.ts,此处 re-export 供组件使用
startApprovalTimer,
clearApprovalTimer,
clearAllApprovalTimers,
}
}
// ARC-06: 模块级监听 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', () => { drainQueue() })
}
+7
View File
@@ -29,6 +29,13 @@ async function detachPanel() {
localStorage.setItem('df-ai-gen', state.generatingConvId)
localStorage.setItem('df-ai-text', state.currentText)
}
// 主窗口 state 与分离窗口 state 独立(各自 webview 独立 JS context,
// stores/ai.ts 模块级单例仅在同 webview 内共享),清主窗口 state 不影响分离窗口生成态。
// 分离窗口接管生成后,主窗口不应再持生成态残留(防重开面板时 UI 显生成中幽灵/进度条)。
// watchdog 在主窗口 AiChat 卸载(App.vue v-if detached)→ stopListener → clearStreamWatchdog 时已清,
// 此处仅清内存 state 视觉残留;localStorage 快照保留供 resumeInDetached 恢复。
state.streaming = false
state.generatingConvId = null
const convId = state.activeConversationId
const sep = convId ? `?conv=${encodeURIComponent(convId)}` : ''
const url = window.location.origin + window.location.pathname + '#/ai-detached' + sep
+4
View File
@@ -22,8 +22,12 @@ export default {
errorAuth: 'Request failed: the API key is invalid or lacks permission.',
errorTimeout: 'The response timed out. Please try again.',
errorNetwork: 'Network connection failed. Please check your network.',
// CR-30-2 / F-260616-07(d) / decision a1: stream pre-failure retry bubble copy
aiStreamRetry: '⚠ Request failed, retrying ({attempt}/{max})…',
streamInterruptedAfterTool: '⚠ The tool finished, but the following reply was interrupted (no data stream for a long time). You can click Continue to retry.',
streamInterrupted: '⚠ The response was interrupted (no data stream for a long time). This may be due to a network disconnection or a backend error. Please try again.',
// UX-2025-04 / CR-30-2 / decision a1: system notice bubble after mid-stream failure preserve (frontend mirrors backend session.messages)
responseIncomplete: '⚠ The response is incomplete due to a network interruption. The above is the partial content received. You can resend to get a full reply.',
queueFull: 'Send queue is full (max {limit} items)',
forceSendConfirm: 'AI generation appears stuck. Force-stop and send anyway? (This may interrupt the current response)',
forceSendBtn: 'Force Send',
+3
View File
@@ -79,6 +79,9 @@ export default {
labelAgentMaxIterations: 'Agent max iterations',
descAgentMaxIterations: 'Max rounds of the agentic loop (stream → tool → feedback) (1-50, default 10); changes take effect on the next message',
labelAgentMaxRetries: 'Stream failure retries',
descAgentMaxRetries: 'Auto-retry count on stream failure (0-10, default 3); only retries pre-stream failures (no token output yet); mid-stream failures are abandoned',
// ===== Knowledge base panel =====
panelKnowledge: '📚 Knowledge base',
labelAutoExtract: 'Auto-distill knowledge',
+4
View File
@@ -22,8 +22,12 @@ export default {
errorAuth: '调用失败:API Key 无效或无权限',
errorTimeout: '响应超时,请重试',
errorNetwork: '网络连接失败,请检查网络',
// CR-30-2 / F-260616-07(d) / 决策 a1: 流前失败重试中错误气泡内更新文案
aiStreamRetry: '⚠ 调用失败,正在重试({attempt}/{max})…',
streamInterruptedAfterTool: '⚠ 工具已执行完成,后续回复中断(长时间无数据流)。可点继续重试。',
streamInterrupted: '⚠ 响应中断(长时间无数据流)。可能是网络断连或后端异常,请重试。',
// UX-2025-04 / CR-30-2 / 决策 a1: 流中途失败保文后的系统提示气泡(前端镜像后端 session.messages)
responseIncomplete: '⚠ 响应因网络中断不完整,以上为已接收的部分内容。可重新发送以获取完整回复。',
queueFull: '待发送队列已满(最多 {limit} 条)',
forceSendConfirm: 'AI 生成似乎卡住了。是否强制结束当前生成并发送消息?(可能中断正在生成的回复)',
forceSendBtn: '强制发送',
+3
View File
@@ -79,6 +79,9 @@ export default {
labelAgentMaxIterations: 'Agent 最大轮次',
descAgentMaxIterations: 'Agentic 循环(流式→工具→回传)的最大轮次(1-50,默认 10);热改下次发消息生效',
labelAgentMaxRetries: '流式失败重试次数',
descAgentMaxRetries: '流式对话失败时自动重试次数(0-10,默认 3);只重试未输出任何 token 的流前失败,已输出文本的失败直接放弃',
// ===== 知识库面板 =====
panelKnowledge: '📚 知识库',
labelAutoExtract: '自动提炼知识',
+3 -1
View File
@@ -36,7 +36,7 @@ import type { AiChatEvent, AiConversationSummary, AiMessage, AiProviderConfig, A
const MESSAGE_CAP = 200
import { useAiEvents } from '@/composables/ai/useAiEvents'
import { useAiStream } from '@/composables/ai/useAiStream'
import { useAiSend } from '@/composables/ai/useAiSend'
import { useAiSend, initDrainQueueListener } from '@/composables/ai/useAiSend'
import { useAiConversations } from '@/composables/ai/useAiConversations'
import { useAiWindow } from '@/composables/ai/useAiWindow'
import { useAiPanel } from '@/composables/ai/useAiPanel'
@@ -130,6 +130,8 @@ const filteredConversations = computed<AiConversationSummary[] | null>(() => {
* (composable ,,)
*/
export function useAiStore() {
// ARC-06: 启动 drain-queue 事件监听(由 useAiEvents AiCompleted 经事件总线触发)
void initDrainQueueListener()
return {
state,
filteredConversations,
+8 -6
View File
@@ -619,11 +619,11 @@ onMounted(() => {
.detail-section { padding-bottom: 18px; margin-bottom: 18px; border-bottom: 0.5px solid var(--df-border); }
.detail-section:last-child { border-bottom: none; margin-bottom: 0; }
.detail-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 12px; }
.detail-title-row { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; }
.detail-kind-icon { font-size: 20px; }
.detail-title { font-size: 18px; font-weight: 500; color: var(--df-text); word-break: break-word; }
.detail-actions { display: flex; gap: 6px; flex-shrink: 0; }
.detail-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 12px; min-width: 0; }
.detail-title-row { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; overflow: hidden; }
.detail-kind-icon { font-size: 20px; flex-shrink: 0; }
.detail-title { font-size: 18px; font-weight: 500; color: var(--df-text); word-break: break-word; overflow-wrap: break-word; min-width: 0; }
.detail-actions { display: flex; gap: 6px; flex-shrink: 0; flex-wrap: wrap; }
.detail-badges { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; font-size: 11px; }
.badge-plain { color: var(--df-text-dim); }
@@ -725,9 +725,11 @@ onMounted(() => {
.form-textarea { min-height: 80px; resize: vertical; font-family: inherit; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
/* ===== 响应式:窄屏详情标题防竖线化(B-260616-19) ===== */
/* ===== 响应式:窄屏详情标题防竖线化(B-260619) ===== */
@media (max-width: 760px) {
.kn-layout { grid-template-columns: 1fr; }
.detail-head { flex-direction: column; align-items: flex-start; }
.detail-actions { width: 100%; }
.detail-title { font-size: 15px; }
}
</style>
+37
View File
@@ -277,6 +277,17 @@
@change="syncAgentMaxIterations" />
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{{ $t('settings.labelAgentMaxRetries') }}</span>
<span class="setting-desc">{{ $t('settings.descAgentMaxRetries') }}</span>
</div>
<div class="setting-control">
<input type="number" class="setting-select" min="0" max="10" style="width:80px"
v-model.number="settings.agentMaxRetries"
@change="syncAgentMaxRetries" />
</div>
</div>
</div>
</section>
@@ -626,6 +637,8 @@ const settings = reactive({
llmPerConvConcurrency: appSettings.get<number>('df-llm-per-conv-concurrency', 2),
// F-260616-01: Agentic 10 DEFAULT_MAX_AGENT_ITERATIONS
agentMaxIterations: appSettings.get<number>('df-ai-agent-max-iterations', 10),
// F-260616-07: 3 DEFAULT_MAX_AGENT_RETRIES
agentMaxRetries: appSettings.get<number>('df-ai-agent-max-retries', 3),
})
function applyTheme(theme: string) {
@@ -664,6 +677,10 @@ watch(() => settings.agentMaxIterations, (v) => {
void appSettings.set('df-ai-agent-max-iterations', v)
})
watch(() => settings.agentMaxRetries, (v) => {
void appSettings.set('df-ai-agent-max-retries', v)
})
// LLM (debounce 300ms, IPC)
// watch appSettings(SQLite) ;
let _concurrencyTimer: ReturnType<typeof setTimeout> | null = null
@@ -704,6 +721,23 @@ function syncAgentMaxIterations() {
}, 300)
}
// (debounce 300ms)
// F-260616-07: loop load loop
// clamp 0-10 + command clamp 0-10
let _agentRetryTimer: ReturnType<typeof setTimeout> | null = null
function syncAgentMaxRetries() {
// clamp 0-10
settings.agentMaxRetries = Math.min(10, Math.max(0, settings.agentMaxRetries ?? 3))
if (_agentRetryTimer) clearTimeout(_agentRetryTimer)
_agentRetryTimer = setTimeout(async () => {
try {
await aiApi.setAgentMaxRetries(settings.agentMaxRetries)
} catch (e) {
console.error('同步流式重试次数失败:', e)
}
}, 300)
}
watch(() => settings.language, (val) => {
void appSettings.set('df-language', val)
// i18n
@@ -772,12 +806,15 @@ onMounted(() => {
syncConcurrencyConfig()
// F-260616-01: Agentic
syncAgentMaxIterations()
// F-260616-07:
syncAgentMaxRetries()
})
// debounce timer, unmount timer fire reactive(timer FR-C3)
onUnmounted(() => {
if (_concurrencyTimer) clearTimeout(_concurrencyTimer)
if (_agentIterTimer) clearTimeout(_agentIterTimer)
if (_agentRetryTimer) clearTimeout(_agentRetryTimer)
if (_knowledgeSaveTimer) clearTimeout(_knowledgeSaveTimer)
if (_toastTimer) clearTimeout(_toastTimer)
})