Compare commits

..

19 Commits

Author SHA1 Message Date
Soulter 16d49d568b fix: add reminder for v4.14.8 users regarding manual redeployment due to a bug 2026-02-10 23:20:49 +08:00
Soulter 776e17062c chore: bump version to 4.15.0 (#5003) 2026-02-10 23:17:23 +08:00
エイカク 8fa8c14b0b fix: 修复app内重启异常,修复app内点击重启不能立刻提示重启,以及在后端就绪时及时刷新界面的问题 (#5013)
* fix: patch pip distlib finder for frozen electron runtime

* fix: use certifi CA bundle for runtime SSL requests

* fix: configure certifi CA before core imports

* fix: improve mac font fallback for dashboard text

* fix: harden frozen pip patch and unify TLS connector

* refactor: centralize dashboard CJK font fallback stacks

* perf: reuse TLS context and avoid repeated frozen pip patch

* refactor: bootstrap TLS setup before core imports

* fix: use async confirm dialog for provider deletions

* fix: replace native confirm dialogs in dashboard

- Add shared confirm helper in dashboard/src/utils/confirmDialog.ts for async dialog usage with safe fallback.

- Migrate provider, chat, config, session, platform, persona, MCP, backup, and knowledge-base delete/close confirmations to use the shared helper.

- Remove scattered inline confirm handling to keep behavior consistent and avoid native blocking dialog focus/caret issues in Electron.

* fix: capture runtime bootstrap logs after logger init

- Add bootstrap record buffer in runtime_bootstrap for early TLS patch logs before logger is ready.

- Flush buffered bootstrap logs to astrbot logger at process startup in main.py.

- Include concrete exception details for TLS bootstrap failures to improve diagnosis.

* fix: harden runtime bootstrap and unify confirm handling

- Simplify bootstrap log buffering and add a public initialize hook for non-main startup paths.

- Guard aiohttp TLS patching with feature/type checks and keep graceful fallback when internals are unavailable.

- Standardize dashboard confirmation flow via shared confirm helpers across composition and options API components.

* refactor: simplify runtime tls bootstrap and tighten confirm typing

* refactor: align ssl helper namespace and confirm usage

* fix: avoid frozen restart crash from multiprocessing import

* fix: include missing frozen dependencies for windows backend

* fix: use execv for stable backend reboot args

* Revert "fix: use execv for stable backend reboot args"

This reverts commit 9cc27becff.

* Revert "fix: include missing frozen dependencies for windows backend"

This reverts commit 52554bea1f.

* Revert "fix: avoid frozen restart crash from multiprocessing import"

This reverts commit 10548645b0.

* fix: reset pyinstaller onefile env before reboot

* fix: unify electron restart path and tray-exit backend cleanup

* fix: stabilize desktop restart detection and frozen reboot args

* fix: make dashboard restart wait detection robust

* fix: revert dashboard restart waiting interaction tweaks

* fix: pass auth token for desktop graceful restart

* fix: avoid false failure during graceful restart wait

* fix: start restart waiting before electron restart call

* fix: harden restart waiting and reboot arg parsing

* fix: parse start_time as numeric timestamp

* fix: preserve windows frozen reboot argv quoting

* fix: align restart waiting with electron restart timing

* fix: tighten graceful restart and unmanaged kill safety
2026-02-10 22:21:04 +09:00
エイカク 64de474139 fix: 修复 Windows 打包版后端重启失败问题 (#5009)
* fix: patch pip distlib finder for frozen electron runtime

* fix: use certifi CA bundle for runtime SSL requests

* fix: configure certifi CA before core imports

* fix: improve mac font fallback for dashboard text

* fix: harden frozen pip patch and unify TLS connector

* refactor: centralize dashboard CJK font fallback stacks

* perf: reuse TLS context and avoid repeated frozen pip patch

* refactor: bootstrap TLS setup before core imports

* fix: use async confirm dialog for provider deletions

* fix: replace native confirm dialogs in dashboard

- Add shared confirm helper in dashboard/src/utils/confirmDialog.ts for async dialog usage with safe fallback.

- Migrate provider, chat, config, session, platform, persona, MCP, backup, and knowledge-base delete/close confirmations to use the shared helper.

- Remove scattered inline confirm handling to keep behavior consistent and avoid native blocking dialog focus/caret issues in Electron.

* fix: capture runtime bootstrap logs after logger init

- Add bootstrap record buffer in runtime_bootstrap for early TLS patch logs before logger is ready.

- Flush buffered bootstrap logs to astrbot logger at process startup in main.py.

- Include concrete exception details for TLS bootstrap failures to improve diagnosis.

* fix: harden runtime bootstrap and unify confirm handling

- Simplify bootstrap log buffering and add a public initialize hook for non-main startup paths.

- Guard aiohttp TLS patching with feature/type checks and keep graceful fallback when internals are unavailable.

- Standardize dashboard confirmation flow via shared confirm helpers across composition and options API components.

* refactor: simplify runtime tls bootstrap and tighten confirm typing

* refactor: align ssl helper namespace and confirm usage

* fix: avoid frozen restart crash from multiprocessing import

* fix: include missing frozen dependencies for windows backend

* fix: use execv for stable backend reboot args

* Revert "fix: use execv for stable backend reboot args"

This reverts commit 9cc27becff.

* Revert "fix: include missing frozen dependencies for windows backend"

This reverts commit 52554bea1f.

* Revert "fix: avoid frozen restart crash from multiprocessing import"

This reverts commit 10548645b0.

* fix: reset pyinstaller onefile env before reboot

* fix: unify electron restart path and tray-exit backend cleanup

* fix: stabilize desktop restart detection and frozen reboot args

* fix: make dashboard restart wait detection robust

* fix: revert dashboard restart waiting interaction tweaks

* fix: pass auth token for desktop graceful restart

* fix: avoid false failure during graceful restart wait

* fix: start restart waiting before electron restart call

* fix: harden restart waiting and reboot arg parsing

* fix: parse start_time as numeric timestamp
2026-02-10 21:33:06 +09:00
エイカク d35771f97d fix: stabilize packaged runtime pip/ssl behavior and mac font fallback (#5007)
* fix: patch pip distlib finder for frozen electron runtime

* fix: use certifi CA bundle for runtime SSL requests

* fix: configure certifi CA before core imports

* fix: improve mac font fallback for dashboard text

* fix: harden frozen pip patch and unify TLS connector

* refactor: centralize dashboard CJK font fallback stacks

* perf: reuse TLS context and avoid repeated frozen pip patch

* refactor: bootstrap TLS setup before core imports

* fix: use async confirm dialog for provider deletions

* fix: replace native confirm dialogs in dashboard

- Add shared confirm helper in dashboard/src/utils/confirmDialog.ts for async dialog usage with safe fallback.

- Migrate provider, chat, config, session, platform, persona, MCP, backup, and knowledge-base delete/close confirmations to use the shared helper.

- Remove scattered inline confirm handling to keep behavior consistent and avoid native blocking dialog focus/caret issues in Electron.

* fix: capture runtime bootstrap logs after logger init

- Add bootstrap record buffer in runtime_bootstrap for early TLS patch logs before logger is ready.

- Flush buffered bootstrap logs to astrbot logger at process startup in main.py.

- Include concrete exception details for TLS bootstrap failures to improve diagnosis.

* fix: harden runtime bootstrap and unify confirm handling

- Simplify bootstrap log buffering and add a public initialize hook for non-main startup paths.

- Guard aiohttp TLS patching with feature/type checks and keep graceful fallback when internals are unavailable.

- Standardize dashboard confirmation flow via shared confirm helpers across composition and options API components.

* refactor: simplify runtime tls bootstrap and tighten confirm typing

* refactor: align ssl helper namespace and confirm usage
2026-02-10 16:42:43 +09:00
dependabot[bot] 7a4d20d329 chore(deps): bump the github-actions group with 2 updates (#5006)
Bumps the github-actions group with 2 updates: [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) and [actions/download-artifact](https://github.com/actions/download-artifact).


Updates `astral-sh/setup-uv` from 6 to 7
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/v6...v7)

Updates `actions/download-artifact` from 6 to 7
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/download-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-10 11:10:26 +08:00
Li-shi-ling aab095347f fix: 'HandoffTool' object has no attribute 'agent' (#5005)
* fix: 移动agent的位置到super().__init__之后

* add: 添加一行注释
2026-02-10 11:01:49 +08:00
エイカク 1addd5b2ab perf: 稳定源码与 Electron 打包环境下的 pip 安装行为,并修复非 Electron 环境下点击 WebUI 更新按钮时出现跳转对话框的问题 (#4996)
* fix: handle pip install execution in frozen runtime

* fix: harden pip subprocess fallback handling

* fix: scope global data root to packaged electron runtime

* refactor: inline frozen runtime check for electron guard

* fix: prefer current interpreter for source pip installs

* fix: avoid resolving venv python symlink for pip

* refactor: share runtime environment detection utilities

* fix: improve error message when pip module is unavailable

* fix: raise ImportError when pip module is unavailable

* fix: preserve ImportError semantics for missing pip

* fix: 修复非electron app环境更新时仍然显示electron更新对话框的问题

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-02-09 23:12:18 +08:00
Soulter da4bb6549c feat: enhance persona tool management and update UI localization for subagent orchestration (#4990)
* feat: enhance persona tool management and update UI localization for subagent orchestration

* fix: remove debug logging for final ProviderRequest in build_main_agent function
2026-02-09 22:38:05 +08:00
Soulter 7193454d50 feat: enhance WecomAIBotAdapter and WecomAIBotMessageEvent for improved streaming message handling (#5000)
fixes: #3965
2026-02-09 22:30:24 +08:00
Soulter d204b92877 feat: 企业微信智能机器人支持主动消息推送以及发送视频、文件等消息类型支持 (#4999) 2026-02-09 22:16:44 +08:00
Soulter 04faf26140 feat: 企业微信应用 支持主动消息推送,并优化企微应用、微信公众号、微信客服音频相关的处理 (#4998) 2026-02-09 22:15:11 +08:00
鸦羽 67b81c279b fix: collect certifi data in desktop backend build (#4995) 2026-02-09 19:40:32 +09:00
エイカク 2afb08d8b2 fix: handle pip install execution in frozen runtime (#4985)
* fix: handle pip install execution in frozen runtime

* fix: harden pip subprocess fallback handling
2026-02-09 15:19:01 +08:00
Soulter 06b2c7cb16 feat: enhance Dingtalk adapter with active push message and image, video, audio message type (#4986) 2026-02-09 15:17:55 +08:00
Copilot 9c12803ddd feat: add delete button to persona management dialog (#4978)
* Initial plan

* feat: add delete button to persona management dialog

- Added delete button to PersonaForm dialog (only visible when editing)
- Implemented deletePersona method with confirmation dialog
- Connected delete event to PersonaManager for proper handling
- Button positioned on left side of dialog actions for clear separation
- Uses existing i18n translations for delete button and messages

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>

* fix: use finally block to ensure saving state is reset

- Moved `this.saving = false` to finally block in deletePersona
- Ensures UI doesn't stay in saving state after errors
- Follows best practices for state management

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
2026-02-09 11:59:28 +08:00
Soulter ce65491d55 chore: update pydantic dependency version (#4980) 2026-02-09 11:59:05 +08:00
Soulter b67adcf481 ci: change ghcr namespace 2026-02-09 11:51:56 +08:00
Soulter 1707d55c02 fix: prepare OpenSSL via vcpkg for Windows ARM64 2026-02-09 11:04:31 +08:00
63 changed files with 2318 additions and 578 deletions
+2 -2
View File
@@ -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:
+3 -3
View File
@@ -160,7 +160,7 @@ 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
@@ -291,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
View File
@@ -1 +1 @@
__version__ = "4.14.8"
__version__ = "4.15.0"
+2 -1
View File
@@ -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 {
+27 -30
View File
@@ -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(
+18 -5
View File
@@ -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"![image]({image_url})\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
View File
@@ -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(
+4
View File
@@ -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())
+33
View File
@@ -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())
+4 -1
View File
@@ -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
+108
View File
@@ -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")
+120 -50
View File
@@ -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
+10
View File
@@ -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"
+6 -6
View File
@@ -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",
+1
View File
@@ -0,0 +1 @@
+24
View File
@@ -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
+41
View File
@@ -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 -1
View File
@@ -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;
+3 -3
View File
@@ -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;
}
}
+11
View File
@@ -0,0 +1,11 @@
import 'vue'
import type { ConfirmDialogHandler } from '@/utils/confirmDialog'
declare module 'vue' {
interface ComponentCustomProperties {
$confirm?: ConfirmDialogHandler
}
}
export {}
+1 -1
View File
@@ -11,7 +11,7 @@ declare global {
restarting: boolean;
canManage: boolean;
}>;
restartBackend: () => Promise<{
restartBackend: (authToken?: string | null) => Promise<{
ok: boolean;
reason: string | null;
}>;
+31
View File
@@ -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)
}
+52
View File
@@ -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)
}
+13 -7
View File
@@ -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>
+10 -4
View File
@@ -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>
+110 -10
View File
@@ -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 })
+7 -5
View File
@@ -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 () => {
-11
View File
@@ -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,
+18 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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'),
});
+2
View File
@@ -35,6 +35,8 @@ const args = [
'pip',
'--collect-submodules',
'astrbot.api',
'--collect-data',
'certifi',
'--add-data',
`${kbStopwordsSrc}${dataSeparator}${kbStopwordsDest}`,
'--distpath',
+12 -5
View File
@@ -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
View File
@@ -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",
+50
View File
@@ -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)