Compare commits

...

6 Commits

Author SHA1 Message Date
Soulter c20cb209d0 feat: search 2026-01-27 01:35:47 +08:00
Soulter b04dad1fd2 docs: add AGENTS.md 2026-01-26 21:21:26 +08:00
xunxiing 3765dd46f7 fix: gemini toolcall repetition call (#4686)
* 修复gemini toolcall 的名称导致的循环调用

* Apply suggestions from code review

Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>

* Refactor function response creation for tool role

Refactor function response handling for tool role to ensure proper ID injection.

---------

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2026-01-26 17:14:58 +08:00
Soulter 17d642efc9 fix: update configuration metadata hints for segmented reply settings 2026-01-25 14:28:07 +08:00
時壹 4839cc6119 feat: add configurable Dashboard API access log toggle (#4661)
* feat: add configurable Dashboard API access log toggle

* chore: remove Dashboard API access log configuration
2026-01-24 16:31:23 +08:00
搁浅 127e8c31c2 feat: add confirmation dialog for update all plugins button to prevent accidental clicks #4300 (#4658) 2026-01-24 16:08:47 +08:00
18 changed files with 966 additions and 122 deletions
+33
View File
@@ -0,0 +1,33 @@
## Setup commands
### Core
```
uv sync
uv run main.py
```
Exposed an API server on `http://localhost:6185` by default.
### Dashboard(WebUI)
```
cd dashboard
pnpm install # First time only. Use npm install -g pnpm if pnpm is not installed.
pnpm dev
```
Runs on `http://localhost:3000` by default.
## Dev environment tips
1. When modifying the WebUI, be sure to maintain componentization and clean code. Avoid duplicate code.
2. Do not add any report files such as xxx_SUMMARY.md.
3. After finishing, use `ruff format .` and `ruff check .` to format and check the code.
4. When committing, ensure to use conventional commits messages, such as `feat: add new agent for data analysis` or `fix: resolve bug in provider manager`.
5. Use English for all new comments.
## PR instructions
1. Title format: use conventional commit messages
2. Use English to write PR title and descriptions.
+6 -8
View File
@@ -166,6 +166,7 @@ DEFAULT_CONFIG = {
"jwt_secret": "",
"host": "0.0.0.0",
"port": 6185,
"disable_access_log": True,
},
"platform": [],
"platform_specific": {
@@ -773,27 +774,21 @@ CONFIG_METADATA_2 = {
"interval_method": {
"type": "string",
"options": ["random", "log"],
"hint": "分段回复的间隔时间计算方法。random 为随机时间,log 为根据消息长度计算,$y=log_<log_base>(x)$x为字数,y的单位为秒。",
},
"interval": {
"type": "string",
"hint": "`random` 方法用。每一段回复的间隔时间,格式为 `最小时间,最大时间`。如 `0.75,2.5`",
},
"log_base": {
"type": "float",
"hint": "`log` 方法用。对数函数的底数。默认为 2.6",
},
"words_count_threshold": {
"type": "int",
"hint": "分段回复的字数上限。只有字数小于此值的消息才会被分段,超过此值的长消息将直接发送(不分段)。默认为 150",
},
"regex": {
"type": "string",
"hint": "用于分隔一段消息。默认情况下会根据句号、问号等标点符号分隔。re.findall(r'<regex>', text)",
},
"content_cleanup_rule": {
"type": "string",
"hint": "移除分段后的内容中的指定的内容。支持正则表达式。如填写 `[。?!]` 将移除所有的句号、问号、感叹号。re.sub(r'<regex>', '', text)",
},
},
},
@@ -3045,7 +3040,8 @@ CONFIG_METADATA_3 = {
"type": "bool",
},
"platform_settings.segmented_reply.interval_method": {
"description": "间隔方法",
"description": "间隔方法",
"hint": "random 为随机时间,log 为根据消息长度计算,$y=log_<log_base>(x)$x为字数,y的单位为秒。",
"type": "string",
"options": ["random", "log"],
},
@@ -3060,13 +3056,14 @@ CONFIG_METADATA_3 = {
"platform_settings.segmented_reply.log_base": {
"description": "对数底数",
"type": "float",
"hint": "对数间隔的底数,默认为 2.0。取值范围为 1.0-10.0。",
"hint": "对数间隔的底数,默认为 2.6。取值范围为 1.0-10.0。",
"condition": {
"platform_settings.segmented_reply.interval_method": "log",
},
},
"platform_settings.segmented_reply.words_count_threshold": {
"description": "分段回复字数阈值",
"hint": "分段回复的字数上限。只有字数小于此值的消息才会被分段,超过此值的长消息将直接发送(不分段)。默认为 150",
"type": "int",
},
"platform_settings.segmented_reply.split_mode": {
@@ -3077,6 +3074,7 @@ CONFIG_METADATA_3 = {
},
"platform_settings.segmented_reply.regex": {
"description": "分段正则表达式",
"hint": "用于分隔一段消息。默认情况下会根据句号、问号等标点符号分隔。如填写 `[。?!]` 将移除所有的句号、问号、感叹号。re.findall(r'<regex>', text)",
"type": "string",
"condition": {
"platform_settings.segmented_reply.split_mode": "regex",
+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:
+12 -9
View File
@@ -382,15 +382,18 @@ class ProviderGoogleGenAI(Provider):
append_or_extend(gemini_contents, parts, types.ModelContent)
elif role == "tool" and not native_tool_enabled:
parts = [
types.Part.from_function_response(
name=message["tool_call_id"],
response={
"name": message["tool_call_id"],
"content": message["content"],
},
),
]
func_name = message.get("name", message["tool_call_id"])
part = types.Part.from_function_response(
name=func_name,
response={
"name": func_name,
"content": message["content"],
},
)
if part.function_response:
part.function_response.id = message["tool_call_id"]
parts = [part]
append_or_extend(gemini_contents, parts, types.UserContent)
if gemini_contents and isinstance(gemini_contents[0], types.ModelContent):
+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")
+18 -5
View File
@@ -7,6 +7,8 @@ from typing import cast
import jwt
import psutil
from flask.json.provider import DefaultJSONProvider
from hypercorn.asyncio import serve
from hypercorn.config import Config as HyperConfig
from psutil._common import addr as psutil_addr
from quart import Quart, g, jsonify, request
from quart.logging import default_handler
@@ -244,11 +246,22 @@ class AstrBotDashboard:
logger.info(display)
return self.app.run_task(
host=host,
port=port,
shutdown_trigger=self.shutdown_trigger,
)
# 配置 Hypercorn
config = HyperConfig()
config.bind = [f"{host}:{port}"]
# 根据配置决定是否禁用访问日志
disable_access_log = self.core_lifecycle.astrbot_config.get(
"dashboard", {}
).get("disable_access_log", True)
if disable_access_log:
config.accesslog = None
else:
# 启用访问日志,使用简洁格式
config.accesslog = "-"
config.access_log_format = "%(h)s %(r)s %(s)s %(b)s %(D)s"
return serve(self.app, config, shutdown_trigger=self.shutdown_trigger)
async def shutdown_trigger(self):
await self.shutdown_event.wait()
+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"
}
}
}
@@ -447,7 +447,8 @@
"description": "Segment Only LLM Results"
},
"interval_method": {
"description": "Interval Method"
"description": "Interval Method",
"hint": "random 为随机时间,log 为根据消息长度计算,$y=log_<log_base>(x)$x为字数,y的单位为秒。"
},
"interval": {
"description": "Random Interval Time",
@@ -455,13 +456,15 @@
},
"log_base": {
"description": "Logarithm Base",
"hint": "Base for logarithmic intervals, defaults to 2.0. Value range: 1.0-10.0."
"hint": "Base for logarithmic intervals, defaults to 2.6. Value range: 1.0-10.0."
},
"words_count_threshold": {
"description": "Segmented Reply Word Count Threshold"
"description": "Segmented Reply Word Count Threshold",
"hint": "Segmented reply word count threshold. Only messages with less than this number of words will be segmented, and messages with more than this number of words will be sent directly (not segmented)."
},
"split_mode": {
"description": "Split Mode",
"hint": "Used to segment a message. By default, it will be separated by punctuation marks like period, question mark, etc. For example, filling `[。?!]` will remove all periods, question marks, and exclamation marks. re.findall(r'<regex>', text)",
"labels": [
"Regex",
"Words List"
@@ -151,6 +151,11 @@
"title": "No New Version Detected",
"message": "No new version detected for this plugin. Do you want to force reinstall? This will pull the latest code from the remote repository.",
"confirm": "Force Update"
},
"updateAllConfirm": {
"title": "Confirm Update All Plugins",
"message": "Are you sure you want to update all {count} plugins? This operation may take some time.",
"confirm": "Confirm Update"
}
},
"messages": {
@@ -217,4 +222,4 @@
"pluginChangelog": {
"menuTitle": "View Changelog"
}
}
}
@@ -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": "创建会话失败,请刷新页面重试"
}
}
}
@@ -445,7 +445,8 @@
"description": "仅对 LLM 结果分段"
},
"interval_method": {
"description": "间隔方法"
"description": "间隔方法",
"hint": "random 为随机时间,log 为根据消息长度计算,$y=log_<log_base>(x)$x为字数,y的单位为秒。"
},
"interval": {
"description": "随机间隔时间",
@@ -453,13 +454,15 @@
},
"log_base": {
"description": "对数底数",
"hint": "对数间隔的底数,默认为 2.0。取值范围为 1.0-10.0。"
"hint": "对数间隔的底数,默认为 2.6。取值范围为 1.0-10.0。"
},
"words_count_threshold": {
"description": "分段回复字数阈值"
"description": "分段回复字数阈值",
"hint": "分段回复的字数上限。只有字数小于此值的消息才会被分段,超过此值的长消息将直接发送(不分段),默认为 150。"
},
"split_mode": {
"description": "分段模式",
"hint": "用于分隔一段消息。默认情况下会根据句号、问号等标点符号分隔。如填写 `[。?!]` 将移除所有的句号、问号、感叹号。re.findall(r'<regex>', text)",
"labels": [
"正则表达式",
"分段词列表"
@@ -151,6 +151,11 @@
"title": "未检测到新版本",
"message": "当前插件未检测到新版本,是否强制重新安装?这将从远程仓库拉取最新代码。",
"confirm": "强制更新"
},
"updateAllConfirm": {
"title": "确认更新全部插件",
"message": "确定要更新全部 {count} 个插件吗?此操作可能需要一些时间。",
"confirm": "确认更新"
}
},
"messages": {
@@ -217,4 +222,4 @@
"pluginChangelog": {
"menuTitle": "查看更新日志"
}
}
}
+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
};
});
+51 -1
View File
@@ -92,6 +92,11 @@ const forceUpdateDialog = reactive({
extensionName: "",
});
// 更新全部插件确认对话框
const updateAllConfirmDialog = reactive({
show: false,
});
// 插件更新日志对话框(复用 ReadmeDialog
const changelogDialog = reactive({
show: false,
@@ -471,6 +476,23 @@ const updateExtension = async (extension_name, forceUpdate = false) => {
};
// 确认强制更新
// 显示更新全部插件确认对话框
const showUpdateAllConfirm = () => {
if (updatableExtensions.value.length === 0) return;
updateAllConfirmDialog.show = true;
};
// 确认更新全部插件
const confirmUpdateAll = () => {
updateAllConfirmDialog.show = false;
updateAllExtensions();
};
// 取消更新全部插件
const cancelUpdateAll = () => {
updateAllConfirmDialog.show = false;
};
const confirmForceUpdate = () => {
const name = forceUpdateDialog.extensionName;
forceUpdateDialog.show = false;
@@ -1128,7 +1150,7 @@ watch(isListView, (newVal) => {
variant="tonal"
:disabled="updatableExtensions.length === 0"
:loading="updatingAll"
@click="updateAllExtensions"
@click="showUpdateAllConfirm"
>
<v-icon>mdi-update</v-icon>
{{ tm("buttons.updateAll") }}
@@ -2279,6 +2301,34 @@ watch(isListView, (newVal) => {
@confirm="handleUninstallConfirm"
/>
<!-- 更新全部插件确认对话框 -->
<v-dialog v-model="updateAllConfirmDialog.show" max-width="420">
<v-card class="rounded-lg">
<v-card-title class="d-flex align-center pa-4">
<v-icon color="warning" class="mr-2">mdi-update</v-icon>
{{ tm("dialogs.updateAllConfirm.title") }}
</v-card-title>
<v-card-text>
<p class="text-body-1">
{{ tm("dialogs.updateAllConfirm.message", { count: updatableExtensions.length }) }}
</p>
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn
variant="text"
@click="cancelUpdateAll"
>{{ tm("buttons.cancel") }}</v-btn>
<v-btn
color="warning"
variant="flat"
@click="confirmUpdateAll"
>{{ tm("dialogs.updateAllConfirm.confirm") }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 指令冲突提示对话框 -->
<v-dialog v-model="conflictDialog.show" max-width="420">
<v-card class="rounded-lg">