Compare commits

...

23 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
35 changed files with 1303 additions and 661 deletions
+1
View File
@@ -34,6 +34,7 @@ dashboard/node_modules/
dashboard/dist/ dashboard/dist/
package-lock.json package-lock.json
package.json package.json
yarn.lock
# Operating System # Operating System
**/.DS_Store **/.DS_Store
+1 -1
View File
@@ -1 +1 @@
__version__ = "3.5.23" __version__ = "4.7.1"
+3 -3
View File
@@ -345,9 +345,6 @@ class MCPClient:
async def cleanup(self): async def cleanup(self):
"""Clean up resources including old exit stacks from reconnections""" """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 # Close current exit stack
try: try:
await self.exit_stack.aclose() await self.exit_stack.aclose()
@@ -359,6 +356,9 @@ class MCPClient:
# Just clear the list to release references # Just clear the list to release references
self._old_exit_stacks.clear() self._old_exit_stacks.clear()
# Set running_event first to unblock any waiting tasks
self.running_event.set()
class MCPTool(FunctionTool, Generic[TContext]): class MCPTool(FunctionTool, Generic[TContext]):
"""A function tool that calls an MCP service.""" """A function tool that calls an MCP service."""
+1 -1
View File
@@ -4,7 +4,7 @@ import os
from astrbot.core.utils.astrbot_path import get_astrbot_data_path 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") DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
# 默认配置 # 默认配置
+21
View File
@@ -213,6 +213,27 @@ class BaseDatabase(abc.ABC):
"""Get an attachment by its ID.""" """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 @abc.abstractmethod
async def insert_persona( async def insert_persona(
self, self,
+42
View File
@@ -470,6 +470,48 @@ class SQLiteDatabase(BaseDatabase):
result = await session.execute(query) result = await session.execute(query)
return result.scalar_one_or_none() 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( async def insert_persona(
self, self,
persona_id, persona_id,
@@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable
from typing import Any from typing import Any
from astrbot import logger 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.message.message_event_result import MessageChain
from astrbot.core.platform import ( from astrbot.core.platform import (
AstrBotMessage, AstrBotMessage,
@@ -112,26 +112,19 @@ class WebChatAdapter(Platform):
if payload["message"]: if payload["message"]:
abm.message.append(Plain(payload["message"])) abm.message.append(Plain(payload["message"]))
if payload["image_url"]:
if isinstance(payload["image_url"], list): # 处理 files
for img in payload["image_url"]: files_info = payload.get("files", [])
abm.message.append( for file_info in files_info:
Image.fromFileSystem(os.path.join(self.imgs_dir, img)), if file_info["type"] == "image":
) abm.message.append(Image.fromFileSystem(file_info["path"]))
else: elif file_info["type"] == "record":
abm.message.append( abm.message.append(Record.fromFileSystem(file_info["path"]))
Image.fromFileSystem( elif file_info["type"] == "file":
os.path.join(self.imgs_dir, payload["image_url"]), filename = os.path.basename(file_info["path"])
), abm.message.append(File(name=filename, file=file_info["path"]))
) elif file_info["type"] == "video":
if payload["audio_url"]: abm.message.append(Video.fromFileSystem(file_info["path"]))
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))
logger.debug(f"WebChatAdapter: {abm.message}") logger.debug(f"WebChatAdapter: {abm.message}")
@@ -1,12 +1,12 @@
import base64 import base64
import os import os
import shutil
import uuid import uuid
from astrbot.api import logger from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain 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.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 from .webchat_queue_mgr import webchat_queue_mgr
@@ -19,7 +19,9 @@ class WebChatMessageEvent(AstrMessageEvent):
os.makedirs(imgs_dir, exist_ok=True) os.makedirs(imgs_dir, exist_ok=True)
@staticmethod @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] cid = session_id.split("!")[-1]
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid) web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
if not message: if not message:
@@ -30,7 +32,7 @@ class WebChatMessageEvent(AstrMessageEvent):
"streaming": False, "streaming": False,
}, # end means this request is finished }, # end means this request is finished
) )
return "" return
data = "" data = ""
for comp in message.chain: for comp in message.chain:
@@ -47,24 +49,11 @@ class WebChatMessageEvent(AstrMessageEvent):
) )
elif isinstance(comp, Image): elif isinstance(comp, Image):
# save image to local # save image to local
filename = str(uuid.uuid4()) + ".jpg" filename = f"{str(uuid.uuid4())}.jpg"
path = os.path.join(imgs_dir, filename) path = os.path.join(imgs_dir, filename)
if comp.file and comp.file.startswith("file:///"): image_base64 = await comp.convert_to_base64()
ph = comp.file[8:] with open(path, "wb") as f:
with open(path, "wb") as f: f.write(base64.b64decode(image_base64))
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())
data = f"[IMAGE]{filename}" data = f"[IMAGE]{filename}"
await web_chat_back_queue.put( await web_chat_back_queue.put(
{ {
@@ -76,19 +65,11 @@ class WebChatMessageEvent(AstrMessageEvent):
) )
elif isinstance(comp, Record): elif isinstance(comp, Record):
# save record to local # save record to local
filename = str(uuid.uuid4()) + ".wav" filename = f"{str(uuid.uuid4())}.wav"
path = os.path.join(imgs_dir, filename) path = os.path.join(imgs_dir, filename)
if comp.file and comp.file.startswith("file:///"): record_base64 = await comp.convert_to_base64()
ph = comp.file[8:] with open(path, "wb") as f:
with open(path, "wb") as f: f.write(base64.b64decode(record_base64))
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())
data = f"[RECORD]{filename}" data = f"[RECORD]{filename}"
await web_chat_back_queue.put( await web_chat_back_queue.put(
{ {
@@ -98,6 +79,23 @@ class WebChatMessageEvent(AstrMessageEvent):
"streaming": streaming, "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: else:
logger.debug(f"webchat 忽略: {comp.type}") logger.debug(f"webchat 忽略: {comp.type}")
@@ -131,6 +129,8 @@ class WebChatMessageEvent(AstrMessageEvent):
session_id=self.session_id, session_id=self.session_id,
streaming=True, streaming=True,
) )
if not r:
continue
if chain.type == "reasoning": if chain.type == "reasoning":
reasoning_content += chain.get_plain_text() reasoning_content += chain.get_plain_text()
else: else:
+1 -1
View File
@@ -10,7 +10,7 @@ class PlatformMessageHistoryManager:
self, self,
platform_id: str, platform_id: str,
user_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_id: str | None = None,
sender_name: str | None = None, sender_name: str | None = None,
): ):
+39 -31
View File
@@ -1,7 +1,7 @@
import asyncio import asyncio
import traceback 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.astrbot_config_mgr import AstrBotConfigManager
from astrbot.core.db import BaseDatabase from astrbot.core.db import BaseDatabase
@@ -24,6 +24,7 @@ class ProviderManager:
db_helper: BaseDatabase, db_helper: BaseDatabase,
persona_mgr: PersonaManager, persona_mgr: PersonaManager,
): ):
self.reload_lock = asyncio.Lock()
self.persona_mgr = persona_mgr self.persona_mgr = persona_mgr
self.acm = acm self.acm = acm
config = acm.confs["default"] config = acm.confs["default"]
@@ -226,6 +227,7 @@ class ProviderManager:
async def load_provider(self, provider_config: dict): async def load_provider(self, provider_config: dict):
if not provider_config["enable"]: if not provider_config["enable"]:
logger.info(f"Provider {provider_config['id']} is disabled, skipping")
return return
if provider_config.get("provider_type", "") == "agent_runner": if provider_config.get("provider_type", "") == "agent_runner":
return return
@@ -434,40 +436,46 @@ class ProviderManager:
) )
async def reload(self, provider_config: dict): async def reload(self, provider_config: dict):
await self.terminate_provider(provider_config["id"]) async with self.reload_lock:
if provider_config["enable"]: await self.terminate_provider(provider_config["id"])
await self.load_provider(provider_config) if provider_config["enable"]:
await self.load_provider(provider_config)
# 和配置文件保持同步 # 和配置文件保持同步
config_ids = [provider["id"] for provider in self.providers_config] self.providers_config = astrbot_config["provider"]
logger.debug(f"providers in user's config: {config_ids}") config_ids = [provider["id"] for provider in self.providers_config]
for key in list(self.inst_map.keys()): logger.info(f"providers in user's config: {config_ids}")
if key not in config_ids: for key in list(self.inst_map.keys()):
await self.terminate_provider(key) if key not in config_ids:
await self.terminate_provider(key)
if len(self.provider_insts) == 0: if len(self.provider_insts) == 0:
self.curr_provider_inst = None self.curr_provider_inst = None
elif self.curr_provider_inst is None and len(self.provider_insts) > 0: elif self.curr_provider_inst is None and len(self.provider_insts) > 0:
self.curr_provider_inst = self.provider_insts[0] self.curr_provider_inst = self.provider_insts[0]
logger.info( logger.info(
f"自动选择 {self.curr_provider_inst.meta().id} 作为当前提供商适配器。", f"自动选择 {self.curr_provider_inst.meta().id} 作为当前提供商适配器。",
) )
if len(self.stt_provider_insts) == 0: if len(self.stt_provider_insts) == 0:
self.curr_stt_provider_inst = None self.curr_stt_provider_inst = None
elif self.curr_stt_provider_inst is None and len(self.stt_provider_insts) > 0: elif (
self.curr_stt_provider_inst = self.stt_provider_insts[0] self.curr_stt_provider_inst is None and len(self.stt_provider_insts) > 0
logger.info( ):
f"自动选择 {self.curr_stt_provider_inst.meta().id} 作为当前语音转文本提供商适配器。", 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: if len(self.tts_provider_insts) == 0:
self.curr_tts_provider_inst = None self.curr_tts_provider_inst = None
elif self.curr_tts_provider_inst is None and len(self.tts_provider_insts) > 0: elif (
self.curr_tts_provider_inst = self.tts_provider_insts[0] self.curr_tts_provider_inst is None and len(self.tts_provider_insts) > 0
logger.info( ):
f"自动选择 {self.curr_tts_provider_inst.meta().id} 作为当前文本转语音提供商适配器。", self.curr_tts_provider_inst = self.tts_provider_insts[0]
) logger.info(
f"自动选择 {self.curr_tts_provider_inst.meta().id} 作为当前文本转语音提供商适配器。",
)
def get_insts(self): def get_insts(self):
return self.provider_insts return self.provider_insts
@@ -290,7 +290,7 @@ class ProviderAnthropic(Provider):
try: try:
llm_response = await self._query(payloads, func_tool) llm_response = await self._query(payloads, func_tool)
except Exception as e: except Exception as e:
logger.error(f"发生了错误。Provider 配置如下: {model_config}") # logger.error(f"发生了错误。Provider 配置如下: {model_config}")
raise e raise e
return llm_response return llm_response
@@ -111,9 +111,9 @@ class ProviderGoogleGenAI(Provider):
f"检测到 Key 异常({e.message}),且已没有可用的 Key。 当前 Key: {self.chosen_api_key[:12]}...", f"检测到 Key 异常({e.message}),且已没有可用的 Key。 当前 Key: {self.chosen_api_key[:12]}...",
) )
raise Exception("达到了 Gemini 速率限制, 请稍后再试...") raise Exception("达到了 Gemini 速率限制, 请稍后再试...")
logger.error( # logger.error(
f"发生了错误(gemini_source)。Provider 配置如下: {self.provider_config}", # f"发生了错误(gemini_source)。Provider 配置如下: {self.provider_config}",
) # )
raise e raise e
async def _prepare_query_config( async def _prepare_query_config(
@@ -433,7 +433,7 @@ class ProviderOpenAIOfficial(Provider):
) )
payloads.pop("tools", None) payloads.pop("tools", None)
return False, chosen_key, available_api_keys, payloads, context_query, 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(): if "tool" in str(e).lower() and "support" in str(e).lower():
logger.error("疑似该模型不支持函数调用工具调用。请输入 /tool off_all") logger.error("疑似该模型不支持函数调用工具调用。请输入 /tool off_all")
-107
View File
@@ -171,110 +171,3 @@ class SessionServiceManager:
# 如果没有配置,默认为启用(兼容性考虑) # 如果没有配置,默认为启用(兼容性考虑)
return True 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
@@ -42,87 +42,6 @@ class SessionPluginManager:
# 如果都没有配置,默认为启用(兼容性考虑) # 如果都没有配置,默认为启用(兼容性考虑)
return True 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 @staticmethod
def filter_handlers_by_session(event: AstrMessageEvent, handlers: list) -> list: def filter_handlers_by_session(event: AstrMessageEvent, handlers: list) -> list:
"""根据会话配置过滤处理器列表 """根据会话配置过滤处理器列表
+266 -64
View File
@@ -1,15 +1,16 @@
import asyncio import asyncio
import json import json
import mimetypes
import os import os
import uuid import uuid
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from quart import Response as QuartResponse from quart import g, make_response, request, send_file
from quart import g, make_response, request
from astrbot.core import logger from astrbot.core import logger
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.db import BaseDatabase 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.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr
from astrbot.core.utils.astrbot_path import get_astrbot_data_path from astrbot.core.utils.astrbot_path import get_astrbot_data_path
@@ -44,7 +45,7 @@ class ChatRoute(Route):
self.update_session_display_name, self.update_session_display_name,
), ),
"/chat/get_file": ("GET", self.get_file), "/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), "/chat/post_file": ("POST", self.post_file),
} }
self.core_lifecycle = core_lifecycle self.core_lifecycle = core_lifecycle
@@ -73,52 +74,176 @@ class ChatRoute(Route):
if not real_file_path.startswith(real_imgs_dir): if not real_file_path.startswith(real_imgs_dir):
return Response().error("Invalid file path").__dict__ return Response().error("Invalid file path").__dict__
with open(real_file_path, "rb") as f: filename_ext = os.path.splitext(filename)[1].lower()
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 == ".wav": if filename_ext[1:] in self.supported_imgs:
return QuartResponse(f.read(), mimetype="audio/wav") return await send_file(real_file_path, mimetype="image/jpeg")
if filename_ext[1:] in self.supported_imgs: return await send_file(real_file_path)
return QuartResponse(f.read(), mimetype="image/jpeg")
return QuartResponse(f.read())
except (FileNotFoundError, OSError): except (FileNotFoundError, OSError):
return Response().error("File access error").__dict__ return Response().error("File access error").__dict__
async def post_image(self): async def get_attachment(self):
post_data = await request.files """Get attachment file by attachment_id."""
if "file" not in post_data: attachment_id = request.args.get("attachment_id")
return Response().error("Missing key: file").__dict__ if not attachment_id:
return Response().error("Missing key: attachment_id").__dict__
file = post_data["file"] try:
filename = str(uuid.uuid4()) + ".jpg" attachment = await self.db.get_attachment_by_id(attachment_id)
path = os.path.join(self.imgs_dir, filename) if not attachment:
await file.save(path) 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): async def post_file(self):
"""Upload a file and create an attachment record, return attachment_id."""
post_data = await request.files post_data = await request.files
if "file" not in post_data: if "file" not in post_data:
return Response().error("Missing key: file").__dict__ return Response().error("Missing key: file").__dict__
file = post_data["file"] file = post_data["file"]
filename = f"{uuid.uuid4()!s}" filename = file.filename or f"{uuid.uuid4()!s}"
# 通过文件格式判断文件类型 content_type = file.content_type or "application/octet-stream"
if file.content_type.startswith("audio"):
filename += ".wav" # 根据 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) path = os.path.join(self.imgs_dir, filename)
await file.save(path) 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): async def chat(self):
username = g.get("username", "guest") username = g.get("username", "guest")
post_data = await request.json post_data = await request.json
if "message" not in post_data and "image_url" not in post_data: if "message" not in post_data and "files" not in post_data:
return Response().error("Missing key: message or image_url").__dict__ return Response().error("Missing key: message or files").__dict__
if "session_id" not in post_data and "conversation_id" not in post_data: if "session_id" not in post_data and "conversation_id" not in post_data:
return ( return (
@@ -126,44 +251,44 @@ class ChatRoute(Route):
) )
message = post_data["message"] message = post_data["message"]
# conversation_id = post_data["conversation_id"]
session_id = post_data.get("session_id", post_data.get("conversation_id")) session_id = post_data.get("session_id", post_data.get("conversation_id"))
image_url = post_data.get("image_url") files = post_data.get("files") # list of attachment_id
audio_url = post_data.get("audio_url")
selected_provider = post_data.get("selected_provider") selected_provider = post_data.get("selected_provider")
selected_model = post_data.get("selected_model") 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: if not message and not files:
return ( return Response().error("Message and files are both empty").__dict__
Response()
.error("Message and image_url and audio_url are empty")
.__dict__
)
if not session_id: if not session_id:
return Response().error("session_id is empty").__dict__ return Response().error("session_id is empty").__dict__
# 追加用户消息
webchat_conv_id = session_id webchat_conv_id = session_id
# 获取会话特定的队列
back_queue = webchat_queue_mgr.get_or_create_back_queue(webchat_conv_id) back_queue = webchat_queue_mgr.get_or_create_back_queue(webchat_conv_id)
new_his = {"type": "user", "message": message} # 构建并保存用户消息
if image_url: attachments = await self.db.get_attachments(files)
new_his["image_url"] = image_url message_parts = await self._build_user_message_parts(message, attachments)
if audio_url: files_info = [
new_his["audio_url"] = audio_url {
"type": attachment.type,
"path": attachment.path,
}
for attachment in attachments
]
await self.platform_history_mgr.insert( await self.platform_history_mgr.insert(
platform_id="webchat", platform_id="webchat",
user_id=webchat_conv_id, user_id=webchat_conv_id,
content=new_his, content={"type": "user", "message": message_parts},
sender_id=username, sender_id=username,
sender_name=username, sender_name=username,
) )
async def stream(): async def stream():
client_disconnected = False client_disconnected = False
accumulated_parts = []
accumulated_text = ""
accumulated_reasoning = ""
try: try:
async with track_conversation(self.running_convs, webchat_conv_id): async with track_conversation(self.running_convs, webchat_conv_id):
@@ -182,16 +307,17 @@ class ChatRoute(Route):
continue continue
result_text = result["data"] result_text = result["data"]
type = result.get("type") msg_type = result.get("type")
streaming = result.get("streaming", False) streaming = result.get("streaming", False)
# 发送 SSE 数据
try: try:
if not client_disconnected: if not client_disconnected:
yield f"data: {json.dumps(result, ensure_ascii=False)}\n\n" yield f"data: {json.dumps(result, ensure_ascii=False)}\n\n"
except Exception as e: except Exception as e:
if not client_disconnected: if not client_disconnected:
logger.debug( logger.debug(
f"[WebChat] 用户 {username} 断开聊天长连接。 {e}", f"[WebChat] 用户 {username} 断开聊天长连接。 {e}"
) )
client_disconnected = True client_disconnected = True
@@ -202,24 +328,55 @@ class ChatRoute(Route):
logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。") logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。")
client_disconnected = True 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 break
elif ( elif (
(streaming and type == "complete") (streaming and msg_type == "complete")
or not streaming or not streaming
or type == "break" or msg_type == "break"
): ):
# 追加机器人消息 await self._save_bot_message(
new_his = {"type": "bot", "message": result_text} webchat_conv_id,
if "reasoning" in result: accumulated_text,
new_his["reasoning"] = result["reasoning"] accumulated_parts,
await self.platform_history_mgr.insert( accumulated_reasoning,
platform_id="webchat",
user_id=webchat_conv_id,
content=new_his,
sender_id="bot",
sender_name="bot",
) )
# 重置累积变量 (对于 break 后的下一段消息)
if msg_type == "break":
accumulated_parts = []
accumulated_text = ""
accumulated_reasoning = ""
except BaseException as e: except BaseException as e:
logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True) logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True)
@@ -231,8 +388,7 @@ class ChatRoute(Route):
webchat_conv_id, webchat_conv_id,
{ {
"message": message, "message": message,
"image_url": image_url, # list "files": files_info,
"audio_url": audio_url,
"selected_provider": selected_provider, "selected_provider": selected_provider,
"selected_model": selected_model, "selected_model": selected_model,
"enable_streaming": enable_streaming, "enable_streaming": enable_streaming,
@@ -249,7 +405,7 @@ class ChatRoute(Route):
"Connection": "keep-alive", "Connection": "keep-alive",
}, },
) )
response.timeout = None # fix SSE auto disconnect issue response.timeout = None # fix SSE auto disconnect issue # pyright: ignore[reportAttributeAccessIssue]
return response return response
async def delete_webchat_session(self): 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}" 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) 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( await self.platform_history_mgr.delete(
platform_id=session.platform_id, platform_id=session.platform_id,
@@ -297,6 +464,41 @@ class ChatRoute(Route):
return Response().ok().__dict__ 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): async def new_session(self):
"""Create a new Platform session (default: webchat).""" """Create a new Platform session (default: webchat)."""
username = g.get("username", "guest") username = g.get("username", "guest")
-156
View File
@@ -60,10 +60,6 @@ class KnowledgeBaseRoute(Route):
# "/kb/media/delete": ("POST", self.delete_media), # "/kb/media/delete": ("POST", self.delete_media),
# 检索 # 检索
"/kb/retrieve": ("POST", self.retrieve), "/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() self.register_routes()
@@ -920,158 +916,6 @@ class KnowledgeBaseRoute(Route):
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return Response().error(f"检索失败: {e!s}").__dict__ 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): async def upload_document_from_url(self):
"""从 URL 上传文档 """从 URL 上传文档
+40 -1
View File
@@ -74,7 +74,10 @@ class SessionManagementRoute(Route):
umo_id = pref.scope_id umo_id = pref.scope_id
if umo_id not in umo_rules: if umo_id not in umo_rules:
umo_rules[umo_id] = {} 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: if search:
@@ -185,6 +188,35 @@ class SessionManagementRoute(Route):
for p in provider_manager.tts_provider_insts 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 ( return (
Response() Response()
.ok( .ok(
@@ -197,6 +229,8 @@ class SessionManagementRoute(Route):
"available_chat_providers": available_chat_providers, "available_chat_providers": available_chat_providers,
"available_stt_providers": available_stt_providers, "available_stt_providers": available_stt_providers,
"available_tts_providers": available_tts_providers, "available_tts_providers": available_tts_providers,
"available_plugins": available_plugins,
"available_kbs": available_kbs,
"available_rule_keys": AVAILABLE_SESSION_RULE_KEYS, "available_rule_keys": AVAILABLE_SESSION_RULE_KEYS,
} }
) )
@@ -229,6 +263,11 @@ class SessionManagementRoute(Route):
if rule_key not in AVAILABLE_SESSION_RULE_KEYS: if rule_key not in AVAILABLE_SESSION_RULE_KEYS:
return Response().error(f"不支持的规则键: {rule_key}").__dict__ return Response().error(f"不支持的规则键: {rule_key}").__dict__
if rule_key == "session_plugin_config":
rule_value = {
umo: rule_value,
}
# 使用 shared preferences 更新规则 # 使用 shared preferences 更新规则
await sp.session_put(umo, rule_key, rule_value) await sp.session_put(umo, rule_key, rule_value)
+18
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
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 无法正常重启的问题
+21 -6
View File
@@ -84,7 +84,8 @@
v-model:prompt="prompt" v-model:prompt="prompt"
:stagedImagesUrl="stagedImagesUrl" :stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl" :stagedAudioUrl="stagedAudioUrl"
:disabled="isStreaming || isConvRunning" :stagedFiles="stagedNonImageFiles"
:disabled="isStreaming"
:enableStreaming="enableStreaming" :enableStreaming="enableStreaming"
:isRecording="isRecording" :isRecording="isRecording"
:session-id="currSessionId || null" :session-id="currSessionId || null"
@@ -93,6 +94,7 @@
@toggleStreaming="toggleStreaming" @toggleStreaming="toggleStreaming"
@removeImage="removeImage" @removeImage="removeImage"
@removeAudio="removeAudio" @removeAudio="removeAudio"
@removeFile="removeFile"
@startRecording="handleStartRecording" @startRecording="handleStartRecording"
@stopRecording="handleStopRecording" @stopRecording="handleStopRecording"
@pasteImage="handlePaste" @pasteImage="handlePaste"
@@ -189,14 +191,17 @@ const {
} = useSessions(props.chatboxMode); } = useSessions(props.chatboxMode);
const { const {
stagedImagesName,
stagedImagesUrl, stagedImagesUrl,
stagedAudioUrl, stagedAudioUrl,
stagedFiles,
stagedNonImageFiles,
getMediaFile, getMediaFile,
processAndUploadImage, processAndUploadImage,
processAndUploadFile,
handlePaste, handlePaste,
removeImage, removeImage,
removeAudio, removeAudio,
removeFile,
clearStaged, clearStaged,
cleanupMediaCache cleanupMediaCache
} = useMediaHandling(); } = useMediaHandling();
@@ -295,13 +300,18 @@ async function handleStopRecording() {
} }
async function handleFileSelect(files: FileList) { async function handleFileSelect(files: FileList) {
const imageTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
for (const file of files) { for (const file of files) {
await processAndUploadImage(file); if (imageTypes.includes(file.type)) {
await processAndUploadImage(file);
} else {
await processAndUploadFile(file);
}
} }
} }
async function handleSendMessage() { async function handleSendMessage() {
if (!prompt.value.trim() && stagedImagesName.value.length === 0 && !stagedAudioUrl.value) { if (!prompt.value.trim() && stagedFiles.value.length === 0 && !stagedAudioUrl.value) {
return; return;
} }
@@ -310,8 +320,13 @@ async function handleSendMessage() {
} }
const promptToSend = prompt.value.trim(); const promptToSend = prompt.value.trim();
const imageNamesToSend = [...stagedImagesName.value];
const audioNameToSend = stagedAudioUrl.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 = ''; prompt.value = '';
@@ -324,7 +339,7 @@ async function handleSendMessage() {
await sendMsg( await sendMsg(
promptToSend, promptToSend,
imageNamesToSend, filesToSend,
audioNameToSend, audioNameToSend,
selectedProviderId, selectedProviderId,
selectedModelName selectedModelName
+36 -7
View File
@@ -30,7 +30,7 @@
</v-tooltip> </v-tooltip>
</div> </div>
<div style="display: flex; justify-content: flex-end; margin-top: 8px; align-items: center;"> <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 /> style="display: none" multiple />
<v-progress-circular v-if="disabled" indeterminate size="16" class="mr-1" width="1.5" /> <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" <v-btn @click="triggerImageInput" icon="mdi-plus" variant="text" color="deep-purple"
@@ -45,8 +45,8 @@
</div> </div>
<!-- 附件预览区 --> <!-- 附件预览区 -->
<div class="attachments-preview" v-if="stagedImagesUrl.length > 0 || stagedAudioUrl"> <div class="attachments-preview" v-if="stagedImagesUrl.length > 0 || stagedAudioUrl || (stagedFiles && stagedFiles.length > 0)">
<div v-for="(img, index) in stagedImagesUrl" :key="index" class="image-preview"> <div v-for="(img, index) in stagedImagesUrl" :key="'img-' + index" class="image-preview">
<img :src="img" class="preview-image" /> <img :src="img" class="preview-image" />
<v-btn @click="$emit('removeImage', index)" class="remove-attachment-btn" icon="mdi-close" <v-btn @click="$emit('removeImage', index)" class="remove-attachment-btn" icon="mdi-close"
size="small" color="error" variant="text" /> size="small" color="error" variant="text" />
@@ -60,6 +60,15 @@
<v-btn @click="$emit('removeAudio')" class="remove-attachment-btn" icon="mdi-close" size="small" <v-btn @click="$emit('removeAudio')" class="remove-attachment-btn" icon="mdi-close" size="small"
color="error" variant="text" /> color="error" variant="text" />
</div> </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>
</div> </div>
</template> </template>
@@ -71,10 +80,19 @@ import ProviderModelSelector from './ProviderModelSelector.vue';
import ConfigSelector from './ConfigSelector.vue'; import ConfigSelector from './ConfigSelector.vue';
import type { Session } from '@/composables/useSessions'; import type { Session } from '@/composables/useSessions';
interface StagedFileInfo {
attachment_id: string;
filename: string;
original_name: string;
url: string;
type: string;
}
interface Props { interface Props {
prompt: string; prompt: string;
stagedImagesUrl: string[]; stagedImagesUrl: string[];
stagedAudioUrl: string; stagedAudioUrl: string;
stagedFiles?: StagedFileInfo[];
disabled: boolean; disabled: boolean;
enableStreaming: boolean; enableStreaming: boolean;
isRecording: boolean; isRecording: boolean;
@@ -86,7 +104,8 @@ interface Props {
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
sessionId: null, sessionId: null,
currentSession: null, currentSession: null,
configId: null configId: null,
stagedFiles: () => []
}); });
const emit = defineEmits<{ const emit = defineEmits<{
@@ -95,6 +114,7 @@ const emit = defineEmits<{
toggleStreaming: []; toggleStreaming: [];
removeImage: [index: number]; removeImage: [index: number];
removeAudio: []; removeAudio: [];
removeFile: [index: number];
startRecording: []; startRecording: [];
stopRecording: []; stopRecording: [];
pasteImage: [event: ClipboardEvent]; pasteImage: [event: ClipboardEvent];
@@ -117,7 +137,7 @@ const sessionPlatformId = computed(() => props.currentSession?.platform_id || 'w
const sessionIsGroup = computed(() => Boolean(props.currentSession?.is_group)); const sessionIsGroup = computed(() => Boolean(props.currentSession?.is_group));
const canSend = computed(() => { 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 // Ctrl+B
@@ -239,7 +259,8 @@ defineExpose({
} }
.image-preview, .image-preview,
.audio-preview { .audio-preview,
.file-preview {
position: relative; position: relative;
display: inline-flex; display: inline-flex;
} }
@@ -252,11 +273,19 @@ defineExpose({
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
} }
.audio-chip { .audio-chip,
.file-chip {
height: 36px; height: 36px;
border-radius: 18px; border-radius: 18px;
} }
.file-name-preview {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.remove-attachment-btn { .remove-attachment-btn {
position: absolute; position: absolute;
top: -8px; top: -8px;
+175 -10
View File
@@ -24,6 +24,22 @@
{{ t('messages.errors.browser.audioNotSupported') }} {{ t('messages.errors.browser.audioNotSupported') }}
</audio> </audio>
</div> </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>
</div> </div>
@@ -77,10 +93,29 @@
{{ t('messages.errors.browser.audioNotSupported') }} {{ t('messages.errors.browser.audioNotSupported') }}
</audio> </audio>
</div> </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> </template>
</div> </div>
<div class="message-actions" v-if="!msg.content.isLoading"> <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) }" :class="{ 'copy-success': isCopySuccess(index) }"
@click="copyBotMessage(msg.content.message, index)" :title="t('core.common.copy')" /> @click="copyBotMessage(msg.content.message, index)" :title="t('core.common.copy')" />
</div> </div>
@@ -96,6 +131,7 @@ import { useI18n, useModuleI18n } from '@/i18n/composables';
import MarkdownIt from 'markdown-it'; import MarkdownIt from 'markdown-it';
import hljs from 'highlight.js'; import hljs from 'highlight.js';
import 'highlight.js/styles/github.css'; import 'highlight.js/styles/github.css';
import axios from 'axios';
const md = new MarkdownIt({ const md = new MarkdownIt({
html: false, html: false,
@@ -147,6 +183,7 @@ export default {
scrollThreshold: 1, scrollThreshold: 1,
scrollTimer: null, scrollTimer: null,
expandedReasoning: new Set(), // Track which reasoning blocks are expanded expandedReasoning: new Set(), // Track which reasoning blocks are expanded
downloadingFiles: new Set(), // Track which files are being downloaded
}; };
}, },
mounted() { mounted() {
@@ -179,6 +216,35 @@ export default {
return this.expandedReasoning.has(messageIndex); 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) { copyCodeToClipboard(code) {
navigator.clipboard.writeText(code).then(() => { navigator.clipboard.writeText(code).then(() => {
@@ -375,6 +441,37 @@ export default {
clearTimeout(this.scrollTimer); clearTimeout(this.scrollTimer);
this.scrollTimer = null; 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 { .message-item {
margin-bottom: 24px; margin-bottom: 12px;
animation: fadeIn 0.3s ease-out; animation: fadeIn 0.3s ease-out;
} }
@@ -441,10 +538,18 @@ export default {
.message-actions { .message-actions {
display: flex; display: flex;
gap: 4px; align-items: center;
gap: 8px;
opacity: 0; opacity: 0;
transition: opacity 0.2s ease; 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 { .bot-message:hover .message-actions {
@@ -549,19 +654,14 @@ export default {
} }
.bot-embedded-image { .bot-embedded-image {
max-width: 80%; max-width: 40%;
width: auto; width: auto;
height: auto; height: auto;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
cursor: pointer; cursor: pointer;
transition: transform 0.2s ease; transition: transform 0.2s ease;
} }
.bot-embedded-image:hover {
transform: scale(1.02);
}
.embedded-audio { .embedded-audio {
width: 300px; width: 300px;
margin-top: 8px; margin-top: 8px;
@@ -572,6 +672,71 @@ export default {
max-width: 300px; 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 { .fade-in {
animation: fadeIn 0.3s ease-in-out; animation: fadeIn 0.3s ease-in-out;
@@ -22,7 +22,7 @@
v-model:prompt="prompt" v-model:prompt="prompt"
:stagedImagesUrl="stagedImagesUrl" :stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl" :stagedAudioUrl="stagedAudioUrl"
:disabled="isStreaming || isConvRunning" :disabled="isStreaming"
:enableStreaming="enableStreaming" :enableStreaming="enableStreaming"
:isRecording="isRecording" :isRecording="isRecording"
:session-id="currSessionId || null" :session-id="currSessionId || null"
@@ -110,9 +110,9 @@ function getSessions() {
} }
const { const {
stagedImagesName,
stagedImagesUrl, stagedImagesUrl,
stagedAudioUrl, stagedAudioUrl,
stagedFiles,
getMediaFile, getMediaFile,
processAndUploadImage, processAndUploadImage,
handlePaste, handlePaste,
@@ -164,7 +164,7 @@ async function handleFileSelect(files: FileList) {
} }
async function handleSendMessage() { async function handleSendMessage() {
if (!prompt.value.trim() && stagedImagesName.value.length === 0 && !stagedAudioUrl.value) { if (!prompt.value.trim() && stagedFiles.value.length === 0 && !stagedAudioUrl.value) {
return; return;
} }
@@ -174,8 +174,13 @@ async function handleSendMessage() {
} }
const promptToSend = prompt.value.trim(); const promptToSend = prompt.value.trim();
const imageNamesToSend = [...stagedImagesName.value];
const audioNameToSend = stagedAudioUrl.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 = ''; prompt.value = '';
@@ -188,7 +193,7 @@ async function handleSendMessage() {
await sendMsg( await sendMsg(
promptToSend, promptToSend,
imageNamesToSend, filesToSend,
audioNameToSend, audioNameToSend,
selectedProviderId, selectedProviderId,
selectedModelName selectedModelName
@@ -7,8 +7,8 @@ import { useCommonStore } from '@/stores/common';
<!-- 添加筛选级别控件 --> <!-- 添加筛选级别控件 -->
<div class="filter-controls mb-2" v-if="showLevelBtns"> <div class="filter-controls mb-2" v-if="showLevelBtns">
<v-chip-group v-model="selectedLevels" column multiple> <v-chip-group v-model="selectedLevels" column multiple>
<v-chip v-for="level in logLevels" :key="level" :color="getLevelColor(level)" filter <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'"> :text-color="level === 'DEBUG' || level === 'INFO' ? 'black' : 'white'" class="font-weight-medium">
{{ level }} {{ level }}
</v-chip> </v-chip>
</v-chip-group> </v-chip-group>
@@ -168,6 +168,7 @@ export default {
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
margin-bottom: 8px; margin-bottom: 8px;
margin-left: 20px;
} }
.fade-in { .fade-in {
+96 -16
View File
@@ -1,10 +1,17 @@
import { ref } from 'vue'; import { ref, computed } from 'vue';
import axios from 'axios'; 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() { export function useMediaHandling() {
const stagedImagesName = ref<string[]>([]);
const stagedImagesUrl = ref<string[]>([]);
const stagedAudioUrl = ref<string>(''); const stagedAudioUrl = ref<string>('');
const stagedFiles = ref<StagedFileInfo[]>([]);
const mediaCache = ref<Record<string, string>>({}); const mediaCache = ref<Record<string, string>>({});
async function getMediaFile(filename: string): Promise<string> { async function getMediaFile(filename: string): Promise<string> {
@@ -32,20 +39,49 @@ export function useMediaHandling() {
formData.append('file', file); formData.append('file', file);
try { try {
const response = await axios.post('/api/chat/post_image', formData, { const response = await axios.post('/api/chat/post_file', formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data' 'Content-Type': 'multipart/form-data'
} }
}); });
const img = response.data.data.filename; const { attachment_id, filename, type } = response.data.data;
stagedImagesName.value.push(img); stagedFiles.value.push({
stagedImagesUrl.value.push(URL.createObjectURL(file)); attachment_id,
filename,
original_name: file.name,
url: URL.createObjectURL(file),
type
});
} catch (err) { } catch (err) {
console.error('Error uploading image:', 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) { async function handlePaste(event: ClipboardEvent) {
const items = event.clipboardData?.items; const items = event.clipboardData?.items;
if (!items) return; if (!items) return;
@@ -61,23 +97,54 @@ export function useMediaHandling() {
} }
function removeImage(index: number) { function removeImage(index: number) {
const urlToRevoke = stagedImagesUrl.value[index]; // 找到第 index 个图片类型的文件
if (urlToRevoke && urlToRevoke.startsWith('blob:')) { let imageCount = 0;
URL.revokeObjectURL(urlToRevoke); 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() { function removeAudio() {
stagedAudioUrl.value = ''; 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() { function clearStaged() {
stagedImagesName.value = [];
stagedImagesUrl.value = [];
stagedAudioUrl.value = ''; stagedAudioUrl.value = '';
// 清理文件的 blob URLs
stagedFiles.value.forEach(file => {
if (file.url.startsWith('blob:')) {
URL.revokeObjectURL(file.url);
}
});
stagedFiles.value = [];
} }
function cleanupMediaCache() { function cleanupMediaCache() {
@@ -89,15 +156,28 @@ export function useMediaHandling() {
mediaCache.value = {}; 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 { return {
stagedImagesName,
stagedImagesUrl, stagedImagesUrl,
stagedAudioUrl, stagedAudioUrl,
stagedFiles,
stagedNonImageFiles,
getMediaFile, getMediaFile,
processAndUploadImage, processAndUploadImage,
processAndUploadFile,
handlePaste, handlePaste,
removeImage, removeImage,
removeAudio, removeAudio,
removeFile,
clearStaged, clearStaged,
cleanupMediaCache cleanupMediaCache
}; };
+161 -46
View File
@@ -2,19 +2,37 @@ import { ref, reactive, type Ref } from 'vue';
import axios from 'axios'; import axios from 'axios';
import { useToast } from '@/utils/toast'; 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 { export interface MessageContent {
type: string; type: string;
message: string; message: string | MessagePart[]; // 支持旧格式(string)和新格式(MessagePart[])
reasoning?: string; reasoning?: string;
image_url?: string[]; image_url?: string[];
audio_url?: string; audio_url?: string;
file_url?: FileInfo[];
embedded_images?: string[]; embedded_images?: string[];
embedded_audio?: string; embedded_audio?: string;
embedded_files?: FileInfo[];
isLoading?: boolean; isLoading?: boolean;
} }
export interface Message { export interface Message {
content: MessageContent; content: MessageContent;
created_at?: string;
} }
export function useMessages( export function useMessages(
@@ -29,6 +47,7 @@ export function useMessages(
const isToastedRunningInfo = ref(false); const isToastedRunningInfo = ref(false);
const activeSSECount = ref(0); const activeSSECount = ref(0);
const enableStreaming = ref(true); const enableStreaming = ref(true);
const attachmentCache = new Map<string, string>(); // attachment_id -> blob URL
// 从 localStorage 读取流式响应开关状态 // 从 localStorage 读取流式响应开关状态
const savedStreamingState = localStorage.getItem('enableStreaming'); const savedStreamingState = localStorage.getItem('enableStreaming');
@@ -41,6 +60,68 @@ export function useMessages(
localStorage.setItem('enableStreaming', JSON.stringify(enableStreaming.value)); 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) { async function getSessionMessages(sessionId: string, router: any) {
if (!sessionId) return; if (!sessionId) return;
@@ -64,35 +145,45 @@ export function useMessages(
// 处理历史消息中的媒体文件 // 处理历史消息中的媒体文件
for (let i = 0; i < history.length; i++) { for (let i = 0; i < history.length; i++) {
let content = history[i].content; let content = history[i].content;
if (content.message?.startsWith('[IMAGE]')) { // 首先尝试解析新格式消息
let img = content.message.replace('[IMAGE]', ''); await parseMessageContent(content);
const imageUrl = await getMediaFile(img);
if (!content.embedded_images) { // 以下是旧格式的兼容处理 (message 是字符串的情况)
content.embedded_images = []; 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) { if (content.image_url && content.image_url.length > 0) {
for (let j = 0; j < content.image_url.length; j++) { 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); content.audio_url = await getMediaFile(content.audio_url);
} }
} }
messages.value = history; messages.value = history;
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -101,7 +192,7 @@ export function useMessages(
async function sendMessage( async function sendMessage(
prompt: string, prompt: string,
imageNames: string[], stagedFiles: { attachment_id: string; url: string; original_name: string; type: string }[],
audioName: string, audioName: string,
selectedProviderId: string, selectedProviderId: string,
selectedModelName: string selectedModelName: string
@@ -111,27 +202,33 @@ export function useMessages(
type: 'user', type: 'user',
message: prompt, message: prompt,
image_url: [], image_url: [],
audio_url: undefined audio_url: undefined,
file_url: []
}; };
// Convert image filenames to blob URLs // 分离图片和文件
if (imageNames.length > 0) { const imageFiles = stagedFiles.filter(f => f.type === 'image');
const imagePromises = imageNames.map(name => { const nonImageFiles = stagedFiles.filter(f => f.type !== 'image');
if (!name.startsWith('blob:')) {
return getMediaFile(name); // 使用 attachment_id 获取图片内容(避免 blob URL 被 revoke 后 404
} if (imageFiles.length > 0) {
return Promise.resolve(name); const imageUrls = await Promise.all(
}); imageFiles.map(f => getAttachment(f.attachment_id))
userMessage.image_url = await Promise.all(imagePromises); );
userMessage.image_url = imageUrls.filter(url => url !== '');
} }
// Convert audio filename to blob URL // 使用 blob URL 作为音频预览(录音不走 attachment)
if (audioName) { if (audioName) {
if (!audioName.startsWith('blob:')) { userMessage.audio_url = audioName;
userMessage.audio_url = await getMediaFile(audioName); }
} else {
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 }); messages.value.push({ content: userMessage });
@@ -151,6 +248,9 @@ export function useMessages(
isConvRunning.value = true; isConvRunning.value = true;
} }
// 收集所有 attachment_id
const files = stagedFiles.map(f => f.attachment_id);
const response = await fetch('/api/chat/send', { const response = await fetch('/api/chat/send', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -160,8 +260,7 @@ export function useMessages(
body: JSON.stringify({ body: JSON.stringify({
message: prompt, message: prompt,
session_id: currSessionId.value, session_id: currSessionId.value,
image_url: imageNames, files: files,
audio_url: audioName ? [audioName] : [],
selected_provider: selectedProviderId, selected_provider: selectedProviderId,
selected_model: selectedModelName, selected_model: selectedModelName,
enable_streaming: enableStreaming.value enable_streaming: enableStreaming.value
@@ -207,6 +306,11 @@ export function useMessages(
continue; continue;
} }
const lastMsg = messages.value[messages.value.length - 1];
if (lastMsg?.content?.isLoading) {
messages.value.pop();
}
if (chunk_json.type === 'error') { if (chunk_json.type === 'error') {
console.error('Error received:', chunk_json.data); console.error('Error received:', chunk_json.data);
continue; continue;
@@ -230,16 +334,26 @@ export function useMessages(
embedded_audio: audioUrl embedded_audio: audioUrl
}; };
messages.value.push({ content: bot_resp }); 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') { } else if (chunk_json.type === 'plain') {
const chain_type = chunk_json.chain_type || 'normal'; const chain_type = chunk_json.chain_type || 'normal';
if (!in_streaming) { if (!in_streaming) {
// 移除加载占位符
const lastMsg = messages.value[messages.value.length - 1];
if (lastMsg?.content?.isLoading) {
messages.value.pop();
}
message_obj = reactive({ message_obj = reactive({
type: 'bot', type: 'bot',
message: chain_type === 'reasoning' ? '' : chunk_json.data, message: chain_type === 'reasoning' ? '' : chunk_json.data,
@@ -298,7 +412,8 @@ export function useMessages(
enableStreaming, enableStreaming,
getSessionMessages, getSessionMessages,
sendMessage, sendMessage,
toggleStreaming toggleStreaming,
getAttachment
}; };
} }
@@ -71,6 +71,10 @@
"reasoning": { "reasoning": {
"thinking": "Thinking Process" "thinking": "Thinking Process"
}, },
"time": {
"today": "Today",
"yesterday": "Yesterday"
},
"connection": { "connection": {
"title": "Connection Status Notice", "title": "Connection Status Notice",
"message": "The system detected that the chat connection needs to be re-established.", "message": "The system detected that the chat connection needs to be re-established.",
@@ -73,6 +73,17 @@
"title": "Persona Configuration", "title": "Persona Configuration",
"selectPersona": "Select Persona", "selectPersona": "Select Persona",
"hint": "Persona settings affect the conversation style and behavior of the LLM" "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": { "deleteConfirm": {
@@ -71,6 +71,10 @@
"reasoning": { "reasoning": {
"thinking": "思考过程" "thinking": "思考过程"
}, },
"time": {
"today": "今天",
"yesterday": "昨天"
},
"connection": { "connection": {
"title": "连接状态提醒", "title": "连接状态提醒",
"message": "系统检测到聊天连接需要重新建立。", "message": "系统检测到聊天连接需要重新建立。",
@@ -73,6 +73,17 @@
"title": "人格配置", "title": "人格配置",
"selectPersona": "选择人格", "selectPersona": "选择人格",
"hint": "应用人格配置后,将会强制该来源的所有对话使用该人格。" "hint": "应用人格配置后,将会强制该来源的所有对话使用该人格。"
},
"pluginConfig": {
"title": "插件配置",
"disabledPlugins": "禁用的插件",
"hint": "选择要在此会话中禁用的插件。未选择的插件将保持启用状态。"
},
"kbConfig": {
"title": "知识库配置",
"selectKbs": "选择知识库",
"topK": "返回结果数量 (Top K)",
"enableRerank": "启用重排序"
} }
}, },
"deleteConfirm": { "deleteConfirm": {
+8 -5
View File
@@ -137,10 +137,11 @@ const pluginMarketHeaders = computed(() => [
// //
const filteredExtensions = computed(() => { const filteredExtensions = computed(() => {
const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
if (!showReserved.value) { if (!showReserved.value) {
return extension_data?.data?.filter(ext => !ext.reserved) || []; return data.filter(ext => !ext.reserved);
} }
return extension_data.data || []; return data;
}); });
// //
@@ -275,7 +276,8 @@ const checkUpdate = () => {
onlinePluginsNameMap.set(plugin.name, plugin); 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 repoKey = extension.repo?.toLowerCase();
const onlinePlugin = repoKey ? onlinePluginsMap.get(repoKey) : null; const onlinePlugin = repoKey ? onlinePluginsMap.get(repoKey) : null;
const onlinePluginByName = onlinePluginsNameMap.get(extension.name); const onlinePluginByName = onlinePluginsNameMap.get(extension.name);
@@ -507,8 +509,9 @@ const trimExtensionName = () => {
}; };
const checkAlreadyInstalled = () => { const checkAlreadyInstalled = () => {
const installedRepos = new Set(extension_data.data.map(ext => ext.repo?.toLowerCase())); const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
const installedNames = new Set(extension_data.data.map(ext => ext.name)); 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++) { for (let i = 0; i < pluginMarketData.value.length; i++) {
const plugin = pluginMarketData.value[i]; const plugin = pluginMarketData.value[i];
+43 -56
View File
@@ -69,6 +69,25 @@
:loading="isProviderTesting(provider.id)" @toggle-enabled="providerStatusChange" :loading="isProviderTesting(provider.id)" @toggle-enabled="providerStatusChange"
:bglogo="getProviderIcon(provider.provider)" @delete="deleteProvider" @edit="configExistingProvider" :bglogo="getProviderIcon(provider.provider)" @delete="deleteProvider" @edit="configExistingProvider"
@copy="copyProvider" :show-copy-button="true"> @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 }"> <template #actions="{ item }">
<v-btn style="z-index: 100000;" variant="tonal" color="info" rounded="xl" size="small" <v-btn style="z-index: 100000;" variant="tonal" color="info" rounded="xl" size="small"
:loading="isProviderTesting(item.id)" @click="testSingleProvider(item)"> :loading="isProviderTesting(item.id)" @click="testSingleProvider(item)">
@@ -96,73 +115,38 @@
:loading="isProviderTesting(provider.id)" @toggle-enabled="providerStatusChange" :loading="isProviderTesting(provider.id)" @toggle-enabled="providerStatusChange"
:bglogo="getProviderIcon(provider.provider)" @delete="deleteProvider" @edit="configExistingProvider" :bglogo="getProviderIcon(provider.provider)" @delete="deleteProvider" @edit="configExistingProvider"
@copy="copyProvider" :show-copy-button="true"> @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 }"> <template #actions="{ item }">
<v-btn style="z-index: 100000;" variant="tonal" color="info" rounded="xl" size="small" <v-btn style="z-index: 100000;" variant="tonal" color="info" rounded="xl" size="small"
:loading="isProviderTesting(item.id)" @click="testSingleProvider(item)"> :loading="isProviderTesting(item.id)" @click="testSingleProvider(item)">
{{ tm('availability.test') }} {{ tm('availability.test') }}
</v-btn> </v-btn>
</template> </template>
<template v-slot:details="{ item }">
</template>
</item-card> </item-card>
</v-col> </v-col>
</v-row> </v-row>
</template> </template>
</div> </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 elevation="0" class="mt-4">
<v-card-title class="d-flex align-center py-3 px-4"> <v-card-title class="d-flex align-center py-3 px-4">
@@ -751,11 +735,14 @@ export default {
return this.testingProviders.includes(providerId); return this.testingProviders.includes(providerId);
}, },
getProviderStatus(providerId) {
return this.providerStatuses.find(s => s.id === providerId);
},
async testSingleProvider(provider) { async testSingleProvider(provider) {
if (this.isProviderTesting(provider.id)) return; if (this.isProviderTesting(provider.id)) return;
this.testingProviders.push(provider.id); this.testingProviders.push(provider.id);
this.showStatus = true; //
// UIpending // UIpending
const statusIndex = this.providerStatuses.findIndex(s => s.id === provider.id); const statusIndex = this.providerStatuses.findIndex(s => s.id === provider.id);
+212 -2
View File
@@ -143,11 +143,11 @@
</v-dialog> </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 v-if="selectedUmo" class="d-flex flex-column" height="600">
<v-card-title class="py-3 px-6 d-flex align-center border-b"> <v-card-title class="py-3 px-6 d-flex align-center border-b">
<span>{{ tm('ruleEditor.title') }}</span> <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 }} {{ selectedUmo.umo }}
</v-chip> </v-chip>
<v-spacer></v-spacer> <v-spacer></v-spacer>
@@ -241,6 +241,59 @@
{{ tm('buttons.save') }} {{ tm('buttons.save') }}
</v-btn> </v-btn>
</div> </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> </div>
</v-card-text> </v-card-text>
</v-card> </v-card>
@@ -347,6 +400,8 @@ export default {
availableChatProviders: [], availableChatProviders: [],
availableSttProviders: [], availableSttProviders: [],
availableTtsProviders: [], availableTtsProviders: [],
availablePlugins: [],
availableKbs: [],
// //
addRuleDialog: false, addRuleDialog: false,
@@ -374,6 +429,19 @@ export default {
text_to_speech: null, text_to_speech: null,
}, },
//
pluginConfig: {
enabled_plugins: [],
disabled_plugins: [],
},
//
kbConfig: {
kb_ids: [],
top_k: 5,
enable_rerank: true,
},
// //
deleteDialog: false, deleteDialog: false,
deleteTarget: null, deleteTarget: null,
@@ -447,6 +515,20 @@ export default {
})) }))
] ]
}, },
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
}))
},
}, },
watch: { watch: {
@@ -492,6 +574,8 @@ export default {
this.availableChatProviders = data.available_chat_providers this.availableChatProviders = data.available_chat_providers
this.availableSttProviders = data.available_stt_providers this.availableSttProviders = data.available_stt_providers
this.availableTtsProviders = data.available_tts_providers this.availableTtsProviders = data.available_tts_providers
this.availablePlugins = data.available_plugins || []
this.availableKbs = data.available_kbs || []
} else { } else {
this.showError(response.data.message || this.tm('messages.loadError')) this.showError(response.data.message || this.tm('messages.loadError'))
} }
@@ -589,6 +673,21 @@ export default {
text_to_speech: this.editingRules['provider_perf_text_to_speech'] || null, 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 this.ruleDialog = true
}, },
@@ -708,6 +807,117 @@ export default {
this.saving = false this.saving = false
}, },
async savePluginConfig() {
if (!this.selectedUmo) return
this.saving = true
try {
const config = {
enabled_plugins: this.pluginConfig.enabled_plugins,
disabled_plugins: this.pluginConfig.disabled_plugins,
}
//
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
}
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.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
},
confirmDeleteRules(item) { confirmDeleteRules(item) {
this.deleteTarget = item this.deleteTarget = item
this.deleteDialog = true this.deleteDialog = true
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "AstrBot" name = "AstrBot"
version = "4.6.1" version = "4.7.1"
description = "Easy-to-use multi-platform LLM chatbot and development framework" description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"