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}"
|
"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,
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -92,6 +92,7 @@ export const computed = {
|
|||||||
'searchQuery',
|
'searchQuery',
|
||||||
'searchTimer',
|
'searchTimer',
|
||||||
'searchResults',
|
'searchResults',
|
||||||
|
'searchGroups',
|
||||||
'conversationInsertAnimations',
|
'conversationInsertAnimations',
|
||||||
'conversationListAnimationMode',
|
'conversationListAnimationMode',
|
||||||
'pendingDeletingConversationIds',
|
'pendingDeletingConversationIds',
|
||||||
|
|||||||
@ -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
|
||||||
: []
|
: []
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -99,8 +99,6 @@ export function dataState() {
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
searchRequestSeq: 0,
|
searchRequestSeq: 0,
|
||||||
searchActiveQuery: '',
|
searchActiveQuery: '',
|
||||||
searchResultIdSet: new Set(),
|
|
||||||
searchPreviewCache: {},
|
|
||||||
|
|
||||||
// Token统计相关状态(修复版)
|
// Token统计相关状态(修复版)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 = [];
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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:
|
||||||
"""获取对话统计"""
|
"""获取对话统计"""
|
||||||
|
|||||||
@ -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:
|
||||||
"""
|
"""
|
||||||
清理旧对话(可选功能)
|
清理旧对话(可选功能)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user