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:
JOJO 2026-07-23 19:26:55 +08:00
parent b593d63856
commit f25523e441
12 changed files with 404 additions and 190 deletions

View File

@ -506,10 +506,10 @@ class WebTerminal(MainTerminal):
"message": f"删除对话异常: {e}" "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版本""" """搜索对话Web版本"""
try: try:
results = self.context_manager.search_conversations(query, limit) results = self.context_manager.search_conversations(query, limit, multi_agent_mode=multi_agent_mode)
return { return {
"success": True, "success": True,
"results": results, "results": results,

View File

@ -968,29 +968,95 @@ def delete_conversation(conversation_id, terminal: WebTerminal, workspace: UserW
@api_login_required @api_login_required
@with_terminal @with_terminal
def search_conversations(terminal: WebTerminal, workspace: UserWorkspace, username: str): def search_conversations(terminal: WebTerminal, workspace: UserWorkspace, username: str):
"""搜索对话""" """搜索对话。
默认仅搜索当前工作区返回扁平结果
all_workspaces=1 时跨当前用户的全部工作区搜索按工作区分组返回
multi_agent_mode: '1' 仅多智能体对话'0' 仅常规对话未传不过滤
"""
try: try:
query = request.args.get('q', '').strip() query = request.args.get('q', '').strip()
limit = request.args.get('limit', 20, type=int) 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: if not query:
return jsonify({ return jsonify({
"success": False, "success": False,
"error": "Missing query parameter", "error": "Missing query parameter",
"message": "请提供搜索关键词" "message": "请提供搜索关键词"
}), 400 }), 400
# 限制参数范围 # 限制参数范围
limit = max(1, min(limit, 50)) 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({ return jsonify({
"success": True, "success": True,
"data": { "data": {
"results": result["results"], "query": query,
"count": result["count"], "groups": groups,
"query": query "total": total
} }
}) })

View File

@ -92,6 +92,7 @@ export const computed = {
'searchQuery', 'searchQuery',
'searchTimer', 'searchTimer',
'searchResults', 'searchResults',
'searchGroups',
'conversationInsertAnimations', 'conversationInsertAnimations',
'conversationListAnimationMode', 'conversationListAnimationMode',
'pendingDeletingConversationIds', 'pendingDeletingConversationIds',

View File

@ -412,6 +412,13 @@ export const actionMethods = {
this.searchResults = this.searchResults.filter( this.searchResults = this.searchResults.filter(
(conversation) => conversation.id !== conversationId (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 = (Array.isArray(this.pendingDeletingConversationIds)
? this.pendingDeletingConversationIds ? this.pendingDeletingConversationIds
: [] : []

View File

@ -1,7 +1,8 @@
// @ts-nocheck // @ts-nocheck
const SEARCH_INITIAL_BATCH = 5000; import { usePersonalizationStore } from '../../stores/personalization';
const SEARCH_MORE_BATCH = 200;
const SEARCH_PREVIEW_LIMIT = 20; const SEARCH_FLAT_LIMIT = 50;
const SEARCH_GROUP_LIMIT = 20;
export const searchMethods = { export const searchMethods = {
handleSidebarSearchInput(value) { handleSidebarSearchInput(value) {
@ -25,12 +26,21 @@ export const searchMethods = {
this.searchOffset = 0; this.searchOffset = 0;
this.searchTotal = 0; this.searchTotal = 0;
this.searchResults = []; this.searchResults = [];
this.searchGroups = [];
this.searchActiveQuery = ''; this.searchActiveQuery = '';
this.searchResultIdSet = new Set();
this.conversationsOffset = 0; this.conversationsOffset = 0;
this.loadConversationsList(); this.loadConversationsList();
}, },
/** 是否处于「按工作区分组」的侧边栏模式(决定搜索是否跨工作区) */
isGroupedSidebarSearch() {
const personalizationStore = usePersonalizationStore();
return (
!!personalizationStore?.form?.group_sidebar_by_workspace &&
!!(this.versioningHostMode || this.dockerProjectMode)
);
},
async startConversationSearch(query) { async startConversationSearch(query) {
const trimmed = String(query || '').trim(); const trimmed = String(query || '').trim();
if (!trimmed) { if (!trimmed) {
@ -40,62 +50,36 @@ export const searchMethods = {
this.searchActiveQuery = trimmed; this.searchActiveQuery = trimmed;
this.searchActive = true; this.searchActive = true;
this.searchInProgress = true; this.searchInProgress = true;
// 后端一次性返回全部匹配(上限 limit不再需要分页加载更多
this.searchMoreAvailable = false; this.searchMoreAvailable = false;
this.searchOffset = 0; this.searchOffset = 0;
this.searchTotal = 0; this.searchTotal = 0;
this.searchResults = []; this.searchResults = [];
this.searchResultIdSet = new Set(); this.searchGroups = [];
await this.searchNextConversationBatch(SEARCH_INITIAL_BATCH, requestSeq);
},
async loadMoreSearchResults() { const maParam = this.multiAgentMode ? '&multi_agent_mode=1' : '&multi_agent_mode=0';
if (!this.searchActive || this.searchInProgress || !this.searchMoreAvailable) { const grouped = this.isGroupedSidebarSearch();
return; const url = grouped
} ? `/api/conversations/search?q=${encodeURIComponent(trimmed)}&limit=${SEARCH_GROUP_LIMIT}&all_workspaces=1${maParam}`
const requestSeq = this.searchRequestSeq; : `/api/conversations/search?q=${encodeURIComponent(trimmed)}&limit=${SEARCH_FLAT_LIMIT}${maParam}`;
this.searchInProgress = true;
await this.searchNextConversationBatch(SEARCH_MORE_BATCH, requestSeq);
},
async searchNextConversationBatch(batchSize, requestSeq) {
const query = this.searchActiveQuery;
if (!query) {
if (requestSeq === this.searchRequestSeq) {
this.searchInProgress = false;
}
return;
}
try { try {
const response = await fetch( const response = await fetch(url);
`/api/conversations?limit=${batchSize}&offset=${this.searchOffset}`
);
const payload = await response.json(); const payload = await response.json();
if (requestSeq !== this.searchRequestSeq) { if (requestSeq !== this.searchRequestSeq) {
return; return;
} }
if (!payload.success) { if (!payload.success) {
console.error('搜索对话失败:', payload.error || payload.message); console.error('搜索对话失败:', payload.error || payload.message);
this.searchInProgress = false;
return; return;
} }
const data = payload.data || {}; const data = payload.data || {};
const conversations = data.conversations || []; if (grouped) {
if (!this.searchTotal) { this.searchGroups = Array.isArray(data.groups) ? data.groups : [];
this.searchTotal = data.total || 0; } 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) { } catch (error) {
console.error('搜索对话异常:', error); console.error('搜索对话异常:', error);
} finally { } finally {
@ -105,73 +89,8 @@ export const searchMethods = {
} }
}, },
async matchConversation(conversation, query, requestSeq) { /** 后端搜索一次性返回结果,保留此方法仅为兼容模板绑定 */
if (!conversation || !conversation.id) { async loadMoreSearchResults() {
return; // no-op搜索结果不再分页
}
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;
} }
}; };

View File

@ -99,8 +99,6 @@ export function dataState() {
// ========================================== // ==========================================
searchRequestSeq: 0, searchRequestSeq: 0,
searchActiveQuery: '', searchActiveQuery: '',
searchResultIdSet: new Set(),
searchPreviewCache: {},
// Token统计相关状态修复版 // Token统计相关状态修复版
// ========================================== // ==========================================

View File

@ -635,7 +635,7 @@
" /><span class="fancy-check" aria-hidden="true" " /><span class="fancy-check" aria-hidden="true"
><svg viewBox="0 0 64 64"> ><svg viewBox="0 0 64 64">
<path <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" pathLength="575.0541381835938"
class="fancy-path" class="fancy-path"
></path></svg></span ></path></svg></span

View File

@ -93,7 +93,6 @@
</svg> </svg>
</span> </span>
<span class="sidebar-nav-label">{{ workspaceKind === 'project' ? '项目' : '工作区' }}</span> <span class="sidebar-nav-label">{{ workspaceKind === 'project' ? '项目' : '工作区' }}</span>
<span class="workspace-entry-current">{{ currentWorkspaceLabel }}</span>
</button> </button>
</div> </div>
@ -140,7 +139,7 @@
</button> </button>
</div> </div>
<template v-if="isGroupByWorkspaceActive && !searchActive"> <template v-if="isGroupByWorkspaceActive && !searchActive">
<div class="workspace-groups"> <div class="workspace-groups" :class="{ 'switch-instant': workspaceSwitchInstant }">
<div <div
v-for="ws in sortedWorkspaces" v-for="ws in sortedWorkspaces"
:key="String(ws.workspace_id || ws.label)" :key="String(ws.workspace_id || ws.label)"
@ -245,6 +244,7 @@
tag="div" tag="div"
class="workspace-conversations-list" class="workspace-conversations-list"
:class="`animation-mode-${conversationListAnimationMode}`" :class="`animation-mode-${conversationListAnimationMode}`"
@before-leave="lockLeaveItemPosition"
> >
<div <div
v-for="conv in getWorkspaceVisibleAndBufferConversations(String(ws.workspace_id || ''))" v-for="conv in getWorkspaceVisibleAndBufferConversations(String(ws.workspace_id || ''))"
@ -344,6 +344,109 @@
</div> </div>
</template> </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> <template v-else>
<div <div
v-if="loading && !displayConversations.length && !searchActive && !isDeletingConversation" v-if="loading && !displayConversations.length && !searchActive && !isDeletingConversation"
@ -628,6 +731,7 @@ const { sidebarCollapsed: collapsed } = storeToRefs(uiStore);
const { const {
searchQuery, searchQuery,
searchResults, searchResults,
searchGroups,
searchActive, searchActive,
searchInProgress, searchInProgress,
searchMoreAvailable, searchMoreAvailable,
@ -649,12 +753,6 @@ const workspaceEntryBtn = ref<HTMLElement | null>(null);
const workspaceSwitcherOpen = ref(false); const workspaceSwitcherOpen = ref(false);
const workspaceAnchorRect = ref<{ left: number; top: number; right: number; bottom: number } | null>(null); 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 = () => { const toggleWorkspaceSwitcher = () => {
if (workspaceSwitcherOpen.value) { if (workspaceSwitcherOpen.value) {
workspaceSwitcherOpen.value = false; workspaceSwitcherOpen.value = false;
@ -679,6 +777,9 @@ const toggleWorkspaceSwitcher = () => {
}; };
const openActionMenuId = ref<string | null>(null); const openActionMenuId = ref<string | null>(null);
const actionMenuPosition = ref<{ top: number; right: number }>({ top: 0, right: 0 }); const actionMenuPosition = ref<{ top: number; right: number }>({ top: 0, right: 0 });
/* true FLIP
使分组头与子对话全部瞬间移动不影响新建/复制/删除动画 */
const workspaceSwitchInstant = ref(false);
const hoverWorkspaceId = ref<string | null>(null); const hoverWorkspaceId = ref<string | null>(null);
const openWorkspaceMenuId = ref<string | null>(null); const openWorkspaceMenuId = ref<string | null>(null);
const renameWorkspaceId = 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 = () => { const closeActionMenu = () => {
openActionMenuId.value = null; openActionMenuId.value = null;
}; };
@ -886,7 +996,19 @@ watch(
watch( watch(
() => props.currentWorkspaceId, () => 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 } { immediate: true }
); );
@ -913,6 +1035,24 @@ const sortedWorkspaces = computed(() => {
const displayConversations = computed(() => const displayConversations = computed(() =>
searchActive.value ? searchResults.value : conversations.value 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(() => const loading = computed(() =>
searchActive.value ? searchInProgress.value : conversationsLoading.value searchActive.value ? searchInProgress.value : conversationsLoading.value
); );

View File

@ -22,9 +22,18 @@ export interface WorkspaceConversationGroup {
expanded: boolean; expanded: boolean;
} }
/** 跨工作区搜索结果分组(后端 /api/conversations/search?all_workspaces=1 返回) */
export interface WorkspaceSearchGroup {
workspace_id: string;
label: string;
count: number;
results: ConversationSummary[];
}
interface ConversationState { interface ConversationState {
conversations: ConversationSummary[]; conversations: ConversationSummary[];
searchResults: ConversationSummary[]; searchResults: ConversationSummary[];
searchGroups: WorkspaceSearchGroup[];
conversationInsertAnimations: Record<string, 'create' | 'duplicate' | 'duplicateSource'>; conversationInsertAnimations: Record<string, 'create' | 'duplicate' | 'duplicateSource'>;
conversationListAnimationMode: 'idle' | 'create' | 'duplicate' | 'delete'; conversationListAnimationMode: 'idle' | 'create' | 'duplicate' | 'delete';
pendingDeletingConversationIds: string[]; pendingDeletingConversationIds: string[];
@ -52,6 +61,7 @@ export const useConversationStore = defineStore('conversation', {
state: (): ConversationState => ({ state: (): ConversationState => ({
conversations: [], conversations: [],
searchResults: [], searchResults: [],
searchGroups: [],
conversationInsertAnimations: {}, conversationInsertAnimations: {},
conversationListAnimationMode: 'idle', conversationListAnimationMode: 'idle',
pendingDeletingConversationIds: [], pendingDeletingConversationIds: [],
@ -82,6 +92,7 @@ export const useConversationStore = defineStore('conversation', {
resetConversations() { resetConversations() {
this.conversations = []; this.conversations = [];
this.searchResults = []; this.searchResults = [];
this.searchGroups = [];
this.conversationInsertAnimations = {}; this.conversationInsertAnimations = {};
this.conversationListAnimationMode = 'idle'; this.conversationListAnimationMode = 'idle';
this.pendingDeletingConversationIds = []; this.pendingDeletingConversationIds = [];

View File

@ -117,7 +117,6 @@
.sidebar-nav-label, .sidebar-nav-label,
.conversation-search, .conversation-search,
.conversation-list, .conversation-list,
.workspace-entry-current,
.personal-label { .personal-label {
opacity: 1; opacity: 1;
transition: opacity 220ms ease; transition: opacity 220ms ease;
@ -127,7 +126,6 @@
.conversation-sidebar.collapsed .sidebar-nav-label, .conversation-sidebar.collapsed .sidebar-nav-label,
.conversation-sidebar.collapsed .conversation-search, .conversation-sidebar.collapsed .conversation-search,
.conversation-sidebar.collapsed .conversation-list, .conversation-sidebar.collapsed .conversation-list,
.conversation-sidebar.collapsed .workspace-entry-current,
.conversation-sidebar.collapsed .personal-label { .conversation-sidebar.collapsed .personal-label {
opacity: 0; opacity: 0;
transition-delay: 0ms; transition-delay: 0ms;
@ -458,7 +456,9 @@ body[data-theme='dark'] .conversation-sidebar .conversation-item.active:focus-wi
.conversation-sidebar .conversation-action-wrap { .conversation-sidebar .conversation-action-wrap {
position: relative; position: relative;
width: 40px; /* 宽度必须与条目第二列32px一致40px 会向右溢出 8px
导致按钮探出条目圆角背景外 */
width: 32px;
height: var(--conversation-row-height); height: var(--conversation-row-height);
display: grid; display: grid;
place-items: center; place-items: center;
@ -608,20 +608,9 @@ body[data-theme='dark'] .conversation-sidebar .conversation-item.active:focus-wi
flex: 0 0 auto; flex: 0 0 auto;
} }
/* 工作区入口行:图标 + 名称 + 当前工作区名(第三列 */ /* 工作区入口行:图标 + 名称(当前工作区名不再展示 */
.sidebar-nav-row.workspace-entry-btn { .sidebar-nav-row.workspace-entry-btn {
grid-template-columns: var(--conversation-icon-cell) minmax(0, 1fr) auto; grid-template-columns: var(--conversation-icon-cell) minmax(0, 1fr);
}
.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);
} }
.personal-page-btn svg { .personal-page-btn svg {
@ -764,6 +753,17 @@ body[data-theme='dark'] .workspace-group-header:hover {
color: var(--claude-text-secondary); 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 { .workspace-group-label {
min-width: 0; min-width: 0;
font-size: 13px; 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); 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 { .workspace-group-children.expanded {
grid-template-rows: 1fr; grid-template-rows: 1fr;
} }
@ -924,9 +934,8 @@ body[data-theme='dark'] .workspace-group-header:hover {
flex-direction: column; flex-direction: column;
} }
.workspace-conversation-item { /* 注意不要在这里用 .workspace-conversation-item 覆盖 grid-template-columns
grid-template-columns: minmax(0, 1fr) 40px; 特异性低于 .conversation-sidebar .conversation-item写了也不会生效 */
}
.workspace-no-conversations, .workspace-no-conversations,
.workspace-conversations-loading { .workspace-conversations-loading {

View File

@ -459,9 +459,9 @@ class ConversationMixin:
pass pass
return False 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: def get_conversation_statistics(self) -> Dict:
"""获取对话统计""" """获取对话统计"""

View File

@ -2,6 +2,7 @@
import json import json
import os import os
import re
import time import time
import tempfile import tempfile
import threading import threading
@ -314,59 +315,121 @@ class ListSearchMixin:
print(f"⌘ 归档对话失败 {conversation_id}: {e}") print(f"⌘ 归档对话失败 {conversation_id}: {e}")
return False 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: Args:
query: 搜索关键词 query: 搜索关键词
limit: 限制数量 limit: 限制数量
multi_agent_mode: True 仅多智能体对话False 仅常规对话None 不过滤
Returns: Returns:
List[Dict]: 匹配的对话列表 List[Dict]: 匹配的对话列表
""" """
try: try:
index = self._load_index() index = self._load_index()
results = [] results = []
query_lower = query.lower() query_lower = (query or "").lower()
if not query_lower:
return []
for conv_id, metadata in index.items(): for conv_id, metadata in index.items():
# 搜索标题 if multi_agent_mode is not None:
title = metadata.get("title", "").lower() 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: if query_lower in title:
score = 100 # 标题匹配权重最高 results.append((100, entry["updated_at"] or "", {**entry, "match_type": "title"}))
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"
}))
continue continue
# 搜索项目路径 # 搜索首条用户消息首句
project_path = metadata.get("project_path", "").lower() first_sentence = self._get_first_user_sentence(conv_id, metadata)
if query_lower in project_path: if first_sentence and query_lower in first_sentence:
results.append((50, { results.append((80, entry["updated_at"] or "", {**entry, "match_type": "content"}))
"id": conv_id,
"title": metadata.get("title"), # 分数优先,同分按更新时间新到旧
"created_at": metadata.get("created_at"), results.sort(key=lambda x: (x[0], x[1]), reverse=True)
"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)
# 返回前N个结果 # 返回前N个结果
return [result[1] for result in results[:limit]] return [item[2] for item in results[:limit]]
except Exception as e: except Exception as e:
print(f"⌘ 搜索对话失败: {e}") print(f"⌘ 搜索对话失败: {e}")
return [] 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: def cleanup_old_conversations(self, days: int = 30) -> int:
""" """
清理旧对话可选功能 清理旧对话可选功能