From f25523e441db786725a5b21a7a5a69da9326014a Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Thu, 23 Jul 2026 19:26:55 +0800 Subject: [PATCH] =?UTF-8?q?fix(sidebar):=20=E4=BE=A7=E8=BE=B9=E6=A0=8F?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F=E4=B8=8E=E5=8A=A8=E7=94=BB=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=EF=BC=8C=E5=AF=B9=E8=AF=9D=E6=90=9C=E7=B4=A2=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E4=B8=BA=E5=90=8E=E7=AB=AF=E8=B7=A8=E5=B7=A5=E4=BD=9C=E5=8C=BA?= =?UTF-8?q?=E8=81=9A=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 对话条目:修复「…」按钮因 action-wrap 宽度未随 32px 列同步而溢出条目圆角外(并移除无效的分组列宽覆盖) - 工作区入口按钮:移除右侧当前工作区名称 - 切换工作区:重排期间禁用子对话 FLIP 位移与展开折叠过渡,全部瞬间移动(不影响新建/复制/删除动画) - 删除动画:before-leave 用 offsetTop 锁定离场元素原位,修复 flex 容器 absolute 静态位置跳到顶部导致非首条删除无左滑 - 个人空间:修复「按项目分组对话」勾选框 SVG path 多重复一段导致描边错位 - 搜索:改为后端 /api/conversations/search(标题+首条用户消息首句匹配,带实例级缓存),分组模式 all_workspaces=1 跨工作区搜索并按工作区分类展示;移除前端 N+1 预览请求与 project_path 噪音匹配 --- core/web_terminal.py | 4 +- server/conversation.py | 84 +++++++++- static/src/app/computed.ts | 1 + static/src/app/methods/conversation/action.ts | 7 + static/src/app/methods/search.ts | 141 ++++------------ static/src/app/state.ts | 2 - .../personalization/PersonalizationDrawer.vue | 2 +- .../sidebar/ConversationSidebar.vue | 158 +++++++++++++++++- static/src/stores/conversation.ts | 11 ++ .../components/sidebar/_conversation.scss | 47 +++--- utils/context_manager/conversation_mixin.py | 4 +- .../conversation_manager/list_search_mixin.py | 133 +++++++++++---- 12 files changed, 404 insertions(+), 190 deletions(-) diff --git a/core/web_terminal.py b/core/web_terminal.py index 1453ec47..769e5402 100644 --- a/core/web_terminal.py +++ b/core/web_terminal.py @@ -506,10 +506,10 @@ class WebTerminal(MainTerminal): "message": f"删除对话异常: {e}" } - def search_conversations(self, query: str, limit: int = 20) -> Dict: + def search_conversations(self, query: str, limit: int = 20, multi_agent_mode: Optional[bool] = None) -> Dict: """搜索对话(Web版本)""" try: - results = self.context_manager.search_conversations(query, limit) + results = self.context_manager.search_conversations(query, limit, multi_agent_mode=multi_agent_mode) return { "success": True, "results": results, diff --git a/server/conversation.py b/server/conversation.py index d89a7fa5..c584d5a4 100644 --- a/server/conversation.py +++ b/server/conversation.py @@ -968,29 +968,95 @@ def delete_conversation(conversation_id, terminal: WebTerminal, workspace: UserW @api_login_required @with_terminal def search_conversations(terminal: WebTerminal, workspace: UserWorkspace, username: str): - """搜索对话""" + """搜索对话。 + + 默认仅搜索当前工作区,返回扁平结果; + all_workspaces=1 时跨当前用户的全部工作区搜索,按工作区分组返回。 + multi_agent_mode: '1' 仅多智能体对话;'0' 仅常规对话;未传不过滤。 + """ try: query = request.args.get('q', '').strip() limit = request.args.get('limit', 20, type=int) - + search_all = request.args.get('all_workspaces', '0') in ('1', 'true', 'True') + ma_param = request.args.get('multi_agent_mode', None) + if ma_param in ('1', 'true', 'True'): + multi_agent_filter: Optional[bool] = True + elif ma_param in ('0', 'false', 'False'): + multi_agent_filter = False + else: + multi_agent_filter = None + if not query: return jsonify({ "success": False, "error": "Missing query parameter", "message": "请提供搜索关键词" }), 400 - + # 限制参数范围 limit = max(1, min(limit, 50)) - - result = terminal.search_conversations(query, limit) - + + if not search_all: + result = terminal.search_conversations(query, limit, multi_agent_mode=multi_agent_filter) + return jsonify({ + "success": True, + "data": { + "results": result["results"], + "count": result["count"], + "query": query + } + }) + + # 跨工作区搜索:收集全部工作区后逐区搜索,按工作区分组返回 + if _is_host_mode_request(username): + catalog, _current_host_ws = resolve_host_workspace() + workspace_entries = [ + (str(ws.get("workspace_id") or ""), str(ws.get("label") or ws.get("workspace_id") or "")) + for ws in (catalog.get("workspaces") or []) + ] + else: + user_workspaces = user_manager.list_user_workspaces(username) or {} + workspace_entries = [ + (str(ws_id), str((info or {}).get("label") or ws_id)) + for ws_id, info in user_workspaces.items() + ] + + groups = [] + total = 0 + seen_ids = set() + for ws_id, label in workspace_entries: + if not ws_id or ws_id in seen_ids: + continue + seen_ids.add(ws_id) + try: + target_terminal, _target_workspace = _resolve_target_terminal_for_workspace( + username, ws_id, terminal, workspace + ) + except (ValueError, RuntimeError) as resolve_err: + print(f"[API] 搜索跳过工作区 {ws_id}: {resolve_err}") + continue + try: + result = target_terminal.search_conversations(query, limit, multi_agent_mode=multi_agent_filter) + except Exception as search_err: + print(f"[API] 搜索工作区对话失败 {ws_id}: {search_err}") + continue + results = result.get("results") or [] + if not results: + continue + groups.append({ + "workspace_id": ws_id, + "label": label or ws_id, + "count": len(results), + "results": results, + }) + total += len(results) + return jsonify({ "success": True, "data": { - "results": result["results"], - "count": result["count"], - "query": query + "query": query, + "groups": groups, + "total": total } }) diff --git a/static/src/app/computed.ts b/static/src/app/computed.ts index a501891f..d612253a 100644 --- a/static/src/app/computed.ts +++ b/static/src/app/computed.ts @@ -92,6 +92,7 @@ export const computed = { 'searchQuery', 'searchTimer', 'searchResults', + 'searchGroups', 'conversationInsertAnimations', 'conversationListAnimationMode', 'pendingDeletingConversationIds', diff --git a/static/src/app/methods/conversation/action.ts b/static/src/app/methods/conversation/action.ts index 2e2e04b2..b64f225b 100644 --- a/static/src/app/methods/conversation/action.ts +++ b/static/src/app/methods/conversation/action.ts @@ -412,6 +412,13 @@ export const actionMethods = { this.searchResults = this.searchResults.filter( (conversation) => conversation.id !== conversationId ); + // 跨工作区搜索结果分组:同步剔除被删对话,空组整体移除 + this.searchGroups = (Array.isArray(this.searchGroups) ? this.searchGroups : []) + .map((group: any) => ({ + ...group, + results: (group.results || []).filter((conv: any) => conv.id !== conversationId) + })) + .filter((group: any) => group.results.length > 0); this.pendingDeletingConversationIds = (Array.isArray(this.pendingDeletingConversationIds) ? this.pendingDeletingConversationIds : [] diff --git a/static/src/app/methods/search.ts b/static/src/app/methods/search.ts index 75193c0e..792fafc7 100644 --- a/static/src/app/methods/search.ts +++ b/static/src/app/methods/search.ts @@ -1,7 +1,8 @@ // @ts-nocheck -const SEARCH_INITIAL_BATCH = 5000; -const SEARCH_MORE_BATCH = 200; -const SEARCH_PREVIEW_LIMIT = 20; +import { usePersonalizationStore } from '../../stores/personalization'; + +const SEARCH_FLAT_LIMIT = 50; +const SEARCH_GROUP_LIMIT = 20; export const searchMethods = { handleSidebarSearchInput(value) { @@ -25,12 +26,21 @@ export const searchMethods = { this.searchOffset = 0; this.searchTotal = 0; this.searchResults = []; + this.searchGroups = []; this.searchActiveQuery = ''; - this.searchResultIdSet = new Set(); this.conversationsOffset = 0; this.loadConversationsList(); }, + /** 是否处于「按工作区分组」的侧边栏模式(决定搜索是否跨工作区) */ + isGroupedSidebarSearch() { + const personalizationStore = usePersonalizationStore(); + return ( + !!personalizationStore?.form?.group_sidebar_by_workspace && + !!(this.versioningHostMode || this.dockerProjectMode) + ); + }, + async startConversationSearch(query) { const trimmed = String(query || '').trim(); if (!trimmed) { @@ -40,62 +50,36 @@ export const searchMethods = { this.searchActiveQuery = trimmed; this.searchActive = true; this.searchInProgress = true; + // 后端一次性返回全部匹配(上限 limit),不再需要分页加载更多 this.searchMoreAvailable = false; this.searchOffset = 0; this.searchTotal = 0; this.searchResults = []; - this.searchResultIdSet = new Set(); - await this.searchNextConversationBatch(SEARCH_INITIAL_BATCH, requestSeq); - }, + this.searchGroups = []; - async loadMoreSearchResults() { - if (!this.searchActive || this.searchInProgress || !this.searchMoreAvailable) { - return; - } - const requestSeq = this.searchRequestSeq; - this.searchInProgress = true; - await this.searchNextConversationBatch(SEARCH_MORE_BATCH, requestSeq); - }, + const maParam = this.multiAgentMode ? '&multi_agent_mode=1' : '&multi_agent_mode=0'; + const grouped = this.isGroupedSidebarSearch(); + const url = grouped + ? `/api/conversations/search?q=${encodeURIComponent(trimmed)}&limit=${SEARCH_GROUP_LIMIT}&all_workspaces=1${maParam}` + : `/api/conversations/search?q=${encodeURIComponent(trimmed)}&limit=${SEARCH_FLAT_LIMIT}${maParam}`; - async searchNextConversationBatch(batchSize, requestSeq) { - const query = this.searchActiveQuery; - if (!query) { - if (requestSeq === this.searchRequestSeq) { - this.searchInProgress = false; - } - return; - } try { - const response = await fetch( - `/api/conversations?limit=${batchSize}&offset=${this.searchOffset}` - ); + const response = await fetch(url); const payload = await response.json(); - if (requestSeq !== this.searchRequestSeq) { return; } - if (!payload.success) { console.error('搜索对话失败:', payload.error || payload.message); - this.searchInProgress = false; return; } - const data = payload.data || {}; - const conversations = data.conversations || []; - if (!this.searchTotal) { - this.searchTotal = data.total || 0; + if (grouped) { + this.searchGroups = Array.isArray(data.groups) ? data.groups : []; + } else { + this.searchResults = Array.isArray(data.results) ? data.results : []; + this.searchTotal = data.count || this.searchResults.length; } - - for (const conv of conversations) { - if (requestSeq !== this.searchRequestSeq) { - return; - } - await this.matchConversation(conv, query, requestSeq); - } - - this.searchOffset += conversations.length; - this.searchMoreAvailable = this.searchOffset < (this.searchTotal || 0); } catch (error) { console.error('搜索对话异常:', error); } finally { @@ -105,73 +89,8 @@ export const searchMethods = { } }, - async matchConversation(conversation, query, requestSeq) { - if (!conversation || !conversation.id) { - return; - } - if (this.searchResultIdSet && this.searchResultIdSet.has(conversation.id)) { - return; - } - const firstSentence = await this.getConversationFirstUserSentence(conversation.id, requestSeq); - if (requestSeq !== this.searchRequestSeq) { - return; - } - const queryLower = String(query || '').toLowerCase(); - const combined = `${conversation.title || ''} ${firstSentence || ''}`.toLowerCase(); - if (queryLower && combined.includes(queryLower)) { - this.searchResults.push(conversation); - this.searchResultIdSet.add(conversation.id); - } - }, - - async getConversationFirstUserSentence(conversationId, requestSeq) { - if (!conversationId) { - return ''; - } - if ( - this.searchPreviewCache && - Object.prototype.hasOwnProperty.call(this.searchPreviewCache, conversationId) - ) { - return this.searchPreviewCache[conversationId]; - } - try { - const resp = await fetch( - `/api/conversations/${conversationId}/review_preview?limit=${SEARCH_PREVIEW_LIMIT}` - ); - const payload = await resp.json(); - if (requestSeq !== this.searchRequestSeq) { - return ''; - } - const lines = payload?.data?.preview || []; - let firstUserLine = ''; - for (const line of lines) { - if (typeof line === 'string' && line.startsWith('user:')) { - firstUserLine = line.slice('user:'.length).trim(); - break; - } - } - const firstSentence = this.extractFirstSentence(firstUserLine); - const cached = firstSentence || firstUserLine || ''; - if (!this.searchPreviewCache) { - this.searchPreviewCache = {}; - } - this.searchPreviewCache[conversationId] = cached; - return cached; - } catch (error) { - console.error('获取对话预览失败:', error); - return ''; - } - }, - - extractFirstSentence(text) { - if (!text) { - return ''; - } - const normalized = String(text).replace(/\s+/g, ' ').trim(); - const match = normalized.match(/(.+?[。!?.!?])/); - if (match) { - return match[1]; - } - return normalized; + /** 后端搜索一次性返回结果,保留此方法仅为兼容模板绑定 */ + async loadMoreSearchResults() { + // no-op:搜索结果不再分页 } }; diff --git a/static/src/app/state.ts b/static/src/app/state.ts index 94626573..2e6bdbb8 100644 --- a/static/src/app/state.ts +++ b/static/src/app/state.ts @@ -99,8 +99,6 @@ export function dataState() { // ========================================== searchRequestSeq: 0, searchActiveQuery: '', - searchResultIdSet: new Set(), - searchPreviewCache: {}, // Token统计相关状态(修复版) // ========================================== diff --git a/static/src/components/personalization/PersonalizationDrawer.vue b/static/src/components/personalization/PersonalizationDrawer.vue index 4ceb4048..7092c93a 100644 --- a/static/src/components/personalization/PersonalizationDrawer.vue +++ b/static/src/components/personalization/PersonalizationDrawer.vue @@ -635,7 +635,7 @@ " /> {{ workspaceKind === 'project' ? '项目' : '工作区' }} - {{ currentWorkspaceLabel }} @@ -140,7 +139,7 @@ + +