Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da546cfe7f | |||
| a211933e83 | |||
| 1d40b5a821 | |||
| 33836daeb7 | |||
| 0de6d0e046 | |||
| 9fedaa9f77 | |||
| bf4c2ecd33 | |||
| f8c18cc1e0 | |||
| 458b900412 | |||
| 192c776e0b | |||
| 5cdec18863 | |||
| 15f856f951 | |||
| 01d52cef74 | |||
| 31d8c40eca | |||
| 56001ed272 | |||
| cfae655068 | |||
| 5596565ec4 | |||
| e98c3d8393 | |||
| 6687b816f0 | |||
| ea8035e854 | |||
| 54b0171d49 | |||
| 676d4277b9 | |||
| a4b1da3ca2 | |||
| 9e9c16e770 | |||
| dc87006fed | |||
| b9b260f26a | |||
| 33fd6a5016 | |||
| 97cbccc2ba | |||
| 1ee4685d5d | |||
| aba18232b1 | |||
| 0a02441b75 | |||
| 1be5b4c7ff | |||
| a0ce0cf18a | |||
| 7c54e5d093 | |||
| b825e51dab | |||
| 589855c393 | |||
| 4c546f2f53 | |||
| 3753fce912 | |||
| 4c02857ec5 | |||
| 33f87ff7d7 | |||
| 784dcf2a9a | |||
| 43ee943acb | |||
| a769fd7d13 | |||
| 2c4fd00b16 | |||
| 264771fe98 | |||
| ecd92dafef | |||
| c8b6e4bea3 | |||
| 3756cb766e | |||
| 068d9ca60b | |||
| 93f632d8b8 | |||
| bb44ce7e74 | |||
| 6986c8d8f7 | |||
| fe95506db4 | |||
| 310ed76b18 | |||
| 98830d147f | |||
| 19c9177d7b | |||
| f41c5f97f6 | |||
| 648c125697 | |||
| 0dc2b89897 | |||
| 83745f83a5 | |||
| 2f91fe4535 | |||
| 739f09059e | |||
| c86f9f0f5f | |||
| 9470ca6bc5 | |||
| 2a92c4d5de | |||
| bb6e892657 | |||
| c9079b9299 | |||
| b6963c1bf9 | |||
| 9c29df47bb | |||
| fc146d3d00 | |||
| 1bf5a21678 | |||
| 011542dc2b | |||
| 489784104e | |||
| 3860634fd2 | |||
| 709c324e18 | |||
| b75d24d92c | |||
| ed80e9424c | |||
| 2fe1f2060a | |||
| c6df820164 | |||
| d6239822db | |||
| bced9ffff9 | |||
| d7d1c1544a | |||
| 44dbe475af | |||
| bd24cf3ea4 | |||
| b493a808fe | |||
| 54035d108d | |||
| c5e8bc7e20 | |||
| 3bbb4779a3 | |||
| 1b3963ebea | |||
| e8ffebc006 | |||
| 2ca95eaa9f | |||
| 0dc5b4cdfc | |||
| cc6cd96d8e | |||
| 4244d37625 | |||
| 0b766095d4 | |||
| 39693a27e3 | |||
| b03fe438d0 | |||
| 7f56824b42 | |||
| 627da3a2bc | |||
| e6b69042de |
+4
-1
@@ -17,4 +17,7 @@ ENV/
|
||||
.conda/
|
||||
README*.md
|
||||
dashboard/
|
||||
data/
|
||||
data/
|
||||
changelogs/
|
||||
tests/
|
||||
.ruff_cache/
|
||||
@@ -4,6 +4,8 @@ WORKDIR /AstrBot
|
||||
COPY . /AstrBot/
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
nodejs \
|
||||
npm \
|
||||
gcc \
|
||||
build-essential \
|
||||
python3-dev \
|
||||
@@ -28,3 +30,6 @@ EXPOSE 6185
|
||||
EXPOSE 6186
|
||||
|
||||
CMD [ "python", "main.py" ]
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -13,10 +13,11 @@ _✨ 易上手的多平台 LLM 聊天机器人及开发框架 ✨_
|
||||
[](https://github.com/Soulter/AstrBot/releases/latest)
|
||||
<img src="https://img.shields.io/badge/python-3.10+-blue.svg?style=for-the-badge&color=76bad9" alt="python">
|
||||
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?style=for-the-badge&color=76bad9"/></a>
|
||||
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=wtbaNx7EioxeaqS9z7RQWVXPIxg2zYr7&jump_from=webapi&authKey=vlqnv/AV2DbJEvGIcxdlNSpfxVy+8vVqijgreRdnVKOaydpc+YSw4MctmEbr0k5"><img alt="Static Badge" src="https://img.shields.io/badge/QQ群-775869627-purple?style=for-the-badge&color=76bad9"></a>
|
||||
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=wtbaNx7EioxeaqS9z7RQWVXPIxg2zYr7&jump_from=webapi&authKey=vlqnv/AV2DbJEvGIcxdlNSpfxVy+8vVqijgreRdnVKOaydpc+YSw4MctmEbr0k5"><img alt="QQ_community" src="https://img.shields.io/badge/QQ群-775869627-purple?style=for-the-badge&color=76bad9"></a>
|
||||
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
[](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e)
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
|
||||
<a href="https://github.com/Soulter/AstrBot/blob/master/README_en.md">English</a> |
|
||||
|
||||
@@ -23,7 +23,10 @@ db_helper = SQLiteDatabase(DB_PATH)
|
||||
sp = (
|
||||
SharedPreferences()
|
||||
) # 简单的偏好设置存储, 这里后续应该存储到数据库中, 一些部分可以存储到配置中
|
||||
pip_installer = PipInstaller(astrbot_config.get("pip_install_arg", ""))
|
||||
pip_installer = PipInstaller(
|
||||
astrbot_config.get("pip_install_arg", ""),
|
||||
astrbot_config.get("pypi_index_url", None),
|
||||
)
|
||||
web_chat_queue = asyncio.Queue(maxsize=32)
|
||||
web_chat_back_queue = asyncio.Queue(maxsize=32)
|
||||
WEBUI_SK = "Advanced_System_for_Text_Response_and_Bot_Operations_Tool"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。
|
||||
"""
|
||||
|
||||
VERSION = "3.5.3.1"
|
||||
VERSION = "3.5.4"
|
||||
DB_PATH = "data/data_v3.db"
|
||||
|
||||
# 默认配置
|
||||
@@ -38,6 +38,7 @@ DEFAULT_CONFIG = {
|
||||
"no_permission_reply": True,
|
||||
"empty_mention_waiting": True,
|
||||
"friend_message_needs_wake_prefix": False,
|
||||
"ignore_bot_self_message": False,
|
||||
},
|
||||
"provider": [],
|
||||
"provider_settings": {
|
||||
@@ -52,6 +53,7 @@ DEFAULT_CONFIG = {
|
||||
"max_context_length": -1,
|
||||
"dequeue_context_length": 1,
|
||||
"streaming_response": False,
|
||||
"streaming_segmented": False,
|
||||
},
|
||||
"provider_stt_settings": {
|
||||
"enable": False,
|
||||
@@ -60,6 +62,7 @@ DEFAULT_CONFIG = {
|
||||
"provider_tts_settings": {
|
||||
"enable": False,
|
||||
"provider_id": "",
|
||||
"dual_output": False,
|
||||
},
|
||||
"provider_ltm_settings": {
|
||||
"group_icl_enable": False,
|
||||
@@ -97,7 +100,7 @@ DEFAULT_CONFIG = {
|
||||
"wake_prefix": ["/"],
|
||||
"log_level": "INFO",
|
||||
"pip_install_arg": "",
|
||||
"plugin_repo_mirror": "",
|
||||
"pypi_index_url": "https://mirrors.aliyun.com/pypi/simple/",
|
||||
"knowledge_db": {},
|
||||
"persona": [],
|
||||
"timezone": "",
|
||||
@@ -287,6 +290,11 @@ CONFIG_METADATA_2 = {
|
||||
"type": "bool",
|
||||
"hint": "启用后,私聊消息需要唤醒前缀才会被处理,同群聊一样。",
|
||||
},
|
||||
"ignore_bot_self_message": {
|
||||
"description": "是否忽略机器人自身的消息",
|
||||
"type": "bool",
|
||||
"hint": "某些平台如 gewechat 会将自身账号在其他 APP 端发送的消息也当做消息事件下发导致给自己发消息时唤醒机器人",
|
||||
},
|
||||
"segmented_reply": {
|
||||
"description": "分段回复",
|
||||
"type": "object",
|
||||
@@ -528,6 +536,8 @@ CONFIG_METADATA_2 = {
|
||||
"model": "gemini-2.0-flash-exp",
|
||||
},
|
||||
"gm_resp_image_modal": False,
|
||||
"gm_native_search": False,
|
||||
"gm_native_coderunner": False,
|
||||
"gm_safety_settings": {
|
||||
"harassment": "BLOCK_MEDIUM_AND_ABOVE",
|
||||
"hate_speech": "BLOCK_MEDIUM_AND_ABOVE",
|
||||
@@ -704,6 +714,18 @@ CONFIG_METADATA_2 = {
|
||||
"type": "bool",
|
||||
"hint": "启用后,将支持返回图片内容。需要模型支持,否则会报错。具体支持模型请查看 Google Gemini 官方网站。温馨提示,如果您需要生成图片,请关闭 `启用群员识别` 配置获得更好的效果。",
|
||||
},
|
||||
"gm_native_search": {
|
||||
"description": "启用原生搜索功能",
|
||||
"type": "bool",
|
||||
"hint": "启用后所有函数工具将全部失效,免费次数限制请查阅官方文档",
|
||||
"obvious_hint": True,
|
||||
},
|
||||
"gm_native_coderunner": {
|
||||
"description": "启用原生代码执行器",
|
||||
"type": "bool",
|
||||
"hint": "启用后所有函数工具将全部失效",
|
||||
"obvious_hint": True,
|
||||
},
|
||||
"gm_safety_settings": {
|
||||
"description": "安全过滤器",
|
||||
"type": "object",
|
||||
@@ -1008,6 +1030,11 @@ CONFIG_METADATA_2 = {
|
||||
"type": "bool",
|
||||
"hint": "启用后,将会流式输出 LLM 的响应。目前仅支持 OpenAI API提供商 以及 Telegram、QQ Official 私聊 两个平台",
|
||||
},
|
||||
"streaming_segmented": {
|
||||
"description": "不支持流式回复的平台分段输出",
|
||||
"type": "bool",
|
||||
"hint": "启用后,若平台不支持流式回复,会分段输出。目前仅支持 aiocqhttp 和 gewechat 两个平台,不支持或无需使用流式分段输出的平台会静默忽略此选项",
|
||||
},
|
||||
},
|
||||
},
|
||||
"persona": {
|
||||
@@ -1082,6 +1109,12 @@ CONFIG_METADATA_2 = {
|
||||
"type": "string",
|
||||
"hint": "文本转语音提供商 ID。如果不填写将使用载入的第一个提供商。",
|
||||
},
|
||||
"dual_output": {
|
||||
"description": "启用语音和文字双输出",
|
||||
"type": "bool",
|
||||
"hint": "启用后,Bot 将同时输出语音和文字消息。",
|
||||
"obvious_hint": True,
|
||||
},
|
||||
},
|
||||
},
|
||||
"provider_ltm_settings": {
|
||||
@@ -1216,16 +1249,10 @@ CONFIG_METADATA_2 = {
|
||||
"type": "string",
|
||||
"hint": "安装插件依赖时,会使用 Python 的 pip 工具。这里可以填写额外的参数,如 `--break-system-package` 等。",
|
||||
},
|
||||
"plugin_repo_mirror": {
|
||||
"description": "插件仓库镜像",
|
||||
"pypi_index_url": {
|
||||
"description": "PyPI 软件仓库地址",
|
||||
"type": "string",
|
||||
"hint": "已废弃,请使用管理面板->设置页的代理地址选择",
|
||||
"obvious_hint": True,
|
||||
"options": [
|
||||
"default",
|
||||
"https://ghp.ci/",
|
||||
"https://github-mirror.us.kg/",
|
||||
],
|
||||
"hint": "安装 Python 依赖时请求的 PyPI 软件仓库地址。默认为 https://mirrors.aliyun.com/pypi/simple/",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -106,7 +106,7 @@ class AstrBotCoreLifecycle:
|
||||
await self.pipeline_scheduler.initialize()
|
||||
|
||||
# 初始化更新器
|
||||
self.astrbot_updator = AstrBotUpdator(self.astrbot_config["plugin_repo_mirror"])
|
||||
self.astrbot_updator = AstrBotUpdator()
|
||||
|
||||
# 初始化事件总线
|
||||
self.event_bus = EventBus(self.event_queue, self.pipeline_scheduler)
|
||||
|
||||
+4
-1
@@ -25,6 +25,7 @@ import logging
|
||||
import colorlog
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from collections import deque
|
||||
from asyncio import Queue
|
||||
from typing import List
|
||||
@@ -171,7 +172,9 @@ class LogManager:
|
||||
if logger.hasHandlers():
|
||||
return logger
|
||||
# 如果logger没有处理器
|
||||
console_handler = logging.StreamHandler() # 创建一个StreamHandler用于控制台输出
|
||||
console_handler = logging.StreamHandler(
|
||||
sys.stdout
|
||||
) # 创建一个StreamHandler用于控制台输出
|
||||
console_handler.setLevel(
|
||||
logging.DEBUG
|
||||
) # 将日志级别设置为DEBUG(最低级别, 显示所有日志), *如果插件没有设置级别, 默认为DEBUG
|
||||
|
||||
@@ -193,6 +193,7 @@ class Record(BaseMessageComponent):
|
||||
bs64_data = file_to_base64(self.file)
|
||||
else:
|
||||
raise Exception(f"not a valid file: {self.file}")
|
||||
bs64_data = bs64_data.removeprefix("base64://")
|
||||
return bs64_data
|
||||
|
||||
|
||||
@@ -397,6 +398,7 @@ class Image(BaseMessageComponent):
|
||||
bs64_data = file_to_base64(url)
|
||||
else:
|
||||
raise Exception(f"not a valid file: {url}")
|
||||
bs64_data = bs64_data.removeprefix("base64://")
|
||||
return bs64_data
|
||||
|
||||
|
||||
|
||||
@@ -146,8 +146,12 @@ class LLMRequestSubStage(Stage):
|
||||
):
|
||||
logger.debug("上下文长度超过限制,将截断。")
|
||||
req.contexts = req.contexts[
|
||||
-(self.max_context_length - self.dequeue_context_length) * 2 :
|
||||
-(self.max_context_length - self.dequeue_context_length + 1) * 2 :
|
||||
]
|
||||
# 找到第一个role 为 user 的索引,确保上下文格式正确
|
||||
index = next((i for i, item in enumerate(req.contexts) if item.get("role") == "user"), None)
|
||||
if index is not None and index > 0:
|
||||
req.contexts = req.contexts[index:]
|
||||
|
||||
# session_id
|
||||
if not req.session_id:
|
||||
|
||||
@@ -12,6 +12,7 @@ from astrbot.core import logger
|
||||
from astrbot.core.message.message_event_result import BaseMessageComponent
|
||||
from astrbot.core.star.star_handler import star_handlers_registry, EventType
|
||||
from astrbot.core.star.star import star_map
|
||||
from astrbot.core.utils.path_util import path_Mapping
|
||||
|
||||
|
||||
@register_stage
|
||||
@@ -56,6 +57,8 @@ class RespondStage(Stage):
|
||||
|
||||
async def initialize(self, ctx: PipelineContext):
|
||||
self.ctx = ctx
|
||||
self.config = ctx.astrbot_config
|
||||
self.platform_settings: dict = self.config.get("platform_settings", {})
|
||||
|
||||
self.reply_with_mention = ctx.astrbot_config["platform_settings"][
|
||||
"reply_with_mention"
|
||||
@@ -143,12 +146,23 @@ class RespondStage(Stage):
|
||||
|
||||
if result.result_content_type == ResultContentType.STREAMING_RESULT:
|
||||
# 流式结果直接交付平台适配器处理
|
||||
use_fallback = self.config.get("provider_settings", {}).get(
|
||||
"streaming_segmented", False
|
||||
)
|
||||
logger.info(f"应用流式输出({event.get_platform_name()})")
|
||||
await event._pre_send()
|
||||
await event.send_streaming(result.async_stream)
|
||||
await event.send_streaming(result.async_stream, use_fallback)
|
||||
await event._post_send()
|
||||
return
|
||||
elif len(result.chain) > 0:
|
||||
# 检查路径映射
|
||||
if mappings := self.platform_settings.get("path_mapping", []):
|
||||
for idx, component in enumerate(result.chain):
|
||||
if isinstance(component, Comp.File) and component.file:
|
||||
# 支持 File 消息段的路径映射。
|
||||
component.file = path_Mapping(mappings, component.file)
|
||||
event.get_result().chain[idx] = component
|
||||
|
||||
await event._pre_send()
|
||||
|
||||
# 检查消息链是否为空
|
||||
@@ -191,6 +205,7 @@ class RespondStage(Stage):
|
||||
try:
|
||||
await event.send(result)
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
logger.error(f"发送消息失败: {e} chain: {result.chain}")
|
||||
await event._post_send()
|
||||
logger.info(
|
||||
|
||||
@@ -151,9 +151,9 @@ class ResultDecorateStage(Stage):
|
||||
# 不分段回复
|
||||
new_chain.append(comp)
|
||||
continue
|
||||
split_response = []
|
||||
for line in comp.text.split("\n"):
|
||||
split_response.extend(re.findall(self.regex, line))
|
||||
split_response = re.findall(
|
||||
self.regex, comp.text, re.DOTALL | re.MULTILINE
|
||||
)
|
||||
if not split_response:
|
||||
new_chain.append(comp)
|
||||
continue
|
||||
@@ -184,6 +184,8 @@ class ResultDecorateStage(Stage):
|
||||
new_chain.append(
|
||||
Record(file=audio_path, url=audio_path)
|
||||
)
|
||||
if(self.ctx.astrbot_config["provider_tts_settings"]["dual_output"]):
|
||||
new_chain.append(comp)
|
||||
else:
|
||||
logger.error(
|
||||
f"由于 TTS 音频文件没找到,消息段转语音失败: {comp.text}"
|
||||
|
||||
@@ -35,10 +35,21 @@ class WakingCheckStage(Stage):
|
||||
self.friend_message_needs_wake_prefix = self.ctx.astrbot_config[
|
||||
"platform_settings"
|
||||
].get("friend_message_needs_wake_prefix", False)
|
||||
# 是否忽略机器人自己发送的消息
|
||||
self.ignore_bot_self_message = self.ctx.astrbot_config["platform_settings"].get(
|
||||
"ignore_bot_self_message", False
|
||||
)
|
||||
|
||||
async def process(
|
||||
self, event: AstrMessageEvent
|
||||
) -> Union[None, AsyncGenerator[None, None]]:
|
||||
if (
|
||||
self.ignore_bot_self_message
|
||||
and event.get_self_id() == event.get_sender_id()
|
||||
):
|
||||
# 忽略机器人自己发送的消息
|
||||
event.stop_event()
|
||||
return
|
||||
# 设置 sender 身份
|
||||
event.message_str = event.message_str.strip()
|
||||
for admin_id in self.ctx.astrbot_config["admins_id"]:
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import abc
|
||||
import asyncio
|
||||
import re
|
||||
import hashlib
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Union, Optional, AsyncGenerator
|
||||
|
||||
@@ -205,9 +208,26 @@ class AstrMessageEvent(abc.ABC):
|
||||
"""
|
||||
return self.role == "admin"
|
||||
|
||||
async def send_streaming(self, generator: AsyncGenerator[MessageChain, None]):
|
||||
async def process_buffer(self, buffer: str, pattern: re.Pattern) -> str:
|
||||
"""
|
||||
将消息缓冲区中的文本按指定正则表达式分割后发送至消息平台,作为不支持流式输出平台的Fallback。
|
||||
"""
|
||||
while True:
|
||||
match = re.search(pattern, buffer)
|
||||
if not match:
|
||||
break
|
||||
matched_text = match.group()
|
||||
await self.send(MessageChain([Plain(matched_text)]))
|
||||
buffer = buffer[match.end() :]
|
||||
await asyncio.sleep(1.5) # 限速
|
||||
return buffer
|
||||
|
||||
async def send_streaming(
|
||||
self, generator: AsyncGenerator[MessageChain, None], use_fallback: bool = False
|
||||
):
|
||||
"""发送流式消息到消息平台,使用异步生成器。
|
||||
目前仅支持: telegram,qq official 私聊。
|
||||
Fallback仅支持 aiocqhttp, gewechat。
|
||||
"""
|
||||
asyncio.create_task(
|
||||
Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
|
||||
@@ -384,8 +404,13 @@ class AstrMessageEvent(abc.ABC):
|
||||
Args:
|
||||
message (MessageChain): 消息链,具体使用方式请参考文档。
|
||||
"""
|
||||
# Leverage BLAKE2 hash function to generate a non-reversible hash of the sender ID for privacy.
|
||||
hash_obj = hashlib.blake2b(self.get_sender_id().encode("utf-8"), digest_size=16)
|
||||
sid = str(uuid.UUID(bytes=hash_obj.digest()))
|
||||
asyncio.create_task(
|
||||
Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
|
||||
Metric.upload(
|
||||
msg_event_tick=1, adapter_name=self.platform_meta.name, sid=sid
|
||||
)
|
||||
)
|
||||
self._has_send_oper = True
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import asyncio
|
||||
import typing
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.platform import Group, MessageMember
|
||||
from astrbot.api.message_components import Plain, Image, Record, At, Node, Nodes
|
||||
import re
|
||||
from typing import AsyncGenerator, Dict, List
|
||||
from aiocqhttp import CQHttp
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.message_components import At, Image, Node, Nodes, Plain, Record
|
||||
from astrbot.api.platform import Group, MessageMember
|
||||
|
||||
|
||||
class AiocqhttpMessageEvent(AstrMessageEvent):
|
||||
@@ -29,7 +30,7 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
|
||||
# convert to base64
|
||||
bs64 = await segment.convert_to_base64()
|
||||
d["data"] = {
|
||||
"file": bs64,
|
||||
"file": f"base64://{bs64}",
|
||||
}
|
||||
elif isinstance(segment, At):
|
||||
d["data"] = {
|
||||
@@ -82,18 +83,39 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
|
||||
|
||||
await super().send(message)
|
||||
|
||||
async def send_streaming(self, generator):
|
||||
buffer = None
|
||||
async for chain in generator:
|
||||
async def send_streaming(
|
||||
self, generator: AsyncGenerator, use_fallback: bool = False
|
||||
):
|
||||
if not use_fallback:
|
||||
buffer = None
|
||||
async for chain in generator:
|
||||
if not buffer:
|
||||
buffer = chain
|
||||
else:
|
||||
buffer.chain.extend(chain.chain)
|
||||
if not buffer:
|
||||
buffer = chain
|
||||
else:
|
||||
buffer.chain.extend(chain.chain)
|
||||
if not buffer:
|
||||
return
|
||||
buffer.squash_plain()
|
||||
await self.send(buffer)
|
||||
return await super().send_streaming(generator)
|
||||
return
|
||||
buffer.squash_plain()
|
||||
await self.send(buffer)
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
|
||||
buffer = ""
|
||||
pattern = re.compile(r"[^。?!~…]+[。?!~…]+")
|
||||
|
||||
async for chain in generator:
|
||||
if isinstance(chain, MessageChain):
|
||||
for comp in chain.chain:
|
||||
if isinstance(comp, Plain):
|
||||
buffer += comp.text
|
||||
if any(p in buffer for p in "。?!~…"):
|
||||
buffer = await self.process_buffer(buffer, pattern)
|
||||
else:
|
||||
await self.send(MessageChain(chain=[comp]))
|
||||
await asyncio.sleep(1.5) # 限速
|
||||
|
||||
if buffer.strip():
|
||||
await self.send(MessageChain([Plain(buffer)]))
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
|
||||
async def get_group(self, group_id=None, **kwargs):
|
||||
if isinstance(group_id, str) and group_id.isdigit():
|
||||
@@ -108,7 +130,7 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
|
||||
group_id=group_id,
|
||||
)
|
||||
|
||||
members: typing.List[typing.Dict] = await self.bot.call_action(
|
||||
members: List[Dict] = await self.bot.call_action(
|
||||
"get_group_member_list",
|
||||
group_id=group_id,
|
||||
)
|
||||
|
||||
@@ -61,7 +61,7 @@ class DingtalkMessageEvent(AstrMessageEvent):
|
||||
await self.send_with_client(self.client, message)
|
||||
await super().send(message)
|
||||
|
||||
async def send_streaming(self, generator):
|
||||
async def send_streaming(self, generator, use_fallback: bool = False):
|
||||
buffer = None
|
||||
async for chain in generator:
|
||||
if not buffer:
|
||||
@@ -72,4 +72,4 @@ class DingtalkMessageEvent(AstrMessageEvent):
|
||||
return
|
||||
buffer.squash_plain()
|
||||
await self.send(buffer)
|
||||
return await super().send_streaming(generator)
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import asyncio
|
||||
import re
|
||||
import wave
|
||||
import uuid
|
||||
import traceback
|
||||
import os
|
||||
|
||||
from typing import AsyncGenerator
|
||||
from astrbot.core.utils.io import save_temp_img, download_file
|
||||
from astrbot.core.utils.tencent_record_helper import wav_to_tencent_silk
|
||||
from astrbot.api import logger
|
||||
@@ -217,15 +220,36 @@ class GewechatPlatformEvent(AstrMessageEvent):
|
||||
members=members,
|
||||
)
|
||||
|
||||
async def send_streaming(self, generator):
|
||||
buffer = None
|
||||
async for chain in generator:
|
||||
async def send_streaming(
|
||||
self, generator: AsyncGenerator, use_fallback: bool = False
|
||||
):
|
||||
if not use_fallback:
|
||||
buffer = None
|
||||
async for chain in generator:
|
||||
if not buffer:
|
||||
buffer = chain
|
||||
else:
|
||||
buffer.chain.extend(chain.chain)
|
||||
if not buffer:
|
||||
buffer = chain
|
||||
else:
|
||||
buffer.chain.extend(chain.chain)
|
||||
if not buffer:
|
||||
return
|
||||
buffer.squash_plain()
|
||||
await self.send(buffer)
|
||||
return await super().send_streaming(generator)
|
||||
return
|
||||
buffer.squash_plain()
|
||||
await self.send(buffer)
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
|
||||
buffer = ""
|
||||
pattern = re.compile(r"[^。?!~…]+[。?!~…]+")
|
||||
|
||||
async for chain in generator:
|
||||
if isinstance(chain, MessageChain):
|
||||
for comp in chain.chain:
|
||||
if isinstance(comp, Plain):
|
||||
buffer += comp.text
|
||||
if any(p in buffer for p in "。?!~…"):
|
||||
buffer = await self.process_buffer(buffer, pattern)
|
||||
else:
|
||||
await self.send(MessageChain(chain=[comp]))
|
||||
await asyncio.sleep(1.5) # 限速
|
||||
|
||||
if buffer.strip():
|
||||
await self.send(MessageChain([Plain(buffer)]))
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
|
||||
@@ -67,7 +67,10 @@ class GewechatPlatformAdapter(Platform):
|
||||
|
||||
async def terminate(self):
|
||||
self.client.shutdown_event.set()
|
||||
await self.client.server.shutdown()
|
||||
try:
|
||||
await self.client.server.shutdown()
|
||||
except Exception as _:
|
||||
pass
|
||||
logger.info("Gewechat 适配器已被优雅地关闭。")
|
||||
|
||||
async def logout(self):
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import json
|
||||
import uuid
|
||||
import base64
|
||||
import lark_oapi as lark
|
||||
from io import BytesIO
|
||||
from typing import List
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.message_components import Plain, Image as AstrBotImage, At
|
||||
@@ -27,22 +29,32 @@ class LarkMessageEvent(AstrMessageEvent):
|
||||
_stage.append({"tag": "at", "user_id": comp.qq, "style": []})
|
||||
elif isinstance(comp, AstrBotImage):
|
||||
file_path = ""
|
||||
image_file = None
|
||||
|
||||
if comp.file and comp.file.startswith("file:///"):
|
||||
file_path = comp.file.replace("file:///", "")
|
||||
elif comp.file and comp.file.startswith("http"):
|
||||
image_file_path = await download_image_by_url(comp.file)
|
||||
file_path = image_file_path
|
||||
elif comp.file and comp.file.startswith("base64://"):
|
||||
pass
|
||||
base64_str = comp.file.removeprefix("base64://")
|
||||
image_data = base64.b64decode(base64_str)
|
||||
# save as temp file
|
||||
file_path = f"data/temp/{uuid.uuid4()}_test.jpg"
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(BytesIO(image_data).getvalue())
|
||||
else:
|
||||
file_path = comp.file
|
||||
|
||||
if image_file is None:
|
||||
image_file = open(file_path, "rb")
|
||||
|
||||
request = (
|
||||
CreateImageRequest.builder()
|
||||
.request_body(
|
||||
CreateImageRequestBody.builder()
|
||||
.image_type("message")
|
||||
.image(open(file_path, "rb"))
|
||||
.image(image_file)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
@@ -51,7 +63,7 @@ class LarkMessageEvent(AstrMessageEvent):
|
||||
if not response.success():
|
||||
logger.error(f"无法上传飞书图片({response.code}): {response.msg}")
|
||||
image_key = response.data.image_key
|
||||
print(image_key)
|
||||
logger.debug(image_key)
|
||||
ret.append(_stage)
|
||||
ret.append([{"tag": "img", "image_key": image_key}])
|
||||
_stage.clear()
|
||||
@@ -92,7 +104,7 @@ class LarkMessageEvent(AstrMessageEvent):
|
||||
|
||||
await super().send(message)
|
||||
|
||||
async def send_streaming(self, generator):
|
||||
async def send_streaming(self, generator, use_fallback: bool = False):
|
||||
buffer = None
|
||||
async for chain in generator:
|
||||
if not buffer:
|
||||
@@ -103,4 +115,4 @@ class LarkMessageEvent(AstrMessageEvent):
|
||||
return
|
||||
buffer.squash_plain()
|
||||
await self.send(buffer)
|
||||
return await super().send_streaming(generator)
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
|
||||
@@ -33,7 +33,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
else:
|
||||
self.send_buffer.chain.extend(message.chain)
|
||||
|
||||
async def send_streaming(self, generator):
|
||||
async def send_streaming(self, generator, use_fallback: bool = False):
|
||||
"""流式输出仅支持消息列表私聊"""
|
||||
stream_payload = {"state": 1, "id": None, "index": 0, "reset": False}
|
||||
last_edit_time = 0 # 上次编辑消息的时间
|
||||
@@ -66,7 +66,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
logger.error(f"发送流式消息时出错: {e}", exc_info=True)
|
||||
self.send_buffer = None
|
||||
|
||||
return await super().send_streaming(generator)
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
|
||||
async def _post_send(self, stream: dict = None):
|
||||
if not self.send_buffer:
|
||||
@@ -97,7 +97,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
"msg_id": self.message_obj.message_id,
|
||||
}
|
||||
|
||||
if not isinstance(source, (botpy.message.Message,botpy.message.DirectMessage)):
|
||||
if not isinstance(source, (botpy.message.Message, botpy.message.DirectMessage)):
|
||||
payload["msg_seq"] = random.randint(1, 10000)
|
||||
|
||||
match type(source):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
@@ -118,8 +119,6 @@ class TelegramPlatformAdapter(Platform):
|
||||
|
||||
if commands:
|
||||
await self.client.set_my_commands(commands)
|
||||
for cmd in commands:
|
||||
logger.debug(f"已注册指令: /{cmd.command} - {cmd.description}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"向 Telegram 注册指令时发生错误: {e!s}")
|
||||
@@ -167,6 +166,10 @@ class TelegramPlatformAdapter(Platform):
|
||||
if not cmd_name or cmd_name in skip_commands:
|
||||
return None
|
||||
|
||||
if not re.match(r"^[a-z0-9_]+$", cmd_name) or len(cmd_name) > 32:
|
||||
logger.debug(f"跳过无法注册的命令: {cmd_name}")
|
||||
return None
|
||||
|
||||
# Build description.
|
||||
description = handler_metadata.desc or (
|
||||
f"指令组: {cmd_name} (包含多个子指令)" if is_group else f"指令: {cmd_name}"
|
||||
|
||||
@@ -91,7 +91,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
await self.send_with_client(self.client, message, self.get_sender_id())
|
||||
await super().send(message)
|
||||
|
||||
async def send_streaming(self, generator):
|
||||
async def send_streaming(self, generator, use_fallback: bool = False):
|
||||
message_thread_id = None
|
||||
|
||||
if self.get_message_type() == MessageType.GROUP_MESSAGE:
|
||||
@@ -183,16 +183,14 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
text=markdown_text,
|
||||
chat_id=payload["chat_id"],
|
||||
message_id=message_id,
|
||||
parse_mode="MarkdownV2"
|
||||
parse_mode="MarkdownV2",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Markdown转换失败,使用普通文本: {e!s}")
|
||||
await self.client.edit_message_text(
|
||||
text=delta,
|
||||
chat_id=payload["chat_id"],
|
||||
message_id=message_id
|
||||
text=delta, chat_id=payload["chat_id"], message_id=message_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"编辑消息失败(streaming): {e!s}")
|
||||
|
||||
return await super().send_streaming(generator)
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
|
||||
@@ -21,7 +21,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
await web_chat_back_queue.put(
|
||||
{"type": "end", "data": "", "streaming": False}
|
||||
)
|
||||
return
|
||||
return ""
|
||||
|
||||
cid = session_id.split("!")[-1]
|
||||
data = ""
|
||||
@@ -106,7 +106,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
)
|
||||
await super().send(message)
|
||||
|
||||
async def send_streaming(self, generator):
|
||||
async def send_streaming(self, generator, use_fallback: bool = False):
|
||||
final_data = ""
|
||||
async for chain in generator:
|
||||
final_data += await WebChatMessageEvent._send(
|
||||
@@ -121,4 +121,4 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
"cid": self.session_id.split("!")[-1],
|
||||
}
|
||||
)
|
||||
await super().send_streaming(generator)
|
||||
await super().send_streaming(generator, use_fallback)
|
||||
|
||||
@@ -237,5 +237,8 @@ class WecomPlatformAdapter(Platform):
|
||||
|
||||
async def terminate(self):
|
||||
self.server.shutdown_event.set()
|
||||
await self.server.server.shutdown()
|
||||
try:
|
||||
await self.server.server.shutdown()
|
||||
except Exception as _:
|
||||
pass
|
||||
logger.info("企业微信 适配器已被优雅地关闭")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import uuid
|
||||
import asyncio
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
|
||||
from astrbot.api.message_components import Plain, Image, Record
|
||||
@@ -33,14 +34,54 @@ class WecomPlatformEvent(AstrMessageEvent):
|
||||
):
|
||||
pass
|
||||
|
||||
async def split_plain(self, plain: str) -> list[str]:
|
||||
"""将长文本分割成多个小文本, 每个小文本长度不超过 2048 字符
|
||||
|
||||
Args:
|
||||
plain (str): 要分割的长文本
|
||||
Returns:
|
||||
list[str]: 分割后的文本列表
|
||||
"""
|
||||
if len(plain) <= 2048:
|
||||
return [plain]
|
||||
else:
|
||||
result = []
|
||||
start = 0
|
||||
while start < len(plain):
|
||||
# 剩下的字符串长度<2048时结束
|
||||
if start + 2048 >= len(plain):
|
||||
result.append(plain[start:])
|
||||
break
|
||||
|
||||
# 向前搜索分割标点符号
|
||||
end = min(start + 2048, len(plain))
|
||||
cut_position = end
|
||||
for i in range(end, start, -1):
|
||||
if i < len(plain) and plain[i-1] in ["。", "!", "?", ".", "!", "?", "\n", ";", ";"]:
|
||||
cut_position = i
|
||||
break
|
||||
|
||||
# 没找到合适的位置分割, 直接切分
|
||||
if cut_position == end and end < len(plain):
|
||||
cut_position = end
|
||||
|
||||
result.append(plain[start:cut_position])
|
||||
start = cut_position
|
||||
|
||||
return result
|
||||
|
||||
async def send(self, message: MessageChain):
|
||||
message_obj = self.message_obj
|
||||
|
||||
for comp in message.chain:
|
||||
if isinstance(comp, Plain):
|
||||
self.client.message.send_text(
|
||||
message_obj.self_id, message_obj.session_id, comp.text
|
||||
)
|
||||
# Split long text messages if needed
|
||||
plain_chunks = await self.split_plain(comp.text)
|
||||
for chunk in plain_chunks:
|
||||
self.client.message.send_text(
|
||||
message_obj.self_id, message_obj.session_id, chunk
|
||||
)
|
||||
await asyncio.sleep(0.5) # Avoid sending too fast
|
||||
elif isinstance(comp, Image):
|
||||
img_path = await comp.convert_to_file_path()
|
||||
|
||||
@@ -85,7 +126,7 @@ class WecomPlatformEvent(AstrMessageEvent):
|
||||
|
||||
await super().send(message)
|
||||
|
||||
async def send_streaming(self, generator):
|
||||
async def send_streaming(self, generator, use_fallback: bool = False):
|
||||
buffer = None
|
||||
async for chain in generator:
|
||||
if not buffer:
|
||||
@@ -96,4 +137,4 @@ class WecomPlatformEvent(AstrMessageEvent):
|
||||
return
|
||||
buffer.squash_plain()
|
||||
await self.send(buffer)
|
||||
return await super().send_streaming(generator)
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
|
||||
@@ -155,7 +155,7 @@ class ProviderRequest:
|
||||
if self.image_urls:
|
||||
user_content = {
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": self.prompt}],
|
||||
"content": [{"type": "text", "text": self.prompt if self.prompt else "[图片]"}],
|
||||
}
|
||||
for image_url in self.image_urls:
|
||||
if image_url.startswith("http"):
|
||||
|
||||
@@ -4,15 +4,18 @@ import textwrap
|
||||
import os
|
||||
import asyncio
|
||||
import copy
|
||||
import logging
|
||||
|
||||
from typing import Dict, List, Awaitable, Literal, Any
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from contextlib import AsyncExitStack
|
||||
from astrbot import logger
|
||||
from astrbot.core.utils.log_pipe import LogPipe
|
||||
|
||||
try:
|
||||
import mcp
|
||||
from mcp.client.sse import sse_client
|
||||
except (ModuleNotFoundError, ImportError):
|
||||
logger.warning("警告: 缺少依赖库 'mcp',将无法使用 MCP 服务。")
|
||||
|
||||
@@ -87,26 +90,58 @@ class MCPClient:
|
||||
self.name = None
|
||||
self.active: bool = True
|
||||
self.tools: List[mcp.Tool] = []
|
||||
self.server_errlogs: List[str] = []
|
||||
|
||||
async def connect_to_server(self, mcp_server_config: dict):
|
||||
"""Connect to an MCP server
|
||||
async def connect_to_server(self, mcp_server_config: dict, name: str):
|
||||
"""连接到 MCP 服务器
|
||||
|
||||
如果 `url` 参数存在,则使用 SSE 的方式连接到 MCP 服务。
|
||||
|
||||
Args:
|
||||
mcp_server_config (dict): Configuration for the MCP server. See https://modelcontextprotocol.io/quickstart/server
|
||||
"""
|
||||
cfg = mcp_server_config.copy()
|
||||
cfg.pop("active", None)
|
||||
server_params = mcp.StdioServerParameters(
|
||||
**cfg,
|
||||
)
|
||||
if "mcpServers" in cfg and len(cfg["mcpServers"]) > 0:
|
||||
key_0 = list(cfg["mcpServers"].keys())[0]
|
||||
cfg = cfg["mcpServers"][key_0]
|
||||
cfg.pop("active", None) # Remove active flag from config
|
||||
|
||||
stdio_transport = await self.exit_stack.enter_async_context(
|
||||
mcp.stdio_client(server_params)
|
||||
)
|
||||
self.stdio, self.write = stdio_transport
|
||||
self.session = await self.exit_stack.enter_async_context(
|
||||
mcp.ClientSession(self.stdio, self.write)
|
||||
)
|
||||
if "url" in cfg:
|
||||
# SSE transport method
|
||||
self._streams_context = sse_client(url=cfg["url"])
|
||||
streams = await self._streams_context.__aenter__()
|
||||
|
||||
# Create a new client session
|
||||
# self.session = await self._session_context.__aenter__()
|
||||
self.session = await self.exit_stack.enter_async_context(
|
||||
mcp.ClientSession(*streams)
|
||||
)
|
||||
|
||||
else:
|
||||
server_params = mcp.StdioServerParameters(
|
||||
**cfg,
|
||||
)
|
||||
|
||||
def callback(msg: str):
|
||||
# 处理 MCP 服务的错误日志
|
||||
self.server_errlogs.append(msg)
|
||||
|
||||
stdio_transport = await self.exit_stack.enter_async_context(
|
||||
mcp.stdio_client(
|
||||
server_params,
|
||||
errlog=LogPipe(
|
||||
level=logging.ERROR,
|
||||
logger=logger,
|
||||
identifier=f"MCPServer-{name}",
|
||||
callback=callback,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# Create a new client session
|
||||
self.session = await self.exit_stack.enter_async_context(
|
||||
mcp.ClientSession(*stdio_transport)
|
||||
)
|
||||
|
||||
await self.session.initialize()
|
||||
|
||||
@@ -260,6 +295,13 @@ class FuncCall:
|
||||
if data["name"] in self.mcp_client_event:
|
||||
self.mcp_client_event[data["name"]].set()
|
||||
self.mcp_client_event.pop(data["name"], None)
|
||||
self.func_list = [
|
||||
f
|
||||
for f in self.func_list
|
||||
if not (
|
||||
f.origin == "mcp" and f.mcp_server_name == data["name"]
|
||||
)
|
||||
]
|
||||
else:
|
||||
for name in self.mcp_client_dict.keys():
|
||||
# await self._terminate_mcp_client(name)
|
||||
@@ -267,6 +309,7 @@ class FuncCall:
|
||||
if name in self.mcp_client_event:
|
||||
self.mcp_client_event[name].set()
|
||||
self.mcp_client_event.pop(name, None)
|
||||
self.func_list = [f for f in self.func_list if f.origin != "mcp"]
|
||||
|
||||
async def _init_mcp_client_task_wrapper(
|
||||
self, name: str, cfg: dict, event: asyncio.Event
|
||||
@@ -278,6 +321,9 @@ class FuncCall:
|
||||
logger.info(f"收到 MCP 客户端 {name} 终止信号")
|
||||
await self._terminate_mcp_client(name)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
logger.error(f"初始化 MCP 客户端 {name} 失败: {e}")
|
||||
|
||||
async def _init_mcp_client(self, name: str, config: dict) -> None:
|
||||
@@ -289,10 +335,10 @@ class FuncCall:
|
||||
|
||||
mcp_client = MCPClient()
|
||||
mcp_client.name = name
|
||||
await mcp_client.connect_to_server(config)
|
||||
self.mcp_client_dict[name] = mcp_client
|
||||
await mcp_client.connect_to_server(config, name)
|
||||
tools_res = await mcp_client.list_tools_and_save()
|
||||
tool_names = [tool.name for tool in tools_res.tools]
|
||||
self.mcp_client_dict[name] = mcp_client
|
||||
|
||||
# 移除该MCP服务之前的工具(如有)
|
||||
self.func_list = [
|
||||
@@ -316,6 +362,9 @@ class FuncCall:
|
||||
logger.info(f"已连接 MCP 服务 {name}, Tools: {tool_names}")
|
||||
return True
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
logger.error(f"初始化 MCP 客户端 {name} 失败: {e}")
|
||||
# 发生错误时确保客户端被清理
|
||||
if name in self.mcp_client_dict:
|
||||
@@ -339,7 +388,7 @@ class FuncCall:
|
||||
]
|
||||
logger.info(f"已关闭 MCP 服务 {name}")
|
||||
|
||||
def get_func_desc_openai_style(self, omit_empty_parameter_field = True) -> list:
|
||||
def get_func_desc_openai_style(self, omit_empty_parameter_field=False) -> list:
|
||||
"""
|
||||
获得 OpenAI API 风格的**已经激活**的工具描述
|
||||
"""
|
||||
|
||||
@@ -21,16 +21,15 @@ class ProviderDashscopeTTSAPI(TTSProvider):
|
||||
self.voice: str = provider_config.get("dashscope_tts_voice", "loongstella")
|
||||
self.set_model(provider_config.get("model", None))
|
||||
self.timeout_ms = float(provider_config.get("timeout", 20)) * 1000
|
||||
|
||||
dashscope.api_key = self.chosen_api_key
|
||||
|
||||
async def get_audio(self, text: str) -> str:
|
||||
path = f"data/temp/dashscope_tts_{uuid.uuid4()}.wav"
|
||||
self.synthesizer = SpeechSynthesizer(
|
||||
model=self.get_model(),
|
||||
voice=self.voice,
|
||||
format=AudioFormat.WAV_24000HZ_MONO_16BIT,
|
||||
)
|
||||
|
||||
async def get_audio(self, text: str) -> str:
|
||||
path = f"data/temp/dashscope_tts_{uuid.uuid4()}.wav"
|
||||
audio = await asyncio.get_event_loop().run_in_executor(
|
||||
None, self.synthesizer.call, text, self.timeout_ms
|
||||
)
|
||||
|
||||
@@ -1,121 +1,55 @@
|
||||
import base64
|
||||
import aiohttp
|
||||
import json
|
||||
import random
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
from typing import Dict, List, Optional
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from google.genai.errors import APIError
|
||||
|
||||
import astrbot.core.message.components as Comp
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.utils.io import download_image_by_url
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.api.provider import Provider, Personality
|
||||
from astrbot import logger
|
||||
from astrbot.core.provider.func_tool_manager import FuncCall
|
||||
from typing import List
|
||||
from ..register import register_provider_adapter
|
||||
from astrbot.api.provider import Personality, Provider
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.provider.entities import LLMResponse
|
||||
from astrbot.core.provider.func_tool_manager import FuncCall
|
||||
from astrbot.core.utils.io import download_image_by_url
|
||||
|
||||
from ..register import register_provider_adapter
|
||||
|
||||
|
||||
class SimpleGoogleGenAIClient:
|
||||
def __init__(self, api_key: str, api_base: str, timeout: int = 120) -> None:
|
||||
self.api_key = api_key
|
||||
if api_base.endswith("/"):
|
||||
self.api_base = api_base[:-1]
|
||||
else:
|
||||
self.api_base = api_base
|
||||
self.client = aiohttp.ClientSession(trust_env=True)
|
||||
self.timeout = timeout
|
||||
class SuppressNonTextPartsWarning(logging.Filter):
|
||||
"""过滤 Gemini SDK 中的非文本部分警告"""
|
||||
|
||||
async def models_list(self) -> List[str]:
|
||||
request_url = f"{self.api_base}/v1beta/models?key={self.api_key}"
|
||||
async with self.client.get(request_url, timeout=self.timeout) as resp:
|
||||
response = await resp.json()
|
||||
def filter(self, record):
|
||||
return "there are non-text parts in the response" not in record.getMessage()
|
||||
|
||||
models = []
|
||||
for model in response["models"]:
|
||||
if "generateContent" in model["supportedGenerationMethods"]:
|
||||
models.append(model["name"].replace("models/", ""))
|
||||
return models
|
||||
|
||||
async def generate_content(
|
||||
self,
|
||||
contents: List[dict],
|
||||
model: str = "gemini-1.5-flash",
|
||||
system_instruction: str = "",
|
||||
tools: dict = None,
|
||||
modalities: List[str] = ["Text"],
|
||||
safety_settings: List[dict] = [],
|
||||
):
|
||||
payload = {}
|
||||
if system_instruction:
|
||||
payload["system_instruction"] = {"parts": {"text": system_instruction}}
|
||||
if tools:
|
||||
payload["tools"] = [tools]
|
||||
payload["contents"] = contents
|
||||
payload["generationConfig"] = {
|
||||
"responseModalities": modalities,
|
||||
}
|
||||
payload["safetySettings"] = [
|
||||
{"category": s["category"], "threshold": s["threshold"]}
|
||||
for s in safety_settings
|
||||
]
|
||||
logger.debug(f"payload: {payload}")
|
||||
request_url = (
|
||||
f"{self.api_base}/v1beta/models/{model}:generateContent?key={self.api_key}"
|
||||
)
|
||||
async with self.client.post(
|
||||
request_url, json=payload, timeout=self.timeout
|
||||
) as resp:
|
||||
if "application/json" in resp.headers.get("Content-Type"):
|
||||
try:
|
||||
response = await resp.json()
|
||||
except Exception as e:
|
||||
text = await resp.text()
|
||||
logger.error(f"Gemini 返回了非 json 数据: {text}")
|
||||
raise e
|
||||
return response
|
||||
else:
|
||||
text = await resp.text()
|
||||
logger.error(f"Gemini 返回了非 json 数据: {text}")
|
||||
raise Exception("Gemini 返回了非 json 数据: ")
|
||||
logging.getLogger("google_genai.types").addFilter(SuppressNonTextPartsWarning())
|
||||
|
||||
async def stream_generate_content(
|
||||
self,
|
||||
contents: List[dict],
|
||||
model: str = "gemini-1.5-flash",
|
||||
system_instruction: str = "",
|
||||
tools: dict = None,
|
||||
modalities: List[str] = ["Text"],
|
||||
safety_settings: List[dict] = [],
|
||||
):
|
||||
payload = {}
|
||||
if system_instruction:
|
||||
payload["system_instruction"] = {"parts": {"text": system_instruction}}
|
||||
if tools:
|
||||
payload["tools"] = [tools]
|
||||
payload["contents"] = contents
|
||||
payload["generationConfig"] = {
|
||||
"responseModalities": modalities,
|
||||
"stream": True,
|
||||
}
|
||||
payload["safetySettings"] = [
|
||||
{"category": s["category"], "threshold": s["threshold"]}
|
||||
for s in safety_settings
|
||||
]
|
||||
logger.debug(f"payload: {payload}")
|
||||
request_url = (
|
||||
f"{self.api_base}/v1beta/models/{model}:streamGenerateContent?key={self.api_key}"
|
||||
)
|
||||
async with self.client.post(
|
||||
request_url, json=payload, timeout=self.timeout
|
||||
) as resp:
|
||||
async for line in resp.content:
|
||||
if line:
|
||||
yield line
|
||||
|
||||
@register_provider_adapter(
|
||||
"googlegenai_chat_completion", "Google Gemini Chat Completion 提供商适配器"
|
||||
)
|
||||
class ProviderGoogleGenAI(Provider):
|
||||
CATEGORY_MAPPING = {
|
||||
"harassment": types.HarmCategory.HARM_CATEGORY_HARASSMENT,
|
||||
"hate_speech": types.HarmCategory.HARM_CATEGORY_HATE_SPEECH,
|
||||
"sexually_explicit": types.HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
|
||||
"dangerous_content": types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
|
||||
}
|
||||
|
||||
THRESHOLD_MAPPING = {
|
||||
"BLOCK_NONE": types.HarmBlockThreshold.BLOCK_NONE,
|
||||
"BLOCK_ONLY_HIGH": types.HarmBlockThreshold.BLOCK_ONLY_HIGH,
|
||||
"BLOCK_MEDIUM_AND_ABOVE": types.HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE,
|
||||
"BLOCK_LOW_AND_ABOVE": types.HarmBlockThreshold.BLOCK_LOW_AND_ABOVE,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider_config: dict,
|
||||
@@ -131,183 +65,384 @@ class ProviderGoogleGenAI(Provider):
|
||||
db_helper,
|
||||
default_persona,
|
||||
)
|
||||
self.chosen_api_key = None
|
||||
self.api_keys: List = provider_config.get("key", [])
|
||||
self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else None
|
||||
self.timeout = provider_config.get("timeout", 180)
|
||||
if isinstance(self.timeout, str):
|
||||
self.timeout = int(self.timeout)
|
||||
self.client = SimpleGoogleGenAIClient(
|
||||
api_key=self.chosen_api_key,
|
||||
api_base=provider_config.get("api_base", None),
|
||||
timeout=self.timeout,
|
||||
)
|
||||
self.chosen_api_key: str = self.api_keys[0] if len(self.api_keys) > 0 else None
|
||||
self.timeout: int = int(provider_config.get("timeout", 180))
|
||||
|
||||
self.api_base: Optional[str] = provider_config.get("api_base", None)
|
||||
if self.api_base and self.api_base.endswith("/"):
|
||||
self.api_base = self.api_base[:-1]
|
||||
|
||||
self._init_client()
|
||||
self.set_model(provider_config["model_config"]["model"])
|
||||
self._init_safety_settings()
|
||||
|
||||
safety_mapping = {
|
||||
"harassment": "HARM_CATEGORY_HARASSMENT",
|
||||
"hate_speech": "HARM_CATEGORY_HATE_SPEECH",
|
||||
"sexually_explicit": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
|
||||
"dangerous_content": "HARM_CATEGORY_DANGEROUS_CONTENT",
|
||||
}
|
||||
def _init_client(self) -> None:
|
||||
"""初始化Gemini客户端"""
|
||||
self.client = genai.Client(
|
||||
api_key=self.chosen_api_key,
|
||||
http_options=types.HttpOptions(
|
||||
base_url=self.api_base,
|
||||
timeout=self.timeout * 1000, # 毫秒
|
||||
),
|
||||
).aio
|
||||
|
||||
self.safety_settings = []
|
||||
def _init_safety_settings(self) -> None:
|
||||
"""初始化安全设置"""
|
||||
user_safety_config = self.provider_config.get("gm_safety_settings", {})
|
||||
for config_key, harm_category in safety_mapping.items():
|
||||
if threshold := user_safety_config.get(config_key):
|
||||
self.safety_settings.append(
|
||||
{"category": harm_category, "threshold": threshold}
|
||||
self.safety_settings = [
|
||||
types.SafetySetting(
|
||||
category=harm_category, threshold=self.THRESHOLD_MAPPING[threshold_str]
|
||||
)
|
||||
for config_key, harm_category in self.CATEGORY_MAPPING.items()
|
||||
if (threshold_str := user_safety_config.get(config_key))
|
||||
and threshold_str in self.THRESHOLD_MAPPING
|
||||
]
|
||||
|
||||
async def _handle_api_error(self, e: APIError, keys: List[str]) -> bool:
|
||||
"""处理API错误,返回是否需要重试"""
|
||||
if e.code == 429 or "API key not valid" in e.message:
|
||||
keys.remove(self.chosen_api_key)
|
||||
if len(keys) > 0:
|
||||
self.set_key(random.choice(keys))
|
||||
logger.info(
|
||||
f"检测到 Key 异常({e.message}),正在尝试更换 API Key 重试... 当前 Key: {self.chosen_api_key[:12]}..."
|
||||
)
|
||||
await asyncio.sleep(1)
|
||||
return True
|
||||
else:
|
||||
logger.error(
|
||||
f"检测到 Key 异常({e.message}),且已没有可用的 Key。 当前 Key: {self.chosen_api_key[:12]}..."
|
||||
)
|
||||
raise Exception("达到了 Gemini 速率限制, 请稍后再试...")
|
||||
else:
|
||||
logger.error(
|
||||
f"发生了错误(gemini_source)。Provider 配置如下: {self.provider_config}"
|
||||
)
|
||||
raise e
|
||||
|
||||
async def _prepare_query_config(
|
||||
self,
|
||||
payloads: dict,
|
||||
tools: Optional[FuncCall] = None,
|
||||
system_instruction: Optional[str] = None,
|
||||
modalities: Optional[List[str]] = None,
|
||||
temperature: float = 0.7,
|
||||
) -> types.GenerateContentConfig:
|
||||
"""准备查询配置"""
|
||||
if not modalities:
|
||||
modalities = ["Text"]
|
||||
|
||||
# 流式输出不支持图片模态
|
||||
if (
|
||||
self.provider_settings.get("streaming_response", False)
|
||||
and "Image" in modalities
|
||||
):
|
||||
logger.warning("流式输出不支持图片模态,已自动降级为文本模态")
|
||||
modalities = ["Text"]
|
||||
|
||||
tool_list = None
|
||||
native_coderunner = self.provider_config.get("gm_native_coderunner", False)
|
||||
native_search = self.provider_config.get("gm_native_search", False)
|
||||
|
||||
if native_coderunner:
|
||||
tool_list = [types.Tool(code_execution=types.ToolCodeExecution())]
|
||||
if native_search:
|
||||
logger.warning("已启用代码执行工具,搜索工具将被忽略")
|
||||
if tools:
|
||||
logger.warning("已启用代码执行工具,函数工具将被忽略")
|
||||
elif native_search:
|
||||
tool_list = [types.Tool(google_search=types.GoogleSearch())]
|
||||
if tools:
|
||||
logger.warning("已启用搜索工具,函数工具将被忽略")
|
||||
elif tools and (func_desc := tools.get_func_desc_google_genai_style()):
|
||||
tool_list = [
|
||||
types.Tool(function_declarations=func_desc["function_declarations"])
|
||||
]
|
||||
return types.GenerateContentConfig(
|
||||
system_instruction=system_instruction,
|
||||
temperature=temperature,
|
||||
max_output_tokens=payloads.get("max_tokens") or payloads.get("maxOutputTokens"),
|
||||
top_p=payloads.get("top_p") or payloads.get("topP"),
|
||||
top_k=payloads.get("top_k") or payloads.get("topK"),
|
||||
frequency_penalty=payloads.get("frequency_penalty") or payloads.get("frequencyPenalty"),
|
||||
presence_penalty=payloads.get("presence_penalty") or payloads.get("presencePenalty"),
|
||||
stop_sequences=payloads.get("stop") or payloads.get("stopSequences"),
|
||||
response_logprobs=payloads.get("response_logprobs") or payloads.get("responseLogprobs"),
|
||||
logprobs=payloads.get("logprobs"),
|
||||
seed=payloads.get("seed"),
|
||||
response_modalities=modalities,
|
||||
tools=tool_list,
|
||||
safety_settings=self.safety_settings if self.safety_settings else None,
|
||||
automatic_function_calling=types.AutomaticFunctionCallingConfig(
|
||||
disable=True
|
||||
),
|
||||
)
|
||||
|
||||
def _prepare_conversation(self, payloads: Dict) -> List[types.Content]:
|
||||
"""准备 Gemini SDK 的 Content 列表"""
|
||||
|
||||
def create_text_part(text: str) -> types.UserContent:
|
||||
content_a = text if text else " "
|
||||
if not text:
|
||||
logger.warning("文本内容为空,已添加空格占位")
|
||||
return types.UserContent(parts=[types.Part.from_text(text=content_a)])
|
||||
|
||||
def process_image_url(image_url_dict: dict) -> types.Part:
|
||||
url = image_url_dict["url"]
|
||||
mime_type = url.split(":")[1].split(";")[0]
|
||||
image_bytes = base64.b64decode(url.split(",", 1)[1])
|
||||
return types.Part.from_bytes(data=image_bytes, mime_type=mime_type)
|
||||
|
||||
gemini_contents: List[types.Content] = []
|
||||
native_tool_enabled = any(
|
||||
[
|
||||
self.provider_config.get("gm_native_coderunner", False),
|
||||
self.provider_config.get("gm_native_search", False),
|
||||
]
|
||||
)
|
||||
for message in payloads["messages"]:
|
||||
role, content = message["role"], message.get("content")
|
||||
|
||||
if role == "user":
|
||||
if isinstance(content, str):
|
||||
gemini_contents.append(create_text_part(content))
|
||||
elif isinstance(content, list):
|
||||
parts = [
|
||||
types.Part.from_text(text=item["text"] or " ")
|
||||
if item["type"] == "text"
|
||||
else process_image_url(item["image_url"])
|
||||
for item in content
|
||||
]
|
||||
gemini_contents.append(types.UserContent(parts=parts))
|
||||
|
||||
elif role == "assistant":
|
||||
if content:
|
||||
gemini_contents.append(
|
||||
types.ModelContent(parts=[types.Part.from_text(text=content)])
|
||||
)
|
||||
elif "tool_calls" in message and not native_tool_enabled:
|
||||
gemini_contents.extend(
|
||||
[
|
||||
types.ModelContent(
|
||||
parts=[
|
||||
types.Part.from_function_call(
|
||||
name=tool["function"]["name"],
|
||||
args=json.loads(tool["function"]["arguments"]),
|
||||
)
|
||||
]
|
||||
)
|
||||
for tool in message["tool_calls"]
|
||||
]
|
||||
)
|
||||
else:
|
||||
logger.warning("assistant 角色的消息内容为空,已添加空格占位")
|
||||
if native_tool_enabled:
|
||||
logger.warning(
|
||||
"检测到启用Gemini原生工具,且上下文中存在函数调用,建议使用 /reset 重置上下文"
|
||||
)
|
||||
gemini_contents.append(
|
||||
types.ModelContent(parts=[types.Part.from_text(text=" ")])
|
||||
)
|
||||
|
||||
elif role == "tool" and not native_tool_enabled:
|
||||
gemini_contents.append(
|
||||
types.UserContent(
|
||||
parts=[
|
||||
types.Part.from_function_response(
|
||||
name=message["tool_call_id"],
|
||||
response={
|
||||
"name": message["tool_call_id"],
|
||||
"content": message["content"],
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
async def get_models(self):
|
||||
return await self.client.models_list()
|
||||
return gemini_contents
|
||||
|
||||
async def _query(self, payloads: dict, tools: FuncCall) -> LLMResponse:
|
||||
tool = None
|
||||
if tools:
|
||||
tool = tools.get_func_desc_google_genai_style()
|
||||
if not tool:
|
||||
tool = None
|
||||
@staticmethod
|
||||
def _process_content_parts(
|
||||
result: types.GenerateContentResponse, llm_response: LLMResponse
|
||||
) -> MessageChain:
|
||||
"""处理内容部分并构建消息链"""
|
||||
finish_reason = result.candidates[0].finish_reason
|
||||
result_parts: Optional[types.Part] = result.candidates[0].content.parts
|
||||
|
||||
if finish_reason == types.FinishReason.SAFETY:
|
||||
raise Exception("模型生成内容未通过用户定义的内容安全检查")
|
||||
|
||||
if finish_reason in {
|
||||
types.FinishReason.PROHIBITED_CONTENT,
|
||||
types.FinishReason.SPII,
|
||||
types.FinishReason.BLOCKLIST,
|
||||
}:
|
||||
raise Exception("模型生成内容违反Gemini平台政策")
|
||||
|
||||
# 防止旧版本SDK不存在IMAGE_SAFETY
|
||||
if hasattr(types.FinishReason, "IMAGE_SAFETY"):
|
||||
if finish_reason == types.FinishReason.IMAGE_SAFETY:
|
||||
raise Exception("模型生成内容违反Gemini平台政策")
|
||||
|
||||
if not result_parts:
|
||||
logger.debug(result.candidates)
|
||||
raise Exception("API 返回的内容为空。")
|
||||
|
||||
chain = []
|
||||
part: types.Part
|
||||
|
||||
# 暂时这样Fallback
|
||||
if all(
|
||||
part.inline_data and part.inline_data.mime_type.startswith("image/")
|
||||
for part in result_parts
|
||||
):
|
||||
chain.append(Comp.Plain("这是图片"))
|
||||
for part in result_parts:
|
||||
if part.text:
|
||||
chain.append(Comp.Plain(part.text))
|
||||
elif part.function_call:
|
||||
llm_response.role = "tool"
|
||||
llm_response.tools_call_name.append(part.function_call.name)
|
||||
llm_response.tools_call_args.append(part.function_call.args)
|
||||
# gemini 返回的 function_call.id 可能为 None
|
||||
llm_response.tools_call_ids.append(
|
||||
part.function_call.id or part.function_call.name
|
||||
)
|
||||
elif part.inline_data and part.inline_data.mime_type.startswith("image/"):
|
||||
chain.append(Comp.Image.fromBytes(part.inline_data.data))
|
||||
return MessageChain(chain=chain)
|
||||
|
||||
async def _query(
|
||||
self, payloads: dict, tools: FuncCall
|
||||
) -> LLMResponse:
|
||||
"""非流式请求 Gemini API"""
|
||||
system_instruction = next(
|
||||
(msg["content"] for msg in payloads["messages"] if msg["role"] == "system"),
|
||||
None,
|
||||
)
|
||||
|
||||
modalities = ["Text"]
|
||||
if self.provider_config.get("gm_resp_image_modal", False):
|
||||
modalities.append("Image")
|
||||
|
||||
conversation = self._prepare_conversation(payloads)
|
||||
temperature=payloads.get("temperature", 0.7)
|
||||
|
||||
result: Optional[types.GenerateContentResponse] = None
|
||||
while True:
|
||||
try:
|
||||
config = await self._prepare_query_config(
|
||||
payloads, tools, system_instruction, modalities, temperature
|
||||
)
|
||||
result = await self.client.models.generate_content(
|
||||
model=self.get_model(),
|
||||
contents=conversation,
|
||||
config=config,
|
||||
)
|
||||
|
||||
if result.candidates[0].finish_reason == types.FinishReason.RECITATION:
|
||||
if temperature > 2:
|
||||
raise Exception("温度参数已超过最大值2,仍然发生recitation")
|
||||
temperature += 0.2
|
||||
logger.warning(
|
||||
f"发生了recitation,正在提高温度至{temperature:.1f}重试..."
|
||||
)
|
||||
continue
|
||||
|
||||
system_instruction = ""
|
||||
for message in payloads["messages"]:
|
||||
if message["role"] == "system":
|
||||
system_instruction = message["content"]
|
||||
break
|
||||
|
||||
google_genai_conversation = []
|
||||
for message in payloads["messages"]:
|
||||
if message["role"] == "user":
|
||||
if isinstance(message["content"], str):
|
||||
if not message["content"]:
|
||||
message["content"] = " "
|
||||
|
||||
google_genai_conversation.append(
|
||||
{"role": "user", "parts": [{"text": message["content"]}]}
|
||||
except APIError as e:
|
||||
if "Developer instruction is not enabled" in e.message:
|
||||
logger.warning(
|
||||
f"{self.get_model()} 不支持 system prompt,已自动去除(影响人格设置)"
|
||||
)
|
||||
elif isinstance(message["content"], list):
|
||||
# images
|
||||
parts = []
|
||||
for part in message["content"]:
|
||||
if part["type"] == "text":
|
||||
if not part["text"]:
|
||||
part["text"] = ""
|
||||
parts.append({"text": part["text"]})
|
||||
elif part["type"] == "image_url":
|
||||
parts.append(
|
||||
{
|
||||
"inline_data": {
|
||||
"mime_type": "image/jpeg",
|
||||
"data": part["image_url"]["url"].replace(
|
||||
"data:image/jpeg;base64,", ""
|
||||
), # base64
|
||||
}
|
||||
}
|
||||
)
|
||||
google_genai_conversation.append({"role": "user", "parts": parts})
|
||||
|
||||
elif message["role"] == "assistant":
|
||||
if "content" in message:
|
||||
if not message["content"]:
|
||||
message["content"] = " "
|
||||
google_genai_conversation.append(
|
||||
{"role": "model", "parts": [{"text": message["content"]}]}
|
||||
system_instruction = None
|
||||
elif "Function calling is not enabled" in e.message:
|
||||
logger.warning(f"{self.get_model()} 不支持函数调用,已自动去除")
|
||||
tools = None
|
||||
elif (
|
||||
"Multi-modal output is not supported" in e.message
|
||||
or "Model does not support the requested response modalities"
|
||||
in e.message
|
||||
or "only supports text output" in e.message
|
||||
):
|
||||
logger.warning(
|
||||
f"{self.get_model()} 不支持多模态输出,降级为文本模态"
|
||||
)
|
||||
elif "tool_calls" in message:
|
||||
# tool calls in the last turn
|
||||
parts = []
|
||||
for tool_call in message["tool_calls"]:
|
||||
parts.append(
|
||||
{
|
||||
"functionCall": {
|
||||
"name": tool_call["function"]["name"],
|
||||
"args": json.loads(
|
||||
tool_call["function"]["arguments"]
|
||||
),
|
||||
}
|
||||
}
|
||||
)
|
||||
google_genai_conversation.append({"role": "model", "parts": parts})
|
||||
elif message["role"] == "tool":
|
||||
parts = []
|
||||
parts.append(
|
||||
{
|
||||
"functionResponse": {
|
||||
"name": message["tool_call_id"],
|
||||
"response": {
|
||||
"name": message["tool_call_id"],
|
||||
"content": message["content"],
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
google_genai_conversation.append({"role": "user", "parts": parts})
|
||||
modalities = ["Text"]
|
||||
else:
|
||||
raise
|
||||
continue
|
||||
|
||||
logger.debug(f"google_genai_conversation: {google_genai_conversation}")
|
||||
|
||||
modalites = ["Text"]
|
||||
if self.provider_config.get("gm_resp_image_modal", False):
|
||||
modalites.append("Image")
|
||||
|
||||
loop = True
|
||||
while loop:
|
||||
loop = False
|
||||
result = await self.client.generate_content(
|
||||
contents=google_genai_conversation,
|
||||
model=self.get_model(),
|
||||
system_instruction=system_instruction,
|
||||
tools=tool,
|
||||
modalities=modalites,
|
||||
safety_settings=self.safety_settings,
|
||||
)
|
||||
logger.debug(f"result: {result}")
|
||||
|
||||
# Developer instruction is not enabled for models/gemini-2.0-flash-exp
|
||||
if "Developer instruction is not enabled" in str(result):
|
||||
logger.warning(
|
||||
f"{self.get_model()} 不支持 system prompt, 已自动去除, 将会影响人格设置。"
|
||||
)
|
||||
system_instruction = ""
|
||||
loop = True
|
||||
|
||||
elif "Function calling is not enabled" in str(result):
|
||||
logger.warning(
|
||||
f"{self.get_model()} 不支持函数调用,已自动去除,不影响使用。"
|
||||
)
|
||||
tool = None
|
||||
loop = True
|
||||
|
||||
elif "Multi-modal output is not supported" in str(result):
|
||||
logger.warning(
|
||||
f"{self.get_model()} 不支持多模态输出,降级为文本模态重新请求。"
|
||||
)
|
||||
modalites = ["Text"]
|
||||
loop = True
|
||||
|
||||
elif "candidates" not in result:
|
||||
raise Exception("Gemini 返回异常结果: " + str(result))
|
||||
|
||||
candidates = result["candidates"][0]["content"]["parts"]
|
||||
llm_response = LLMResponse("assistant")
|
||||
chain = []
|
||||
for candidate in candidates:
|
||||
if "text" in candidate:
|
||||
chain.append(Comp.Plain(candidate["text"]))
|
||||
elif "functionCall" in candidate:
|
||||
llm_response.role = "tool"
|
||||
llm_response.tools_call_args.append(candidate["functionCall"]["args"])
|
||||
llm_response.tools_call_name.append(candidate["functionCall"]["name"])
|
||||
llm_response.tools_call_ids.append(
|
||||
candidate["functionCall"]["name"]
|
||||
) # 没有 tool id
|
||||
elif "inlineData" in candidate:
|
||||
mime_type: str = candidate["inlineData"]["mimeType"]
|
||||
if mime_type.startswith("image/"):
|
||||
chain.append(Comp.Image.fromBase64(candidate["inlineData"]["data"]))
|
||||
|
||||
llm_response.result_chain = MessageChain(chain=chain)
|
||||
llm_response.result_chain = self._process_content_parts(result, llm_response)
|
||||
return llm_response
|
||||
|
||||
async def _query_stream(
|
||||
self, payloads: dict, tools: FuncCall
|
||||
) -> AsyncGenerator[LLMResponse, None]:
|
||||
"""流式请求 Gemini API"""
|
||||
system_instruction = next(
|
||||
(msg["content"] for msg in payloads["messages"] if msg["role"] == "system"),
|
||||
None,
|
||||
)
|
||||
|
||||
conversation = self._prepare_conversation(payloads)
|
||||
|
||||
result = None
|
||||
while True:
|
||||
try:
|
||||
config = await self._prepare_query_config(
|
||||
payloads, tools, system_instruction
|
||||
)
|
||||
result = await self.client.models.generate_content_stream(
|
||||
model=self.get_model(),
|
||||
contents=conversation,
|
||||
config=config,
|
||||
)
|
||||
break
|
||||
except APIError as e:
|
||||
if "Developer instruction is not enabled" in e.message:
|
||||
logger.warning(
|
||||
f"{self.get_model()} 不支持 system prompt,已自动去除(影响人格设置)"
|
||||
)
|
||||
system_instruction = None
|
||||
elif "Function calling is not enabled" in e.message:
|
||||
logger.warning(f"{self.get_model()} 不支持函数调用,已自动去除")
|
||||
tools = None
|
||||
else:
|
||||
raise
|
||||
continue
|
||||
|
||||
async for chunk in result:
|
||||
llm_response = LLMResponse("assistant", is_chunk=True)
|
||||
|
||||
if chunk.candidates[0].content.parts and any(
|
||||
part.function_call for part in chunk.candidates[0].content.parts
|
||||
):
|
||||
llm_response = LLMResponse("assistant", is_chunk=False)
|
||||
llm_response.result_chain = self._process_content_parts(
|
||||
chunk, llm_response
|
||||
)
|
||||
yield llm_response
|
||||
break
|
||||
|
||||
if chunk.text:
|
||||
llm_response.result_chain = MessageChain(chain=[Comp.Plain(chunk.text)])
|
||||
yield llm_response
|
||||
|
||||
if chunk.candidates[0].finish_reason:
|
||||
llm_response = LLMResponse("assistant", is_chunk=False)
|
||||
if not chunk.candidates[0].content.parts:
|
||||
llm_response.result_chain = MessageChain(chain=[Comp.Plain(" ")])
|
||||
else:
|
||||
llm_response.result_chain = self._process_content_parts(
|
||||
chunk, llm_response
|
||||
)
|
||||
yield llm_response
|
||||
break
|
||||
|
||||
async def text_chat(
|
||||
self,
|
||||
prompt: str,
|
||||
@@ -320,7 +455,6 @@ class ProviderGoogleGenAI(Provider):
|
||||
**kwargs,
|
||||
) -> LLMResponse:
|
||||
new_record = await self.assemble_context(prompt, image_urls)
|
||||
context_query = []
|
||||
context_query = [*contexts, new_record]
|
||||
if system_prompt:
|
||||
context_query.insert(0, {"role": "system", "content": system_prompt})
|
||||
@@ -337,82 +471,90 @@ class ProviderGoogleGenAI(Provider):
|
||||
model_config["model"] = self.get_model()
|
||||
|
||||
payloads = {"messages": context_query, **model_config}
|
||||
llm_response = None
|
||||
|
||||
retry = 10
|
||||
keys = self.api_keys.copy()
|
||||
chosen_key = random.choice(keys)
|
||||
|
||||
for i in range(retry):
|
||||
for _ in range(retry):
|
||||
try:
|
||||
self.client.api_key = chosen_key
|
||||
llm_response = await self._query(payloads, func_tool)
|
||||
return await self._query(payloads, func_tool)
|
||||
except APIError as e:
|
||||
if await self._handle_api_error(e, keys):
|
||||
continue
|
||||
break
|
||||
except Exception as e:
|
||||
if "429" in str(e) or "API key not valid" in str(e):
|
||||
keys.remove(chosen_key)
|
||||
if len(keys) > 0:
|
||||
chosen_key = random.choice(keys)
|
||||
logger.info(
|
||||
f"检测到 Key 异常({str(e)}),正在尝试更换 API Key 重试... 当前 Key: {chosen_key[:12]}..."
|
||||
)
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
else:
|
||||
logger.error(
|
||||
f"检测到 Key 异常({str(e)}),且已没有可用的 Key。 当前 Key: {chosen_key[:12]}..."
|
||||
)
|
||||
raise Exception("达到了 Gemini 速率限制, 请稍后再试...")
|
||||
else:
|
||||
logger.error(
|
||||
f"发生了错误(gemini_source)。Provider 配置如下: {self.provider_config}"
|
||||
)
|
||||
raise e
|
||||
|
||||
return llm_response
|
||||
|
||||
async def text_chat_stream(
|
||||
self,
|
||||
prompt,
|
||||
session_id=None,
|
||||
image_urls=...,
|
||||
func_tool=None,
|
||||
contexts=...,
|
||||
prompt: str,
|
||||
session_id: str = None,
|
||||
image_urls: List[str] = [],
|
||||
func_tool: FuncCall = None,
|
||||
contexts=[],
|
||||
system_prompt=None,
|
||||
tool_calls_result=None,
|
||||
**kwargs,
|
||||
):
|
||||
# raise NotImplementedError("This method is not implemented yet.")
|
||||
# 调用 text_chat 模拟流式
|
||||
llm_response = await self.text_chat(
|
||||
prompt=prompt,
|
||||
session_id=session_id,
|
||||
image_urls=image_urls,
|
||||
func_tool=func_tool,
|
||||
contexts=contexts,
|
||||
system_prompt=system_prompt,
|
||||
tool_calls_result=tool_calls_result,
|
||||
)
|
||||
llm_response.is_chunk = True
|
||||
yield llm_response
|
||||
llm_response.is_chunk = False
|
||||
yield llm_response
|
||||
) -> AsyncGenerator[LLMResponse, None]:
|
||||
new_record = await self.assemble_context(prompt, image_urls)
|
||||
context_query = [*contexts, new_record]
|
||||
if system_prompt:
|
||||
context_query.insert(0, {"role": "system", "content": system_prompt})
|
||||
|
||||
for part in context_query:
|
||||
if "_no_save" in part:
|
||||
del part["_no_save"]
|
||||
|
||||
# tool calls result
|
||||
if tool_calls_result:
|
||||
context_query.extend(tool_calls_result.to_openai_messages())
|
||||
|
||||
model_config = self.provider_config.get("model_config", {})
|
||||
model_config["model"] = self.get_model()
|
||||
|
||||
payloads = {"messages": context_query, **model_config}
|
||||
|
||||
retry = 10
|
||||
keys = self.api_keys.copy()
|
||||
|
||||
for _ in range(retry):
|
||||
try:
|
||||
async for response in self._query_stream(payloads, func_tool):
|
||||
yield response
|
||||
break
|
||||
except APIError as e:
|
||||
if await self._handle_api_error(e, keys):
|
||||
continue
|
||||
break
|
||||
|
||||
async def get_models(self):
|
||||
try:
|
||||
models = await self.client.models.list()
|
||||
return [
|
||||
m.name.replace("models/", "")
|
||||
for m in models
|
||||
if "generateContent" in m.supported_actions
|
||||
]
|
||||
except APIError as e:
|
||||
raise Exception(f"获取模型列表失败: {e.message}")
|
||||
|
||||
def get_current_key(self) -> str:
|
||||
return self.client.api_key
|
||||
return self.chosen_api_key
|
||||
|
||||
def get_keys(self) -> List[str]:
|
||||
return self.api_keys
|
||||
|
||||
def set_key(self, key):
|
||||
self.client.api_key = key
|
||||
self.chosen_api_key = key
|
||||
self._init_client()
|
||||
|
||||
async def assemble_context(self, text: str, image_urls: List[str] = None):
|
||||
"""
|
||||
组装上下文。
|
||||
"""
|
||||
if image_urls:
|
||||
user_content = {"role": "user", "content": [{"type": "text", "text": text}]}
|
||||
user_content = {
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": text if text else "[图片]"}],
|
||||
}
|
||||
for image_url in image_urls:
|
||||
if image_url.startswith("http"):
|
||||
image_path = await download_image_by_url(image_url)
|
||||
@@ -444,5 +586,4 @@ class ProviderGoogleGenAI(Provider):
|
||||
return ""
|
||||
|
||||
async def terminate(self):
|
||||
await self.client.client.close()
|
||||
logger.info("Google GenAI 适配器已终止。")
|
||||
|
||||
@@ -87,7 +87,8 @@ class ProviderOpenAIOfficial(Provider):
|
||||
|
||||
async def _query(self, payloads: dict, tools: FuncCall) -> LLMResponse:
|
||||
if tools:
|
||||
omit_empty_param_field = "grok" not in payloads.get("model", "").lower()
|
||||
model = payloads.get("model", "").lower()
|
||||
omit_empty_param_field = "gemini" in model
|
||||
tool_list = tools.get_func_desc_openai_style(
|
||||
omit_empty_parameter_field=omit_empty_param_field
|
||||
)
|
||||
@@ -124,7 +125,8 @@ class ProviderOpenAIOfficial(Provider):
|
||||
) -> AsyncGenerator[LLMResponse, None]:
|
||||
"""流式查询API,逐步返回结果"""
|
||||
if tools:
|
||||
omit_empty_param_field = "grok" not in payloads.get("model", "").lower()
|
||||
model = payloads.get("model", "").lower()
|
||||
omit_empty_param_field = "gemini" in model
|
||||
tool_list = tools.get_func_desc_openai_style(
|
||||
omit_empty_parameter_field=omit_empty_param_field
|
||||
)
|
||||
@@ -503,7 +505,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
async def assemble_context(self, text: str, image_urls: List[str] = None) -> dict:
|
||||
"""组装成符合 OpenAI 格式的 role 为 user 的消息段"""
|
||||
if image_urls:
|
||||
user_content = {"role": "user", "content": [{"type": "text", "text": text}]}
|
||||
user_content = {"role": "user", "content": [{"type": "text", "text": text if text else "[图片]"}]}
|
||||
for image_url in image_urls:
|
||||
if image_url.startswith("http"):
|
||||
image_path = await download_image_by_url(image_url)
|
||||
|
||||
@@ -28,7 +28,7 @@ from .filter.permission import PermissionTypeFilter, PermissionType
|
||||
|
||||
class PluginManager:
|
||||
def __init__(self, context: Context, config: AstrBotConfig):
|
||||
self.updator = PluginUpdator(config["plugin_repo_mirror"])
|
||||
self.updator = PluginUpdator()
|
||||
|
||||
self.context = context
|
||||
self.context._star_manager = self
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import threading
|
||||
import os
|
||||
from logging import Logger
|
||||
|
||||
|
||||
class LogPipe(threading.Thread):
|
||||
def __init__(
|
||||
self,
|
||||
level,
|
||||
logger: Logger,
|
||||
identifier=None,
|
||||
callback=None,
|
||||
):
|
||||
threading.Thread.__init__(self)
|
||||
self.daemon = True
|
||||
self.level = level
|
||||
self.fd_read, self.fd_write = os.pipe()
|
||||
self.identifier = identifier
|
||||
self.logger = logger
|
||||
self.callback = callback
|
||||
self.reader = os.fdopen(self.fd_read)
|
||||
self.start()
|
||||
|
||||
def fileno(self):
|
||||
return self.fd_write
|
||||
|
||||
def run(self):
|
||||
for line in iter(self.reader.readline, ""):
|
||||
if self.callback:
|
||||
self.callback(line.strip())
|
||||
self.logger.log(self.level, f"[{self.identifier}] {line.strip()}")
|
||||
|
||||
self.reader.close()
|
||||
|
||||
def close(self):
|
||||
os.close(self.fd_write)
|
||||
@@ -1,10 +1,42 @@
|
||||
import aiohttp
|
||||
import sys
|
||||
import os
|
||||
import socket
|
||||
import uuid
|
||||
from astrbot.core.config import VERSION
|
||||
from astrbot.core import db_helper, logger
|
||||
|
||||
|
||||
class Metric:
|
||||
_iid_cache = None
|
||||
|
||||
@staticmethod
|
||||
def get_installation_id():
|
||||
"""获取或创建一个唯一的安装ID"""
|
||||
if Metric._iid_cache is not None:
|
||||
return Metric._iid_cache
|
||||
|
||||
config_dir = os.path.join(os.path.expanduser("~"), ".astrbot")
|
||||
id_file = os.path.join(config_dir, ".installation_id")
|
||||
|
||||
if os.path.exists(id_file):
|
||||
try:
|
||||
with open(id_file, "r") as f:
|
||||
Metric._iid_cache = f.read().strip()
|
||||
return Metric._iid_cache
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
os.makedirs(config_dir, exist_ok=True)
|
||||
installation_id = str(uuid.uuid4())
|
||||
with open(id_file, "w") as f:
|
||||
f.write(installation_id)
|
||||
Metric._iid_cache = installation_id
|
||||
return installation_id
|
||||
except Exception:
|
||||
Metric._iid_cache = "null"
|
||||
return "null"
|
||||
|
||||
@staticmethod
|
||||
async def upload(**kwargs):
|
||||
"""
|
||||
@@ -16,6 +48,14 @@ class Metric:
|
||||
kwargs["v"] = VERSION
|
||||
kwargs["os"] = sys.platform
|
||||
payload = {"metrics_data": kwargs}
|
||||
try:
|
||||
kwargs["hn"] = socket.gethostname()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
kwargs["iid"] = Metric.get_installation_id()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if "adapter_name" in kwargs:
|
||||
db_helper.insert_platform_metrics({kwargs["adapter_name"]: 1})
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import os
|
||||
from astrbot.core import logger
|
||||
|
||||
def path_Mapping(mappings, srcPath: str)->str:
|
||||
"""路径映射处理函数。尝试支援 Windows 和 Linux 的路径映射。
|
||||
Args:
|
||||
mappings: 映射规则列表
|
||||
srcPath: 原路径
|
||||
Returns:
|
||||
str: 处理后的路径
|
||||
"""
|
||||
for mapping in mappings:
|
||||
rule = mapping.split(":")
|
||||
if len(rule) == 2:
|
||||
from_, to_ = mapping.split(":")
|
||||
elif len(rule) > 4 or len(rule) == 1:
|
||||
# 切割后大于4个项目,或者只有1个项目,那肯定是错误的,只能是2,3,4个项目
|
||||
logger.warning(f"路径映射规则错误: {mapping}")
|
||||
continue
|
||||
else:
|
||||
# rule.len == 3 or 4
|
||||
if(os.path.exists(rule[0]+":"+rule[1])):
|
||||
# 前面两个项目合并路径存在,说明是本地Window路径。后面一个或两个项目组成的路径本地大概率无法解析,直接拼接
|
||||
from_ = rule[0] + ":" + rule[1]
|
||||
if len(rule) == 3:
|
||||
to_ = rule[2]
|
||||
else:
|
||||
to_ = rule[2] + ":" + rule[3]
|
||||
else:
|
||||
# 前面两个项目合并路径不存在,说明第一个项目是本地Linux路径,后面一个或两个项目直接拼接。
|
||||
from_ = rule[0]
|
||||
if len(rule) == 3:
|
||||
to_ = rule[1] + ":" + rule[2]
|
||||
else:
|
||||
# 这种情况下存在四个项目,说明规则也是错误的
|
||||
logger.warning(f"路径映射规则错误: {mapping}")
|
||||
continue
|
||||
|
||||
from_ = from_.removesuffix("/")
|
||||
from_ = from_.removesuffix("\\")
|
||||
to_ = to_.removesuffix("/")
|
||||
to_ = to_.removesuffix("\\")
|
||||
# logger.debug(f"\t路径映射-规则(处理): {from_} -> {to_}")
|
||||
|
||||
url = srcPath.removeprefix("file://")
|
||||
if url.startswith(from_):
|
||||
srcPath = url.replace(from_, to_, 1)
|
||||
if ":" in srcPath:
|
||||
# Windows路径处理
|
||||
srcPath = srcPath.replace("/", "\\")
|
||||
else:
|
||||
has_replaced_processed = False
|
||||
if srcPath.startswith("."):
|
||||
# 相对路径处理。如果是相对路径,可能是Linux路径,也可能是Windows路径
|
||||
sign = srcPath[1]
|
||||
# 处理两个点的情况
|
||||
if sign == ".":
|
||||
sign = srcPath[2]
|
||||
if sign == "/":
|
||||
srcPath = srcPath.replace("\\", "/")
|
||||
has_replaced_processed = True
|
||||
elif sign == "\\":
|
||||
srcPath = srcPath.replace("/", "\\")
|
||||
has_replaced_processed = True
|
||||
if has_replaced_processed == False:
|
||||
# 如果不是相对路径或不能处理,默认按照Linux路径处理
|
||||
srcPath = srcPath.replace("\\", "/")
|
||||
logger.info(f"路径映射: {url} -> {srcPath}")
|
||||
return srcPath
|
||||
return srcPath
|
||||
@@ -5,8 +5,9 @@ logger = logging.getLogger("astrbot")
|
||||
|
||||
|
||||
class PipInstaller:
|
||||
def __init__(self, pip_install_arg: str):
|
||||
def __init__(self, pip_install_arg: str, pypi_index_url: str = None):
|
||||
self.pip_install_arg = pip_install_arg
|
||||
self.pypi_index_url = pypi_index_url
|
||||
|
||||
def install(
|
||||
self,
|
||||
@@ -20,10 +21,9 @@ class PipInstaller:
|
||||
elif requirements_path:
|
||||
args.extend(["-r", requirements_path])
|
||||
|
||||
if not mirror:
|
||||
mirror = "https://mirrors.aliyun.com/pypi/simple/"
|
||||
index_url = mirror or self.pypi_index_url or "https://pypi.org/simple"
|
||||
|
||||
args.extend(["--trusted-host", "mirrors.aliyun.com", "-i", mirror])
|
||||
args.extend(["--trusted-host", "mirrors.aliyun.com", "-i", index_url])
|
||||
|
||||
if self.pip_install_arg:
|
||||
args.extend(self.pip_install_arg.split())
|
||||
|
||||
@@ -97,8 +97,8 @@ class SessionFilter:
|
||||
|
||||
class DefaultSessionFilter(SessionFilter):
|
||||
def filter(self, event: AstrMessageEvent) -> str:
|
||||
"""默认实现,返回发送者的 ID 作为会话标识符"""
|
||||
return event.get_sender_id()
|
||||
"""默认实现,返回统一消息来源字符串作为会话标识符"""
|
||||
return event.unified_msg_origin
|
||||
|
||||
|
||||
class SessionWaiter:
|
||||
|
||||
@@ -9,8 +9,11 @@ class SharedPreferences:
|
||||
|
||||
def _load_preferences(self):
|
||||
if os.path.exists(self.path):
|
||||
with open(self.path, "r") as f:
|
||||
return json.load(f)
|
||||
try:
|
||||
with open(self.path, "r") as f:
|
||||
return json.load(f)
|
||||
except json.JSONDecodeError:
|
||||
os.remove(self.path)
|
||||
return {}
|
||||
|
||||
def _save_preferences(self):
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import re
|
||||
|
||||
|
||||
class VersionComparator:
|
||||
@staticmethod
|
||||
def compare_version(v1: str, v2: str) -> int:
|
||||
"""根据 Semver 语义版本规范来比较版本号的大小。支持不仅局限于 3 个数字的版本号,并处理预发布标签。
|
||||
|
||||
参考: https://semver.org/lang/zh-CN/
|
||||
|
||||
返回 1 表示 v1 > v2,返回 -1 表示 v1 < v2,返回 0 表示 v1 = v2。
|
||||
"""
|
||||
v1 = v1.lower().replace("v", "")
|
||||
v2 = v2.lower().replace("v", "")
|
||||
|
||||
def split_version(version):
|
||||
match = re.match(
|
||||
r"^([0-9]+(?:\.[0-9]+)*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+(.+))?$",
|
||||
version,
|
||||
)
|
||||
if not match:
|
||||
return [], None
|
||||
major_minor_patch = match.group(1).split(".")
|
||||
prerelease = match.group(2)
|
||||
# buildmetadata = match.group(3) # 构建元数据在比较时忽略
|
||||
parts = [int(x) for x in major_minor_patch]
|
||||
prerelease = VersionComparator._split_prerelease(prerelease)
|
||||
return parts, prerelease
|
||||
|
||||
v1_parts, v1_prerelease = split_version(v1)
|
||||
v2_parts, v2_prerelease = split_version(v2)
|
||||
|
||||
# 比较数字部分
|
||||
length = max(len(v1_parts), len(v2_parts))
|
||||
v1_parts.extend([0] * (length - len(v1_parts)))
|
||||
v2_parts.extend([0] * (length - len(v2_parts)))
|
||||
|
||||
for i in range(length):
|
||||
if v1_parts[i] > v2_parts[i]:
|
||||
return 1
|
||||
elif v1_parts[i] < v2_parts[i]:
|
||||
return -1
|
||||
|
||||
# 比较预发布标签
|
||||
if v1_prerelease is None and v2_prerelease is not None:
|
||||
return 1 # 没有预发布标签的版本高于有预发布标签的版本
|
||||
elif v1_prerelease is not None and v2_prerelease is None:
|
||||
return -1 # 有预发布标签的版本低于没有预发布标签的版本
|
||||
elif v1_prerelease is not None and v2_prerelease is not None:
|
||||
len_pre = max(len(v1_prerelease), len(v2_prerelease))
|
||||
for i in range(len_pre):
|
||||
p1 = v1_prerelease[i] if i < len(v1_prerelease) else None
|
||||
p2 = v2_prerelease[i] if i < len(v2_prerelease) else None
|
||||
|
||||
if p1 is None and p2 is not None:
|
||||
return -1
|
||||
elif p1 is not None and p2 is None:
|
||||
return 1
|
||||
elif isinstance(p1, int) and isinstance(p2, str):
|
||||
return -1
|
||||
elif isinstance(p1, str) and isinstance(p2, int):
|
||||
return 1
|
||||
elif isinstance(p1, int) and isinstance(p2, int):
|
||||
if p1 > p2:
|
||||
return 1
|
||||
elif p1 < p2:
|
||||
return -1
|
||||
elif isinstance(p1, str) and isinstance(p2, str):
|
||||
if p1 > p2:
|
||||
return 1
|
||||
elif p1 < p2:
|
||||
return -1
|
||||
return 0 # 预发布标签完全相同
|
||||
|
||||
return 0 # 数字部分和预发布标签都相同
|
||||
|
||||
@staticmethod
|
||||
def _split_prerelease(prerelease):
|
||||
if not prerelease:
|
||||
return None
|
||||
parts = prerelease.split(".")
|
||||
result = []
|
||||
for part in parts:
|
||||
if part.isdigit():
|
||||
result.append(int(part))
|
||||
else:
|
||||
result.append(part)
|
||||
return result
|
||||
@@ -8,6 +8,7 @@ import certifi
|
||||
|
||||
from astrbot.core.utils.io import on_error, download_file
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.utils.version_comparator import VersionComparator
|
||||
|
||||
|
||||
class ReleaseInfo:
|
||||
@@ -102,31 +103,10 @@ class RepoZipUpdator:
|
||||
raise NotImplementedError()
|
||||
|
||||
def compare_version(self, v1: str, v2: str) -> int:
|
||||
"""
|
||||
比较两个版本号的大小。
|
||||
返回 1 表示 v1 > v2,返回 -1 表示 v1 < v2,返回 0 表示 v1 = v2。
|
||||
支持任意长度的版本号,如v1.2.3或v3.5.3.1。
|
||||
"""
|
||||
v1 = v1.replace("v", "")
|
||||
v2 = v2.replace("v", "")
|
||||
v1_parts = v1.split(".")
|
||||
v2_parts = v2.split(".")
|
||||
"""Semver 版本比较"""
|
||||
return VersionComparator.compare_version(v1, v2)
|
||||
|
||||
# 获取最长的版本号长度
|
||||
length = max(len(v1_parts), len(v2_parts))
|
||||
|
||||
# 将短版本号补0以便比较
|
||||
v1_parts.extend(["0"] * (length - len(v1_parts)))
|
||||
v2_parts.extend(["0"] * (length - len(v2_parts)))
|
||||
|
||||
for i in range(length):
|
||||
if int(v1_parts[i]) > int(v2_parts[i]):
|
||||
return 1
|
||||
elif int(v1_parts[i]) < int(v2_parts[i]):
|
||||
return -1
|
||||
return 0
|
||||
|
||||
async def check_update(self, url: str, current_version: str) -> ReleaseInfo:
|
||||
async def check_update(self, url: str, current_version: str) -> ReleaseInfo | None:
|
||||
update_data = await self.fetch_release_info(url)
|
||||
tag_name = update_data[0]["tag_name"]
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import json
|
||||
import aiohttp
|
||||
import traceback
|
||||
from .route import Route, Response, RouteContext
|
||||
from quart import request
|
||||
@@ -20,6 +21,7 @@ class ToolsRoute(Route):
|
||||
"/tools/mcp/add": ("POST", self.add_mcp_server),
|
||||
"/tools/mcp/update": ("POST", self.update_mcp_server),
|
||||
"/tools/mcp/delete": ("POST", self.delete_mcp_server),
|
||||
"/tools/mcp/market": ("GET", self.get_mcp_markets),
|
||||
}
|
||||
self.register_routes()
|
||||
self.tool_mgr = self.core_lifecycle.provider_manager.llm_tools
|
||||
@@ -78,6 +80,7 @@ class ToolsRoute(Route):
|
||||
) in self.tool_mgr.mcp_client_dict.items():
|
||||
if name_key == name:
|
||||
server_info["tools"] = [tool.name for tool in mcp_client.tools]
|
||||
server_info["errlogs"] = mcp_client.server_errlogs
|
||||
break
|
||||
else:
|
||||
server_info["tools"] = []
|
||||
@@ -105,8 +108,14 @@ class ToolsRoute(Route):
|
||||
|
||||
# 复制所有配置字段
|
||||
for key, value in server_data.items():
|
||||
if key not in ["name", "active", "tools"]: # 排除特殊字段
|
||||
server_config[key] = value
|
||||
if key not in ["name", "active", "tools", "errlogs"]: # 排除特殊字段
|
||||
if key == "mcpServers":
|
||||
key_0 = list(server_data["mcpServers"].keys())[
|
||||
0
|
||||
] # 不考虑为空的情况
|
||||
server_config = server_data["mcpServers"][key_0]
|
||||
else:
|
||||
server_config[key] = value
|
||||
has_valid_config = True
|
||||
|
||||
if not has_valid_config:
|
||||
@@ -121,7 +130,7 @@ class ToolsRoute(Route):
|
||||
|
||||
if self.save_mcp_config(config):
|
||||
# 动态初始化新MCP客户端
|
||||
self.tool_mgr.mcp_service_queue.put_nowait(
|
||||
await self.tool_mgr.mcp_service_queue.put(
|
||||
{
|
||||
"type": "init",
|
||||
"name": name,
|
||||
@@ -162,8 +171,14 @@ class ToolsRoute(Route):
|
||||
|
||||
# 复制所有配置字段
|
||||
for key, value in server_data.items():
|
||||
if key not in ["name", "active", "tools"]: # 排除特殊字段
|
||||
server_config[key] = value
|
||||
if key not in ["name", "active", "tools", "errlogs"]: # 排除特殊字段
|
||||
if key == "mcpServers":
|
||||
key_0 = list(server_data["mcpServers"].keys())[
|
||||
0
|
||||
] # 不考虑为空的情况
|
||||
server_config = server_data["mcpServers"][key_0]
|
||||
else:
|
||||
server_config[key] = value
|
||||
only_update_active = False
|
||||
|
||||
# 如果只更新活动状态,保留原始配置
|
||||
@@ -194,7 +209,7 @@ class ToolsRoute(Route):
|
||||
)
|
||||
else:
|
||||
# 客户端不存在,初始化
|
||||
self.tool_mgr.mcp_service_queue.put_nowait(
|
||||
await self.tool_mgr.mcp_service_queue.put(
|
||||
{
|
||||
"type": "init",
|
||||
"name": name,
|
||||
@@ -250,3 +265,26 @@ class ToolsRoute(Route):
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(f"删除 MCP 服务器失败: {str(e)}").__dict__
|
||||
|
||||
async def get_mcp_markets(self):
|
||||
page = request.args.get("page", 1, type=int)
|
||||
page_size = request.args.get("page_size", 10, type=int)
|
||||
BASE_URL = "https://api.soulter.top/astrbot/mcpservers?page={}&page_size={}".format(
|
||||
page,
|
||||
page_size,
|
||||
)
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(f"{BASE_URL}") as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
return Response().ok(data["data"]).__dict__
|
||||
else:
|
||||
return (
|
||||
Response()
|
||||
.error(f"获取市场数据失败: HTTP {response.status}")
|
||||
.__dict__
|
||||
)
|
||||
except Exception as _:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error("获取市场数据失败").__dict__
|
||||
@@ -136,10 +136,11 @@ class UpdateRoute(Route):
|
||||
|
||||
data = await request.json
|
||||
package = data.get("package", "")
|
||||
mirror = data.get("mirror", None)
|
||||
if not package:
|
||||
return Response().error("缺少参数 package 或不合法。").__dict__
|
||||
try:
|
||||
pip_installer.install(package)
|
||||
pip_installer.install(package, mirror=mirror)
|
||||
return Response().ok(None, "安装成功。").__dict__
|
||||
except Exception as e:
|
||||
logger.error(f"/api/update_pip: {traceback.format_exc()}")
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# What's Changed
|
||||
|
||||
> 📢 在升级前,请完整阅读本次更新日志。
|
||||
> 此版本为针对 `v3.5.3` 的紧急修复版本
|
||||
> 修复以下 BUG:
|
||||
> 1. 智谱 GLM 在函数工具有空参数时报错的问题。
|
||||
|
||||
---
|
||||
|
||||
v3.5.3
|
||||
|
||||
## ✨ 新增的功能
|
||||
|
||||
1. Telegram、Webchat、QQ官方机器人平台(私聊)支持流式输出(实验性)。@Soulter @Raven95676 @anka-afk
|
||||
2. 支持针对不同消息平台开启/关闭插件 @zhx8702 @Raven95676 @Soulter
|
||||
3. 插件市场支持显示 Star 个数、插件管理支持插件帮助对话框 @kterna
|
||||
4. 飞书平台支持主动消息发送 @Soulter
|
||||
5. Telegram 平台适配显示指令列表,支持自动补全 @Raven95676
|
||||
6. 新增配置项允许配置当超出最多携带对话数量时,一次性丢弃多少条旧消息 @Rail1bc
|
||||
7. StarTool 新增获取插件数据目录接口 @Raven95676
|
||||
|
||||
## 🎈 功能性优化
|
||||
|
||||
1. 优化 /his 指令对函数调用的显示 @anka-afk
|
||||
2. QQ 官方机器人支持对同一条消息多次回复 @kuangfeng
|
||||
|
||||
## 🐛 修复的 Bug
|
||||
|
||||
1. ‼️ 修复使用 gemini 时,函数数工具调用会重复调用已经在过去会话中调用过的工具 @Soulter
|
||||
2. 修复使用 Gemini 模型时出现 <empty_content> 的问题 @anka-afk
|
||||
4. 修复使用 OneAPI + Gemini(openai) 传递空参数函数工具时可能报错的问题 @Soulter
|
||||
5. 修复 permission 过滤算子的 raise_error 参数失效的问题 @Soulter
|
||||
6. 修复函数调用时可能出现 `messages with role 'tool' must be a response to a preceeding message with 'tool_calls'` 报错的问题 @anka-afk
|
||||
7. 修复 dify 下删除对话的报错问题 @Soulter
|
||||
8. 修复人格预设对话多次插入上下文的问题 @Rail1bc
|
||||
9. 修复了 event.get_sender_id() 返回值与函数注释不一致的问题 @zsbai
|
||||
|
||||
|
||||
## 🧩 新增的插件
|
||||
|
||||
待补充
|
||||
@@ -0,0 +1,156 @@
|
||||
# What's Changed
|
||||
|
||||
> 📢 在升级前,请完整阅读本次更新日志。
|
||||
|
||||
## ✨ 新增的功能
|
||||
|
||||
1. 上线 MCP 市场(beta) @Soulter
|
||||
2. MCP 服务器支持通过 SSE 连接 @Soulter
|
||||
3. 支持自定义 PyPI 软件仓库地址 @Soulter
|
||||
4. 支持开关是否忽略自身发送的消息 @Soulter
|
||||
5. Docker 镜像自带 node 环境以适应 MCP 需要 @Soulter
|
||||
6. 添加对 Gemini 原生搜索功能的支持 @Raven95676
|
||||
7. 企业微信添加长文本分割功能以支持发送超过 2048 字符的消息 @Soulter @anka-afk
|
||||
8. TTS 支持同时输出原始文本 @YOO-koishi
|
||||
|
||||
## 🎈 功能性优化
|
||||
|
||||
1. shared_preferences加载失败时自动删除无效文件 @Raven95676
|
||||
2. 适配 MCP 配置文件带 mcpServers 的情况(Cursor) @Soulter
|
||||
3. 采用 google-genai SDK 重构 Gemini 适配器 @Raven95676 @Soulter
|
||||
4. 优化已安装的插件页,支持以列表展示 @Soulter
|
||||
5. 分段回复优化 @huirh @Raven95676
|
||||
6. 优化 MCP 服务器的日志回显 @Soulter
|
||||
7. 为不支持流式输出的平台提供 Fallback @Raven95676
|
||||
8. 替换为采用 Semver 语义化版本来比较版本号 @Soulter
|
||||
9. 文件发送时支持路径映射 @Jackxwb
|
||||
|
||||
## 🐛 修复的 Bug
|
||||
|
||||
1. 修复关闭/删除 MCP 服务器后 Tools 没有清除的问题 @Soulter
|
||||
2. 修复超出最大对话数时每次清除的消息比实际上期望的多 1 条 的问题 @Raila23
|
||||
3. 修复调用函数工具可能导致 400 Bad Request 的问题 @Raila23
|
||||
4. 修复飞书适配器无法发送 Base64 图片的问题 @KimigaiiWuyi @Soulter
|
||||
5. 修复上下文带图的情况下,对话数据库页无法查看对话详情的问题 @Soulter
|
||||
6. Telegram 适配器注册指令功能优化 @Raven95676
|
||||
7. 修复阿里云百炼 TTS 只能发送一次语音,第二次就会报错 @Soulter
|
||||
|
||||
## 🧩 新增的插件
|
||||
|
||||
> Automatically generated by program.
|
||||
|
||||
- [Plugin] 60秒国内新闻 by @bbpn-cn in #970
|
||||
- [Plugin] astrbot_plugin_memelite by @Zhalslar in #977
|
||||
- [Plugin] 赛博打胶 by @tenno1174 in #980
|
||||
- [Plugin] astrbot_plugin_PockAttack by @LouieKH359 in #981
|
||||
- [Plugin] astrbot_plugin_saris_economic by @chengcheng0325 in #984
|
||||
- [Plugin] astrbot_plugin_saris_db by @chengcheng0325 in #985
|
||||
- [Plugin] astrbot_plugin_today_in_history by @Zhalslar in #987
|
||||
- [Plugin] astrbot_plugin_history_day by @Zhalslar in #989
|
||||
- [Plugin] astrbot_plugin_nachoneko by @Rinyin in #991
|
||||
- [Plugin] jmcomicsget by @Ayachi2225 in #993
|
||||
- [Plugin] astrbot_plugin_idiom by @zhx8702 in #994
|
||||
- [Plugin] anime_gacha by @xco2 in #997
|
||||
- [Plugin] jmcomic_downloader by @QiChenSn in #1007
|
||||
- [Plugin] gewe_chatsummary by @NiceAir in #1013
|
||||
- [Plugin] 群CCB by @tenno1174 in #1016
|
||||
- [Plugin] 自动生成图表(思维导图、流程图等) by @kterna in #1018
|
||||
- [Plugin] astrbot_plugin_jm_sender by @EnderPPT in #1019
|
||||
- [Plugin] 插件名random image by @IGCrystal in #1021
|
||||
- [Plugin] astrbot_plugin_saris_fish by @chengcheng0325 in #1022
|
||||
- [Plugin] astrbot_plugin_membercontrast by @laopanmemz in #1027
|
||||
- [Plugin] jm_search by @Ryonnoski0 in #1028
|
||||
- [Plugin] astrbot_plugin_gomoku by @zhx8702 in #1029
|
||||
- [Plugin] astrbot_plugin_CounterStrikle by @Last-emo-boy in #1036
|
||||
- [Plugin] encrypt-and-decrypt by @Soffd in #1037
|
||||
- [Plugin] emoji合成 by @ttq7 in #1041
|
||||
- [Plugin] astrbot_plugin_file_reader by @xiewoc in #1043
|
||||
- [Plugin] vv_pic by @LonelySky7490 in #1048
|
||||
- [Plugin] bot代戳 by @791819 in #1049
|
||||
- [Plugin] astrbot_plugin_weather_wttr_in by @xiewoc in #1051
|
||||
- [Plugin] astrbot_plugin_kahunabot by @AraragiEro in #1053
|
||||
- [Plugin] astrbot_plugin_answerbook by @litsum in #1058
|
||||
- [Plugin] astrbot_plugin_ewords by @IGCrystal in #1059
|
||||
- [Plugin] minecraft投影管理器 by @kterna in #1064
|
||||
- [Plugin] minecraft投影管理器 by @kterna in #1063
|
||||
- [Plugin] astrbot_plugin_encipherer by @Soffd in #1066
|
||||
- [Plugin] 定时任务提醒插件 by @advent259141 in #1068
|
||||
- [Plugin] 定时任务提醒插件 by @advent259141 in #1067
|
||||
- [Plugin] bot_plugin_doro_today by @Futureppo in #1071
|
||||
- [Plugin] JmCli by @gaxiic in #1076
|
||||
- [Plugin] 用户自定义识别nickname by @MR-pofeng in #1078
|
||||
- [Plugin] astrbot_plugin_timtip by @IGCrystal in #1082
|
||||
- [Plugin] y @caomeiguodong in #1083
|
||||
- [Plugin] astrbot_plugin_search_pic by @lyjlyjlyjly in #1084
|
||||
- [Plugin] ot_plugin_quote_collocter by @litsum in #1089
|
||||
- [Plugin] astrbot_plugin_ending by @clfpwp in #1090
|
||||
- [Plugin] astrbot_plugin_GPT_SoVITS by @Zhalslar in #1091
|
||||
- [Plugin] astrbot_plugin_Merge_WeMSG by @zj591227045 in #1092
|
||||
- [Plugin] 随机维什戴尔游戏日语语音的AstrBot插件 by @zhewang448 in #1094
|
||||
- [Plugin] astrbot_plugin_QQProfile by @Zhalslar in #1095
|
||||
- [Plugin] astrbot_plugin_mcping by @Zhalslar in #1097
|
||||
- [Plugin] astrbot_plugin_lorebook_lite by @Raven95676 in #1098
|
||||
- [Plugin] astrbot_plugin_grok_filter by @Cheng-MaoMao in #1099
|
||||
- [Plugin] astrbot_plugin_quarksave by @lm379 in #1100
|
||||
- [Plugin] daily_limit by @left666 in #1102
|
||||
- [Plugin] 赛博塔罗牌 by @XziXmn in #1103
|
||||
- [Plugin] 把QQ里面不可保存的表情转化为可以保存的插件astrbot_plugins_ConvetPicture by @orchidsziyou in #1115
|
||||
- [Plugin] astrbot_plugin_get_weather_cmd by @whzcc in #1117
|
||||
- [Plugin] httpposter by @Wayzinx in #1118
|
||||
- [Plugin] astrbot_plugin_get_weather_msg by @whzcc in #1119
|
||||
- [Plugin] astrbot_plugin_cs2-box by @bvzrays in #1124
|
||||
- [Plugin] astrbot_plugin_liars_bar by @xunxiing in #1125
|
||||
- [Plugin] astrbot_plugin_cs2-box by @bvzrays in #1129
|
||||
- [Plugin] astrbot_plugin_no_dragon_lord by @anka-afk in #1130
|
||||
- [Plugin] astrbot_plugin_liars_bar by @xunxiing in #1134
|
||||
- [Plugin] astrbot_plugin_QQAdmin by @Zhalslar in #1137
|
||||
- [Plugin] astrbot_plugin_SessionFaker by @advent259141 in #1138
|
||||
- [Plugin] astrbot_plugin_browser by @Zhalslar in #1140
|
||||
- [Plugin] astrbot_plugin_aishit by @advent259141 in #1141
|
||||
- [Plugin] Arch Linux 软件包搜索插件 by @xmengnet in #1142
|
||||
- [Plugin] astrbot_plugin_composting_bucket by @Rail1bc in #1147
|
||||
- [Plugin] 任务管理task-management by @zengweis in #1149
|
||||
- [Plugin] astrbot_plugin_appreview by @qiqi55488 in #1151
|
||||
- [Plugin] doro互动故事 by @ttq7 in #1153
|
||||
- [Plugin] 斗牛牛 by @LaoZhuJackson in #1155
|
||||
- [Plugin] astrbot_plugin_reread by @Zhalslar in #1162
|
||||
- [Plugin] astrbot_plugin_media302-save by @Qoo-330ml in #1163
|
||||
- [Plugin] astrbot_plugin_ehentai_bot by @drdon1234 in #1168
|
||||
- [Plugin] 追番助手(AGE) by @xiamuceer-j in #1181
|
||||
- [Plugin] astrbot_plugin_zanwo by @Futureppo in #1183
|
||||
- [Plugin] astrbot_plugin_jrrp by @exusiaiwei in #1189
|
||||
- [Plugin] astrbot_plugin_group_chatsummary by @glidersxu in #1193
|
||||
- [Plugin] astrbot_plugin_showmejm by @drdon1234 in #1202
|
||||
- [Plugin] astrbot_plugin_gscore_adapter by @KimigaiiWuyi in #1206
|
||||
- [Plugin] astrbot_portainer_plugin by @RC-CHN in #1209
|
||||
- [Plugin] astrbot_plugin_goldprice by @waterfeet in #1210
|
||||
- [Plugin] astrbot_plugin_xyzw by @XuYingJie-cmd in #1213
|
||||
- [Plugin] astrbot_plugin_alist by @yukikazechan in #1217
|
||||
- [Plugin] astrbot_plugin_showme_xjj by @drdon1234 in #1219
|
||||
- [Plugin] astrbot_plugin_60s_news by @flyinsz in #1220
|
||||
- [Plugin] astrbot_plugin_mp by @EWEDLCM in #1229
|
||||
- [Plugin] 浅草寺抽签插件-PRO by @xiamuceer-j in #1230
|
||||
- [Plugin] astrbot_plugin_gallery by @Zhalslar in #1238
|
||||
- [Plugin] astrbot_plugin_openweaponscase by @luooka in #1250
|
||||
- [Plugin] 多功能插件 by @ttq7 in #1254
|
||||
- [Plugin] astrbot_plugin_douyin_bot by @drdon1234 in #1255
|
||||
- [Plugin] astrbot_plugin_password by @Zhalslar in #1262
|
||||
- [Plugin] FavorSystem by @wuyan1003 in #1264
|
||||
- [Plugin] astrbot_plugin_ExchangeRateQuery by @MoonShadow1976 in #1271
|
||||
- [Plugin] astrbot_plugin_pexels by @xiamuceer-j in #1278
|
||||
- [Plugin] astrbot_plugin_hello-bye by @tinkerbellqwq in #1279
|
||||
- [Plugin] astrbot_plugin_gotify by @BetaCatX in #1283
|
||||
- [Plugin] astrbot_plugin_ccb_plus by @Koikokokokoro in #1287
|
||||
- [Plugin] astrbot-gold-plugin by @RC-CHN in #1299
|
||||
- [Plugin] 幻影坦克 by @bigshabei in #1305
|
||||
- [Plugin] astrbot_plugin_repeat_after_me by @0d00-Ciallo-0721 in #1306
|
||||
- [Plugin] astrbot_plugin_video by @guowenye in #1307
|
||||
- [Plugin] astrbot_plugin_group_sum_ai by @Ayu-u in #1308
|
||||
- [Plugin] 五子棋 by @bigshabei in #1309
|
||||
- [Plugin] 舔狗日记 by @bigshabei in #1310
|
||||
- [Plugin] astrbot_plugin_status-pro by @tinkerbellqwq in #1312
|
||||
- [Plugin] 监听/转发 by @Cedar2352 in #1322
|
||||
- [Plugin] astrbot_plugin_fuck by @vmoranv in #1338
|
||||
- [Plugin] 食物推荐插件 by @Wayzinx in #1331
|
||||
- [Plugin] astrbot_plugin_llmgo by @advent259141 in #1332
|
||||
- [Plugin] astrbot_plugin_a2s by @ZvZPvz in #1337
|
||||
@@ -81,7 +81,7 @@ const viewReadme = () => {
|
||||
<div class="flex-grow-1">
|
||||
<div>{{ extension.author }} /</div>
|
||||
|
||||
<p class="text-h3 font-weight-black" :class="{ 'text-h4': $vuetify.display.xs }">
|
||||
<p class="text-h4 font-weight-black" :class="{ 'text-h4': $vuetify.display.xs }">
|
||||
{{ extension.name }}
|
||||
<v-tooltip location="top" v-if="extension?.has_update && !marketMode">
|
||||
<template v-slot:activator="{ props: tooltipProps }">
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<v-row v-else>
|
||||
<v-col v-for="(item, index) in items" :key="index" cols="12" md="6" lg="4" xl="3">
|
||||
<v-card class="item-card hover-elevation" :color="getItemEnabled(item) ? '' : 'grey-lighten-4'">
|
||||
<div class="item-status-indicator" :class="{'active': getItemEnabled(item)}"></div>
|
||||
<!-- <div class="item-status-indicator" :class="{'active': getItemEnabled(item)}"></div> -->
|
||||
<v-card-title class="d-flex justify-space-between align-center pb-1 pt-3">
|
||||
<span class="text-h4 text-truncate" :title="getItemTitle(item)">{{ getItemTitle(item) }}</span>
|
||||
<v-tooltip location="top">
|
||||
@@ -114,19 +114,6 @@ export default {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.item-status-indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.item-status-indicator.active {
|
||||
background-color: #4CAF50;
|
||||
}
|
||||
|
||||
.hover-elevation:hover {
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
|
||||
@@ -27,8 +27,8 @@ import axios from 'axios';
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field v-model="pipInstallPayload.package" label="*库名,如 llmtuner" variant="outlined"></v-text-field>
|
||||
<v-text-field v-model="pipInstallPayload.mirror" label="镜像站链接(可选)" variant="outlined"></v-text-field>
|
||||
<small>如果不填镜像站链接,默认使用阿里云镜像:https://mirrors.aliyun.com/pypi/simple/</small>
|
||||
<v-text-field v-model="pipInstallPayload.mirror" label="强制 PyPI 软件仓库链接(可选)" variant="outlined"></v-text-field>
|
||||
<small>强制 PyPI 软件仓库链接 > 配置项 `PyPI 软件仓库地址`</small>
|
||||
<div>
|
||||
<small>{{ status }}</small>
|
||||
</div>
|
||||
|
||||
@@ -839,10 +839,30 @@ export default {
|
||||
|
||||
// 格式化消息内容
|
||||
formatMessage(content) {
|
||||
if (!content) return '空消息';
|
||||
|
||||
// content 可能是数组
|
||||
// [{"type": "image_url", "image_url": {"url": url_or_base64}}, {"type": "text", "text": "text"}]
|
||||
|
||||
let final_content = content;
|
||||
if (Array.isArray(content)) {
|
||||
// 处理数组内容
|
||||
final_content = content.map(item => {
|
||||
if (item.type === 'image_url') {
|
||||
return `<img src="${item.image_url.url}" alt="Image" />`;
|
||||
} else if (item.type === 'text') {
|
||||
return item.text;
|
||||
}
|
||||
return '';
|
||||
}).join('\n');
|
||||
} else if (typeof content === 'object') {
|
||||
// 处理对象内容
|
||||
final_content = Object.values(content).join('');
|
||||
} else if (typeof content === 'string') {
|
||||
// 处理字符串内容
|
||||
final_content = content;
|
||||
} else if (!final_content) return '空消息';
|
||||
// 使用marked处理Markdown格式
|
||||
return marked(content);
|
||||
return marked(final_content);
|
||||
},
|
||||
|
||||
// 显示成功消息
|
||||
|
||||
@@ -50,6 +50,11 @@ const platformEnableData = reactive({
|
||||
});
|
||||
const loadingPlatformData = ref(false);
|
||||
|
||||
// 新增变量支持列表视图
|
||||
const isListView = ref(false);
|
||||
const pluginSearch = ref("");
|
||||
const loading_ = ref(false);
|
||||
|
||||
const plugin_handler_info_headers = [
|
||||
{ title: '行为类型', key: 'event_type_h' },
|
||||
{ title: '描述', key: 'desc', maxWidth: '250px' },
|
||||
@@ -57,11 +62,36 @@ const plugin_handler_info_headers = [
|
||||
{ title: '触发方式', key: 'cmd' },
|
||||
];
|
||||
|
||||
// 插件表格的表头定义
|
||||
const pluginHeaders = [
|
||||
{ title: '名称', key: 'name', width: '200px' },
|
||||
{ title: '描述', key: 'desc', maxWidth: '250px' },
|
||||
{ title: '版本', key: 'version', width: '100px' },
|
||||
{ title: '作者', key: 'author', width: '100px' },
|
||||
{ title: '状态', key: 'status', width: '80px' },
|
||||
{ title: '操作', key: 'actions', sortable: false, width: '220px' }
|
||||
];
|
||||
|
||||
// 过滤要显示的插件
|
||||
const filteredExtensions = computed(() => {
|
||||
if (showReserved.value) {
|
||||
return extension_data.data;
|
||||
if (!showReserved.value) {
|
||||
return extension_data?.data?.filter(ext => !ext.reserved) || [];
|
||||
}
|
||||
return extension_data?.data?.filter(ext => !ext.reserved);
|
||||
return extension_data.data || [];
|
||||
});
|
||||
|
||||
// 通过搜索过滤插件
|
||||
const filteredPlugins = computed(() => {
|
||||
if (!pluginSearch.value) {
|
||||
return filteredExtensions.value;
|
||||
}
|
||||
|
||||
const search = pluginSearch.value.toLowerCase();
|
||||
return filteredExtensions.value.filter(plugin => {
|
||||
return plugin.name?.toLowerCase().includes(search) ||
|
||||
plugin.desc?.toLowerCase().includes(search) ||
|
||||
plugin.author?.toLowerCase().includes(search);
|
||||
});
|
||||
});
|
||||
|
||||
// 方法
|
||||
@@ -90,12 +120,15 @@ const onLoadingDialogResult = (statusCode, result, timeToClose = 2000) => {
|
||||
};
|
||||
|
||||
const getExtensions = async () => {
|
||||
loading_.value = true;
|
||||
try {
|
||||
const res = await axios.get('/api/plugin/get');
|
||||
Object.assign(extension_data, res.data);
|
||||
checkUpdate();
|
||||
} catch (err) {
|
||||
toast(err, "error");
|
||||
} finally {
|
||||
loading_.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -118,7 +151,7 @@ const checkUpdate = () => {
|
||||
|
||||
if (matchedPlugin) {
|
||||
extension.online_version = matchedPlugin.version;
|
||||
extension.has_update = extension.version !== matchedPlugin.version &&
|
||||
extension.has_update = extension.version !== matchedPlugin.version &&
|
||||
matchedPlugin.version !== "未知";
|
||||
} else {
|
||||
extension.has_update = false;
|
||||
@@ -150,12 +183,12 @@ const updateExtension = async (extension_name) => {
|
||||
name: extension_name,
|
||||
proxy: localStorage.getItem('selectedGitHubProxy') || ""
|
||||
});
|
||||
|
||||
|
||||
if (res.data.status === "error") {
|
||||
onLoadingDialogResult(2, res.data.message, -1);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Object.assign(extension_data, res.data);
|
||||
onLoadingDialogResult(1, res.data.message);
|
||||
} catch (err) {
|
||||
@@ -198,7 +231,7 @@ const openExtensionConfig = async (extension_name) => {
|
||||
const res = await axios.get('/api/config/get?plugin_name=' + extension_name);
|
||||
extension_config.metadata = res.data.data.metadata;
|
||||
extension_config.config = res.data.data.config;
|
||||
|
||||
|
||||
} catch (err) {
|
||||
toast(err, "error");
|
||||
}
|
||||
@@ -255,11 +288,11 @@ const getPlatformEnableConfig = async () => {
|
||||
toast(res.data.message, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
platformEnableData.platforms = res.data.data.platforms;
|
||||
platformEnableData.plugins = res.data.data.plugins;
|
||||
platformEnableData.platform_enable = res.data.data.platform_enable;
|
||||
|
||||
|
||||
// 如果没有平台,给出提示但仍显示对话框
|
||||
if (platformEnableData.platforms.length === 0) {
|
||||
toast("未添加任何平台适配器,请先在平台管理中添加平台", "warning");
|
||||
@@ -269,7 +302,7 @@ const getPlatformEnableConfig = async () => {
|
||||
if (!platformEnableData.platform_enable[platform.name]) {
|
||||
platformEnableData.platform_enable[platform.name] = {};
|
||||
}
|
||||
|
||||
|
||||
// 确保每个插件在每个平台都有一个配置项
|
||||
platformEnableData.plugins.forEach(plugin => {
|
||||
if (platformEnableData.platform_enable[platform.name][plugin.name] === undefined) {
|
||||
@@ -278,7 +311,7 @@ const getPlatformEnableConfig = async () => {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
platformEnableDialog.value = true;
|
||||
} catch (err) {
|
||||
toast("获取平台插件配置失败: " + err, "error");
|
||||
@@ -294,12 +327,12 @@ const savePlatformEnableConfig = async () => {
|
||||
const res = await axios.post('/api/plugin/platform_enable/set', {
|
||||
platform_enable: platformEnableData.platform_enable
|
||||
});
|
||||
|
||||
|
||||
if (res.data.status === "error") {
|
||||
toast(res.data.message, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
toast(res.data.message, "success");
|
||||
platformEnableDialog.value = false;
|
||||
} catch (err) {
|
||||
@@ -315,7 +348,7 @@ const selectAllPluginsForPlatform = (platformName, isSelected, onlyReserved = nu
|
||||
if (!platformEnableData.platform_enable[platformName]) {
|
||||
platformEnableData.platform_enable[platformName] = {};
|
||||
}
|
||||
|
||||
|
||||
// 为所有插件设置相同的状态
|
||||
platformEnableData.plugins.forEach(plugin => {
|
||||
// 如果onlyReserved为null,处理所有插件
|
||||
@@ -333,7 +366,7 @@ const toggleAllPluginsForPlatform = (platformName) => {
|
||||
if (!platformEnableData.platform_enable[platformName]) {
|
||||
platformEnableData.platform_enable[platformName] = {};
|
||||
}
|
||||
|
||||
|
||||
// 对每个插件进行反选操作
|
||||
platformEnableData.plugins.forEach(plugin => {
|
||||
const currentState = platformEnableData.platform_enable[platformName][plugin.name];
|
||||
@@ -344,7 +377,7 @@ const toggleAllPluginsForPlatform = (platformName) => {
|
||||
// 生命周期
|
||||
onMounted(async () => {
|
||||
await getExtensions();
|
||||
|
||||
|
||||
try {
|
||||
const data = await commonStore.getPluginCollections();
|
||||
pluginMarketData.value = data;
|
||||
@@ -358,147 +391,308 @@ onMounted(async () => {
|
||||
<template>
|
||||
<v-row>
|
||||
<v-col cols="12" md="12">
|
||||
<div style="background-color: white; width: 100%; padding: 16px; border-radius: 10px;">
|
||||
<div style="display: flex; align-items: center;">
|
||||
<h3>🧩 已安装的插件</h3>
|
||||
<v-btn class="text-none ml-2" size="small" variant="flat" border @click="toggleShowReserved">
|
||||
{{ showReserved ? '隐藏系统保留插件' : '显示系统保留插件' }}
|
||||
</v-btn>
|
||||
<v-btn class="text-none ml-2" size="small" variant="flat" color="primary" border @click="getPlatformEnableConfig">
|
||||
平台命令配置
|
||||
</v-btn>
|
||||
<v-dialog max-width="500px" v-if="extension_data.message">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn v-bind="props" icon size="small" color="error" style="margin-left: auto;" variant="plain">
|
||||
<v-icon>mdi-alert-circle</v-icon>
|
||||
<v-card variant="flat" class="rounded-xl">
|
||||
<v-card-item>
|
||||
<template v-slot:prepend>
|
||||
<div class="plugin-page-icon d-flex justify-center align-center rounded-lg mr-4">
|
||||
<v-icon size="36" color="primary">mdi-puzzle</v-icon>
|
||||
</div>
|
||||
</template>
|
||||
<v-card-title class="text-h4 font-weight-bold">
|
||||
已安装的插件
|
||||
</v-card-title>
|
||||
<v-card-subtitle class="text-subtitle-1 mt-1 text-medium-emphasis">
|
||||
管理已经安装的所有插件
|
||||
</v-card-subtitle>
|
||||
</v-card-item>
|
||||
|
||||
<v-card-text class="pt-2">
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12" sm="6" md="6" class="d-flex align-center">
|
||||
<v-btn-group variant="outlined" density="comfortable" color="primary">
|
||||
<v-btn @click="isListView = false" :color="!isListView ? 'primary' : undefined"
|
||||
:variant="!isListView ? 'flat' : 'outlined'">
|
||||
<v-icon>mdi-view-grid</v-icon>
|
||||
</v-btn>
|
||||
<v-btn @click="isListView = true" :color="isListView ? 'primary' : undefined"
|
||||
:variant="isListView ? 'flat' : 'outlined'">
|
||||
<v-icon>mdi-view-list</v-icon>
|
||||
</v-btn>
|
||||
</v-btn-group>
|
||||
|
||||
<v-btn class="ml-2" @click="toggleShowReserved" prepend-icon="mdi-eye-settings-outline"
|
||||
:color="showReserved ? 'primary' : undefined" :variant="showReserved ? 'flat' : 'outlined'">
|
||||
{{ showReserved ? '隐藏系统插件' : '显示系统插件' }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<template v-slot:default="{ isActive }">
|
||||
<v-card>
|
||||
<v-card-title class="headline">错误信息</v-card-title>
|
||||
<v-card-text>
|
||||
{{ extension_data.message }}<br>
|
||||
<small>详情请检查控制台</small>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="primary" text @click="isActive.value = false">关闭</v-btn>
|
||||
</v-card-actions>
|
||||
|
||||
<v-btn class="ml-2" prepend-icon="mdi-tune-vertical" color="primary" variant="outlined"
|
||||
@click="getPlatformEnableConfig">
|
||||
平台命令配置
|
||||
</v-btn>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="auto" md="6" class="ml-auto">
|
||||
<div class="search-container rounded-lg">
|
||||
|
||||
<v-text-field v-model="pluginSearch" density="compact" label="Search" prepend-inner-icon="mdi-magnify"
|
||||
variant="solo-filled" flat hide-details single-line></v-text-field>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<v-dialog max-width="500px" v-if="extension_data.message">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn v-bind="props" icon size="small" color="error" class="ml-2" variant="tonal">
|
||||
<v-icon>mdi-alert-circle</v-icon>
|
||||
<v-badge dot color="error" floating></v-badge>
|
||||
</v-btn>
|
||||
</template>
|
||||
<template v-slot:default="{ isActive }">
|
||||
<v-card class="rounded-lg">
|
||||
<v-card-title class="headline d-flex align-center">
|
||||
<v-icon color="error" class="mr-2">mdi-alert-circle</v-icon>
|
||||
错误信息
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="text-body-1">{{ extension_data.message }}</p>
|
||||
<p class="text-caption mt-2">详情请检查控制台</p>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="primary" @click="isActive.value = false">关闭</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
</v-dialog>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-fade-transition hide-on-leave>
|
||||
<!-- 表格视图 -->
|
||||
<div v-if="isListView">
|
||||
<v-card class="rounded-lg overflow-hidden elevation-1">
|
||||
<v-data-table :headers="pluginHeaders" :items="filteredPlugins" :loading="loading_" item-key="name"
|
||||
hover>
|
||||
<template v-slot:loader>
|
||||
<v-row class="py-8 d-flex align-center justify-center">
|
||||
<v-progress-circular indeterminate color="primary"></v-progress-circular>
|
||||
<span class="ml-2">加载中...</span>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.name="{ item }">
|
||||
<div class="d-flex align-center py-2">
|
||||
<div>
|
||||
<div class="text-subtitle-1 font-weight-medium">{{ item.name }}</div>
|
||||
<div v-if="item.reserved" class="d-flex align-center mt-1">
|
||||
<v-chip color="primary" size="x-small" class="font-weight-medium">系统</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.desc="{ item }">
|
||||
<div class="text-body-2 text-medium-emphasis">{{ item.desc }}</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.version="{ item }">
|
||||
<div class="d-flex align-center">
|
||||
<span class="text-body-2">{{ item.version }}</span>
|
||||
<v-icon v-if="item.has_update" color="warning" size="small" class="ml-1">mdi-alert</v-icon>
|
||||
<v-tooltip v-if="item.has_update" activator="parent">
|
||||
<span>有新版本: {{ item.online_version }}</span>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.author="{ item }">
|
||||
<div class="text-body-2">{{ item.author }}</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.status="{ item }">
|
||||
<v-chip :color="item.activated ? 'success' : 'error'" size="small" class="font-weight-medium"
|
||||
:variant="item.activated ? 'flat' : 'outlined'">
|
||||
{{ item.activated ? '启用' : '禁用' }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.actions="{ item }">
|
||||
<div class="d-flex align-center">
|
||||
<v-btn-group density="comfortable" variant="text" color="primary">
|
||||
<v-btn v-if="!item.activated" icon size="small" color="success" @click="pluginOn(item)">
|
||||
<v-icon>mdi-play</v-icon>
|
||||
<v-tooltip activator="parent" location="top">点击启用</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn v-else icon size="small" color="error" @click="pluginOff(item)">
|
||||
<v-icon>mdi-pause</v-icon>
|
||||
<v-tooltip activator="parent" location="top">点击禁用</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-btn icon size="small" color="info" @click="reloadPlugin(item.name)">
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
<v-tooltip activator="parent" location="top">重载</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-btn icon size="small" @click="openExtensionConfig(item.name)">
|
||||
<v-icon>mdi-cog</v-icon>
|
||||
<v-tooltip activator="parent" location="top">配置</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-btn icon size="small" @click="showPluginInfo(item)">
|
||||
<v-icon>mdi-information</v-icon>
|
||||
<v-tooltip activator="parent" location="top">行为</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-btn v-if="item.repo" icon size="small" @click="viewReadme(item)">
|
||||
<v-icon>mdi-book-open-page-variant</v-icon>
|
||||
<v-tooltip activator="parent" location="top">文档</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-btn icon size="small" color="warning"
|
||||
@click="updateExtension(item.name)" :v-show="item.has_update">
|
||||
<v-icon>mdi-update</v-icon>
|
||||
<v-tooltip activator="parent" location="top">更新</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-btn icon size="small" color="error"
|
||||
@click="uninstallExtension(item.name)" :disabled="item.reserved">
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
<v-tooltip activator="parent" location="top">卸载</v-tooltip>
|
||||
</v-btn>
|
||||
</v-btn-group>
|
||||
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:no-data>
|
||||
<div class="text-center pa-8">
|
||||
<v-icon size="64" color="info" class="mb-4">mdi-puzzle-outline</v-icon>
|
||||
<div class="text-h5 mb-2">暂无插件</div>
|
||||
<div class="text-body-1 mb-4">尝试安装插件或者显示系统插件</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
</template>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="10" md="6" lg="6" v-for="extension in filteredExtensions" :key="extension.name">
|
||||
<ExtensionCard :extension="extension"
|
||||
@configure="openExtensionConfig(extension.name)"
|
||||
@uninstall="uninstallExtension(extension.name)"
|
||||
@update="updateExtension(extension.name)"
|
||||
@reload="reloadPlugin(extension.name)"
|
||||
@toggle-activation="extension.activated ? pluginOff(extension) : pluginOn(extension)"
|
||||
@view-handlers="showPluginInfo(extension)"
|
||||
@view-readme="viewReadme(extension)">
|
||||
</ExtensionCard>
|
||||
</div>
|
||||
|
||||
<!-- 卡片视图 -->
|
||||
<div v-else>
|
||||
<v-row v-if="filteredPlugins.length === 0" class="text-center">
|
||||
<v-col cols="12" class="pa-8">
|
||||
<v-icon size="64" color="info" class="mb-4">mdi-puzzle-outline</v-icon>
|
||||
<div class="text-h5 mb-2">暂无插件</div>
|
||||
<div class="text-body-1 mb-4">尝试安装插件或者显示系统插件</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12" md="6" lg="4" v-for="extension in filteredPlugins" :key="extension.name" class="pb-4">
|
||||
<ExtensionCard :extension="extension" class="h-120 rounded-lg"
|
||||
@configure="openExtensionConfig(extension.name)" @uninstall="uninstallExtension(extension.name)"
|
||||
@update="updateExtension(extension.name)" @reload="reloadPlugin(extension.name)"
|
||||
@toggle-activation="extension.activated ? pluginOff(extension) : pluginOn(extension)"
|
||||
@view-handlers="showPluginInfo(extension)" @view-readme="viewReadme(extension)">
|
||||
</ExtensionCard>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</v-fade-transition>
|
||||
|
||||
<v-row v-if="loading_">
|
||||
<v-col cols="12" class="d-flex justify-center">
|
||||
<v-progress-circular indeterminate color="primary" size="48"></v-progress-circular>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 插件平台配置对话框 -->
|
||||
<v-dialog v-model="platformEnableDialog" max-width="800" persistent>
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<span class="headline">平台命令可用性配置</span>
|
||||
</v-card-title>
|
||||
<v-card-subtitle>
|
||||
设置每个插件在不同平台上的可用性,勾选表示启用
|
||||
</v-card-subtitle>
|
||||
<v-card-text>
|
||||
<v-overlay
|
||||
:model-value="loadingPlatformData"
|
||||
class="align-center justify-center"
|
||||
persistent
|
||||
>
|
||||
<v-progress-circular
|
||||
color="primary"
|
||||
indeterminate
|
||||
size="64"
|
||||
></v-progress-circular>
|
||||
<v-dialog v-model="platformEnableDialog" max-width="900" persistent>
|
||||
<v-card class="rounded-lg">
|
||||
<v-toolbar color="primary" density="comfortable" flat>
|
||||
<v-toolbar-title class="text-white">平台命令可用性配置</v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn icon @click="platformEnableDialog = false" variant="text" color="white">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
|
||||
<v-card-text class="pt-4">
|
||||
<p class="text-body-2 mb-4">设置每个插件在不同平台上的可用性,勾选表示启用</p>
|
||||
|
||||
<v-overlay :model-value="loadingPlatformData" class="align-center justify-center" persistent>
|
||||
<v-progress-circular color="primary" indeterminate size="64"></v-progress-circular>
|
||||
</v-overlay>
|
||||
|
||||
<div v-if="platformEnableData.platforms.length === 0" class="text-center pa-4">
|
||||
|
||||
<div v-if="platformEnableData.platforms.length === 0" class="text-center pa-8">
|
||||
<v-icon icon="mdi-alert" color="warning" size="64" class="mb-4"></v-icon>
|
||||
<div class="text-h6 mb-2">未找到平台适配器</div>
|
||||
<div class="text-h5 mb-2">未找到平台适配器</div>
|
||||
<div class="text-body-1 mb-4">请先在 <strong>平台管理</strong> 中添加并配置平台适配器,然后再设置插件的平台可用性</div>
|
||||
<v-btn color="primary" to="/platforms">前往平台管理</v-btn>
|
||||
<v-btn color="primary" to="/platforms" variant="elevated">前往平台管理</v-btn>
|
||||
</div>
|
||||
|
||||
<v-table v-else>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>插件名称</th>
|
||||
<th v-for="platform in platformEnableData.platforms" :key="platform.name">
|
||||
<div class="d-flex align-center">
|
||||
{{ platform.display_name }}
|
||||
<v-menu>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
icon
|
||||
density="compact"
|
||||
variant="text"
|
||||
size="small"
|
||||
v-bind="props"
|
||||
class="ms-1"
|
||||
>
|
||||
<v-icon>mdi-dots-vertical</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list>
|
||||
<v-list-item @click="selectAllPluginsForPlatform(platform.name, true)">
|
||||
<v-list-item-title>全选</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="selectAllPluginsForPlatform(platform.name, true, false)">
|
||||
<v-list-item-title>全选普通插件</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="selectAllPluginsForPlatform(platform.name, true, true)">
|
||||
<v-list-item-title>全选系统插件</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="selectAllPluginsForPlatform(platform.name, false)">
|
||||
<v-list-item-title>全不选</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="toggleAllPluginsForPlatform(platform.name)">
|
||||
<v-list-item-title>反选</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="plugin in platformEnableData.plugins" :key="plugin.name">
|
||||
<td>
|
||||
<div class="d-flex align-center">
|
||||
{{ plugin.name }}
|
||||
<v-chip v-if="plugin.reserved" color="primary" size="x-small" class="ml-2">系统</v-chip>
|
||||
</div>
|
||||
<div class="text-caption text-grey">{{ plugin.desc }}</div>
|
||||
</td>
|
||||
<td v-for="platform in platformEnableData.platforms" :key="platform.name">
|
||||
<v-checkbox
|
||||
v-model="platformEnableData.platform_enable[platform.name][plugin.name]"
|
||||
hide-details
|
||||
density="compact"
|
||||
></v-checkbox>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
|
||||
<v-sheet v-else class="rounded-lg overflow-hidden">
|
||||
<v-table hover class="elevation-1">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-left">插件名称</th>
|
||||
<th v-for="platform in platformEnableData.platforms" :key="platform.name">
|
||||
<div class="d-flex align-center">
|
||||
{{ platform.display_name }}
|
||||
<v-menu>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn icon density="compact" variant="text" size="small" v-bind="props" class="ms-1">
|
||||
<v-icon>mdi-dots-vertical</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list>
|
||||
<v-list-item @click="selectAllPluginsForPlatform(platform.name, true)">
|
||||
<v-list-item-title>全选</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="selectAllPluginsForPlatform(platform.name, true, false)">
|
||||
<v-list-item-title>全选普通插件</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="selectAllPluginsForPlatform(platform.name, true, true)">
|
||||
<v-list-item-title>全选系统插件</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="selectAllPluginsForPlatform(platform.name, false)">
|
||||
<v-list-item-title>全不选</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="toggleAllPluginsForPlatform(platform.name)">
|
||||
<v-list-item-title>反选</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="plugin in platformEnableData.plugins" :key="plugin.name">
|
||||
<td>
|
||||
<div class="d-flex align-center">
|
||||
{{ plugin.name }}
|
||||
<v-chip v-if="plugin.reserved" color="primary" size="x-small" class="ml-2">系统</v-chip>
|
||||
</div>
|
||||
<div class="text-caption text-grey">{{ plugin.desc }}</div>
|
||||
</td>
|
||||
<td v-for="platform in platformEnableData.platforms" :key="platform.name">
|
||||
<v-checkbox v-model="platformEnableData.platform_enable[platform.name][plugin.name]" hide-details
|
||||
density="compact"></v-checkbox>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-sheet>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="grey" text @click="platformEnableDialog = false">关闭</v-btn>
|
||||
<v-btn v-if="platformEnableData.platforms.length > 0" color="primary" @click="savePlatformEnableConfig">保存</v-btn>
|
||||
<v-btn v-if="platformEnableData.platforms.length > 0" color="primary"
|
||||
@click="savePlatformEnableConfig">保存</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
@@ -525,15 +719,16 @@ onMounted(async () => {
|
||||
<v-card>
|
||||
<v-card-title class="text-h5">{{ loadingDialog.title }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-progress-linear v-if="loadingDialog.statusCode === 0" indeterminate color="primary" class="mb-4"></v-progress-linear>
|
||||
|
||||
<v-progress-linear v-if="loadingDialog.statusCode === 0" indeterminate color="primary"
|
||||
class="mb-4"></v-progress-linear>
|
||||
|
||||
<div v-if="loadingDialog.statusCode !== 0" class="py-8 text-center">
|
||||
<v-icon class="mb-6" :color="loadingDialog.statusCode === 1 ? 'success' : 'error'"
|
||||
:icon="loadingDialog.statusCode === 1 ? 'mdi-check-circle-outline' : 'mdi-alert-circle-outline'"
|
||||
<v-icon class="mb-6" :color="loadingDialog.statusCode === 1 ? 'success' : 'error'"
|
||||
:icon="loadingDialog.statusCode === 1 ? 'mdi-check-circle-outline' : 'mdi-alert-circle-outline'"
|
||||
size="128"></v-icon>
|
||||
<div class="text-h4 font-weight-bold">{{ loadingDialog.result }}</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div style="margin-top: 32px;">
|
||||
<h3>日志</h3>
|
||||
<ConsoleDisplayer historyNum="10" style="height: 200px; margin-top: 16px;"></ConsoleDisplayer>
|
||||
@@ -551,7 +746,7 @@ onMounted(async () => {
|
||||
<v-card>
|
||||
<v-card-title class="text-h5">{{ selectedPlugin.name }} 插件行为</v-card-title>
|
||||
<v-card-text>
|
||||
<v-data-table style="font-size: 17px;" :headers="plugin_handler_info_headers" :items="selectedPlugin.handlers"
|
||||
<v-data-table style="font-size: 17px;" :headers="plugin_handler_info_headers" :items="selectedPlugin.handlers"
|
||||
item-key="name">
|
||||
<template v-slot:header.id="{ column }">
|
||||
<p style="font-weight: bold;">{{ column.title }}</p>
|
||||
@@ -584,12 +779,9 @@ onMounted(async () => {
|
||||
</v-snackbar>
|
||||
|
||||
<WaitingForRestart ref="wfr"></WaitingForRestart>
|
||||
|
||||
<ReadmeDialog
|
||||
v-model:show="readmeDialog.show"
|
||||
:plugin-name="readmeDialog.pluginName"
|
||||
:repo-url="readmeDialog.repoUrl"
|
||||
/>
|
||||
|
||||
<ReadmeDialog v-model:show="readmeDialog.show" :plugin-name="readmeDialog.pluginName"
|
||||
:repo-url="readmeDialog.repoUrl" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
+571
-289
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "AstrBot"
|
||||
version = "3.4.39"
|
||||
version = "3.5.4"
|
||||
description = "易上手的多平台 LLM 聊天机器人及开发框架"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -19,6 +19,7 @@ dependencies = [
|
||||
"defusedxml>=0.7.1",
|
||||
"dingtalk-stream>=0.22.1",
|
||||
"docstring-parser>=0.16",
|
||||
"google-genai>=1.10.0",
|
||||
"googlesearch-python>=1.3.0",
|
||||
"lark-oapi>=1.4.12",
|
||||
"lxml-html-clean>=0.4.1",
|
||||
|
||||
+2
-1
@@ -29,4 +29,5 @@ defusedxml
|
||||
mcp
|
||||
certifi
|
||||
pip
|
||||
telegramify-markdown
|
||||
telegramify-markdown
|
||||
google-genai
|
||||
@@ -209,6 +209,7 @@ dependencies = [
|
||||
{ name = "defusedxml" },
|
||||
{ name = "dingtalk-stream" },
|
||||
{ name = "docstring-parser" },
|
||||
{ name = "google-genai" },
|
||||
{ name = "googlesearch-python" },
|
||||
{ name = "lark-oapi" },
|
||||
{ name = "lxml-html-clean" },
|
||||
@@ -245,6 +246,7 @@ requires-dist = [
|
||||
{ name = "defusedxml", specifier = ">=0.7.1" },
|
||||
{ name = "dingtalk-stream", specifier = ">=0.22.1" },
|
||||
{ name = "docstring-parser", specifier = ">=0.16" },
|
||||
{ name = "google-genai", specifier = ">=1.10.0" },
|
||||
{ name = "googlesearch-python", specifier = ">=1.3.0" },
|
||||
{ name = "lark-oapi", specifier = ">=1.4.12" },
|
||||
{ name = "lxml-html-clean", specifier = ">=0.4.1" },
|
||||
@@ -305,6 +307,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cachetools"
|
||||
version = "5.5.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6c/81/3747dad6b14fa2cf53fcf10548cf5aea6913e96fab41a3c198676f8948a5/cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4", size = 28380 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/72/76/20fa66124dbe6be5cafeb312ece67de6b61dd91a0247d1ea13db4ebb33c2/cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a", size = 10080 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.1.31"
|
||||
@@ -676,6 +687,38 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/c8/a5be5b7550c10858fcf9b0ea054baccab474da77d37f1e828ce043a3a5d4/frozenlist-1.5.0-py3-none-any.whl", hash = "sha256:d994863bba198a4a518b467bb971c56e1db3f180a25c6cf7bb1949c267f748c3", size = 11901 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-auth"
|
||||
version = "2.39.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cachetools" },
|
||||
{ name = "pyasn1-modules" },
|
||||
{ name = "rsa" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cb/8e/8f45c9a32f73e786e954b8f9761c61422955d23c45d1e8c347f9b4b59e8e/google_auth-2.39.0.tar.gz", hash = "sha256:73222d43cdc35a3aeacbfdcaf73142a97839f10de930550d89ebfe1d0a00cde7", size = 274834 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/12/ad37a1ef86006d0a0117fc06a4a00bd461c775356b534b425f00dde208ea/google_auth-2.39.0-py2.py3-none-any.whl", hash = "sha256:0150b6711e97fb9f52fe599f55648950cc4540015565d8fbb31be2ad6e1548a2", size = 212319 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-genai"
|
||||
version = "1.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "google-auth" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "requests" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0e/7a/224e2f70c835202042969685ee3da00a6475508d1b64f0f1e90144f96beb/google_genai-1.10.0.tar.gz", hash = "sha256:f59423e0f155dc66b7792c8a0e6724c75c72dc699d1eb7907d4d0006d4f6186f", size = 156355 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/a0/56839a2e202d79c773edd1c1db124da8eb2a7b657267a888080b678d0369/google_genai-1.10.0-py3-none-any.whl", hash = "sha256:41b105a2fcf8a027fc45cc16694cd559b8cd1272eab7345ad58cfa2c353bf34f", size = 154705 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "googlesearch-python"
|
||||
version = "1.3.0"
|
||||
@@ -1402,6 +1445,27 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/50/1b/6921afe68c74868b4c9fa424dad3be35b095e16687989ebbb50ce4fceb7c/psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553", size = 244885 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1-modules"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyasn1" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "2.22"
|
||||
@@ -1697,6 +1761,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "4.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyasn1" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/65/7d973b89c4d2351d7fb232c2e452547ddfa243e93131e7cfa766da627b52/rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21", size = 29711 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/97/fa78e3d2f65c02c8e1268b9aba606569fe97f6c8f7c2d74394553347c145/rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7", size = 34315 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "silk-python"
|
||||
version = "0.2.6"
|
||||
|
||||
Reference in New Issue
Block a user