From 65c0f6b2b6e3b644a5e895fa42baf5451f6a234b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sat, 8 Aug 2026 20:38:33 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20aichat=20=E4=BD=93?= =?UTF-8?q?=E9=AA=8C=E6=94=B6=E5=B0=BE(useAiEvents=E6=8B=86=E5=88=864?= =?UTF-8?q?=E5=AD=90=E5=9F=9F=20+=20AIC-FIX=20P1=E9=98=9F=E5=88=97/mutex/?= =?UTF-8?q?=E7=BC=93=E5=AD=98=20+=20i18n=E6=AE=8B=E7=95=99=E6=8A=BD?= =?UTF-8?q?=E5=8F=96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 1 + src/components/ai/ChatInput.vue | 3 +- src/components/ai/DirAuthDialog.vue | 2 +- src/components/ai/HelpRequiredCard.vue | 2 +- src/components/ai/MaxRoundsCard.vue | 3 +- src/components/ai/MessageList.vue | 20 +- src/components/ai/TopBar.vue | 20 +- src/components/project/DependencyGraph.vue | 42 +- src/composables/ai/aiShared.ts | 126 +++ src/composables/ai/useAiConversations.ts | 17 +- src/composables/ai/useAiEvents.ts | 898 ++------------------- src/composables/ai/useAiLifecycleEvents.ts | 222 +++++ src/composables/ai/useAiPendingState.ts | 77 ++ src/composables/ai/useAiSend.ts | 25 +- src/composables/ai/useAiStreamingEvents.ts | 143 ++++ src/composables/ai/useAiToolEvents.ts | 108 +++ src/i18n/en/aiChat.ts | 17 + src/i18n/en/dependencyGraph.ts | 20 + src/i18n/zh-CN/aiChat.ts | 17 + src/i18n/zh-CN/dependencyGraph.ts | 20 + src/stores/ai.ts | 12 +- 21 files changed, 882 insertions(+), 913 deletions(-) create mode 100644 src/composables/ai/useAiLifecycleEvents.ts create mode 100644 src/composables/ai/useAiPendingState.ts create mode 100644 src/composables/ai/useAiStreamingEvents.ts create mode 100644 src/composables/ai/useAiToolEvents.ts diff --git a/Cargo.lock b/Cargo.lock index f40f142..0fbcf09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -953,6 +953,7 @@ dependencies = [ "anyhow", "axum", "df-nodes", + "df-project", "df-storage", "df-types", "futures", diff --git a/src/components/ai/ChatInput.vue b/src/components/ai/ChatInput.vue index 63a0917..197b2f9 100644 --- a/src/components/ai/ChatInput.vue +++ b/src/components/ai/ChatInput.vue @@ -114,7 +114,8 @@ import { ref, computed, nextTick, watch } from 'vue' import { useI18n } from 'vue-i18n' import { useAiStore } from '../../stores/ai' import { useProjectStore } from '../../stores/project' -import { getConvState, textIdle } from '../../composables/ai/useAiEvents' +import { getConvState } from '../../composables/ai/useAiEvents' +import { textIdle } from '../../composables/ai/useAiPendingState' import { extractImageUrlParts } from '../../composables/ai/utils' import { uploadFile } from '../../composables/ai/fileUpload' import { open } from '@tauri-apps/plugin-dialog' diff --git a/src/components/ai/DirAuthDialog.vue b/src/components/ai/DirAuthDialog.vue index b46146e..87102f8 100644 --- a/src/components/ai/DirAuthDialog.vue +++ b/src/components/ai/DirAuthDialog.vue @@ -43,7 +43,7 @@ import { reactive, computed, watch, nextTick, ref } from 'vue' import { useI18n } from 'vue-i18n' import { useAiStore } from '../../stores/ai' -import { pendingDirAuths } from '../../composables/ai/useAiEvents' +import { pendingDirAuths } from '../../composables/ai/useAiPendingState' import { aiApi } from '../../api' const emit = defineEmits<{ diff --git a/src/components/ai/HelpRequiredCard.vue b/src/components/ai/HelpRequiredCard.vue index ebf5dcd..2583d93 100644 --- a/src/components/ai/HelpRequiredCard.vue +++ b/src/components/ai/HelpRequiredCard.vue @@ -25,7 +25,7 @@ import { computed, ref, watch, nextTick } from 'vue' import { useI18n } from 'vue-i18n' import { useAiStore } from '../../stores/ai' -import { pendingHelp } from '../../composables/ai/useAiEvents' +import { pendingHelp } from '../../composables/ai/useAiPendingState' // toast 经 emit 转父(保持单一 toast 源,与 MaxRoundsCard 一致) const emit = defineEmits<{ diff --git a/src/components/ai/MaxRoundsCard.vue b/src/components/ai/MaxRoundsCard.vue index 8587108..db958a5 100644 --- a/src/components/ai/MaxRoundsCard.vue +++ b/src/components/ai/MaxRoundsCard.vue @@ -27,7 +27,8 @@ import { ref, computed, watch, nextTick } from 'vue' import { useI18n } from 'vue-i18n' import { useAiStore } from '../../stores/ai' -import { pendingMaxRounds, getConvState } from '../../composables/ai/useAiEvents' +import { getConvState } from '../../composables/ai/useAiEvents' +import { pendingMaxRounds } from '../../composables/ai/useAiPendingState' import { aiApi } from '../../api' const emit = defineEmits<{ diff --git a/src/components/ai/MessageList.vue b/src/components/ai/MessageList.vue index 6de80d9..000152f 100644 --- a/src/components/ai/MessageList.vue +++ b/src/components/ai/MessageList.vue @@ -901,7 +901,7 @@ defineExpose({ :aria-expanded="tokenPopoverMsgId === item.msg.id" @click="toggleTokenPopover(item.msg, $event)" > - {{ formatTokens(tokenInOf(item.msg)) }} in + {{ formatTokens(tokenInOf(item.msg)) }} in · {{ formatTokens(tokenCacheOf(item.msg)) }} cache · @@ -917,34 +917,34 @@ defineExpose({ class="ai-token-popover" @click.stop > -
Token 用量详情
+
{{ $t('aiChat.tokenDetailTitle') }}
- 输入(未命中,全价) + {{ $t('aiChat.tokenInLabel') }} {{ formatTokens(tokenInOf(item.msg)) }} ({{ tokenInOf(item.msg) }})
- 缓存命中(低价) + {{ $t('aiChat.tokenCacheLabel') }} {{ formatTokens(tokenCacheOf(item.msg)) }} ({{ tokenCacheOf(item.msg) }})
- 输出 + {{ $t('aiChat.tokenOutLabel') }} {{ formatTokens(tokenOutOf(item.msg)) }} ({{ tokenOutOf(item.msg) }})
- 思考(reasoning) + {{ $t('aiChat.tokenReasonLabel') }} {{ formatTokens(tokenReasonOf(item.msg)) }} ({{ tokenReasonOf(item.msg) }})
- 缓存命中率 + {{ $t('aiChat.tokenCacheRateLabel') }} {{ cacheHitRate(item.msg) }}%
- 模型 + {{ $t('aiChat.tokenModelLabel') }} {{ item.msg.model }}
- 用量说明 - prompt_tokens 缺失,按估算展示 + {{ $t('aiChat.tokenUsageNoteLabel') }} + {{ $t('aiChat.tokenUsageNote') }}
diff --git a/src/components/ai/TopBar.vue b/src/components/ai/TopBar.vue index c4e69b8..eb7cc2d 100644 --- a/src/components/ai/TopBar.vue +++ b/src/components/ai/TopBar.vue @@ -172,8 +172,8 @@
{{ $t('aiChat.contextSystemPrompt') }}
- 目标({{ contextInfo.goals.length }}) - {{ contextInfo.goals.join('; ') || '无' }} + {{ $t('aiChat.goalsCount', { n: contextInfo.goals.length }) }} + {{ contextInfo.goals.join('; ') || $t('aiChat.none') }}
{{ $t('aiChat.contextEnvironment') }} @@ -190,8 +190,8 @@
{{ $t('aiChat.contextEnrichment') }}
- @项目 - {{ contextInfo.projectNames || '无' }} + {{ $t('aiChat.projectLabel') }} + {{ contextInfo.projectNames || $t('aiChat.none') }}
@@ -325,9 +325,13 @@ function onModelSelect(e: Event) { store.modelOverride.value = v || null } -// 切换会话默认选中权重最高 model +// 切换会话:仅当手选模型空/对目标模型池无效时才回落权重最高(保留用户手选,不每次重置) watch(() => store.state.activeConversationId, () => { - store.modelOverride.value = enabledModels.value[0]?.model_id || null + const current = store.modelOverride.value + const valid = current && enabledModels.value.some(m => m.model_id === current) + if (!valid) { + store.modelOverride.value = enabledModels.value[0]?.model_id || null + } }) // 切 provider 后模型 override 同步(BUG-2026-07-07:provider 切换后 header 模型名不刷新): @@ -372,7 +376,7 @@ function fmtTime(ts: number): string { const yest = new Date(now) yest.setDate(yest.getDate() - 1) if (d.getFullYear() === yest.getFullYear() && d.getMonth() === yest.getMonth() && d.getDate() === yest.getDate()) { - return `昨天 ${hm}` + return `${t('aiChat.yesterday')} ${hm}` } // 更早 return `${pad(d.getMonth()+1)}-${pad(d.getDate())} ${hm}` @@ -481,7 +485,7 @@ const contextInfo = computed(() => { goals: conv?.pinned_goals ?? [], summaryCount: summaryMsgs.value.length, osInfo: 'Windows 11 / PowerShell', - projectNames: Array.from(projectLabels).join(', ') || '无', + projectNames: Array.from(projectLabels).join(', ') || t('aiChat.none'), } }) diff --git a/src/components/project/DependencyGraph.vue b/src/components/project/DependencyGraph.vue index 9c3bbde..7679e39 100644 --- a/src/components/project/DependencyGraph.vue +++ b/src/components/project/DependencyGraph.vue @@ -3,10 +3,10 @@
{{ $t('dependencyGraph.title') }}
- - - - + + + +
@@ -36,32 +36,32 @@
-

添加工程依赖

+

{{ $t('dependencyGraph.addDepTitle') }}

- +
- +
- +
- - + +
@@ -257,13 +257,13 @@ async function checkCycles() { cycleNodes.value = new Set(cycles) renderGraph() if (cycles.length > 0) { - Message.warning(`检测到 ${cycles.length} 个环节点(已红框高亮)`) + Message.warning(t('dependencyGraph.cycleWarning', { n: cycles.length })) } else { - Message.success('未检测到环形依赖') + Message.success(t('dependencyGraph.noCycle')) } } catch (e) { console.error('[DependencyGraph] 环检测失败:', e) - Message.error('环检测失败') + Message.error(t('dependencyGraph.cycleError')) } } @@ -286,17 +286,17 @@ async function exportPNG() { async function confirmRemoveDep(dep: ModuleDependencyRecord) { const fromName = modules.value.find(m => m.id === dep.from_module_id)?.name ?? dep.from_module_id const toName = modules.value.find(m => m.id === dep.to_module_id)?.name ?? dep.to_module_id - const ok = await confirmDialog(`确定删除「${fromName} → ${toName}」的依赖吗?此操作不可撤销。`, t('common.delete')) + const ok = await confirmDialog(t('dependencyGraph.deleteConfirm', { from: fromName, to: toName }), t('common.delete')) if (!ok) return try { await moduleApi.removeModuleDependency(dep.id) - Message.info('依赖已删除') + Message.info(t('dependencyGraph.deleteSuccess')) await loadModules() renderGraph() bindEdgeDeleteButtons() } catch (e) { console.error('[DependencyGraph] 删除依赖失败:', e) - Message.error('删除依赖失败') + Message.error(t('dependencyGraph.deleteError')) } } diff --git a/src/composables/ai/aiShared.ts b/src/composables/ai/aiShared.ts index fbfa7e8..4722c9a 100644 --- a/src/composables/ai/aiShared.ts +++ b/src/composables/ai/aiShared.ts @@ -11,11 +11,26 @@ //! events 模块触发(start/clear/allClear),send 模块也需导出给组件,故下沉到共享层 import { reactive } from 'vue' +import { emit } from '@tauri-apps/api/event' import { aiApi } from '@/api' import { useAppSettingsStore } from '@/stores/appSettings' import { t } from '@/i18n/i18n-helpers' import type { AiMessage, AiToolCallInfo, ConvState, MessageId } from '@/api/types' +/** 通知会话列表刷新(newConversation/deleteConversation/rename 等触发侧栏更新) */ +export function notifyConversationChanged(): void { + emit('ai-conversation-changed', {}) +} + +/** 后端原始错误转用户友好提示 */ +export function friendlyError(raw: string): string { + if (/404|not\s*found/i.test(raw)) return t('ai.errorNotFound') + if (/401|403|unauthorized|api[_\s-]?key/i.test(raw)) return t('ai.errorAuth') + if (/timeout|超时/i.test(raw)) return t('ai.errorTimeout') + if (/network|connection|ECONN|网络|连接/i.test(raw)) return t('ai.errorNetwork') + return raw +} + /** * 解析 AI 回复语言(useAiSend.sendMessage / useAiContext.compressContext 共用)。 @@ -329,3 +344,114 @@ export function clearConvStreamState(convId: string | null | undefined): void { // // 普通 Set(仅事件处理器读,无渲染追踪需求);生命周期由 switchConversation 的 finally 管理。 export const switchingConvs = new Set() + +// ── 流式文本回填 + delta 去重(useAiEvents 拆分后跨域共享) ── +// +// flushCurrentText 被 streaming(新轮)/lifecycle(收尾)/send(发送前)三域共用,且需写 +// _lastDeltas 去重 Map,故下沉到本共享层。为规避 aiShared ↔ stores/ai 顶层循环引用(TDZ), +// 沿用 __bindMessages 的 getter 注入模式:stores/ai 在 state 就绪后注入 getter, +// 本模块函数运行时惰性取最新 state。 +export interface AiStateShape { + currentText: string + messages: AiMessage[] + activeConversationId: string | null +} +let _stateGetter: (() => AiStateShape) | null = null + +/** @internal 由 stores/ai.ts 在 state 创建后注入 getter(flushCurrentText 惰性读 state) */ +export function __bindState(getter: () => AiStateShape): void { + _stateGetter = getter +} + +function getState(): AiStateShape | null { + return _stateGetter?.() ?? null +} + +/** per-conv delta 去重 Map(convId → 上一次 delta 内容,根治跨会话撞值误丢) */ +const _lastDeltas = new Map() + +function lastDeltaKey(convId: string | null | undefined): string { + return convId || getState()?.activeConversationId || '' +} + +/** 读某会话上一次 delta(去重检查);无记录返回 undefined */ +export function getLastDelta(convId: string | null | undefined): string | undefined { + return _lastDeltas.get(lastDeltaKey(convId)) +} + +/** 记录某会话当前 delta */ +export function setLastDelta(convId: string | null | undefined, delta: string): void { + _lastDeltas.set(lastDeltaKey(convId), delta) +} + +/** 删除某会话的 delta 记录(会话删除/收尾时调用,防 Map 无限增长) */ +export function clearLastDelta(convId: string | null | undefined): void { + _lastDeltas.delete(lastDeltaKey(convId)) +} + +/** + * 把流式累积的 currentText 回填到最后一条 assistant 消息(AiAgentRound/AiCompleted/AiError 收尾共用)。 + * + * 只 guard 空串会漏 whitespace —— LLM 工具调用前常推 `\n`/空格,累积成 whitespace-only 后写进 + * 占位 content 会渲染带边框空气泡,故 trim 兜底空白不回填。回填后立即自清 currentText, + * 消除对调用方清空顺序的依赖(防残留文本渲到新气泡)。 + */ +export function flushCurrentText(): void { + const s = getState() + if (!s) return + const resetDelta = () => clearLastDelta(s.activeConversationId) + if (!s.currentText || !s.currentText.trim()) { + s.currentText = '' + resetDelta() + return + } + // 从末尾向前找最后一个非 isError assistant 气泡写入(跳过重试错误气泡,保留部分回复) + for (let i = s.messages.length - 1; i >= 0; i--) { + const m = s.messages[i] + if (m.role !== 'assistant') break + if (!m.isError) { + m.content = s.currentText + break + } + } + s.currentText = '' + resetDelta() +} + +// ── 工具慢执行计时器(useAiEvents 拆分后跨域共享:tool 域 + lifecycle 收尾共用) ── +// +// 工具执行超时提示(纯前端降级,后端无工具级取消 IPC):每个 running 工具一个独立 setTimeout, +// 到时若仍未收到 Completed/Approval,经 Tauri 事件 ai-tool-slow-toast 弹 warning toast(仅提示一次, +// 不动 running 态——慢工具如 read_file 大文件/run_workflow 长任务不可误杀)。 +const TOOL_SLOW_MS = 30000 +const _toolTimers = new Map>() +const _toolSlowNotified = new Set() + +/** 启动工具慢执行计时器(幂等:同 id 重复 Started 不重建;超时仅弹 toast,不改 status) */ +export function startToolSlowTimer(callId: string, toolName: string): void { + if (_toolTimers.has(callId)) return + const timer = setTimeout(() => { + _toolTimers.delete(callId) + if (_toolSlowNotified.has(callId)) return + _toolSlowNotified.add(callId) + // 经 Tauri 事件总线广播(composable 无组件上下文),AiChat.vue listen 后弹本地 toast + void emit('ai-tool-slow-toast', { name: toolName }) + }, TOOL_SLOW_MS) + _toolTimers.set(callId, timer) +} + +/** 清除单个工具的慢执行计时器(收到 Completed/Approval 时调用) */ +export function clearToolSlowTimer(callId: string): void { + const timer = _toolTimers.get(callId) + if (timer) { + clearTimeout(timer) + _toolTimers.delete(callId) + } +} + +/** 清除全部工具慢执行计时器(stopListener/整流超时收尾时调用) */ +export function clearAllToolSlowTimers(): void { + for (const timer of _toolTimers.values()) clearTimeout(timer) + _toolTimers.clear() + _toolSlowNotified.clear() +} diff --git a/src/composables/ai/useAiConversations.ts b/src/composables/ai/useAiConversations.ts index c695af5..43eb2a6 100644 --- a/src/composables/ai/useAiConversations.ts +++ b/src/composables/ai/useAiConversations.ts @@ -7,10 +7,9 @@ import { aiApi } from '@/api' import { useAppSettingsStore } from '@/stores/appSettings' import { state } from '@/stores/ai' -import { notifyConversationChanged } from './useAiEvents' import { persistUiState } from './useAiPanel' import { setStreaming } from './streamingGuard' -import { nextMsgId, getConvState, clearConvStreamState, startApprovalTimer, clearAllApprovalTimers, switchingConvs, getConvStreamState, setConvCurrentText } from './aiShared' +import { nextMsgId, getConvState, clearConvStreamState, clearLastDelta, startApprovalTimer, clearAllApprovalTimers, switchingConvs, getConvStreamState, setConvCurrentText, notifyConversationChanged } from './aiShared' import { t } from '@/i18n/i18n-helpers' import type { AiConversationDetail, AiMessage, AiToolCallInfo, ConvId } from '@/api/types' @@ -425,14 +424,16 @@ export async function switchConversation(id: string, force = false) { /** 删除会话;若删的是当前活跃会话则清空消息+移除活跃 id 持久化。 * G3.4:收敛进 withConvOp(乐观移除列表 + 失败回滚 + 错误气泡),原裸 await 失败抛 unhandled rejection。 */ async function deleteConversation(id: string) { - // AIC-FIX-17-P0-2:删除会话时清空所有审批计时器(防已删会话的过期 timer 到期误拒审批 + - // 错误气泡推错视图)。 + // 删除会话时清空所有审批计时器(防已删会话的过期 timer 到期误拒审批 + 错误气泡推错视图) clearAllApprovalTimers() const conv = state.conversations.find(c => c.id === id) const prevIndex = conv ? state.conversations.indexOf(conv) : -1 const wasActive = state.activeConversationId === id const prevActive = state.activeConversationId const prevMessages = state.messages + // 删活跃会话前捕获相邻会话(删除后回落,避免空白无引导) + const neighbor = wasActive ? (state.conversations[prevIndex + 1] ?? state.conversations[prevIndex - 1]) : null + const neighborId = neighbor?.id ?? null const ok = await withConvOp( () => { // 乐观:从列表移除 + 若删的是活跃会话则清空视图 @@ -459,18 +460,20 @@ async function deleteConversation(id: string) { ) // 注意:op 返回 void,成功=undefined、失败=null(不能 `!ok`——undefined 也 falsy 会误判失败) if (ok === null) return // 失败已回滚 + 推气泡,保持原视图 - // F-09 per-conv:清该会话的 stream state(streaming/currentText),防 convStreamStates Map 无限增长 - // (与 convStates/待审批等 per-conv 资源同款会话级清理语义)。 + // 清该会话的 stream state 与 delta 去重记录,防 per-conv Map 无限增长 clearConvStreamState(id) + clearLastDelta(id) if (wasActive) { void appSettings.remove('df-ai-active-conv') - // G3.5:删除活跃会话 → load_more 游标复位(无 active 视图) + // 删除活跃会话 → load_more 游标复位(无 active 视图) loadMoreCursor.hasMore = false loadMoreCursor.earliestSeq = null loadMoreCursor.convId = null loadMoreCursor.loading = false } await loadConversations() + // 删的是活跃会话:回落相邻会话作为新活跃视图 + if (neighborId) await switchConversation(neighborId) notifyConversationChanged() } diff --git a/src/composables/ai/useAiEvents.ts b/src/composables/ai/useAiEvents.ts index 80d5bf1..1232777 100644 --- a/src/composables/ai/useAiEvents.ts +++ b/src/composables/ai/useAiEvents.ts @@ -1,633 +1,68 @@ -//! AI 事件监听与分发 — startListener/stopListener/handleEvent 及其辅助函数 +//! AI 事件监听与分发(分派器)— startListener / stopListener / handleEvent //! -//! 模块级私有状态(不进 reactive): -//! - _unlistenAiEvent / _unlistenConvChanged: 已注册的 unlistener -//! - _startPromise: startListener 并发去重(防 onMounted 与 sendMessage 首发竞态重复注册) +//! 拆分(批次 A-B1):14 个事件 case 按域拆到 3 个叶子 handler: +//! - useAiStreamingEvents.handleStreamingEvent(流式) +//! - useAiToolEvents.handleToolEvent(工具) +//! - useAiLifecycleEvents.handleLifecycleEvent(生命周期) +//! 跨域共享的 helper/状态(textIdle/pendingMaxRounds/pendingDirAuths/pendingHelp、工具慢执行 +//! 计时器、delta 去重 Map、flushCurrentText/friendlyError/notifyConversationChanged)已下沉到 +//! aiShared 或 useAiPendingState(叶子)。依赖方向:分派器 → 各叶子 handler → aiShared / 叶子(无环)。 //! -//! handleEvent 结构(fe-arch P0-2 拆分后): -//! - 外围逻辑(convId 同步/路由/看门狗)留在 handleEvent -//! - 14 个 case 按域分派到 3 个 handler: -//! - handleStreamingEvent:AiTextDelta/AiAgentRound/AiHeartbeat/AiStreamRetry/AiMaxRoundsReached -//! - handleToolEvent:AiToolCallStarted/AiToolCallCompleted/AiToolAutoApproved/AiApprovalRequired/AiApprovalResult -//! - handleLifecycleEvent:AiCompleted/AiError -//! 各 handler 共享模块级 helper(state/aiShared/审批计时器/工具慢执行计时器)。 -//! -//! 耦合: -//! - handleEvent 调 useAiConversations.loadConversations、 -//! useAiStream.{resetStreamWatchdog,clearStreamWatchdog}、本模块 flushCurrentText/findToolCall/notifyConversationChanged -//! - drainQueue 经 ai-drain-queue 事件总线桥接(原直接 import useAiSend 构成循环依赖,已破除) -//! - 审批计时器(startApprovalTimer/clearApprovalTimer/clearAllApprovalTimers)已下沉到 aiShared(同上) -//! - nextMsgId 已下沉到 aiShared(原为本模块导出,useAiStream 亦依赖之构成循环依赖,故抽出) +//! handleEvent 保留外围逻辑(convId 同步/路由/看门狗)与 L2 状态机、跨端用户消息两个域内 handler。 +//! useAiEvents() 返回 shape 保持不变(stores/ai.ts spread 形状不变 → 组件零改动)。 import { listen, emit } from '@tauri-apps/api/event' -import { ref } from 'vue' import { aiApi } from '@/api' import { useAppSettingsStore } from '@/stores/appSettings' import { state } from '@/stores/ai' -import { t } from '@/i18n/i18n-helpers' -import { nextMsgId, findToolCall, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, convStates, setConvState, switchingConvs, getConvStreamState, setConvCurrentText, setConvStreaming } from './aiShared' -// 批4 双轨收口:getConvState 下沉到 aiShared,本模块 re-export 保持消费方 -// (ChatInput.vue/MaxRoundsCard.vue 等)import 路径不变,组件零改动透明继承。 -export { getConvState } from './aiShared' -import { resetStreamWatchdog, clearStreamWatchdog, clearAllStreamWatchdogs } from './useAiStream' -import { setStreaming } from './streamingGuard' +import { nextMsgId, setConvState, convStates, switchingConvs, getConvStreamState, setConvCurrentText, setConvStreaming, clearAllApprovalTimers, clearAllToolSlowTimers, flushCurrentText, friendlyError, notifyConversationChanged } from './aiShared' +import { resetStreamWatchdog, clearAllStreamWatchdogs } from './useAiStream' import { loadConversations } from './useAiConversations' -import type { AiChatEvent, AiMessage, AiToolCallInfo, MessageId } from '@/api/types' +import { handleStreamingEvent } from './useAiStreamingEvents' +import { handleToolEvent } from './useAiToolEvents' +import { handleLifecycleEvent } from './useAiLifecycleEvents' +import type { AiChatEvent, MessageId } from '@/api/types' + +// 跨域共享 helper re-export(原为 useAiEvents 导出,拆分后从 aiShared/叶子透传,消费方 import 路径不变) +export { getConvState } from './aiShared' +export { flushCurrentText, friendlyError, notifyConversationChanged } +export { setContextCallbacks } from './useAiLifecycleEvents' +export type { ContextCallbacks } from './useAiLifecycleEvents' let _unlistenAiEvent: (() => void) | null = null let _unlistenConvChanged: (() => void) | null = null -// AE-2025-06: onStreamTimeout 广播的审批计时器清理事件 unlistener +// onStreamTimeout 广播的审批计时器清理事件 unlistener let _unlistenApprovalClear: (() => void) | null = null // startListener 并发去重:防 onMounted 与 sendMessage 首发竞态下重复注册 listener // (两回调写同一 state.currentText → 流式文字双倍) let _startPromise: Promise | null = null -// 上一次 AiTextDelta 的内容(单窗口内 LLM 重复 delta 防御用,见 handleStreamingEvent) -let _lastDelta = '' - -// 文本空闲信号(reactive ref):streaming=true 且 300ms 无新 delta 时=true。 -// 按钮据此显「发送」(idle)而非「停止」。不动 streaming(streaming 是渲染态, -// 控制 MessageList 流式 block 显隐,不能在文本中途翻转,否则闪掉)。 -export const textIdle = ref(true) -let _textIdleTimer: ReturnType | null = null -function resetTextIdleTimer(): void { - if (_textIdleTimer) clearTimeout(_textIdleTimer) - textIdle.value = false // 有新 delta → 文本活跃 - _textIdleTimer = setTimeout(() => { - textIdle.value = true // 300ms 无新 delta → 文本空闲 - _textIdleTimer = null - }, 300) -} -function clearTextIdleTimer(): void { - if (_textIdleTimer) { clearTimeout(_textIdleTimer); _textIdleTimer = null } - textIdle.value = true // 整轮结束/新轮开始 → 默认空闲 -} - const appSettings = useAppSettingsStore() -// B-260616-17: 看门狗不重置的事件集合(审批等待/完成/错误由各自 case 内 clear)。 -// 模块级 Set 复用,避免 handleEvent 每事件(delta/token 高频)新建数组字面量做 includes。 -// F-260616-03: AiMaxRoundsReached 加入——达 max 暂停态等用户决定继续/停止,不计整流超时。 -// F-260619-03 Phase B: AiDirAuthRequired 加入——路径授权挂起等用户决定,不计整流超时。 -// L1 求助协议(§2.3):AiHelpRequired 加入——断路器熔断已 guard.reset 终止 loop,等用户选 option。 +// 看门狗不重置的事件集合(审批等待/完成/错误/达 max/路径授权/求助由各自 case 内 clear)。 +// 模块级 Set 复用,避免每事件(delta/token 高频)新建数组字面量做 includes。 const NO_RESET_WATCHDOG = new Set(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached', 'AiDirAuthRequired', 'AiHelpRequired']) -// B-260616-12: 工具执行超时提示(纯前端降级,后端无工具级取消 IPC)。 -// 每个 running 工具一个独立 setTimeout;到时若仍未收到 Completed/Approval, -// 经 Tauri 事件 ai-tool-slow-toast 通知 AiChat.vue 弹 warning toast(仅提示一次, -// 不动 running 态——慢工具如 read_file 大文件/run_workflow 长任务不可误杀)。 -// 真正取消需后端开 ai_cancel_tool IPC + 工具执行 select 改造,单独立项。 -const TOOL_SLOW_MS = 30000 -// callId → timer;记录所有已挂载的工具级计时器 -const _toolTimers = new Map>() -// 已提示过的 callId(同一工具只弹一次 toast,即便仍 running 到本轮结束) -const _toolSlowNotified = new Set() - -/** 启动工具级慢执行计时器(超时仅弹 toast,不修改 status) */ -function startToolSlowTimer(callId: string, toolName: string): void { - if (_toolTimers.has(callId)) return // 幂等:同 id 重复 Started 不重建 - const timer = setTimeout(() => { - _toolTimers.delete(callId) - if (_toolSlowNotified.has(callId)) return - _toolSlowNotified.add(callId) - // 经 Tauri 事件总线广播(composable 无组件上下文,无法直接调 toast)。 - // AiChat.vue(主窗口与分离窗口各挂一份)listen 后弹本地 toast。 - // toolName 来自后端事件,经 i18n key 查不到翻译时回退原值——这里原样透传, - // 由消费方 i18n 插值展示。 - void emit('ai-tool-slow-toast', { name: toolName }) - }, TOOL_SLOW_MS) - _toolTimers.set(callId, timer) -} - -/** 清除单个工具的慢执行计时器(收到 Completed/Approval 时调用) */ -function clearToolSlowTimer(callId: string): void { - const timer = _toolTimers.get(callId) - if (timer) { - clearTimeout(timer) - _toolTimers.delete(callId) - } -} - -/** 清除全部工具慢执行计时器(stopListener/整流超时收尾时调用) */ -function clearAllToolSlowTimers(): void { - for (const timer of _toolTimers.values()) clearTimeout(timer) - _toolTimers.clear() - _toolSlowNotified.clear() -} - -// F-260616-03: 达 max_iterations 暂停态(后端仍 generating=true)。 -// -// 不进 store.state(批次41 领地,且与 pendingApprovals 同性质是"待用户操作"信号, -// 独立事件源——AiMaxRoundsReached 与 AiApprovalRequired 不混)。用模块级 ref 导出, -// AiChat.vue 直接 import 读;case 内 push,true 表"有挂起询问"。 -// 一次只追踪一个挂起询问(后端 AiSession 单例,达 max 后续轮次前用户必先决定), -// 故用单值 ref 而非数组,语义更精确。 -// -// TD-260621-02 per-conv:原 ref(false)(boolean)无 convId 维度,F-09 多会话并发下 A 达 max -// 挂起时切到 B 会话,MaxRoundsCard 守卫(仅判 isViewingGenerating)会把 A 的操作卡错显于 B。 -// 改 ref(存挂起 convId,null=无挂起)。消费方(MaxRoundsCard)守卫改精确比对 -// pendingMaxRounds === activeConversationId。set 按事件 conversation_id 赋值; -// clear(AiCompleted/AiError)仅当 pendingMaxRounds===该 convId 才清 null(避免清错会话的挂起)。 -export const pendingMaxRounds = ref(null) - -// F-260619-03 Phase B: 路径授权弹窗挂起态(后端 AiDirAuthRequired 事件置,用户决策后清)。 -// -// path_auth 审批链阶段1(解卡死):单 ref → 数组。根因——同轮多个文件工具落不同未授权目录时, -// 后端连发多条 AiDirAuthRequired,单 ref 后到覆盖先到,先到的弹窗丢失→授权无法到达→loop 卡死。 -// 改数组 push 多条并存,各 case(AiApprovalResult/AiCompleted/AiError)按 id filter 移除。 -// 开关 df-ai-multi-pending-auth 控制多挂起并存(true,默认)vs 单 ref 覆盖(false,兜底回退)。 -// 模块级 ref(不进 store.state),AiChat.vue 的 DirAuthDialog 组件直接 import 读。 -export interface PendingDirAuth { - id: string - tool: string - path: string - dir: string - conversationId?: string -} -export const pendingDirAuths = ref([]) - -// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21): -// -// agent 断路器熔断时后端 emit AiHelpRequired(已 guard.reset,generating=false,loop 终止)。 -// 前端据此翻 pendingHelp 驱动求助卡(HelpRequiredCard),显 reason + options 按钮供用户选。 -// 模块级 ref(不进 store.state,与 pendingMaxRounds/pendingDirAuths 同性质是"待用户操作"信号, -// 独立事件源——求助卡不与达 max 操作卡/审批卡混)。TD-260621-02 per-conv:存挂起 convId, -// 消费方(HelpRequiredCard)守卫按 activeConversationId 精确比对(F-09 多会话并发不串台)。 -// 一次只追踪一个挂起求助(后端断路器熔断即 return 终止 loop,用户选 option 前不再发)。 -export interface PendingHelp { - reason: string - context: string - options: string[] - conversationId: string | null -} -export const pendingHelp = ref(null) - -// F-15 上下文管理生命周期事件回调(原 useAiContext 独立监听器,现合入统一分发器)。 -// useAiContext.initContextListener 注入回调,useAiEvents.handleLifecycleEvent 统一分发。 -// 设计:一个事件源(ai-chat-event)→ 一个监听器(useAiEvents)→ 一个分发器(handleEvent), -// 架构保证无双重处理风险(原 useAiContext 第二监听器已删除)。 -export interface ContextCallbacks { - /** 压缩开始(AiCompressing):置 loading=true */ - onCompressing?: () => void - /** 手动压缩成功(AiManualCompressed):置 loading=false + toast + 回填摘要 */ - onManualCompressed?: (conversationId: string, summary: string) => void - /** 自动压缩成功(AiAutoCompressed):静默置 loading=false(不弹 toast) */ - onAutoCompressed?: () => void - /** 上下文已清空(AiContextCleared):toast + 刷新消息列表 */ - onCleared?: (conversationId: string) => void - /** 错误(AiError,含压缩失败):仅在 loading 时置 false + toast */ - onError?: (conversationId: string, message: string) => void -} -let _contextCallbacks: ContextCallbacks = {} - -/** 注入上下文生命周期回调(useAiContext.initContextListener 调用) */ -export function setContextCallbacks(callbacks: ContextCallbacks): void { - _contextCallbacks = callbacks -} - -// L2 统一状态机 convStates/getConvState/setConvState 已下沉到 aiShared.ts(批4 双轨收口破环: -// stores/ai.ts、useAiStream.ts、useAiConversations.ts 等读点 import getConvState 会与 -// useAiEvents 现有依赖构成环,故下沉到本模块既有的破环共享层)。本模块从 aiShared re-import。 -// 批4 收口后:convStates 是「会话是否生成中」的唯一真相源,旧 generatingConvs bool 轨已退役, -// 不再有双轨同步逻辑(原 handleConvStateEvent 内 idle/error delete、generating/stopping/compressed -// add 的兜底分支已删,enum 单写 setConvState 即收敛)。 - -/** 通知会话列表发生变化(供 newConversation/deleteConversation/rename/archive 等触发刷新侧栏) */ -export function notifyConversationChanged() { - emit('ai-conversation-changed', {}) -} - -/** 后端原始错误转用户友好提示 */ -export function friendlyError(raw: string): string { - if (/404|not\s*found/i.test(raw)) return t('ai.errorNotFound') - if (/401|403|unauthorized|api[_\s-]?key/i.test(raw)) return t('ai.errorAuth') - if (/timeout|超时/i.test(raw)) return t('ai.errorTimeout') - if (/network|connection|ECONN|网络|连接/i.test(raw)) return t('ai.errorNetwork') - return raw -} - -/** 把流式累积的 currentText 回填到最后一条 assistant 消息(AiAgentRound/AiCompleted/AiError 收尾共用) */ -export function flushCurrentText() { - // 根因修复(空气泡):只 guard 空串会漏 whitespace —— LLM 工具调用前常推 `\n`/空格, - // 累积成 whitespace-only currentText 后写进占位 content,前端 !content 真值判断漏过 - // → 渲染带边框空气泡。trim 兜底空白,空白流式文本不回填(无实质内容)。 - if (!state.currentText || !state.currentText.trim()) { - state.currentText = '' - _lastDelta = '' // 同步复位 delta 跟踪(防下一轮首个 delta 误判重复) - return - } - // 从末尾向前找最后一个非 isError assistant 气泡写入。跳过 AiStreamRetry 错误气泡 - // (isError),写入其前的占位 assistant,保留部分回复(UX-260619-06 MED-1)。 - for (let i = state.messages.length - 1; i >= 0; i--) { - const m = state.messages[i] - if (m.role !== 'assistant') break // 遇 user/tool 停,占位不在其后 - if (!m.isError) { - m.content = state.currentText - break - } - } - // BUG-260624-01(消息重叠根治·诊断 workflow 确认):回填后立即自清 currentText。 - // 原:清空依赖调用方(AiAgentRound/AiCompleted/AiError/AiHelpRequired 各跟一行 currentText='')。 - // 竞态:任一新增 flush 调用点漏清,或事件乱序致渲染先于调用方清空,全局单例 currentText - // 残留 → MessageList 渲染侧 isLastAi(msg)&¤tText 把残留文本渲到新气泡 → 重叠堆叠。 - // 自清把 flush 语义收敛为"回填并归零",消除对调用方清空顺序的依赖。各调用方后续的 - // currentText='' 对已清空值幂等,无副作用。 - state.currentText = '' - _lastDelta = '' // 同步重置 delta 跟踪,避免新轮首个 delta 误命中上一轮末 delta 重复检测 -} - -/** token 用量展示开关(读 appSettings,与 Settings.vue 共享 key `df-show-token-usage`) */ -function isShowTokenUsage(): boolean { - return appSettings.get('df-show-token-usage', false) -} - -/** path_auth 多挂起并行开关(appSettings key `df-ai-multi-pending-auth`)。 - * path_auth 审批链阶段1:解卡死核心——同轮多个文件工具落不同未授权目录时,后端会连发多条 - * AiDirAuthRequired。单 ref 会被后到的事件覆盖,先到的弹窗丢失→授权无法到达→loop 卡死。 - * 开(true,默认):pendingDirAuths 数组,push 多条并存,按 id 定位消费。 - * 关(false,兜底回退):退化单 ref 语义——后到覆盖先到(push 前清空数组),行为对齐旧版。 - * 回退路径:appSettings.set('df-ai-multi-pending-auth', false) 即恢复单 ref 行为,无需改代码。 */ -function isMultiPendingAuth(): boolean { - return appSettings.get('df-ai-multi-pending-auth', true) -} - -/** - * path_auth 审批链阶段3b(统一审批模型)开关(appSettings key `df-ai-unified-approval`)。 - * - * 决策(plan 阶段3b):状态层合(单 pendingApprovals + kind 字段)+ 决策层分(path 走 authorizeDir / - * risk 走 approve)。开关控制 path 类挂起是否归一进 pendingApprovals 带 kind='path' 字段, - * 由 ToolCard 内联审批(once/always/deny);关时回退 DirAuthDialog 老链路(独立弹窗)。 - * - * 开(true,默认):AiDirAuthRequired case 把 path 挂起转成 pendingApprovals 项(kind='path'), - * 不再 push pendingDirAuths;DirAuthDialog 在 AiChat 中按开关判断不挂载。ToolCard 按 kind - * 显 once/always/deny 三按钮,调 aiApi.authorizeDir。 - * 关(false,兜底回退):退回阶段1老链路——AiDirAuthRequired push pendingDirAuths,DirAuthDialog - * 渲染弹窗,与阶段3a 后端合(单 HashMap + kind 字段)兼容(后端语义层已合,前端 UI 不切)。 - * 回退路径:appSettings.set('df-ai-unified-approval', false) 即恢复 DirAuthDialog 弹窗,无需改代码。 - */ -function isUnifiedApproval(): boolean { - return appSettings.get('df-ai-unified-approval', true) -} - -/** 向 pendingDirAuths 追加一条(开关控制:多挂起并存 vs 单 ref 覆盖) */ -function pushPendingDirAuth(p: PendingDirAuth): void { - if (isMultiPendingAuth()) { - pendingDirAuths.value.push(p) - } else { - // 兜底单 ref 语义:后到覆盖先到(行为对齐旧版,数组只留最新一条) - pendingDirAuths.value = [p] - } -} - -/** 按 id 从 pendingDirAuths 移除一条 */ -function removePendingDirAuth(id: string): void { - pendingDirAuths.value = pendingDirAuths.value.filter(p => p.id !== id) -} - -// ─── 事件域 handler(从原 handleEvent switch 抽出,按域分组)──────────────────────────── -// -// 拆分策略:策略 C(抽 case 体为函数)+ 按域分组(streaming/tool/lifecycle)。 -// handleEvent 保留外围逻辑(convId 同步/路由/看门狗),switch 改为按 event.type 分派到 -// 对应域 handler。各 handler 共享模块级 helper(state/aiShared/审批计时器/工具慢执行计时器)。 -// 零行为变更:逻辑等价搬运,case 体一字未改。 -// -// 域分组: -// - streaming 流式:AiTextDelta / AiAgentRound / AiHeartbeat / AiStreamRetry / AiMaxRoundsReached -// - tool 工具:AiToolCallStarted / AiToolCallCompleted / AiToolAutoApproved / AiApprovalRequired / AiApprovalResult -// - lifecycle 生命周期:AiCompleted / AiError -// -// 返回值:true=已处理(命中域内 case);false=未命中(供 handleEvent 兜底/未来扩展) -// 例外:AiMaxRoundsReached 在 streaming 域(语义属流式中断信号),但 lifecycle 域收尾时 -// 需置 pendingMaxRounds=false —— 该 ref 为模块级,跨域共享无碍。 - -/** streaming 域:流式文本累积/新轮/心跳/重试/max 轮暂停 */ -function handleStreamingEvent(event: AiChatEvent): boolean { - switch (event.type) { - case 'AiTextDelta': { - // BUG-260624-01 / UX-260617-12 消息重叠防御: - // 历史多次报「LLM 回复文字重复」(如「好好,,现在现在我对我对」单字双重叠加)。 - // 已排除的根因:后端 stream_recv.rs emit 增量 chunk.delta(非累积)、 - // startListener 幂等+_startPromise 并发去重、streamingBlocks 块级 memo。 - // 最可能残留路径:① 主窗口 AI 面板与 ai-detached 分离窗口同时打开,两 webview 独立 JS 上下文 - // 各自 listen 全局广播事件 → 各自 currentText += delta(用户感知「双倍」)。 - // 桌面双窗口场景下难以在单窗口内根治,需后端定向 emit_to(label) 重构。 - // ② LLM provider(GLM anthropic_compat 等)在特定情况下返重复 delta。 - // 防御:连续两次 delta 完全相同(且非空)时丢弃第二次 + console.warn,留下诊断证据。 - // 副作用极低:正常 LLM 流不会连发完全相同 delta(空格/单字符除外,已 len>1 守卫)。 - if (event.delta && _lastDelta === event.delta && event.delta.length > 1) { - // df-ai-trace-delta 开关控制(默认开,生产可关) - if (appSettings.get('df-ai-trace-delta', true)) { - console.warn('[AiTextDelta] 重复 delta 已丢弃(疑似双窗口 listener 或 provider 异常):', JSON.stringify(event.delta)) - } - return true - } - _lastDelta = event.delta - state.currentText += event.delta - // 重置文本空闲定时器:每次 delta 重置 300ms。无新 delta 到 300ms → textIdle=true → 按钮白。 - resetTextIdleTimer() - return true - } - - case 'AiAgentRound': { - // Agent 循环新一轮:保存当前文本到上一条 assistant 消息,新建空 assistant 消息 - flushCurrentText() - state.currentText = '' - // 文本已刷新完毕 → 清空闲定时器(按钮白,等下一轮 deltas 来再红) - // 不设 streaming=false:streaming 管渲染,多轮间需持续 true 让 MessageList 渲染后续 deltas。 - // 按钮白由 textIdle 独立控制,无需翻转 streaming。 - clearTextIdleTimer() - state.completedTools = 0 // 新轮重置工具计数器 - state.messages.push({ - id: `ai-${nextMsgId()}` as MessageId, - role: 'assistant', - content: '', - timestamp: Date.now(), - }) - // AE-2025-07: 记录当前轮次供进度条展示。 - // event.round>0 = run_agentic_loop 内 iteration+1(第几轮工具→LLM 循环); - // event.round==0 = try_continue_agent_loop 审批通过后"隔开新一轮"的占位事件, - // 此时实际轮次尚未推进(run_agentic_loop 入口 iteration=0),不覆盖,避免审批通过瞬间 - // 进度条 2→0 闪烁;下一轮真正的 round>0 事件到达时再更新。 - if (event.round > 0) state.agentRound = event.round - return true - } - - case 'AiHeartbeat': - // 心跳事件:仅维持看门狗(已在上方 reset),无需额外处理;显式 case 防 switch 穿透 - return true - - case 'AiStreamRetry': { - // UX-260618-15: 流前失败重试中——后端 emit AiStreamRetry 携带 attempt/max_attempts。 - // 方案 A 根治 N+1 气泡:后端重试过程不再先 emit AiError(emit 权交 agentic 重试耗尽/Fatal 时 - // 统一发最终错误),故首次 AiStreamRetry 到达时末条不是 isError,需创建新错误气泡; - // 后续 AiStreamRetry 到达时末条已是 isError,仅更新 content(单气泡聚合)。 - // 看门狗已在上方 NO_RESET_WATCHDOG 之外(此事件不在集合内)reset,不卡整流超时。 - const lastMsg = state.messages[state.messages.length - 1] - const retryText = t('ai.aiStreamRetry', { attempt: event.attempt, max: event.max_attempts }) - if (lastMsg && lastMsg.isError) { - lastMsg.content = retryText - } else { - state.messages.push({ - id: `err-${nextMsgId()}` as MessageId, - role: 'assistant', - content: retryText, - isError: true, - timestamp: Date.now(), - } as AiMessage) - } - return true - } - - case 'AiMaxRoundsReached': { - // F-260616-03: 达 max_iterations 暂停态(后端仍 generating=true),前端展示操作卡询问。 - // 后端已 save 落库,这里仅翻 pendingMaxRounds 驱动 UI 卡片;看门狗已由 - // NO_RESET_WATCHDOG 跳过 reset(达 max 不计整流超时)。 - // BUG-260623-06: NO_RESET 只跳 reset 不 clear,达 max 前活跃事件(delta/tool)设的 timer 仍走, - // 130s 后触发 onStreamTimeout → generating=true(后端保持)+ tool completed → 误报 - // "工具已执行完成,后续回复中断"(实测用户报"对话完成后等一会弹出")。 - // 修:对齐 AiApprovalRequired/AiDirAuthRequired(等用户场景均 clear),达 max 也 clear watchdog。 - // 用户点继续(ai_continue_loop→后端续跑→delta reset)/停止(ai_stop_loop→AiCompleted clear)时重计。 - clearStreamWatchdog(event.conversation_id || undefined) - // TD-260621-02 per-conv:存挂起 convId(非 boolean),消费方按 activeConversationId 精确比对。 - // 注:无 convId 时**不**设 null(=无挂起,达 max 操作卡丢失)——保留原值兜底。 - // 用 activeConversationId 兜底:达 max 事件理论上必带 convId,无时默认属当前活跃会话, - // 优于清空挂起导致 MaxRoundsCard 守卫误判无挂起(用户达 max 操作卡凭空消失)。 - pendingMaxRounds.value = event.conversation_id || state.activeConversationId || pendingMaxRounds.value - return true - } - - case 'AiDirAuthRequired': { - // F-260619-03 Phase B: 路径授权挂起(后端 generating 保持 true,等用户决策)。 - // push 到 pendingDirAuths 驱动 DirAuthDialog 弹窗;看门狗已由 NO_RESET_WATCHDOG 跳过 reset。 - // F-260620 根治(错调 ai_approve 致卡死):path_auth 挂起**不**置工具卡 pending_approval—— - // 工具卡 pending_approval 态有审批按钮调 ai_approve(为 RiskLevel 审批设计),path_auth 错调 - // ai_approve 会被后端拦(path_auth 回滚+Err)→ 前端转圈卡死(authz-debug.log 铁证:同 tool_call_id - // ai_approve+ai_authorize_dir 双调)。path_auth 只走 DirAuthDialog(ai_authorize_dir), - // 工具卡保持 running(等授权结果,授权后 AiToolCallCompleted 转 completed)。 - // - // path_auth 审批链阶段3b(统一审批模型):开关 df-ai-unified-approval 开时,path 挂起归一进 - // pendingApprovals 带 kind='path',由 ToolCard 内联显 once/always/deny 三按钮(调 authorizeDir), - // 消除独立 DirAuthDialog 弹窗(单真相源:状态层合)。开关关时回退阶段1老链路(push pendingDirAuths, - // DirAuthDialog 渲染)。两路共存,兜底可随时回退。 - clearStreamWatchdog(event.conversation_id || undefined) - clearToolSlowTimer(event.id) - // 开关开:归一进 pendingApprovals(kind='path'),驱动 ToolCard 内联审批。 - // 同 id 幂等:GLM anthropic_compat id 不稳或重发时,已有同 id 不重复 push(防残留重复卡)。 - if (isUnifiedApproval()) { - if (!state.pendingApprovals.some(p => p.id === event.id)) { - state.pendingApprovals.push({ - id: event.id, - name: event.tool, - args: { path: event.path }, - status: 'pending_approval', - kind: 'path', - dir: event.dir, - path: event.path, - // A2-B10 conv-scoped:挂起项归属会话 id,cleanup 按此仅清本会话审批(不连累并发会话)。 - conversationId: event.conversation_id ?? state.activeConversationId ?? undefined, - // path 类审批提示复用 aiChat.dirAuthHint(已存在 i18n,tool+path 文案); - // 不新增 key 避免 i18n 缺失 prod runtime 报错(memory: i18n-message-compile-blindspot)。 - reason: t('aiChat.dirAuthHint', { tool: event.tool, path: event.path }), - }) - // 同步改对应工具卡的 tc(让 ToolCard 显 path 类审批按钮 once/always/deny + status-dot pending 色)。 - // F-260620 注释"工具卡保持 running"是为防 path_auth 错调 ai_approve 卡死——统一模式后 path 类 - // 调 authorizeDir 不再错调,故可安全进 pending_approval(与 risk 类 AiApprovalRequired 同款流转)。 - // 开关关(兜底)时仍保持老语义(tc 不动,走 DirAuthDialog)。 - const tc = findToolCall(event.id) - if (tc) { - tc.status = 'pending_approval' - tc.kind = 'path' - tc.dir = event.dir - tc.path = event.path - tc.reason = t('aiChat.dirAuthHint', { tool: event.tool, path: event.path }) - } - // path 类挂起同样启动审批超时计时器(5min 不处理自动拒),与 risk 类一致。 - // 传 kind='path' → 到点回调调 authorizeDir(id,'deny')(非 ai_approve,避免后端 kind==Risk 校验拒卡死)。 - startApprovalTimer(event.id, event.tool, 'path') - } - // BUG-260624-02(授权弹窗卡死根治·诊断 workflow high 置信):path 类审批卡归一进 ToolCard - // 内联后,可能落入同名工具≥2 的折叠分组(ToolCardList group-hidden display:none)对用户 - // 不可见 → 5min 超时(aiShared APPROVAL_TIMEOUT_MS)静默 authorizeDir('deny')→ 误显 - // "用户拒绝" → 用户全程未见审批入口即卡死。通知 ToolCardList 自动展开折叠组 + scroll。 - void emit('ai-pending-arrived', { toolCallId: event.id }) - return true - } - // 开关关(兜底回退):push 到 pendingDirAuths 驱动 DirAuthDialog 弹窗。 - // pushPendingDirAuth 据开关(df-ai-multi-pending-auth)决定多挂起并存(默认)还是单 ref 覆盖兜底。 - if (!pendingDirAuths.value.some(p => p.id === event.id)) { - pushPendingDirAuth({ - id: event.id, - tool: event.tool, - path: event.path, - dir: event.dir, - conversationId: event.conversation_id, - }) - } - return true - } - - default: - return false - } -} - -/** tool 域:工具卡片状态流转/会话级信任/审批 */ -function handleToolEvent(event: AiChatEvent): boolean { - switch (event.type) { - case 'AiToolCallStarted': { - const info: AiToolCallInfo = { - id: event.id, - name: event.name, - args: event.args, - status: 'running', - } - // B-260616-21: 同 tool_call_id 重复 emit Started 时(GLM anthropic_compat id 不稳), - // 仅挂计时器不重复 push(对齐 startToolSlowTimer 守卫风格),防残留 running 空卡(0 行·N KB)。 - // 详 docs/02-架构设计/已编号方案/B-260616-21排查方案-2026-06-16.md 方案①治标(后端治本待取证)。 - if (!findToolCall(event.id)) { - const lastMsg = state.messages[state.messages.length - 1] - if (lastMsg && lastMsg.role === 'assistant') { - lastMsg.toolCalls = lastMsg.toolCalls || [] - lastMsg.toolCalls.push(info) - } - } - // B-260616-12: 工具开始执行即挂慢执行计时器(超时仅 toast,不动 running 态) - startToolSlowTimer(event.id, event.name) - return true - } - - case 'AiToolCallCompleted': { - const tc = findToolCall(event.id) - if (tc) { - tc.status = 'completed' - tc.result = event.result - } - state.completedTools++ // 进度条计数器 - state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id) - clearToolSlowTimer(event.id) - // AE-2025-06: 工具结束(无论审批通过后执行还是被拒),清审批超时计时器 - clearApprovalTimer(event.id) - return true - } - - case 'AiToolAutoApproved': { - // AE-2025-04 会话级信任:不写消息/不动 pending(Started/Completed 仍独立发), - // 仅经事件总线桥接到 AiChat.vue 弹本地 toast(composable 无组件上下文,与 - // ai-tool-slow-toast 同款中转模式)。主窗口与分离窗口各挂一份 AiChat,各自消费。 - void emit('ai-tool-auto-approved-toast', { tool: event.tool, dir: event.dir }) - return true - } - - case 'AiApprovalRequired': { - clearStreamWatchdog(event.conversation_id || undefined) // 等用户审批,不计超时 - // path_auth 审批链阶段3b:event.kind 缺省默认 'risk'(后端阶段3a wire 未加 kind 字段, - // 老后端不传即 risk 类)。event.kind 为 'path' 时理论上 path 挂起也走此事件(后端未来可统一), - // 当前阶段3a 后端 path 挂起仍走独立 AiDirAuthRequired 事件,故此处 kind 恒 'risk'。 - const info: AiToolCallInfo = { - id: event.id, - name: event.name, - args: event.args, - status: 'pending_approval', - kind: event.kind ?? 'risk', - // BUG-260624-03(根因3):reason 必须写入 info,否则浮窗 ApprovalPopup.vue - // 的 tc.reason 永远空(后端 build_approval_reason 生成的风险说明丢失)。 - // tc.reason 与 pendingApprovals[].reason 是两条独立赋值路径:tc 走 findToolCall, - // 浮窗走主窗口推送的 pendingApprovals 快照,后者此前漏写 reason。 - reason: event.reason, - // A2-B10 conv-scoped:挂起项归属会话 id,cleanup 按此仅清本会话审批(不连累并发会话)。 - // 事件必带 conversation_id(缺省兜底当前活跃会话——AiApprovalRequired 仅当 isCurrent 才到达此处)。 - conversationId: event.conversation_id ?? state.activeConversationId ?? undefined, - } - state.pendingApprovals.push(info) - const tc = findToolCall(event.id) - if (tc) { - tc.status = 'pending_approval' - tc.reason = event.reason - // AE-2025-03: write_file 审批注入行级 diff(旧文件 vs 新内容),前端审批卡预览 - if (event.diff) tc.diff = event.diff - } - // B-260616-12: 进入审批等待→取消该工具慢执行计时器(审批耗时由用户主导,非执行慢) - clearToolSlowTimer(event.id) - // AE-2025-06: 审批等待开始→启动审批超时计时器(5min 不处理自动拒绝) - // 传 kind='risk' → 到点回调调 aiApi.approve(id,false)(后端 ai_approve kind==Risk 链路)。 - startApprovalTimer(event.id, event.name, 'risk') - // BUG-260624-02:同 AiDirAuthRequired,risk 类 pending 卡落折叠分组不可见时自动展开 + scroll。 - void emit('ai-pending-arrived', { toolCallId: event.id }) - return true - } - - case 'AiApprovalResult': { - if (!event.approved) { - const tc = findToolCall(event.id) - if (tc) { - tc.status = 'rejected' - // CR-260615-08(P1-1):走 i18n —— 原硬编码中文使 en locale 拒绝提示恒中文, - // 且废掉已存在的 aiTool.rejectedHint(en: 'User rejected this action')翻译 - tc.result = t('ai.aiTool.rejectedHint') - } - state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id) - clearToolSlowTimer(event.id) - // AE-2025-06: 用户已拒绝(状态离开 pending_approval)→清审批超时计时器 - clearApprovalTimer(event.id) - } else { - // B-260616-12: 审批通过→工具重新进入执行态,重启慢执行计时器 - startToolSlowTimer(event.id, findToolCall(event.id)?.name || '') - // path_auth 审批链:path 类(once/always 通过)与 risk 类共用此分支。 - // 补 clearApprovalTimer 防 timer 到期错调:授权通过但超时计时器未清,5min 后仍会触发拒绝回调 - // (path 类调 authorizeDir('deny')/risk 类调 ai_approve(false))与已通过的执行态冲突。risk 类拒绝分支(:422)已清, - // 此处通过分支原漏清(回归:统一审批开关开后 path 类挂起也挂 timer,通过分支须对称清)。 - clearApprovalTimer(event.id) - } - // F-260619-03 Phase B + path_auth 审批链阶段1:路径授权挂起的工具收到 ApprovalResult - // (once/always 通过 / deny 拒绝)→ 按 id 从 pendingDirAuths 移除该条(后端 ai_authorize_dir - // 已 try_continue 续 loop)。数组化后只清这一条,不影响同轮其他未决策的挂起项。 - removePendingDirAuth(event.id) - return true - } - - default: - return false - } -} - /** L2 状态机域:消费 AiConvStateChanged,写 convStates(per-conv conv_state 真相源)。 - * - * 批4 双轨收口后:enum(convStates)是唯一真相源,setConvState 单写即收敛。 - * 原 idle/error 同步 delete generatingConvs、非终止态同步 add 的双轨兜底分支已删 - * (bool 轨 generatingConvs 已退役)。看门狗由活跃 delta/工具事件重置,这里不干预。 - * 返回值:true=已处理(命中 case);false=未命中。 */ + * 返回 true=已处理;无 convId 无法路由时忽略(防误写全局态)。 */ function handleConvStateEvent(event: AiChatEvent): boolean { if (event.type !== 'AiConvStateChanged') return false const convId = event.conversation_id || state.activeConversationId - if (!convId) return true // 无 convId 无法路由,忽略(防误写全局态) + if (!convId) return true setConvState(convId, event.conv_state) return true } -/** 跨端用户消息域:F-260622-02 miniapp→桌面 user 气泡同步。 - * - * 仅处理 AiUserMessage 一个事件。语义:远程(miniapp)发来的用户消息经后端 publish_event - * 广播到 ai_event_bus,桌面收到后补 user 气泡(桌面本地 sendMessage 是乐观渲染不发此事件, - * 故桌面 store 不会有这条 user 气泡,需要此事件填补,否则桌面只看到孤立 assistant 响应气泡)。 - * - * 去重(防双气泡):若末条消息已是同 content 的 user(理论上桌面本地已乐观 push 过, - * 或 miniapp 自身乐观 push 后又被此事件回灌),跳过不重复 push。MVP 按末条 + content 比对 - * 足够覆盖单会话场景;极端情况下两条不同 user 恰巧同 content 紧邻不会丢(末条不同才 push)。 - * - * 返回值:true=命中 AiUserMessage;false=其他事件。 */ +/** 跨端用户消息域:miniapp→桌面 user 气泡同步(仅处理 AiUserMessage)。 + * 去重:末条已是同 content 的 user 则跳过(防本地乐观 push + 事件回灌双气泡)。 */ function handleUserMessageEvent(event: AiChatEvent): boolean { if (event.type !== 'AiUserMessage') return false - // 多会话并行隔离(2026-08-05 BUG-260805-02):AiUserMessage 必带 conversation_id(后端已 resolve)。 - // - 当前会话(或 null)→ push user 气泡(微信端当前会话消息,桌面若同会话直接展示) - // - 非当前会话 → 不污染当前视图(桌面在并行看别的会话),仅刷新会话列表 - // (侧栏显示该会话有新消息;用户切过去 switchConversation 从 DB 加载完整历史)。 - // 旧实现无条件 push 到 state.messages → 非当前会话的 user 消息混入当前视图(历史内容被"污染")。 + // 多会话并行隔离:AiUserMessage 必带 conversation_id;非当前会话不污染当前视图,仅刷新列表 const uc = event.conversation_id ?? null if (uc && state.activeConversationId && uc !== state.activeConversationId) { void loadConversations() return true } - // 去重:末条已是 user 且 content 相同则跳过(防桌面本地乐观 push + 事件回灌双气泡)。 const last = state.messages[state.messages.length - 1] if (last && last.role === 'user' && last.content === event.message) { return true @@ -641,251 +76,27 @@ function handleUserMessageEvent(event: AiChatEvent): boolean { return true } -/** - * 公共会话终止收尾(任务 #6 DRY 抽离):AiCompleted / AiError / AiHelpRequired - * 三分支共用同一套清场逻辑——看门狗/计时器/流式态/currentText/agentRound/per-conv - * 挂起(localStorage 快照 + pendingMaxRounds + pendingDirAuths + convStates + - * A2-B10 conv-scoped 审批收尾:pendingApprovals + 对应审批超时计时器按 convId 收敛)。 - * - * 语义差异(调用方自行处理): - * - AiCompleted: 调用后追加 incomplete 气泡 / token 用量 / 队列 drain - * - AiError: 调用后追加错误气泡 + 清 queue(审批清理已在 cleanup 内 conv-scoped 完成) - * - AiHelpRequired: 调用后翻 pendingHelp 驱动求助卡 - */ -function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | 'AiError' | 'AiHelpRequired') { - clearStreamWatchdog(convId || undefined) - clearAllToolSlowTimers() // B-260616-12: 整轮/错误/求助结束清全部工具慢执行计时器与已提示集合 - clearTextIdleTimer() // 清文本空闲定时器 + 置 textIdle=true(整轮结束不该有活跃信号残留) - flushCurrentText() - state.currentText = '' - // 清「重试中」过渡气泡(成功后残留根治,与 miniapp clearRetryBubbles 对齐)。 - // AiStreamRetry 推的 retry 气泡(isError,content 含「正在重试(」)是过渡态,终态不清会 - // 永久残留列表(flushCurrentText 只写非 error 气泡)。这里按 i18n 文案前缀清除。 - for (let i = state.messages.length - 1; i >= 0; i--) { - const m = state.messages[i] - if (m.isError && typeof m.content === 'string' && m.content.includes('正在重试(')) { - state.messages.splice(i, 1) - } - } - setStreaming(false, { convId: convId || null, reason }) - state.agentRound = 0 // AE-2025-07: agentic 结束/中断/求助,复位轮次(隐藏进度条) - - // 批4 双轨收口:收尾收敛 convStates(AiCompleted/AiHelpRequired 删项→idle,AiError 置 error)。 - // 后端 CONV_STATE_ENABLED=on 时 AiConvStateChanged 已先到此处幂等 no-op; - // off 或老后端无 AiConvStateChanged 时此处保证 convStates 不陈旧。 - if (reason === 'AiError') { - if (convId) convStates.set(convId, 'error') // 保留 error 项供停止按钮显「重试」态 - } else { - convStates.delete(convId) - } - - // TD-260621-02 per-conv:仅当 pendingMaxRounds===本 conv 才清 null(避免清其他会话的挂起)。 - if (pendingMaxRounds.value && pendingMaxRounds.value === convId) { - pendingMaxRounds.value = null - } - // L1 求助协议(§2.3):求助卡挂起仅清本 conv(终止后旧求助卡应消失)。 - if (pendingHelp.value && pendingHelp.value.conversationId === convId) { - pendingHelp.value = null - } - // TD-260621-03 per-conv:仅清本 conv 的 path_auth 挂起(终止只清本会话弹窗,不连累并发会话)。 - // F-09 多会话并发下全清会让 B 会话的 DirAuthDialog 凭空消失(用户报"弹窗没了我没操作")。 - // A2-B10 修正:终止语义=清本 conv(该会话已结束),保其他 conv;无归属旧项保守保留。 - pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId !== convId) - - // A2-B10 conv-scoped 审批收尾:仅清目标 conv 的待审批项 + 对称清其审批超时计时器。 - // 对齐上方 pendingDirAuths per-conv filter 范例。conversationId 缺失的旧项(无法归属) - // 保守保留——误清其他 conv 正在审批的卡 = 回归;仅清 conversationId===convId 的项。 - // 与旧全局清(AiError/AiHelpRequired 分支 state.pendingApprovals = [] + clearAllApprovalTimers) - // 的差异:此处按 convId 收敛,其他会话的审批卡/计时器不受影响。 - const removedApprovals = state.pendingApprovals.filter(p => p.conversationId === convId) - state.pendingApprovals = state.pendingApprovals.filter(p => !p.conversationId || p.conversationId !== convId) - // 对称清本 conv 被移除项的审批超时计时器(id 集合),防到点回调改已终止会话 state。 - for (const p of removedApprovals) clearApprovalTimer(p.id) - - // F-09: 清理分离窗口生成态快照(per-conv key,清本会话快照;兼容旧单 key) - if (convId) { - localStorage.removeItem(`df-ai-gen-${convId}`) - localStorage.removeItem(`df-ai-text-${convId}`) - } - localStorage.removeItem('df-ai-gen') - localStorage.removeItem('df-ai-text') -} - -/** lifecycle 域:整轮收尾(AiCompleted)与异常中断(AiError) */ -function handleLifecycleEvent(event: AiChatEvent): boolean { - switch (event.type) { - case 'AiCompleted': { - console.warn('[FE-AICOMPLETED] 收到 conv=', event.conversation_id, 'streaming=', state.streaming) - cleanupTerminatedConversation(event.conversation_id || '', 'AiCompleted') - console.warn('[FE-AICOMPLETED] cleanup 后 streaming=', state.streaming) - // 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()}` as MessageId, - role: 'assistant', - content: t('ai.responseIncomplete'), - timestamp: Date.now(), - }) - } - // 对话透明化 L1:直接从事件更新 pinned_goals,不等 loadConversations 异步刷新 - if (event.pinned_goals && state.activeConversationId) { - const conv = state.conversations.find(c => c.id === state.activeConversationId) - if (conv) conv.pinned_goals = event.pinned_goals - } - void loadConversations() - // token 用量记录(开关开时):lastTokenUsage 供当前回复展示,convTokenTotal 累加对话总量 - // 分项 token(2026-08-02):cache_hit/cache_miss/reasoning 透传,前端 in=cache_miss 分计费展示 - if (isShowTokenUsage()) { - state.lastTokenUsage = { - prompt: event.prompt_tokens, - completion: event.completion_tokens, - total: event.total_tokens, - cache_hit: event.prompt_cache_hit_tokens, - cache_miss: event.prompt_cache_miss_tokens, - reasoning: event.reasoning_tokens, - } - if (state.convTokenTotal) { - state.convTokenTotal.prompt += event.prompt_tokens - state.convTokenTotal.completion += event.completion_tokens - state.convTokenTotal.total += event.total_tokens - } else { - state.convTokenTotal = { prompt: event.prompt_tokens, completion: event.completion_tokens, total: event.total_tokens } - } - // 每轮 token 写入对应 assistant 消息(最后一条 AI 消息),供 MessageList 逐条显示。 - // 同时写消息级 cache/reasoning 字段(详情面板 + 分项显示用)。 - for (let i = state.messages.length - 1; i >= 0; i--) { - const m = state.messages[i] - if (m.role === 'assistant' && !m.isError) { - m.tokenUsage = { - prompt: event.prompt_tokens, - completion: event.completion_tokens, - cache_hit: event.prompt_cache_hit_tokens, - cache_miss: event.prompt_cache_miss_tokens, - reasoning: event.reasoning_tokens, - is_estimated: event.is_estimated, - } - m.prompt_cache_hit_tokens = event.prompt_cache_hit_tokens - m.prompt_cache_miss_tokens = event.prompt_cache_miss_tokens - m.reasoning_tokens = event.reasoning_tokens - break - } - } - } - notifyConversationChanged() - // 队列续发:当前完成后自动发下一条(经事件总线桥接,避免 import useAiSend 构成循环依赖) - emit('ai-drain-queue', { conversationId: event.conversation_id }) - return true - } - - case 'AiError': { - cleanupTerminatedConversation(event.conversation_id || '', 'AiError') - // A2-B10 conv-scoped 审批收尾:本 conv 的待审批项 + 审批超时计时器已由 - // cleanupTerminatedConversation 按 convId 收敛(不再全局 clearAllApprovalTimers / - // state.pendingApprovals = [],避免连累并发会话正在审批的卡)。 - // 只清除出错会话的队列项,不误伤其他会话的排队消息 - if (event.conversation_id) { - state.queue = state.queue.filter(q => q.conversationId !== event.conversation_id) - } else { - state.queue = [] - } - // UX-03: 错误消息携带 error_type(供错误气泡差异化显隐「去设置」按钮)。 - // AiMessage 类型未含 errorType 字段(不在本批白名单),用对象字面量 + cast 扩展; - // 消费方(AiChat.vue canOpenSettings)经同 cast 读取,类型闭环在两端,不污染 types.ts。 - state.messages.push({ - id: `err-${nextMsgId()}` as MessageId, - role: 'assistant', - content: friendlyError(event.error), - isError: true, - errorType: event.error_type, - timestamp: Date.now(), - } as AiMessage) - // F-15 上下文管理:AiError 也用于压缩 IPC 失败场景。通知上下文回调复位 loading + - // 弹错误 toast(若处于压缩中)。与上方流式错误处理不冲突——上下文回调内部判 isCompressing 守卫。 - _contextCallbacks.onError?.(event.conversation_id || '', event.error) - return true - } - - case 'AiHelpRequired': { - // L1 求助协议(§2.3,2026-06-21):后端断路器瘝断已 guard.reset(generating=false,loop 终止)。 - // 前端按终止态收尾(对齐 AiError 分支:清看门狗/流式态/快照/队尾文本 flushCurrentText), - // 但不创建错误气泡(求助非错误,是 AI 主动求助)——改为翻 pendingHelp 驱动求助卡(HelpRequiredCard) - // 显 reason + options 按钮供用户选。 - cleanupTerminatedConversation(event.conversation_id || '', 'AiHelpRequired') - // A2-B10 conv-scoped 审批收尾:本 conv 的待审批项 + 审批超时计时器已由 - // cleanupTerminatedConversation 按 convId 收敛(不再全局 state.pendingApprovals = [], - // 避免连累并发会话正在审批的卡)。 - // 翻 pendingHelp 驱动求助卡:用 convId 兜底(后端必带,无时默认当前活跃会话,优于丢卡片)。 - pendingHelp.value = { - reason: event.reason, - context: event.context, - options: event.options, - conversationId: event.conversation_id || state.activeConversationId || null, - } - void loadConversations() - notifyConversationChanged() - return true - } - - // F-15 上下文管理生命周期事件(原 useAiContext 独立监听器,现合入统一分发器) - case 'AiCompressing': - _contextCallbacks.onCompressing?.() - return true - - case 'AiManualCompressed': { - // 手动 IPC 压缩(用户点压缩按钮):复位 loading + 弹 toast + 刷新视图回填摘要。 - _contextCallbacks.onManualCompressed?.(event.conversation_id || '', event.summary || '') - return true - } - - case 'AiAutoCompressed': { - // loop 自动压缩对桌面静默——仅复位 loading(冗余兜底,Auto 路径前端 loading 本就为 false), - // 不调 onManualCompressed(防每次发送误弹 toast + 误 switchConversation 打断 LLM 流)。 - _contextCallbacks.onAutoCompressed?.() - return true - } - - case 'AiContextCleared': - _contextCallbacks.onCleared?.(event.conversation_id || '') - return true - - default: - return false - } -} - /** 后端事件分发:按 conversation_id 路由,流式累积文本,工具状态流转,看门狗联动 */ export function handleEvent(event: AiChatEvent) { const convId = event.conversation_id - // 注:F-260620 path_auth 审批链根治卡死后,原 [AI-DIRAUTH-DIAG] 高频诊断 console.log 已删除 - // (使命完成;热路径污染 devtools)。后续如需事件流诊断用 df-ai-event-trace 开关控制。 - // 首次收到事件时同步当前对话 id(后端自动建对话的场景) - // 同步写 appSettings(SQLite):刷新页面后 loadConversations 据此恢复上次会话 + // 首次收到事件时同步当前对话 id(后端自动建对话的场景)并持久化 if (convId && !state.activeConversationId) { state.activeConversationId = convId void appSettings.set('df-ai-active-conv', convId) } - // L2 状态机:AiConvStateChanged 是 per-conv 状态信号(F-09 多会话并发下须追踪所有会话), - // 在 isCurrent 守卫**之前**处理,避免切走会话时 conv_state 不更新致停止按钮/MaxRoundsCard 错态。 - // 该事件非流式活跃信号,不重置看门狗、不进活跃事件兜底写(由 handleConvStateEvent 写 enum)。 + // L2 状态机:AiConvStateChanged 是 per-conv 状态信号,在 isCurrent 守卫之前处理 + // (切走会话时也须更新 conv_state,防停止按钮/MaxRoundsCard 错态) if (handleConvStateEvent(event)) return - // F-260622-02 跨端用户消息同步:在 isCurrent 守卫**之前**处理——AiUserMessage 是补 user 气泡 - // 信号(非流式活跃事件),不应触发 watchdog reset / 活跃事件兜底写(后续 AiAgentRound/ - // AiTextDelta 等活跃事件会正常触发)。也不受"非当前会话"过滤——理论上此事件必带 conversation_id - // 且属当前会话(后端 route_send_message 在放行时广播),即使因切走会话到达也仅补 user 气泡, - // 不影响其他态(handleUserMessageEvent 去重幂等)。 + // 跨端用户消息同步:在 isCurrent 守卫之前处理(补 user 气泡信号,非流式活跃事件) if (handleUserMessageEvent(event)) return // 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误/求助时刷新侧边栏 const isCurrent = !convId || convId === state.activeConversationId if (!isCurrent) { if (event.type === 'AiCompleted' || event.type === 'AiError' || event.type === 'AiHelpRequired') { - // 非当前会话终止事件:收敛会话状态(删 Map 项回不在生成)。非当前会话的 error 态无 - // 消费方(操作卡/输入区均读 activeConversationId),回退 null 与停止按钮回退行为等价。 + // 非当前会话终止:收敛会话状态(删 Map 项回不在生成) convStates.delete(convId || '') void loadConversations() - // 后台会话终止也触发队列续发/清队(原 isCurrent 守卫拦截导致非当前会话排队消息永不 - // drain,切回后队列卡死)。完成触发续发;错误终止对齐当前会话分支清该会话队列。 + // 后台会话终止也触发队列续发/清队(完成续发;错误清该会话队列) if (event.type === 'AiCompleted') { emit('ai-drain-queue', { conversationId: event.conversation_id }) } else if (event.type === 'AiError') { @@ -896,29 +107,25 @@ export function handleEvent(event: AiChatEvent) { } } } else if (event.type === 'AiTextDelta' && convId && switchingConvs.has(convId)) { - // 切换中缓冲:目标会话正被 switchConversation 拉取,该会话的 delta 累积到其 per-conv - // 流式态(而非丢弃),切换完成后由渲染层续显,避免切换窗口丢可见内容。不做气泡/工具 - // 副作用(消息视图尚未切到目标会话,避免污染当前视图)。 + // 切换中缓冲:目标会话正被 switchConversation 拉取,其 delta 累积到 per-conv 流式态, + // 切换完成后由渲染层续显,避免切换窗口丢可见内容 setConvStreaming(convId, true) setConvCurrentText(convId, (getConvStreamState(convId)?.currentText ?? '') + (event.delta ?? '')) } return } - // 批4 双轨收口:活跃事件(delta/工具/新轮/审批结果)到达即标记该会话生成中。 - // 完成后端 CONV_STATE_ENABLED=off 或老后端不发 AiConvStateChanged{generating} 时,此处作为 - // enum 兜底写入口(替代原 generatingConvs.add + 清残留 error 双操作,合并为 setConvState('generating') 一步)。 - // CONV_STATE_ENABLED=on 时后端 AiConvStateChanged{generating} 已先到(handleConvStateEvent 在 - // isCurrent 守卫前处理),此处 setConvState 同值 set 幂等覆盖,无副作用。完成/错误/求助事件除外 - // (三者后端均已终止 loop,在各自 case 内由 convStates.delete/set('error') 收敛)。 + // 无目标会话(事件缺 convId 且当前无活跃会话):流式 delta 无处累积,跳过写防 null 键无意义写入 + if (!convId && !state.activeConversationId && event.type === 'AiTextDelta') return + // 活跃事件(delta/工具/新轮/审批结果)到达即标记该会话生成中(enum 兜底写入口; + // 后端 AiConvStateChanged{generating} 已先到时同值 set 幂等覆盖,无副作用) if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError' && event.type !== 'AiHelpRequired') { setConvState(convId, 'generating') } - // 流式看门狗:活跃事件(delta/工具/新轮/审批结果)重置;审批等待/完成/错误在 case 内 clear - // TD-260621-01 per-conv:传 convId 走 per-conv Map(各会话独立计时,互不顶替/连累)。 + // 流式看门狗:活跃事件重置;审批等待/完成/错误在 case 内 clear(per-conv,各会话独立计时) if (!NO_RESET_WATCHDOG.has(event.type)) { resetStreamWatchdog(convId || undefined) } - // 按域分派(streaming → tool → lifecycle);未命中任一域 → 无操作(保留原 switch 无 default 的语义) + // 按域分派(streaming → tool → lifecycle);未命中任一域 → 无操作 if (handleStreamingEvent(event)) return if (handleToolEvent(event)) return void handleLifecycleEvent(event) @@ -926,11 +133,9 @@ export function handleEvent(event: AiChatEvent) { /** 启动事件监听(幂等 + 并发去重,onMounted 与 sendMessage 首发竞态不会重复注册) */ export async function startListener() { - // 已注册 → 直接复用(幂等;sendMessage 每次调用不重复注册) if (_unlistenAiEvent && _unlistenConvChanged && _unlistenApprovalClear) { return } - // 并发去重:防 onMounted 与 sendMessage 首发竞态重复注册 listener → 文字双倍 if (_startPromise) return _startPromise _startPromise = (async () => { try { @@ -950,9 +155,7 @@ export async function startListener() { } } -/** 停止事件监听(卸载时调用,释放后端 listener + 清看门狗) - * 清看门狗:卸载时若仍在生成,_streamWatchdog 计时器未释放,130s 后 onStreamTimeout - * 仍写 state(messages.push/置 streaming)——已卸载组件不应再被触发。故同步清除。 */ +/** 停止事件监听(卸载时调用,释放后端 listener + 清看门狗/计时器,防回调改已卸载组件 state) */ export function stopListener() { _unlistenAiEvent?.() _unlistenConvChanged?.() @@ -960,13 +163,12 @@ export function stopListener() { _unlistenAiEvent = null _unlistenConvChanged = null _unlistenApprovalClear = null - // UX-260617-26: stop 清零去重 promise——极速 mount/unmount/mount(HMR)时,首 mount 的 - // _startPromise 可能仍在 pending(未 await 完即 unmount),再 mount 若命中并发去重分支会 - // 返回这个已代表「旧注册」的过期 promise,新 listener 实际未注册。 + // stop 清零去重 promise:极速 mount/unmount/mount(HMR)时,首 mount 的 _startPromise 可能仍 pending, + // 再 mount 若命中并发去重分支会返回这个已代表「旧注册」的过期 promise,新 listener 实际未注册 _startPromise = null - clearAllStreamWatchdogs() // TD-260621-01: 卸载清全部 per-conv + legacy fallback timer,防回调改已卸载组件 state - clearAllToolSlowTimers() // B-260616-12: 卸载时释放所有工具慢执行计时器,防回调改 state 触发已卸载组件 - clearAllApprovalTimers() // AE-2025-06: 卸载时释放所有审批超时计时器,防回调 push 消息触发已卸载组件 + clearAllStreamWatchdogs() + clearAllToolSlowTimers() + clearAllApprovalTimers() } export function useAiEvents() { diff --git a/src/composables/ai/useAiLifecycleEvents.ts b/src/composables/ai/useAiLifecycleEvents.ts new file mode 100644 index 0000000..b8ea9e8 --- /dev/null +++ b/src/composables/ai/useAiLifecycleEvents.ts @@ -0,0 +1,222 @@ +//! 生命周期事件域 — handleLifecycleEvent + cleanupTerminatedConversation +//! +//! 处理:AiCompleted / AiError / AiHelpRequired + 上下文生命周期事件(AiCompressing/ +//! AiManualCompressed/AiAutoCompressed/AiContextCleared)。 +//! 依赖方向:本模块 → aiShared / useAiPendingState(叶子)/ useAiStream / streamingGuard / +//! useAiConversations,不导入任何同级子 composable。 + +import { emit } from '@tauri-apps/api/event' +import { useAppSettingsStore } from '@/stores/appSettings' +import { state } from '@/stores/ai' +import { t } from '@/i18n/i18n-helpers' +import { nextMsgId, convStates, clearApprovalTimer, flushCurrentText, clearAllToolSlowTimers, friendlyError, notifyConversationChanged } from './aiShared' +import { clearTextIdleTimer, pendingMaxRounds, pendingHelp, pendingDirAuths } from './useAiPendingState' +import { clearStreamWatchdog } from './useAiStream' +import { setStreaming } from './streamingGuard' +import { loadConversations } from './useAiConversations' +import type { AiChatEvent, AiMessage, MessageId } from '@/api/types' + +const appSettings = useAppSettingsStore() + +// 上下文管理生命周期事件回调(useAiContext.initContextListener 注入,本域统一分发) +export interface ContextCallbacks { + /** 压缩开始(AiCompressing):置 loading=true */ + onCompressing?: () => void + /** 手动压缩成功(AiManualCompressed):置 loading=false + toast + 回填摘要 */ + onManualCompressed?: (conversationId: string, summary: string) => void + /** 自动压缩成功(AiAutoCompressed):静默置 loading=false(不弹 toast) */ + onAutoCompressed?: () => void + /** 上下文已清空(AiContextCleared):toast + 刷新消息列表 */ + onCleared?: (conversationId: string) => void + /** 错误(AiError,含压缩失败):仅在 loading 时置 false + toast */ + onError?: (conversationId: string, message: string) => void +} +let _contextCallbacks: ContextCallbacks = {} + +/** 注入上下文生命周期回调(useAiContext.initContextListener 调用) */ +export function setContextCallbacks(callbacks: ContextCallbacks): void { + _contextCallbacks = callbacks +} + +/** token 用量展示开关(读 appSettings,与 Settings.vue 共享 key `df-show-token-usage`) */ +function isShowTokenUsage(): boolean { + return appSettings.get('df-show-token-usage', false) +} + +/** + * 公共会话终止收尾(AiCompleted/AiError/AiHelpRequired 共用):清看门狗/计时器/流式态/挂起。 + * + * 语义差异(调用方自行处理): + * - AiCompleted: 调用后追加 incomplete 气泡 / token 用量 / 队列 drain + * - AiError: 调用后追加错误气泡 + 清 queue + * - AiHelpRequired: 调用后翻 pendingHelp 驱动求助卡 + */ +function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | 'AiError' | 'AiHelpRequired') { + clearStreamWatchdog(convId || undefined) + clearAllToolSlowTimers() // 整轮/错误/求助结束清全部工具慢执行计时器与已提示集合 + clearTextIdleTimer() // 清文本空闲定时器 + 置 textIdle=true(整轮结束不该有活跃信号残留) + flushCurrentText() + state.currentText = '' + // 清「重试中」过渡气泡(成功后残留根治;AiStreamRetry 推的 retry 气泡是过渡态,终态不清会永久残留) + for (let i = state.messages.length - 1; i >= 0; i--) { + const m = state.messages[i] + if (m.isError && typeof m.content === 'string' && m.content.includes('正在重试(')) { + state.messages.splice(i, 1) + } + } + setStreaming(false, { convId: convId || null, reason }) + state.agentRound = 0 // 结束/中断/求助,复位轮次(隐藏进度条) + // convStates 收敛:AiError 保留 error 项(停止按钮显「重试」态),其余删项回 idle + if (reason === 'AiError') { + if (convId) convStates.set(convId, 'error') + } else { + convStates.delete(convId) + } + // per-conv 挂起仅清本 conv(避免清错其他会话的挂起) + if (pendingMaxRounds.value && pendingMaxRounds.value === convId) { + pendingMaxRounds.value = null + } + if (pendingHelp.value && pendingHelp.value.conversationId === convId) { + pendingHelp.value = null + } + // 路径授权挂起仅清本 conv(终止只清本会话弹窗,不连累并发会话;无归属旧项保守保留) + pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId !== convId) + // 审批收尾仅清目标 conv 的待审批项 + 对称清其超时计时器(其他会话的审批卡/计时器不受影响; + // conversationId 缺失的旧项无法归属,保守保留防误清正在审批的卡) + const removedApprovals = state.pendingApprovals.filter(p => p.conversationId === convId) + state.pendingApprovals = state.pendingApprovals.filter(p => !p.conversationId || p.conversationId !== convId) + for (const p of removedApprovals) clearApprovalTimer(p.id) + // 清分离窗口生成态快照(per-conv key,清本会话;兼容旧单 key) + if (convId) { + localStorage.removeItem(`df-ai-gen-${convId}`) + localStorage.removeItem(`df-ai-text-${convId}`) + } + localStorage.removeItem('df-ai-gen') + localStorage.removeItem('df-ai-text') +} + +/** lifecycle 域:整轮收尾(AiCompleted)与异常中断(AiError)。返回 true=已处理。 */ +export function handleLifecycleEvent(event: AiChatEvent): boolean { + switch (event.type) { + case 'AiCompleted': { + console.warn('[FE-AICOMPLETED] 收到 conv=', event.conversation_id, 'streaming=', state.streaming) + cleanupTerminatedConversation(event.conversation_id || '', 'AiCompleted') + console.warn('[FE-AICOMPLETED] cleanup 后 streaming=', state.streaming) + // 流中途失败保文:后端 emit AiCompleted(incomplete=true),前端追加系统提示气泡 + if (event.incomplete) { + state.messages.push({ + id: `incomplete-${nextMsgId()}` as MessageId, + role: 'assistant', + content: t('ai.responseIncomplete'), + timestamp: Date.now(), + }) + } + // 直接从事件更新 pinned_goals,不等 loadConversations 异步刷新 + if (event.pinned_goals && state.activeConversationId) { + const conv = state.conversations.find(c => c.id === state.activeConversationId) + if (conv) conv.pinned_goals = event.pinned_goals + } + void loadConversations() + // token 用量记录(开关开时):lastTokenUsage 供当前回复展示,convTokenTotal 累加对话总量 + if (isShowTokenUsage()) { + state.lastTokenUsage = { + prompt: event.prompt_tokens, + completion: event.completion_tokens, + total: event.total_tokens, + cache_hit: event.prompt_cache_hit_tokens, + cache_miss: event.prompt_cache_miss_tokens, + reasoning: event.reasoning_tokens, + } + if (state.convTokenTotal) { + state.convTokenTotal.prompt += event.prompt_tokens + state.convTokenTotal.completion += event.completion_tokens + state.convTokenTotal.total += event.total_tokens + } else { + state.convTokenTotal = { prompt: event.prompt_tokens, completion: event.completion_tokens, total: event.total_tokens } + } + // 每轮 token 写入最后一条 AI 消息,供 MessageList 逐条显示 + for (let i = state.messages.length - 1; i >= 0; i--) { + const m = state.messages[i] + if (m.role === 'assistant' && !m.isError) { + m.tokenUsage = { + prompt: event.prompt_tokens, + completion: event.completion_tokens, + cache_hit: event.prompt_cache_hit_tokens, + cache_miss: event.prompt_cache_miss_tokens, + reasoning: event.reasoning_tokens, + is_estimated: event.is_estimated, + } + m.prompt_cache_hit_tokens = event.prompt_cache_hit_tokens + m.prompt_cache_miss_tokens = event.prompt_cache_miss_tokens + m.reasoning_tokens = event.reasoning_tokens + break + } + } + } + notifyConversationChanged() + // 队列续发:当前完成后自动发下一条(经事件总线桥接,避免 import useAiSend 循环依赖) + emit('ai-drain-queue', { conversationId: event.conversation_id }) + return true + } + + case 'AiError': { + cleanupTerminatedConversation(event.conversation_id || '', 'AiError') + // 只清除出错会话的队列项,不误伤其他会话的排队消息 + if (event.conversation_id) { + state.queue = state.queue.filter(q => q.conversationId !== event.conversation_id) + } else { + state.queue = [] + } + state.messages.push({ + id: `err-${nextMsgId()}` as MessageId, + role: 'assistant', + content: friendlyError(event.error), + isError: true, + errorType: event.error_type, + timestamp: Date.now(), + } as AiMessage) + // AiError 也用于压缩 IPC 失败:通知上下文回调复位 loading + 弹错误 toast(内部判 isCompressing 守卫) + _contextCallbacks.onError?.(event.conversation_id || '', event.error) + return true + } + + case 'AiHelpRequired': { + // 求助协议:后端断路器熔断已 guard.reset(loop 终止)。按终止态收尾但不创建错误气泡, + // 改为翻 pendingHelp 驱动求助卡(reason + options 按钮供用户选)。 + cleanupTerminatedConversation(event.conversation_id || '', 'AiHelpRequired') + // 用 convId 兜底(后端必带,无时默认当前活跃会话,优于丢卡片) + pendingHelp.value = { + reason: event.reason, + context: event.context, + options: event.options, + conversationId: event.conversation_id || state.activeConversationId || null, + } + void loadConversations() + notifyConversationChanged() + return true + } + + case 'AiCompressing': + _contextCallbacks.onCompressing?.() + return true + + case 'AiManualCompressed': { + // 手动 IPC 压缩:复位 loading + 弹 toast + 刷新视图回填摘要 + _contextCallbacks.onManualCompressed?.(event.conversation_id || '', event.summary || '') + return true + } + + case 'AiAutoCompressed': { + // loop 自动压缩对桌面静默——仅复位 loading(不弹 toast、不打断 LLM 流) + _contextCallbacks.onAutoCompressed?.() + return true + } + + case 'AiContextCleared': + _contextCallbacks.onCleared?.(event.conversation_id || '') + return true + + default: + return false + } +} diff --git a/src/composables/ai/useAiPendingState.ts b/src/composables/ai/useAiPendingState.ts new file mode 100644 index 0000000..b71eebd --- /dev/null +++ b/src/composables/ai/useAiPendingState.ts @@ -0,0 +1,77 @@ +//! AI 待决/流式信号共享态(叶子)—— textIdle / pendingMaxRounds / pendingDirAuths / pendingHelp +//! 及各自的定时器与 push/remove。 +//! +//! 叶子定位:仅被导入,不导入任何子 composable(依赖仅 vue + appSettings),故无环风险。 +//! useAiEvents 拆分后,streaming/tool/lifecycle 三域与组件(ChatInput/MaxRoundsCard/ +//! DirAuthDialog/HelpRequiredCard)统一从这里取这些跨域信号。 + +import { ref } from 'vue' +import { useAppSettingsStore } from '@/stores/appSettings' + +const appSettings = useAppSettingsStore() + +// 文本空闲信号:streaming=true 且 300ms 无新 delta 时=true,按钮据此显「发送」而非「停止」。 +// 不动 streaming(streaming 是渲染态,控制 MessageList 流式 block 显隐,不能中途翻转)。 +export const textIdle = ref(true) +let _textIdleTimer: ReturnType | null = null + +/** 每次新 delta 重置 300ms 空闲计时(textIdle=false 表示文本活跃) */ +export function resetTextIdleTimer(): void { + if (_textIdleTimer) clearTimeout(_textIdleTimer) + textIdle.value = false + _textIdleTimer = setTimeout(() => { + textIdle.value = true + _textIdleTimer = null + }, 300) +} + +/** 整轮结束/新轮开始清空闲计时并回落默认空闲 */ +export function clearTextIdleTimer(): void { + if (_textIdleTimer) { clearTimeout(_textIdleTimer); _textIdleTimer = null } + textIdle.value = true +} + +// 达 max_iterations 暂停态(后端仍 generating=true):存挂起 convId,null=无挂起。 +// 消费方(MaxRoundsCard)按 pendingMaxRounds === activeConversationId 精确比对,多会话不串台。 +export const pendingMaxRounds = ref(null) + +// 路径授权挂起(后端 AiDirAuthRequired 置,用户决策后清)。数组 push 多条并存: +// 同轮多个文件工具落不同未授权目录时,后端连发多条,单 ref 会被后到覆盖致弹窗丢失。 +export interface PendingDirAuth { + id: string + tool: string + path: string + dir: string + conversationId?: string +} +export const pendingDirAuths = ref([]) + +// 多挂起并存开关(appSettings key `df-ai-multi-pending-auth`):开=数组 push 多条; +// 关=单 ref 覆盖(后到覆盖先到,行为对齐旧版)。 +function isMultiPendingAuth(): boolean { + return appSettings.get('df-ai-multi-pending-auth', true) +} + +/** 向 pendingDirAuths 追加一条(开关控制多挂起并存 vs 单 ref 覆盖) */ +export function pushPendingDirAuth(p: PendingDirAuth): void { + if (isMultiPendingAuth()) { + pendingDirAuths.value.push(p) + } else { + pendingDirAuths.value = [p] + } +} + +/** 按 id 从 pendingDirAuths 移除一条 */ +export function removePendingDirAuth(id: string): void { + pendingDirAuths.value = pendingDirAuths.value.filter(p => p.id !== id) +} + +// 求助协议挂起(agent 断路器熔断时后端 AiHelpRequired 置):显 reason + options 驱动求助卡。 +// 存挂起 convId,消费方按 activeConversationId 精确比对(多会话并发不串台)。 +export interface PendingHelp { + reason: string + context: string + options: string[] + conversationId: string | null +} +export const pendingHelp = ref(null) diff --git a/src/composables/ai/useAiSend.ts b/src/composables/ai/useAiSend.ts index 6c889bc..7530f62 100644 --- a/src/composables/ai/useAiSend.ts +++ b/src/composables/ai/useAiSend.ts @@ -23,8 +23,8 @@ import type { ContentPart, AiMessage, MentionSpan, MessageId } from '@/api/types 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, setConvCurrentText, setConvStreaming } from './aiShared' +import { startListener } from './useAiEvents' +import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang, convStates, setConvCurrentText, setConvStreaming, flushCurrentText } from './aiShared' /// 待发送队列上限(超过抛错提示用户) const QUEUE_LIMIT = 10 @@ -33,18 +33,18 @@ const QUEUE_LIMIT = 10 const QUEUE_TIMEOUT_MS = 30_000 /** - * F-01 阶段6: 用户指定模型 override(主对话专用,模块级单例)。 + * 用户指定模型 override(主对话专用,模块级单例)。 * * null=自动模式(路由器选);非空字符串=用户从下拉指定的 model_id。 * 仅主对话(agentic)生效:doSend/sendMessage/regenerate/editMessage 透传给后端, - * 标题/扫描/灵感等内部调用不读此字段(后端各自独立 IPC 不带 override)。 + * 标题/扫描/灵感等内部调用不读此字段。 * * 后端兜底(agentic.rs):override 非空且在该 provider model_configs 池中才用, * 否则落回路由结果——绝不让 override 导致无模型。 * - * 模块级(非组件级):AiChat.vue 顶部下拉读写此 ref,发送链路读此 ref 透传, - * 单实例 AiChat 不需 props/inject 串联。切换对话时后端清 session.model_override, - * 前端应同步清(modelOverride.value = null)保持 UI 与后端一致。 + * 模块级 ref(非组件级):AiChat 顶部下拉读写,发送链路读此 ref 透传,单实例不需 props/inject。 + * 切换会话后端会清自身 session.model_override;前端保留用户手选(每会话默认权重最高 + * 由 TopBar 兜底:仅当 override 空/对目标模型池无效时才重置),避免切会话往返丢失手选模型。 */ const modelOverride = ref(null) @@ -278,9 +278,8 @@ export function drainQueue(convId?: string | null) { const next = state.queue.splice(idx, 1)[0]! void (async () => { try { - // 用目标会话 id 发送(而非当前活跃会话)——若已切到别的会话,原实现会把 A 的队首消息 - // 误发到当前活跃会话 B。队列项无 mentionSpans 字段,传 undefined。 - await sendMessage(next.text, next.skill, false, next.parts, undefined, targetId) + // 用目标会话 id 发送(而非当前活跃会话),避免切走后把 A 的队首消息误发到 B;spans 一并透传 + await sendMessage(next.text, next.skill, false, next.parts, next.spans, targetId) } catch (e) { // 失败时彻底收尾:带目标会话 id 精准清理 per-conv 看门狗 + 会话状态 setConvStreaming(targetId, false) @@ -352,6 +351,7 @@ async function sendMessage(text: string, skill?: string, forceMode = false, part skill: skill || undefined, enqueuedAt: Date.now(), parts: parts && parts.length > 0 ? parts : undefined, + spans: spans && spans.length > 0 ? spans : undefined, conversationId: targetConvId, }) return @@ -388,7 +388,7 @@ export async function tryForceSend(confirmFn: (msg: string) => Promise, // 此处无需前置 false(前置 false 会触发消息列表 watch 清流式块再重建,产生瞬态抖动) clearStreamWatchdog() try { - await sendMessage(first.text, first.skill, true, first.parts, undefined, first.conversationId) + await sendMessage(first.text, first.skill, true, first.parts, first.spans, first.conversationId) return true } catch (e) { // force_send 也失败:回填该会话队首保消息不丢,复位 streaming 让 UI 可继续操作 @@ -398,6 +398,7 @@ export async function tryForceSend(confirmFn: (msg: string) => Promise, skill: first.skill, enqueuedAt: first.enqueuedAt, parts: first.parts, + spans: first.spans, conversationId: first.conversationId, }) setStreaming(false, { convId: targetConv ?? state.activeConversationId, reason: 'tryForceSend-fail' }) @@ -481,7 +482,7 @@ async function sendQueuedNow(index: number) { // backendGenerating(后端真值仍 true)→ spliced 被入队而非立即发,与"立即发送"语义不符。 // force_send 原子复位 generating=false 再发,无竞态窗口;stop_flag 让旧 loop 在下个检测点退出。 // 显式传 spliced 所属会话 id(当前活跃视图内操作,即当前会话)。 - await sendMessage(spliced.text, spliced.skill, true, spliced.parts, undefined, spliced.conversationId) + await sendMessage(spliced.text, spliced.skill, true, spliced.parts, spliced.spans, spliced.conversationId) } /** 停止当前生成:本地先复位 streaming,再发停止信号。 diff --git a/src/composables/ai/useAiStreamingEvents.ts b/src/composables/ai/useAiStreamingEvents.ts new file mode 100644 index 0000000..e83b58d --- /dev/null +++ b/src/composables/ai/useAiStreamingEvents.ts @@ -0,0 +1,143 @@ +//! 流式事件域 — handleStreamingEvent +//! +//! 处理:AiTextDelta / AiAgentRound / AiHeartbeat / AiStreamRetry / AiMaxRoundsReached / AiDirAuthRequired。 +//! 依赖方向:本模块 → aiShared / useAiPendingState(叶子)/ useAiStream,不导入任何同级子 composable。 +//! _lastDelta 去重 Map 与 flushCurrentText 因跨域共用已下沉 aiShared,这里仅经 accessor 读写。 + +import { emit } from '@tauri-apps/api/event' +import { useAppSettingsStore } from '@/stores/appSettings' +import { state } from '@/stores/ai' +import { t } from '@/i18n/i18n-helpers' +import { nextMsgId, findToolCall, startApprovalTimer, clearToolSlowTimer, getLastDelta, setLastDelta, flushCurrentText } from './aiShared' +import { resetTextIdleTimer, clearTextIdleTimer, pendingMaxRounds, pendingDirAuths, pushPendingDirAuth } from './useAiPendingState' +import { clearStreamWatchdog } from './useAiStream' +import type { AiChatEvent, AiMessage, MessageId } from '@/api/types' + +const appSettings = useAppSettingsStore() + +// path_auth 统一审批模型开关(appSettings key `df-ai-unified-approval`):开=path 挂起归一进 +// pendingApprovals(kind='path',ToolCard 内联审批);关=走 pendingDirAuths 独立弹窗(DirAuthDialog)。 +function isUnifiedApproval(): boolean { + return appSettings.get('df-ai-unified-approval', true) +} + +/** streaming 域:流式文本累积/新轮/心跳/重试/max 轮暂停。返回 true=已处理。 */ +export function handleStreamingEvent(event: AiChatEvent): boolean { + switch (event.type) { + case 'AiTextDelta': { + // 连续完全相同 delta(且非空)丢弃第二次(双窗口 listener 或 provider 异常重复,留诊断证据) + const convRef = event.conversation_id || state.activeConversationId + const lastDelta = getLastDelta(convRef) + if (event.delta && lastDelta === event.delta && event.delta.length > 1) { + if (appSettings.get('df-ai-trace-delta', true)) { + console.warn('[AiTextDelta] 重复 delta 已丢弃(疑似双窗口 listener 或 provider 异常):', JSON.stringify(event.delta)) + } + return true + } + setLastDelta(convRef, event.delta) + state.currentText += event.delta + // 重置文本空闲定时器:每次 delta 重置 300ms,无新 delta 到 300ms → textIdle=true → 按钮白 + resetTextIdleTimer() + return true + } + + case 'AiAgentRound': { + // 新一轮:保存当前文本到上一条 assistant 消息,新建空 assistant 消息,清空闲定时器。 + // 不设 streaming=false(streaming 管渲染,多轮间需持续 true 让 MessageList 渲染后续 deltas)。 + flushCurrentText() + state.currentText = '' + clearTextIdleTimer() + state.completedTools = 0 // 新轮重置工具计数器 + state.messages.push({ + id: `ai-${nextMsgId()}` as MessageId, + role: 'assistant', + content: '', + timestamp: Date.now(), + }) + // round>0 才更新进度条(round=0 是审批通过后「隔开新一轮」的占位,实际轮次未推进,防闪烁) + if (event.round > 0) state.agentRound = event.round + return true + } + + case 'AiHeartbeat': + // 心跳仅维持看门狗(已在上方 reset),显式 case 防 switch 穿透 + return true + + case 'AiStreamRetry': { + // 重试中——首条重试推新错误气泡,后续仅更新其 content(单气泡聚合,根治 N+1 气泡) + const lastMsg = state.messages[state.messages.length - 1] + const retryText = t('ai.aiStreamRetry', { attempt: event.attempt, max: event.max_attempts }) + if (lastMsg && lastMsg.isError) { + lastMsg.content = retryText + } else { + state.messages.push({ + id: `err-${nextMsgId()}` as MessageId, + role: 'assistant', + content: retryText, + isError: true, + timestamp: Date.now(), + } as AiMessage) + } + return true + } + + case 'AiMaxRoundsReached': { + // 达 max 暂停态(后端仍 generating=true):看门狗不计整流超时(clear),挂起 convId 驱动操作卡。 + // 无 convId 时保留原值兜底(达 max 事件理论上必带 convId,缺失默认当前活跃会话)。 + clearStreamWatchdog(event.conversation_id || undefined) + pendingMaxRounds.value = event.conversation_id || state.activeConversationId || pendingMaxRounds.value + return true + } + + case 'AiDirAuthRequired': { + // 路径授权挂起(后端 generating 保持 true,等用户决策):看门狗跳过 reset,由本 case clear。 + clearStreamWatchdog(event.conversation_id || undefined) + clearToolSlowTimer(event.id) + // 统一审批模型开:path 挂起归一进 pendingApprovals(kind='path'),ToolCard 内联审批。 + // 同 id 幂等(provider id 不稳或重发时不重复 push 防残留卡)。 + if (isUnifiedApproval()) { + if (!state.pendingApprovals.some(p => p.id === event.id)) { + state.pendingApprovals.push({ + id: event.id, + name: event.tool, + args: { path: event.path }, + status: 'pending_approval', + kind: 'path', + dir: event.dir, + path: event.path, + conversationId: event.conversation_id ?? state.activeConversationId ?? undefined, + reason: t('aiChat.dirAuthHint', { tool: event.tool, path: event.path }), + }) + // 同步改对应工具卡的 tc,让 ToolCard 显 path 类审批按钮(once/always/deny) + const tc = findToolCall(event.id) + if (tc) { + tc.status = 'pending_approval' + tc.kind = 'path' + tc.dir = event.dir + tc.path = event.path + tc.reason = t('aiChat.dirAuthHint', { tool: event.tool, path: event.path }) + } + // path 类挂起同样启动审批超时计时器(到点回调调 authorizeDir(id,'deny')) + startApprovalTimer(event.id, event.tool, 'path') + } + // 通知 ToolCardList 自动展开折叠组 + scroll(挂起卡落折叠分组 display:none 用户不可见) + void emit('ai-pending-arrived', { toolCallId: event.id }) + return true + } + // 统一审批模型关(兜底):走 DirAuthDialog 独立弹窗(push pendingDirAuths,开关控多挂起并存) + if (!pendingDirAuths.value.some(p => p.id === event.id)) { + pushPendingDirAuth({ + id: event.id, + tool: event.tool, + path: event.path, + dir: event.dir, + conversationId: event.conversation_id, + }) + } + return true + } + + default: + return false + } +} diff --git a/src/composables/ai/useAiToolEvents.ts b/src/composables/ai/useAiToolEvents.ts new file mode 100644 index 0000000..d7a352b --- /dev/null +++ b/src/composables/ai/useAiToolEvents.ts @@ -0,0 +1,108 @@ +//! 工具事件域 — handleToolEvent +//! +//! 处理:AiToolCallStarted / AiToolCallCompleted / AiToolAutoApproved / AiApprovalRequired / AiApprovalResult。 +//! 依赖方向:本模块 → aiShared / useAiPendingState(叶子)/ useAiStream,不导入任何同级子 composable。 + +import { emit } from '@tauri-apps/api/event' +import { state } from '@/stores/ai' +import { t } from '@/i18n/i18n-helpers' +import { findToolCall, clearApprovalTimer, startApprovalTimer, startToolSlowTimer, clearToolSlowTimer } from './aiShared' +import { removePendingDirAuth } from './useAiPendingState' +import { clearStreamWatchdog } from './useAiStream' +import type { AiChatEvent, AiToolCallInfo } from '@/api/types' + +/** tool 域:工具卡片状态流转/会话级信任/审批。返回 true=已处理。 */ +export function handleToolEvent(event: AiChatEvent): boolean { + switch (event.type) { + case 'AiToolCallStarted': { + const info: AiToolCallInfo = { + id: event.id, + name: event.name, + args: event.args, + status: 'running', + } + // 同 id 重复 Started 不重复 push(provider id 不稳,防残留 running 空卡) + if (!findToolCall(event.id)) { + const lastMsg = state.messages[state.messages.length - 1] + if (lastMsg && lastMsg.role === 'assistant') { + lastMsg.toolCalls = lastMsg.toolCalls || [] + lastMsg.toolCalls.push(info) + } + } + // 工具开始执行即挂慢执行计时器(超时仅 toast,不动 running 态) + startToolSlowTimer(event.id, event.name) + return true + } + + case 'AiToolCallCompleted': { + const tc = findToolCall(event.id) + if (tc) { + tc.status = 'completed' + tc.result = event.result + } + state.completedTools++ // 进度条计数器 + state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id) + clearToolSlowTimer(event.id) + // 工具结束(无论审批通过后执行还是被拒),清审批超时计时器 + clearApprovalTimer(event.id) + return true + } + + case 'AiToolAutoApproved': { + // 会话级信任:经事件总线桥接弹本地 toast(composable 无组件上下文) + void emit('ai-tool-auto-approved-toast', { tool: event.tool, dir: event.dir }) + return true + } + + case 'AiApprovalRequired': { + clearStreamWatchdog(event.conversation_id || undefined) // 等用户审批,不计整流超时 + const info: AiToolCallInfo = { + id: event.id, + name: event.name, + args: event.args, + status: 'pending_approval', + kind: event.kind ?? 'risk', + // reason 必须写入 info,否则浮窗 ApprovalPopup 的 tc.reason 永远空(审批说明丢失) + reason: event.reason, + conversationId: event.conversation_id ?? state.activeConversationId ?? undefined, + } + state.pendingApprovals.push(info) + const tc = findToolCall(event.id) + if (tc) { + tc.status = 'pending_approval' + tc.reason = event.reason + // write_file 审批注入行级 diff(旧文件 vs 新内容),前端审批卡预览 + if (event.diff) tc.diff = event.diff + } + // 进入审批等待 → 取消该工具慢执行计时器(审批耗时由用户主导,非执行慢),再启动审批超时计时器 + clearToolSlowTimer(event.id) + startApprovalTimer(event.id, event.name, 'risk') + // 通知 ToolCardList 展开折叠组 + scroll(pending 卡落折叠分组不可见时) + void emit('ai-pending-arrived', { toolCallId: event.id }) + return true + } + + case 'AiApprovalResult': { + if (!event.approved) { + const tc = findToolCall(event.id) + if (tc) { + tc.status = 'rejected' + tc.result = t('ai.aiTool.rejectedHint') + } + state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id) + clearToolSlowTimer(event.id) + clearApprovalTimer(event.id) + } else { + // 审批通过 → 工具重回执行态,重启慢执行计时器;对称清审批超时计时器防到点误拒 + startToolSlowTimer(event.id, findToolCall(event.id)?.name || '') + clearApprovalTimer(event.id) + } + // 路径授权挂起的工具收到 ApprovalResult → 按 id 移除该条(数组化后只清这一条) + removePendingDirAuth(event.id) + return true + } + + default: + return false + } +} diff --git a/src/i18n/en/aiChat.ts b/src/i18n/en/aiChat.ts index 2d509bc..4b3635b 100644 --- a/src/i18n/en/aiChat.ts +++ b/src/i18n/en/aiChat.ts @@ -246,5 +246,22 @@ export default { ciNFailed: '{n} failed', ciNPending: '{n} pending', ciNPassed: '{n} passed', + + // ── MessageList token detail popover (per-fee breakdown: input/cache hit/output/reasoning) ── + tokenDetailTitle: 'Token usage details', + estimated: 'estimated', + tokenInLabel: 'Input (cache miss, full price)', + tokenCacheLabel: 'Cache hit (low price)', + tokenOutLabel: 'Output', + tokenReasonLabel: 'Reasoning', + tokenCacheRateLabel: 'Cache hit rate', + tokenModelLabel: 'Model', + tokenUsageNoteLabel: 'Usage note', + tokenUsageNote: 'prompt_tokens missing, shown as estimated', + + // ── TopBar goal/project context labels ── + goalsCount: 'Goals ({n})', + none: 'None', + projectLabel: "{'@'}Projects", }, } diff --git a/src/i18n/en/dependencyGraph.ts b/src/i18n/en/dependencyGraph.ts index 7ea678d..dc5c7c7 100644 --- a/src/i18n/en/dependencyGraph.ts +++ b/src/i18n/en/dependencyGraph.ts @@ -3,5 +3,25 @@ export default { title: 'Dependency Graph', empty: 'No modules yet, please add a module first', tabTitle: '🔗 Dependencies', + addDep: '+ Add dependency', + checkCycles: '🔍 Check cycles', + exportImage: '📷 Export', + fitContent: 'Fit content', + addDepTitle: 'Add project dependency', + sourceLabel: 'Source module (depends on)', + targetLabel: 'Target module (depended on)', + depTypeLabel: 'Dependency type', + typeLibrary: 'Library', + typeApi: 'API call', + typeMq: 'Message queue', + typeShared: 'Shared resource', + typeCustom: 'Custom', + confirm: 'Confirm', + cycleWarning: 'Detected {n} cycle nodes (highlighted in red)', + noCycle: 'No cyclic dependencies detected', + cycleError: 'Cycle detection failed', + deleteConfirm: 'Delete the dependency "{from} → {to}"? This cannot be undone.', + deleteSuccess: 'Dependency deleted', + deleteError: 'Failed to delete dependency', }, } diff --git a/src/i18n/zh-CN/aiChat.ts b/src/i18n/zh-CN/aiChat.ts index f27d2d8..9fd19de 100644 --- a/src/i18n/zh-CN/aiChat.ts +++ b/src/i18n/zh-CN/aiChat.ts @@ -247,5 +247,22 @@ export default { ciNFailed: '{n} 项失败', ciNPending: '{n} 项进行中', ciNPassed: '{n} 项通过', + + // ── MessageList token 详情面板(分计费:输入/缓存命中/输出/思考) ── + tokenDetailTitle: 'Token 用量详情', + estimated: '估算', + tokenInLabel: '输入(未命中,全价)', + tokenCacheLabel: '缓存命中(低价)', + tokenOutLabel: '输出', + tokenReasonLabel: '思考(reasoning)', + tokenCacheRateLabel: '缓存命中率', + tokenModelLabel: '模型', + tokenUsageNoteLabel: '用量说明', + tokenUsageNote: 'prompt_tokens 缺失,按估算展示', + + // ── TopBar 目标/项目上下文标签 ── + goalsCount: '目标({n})', + none: '无', + projectLabel: "{'@'}项目", }, } diff --git a/src/i18n/zh-CN/dependencyGraph.ts b/src/i18n/zh-CN/dependencyGraph.ts index 7f63201..5394c7f 100644 --- a/src/i18n/zh-CN/dependencyGraph.ts +++ b/src/i18n/zh-CN/dependencyGraph.ts @@ -3,5 +3,25 @@ export default { title: '依赖关系图', empty: '暂无工程,请先添加工程', tabTitle: '🔗 依赖图', + addDep: '+ 依赖', + checkCycles: '🔍 环检测', + exportImage: '📷 导出', + fitContent: '适应内容', + addDepTitle: '添加工程依赖', + sourceLabel: '源工程(依赖方)', + targetLabel: '目标工程(被依赖)', + depTypeLabel: '依赖类型', + typeLibrary: '类库', + typeApi: 'API 调用', + typeMq: '消息队列', + typeShared: '共享资源', + typeCustom: '自定义', + confirm: '确认', + cycleWarning: '检测到 {n} 个环节点(已红框高亮)', + noCycle: '未检测到环形依赖', + cycleError: '环检测失败', + deleteConfirm: '确定删除「{from} → {to}」的依赖吗?此操作不可撤销。', + deleteSuccess: '依赖已删除', + deleteError: '删除依赖失败', }, } diff --git a/src/stores/ai.ts b/src/stores/ai.ts index fce9644..abaa7b4 100644 --- a/src/stores/ai.ts +++ b/src/stores/ai.ts @@ -20,7 +20,7 @@ //! - state 为模块级单例,全应用共享同一引用 import { computed, reactive, watch } from 'vue' -import type { AiChatEvent, AiConversationSummary, AiMessage, AiProviderConfig, AiToolCallInfo, ContentPart, SkillInfo } from '@/api/types' +import type { AiChatEvent, AiConversationSummary, AiMessage, AiProviderConfig, AiToolCallInfo, ContentPart, MentionSpan, SkillInfo } from '@/api/types' /** * 单对话 messages 软上限(滚动淘汰)。 @@ -52,7 +52,7 @@ import { useAiEvents } from '@/composables/ai/useAiEvents' import { useAiStream } from '@/composables/ai/useAiStream' // 批4 双轨收口:getConvState 从 aiShared 取(下沉破环,避免 stores/ai↔useAiEvents 反向环)。 // F-09 per-conv streaming/currentText(DEC-07a 父④):convStreamStates Map + 辅助函数亦从 aiShared 取。 -import { getConvState, getConvStreamState, setConvStreaming, setConvCurrentText, __bindMessages } from '@/composables/ai/aiShared' +import { getConvState, getConvStreamState, setConvStreaming, setConvCurrentText, __bindMessages, __bindState } from '@/composables/ai/aiShared' import { useAiSend, initDrainQueueListener } from '@/composables/ai/useAiSend' import { useAiApproval } from '@/composables/ai/useAiApproval' import { useAiConversations } from '@/composables/ai/useAiConversations' @@ -101,7 +101,7 @@ const _stateBase: { detached: boolean docked: boolean skills: SkillInfo[] - queue: { text: string; skill?: string; enqueuedAt: number; parts?: ContentPart[]; conversationId?: string | null }[] + queue: { text: string; skill?: string; enqueuedAt: number; parts?: ContentPart[]; spans?: MentionSpan[]; conversationId?: string | null }[] agentRound: number /// 已完成工具调用数(本轮累计,AiToolCallCompleted 时 +1,AiAgentRound 时重置) completedTools: number @@ -203,6 +203,12 @@ try { // aiShared 模块初始化中(ES module 循环),延迟到微任务里重试 Promise.resolve().then(() => __bindMessages(() => state.messages)) } +// 注入 state getter 到 aiShared 共享层(flushCurrentText 惰性读 state,破 aiShared ↔ 本模块循环) +try { + __bindState(() => state) +} catch (e) { + Promise.resolve().then(() => __bindState(() => state)) +} // 旁注:此处不再保留 type-only 导出(AiChatEvent 等),因组件直接从 api/types import。 // 若有外部模块仍从本文件 import 这些类型,下方 re-export 兜底: