新增: 后台任务卡徽章+轮次投影(convRounds), 可恢复分区(parked), reattachToMain i18n 补键(#12)

This commit is contained in:
lxy
2026-10-06 12:13:33 +08:00
parent 70d6e98f82
commit 04db8c0d3b
11 changed files with 250 additions and 32 deletions
+2
View File
@@ -781,6 +781,8 @@ export interface AiConversationSummary {
completion_tokens?: number | null completion_tokens?: number | null
created_at: string created_at: string
updated_at: string updated_at: string
/** V49 断点续跑:running=进行中 / parked=中断可恢复 / null=无任务(2026-10-06 读侧透出) */
loop_state?: 'running' | 'parked' | null
} }
/** G1 目标钉扎:单条目标记录(含状态追踪) */ /** G1 目标钉扎:单条目标记录(含状态追踪) */
+145 -5
View File
@@ -151,6 +151,30 @@
<span class="ai-task-progress-fill" :style="{ width: taskProgressPct + '%' }"></span> <span class="ai-task-progress-fill" :style="{ width: taskProgressPct + '%' }"></span>
</span> </span>
</div> </div>
<!-- #14 桌面任务卡:后台在途任务(多会话并发生成中,点击切回) -->
<div v-if="runningTasks.length || recoverableTasks.length" class="ai-running-tasks-inline">
<button type="button" class="ai-running-tasks-badge ai-tool-badge" :class="{ 'is-active': runningTasksExpanded }" @click="togglePanel('running')" :title="$t('aiChat.runningTasks')">
<span class="ai-running-tasks-pulse"></span>
{{ runningTasks.length }}
</button>
<Transition name="ai-pop">
<div v-if="runningTasksExpanded" class="ai-running-tasks-list">
<button v-for="task in runningTasks" :key="task.id" type="button" class="ai-running-task-item" @click="switchToRunning(task.id)">
<span class="ai-running-task-dot"></span>
<span class="ai-running-task-title">{{ task.title }}</span>
<span class="ai-running-task-round">{{ t('aiChat.runningRounds', { n: task.rounds }) }}</span>
</button>
<template v-if="recoverableTasks.length">
<div class="ai-running-tasks-sep"></div>
<button v-for="task in recoverableTasks" :key="task.id" type="button" class="ai-running-task-item" @click="switchToRunning(task.id)">
<span class="ai-running-task-dot ai-running-task-dot--parked"></span>
<span class="ai-running-task-title">{{ task.title }}</span>
<span class="ai-running-task-round ai-running-task-round--parked">{{ t('aiChat.recoverableTask') }}</span>
</button>
</template>
</div>
</Transition>
</div>
<!-- 目标计数 --> <!-- 目标计数 -->
<div v-if="goals.length" class="ai-goals-inline"> <div v-if="goals.length" class="ai-goals-inline">
<button type="button" class="ai-goals-inline-badge ai-tool-badge" :class="{ 'is-active': goalsExpanded }" @click="togglePanel('goals')" :title="$t('aiChat.viewGoals')"> <button type="button" class="ai-goals-inline-badge ai-tool-badge" :class="{ 'is-active': goalsExpanded }" @click="togglePanel('goals')" :title="$t('aiChat.viewGoals')">
@@ -258,6 +282,8 @@ import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai' import { useAiStore } from '../../stores/ai'
import { aiApi } from '../../api/ai' import { aiApi } from '../../api/ai'
import { pendingTaskProgress } from '../../composables/ai/useAiPendingState' import { pendingTaskProgress } from '../../composables/ai/useAiPendingState'
import { convRounds } from '../../composables/ai/aiShared'
import { switchConversation } from '../../composables/ai/useAiConversations'
import type { AiMessage, ModelConfig, GoalEntry } from '../../api/types' import type { AiMessage, ModelConfig, GoalEntry } from '../../api/types'
defineProps<{ defineProps<{
@@ -414,6 +440,36 @@ const taskProgress = computed(() => {
const agenticProgressVisible = computed(() => const agenticProgressVisible = computed(() =>
store.state.streaming && store.state.agentRound > 0, store.state.streaming && store.state.agentRound > 0,
) )
// ── #14 桌面任务卡:后台在途任务列表(多会话并发,切走后可见可切回) ──
// 数据源=convRounds(轮次追踪,事件写)× store.isGenerating(状态,同源 convStates),
// 排除当前会话(自身进度已有 agentic 实时进度条,不重复)。响应式:convRounds 是
// aiShared 模块级 reactive Map,computed 内遍历即建依赖。
const runningTasksExpanded = ref(false)
const runningTasks = computed(() => {
const items: { id: string; title: string; rounds: number }[] = []
for (const [id, rounds] of convRounds) {
if (id === store.state.activeConversationId) continue
if (!store.isGenerating(id)) continue
const conv = store.state.conversations.find(c => c.id === id)
items.push({ id, title: conv?.title || t('aiChat.untitledConversation'), rounds })
}
return items
})
// V49 可恢复入口(断点续跑读侧):conversations 中 loop_state='parked' 的会话(重启中断待续),
// 独立分区展示在任务面板下方,点击切回(继续与否由用户在会话内决定,不做自动续跑)
const recoverableTasks = computed(() => {
return store.state.conversations
.filter(c => c.loop_state === 'parked' && c.id !== store.state.activeConversationId)
.slice(0, 10)
.map(c => ({ id: c.id, title: c.title || t('aiChat.untitledConversation') }))
})
async function switchToRunning(id: string) {
runningTasksExpanded.value = false
if (id !== store.state.activeConversationId) {
await switchConversation(id)
}
}
const taskProgressText = computed(() => { const taskProgressText = computed(() => {
const p = taskProgress.value const p = taskProgress.value
if (!p) return '' if (!p) return ''
@@ -531,11 +587,11 @@ interface ContextInfo {
} }
const contextExpanded = ref(false) const contextExpanded = ref(false)
/** 弹出面板互斥:goals/history/summary/context 四个 badge 面板,点一个收起其他,再点当前切换。 */ /** 弹出面板互斥:goals/history/summary/context/running 五个 badge 面板,点一个收起其他,再点当前切换。 */
function togglePanel(panel: 'goals' | 'history' | 'summary' | 'context') { function togglePanel(panel: 'goals' | 'history' | 'summary' | 'context' | 'running') {
const refs = { goals: goalsExpanded, history: historyExpanded, summary: summaryExpanded, context: contextExpanded } const refs = { goals: goalsExpanded, history: historyExpanded, summary: summaryExpanded, context: contextExpanded, running: runningTasksExpanded }
const willOpen = !refs[panel].value const willOpen = !refs[panel].value
goalsExpanded.value = historyExpanded.value = summaryExpanded.value = contextExpanded.value = false goalsExpanded.value = historyExpanded.value = summaryExpanded.value = contextExpanded.value = runningTasksExpanded.value = false
refs[panel].value = willOpen refs[panel].value = willOpen
} }
@@ -754,7 +810,7 @@ onBeforeUnmount(() => {
background: transparent; background: transparent;
color: var(--df-text-dim); color: var(--df-text-dim);
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: background 0.15s, color 0.15s;
} }
.ai-btn-icon:hover { .ai-btn-icon:hover {
background: var(--df-bg-card); background: var(--df-bg-card);
@@ -954,6 +1010,90 @@ onBeforeUnmount(() => {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
} }
/* ── #14 桌面任务卡:后台在途任务徽章+面板 ── */
.ai-running-tasks-inline {
position: relative;
display: inline-flex;
align-items: center;
}
.ai-running-tasks-badge {
display: inline-flex;
align-items: center;
gap: 4px;
}
.ai-running-tasks-pulse {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--df-success, #34c77b);
animation: ai-running-pulse 2s ease-in-out infinite;
}
@keyframes ai-running-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.ai-running-tasks-list {
position: absolute;
left: auto; right: 0;
top: 100%;
z-index: 100;
min-width: 260px;
max-width: 400px;
max-height: 300px;
overflow-y: auto;
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 4px 0;
}
.ai-running-task-item {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 5px 10px;
border: none;
background: none;
cursor: pointer;
text-align: left;
font-size: 12px;
color: var(--df-text);
}
.ai-running-task-item:hover {
background: var(--df-hover, rgba(128,128,128,0.08));
}
.ai-running-task-dot {
flex-shrink: 0;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--df-success, #34c77b);
}
.ai-running-task-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-running-task-round {
flex-shrink: 0;
font-size: 11px;
color: var(--df-text-secondary, #888);
}
/* V49 可恢复分区:琥珀点+「可恢复」徽标,与进行中(绿)区分 */
.ai-running-tasks-sep {
height: 1px;
margin: 4px 8px;
background: var(--df-border);
}
.ai-running-task-dot--parked {
background: var(--df-warning, #e6a23c);
}
.ai-running-task-round--parked {
color: var(--df-warning, #e6a23c);
}
.ai-history-inline-list { .ai-history-inline-list {
position: absolute; position: absolute;
left: auto; right: 0; left: auto; right: 0;
+20
View File
@@ -208,6 +208,26 @@ export function clearAllApprovalTimers(): void {
_approvalTimers.clear() _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 双轨收口) ── // ── L2 统一状态机 convStates(下沉自 useAiEvents,批4 双轨收口) ──
// //
// 下沉原因:批4 退役 generatingConvs(bool 轨)后,stores/ai.ts(isGenerating)/ // 下沉原因:批4 退役 generatingConvs(bool 轨)后,stores/ai.ts(isGenerating)/
+44 -7
View File
@@ -1,4 +1,4 @@
//! AI 事件监听与分发(分派器)— startListener / stopListener / handleEvent //! AI 事件监听与分发(分派器)— startListener(租约)/ ensureListenerStarted(幂等确保)/ handleEvent
//! //!
//! 拆分(批次 A-B1):14 个事件 case 按域拆到 3 个叶子 handler: //! 拆分(批次 A-B1):14 个事件 case 按域拆到 3 个叶子 handler:
//! - useAiStreamingEvents.handleStreamingEvent(流式) //! - useAiStreamingEvents.handleStreamingEvent(流式)
@@ -16,7 +16,7 @@ import { t } from '@/i18n/i18n-helpers'
import { aiApi } from '@/api' import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings' import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai' 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 { resetStreamWatchdog, clearAllStreamWatchdogs, clearResumeAttempts } from './useAiStream'
import { setStreaming } from './streamingGuard' import { setStreaming } from './streamingGuard'
import { scheduleConversationsRefresh, activeConvKvKey } from './useAiConversations' import { scheduleConversationsRefresh, activeConvKvKey } from './useAiConversations'
@@ -40,6 +40,9 @@ let _unlistenApprovalClear: (() => void) | null = null
// startListener 并发去重:防 onMounted 与 sendMessage 首发竞态下重复注册 listener // startListener 并发去重:防 onMounted 与 sendMessage 首发竞态下重复注册 listener
// (两回调写同一 state.currentText → 流式文字双倍) // (两回调写同一 state.currentText → 流式文字双倍)
let _startPromise: Promise<void> | null = null let _startPromise: Promise<void> | null = null
// 租约计数(所有权模型,2026-10-05 根本修):仅组件挂载级 startListener 借/还,
// send 等运行时路径走 ensureListenerStarted 不计数——计数只由对称生命周期驱动,不漂移。
let _leaseCount = 0
const appSettings = useAppSettingsStore() const appSettings = useAppSettingsStore()
@@ -219,6 +222,8 @@ export function handleEvent(event: AiChatEvent) {
clearResumeAttempts(convId || undefined) clearResumeAttempts(convId || undefined)
// 终态即清进度快照(事件即逝态,会话已终止不留陈旧「进行中」) // 终态即清进度快照(事件即逝态,会话已终止不留陈旧「进行中」)
pendingTaskProgress.delete(convId || '') pendingTaskProgress.delete(convId || '')
// #14 桌面任务卡:终态即清轮次(任务卡据 convRounds 渲染在途项,终止不留陈旧项)
clearConvRound(convId || '')
// FE1:后台会话终止只刷新列表(防抖调度,避免与 AiCompleted 收尾的 notify 双拉)。 // FE1:后台会话终止只刷新列表(防抖调度,避免与 AiCompleted 收尾的 notify 双拉)。
scheduleConversationsRefresh() scheduleConversationsRefresh()
// 后台会话终止也触发队列续发/清队(完成续发;错误清该会话队列) // 后台会话终止也触发队列续发/清队(完成续发;错误清该会话队列)
@@ -238,6 +243,10 @@ export function handleEvent(event: AiChatEvent) {
// 切换完成后由渲染层续显,避免切换窗口丢可见内容 // 切换完成后由渲染层续显,避免切换窗口丢可见内容
setConvStreaming(convId, true) setConvStreaming(convId, true)
setConvCurrentText(convId, (getConvStreamState(convId)?.currentText ?? '') + (event.delta ?? '')) 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 return
} }
@@ -260,8 +269,30 @@ export function handleEvent(event: AiChatEvent) {
void handleLifecycleEvent(event) void handleLifecycleEvent(event)
} }
/** 启动事件监听(幂等 + 并发去重,onMounted 与 sendMessage 首发竞态不会重复注册) */ /** 启动事件监听(幂等确保注册,无所有权——send 等运行时路径调用,不计入租约)。
export async function startListener() { * 所有权模型(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) { if (_unlistenAiEvent && _unlistenConvChanged && _unlistenApprovalClear) {
return return
} }
@@ -304,8 +335,14 @@ export async function startListener() {
} }
} }
/** 停止事件监听(卸载时调用,释放后端 listener + 清看门狗/计时器,防回调改已卸载组件 state) */ /** 归还一份租约:计数归零才真拆(多实例挂载时先退出的实例不误拆他人在用的 listener)。 */
export function stopListener() { function releaseLease() {
if (_leaseCount === 0) {
if (import.meta.env.DEV) console.error('[useAiEvents] 租约下溢(归还多于借出,调用方 bug)')
return
}
_leaseCount--
if (_leaseCount > 0) return
_unlistenAiEvent?.() _unlistenAiEvent?.()
_unlistenConvChanged?.() _unlistenConvChanged?.()
_unlistenApprovalClear?.() _unlistenApprovalClear?.()
@@ -323,7 +360,7 @@ export function stopListener() {
export function useAiEvents() { export function useAiEvents() {
return { return {
startListener, startListener,
stopListener, ensureListenerStarted,
handleEvent, handleEvent,
flushCurrentText, flushCurrentText,
friendlyError, friendlyError,
+3 -1
View File
@@ -9,7 +9,7 @@ import { emit } from '@tauri-apps/api/event'
import { useAppSettingsStore } from '@/stores/appSettings' import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai' import { state } from '@/stores/ai'
import { t } from '@/i18n/i18n-helpers' 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 { clearTextIdleTimer, pendingMaxRounds, pendingHelp, pendingDirAuths, pendingTaskParked, pendingTaskProgress } from './useAiPendingState'
import { clearStreamWatchdog, clearResumeAttempts } from './useAiStream' import { clearStreamWatchdog, clearResumeAttempts } from './useAiStream'
import { setStreaming } from './streamingGuard' import { setStreaming } from './streamingGuard'
@@ -74,6 +74,8 @@ function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | '
} }
setStreaming(false, { convId: convId || null, reason }) setStreaming(false, { convId: convId || null, reason })
state.agentRound = 0 // 结束/中断/求助,复位轮次(隐藏进度条) state.agentRound = 0 // 结束/中断/求助,复位轮次(隐藏进度条)
// #14 桌面任务卡:终态即清 per-conv 轮次(前台会话终态收敛,与后台分支 clearConvRound 对齐)
clearConvRound(convId)
// convStates 收敛:AiError 保留 error 项(停止按钮显「重试」态),其余删项回 idle // convStates 收敛:AiError 保留 error 项(停止按钮显「重试」态),其余删项回 idle
if (reason === 'AiError') { if (reason === 'AiError') {
if (convId) convStates.set(convId, 'error') if (convId) convStates.set(convId, 'error')
+4 -4
View File
@@ -23,7 +23,7 @@ import type { ContentPart, AiMessage, MentionSpan, MessageId } from '@/api/types
import { t } from '@/i18n/i18n-helpers' import { t } from '@/i18n/i18n-helpers'
import { resetStreamWatchdog, clearStreamWatchdog, clearResumeAttempts } from './useAiStream' import { resetStreamWatchdog, clearStreamWatchdog, clearResumeAttempts } from './useAiStream'
import { setStreaming } from './streamingGuard' 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' import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang, convStates, setConvCurrentText, setConvStreaming, flushCurrentText, notifyQueueCleared, classifySendError, type SendErrorKind } from './aiShared'
/// UX-P2-A5:drainQueue 失败气泡的错误分类文案映射(分类逻辑 aiShared.classifySendError, /// UX-P2-A5:drainQueue 失败气泡的错误分类文案映射(分类逻辑 aiShared.classifySendError,
@@ -117,7 +117,7 @@ async function doSend(text: string, skill?: string, force = false, parts?: Conte
setConvCurrentText(targetConvId, '') setConvCurrentText(targetConvId, '')
} }
await startListener() await ensureListenerStarted()
const lang = resolveAiLang() const lang = resolveAiLang()
try { try {
// force=true 走 force_send IPC(复位残留 generating),否则走正常 send // force=true 走 force_send IPC(复位残留 generating),否则走正常 send
@@ -183,7 +183,7 @@ async function regenerate() {
// 会话态走 per-conv 键(与 doSend 一致),不再落 legacy 单 timer fallback // 会话态走 per-conv 键(与 doSend 一致),不再落 legacy 单 timer fallback
resetStreamWatchdog(regenConvId || undefined) resetStreamWatchdog(regenConvId || undefined)
await startListener() await ensureListenerStarted()
const lang = resolveAiLang() const lang = resolveAiLang()
const convId = state.activeConversationId const convId = state.activeConversationId
if (!convId) { if (!convId) {
@@ -259,7 +259,7 @@ async function editMessage(newMessage: string) {
// 会话态走 per-conv 键(与 doSend 一致),不再落 legacy 单 timer fallback // 会话态走 per-conv 键(与 doSend 一致),不再落 legacy 单 timer fallback
resetStreamWatchdog(editConvId || undefined) resetStreamWatchdog(editConvId || undefined)
await startListener() await ensureListenerStarted()
const lang = resolveAiLang() const lang = resolveAiLang()
const convId = state.activeConversationId const convId = state.activeConversationId
if (!convId) { if (!convId) {
+5 -1
View File
@@ -10,7 +10,7 @@ import { emit } from '@tauri-apps/api/event'
import { useAppSettingsStore } from '@/stores/appSettings' import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai' import { state } from '@/stores/ai'
import { t } from '@/i18n/i18n-helpers' 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 { resetTextIdleTimer, clearTextIdleTimer, pendingDirAuths, pushPendingDirAuth } from './useAiPendingState'
import { clearStreamWatchdog } from './useAiStream' import { clearStreamWatchdog } from './useAiStream'
import type { AiChatEvent, AiMessage, MessageId } from '@/api/types' import type { AiChatEvent, AiMessage, MessageId } from '@/api/types'
@@ -57,6 +57,10 @@ export function handleStreamingEvent(event: AiChatEvent): boolean {
}) })
// round>0 才更新进度条(round=0 是审批通过后「隔开新一轮」的占位,实际轮次未推进,防闪烁) // round>0 才更新进度条(round=0 是审批通过后「隔开新一轮」的占位,实际轮次未推进,防闪烁)
if (event.round > 0) state.agentRound = event.round 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 return true
} }
+12 -13
View File
@@ -17,7 +17,7 @@ import { nextMsgId, getConvState, convStates } from './aiShared'
import type { MessageId } from '@/api/types' import type { MessageId } from '@/api/types'
import { setStreaming } from './streamingGuard' import { setStreaming } from './streamingGuard'
import { persistUiState } from './useAiPanel' import { persistUiState } from './useAiPanel'
import { stopListener } from './useAiEvents'
// ── 主窗口事件跟随 unlistener ── // ── 主窗口事件跟随 unlistener ──
let _unlistenMove: (() => void) | null = null let _unlistenMove: (() => void) | null = null
@@ -200,19 +200,19 @@ async function detachPanel(convId?: string) {
stopFollowMain() stopFollowMain()
}) })
state.detached = true state.detached = true
// 时序加固:detached 置 true 后立即停主窗口 listener,不依赖 v-if → AiChat onBeforeUnmount 的 // 时序注(2026-10-05 租约模型):原此处同步调 stopListener() 提前拆主窗 listener,消除
// 异步传播。消除 detached=true 到 AiChat 实际卸载的窗口期(reattach 重叠期/HMR/极速 mount-unmount // detached=true 到 AiChat 实际卸载的窗口期竞态——但同步拆全局单例正是双实例互踩根源,
// 竞态下,onBeforeUnmount 可能延迟,旧 listener 残留会与分离窗口新 listener 并存竞态)。 // 租约模型下删除:主窗 v-if 立即失效 → onBeforeUnmount 归还租约(最后一份才真拆)。
// stopListener 同步释放后端 listener + 清看门狗,分离窗口 resumeInDetached 起自己的 startListener。 // 竞态窗口期(毫秒级)旧 listener 多活无害:事件按 conversation_id 路由,分离窗口独立注册
// (BUG-260624-02 已实证审批事件无缺口),主窗残听仅多收一次同事件,无串台。
// //
// BUG-260624-02 核验结论(2026-08-15):分离窗口事件监听独立存在,审批事件无缺口。 // BUG-260624-02 核验结论(2026-08-15):分离窗口事件监听独立存在,审批事件无缺口。
// 链路:本窗口 url '#/ai-detached' → AiDetached.vue 挂 AiChat(detached) → AiChat onMounted // 链路:本窗口 url '#/ai-detached' → AiDetached.vue 挂 AiChat(detached) → AiChat onMounted
// 调 store.startListener()(独立 JS context 各自注册);后端 ai-chat-event 为 app 级广播 // 调 store.startListener()(独立 JS context 各自注册);后端 ai-chat-event 为 app 级广播
// (emit 到全部 webview),故审批类事件(工具 pending/审批结果)分离窗口自身可收。 // (emit 到全部 webview),故审批类事件(工具 pending/审批结果)分离窗口自身可收。
// 前端派生的 'ai-pending-arrived'(useAiStreamingEvents/useAiToolEvents emit)亦为全局广播, // 前端派生的 'ai-pending-arrived'(useAiStreamingEvents/useAiToolEvents emit)亦为全局广播,
// 分离窗口 ToolCardList 的 listener 同样收到。主窗口 listener 在此 stopListener 释清, // 分离窗口 ToolCardList 的 listener 同样收到。主窗 listener 由租约归还(见上方时序注)释清,
// 不与分离窗口并存竞态。审批超时计时器(aiShared)同样随 listener 在接管窗口内重建。 // 不与分离窗口并存竞态。审批超时计时器(aiShared)同样随 listener 在接管窗口内重建。
stopListener()
} }
/** 关闭分离窗口并回到内嵌面板(主窗口侧调用)。 /** 关闭分离窗口并回到内嵌面板(主窗口侧调用)。
@@ -234,9 +234,9 @@ async function reattachPanel() {
persistUiState() persistUiState()
// V11c: 清分离窗活跃会话 KV(分离窗已全关,残留无意义;下次 detachPanel 会重新种子) // V11c: 清分离窗活跃会话 KV(分离窗已全关,残留无意义;下次 detachPanel 会重新种子)
void useAppSettingsStore().remove('df-ai-active-conv-detached') void useAppSettingsStore().remove('df-ai-active-conv-detached')
// 时序加固:reattach 关闭分离窗口后,显式停分离窗口残留 listener(分离窗口 close 异步, // 时序注(租约模型):原此处显式 stopListener 防「分离窗旧 listener 与主面板重挂载重叠」——
// onBeforeUnmount stopListener 可能延迟),防主面板重新挂载 startListener 时与旧 listener 重叠竞态。 // 但分离窗 listener 活在分离窗自己的 JS context,主窗此处拆的只是本 context 已归还空的
stopListener() // listener(跨 webview 隔离,原调用实为 no-op 式心理安慰)。租约归还链已完整覆盖,删除。
// 清所有 per-conv 快照(分离窗口已全关,快照无消费方) // 清所有 per-conv 快照(分离窗口已全关,快照无消费方)
cleanupAllDetachedSnapshots() cleanupAllDetachedSnapshots()
} }
@@ -348,9 +348,8 @@ async function resumeInDetached(convId: string | null) {
async function closeDetachedWindow() { async function closeDetachedWindow() {
state.docked = false state.docked = false
stopFollowMain() stopFollowMain()
// 时序加固:关闭分离窗口前先停 listener,防 close 异步传播期间 onBeforeUnmount 延迟、 // 时序注(租约模型):原此处关闭前先 stopListener 防竞态——同上,租约归还链
// 主面板重挂载 startListener 与旧分离窗口 listener 短暂并存(极速 mount-unmount/HMR 竞态)。 // (本窗口 AiChat onBeforeUnmount)已完整覆盖,强拆会使租约计数与实际状态失配,删除。
stopListener()
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow') const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
const currentWin = getCurrentWebviewWindow() const currentWin = getCurrentWebviewWindow()
const lbl = currentWin.label const lbl = currentWin.label
+7
View File
@@ -43,6 +43,7 @@ export default {
restoreSidebar: 'Restore sidebar', restoreSidebar: 'Restore sidebar',
sidebarResizeHint: 'Drag to resize sidebar', sidebarResizeHint: 'Drag to resize sidebar',
detachWindow: 'Detach window', detachWindow: 'Detach window',
reattachToMain: 'Reattach to main panel',
closePanel: 'Close panel', closePanel: 'Close panel',
dockRight: 'Dock to main window right', dockRight: 'Dock to main window right',
undock: 'Undock', undock: 'Undock',
@@ -255,6 +256,8 @@ export default {
autoCompressed: 'Context auto-compressed (earlier messages folded into summary)', autoCompressed: 'Context auto-compressed (earlier messages folded into summary)',
compressError: 'Compression failed: {msg}', compressError: 'Compression failed: {msg}',
clearError: 'Clear failed: {msg}', clearError: 'Clear failed: {msg}',
initError: 'AI panel init failed: {msg} (reopen panel to retry)',
initDegraded: 'List load failed, continuing in degraded mode: {msg}',
// Separator expand / collapse // Separator expand / collapse
expand: 'Expand', expand: 'Expand',
collapse: 'Collapse', collapse: 'Collapse',
@@ -263,6 +266,10 @@ export default {
// ── TopBar bottom context badges (tooltip + L3 full context popover labels) ── // ── TopBar bottom context badges (tooltip + L3 full context popover labels) ──
viewGoals: 'View goals', viewGoals: 'View goals',
runningTasks: 'Running background tasks',
runningRounds: '{n} rounds running',
untitledConversation: 'Untitled conversation',
recoverableTask: 'Interrupted · recoverable',
viewHistory: 'View history messages', viewHistory: 'View history messages',
viewSummary: 'View context summary', viewSummary: 'View context summary',
viewContext: 'View AI context', viewContext: 'View AI context',
+7
View File
@@ -43,6 +43,7 @@ export default {
restoreSidebar: '还原侧栏', restoreSidebar: '还原侧栏',
sidebarResizeHint: '拖拽调整侧栏宽度', sidebarResizeHint: '拖拽调整侧栏宽度',
detachWindow: '分离窗口', detachWindow: '分离窗口',
reattachToMain: '收回主面板',
closePanel: '关闭面板', closePanel: '关闭面板',
dockRight: '吸附到主窗口右侧', dockRight: '吸附到主窗口右侧',
undock: '取消吸附', undock: '取消吸附',
@@ -256,6 +257,8 @@ export default {
autoCompressed: '上下文已自动压缩(较早消息已折叠为摘要)', autoCompressed: '上下文已自动压缩(较早消息已折叠为摘要)',
compressError: '压缩失败:{msg}', compressError: '压缩失败:{msg}',
clearError: '清空失败:{msg}', clearError: '清空失败:{msg}',
initError: 'AI 面板初始化失败:{msg}(重开面板可重试)',
initDegraded: '列表加载失败,面板降级继续:{msg}',
// 折叠条展开 / 收起 // 折叠条展开 / 收起
expand: '展开', expand: '展开',
collapse: '收起', collapse: '收起',
@@ -264,6 +267,10 @@ export default {
// ── TopBar 底部上下文徽章(tooltip + L3 完整上下文 popover 标签) ── // ── TopBar 底部上下文徽章(tooltip + L3 完整上下文 popover 标签) ──
viewGoals: '查看目标', viewGoals: '查看目标',
runningTasks: '后台进行中任务',
runningRounds: '进行中 {n} 轮',
untitledConversation: '未命名会话',
recoverableTask: '中断·可恢复',
viewHistory: '查看历史消息', viewHistory: '查看历史消息',
viewSummary: '查看上下文摘要', viewSummary: '查看上下文摘要',
viewContext: '查看 AI 上下文', viewContext: '查看 AI 上下文',
+1 -1
View File
@@ -7,7 +7,7 @@
//! - composables 之间通过模块级 import 直接互调(events↔send↔stream 耦合) //! - composables 之间通过模块级 import 直接互调(events↔send↔stream 耦合)
//! //!
//! composables 职责: //! composables 职责:
//! - useAiEvents startListener/stopListener/handleEvent/flushCurrentText/findToolCall/friendlyError/notifyConversationChanged //! - useAiEvents startListener/ensureListenerStarted/handleEvent/flushCurrentText/findToolCall/friendlyError/notifyConversationChanged
//! - useAiStream onStreamTimeout/resetStreamWatchdog/clearStreamWatchdog + STREAM_TIMEOUT_MS //! - useAiStream onStreamTimeout/resetStreamWatchdog/clearStreamWatchdog + STREAM_TIMEOUT_MS
//! - useAiSend sendMessage/approveToolCall/drainQueue/cancelQueued/clearQueue/stopChat //! - useAiSend sendMessage/approveToolCall/drainQueue/cancelQueued/clearQueue/stopChat
//! - useAiConversations loadConversations/newConversation/switchConversation/deleteConversation/renameConversation/archiveConversation/toggleArchivedFold/toggleSidebar //! - useAiConversations loadConversations/newConversation/switchConversation/deleteConversation/renameConversation/archiveConversation/toggleArchivedFold/toggleSidebar