新增: Phase2 阶段收尾(Sprint 1-20)

重构:删 5 零引用 crate(df-evolve/plugin/stages/task/traceability)+ 清死模块、ai.rs 拆 11 子 module、ai.ts 拆 6 composable、i18n 拆目录
功能:知识库全栈(df-project/scan + CRUD + 时间线 + 前端)、Settings 拆分、appSettings KV 迁移、模型池、LLM 并发 Semaphore
修复:审批持久化根治、ConditionEngine 默认拒绝、NodeRegistry unimplemented 清除、promote 补偿删除、工具结果截断 50KB、路径校验防 symlink 逃逸
文档:B-03 人工审批设计、决策记录三分档、规格契约自检、经验记录、todo 看板、PROGRESS 更新

详见 PROGRESS.md。src-tauri/儿童每日打卡应用/ 与本项目无关,已排除。
This commit is contained in:
2026-06-14 14:08:20 +08:00
parent 98393b4908
commit cf017f81e2
167 changed files with 19549 additions and 6886 deletions

View File

@@ -0,0 +1,257 @@
//! AI 事件监听与分发 — startListener/stopListener/handleEvent 及其辅助函数
//!
//! 模块级私有状态(不进 reactive):
//! - _unlistenAiEvent / _unlistenConvChanged: 已注册的 unlistener
//! - _startPromise: startListener 并发去重(防 onMounted 与 sendMessage 首发竞态重复注册)
//! - _msgCounter: 客户端消息自增 id(全局唯一,多个 composable 共用 nextMsgId)
//!
//! 耦合:
//! - handleEvent 调 useAiConversations.loadConversations、useAiSend.drainQueue、
//! useAiStream.{resetStreamWatchdog,clearStreamWatchdog}、本模块 flushCurrentText/findToolCall/notifyConversationChanged
//! - onStreamTimeout(useAiStream) 调本模块 nextMsgId
import { listen, emit } from '@tauri-apps/api/event'
import { aiApi } from '../../api'
import { useAppSettingsStore } from '../../stores/appSettings'
import { state } from '../../stores/ai'
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
import { drainQueue } from './useAiSend'
import { loadConversations } from './useAiConversations'
import type { AiChatEvent, AiToolCallInfo } from '../../api/types'
let _unlistenAiEvent: (() => void) | null = null
let _unlistenConvChanged: (() => void) | null = null
// startListener 并发去重:防 onMounted 与 sendMessage 首发竞态下重复注册 listener
// (两回调写同一 state.currentText → 流式文字双倍)
let _startPromise: Promise<void> | null = null
let _msgCounter = 0
const appSettings = useAppSettingsStore()
/** 全局消息 id 自增(供 events/stream/send/window 各 composable 共享同一计数器) */
export function nextMsgId(): number {
return ++_msgCounter
}
/** 通知会话列表发生变化(供 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 '调用失败:接口地址或模型不存在,请检查 Provider 配置'
if (/401|403|unauthorized|api[_\s-]?key/i.test(raw)) return '调用失败:API Key 无效或无权限'
if (/timeout|超时/i.test(raw)) return '响应超时,请重试'
if (/network|connection|ECONN|网络|连接/i.test(raw)) return '网络连接失败,请检查网络'
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 的工具调用卡片(用于状态流转) */
export function findToolCall(id: string): AiToolCallInfo | undefined {
for (const msg of state.messages) {
if (msg.toolCalls) {
const tc = msg.toolCalls.find(t => t.id === id)
if (tc) return tc
}
}
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,
}
}