Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c20cb209d0 |
@@ -203,6 +203,23 @@ class BaseDatabase(abc.ABC):
|
|||||||
"""Get platform message history for a specific user."""
|
"""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
|
@abc.abstractmethod
|
||||||
async def get_platform_message_history_by_id(
|
async def get_platform_message_history_by_id(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import threading
|
import threading
|
||||||
import typing as T
|
import typing as T
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
@@ -483,6 +484,144 @@ class SQLiteDatabase(BaseDatabase):
|
|||||||
result = await session.execute(query.offset(offset).limit(page_size))
|
result = await session.execute(query.offset(offset).limit(page_size))
|
||||||
return result.scalars().all()
|
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(
|
async def get_platform_message_history_by_id(
|
||||||
self, message_id: int
|
self, message_id: int
|
||||||
) -> PlatformMessageHistory | None:
|
) -> PlatformMessageHistory | None:
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class ChatRoute(Route):
|
|||||||
"POST",
|
"POST",
|
||||||
self.update_session_display_name,
|
self.update_session_display_name,
|
||||||
),
|
),
|
||||||
|
"/chat/search": ("GET", self.search_sessions),
|
||||||
"/chat/get_file": ("GET", self.get_file),
|
"/chat/get_file": ("GET", self.get_file),
|
||||||
"/chat/get_attachment": ("GET", self.get_attachment),
|
"/chat/get_attachment": ("GET", self.get_attachment),
|
||||||
"/chat/post_file": ("POST", self.post_file),
|
"/chat/post_file": ("POST", self.post_file),
|
||||||
@@ -63,6 +64,35 @@ class ChatRoute(Route):
|
|||||||
|
|
||||||
self.running_convs: dict[str, bool] = {}
|
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):
|
async def get_file(self):
|
||||||
filename = request.args.get("filename")
|
filename = request.args.get("filename")
|
||||||
if not filename:
|
if not filename:
|
||||||
@@ -731,6 +761,57 @@ class ChatRoute(Route):
|
|||||||
|
|
||||||
return Response().ok(data=sessions_data).__dict__
|
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):
|
async def get_session(self):
|
||||||
"""Get session information and message history by session_id."""
|
"""Get session information and message history by session_id."""
|
||||||
session_id = request.args.get("session_id")
|
session_id = request.args.get("session_id")
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
@createProject="showCreateProjectDialog"
|
@createProject="showCreateProjectDialog"
|
||||||
@editProject="showEditProjectDialog"
|
@editProject="showEditProjectDialog"
|
||||||
@deleteProject="handleDeleteProject"
|
@deleteProject="handleDeleteProject"
|
||||||
|
@openSearch="handleOpenSearch"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 右侧聊天内容区域 -->
|
<!-- 右侧聊天内容区域 -->
|
||||||
@@ -42,65 +43,99 @@
|
|||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 面包屑导航 -->
|
<ChatSearchView
|
||||||
<div v-if="currentSessionProject && messages && messages.length > 0" class="breadcrumb-container">
|
v-if="isSearchActive"
|
||||||
<div class="breadcrumb-content">
|
@close="handleCloseSearch"
|
||||||
<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])"
|
@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>
|
<template v-else>
|
||||||
<WelcomeView
|
<!-- 面包屑导航 -->
|
||||||
v-else
|
<div v-if="currentSessionProject && messages && messages.length > 0" class="breadcrumb-container">
|
||||||
:isLoading="isLoadingMessages"
|
<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
|
<ChatInput
|
||||||
|
v-if="currSessionId && !selectedProjectId"
|
||||||
v-model:prompt="prompt"
|
v-model:prompt="prompt"
|
||||||
:stagedImagesUrl="stagedImagesUrl"
|
:stagedImagesUrl="stagedImagesUrl"
|
||||||
:stagedAudioUrl="stagedAudioUrl"
|
:stagedAudioUrl="stagedAudioUrl"
|
||||||
@@ -124,34 +159,7 @@
|
|||||||
@openLiveMode="openLiveMode"
|
@openLiveMode="openLiveMode"
|
||||||
ref="chatInputRef"
|
ref="chatInputRef"
|
||||||
/>
|
/>
|
||||||
</WelcomeView>
|
</template>
|
||||||
|
|
||||||
<!-- 输入区域 -->
|
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
@@ -200,6 +208,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
import { useRouter, useRoute } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
import { useCustomizerStore } from '@/stores/customizer';
|
import { useCustomizerStore } from '@/stores/customizer';
|
||||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
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 ChatInput from '@/components/chat/ChatInput.vue';
|
||||||
import ProjectDialog from '@/components/chat/ProjectDialog.vue';
|
import ProjectDialog from '@/components/chat/ProjectDialog.vue';
|
||||||
import ProjectView from '@/components/chat/ProjectView.vue';
|
import ProjectView from '@/components/chat/ProjectView.vue';
|
||||||
|
import ChatSearchView from '@/components/chat/ChatSearchView.vue';
|
||||||
import WelcomeView from '@/components/chat/WelcomeView.vue';
|
import WelcomeView from '@/components/chat/WelcomeView.vue';
|
||||||
import RefsSidebar from '@/components/chat/message_list_comps/RefsSidebar.vue';
|
import RefsSidebar from '@/components/chat/message_list_comps/RefsSidebar.vue';
|
||||||
import LiveMode from '@/components/chat/LiveMode.vue';
|
import LiveMode from '@/components/chat/LiveMode.vue';
|
||||||
@@ -219,6 +229,7 @@ import { useMediaHandling } from '@/composables/useMediaHandling';
|
|||||||
import { useProjects } from '@/composables/useProjects';
|
import { useProjects } from '@/composables/useProjects';
|
||||||
import type { Project } from '@/components/chat/ProjectList.vue';
|
import type { Project } from '@/components/chat/ProjectList.vue';
|
||||||
import { useRecording } from '@/composables/useRecording';
|
import { useRecording } from '@/composables/useRecording';
|
||||||
|
import { useChatSearchStore } from '@/stores/chatSearch';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
chatboxMode?: boolean;
|
chatboxMode?: boolean;
|
||||||
@@ -233,6 +244,8 @@ const route = useRoute();
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { tm } = useModuleI18n('features/chat');
|
const { tm } = useModuleI18n('features/chat');
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const chatSearchStore = useChatSearchStore();
|
||||||
|
const { active: isSearchActive } = storeToRefs(chatSearchStore);
|
||||||
|
|
||||||
// UI 状态
|
// UI 状态
|
||||||
const isMobile = ref(false);
|
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;
|
if (!sessionIds[0]) return;
|
||||||
|
|
||||||
// 退出项目视图
|
// 退出项目视图
|
||||||
selectedProjectId.value = null;
|
selectedProjectId.value = null;
|
||||||
projectSessions.value = [];
|
projectSessions.value = [];
|
||||||
|
if (shouldCloseSearch) {
|
||||||
|
chatSearchStore.closeSearch();
|
||||||
|
}
|
||||||
|
|
||||||
// 立即更新选中状态,避免需要点击两次
|
// 立即更新选中状态,避免需要点击两次
|
||||||
currSessionId.value = sessionIds[0];
|
currSessionId.value = sessionIds[0];
|
||||||
@@ -482,6 +498,7 @@ function handleNewChat() {
|
|||||||
// 退出项目视图
|
// 退出项目视图
|
||||||
selectedProjectId.value = null;
|
selectedProjectId.value = null;
|
||||||
projectSessions.value = [];
|
projectSessions.value = [];
|
||||||
|
chatSearchStore.closeSearch();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteConversation(sessionId: string) {
|
async function handleDeleteConversation(sessionId: string) {
|
||||||
@@ -500,6 +517,7 @@ async function handleSelectProject(projectId: string) {
|
|||||||
const sessions = await getProjectSessions(projectId);
|
const sessions = await getProjectSessions(projectId);
|
||||||
projectSessions.value = sessions;
|
projectSessions.value = sessions;
|
||||||
messages.value = [];
|
messages.value = [];
|
||||||
|
chatSearchStore.closeSearch();
|
||||||
|
|
||||||
// 清空当前会话ID,准备在项目中创建新对话
|
// 清空当前会话ID,准备在项目中创建新对话
|
||||||
currSessionId.value = '';
|
currSessionId.value = '';
|
||||||
@@ -573,6 +591,17 @@ function closeLiveMode() {
|
|||||||
liveModeOpen.value = false;
|
liveModeOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleOpenSearch() {
|
||||||
|
chatSearchStore.openSearch();
|
||||||
|
if (isMobile.value) {
|
||||||
|
closeMobileSidebar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCloseSearch() {
|
||||||
|
chatSearchStore.closeSearch();
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSendMessage() {
|
async function handleSendMessage() {
|
||||||
// 只有引用不能发送,必须有输入内容
|
// 只有引用不能发送,必须有输入内容
|
||||||
if (!prompt.value.trim() && stagedFiles.value.length === 0 && !stagedAudioUrl.value) {
|
if (!prompt.value.trim() && stagedFiles.value.length === 0 && !stagedAudioUrl.value) {
|
||||||
@@ -647,7 +676,7 @@ watch(
|
|||||||
if (sessions.value.length > 0) {
|
if (sessions.value.length > 0) {
|
||||||
const session = sessions.value.find(s => s.session_id === pathSessionId);
|
const session = sessions.value.find(s => s.session_id === pathSessionId);
|
||||||
if (session) {
|
if (session) {
|
||||||
handleSelectConversation([pathSessionId]);
|
handleSelectConversation([pathSessionId], !isSearchActive.value);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
pendingSessionId.value = pathSessionId;
|
pendingSessionId.value = pathSessionId;
|
||||||
@@ -664,13 +693,13 @@ watch(sessions, (newSessions) => {
|
|||||||
const session = newSessions.find(s => s.session_id === pendingSessionId.value);
|
const session = newSessions.find(s => s.session_id === pendingSessionId.value);
|
||||||
if (session) {
|
if (session) {
|
||||||
selectedSessions.value = [pendingSessionId.value];
|
selectedSessions.value = [pendingSessionId.value];
|
||||||
handleSelectConversation([pendingSessionId.value]);
|
handleSelectConversation([pendingSessionId.value], !isSearchActive.value);
|
||||||
pendingSessionId.value = null;
|
pendingSessionId.value = null;
|
||||||
}
|
}
|
||||||
} else if (!currSessionId.value && newSessions.length > 0) {
|
} else if (!currSessionId.value && newSessions.length > 0) {
|
||||||
const firstSession = newSessions[0];
|
const firstSession = newSessions[0];
|
||||||
selectedSessions.value = [firstSession.session_id];
|
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>
|
</v-btn>
|
||||||
</div>
|
</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;">
|
<div style="padding: 8px; opacity: 0.6;">
|
||||||
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
|
<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>
|
v-if="!sidebarCollapsed || isMobile" prepend-icon="mdi-square-edit-outline">{{ tm('actions.newChat') }}</v-btn>
|
||||||
@@ -178,6 +187,7 @@ const emit = defineEmits<{
|
|||||||
createProject: [];
|
createProject: [];
|
||||||
editProject: [project: Project];
|
editProject: [project: Project];
|
||||||
deleteProject: [projectId: string];
|
deleteProject: [projectId: string];
|
||||||
|
openSearch: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -264,6 +274,13 @@ function handleDeleteConversation(session: Session) {
|
|||||||
padding: 8px 16px !important;
|
padding: 8px 16px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-chat-btn {
|
||||||
|
justify-content: flex-start;
|
||||||
|
background-color: transparent !important;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 8px 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
.conversation-item {
|
.conversation-item {
|
||||||
/* margin-bottom: 4px; */
|
/* margin-bottom: 4px; */
|
||||||
border-radius: 20px !important;
|
border-radius: 20px !important;
|
||||||
@@ -359,4 +376,3 @@ function handleDeleteConversation(session: Session) {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,18 @@
|
|||||||
"noSessions": "No conversations in this project",
|
"noSessions": "No conversations in this project",
|
||||||
"confirmDelete": "Are you sure you want to delete project \"{title}\"? Conversations in this project will not be deleted."
|
"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": {
|
"time": {
|
||||||
"today": "Today",
|
"today": "Today",
|
||||||
"yesterday": "Yesterday"
|
"yesterday": "Yesterday"
|
||||||
@@ -133,4 +145,4 @@
|
|||||||
"sendMessageFailed": "Failed to send message, please try again",
|
"sendMessageFailed": "Failed to send message, please try again",
|
||||||
"createSessionFailed": "Failed to create session, please refresh the page"
|
"createSessionFailed": "Failed to create session, please refresh the page"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,18 @@
|
|||||||
"noSessions": "该项目暂无对话",
|
"noSessions": "该项目暂无对话",
|
||||||
"confirmDelete": "确定要删除项目 \"{title}\" 吗?项目中的对话不会被删除。"
|
"confirmDelete": "确定要删除项目 \"{title}\" 吗?项目中的对话不会被删除。"
|
||||||
},
|
},
|
||||||
|
"search": {
|
||||||
|
"title": "搜索",
|
||||||
|
"placeholder": "输入关键词搜索标题或内容",
|
||||||
|
"hint": "输入关键词开始搜索",
|
||||||
|
"noResults": "没有找到匹配的对话",
|
||||||
|
"matchTitle": "标题匹配",
|
||||||
|
"matchContent": "内容匹配",
|
||||||
|
"matchPosition": "匹配位置",
|
||||||
|
"createdAt": "创建",
|
||||||
|
"updatedAt": "更新",
|
||||||
|
"pageSize": "每页条数"
|
||||||
|
},
|
||||||
"time": {
|
"time": {
|
||||||
"today": "今天",
|
"today": "今天",
|
||||||
"yesterday": "昨天"
|
"yesterday": "昨天"
|
||||||
@@ -135,4 +147,4 @@
|
|||||||
"sendMessageFailed": "发送消息失败,请重试",
|
"sendMessageFailed": "发送消息失败,请重试",
|
||||||
"createSessionFailed": "创建会话失败,请刷新页面重试"
|
"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