diff --git a/src/components/AiChat.vue b/src/components/AiChat.vue index cbb4f88..c387ba7 100644 --- a/src/components/AiChat.vue +++ b/src/components/AiChat.vue @@ -242,7 +242,7 @@ async function confirmClearChat() { * 新建对话(UX-08 §3.4):生成中点新建 → 弹 ConfirmDialog「当前正在生成,确定中断并新建?」, * 确认走原软复位流程(B-260615-10 store.newConversation,中断 + 新建);取消不动(保持生成)。 * 非生成中直接走原新建流程(零行为变化)。仅「当前视图正在生成」才弹确认 —— - * 切走后台生成时(streaming && generatingConvId !== activeConversationId)不算中断当前视图。 + * 切走后台生成时(streaming && !isGenerating(activeConversationId))不算中断当前视图。 */ async function confirmNewConversation() { if (isViewingGenerating.value) { @@ -277,8 +277,10 @@ const convSidebarRef = ref | null>(null // (顶部工具栏 + provider/model 选择逻辑,零行为变更,store 单例共享 modelOverride)。 // 当前视图是否正在生成(切走后台生成时光标不显示) +// F-09: 多会话并发 — 用 isGenerating(activeConversationId) 替代单值比对, +// 当前会话在生成 Set 中即算"视图正在生成"(其他会话后台生成不影响本视图判定) const isViewingGenerating = computed(() => - store.state.streaming && store.state.generatingConvId === store.state.activeConversationId + store.state.streaming && store.isGenerating(store.state.activeConversationId) ) /** UX-09:进入编辑态 — 置 editingMsgId(透传 ChatInput prop)+ 委托 ChatInput.startEditFocus 回填/聚焦。 */ @@ -806,6 +808,22 @@ body.ai-sidebar-resizing * { font-size: 9px; color: var(--df-text-dim); } +/* F-09: 多会话并发生成指示器(会话项标题行内脉冲点,多会话可同时显示) */ +.ai-conv-gen-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--df-primary, #4f8cff); + margin-left: 4px; + vertical-align: middle; + flex-shrink: 0; + animation: ai-conv-gen-pulse 1.4s ease-in-out infinite; +} +@keyframes ai-conv-gen-pulse { + 0%, 100% { opacity: 0.35; transform: scale(0.85); } + 50% { opacity: 1; transform: scale(1.15); } +} .ai-conv-item-actions { display: none; align-items: center; diff --git a/src/components/ai/ConversationSidebar.vue b/src/components/ai/ConversationSidebar.vue index 15ef76c..e7bb83c 100644 --- a/src/components/ai/ConversationSidebar.vue +++ b/src/components/ai/ConversationSidebar.vue @@ -45,7 +45,10 @@ @click="onSelectSearchResult(conv.id)" >
- {{ conv.title || t('aiChat.newConversation') }} + + {{ conv.title || t('aiChat.newConversation') }} + + {{ formatTime(conv.updated_at) }}
@@ -125,6 +128,8 @@ class="ai-conv-item-title-badge" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" > + + {{ formatTime(conv.updated_at) }}
@@ -167,7 +172,10 @@ @click="store.switchConversation(conv.id)" >
- {{ conv.title || t('aiChat.newConversation') }} + + {{ conv.title || t('aiChat.newConversation') }} + + {{ formatTime(conv.updated_at) }}
diff --git a/src/components/ai/DirAuthDialog.vue b/src/components/ai/DirAuthDialog.vue index 61c5a7d..3db8ab8 100644 --- a/src/components/ai/DirAuthDialog.vue +++ b/src/components/ai/DirAuthDialog.vue @@ -46,8 +46,9 @@ const store = useAiStore() const { t } = useI18n() // 当前视图是否正在生成(切走后台生成时光标不显示,与 MaxRoundsCard 一致守卫) +// F-09: 多会话并发 — isGenerating(active) 替代单值比对 const isViewingGenerating = computed(() => - store.state.streaming && store.state.generatingConvId === store.state.activeConversationId, + store.state.streaming && store.isGenerating(store.state.activeConversationId), ) // pendingDirAuth(AiDirAuthRequired 事件置)+ 当前视图正在生成(防切走后误显)双重守卫。 diff --git a/src/components/ai/MaxRoundsCard.vue b/src/components/ai/MaxRoundsCard.vue index 06db5bd..69e3e7c 100644 --- a/src/components/ai/MaxRoundsCard.vue +++ b/src/components/ai/MaxRoundsCard.vue @@ -38,8 +38,9 @@ const store = useAiStore() const { t } = useI18n() // 当前视图是否正在生成(切走后台生成时光标不显示) +// F-09: 多会话并发 — isGenerating(active) 替代单值比对 const isViewingGenerating = computed(() => - store.state.streaming && store.state.generatingConvId === store.state.activeConversationId, + store.state.streaming && store.isGenerating(store.state.activeConversationId), ) // pendingMaxRounds(达 max 事件置)+ 当前视图正在生成(防切走后误显)双重守卫。 diff --git a/src/components/ai/MessageList.vue b/src/components/ai/MessageList.vue index d9e970f..d07fb7b 100644 --- a/src/components/ai/MessageList.vue +++ b/src/components/ai/MessageList.vue @@ -192,8 +192,9 @@ const messagesContainer = ref() const toolCardListRef = ref>() // 当前视图是否正在生成(切走后台生成时光标不显示) +// F-09: 多会话并发 — isGenerating(active) 替代单值比对 const isViewingGenerating = computed(() => - store.state.streaming && store.state.generatingConvId === store.state.activeConversationId + store.state.streaming && store.isGenerating(store.state.activeConversationId) ) function isLastAi(msg: AiMessage): boolean { @@ -347,10 +348,10 @@ watch(() => store.state.streaming, (s) => { if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null } if (s) { // UX-260616-03: 新一轮生成开始 → 收起上一轮已完成的旧工具卡,聚焦当前生成内容。 - // 仅当前视图正在生成时触发(generatingConvId 对齐 activeConversationId), + // 仅当前视图正在生成时触发(isGenerating(activeConversationId)), // 切走后台生成/恢复会话(streaming 初值已 true 的 onMounted 路径)不误触。 // buildActiveToolIds/collapseAllToolLists 是 function 声明,提升可用。 - if (store.state.generatingConvId === store.state.activeConversationId) { + if (store.isGenerating(store.state.activeConversationId)) { collapseAllToolLists(buildActiveToolIds(store.state.messages)) } } else { diff --git a/src/composables/ai/useAiConversations.ts b/src/composables/ai/useAiConversations.ts index 8ace172..3a6fce9 100644 --- a/src/composables/ai/useAiConversations.ts +++ b/src/composables/ai/useAiConversations.ts @@ -72,10 +72,10 @@ async function newConversation() { state.pendingApprovals = [] state.streaming = false // F-260616-09(A 路线):补漏清字段维持单例软隔离,解「新建会话上下文残留」。 - // queue 清空防旧会话排队消息被带进新会话 drain;generatingConvId 清空防后台事件 + // queue 清空防旧会话排队消息被带进新会话 drain;generatingConvs 清空防后台事件 // 错误路由;agentRound 复位 0;searchQuery 清空防新会话侧栏被旧搜索过滤。 state.queue = [] - state.generatingConvId = null + state.generatingConvs.clear() state.agentRound = 0 state.searchQuery = '' await loadConversations() diff --git a/src/composables/ai/useAiEvents.ts b/src/composables/ai/useAiEvents.ts index 8f49982..8c47c06 100644 --- a/src/composables/ai/useAiEvents.ts +++ b/src/composables/ai/useAiEvents.ts @@ -359,7 +359,7 @@ function handleLifecycleEvent(event: AiChatEvent): boolean { flushCurrentText() state.currentText = '' state.streaming = false - state.generatingConvId = null + state.generatingConvs.delete(event.conversation_id || '') state.agentRound = 0 // AE-2025-07: agentic 结束,复位轮次(隐藏进度条) pendingMaxRounds.value = false // F-260616-03: 收尾(停止/续跑后新一轮达 max 才会再 set),清操作卡 pendingDirAuth.value = null // F-260619-03 Phase B: 收尾清路径授权弹窗(防残留) @@ -389,7 +389,12 @@ function handleLifecycleEvent(event: AiChatEvent): boolean { state.convTokenTotal = { prompt: event.prompt_tokens, completion: event.completion_tokens, total: event.total_tokens } } } - // 清理分离窗口生成态快照 + // 清理分离窗口生成态快照(F-09:per-conv key,清本会话快照;兼容旧单 key) + const doneConv = event.conversation_id || '' + if (doneConv) { + localStorage.removeItem(`df-ai-gen-${doneConv}`) + localStorage.removeItem(`df-ai-text-${doneConv}`) + } localStorage.removeItem('df-ai-gen') localStorage.removeItem('df-ai-text') void loadConversations() @@ -407,7 +412,7 @@ function handleLifecycleEvent(event: AiChatEvent): boolean { // 保留"回答到一半"的部分回复(否则清 currentText 后部分内容消失)。 flushCurrentText() state.streaming = false - state.generatingConvId = null + state.generatingConvs.delete(event.conversation_id || '') state.currentText = '' state.agentRound = 0 // AE-2025-07: agentic 异常中断,复位轮次 state.queue = [] // B-32:错误收尾清队列,防生成中入队的消息被静默丢失(drainQueue 仅 AiCompleted 触发) @@ -416,6 +421,12 @@ function handleLifecycleEvent(event: AiChatEvent): boolean { state.pendingApprovals = [] pendingMaxRounds.value = false // F-260616-03: 异常中断,清操作卡 pendingDirAuth.value = null // F-260619-03 Phase B: 异常中断清路径授权弹窗 + // F-09: 清理分离窗口生成态快照(per-conv key,清本会话快照;兼容旧单 key) + const errConv = event.conversation_id || '' + if (errConv) { + localStorage.removeItem(`df-ai-gen-${errConv}`) + localStorage.removeItem(`df-ai-text-${errConv}`) + } localStorage.removeItem('df-ai-gen') localStorage.removeItem('df-ai-text') // UX-03: 错误消息携带 error_type(供错误气泡差异化显隐「去设置」按钮)。 @@ -450,14 +461,15 @@ export function handleEvent(event: AiChatEvent) { const isCurrent = !convId || convId === state.activeConversationId if (!isCurrent) { if (event.type === 'AiCompleted' || event.type === 'AiError') { - state.generatingConvId = null + // F-09: 多会话并发 — 仅从 Set 移除该 conv(其他会话可能仍在生成),不再置 null 全局清空 + state.generatingConvs.delete(convId || '') void loadConversations() } return } // 标记正在生成的对话(完成/错误事件除外) if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError') { - state.generatingConvId = convId + state.generatingConvs.add(convId) } // 流式看门狗:活跃事件(delta/工具/新轮/审批结果)重置;审批等待/完成/错误在 case 内 clear if (!NO_RESET_WATCHDOG.has(event.type)) { diff --git a/src/composables/ai/useAiStream.ts b/src/composables/ai/useAiStream.ts index ade03b5..b9e109c 100644 --- a/src/composables/ai/useAiStream.ts +++ b/src/composables/ai/useAiStream.ts @@ -25,7 +25,9 @@ let _streamWatchdog: ReturnType | null = null /** 看门狗超时回调:收尾 streaming 态并补错误消息 */ export function onStreamTimeout() { state.streaming = false - state.generatingConvId = null + // F-09: 多会话并发 — 超时收尾清空全部生成态(整流超时是兜底场景,无法确定具体 conv, + // 保守全清避免幽灵;正常路径由各 conv 的 AiCompleted/AiError 精确 remove) + state.generatingConvs.clear() state.currentText = '' state.queue = [] // B-32:超时收尾同步清队列,防生成中入队的消息静默丢失 clearStreamWatchdog() diff --git a/src/composables/ai/useAiWindow.ts b/src/composables/ai/useAiWindow.ts index 7e6a7c7..79673fc 100644 --- a/src/composables/ai/useAiWindow.ts +++ b/src/composables/ai/useAiWindow.ts @@ -17,30 +17,55 @@ import { persistUiState } from './useAiPanel' let _unlistenMove: (() => void) | null = null let _unlistenResize: (() => void) | null = null -/** 分离 AI 面板到独立窗口(若已存在则聚焦);快照当前生成态供分离窗口接管 */ -async function detachPanel() { +/** + * F-09 阶段4:多会话独立窗口的 per-conv key/label 生成器。 + * + * 旧实现用单 label 'ai-detached' + 单 key df-ai-gen/df-ai-text,多会话并发下冲突 + * (会话 A 的窗口快照被会话 B 覆盖,getByLabel 只能存一个窗口)。现按 convId 命名空间 + * 隔离:每个会话独立窗口 + 独立 localStorage 快照,互不串扰。 + * 单会话场景:convId 固定,key/label 退化为单值,行为等价。 + */ +const DETACHED_LABEL_PREFIX = 'ai-detached-' +const GEN_KEY_PREFIX = 'df-ai-gen-' +const TEXT_KEY_PREFIX = 'df-ai-text-' + +function detachedLabel(convId: string): string { + return DETACHED_LABEL_PREFIX + convId +} +function genKey(convId: string): string { + return GEN_KEY_PREFIX + convId +} +function textKey(convId: string): string { + return TEXT_KEY_PREFIX + convId +} + +/** 分离 AI 面板到独立窗口(若该会话窗口已存在则聚焦);快照当前生成态供分离窗口接管 */ +async function detachPanel(convId?: string) { const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow') - const existing = await WebviewWindow.getByLabel('ai-detached') + const targetConv = convId || state.activeConversationId || '' + const label = targetConv ? detachedLabel(targetConv) : 'ai-detached' + const existing = await WebviewWindow.getByLabel(label) if (existing) { await existing.setFocus() return } // 快照当前生成态,供分离窗口接管(保持正在进行的对话) - if (state.streaming && state.generatingConvId) { - localStorage.setItem('df-ai-gen', state.generatingConvId) - localStorage.setItem('df-ai-text', state.currentText) + // F-09:用 per-conv key 写入(多会话各自快照互不覆盖) + if (state.streaming && targetConv && state.generatingConvs.has(targetConv)) { + localStorage.setItem(genKey(targetConv), targetConv) + localStorage.setItem(textKey(targetConv), state.currentText) } // 主窗口 state 与分离窗口 state 独立(各自 webview 独立 JS context, // stores/ai.ts 模块级单例仅在同 webview 内共享),清主窗口 state 不影响分离窗口生成态。 - // 分离窗口接管生成后,主窗口不应再持生成态残留(防重开面板时 UI 显生成中幽灵/进度条)。 + // 分离窗口接管生成后,主窗口不应再持该会话生成态残留(防重开面板时 UI 显生成中幽灵/进度条)。 + // F-09:仅从 Set 移除该 conv(其他会话可能仍在生成),不全局清空。 // watchdog 在主窗口 AiChat 卸载(App.vue v-if detached)→ stopListener → clearStreamWatchdog 时已清, // 此处仅清内存 state 视觉残留;localStorage 快照保留供 resumeInDetached 恢复。 state.streaming = false - state.generatingConvId = null - const convId = state.activeConversationId - const sep = convId ? `?conv=${encodeURIComponent(convId)}` : '' + if (targetConv) state.generatingConvs.delete(targetConv) + const sep = targetConv ? `?conv=${encodeURIComponent(targetConv)}` : '' const url = window.location.origin + window.location.pathname + '#/ai-detached' + sep - const win = new WebviewWindow('ai-detached', { + const win = new WebviewWindow(label, { url, title: 'DevFlow AI', width: 600, // U-260618: 加宽(原 520 偏窄),与吸附态 AI_DOCK_WIDTH 一致 @@ -68,30 +93,48 @@ async function detachPanel() { state.detached = true } -/** 关闭分离窗口并回到内嵌面板(主窗口侧调用) */ +/** 关闭分离窗口并回到内嵌面板(主窗口侧调用)。 + * F-09:多会话并发下可能存在多个 detached 窗口,全部关闭并清各自 per-conv 快照。 */ async function reattachPanel() { stopFollowMain() const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow') - const win = await WebviewWindow.getByLabel('ai-detached') - if (win) { - await win.close() + // 关闭所有 ai-detached-* 前缀的窗口(含旧版单 label 'ai-detached' 兼容) + const all = await WebviewWindow.getAll() + for (const w of all) { + const lbl = w.label + if (lbl === 'ai-detached' || lbl.startsWith(DETACHED_LABEL_PREFIX)) { + try { await w.close() } catch { /* 忽略单个窗口关闭失败 */ } + } } state.detached = false state.docked = false state.panelOpen = true persistUiState() - localStorage.removeItem('df-ai-gen') - localStorage.removeItem('df-ai-text') + // F-09:清所有 per-conv 快照(分离窗口已全关,快照无消费方) + cleanupAllDetachedSnapshots() +} + +/** 清理所有 df-ai-gen-* / df-ai-text-* 快照(分离窗口全关 / 整轮收尾时调用) */ +function cleanupAllDetachedSnapshots(): void { + const keysToRemove: string[] = [] + for (let i = localStorage.length - 1; i >= 0; i--) { + const k = localStorage.key(i) + if (!k) continue + if (k.startsWith(GEN_KEY_PREFIX) || k.startsWith(TEXT_KEY_PREFIX) || k === 'df-ai-gen' || k === 'df-ai-text') { + keysToRemove.push(k) + } + } + for (const k of keysToRemove) localStorage.removeItem(k) } /** * 重建生成中态(分离窗口 resumeInDetached 与主面板 onMounted 恢复共用)。 * - * 读 localStorage df-ai-gen/df-ai-text,核对后端 ai_is_generating 真值后重建: - * 1. state.currentText = df-ai-text(已生成文本) + * 读 localStorage df-ai-gen-${convId}/df-ai-text-${convId}(F-09 per-conv key),核对后端 ai_is_generating 真值后重建: + * 1. state.currentText = df-ai-text-${convId}(已生成文本) * 2. push 占位 assistant 气泡(content='') * 3. state.streaming = true - * 4. state.generatingConvId = df-ai-gen + * 4. state.generatingConvs.add(df-ai-gen) * * 幂等:若 state.streaming 已是 true(已恢复过/正在生成),直接返回不重复 push。 * 这对主面板刷新场景至关重要:detachPanel 时主窗口 state 已被清 streaming=false @@ -103,17 +146,19 @@ async function reattachPanel() { * * @returns true=已重建;false=未恢复(无快照/后端未生成/核对失败/已生成中) */ -export async function restoreGeneratingState(opts?: { fromMainPanel?: boolean }): Promise { +export async function restoreGeneratingState(opts?: { fromMainPanel?: boolean; convId?: string }): Promise { // 幂等:已生成中态不再重建(防主面板 + 分离窗口共享 state 时双重恢复; // 主面板与分离窗口 state 各自独立,但稳妥起见仍守卫) if (state.streaming) return false const fromMainPanel = opts?.fromMainPanel ?? false - // 主面板刷新场景:detachPanel 未触发 → df-ai-gen 不存在。 + // 主面板刷新场景:detachPanel 未触发 → df-ai-gen-* 不存在。 // 此时用 state.activeConversationId 作为生成中会话(刷新前 loadConversations 已恢复), - // 无 df-ai-text 快照 → currentText 从空开始(刷新前已生成部分丢失,刷新固有代价; + // 无 df-ai-text-* 快照 → currentText 从空开始(刷新前已生成部分丢失,刷新固有代价; // 后端后续 AiTextDelta 仍会追加,核心是恢复 streaming 态 + 占位气泡消除假死)。 - // 分离窗口场景:读 detachPanel 写入的 df-ai-gen 快照。 - const gen = fromMainPanel ? state.activeConversationId : localStorage.getItem('df-ai-gen') + // 分离窗口场景:读 detachPanel 写入的 df-ai-gen-${convId} 快照(F-09 per-conv key)。 + const gen = fromMainPanel + ? state.activeConversationId + : (opts?.convId && localStorage.getItem(genKey(opts.convId))) || localStorage.getItem('df-ai-gen') if (!gen) return false // 核对后端 ai_is_generating 真值 // F-260616-09 B 批4(决策 e):传 gen(目标 conv)精确查,不读全局单值。 @@ -127,15 +172,19 @@ export async function restoreGeneratingState(opts?: { fromMainPanel?: boolean }) if (!backendGenerating) { // 后端已不再生成,清残留快照(仅分离窗口快照路径),不重建 if (!fromMainPanel) { + if (opts?.convId) { + localStorage.removeItem(genKey(opts.convId)) + localStorage.removeItem(textKey(opts.convId)) + } localStorage.removeItem('df-ai-gen') localStorage.removeItem('df-ai-text') } return false } // 恢复已生成文本并补占位,后续 delta 继续追加 - // 主面板无 df-ai-text 快照,保持 currentText 当前值(空),仅靠占位气泡 + streaming 态接收后续 delta + // 主面板无 df-ai-text-* 快照,保持 currentText 当前值(空),仅靠占位气泡 + streaming 态接收后续 delta if (!fromMainPanel) { - state.currentText = localStorage.getItem('df-ai-text') || '' + state.currentText = (opts?.convId && localStorage.getItem(textKey(opts.convId))) || localStorage.getItem('df-ai-text') || '' } state.messages.push({ id: `ai-${nextMsgId()}`, @@ -144,7 +193,7 @@ export async function restoreGeneratingState(opts?: { fromMainPanel?: boolean }) timestamp: Date.now(), }) state.streaming = true - state.generatingConvId = gen + state.generatingConvs.add(gen) return true } @@ -152,18 +201,28 @@ export async function restoreGeneratingState(opts?: { fromMainPanel?: boolean }) async function resumeInDetached(convId: string | null) { // R5: 不自动 switchConversation —— 分离窗口可看历史列表,但不主动覆盖主窗口 activeConversationId // (避免主窗口期间发消息落错会话)。若需切换会话,由用户在分离窗口显式操作触发。 - void convId - await restoreGeneratingState() + // F-09: 透传 convId 给 restoreGeneratingState,定位正确的 per-conv 快照 key。 + await restoreGeneratingState({ convId: convId || undefined }) } -/** 关闭分离窗口自身(分离窗口侧调用) */ +/** 关闭分离窗口自身(分离窗口侧调用)。 + * F-09:从当前 webview 的 label 反推 convId,清自己的 per-conv 快照。 */ async function closeDetachedWindow() { state.docked = false stopFollowMain() - localStorage.removeItem('df-ai-gen') - localStorage.removeItem('df-ai-text') const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow') - await getCurrentWebviewWindow().close() + const currentWin = getCurrentWebviewWindow() + const lbl = currentWin.label + // per-conv label 'ai-detached-' → 清对应快照;旧 label 'ai-detached' → 清遗留单 key + if (lbl.startsWith(DETACHED_LABEL_PREFIX)) { + const convId = lbl.slice(DETACHED_LABEL_PREFIX.length) + localStorage.removeItem(genKey(convId)) + localStorage.removeItem(textKey(convId)) + } else { + localStorage.removeItem('df-ai-gen') + localStorage.removeItem('df-ai-text') + } + await currentWin.close() } /** 吸附/取消吸附:AI 窗口跟随主窗口右侧 */ diff --git a/src/i18n/en/aiChat.ts b/src/i18n/en/aiChat.ts index c9e554e..4472cc1 100644 --- a/src/i18n/en/aiChat.ts +++ b/src/i18n/en/aiChat.ts @@ -4,6 +4,7 @@ export default { sidebarTitle: 'Chats', newConversation: 'New chat', doubleClickToRename: 'Double-click to rename', + generating: 'Generating…', archive: 'Archive', unarchive: 'Unarchive', pinConversation: 'Pin', diff --git a/src/i18n/zh-CN/aiChat.ts b/src/i18n/zh-CN/aiChat.ts index 8ee2944..c8265ba 100644 --- a/src/i18n/zh-CN/aiChat.ts +++ b/src/i18n/zh-CN/aiChat.ts @@ -4,6 +4,7 @@ export default { sidebarTitle: '对话', newConversation: '新对话', doubleClickToRename: '双击重命名', + generating: '生成中…', archive: '归档', unarchive: '取消归档', pinConversation: '置顶', diff --git a/src/stores/ai.ts b/src/stores/ai.ts index 08d14f6..365193b 100644 --- a/src/stores/ai.ts +++ b/src/stores/ai.ts @@ -63,8 +63,11 @@ export const state = reactive({ streaming: false, currentText: '', pendingApprovals: [] as AiToolCallInfo[], - // 正在生成的对话 id:生成中切走时用于路由,后台事件不污染当前视图 - generatingConvId: null as string | null, + // 正在生成的会话集合(F-09 多会话并发):后端 per-conv 已支持多会话同时生成, + // 前端用 Set 表达"多会话同时生成"态。生成中切走时用于路由,后台事件不污染当前视图。 + // isGenerating(convId)/addGenerating/removeGenerating helper 经 useAiStore 暴露。 + // 单会话场景行为不变(Set 含 0 或 1 元素)。 + generatingConvs: new Set(), providers: [] as AiProviderConfig[], activeProvider: null as string | null, panelOpen: true, @@ -185,6 +188,23 @@ const filteredConversations = computed(() => { .sort((a, b) => (b.updated_at > a.updated_at ? 1 : b.updated_at < a.updated_at ? -1 : 0)) }) +/** + * F-09 多会话并发:生成态 Set helper。 + * + * Vue 3 reactive 对 Set 的 has/add/delete 均有 proxy 跟踪,在 computed/template + * 内调用 isGenerating(convId) 会建立响应式依赖,Set 变更时自动触发更新。 + * 单会话场景:Set 含 0 或 1 元素,行为与原单值 generatingConvId 等价。 + */ +function addGenerating(convId: string): void { + state.generatingConvs.add(convId) +} +function removeGenerating(convId: string): void { + state.generatingConvs.delete(convId) +} +function isGenerating(convId: string | null | undefined): boolean { + return !!convId && state.generatingConvs.has(convId) +} + /** * AI Store 统一入口 — 返回单例 state + 全部方法。 * @@ -197,6 +217,10 @@ export function useAiStore() { return { state, filteredConversations, + // F-09: 生成态 Set helper(替代单值 generatingConvId 的读写) + addGenerating, + removeGenerating, + isGenerating, ...useAiEvents(), ...useAiStream(), ...useAiSend(),