diff --git a/README.md b/README.md index 40a3e95f1..2240acc21 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ uvx astrbot init | 名称 | 支持性 | 类型 | 备注 | | -------- | ------- | ------- | ------- | -| OpenAI API | ✔ | 文本生成 | 也支持 DeepSeek、Google Gemini、GLM、Kimi、xAI 等兼容 OpenAI API 的服务 | +| OpenAI API | ✔ | 文本生成 | 也支持 DeepSeek、Gemini、Kimi、xAI 等兼容 OpenAI API 的服务 | | Claude API | ✔ | 文本生成 | | | Google Gemini API | ✔ | 文本生成 | | | Dify | ✔ | LLMOps | | @@ -152,6 +152,8 @@ uvx astrbot init | Ollama | ✔ | 模型加载器 | 本地部署 DeepSeek、Llama 等开源语言模型 | | LM Studio | ✔ | 模型加载器 | 本地部署 DeepSeek、Llama 等开源语言模型 | | LLMTuner | ✔ | 模型加载器 | 本地加载 lora 等微调模型 | +| [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | ✔ | 模型 API 及算力服务平台 | | +| [302.AI](https://share.302.ai/rr1M3l) | ✔ | 模型 API 服务平台 | | | 硅基流动 | ✔ | 模型 API 服务平台 | | | PPIO 派欧云 | ✔ | 模型 API 服务平台 | | | OneAPI | ✔ | LLM 分发系统 | | diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index b3982cb13..d24d701c6 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -6,7 +6,7 @@ import os from astrbot.core.utils.astrbot_path import get_astrbot_data_path -VERSION = "3.5.19" +VERSION = "3.5.22" DB_PATH = os.path.join(get_astrbot_data_path(), "data_v3.db") # 默认配置 @@ -318,8 +318,7 @@ CONFIG_METADATA_2 = { "id": { "description": "机器人名称", "type": "string", - "obvious_hint": True, - "hint": "机器人名称(ID)不能和其它的平台适配器重复。", + "hint": "机器人名称", }, "type": { "description": "适配器类型", @@ -370,7 +369,6 @@ CONFIG_METADATA_2 = { "description": "飞书机器人的名字", "type": "string", "hint": "请务必填对,否则 @ 机器人将无法唤醒,只能通过前缀唤醒。", - "obvious_hint": True, }, "discord_token": { "description": "Discord Bot Token", @@ -486,13 +484,11 @@ CONFIG_METADATA_2 = { "regex": { "description": "正则表达式", "type": "string", - "obvious_hint": True, "hint": "用于分隔一段消息。默认情况下会根据句号、问号等标点符号分隔。re.findall(r'', text)", }, "content_cleanup_rule": { "description": "过滤分段后的内容", "type": "string", - "obvious_hint": True, "hint": "移除分段后的内容中的指定的内容。支持正则表达式。如填写 `[。?!]` 将移除所有的句号、问号、感叹号。re.sub(r'', '', text)", }, }, @@ -515,7 +511,6 @@ CONFIG_METADATA_2 = { "description": "ID 白名单", "type": "list", "items": {"type": "string"}, - "obvious_hint": True, "hint": "只处理填写的 ID 发来的消息事件,为空时不启用。可使用 /sid 指令获取在平台上的会话 ID(类似 abc:GroupMessage:123)。管理员可使用 /wl 添加白名单", }, "id_whitelist_log": { @@ -545,7 +540,6 @@ CONFIG_METADATA_2 = { "description": "路径映射", "type": "list", "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 部署时特别有用。", }, }, @@ -605,6 +599,7 @@ CONFIG_METADATA_2 = { "config_template": { "OpenAI": { "id": "openai", + "provider": "openai", "type": "openai_chat_completion", "provider_type": "chat_completion", "enable": True, @@ -617,6 +612,7 @@ CONFIG_METADATA_2 = { }, "Azure OpenAI": { "id": "azure", + "provider": "azure", "type": "openai_chat_completion", "provider_type": "chat_completion", "enable": True, @@ -630,6 +626,7 @@ CONFIG_METADATA_2 = { }, "xAI": { "id": "xai", + "provider": "xai", "type": "openai_chat_completion", "provider_type": "chat_completion", "enable": True, @@ -642,6 +639,7 @@ CONFIG_METADATA_2 = { }, "Anthropic": { "id": "claude", + "provider": "anthropic", "type": "anthropic_chat_completion", "provider_type": "chat_completion", "enable": True, @@ -655,6 +653,7 @@ CONFIG_METADATA_2 = { }, "Ollama": { "id": "ollama_default", + "provider": "ollama", "type": "openai_chat_completion", "provider_type": "chat_completion", "enable": True, @@ -666,6 +665,7 @@ CONFIG_METADATA_2 = { }, "LM Studio": { "id": "lm_studio", + "provider": "lm_studio", "type": "openai_chat_completion", "provider_type": "chat_completion", "enable": True, @@ -677,6 +677,7 @@ CONFIG_METADATA_2 = { }, "Gemini(OpenAI兼容)": { "id": "gemini_default", + "provider": "google", "type": "openai_chat_completion", "provider_type": "chat_completion", "enable": True, @@ -689,6 +690,7 @@ CONFIG_METADATA_2 = { }, "Gemini": { "id": "gemini_default", + "provider": "google", "type": "googlegenai_chat_completion", "provider_type": "chat_completion", "enable": True, @@ -714,6 +716,7 @@ CONFIG_METADATA_2 = { }, "DeepSeek": { "id": "deepseek_default", + "provider": "deepseek", "type": "openai_chat_completion", "provider_type": "chat_completion", "enable": True, @@ -726,6 +729,7 @@ CONFIG_METADATA_2 = { }, "302.AI": { "id": "302ai", + "provider": "302ai", "type": "openai_chat_completion", "provider_type": "chat_completion", "enable": True, @@ -738,6 +742,7 @@ CONFIG_METADATA_2 = { }, "硅基流动": { "id": "siliconflow", + "provider": "siliconflow", "type": "openai_chat_completion", "provider_type": "chat_completion", "enable": True, @@ -750,6 +755,7 @@ CONFIG_METADATA_2 = { }, "PPIO派欧云": { "id": "ppio", + "provider": "ppio", "type": "openai_chat_completion", "provider_type": "chat_completion", "enable": True, @@ -762,6 +768,7 @@ CONFIG_METADATA_2 = { }, "Kimi": { "id": "moonshot", + "provider": "moonshot", "type": "openai_chat_completion", "provider_type": "chat_completion", "enable": True, @@ -774,6 +781,7 @@ CONFIG_METADATA_2 = { }, "智谱 AI": { "id": "zhipu_default", + "provider": "zhipu", "type": "zhipu_chat_completion", "provider_type": "chat_completion", "enable": True, @@ -786,6 +794,7 @@ CONFIG_METADATA_2 = { }, "Dify": { "id": "dify_app_default", + "provider": "dify", "type": "dify", "provider_type": "chat_completion", "enable": True, @@ -799,6 +808,7 @@ CONFIG_METADATA_2 = { }, "阿里云百炼应用": { "id": "dashscope", + "provider": "dashscope", "type": "dashscope", "provider_type": "chat_completion", "enable": True, @@ -815,6 +825,7 @@ CONFIG_METADATA_2 = { }, "FastGPT": { "id": "fastgpt", + "provider": "fastgpt", "type": "openai_chat_completion", "provider_type": "chat_completion", "enable": True, @@ -824,6 +835,7 @@ CONFIG_METADATA_2 = { }, "Whisper(API)": { "id": "whisper", + "provider": "openai", "type": "openai_whisper_api", "provider_type": "speech_to_text", "enable": False, @@ -833,15 +845,17 @@ CONFIG_METADATA_2 = { }, "Whisper(本地加载)": { "whisper_hint": "(不用修改我)", + "provider": "openai", "type": "openai_whisper_selfhost", "provider_type": "speech_to_text", "enable": False, - "id": "whisper", + "id": "whisper_selfhost", "model": "tiny", }, "SenseVoice(本地加载)": { "sensevoice_hint": "(不用修改我)", "type": "sensevoice_stt_selfhost", + "provider": "sensevoice", "provider_type": "speech_to_text", "enable": False, "id": "sensevoice", @@ -851,6 +865,7 @@ CONFIG_METADATA_2 = { "OpenAI TTS(API)": { "id": "openai_tts", "type": "openai_tts_api", + "provider": "openai", "provider_type": "text_to_speech", "enable": False, "api_key": "", @@ -862,6 +877,7 @@ CONFIG_METADATA_2 = { "Edge TTS": { "edgetts_hint": "提示:使用这个服务前需要安装有 ffmpeg,并且可以直接在终端调用 ffmpeg 指令。", "id": "edge_tts", + "provider": "microsoft", "type": "edge_tts", "provider_type": "text_to_speech", "enable": False, @@ -871,6 +887,7 @@ CONFIG_METADATA_2 = { "GSV TTS(本地加载)": { "id": "gsv_tts", "enable": False, + "provider": "gpt_sovits", "type": "gsv_tts_selfhost", "provider_type": "text_to_speech", "api_base": "http://127.0.0.1:9880", @@ -902,6 +919,7 @@ CONFIG_METADATA_2 = { "GSVI TTS(API)": { "id": "gsvi_tts", "type": "gsvi_tts_api", + "provider": "gpt_sovits_inference", "provider_type": "text_to_speech", "api_base": "http://127.0.0.1:5000", "character": "", @@ -911,6 +929,7 @@ CONFIG_METADATA_2 = { }, "FishAudio TTS(API)": { "id": "fishaudio_tts", + "provider": "fishaudio", "type": "fishaudio_tts_api", "provider_type": "text_to_speech", "enable": False, @@ -921,6 +940,7 @@ CONFIG_METADATA_2 = { }, "阿里云百炼 TTS(API)": { "id": "dashscope_tts", + "provider": "dashscope", "type": "dashscope_tts", "provider_type": "text_to_speech", "enable": False, @@ -932,6 +952,7 @@ CONFIG_METADATA_2 = { "Azure TTS": { "id": "azure_tts", "type": "azure_tts", + "provider": "azure", "provider_type": "text_to_speech", "enable": True, "azure_tts_voice": "zh-CN-YunxiaNeural", @@ -945,6 +966,7 @@ CONFIG_METADATA_2 = { "MiniMax TTS(API)": { "id": "minimax_tts", "type": "minimax_tts_api", + "provider": "minimax", "provider_type": "text_to_speech", "enable": False, "api_key": "", @@ -966,6 +988,7 @@ CONFIG_METADATA_2 = { "火山引擎_TTS(API)": { "id": "volcengine_tts", "type": "volcengine_tts", + "provider": "volcengine", "provider_type": "text_to_speech", "enable": False, "api_key": "", @@ -979,6 +1002,7 @@ CONFIG_METADATA_2 = { "Gemini TTS": { "id": "gemini_tts", "type": "gemini_tts", + "provider": "google", "provider_type": "text_to_speech", "enable": False, "gemini_tts_api_key": "", @@ -991,6 +1015,7 @@ CONFIG_METADATA_2 = { "OpenAI Embedding": { "id": "openai_embedding", "type": "openai_embedding", + "provider": "openai", "provider_type": "embedding", "enable": True, "embedding_api_key": "", @@ -1002,6 +1027,7 @@ CONFIG_METADATA_2 = { "Gemini Embedding": { "id": "gemini_embedding", "type": "gemini_embedding", + "provider": "google", "provider_type": "embedding", "enable": True, "embedding_api_key": "", @@ -1012,17 +1038,19 @@ CONFIG_METADATA_2 = { }, }, "items": { + "provider": { + "type": "string", + "invisible": True, + }, "gpt_weights_path": { "description": "GPT模型文件路径", "type": "string", "hint": "即“.ckpt”后缀的文件,请使用绝对路径,路径两端不要带双引号,不填则默认用GPT_SoVITS内置的SoVITS模型(建议直接在GPT_SoVITS中改默认模型)", - "obvious_hint": True, }, "sovits_weights_path": { "description": "SoVITS模型文件路径", "type": "string", "hint": "即“.pth”后缀的文件,请使用绝对路径,路径两端不要带双引号,不填则默认用GPT_SoVITS内置的SoVITS模型(建议直接在GPT_SoVITS中改默认模型)", - "obvious_hint": True, }, "gsv_default_parms": { "description": "GPT_SoVITS默认参数", @@ -1033,13 +1061,11 @@ CONFIG_METADATA_2 = { "description": "参考音频文件路径", "type": "string", "hint": "必填!请使用绝对路径!路径两端不要带双引号!", - "obvious_hint": True, }, "gsv_prompt_text": { "description": "参考音频文本", "type": "string", "hint": "必填!请填写参考音频讲述的文本", - "obvious_hint": True, }, "gsv_prompt_lang": { "description": "参考音频文本语言", @@ -1266,19 +1292,16 @@ CONFIG_METADATA_2 = { "description": "启用原生搜索功能", "type": "bool", "hint": "启用后所有函数工具将全部失效,免费次数限制请查阅官方文档", - "obvious_hint": True, }, "gm_native_coderunner": { "description": "启用原生代码执行器", "type": "bool", "hint": "启用后所有函数工具将全部失效", - "obvious_hint": True, }, "gm_url_context": { "description": "启用URL上下文功能", "type": "bool", "hint": "启用后所有函数工具将全部失效", - "obvious_hint": True, }, "gm_safety_settings": { "description": "安全过滤器", @@ -1462,7 +1485,6 @@ CONFIG_METADATA_2 = { "description": "部署SenseVoice", "type": "string", "hint": "启用前请 pip 安装 funasr、funasr_onnx、torchaudio、torch、modelscope、jieba 库(默认使用CPU,大约下载 1 GB),并且安装 ffmpeg。否则将无法正常转文字。", - "obvious_hint": True, }, "is_emotion": { "description": "情绪识别", @@ -1477,18 +1499,10 @@ CONFIG_METADATA_2 = { "variables": { "description": "工作流固定输入变量", "type": "object", - "obvious_hint": True, "items": {}, "hint": "可选。工作流固定输入变量,将会作为工作流的输入。也可以在对话时使用 /set 指令动态设置变量。如果变量名冲突,优先使用动态设置的变量。", "invisible": True, }, - # "fastgpt_app_type": { - # "description": "应用类型", - # "type": "string", - # "hint": "FastGPT 应用的应用类型。", - # "options": ["agent", "workflow", "plugin"], - # "obvious_hint": True, - # }, "dashscope_app_type": { "description": "应用类型", "type": "string", @@ -1499,7 +1513,6 @@ CONFIG_METADATA_2 = { "dialog-workflow", "task-workflow", ], - "obvious_hint": True, }, "timeout": { "description": "超时时间", @@ -1509,26 +1522,22 @@ CONFIG_METADATA_2 = { "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", "hint": "启用前请 pip 安装 openai-whisper 库(N卡用户大约下载 2GB,主要是 torch 和 cuda,CPU 用户大约下载 1 GB),并且安装 ffmpeg。否则将无法正常转文字。", - "obvious_hint": True, }, "id": { "description": "ID", "type": "string", - "obvious_hint": True, - "hint": "ID 不能和其它的服务提供商重复,否则将发生严重冲突。", + "hint": "模型提供商名字。", }, "type": { "description": "模型提供商种类", @@ -1543,53 +1552,27 @@ CONFIG_METADATA_2 = { "enable": { "description": "启用", "type": "bool", - "hint": "是否启用该模型。未启用的模型将不会被使用。", + "hint": "是否启用。", }, "key": { "description": "API Key", "type": "list", "items": {"type": "string"}, - "hint": "API Key 列表。填写好后输入回车即可添加 API Key。支持多个 API Key。", + "hint": "提供商 API Key。", }, "api_base": { "description": "API Base URL", "type": "string", - "hint": "API Base URL 请在在模型提供商处获得。如出现 404 报错,尝试在地址末尾加上 /v1", - "obvious_hint": True, - }, - "base_model_path": { - "description": "基座模型路径", - "type": "string", - "hint": "基座模型路径。", - }, - "adapter_model_path": { - "description": "Adapter 模型路径", - "type": "string", - "hint": "Adapter 模型路径。如 Lora", - }, - "llmtuner_template": { - "description": "template", - "type": "string", - "hint": "基座模型的类型。如 llama3, qwen, 请参考 LlamaFactory 文档。", - }, - "finetuning_type": { - "description": "微调类型", - "type": "string", - "hint": "微调类型。如 `lora`", - }, - "quantization_bit": { - "description": "量化位数", - "type": "int", - "hint": "量化位数。如 4", + "hint": "API Base URL 请在模型提供商处获得。如出现 404 报错,尝试在地址末尾加上 /v1", }, "model_config": { - "description": "文本生成模型", + "description": "模型配置", "type": "object", "items": { "model": { "description": "模型名称", "type": "string", - "hint": "大语言模型的名称,一般是小写的英文。如 gpt-4o-mini, deepseek-chat 等。", + "hint": "模型名称,如 gpt-4o-mini, deepseek-chat。", }, "max_tokens": { "description": "模型最大输出长度(tokens)", @@ -1636,7 +1619,6 @@ CONFIG_METADATA_2 = { "description": "启用大语言模型聊天", "type": "bool", "hint": "如需切换大语言模型提供商,请使用 /provider 命令。", - "obvious_hint": True, }, "separate_provider": { "description": "提供商会话隔离", @@ -1656,13 +1638,11 @@ CONFIG_METADATA_2 = { "web_search": { "description": "启用网页搜索", "type": "bool", - "obvious_hint": True, "hint": "能访问 Google 时效果最佳(国内需要在 `其他配置` 开启 HTTP 代理)。如果 Google 访问失败,程序会依次访问 Bing, Sogo 搜索引擎。", }, "web_search_link": { "description": "网页搜索引用链接", "type": "bool", - "obvious_hint": True, "hint": "开启后,将会传入网页搜索结果的链接给模型,并引导模型输出引用链接。", }, "display_reasoning_text": { @@ -1673,13 +1653,11 @@ CONFIG_METADATA_2 = { "identifier": { "description": "启动识别群员", "type": "bool", - "obvious_hint": True, "hint": "在 Prompt 前加上群成员的名字以让模型更好地了解群聊状态。启用将略微增加 token 开销。", }, "datetime_system_prompt": { "description": "启用日期时间系统提示", "type": "bool", - "obvious_hint": True, "hint": "启用后,会在系统提示词中加上当前机器的日期时间。", }, "default_personality": { @@ -1736,7 +1714,6 @@ CONFIG_METADATA_2 = { "description": "人格名称", "type": "string", "hint": "人格名称,用于在多个人格中区分。使用 /persona 指令可切换人格。在 大语言模型设置 处可以设置默认人格。", - "obvious_hint": True, }, "prompt": { "description": "设定(系统提示词)", @@ -1748,14 +1725,12 @@ CONFIG_METADATA_2 = { "type": "list", "items": {"type": "string"}, "hint": "可选。在每个对话前会插入这些预设对话。对话需要成对(用户和助手),输入完一个角色的内容之后按【回车】。需要偶数个对话", - "obvious_hint": True, }, "mood_imitation_dialogs": { "description": "对话风格模仿", "type": "list", "items": {"type": "string"}, "hint": "旨在让模型尽可能模仿学习到所填写的对话的语气风格。格式和 `预设对话` 一致。对话需要成对(用户和助手),输入完一个角色的内容之后按【回车】。需要偶数个对话", - "obvious_hint": True, }, }, }, @@ -1767,7 +1742,6 @@ CONFIG_METADATA_2 = { "description": "启用语音转文本(STT)", "type": "bool", "hint": "启用前请在 服务提供商配置 处创建支持 语音转文本任务 的提供商。如 whisper。", - "obvious_hint": True, }, "provider_id": { "description": "提供商 ID", @@ -1784,7 +1758,6 @@ CONFIG_METADATA_2 = { "description": "启用文本转语音(TTS)", "type": "bool", "hint": "启用前请在 服务提供商配置 处创建支持 语音转文本任务 的提供商。如 openai_tts。", - "obvious_hint": True, }, "provider_id": { "description": "提供商 ID", @@ -1795,7 +1768,6 @@ CONFIG_METADATA_2 = { "description": "启用语音和文字双输出", "type": "bool", "hint": "启用后,Bot 将同时输出语音和文字消息。", - "obvious_hint": True, }, "use_file_service": { "description": "使用文件服务提供 TTS 语音文件", @@ -1811,25 +1783,21 @@ CONFIG_METADATA_2 = { "group_icl_enable": { "description": "群聊内记录各群员对话", "type": "bool", - "obvious_hint": True, "hint": "启用后,会记录群聊内各群员的对话。使用 /reset 命令清除记录。推荐使用 gpt-4o-mini 模型。", }, "group_message_max_cnt": { "description": "群聊消息最大数量", "type": "int", - "obvious_hint": True, "hint": "群聊消息最大数量。超过此数量后,会自动清除旧消息。", }, "image_caption": { "description": "群聊图像转述(需模型支持)", "type": "bool", - "obvious_hint": True, "hint": "用模型将群聊中的图片消息转述为文字,推荐 gpt-4o-mini 模型。和机器人的唤醒聊天中的图片消息仍然会直接作为上下文输入。", }, "image_caption_provider_id": { "description": "图像转述提供商 ID", "type": "string", - "obvious_hint": True, "hint": "可选。图像转述提供商 ID。如为空将选择聊天使用的提供商。", }, "image_caption_prompt": { @@ -1843,14 +1811,12 @@ CONFIG_METADATA_2 = { "enable": { "description": "启用主动回复", "type": "bool", - "obvious_hint": True, "hint": "启用后,会根据触发概率主动回复群聊内的对话。QQ官方API(qq_official)不可用", }, "whitelist": { "description": "主动回复白名单", "type": "list", "items": {"type": "string"}, - "obvious_hint": True, "hint": "启用后,只有在白名单内的群聊会被主动回复。为空时不启用白名单过滤。需要通过 /sid 获取 SID 添加到这里。", }, "method": { @@ -1862,13 +1828,11 @@ CONFIG_METADATA_2 = { "possibility_reply": { "description": "回复概率", "type": "float", - "obvious_hint": True, "hint": "回复概率。当回复方法为 possibility_reply 时有效。当概率 >= 1 时,每条消息都会回复。", }, "prompt": { "description": "提示词", "type": "string", - "obvious_hint": True, "hint": "提示词。当提示词为空时,如果触发回复,则向 LLM 请求的是触发的消息的内容;否则是提示词。此项可以和定时回复(暂未实现)配合使用。", }, }, @@ -1884,7 +1848,6 @@ CONFIG_METADATA_2 = { "description": "机器人唤醒前缀", "type": "list", "items": {"type": "string"}, - "obvious_hint": True, "hint": "在不 @ 机器人的情况下,可以通过外加消息前缀来唤醒机器人。更改此配置将影响整个 Bot 的功能唤醒,包括所有指令。如果您不保留 `/`,则内置指令(help等)将需要通过您的唤醒前缀来触发。", }, "t2i": { @@ -1911,13 +1874,11 @@ CONFIG_METADATA_2 = { "timezone": { "description": "时区", "type": "string", - "obvious_hint": True, "hint": "时区设置。请填写 IANA 时区名称, 如 Asia/Shanghai, 为空时使用系统默认时区。所有时区请查看: https://data.iana.org/time-zones/tzdb-2021a/zone1970.tab", }, "callback_api_base": { "description": "对外可达的回调接口地址", "type": "string", - "obvious_hint": True, "hint": "外部服务可能会通过 AstrBot 生成的回调链接(如文件下载链接)访问 AstrBot 后端。由于 AstrBot 无法自动判断部署环境中对外可达的主机地址(host),因此需要通过此配置项显式指定 “外部服务如何访问 AstrBot” 的地址。如 http://localhost:6185,https://example.com 等。", }, "log_level": { @@ -1965,90 +1926,3 @@ DEFAULT_VALUE_MAP = { "list": [], "object": {}, } - - -# "project_atri": { -# "description": "Project ATRI 配置", -# "type": "object", -# "items": { -# "enable": {"description": "启用", "type": "bool"}, -# "long_term_memory": { -# "description": "长期记忆", -# "type": "object", -# "items": { -# "enable": {"description": "启用", "type": "bool"}, -# "summary_threshold_cnt": { -# "description": "摘要阈值", -# "type": "int", -# "hint": "当一个会话的对话记录数量超过该阈值时,会自动进行摘要。", -# }, -# "embedding_provider_id": { -# "description": "Embedding provider ID", -# "type": "string", -# "hint": "只有当启用了长期记忆时,才需要填写此项。将会使用指定的 provider 来获取 Embedding,请确保所填的 provider id 在 `配置页` 中存在并且设置了 Embedding 配置", -# "obvious_hint": True, -# }, -# "summarize_provider_id": { -# "description": "Summary provider ID", -# "type": "string", -# "hint": "只有当启用了长期记忆时,才需要填写此项。将会使用指定的 provider 来获取 Summary,请确保所填的 provider id 在 `配置页` 中存在。", -# "obvious_hint": True, -# }, -# }, -# }, -# "active_message": { -# "description": "主动消息", -# "type": "object", -# "items": { -# "enable": {"description": "启用", "type": "bool"}, -# }, -# }, -# "vision": { -# "description": "视觉理解", -# "type": "object", -# "items": { -# "enable": {"description": "启用", "type": "bool"}, -# "provider_id_or_ofa_model_path": { -# "description": "提供商 ID 或 OFA 模型路径", -# "type": "string", -# "hint": "将会使用指定的 provider 来进行视觉处理,请确保所填的 provider id 在 `配置页` 中存在。", -# }, -# }, -# }, -# "split_response": { -# "description": "是否分割回复", -# "type": "bool", -# "hint": "启用后,将会根据句子分割回复以更像人类回复。每次回复之间具有随机的时间间隔。默认启用。", -# }, -# "persona": { -# "description": "人格", -# "type": "string", -# "hint": "默认人格。当启动 ATRI 之后,在 Provider 处设置的人格将会失效。", -# "obvious_hint": True, -# }, -# "chat_provider_id": { -# "description": "Chat provider ID", -# "type": "string", -# "hint": "将会使用指定的 provider 来进行文本聊天,请确保所填的 provider id 在 `配置页` 中存在。", -# "obvious_hint": True, -# }, -# "chat_base_model_path": { -# "description": "用于聊天的基座模型路径", -# "type": "string", -# "hint": "用于聊天的基座模型路径。当填写此项和 Lora 路径后,将会忽略上面设置的 Chat provider ID。", -# "obvious_hint": True, -# }, -# "chat_adapter_model_path": { -# "description": "用于聊天的 Lora 模型路径", -# "type": "string", -# "hint": "Lora 模型路径。", -# "obvious_hint": True, -# }, -# "quantization_bit": { -# "description": "量化位数", -# "type": "int", -# "hint": "模型量化位数。如果你不知道这是什么,请不要修改。默认为 4。", -# "obvious_hint": True, -# }, -# }, -# }, diff --git a/astrbot/core/pipeline/process_stage/method/llm_request.py b/astrbot/core/pipeline/process_stage/method/llm_request.py index b1d9310c6..697150107 100644 --- a/astrbot/core/pipeline/process_stage/method/llm_request.py +++ b/astrbot/core/pipeline/process_stage/method/llm_request.py @@ -166,6 +166,7 @@ class LLMRequestSubStage(Stage): event=event, pipeline_ctx=self.ctx, ) + logger.debug(f"handle provider[id: {provider.provider_config['id']}] request: {req}") await tool_loop_agent.reset(req=req, streaming=self.streaming_response) async def requesting(): @@ -184,7 +185,8 @@ class LLMRequestSubStage(Stage): await event.send(resp.data["chain"]) continue # 对于其他情况,暂时先不处理 - if resp.type == "tool_call": + continue + elif resp.type == "tool_call": if self.streaming_response: # 用来标记流式响应需要分节 yield MessageChain(chain=[], type="break") diff --git a/astrbot/core/pipeline/scheduler.py b/astrbot/core/pipeline/scheduler.py index d29c7ec80..a014aae6f 100644 --- a/astrbot/core/pipeline/scheduler.py +++ b/astrbot/core/pipeline/scheduler.py @@ -73,7 +73,7 @@ class PipelineScheduler: await self._process_stages(event) # 如果没有发送操作, 则发送一个空消息, 以便于后续的处理 - if not event._has_send_oper and event.get_platform_name() == "webchat": + if event.get_platform_name() == "webchat": await event.send(None) logger.debug("pipeline 执行完毕。") diff --git a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py index b5539b7eb..57c46cd33 100644 --- a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py +++ b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py @@ -1,7 +1,7 @@ import asyncio import re from typing import AsyncGenerator, Dict, List -from aiocqhttp import CQHttp +from aiocqhttp import CQHttp, Event from astrbot.api.event import AstrMessageEvent, MessageChain from astrbot.api.message_components import ( Image, @@ -58,50 +58,85 @@ class AiocqhttpMessageEvent(AstrMessageEvent): ret.append(d) return ret - async def send(self, message: MessageChain): + @classmethod + async def _dispatch_send( + cls, + bot: CQHttp, + event: Event | None, + is_group: bool, + session_id: str, + messages: list[dict], + ): + if event: + await bot.send(event=event, message=messages) + elif is_group: + await bot.send_group_msg(group_id=session_id, message=messages) + else: + await bot.send_private_msg(user_id=session_id, message=messages) + + @classmethod + async def send_message( + cls, + bot: CQHttp, + message_chain: MessageChain, + event: Event | None = None, + is_group: bool = False, + session_id: str = None, + ): + """发送消息""" + # 转发消息、文件消息不能和普通消息混在一起发送 send_one_by_one = any( - isinstance(seg, (Node, Nodes, File)) for seg in message.chain + isinstance(seg, (Node, Nodes, File)) for seg in message_chain.chain ) - if send_one_by_one: - for seg in message.chain: - if isinstance(seg, (Node, Nodes)): - # 合并转发消息 - - if isinstance(seg, Node): - nodes = Nodes([seg]) - seg = nodes - - payload = await seg.to_dict() - - if self.get_group_id(): - payload["group_id"] = self.get_group_id() - await self.bot.call_action("send_group_forward_msg", **payload) - else: - payload["user_id"] = self.get_sender_id() - await self.bot.call_action( - "send_private_forward_msg", **payload - ) - elif isinstance(seg, File): - d = await AiocqhttpMessageEvent._from_segment_to_dict(seg) - await self.bot.send( - self.message_obj.raw_message, - [d], - ) - else: - await self.bot.send( - self.message_obj.raw_message, - await AiocqhttpMessageEvent._parse_onebot_json( - MessageChain([seg]) - ), - ) - await asyncio.sleep(0.5) - else: - ret = await AiocqhttpMessageEvent._parse_onebot_json(message) + if not send_one_by_one: + ret = await cls._parse_onebot_json(message_chain) if not ret: return - await self.bot.send(self.message_obj.raw_message, ret) + await cls._dispatch_send(bot, event, is_group, session_id, ret) + return + for seg in message_chain.chain: + if isinstance(seg, (Node, Nodes)): + # 合并转发消息 + if isinstance(seg, Node): + nodes = Nodes([seg]) + seg = nodes + payload = await seg.to_dict() + + if is_group: + payload["group_id"] = session_id + await bot.call_action("send_group_forward_msg", **payload) + else: + payload["user_id"] = session_id + await bot.call_action("send_private_forward_msg", **payload) + elif isinstance(seg, File): + d = await cls._from_segment_to_dict(seg) + await cls._dispatch_send(bot, event, is_group, session_id, [d]) + else: + messages = await cls._parse_onebot_json(MessageChain([seg])) + if not messages: + continue + await cls._dispatch_send(bot, event, is_group, session_id, messages) + await asyncio.sleep(0.5) + + async def send(self, message: MessageChain): + """发送消息""" + event = self.message_obj.raw_message + assert isinstance(event, Event), "Event must be an instance of aiocqhttp.Event" + is_group = False + if self.get_group_id(): + is_group = True + session_id = self.get_group_id() + else: + session_id = self.get_sender_id() + await self.send_message( + bot=self.bot, + message_chain=message, + event=event, + is_group=is_group, + session_id=session_id, + ) await super().send(message) async def send_streaming( diff --git a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py index 02e655af7..1991bf393 100644 --- a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +++ b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py @@ -83,19 +83,18 @@ class AiocqhttpAdapter(Platform): async def send_by_session( self, session: MessageSesion, message_chain: MessageChain ): - ret = await AiocqhttpMessageEvent._parse_onebot_json(message_chain) - match session.message_type.value: - case MessageType.GROUP_MESSAGE.value: - if "_" in session.session_id: - # 独立会话 - _, group_id = session.session_id.split("_") - await self.bot.send_group_msg(group_id=group_id, message=ret) - else: - await self.bot.send_group_msg( - group_id=session.session_id, message=ret - ) - case MessageType.FRIEND_MESSAGE.value: - await self.bot.send_private_msg(user_id=session.session_id, message=ret) + is_group = session.message_type == MessageType.GROUP_MESSAGE + if is_group: + session_id = session.session_id.split("_")[-1] + else: + session_id = session.session_id + await AiocqhttpMessageEvent.send_message( + bot=self.bot, + message_chain=message_chain, + event=None, # 这里不需要 event,因为是通过 session 发送的 + is_group=is_group, + session_id=session_id, + ) await super().send_by_session(session, message_chain) async def convert_message(self, event: Event) -> AstrBotMessage: @@ -307,7 +306,9 @@ class AiocqhttpAdapter(Platform): user_id=int(m["data"]["qq"]), ) if at_info: - nickname = at_info.get("nick", "") or at_info.get("nickname", "") + nickname = at_info.get("nick", "") or at_info.get( + "nickname", "" + ) is_at_self = str(m["data"]["qq"]) in {abm.self_id, "all"} abm.message.append( diff --git a/astrbot/core/platform/sources/webchat/webchat_event.py b/astrbot/core/platform/sources/webchat/webchat_event.py index c4e5d63c0..3bf1c0a2a 100644 --- a/astrbot/core/platform/sources/webchat/webchat_event.py +++ b/astrbot/core/platform/sources/webchat/webchat_event.py @@ -22,7 +22,11 @@ class WebChatMessageEvent(AstrMessageEvent): web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid) if not message: await web_chat_back_queue.put( - {"type": "end", "data": "", "streaming": False} + { + "type": "end", + "data": "", + "streaming": False, + } # end means this request is finished ) return "" @@ -99,16 +103,6 @@ class WebChatMessageEvent(AstrMessageEvent): async def send(self, message: MessageChain): await WebChatMessageEvent._send(message, session_id=self.session_id) - cid = self.session_id.split("!")[-1] - web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid) - await web_chat_back_queue.put( - { - "type": "end", - "data": "", - "streaming": False, - "cid": cid, - } - ) await super().send(message) async def send_streaming(self, generator, use_fallback: bool = False): @@ -120,7 +114,7 @@ class WebChatMessageEvent(AstrMessageEvent): # 分割符 await web_chat_back_queue.put( { - "type": "end", + "type": "break", # break means a segment end "data": final_data, "streaming": True, "cid": cid, @@ -134,7 +128,7 @@ class WebChatMessageEvent(AstrMessageEvent): await web_chat_back_queue.put( { - "type": "end", + "type": "complete", # complete means we return the final result "data": final_data, "streaming": True, "cid": cid, diff --git a/astrbot/core/provider/manager.py b/astrbot/core/provider/manager.py index 05747c3ff..df21e6a12 100644 --- a/astrbot/core/provider/manager.py +++ b/astrbot/core/provider/manager.py @@ -7,7 +7,7 @@ from astrbot.core.config.astrbot_config import AstrBotConfig from astrbot.core.db import BaseDatabase from .entities import ProviderType -from .provider import Personality, Provider, STTProvider, TTSProvider +from .provider import Personality, Provider, STTProvider, TTSProvider, EmbeddingProvider from .register import llm_tools, provider_cls_map @@ -93,7 +93,7 @@ class ProviderManager: """加载的 Speech To Text Provider 的实例""" self.tts_provider_insts: List[TTSProvider] = [] """加载的 Text To Speech Provider 的实例""" - self.embedding_provider_insts: List[Provider] = [] + self.embedding_provider_insts: List[EmbeddingProvider] = [] """加载的 Embedding Provider 的实例""" self.inst_map: dict[str, Provider] = {} """Provider 实例映射. key: provider_id, value: Provider 实例""" diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index ec1624776..e44b42612 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -482,13 +482,8 @@ class ProviderOpenAIOfficial(Provider): """ new_contexts = [] - flag = False for context in contexts: - if flag: - flag = False # 删除 image 后,下一条(LLM 响应)也要删除 - continue if "content" in context and isinstance(context["content"], list): - flag = True # continue new_content = [] for item in context["content"]: diff --git a/astrbot/core/star/__init__.py b/astrbot/core/star/__init__.py index 3337e4c25..25c291659 100644 --- a/astrbot/core/star/__init__.py +++ b/astrbot/core/star/__init__.py @@ -1,4 +1,4 @@ -from .star import StarMetadata, star_map +from .star import StarMetadata, star_map, star_registry from .star_manager import PluginManager from .context import Context from astrbot.core.provider import Provider @@ -16,11 +16,16 @@ class Star(CommandParserMixin): def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) - metadata = StarMetadata( - star_cls_type=cls, - module_path=cls.__module__, - ) - star_map[cls.__module__] = metadata + if not star_map.get(cls.__module__): + metadata = StarMetadata( + star_cls_type=cls, + module_path=cls.__module__, + ) + star_map[cls.__module__] = metadata + star_registry.append(metadata) + else: + star_map[cls.__module__].star_cls_type = cls + star_map[cls.__module__].module_path = cls.__module__ @staticmethod async def text_to_image(text: str, return_url=True) -> str: diff --git a/astrbot/core/star/context.py b/astrbot/core/star/context.py index dda664b1b..0b14525d3 100644 --- a/astrbot/core/star/context.py +++ b/astrbot/core/star/context.py @@ -2,7 +2,7 @@ from asyncio import Queue from typing import List, Union from astrbot.core import sp -from astrbot.core.provider.provider import Provider, TTSProvider, STTProvider +from astrbot.core.provider.provider import Provider, TTSProvider, STTProvider, EmbeddingProvider from astrbot.core.provider.entities import ProviderType from astrbot.core.db import BaseDatabase from astrbot.core.config.astrbot_config import AstrBotConfig @@ -141,6 +141,10 @@ class Context: """获取所有用于 STT 任务的 Provider。""" return self.provider_manager.stt_provider_insts + def get_all_embedding_providers(self) -> List[EmbeddingProvider]: + """获取所有用于 Embedding 任务的 Provider。""" + return self.provider_manager.embedding_provider_insts + def get_using_provider(self, umo: str = None) -> Provider: """ 获取当前使用的用于文本生成任务的 LLM Provider(Chat_Completion 类型)。通过 /provider 指令切换。 diff --git a/astrbot/core/star/register/star.py b/astrbot/core/star/register/star.py index 7e5f89fd2..d4814d07c 100644 --- a/astrbot/core/star/register/star.py +++ b/astrbot/core/star/register/star.py @@ -1,5 +1,7 @@ import warnings +from astrbot.core.star import StarMetadata, star_map + _warned_register_star = False @@ -37,6 +39,22 @@ def register_star(name: str, author: str, desc: str, version: str, repo: str = N ) def decorator(cls): + if not star_map.get(cls.__module__): + metadata = StarMetadata( + name=name, + author=author, + desc=desc, + version=version, + repo=repo, + ) + star_map[cls.__module__] = metadata + else: + star_map[cls.__module__].name = name + star_map[cls.__module__].author = author + star_map[cls.__module__].desc = desc + star_map[cls.__module__].version = version + star_map[cls.__module__].repo = repo + return cls return decorator diff --git a/astrbot/core/star/star.py b/astrbot/core/star/star.py index bc8f3d404..d44388238 100644 --- a/astrbot/core/star/star.py +++ b/astrbot/core/star/star.py @@ -56,7 +56,10 @@ class StarMetadata: """插件支持的平台ID字典,key为平台ID,value为是否支持""" def __str__(self) -> str: - return f"StarMetadata({self.name}, {self.desc}, {self.version}, {self.repo})" + return f"Plugin {self.name} ({self.version}) by {self.author}: {self.desc}" + + def __repr__(self) -> str: + return f"Plugin {self.name} ({self.version}) by {self.author}: {self.desc}" def update_platform_compatibility(self, plugin_enable_config: dict) -> None: """更新插件支持的平台列表 diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index b8365ed61..4a6d4d902 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -36,12 +36,6 @@ except ImportError: if os.getenv("ASTRBOT_RELOAD", "0") == "1": logger.warning("未安装 watchfiles,无法实现插件的热重载。") -try: - import nh3 -except ImportError: - logger.warning("未安装 nh3 库,无法清理插件 README.md 中的 HTML 标签。") - nh3 = None - class PluginManager: def __init__(self, context: Context, config: AstrBotConfig): @@ -63,6 +57,8 @@ class PluginManager: """保留插件的路径。在 packages 目录下""" self.conf_schema_fname = "_conf_schema.json" """插件配置 Schema 文件名""" + self._pm_lock = asyncio.Lock() + """StarManager操作互斥锁""" self.failed_plugin_info = "" if os.getenv("ASTRBOT_RELOAD", "0") == "1": @@ -192,9 +188,9 @@ class PluginManager: @staticmethod def _load_plugin_metadata(plugin_path: str, plugin_obj=None) -> StarMetadata: - """v3.4.0 以前的方式载入插件元数据 + """先寻找 metadata.yaml 文件,如果不存在,则使用插件对象的 info() 函数获取元数据。 - 先寻找 metadata.yaml 文件,如果不存在,则使用插件对象的 info() 函数获取元数据。 + Notes: 旧版本 AstrBot 插件可能使用的是 info() 函数来获取元数据。 """ metadata = None @@ -206,11 +202,14 @@ class PluginManager: os.path.join(plugin_path, "metadata.yaml"), "r", encoding="utf-8" ) as f: metadata = yaml.safe_load(f) - elif plugin_obj: + elif plugin_obj and hasattr(plugin_obj, "info"): # 使用 info() 函数 metadata = plugin_obj.info() if isinstance(metadata, dict): + if "desc" not in metadata and "description" in metadata: + metadata["desc"] = metadata["description"] + if ( "name" not in metadata or "desc" not in metadata @@ -296,50 +295,51 @@ class PluginManager: - success (bool): 重载是否成功 - error_message (str|None): 错误信息,成功时为 None """ - 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 + async with self._pm_lock: + 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: - try: - await self._terminate_plugin(smd) - except Exception as e: - logger.warning(traceback.format_exc()) - logger.warning( - f"插件 {smd.name} 未被正常终止: {str(e)}, 可能会导致该插件运行不正常。" - ) + # 终止插件 + if not specified_module_path: + # 重载所有插件 + for smd in star_registry: + try: + await self._terminate_plugin(smd) + except Exception as e: + logger.warning(traceback.format_exc()) + logger.warning( + f"插件 {smd.name} 未被正常终止: {str(e)}, 可能会导致该插件运行不正常。" + ) - await self._unbind_plugin(smd.name, smd.module_path) + await self._unbind_plugin(smd.name, smd.module_path) - star_handlers_registry.clear() - star_map.clear() - star_registry.clear() - else: - # 只重载指定插件 - smd = star_map.get(specified_module_path) - if smd: - try: - await self._terminate_plugin(smd) - except Exception as e: - logger.warning(traceback.format_exc()) - logger.warning( - f"插件 {smd.name} 未被正常终止: {str(e)}, 可能会导致该插件运行不正常。" - ) + star_handlers_registry.clear() + star_map.clear() + star_registry.clear() + else: + # 只重载指定插件 + smd = star_map.get(specified_module_path) + if smd: + try: + await self._terminate_plugin(smd) + except Exception as e: + logger.warning(traceback.format_exc()) + logger.warning( + f"插件 {smd.name} 未被正常终止: {str(e)}, 可能会导致该插件运行不正常。" + ) - await self._unbind_plugin(smd.name, specified_module_path) + await self._unbind_plugin(smd.name, specified_module_path) - result = await self.load(specified_module_path) + result = await self.load(specified_module_path) - # 更新所有插件的平台兼容性 - await self.update_all_platform_compatibility() + # 更新所有插件的平台兼容性 + await self.update_all_platform_compatibility() - return result + return result async def update_all_platform_compatibility(self): """更新所有插件的平台兼容性设置""" @@ -438,7 +438,7 @@ class PluginManager: ) if path in star_map: - # 通过__init__subclass__注册插件 + # 通过 __init__subclass__ 注册插件 metadata = star_map[path] try: @@ -452,8 +452,11 @@ class PluginManager: metadata.desc = metadata_yaml.desc metadata.version = metadata_yaml.version metadata.repo = metadata_yaml.repo - except Exception: - pass + except Exception as e: + logger.warning( + f"插件 {root_dir_name} 元数据载入失败: {str(e)}。使用默认元数据。" + ) + logger.info(metadata) metadata.config = plugin_config if path not in inactivated_plugins: # 只有没有禁用插件时才实例化插件类 @@ -507,8 +510,6 @@ class PluginManager: if func_tool.name in inactivated_llm_tools: func_tool.active = False - star_registry.append(metadata) - else: # v3.4.0 以前的方式注册插件 logger.debug( @@ -627,43 +628,45 @@ class PluginManager: - readme: README.md 文件的内容(如果存在) 如果找不到插件元数据则返回 None。 """ - plugin_path = await self.updator.install(repo_url, proxy) - # reload the plugin - dir_name = os.path.basename(plugin_path) - await self.load(specified_dir_name=dir_name) + async with self._pm_lock: + plugin_path = await self.updator.install(repo_url, proxy) + # reload the plugin + dir_name = os.path.basename(plugin_path) + await self.load(specified_dir_name=dir_name) - # Get the plugin metadata to return repo info - plugin = self.context.get_registered_star(dir_name) - if not plugin: - # Try to find by other name if directory name doesn't match plugin name - for star in self.context.get_all_stars(): - if star.root_dir_name == dir_name: - plugin = star - break + # Get the plugin metadata to return repo info + plugin = self.context.get_registered_star(dir_name) + if not plugin: + # Try to find by other name if directory name doesn't match plugin name + for star in self.context.get_all_stars(): + if star.root_dir_name == dir_name: + plugin = star + break - # Extract README.md content if exists - readme_content = None - readme_path = os.path.join(plugin_path, "README.md") - if not os.path.exists(readme_path): - readme_path = os.path.join(plugin_path, "readme.md") + # Extract README.md content if exists + readme_content = None + readme_path = os.path.join(plugin_path, "README.md") + if not os.path.exists(readme_path): + readme_path = os.path.join(plugin_path, "readme.md") - if os.path.exists(readme_path) and nh3: - try: - with open(readme_path, "r", encoding="utf-8") as f: - readme_content = f.read() - cleaned_content = nh3.clean(readme_content) - except Exception as e: - logger.warning(f"读取插件 {dir_name} 的 README.md 文件失败: {str(e)}") + if os.path.exists(readme_path): + try: + with open(readme_path, "r", encoding="utf-8") as f: + readme_content = f.read() + except Exception as e: + logger.warning( + f"读取插件 {dir_name} 的 README.md 文件失败: {str(e)}" + ) - plugin_info = None - if plugin: - plugin_info = { - "repo": plugin.repo, - "readme": cleaned_content, - "name": plugin.name, - } + plugin_info = None + if plugin: + plugin_info = { + "repo": plugin.repo, + "readme": readme_content, + "name": plugin.name, + } - return plugin_info + return plugin_info async def uninstall_plugin(self, plugin_name: str): """卸载指定的插件。 @@ -674,32 +677,33 @@ class PluginManager: Raises: Exception: 当插件不存在、是保留插件时,或删除插件文件夹失败时抛出异常 """ - plugin = self.context.get_registered_star(plugin_name) - if not plugin: - raise Exception("插件不存在。") - if plugin.reserved: - raise Exception("该插件是 AstrBot 保留插件,无法卸载。") - root_dir_name = plugin.root_dir_name - ppath = self.plugin_store_path + async with self._pm_lock: + plugin = self.context.get_registered_star(plugin_name) + if not plugin: + raise Exception("插件不存在。") + if plugin.reserved: + raise Exception("该插件是 AstrBot 保留插件,无法卸载。") + root_dir_name = plugin.root_dir_name + ppath = self.plugin_store_path - # 终止插件 - try: - await self._terminate_plugin(plugin) - except Exception as e: - logger.warning(traceback.format_exc()) - logger.warning( - f"插件 {plugin_name} 未被正常终止 {str(e)}, 可能会导致资源泄露等问题。" - ) + # 终止插件 + try: + await self._terminate_plugin(plugin) + except Exception as e: + logger.warning(traceback.format_exc()) + logger.warning( + f"插件 {plugin_name} 未被正常终止 {str(e)}, 可能会导致资源泄露等问题。" + ) - # 从 star_registry 和 star_map 中删除 - await self._unbind_plugin(plugin_name, plugin.module_path) + # 从 star_registry 和 star_map 中删除 + await self._unbind_plugin(plugin_name, plugin.module_path) - try: - remove_dir(os.path.join(ppath, root_dir_name)) - except Exception as e: - raise Exception( - f"移除插件成功,但是删除插件文件夹失败: {str(e)}。您可以手动删除该文件夹,位于 addons/plugins/ 下。" - ) + try: + remove_dir(os.path.join(ppath, root_dir_name)) + except Exception as e: + raise Exception( + f"移除插件成功,但是删除插件文件夹失败: {str(e)}。您可以手动删除该文件夹,位于 addons/plugins/ 下。" + ) async def _unbind_plugin(self, plugin_name: str, plugin_module_path: str): """解绑并移除一个插件。 @@ -752,33 +756,34 @@ class PluginManager: 将插件的 module_path 加入到 data/shared_preferences.json 的 inactivated_plugins 列表中。 并且同时将插件启用的 llm_tool 禁用。 """ - plugin = self.context.get_registered_star(plugin_name) - if not plugin: - raise Exception("插件不存在。") + async with self._pm_lock: + plugin = self.context.get_registered_star(plugin_name) + if not plugin: + raise Exception("插件不存在。") - # 调用插件的终止方法 - await self._terminate_plugin(plugin) + # 调用插件的终止方法 + await self._terminate_plugin(plugin) - # 加入到 shared_preferences 中 - inactivated_plugins: list = sp.get("inactivated_plugins", []) - if plugin.module_path not in inactivated_plugins: - inactivated_plugins.append(plugin.module_path) + # 加入到 shared_preferences 中 + inactivated_plugins: list = sp.get("inactivated_plugins", []) + if plugin.module_path not in inactivated_plugins: + inactivated_plugins.append(plugin.module_path) - inactivated_llm_tools: list = list( - set(sp.get("inactivated_llm_tools", [])) - ) # 后向兼容 + inactivated_llm_tools: list = list( + set(sp.get("inactivated_llm_tools", [])) + ) # 后向兼容 - # 禁用插件启用的 llm_tool - for func_tool in llm_tools.func_list: - if func_tool.handler_module_path == plugin.module_path: - func_tool.active = False - if func_tool.name not in inactivated_llm_tools: - inactivated_llm_tools.append(func_tool.name) + # 禁用插件启用的 llm_tool + for func_tool in llm_tools.func_list: + if func_tool.handler_module_path == plugin.module_path: + func_tool.active = False + if func_tool.name not in inactivated_llm_tools: + inactivated_llm_tools.append(func_tool.name) - sp.put("inactivated_plugins", inactivated_plugins) - sp.put("inactivated_llm_tools", inactivated_llm_tools) + sp.put("inactivated_plugins", inactivated_plugins) + sp.put("inactivated_llm_tools", inactivated_llm_tools) - plugin.activated = False + plugin.activated = False @staticmethod async def _terminate_plugin(star_metadata: StarMetadata): diff --git a/astrbot/dashboard/routes/chat.py b/astrbot/dashboard/routes/chat.py index b704a8888..651f1b65c 100644 --- a/astrbot/dashboard/routes/chat.py +++ b/astrbot/dashboard/routes/chat.py @@ -166,15 +166,12 @@ class ChatRoute(Route): type = result.get("type") cid = result.get("cid") streaming = result.get("streaming", False) - chain_type = result.get("chain_type") yield f"data: {json.dumps(result, ensure_ascii=False)}\n\n" await asyncio.sleep(0.05) - if streaming and type != "end": - # If the result is still streaming, we continue to wait for more data - continue - - if result_text: + if type == "end": + break + elif (streaming and type == "complete") or not streaming: # append bot message conversation = self.db.get_conversation_by_user_id( username, cid @@ -188,10 +185,6 @@ class ChatRoute(Route): self.db.update_conversation( username, cid, history=json.dumps(history) ) - if chain_type not in ["tool_call", "tool_call_result"]: - # If the result is not a tool call or tool call result, - # we can break the loop and end the stream - break except BaseException as _: logger.debug(f"用户 {username} 断开聊天长连接。") diff --git a/astrbot/dashboard/routes/conversation.py b/astrbot/dashboard/routes/conversation.py index d73e6186a..dde6f9a5a 100644 --- a/astrbot/dashboard/routes/conversation.py +++ b/astrbot/dashboard/routes/conversation.py @@ -166,7 +166,7 @@ class ConversationRoute(Route): if not user_id or not cid: return Response().error("缺少必要参数: user_id 和 cid").__dict__ - self.core_lifecycle.conversation_manager.delete_conversation( + await self.core_lifecycle.conversation_manager.delete_conversation( unified_msg_origin=user_id, conversation_id=cid ) return Response().ok({"message": "对话删除成功"}).__dict__ diff --git a/astrbot/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py index 4f89dbcfc..6f37609b2 100644 --- a/astrbot/dashboard/routes/plugin.py +++ b/astrbot/dashboard/routes/plugin.py @@ -18,12 +18,6 @@ from astrbot.core.star.filter.regex import RegexFilter from astrbot.core.star.star_handler import EventType from astrbot.core import DEMO_MODE -try: - import nh3 -except ImportError: - logger.warning("未安装 nh3 库,无法清理插件 README.md 中的 HTML 标签。") - nh3 = None - class PluginRoute(Route): def __init__( @@ -332,9 +326,6 @@ class PluginRoute(Route): return Response().error(str(e)).__dict__ async def get_plugin_readme(self): - if not nh3: - return Response().error("未安装 nh3 库").__dict__ - plugin_name = request.args.get("name") logger.debug(f"正在获取插件 {plugin_name} 的README文件内容") @@ -370,11 +361,9 @@ class PluginRoute(Route): with open(readme_path, "r", encoding="utf-8") as f: readme_content = f.read() - cleaned_content = nh3.clean(readme_content) - return ( Response() - .ok({"content": cleaned_content}, "成功获取README内容") + .ok({"content": readme_content}, "成功获取README内容") .__dict__ ) except Exception as e: @@ -395,12 +384,14 @@ class PluginRoute(Route): platform_type = platform.get("type", "") platform_id = platform.get("id", "") - platforms.append({ - "name": platform_id, # 使用type作为name,这是系统内部使用的平台名称 - "id": platform_id, # 保留id字段以便前端可以显示 - "type": platform_type, - "display_name": f"{platform_type}({platform_id})", - }) + platforms.append( + { + "name": platform_id, # 使用type作为name,这是系统内部使用的平台名称 + "id": platform_id, # 保留id字段以便前端可以显示 + "type": platform_type, + "display_name": f"{platform_type}({platform_id})", + } + ) adjusted_platform_enable = {} for platform_id, plugins in platform_enable.items(): @@ -409,11 +400,13 @@ class PluginRoute(Route): # 获取所有插件,包括系统内部插件 plugins = [] for plugin in self.plugin_manager.context.get_all_stars(): - plugins.append({ - "name": plugin.name, - "desc": plugin.desc, - "reserved": plugin.reserved, # 添加reserved标志 - }) + plugins.append( + { + "name": plugin.name, + "desc": plugin.desc, + "reserved": plugin.reserved, # 添加reserved标志 + } + ) logger.debug( f"获取插件平台配置: 原始配置={platform_enable}, 调整后={adjusted_platform_enable}" @@ -421,11 +414,13 @@ class PluginRoute(Route): return ( Response() - .ok({ - "platforms": platforms, - "plugins": plugins, - "platform_enable": adjusted_platform_enable, - }) + .ok( + { + "platforms": platforms, + "plugins": plugins, + "platform_enable": adjusted_platform_enable, + } + ) .__dict__ ) except Exception as e: diff --git a/changelogs/v3.5.20.md b/changelogs/v3.5.20.md new file mode 100644 index 000000000..6c0a355ed --- /dev/null +++ b/changelogs/v3.5.20.md @@ -0,0 +1,6 @@ +# What's Changed + +1. 修复: 工具调用的结果错误地被当作消息发送 +2. 新增: 支持对引用消息中的图片进行理解(QQ, Telegram) +3. 优化: QQ 主动消息发送逻辑,优化合并消息、文件、语音、图片等的处理 +4. 优化: 移除插件的 @register 插件注册装饰器(插件只需要继承 Star 类即可,AstrBot 会自动处理),简化插件代码开发 diff --git a/changelogs/v3.5.21.md b/changelogs/v3.5.21.md new file mode 100644 index 000000000..cb82c155e --- /dev/null +++ b/changelogs/v3.5.21.md @@ -0,0 +1,7 @@ +# What's Changed + +1. 修复: WebChat 下图片、音频消息没有被正确渲染 +2. 修复: 部分情况下,插件信息无法正确显示 +3. 修复: WebChat 下开启分段回复后,消息错位 +4. 优化: 提高插件加载的性能和稳定性 +5. 修复: WebUI 对话数据库页中,无法真正删除对话 diff --git a/changelogs/v3.5.22.md b/changelogs/v3.5.22.md new file mode 100644 index 000000000..752c57504 --- /dev/null +++ b/changelogs/v3.5.22.md @@ -0,0 +1,3 @@ +# What's Changed + +1. 修复: 用户环境没有 Docker 时,可能导致死锁(表现为在初始化 AstrBot 的时候卡住) diff --git a/dashboard/src/components/shared/ItemCard.vue b/dashboard/src/components/shared/ItemCard.vue index c2aeaf01e..ff790cb7b 100644 --- a/dashboard/src/components/shared/ItemCard.vue +++ b/dashboard/src/components/shared/ItemCard.vue @@ -47,7 +47,6 @@ contain width="120" height="120" - class="rounded-circle" > diff --git a/dashboard/src/views/ChatPage.vue b/dashboard/src/views/ChatPage.vue index f7cd6542a..3d6aeb444 100644 --- a/dashboard/src/views/ChatPage.vue +++ b/dashboard/src/views/ChatPage.vue @@ -171,14 +171,33 @@ - +
-
+ +
+ + +
+
+ +
+
+ + +
+ +
` + if (!message[i].embedded_images) { + message[i].embedded_images = []; + } + message[i].embedded_images.push(imageUrl); + message[i].message = ''; // 清空message,避免显示标记文本 } + if (message[i].message.startsWith('[RECORD]')) { let audio = message[i].message.replace('[RECORD]', ''); const audioUrl = await this.getMediaFile(audio); - message[i].message = `` + message[i].embedded_audio = audioUrl; + message[i].message = ''; // 清空message,避免显示标记文本 } + if (message[i].image_url && message[i].image_url.length > 0) { for (let j = 0; j < message[i].image_url.length; j++) { message[i].image_url[j] = await this.getMediaFile(message[i].image_url[j]); } } + if (message[i].audio_url) { message[i].audio_url = await this.getMediaFile(message[i].audio_url); } @@ -924,9 +947,6 @@ export default { continue; } - if (chunk_json.type === 'heartbeat') { - continue; // 心跳包 - } if (chunk_json.type === 'error') { console.error('Error received:', chunk_json.data); continue; @@ -937,7 +957,8 @@ export default { const imageUrl = await this.getMediaFile(img); let bot_resp = { type: 'bot', - message: `` + message: '', + embedded_images: [imageUrl] } this.messages.push(bot_resp); } else if (chunk_json.type === 'record') { @@ -945,10 +966,8 @@ export default { const audioUrl = await this.getMediaFile(audio); let bot_resp = { type: 'bot', - message: `` + message: '', + embedded_audio: audioUrl } this.messages.push(bot_resp); } else if (chunk_json.type === 'plain') { @@ -962,20 +981,19 @@ export default { } else { message_obj.message.value += chunk_json.data; } - } else if (chunk_json.type === 'end') { - in_streaming = false; - // 在消息流结束后初始化代码复制按钮和图片点击事件 - this.initCodeCopyButtons(); - this.initImageClickEvents(); - continue; } else if (chunk_json.type === 'update_title') { // 更新对话标题 const conversation = this.conversations.find(c => c.cid === chunk_json.cid); if (conversation) { conversation.title = chunk_json.data; } - } else { - console.warn('未知数据类型:', chunk_json.type); + } + if ((chunk_json.type === 'break' && chunk_json.streaming) || !chunk_json.streaming) { + // break means a segment end + in_streaming = false; + // 在消息流结束后初始化代码复制按钮和图片点击事件 + this.initCodeCopyButtons(); + this.initImageClickEvents(); } this.scrollToBottom(); } @@ -1077,19 +1095,43 @@ export default { // 复制bot消息到剪贴板 copyBotMessage(message, messageIndex) { - // 移除HTML标签,获取纯文本 - const tempDiv = document.createElement('div'); - tempDiv.innerHTML = message; - const plainText = tempDiv.textContent || tempDiv.innerText || message; + // 获取对应的消息对象 + const msgObj = this.messages[messageIndex]; + let textToCopy = ''; + + // 如果有文本消息,添加到复制内容中 + if (message && message.trim()) { + // 移除HTML标签,获取纯文本 + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = message; + textToCopy = tempDiv.textContent || tempDiv.innerText || message; + } + + // 如果有内嵌图片,添加说明 + if (msgObj && msgObj.embedded_images && msgObj.embedded_images.length > 0) { + if (textToCopy) textToCopy += '\n\n'; + textToCopy += `[包含 ${msgObj.embedded_images.length} 张图片]`; + } + + // 如果有内嵌音频,添加说明 + if (msgObj && msgObj.embedded_audio) { + if (textToCopy) textToCopy += '\n\n'; + textToCopy += '[包含音频内容]'; + } + + // 如果没有任何内容,使用默认文本 + if (!textToCopy.trim()) { + textToCopy = '[媒体内容]'; + } - navigator.clipboard.writeText(plainText).then(() => { + navigator.clipboard.writeText(textToCopy).then(() => { console.log('消息已复制到剪贴板'); this.showCopySuccess(messageIndex); }).catch(err => { console.error('复制失败:', err); // 如果现代API失败,使用传统方法 const textArea = document.createElement('textarea'); - textArea.value = plainText; + textArea.value = textToCopy; document.body.appendChild(textArea); textArea.select(); try { @@ -1920,4 +1962,39 @@ export default { padding-right: 32px; flex-shrink: 0; } + +.embedded-images { + margin-top: 8px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.embedded-image { + display: flex; + justify-content: flex-start; +} + +.bot-embedded-image { + max-width: 80%; + width: auto; + height: auto; + border-radius: 8px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + cursor: pointer; + transition: transform 0.2s ease; +} + +.bot-embedded-image:hover { + transform: scale(1.02); +} + +.embedded-audio { + margin-top: 8px; +} + +.embedded-audio .audio-player { + width: 100%; + max-width: 300px; +} \ No newline at end of file diff --git a/dashboard/src/views/ProviderPage.vue b/dashboard/src/views/ProviderPage.vue index d140ad70f..f98856692 100644 --- a/dashboard/src/views/ProviderPage.vue +++ b/dashboard/src/views/ProviderPage.vue @@ -60,6 +60,7 @@ title-field="id" enabled-field="enable" @toggle-enabled="providerStatusChange" + :bglogo="getProviderIcon(provider.provider)" @delete="deleteProvider" @edit="configExistingProvider">