新增: 后台任务卡徽章+轮次投影(convRounds), 可恢复分区(parked), reattachToMain i18n 补键(#12)
This commit is contained in:
@@ -208,6 +208,26 @@ export function clearAllApprovalTimers(): void {
|
||||
_approvalTimers.clear()
|
||||
}
|
||||
|
||||
// ── #14 任务式委托:per-conv 轮次追踪 convRounds(#14 桌面任务卡数据源) ──
|
||||
//
|
||||
// 语义:后台在途会话的 agentic 轮次(AiAgentRound 事件写入口),桌面任务卡显示
|
||||
// 「进行中 N 轮」。模块级 reactive Map(对齐 convStates 模式),conv 终态(AiCompleted/
|
||||
// AiError)时清除——任务卡消费方据「isGenerating && 有轮次记录」渲染在途项。
|
||||
export const convRounds = reactive(new Map<string, number>())
|
||||
|
||||
/** 写入 conv 轮次(AiAgentRound case 唯一写入口,取 max 防事件乱序回退) */
|
||||
export function setConvRound(convId: string | null | undefined, round: number): void {
|
||||
if (!convId) return
|
||||
const cur = convRounds.get(convId) ?? 0
|
||||
if (round > cur) convRounds.set(convId, round)
|
||||
}
|
||||
|
||||
/** 清除 conv 轮次(终态收敛:与 setConvState idle 删除同语义) */
|
||||
export function clearConvRound(convId: string | null | undefined): void {
|
||||
if (!convId) return
|
||||
convRounds.delete(convId)
|
||||
}
|
||||
|
||||
// ── L2 统一状态机 convStates(下沉自 useAiEvents,批4 双轨收口) ──
|
||||
//
|
||||
// 下沉原因:批4 退役 generatingConvs(bool 轨)后,stores/ai.ts(isGenerating)/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! AI 事件监听与分发(分派器)— startListener / stopListener / handleEvent
|
||||
//! AI 事件监听与分发(分派器)— startListener(租约)/ ensureListenerStarted(幂等确保)/ handleEvent
|
||||
//!
|
||||
//! 拆分(批次 A-B1):14 个事件 case 按域拆到 3 个叶子 handler:
|
||||
//! - useAiStreamingEvents.handleStreamingEvent(流式)
|
||||
@@ -16,7 +16,7 @@ import { t } from '@/i18n/i18n-helpers'
|
||||
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, clearLastDelta, discardConvPendingApprovals, addConvAgentFinished, clearConvAgentFinished, type AgentFinishedStatus } from './aiShared'
|
||||
import { nextMsgId, setConvState, convStates, switchingConvs, getConvStreamState, setConvCurrentText, setConvStreaming, clearConvStreamState, clearAllApprovalTimers, clearAllToolSlowTimers, flushCurrentText, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals, addConvAgentFinished, clearConvAgentFinished, setConvRound, clearConvRound, type AgentFinishedStatus } from './aiShared'
|
||||
import { resetStreamWatchdog, clearAllStreamWatchdogs, clearResumeAttempts } from './useAiStream'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
import { scheduleConversationsRefresh, activeConvKvKey } from './useAiConversations'
|
||||
@@ -40,6 +40,9 @@ let _unlistenApprovalClear: (() => void) | null = null
|
||||
// startListener 并发去重:防 onMounted 与 sendMessage 首发竞态下重复注册 listener
|
||||
// (两回调写同一 state.currentText → 流式文字双倍)
|
||||
let _startPromise: Promise<void> | null = null
|
||||
// 租约计数(所有权模型,2026-10-05 根本修):仅组件挂载级 startListener 借/还,
|
||||
// send 等运行时路径走 ensureListenerStarted 不计数——计数只由对称生命周期驱动,不漂移。
|
||||
let _leaseCount = 0
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
@@ -219,6 +222,8 @@ export function handleEvent(event: AiChatEvent) {
|
||||
clearResumeAttempts(convId || undefined)
|
||||
// 终态即清进度快照(事件即逝态,会话已终止不留陈旧「进行中」)
|
||||
pendingTaskProgress.delete(convId || '')
|
||||
// #14 桌面任务卡:终态即清轮次(任务卡据 convRounds 渲染在途项,终止不留陈旧项)
|
||||
clearConvRound(convId || '')
|
||||
// FE1:后台会话终止只刷新列表(防抖调度,避免与 AiCompleted 收尾的 notify 双拉)。
|
||||
scheduleConversationsRefresh()
|
||||
// 后台会话终止也触发队列续发/清队(完成续发;错误清该会话队列)
|
||||
@@ -238,6 +243,10 @@ export function handleEvent(event: AiChatEvent) {
|
||||
// 切换完成后由渲染层续显,避免切换窗口丢可见内容
|
||||
setConvStreaming(convId, true)
|
||||
setConvCurrentText(convId, (getConvStreamState(convId)?.currentText ?? '') + (event.delta ?? ''))
|
||||
} else if (event.type === 'AiAgentRound' && convId && event.round > 0) {
|
||||
// #14 桌面任务卡:后台会话轮次追踪(isCurrent=false 原本直接丢弃本事件,
|
||||
// 任务卡需要后台轮次显示「进行中 N 轮」;取 max 防事件乱序回退)
|
||||
setConvRound(convId, event.round)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -260,8 +269,30 @@ export function handleEvent(event: AiChatEvent) {
|
||||
void handleLifecycleEvent(event)
|
||||
}
|
||||
|
||||
/** 启动事件监听(幂等 + 并发去重,onMounted 与 sendMessage 首发竞态不会重复注册) */
|
||||
export async function startListener() {
|
||||
/** 启动事件监听(幂等确保注册,无所有权——send 等运行时路径调用,不计入租约)。
|
||||
* 所有权模型(2026-10-05 根本修):
|
||||
* - ensureListenerStarted:仅保证 listener 存在,谁都可调、任意次,不持租约;
|
||||
* - startListener:组件挂载用,返回 release 租约句柄,unmount 时调用归还;
|
||||
* 最后一份租约归还才真拆 listener + 清全局计时器。
|
||||
* 两者分离根治「send 裸调 start 无配对 stop → 裸计数只涨不跌 → 永不真拆」的结构性漂移:
|
||||
* 计数只由组件挂载/卸载这一对对称生命周期驱动,不变量可静态审计。 */
|
||||
export async function startListener(): Promise<() => void> {
|
||||
await ensureListenerStarted()
|
||||
_leaseCount++
|
||||
let released = false
|
||||
return () => {
|
||||
if (released) {
|
||||
// 双重归还是调用方 bug(组件卸载钩子重复执行等),dev 下即刻暴露防静默漂移
|
||||
if (import.meta.env.DEV) console.error('[useAiEvents] listener 租约重复归还(调用方 bug)')
|
||||
return
|
||||
}
|
||||
released = true
|
||||
releaseLease()
|
||||
}
|
||||
}
|
||||
|
||||
/** 幂等确保 listener 已注册(并发去重;不持租约,不触发拆卸)。 */
|
||||
export async function ensureListenerStarted(): Promise<void> {
|
||||
if (_unlistenAiEvent && _unlistenConvChanged && _unlistenApprovalClear) {
|
||||
return
|
||||
}
|
||||
@@ -304,8 +335,14 @@ export async function startListener() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 停止事件监听(卸载时调用,释放后端 listener + 清看门狗/计时器,防回调改已卸载组件 state) */
|
||||
export function stopListener() {
|
||||
/** 归还一份租约:计数归零才真拆(多实例挂载时先退出的实例不误拆他人在用的 listener)。 */
|
||||
function releaseLease() {
|
||||
if (_leaseCount === 0) {
|
||||
if (import.meta.env.DEV) console.error('[useAiEvents] 租约下溢(归还多于借出,调用方 bug)')
|
||||
return
|
||||
}
|
||||
_leaseCount--
|
||||
if (_leaseCount > 0) return
|
||||
_unlistenAiEvent?.()
|
||||
_unlistenConvChanged?.()
|
||||
_unlistenApprovalClear?.()
|
||||
@@ -323,7 +360,7 @@ export function stopListener() {
|
||||
export function useAiEvents() {
|
||||
return {
|
||||
startListener,
|
||||
stopListener,
|
||||
ensureListenerStarted,
|
||||
handleEvent,
|
||||
flushCurrentText,
|
||||
friendlyError,
|
||||
|
||||
@@ -9,7 +9,7 @@ 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, clearLastDelta, discardConvPendingApprovals, notifyQueueCleared } from './aiShared'
|
||||
import { nextMsgId, convStates, clearApprovalTimer, flushCurrentText, clearAllToolSlowTimers, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals, notifyQueueCleared, clearConvRound } from './aiShared'
|
||||
import { clearTextIdleTimer, pendingMaxRounds, pendingHelp, pendingDirAuths, pendingTaskParked, pendingTaskProgress } from './useAiPendingState'
|
||||
import { clearStreamWatchdog, clearResumeAttempts } from './useAiStream'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
@@ -74,6 +74,8 @@ function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | '
|
||||
}
|
||||
setStreaming(false, { convId: convId || null, reason })
|
||||
state.agentRound = 0 // 结束/中断/求助,复位轮次(隐藏进度条)
|
||||
// #14 桌面任务卡:终态即清 per-conv 轮次(前台会话终态收敛,与后台分支 clearConvRound 对齐)
|
||||
clearConvRound(convId)
|
||||
// convStates 收敛:AiError 保留 error 项(停止按钮显「重试」态),其余删项回 idle
|
||||
if (reason === 'AiError') {
|
||||
if (convId) convStates.set(convId, 'error')
|
||||
|
||||
@@ -23,7 +23,7 @@ import type { ContentPart, AiMessage, MentionSpan, MessageId } from '@/api/types
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import { resetStreamWatchdog, clearStreamWatchdog, clearResumeAttempts } from './useAiStream'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
import { startListener } from './useAiEvents'
|
||||
import { ensureListenerStarted } from './useAiEvents'
|
||||
import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang, convStates, setConvCurrentText, setConvStreaming, flushCurrentText, notifyQueueCleared, classifySendError, type SendErrorKind } from './aiShared'
|
||||
|
||||
/// UX-P2-A5:drainQueue 失败气泡的错误分类文案映射(分类逻辑 aiShared.classifySendError,
|
||||
@@ -117,7 +117,7 @@ async function doSend(text: string, skill?: string, force = false, parts?: Conte
|
||||
setConvCurrentText(targetConvId, '')
|
||||
}
|
||||
|
||||
await startListener()
|
||||
await ensureListenerStarted()
|
||||
const lang = resolveAiLang()
|
||||
try {
|
||||
// force=true 走 force_send IPC(复位残留 generating),否则走正常 send
|
||||
@@ -183,7 +183,7 @@ async function regenerate() {
|
||||
// 会话态走 per-conv 键(与 doSend 一致),不再落 legacy 单 timer fallback
|
||||
resetStreamWatchdog(regenConvId || undefined)
|
||||
|
||||
await startListener()
|
||||
await ensureListenerStarted()
|
||||
const lang = resolveAiLang()
|
||||
const convId = state.activeConversationId
|
||||
if (!convId) {
|
||||
@@ -259,7 +259,7 @@ async function editMessage(newMessage: string) {
|
||||
// 会话态走 per-conv 键(与 doSend 一致),不再落 legacy 单 timer fallback
|
||||
resetStreamWatchdog(editConvId || undefined)
|
||||
|
||||
await startListener()
|
||||
await ensureListenerStarted()
|
||||
const lang = resolveAiLang()
|
||||
const convId = state.activeConversationId
|
||||
if (!convId) {
|
||||
|
||||
@@ -10,7 +10,7 @@ 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, addPendingApproval } from './aiShared'
|
||||
import { nextMsgId, findToolCall, startApprovalTimer, clearToolSlowTimer, getLastDelta, setLastDelta, flushCurrentText, addPendingApproval, setConvRound } from './aiShared'
|
||||
import { resetTextIdleTimer, clearTextIdleTimer, pendingDirAuths, pushPendingDirAuth } from './useAiPendingState'
|
||||
import { clearStreamWatchdog } from './useAiStream'
|
||||
import type { AiChatEvent, AiMessage, MessageId } from '@/api/types'
|
||||
@@ -57,6 +57,10 @@ export function handleStreamingEvent(event: AiChatEvent): boolean {
|
||||
})
|
||||
// round>0 才更新进度条(round=0 是审批通过后「隔开新一轮」的占位,实际轮次未推进,防闪烁)
|
||||
if (event.round > 0) state.agentRound = event.round
|
||||
// #14 桌面任务卡:per-conv 轮次追踪,前台会话在此写(取 max 防事件乱序回退);
|
||||
// 后台会话在 useAiEvents 非 current 分支的 return 前补写(彼处 AiAgentRound
|
||||
// 原本直接穿透丢弃,任务卡需要后台轮次才能显示「进行中 N 轮」)
|
||||
if (event.round > 0) setConvRound(event.conversation_id || state.activeConversationId, event.round)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import { nextMsgId, getConvState, convStates } from './aiShared'
|
||||
import type { MessageId } from '@/api/types'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
import { persistUiState } from './useAiPanel'
|
||||
import { stopListener } from './useAiEvents'
|
||||
|
||||
|
||||
// ── 主窗口事件跟随 unlistener ──
|
||||
let _unlistenMove: (() => void) | null = null
|
||||
@@ -200,19 +200,19 @@ async function detachPanel(convId?: string) {
|
||||
stopFollowMain()
|
||||
})
|
||||
state.detached = true
|
||||
// 时序加固:detached 置 true 后立即停主窗口 listener,不依赖 v-if → AiChat onBeforeUnmount 的
|
||||
// 异步传播。消除 detached=true 到 AiChat 实际卸载的窗口期(reattach 重叠期/HMR/极速 mount-unmount
|
||||
// 竞态下,onBeforeUnmount 可能延迟,旧 listener 残留会与分离窗口新 listener 并存竞态)。
|
||||
// stopListener 同步释放后端 listener + 清看门狗,分离窗口 resumeInDetached 起自己的 startListener。
|
||||
// 时序注(2026-10-05 租约模型):原此处同步调 stopListener() 提前拆主窗 listener,消除
|
||||
// detached=true 到 AiChat 实际卸载的窗口期竞态——但同步拆全局单例正是双实例互踩根源,
|
||||
// 租约模型下删除:主窗 v-if 立即失效 → onBeforeUnmount 归还租约(最后一份才真拆)。
|
||||
// 竞态窗口期(毫秒级)旧 listener 多活无害:事件按 conversation_id 路由,分离窗口独立注册
|
||||
// (BUG-260624-02 已实证审批事件无缺口),主窗残听仅多收一次同事件,无串台。
|
||||
//
|
||||
// BUG-260624-02 核验结论(2026-08-15):分离窗口事件监听独立存在,审批事件无缺口。
|
||||
// 链路:本窗口 url '#/ai-detached' → AiDetached.vue 挂 AiChat(detached) → AiChat onMounted
|
||||
// 调 store.startListener()(独立 JS context 各自注册);后端 ai-chat-event 为 app 级广播
|
||||
// (emit 到全部 webview),故审批类事件(工具 pending/审批结果)分离窗口自身可收。
|
||||
// 前端派生的 'ai-pending-arrived'(useAiStreamingEvents/useAiToolEvents emit)亦为全局广播,
|
||||
// 分离窗口 ToolCardList 的 listener 同样收到。主窗口 listener 在此 stopListener 释清,
|
||||
// 分离窗口 ToolCardList 的 listener 同样收到。主窗 listener 由租约归还(见上方时序注)释清,
|
||||
// 不与分离窗口并存竞态。审批超时计时器(aiShared)同样随 listener 在接管窗口内重建。
|
||||
stopListener()
|
||||
}
|
||||
|
||||
/** 关闭分离窗口并回到内嵌面板(主窗口侧调用)。
|
||||
@@ -234,9 +234,9 @@ async function reattachPanel() {
|
||||
persistUiState()
|
||||
// V11c: 清分离窗活跃会话 KV(分离窗已全关,残留无意义;下次 detachPanel 会重新种子)
|
||||
void useAppSettingsStore().remove('df-ai-active-conv-detached')
|
||||
// 时序加固:reattach 关闭分离窗口后,显式停分离窗口残留 listener(分离窗口 close 异步,
|
||||
// onBeforeUnmount stopListener 可能延迟),防主面板重新挂载 startListener 时与旧 listener 重叠竞态。
|
||||
stopListener()
|
||||
// 时序注(租约模型):原此处显式 stopListener 防「分离窗旧 listener 与主面板重挂载重叠」——
|
||||
// 但分离窗 listener 活在分离窗自己的 JS context,主窗此处拆的只是本 context 已归还空的
|
||||
// listener(跨 webview 隔离,原调用实为 no-op 式心理安慰)。租约归还链已完整覆盖,删除。
|
||||
// 清所有 per-conv 快照(分离窗口已全关,快照无消费方)
|
||||
cleanupAllDetachedSnapshots()
|
||||
}
|
||||
@@ -348,9 +348,8 @@ async function resumeInDetached(convId: string | null) {
|
||||
async function closeDetachedWindow() {
|
||||
state.docked = false
|
||||
stopFollowMain()
|
||||
// 时序加固:关闭分离窗口前先停 listener,防 close 异步传播期间 onBeforeUnmount 延迟、
|
||||
// 主面板重挂载 startListener 与旧分离窗口 listener 短暂并存(极速 mount-unmount/HMR 竞态)。
|
||||
stopListener()
|
||||
// 时序注(租约模型):原此处关闭前先 stopListener 防竞态——同上,租约归还链
|
||||
// (本窗口 AiChat onBeforeUnmount)已完整覆盖,强拆会使租约计数与实际状态失配,删除。
|
||||
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const currentWin = getCurrentWebviewWindow()
|
||||
const lbl = currentWin.label
|
||||
|
||||
Reference in New Issue
Block a user