Files
DevFlow/src/stores/ai.ts
绝尘 7d5402951b 新增: 多会话并发前端 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 零代码残留(仅注释)
2026-06-19 20:15:37 +08:00

234 lines
11 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'
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(),
}
}