重构: 应用状态拆分+事件分发统一+路径授权治本
三项治本性架构重构,解决长期遗留的技术债: 一、应用状态文件拆分(原 1416 行单文件 → 按职责分模块) - 文件授权白名单(AllowedDirs/路径校验/黑名单)独立成模块 - 知识库行为配置(ExtractTrigger/KnowledgeConfig/KV 常量)独立成模块 - LLM 并发控制(LlmConcurrency 双层信号量)独立成模块 - 主文件仅保留 AppState 结构体与初始化逻辑 二、事件分发器统一(消除双监听器架构隐患) - 压缩/清空等 5 类生命周期事件纳入主事件联合类型 - 删除上下文管理模块的独立第二监听器 - 改为由主事件分发器统一处理,经回调注入副作用 - 架构保证:一个事件源 → 一个监听器 → 一个分发器 三、路径授权去除编译期硬编码(分发适配治本) - 启动时不再硬塞编译机路径到授权白名单 - 工程根授权完全靠项目绑定目录自动授权 + 设置页手动配置 - 分发到其他机器后不再有脏白名单数据
This commit is contained in:
@@ -1,26 +1,18 @@
|
||||
//! F-15 阶段2: 手动上下文管理 — 清空 / 压缩
|
||||
//! F-15 手动上下文管理 — 清空 / 压缩
|
||||
//!
|
||||
//! 职责:
|
||||
//! - clearContext():二次确认 → aiApi.clearContext(activeConversationId)
|
||||
//! - compressContext():aiApi.compressContext(activeConversationId, locale)
|
||||
//! + isCompressing ref(loading,防重复点击)
|
||||
//! - 模块级 listener:消费后端 emit 的 ai-chat-event 生命周期事件(serde 默认 PascalCase,
|
||||
//! 对齐 useAiEvents.ts 既有 9 事件):
|
||||
//! AiCompressing → isCompressing=true
|
||||
//! AiManualCompressed → isCompressing=false + (手动 IPC,成功 toast 由调用方弹,摘要已在事件里)
|
||||
//! AiAutoCompressed → isCompressing=false + (loop 自动压缩,静默仅复位,治 Task#1 不弹 toast)
|
||||
//! AiContextCleared → (刷新提示由调用方弹)
|
||||
//! AiError → isCompressing=false + (错误 toast 由调用方弹)
|
||||
//! - initContextListener:注入生命周期回调到 useAiEvents 统一分发器
|
||||
//! (原独立第二监听器已删除,治本:一个事件源→一个监听器→一个分发器)
|
||||
//!
|
||||
//! 设计:
|
||||
//! - 与 useAiEvents.startListener 共存:后者监听 AiChatEvent 联合类型(不含
|
||||
//! AiCompressing/AiManualCompressed|AiAutoCompressed/AiContextCleared,因 types.ts 不在本任务白名单),
|
||||
//! 本模块注册第二个 listen('ai-chat-event') 监听器,专门按 type 字符串分发上述 5 类事件。
|
||||
//! 两个监听器都触发同一后端事件,但各取所需类型,Tauri 事件多 listener 是合法的。
|
||||
//! - toast/刷新副作用经回调注入(composable 无组件上下文,无 toast ref),
|
||||
//! AiChat.vue 调 initContextListener(onCleared/onCompressed/onError) 注入副作用,
|
||||
//! 避免循环依赖(composable import 组件 toast)。
|
||||
//! - listener 幂等:已注册则直接返回(对齐 useAiEvents.startListener 的并发去重)。
|
||||
//! 事件由 useAiEvents.handleLifecycleEvent 统一处理:
|
||||
//! AiCompressing → onCompressing(isCompressing=true)
|
||||
//! AiManualCompressed → onManualCompressed(isCompressing=false + toast + 摘要回填)
|
||||
//! AiAutoCompressed → onAutoCompressed(isCompressing=false,静默不弹 toast)
|
||||
//! AiContextCleared → onCleared(toast + 刷新)
|
||||
//! AiError → onError(仅在 isCompressing 时复位 loading + toast)
|
||||
//!
|
||||
//! 耦合:
|
||||
//! - aiApi.clearContext / aiApi.compressContext(IPC)
|
||||
@@ -28,99 +20,57 @@
|
||||
//! - resolveAiLang(压缩语言;与 useAiSend.sendMessage 同源,实现在 aiShared)
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import { aiApi } from '@/api'
|
||||
import { state } from '@/stores/ai'
|
||||
import { resolveAiLang } from './aiShared'
|
||||
import { setContextCallbacks } from './useAiEvents'
|
||||
|
||||
/// 压缩中 loading(防重复点击;AiCompressing→true / AiManualCompressed|AiAutoCompressed|AiError→false)
|
||||
const isCompressing = ref(false)
|
||||
|
||||
/// 已注册的 ai-chat-event 第二监听器(生命周期事件);幂等
|
||||
let _unlistenContext: UnlistenFn | null = null
|
||||
|
||||
/// 副作用回调(由 AiChat.vue 注入:toast / 刷新 / 摘要渲染)
|
||||
/// 用对象形式便于选择性注入(只压成功了才需 onCompressed)
|
||||
interface ContextCallbacks {
|
||||
/** 清空完成(AiContextCleared):toast + 刷新消息列表 */
|
||||
onCleared?: (conversationId: string) => void
|
||||
/** 手动压缩成功(AiManualCompressed):toast + 渲染摘要 system 消息。
|
||||
* 注:仅 AiManualCompressed 触发;AiAutoCompressed(loop 自动)静默不进此回调(治 Task#1)。 */
|
||||
onCompressed?: (conversationId: string, summary: string) => void
|
||||
/** 压缩失败 / 错误(AiError with conversation_id):toast */
|
||||
onError?: (conversationId: string, message: string) => void
|
||||
}
|
||||
let _callbacks: ContextCallbacks = {}
|
||||
|
||||
/**
|
||||
* 注册 ai-chat-event 生命周期事件监听器(幂等)。
|
||||
* 注入上下文生命周期回调到统一事件分发器(幂等)。
|
||||
*
|
||||
* AiChat.vue onMounted 调一次,注入 toast/刷新副作用回调。
|
||||
* 不能在模块加载时副作用注册(组件未挂载,listener 仍生效但回调为空 → 无意义);
|
||||
* 必须在 AiChat 挂载后注入回调再注册,保证回调已就绪。
|
||||
*
|
||||
* 事件载荷是任意对象(新 type 未进 AiChatEvent 联合,types.ts 不在本任务白名单),
|
||||
* 这里按 type 字符串结构判定 + conversation_id 路由(仅处理当前活跃对话的事件,
|
||||
* 跨对话事件忽略——清空/压缩仅作用于当前对话,跨对话事件理论不会发生但防御)。
|
||||
* 事件由 useAiEvents 统一监听与分发(原独立第二监听器已删除)。
|
||||
*/
|
||||
async function initContextListener(callbacks: ContextCallbacks = {}): Promise<void> {
|
||||
_callbacks = callbacks
|
||||
if (_unlistenContext) return // 幂等
|
||||
_unlistenContext = await listen<{ type?: string; conversation_id?: string; summary?: string; message?: string }>(
|
||||
'ai-chat-event',
|
||||
(e) => {
|
||||
const payload = e.payload
|
||||
if (!payload || typeof payload.type !== 'string') return
|
||||
const convId = payload.conversation_id
|
||||
// 跨对话事件忽略(清空/压缩仅作用于当前活跃对话)
|
||||
if (convId && state.activeConversationId && convId !== state.activeConversationId) return
|
||||
switch (payload.type) {
|
||||
case 'AiCompressing':
|
||||
isCompressing.value = true
|
||||
break
|
||||
case 'AiManualCompressed':
|
||||
// 手动 IPC 压缩(用户点压缩按钮):复位 isCompressing + 弹 toast + 刷新视图回填摘要。
|
||||
isCompressing.value = false
|
||||
_callbacks.onCompressed?.(convId || '', payload.summary || '')
|
||||
break
|
||||
case 'AiAutoCompressed':
|
||||
// 治 Task#1:loop 自动压缩对桌面静默——仅复位 isCompressing(冗余兜底,Auto 路径
|
||||
// 前端 isCompressing 本就为 false,只有手动 compressContext 才置 true),不调
|
||||
// onCompressed(防每次发送误弹 toast + 误 switchConversation 打断 LLM 流)。
|
||||
// 摘要 system 消息已由后端 insert_at,后续 stream 自然带出,无需整会话刷新。
|
||||
isCompressing.value = false
|
||||
break
|
||||
case 'AiContextCleared':
|
||||
_callbacks.onCleared?.(convId || '')
|
||||
break
|
||||
case 'AiError':
|
||||
// AiError 也用于流式生成失败(useAiEvents 已处理并 push 错误气泡);
|
||||
// 此处仅在 isCompressing 时复位 loading + 弹错误,避免双重处理流式错误。
|
||||
if (isCompressing.value) {
|
||||
isCompressing.value = false
|
||||
_callbacks.onError?.(convId || '', payload.message || '')
|
||||
}
|
||||
break
|
||||
default:
|
||||
// 其他 type(delta/tool/agent round 等)交 useAiEvents 处理,本监听器不触碰
|
||||
break
|
||||
async function initContextListener(callbacks: {
|
||||
onCleared?: (conversationId: string) => void
|
||||
onCompressed?: (conversationId: string, summary: string) => void
|
||||
onError?: (conversationId: string, message: string) => void
|
||||
}): Promise<void> {
|
||||
setContextCallbacks({
|
||||
onCompressing: () => { isCompressing.value = true },
|
||||
onManualCompressed: (convId, summary) => {
|
||||
isCompressing.value = false
|
||||
callbacks.onCompressed?.(convId, summary)
|
||||
},
|
||||
onAutoCompressed: () => {
|
||||
// loop 自动压缩静默——仅复位 loading(冗余兜底,Auto 路径前端 loading 本就 false)
|
||||
isCompressing.value = false
|
||||
},
|
||||
onCleared: (convId) => callbacks.onCleared?.(convId),
|
||||
onError: (convId, message) => {
|
||||
// AiError 也用于流式生成失败(useAiEvents 已处理);此处仅在压缩中时复位 + toast,
|
||||
// 避免双重处理流式错误。
|
||||
if (isCompressing.value) {
|
||||
isCompressing.value = false
|
||||
callbacks.onError?.(convId, message)
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/** 释放监听器(AiChat.vue onBeforeUnmount 调) */
|
||||
/** 释放回调(AiChat.vue onBeforeUnmount 调) */
|
||||
function stopContextListener(): void {
|
||||
_unlistenContext?.()
|
||||
_unlistenContext = null
|
||||
_callbacks = {}
|
||||
setContextCallbacks({})
|
||||
}
|
||||
|
||||
/**
|
||||
* 压缩当前对话上下文。
|
||||
*
|
||||
* 失败兜底:IPC 未送达(spawn 前/provider 配置错)由 catch 回滚 isCompressing + 抛错,
|
||||
* 让调用方弹错误 toast;后端处理失败走 AiError 事件 → 监听器复位 + onCompressed/onError。
|
||||
* 让调用方弹错误 toast;后端处理失败走 AiError 事件 → 分发器复位 + onError。
|
||||
*
|
||||
* @throws IPC 失败时抛出原错(调用方 toast)
|
||||
*/
|
||||
@@ -132,7 +82,7 @@ async function compressContext(): Promise<void> {
|
||||
try {
|
||||
await aiApi.compressContext(convId, resolveAiLang())
|
||||
// 后端 IPC 同步等到 LLM 压缩完成才 resolve(非 spawn 异步),成功即复位 isCompressing;
|
||||
// case 'AiManualCompressed' 事件也复位(双保险,防事件丢失致按钮永久禁用)
|
||||
// AiManualCompressed 事件也复位(双保险,防事件丢失致按钮永久禁用)
|
||||
isCompressing.value = false
|
||||
} catch (e) {
|
||||
isCompressing.value = false
|
||||
|
||||
@@ -141,6 +141,29 @@ export interface PendingHelp {
|
||||
}
|
||||
export const pendingHelp = ref<PendingHelp | null>(null)
|
||||
|
||||
// F-15 上下文管理生命周期事件回调(原 useAiContext 独立监听器,现合入统一分发器)。
|
||||
// useAiContext.initContextListener 注入回调,useAiEvents.handleLifecycleEvent 统一分发。
|
||||
// 设计:一个事件源(ai-chat-event)→ 一个监听器(useAiEvents)→ 一个分发器(handleEvent),
|
||||
// 架构保证无双重处理风险(原 useAiContext 第二监听器已删除)。
|
||||
export interface ContextCallbacks {
|
||||
/** 压缩开始(AiCompressing):置 loading=true */
|
||||
onCompressing?: () => void
|
||||
/** 手动压缩成功(AiManualCompressed):置 loading=false + toast + 回填摘要 */
|
||||
onManualCompressed?: (conversationId: string, summary: string) => void
|
||||
/** 自动压缩成功(AiAutoCompressed):静默置 loading=false(不弹 toast) */
|
||||
onAutoCompressed?: () => void
|
||||
/** 上下文已清空(AiContextCleared):toast + 刷新消息列表 */
|
||||
onCleared?: (conversationId: string) => void
|
||||
/** 错误(AiError,含压缩失败):仅在 loading 时置 false + toast */
|
||||
onError?: (conversationId: string, message: string) => void
|
||||
}
|
||||
let _contextCallbacks: ContextCallbacks = {}
|
||||
|
||||
/** 注入上下文生命周期回调(useAiContext.initContextListener 调用) */
|
||||
export function setContextCallbacks(callbacks: ContextCallbacks): void {
|
||||
_contextCallbacks = callbacks
|
||||
}
|
||||
|
||||
// L2 统一状态机 convStates/getConvState/setConvState 已下沉到 aiShared.ts(批4 双轨收口破环:
|
||||
// stores/ai.ts、useAiStream.ts、useAiConversations.ts 等读点 import getConvState 会与
|
||||
// useAiEvents 现有依赖构成环,故下沉到本模块既有的破环共享层)。本模块从 aiShared re-import。
|
||||
@@ -664,11 +687,14 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||
errorType: event.error_type,
|
||||
timestamp: Date.now(),
|
||||
} as AiMessage)
|
||||
// F-15 上下文管理:AiError 也用于压缩 IPC 失败场景。通知上下文回调复位 loading +
|
||||
// 弹错误 toast(若处于压缩中)。与上方流式错误处理不冲突——上下文回调内部判 isCompressing 守卫。
|
||||
_contextCallbacks.onError?.(event.conversation_id || '', event.error)
|
||||
return true
|
||||
}
|
||||
|
||||
case 'AiHelpRequired': {
|
||||
// L1 求助协议(§2.3,2026-06-21):后端断路器熔断已 guard.reset(generating=false,loop 终止)。
|
||||
// L1 求助协议(§2.3,2026-06-21):后端断路器瘝断已 guard.reset(generating=false,loop 终止)。
|
||||
// 前端按终止态收尾(对齐 AiError 分支:清看门狗/流式态/快照/队尾文本 flushCurrentText),
|
||||
// 但不创建错误气泡(求助非错误,是 AI 主动求助)——改为翻 pendingHelp 驱动求助卡(HelpRequiredCard)
|
||||
// 显 reason + options 按钮供用户选。
|
||||
@@ -710,6 +736,28 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
// F-15 上下文管理生命周期事件(原 useAiContext 独立监听器,现合入统一分发器)
|
||||
case 'AiCompressing':
|
||||
_contextCallbacks.onCompressing?.()
|
||||
return true
|
||||
|
||||
case 'AiManualCompressed': {
|
||||
// 手动 IPC 压缩(用户点压缩按钮):复位 loading + 弹 toast + 刷新视图回填摘要。
|
||||
_contextCallbacks.onManualCompressed?.(event.conversation_id || '', event.summary || '')
|
||||
return true
|
||||
}
|
||||
|
||||
case 'AiAutoCompressed': {
|
||||
// loop 自动压缩对桌面静默——仅复位 loading(冗余兜底,Auto 路径前端 loading 本就为 false),
|
||||
// 不调 onManualCompressed(防每次发送误弹 toast + 误 switchConversation 打断 LLM 流)。
|
||||
_contextCallbacks.onAutoCompressed?.()
|
||||
return true
|
||||
}
|
||||
|
||||
case 'AiContextCleared':
|
||||
_contextCallbacks.onCleared?.(event.conversation_id || '')
|
||||
return true
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user