feat: search
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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": "创建会话失败,请刷新页面重试"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user