Compare commits

..

6 Commits

Author SHA1 Message Date
NLKASHEI
029e9c84af 修复了DISCORD适配器注册命令正则过于严格的问题 (#9102)
* 修复了DISCORD适配器注册命令正则过于严格的问题

* Update astrbot/core/platform/sources/discord/discord_platform_adapter.py

# Discord 支持 Unicode 命令名(如中文),放宽匹配并确保全小写

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-07-02 11:36:38 +09:00
Cooper
3b41a870ff fix: updated reboot logic (#9073)
* Update updator.py

* test: cover Windows reboot process spawning

---------

Co-authored-by: 邹永赫 <1259085392@qq.com>
2026-07-02 11:33:47 +09:00
エイカク
b673cb375f fix: guard desktop-managed core restart (#9098) 2026-07-02 11:21:02 +09:00
Fiber
4cf210e503 fix: resample and downmix WAV files for Tencent Silk encoding (#9100)
* fix: resample and downmix WAV files for Tencent Silk encoding

* fix: improve WAV to Tencent Silk conversion by handling sample width and resampling
2026-07-01 22:33:36 +08:00
Simon He
372b9f5bfc fix: reduce markdown streaming lag (#9097)
* fix: reduce markdown streaming lag

* refactor: centralize markdown live node limit
2026-07-01 09:53:21 +09:00
Haoran Xu
41f8960302 fix: astrbot_file_read_tool returns clear error for directory path instead of misleading Permission denied (#9088)
When LLM passes a directory path to astrbot_file_read_tool, the tool previously
returned Error: [Errno 13] Permission denied, misleading the LLM into thinking
it was a permissions issue. The real cause: _probe_local_file() calls open('rb')
on the path, which fails on directories with Errno 13 on Windows. This is caught
by except PermissionError and displayed as-is.

Fix: Add os.path.isdir() check in FileReadTool.call() before any file I/O, at
the earliest safe point after path normalization and permission validation.
Returns a clear message: '<path> is a directory, not a file. Use a file path
instead, or use astrbot_execute_shell to list directory contents.'

Changes:
- astrbot/core/tools/computer_tools/fs.py: add isdir guard
- tests/test_computer_fs_tools.py: add test_file_read_tool_rejects_directory_with_clear_message
2026-06-30 20:39:50 +08:00
18 changed files with 413 additions and 72 deletions

View File

@@ -0,0 +1,10 @@
import os
DESKTOP_MANAGED_RESTART_MESSAGE = (
"AstrBot Desktop manages this backend process. Please restart or update from "
"the desktop app instead of the core WebUI."
)
def is_desktop_managed_backend() -> bool:
return os.environ.get("ASTRBOT_DESKTOP_MANAGED") == "1"

View File

@@ -565,7 +565,7 @@ class DiscordPlatformAdapter(Platform):
return None
# Discord 斜杠指令名称规范
if not re.match(r"^[a-z0-9_-]{1,32}$", cmd_name):
if cmd_name != cmd_name.lower() or not re.match(r"^[-_'\\w]{1,32}$", cmd_name):
logger.debug(f"[Discord] Skipping invalid slash command format: {cmd_name}")
return None

View File

@@ -304,6 +304,11 @@ class FileReadTool(FunctionTool):
)
if not normalized_path:
raise ValueError("`path` must be a non-empty string.")
if local_env and os.path.isdir(normalized_path):
return (
f"Error: '{normalized_path}' is a directory, not a file. "
"Use a file path instead, or use 'astrbot_execute_shell' to list directory contents."
)
offset, limit = self._validate_read_window(offset, limit)
sb = await get_booter(
context.context.context,

View File

@@ -1,4 +1,5 @@
import os
import subprocess
import sys
import time
import zipfile
@@ -8,6 +9,10 @@ import psutil
from astrbot.core import logger
from astrbot.core.config.default import VERSION
from astrbot.core.desktop_runtime import (
DESKTOP_MANAGED_RESTART_MESSAGE,
is_desktop_managed_backend,
)
from astrbot.core.utils.astrbot_path import get_astrbot_path
from astrbot.core.utils.io import ensure_dir
@@ -135,6 +140,11 @@ class AstrBotUpdator(RepoZipUpdator):
quoted_args = [f'"{arg}"' if " " in arg else arg for arg in argv[1:]]
os.execl(executable, quoted_executable, *quoted_args)
return
elif os.name == "nt":
subprocess.Popen(
[executable] + argv[1:], creationflags=subprocess.CREATE_NEW_CONSOLE
)
os._exit(0)
os.execv(executable, argv)
def _reboot(self, delay: int = 3) -> None:
@@ -142,6 +152,10 @@ class AstrBotUpdator(RepoZipUpdator):
在指定的延迟后,终止所有子进程并重新启动程序
这里只能使用 os.exec* 来重启程序
"""
if is_desktop_managed_backend():
logger.error(DESKTOP_MANAGED_RESTART_MESSAGE)
raise RuntimeError(DESKTOP_MANAGED_RESTART_MESSAGE)
time.sleep(delay)
self.terminate_child_processes()
executable = sys.executable

View File

@@ -1,6 +1,7 @@
"""Tencent Silk audio conversion helpers."""
import asyncio
import audioop
import os
import subprocess
import wave
@@ -8,6 +9,9 @@ from io import BytesIO
from astrbot.core import logger
# The SILK SDK only supports these rates
_PYSILK_SUPPORTED_RATES = frozenset({8000, 12000, 16000, 24000, 32000, 48000})
async def tencent_silk_to_wav(silk_path: str, output_path: str) -> str:
"""Decode a Tencent Silk file to 24 kHz mono PCM WAV.
@@ -69,8 +73,19 @@ async def wav_to_tencent_silk(wav_path: str, output_path: str) -> float:
with wave.open(wav_path, "rb") as wav:
rate = wav.getframerate()
frames = wav.getnframes()
pcm_data = wav.readframes(frames)
channels = wav.getnchannels()
sampwidth = wav.getsampwidth()
pcm_data = wav.readframes(wav.getnframes())
# Downmix to mono, resample to 24 kHz if needed, and convert to 16-bit PCM
# (pysilk only accepts 16-bit linear PCM)
if channels == 2:
pcm_data = audioop.tomono(pcm_data, sampwidth, 0.5, 0.5)
if rate not in _PYSILK_SUPPORTED_RATES:
pcm_data, _ = audioop.ratecv(pcm_data, sampwidth, 1, rate, 24000, None)
rate = 24000
if sampwidth != 2:
pcm_data = audioop.lin2lin(pcm_data, sampwidth, 2)
input_io = BytesIO(pcm_data)
output_io = BytesIO()
@@ -78,7 +93,7 @@ async def wav_to_tencent_silk(wav_path: str, output_path: str) -> float:
pysilk.encode(input_io, output_io, rate, rate, tencent=True)
with open(output_path, "wb") as f:
f.write(output_io.getvalue())
return frames / rate if rate else 0
return len(pcm_data) / (2 * rate) if rate else 0
async def convert_to_pcm_wav(input_path: str, output_path: str) -> str:

View File

@@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import JSONResponse
from astrbot.core import logger
from astrbot.core.desktop_runtime import DESKTOP_MANAGED_RESTART_MESSAGE
from astrbot.dashboard.async_utils import run_maybe_async
from astrbot.dashboard.schemas import PipInstallRequest, UpdateRequest
from astrbot.dashboard.services.update_service import (
@@ -58,6 +59,15 @@ def _service_response(result: UpdateServiceResult) -> JSONResponse:
def _service_error(exc: UpdateServiceError) -> JSONResponse:
logger.error(f"Dashboard update operation failed: {exc}", exc_info=True)
if exc.code == "desktop_managed":
return JSONResponse(
{
"status": "error",
"message": DESKTOP_MANAGED_RESTART_MESSAGE,
"data": None,
},
status_code=200,
)
return JSONResponse(
{"status": "error", "message": "An internal error has occurred.", "data": None},
status_code=200,

View File

@@ -21,6 +21,10 @@ from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.db import BaseDatabase
from astrbot.core.db.po import ProviderStat
from astrbot.core.desktop_runtime import (
DESKTOP_MANAGED_RESTART_MESSAGE,
is_desktop_managed_backend,
)
from astrbot.core.utils.astrbot_path import get_astrbot_path
from astrbot.core.utils.auth_password import (
is_default_dashboard_password,
@@ -57,6 +61,9 @@ class StatService:
raise StatServiceError(
"You are not permitted to do this operation in demo mode"
)
if is_desktop_managed_backend():
raise StatServiceError(DESKTOP_MANAGED_RESTART_MESSAGE)
await self.core_lifecycle.restart()
@staticmethod

View File

@@ -15,6 +15,10 @@ from astrbot.core import logger
from astrbot.core import pip_installer as _pip_installer
from astrbot.core.config.default import VERSION
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.desktop_runtime import (
DESKTOP_MANAGED_RESTART_MESSAGE,
is_desktop_managed_backend,
)
from astrbot.core.updator import AstrBotUpdator
from astrbot.core.utils.astrbot_path import (
get_astrbot_data_path,
@@ -67,7 +71,9 @@ class UpdateServiceResult:
class UpdateServiceError(Exception):
pass
def __init__(self, message: str, *, code: str | None = None) -> None:
super().__init__(message)
self.code = code
class UpdateService:
@@ -143,6 +149,12 @@ class UpdateService:
raise UpdateServiceError(exc.__str__()) from exc
async def update_project(self, data: object) -> UpdateServiceResult:
if is_desktop_managed_backend():
raise UpdateServiceError(
DESKTOP_MANAGED_RESTART_MESSAGE,
code="desktop_managed",
)
payload = data if isinstance(data, dict) else {}
version = payload.get("version", "")
reboot = payload.get("reboot", True)

View File

@@ -31,14 +31,14 @@
"katex": "^0.16.27",
"lodash": "4.17.23",
"markdown-it": "^14.1.1",
"markstream-vue": "1.0.1-beta.1",
"markstream-vue": "1.0.5-beta.0",
"mermaid": "^11.12.2",
"monaco-editor": "^0.52.2",
"pinia": "2.1.6",
"pinyin-pro": "^3.26.0",
"qrcode": "^1.5.4",
"shiki": "^3.20.0",
"stream-markdown": "^0.0.15",
"shiki": "^3.23.0",
"stream-markdown": "^0.0.16",
"vee-validate": "4.11.3",
"vite-plugin-vuetify": "2.1.3",
"vue": "3.3.4",

135
dashboard/pnpm-lock.yaml generated
View File

@@ -58,8 +58,8 @@ importers:
specifier: ^14.1.1
version: 14.1.1
markstream-vue:
specifier: 1.0.1-beta.1
version: 1.0.1-beta.1(katex@0.16.28)(mermaid@11.12.2)(stream-markdown@0.0.15(shiki@3.22.0)(vue@3.3.4))(vue-i18n@11.2.8(vue@3.3.4))(vue@3.3.4)
specifier: 1.0.5-beta.0
version: 1.0.5-beta.0(katex@0.16.28)(mermaid@11.12.2)(stream-markdown@0.0.16(shiki@3.23.0)(vue@3.3.4))(vue-i18n@11.2.8(vue@3.3.4))(vue@3.3.4)
mermaid:
specifier: ^11.12.2
version: 11.12.2
@@ -76,11 +76,11 @@ importers:
specifier: ^1.5.4
version: 1.5.4
shiki:
specifier: ^3.20.0
version: 3.22.0
specifier: ^3.23.0
version: 3.23.0
stream-markdown:
specifier: ^0.0.15
version: 0.0.15(shiki@3.22.0)(vue@3.3.4)
specifier: ^0.0.16
version: 0.0.16(shiki@3.23.0)(vue@3.3.4)
vee-validate:
specifier: 4.11.3
version: 4.11.3(vue@3.3.4)
@@ -676,21 +676,27 @@ packages:
'@shikijs/core@3.22.0':
resolution: {integrity: sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA==}
'@shikijs/engine-javascript@3.22.0':
resolution: {integrity: sha512-jdKhfgW9CRtj3Tor0L7+yPwdG3CgP7W+ZEqSsojrMzCjD1e0IxIbwUMDDpYlVBlC08TACg4puwFGkZfLS+56Tw==}
'@shikijs/core@3.23.0':
resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==}
'@shikijs/engine-oniguruma@3.22.0':
resolution: {integrity: sha512-DyXsOG0vGtNtl7ygvabHd7Mt5EY8gCNqR9Y7Lpbbd/PbJvgWrqaKzH1JW6H6qFkuUa8aCxoiYVv8/YfFljiQxA==}
'@shikijs/engine-javascript@3.23.0':
resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==}
'@shikijs/langs@3.22.0':
resolution: {integrity: sha512-x/42TfhWmp6H00T6uwVrdTJGKgNdFbrEdhaDwSR5fd5zhQ1Q46bHq9EO61SCEWJR0HY7z2HNDMaBZp8JRmKiIA==}
'@shikijs/engine-oniguruma@3.23.0':
resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==}
'@shikijs/themes@3.22.0':
resolution: {integrity: sha512-o+tlOKqsr6FE4+mYJG08tfCFDS+3CG20HbldXeVoyP+cYSUxDhrFf3GPjE60U55iOkkjbpY2uC3It/eeja35/g==}
'@shikijs/langs@3.23.0':
resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==}
'@shikijs/themes@3.23.0':
resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==}
'@shikijs/types@3.22.0':
resolution: {integrity: sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg==}
'@shikijs/types@3.23.0':
resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==}
'@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
@@ -2134,6 +2140,9 @@ packages:
linkify-it@5.0.0:
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
linkify-it@5.0.1:
resolution: {integrity: sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==}
loader-runner@4.3.1:
resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==}
engines: {node: '>=6.11.5'}
@@ -2187,8 +2196,8 @@ packages:
markdown-it-task-checkbox@1.0.6:
resolution: {integrity: sha512-7pxkHuvqTOu3iwVGmDPeYjQg+AIS9VQxzyLP9JCg9lBjgPAJXGEkChK6A2iFuj3tS0GV3HG2u5AMNhcQqwxpJw==}
markdown-it-ts@1.0.0:
resolution: {integrity: sha512-hQT/yCYryC3jNs2wJ35R4m1zKcBxNuFaKCGzwpmq2OuMXMNbUK1oTwCxONIjy5lXWWG1UCNbGXe1nbTiWbH/iA==}
markdown-it-ts@1.0.2:
resolution: {integrity: sha512-zba9mN313K2HmKk+BOHqkO/nuZtj9M1TTnUlSbItGrCMpYzc8OHGCm+IaqxWCi2pGcgpiFC8ltxkasYWYpp/YQ==}
engines: {node: '>=18'}
markdown-it@14.1.1:
@@ -2200,18 +2209,18 @@ packages:
engines: {node: '>= 20'}
hasBin: true
markstream-core@1.0.0:
resolution: {integrity: sha512-V39W0rPgJ5Yj/XEl11LaKQxX9dYOI34RL729Zoi9oyy/Y6z6H+PJOsBFktu7gU9ZZxCsevWMb/Re2LgSKbWfRg==}
markstream-core@1.0.3:
resolution: {integrity: sha512-QXn+yERo1q+RD6YlGDW61zc/af65uhkQEH3K8YvEKkprHbgRJ/JIeBeEQjELCTyBnPoxqtKQ7I565rylY+PePg==}
markstream-vue@1.0.1-beta.1:
resolution: {integrity: sha512-45br3sbOQirIg0tmPMWRmdWk/vLE5aJtVx7cvLaa3e+klyYpILzQ7c4eOMq2EZkQTVewJhZHUVc2FTj+ujUJPg==}
markstream-vue@1.0.5-beta.0:
resolution: {integrity: sha512-/gmcNKa7v6qqmwQ/JAn3sWUoiM+EaMkvS0X5YRgqD74d+G22dzua4X8ZViYpbDGR/bl7BTw3juzsRfC0dm15pw==}
peerDependencies:
'@antv/infographic': ^0.2.3
'@terrastruct/d2': '>=0.1.33'
katex: '>=0.16.22'
mermaid: '>=11'
stream-markdown: '>=0.0.15'
stream-monaco: '>=0.0.41'
stream-markdown: '>=0.0.16'
stream-monaco: '>=0.0.45'
vue: '>=3.0.0'
vue-i18n: '>=9'
peerDependenciesMeta:
@@ -2740,8 +2749,8 @@ packages:
vue:
optional: true
shiki@3.22.0:
resolution: {integrity: sha512-LBnhsoYEe0Eou4e1VgJACes+O6S6QC0w71fCSp5Oya79inkwkm15gQ1UF6VtQ8j/taMDh79hAB49WUk8ALQW3g==}
shiki@3.23.0:
resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==}
slash@3.0.0:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
@@ -2764,13 +2773,13 @@ packages:
state-local@1.0.7:
resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==}
stream-markdown-parser@1.0.0:
resolution: {integrity: sha512-uOYQ8G9YFFtGM726fK77O/mJPYrwYHPy0DKBtk7bCPmkf06XwDVSOAFRTo2FsXnZ3vAaPBtxOgNGJ0CGieBcSQ==}
stream-markdown-parser@1.0.8:
resolution: {integrity: sha512-+Oo9ik7BtMBxf+Krh19YA0jmk+TIUxfULU5qQpWHo3Z4SBX74/Qwt6lVHcUvEcUQcd1kV35VNmw1SYdhBY4aOA==}
stream-markdown@0.0.15:
resolution: {integrity: sha512-1WlzjZUb9W5BWZYMKCr2/exPVh5P7HIhHzkcYZczkXm0upiuN4zEddwjdckL+WSQWGGlv9bboXCqcTYCEgqexw==}
stream-markdown@0.0.16:
resolution: {integrity: sha512-2WoOxlpc3N5RLc3zGuW+g/w76z6ketWBY0N1YzDYbXds1qw7zrUBv5PTQ3DOtpqgCjLuF3P2iCfjJTEqWGv0NQ==}
peerDependencies:
shiki: '>=3.13.0'
shiki: '>=3.23.0'
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
@@ -3599,30 +3608,42 @@ snapshots:
'@types/hast': 3.0.4
hast-util-to-html: 9.0.5
'@shikijs/engine-javascript@3.22.0':
'@shikijs/core@3.23.0':
dependencies:
'@shikijs/types': 3.22.0
'@shikijs/types': 3.23.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
hast-util-to-html: 9.0.5
'@shikijs/engine-javascript@3.23.0':
dependencies:
'@shikijs/types': 3.23.0
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.4
'@shikijs/engine-oniguruma@3.22.0':
'@shikijs/engine-oniguruma@3.23.0':
dependencies:
'@shikijs/types': 3.22.0
'@shikijs/types': 3.23.0
'@shikijs/vscode-textmate': 10.0.2
'@shikijs/langs@3.22.0':
'@shikijs/langs@3.23.0':
dependencies:
'@shikijs/types': 3.22.0
'@shikijs/types': 3.23.0
'@shikijs/themes@3.22.0':
'@shikijs/themes@3.23.0':
dependencies:
'@shikijs/types': 3.22.0
'@shikijs/types': 3.23.0
'@shikijs/types@3.22.0':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/types@3.23.0':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/vscode-textmate@10.0.2': {}
'@tiptap/core@2.27.2(@tiptap/pm@2.27.2)':
@@ -5268,6 +5289,10 @@ snapshots:
dependencies:
uc.micro: 2.1.0
linkify-it@5.0.1:
dependencies:
uc.micro: 2.1.0
loader-runner@4.3.1: {}
loader-utils@2.0.4:
@@ -5312,12 +5337,12 @@ snapshots:
markdown-it-task-checkbox@1.0.6: {}
markdown-it-ts@1.0.0:
markdown-it-ts@1.0.2:
dependencies:
'@types/linkify-it': 5.0.0
'@types/mdurl': 2.0.0
entities: 4.5.0
linkify-it: 5.0.0
linkify-it: 5.0.1
mdurl: 2.0.0
punycode.js: 2.3.1
uc.micro: 2.1.0
@@ -5333,19 +5358,19 @@ snapshots:
marked@16.4.2: {}
markstream-core@1.0.0: {}
markstream-core@1.0.3: {}
markstream-vue@1.0.1-beta.1(katex@0.16.28)(mermaid@11.12.2)(stream-markdown@0.0.15(shiki@3.22.0)(vue@3.3.4))(vue-i18n@11.2.8(vue@3.3.4))(vue@3.3.4):
markstream-vue@1.0.5-beta.0(katex@0.16.28)(mermaid@11.12.2)(stream-markdown@0.0.16(shiki@3.23.0)(vue@3.3.4))(vue-i18n@11.2.8(vue@3.3.4))(vue@3.3.4):
dependencies:
'@chenglou/pretext': 0.0.5
'@floating-ui/dom': 1.7.6
markstream-core: 1.0.0
stream-markdown-parser: 1.0.0
markstream-core: 1.0.3
stream-markdown-parser: 1.0.8
vue: 3.3.4
optionalDependencies:
katex: 0.16.28
mermaid: 11.12.2
stream-markdown: 0.0.15(shiki@3.22.0)(vue@3.3.4)
stream-markdown: 0.0.16(shiki@3.23.0)(vue@3.3.4)
vue-i18n: 11.2.8(vue@3.3.4)
math-intrinsics@1.1.0: {}
@@ -5885,14 +5910,14 @@ snapshots:
optionalDependencies:
vue: 3.3.4
shiki@3.22.0:
shiki@3.23.0:
dependencies:
'@shikijs/core': 3.22.0
'@shikijs/engine-javascript': 3.22.0
'@shikijs/engine-oniguruma': 3.22.0
'@shikijs/langs': 3.22.0
'@shikijs/themes': 3.22.0
'@shikijs/types': 3.22.0
'@shikijs/core': 3.23.0
'@shikijs/engine-javascript': 3.23.0
'@shikijs/engine-oniguruma': 3.23.0
'@shikijs/langs': 3.23.0
'@shikijs/themes': 3.23.0
'@shikijs/types': 3.23.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
@@ -5911,7 +5936,7 @@ snapshots:
state-local@1.0.7: {}
stream-markdown-parser@1.0.0:
stream-markdown-parser@1.0.8:
dependencies:
markdown-it-container: 4.0.0
markdown-it-footnote: 4.0.0
@@ -5920,11 +5945,11 @@ snapshots:
markdown-it-sub: 2.0.0
markdown-it-sup: 2.0.0
markdown-it-task-checkbox: 1.0.6
markdown-it-ts: 1.0.0
markdown-it-ts: 1.0.2
stream-markdown@0.0.15(shiki@3.22.0)(vue@3.3.4):
stream-markdown@0.0.16(shiki@3.23.0)(vue@3.3.4):
dependencies:
shiki: 3.22.0
shiki: 3.23.0
shiki-stream: 0.1.4(vue@3.3.4)
transitivePeerDependencies:
- react

View File

@@ -8,13 +8,14 @@
:smooth-streaming="isStreaming ? 'auto' : false"
:fade="false"
:typewriter="false"
:max-live-nodes="0"
:max-live-nodes="MARKDOWN_RENDER_MAX_LIVE_NODES"
/>
</template>
<script setup lang="ts">
import { computed, provide } from "vue";
import { MarkdownRender } from "markstream-vue";
import { MARKDOWN_RENDER_MAX_LIVE_NODES } from "@/components/chat/markdownRenderConfig";
import type { ChatThread } from "@/composables/useMessages";
const props = defineProps<{

View File

@@ -0,0 +1 @@
export const MARKDOWN_RENDER_MAX_LIVE_NODES = 320;

View File

@@ -9,7 +9,7 @@
:smooth-streaming="isStreaming ? 'auto' : false"
:fade="false"
:typewriter="false"
:max-live-nodes="0"
:max-live-nodes="MARKDOWN_RENDER_MAX_LIVE_NODES"
/>
</div>
</template>
@@ -17,6 +17,7 @@
<script setup lang="ts">
import { computed, provide } from "vue";
import { MarkdownRender } from "markstream-vue";
import { MARKDOWN_RENDER_MAX_LIVE_NODES } from "@/components/chat/markdownRenderConfig";
const props = defineProps<{
content: string;

View File

@@ -27,7 +27,7 @@
:fade="false"
:typewriter="false"
:is-dark="isDark"
:max-live-nodes="0"
:max-live-nodes="MARKDOWN_RENDER_MAX_LIVE_NODES"
/>
<div v-else-if="entry.tool" class="reasoning-tool-call-block">
@@ -62,6 +62,7 @@
<script setup lang="ts">
import { computed } from "vue";
import { MarkdownRender } from "markstream-vue";
import { MARKDOWN_RENDER_MAX_LIVE_NODES } from "@/components/chat/markdownRenderConfig";
import IPythonToolBlock from "@/components/chat/message_list_comps/IPythonToolBlock.vue";
import ToolCallCard from "@/components/chat/message_list_comps/ToolCallCard.vue";
import ToolCallItem from "@/components/chat/message_list_comps/ToolCallItem.vue";

View File

@@ -620,3 +620,23 @@ async def test_grep_tool_applies_result_limit(
assert "match-2" in result
assert "match-3" not in result
assert "[Truncated to first 2 result groups.]" in result
@pytest.mark.asyncio
async def test_file_read_tool_rejects_directory_with_clear_message(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
):
"""FileReadTool should return a helpful message when given a directory path."""
workspace = _setup_local_fs_tools(monkeypatch, tmp_path)
subdir = workspace / "my-directory"
subdir.mkdir()
result = await fs_tools.FileReadTool().call(
_make_context(),
path="my-directory",
)
assert "is a directory, not a file" in result
assert "my-directory" in result
assert "'astrbot_execute_shell'" in result

View File

@@ -20,6 +20,7 @@ from werkzeug.datastructures import FileStorage
from astrbot.core import LogBroker
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.db.sqlite import SQLiteDatabase
from astrbot.core.desktop_runtime import DESKTOP_MANAGED_RESTART_MESSAGE
from astrbot.core.star.star import StarMetadata, star_registry
from astrbot.core.star.star_handler import star_handlers_registry
from astrbot.core.utils.auth_password import (
@@ -2662,6 +2663,35 @@ async def test_check_update(
assert data["data"]["has_new_version"] is False
@pytest.mark.asyncio
async def test_restart_core_rejects_desktop_managed_backend(
app: FastAPIAppAdapter,
authenticated_header: dict,
core_lifecycle_td: AstrBotCoreLifecycle,
monkeypatch,
):
test_client = app.test_client()
restart_called = False
async def mock_restart():
nonlocal restart_called
restart_called = True
monkeypatch.setenv("ASTRBOT_DESKTOP_MANAGED", "1")
monkeypatch.setattr(core_lifecycle_td, "restart", mock_restart)
response = await test_client.post(
"/api/stat/restart-core",
headers=authenticated_header,
)
assert response.status_code == 400
data = await response.get_json()
assert data["status"] == "error"
assert data["message"] == DESKTOP_MANAGED_RESTART_MESSAGE
assert restart_called is False
@pytest.mark.asyncio
async def test_do_update(
app: FastAPIAppAdapter,
@@ -2826,6 +2856,44 @@ async def test_do_update_does_not_apply_files_when_core_download_fails(
assert calls == ["download-dashboard", "download-core"]
@pytest.mark.asyncio
async def test_do_update_rejects_desktop_managed_backend(
app: FastAPIAppAdapter,
authenticated_header: dict,
core_lifecycle_td: AstrBotCoreLifecycle,
monkeypatch,
):
test_client = app.test_client()
calls = []
async def mock_download_core(*args, **kwargs):
del args, kwargs
calls.append("download-core")
async def mock_restart():
calls.append("restart")
monkeypatch.setenv("ASTRBOT_DESKTOP_MANAGED", "1")
monkeypatch.setattr(
core_lifecycle_td.astrbot_updator,
"download_update_package",
mock_download_core,
)
monkeypatch.setattr(core_lifecycle_td, "restart", mock_restart)
response = await test_client.post(
"/api/update/do",
headers=authenticated_header,
json={"version": "v3.4.0", "progress_id": "desktop-progress"},
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "error"
assert data["message"] == DESKTOP_MANAGED_RESTART_MESSAGE
assert calls == []
@pytest.mark.asyncio
async def test_do_update_does_not_apply_files_when_package_verification_fails(
app: FastAPIAppAdapter,

View File

@@ -2,6 +2,7 @@ import base64
import math
import os
import struct
import sys
import wave
from io import BytesIO
from pathlib import Path
@@ -653,19 +654,39 @@ def test_path_mapping_accepts_standard_and_legacy_file_uri(tmp_path):
@pytest.mark.asyncio
async def test_tencent_silk_encoding_uses_pysilk_tencent_format(tmp_path, monkeypatch):
@pytest.mark.parametrize(
"rate, channels",
[
(24000, 1), # supported, no resample
(44100, 1), # unsupported rate, triggers resample
(22050, 1), # unsupported rate, triggers resample
(48000, 2), # stereo at supported rate, triggers downmix
(44100, 2), # stereo + unsupported rate, triggers both
],
ids=["24k-mono", "44.1k-mono", "22.05k-mono", "48k-stereo", "44.1k-stereo"],
)
async def test_tencent_silk_encoding_uses_pysilk_tencent_format(
rate, channels, tmp_path, monkeypatch
):
"""Real pysilk end-to-end across sample rates that previously failed.
44100 Hz was the regression trigger: pysilk rejects it with
ENC_INPUT_INVALID_NO_OF_SAMPLES. The fix resamples to 24 kHz mono via
audioop.ratecv before encoding.
"""
monkeypatch.setattr(media_utils, "get_astrbot_temp_path", lambda: str(tmp_path))
wav_path = tmp_path / "tone.wav"
silk_path = tmp_path / "tone.silk"
rate = 24000
frames = int(rate * 0.2)
secs = 0.2
frames = int(rate * secs)
with wave.open(str(wav_path), "wb") as wav:
wav.setnchannels(1)
wav.setnchannels(channels)
wav.setsampwidth(2)
wav.setframerate(rate)
for i in range(frames):
sample = int(0.2 * 32767 * math.sin(2 * math.pi * 440 * i / rate))
wav.writeframesraw(struct.pack("<h", sample))
for _ in range(channels):
wav.writeframesraw(struct.pack("<h", sample))
duration = await wav_to_tencent_silk(str(wav_path), str(silk_path))
silk_bytes = silk_path.read_bytes()
@@ -679,7 +700,82 @@ async def test_tencent_silk_encoding_uses_pysilk_tencent_format(tmp_path, monkey
assert resolved.format == "tencent_silk"
assert resolved.mime_type == "audio/silk"
assert duration == pytest.approx(0.2)
assert duration == pytest.approx(secs, abs=0.05)
assert silk_bytes.startswith(b"\x02#!SILK_V3")
assert resolved_silk_bytes.startswith(b"\x02#!SILK_V3")
assert not resolved_silk_path.exists()
def _make_wav(path, rate, channels=1, secs=0.2, freq=440):
"""Write a short sine-tone WAV at the given rate/channels."""
nframes = int(rate * secs)
with wave.open(str(path), "wb") as wav:
wav.setnchannels(channels)
wav.setsampwidth(2)
wav.setframerate(rate)
for i in range(nframes):
sample = int(0.2 * 32767 * math.sin(2 * math.pi * freq * i / rate))
for _ in range(channels):
wav.writeframesraw(struct.pack("<h", sample))
class _FakePysilk:
"""Stand-in for the ``pysilk`` module that records encode() calls."""
def __init__(self):
self.calls = []
def encode(self, input_io, output_io, sample_rate, bit_rate, tencent=True):
self.calls.append({"sample_rate": sample_rate, "tencent": tencent})
output_io.write(b"\x02#!SILK_V3")
@pytest.mark.asyncio
async def test_wav_to_tencent_silk_resamples_unsupported_rate(tmp_path, monkeypatch):
"""44100 Hz input must be resampled to 24 kHz before pysilk.encode."""
fake = _FakePysilk()
monkeypatch.setitem(sys.modules, "pysilk", fake)
wav_path = tmp_path / "tts_44100.wav"
_make_wav(wav_path, 44100)
silk_path = tmp_path / "out.silk"
await wav_to_tencent_silk(str(wav_path), str(silk_path))
assert len(fake.calls) == 1
assert fake.calls[0]["sample_rate"] == 24000
assert fake.calls[0]["tencent"] is True
assert silk_path.read_bytes().startswith(b"\x02#!SILK_V3")
@pytest.mark.asyncio
async def test_wav_to_tencent_silk_resamples_stereo(tmp_path, monkeypatch):
"""Stereo input at a supported rate must still be downmixed to mono."""
fake = _FakePysilk()
monkeypatch.setitem(sys.modules, "pysilk", fake)
wav_path = tmp_path / "stereo_48k.wav"
_make_wav(wav_path, 48000, channels=2)
await wav_to_tencent_silk(str(wav_path), str(tmp_path / "out.silk"))
assert len(fake.calls) == 1
# 48000 Hz is supported, so only downmix happens -- rate stays unchanged.
assert fake.calls[0]["sample_rate"] == 48000
@pytest.mark.asyncio
async def test_wav_to_tencent_silk_skips_resample_for_supported_rate(
tmp_path, monkeypatch
):
"""24000 Hz mono must go straight to pysilk without resampling."""
fake = _FakePysilk()
monkeypatch.setitem(sys.modules, "pysilk", fake)
wav_path = tmp_path / "tone_24k.wav"
_make_wav(wav_path, 24000)
await wav_to_tencent_silk(str(wav_path), str(tmp_path / "out.silk"))
assert len(fake.calls) == 1
assert fake.calls[0]["sample_rate"] == 24000

View File

@@ -10,6 +10,7 @@ import certifi
import httpx
import pytest
from astrbot.core import updator as core_updator
from astrbot.core.star.updator import PluginUpdator
from astrbot.core.updator import AstrBotUpdator
from astrbot.core.utils import io as io_utils
@@ -80,6 +81,60 @@ class _FakeStatusErrorResponse:
)
def test_astrbot_updator_exec_reboot_spawns_new_console_on_windows(
monkeypatch: pytest.MonkeyPatch,
):
popen_calls = []
exit_codes = []
execv_calls = []
def fake_popen(args, creationflags=0):
popen_calls.append((args, creationflags))
return SimpleNamespace(pid=1234)
def fake_exit(code):
exit_codes.append(code)
raise SystemExit(code)
def fake_execv(*args):
execv_calls.append(args)
monkeypatch.setattr(core_updator.os, "name", "nt")
monkeypatch.setattr(core_updator.sys, "frozen", False, raising=False)
monkeypatch.setattr(
core_updator.subprocess, "CREATE_NEW_CONSOLE", 0x00000010, raising=False
)
monkeypatch.setattr(core_updator.subprocess, "Popen", fake_popen)
monkeypatch.setattr(core_updator.os, "_exit", fake_exit)
monkeypatch.setattr(core_updator.os, "execv", fake_execv)
with pytest.raises(SystemExit) as exc_info:
AstrBotUpdator._exec_reboot(
r"C:\Python312\python.exe",
[
r"C:\Python312\python.exe",
"main.py",
"--webui-dir",
r"C:\AstrBot WebUI\dist",
],
)
assert exc_info.value.code == 0
assert popen_calls == [
(
[
r"C:\Python312\python.exe",
"main.py",
"--webui-dir",
r"C:\AstrBot WebUI\dist",
],
core_updator.subprocess.CREATE_NEW_CONSOLE,
)
]
assert exit_codes == [0]
assert execv_calls == []
@dataclass
class _FakeAsyncClientState:
json_payload: object = field(default_factory=list)