- ARC-06: 提 aiShared.ts 下沉 nextMsgId+_msgCounter(叶子模块),useAiEvents/useAiStream/useAiSend/useAiWindow import 源改 aiShared,useAiStream 不再 import useAiEvents 环消除。watchdog 三函数留 useAiStream(持计时器状态非纯函数)。行为零变化。 - B-34(另一会话走查发现 P0): stores/project.ts:273 selectType→select_type(Tauri 2 IPC 不转 camelCase),对齐后端 workflow.rs:211。F-260615-01 多选审批回归修复——前端传 selectType 后端收 None 归一化 Single 致多选被拒。删错误注释。 批1 wwllb4ith(ARC-06)+主代理(B-34),vue-tsc 0 err
271 lines
10 KiB
TypeScript
271 lines
10 KiB
TypeScript
//! AI 事件监听与分发 — startListener/stopListener/handleEvent 及其辅助函数
|
|
//!
|
|
//! 模块级私有状态(不进 reactive):
|
|
//! - _unlistenAiEvent / _unlistenConvChanged: 已注册的 unlistener
|
|
//! - _startPromise: startListener 并发去重(防 onMounted 与 sendMessage 首发竞态重复注册)
|
|
//!
|
|
//! 耦合:
|
|
//! - handleEvent 调 useAiConversations.loadConversations、useAiSend.drainQueue、
|
|
//! useAiStream.{resetStreamWatchdog,clearStreamWatchdog}、本模块 flushCurrentText/findToolCall/notifyConversationChanged
|
|
//! - nextMsgId 已下沉到 aiShared(原为本模块导出,useAiStream 亦依赖之构成循环依赖,故抽出)
|
|
|
|
import { listen, emit } from '@tauri-apps/api/event'
|
|
import { aiApi } from '../../api'
|
|
import { useAppSettingsStore } from '../../stores/appSettings'
|
|
import { state } from '../../stores/ai'
|
|
import i18n from '../../i18n'
|
|
import { nextMsgId } from './aiShared'
|
|
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
|
|
import { drainQueue } from './useAiSend'
|
|
import { loadConversations } from './useAiConversations'
|
|
import type { AiChatEvent, AiToolCallInfo } from '../../api/types'
|
|
|
|
// composable 内非组件上下文(无 setup),用 vue-i18n 全局实例的 t 而非 useI18n()。
|
|
// 通过 any 中转规避 vue-i18n 深度 message schema 泛型导致的 TS2589(类型实例化过深)。
|
|
const t = ((i18n as any).global.t as (key: string) => string).bind((i18n as any).global)
|
|
|
|
let _unlistenAiEvent: (() => void) | null = null
|
|
let _unlistenConvChanged: (() => void) | null = null
|
|
// startListener 并发去重:防 onMounted 与 sendMessage 首发竞态下重复注册 listener
|
|
// (两回调写同一 state.currentText → 流式文字双倍)
|
|
let _startPromise: Promise<void> | null = null
|
|
|
|
const appSettings = useAppSettingsStore()
|
|
|
|
/** 通知会话列表发生变化(供 newConversation/deleteConversation/rename/archive 等触发刷新侧栏) */
|
|
export function notifyConversationChanged() {
|
|
emit('ai-conversation-changed', {})
|
|
}
|
|
|
|
/** 后端原始错误转用户友好提示 */
|
|
export function friendlyError(raw: string): string {
|
|
if (/404|not\s*found/i.test(raw)) return t('ai.errorNotFound')
|
|
if (/401|403|unauthorized|api[_\s-]?key/i.test(raw)) return t('ai.errorAuth')
|
|
if (/timeout|超时/i.test(raw)) return t('ai.errorTimeout')
|
|
if (/network|connection|ECONN|网络|连接/i.test(raw)) return t('ai.errorNetwork')
|
|
return raw
|
|
}
|
|
|
|
/** 把流式累积的 currentText 回填到最后一条 assistant 消息(AiAgentRound/AiCompleted 收尾共用) */
|
|
export function flushCurrentText() {
|
|
if (!state.currentText) return
|
|
const last = state.messages[state.messages.length - 1]
|
|
if (last && last.role === 'assistant') last.content = state.currentText
|
|
}
|
|
|
|
/**
|
|
* 在全部消息中查找指定 id 的工具调用卡片(用于状态流转)
|
|
*
|
|
* 扫描策略:从尾部反向遍历并提前退出。tool_result/approval/completed
|
|
* 通常对应最近触发的 tool_use(agent 顺序执行工具并尽快回填结果),
|
|
* 反向扫描使其命中落在尾部 → 平均 O(1)、最坏 O(n)。(原正向全量扫描为 O(n²),
|
|
* 每条 tool_result 都从头扫整条历史。)
|
|
*
|
|
* 注:未引入 Map<id, tc> 索引。state.messages 在 newConversation /
|
|
* switchConversation / deleteConversation 等处被整体替换(且这些操作散落在
|
|
* useAiEvents 之外),独立索引极易与响应式数组失配形成"陈旧引用"——状态卡
|
|
* 片读到已不存在的对象。反向扫描零额外状态、改动最小且行为完全一致。
|
|
*/
|
|
export function findToolCall(id: string): AiToolCallInfo | undefined {
|
|
for (let i = state.messages.length - 1; i >= 0; i--) {
|
|
const toolCalls = state.messages[i].toolCalls
|
|
if (!toolCalls) continue
|
|
// 同一消息内工具调用通常很少,沿用正向扫描;命中即返回
|
|
for (let j = 0; j < toolCalls.length; j++) {
|
|
if (toolCalls[j].id === id) return toolCalls[j]
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/** token 用量展示开关(读 appSettings,与 Settings.vue 共享 key `df-show-token-usage`) */
|
|
function isShowTokenUsage(): boolean {
|
|
return appSettings.get<boolean>('df-show-token-usage', false)
|
|
}
|
|
|
|
/** 后端事件分发:按 conversation_id 路由,流式累积文本,工具状态流转,看门狗联动 */
|
|
export function handleEvent(event: AiChatEvent) {
|
|
const convId = event.conversation_id
|
|
// 首次收到事件时同步当前对话 id(后端自动建对话的场景)
|
|
// 同步写 appSettings(SQLite):刷新页面后 loadConversations 据此恢复上次会话
|
|
if (convId && !state.activeConversationId) {
|
|
state.activeConversationId = convId
|
|
void appSettings.set('df-ai-active-conv', convId)
|
|
}
|
|
// 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误时刷新侧边栏
|
|
const isCurrent = !convId || convId === state.activeConversationId
|
|
if (!isCurrent) {
|
|
if (event.type === 'AiCompleted' || event.type === 'AiError') {
|
|
state.generatingConvId = null
|
|
void loadConversations()
|
|
}
|
|
return
|
|
}
|
|
// 标记正在生成的对话(完成/错误事件除外)
|
|
if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError') {
|
|
state.generatingConvId = convId
|
|
}
|
|
// 流式看门狗:活跃事件(delta/工具/新轮/审批结果)重置;审批等待/完成/错误在 case 内 clear
|
|
if (!['AiApprovalRequired', 'AiCompleted', 'AiError'].includes(event.type)) {
|
|
resetStreamWatchdog()
|
|
}
|
|
switch (event.type) {
|
|
case 'AiTextDelta':
|
|
state.currentText += event.delta
|
|
break
|
|
|
|
case 'AiAgentRound': {
|
|
// Agent 循环新一轮:保存当前文本到上一条 assistant 消息,新建空 assistant 消息
|
|
flushCurrentText()
|
|
state.currentText = ''
|
|
state.messages.push({
|
|
id: `ai-${nextMsgId()}`,
|
|
role: 'assistant',
|
|
content: '',
|
|
timestamp: Date.now(),
|
|
})
|
|
break
|
|
}
|
|
|
|
case 'AiToolCallStarted': {
|
|
const info: AiToolCallInfo = {
|
|
id: event.id,
|
|
name: event.name,
|
|
args: event.args,
|
|
status: 'running',
|
|
}
|
|
const lastMsg = state.messages[state.messages.length - 1]
|
|
if (lastMsg && lastMsg.role === 'assistant') {
|
|
lastMsg.toolCalls = lastMsg.toolCalls || []
|
|
lastMsg.toolCalls.push(info)
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'AiToolCallCompleted': {
|
|
const tc = findToolCall(event.id)
|
|
if (tc) {
|
|
tc.status = 'completed'
|
|
tc.result = event.result
|
|
}
|
|
state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id)
|
|
break
|
|
}
|
|
|
|
case 'AiApprovalRequired': {
|
|
clearStreamWatchdog() // 等用户审批,不计超时
|
|
const info: AiToolCallInfo = {
|
|
id: event.id,
|
|
name: event.name,
|
|
args: event.args,
|
|
status: 'pending_approval',
|
|
}
|
|
state.pendingApprovals.push(info)
|
|
const tc = findToolCall(event.id)
|
|
if (tc) {
|
|
tc.status = 'pending_approval'
|
|
tc.reason = event.reason
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'AiApprovalResult': {
|
|
if (!event.approved) {
|
|
const tc = findToolCall(event.id)
|
|
if (tc) {
|
|
tc.status = 'rejected'
|
|
// 与后端落库一致:拒绝结果回填卡片,供 UI 展示拒绝原因
|
|
tc.result = '用户拒绝了此操作'
|
|
}
|
|
state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id)
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'AiCompleted': {
|
|
clearStreamWatchdog()
|
|
flushCurrentText()
|
|
state.currentText = ''
|
|
state.streaming = false
|
|
state.generatingConvId = null
|
|
// token 用量记录(开关开时):lastTokenUsage 供当前回复展示,convTokenTotal 累加对话总量
|
|
if (isShowTokenUsage()) {
|
|
state.lastTokenUsage = {
|
|
prompt: event.prompt_tokens,
|
|
completion: event.completion_tokens,
|
|
total: event.total_tokens,
|
|
}
|
|
if (state.convTokenTotal) {
|
|
state.convTokenTotal.prompt += event.prompt_tokens
|
|
state.convTokenTotal.completion += event.completion_tokens
|
|
state.convTokenTotal.total += event.total_tokens
|
|
} else {
|
|
state.convTokenTotal = { prompt: event.prompt_tokens, completion: event.completion_tokens, total: event.total_tokens }
|
|
}
|
|
}
|
|
// 清理分离窗口生成态快照
|
|
localStorage.removeItem('df-ai-gen')
|
|
localStorage.removeItem('df-ai-text')
|
|
void loadConversations()
|
|
notifyConversationChanged()
|
|
// 队列续发:当前完成后自动发下一条(后端 generating 已复位,不会被"正在生成中"拒绝)
|
|
drainQueue()
|
|
break
|
|
}
|
|
|
|
case 'AiError': {
|
|
clearStreamWatchdog()
|
|
state.streaming = false
|
|
state.generatingConvId = null
|
|
state.currentText = ''
|
|
localStorage.removeItem('df-ai-gen')
|
|
localStorage.removeItem('df-ai-text')
|
|
state.messages.push({
|
|
id: `err-${nextMsgId()}`,
|
|
role: 'assistant',
|
|
content: friendlyError(event.error),
|
|
isError: true,
|
|
timestamp: Date.now(),
|
|
})
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 启动事件监听(幂等 + 并发去重,onMounted 与 sendMessage 首发竞态不会重复注册) */
|
|
export async function startListener() {
|
|
// 已注册 → 直接复用(幂等;sendMessage 每次调用不重复注册)
|
|
if (_unlistenAiEvent && _unlistenConvChanged) return
|
|
// 并发去重:防 onMounted 与 sendMessage 首发竞态重复注册 listener → 文字双倍
|
|
if (_startPromise) return _startPromise
|
|
_startPromise = (async () => {
|
|
_unlistenAiEvent = await aiApi.onEvent(handleEvent)
|
|
_unlistenConvChanged = await listen('ai-conversation-changed', () => { void loadConversations() })
|
|
})()
|
|
try {
|
|
await _startPromise
|
|
} finally {
|
|
_startPromise = null
|
|
}
|
|
}
|
|
|
|
/** 停止事件监听(卸载时调用,释放后端 listener) */
|
|
function stopListener() {
|
|
_unlistenAiEvent?.()
|
|
_unlistenConvChanged?.()
|
|
_unlistenAiEvent = null
|
|
_unlistenConvChanged = null
|
|
}
|
|
|
|
export function useAiEvents() {
|
|
return {
|
|
startListener,
|
|
stopListener,
|
|
handleEvent,
|
|
flushCurrentText,
|
|
findToolCall,
|
|
friendlyError,
|
|
notifyConversationChanged,
|
|
}
|
|
}
|