diff --git a/.gitignore b/.gitignore index a863e36ec..865b0596d 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,5 @@ venv/* packages/python_interpreter/workplace .venv/* .conda/ +.idea/ +pytest.ini diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index ba87ba528..280c841d0 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -85,6 +85,7 @@ DEFAULT_CONFIG = { "enable": True, "username": "astrbot", "password": "77b90590a8945a7d36c963981a307dc9", + "host": "127.0.0.1", "port": 6185, }, "platform": [], @@ -122,6 +123,7 @@ CONFIG_METADATA_2 = { "enable": False, "appid": "", "secret": "", + "callback_server_host": "0.0.0.0", "port": 6196, }, "aiocqhttp(OneBotv11)": { @@ -146,10 +148,11 @@ CONFIG_METADATA_2 = { "enable": False, "corpid": "", "secret": "", - "port": 6195, "token": "", "encoding_aes_key": "", "api_base_url": "https://qyapi.weixin.qq.com/cgi-bin/", + "callback_server_host": "0.0.0.0", + "port": 6195, }, "lark(飞书)": { "id": "lark", diff --git a/astrbot/core/pipeline/process_stage/method/llm_request.py b/astrbot/core/pipeline/process_stage/method/llm_request.py index 22d67d322..e8246805e 100644 --- a/astrbot/core/pipeline/process_stage/method/llm_request.py +++ b/astrbot/core/pipeline/process_stage/method/llm_request.py @@ -148,11 +148,18 @@ class LLMRequestSubStage(Stage): if llm_response.role == "assistant": # text completion - event.set_result( - MessageEventResult() - .message(llm_response.completion_text) - .set_result_content_type(ResultContentType.LLM_RESULT) - ) + if llm_response.result_chain: + event.set_result( + MessageEventResult( + chain=llm_response.result_chain.chain + ).set_result_content_type(ResultContentType.LLM_RESULT) + ) + else: + event.set_result( + MessageEventResult() + .message(llm_response.completion_text) + .set_result_content_type(ResultContentType.LLM_RESULT) + ) elif llm_response.role == "err": event.set_result( MessageEventResult().message( diff --git a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py index ce38296e6..dbe9a3ce0 100644 --- a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py +++ b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py @@ -21,6 +21,7 @@ class AiocqhttpMessageEvent(AstrMessageEvent): d = segment.toDict() if isinstance(segment, Plain): d["type"] = "text" + d["data"]["text"] = segment.text.strip() elif isinstance(segment, (Image, Record)): # convert to base64 if segment.file and segment.file.startswith("file:///"): diff --git a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py index 562574204..a219e2492 100644 --- a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py @@ -15,6 +15,7 @@ class QQOfficialWebhook: self.appid = config["appid"] self.secret = config["secret"] self.port = config.get("port", 6196) + self.callback_server_host = config.get("callback_server_host", "0.0.0.0") if isinstance(self.port, str): self.port = int(self.port) @@ -95,8 +96,11 @@ class QQOfficialWebhook: return {"opcode": 12} async def start_polling(self): + logger.info( + f"将在 {self.callback_server_host}:{self.port} 端口启动 QQ 官方机器人 webhook 适配器。" + ) await self.server.run_task( - host="0.0.0.0", + host=self.callback_server_host, port=self.port, shutdown_trigger=self.shutdown_trigger_placeholder, ) diff --git a/astrbot/core/platform/sources/wecom/wecom_adapter.py b/astrbot/core/platform/sources/wecom/wecom_adapter.py index 77eae03d6..cef83b030 100644 --- a/astrbot/core/platform/sources/wecom/wecom_adapter.py +++ b/astrbot/core/platform/sources/wecom/wecom_adapter.py @@ -34,6 +34,7 @@ class WecomServer: def __init__(self, event_queue: asyncio.Queue, config: dict): self.server = quart.Quart(__name__) self.port = int(config.get("port")) + self.callback_server_host = config.get("callback_server_host", "0.0.0.0") self.server.add_url_rule( "/callback/command", view_func=self.verify, methods=["GET"] ) @@ -86,9 +87,11 @@ class WecomServer: return "success" async def start_polling(self): - logger.info(f"将在 0.0.0.0:{self.port} 端口启动 企业微信 适配器。") + logger.info( + f"将在 {self.callback_server_host}:{self.port} 端口启动 企业微信 适配器。" + ) await self.server.run_task( - host="0.0.0.0", + host=self.callback_server_host, port=self.port, shutdown_trigger=self.shutdown_trigger_placeholder, ) diff --git a/astrbot/core/provider/entites.py b/astrbot/core/provider/entites.py index 3180b4955..17b6edd46 100644 --- a/astrbot/core/provider/entites.py +++ b/astrbot/core/provider/entites.py @@ -4,6 +4,7 @@ from typing import List, Dict, Type from .func_tool_manager import FuncCall from openai.types.chat.chat_completion import ChatCompletion from astrbot.core.db.po import Conversation +from astrbot.core.message.message_event_result import MessageChain class ProviderType(enum.Enum): @@ -56,8 +57,10 @@ class ProviderRequest: class LLMResponse: role: str """角色, assistant, tool, err""" + result_chain: MessageChain = None + """返回的消息链""" completion_text: str = "" - """LLM 返回的文本""" + """LLM 返回的文本, 已经废弃但仍然兼容。使用 result_chain 替代""" tools_call_args: List[Dict[str, any]] = field(default_factory=list) """工具调用参数""" tools_call_name: List[str] = field(default_factory=list) diff --git a/astrbot/core/provider/func_tool_manager.py b/astrbot/core/provider/func_tool_manager.py index cdb1b3d6d..0f04628f7 100644 --- a/astrbot/core/provider/func_tool_manager.py +++ b/astrbot/core/provider/func_tool_manager.py @@ -73,7 +73,7 @@ class FuncCall: handler=handler, ) self.func_list.append(_func) - logger.info(f"添加了函数调用工具({len(self.func_list)}): {name} - {desc}") + logger.info(f"添加函数调用工具: {name}") def remove_func(self, name: str) -> None: """ diff --git a/astrbot/core/provider/sources/dify_source.py b/astrbot/core/provider/sources/dify_source.py index 37f575f21..9af198aa7 100644 --- a/astrbot/core/provider/sources/dify_source.py +++ b/astrbot/core/provider/sources/dify_source.py @@ -1,3 +1,5 @@ +import astrbot.core.message.components as Comp + from typing import List from .. import Provider, Personality from ..entites import LLMResponse @@ -5,8 +7,9 @@ from ..func_tool_manager import FuncCall from astrbot.core.db import BaseDatabase from ..register import register_provider_adapter from astrbot.core.utils.dify_api_client import DifyAPIClient -from astrbot.core.utils.io import download_image_by_url +from astrbot.core.utils.io import download_image_by_url, download_file from astrbot.core import logger, sp +from astrbot.core.message.message_event_result import MessageChain @register_provider_adapter("dify", "Dify APP 适配器。") @@ -148,8 +151,9 @@ class ProviderDify(Provider): ) case "workflow_finished": logger.info( - f"Dify 工作流(ID: {chunk['workflow_run_id']})运行结束。" + f"Dify 工作流(ID: {chunk['workflow_run_id']})运行结束" ) + logger.debug(f"Dify 工作流结果:{chunk}") if chunk["data"]["error"]: logger.error( f"Dify 工作流出现错误:{chunk['data']['error']}" @@ -164,9 +168,7 @@ class ProviderDify(Provider): raise Exception( f"Dify 工作流的输出不包含指定的键名:{self.workflow_output_key}" ) - result = chunk["data"]["outputs"][ - self.workflow_output_key - ] + result = chunk case _: raise Exception(f"未知的 Dify API 类型:{self.api_type}") except Exception as e: @@ -176,7 +178,54 @@ class ProviderDify(Provider): if not result: logger.warning("Dify 请求结果为空,请查看 Debug 日志。") - return LLMResponse(role="assistant", completion_text=result) + chain = await self.parse_dify_result(result) + + return LLMResponse(role="assistant", result_chain=chain) + + async def parse_dify_result(self, chunk: dict | str) -> MessageChain: + if isinstance(chunk, str): + # Chat + return MessageChain(chain=[Comp.Plain(chunk)]) + + async def parse_file(item: dict) -> Comp: + match item["type"]: + case "image": + return Comp.Image(file=item["url"], url=item["url"]) + case "audio": + # 仅支持 wav + path = f"data/temp/{item['filename']}.wav" + await download_file(item["url"], path) + return Comp.Image(file=item["url"], url=item["url"]) + case "video": + return Comp.Video(file=item["url"]) + case _: + return Comp.File(name=item["filename"], file=item["url"]) + + output = chunk["data"]["outputs"][self.workflow_output_key] + chains = [] + if isinstance(output, str): + # 纯文本输出 + chains.append(Comp.Plain(output)) + elif isinstance(output, list): + # 主要适配 Dify 的 HTTP 请求结点的多模态输出 + for item in output: + # handle Array[File] + if ( + not isinstance(item, dict) + or item.get("dify_model_identity", "") != "__dify__file__" + ): + chains.append(Comp.Plain(str(output))) + break + else: + chains.append(Comp.Plain(str(output))) + + # scan file + files = chunk["data"].get("files", []) + for item in files: + comp = await parse_file(item) + chains.append(comp) + + return MessageChain(chain=chains) async def forget(self, session_id): self.conversation_ids[session_id] = "" diff --git a/astrbot/core/star/register/star_handler.py b/astrbot/core/star/register/star_handler.py index 77d7fb482..0b9f7ad09 100644 --- a/astrbot/core/star/register/star_handler.py +++ b/astrbot/core/star/register/star_handler.py @@ -358,7 +358,9 @@ def register_llm_tool(name: str = None): } ) md = get_handler_or_create(awaitable, EventType.OnCallingFuncToolEvent) - llm_tools.add_func(llm_tool_name, args, docstring.description, md.handler) + llm_tools.add_func( + llm_tool_name, args, docstring.description.strip(), md.handler + ) return awaitable return decorator diff --git a/astrbot/core/utils/dify_api_client.py b/astrbot/core/utils/dify_api_client.py index 80be3fff7..badf5d62b 100644 --- a/astrbot/core/utils/dify_api_client.py +++ b/astrbot/core/utils/dify_api_client.py @@ -8,7 +8,7 @@ class DifyAPIClient: def __init__(self, api_key: str, api_base: str = "https://api.dify.ai/v1"): self.api_key = api_key self.api_base = api_base - self.session = ClientSession() + self.session = ClientSession(trust_env=True) self.headers = { "Authorization": f"Bearer {self.api_key}", } diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index 42bbad318..072ded4ae 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -120,12 +120,14 @@ class AstrBotDashboard: return f"获取进程信息失败: {str(e)}" def run(self): - try: - ip_addr = get_local_ip_addresses() - except Exception as _: - ip_addr = [] - + ip_addr = [] port = self.core_lifecycle.astrbot_config["dashboard"].get("port", 6185) + host = self.core_lifecycle.astrbot_config["dashboard"].get("host", "127.0.0.1") + if host not in ["localhost", "127.0.0.1"]: + try: + ip_addr = get_local_ip_addresses() + except Exception as _: + pass if isinstance(port, str): port = int(port) @@ -147,10 +149,16 @@ class AstrBotDashboard: for ip in ip_addr: display += f" ➜ 网络: http://{ip}:{port}\n" display += " ➜ 默认用户名和密码: astrbot\n ✨✨✨\n" + + if not ip_addr: + display += ( + "可在 data/cmd_config.json 中配置 dashboard.host 以便远程访问。\n" + ) + logger.info(display) return self.app.run_task( - host="0.0.0.0", + host=host, port=port, shutdown_trigger=self.shutdown_trigger_placeholder, )