diff --git a/src/stores/ai.ts b/src/stores/ai.ts index a1869c2..6f05111 100644 --- a/src/stores/ai.ts +++ b/src/stores/ai.ts @@ -19,8 +19,23 @@ //! 故 useAiStore() 之外无需手动 init;以下 import 即触发恢复逻辑 //! - state 为模块级单例,全应用共享同一引用 -import { reactive } from 'vue' +import { reactive, watch } from 'vue' import type { AiChatEvent, AiConversationSummary, AiMessage, AiProviderConfig, AiToolCallInfo, 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 +/** push 单次增量上限(用于区分 push 增长 vs 整体替换)。useAiSend 连续 push user+ai=2,其余事件 push 1。 */ +const MESSAGE_PUSH_BURST = 2 import { useAiEvents } from '../composables/ai/useAiEvents' import { useAiStream } from '../composables/ai/useAiStream' import { useAiSend } from '../composables/ai/useAiSend' @@ -64,6 +79,25 @@ export const state = reactive({ // 若有外部模块仍从本文件 import 这些类型,下方 re-export 兜底: export type { AiChatEvent, AiConversationSummary, AiMessage, AiProviderConfig, AiToolCallInfo, SkillInfo } +/** + * messages 软上限守卫:push 式增长超 MESSAGE_CAP 时,splice 掉最早条滚动淘汰。 + * + * 仅拦截 push(oldVal>0 且 newVal-oldVal ≤ MESSAGE_PUSH_BURST);整体替换(切换对话) + * 放行,避免截断后端历史。watch 在模块加载即注册(state 单例生命周期内常驻)。 + */ +watch( + () => state.messages.length, + (newLen, oldLen) => { + if ( + oldLen > 0 && + newLen - oldLen <= MESSAGE_PUSH_BURST && + newLen > MESSAGE_CAP + ) { + state.messages.splice(0, newLen - MESSAGE_CAP) + } + }, +) + /** * AI Store 统一入口 — 返回单例 state + 全部方法。 * diff --git a/src/stores/knowledge.ts b/src/stores/knowledge.ts index d175c64..040a19c 100644 --- a/src/stores/knowledge.ts +++ b/src/stores/knowledge.ts @@ -19,6 +19,14 @@ const state = reactive({ error: null as string | null, }) +// ── 异步竞态保护(请求序号,类似 ai.ts messages 的 switch token 模式) ── +// 快速连点(切换筛选/连续检索/重连)时,旧响应晚到会覆盖新响应。序号自增, +// 请求发起时捕获序号,响应回时比对:非最新请求的响应直接丢弃,只取最新。 +// items 序号覆盖 loadList/loadCandidates/search;candidates 与 items 共用同一队列 +// (loadList/search 写 items,loadCandidates 写 candidates),故 items 序号也兜底 candidates。 +let _itemsReqSeq = 0 +let _configReqSeq = 0 + // 7 种知识类型(snake_case,与后端 KnowledgeKind 对齐) // 仅存 key + icon;展示文案(label)走 i18n: $t('knowledge.kind.') export const KNOWLEDGE_KINDS = [ @@ -45,81 +53,118 @@ export function parseTags(tags: string | null): string[] { export function useKnowledgeStore() { // ── 加载列表(默认排除 archived) ── async function loadList(status?: string) { + const seq = ++_itemsReqSeq state.loading = true state.error = null try { - state.items = await knowledgeApi.list(status ?? null) + const result = await knowledgeApi.list(status ?? null) + if (seq !== _itemsReqSeq) return // 旧响应丢弃,只取最新 + state.items = result } catch (e: any) { + if (seq !== _itemsReqSeq) return // 旧请求的失败不污染当前视图 state.error = e?.toString() ?? '加载知识库失败' } finally { - state.loading = false + if (seq === _itemsReqSeq) state.loading = false } } // ── 加载审核收件箱(候选列表) ── async function loadCandidates() { + const seq = ++_itemsReqSeq try { - state.candidates = await knowledgeApi.listCandidates() + const result = await knowledgeApi.listCandidates() + if (seq !== _itemsReqSeq) return // 旧响应丢弃,只取最新 + state.candidates = result } catch (e: any) { + if (seq !== _itemsReqSeq) return state.error = e?.toString() ?? '加载收件箱失败' } } async function search(query: string, kind?: string) { + const seq = ++_itemsReqSeq state.loading = true state.error = null try { - state.items = await knowledgeApi.search({ query, kind }) + const result = await knowledgeApi.search({ query, kind }) + if (seq !== _itemsReqSeq) return // 旧响应丢弃,只取最新 + state.items = result } catch (e: any) { + if (seq !== _itemsReqSeq) return state.error = e?.toString() ?? '检索失败' } finally { - state.loading = false + if (seq === _itemsReqSeq) state.loading = false } } async function create(input: CreateKnowledgeInput) { - const record = await knowledgeApi.create(input) - // 手动录入默认 candidate,刷新收件箱。 - // create 已成功返回 record,刷新失败不应污染成功语义 —— 直接走原始 API + 非阻塞 console.warn, - // 不经 loadCandidates(后者会写 state.error,污染 create 成功语义)。 try { - state.candidates = await knowledgeApi.listCandidates() + const record = await knowledgeApi.create(input) + // 手动录入默认 candidate,刷新收件箱。 + // create 已成功返回 record,刷新失败不应污染成功语义 —— 直接走原始 API + 非阻塞 console.warn, + // 不经 loadCandidates(后者会写 state.error,污染 create 成功语义)。 + try { + state.candidates = await knowledgeApi.listCandidates() + } catch (e: any) { + console.warn('create 后刷新收件箱失败(非阻塞)', e?.toString?.() ?? e) + } + return record } catch (e: any) { - console.warn('create 后刷新收件箱失败(非阻塞)', e?.toString?.() ?? e) + state.error = e?.toString() ?? '创建知识失败' + return null } - return record } async function updateStatus(id: string, status: string) { - await knowledgeApi.updateStatus(id, status) - // 从 items 和 candidates 中同步移除 - state.items = state.items.filter(k => k.id !== id) - state.candidates = state.candidates.filter(k => k.id !== id) + try { + await knowledgeApi.updateStatus(id, status) + // 从 items 和 candidates 中同步移除 + state.items = state.items.filter(k => k.id !== id) + state.candidates = state.candidates.filter(k => k.id !== id) + } catch (e: any) { + state.error = e?.toString() ?? '更新状态失败' + } } async function archive(id: string) { - await knowledgeApi.archive(id) - state.items = state.items.filter(k => k.id !== id) - state.candidates = state.candidates.filter(k => k.id !== id) + try { + await knowledgeApi.archive(id) + state.items = state.items.filter(k => k.id !== id) + state.candidates = state.candidates.filter(k => k.id !== id) + } catch (e: any) { + state.error = e?.toString() ?? '归档失败' + } } // ── 配置 ── async function loadConfig() { + const seq = ++_configReqSeq try { - state.config = await knowledgeApi.getConfig() + const result = await knowledgeApi.getConfig() + if (seq !== _configReqSeq) return // 旧响应丢弃,只取最新 + state.config = result } catch (e: any) { + if (seq !== _configReqSeq) return state.error = e?.toString() ?? '加载配置失败' } } async function saveConfig(config: KnowledgeConfig) { - await knowledgeApi.saveConfig(config) - state.config = config + try { + await knowledgeApi.saveConfig(config) + state.config = config + } catch (e: any) { + state.error = e?.toString() ?? '保存配置失败' + } } async function extractNow() { - await knowledgeApi.extractNow() - await loadCandidates() + try { + await knowledgeApi.extractNow() + await loadCandidates() + } catch (e: any) { + state.error = e?.toString() ?? '立即抽取失败' + } } // ── 生命线:详情 / 编辑 / 事件查询(按需调用,不入全局 state) ──