新增: 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:
178
src/composables/ai/useAiConversations.ts
Normal file
178
src/composables/ai/useAiConversations.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
//! 对话管理 — load/list/new/switch/delete/rename/archive + 侧栏折叠态
|
||||
//!
|
||||
//! 耦合:
|
||||
//! - 多处调 useAiEvents.notifyConversationChanged
|
||||
//! - newConversation/switchConversation 写 appSettings 持久化活跃会话 id
|
||||
|
||||
import { aiApi } from '../../api'
|
||||
import { useAppSettingsStore } from '../../stores/appSettings'
|
||||
import { state } from '../../stores/ai'
|
||||
import { notifyConversationChanged } from './useAiEvents'
|
||||
import { persistUiState } from './useAiPanel'
|
||||
import type { AiToolCallInfo } from '../../api/types'
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
/** 拉取会话列表;首次加载时若 appSettings 仍有有效 active-conv 且当前无活跃对话则恢复 */
|
||||
async function loadConversations() {
|
||||
try {
|
||||
state.conversations = await aiApi.listConversations()
|
||||
// 恢复上次活跃对话(仅在首次加载、ID 仍有效且当前无活跃对话时)
|
||||
const pending = appSettings.get<string | null>('df-ai-active-conv', null)
|
||||
if (pending && !state.activeConversationId && state.conversations.some(c => c.id === pending)) {
|
||||
await switchConversation(pending)
|
||||
}
|
||||
} catch {
|
||||
// 静默失败,不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
/** 新建空对话并切过去 */
|
||||
async function newConversation() {
|
||||
const result = await aiApi.createConversation()
|
||||
state.activeConversationId = result.id
|
||||
void appSettings.set('df-ai-active-conv', result.id)
|
||||
state.messages = []
|
||||
state.currentText = ''
|
||||
state.pendingApprovals = []
|
||||
state.streaming = false
|
||||
await loadConversations()
|
||||
notifyConversationChanged()
|
||||
}
|
||||
|
||||
/** 切换到指定会话:加载历史消息(含 tool_calls 回填 + tool_result 映射 + pending 审批恢复) */
|
||||
export async function switchConversation(id: string) {
|
||||
// 允许生成中切换:后台继续生成,事件按 conversation_id 路由不污染当前视图
|
||||
const detail = await aiApi.switchConversation(id)
|
||||
state.activeConversationId = id
|
||||
void appSettings.set('df-ai-active-conv', id)
|
||||
|
||||
try {
|
||||
const rawMsgs = typeof detail.messages === 'string'
|
||||
? JSON.parse(detail.messages)
|
||||
: detail.messages
|
||||
|
||||
// 构建 tool_call_id → tool_result 映射,用于回填工具执行结果
|
||||
const toolResultMap = new Map<string, string>()
|
||||
for (const m of rawMsgs) {
|
||||
if (m.role === 'tool' && m.tool_call_id) {
|
||||
toolResultMap.set(m.tool_call_id, m.content || '')
|
||||
}
|
||||
}
|
||||
|
||||
state.messages = rawMsgs
|
||||
.filter((m: any) => m.role !== 'tool')
|
||||
.map((m: any, i: number) => ({
|
||||
id: `loaded-${i}`,
|
||||
role: m.role,
|
||||
content: m.content || '',
|
||||
model: m.model,
|
||||
timestamp: Date.now(),
|
||||
toolCalls: m.tool_calls?.map((tc: any) => ({
|
||||
id: tc.id,
|
||||
name: tc.function?.name || '',
|
||||
args: typeof tc.function?.arguments === 'string'
|
||||
? JSON.parse(tc.function.arguments || '{}')
|
||||
: tc.function?.arguments || {},
|
||||
status: 'completed' as const,
|
||||
result: toolResultMap.get(tc.id),
|
||||
})),
|
||||
}))
|
||||
} catch {
|
||||
state.messages = []
|
||||
}
|
||||
|
||||
// 加载历史对话 token 总量(来自 DB summary);切换对话清空实时值
|
||||
const conv = state.conversations.find(c => c.id === id)
|
||||
if (conv && conv.prompt_tokens != null && conv.completion_tokens != null) {
|
||||
state.convTokenTotal = {
|
||||
prompt: conv.prompt_tokens,
|
||||
completion: conv.completion_tokens,
|
||||
total: conv.prompt_tokens + conv.completion_tokens,
|
||||
}
|
||||
} else {
|
||||
state.convTokenTotal = null
|
||||
}
|
||||
state.lastTokenUsage = null
|
||||
|
||||
state.currentText = ''
|
||||
// 恢复该对话积压的待审批:重启后后端从审计表重建了 pending_approvals,
|
||||
// 此处查回并把对应 toolCard.status 置 pending_approval,使审批卡片重新可见
|
||||
try {
|
||||
const pending = await aiApi.pendingToolCalls(id)
|
||||
if (pending.length) {
|
||||
const pendingIds = new Set(pending.map(p => p.tool_call_id))
|
||||
const restored: AiToolCallInfo[] = []
|
||||
for (const m of state.messages) {
|
||||
for (const tc of (m.toolCalls || [])) {
|
||||
if (pendingIds.has(tc.id)) {
|
||||
tc.status = 'pending_approval'
|
||||
restored.push({ id: tc.id, name: tc.name, args: tc.args, status: 'pending_approval' })
|
||||
}
|
||||
}
|
||||
}
|
||||
state.pendingApprovals = restored
|
||||
} else {
|
||||
state.pendingApprovals = []
|
||||
}
|
||||
} catch {
|
||||
state.pendingApprovals = []
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除会话;若删的是当前活跃会话则清空消息+移除活跃 id 持久化 */
|
||||
async function deleteConversation(id: string) {
|
||||
await aiApi.deleteConversation(id)
|
||||
if (state.activeConversationId === id) {
|
||||
state.activeConversationId = null
|
||||
void appSettings.remove('df-ai-active-conv')
|
||||
state.messages = []
|
||||
}
|
||||
await loadConversations()
|
||||
notifyConversationChanged()
|
||||
}
|
||||
|
||||
/** 重命名会话(后端 + 本地侧栏摘要同步) */
|
||||
async function renameConversation(id: string, title: string) {
|
||||
await aiApi.renameConversation(id, title)
|
||||
// 本地同步更新侧栏摘要标题
|
||||
const conv = state.conversations.find(c => c.id === id)
|
||||
if (conv) conv.title = title
|
||||
notifyConversationChanged()
|
||||
}
|
||||
|
||||
/** 归档/取消归档(后端 + 本地侧栏分组同步) */
|
||||
async function archiveConversation(id: string, archived: boolean) {
|
||||
await aiApi.archiveConversation(id, archived)
|
||||
// 本地同步归档态(侧栏立即移入/移出归档分组)
|
||||
const conv = state.conversations.find(c => c.id === id)
|
||||
if (conv) conv.archived = archived
|
||||
notifyConversationChanged()
|
||||
}
|
||||
|
||||
/** 折叠/展开归档分组 */
|
||||
function toggleArchivedFold() {
|
||||
state.archivedCollapsed = !state.archivedCollapsed
|
||||
persistUiState()
|
||||
}
|
||||
|
||||
/** 展开/收起侧栏(会话列表) */
|
||||
function toggleSidebar() {
|
||||
state.sidebarOpen = !state.sidebarOpen
|
||||
persistUiState()
|
||||
}
|
||||
|
||||
export function useAiConversations() {
|
||||
return {
|
||||
loadConversations,
|
||||
newConversation,
|
||||
switchConversation,
|
||||
deleteConversation,
|
||||
renameConversation,
|
||||
archiveConversation,
|
||||
toggleArchivedFold,
|
||||
toggleSidebar,
|
||||
}
|
||||
}
|
||||
|
||||
export { loadConversations }
|
||||
257
src/composables/ai/useAiEvents.ts
Normal file
257
src/composables/ai/useAiEvents.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
107
src/composables/ai/useAiPanel.ts
Normal file
107
src/composables/ai/useAiPanel.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
//! 面板与持久化 — togglePanel/toggleMaximize + UI 持久化(restoreUiState/persistUiState)
|
||||
//! + token 开关 + providers/skills/clearChat
|
||||
//!
|
||||
//! 模块级私有:
|
||||
//! - 启动恢复:模块加载时 restoreUiState()(从 appSettings 读上次 UI 布局)
|
||||
//! - watch appSettings.cache['df-ai-ui']:loadAll() 异步填充后再次应用 UI 布局
|
||||
//!
|
||||
//! 耦合:
|
||||
//! - togglePanel/toggleMaximize/toggleArchivedFold/toggleSidebar 调 persistUiState
|
||||
|
||||
import { watch } from 'vue'
|
||||
import { aiApi } from '../../api'
|
||||
import { useAppSettingsStore } from '../../stores/appSettings'
|
||||
import { state } from '../../stores/ai'
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
// 启动恢复:从 appSettings(SQLite)读取上次的 UI 布局(模块级单例 state)
|
||||
// detached/docked 不恢复 — 重启后分离窗口必然不存在,必须回 false
|
||||
export function restoreUiState() {
|
||||
const s = appSettings.get<any>('df-ai-ui', null)
|
||||
if (!s) return
|
||||
if (typeof s.panelOpen === 'boolean') state.panelOpen = s.panelOpen
|
||||
if (typeof s.maximized === 'boolean') state.maximized = s.maximized
|
||||
if (typeof s.sidebarOpen === 'boolean') state.sidebarOpen = s.sidebarOpen
|
||||
if (typeof s.archivedCollapsed === 'boolean') state.archivedCollapsed = s.archivedCollapsed
|
||||
}
|
||||
restoreUiState()
|
||||
|
||||
// loadAll() 异步填充缓存后,再次应用 UI 布局(模块级 state,watch 仅触发一次)
|
||||
watch(
|
||||
() => appSettings.cache['df-ai-ui'],
|
||||
(v) => {
|
||||
if (!v) return
|
||||
const s = v as any
|
||||
if (typeof s.panelOpen === 'boolean') state.panelOpen = s.panelOpen
|
||||
if (typeof s.maximized === 'boolean') state.maximized = s.maximized
|
||||
if (typeof s.sidebarOpen === 'boolean') state.sidebarOpen = s.sidebarOpen
|
||||
if (typeof s.archivedCollapsed === 'boolean') state.archivedCollapsed = s.archivedCollapsed
|
||||
},
|
||||
)
|
||||
|
||||
/** 持久化 UI 布局到 appSettings(debounced 写 SQLite) */
|
||||
export function persistUiState() {
|
||||
void appSettings.set('df-ai-ui', {
|
||||
panelOpen: state.panelOpen,
|
||||
maximized: state.maximized,
|
||||
sidebarOpen: state.sidebarOpen,
|
||||
archivedCollapsed: state.archivedCollapsed,
|
||||
})
|
||||
}
|
||||
|
||||
/** 切换面板展开/收起(收起时同步退出最大化,避免 v-show 卡死 main 交互) */
|
||||
function togglePanel() {
|
||||
state.panelOpen = !state.panelOpen
|
||||
// 关闭面板时同步退出最大化,避免 <main> 被 v-show 隐藏导致其他页面无法交互
|
||||
if (!state.panelOpen) state.maximized = false
|
||||
persistUiState()
|
||||
}
|
||||
|
||||
/** 最大化/还原侧栏宽度 */
|
||||
function toggleMaximize() {
|
||||
state.maximized = !state.maximized
|
||||
persistUiState()
|
||||
}
|
||||
|
||||
// ── Providers / Skills / 清屏 ──
|
||||
|
||||
/** 拉取 provider 列表(用于顶部 bar 展示与切换) */
|
||||
async function loadProviders() {
|
||||
state.providers = await aiApi.listProviders()
|
||||
}
|
||||
|
||||
/** 拉取本机技能列表(用于 `/` 联想,静默失败) */
|
||||
async function loadSkills() {
|
||||
try {
|
||||
state.skills = await aiApi.listSkills()
|
||||
} catch {
|
||||
// 静默失败,不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换活跃 provider(后端 + 本地 state) */
|
||||
async function setProvider(providerId: string) {
|
||||
await aiApi.setProvider(providerId)
|
||||
state.activeProvider = providerId
|
||||
}
|
||||
|
||||
/** 清空当前会话消息(后端 + 本地 state) */
|
||||
async function clearChat() {
|
||||
await aiApi.clearChat()
|
||||
state.messages = []
|
||||
state.currentText = ''
|
||||
state.pendingApprovals = []
|
||||
state.streaming = false
|
||||
}
|
||||
|
||||
export function useAiPanel() {
|
||||
return {
|
||||
togglePanel,
|
||||
toggleMaximize,
|
||||
loadProviders,
|
||||
loadSkills,
|
||||
setProvider,
|
||||
clearChat,
|
||||
}
|
||||
}
|
||||
124
src/composables/ai/useAiSend.ts
Normal file
124
src/composables/ai/useAiSend.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
//! 发送与队列管理 — sendMessage/approveToolCall + 队列(drain/cancel/clear) + stopChat
|
||||
//!
|
||||
//! 模块级私有:
|
||||
//! - 无(队列存在 state.queue 中,看门狗/审批状态机依赖 events 与 stream)
|
||||
//!
|
||||
//! 耦合:
|
||||
//! - sendMessage 调 useAiEvents.startListener(useAiEvents 导出但不在解构集中暴露给组件,
|
||||
//! 故 startListener 同时经 useAiEvents 导出,sendMessage 直接 import 调用)
|
||||
//! - sendMessage 调 useAiStream.resetStreamWatchdog
|
||||
//! - drainQueue 在 sendMessage 完成后由 handleEvent(AiCompleted) 调用 — 故 drainQueue 必须为模块级 export
|
||||
|
||||
import { aiApi } from '../../api'
|
||||
import { useAppSettingsStore } from '../../stores/appSettings'
|
||||
import { state } from '../../stores/ai'
|
||||
import { resetStreamWatchdog } from './useAiStream'
|
||||
import { startListener } from './useAiEvents'
|
||||
import { nextMsgId } from './useAiEvents'
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
/// 待发送队列上限(超过抛错提示用户)
|
||||
const QUEUE_LIMIT = 10
|
||||
|
||||
/** 取出队首并发送(AiCompleted 触发,此时 streaming 已 false) */
|
||||
export function drainQueue() {
|
||||
if (state.queue.length === 0) return
|
||||
const next = state.queue.shift()!
|
||||
void sendMessage(next.text, next.skill)
|
||||
}
|
||||
|
||||
/** 发送消息:生成中入队,否则推送 user+air 气泡并触发后端流式 */
|
||||
async function sendMessage(text: string, skill?: string) {
|
||||
if (!text.trim()) return
|
||||
|
||||
// 生成中:进入待发送队列,当前对话完成后(AiCompleted)由 drainQueue 自动续发
|
||||
if (state.streaming) {
|
||||
if (state.queue.length >= QUEUE_LIMIT) {
|
||||
throw new Error(`待发送队列已满(最多 ${QUEUE_LIMIT} 条)`)
|
||||
}
|
||||
state.queue.push({ text: text.trim(), skill: skill || undefined })
|
||||
return
|
||||
}
|
||||
|
||||
state.messages.push({
|
||||
id: `user-${nextMsgId()}`,
|
||||
role: 'user',
|
||||
content: text.trim(),
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
const aiMsgId = `ai-${nextMsgId()}`
|
||||
state.messages.push({
|
||||
id: aiMsgId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
state.streaming = true
|
||||
state.currentText = ''
|
||||
resetStreamWatchdog() // 启动流式看门狗,无数据超时兜底
|
||||
|
||||
await startListener()
|
||||
const raw = appSettings.get<string>('df-ai-language', 'auto')
|
||||
const lang = raw === 'auto'
|
||||
? appSettings.get<string>('df-language', 'zh-CN')
|
||||
: raw
|
||||
try {
|
||||
await aiApi.sendMessage(text.trim(), lang, skill)
|
||||
} catch (e) {
|
||||
// IPC 失败(spawn 前/provider 配置错等):回滚 streaming 防光标卡死 + 移除空气泡占位;
|
||||
// 重新抛出由 handleSend 回填输入框,用户可重试
|
||||
state.streaming = false
|
||||
state.messages = state.messages.filter(m => m.id !== aiMsgId)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/** 工具审批:乐观置 running,IPC 失败时改 completed+错误文案(不回滚避免卡按钮) */
|
||||
async function approveToolCall(toolCallId: string, approved: boolean) {
|
||||
// 乐观置运行中,禁用审批按钮防重复点击(后端事件回来后转 completed/rejected)
|
||||
const tc = state.messages
|
||||
.flatMap(m => m.toolCalls || [])
|
||||
.find(t => t.id === toolCallId)
|
||||
if (tc) tc.status = 'running'
|
||||
try {
|
||||
await aiApi.approve(toolCallId, approved)
|
||||
} catch (e) {
|
||||
// 仅 IPC 真失败(审批已处理/网络断)走到这里:后端工具失败已改走 emit completed,不进此分支
|
||||
// 不回滚 pending_approval(会卡死按钮),改设 completed + 错误提示,让用户知晓失败
|
||||
console.error('[AI] 审批操作未送达后端:', e)
|
||||
if (tc) {
|
||||
tc.status = 'completed'
|
||||
tc.result = `审批操作未送达后端:${e instanceof Error ? e.message : String(e)}`
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/** 取消队列中指定位置的消息 */
|
||||
function cancelQueued(index: number) {
|
||||
state.queue.splice(index, 1)
|
||||
}
|
||||
|
||||
/** 清空整个待发送队列 */
|
||||
function clearQueue() {
|
||||
state.queue = []
|
||||
}
|
||||
|
||||
/** 停止当前生成:仅发停止信号,streaming 状态由后端 AiCompleted 事件收尾 */
|
||||
async function stopChat() {
|
||||
await aiApi.stopChat()
|
||||
}
|
||||
|
||||
export function useAiSend() {
|
||||
return {
|
||||
sendMessage,
|
||||
approveToolCall,
|
||||
drainQueue,
|
||||
cancelQueued,
|
||||
clearQueue,
|
||||
stopChat,
|
||||
}
|
||||
}
|
||||
52
src/composables/ai/useAiStream.ts
Normal file
52
src/composables/ai/useAiStream.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
//! 流式看门狗 — 无数据超时兜底
|
||||
//!
|
||||
//! 后端断连/异常不发 AiCompleted/AiError 时,前端自动收尾+提示,
|
||||
//! 避免 streaming 永久 true 卡死对话(用户报"流式文字停在中途"即此类)。
|
||||
//!
|
||||
//! 耦合说明:
|
||||
//! - sendMessage 启动生成时 resetStreamWatchdog() 启动计时
|
||||
//! - handleEvent 每个活跃事件(delta/工具/新轮/审批结果)重置;审批等待/完成/错误 clear
|
||||
//! - 超时回调 onStreamTimeout 直接改 state 并补一条错误消息
|
||||
|
||||
import { state } from '../../stores/ai'
|
||||
import { nextMsgId } from './useAiEvents'
|
||||
|
||||
/// ≥ 后端 STREAM_IDLE_TIMEOUT(120s, ai.rs:848)+余量;
|
||||
/// 前端若短于后端,慢首 token 会被前端先误杀
|
||||
export const STREAM_TIMEOUT_MS = 130000
|
||||
|
||||
let _streamWatchdog: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/** 看门狗超时回调:收尾 streaming 态并补错误消息 */
|
||||
export function onStreamTimeout() {
|
||||
state.streaming = false
|
||||
state.generatingConvId = null
|
||||
state.currentText = ''
|
||||
clearStreamWatchdog()
|
||||
state.messages.push({
|
||||
id: `timeout-${nextMsgId()}`,
|
||||
role: 'assistant',
|
||||
content: '⚠ 响应中断(长时间无数据流)。可能是网络断连或后端异常,请重试。',
|
||||
isError: true,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
/** 启动/重置看门狗(活跃事件或 sendMessage 时调用) */
|
||||
export function resetStreamWatchdog() {
|
||||
if (_streamWatchdog) clearTimeout(_streamWatchdog)
|
||||
_streamWatchdog = setTimeout(onStreamTimeout, STREAM_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
/** 清除看门狗(完成/错误/审批等待时调用) */
|
||||
export function clearStreamWatchdog() {
|
||||
if (_streamWatchdog) { clearTimeout(_streamWatchdog); _streamWatchdog = null }
|
||||
}
|
||||
|
||||
export function useAiStream() {
|
||||
return {
|
||||
onStreamTimeout,
|
||||
resetStreamWatchdog,
|
||||
clearStreamWatchdog,
|
||||
}
|
||||
}
|
||||
167
src/composables/ai/useAiWindow.ts
Normal file
167
src/composables/ai/useAiWindow.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
//! 窗口分离模式 — detach/reattach/resumeInDetached/closeDetachedWindow/dock/syncToMain/startFollowMain/stopFollowMain
|
||||
//!
|
||||
//! 模块级私有(不进 reactive):
|
||||
//! - _unlistenMove / _unlistenResize: 主窗口 move/resize 跟随的 unlistener
|
||||
//!
|
||||
//! 耦合:
|
||||
//! - detachPanel/resumeInDetached 调 useAiConversations.switchConversation
|
||||
//! - resumeInDetached 调 useAiEvents.nextMsgId 占位 ai 气泡
|
||||
//! - reattachPanel/detachPanel 调 useAiPanel.persistUiState
|
||||
|
||||
import { state } from '../../stores/ai'
|
||||
import { nextMsgId } from './useAiEvents'
|
||||
import { switchConversation } from './useAiConversations'
|
||||
import { persistUiState } from './useAiPanel'
|
||||
|
||||
// ── 主窗口事件跟随 unlistener ──
|
||||
let _unlistenMove: (() => void) | null = null
|
||||
let _unlistenResize: (() => void) | null = null
|
||||
|
||||
/** 分离 AI 面板到独立窗口(若已存在则聚焦);快照当前生成态供分离窗口接管 */
|
||||
async function detachPanel() {
|
||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const existing = await WebviewWindow.getByLabel('ai-detached')
|
||||
if (existing) {
|
||||
await existing.setFocus()
|
||||
return
|
||||
}
|
||||
// 快照当前生成态,供分离窗口接管(保持正在进行的对话)
|
||||
if (state.streaming && state.generatingConvId) {
|
||||
localStorage.setItem('df-ai-gen', state.generatingConvId)
|
||||
localStorage.setItem('df-ai-text', state.currentText)
|
||||
}
|
||||
const convId = state.activeConversationId
|
||||
const sep = convId ? `?conv=${encodeURIComponent(convId)}` : ''
|
||||
const url = window.location.origin + window.location.pathname + '#/ai-detached' + sep
|
||||
const win = new WebviewWindow('ai-detached', {
|
||||
url,
|
||||
title: 'DevFlow AI',
|
||||
width: 520,
|
||||
height: 700,
|
||||
minWidth: 360,
|
||||
minHeight: 400,
|
||||
center: true,
|
||||
decorations: true,
|
||||
})
|
||||
win.once('tauri://created', () => {
|
||||
state.panelOpen = false
|
||||
persistUiState()
|
||||
})
|
||||
win.once('tauri://error', (e) => {
|
||||
console.error('[AI] 窗口创建失败:', e)
|
||||
state.detached = false
|
||||
})
|
||||
win.once('tauri://destroyed', () => {
|
||||
state.detached = false
|
||||
state.docked = false
|
||||
stopFollowMain()
|
||||
})
|
||||
state.detached = true
|
||||
}
|
||||
|
||||
/** 关闭分离窗口并回到内嵌面板(主窗口侧调用) */
|
||||
async function reattachPanel() {
|
||||
stopFollowMain()
|
||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const win = await WebviewWindow.getByLabel('ai-detached')
|
||||
if (win) {
|
||||
await win.close()
|
||||
}
|
||||
state.detached = false
|
||||
state.docked = false
|
||||
state.panelOpen = true
|
||||
persistUiState()
|
||||
localStorage.removeItem('df-ai-gen')
|
||||
localStorage.removeItem('df-ai-text')
|
||||
}
|
||||
|
||||
/** 分离窗口挂载时接管主窗口当前对话(含生成中态) */
|
||||
async function resumeInDetached(convId: string | null) {
|
||||
const id = convId || state.activeConversationId
|
||||
if (id) await switchConversation(id)
|
||||
const gen = localStorage.getItem('df-ai-gen')
|
||||
if (gen) {
|
||||
// 接管正在生成的对话:恢复已生成文本并补占位,后续 delta 继续追加
|
||||
state.currentText = localStorage.getItem('df-ai-text') || ''
|
||||
state.messages.push({
|
||||
id: `ai-${nextMsgId()}`,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
state.streaming = true
|
||||
state.generatingConvId = gen
|
||||
}
|
||||
}
|
||||
|
||||
/** 关闭分离窗口自身(分离窗口侧调用) */
|
||||
async function closeDetachedWindow() {
|
||||
state.docked = false
|
||||
stopFollowMain()
|
||||
localStorage.removeItem('df-ai-gen')
|
||||
localStorage.removeItem('df-ai-text')
|
||||
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
await getCurrentWebviewWindow().close()
|
||||
}
|
||||
|
||||
/** 吸附/取消吸附:AI 窗口跟随主窗口右侧 */
|
||||
async function dockDetached() {
|
||||
// 取消吸附
|
||||
if (state.docked) {
|
||||
state.docked = false
|
||||
stopFollowMain()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await syncToMain()
|
||||
state.docked = true
|
||||
startFollowMain()
|
||||
} catch (e) {
|
||||
console.error('[AI] 吸附失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 同步 AI 窗口位置到主窗口右侧(保留 4px 间隙) */
|
||||
async function syncToMain() {
|
||||
const { WebviewWindow, getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const { PhysicalPosition, PhysicalSize } = await import('@tauri-apps/api/dpi')
|
||||
const mainWin = await WebviewWindow.getByLabel('main')
|
||||
const aiWin = getCurrentWebviewWindow()
|
||||
if (!mainWin) return
|
||||
const pos = await mainWin.outerPosition()
|
||||
const size = await mainWin.innerSize()
|
||||
await aiWin.setPosition(new PhysicalPosition(pos.x + size.width + 4, pos.y))
|
||||
await aiWin.setSize(new PhysicalSize(600, size.height))
|
||||
}
|
||||
|
||||
/** 启动主窗口 move/resize 跟随(吸附时调用) */
|
||||
async function startFollowMain() {
|
||||
stopFollowMain()
|
||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const mainWin = await WebviewWindow.getByLabel('main')
|
||||
if (!mainWin) return
|
||||
_unlistenMove = await mainWin.onMoved(() => { void syncToMain() })
|
||||
_unlistenResize = await mainWin.onResized(() => { void syncToMain() })
|
||||
}
|
||||
|
||||
/** 停止主窗口跟随 */
|
||||
function stopFollowMain() {
|
||||
_unlistenMove?.()
|
||||
_unlistenResize?.()
|
||||
_unlistenMove = null
|
||||
_unlistenResize = null
|
||||
}
|
||||
|
||||
export function useAiWindow() {
|
||||
return {
|
||||
detachPanel,
|
||||
reattachPanel,
|
||||
resumeInDetached,
|
||||
closeDetachedWindow,
|
||||
dockDetached,
|
||||
syncToMain,
|
||||
startFollowMain,
|
||||
stopFollowMain,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user