diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..ae5829fcc --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,15 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: astrbot +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +thanks_dev: # Replace with a single thanks.dev username +custom: ['https://afdian.com/a/astrbot_team'] diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 6b226d48a..98e6a3ada 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,5 +1,5 @@ -修复了 #XYZ +解决了 #XYZ ### Motivation @@ -10,5 +10,10 @@ ### Check -- [ ] 我的 Commit Message 符合良好的[规范](https://www.conventionalcommits.org/en/v1.0.0/#summary) -- [ ] 我新增/修复/优化的功能经过良好的测试 + + + +- [ ] 😊 我的 Commit Message 符合良好的[规范](https://www.conventionalcommits.org/en/v1.0.0/#summary) +- [ ] 👀 我的更改经过良好的测试 +- [ ] 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 `requirements.txt` 和 `pyproject.toml` 文件相应位置。 +- [ ] 😮 我的更改没有引入恶意代码 diff --git a/README.md b/README.md index 7c7d12825..6efd84637 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,8 @@ pre-commit install ## ✨ Demo +
👉 点击展开多张 Demo 截图 👈 +
@@ -173,6 +175,9 @@ _✨ WebUI ✨_
+
+ + ## ❤️ Special Thanks 特别感谢所有 Contributors 和插件开发者对 AstrBot 的贡献 ❤️ diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index b2f4d80fd..664a11307 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -140,6 +140,7 @@ CONFIG_METADATA_2 = { "enable": False, "ws_reverse_host": "0.0.0.0", "ws_reverse_port": 6199, + "ws_reverse_token": "", }, "gewechat(微信)": { "id": "gwchat", @@ -186,6 +187,9 @@ CONFIG_METADATA_2 = { "start_message": "Hello, I'm AstrBot!", "telegram_api_base_url": "https://api.telegram.org/bot", "telegram_file_base_url": "https://api.telegram.org/file/bot", + "telegram_command_register": True, + "telegram_command_auto_refresh": True, + "telegram_command_register_interval": 300, }, }, "items": { @@ -194,6 +198,21 @@ CONFIG_METADATA_2 = { "type": "string", "hint": "如果你的网络环境为中国大陆,请在 `其他配置` 处设置代理或更改 api_base。", }, + "telegram_command_register": { + "description": "Telegram 命令注册", + "type": "bool", + "hint": "启用后,AstrBot 将会自动注册 Telegram 命令。", + }, + "telegram_command_auto_refresh": { + "description": "Telegram 命令自动刷新", + "type": "bool", + "hint": "启用后,AstrBot 将会在运行时自动刷新 Telegram 命令。(单独设置此项无效)", + }, + "telegram_command_register_interval": { + "description": "Telegram 命令自动刷新间隔", + "type": "int", + "hint": "Telegram 命令自动刷新间隔,单位为秒。", + }, "id": { "description": "ID", "type": "string", @@ -240,6 +259,11 @@ CONFIG_METADATA_2 = { "type": "int", "hint": "aiocqhttp 适配器的反向 Websocket 端口。", }, + "ws_reverse_token": { + "description": "反向 Websocket Token", + "type": "string", + "hint": "aiocqhttp 适配器的反向 Websocket Token。未设置则不启用 Token 验证。", + }, "lark_bot_name": { "description": "飞书机器人的名字", "type": "string", diff --git a/astrbot/core/message/components.py b/astrbot/core/message/components.py index 781dfafca..5020d6b26 100644 --- a/astrbot/core/message/components.py +++ b/astrbot/core/message/components.py @@ -407,17 +407,15 @@ class Reply(BaseMessageComponent): id: T.Union[str, int] """所引用的消息 ID""" chain: T.Optional[T.List["BaseMessageComponent"]] = [] - """引用的消息段列表""" + """被引用的消息段列表""" sender_id: T.Optional[int] | T.Optional[str] = 0 - """引用的消息发送者 ID""" + """被引用的消息对应的发送者的 ID""" sender_nickname: T.Optional[str] = "" - """引用的消息发送者昵称""" + """被引用的消息对应的发送者的昵称""" time: T.Optional[int] = 0 - """引用的消息发送时间""" + """被引用的消息发送时间""" message_str: T.Optional[str] = "" - """解析后的纯文本消息字符串""" - sender_str: T.Optional[str] = "" - """被引用的消息纯文本""" + """被引用的消息解析后的纯文本消息字符串""" text: T.Optional[str] = "" """deprecated""" diff --git a/astrbot/core/pipeline/process_stage/method/llm_request.py b/astrbot/core/pipeline/process_stage/method/llm_request.py index fd70275d8..28745f2c5 100644 --- a/astrbot/core/pipeline/process_stage/method/llm_request.py +++ b/astrbot/core/pipeline/process_stage/method/llm_request.py @@ -26,6 +26,13 @@ from astrbot.core.provider.entities import ( ) from astrbot.core.star.star_handler import star_handlers_registry, EventType from astrbot.core.star.star import star_map +from mcp.types import ( + TextContent, + ImageContent, + EmbeddedResource, + TextResourceContents, + BlobResourceContents, +) class LLMRequestSubStage(Stage): @@ -66,9 +73,9 @@ class LLMRequestSubStage(Stage): if event.get_extra("provider_request"): req = event.get_extra("provider_request") - assert isinstance( - req, ProviderRequest - ), "provider_request 必须是 ProviderRequest 类型。" + assert isinstance(req, ProviderRequest), ( + "provider_request 必须是 ProviderRequest 类型。" + ) if req.conversation: all_contexts = json.loads(req.conversation.history) @@ -149,7 +156,14 @@ class LLMRequestSubStage(Stage): -(self.max_context_length - self.dequeue_context_length + 1) * 2 : ] # 找到第一个role 为 user 的索引,确保上下文格式正确 - index = next((i for i, item in enumerate(req.contexts) if item.get("role") == "user"), None) + index = next( + ( + i + for i, item in enumerate(req.contexts) + if item.get("role") == "user" + ), + None, + ) if index is not None and index > 0: req.contexts = req.contexts[index:] @@ -265,6 +279,12 @@ class LLMRequestSubStage(Stage): event.set_extra("tool_call_result", None) yield + # 暂时直接发出去 + if img_b64 := event.get_extra("tool_call_img_respond"): + await event.send(MessageChain(chain=[Image.fromBase64(img_b64)])) + event.set_extra("tool_call_img_respond", None) + yield + async def _handle_llm_response( self, event: AstrMessageEvent, @@ -375,21 +395,68 @@ class LLMRequestSubStage(Stage): client = req.func_tool.mcp_client_dict[func_tool.mcp_server_name] res = await client.session.call_tool(func_tool.name, func_tool_args) if res: - # TODO content的类型可能包括list[TextContent | ImageContent | EmbeddedResource],这里只处理了TextContent。 - tool_call_result.append( - ToolCallMessageSegment( - role="tool", - tool_call_id=func_tool_id, - content=res.content[0].text, + # TODO 仅对ImageContent | EmbeddedResource进行了简单的Fallback + if isinstance(res.content[0], TextContent): + tool_call_result.append( + ToolCallMessageSegment( + role="tool", + tool_call_id=func_tool_id, + content=res.content[0].text, + ) ) - ) + elif isinstance(res.content[0], ImageContent): + tool_call_result.append( + ToolCallMessageSegment( + role="tool", + tool_call_id=func_tool_id, + content="返回了图片(已直接发送给用户)", + ) + ) + event.set_extra( + "tool_call_img_respond", + res.content[0].data, + ) + elif isinstance(res.content[0], EmbeddedResource): + resource = res.content[0].resource + if isinstance(resource, TextResourceContents): + tool_call_result.append( + ToolCallMessageSegment( + role="tool", + tool_call_id=func_tool_id, + content=resource.text, + ) + ) + elif ( + isinstance(resource, BlobResourceContents) + and resource.mimeType + and resource.mimeType.startswith("image/") + ): + tool_call_result.append( + ToolCallMessageSegment( + role="tool", + tool_call_id=func_tool_id, + content="返回了图片(已直接发送给用户)", + ) + ) + event.set_extra( + "tool_call_img_respond", + res.content[0].data, + ) + else: + tool_call_result.append( + ToolCallMessageSegment( + role="tool", + tool_call_id=func_tool_id, + content="返回的数据类型不受支持", + ) + ) else: # 获取处理器,过滤掉平台不兼容的处理器 platform_id = event.get_platform_id() star_md = star_map.get(func_tool.handler_module_path) if ( - star_md and - platform_id in star_md.supported_platforms + star_md + and platform_id in star_md.supported_platforms and not star_md.supported_platforms[platform_id] ): logger.debug( diff --git a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py index 198946b1a..a1822c5e9 100644 --- a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +++ b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py @@ -3,6 +3,7 @@ import time import asyncio import logging import uuid +import itertools from typing import Awaitable, Any from aiocqhttp import CQHttp, Event from astrbot.api.platform import ( @@ -45,7 +46,12 @@ class AiocqhttpAdapter(Platform): ) self.bot = CQHttp( - use_ws_reverse=True, import_name="aiocqhttp", api_timeout_sec=180 + use_ws_reverse=True, + import_name="aiocqhttp", + api_timeout_sec=180, + access_token=platform_config.get( + "ws_reverse_token" + ), # 以防旧版本配置不存在 ) @self.bot.on_request() @@ -119,6 +125,12 @@ class AiocqhttpAdapter(Platform): abm.type = MessageType.FRIEND_MESSAGE if self.unique_session and abm.type == MessageType.GROUP_MESSAGE: abm.session_id = str(abm.sender.user_id) + "_" + str(event.group_id) + else: + abm.session_id = ( + str(event.group_id) + if abm.type == MessageType.GROUP_MESSAGE + else abm.sender.user_id + ) abm.message_str = "" abm.message = [] abm.timestamp = int(time.time()) @@ -202,82 +214,85 @@ class AiocqhttpAdapter(Platform): return # 按消息段类型类型适配 - for m in event.message: - t = m["type"] + for t, m_group in itertools.groupby(event.message, key=lambda x: x["type"]): a = None if t == "text": - message_str += m["data"]["text"].strip() - a = ComponentTypes[t](**m["data"]) # noqa: F405 + # 合并相邻文本段 + message_str = "".join(m["data"]["text"] for m in m_group).strip() + a = ComponentTypes[t](text=message_str) # noqa: F405 abm.message.append(a) elif t == "file": - if m["data"].get("url") and m["data"].get("url").startswith("http"): - # Lagrange - logger.info("guessing lagrange") + for m in m_group: + if m["data"].get("url") and m["data"].get("url").startswith("http"): + # Lagrange + logger.info("guessing lagrange") - file_name = m["data"].get("file_name", "file") - path = os.path.join("data/temp", file_name) - await download_file(m["data"]["url"], path) + file_name = m["data"].get("file_name", "file") + path = os.path.join("data/temp", file_name) + await download_file(m["data"]["url"], path) - m["data"] = {"file": path, "name": file_name} - a = ComponentTypes[t](**m["data"]) # noqa: F405 - abm.message.append(a) - - else: - try: - # Napcat, LLBot - ret = await self.bot.call_action( - action="get_file", - file_id=event.message[0]["data"]["file_id"], - ) - if not ret.get("file", None): - raise ValueError(f"无法解析文件响应: {ret}") - if not os.path.exists(ret["file"]): - raise FileNotFoundError( - f"文件不存在或者权限问题: {ret['file']}。如果您使用 Docker 部署了 AstrBot 或者消息协议端(Napcat等),请先映射路径。如果路径在 /root 目录下,请用 sudo 打开 AstrBot" - ) - - m["data"] = {"file": ret["file"], "name": ret["file_name"]} + m["data"] = {"file": path, "name": file_name} a = ComponentTypes[t](**m["data"]) # noqa: F405 abm.message.append(a) - except ActionFailed as e: - logger.error(f"获取文件失败: {e},此消息段将被忽略。") - except BaseException as e: - logger.error(f"获取文件失败: {e},此消息段将被忽略。") + + else: + try: + # Napcat, LLBot + ret = await self.bot.call_action( + action="get_file", + file_id=event.message[0]["data"]["file_id"], + ) + if not ret.get("file", None): + raise ValueError(f"无法解析文件响应: {ret}") + if not os.path.exists(ret["file"]): + raise FileNotFoundError( + f"文件不存在或者权限问题: {ret['file']}。如果您使用 Docker 部署了 AstrBot 或者消息协议端(Napcat等),请先映射路径。如果路径在 /root 目录下,请用 sudo 打开 AstrBot" + ) + + m["data"] = {"file": ret["file"], "name": ret["file_name"]} + a = ComponentTypes[t](**m["data"]) # noqa: F405 + abm.message.append(a) + except ActionFailed as e: + logger.error(f"获取文件失败: {e},此消息段将被忽略。") + except BaseException as e: + logger.error(f"获取文件失败: {e},此消息段将被忽略。") elif t == "reply": - if not get_reply: - a = ComponentTypes[t](**m["data"]) # noqa: F405 - abm.message.append(a) - else: - try: - reply_event_data = await self.bot.call_action( - action="get_msg", - message_id=int(m["data"]["id"]), - ) - abm_reply = await self._convert_handle_message_event( - Event.from_payload(reply_event_data), get_reply=False - ) - - reply_seg = Reply( - id=abm_reply.message_id, - chain=abm_reply.message, - sender_id=abm_reply.sender.user_id, - sender_nickname=abm_reply.sender.nickname, - time=abm_reply.timestamp, - message_str=abm_reply.message_str, - text=abm_reply.message_str, # for compatibility - qq=abm_reply.sender.user_id, # for compatibility - ) - - abm.message.append(reply_seg) - except BaseException as e: - logger.error(f"获取引用消息失败: {e}。") + for m in m_group: + if not get_reply: a = ComponentTypes[t](**m["data"]) # noqa: F405 abm.message.append(a) + else: + try: + reply_event_data = await self.bot.call_action( + action="get_msg", + message_id=int(m["data"]["id"]), + ) + abm_reply = await self._convert_handle_message_event( + Event.from_payload(reply_event_data), get_reply=False + ) + + reply_seg = Reply( + id=abm_reply.message_id, + chain=abm_reply.message, + sender_id=abm_reply.sender.user_id, + sender_nickname=abm_reply.sender.nickname, + time=abm_reply.timestamp, + message_str=abm_reply.message_str, + text=abm_reply.message_str, # for compatibility + qq=abm_reply.sender.user_id, # for compatibility + ) + + abm.message.append(reply_seg) + except BaseException as e: + logger.error(f"获取引用消息失败: {e}。") + a = ComponentTypes[t](**m["data"]) # noqa: F405 + abm.message.append(a) else: - a = ComponentTypes[t](**m["data"]) # noqa: F405 - abm.message.append(a) + for m in m_group: + a = ComponentTypes[t](**m["data"]) # noqa: F405 + abm.message.append(a) abm.timestamp = int(time.time()) abm.message_str = message_str diff --git a/astrbot/core/platform/sources/gewechat/client.py b/astrbot/core/platform/sources/gewechat/client.py index ccecc0c71..17b9c82d3 100644 --- a/astrbot/core/platform/sources/gewechat/client.py +++ b/astrbot/core/platform/sources/gewechat/client.py @@ -143,18 +143,25 @@ class SimpleGewechatClient: content = d["Content"]["string"] # 消息内容 at_me = False + at_wxids = [] if "@chatroom" in from_user_name: abm.type = MessageType.GROUP_MESSAGE _t = content.split(":\n") user_id = _t[0] content = _t[1] + # at + msg_source = d["MsgSource"] if "\u2005" in content: # at # content = content.split('\u2005')[1] content = re.sub(r"@[^\u2005]*\u2005", "", content) + at_wxids = re.findall( + r")", + msg_source, + ) + abm.group_id = from_user_name - # at - msg_source = d["MsgSource"] + if ( f"" in msg_source or f"" in msg_source @@ -167,13 +174,12 @@ class SimpleGewechatClient: user_id = from_user_name # 检查消息是否由自己发送,若是则忽略 - if user_id == abm.self_id: - logger.info("忽略自己发送的消息") - return None + # 已经有可配置项专门配置是否需要响应自己的消息,因此这里注释掉。 + # if user_id == abm.self_id: + # logger.info("忽略自己发送的消息") + # return None abm.message = [] - if at_me: - abm.message.insert(0, At(qq=abm.self_id)) # 解析用户真实名字 user_real_name = "unknown" @@ -197,7 +203,19 @@ class SimpleGewechatClient: else: user_real_name = self.userrealnames[abm.group_id][user_id] else: - user_real_name = d.get("PushContent", "unknown : ").split(" : ")[0] + try: + info = (await self.get_user_or_group_info(user_id))["data"][0] + user_real_name = info["nickName"] + except Exception as e: + logger.debug(f"获取用户 {user_id} 昵称失败: {e}") + user_real_name = user_id + + if at_me: + abm.message.insert(0, At(qq=abm.self_id, name=self.nickname)) + for wxid in at_wxids: + # 群聊里 At 其他人的列表 + _username = self.userrealnames.get(abm.group_id, {}).get(wxid, wxid) + abm.message.append(At(qq=wxid, name=_username)) abm.sender = MessageMember(user_id, user_real_name) abm.raw_message = d @@ -248,9 +266,12 @@ class SimpleGewechatClient: logger.info("消息类型(48):地理位置") case 49: # 公众号/文件/小程序/引用/转账/红包/视频号/群聊邀请 data_parser = GeweDataParser(content, abm.group_id == "") - abm_data = data_parser.parse_mutil_49() - if abm_data: - abm.message.append(abm_data) + segments = data_parser.parse_mutil_49() + if segments: + abm.message.extend(segments) + for seg in segments: + if isinstance(seg, Plain): + abm.message_str += seg.text case 51: # 帐号消息同步? logger.info("消息类型(51):帐号消息同步?") case 10000: # 被踢出群聊/更换群主/修改群名称 diff --git a/astrbot/core/platform/sources/gewechat/xml_data_parser.py b/astrbot/core/platform/sources/gewechat/xml_data_parser.py index 476c37644..1af4a051a 100644 --- a/astrbot/core/platform/sources/gewechat/xml_data_parser.py +++ b/astrbot/core/platform/sources/gewechat/xml_data_parser.py @@ -1,6 +1,11 @@ from defusedxml import ElementTree as eT from astrbot.api import logger -from astrbot.api.message_components import WechatEmoji as Emoji, Reply, Plain +from astrbot.api.message_components import ( + WechatEmoji as Emoji, + Reply, + Plain, + BaseMessageComponent, +) class GeweDataParser: @@ -11,7 +16,7 @@ class GeweDataParser: def _format_to_xml(self): return eT.fromstring(self.data) - def parse_mutil_49(self): + def parse_mutil_49(self) -> list[BaseMessageComponent] | None: appmsg_type = self._format_to_xml().find(".//appmsg/type") if appmsg_type is None: return @@ -34,13 +39,18 @@ class GeweDataParser: except Exception as e: logger.error(f"gewechat: parse_emoji failed, {e}") - def parse_reply(self) -> Reply | None: + def parse_reply(self) -> list[Reply, Plain] | None: + """解析引用消息 + + Returns: + list[Reply, Plain]: 一个包含两个元素的列表。Reply 消息对象和引用者说的文本内容。微信平台下引用消息时只能发送文本消息。 + """ try: replied_id = -1 replied_uid = 0 replied_nickname = "" - replied_content = "" - content = "" + replied_content = "" # 被引用者说的内容 + content = "" # 引用者说的内容 root = self._format_to_xml() refermsg = root.find(".//refermsg") @@ -57,22 +67,44 @@ class GeweDataParser: if displayname is not None: replied_nickname = displayname.text if refermsg_content is not None: - replied_content = refermsg_content.text + # 处理引用嵌套,包括嵌套公众号消息 + if refermsg_content.text.startswith( + "" + ) or refermsg_content.text.startswith(" None: """关闭并清理MCP客户端""" @@ -441,11 +440,19 @@ class FuncCall: """ # Gemini API 支持的数据类型和格式 - supported_types = {"string", "number", "integer", "boolean", "array", "object", "null"} + supported_types = { + "string", + "number", + "integer", + "boolean", + "array", + "object", + "null", + } supported_formats = { "string": {"enum", "date-time"}, "integer": {"int32", "int64"}, - "number": {"float", "double"} + "number": {"float", "double"}, } def convert_schema(schema: dict) -> dict: @@ -454,15 +461,25 @@ class FuncCall: if "type" in schema and schema["type"] in supported_types: result["type"] = schema["type"] - if ("format" in schema and - schema["format"] in supported_formats.get(result["type"], set())): + if "format" in schema and schema["format"] in supported_formats.get( + result["type"], set() + ): result["format"] = schema["format"] else: # 暂时指定默认为null result["type"] = "null" - support_fields = {"title", "description", "enum", "minimum", "maximum", - "maxItems", "minItems", "nullable", "required"} + support_fields = { + "title", + "description", + "enum", + "minimum", + "maximum", + "maxItems", + "minItems", + "nullable", + "required", + } result.update({k: schema[k] for k in support_fields if k in schema}) if "properties" in schema: @@ -487,9 +504,10 @@ class FuncCall: { "name": f.name, "description": f.description, - **({"parameters": convert_schema(f.parameters)}) + **({"parameters": convert_schema(f.parameters)}), } - for f in self.func_list if f.active + for f in self.func_list + if f.active ] declarations = {} diff --git a/astrbot/core/provider/sources/gemini_source.py b/astrbot/core/provider/sources/gemini_source.py index ef2305069..a175a3d68 100644 --- a/astrbot/core/provider/sources/gemini_source.py +++ b/astrbot/core/provider/sources/gemini_source.py @@ -182,11 +182,11 @@ class ProviderGoogleGenAI(Provider): def _prepare_conversation(self, payloads: Dict) -> List[types.Content]: """准备 Gemini SDK 的 Content 列表""" - def create_text_part(text: str) -> types.UserContent: + def create_text_part(text: str) -> types.Part: content_a = text if text else " " if not text: logger.warning("文本内容为空,已添加空格占位") - return types.UserContent(parts=[types.Part.from_text(text=content_a)]) + return types.Part.from_text(text=content_a) def process_image_url(image_url_dict: dict) -> types.Part: url = image_url_dict["url"] @@ -194,6 +194,12 @@ class ProviderGoogleGenAI(Provider): image_bytes = base64.b64decode(url.split(",", 1)[1]) return types.Part.from_bytes(data=image_bytes, mime_type=mime_type) + def append_or_extend(contents: list[types.Content], part: list[types.Part], content_cls: type[types.Content]) -> None: + if contents and isinstance(contents[-1], content_cls): + contents[-1].parts.extend(part) + else: + contents.append(content_cls(parts=part)) + gemini_contents: List[types.Content] = [] native_tool_enabled = any( [ @@ -205,60 +211,53 @@ class ProviderGoogleGenAI(Provider): role, content = message["role"], message.get("content") if role == "user": - if isinstance(content, str): - gemini_contents.append(create_text_part(content)) - elif isinstance(content, list): + if isinstance(content, list): parts = [ types.Part.from_text(text=item["text"] or " ") if item["type"] == "text" else process_image_url(item["image_url"]) for item in content ] - gemini_contents.append(types.UserContent(parts=parts)) + else: + parts = [create_text_part(content)] + append_or_extend(gemini_contents, parts, types.UserContent) elif role == "assistant": if content: - gemini_contents.append( - types.ModelContent(parts=[types.Part.from_text(text=content)]) - ) - elif "tool_calls" in message and not native_tool_enabled: - gemini_contents.extend( - [ - types.ModelContent( - parts=[ - types.Part.from_function_call( - name=tool["function"]["name"], - args=json.loads(tool["function"]["arguments"]), - ) - ] - ) - for tool in message["tool_calls"] - ] - ) + parts = [types.Part.from_text(text=content)] + append_or_extend(gemini_contents, parts, types.ModelContent) + elif not native_tool_enabled and "tool_calls" in message : + parts = [ + types.Part.from_function_call( + name=tool["function"]["name"], + args=json.loads(tool["function"]["arguments"]), + ) + for tool in message["tool_calls"] + ] + append_or_extend(gemini_contents, parts, types.ModelContent) else: logger.warning("assistant 角色的消息内容为空,已添加空格占位") - if native_tool_enabled: + if native_tool_enabled and "tool_calls" in message: logger.warning( "检测到启用Gemini原生工具,且上下文中存在函数调用,建议使用 /reset 重置上下文" ) - gemini_contents.append( - types.ModelContent(parts=[types.Part.from_text(text=" ")]) - ) + parts = [types.Part.from_text(text=" ")] + append_or_extend(gemini_contents, parts, types.ModelContent) elif role == "tool" and not native_tool_enabled: - gemini_contents.append( - types.UserContent( - parts=[ - types.Part.from_function_response( - name=message["tool_call_id"], - response={ - "name": message["tool_call_id"], - "content": message["content"], - }, - ) - ] + parts = [ + types.Part.from_function_response( + name=message["tool_call_id"], + response={ + "name": message["tool_call_id"], + "content": message["content"], + }, ) - ) + ] + append_or_extend(gemini_contents, parts, types.UserContent) + + if gemini_contents and isinstance(gemini_contents[0], types.ModelContent): + gemini_contents.pop() return gemini_contents diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index f4e02b5f5..5399fbc32 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -362,7 +362,7 @@ class ProviderOpenAIOfficial(Provider): available_api_keys = self.api_keys.copy() chosen_key = random.choice(available_api_keys) - e = None + last_exception = None retry_cnt = 0 for retry_cnt in range(max_retries): try: @@ -376,6 +376,7 @@ class ProviderOpenAIOfficial(Provider): payloads["messages"] = new_contexts context_query = new_contexts except Exception as e: + last_exception = e ( success, chosen_key, @@ -398,7 +399,9 @@ class ProviderOpenAIOfficial(Provider): if retry_cnt == max_retries - 1: logger.error(f"API 调用失败,重试 {max_retries} 次仍然失败。") - raise e + if last_exception is None: + raise Exception("未知错误") + raise last_exception return llm_response async def text_chat_stream( @@ -428,7 +431,7 @@ class ProviderOpenAIOfficial(Provider): available_api_keys = self.api_keys.copy() chosen_key = random.choice(available_api_keys) - e = None + last_exception = None retry_cnt = 0 for retry_cnt in range(max_retries): try: @@ -443,6 +446,7 @@ class ProviderOpenAIOfficial(Provider): payloads["messages"] = new_contexts context_query = new_contexts except Exception as e: + last_exception = e ( success, chosen_key, @@ -465,7 +469,9 @@ class ProviderOpenAIOfficial(Provider): if retry_cnt == max_retries - 1: logger.error(f"API 调用失败,重试 {max_retries} 次仍然失败。") - raise e + if last_exception is None: + raise Exception("未知错误") + raise last_exception async def _remove_image_from_context(self, contexts: List): """ @@ -505,7 +511,10 @@ class ProviderOpenAIOfficial(Provider): async def assemble_context(self, text: str, image_urls: List[str] = None) -> dict: """组装成符合 OpenAI 格式的 role 为 user 的消息段""" if image_urls: - user_content = {"role": "user", "content": [{"type": "text", "text": text if text else "[图片]"}]} + user_content = { + "role": "user", + "content": [{"type": "text", "text": text if text else "[图片]"}], + } for image_url in image_urls: if image_url.startswith("http"): image_path = await download_image_by_url(image_url) diff --git a/dashboard/src/stores/common.js b/dashboard/src/stores/common.js index 49f55f5db..410fcc7ce 100644 --- a/dashboard/src/stores/common.js +++ b/dashboard/src/stores/common.js @@ -145,6 +145,8 @@ export const useCommonStore = defineStore({ "tags": res.data.data[key]?.tags ? res.data.data[key].tags : [], "logo": res.data.data[key]?.logo ? res.data.data[key].logo : "", "pinned": res.data.data[key]?.pinned ? res.data.data[key].pinned : false, + "stars": res.data.data[key]?.stars ? res.data.data[key].stars : 0, + "updated_at": res.data.data[key]?.updated_at ? res.data.data[key].updated_at : "", }) } this.pluginMarketData = data; diff --git a/dashboard/src/views/ExtensionMarketplace.vue b/dashboard/src/views/ExtensionMarketplace.vue index 2e270d473..64914deed 100644 --- a/dashboard/src/views/ExtensionMarketplace.vue +++ b/dashboard/src/views/ExtensionMarketplace.vue @@ -99,15 +99,13 @@ import 'highlight.js/styles/github.css'; + +