新增: F-15阶段2手动上下文管理(2 IPC+3事件+前端按钮+status渲染)
This commit is contained in:
168
src/composables/ai/useAiContext.ts
Normal file
168
src/composables/ai/useAiContext.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
//! F-15 阶段2: 手动上下文管理 — 清空 / 压缩
|
||||
//!
|
||||
//! 职责:
|
||||
//! - clearContext():二次确认 → aiApi.clearContext(activeConversationId)
|
||||
//! - compressContext():aiApi.compressContext(activeConversationId, locale)
|
||||
//! + isCompressing ref(loading,防重复点击)
|
||||
//! - 模块级 listener:消费后端 emit 的 ai-chat-event 生命周期事件:
|
||||
//! ai_compressing → isCompressing=true
|
||||
//! ai_compressed → isCompressing=false + (成功 toast 由调用方弹,摘要已在事件里)
|
||||
//! ai_context_cleared → (刷新提示由调用方弹)
|
||||
//! ai_error → isCompressing=false + (错误 toast 由调用方弹)
|
||||
//!
|
||||
//! 设计:
|
||||
//! - 与 useAiEvents.startListener 共存:后者监听 AiChatEvent 联合类型(不含
|
||||
//! ai_compressing/ai_compressed/ai_context_cleared,因 types.ts 不在本任务白名单),
|
||||
//! 本模块注册第二个 listen('ai-chat-event') 监听器,专门按 type 字符串分发上述 4 类事件。
|
||||
//! 两个监听器都触发同一后端事件,但各取所需类型,Tauri 事件多 listener 是合法的。
|
||||
//! - toast/刷新副作用经回调注入(composable 无组件上下文,无 toast ref),
|
||||
//! AiChat.vue 调 initContextListener(onCleared/onCompressed/onError) 注入副作用,
|
||||
//! 避免循环依赖(composable import 组件 toast)。
|
||||
//! - listener 幂等:已注册则直接返回(对齐 useAiEvents.startListener 的并发去重)。
|
||||
//!
|
||||
//! 耦合:
|
||||
//! - aiApi.clearContext / aiApi.compressContext(IPC)
|
||||
//! - state.activeConversationId(读当前对话)
|
||||
//! - appSettings.get(df-ai-language/df-language)(压缩语言;与 useAiSend.sendMessage 同源)
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import { aiApi } from '@/api'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { state } from '@/stores/ai'
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
/// 压缩中 loading(防重复点击;ai_compressing→true / ai_compressed|ai_error→false)
|
||||
const isCompressing = ref(false)
|
||||
|
||||
/// 已注册的 ai-chat-event 第二监听器(生命周期事件);幂等
|
||||
let _unlistenContext: UnlistenFn | null = null
|
||||
|
||||
/// 副作用回调(由 AiChat.vue 注入:toast / 刷新 / 摘要渲染)
|
||||
/// 用对象形式便于选择性注入(只压成功了才需 onCompressed)
|
||||
interface ContextCallbacks {
|
||||
/** 清空完成(ai_context_cleared):toast + 刷新消息列表 */
|
||||
onCleared?: (conversationId: string) => void
|
||||
/** 压缩成功(ai_compressed):toast + 渲染摘要 system 消息 */
|
||||
onCompressed?: (conversationId: string, summary: string) => void
|
||||
/** 压缩失败 / 错误(ai_error 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 路由(仅处理当前活跃对话的事件,
|
||||
* 跨对话事件忽略——清空/压缩仅作用于当前对话,跨对话事件理论不会发生但防御)。
|
||||
*/
|
||||
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 'ai_compressing':
|
||||
isCompressing.value = true
|
||||
break
|
||||
case 'ai_compressed':
|
||||
isCompressing.value = false
|
||||
_callbacks.onCompressed?.(convId || '', payload.summary || '')
|
||||
break
|
||||
case 'ai_context_cleared':
|
||||
_callbacks.onCleared?.(convId || '')
|
||||
break
|
||||
case 'ai_error':
|
||||
// ai_error 也用于流式生成失败(useAiEvents 已处理并 push 错误气泡);
|
||||
// 此处仅在 isCompressing 时复位 loading + 弹错误,避免双重处理流式错误。
|
||||
if (isCompressing.value) {
|
||||
isCompressing.value = false
|
||||
_callbacks.onError?.(convId || '', payload.message || '')
|
||||
}
|
||||
break
|
||||
default:
|
||||
// 其他 type(delta/tool/agent round 等)交 useAiEvents 处理,本监听器不触碰
|
||||
break
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** 释放监听器(AiChat.vue onBeforeUnmount 调) */
|
||||
function stopContextListener(): void {
|
||||
_unlistenContext?.()
|
||||
_unlistenContext = null
|
||||
_callbacks = {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析压缩语言(与 useAiSend.sendMessage 同源逻辑):
|
||||
* - df-ai-language='auto' → df-language(应用主语言)
|
||||
* - 否则用 df-ai-language 指定值
|
||||
* - 兜底 'zh-CN'
|
||||
*/
|
||||
function resolveLanguage(): string {
|
||||
const raw = appSettings.get<string>('df-ai-language', 'auto')
|
||||
if (raw === 'auto') {
|
||||
return appSettings.get<string>('df-language', 'zh-CN')
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
/**
|
||||
* 压缩当前对话上下文。
|
||||
*
|
||||
* 失败兜底:IPC 未送达(spawn 前/provider 配置错)由 catch 回滚 isCompressing + 抛错,
|
||||
* 让调用方弹错误 toast;后端处理失败走 ai_error 事件 → 监听器复位 + onCompressed/onError。
|
||||
*
|
||||
* @throws IPC 失败时抛出原错(调用方 toast)
|
||||
*/
|
||||
async function compressContext(): Promise<void> {
|
||||
const convId = state.activeConversationId
|
||||
if (!convId) return
|
||||
if (isCompressing.value) return // 防重复点击
|
||||
isCompressing.value = true
|
||||
try {
|
||||
await aiApi.compressContext(convId, resolveLanguage())
|
||||
// 成功 IPC 仅表示请求受理;真正压缩完成由 ai_compressed 事件复位 isCompressing
|
||||
} catch (e) {
|
||||
isCompressing.value = false
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空当前对话上下文(仅触发 IPC,二次确认由调用方 AiChat.vue 弹)。
|
||||
*
|
||||
* @throws IPC 失败时抛出原错(调用方 toast)
|
||||
*/
|
||||
async function clearContext(): Promise<void> {
|
||||
const convId = state.activeConversationId
|
||||
if (!convId) return
|
||||
await aiApi.clearContext(convId)
|
||||
}
|
||||
|
||||
export function useAiContext() {
|
||||
return {
|
||||
// 状态
|
||||
isCompressing,
|
||||
// 操作
|
||||
clearContext,
|
||||
compressContext,
|
||||
// 生命周期
|
||||
initContextListener,
|
||||
stopContextListener,
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,10 @@ export async function switchConversation(id: string) {
|
||||
content: m.content || '',
|
||||
model: m.model,
|
||||
timestamp: Date.now(),
|
||||
// F-15 阶段2: 透传 status(archived_segment/compressed/null|active),
|
||||
// 供 AiChat.vue 按 status 折叠分组渲染。types.ts 未含此字段(不在本任务白名单),
|
||||
// 经 as any 透传,消费方 AiChat.vue 同样 cast 读取,类型闭环在两端。
|
||||
status: m.status,
|
||||
toolCalls: m.tool_calls?.map((tc: any): AiToolCallInfo => {
|
||||
// 逐条容错:单条坏 arguments 仅降级为空对象,不影响整条对话回填
|
||||
let args: unknown = {}
|
||||
|
||||
Reference in New Issue
Block a user