token分项(各计费不同,不显 total):df-ai 解析 provider cache/reasoning(openai_compat prompt_cache_hit/miss/reasoning_tokens + anthropic cache_read/creation)+ TokenUsage 加字段(全构造点)+ AiMessage/AiCompleted/DB V39(ai_messages 加 cache_hit/miss/reasoning 列)+ message_repo 映射(持久化)+ 前端 MessageList 显 in·cache·out·reason(in=cache_miss 全价,reasoning 有才显)+ 点击 token 弹详情面板(完整 usage+缓存命中率+model)+ df-miniapp 同步 base前置(提升 prompt cache 命中率):chat.rs aug 拼 base 后(4处)+ knowledge_inject 知识拼 base 后(固定 base 前缀,cache 命中) 附修:replace_conversation 原 13 列 INSERT 丢消息级 token → 改 18 列
396 lines
18 KiB
TypeScript
396 lines
18 KiB
TypeScript
//! 对话管理 — 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 { setStreaming } from './streamingGuard'
|
||
import { nextMsgId, getConvState, clearConvStreamState, startApprovalTimer, clearAllApprovalTimers } from './aiShared'
|
||
import { t } from '@/i18n/i18n-helpers'
|
||
import type { AiConversationDetail, AiMessage, AiToolCallInfo, ConvId } from '@/api/types'
|
||
|
||
const appSettings = useAppSettingsStore()
|
||
|
||
/** 拉取会话列表;首次加载时若 appSettings 仍有有效 active-conv 且当前无活跃对话则恢复 */
|
||
async function loadConversations() {
|
||
try {
|
||
state.conversations = await aiApi.listConversations()
|
||
// 虚拟项保底:活跃会话未落库(懒创建——首条消息前 DB 无记录)时,侧栏显示虚拟占位项。
|
||
// 后端 ai_conversation_create 仅生成 id 存内存不落库,首条消息发送后 save_conversation 才写库。
|
||
// 此处 active 会话不在 DB 列表时补一个虚拟项,实现「点新建立即出现在侧栏,不发消息不落库」。
|
||
// 首条消息落库后 AiCompleted/AiError 触发的 loadConversations 拉到同 id 真实记录,虚拟项被替代无重复。
|
||
if (state.activeConversationId
|
||
&& !state.conversations.some(c => c.id === state.activeConversationId)) {
|
||
const now = String(Date.now())
|
||
state.conversations.unshift({
|
||
id: state.activeConversationId as ConvId,
|
||
title: null,
|
||
provider_id: null,
|
||
model: null,
|
||
archived: false,
|
||
pinned: false,
|
||
prompt_tokens: 0,
|
||
completion_tokens: 0,
|
||
created_at: now,
|
||
updated_at: now,
|
||
})
|
||
}
|
||
// 恢复上次活跃对话(仅在首次加载、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 (e) {
|
||
// UX-260617-08:网络抖动/IPC 断开时原静默失败,列表可能突空用户不知原因。
|
||
// 此处保留旧列表(赋值语句抛出,旧 state.conversations 不被覆盖)+ 推错误气泡反馈。
|
||
// 仅首次加载(state.conversations 为空)时推气泡,避免拉取/侧栏刷新重试时反复弹错。
|
||
console.error('[AI] 加载会话列表失败:', e)
|
||
if (state.conversations.length === 0) {
|
||
state.messages.push({
|
||
id: `load-conv-fail-${nextMsgId()}`,
|
||
role: 'assistant',
|
||
content: t('ai.loadConvFail'),
|
||
isError: true,
|
||
timestamp: Date.now(),
|
||
} as AiMessage)
|
||
notifyConversationChanged()
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 新建空对话并切过去 */
|
||
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 = []
|
||
setStreaming(false, { reason: 'newConversation' })
|
||
// F-09 决策e(真并发):newConversation 对齐 switchConversation 并行语义——旧 conv 后台 loop 继续,
|
||
// 不清 generatingConvs(保留旧 conv 生成态跟踪,侧栏显双会话生成,AiCompleted/AiError 按
|
||
// conversation_id 正确收尾旧 conv)。原 state.generatingConvs.clear() 是 A 路线 F-260616-09
|
||
// 单 loop 软隔离遗留,真并发下致后台生成前端丢失跟踪,废弃。
|
||
// agentRound 复位 0;searchQuery 清空防新会话侧栏被旧搜索过滤。
|
||
// queue 保留旧会话排队消息(它们有 conversationId,会在旧会话 AiCompleted 时按 ID 精准 drain)
|
||
state.agentRound = 0
|
||
state.searchQuery = ''
|
||
await loadConversations()
|
||
notifyConversationChanged()
|
||
}
|
||
|
||
// 切换 token:快速连点 A→B 时,后返回的 A 响应按 token 丢弃,防 messages 错配(FR-R1)
|
||
let _latestSwitchId = 0
|
||
|
||
/** 切换到指定会话:加载历史消息(含 tool_calls 回填 + tool_result 映射 + pending 审批恢复) */
|
||
export async function switchConversation(id: string) {
|
||
// AIC-FIX-17-P0-2:切换会话时清空所有审批计时器(防切走后过期 timer 误拒后台 pending + 错误气泡推错视图)。
|
||
// 新会话的审批计时器由下文 restore pending 路径的 startApprovalTimer 重新建立。
|
||
clearAllApprovalTimers()
|
||
const mySwitchId = ++_latestSwitchId
|
||
// 允许生成中切换:后台继续生成,事件按 conversation_id 路由不污染当前视图
|
||
let detail: AiConversationDetail
|
||
try {
|
||
detail = await aiApi.switchConversation(id)
|
||
} catch {
|
||
// 对话不存在(已删除/未落库的虚 ID)→ 创建新对话替代
|
||
console.warn('[AI] switchConversation 失败,创建新对话:', id)
|
||
const created = await aiApi.createConversation()
|
||
if (mySwitchId !== _latestSwitchId) return
|
||
void appSettings.set('df-ai-active-conv', created.id)
|
||
// 用新对话 id 重走后续逻辑
|
||
detail = { id: created.id as ConvId, title: null, messages: '[]' }
|
||
state.messages = []
|
||
notifyConversationChanged()
|
||
void loadConversations()
|
||
return
|
||
}
|
||
// 过期响应丢弃(用户已切到别的对话,防 A 后返回覆盖 B)
|
||
if (mySwitchId !== _latestSwitchId) return
|
||
state.activeConversationId = id
|
||
void appSettings.set('df-ai-active-conv', id)
|
||
// P1#6 技术债审查(2026-06-21):streaming 是全局单值,切到非生成会话需按目标 conv 生成态重算,
|
||
// 否则残留 stop 按钮(ChatInput.vue:88 v-if=streaming)→ 点击 store.stopChat 传 activeConversationId
|
||
// 发错会话。对齐 newConversation 复位语义;目标在后台生成时保留 true(stop/流式显示正确)。
|
||
// 根治归 F-09 B 路线:streaming 改 per-conv 态。此处为 A 路线过渡补丁。
|
||
// 批4 双轨收口:读 getConvState(enum 真相源)派生,替代旧 generatingConvs.has。
|
||
// 桥接语义:has(id)=true 等价于 conv_state∈{generating,stopping,compressed}(非终止三态)。
|
||
const targetCs = getConvState(id)
|
||
const targetGen = targetCs === 'generating' || targetCs === 'stopping' || targetCs === 'compressed'
|
||
setStreaming(targetGen, { convId: id, reason: 'switchConversation-recompute' })
|
||
|
||
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
|
||
// 过滤 tool 消息 + truncated 软删消息(UX-09:编辑某条 user 后其后消息标 truncated,
|
||
// 保留 DB 可追溯但从视图移除;前端无 status 字段故按原始 JSON 字段过滤)
|
||
.filter((m: any) => m.role !== 'tool' && m.status !== 'truncated')
|
||
.map((m: any, i: number) => ({
|
||
id: `loaded-${i}`,
|
||
role: m.role,
|
||
content: m.content || '',
|
||
model: m.model,
|
||
// 用后端持久化的真实时间(BUG-260618-01);老数据无 timestamp 字段回退 Date.now()
|
||
timestamp: typeof m.timestamp === 'number' ? m.timestamp : Date.now(),
|
||
// 消息级 token 回显:assistant 消息若 DB 持久化了 prompt_tokens/completion_tokens
|
||
// (V38 迁移后 push_assistant_message 落库的本轮 token),映射回 tokenUsage 供
|
||
// MessageList.vue 渲染 in/out 计数。压缩/切会话后历史 assistant 消息 token 不丢。
|
||
// 老消息 NULL → m.prompt_tokens==null → tokenUsage 不设(对齐 useAiEvents 实时态语义)。
|
||
// 分项 token(2026-08-02):cache/reasoning 透传(V39 列),老消息无则 undefined 前端 fallback。
|
||
tokenUsage: m.role === 'assistant' && m.prompt_tokens != null
|
||
? {
|
||
prompt: m.prompt_tokens,
|
||
completion: m.completion_tokens ?? 0,
|
||
cache_hit: m.prompt_cache_hit_tokens,
|
||
cache_miss: m.prompt_cache_miss_tokens,
|
||
reasoning: m.reasoning_tokens,
|
||
}
|
||
: undefined,
|
||
// 分项 token 消息级字段(详情面板直接读 msg.xxx,与实时态 useAiEvents 写入一致)
|
||
prompt_cache_hit_tokens: m.prompt_cache_hit_tokens,
|
||
prompt_cache_miss_tokens: m.prompt_cache_miss_tokens,
|
||
reasoning_tokens: m.reasoning_tokens,
|
||
// F-260614-05 Phase 2b: 透传 parts(多模态 Image 片)。后端序列化的 ContentPart[]
|
||
// 含 type:'text'|'image' discriminator + url/base64/media_type/alt 字段,
|
||
// 此处原样透传供 AiChat.vue 用户气泡渲染 <img>(base64 模式持久化层已替换占位 Text 片,
|
||
// 故历史 parts 通常仅含 url 模式 Image 或纯 Text)。
|
||
parts: Array.isArray(m.parts) && m.parts.length > 0 ? m.parts : undefined,
|
||
// 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 = {}
|
||
const rawArgs = tc.function?.arguments
|
||
if (typeof rawArgs === 'string') {
|
||
try {
|
||
args = rawArgs ? JSON.parse(rawArgs) : {}
|
||
} catch {
|
||
args = {}
|
||
}
|
||
} else if (rawArgs && typeof rawArgs === 'object') {
|
||
args = rawArgs
|
||
}
|
||
return {
|
||
id: tc.id,
|
||
name: tc.function?.name || '',
|
||
args,
|
||
status: 'completed' as const,
|
||
result: toolResultMap.get(tc.id),
|
||
}
|
||
}),
|
||
}))
|
||
} catch (e) {
|
||
// UX-260617-08:历史消息解析/映射失败原仅 state.messages=[] → 切换后空白用户不知原因。
|
||
// 改为推错误气泡提示 + 控制台日志(保留 state.messages 不再清空,避免空白无反馈)。
|
||
console.error('[AI] 切换对话历史消息解析失败:', e)
|
||
state.messages.push({
|
||
id: `switch-conv-fail-${nextMsgId()}`,
|
||
role: 'assistant',
|
||
content: t('ai.switchConvFail'),
|
||
isError: true,
|
||
timestamp: Date.now(),
|
||
} as AiMessage)
|
||
}
|
||
|
||
// 加载历史对话 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,使审批卡片重新可见
|
||
// 阶段4:按 IPC 返的 kind 渲染——'path' 类显 once/always/deny(tc.kind='path' + 推 path/dir 文案),
|
||
// 'risk' 类显 approve/reject(tc.kind='risk'/缺省)。对齐阶段3b 统一审批模型(两 kind 都可恢复)。
|
||
try {
|
||
const pending = await aiApi.pendingToolCalls(id)
|
||
// 第二 await 后二次比对:切换 A→B 期间避免用 A 的 pending 覆写 B 的 pendingApprovals
|
||
if (mySwitchId !== _latestSwitchId) return
|
||
if (pending.length) {
|
||
// kind 索引:tool_call_id → kind('risk'/'path'),供恢复 tc/pendingApprovals 按类型渲染
|
||
const pendingKindMap = new Map(pending.map(p => [p.tool_call_id, p.kind]))
|
||
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'
|
||
const kind = pendingKindMap.get(tc.id) ?? 'risk'
|
||
tc.kind = kind
|
||
// path 类审批:从 tc.args.path 推 path/dir 文案(后端 IPC 仅返 kind,无 dir/path;
|
||
// 此处从工具参数派生,供 ToolCard 审批提示展示)。缺 path 参数的 path 类回退空。
|
||
if (kind === 'path') {
|
||
const p = (tc.args as { path?: string } | null)?.path
|
||
tc.path = p
|
||
tc.dir = p ?? undefined
|
||
tc.reason = t('aiChat.dirAuthHint', { tool: tc.name, path: p ?? '' })
|
||
}
|
||
restored.push({
|
||
id: tc.id,
|
||
name: tc.name,
|
||
args: tc.args,
|
||
status: 'pending_approval',
|
||
kind,
|
||
path: tc.path,
|
||
dir: tc.dir,
|
||
reason: tc.reason,
|
||
})
|
||
// 重新启动审批计时器(APPROVAL_TIMEOUT_MS 默认 15min,0=不限时跳过;
|
||
// 启动仅用于计时器注册一致性,保持与 AiApprovalRequired 事件处理对称)
|
||
startApprovalTimer(tc.id, tc.name, kind)
|
||
}
|
||
}
|
||
}
|
||
state.pendingApprovals = restored
|
||
} else {
|
||
state.pendingApprovals = []
|
||
}
|
||
} catch {
|
||
state.pendingApprovals = []
|
||
}
|
||
}
|
||
|
||
/** 删除会话;若删的是当前活跃会话则清空消息+移除活跃 id 持久化 */
|
||
async function deleteConversation(id: string) {
|
||
// AIC-FIX-17-P0-2:删除会话时清空所有审批计时器(防已删会话的过期 timer 到期误拒审批 +
|
||
// 错误气泡推错视图)。
|
||
clearAllApprovalTimers()
|
||
await aiApi.deleteConversation(id)
|
||
// F-09 per-conv:清该会话的 stream state(streaming/currentText),防 convStreamStates Map 无限增长
|
||
// (与 convStates/待审批等 per-conv 资源同款会话级清理语义)。
|
||
clearConvStreamState(id)
|
||
if (state.activeConversationId === id) {
|
||
state.activeConversationId = null
|
||
void appSettings.remove('df-ai-active-conv')
|
||
state.messages = []
|
||
}
|
||
await loadConversations()
|
||
notifyConversationChanged()
|
||
}
|
||
|
||
/** M30:写后端 IPC 失败时推送错误气泡(对齐 loadConversations/switchConversation 的失败反馈),
|
||
* 保持侧栏已有交互不被无声吞掉(原仅向上抛,调用方 store 直调无 try → 异常进 unhandledrejection)。 */
|
||
function pushConvOpFail(key: 'renameConvFail' | 'archiveConvFail' | 'pinConvFail', params?: Record<string, string | number>) {
|
||
state.messages.push({
|
||
id: `conv-op-fail-${nextMsgId()}`,
|
||
role: 'assistant',
|
||
content: t(`ai.${key}`, params),
|
||
isError: true,
|
||
timestamp: Date.now(),
|
||
} as AiMessage)
|
||
notifyConversationChanged()
|
||
}
|
||
|
||
/** 重命名会话(后端 + 本地侧栏摘要同步)。
|
||
* M30:乐观更新(本地先写 → IPC → 失败回滚 + 提示)。原逻辑 IPC 成功才写本地,IPC 失败时异常
|
||
* 向上抛但 store 直调处无 try/catch → unhandledrejection,用户无感。改乐观更新让失败可见可回滚。 */
|
||
async function renameConversation(id: string, title: string) {
|
||
const conv = state.conversations.find(c => c.id === id)
|
||
const prevTitle = conv?.title ?? null
|
||
if (conv) conv.title = title
|
||
notifyConversationChanged()
|
||
try {
|
||
await aiApi.renameConversation(id, title)
|
||
} catch (e) {
|
||
console.error('[AI] 重命名会话失败:', e)
|
||
if (conv) conv.title = prevTitle
|
||
notifyConversationChanged()
|
||
pushConvOpFail('renameConvFail')
|
||
}
|
||
}
|
||
|
||
/** 归档/取消归档(后端 + 本地侧栏分组同步)。
|
||
* M30:乐观更新 + 失败回滚 + 提示(同 renameConversation)。 */
|
||
async function archiveConversation(id: string, archived: boolean) {
|
||
const conv = state.conversations.find(c => c.id === id)
|
||
const prevArchived = conv?.archived ?? false
|
||
if (conv) conv.archived = archived
|
||
notifyConversationChanged()
|
||
try {
|
||
await aiApi.archiveConversation(id, archived)
|
||
} catch (e) {
|
||
console.error('[AI] 归档会话失败:', e)
|
||
if (conv) conv.archived = prevArchived
|
||
notifyConversationChanged()
|
||
pushConvOpFail('archiveConvFail', { action: archived ? t('ai.archiveAction') : t('ai.unarchiveAction') })
|
||
}
|
||
}
|
||
|
||
/** 置顶/取消置顶(后端 + 本地侧栏排序同步;UX-17)。
|
||
* M30:乐观更新 + 失败回滚 + 提示(同 renameConversation)。 */
|
||
async function setPinnedConversation(id: string, pinned: boolean) {
|
||
const conv = state.conversations.find(c => c.id === id)
|
||
const prevPinned = conv?.pinned ?? false
|
||
if (conv) conv.pinned = pinned
|
||
notifyConversationChanged()
|
||
try {
|
||
await aiApi.setPinnedConversation(id, pinned)
|
||
} catch (e) {
|
||
console.error('[AI] 置顶会话失败:', e)
|
||
if (conv) conv.pinned = prevPinned
|
||
notifyConversationChanged()
|
||
pushConvOpFail('pinConvFail', { action: pinned ? t('ai.pinAction') : t('ai.unpinAction') })
|
||
}
|
||
}
|
||
|
||
/** 折叠/展开归档分组 */
|
||
function toggleArchivedFold() {
|
||
state.archivedCollapsed = !state.archivedCollapsed
|
||
persistUiState()
|
||
}
|
||
|
||
/** 折叠/展开时间分组(today/yesterday/earlier) */
|
||
function toggleGroupFold(key: string) {
|
||
state.foldedGroups[key] = !state.foldedGroups[key]
|
||
persistUiState()
|
||
}
|
||
|
||
/** 展开/收起侧栏(会话列表) */
|
||
function toggleSidebar() {
|
||
state.sidebarOpen = !state.sidebarOpen
|
||
persistUiState()
|
||
}
|
||
|
||
export function useAiConversations() {
|
||
return {
|
||
loadConversations,
|
||
newConversation,
|
||
switchConversation,
|
||
deleteConversation,
|
||
renameConversation,
|
||
archiveConversation,
|
||
setPinnedConversation,
|
||
toggleArchivedFold,
|
||
toggleGroupFold,
|
||
toggleSidebar,
|
||
}
|
||
}
|
||
|
||
export { loadConversations }
|