From 0c348f231165c50ee31ccb5cd9e3a1c0b3d81e51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sat, 27 Jun 2026 00:25:30 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20=E7=88=B6=E2=91=A3F-09=20?= =?UTF-8?q?streaming/currentText=20per-conv(accessor=E5=A7=94=E6=B4=BE,?= =?UTF-8?q?=E7=9C=9F=E5=A4=9A=E4=BC=9A=E8=AF=9D=E5=89=8D=E7=AB=AF=E5=9F=BA?= =?UTF-8?q?=E7=A1=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DEC-07 a(父②后启动)。accessor 委派方案(非 3 独立 Map,禁动域零触碰): - aiShared.ts: convStreamStates reactive Map + getConvStreamState/setConvStreaming/setConvCurrentText/clearConvStreamState - stores/ai.ts: state.streaming/currentText 改 Object.defineProperty accessor,委派 convStreamStates 按 activeConversationId 索引(消费方零改动透明继承) - useAiConversations.ts: deleteConversation 调 clearConvStreamState 防 Map 无限增长 铁律达成: - 单会话回归零变化(activeConversationId 恒定 Map 仅1项,accessor 等价全局单例,13/13 测试) - 禁动域零触碰(useAiSend/useAiEvents/streamingGuard/MessageList 等全零改动) - 切会话隔离(BUG-260624-01 单例根因清除,A 后台 currentText 不串 B 视图) - 内存泄漏防护(deleteConversation + streaming false 收敛删项) vue-tsc EXIT 0。streamingGuard convId 精确路由留 F-09 后续批次(技术债点,单会话无回归)。 --- docs/todo.md | 4 +- src/composables/ai/aiShared.ts | 93 ++++++++++++++++++ src/composables/ai/useAiConversations.ts | 5 +- src/stores/ai.ts | 118 ++++++++++++++++++++--- 4 files changed, 202 insertions(+), 18 deletions(-) diff --git a/docs/todo.md b/docs/todo.md index 1bb014e..74c7167 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -39,7 +39,7 @@ graph TD P1["父① 小bug攒批
✅①.2/①.3(①.1过时·①.4归⑤)"]:::done P2["父② 知识图谱Phase1
✅Phase1(数据层+业务层)"]:::done P3["父③ AI对话体验
📋待办"]:::todo - P4["父④ F-09 per-conv
📋待办"]:::todo + P4["父④ F-09 per-conv
✅前端per-conv(accessor)"]:::done P5["父⑤ 灵感模块
✅完成(⑤.1/①.4/⑤.2)"]:::done P6["父⑥ Phase2-5
🔨Phase2✅·3-5待办"]:::doing P7["父⑦ 技术债
📋待办"]:::todo @@ -57,7 +57,7 @@ graph TD | **父①** 小bug攒批 | ✅ 完成 | ①.2 白名单✅(settings.rs) / ①.3 priority✅(idea.rs) / ①.1 BUG层1❌过时(F-260619-03 方案①取代,层2待决策) / ①.4 雷达图→归父⑤ | — | | **父②** 知识图谱Phase1 | ✅ Phase1完成 | ②.1 V29迁移✅ / ②.2 TaskRecord+TaskLinkRepo✅ / ②.3 IPC(create_task扩展+task_link CRUD+move_queue+get_tree)✅ / ②.4 父聚合✅(set_status_for_aggregation绕status收口) / ②.5 AI工具6✅(基线38) | G1(弱) | | **父③** AI对话体验 | 📋 待办 | ③.1 B-260619-04 ToolCard / ③.2 REFACTOR-260619-04 审批状态机 | — | -| **父④** F-09 per-conv | 📋 待办 | ④.1 streaming/currentText per-conv | — | +| **父④** F-09 per-conv | ✅ 前端per-conv | ④.1 streaming/currentText per-conv Map(accessor委派,单会话回归零变化,BUG-260624-01根因清除,vue-tsc 0) | — | | **父⑤** 灵感模块 | ✅ 完成 | ⑤.1 软删除✅ / ①.4 雷达图✅ / ⑤.2 #05✅/#06拆const✅/#09/#10表单(逗号tags)✅ / #07 DEC-02保留purge(不改) / 附:priority_from_i32跨层映射修复(对齐前端0=critical) | #07→②.1 | | **父⑥** Phase2-5 | 🔨 Phase2✅ | ⑥.1事件流✅(V30 project_events+埋点9处+timeline IPC+AI工具) / ⑥.2基础设施待办 / ⑥.3注入(依赖父②+父④) / ⑥.4前端 | ⑥→父②, ⑥.3→父②+父④+G1 | | **父⑦** 技术债 | 📋 待办 | SMELL-P1-6 / conditions / CR缓存 / UX分页 / 审批超时 / miniapp / 双监听器 | — (穿插) | diff --git a/src/composables/ai/aiShared.ts b/src/composables/ai/aiShared.ts index 6a4c941..3e58ce7 100644 --- a/src/composables/ai/aiShared.ts +++ b/src/composables/ai/aiShared.ts @@ -170,3 +170,96 @@ export function setConvState(convId: string | null | undefined, s: ConvState): v convStates.set(convId, s) } } + +// ── F-09 B 路线 per-conv 流式态(streaming/currentText) ── +// +// 背景(DEC-07a 父④):原 `state.streaming`(bool)/`state.currentText`(string)是 store 全局单例, +// F-09 多会话并发下:A 后台生成中切到 B 会话,B 末条 AI 气泡命中 `isLastAi && streaming && +// currentText` 会渲染 A 残留 currentText(BUG-260624-01 同源根因)。本批改 per-conv Map,各会话 +// 独立累积流式文本/流式开关,切会话即切到该会话的 stream state,真并发不串话。 +// +// 铁律:单会话回归零变化。per-conv Map 在单会话场景只有 activeConversationId 一项,等价原全局 +// 单例。state.streaming/currentText 改为委派本 Map 的 accessor(stores/ai.ts),所有现有消费方 +// (streamingGuard/useAiEvents/useAiSend/useAiConversations/useAiWindow/useAiPanel/MessageList/ +// ChatInput/TopBar/AiChat)读写 `store.state.streaming` / `store.state.currentText` 零改动透明继承。 +// +// 下沉到 aiShared.ts(与 convStates 同位置)的原因:stores/ai.ts 已 import aiShared(getConvState), +// 把 per-conv Map 放这里不新增依赖环;且与 convStates 同款 per-conv 模式集中管理,语义聚合。 +// +// 类型:streaming=boolean(该会话是否正在流式输出),currentText=string(该会话累积的流式文本)。 +export interface ConvStreamState { + streaming: boolean + currentText: string +} + +/** + * per-conv 流式态真相源:convId → {streaming, currentText}。 + * + * 模块级 reactive Map(对齐 convStates 模式)。stores/ai.ts 的 state.streaming/currentText + * accessor 委派本 Map 按 activeConversationId 索引读写,响应式依赖经 Vue reactive Map proxy 跟踪。 + * + * 收敛:streaming=false 时删 Map 项(对齐 convStates idle 收敛,Map 不持陈旧 false 项); + * 写 true 时按需 set。currentText 写空串等同收敛(下次写非空重新 set)。 + */ +export const convStreamStates = reactive(new Map()) + +/** + * 取某 conv 的 stream state;未追踪过返回 null。 + * + * 单会话场景:activeConversationId 恒定,Map 仅此一项,等价原全局单例。 + */ +export function getConvStreamState(convId: string | null | undefined): ConvStreamState | null { + if (!convId) return null + return convStreamStates.get(convId) ?? null +} + +/** + * 取某 conv 的 stream state,不存在则惰性创建(供 streaming/currentText 写入路径建项)。 + * 惰性建对齐 per-conv「写时建」语义——读路径(get)不建,仅写入路径(set)建。 + */ +function ensureConvStreamState(convId: string): ConvStreamState { + let s = convStreamStates.get(convId) + if (!s) { + s = { streaming: false, currentText: '' } + convStreamStates.set(convId, s) + } + return s +} + +/** + * 写某 conv 的 streaming 态(false 时收敛删 Map 项,对齐 convStates idle 语义)。 + * + * @param convId 目标会话 id + * @param value streaming 目标值 + */ +export function setConvStreaming(convId: string | null | undefined, value: boolean): void { + if (!convId) return + if (value) { + ensureConvStreamState(convId).streaming = value + } else { + // false 收敛:若该 conv 已无任何活跃态(streaming=false 且 currentText 空),删 Map 项避免陈旧。 + const cur = convStreamStates.get(convId) + if (cur) { + cur.streaming = false + if (!cur.currentText) convStreamStates.delete(convId) + } + } +} + +/** + * 写某 conv 的 currentText(空串时仅清字段,streaming 仍 true 则保留项)。 + * + * @param convId 目标会话 id + * @param value currentText 目标值(空串=清流式累积,但会话可能仍在 streaming) + */ +export function setConvCurrentText(convId: string | null | undefined, value: string): void { + if (!convId) return + ensureConvStreamState(convId).currentText = value +} + +/** 清某 conv 的 stream state(会话删除/收尾彻底清,删整个 Map 项)。 + * 注:日常 streaming=false/currentText='' 收敛走 setConvStreaming 内联删,本函数用于会话级删除。 */ +export function clearConvStreamState(convId: string | null | undefined): void { + if (!convId) return + convStreamStates.delete(convId) +} diff --git a/src/composables/ai/useAiConversations.ts b/src/composables/ai/useAiConversations.ts index 4f4965a..57e4f8d 100644 --- a/src/composables/ai/useAiConversations.ts +++ b/src/composables/ai/useAiConversations.ts @@ -10,7 +10,7 @@ import { state } from '@/stores/ai' import { notifyConversationChanged } from './useAiEvents' import { persistUiState } from './useAiPanel' import { setStreaming } from './streamingGuard' -import { nextMsgId, getConvState } from './aiShared' +import { nextMsgId, getConvState, clearConvStreamState } from './aiShared' import { t } from '@/i18n/i18n-helpers' import type { AiMessage, AiToolCallInfo } from '@/api/types' @@ -241,6 +241,9 @@ export async function switchConversation(id: string) { /** 删除会话;若删的是当前活跃会话则清空消息+移除活跃 id 持久化 */ async function deleteConversation(id: string) { await aiApi.deleteConversation(id) + // F-09 per-conv:清该会话的 stream state(streaming/currentText),防 convStreamStates Map 无限增长 + // (与 convStates/待审批等 per-conv 资源同款会话级清理语义)。 + clearConvStreamState(id) if (state.activeConversationId === id) { state.activeConversationId = null void appSettings.remove('df-ai-active-conv') diff --git a/src/stores/ai.ts b/src/stores/ai.ts index 0725460..c28ed66 100644 --- a/src/stores/ai.ts +++ b/src/stores/ai.ts @@ -51,7 +51,8 @@ const PARTS_SIZE_BUDGET = 50 * 1024 * 1024 // 50MB(base64 char 计) import { useAiEvents } from '@/composables/ai/useAiEvents' import { useAiStream } from '@/composables/ai/useAiStream' // 批4 双轨收口:getConvState 从 aiShared 取(下沉破环,避免 stores/ai↔useAiEvents 反向环)。 -import { getConvState } from '@/composables/ai/aiShared' +// F-09 per-conv streaming/currentText(DEC-07a 父④):convStreamStates Map + 辅助函数亦从 aiShared 取。 +import { getConvState, getConvStreamState, setConvStreaming, setConvCurrentText } from '@/composables/ai/aiShared' import { useAiSend, initDrainQueueListener } from '@/composables/ai/useAiSend' import { useAiApproval } from '@/composables/ai/useAiApproval' import { useAiConversations } from '@/composables/ai/useAiConversations' @@ -60,22 +61,67 @@ import { useAiPanel } from '@/composables/ai/useAiPanel' import { useAiContext } from '@/composables/ai/useAiContext' /// 模块级单例 state — 全应用共享(composables 通过 `import { state } from '@/stores/ai'` 取用) -export const state = reactive({ - messages: [] as AiMessage[], - streaming: false, - currentText: '', - pendingApprovals: [] as AiToolCallInfo[], +// +// F-09 B 路线 per-conv streaming/currentText(DEC-07a 父④): +// `streaming`/`currentText` 不再是全局单例字段,改为 accessor 委派 aiShared.convStreamStates +// (per-conv Map,定义于 composables/ai/aiShared.ts)按 activeConversationId 索引读写。 +// +// 单会话回归铁律:单会话场景 activeConversationId 恒定,Map 仅此一项,accessor 行为等价原全局 +// 单例(读返回该项值,写改该项值)。所有现有消费方 `store.state.streaming` / `store.state.currentText` +// 零改动透明继承(read = get active 项,write = set active 项)。 +// +// 多会话收益:切会话(activeConversationId 变)即切到该会话的 stream state,A 后台生成中切到 B, +// B 的 streaming/currentText 读各自 Map 项,A 残留 currentText 不串到 B 视图(BUG-260624-01 根因清除)。 +// +// accessor 实现要点: +// - get:读 activeConversationId → 取 convStreamStates 项;无 active/无项 → streaming 返回 false、 +// currentText 返回 ''(与原全局单例初值一致,空安全)。 +// - set:读 activeConversationId → 写 convStreamStates 对应项;无 active 则 no-op(无目标 conv +// 无法路由,原全局单例此场景也写无意义的全局值,no-op 更干净)。 +// - reactive 追踪:Vue 3 reactive 包装带 accessor 描述符的对象时,会保留 get/set 并经 +// Reflect.get/set 调用,track/trigger 正常工作。`state.streaming` 读取建立对 activeConversationId +// + 对应 Map 项的响应式依赖,任一变化触发更新。 +// - 闭包引用 state 本身:get/set 需读 state.activeConversationId,用下方 `stateRef` 闭包变量指向 +// reactive 包装后的对象(先声明后赋值,accessor 闭包延迟读 stateRef,避免 TDZ)。 +const _stateBase: { + messages: AiMessage[] + // F-09 per-conv:streaming/currentText 是 accessor(下方 Object.defineProperty 定义), + // 委派 aiShared.convStreamStates 按 activeConversationId 索引读写。streaming/currentText + // 不在字面量显式赋值(由 accessor 提供),故 _stateBase 类型不含此两键; + // 通过下方 StateWithStream 接口扩展导出 state 的完整类型(消费方编译可见)。 + pendingApprovals: AiToolCallInfo[] + providers: AiProviderConfig[] + activeProvider: string | null + panelOpen: boolean + maximized: boolean + conversations: AiConversationSummary[] + activeConversationId: string | null + sidebarOpen: boolean + sidebarWidth: number + detached: boolean + docked: boolean + skills: SkillInfo[] + queue: { text: string; skill?: string; enqueuedAt: number; parts?: ContentPart[] }[] + agentRound: number + archivedCollapsed: boolean + foldedGroups: Record + searchQuery: string + lastTokenUsage: { prompt: number; completion: number; total: number } | null + convTokenTotal: { prompt: number; completion: number; total: number } | null +} = { + messages: [], + pendingApprovals: [], // 批4 双轨收口:generatingConvs(Set,bool 轨)已退役。会话生成态真相归 convStates // (reactive Map,enum 轨),定义于 composables/ai/aiShared.ts(下沉破环)。 // 消费方经 isGenerating(convId) helper 或直接 getConvState(convId) 读,不再读 state.xxx。 - providers: [] as AiProviderConfig[], - activeProvider: null as string | null, + providers: [], + activeProvider: null, panelOpen: true, // 面板模式:maximized=全宽占满main | sidebar=固定宽侧栏(可拖拽) maximized: true, // 对话管理 - conversations: [] as AiConversationSummary[], - activeConversationId: null as string | null, + conversations: [], + activeConversationId: null, sidebarOpen: false, // 侧栏宽度(px,可拖拽;clamp 120~280,默认 200;持久化于 df-ai-ui。UX-2025-16) sidebarWidth: 200, @@ -84,24 +130,66 @@ export const state = reactive({ // 吸附跟随中 docked: false, // 本机技能(`/` 联想) - skills: [] as SkillInfo[], + skills: [], // 待发送队列(生成中发的消息排队,AiCompleted 后自动续发;enqueuedAt 记录入队时间供超时检测) // F-260614-05 Phase 2b: parts 挂多模态图片片(供 drainQueue/force 续发时本地 user 消息渲染图) - queue: [] as { text: string; skill?: string; enqueuedAt: number; parts?: ContentPart[] }[], + queue: [], // Agentic 循环当前轮次(0=非agentic/未开始;>0=收到后端 AiAgentRound 事件的 round) // AE-2025-07:进度条用; AiCompleted/AiError 复位为 0 agentRound: 0, // 归档分组折叠态(默认折叠) archivedCollapsed: true, // 时间分组折叠态(默认:更早折叠,今天/昨天展开;持久化进 df-ai-ui) - foldedGroups: { today: false, yesterday: false, earlier: true } as Record, + foldedGroups: { today: false, yesterday: false, earlier: true }, // 对话搜索关键字(UX-06 §3.1):非空时侧栏取消时间分组,平铺展示标题匹配的会话(updated_at DESC)。 // 清空恢复原分组。消费方:Sidebar 渲染(读 searchQuery 决定走 filteredConversations 还是 groupedActive)。 searchQuery: '', // token 用量展示(受 df-show-token-usage 开关控制;lastTokenUsage=最近一条回复,convTokenTotal=当前对话累计) - lastTokenUsage: null as { prompt: number; completion: number; total: number } | null, - convTokenTotal: null as { prompt: number; completion: number; total: number } | null, + lastTokenUsage: null, + convTokenTotal: null, +} + +// F-09 per-conv streaming/currentText accessor 委派层:先声明 stateRef 占位, +// reactive 包装后回填,accessor 闭包经 stateRef 读 activeConversationId。 +let stateRef: typeof _stateBase | null = null + +// 在 _stateBase 上定义 streaming/currentText accessor(委派 convStreamStates per-conv Map)。 +// 注:用 Object.defineProperty 而非字面量 getter,避免与上方类型声明冲突;reactive 会保留此描述符。 +Object.defineProperty(_stateBase, 'streaming', { + enumerable: true, + configurable: true, + get(): boolean { + const s = stateRef + if (!s) return false + return getConvStreamState(s.activeConversationId)?.streaming ?? false + }, + set(value: boolean) { + const s = stateRef + if (!s) return + setConvStreaming(s.activeConversationId, value) + }, }) +Object.defineProperty(_stateBase, 'currentText', { + enumerable: true, + configurable: true, + get(): string { + const s = stateRef + if (!s) return '' + return getConvStreamState(s.activeConversationId)?.currentText ?? '' + }, + set(value: string) { + const s = stateRef + if (!s) return + setConvCurrentText(s.activeConversationId, value) + }, +}) + +export const state = reactive(_stateBase) as typeof _stateBase & { + streaming: boolean + currentText: string +} +// 回填 stateRef:accessor 闭包读此引用(经 reactive 包装后的代理对象,activeConversationId 变化可追踪)。 +stateRef = state // 旁注:此处不再保留 type-only 导出(AiChatEvent 等),因组件直接从 api/types import。 // 若有外部模块仍从本文件 import 这些类型,下方 re-export 兜底: