后端 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 零代码残留(仅注释)
234 lines
11 KiB
TypeScript
234 lines
11 KiB
TypeScript
//! 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'
|
||
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'` 取用)
|
||
export const state = reactive({
|
||
messages: [] as AiMessage[],
|
||
streaming: false,
|
||
currentText: '',
|
||
pendingApprovals: [] as AiToolCallInfo[],
|
||
// 正在生成的会话集合(F-09 多会话并发):后端 per-conv 已支持多会话同时生成,
|
||
// 前端用 Set 表达"多会话同时生成"态。生成中切走时用于路由,后台事件不污染当前视图。
|
||
// isGenerating(convId)/addGenerating/removeGenerating helper 经 useAiStore 暴露。
|
||
// 单会话场景行为不变(Set 含 0 或 1 元素)。
|
||
generatingConvs: new Set<string>(),
|
||
providers: [] as AiProviderConfig[],
|
||
activeProvider: null as string | null,
|
||
panelOpen: true,
|
||
// 面板模式:maximized=全宽占满main | sidebar=固定宽侧栏(可拖拽)
|
||
maximized: true,
|
||
// 对话管理
|
||
conversations: [] as AiConversationSummary[],
|
||
activeConversationId: null as string | null,
|
||
sidebarOpen: false,
|
||
// 侧栏宽度(px,可拖拽;clamp 120~280,默认 200;持久化于 df-ai-ui。UX-2025-16)
|
||
sidebarWidth: 200,
|
||
// 窗口分离
|
||
detached: false,
|
||
// 吸附跟随中
|
||
docked: false,
|
||
// 本机技能(`/` 联想)
|
||
skills: [] as SkillInfo[],
|
||
// 待发送队列(生成中发的消息排队,AiCompleted 后自动续发;enqueuedAt 记录入队时间供超时检测)
|
||
// F-260614-05 Phase 2b: parts 挂多模态图片片(供 drainQueue/force 续发时本地 user 消息渲染图)
|
||
queue: [] as { text: string; skill?: string; enqueuedAt: number; parts?: ContentPart[] }[],
|
||
// 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>,
|
||
// 对话搜索关键字(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,
|
||
})
|
||
|
||
// 旁注:此处不再保留 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 多会话并发:生成态 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 + 全部方法。
|
||
*
|
||
* 组件零改动:解构 shape 与拆分前完全一致。每次调用都重新展开 composable 方法
|
||
* (composable 内部全部是模块级单例实现,展开的只是引用,无重复实例化开销)。
|
||
*/
|
||
export function useAiStore() {
|
||
// ARC-06: 启动 drain-queue 事件监听(由 useAiEvents AiCompleted 经事件总线触发)
|
||
void initDrainQueueListener()
|
||
return {
|
||
state,
|
||
filteredConversations,
|
||
// F-09: 生成态 Set helper(替代单值 generatingConvId 的读写)
|
||
addGenerating,
|
||
removeGenerating,
|
||
isGenerating,
|
||
...useAiEvents(),
|
||
...useAiStream(),
|
||
...useAiSend(),
|
||
...useAiApproval(),
|
||
...useAiConversations(),
|
||
...useAiWindow(),
|
||
...useAiPanel(),
|
||
...useAiContext(),
|
||
}
|
||
}
|