fix(sidebar): 侧边栏样式与动画修复,对话搜索重构为后端跨工作区聚合
- 对话条目:修复「…」按钮因 action-wrap 宽度未随 32px 列同步而溢出条目圆角外(并移除无效的分组列宽覆盖) - 工作区入口按钮:移除右侧当前工作区名称 - 切换工作区:重排期间禁用子对话 FLIP 位移与展开折叠过渡,全部瞬间移动(不影响新建/复制/删除动画) - 删除动画:before-leave 用 offsetTop 锁定离场元素原位,修复 flex 容器 absolute 静态位置跳到顶部导致非首条删除无左滑 - 个人空间:修复「按项目分组对话」勾选框 SVG path 多重复一段导致描边错位 - 搜索:改为后端 /api/conversations/search(标题+首条用户消息首句匹配,带实例级缓存),分组模式 all_workspaces=1 跨工作区搜索并按工作区分类展示;移除前端 N+1 预览请求与 project_path 噪音匹配
This commit is contained in:
parent
b593d63856
commit
f25523e441
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -92,6 +92,7 @@ export const computed = {
|
||||
'searchQuery',
|
||||
'searchTimer',
|
||||
'searchResults',
|
||||
'searchGroups',
|
||||
'conversationInsertAnimations',
|
||||
'conversationListAnimationMode',
|
||||
'pendingDeletingConversationIds',
|
||||
|
||||
@ -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
|
||||
: []
|
||||
|
||||
@ -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:搜索结果不再分页
|
||||
}
|
||||
};
|
||||
|
||||
@ -99,8 +99,6 @@ export function dataState() {
|
||||
// ==========================================
|
||||
searchRequestSeq: 0,
|
||||
searchActiveQuery: '',
|
||||
searchResultIdSet: new Set(),
|
||||
searchPreviewCache: {},
|
||||
|
||||
// Token统计相关状态(修复版)
|
||||
// ==========================================
|
||||
|
||||
@ -635,7 +635,7 @@
|
||||
" /><span class="fancy-check" aria-hidden="true"
|
||||
><svg viewBox="0 0 64 64">
|
||||
<path
|
||||
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
|
||||
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
|
||||
pathLength="575.0541381835938"
|
||||
class="fancy-path"
|
||||
></path></svg></span
|
||||
|
||||
@ -93,7 +93,6 @@
|
||||
</svg>
|
||||
</span>
|
||||
<span class="sidebar-nav-label">{{ workspaceKind === 'project' ? '项目' : '工作区' }}</span>
|
||||
<span class="workspace-entry-current">{{ currentWorkspaceLabel }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -140,7 +139,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<template v-if="isGroupByWorkspaceActive && !searchActive">
|
||||
<div class="workspace-groups">
|
||||
<div class="workspace-groups" :class="{ 'switch-instant': workspaceSwitchInstant }">
|
||||
<div
|
||||
v-for="ws in sortedWorkspaces"
|
||||
:key="String(ws.workspace_id || ws.label)"
|
||||
@ -245,6 +244,7 @@
|
||||
tag="div"
|
||||
class="workspace-conversations-list"
|
||||
:class="`animation-mode-${conversationListAnimationMode}`"
|
||||
@before-leave="lockLeaveItemPosition"
|
||||
>
|
||||
<div
|
||||
v-for="conv in getWorkspaceVisibleAndBufferConversations(String(ws.workspace_id || ''))"
|
||||
@ -344,6 +344,109 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="isGroupByWorkspaceActive && searchActive">
|
||||
<div class="workspace-groups search-result-groups">
|
||||
<div v-if="!searchInProgress && !sortedSearchGroups.length" class="no-conversations">
|
||||
未找到匹配对话
|
||||
</div>
|
||||
<div
|
||||
v-for="group in sortedSearchGroups"
|
||||
:key="String(group.workspace_id || '')"
|
||||
class="workspace-group"
|
||||
>
|
||||
<div class="workspace-group-header search-result-group-header">
|
||||
<div class="workspace-group-toggle search-result-group-toggle">
|
||||
<span
|
||||
class="icon icon-sm workspace-folder-icon"
|
||||
:style="iconStyle('folderClosed')"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="workspace-group-label">{{ group.label || group.workspace_id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="workspace-group-children expanded">
|
||||
<div class="workspace-group-children-inner">
|
||||
<div class="workspace-conversations-list">
|
||||
<div
|
||||
v-for="conv in group.results"
|
||||
:key="conv.id"
|
||||
class="conversation-item workspace-conversation-item"
|
||||
:class="{
|
||||
active: conv.id === currentConversationId,
|
||||
running: isConversationActive(conv.id) || isConversationCompleted(conv.id)
|
||||
}"
|
||||
@click="handleWorkspaceConversationClick(conv.id, String(group.workspace_id || ''))"
|
||||
>
|
||||
<div class="conversation-title">{{ conv.title }}</div>
|
||||
<div
|
||||
class="conversation-action-wrap"
|
||||
:class="{ 'menu-open': openActionMenuId === conv.id }"
|
||||
@click.stop
|
||||
>
|
||||
<span
|
||||
v-if="isConversationActive(conv.id)"
|
||||
class="conversation-running-loader"
|
||||
aria-label="运行中"
|
||||
title="运行中"
|
||||
></span>
|
||||
<span
|
||||
v-else-if="isConversationCompleted(conv.id)"
|
||||
class="conversation-complete-check"
|
||||
aria-label="已完成"
|
||||
title="已完成"
|
||||
></span>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="conversation-more-btn"
|
||||
title="更多操作"
|
||||
aria-label="更多操作"
|
||||
:data-action-trigger="conv.id"
|
||||
:aria-expanded="openActionMenuId === conv.id"
|
||||
@click="toggleActionMenu(conv.id)"
|
||||
>
|
||||
<span aria-hidden="true"></span>
|
||||
</button>
|
||||
<div
|
||||
v-if="shouldShowActionMenu(conv.id)"
|
||||
class="conversation-actions-menu conversation-actions-menu--fixed"
|
||||
:class="{ open: openActionMenuId === conv.id }"
|
||||
:style="{
|
||||
top: actionMenuPosition.top + 'px',
|
||||
right: actionMenuPosition.right + 'px'
|
||||
}"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@click="
|
||||
openActionMenuId = null;
|
||||
$emit('duplicate', conv.id);
|
||||
"
|
||||
>
|
||||
<span class="icon icon-sm" :style="iconStyle('copy')" aria-hidden="true"></span>
|
||||
<span>复制</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="danger"
|
||||
@click="
|
||||
openActionMenuId = null;
|
||||
$emit('delete', { id: conv.id, workspaceId: String(group.workspace_id || '') });
|
||||
"
|
||||
>
|
||||
<span class="icon icon-sm" :style="iconStyle('trash')" aria-hidden="true"></span>
|
||||
<span>删除</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="loading && !displayConversations.length && !searchActive && !isDeletingConversation"
|
||||
@ -628,6 +731,7 @@ const { sidebarCollapsed: collapsed } = storeToRefs(uiStore);
|
||||
const {
|
||||
searchQuery,
|
||||
searchResults,
|
||||
searchGroups,
|
||||
searchActive,
|
||||
searchInProgress,
|
||||
searchMoreAvailable,
|
||||
@ -649,12 +753,6 @@ const workspaceEntryBtn = ref<HTMLElement | null>(null);
|
||||
const workspaceSwitcherOpen = ref(false);
|
||||
const workspaceAnchorRect = ref<{ left: number; top: number; right: number; bottom: number } | null>(null);
|
||||
|
||||
const currentWorkspaceLabel = computed(() => {
|
||||
const list = Array.isArray(props.workspaces) ? props.workspaces : [];
|
||||
const current = list.find((item: any) => String(item?.workspace_id || '') === props.currentWorkspaceId);
|
||||
return current?.label || '';
|
||||
});
|
||||
|
||||
const toggleWorkspaceSwitcher = () => {
|
||||
if (workspaceSwitcherOpen.value) {
|
||||
workspaceSwitcherOpen.value = false;
|
||||
@ -679,6 +777,9 @@ const toggleWorkspaceSwitcher = () => {
|
||||
};
|
||||
const openActionMenuId = ref<string | null>(null);
|
||||
const actionMenuPosition = ref<{ top: number; right: number }>({ top: 0, right: 0 });
|
||||
/* 切换工作区重排期间为 true:禁用子对话 FLIP 位移与分组展开折叠过渡,
|
||||
使分组头与子对话全部瞬间移动(不影响新建/复制/删除动画) */
|
||||
const workspaceSwitchInstant = ref(false);
|
||||
const hoverWorkspaceId = ref<string | null>(null);
|
||||
const openWorkspaceMenuId = ref<string | null>(null);
|
||||
const renameWorkspaceId = ref<string | null>(null);
|
||||
@ -728,6 +829,15 @@ const shouldShowActionMenu = (conversationId: string) => {
|
||||
);
|
||||
};
|
||||
|
||||
/* 删除离场动画定位修复:分组列表是 flex 容器,按 CSS Flexbox 规范,
|
||||
绝对定位子元素的静态位置会跑到容器顶部(而不是元素原来的行位置),
|
||||
导致删除非首条对话时离场元素瞬移到顶部。before-leave 时元素仍在文档流中,
|
||||
用 offsetTop 锁定原位,leave-active 的 position:absolute 生效后即停在原行。 */
|
||||
const lockLeaveItemPosition = (el: Element) => {
|
||||
const item = el as HTMLElement;
|
||||
item.style.top = `${item.offsetTop}px`;
|
||||
};
|
||||
|
||||
const closeActionMenu = () => {
|
||||
openActionMenuId.value = null;
|
||||
};
|
||||
@ -886,7 +996,19 @@ watch(
|
||||
|
||||
watch(
|
||||
() => props.currentWorkspaceId,
|
||||
() => bumpCurrentWorkspaceToTop(),
|
||||
async () => {
|
||||
/* 切换工作区会触发 bumpCurrentWorkspaceToTop 重排分组:分组头是瞬时
|
||||
移动的,而 transition-group 会对子对话做 FLIP 位移动画,节奏不一致
|
||||
很诡异。重排期间挂上 switch-instant 让两者都瞬间完成。 */
|
||||
if (isGroupByWorkspaceActive.value) {
|
||||
workspaceSwitchInstant.value = true;
|
||||
bumpCurrentWorkspaceToTop();
|
||||
await nextTick();
|
||||
workspaceSwitchInstant.value = false;
|
||||
} else {
|
||||
bumpCurrentWorkspaceToTop();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
@ -913,6 +1035,24 @@ const sortedWorkspaces = computed(() => {
|
||||
const displayConversations = computed(() =>
|
||||
searchActive.value ? searchResults.value : conversations.value
|
||||
);
|
||||
|
||||
/* 跨工作区搜索结果:按与工作区分组列表一致的顺序排列(置顶 > 自定义顺序 > 名称) */
|
||||
const sortedSearchGroups = computed(() => {
|
||||
const groups = Array.isArray(searchGroups.value) ? [...searchGroups.value] : [];
|
||||
return groups.sort((a: any, b: any) => {
|
||||
const aId = String(a?.workspace_id || '');
|
||||
const bId = String(b?.workspace_id || '');
|
||||
const aPinned = pinnedWorkspaceIds.value.has(aId) ? 1 : 0;
|
||||
const bPinned = pinnedWorkspaceIds.value.has(bId) ? 1 : 0;
|
||||
if (aPinned !== bPinned) return bPinned - aPinned;
|
||||
const aIndex = workspaceOrder.value.indexOf(aId);
|
||||
const bIndex = workspaceOrder.value.indexOf(bId);
|
||||
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
|
||||
if (aIndex !== -1) return -1;
|
||||
if (bIndex !== -1) return 1;
|
||||
return String(a?.label || aId).localeCompare(String(b?.label || bId));
|
||||
});
|
||||
});
|
||||
const loading = computed(() =>
|
||||
searchActive.value ? searchInProgress.value : conversationsLoading.value
|
||||
);
|
||||
|
||||
@ -22,9 +22,18 @@ export interface WorkspaceConversationGroup {
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
/** 跨工作区搜索结果分组(后端 /api/conversations/search?all_workspaces=1 返回) */
|
||||
export interface WorkspaceSearchGroup {
|
||||
workspace_id: string;
|
||||
label: string;
|
||||
count: number;
|
||||
results: ConversationSummary[];
|
||||
}
|
||||
|
||||
interface ConversationState {
|
||||
conversations: ConversationSummary[];
|
||||
searchResults: ConversationSummary[];
|
||||
searchGroups: WorkspaceSearchGroup[];
|
||||
conversationInsertAnimations: Record<string, 'create' | 'duplicate' | 'duplicateSource'>;
|
||||
conversationListAnimationMode: 'idle' | 'create' | 'duplicate' | 'delete';
|
||||
pendingDeletingConversationIds: string[];
|
||||
@ -52,6 +61,7 @@ export const useConversationStore = defineStore('conversation', {
|
||||
state: (): ConversationState => ({
|
||||
conversations: [],
|
||||
searchResults: [],
|
||||
searchGroups: [],
|
||||
conversationInsertAnimations: {},
|
||||
conversationListAnimationMode: 'idle',
|
||||
pendingDeletingConversationIds: [],
|
||||
@ -82,6 +92,7 @@ export const useConversationStore = defineStore('conversation', {
|
||||
resetConversations() {
|
||||
this.conversations = [];
|
||||
this.searchResults = [];
|
||||
this.searchGroups = [];
|
||||
this.conversationInsertAnimations = {};
|
||||
this.conversationListAnimationMode = 'idle';
|
||||
this.pendingDeletingConversationIds = [];
|
||||
|
||||
@ -117,7 +117,6 @@
|
||||
.sidebar-nav-label,
|
||||
.conversation-search,
|
||||
.conversation-list,
|
||||
.workspace-entry-current,
|
||||
.personal-label {
|
||||
opacity: 1;
|
||||
transition: opacity 220ms ease;
|
||||
@ -127,7 +126,6 @@
|
||||
.conversation-sidebar.collapsed .sidebar-nav-label,
|
||||
.conversation-sidebar.collapsed .conversation-search,
|
||||
.conversation-sidebar.collapsed .conversation-list,
|
||||
.conversation-sidebar.collapsed .workspace-entry-current,
|
||||
.conversation-sidebar.collapsed .personal-label {
|
||||
opacity: 0;
|
||||
transition-delay: 0ms;
|
||||
@ -458,7 +456,9 @@ body[data-theme='dark'] .conversation-sidebar .conversation-item.active:focus-wi
|
||||
|
||||
.conversation-sidebar .conversation-action-wrap {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
/* 宽度必须与条目第二列(32px)一致:40px 会向右溢出 8px,
|
||||
导致「…」按钮探出条目圆角背景外 */
|
||||
width: 32px;
|
||||
height: var(--conversation-row-height);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@ -608,20 +608,9 @@ body[data-theme='dark'] .conversation-sidebar .conversation-item.active:focus-wi
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* 工作区入口行:图标 + 名称 + 当前工作区名(第三列) */
|
||||
/* 工作区入口行:图标 + 名称(当前工作区名不再展示) */
|
||||
.sidebar-nav-row.workspace-entry-btn {
|
||||
grid-template-columns: var(--conversation-icon-cell) minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.workspace-entry-current {
|
||||
min-width: 0;
|
||||
max-width: 96px;
|
||||
padding-right: 10px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
color: var(--claude-text-tertiary);
|
||||
grid-template-columns: var(--conversation-icon-cell) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.personal-page-btn svg {
|
||||
@ -764,6 +753,17 @@ body[data-theme='dark'] .workspace-group-header:hover {
|
||||
color: var(--claude-text-secondary);
|
||||
}
|
||||
|
||||
/* 搜索结果分组头:仅作分类标识,不可交互、无 hover 高亮 */
|
||||
.workspace-group-header.search-result-group-header:hover,
|
||||
:root[data-theme='dark'] .workspace-group-header.search-result-group-header:hover,
|
||||
body[data-theme='dark'] .workspace-group-header.search-result-group-header:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.workspace-group-toggle.search-result-group-toggle {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.workspace-group-label {
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
@ -902,6 +902,16 @@ body[data-theme='dark'] .workspace-group-header:hover {
|
||||
transition: grid-template-rows 260ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
/* 切换工作区引发的重排/展开折叠:全部瞬间完成,不做滑动。
|
||||
注意只禁用位移类过渡(move + grid-rows),保留新建/复制等 enter 动画。 */
|
||||
.workspace-groups.switch-instant .workspace-group-children {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.workspace-groups.switch-instant .conversation-delete-move {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.workspace-group-children.expanded {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
@ -924,9 +934,8 @@ body[data-theme='dark'] .workspace-group-header:hover {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.workspace-conversation-item {
|
||||
grid-template-columns: minmax(0, 1fr) 40px;
|
||||
}
|
||||
/* 注意:不要在这里用 .workspace-conversation-item 覆盖 grid-template-columns,
|
||||
特异性低于 .conversation-sidebar .conversation-item,写了也不会生效。 */
|
||||
|
||||
.workspace-no-conversations,
|
||||
.workspace-conversations-loading {
|
||||
|
||||
@ -459,9 +459,9 @@ class ConversationMixin:
|
||||
pass
|
||||
return False
|
||||
|
||||
def search_conversations(self, query: str, limit: int = 20) -> List[Dict]:
|
||||
def search_conversations(self, query: str, limit: int = 20, multi_agent_mode: Optional[bool] = None) -> List[Dict]:
|
||||
"""搜索对话"""
|
||||
return self.conversation_manager.search_conversations(query, limit)
|
||||
return self.conversation_manager.search_conversations(query, limit, multi_agent_mode=multi_agent_mode)
|
||||
|
||||
def get_conversation_statistics(self) -> Dict:
|
||||
"""获取对话统计"""
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import tempfile
|
||||
import threading
|
||||
@ -314,59 +315,121 @@ class ListSearchMixin:
|
||||
print(f"⌘ 归档对话失败 {conversation_id}: {e}")
|
||||
return False
|
||||
|
||||
def search_conversations(self, query: str, limit: int = 20) -> List[Dict]:
|
||||
def search_conversations(self, query: str, limit: int = 20, multi_agent_mode: Optional[bool] = None) -> List[Dict]:
|
||||
"""
|
||||
搜索对话
|
||||
|
||||
搜索对话(标题 / 首条用户消息首句,与原前端客户端搜索口径一致)
|
||||
|
||||
Args:
|
||||
query: 搜索关键词
|
||||
limit: 限制数量
|
||||
|
||||
multi_agent_mode: True 仅多智能体对话;False 仅常规对话;None 不过滤
|
||||
|
||||
Returns:
|
||||
List[Dict]: 匹配的对话列表
|
||||
"""
|
||||
try:
|
||||
index = self._load_index()
|
||||
results = []
|
||||
|
||||
query_lower = query.lower()
|
||||
|
||||
|
||||
query_lower = (query or "").lower()
|
||||
if not query_lower:
|
||||
return []
|
||||
|
||||
for conv_id, metadata in index.items():
|
||||
# 搜索标题
|
||||
title = metadata.get("title", "").lower()
|
||||
if multi_agent_mode is not None:
|
||||
conv_ma = bool(metadata.get("multi_agent_mode", False))
|
||||
if conv_ma != multi_agent_mode:
|
||||
continue
|
||||
|
||||
entry = {
|
||||
"id": conv_id,
|
||||
"title": metadata.get("title"),
|
||||
"created_at": metadata.get("created_at"),
|
||||
"updated_at": metadata.get("updated_at"),
|
||||
"project_path": metadata.get("project_path"),
|
||||
}
|
||||
|
||||
# 搜索标题(权重最高)
|
||||
title = (metadata.get("title") or "").lower()
|
||||
if query_lower in title:
|
||||
score = 100 # 标题匹配权重最高
|
||||
results.append((score, {
|
||||
"id": conv_id,
|
||||
"title": metadata.get("title"),
|
||||
"created_at": metadata.get("created_at"),
|
||||
"updated_at": metadata.get("updated_at"),
|
||||
"project_path": metadata.get("project_path"),
|
||||
"match_type": "title"
|
||||
}))
|
||||
results.append((100, entry["updated_at"] or "", {**entry, "match_type": "title"}))
|
||||
continue
|
||||
|
||||
# 搜索项目路径
|
||||
project_path = metadata.get("project_path", "").lower()
|
||||
if query_lower in project_path:
|
||||
results.append((50, {
|
||||
"id": conv_id,
|
||||
"title": metadata.get("title"),
|
||||
"created_at": metadata.get("created_at"),
|
||||
"updated_at": metadata.get("updated_at"),
|
||||
"project_path": metadata.get("project_path"),
|
||||
"match_type": "project_path"
|
||||
}))
|
||||
|
||||
# 按分数排序
|
||||
results.sort(key=lambda x: x[0], reverse=True)
|
||||
|
||||
|
||||
# 搜索首条用户消息首句
|
||||
first_sentence = self._get_first_user_sentence(conv_id, metadata)
|
||||
if first_sentence and query_lower in first_sentence:
|
||||
results.append((80, entry["updated_at"] or "", {**entry, "match_type": "content"}))
|
||||
|
||||
# 分数优先,同分按更新时间新到旧
|
||||
results.sort(key=lambda x: (x[0], x[1]), reverse=True)
|
||||
|
||||
# 返回前N个结果
|
||||
return [result[1] for result in results[:limit]]
|
||||
return [item[2] for item in results[:limit]]
|
||||
except Exception as e:
|
||||
print(f"⌘ 搜索对话失败: {e}")
|
||||
return []
|
||||
|
||||
def _get_first_user_sentence(self, conversation_id: str, metadata: Optional[Dict] = None) -> str:
|
||||
"""读取对话首条用户消息并提取首句(小写)。
|
||||
|
||||
实例级缓存(terminal 按工作区缓存,manager 实例跨请求存活),
|
||||
以 updated_at 作为失效依据,避免每次按键都重读对话文件。
|
||||
"""
|
||||
try:
|
||||
cache = getattr(self, "_search_sentence_cache", None)
|
||||
if cache is None:
|
||||
cache = {}
|
||||
self._search_sentence_cache = cache
|
||||
updated_at = (metadata or {}).get("updated_at") or ""
|
||||
cached = cache.get(conversation_id)
|
||||
if cached and cached[0] == updated_at:
|
||||
return cached[1]
|
||||
|
||||
sentence = ""
|
||||
conversation_data = self.load_conversation(conversation_id)
|
||||
if conversation_data:
|
||||
for msg in conversation_data.get("messages") or []:
|
||||
if not isinstance(msg, dict) or msg.get("role") != "user":
|
||||
continue
|
||||
sentence = self._extract_first_sentence(self._extract_message_text(msg))
|
||||
break
|
||||
|
||||
sentence_lower = sentence.lower()
|
||||
cache[conversation_id] = (updated_at, sentence_lower)
|
||||
# 防止缓存无限增长
|
||||
if len(cache) > 2000:
|
||||
cache.clear()
|
||||
return sentence_lower
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _extract_message_text(msg: Dict) -> str:
|
||||
"""从消息中提取纯文本(与 build_review_lines 的 extract_text 口径一致)。"""
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for item in content:
|
||||
if isinstance(item, dict) and item.get("type") == "text":
|
||||
parts.append(item.get("text") or "")
|
||||
elif isinstance(item, str):
|
||||
parts.append(item)
|
||||
return "".join(parts)
|
||||
if isinstance(content, dict):
|
||||
return content.get("text") or ""
|
||||
return str(msg.get("text") or "")
|
||||
|
||||
@staticmethod
|
||||
def _extract_first_sentence(text: str) -> str:
|
||||
"""与旧前端 extractFirstSentence 口径一致:取到第一个句末标点为止。"""
|
||||
if not text:
|
||||
return ""
|
||||
normalized = re.sub(r"\s+", " ", str(text)).strip()
|
||||
m = re.match(r"(.+?[。!?.!?])", normalized)
|
||||
return m.group(1) if m else normalized
|
||||
|
||||
def cleanup_old_conversations(self, days: int = 30) -> int:
|
||||
"""
|
||||
清理旧对话(可选功能)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user