优化: 批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:
@@ -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 默认 15min,0=不限时跳过;
|
||||
// 启动仅用于计时器注册一致性,保持与 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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//! 每实例独立的定时器所有权 composable。
|
||||
//!
|
||||
//! 背景:模块级/组件级 let timer 单例共享(如 useToast 原模块级 _timer)会跨实例串扰 ——
|
||||
//! 组件 B 的 showToast/onUnmounted 清掉组件 A 的 timer,致 A 的 toast 永不隐藏;
|
||||
//! 组件卸载后 timer 仍可能触发(向单例 store 写入,setState-after-unmount)。
|
||||
//!
|
||||
//! 分工:本 composable 为每个调用实例持有独立的 timeout/interval 注册表,
|
||||
//! onUnmounted 自动全清,互不影响。setOwnTimeout 触发后自动从注册表移除(一次性)。
|
||||
//! 适用:toast 自动隐藏 / keyword 防抖 / FilePreview 等后续 debounce 场景。
|
||||
//!
|
||||
//! 注意:仅在组件 setup 内调用(内部依赖 onUnmounted 生命周期钩子)。
|
||||
|
||||
import { onUnmounted } from 'vue'
|
||||
|
||||
export function useTimerOwnership() {
|
||||
const timeouts = new Set<ReturnType<typeof setTimeout>>()
|
||||
const intervals = new Set<ReturnType<typeof setInterval>>()
|
||||
|
||||
/** 注册一次性 timeout;触发后自动从注册表移除。返回可交给 clearOwn 的 id。 */
|
||||
function setOwnTimeout(fn: () => void, ms: number) {
|
||||
const id = setTimeout(() => {
|
||||
timeouts.delete(id)
|
||||
fn()
|
||||
}, ms)
|
||||
timeouts.add(id)
|
||||
return id
|
||||
}
|
||||
|
||||
/** 注册重复 interval;onUnmounted 自动清理。返回可交给 clearOwnInterval 的 id。 */
|
||||
function setOwnInterval(fn: () => void, ms: number) {
|
||||
const id = setInterval(fn, ms)
|
||||
intervals.add(id)
|
||||
return id
|
||||
}
|
||||
|
||||
/** 清理指定 timeout(id 为 null/undefined 时 no-op)。 */
|
||||
function clearOwn(id?: ReturnType<typeof setTimeout> | null) {
|
||||
if (id != null) {
|
||||
clearTimeout(id)
|
||||
timeouts.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
/** 清理指定 interval(id 为 null/undefined 时 no-op)。 */
|
||||
function clearOwnInterval(id?: ReturnType<typeof setInterval> | null) {
|
||||
if (id != null) {
|
||||
clearInterval(id)
|
||||
intervals.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
timeouts.forEach(clearTimeout)
|
||||
intervals.forEach(clearInterval)
|
||||
timeouts.clear()
|
||||
intervals.clear()
|
||||
})
|
||||
|
||||
return { setOwnTimeout, setOwnInterval, clearOwn, clearOwnInterval }
|
||||
}
|
||||
+13
-13
@@ -11,7 +11,8 @@
|
||||
* showToast('导入失败', 'error', 4000)
|
||||
* // template: <div v-if="toast.visible" class="toast ...">{{ toast.msg }}</div>
|
||||
*/
|
||||
import { reactive, onUnmounted } from 'vue'
|
||||
import { reactive } from 'vue'
|
||||
import { useTimerOwnership } from './useTimerOwnership'
|
||||
|
||||
// P0-2: 加 'success'(保存成功用绿色 toast,此前成功/中性都用 info 致反馈不明确)
|
||||
export type ToastType = 'info' | 'error' | 'warning' | 'success'
|
||||
@@ -22,8 +23,6 @@ export interface ToastState {
|
||||
type: ToastType
|
||||
}
|
||||
|
||||
let _timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
export function useToast(defaultDurationMs = 3000) {
|
||||
const toast = reactive<ToastState>({
|
||||
visible: false,
|
||||
@@ -31,30 +30,31 @@ export function useToast(defaultDurationMs = 3000) {
|
||||
type: 'info',
|
||||
})
|
||||
|
||||
// G5.4:原模块级 `let _timer` 单例被所有 useToast() 实例共享 —— 组件 B showToast/onUnmounted
|
||||
// 会清掉组件 A 的 timer,致 A 的 toast 永不隐藏(跨实例 bug)。
|
||||
// 已下沉为「每实例独立 timer」:useTimerOwnership 每实例注册表 + onUnmounted 只清自己。
|
||||
// 不再保留模块级兜底 —— 模块级共享正是串扰根源,每实例自清理即正确语义。
|
||||
const { setOwnTimeout, clearOwn } = useTimerOwnership()
|
||||
let _timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function showToast(msg: string, type: ToastType = 'info', durationMs?: number) {
|
||||
toast.msg = msg
|
||||
toast.type = type
|
||||
toast.visible = true
|
||||
if (_timer) clearTimeout(_timer)
|
||||
_timer = setTimeout(() => {
|
||||
if (_timer) clearOwn(_timer)
|
||||
_timer = setOwnTimeout(() => {
|
||||
_timer = null
|
||||
toast.visible = false
|
||||
}, durationMs ?? defaultDurationMs)
|
||||
}
|
||||
|
||||
function hideToast() {
|
||||
if (_timer) {
|
||||
clearTimeout(_timer)
|
||||
clearOwn(_timer)
|
||||
_timer = null
|
||||
}
|
||||
toast.visible = false
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (_timer) {
|
||||
clearTimeout(_timer)
|
||||
_timer = null
|
||||
}
|
||||
})
|
||||
|
||||
return { toast, showToast, hideToast }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user