优化: 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 }
+7 -4
View File
@@ -18,7 +18,7 @@ import { state } from '@/stores/ai'
import { nextMsgId, setConvState, convStates, switchingConvs, getConvStreamState, setConvCurrentText, setConvStreaming, clearConvStreamState, clearAllApprovalTimers, clearAllToolSlowTimers, flushCurrentText, friendlyError, notifyConversationChanged } from './aiShared'
import { resetStreamWatchdog, clearAllStreamWatchdogs } from './useAiStream'
import { setStreaming } from './streamingGuard'
import { loadConversations } from './useAiConversations'
import { scheduleConversationsRefresh } from './useAiConversations'
import { handleStreamingEvent } from './useAiStreamingEvents'
import { handleToolEvent } from './useAiToolEvents'
import { handleLifecycleEvent } from './useAiLifecycleEvents'
@@ -61,7 +61,8 @@ function handleUserMessageEvent(event: AiChatEvent): boolean {
// 多会话并行隔离:AiUserMessage 必带 conversation_id;非当前会话不污染当前视图,仅刷新列表
const uc = event.conversation_id ?? null
if (uc && state.activeConversationId && uc !== state.activeConversationId) {
void loadConversations()
// FE1:非当前会话的用户消息只刷新列表(走防抖调度,与收尾 notify 合并为一次)。
scheduleConversationsRefresh()
return true
}
const last = state.messages[state.messages.length - 1]
@@ -96,7 +97,8 @@ export function handleEvent(event: AiChatEvent) {
if (event.type === 'AiCompleted' || event.type === 'AiError' || event.type === 'AiHelpRequired') {
// 非当前会话终止:收敛会话状态(删 Map 项回不在生成)
convStates.delete(convId || '')
void loadConversations()
// FE1:后台会话终止只刷新列表(防抖调度,避免与 AiCompleted 收尾的 notify 双拉)。
scheduleConversationsRefresh()
// 后台会话终止也触发队列续发/清队(完成续发;错误清该会话队列)
if (event.type === 'AiCompleted') {
emit('ai-drain-queue', { conversationId: event.conversation_id })
@@ -142,7 +144,8 @@ export async function startListener() {
try {
_unlistenAiEvent = await aiApi.onEvent(handleEvent)
_unlistenConvChanged = await listen('ai-conversation-changed', (e) => {
void loadConversations()
// FE1:notify 自触发的刷新走防抖调度,同窗口内多次 notify 合并为一次列表拉取。
scheduleConversationsRefresh()
// 主窗口删除了本窗口当前展示的会话:清空视图防陈旧幽灵(分离窗口独立 JS context,经事件同步)。
const deleted = (e.payload as { deletedConvId?: string } | undefined)?.deletedConvId
if (deleted && state.activeConversationId === deleted) {
+3 -3
View File
@@ -13,7 +13,6 @@ import { nextMsgId, convStates, clearApprovalTimer, flushCurrentText, clearAllTo
import { clearTextIdleTimer, pendingMaxRounds, pendingHelp, pendingDirAuths } from './useAiPendingState'
import { clearStreamWatchdog } from './useAiStream'
import { setStreaming } from './streamingGuard'
import { loadConversations } from './useAiConversations'
import type { AiChatEvent, AiMessage, MessageId } from '@/api/types'
const appSettings = useAppSettingsStore()
@@ -116,7 +115,8 @@ export function handleLifecycleEvent(event: AiChatEvent): boolean {
const conv = state.conversations.find(c => c.id === state.activeConversationId)
if (conv) conv.pinned_goals = event.pinned_goals
}
void loadConversations()
// FE1(AC-EFF-F1-1/2/3):去掉显式 loadConversations(曾与下方 notify 自触发双拉),
// 列表刷新统一收敛到 ai-conversation-changed listener 的防抖调度,一次收尾只拉一次。
// token 用量记录(开关开时):lastTokenUsage 供当前回复展示,convTokenTotal 累加对话总量
if (isShowTokenUsage()) {
state.lastTokenUsage = {
@@ -191,7 +191,7 @@ export function handleLifecycleEvent(event: AiChatEvent): boolean {
options: event.options,
conversationId: event.conversation_id || state.activeConversationId || null,
}
void loadConversations()
// FE1:去掉显式 loadConversations(与下行 notify 双拉),由 listener 防抖调度统一收口。
notifyConversationChanged()
return true
}