Compare commits

..

24 Commits

Author SHA1 Message Date
Soulter c0c967390c chore: remove japanese prompt 2026-01-21 16:03:50 +08:00
Soulter aec5f4e9e6 perf: live mode entry 2026-01-21 15:59:24 +08:00
Soulter 991b85e0c0 Merge branch 'master' into feat/live-mode 2026-01-21 15:49:42 +08:00
Ruochen Pan 473d258b69 feat: implement persona folder for advanced persona management (#4443)
* feat(db): add persona folder management for hierarchical organization

Implement hierarchical folder structure for organizing personas:
- Add PersonaFolder model with recursive parent-child relationships
- Add folder_id and sort_order fields to Persona model
- Implement CRUD operations for persona folders in database layer
- Add migration support for existing databases
- Extend PersonaManager with folder management methods
- Add dashboard API routes for folder operations

* feat(persona): add batch sort order update endpoint for personas and folders

Add new API endpoint POST /persona/reorder to batch update sort_order
for both personas and folders. This enables drag-and-drop reordering
in the dashboard UI.

Changes:
- Add abstract batch_update_sort_order method to BaseDatabase
- Implement batch_update_sort_order in SQLiteDatabase
- Add batch_update_sort_order to PersonaManager with cache refresh
- Add reorder_items route handler with input validation

* feat(persona): add folder_id and sort_order params to persona creation

Extend persona creation flow to support folder placement and ordering:
- Add folder_id and sort_order parameters to insert_persona in db layer
- Update PersonaManager.create_persona to accept and pass folder params
- Add get_folder_detail API endpoint for retrieving folder information
- Include folder_id and sort_order in persona creation response
- Add session flush/refresh to return complete persona object

* feat(dashboard): implement persona folder management UI

- Add folder management system with tree view and breadcrumbs
- Implement create, rename, delete, and move operations for folders
- Add drag-and-drop support for organizing personas and folders
- Create new PersonaManager component and Pinia store for state management
- Refactor PersonaPage to support hierarchical structure
- Update locale files with folder-related translations
- Handle empty parent_id correctly in backend route

* feat(dashboard): centralize folder expansion state in persona store

Move folder expansion logic from local component state to global Pinia
store to persist expansion state.
- Add `expandedFolderIds` state and toggle actions to `personaStore`
- Update `FolderTreeNode` to use store state instead of local data
- Automatically navigate to target folder after moving a persona

* feat(dashboard): add reusable folder management component library

Extract folder management UI into reusable base components and create
persona-specific wrapper components that integrate with personaStore.

- Add base folder components (tree, breadcrumb, card, dialogs) with
  customizable labels for i18n support
- Create useFolderManager composable for folder state management
- Implement drag-and-drop support for moving personas between folders
- Add persona-specific wrapper components connecting to personaStore
- Reorganize PersonaManager into views/persona directory structure
- Include comprehensive README documentation for component usage

* refactor(dashboard): remove legacy persona folder management components

Remove deprecated persona folder management Vue components that have been
superseded by the new reusable folder management component library.

Deleted components:
- CreateFolderDialog.vue
- FolderBreadcrumb.vue
- FolderCard.vue
- FolderTree.vue
- FolderTreeNode.vue
- MoveTargetNode.vue
- MoveToFolderDialog.vue
- PersonaCard.vue
- PersonaManager.vue

These components are replaced by the centralized folder management
implementation introduced in commit 3fbb3db2.

* fix(dashboard): add delayed skeleton loading to prevent UI flicker

Implement a 150ms delay before showing the skeleton loader in
PersonaManager to prevent visual flicker during fast loading operations.

- Add showSkeleton state with timer-based delay mechanism
- Use v-fade-transition for smooth skeleton visibility transitions
- Clean up timer on component unmount to prevent memory leaks
- Only display skeleton when loading exceeds threshold duration

* feat(dashboard): add generic folder item selector component for persona selection

Introduce BaseFolderItemSelector.vue as a reusable component for selecting
items within folder hierarchies. Refactor PersonaSelector to use this new
base component instead of its previous flat list implementation.

Changes:
- Add BaseFolderItemSelector with folder tree navigation and item selection
- Extend folder types with SelectableItem and FolderItemSelectorLabels
- Refactor PersonaSelector to leverage the new base component
- Add i18n translations for rootFolder and emptyFolder labels

* feat(persona): add tree-view display for persona list command

Add hierarchical folder tree output for the persona list command,
showing personas organized by folders with visual tree connectors.

- Add _build_tree_output method for recursive tree structure rendering
- Display folders with 📁 icon and personas with 👤 icon
- Show root-level personas separately from folder contents
- Include total persona count in output

* refactor(persona): simplify tree-view output with shorter indentation lines

Replace complex tree connector logic with simpler depth-based indentation
using "│ " prefix. Remove unnecessary parameters (prefix, is_last) and
computed variables (has_content, total_items, item_idx) in favor of a
cleaner depth-based approach.

* feat(dashboard): add duplicate persona ID validation in create form

Add frontend validation to prevent creating personas with duplicate IDs.
Load existing persona IDs when opening the create form and validate
against them in real-time.

- Add existingPersonaIds array and loadExistingPersonaIds method
- Add validation rule to check for duplicate persona IDs
- Add i18n messages for duplicate ID error (en-US and zh-CN)
- Fix minLength validation to require at least 1 character

* i18n(persona): add createButton translation key for folder dialog

Move create button label to folder-specific translation path
instead of using generic buttons.create key.

* feat(persona): show target folder name in persona creation dialog

Add visual feedback showing which folder a new persona will be created in.

- Add info alert in PersonaForm displaying the target folder name
- Pass currentFolderName prop from PersonaManager and PersonaSelector
- Add recursive findFolderName helper to resolve folder ID to name
- Add i18n translations for createInFolder and rootFolder labels

* style:format code

* fix: remove 'persistent' attribute from dialog components

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-01-21 15:45:35 +08:00
jiangman202506 93cc4cebe6 fix: streaming response for DingTalk (#4590)
closes: #4384

* #4384 钉钉消息回复卡片模板

* chore: ruff format

* chore: ruff format

---------

Co-authored-by: ManJiang <man.jiang@jg-robust.com>
Co-authored-by: Soulter <905617992@qq.com>
2026-01-21 15:45:35 +08:00
Clhikari 4d28de6b4a feat: add file drag upload feature for ChatUI (#4583)
* feat(chat): add drag-drop upload and fix batch file upload

* style(chat): adjust drop overlay to only cover input container
2026-01-21 15:45:35 +08:00
Anima-IGCenter e7540b80ad perf: T2I template editor preview (#4574) 2026-01-21 15:45:09 +08:00
Soulter 97ee36b422 fix: ensure embedding dimensions are returned as integers in providers (#4547)
* fix: ensure embedding dimensions are returned as integers in providers

* chore: ruff format
2026-01-21 15:45:09 +08:00
Soulter 242cf8745b chore: bump version to 4.12.3 2026-01-21 15:45:09 +08:00
Soulter 625401a4d0 refactor: update event types for LLM tool usage and response 2026-01-21 15:45:09 +08:00
Soulter c95bbd11ae docs: update 4.12.2 changelog 2026-01-21 15:45:09 +08:00
Soulter 831907b22a chore: bump version to 4.12.2 2026-01-21 15:45:09 +08:00
Soulter ad2dae3a8c fix: clarify logic for skipping initial system messages in conversation 2026-01-21 15:45:09 +08:00
Soulter 92de1061aa feat: skip saving head system messages in history (#4538)
* feat: skip saving the first system message in history

* fix: rename variable for clarity in system message handling

* fix: update logic to skip all system messages until the first non-system message
2026-01-21 15:45:09 +08:00
Soulter ddff652003 chore: update readme
Added '自动压缩对话' feature and updated features list.
2026-01-21 15:45:09 +08:00
Soulter fa4df28c22 feat: nervous 2026-01-18 17:07:19 +08:00
Soulter 06fa7be63e feat: eyes 2026-01-18 10:53:04 +08:00
Soulter e92b103fd0 feat: add metrics 2026-01-17 21:44:13 +08:00
Soulter dcd699d733 feat: enhance live mode audio processing and text handling 2026-01-17 17:11:31 +08:00
Soulter 2e53d8116e feat: genie tts 2026-01-17 16:27:20 +08:00
Soulter 856d3496fa feat: enhance audio processing and metrics display in live mode 2026-01-17 15:35:02 +08:00
Soulter 19e6253d5d feat: metrics 2026-01-17 15:34:46 +08:00
Soulter 1d426a7458 chore: remove 2026-01-17 14:44:36 +08:00
Soulter c0846bc789 feat: astr live 2026-01-17 14:41:05 +08:00
32 changed files with 215 additions and 1083 deletions
-33
View File
@@ -1,33 +0,0 @@
## 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.
@@ -11,6 +11,7 @@ from .provider import ProviderCommands
from .setunset import SetUnsetCommands
from .sid import SIDCommand
from .t2i import T2ICommand
from .tool import ToolCommands
from .tts import TTSCommand
__all__ = [
@@ -26,4 +27,5 @@ __all__ = [
"SetUnsetCommands",
"T2ICommand",
"TTSCommand",
"ToolCommands",
]
@@ -0,0 +1,31 @@
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
class ToolCommands:
def __init__(self, context: star.Context):
self.context = context
async def tool_ls(self, event: AstrMessageEvent):
"""查看函数工具列表"""
event.set_result(
MessageEventResult().message("tool 指令在 AstrBot v4.0.0 已经被移除。"),
)
async def tool_on(self, event: AstrMessageEvent, tool_name: str = ""):
"""启用一个函数工具"""
event.set_result(
MessageEventResult().message("tool 指令在 AstrBot v4.0.0 已经被移除。"),
)
async def tool_off(self, event: AstrMessageEvent, tool_name: str = ""):
"""停用一个函数工具"""
event.set_result(
MessageEventResult().message("tool 指令在 AstrBot v4.0.0 已经被移除。"),
)
async def tool_all_off(self, event: AstrMessageEvent):
"""停用所有函数工具"""
event.set_result(
MessageEventResult().message("tool 指令在 AstrBot v4.0.0 已经被移除。"),
)
@@ -13,6 +13,7 @@ from .commands import (
SetUnsetCommands,
SIDCommand,
T2ICommand,
ToolCommands,
TTSCommand,
)
@@ -23,6 +24,7 @@ class Main(star.Star):
self.help_c = HelpCommand(self.context)
self.llm_c = LLMCommands(self.context)
self.tool_c = ToolCommands(self.context)
self.plugin_c = PluginCommands(self.context)
self.admin_c = AdminCommands(self.context)
self.conversation_c = ConversationCommands(self.context)
@@ -45,6 +47,30 @@ class Main(star.Star):
"""开启/关闭 LLM"""
await self.llm_c.llm(event)
@filter.command_group("tool")
def tool(self):
"""函数工具管理"""
@tool.command("ls")
async def tool_ls(self, event: AstrMessageEvent):
"""查看函数工具列表"""
await self.tool_c.tool_ls(event)
@tool.command("on")
async def tool_on(self, event: AstrMessageEvent, tool_name: str):
"""启用一个函数工具"""
await self.tool_c.tool_on(event, tool_name)
@tool.command("off")
async def tool_off(self, event: AstrMessageEvent, tool_name: str):
"""停用一个函数工具"""
await self.tool_c.tool_off(event, tool_name)
@tool.command("off_all")
async def tool_all_off(self, event: AstrMessageEvent):
"""停用所有函数工具"""
await self.tool_c.tool_all_off(event)
@filter.command_group("plugin")
def plugin(self):
"""插件管理"""
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.12.4"
__version__ = "4.12.3"
+1 -3
View File
@@ -56,10 +56,8 @@ class MainAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
)
# special handle web_search_tavily
platform_name = run_context.context.event.get_platform_name()
if (
platform_name == "webchat"
and tool.name == "web_search_tavily"
tool.name == "web_search_tavily"
and len(run_context.messages) > 0
and tool_result
and len(tool_result.content)
+10 -22
View File
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.12.4"
VERSION = "4.12.3"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [
@@ -166,7 +166,6 @@ DEFAULT_CONFIG = {
"jwt_secret": "",
"host": "0.0.0.0",
"port": 6185,
"disable_access_log": True,
},
"platform": [],
"platform_specific": {
@@ -774,21 +773,27 @@ 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)",
},
},
},
@@ -1186,11 +1191,7 @@ CONFIG_METADATA_2 = {
"type": "genie_tts",
"provider_type": "text_to_speech",
"enable": False,
"genie_character_name": "mika",
"genie_onnx_model_dir": "CharacterModels/v2ProPlus/mika/tts_models",
"genie_language": "Japanese",
"genie_refer_audio_path": "",
"genie_refer_text": "",
"character_name": "mika",
"timeout": 20,
},
"Edge TTS": {
@@ -1409,16 +1410,6 @@ CONFIG_METADATA_2 = {
},
},
"items": {
"genie_onnx_model_dir": {
"description": "ONNX Model Directory",
"type": "string",
"hint": "The directory path containing the ONNX model files",
},
"genie_language": {
"description": "Language",
"type": "string",
"options": ["Japanese", "English", "Chinese"],
},
"provider_source_id": {
"invisible": True,
"type": "string",
@@ -3040,8 +3031,7 @@ CONFIG_METADATA_3 = {
"type": "bool",
},
"platform_settings.segmented_reply.interval_method": {
"description": "间隔方法",
"hint": "random 为随机时间,log 为根据消息长度计算,$y=log_<log_base>(x)$x为字数,y的单位为秒。",
"description": "间隔方法",
"type": "string",
"options": ["random", "log"],
},
@@ -3056,14 +3046,13 @@ CONFIG_METADATA_3 = {
"platform_settings.segmented_reply.log_base": {
"description": "对数底数",
"type": "float",
"hint": "对数间隔的底数,默认为 2.6。取值范围为 1.0-10.0。",
"hint": "对数间隔的底数,默认为 2.0。取值范围为 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": {
@@ -3074,7 +3063,6 @@ 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,23 +203,6 @@ 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,5 +1,4 @@
import asyncio
import json
import threading
import typing as T
from collections.abc import Awaitable, Callable
@@ -484,144 +483,6 @@ 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:
@@ -116,12 +116,8 @@ class InternalAgentSubStage(Stage):
if not provider:
logger.error(f"未找到指定的提供商: {sel_provider}")
return provider
try:
prov = _ctx.get_using_provider(umo=event.unified_msg_origin)
except ValueError as e:
logger.error(f"Error occurred while selecting provider: {e}")
return None
return prov
return _ctx.get_using_provider(umo=event.unified_msg_origin)
async def _get_session_conv(self, event: AstrMessageEvent) -> Conversation:
umo = event.unified_msg_origin
@@ -500,7 +496,6 @@ class InternalAgentSubStage(Stage):
try:
provider = self._select_provider(event)
if provider is None:
logger.info("未找到任何对话模型(提供商),跳过 LLM 请求处理。")
return
if not isinstance(provider, Provider):
logger.error(
@@ -41,7 +41,6 @@ TOOL_CALL_PROMPT = (
"You MUST NOT return an empty response, especially after invoking a tool."
"Before calling any tool, provide a brief explanatory message to the user stating the purpose of the tool call."
"After the tool call is completed, you must briefly summarize the results returned by the tool for the user."
"Keep the role-play and style consistent throughout the conversation."
)
CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT = (
@@ -165,6 +165,7 @@ class WakingCheckStage(Stage):
and handler.handler_module_path
== "astrbot.builtin_stars.builtin_commands.main"
):
logger.debug("skipping builtin command")
continue
# filter 需满足 AND 逻辑关系
@@ -62,44 +62,27 @@ class AiocqhttpAdapter(Platform):
@self.bot.on_request()
async def request(event: Event):
try:
abm = await self.convert_message(event)
if not abm:
return
abm = await self.convert_message(event)
if abm:
await self.handle_msg(abm)
except Exception as e:
logger.exception(f"Handle request message failed: {e}")
return
@self.bot.on_notice()
async def notice(event: Event):
try:
abm = await self.convert_message(event)
if abm:
await self.handle_msg(abm)
except Exception as e:
logger.exception(f"Handle notice message failed: {e}")
return
abm = await self.convert_message(event)
if abm:
await self.handle_msg(abm)
@self.bot.on_message("group")
async def group(event: Event):
try:
abm = await self.convert_message(event)
if abm:
await self.handle_msg(abm)
except Exception as e:
logger.exception(f"Handle group message failed: {e}")
return
abm = await self.convert_message(event)
if abm:
await self.handle_msg(abm)
@self.bot.on_message("private")
async def private(event: Event):
try:
abm = await self.convert_message(event)
if abm:
await self.handle_msg(abm)
except Exception as e:
logger.exception(f"Handle private message failed: {e}")
return
abm = await self.convert_message(event)
if abm:
await self.handle_msg(abm)
@self.bot.on_websocket_connection
def on_websocket_connection(_):
@@ -389,10 +372,9 @@ class AiocqhttpAdapter(Platform):
message_str += "".join(at_parts)
elif t == "markdown":
for m in m_group:
text = m["data"].get("markdown") or m["data"].get("content", "")
abm.message.append(Plain(text=text))
message_str += text
text = m["data"].get("markdown") or m["data"].get("content", "")
abm.message.append(Plain(text=text))
message_str += text
else:
for m in m_group:
try:
+9 -12
View File
@@ -382,18 +382,15 @@ class ProviderGoogleGenAI(Provider):
append_or_extend(gemini_contents, parts, types.ModelContent)
elif role == "tool" and not native_tool_enabled:
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]
parts = [
types.Part.from_function_response(
name=message["tool_call_id"],
response={
"name": message["tool_call_id"],
"content": message["content"],
},
),
]
append_or_extend(gemini_contents, parts, types.UserContent)
if gemini_contents and isinstance(gemini_contents[0], types.ModelContent):
+2 -16
View File
@@ -29,24 +29,10 @@ class GenieTTSProvider(TTSProvider):
if not genie:
raise ImportError("Please install genie_tts first.")
self.character_name = provider_config.get("genie_character_name", "mika")
language = provider_config.get("genie_language", "Japanese")
model_dir = provider_config.get("genie_onnx_model_dir", "")
refer_audio_path = provider_config.get("genie_refer_audio_path", "")
refer_text = provider_config.get("genie_refer_text", "")
self.character_name = provider_config.get("character_name", "mika")
try:
genie.load_character(
character_name=self.character_name,
language=language,
onnx_model_dir=model_dir,
)
genie.set_reference_audio(
character_name=self.character_name,
audio_path=refer_audio_path,
audio_text=refer_text,
language=language,
)
genie.load_predefined_character(self.character_name)
except Exception as e:
raise RuntimeError(f"Failed to load character {self.character_name}: {e}")
+8 -9
View File
@@ -328,29 +328,28 @@ class Context:
"""获取所有用于 Embedding 任务的 Provider。"""
return self.provider_manager.embedding_provider_insts
def get_using_provider(self, umo: str | None = None) -> Provider | None:
def get_using_provider(self, umo: str | None = None) -> Provider:
"""获取当前使用的用于文本生成任务的 LLM Provider(Chat_Completion 类型)。
Args:
umo: unified_message_origin 值,如果传入并且用户启用了提供商会话隔离,
则使用该会话偏好的对话模型(提供商
则使用该会话偏好的提供商。
Returns:
当前使用的对话模型(提供商),如果未设置则返回 None
当前使用的文本生成提供者
Raises:
ValueError: 该会话来源配置的的对话模型(提供商)的类型不正确
ValueError: 返回的提供者不是 Provider 类型
Note:
通过 /provider 指令可以切换提供者。
"""
prov = self.provider_manager.get_using_provider(
provider_type=ProviderType.CHAT_COMPLETION,
umo=umo,
)
if prov is None:
return None
if not isinstance(prov, Provider):
raise ValueError(
f"该会话来源的对话模型(提供商)的类型不正确: {type(prov)}"
)
raise ValueError("返回的 Provider 不是 Provider 类型")
return prov
def get_using_tts_provider(self, umo: str | None = None) -> TTSProvider | None:
-81
View File
@@ -46,7 +46,6 @@ 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),
@@ -64,35 +63,6 @@ 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:
@@ -761,57 +731,6 @@ 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")
+5 -18
View File
@@ -7,8 +7,6 @@ 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
@@ -246,22 +244,11 @@ class AstrBotDashboard:
logger.info(display)
# 配置 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)
return self.app.run_task(
host=host,
port=port,
shutdown_trigger=self.shutdown_trigger,
)
async def shutdown_trigger(self):
await self.shutdown_event.wait()
-21
View File
@@ -1,21 +0,0 @@
## 更新内容
### 新功能
- 为 ChatUI 添加文件拖拽上传功能 ([#4583](https://github.com/AstrBotDevs/AstrBot/issues/4583))
- 实现人格文件夹以进行高级人格管理 ([#4443](https://github.com/AstrBotDevs/AstrBot/issues/4443))
- 添加人格文件夹管理以支持层级组织 (db)
- 支持 Genie TTS
### 修复
- 增强提供商选择错误处理和日志记录,避免出现 `Provider 不是 Provider 类型` 的错误 ([#4654](https://github.com/AstrBotDevs/AstrBot/issues/4654))
- aiocqhttp 适配器中的 Markdown KeyError 或 UnboundLocalError 问题 ([#4656](https://github.com/AstrBotDevs/AstrBot/issues/4656))
- 确保 providers 中的 embedding 维度作为整数返回 ([#4547](https://github.com/AstrBotDevs/AstrBot/issues/4547))
- 钉钉流式响应问题 ([#4590](https://github.com/AstrBotDevs/AstrBot/issues/4590))
- 提供商选择按钮被长模型名称遮挡的问题 ([#4631](https://github.com/AstrBotDevs/AstrBot/issues/4631))
- 更新 `web_search_tavily` 处理,避免在非 ChatUI 平台出现信息引用 ([#4633](https://github.com/AstrBotDevs/AstrBot/issues/4633))
### 性能优化
- T2I 模板编辑器预览 ([#4574](https://github.com/AstrBotDevs/AstrBot/issues/4574))
### 杂项
- 移除已弃用的 `tool` 命令
+89 -118
View File
@@ -26,7 +26,6 @@
@createProject="showCreateProjectDialog"
@editProject="showEditProjectDialog"
@deleteProject="handleDeleteProject"
@openSearch="handleOpenSearch"
/>
<!-- 右侧聊天内容区域 -->
@@ -43,99 +42,65 @@
</v-btn>
</div>
<ChatSearchView
v-if="isSearchActive"
@close="handleCloseSearch"
<!-- 面包屑导航 -->
<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])"
/>
<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>
<!-- 输入区域 -->
@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-if="currSessionId && !selectedProjectId"
v-model:prompt="prompt"
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
@@ -159,7 +124,34 @@
@openLiveMode="openLiveMode"
ref="chatInputRef"
/>
</template>
</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>
</div>
@@ -208,7 +200,6 @@
<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';
@@ -218,7 +209,6 @@ 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';
@@ -229,7 +219,6 @@ 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;
@@ -244,8 +233,6 @@ 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);
@@ -449,15 +436,12 @@ function handleOpenRefs(refs: any) {
}
}
async function handleSelectConversation(sessionIds: string[], shouldCloseSearch: boolean = true) {
async function handleSelectConversation(sessionIds: string[]) {
if (!sessionIds[0]) return;
// 退出项目视图
selectedProjectId.value = null;
projectSessions.value = [];
if (shouldCloseSearch) {
chatSearchStore.closeSearch();
}
// 立即更新选中状态,避免需要点击两次
currSessionId.value = sessionIds[0];
@@ -498,7 +482,6 @@ function handleNewChat() {
// 退出项目视图
selectedProjectId.value = null;
projectSessions.value = [];
chatSearchStore.closeSearch();
}
async function handleDeleteConversation(sessionId: string) {
@@ -517,7 +500,6 @@ async function handleSelectProject(projectId: string) {
const sessions = await getProjectSessions(projectId);
projectSessions.value = sessions;
messages.value = [];
chatSearchStore.closeSearch();
// 清空当前会话ID,准备在项目中创建新对话
currSessionId.value = '';
@@ -591,17 +573,6 @@ 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) {
@@ -676,7 +647,7 @@ watch(
if (sessions.value.length > 0) {
const session = sessions.value.find(s => s.session_id === pathSessionId);
if (session) {
handleSelectConversation([pathSessionId], !isSearchActive.value);
handleSelectConversation([pathSessionId]);
}
} else {
pendingSessionId.value = pathSessionId;
@@ -693,13 +664,13 @@ watch(sessions, (newSessions) => {
const session = newSessions.find(s => s.session_id === pendingSessionId.value);
if (session) {
selectedSessions.value = [pendingSessionId.value];
handleSelectConversation([pendingSessionId.value], !isSearchActive.value);
handleSelectConversation([pendingSessionId.value]);
pendingSessionId.value = null;
}
} else if (!currSessionId.value && newSessions.length > 0) {
const firstSession = newSessions[0];
selectedSessions.value = [firstSession.session_id];
handleSelectConversation([firstSession.session_id], !isSearchActive.value);
handleSelectConversation([firstSession.session_id]);
}
});
@@ -1,313 +0,0 @@
<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,15 +20,6 @@
</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>
@@ -187,7 +178,6 @@ const emit = defineEmits<{
createProject: [];
editProject: [project: Project];
deleteProject: [projectId: string];
openSearch: [];
}>();
const { t } = useI18n();
@@ -274,13 +264,6 @@ 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;
@@ -376,3 +359,4 @@ function handleDeleteConversation(session: Session) {
justify-content: center;
}
</style>
@@ -3,7 +3,7 @@
<span v-if="!modelValue" style="color: rgb(var(--v-theme-primaryText));">
{{ tm('providerSelector.notSelected') }}
</span>
<span v-else class="provider-name-text">
<span v-else>
{{ modelValue }}
</span>
<v-btn size="small" color="primary" variant="tonal" @click="openDialog">
@@ -228,14 +228,6 @@ function closeProviderDrawer() {
</script>
<style scoped>
.provider-name-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: calc(100% - 80px);
display: inline-block;
}
.v-list-item {
transition: all 0.2s ease;
}
@@ -98,18 +98,6 @@
"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"
@@ -145,4 +133,4 @@
"sendMessageFailed": "Failed to send message, please try again",
"createSessionFailed": "Failed to create session, please refresh the page"
}
}
}
@@ -447,8 +447,7 @@
"description": "Segment Only LLM Results"
},
"interval_method": {
"description": "Interval Method",
"hint": "random 为随机时间,log 为根据消息长度计算,$y=log_<log_base>(x)$x为字数,y的单位为秒。"
"description": "Interval Method"
},
"interval": {
"description": "Random Interval Time",
@@ -456,15 +455,13 @@
},
"log_base": {
"description": "Logarithm Base",
"hint": "Base for logarithmic intervals, defaults to 2.6. Value range: 1.0-10.0."
"hint": "Base for logarithmic intervals, defaults to 2.0. Value range: 1.0-10.0."
},
"words_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)."
"description": "Segmented Reply Word Count Threshold"
},
"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,11 +151,6 @@
"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": {
@@ -222,4 +217,4 @@
"pluginChangelog": {
"menuTitle": "View Changelog"
}
}
}
@@ -100,18 +100,6 @@
"noSessions": "该项目暂无对话",
"confirmDelete": "确定要删除项目 \"{title}\" 吗?项目中的对话不会被删除。"
},
"search": {
"title": "搜索",
"placeholder": "输入关键词搜索标题或内容",
"hint": "输入关键词开始搜索",
"noResults": "没有找到匹配的对话",
"matchTitle": "标题匹配",
"matchContent": "内容匹配",
"matchPosition": "匹配位置",
"createdAt": "创建",
"updatedAt": "更新",
"pageSize": "每页条数"
},
"time": {
"today": "今天",
"yesterday": "昨天"
@@ -147,4 +135,4 @@
"sendMessageFailed": "发送消息失败,请重试",
"createSessionFailed": "创建会话失败,请刷新页面重试"
}
}
}
@@ -445,8 +445,7 @@
"description": "仅对 LLM 结果分段"
},
"interval_method": {
"description": "间隔方法",
"hint": "random 为随机时间,log 为根据消息长度计算,$y=log_<log_base>(x)$x为字数,y的单位为秒。"
"description": "间隔方法"
},
"interval": {
"description": "随机间隔时间",
@@ -454,15 +453,13 @@
},
"log_base": {
"description": "对数底数",
"hint": "对数间隔的底数,默认为 2.6。取值范围为 1.0-10.0。"
"hint": "对数间隔的底数,默认为 2.0。取值范围为 1.0-10.0。"
},
"words_count_threshold": {
"description": "分段回复字数阈值",
"hint": "分段回复的字数上限。只有字数小于此值的消息才会被分段,超过此值的长消息将直接发送(不分段),默认为 150。"
"description": "分段回复字数阈值"
},
"split_mode": {
"description": "分段模式",
"hint": "用于分隔一段消息。默认情况下会根据句号、问号等标点符号分隔。如填写 `[。?!]` 将移除所有的句号、问号、感叹号。re.findall(r'<regex>', text)",
"labels": [
"正则表达式",
"分段词列表"
@@ -151,11 +151,6 @@
"title": "未检测到新版本",
"message": "当前插件未检测到新版本,是否强制重新安装?这将从远程仓库拉取最新代码。",
"confirm": "强制更新"
},
"updateAllConfirm": {
"title": "确认更新全部插件",
"message": "确定要更新全部 {count} 个插件吗?此操作可能需要一些时间。",
"confirm": "确认更新"
}
},
"messages": {
@@ -222,4 +217,4 @@
"pluginChangelog": {
"menuTitle": "查看更新日志"
}
}
}
-112
View File
@@ -1,112 +0,0 @@
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
};
});
+1 -51
View File
@@ -92,11 +92,6 @@ const forceUpdateDialog = reactive({
extensionName: "",
});
// 更新全部插件确认对话框
const updateAllConfirmDialog = reactive({
show: false,
});
// 插件更新日志对话框(复用 ReadmeDialog
const changelogDialog = reactive({
show: false,
@@ -476,23 +471,6 @@ 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;
@@ -1150,7 +1128,7 @@ watch(isListView, (newVal) => {
variant="tonal"
:disabled="updatableExtensions.length === 0"
:loading="updatingAll"
@click="showUpdateAllConfirm"
@click="updateAllExtensions"
>
<v-icon>mdi-update</v-icon>
{{ tm("buttons.updateAll") }}
@@ -2301,34 +2279,6 @@ 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">
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "4.12.4"
version = "4.12.3"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
requires-python = ">=3.10"