新增: 多会话并发前端 UI 阶段3/4(F-09 收尾)
后端 per-conv 并发已就绪(d899c58 + CR-03~07),前端呈现层收尾:
阶段3 多会话生成态:
- stores/ai.ts generatingConvId 单值 → generatingConvs:Set + add/remove/isGenerating helper
- useAiEvents 5 处迁移(Completed/Error/路由标记改 per-conv Set 操作,
AiError 精确清出错会话非全局清空,修原 = null 误清其他会话)
- ConversationSidebar 多会话并行生成脉冲指示(isGenerating 各会话独立)
阶段4 独立窗口多会话:
- useAiWindow label ai-detached → ai-detached-{convId}(per-conv 独立窗口)
- localStorage 单 key → per-conv key(防多会话串扰,读路径兼容旧 key 回退)
vue-tsc EXIT 0 + generatingConvId 零代码残留(仅注释)
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -25,7 +25,9 @@ let _streamWatchdog: ReturnType<typeof setTimeout> | 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()
|
||||
|
||||
@@ -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<boolean> {
|
||||
export async function restoreGeneratingState(opts?: { fromMainPanel?: boolean; convId?: string }): Promise<boolean> {
|
||||
// 幂等:已生成中态不再重建(防主面板 + 分离窗口共享 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-<convId>' → 清对应快照;旧 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 窗口跟随主窗口右侧 */
|
||||
|
||||
Reference in New Issue
Block a user