diff --git a/static/src/app/methods/conversation/action.ts b/static/src/app/methods/conversation/action.ts index b3b2d99e..ea4a1197 100644 --- a/static/src/app/methods/conversation/action.ts +++ b/static/src/app/methods/conversation/action.ts @@ -196,10 +196,13 @@ export const actionMethods = { [newConversationId]: 'create' }; this.conversationListAnimationMode = 'create'; - this.conversations = [ + /* 原地 splice 保持数组引用:conversations 与双类型缓存中当前类型列表同一引用 */ + this.conversations.splice( + 0, + this.conversations.length, placeholder, ...this.conversations.filter((conv) => conv && conv.id !== newConversationId) - ]; + ); // 分组视图下同步在当前工作区分组顶部插入占位 try { @@ -212,10 +215,12 @@ export const actionMethods = { (g: any) => g.workspaceId === currentWorkspaceId ); if (group) { - group.conversations = [ + group.conversations.splice( + 0, + group.conversations.length, placeholder, ...group.conversations.filter((conv: any) => conv.id !== newConversationId) - ]; + ); group.expanded = true; group.visibleOffset = 0; } @@ -336,10 +341,12 @@ export const actionMethods = { conversationStore.ensureWorkspaceGroup(workspaceId); const group = conversationStore.workspaceGroups.find((g: any) => g.workspaceId === workspaceId); if (group) { - group.conversations = [ + group.conversations.splice( + 0, + group.conversations.length, placeholder, ...group.conversations.filter((conv: any) => conv.id !== newConversationId) - ]; + ); group.expanded = true; group.visibleOffset = 0; } @@ -432,7 +439,11 @@ export const actionMethods = { const { useConversationStore } = await import('../../../stores/conversation'); const conversationStore = useConversationStore(); conversationStore.workspaceGroups.forEach((group: any) => { - group.conversations = group.conversations.filter((conv: any) => conv.id !== conversationId); + group.conversations.splice( + 0, + group.conversations.length, + ...group.conversations.filter((conv: any) => conv.id !== conversationId) + ); const maxVisibleOffset = Math.max(0, group.conversations.length - group.visibleLimit); if (group.visibleOffset > maxVisibleOffset) { group.visibleOffset = maxVisibleOffset; @@ -443,8 +454,10 @@ export const actionMethods = { } await waitForAnimation(430); - this.conversations = this.conversations.filter( - (conversation) => conversation.id !== conversationId + this.conversations.splice( + 0, + this.conversations.length, + ...this.conversations.filter((conversation) => conversation.id !== conversationId) ); this.searchResults = this.searchResults.filter( (conversation) => conversation.id !== conversationId @@ -532,13 +545,15 @@ export const actionMethods = { (conv) => conv && conv.id === conversationId ); if (insertIndex >= 0) { - this.conversations = [ + this.conversations.splice( + 0, + this.conversations.length, ...withoutDuplicate.slice(0, insertIndex + 1), duplicatePlaceholder, ...withoutDuplicate.slice(insertIndex + 1) - ]; + ); } else { - this.conversations = [duplicatePlaceholder, ...withoutDuplicate]; + this.conversations.splice(0, this.conversations.length, duplicatePlaceholder, ...withoutDuplicate); } window.setTimeout(() => { @@ -560,13 +575,15 @@ export const actionMethods = { const sourceGroupIndex = group.conversations.findIndex((conv: any) => conv.id === conversationId); const withoutDuplicate = group.conversations.filter((conv: any) => conv.id !== newId); if (sourceGroupIndex >= 0) { - group.conversations = [ + group.conversations.splice( + 0, + group.conversations.length, ...withoutDuplicate.slice(0, sourceGroupIndex + 1), duplicatePlaceholder, ...withoutDuplicate.slice(sourceGroupIndex + 1) - ]; + ); } else { - group.conversations = [duplicatePlaceholder, ...withoutDuplicate]; + group.conversations.splice(0, group.conversations.length, duplicatePlaceholder, ...withoutDuplicate); } group.expanded = true; } diff --git a/static/src/app/methods/conversation/load.ts b/static/src/app/methods/conversation/load.ts index fedcda40..6da83a83 100644 --- a/static/src/app/methods/conversation/load.ts +++ b/static/src/app/methods/conversation/load.ts @@ -12,37 +12,49 @@ export const loadMethods = { * 由 ConversationSidebar 的 conversation-type-change 事件触发; * store 中的 sidebarConversationType 已由组件先行写入。 */ + /** + * 侧边栏对话类型(普通/多智能体)切换后的补载: + * 列表引用已由 store setSidebarConversationType 同步交换(零请求切换), + * 此处仅兜底「目标类型从未加载过」的场景(如登录后首次直接切换), + * 按需补载当前类型的主列表与工作区分组首页。 + */ async handleSidebarConversationTypeChange(_type?: 'normal' | 'multi_agent') { - this.conversationsOffset = 0; - this.hasMoreConversations = false; - await this.loadConversationsList(); + const { useConversationStore } = await import('../../../stores/conversation'); + const conversationStore = useConversationStore(); + const listType = conversationStore.sidebarConversationType; + if (!conversationStore.conversationsCache[listType]?.loaded) { + this.conversationsOffset = 0; + this.hasMoreConversations = false; + await this.loadConversationsList(); + } try { - const { useConversationStore } = await import('../../../stores/conversation'); - const conversationStore = useConversationStore(); const groups = Array.isArray(conversationStore.workspaceGroups) ? conversationStore.workspaceGroups : []; for (const group of groups) { - if (group && group.workspaceId) { - await this.loadWorkspaceConversations(group.workspaceId, { reset: true }); + if (group && group.workspaceId && !group.pagingByType?.[listType]?.loaded) { + await conversationStore.loadWorkspaceConversations(group.workspaceId); } } } catch (error) { - console.error('刷新工作区分组对话失败:', error); + console.error('补载工作区分组对话失败:', error); } }, async loadConversationsList() { const queryOffset = this.conversationsOffset; const queryLimit = this.conversationsLimit; + const { useConversationStore } = await import('../../../stores/conversation'); + const conversationStore = useConversationStore(); + /* 锁定发起时类型:响应期间用户可能已切换过滤器,数据始终写入对应类型缓存 */ + const listType = conversationStore.sidebarConversationType; const refreshToken = queryOffset === 0 ? ++this.conversationListRefreshToken : this.conversationListRefreshToken; const requestSeq = ++this.conversationListRequestSeq; this.conversationsLoading = true; try { - // 列表过滤由侧边栏类型过滤器(普通/多智能体)决定 - const { useConversationStore } = await import('../../../stores/conversation'); - const maParam = useConversationStore().sidebarConversationType === 'multi_agent' ? '&multi_agent_mode=1' : '&multi_agent_mode=0'; + // 列表过滤由请求发起时的侧边栏类型过滤器(普通/多智能体)决定 + const maParam = listType === 'multi_agent' ? '&multi_agent_mode=1' : '&multi_agent_mode=0'; const response = await fetch(`/api/conversations?limit=${queryLimit}&offset=${queryOffset}${maParam}`); const data = await response.json(); @@ -57,32 +69,48 @@ export const loadMethods = { return; } + /* 原地写入该类型缓存(当前显示类型的缓存与 conversations 同一引用,原地修改即同步显示) */ + const cache = conversationStore.conversationsCache[listType]; + const items = data.data.conversations; if (queryOffset === 0) { - this.conversations = data.data.conversations; + cache.list.splice(0, cache.list.length, ...items); } else { - this.conversations.push(...data.data.conversations); + cache.list.push(...items); } - if (this.currentConversationId) { - this.promoteConversationToTop(this.currentConversationId); - } - this.hasMoreConversations = data.data.has_more; - debugLog(`已加载 ${this.conversations.length} 个对话`); + cache.offset = queryOffset; + cache.hasMore = data.data.has_more; + cache.loaded = true; - if ( - this.conversationsOffset === 0 && - !this.currentConversationId && - this.conversations.length > 0 && - !this.isExplicitNewConversationRoute() - ) { - // 只有在初始化完成后,才自动加载第一个对话 - // 避免与 bootstrapRoute 冲突 - if (this.initialRouteResolved) { - const latestConversation = this.conversations[0]; - if (latestConversation && latestConversation.id) { - await this.loadConversation(latestConversation.id); + /* 仅仍是当前显示类型时同步扁平字段与首对话自动加载 */ + if (listType === conversationStore.sidebarConversationType) { + this.conversations = cache.list; + this.hasMoreConversations = cache.hasMore; + if (this.currentConversationId) { + this.promoteConversationToTop(this.currentConversationId); + } + if ( + queryOffset === 0 && + !this.currentConversationId && + cache.list.length > 0 && + !this.isExplicitNewConversationRoute() + ) { + // 只有在初始化完成后,才自动加载第一个对话 + // 避免与 bootstrapRoute 冲突 + if (this.initialRouteResolved) { + const latestConversation = cache.list[0]; + if (latestConversation && latestConversation.id) { + await this.loadConversation(latestConversation.id); + } } } } + debugLog(`已加载 ${cache.list.length} 个对话`); + + /* 首页加载成功后后台补载另一类型,保证切换过滤器时零等待 */ + const otherType = listType === 'multi_agent' ? 'normal' : 'multi_agent'; + if (queryOffset === 0 && !conversationStore.conversationsCache[otherType].loaded) { + this.loadConversationTypeCache(otherType).catch(() => {}); + } } else { console.error('加载对话列表失败:', data.error); } @@ -94,6 +122,32 @@ export const loadMethods = { } } }, + + /** 后台补载指定类型的首页列表缓存:已加载则跳过;refreshToken 快照防 reset/刷新后旧响应污染 */ + async loadConversationTypeCache(type: 'normal' | 'multi_agent') { + const { useConversationStore } = await import('../../../stores/conversation'); + const conversationStore = useConversationStore(); + const cache = conversationStore.conversationsCache[type]; + if (!cache || cache.loaded) return; + const tokenAtStart = this.conversationListRefreshToken; + const maParam = type === 'multi_agent' ? '&multi_agent_mode=1' : '&multi_agent_mode=0'; + try { + const response = await fetch( + `/api/conversations?limit=${this.conversationsLimit}&offset=0${maParam}` + ); + const data = await response.json(); + if (!data?.success) return; + /* reset/刷新后旧响应丢弃;并发补载去重 */ + if (tokenAtStart !== this.conversationListRefreshToken || cache.loaded) return; + const items = data.data?.conversations || []; + cache.list.push(...items); + cache.offset = 0; + cache.hasMore = !!data.data?.has_more; + cache.loaded = true; + } catch (error) { + console.error('补载对话列表缓存异常:', error); + } + }, async loadMoreConversations() { if (this.loadingMoreConversations || !this.hasMoreConversations) return; @@ -254,70 +308,9 @@ export const loadMethods = { type: 'error' }); } - }, - - async loadWorkspaceConversations(workspaceId: string, { reset = false } = {}) { - if (!workspaceId) return; - const { useConversationStore } = await import('../../../stores/conversation'); - const conversationStore = useConversationStore(); - let group = conversationStore.workspaceGroups.find((g: any) => g.workspaceId === workspaceId); - if (!group) { - group = { - workspaceId, - conversations: [], - loading: true, - hasMore: false, - loadingMore: false, - offset: 0, - limit: 20, - expanded: true - }; - conversationStore.workspaceGroups.push(group); - } - if (reset) { - group.conversations = []; - group.offset = 0; - group.hasMore = false; - } - if (group.loading || group.loadingMore) return; - group.loading = true; - try { - const { useConversationStore: useConvStore } = await import('../../../stores/conversation'); - const maParam = useConvStore().sidebarConversationType === 'multi_agent' ? '&multi_agent_mode=1' : '&multi_agent_mode=0'; - const response = await fetch(`/api/conversations?workspace_id=${encodeURIComponent(workspaceId)}&limit=${group.limit}&offset=${group.offset}${maParam}`); - const data = await response.json(); - if (data.success) { - const items = (data.data?.conversations || []).map((conv: any) => ({ - id: conv.id, - title: conv.title, - updated_at: conv.updated_at, - total_messages: conv.total_messages, - total_tools: conv.total_tools - })); - if (group.offset === 0) { - group.conversations = items; - } else { - group.conversations.push(...items); - } - group.hasMore = !!data.data?.has_more; - } else { - console.error('加载工作区对话失败:', data.error); - } - } catch (error) { - console.error('加载工作区对话异常:', error); - } finally { - group.loading = false; - } - }, - - async loadMoreWorkspaceConversations(workspaceId: string) { - const { useConversationStore } = await import('../../../stores/conversation'); - const conversationStore = useConversationStore(); - const group = conversationStore.workspaceGroups.find((g: any) => g.workspaceId === workspaceId); - if (!group || group.loadingMore || !group.hasMore) return; - group.loadingMore = true; - group.offset += group.limit; - await this.loadWorkspaceConversations(workspaceId); - group.loadingMore = false; } }; + +/* 工作区分组加载统一走 store 版(stores/conversation.ts 的 loadWorkspaceConversations/ + loadMoreWorkspaceConversations/loadWorkspaceConversationTypeCache),双类型缓存已在那里适配; + 此处不再保留重复实现 */ diff --git a/static/src/app/methods/message/send.ts b/static/src/app/methods/message/send.ts index a69b8d2d..bec586b5 100644 --- a/static/src/app/methods/message/send.ts +++ b/static/src/app/methods/message/send.ts @@ -302,7 +302,12 @@ export const sendMethods = { total_messages: 0, total_tools: 0 }; - this.conversations = [newPlaceholder, ...this.conversations.filter((conv) => conv && conv.id !== targetConversationId)]; + this.conversations.splice( + 0, + this.conversations.length, + newPlaceholder, + ...this.conversations.filter((conv) => conv && conv.id !== targetConversationId) + ); // 分组视图下同步到当前工作区 try { @@ -315,7 +320,12 @@ export const sendMethods = { (g: any) => g.workspaceId === currentWorkspaceId ); if (group) { - group.conversations = [newPlaceholder, ...group.conversations.filter((conv: any) => conv.id !== targetConversationId)]; + group.conversations.splice( + 0, + group.conversations.length, + newPlaceholder, + ...group.conversations.filter((conv: any) => conv.id !== targetConversationId) + ); group.expanded = true; group.visibleOffset = 0; group.visibleLimit = 5; diff --git a/static/src/app/methods/taskPolling/title.ts b/static/src/app/methods/taskPolling/title.ts index 8ff0ddb3..efb561f8 100644 --- a/static/src/app/methods/taskPolling/title.ts +++ b/static/src/app/methods/taskPolling/title.ts @@ -38,19 +38,15 @@ export const titleMethods = { } if (Array.isArray(this.conversations)) { - let listChanged = false; - const nextConversations = this.conversations.map((conv: any) => { - if (!conv || conv.id !== normalizedConversationId || conv.title === normalizedTitle) { - return conv; - } - listChanged = true; - return { - ...conv, + /* 原地替换保持数组引用:conversations 与双类型缓存中当前类型列表同一引用 */ + const convIndex = this.conversations.findIndex( + (conv: any) => conv && conv.id === normalizedConversationId && conv.title !== normalizedTitle + ); + if (convIndex >= 0) { + this.conversations.splice(convIndex, 1, { + ...this.conversations[convIndex], title: normalizedTitle - }; - }); - if (listChanged) { - this.conversations = nextConversations; + }); changed = true; } } diff --git a/static/src/app/methods/ui/hostWorkspace.ts b/static/src/app/methods/ui/hostWorkspace.ts index eef0c4ce..38dbc28d 100644 --- a/static/src/app/methods/ui/hostWorkspace.ts +++ b/static/src/app/methods/ui/hostWorkspace.ts @@ -78,7 +78,9 @@ export const hostWorkspaceMethods = { const previousSearchActive = this.searchActive; this.searchActive = false; this.searchResults = []; - this.conversations = []; + /* 主列表是当前工作区作用域:切换前使双类型缓存整体失效(conversations 重指空缓存) */ + const { useConversationStore } = await import('../../../stores/conversation'); + useConversationStore().resetConversationsTypeCache(); this.conversationsOffset = 0; this.hasMoreConversations = false; this.conversationsLoading = true; @@ -153,7 +155,12 @@ export const hostWorkspaceMethods = { this.refreshProjectGitSummary?.(); this.fetchTerminalCount(); } catch (error) { - this.conversations = previousConversationList; + /* 切换失败仍在旧工作区:恢复旧列表并同步回当前类型缓存,保持引用一致 */ + const conversationStore = useConversationStore(); + const restoredCache = conversationStore.conversationsCache[conversationStore.sidebarConversationType]; + restoredCache.list = previousConversationList; + restoredCache.loaded = true; + this.conversations = restoredCache.list; this.searchActive = previousSearchActive; this.conversationsLoading = false; const message = error instanceof Error ? error.message : String(error || '切换失败'); @@ -322,7 +329,9 @@ export const hostWorkspaceMethods = { this.currentConversationTitle = '新对话'; this.searchActive = false; this.searchResults = []; - this.conversations = []; + /* 当前工作区已删除:双类型缓存整体失效后重新加载 */ + const { useConversationStore } = await import('../../../stores/conversation'); + useConversationStore().resetConversationsTypeCache(); this.conversationsOffset = 0; this.hasMoreConversations = false; this.conversationsLoading = true; diff --git a/static/src/app/methods/versioning.ts b/static/src/app/methods/versioning.ts index 1122e22e..9e0bc1b8 100644 --- a/static/src/app/methods/versioning.ts +++ b/static/src/app/methods/versioning.ts @@ -221,10 +221,12 @@ export const versioningMethods = { await this.loadConversationsList(); // copy 模式下给侧边栏一个即时占位,随后列表刷新会补齐真实数据 if (restoreMode === 'copy' && !this.conversations.some((c) => c && c.id === targetConversationId)) { - this.conversations = [ + this.conversations.splice( + 0, + this.conversations.length, { id: targetConversationId, title: '版本回溯对话', updated_at: new Date().toISOString(), total_messages: 0, total_tools: 0 }, ...this.conversations.filter((c) => c && c.id !== targetConversationId) - ]; + ); } this.uiPushToast({ title: '版本管理', diff --git a/static/src/components/sidebar/ConversationSidebar.vue b/static/src/components/sidebar/ConversationSidebar.vue index 9dfc6dcc..17df2943 100644 --- a/static/src/components/sidebar/ConversationSidebar.vue +++ b/static/src/components/sidebar/ConversationSidebar.vue @@ -768,6 +768,9 @@ const slideTransitionName = computed(() => /** 列表容器:切换类型时重置滚动位置,保证推挤动画从顶部开始 */ const conversationListEl = ref(null); +/* 切换为纯本地操作:store 内两种类型的列表常驻缓存,setSidebarConversationType + 同步交换 conversations 引用与分页状态,无请求无加载态;pane key 与数据同 tick 变化, + 新面板初始挂载即新数据(transition-group 初始渲染不播动画),只保留整板左右平移 */ const setSidebarType = (type: 'normal' | 'multi_agent') => { if (conversationStore.sidebarConversationType === type) return; if (conversationListEl.value) conversationListEl.value.scrollTop = 0; diff --git a/static/src/stores/conversation.ts b/static/src/stores/conversation.ts index 6010033e..cc8dedb5 100644 --- a/static/src/stores/conversation.ts +++ b/static/src/stores/conversation.ts @@ -30,6 +30,7 @@ export interface ConversationSummary { export interface WorkspaceConversationGroup { workspaceId: string; + /** 当前过滤器类型对应的列表:与 conversationsByType[sidebarConversationType] 同一数组引用 */ conversations: ConversationSummary[]; loading: boolean; hasMore: boolean; @@ -40,8 +41,27 @@ export interface WorkspaceConversationGroup { bufferLimit: number; fetchLimit: number; expanded: boolean; + /** 双类型常驻缓存:切换过滤器时 conversations 只换引用,不重请求 */ + conversationsByType: { normal: ConversationSummary[]; multi_agent: ConversationSummary[] }; + /** 各类型分页状态(offset/hasMore)与是否已加载过,切换时与扁平字段交换 */ + pagingByType: { + normal: { offset: number; hasMore: boolean; loaded: boolean }; + multi_agent: { offset: number; hasMore: boolean; loaded: boolean }; + }; } +/** 新建空分组时的双类型缓存初始化 */ +export const createEmptyGroupTypeCache = () => ({ + conversationsByType: { normal: [], multi_agent: [] } as { + normal: ConversationSummary[]; + multi_agent: ConversationSummary[]; + }, + pagingByType: { + normal: { offset: 0, hasMore: false, loaded: false }, + multi_agent: { offset: 0, hasMore: false, loaded: false } + } +}); + /** 跨工作区搜索结果分组(后端 /api/conversations/search?all_workspaces=1 返回) */ export interface WorkspaceSearchGroup { workspace_id: string; @@ -78,6 +98,14 @@ interface ConversationState { multiAgentMode: boolean; /** 侧边栏对话类型过滤器:'normal' 普通对话 | 'multi_agent' 多智能体对话(localStorage 持久化) */ sidebarConversationType: 'normal' | 'multi_agent'; + /** 双类型列表缓存:conversations 始终是 conversationsCache[sidebarConversationType].list + 的同一数组引用——原地增删(push/splice/unshift)天然同步;重新赋值 conversations 后 + 必须同步刷新缓存引用。切换过滤器为纯本地引用交换,零请求零加载态。 + loaded 区分「未加载过」与「加载过但为空」。 */ + conversationsCache: { + normal: { list: ConversationSummary[]; offset: number; hasMore: boolean; loaded: boolean }; + multi_agent: { list: ConversationSummary[]; offset: number; hasMore: boolean; loaded: boolean }; + }; } export const useConversationStore = defineStore('conversation', { @@ -106,22 +134,50 @@ export const useConversationStore = defineStore('conversation', { acknowledgedCompletedTaskIds: [], workspaceGroups: [], multiAgentMode: false, - sidebarConversationType: loadSidebarConversationType() + sidebarConversationType: loadSidebarConversationType(), + conversationsCache: { + normal: { list: [], offset: 0, hasMore: false, loaded: false }, + multi_agent: { list: [], offset: 0, hasMore: false, loaded: false } + } }), actions: { /** 当前对话 id 同步(由 app watcher 在 this.currentConversationId 变化时写入) */ setCurrentConversationId(id: string | null) { this.currentConversationId = id; }, - /** 侧边栏对话类型过滤器切换(左右切换控件写入,驱动列表请求的服务端过滤) */ + /** + * 侧边栏对话类型过滤器切换(左右切换控件写入)。 + * 纯本地引用交换:当前分页状态存回缓存 → conversations 指向目标类型缓存数组 + * → 恢复目标分页状态;工作区分组同步换引用。零请求,配合 pane 平移动画即时呈现。 + */ setSidebarConversationType(type: 'normal' | 'multi_agent') { const normalized = type === 'multi_agent' ? 'multi_agent' : 'normal'; if (this.sidebarConversationType === normalized) return; + const oldType = this.sidebarConversationType; + const curCache = this.conversationsCache[oldType]; + curCache.offset = this.conversationsOffset; + curCache.hasMore = this.hasMoreConversations; this.sidebarConversationType = normalized; persistSidebarConversationType(normalized); + const nextCache = this.conversationsCache[normalized]; + this.conversations = nextCache.list; + this.conversationsOffset = nextCache.offset; + this.hasMoreConversations = nextCache.hasMore; + this.loadingMoreConversations = false; + for (const group of this.workspaceGroups) { + if (!group || !group.conversationsByType) continue; + const curPaging = group.pagingByType[oldType]; + curPaging.offset = group.offset; + curPaging.hasMore = group.hasMore; + group.conversations = group.conversationsByType[normalized]; + group.offset = group.pagingByType[normalized].offset; + group.hasMore = group.pagingByType[normalized].hasMore; + group.loadingMore = false; + } }, resetConversations() { - this.conversations = []; + /* 重置双类型缓存并对齐 conversations 引用(核心不变量) */ + this.resetConversationsCacheOnly(); this.searchResults = []; this.searchGroups = []; this.conversationInsertAnimations = {}; @@ -138,6 +194,19 @@ export const useConversationStore = defineStore('conversation', { this.runningWorkspaceTasks = []; this.acknowledgedCompletedTaskIds = []; }, + /** 切换/删除工作区时使双类型列表缓存整体失效:主列表是当前工作区作用域, + 不同工作区对话集合不同,缓存不可复用;conversations 重指到新空缓存 */ + resetConversationsTypeCache() { + this.resetConversationsCacheOnly(); + }, + /** 内部共用:仅重置双类型缓存并对齐 conversations 引用 */ + resetConversationsCacheOnly() { + this.conversationsCache = { + normal: { list: [], offset: 0, hasMore: false, loaded: false }, + multi_agent: { list: [], offset: 0, hasMore: false, loaded: false } + }; + this.conversations = this.conversationsCache[this.sidebarConversationType].list; + }, cancelSearchTimer() { if (this.searchTimer) { clearTimeout(this.searchTimer); @@ -156,7 +225,8 @@ export const useConversationStore = defineStore('conversation', { setWorkspaceGroupConversations(workspaceId: string, conversations: ConversationSummary[]) { const group = this.workspaceGroups.find((g) => g.workspaceId === workspaceId); if (group) { - group.conversations = conversations; + /* 原地替换保持数组引用:group.conversations 与 conversationsByType[当前类型] 是同一引用 */ + group.conversations.splice(0, group.conversations.length, ...conversations); } }, appendWorkspaceGroupConversations(workspaceId: string, conversations: ConversationSummary[]) { @@ -199,9 +269,11 @@ export const useConversationStore = defineStore('conversation', { if (!workspaceId) return; const exists = this.workspaceGroups.some((g) => g.workspaceId === workspaceId); if (!exists) { + const typeCache = createEmptyGroupTypeCache(); this.workspaceGroups.push({ workspaceId, - conversations: [], + conversations: typeCache.conversationsByType[this.sidebarConversationType], + ...typeCache, loading: false, hasMore: false, loadingMore: false, @@ -225,18 +297,27 @@ export const useConversationStore = defineStore('conversation', { index = this.workspaceGroups.length - 1; } const group = this.workspaceGroups[index]; + /* 锁定发起时类型:响应期间用户可能已切换过滤器,数据始终写入对应类型缓存 */ + const listType = this.sidebarConversationType; + const listCache = group.conversationsByType[listType]; + const paging = group.pagingByType[listType]; if (reset) { - group.conversations = []; - group.offset = 0; + listCache.length = 0; + paging.offset = 0; + paging.hasMore = false; + paging.loaded = false; group.visibleOffset = 0; - group.hasMore = false; + if (listType === this.sidebarConversationType) { + group.offset = 0; + group.hasMore = false; + } } if (group.loading || group.loadingMore) return; - const fetchOffset = refresh ? 0 : group.offset; + const fetchOffset = refresh ? 0 : paging.offset; group.loading = true; try { - // 列表过滤由侧边栏类型过滤器(普通/多智能体)决定 - const maParam = this.sidebarConversationType === 'multi_agent' ? '&multi_agent_mode=1' : '&multi_agent_mode=0'; + // 列表过滤由请求发起时的侧边栏类型过滤器(普通/多智能体)决定 + const maParam = listType === 'multi_agent' ? '&multi_agent_mode=1' : '&multi_agent_mode=0'; const response = await fetch( `/api/conversations?workspace_id=${encodeURIComponent(workspaceId)}&limit=${group.fetchLimit}&offset=${fetchOffset}${maParam}` ); @@ -249,17 +330,29 @@ export const useConversationStore = defineStore('conversation', { total_messages: conv.total_messages, total_tools: conv.total_tools })); + /* 原地写入缓存数组,保持 group.conversations 引用一致 */ if (refresh) { - const tail = group.conversations.slice(items.length); - group.conversations = [...items, ...tail]; + const tail = listCache.slice(items.length); + listCache.splice(0, listCache.length, ...items, ...tail); } else if (fetchOffset === 0) { - group.conversations = items; + listCache.splice(0, listCache.length, ...items); } else { - group.conversations.push(...items); + listCache.push(...items); } - group.hasMore = !!data.data?.has_more; + paging.hasMore = !!data.data?.has_more; + paging.loaded = true; if (!refresh) { - group.offset = fetchOffset + items.length; + paging.offset = fetchOffset + items.length; + } + /* 仍是当前显示类型时同步扁平字段(group.conversations 引用已是该缓存) */ + if (listType === this.sidebarConversationType) { + group.hasMore = paging.hasMore; + group.offset = paging.offset; + } + /* 首页加载成功后后台补载另一类型,保证切换过滤器时零等待 */ + const otherType = listType === 'multi_agent' ? 'normal' : 'multi_agent'; + if (fetchOffset === 0 && !group.pagingByType[otherType].loaded) { + this.loadWorkspaceConversationTypeCache(workspaceId, otherType).catch(() => {}); } } else { console.error('加载工作区对话失败:', data.error); @@ -270,6 +363,36 @@ export const useConversationStore = defineStore('conversation', { group.loading = false; } }, + /** 后台补载某工作区指定类型的首页缓存:已加载则跳过,不干扰当前显示 */ + async loadWorkspaceConversationTypeCache( + workspaceId: string, + type: 'normal' | 'multi_agent' + ) { + const group = this.workspaceGroups.find((g) => g.workspaceId === workspaceId); + if (!group || group.pagingByType[type].loaded) return; + const maParam = type === 'multi_agent' ? '&multi_agent_mode=1' : '&multi_agent_mode=0'; + try { + const response = await fetch( + `/api/conversations?workspace_id=${encodeURIComponent(workspaceId)}&limit=${group.fetchLimit}&offset=0${maParam}` + ); + const data = await response.json(); + /* 响应时重新校验:可能已被 reset/补载并发填充 */ + if (!data?.success || group.pagingByType[type].loaded) return; + const items = (data.data?.conversations || []).map((conv: any) => ({ + id: conv.id, + title: conv.title, + updated_at: conv.updated_at, + total_messages: conv.total_messages, + total_tools: conv.total_tools + })); + group.conversationsByType[type].push(...items); + group.pagingByType[type].offset = items.length; + group.pagingByType[type].hasMore = !!data.data?.has_more; + group.pagingByType[type].loaded = true; + } catch (error) { + console.error('补载工作区对话缓存异常:', error); + } + }, async loadMoreWorkspaceConversations(workspaceId: string) { const index = this.workspaceGroups.findIndex((g) => g.workspaceId === workspaceId); if (index === -1) return; @@ -281,9 +404,10 @@ export const useConversationStore = defineStore('conversation', { group.loadingMore = true; // 先尝试从后端再加载 20 条作为新的缓冲 if (group.hasMore) { + const listType = this.sidebarConversationType; try { const fetchOffset = group.conversations.length; - const maParam = this.sidebarConversationType === 'multi_agent' ? '&multi_agent_mode=1' : '&multi_agent_mode=0'; + const maParam = listType === 'multi_agent' ? '&multi_agent_mode=1' : '&multi_agent_mode=0'; const response = await fetch( `/api/conversations?workspace_id=${encodeURIComponent(workspaceId)}&limit=${group.bufferLimit}&offset=${fetchOffset}${maParam}` ); @@ -298,6 +422,12 @@ export const useConversationStore = defineStore('conversation', { })); group.conversations.push(...items); group.hasMore = !!data.data?.has_more; + /* 同步该类型分页缓存(group.conversations 与该缓存同一引用,原地 push 已同步) */ + const paging = group.pagingByType?.[listType]; + if (paging) { + paging.offset = group.conversations.length; + paging.hasMore = group.hasMore; + } } } catch (error) { console.error('加载更多工作区对话异常:', error); diff --git a/static/src/styles/components/sidebar/_conversation.scss b/static/src/styles/components/sidebar/_conversation.scss index 90143b8f..297d06b0 100644 --- a/static/src/styles/components/sidebar/_conversation.scss +++ b/static/src/styles/components/sidebar/_conversation.scss @@ -1164,6 +1164,14 @@ body[data-theme='dark'] .conversation-sidebar .load-more-btn:hover:not(:disabled font-weight: 600; } +/* 深色模式:surface-soft 是近黑 #0f0f0f,比轨道(#141414)更暗, + 选中态变成「更黑的坑」,层级方向反了且违反深色禁近黑规则; + 改为白色微量 tint 提亮一档(≈#3a3a3a,与深色可见灰阶先例一致) */ +:root[data-theme='dark'] .conversation-type-option.active, +body[data-theme='dark'] .conversation-type-option.active { + background: color-mix(in srgb, var(--text-primary) 16%, transparent); +} + /* 列表区域整体左右滑动切换动画(推挤式:新面板从侧边滑入把旧面板顶出去, 无 out-in 空白期;两面板同速同幅滑动,边缘始终相接) */ .conversation-list-pane {