Compare commits

...

7 Commits

Author SHA1 Message Date
Soulter 602ae4eee2 feat(heihe): enhance configuration metadata and add new parameters for WebSocket connection 2026-02-25 16:21:54 +08:00
Soulter 3d82f42311 Merge remote-tracking branch 'origin/master' into feat/heibox 2026-02-25 12:00:06 +08:00
エイカク 5530a2260a feat(dashboard): add generic desktop app updater bridge (#5424)
* feat(dashboard): add generic desktop app updater bridge

* fix(dashboard): address updater bridge review feedback

* fix(dashboard): unify updater bridge types and error logging

* fix(dashboard): consolidate updater bridge typings
2026-02-25 10:01:13 +09:00
Soulter c24de24ca4 chore: ruff format 2026-02-24 23:12:18 +08:00
Yunhao Cao b54b4c79ed fix: Telegram voice message format (OGG instead of WAV) causing issues with OpenAI STT API (#5389) 2026-02-24 23:11:56 +08:00
Soulter c6cc7aae84 chore: bump version to 4.18.2 2026-02-24 23:08:53 +08:00
Soulter aec2e3bb91 feat: supports 小黑盒语音机器人 2026-02-14 00:44:35 +08:00
9 changed files with 801 additions and 76 deletions
+4
View File
@@ -180,6 +180,10 @@ class PlatformManager:
from .sources.line.line_adapter import (
LinePlatformAdapter, # noqa: F401
)
case "heihe":
from .sources.heihe.heihe_adapter import (
HeihePlatformAdapter, # noqa: F401
)
except (ImportError, ModuleNotFoundError) as e:
logger.error(
f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->平台日志->安装Pip库 中安装依赖库。",
@@ -0,0 +1,523 @@
import asyncio
import json
import time
import uuid
from collections.abc import Mapping
from typing import Any, cast
import websockets
from websockets.asyncio.client import ClientConnection, connect
from astrbot.api import logger
from astrbot.api.event import MessageChain
from astrbot.api.message_components import At, Image, Plain
from astrbot.api.platform import (
AstrBotMessage,
Group,
MessageMember,
MessageType,
Platform,
PlatformMetadata,
)
from astrbot.core.platform.astr_message_event import MessageSesion
from ...register import register_platform_adapter
from .heihe_event import HeiheMessageEvent
HEIHE_CONFIG_METADATA = {
"heihe_ws_url": {
"description": "Heihe WebSocket URL",
"type": "string",
"hint": "一般情况下不需要修改。",
},
"heihe_token": {
"description": "Bot Token",
"type": "string",
"hint": "黑盒 Bot Token。可填写纯 Token(推荐),适配器会自动添加 Authorization 头。",
},
"heihe_origin": {
"description": "WebSocket Origin",
"type": "string",
"hint": "用于 WebSocket 握手的 Origin 头,默认 https://chat.xiaoheihe.cn。",
},
"heihe_bot_id": {
"description": "Bot ID",
"type": "string",
"hint": "可选。为空时会根据收到的消息自动识别机器人 ID。",
},
"heihe_auto_reconnect": {
"description": "Auto Reconnect",
"type": "bool",
"hint": "WebSocket 断开后是否自动重连。",
},
"heihe_heartbeat_interval": {
"description": "Heartbeat Interval (seconds)",
"type": "int",
"hint": "发送心跳包间隔。<=0 表示关闭主动心跳。",
},
"heihe_reconnect_delay": {
"description": "Reconnect Delay (seconds)",
"type": "int",
"hint": "WebSocket 断开后的重连等待时间。",
},
"heihe_ignore_self_message": {
"description": "Ignore Self Message",
"type": "bool",
"hint": "是否忽略机器人自身发送的消息。",
},
}
HEIHE_I18N_RESOURCES = {
"zh-CN": {
"heihe_ws_url": {
"description": "黑盒 WebSocket 地址",
"hint": "一般情况下不需要修改。",
},
"heihe_token": {
"description": "机器人 Token",
"hint": "建议填写纯 Token,适配器会自动补齐 Authorization 头。",
},
"heihe_origin": {
"description": "WebSocket Origin",
"hint": "用于握手的 Origin 头,默认 https://chat.xiaoheihe.cn。",
},
"heihe_bot_id": {
"description": "机器人 ID",
"hint": "可选。为空时会根据收到的消息自动识别机器人 ID。",
},
"heihe_auto_reconnect": {
"description": "自动重连",
"hint": "WebSocket 断开后是否自动重连。",
},
"heihe_heartbeat_interval": {
"description": "心跳间隔(秒)",
"hint": "设置 <=0 将关闭主动心跳。",
},
"heihe_reconnect_delay": {
"description": "重连间隔(秒)",
"hint": "WebSocket 断开后的重连等待时间。",
},
"heihe_ignore_self_message": {
"description": "忽略机器人自身消息",
"hint": "开启后,机器人自己发出的消息将不会触发事件处理。",
},
},
"en-US": {
"heihe_ws_url": {
"description": "Heihe WebSocket URL",
"hint": "Usually no need to change this.",
},
"heihe_token": {
"description": "Bot Token",
"hint": "Plain token is recommended. Authorization header is added automatically.",
},
"heihe_origin": {
"description": "WebSocket Origin",
"hint": "Origin header used in websocket handshake. Default: https://chat.xiaoheihe.cn.",
},
"heihe_bot_id": {
"description": "Bot ID",
"hint": "Optional. If empty, the adapter will infer it from incoming messages.",
},
"heihe_auto_reconnect": {
"description": "Auto Reconnect",
"hint": "Whether to reconnect automatically after websocket disconnects.",
},
"heihe_heartbeat_interval": {
"description": "Heartbeat Interval (seconds)",
"hint": "Set <=0 to disable active heartbeat.",
},
"heihe_reconnect_delay": {
"description": "Reconnect Delay (seconds)",
"hint": "Delay before reconnecting after disconnect.",
},
"heihe_ignore_self_message": {
"description": "Ignore Self Message",
"hint": "When enabled, messages sent by the bot itself will be ignored.",
},
},
}
@register_platform_adapter(
"heihe",
"黑盒机器人(WebSocket)适配器",
support_streaming_message=False,
default_config_tmpl={
"id": "heihe",
"type": "heihe",
"enable": False,
"heihe_ws_url": "wss://chat.xiaoheihe.cn/chatroom/ws/connect",
"heihe_token": "",
"heihe_origin": "https://chat.xiaoheihe.cn",
"heihe_bot_id": "",
"heihe_auto_reconnect": True,
"heihe_heartbeat_interval": 20,
"heihe_reconnect_delay": 5,
"heihe_ignore_self_message": True,
},
config_metadata=HEIHE_CONFIG_METADATA,
i18n_resources=HEIHE_I18N_RESOURCES,
)
class HeihePlatformAdapter(Platform):
def __init__(
self,
platform_config: dict,
platform_settings: dict,
event_queue: asyncio.Queue,
) -> None:
super().__init__(platform_config, event_queue)
self.settings = platform_settings
self.ws_url = str(platform_config.get("heihe_ws_url", "")).strip()
self.token = str(platform_config.get("heihe_token", "")).strip()
self.origin = str(
platform_config.get("heihe_origin", "https://chat.xiaoheihe.cn"),
).strip()
self.bot_id = str(platform_config.get("heihe_bot_id", "")).strip()
self.auto_reconnect = bool(platform_config.get("heihe_auto_reconnect", True))
self.heartbeat_interval = int(
cast(int, platform_config.get("heihe_heartbeat_interval", 20)),
)
self.reconnect_delay = int(
cast(int, platform_config.get("heihe_reconnect_delay", 5)),
)
self.ignore_self_message = bool(
platform_config.get("heihe_ignore_self_message", True),
)
if not self.ws_url:
raise ValueError("heihe_ws_url 不能为空。")
self.metadata = PlatformMetadata(
name="heihe",
description="黑盒机器人(WebSocket)适配器",
id=cast(str, self.config.get("id", "heihe")),
support_streaming_message=False,
)
self.ws: ClientConnection | None = None
self.running = False
self.heartbeat_task: asyncio.Task | None = None
self._last_heartbeat_ts = 0
def meta(self) -> PlatformMetadata:
return self.metadata
async def run(self) -> None:
self.running = True
while self.running:
try:
await self._connect_and_loop()
except websockets.exceptions.ConnectionClosed as e:
logger.warning("[heihe] websocket disconnected: %s", e)
except Exception as e:
logger.error("[heihe] websocket failed: %s", e)
if not self.running:
break
if not self.auto_reconnect:
break
await asyncio.sleep(max(1, self.reconnect_delay))
async def terminate(self) -> None:
self.running = False
if self.heartbeat_task:
self.heartbeat_task.cancel()
try:
await self.heartbeat_task
except asyncio.CancelledError:
pass
if self.ws:
try:
await self.ws.close()
except Exception:
pass
self.ws = None
async def send_by_session(
self,
session: MessageSesion,
message_chain: MessageChain,
) -> None:
await HeiheMessageEvent.send_with_adapter(
self,
message_chain,
session.session_id,
)
await super().send_by_session(session, message_chain)
async def send_payload(self, payload: Mapping[str, Any]) -> None:
if not self.ws:
raise RuntimeError("[heihe] websocket not connected")
if self.ws.close_code is not None:
raise RuntimeError("[heihe] websocket already closed")
body = dict(payload)
body.setdefault("timestamp", int(time.time()))
await self.ws.send(json.dumps(body, ensure_ascii=False))
async def _connect_and_loop(self) -> None:
logger.info("[heihe] connecting websocket: %s", self.ws_url)
headers: dict[str, str] = {}
if self.token:
headers["Authorization"] = f"Bearer {self.token}"
headers["X-Token"] = self.token
websocket = await connect(
self.ws_url,
additional_headers=headers,
max_size=10 * 1024 * 1024,
ping_interval=None,
)
self.ws = websocket
logger.info("[heihe] websocket connected")
if self.heartbeat_interval > 0:
self.heartbeat_task = asyncio.create_task(self._heartbeat_loop())
try:
async for raw in websocket:
await self._handle_incoming(raw)
finally:
if self.heartbeat_task:
self.heartbeat_task.cancel()
try:
await self.heartbeat_task
except asyncio.CancelledError:
pass
self.heartbeat_task = None
if self.ws:
try:
await self.ws.close()
except Exception:
pass
self.ws = None
async def _heartbeat_loop(self) -> None:
try:
while self.running and self.ws and self.ws.close_code is None:
await asyncio.sleep(self.heartbeat_interval)
self._last_heartbeat_ts = int(time.time())
await self.send_payload(
{
"type": "ping",
"ping": self._last_heartbeat_ts,
},
)
except asyncio.CancelledError:
pass
except Exception as e:
logger.warning("[heihe] heartbeat error: %s", e)
async def _handle_incoming(self, raw: Any) -> None:
if isinstance(raw, bytes):
try:
raw = raw.decode("utf-8")
except UnicodeDecodeError:
return
if not isinstance(raw, str):
return
try:
data = json.loads(raw)
except json.JSONDecodeError:
logger.debug("[heihe] skip non-json frame: %s", raw[:200])
return
if isinstance(data, list):
for item in data:
if isinstance(item, dict):
await self._handle_packet(item)
return
if isinstance(data, dict):
await self._handle_packet(data)
async def _handle_packet(self, packet: dict[str, Any]) -> None:
if "ping" in packet:
await self.send_payload({"type": "pong", "pong": packet.get("ping")})
return
if str(packet.get("type", "")).lower() == "ping":
await self.send_payload({"type": "pong", "pong": packet.get("ping")})
return
event_type = str(
packet.get("event")
or packet.get("event_type")
or packet.get("type")
or packet.get("topic")
or "",
).lower()
payload_obj = packet.get("data")
payload = payload_obj if isinstance(payload_obj, dict) else packet
if not self._is_message_event(event_type, payload):
return
abm = self._convert_message(payload, packet)
if not abm:
return
await self.handle_msg(abm)
@staticmethod
def _is_message_event(event_type: str, payload: Mapping[str, Any]) -> bool:
if "message" in event_type:
return True
keys = payload.keys()
return "content" in keys or "text" in keys or "message" in keys
def _convert_message(
self,
payload: Mapping[str, Any],
raw_packet: Mapping[str, Any],
) -> AstrBotMessage | None:
message_obj = payload.get("message")
message = message_obj if isinstance(message_obj, Mapping) else payload
sender_data_obj = (
payload.get("sender") or payload.get("author") or payload.get("user") or {}
)
sender_data = sender_data_obj if isinstance(sender_data_obj, Mapping) else {}
sender_id = str(
sender_data.get("id")
or sender_data.get("user_id")
or payload.get("sender_id")
or payload.get("user_id")
or "",
).strip()
sender_name = str(
sender_data.get("nickname")
or sender_data.get("name")
or sender_data.get("username")
or sender_id
or "unknown",
)
self_id = str(
payload.get("self_id")
or payload.get("bot_id")
or self.bot_id
or self.meta().id,
)
if self.ignore_self_message and sender_id and self_id and sender_id == self_id:
return None
channel_id = str(
payload.get("channel_id")
or payload.get("room_id")
or payload.get("chat_id")
or payload.get("session_id")
or "",
).strip()
guild_id = str(
payload.get("guild_id")
or payload.get("server_id")
or payload.get("group_id")
or "",
).strip()
is_private = bool(payload.get("is_private", False))
if str(payload.get("message_type", "")).lower() in {"private", "friend", "dm"}:
is_private = True
session_id = channel_id or sender_id
if not session_id:
return None
text = str(message.get("content") or message.get("text") or "").strip()
components = self._build_components(text, payload)
if not components:
return None
abm = AstrBotMessage()
abm.self_id = self_id
abm.message_id = str(
message.get("id")
or message.get("message_id")
or payload.get("message_id")
or payload.get("msg_id")
or uuid.uuid4().hex
)
timestamp_raw = (
payload.get("timestamp")
or payload.get("time")
or message.get("timestamp")
or message.get("time")
)
abm.timestamp = int(time.time())
if isinstance(timestamp_raw, int):
abm.timestamp = (
timestamp_raw // 1000
if timestamp_raw > 1_000_000_000_000
else timestamp_raw
)
if not is_private and (channel_id or guild_id):
abm.type = MessageType.GROUP_MESSAGE
abm.group = Group(
group_id=guild_id or channel_id, group_name=guild_id or ""
)
else:
abm.type = MessageType.FRIEND_MESSAGE
abm.session_id = session_id
abm.sender = MessageMember(user_id=sender_id or "unknown", nickname=sender_name)
abm.message = components
abm.message_str = self._build_message_str(components)
abm.raw_message = dict(raw_packet)
return abm
@staticmethod
def _build_components(text: str, payload: Mapping[str, Any]) -> list:
components: list = []
if text:
components.append(Plain(text=text))
mentions_obj = payload.get("mentions")
if isinstance(mentions_obj, list):
for mention in mentions_obj:
if not isinstance(mention, Mapping):
continue
user_id = str(mention.get("user_id") or mention.get("id") or "").strip()
name = str(mention.get("name") or mention.get("nickname") or "").strip()
if user_id or name:
components.append(At(qq=user_id, name=name))
attachments_obj = payload.get("attachments")
if isinstance(attachments_obj, list):
for item in attachments_obj:
if not isinstance(item, Mapping):
continue
url = str(item.get("url") or item.get("file_url") or "").strip()
if not url:
continue
kind = str(item.get("type") or item.get("media_type") or "").lower()
if "image" in kind:
components.append(Image.fromURL(url))
else:
components.append(Plain(text=f"[{kind or 'file'}] {url}"))
return components
@staticmethod
def _build_message_str(components: list) -> str:
parts: list[str] = []
for comp in components:
if isinstance(comp, Plain):
parts.append(comp.text)
elif isinstance(comp, At):
parts.append(f"@{comp.name or comp.qq}")
elif isinstance(comp, Image):
parts.append("[image]")
else:
parts.append(f"[{comp.type}]")
return " ".join(i for i in parts if i).strip()
async def handle_msg(self, abm: AstrBotMessage) -> None:
event = HeiheMessageEvent(
message_str=abm.message_str,
message_obj=abm,
platform_meta=self.meta(),
session_id=abm.session_id,
adapter=self,
)
self.commit_event(event)
@@ -0,0 +1,108 @@
from collections.abc import AsyncGenerator
from typing import TYPE_CHECKING, Any
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import At, Image, Plain, Reply
if TYPE_CHECKING:
from .heihe_adapter import HeihePlatformAdapter
class HeiheMessageEvent(AstrMessageEvent):
def __init__(
self,
message_str: str,
message_obj,
platform_meta,
session_id: str,
adapter: "HeihePlatformAdapter",
) -> None:
super().__init__(message_str, message_obj, platform_meta, session_id)
self.adapter = adapter
@classmethod
async def send_with_adapter(
cls,
adapter: "HeihePlatformAdapter",
message: MessageChain,
session_id: str,
) -> None:
payload = await cls._build_send_payload(message, session_id)
await adapter.send_payload(payload)
async def send(self, message: MessageChain) -> None:
await self.send_with_adapter(self.adapter, message, self.session_id)
await super().send(message)
async def send_streaming(
self,
generator: AsyncGenerator,
use_fallback: bool = False,
):
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)
@classmethod
async def _build_send_payload(
cls,
message: MessageChain,
session_id: str,
) -> dict[str, Any]:
text_parts: list[str] = []
segments: list[dict[str, Any]] = []
for component in message.chain:
if isinstance(component, Plain):
if component.text:
text_parts.append(component.text)
segments.append({"type": "text", "text": component.text})
continue
if isinstance(component, At):
at_name = str(component.name or component.qq or "").strip()
if at_name:
text_parts.append(f"@{at_name}")
segments.append(
{
"type": "mention",
"user_id": str(component.qq or ""),
"name": at_name,
},
)
continue
if isinstance(component, Reply):
if component.id:
segments.append({"type": "reply", "message_id": component.id})
continue
if isinstance(component, Image):
image_url = ""
try:
image_url = await component.register_to_file_service()
except Exception as e:
logger.debug("[heihe] image upload fallback failed: %s", e)
if image_url:
segments.append({"type": "image", "url": image_url})
text_parts.append("[image]")
continue
content = "".join(text_parts).strip()
payload: dict[str, Any] = {
"action": "send_message",
"channel_id": session_id,
"content": content,
"segments": segments,
}
return payload
@@ -1,4 +1,5 @@
import asyncio
import os
import re
import sys
import uuid
@@ -25,6 +26,9 @@ from astrbot.core.star.filter.command import CommandFilter
from astrbot.core.star.filter.command_group import CommandGroupFilter
from astrbot.core.star.star import star_map
from astrbot.core.star.star_handler import star_handlers_registry
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.io import download_image_by_url
from astrbot.core.utils.media_utils import convert_audio_to_wav
from .tg_event import TelegramPlatformEvent
@@ -375,8 +379,19 @@ class TelegramPlatformAdapter(Platform):
elif update.message.voice:
file = await update.message.voice.get_file()
file_basename = os.path.basename(file.file_path)
temp_dir = get_astrbot_temp_path()
temp_path = os.path.join(temp_dir, file_basename)
temp_path = await download_image_by_url(file.file_path, path=temp_path)
path_wav = os.path.join(
temp_dir,
f"{file_basename}.wav",
)
path_wav = await convert_audio_to_wav(temp_path, path_wav)
message.message = [
Comp.Record(file=file.file_path, url=file.file_path),
Comp.Record(file=path_wav, url=path_wav),
]
elif update.message.photo:
@@ -58,6 +58,18 @@
"guideStep2": "Install it and restart AstrBot.",
"guideStep3": "If you use Docker, prefer the image update path."
},
"desktopApp": {
"title": "Update Desktop App",
"message": "Check and upgrade the AstrBot desktop application.",
"currentVersion": "Current version: ",
"latestVersion": "Latest version: ",
"checking": "Checking desktop app updates...",
"hasNewVersion": "A new version is available. Click confirm to upgrade.",
"isLatest": "Already on the latest version",
"installing": "Downloading and installing update. The app will restart automatically...",
"checkFailed": "Failed to check updates. Please try again later.",
"installFailed": "Upgrade failed. Please try again later."
},
"dashboardUpdate": {
"title": "Update Dashboard to Latest Version Only",
"currentVersion": "Current Version",
@@ -58,6 +58,18 @@
"guideStep2": "完成安装后重启 AstrBot。",
"guideStep3": "如果你使用 Docker,请优先使用镜像更新方式。"
},
"desktopApp": {
"title": "更新桌面应用",
"message": "将检查并升级 AstrBot 桌面端程序。",
"currentVersion": "当前版本:",
"latestVersion": "最新版本:",
"checking": "正在检查桌面应用更新...",
"hasNewVersion": "发现新版本,可点击确认升级。",
"isLatest": "已经是最新版本",
"installing": "正在下载并安装更新,完成后将自动重启应用...",
"checkFailed": "检查更新失败,请稍后重试。",
"installFailed": "升级失败,请稍后重试。"
},
"dashboardUpdate": {
"title": "单独更新管理面板到最新版本",
"currentVersion": "当前版本",
@@ -50,24 +50,27 @@ let installLoading = ref(false);
const isDesktopReleaseMode = ref(
typeof window !== 'undefined' && !!window.astrbotDesktop?.isDesktop
);
const redirectConfirmDialog = ref(false);
const pendingRedirectUrl = ref('');
const resolvingReleaseTarget = ref(false);
const DEFAULT_ASTRBOT_RELEASE_BASE_URL = 'https://github.com/AstrBotDevs/AstrBot/releases';
const resolveReleaseBaseUrl = () => {
const raw = import.meta.env.VITE_ASTRBOT_RELEASE_BASE_URL;
// Keep upstream default on AstrBot releases; desktop distributors can override via env injection.
const normalized = raw?.trim()?.replace(/\/+$/, '') || '';
const withoutLatestSuffix = normalized.replace(/\/latest$/i, '');
return withoutLatestSuffix || DEFAULT_ASTRBOT_RELEASE_BASE_URL;
};
const releaseBaseUrl = resolveReleaseBaseUrl();
const getReleaseUrlByTag = (tag: string | null | undefined) => {
const normalizedTag = (tag || '').trim();
if (!normalizedTag || normalizedTag.toLowerCase() === 'latest') {
return `${releaseBaseUrl}/latest`;
const desktopUpdateDialog = ref(false);
const desktopUpdateChecking = ref(false);
const desktopUpdateInstalling = ref(false);
const desktopUpdateHasNewVersion = ref(false);
const desktopUpdateCurrentVersion = ref('-');
const desktopUpdateLatestVersion = ref('-');
const desktopUpdateStatus = ref('');
const getAppUpdaterBridge = (): AstrBotAppUpdaterBridge | null => {
if (typeof window === 'undefined') {
return null;
}
return `${releaseBaseUrl}/tag/${normalizedTag}`;
const bridge = window.astrbotAppUpdater;
if (
bridge &&
typeof bridge.checkForAppUpdate === 'function' &&
typeof bridge.installAppUpdate === 'function'
) {
return bridge;
}
return null;
};
const getSelectedGitHubProxy = () => {
@@ -89,16 +92,6 @@ const releasesHeader = computed(() => [
{ title: t('core.header.updateDialog.table.sourceUrl'), key: 'zipball_url' },
{ title: t('core.header.updateDialog.table.actions'), key: 'switch' }
]);
const latestReleaseTag = computed(() => {
const firstRelease = (releases.value as any[])?.[0];
if (firstRelease?.tag_name) {
return firstRelease.tag_name as string;
}
return hasNewVersion.value
? t('core.header.updateDialog.redirectConfirm.latestLabel')
: (botCurrVersion.value || '-');
});
// Form validation
const formValid = ref(true);
const passwordRules = computed(() => [
@@ -126,47 +119,88 @@ const accountEditStatus = ref({
message: ''
});
const open = (link: string) => {
window.open(link, '_blank');
};
function requestExternalRedirect(link: string) {
pendingRedirectUrl.value = link;
redirectConfirmDialog.value = true;
function cancelDesktopUpdate() {
if (desktopUpdateInstalling.value) {
return;
}
desktopUpdateDialog.value = false;
}
function cancelExternalRedirect() {
redirectConfirmDialog.value = false;
pendingRedirectUrl.value = '';
}
async function openDesktopUpdateDialog() {
desktopUpdateDialog.value = true;
desktopUpdateChecking.value = true;
desktopUpdateInstalling.value = false;
desktopUpdateHasNewVersion.value = false;
desktopUpdateCurrentVersion.value = '-';
desktopUpdateLatestVersion.value = '-';
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.checking');
function confirmExternalRedirect() {
const targetUrl = pendingRedirectUrl.value;
cancelExternalRedirect();
if (targetUrl) {
open(targetUrl);
const bridge = getAppUpdaterBridge();
if (!bridge) {
desktopUpdateChecking.value = false;
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.checkFailed');
return;
}
try {
const result = await bridge.checkForAppUpdate();
if (!result?.ok) {
desktopUpdateCurrentVersion.value = result?.currentVersion || '-';
desktopUpdateLatestVersion.value =
result?.latestVersion || result?.currentVersion || '-';
desktopUpdateStatus.value =
result?.reason || t('core.header.updateDialog.desktopApp.checkFailed');
return;
}
desktopUpdateCurrentVersion.value = result.currentVersion || '-';
desktopUpdateLatestVersion.value =
result.latestVersion || result.currentVersion || '-';
desktopUpdateHasNewVersion.value = !!result.hasUpdate;
desktopUpdateStatus.value = result.hasUpdate
? t('core.header.updateDialog.desktopApp.hasNewVersion')
: t('core.header.updateDialog.desktopApp.isLatest');
} catch (error) {
console.error(error);
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.checkFailed');
} finally {
desktopUpdateChecking.value = false;
}
}
const getReleaseUrlForDesktop = () => {
const firstRelease = (releases.value as any[])?.[0];
if (firstRelease?.tag_name) {
return getReleaseUrlByTag(firstRelease.tag_name as string);
async function confirmDesktopUpdate() {
if (!desktopUpdateHasNewVersion.value || desktopUpdateInstalling.value) {
return;
}
if (hasNewVersion.value) return getReleaseUrlByTag('latest');
const tag = botCurrVersion.value?.startsWith('v') ? botCurrVersion.value : 'latest';
return getReleaseUrlByTag(tag);
};
const bridge = getAppUpdaterBridge();
if (!bridge) {
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.installFailed');
return;
}
desktopUpdateInstalling.value = true;
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.installing');
try {
const result = await bridge.installAppUpdate();
if (result?.ok) {
desktopUpdateDialog.value = false;
return;
}
desktopUpdateStatus.value =
result?.reason || t('core.header.updateDialog.desktopApp.installFailed');
} catch (error) {
console.error(error);
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.installFailed');
} finally {
desktopUpdateInstalling.value = false;
}
}
function handleUpdateClick() {
if (isDesktopReleaseMode.value) {
requestExternalRedirect('');
resolvingReleaseTarget.value = true;
checkUpdate();
void getReleases().finally(() => {
pendingRedirectUrl.value = getReleaseUrlForDesktop() || getReleaseUrlByTag('latest');
resolvingReleaseTarget.value = false;
});
void openDesktopUpdateDialog();
return;
}
checkUpdate();
@@ -680,40 +714,38 @@ onMounted(async () => {
</v-card>
</v-dialog>
<v-dialog v-model="redirectConfirmDialog" max-width="460">
<v-dialog v-model="desktopUpdateDialog" max-width="460">
<v-card>
<v-card-title class="text-h3 pa-4 pl-6 pb-0">
{{ t('core.header.updateDialog.redirectConfirm.title') }}
{{ t('core.header.updateDialog.desktopApp.title') }}
</v-card-title>
<v-card-text>
<div class="mb-3">
{{ t('core.header.updateDialog.redirectConfirm.message') }}
{{ t('core.header.updateDialog.desktopApp.message') }}
</div>
<v-alert type="info" variant="tonal" density="compact">
<div>
{{ t('core.header.updateDialog.redirectConfirm.targetVersion') }}
<strong v-if="!resolvingReleaseTarget">{{ latestReleaseTag }}</strong>
<v-progress-circular v-else indeterminate size="16" width="2" class="ml-1" />
{{ t('core.header.updateDialog.desktopApp.currentVersion') }}
<strong>{{ desktopUpdateCurrentVersion }}</strong>
</div>
<div class="text-caption">
{{ t('core.header.updateDialog.redirectConfirm.currentVersion') }}
{{ botCurrVersion || '-' }}
<div>
{{ t('core.header.updateDialog.desktopApp.latestVersion') }}
<strong v-if="!desktopUpdateChecking">{{ desktopUpdateLatestVersion }}</strong>
<v-progress-circular v-else indeterminate size="16" width="2" class="ml-1" />
</div>
</v-alert>
<div class="text-caption mt-3">
<div>{{ t('core.header.updateDialog.redirectConfirm.guideTitle') }}</div>
<div>1. {{ t('core.header.updateDialog.redirectConfirm.guideStep1') }}</div>
<div>2. {{ t('core.header.updateDialog.redirectConfirm.guideStep2') }}</div>
<div>3. {{ t('core.header.updateDialog.redirectConfirm.guideStep3') }}</div>
{{ desktopUpdateStatus }}
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" variant="text" @click="cancelExternalRedirect">
<v-btn color="grey" variant="text" @click="cancelDesktopUpdate" :disabled="desktopUpdateInstalling">
{{ t('core.common.dialog.cancelButton') }}
</v-btn>
<v-btn color="primary" variant="flat" @click="confirmExternalRedirect"
:loading="resolvingReleaseTarget" :disabled="resolvingReleaseTarget || !pendingRedirectUrl">
<v-btn color="primary" variant="flat" @click="confirmDesktopUpdate"
:loading="desktopUpdateInstalling"
:disabled="desktopUpdateChecking || desktopUpdateInstalling || !desktopUpdateHasNewVersion">
{{ t('core.common.dialog.confirmButton') }}
</v-btn>
</v-card-actions>
@@ -1,7 +1,26 @@
export {};
declare global {
interface AstrBotDesktopAppUpdateCheckResult {
ok: boolean;
reason?: string | null;
currentVersion?: string;
latestVersion?: string | null;
hasUpdate: boolean;
}
interface AstrBotDesktopAppUpdateResult {
ok: boolean;
reason?: string | null;
}
interface AstrBotAppUpdaterBridge {
checkForAppUpdate: () => Promise<AstrBotDesktopAppUpdateCheckResult>;
installAppUpdate: () => Promise<AstrBotDesktopAppUpdateResult>;
}
interface Window {
astrbotAppUpdater?: AstrBotAppUpdaterBridge;
astrbotDesktop?: {
isDesktop: boolean;
isDesktopRuntime: () => Promise<boolean>;
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "4.18.1"
version = "4.18.2"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
requires-python = ">=3.12"