feat: search

This commit is contained in:
Soulter
2026-01-27 01:35:47 +08:00
parent b04dad1fd2
commit c20cb209d0
9 changed files with 822 additions and 91 deletions
+17
View File
@@ -203,6 +203,23 @@ class BaseDatabase(abc.ABC):
"""Get platform message history for a specific user."""
...
@abc.abstractmethod
async def search_platform_sessions(
self,
creator: str,
query: str,
context_len: int = 40,
page: int = 1,
page_size: int = 10,
) -> tuple[list[dict], int]:
"""Search platform sessions (title or message content) for a given creator.
Returns a tuple of (results, total) where results is a list of dicts with keys:
session_id, title, match_field, match_index, match_length, snippet, snippet_start,
created_at, updated_at
"""
...
@abc.abstractmethod
async def get_platform_message_history_by_id(
self,
+139
View File
@@ -1,4 +1,5 @@
import asyncio
import json
import threading
import typing as T
from collections.abc import Awaitable, Callable
@@ -483,6 +484,144 @@ class SQLiteDatabase(BaseDatabase):
result = await session.execute(query.offset(offset).limit(page_size))
return result.scalars().all()
def _build_snippet(self, text: str, match_index: int, match_length: int, context_len: int):
if match_index < 0:
return "", 0
start = max(match_index - context_len, 0)
end = min(match_index + match_length + context_len, len(text))
return text[start:end], start
async def search_platform_sessions(
self,
creator: str,
query: str,
context_len: int = 40,
page: int = 1,
page_size: int = 10,
) -> tuple[list[dict], int]:
"""Search platform sessions (by title or by message content) for a given creator.
This implementation performs searching at DB level using SQL LIKE on
`platform_sessions.display_name` and the JSON `platform_message_history.content`.
To keep work minimal and compatible with SQLite JSON storage, the content
column is searched as text using LIKE.
Returns (results, total) where results are dicts suitable for the caller.
"""
async with self.get_db() as session:
session: AsyncSession
pattern = f"%{query}%"
# 1) Title matches
title_q = (
select(PlatformSession)
.where(col(PlatformSession.creator) == creator)
.where(col(PlatformSession.display_name).ilike(pattern))
.order_by(desc(PlatformSession.updated_at))
)
title_result = await session.execute(title_q)
title_rows = title_result.scalars().all()
results: list[dict] = []
for session_row in title_rows:
title = session_row.display_name or ""
title_lower = title.lower()
qlower = query.lower()
match_index = title_lower.find(qlower) if title else -1
snippet, snippet_start = self._build_snippet(title, match_index, len(query), context_len)
results.append(
{
"session_id": session_row.session_id,
"title": session_row.display_name,
"match_field": "title",
"match_index": match_index,
"match_length": len(query),
"snippet": snippet,
"snippet_start": snippet_start,
"created_at": session_row.created_at.astimezone().isoformat(),
"updated_at": session_row.updated_at.astimezone().isoformat(),
}
)
# 2) Content matches: find latest matching message per session (user_id)
# Use a subquery to select the latest message id per user that matches the pattern
subq = (
select(func.max(col(PlatformMessageHistory.id)).label("max_id"))
.select_from(PlatformMessageHistory)
.join(
PlatformSession,
col(PlatformMessageHistory.user_id) == col(PlatformSession.session_id),
)
.where(col(PlatformSession.creator) == creator)
.where(col(PlatformMessageHistory.content).ilike(pattern))
.group_by(col(PlatformMessageHistory.user_id))
)
ids_result = await session.execute(subq)
id_rows = [r[0] for r in ids_result.fetchall() if r[0] is not None]
if id_rows:
q = select(PlatformMessageHistory).where(col(PlatformMessageHistory.id).in_(id_rows))
q = q.order_by(desc(PlatformMessageHistory.created_at))
hist_result = await session.execute(q)
histories = hist_result.scalars().all()
for history in histories:
# find associated session to get display_name/created_at/updated_at
ps_q = select(PlatformSession).where(col(PlatformSession.session_id) == history.user_id)
ps_res = await session.execute(ps_q)
ps = ps_res.scalar_one_or_none()
text = None
try:
# convert content json to plain text similar to ChatRoute._extract_plain_text
msg = history.content
if isinstance(msg, dict):
message = msg.get("message")
if isinstance(message, str):
text = message
elif isinstance(message, list):
parts = []
for part in message:
if not isinstance(part, dict):
continue
part_type = part.get("type")
if part_type == "plain" and part.get("text"):
parts.append(str(part.get("text")))
elif part_type == "reply" and part.get("selected_text"):
parts.append(str(part.get("selected_text")))
text = "\n".join(parts)
except Exception:
text = None
if not text:
# fallback to stringified JSON
text = json.dumps(history.content, ensure_ascii=False)
lower_text = text.lower() if isinstance(text, str) else ""
match_index = lower_text.find(query.lower())
if match_index == -1:
continue
snippet, snippet_start = self._build_snippet(text, match_index, len(query), context_len)
results.append(
{
"session_id": history.user_id,
"title": ps.display_name if ps else None,
"match_field": "content",
"match_index": match_index,
"match_length": len(query),
"snippet": snippet,
"snippet_start": snippet_start,
"created_at": ps.created_at.astimezone().isoformat() if ps else history.created_at.astimezone().isoformat(),
"updated_at": ps.updated_at.astimezone().isoformat() if ps else history.updated_at.astimezone().isoformat(),
}
)
# sort and paginate
results.sort(key=lambda item: item["updated_at"], reverse=True)
total = len(results)
offset = (page - 1) * page_size
paged = results[offset : offset + page_size]
return paged, total
async def get_platform_message_history_by_id(
self, message_id: int
) -> PlatformMessageHistory | None:
+81
View File
@@ -46,6 +46,7 @@ class ChatRoute(Route):
"POST",
self.update_session_display_name,
),
"/chat/search": ("GET", self.search_sessions),
"/chat/get_file": ("GET", self.get_file),
"/chat/get_attachment": ("GET", self.get_attachment),
"/chat/post_file": ("POST", self.post_file),
@@ -63,6 +64,35 @@ class ChatRoute(Route):
self.running_convs: dict[str, bool] = {}
@staticmethod
def _extract_plain_text(content: dict) -> str:
if not isinstance(content, dict):
return ""
message = content.get("message")
if isinstance(message, str):
return message
if not isinstance(message, list):
return ""
parts = []
for part in message:
if not isinstance(part, dict):
continue
part_type = part.get("type")
if part_type == "plain" and part.get("text"):
parts.append(str(part.get("text")))
elif part_type == "reply" and part.get("selected_text"):
parts.append(str(part.get("selected_text")))
return "\n".join(parts)
@staticmethod
def _build_snippet(text: str, match_index: int, match_length: int, context_len: int):
if match_index < 0:
return "", 0
start = max(match_index - context_len, 0)
end = min(match_index + match_length + context_len, len(text))
return text[start:end], start
async def get_file(self):
filename = request.args.get("filename")
if not filename:
@@ -731,6 +761,57 @@ class ChatRoute(Route):
return Response().ok(data=sessions_data).__dict__
async def search_sessions(self):
"""Search sessions by title or content, with pagination."""
username = g.get("username", "guest")
query = request.args.get("query", "", type=str).strip()
page = max(request.args.get("page", 1, type=int), 1)
page_size = min(max(request.args.get("page_size", 10, type=int), 1), 100)
context_len = min(max(request.args.get("context", 40, type=int), 0), 200)
if not query:
return (
Response()
.ok(
data={
"results": [],
"pagination": {
"page": page,
"page_size": page_size,
"total": 0,
"total_pages": 1,
},
}
)
.__dict__
)
# Delegate searching to the database implementation for efficiency
paged_results, total = await self.db.search_platform_sessions(
creator=username,
query=query,
context_len=context_len,
page=page,
page_size=page_size,
)
total_pages = (total + page_size - 1) // page_size if total > 0 else 1
return (
Response()
.ok(
data={
"results": paged_results,
"pagination": {
"page": page,
"page_size": page_size,
"total": total,
"total_pages": total_pages,
},
}
)
.__dict__
)
async def get_session(self):
"""Get session information and message history by session_id."""
session_id = request.args.get("session_id")
+117 -88
View File
@@ -26,6 +26,7 @@
@createProject="showCreateProjectDialog"
@editProject="showEditProjectDialog"
@deleteProject="handleDeleteProject"
@openSearch="handleOpenSearch"
/>
<!-- 右侧聊天内容区域 -->
@@ -42,65 +43,99 @@
</v-btn>
</div>
<!-- 面包屑导航 -->
<div v-if="currentSessionProject && messages && messages.length > 0" class="breadcrumb-container">
<div class="breadcrumb-content">
<span class="breadcrumb-emoji">{{ currentSessionProject.emoji || '📁' }}</span>
<span class="breadcrumb-project" @click="handleSelectProject(currentSessionProject.project_id)">{{ currentSessionProject.title }}</span>
<v-icon size="small" class="breadcrumb-separator">mdi-chevron-right</v-icon>
<span class="breadcrumb-session">{{ getCurrentSession?.display_name || tm('conversation.newConversation') }}</span>
</div>
</div>
<div class="message-list-wrapper" v-if="currSessionId && !selectedProjectId">
<MessageList :messages="messages" :isDark="isDark"
:isStreaming="isStreaming || isConvRunning"
:isLoadingMessages="isLoadingMessages"
@openImagePreview="openImagePreview"
@replyMessage="handleReplyMessage"
@replyWithText="handleReplyWithText"
@openRefs="handleOpenRefs"
ref="messageList" />
<div class="message-list-fade" :class="{ 'fade-dark': isDark }"></div>
</div>
<ProjectView
v-else-if="selectedProjectId"
:project="currentProject"
:sessions="projectSessions"
<ChatSearchView
v-if="isSearchActive"
@close="handleCloseSearch"
@selectSession="(sessionId) => handleSelectConversation([sessionId])"
@editSessionTitle="showEditTitleDialog"
@deleteSession="handleDeleteConversation"
>
<ChatInput
v-model:prompt="prompt"
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
:stagedFiles="stagedNonImageFiles"
:disabled="isStreaming"
:enableStreaming="enableStreaming"
:isRecording="isRecording"
:session-id="currSessionId || null"
:current-session="getCurrentSession"
:replyTo="replyTo"
@send="handleSendMessage"
@toggleStreaming="toggleStreaming"
@removeImage="removeImage"
@removeAudio="removeAudio"
@removeFile="removeFile"
@startRecording="handleStartRecording"
@stopRecording="handleStopRecording"
@pasteImage="handlePaste"
@fileSelect="handleFileSelect"
@clearReply="clearReply"
@openLiveMode="openLiveMode"
ref="chatInputRef"
/>
</ProjectView>
<WelcomeView
v-else
:isLoading="isLoadingMessages"
>
<template v-else>
<!-- 面包屑导航 -->
<div v-if="currentSessionProject && messages && messages.length > 0" class="breadcrumb-container">
<div class="breadcrumb-content">
<span class="breadcrumb-emoji">{{ currentSessionProject.emoji || '📁' }}</span>
<span class="breadcrumb-project" @click="handleSelectProject(currentSessionProject.project_id)">{{ currentSessionProject.title }}</span>
<v-icon size="small" class="breadcrumb-separator">mdi-chevron-right</v-icon>
<span class="breadcrumb-session">{{ getCurrentSession?.display_name || tm('conversation.newConversation') }}</span>
</div>
</div>
<div class="message-list-wrapper" v-if="currSessionId && !selectedProjectId">
<MessageList :messages="messages" :isDark="isDark"
:isStreaming="isStreaming || isConvRunning"
:isLoadingMessages="isLoadingMessages"
@openImagePreview="openImagePreview"
@replyMessage="handleReplyMessage"
@replyWithText="handleReplyWithText"
@openRefs="handleOpenRefs"
ref="messageList" />
<div class="message-list-fade" :class="{ 'fade-dark': isDark }"></div>
</div>
<ProjectView
v-else-if="selectedProjectId"
:project="currentProject"
:sessions="projectSessions"
@selectSession="(sessionId) => handleSelectConversation([sessionId])"
@editSessionTitle="showEditTitleDialog"
@deleteSession="handleDeleteConversation"
>
<ChatInput
v-model:prompt="prompt"
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
:stagedFiles="stagedNonImageFiles"
:disabled="isStreaming"
:enableStreaming="enableStreaming"
:isRecording="isRecording"
:session-id="currSessionId || null"
:current-session="getCurrentSession"
:replyTo="replyTo"
@send="handleSendMessage"
@toggleStreaming="toggleStreaming"
@removeImage="removeImage"
@removeAudio="removeAudio"
@removeFile="removeFile"
@startRecording="handleStartRecording"
@stopRecording="handleStopRecording"
@pasteImage="handlePaste"
@fileSelect="handleFileSelect"
@clearReply="clearReply"
@openLiveMode="openLiveMode"
ref="chatInputRef"
/>
</ProjectView>
<WelcomeView
v-else
:isLoading="isLoadingMessages"
>
<ChatInput
v-model:prompt="prompt"
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
:stagedFiles="stagedNonImageFiles"
:disabled="isStreaming"
:enableStreaming="enableStreaming"
:isRecording="isRecording"
:session-id="currSessionId || null"
:current-session="getCurrentSession"
:replyTo="replyTo"
@send="handleSendMessage"
@toggleStreaming="toggleStreaming"
@removeImage="removeImage"
@removeAudio="removeAudio"
@removeFile="removeFile"
@startRecording="handleStartRecording"
@stopRecording="handleStopRecording"
@pasteImage="handlePaste"
@fileSelect="handleFileSelect"
@clearReply="clearReply"
@openLiveMode="openLiveMode"
ref="chatInputRef"
/>
</WelcomeView>
<!-- 输入区域 -->
<ChatInput
v-if="currSessionId && !selectedProjectId"
v-model:prompt="prompt"
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
@@ -124,34 +159,7 @@
@openLiveMode="openLiveMode"
ref="chatInputRef"
/>
</WelcomeView>
<!-- 输入区域 -->
<ChatInput
v-if="currSessionId && !selectedProjectId"
v-model:prompt="prompt"
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
:stagedFiles="stagedNonImageFiles"
:disabled="isStreaming"
:enableStreaming="enableStreaming"
:isRecording="isRecording"
:session-id="currSessionId || null"
:current-session="getCurrentSession"
:replyTo="replyTo"
@send="handleSendMessage"
@toggleStreaming="toggleStreaming"
@removeImage="removeImage"
@removeAudio="removeAudio"
@removeFile="removeFile"
@startRecording="handleStartRecording"
@stopRecording="handleStopRecording"
@pasteImage="handlePaste"
@fileSelect="handleFileSelect"
@clearReply="clearReply"
@openLiveMode="openLiveMode"
ref="chatInputRef"
/>
</template>
</template>
</div>
@@ -200,6 +208,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
import { storeToRefs } from 'pinia';
import { useRouter, useRoute } from 'vue-router';
import { useCustomizerStore } from '@/stores/customizer';
import { useI18n, useModuleI18n } from '@/i18n/composables';
@@ -209,6 +218,7 @@ import ConversationSidebar from '@/components/chat/ConversationSidebar.vue';
import ChatInput from '@/components/chat/ChatInput.vue';
import ProjectDialog from '@/components/chat/ProjectDialog.vue';
import ProjectView from '@/components/chat/ProjectView.vue';
import ChatSearchView from '@/components/chat/ChatSearchView.vue';
import WelcomeView from '@/components/chat/WelcomeView.vue';
import RefsSidebar from '@/components/chat/message_list_comps/RefsSidebar.vue';
import LiveMode from '@/components/chat/LiveMode.vue';
@@ -219,6 +229,7 @@ import { useMediaHandling } from '@/composables/useMediaHandling';
import { useProjects } from '@/composables/useProjects';
import type { Project } from '@/components/chat/ProjectList.vue';
import { useRecording } from '@/composables/useRecording';
import { useChatSearchStore } from '@/stores/chatSearch';
interface Props {
chatboxMode?: boolean;
@@ -233,6 +244,8 @@ const route = useRoute();
const { t } = useI18n();
const { tm } = useModuleI18n('features/chat');
const theme = useTheme();
const chatSearchStore = useChatSearchStore();
const { active: isSearchActive } = storeToRefs(chatSearchStore);
// UI 状态
const isMobile = ref(false);
@@ -436,12 +449,15 @@ function handleOpenRefs(refs: any) {
}
}
async function handleSelectConversation(sessionIds: string[]) {
async function handleSelectConversation(sessionIds: string[], shouldCloseSearch: boolean = true) {
if (!sessionIds[0]) return;
// 退出项目视图
selectedProjectId.value = null;
projectSessions.value = [];
if (shouldCloseSearch) {
chatSearchStore.closeSearch();
}
// 立即更新选中状态,避免需要点击两次
currSessionId.value = sessionIds[0];
@@ -482,6 +498,7 @@ function handleNewChat() {
// 退出项目视图
selectedProjectId.value = null;
projectSessions.value = [];
chatSearchStore.closeSearch();
}
async function handleDeleteConversation(sessionId: string) {
@@ -500,6 +517,7 @@ async function handleSelectProject(projectId: string) {
const sessions = await getProjectSessions(projectId);
projectSessions.value = sessions;
messages.value = [];
chatSearchStore.closeSearch();
// 清空当前会话ID,准备在项目中创建新对话
currSessionId.value = '';
@@ -573,6 +591,17 @@ function closeLiveMode() {
liveModeOpen.value = false;
}
function handleOpenSearch() {
chatSearchStore.openSearch();
if (isMobile.value) {
closeMobileSidebar();
}
}
function handleCloseSearch() {
chatSearchStore.closeSearch();
}
async function handleSendMessage() {
// 只有引用不能发送,必须有输入内容
if (!prompt.value.trim() && stagedFiles.value.length === 0 && !stagedAudioUrl.value) {
@@ -647,7 +676,7 @@ watch(
if (sessions.value.length > 0) {
const session = sessions.value.find(s => s.session_id === pathSessionId);
if (session) {
handleSelectConversation([pathSessionId]);
handleSelectConversation([pathSessionId], !isSearchActive.value);
}
} else {
pendingSessionId.value = pathSessionId;
@@ -664,13 +693,13 @@ watch(sessions, (newSessions) => {
const session = newSessions.find(s => s.session_id === pendingSessionId.value);
if (session) {
selectedSessions.value = [pendingSessionId.value];
handleSelectConversation([pendingSessionId.value]);
handleSelectConversation([pendingSessionId.value], !isSearchActive.value);
pendingSessionId.value = null;
}
} else if (!currSessionId.value && newSessions.length > 0) {
const firstSession = newSessions[0];
selectedSessions.value = [firstSession.session_id];
handleSelectConversation([firstSession.session_id]);
handleSelectConversation([firstSession.session_id], !isSearchActive.value);
}
});
@@ -0,0 +1,313 @@
<template>
<div class="chat-search-container fade-in">
<div class="chat-search-header">
<div class="chat-search-header-info">
<h2 class="chat-search-header-title">{{ tm('search.title') }}</h2>
</div>
</div>
<div class="chat-search-input">
<v-text-field
v-model="query"
:placeholder="tm('search.placeholder')"
prepend-inner-icon="mdi-magnify"
variant="outlined"
rounded="xl"
density="comfortable"
clearable
flat
hide-details
:loading="isLoading"
@keyup.enter="handleSearch"
@click:clear="handleClear"
/>
</div>
<v-card flat class="chat-search-results">
<v-list v-if="results.length > 0">
<v-list-item
v-for="item in results"
:key="item.session_id"
class="chat-search-result-item"
rounded="lg"
@click="emit('selectSession', item.session_id)"
>
<v-list-item-title style="font-weight: bold;">
{{ item.title || tm('conversation.newConversation') }}
</v-list-item-title>
<v-list-item-subtitle class="chat-search-snippet">
<span>{{ getSnippetParts(item).before }}</span>
<span class="chat-search-highlight">{{ getSnippetParts(item).match }}</span>
<span>{{ getSnippetParts(item).after }}</span>
</v-list-item-subtitle>
<v-list-item-subtitle class="chat-search-meta">
<!-- {{ getMatchFieldLabel(item) }} -->
<!-- · {{ tm('search.matchPosition') }} {{ item.match_index + 1 }} -->
{{ tm('search.createdAt') }} {{ formatDate(item.created_at) }}
· {{ tm('search.updatedAt') }} {{ formatDate(item.updated_at) }}
</v-list-item-subtitle>
</v-list-item>
</v-list>
<div v-else class="chat-search-empty">
<v-icon icon="mdi-text-box-search-outline" size="large" color="grey-lighten-1"></v-icon>
<p>
{{ searchPerformed ? tm('search.noResults') : tm('search.hint') }}
</p>
</div>
</v-card>
<div v-if="pagination.total > 0" class="chat-search-pagination">
<div class="chat-search-page-size">
<span class="chat-search-page-label">{{ tm('search.pageSize') }}</span>
<v-select
v-model="pageSizeProxy"
:items="pageSizeOptions"
variant="outlined"
density="compact"
hide-details
:disabled="isLoading"
/>
</div>
<v-pagination
v-model="pageProxy"
:length="pagination.total_pages"
:disabled="isLoading"
rounded="circle"
:total-visible="7"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import { useChatSearchStore, type ChatSearchResult } from '@/stores/chatSearch';
const emit = defineEmits<{
close: [];
selectSession: [sessionId: string];
}>();
const { t } = useI18n();
const { tm } = useModuleI18n('features/chat');
const chatSearchStore = useChatSearchStore();
const { query, results, pagination, isLoading, searchPerformed } = storeToRefs(chatSearchStore);
const pageSizeOptions = [10, 20, 50];
const searchTimeout = ref<ReturnType<typeof setTimeout> | null>(null);
const debounceDelay = 400;
const pageProxy = computed({
get: () => pagination.value.page,
set: (value) => chatSearchStore.setPage(value)
});
const pageSizeProxy = computed({
get: () => pagination.value.page_size,
set: (value) => chatSearchStore.setPageSize(value)
});
function handleSearch() {
chatSearchStore.runNewSearch();
}
function handleClear() {
chatSearchStore.search();
}
function scheduleSearch() {
if (searchTimeout.value) {
clearTimeout(searchTimeout.value);
}
searchTimeout.value = setTimeout(() => {
chatSearchStore.runNewSearch();
}, debounceDelay);
}
watch(query, (value) => {
if (!value || !value.trim()) {
if (searchTimeout.value) {
clearTimeout(searchTimeout.value);
}
chatSearchStore.search();
return;
}
scheduleSearch();
});
onBeforeUnmount(() => {
if (searchTimeout.value) {
clearTimeout(searchTimeout.value);
}
});
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleString();
}
function getSnippetParts(item: ChatSearchResult) {
const localIndex = Math.max(0, item.match_index - item.snippet_start);
return {
before: item.snippet.slice(0, localIndex),
match: item.snippet.slice(localIndex, localIndex + item.match_length),
after: item.snippet.slice(localIndex + item.match_length)
};
}
function getMatchFieldLabel(item: ChatSearchResult) {
return item.match_field === 'title' ? tm('search.matchTitle') : tm('search.matchContent');
}
</script>
<style scoped>
.chat-search-container {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding: 32px;
overflow-y: auto;
}
.chat-search-header {
text-align: center;
margin-bottom: 24px;
max-width: 640px;
}
.chat-search-header-info {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 12px;
}
.chat-search-header-emoji {
font-size: 44px;
}
.chat-search-header-title {
font-size: 30px;
font-weight: 600;
}
.chat-search-header-description {
font-size: 14px;
color: var(--v-theme-secondaryText);
margin: 0 0 12px;
}
.chat-search-input {
width: 100%;
max-width: 730px;
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 20px;
}
.chat-search-results {
width: 100%;
max-width: 760px;
background-color: transparent !important;
}
.chat-search-result-item {
margin-bottom: 8px;
border-radius: 12px !important;
cursor: pointer;
}
.chat-search-result-item:hover {
background-color: rgba(103, 58, 183, 0.05);
}
.chat-search-snippet {
font-size: 14px;
color: var(--v-theme-secondaryText);
margin-top: 4px;
}
.chat-search-highlight {
background-color: rgba(255, 204, 102, 0.45);
padding: 0 2px;
border-radius: 4px;
}
.chat-search-meta {
font-size: 12px;
margin-top: 6px;
opacity: 1;
}
.chat-search-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
opacity: 0.6;
}
.chat-search-empty p {
margin-top: 12px;
font-size: 14px;
}
.chat-search-pagination {
width: 100%;
max-width: 760px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 4px 0;
}
.chat-search-page-size {
display: flex;
align-items: center;
gap: 8px;
min-width: 200px;
}
.chat-search-page-label {
font-size: 12px;
color: var(--v-theme-secondaryText);
}
.fade-in {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 768px) {
.chat-search-container {
padding: 24px 16px;
}
.chat-search-input {
flex-direction: column;
}
.chat-search-pagination {
flex-direction: column;
align-items: flex-start;
}
}
</style>
@@ -20,6 +20,15 @@
</v-btn>
</div>
<div style="padding: 8px; padding-bottom: 0px; opacity: 0.6;">
<v-btn block variant="text" class="search-chat-btn" @click="$emit('openSearch')"
v-if="!sidebarCollapsed || isMobile" prepend-icon="mdi-magnify">
{{ t('core.actions.search') }}
</v-btn>
<v-btn icon="mdi-magnify" rounded="xl" @click="$emit('openSearch')"
v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn>
</div>
<div style="padding: 8px; opacity: 0.6;">
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
v-if="!sidebarCollapsed || isMobile" prepend-icon="mdi-square-edit-outline">{{ tm('actions.newChat') }}</v-btn>
@@ -178,6 +187,7 @@ const emit = defineEmits<{
createProject: [];
editProject: [project: Project];
deleteProject: [projectId: string];
openSearch: [];
}>();
const { t } = useI18n();
@@ -264,6 +274,13 @@ function handleDeleteConversation(session: Session) {
padding: 8px 16px !important;
}
.search-chat-btn {
justify-content: flex-start;
background-color: transparent !important;
border-radius: 20px;
padding: 8px 16px !important;
}
.conversation-item {
/* margin-bottom: 4px; */
border-radius: 20px !important;
@@ -359,4 +376,3 @@ function handleDeleteConversation(session: Session) {
justify-content: center;
}
</style>
@@ -98,6 +98,18 @@
"noSessions": "No conversations in this project",
"confirmDelete": "Are you sure you want to delete project \"{title}\"? Conversations in this project will not be deleted."
},
"search": {
"title": "Search",
"placeholder": "Enter keywords to search titles or content",
"hint": "Enter keywords to start searching",
"noResults": "No matching conversations found",
"matchTitle": "Title match",
"matchContent": "Content match",
"matchPosition": "Match position",
"createdAt": "Created",
"updatedAt": "Updated",
"pageSize": "Items per page"
},
"time": {
"today": "Today",
"yesterday": "Yesterday"
@@ -133,4 +145,4 @@
"sendMessageFailed": "Failed to send message, please try again",
"createSessionFailed": "Failed to create session, please refresh the page"
}
}
}
@@ -100,6 +100,18 @@
"noSessions": "该项目暂无对话",
"confirmDelete": "确定要删除项目 \"{title}\" 吗?项目中的对话不会被删除。"
},
"search": {
"title": "搜索",
"placeholder": "输入关键词搜索标题或内容",
"hint": "输入关键词开始搜索",
"noResults": "没有找到匹配的对话",
"matchTitle": "标题匹配",
"matchContent": "内容匹配",
"matchPosition": "匹配位置",
"createdAt": "创建",
"updatedAt": "更新",
"pageSize": "每页条数"
},
"time": {
"today": "今天",
"yesterday": "昨天"
@@ -135,4 +147,4 @@
"sendMessageFailed": "发送消息失败,请重试",
"createSessionFailed": "创建会话失败,请刷新页面重试"
}
}
}
+112
View File
@@ -0,0 +1,112 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import axios from 'axios';
export interface ChatSearchResult {
session_id: string;
title: string | null;
match_field: 'title' | 'content';
match_index: number;
match_length: number;
snippet: string;
snippet_start: number;
created_at: string;
updated_at: string;
}
interface ChatSearchPagination {
page: number;
page_size: number;
total: number;
total_pages: number;
}
const defaultPagination: ChatSearchPagination = {
page: 1,
page_size: 10,
total: 0,
total_pages: 1
};
export const useChatSearchStore = defineStore('chatSearch', () => {
const active = ref(false);
const query = ref('');
const results = ref<ChatSearchResult[]>([]);
const pagination = ref<ChatSearchPagination>({ ...defaultPagination });
const isLoading = ref(false);
const searchPerformed = ref(false);
const contextLength = ref(40);
function openSearch() {
active.value = true;
}
function closeSearch() {
active.value = false;
}
async function search() {
const trimmedQuery = query.value.trim();
if (!trimmedQuery) {
results.value = [];
pagination.value = { ...defaultPagination };
searchPerformed.value = false;
return;
}
searchPerformed.value = true;
isLoading.value = true;
try {
const response = await axios.get('/api/chat/search', {
params: {
query: trimmedQuery,
page: pagination.value.page,
page_size: pagination.value.page_size,
context: contextLength.value
}
});
const data = response.data?.data || {};
results.value = data.results || [];
pagination.value = data.pagination || { ...defaultPagination };
} catch (error) {
console.error('Search sessions failed:', error);
results.value = [];
} finally {
isLoading.value = false;
}
}
async function setPage(page: number) {
pagination.value.page = page;
await search();
}
async function setPageSize(pageSize: number) {
pagination.value.page_size = pageSize;
pagination.value.page = 1;
await search();
}
async function runNewSearch() {
pagination.value.page = 1;
await search();
}
return {
active,
query,
results,
pagination,
isLoading,
searchPerformed,
contextLength,
openSearch,
closeSearch,
search,
setPage,
setPageSize,
runNewSearch
};
});