优化: aichat效率批(save无变化捷径/断路器尾窗/system缓存指纹/会话列表刷新收敛) + 新增 LLM prompt caching(cache_control开关默认关/system稳定段易变段分离治缓存命中率) + 销账

This commit is contained in:
lxy
2026-08-09 21:35:13 +08:00
parent 4df91ed155
commit 7eb2e25ed5
13 changed files with 698 additions and 180 deletions
+70 -47
View File
@@ -62,6 +62,20 @@ async function loadConversations() {
}
}
// 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
@@ -103,7 +117,8 @@ async function newConversation() {
// queue 保留旧会话排队消息(它们有 conversationId,会在旧会话 AiCompleted 时按 ID 精准 drain)
state.agentRound = 0
state.searchQuery = ''
await loadConversations()
// FE1:走防抖调度(250ms trailing),与下行 notify 自触发的 listener 刷新合并为一次。
scheduleConversationsRefresh()
notifyConversationChanged()
} finally {
// 防抖释放:300ms 后允许再次新建
@@ -286,15 +301,15 @@ export async function switchConversation(id: string, force = false) {
loadMoreCursor.loading = false
state.messages = []
notifyConversationChanged()
void loadConversations()
scheduleConversationsRefresh()
return
}
// 瞬态失败:保留当前视图 + 错误气泡(复用会话操作失败气泡模式);过期响应丢弃。
// 保留 loadConversations() 刷新:陈旧 id(已被后端删除)在下一次列表刷新中消失。
// 保留 scheduleConversationsRefresh():陈旧 id(已被后端删除)在下一次列表刷新中消失。
console.error('[AI] switchConversation 失败(瞬态),保留当前视图:', e)
if (mySwitchId !== _latestSwitchId) return
pushConvOpFail('switchConvFail')
void loadConversations()
scheduleConversationsRefresh()
return
} finally {
// 切换结束(含失败/过期/新建会话路径)清切换中标记 + 统一收集并清空缓冲。
@@ -393,53 +408,60 @@ export async function switchConversation(id: string, force = false) {
// 恢复该对话积压的待审批:重启后后端从审计表重建了挂起审批,
// 此处查回并把对应 toolCard.status 置为待审批,使审批卡片重新可见。
// 按 IPC 返的 kind 渲染:'path' 类显 once/always/deny,'risk' 类显 approve/reject。
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 ?? '' })
// 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)
}
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 = []
}
state.pendingApprovals = restored
} else {
} catch {
state.pendingApprovals = []
}
} catch {
state.pendingApprovals = []
}
}
@@ -493,7 +515,7 @@ async function deleteConversation(id: string) {
loadMoreCursor.convId = null
loadMoreCursor.loading = false
}
await loadConversations()
scheduleConversationsRefresh()
// 删的是活跃会话:回落相邻会话作为新活跃视图
if (neighborId) await switchConversation(neighborId)
notifyConversationChanged({ deletedConvId: id })
@@ -604,6 +626,7 @@ function toggleSidebar() {
export function useAiConversations() {
return {
loadConversations,
scheduleConversationsRefresh,
newConversation,
switchConversation,
deleteConversation,
@@ -617,4 +640,4 @@ export function useAiConversations() {
}
}
export { loadConversations, loadMoreHistory, getLoadMoreState }
export { loadConversations, scheduleConversationsRefresh, loadMoreHistory, getLoadMoreState }