Files
DevFlow/src/stores/ai.ts
绝尘 76aec62d36 修复: stores/ai 模块顶层 __bindMessages TDZ 防御
- __bindMessages 包装 try/catch,aiShared 未就绪时延迟到微任务重试
- 防 ES module 循环初始化顺序导致的 Cannot access before initialization
2026-06-30 23:26:40 +08:00

331 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! AI 聊天 Store — 模块级单例 state + useAiStore() 统一入口
//!
//! 架构(路线A:state 单例 + 逻辑外移到 composables):
//! - 本文件只保留模块级单例 reactive state(19 字段)+ useAiStore() 统一入口
//! - 所有方法搬到 src/composables/ai/*,通过 ...useAiXxx(state) 展开到 useAiStore() 返回值
//! - 组件用法不变:`const { state, sendMessage, ... } = useAiStore()` 零改动
//! - composables 之间通过模块级 import 直接互调(events↔send↔stream 耦合)
//!
//! composables 职责:
//! - useAiEvents startListener/stopListener/handleEvent/flushCurrentText/findToolCall/friendlyError/notifyConversationChanged
//! - useAiStream onStreamTimeout/resetStreamWatchdog/clearStreamWatchdog + STREAM_TIMEOUT_MS
//! - useAiSend sendMessage/approveToolCall/drainQueue/cancelQueued/clearQueue/stopChat
//! - useAiConversations loadConversations/newConversation/switchConversation/deleteConversation/renameConversation/archiveConversation/toggleArchivedFold/toggleSidebar
//! - useAiWindow detachPanel/reattachPanel/resumeInDetached/closeDetachedWindow/dockDetached/syncToMain/startFollowMain/stopFollowMain
//! - useAiPanel togglePanel/toggleMaximize/loadProviders/loadSkills/setProvider/clearChat + restoreUiState/persistUiState
//!
//! 注意:
//! - 各 composable 模块加载时会执行其顶层副作用(useAiPanel.restoreUiState + watch appSettings),
//! 故 useAiStore() 之外无需手动 init;以下 import 即触发恢复逻辑
//! - state 为模块级单例,全应用共享同一引用
import { computed, reactive, watch } from 'vue'
import type { AiChatEvent, AiConversationSummary, AiMessage, AiProviderConfig, AiToolCallInfo, ContentPart, SkillInfo } from '@/api/types'
/**
* 单对话 messages 软上限(滚动淘汰)。
*
* 设计要点:
* - 单对话内 messages 线性增长(useAiSend push user+ai / useAiEvents push err 审批 / useAiStream push delta),
* 长会话 + 大工具结果内存可观;push 触发后,若超限 splice 掉最早条,保近期消息可见。
* - 切换对话走 `state.messages = rawMsgs` 整体替换(见 useAiConversations),是另一减负点,
* 不受本上限约束 —— watch 仅拦截"push 式增长"(length 小幅单调增,oldVal>0 且增量≤2),
* 对整体替换(length 大跳变 / oldVal=0 首次加载)放行,保留后端历史全量。
* - 现有 push 逻辑(composables 内)零改动,上限由本模块 watch 统一兜底。
*/
const MESSAGE_CAP = 200
/**
* 单对话 messages 的 parts 体积软上限(UX-260617-20)。
*
* 背景:MESSAGE_CAP 仅按"条数"裁剪,不感知单条体积。多模态 base64 图片(F-260614-05)
* 单条可达数 MB,极端 200 条 × 5MB ≈ 1GB 会撑爆渲染内存。
*
* 设计:
* - 仅度量 image 片的 base64/url 字符串长度(其余 text/diff 普通字符串可忽略量级);
* base64 char 数 ≈ 编码后字节数(3/4 编码开销),量纲稳定可作预算代理。
* - 超出预算时,从最旧消息起逐条裁掉其 parts(置空数组),保条数与文本可见、丢历史图片。
* - 与 MESSAGE_CAP 互补:条数先裁体积小场景,体积裁多模态大体积场景;两 watch 各司其职。
*/
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 反向环)。
// F-09 per-conv streaming/currentText(DEC-07a 父④):convStreamStates Map + 辅助函数亦从 aiShared 取。
import { getConvState, getConvStreamState, setConvStreaming, setConvCurrentText, __bindMessages } from '@/composables/ai/aiShared'
import { useAiSend, initDrainQueueListener } from '@/composables/ai/useAiSend'
import { useAiApproval } from '@/composables/ai/useAiApproval'
import { useAiConversations } from '@/composables/ai/useAiConversations'
import { useAiWindow } from '@/composables/ai/useAiWindow'
import { useAiPanel } from '@/composables/ai/useAiPanel'
import { useAiContext } from '@/composables/ai/useAiContext'
/// 模块级单例 state — 全应用共享(composables 通过 `import { state } from '@/stores/ai'` 取用)
//
// 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: [],
activeProvider: null,
panelOpen: true,
// 面板模式maximized=全宽占满main | sidebar=固定宽侧栏(可拖拽)
maximized: true,
// 对话管理
conversations: [],
activeConversationId: null,
sidebarOpen: false,
// 侧栏宽度(px,可拖拽;clamp 120~280,默认 200;持久化于 df-ai-ui。UX-2025-16)
sidebarWidth: 200,
// 窗口分离
detached: false,
// 吸附跟随中
docked: false,
// 本机技能(`/` 联想)
skills: [],
// 待发送队列生成中发的消息排队AiCompleted 后自动续发enqueuedAt 记录入队时间供超时检测)
// F-260614-05 Phase 2b: parts 挂多模态图片片(供 drainQueue/force 续发时本地 user 消息渲染图)
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 },
// 对话搜索关键字(UX-06 §3.1):非空时侧栏取消时间分组,平铺展示标题匹配的会话(updated_at DESC)。
// 清空恢复原分组。消费方:Sidebar 渲染(读 searchQuery 决定走 filteredConversations 还是 groupedActive)。
searchQuery: '',
// token 用量展示(受 df-show-token-usage 开关控制;lastTokenUsage=最近一条回复,convTokenTotal=当前对话累计)
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
// 注入 messages getter 到 aiShared 共享层(破循环依赖,避免 Vite 打包 TDZ)。
// 用 getter 而非数组引用:switchConversation/newConversation 会整体替换 state.messages,
// 缓存引用会指向陈旧数组(findToolCall 找不到新数组的工具卡片 → Completed 事件无法更新 → 卡片卡 running)。
// 延迟执行:wrap 在 try/catch 中,防 ES module 初始化顺序导致 aiShared 未就绪时炸
try {
__bindMessages(() => state.messages)
} catch (e) {
// aiShared 模块初始化中(ES module 循环),延迟到微任务里重试
Promise.resolve().then(() => __bindMessages(() => state.messages))
}
// 旁注:此处不再保留 type-only 导出(AiChatEvent 等),因组件直接从 api/types import。
// 若有外部模块仍从本文件 import 这些类型,下方 re-export 兜底:
export type { AiChatEvent, AiConversationSummary, AiMessage, AiProviderConfig, AiToolCallInfo, SkillInfo }
/**
* messages 软上限守卫:push 式增长超 MESSAGE_CAP 时,splice 掉最早条滚动淘汰。
*
* 仅拦截 push(oldVal>0 且增量≤2);整体替换(切换对话)
* 放行,避免截断后端历史。watch 在模块加载即注册(state 单例生命周期内常驻)。
*/
watch(
() => state.messages.length,
(newLen, oldLen) => {
if (
oldLen > 0 &&
newLen - oldLen <= 2 && // 最多连续 push 2 条触发滚动(useAiSend user+ai=2)
newLen > MESSAGE_CAP
) {
state.messages.splice(0, newLen - MESSAGE_CAP)
}
},
)
/**
* 估算单条消息 parts 的字符体积(base64/url 字符串长度之和,UX-260617-20)。
*
* 只计 image 片的 base64 与 url(大体量载荷);text 片(正文/工具结果文本)不在此预算口径内,
* 避免对常规长文本消息误伤。无 parts 或非 image 片返回 0。
*/
function estimatePartsSize(parts: ContentPart[] | undefined): number {
if (!parts || parts.length === 0) return 0
let size = 0
for (const p of parts) {
if (p.type === 'image') {
if (p.base64) size += p.base64.length
if (p.url) size += p.url.length
}
}
return size
}
/**
* parts 体积软上限守卫(UX-260617-20):push 式增长使总 parts 体积超 PARTS_SIZE_BUDGET 时,
* 从最旧消息起逐条置空其 parts(丢历史多模态图,保文本/条数)。
*
* 仅拦截 push(同上 length watch 的判定),避免切换对话整体替换时误裁后端历史图。
*/
watch(
() => state.messages.length,
(newLen, oldLen) => {
if (oldLen <= 0 || newLen - oldLen > 2) return
let total = 0
for (const m of state.messages) total += estimatePartsSize(m.parts)
if (total <= PARTS_SIZE_BUDGET) return
// 超预算:从最旧起逐条清 parts,直到总量回落到预算内
for (const m of state.messages) {
if (total <= PARTS_SIZE_BUDGET) break
const partSize = estimatePartsSize(m.parts)
if (partSize > 0) {
m.parts = []
total -= partSize
}
}
},
)
/**
* 对话搜索过滤结果(UX-06 §3.1)。
*
* 行为:
* - searchQuery 为空 → 返回 null(信号:消费方走原时间分组 groupedActive / 归档分组)。
* - 非空 → 在全部对话(活跃+归档)中按标题做 case-insensitive 子串匹配,平铺返回
* (取消分组),按 updated_at DESC 排序,最近改过的在最前。
*
* 设计:过滤逻辑放 store 而非组件,供主窗口/分离窗口两处侧栏复用同一结果;
* 消费方仅需 `store.filteredConversations` 即得数组或 null,UI 连接在侧栏视图内完成。
*/
const filteredConversations = computed<AiConversationSummary[] | null>(() => {
const q = state.searchQuery.trim().toLowerCase()
if (!q) return null
return state.conversations
.filter(c => (c.title ?? '').toLowerCase().includes(q))
.sort((a, b) => (b.updated_at > a.updated_at ? 1 : b.updated_at < a.updated_at ? -1 : 0))
})
/**
* F-09 多会话并发:生成态 helper(批4 双轨收口后改为派生)。
*
* 批4 收口:generatingConvs(Set,bool 轨)已退役,生成态真相归 convStates(enum Map,定义于
* aiShared.ts)。本 helper 改派生自 getConvState:conv_state∈{generating,stopping,compressed}
* (非终止三态)视为生成中,idle/error/null 视为不在生成。桥接语义对齐原 bool 轨 has() 判定。
*
* Vue 3 reactive 对 Map 的 get 有 proxy 跟踪,在 computed/template 内调用 isGenerating(convId)
* 仍建立响应式依赖,convStates 变更时自动触发更新(等价原 Set 行为)。
* addGenerating/removeGenerating 写 helper 已删(写收敛到 useAiEvents setConvState 唯一入口)。
*/
function isGenerating(convId: string | null | undefined): boolean {
if (!convId) return false
const cs = getConvState(convId)
return cs === 'generating' || cs === 'stopping' || cs === 'compressed'
}
/**
* AI Store 统一入口 — 返回单例 state + 全部方法。
*
* 组件零改动:解构 shape 与拆分前完全一致。每次调用都重新展开 composable 方法
* (composable 内部全部是模块级单例实现,展开的只是引用,无重复实例化开销)。
*/
export function useAiStore() {
// ARC-06: 启动 drain-queue 事件监听(由 useAiEvents AiCompleted 经事件总线触发)
void initDrainQueueListener()
return {
state,
filteredConversations,
// F-09: 生成态 helper(批4 双轨收口:仅读 isGenerating 派生自 getConvState;
// addGenerating/removeGenerating 写 helper 已删,写收敛到 useAiEvents setConvState)
isGenerating,
...useAiEvents(),
...useAiStream(),
...useAiSend(),
...useAiApproval(),
...useAiConversations(),
...useAiWindow(),
...useAiPanel(),
...useAiContext(),
}
}