diff --git a/src/api/ai.ts b/src/api/ai.ts index 2a677d7..89ba333 100644 --- a/src/api/ai.ts +++ b/src/api/ai.ts @@ -93,6 +93,11 @@ export const aiApi = { return invoke('ai_continue_loop', { conversationId }) }, + /** 流中断探测:后端按真实状态返回 generating/resumed/finished,供看门狗超时自动续跑 */ + resumeInterrupted(conversationId: string): Promise<'generating' | 'resumed' | 'finished'> { + return invoke('ai_resume_interrupted', { conversationId }) + }, + /** * 停止 agentic 循环并走完成流程(达 max_iterations 暂停态点停止)。 * 后端 ai_stop_loop:复位 generating + emit AiCompleted(暂停前已 save)。 diff --git a/src/api/types.ts b/src/api/types.ts index 2f7c4dc..0ae662c 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -449,6 +449,11 @@ export type AiChatEvent = ({ * undefined/false=真实值。前端 MessageList 据此标注『估算』区分 estimated vs real。 */ is_estimated?: boolean + /** + * incomplete 场景细分(达上限自动收尾 vs 网络中断,前端据此区分提示文案): + * "max_rounds"=达最大轮次后端自动收尾;其他/缺省=网络中断等异常。 + */ + incomplete_reason?: string incomplete?: boolean; pinned_goals?: GoalEntry[] } | { type: 'AiError'; error: string; error_type?: AiErrorType diff --git a/src/composables/ai/aiShared.ts b/src/composables/ai/aiShared.ts index efbcad4..e7f8a0b 100644 --- a/src/composables/ai/aiShared.ts +++ b/src/composables/ai/aiShared.ts @@ -361,6 +361,78 @@ export function clearConvStreamState(convId: string | null | undefined): void { convStreamStates.delete(convId) } +// ── per-conv 待审批集合(审批挂起按 conversation_id 归会话,跨会话互不覆盖) ── +// +// 背景:state.pendingApprovals 是全局单数组,switchConversation 恢复挂起时对它整体替换 +// (= restored / = []),后台会话经事件到达的挂起会被切换动作整组覆盖丢失(全局单值覆盖语义)。 +// 改为双层结构:convPendingApprovals Map 是跨会话真相源(各会话独立数组,互不覆盖); +// state.pendingApprovals 退化为「当前活跃会话」的视图投影,组件消费方(徽标/浮层/审批浮窗) +// 仍只读该数组,零改动保持视图按当前会话展示。 +// +// 注:Map 本体不建响应式(仅切换/收尾时由本组函数读写,渲染只依赖 state.pendingApprovals)。 +export const convPendingApprovals = new Map() + +function pendingActiveKey(): string { + return getState()?.activeConversationId ?? '' +} + +/** + * 追加一条待审批:按 conversationId 归入所属会话集合(缺省归当前活跃会话); + * 属当前活跃会话时同步投影进 state.pendingApprovals。同 id 幂等,返回是否真正新增 + * (调用方据此决定是否做工具卡改写/计时器启动/滚动通知等一次性副作用)。 + */ +export function addPendingApproval(info: AiToolCallInfo): boolean { + const key = info.conversationId || pendingActiveKey() + let arr = convPendingApprovals.get(key) + if (!arr) { + arr = [] + convPendingApprovals.set(key, arr) + } + let added = false + if (!arr.some(p => p.id === info.id)) { + arr.push(info) + added = true + } + if (key === pendingActiveKey()) { + const view = getState()?.pendingApprovals + if (view && !view.some(p => p.id === info.id)) view.push(info) + } + return added +} + +/** 按 id 移除一条待审批(全会话集合扫描;审批结果不依赖当前视图是哪个会话) */ +export function removePendingApprovalById(id: string): void { + for (const [k, arr] of convPendingApprovals) { + if (arr.some(p => p.id === id)) { + convPendingApprovals.set(k, arr.filter(p => p.id !== id)) + } + } + const view = getState()?.pendingApprovals + if (view) { + const idx = view.findIndex(p => p.id === id) + if (idx >= 0) view.splice(idx, 1) + } +} + +/** + * 整体替换某会话的待审批集合(切换恢复后端审计挂起 / 新建清空用); + * 目标为当前活跃会话时同步投影进视图,否则只写集合不动视图(跨会话互不覆盖的关键)。 + */ +export function setConvPendingApprovals(convId: string | null | undefined, items: AiToolCallInfo[]): void { + if (!convId) return + convPendingApprovals.set(convId, items) + if (convId === pendingActiveKey()) { + const view = getState()?.pendingApprovals + if (view) view.splice(0, view.length, ...items) + } +} + +/** 仅删除某会话的集合项,不动视图数组(会话终止/删除时用;视图侧无法归属的旧项由调用方自定保留策略) */ +export function discardConvPendingApprovals(convId: string | null | undefined): void { + if (!convId) return + convPendingApprovals.delete(convId) +} + // ── 切换中缓冲 ── // // 背景:后台会话(A)生成中,用户点击切换到 A(switchConversation await 往返期间),A 的 @@ -385,6 +457,7 @@ export interface AiStateShape { streaming: boolean messages: AiMessage[] activeConversationId: string | null + pendingApprovals: AiToolCallInfo[] } let _stateGetter: (() => AiStateShape) | null = null @@ -494,3 +567,101 @@ export function clearAllToolSlowTimers(): void { _toolTimers.clear() _toolSlowNotified.clear() } + +// ── V8 per-conv 输入草稿 ── +// +// 背景:输入框草稿原先只在 ChatInput 组件内持单份 ref,切会话后 A 会话未发送的草稿直接 +// 显示在 B 会话输入框(串稿)。本 Map 是跨会话草稿的内存真相源,ChatInput 在 +// activeConversationId 变化时写旧会话文本、读新会话文本。内存态,不落 localStorage。 +// +// 放本模块的原因:沿用 convStreamStates 同款 per-conv reactive Map 模式集中管理, +// 消费方(ChatInput)与潜在的其他输入域消费方都可读,且不新增依赖环。 +export const convDrafts = reactive(new Map()) + +/** 读某会话草稿;无记录返回空串(调用方直接回填输入框即可) */ +export function getConvDraft(convId: string | null | undefined): string { + if (!convId) return '' + return convDrafts.get(convId) ?? '' +} + +/** 写某会话草稿;空串收敛删 Map 项(对齐 convStates idle 收敛语义,Map 不持陈旧空项) */ +export function setConvDraft(convId: string | null | undefined, text: string): void { + if (!convId) return + if (text) { + convDrafts.set(convId, text) + } else { + convDrafts.delete(convId) + } +} + +// ── I5 发送失败错误分类 ── +// +// 背景:发送失败错误串原样透出到 toast,后端/上游错误原文(429 Too Many Requests、 +// insufficient_quota、ECONNREFUSED 等)对用户不友好。按内容归类四类,消费方 +// (ChatInput 发送失败 toast)据类别映射 i18n 文案,原始错误串作为 {msg} 细节保留不丢。 +export type SendErrorKind = 'rate_limit' | 'quota' | 'network' | 'other' + +/** + * 按错误串内容分类。顺序敏感:429/限流先判(部分上游限流文案同时含网络相关词), + * 其次额度/余额,再网络/超时,兜底 other。 + */ +export function classifySendError(raw: string): SendErrorKind { + const s = String(raw ?? '') + if (/\b429\b|rate[_\s-]?limit|too\s*many\s*requests|限流|请求过于频繁/i.test(s)) return 'rate_limit' + if (/\b402\b|quota|insufficient|balance|billing|credit|额度|余额|欠费|配额/i.test(s)) return 'quota' + if (/timeout|timed?[_\s-]?out|network|connection|ECONN|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|\bDNS\b|超时|网络|连接/i.test(s)) return 'network' + return 'other' +} + +// ── S1 子代理完成卡(per-conv 归集,消费后端 AiAgentFinished 事件) ── +// +// 背景:spawn_agent 每个子循环结束经统一出口挂父会话 emit 一条 AiAgentFinished +// (status/summary/rounds/tokens,后端 sub_agent.rs;status 字面量对齐后端 SubAgentStatus::as_str)。 +// 本 Map 是跨会话完成卡的内存真相源,沿用 convStreamStates 同款 per-conv reactive Map 模式: +// 后台会话的完成事件也归集,切会话重进仍可见;MessageList 只读当前活跃会话的数组,不串视图。 +// 内存态不落库(与待审批/流式态同口径),会话删除即清。 +export type AgentFinishedStatus = 'completed' | 'truncated' | 'failed' + +export interface AgentFinishedCard { + /** 子会话 id(形态 {parent}::agent-{n}),同会话多子代理各占一卡按此键控 */ + agentId: string + summary: string + status: AgentFinishedStatus + rounds: number + tokens: number + finishedAt: number +} + +export const convAgentFinished = reactive(new Map()) + +/** 读某会话的完成卡数组;未出现过子代理返回空数组(渲染方零特判) */ +export function getConvAgentFinished(convId: string | null | undefined): AgentFinishedCard[] { + if (!convId) return [] + return convAgentFinished.get(convId) ?? [] +} + +/** + * 追加一条完成卡(按 agentId 幂等覆盖)。后端单一出口每子循环只 emit 一次,覆盖仅为防 + * 监听重放双卡,不改变「同一会话多子代理多卡共存」语义;追加序即完成序。 + */ +export function addConvAgentFinished( + convId: string | null | undefined, + card: Omit, +): void { + if (!convId || !card.agentId) return + let arr = convAgentFinished.get(convId) + if (!arr) { + arr = [] + convAgentFinished.set(convId, arr) + } + const withTs: AgentFinishedCard = { ...card, finishedAt: Date.now() } + const idx = arr.findIndex(c => c.agentId === card.agentId) + if (idx >= 0) arr.splice(idx, 1, withTs) + else arr.push(withTs) +} + +/** 清某会话完成卡(会话删除时调用,防 Map 持已删会话条目无限增长) */ +export function clearConvAgentFinished(convId: string | null | undefined): void { + if (!convId) return + convAgentFinished.delete(convId) +} diff --git a/src/composables/ai/useAiApproval.ts b/src/composables/ai/useAiApproval.ts index 9e8ce8c..b81ba06 100644 --- a/src/composables/ai/useAiApproval.ts +++ b/src/composables/ai/useAiApproval.ts @@ -18,7 +18,7 @@ import { aiApi } from '@/api' import { state } from '@/stores/ai' import { t } from '@/i18n/i18n-helpers' import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream' -import { findToolCall } from './aiShared' +import { findToolCall, removePendingApprovalById } from './aiShared' import { dispatchApprovalIPC } from './useToolApproval' /** @@ -90,8 +90,8 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?: tc.result = t('ai.aiTool.rejectedHint') } } - // 同步从 pendingApprovals 移除(不等后端 AiApprovalResult 事件来移,防 loading 卡死) - state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== toolCallId) + // 同步从待审批集合移除(含 per-conv 集合;不等后端 AiApprovalResult 事件来移,防 loading 卡死) + removePendingApprovalById(toolCallId) } catch (e) { // IPC 未送达:用户已点过按钮,清看门狗(避免 130s 后误触发假错误)再回滚到失败态 // TD-260621-03 per-conv:清审批 tc 所属会话的 timer(与上方 reset 同 conv,对称成对)。 diff --git a/src/composables/ai/useAiConversations.ts b/src/composables/ai/useAiConversations.ts index 87d3150..a91f0be 100644 --- a/src/composables/ai/useAiConversations.ts +++ b/src/composables/ai/useAiConversations.ts @@ -9,7 +9,7 @@ import { useAppSettingsStore } from '@/stores/appSettings' import { state } from '@/stores/ai' import { persistUiState } from './useAiPanel' import { setStreaming } from './streamingGuard' -import { nextMsgId, getConvState, clearConvStreamState, clearLastDelta, startApprovalTimer, clearAllApprovalTimers, switchingConvs, getConvStreamState, setConvCurrentText, notifyConversationChanged } from './aiShared' +import { nextMsgId, getConvState, clearConvStreamState, clearLastDelta, startApprovalTimer, clearAllApprovalTimers, switchingConvs, getConvStreamState, setConvCurrentText, notifyConversationChanged, setConvPendingApprovals, discardConvPendingApprovals } from './aiShared' import { t } from '@/i18n/i18n-helpers' import type { AiConversationDetail, AiMessage, AiToolCallInfo, ConvId, MessageId } from '@/api/types' @@ -107,7 +107,8 @@ async function newConversation() { loadMoreCursor.loading = false state.messages = [] state.currentText = '' - state.pendingApprovals = [] + // 新会话挂起为空:写其 per-conv 集合并投影视图(旧会话挂起留在集合里,切回时恢复,不被此清空覆盖) + setConvPendingApprovals(result.id, []) setStreaming(false, { reason: 'newConversation' }) // F-09 决策e(真并发):newConversation 对齐 switchConversation 并行语义——旧 conv 后台 loop 继续, // 不清 generatingConvs(保留旧 conv 生成态跟踪,侧栏显双会话生成,AiCompleted/AiError 按 @@ -296,6 +297,9 @@ export async function switchConversation(id: string, force = false) { const created = await aiApi.createConversation() if (mySwitchId !== _latestSwitchId) return void appSettings.set('df-ai-active-conv', created.id) + // 兜底置 activeId:此路径原只持久化不写 state.activeConversationId,旧(不存在)id 残留 + // 会让后续所有事件被 isCurrent 判为非当前而丢弃或误路由 + state.activeConversationId = created.id // 用新对话 id 重走后续逻辑 detail = { id: created.id as ConvId, title: null, messages: '[]' } // 新建对话无历史,load_more 游标复位 @@ -304,6 +308,8 @@ export async function switchConversation(id: string, force = false) { loadMoreCursor.convId = created.id loadMoreCursor.loading = false state.messages = [] + // 新会话挂起为空(同 newConversation:清视图投影并登记空集合,旧会话挂起保留在集合里) + setConvPendingApprovals(created.id, []) notifyConversationChanged() scheduleConversationsRefresh() return @@ -416,7 +422,8 @@ export async function switchConversation(id: string, force = false) { // 含工具卡,或目标会话生成中(可能在途产生待审批)才需要恢复挂起。纯文本/无工具历史直接置空。 const hasToolCalls = state.messages.some(m => (m.toolCalls ?? []).length > 0) if (!targetGen && !hasToolCalls) { - state.pendingApprovals = [] + // 纯文本会话挂起为空:写集合并投影视图(其他会话的挂起集合不受影响) + setConvPendingApprovals(id, []) } else { try { const pending = await aiApi.pendingToolCalls(id) @@ -459,11 +466,14 @@ export async function switchConversation(id: string, force = false) { } } } - state.pendingApprovals = restored + // per-conv 合并语义:整体替换目标会话自己的挂起集合;其他会话的集合不动, + // 后台会话经事件到达的挂起不被切换动作覆盖丢失 + setConvPendingApprovals(id, restored) } else { - state.pendingApprovals = [] + setConvPendingApprovals(id, []) } } catch { + // 挂起查询 IPC 失败:后端真值未知,仅清视图投影(集合保留,切走再切回时重新查询校正) state.pendingApprovals = [] } } @@ -508,9 +518,10 @@ async function deleteConversation(id: string) { ) // 注意:op 返回 void,成功=undefined、失败=null(不能 `!ok`——undefined 也 falsy 会误判失败) if (ok === null) return // 失败已回滚 + 推气泡,保持原视图 - // 清该会话的 stream state 与 delta 去重记录,防 per-conv Map 无限增长 + // 清该会话的 stream state 与 delta 去重记录,防 per-conv Map 无限增长;挂起集合同步作废 clearConvStreamState(id) clearLastDelta(id) + discardConvPendingApprovals(id) if (wasActive) { void appSettings.remove('df-ai-active-conv') // 删除活跃会话 → load_more 游标复位(无 active 视图) diff --git a/src/composables/ai/useAiEvents.ts b/src/composables/ai/useAiEvents.ts index 9e93c73..1166e4b 100644 --- a/src/composables/ai/useAiEvents.ts +++ b/src/composables/ai/useAiEvents.ts @@ -15,8 +15,8 @@ import { listen, emit } from '@tauri-apps/api/event' import { aiApi } from '@/api' import { useAppSettingsStore } from '@/stores/appSettings' import { state } from '@/stores/ai' -import { nextMsgId, setConvState, convStates, switchingConvs, getConvStreamState, setConvCurrentText, setConvStreaming, clearConvStreamState, clearAllApprovalTimers, clearAllToolSlowTimers, flushCurrentText, friendlyError, notifyConversationChanged } from './aiShared' -import { resetStreamWatchdog, clearAllStreamWatchdogs } from './useAiStream' +import { nextMsgId, setConvState, convStates, switchingConvs, getConvStreamState, setConvCurrentText, setConvStreaming, clearConvStreamState, clearAllApprovalTimers, clearAllToolSlowTimers, flushCurrentText, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals, addConvAgentFinished, clearConvAgentFinished, type AgentFinishedStatus } from './aiShared' +import { resetStreamWatchdog, clearAllStreamWatchdogs, clearResumeAttempts } from './useAiStream' import { setStreaming } from './streamingGuard' import { scheduleConversationsRefresh } from './useAiConversations' import { handleStreamingEvent } from './useAiStreamingEvents' @@ -44,6 +44,13 @@ const appSettings = useAppSettingsStore() // 模块级 Set 复用,避免每事件(delta/token 高频)新建数组字面量做 includes。 const NO_RESET_WATCHDOG = new Set(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached', 'AiDirAuthRequired', 'AiHelpRequired']) +/** 全局审批类事件:跨会话放行,不因非当前会话被过滤。审批挂起是等用户决策的跨会话资产, + * 丢弃后用户在切回该会话前没有任何感知与处理入口;归口仍按 conversation_id 写 per-conv + * 待审批集合(aiShared.addPendingApproval),不污染当前视图。 */ +function isGlobalApprovalEvent(event: AiChatEvent): boolean { + return event.type === 'AiApprovalRequired' || event.type === 'AiDirAuthRequired' +} + /** L2 状态机域:消费 AiConvStateChanged,写 convStates(per-conv conv_state 真相源)。 * 返回 true=已处理;无 convId 无法路由时忽略(防误写全局态)。 */ function handleConvStateEvent(event: AiChatEvent): boolean { @@ -54,6 +61,34 @@ function handleConvStateEvent(event: AiChatEvent): boolean { return true } +/** S1 子代理完成域:消费 AiAgentFinished,写 per-conv 完成卡集合(aiShared.convAgentFinished)。 + * AiChatEvent 联合类型(types.ts 归口)暂未收编该事件,经局部 shape 窄化取字段,不改 types。 + * 归集挂在 isCurrent 守卫之前(同 AiConvStateChanged 范式):后台会话的子代理结束也要落卡, + * 切回仍可见;「不串当前视图」由消费侧只读当前会话数组天然保证,无需在此按活跃会话过滤。 */ +function handleAgentFinishedEvent(event: AiChatEvent): boolean { + if ((event as { type?: string }).type !== 'AiAgentFinished') return false + const ev = event as unknown as { + agent_id: string + summary: string + status: AgentFinishedStatus + rounds: number + tokens: number + } + const convId = event.conversation_id + if (!convId || !ev.agent_id) return true + addConvAgentFinished(convId, { + agentId: ev.agent_id, + summary: ev.summary, + status: ev.status, + rounds: ev.rounds, + tokens: ev.tokens, + }) + // 子代理执行是该会话的真实活跃信号(父 loop 在 spawn_agent 内等待):此前该事件穿透到 + // 活跃事件分支会重置看门狗,现提前归集后显式补上,保持既有不误判停滞的语义。 + resetStreamWatchdog(convId) + return true +} + /** 跨端用户消息域:miniapp→桌面 user 气泡同步(仅处理 AiUserMessage)。 * 去重:末条已是同 content 的 user 则跳过(防本地乐观 push + 事件回灌双气泡)。 */ function handleUserMessageEvent(event: AiChatEvent): boolean { @@ -91,9 +126,12 @@ export function handleEvent(event: AiChatEvent) { if (handleConvStateEvent(event)) return // 跨端用户消息同步:在 isCurrent 守卫之前处理(补 user 气泡信号,非流式活跃事件) if (handleUserMessageEvent(event)) return - // 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误/求助时刷新侧边栏 + // S1 子代理完成卡归集:在 isCurrent 守卫之前处理(per-conv 落卡,后台会话不丢卡) + if (handleAgentFinishedEvent(event)) return + // 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误/求助时刷新侧边栏; + // 全局审批类事件例外(isGlobalApprovalEvent),放行到下方按域分派 const isCurrent = !convId || convId === state.activeConversationId - if (!isCurrent) { + if (!isCurrent && !isGlobalApprovalEvent(event)) { if (event.type === 'AiCompleted' || event.type === 'AiError' || event.type === 'AiHelpRequired') { // 非当前会话终止:收敛会话状态(删 Map 项回不在生成) convStates.delete(convId || '') @@ -101,6 +139,12 @@ export function handleEvent(event: AiChatEvent) { // switchConversation 恢复进 currentText,下轮发送的防御 flush 又把它写进上一轮气泡, // 把已完成回复覆盖为残片;回复正文以切换时 DB 加载为准,流式态无需保留。 clearConvStreamState(convId || '') + // 对齐前台 cleanupTerminatedConversation 的 per-conv 收敛:清 delta 去重记录(残留记录 + // 会在切回后把下一轮首个与上轮末个相同内容的 delta 误判重复丢弃)、待审批集合(终止即作废, + // 切回时由 pendingToolCalls 从后端审计表按需重建)、自动续跑计数(终止即清,不带入新轮) + clearLastDelta(convId || '') + discardConvPendingApprovals(convId || '') + clearResumeAttempts(convId || undefined) // FE1:后台会话终止只刷新列表(防抖调度,避免与 AiCompleted 收尾的 notify 双拉)。 scheduleConversationsRefresh() // 后台会话终止也触发队列续发/清队(完成续发;错误清该会话队列) @@ -159,6 +203,10 @@ export async function startListener() { setStreaming(false, { convId: deleted, reason: 'conv-deleted' }) convStates.delete(deleted) } + // S1:会话删除连带清其完成卡(Map 不持已删会话条目;存在即清,不限当前视图) + if (deleted) { + clearConvAgentFinished(deleted) + } }) _unlistenApprovalClear = await listen('ai-approval-clear-timers', () => { clearAllApprovalTimers() }) emit('ai-client-ready', { lastConvId: state.activeConversationId }) diff --git a/src/composables/ai/useAiLifecycleEvents.ts b/src/composables/ai/useAiLifecycleEvents.ts index 2c619c4..1e3104f 100644 --- a/src/composables/ai/useAiLifecycleEvents.ts +++ b/src/composables/ai/useAiLifecycleEvents.ts @@ -9,9 +9,9 @@ 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 { nextMsgId, convStates, clearApprovalTimer, flushCurrentText, clearAllToolSlowTimers, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals } from './aiShared' import { clearTextIdleTimer, pendingMaxRounds, pendingHelp, pendingDirAuths } from './useAiPendingState' -import { clearStreamWatchdog } from './useAiStream' +import { clearStreamWatchdog, clearResumeAttempts } from './useAiStream' import { setStreaming } from './streamingGuard' import type { AiChatEvent, AiMessage, MessageId } from '@/api/types' @@ -52,6 +52,12 @@ function isShowTokenUsage(): boolean { */ function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | 'AiError' | 'AiHelpRequired') { clearStreamWatchdog(convId || undefined) + // 正常收尾路径清该会话的 delta 去重记录:flushCurrentText 只清活跃会话的,这里按 convId + // 显式清一遍兜住 convId 与 active 不一致或事件缺 convId 的时序,防残留记录误丢下轮同内容 delta + clearLastDelta(convId || undefined) + // 正常收尾清自动续跑计数:计数只服务于单轮内连续超时的风暴防护,终态后必须归零, + // 否则陈旧计数会让下一轮的正常超时提前触发强制收敛(清零只放收尾路径,不在发消息时清) + clearResumeAttempts(convId || undefined) clearAllToolSlowTimers() // 整轮/错误/求助结束清全部工具慢执行计时器与已提示集合 clearTextIdleTimer() // 清文本空闲定时器 + 置 textIdle=true(整轮结束不该有活跃信号残留) flushCurrentText() @@ -86,6 +92,9 @@ function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | ' 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 挂起集合作废(仅删集合项,视图保留上面无法归属的旧项; + // 切回该会话时由 pendingToolCalls 从后端审计表按需重建) + discardConvPendingApprovals(convId || undefined) // 清分离窗口生成态快照(per-conv key,清本会话;兼容旧单 key) if (convId) { localStorage.removeItem(`df-ai-gen-${convId}`) @@ -102,12 +111,14 @@ export function handleLifecycleEvent(event: AiChatEvent): boolean { 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),前端追加系统提示气泡 + // 流中途失败保文:后端 emit AiCompleted(incomplete=true),前端追加系统提示气泡。 + // incomplete_reason 细分文案:"max_rounds"=达上限自动收尾(非网络中断,语义不同), + // 其他/缺省=网络中断等异常走原提示。 if (event.incomplete) { state.messages.push({ id: `incomplete-${nextMsgId()}` as MessageId, role: 'assistant', - content: t('ai.responseIncomplete'), + content: event.incomplete_reason === 'max_rounds' ? t('ai.responseIncompleteMaxRounds') : t('ai.responseIncomplete'), timestamp: Date.now(), }) } diff --git a/src/composables/ai/useAiPanel.ts b/src/composables/ai/useAiPanel.ts index 05feb74..aa74f6e 100644 --- a/src/composables/ai/useAiPanel.ts +++ b/src/composables/ai/useAiPanel.ts @@ -14,7 +14,7 @@ import { useAppSettingsStore } from '@/stores/appSettings' import { state } from '@/stores/ai' import { setStreaming } from './streamingGuard' import { syncHighRiskTools } from './useToolApproval' -import { nextMsgId } from './aiShared' +import { nextMsgId, setConvPendingApprovals } from './aiShared' import { t } from '@/i18n/i18n-helpers' import type { AiMessage, AiProviderConfig } from '@/api/types' @@ -188,7 +188,12 @@ async function clearChat() { await aiApi.clearChat() state.messages = [] state.currentText = '' - state.pendingApprovals = [] + // 清空的是当前会话:其 per-conv 挂起集合同步清(其他会话集合不动) + if (state.activeConversationId) { + setConvPendingApprovals(state.activeConversationId, []) + } else { + state.pendingApprovals = [] + } setStreaming(false, { reason: 'clearChat' }) } catch (e) { // A2-B9(G3.2):清空失败需反馈,原裸 await 失败静默(异常进 unhandledrejection,用户无感)。 diff --git a/src/composables/ai/useAiSend.ts b/src/composables/ai/useAiSend.ts index 3beafdf..b87c804 100644 --- a/src/composables/ai/useAiSend.ts +++ b/src/composables/ai/useAiSend.ts @@ -21,7 +21,7 @@ import { aiApi } from '@/api' import { state } from '@/stores/ai' import type { ContentPart, AiMessage, MentionSpan, MessageId } from '@/api/types' import { t } from '@/i18n/i18n-helpers' -import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream' +import { resetStreamWatchdog, clearStreamWatchdog, clearResumeAttempts } from './useAiStream' import { setStreaming } from './streamingGuard' import { startListener } from './useAiEvents' import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang, convStates, setConvCurrentText, setConvStreaming, flushCurrentText } from './aiShared' @@ -166,7 +166,8 @@ async function regenerate() { // 只清当前会话的排队消息,不误伤其他会话 const regenConvId = state.activeConversationId state.queue = state.queue.filter(q => q.conversationId !== regenConvId) - resetStreamWatchdog() + // 会话态走 per-conv 键(与 doSend 一致),不再落 legacy 单 timer fallback + resetStreamWatchdog(regenConvId || undefined) await startListener() const lang = resolveAiLang() @@ -174,7 +175,7 @@ async function regenerate() { if (!convId) { // 无活跃对话:回滚占位,报错 setStreaming(false, { reason: 'regenerate-no-conv' }) - clearStreamWatchdog() + clearStreamWatchdog(regenConvId || undefined) state.messages = state.messages.filter(m => m.id !== aiMsgId) throw new Error(t('aiChat.regenerateUnavailable')) } @@ -183,7 +184,7 @@ async function regenerate() { } catch (e) { // IPC 失败:回滚 streaming + 移除占位气泡(旧回复后端已弹但前端先弹了,保持弹后态) setStreaming(false, { convId, reason: 'regenerate-ipc-fail' }) - clearStreamWatchdog() + clearStreamWatchdog(convId || undefined) state.messages = state.messages.filter(m => m.id !== aiMsgId) throw e } @@ -238,14 +239,15 @@ async function editMessage(newMessage: string) { // 只清当前会话的排队消息,不误伤其他会话 const editConvId = state.activeConversationId state.queue = state.queue.filter(q => q.conversationId !== editConvId) - resetStreamWatchdog() + // 会话态走 per-conv 键(与 doSend 一致),不再落 legacy 单 timer fallback + resetStreamWatchdog(editConvId || undefined) await startListener() const lang = resolveAiLang() const convId = state.activeConversationId if (!convId) { setStreaming(false, { reason: 'editMessage-no-conv' }) - clearStreamWatchdog() + clearStreamWatchdog(editConvId || undefined) state.messages = state.messages.filter(m => m.id !== aiMsgId) throw new Error(t('aiChat.editNotLastUser')) } @@ -254,7 +256,7 @@ async function editMessage(newMessage: string) { } catch (e) { // IPC 失败:回滚 streaming + 移除占位气泡(被截断的旧回复后端已标 truncated,前端已移除) setStreaming(false, { convId, reason: 'editMessage-ipc-fail' }) - clearStreamWatchdog() + clearStreamWatchdog(convId || undefined) state.messages = state.messages.filter(m => m.id !== aiMsgId) throw e } @@ -395,7 +397,7 @@ export async function tryForceSend(confirmFn: (msg: string) => Promise, state.queue.splice(idx, 1) // 清看门狗:doSend 内 force_send 路径会重设 streaming=true 并重启看门狗, // 此处无需前置 false(前置 false 会触发消息列表 watch 清流式块再重建,产生瞬态抖动) - clearStreamWatchdog() + clearStreamWatchdog(targetConv || undefined) try { await sendMessage(first.text, first.skill, true, first.parts, first.spans, first.conversationId) return true @@ -411,7 +413,7 @@ export async function tryForceSend(confirmFn: (msg: string) => Promise, conversationId: first.conversationId, }) setStreaming(false, { convId: targetConv ?? state.activeConversationId, reason: 'tryForceSend-fail' }) - clearStreamWatchdog() + clearStreamWatchdog(targetConv || undefined) console.error('[AI] tryForceSend force_send 失败,消息已回填队首:', e) return false } @@ -509,7 +511,10 @@ async function stopChat() { // 又会因 flushCurrentText 的非流式守卫跳过回填,不在此处 flush 则部分回复随停止丢失。 flushCurrentText() setStreaming(false, { convId: state.activeConversationId, reason: 'stopChat' }) // 本地先复位,不依赖后端 AiCompleted(审批态看门狗已 clear,竞态丢失则卡死) - clearStreamWatchdog() // 清残留看门狗 + // 停止属正常收尾:按目标会话清 per-conv 看门狗与自动续跑计数(计数只放收尾路径清, + // 不在发消息时清,否则会掩盖单轮内的真实重试次数) + clearStreamWatchdog(state.activeConversationId || undefined) + clearResumeAttempts(state.activeConversationId || undefined) // 决策 a(逐条续发):不再清队列,保留供 AiCompleted 链式触发 drainQueue 续发。 // 后端 agentic/mod.rs emit AiCompleted 注释明确「保证前端收事件时后端已可接下一条,队列续发不被拒」; // 当前未完成回复已入库(agentic/mod.rs save_conversation)保留为截断回复。 @@ -519,7 +524,7 @@ async function stopChat() { } catch (e) { // stop 信号未送达后端:重启看门狗(后端继续生成时活跃事件会重置它,真 hang 则 130s 后兜底收尾), // 避免后端仍在跑却无任何恢复路径(原 clearStreamWatchdog 已把兜底关掉)。 - resetStreamWatchdog() + resetStreamWatchdog(state.activeConversationId || undefined) const errMsg = e instanceof Error ? e.message : String(e) state.messages.push({ id: `stop-err-${nextMsgId()}`, diff --git a/src/composables/ai/useAiStream.ts b/src/composables/ai/useAiStream.ts index 58f1938..b3a5a7d 100644 --- a/src/composables/ai/useAiStream.ts +++ b/src/composables/ai/useAiStream.ts @@ -6,14 +6,16 @@ //! 耦合说明: //! - sendMessage 启动生成时 resetStreamWatchdog() 启动计时 //! - handleEvent 每个活跃事件(delta/工具/新轮/审批结果)重置;审批等待/完成/错误 clear -//! - 超时回调 onStreamTimeout 直接改 state 并补一条错误消息 +//! - 超时回调 onStreamTimeout 先探测后端真实状态:loop 活着/已续跑则静默重挂看门狗, +//! 仅后端已收敛/探测失败/连续超时达上限才强制收敛+补气泡 //! //! 依赖:nextMsgId 取自 aiShared(原从 useAiEvents 取,构成 useAiEvents ↔ useAiStream //! 循环依赖;下沉到 aiShared 后本模块不再 import useAiEvents,环消除) import { state } from '@/stores/ai' +import { aiApi } from '@/api/ai' import { t } from '@/i18n/i18n-helpers' -import { nextMsgId, convStates, flushCurrentText } from './aiShared' +import { nextMsgId, convStates, flushCurrentText, clearLastDelta, getConvState, getConvStreamState, clearConvStreamState } from './aiShared' import type { MessageId } from '@/api/types' import { forceResetStreaming } from './streamingGuard' import { emit } from '@tauri-apps/api/event' @@ -32,28 +34,45 @@ export const STREAM_TIMEOUT_MS = 90000 // 背景(F-09 多会话并发):原单 timer 下,A 会话 delta 重置计时器会顶掉 B 会话已挂起的 timer, // 或 A 超时连累 B 的 generatingConvs(全清)。per-conv Map 后,各会话独立计时,互不顶替/连累。 // -// 向后兼容(避撞 F-09 禁动域):convId 可选。禁动域 useAiSend.ts/useAiApproval.ts 的 14 处调用 -// 不传 convId,走 _legacyWatchdog fallback(模块级单 timer,行为对齐旧版);本批域 useAiEvents.ts -// 的调用点(已知 convId)传 convId 走 per-conv Map。两路并存,等 F-09 统一迁移调用点后可删 fallback。 +// 向后兼容(双轨迁移收尾):convId 可选。useAiSend/useAiEvents/useAiApproval 的调用点均已带 +// 会话上下文传 convId 走 per-conv Map(无活跃会话时传 undefined 才落 legacy 单 timer fallback)。 // // per-conv 路径与 legacy 路径互不干扰:convId 路径只动 Map,legacy 路径只动 _legacyWatchdog。 -// 实际并发场景:F-09 已让 useAiEvents 按 conversation_id 路由事件(handleEvent:546 isCurrent 判定), +// 实际并发场景:F-09 已让 useAiEvents 按 conversation_id 路由事件(handleEvent isCurrent 判定), // 非 current conv 的事件不触发 reset/clear(reset/clear 在 isCurrent 分支内调用),故 per-conv // 路径天然只跟踪当前展示会话的活跃 timer,跨会话切换时旧 conv 的 timer 由其 AiCompleted/AiError 精确清。 const _watchdogTimers = new Map>() -// 禁动域 fallback 单 timer(useAiSend/useAiApproval 无 convId 调用走此,行为对齐旧版)。 +// 禁动域 fallback 单 timer(调用点无活跃会话上下文传 undefined 时走此,行为对齐旧版)。 let _legacyWatchdog: ReturnType | null = null +// 看门狗超时自动续跑计数:同一会话连续超时且探测到后端活着/已续跑的次数(达上限降级强制收敛)。 +// 真实活跃事件到达 resetStreamWatchdog 即清零(链路已活,计数失去意义)。 +const _resumeAttempts = new Map() +// 自动续跑上限:超限说明前端事件链路大概率已死(后端活着也收不到事件),静默等无意义 +const MAX_AUTO_RESUME = 3 + /** - * 看门狗超时回调:收尾 streaming 态并补错误消息(走 forceResetStreaming 兜底复位, - * 对齐 [[devflow-generating-statemachine]])。 + * 清某会话的自动续跑计数(正常收尾 completed/error/stop 路径调用)。 + * 计数只服务于「单轮内连续超时」的风暴防护,终态后必须归零,防陈旧计数让下一轮的首次超时 + * 被误判为连续超时而提前强制收敛;刻意不提供发消息时清零的入口(发消息清会掩盖真实重试次数)。 + */ +export function clearResumeAttempts(convId?: string | null): void { + if (convId) _resumeAttempts.delete(convId) +} + +/** + * 强制收敛:清 streaming/convStates/watchdog + 补提示气泡(原 onStreamTimeout 主体, + * 走 forceResetStreaming 兜底复位,对齐 [[devflow-generating-statemachine]])。 * * TD-260621-01 per-conv:携带 convId 时,forceResetStreaming(reason, convId) 经 setStreaming * 仅复位该会话 streaming 态。批4 双轨收口后 generatingConvs 已退役,convStates 项由各会话 * 收尾事件精确管理(on/off 后端均 emit 收尾态),本回调不再触碰生成态集合。convId 为空 * (legacy fallback 路径)时全局兜底,行为对齐旧版。 + * 补气泡仅限当前视图会话:后台会话静默收敛(状态照常清理,提示气泡落进无关视图属错位), + * 见下方实现处守卫注释。 + * contentOverride 覆盖补气泡文案(后端已收敛的轻收敛提示用),缺省按 hasCompletedTool 区分中断文案。 */ -export function onStreamTimeout(convId?: string) { +function forceConverge(convId?: string, contentOverride?: string) { // BUG-2026-07-17 根治性重构:状态机必须能自我收敛。 // // 原问题:streaming=true 写在多个入口(doSend/regenerate/edit/L1入队), @@ -67,6 +86,24 @@ export function onStreamTimeout(convId?: string) { // 不管 stillGenerating 检测(即使已经 false 也再清理一次,完备包底)。 console.warn(`[AI-Watchdog] 超时触发,强制收敛状态${convId ? `(convId=${convId})` : '(全局)'}`) + // per-conv 触发守卫(仅 convId 路径,legacy 全局兜底不设防):会话已不存在(已从列表删除 + // 且非当前视图)或已收敛(conv_state 无非终态且 per-conv streaming 已 false)时,真实收尾/ + // 删除路径早已清理过,迟到或重复触发的 timer 只清残留计时,直接返回。不设防的后果: + // 对已删会话收敛会在当前视图推出幽灵错误气泡;对已收敛会话会连环补重复的错误气泡。 + if (convId) { + const convExists = convId === state.activeConversationId + || state.conversations.some(c => c.id === convId) + const alreadyConverged = !getConvState(convId) && !(getConvStreamState(convId)?.streaming ?? false) + if (!convExists || alreadyConverged) { + clearStreamWatchdog(convId) + _resumeAttempts.delete(convId) + clearLastDelta(convId) + clearConvStreamState(convId) + convStates.delete(convId) + return + } + } + // 先把已流出的部分回复回填气泡再清态:forceResetStreaming 会清 currentText,streaming // 翻 false 又清流式分块,不 flush 则屏幕上已显示的部分正文一并蒸发(内容空白消失)。 // flushCurrentText 只认当前活跃会话的流式态,仅本会话是当前视图时才 flush。 @@ -82,6 +119,9 @@ export function onStreamTimeout(convId?: string) { } } clearStreamWatchdog(convId) + // 异常中断路径同步清该会话的 delta 去重记录:后台会话超时不走 flushCurrentText(仅当前视图 + // 会 flush),不显式清则残留记录会在该会话下一轮把首个与上轮末个相同内容的 delta 误判重复丢弃 + if (convId) clearLastDelta(convId) // 广播清审批计时器(流式已中断,审批计时器无意义) void emit('ai-approval-clear-timers', {}) @@ -100,29 +140,90 @@ export function onStreamTimeout(convId?: string) { if (hasCompletedTool) break } - // 补一条错误气泡(仅在实际还在生成态时补,避免重复补) - // 这里直接补——forceResetStreaming 已重置状态,不会重复触发 - const content = hasCompletedTool - ? t('ai.streamInterruptedAfterTool') - : t('ai.streamInterrupted') - state.messages.push({ - id: `timeout-${nextMsgId()}` as MessageId, - role: 'assistant', - content, - isError: true, - timestamp: Date.now(), - }) + // 补一条错误气泡,仅限当前视图会话:后台会话静默收敛(状态照常清理,提示气泡落进 + // 无关视图属错位)。上方 per-conv 守卫已拦掉已删除/已收敛会话,此处再按视图过滤一次。 + if (!convId || convId === state.activeConversationId) { + const content = contentOverride ?? (hasCompletedTool ? t('ai.streamInterruptedAfterTool') : t('ai.streamInterrupted')) + state.messages.push({ + id: `timeout-${nextMsgId()}` as MessageId, + role: 'assistant', + content, + isError: true, + timestamp: Date.now(), + }) + } +} + +/** + * 看门狗超时回调:先探测后端真实状态(ai_resume_interrupted),能续跑则静默重挂看门狗, + * 不能才强制收敛——治"后端 loop 静默死亡/前端误判一超时就任务失败"。 + * + * - 无 convId(legacy fallback):无法探测,直接 forceConverge(行为对齐旧版)。 + * - 连续自动续跑达 MAX_AUTO_RESUME 次:不再探测,清计数降级 forceConverge(防后端活但 + * 前端事件链路已死时无限静默等)。 + * - 探测 generating/resumed:后端 loop 活着/已自动续跑,前端生成态不动(不 flush 不复位, + * 后端续跑后心跳/事件会自然喂狗),仅重挂看门狗;计数 +1。先重挂再写计数—— + * resetStreamWatchdog 入口按"真实活跃事件到达"语义清计数,探测重挂不算活跃事件。 + * - 探测 finished:后端已正常收敛仅收尾事件丢失,清计数 forceConverge 补轻收敛文案。 + * - 探测抛错(会话不存在/后端异常):清计数降级 forceConverge(原中断行为含原错误气泡)。 + * + * 防重入:超时 timer 一次性且已触发,探测 await 期间该 conv 无 armed timer, + * 本回调不会被重复触发,无需加锁。 + */ +export async function onStreamTimeout(convId?: string) { + if (!convId) { + forceConverge() + return + } + + const attempts = _resumeAttempts.get(convId) ?? 0 + if (attempts >= MAX_AUTO_RESUME) { + _resumeAttempts.delete(convId) + forceConverge(convId) + return + } + + try { + const probe = await aiApi.resumeInterrupted(convId) + if (probe === 'finished') { + _resumeAttempts.delete(convId) + forceConverge(convId, t('ai.streamFinishedConverged')) + return + } + // generating/resumed:后端活着或已续跑,静默重挂看门狗继续等 + // 首次续跑补一条非错误提示气泡(自动恢复对用户可见,防 90s+ 无反馈期间误手动点继续; + // 后续重挂静默,防刷屏)。手动点继续有 is_active 前置校验兜底,无双跑风险。 + if (attempts === 0 && convId === state.activeConversationId) { + // 仅当前活跃会话补「自动恢复」气泡:后台会话的气泡落进无关视图属错位,静默重挂即可 + state.messages.push({ + id: `auto-resume-${nextMsgId()}` as MessageId, + role: 'assistant', + content: t('ai.streamAutoResuming'), + timestamp: Date.now(), + }) + } + resetStreamWatchdog(convId) + _resumeAttempts.set(convId, attempts + 1) + console.warn(`[AI-Watchdog] 超时探测后端=${probe},重挂看门狗继续等(convId=${convId},自动续跑第${attempts + 1}次)`) + } catch (err) { + _resumeAttempts.delete(convId) + console.warn(`[AI-Watchdog] 超时探测失败,降级强制收敛(convId=${convId})`, err) + forceConverge(convId) + } } /** * 启动/重置看门狗(活跃事件或 sendMessage 时调用)。 * * TD-260621-01 per-conv:传 convId → 走 per-conv Map(arm 前 clear 该 conv 旧 timer,防重入堆积); - * 省略 convId(禁动域 useAiSend/useAiApproval 调用)→ 走 _legacyWatchdog fallback(单 timer,行为对齐旧版)。 + * 省略 convId(调用点无活跃会话上下文)→ 走 _legacyWatchdog fallback(单 timer,行为对齐旧版)。 * setTimeout 回调闭包捕获 convId,到期 onStreamTimeout(convId) 精确清该 conv 态。 */ export function resetStreamWatchdog(convId?: string) { if (convId) { + // 真实活跃事件到达即证明前后端链路活了,自动续跑计数清零(本入口语义=链路活跃; + // 超时探测路径的静默重挂在 onStreamTimeout 里先调本函数再写计数,不受此清零影响)。 + _resumeAttempts.delete(convId) const old = _watchdogTimers.get(convId) if (old) clearTimeout(old) const timer = setTimeout(() => onStreamTimeout(convId), STREAM_TIMEOUT_MS) diff --git a/src/composables/ai/useAiStreamingEvents.ts b/src/composables/ai/useAiStreamingEvents.ts index 16a53d9..df564b7 100644 --- a/src/composables/ai/useAiStreamingEvents.ts +++ b/src/composables/ai/useAiStreamingEvents.ts @@ -1,6 +1,8 @@ //! 流式事件域 — handleStreamingEvent //! -//! 处理:AiTextDelta / AiAgentRound / AiHeartbeat / AiStreamRetry / AiMaxRoundsReached / AiDirAuthRequired。 +//! 处理:AiTextDelta / AiAgentRound / AiHeartbeat / AiStreamRetry / AiDirAuthRequired。 +//! AiMaxRoundsReached 不处理:后端达 max 已改 AiCompleted(incomplete=true) 自动完成(暂停态 +//! 语义已废),该事件后端零 emit;pendingMaxRounds/MaxRoundsCard 续停操作卡为遗留死链待清理。 //! 依赖方向:本模块 → aiShared / useAiPendingState(叶子)/ useAiStream,不导入任何同级子 composable。 //! _lastDelta 去重 Map 与 flushCurrentText 因跨域共用已下沉 aiShared,这里仅经 accessor 读写。 @@ -8,8 +10,8 @@ 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 { nextMsgId, findToolCall, startApprovalTimer, clearToolSlowTimer, getLastDelta, setLastDelta, flushCurrentText, addPendingApproval } from './aiShared' +import { resetTextIdleTimer, clearTextIdleTimer, pendingDirAuths, pushPendingDirAuth } from './useAiPendingState' import { clearStreamWatchdog } from './useAiStream' import type { AiChatEvent, AiMessage, MessageId } from '@/api/types' @@ -21,7 +23,7 @@ function isUnifiedApproval(): boolean { return appSettings.get('df-ai-unified-approval', true) } -/** streaming 域:流式文本累积/新轮/心跳/重试/max 轮暂停。返回 true=已处理。 */ +/** streaming 域:流式文本累积/新轮/心跳/重试。返回 true=已处理。 */ export function handleStreamingEvent(event: AiChatEvent): boolean { switch (event.type) { case 'AiTextDelta': { @@ -84,33 +86,25 @@ export function handleStreamingEvent(event: AiChatEvent): boolean { 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 防残留卡)。 + // 统一审批模型开:path 挂起归一进待审批集合(kind='path'),ToolCard 内联审批。 + // per-conv 归会话追加(同 id 幂等):跨会话事件互不覆盖;首次到达才做工具卡改写等一次性副作用。 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 }), - }) + const added = addPendingApproval({ + 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 }), + }) + if (added) { // 同步改对应工具卡的 tc,让 ToolCard 显 path 类审批按钮(once/always/deny) const tc = findToolCall(event.id) if (tc) { diff --git a/src/composables/ai/useAiToolEvents.ts b/src/composables/ai/useAiToolEvents.ts index b49b79c..abdaf29 100644 --- a/src/composables/ai/useAiToolEvents.ts +++ b/src/composables/ai/useAiToolEvents.ts @@ -6,7 +6,7 @@ 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 { findToolCall, clearApprovalTimer, startApprovalTimer, startToolSlowTimer, clearToolSlowTimer, addPendingApproval, removePendingApprovalById } from './aiShared' import { removePendingDirAuth } from './useAiPendingState' import { clearStreamWatchdog } from './useAiStream' import type { AiChatEvent, AiToolCallInfo } from '@/api/types' @@ -43,7 +43,8 @@ export function handleToolEvent(event: AiChatEvent): boolean { tc.result = event.result tc.completedAt = Date.now() } - state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id) + // 移除待审批(含 per-conv 集合,后台会话集合里的同 id 挂起一并清) + removePendingApprovalById(event.id) clearToolSlowTimer(event.id) // 工具结束(无论审批通过后执行还是被拒),清审批超时计时器 clearApprovalTimer(event.id) @@ -68,19 +69,21 @@ export function handleToolEvent(event: AiChatEvent): boolean { 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 + // per-conv 归会话追加(同 id 幂等):跨会话事件不再互相覆盖;首次到达才做后续副作用 + if (addPendingApproval(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 }) } - // 进入审批等待 → 取消该工具慢执行计时器(审批耗时由用户主导,非执行慢),再启动审批超时计时器 - clearToolSlowTimer(event.id) - startApprovalTimer(event.id, event.name, 'risk') - // 通知 ToolCardList 展开折叠组 + scroll(pending 卡落折叠分组不可见时) - void emit('ai-pending-arrived', { toolCallId: event.id }) return true } @@ -91,7 +94,8 @@ export function handleToolEvent(event: AiChatEvent): boolean { tc.status = 'rejected' tc.result = t('ai.aiTool.rejectedHint') } - state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id) + // 移除待审批(含 per-conv 集合) + removePendingApprovalById(event.id) clearToolSlowTimer(event.id) clearApprovalTimer(event.id) } else { diff --git a/src/stores/project.ts b/src/stores/project.ts index 43697e4..96c7327 100644 --- a/src/stores/project.ts +++ b/src/stores/project.ts @@ -38,7 +38,7 @@ function createStore() { // // F-260621-02(P1 status 下沉):新增 _activeTaskStatus 追踪当前 status 筛选,数据变更 // 联动刷新时一并下沉到后端 query(保 B-29「store.tasks 反映当前筛选」契约, - // 避免刷新把已选 status 冲掉)。 + // 避免刷新把已选 status 冲掉)。仅单选下沉;多选拼接串见 buildActiveTaskQuery 内注释。 let _activeTaskProject: string | undefined = undefined let _activeTaskStatus: string | undefined = undefined // Tasks 视图搜索关键字登记(数据变更联动刷新时一并下沉 keyword,避免冲掉搜索结果) @@ -57,7 +57,11 @@ function createStore() { if (_activeTaskProject === undefined) return undefined // 视图未挂载,不触发 const query: TaskQuery = {} if (_activeTaskProject !== 'all') query.project_id = _activeTaskProject - if (_activeTaskStatus && _activeTaskStatus !== 'all') query.status = _activeTaskStatus + // UX走查 2026-10-03(P1):后端 status 单值语义,多选拼接串("todo,done")按单值精确匹配 + // 必返空集,外部变更联动刷新会把可见行冲空。多选时不传 status,退化为按项目全量拉取 + + // 前端过滤(对齐 Tasks.vue buildTaskQuery 口径,status 筛选纯前端);单选仍下沉后端。 + const isMultiStatus = _activeTaskStatus != null && _activeTaskStatus.includes(',') + if (_activeTaskStatus && _activeTaskStatus !== 'all' && !isMultiStatus) query.status = _activeTaskStatus if (_activeTaskKeyword) query.keyword = _activeTaskKeyword return query }