优化: CR-29 ai.ts messages 软上限+knowledge 异步竞态序号/错误统一
①ai.ts MESSAGE_CAP=200 watch(messages.length) 拦截 push 超限 splice 最早条滚动淘汰(条件 oldLen>0 && newLen-oldLen<=2 规避切换对话整体替换大跳变/首次加载误截断),现有 composables push 逻辑零改动; ②knowledge.ts _itemsReqSeq/_configReqSeq 模块级竞态序号(loadList/loadCandidates/search/loadConfig 请求捕获 seq,响应回序号不匹配丢弃旧响应含错误分支,loading 仅最新请求复位); ③create/updateStatus/archive/saveConfig/extractNow 加 try/catch 统一塞 state.error(与 load*/search 一致,CR-28 Knowledge.vue banner 消费)。 批11 agent,vue-tsc 0
This commit is contained in:
@@ -19,8 +19,23 @@
|
|||||||
//! 故 useAiStore() 之外无需手动 init;以下 import 即触发恢复逻辑
|
//! 故 useAiStore() 之外无需手动 init;以下 import 即触发恢复逻辑
|
||||||
//! - state 为模块级单例,全应用共享同一引用
|
//! - state 为模块级单例,全应用共享同一引用
|
||||||
|
|
||||||
import { reactive } from 'vue'
|
import { reactive, watch } from 'vue'
|
||||||
import type { AiChatEvent, AiConversationSummary, AiMessage, AiProviderConfig, AiToolCallInfo, SkillInfo } from '../api/types'
|
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 { useAiEvents } from '../composables/ai/useAiEvents'
|
||||||
import { useAiStream } from '../composables/ai/useAiStream'
|
import { useAiStream } from '../composables/ai/useAiStream'
|
||||||
import { useAiSend } from '../composables/ai/useAiSend'
|
import { useAiSend } from '../composables/ai/useAiSend'
|
||||||
@@ -64,6 +79,25 @@ export const state = reactive({
|
|||||||
// 若有外部模块仍从本文件 import 这些类型,下方 re-export 兜底:
|
// 若有外部模块仍从本文件 import 这些类型,下方 re-export 兜底:
|
||||||
export type { AiChatEvent, AiConversationSummary, AiMessage, AiProviderConfig, AiToolCallInfo, SkillInfo }
|
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 + 全部方法。
|
* AI Store 统一入口 — 返回单例 state + 全部方法。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ const state = reactive({
|
|||||||
error: null as string | null,
|
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 对齐)
|
// 7 种知识类型(snake_case,与后端 KnowledgeKind 对齐)
|
||||||
// 仅存 key + icon;展示文案(label)走 i18n: $t('knowledge.kind.<key>')
|
// 仅存 key + icon;展示文案(label)走 i18n: $t('knowledge.kind.<key>')
|
||||||
export const KNOWLEDGE_KINDS = [
|
export const KNOWLEDGE_KINDS = [
|
||||||
@@ -45,39 +53,52 @@ export function parseTags(tags: string | null): string[] {
|
|||||||
export function useKnowledgeStore() {
|
export function useKnowledgeStore() {
|
||||||
// ── 加载列表(默认排除 archived) ──
|
// ── 加载列表(默认排除 archived) ──
|
||||||
async function loadList(status?: string) {
|
async function loadList(status?: string) {
|
||||||
|
const seq = ++_itemsReqSeq
|
||||||
state.loading = true
|
state.loading = true
|
||||||
state.error = null
|
state.error = null
|
||||||
try {
|
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) {
|
} catch (e: any) {
|
||||||
|
if (seq !== _itemsReqSeq) return // 旧请求的失败不污染当前视图
|
||||||
state.error = e?.toString() ?? '加载知识库失败'
|
state.error = e?.toString() ?? '加载知识库失败'
|
||||||
} finally {
|
} finally {
|
||||||
state.loading = false
|
if (seq === _itemsReqSeq) state.loading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 加载审核收件箱(候选列表) ──
|
// ── 加载审核收件箱(候选列表) ──
|
||||||
async function loadCandidates() {
|
async function loadCandidates() {
|
||||||
|
const seq = ++_itemsReqSeq
|
||||||
try {
|
try {
|
||||||
state.candidates = await knowledgeApi.listCandidates()
|
const result = await knowledgeApi.listCandidates()
|
||||||
|
if (seq !== _itemsReqSeq) return // 旧响应丢弃,只取最新
|
||||||
|
state.candidates = result
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
if (seq !== _itemsReqSeq) return
|
||||||
state.error = e?.toString() ?? '加载收件箱失败'
|
state.error = e?.toString() ?? '加载收件箱失败'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function search(query: string, kind?: string) {
|
async function search(query: string, kind?: string) {
|
||||||
|
const seq = ++_itemsReqSeq
|
||||||
state.loading = true
|
state.loading = true
|
||||||
state.error = null
|
state.error = null
|
||||||
try {
|
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) {
|
} catch (e: any) {
|
||||||
|
if (seq !== _itemsReqSeq) return
|
||||||
state.error = e?.toString() ?? '检索失败'
|
state.error = e?.toString() ?? '检索失败'
|
||||||
} finally {
|
} finally {
|
||||||
state.loading = false
|
if (seq === _itemsReqSeq) state.loading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function create(input: CreateKnowledgeInput) {
|
async function create(input: CreateKnowledgeInput) {
|
||||||
|
try {
|
||||||
const record = await knowledgeApi.create(input)
|
const record = await knowledgeApi.create(input)
|
||||||
// 手动录入默认 candidate,刷新收件箱。
|
// 手动录入默认 candidate,刷新收件箱。
|
||||||
// create 已成功返回 record,刷新失败不应污染成功语义 —— 直接走原始 API + 非阻塞 console.warn,
|
// create 已成功返回 record,刷新失败不应污染成功语义 —— 直接走原始 API + 非阻塞 console.warn,
|
||||||
@@ -88,38 +109,62 @@ export function useKnowledgeStore() {
|
|||||||
console.warn('create 后刷新收件箱失败(非阻塞)', e?.toString?.() ?? e)
|
console.warn('create 后刷新收件箱失败(非阻塞)', e?.toString?.() ?? e)
|
||||||
}
|
}
|
||||||
return record
|
return record
|
||||||
|
} catch (e: any) {
|
||||||
|
state.error = e?.toString() ?? '创建知识失败'
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateStatus(id: string, status: string) {
|
async function updateStatus(id: string, status: string) {
|
||||||
|
try {
|
||||||
await knowledgeApi.updateStatus(id, status)
|
await knowledgeApi.updateStatus(id, status)
|
||||||
// 从 items 和 candidates 中同步移除
|
// 从 items 和 candidates 中同步移除
|
||||||
state.items = state.items.filter(k => k.id !== id)
|
state.items = state.items.filter(k => k.id !== id)
|
||||||
state.candidates = state.candidates.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) {
|
async function archive(id: string) {
|
||||||
|
try {
|
||||||
await knowledgeApi.archive(id)
|
await knowledgeApi.archive(id)
|
||||||
state.items = state.items.filter(k => k.id !== id)
|
state.items = state.items.filter(k => k.id !== id)
|
||||||
state.candidates = state.candidates.filter(k => k.id !== id)
|
state.candidates = state.candidates.filter(k => k.id !== id)
|
||||||
|
} catch (e: any) {
|
||||||
|
state.error = e?.toString() ?? '归档失败'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 配置 ──
|
// ── 配置 ──
|
||||||
async function loadConfig() {
|
async function loadConfig() {
|
||||||
|
const seq = ++_configReqSeq
|
||||||
try {
|
try {
|
||||||
state.config = await knowledgeApi.getConfig()
|
const result = await knowledgeApi.getConfig()
|
||||||
|
if (seq !== _configReqSeq) return // 旧响应丢弃,只取最新
|
||||||
|
state.config = result
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
if (seq !== _configReqSeq) return
|
||||||
state.error = e?.toString() ?? '加载配置失败'
|
state.error = e?.toString() ?? '加载配置失败'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveConfig(config: KnowledgeConfig) {
|
async function saveConfig(config: KnowledgeConfig) {
|
||||||
|
try {
|
||||||
await knowledgeApi.saveConfig(config)
|
await knowledgeApi.saveConfig(config)
|
||||||
state.config = config
|
state.config = config
|
||||||
|
} catch (e: any) {
|
||||||
|
state.error = e?.toString() ?? '保存配置失败'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function extractNow() {
|
async function extractNow() {
|
||||||
|
try {
|
||||||
await knowledgeApi.extractNow()
|
await knowledgeApi.extractNow()
|
||||||
await loadCandidates()
|
await loadCandidates()
|
||||||
|
} catch (e: any) {
|
||||||
|
state.error = e?.toString() ?? '立即抽取失败'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 生命线:详情 / 编辑 / 事件查询(按需调用,不入全局 state) ──
|
// ── 生命线:详情 / 编辑 / 事件查询(按需调用,不入全局 state) ──
|
||||||
|
|||||||
Reference in New Issue
Block a user