重构: AI流式断线保文StreamResult三分支+重试对齐决策a1,推进链落df-nodes
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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 往返 <100ms,300ms 窗口足够覆盖竞态。
|
||||
*/
|
||||
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() })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '强制发送',
|
||||
|
||||
@@ -79,6 +79,9 @@ export default {
|
||||
labelAgentMaxIterations: 'Agent 最大轮次',
|
||||
descAgentMaxIterations: 'Agentic 循环(流式→工具→回传)的最大轮次(1-50,默认 10);热改下次发消息生效',
|
||||
|
||||
labelAgentMaxRetries: '流式失败重试次数',
|
||||
descAgentMaxRetries: '流式对话失败时自动重试次数(0-10,默认 3);只重试未输出任何 token 的流前失败,已输出文本的失败直接放弃',
|
||||
|
||||
// ===== 知识库面板 =====
|
||||
panelKnowledge: '📚 知识库',
|
||||
labelAutoExtract: '自动提炼知识',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user