Compare commits

..

35 Commits

Author SHA1 Message Date
Soulter
1742bc23c1 fix: compute variable errors after uninstalling plugins 2025-12-02 12:03:38 +08:00
Soulter
9e5c48c70d fix: update property name for embedded files in message handling 2025-12-02 11:57:02 +08:00
Soulter
2e8de16535 feat: enhance file handling in chat and webchat, supporting video uploads and improved attachment management 2025-12-01 17:38:23 +08:00
Soulter
7a66911988 fix: handle missing filename in file upload for chat route 2025-12-01 14:52:10 +08:00
Soulter
9221741cb2 feat: add message timestamp formatting and localization support in chat 2025-11-29 22:31:53 +08:00
Soulter
e7a1e17d7f refactor: remove unused import in chat route 2025-11-29 22:22:35 +08:00
Soulter
0599465a60 perf: improve performance of file downloading 2025-11-29 22:21:26 +08:00
Soulter
e103414114 feat: supports to delete attachments when webchat session is deleted 2025-11-29 22:14:23 +08:00
Soulter
e3e252ef69 feat: supports file upload in webchat 2025-11-29 22:00:14 +08:00
Soulter
77f996f137 fix: thinking placeholder in webchat 2025-11-29 21:24:24 +08:00
Soulter
839344bcd4 refactor: update image and record handling in webchat event processing 2025-11-29 21:12:02 +08:00
Soulter
c98dea7e4b refactor: message storage format of webchat 2025-11-29 21:01:10 +08:00
Soulter
df4412aa80 style: adjust bot-embedded-image max-width and remove hover effect for improved layout 2025-11-29 01:31:25 +08:00
Soulter
ab2c94e19a chore: comment out error logging in provider sources to reduce verbosity 2025-11-28 19:59:33 +08:00
Oscar Shaw
37cc4e2121 perf: console tag UI improve (#3816)
- Added yarn.lock to .gitignore to prevent tracking of Yarn lock files.
- Updated ConsoleDisplayer.vue to improve chip styling
2025-11-28 17:17:11 +08:00
Soulter
60dfdd0a66 chore: update astrbot cli version 2025-11-28 16:53:20 +08:00
Soulter
bb8b2cb194 chore: bump version to 4.7.1 2025-11-28 15:13:35 +08:00
Soulter
4e29684aa3 fix: add plugin set and knowledge bases selection in custom rules page (#3813)
fixes: #3806
2025-11-28 13:29:50 +08:00
Soulter
0e17e3553d chore: bump version to 4.7.0 2025-11-27 23:50:05 +08:00
Soulter
0a55060e89 fix: session controller in webchat 2025-11-27 22:32:35 +08:00
Soulter
77859c7daa feat: enhance provider status display in ProviderPage
- Added a tooltip to show detailed provider status, including availability and error messages.
- Refactored item details template to include status chips for better visual representation.
- Removed unused status section to streamline the UI.
2025-11-27 16:39:51 +08:00
Soulter
ba39c393a0 perf: enhance provider management with reload locking and logging (#3793)
- Introduced a reload lock to prevent concurrent reloads of providers.
- Added logging to indicate when a provider is disabled and when providers are being synchronized with the configuration.
- Refactored the reload method to improve clarity and maintainability.


Co-authored-by: anka <1350989414@qq.com>
2025-11-27 16:25:31 +08:00
Soulter
6a50d316d9 fix: mcp server cannot reload successfully after updating mcp server config (#3797)
fixes: #3780
2025-11-27 16:22:26 +08:00
Soulter
88c1d77f0b perf: add at message to group chat history (#3796)
* feat: enhance long-term memory message formatting

- Added support for 'At' message components in long-term memory, allowing for better representation of mentions in messages.

* chore: ruff check
2025-11-27 15:59:07 +08:00
Dt8333
758ce40cc1 chore: fix test (#3787) 2025-11-27 14:02:42 +08:00
Soulter
3e7bb80492 chore: ruff format 2025-11-27 14:01:25 +08:00
Soulter
75e95aa9ca fix: update session management icon in sidebar
- Changed the icon for the session management sidebar item from 'mdi-account-group' to 'mdi-pencil-ruler' for better representation.
2025-11-27 14:00:05 +08:00
Soulter
a389842e25 feat: update session management UI with information button and layout adjustments
- Added an information button linking to custom rules documentation.
- Adjusted layout for improved spacing and readability in the session management page.
- Minor refactoring of the data table component for better alignment.
2025-11-27 13:58:37 +08:00
Soulter
0f6a3c3f5a refactor: session management custom rules (#3792)
* refactor: umo custom rules

* feat(i18n): update session management translations and improve provider configuration handling

- Updated English and Chinese translations for session management, including "Unified Message Origin" and "Follow Config".
- Enhanced provider configuration options to include "Follow Config" as a selectable item.
- Removed unused clear buttons and refactored provider configuration saving logic to handle updates and deletions more efficiently.
2025-11-27 13:30:43 +08:00
Soulter
133f27422d feat: implement i18n of astrbot config (#3772)
* feat: implement i18n of astrbot config

* feat(config): update configuration metadata with i18n details and future deprecation notes
2025-11-26 16:40:58 +08:00
RC-CHN
abc6deb244 feat: add plugin logo placeholder (#3784) 2025-11-26 16:22:11 +08:00
teapot1de
06869b4597 docs: clarify segmented_reply words_count_threshold hint (#3779)
Update the configuration hint for `words_count_threshold` to explicitly state that it acts as a maximum limit for segmentation, preventing user confusion about it being a minimum trigger.
2025-11-26 16:15:09 +08:00
dependabot[bot]
d32cea9870 chore(deps): bump actions/checkout in the github-actions group (#3775)
Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 5 to 6
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-26 16:13:42 +08:00
Soulter
4b68100f16 feat(chat): add standalone chat component and integrate with config page for testing configurations (#3767)
* feat(chat): add standalone chat component and integrate with config page for testing configurations

* feat(chat): add error handling for message sending and session creation
2025-11-24 22:06:02 +08:00
Soulter
5c5515d462 fix: segmented reply regex error handling (#3771)
* fix: segmented reply regex error handling

closes: #3761

* fix: improve regex handling for segmented replies to support multiline input

* fix: update regex handling in ResultDecorateStage to use findall for segmented replies

* fix: update error logging message for segmented reply regex handling
2025-11-24 22:00:59 +08:00
51 changed files with 1843 additions and 782 deletions

View File

@@ -13,7 +13,7 @@ jobs:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Dashboard Build
run: |
@@ -70,7 +70,7 @@ jobs:
needs: build-and-publish-to-github-release
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6

View File

@@ -12,7 +12,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6

View File

@@ -56,7 +56,7 @@ jobs:
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL

View File

@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 0

View File

@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -20,7 +20,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 1
fetch-tag: true
@@ -118,7 +118,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 1
fetch-tag: true

1
.gitignore vendored
View File

@@ -34,6 +34,7 @@ dashboard/node_modules/
dashboard/dist/
package-lock.json
package.json
yarn.lock
# Operating System
**/.DS_Store

View File

@@ -1 +1 @@
__version__ = "3.5.23"
__version__ = "4.7.1"

View File

@@ -345,9 +345,6 @@ class MCPClient:
async def cleanup(self):
"""Clean up resources including old exit stacks from reconnections"""
# Set running_event first to unblock any waiting tasks
self.running_event.set()
# Close current exit stack
try:
await self.exit_stack.aclose()
@@ -359,6 +356,9 @@ class MCPClient:
# Just clear the list to release references
self._old_exit_stacks.clear()
# Set running_event first to unblock any waiting tasks
self.running_event.set()
class MCPTool(FunctionTool, Generic[TContext]):
"""A function tool that calls an MCP service."""

View File

@@ -4,7 +4,7 @@ import os
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.6.1"
VERSION = "4.7.1"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
# 默认配置
@@ -647,7 +647,7 @@ CONFIG_METADATA_2 = {
},
"words_count_threshold": {
"type": "int",
"hint": "超过这个字数的消息会被分段回复。默认为 150",
"hint": "分段回复的字数上限。只有字数小于此值的消息会被分段,超过此值的长消息将直接发送(不分段)。默认为 150",
},
"regex": {
"type": "string",

View File

@@ -213,6 +213,27 @@ class BaseDatabase(abc.ABC):
"""Get an attachment by its ID."""
...
@abc.abstractmethod
async def get_attachments(self, attachment_ids: list[str]) -> list[Attachment]:
"""Get multiple attachments by their IDs."""
...
@abc.abstractmethod
async def delete_attachment(self, attachment_id: str) -> bool:
"""Delete an attachment by its ID.
Returns True if the attachment was deleted, False if it was not found.
"""
...
@abc.abstractmethod
async def delete_attachments(self, attachment_ids: list[str]) -> int:
"""Delete multiple attachments by their IDs.
Returns the number of attachments deleted.
"""
...
@abc.abstractmethod
async def insert_persona(
self,

View File

@@ -470,6 +470,48 @@ class SQLiteDatabase(BaseDatabase):
result = await session.execute(query)
return result.scalar_one_or_none()
async def get_attachments(self, attachment_ids: list[str]) -> list:
"""Get multiple attachments by their IDs."""
if not attachment_ids:
return []
async with self.get_db() as session:
session: AsyncSession
query = select(Attachment).where(
Attachment.attachment_id.in_(attachment_ids)
)
result = await session.execute(query)
return list(result.scalars().all())
async def delete_attachment(self, attachment_id: str) -> bool:
"""Delete an attachment by its ID.
Returns True if the attachment was deleted, False if it was not found.
"""
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
query = delete(Attachment).where(
Attachment.attachment_id == attachment_id
)
result = await session.execute(query)
return result.rowcount > 0
async def delete_attachments(self, attachment_ids: list[str]) -> int:
"""Delete multiple attachments by their IDs.
Returns the number of attachments deleted.
"""
if not attachment_ids:
return 0
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
query = delete(Attachment).where(
Attachment.attachment_id.in_(attachment_ids)
)
result = await session.execute(query)
return result.rowcount
async def insert_persona(
self,
persona_id,

View File

@@ -161,11 +161,21 @@ class ResultDecorateStage(Stage):
# 不分段回复
new_chain.append(comp)
continue
split_response = re.findall(
self.regex,
comp.text,
re.DOTALL | re.MULTILINE,
)
try:
split_response = re.findall(
self.regex,
comp.text,
re.DOTALL | re.MULTILINE,
)
except re.error:
logger.error(
f"分段回复正则表达式错误,使用默认分段方式: {traceback.format_exc()}",
)
split_response = re.findall(
r".*?[。?!~…]+|.+$",
comp.text,
re.DOTALL | re.MULTILINE,
)
if not split_response:
new_chain.append(comp)
continue

View File

@@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable
from typing import Any
from astrbot import logger
from astrbot.core.message.components import Image, Plain, Record
from astrbot.core.message.components import File, Image, Plain, Record, Video
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.platform import (
AstrBotMessage,
@@ -112,26 +112,19 @@ class WebChatAdapter(Platform):
if payload["message"]:
abm.message.append(Plain(payload["message"]))
if payload["image_url"]:
if isinstance(payload["image_url"], list):
for img in payload["image_url"]:
abm.message.append(
Image.fromFileSystem(os.path.join(self.imgs_dir, img)),
)
else:
abm.message.append(
Image.fromFileSystem(
os.path.join(self.imgs_dir, payload["image_url"]),
),
)
if payload["audio_url"]:
if isinstance(payload["audio_url"], list):
for audio in payload["audio_url"]:
path = os.path.join(self.imgs_dir, audio)
abm.message.append(Record(file=path, path=path))
else:
path = os.path.join(self.imgs_dir, payload["audio_url"])
abm.message.append(Record(file=path, path=path))
# 处理 files
files_info = payload.get("files", [])
for file_info in files_info:
if file_info["type"] == "image":
abm.message.append(Image.fromFileSystem(file_info["path"]))
elif file_info["type"] == "record":
abm.message.append(Record.fromFileSystem(file_info["path"]))
elif file_info["type"] == "file":
filename = os.path.basename(file_info["path"])
abm.message.append(File(name=filename, file=file_info["path"]))
elif file_info["type"] == "video":
abm.message.append(Video.fromFileSystem(file_info["path"]))
logger.debug(f"WebChatAdapter: {abm.message}")

View File

@@ -1,12 +1,12 @@
import base64
import os
import shutil
import uuid
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import Image, Plain, Record
from astrbot.api.message_components import File, Image, Plain, Record
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.core.utils.io import download_image_by_url
from .webchat_queue_mgr import webchat_queue_mgr
@@ -19,7 +19,9 @@ class WebChatMessageEvent(AstrMessageEvent):
os.makedirs(imgs_dir, exist_ok=True)
@staticmethod
async def _send(message: MessageChain, session_id: str, streaming: bool = False):
async def _send(
message: MessageChain | None, session_id: str, streaming: bool = False
) -> str | None:
cid = session_id.split("!")[-1]
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
if not message:
@@ -30,7 +32,7 @@ class WebChatMessageEvent(AstrMessageEvent):
"streaming": False,
}, # end means this request is finished
)
return ""
return
data = ""
for comp in message.chain:
@@ -47,24 +49,11 @@ class WebChatMessageEvent(AstrMessageEvent):
)
elif isinstance(comp, Image):
# save image to local
filename = str(uuid.uuid4()) + ".jpg"
filename = f"{str(uuid.uuid4())}.jpg"
path = os.path.join(imgs_dir, filename)
if comp.file and comp.file.startswith("file:///"):
ph = comp.file[8:]
with open(path, "wb") as f:
with open(ph, "rb") as f2:
f.write(f2.read())
elif comp.file.startswith("base64://"):
base64_str = comp.file[9:]
image_data = base64.b64decode(base64_str)
with open(path, "wb") as f:
f.write(image_data)
elif comp.file and comp.file.startswith("http"):
await download_image_by_url(comp.file, path=path)
else:
with open(path, "wb") as f:
with open(comp.file, "rb") as f2:
f.write(f2.read())
image_base64 = await comp.convert_to_base64()
with open(path, "wb") as f:
f.write(base64.b64decode(image_base64))
data = f"[IMAGE]{filename}"
await web_chat_back_queue.put(
{
@@ -76,19 +65,11 @@ class WebChatMessageEvent(AstrMessageEvent):
)
elif isinstance(comp, Record):
# save record to local
filename = str(uuid.uuid4()) + ".wav"
filename = f"{str(uuid.uuid4())}.wav"
path = os.path.join(imgs_dir, filename)
if comp.file and comp.file.startswith("file:///"):
ph = comp.file[8:]
with open(path, "wb") as f:
with open(ph, "rb") as f2:
f.write(f2.read())
elif comp.file and comp.file.startswith("http"):
await download_image_by_url(comp.file, path=path)
else:
with open(path, "wb") as f:
with open(comp.file, "rb") as f2:
f.write(f2.read())
record_base64 = await comp.convert_to_base64()
with open(path, "wb") as f:
f.write(base64.b64decode(record_base64))
data = f"[RECORD]{filename}"
await web_chat_back_queue.put(
{
@@ -98,6 +79,23 @@ class WebChatMessageEvent(AstrMessageEvent):
"streaming": streaming,
},
)
elif isinstance(comp, File):
# save file to local
file_path = await comp.get_file()
original_name = comp.name or os.path.basename(file_path)
ext = os.path.splitext(original_name)[1] or ""
filename = f"{uuid.uuid4()!s}{ext}"
dest_path = os.path.join(imgs_dir, filename)
shutil.copy2(file_path, dest_path)
data = f"[FILE]{filename}|{original_name}"
await web_chat_back_queue.put(
{
"type": "file",
"cid": cid,
"data": data,
"streaming": streaming,
},
)
else:
logger.debug(f"webchat 忽略: {comp.type}")
@@ -131,6 +129,8 @@ class WebChatMessageEvent(AstrMessageEvent):
session_id=self.session_id,
streaming=True,
)
if not r:
continue
if chain.type == "reasoning":
reasoning_content += chain.get_plain_text()
else:

View File

@@ -10,7 +10,7 @@ class PlatformMessageHistoryManager:
self,
platform_id: str,
user_id: str,
content: list[dict], # TODO: parse from message chain
content: dict, # TODO: parse from message chain
sender_id: str | None = None,
sender_name: str | None = None,
):

View File

@@ -1,7 +1,7 @@
import asyncio
import traceback
from astrbot.core import logger, sp
from astrbot.core import astrbot_config, logger, sp
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
from astrbot.core.db import BaseDatabase
@@ -24,6 +24,7 @@ class ProviderManager:
db_helper: BaseDatabase,
persona_mgr: PersonaManager,
):
self.reload_lock = asyncio.Lock()
self.persona_mgr = persona_mgr
self.acm = acm
config = acm.confs["default"]
@@ -226,6 +227,7 @@ class ProviderManager:
async def load_provider(self, provider_config: dict):
if not provider_config["enable"]:
logger.info(f"Provider {provider_config['id']} is disabled, skipping")
return
if provider_config.get("provider_type", "") == "agent_runner":
return
@@ -434,40 +436,46 @@ class ProviderManager:
)
async def reload(self, provider_config: dict):
await self.terminate_provider(provider_config["id"])
if provider_config["enable"]:
await self.load_provider(provider_config)
async with self.reload_lock:
await self.terminate_provider(provider_config["id"])
if provider_config["enable"]:
await self.load_provider(provider_config)
# 和配置文件保持同步
config_ids = [provider["id"] for provider in self.providers_config]
logger.debug(f"providers in user's config: {config_ids}")
for key in list(self.inst_map.keys()):
if key not in config_ids:
await self.terminate_provider(key)
# 和配置文件保持同步
self.providers_config = astrbot_config["provider"]
config_ids = [provider["id"] for provider in self.providers_config]
logger.info(f"providers in user's config: {config_ids}")
for key in list(self.inst_map.keys()):
if key not in config_ids:
await self.terminate_provider(key)
if len(self.provider_insts) == 0:
self.curr_provider_inst = None
elif self.curr_provider_inst is None and len(self.provider_insts) > 0:
self.curr_provider_inst = self.provider_insts[0]
logger.info(
f"自动选择 {self.curr_provider_inst.meta().id} 作为当前提供商适配器。",
)
if len(self.provider_insts) == 0:
self.curr_provider_inst = None
elif self.curr_provider_inst is None and len(self.provider_insts) > 0:
self.curr_provider_inst = self.provider_insts[0]
logger.info(
f"自动选择 {self.curr_provider_inst.meta().id} 作为当前提供商适配器。",
)
if len(self.stt_provider_insts) == 0:
self.curr_stt_provider_inst = None
elif self.curr_stt_provider_inst is None and len(self.stt_provider_insts) > 0:
self.curr_stt_provider_inst = self.stt_provider_insts[0]
logger.info(
f"自动选择 {self.curr_stt_provider_inst.meta().id} 作为当前语音转文本提供商适配器。",
)
if len(self.stt_provider_insts) == 0:
self.curr_stt_provider_inst = None
elif (
self.curr_stt_provider_inst is None and len(self.stt_provider_insts) > 0
):
self.curr_stt_provider_inst = self.stt_provider_insts[0]
logger.info(
f"自动选择 {self.curr_stt_provider_inst.meta().id} 作为当前语音转文本提供商适配器。",
)
if len(self.tts_provider_insts) == 0:
self.curr_tts_provider_inst = None
elif self.curr_tts_provider_inst is None and len(self.tts_provider_insts) > 0:
self.curr_tts_provider_inst = self.tts_provider_insts[0]
logger.info(
f"自动选择 {self.curr_tts_provider_inst.meta().id} 作为当前文本转语音提供商适配器。",
)
if len(self.tts_provider_insts) == 0:
self.curr_tts_provider_inst = None
elif (
self.curr_tts_provider_inst is None and len(self.tts_provider_insts) > 0
):
self.curr_tts_provider_inst = self.tts_provider_insts[0]
logger.info(
f"自动选择 {self.curr_tts_provider_inst.meta().id} 作为当前文本转语音提供商适配器。",
)
def get_insts(self):
return self.provider_insts

View File

@@ -290,7 +290,7 @@ class ProviderAnthropic(Provider):
try:
llm_response = await self._query(payloads, func_tool)
except Exception as e:
logger.error(f"发生了错误。Provider 配置如下: {model_config}")
# logger.error(f"发生了错误。Provider 配置如下: {model_config}")
raise e
return llm_response

View File

@@ -111,9 +111,9 @@ class ProviderGoogleGenAI(Provider):
f"检测到 Key 异常({e.message}),且已没有可用的 Key。 当前 Key: {self.chosen_api_key[:12]}...",
)
raise Exception("达到了 Gemini 速率限制, 请稍后再试...")
logger.error(
f"发生了错误(gemini_source)。Provider 配置如下: {self.provider_config}",
)
# logger.error(
# f"发生了错误(gemini_source)。Provider 配置如下: {self.provider_config}",
# )
raise e
async def _prepare_query_config(

View File

@@ -433,7 +433,7 @@ class ProviderOpenAIOfficial(Provider):
)
payloads.pop("tools", None)
return False, chosen_key, available_api_keys, payloads, context_query, None
logger.error(f"发生了错误。Provider 配置如下: {self.provider_config}")
# logger.error(f"发生了错误。Provider 配置如下: {self.provider_config}")
if "tool" in str(e).lower() and "support" in str(e).lower():
logger.error("疑似该模型不支持函数调用工具调用。请输入 /tool off_all")

View File

@@ -171,110 +171,3 @@ class SessionServiceManager:
# 如果没有配置,默认为启用(兼容性考虑)
return True
@staticmethod
def set_session_status(session_id: str, enabled: bool) -> None:
"""设置会话的整体启停状态
Args:
session_id: 会话ID (unified_msg_origin)
enabled: True表示启用False表示禁用
"""
session_config = (
sp.get("session_service_config", {}, scope="umo", scope_id=session_id) or {}
)
session_config["session_enabled"] = enabled
sp.put(
"session_service_config",
session_config,
scope="umo",
scope_id=session_id,
)
logger.info(
f"会话 {session_id} 的整体状态已更新为: {'启用' if enabled else '禁用'}",
)
@staticmethod
def should_process_session_request(event: AstrMessageEvent) -> bool:
"""检查是否应该处理会话请求(会话整体启停检查)
Args:
event: 消息事件
Returns:
bool: True表示应该处理False表示跳过
"""
session_id = event.unified_msg_origin
return SessionServiceManager.is_session_enabled(session_id)
# =============================================================================
# 会话命名相关方法
# =============================================================================
@staticmethod
def get_session_custom_name(session_id: str) -> str | None:
"""获取会话的自定义名称
Args:
session_id: 会话ID (unified_msg_origin)
Returns:
str: 自定义名称如果没有设置则返回None
"""
session_services = sp.get(
"session_service_config",
{},
scope="umo",
scope_id=session_id,
)
return session_services.get("custom_name")
@staticmethod
def set_session_custom_name(session_id: str, custom_name: str) -> None:
"""设置会话的自定义名称
Args:
session_id: 会话ID (unified_msg_origin)
custom_name: 自定义名称,可以为空字符串来清除名称
"""
session_config = (
sp.get("session_service_config", {}, scope="umo", scope_id=session_id) or {}
)
if custom_name and custom_name.strip():
session_config["custom_name"] = custom_name.strip()
else:
# 如果传入空名称,则删除自定义名称
session_config.pop("custom_name", None)
sp.put(
"session_service_config",
session_config,
scope="umo",
scope_id=session_id,
)
logger.info(
f"会话 {session_id} 的自定义名称已更新为: {custom_name.strip() if custom_name and custom_name.strip() else '已清除'}",
)
@staticmethod
def get_session_display_name(session_id: str) -> str:
"""获取会话的显示名称优先显示自定义名称否则显示原始session_id的最后一段
Args:
session_id: 会话ID (unified_msg_origin)
Returns:
str: 显示名称
"""
custom_name = SessionServiceManager.get_session_custom_name(session_id)
if custom_name:
return custom_name
# 如果没有自定义名称返回session_id的最后一段
return session_id.split(":")[2] if session_id.count(":") >= 2 else session_id

View File

@@ -42,87 +42,6 @@ class SessionPluginManager:
# 如果都没有配置,默认为启用(兼容性考虑)
return True
@staticmethod
def set_plugin_status_for_session(
session_id: str,
plugin_name: str,
enabled: bool,
) -> None:
"""设置插件在指定会话中的启停状态
Args:
session_id: 会话ID (unified_msg_origin)
plugin_name: 插件名称
enabled: True表示启用False表示禁用
"""
# 获取当前配置
session_plugin_config = sp.get(
"session_plugin_config",
{},
scope="umo",
scope_id=session_id,
)
if session_id not in session_plugin_config:
session_plugin_config[session_id] = {
"enabled_plugins": [],
"disabled_plugins": [],
}
session_config = session_plugin_config[session_id]
enabled_plugins = session_config.get("enabled_plugins", [])
disabled_plugins = session_config.get("disabled_plugins", [])
if enabled:
# 启用插件
if plugin_name in disabled_plugins:
disabled_plugins.remove(plugin_name)
if plugin_name not in enabled_plugins:
enabled_plugins.append(plugin_name)
else:
# 禁用插件
if plugin_name in enabled_plugins:
enabled_plugins.remove(plugin_name)
if plugin_name not in disabled_plugins:
disabled_plugins.append(plugin_name)
# 保存配置
session_config["enabled_plugins"] = enabled_plugins
session_config["disabled_plugins"] = disabled_plugins
session_plugin_config[session_id] = session_config
sp.put(
"session_plugin_config",
session_plugin_config,
scope="umo",
scope_id=session_id,
)
logger.info(
f"会话 {session_id} 的插件 {plugin_name} 状态已更新为: {'启用' if enabled else '禁用'}",
)
@staticmethod
def get_session_plugin_config(session_id: str) -> dict[str, list[str]]:
"""获取指定会话的插件配置
Args:
session_id: 会话ID (unified_msg_origin)
Returns:
Dict[str, List[str]]: 包含enabled_plugins和disabled_plugins的字典
"""
session_plugin_config = sp.get(
"session_plugin_config",
{},
scope="umo",
scope_id=session_id,
)
return session_plugin_config.get(
session_id,
{"enabled_plugins": [], "disabled_plugins": []},
)
@staticmethod
def filter_handlers_by_session(event: AstrMessageEvent, handlers: list) -> list:
"""根据会话配置过滤处理器列表

View File

@@ -1,15 +1,16 @@
import asyncio
import json
import mimetypes
import os
import uuid
from contextlib import asynccontextmanager
from quart import Response as QuartResponse
from quart import g, make_response, request
from quart import g, make_response, request, send_file
from astrbot.core import logger
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.db import BaseDatabase
from astrbot.core.db.po import Attachment
from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
@@ -44,7 +45,7 @@ class ChatRoute(Route):
self.update_session_display_name,
),
"/chat/get_file": ("GET", self.get_file),
"/chat/post_image": ("POST", self.post_image),
"/chat/get_attachment": ("GET", self.get_attachment),
"/chat/post_file": ("POST", self.post_file),
}
self.core_lifecycle = core_lifecycle
@@ -73,52 +74,176 @@ class ChatRoute(Route):
if not real_file_path.startswith(real_imgs_dir):
return Response().error("Invalid file path").__dict__
with open(real_file_path, "rb") as f:
filename_ext = os.path.splitext(filename)[1].lower()
if filename_ext == ".wav":
return QuartResponse(f.read(), mimetype="audio/wav")
if filename_ext[1:] in self.supported_imgs:
return QuartResponse(f.read(), mimetype="image/jpeg")
return QuartResponse(f.read())
filename_ext = os.path.splitext(filename)[1].lower()
if filename_ext == ".wav":
return await send_file(real_file_path, mimetype="audio/wav")
if filename_ext[1:] in self.supported_imgs:
return await send_file(real_file_path, mimetype="image/jpeg")
return await send_file(real_file_path)
except (FileNotFoundError, OSError):
return Response().error("File access error").__dict__
async def post_image(self):
post_data = await request.files
if "file" not in post_data:
return Response().error("Missing key: file").__dict__
async def get_attachment(self):
"""Get attachment file by attachment_id."""
attachment_id = request.args.get("attachment_id")
if not attachment_id:
return Response().error("Missing key: attachment_id").__dict__
file = post_data["file"]
filename = str(uuid.uuid4()) + ".jpg"
path = os.path.join(self.imgs_dir, filename)
await file.save(path)
try:
attachment = await self.db.get_attachment_by_id(attachment_id)
if not attachment:
return Response().error("Attachment not found").__dict__
return Response().ok(data={"filename": filename}).__dict__
file_path = attachment.path
real_file_path = os.path.realpath(file_path)
return await send_file(real_file_path, mimetype=attachment.mime_type)
except (FileNotFoundError, OSError):
return Response().error("File access error").__dict__
async def post_file(self):
"""Upload a file and create an attachment record, return attachment_id."""
post_data = await request.files
if "file" not in post_data:
return Response().error("Missing key: file").__dict__
file = post_data["file"]
filename = f"{uuid.uuid4()!s}"
# 通过文件格式判断文件类型
if file.content_type.startswith("audio"):
filename += ".wav"
filename = file.filename or f"{uuid.uuid4()!s}"
content_type = file.content_type or "application/octet-stream"
# 根据 content_type 判断文件类型并添加扩展名
if content_type.startswith("image"):
attach_type = "image"
elif content_type.startswith("audio"):
attach_type = "record"
elif content_type.startswith("video"):
attach_type = "video"
else:
attach_type = "file"
path = os.path.join(self.imgs_dir, filename)
await file.save(path)
return Response().ok(data={"filename": filename}).__dict__
# 创建 attachment 记录
attachment = await self.db.insert_attachment(
path=path,
type=attach_type,
mime_type=content_type,
)
if not attachment:
return Response().error("Failed to create attachment").__dict__
filename = os.path.basename(attachment.path)
return (
Response()
.ok(
data={
"attachment_id": attachment.attachment_id,
"filename": filename,
"type": attach_type,
}
)
.__dict__
)
async def _build_user_message_parts(
self,
message: str,
attachments: list[Attachment],
) -> list:
"""构建用户消息的部分列表
Args:
message: 文本消息
files: attachment_id 列表
"""
parts = []
if message:
parts.append({"type": "plain", "text": message})
if attachments:
for attachment in attachments:
parts.append(
{
"type": attachment.type,
"attachment_id": attachment.attachment_id,
"filename": os.path.basename(attachment.path),
}
)
return parts
async def _create_attachment_from_file(
self, filename: str, attach_type: str
) -> dict | None:
"""从本地文件创建 attachment 并返回消息部分
用于处理 bot 回复中的媒体文件
Args:
filename: 存储的文件名
attach_type: 附件类型 (image, record, file, video)
"""
file_path = os.path.join(self.imgs_dir, os.path.basename(filename))
if not os.path.exists(file_path):
return None
# guess mime type
mime_type, _ = mimetypes.guess_type(filename)
if not mime_type:
mime_type = "application/octet-stream"
# insert attachment
attachment = await self.db.insert_attachment(
path=file_path,
type=attach_type,
mime_type=mime_type,
)
if not attachment:
return None
return {
"type": attach_type,
"attachment_id": attachment.attachment_id,
"filename": os.path.basename(file_path),
}
async def _save_bot_message(
self,
webchat_conv_id: str,
text: str,
media_parts: list,
reasoning: str,
):
"""保存 bot 消息到历史记录"""
bot_message_parts = []
if text:
bot_message_parts.append({"type": "plain", "text": text})
bot_message_parts.extend(media_parts)
new_his = {"type": "bot", "message": bot_message_parts}
if reasoning:
new_his["reasoning"] = reasoning
await self.platform_history_mgr.insert(
platform_id="webchat",
user_id=webchat_conv_id,
content=new_his,
sender_id="bot",
sender_name="bot",
)
async def chat(self):
username = g.get("username", "guest")
post_data = await request.json
if "message" not in post_data and "image_url" not in post_data:
return Response().error("Missing key: message or image_url").__dict__
if "message" not in post_data and "files" not in post_data:
return Response().error("Missing key: message or files").__dict__
if "session_id" not in post_data and "conversation_id" not in post_data:
return (
@@ -126,44 +251,44 @@ class ChatRoute(Route):
)
message = post_data["message"]
# conversation_id = post_data["conversation_id"]
session_id = post_data.get("session_id", post_data.get("conversation_id"))
image_url = post_data.get("image_url")
audio_url = post_data.get("audio_url")
files = post_data.get("files") # list of attachment_id
selected_provider = post_data.get("selected_provider")
selected_model = post_data.get("selected_model")
enable_streaming = post_data.get("enable_streaming", True) # 默认为 True
enable_streaming = post_data.get("enable_streaming", True)
if not message and not image_url and not audio_url:
return (
Response()
.error("Message and image_url and audio_url are empty")
.__dict__
)
if not message and not files:
return Response().error("Message and files are both empty").__dict__
if not session_id:
return Response().error("session_id is empty").__dict__
# 追加用户消息
webchat_conv_id = session_id
# 获取会话特定的队列
back_queue = webchat_queue_mgr.get_or_create_back_queue(webchat_conv_id)
new_his = {"type": "user", "message": message}
if image_url:
new_his["image_url"] = image_url
if audio_url:
new_his["audio_url"] = audio_url
# 构建并保存用户消息
attachments = await self.db.get_attachments(files)
message_parts = await self._build_user_message_parts(message, attachments)
files_info = [
{
"type": attachment.type,
"path": attachment.path,
}
for attachment in attachments
]
await self.platform_history_mgr.insert(
platform_id="webchat",
user_id=webchat_conv_id,
content=new_his,
content={"type": "user", "message": message_parts},
sender_id=username,
sender_name=username,
)
async def stream():
client_disconnected = False
accumulated_parts = []
accumulated_text = ""
accumulated_reasoning = ""
try:
async with track_conversation(self.running_convs, webchat_conv_id):
@@ -182,16 +307,17 @@ class ChatRoute(Route):
continue
result_text = result["data"]
type = result.get("type")
msg_type = result.get("type")
streaming = result.get("streaming", False)
# 发送 SSE 数据
try:
if not client_disconnected:
yield f"data: {json.dumps(result, ensure_ascii=False)}\n\n"
except Exception as e:
if not client_disconnected:
logger.debug(
f"[WebChat] 用户 {username} 断开聊天长连接。 {e}",
f"[WebChat] 用户 {username} 断开聊天长连接。 {e}"
)
client_disconnected = True
@@ -202,24 +328,55 @@ class ChatRoute(Route):
logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。")
client_disconnected = True
if type == "end":
# 累积消息部分
if msg_type == "plain":
chain_type = result.get("chain_type", "normal")
if chain_type == "reasoning":
accumulated_reasoning += result_text
else:
accumulated_text += result_text
elif msg_type == "image":
filename = result_text.replace("[IMAGE]", "")
part = await self._create_attachment_from_file(
filename, "image"
)
if part:
accumulated_parts.append(part)
elif msg_type == "record":
filename = result_text.replace("[RECORD]", "")
part = await self._create_attachment_from_file(
filename, "record"
)
if part:
accumulated_parts.append(part)
elif msg_type == "file":
# 格式: [FILE]filename
filename = result_text.replace("[FILE]", "")
part = await self._create_attachment_from_file(
filename, "file"
)
if part:
accumulated_parts.append(part)
# 消息结束处理
if msg_type == "end":
break
elif (
(streaming and type == "complete")
(streaming and msg_type == "complete")
or not streaming
or type == "break"
or msg_type == "break"
):
# 追加机器人消息
new_his = {"type": "bot", "message": result_text}
if "reasoning" in result:
new_his["reasoning"] = result["reasoning"]
await self.platform_history_mgr.insert(
platform_id="webchat",
user_id=webchat_conv_id,
content=new_his,
sender_id="bot",
sender_name="bot",
await self._save_bot_message(
webchat_conv_id,
accumulated_text,
accumulated_parts,
accumulated_reasoning,
)
# 重置累积变量 (对于 break 后的下一段消息)
if msg_type == "break":
accumulated_parts = []
accumulated_text = ""
accumulated_reasoning = ""
except BaseException as e:
logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True)
@@ -231,8 +388,7 @@ class ChatRoute(Route):
webchat_conv_id,
{
"message": message,
"image_url": image_url, # list
"audio_url": audio_url,
"files": files_info,
"selected_provider": selected_provider,
"selected_model": selected_model,
"enable_streaming": enable_streaming,
@@ -249,7 +405,7 @@ class ChatRoute(Route):
"Connection": "keep-alive",
},
)
response.timeout = None # fix SSE auto disconnect issue
response.timeout = None # fix SSE auto disconnect issue # pyright: ignore[reportAttributeAccessIssue]
return response
async def delete_webchat_session(self):
@@ -271,6 +427,17 @@ class ChatRoute(Route):
unified_msg_origin = f"{session.platform_id}:{message_type}:{session.platform_id}!{username}!{session_id}"
await self.conv_mgr.delete_conversations_by_user_id(unified_msg_origin)
# 获取消息历史中的所有附件 ID 并删除附件
history_list = await self.platform_history_mgr.get(
platform_id=session.platform_id,
user_id=session_id,
page=1,
page_size=100000, # 获取足够多的记录
)
attachment_ids = self._extract_attachment_ids(history_list)
if attachment_ids:
await self._delete_attachments(attachment_ids)
# 删除消息历史
await self.platform_history_mgr.delete(
platform_id=session.platform_id,
@@ -297,6 +464,41 @@ class ChatRoute(Route):
return Response().ok().__dict__
def _extract_attachment_ids(self, history_list) -> list[str]:
"""从消息历史中提取所有 attachment_id"""
attachment_ids = []
for history in history_list:
content = history.content
if not content or "message" not in content:
continue
message_parts = content.get("message", [])
for part in message_parts:
if isinstance(part, dict) and "attachment_id" in part:
attachment_ids.append(part["attachment_id"])
return attachment_ids
async def _delete_attachments(self, attachment_ids: list[str]):
"""删除附件(包括数据库记录和磁盘文件)"""
try:
attachments = await self.db.get_attachments(attachment_ids)
for attachment in attachments:
if not os.path.exists(attachment.path):
continue
try:
os.remove(attachment.path)
except OSError as e:
logger.warning(
f"Failed to delete attachment file {attachment.path}: {e}"
)
except Exception as e:
logger.warning(f"Failed to get attachments: {e}")
# 批量删除数据库记录
try:
await self.db.delete_attachments(attachment_ids)
except Exception as e:
logger.warning(f"Failed to delete attachments: {e}")
async def new_session(self):
"""Create a new Platform session (default: webchat)."""
username = g.get("username", "guest")

View File

@@ -60,10 +60,6 @@ class KnowledgeBaseRoute(Route):
# "/kb/media/delete": ("POST", self.delete_media),
# 检索
"/kb/retrieve": ("POST", self.retrieve),
# 会话知识库配置
"/kb/session/config/get": ("GET", self.get_session_kb_config),
"/kb/session/config/set": ("POST", self.set_session_kb_config),
"/kb/session/config/delete": ("POST", self.delete_session_kb_config),
}
self.register_routes()
@@ -920,158 +916,6 @@ class KnowledgeBaseRoute(Route):
logger.error(traceback.format_exc())
return Response().error(f"检索失败: {e!s}").__dict__
# ===== 会话知识库配置 API =====
async def get_session_kb_config(self):
"""获取会话的知识库配置
Query 参数:
- session_id: 会话 ID (必填)
返回:
- kb_ids: 知识库 ID 列表
- top_k: 返回结果数量
- enable_rerank: 是否启用重排序
"""
try:
from astrbot.core import sp
session_id = request.args.get("session_id")
if not session_id:
return Response().error("缺少参数 session_id").__dict__
# 从 SharedPreferences 获取配置
config = await sp.session_get(session_id, "kb_config", default={})
logger.debug(f"[KB配置] 读取到配置: session_id={session_id}")
# 如果没有配置,返回默认值
if not config:
config = {"kb_ids": [], "top_k": 5, "enable_rerank": True}
return Response().ok(config).__dict__
except Exception as e:
logger.error(f"[KB配置] 获取配置时出错: {e}", exc_info=True)
return Response().error(f"获取会话知识库配置失败: {e!s}").__dict__
async def set_session_kb_config(self):
"""设置会话的知识库配置
Body:
- scope: 配置范围 (目前只支持 "session")
- scope_id: 会话 ID (必填)
- kb_ids: 知识库 ID 列表 (必填)
- top_k: 返回结果数量 (可选, 默认 5)
- enable_rerank: 是否启用重排序 (可选, 默认 true)
"""
try:
from astrbot.core import sp
data = await request.json
scope = data.get("scope")
scope_id = data.get("scope_id")
kb_ids = data.get("kb_ids", [])
top_k = data.get("top_k", 5)
enable_rerank = data.get("enable_rerank", True)
# 验证参数
if scope != "session":
return Response().error("目前仅支持 session 范围的配置").__dict__
if not scope_id:
return Response().error("缺少参数 scope_id").__dict__
if not isinstance(kb_ids, list):
return Response().error("kb_ids 必须是列表").__dict__
# 验证知识库是否存在
kb_mgr = self._get_kb_manager()
invalid_ids = []
valid_ids = []
for kb_id in kb_ids:
kb_helper = await kb_mgr.get_kb(kb_id)
if kb_helper:
valid_ids.append(kb_id)
else:
invalid_ids.append(kb_id)
logger.warning(f"[KB配置] 知识库不存在: {kb_id}")
if invalid_ids:
logger.warning(f"[KB配置] 以下知识库ID无效: {invalid_ids}")
# 允许保存空列表,表示明确不使用任何知识库
if kb_ids and not valid_ids:
# 只有当用户提供了 kb_ids 但全部无效时才报错
return Response().error(f"所有提供的知识库ID都无效: {kb_ids}").__dict__
# 如果 kb_ids 为空列表,表示用户想清空配置
if not kb_ids:
valid_ids = []
# 构建配置对象只保存有效的ID
config = {
"kb_ids": valid_ids,
"top_k": top_k,
"enable_rerank": enable_rerank,
}
# 保存到 SharedPreferences
await sp.session_put(scope_id, "kb_config", config)
# 立即验证是否保存成功
verify_config = await sp.session_get(scope_id, "kb_config", default={})
if verify_config == config:
return (
Response()
.ok(
{"valid_ids": valid_ids, "invalid_ids": invalid_ids},
"保存知识库配置成功",
)
.__dict__
)
logger.error("[KB配置] 配置保存失败,验证不匹配")
return Response().error("配置保存失败").__dict__
except Exception as e:
logger.error(f"[KB配置] 设置配置时出错: {e}", exc_info=True)
return Response().error(f"设置会话知识库配置失败: {e!s}").__dict__
async def delete_session_kb_config(self):
"""删除会话的知识库配置
Body:
- scope: 配置范围 (目前只支持 "session")
- scope_id: 会话 ID (必填)
"""
try:
from astrbot.core import sp
data = await request.json
scope = data.get("scope")
scope_id = data.get("scope_id")
# 验证参数
if scope != "session":
return Response().error("目前仅支持 session 范围的配置").__dict__
if not scope_id:
return Response().error("缺少参数 scope_id").__dict__
# 从 SharedPreferences 删除配置
await sp.session_remove(scope_id, "kb_config")
return Response().ok(message="删除知识库配置成功").__dict__
except Exception as e:
logger.error(f"删除会话知识库配置失败: {e}")
logger.error(traceback.format_exc())
return Response().error(f"删除会话知识库配置失败: {e!s}").__dict__
async def upload_document_from_url(self):
"""从 URL 上传文档

View File

@@ -74,7 +74,10 @@ class SessionManagementRoute(Route):
umo_id = pref.scope_id
if umo_id not in umo_rules:
umo_rules[umo_id] = {}
umo_rules[umo_id][pref.key] = pref.value["val"]
if pref.key == "session_plugin_config" and umo_id in pref.value["val"]:
umo_rules[umo_id][pref.key] = pref.value["val"][umo_id]
else:
umo_rules[umo_id][pref.key] = pref.value["val"]
# 搜索过滤
if search:
@@ -185,6 +188,35 @@ class SessionManagementRoute(Route):
for p in provider_manager.tts_provider_insts
]
# 获取可用的插件列表(排除 reserved 的系统插件)
plugin_manager = self.core_lifecycle.plugin_manager
available_plugins = [
{
"name": p.name,
"display_name": p.display_name or p.name,
"desc": p.desc,
}
for p in plugin_manager.context.get_all_stars()
if not p.reserved and p.name
]
# 获取可用的知识库列表
available_kbs = []
kb_manager = self.core_lifecycle.kb_manager
if kb_manager:
try:
kbs = await kb_manager.list_kbs()
available_kbs = [
{
"kb_id": kb.kb_id,
"kb_name": kb.kb_name,
"emoji": kb.emoji,
}
for kb in kbs
]
except Exception as e:
logger.warning(f"获取知识库列表失败: {e!s}")
return (
Response()
.ok(
@@ -197,6 +229,8 @@ class SessionManagementRoute(Route):
"available_chat_providers": available_chat_providers,
"available_stt_providers": available_stt_providers,
"available_tts_providers": available_tts_providers,
"available_plugins": available_plugins,
"available_kbs": available_kbs,
"available_rule_keys": AVAILABLE_SESSION_RULE_KEYS,
}
)
@@ -229,6 +263,11 @@ class SessionManagementRoute(Route):
if rule_key not in AVAILABLE_SESSION_RULE_KEYS:
return Response().error(f"不支持的规则键: {rule_key}").__dict__
if rule_key == "session_plugin_config":
rule_value = {
umo: rule_value,
}
# 使用 shared preferences 更新规则
await sp.session_put(umo, rule_key, rule_value)

18
changelogs/v4.7.0.md Normal file
View File

@@ -0,0 +1,18 @@
## What's Changed
重构:
- 将 Dify、Coze、阿里云百炼应用等 LLMOps 提供商迁移到 Agent 执行器层,理清和本地 Agent 执行器的边界
- 将「会话管理」功能重构为「自定义规则」功能,理清和多配置文件功能的边界。详见:[自定义规则](https://docs.astrbot.app/use/custom-rules.html)
优化:
- Dify、阿里云百炼应用支持流式输出
- 防止分段回复正则表达式解析错误导致消息不发送
- 群聊上下文感知记录 At 信息
- 优化模型提供商页面的测试提供商功能
新增:
- 支持在配置文件页面快速测试对话
- 为配置文件配置项内容添加国际化支持
修复:
- 在更新 MCP Server 配置后MCP 无法正常重启的问题

22
changelogs/v4.7.1.md Normal file
View File

@@ -0,0 +1,22 @@
## What's Changed
### 修复了自定义规则页面无法设置插件和知识库的规则的问题
---
重构:
- 将 Dify、Coze、阿里云百炼应用等 LLMOps 提供商迁移到 Agent 执行器层,理清和本地 Agent 执行器的边界。详见:[Agent 执行器](https://docs.astrbot.app/use/agent-runner.html)
- 将「会话管理」功能重构为「自定义规则」功能,理清和多配置文件功能的边界。详见:[自定义规则](https://docs.astrbot.app/use/custom-rules.html)
优化:
- Dify、阿里云百炼应用支持流式输出
- 防止分段回复正则表达式解析错误导致消息不发送
- 群聊上下文感知记录 At 信息
- 优化模型提供商页面的测试提供商功能
新增:
- 支持在配置文件页面快速测试对话
- 为配置文件配置项内容添加国际化支持
修复:
- 在更新 MCP Server 配置后MCP 无法正常重启的问题

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -84,7 +84,8 @@
v-model:prompt="prompt"
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
:disabled="isStreaming || isConvRunning"
:stagedFiles="stagedNonImageFiles"
:disabled="isStreaming"
:enableStreaming="enableStreaming"
:isRecording="isRecording"
:session-id="currSessionId || null"
@@ -93,6 +94,7 @@
@toggleStreaming="toggleStreaming"
@removeImage="removeImage"
@removeAudio="removeAudio"
@removeFile="removeFile"
@startRecording="handleStartRecording"
@stopRecording="handleStopRecording"
@pasteImage="handlePaste"
@@ -189,14 +191,17 @@ const {
} = useSessions(props.chatboxMode);
const {
stagedImagesName,
stagedImagesUrl,
stagedAudioUrl,
stagedFiles,
stagedNonImageFiles,
getMediaFile,
processAndUploadImage,
processAndUploadFile,
handlePaste,
removeImage,
removeAudio,
removeFile,
clearStaged,
cleanupMediaCache
} = useMediaHandling();
@@ -295,13 +300,18 @@ async function handleStopRecording() {
}
async function handleFileSelect(files: FileList) {
const imageTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
for (const file of files) {
await processAndUploadImage(file);
if (imageTypes.includes(file.type)) {
await processAndUploadImage(file);
} else {
await processAndUploadFile(file);
}
}
}
async function handleSendMessage() {
if (!prompt.value.trim() && stagedImagesName.value.length === 0 && !stagedAudioUrl.value) {
if (!prompt.value.trim() && stagedFiles.value.length === 0 && !stagedAudioUrl.value) {
return;
}
@@ -310,8 +320,13 @@ async function handleSendMessage() {
}
const promptToSend = prompt.value.trim();
const imageNamesToSend = [...stagedImagesName.value];
const audioNameToSend = stagedAudioUrl.value;
const filesToSend = stagedFiles.value.map(f => ({
attachment_id: f.attachment_id,
url: f.url,
original_name: f.original_name,
type: f.type
}));
// 清空输入和附件
prompt.value = '';
@@ -324,7 +339,7 @@ async function handleSendMessage() {
await sendMsg(
promptToSend,
imageNamesToSend,
filesToSend,
audioNameToSend,
selectedProviderId,
selectedModelName

View File

@@ -15,6 +15,7 @@
:session-id="sessionId || null"
:platform-id="sessionPlatformId"
:is-group="sessionIsGroup"
:initial-config-id="props.configId"
@config-changed="handleConfigChange"
/>
<ProviderModelSelector v-if="showProviderSelector" ref="providerModelSelectorRef" />
@@ -29,7 +30,7 @@
</v-tooltip>
</div>
<div style="display: flex; justify-content: flex-end; margin-top: 8px; align-items: center;">
<input type="file" ref="imageInputRef" @change="handleFileSelect" accept="image/*"
<input type="file" ref="imageInputRef" @change="handleFileSelect"
style="display: none" multiple />
<v-progress-circular v-if="disabled" indeterminate size="16" class="mr-1" width="1.5" />
<v-btn @click="triggerImageInput" icon="mdi-plus" variant="text" color="deep-purple"
@@ -44,8 +45,8 @@
</div>
<!-- 附件预览区 -->
<div class="attachments-preview" v-if="stagedImagesUrl.length > 0 || stagedAudioUrl">
<div v-for="(img, index) in stagedImagesUrl" :key="index" class="image-preview">
<div class="attachments-preview" v-if="stagedImagesUrl.length > 0 || stagedAudioUrl || (stagedFiles && stagedFiles.length > 0)">
<div v-for="(img, index) in stagedImagesUrl" :key="'img-' + index" class="image-preview">
<img :src="img" class="preview-image" />
<v-btn @click="$emit('removeImage', index)" class="remove-attachment-btn" icon="mdi-close"
size="small" color="error" variant="text" />
@@ -59,6 +60,15 @@
<v-btn @click="$emit('removeAudio')" class="remove-attachment-btn" icon="mdi-close" size="small"
color="error" variant="text" />
</div>
<div v-for="(file, index) in stagedFiles" :key="'file-' + index" class="file-preview">
<v-chip color="blue-grey-lighten-4" class="file-chip">
<v-icon start icon="mdi-file-document-outline" size="small"></v-icon>
<span class="file-name-preview">{{ file.original_name }}</span>
</v-chip>
<v-btn @click="$emit('removeFile', index)" class="remove-attachment-btn" icon="mdi-close" size="small"
color="error" variant="text" />
</div>
</div>
</div>
</template>
@@ -70,20 +80,32 @@ import ProviderModelSelector from './ProviderModelSelector.vue';
import ConfigSelector from './ConfigSelector.vue';
import type { Session } from '@/composables/useSessions';
interface StagedFileInfo {
attachment_id: string;
filename: string;
original_name: string;
url: string;
type: string;
}
interface Props {
prompt: string;
stagedImagesUrl: string[];
stagedAudioUrl: string;
stagedFiles?: StagedFileInfo[];
disabled: boolean;
enableStreaming: boolean;
isRecording: boolean;
sessionId?: string | null;
currentSession?: Session | null;
configId?: string | null;
}
const props = withDefaults(defineProps<Props>(), {
sessionId: null,
currentSession: null
currentSession: null,
configId: null,
stagedFiles: () => []
});
const emit = defineEmits<{
@@ -92,6 +114,7 @@ const emit = defineEmits<{
toggleStreaming: [];
removeImage: [index: number];
removeAudio: [];
removeFile: [index: number];
startRecording: [];
stopRecording: [];
pasteImage: [event: ClipboardEvent];
@@ -114,7 +137,7 @@ const sessionPlatformId = computed(() => props.currentSession?.platform_id || 'w
const sessionIsGroup = computed(() => Boolean(props.currentSession?.is_group));
const canSend = computed(() => {
return (props.prompt && props.prompt.trim()) || props.stagedImagesUrl.length > 0 || props.stagedAudioUrl;
return (props.prompt && props.prompt.trim()) || props.stagedImagesUrl.length > 0 || props.stagedAudioUrl || (props.stagedFiles && props.stagedFiles.length > 0);
});
// Ctrl+B 长按录音相关
@@ -236,7 +259,8 @@ defineExpose({
}
.image-preview,
.audio-preview {
.audio-preview,
.file-preview {
position: relative;
display: inline-flex;
}
@@ -249,11 +273,19 @@ defineExpose({
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.audio-chip {
.audio-chip,
.file-chip {
height: 36px;
border-radius: 18px;
}
.file-name-preview {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.remove-attachment-btn {
position: absolute;
top: -8px;

View File

@@ -90,10 +90,12 @@ const props = withDefaults(defineProps<{
sessionId?: string | null;
platformId?: string;
isGroup?: boolean;
initialConfigId?: string | null;
}>(), {
sessionId: null,
platformId: 'webchat',
isGroup: false
isGroup: false,
initialConfigId: null
});
const emit = defineEmits<{ 'config-changed': [ConfigChangedPayload] }>();
@@ -291,7 +293,7 @@ watch(
onMounted(async () => {
await fetchConfigList();
const stored = localStorage.getItem(STORAGE_KEY) || 'default';
const stored = props.initialConfigId || localStorage.getItem(STORAGE_KEY) || 'default';
selectedConfigId.value = stored;
await setSelection(stored);
await syncSelectionForSession();

View File

@@ -24,6 +24,22 @@
{{ t('messages.errors.browser.audioNotSupported') }}
</audio>
</div>
<!-- 文件附件 -->
<div class="file-attachments" v-if="msg.content.file_url && msg.content.file_url.length > 0">
<div v-for="(file, fileIdx) in msg.content.file_url" :key="fileIdx" class="file-attachment">
<a v-if="file.url" :href="file.url" :download="file.filename" class="file-link">
<v-icon size="small" class="file-icon">mdi-file-document-outline</v-icon>
<span class="file-name">{{ file.filename }}</span>
</a>
<a v-else @click="downloadFile(file)" class="file-link file-link-download">
<v-icon size="small" class="file-icon">mdi-file-document-outline</v-icon>
<span class="file-name">{{ file.filename }}</span>
<v-icon v-if="downloadingFiles.has(file.attachment_id)" size="small" class="download-icon">mdi-loading mdi-spin</v-icon>
<v-icon v-else size="small" class="download-icon">mdi-download</v-icon>
</a>
</div>
</div>
</div>
</div>
@@ -77,10 +93,29 @@
{{ t('messages.errors.browser.audioNotSupported') }}
</audio>
</div>
<!-- Files -->
<div class="embedded-files"
v-if="msg.content.embedded_files && msg.content.embedded_files.length > 0">
<div v-for="(file, fileIndex) in msg.content.embedded_files" :key="fileIndex"
class="embedded-file">
<a v-if="file.url" :href="file.url" :download="file.filename" class="file-link">
<v-icon size="small" class="file-icon">mdi-file-document-outline</v-icon>
<span class="file-name">{{ file.filename }}</span>
</a>
<a v-else @click="downloadFile(file)" class="file-link file-link-download">
<v-icon size="small" class="file-icon">mdi-file-document-outline</v-icon>
<span class="file-name">{{ file.filename }}</span>
<v-icon v-if="downloadingFiles.has(file.attachment_id)" size="small" class="download-icon">mdi-loading mdi-spin</v-icon>
<v-icon v-else size="small" class="download-icon">mdi-download</v-icon>
</a>
</div>
</div>
</template>
</div>
<div class="message-actions" v-if="!msg.content.isLoading">
<v-btn :icon="getCopyIcon(index)" size="small" variant="text" class="copy-message-btn"
<span class="message-time" v-if="msg.created_at">{{ formatMessageTime(msg.created_at) }}</span>
<v-btn :icon="getCopyIcon(index)" size="x-small" variant="text" class="copy-message-btn"
:class="{ 'copy-success': isCopySuccess(index) }"
@click="copyBotMessage(msg.content.message, index)" :title="t('core.common.copy')" />
</div>
@@ -96,6 +131,7 @@ import { useI18n, useModuleI18n } from '@/i18n/composables';
import MarkdownIt from 'markdown-it';
import hljs from 'highlight.js';
import 'highlight.js/styles/github.css';
import axios from 'axios';
const md = new MarkdownIt({
html: false,
@@ -147,6 +183,7 @@ export default {
scrollThreshold: 1,
scrollTimer: null,
expandedReasoning: new Set(), // Track which reasoning blocks are expanded
downloadingFiles: new Set(), // Track which files are being downloaded
};
},
mounted() {
@@ -179,6 +216,35 @@ export default {
return this.expandedReasoning.has(messageIndex);
},
// 下载文件
async downloadFile(file) {
if (!file.attachment_id) return;
// 标记为下载中
this.downloadingFiles.add(file.attachment_id);
this.downloadingFiles = new Set(this.downloadingFiles);
try {
const response = await axios.get(`/api/chat/get_attachment?attachment_id=${file.attachment_id}`, {
responseType: 'blob'
});
const url = URL.createObjectURL(response.data);
const a = document.createElement('a');
a.href = url;
a.download = file.filename || 'file';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 100);
} catch (err) {
console.error('Download file failed:', err);
} finally {
this.downloadingFiles.delete(file.attachment_id);
this.downloadingFiles = new Set(this.downloadingFiles);
}
},
// 复制代码到剪贴板
copyCodeToClipboard(code) {
navigator.clipboard.writeText(code).then(() => {
@@ -375,6 +441,37 @@ export default {
clearTimeout(this.scrollTimer);
this.scrollTimer = null;
}
},
// 格式化消息时间,支持别名显示
formatMessageTime(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
const now = new Date();
// 获取本地时间的日期部分
const dateDay = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const todayDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterdayDay = new Date(todayDay);
yesterdayDay.setDate(yesterdayDay.getDate() - 1);
// 格式化时间 HH:MM
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
const timeStr = `${hours}:${minutes}`;
// 判断是今天、昨天还是更早
if (dateDay.getTime() === todayDay.getTime()) {
return `${this.tm('time.today')} ${timeStr}`;
} else if (dateDay.getTime() === yesterdayDay.getTime()) {
return `${this.tm('time.yesterday')} ${timeStr}`;
} else {
// 更早的日期显示完整格式
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
return `${month}-${day} ${timeStr}`;
}
}
}
}
@@ -413,7 +510,7 @@ export default {
}
.message-item {
margin-bottom: 24px;
margin-bottom: 12px;
animation: fadeIn 0.3s ease-out;
}
@@ -441,10 +538,18 @@ export default {
.message-actions {
display: flex;
gap: 4px;
align-items: center;
gap: 8px;
opacity: 0;
transition: opacity 0.2s ease;
margin-left: 8px;
margin-left: 16px;
}
.message-time {
font-size: 12px;
color: var(--v-theme-secondaryText);
opacity: 0.7;
white-space: nowrap;
}
.bot-message:hover .message-actions {
@@ -549,19 +654,14 @@ export default {
}
.bot-embedded-image {
max-width: 80%;
max-width: 40%;
width: auto;
height: auto;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: transform 0.2s ease;
}
.bot-embedded-image:hover {
transform: scale(1.02);
}
.embedded-audio {
width: 300px;
margin-top: 8px;
@@ -572,6 +672,71 @@ export default {
max-width: 300px;
}
/* 文件附件样式 */
.file-attachments,
.embedded-files {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.file-attachment,
.embedded-file {
display: flex;
align-items: center;
}
.file-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background-color: rgba(var(--v-theme-primary), 0.08);
border: 1px solid rgba(var(--v-theme-primary), 0.2);
border-radius: 8px;
color: rgb(var(--v-theme-primary));
text-decoration: none;
font-size: 14px;
transition: all 0.2s ease;
max-width: 300px;
}
.file-link-download {
cursor: pointer;
}
.download-icon {
margin-left: 4px;
opacity: 0.7;
}
.file-icon {
flex-shrink: 0;
color: rgb(var(--v-theme-primary));
}
.file-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.v-theme--dark .file-link {
background-color: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
color: var(--v-theme-secondary);
}
.v-theme--dark .file-link:hover {
background-color: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
}
.v-theme--dark .file-icon {
color: var(--v-theme-secondary);
}
/* 动画类 */
.fade-in {
animation: fadeIn 0.3s ease-in-out;

View File

@@ -0,0 +1,324 @@
<template>
<v-card class="standalone-chat-card" elevation="0" rounded="0">
<v-card-text class="standalone-chat-container">
<div class="chat-layout">
<!-- 聊天内容区域 -->
<div class="chat-content-panel">
<MessageList v-if="messages && messages.length > 0" :messages="messages" :isDark="isDark"
:isStreaming="isStreaming || isConvRunning" @openImagePreview="openImagePreview"
ref="messageList" />
<div class="welcome-container fade-in" v-else>
<div class="welcome-title">
<span>Hello, I'm</span>
<span class="bot-name">AstrBot ⭐</span>
</div>
<p class="text-caption text-medium-emphasis mt-2">
测试配置: {{ configId || 'default' }}
</p>
</div>
<!-- 输入区域 -->
<ChatInput
v-model:prompt="prompt"
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
:disabled="isStreaming"
:enableStreaming="enableStreaming"
:isRecording="isRecording"
:session-id="currSessionId || null"
:current-session="getCurrentSession"
:config-id="configId"
@send="handleSendMessage"
@toggleStreaming="toggleStreaming"
@removeImage="removeImage"
@removeAudio="removeAudio"
@startRecording="handleStartRecording"
@stopRecording="handleStopRecording"
@pasteImage="handlePaste"
@fileSelect="handleFileSelect"
ref="chatInputRef"
/>
</div>
</div>
</v-card-text>
</v-card>
<!-- 图片预览对话框 -->
<v-dialog v-model="imagePreviewDialog" max-width="90vw" max-height="90vh">
<v-card class="image-preview-card" elevation="8">
<v-card-title class="d-flex justify-space-between align-center pa-4">
<span>{{ t('core.common.imagePreview') }}</span>
<v-btn icon="mdi-close" variant="text" @click="imagePreviewDialog = false" />
</v-card-title>
<v-card-text class="text-center pa-4">
<img :src="previewImageUrl" class="preview-image-large" />
</v-card-text>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
import axios from 'axios';
import { useCustomizerStore } from '@/stores/customizer';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import { useTheme } from 'vuetify';
import MessageList from '@/components/chat/MessageList.vue';
import ChatInput from '@/components/chat/ChatInput.vue';
import { useMessages } from '@/composables/useMessages';
import { useMediaHandling } from '@/composables/useMediaHandling';
import { useRecording } from '@/composables/useRecording';
import { useToast } from '@/utils/toast';
interface Props {
configId?: string | null;
}
const props = withDefaults(defineProps<Props>(), {
configId: null
});
const { t } = useI18n();
const { error: showError } = useToast();
// UI 状态
const imagePreviewDialog = ref(false);
const previewImageUrl = ref('');
// 会话管理(不使用 useSessions 避免路由跳转)
const currSessionId = ref('');
const getCurrentSession = computed(() => null); // 独立测试模式不需要会话信息
async function newSession() {
try {
const response = await axios.get('/api/chat/new_session');
const sessionId = response.data.data.session_id;
currSessionId.value = sessionId;
return sessionId;
} catch (err) {
console.error(err);
throw err;
}
}
function updateSessionTitle(sessionId: string, title: string) {
// 独立模式不需要更新会话标题
}
function getSessions() {
// 独立模式不需要加载会话列表
}
const {
stagedImagesUrl,
stagedAudioUrl,
stagedFiles,
getMediaFile,
processAndUploadImage,
handlePaste,
removeImage,
removeAudio,
clearStaged,
cleanupMediaCache
} = useMediaHandling();
const { isRecording, startRecording: startRec, stopRecording: stopRec } = useRecording();
const {
messages,
isStreaming,
isConvRunning,
enableStreaming,
getSessionMessages: getSessionMsg,
sendMessage: sendMsg,
toggleStreaming
} = useMessages(currSessionId, getMediaFile, updateSessionTitle, getSessions);
// 组件引用
const messageList = ref<InstanceType<typeof MessageList> | null>(null);
const chatInputRef = ref<InstanceType<typeof ChatInput> | null>(null);
// 输入状态
const prompt = ref('');
const isDark = computed(() => useCustomizerStore().uiTheme === 'PurpleThemeDark');
function openImagePreview(imageUrl: string) {
previewImageUrl.value = imageUrl;
imagePreviewDialog.value = true;
}
async function handleStartRecording() {
await startRec();
}
async function handleStopRecording() {
const audioFilename = await stopRec();
stagedAudioUrl.value = audioFilename;
}
async function handleFileSelect(files: FileList) {
for (const file of files) {
await processAndUploadImage(file);
}
}
async function handleSendMessage() {
if (!prompt.value.trim() && stagedFiles.value.length === 0 && !stagedAudioUrl.value) {
return;
}
try {
if (!currSessionId.value) {
await newSession();
}
const promptToSend = prompt.value.trim();
const audioNameToSend = stagedAudioUrl.value;
const filesToSend = stagedFiles.value.map(f => ({
attachment_id: f.attachment_id,
url: f.url,
original_name: f.original_name,
type: f.type
}));
// 清空输入和附件
prompt.value = '';
clearStaged();
// 获取选择的提供商和模型
const selection = chatInputRef.value?.getCurrentSelection();
const selectedProviderId = selection?.providerId || '';
const selectedModelName = selection?.modelName || '';
await sendMsg(
promptToSend,
filesToSend,
audioNameToSend,
selectedProviderId,
selectedModelName
);
// 滚动到底部
nextTick(() => {
messageList.value?.scrollToBottom();
});
} catch (err) {
console.error('Failed to send message:', err);
showError(t('features.chat.errors.sendMessageFailed'));
// 恢复输入内容,让用户可以重试
// 注意:附件已经上传到服务器,所以不恢复附件
}
}
onMounted(async () => {
// 独立模式在挂载时创建新会话
try {
await newSession();
} catch (err) {
console.error('Failed to create initial session:', err);
showError(t('features.chat.errors.createSessionFailed'));
}
});
onBeforeUnmount(() => {
cleanupMediaCache();
});
</script>
<style scoped>
/* 基础动画 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.standalone-chat-card {
width: 100%;
height: 100%;
max-height: 100%;
overflow: hidden;
}
.standalone-chat-container {
width: 100%;
height: 100%;
max-height: 100%;
padding: 0;
overflow: hidden;
}
.chat-layout {
height: 100%;
max-height: 100%;
display: flex;
overflow: hidden;
}
.chat-content-panel {
height: 100%;
max-height: 100%;
width: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.conversation-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
padding-left: 16px;
border-bottom: 1px solid var(--v-theme-border);
width: 100%;
padding-right: 32px;
flex-shrink: 0;
}
.conversation-header-info h4 {
margin: 0;
font-weight: 500;
}
.conversation-header-actions {
display: flex;
gap: 8px;
align-items: center;
}
.welcome-container {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.welcome-title {
font-size: 28px;
margin-bottom: 8px;
}
.bot-name {
font-weight: 700;
margin-left: 8px;
color: var(--v-theme-secondary);
}
.fade-in {
animation: fadeIn 0.3s ease-in-out;
}
.preview-image-large {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
}
</style>

View File

@@ -7,8 +7,8 @@ import { useCommonStore } from '@/stores/common';
<!-- 添加筛选级别控件 -->
<div class="filter-controls mb-2" v-if="showLevelBtns">
<v-chip-group v-model="selectedLevels" column multiple>
<v-chip v-for="level in logLevels" :key="level" :color="getLevelColor(level)" filter
:text-color="level === 'DEBUG' || level === 'INFO' ? 'black' : 'white'">
<v-chip v-for="level in logLevels" :key="level" :color="getLevelColor(level)" filter variant="flat" size="small"
:text-color="level === 'DEBUG' || level === 'INFO' ? 'black' : 'white'" class="font-weight-medium">
{{ level }}
</v-chip>
</v-chip-group>
@@ -168,6 +168,7 @@ export default {
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
margin-left: 20px;
}
.fade-in {

View File

@@ -1,10 +1,17 @@
import { ref } from 'vue';
import { ref, computed } from 'vue';
import axios from 'axios';
export interface StagedFileInfo {
attachment_id: string;
filename: string;
original_name: string;
url: string; // blob URL for preview
type: string; // image, record, file, video
}
export function useMediaHandling() {
const stagedImagesName = ref<string[]>([]);
const stagedImagesUrl = ref<string[]>([]);
const stagedAudioUrl = ref<string>('');
const stagedFiles = ref<StagedFileInfo[]>([]);
const mediaCache = ref<Record<string, string>>({});
async function getMediaFile(filename: string): Promise<string> {
@@ -32,20 +39,49 @@ export function useMediaHandling() {
formData.append('file', file);
try {
const response = await axios.post('/api/chat/post_image', formData, {
const response = await axios.post('/api/chat/post_file', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
const img = response.data.data.filename;
stagedImagesName.value.push(img);
stagedImagesUrl.value.push(URL.createObjectURL(file));
const { attachment_id, filename, type } = response.data.data;
stagedFiles.value.push({
attachment_id,
filename,
original_name: file.name,
url: URL.createObjectURL(file),
type
});
} catch (err) {
console.error('Error uploading image:', err);
}
}
async function processAndUploadFile(file: File) {
const formData = new FormData();
formData.append('file', file);
try {
const response = await axios.post('/api/chat/post_file', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
const { attachment_id, filename, type } = response.data.data;
stagedFiles.value.push({
attachment_id,
filename,
original_name: file.name,
url: URL.createObjectURL(file),
type
});
} catch (err) {
console.error('Error uploading file:', err);
}
}
async function handlePaste(event: ClipboardEvent) {
const items = event.clipboardData?.items;
if (!items) return;
@@ -61,23 +97,54 @@ export function useMediaHandling() {
}
function removeImage(index: number) {
const urlToRevoke = stagedImagesUrl.value[index];
if (urlToRevoke && urlToRevoke.startsWith('blob:')) {
URL.revokeObjectURL(urlToRevoke);
// 找到第 index 个图片类型的文件
let imageCount = 0;
for (let i = 0; i < stagedFiles.value.length; i++) {
if (stagedFiles.value[i].type === 'image') {
if (imageCount === index) {
const fileToRemove = stagedFiles.value[i];
if (fileToRemove.url.startsWith('blob:')) {
URL.revokeObjectURL(fileToRemove.url);
}
stagedFiles.value.splice(i, 1);
return;
}
imageCount++;
}
}
stagedImagesName.value.splice(index, 1);
stagedImagesUrl.value.splice(index, 1);
}
function removeAudio() {
stagedAudioUrl.value = '';
}
function removeFile(index: number) {
// 找到第 index 个非图片类型的文件
let fileCount = 0;
for (let i = 0; i < stagedFiles.value.length; i++) {
if (stagedFiles.value[i].type !== 'image') {
if (fileCount === index) {
const fileToRemove = stagedFiles.value[i];
if (fileToRemove.url.startsWith('blob:')) {
URL.revokeObjectURL(fileToRemove.url);
}
stagedFiles.value.splice(i, 1);
return;
}
fileCount++;
}
}
}
function clearStaged() {
stagedImagesName.value = [];
stagedImagesUrl.value = [];
stagedAudioUrl.value = '';
// 清理文件的 blob URLs
stagedFiles.value.forEach(file => {
if (file.url.startsWith('blob:')) {
URL.revokeObjectURL(file.url);
}
});
stagedFiles.value = [];
}
function cleanupMediaCache() {
@@ -89,15 +156,28 @@ export function useMediaHandling() {
mediaCache.value = {};
}
// 计算属性:获取图片的 URL 列表(用于预览)
const stagedImagesUrl = computed(() =>
stagedFiles.value.filter(f => f.type === 'image').map(f => f.url)
);
// 计算属性:获取非图片文件列表
const stagedNonImageFiles = computed(() =>
stagedFiles.value.filter(f => f.type !== 'image')
);
return {
stagedImagesName,
stagedImagesUrl,
stagedAudioUrl,
stagedFiles,
stagedNonImageFiles,
getMediaFile,
processAndUploadImage,
processAndUploadFile,
handlePaste,
removeImage,
removeAudio,
removeFile,
clearStaged,
cleanupMediaCache
};

View File

@@ -2,19 +2,37 @@ import { ref, reactive, type Ref } from 'vue';
import axios from 'axios';
import { useToast } from '@/utils/toast';
// 新格式消息部分的类型定义
export interface MessagePart {
type: 'plain' | 'image' | 'record' | 'file' | 'video';
text?: string; // for plain
attachment_id?: string; // for image, record, file, video
filename?: string; // for file (filename from backend)
}
// 文件信息结构
export interface FileInfo {
url?: string; // blob URL (可选,点击时才加载)
filename: string;
attachment_id?: string; // 用于按需下载
}
export interface MessageContent {
type: string;
message: string;
message: string | MessagePart[]; // 支持旧格式(string)和新格式(MessagePart[])
reasoning?: string;
image_url?: string[];
audio_url?: string;
file_url?: FileInfo[];
embedded_images?: string[];
embedded_audio?: string;
embedded_files?: FileInfo[];
isLoading?: boolean;
}
export interface Message {
content: MessageContent;
created_at?: string;
}
export function useMessages(
@@ -29,6 +47,7 @@ export function useMessages(
const isToastedRunningInfo = ref(false);
const activeSSECount = ref(0);
const enableStreaming = ref(true);
const attachmentCache = new Map<string, string>(); // attachment_id -> blob URL
// 从 localStorage 读取流式响应开关状态
const savedStreamingState = localStorage.getItem('enableStreaming');
@@ -41,6 +60,68 @@ export function useMessages(
localStorage.setItem('enableStreaming', JSON.stringify(enableStreaming.value));
}
// 获取 attachment 文件并返回 blob URL
async function getAttachment(attachmentId: string): Promise<string> {
if (attachmentCache.has(attachmentId)) {
return attachmentCache.get(attachmentId)!;
}
try {
const response = await axios.get(`/api/chat/get_attachment?attachment_id=${attachmentId}`, {
responseType: 'blob'
});
const blobUrl = URL.createObjectURL(response.data);
attachmentCache.set(attachmentId, blobUrl);
return blobUrl;
} catch (err) {
console.error('Failed to get attachment:', attachmentId, err);
return '';
}
}
// 解析新格式消息为旧格式兼容的结构 (用于显示)
async function parseMessageContent(content: any): Promise<void> {
const message = content.message;
// 如果 message 是数组 (新格式)
if (Array.isArray(message)) {
let textParts: string[] = [];
let imageUrls: string[] = [];
let audioUrl: string | undefined;
let fileInfos: FileInfo[] = [];
for (const part of message as MessagePart[]) {
if (part.type === 'plain' && part.text) {
textParts.push(part.text);
} else if (part.type === 'image' && part.attachment_id) {
const url = await getAttachment(part.attachment_id);
if (url) imageUrls.push(url);
} else if (part.type === 'record' && part.attachment_id) {
audioUrl = await getAttachment(part.attachment_id);
} else if (part.type === 'file' && part.attachment_id) {
// file 类型不预加载,保留 attachment_id 以便点击时下载
fileInfos.push({
attachment_id: part.attachment_id,
filename: part.filename || 'file'
});
}
// video 类型可以后续扩展
}
// 转换为旧格式兼容的结构
content.message = textParts.join('\n');
if (content.type === 'user') {
content.image_url = imageUrls.length > 0 ? imageUrls : undefined;
content.audio_url = audioUrl;
content.file_url = fileInfos.length > 0 ? fileInfos : undefined;
} else {
content.embedded_images = imageUrls.length > 0 ? imageUrls : undefined;
content.embedded_audio = audioUrl;
content.embedded_files = fileInfos.length > 0 ? fileInfos : undefined;
}
}
// 如果 message 是字符串 (旧格式),保持原有处理逻辑
}
async function getSessionMessages(sessionId: string, router: any) {
if (!sessionId) return;
@@ -64,35 +145,45 @@ export function useMessages(
// 处理历史消息中的媒体文件
for (let i = 0; i < history.length; i++) {
let content = history[i].content;
if (content.message?.startsWith('[IMAGE]')) {
let img = content.message.replace('[IMAGE]', '');
const imageUrl = await getMediaFile(img);
if (!content.embedded_images) {
content.embedded_images = [];
// 首先尝试解析新格式消息
await parseMessageContent(content);
// 以下是旧格式的兼容处理 (message 是字符串的情况)
if (typeof content.message === 'string') {
if (content.message?.startsWith('[IMAGE]')) {
let img = content.message.replace('[IMAGE]', '');
const imageUrl = await getMediaFile(img);
if (!content.embedded_images) {
content.embedded_images = [];
}
content.embedded_images.push(imageUrl);
content.message = '';
}
if (content.message?.startsWith('[RECORD]')) {
let audio = content.message.replace('[RECORD]', '');
const audioUrl = await getMediaFile(audio);
content.embedded_audio = audioUrl;
content.message = '';
}
content.embedded_images.push(imageUrl);
content.message = '';
}
if (content.message?.startsWith('[RECORD]')) {
let audio = content.message.replace('[RECORD]', '');
const audioUrl = await getMediaFile(audio);
content.embedded_audio = audioUrl;
content.message = '';
}
// 旧格式中的 image_url 和 audio_url 字段处理
if (content.image_url && content.image_url.length > 0) {
for (let j = 0; j < content.image_url.length; j++) {
content.image_url[j] = await getMediaFile(content.image_url[j]);
// 检查是否已经是 blob URL (新格式解析后的结果)
if (!content.image_url[j].startsWith('blob:')) {
content.image_url[j] = await getMediaFile(content.image_url[j]);
}
}
}
if (content.audio_url) {
if (content.audio_url && !content.audio_url.startsWith('blob:')) {
content.audio_url = await getMediaFile(content.audio_url);
}
}
messages.value = history;
} catch (err) {
console.error(err);
@@ -101,7 +192,7 @@ export function useMessages(
async function sendMessage(
prompt: string,
imageNames: string[],
stagedFiles: { attachment_id: string; url: string; original_name: string; type: string }[],
audioName: string,
selectedProviderId: string,
selectedModelName: string
@@ -111,27 +202,33 @@ export function useMessages(
type: 'user',
message: prompt,
image_url: [],
audio_url: undefined
audio_url: undefined,
file_url: []
};
// Convert image filenames to blob URLs
if (imageNames.length > 0) {
const imagePromises = imageNames.map(name => {
if (!name.startsWith('blob:')) {
return getMediaFile(name);
}
return Promise.resolve(name);
});
userMessage.image_url = await Promise.all(imagePromises);
// 分离图片和文件
const imageFiles = stagedFiles.filter(f => f.type === 'image');
const nonImageFiles = stagedFiles.filter(f => f.type !== 'image');
// 使用 attachment_id 获取图片内容(避免 blob URL 被 revoke 后 404
if (imageFiles.length > 0) {
const imageUrls = await Promise.all(
imageFiles.map(f => getAttachment(f.attachment_id))
);
userMessage.image_url = imageUrls.filter(url => url !== '');
}
// Convert audio filename to blob URL
// 使用 blob URL 作为音频预览(录音不走 attachment
if (audioName) {
if (!audioName.startsWith('blob:')) {
userMessage.audio_url = await getMediaFile(audioName);
} else {
userMessage.audio_url = audioName;
}
userMessage.audio_url = audioName;
}
// 文件不预加载,只显示文件名和 attachment_id
if (nonImageFiles.length > 0) {
userMessage.file_url = nonImageFiles.map(f => ({
filename: f.original_name,
attachment_id: f.attachment_id
}));
}
messages.value.push({ content: userMessage });
@@ -151,6 +248,9 @@ export function useMessages(
isConvRunning.value = true;
}
// 收集所有 attachment_id
const files = stagedFiles.map(f => f.attachment_id);
const response = await fetch('/api/chat/send', {
method: 'POST',
headers: {
@@ -160,8 +260,7 @@ export function useMessages(
body: JSON.stringify({
message: prompt,
session_id: currSessionId.value,
image_url: imageNames,
audio_url: audioName ? [audioName] : [],
files: files,
selected_provider: selectedProviderId,
selected_model: selectedModelName,
enable_streaming: enableStreaming.value
@@ -207,6 +306,11 @@ export function useMessages(
continue;
}
const lastMsg = messages.value[messages.value.length - 1];
if (lastMsg?.content?.isLoading) {
messages.value.pop();
}
if (chunk_json.type === 'error') {
console.error('Error received:', chunk_json.data);
continue;
@@ -230,16 +334,26 @@ export function useMessages(
embedded_audio: audioUrl
};
messages.value.push({ content: bot_resp });
} else if (chunk_json.type === 'file') {
// 格式: [FILE]filename|original_name
let fileData = chunk_json.data.replace('[FILE]', '');
let [filename, originalName] = fileData.includes('|')
? fileData.split('|', 2)
: [fileData, fileData];
const fileUrl = await getMediaFile(filename);
let bot_resp: MessageContent = {
type: 'bot',
message: '',
embedded_files: [{
url: fileUrl,
filename: originalName
}]
};
messages.value.push({ content: bot_resp });
} else if (chunk_json.type === 'plain') {
const chain_type = chunk_json.chain_type || 'normal';
if (!in_streaming) {
// 移除加载占位符
const lastMsg = messages.value[messages.value.length - 1];
if (lastMsg?.content?.isLoading) {
messages.value.pop();
}
message_obj = reactive({
type: 'bot',
message: chain_type === 'reasoning' ? '' : chunk_json.data,
@@ -298,7 +412,8 @@ export function useMessages(
enableStreaming,
getSessionMessages,
sendMessage,
toggleStreaming
toggleStreaming,
getAttachment
};
}

View File

@@ -71,6 +71,10 @@
"reasoning": {
"thinking": "Thinking Process"
},
"time": {
"today": "Today",
"yesterday": "Yesterday"
},
"connection": {
"title": "Connection Status Notice",
"message": "The system detected that the chat connection needs to be re-established.",
@@ -85,5 +89,9 @@
"reconnected": "Chat connection re-established",
"failed": "Connection failed, please refresh the page"
}
},
"errors": {
"sendMessageFailed": "Failed to send message, please try again",
"createSessionFailed": "Failed to create session, please refresh the page"
}
}

View File

@@ -36,13 +36,16 @@
},
"table": {
"headers": {
"umoInfo": "Session Info",
"umoInfo": "Unified Message Origin",
"rulesOverview": "Rules Overview",
"actions": "Actions"
}
},
"persona": {
"none": "No Persona"
"none": "Follow Config"
},
"provider": {
"followConfig": "Follow Config"
},
"addRule": {
"title": "Add Custom Rule",
@@ -70,6 +73,17 @@
"title": "Persona Configuration",
"selectPersona": "Select Persona",
"hint": "Persona settings affect the conversation style and behavior of the LLM"
},
"pluginConfig": {
"title": "Plugin Configuration",
"disabledPlugins": "Disabled Plugins",
"hint": "Select plugins to disable for this session. Unselected plugins will remain enabled."
},
"kbConfig": {
"title": "Knowledge Base Configuration",
"selectKbs": "Select Knowledge Bases",
"topK": "Top K Results",
"enableRerank": "Enable Reranking"
}
},
"deleteConfirm": {

View File

@@ -71,6 +71,10 @@
"reasoning": {
"thinking": "思考过程"
},
"time": {
"today": "今天",
"yesterday": "昨天"
},
"connection": {
"title": "连接状态提醒",
"message": "系统检测到聊天连接需要重新建立。",
@@ -85,5 +89,9 @@
"reconnected": "聊天连接已重新建立",
"failed": "连接失败,请刷新页面重试"
}
},
"errors": {
"sendMessageFailed": "发送消息失败,请重试",
"createSessionFailed": "创建会话失败,请刷新页面重试"
}
}

View File

@@ -42,7 +42,10 @@
}
},
"persona": {
"none": "无人格"
"none": "跟随配置文件"
},
"provider": {
"followConfig": "跟随配置文件"
},
"addRule": {
"title": "添加自定义规则",
@@ -69,7 +72,18 @@
"personaConfig": {
"title": "人格配置",
"selectPersona": "选择人格",
"hint": "人格配置会影响 LLM 的对话风格和行为"
"hint": "应用人格配置后,将会强制该来源的所有对话使用该人格。"
},
"pluginConfig": {
"title": "插件配置",
"disabledPlugins": "禁用的插件",
"hint": "选择要在此会话中禁用的插件。未选择的插件将保持启用状态。"
},
"kbConfig": {
"title": "知识库配置",
"selectKbs": "选择知识库",
"topK": "返回结果数量 (Top K)",
"enableRerank": "启用重排序"
}
},
"deleteConfirm": {

View File

@@ -69,7 +69,7 @@ const sidebarItem: menu[] = [
},
{
title: 'core.navigation.sessionManagement',
icon: 'mdi-account-group',
icon: 'mdi-pencil-ruler',
to: '/session-management'
},
{

View File

@@ -45,6 +45,15 @@
@click="configToString(); codeEditorDialog = true">
</v-btn>
<v-tooltip text="测试当前配置" location="left" v-if="!isSystemConfig">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" icon="mdi-chat-processing" size="x-large"
style="position: fixed; right: 52px; bottom: 196px;" color="secondary"
@click="openTestChat">
</v-btn>
</template>
</v-tooltip>
</div>
</v-slide-y-transition>
@@ -135,6 +144,34 @@
</v-snackbar>
<WaitingForRestart ref="wfr"></WaitingForRestart>
<!-- 测试聊天抽屉 -->
<v-overlay
v-model="testChatDrawer"
class="test-chat-overlay"
location="right"
transition="slide-x-reverse-transition"
:scrim="true"
@click:outside="closeTestChat"
>
<v-card class="test-chat-card" elevation="12">
<div class="test-chat-header">
<div>
<span class="text-h6">测试配置</span>
<div v-if="selectedConfigInfo.name" class="text-caption text-grey">
{{ selectedConfigInfo.name }} ({{ testConfigId }})
</div>
</div>
<v-btn icon variant="text" @click="closeTestChat">
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
<v-divider></v-divider>
<div class="test-chat-content">
<StandaloneChat v-if="testChatDrawer" :configId="testConfigId" />
</div>
</v-card>
</v-overlay>
</template>
@@ -142,6 +179,7 @@
import axios from 'axios';
import AstrBotCoreConfigWrapper from '@/components/config/AstrBotCoreConfigWrapper.vue';
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
import StandaloneChat from '@/components/chat/StandaloneChat.vue';
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
import { useI18n, useModuleI18n } from '@/i18n/composables';
@@ -150,7 +188,8 @@ export default {
components: {
AstrBotCoreConfigWrapper,
VueMonacoEditor,
WaitingForRestart
WaitingForRestart,
StandaloneChat
},
props: {
initialConfigId: {
@@ -238,6 +277,10 @@ export default {
name: '',
},
editingConfigId: null,
// 测试聊天
testChatDrawer: false,
testConfigId: null,
}
},
mounted() {
@@ -506,6 +549,20 @@ export default {
this.getConfigInfoList("default");
}
}
},
openTestChat() {
if (!this.selectedConfigID) {
this.save_message = "请先选择一个配置文件";
this.save_message_snack = true;
this.save_message_success = "warning";
return;
}
this.testConfigId = this.selectedConfigID;
this.testChatDrawer = true;
},
closeTestChat() {
this.testChatDrawer = false;
this.testConfigId = null;
}
},
}
@@ -565,4 +622,32 @@ export default {
width: 100%;
}
}
/* 测试聊天抽屉样式 */
.test-chat-overlay {
align-items: stretch;
justify-content: flex-end;
}
.test-chat-card {
width: clamp(320px, 50vw, 720px);
height: calc(100vh - 32px);
display: flex;
flex-direction: column;
margin: 16px;
}
.test-chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 12px 20px;
}
.test-chat-content {
flex: 1;
overflow: hidden;
padding: 0;
border-radius: 0 0 16px 16px;
}
</style>

View File

@@ -9,6 +9,7 @@ import axios from 'axios';
import { pinyin } from 'pinyin-pro';
import { useCommonStore } from '@/stores/common';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import defaultPluginIcon from '@/assets/images/plugin_icon.png';
import { ref, computed, onMounted, reactive, inject, watch } from 'vue';
@@ -136,10 +137,11 @@ const pluginMarketHeaders = computed(() => [
// 过滤要显示的插件
const filteredExtensions = computed(() => {
const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
if (!showReserved.value) {
return extension_data?.data?.filter(ext => !ext.reserved) || [];
return data.filter(ext => !ext.reserved);
}
return extension_data.data || [];
return data;
});
// 通过搜索过滤插件
@@ -274,7 +276,8 @@ const checkUpdate = () => {
onlinePluginsNameMap.set(plugin.name, plugin);
});
extension_data.data.forEach(extension => {
const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
data.forEach(extension => {
const repoKey = extension.repo?.toLowerCase();
const onlinePlugin = repoKey ? onlinePluginsMap.get(repoKey) : null;
const onlinePluginByName = onlinePluginsNameMap.get(extension.name);
@@ -506,8 +509,9 @@ const trimExtensionName = () => {
};
const checkAlreadyInstalled = () => {
const installedRepos = new Set(extension_data.data.map(ext => ext.repo?.toLowerCase()));
const installedNames = new Set(extension_data.data.map(ext => ext.name));
const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
const installedRepos = new Set(data.map(ext => ext.repo?.toLowerCase()));
const installedNames = new Set(data.map(ext => ext.name));
for (let i = 0; i < pluginMarketData.value.length; i++) {
const plugin = pluginMarketData.value[i];
@@ -939,7 +943,7 @@ watch(marketSearch, (newVal) => {
<v-row style="min-height: 26rem;">
<v-col v-for="plugin in paginatedPlugins" :key="plugin.name" cols="12" md="6" lg="4">
<v-card class="rounded-lg d-flex flex-column" elevation="0"
<v-card class="rounded-lg d-flex flex-column plugin-card" elevation="0"
style=" height: 12rem; position: relative;">
<!-- 推荐标记 -->
@@ -950,8 +954,8 @@ watch(marketSearch, (newVal) => {
<v-card-text
style="padding: 12px; padding-bottom: 8px; display: flex; gap: 12px; width: 100%; flex: 1; overflow: hidden;">
<div v-if="plugin?.logo" style="flex-shrink: 0;">
<img :src="plugin.logo" :alt="plugin.name"
<div style="flex-shrink: 0;">
<img :src="plugin?.logo || defaultPluginIcon" :alt="plugin.name"
style="height: 75px; width: 75px; border-radius: 8px; object-fit: cover;" />
</div>
@@ -986,8 +990,7 @@ watch(marketSearch, (newVal) => {
</div>
<!-- Description -->
<div class="text-caption"
style="overflow: scroll; color: rgba(var(--v-theme-on-surface), 0.6); line-height: 1.3; margin-bottom: 6px; flex: 1;">
<div class="text-caption plugin-description">
{{ plugin.desc }}
</div>
@@ -1246,4 +1249,36 @@ watch(marketSearch, (newVal) => {
border-radius: 5px;
background-color: #f5f5f5;
}
.plugin-description {
color: rgba(var(--v-theme-on-surface), 0.6);
line-height: 1.3;
margin-bottom: 6px;
flex: 1;
overflow-y: hidden;
}
.plugin-card:hover .plugin-description {
overflow-y: auto;
}
.plugin-description::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.plugin-description::-webkit-scrollbar-track {
background: transparent;
}
.plugin-description::-webkit-scrollbar-thumb {
background-color: rgba(var(--v-theme-primary-rgb), 0.4);
border-radius: 4px;
border: 2px solid transparent;
background-clip: content-box;
}
.plugin-description::-webkit-scrollbar-thumb:hover {
background-color: rgba(var(--v-theme-primary-rgb), 0.6);
}
</style>

View File

@@ -69,6 +69,25 @@
:loading="isProviderTesting(provider.id)" @toggle-enabled="providerStatusChange"
:bglogo="getProviderIcon(provider.provider)" @delete="deleteProvider" @edit="configExistingProvider"
@copy="copyProvider" :show-copy-button="true">
<template #item-details="{ item }">
<!-- 测试状态 chip -->
<v-tooltip v-if="getProviderStatus(item.id)" location="top" max-width="300">
<template v-slot:activator="{ props }">
<v-chip v-bind="props" :color="getStatusColor(getProviderStatus(item.id).status)" size="small">
<v-icon start size="small">
{{ getProviderStatus(item.id).status === 'available' ? 'mdi-check-circle' :
getProviderStatus(item.id).status === 'unavailable' ? 'mdi-alert-circle' :
'mdi-clock-outline' }}
</v-icon>
{{ getStatusText(getProviderStatus(item.id).status) }}
</v-chip>
</template>
<span v-if="getProviderStatus(item.id).status === 'unavailable'">
{{ getProviderStatus(item.id).error }}
</span>
<span v-else>{{ getStatusText(getProviderStatus(item.id).status) }}</span>
</v-tooltip>
</template>
<template #actions="{ item }">
<v-btn style="z-index: 100000;" variant="tonal" color="info" rounded="xl" size="small"
:loading="isProviderTesting(item.id)" @click="testSingleProvider(item)">
@@ -96,73 +115,38 @@
:loading="isProviderTesting(provider.id)" @toggle-enabled="providerStatusChange"
:bglogo="getProviderIcon(provider.provider)" @delete="deleteProvider" @edit="configExistingProvider"
@copy="copyProvider" :show-copy-button="true">
<template #item-details="{ item }">
<!-- 测试状态 chip -->
<v-tooltip v-if="getProviderStatus(item.id)" location="top" max-width="300">
<template v-slot:activator="{ props }">
<v-chip v-bind="props" :color="getStatusColor(getProviderStatus(item.id).status)" size="small">
<v-icon start size="small">
{{ getProviderStatus(item.id).status === 'available' ? 'mdi-check-circle' :
getProviderStatus(item.id).status === 'unavailable' ? 'mdi-alert-circle' :
'mdi-clock-outline' }}
</v-icon>
{{ getStatusText(getProviderStatus(item.id).status) }}
</v-chip>
</template>
<span v-if="getProviderStatus(item.id).status === 'unavailable'">
{{ getProviderStatus(item.id).error }}
</span>
<span v-else>{{ getStatusText(getProviderStatus(item.id).status) }}</span>
</v-tooltip>
</template>
<template #actions="{ item }">
<v-btn style="z-index: 100000;" variant="tonal" color="info" rounded="xl" size="small"
:loading="isProviderTesting(item.id)" @click="testSingleProvider(item)">
{{ tm('availability.test') }}
</v-btn>
</template>
<template v-slot:details="{ item }">
</template>
</item-card>
</v-col>
</v-row>
</template>
</div>
<!-- 供应商状态部分 -->
<v-card elevation="0" class="mt-4">
<v-card-title class="d-flex align-center py-3 px-4">
<v-icon class="me-2">mdi-heart-pulse</v-icon>
<span class="text-h4">{{ tm('availability.title') }}</span>
<v-spacer></v-spacer>
<v-btn color="primary" variant="tonal" :loading="testingProviders.length > 0" @click="fetchProviderStatus">
<v-icon left>mdi-refresh</v-icon>
{{ tm('availability.refresh') }}
</v-btn>
<v-btn variant="text" color="primary" @click="showStatus = !showStatus" style="margin-left: 8px;">
{{ showStatus ? tm('logs.collapse') : tm('logs.expand') }}
<v-icon>{{ showStatus ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
</v-btn>
</v-card-title>
<v-expand-transition>
<v-card-text class="pa-0" v-if="showStatus">
<v-card-text class="px-4 py-3">
<v-alert v-if="providerStatuses.length === 0" type="info" variant="tonal">
{{ tm('availability.noData') }}
</v-alert>
<v-container v-else class="pa-0">
<v-row>
<v-col v-for="status in providerStatuses" :key="status.id" cols="12" sm="6" md="4">
<v-card variant="outlined" class="status-card" :class="`status-${status.status}`">
<v-card-item>
<v-icon v-if="status.status === 'available'" color="success"
class="me-2">mdi-check-circle</v-icon>
<v-icon v-else-if="status.status === 'unavailable'" color="error"
class="me-2">mdi-alert-circle</v-icon>
<v-progress-circular v-else-if="status.status === 'pending'" indeterminate color="primary"
size="20" width="2" class="me-2"></v-progress-circular>
<span class="font-weight-bold">{{ status.id }}</span>
<v-chip :color="getStatusColor(status.status)" size="small" class="ml-2">
{{ getStatusText(status.status) }}
</v-chip>
</v-card-item>
<v-card-text v-if="status.status === 'unavailable'" class="text-caption text-medium-emphasis">
<span class="font-weight-bold">{{ tm('availability.errorMessage') }}:</span> {{ status.error }}
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</v-card-text>
</v-card-text>
</v-expand-transition>
</v-card>
<!-- 日志部分 -->
<v-card elevation="0" class="mt-4">
<v-card-title class="d-flex align-center py-3 px-4">
@@ -751,11 +735,14 @@ export default {
return this.testingProviders.includes(providerId);
},
getProviderStatus(providerId) {
return this.providerStatuses.find(s => s.id === providerId);
},
async testSingleProvider(provider) {
if (this.isProviderTesting(provider.id)) return;
this.testingProviders.push(provider.id);
this.showStatus = true; // 自动展开状态部分
// 更新UI为pending状态
const statusIndex = this.providerStatuses.findIndex(s => s.id === provider.id);

View File

@@ -4,7 +4,8 @@
<v-card flat>
<v-card-title class="d-flex align-center py-3 px-4">
<span class="text-h4">{{ tm('customRules.title') }}</span>
<v-chip size="small" class="ml-2">{{ totalItems }} {{ tm('customRules.rulesCount') }}</v-chip>
<v-btn icon="mdi-information-outline" size="small" variant="text" href="https://astrbot.app/use/custom-rules.html" target="_blank"></v-btn>
<v-chip size="small" class="ml-1">{{ totalItems }} {{ tm('customRules.rulesCount') }}</v-chip>
<v-row class="me-4 ms-4" dense>
<v-text-field v-model="searchQuery" prepend-inner-icon="mdi-magnify" :label="tm('search.placeholder')"
hide-details clearable variant="solo-filled" flat class="me-4" density="compact"></v-text-field>
@@ -28,8 +29,8 @@
<v-card-text class="pa-0">
<v-data-table-server :headers="headers" :items="filteredRulesList" :loading="loading"
:items-length="totalItems" v-model:items-per-page="itemsPerPage" v-model:page="currentPage"
@update:options="onTableOptionsUpdate" class="elevation-0" style="font-size: 12px;"
v-model="selectedItems" show-select item-value="umo" return-object>
@update:options="onTableOptionsUpdate" class="elevation-0" style="font-size: 12px;" v-model="selectedItems"
show-select item-value="umo" return-object>
<!-- UMO 信息 -->
<template v-slot:item.umo_info="{ item }">
@@ -40,7 +41,8 @@
</v-chip>
<span class="text-truncate" style="max-width: 300px;">{{ item.umo }}</span>
<div class="d-flex align-center" v-if="item.rules?.session_service_config?.custom_name || true">
<span class="ml-2" style="color: gray; font-size: 10px;" v-if="item.rules?.session_service_config?.custom_name">
<span class="ml-2" style="color: gray; font-size: 10px;"
v-if="item.rules?.session_service_config?.custom_name">
({{ item.rules?.session_service_config?.custom_name }})
</span>
<v-btn icon size="x-small" variant="text" class="ml-1" @click.stop="openQuickEditName(item)">
@@ -141,11 +143,11 @@
</v-dialog>
<!-- 规则编辑对话框 -->
<v-dialog v-model="ruleDialog" max-width="700" scrollable>
<v-dialog v-model="ruleDialog" max-width="550" scrollable>
<v-card v-if="selectedUmo" class="d-flex flex-column" height="600">
<v-card-title class="py-3 px-6 d-flex align-center border-b">
<span>{{ tm('ruleEditor.title') }}</span>
<v-chip size="small" class="ml-4 font-weight-regular" variant="outlined">
<v-chip size="x-small" class="ml-2 font-weight-regular" variant="outlined">
{{ selectedUmo.umo }}
</v-chip>
<v-spacer></v-spacer>
@@ -179,9 +181,6 @@
</v-row>
<div class="d-flex justify-end mt-4">
<v-btn variant="text" color="error" size="small" @click="clearServiceConfig" class="mr-2">
{{ tm('buttons.clear') }}
</v-btn>
<v-btn color="primary" variant="tonal" size="small" @click="saveServiceConfig" :loading="saving"
prepend-icon="mdi-content-save">
{{ tm('buttons.save') }}
@@ -197,24 +196,21 @@
<v-col cols="12">
<v-select v-model="providerConfig.chat_completion" :items="chatProviderOptions" item-title="label"
item-value="value" :label="tm('ruleEditor.providerConfig.chatProvider')" variant="outlined"
hide-details clearable class="mb-2" />
hide-details class="mb-2" />
</v-col>
<v-col cols="12">
<v-select v-model="providerConfig.speech_to_text" :items="sttProviderOptions" item-title="label"
item-value="value" :label="tm('ruleEditor.providerConfig.sttProvider')" variant="outlined"
hide-details clearable :disabled="sttProviderOptions.length === 0" class="mb-2" />
hide-details :disabled="availableSttProviders.length === 0" class="mb-2" />
</v-col>
<v-col cols="12">
<v-select v-model="providerConfig.text_to_speech" :items="ttsProviderOptions" item-title="label"
item-value="value" :label="tm('ruleEditor.providerConfig.ttsProvider')" variant="outlined"
hide-details clearable :disabled="ttsProviderOptions.length === 0" />
hide-details :disabled="availableTtsProviders.length === 0" />
</v-col>
</v-row>
<div class="d-flex justify-end mt-4">
<v-btn variant="text" color="error" size="small" @click="clearProviderConfig" class="mr-2">
{{ tm('buttons.clear') }}
</v-btn>
<v-btn color="primary" variant="tonal" size="small" @click="saveProviderConfig" :loading="saving"
prepend-icon="mdi-content-save">
{{ tm('buttons.save') }}
@@ -245,6 +241,59 @@
{{ tm('buttons.save') }}
</v-btn>
</div>
<!-- Plugin Config Section -->
<div class="d-flex align-center mb-4 mt-4">
<h3 class="font-weight-bold mb-0">{{ tm('ruleEditor.pluginConfig.title') }}</h3>
</div>
<v-row dense>
<v-col cols="12">
<v-select v-model="pluginConfig.disabled_plugins" :items="pluginOptions" item-title="label"
item-value="value" :label="tm('ruleEditor.pluginConfig.disabledPlugins')" variant="outlined"
hide-details multiple chips closable-chips clearable />
</v-col>
<v-col cols="12">
<v-alert type="info" variant="tonal" class="mt-2" icon="mdi-information-outline">
{{ tm('ruleEditor.pluginConfig.hint') }}
</v-alert>
</v-col>
</v-row>
<div class="d-flex justify-end mt-4">
<v-btn color="primary" variant="tonal" size="small" @click="savePluginConfig" :loading="saving"
prepend-icon="mdi-content-save">
{{ tm('buttons.save') }}
</v-btn>
</div>
<!-- KB Config Section -->
<div class="d-flex align-center mb-4 mt-4">
<h3 class="font-weight-bold mb-0">{{ tm('ruleEditor.kbConfig.title') }}</h3>
</div>
<v-row dense>
<v-col cols="12">
<v-select v-model="kbConfig.kb_ids" :items="kbOptions" item-title="label" item-value="value" :disabled="availableKbs.length === 0"
:label="tm('ruleEditor.kbConfig.selectKbs')" variant="outlined" hide-details multiple chips
closable-chips clearable />
</v-col>
<v-col cols="12" md="6">
<v-text-field v-model.number="kbConfig.top_k" :label="tm('ruleEditor.kbConfig.topK')"
variant="outlined" hide-details type="number" min="1" max="20" class="mt-3"/>
</v-col>
<v-col cols="12" md="6">
<v-checkbox v-model="kbConfig.enable_rerank" :label="tm('ruleEditor.kbConfig.enableRerank')"
color="primary" hide-details class="mt-3"/>
</v-col>
</v-row>
<div class="d-flex justify-end mt-4">
<v-btn color="primary" variant="tonal" size="small" @click="saveKbConfig" :loading="saving"
prepend-icon="mdi-content-save">
{{ tm('buttons.save') }}
</v-btn>
</div>
</div>
</v-card-text>
</v-card>
@@ -263,7 +312,7 @@
<v-spacer></v-spacer>
<v-btn variant="text" @click="deleteDialog = false">{{ tm('buttons.cancel') }}</v-btn>
<v-btn color="error" variant="tonal" @click="deleteAllRules" :loading="deleting">{{ tm('buttons.delete')
}}</v-btn>
}}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
@@ -351,6 +400,8 @@ export default {
availableChatProviders: [],
availableSttProviders: [],
availableTtsProviders: [],
availablePlugins: [],
availableKbs: [],
// 添加规则
addRuleDialog: false,
@@ -378,6 +429,19 @@ export default {
text_to_speech: null,
},
// 插件配置
pluginConfig: {
enabled_plugins: [],
disabled_plugins: [],
},
// 知识库配置
kbConfig: {
kb_ids: [],
top_k: 5,
enable_rerank: true,
},
// 删除确认
deleteDialog: false,
deleteTarget: null,
@@ -423,23 +487,46 @@ export default {
},
chatProviderOptions() {
return this.availableChatProviders.map(p => ({
label: `${p.name} (${p.model})`,
value: p.id
}))
return [
{ label: this.tm('provider.followConfig'), value: null },
...this.availableChatProviders.map(p => ({
label: `${p.name} (${p.model})`,
value: p.id
}))
]
},
sttProviderOptions() {
return this.availableSttProviders.map(p => ({
label: `${p.name} (${p.model})`,
value: p.id
}))
return [
{ label: this.tm('provider.followConfig'), value: null },
...this.availableSttProviders.map(p => ({
label: `${p.name} (${p.model})`,
value: p.id
}))
]
},
ttsProviderOptions() {
return this.availableTtsProviders.map(p => ({
label: `${p.name} (${p.model})`,
value: p.id
return [
{ label: this.tm('provider.followConfig'), value: null },
...this.availableTtsProviders.map(p => ({
label: `${p.name} (${p.model})`,
value: p.id
}))
]
},
pluginOptions() {
return this.availablePlugins.map(p => ({
label: p.display_name || p.name,
value: p.name
}))
},
kbOptions() {
return this.availableKbs.map(kb => ({
label: `${kb.emoji || '📚'} ${kb.kb_name}`,
value: kb.kb_id
}))
},
},
@@ -487,6 +574,8 @@ export default {
this.availableChatProviders = data.available_chat_providers
this.availableSttProviders = data.available_stt_providers
this.availableTtsProviders = data.available_tts_providers
this.availablePlugins = data.available_plugins || []
this.availableKbs = data.available_kbs || []
} else {
this.showError(response.data.message || this.tm('messages.loadError'))
}
@@ -584,6 +673,21 @@ export default {
text_to_speech: this.editingRules['provider_perf_text_to_speech'] || null,
}
// 初始化插件配置
const pluginCfg = this.editingRules.session_plugin_config || {}
this.pluginConfig = {
enabled_plugins: pluginCfg.enabled_plugins || [],
disabled_plugins: pluginCfg.disabled_plugins || [],
}
// 初始化知识库配置
const kbCfg = this.editingRules.kb_config || {}
this.kbConfig = {
kb_ids: kbCfg.kb_ids || [],
top_k: kbCfg.top_k ?? 5,
enable_rerank: kbCfg.enable_rerank !== false,
}
this.ruleDialog = true
},
@@ -636,68 +740,40 @@ export default {
this.saving = false
},
async clearServiceConfig() {
if (!this.selectedUmo) return
this.saving = true
try {
const response = await axios.post('/api/session/delete-rule', {
umo: this.selectedUmo.umo,
rule_key: 'session_service_config'
})
if (response.data.status === 'ok') {
this.showSuccess(this.tm('messages.clearSuccess'))
delete this.editingRules.session_service_config
this.serviceConfig = {
session_enabled: true,
llm_enabled: true,
tts_enabled: true,
custom_name: '',
persona_id: null,
}
// 更新列表中的数据
const item = this.rulesList.find(u => u.umo === this.selectedUmo.umo)
if (item) {
delete item.rules.session_service_config
// 如果没有任何规则了,从列表中移除
if (Object.keys(item.rules).length === 0) {
const index = this.rulesList.findIndex(u => u.umo === this.selectedUmo.umo)
if (index > -1) this.rulesList.splice(index, 1)
}
}
} else {
this.showError(response.data.message || this.tm('messages.clearError'))
}
} catch (error) {
this.showError(error.response?.data?.message || this.tm('messages.clearError'))
}
this.saving = false
},
async saveProviderConfig() {
if (!this.selectedUmo) return
this.saving = true
try {
const tasks = []
const updateTasks = []
const deleteTasks = []
const providerTypes = ['chat_completion', 'speech_to_text', 'text_to_speech']
for (const type of providerTypes) {
const value = this.providerConfig[type]
if (value) {
tasks.push(
// 有值时更新
updateTasks.push(
axios.post('/api/session/update-rule', {
umo: this.selectedUmo.umo,
rule_key: `provider_perf_${type}`,
rule_value: value
})
)
} else if (this.editingRules[`provider_perf_${type}`]) {
// 选择了"跟随配置文件"null且之前有配置则删除
deleteTasks.push(
axios.post('/api/session/delete-rule', {
umo: this.selectedUmo.umo,
rule_key: `provider_perf_${type}`
})
)
}
}
if (tasks.length > 0) {
await Promise.all(tasks)
const allTasks = [...updateTasks, ...deleteTasks]
if (allTasks.length > 0) {
await Promise.all(allTasks)
this.showSuccess(this.tm('messages.saveSuccess'))
// 更新或添加到列表
@@ -716,6 +792,10 @@ export default {
if (this.providerConfig[type]) {
item.rules[`provider_perf_${type}`] = this.providerConfig[type]
this.editingRules[`provider_perf_${type}`] = this.providerConfig[type]
} else {
// 删除本地数据
delete item.rules[`provider_perf_${type}`]
delete this.editingRules[`provider_perf_${type}`]
}
}
} else {
@@ -727,42 +807,113 @@ export default {
this.saving = false
},
async clearProviderConfig() {
async savePluginConfig() {
if (!this.selectedUmo) return
this.saving = true
try {
const providerTypes = ['chat_completion', 'speech_to_text', 'text_to_speech']
const tasks = providerTypes.map(type =>
axios.post('/api/session/delete-rule', {
umo: this.selectedUmo.umo,
rule_key: `provider_perf_${type}`
})
)
await Promise.all(tasks)
this.showSuccess(this.tm('messages.clearSuccess'))
// 更新本地数据
this.providerConfig = {
chat_completion: null,
speech_to_text: null,
text_to_speech: null,
const config = {
enabled_plugins: this.pluginConfig.enabled_plugins,
disabled_plugins: this.pluginConfig.disabled_plugins,
}
const item = this.rulesList.find(u => u.umo === this.selectedUmo.umo)
if (item) {
for (const type of providerTypes) {
delete item.rules[`provider_perf_${type}`]
delete this.editingRules[`provider_perf_${type}`]
// 如果两个列表都为空,删除配置
if (config.enabled_plugins.length === 0 && config.disabled_plugins.length === 0) {
if (this.editingRules.session_plugin_config) {
await axios.post('/api/session/delete-rule', {
umo: this.selectedUmo.umo,
rule_key: 'session_plugin_config'
})
delete this.editingRules.session_plugin_config
let item = this.rulesList.find(u => u.umo === this.selectedUmo.umo)
if (item) delete item.rules.session_plugin_config
}
// 如果没有任何规则了,从列表中移除
if (Object.keys(item.rules).length === 0) {
const index = this.rulesList.findIndex(u => u.umo === this.selectedUmo.umo)
if (index > -1) this.rulesList.splice(index, 1)
this.showSuccess(this.tm('messages.saveSuccess'))
} else {
const response = await axios.post('/api/session/update-rule', {
umo: this.selectedUmo.umo,
rule_key: 'session_plugin_config',
rule_value: config
})
if (response.data.status === 'ok') {
this.showSuccess(this.tm('messages.saveSuccess'))
this.editingRules.session_plugin_config = config
let item = this.rulesList.find(u => u.umo === this.selectedUmo.umo)
if (item) {
item.rules.session_plugin_config = config
} else {
this.rulesList.push({
umo: this.selectedUmo.umo,
platform: this.selectedUmo.platform,
message_type: this.selectedUmo.message_type,
session_id: this.selectedUmo.session_id,
rules: { session_plugin_config: config }
})
}
} else {
this.showError(response.data.message || this.tm('messages.saveError'))
}
}
} catch (error) {
this.showError(error.response?.data?.message || this.tm('messages.clearError'))
this.showError(error.response?.data?.message || this.tm('messages.saveError'))
}
this.saving = false
},
async saveKbConfig() {
if (!this.selectedUmo) return
this.saving = true
try {
const config = {
kb_ids: this.kbConfig.kb_ids,
top_k: this.kbConfig.top_k,
enable_rerank: this.kbConfig.enable_rerank,
}
// 如果 kb_ids 为空,删除配置
if (config.kb_ids.length === 0) {
if (this.editingRules.kb_config) {
await axios.post('/api/session/delete-rule', {
umo: this.selectedUmo.umo,
rule_key: 'kb_config'
})
delete this.editingRules.kb_config
let item = this.rulesList.find(u => u.umo === this.selectedUmo.umo)
if (item) delete item.rules.kb_config
}
this.showSuccess(this.tm('messages.saveSuccess'))
} else {
const response = await axios.post('/api/session/update-rule', {
umo: this.selectedUmo.umo,
rule_key: 'kb_config',
rule_value: config
})
if (response.data.status === 'ok') {
this.showSuccess(this.tm('messages.saveSuccess'))
this.editingRules.kb_config = config
let item = this.rulesList.find(u => u.umo === this.selectedUmo.umo)
if (item) {
item.rules.kb_config = config
} else {
this.rulesList.push({
umo: this.selectedUmo.umo,
platform: this.selectedUmo.platform,
message_type: this.selectedUmo.message_type,
session_id: this.selectedUmo.session_id,
rules: { kb_config: config }
})
}
} else {
this.showError(response.data.message || this.tm('messages.saveError'))
}
}
} catch (error) {
this.showError(error.response?.data?.message || this.tm('messages.saveError'))
}
this.saving = false
},

View File

@@ -6,7 +6,7 @@ from collections import defaultdict
from astrbot import logger
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent
from astrbot.api.message_components import Image, Plain
from astrbot.api.message_components import At, Image, Plain
from astrbot.api.platform import MessageType
from astrbot.api.provider import Provider, ProviderRequest
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
@@ -142,6 +142,8 @@ class LongTermMemory:
logger.error(f"获取图片描述失败: {e}")
else:
parts.append(" [Image]")
elif isinstance(comp, At):
parts.append(f" [At: {comp.name}]")
final_message = "".join(parts)
logger.debug(f"ltm | {event.unified_msg_origin} | {final_message}")

View File

@@ -41,7 +41,6 @@ class ProcessLLMRequest:
if default_persona:
persona_id = default_persona["name"]
persona = next(
builtins.filter(
lambda persona: persona["name"] == persona_id,

View File

@@ -14,7 +14,7 @@ from astrbot.core.utils.session_waiter import (
)
class Waiter(Star):
class Main(Star):
"""会话控制"""
def __init__(self, context: Context):

View File

@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "4.6.1"
version = "4.7.1"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
requires-python = ">=3.10"

View File

@@ -21,7 +21,17 @@ async def core_lifecycle_td(tmp_path_factory):
log_broker = LogBroker()
core_lifecycle = AstrBotCoreLifecycle(log_broker, db)
await core_lifecycle.initialize()
return core_lifecycle
try:
yield core_lifecycle
finally:
# 优先停止核心生命周期以释放资源(包括关闭 MCP 等后台任务)
try:
_stop_res = core_lifecycle.stop()
if asyncio.iscoroutine(_stop_res):
await _stop_res
except Exception:
# 停止过程中如有异常,不影响后续清理
pass
@pytest.fixture(scope="module")

View File

@@ -39,6 +39,7 @@ def plugin_manager_pm(tmp_path):
message_history_manager = MagicMock()
persona_manager = MagicMock()
astrbot_config_mgr = MagicMock()
knowledge_base_manager = MagicMock()
star_context = Context(
event_queue,
@@ -50,6 +51,7 @@ def plugin_manager_pm(tmp_path):
message_history_manager,
persona_manager,
astrbot_config_mgr,
knowledge_base_manager=knowledge_base_manager,
)
# Create the PluginManager instance