Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 16d49d568b | |||
| 776e17062c | |||
| 8fa8c14b0b | |||
| 64de474139 | |||
| d35771f97d | |||
| 7a4d20d329 | |||
| aab095347f | |||
| 1addd5b2ab | |||
| da4bb6549c | |||
| 7193454d50 | |||
| d204b92877 | |||
| 04faf26140 | |||
| 67b81c279b | |||
| 2afb08d8b2 | |||
| 06b2c7cb16 | |||
| 9c12803ddd | |||
| ce65491d55 | |||
| b67adcf481 | |||
| 1707d55c02 |
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
GHCR_OWNER: soulter
|
||||
GHCR_OWNER: astrbotdevs
|
||||
HAS_GHCR_TOKEN: ${{ secrets.GHCR_GITHUB_TOKEN != '' }}
|
||||
|
||||
steps:
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
GHCR_OWNER: soulter
|
||||
GHCR_OWNER: astrbotdevs
|
||||
HAS_GHCR_TOKEN: ${{ secrets.GHCR_GITHUB_TOKEN != '' }}
|
||||
|
||||
steps:
|
||||
|
||||
@@ -160,12 +160,12 @@ jobs:
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -181,6 +181,21 @@ jobs:
|
||||
dashboard/pnpm-lock.yaml
|
||||
desktop/pnpm-lock.yaml
|
||||
|
||||
- name: Prepare OpenSSL for Windows ARM64
|
||||
if: ${{ matrix.os == 'win' && matrix.arch == 'arm64' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
git clone https://github.com/microsoft/vcpkg.git C:\vcpkg
|
||||
& C:\vcpkg\bootstrap-vcpkg.bat -disableMetrics
|
||||
& C:\vcpkg\vcpkg.exe install openssl:arm64-windows
|
||||
|
||||
"VCPKG_ROOT=C:\vcpkg" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"VCPKGRS_TRIPLET=arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"OPENSSL_DIR=C:\vcpkg\installed\arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"OPENSSL_ROOT_DIR=C:\vcpkg\installed\arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"OPENSSL_LIB_DIR=C:\vcpkg\installed\arm64-windows\lib" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"OPENSSL_INCLUDE_DIR=C:\vcpkg\installed\arm64-windows\include" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -276,13 +291,13 @@ jobs:
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download dashboard artifact
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: Dashboard-${{ steps.tag.outputs.tag }}
|
||||
path: release-assets
|
||||
|
||||
- name: Download desktop artifacts
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: AstrBot-${{ steps.tag.outputs.tag }}-*
|
||||
path: release-assets
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.14.8"
|
||||
__version__ = "4.15.0"
|
||||
|
||||
@@ -15,7 +15,6 @@ class HandoffTool(FunctionTool, Generic[TContext]):
|
||||
tool_description: str | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
self.agent = agent
|
||||
|
||||
# Avoid passing duplicate `description` to the FunctionTool dataclass.
|
||||
# Some call sites (e.g. SubAgentOrchestrator) pass `description` via kwargs
|
||||
@@ -34,6 +33,8 @@ class HandoffTool(FunctionTool, Generic[TContext]):
|
||||
# Optional provider override for this subagent. When set, the handoff
|
||||
# execution will use this chat provider id instead of the global/default.
|
||||
self.provider_id: str | None = None
|
||||
# Note: Must assign after super().__init__() to prevent parent class from overriding this attribute
|
||||
self.agent = agent
|
||||
|
||||
def default_parameters(self) -> dict:
|
||||
return {
|
||||
|
||||
@@ -326,6 +326,24 @@ async def _ensure_persona_and_skills(
|
||||
)
|
||||
tmgr = plugin_context.get_llm_tool_manager()
|
||||
|
||||
# inject toolset in the persona
|
||||
if (persona and persona.get("tools") is None) or not persona:
|
||||
persona_toolset = tmgr.get_full_tool_set()
|
||||
for tool in list(persona_toolset):
|
||||
if not tool.active:
|
||||
persona_toolset.remove_tool(tool.name)
|
||||
else:
|
||||
persona_toolset = ToolSet()
|
||||
if persona["tools"]:
|
||||
for tool_name in persona["tools"]:
|
||||
tool = tmgr.get_func(tool_name)
|
||||
if tool and tool.active:
|
||||
persona_toolset.add_tool(tool)
|
||||
if not req.func_tool:
|
||||
req.func_tool = persona_toolset
|
||||
else:
|
||||
req.func_tool.merge(persona_toolset)
|
||||
|
||||
# sub agents integration
|
||||
orch_cfg = plugin_context.get_config().get("subagent_orchestrator", {})
|
||||
so = plugin_context.subagent_orchestrator
|
||||
@@ -371,22 +389,19 @@ async def _ensure_persona_and_skills(
|
||||
assigned_tools.add(name)
|
||||
|
||||
if req.func_tool is None:
|
||||
toolset = ToolSet()
|
||||
else:
|
||||
toolset = req.func_tool
|
||||
req.func_tool = ToolSet()
|
||||
|
||||
# add subagent handoff tools
|
||||
for tool in so.handoffs:
|
||||
toolset.add_tool(tool)
|
||||
req.func_tool.add_tool(tool)
|
||||
|
||||
# check duplicates
|
||||
if remove_dup:
|
||||
names = toolset.names()
|
||||
handoff_names = {tool.name for tool in so.handoffs}
|
||||
for tool_name in assigned_tools:
|
||||
if tool_name in names:
|
||||
toolset.remove_tool(tool_name)
|
||||
|
||||
req.func_tool = toolset
|
||||
if tool_name in handoff_names:
|
||||
continue
|
||||
req.func_tool.remove_tool(tool_name)
|
||||
|
||||
router_prompt = (
|
||||
plugin_context.get_config()
|
||||
@@ -395,32 +410,14 @@ async def _ensure_persona_and_skills(
|
||||
).strip()
|
||||
if router_prompt:
|
||||
req.system_prompt += f"\n{router_prompt}\n"
|
||||
return
|
||||
|
||||
# inject toolset in the persona
|
||||
if (persona and persona.get("tools") is None) or not persona:
|
||||
toolset = tmgr.get_full_tool_set()
|
||||
for tool in list(toolset):
|
||||
if not tool.active:
|
||||
toolset.remove_tool(tool.name)
|
||||
else:
|
||||
toolset = ToolSet()
|
||||
if persona["tools"]:
|
||||
for tool_name in persona["tools"]:
|
||||
tool = tmgr.get_func(tool_name)
|
||||
if tool and tool.active:
|
||||
toolset.add_tool(tool)
|
||||
if not req.func_tool:
|
||||
req.func_tool = toolset
|
||||
else:
|
||||
req.func_tool.merge(toolset)
|
||||
try:
|
||||
event.trace.record(
|
||||
"sel_persona", persona_id=persona_id, persona_toolset=toolset.names()
|
||||
"sel_persona",
|
||||
persona_id=persona_id,
|
||||
persona_toolset=persona_toolset.names(),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("Tool set for persona %s: %s", persona_id, toolset.names())
|
||||
|
||||
|
||||
async def _request_img_caption(
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
VERSION = "4.14.8"
|
||||
VERSION = "4.15.0"
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||
|
||||
WEBHOOK_SUPPORTED_PLATFORMS = [
|
||||
@@ -129,8 +129,9 @@ DEFAULT_CONFIG = {
|
||||
},
|
||||
# SubAgent orchestrator mode:
|
||||
# - main_enable = False: disabled; main LLM mounts tools normally (persona selection).
|
||||
# - main_enable = True: enabled; main LLM will include handoff tools and can optionally
|
||||
# remove tools that are duplicated on subagents via remove_main_duplicate_tools.
|
||||
# - main_enable = True: enabled; main LLM keeps its own tools and includes handoff
|
||||
# tools (transfer_to_*). remove_main_duplicate_tools can remove tools that are
|
||||
# duplicated on subagents from the main LLM toolset.
|
||||
"subagent_orchestrator": {
|
||||
"main_enable": False,
|
||||
"remove_main_duplicate_tools": False,
|
||||
@@ -319,9 +320,11 @@ CONFIG_METADATA_2 = {
|
||||
"id": "wecom_ai_bot",
|
||||
"type": "wecom_ai_bot",
|
||||
"enable": True,
|
||||
"wecomaibot_init_respond_text": "💭 思考中...",
|
||||
"wecomaibot_init_respond_text": "",
|
||||
"wecomaibot_friend_message_welcome_text": "",
|
||||
"wecom_ai_bot_name": "",
|
||||
"msg_push_webhook_url": "",
|
||||
"only_use_webhook_url_to_send": False,
|
||||
"token": "",
|
||||
"encoding_aes_key": "",
|
||||
"unified_webhook_mode": True,
|
||||
@@ -687,13 +690,23 @@ CONFIG_METADATA_2 = {
|
||||
"wecomaibot_init_respond_text": {
|
||||
"description": "企业微信智能机器人初始响应文本",
|
||||
"type": "string",
|
||||
"hint": "当机器人收到消息时,首先回复的文本内容。留空则使用默认值。",
|
||||
"hint": "当机器人收到消息时,首先回复的文本内容。留空则不设置。",
|
||||
},
|
||||
"wecomaibot_friend_message_welcome_text": {
|
||||
"description": "企业微信智能机器人私聊欢迎语",
|
||||
"type": "string",
|
||||
"hint": "当用户当天进入智能机器人单聊会话,回复欢迎语,留空则不回复。",
|
||||
},
|
||||
"msg_push_webhook_url": {
|
||||
"description": "企业微信消息推送 Webhook URL",
|
||||
"type": "string",
|
||||
"hint": "用于 send_by_session 主动消息推送。格式示例: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx",
|
||||
},
|
||||
"only_use_webhook_url_to_send": {
|
||||
"description": "仅使用 Webhook 发送消息",
|
||||
"type": "bool",
|
||||
"hint": "启用后,企业微信智能机器人的所有回复都改为通过消息推送 Webhook 发送。消息推送 Webhook 支持更多的消息类型(如图片、文件等)。",
|
||||
},
|
||||
"lark_bot_name": {
|
||||
"description": "飞书机器人的名字",
|
||||
"type": "string",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import asyncio
|
||||
import os
|
||||
import json
|
||||
import threading
|
||||
import uuid
|
||||
from typing import NoReturn, cast
|
||||
from pathlib import Path
|
||||
from typing import Literal, NoReturn, cast
|
||||
|
||||
import aiohttp
|
||||
import dingtalk_stream
|
||||
@@ -10,7 +11,7 @@ from dingtalk_stream import AckMessage
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.api.message_components import At, Image, Plain
|
||||
from astrbot.api.message_components import At, Image, Plain, Record, Video
|
||||
from astrbot.api.platform import (
|
||||
AstrBotMessage,
|
||||
MessageMember,
|
||||
@@ -18,9 +19,16 @@ from astrbot.api.platform import (
|
||||
Platform,
|
||||
PlatformMetadata,
|
||||
)
|
||||
from astrbot.core import sp
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.io import download_file
|
||||
from astrbot.core.utils.media_utils import (
|
||||
convert_audio_format,
|
||||
convert_video_format,
|
||||
extract_video_cover,
|
||||
get_media_duration,
|
||||
)
|
||||
|
||||
from ...register import register_platform_adapter
|
||||
from .dingtalk_event import DingtalkMessageEvent
|
||||
@@ -75,8 +83,6 @@ class DingtalkPlatformAdapter(Platform):
|
||||
)
|
||||
self.client_ = client # 用于 websockets 的 client
|
||||
self._shutdown_event: threading.Event | None = None
|
||||
self.card_template_id = platform_config.get("card_template_id")
|
||||
self.card_instance_id_dict = {}
|
||||
|
||||
def _id_to_sid(self, dingtalk_id: str | None) -> str:
|
||||
if not dingtalk_id:
|
||||
@@ -91,7 +97,44 @@ class DingtalkPlatformAdapter(Platform):
|
||||
session: MessageSesion,
|
||||
message_chain: MessageChain,
|
||||
) -> None:
|
||||
raise NotImplementedError("钉钉机器人适配器不支持 send_by_session")
|
||||
robot_code = self.client_id
|
||||
|
||||
if session.message_type == MessageType.GROUP_MESSAGE:
|
||||
open_conversation_id = session.session_id
|
||||
await self.send_message_chain_to_group(
|
||||
open_conversation_id=open_conversation_id,
|
||||
robot_code=robot_code,
|
||||
message_chain=message_chain,
|
||||
)
|
||||
else:
|
||||
staff_id = await self._get_sender_staff_id(session)
|
||||
if not staff_id:
|
||||
logger.warning(
|
||||
"钉钉私聊会话缺少 staff_id 映射,回退使用 session_id 作为 userId 发送",
|
||||
)
|
||||
staff_id = session.session_id
|
||||
await self.send_message_chain_to_user(
|
||||
staff_id=staff_id,
|
||||
robot_code=robot_code,
|
||||
message_chain=message_chain,
|
||||
)
|
||||
|
||||
await super().send_by_session(session, message_chain)
|
||||
|
||||
async def send_with_session(
|
||||
self,
|
||||
session: MessageSesion,
|
||||
message_chain: MessageChain,
|
||||
) -> None:
|
||||
await self.send_by_session(session, message_chain)
|
||||
|
||||
async def send_with_sesison(
|
||||
self,
|
||||
session: MessageSesion,
|
||||
message_chain: MessageChain,
|
||||
) -> None:
|
||||
# backward typo compatibility
|
||||
await self.send_by_session(session, message_chain)
|
||||
|
||||
def meta(self) -> PlatformMetadata:
|
||||
return PlatformMetadata(
|
||||
@@ -99,67 +142,9 @@ class DingtalkPlatformAdapter(Platform):
|
||||
description="钉钉机器人官方 API 适配器",
|
||||
id=cast(str, self.config.get("id")),
|
||||
support_streaming_message=True,
|
||||
support_proactive_message=False,
|
||||
support_proactive_message=True,
|
||||
)
|
||||
|
||||
async def create_message_card(
|
||||
self, message_id: str, incoming_message: dingtalk_stream.ChatbotMessage
|
||||
) -> bool | None:
|
||||
if not self.card_template_id:
|
||||
return False
|
||||
|
||||
card_instance = dingtalk_stream.AICardReplier(self.client_, incoming_message)
|
||||
card_data = {"content": ""} # Initial content empty
|
||||
|
||||
try:
|
||||
card_instance_id = await card_instance.async_create_and_deliver_card(
|
||||
self.card_template_id,
|
||||
card_data,
|
||||
)
|
||||
self.card_instance_id_dict[message_id] = (card_instance, card_instance_id)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"创建钉钉卡片失败: {e}")
|
||||
return False
|
||||
|
||||
async def send_card_message(
|
||||
self, message_id: str, content: str, is_final: bool
|
||||
) -> None:
|
||||
if message_id not in self.card_instance_id_dict:
|
||||
return
|
||||
|
||||
card_instance, card_instance_id = self.card_instance_id_dict[message_id]
|
||||
content_key = "content"
|
||||
|
||||
try:
|
||||
# 钉钉卡片流式更新
|
||||
|
||||
await card_instance.async_streaming(
|
||||
card_instance_id,
|
||||
content_key=content_key,
|
||||
content_value=content,
|
||||
append=False,
|
||||
finished=is_final,
|
||||
failed=False,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"发送钉钉卡片消息失败: {e}")
|
||||
# Try to report failure
|
||||
try:
|
||||
await card_instance.async_streaming(
|
||||
card_instance_id,
|
||||
content_key=content_key,
|
||||
content_value=content, # Keep existing content
|
||||
append=False,
|
||||
finished=True,
|
||||
failed=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if is_final:
|
||||
self.card_instance_id_dict.pop(message_id, None)
|
||||
|
||||
async def convert_msg(
|
||||
self,
|
||||
message: dingtalk_stream.ChatbotMessage,
|
||||
@@ -217,8 +202,35 @@ class DingtalkPlatformAdapter(Platform):
|
||||
case "audio":
|
||||
pass
|
||||
|
||||
await self._remember_sender_binding(message, abm)
|
||||
return abm # 别忘了返回转换后的消息对象
|
||||
|
||||
async def _remember_sender_binding(
|
||||
self,
|
||||
message: dingtalk_stream.ChatbotMessage,
|
||||
abm: AstrBotMessage,
|
||||
) -> None:
|
||||
try:
|
||||
if abm.type == MessageType.FRIEND_MESSAGE:
|
||||
sender_id = abm.sender.user_id
|
||||
sender_staff_id = cast(str, message.sender_staff_id or "")
|
||||
if sender_staff_id:
|
||||
umo = str(
|
||||
MessageSesion(
|
||||
platform_name=self.meta().id,
|
||||
message_type=abm.type,
|
||||
session_id=sender_id,
|
||||
)
|
||||
)
|
||||
await sp.put_async(
|
||||
"global",
|
||||
umo,
|
||||
"dingtalk_staffid",
|
||||
sender_staff_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"保存钉钉会话映射失败: {e}")
|
||||
|
||||
async def download_ding_file(
|
||||
self,
|
||||
download_code: str,
|
||||
@@ -241,8 +253,9 @@ class DingtalkPlatformAdapter(Platform):
|
||||
"downloadCode": download_code,
|
||||
"robotCode": robot_code,
|
||||
}
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
f_path = os.path.join(temp_dir, f"dingtalk_file_{uuid.uuid4()}.{ext}")
|
||||
temp_dir = Path(get_astrbot_data_path()) / "temp"
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
f_path = temp_dir / f"dingtalk_file_{uuid.uuid4()}.{ext}"
|
||||
async with (
|
||||
aiohttp.ClientSession() as session,
|
||||
session.post(
|
||||
@@ -258,14 +271,21 @@ class DingtalkPlatformAdapter(Platform):
|
||||
return ""
|
||||
resp_data = await resp.json()
|
||||
download_url = resp_data["data"]["downloadUrl"]
|
||||
await download_file(download_url, f_path)
|
||||
return f_path
|
||||
await download_file(download_url, str(f_path))
|
||||
return str(f_path)
|
||||
|
||||
async def get_access_token(self) -> str:
|
||||
payload = {
|
||||
"appKey": self.client_id,
|
||||
"appSecret": self.client_secret,
|
||||
}
|
||||
try:
|
||||
access_token = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
self.client_.get_access_token,
|
||||
)
|
||||
if access_token:
|
||||
return access_token
|
||||
except Exception as e:
|
||||
logger.warning(f"通过 dingtalk_stream 获取 access_token 失败: {e}")
|
||||
|
||||
payload = {"appKey": self.client_id, "appSecret": self.client_secret}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
"https://api.dingtalk.com/v1.0/oauth2/accessToken",
|
||||
@@ -276,7 +296,328 @@ class DingtalkPlatformAdapter(Platform):
|
||||
f"获取钉钉机器人 access_token 失败: {resp.status}, {await resp.text()}",
|
||||
)
|
||||
return ""
|
||||
return (await resp.json())["data"]["accessToken"]
|
||||
data = await resp.json()
|
||||
return cast(str, data.get("data", {}).get("accessToken", ""))
|
||||
|
||||
async def _get_sender_staff_id(self, session: MessageSesion) -> str:
|
||||
try:
|
||||
staff_id = await sp.get_async(
|
||||
"global",
|
||||
str(session),
|
||||
"dingtalk_staffid",
|
||||
"",
|
||||
)
|
||||
return cast(str, staff_id or "")
|
||||
except Exception as e:
|
||||
logger.warning(f"读取钉钉 staff_id 映射失败: {e}")
|
||||
return ""
|
||||
|
||||
async def _send_group_message(
|
||||
self,
|
||||
open_conversation_id: str,
|
||||
robot_code: str,
|
||||
msg_key: str,
|
||||
msg_param: dict,
|
||||
) -> None:
|
||||
access_token = await self.get_access_token()
|
||||
if not access_token:
|
||||
logger.error("钉钉群消息发送失败: access_token 为空")
|
||||
return
|
||||
|
||||
payload = {
|
||||
"msgKey": msg_key,
|
||||
"msgParam": json.dumps(msg_param, ensure_ascii=False),
|
||||
"openConversationId": open_conversation_id,
|
||||
"robotCode": robot_code,
|
||||
}
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"x-acs-dingtalk-access-token": access_token,
|
||||
}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
"https://api.dingtalk.com/v1.0/robot/groupMessages/send",
|
||||
headers=headers,
|
||||
json=payload,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
logger.error(
|
||||
f"钉钉群消息发送失败: {resp.status}, {await resp.text()}",
|
||||
)
|
||||
|
||||
async def _send_private_message(
|
||||
self,
|
||||
staff_id: str,
|
||||
robot_code: str,
|
||||
msg_key: str,
|
||||
msg_param: dict,
|
||||
) -> None:
|
||||
access_token = await self.get_access_token()
|
||||
if not access_token:
|
||||
logger.error("钉钉私聊消息发送失败: access_token 为空")
|
||||
return
|
||||
|
||||
payload = {
|
||||
"robotCode": robot_code,
|
||||
"userIds": [staff_id],
|
||||
"msgKey": msg_key,
|
||||
"msgParam": json.dumps(msg_param, ensure_ascii=False),
|
||||
}
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"x-acs-dingtalk-access-token": access_token,
|
||||
}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
"https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend",
|
||||
headers=headers,
|
||||
json=payload,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
logger.error(
|
||||
f"钉钉私聊消息发送失败: {resp.status}, {await resp.text()}",
|
||||
)
|
||||
|
||||
def _safe_remove_file(self, file_path: str | None) -> None:
|
||||
if not file_path:
|
||||
return
|
||||
try:
|
||||
p = Path(file_path)
|
||||
if p.exists() and p.is_file():
|
||||
p.unlink()
|
||||
except Exception as e:
|
||||
logger.warning(f"清理临时文件失败: {file_path}, {e}")
|
||||
|
||||
async def _prepare_voice_for_dingtalk(self, input_path: str) -> tuple[str, bool]:
|
||||
"""优先转换为 OGG(Opus),不可用时回退 AMR。"""
|
||||
lower_path = input_path.lower()
|
||||
if lower_path.endswith((".amr", ".ogg")):
|
||||
return input_path, False
|
||||
|
||||
try:
|
||||
converted = await convert_audio_format(input_path, "ogg")
|
||||
return converted, converted != input_path
|
||||
except Exception as e:
|
||||
logger.warning(f"钉钉语音转 OGG 失败,回退 AMR: {e}")
|
||||
converted = await convert_audio_format(input_path, "amr")
|
||||
return converted, converted != input_path
|
||||
|
||||
async def upload_media(self, file_path: str, media_type: str) -> str:
|
||||
media_file_path = Path(file_path)
|
||||
access_token = await self.get_access_token()
|
||||
if not access_token:
|
||||
logger.error("钉钉媒体上传失败: access_token 为空")
|
||||
return ""
|
||||
|
||||
form = aiohttp.FormData()
|
||||
form.add_field(
|
||||
"media",
|
||||
media_file_path.read_bytes(),
|
||||
filename=media_file_path.name,
|
||||
content_type="application/octet-stream",
|
||||
)
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f"https://oapi.dingtalk.com/media/upload?access_token={access_token}&type={media_type}",
|
||||
data=form,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
logger.error(
|
||||
f"钉钉媒体上传失败: {resp.status}, {await resp.text()}"
|
||||
)
|
||||
return ""
|
||||
data = await resp.json()
|
||||
if data.get("errcode") != 0:
|
||||
logger.error(f"钉钉媒体上传失败: {data}")
|
||||
return ""
|
||||
return cast(str, data.get("media_id", ""))
|
||||
|
||||
async def upload_image(self, image: Image) -> str:
|
||||
image_file_path = await image.convert_to_file_path()
|
||||
return await self.upload_media(image_file_path, "image")
|
||||
|
||||
async def _send_message_chain(
|
||||
self,
|
||||
target_type: Literal["group", "user"],
|
||||
target_id: str,
|
||||
robot_code: str,
|
||||
message_chain: MessageChain,
|
||||
at_str: str = "",
|
||||
) -> None:
|
||||
async def send_message(msg_key: str, msg_param: dict) -> None:
|
||||
if target_type == "group":
|
||||
await self._send_group_message(
|
||||
open_conversation_id=target_id,
|
||||
robot_code=robot_code,
|
||||
msg_key=msg_key,
|
||||
msg_param=msg_param,
|
||||
)
|
||||
else:
|
||||
await self._send_private_message(
|
||||
staff_id=target_id,
|
||||
robot_code=robot_code,
|
||||
msg_key=msg_key,
|
||||
msg_param=msg_param,
|
||||
)
|
||||
|
||||
for segment in message_chain.chain:
|
||||
if isinstance(segment, Plain):
|
||||
text = segment.text.strip()
|
||||
if not text and not at_str:
|
||||
continue
|
||||
await send_message(
|
||||
msg_key="sampleMarkdown",
|
||||
msg_param={
|
||||
"title": "AstrBot",
|
||||
"text": f"{at_str} {text}".strip(),
|
||||
},
|
||||
)
|
||||
elif isinstance(segment, Image):
|
||||
photo_url = segment.file or segment.url or ""
|
||||
if photo_url.startswith(("http://", "https://")):
|
||||
pass
|
||||
else:
|
||||
photo_url = await self.upload_image(segment)
|
||||
if not photo_url:
|
||||
continue
|
||||
await send_message(
|
||||
msg_key="sampleImageMsg",
|
||||
msg_param={"photoURL": photo_url},
|
||||
)
|
||||
elif isinstance(segment, Record):
|
||||
converted_audio = None
|
||||
try:
|
||||
audio_path = await segment.convert_to_file_path()
|
||||
(
|
||||
audio_path,
|
||||
converted_audio,
|
||||
) = await self._prepare_voice_for_dingtalk(audio_path)
|
||||
media_id = await self.upload_media(audio_path, "voice")
|
||||
if not media_id:
|
||||
continue
|
||||
duration_ms = await get_media_duration(audio_path)
|
||||
await send_message(
|
||||
msg_key="sampleAudio",
|
||||
msg_param={
|
||||
"mediaId": media_id,
|
||||
"duration": str(duration_ms or 1000),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"钉钉语音发送失败: {e}")
|
||||
continue
|
||||
finally:
|
||||
if converted_audio:
|
||||
self._safe_remove_file(audio_path)
|
||||
elif isinstance(segment, Video):
|
||||
converted_video = False
|
||||
cover_path = None
|
||||
try:
|
||||
source_video_path = await segment.convert_to_file_path()
|
||||
video_path = source_video_path
|
||||
if not video_path.lower().endswith(".mp4"):
|
||||
video_path = await convert_video_format(video_path, "mp4")
|
||||
converted_video = video_path != source_video_path
|
||||
cover_path = await extract_video_cover(video_path)
|
||||
video_media_id = await self.upload_media(video_path, "file")
|
||||
pic_media_id = await self.upload_media(cover_path, "image")
|
||||
if not video_media_id or not pic_media_id:
|
||||
continue
|
||||
duration_ms = await get_media_duration(video_path)
|
||||
duration_sec = max(1, int((duration_ms or 1000) / 1000))
|
||||
await send_message(
|
||||
msg_key="sampleVideo",
|
||||
msg_param={
|
||||
"duration": str(duration_sec),
|
||||
"videoMediaId": video_media_id,
|
||||
"videoType": "mp4",
|
||||
"picMediaId": pic_media_id,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"钉钉视频发送失败: {e}")
|
||||
continue
|
||||
finally:
|
||||
self._safe_remove_file(cover_path)
|
||||
if converted_video:
|
||||
self._safe_remove_file(video_path)
|
||||
|
||||
async def send_message_chain_to_group(
|
||||
self,
|
||||
open_conversation_id: str,
|
||||
robot_code: str,
|
||||
message_chain: MessageChain,
|
||||
at_str: str = "",
|
||||
) -> None:
|
||||
await self._send_message_chain(
|
||||
target_type="group",
|
||||
target_id=open_conversation_id,
|
||||
robot_code=robot_code,
|
||||
message_chain=message_chain,
|
||||
at_str=at_str,
|
||||
)
|
||||
|
||||
async def send_message_chain_to_user(
|
||||
self,
|
||||
staff_id: str,
|
||||
robot_code: str,
|
||||
message_chain: MessageChain,
|
||||
at_str: str = "",
|
||||
) -> None:
|
||||
await self._send_message_chain(
|
||||
target_type="user",
|
||||
target_id=staff_id,
|
||||
robot_code=robot_code,
|
||||
message_chain=message_chain,
|
||||
at_str=at_str,
|
||||
)
|
||||
|
||||
async def send_message_chain_with_incoming(
|
||||
self,
|
||||
incoming_message: dingtalk_stream.ChatbotMessage,
|
||||
message_chain: MessageChain,
|
||||
) -> None:
|
||||
robot_code = self.client_id
|
||||
|
||||
# at_list: list[str] = []
|
||||
sender_id = cast(str, incoming_message.sender_id or "")
|
||||
sender_staff_id = cast(str, incoming_message.sender_staff_id or "")
|
||||
normalized_sender_id = self._id_to_sid(sender_id)
|
||||
# 现在用的发消息接口不支持 at
|
||||
# for segment in message_chain.chain:
|
||||
# if isinstance(segment, At):
|
||||
# if (
|
||||
# str(segment.qq) in {sender_id, normalized_sender_id}
|
||||
# and sender_staff_id
|
||||
# ):
|
||||
# at_list.append(f"@{sender_staff_id}")
|
||||
# else:
|
||||
# at_list.append(f"@{segment.qq}")
|
||||
# at_str = " ".join(at_list)
|
||||
|
||||
if incoming_message.conversation_type == "2":
|
||||
await self.send_message_chain_to_group(
|
||||
open_conversation_id=cast(str, incoming_message.conversation_id),
|
||||
robot_code=robot_code,
|
||||
message_chain=message_chain,
|
||||
# at_str=at_str,
|
||||
)
|
||||
else:
|
||||
session = MessageSesion(
|
||||
platform_name=self.meta().id,
|
||||
message_type=MessageType.FRIEND_MESSAGE,
|
||||
session_id=normalized_sender_id,
|
||||
)
|
||||
staff_id = sender_staff_id or await self._get_sender_staff_id(session)
|
||||
if not staff_id:
|
||||
logger.error("钉钉私聊回复失败: 缺少 sender_staff_id")
|
||||
return
|
||||
await self.send_message_chain_to_user(
|
||||
staff_id=staff_id,
|
||||
robot_code=robot_code,
|
||||
message_chain=message_chain,
|
||||
# at_str=at_str,
|
||||
)
|
||||
|
||||
async def handle_msg(self, abm: AstrBotMessage) -> None:
|
||||
event = DingtalkMessageEvent(
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import asyncio
|
||||
from typing import Any, cast
|
||||
from typing import Any
|
||||
|
||||
import dingtalk_stream
|
||||
|
||||
import astrbot.api.message_components as Comp
|
||||
from astrbot import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
|
||||
@@ -15,128 +11,33 @@ class DingtalkMessageEvent(AstrMessageEvent):
|
||||
message_obj,
|
||||
platform_meta,
|
||||
session_id,
|
||||
client: dingtalk_stream.ChatbotHandler,
|
||||
client: Any = None,
|
||||
adapter: "Any" = None,
|
||||
) -> None:
|
||||
super().__init__(message_str, message_obj, platform_meta, session_id)
|
||||
self.client = client
|
||||
self.adapter = adapter
|
||||
|
||||
async def send_with_client(
|
||||
self,
|
||||
client: dingtalk_stream.ChatbotHandler,
|
||||
message: MessageChain,
|
||||
) -> None:
|
||||
icm = cast(dingtalk_stream.ChatbotMessage, self.message_obj.raw_message)
|
||||
ats = []
|
||||
# fixes: #4218
|
||||
# 钉钉 at 机器人需要使用 sender_staff_id 而不是 sender_id
|
||||
for i in message.chain:
|
||||
if isinstance(i, Comp.At):
|
||||
print(i.qq, icm.sender_id, icm.sender_staff_id)
|
||||
if str(i.qq) in str(icm.sender_id or ""):
|
||||
# 适配器会将开头的 $:LWCP_v1:$ 去掉,因此我们用 in 判断
|
||||
ats.append(f"@{icm.sender_staff_id}")
|
||||
else:
|
||||
ats.append(f"@{i.qq}")
|
||||
at_str = " ".join(ats)
|
||||
|
||||
for segment in message.chain:
|
||||
if isinstance(segment, Comp.Plain):
|
||||
segment.text = segment.text.strip()
|
||||
await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
client.reply_markdown,
|
||||
segment.text,
|
||||
f"{at_str} {segment.text}".strip(),
|
||||
cast(dingtalk_stream.ChatbotMessage, self.message_obj.raw_message),
|
||||
)
|
||||
elif isinstance(segment, Comp.Image):
|
||||
markdown_str = ""
|
||||
|
||||
try:
|
||||
if not segment.file:
|
||||
logger.warning("钉钉图片 segment 缺少 file 字段,跳过")
|
||||
continue
|
||||
if segment.file.startswith(("http://", "https://")):
|
||||
image_url = segment.file
|
||||
else:
|
||||
image_url = await segment.register_to_file_service()
|
||||
|
||||
markdown_str = f"\n\n"
|
||||
|
||||
ret = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
client.reply_markdown,
|
||||
"😄",
|
||||
markdown_str,
|
||||
cast(
|
||||
dingtalk_stream.ChatbotMessage, self.message_obj.raw_message
|
||||
),
|
||||
)
|
||||
logger.debug(f"send image: {ret}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"钉钉图片处理失败: {e}, 跳过图片发送")
|
||||
continue
|
||||
|
||||
async def send(self, message: MessageChain) -> None:
|
||||
await self.send_with_client(self.client, message)
|
||||
if not self.adapter:
|
||||
logger.error("钉钉消息发送失败: 缺少 adapter")
|
||||
return
|
||||
await self.adapter.send_message_chain_with_incoming(
|
||||
incoming_message=self.message_obj.raw_message,
|
||||
message_chain=message,
|
||||
)
|
||||
await super().send(message)
|
||||
|
||||
async def send_streaming(self, generator, use_fallback: bool = False):
|
||||
if not self.adapter or not self.adapter.card_template_id:
|
||||
logger.warning(
|
||||
f"DingTalk streaming is enabled, but 'card_template_id' is not configured for platform '{self.platform_meta.id}'. Falling back to text streaming."
|
||||
)
|
||||
# Fallback to default behavior (buffer and send)
|
||||
buffer = None
|
||||
async for chain in generator:
|
||||
if not buffer:
|
||||
buffer = chain
|
||||
else:
|
||||
buffer.chain.extend(chain.chain)
|
||||
# 钉钉统一回退为缓冲发送:最终发送仍使用新的 HTTP 消息接口。
|
||||
buffer = None
|
||||
async for chain in generator:
|
||||
if not buffer:
|
||||
return None
|
||||
buffer.squash_plain()
|
||||
await self.send(buffer)
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
|
||||
# Create card
|
||||
msg_id = self.message_obj.message_id
|
||||
incoming_msg = self.message_obj.raw_message
|
||||
created = await self.adapter.create_message_card(msg_id, incoming_msg)
|
||||
|
||||
if not created:
|
||||
# Fallback to default behavior (buffer and send)
|
||||
buffer = None
|
||||
async for chain in generator:
|
||||
if not buffer:
|
||||
buffer = chain
|
||||
else:
|
||||
buffer.chain.extend(chain.chain)
|
||||
if not buffer:
|
||||
return None
|
||||
buffer.squash_plain()
|
||||
await self.send(buffer)
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
|
||||
full_content = ""
|
||||
seq = 0
|
||||
try:
|
||||
async for chain in generator:
|
||||
for segment in chain.chain:
|
||||
if isinstance(segment, Comp.Plain):
|
||||
full_content += segment.text
|
||||
|
||||
seq += 1
|
||||
if seq % 2 == 0: # Update every 2 chunks to be more responsive than 8
|
||||
await self.adapter.send_card_message(
|
||||
msg_id, full_content, is_final=False
|
||||
)
|
||||
|
||||
await self.adapter.send_card_message(msg_id, full_content, is_final=True)
|
||||
except Exception as e:
|
||||
logger.error(f"DingTalk streaming error: {e}")
|
||||
# Try to ensure final state is sent or cleaned up?
|
||||
await self.adapter.send_card_message(msg_id, full_content, is_final=True)
|
||||
buffer = chain
|
||||
else:
|
||||
buffer.chain.extend(chain.chain)
|
||||
if not buffer:
|
||||
return None
|
||||
buffer.squash_plain()
|
||||
await self.send(buffer)
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
|
||||
@@ -26,6 +26,7 @@ from astrbot.api.platform import (
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.media_utils import convert_audio_to_wav
|
||||
from astrbot.core.utils.webhook_utils import log_webhook_info
|
||||
|
||||
from .wecom_event import WecomPlatformEvent
|
||||
@@ -165,6 +166,7 @@ class WecomPlatformAdapter(Platform):
|
||||
self.api_base_url += "/"
|
||||
|
||||
self.server = WecomServer(self._event_queue, self.config)
|
||||
self.agent_id: str | None = None
|
||||
|
||||
self.client = WeChatClient(
|
||||
self.config["corpid"].strip(),
|
||||
@@ -215,6 +217,36 @@ class WecomPlatformAdapter(Platform):
|
||||
session: MessageSesion,
|
||||
message_chain: MessageChain,
|
||||
) -> None:
|
||||
# 企业微信客服不支持主动发送
|
||||
if hasattr(self.client, "kf_message"):
|
||||
logger.warning("企业微信客服模式不支持 send_by_session 主动发送。")
|
||||
await super().send_by_session(session, message_chain)
|
||||
return
|
||||
if not self.agent_id:
|
||||
logger.warning(
|
||||
f"send_by_session 失败:无法为会话 {session.session_id} 推断 agent_id。",
|
||||
)
|
||||
await super().send_by_session(session, message_chain)
|
||||
return
|
||||
|
||||
message_obj = AstrBotMessage()
|
||||
message_obj.self_id = self.agent_id
|
||||
message_obj.session_id = session.session_id
|
||||
message_obj.type = session.message_type
|
||||
message_obj.sender = MessageMember(session.session_id, session.session_id)
|
||||
message_obj.message = []
|
||||
message_obj.message_str = ""
|
||||
message_obj.message_id = uuid.uuid4().hex
|
||||
message_obj.raw_message = {"_proactive_send": True}
|
||||
|
||||
event = WecomPlatformEvent(
|
||||
message_str=message_obj.message_str,
|
||||
message_obj=message_obj,
|
||||
platform_meta=self.meta(),
|
||||
session_id=message_obj.session_id,
|
||||
client=self.client,
|
||||
)
|
||||
await event.send(message_chain)
|
||||
await super().send_by_session(session, message_chain)
|
||||
|
||||
@override
|
||||
@@ -318,11 +350,8 @@ class WecomPlatformAdapter(Platform):
|
||||
f.write(resp.content)
|
||||
|
||||
try:
|
||||
from pydub import AudioSegment
|
||||
|
||||
path_wav = os.path.join(temp_dir, f"wecom_{msg.media_id}.wav")
|
||||
audio = AudioSegment.from_file(path)
|
||||
audio.export(path_wav, format="wav")
|
||||
path_wav = await convert_audio_to_wav(path, path_wav)
|
||||
except Exception as e:
|
||||
logger.error(f"转换音频失败: {e}。如果没有安装 ffmpeg 请先安装。")
|
||||
path_wav = path
|
||||
@@ -344,6 +373,7 @@ class WecomPlatformAdapter(Platform):
|
||||
logger.warning(f"暂未实现的事件: {msg.type}")
|
||||
return
|
||||
|
||||
self.agent_id = abm.self_id
|
||||
logger.info(f"abm: {abm}")
|
||||
await self.handle_msg(abm)
|
||||
|
||||
@@ -388,11 +418,8 @@ class WecomPlatformAdapter(Platform):
|
||||
f.write(resp.content)
|
||||
|
||||
try:
|
||||
from pydub import AudioSegment
|
||||
|
||||
path_wav = os.path.join(temp_dir, f"weixinkefu_{media_id}.wav")
|
||||
audio = AudioSegment.from_file(path)
|
||||
audio.export(path_wav, format="wav")
|
||||
path_wav = await convert_audio_to_wav(path, path_wav)
|
||||
except Exception as e:
|
||||
logger.error(f"转换音频失败: {e}。如果没有安装 ffmpeg 请先安装。")
|
||||
path_wav = path
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
import asyncio
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from wechatpy.enterprise import WeChatClient
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.message_components import Image, Plain, Record
|
||||
from astrbot.api.message_components import File, Image, Plain, Record, Video
|
||||
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.media_utils import convert_audio_to_amr
|
||||
|
||||
from .wecom_kf_message import WeChatKFMessage
|
||||
|
||||
try:
|
||||
import pydub
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"检测到 pydub 库未安装,企业微信将无法语音收发。如需使用语音,请前往管理面板 -> 平台日志 -> 安装 Pip 库安装 pydub。",
|
||||
)
|
||||
|
||||
|
||||
class WecomPlatformEvent(AstrMessageEvent):
|
||||
def __init__(
|
||||
@@ -125,25 +117,66 @@ class WecomPlatformEvent(AstrMessageEvent):
|
||||
)
|
||||
elif isinstance(comp, Record):
|
||||
record_path = await comp.convert_to_file_path()
|
||||
# 转成amr
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
record_path_amr = os.path.join(temp_dir, f"{uuid.uuid4()}.amr")
|
||||
pydub.AudioSegment.from_wav(record_path).export(
|
||||
record_path_amr,
|
||||
format="amr",
|
||||
)
|
||||
record_path_amr = await convert_audio_to_amr(record_path)
|
||||
|
||||
with open(record_path_amr, "rb") as f:
|
||||
try:
|
||||
with open(record_path_amr, "rb") as f:
|
||||
try:
|
||||
response = self.client.media.upload("voice", f)
|
||||
except Exception as e:
|
||||
logger.error(f"微信客服上传语音失败: {e}")
|
||||
await self.send(
|
||||
MessageChain().message(
|
||||
f"微信客服上传语音失败: {e}"
|
||||
),
|
||||
)
|
||||
return
|
||||
logger.info(f"微信客服上传语音返回: {response}")
|
||||
kf_message_api.send_voice(
|
||||
user_id,
|
||||
self.get_self_id(),
|
||||
response["media_id"],
|
||||
)
|
||||
finally:
|
||||
if record_path_amr != record_path and os.path.exists(
|
||||
record_path_amr,
|
||||
):
|
||||
try:
|
||||
os.remove(record_path_amr)
|
||||
except OSError as e:
|
||||
logger.warning(f"删除临时音频文件失败: {e}")
|
||||
elif isinstance(comp, File):
|
||||
file_path = await comp.get_file()
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
try:
|
||||
response = self.client.media.upload("voice", f)
|
||||
response = self.client.media.upload("file", f)
|
||||
except Exception as e:
|
||||
logger.error(f"微信客服上传语音失败: {e}")
|
||||
logger.error(f"微信客服上传文件失败: {e}")
|
||||
await self.send(
|
||||
MessageChain().message(f"微信客服上传语音失败: {e}"),
|
||||
MessageChain().message(f"微信客服上传文件失败: {e}"),
|
||||
)
|
||||
return
|
||||
logger.info(f"微信客服上传语音返回: {response}")
|
||||
kf_message_api.send_voice(
|
||||
logger.debug(f"微信客服上传文件返回: {response}")
|
||||
kf_message_api.send_file(
|
||||
user_id,
|
||||
self.get_self_id(),
|
||||
response["media_id"],
|
||||
)
|
||||
elif isinstance(comp, Video):
|
||||
video_path = await comp.convert_to_file_path()
|
||||
|
||||
with open(video_path, "rb") as f:
|
||||
try:
|
||||
response = self.client.media.upload("video", f)
|
||||
except Exception as e:
|
||||
logger.error(f"微信客服上传视频失败: {e}")
|
||||
await self.send(
|
||||
MessageChain().message(f"微信客服上传视频失败: {e}"),
|
||||
)
|
||||
return
|
||||
logger.debug(f"微信客服上传视频返回: {response}")
|
||||
kf_message_api.send_video(
|
||||
user_id,
|
||||
self.get_self_id(),
|
||||
response["media_id"],
|
||||
@@ -183,25 +216,66 @@ class WecomPlatformEvent(AstrMessageEvent):
|
||||
)
|
||||
elif isinstance(comp, Record):
|
||||
record_path = await comp.convert_to_file_path()
|
||||
# 转成amr
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
record_path_amr = os.path.join(temp_dir, f"{uuid.uuid4()}.amr")
|
||||
pydub.AudioSegment.from_wav(record_path).export(
|
||||
record_path_amr,
|
||||
format="amr",
|
||||
)
|
||||
record_path_amr = await convert_audio_to_amr(record_path)
|
||||
|
||||
with open(record_path_amr, "rb") as f:
|
||||
try:
|
||||
with open(record_path_amr, "rb") as f:
|
||||
try:
|
||||
response = self.client.media.upload("voice", f)
|
||||
except Exception as e:
|
||||
logger.error(f"企业微信上传语音失败: {e}")
|
||||
await self.send(
|
||||
MessageChain().message(
|
||||
f"企业微信上传语音失败: {e}"
|
||||
),
|
||||
)
|
||||
return
|
||||
logger.info(f"企业微信上传语音返回: {response}")
|
||||
self.client.message.send_voice(
|
||||
message_obj.self_id,
|
||||
message_obj.session_id,
|
||||
response["media_id"],
|
||||
)
|
||||
finally:
|
||||
if record_path_amr != record_path and os.path.exists(
|
||||
record_path_amr,
|
||||
):
|
||||
try:
|
||||
os.remove(record_path_amr)
|
||||
except OSError as e:
|
||||
logger.warning(f"删除临时音频文件失败: {e}")
|
||||
elif isinstance(comp, File):
|
||||
file_path = await comp.get_file()
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
try:
|
||||
response = self.client.media.upload("voice", f)
|
||||
response = self.client.media.upload("file", f)
|
||||
except Exception as e:
|
||||
logger.error(f"企业微信上传语音失败: {e}")
|
||||
logger.error(f"企业微信上传文件失败: {e}")
|
||||
await self.send(
|
||||
MessageChain().message(f"企业微信上传语音失败: {e}"),
|
||||
MessageChain().message(f"企业微信上传文件失败: {e}"),
|
||||
)
|
||||
return
|
||||
logger.info(f"企业微信上传语音返回: {response}")
|
||||
self.client.message.send_voice(
|
||||
logger.debug(f"企业微信上传文件返回: {response}")
|
||||
self.client.message.send_file(
|
||||
message_obj.self_id,
|
||||
message_obj.session_id,
|
||||
response["media_id"],
|
||||
)
|
||||
elif isinstance(comp, Video):
|
||||
video_path = await comp.convert_to_file_path()
|
||||
|
||||
with open(video_path, "rb") as f:
|
||||
try:
|
||||
response = self.client.media.upload("video", f)
|
||||
except Exception as e:
|
||||
logger.error(f"企业微信上传视频失败: {e}")
|
||||
await self.send(
|
||||
MessageChain().message(f"企业微信上传视频失败: {e}"),
|
||||
)
|
||||
return
|
||||
logger.debug(f"企业微信上传视频返回: {response}")
|
||||
self.client.message.send_video(
|
||||
message_obj.self_id,
|
||||
message_obj.session_id,
|
||||
response["media_id"],
|
||||
|
||||
@@ -39,6 +39,7 @@ from .wecomai_utils import (
|
||||
generate_random_string,
|
||||
process_encrypted_image,
|
||||
)
|
||||
from .wecomai_webhook import WecomAIBotWebhookClient, WecomAIBotWebhookError
|
||||
|
||||
|
||||
class WecomAIQueueListener:
|
||||
@@ -84,20 +85,24 @@ class WecomAIBotAdapter(Platform):
|
||||
self.bot_name = self.config.get("wecom_ai_bot_name", "")
|
||||
self.initial_respond_text = self.config.get(
|
||||
"wecomaibot_init_respond_text",
|
||||
"💭 思考中...",
|
||||
"",
|
||||
)
|
||||
self.friend_message_welcome_text = self.config.get(
|
||||
"wecomaibot_friend_message_welcome_text",
|
||||
"",
|
||||
)
|
||||
self.unified_webhook_mode = self.config.get("unified_webhook_mode", False)
|
||||
self.msg_push_webhook_url = self.config.get("msg_push_webhook_url", "").strip()
|
||||
self.only_use_webhook_url_to_send = bool(
|
||||
self.config.get("only_use_webhook_url_to_send", False),
|
||||
)
|
||||
|
||||
# 平台元数据
|
||||
self.metadata = PlatformMetadata(
|
||||
name="wecom_ai_bot",
|
||||
description="企业微信智能机器人适配器,支持 HTTP 回调接收消息",
|
||||
id=self.config.get("id", "wecom_ai_bot"),
|
||||
support_proactive_message=False,
|
||||
support_proactive_message=bool(self.msg_push_webhook_url),
|
||||
)
|
||||
|
||||
# 初始化 API 客户端
|
||||
@@ -122,6 +127,16 @@ class WecomAIBotAdapter(Platform):
|
||||
self.queue_mgr,
|
||||
self._handle_queued_message,
|
||||
)
|
||||
self._stream_plain_cache: dict[str, str] = {}
|
||||
|
||||
self.webhook_client: WecomAIBotWebhookClient | None = None
|
||||
if self.msg_push_webhook_url:
|
||||
try:
|
||||
self.webhook_client = WecomAIBotWebhookClient(
|
||||
self.msg_push_webhook_url,
|
||||
)
|
||||
except WecomAIBotWebhookError as e:
|
||||
logger.error("企业微信消息推送 webhook 配置无效: %s", e)
|
||||
|
||||
async def _handle_queued_message(self, data: dict) -> None:
|
||||
"""处理队列中的消息,类似webchat的callback"""
|
||||
@@ -164,16 +179,19 @@ class WecomAIBotAdapter(Platform):
|
||||
)
|
||||
self.queue_mgr.set_pending_response(stream_id, callback_params)
|
||||
|
||||
resp = WecomAIBotStreamMessageBuilder.make_text_stream(
|
||||
stream_id,
|
||||
self.initial_respond_text,
|
||||
False,
|
||||
)
|
||||
return await self.api_client.encrypt_message(
|
||||
resp,
|
||||
callback_params["nonce"],
|
||||
callback_params["timestamp"],
|
||||
)
|
||||
if self.only_use_webhook_url_to_send and self.webhook_client:
|
||||
return None
|
||||
if self.initial_respond_text:
|
||||
resp = WecomAIBotStreamMessageBuilder.make_text_stream(
|
||||
stream_id,
|
||||
self.initial_respond_text,
|
||||
False,
|
||||
)
|
||||
return await self.api_client.encrypt_message(
|
||||
resp,
|
||||
callback_params["nonce"],
|
||||
callback_params["timestamp"],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("处理消息时发生异常: %s", e)
|
||||
return None
|
||||
@@ -181,6 +199,7 @@ class WecomAIBotAdapter(Platform):
|
||||
# wechat server is requesting for updates of a stream
|
||||
stream_id = message_data["stream"]["id"]
|
||||
if not self.queue_mgr.has_back_queue(stream_id):
|
||||
self._stream_plain_cache.pop(stream_id, None)
|
||||
if self.queue_mgr.is_stream_finished(stream_id):
|
||||
logger.debug(
|
||||
f"Stream already finished, returning end message: {stream_id}"
|
||||
@@ -208,24 +227,48 @@ class WecomAIBotAdapter(Platform):
|
||||
return None
|
||||
|
||||
# aggregate all delta chains in the back queue
|
||||
latest_plain_content = ""
|
||||
cached_plain_content = self._stream_plain_cache.get(stream_id, "")
|
||||
latest_plain_content = cached_plain_content
|
||||
image_base64 = []
|
||||
finish = False
|
||||
while not queue.empty():
|
||||
msg = await queue.get()
|
||||
if msg["type"] == "plain":
|
||||
latest_plain_content = msg["data"] or ""
|
||||
plain_data = msg.get("data") or ""
|
||||
if msg.get("streaming", False):
|
||||
# streaming plain payload is already cumulative
|
||||
cached_plain_content = plain_data
|
||||
else:
|
||||
# segmented non-stream send() pushes plain chunks, needs append
|
||||
cached_plain_content += plain_data
|
||||
latest_plain_content = cached_plain_content
|
||||
elif msg["type"] == "image":
|
||||
image_base64.append(msg["image_data"])
|
||||
elif msg["type"] == "break":
|
||||
continue
|
||||
elif msg["type"] in {"end", "complete"}:
|
||||
# stream end
|
||||
finish = True
|
||||
self.queue_mgr.remove_queues(stream_id, mark_finished=True)
|
||||
self._stream_plain_cache.pop(stream_id, None)
|
||||
break
|
||||
|
||||
logger.debug(
|
||||
f"Aggregated content: {latest_plain_content}, image: {len(image_base64)}, finish: {finish}",
|
||||
)
|
||||
if not finish:
|
||||
self._stream_plain_cache[stream_id] = cached_plain_content
|
||||
if finish and not latest_plain_content and not image_base64:
|
||||
end_message = WecomAIBotStreamMessageBuilder.make_text_stream(
|
||||
stream_id,
|
||||
"",
|
||||
True,
|
||||
)
|
||||
return await self.api_client.encrypt_message(
|
||||
end_message,
|
||||
callback_params["nonce"],
|
||||
callback_params["timestamp"],
|
||||
)
|
||||
if latest_plain_content or image_base64:
|
||||
msg_items = []
|
||||
if finish and image_base64:
|
||||
@@ -393,9 +436,23 @@ class WecomAIBotAdapter(Platform):
|
||||
session: MessageSesion,
|
||||
message_chain: MessageChain,
|
||||
) -> None:
|
||||
"""通过会话发送消息"""
|
||||
# 企业微信智能机器人主要通过回调响应,这里记录日志
|
||||
logger.info("会话发送消息: %s -> %s", session.session_id, message_chain)
|
||||
"""通过消息推送 webhook 发送消息。"""
|
||||
if not self.webhook_client:
|
||||
logger.warning(
|
||||
"主动消息发送失败: 未配置企业微信消息推送 Webhook URL,请前往配置添加。session_id=%s",
|
||||
session.session_id,
|
||||
)
|
||||
await super().send_by_session(session, message_chain)
|
||||
return
|
||||
|
||||
try:
|
||||
await self.webhook_client.send_message_chain(message_chain)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"企业微信消息推送失败(session=%s): %s",
|
||||
session.session_id,
|
||||
e,
|
||||
)
|
||||
await super().send_by_session(session, message_chain)
|
||||
|
||||
def run(self) -> Awaitable[Any]:
|
||||
@@ -448,6 +505,8 @@ class WecomAIBotAdapter(Platform):
|
||||
session_id=message.session_id,
|
||||
api_client=self.api_client,
|
||||
queue_mgr=self.queue_mgr,
|
||||
webhook_client=self.webhook_client,
|
||||
only_use_webhook_url_to_send=self.only_use_webhook_url_to_send,
|
||||
)
|
||||
|
||||
self.commit_event(message_event)
|
||||
|
||||
@@ -2,13 +2,11 @@
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.message_components import (
|
||||
Image,
|
||||
Plain,
|
||||
)
|
||||
from astrbot.api.message_components import At, Image, Plain
|
||||
|
||||
from .wecomai_api import WecomAIBotAPIClient
|
||||
from .wecomai_queue_mgr import WecomAIQueueMgr
|
||||
from .wecomai_webhook import WecomAIBotWebhookClient
|
||||
|
||||
|
||||
class WecomAIBotMessageEvent(AstrMessageEvent):
|
||||
@@ -22,6 +20,8 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
|
||||
session_id: str,
|
||||
api_client: WecomAIBotAPIClient,
|
||||
queue_mgr: WecomAIQueueMgr,
|
||||
webhook_client: WecomAIBotWebhookClient | None = None,
|
||||
only_use_webhook_url_to_send: bool = False,
|
||||
) -> None:
|
||||
"""初始化消息事件
|
||||
|
||||
@@ -36,6 +36,19 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
|
||||
super().__init__(message_str, message_obj, platform_meta, session_id)
|
||||
self.api_client = api_client
|
||||
self.queue_mgr = queue_mgr
|
||||
self.webhook_client = webhook_client
|
||||
self.only_use_webhook_url_to_send = only_use_webhook_url_to_send
|
||||
|
||||
async def _mark_stream_complete(self, stream_id: str) -> None:
|
||||
back_queue = self.queue_mgr.get_or_create_back_queue(stream_id)
|
||||
await back_queue.put(
|
||||
{
|
||||
"type": "complete",
|
||||
"data": "",
|
||||
"streaming": False,
|
||||
"session_id": stream_id,
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _send(
|
||||
@@ -43,6 +56,7 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
|
||||
stream_id: str,
|
||||
queue_mgr: WecomAIQueueMgr,
|
||||
streaming: bool = False,
|
||||
suppress_unsupported_log: bool = False,
|
||||
):
|
||||
back_queue = queue_mgr.get_or_create_back_queue(stream_id)
|
||||
|
||||
@@ -58,7 +72,17 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
|
||||
|
||||
data = ""
|
||||
for comp in message_chain.chain:
|
||||
if isinstance(comp, Plain):
|
||||
if isinstance(comp, At):
|
||||
data = f"@{comp.name} "
|
||||
await back_queue.put(
|
||||
{
|
||||
"type": "plain",
|
||||
"data": data,
|
||||
"streaming": streaming,
|
||||
"session_id": stream_id,
|
||||
},
|
||||
)
|
||||
elif isinstance(comp, Plain):
|
||||
data = comp.text
|
||||
await back_queue.put(
|
||||
{
|
||||
@@ -86,7 +110,10 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
|
||||
except Exception as e:
|
||||
logger.error("处理图片消息失败: %s", e)
|
||||
else:
|
||||
logger.warning(f"[WecomAI] 不支持的消息组件类型: {type(comp)}, 跳过")
|
||||
if not suppress_unsupported_log:
|
||||
logger.warning(
|
||||
f"[WecomAI] 不支持的消息组件类型: {type(comp)}, 跳过"
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
@@ -97,7 +124,24 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
|
||||
"wecom_ai_bot platform event raw_message should be a dict"
|
||||
)
|
||||
stream_id = raw.get("stream_id", self.session_id)
|
||||
await WecomAIBotMessageEvent._send(message, stream_id, self.queue_mgr)
|
||||
if self.only_use_webhook_url_to_send and self.webhook_client and message:
|
||||
await self.webhook_client.send_message_chain(message)
|
||||
await self._mark_stream_complete(stream_id)
|
||||
await super().send(MessageChain([]))
|
||||
return
|
||||
|
||||
if self.webhook_client and message:
|
||||
await self.webhook_client.send_message_chain(
|
||||
message,
|
||||
unsupported_only=True,
|
||||
)
|
||||
|
||||
await WecomAIBotMessageEvent._send(
|
||||
message,
|
||||
stream_id,
|
||||
self.queue_mgr,
|
||||
suppress_unsupported_log=self.webhook_client is not None,
|
||||
)
|
||||
await super().send(MessageChain([]))
|
||||
|
||||
async def send_streaming(self, generator, use_fallback=False) -> None:
|
||||
@@ -110,9 +154,23 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
|
||||
stream_id = raw.get("stream_id", self.session_id)
|
||||
back_queue = self.queue_mgr.get_or_create_back_queue(stream_id)
|
||||
|
||||
if self.only_use_webhook_url_to_send and self.webhook_client:
|
||||
merged_chain = MessageChain([])
|
||||
async for chain in generator:
|
||||
merged_chain.chain.extend(chain.chain)
|
||||
merged_chain.squash_plain()
|
||||
await self.webhook_client.send_message_chain(merged_chain)
|
||||
await self._mark_stream_complete(stream_id)
|
||||
await super().send_streaming(generator, use_fallback)
|
||||
return
|
||||
|
||||
# 企业微信智能机器人不支持增量发送,因此我们需要在这里将增量内容累积起来,积累发送
|
||||
increment_plain = ""
|
||||
async for chain in generator:
|
||||
if self.webhook_client:
|
||||
await self.webhook_client.send_message_chain(
|
||||
chain, unsupported_only=True
|
||||
)
|
||||
# 累积增量内容,并改写 Plain 段
|
||||
chain.squash_plain()
|
||||
for comp in chain.chain:
|
||||
@@ -128,7 +186,7 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
|
||||
"type": "break", # break means a segment end
|
||||
"data": final_data,
|
||||
"streaming": True,
|
||||
"session_id": self.session_id,
|
||||
"session_id": stream_id,
|
||||
},
|
||||
)
|
||||
final_data = ""
|
||||
@@ -139,6 +197,7 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
|
||||
stream_id=stream_id,
|
||||
queue_mgr=self.queue_mgr,
|
||||
streaming=True,
|
||||
suppress_unsupported_log=self.webhook_client is not None,
|
||||
)
|
||||
|
||||
await back_queue.put(
|
||||
@@ -146,7 +205,7 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
|
||||
"type": "complete", # complete means we return the final result
|
||||
"data": final_data,
|
||||
"streaming": True,
|
||||
"session_id": self.session_id,
|
||||
"session_id": stream_id,
|
||||
},
|
||||
)
|
||||
await super().send_streaming(generator, use_fallback)
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""企业微信智能机器人 webhook 推送客户端。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
from urllib.parse import parse_qs, urlencode, urlparse
|
||||
|
||||
import aiohttp
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.api.message_components import At, File, Image, Plain, Record, Video
|
||||
from astrbot.core.utils.media_utils import convert_audio_format
|
||||
|
||||
|
||||
class WecomAIBotWebhookError(RuntimeError):
|
||||
"""企业微信 webhook 推送异常。"""
|
||||
|
||||
|
||||
class WecomAIBotWebhookClient:
|
||||
"""企业微信智能机器人 webhook 消息推送客户端。"""
|
||||
|
||||
def __init__(self, webhook_url: str, timeout_seconds: int = 15) -> None:
|
||||
self.webhook_url = webhook_url.strip()
|
||||
self.timeout_seconds = timeout_seconds
|
||||
if not self.webhook_url:
|
||||
raise WecomAIBotWebhookError("消息推送 webhook URL 不能为空")
|
||||
self._webhook_key = self._extract_webhook_key()
|
||||
|
||||
def _extract_webhook_key(self) -> str:
|
||||
parsed = urlparse(self.webhook_url)
|
||||
key = parse_qs(parsed.query).get("key", [""])[0].strip()
|
||||
if not key:
|
||||
raise WecomAIBotWebhookError("消息推送 webhook URL 缺少 key 参数")
|
||||
return key
|
||||
|
||||
def _build_upload_url(self, media_type: Literal["file", "voice"]) -> str:
|
||||
query = urlencode({"key": self._webhook_key, "type": media_type})
|
||||
return f"https://qyapi.weixin.qq.com/cgi-bin/webhook/upload_media?{query}"
|
||||
|
||||
@staticmethod
|
||||
def _split_markdown_v2_content(content: str, max_bytes: int = 4096) -> list[str]:
|
||||
if not content:
|
||||
return []
|
||||
chunks: list[str] = []
|
||||
buffer: list[str] = []
|
||||
current_size = 0
|
||||
for char in content:
|
||||
char_size = len(char.encode("utf-8"))
|
||||
if current_size + char_size > max_bytes and buffer:
|
||||
chunks.append("".join(buffer))
|
||||
buffer = [char]
|
||||
current_size = char_size
|
||||
else:
|
||||
buffer.append(char)
|
||||
current_size += char_size
|
||||
if buffer:
|
||||
chunks.append("".join(buffer))
|
||||
return chunks
|
||||
|
||||
async def send_payload(self, payload: dict[str, Any]) -> None:
|
||||
timeout = aiohttp.ClientTimeout(total=self.timeout_seconds)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.post(self.webhook_url, json=payload) as response:
|
||||
text = await response.text()
|
||||
if response.status != 200:
|
||||
raise WecomAIBotWebhookError(
|
||||
f"Webhook 请求失败: HTTP {response.status}, {text}"
|
||||
)
|
||||
result = await response.json(content_type=None)
|
||||
if result.get("errcode") != 0:
|
||||
raise WecomAIBotWebhookError(
|
||||
f"Webhook 返回错误: {result.get('errcode')} {result.get('errmsg')}"
|
||||
)
|
||||
logger.debug("企业微信消息推送成功: %s", payload.get("msgtype", "unknown"))
|
||||
|
||||
async def send_markdown_v2(self, content: str) -> None:
|
||||
for chunk in self._split_markdown_v2_content(content):
|
||||
await self.send_payload(
|
||||
{
|
||||
"msgtype": "markdown_v2",
|
||||
"markdown_v2": {"content": chunk},
|
||||
}
|
||||
)
|
||||
|
||||
async def send_image_base64(self, image_base64: str) -> None:
|
||||
image_bytes = base64.b64decode(image_base64)
|
||||
md5 = hashlib.md5(image_bytes).hexdigest()
|
||||
await self.send_payload(
|
||||
{
|
||||
"msgtype": "image",
|
||||
"image": {
|
||||
"base64": image_base64,
|
||||
"md5": md5,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
async def upload_media(
|
||||
self, file_path: Path, media_type: Literal["file", "voice"]
|
||||
) -> str:
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise WecomAIBotWebhookError(f"文件不存在: {file_path}")
|
||||
|
||||
content_type = (
|
||||
mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
|
||||
)
|
||||
form = aiohttp.FormData()
|
||||
form.add_field(
|
||||
"media",
|
||||
file_path.read_bytes(),
|
||||
filename=file_path.name,
|
||||
content_type=content_type,
|
||||
)
|
||||
|
||||
timeout = aiohttp.ClientTimeout(total=self.timeout_seconds)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.post(
|
||||
self._build_upload_url(media_type),
|
||||
data=form,
|
||||
) as response:
|
||||
text = await response.text()
|
||||
if response.status != 200:
|
||||
raise WecomAIBotWebhookError(
|
||||
f"上传媒体失败: HTTP {response.status}, {text}"
|
||||
)
|
||||
result = await response.json(content_type=None)
|
||||
if result.get("errcode") != 0:
|
||||
raise WecomAIBotWebhookError(
|
||||
f"上传媒体失败: {result.get('errcode')} {result.get('errmsg')}"
|
||||
)
|
||||
media_id = result.get("media_id", "")
|
||||
if not media_id:
|
||||
raise WecomAIBotWebhookError("上传媒体失败: 返回缺少 media_id")
|
||||
return str(media_id)
|
||||
|
||||
async def send_file(self, file_path: Path) -> None:
|
||||
media_id = await self.upload_media(file_path, "file")
|
||||
await self.send_payload(
|
||||
{
|
||||
"msgtype": "file",
|
||||
"file": {"media_id": media_id},
|
||||
}
|
||||
)
|
||||
|
||||
async def send_voice(self, file_path: Path) -> None:
|
||||
media_id = await self.upload_media(file_path, "voice")
|
||||
await self.send_payload(
|
||||
{
|
||||
"msgtype": "voice",
|
||||
"voice": {"media_id": media_id},
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def is_stream_supported_component(component: Any) -> bool:
|
||||
return isinstance(component, Plain | Image | At)
|
||||
|
||||
async def send_message_chain(
|
||||
self,
|
||||
message_chain: MessageChain,
|
||||
unsupported_only: bool = False,
|
||||
) -> None:
|
||||
async def flush_markdown_buffer(parts: list[str]) -> None:
|
||||
content = "".join(parts).strip()
|
||||
parts.clear()
|
||||
if content:
|
||||
await self.send_markdown_v2(content)
|
||||
|
||||
markdown_buffer: list[str] = []
|
||||
|
||||
for component in message_chain.chain:
|
||||
if unsupported_only and self.is_stream_supported_component(component):
|
||||
continue
|
||||
if isinstance(component, Plain):
|
||||
markdown_buffer.append(component.text)
|
||||
elif isinstance(component, At):
|
||||
mention_name = component.name or str(component.qq)
|
||||
markdown_buffer.append(f" @{mention_name} ")
|
||||
elif isinstance(component, Image):
|
||||
await flush_markdown_buffer(markdown_buffer)
|
||||
image_base64 = await component.convert_to_base64()
|
||||
await self.send_image_base64(image_base64)
|
||||
elif isinstance(component, File):
|
||||
await flush_markdown_buffer(markdown_buffer)
|
||||
file_path = await component.get_file()
|
||||
if not file_path:
|
||||
logger.warning("文件消息缺少有效文件路径,已跳过: %s", component)
|
||||
continue
|
||||
await self.send_file(Path(file_path))
|
||||
elif isinstance(component, Video):
|
||||
await flush_markdown_buffer(markdown_buffer)
|
||||
video_path = await component.convert_to_file_path()
|
||||
await self.send_file(Path(video_path))
|
||||
elif isinstance(component, Record):
|
||||
await flush_markdown_buffer(markdown_buffer)
|
||||
source_voice_path = Path(await component.convert_to_file_path())
|
||||
target_voice_path = source_voice_path
|
||||
converted = False
|
||||
if source_voice_path.suffix.lower() != ".amr":
|
||||
target_voice_path = Path(
|
||||
await convert_audio_format(str(source_voice_path), "amr"),
|
||||
)
|
||||
converted = target_voice_path != source_voice_path
|
||||
try:
|
||||
await self.send_voice(target_voice_path)
|
||||
finally:
|
||||
if converted and target_voice_path.exists():
|
||||
try:
|
||||
target_voice_path.unlink()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"清理临时语音文件失败 %s: %s", target_voice_path, e
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"企业微信消息推送暂不支持组件类型 %s,已跳过",
|
||||
type(component).__name__,
|
||||
)
|
||||
|
||||
await flush_markdown_buffer(markdown_buffer)
|
||||
@@ -24,6 +24,7 @@ from astrbot.api.platform import (
|
||||
)
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
from astrbot.core.utils.media_utils import convert_audio_to_wav
|
||||
from astrbot.core.utils.webhook_utils import log_webhook_info
|
||||
|
||||
from .weixin_offacc_event import WeixinOfficialAccountPlatformEvent
|
||||
@@ -294,14 +295,11 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
|
||||
f.write(resp.content)
|
||||
|
||||
try:
|
||||
from pydub import AudioSegment
|
||||
|
||||
path_wav = f"data/temp/wecom_{msg.media_id}.wav"
|
||||
audio = AudioSegment.from_file(path)
|
||||
audio.export(path_wav, format="wav")
|
||||
path_wav = await convert_audio_to_wav(path, path_wav)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"转换音频失败: {e}。如果没有安装 pydub 和 ffmpeg 请先安装。",
|
||||
f"转换音频失败: {e}。如果没有安装 ffmpeg 请先安装。",
|
||||
)
|
||||
path_wav = path
|
||||
return
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
import os
|
||||
from typing import cast
|
||||
|
||||
from wechatpy import WeChatClient
|
||||
@@ -9,13 +9,7 @@ from astrbot.api import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.message_components import Image, Plain, Record
|
||||
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
|
||||
|
||||
try:
|
||||
import pydub
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"检测到 pydub 库未安装,微信公众平台将无法语音收发。如需使用语音,请前往管理面板 -> 平台日志 -> 安装 Pip 库安装 pydub。",
|
||||
)
|
||||
from astrbot.core.utils.media_utils import convert_audio_to_amr
|
||||
|
||||
|
||||
class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
|
||||
@@ -137,38 +131,46 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
|
||||
|
||||
elif isinstance(comp, Record):
|
||||
record_path = await comp.convert_to_file_path()
|
||||
# 转成amr
|
||||
record_path_amr = f"data/temp/{uuid.uuid4()}.amr"
|
||||
pydub.AudioSegment.from_wav(record_path).export(
|
||||
record_path_amr,
|
||||
format="amr",
|
||||
)
|
||||
record_path_amr = await convert_audio_to_amr(record_path)
|
||||
|
||||
with open(record_path_amr, "rb") as f:
|
||||
try:
|
||||
response = self.client.media.upload("voice", f)
|
||||
except Exception as e:
|
||||
logger.error(f"微信公众平台上传语音失败: {e}")
|
||||
await self.send(
|
||||
MessageChain().message(f"微信公众平台上传语音失败: {e}"),
|
||||
)
|
||||
return
|
||||
logger.info(f"微信公众平台上传语音返回: {response}")
|
||||
try:
|
||||
with open(record_path_amr, "rb") as f:
|
||||
try:
|
||||
response = self.client.media.upload("voice", f)
|
||||
except Exception as e:
|
||||
logger.error(f"微信公众平台上传语音失败: {e}")
|
||||
await self.send(
|
||||
MessageChain().message(
|
||||
f"微信公众平台上传语音失败: {e}"
|
||||
),
|
||||
)
|
||||
return
|
||||
logger.info(f"微信公众平台上传语音返回: {response}")
|
||||
|
||||
if active_send_mode:
|
||||
self.client.message.send_voice(
|
||||
message_obj.sender.user_id,
|
||||
response["media_id"],
|
||||
)
|
||||
else:
|
||||
reply = VoiceReply(
|
||||
media_id=response["media_id"],
|
||||
message=cast(dict, self.message_obj.raw_message)["message"],
|
||||
)
|
||||
xml = reply.render()
|
||||
future = cast(dict, self.message_obj.raw_message)["future"]
|
||||
assert isinstance(future, asyncio.Future)
|
||||
future.set_result(xml)
|
||||
if active_send_mode:
|
||||
self.client.message.send_voice(
|
||||
message_obj.sender.user_id,
|
||||
response["media_id"],
|
||||
)
|
||||
else:
|
||||
reply = VoiceReply(
|
||||
media_id=response["media_id"],
|
||||
message=cast(dict, self.message_obj.raw_message)[
|
||||
"message"
|
||||
],
|
||||
)
|
||||
xml = reply.render()
|
||||
future = cast(dict, self.message_obj.raw_message)["future"]
|
||||
assert isinstance(future, asyncio.Future)
|
||||
future.set_result(xml)
|
||||
finally:
|
||||
if record_path_amr != record_path and os.path.exists(
|
||||
record_path_amr
|
||||
):
|
||||
try:
|
||||
os.remove(record_path_amr)
|
||||
except OSError as e:
|
||||
logger.warning(f"删除临时音频文件失败: {e}")
|
||||
|
||||
else:
|
||||
logger.warning(f"还没实现这个消息类型的发送逻辑: {comp.type}。")
|
||||
|
||||
+72
-19
@@ -44,6 +44,73 @@ class AstrBotUpdator(RepoZipUpdator):
|
||||
except psutil.NoSuchProcess:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _is_option_arg(arg: str) -> bool:
|
||||
return arg.startswith("-")
|
||||
|
||||
@classmethod
|
||||
def _collect_flag_values(cls, argv: list[str], flag: str) -> str | None:
|
||||
try:
|
||||
idx = argv.index(flag)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if idx + 1 >= len(argv):
|
||||
return None
|
||||
|
||||
value_parts: list[str] = []
|
||||
for arg in argv[idx + 1 :]:
|
||||
if cls._is_option_arg(arg):
|
||||
break
|
||||
if arg:
|
||||
value_parts.append(arg)
|
||||
|
||||
if not value_parts:
|
||||
return None
|
||||
|
||||
return " ".join(value_parts).strip() or None
|
||||
|
||||
@classmethod
|
||||
def _resolve_webui_dir_arg(cls, argv: list[str]) -> str | None:
|
||||
return cls._collect_flag_values(argv, "--webui-dir")
|
||||
|
||||
def _build_frozen_reboot_args(self) -> list[str]:
|
||||
argv = list(sys.argv[1:])
|
||||
webui_dir = self._resolve_webui_dir_arg(argv)
|
||||
if not webui_dir:
|
||||
webui_dir = os.environ.get("ASTRBOT_WEBUI_DIR")
|
||||
|
||||
if webui_dir:
|
||||
return ["--webui-dir", webui_dir]
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _reset_pyinstaller_environment() -> None:
|
||||
if not getattr(sys, "frozen", False):
|
||||
return
|
||||
os.environ["PYINSTALLER_RESET_ENVIRONMENT"] = "1"
|
||||
for key in list(os.environ.keys()):
|
||||
if key.startswith("_PYI_"):
|
||||
os.environ.pop(key, None)
|
||||
|
||||
def _build_reboot_argv(self, executable: str) -> list[str]:
|
||||
if os.environ.get("ASTRBOT_CLI") == "1":
|
||||
args = sys.argv[1:]
|
||||
return [executable, "-m", "astrbot.cli.__main__", *args]
|
||||
if getattr(sys, "frozen", False):
|
||||
args = self._build_frozen_reboot_args()
|
||||
return [executable, *args]
|
||||
return [executable, *sys.argv]
|
||||
|
||||
@staticmethod
|
||||
def _exec_reboot(executable: str, argv: list[str]) -> None:
|
||||
if os.name == "nt" and getattr(sys, "frozen", False):
|
||||
quoted_executable = f'"{executable}"' if " " in executable else executable
|
||||
quoted_args = [f'"{arg}"' if " " in arg else arg for arg in argv[1:]]
|
||||
os.execl(executable, quoted_executable, *quoted_args)
|
||||
return
|
||||
os.execv(executable, argv)
|
||||
|
||||
def _reboot(self, delay: int = 3) -> None:
|
||||
"""重启当前程序
|
||||
在指定的延迟后,终止所有子进程并重新启动程序
|
||||
@@ -51,28 +118,14 @@ class AstrBotUpdator(RepoZipUpdator):
|
||||
"""
|
||||
time.sleep(delay)
|
||||
self.terminate_child_processes()
|
||||
if os.name == "nt":
|
||||
py = f'"{sys.executable}"'
|
||||
else:
|
||||
py = sys.executable
|
||||
executable = sys.executable
|
||||
|
||||
try:
|
||||
# 仅 CLI 模式走 `python -m astrbot.cli.__main__`,
|
||||
# 打包后的后端可执行文件需要直接 exec 自身。
|
||||
if os.environ.get("ASTRBOT_CLI") == "1":
|
||||
if os.name == "nt":
|
||||
args = [f'"{arg}"' if " " in arg else arg for arg in sys.argv[1:]]
|
||||
else:
|
||||
args = sys.argv[1:]
|
||||
os.execl(sys.executable, py, "-m", "astrbot.cli.__main__", *args)
|
||||
else:
|
||||
if getattr(sys, "frozen", False):
|
||||
# Frozen executable should not receive argv[0] as a positional argument.
|
||||
os.execl(sys.executable, py, *sys.argv[1:])
|
||||
else:
|
||||
os.execl(sys.executable, py, *sys.argv)
|
||||
self._reset_pyinstaller_environment()
|
||||
reboot_argv = self._build_reboot_argv(executable)
|
||||
self._exec_reboot(executable, reboot_argv)
|
||||
except Exception as e:
|
||||
logger.error(f"重启失败({py}, {e}),请尝试手动重启。")
|
||||
logger.error(f"重启失败({executable}, {e}),请尝试手动重启。")
|
||||
raise e
|
||||
|
||||
async def check_update(
|
||||
|
||||
@@ -15,6 +15,8 @@ Skills 目录路径:固定为数据目录下的 skills 目录
|
||||
|
||||
import os
|
||||
|
||||
from astrbot.core.utils.runtime_env import is_packaged_electron_runtime
|
||||
|
||||
|
||||
def get_astrbot_path() -> str:
|
||||
"""获取Astrbot项目路径"""
|
||||
@@ -27,6 +29,8 @@ def get_astrbot_root() -> str:
|
||||
"""获取Astrbot根目录路径"""
|
||||
if path := os.environ.get("ASTRBOT_ROOT"):
|
||||
return os.path.realpath(path)
|
||||
if is_packaged_electron_runtime():
|
||||
return os.path.realpath(os.path.join(os.path.expanduser("~"), ".astrbot"))
|
||||
return os.path.realpath(os.getcwd())
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import logging
|
||||
import ssl
|
||||
import threading
|
||||
|
||||
import aiohttp
|
||||
|
||||
from astrbot.utils.http_ssl_common import (
|
||||
build_ssl_context_with_certifi as _build_ssl_context,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
_SHARED_TLS_CONTEXT: ssl.SSLContext | None = None
|
||||
_SHARED_TLS_CONTEXT_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def build_ssl_context_with_certifi() -> ssl.SSLContext:
|
||||
"""Build an SSL context from system trust store and add certifi CAs."""
|
||||
global _SHARED_TLS_CONTEXT
|
||||
|
||||
if _SHARED_TLS_CONTEXT is not None:
|
||||
return _SHARED_TLS_CONTEXT
|
||||
|
||||
with _SHARED_TLS_CONTEXT_LOCK:
|
||||
if _SHARED_TLS_CONTEXT is not None:
|
||||
return _SHARED_TLS_CONTEXT
|
||||
|
||||
_SHARED_TLS_CONTEXT = _build_ssl_context(log_obj=logger)
|
||||
return _SHARED_TLS_CONTEXT
|
||||
|
||||
|
||||
def build_tls_connector() -> aiohttp.TCPConnector:
|
||||
return aiohttp.TCPConnector(ssl=build_ssl_context_with_certifi())
|
||||
@@ -3,6 +3,7 @@ from typing import Literal, TypedDict
|
||||
import aiohttp
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.utils.http_ssl import build_tls_connector
|
||||
|
||||
|
||||
class LLMModalities(TypedDict):
|
||||
@@ -32,7 +33,9 @@ LLM_METADATAS: dict[str, LLMMetadata] = {}
|
||||
async def update_llm_metadata() -> None:
|
||||
url = "https://models.dev/api.json"
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with aiohttp.ClientSession(
|
||||
trust_env=True, connector=build_tls_connector()
|
||||
) as session:
|
||||
async with session.get(url) as response:
|
||||
data = await response.json()
|
||||
global LLM_METADATAS
|
||||
|
||||
@@ -7,6 +7,7 @@ import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
@@ -205,3 +206,110 @@ async def convert_video_format(
|
||||
except Exception as e:
|
||||
logger.error(f"[Media Utils] 转换视频格式时出错: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def convert_audio_format(
|
||||
audio_path: str,
|
||||
output_format: str = "amr",
|
||||
output_path: str | None = None,
|
||||
) -> str:
|
||||
"""使用ffmpeg将音频转换为指定格式。
|
||||
|
||||
Args:
|
||||
audio_path: 原始音频文件路径
|
||||
output_format: 目标格式,例如 amr / ogg
|
||||
output_path: 输出文件路径,如果为None则自动生成
|
||||
|
||||
Returns:
|
||||
转换后的音频文件路径
|
||||
"""
|
||||
if audio_path.lower().endswith(f".{output_format}"):
|
||||
return audio_path
|
||||
|
||||
if output_path is None:
|
||||
temp_dir = Path(get_astrbot_data_path()) / "temp"
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_path = str(temp_dir / f"{uuid.uuid4()}.{output_format}")
|
||||
|
||||
args = ["ffmpeg", "-y", "-i", audio_path]
|
||||
if output_format == "amr":
|
||||
args.extend(["-ac", "1", "-ar", "8000", "-ab", "12.2k"])
|
||||
elif output_format == "ogg":
|
||||
args.extend(["-acodec", "libopus", "-ac", "1", "-ar", "16000"])
|
||||
args.append(output_path)
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await process.communicate()
|
||||
if process.returncode != 0:
|
||||
if output_path and os.path.exists(output_path):
|
||||
try:
|
||||
os.remove(output_path)
|
||||
except OSError as e:
|
||||
logger.warning(f"[Media Utils] 清理失败的音频输出文件时出错: {e}")
|
||||
error_msg = stderr.decode() if stderr else "未知错误"
|
||||
raise Exception(f"ffmpeg conversion failed: {error_msg}")
|
||||
logger.debug(f"[Media Utils] 音频转换成功: {audio_path} -> {output_path}")
|
||||
return output_path
|
||||
except FileNotFoundError:
|
||||
raise Exception("ffmpeg not found")
|
||||
|
||||
|
||||
async def convert_audio_to_amr(audio_path: str, output_path: str | None = None) -> str:
|
||||
"""将音频转换为amr格式。"""
|
||||
return await convert_audio_format(
|
||||
audio_path=audio_path,
|
||||
output_format="amr",
|
||||
output_path=output_path,
|
||||
)
|
||||
|
||||
|
||||
async def convert_audio_to_wav(audio_path: str, output_path: str | None = None) -> str:
|
||||
"""将音频转换为wav格式。"""
|
||||
return await convert_audio_format(
|
||||
audio_path=audio_path,
|
||||
output_format="wav",
|
||||
output_path=output_path,
|
||||
)
|
||||
|
||||
|
||||
async def extract_video_cover(
|
||||
video_path: str,
|
||||
output_path: str | None = None,
|
||||
) -> str:
|
||||
"""从视频中提取封面图(JPG)。"""
|
||||
if output_path is None:
|
||||
temp_dir = Path(get_astrbot_data_path()) / "temp"
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_path = str(temp_dir / f"{uuid.uuid4()}.jpg")
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
video_path,
|
||||
"-ss",
|
||||
"00:00:00",
|
||||
"-frames:v",
|
||||
"1",
|
||||
output_path,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await process.communicate()
|
||||
if process.returncode != 0:
|
||||
if output_path and os.path.exists(output_path):
|
||||
try:
|
||||
os.remove(output_path)
|
||||
except OSError as e:
|
||||
logger.warning(f"[Media Utils] 清理失败的视频封面文件时出错: {e}")
|
||||
error_msg = stderr.decode() if stderr else "未知错误"
|
||||
raise Exception(f"ffmpeg extract cover failed: {error_msg}")
|
||||
return output_path
|
||||
except FileNotFoundError:
|
||||
raise Exception("ffmpeg not found")
|
||||
|
||||
@@ -2,43 +2,32 @@ import asyncio
|
||||
import contextlib
|
||||
import importlib
|
||||
import io
|
||||
import locale
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path
|
||||
from astrbot.core.utils.runtime_env import is_packaged_electron_runtime
|
||||
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
|
||||
def _robust_decode(line: bytes) -> str:
|
||||
"""解码字节流,兼容不同平台的编码"""
|
||||
try:
|
||||
return line.decode("utf-8").strip()
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
try:
|
||||
return line.decode(locale.getpreferredencoding(False)).strip()
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
if sys.platform.startswith("win"):
|
||||
try:
|
||||
return line.decode("gbk").strip()
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
return line.decode("utf-8", errors="replace").strip()
|
||||
|
||||
|
||||
def _is_frozen_runtime() -> bool:
|
||||
return bool(getattr(sys, "frozen", False))
|
||||
_DISTLIB_FINDER_PATCH_ATTEMPTED = False
|
||||
|
||||
|
||||
def _get_pip_main():
|
||||
try:
|
||||
from pip._internal.cli.main import main as pip_main
|
||||
except ImportError:
|
||||
from pip import main as pip_main
|
||||
try:
|
||||
from pip import main as pip_main
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"pip module is unavailable "
|
||||
f"(sys.executable={sys.executable}, "
|
||||
f"frozen={getattr(sys, 'frozen', False)}, "
|
||||
f"ASTRBOT_ELECTRON_CLIENT={os.environ.get('ASTRBOT_ELECTRON_CLIENT')})"
|
||||
) from exc
|
||||
|
||||
return pip_main
|
||||
|
||||
|
||||
@@ -60,6 +49,110 @@ def _cleanup_added_root_handlers(original_handlers: list[logging.Handler]) -> No
|
||||
handler.close()
|
||||
|
||||
|
||||
def _get_loader_for_package(package: object) -> object | None:
|
||||
loader = getattr(package, "__loader__", None)
|
||||
if loader is not None:
|
||||
return loader
|
||||
|
||||
spec = getattr(package, "__spec__", None)
|
||||
if spec is None:
|
||||
return None
|
||||
return getattr(spec, "loader", None)
|
||||
|
||||
|
||||
def _try_register_distlib_finder(
|
||||
distlib_resources: object,
|
||||
finder_registry: dict[type, object],
|
||||
register_finder,
|
||||
resource_finder: object,
|
||||
loader: object,
|
||||
package_name: str,
|
||||
) -> bool:
|
||||
loader_type = type(loader)
|
||||
if loader_type in finder_registry:
|
||||
return False
|
||||
|
||||
try:
|
||||
register_finder(loader_type, resource_finder)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to patch pip distlib finder for loader %s (%s): %s",
|
||||
loader_type.__name__,
|
||||
package_name,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
updated_registry = getattr(distlib_resources, "_finder_registry", finder_registry)
|
||||
if isinstance(updated_registry, dict) and loader_type not in updated_registry:
|
||||
logger.warning(
|
||||
"Distlib finder patch did not take effect for loader %s (%s).",
|
||||
loader_type.__name__,
|
||||
package_name,
|
||||
)
|
||||
return False
|
||||
|
||||
logger.info(
|
||||
"Patched pip distlib finder for frozen loader: %s (%s)",
|
||||
loader_type.__name__,
|
||||
package_name,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _patch_distlib_finder_for_frozen_runtime() -> None:
|
||||
global _DISTLIB_FINDER_PATCH_ATTEMPTED
|
||||
|
||||
if not getattr(sys, "frozen", False):
|
||||
return
|
||||
if _DISTLIB_FINDER_PATCH_ATTEMPTED:
|
||||
return
|
||||
|
||||
_DISTLIB_FINDER_PATCH_ATTEMPTED = True
|
||||
|
||||
try:
|
||||
from pip._vendor.distlib import resources as distlib_resources
|
||||
except Exception:
|
||||
return
|
||||
|
||||
finder_registry = getattr(distlib_resources, "_finder_registry", None)
|
||||
register_finder = getattr(distlib_resources, "register_finder", None)
|
||||
resource_finder = getattr(distlib_resources, "ResourceFinder", None)
|
||||
|
||||
if not isinstance(finder_registry, dict):
|
||||
logger.warning(
|
||||
"Skip patching distlib finder because _finder_registry is unavailable."
|
||||
)
|
||||
return
|
||||
if not callable(register_finder) or resource_finder is None:
|
||||
logger.warning(
|
||||
"Skip patching distlib finder because register API is unavailable."
|
||||
)
|
||||
return
|
||||
|
||||
for package_name in ("pip._vendor.distlib", "pip._vendor"):
|
||||
try:
|
||||
package = importlib.import_module(package_name)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
loader = _get_loader_for_package(package)
|
||||
if loader is None:
|
||||
continue
|
||||
|
||||
if _try_register_distlib_finder(
|
||||
distlib_resources,
|
||||
finder_registry,
|
||||
register_finder,
|
||||
resource_finder,
|
||||
loader,
|
||||
package_name,
|
||||
):
|
||||
finder_registry = getattr(
|
||||
distlib_resources, "_finder_registry", finder_registry
|
||||
)
|
||||
|
||||
|
||||
class PipInstaller:
|
||||
def __init__(self, pip_install_arg: str, pypi_index_url: str | None = None) -> None:
|
||||
self.pip_install_arg = pip_install_arg
|
||||
@@ -78,11 +171,10 @@ class PipInstaller:
|
||||
args.extend(["-r", requirements_path])
|
||||
|
||||
index_url = mirror or self.pypi_index_url or "https://pypi.org/simple"
|
||||
|
||||
args.extend(["--trusted-host", "mirrors.aliyun.com", "-i", index_url])
|
||||
|
||||
target_site_packages = None
|
||||
if _is_frozen_runtime():
|
||||
if is_packaged_electron_runtime():
|
||||
target_site_packages = get_astrbot_site_packages_path()
|
||||
os.makedirs(target_site_packages, exist_ok=True)
|
||||
args.extend(["--target", target_site_packages])
|
||||
@@ -91,14 +183,7 @@ class PipInstaller:
|
||||
args.extend(self.pip_install_arg.split())
|
||||
|
||||
logger.info(f"Pip 包管理器: pip {' '.join(args)}")
|
||||
result_code = None
|
||||
if _is_frozen_runtime():
|
||||
result_code = await self._run_pip_in_process(args)
|
||||
else:
|
||||
try:
|
||||
result_code = await self._run_pip_subprocess(args)
|
||||
except FileNotFoundError:
|
||||
result_code = await self._run_pip_in_process(args)
|
||||
result_code = await self._run_pip_in_process(args)
|
||||
|
||||
if result_code != 0:
|
||||
raise Exception(f"安装失败,错误码:{result_code}")
|
||||
@@ -107,25 +192,10 @@ class PipInstaller:
|
||||
sys.path.insert(0, target_site_packages)
|
||||
importlib.invalidate_caches()
|
||||
|
||||
async def _run_pip_subprocess(self, args: list[str]) -> int:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
|
||||
assert process.stdout is not None
|
||||
async for line in process.stdout:
|
||||
logger.info(_robust_decode(line))
|
||||
|
||||
await process.wait()
|
||||
return process.returncode
|
||||
|
||||
async def _run_pip_in_process(self, args: list[str]) -> int:
|
||||
pip_main = _get_pip_main()
|
||||
_patch_distlib_finder_for_frozen_runtime()
|
||||
|
||||
original_handlers = list(logging.getLogger().handlers)
|
||||
result_code, output = await asyncio.to_thread(
|
||||
_run_pip_main_with_output, pip_main, args
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def is_frozen_runtime() -> bool:
|
||||
return bool(getattr(sys, "frozen", False))
|
||||
|
||||
|
||||
def is_packaged_electron_runtime() -> bool:
|
||||
return is_frozen_runtime() and os.environ.get("ASTRBOT_ELECTRON_CLIENT") == "1"
|
||||
@@ -1,12 +1,11 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
import ssl
|
||||
|
||||
import aiohttp
|
||||
import certifi
|
||||
|
||||
from astrbot.core.config import VERSION
|
||||
from astrbot.core.utils.http_ssl import build_tls_connector
|
||||
from astrbot.core.utils.io import download_image_by_url
|
||||
from astrbot.core.utils.t2i.template_manager import TemplateManager
|
||||
|
||||
@@ -39,7 +38,10 @@ class NetworkRenderStrategy(RenderStrategy):
|
||||
async def get_official_endpoints(self) -> None:
|
||||
"""获取官方的 t2i 端点列表。"""
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with aiohttp.ClientSession(
|
||||
trust_env=True,
|
||||
connector=build_tls_connector(),
|
||||
) as session:
|
||||
async with session.get(
|
||||
"https://api.soulter.top/astrbot/t2i-endpoints",
|
||||
) as resp:
|
||||
@@ -88,12 +90,10 @@ class NetworkRenderStrategy(RenderStrategy):
|
||||
for endpoint in endpoints:
|
||||
try:
|
||||
if return_url:
|
||||
ssl_context = ssl.create_default_context(cafile=certifi.where())
|
||||
connector = aiohttp.TCPConnector(ssl=ssl_context)
|
||||
async with (
|
||||
aiohttp.ClientSession(
|
||||
trust_env=True,
|
||||
connector=connector,
|
||||
connector=build_tls_connector(),
|
||||
) as session,
|
||||
session.post(
|
||||
f"{endpoint}/generate",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import logging
|
||||
import ssl
|
||||
from typing import Any
|
||||
|
||||
import certifi
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_ssl_context_with_certifi(log_obj: Any | None = None) -> ssl.SSLContext:
|
||||
logger = log_obj or _LOGGER
|
||||
|
||||
ssl_context = ssl.create_default_context()
|
||||
try:
|
||||
ssl_context.load_verify_locations(cafile=certifi.where())
|
||||
except Exception as exc:
|
||||
if logger and hasattr(logger, "warning"):
|
||||
logger.warning(
|
||||
"Failed to load certifi CA bundle into SSL context; "
|
||||
"falling back to system trust store only: %s",
|
||||
exc,
|
||||
)
|
||||
|
||||
return ssl_context
|
||||
@@ -0,0 +1,41 @@
|
||||
## What's Changed
|
||||
|
||||
> 提醒 **v4.14.8** 用户:由于 v4.14.8 版本 Bug,若您未使用 Electron AstrBot 桌面应用,会被错误地通过 WebUI 对话框跳转到此页,**您可能需要手动重新部署 AstrBot 才能升级**。
|
||||
|
||||
### 新增
|
||||
- 企业微信智能机器人支持主动消息推送,并新增视频、文件等消息类型支持 ([#4999](https://github.com/AstrBotDevs/AstrBot/issues/4999))
|
||||
- 企业微信应用支持主动消息推送,并优化企微应用、微信公众号、微信客服的音频处理流程 ([#4998](https://github.com/AstrBotDevs/AstrBot/issues/4998))
|
||||
- 钉钉适配器支持主动消息推送,并新增图片、视频、音频等消息类型支持 ([#4986](https://github.com/AstrBotDevs/AstrBot/issues/4986))
|
||||
- 人格管理弹窗新增删除按钮 ([#4978](https://github.com/AstrBotDevs/AstrBot/issues/4978))
|
||||
|
||||
### 修复
|
||||
- 修复 SubAgents 工具去重相关问题 ([#4990](https://github.com/AstrBotDevs/AstrBot/issues/4990))
|
||||
- 改进 WeCom AI Bot 的流式消息处理逻辑,提升分段与流式回复稳定性 ([#5000](https://github.com/AstrBotDevs/AstrBot/issues/5000))
|
||||
- 稳定源码与 Electron 打包环境下的 pip 安装行为,并修复非 Electron 场景点击 WebUI 更新按钮时误触发跳转对话框的问题 ([#4996](https://github.com/AstrBotDevs/AstrBot/issues/4996))
|
||||
- 修复桌面端后端构建时 certifi 数据收集问题 ([#4995](https://github.com/AstrBotDevs/AstrBot/issues/4995))
|
||||
- 修复冻结运行时(frozen runtime)中的 pip install 执行问题 ([#4985](https://github.com/AstrBotDevs/AstrBot/issues/4985))
|
||||
- 为 Windows ARM64 通过 vcpkg 预置 OpenSSL,修复相关构建准备问题
|
||||
|
||||
### 优化
|
||||
- 更新 `pydantic` 依赖版本 ([#4980](https://github.com/AstrBotDevs/AstrBot/issues/4980))
|
||||
- 调整 GHCR namespace 的 CI 配置
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### New Features
|
||||
- Enhanced persona tool management and improved UI localization for subagent orchestration ([#4990](https://github.com/AstrBotDevs/AstrBot/issues/4990))
|
||||
- Added proactive message push for WeCom AI Bot, with support for video, file, and more message types ([#4999](https://github.com/AstrBotDevs/AstrBot/issues/4999))
|
||||
- Added proactive message push for WeCom app, and improved audio handling for WeCom app, WeChat Official Account, and WeCom customer service ([#4998](https://github.com/AstrBotDevs/AstrBot/issues/4998))
|
||||
- Enhanced Dingtalk adapter with proactive push and support for image, video, and audio message types ([#4986](https://github.com/AstrBotDevs/AstrBot/issues/4986))
|
||||
- Added a delete button to the persona management dialog for better usability ([#4978](https://github.com/AstrBotDevs/AstrBot/issues/4978))
|
||||
|
||||
### Fixes
|
||||
- Improved streaming message handling in WeCom AI Bot for better segmented and streaming reply stability ([#5000](https://github.com/AstrBotDevs/AstrBot/issues/5000))
|
||||
- Stabilized pip installation behavior in source and Electron packaged environments, and fixed the unexpected redirect dialog when clicking WebUI update in non-Electron mode ([#4996](https://github.com/AstrBotDevs/AstrBot/issues/4996))
|
||||
- Fixed certifi data collection in desktop backend build ([#4995](https://github.com/AstrBotDevs/AstrBot/issues/4995))
|
||||
- Fixed pip install execution in frozen runtime ([#4985](https://github.com/AstrBotDevs/AstrBot/issues/4985))
|
||||
- Prepared OpenSSL via vcpkg for Windows ARM64 build flow
|
||||
|
||||
### Improvements
|
||||
- Updated `pydantic` dependency version ([#4980](https://github.com/AstrBotDevs/AstrBot/issues/4980))
|
||||
- Updated CI configuration for GHCR namespace
|
||||
@@ -144,6 +144,7 @@
|
||||
import { ref } from 'vue';
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||
import type { Session } from '@/composables/useSessions';
|
||||
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog';
|
||||
import LanguageSwitcher from '@/components/shared/LanguageSwitcher.vue';
|
||||
import StyledMenu from '@/components/shared/StyledMenu.vue';
|
||||
import ProviderConfigDialog from '@/components/chat/ProviderConfigDialog.vue';
|
||||
@@ -183,6 +184,8 @@ const emit = defineEmits<{
|
||||
const { t } = useI18n();
|
||||
const { tm } = useModuleI18n('features/chat');
|
||||
|
||||
const confirmDialog = useConfirmDialog();
|
||||
|
||||
const sidebarCollapsed = ref(true);
|
||||
const showProviderConfigDialog = ref(false);
|
||||
|
||||
@@ -199,10 +202,10 @@ function toggleSidebar() {
|
||||
localStorage.setItem('sidebarCollapsed', JSON.stringify(sidebarCollapsed.value));
|
||||
}
|
||||
|
||||
function handleDeleteConversation(session: Session) {
|
||||
async function handleDeleteConversation(session: Session) {
|
||||
const sessionTitle = session.display_name || tm('conversation.newConversation');
|
||||
const message = tm('conversation.confirmDelete', { name: sessionTitle });
|
||||
if (window.confirm(message)) {
|
||||
if (await askForConfirmation(message, confirmDialog)) {
|
||||
emit('deleteConversation', session.session_id);
|
||||
}
|
||||
}
|
||||
@@ -359,4 +362,3 @@ function handleDeleteConversation(session: Session) {
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -42,8 +42,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog';
|
||||
|
||||
export interface Project {
|
||||
project_id: string;
|
||||
@@ -72,6 +73,8 @@ const emit = defineEmits<{
|
||||
|
||||
const { tm } = useModuleI18n('features/chat');
|
||||
|
||||
const confirmDialog = useConfirmDialog();
|
||||
|
||||
const expanded = ref(props.initialExpanded);
|
||||
|
||||
// 从 localStorage 读取项目展开状态
|
||||
@@ -85,9 +88,9 @@ function toggleExpanded() {
|
||||
localStorage.setItem('projectsExpanded', JSON.stringify(expanded.value));
|
||||
}
|
||||
|
||||
function handleDeleteProject(project: Project) {
|
||||
async function handleDeleteProject(project: Project) {
|
||||
const message = tm('project.confirmDelete', { title: project.title });
|
||||
if (window.confirm(message)) {
|
||||
if (await askForConfirmation(message, confirmDialog)) {
|
||||
emit('deleteProject', project.project_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import type { Project } from '@/components/chat/ProjectList.vue';
|
||||
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog';
|
||||
|
||||
interface Session {
|
||||
session_id: string;
|
||||
@@ -69,14 +70,16 @@ const emit = defineEmits<{
|
||||
|
||||
const { tm } = useModuleI18n('features/chat');
|
||||
|
||||
const confirmDialog = useConfirmDialog();
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleString();
|
||||
}
|
||||
|
||||
function handleDeleteSession(session: Session) {
|
||||
async function handleDeleteSession(session: Session) {
|
||||
const sessionTitle = session.display_name || tm('conversation.newConversation');
|
||||
const message = tm('conversation.confirmDelete', { name: sessionTitle });
|
||||
if (window.confirm(message)) {
|
||||
if (await askForConfirmation(message, confirmDialog)) {
|
||||
emit('deleteSession', session.session_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +218,10 @@ import axios from 'axios';
|
||||
import { VueMonacoEditor } from '@guolao/vue-monaco-editor';
|
||||
import ItemCard from '@/components/shared/ItemCard.vue';
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||
import {
|
||||
askForConfirmation as askForConfirmationDialog,
|
||||
useConfirmDialog
|
||||
} from '@/utils/confirmDialog';
|
||||
|
||||
export default {
|
||||
name: 'McpServersSection',
|
||||
@@ -228,7 +232,8 @@ export default {
|
||||
setup() {
|
||||
const { t } = useI18n();
|
||||
const { tm } = useModuleI18n('features/tooluse');
|
||||
return { t, tm };
|
||||
const confirmDialog = useConfirmDialog();
|
||||
return { t, tm, confirmDialog };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -382,18 +387,21 @@ export default {
|
||||
this.showError(this.tm('dialogs.addServer.errors.jsonParse', { error: e.message }));
|
||||
}
|
||||
},
|
||||
deleteServer(server) {
|
||||
async deleteServer(server) {
|
||||
const serverName = server.name || server;
|
||||
if (confirm(this.tm('dialogs.confirmDelete', { name: serverName }))) {
|
||||
axios.post('/api/tools/mcp/delete', { name: serverName })
|
||||
.then(response => {
|
||||
this.getServers();
|
||||
this.showSuccess(response.data.message || this.tm('messages.deleteSuccess'));
|
||||
})
|
||||
.catch(error => {
|
||||
this.showError(this.tm('messages.deleteError', { error: error.response?.data?.message || error.message }));
|
||||
});
|
||||
const message = this.tm('dialogs.confirmDelete', { name: serverName });
|
||||
if (!(await askForConfirmationDialog(message, this.confirmDialog))) {
|
||||
return;
|
||||
}
|
||||
|
||||
axios.post('/api/tools/mcp/delete', { name: serverName })
|
||||
.then(response => {
|
||||
this.getServers();
|
||||
this.showSuccess(response.data.message || this.tm('messages.deleteSuccess'));
|
||||
})
|
||||
.catch(error => {
|
||||
this.showError(this.tm('messages.deleteError', { error: error.response?.data?.message || error.message }));
|
||||
});
|
||||
},
|
||||
editServer(server) {
|
||||
const configCopy = { ...server };
|
||||
|
||||
@@ -370,10 +370,14 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { useI18n } from '@/i18n/composables'
|
||||
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog'
|
||||
import { restartAstrBot as restartAstrBotRuntime } from '@/utils/restartAstrBot'
|
||||
import WaitingForRestart from './WaitingForRestart.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const confirmDialog = useConfirmDialog()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const activeTab = ref('export')
|
||||
const wfr = ref(null)
|
||||
@@ -844,7 +848,7 @@ const restoreFromList = async (filename) => {
|
||||
|
||||
// 删除备份
|
||||
const deleteBackup = async (filename) => {
|
||||
if (!confirm(t('features.settings.backup.list.confirmDelete'))) return
|
||||
if (!(await askForConfirmation(t('features.settings.backup.list.confirmDelete'), confirmDialog))) return
|
||||
|
||||
try {
|
||||
const response = await axios.post('/api/backup/delete', { filename })
|
||||
@@ -945,12 +949,12 @@ const formatISODate = (isoString) => {
|
||||
}
|
||||
|
||||
// 重启 AstrBot
|
||||
const restartAstrBot = () => {
|
||||
axios.post('/api/stat/restart-core').then(() => {
|
||||
if (wfr.value) {
|
||||
wfr.value.check()
|
||||
}
|
||||
})
|
||||
const restartAstrBot = async () => {
|
||||
try {
|
||||
await restartAstrBotRuntime(wfr.value)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 重置所有状态
|
||||
@@ -992,4 +996,4 @@ defineExpose({ open })
|
||||
.non-interactive-chip:hover {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -269,8 +269,8 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
span.style = style + 'display: block; font-size: 12px; font-family: Consolas, monospace; white-space: pre-wrap; margin-bottom: 2px;'
|
||||
span.classList.add('fade-in')
|
||||
span.style = style
|
||||
span.classList.add('console-log-line', 'fade-in')
|
||||
span.innerText = `${log}`;
|
||||
ele.appendChild(span)
|
||||
if (this.autoScroll) {
|
||||
@@ -290,7 +290,15 @@ export default {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
:deep(.console-log-line) {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
font-family: SFMono-Regular, Menlo, Monaco, Consolas, var(--astrbot-font-cjk-mono), monospace;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
:deep(.fade-in) {
|
||||
animation: fadeIn 0.3s;
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +113,7 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { useI18n } from '@/i18n/composables'
|
||||
import { restartAstrBot as restartAstrBotRuntime } from '@/utils/restartAstrBot'
|
||||
import ConsoleDisplayer from './ConsoleDisplayer.vue'
|
||||
import WaitingForRestart from './WaitingForRestart.vue'
|
||||
|
||||
@@ -258,12 +259,12 @@ const getPlatformLabel = (platform) => {
|
||||
}
|
||||
|
||||
// 重启 AstrBot
|
||||
const restartAstrBot = () => {
|
||||
axios.post('/api/stat/restart-core').then(() => {
|
||||
if (wfr.value) {
|
||||
wfr.value.check();
|
||||
}
|
||||
})
|
||||
const restartAstrBot = async () => {
|
||||
try {
|
||||
await restartAstrBotRuntime(wfr.value)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开对话框的方法
|
||||
|
||||
@@ -289,6 +289,9 @@
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-btn v-if="editingPersona" color="error" variant="text" @click="deletePersona">
|
||||
{{ tm('buttons.delete') }}
|
||||
</v-btn>
|
||||
<v-spacer />
|
||||
<v-btn color="grey" variant="text" @click="closeDialog">
|
||||
{{ tm('buttons.cancel') }}
|
||||
@@ -304,6 +307,10 @@
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import {
|
||||
askForConfirmation as askForConfirmationDialog,
|
||||
useConfirmDialog
|
||||
} from '@/utils/confirmDialog';
|
||||
|
||||
export default {
|
||||
name: 'PersonaForm',
|
||||
@@ -325,10 +332,11 @@ export default {
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'saved', 'error'],
|
||||
emits: ['update:modelValue', 'saved', 'error', 'deleted'],
|
||||
setup() {
|
||||
const { tm } = useModuleI18n('features/persona');
|
||||
return { tm };
|
||||
const confirmDialog = useConfirmDialog();
|
||||
return { tm, confirmDialog };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -591,6 +599,37 @@ export default {
|
||||
this.saving = false;
|
||||
},
|
||||
|
||||
async deletePersona() {
|
||||
if (!this.editingPersona) return;
|
||||
|
||||
if (
|
||||
!(await askForConfirmationDialog(
|
||||
this.tm('messages.deleteConfirm', { id: this.editingPersona.persona_id }),
|
||||
this.confirmDialog,
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.saving = true;
|
||||
try {
|
||||
const response = await axios.post('/api/persona/delete', {
|
||||
persona_id: this.editingPersona.persona_id
|
||||
});
|
||||
|
||||
if (response.data.status === 'ok') {
|
||||
this.$emit('deleted', response.data.message || this.tm('messages.deleteSuccess'));
|
||||
this.closeDialog();
|
||||
} else {
|
||||
this.$emit('error', response.data.message || this.tm('messages.deleteError'));
|
||||
}
|
||||
} catch (error) {
|
||||
this.$emit('error', error.response?.data?.message || this.tm('messages.deleteError'));
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
addDialogPair() {
|
||||
this.personaForm.begin_dialogs.push('', '');
|
||||
// 自动展开预设对话面板
|
||||
|
||||
@@ -31,22 +31,31 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async check() {
|
||||
async check(initialStartTime = null) {
|
||||
this.newStartTime = -1
|
||||
this.cnt = 0
|
||||
this.visible = true
|
||||
this.status = ""
|
||||
const commonStore = useCommonStore()
|
||||
try {
|
||||
this.startTime = await commonStore.fetchStartTime()
|
||||
} catch (_error) {
|
||||
this.startTime = commonStore.getStartTime()
|
||||
if (typeof initialStartTime === 'number' && Number.isFinite(initialStartTime)) {
|
||||
this.startTime = initialStartTime
|
||||
} else {
|
||||
const commonStore = useCommonStore()
|
||||
try {
|
||||
this.startTime = await commonStore.fetchStartTime()
|
||||
} catch (_error) {
|
||||
this.startTime = commonStore.getStartTime()
|
||||
}
|
||||
}
|
||||
console.log('start wfr')
|
||||
setTimeout(() => {
|
||||
this.timeoutInternal()
|
||||
}, 1000)
|
||||
},
|
||||
stop() {
|
||||
this.visible = false
|
||||
this.cnt = 0
|
||||
this.newStartTime = -1
|
||||
},
|
||||
timeoutInternal() {
|
||||
console.log('wfr: timeoutInternal', this.newStartTime, this.startTime)
|
||||
if (this.newStartTime === -1 && this.cnt < 60 && this.visible) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { getProviderIcon } from '@/utils/providerUtils'
|
||||
import { askForConfirmation as askForConfirmationDialog, useConfirmDialog } from '@/utils/confirmDialog'
|
||||
|
||||
export interface UseProviderSourcesOptions {
|
||||
defaultTab?: string
|
||||
@@ -37,6 +38,12 @@ export function resolveDefaultTab(value?: string) {
|
||||
export function useProviderSources(options: UseProviderSourcesOptions) {
|
||||
const { tm, showMessage } = options
|
||||
|
||||
const confirmDialog = useConfirmDialog()
|
||||
|
||||
async function askForConfirmation(message: string) {
|
||||
return askForConfirmationDialog(message, confirmDialog)
|
||||
}
|
||||
|
||||
// ===== State =====
|
||||
const config = ref<Record<string, any>>({})
|
||||
const metadata = ref<Record<string, any>>({})
|
||||
@@ -396,7 +403,10 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
|
||||
}
|
||||
|
||||
async function deleteProviderSource(source: any) {
|
||||
if (!confirm(tm('providerSources.deleteConfirm', { id: source.id }))) return
|
||||
const confirmed = await askForConfirmation(
|
||||
tm('providerSources.deleteConfirm', { id: source.id })
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
await axios.post('/api/config/provider_sources/delete', { id: source.id })
|
||||
@@ -558,7 +568,8 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
|
||||
}
|
||||
|
||||
async function deleteProvider(provider: any) {
|
||||
if (!confirm(tm('models.deleteConfirm', { id: provider.id }))) return
|
||||
const confirmed = await askForConfirmation(tm('models.deleteConfirm', { id: provider.id }))
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
await axios.post('/api/config/provider/delete', { id: provider.id })
|
||||
|
||||
@@ -501,7 +501,7 @@
|
||||
},
|
||||
"wecomaibot_init_respond_text": {
|
||||
"description": "WeCom AI Bot Initial Response Text",
|
||||
"hint": "First reply when the bot receives a message. Leave empty to use default."
|
||||
"hint": "First reply when the bot receives a message. Leave empty to disable."
|
||||
},
|
||||
"wpp_active_message_poll": {
|
||||
"description": "Enable Proactive Message Polling",
|
||||
@@ -521,6 +521,14 @@
|
||||
"ws_reverse_token": {
|
||||
"description": "Reverse WebSocket Token",
|
||||
"hint": "Reverse WebSocket token. If not set, token verification is disabled."
|
||||
},
|
||||
"msg_push_webhook_url": {
|
||||
"description": "WeCom Message Push Webhook URL",
|
||||
"hint": "Used for proactive message push. It is strongly recommended to set this for a better message sending experience."
|
||||
},
|
||||
"only_use_webhook_url_to_send": {
|
||||
"description": "Send Replies via Webhook Only",
|
||||
"hint": "When enabled, all WeCom AI Bot replies are sent through msg_push_webhook_url. The message push webhook supports more message types (such as images, files, etc.). If you do not need the typing effect, it is strongly recommended to use this option. "
|
||||
}
|
||||
},
|
||||
"general": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"page": {
|
||||
"title": "SubAgent Orchestration",
|
||||
"beta": "Experimental",
|
||||
"subtitle": "The main LLM only chats and delegates; tools live on individual SubAgents."
|
||||
"subtitle": "The main LLM can use its own tools directly and delegate tasks to SubAgents via handoff."
|
||||
},
|
||||
"actions": {
|
||||
"refresh": "Refresh",
|
||||
|
||||
@@ -350,7 +350,7 @@
|
||||
},
|
||||
"kf_name": {
|
||||
"description": "微信客服账号名",
|
||||
"hint": "可选。微信客服账号名(不是 ID)。可在 https://kf.weixin.qq.com/kf/frame#/accounts 获取"
|
||||
"hint": "如果填写此项,即代表你将使用企业微信客服,而不是企业微信应用。可在 https://kf.weixin.qq.com/kf/frame#/accounts 获取。"
|
||||
},
|
||||
"lark_bot_name": {
|
||||
"description": "飞书机器人的名字",
|
||||
@@ -500,11 +500,11 @@
|
||||
},
|
||||
"wecomaibot_friend_message_welcome_text": {
|
||||
"description": "企业微信智能机器人私聊欢迎语",
|
||||
"hint": "当用户当天进入智能机器人单聊会话,回复欢迎语,留空则不回复。"
|
||||
"hint": "当用户当天进入智能机器人单聊会话,回复欢迎语,如 “💭 思考中...”。留空则不回复。"
|
||||
},
|
||||
"wecomaibot_init_respond_text": {
|
||||
"description": "企业微信智能机器人初始响应文本",
|
||||
"hint": "当机器人收到消息时,首先回复的文本内容。留空则使用默认值。"
|
||||
"hint": "当机器人收到消息时,首先回复的文本内容。留空则不设置。"
|
||||
},
|
||||
"wpp_active_message_poll": {
|
||||
"description": "是否启用主动消息轮询",
|
||||
@@ -524,6 +524,14 @@
|
||||
"ws_reverse_token": {
|
||||
"description": "反向 Websocket Token",
|
||||
"hint": "反向 Websocket Token。未设置则不启用 Token 验证。"
|
||||
},
|
||||
"msg_push_webhook_url": {
|
||||
"description": "企业微信消息推送 Webhook URL",
|
||||
"hint": "用于主动消息推送,请在企微群->消息推送得到 URL。强烈建议设置此项以带来更好的消息发送体验。"
|
||||
},
|
||||
"only_use_webhook_url_to_send": {
|
||||
"description": "仅使用 Webhook 发送消息",
|
||||
"hint": "启用后,企业微信智能机器人的所有回复都改为通过消息推送 Webhook 发送。消息推送 Webhook 支持更多的消息类型(如图片、文件等)。如果不需要打字机效果,强烈建议使用此选项。"
|
||||
}
|
||||
},
|
||||
"general": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"page": {
|
||||
"title": "SubAgent 编排",
|
||||
"beta": "实验性",
|
||||
"subtitle": "主 LLM 只负责聊天与分派(handoff),工具挂载在各个 SubAgent 上。"
|
||||
"subtitle": "主 LLM 可直接使用自身工具,也可通过 handoff 分派给各个 SubAgent。"
|
||||
},
|
||||
"actions": {
|
||||
"refresh": "刷新",
|
||||
|
||||
@@ -45,7 +45,9 @@ let version = ref('');
|
||||
let releases = ref([]);
|
||||
let updatingDashboardLoading = ref(false);
|
||||
let installLoading = ref(false);
|
||||
const isElectronApp = ref(false);
|
||||
const isElectronApp = ref(
|
||||
typeof window !== 'undefined' && !!window.astrbotDesktop?.isElectron
|
||||
);
|
||||
const redirectConfirmDialog = ref(false);
|
||||
const pendingRedirectUrl = ref('');
|
||||
const resolvingReleaseTarget = ref(false);
|
||||
@@ -235,7 +237,9 @@ function checkUpdate() {
|
||||
} else {
|
||||
updateStatus.value = res.data.message;
|
||||
}
|
||||
dashboardHasNewVersion.value = res.data.data.dashboard_has_new_version;
|
||||
dashboardHasNewVersion.value = isElectronApp.value
|
||||
? false
|
||||
: res.data.data.dashboard_has_new_version;
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.response && err.response.status == 401) {
|
||||
@@ -381,7 +385,9 @@ onMounted(async () => {
|
||||
} catch {
|
||||
isElectronApp.value = false;
|
||||
}
|
||||
isElectronApp.value = true
|
||||
if (isElectronApp.value) {
|
||||
dashboardHasNewVersion.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -426,7 +432,7 @@ onMounted(async () => {
|
||||
<small v-if="hasNewVersion">
|
||||
{{ t('core.header.version.hasNewVersion') }}
|
||||
</small>
|
||||
<small v-else-if="dashboardHasNewVersion">
|
||||
<small v-else-if="dashboardHasNewVersion && !isElectronApp">
|
||||
{{ t('core.header.version.dashboardHasNewVersion') }}
|
||||
</small>
|
||||
</div>
|
||||
@@ -509,7 +515,7 @@ onMounted(async () => {
|
||||
<v-icon>mdi-arrow-up-circle</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ t('core.header.updateDialog.title') }}</v-list-item-title>
|
||||
<template v-slot:append v-if="hasNewVersion || dashboardHasNewVersion">
|
||||
<template v-slot:append v-if="hasNewVersion || (dashboardHasNewVersion && !isElectronApp)">
|
||||
<v-chip size="x-small" color="primary" variant="tonal" class="ml-2">!</v-chip>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
@@ -9,7 +9,15 @@ $color-pack: false;
|
||||
// Global font size and border radius
|
||||
$font-size-root: 1rem;
|
||||
$border-radius-root: 8px;
|
||||
$body-font-family: 'Roboto', sans-serif !default;
|
||||
$cjk-sans-fallback: 'PingFang SC', 'Hiragino Sans GB', 'Noto Sans CJK SC', 'Microsoft YaHei' !default;
|
||||
$cjk-mono-fallback: 'PingFang SC', 'PingFang TC', 'Hiragino Sans GB', 'Noto Sans CJK SC', 'Microsoft YaHei' !default;
|
||||
|
||||
:root {
|
||||
--astrbot-font-cjk-sans: #{$cjk-sans-fallback};
|
||||
--astrbot-font-cjk-mono: #{$cjk-mono-fallback};
|
||||
}
|
||||
|
||||
$body-font-family: 'Roboto', $cjk-sans-fallback, sans-serif !default;
|
||||
$heading-font-family: $body-font-family !default;
|
||||
$btn-font-weight: 400 !default;
|
||||
$btn-letter-spacing: 0 !default;
|
||||
|
||||
@@ -85,15 +85,15 @@ $sizes: (
|
||||
|
||||
body {
|
||||
.Poppins {
|
||||
font-family: 'Poppins', sans-serif !important;
|
||||
font-family: 'Poppins', $cjk-sans-fallback, sans-serif !important;
|
||||
}
|
||||
|
||||
.Inter {
|
||||
font-family: 'Inter', sans-serif !important;
|
||||
font-family: 'Inter', $cjk-sans-fallback, sans-serif !important;
|
||||
}
|
||||
|
||||
.Outfit {
|
||||
font-family: 'Outfit', sans-serif !important;
|
||||
font-family: 'Outfit', $cjk-sans-fallback, sans-serif !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
import 'vue'
|
||||
|
||||
import type { ConfirmDialogHandler } from '@/utils/confirmDialog'
|
||||
|
||||
declare module 'vue' {
|
||||
interface ComponentCustomProperties {
|
||||
$confirm?: ConfirmDialogHandler
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
+1
-1
@@ -11,7 +11,7 @@ declare global {
|
||||
restarting: boolean;
|
||||
canManage: boolean;
|
||||
}>;
|
||||
restartBackend: () => Promise<{
|
||||
restartBackend: (authToken?: string | null) => Promise<{
|
||||
ok: boolean;
|
||||
reason: string | null;
|
||||
}>;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { inject } from 'vue'
|
||||
|
||||
export type ConfirmDialogOptions = {
|
||||
title?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export type ConfirmDialogHandler = (options: ConfirmDialogOptions) => Promise<boolean>
|
||||
|
||||
export type ConfirmDialogCandidate = ConfirmDialogHandler | null | undefined
|
||||
|
||||
export function useConfirmDialog(): ConfirmDialogHandler | undefined {
|
||||
return inject<ConfirmDialogHandler | undefined>('$confirm', undefined)
|
||||
}
|
||||
|
||||
export async function askForConfirmation(
|
||||
message: string,
|
||||
candidate?: ConfirmDialogCandidate
|
||||
): Promise<boolean> {
|
||||
const confirmDialog = candidate ?? undefined
|
||||
|
||||
if (confirmDialog) {
|
||||
try {
|
||||
return await confirmDialog({ message })
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return window.confirm(message)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import axios from 'axios'
|
||||
|
||||
type WaitingForRestartRef = {
|
||||
check: (initialStartTime?: number | null) => void | Promise<void>
|
||||
stop?: () => void
|
||||
}
|
||||
|
||||
async function triggerWaiting(
|
||||
waitingRef?: WaitingForRestartRef | null,
|
||||
initialStartTime?: number | null
|
||||
) {
|
||||
if (!waitingRef) return
|
||||
await waitingRef.check(initialStartTime)
|
||||
}
|
||||
|
||||
async function fetchCurrentStartTime(): Promise<number | null> {
|
||||
try {
|
||||
const response = await axios.get('/api/stat/start-time', { timeout: 1500 })
|
||||
const rawStartTime = response?.data?.data?.start_time
|
||||
const numericStartTime = Number(rawStartTime)
|
||||
return Number.isFinite(numericStartTime) ? numericStartTime : null
|
||||
} catch (_error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function restartAstrBot(
|
||||
waitingRef?: WaitingForRestartRef | null
|
||||
): Promise<void> {
|
||||
const desktopBridge = window.astrbotDesktop
|
||||
|
||||
if (desktopBridge?.isElectron) {
|
||||
const authToken = localStorage.getItem('token')
|
||||
const initialStartTime = await fetchCurrentStartTime()
|
||||
try {
|
||||
const restartPromise = desktopBridge.restartBackend(authToken)
|
||||
await triggerWaiting(waitingRef, initialStartTime)
|
||||
const result = await restartPromise
|
||||
if (!result.ok) {
|
||||
waitingRef?.stop?.()
|
||||
throw new Error(result.reason || 'Failed to restart backend.')
|
||||
}
|
||||
} catch (error) {
|
||||
waitingRef?.stop?.()
|
||||
throw error
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
await axios.post('/api/stat/restart-core')
|
||||
await triggerWaiting(waitingRef)
|
||||
}
|
||||
@@ -190,6 +190,11 @@ import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
|
||||
import StandaloneChat from '@/components/chat/StandaloneChat.vue';
|
||||
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||
import { restartAstrBot as restartAstrBotRuntime } from '@/utils/restartAstrBot';
|
||||
import {
|
||||
askForConfirmation as askForConfirmationDialog,
|
||||
useConfirmDialog
|
||||
} from '@/utils/confirmDialog';
|
||||
|
||||
export default {
|
||||
name: 'ConfigPage',
|
||||
@@ -208,10 +213,12 @@ export default {
|
||||
setup() {
|
||||
const { t } = useI18n();
|
||||
const { tm } = useModuleI18n('features/config');
|
||||
const confirmDialog = useConfirmDialog();
|
||||
|
||||
return {
|
||||
t,
|
||||
tm
|
||||
tm,
|
||||
confirmDialog
|
||||
};
|
||||
},
|
||||
|
||||
@@ -369,9 +376,7 @@ export default {
|
||||
this.save_message_success = "success";
|
||||
|
||||
if (this.isSystemConfig) {
|
||||
axios.post('/api/stat/restart-core').then(() => {
|
||||
this.$refs.wfr.check();
|
||||
})
|
||||
restartAstrBotRuntime(this.$refs.wfr).catch(() => {})
|
||||
}
|
||||
} else {
|
||||
this.save_message = res.data.message || this.messages.saveError;
|
||||
@@ -473,8 +478,9 @@ export default {
|
||||
this.createNewConfig();
|
||||
}
|
||||
},
|
||||
confirmDeleteConfig(config) {
|
||||
if (confirm(this.tm('configManagement.confirmDelete').replace('{name}', config.name))) {
|
||||
async confirmDeleteConfig(config) {
|
||||
const message = this.tm('configManagement.confirmDelete').replace('{name}', config.name);
|
||||
if (await askForConfirmationDialog(message, this.confirmDialog)) {
|
||||
this.deleteConfig(config.id);
|
||||
}
|
||||
},
|
||||
@@ -658,4 +664,4 @@ export default {
|
||||
padding: 0;
|
||||
border-radius: 0 0 16px 16px;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -333,6 +333,10 @@ import { useCommonStore } from '@/stores/common';
|
||||
import { useCustomizerStore } from '@/stores/customizer';
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||
import MessageList from '@/components/chat/MessageList.vue';
|
||||
import {
|
||||
askForConfirmation as askForConfirmationDialog,
|
||||
useConfirmDialog
|
||||
} from '@/utils/confirmDialog';
|
||||
|
||||
export default {
|
||||
name: 'ConversationPage',
|
||||
@@ -345,12 +349,14 @@ export default {
|
||||
const { t, locale } = useI18n();
|
||||
const { tm } = useModuleI18n('features/conversation');
|
||||
const customizerStore = useCustomizerStore();
|
||||
const confirmDialog = useConfirmDialog();
|
||||
|
||||
return {
|
||||
t,
|
||||
tm,
|
||||
locale,
|
||||
customizerStore
|
||||
customizerStore,
|
||||
confirmDialog
|
||||
};
|
||||
},
|
||||
|
||||
@@ -744,9 +750,9 @@ export default {
|
||||
},
|
||||
|
||||
// 关闭对话历史对话框
|
||||
closeHistoryDialog() {
|
||||
async closeHistoryDialog() {
|
||||
if (this.isEditingHistory) {
|
||||
if (confirm(this.tm('dialogs.view.confirmClose'))) {
|
||||
if (await askForConfirmationDialog(this.tm('dialogs.view.confirmClose'), this.confirmDialog)) {
|
||||
this.dialogView = false;
|
||||
}
|
||||
} else {
|
||||
@@ -1133,4 +1139,4 @@ export default {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -197,6 +197,10 @@ import AddNewPlatform from '@/components/platform/AddNewPlatform.vue';
|
||||
import { useCommonStore } from '@/stores/common';
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||
import { getPlatformIcon, getTutorialLink } from '@/utils/platformUtils';
|
||||
import {
|
||||
askForConfirmation as askForConfirmationDialog,
|
||||
useConfirmDialog
|
||||
} from '@/utils/confirmDialog';
|
||||
|
||||
export default {
|
||||
name: 'PlatformPage',
|
||||
@@ -210,10 +214,12 @@ export default {
|
||||
setup() {
|
||||
const { t } = useI18n();
|
||||
const { tm } = useModuleI18n('features/platform');
|
||||
const confirmDialog = useConfirmDialog();
|
||||
|
||||
return {
|
||||
t,
|
||||
tm
|
||||
tm,
|
||||
confirmDialog
|
||||
};
|
||||
},
|
||||
data() {
|
||||
@@ -351,8 +357,99 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
findPlatformTemplate(platform) {
|
||||
const templates = this.metadata?.platform_group?.metadata?.platform?.config_template || {};
|
||||
|
||||
if (platform?.type && templates[platform.type]) {
|
||||
return templates[platform.type];
|
||||
}
|
||||
if (platform?.id && templates[platform.id]) {
|
||||
return templates[platform.id];
|
||||
}
|
||||
|
||||
for (const template of Object.values(templates)) {
|
||||
if (template?.type === platform?.type) {
|
||||
return template;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
mergeConfigWithTemplate(sourceConfig, templateConfig) {
|
||||
const merge = (source, reference) => {
|
||||
const target = {};
|
||||
const sourceObj = source && typeof source === 'object' && !Array.isArray(source) ? source : {};
|
||||
const referenceObj = reference && typeof reference === 'object' && !Array.isArray(reference) ? reference : null;
|
||||
|
||||
if (!referenceObj) {
|
||||
for (const [key, value] of Object.entries(sourceObj)) {
|
||||
if (Array.isArray(value)) {
|
||||
target[key] = [...value];
|
||||
} else if (value && typeof value === 'object') {
|
||||
target[key] = { ...value };
|
||||
} else {
|
||||
target[key] = value;
|
||||
}
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
// 1) 先按模板顺序写入,保证字段相对顺序与 template 一致
|
||||
for (const [key, refValue] of Object.entries(referenceObj)) {
|
||||
const hasSourceKey = Object.prototype.hasOwnProperty.call(sourceObj, key);
|
||||
const sourceValue = sourceObj[key];
|
||||
|
||||
if (refValue && typeof refValue === 'object' && !Array.isArray(refValue)) {
|
||||
target[key] = merge(
|
||||
hasSourceKey && sourceValue && typeof sourceValue === 'object' && !Array.isArray(sourceValue)
|
||||
? sourceValue
|
||||
: {},
|
||||
refValue
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hasSourceKey) {
|
||||
if (Array.isArray(sourceValue)) {
|
||||
target[key] = [...sourceValue];
|
||||
} else if (sourceValue && typeof sourceValue === 'object') {
|
||||
target[key] = { ...sourceValue };
|
||||
} else {
|
||||
target[key] = sourceValue;
|
||||
}
|
||||
} else if (Array.isArray(refValue)) {
|
||||
target[key] = [...refValue];
|
||||
} else {
|
||||
target[key] = refValue;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 再补充 source 中模板没有的额外字段,保持旧配置兼容性
|
||||
for (const [key, value] of Object.entries(sourceObj)) {
|
||||
if (Object.prototype.hasOwnProperty.call(referenceObj, key)) {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
target[key] = [...value];
|
||||
} else if (value && typeof value === 'object') {
|
||||
target[key] = { ...value };
|
||||
} else {
|
||||
target[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return target;
|
||||
};
|
||||
|
||||
return merge(sourceConfig, templateConfig);
|
||||
},
|
||||
|
||||
editPlatform(platform) {
|
||||
this.updatingPlatformConfig = JSON.parse(JSON.stringify(platform));
|
||||
const platformCopy = JSON.parse(JSON.stringify(platform));
|
||||
const template = this.findPlatformTemplate(platformCopy);
|
||||
this.updatingPlatformConfig = template
|
||||
? this.mergeConfigWithTemplate(platformCopy, template)
|
||||
: platformCopy;
|
||||
this.updatingMode = true;
|
||||
this.showAddPlatformDialog = true;
|
||||
this.$nextTick(() => {
|
||||
@@ -360,15 +457,18 @@ export default {
|
||||
});
|
||||
},
|
||||
|
||||
deletePlatform(platform) {
|
||||
if (confirm(`${this.messages.deleteConfirm} ${platform.id}?`)) {
|
||||
axios.post('/api/config/platform/delete', { id: platform.id }).then((res) => {
|
||||
this.getConfig();
|
||||
this.showSuccess(res.data.message || this.messages.deleteSuccess);
|
||||
}).catch((err) => {
|
||||
this.showError(err.response?.data?.message || err.message);
|
||||
});
|
||||
async deletePlatform(platform) {
|
||||
const message = `${this.messages.deleteConfirm} ${platform.id}?`;
|
||||
if (!(await askForConfirmationDialog(message, this.confirmDialog))) {
|
||||
return;
|
||||
}
|
||||
|
||||
axios.post('/api/config/platform/delete', { id: platform.id }).then((res) => {
|
||||
this.getConfig();
|
||||
this.showSuccess(res.data.message || this.messages.deleteSuccess);
|
||||
}).catch((err) => {
|
||||
this.showError(err.response?.data?.message || err.message);
|
||||
});
|
||||
},
|
||||
|
||||
platformStatusChange(platform) {
|
||||
|
||||
@@ -522,16 +522,22 @@
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables'
|
||||
import {
|
||||
askForConfirmation as askForConfirmationDialog,
|
||||
useConfirmDialog
|
||||
} from '@/utils/confirmDialog'
|
||||
|
||||
export default {
|
||||
name: 'SessionManagementPage',
|
||||
setup() {
|
||||
const { t } = useI18n()
|
||||
const { tm } = useModuleI18n('features/session-management')
|
||||
const confirmDialog = useConfirmDialog()
|
||||
|
||||
return {
|
||||
t,
|
||||
tm
|
||||
tm,
|
||||
confirmDialog
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -1503,7 +1509,8 @@ export default {
|
||||
},
|
||||
|
||||
async deleteGroup(group) {
|
||||
if (!confirm(`确定要删除分组 "${group.name}" 吗?`)) return
|
||||
const message = `确定要删除分组 "${group.name}" 吗?`
|
||||
if (!(await askForConfirmationDialog(message, this.confirmDialog))) return
|
||||
|
||||
try {
|
||||
const response = await axios.post('/api/session/group/delete', { id: group.id })
|
||||
|
||||
@@ -78,12 +78,12 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
|
||||
import ProxySelector from '@/components/shared/ProxySelector.vue';
|
||||
import MigrationDialog from '@/components/shared/MigrationDialog.vue';
|
||||
import SidebarCustomizer from '@/components/shared/SidebarCustomizer.vue';
|
||||
import BackupDialog from '@/components/shared/BackupDialog.vue';
|
||||
import { restartAstrBot as restartAstrBotRuntime } from '@/utils/restartAstrBot';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import { useTheme } from 'vuetify';
|
||||
import { PurpleTheme } from '@/theme/LightTheme';
|
||||
@@ -136,10 +136,12 @@ const wfr = ref(null);
|
||||
const migrationDialog = ref(null);
|
||||
const backupDialog = ref(null);
|
||||
|
||||
const restartAstrBot = () => {
|
||||
axios.post('/api/stat/restart-core').then(() => {
|
||||
wfr.value.check();
|
||||
})
|
||||
const restartAstrBot = async () => {
|
||||
try {
|
||||
await restartAstrBotRuntime(wfr.value);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
const startMigration = async () => {
|
||||
|
||||
@@ -142,17 +142,6 @@
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="text-caption text-medium-emphasis">{{ tm('cards.previewTitle') }}</div>
|
||||
<div class="d-flex align-center" style="gap: 8px; flex-wrap: wrap;">
|
||||
<v-chip size="small" variant="outlined" color="primary">
|
||||
{{ tm('cards.transferPrefix', { name: agent.name || '...' }) }}
|
||||
</v-chip>
|
||||
<v-chip size="small" variant="tonal" color="secondary" v-if="agent.persona_id">
|
||||
{{ tm('cards.personaChip', { id: agent.persona_id }) }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
|
||||
@@ -244,10 +244,13 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import axios from 'axios'
|
||||
import { useModuleI18n } from '@/i18n/composables'
|
||||
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog'
|
||||
|
||||
const { tm: t } = useModuleI18n('features/knowledge-base/document')
|
||||
const route = useRoute()
|
||||
|
||||
const confirmDialog = useConfirmDialog()
|
||||
|
||||
const kbId = ref(route.params.kbId as string)
|
||||
const docId = ref(route.params.docId as string)
|
||||
|
||||
@@ -356,7 +359,7 @@ const viewChunk = (chunk: any) => {
|
||||
|
||||
// 删除分块
|
||||
const deleteChunk = async (chunk: any) => {
|
||||
if (!confirm(t('chunks.deleteConfirm'))) return
|
||||
if (!(await askForConfirmation(t('chunks.deleteConfirm'), confirmDialog))) return
|
||||
try {
|
||||
const response = await axios.post('/api/kb/chunk/delete', {
|
||||
chunk_id: chunk.chunk_id,
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
<!-- 创建/编辑 Persona 对话框 -->
|
||||
<PersonaForm v-model="showPersonaDialog" :editing-persona="editingPersona ?? undefined"
|
||||
:current-folder-id="currentFolderId ?? undefined" :current-folder-name="currentFolderName ?? undefined"
|
||||
@saved="handlePersonaSaved" @error="showError" />
|
||||
@saved="handlePersonaSaved" @deleted="handlePersonaDeleted" @error="showError" />
|
||||
|
||||
<!-- 查看 Persona 详情对话框 -->
|
||||
<v-dialog v-model="showViewDialog" max-width="700px">
|
||||
@@ -260,6 +260,10 @@ import PersonaCard from './PersonaCard.vue';
|
||||
import PersonaForm from '@/components/shared/PersonaForm.vue';
|
||||
import CreateFolderDialog from './CreateFolderDialog.vue';
|
||||
import MoveToFolderDialog from './MoveToFolderDialog.vue';
|
||||
import {
|
||||
askForConfirmation as askForConfirmationDialog,
|
||||
useConfirmDialog
|
||||
} from '@/utils/confirmDialog';
|
||||
|
||||
import type { Folder, FolderTreeNode } from '@/components/folder/types';
|
||||
|
||||
@@ -294,7 +298,8 @@ export default defineComponent({
|
||||
setup() {
|
||||
const { t } = useI18n();
|
||||
const { tm } = useModuleI18n('features/persona');
|
||||
return { t, tm };
|
||||
const confirmDialog = useConfirmDialog();
|
||||
return { t, tm, confirmDialog };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -414,8 +419,18 @@ export default defineComponent({
|
||||
this.refreshCurrentFolder();
|
||||
},
|
||||
|
||||
handlePersonaDeleted(message: string) {
|
||||
this.showSuccess(message);
|
||||
this.refreshCurrentFolder();
|
||||
},
|
||||
|
||||
async confirmDeletePersona(persona: Persona) {
|
||||
if (!confirm(this.tm('messages.deleteConfirm', { id: persona.persona_id }))) {
|
||||
if (
|
||||
!(await askForConfirmationDialog(
|
||||
this.tm('messages.deleteConfirm', { id: persona.persona_id }),
|
||||
this.confirmDialog,
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+320
-32
@@ -7,6 +7,7 @@ const { spawn, spawnSync } = require('child_process');
|
||||
const { delay, ensureDir, normalizeUrl, waitForProcessExit } = require('./common');
|
||||
|
||||
const PACKAGED_BACKEND_TIMEOUT_FALLBACK_MS = 5 * 60 * 1000;
|
||||
const GRACEFUL_RESTART_WAIT_FALLBACK_MS = 20 * 1000;
|
||||
|
||||
function parseBackendTimeoutMs(app) {
|
||||
const defaultTimeoutMs = app.isPackaged ? 0 : 20000;
|
||||
@@ -177,6 +178,19 @@ class BackendManager {
|
||||
return this.backendConfig;
|
||||
}
|
||||
|
||||
getBackendPort() {
|
||||
try {
|
||||
const parsed = new URL(this.backendUrl);
|
||||
if (parsed.port) {
|
||||
const port = Number.parseInt(parsed.port, 10);
|
||||
return Number.isFinite(port) ? port : null;
|
||||
}
|
||||
return parsed.protocol === 'https:' ? 443 : 80;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
canManageBackend() {
|
||||
return Boolean(this.getBackendConfig().cmd);
|
||||
}
|
||||
@@ -207,13 +221,117 @@ class BackendManager {
|
||||
}
|
||||
}
|
||||
|
||||
getEffectiveWaitMs(maxWaitMs = 0) {
|
||||
if (maxWaitMs > 0) {
|
||||
return maxWaitMs;
|
||||
}
|
||||
if (this.app.isPackaged) {
|
||||
return PACKAGED_BACKEND_TIMEOUT_FALLBACK_MS;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async requestBackendJson(pathname, options = {}) {
|
||||
const timeoutMs = options.timeoutMs || 2000;
|
||||
const method = options.method || 'GET';
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const requestUrl = new URL(pathname, this.backendUrl);
|
||||
requestUrl.searchParams.set('_ts', `${Date.now()}`);
|
||||
|
||||
const authToken =
|
||||
typeof options.authToken === 'string' && options.authToken
|
||||
? options.authToken
|
||||
: null;
|
||||
|
||||
try {
|
||||
const response = await fetch(requestUrl.toString(), {
|
||||
method,
|
||||
signal: controller.signal,
|
||||
redirect: 'manual',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
return { ok: false, data: null };
|
||||
}
|
||||
const data = await response.json();
|
||||
return { ok: true, data };
|
||||
} catch {
|
||||
return { ok: false, data: null };
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async getBackendStartTime() {
|
||||
const result = await this.requestBackendJson('/api/stat/start-time', {
|
||||
timeoutMs: 1800,
|
||||
method: 'GET',
|
||||
});
|
||||
if (!result.ok || !result.data) {
|
||||
return null;
|
||||
}
|
||||
const rawStartTime = result.data?.data?.start_time;
|
||||
const numericStartTime = Number(rawStartTime);
|
||||
return Number.isFinite(numericStartTime) ? numericStartTime : null;
|
||||
}
|
||||
|
||||
async requestGracefulRestart(authToken = null) {
|
||||
const result = await this.requestBackendJson('/api/stat/restart-core', {
|
||||
timeoutMs: 2500,
|
||||
method: 'POST',
|
||||
authToken,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
return result.ok;
|
||||
}
|
||||
|
||||
async waitForGracefulRestart(previousStartTime, maxWaitMs = 0) {
|
||||
const effectiveMaxWaitMs = this.getEffectiveWaitMs(maxWaitMs);
|
||||
const gracefulWaitMs =
|
||||
effectiveMaxWaitMs > 0
|
||||
? effectiveMaxWaitMs
|
||||
: GRACEFUL_RESTART_WAIT_FALLBACK_MS;
|
||||
const start = Date.now();
|
||||
let sawBackendDown = false;
|
||||
|
||||
while (true) {
|
||||
const reachable = await this.pingBackend(700);
|
||||
if (!reachable) {
|
||||
sawBackendDown = true;
|
||||
} else {
|
||||
const currentStartTime = await this.getBackendStartTime();
|
||||
if (
|
||||
previousStartTime !== null &&
|
||||
currentStartTime !== null &&
|
||||
currentStartTime !== previousStartTime
|
||||
) {
|
||||
return { ok: true, reason: null };
|
||||
}
|
||||
if (sawBackendDown && previousStartTime === null) {
|
||||
return { ok: true, reason: null };
|
||||
}
|
||||
}
|
||||
|
||||
if (Date.now() - start >= gracefulWaitMs) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Timed out after ${gracefulWaitMs}ms waiting for graceful restart.`,
|
||||
};
|
||||
}
|
||||
|
||||
await delay(350);
|
||||
}
|
||||
}
|
||||
|
||||
async waitForBackend(maxWaitMs = 0, failOnProcessExit = false) {
|
||||
const effectiveMaxWaitMs =
|
||||
maxWaitMs > 0
|
||||
? maxWaitMs
|
||||
: this.app.isPackaged
|
||||
? PACKAGED_BACKEND_TIMEOUT_FALLBACK_MS
|
||||
: 0;
|
||||
const effectiveMaxWaitMs = this.getEffectiveWaitMs(maxWaitMs);
|
||||
const start = Date.now();
|
||||
while (true) {
|
||||
if (await this.pingBackend()) {
|
||||
@@ -255,6 +373,12 @@ class BackendManager {
|
||||
...process.env,
|
||||
PYTHONUNBUFFERED: '1',
|
||||
};
|
||||
if (this.app.isPackaged) {
|
||||
env.ASTRBOT_ELECTRON_CLIENT = '1';
|
||||
}
|
||||
if (backendConfig.webuiDir) {
|
||||
env.ASTRBOT_WEBUI_DIR = backendConfig.webuiDir;
|
||||
}
|
||||
if (backendConfig.rootDir) {
|
||||
env.ASTRBOT_ROOT = backendConfig.rootDir;
|
||||
const logsDir = path.join(backendConfig.rootDir, 'logs');
|
||||
@@ -341,6 +465,8 @@ class BackendManager {
|
||||
|
||||
if (process.platform === 'win32' && pid) {
|
||||
try {
|
||||
// Synchronous taskkill is acceptable here because stop/restart is
|
||||
// already a control-path operation and not latency-sensitive.
|
||||
const result = spawnSync('taskkill', ['/pid', `${pid}`, '/t', '/f'], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
@@ -383,6 +509,167 @@ class BackendManager {
|
||||
this.closeBackendLogFd();
|
||||
}
|
||||
|
||||
findListeningPidsOnWindows(port) {
|
||||
// Synchronous netstat parsing is acceptable here because this helper is
|
||||
// used only during shutdown/restart cleanup paths.
|
||||
const result = spawnSync('netstat', ['-ano', '-p', 'tcp'], {
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
if (result.status !== 0 || !result.stdout) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const pids = new Set();
|
||||
const lines = result.stdout.split(/\r?\n/);
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || !trimmed.toUpperCase().startsWith('TCP')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parts = trimmed.split(/\s+/);
|
||||
if (parts.length < 5) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const localAddress = parts[1] || '';
|
||||
const state = (parts[3] || '').toUpperCase();
|
||||
const pid = parts[parts.length - 1];
|
||||
if (!/^\d+$/.test(pid)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (state !== 'LISTENING') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const cleanedLocalAddress = localAddress.replace(/\]$/, '');
|
||||
const segments = cleanedLocalAddress.split(':');
|
||||
const portStr = segments[segments.length - 1];
|
||||
const portNum = Number(portStr);
|
||||
if (Number.isInteger(portNum) && portNum === Number(port)) {
|
||||
pids.add(pid);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(pids);
|
||||
}
|
||||
|
||||
getWindowsProcessInfo(pid) {
|
||||
const result = spawnSync(
|
||||
'tasklist',
|
||||
['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'],
|
||||
{
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
if (result.status !== 0 || !result.stdout) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstLine = result.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0);
|
||||
if (!firstLine || firstLine.startsWith('INFO:')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fields = firstLine
|
||||
.replace(/^"/, '')
|
||||
.replace(/"$/, '')
|
||||
.split('","');
|
||||
const imageName = fields[0] || '';
|
||||
const parsedPid = Number.parseInt(fields[1] || '', 10);
|
||||
if (!imageName || !Number.isInteger(parsedPid) || parsedPid !== Number(pid)) {
|
||||
return null;
|
||||
}
|
||||
return { imageName, pid: parsedPid };
|
||||
}
|
||||
|
||||
async stopUnmanagedBackendByPort() {
|
||||
if (!this.app.isPackaged || process.platform !== 'win32') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const port = this.getBackendPort();
|
||||
if (!port) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pids = this.findListeningPidsOnWindows(port);
|
||||
if (!pids.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.log(
|
||||
`Attempting unmanaged backend cleanup by port=${port} pids=${pids.join(',')}`,
|
||||
);
|
||||
|
||||
const expectedImageName = (
|
||||
path.basename(this.getPackagedBackendPath() || '') || 'astrbot-backend.exe'
|
||||
).toLowerCase();
|
||||
|
||||
for (const pid of pids) {
|
||||
const processInfo = this.getWindowsProcessInfo(pid);
|
||||
if (!processInfo) {
|
||||
this.log(`Skip unmanaged cleanup for pid=${pid}: unable to resolve process info.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const actualImageName = processInfo.imageName.toLowerCase();
|
||||
if (actualImageName !== expectedImageName) {
|
||||
this.log(
|
||||
`Skip unmanaged cleanup for pid=${pid}: unexpected process image ${processInfo.imageName}.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Synchronous taskkill is acceptable here because unmanaged cleanup
|
||||
// is performed only during shutdown/restart control flows.
|
||||
spawnSync('taskkill', ['/pid', `${pid}`, '/t', '/f'], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
await delay(500);
|
||||
return !(await this.pingBackend(1200));
|
||||
}
|
||||
|
||||
async stopAnyBackend() {
|
||||
if (this.backendProcess) {
|
||||
await this.stopManagedBackend();
|
||||
const running = await this.pingBackend();
|
||||
if (!running) {
|
||||
return { ok: true, reason: null };
|
||||
}
|
||||
} else {
|
||||
const running = await this.pingBackend();
|
||||
if (!running) {
|
||||
return { ok: true, reason: null };
|
||||
}
|
||||
}
|
||||
|
||||
const cleaned = await this.stopUnmanagedBackendByPort();
|
||||
if (cleaned) {
|
||||
return { ok: true, reason: null };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'Backend is running but not managed by Electron.',
|
||||
};
|
||||
}
|
||||
|
||||
async ensureBackend() {
|
||||
this.backendStartupFailureReason = null;
|
||||
|
||||
@@ -412,7 +699,7 @@ class BackendManager {
|
||||
};
|
||||
}
|
||||
|
||||
async restartBackend() {
|
||||
async restartBackend(authToken = null) {
|
||||
if (!this.canManageBackend()) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -428,6 +715,31 @@ class BackendManager {
|
||||
|
||||
this.backendRestarting = true;
|
||||
try {
|
||||
const backendRunning = await this.pingBackend(900);
|
||||
if (backendRunning) {
|
||||
const previousStartTime = await this.getBackendStartTime();
|
||||
const gracefulRequested = await this.requestGracefulRestart(authToken);
|
||||
if (gracefulRequested) {
|
||||
const gracefulResult = await this.waitForGracefulRestart(
|
||||
previousStartTime,
|
||||
this.backendTimeoutMs,
|
||||
);
|
||||
if (gracefulResult.ok) {
|
||||
return {
|
||||
ok: true,
|
||||
reason: null,
|
||||
};
|
||||
}
|
||||
this.log(
|
||||
`Graceful restart did not complete: ${gracefulResult.reason || 'unknown reason'}`,
|
||||
);
|
||||
} else {
|
||||
this.log(
|
||||
'Graceful restart request failed; falling back to managed restart.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.stopManagedBackend();
|
||||
const startResult = await this.startBackendAndWait(this.backendTimeoutMs);
|
||||
if (!startResult.ok) {
|
||||
@@ -465,31 +777,7 @@ class BackendManager {
|
||||
}
|
||||
|
||||
try {
|
||||
if (!this.backendProcess) {
|
||||
const running = await this.pingBackend();
|
||||
if (running) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'Backend is running but not managed by Electron.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
reason: null,
|
||||
};
|
||||
}
|
||||
await this.stopManagedBackend();
|
||||
const running = await this.pingBackend();
|
||||
if (running) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'Backend is still reachable after stop request.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
reason: null,
|
||||
};
|
||||
return await this.stopAnyBackend();
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
+16
-8
@@ -161,6 +161,16 @@ function createWindow() {
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
...(isMac
|
||||
? {
|
||||
defaultFontFamily: {
|
||||
standard: 'PingFang SC',
|
||||
sansSerif: 'PingFang SC',
|
||||
serif: 'Songti SC',
|
||||
monospace: 'SF Mono',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -235,8 +245,8 @@ function registerIpcHandlers() {
|
||||
return backendManager.getState();
|
||||
});
|
||||
|
||||
ipcMain.handle('astrbot-desktop:restart-backend', async () => {
|
||||
return backendManager.restartBackend();
|
||||
ipcMain.handle('astrbot-desktop:restart-backend', async (_event, authToken) => {
|
||||
return backendManager.restartBackend(authToken);
|
||||
});
|
||||
|
||||
ipcMain.handle('astrbot-desktop:stop-backend', async () => {
|
||||
@@ -348,12 +358,10 @@ app.on('before-quit', (event) => {
|
||||
.persistLocaleFromDashboard(mainWindow, backendManager.getBackendUrl())
|
||||
.catch(() => {})
|
||||
.then(() =>
|
||||
backendManager.stopManagedBackend().catch((error) => {
|
||||
logElectron(
|
||||
`stopBackend failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
backendManager.stopAnyBackend().then((result) => {
|
||||
if (!result.ok) {
|
||||
logElectron(`stopBackend failed: ${result.reason || 'unknown reason'}`);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.finally(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "astrbot-desktop",
|
||||
"version": "4.14.7",
|
||||
"version": "4.15.0",
|
||||
"description": "AstrBot desktop wrapper",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
|
||||
+2
-1
@@ -6,6 +6,7 @@ contextBridge.exposeInMainWorld('astrbotDesktop', {
|
||||
isElectron: true,
|
||||
isElectronRuntime: () => ipcRenderer.invoke('astrbot-desktop:is-electron-runtime'),
|
||||
getBackendState: () => ipcRenderer.invoke('astrbot-desktop:get-backend-state'),
|
||||
restartBackend: () => ipcRenderer.invoke('astrbot-desktop:restart-backend'),
|
||||
restartBackend: (authToken) =>
|
||||
ipcRenderer.invoke('astrbot-desktop:restart-backend', authToken),
|
||||
stopBackend: () => ipcRenderer.invoke('astrbot-desktop:stop-backend'),
|
||||
});
|
||||
|
||||
@@ -35,6 +35,8 @@ const args = [
|
||||
'pip',
|
||||
'--collect-submodules',
|
||||
'astrbot.api',
|
||||
'--collect-data',
|
||||
'certifi',
|
||||
'--add-data',
|
||||
`${kbStopwordsSrc}${dataSeparator}${kbStopwordsDest}`,
|
||||
'--distpath',
|
||||
|
||||
@@ -5,10 +5,14 @@ import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from astrbot.core import LogBroker, LogManager, db_helper, logger
|
||||
from astrbot.core.config.default import VERSION
|
||||
from astrbot.core.initial_loader import InitialLoader
|
||||
from astrbot.core.utils.astrbot_path import (
|
||||
import runtime_bootstrap
|
||||
|
||||
runtime_bootstrap.initialize_runtime_bootstrap()
|
||||
|
||||
from astrbot.core import LogBroker, LogManager, db_helper, logger # noqa: E402
|
||||
from astrbot.core.config.default import VERSION # noqa: E402
|
||||
from astrbot.core.initial_loader import InitialLoader # noqa: E402
|
||||
from astrbot.core.utils.astrbot_path import ( # noqa: E402
|
||||
get_astrbot_config_path,
|
||||
get_astrbot_data_path,
|
||||
get_astrbot_plugin_path,
|
||||
@@ -16,7 +20,10 @@ from astrbot.core.utils.astrbot_path import (
|
||||
get_astrbot_site_packages_path,
|
||||
get_astrbot_temp_path,
|
||||
)
|
||||
from astrbot.core.utils.io import download_dashboard, get_dashboard_version
|
||||
from astrbot.core.utils.io import ( # noqa: E402
|
||||
download_dashboard,
|
||||
get_dashboard_version,
|
||||
)
|
||||
|
||||
# 将父目录添加到 sys.path
|
||||
sys.path.append(Path(__file__).parent.as_posix())
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "AstrBot"
|
||||
version = "4.14.8"
|
||||
version = "4.15.0"
|
||||
description = "Easy-to-use multi-platform LLM chatbot and development framework"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -36,7 +36,7 @@ dependencies = [
|
||||
"pip>=25.1.1",
|
||||
"psutil>=5.8.0,<7.2.0",
|
||||
"py-cord>=2.6.1",
|
||||
"pydantic~=2.10.3",
|
||||
"pydantic>=2.12.5",
|
||||
"pydub>=0.25.1",
|
||||
"pyjwt>=2.10.1",
|
||||
"python-telegram-bot>=22.0",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import logging
|
||||
import ssl
|
||||
from typing import Any
|
||||
|
||||
import aiohttp.connector as aiohttp_connector
|
||||
|
||||
from astrbot.utils.http_ssl_common import build_ssl_context_with_certifi
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _try_patch_aiohttp_ssl_context(
|
||||
ssl_context: ssl.SSLContext,
|
||||
log_obj: Any | None = None,
|
||||
) -> bool:
|
||||
log = log_obj or logger
|
||||
attr_name = "_SSL_CONTEXT_VERIFIED"
|
||||
|
||||
if not hasattr(aiohttp_connector, attr_name):
|
||||
log.warning(
|
||||
"aiohttp connector does not expose _SSL_CONTEXT_VERIFIED; skipped patch.",
|
||||
)
|
||||
return False
|
||||
|
||||
current_value = getattr(aiohttp_connector, attr_name, None)
|
||||
if current_value is not None and not isinstance(current_value, ssl.SSLContext):
|
||||
log.warning(
|
||||
"aiohttp connector exposes _SSL_CONTEXT_VERIFIED with unexpected type; skipped patch.",
|
||||
)
|
||||
return False
|
||||
|
||||
setattr(aiohttp_connector, attr_name, ssl_context)
|
||||
log.info("Configured aiohttp verified SSL context with system+certifi trust chain.")
|
||||
return True
|
||||
|
||||
|
||||
def configure_runtime_ca_bundle(log_obj: Any | None = None) -> bool:
|
||||
log = log_obj or logger
|
||||
|
||||
try:
|
||||
log.info("Bootstrapping runtime CA bundle.")
|
||||
ssl_context = build_ssl_context_with_certifi(log_obj=log)
|
||||
return _try_patch_aiohttp_ssl_context(ssl_context, log_obj=log)
|
||||
except Exception as exc:
|
||||
log.error("Failed to configure runtime CA bundle for aiohttp: %r", exc)
|
||||
return False
|
||||
|
||||
|
||||
def initialize_runtime_bootstrap(log_obj: Any | None = None) -> bool:
|
||||
return configure_runtime_ca_bundle(log_obj=log_obj)
|
||||
Reference in New Issue
Block a user