新增: 父④F-09 streaming/currentText per-conv(accessor委派,真多会话前端基础)

DEC-07 a(父②后启动)。accessor 委派方案(非 3 独立 Map,禁动域零触碰):
- aiShared.ts: convStreamStates reactive Map<convId,{streaming,currentText}> + 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 后续批次(技术债点,单会话无回归)。
This commit is contained in:
2026-06-27 00:25:30 +08:00
parent 5a893680b7
commit 0c348f2311
4 changed files with 202 additions and 18 deletions

View File

@@ -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<string, boolean>
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<string, boolean>,
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 兜底: