优化: 批A前端(timer所有权+会话族+审批conv-scoped+FilePreview+失败反馈)

A1-B1 timer所有权:新建 useTimerOwnership composable(每实例独立ref+onUnmounted清理)+ useToast _timer 下沉 + Ideas debounce 补清理(治跨实例串扰)

A1-B2 FilePreview:reqSeq 双计数器守卫(loadFile/loadDiff 最新seq才写,防乱序覆盖)+ mermaid securityLevel strict + filePath 比对(防跨文件SVG注入)

A1-B3 会话族:load_more 滚顶加载接线(switch透传has_more/earliest_seq+prepend去重+scrollTop恢复)+ switch失败保留视图+报错(仅对话不存在才create-new)+ new/delete失败反馈(withConvOp helper收敛)+ delete清ai_messages孤儿

A1-B5前端契约:isToolFailure 三处加 success===false 判定(useToolCard/ToolResultBody/ToolCard,git只读失败不再绿框)

A2-B10 审批conv-scoped:pendingApprovals 补conversationId + cleanup 按convId filter + 移除全局清(AiError不再误清其他conv)+ :676 dir auth filter方向修

usage打标前端:is_estimated 字段+事件透传+MessageList『估算』角标(详情面板说明)

A2-B9前端:clearChat try/catch 错误气泡
This commit is contained in:
lxy
2026-08-05 22:10:50 +08:00
parent 5667da6cf4
commit c480627ba6
14 changed files with 837 additions and 177 deletions
+280 -120
View File
@@ -65,9 +65,22 @@ async function loadConversations() {
/** 新建空对话并切过去 */
async function newConversation() {
const result = await aiApi.createConversation()
// 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 = []
@@ -87,6 +100,138 @@ async function newConversation() {
// 切换 token:快速连点 A→B 时,后返回的 A 响应按 token 丢弃,防 messages 错配(FR-R1)
let _latestSwitchId = 0
// ============================================================
// 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,
}
: 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 审批恢复) */
export async function switchConversation(id: string) {
// AIC-FIX-17-P0-2:切换会话时清空所有审批计时器(防切走后过期 timer 误拒后台 pending + 错误气泡推错视图)。
@@ -97,22 +242,45 @@ export async function switchConversation(id: string) {
let detail: AiConversationDetail
try {
detail = await aiApi.switchConversation(id)
} catch {
// 对话不存在(已删除/未落库的虚 ID)→ 创建新对话替代
console.warn('[AI] switchConversation 失败,创建新对话:', id)
const created = await aiApi.createConversation()
} catch (e) {
// G3.3:区分 Err 形态——仅"对话不存在"(已删除/未落库的虚 ID)才 create-new 兜底;
// 瞬态 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: '[]' }
// G3.5:新建对话无历史,load_more 游标复位
loadMoreCursor.hasMore = false
loadMoreCursor.earliestSeq = null
loadMoreCursor.convId = created.id
loadMoreCursor.loading = false
state.messages = []
notifyConversationChanged()
void loadConversations()
return
}
// 瞬态失败:保留当前视图 + 错误气泡(复用 M30 pushConvOpFail 模式);过期响应丢弃。
// 保留 loadConversations() 刷新:陈旧 id(已被后端删除)在下一次列表刷新中消失。
console.error('[AI] switchConversation 失败(瞬态),保留当前视图:', e)
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()
pushConvOpFail('switchConvFail')
void loadConversations()
return
}
// 过期响应丢弃(用户已切到别的对话,防 A 后返回覆盖 B)
if (mySwitchId !== _latestSwitchId) return
state.activeConversationId = id
// G3.5:透传 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
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
@@ -128,75 +296,10 @@ export async function switchConversation(id: string) {
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),
}
}),
}))
// G3.5:消息映射收敛进 parseConvMessages(switch + load_more 共用,过滤/映射语义一致)。
// 相比原内联逻辑唯一行为变化:id 由 `loaded-${i}` 改为优先后端真实消息 id
// (DB 主键,prepend 时 v-for key 稳定不重建 DOM),缺失才兜底 `loaded-${i}`。
state.messages = parseConvMessages(rawMsgs, 'loaded')
} catch (e) {
// UX-260617-08:历史消息解析/映射失败原仅 state.messages=[] → 切换后空白用户不知原因。
// 改为推错误气泡提示 + 控制台日志(保留 state.messages 不再清空,避免空白无反馈)。
@@ -260,6 +363,9 @@ export async function switchConversation(id: string) {
path: tc.path,
dir: tc.dir,
reason: tc.reason,
// A2-B10 conv-scoped 审批收尾:恢复的历史挂起带目标会话 id,
// cleanupTerminatedConversation 按此仅清本会话的待审批项(不连累并发会话)。
conversationId: id,
})
// 重新启动审批计时器(APPROVAL_TIMEOUT_MS 默认 15min0=不限时跳过;
// 启动仅用于计时器注册一致性,保持与 AiApprovalRequired 事件处理对称)
@@ -276,27 +382,66 @@ export async function switchConversation(id: string) {
}
}
/** 删除会话;若删的是当前活跃会话则清空消息+移除活跃 id 持久化 */
/** 删除会话;若删的是当前活跃会话则清空消息+移除活跃 id 持久化
* G3.4:收敛进 withConvOp(乐观移除列表 + 失败回滚 + 错误气泡),原裸 await 失败抛 unhandled rejection。 */
async function deleteConversation(id: string) {
// AIC-FIX-17-P0-2:删除会话时清空所有审批计时器(防已删会话的过期 timer 到期误拒审批 +
// 错误气泡推错视图)。
clearAllApprovalTimers()
await aiApi.deleteConversation(id)
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 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 // 失败已回滚 + 推气泡,保持原视图
// F-09 per-conv:清该会话的 stream state(streaming/currentText),防 convStreamStates Map 无限增长
// (与 convStates/待审批等 per-conv 资源同款会话级清理语义)。
clearConvStreamState(id)
if (state.activeConversationId === id) {
state.activeConversationId = null
if (wasActive) {
void appSettings.remove('df-ai-active-conv')
state.messages = []
// G3.5:删除活跃会话 → load_more 游标复位(无 active 视图)
loadMoreCursor.hasMore = false
loadMoreCursor.earliestSeq = null
loadMoreCursor.convId = null
loadMoreCursor.loading = false
}
await loadConversations()
notifyConversationChanged()
}
/** 会话操作失败气泡的 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: 'renameConvFail' | 'archiveConvFail' | 'pinConvFail', params?: Record<string, string | number>) {
function pushConvOpFail(key: ConvOpFailKey, params?: Record<string, string | number>) {
state.messages.push({
id: `conv-op-fail-${nextMsgId()}`,
role: 'assistant',
@@ -307,56 +452,70 @@ function pushConvOpFail(key: 'renameConvFail' | 'archiveConvFail' | 'pinConvFail
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:乐观更新(本地先写 → IPC → 失败回滚 + 提示)。原逻辑 IPC 成功才写本地,IPC 失败时异常
* 向上抛但 store 直调处无 try/catch → unhandledrejection,用户无感。改乐观更新让失败可见可回滚。 */
* 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
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')
}
await withConvOp(
() => { if (conv) conv.title = title },
() => aiApi.renameConversation(id, title),
() => { if (conv) conv.title = prevTitle },
'renameConvFail',
)
}
/** 归档/取消归档(后端 + 本地侧栏分组同步)。
* M30:乐观更新 + 失败回滚 + 提示(同 renameConversation)。 */
* 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
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') })
}
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:乐观更新 + 失败回滚 + 提示(同 renameConversation)。 */
* 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
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') })
}
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') },
)
}
/** 折叠/展开归档分组 */
@@ -386,10 +545,11 @@ export function useAiConversations() {
renameConversation,
archiveConversation,
setPinnedConversation,
loadMoreHistory,
toggleArchivedFold,
toggleGroupFold,
toggleSidebar,
}
}
export { loadConversations }
export { loadConversations, loadMoreHistory, getLoadMoreState }
+54 -10
View File
@@ -208,7 +208,14 @@ export function friendlyError(raw: string): string {
/** 把流式累积的 currentText 回填到最后一条 assistant 消息(AiAgentRound/AiCompleted/AiError 收尾共用) */
export function flushCurrentText() {
if (!state.currentText) return
// 根因修复(空气泡):只 guard 空串会漏 whitespace —— LLM 工具调用前常推 `\n`/空格,
// 累积成 whitespace-only currentText 后写进占位 content,前端 !content 真值判断漏过
// → 渲染带边框空气泡。trim 兜底空白,空白流式文本不回填(无实质内容)。
if (!state.currentText || !state.currentText.trim()) {
state.currentText = ''
_lastDelta = '' // 同步复位 delta 跟踪(防下一轮首个 delta 误判重复)
return
}
// 从末尾向前找最后一个非 isError assistant 气泡写入。跳过 AiStreamRetry 错误气泡
// (isError),写入其前的占位 assistant,保留部分回复(UX-260619-06 MED-1)。
for (let i = state.messages.length - 1; i >= 0; i--) {
@@ -416,6 +423,8 @@ function handleStreamingEvent(event: AiChatEvent): boolean {
kind: 'path',
dir: event.dir,
path: event.path,
// A2-B10 conv-scoped:挂起项归属会话 id,cleanup 按此仅清本会话审批(不连累并发会话)。
conversationId: event.conversation_id ?? state.activeConversationId ?? undefined,
// path 类审批提示复用 aiChat.dirAuthHint(已存在 i18n,tool+path 文案);
// 不新增 key 避免 i18n 缺失 prod runtime 报错(memory: i18n-message-compile-blindspot)。
reason: t('aiChat.dirAuthHint', { tool: event.tool, path: event.path }),
@@ -525,6 +534,9 @@ function handleToolEvent(event: AiChatEvent): boolean {
// tc.reason 与 pendingApprovals[].reason 是两条独立赋值路径:tc 走 findToolCall,
// 浮窗走主窗口推送的 pendingApprovals 快照,后者此前漏写 reason。
reason: event.reason,
// A2-B10 conv-scoped:挂起项归属会话 id,cleanup 按此仅清本会话审批(不连累并发会话)。
// 事件必带 conversation_id(缺省兜底当前活跃会话——AiApprovalRequired 仅当 isCurrent 才到达此处)。
conversationId: event.conversation_id ?? state.activeConversationId ?? undefined,
}
state.pendingApprovals.push(info)
const tc = findToolCall(event.id)
@@ -605,6 +617,16 @@ function handleConvStateEvent(event: AiChatEvent): boolean {
* 返回值:true=命中 AiUserMessage;false=其他事件。 */
function handleUserMessageEvent(event: AiChatEvent): boolean {
if (event.type !== 'AiUserMessage') return false
// 多会话并行隔离(2026-08-05 BUG-260805-02):AiUserMessage 必带 conversation_id(后端已 resolve)。
// - 当前会话(或 null)→ push user 气泡(微信端当前会话消息,桌面若同会话直接展示)
// - 非当前会话 → 不污染当前视图(桌面在并行看别的会话),仅刷新会话列表
// (侧栏显示该会话有新消息;用户切过去 switchConversation 从 DB 加载完整历史)。
// 旧实现无条件 push 到 state.messages → 非当前会话的 user 消息混入当前视图(历史内容被"污染")。
const uc = event.conversation_id ?? null
if (uc && state.activeConversationId && uc !== state.activeConversationId) {
void loadConversations()
return true
}
// 去重:末条已是 user 且 content 相同则跳过(防桌面本地乐观 push + 事件回灌双气泡)。
const last = state.messages[state.messages.length - 1]
if (last && last.role === 'user' && last.content === event.message) {
@@ -622,11 +644,12 @@ function handleUserMessageEvent(event: AiChatEvent): boolean {
/**
* 公共会话终止收尾(任务 #6 DRY 抽离):AiCompleted / AiError / AiHelpRequired
* 三分支共用同一套清场逻辑——看门狗/计时器/流式态/currentText/agentRound/per-conv
* 挂起(localStorage 快照 + pendingMaxRounds + pendingDirAuths + convStates)。
* 挂起(localStorage 快照 + pendingMaxRounds + pendingDirAuths + convStates +
* A2-B10 conv-scoped 审批收尾:pendingApprovals + 对应审批超时计时器按 convId 收敛)。
*
* 语义差异(调用方自行处理):
* - AiCompleted: 调用后追加 incomplete 气泡 / token 用量 / 队列 drain
* - AiError: 调用后追加错误气泡 + clearAllApprovalTimers + 清 queue/pendingApprovals
* - AiError: 调用后追加错误气泡 + 清 queue(审批清理已在 cleanup 内 conv-scoped 完成)
* - AiHelpRequired: 调用后翻 pendingHelp 驱动求助卡
*/
function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | 'AiError' | 'AiHelpRequired') {
@@ -635,6 +658,15 @@ function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | '
clearTextIdleTimer() // 清文本空闲定时器 + 置 textIdle=true(整轮结束不该有活跃信号残留)
flushCurrentText()
state.currentText = ''
// 清「重试中」过渡气泡(成功后残留根治,与 miniapp clearRetryBubbles 对齐)。
// AiStreamRetry 推的 retry 气泡(isError,content 含「正在重试(」)是过渡态,终态不清会
// 永久残留列表(flushCurrentText 只写非 error 气泡)。这里按 i18n 文案前缀清除。
for (let i = state.messages.length - 1; i >= 0; i--) {
const m = state.messages[i]
if (m.isError && typeof m.content === 'string' && m.content.includes('正在重试(')) {
state.messages.splice(i, 1)
}
}
setStreaming(false, { convId: convId || null, reason })
state.agentRound = 0 // AE-2025-07: agentic 结束/中断/求助,复位轮次(隐藏进度条)
@@ -657,7 +689,18 @@ function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | '
}
// TD-260621-03 per-conv:仅清本 conv 的 path_auth 挂起(终止只清本会话弹窗,不连累并发会话)。
// F-09 多会话并发下全清会让 B 会话的 DirAuthDialog 凭空消失(用户报"弹窗没了我没操作")。
pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId === convId)
// A2-B10 修正:终止语义=清本 conv(该会话已结束),保其他 conv;无归属旧项保守保留。
pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId !== convId)
// A2-B10 conv-scoped 审批收尾:仅清目标 conv 的待审批项 + 对称清其审批超时计时器。
// 对齐上方 pendingDirAuths per-conv filter 范例。conversationId 缺失的旧项(无法归属)
// 保守保留——误清其他 conv 正在审批的卡 = 回归;仅清 conversationId===convId 的项。
// 与旧全局清(AiError/AiHelpRequired 分支 state.pendingApprovals = [] + clearAllApprovalTimers)
// 的差异:此处按 convId 收敛,其他会话的审批卡/计时器不受影响。
const removedApprovals = state.pendingApprovals.filter(p => p.conversationId === convId)
state.pendingApprovals = state.pendingApprovals.filter(p => !p.conversationId || p.conversationId !== convId)
// 对称清本 conv 被移除项的审批超时计时器(id 集合),防到点回调改已终止会话 state。
for (const p of removedApprovals) clearApprovalTimer(p.id)
// F-09: 清理分离窗口生成态快照(per-conv key,清本会话快照;兼容旧单 key)
if (convId) {
@@ -721,6 +764,7 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
cache_hit: event.prompt_cache_hit_tokens,
cache_miss: event.prompt_cache_miss_tokens,
reasoning: event.reasoning_tokens,
is_estimated: event.is_estimated,
}
m.prompt_cache_hit_tokens = event.prompt_cache_hit_tokens
m.prompt_cache_miss_tokens = event.prompt_cache_miss_tokens
@@ -737,16 +781,15 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
case 'AiError': {
cleanupTerminatedConversation(event.conversation_id || '', 'AiError')
clearAllApprovalTimers() // UX-260617-10: 错误中断释放所有审批超时计时器,防回调改 state 触发已卸载/已错流程
// A2-B10 conv-scoped 审批收尾:本 conv 的待审批项 + 审批超时计时器已由
// cleanupTerminatedConversation 按 convId 收敛(不再全局 clearAllApprovalTimers /
// state.pendingApprovals = [],避免连累并发会话正在审批的卡)。
// 只清除出错会话的队列项,不误伤其他会话的排队消息
if (event.conversation_id) {
state.queue = state.queue.filter(q => q.conversationId !== event.conversation_id)
} else {
state.queue = []
}
// UX-260617-10: 错误收尾清残留待审批项——错误发生时若有工具停在 pending_approval,
// 残留可点击审批按钮会让用户误以为还能批(实际后端已终止),残留审批卡误导操作。
state.pendingApprovals = []
// UX-03: 错误消息携带 error_type(供错误气泡差异化显隐「去设置」按钮)。
// AiMessage 类型未含 errorType 字段(不在本批白名单),用对象字面量 + cast 扩展;
// 消费方(AiChat.vue canOpenSettings)经同 cast 读取,类型闭环在两端,不污染 types.ts。
@@ -770,8 +813,9 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
// 但不创建错误气泡(求助非错误,是 AI 主动求助)——改为翻 pendingHelp 驱动求助卡(HelpRequiredCard)
// 显 reason + options 按钮供用户选。
cleanupTerminatedConversation(event.conversation_id || '', 'AiHelpRequired')
// 求助即终止 loop,清残留待审批项(对齐 AiError 分支语义,防残留审批卡误导)。
state.pendingApprovals = []
// A2-B10 conv-scoped 审批收尾:本 conv 的待审批项 + 审批超时计时器已由
// cleanupTerminatedConversation 按 convId 收敛(不再全局 state.pendingApprovals = [],
// 避免连累并发会话正在审批的卡)。
// 翻 pendingHelp 驱动求助卡:用 convId 兜底(后端必带,无时默认当前活跃会话,优于丢卡片)。
pendingHelp.value = {
reason: event.reason,