Compare commits

...

48 Commits

Author SHA1 Message Date
Soulter 386a312e96 fix: 修复一些typo 2025-02-08 22:52:24 +08:00
Soulter 2759d347e6 update: add socksio, echatpy, cryptography to dockerfile 2025-02-08 22:10:17 +08:00
Soulter b6ec327b49 perf:完善主动会话 2025-02-08 22:04:36 +08:00
Soulter ee02d622ba v3.4.23 2025-02-08 21:42:37 +08:00
Soulter 5c4a6083f5 Merge pull request #433 from Cvandia/master
支持 fishaudio tts 文字转语音
2025-02-08 21:20:03 +08:00
Soulter 49e63a3d3d perf: 优化报错显示 2025-02-08 21:19:25 +08:00
Soulter 6bae9dc9ed 👌 perf: 当响应头不为audio/wav时抛出报错 2025-02-08 21:16:09 +08:00
Cvandia 5fa1979a46 🐛 fix: 移除调试过程的不必要的文件写入操作 2025-02-08 20:49:37 +08:00
Cvandia b40d4fa315 Merge remote-tracking branch 'upstream/master' 2025-02-08 20:45:49 +08:00
Soulter 4d2ff7cd5b fix: 修复 qq 回复别人的时候也会触发机器人, Onebot at 使用 string #330 2025-02-08 20:35:10 +08:00
Cvandia d8ec0e64d0 Merge remote-tracking branch 'upstream/master' 2025-02-08 19:40:56 +08:00
Cvandia 82e979cc07 feat: 添加 FishAudio TTS API 支持,更新配置和依赖项 2025-02-08 19:37:43 +08:00
Soulter 8c132a51f5 fix: 修复子指令设置permission之后会导致其一定会被执行 #427 2025-02-08 18:51:30 +08:00
Soulter 40bd372cc1 fix: 重启gewe的时候机器人会疯狂发消息 #421 2025-02-08 18:02:42 +08:00
Soulter 212e114270 perf: 优化了一些提示 2025-02-08 15:55:46 +08:00
Soulter b0e9de6951 perf: 增加DIFY超时时间 #422 2025-02-08 12:58:54 +08:00
Soulter 3489522bbb feat: 支持展示插件是否有更新 2025-02-08 12:22:36 +08:00
Soulter 96237abc03 fix: 当群聊自动回复时,不会带上人格的Prompt #419 2025-02-08 10:17:43 +08:00
Soulter a8b2b09e0f v3.4.22 2025-02-08 00:01:47 +08:00
Soulter 6858b8c555 perf: 当图片数据为空时不加入上下文 #379 2025-02-07 23:57:25 +08:00
Soulter 0e493b1a0e Merge pull request #411 from zhaolj/fix-bug-#298
fix bug #298
2025-02-07 23:39:03 +08:00
Soulter 37d478f970 fix: 移除了分段回复llm提示词辅助 2025-02-07 23:21:05 +08:00
zhaolj 7d0d42a49f fix bug #298 2025-02-07 22:57:49 +08:00
Soulter 0eb1684ef1 fix: 修复 openai_source 尝试弹出最早的记录失败的问题 2025-02-07 22:38:04 +08:00
Soulter 9b0b723143 fix: 联网搜索失败,函数调用无返回值 #342 2025-02-07 22:07:56 +08:00
Soulter 532bc6e1e6 fix: Google Search 报 429 错误时,放宽 Exception 至其他搜索引擎 #405 2025-02-07 21:32:06 +08:00
Soulter fe3ed4c454 fix: 自部署文转图不生效 #352 2025-02-07 20:24:11 +08:00
Soulter b5ec89e586 fix: 插件错误信息点击关闭没反应 #394 2025-02-07 20:05:45 +08:00
Soulter 895e7397c2 remove: 移除了 put_history_to_prompt。当主动回复时,将群聊记录将自动放入prompt,当未主动回复但是开启群聊增强时,群聊记录将放入system prompt 2025-02-07 20:00:30 +08:00
Soulter 59b767957a fix: 400 Bad Request: The browser (or proxy) sent a request that this server could not understand. #396 2025-02-07 18:26:31 +08:00
Soulter 17d4bf8f22 perf: 管理面板优化新增列表项的提示 2025-02-06 20:19:53 +08:00
Soulter 836be3b097 update: changelogs 2025-02-06 18:51:47 +08:00
Soulter 310415bea9 feat: 聊天增强图像转述支持自定义 Provider id 2025-02-06 18:49:16 +08:00
Soulter aafc1276a9 v3.4.21 2025-02-06 18:34:43 +08:00
Soulter 2993e794cc perf: hint 2025-02-06 17:45:15 +08:00
Soulter 58cb9cfb2d chore: clean code 2025-02-06 17:43:04 +08:00
Soulter fbdf0901d5 fix: 修复reminder时区问题 2025-02-06 17:41:34 +08:00
Soulter af8c81b621 feat: 支持重载插件 2025-02-06 17:27:53 +08:00
Soulter 06b5275e48 perf: 增加报错显示 2025-02-06 16:43:40 +08:00
Soulter ad95572d5f perf: 更好的 list 可视化 2025-02-06 15:59:45 +08:00
Soulter aebc7850f4 fix: openrouter 报错 no endpoints found that support tool use #371 2025-02-06 15:25:15 +08:00
Soulter 1b7efbc607 支持列表展示插件市场 2025-02-06 15:18:11 +08:00
Soulter 3800e96d14 fix: 修复metadata不生效的问题
feat: 支持查看插件行为
2025-02-06 15:10:24 +08:00
Soulter 461f1bb07c feat: 支持插件handler优先级 2025-02-06 12:35:43 +08:00
Soulter 7d4c07e4f6 feat: 支持设置 timeout 2025-02-06 12:31:39 +08:00
Soulter 31b788f463 fix: 修复不支持图片的模型请求异常 2025-02-06 01:50:53 +08:00
Soulter 96ab761f73 fix: 修复reminder无法删除的问题 2025-02-05 22:45:02 +08:00
Soulter 2b3f05c039 update: 优化部分注释 2025-02-05 19:58:48 +08:00
44 changed files with 1144 additions and 355 deletions
+3 -1
View File
@@ -12,7 +12,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN python -m pip install -r requirements.txt
RUN python -m pip install -r requirements.txt --no-cache-dir
RUN python -m pip install socksio wechatpy cryptography --no-cache-dir
EXPOSE 6185
EXPOSE 6186
+2 -1
View File
@@ -11,7 +11,8 @@ from astrbot.core.config import AstrBotConfig
os.makedirs("data", exist_ok=True)
astrbot_config = AstrBotConfig()
html_renderer = HtmlRenderer()
t2i_base_url = astrbot_config.get('t2i_endpoint', 'https://t2i.soulter.top/text2img')
html_renderer = HtmlRenderer(t2i_base_url)
logger = LogManager.GetLogger(log_name='astrbot')
if os.environ.get('TESTING', ""):
+50 -20
View File
@@ -2,7 +2,7 @@
如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。
"""
VERSION = "3.4.20"
VERSION = "3.4.23"
DB_PATH = "data/data_v3.db"
# 默认配置
@@ -29,7 +29,6 @@ DEFAULT_CONFIG = {
"enable": False,
"only_llm_result": True,
"interval": "1.5,3.5",
"seg_prompt": "",
"regex": ".*?[。?!~…]+|.+$"
},
"no_permission_reply": True,
@@ -57,21 +56,24 @@ DEFAULT_CONFIG = {
"group_icl_enable": False,
"group_message_max_cnt": 300,
"image_caption": False,
"image_caption_provider_id": "",
"image_caption_prompt": "Please describe the image using Chinese.",
"active_reply": {
"enable": False,
"method": "possibility_reply",
"possibility_reply": 0.1,
"prompt": "",
},
"put_history_to_prompt": True,
}
},
"content_safety": {
"internal_keywords": {"enable": True, "extra_keywords": []},
"baidu_aip": {"enable": False, "app_id": "", "api_key": "", "secret_key": ""},
},
"admins_id": [],
"admins_id": [
"astrbot"
],
"t2i": False,
"t2i_word_threshold": 150,
"http_proxy": "",
"dashboard": {
"enable": True,
@@ -218,11 +220,6 @@ CONFIG_METADATA_2 = {
"type": "string",
"hint": "每一段回复的间隔时间,格式为 `最小时间,最大时间`。如 `0.75,2.5`",
},
"seg_prompt": {
"description": "分段提示词辅助",
"type": "string",
"hint": "此项为空时表达不启用这个方法。此方法会调用一次LLM请求。让 LLM 在某一句话中插入一个可以用正则表达式分隔的标记,来实现LLM基于情感分段。如: `请基于情感对以下文本进行分段, 并在两段之间添加`<seg>`以便我用正则匹配。` 然后将下面的正则表达式更换为`.+?<seg>`。",
},
"regex": {
"description": "正则表达式",
"type": "string",
@@ -250,7 +247,7 @@ CONFIG_METADATA_2 = {
"type": "list",
"items": {"type": "string"},
"obvious_hint": True,
"hint": "AstrBot 只处理所填写的 ID 发来的消息事件。为空时不启用白名单过滤。可以使用 /sid 指令获取在某个平台上的会话 ID。也可在 AstrBot 日志内获取会话 ID,当一条消息没通过白名单时,会输出 INFO 级别的日志。会话 ID 类似 aiocqhttp:GroupMessage:547540978",
"hint": "AstrBot 只处理所填写的 ID 发来的消息事件。为空时不启用白名单过滤。可以使用 /sid 指令获取在某个平台上的会话 ID。也可在 AstrBot 日志内获取会话 ID,当一条消息没通过白名单时,会输出 INFO 级别的日志。会话 ID 类似 aiocqhttp:GroupMessage:547540978。管理员可使用 /wl 添加白名单",
},
"id_whitelist_log": {
"description": "打印白名单日志",
@@ -281,7 +278,7 @@ CONFIG_METADATA_2 = {
"items": {"type": "string"},
"obvious_hint": True,
"hint": "此功能解决由于文件系统不一致导致路径不存在的问题。格式为 <原路径>:<映射路径>。如 `/app/.config/QQ:/var/lib/docker/volumes/xxxx/_data`。这样,当消息平台下发的事件中图片和语音路径以 `/app/.config/QQ` 开头时,开头被替换为 `/var/lib/docker/volumes/xxxx/_data`。这在 AstrBot 或者平台协议端使用 Docker 部署时特别有用。",
}
},
},
},
"content_safety": {
@@ -338,6 +335,7 @@ CONFIG_METADATA_2 = {
"enable": True,
"key": [],
"api_base": "https://api.openai.com/v1",
"timeout": 120,
"model_config": {
"model": "gpt-4o-mini",
},
@@ -349,6 +347,7 @@ CONFIG_METADATA_2 = {
"api_version": "2024-05-01-preview",
"key": [],
"api_base": "",
"timeout": 120,
"model_config": {
"model": "gpt-4o-mini",
},
@@ -369,6 +368,7 @@ CONFIG_METADATA_2 = {
"enable": True,
"key": [],
"api_base": "https://generativelanguage.googleapis.com/v1beta/openai/",
"timeout": 120,
"model_config": {
"model": "gemini-1.5-flash",
},
@@ -379,6 +379,7 @@ CONFIG_METADATA_2 = {
"enable": True,
"key": [],
"api_base": "https://generativelanguage.googleapis.com/",
"timeout": 120,
"model_config": {
"model": "gemini-1.5-flash",
},
@@ -389,6 +390,7 @@ CONFIG_METADATA_2 = {
"enable": True,
"key": [],
"api_base": "https://api.deepseek.com/v1",
"timeout": 120,
"model_config": {
"model": "deepseek-chat",
},
@@ -398,6 +400,7 @@ CONFIG_METADATA_2 = {
"type": "zhipu_chat_completion",
"enable": True,
"key": [],
"timeout": 120,
"api_base": "https://open.bigmodel.cn/api/paas/v4/",
"model_config": {
"model": "glm-4-flash",
@@ -408,6 +411,7 @@ CONFIG_METADATA_2 = {
"type": "openai_chat_completion",
"enable": True,
"key": [],
"timeout": 120,
"api_base": "https://api.siliconflow.cn/v1",
"model_config": {
"model": "deepseek-ai/DeepSeek-V3",
@@ -431,6 +435,7 @@ CONFIG_METADATA_2 = {
"dify_api_key": "",
"dify_api_base": "https://api.dify.ai/v1",
"dify_workflow_output_key": "",
"timeout": 60,
},
"whisper(API)": {
"id": "whisper",
@@ -457,14 +462,34 @@ CONFIG_METADATA_2 = {
"openai-tts-voice": "alloy",
"timeout": "20",
},
"fishaudio_tts(API)": {
"id": "fishaudio_tts",
"type": "fishaudio_tts_api",
"enable": False,
"api_key": "",
"api_base": "https://api.fish-audio.cn/v1",
"fishaudio-tts-character": "可莉",
"timeout": "20",
},
},
"items": {
"timeout": {
"description": "超时时间",
"type": "int",
"hint": "超时时间,单位为秒。",
},
"openai-tts-voice": {
"description": "voice",
"type": "string",
"obvious_hint": True,
"hint": "OpenAI TTS 的声音。OpenAI 默认支持:'alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'",
},
"fishaudio-tts-character": {
"description": "character",
"type": "string",
"obvious_hint": True,
"hint": "fishaudio TTS 的角色。默认为可莉。更多角色请访问:https://fish.audio/zh-CN/discovery",
},
"whisper_hint": {
"description": "本地部署 Whisper 模型须知",
"type": "string",
@@ -710,9 +735,15 @@ CONFIG_METADATA_2 = {
"obvious_hint": True,
"hint": "启用后,当接收到图片消息时,会使用模型先将图片转述为文字再进行后续处理。推荐使用 gpt-4o-mini 模型。",
},
"image_caption_provider_id": {
"description": "图像转述提供商 ID",
"type": "string",
"obvious_hint": True,
"hint": "可选。图像转述提供商 ID。如为空将选择聊天使用的提供商。",
},
"image_caption_prompt": {
"description": "图像转述提示词",
"type": "string"
"type": "string",
},
"active_reply": {
"description": "主动回复",
@@ -740,16 +771,10 @@ CONFIG_METADATA_2 = {
"description": "提示词",
"type": "string",
"obvious_hint": True,
"hint": "提示词。当提示词为空时,如果触发回复,prompt是触发的消息的内容;否则是提示词。此项可以和定时回复(暂未实现)配合使用。",
"hint": "提示词。当提示词为空时,如果触发回复,则向 LLM 请求的是触发的消息的内容;否则是提示词。此项可以和定时回复(暂未实现)配合使用。",
},
},
},
"put_history_to_prompt": {
"description": "将群聊历史记录作为 prompt",
"type": "bool",
"obvious_hint": True,
"hint": "需要先启用 group_icl_enable。此功能会将群聊历史记录放到 prompt 再请求。如果关闭,则是放在 system_prompt。如果开启了主动回复,建议启用,模型能够更好地完成回复任务。",
}
},
},
},
@@ -769,6 +794,11 @@ CONFIG_METADATA_2 = {
"type": "bool",
"hint": "启用后,超出一定长度的文本将会通过 AstrBot API 渲染成 Markdown 图片发送。可以缓解审核和消息过长刷屏的问题,并提高 Markdown 文本的可读性。",
},
"t2i_word_threshold": {
"description": "文本转图像字数阈值",
"type": "int",
"hint": "超出此字符长度的文本将会被转换成图片。字数不能低于 50。",
},
"admins_id": {
"description": "管理员 ID",
"type": "list",
@@ -129,6 +129,9 @@ class LLMRequestSubStage(Stage):
req.prompt += extra_prompt
async for _ in self.process(event, _nested=True):
yield
else:
if llm_response.completion_text:
event.set_result(MessageEventResult().message(llm_response.completion_text))
except BaseException as e:
logger.error(traceback.format_exc())
+10 -18
View File
@@ -19,11 +19,17 @@ class ResultDecorateStage:
self.reply_with_mention = ctx.astrbot_config['platform_settings']['reply_with_mention']
self.reply_with_quote = ctx.astrbot_config['platform_settings']['reply_with_quote']
self.use_tts = ctx.astrbot_config['provider_tts_settings']['enable']
self.t2i_word_threshold = ctx.astrbot_config['t2i_word_threshold']
try:
self.t2i_word_threshold = int(self.t2i_word_threshold)
if self.t2i_word_threshold < 50:
self.t2i_word_threshold = 50
except BaseException:
self.t2i_word_threshold = 150
# 分段回复
self.enable_segmented_reply = ctx.astrbot_config['platform_settings']['segmented_reply']['enable']
self.only_llm_result = ctx.astrbot_config['platform_settings']['segmented_reply']['only_llm_result']
self.seg_prompt = ctx.astrbot_config['platform_settings']['segmented_reply']['seg_prompt']
self.regex = ctx.astrbot_config['platform_settings']['segmented_reply']['regex']
async def process(self, event: AstrMessageEvent) -> Union[None, AsyncGenerator[None, None]]:
@@ -33,9 +39,8 @@ class ResultDecorateStage:
handlers = star_handlers_registry.get_handlers_by_event_type(EventType.OnDecoratingResultEvent)
for handler in handlers:
# TODO: 如何让这里的 handler 也能使用 LLM 能力。也许需要将 LLMRequestSubStage 提取出来。
await handler.handler(event)
if len(result.chain) > 0:
# 回复前缀
if self.reply_prefix:
@@ -50,19 +55,6 @@ class ResultDecorateStage:
new_chain = []
for comp in result.chain:
if isinstance(comp, Plain):
if self.seg_prompt:
try:
llm_resp = await self.ctx.plugin_manager.context.get_using_provider().text_chat(
prompt=f"{self.seg_prompt}\n{comp.text}",
)
comp.text = llm_resp.completion_text
except BaseException as e:
traceback.print_exc()
logger.error("使用 LLM 分段回复失败: " + str(e))
new_chain.append(comp)
continue
split_response = re.findall(self.regex, comp.text)
if not split_response:
new_chain.append(comp)
@@ -91,7 +83,7 @@ class ResultDecorateStage:
logger.error(f"由于 TTS 音频文件没找到,消息段转语音失败: {comp.text}")
new_chain.append(comp)
except BaseException:
traceback.print_exc()
logger.error(traceback.format_exc())
logger.error("TTS 失败,使用文本发送。")
new_chain.append(comp)
else:
@@ -106,7 +98,7 @@ class ResultDecorateStage:
plain_str += "\n\n" + comp.text
else:
break
if plain_str and len(plain_str) > 150:
if plain_str and len(plain_str) > self.t2i_word_threshold:
render_start = time.time()
try:
url = await html_renderer.render_t2i(plain_str, return_url=True)
+7 -3
View File
@@ -3,7 +3,7 @@ from ..context import PipelineContext
from typing import Union, AsyncGenerator
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.message.message_event_result import MessageEventResult, MessageChain
from astrbot.core.message.components import At
from astrbot.core.message.components import At, Reply
from astrbot.core.star.star_handler import star_handlers_registry, EventType
from astrbot.core.star.filter.command_group import CommandGroupFilter
from astrbot.core.star.filter.permission import PermissionTypeFilter
@@ -43,7 +43,7 @@ class WakingCheckStage(Stage):
if event.message_str.startswith(wake_prefix):
if (
not event.is_private_chat()
and isinstance(messages[0], At)
and (isinstance(messages[0], At) or isinstance(messages[0], Reply))
and str(messages[0].qq) != str(event.get_self_id())
and str(messages[0].qq) != "all"
):
@@ -86,6 +86,10 @@ class WakingCheckStage(Stage):
if len(handler.event_filters) == 0:
# 不可能有这种情况, 也不允许有这种情况
continue
if 'sub_command' in handler.extras_configs:
# 如果是子指令
continue
for filter in handler.event_filters:
try:
@@ -122,7 +126,7 @@ class WakingCheckStage(Stage):
if permission_not_pass:
if self.no_permission_reply:
await event.send(MessageChain().message(f"ID {event.get_sender_id()} 权限不足"))
await event.send(MessageChain().message(f"ID {event.get_sender_id()} 权限不足。通过 /sid 获取 ID 并请管理员添加。"))
event.stop_event()
return
+20 -9
View File
@@ -8,7 +8,7 @@ from typing import List, Union
from astrbot.core.message.components import Plain, Image, BaseMessageComponent, Face, At, AtAll, Forward
from astrbot.core.utils.metrics import Metric
from astrbot.core.provider.entites import ProviderRequest
from astrbot.core.db.po import Conversation
@dataclass
class MessageSesion:
@@ -31,12 +31,19 @@ class AstrMessageEvent(abc.ABC):
platform_meta: PlatformMetadata,
session_id: str,):
self.message_str = message_str
'''纯文本的消息'''
self.message_obj = message_obj
'''消息对象,AstrBotMessage。带有完整的消息结构。'''
self.platform_meta = platform_meta
'''消息平台的信息, 其中 name 是平台的类型,如 aiocqhttp'''
self.session_id = session_id
'''用户的会话 ID。可以直接使用下面的 unified_msg_origin'''
self.role = "member"
'''用户是否是管理员。如果是管理员,这里是 admin'''
self.is_wake = False # 是否通过 WakingStage
self.is_at_or_wake_command = False # 是否是 At 机器人或者带有唤醒词或者是私聊(事件监听器会让 is_wake 设为 True
'''是否唤醒'''
self.is_at_or_wake_command = False
'''是否是 At 机器人或者带有唤醒词或者是私聊(事件监听器会让 is_wake 设为 True,但是不会让这个属性置为 True)'''
self._extras = {}
self.session = MessageSesion(
platform_name=platform_meta.name,
@@ -44,7 +51,7 @@ class AstrMessageEvent(abc.ABC):
session_id=session_id
)
self.unified_msg_origin = str(self.session)
'''统一的消息来源字符串。格式为 platform_name:message_type:session_id'''
self._result: MessageEventResult = None
'''消息事件的结果'''
@@ -298,9 +305,10 @@ class AstrMessageEvent(abc.ABC):
prompt: str,
func_tool_manager = None,
session_id: str = None,
image_urls: List[str] = None,
contexts: List = None,
system_prompt: str = ""
image_urls: List[str] = [],
contexts: List = [],
system_prompt: str = "",
conversation: Conversation = None
) -> ProviderRequest:
'''
创建一个 LLM 请求。
@@ -309,10 +317,12 @@ class AstrMessageEvent(abc.ABC):
```py
yield event.request_llm(prompt="hi")
```
prompt: 提示词
session_id: 已经过时,留空即可
image_urls: 可以是 base64:// 或者 http:// 开头的图片链接,也可以是本地图片路径。
contexts: 当指定 contexts 时,将会**只**使用 contexts 作为上下文。
contexts: 当指定 contexts 时,将会使用 contexts 作为上下文。
func_tool_manager: 函数工具管理器,用于调用函数工具。用 self.context.get_llm_tool_manager() 获取。
conversation: 可选。如果指定,将在指定的对话中进行 LLM 请求。对话的人格会被用于 LLM 请求,并且结果将会被记录到对话中。
'''
return ProviderRequest(
prompt = prompt,
@@ -320,5 +330,6 @@ class AstrMessageEvent(abc.ABC):
image_urls = image_urls,
func_tool = func_tool_manager,
contexts = contexts,
system_prompt = system_prompt
system_prompt = system_prompt,
conversation=conversation
)
@@ -1,9 +1,7 @@
import os
import random
import asyncio
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import Plain, Image, Record
from astrbot.api.message_components import Plain, Image, Record, At
from aiocqhttp import CQHttp
from astrbot.core.utils.io import file_to_base64, download_image_by_url
@@ -33,6 +31,10 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
d['data'] = {
'file': bs64_data,
}
if isinstance(segment, At):
d['data'] = {
'qq': str(segment.qq) # 转换为字符串
}
ret.append(d)
return ret
@@ -3,6 +3,7 @@ import asyncio
import aiohttp
import quart
import base64
import datetime
from astrbot.api.platform import AstrBotMessage, MessageMember, MessageType
from astrbot.api.message_components import Plain, Image, At, Record
@@ -67,6 +68,17 @@ class SimpleGewechatClient():
logger.critical("收到 gewechat 下线通知。")
return
if 'Data' in data and 'CreateTime' in data['Data']:
# 得到系统 UTF+8 的 ts
tz_offset = datetime.timedelta(hours=8)
tz = datetime.timezone(tz_offset)
ts = datetime.datetime.now(tz).timestamp()
create_time = data['Data']['CreateTime']
if create_time < ts - 30:
logger.warning(f"消息时间戳过旧: {create_time},当前时间戳: {ts}")
return
abm = AstrBotMessage()
d = data['Data']
@@ -143,7 +155,7 @@ class SimpleGewechatClient():
abm.message.append(Record(file=file_path, url=file_path))
case _:
logger.error(f"未实现的消息类型: {d['MsgType']}")
logger.info(f"未实现的消息类型: {d['MsgType']}")
return
logger.info(f"abm: {abm}")
+10 -8
View File
@@ -114,22 +114,24 @@ class ProviderManager():
try:
match provider_cfg['type']:
case "openai_chat_completion":
from .sources.openai_source import ProviderOpenAIOfficial # noqa: F401
from .sources.openai_source import ProviderOpenAIOfficial as ProviderOpenAIOfficial
case "zhipu_chat_completion":
from .sources.zhipu_source import ProviderZhipu # noqa: F401
from .sources.zhipu_source import ProviderZhipu as ProviderZhipu
case "llm_tuner":
logger.info("加载 LLM Tuner 工具 ...")
from .sources.llmtuner_source import LLMTunerModelLoader # noqa: F401
from .sources.llmtuner_source import LLMTunerModelLoader as LLMTunerModelLoader
case "dify":
from .sources.dify_source import ProviderDify # noqa: F401
from .sources.dify_source import ProviderDify as ProviderDify
case "googlegenai_chat_completion":
from .sources.gemini_source import ProviderGoogleGenAI # noqa: F401
from .sources.gemini_source import ProviderGoogleGenAI as ProviderGoogleGenAI
case "openai_whisper_api":
from .sources.whisper_api_source import ProviderOpenAIWhisperAPI # noqa: F401
from .sources.whisper_api_source import ProviderOpenAIWhisperAPI as ProviderOpenAIWhisperAPI
case "openai_whisper_selfhost":
from .sources.whisper_selfhosted_source import ProviderOpenAIWhisperSelfHost # noqa: F401
from .sources.whisper_selfhosted_source import ProviderOpenAIWhisperSelfHost as ProviderOpenAIWhisperSelfHost
case "openai_tts_api":
from .sources.openai_tts_api_source import ProviderOpenAITTSAPI # noqa: F401
from .sources.openai_tts_api_source import ProviderOpenAITTSAPI as ProviderOpenAITTSAPI
case "fishaudio_tts_api":
from .sources.fishaudio_tts_api_source import ProviderFishAudioTTSAPI as ProviderFishAudioTTSAPI
except (ImportError, ModuleNotFoundError) as e:
logger.critical(f"加载 {provider_cfg['type']}({provider_cfg['id']}) 提供商适配器失败:{e}。可能是因为有未安装的依赖。")
continue
+8 -7
View File
@@ -31,7 +31,9 @@ class ProviderDify(Provider):
raise Exception("Dify API 类型不能为空。")
self.model_name = "dify"
self.workflow_output_key = provider_config.get("dify_workflow_output_key", "astrbot_wf_output")
self.timeout = provider_config.get("timeout", 120)
if isinstance(self.timeout, str):
self.timeout = int(self.timeout)
self.conversation_ids = {}
@@ -39,7 +41,7 @@ class ProviderDify(Provider):
self,
prompt: str,
session_id: str = None,
image_urls: List[str] = None,
image_urls: List[str] = [],
func_tool: FuncCall = None,
contexts: List = None,
system_prompt: str = None,
@@ -64,8 +66,6 @@ class ProviderDify(Provider):
else:
# TODO: 处理更多情况
logger.warning(f"未知的图片链接:{image_url},图片将忽略。")
logger.debug(files_payload)
# 获得会话变量
session_vars = sp.get("session_variables", {})
@@ -80,7 +80,8 @@ class ProviderDify(Provider):
query=prompt,
user=session_id,
conversation_id=conversation_id,
files=files_payload
files=files_payload,
timeout=self.timeout
):
logger.debug(f"dify resp chunk: {chunk}")
if chunk['event'] == "message" or \
@@ -98,7 +99,8 @@ class ProviderDify(Provider):
**session_var
},
user=session_id,
files=files_payload
files=files_payload,
timeout=self.timeout
):
match chunk['event']:
case "workflow_started":
@@ -115,7 +117,6 @@ class ProviderDify(Provider):
result = chunk['data']['outputs'][self.workflow_output_key]
case _:
raise Exception(f"未知的 Dify API 类型:{self.api_type}")
return LLMResponse(role="assistant", completion_text=result)
async def forget(self, session_id):
@@ -0,0 +1,105 @@
import uuid
import ormsgpack
from pydantic import BaseModel, conint
from httpx import AsyncClient
from typing import Annotated, Literal
from ..provider import TTSProvider
from ..entites import ProviderType
from ..register import register_provider_adapter
class ServeReferenceAudio(BaseModel):
audio: bytes
text: str
class ServeTTSRequest(BaseModel):
text: str
chunk_length: Annotated[int, conint(ge=100, le=300, strict=True)] = 200
# 音频格式
format: Literal["wav", "pcm", "mp3"] = "mp3"
mp3_bitrate: Literal[64, 128, 192] = 128
# 参考音频
references: list[ServeReferenceAudio] = []
# 参考模型 ID
# 例如 https://fish.audio/m/7f92f8afb8ec43bf81429cc1c9199cb1/
# 其中reference_id为 7f92f8afb8ec43bf81429cc1c9199cb1
reference_id: str | None = None
# 对中英文文本进行标准化,这可以提高数字的稳定性
normalize: bool = True
# 平衡模式将延迟减少到300毫秒,但可能会降低稳定性
latency: Literal["normal", "balanced"] = "normal"
@register_provider_adapter(
"fishaudio_tts_api", "FishAudio TTS API", provider_type=ProviderType.TEXT_TO_SPEECH
)
class ProviderFishAudioTTSAPI(TTSProvider):
def __init__(
self,
provider_config: dict,
provider_settings: dict,
) -> None:
super().__init__(provider_config, provider_settings)
self.chosen_api_key: str = provider_config.get("api_key", "")
self.character: str = provider_config.get("fishaudio-tts-character", "可莉")
self.api_base: str = provider_config.get(
"api_base", "https://api.fish-audio.cn/v1"
)
self.headers = {
"Authorization": f"Bearer {self.chosen_api_key}",
}
self.set_model(provider_config.get("model", None))
async def _get_reference_id_by_character(self, character: str) -> str:
"""
获取角色的reference_id
Args:
character: 角色名称
Returns:
reference_id: 角色的reference_id
exception:
APIException: 获取语音角色列表为空
"""
sort_options = ["score", "task_count", "created_at"]
async with AsyncClient(base_url=self.api_base.replace("/v1", "")) as client:
for sort_by in sort_options:
params = {"title": character, "sort_by": sort_by}
response = await client.get(
"/model", params=params, headers=self.headers
)
resp_data = response.json()
if resp_data["total"] == 0:
continue
for item in resp_data["items"]:
if character in item["title"]:
return item["_id"]
return None
async def _generate_request(self, text: str) -> dict:
return ServeTTSRequest(
text=text,
format="wav",
reference_id=await self._get_reference_id_by_character(self.character),
)
async def get_audio(self, text: str) -> str:
path = f"data/temp/fishaudio_tts_api_{uuid.uuid4()}.wav"
self.headers["content-type"] = "application/msgpack"
request = await self._generate_request(text)
async with AsyncClient(base_url=self.api_base).stream(
"POST",
"/tts",
headers=self.headers,
content=ormsgpack.packb(request, option=ormsgpack.OPT_SERIALIZE_PYDANTIC),
) as response:
if response.headers["content-type"] == "audio/wav":
with open(path, "wb") as f:
async for chunk in response.aiter_bytes():
f.write(chunk)
return path
text = await response.aread()
raise Exception(f"Fish Audio API请求失败: {text}")
+24 -11
View File
@@ -1,6 +1,4 @@
import traceback
import base64
import json
import aiohttp
from astrbot.core.utils.io import download_image_by_url
from astrbot.core.db import BaseDatabase
@@ -12,17 +10,18 @@ from ..register import register_provider_adapter
from astrbot.core.provider.entites import LLMResponse
class SimpleGoogleGenAIClient():
def __init__(self, api_key: str, api_base: str):
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
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=10) as resp:
async with self.client.get(request_url, timeout=self.timeout) as resp:
response = await resp.json()
models = []
@@ -48,8 +47,13 @@ class SimpleGoogleGenAIClient():
payload["contents"] = contents
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=10) as resp:
response = await resp.json()
async with self.client.post(request_url, json=payload, timeout=self.timeout) as resp:
try:
response = await resp.json()
except Exception as e:
text = await resp.text()
logger.error(f"gemini 返回了非 json 数据: {text}")
raise e
return response
@@ -67,10 +71,13 @@ class ProviderGoogleGenAI(Provider):
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)
api_base=provider_config.get("api_base", None),
timeout=self.timeout
)
self.set_model(provider_config['model_config']['model'])
@@ -179,9 +186,9 @@ class ProviderGoogleGenAI(Provider):
llm_response = await self._query(payloads, func_tool)
except Exception as e:
if "maximum context length" in str(e):
retry_cnt = 10
retry_cnt = 20
while retry_cnt > 0:
logger.warning(f"请求失败:{e}。上下文长度超过限制。尝试弹出最早的记录然后重试。")
logger.warning(f"请求失败:{e}。上下文长度超过限制。尝试弹出最早的记录然后重试。当前记录条数: {len(context_query)}")
try:
await self.pop_record(context_query)
llm_response = await self._query(payloads, func_tool)
@@ -194,7 +201,7 @@ class ProviderGoogleGenAI(Provider):
if retry_cnt == 0:
llm_response = LLMResponse("err", "err: 请尝试 /reset 重置会话")
elif "Function calling is not enabled" in str(e):
logger.info(f"{self.get_model()} 不支持函数调用工具调用,已自动去除")
logger.info(f"{self.get_model()} 不支持函数工具调用,已自动去除,不影响使用。")
if 'tools' in payloads:
del payloads['tools']
llm_response = await self._query(payloads, None)
@@ -224,8 +231,14 @@ class ProviderGoogleGenAI(Provider):
if image_url.startswith("http"):
image_path = await download_image_by_url(image_url)
image_data = await self.encode_image_bs64(image_path)
elif image_url.startswith("file:///"):
image_path = image_url.replace("file:///", "")
image_data = await self.encode_image_bs64(image_path)
else:
image_data = await self.encode_image_bs64(image_url)
if not image_data:
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
continue
user_content["content"].append({"type": "image_url", "image_url": {"url": image_data}})
return user_content
else:
+40 -19
View File
@@ -4,7 +4,7 @@ import os
from openai import AsyncOpenAI, AsyncAzureOpenAI, NOT_GIVEN
from openai.types.chat.chat_completion import ChatCompletion
from openai._exceptions import NotFoundError
from openai._exceptions import NotFoundError, UnprocessableEntityError
from astrbot.core.utils.io import download_image_by_url
from astrbot.core.db import BaseDatabase
@@ -29,7 +29,9 @@ class ProviderOpenAIOfficial(Provider):
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", 120)
if isinstance(self.timeout, str):
self.timeout = int(self.timeout)
# 适配 azure openai #332
if "api_version" in provider_config:
# 使用 azure api
@@ -37,14 +39,14 @@ class ProviderOpenAIOfficial(Provider):
api_key=self.chosen_api_key,
api_version=provider_config.get("api_version", None),
base_url=provider_config.get("api_base", None),
timeout=provider_config.get("timeout", NOT_GIVEN),
timeout=self.timeout
)
else:
# 使用 openai api
self.client = AsyncOpenAI(
api_key=self.chosen_api_key,
base_url=provider_config.get("api_base", None),
timeout=provider_config.get("timeout", NOT_GIVEN),
timeout=self.timeout
)
self.set_model(provider_config['model_config']['model'])
@@ -78,12 +80,14 @@ class ProviderOpenAIOfficial(Provider):
raise Exception("API 返回的 completion 为空。")
choice = completion.choices[0]
llm_response = LLMResponse("assistant")
if choice.message.content:
# text completion
completion_text = str(choice.message.content).strip()
return LLMResponse("assistant", completion_text, raw_completion=completion)
elif choice.message.tool_calls:
llm_response.completion_text = completion_text
if choice.message.tool_calls:
# tools call (function calling)
args_ls = []
func_name_ls = []
@@ -93,16 +97,21 @@ class ProviderOpenAIOfficial(Provider):
args = json.loads(tool_call.function.arguments)
args_ls.append(args)
func_name_ls.append(tool_call.function.name)
return LLMResponse(role="tool", tools_call_args=args_ls, tools_call_name=func_name_ls, raw_completion=completion)
else:
llm_response.role = "tool"
llm_response.tools_call_args = args_ls
llm_response.tools_call_name = func_name_ls
if not llm_response.completion_text and not llm_response.tools_call_args:
logger.error(f"API 返回的 completion 无法解析:{completion}")
raise Exception("Internal Error")
raise Exception(f"API 返回的 completion 无法解析:{completion}")
return llm_response
async def text_chat(
self,
prompt: str,
session_id: str=None,
image_urls: List[str]=None,
image_urls: List[str]=[],
func_tool: FuncCall=None,
contexts=[],
system_prompt=None,
@@ -128,14 +137,21 @@ class ProviderOpenAIOfficial(Provider):
llm_response = None
try:
llm_response = await self._query(payloads, func_tool)
except UnprocessableEntityError as e:
logger.warning(f"不可处理的实体错误:{e},尝试删除图片。")
# 尝试删除所有 image
new_contexts = await self._remove_image_from_context(context_query)
payloads['messages'] = new_contexts
context_query = new_contexts
llm_response = await self._query(payloads, func_tool)
except Exception as e:
if "maximum context length" in str(e):
# 重试 10 次
retry_cnt = 10
retry_cnt = 20
while retry_cnt > 0:
logger.warning("上下文长度超过限制。尝试弹出最早的记录然后重试。")
logger.warning(f"上下文长度超过限制。尝试弹出最早的记录然后重试。当前记录条数: {len(context_query)}")
try:
await self.pop_record(session_id)
await self.pop_record(context_query)
llm_response = await self._query(payloads, func_tool)
break
except Exception as e:
@@ -156,8 +172,9 @@ class ProviderOpenAIOfficial(Provider):
or 'does not support tools' in str(e) \
or 'Function call is not supported' in str(e) \
or 'Function calling is not enabled' in str(e) \
or 'Tool calling is not supported' in str(e): # siliconcloud
logger.info(f"{self.get_model()} 不支持函数调用工具调用,已经自动去除")
or 'Tool calling is not supported' in str(e) \
or 'No endpoints found that support tool use' in str(e): # siliconcloud
logger.info(f"{self.get_model()} 不支持函数工具调用,已自动去除,不影响使用。")
if 'tools' in payloads:
del payloads['tools']
llm_response = await self._query(payloads, None)
@@ -165,7 +182,7 @@ class ProviderOpenAIOfficial(Provider):
logger.error(f"发生了错误。Provider 配置如下: {self.provider_config}")
if 'tool' in str(e).lower() and 'support' in str(e).lower():
logger.error(f"疑似该模型不支持函数调用工具调用。请输入 /tool off_all")
logger.error("疑似该模型不支持函数调用工具调用。请输入 /tool off_all")
if 'Connection error.' in str(e):
proxy = os.environ.get("http_proxy", None)
@@ -221,10 +238,14 @@ class ProviderOpenAIOfficial(Provider):
if image_url.startswith("http"):
image_path = await download_image_by_url(image_url)
image_data = await self.encode_image_bs64(image_path)
elif image_url.startswith("file:///"):
image_path = image_url.replace("file:///", "")
image_data = await self.encode_image_bs64(image_path)
else:
if image_url.startswith("file:///"):
image_url = image_url.replace("file:///", "")
image_data = await self.encode_image_bs64(image_url)
if not image_data:
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
continue
user_content["content"].append({"type": "image_url", "image_url": {"url": image_data}})
return user_content
else:
+1
View File
@@ -8,6 +8,7 @@ from astrbot.core.config import AstrBotConfig
class RegexFilter(HandlerFilter):
'''正则表达式过滤器'''
def __init__(self, regex: str):
self.regex_str = regex
self.regex = re.compile(regex)
def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
+38 -20
View File
@@ -17,7 +17,12 @@ def get_handler_full_name(awaitable: Awaitable) -> str:
'''获取 Handler 的全名'''
return f"{awaitable.__module__}_{awaitable.__name__}"
def get_handler_or_create(handler: Awaitable, event_type: EventType, dont_add = False, **kwargs) -> StarHandlerMetadata:
def get_handler_or_create(
handler: Awaitable,
event_type: EventType,
dont_add = False,
**kwargs
) -> StarHandlerMetadata:
'''获取 Handler 或者创建一个新的 Handler'''
handler_full_name = get_handler_full_name(handler)
md = star_handlers_registry.get_handler_by_full_name(handler_full_name)
@@ -30,18 +35,27 @@ def get_handler_or_create(handler: Awaitable, event_type: EventType, dont_add =
handler_name=handler.__name__,
handler_module_path=handler.__module__,
handler=handler,
event_filters=[],
event_filters=[]
)
# 插件handler的附加额外信息
if handler.__doc__:
md.desc = handler.__doc__.strip()
if 'desc' in kwargs:
md.desc = kwargs['desc']
del kwargs['desc']
md.extras_configs = kwargs
if not dont_add:
star_handlers_registry.append(md)
return md
def register_command(command_name: str = None, *args):
def register_command(command_name: str = None, *args, **kwargs):
'''注册一个 Command.
'''
# print("command: ", command_name, args, kwargs)
new_command = None
add_to_event_filters = False
if isinstance(command_name, RegisteringCommandable):
@@ -54,20 +68,24 @@ def register_command(command_name: str = None, *args):
add_to_event_filters = True
def decorator(awaitable):
handler_md = get_handler_or_create(awaitable, EventType.AdapterMessageEvent)
if not add_to_event_filters:
kwargs['sub_command'] = True # 打一个标记,表示这是一个子指令,再 wakingstage 阶段这个 handler 将会直接被跳过(其父指令会接管)
handler_md = get_handler_or_create(awaitable, EventType.AdapterMessageEvent, **kwargs)
new_command.init_handler_md(handler_md)
if add_to_event_filters:
# 裸指令
handler_md.event_filters.append(new_command)
return awaitable
return decorator
def register_command_group(command_group_name: str = None, *args):
def register_command_group(command_group_name: str = None, *args, **kwargs):
'''注册一个 CommandGroup
'''
# print("commandgroup: ", command_group_name,args, kwargs)
new_group = None
add_to_event_filters = False
if isinstance(command_group_name, RegisteringCommandable):
@@ -82,7 +100,7 @@ def register_command_group(command_group_name: str = None, *args):
def decorator(obj):
if add_to_event_filters:
# 根指令组
handler_md = get_handler_or_create(obj, EventType.AdapterMessageEvent)
handler_md = get_handler_or_create(obj, EventType.AdapterMessageEvent, **kwargs)
handler_md.event_filters.append(new_group)
return RegisteringCommandable(new_group)
@@ -97,16 +115,16 @@ class RegisteringCommandable():
def __init__(self, parent_group: CommandGroupFilter):
self.parent_group = parent_group
def register_event_message_type(event_message_type: EventMessageType):
def register_event_message_type(event_message_type: EventMessageType, **kwargs):
'''注册一个 EventMessageType'''
def decorator(awaitable):
handler_md = get_handler_or_create(awaitable, EventType.AdapterMessageEvent)
handler_md = get_handler_or_create(awaitable, EventType.AdapterMessageEvent, **kwargs)
handler_md.event_filters.append(EventMessageTypeFilter(event_message_type))
return awaitable
return decorator
def register_platform_adapter_type(platform_adapter_type: PlatformAdapterType):
def register_platform_adapter_type(platform_adapter_type: PlatformAdapterType, **kwargs):
'''注册一个 PlatformAdapterType'''
def decorator(awaitable):
handler_md = get_handler_or_create(awaitable, EventType.AdapterMessageEvent)
@@ -115,10 +133,10 @@ def register_platform_adapter_type(platform_adapter_type: PlatformAdapterType):
return decorator
def register_regex(regex: str):
def register_regex(regex: str, **kwargs):
'''注册一个 Regex'''
def decorator(awaitable):
handler_md = get_handler_or_create(awaitable, EventType.AdapterMessageEvent)
handler_md = get_handler_or_create(awaitable, EventType.AdapterMessageEvent, **kwargs)
handler_md.event_filters.append(RegexFilter(regex))
return awaitable
@@ -138,7 +156,7 @@ def register_permission_type(permission_type: PermissionType, raise_error: bool
return decorator
def register_on_llm_request():
def register_on_llm_request(**kwargs):
'''当有 LLM 请求时的事件
Examples:
@@ -153,12 +171,12 @@ def register_on_llm_request():
请务必接收两个参数:event, request
'''
def decorator(awaitable):
_ = get_handler_or_create(awaitable, EventType.OnLLMRequestEvent)
_ = get_handler_or_create(awaitable, EventType.OnLLMRequestEvent, **kwargs)
return awaitable
return decorator
def register_on_llm_response():
def register_on_llm_response(**kwargs):
'''当有 LLM 请求后的事件
Examples:
@@ -173,7 +191,7 @@ def register_on_llm_response():
请务必接收两个参数:event, request
'''
def decorator(awaitable):
_ = get_handler_or_create(awaitable, EventType.OnLLMResponseEvent)
_ = get_handler_or_create(awaitable, EventType.OnLLMResponseEvent, **kwargs)
return awaitable
return decorator
@@ -219,18 +237,18 @@ def register_llm_tool(name: str = None):
return decorator
def register_on_decorating_result():
def register_on_decorating_result(**kwargs):
'''在发送消息前的事件'''
def decorator(awaitable):
_ = get_handler_or_create(awaitable, EventType.OnDecoratingResultEvent)
_ = get_handler_or_create(awaitable, EventType.OnDecoratingResultEvent, **kwargs)
return awaitable
return decorator
def register_after_message_sent():
def register_after_message_sent(**kwargs):
'''在消息发送后的事件'''
def decorator(awaitable):
_ = get_handler_or_create(awaitable, EventType.OnAfterMessageSentEvent)
_ = get_handler_or_create(awaitable, EventType.OnAfterMessageSentEvent, **kwargs)
return awaitable
return decorator
+4 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from types import ModuleType
from typing import List, Dict
from dataclasses import dataclass
from dataclasses import dataclass, field
from astrbot.core.config import AstrBotConfig
star_registry: List[StarMetadata] = []
@@ -39,6 +39,9 @@ class StarMetadata:
config: AstrBotConfig = None
'''插件配置'''
star_handler_full_names: List[str] = field(default_factory=list)
'''注册的 Handler 的全名列表'''
def __str__(self) -> str:
return f"StarMetadata({self.name}, {self.desc}, {self.version}, {self.repo})"
+54 -15
View File
@@ -1,34 +1,41 @@
from __future__ import annotations
import enum
from dataclasses import dataclass
import heapq
from dataclasses import dataclass, field
from typing import Awaitable, List, Dict, TypeVar, Generic
from .filter import HandlerFilter
from .star import star_map
T = TypeVar('T', bound='StarHandlerMetadata')
class StarHandlerRegistry(Generic[T], List[T]):
class StarHandlerRegistry(Generic[T]):
'''用于存储所有的 Star Handler'''
star_handlers_map: Dict[str, StarHandlerMetadata] = {}
'''用于快速查找。key 是 handler_full_name'''
_handlers = []
def append(self, handler: StarHandlerMetadata):
'''添加一个 Handler'''
super().append(handler)
if 'priority' not in handler.extras_configs:
handler.extras_configs['priority'] = 0
heapq.heappush(self._handlers, (-handler.extras_configs['priority'], handler))
self.star_handlers_map[handler.handler_full_name] = handler
def get_handlers_by_event_type(self, event_type: EventType, only_activated = True) -> List[StarHandlerMetadata]:
def _print_handlers(self):
'''打印所有的 Handler'''
for _, handler in self._handlers:
print(handler.handler_full_name)
def get_handlers_by_event_type(self, event_type: EventType, only_activated=True) -> List[StarHandlerMetadata]:
'''通过事件类型获取 Handler'''
if only_activated:
return [
handler
for handler in self
if handler.event_type == event_type and
star_map[handler.handler_module_path] and
star_map[handler.handler_module_path].activated
]
else:
return [handler for handler in self if handler.event_type == event_type]
handlers = [
handler
for _, handler in self._handlers
if handler.event_type == event_type and
(not only_activated or (star_map[handler.handler_module_path] and star_map[handler.handler_module_path].activated))
]
return handlers
def get_handler_by_full_name(self, full_name: str) -> StarHandlerMetadata:
'''通过 Handler 的全名获取 Handler'''
@@ -36,7 +43,32 @@ class StarHandlerRegistry(Generic[T], List[T]):
def get_handlers_by_module_name(self, module_name: str) -> List[StarHandlerMetadata]:
'''通过模块名获取 Handler'''
return [handler for handler in self if handler.handler_module_path == module_name]
return [handler for _, handler in self._handlers if handler.handler_module_path == module_name]
def clear(self):
'''清空所有的 Handler'''
self.star_handlers_map.clear()
self._handlers.clear()
def remove(self, handler: StarHandlerMetadata):
'''删除一个 Handler'''
# self._handlers.remove(handler)
for i, h in enumerate(self._handlers):
if h[1] == handler:
self._handlers.pop(i)
break
try:
del self.star_handlers_map[handler.handler_full_name]
except KeyError:
pass
def __iter__(self):
'''使 StarHandlerRegistry 支持迭代'''
return (handler for _, handler in self._handlers)
def __len__(self):
'''返回 Handler 的数量'''
return len(self._handlers)
star_handlers_registry = StarHandlerRegistry()
@@ -76,3 +108,10 @@ class StarHandlerMetadata():
desc: str = ""
'''Handler 的描述信息'''
extras_configs: dict = field(default_factory=dict)
'''插件注册的一些其他的信息, 如 priority 等'''
def __lt__(self, other: StarHandlerMetadata):
'''定义小于运算符以支持优先队列'''
return self.extras_configs.get('priority', 0) < other.extras_configs.get('priority', 0)
+71 -28
View File
@@ -9,7 +9,6 @@ import logging
from types import ModuleType
from typing import List
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.config.default import DEFAULT_VALUE_MAP
from astrbot.core import logger, sp, pip_installer
from .context import Context
from . import StarMetadata
@@ -41,6 +40,8 @@ class PluginManager:
'''保留插件的路径。在 packages 目录下'''
self.conf_schema_fname = "_conf_schema.json"
'''插件配置 Schema 文件名'''
self.failed_plugin_info = ""
def _get_classes(self, arg: ModuleType):
'''获取指定模块(可以理解为一个 python 文件)下所有的类'''
@@ -127,7 +128,7 @@ class PluginManager:
if isinstance(metadata, dict):
if 'name' not in metadata or 'desc' not in metadata or 'version' not in metadata or 'author' not in metadata:
raise Exception("插件元数据信息不完整。")
raise Exception("插件元数据信息不完整。name, desc, version, author 是必须的字段。")
metadata = StarMetadata(
name=metadata['name'],
author=metadata['author'],
@@ -137,25 +138,45 @@ class PluginManager:
)
return metadata
async def reload(self):
'''扫描并加载所有的插件'''
for smd in star_registry:
logger.debug(f"尝试终止插件 {smd.name} ...")
if hasattr(smd.star_cls, "__del__"):
smd.star_cls.__del__()
star_handlers_registry.clear()
star_handlers_registry.star_handlers_map.clear()
star_map.clear()
star_registry.clear()
for key in list(sys.modules.keys()):
if key.startswith("data.plugins") or key.startswith("packages"):
del sys.modules[key]
async def reload(self, specified_plugin_name=None):
'''扫描并加载所有的插件 当 specified_module_path 指定时,重载指定插件'''
specified_module_path = None
if specified_plugin_name:
for smd in star_registry:
if smd.name == specified_plugin_name:
specified_module_path = smd.module_path
break
# 终止插件
if not specified_module_path:
for smd in star_registry:
logger.debug(f"尝试终止插件 {smd.name} ...")
if hasattr(smd.star_cls, "__del__"):
smd.star_cls.__del__()
star_handlers_registry.clear()
star_map.clear()
star_registry.clear()
for key in list(sys.modules.keys()):
if key.startswith("data.plugins") or key.startswith("packages"):
del sys.modules[key]
else:
# 只重载指定插件
smd = star_map.get(specified_module_path)
if smd:
await self._unbind_plugin(smd.name, specified_module_path)
try:
del sys.modules[specified_module_path]
except KeyError:
logger.warning(f"模块 {specified_module_path} 未载入")
plugin_modules = self._get_plugin_modules()
if plugin_modules is None:
return False, "未找到任何插件模块"
fail_rec = ""
inactivated_plugins: list = sp.get("inactivated_plugins", [])
@@ -171,11 +192,15 @@ class PluginManager:
root_dir_name = plugin_module['pname'] # 插件的目录名
reserved = plugin_module.get('reserved', False) # 是否是保留插件。目前在 packages/ 目录下的都是保留插件。保留插件不可以卸载。
path = "data.plugins." if not reserved else "packages."
path += root_dir_name + "." + module_str
if specified_module_path and path != specified_module_path:
continue
logger.info(f"正在载入插件 {root_dir_name} ...")
# 尝试导入模块
path = "data.plugins." if not reserved else "packages."
path += root_dir_name + "." + module_str
try:
module = __import__(path, fromlist=[module_str])
except (ModuleNotFoundError, ImportError):
@@ -204,6 +229,18 @@ class PluginManager:
# 通过装饰器的方式注册插件
metadata = star_map[path]
try:
# yaml 文件的元数据优先
metadata_yaml = self._load_plugin_metadata(plugin_path=plugin_dir_path)
if metadata_yaml:
metadata.name = metadata_yaml.name
metadata.author = metadata_yaml.author
metadata.desc = metadata_yaml.desc
metadata.version = metadata_yaml.version
metadata.repo = metadata_yaml.repo
except Exception:
pass
if plugin_config:
metadata.config = plugin_config
try:
@@ -220,7 +257,6 @@ class PluginManager:
# 绑定 handler
related_handlers = star_handlers_registry.get_handlers_by_module_name(metadata.module_path)
for handler in related_handlers:
logger.debug(f"bind handler {handler.handler_name} to {metadata.name}")
handler.handler = functools.partial(handler.handler, metadata.star_cls)
# 绑定 llm_tool handler
for func_tool in llm_tools.func_list:
@@ -244,8 +280,7 @@ class PluginManager:
obj = getattr(module, classes[0])(context=self.context) # 实例化插件类
metadata = None
plugin_path = os.path.join(self.plugin_store_path, root_dir_name) if not reserved else os.path.join(self.reserved_plugin_path, root_dir_name)
metadata = self._load_plugin_metadata(plugin_path=plugin_path, plugin_obj=obj)
metadata = self._load_plugin_metadata(plugin_path=plugin_dir_path, plugin_obj=obj)
metadata.star_cls = obj
metadata.config = plugin_config
metadata.module = module
@@ -255,16 +290,17 @@ class PluginManager:
metadata.module_path = path
star_map[path] = metadata
star_registry.append(metadata)
logger.debug(f"插件 {root_dir_name} 载入成功。")
# 禁用/启用插件
if metadata.module_path in inactivated_plugins:
metadata.activated = False
# 检查并且植入自定义的权限过滤器(alter_cmd)
full_names = []
for handler in star_handlers_registry.get_handlers_by_module_name(metadata.module_path):
full_names.append(handler.handler_full_name)
# 检查并且植入自定义的权限过滤器(alter_cmd)
if metadata.name in alter_cmd and handler.handler_name in alter_cmd[metadata.name]:
# 注入权限过滤器
cmd_type = alter_cmd[metadata.name][handler.handler_name].get("permission", "member")
found_permission_filter = False
for filter_ in handler.event_filters:
@@ -280,21 +316,28 @@ class PluginManager:
logger.debug(f"插入权限过滤器 {cmd_type}{metadata.name}{handler.handler_name} 方法。")
metadata.star_handler_full_names = full_names
# 执行 initialize() 方法
if hasattr(metadata.star_cls, "initialize"):
await metadata.star_cls.initialize()
except BaseException as e:
traceback.print_exc()
fail_rec += f"加载 {path} 插件时出现问题,原因 {str(e)}\n"
logger.error(f"----- 插件 {root_dir_name} 载入失败 -----")
errors = traceback.format_exc()
for line in errors.split('\n'):
logger.error(f"| {line}")
logger.error("----------------------------------")
fail_rec += f"加载 {root_dir_name} 插件时出现问题,原因 {str(e)}\n"
# 清除 pip.main 导致的多余的 logging handlers
for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler)
if not fail_rec:
return True, None
else:
self.failed_plugin_info = fail_rec
return False, fail_rec
async def install_plugin(self, repo_url: str):
+52 -20
View File
@@ -27,22 +27,38 @@ class DifyAPIClient:
payload = locals()
payload.pop("self")
payload.pop("timeout")
logger.info(f"chat_messages payload: {payload}")
async with self.session.post(
url, json=payload, headers=self.headers, timeout=timeout
) as resp:
if resp.status != 200:
text = await resp.text()
raise Exception(f"chat_messages 请求失败:{resp.status}. {text}")
buffer = ""
while True:
data = await resp.content.read(8192) # 防止数据过大导致高水位报错
if not data:
# 保持原有的8192字节限制,防止数据过大导致高水位报错
chunk = await resp.content.read(8192)
if not chunk:
break
if not data.strip():
continue
elif data.startswith(b"data:"):
try:
json_ = json.loads(data[5:])
yield json_
except BaseException:
pass
buffer += chunk.decode('utf-8')
blocks = buffer.split('\n\n')
# 处理完整的数据块
for block in blocks[:-1]:
if block.strip() and block.startswith('data:'):
try:
json_str = block[5:] # 移除 "data:" 前缀
json_obj = json.loads(json_str)
yield json_obj
except json.JSONDecodeError as e:
logger.error(f"JSON解析错误: {str(e)}")
logger.error(f"原始数据块: {json_str}")
# 保留最后一个可能不完整的块
buffer = blocks[-1] if blocks else ""
async def workflow_run(
self,
inputs: Dict,
@@ -55,22 +71,38 @@ class DifyAPIClient:
payload = locals()
payload.pop("self")
payload.pop("timeout")
logger.info(f"workflow_run payload: {payload}")
async with self.session.post(
url, json=payload, headers=self.headers, timeout=timeout
) as resp:
if resp.status != 200:
text = await resp.text()
raise Exception(f"workflow_run 请求失败:{resp.status}. {text}")
buffer = ""
while True:
data = await resp.content.read(8192) # 防止数据过大导致高水位报错
if not data:
# 保持原有的8192字节限制,防止数据过大导致高水位报错
chunk = await resp.content.read(8192)
if not chunk:
break
if not data.strip():
continue
elif data.startswith(b"data:"):
try:
json_ = json.loads(data[5:])
yield json_
except BaseException:
pass
buffer += chunk.decode('utf-8')
blocks = buffer.split('\n\n')
# 处理完整的数据块
for block in blocks[:-1]:
if block.strip() and block.startswith('data:'):
try:
json_str = block[5:] # 移除 "data:" 前缀
json_obj = json.loads(json_str)
yield json_obj
except json.JSONDecodeError as e:
logger.error(f"JSON解析错误: {str(e)}")
logger.error(f"原始数据块: {json_str}")
# 保留最后一个可能不完整的块
buffer = blocks[-1] if blocks else ""
async def file_upload(
self,
file_path: str,
@@ -14,11 +14,22 @@ class NetworkRenderStrategy(RenderStrategy):
base_url = ASTRBOT_T2I_DEFAULT_ENDPOINT
self.BASE_RENDER_URL = base_url
self.TEMPLATE_PATH = os.path.join(os.path.dirname(__file__), "template")
if self.BASE_RENDER_URL.endswith("/"):
self.BASE_RENDER_URL = self.BASE_RENDER_URL[:-1]
if not self.BASE_RENDER_URL.endswith("text2img"):
self.BASE_RENDER_URL += "/text2img"
def set_endpoint(self, base_url: str):
if not base_url:
base_url = ASTRBOT_T2I_DEFAULT_ENDPOINT
self.BASE_RENDER_URL = base_url
if self.BASE_RENDER_URL.endswith("/"):
self.BASE_RENDER_URL = self.BASE_RENDER_URL[:-1]
if not self.BASE_RENDER_URL.endswith("text2img"):
self.BASE_RENDER_URL += "/text2img"
async def render_custom_template(self, tmpl_str: str, tmpl_data: dict, return_url: bool=True) -> str:
'''使用自定义文转图模板'''
+1 -1
View File
@@ -110,7 +110,7 @@ class RepoZipUpdator():
releases = await self.fetch_release_info(url=release_url)
if not releases:
# download from the default branch directly.
logger.info(f"未在仓库 {author}/{repo} 中找到任何发布版本,正在从默认分支下载。")
logger.info(f"正在从默认分支下载 {author}/{repo} ")
release_url = f"https://github.com/{author}/{repo}/archive/refs/heads/master.zip"
else:
release_url = releases[0]['zipball_url']
+83 -8
View File
@@ -1,11 +1,16 @@
import traceback
import aiohttp
import uuid
from .route import Route, Response, RouteContext
from astrbot.core import logger
from quart import request
from astrbot.core.star.star_manager import PluginManager
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.star.star_handler import star_handlers_registry
from astrbot.core.star.filter.command import CommandFilter
from astrbot.core.star.filter.command_group import CommandGroupFilter
from astrbot.core.star.filter.permission import PermissionTypeFilter
from astrbot.core.star.filter.regex import RegexFilter
from astrbot.core.star.star_handler import EventType
class PluginRoute(Route):
def __init__(self, context: RouteContext, core_lifecycle: AstrBotCoreLifecycle, plugin_manager: PluginManager) -> None:
@@ -18,11 +23,33 @@ class PluginRoute(Route):
'/plugin/uninstall': ('POST', self.uninstall_plugin),
'/plugin/market_list': ('GET', self.get_online_plugins),
'/plugin/off': ('POST', self.off_plugin),
'/plugin/on': ('POST', self.on_plugin)
'/plugin/on': ('POST', self.on_plugin),
'/plugin/reload': ('POST', self.reload_plugins),
}
self.core_lifecycle = core_lifecycle
self.plugin_manager = plugin_manager
self.register_routes()
self.translated_event_type = {
EventType.AdapterMessageEvent: "平台消息下发时",
EventType.OnLLMRequestEvent: "LLM 请求时",
EventType.OnLLMResponseEvent: "LLM 响应后",
EventType.OnDecoratingResultEvent: "回复消息前",
EventType.OnCallingFuncToolEvent: "函数工具",
EventType.OnAfterMessageSentEvent: "发送消息后"
}
async def reload_plugins(self):
data = await request.json
plugin_name = data.get("name", None)
try:
success, message = await self.plugin_manager.reload(plugin_name)
if not success:
return Response().error(message).__dict__
return Response().ok(None, "重载成功。").__dict__
except Exception as e:
logger.error(f"/api/plugin/reload: {traceback.format_exc()}")
return Response().error(str(e)).__dict__
async def get_online_plugins(self):
custom = request.args.get("custom_registry")
@@ -31,7 +58,6 @@ class PluginRoute(Route):
urls = [custom]
else:
urls = [
"https://soulter.github.io/AstrBot_Plugins_Collection/plugins.json",
"https://api.soulter.top/astrbot/plugins"
]
@@ -59,10 +85,59 @@ class PluginRoute(Route):
"desc": plugin.desc,
"version": plugin.version,
"reserved": plugin.reserved,
"activated": plugin.activated
"activated": plugin.activated,
"online_vesion": "",
"handlers": await self.get_plugin_handlers_info(plugin.star_handler_full_names),
}
_plugin_resp.append(_t)
return Response().ok(_plugin_resp).__dict__
return Response().ok(_plugin_resp, message=self.plugin_manager.failed_plugin_info).__dict__
async def get_plugin_handlers_info(self, handler_full_names: list[str]):
'''解析插件行为'''
handlers = []
for handler_full_name in handler_full_names:
info = {}
handler = star_handlers_registry.star_handlers_map.get(handler_full_name, None)
if handler is None:
continue
info["event_type"] = handler.event_type.name
info["event_type_h"] = self.translated_event_type.get(handler.event_type, handler.event_type.name)
info["handler_full_name"] = handler.handler_full_name
info["desc"] = handler.desc
info["handler_name"] = handler.handler_name
if handler.event_type == EventType.AdapterMessageEvent:
# 处理平台适配器消息事件
has_admin = False
for filter in handler.event_filters: # 正常handler就只有 1~2 个 filter,因此这里时间复杂度不会太高
if isinstance(filter, CommandFilter):
info["type"] = "指令"
info["cmd"] = filter.command_name
elif isinstance(filter, CommandGroupFilter):
info["type"] = "指令组"
info["cmd"] = filter.group_name
info["sub_command"] = filter.print_cmd_tree(filter.sub_command_filters)
elif isinstance(filter, RegexFilter):
info["type"] = "正则匹配"
info["cmd"] = filter.regex_str
elif isinstance(filter, PermissionTypeFilter):
has_admin = True
info["has_admin"] = has_admin
if "cmd" not in info:
info["cmd"] = "未知"
if "type" not in info:
info["type"] = "事件监听器"
else:
info["cmd"] = "自动触发"
info["type"] = ""
if not info["desc"]:
info["desc"] = "无描述"
handlers.append(info)
return handlers
async def install_plugin(self):
post_data = await request.json
@@ -114,7 +189,7 @@ class PluginRoute(Route):
logger.info(f"更新插件 {plugin_name} 成功。")
return Response().ok(None, "更新成功。").__dict__
except Exception as e:
logger.error(f"/api/extensions/update: {traceback.format_exc()}")
logger.error(f"/api/plugin/update: {traceback.format_exc()}")
return Response().error(str(e)).__dict__
async def off_plugin(self):
@@ -125,7 +200,7 @@ class PluginRoute(Route):
logger.info(f"停用插件 {plugin_name}")
return Response().ok(None, "停用成功。").__dict__
except Exception as e:
logger.error(f"/api/extensions/off: {traceback.format_exc()}")
logger.error(f"/api/plugin/off: {traceback.format_exc()}")
return Response().error(str(e)).__dict__
async def on_plugin(self):
@@ -136,5 +211,5 @@ class PluginRoute(Route):
logger.info(f"启用插件 {plugin_name}")
return Response().ok(None, "启用成功。").__dict__
except Exception as e:
logger.error(f"/api/extensions/on: {traceback.format_exc()}")
logger.error(f"/api/plugin/on: {traceback.format_exc()}")
return Response().error(str(e)).__dict__
+19
View File
@@ -0,0 +1,19 @@
# What's Changed
> 由于重写了会话记录部分,更新此版本后,将会造成之前的对话记录清空(但没有被删除)。
> 关于更好的对话管理,如果有任何报错或者优化建议,请直接提交 issue~
1. 修复 reminder 时区问题
2. 面板支持重载单个插件 #297
3. 面板支持列表展示插件市场
4. 文字转图片支持自定义字数阈值(配置->其他配置)
5. 面板更好的列表可视化 #274
6. 面板支持查看插件行为
7. 支持设置 timeout 超时时间参数,防止思考模型太长达到超时时间。(需要重新配置服务提供商或者在服务提供商 config 中配置 timeout 参数) #378
8. openrouter 报错 no endpoints found that support tool use #371
9. 修复插件 metadata 不生效的问题
10. 修复不支持图片的模型请求异常
11. 修复 reminder 无法删除的问题
12. 修复 /model 切换不了模型的问题
13. 插件支持设置优先级
14. 聊天增强图像转述支持自定义 provider id。#274
+12
View File
@@ -0,0 +1,12 @@
# What's Changed
1. fix: 400 Bad Request: The browser (or proxy) sent a request that this server could not understand. #396
2. remove: 移除了 put_history_to_prompt。当主动回复时,将群聊记录将自动放入prompt,当未主动回复但是开启群聊增强时,群聊记录将放入system prompt
3. fix: 插件错误信息点击关闭没反应 #394
4. fix: 自部署文转图不生效 #352
5. fix: Google Search 报 429 错误时,放宽 Exception 至其他搜索引擎 #405
6. fix: 使用 Google Gemini OpenAI 兼容)的部分情况下联网搜索等函数调用工具没被调用 #342
7. fix: 修复尝试弹出最早的记录失效的问题
8. fix: 移除了分段回复llm提示词辅助
9. perf: 当图片数据为空时不加入上下文 #379
10. 修复 dify 返回的结果带有多行数据时的 json 解析异常导致返回值为空的问题 #298 by @zhaolj
+11
View File
@@ -0,0 +1,11 @@
# What's Changed
0. ✨ 新增: 支持 海豚 AIFishAudio TTS API #433 by @Cvandia
1. 🐛 修复: 当群聊主动回复时,不会带上人格的Prompt #419
2. ✨ 新增: 支持展示插件是否有更新
3. 👌 优化: 增加DIFY超时时间 #422
4. 🐛 修复: 自部署文转图不生效 #352
5. 🐛 修复: 修复 qq 回复别人的时候也会触发机器人, Onebot at 使用 string #330
6. 👌 优化: 增加DIFY超时时间 #422
7. 🐛 修复: 重启gewe的时候机器人会疯狂发消息 #421
8. 🐛 修复: 修复子指令设置permission之后会导致其一定会被执行 #427
+1 -1
View File
@@ -32,7 +32,7 @@
"vue-router": "4.2.4",
"vue3-apexcharts": "1.4.4",
"vue3-print-nb": "0.1.4",
"vuetify": "3.3.14",
"vuetify": "3.7.11",
"yup": "1.2.0"
},
"devDependencies": {
@@ -1,22 +1,26 @@
<template>
<h3 style="margin-bottom: 8px;" v-if="iterable && metadata[metadataKey]?.type === 'object'">
{{metadata[metadataKey]?.description }}
{{ metadata[metadataKey]?.description }}
</h3>
<v-card-text>
<div v-for="(index, key) in iterable" :key="key" style="margin-bottom: 0.5px;" v-if="metadata[metadataKey]?.type === 'object' || metadata[metadataKey]?.config_template">
<v-alert v-if="metadata[metadataKey].items[key]?.obvious_hint && metadata[metadataKey].items[key]?.hint" style="margin-bottom: 16px"
:text="metadata[metadataKey].items[key]?.hint" :title="'💡 关于' + metadata[metadataKey].items[key]?.description"
type="info" variant="tonal">
<div v-for="(index, key) in iterable" :key="key" style="margin-bottom: 0.5px;"
v-if="metadata[metadataKey]?.type === 'object' || metadata[metadataKey]?.config_template">
<v-alert v-if="metadata[metadataKey].items[key]?.obvious_hint && metadata[metadataKey].items[key]?.hint"
style="margin-bottom: 16px" :text="metadata[metadataKey].items[key]?.hint"
:title="'💡 关于' + metadata[metadataKey].items[key]?.description" type="info" variant="tonal">
</v-alert>
<div style="display: flex; align-items: center; justify-content: center; gap: 16px">
<div style="width: 100%;" v-if="metadata[metadataKey].items[key]">
<v-select v-if="metadata[metadataKey].items[key]?.options && !metadata[metadataKey].items[key]?.invisible" v-model="iterable[key]"
variant="outlined" :items="metadata[metadataKey].items[key]?.options"
:label="metadata[metadataKey].items[key]?.description + '(' + key + ')'" dense :disabled="metadata[metadataKey].items[key]?.readonly"></v-select>
<v-text-field v-else-if="metadata[metadataKey].items[key]?.type === 'string' && !metadata[metadataKey].items[key]?.invisible"
<v-select
v-if="metadata[metadataKey].items[key]?.options && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]" variant="outlined" :items="metadata[metadataKey].items[key]?.options"
:label="metadata[metadataKey].items[key]?.description + '(' + key + ')'" dense
:disabled="metadata[metadataKey].items[key]?.readonly"></v-select>
<v-text-field
v-else-if="metadata[metadataKey].items[key]?.type === 'string' && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]" :label="metadata[metadataKey].items[key]?.description + '(' + key + ')'"
variant="outlined" dense ></v-text-field>
variant="outlined" dense></v-text-field>
<v-text-field
v-else-if="(metadata[metadataKey].items[key]?.type === 'int' || metadata[metadataKey].items[key]?.type === 'float') && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]" :label="metadata[metadataKey].items[key]?.description + '(' + key + ')'"
@@ -27,17 +31,11 @@
<v-switch v-else-if="metadata[metadataKey].items[key]?.type === 'bool' && !metadata[metadataKey].items[key]?.invisible" v-model="iterable[key]"
:label="metadata[metadataKey].items[key]?.description + '(' + key + ')'" color="primary"
inset></v-switch>
<v-combobox variant="outlined" v-else-if="metadata[metadataKey].items[key]?.type === 'list' && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]" chips clearable
:label="metadata[metadataKey].items[key]?.description + '(' + key + ')'" multiple
prepend-icon="mdi-tag-multiple-outline">
<template v-slot:selection="{ attrs, item, select, selected }">
<v-chip v-bind="attrs" :model-value="selected" closable @click="select"
@click:close="remove(item)">
<strong>{{ item }}</strong>
</v-chip>
</template>
</v-combobox>
<ListConfigItem
v-else-if="metadata[metadataKey].items[key]?.type === 'list' && !metadata[metadataKey].items[key]?.invisible"
:value="iterable[key]"
:label="metadata[metadataKey].items[key]?.description + '(' + key + ')'"/>
<div v-else-if="metadata[metadataKey].items[key]?.type === 'object' && !metadata[metadataKey].items[key]?.invisible"
style="border: 1px solid #e0e0e0; padding: 8px; margin-bottom: 16px; border-radius: 10px;">
<AstrBotConfig :metadata="metadata[metadataKey].items" :iterable="iterable[key]"
@@ -59,44 +57,47 @@
}}</v-tooltip>
</v-btn>
</div>
<div>
<v-chip v-if="!metadata[metadataKey].items[key]?.invisible" color="primary">{{ metadata[metadataKey].items[key]?.type }}</v-chip>
</div>
</div>
</div>
<div v-else>
<v-alert v-if="metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint" style="margin-bottom: 16px"
:text="metadata[metadataKey]?.hint" :title="'💡 关于' + metadata[metadataKey]?.description"
type="info" variant="tonal">
<v-alert v-if="metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint"
style="margin-bottom: 16px" :text="metadata[metadataKey]?.hint"
:title="'💡 关于' + metadata[metadataKey]?.description" type="info" variant="tonal">
</v-alert>
<div style="display: flex; align-items: center; justify-content: center; gap: 16px">
<div style="width: 100%;">
<v-select v-if="metadata[metadataKey]?.options && !metadata[metadataKey]?.invisible" v-model="iterable[metadataKey]"
variant="outlined" :items="metadata[metadataKey]?.options"
:label="metadata[metadataKey]?.description + '(' + metadataKey+ ')'" dense :disabled="metadata[metadataKey]?.readonly"></v-select>
<v-text-field v-else-if="metadata[metadataKey]?.type === 'string' && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]" :label="metadata[metadataKey]?.description + '(' + metadataKey+ ')'"
variant="outlined" dense ></v-text-field>
<v-select v-if="metadata[metadataKey]?.options && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]" variant="outlined" :items="metadata[metadataKey]?.options"
:label="metadata[metadataKey]?.description + '(' + metadataKey + ')'" dense
:disabled="metadata[metadataKey]?.readonly"></v-select>
<v-text-field
v-else-if="metadata[metadataKey]?.type === 'string' && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]"
:label="metadata[metadataKey]?.description + '(' + metadataKey + ')'" variant="outlined"
dense></v-text-field>
<v-text-field
v-else-if="(metadata[metadataKey]?.type === 'int' || metadata[metadataKey]?.type === 'float') && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]" :label="metadata[metadataKey]?.description + '(' + metadataKey+ ')'"
variant="outlined" dense></v-text-field>
<v-textarea v-else-if="metadata[metadataKey]?.type === 'text' && !metadata[metadataKey]?.invisible" v-model="iterable[metadataKey]"
:label="metadata[metadataKey]?.description + '(' + metadataKey+ ')'" variant="outlined"
v-model="iterable[metadataKey]"
:label="metadata[metadataKey]?.description + '(' + metadataKey + ')'" variant="outlined"
dense></v-text-field>
<v-textarea v-else-if="metadata[metadataKey]?.type === 'text' && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]"
:label="metadata[metadataKey]?.description + '(' + metadataKey + ')'" variant="outlined"
dense></v-textarea>
<v-switch v-else-if="metadata[metadataKey]?.type === 'bool' && !metadata[metadataKey]?.invisible" v-model="iterable[metadataKey]"
:label="metadata[metadataKey]?.description + '(' + metadataKey+ ')'" color="primary"
<v-switch v-else-if="metadata[metadataKey]?.type === 'bool' && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]"
:label="metadata[metadataKey]?.description + '(' + metadataKey + ')'" color="primary"
inset></v-switch>
<v-combobox variant="outlined" v-else-if="metadata[metadataKey]?.type === 'list' && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]" chips clearable
:label="metadata[metadataKey]?.description + '(' + metadataKey+ ')'" multiple
prepend-icon="mdi-tag-multiple-outline">
<template v-slot:selection="{ attrs, item, select, selected }">
<v-chip v-bind="attrs" :model-value="selected" closable @click="select"
@click:close="remove(item)">
<strong>{{ item }}</strong>
</v-chip>
</template>
</v-combobox>
<ListConfigItem
v-else-if="metadata[metadataKey]?.type === 'list' && !metadata[metadataKey]?.invisible"
:value="iterable[metadataKey]"
:label="metadata[metadataKey]?.description + '(' + metadataKey+ ')'"/>
<div v-else-if="metadata[metadataKey]?.type === 'object' && !metadata[metadataKey]?.invisible"
style="border: 1px solid #e0e0e0; padding: 8px; margin-bottom: 16px; border-radius: 10px;">
<AstrBotConfig :metadata="metadata[metadataKey].items" :iterable="iterable[metadataKey]"
@@ -113,6 +114,10 @@
}}</v-tooltip>
</v-btn>
</div>
<div>
<v-chip v-if="!metadata[metadataKey]?.invisible" color="primary">{{ metadata[metadataKey]?.type }}</v-chip>
</div>
</div>
</div>
</v-card-text>
@@ -120,8 +125,12 @@
<script>
import { readonly } from 'vue';
import ListConfigItem from './ListConfigItem.vue';
export default {
components: {
ListConfigItem
},
props: {
metadata: Object,
iterable: Object,
@@ -2,7 +2,8 @@
const props = defineProps({
title: String,
link: String,
logo: String
logo: String,
has_update: Boolean,
});
const open = (link: string | undefined) => {
@@ -12,16 +13,17 @@ const open = (link: string | undefined) => {
<template>
<v-card variant="outlined" elevation="0" class="withbg">
<v-card-item style="padding: 10px 14px">
<v-card-item style="padding: 10px 12px">
<div class="d-sm-flex align-center justify-space-between">
<img v-if="logo" :src="logo" alt="logo" style="width: 40px; height: 40px; margin-right: 8px;">
<v-card-title style="font-size: 17px;">{{ props.title }}</v-card-title>
<v-card-title style="font-size: 16px;">{{ props.title }}</v-card-title>
<v-spacer></v-spacer>
<v-btn variant="plain" @click="open(props.link)">仓库</v-btn>
<v-icon color="success" v-if="has_update">mdi-arrow-up-bold</v-icon>
<v-btn size="small" text="Read" variant="flat" border @click="open(props.link)">帮助</v-btn>
</div>
</v-card-item>
<v-divider></v-divider>
<v-card-text>
<v-card-text style="padding: 16px;">
<slot />
</v-card-text>
</v-card>
@@ -0,0 +1,93 @@
<template>
<div class="list-config-item">
<h3>{{ label }}</h3>
<v-list dense style="background-color: transparent;max-height: 300px; overflow-y: scroll;" >
<v-list-item v-for="(item, index) in items" :key="index">
<v-list-item-content style="display: flex; justify-content: space-between;">
<v-list-item-title>
<v-chip>{{ item }}</v-chip>
</v-list-item-title>
<v-btn @click="removeItem(index)" variant="plain">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-list-item-content>
</v-list-item>
</v-list>
<v-text-field
v-model="newItem"
label="添加新项,按回车确认添加"
@keyup.enter="addItem"
clearable
dense
hide-details
variant="outlined"
></v-text-field>
</div>
</template>
<script>
export default {
name: 'ListConfigItem',
props: {
value: {
type: Array,
default: () => [],
},
label: {
type: String,
default: '',
},
},
data() {
return {
newItem: '',
items: this.value,
};
},
watch: {
items(newVal) {
this.$emit('input', newVal);
},
},
methods: {
addItem() {
if (this.newItem.trim() !== '') {
this.items.push(this.newItem.trim());
this.newItem = '';
}
},
removeItem(index) {
this.items.splice(index, 1);
},
},
};
</script>
<style scoped>
.list-config-item {
border: 1px solid #e0e0e0;
padding: 16px;
margin-bottom: 16px;
border-radius: 10px;
background-color: #ffffff;
}
.list-config-item h3 {
margin-top: 0;
margin-bottom: 16px;
font-size: 18px;
font-weight: 500;
}
.v-list-item {
padding: 0;
}
.v-list-item-title {
font-size: 14px;
}
.v-btn {
margin-left: 8px;
}
</style>
@@ -9,7 +9,7 @@ const sidebarMenu = shallowRef(sidebarItems);
</script>
<template>
<v-navigation-drawer left v-model="customizer.Sidebar_drawer" elevation="0" rail-width="80" mobile-breakpoint="960"
<v-navigation-drawer left v-model="customizer.Sidebar_drawer" elevation="0" rail-width="80"
app class="leftSidebar" :rail="customizer.mini_sidebar">
<v-list class="pa-4 listitem" style="height: auto">
<template v-for="(item, i) in sidebarMenu" :key="i">
+15 -1
View File
@@ -68,7 +68,7 @@ import config from '@/config';
<v-tabs-window-item v-for="(config_item, index) in config_data[key2]" v-show="config_template_tab === index"
:key="index" :value="index">
<v-container>
<v-btn variant="tonal" rounded="xl" color="error" @click="config_data[key2].splice(index, 1)">
<v-btn variant="tonal" rounded="xl" color="error" @click="deleteItem(key2, index)">
删除这项
</v-btn>
@@ -215,6 +215,20 @@ export default {
// new_tmpl_cfg.id = "new_" + val + "_" + this.config_data[config_item_name].length;
this.config_data[config_item_name].push(new_tmpl_cfg);
this.config_template_tab = this.config_data[config_item_name].length - 1;
},
deleteItem(config_item_name, index) {
console.log(config_item_name, index);
let new_list = [];
for (let i = 0; i < this.config_data[config_item_name].length; i++) {
if (i !== index) {
new_list.push(this.config_data[config_item_name][i]);
}
}
this.config_data[config_item_name] = new_list;
if (this.config_template_tab > 0) {
this.config_template_tab -= 1;
}
}
},
}
+191 -39
View File
@@ -4,6 +4,7 @@ import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
import axios from 'axios';
import { max } from 'date-fns';
</script>
@@ -14,30 +15,70 @@ import axios from 'axios';
</v-alert>
<v-col cols="12" md="12">
<div style="background-color: white; width: 100%; padding: 16px; border-radius: 10px;">
<h3>🧩 已安装的插件</h3>
<div style="display: flex; align-items: center;">
<h3>🧩 已安装的插件</h3>
<v-dialog max-width="500px">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" v-if="extension_data.message" icon size="small" color="error"
style="margin-left: auto;" variant="plain">
<v-icon>mdi-alert-circle</v-icon>
</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-card>
</template>
</v-dialog>
</div>
</div>
</v-col>
<v-col cols="12" md="6" lg="3" v-for="extension in extension_data.data">
<ExtensionCard :key="extension.name" :title="extension.name" :link="extension.repo" :logo="extension?.logo"
<ExtensionCard :key="extension.name" :title="extension.name" :link="extension.repo" :logo="extension?.logo" :has_update="extension.has_update"
style="margin-bottom: 4px;">
<p style="min-height: 130px; max-height: 130px; overflow: none;">{{ extension.desc }}</p>
<div class="d-flex align-center gap-2">
<v-icon>mdi-account</v-icon>
<span>{{ extension.author }}</span>
<v-spacer></v-spacer>
<div v-if="!extension.reserved">
<v-btn class="text-none mr-2" size="small" text="Read" variant="flat" border
@click="openExtensionConfig(extension.name)">配置</v-btn>
<v-btn class="text-none mr-2" size="small" text="Read" variant="flat" border
@click="updateExtension(extension.name)">更新</v-btn>
<v-btn class="text-none mr-2" size="small" text="Read" variant="flat" border
@click="uninstallExtension(extension.name)">卸载</v-btn>
<div style="min-height: 140px; max-height: 140px; overflow: none;">
<div>
<span style="font-weight: bold;">By @{{ extension.author }}</span>
<span> | 插件有 {{ extension.handlers.length }} 个行为</span>
</div>
<span> 当前: <v-chip size="small" color="primary">{{ extension.version }}</v-chip>
<span v-if="extension.online_version">
| 最新: <v-chip size="small" color="primary">{{ extension.online_version }}</v-chip>
</span>
<span v-if="extension.has_update" style="font-weight: bold;">有更新
</span>
</span>
<p style="margin-top: 8px;">{{ extension.desc }}</p>
<a style="font-size: 12px; cursor: pointer; text-decoration: underline; color: #555;"
@click="reloadPlugin(extension.name)">重载插件</a>
</div>
<div class="d-flex align-center gap-2 " style="overflow-x: auto;">
<v-btn v-if="!extension.reserved" class="text-none mr-2" size="small" text="Read" variant="flat" border
@click="openExtensionConfig(extension.name)">配置</v-btn>
<v-btn v-if="!extension.reserved" class="text-none mr-2" size="small" text="Read" variant="flat" border
@click="updateExtension(extension.name)">更新</v-btn>
<v-btn v-if="!extension.reserved" class="text-none mr-2" size="small" text="Read" variant="flat" border
@click="uninstallExtension(extension.name)">卸载</v-btn>
<!-- <span v-else>保留插件</span> -->
<v-btn class="text-none mr-2" size="small" text="Read" variant="flat" border v-if="extension.activated"
@click="pluginOff(extension)">禁用</v-btn>
<v-btn class="text-none mr-2" size="small" text="Read" variant="flat" border v-else
@click="pluginOn(extension)">启用</v-btn>
<v-btn class="text-none mr-2" size="small" text="Read" variant="flat" border
@click="showPluginInfo(extension)">行为</v-btn>
</div>
</ExtensionCard>
</v-col>
@@ -48,29 +89,44 @@ import axios from 'axios';
<small style="margin-left: 16px;">如无法显示请打开 <a
href="https://soulter.github.io/AstrBot_Plugins_Collection/plugins.json">链接</a> 复制想安装插件对应的 `repo`
链接然后点击右下角 + 号安装或打开链接下载压缩包安装</small>
<v-btn icon @click="isListView = !isListView" size="small" style="margin-left: auto;" variant="plain">
<v-icon>{{ isListView ? 'mdi-view-grid' : 'mdi-view-list' }}</v-icon>
</v-btn>
</div>
</div>
</v-col>
<v-col cols="12" md="12" v-if="announcement">
<v-banner color="success" lines="one" :text="announcement" :stacked="false" >
<v-banner color="success" lines="one" :text="announcement" :stacked="false">
</v-banner>
</v-col>
<v-col cols="12" md="6" lg="3" v-for="plugin in pluginMarketData">
<ExtensionCard :key="plugin.name" :title="plugin.name" :link="plugin.repo" style="margin-bottom: 4px;">
<p style="min-height: 130px; max-height: 130px; overflow: hidden;">{{ plugin.desc }}</p>
<div class="d-flex align-center gap-2">
<v-icon>mdi-account</v-icon>
<span>{{ plugin.author }}</span>
<v-spacer></v-spacer>
<v-btn v-if="!plugin.installed" class="text-none mr-2" size="small" text="Read" variant="flat" border
@click="extension_url = plugin.repo; newExtension()">安装</v-btn>
<v-btn v-else class="text-none mr-2" size="small" text="Read" variant="flat" border disabled>已安装</v-btn>
</div>
</ExtensionCard>
</v-col>
<template v-if="isListView">
<v-col cols="12" md="12">
<v-data-table :headers="pluginMarketHeaders" :items="pluginMarketData" item-key="name">
<template v-slot:item.actions="{ item }">
<v-btn v-if="!item.installed" class="text-none mr-2" size="small" text="Read" variant="flat" border
@click="extension_url = item.repo; newExtension()">安装</v-btn>
<v-btn v-else class="text-none mr-2" size="small" text="Read" variant="flat" border disabled>已安装</v-btn>
</template>
</v-data-table>
</v-col>
</template>
<template v-else>
<v-col cols="12" md="6" lg="3" v-for="plugin in pluginMarketData">
<ExtensionCard :key="plugin.name" :title="plugin.name" :link="plugin.repo" style="margin-bottom: 4px;">
<div style="min-height: 130px; max-height: 130px; overflow: hidden;">
<p style="font-weight: bold;">By @{{ plugin.author }}</p>
{{ plugin.desc }}
</div>
<div class="d-flex align-center gap-2">
<v-btn v-if="!plugin.installed" class="text-none mr-2" size="small" text="Read" variant="flat" border
@click="extension_url = plugin.repo; newExtension()">安装</v-btn>
<v-btn v-else class="text-none mr-2" size="small" text="Read" variant="flat" border disabled>已安装</v-btn>
</div>
</ExtensionCard>
</v-col>
</template>
<v-col style="margin-bottom: 16px;" cols="12" md="12">
<small><a href="https://astrbot.app/dev/plugin.html">插件开发文档</a></small> |
@@ -184,6 +240,44 @@ import axios from 'axios';
</v-card>
</v-dialog>
<v-dialog v-model="showPluginInfoDialog" width="1200">
<template v-slot:activator="{ props }">
</template>
<v-card>
<v-card-title>
<span class="text-h5">{{ selectedPlugin.name }} 插件行为</span>
</v-card-title>
<v-card-text>
<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>
</template>
<template v-slot:item.event_type="{ item }">
{{ item.event_type }}
</template>
<template v-slot:item.desc="{ item }">
{{ item.desc }}
</template>
<template v-slot:item.type="{ item }">
<v-chip color="success">
{{ item.type }}
</v-chip>
</template>
<template v-slot:item.cmd="{ item }">
<span style="font-weight: bold;">{{ item.cmd }}</span>
</template>
</v-data-table>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="blue-darken-1" variant="text" @click="showPluginInfoDialog = false">
关闭
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar :timeout="2000" elevation="24" :color="snack_success" v-model="snack_show">
{{ snack_message }}
</v-snackbar>
@@ -204,7 +298,8 @@ export default {
data() {
return {
extension_data: {
"data": []
"data": [],
"message": ""
},
extension_url: "",
status: "",
@@ -227,7 +322,24 @@ export default {
result: ""
},
announcement: ""
announcement: "",
showPluginInfoDialog: false,
selectedPlugin: {},
plugin_handler_info_headers: [
{ title: '行为类型', key: 'event_type_h' },
{ title: '描述', key: 'desc', maxWidth: '250px' },
{ title: '具体类型', key: 'type' },
{ title: '触发方式', key: 'cmd' },
],
isListView: false,
pluginMarketHeaders: [
{ title: '名称', value: 'name' },
{ title: '描述', value: 'desc' },
{ title: '作者', value: 'author' },
{ title: '操作', value: 'actions', sortable: false }
],
alreadyCheckUpdate: false
}
},
mounted() {
@@ -265,10 +377,30 @@ export default {
},
getExtensions() {
axios.get('/api/plugin/get').then((res) => {
this.extension_data.data = res.data.data;
this.extension_data = res.data;
this.checkAlreadyInstalled();
this.checkUpdate()
});
},
checkUpdate() {
// 遍历 extension_data 和 pluginMarketData,检查是否有更新\
for (let i = 0; i < this.extension_data.data.length; i++) {
for (let j = 0; j < this.pluginMarketData.length; j++) {
console.log(this.extension_data.data[i].repo, this.pluginMarketData[j].repo);
if (this.extension_data.data[i].repo === this.pluginMarketData[j].repo ||
this.extension_data.data[i].name === this.pluginMarketData[j].name) {
this.extension_data.data[i].online_version = this.pluginMarketData[j].version;
if (this.extension_data.data[i].version !== this.pluginMarketData[j].version && this.pluginMarketData[j].version !== "未知") {
this.extension_data.data[i].has_update = true;
} else {
this.extension_data.data[i].has_update = false;
}
}
}
}
},
newExtension() {
if (this.extension_url === "" && this.upload_file === null) {
this.toast("请填写插件链接或上传插件文件", "error");
@@ -284,7 +416,7 @@ export default {
if (this.upload_file !== null) {
this.toast("正在从文件安装插件", "primary");
const formData = new FormData();
formData.append('file', this.upload_file[0]);
formData.append('file', this.upload_file);
axios.post('/api/plugin/install-upload', formData, {
headers: {
'Content-Type': 'multipart/form-data'
@@ -295,7 +427,7 @@ export default {
this.onLoadingDialogResult(2, res.data.message, -1);
return;
}
this.extension_data.data = res.data.data;
this.extension_data = res.data;
this.upload_file = "";
this.onLoadingDialogResult(1, res.data.message);
this.dialog = false;
@@ -316,8 +448,7 @@ export default {
this.onLoadingDialogResult(2, res.data.message, -1);
return;
}
this.extension_data.data = res.data.data;
console.log(this.extension_data);
this.extension_data = res.data;
this.extension_url = "";
this.onLoadingDialogResult(1, res.data.message);
this.dialog = false;
@@ -339,7 +470,7 @@ export default {
this.toast(res.data.message, "error");
return;
}
this.extension_data.data = res.data.data;
this.extension_data = res.data;
this.toast(res.data.message, "success");
this.dialog = false;
this.getExtensions();
@@ -357,7 +488,7 @@ export default {
this.onLoadingDialogResult(2, res.data.message, -1);
return;
}
this.extension_data.data = res.data.data;
this.extension_data = res.data;
console.log(this.extension_data);
this.onLoadingDialogResult(1, res.data.message);
this.dialog = false;
@@ -428,11 +559,13 @@ export default {
"desc": res.data.data[key].desc,
"author": res.data.data[key].author,
"repo": res.data.data[key].repo,
"installed": false
"installed": false,
"version": res.data.data[key]?.version ? res.data.data[key].version : "未知",
})
}
this.pluginMarketData = data;
this.checkAlreadyInstalled();
this.checkUpdate();
}).catch((err) => {
this.toast("获取插件市场数据失败: " + err, "error");
});
@@ -460,6 +593,25 @@ export default {
}
}
this.pluginMarketData = notInstalled.concat(installed);
},
showPluginInfo(plugin) {
this.selectedPlugin = plugin;
this.showPluginInfoDialog = true;
},
reloadPlugin(plugin_name) {
axios.post('/api/plugin/reload',
{
name: plugin_name
}).then((res) => {
if (res.data.status === "error") {
this.onLoadingDialogResult(2, res.data.message, -1);
return;
}
this.toast("重载成功", "success");
this.getExtensions();
}).catch((err) => {
this.toast(err, "error");
});
}
},
}
+11 -4
View File
@@ -25,6 +25,7 @@ class LongTermMemory:
self.max_cnt = 300
self.image_caption = self.config["image_caption"]
self.image_caption_prompt = self.config["image_caption_prompt"]
self.image_caption_provider_id = self.config["image_caption_provider_id"]
self.active_reply = self.config["active_reply"]
self.enable_active_reply = self.active_reply.get("enable", False)
@@ -32,7 +33,7 @@ class LongTermMemory:
self.ar_possibility = self.active_reply["possibility_reply"]
self.ar_prompt = self.active_reply.get("prompt", "")
self.put_history_to_prompt = self.config["put_history_to_prompt"]
# self.put_history_to_prompt = self.config["put_history_to_prompt"]
async def remove_session(self, event: AstrMessageEvent) -> int:
cnt = 0
@@ -42,7 +43,13 @@ class LongTermMemory:
return cnt
async def get_image_caption(self, image_url: str) -> str:
provider = self.context.get_using_provider()
if not self.image_caption_provider_id:
provider = self.context.get_using_provider()
else:
provider = self.context.get_provider_by_id(self.image_caption_provider_id)
if not provider:
raise Exception(f"没有找到 ID 为 {self.image_caption_provider_id} 的提供商")
response = await provider.text_chat(
prompt=self.image_caption_prompt,
session_id=uuid.uuid4().hex,
@@ -103,11 +110,11 @@ class LongTermMemory:
chats_str = '\n---\n'.join(self.session_chats[event.unified_msg_origin])
if self.put_history_to_prompt:
if self.enable_active_reply:
prompt = req.prompt
req.prompt = f"You are now in a chatroom. The chat history is as follows:\n{chats_str}"
req.prompt += f"\nNow, a new message is coming: `{prompt}`. Please react to it. Only output your response and do not output any other information."
req.contexts = [] # 清空上下文,当使用了群聊增强,所有聊天记录都在一个prompt中。
req.contexts = [] # 清空上下文,当使用了主动回复,所有聊天记录都在一个prompt中。
else:
req.system_prompt += "You are now in a chatroom. The chat history is as follows: \n"
req.system_prompt += chats_str
+15 -8
View File
@@ -6,6 +6,7 @@ import astrbot.api.star as star
import astrbot.api.event.filter as filter
from astrbot.api.event import AstrMessageEvent, MessageEventResult
from astrbot.api import sp
from astrbot.api.platform import MessageType
from astrbot.api.provider import Personality, ProviderRequest, LLMResponse
from astrbot.core.utils.io import download_dashboard, get_dashboard_version
from astrbot.core.star.star_handler import star_handlers_registry, StarHandlerMetadata
@@ -80,6 +81,7 @@ AstrBot 指令:
/persona: 人格情景(op)
/tool ls: 函数工具
/key: API Key(op)
/websearch: 网页搜索
[其他]
/set <变量名> <值>: 为会话定义变量。适用于 Dify 工作流输入。
@@ -227,6 +229,7 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("wl")
async def wl(self, event: AstrMessageEvent, sid: str):
'''添加白名单。wl <sid>'''
self.context.get_config()['platform_settings']['id_whitelist'].append(sid)
self.context.get_config().save_config()
event.set_result(MessageEventResult().message("添加白名单成功。"))
@@ -234,6 +237,7 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("dwl")
async def dwl(self, event: AstrMessageEvent, sid: str):
'''删除白名单。dwl <sid>'''
try:
self.context.get_config()['platform_settings']['id_whitelist'].remove(sid)
self.context.get_config().save_config()
@@ -274,7 +278,7 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("reset")
async def reset(self, message: AstrMessageEvent):
'''重置 LLM 会话'''
if not self.context.get_using_provider():
message.set_result(MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"))
return
@@ -298,7 +302,7 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
@filter.command("model")
async def model_ls(self, message: AstrMessageEvent, idx_or_name: Union[int, str] = None):
'''查看或者切换模型'''
if not self.context.get_using_provider():
message.set_result(MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"))
return
@@ -339,7 +343,7 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
else:
self.context.get_using_provider().set_model(idx_or_name)
message.set_result(
MessageEventResult().message(f"切换模型成功。 \n模型信息: {idx_or_name}"))
MessageEventResult().message(f"切换模型{self.context.get_using_provider().get_model()}"))
@filter.command("history")
@@ -629,7 +633,7 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
session_curr_cid = await self.context.conversation_manager.get_curr_conversation_id(event.unified_msg_origin)
if not session_curr_cid:
logger.error("当前未处于对话状态,无法主动回复,请使用 /switch 切换或者 /new 创建。")
logger.error("当前未处于对话状态,无法主动回复,请确保 平台设置->会话隔离(unique_session) 未开启,并使用 /switch 切换或者 /new 创建一个会话")
return
conv = await self.context.conversation_manager.get_conversation(
@@ -646,7 +650,8 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
prompt=prompt,
func_tool_manager=self.context.get_llm_tool_manager(),
session_id=event.session_id,
contexts=history if history else []
contexts=history if history else [],
conversation=conv,
)
except BaseException as e:
logger.error(f"主动回复失败: {e}")
@@ -655,7 +660,6 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
@filter.on_llm_request()
async def decorate_llm_req(self, event: AstrMessageEvent, req: ProviderRequest):
'''在请求 LLM 前注入人格信息、Identifier、时间等 System Prompt'''
provider = self.context.get_using_provider()
if self.prompt_prefix:
req.prompt = self.prompt_prefix + req.prompt
@@ -666,8 +670,11 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
req.prompt = user_info + req.prompt
if self.enable_datetime:
req.system_prompt += f"\nCurrent datetime: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M')}\n"
tz_offset = datetime.timedelta(hours=8)
tz = datetime.timezone(tz_offset)
current_time = datetime.datetime.now(tz).strftime('%Y-%m-%d %H:%M')
req.system_prompt += f"\nCurrent datetime: {current_time}\n"
if req.conversation:
persona_id = req.conversation.persona_id
if not persona_id and persona_id != "[%None]": # [%None] 为用户取消人格
+45 -13
View File
@@ -1,6 +1,7 @@
import os
import json
import datetime
import uuid
import astrbot.api.star as star
from astrbot.api.event import filter
from apscheduler.schedulers.asyncio import AsyncIOScheduler
@@ -28,11 +29,18 @@ class Main(star.Star):
'''Initialize the scheduler.'''
for group in self.reminder_data:
for reminder in self.reminder_data[group]:
if 'id' not in reminder:
id_ = str(uuid.uuid4())
reminder['id'] = id_
else:
id_ = reminder['id']
if "datetime" in reminder:
if self.check_is_outdated(reminder):
continue
self.scheduler.add_job(
self._reminder_callback,
id=id_,
trigger='date',
args=[group, reminder],
run_date=datetime.datetime.strptime(reminder["datetime"], "%Y-%m-%d %H:%M"),
@@ -42,6 +50,7 @@ class Main(star.Star):
self.scheduler.add_job(
self._reminder_callback,
trigger='cron',
id=id_,
args=[group, reminder],
misfire_grace_time=60,
**self._parse_cron_expr(reminder["cron"])
@@ -69,11 +78,11 @@ class Main(star.Star):
}
@llm_tool("reminder")
async def reminder_tool(self, event: AstrMessageEvent, text: str, datetime_str: str = None, cron_expression: str = None, human_readable_cron: str = None):
async def reminder_tool(self, event: AstrMessageEvent, text: str=None, datetime_str: str = None, cron_expression: str = None, human_readable_cron: str = None):
'''Call this function when user ask for setting a reminder.
Args:
text(string): The content of the reminder.
text(string): Must Required. The content of the reminder.
datetime_str(string): Required when user's reminder is a single reminder. The datetime string of the reminder, Must format with %Y-%m-%d %H:%M
cron_expression(string): Required when user's reminder is a repeated reminder. The cron expression of the reminder.
human_readable_cron(string): Optional. The human readable cron expression of the reminder.
@@ -88,65 +97,88 @@ class Main(star.Star):
if not cron_expression and not datetime_str:
raise ValueError("The cron_expression and datetime_str cannot be both None.")
reminder_time = ""
if not text:
text = "未命名待办事项"
if cron_expression:
d = { "text": text, "cron": cron_expression, "cron_h": human_readable_cron }
d = { "text": text, "cron": cron_expression, "cron_h": human_readable_cron, "id": str(uuid.uuid4()) }
self.reminder_data[event.unified_msg_origin].append(d)
self.scheduler.add_job(
self._reminder_callback,
'cron',
id=d["id"],
misfire_grace_time=60,
**self._parse_cron_expr(cron_expression), args=[event.unified_msg_origin, d]
)
if human_readable_cron:
reminder_time = f"{human_readable_cron}(Cron: {cron_expression})"
else:
d = { "text": text, "datetime": datetime_str }
d = { "text": text, "datetime": datetime_str, "id": str(uuid.uuid4()) }
self.reminder_data[event.unified_msg_origin].append(d)
datetime_scheduled = datetime.datetime.strptime(datetime_str, "%Y-%m-%d %H:%M")
self.scheduler.add_job(
self._reminder_callback,
'date',
id=d["id"],
args=[event.unified_msg_origin, d],
run_date=datetime_scheduled,
misfire_grace_time=60
)
reminder_time = datetime_str
await self._save_data()
yield event.plain_result("成功设置待办事项。\n内容: " + text + "\n时间: " + reminder_time + "\n\n使用 /reminder ls 查看所有待办事项。")
yield event.plain_result("成功设置待办事项。\n内容: " + text + "\n时间: " + reminder_time + "\n\n使用 /reminder ls 查看所有待办事项。\n使用 /tool off reminder 关闭此功能。")
@filter.command_group("reminder")
def reminder(self):
'''The command group of the reminder.'''
pass
async def get_upcoming_reminders(self, unified_msg_origin: str):
'''Get upcoming reminders.'''
reminders = self.reminder_data.get(unified_msg_origin, [])
if not reminders:
return []
now = datetime.datetime.now()
upcoming_reminders = [
reminder for reminder in reminders
if "datetime" not in reminder or datetime.datetime.strptime(reminder["datetime"], "%Y-%m-%d %H:%M") >= now
]
return upcoming_reminders
@reminder.command("ls")
async def reminder_ls(self, event: AstrMessageEvent):
'''List all reminders.'''
reminders = self.reminder_data.get(event.unified_msg_origin, [])
'''List upcoming reminders.'''
reminders = await self.get_upcoming_reminders(event.unified_msg_origin)
if not reminders:
yield event.plain_result("没有待办事项。")
yield event.plain_result("没有正在进行的待办事项。")
else:
reminder_str = "待办事项:\n"
reminder_str = "正在进行的待办事项:\n"
for i, reminder in enumerate(reminders):
time_ = reminder.get("datetime", "")
if not time_:
cron_expr = reminder.get("cron", "")
time_ = reminder.get("cron_h", "") + f"(Cron: {cron_expr})"
reminder_str += f"{i + 1}. {reminder['text']} - {time_}\n"
reminder_str += "\n使用 /reminder rm <index> 删除待办事项。"
reminder_str += "\n使用 /reminder rm <id> 删除待办事项。\n"
yield event.plain_result(reminder_str)
@reminder.command("rm")
async def reminder_rm(self, event: AstrMessageEvent, index: int):
'''Remove a reminder by index.'''
reminders = self.reminder_data.get(event.unified_msg_origin, [])
reminders = await self.get_upcoming_reminders(event.unified_msg_origin)
if not reminders:
yield event.plain_result("没有待办事项。")
elif index < 1 or index > len(reminders):
yield event.plain_result("索引越界。")
else:
reminder = reminders.pop(index - 1)
self.scheduler.remove_job(event.unified_msg_origin)
job_id = reminder.get("id")
try:
self.scheduler.remove_job(job_id)
except Exception as e:
logger.error(f"Remove job error: {e}")
await self._save_data()
yield event.plain_result("成功删除待办事项:\n" + reminder["text"])
@@ -1,9 +1,30 @@
import random
from .config import HEADERS, USER_AGENTS
from bs4 import BeautifulSoup
from aiohttp import ClientSession
from dataclasses import dataclass
from typing import List
import urllib.parse
HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; rv:84.0) Gecko/20100101 Firefox/84.0',
'Accept': '*/*',
'Connection': 'keep-alive',
'Accept-Language': 'en-GB,en;q=0.5'
}
USER_AGENT_BING = 'Mozilla/5.0 (Windows NT 6.1; rv:84.0) Gecko/20100101 Firefox/84.0'
USER_AGENTS = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.131 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.131 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Version/14.1.2 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Version/14.1 Safari/537.36',
'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:89.0) Gecko/20100101 Firefox/89.0',
'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:88.0) Gecko/20100101 Firefox/88.0'
]
@dataclass
@@ -38,11 +59,13 @@ class SearchEngine():
if data:
async with ClientSession() as session:
async with session.post(url, headers=headers, data=data, timeout=self.TIMEOUT) as resp:
return await resp.text(encoding="utf-8")
ret = await resp.text(encoding="utf-8")
return ret
else:
async with ClientSession() as session:
async with session.get(url, headers=headers, timeout=self.TIMEOUT) as resp:
return await resp.text(encoding="utf-8")
ret = await resp.text(encoding="utf-8")
return ret
def tidy_text(self, text: str) -> str:
@@ -53,6 +76,8 @@ class SearchEngine():
async def search(self, query: str, num_results: int) -> List[SearchResult]:
query = urllib.parse.quote(query)
try:
resp = await self._get_next_page(query)
soup = BeautifulSoup(resp, 'html.parser')
+14 -8
View File
@@ -1,11 +1,11 @@
from typing import List
from .engine import SearchEngine, SearchResult
from .config import USER_AGENT_BING
from . import SearchEngine, SearchResult
from . import USER_AGENT_BING
class Bing(SearchEngine):
def __init__(self) -> None:
super().__init__()
self.base_url = "https://www.bing.com"
self.base_urls = ["https://cn.bing.com", "https://www.bing.com"]
self.headers.update({'User-Agent': USER_AGENT_BING})
def _set_selector(self, selector: str):
@@ -19,11 +19,17 @@ class Bing(SearchEngine):
return selectors[selector]
async def _get_next_page(self, query) -> str:
if self.page == 1:
await self._get_html(self.base_url)
url = f'{self.base_url}/search?q={query}&form=QBLH&sp=-1&lq=0&pq=hi&sc=10-2&qs=n&sk=&cvid=DE75965E2D6346D681288933984DE48F&ghsh=0&ghacc=0&ghpl='
return await self._get_html(url, None)
# if self.page == 1:
# await self._get_html(self.base_url)
for base_url in self.base_urls:
try:
url = f'{base_url}/search?q={query}'
return await self._get_html(url, None)
except Exception as _:
self.base_url = base_url
continue
raise Exception("Bing search failed")
async def search(self, query: str, num_results: int) -> List[SearchResult]:
results = await super().search(query, num_results)
for result in results:
-20
View File
@@ -1,20 +0,0 @@
HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; rv:84.0) Gecko/20100101 Firefox/84.0',
'Accept': '*/*',
'Connection': 'keep-alive',
'Accept-Language': 'en-GB,en;q=0.5'
}
USER_AGENT_BING = 'Mozilla/5.0 (Windows NT 6.1; rv:84.0) Gecko/20100101 Firefox/84.0'
USER_AGENTS = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.131 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.131 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Version/14.1.2 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Version/14.1 Safari/537.36',
'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:89.0) Gecko/20100101 Firefox/89.0',
'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:88.0) Gecko/20100101 Firefox/88.0'
]
+1 -2
View File
@@ -1,7 +1,7 @@
import os
from googlesearch import search
from .engine import SearchEngine, SearchResult
from . import SearchEngine, SearchResult
from typing import List
@@ -9,7 +9,6 @@ class Google(SearchEngine):
def __init__(self) -> None:
super().__init__()
self.proxy = os.environ.get("https_proxy")
print(f"Google Search using proxy: {self.proxy}")
async def search(self, query: str, num_results: int) -> List[SearchResult]:
results = []
+2 -2
View File
@@ -1,8 +1,8 @@
import random
import re
from bs4 import BeautifulSoup
from .engine import SearchEngine, SearchResult
from .config import USER_AGENTS
from . import SearchEngine, SearchResult
from . import USER_AGENTS
from typing import List
+4 -4
View File
@@ -9,7 +9,7 @@ from .engines.sogo import Sogo
from .engines.google import Google
from readability import Document
from bs4 import BeautifulSoup
from .engines.config import HEADERS, USER_AGENTS
from .engines import HEADERS, USER_AGENTS
@star.register(name="astrbot-web-searcher", desc="让 LLM 具有网页检索能力", author="Soulter", version="1.14.514")
@@ -85,19 +85,19 @@ class Main(star.Star):
RESULT_NUM = 5
try:
results = await self.google.search(query, RESULT_NUM)
except BaseException as e:
except Exception as e:
logger.error(f"google search error: {e}, try the next one...")
if len(results) == 0:
logger.debug("search google failed")
try:
results = await self.bing_search.search(query, RESULT_NUM)
except BaseException as e:
except Exception as e:
logger.error(f"bing search error: {e}, try the next one...")
if len(results) == 0:
logger.debug("search bing failed")
try:
results = await self.sogo_search.search(query, RESULT_NUM)
except BaseException as e:
except Exception as e:
logger.error(f"sogo search error: {e}")
if len(results) == 0:
logger.debug("search sogo failed")
+2 -1
View File
@@ -16,4 +16,5 @@ pyjwt
apscheduler
docstring_parser
aiodocker
silk-python
silk-python
ormsgpack
-1
View File
@@ -30,7 +30,6 @@ async def test_plugin_manager_reload(plugin_manager_pm: PluginManager):
success, err_message = await plugin_manager_pm.reload()
assert success is True
assert err_message is None
assert len(star_handlers_registry) > 0 # package
@pytest.mark.asyncio
async def test_plugin_crud(plugin_manager_pm: PluginManager):