Files
DevFlow/src/composables/ai/useAiConversations.ts
T

646 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 对话管理 — 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 { persistUiState } from './useAiPanel'
import { setStreaming } from './streamingGuard'
import { nextMsgId, getConvState, clearConvStreamState, clearLastDelta, startApprovalTimer, clearAllApprovalTimers, switchingConvs, getConvStreamState, setConvCurrentText, notifyConversationChanged } from './aiShared'
import { t } from '@/i18n/i18n-helpers'
import type { AiConversationDetail, AiMessage, AiToolCallInfo, ConvId, MessageId } 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()
}
}
}
// FE1(AC-EFF-F1-1/2/3):会话列表刷新收敛——所有「操作后/事件后」的列表刷新统一走本调度器。
// 约 250ms trailing debounce:合并同一窗口内多次触发(回合收尾的 notify 自触发 + 显式
// loadConversations 曾双拉,switch/new/delete 的本地刷新 + notify)为一次 IPC 列表拉取。
// 跨窗口同步仍靠 notifyConversationChanged 的 emit——分离窗口 listener 也收敛到本调度器,
// 各自窗口只拉一次。初始加载(AiChat.vue 挂载)仍直调 loadConversations,不受防抖延迟。
let _refreshTimer: ReturnType<typeof setTimeout> | null = null
function scheduleConversationsRefresh(): void {
if (_refreshTimer) clearTimeout(_refreshTimer)
_refreshTimer = setTimeout(() => {
_refreshTimer = null
void loadConversations()
}, 250)
}
// 新建防抖锁:快速连点「+」/ Ctrl+N 只触发一次(300ms 内重复调用直接 return,
// 防多个空会话 + 虚拟项堆积)。模块级(跨组件共享,主/分离窗口各自实例由 store 单例统一)。
let _newConvLock = false
/** 新建空对话并切过去 */
async function newConversation() {
// 新建会话清空所有审批计时器(对齐切换/删除会话——旧 conv 的审批超时到点会误拒后台
// 挂起审批 + 错误气泡进新会话视图)。
clearAllApprovalTimers()
// 防抖(300ms 内重复调用直接 return)
if (_newConvLock) return
_newConvLock = true
try {
// G3.4:收敛进会话操作族 withConvOp——原裸 await 失败抛 unhandled rejection 用户无感,
// 现失败推错误气泡 + 保持当前视图。新建无乐观本地变化(后端生成 id,失败无副作用)。
const result = await withConvOp(
() => {},
() => aiApi.createConversation(),
() => {},
'newConvFail',
)
if (!result) return // 失败已推气泡,保持当前视图
state.activeConversationId = result.id
void appSettings.set('df-ai-active-conv', result.id)
// G3.5:新对话无历史,load_more 游标复位
loadMoreCursor.hasMore = false
loadMoreCursor.earliestSeq = null
loadMoreCursor.convId = result.id
loadMoreCursor.loading = false
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 = ''
// FE1:走防抖调度(250ms trailing),与下行 notify 自触发的 listener 刷新合并为一次。
scheduleConversationsRefresh()
notifyConversationChanged()
} finally {
// 防抖释放:300ms 后允许再次新建
setTimeout(() => { _newConvLock = false }, 300)
}
}
// 切换 token:快速连点 A→B 时,后返回的 A 响应按 token 丢弃,防 messages 错配(FR-R1)
let _latestSwitchId = 0
// 最近一次切换的切换窗口缓冲(成功提交路径恢复续显用;失败/过期路径丢弃,
// 防陈旧文本在后续切换被误恢复造成内容重复)。由 switchConversation 的 finally 统一填充。
let _switchBufferedText = ''
// ============================================================
// G3.5 load_more 分页(滚顶加载更早历史)
// ============================================================
//
// 后端 ai_conversation_switch 已返 has_more/earliest_seq(最近 50 条之外的更早历史游标),
// 本模块持游标;MessageList 滚顶阈值触发 loadMoreHistory → IPC → prepend + 按 id 去重。
// 普通对象(非响应式):仅滚动事件触发时读,无需渲染追踪。
const loadMoreCursor: {
hasMore: boolean
earliestSeq: number | null
loading: boolean
convId: string | null
} = { hasMore: false, earliestSeq: null, loading: false, convId: null }
/** 读 load_more 游标(MessageList 滚动处理器用;返回同一引用,读到的即最新态) */
function getLoadMoreState() {
return loadMoreCursor
}
/**
* 后端 ChatMessage[] → 前端 AiMessage[](switch + load_more 共用)。
* 过滤/映射语义与 switchConversation 原内联逻辑逐行对齐(tool 消息 + truncated 软删剔除、
* token 分项透传、parts 透传、tool_calls 回填 tool_result)。
* id:优先后端真实消息 id(DB 主键,prepend 时 v-for key 稳定),缺失兜底 `${fallbackPrefix}-${i}`
* (老数据无 id / 旧 messages JSON 列)。fallbackPrefix 区分 switch('loaded')与 load_more('older'),
* 防两条 id 方案在 prepend 后冲突。
*/
function parseConvMessages(rawMsgs: any[], fallbackPrefix: string): AiMessage[] {
// 构建 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 || '')
}
}
return 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: (m.id && m.id !== '') ? m.id : `${fallbackPrefix}-${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,
// 消息级估算标记(DB 列,老消息 null → undefined):reload 逐条回显对齐 live 态 AiCompleted
is_estimated: m.is_estimated,
}
: 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>。
parts: Array.isArray(m.parts) && m.parts.length > 0 ? m.parts : undefined,
// F-15 阶段2: 透传 status(archived_segment/compressed/null|active),
// 供 AiChat.vue 按 status 折叠分组渲染。
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),
}
}),
}))
}
/**
* G3.5:加载更早历史消息并 prepend 到当前视图。
* 游标守卫(hasMore/loading/earliestSeq/convId)+ 切走过期丢弃(convId 与 active 不一致即弃)。
* 返回是否实际插入了新消息(MessageList 据此恢复 scrollTop)。
*/
async function loadMoreHistory(): Promise<boolean> {
const cur = loadMoreCursor
if (!cur.convId || cur.earliestSeq == null || cur.loading || !cur.hasMore) return false
cur.loading = true
try {
const res = await aiApi.loadMoreMessages(cur.convId, cur.earliestSeq)
// 过期丢弃:等待期间用户切走/删除会话,游标已指向别的 conv → 不 prepend 错视图
if (cur.convId !== state.activeConversationId) return false
const rawMsgs = typeof res.messages === 'string' ? JSON.parse(res.messages) : res.messages
const older = parseConvMessages(rawMsgs, 'older')
// 按 id 去重(防御:游标边界变化 / 与已加载消息重复)
const existing = new Set(state.messages.map(m => m.id))
const fresh = older.filter(m => !existing.has(m.id))
if (fresh.length === 0) {
// 无新消息(极端重复):推进游标但不算"插入",防 MessageList 恢复滚动误判
cur.hasMore = res.has_more ?? false
if (res.earliest_seq != null) cur.earliestSeq = res.earliest_seq
return false
}
state.messages = [...fresh, ...state.messages]
cur.hasMore = res.has_more ?? false
if (res.earliest_seq != null) cur.earliestSeq = res.earliest_seq
return true
} catch (e) {
console.error('[AI] 加载更多历史消息失败:', e)
return false
} finally {
cur.loading = false
}
}
/** 切换到指定会话:加载历史消息(含 tool_calls 回填 + tool_result 映射 + pending 审批恢复)
* @param force true=跳过同会话短路(程序化刷新当前会话用:清空/压缩后回刷,如 AiChat.vue
* onCleared/onCompressed 回调);false(默认)=同会话点击直接 return。 */
export async function switchConversation(id: string, force = false) {
// 同会话点击短路:已活跃会话不重拉。原实现无条件走 currentText='' + 全量 parse +
// pending 重恢复,流式中点侧栏高亮项会清空流式文本、delta 从空续流 → 回复缺前缀。短路保留
// in-flight 流式文本。程序化刷新(force=true)不受影响。
if (!force && id === state.activeConversationId) return
// 切换会话时清空所有审批计时器(防切走后过期 timer 误拒后台挂起审批 + 错误气泡推错视图)。
// 新会话的审批计时器由下文恢复挂起路径的 startApprovalTimer 重新建立。
clearAllApprovalTimers()
const mySwitchId = ++_latestSwitchId
// 允许生成中切换:后台继续生成,事件按 conversation_id 路由不污染当前视图
let detail: AiConversationDetail
try {
// 标记目标会话切换中(await 往返期间),useAiEvents 对非当前会话的 AiTextDelta
// 若命中此集合则累积到其 per-conv 流式态而非丢弃(治切换窗口丢 token)。
switchingConvs.add(id)
detail = await aiApi.switchConversation(id)
} catch (e) {
// 区分错误形态:仅"对话不存在"(已删除/未落库的虚 ID)才新建会话兜底;
// 瞬态 IPC 失败(网络抖动/后端异常)保留原视图 + 推错误气泡,不再吞当前视图。
const errMsg = e instanceof Error ? e.message : String(e)
if (errMsg.includes('对话不存在')) {
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: '[]' }
// 新建对话无历史,load_more 游标复位
loadMoreCursor.hasMore = false
loadMoreCursor.earliestSeq = null
loadMoreCursor.convId = created.id
loadMoreCursor.loading = false
state.messages = []
notifyConversationChanged()
scheduleConversationsRefresh()
return
}
// 瞬态失败:保留当前视图 + 错误气泡(复用会话操作失败气泡模式);过期响应丢弃。
// 保留 scheduleConversationsRefresh():陈旧 id(已被后端删除)在下一次列表刷新中消失。
console.error('[AI] switchConversation 失败(瞬态),保留当前视图:', e)
if (mySwitchId !== _latestSwitchId) return
pushConvOpFail('switchConvFail')
scheduleConversationsRefresh()
return
} finally {
// 切换结束(含失败/过期/新建会话路径)清切换中标记 + 统一收集并清空缓冲。
// 成功提交路径在下方用 _switchBufferedText 恢复续显;失败/过期路径缓冲被丢弃,
// 防陈旧文本在后续切换被误恢复造成内容重复。
switchingConvs.delete(id)
_switchBufferedText = getConvStreamState(id)?.currentText ?? ''
setConvCurrentText(id, '')
}
// 过期响应丢弃(用户已切到别的对话,防 A 后返回覆盖 B)
if (mySwitchId !== _latestSwitchId) return
// 透传 has_more/earliest_seq 游标(前端滚顶加载更多历史)。
// 后端在 generating/空对话等场景返 false/null,此处默认兜底。
const switchDetailAny = detail as any
loadMoreCursor.hasMore = switchDetailAny.has_more ?? false
loadMoreCursor.earliestSeq = switchDetailAny.earliest_seq ?? null
loadMoreCursor.convId = id
loadMoreCursor.loading = false
// streaming 是全局单值,切到非生成会话需按目标会话生成态重算,否则残留停止按钮
// (输入区 v-if=streaming)→ 点击停止会传 activeConversationId 发错会话。对齐新建会话的
// 复位语义;目标在后台生成时保留 true(停止/流式显示正确)。读会话状态(枚举真相源)派生,
// 桥接语义:非终止三态(generating/stopping/compressed)视为生成中。
const targetCs = getConvState(id)
const targetGen = targetCs === 'generating' || targetCs === 'stopping' || targetCs === 'compressed'
// 先 parse 成功替换 messages,成功后再置 activeConversationId(同一同步块内赋值,
// 无 await 间隙 → 无中间态渲染)。原顺序先置 active 后 parse,parse 失败时 active 已是新 id 而
// messages 仍是旧视图 + 错误气泡 → active 与 messages 错配(视图显示旧会话却以新会话高亮)。
try {
const rawMsgs = typeof detail.messages === 'string'
? JSON.parse(detail.messages)
: detail.messages
// 消息映射收敛进 parseConvMessages(切换 + 加载更多共用,过滤/映射语义一致)。
// 相比原内联逻辑唯一行为变化:id 由 `loaded-${i}` 改为优先后端真实消息 id
// (DB 主键,prepend 时 v-for key 稳定不重建 DOM),缺失才兜底 `loaded-${i}`。
state.messages = parseConvMessages(rawMsgs, 'loaded')
// parse 成功才置 active(保证 active 与 messages 一致)
state.activeConversationId = id
// CSW-P1-4: 切到 round0 进行中的会话时,后端该轮 per_conv 只有 user 消息(assistant 占位
// 轮末才入),末条是 user → 流式 currentText 无 assistant 承载(MessageList isLastAi 不命中,
// flushCurrentText 遇 user break)整轮回复丢失。补前端占位 assistant 气泡承接 currentText,
// 生成完成由 AiCompleted/AiAgentRound 收尾 flushCurrentText 回填 content。
// targetGen(非终止三态 generating/stopping/compressed)在上文已算,直接复用;
// round0-pending- 前缀防御判重(force 重刷同会话时 parseConvMessages 已整体重建,不叠加)。
// 字段满足 AiMessage 必填(id/role/content/timestamp),仅 id 因 branded MessageId 逐字段 cast。
const lastMsg = state.messages[state.messages.length - 1]
if (
targetGen
&& lastMsg
&& lastMsg.role === 'user'
&& !state.messages.some((m) => m.role === 'assistant' && m.id.startsWith('round0-pending-'))
) {
state.messages.push({
id: `round0-pending-${id}` as MessageId,
role: 'assistant',
content: '',
isError: false,
timestamp: Date.now(),
})
}
} catch (e) {
// 历史消息解析/映射失败原仅 state.messages=[] → 切换后空白用户不知原因。
// 改为推错误气泡提示 + 控制台日志(保留 state.messages 不再清空,避免空白无反馈)。
// parse 失败不切换(active/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)
return
}
// 切换提交完成才持久化活跃会话 + 重算 streaming(active 已指向目标 conv)
void appSettings.set('df-ai-active-conv', id)
setStreaming(targetGen, { convId: id, reason: 'switchConversation-recompute' })
// 加载历史对话 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 = ''
// 恢复切换窗口缓冲的流式文本(切换中累积到该会话 per-conv 态的后端快照之后增量),
// 由消息列表续显,避免切换完成时丢可见内容(回复缺前缀)。空缓冲则无操作。
if (_switchBufferedText) state.currentText = _switchBufferedText
// 恢复该对话积压的待审批:重启后后端从审计表重建了挂起审批,
// 此处查回并把对应 toolCard.status 置为待审批,使审批卡片重新可见。
// 按 IPC 返的 kind 渲染:'path' 类显 once/always/deny,'risk' 类显 approve/reject。
// F2-1(AC-EFF-F2-1):纯文本会话跳过挂起查询(省 1 次 IPC + 后端全扫)——仅当目标会话消息
// 含工具卡,或目标会话生成中(可能在途产生待审批)才需要恢复挂起。纯文本/无工具历史直接置空。
const hasToolCalls = state.messages.some(m => (m.toolCalls ?? []).length > 0)
if (!targetGen && !hasToolCalls) {
state.pendingApprovals = []
} else {
try {
const pending = await aiApi.pendingToolCalls(id)
// 第二 await 后二次比对:切换 A→B 期间避免用 A 的挂起覆写 B 的 pendingApprovals
if (mySwitchId !== _latestSwitchId) return
if (pending.length) {
// kind 索引:tool_call_id → kind('risk'/'path'),供恢复工具卡/挂起列表按类型渲染
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,
// 恢复的历史挂起带目标会话 id:会话终止收尾按此仅清本会话的待审批项,不连累并发会话。
conversationId: id,
})
// 重新启动审批计时器(APPROVAL_TIMEOUT_MS 默认 15min0=不限时跳过;
// 启动仅用于计时器注册一致性,保持与 AiApprovalRequired 事件处理对称)
startApprovalTimer(tc.id, tc.name, kind)
}
}
}
state.pendingApprovals = restored
} else {
state.pendingApprovals = []
}
} catch {
state.pendingApprovals = []
}
}
}
/** 删除会话;若删的是当前活跃会话则清空消息+移除活跃 id 持久化。
* G3.4:收敛进 withConvOp(乐观移除列表 + 失败回滚 + 错误气泡),原裸 await 失败抛 unhandled rejection。 */
async function deleteConversation(id: string) {
// 删除会话时清空所有审批计时器(防已删会话的过期 timer 到期误拒审批 + 错误气泡推错视图)
clearAllApprovalTimers()
const conv = state.conversations.find(c => c.id === id)
const prevIndex = conv ? state.conversations.indexOf(conv) : -1
const wasActive = state.activeConversationId === id
const prevActive = state.activeConversationId
const prevMessages = state.messages
// 删活跃会话前捕获相邻会话(删除后回落,避免空白无引导)
const neighbor = wasActive ? (state.conversations[prevIndex + 1] ?? state.conversations[prevIndex - 1]) : null
const neighborId = neighbor?.id ?? null
const ok = await withConvOp(
() => {
// 乐观:从列表移除 + 若删的是活跃会话则清空视图
state.conversations = state.conversations.filter(c => c.id !== id)
if (wasActive) {
state.activeConversationId = null
state.messages = []
}
},
() => aiApi.deleteConversation(id),
() => {
// 回滚:恢复列表原位置 + 恢复活跃会话与消息视图(失败保留原视图)
if (conv) {
const list = [...state.conversations]
list.splice(prevIndex === -1 ? list.length : Math.min(prevIndex, list.length), 0, conv)
state.conversations = list
}
if (wasActive) {
state.activeConversationId = prevActive
state.messages = prevMessages
}
},
'deleteConvFail',
)
// 注意:op 返回 void,成功=undefined、失败=null(不能 `!ok`——undefined 也 falsy 会误判失败)
if (ok === null) return // 失败已回滚 + 推气泡,保持原视图
// 清该会话的 stream state 与 delta 去重记录,防 per-conv Map 无限增长
clearConvStreamState(id)
clearLastDelta(id)
if (wasActive) {
void appSettings.remove('df-ai-active-conv')
// 删除活跃会话 → load_more 游标复位(无 active 视图)
loadMoreCursor.hasMore = false
loadMoreCursor.earliestSeq = null
loadMoreCursor.convId = null
loadMoreCursor.loading = false
}
scheduleConversationsRefresh()
// 删的是活跃会话:回落相邻会话作为新活跃视图
if (neighborId) await switchConversation(neighborId)
notifyConversationChanged({ deletedConvId: id })
}
/** 会话操作失败气泡的 i18n key(M30 族 rename/archive/pin + G3.4 new/delete + G3.3 switch 瞬态失败) */
type ConvOpFailKey =
| 'renameConvFail' | 'archiveConvFail' | 'pinConvFail'
| 'newConvFail' | 'deleteConvFail' | 'switchConvFail'
/** M30:写后端 IPC 失败时推送错误气泡(对齐 loadConversations/switchConversation 的失败反馈),
* 保持侧栏已有交互不被无声吞掉(原仅向上抛,调用方 store 直调无 try → 异常进 unhandledrejection)。 */
function pushConvOpFail(key: ConvOpFailKey, 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()
}
/** 会话操作族统一 helper(G3.4):乐观更新 + 失败回滚 + 错误气泡。
* 边界=会话操作族(rename/archive/pin/new/delete),非全局错误抽象。
* apply:乐观本地变更(同步,幂等);op:后端 IPC;rollback:失败恢复 apply 前本地态;
* failKey:错误气泡 i18n key;params:i18n 插值参数。
* 返回后端 op 结果,失败返 null(气泡已推,调用方据此短路后续流程)。 */
async function withConvOp<T>(
apply: () => void,
op: () => Promise<T>,
rollback: () => void,
failKey: ConvOpFailKey,
params?: Record<string, string | number>,
): Promise<T | null> {
apply()
notifyConversationChanged()
try {
return await op()
} catch (e) {
console.error('[AI] 会话操作失败:', e)
rollback()
notifyConversationChanged()
pushConvOpFail(failKey, params)
return null
}
}
/** 重命名会话(后端 + 本地侧栏摘要同步)。
* M30/G3.4:乐观更新 + 失败回滚 + 错误气泡(收敛进 withConvOp)。 */
async function renameConversation(id: string, title: string) {
const conv = state.conversations.find(c => c.id === id)
const prevTitle = conv?.title ?? null
await withConvOp(
() => { if (conv) conv.title = title },
() => aiApi.renameConversation(id, title),
() => { if (conv) conv.title = prevTitle },
'renameConvFail',
)
}
/** 归档/取消归档(后端 + 本地侧栏分组同步)。
* M30/G3.4:乐观更新 + 失败回滚 + 错误气泡(收敛进 withConvOp)。 */
async function archiveConversation(id: string, archived: boolean) {
const conv = state.conversations.find(c => c.id === id)
const prevArchived = conv?.archived ?? false
await withConvOp(
() => { if (conv) conv.archived = archived },
() => aiApi.archiveConversation(id, archived),
() => { if (conv) conv.archived = prevArchived },
'archiveConvFail',
{ action: archived ? t('ai.archiveAction') : t('ai.unarchiveAction') },
)
}
/** 置顶/取消置顶(后端 + 本地侧栏排序同步;UX-17)。
* M30/G3.4:乐观更新 + 失败回滚 + 错误气泡(收敛进 withConvOp)。 */
async function setPinnedConversation(id: string, pinned: boolean) {
const conv = state.conversations.find(c => c.id === id)
const prevPinned = conv?.pinned ?? false
await withConvOp(
() => { if (conv) conv.pinned = pinned },
() => aiApi.setPinnedConversation(id, pinned),
() => { if (conv) conv.pinned = prevPinned },
'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,
scheduleConversationsRefresh,
newConversation,
switchConversation,
deleteConversation,
renameConversation,
archiveConversation,
setPinnedConversation,
loadMoreHistory,
toggleArchivedFold,
toggleGroupFold,
toggleSidebar,
}
}
export { loadConversations, scheduleConversationsRefresh, loadMoreHistory, getLoadMoreState }