Compare commits

..

25 Commits

Author SHA1 Message Date
Soulter
fddfbb946c fix: wrap command in parentheses for background output redirection 2026-04-28 23:25:28 +08:00
Soulter
451d5450ee fix: reorder import statements in shell.py for consistency 2026-04-28 23:24:19 +08:00
Weilong Liao
33eeed1739 Merge branch 'master' into perf/shell-background 2026-04-28 23:23:21 +08:00
エイカク
2f33c34b5c fix: protect desktop plugin installs with core lock (#7872) 2026-04-28 21:10:19 +09:00
Weilong Liao
d8de0035a9 feat: add attachment saved event handling in chat and live chat routes (#7869)
Co-authored-by: Zhilan615 <2864095951@qq.com>
2026-04-28 17:14:40 +08:00
Soulter
1801834cac fix: remove BOM from install.ps1 file 2026-04-28 15:34:33 +08:00
Soulter
4d9340c216 feat: add deploy scripts for Windows and Linux installation, remove copy-deploy-cli script 2026-04-28 15:05:35 +08:00
dependabot[bot]
9016a3b2c4 chore(deps): bump pnpm/action-setup in the github-actions group (#7857)
Bumps the github-actions group with 1 update: [pnpm/action-setup](https://github.com/pnpm/action-setup).


Updates `pnpm/action-setup` from 5.0.0 to 6.0.3
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/v5.0.0...v6.0.3)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.3
  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-04-28 14:57:31 +08:00
Soulter
e4a9274b41 fix: update .gitignore and copy-deploy-cli script paths for public directory 2026-04-28 14:56:44 +08:00
EterUltimate
e218620a37 feat: add one-line deploy script (deploy-cli.sh) (#7631)
* feat: add one-line deploy script (deploy-cli.sh) and update cli.md

- Add docs/scripts/deploy-cli.sh for one-command deployment (Linux/macOS/WSL)
- Update docs/zh/deploy/astrbot/cli.md to reference local script
- Replace non-existent https://astrbot.app/deploy.sh with raw.githubusercontent.com URL
- Add local run tip and WSL-based Windows support

* fix: address PR review feedback for deploy-cli.sh

- Replace sort -V with Python-native version check (macOS BSD compat)
- Bump Python requirement from >=3.10 to >=3.12 (match pyproject.toml)
- Detect current directory as project root (avoid nested clone)
- Handle existing non-git directory explicitly
- Add curl dependency check
- Support ASTRBOT_REPO and ASTRBOT_DIR env vars for forks/mirrors
- Update cli.md version description to match

* feat: add Windows PowerShell deploy script and update docs

- Add deploy-cli.ps1 for Windows native environment (PowerShell 7+)
- Update cli.md to document Windows one-liner deployment
- Add local script execution instructions for both bash and ps1

* refactor: standardize log messages and improve clarity in various modules

Co-authored-by: Copilot <copilot@github.com>

* docs: 移除一行命令快速部署部分,简化安装说明

* feat: 添加脚本以复制部署 CLI 文件并更新构建命令

* refactor: 更新日志消息以提高可读性,统一英文提示信息

---------

Co-authored-by: Soulter <905617992@qq.com>
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Weilong Liao <37870767+Soulter@users.noreply.github.com>
2026-04-28 14:49:50 +08:00
エイカク
cb5c172e69 feat: add CUA computer-use sandbox support (#7828)
* feat: add CUA computer-use sandbox support

* fix: add CUA config metadata translations

* fix: address CUA sandbox review feedback

* fix: default CUA sandbox to local mode

* fix: harden CUA SDK method compatibility

* fix: harden CUA GUI and permission handling

* fix: refine CUA capability and shell handling

* fix: avoid inline CUA screenshot image results by default

* fix: guide CUA browser startup workflow

* feat: add CUA browser and key press tools

* fix: launch CUA browser as sandbox user

* fix: stabilize CUA browser screenshots

* fix: simplify CUA browser launch command

* fix: remove CUA open browser tool

* fix: align CUA desktop control guidance

* fix: harden CUA shell background handling

* fix: harden CUA runtime adapters

* fix: surface CUA filesystem failures

* fix: clarify CUA shell fallback support

* fix: harden CUA shell helpers

* fix: guard CUA file fallbacks

* fix: redact sensitive config log paths

* fix: guard CUA download fallback

* test: cover CUA GUI and shell env wiring

* fix: preserve CUA command result output

* fix: normalize CUA return codes

* fix: preserve foreground shell behavior

* fix: clean up failed CUA boots

* docs: add CUA sandbox runtime guide

* test: cover CUA GUI tool registration

* refactor: simplify CUA fallback handling

* refactor: simplify CUA shell helpers

* test: cover CUA screenshot result shapes
2026-04-28 01:40:14 +09:00
Soulter
3f52c7aaa0 test(shell): remove obsolete test for background shell command output redirection 2026-04-27 22:29:45 +08:00
Soulter
44e31bd49c feat(shell): implement background command execution with detached shell command support
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 22:27:18 +08:00
Soulter
ffa7508a8b feat(shell): reorder timeout parameter in ExecuteShellTool configuration 2026-04-27 22:10:41 +08:00
Soulter
988961a9a4 feat(shell): set default timeout to 300s for shell execution 2026-04-27 22:08:48 +08:00
Soulter
d7a54aed76 feat(shell): set default timeout for shell execution to 10,000,000 milliseconds 2026-04-27 22:05:47 +08:00
Soulter
f1206d987b feat(shell): update timeout parameter to be optional in shell execution methods 2026-04-27 21:59:49 +08:00
エイカク
67c7445d25 fix: prevent IME enter from sending chat (#7845)
* fix: prevent IME enter from sending chat

* fix: prevent IME enter from sending chat

* refactor: clarify IME composition state handling
2026-04-27 22:56:24 +09:00
Weilong Liao
72d65680b8 docs: add pre-commit setup guide to AGENTS.md (#7838)
* fix(dashboard): add tooltip for truncated command/tool descriptions in WebUI

- CommandTable.vue: add :title binding to description div
- ToolTable.vue: add :title binding to description and origin_name divs

Fixes #7583 - Webui中超出显示长度的指令描述无法以任何方式看到

* docs: add pre-commit setup guide to AGENTS.md

Extract the pre-commit and ruff setup instructions from README.md
into AGENTS.md so AI agents have a complete reference for
setting up the development environment.

---------

Co-authored-by: AstrBot Fixer <astrbot@fix-bot.local>
Co-authored-by: AstrBot Fixer <astrbot-fixer@users.noreply.github.com>
2026-04-27 21:42:56 +08:00
時壹
b711425b73 feat: add message-level markdown control for QQ Official platform (#6980)
* feat: add message-level markdown control for QQ Official platform

* feat: propagate MessageChain metadata through RespondStage chain splitting
2026-04-27 21:21:56 +08:00
若月千鸮
72f4e748e8 fix: restore T2I text template rendering (#7789)
* fix: restore T2I text template rendering

- keep using {{ text | safe }} instead of text_base64
- inject Shiki runtime by default for T2I templates
- update built-in templates to read markdown from a hidden textarea
- improve WebUI preview sample text and Shiki runtime serving
- add regression tests for template rendering and runtime injection

* fix: prevent injected Shiki runtime from breaking T2I templates

* fix(t2i): restore raw text template rendering

* test(t2i): remove test

* fix(t2i): restore previewText
2026-04-27 15:38:43 +08:00
Soulter
77040cabcd feat(shell): add background command execution with output redirection and timeout support 2026-04-27 13:43:55 +08:00
Soulter
09ab45fcb5 chore: bump version to 4.23.6 2026-04-27 13:05:20 +08:00
Weilong Liao
1efe4fd60e fix(stats): TPM now only counts output tokens (#7827)
* fix(stats): TPM now only counts output tokens

- Add range_total_output_tokens accumulation, separate from total tokens
- Change range_avg_tpm formula to use output tokens only
- Update i18n labels to reflect Output TPM

* fix(stats): range
2026-04-27 12:59:44 +08:00
Weilong Liao
c5ab4f7263 feat: add /stats command to view conversation token usage (#7831)
* feat: add /stats command to view conversation token usage

- Add stats() method to ConversationCommands that queries ProviderStat
  records by conversation_id and aggregates token breakdowns
- Register /stats command in main.py

* feat: reorder conversation stats output for better readability

Co-authored-by: Copilot <copilot@github.com>

* feat: reorder token usage output for improved clarity

* feat: enhance stats command to aggregate conversation token usage

* feat: add cached input tokens display and update translations for clarity

* feat: update stats command to clarify conversation token usage display

---------

Co-authored-by: Copilot <copilot@github.com>
2026-04-27 12:59:21 +08:00
77 changed files with 4289 additions and 170 deletions

View File

@@ -13,7 +13,7 @@ jobs:
- name: checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v5.0.0
uses: pnpm/action-setup@v6.0.3
with:
version: 10.28.2
- name: Setup Node.js

View File

@@ -15,7 +15,7 @@ jobs:
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v5.0.0
uses: pnpm/action-setup@v6.0.3
with:
version: 10.28.2

View File

@@ -51,7 +51,7 @@ jobs:
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Setup pnpm
uses: pnpm/action-setup@v5.0.0
uses: pnpm/action-setup@v6.0.3
with:
version: 10.28.2

View File

@@ -19,6 +19,26 @@ pnpm dev
Runs on `http://localhost:3000` by default.
## Pre-commit setup
AstrBot uses [pre-commit](https://pre-commit.com/) hooks to automatically format and lint Python code before each commit. The hooks run `ruff check`, `ruff format`, and `pyupgrade` (see [`.pre-commit-config.yaml`](.pre-commit-config.yaml) for details).
To set it up:
```bash
pip install pre-commit
pre-commit install
```
After installation, the hooks will run automatically on `git commit`. You can also run them manually at any time:
```bash
ruff format .
ruff check .
```
> **Note:** If you use VSCode, install the `Ruff` extension for real-time formatting and linting in the editor.
## Dev environment tips
1. When modifying the WebUI, be sure to maintain componentization and clean code. Avoid duplicate code.

View File

@@ -1 +1 @@
__version__ = "4.23.5"
__version__ = "4.23.6"

View File

@@ -52,7 +52,6 @@ class ToolImageCache:
self._initialized = True
self._cache_dir = os.path.join(get_astrbot_temp_path(), self.CACHE_DIR_NAME)
os.makedirs(self._cache_dir, exist_ok=True)
logger.debug(f"ToolImageCache initialized, cache dir: {self._cache_dir}")
def _get_file_extension(self, mime_type: str) -> str:
"""Get file extension from MIME type."""

View File

@@ -31,6 +31,9 @@ from astrbot.core.platform.message_session import MessageSession
from astrbot.core.provider.entites import ProviderRequest
from astrbot.core.provider.register import llm_tools
from astrbot.core.tools.computer_tools import (
CuaKeyboardTypeTool,
CuaMouseClickTool,
CuaScreenshotTool,
ExecuteShellTool,
FileDownloadTool,
FileEditTool,
@@ -186,7 +189,9 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
cls,
runtime: str,
tool_mgr,
booter: str | None = None,
) -> dict[str, FunctionTool]:
booter = "" if booter is None else str(booter).lower()
if runtime == "sandbox":
shell_tool = tool_mgr.get_builtin_tool(ExecuteShellTool)
python_tool = tool_mgr.get_builtin_tool(PythonTool)
@@ -196,7 +201,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
write_tool = tool_mgr.get_builtin_tool(FileWriteTool)
edit_tool = tool_mgr.get_builtin_tool(FileEditTool)
grep_tool = tool_mgr.get_builtin_tool(GrepTool)
return {
tools = {
shell_tool.name: shell_tool,
python_tool.name: python_tool,
upload_tool.name: upload_tool,
@@ -206,6 +211,18 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
edit_tool.name: edit_tool,
grep_tool.name: grep_tool,
}
if booter == "cua":
screenshot_tool = tool_mgr.get_builtin_tool(CuaScreenshotTool)
mouse_click_tool = tool_mgr.get_builtin_tool(CuaMouseClickTool)
keyboard_type_tool = tool_mgr.get_builtin_tool(CuaKeyboardTypeTool)
tools.update(
{
screenshot_tool.name: screenshot_tool,
mouse_click_tool.name: mouse_click_tool,
keyboard_type_tool.name: keyboard_type_tool,
}
)
return tools
if runtime == "local":
shell_tool = tool_mgr.get_builtin_tool(ExecuteShellTool)
python_tool = tool_mgr.get_builtin_tool(LocalPythonTool)
@@ -242,6 +259,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
runtime_computer_tools = cls._get_runtime_computer_tools(
runtime,
tool_mgr,
provider_settings.get("sandbox", {}).get("booter"),
)
# Keep persona semantics aligned with the main agent: tools=None means

View File

@@ -47,6 +47,9 @@ from astrbot.core.tools.computer_tools import (
BrowserExecTool,
CreateSkillCandidateTool,
CreateSkillPayloadTool,
CuaKeyboardTypeTool,
CuaMouseClickTool,
CuaScreenshotTool,
EvaluateSkillCandidateTool,
ExecuteShellTool,
FileDownloadTool,
@@ -1015,6 +1018,22 @@ def _apply_sandbox_tools(
req.func_tool.add_tool(tool_mgr.get_builtin_tool(RollbackSkillReleaseTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(SyncSkillReleaseTool))
if booter == "cua":
req.system_prompt += (
"\n[CUA Desktop Control]\n"
"Use `astrbot_execute_shell` with `background=true` to launch GUI apps. "
'Use Firefox for browser tasks, for example `firefox "https://example.com"`. '
"After each visible step, call `astrbot_cua_screenshot` with "
"`send_to_user=true` and `return_image_to_llm=true` so the user can "
"monitor progress. When typing, inspect the screenshot first and confirm "
"the target field is focused and empty or safe to append to. Use "
"`astrbot_cua_mouse_click` for coordinates and `astrbot_cua_keyboard_type` "
"for text input; use text=`\\n` for Enter.\n"
)
req.func_tool.add_tool(tool_mgr.get_builtin_tool(CuaScreenshotTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(CuaMouseClickTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(CuaKeyboardTypeTool))
req.system_prompt = f"{req.system_prompt or ''}\n{SANDBOX_MODE_PROMPT}\n"

View File

@@ -1,6 +1,7 @@
from ..olayer import (
BrowserComponent,
FileSystemComponent,
GUIComponent,
PythonComponent,
ShellComponent,
)
@@ -29,6 +30,10 @@ class ComputerBooter:
def browser(self) -> BrowserComponent | None:
return None
@property
def gui(self) -> GUIComponent | None:
return None
async def boot(self, session_id: str) -> None: ...
async def shutdown(self) -> None: ...

View File

@@ -0,0 +1,830 @@
from __future__ import annotations
import base64
import inspect
import shlex
from dataclasses import asdict, dataclass, is_dataclass
from pathlib import Path
from typing import Any
from astrbot.api import logger
from ..olayer import FileSystemComponent, GUIComponent, PythonComponent, ShellComponent
from .base import ComputerBooter
from .cua_defaults import CUA_CONFIG_KEYS, CUA_DEFAULT_CONFIG
from .shipyard_search_file_util import search_files_via_shell
_POSIX_OS_TYPES = {"linux", "darwin", "macos"}
_CUA_BACKGROUND_LAUNCHER = """
import subprocess, sys, time
p = subprocess.Popen(
["sh", "-lc", sys.argv[1]],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
sys.stdout.write(str(p.pid) + "\\n")
sys.stdout.flush()
time.sleep(0.2)
code = p.poll()
sys.exit(0 if code is None else code)
""".strip()
async def _maybe_await(value: Any) -> Any:
if inspect.isawaitable(value):
return await value
return value
def build_cua_booter_kwargs(sandbox_cfg: dict[str, Any]) -> dict[str, Any]:
return {
name: sandbox_cfg.get(config_key, CUA_DEFAULT_CONFIG[name])
for name, config_key in CUA_CONFIG_KEYS.items()
}
async def _write_base64_via_shell(
shell: ShellComponent,
path: str,
data: bytes,
) -> dict[str, Any]:
encoded = base64.b64encode(data).decode("ascii")
decoder = (
"import base64,pathlib,sys; "
"pathlib.Path(sys.argv[1]).write_bytes(base64.b64decode(sys.stdin.read()))"
)
return await shell.exec(
f"python3 -c {shlex.quote(decoder)} {shlex.quote(path)} <<'EOF'\n{encoded}\nEOF"
)
@dataclass(slots=True)
class ProcessResult:
stdout: str
stderr: str
exit_code: int | None
success: bool
def _maybe_model_dump(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return value
if is_dataclass(value) and not isinstance(value, type):
return asdict(value)
if hasattr(value, "model_dump"):
dumped = value.model_dump()
if isinstance(dumped, dict):
return dumped
if hasattr(value, "dict"):
dumped = value.dict()
if isinstance(dumped, dict):
return dumped
attr_payload = {
key: getattr(value, key)
for key in (
"stdout",
"stderr",
"output",
"error",
"returncode",
"return_code",
"exit_code",
"success",
)
if hasattr(value, key)
}
if attr_payload:
return attr_payload
return {}
def _slice_content_by_lines(
content: str,
*,
offset: int | None = None,
limit: int | None = None,
) -> str:
lines = content.splitlines(keepends=True)
start = 0 if offset is None else offset
selected = lines[start:] if limit is None else lines[start : start + limit]
return "".join(selected)
def _normalize_process_result(raw: Any) -> ProcessResult:
"""Best-effort normalization for the process shapes returned by CUA SDKs."""
payload = _maybe_model_dump(raw)
if not payload and isinstance(raw, str):
payload = {"stdout": raw}
def first_text(*keys: str) -> str:
for key in keys:
value = payload.get(key)
if value is not None:
return str(value)
return ""
stdout = first_text("stdout", "output")
stderr = first_text("stderr", "error")
exit_code = payload.get("exit_code")
if exit_code is None:
exit_code = payload.get("returncode")
if exit_code is None:
exit_code = payload.get("return_code")
if exit_code is None:
exit_code = 0 if not stderr else 1
success = bool(payload.get("success", not stderr and exit_code in (0, None)))
return ProcessResult(
stdout=stdout,
stderr=stderr,
exit_code=exit_code,
success=success,
)
def _is_missing_python3_error(stderr: str) -> bool:
lowered = stderr.lower()
return "python3" in lowered and (
"not found" in lowered
or "command not found" in lowered
or "no such file" in lowered
)
def _python3_requirement_error(operation: str, stderr: str) -> str:
return f"CUA {operation} requires python3 in the sandbox image: {stderr}"
def _normalize_with_python3_requirement(raw: Any, operation: str) -> ProcessResult:
proc = _normalize_process_result(raw)
if proc.stderr and _is_missing_python3_error(proc.stderr):
return ProcessResult(
stdout=proc.stdout,
stderr=_python3_requirement_error(operation, proc.stderr),
exit_code=proc.exit_code,
success=proc.success,
)
return proc
async def _exec_python3_or_error(
shell: ShellComponent,
code: str,
*,
operation: str,
timeout: int | None = 30,
) -> ProcessResult:
result = await shell.exec(f"python3 - <<'PY'\n{code}\nPY", timeout=timeout)
return _normalize_with_python3_requirement(result, operation)
def _is_posix_os_type(os_type: str) -> bool:
return os_type.lower() in _POSIX_OS_TYPES
def _posix_fs_error_message(os_type: str) -> str:
return (
"CUA filesystem shell fallback is only supported for POSIX images; "
f"os_type={os_type!r} does not support the required shell commands."
)
def _non_posix_filesystem_result(path: str, os_type: str) -> dict[str, Any]:
error = _posix_fs_error_message(os_type)
return {"success": False, "path": path, "error": error, "message": error}
def _raise_non_posix_filesystem_error(os_type: str) -> None:
raise RuntimeError(_posix_fs_error_message(os_type))
def _resolve_component_method(
component: Any,
method_names: str | tuple[str, ...],
) -> Any | None:
if component is None:
return None
names = (method_names,) if isinstance(method_names, str) else method_names
for method_name in names:
method = getattr(component, method_name, None)
if method is not None:
return method
return None
def _missing_component_method_error(
component_name: str,
method_names: str | tuple[str, ...],
) -> RuntimeError:
names = (method_names,) if isinstance(method_names, str) else method_names
candidates = ", ".join(f"{component_name}.{name}" for name in names)
return RuntimeError(
f"CUA sandbox does not provide any of: {candidates}. "
"Please check the installed CUA SDK version and sandbox backend."
)
def _has_component_method(root: Any, component_name: str, method_name: str) -> bool:
component = getattr(root, component_name, None)
return getattr(component, method_name, None) is not None
class CuaShellComponent(ShellComponent):
def __init__(self, sandbox: Any, os_type: str = "linux") -> None:
self._sandbox = sandbox
self._os_type = os_type.lower()
shell = sandbox.shell
self._exec_raw = getattr(shell, "exec", None) or getattr(shell, "run", None)
if self._exec_raw is None:
raise RuntimeError("CUA sandbox shell must provide `.exec` or `.run`.")
async def exec(
self,
command: str,
cwd: str | None = None,
env: dict[str, str] | None = None,
timeout: int | None = 30,
shell: bool = True,
background: bool = False,
) -> dict[str, Any]:
if not shell:
return {
"stdout": "",
"stderr": "error: only shell mode is supported in CUA booter.",
"exit_code": 2,
"success": False,
}
kwargs: dict[str, Any] = {}
if cwd is not None:
kwargs["cwd"] = cwd
if timeout is not None:
kwargs["timeout"] = timeout
if env:
kwargs["env"] = env
if background:
if not _is_posix_os_type(self._os_type):
return {
"stdout": "",
"stderr": "error: background shell execution is only supported for POSIX CUA images.",
"exit_code": 2,
"success": False,
}
command = _build_cua_background_command(command)
result = await _maybe_await(self._exec_raw(command, **kwargs))
proc = (
_normalize_with_python3_requirement(result, "background execution")
if background
else _normalize_process_result(result)
)
response = {
"stdout": proc.stdout,
"stderr": proc.stderr,
"exit_code": proc.exit_code,
"success": proc.success,
}
if background:
try:
response["pid"] = int(proc.stdout.strip().splitlines()[-1])
except Exception:
response["pid"] = None
return response
def _build_cua_background_command(command: str) -> str:
return f"python3 -c {shlex.quote(_CUA_BACKGROUND_LAUNCHER)} {shlex.quote(command)}"
class CuaPythonComponent(PythonComponent):
def __init__(self, sandbox: Any, os_type: str = "linux") -> None:
self._sandbox = sandbox
self._os_type = os_type
python = getattr(sandbox, "python", None)
self._python_exec = None
if python is not None:
self._python_exec = getattr(python, "exec", None) or getattr(
python, "run", None
)
async def exec(
self,
code: str,
kernel_id: str | None = None,
timeout: int = 30,
silent: bool = False,
) -> dict[str, Any]:
_ = kernel_id
if self._python_exec is not None:
result = await _maybe_await(self._python_exec(code, timeout=timeout))
proc = _normalize_process_result(result)
else:
shell = CuaShellComponent(self._sandbox, os_type=self._os_type)
proc = await _exec_python3_or_error(
shell,
code,
operation="Python execution fallback",
timeout=timeout,
)
output_text = "" if silent else proc.stdout
error_text = proc.stderr
return {
"success": proc.success if not silent else not bool(error_text),
"data": {
"output": {"text": output_text, "images": []},
"error": error_text,
},
"output": output_text,
"error": error_text,
}
def _write_result(path: str, result: dict[str, Any]) -> dict[str, Any]:
stderr = result.get("stderr", "")
if stderr and _is_missing_python3_error(stderr):
result = {
**result,
"stderr": _python3_requirement_error("filesystem write fallback", stderr),
}
if result.get("stderr") or result.get("success") is False:
return {"success": False, "path": path, **result}
return {"success": True, "path": path, **result}
class CuaFileSystemComponent(FileSystemComponent):
def __init__(
self, sandbox: Any, os_type: str = CUA_DEFAULT_CONFIG["os_type"]
) -> None:
self._shell = CuaShellComponent(sandbox, os_type=os_type)
self._fs = getattr(sandbox, "filesystem", None)
self._os_type = os_type.lower()
self._fallback = _PosixShellFileSystem(self._shell, self._os_type)
async def create_file(
self,
path: str,
content: str = "",
mode: int = 0o644,
) -> dict[str, Any]:
write_result = await self.write_file(path, content)
if not write_result.get("success"):
return {**write_result, "mode": mode, "mode_applied": False}
return {"success": True, "path": path, "mode": mode, "mode_applied": False}
async def read_file(
self,
path: str,
encoding: str = "utf-8",
offset: int | None = None,
limit: int | None = None,
) -> dict[str, Any]:
read_file = None if self._fs is None else getattr(self._fs, "read_file", None)
if read_file is None:
return await self._fallback.read_file(path, encoding, offset, limit)
else:
content = await _maybe_await(read_file(path))
if isinstance(content, bytes):
content = content.decode(encoding, errors="replace")
return {
"success": True,
"path": path,
"content": _slice_content_by_lines(
str(content), offset=offset, limit=limit
),
}
async def write_file(
self,
path: str,
content: str,
mode: str = "w",
encoding: str = "utf-8",
) -> dict[str, Any]:
_ = mode
write_file = None if self._fs is None else getattr(self._fs, "write_file", None)
if write_file is None:
return await self._fallback.write_file(path, content, mode, encoding)
else:
await _maybe_await(write_file(path, content))
return {"success": True, "path": path}
async def delete_file(self, path: str) -> dict[str, Any]:
delete = None
if self._fs is not None:
delete = getattr(self._fs, "delete", None) or getattr(
self._fs, "delete_file", None
)
if delete is None:
return await self._fallback.delete_file(path)
else:
await _maybe_await(delete(path))
return {"success": True, "path": path}
async def list_dir(
self,
path: str = ".",
show_hidden: bool = False,
) -> dict[str, Any]:
list_dir = None if self._fs is None else getattr(self._fs, "list_dir", None)
if list_dir is not None:
entries = await _maybe_await(list_dir(path))
return {"success": True, "path": path, "entries": entries}
return await self._fallback.list_dir(path, show_hidden)
async def search_files(
self,
pattern: str,
path: str | None = None,
glob: str | None = None,
after_context: int | None = None,
before_context: int | None = None,
) -> dict[str, Any]:
return await self._fallback.search_files(
pattern=pattern,
path=path,
glob=glob,
after_context=after_context,
before_context=before_context,
)
async def edit_file(
self,
path: str,
old_string: str,
new_string: str,
replace_all: bool = False,
encoding: str = "utf-8",
) -> dict[str, Any]:
read_result = await self.read_file(path, encoding=encoding)
if not read_result.get("success"):
return read_result
content = read_result.get("content", "")
occurrences = content.count(old_string)
if occurrences == 0:
return {
"success": False,
"error": "old string not found in file",
"replacements": 0,
}
updated = content.replace(old_string, new_string, -1 if replace_all else 1)
write_result = await self.write_file(path, updated, encoding=encoding)
if not write_result.get("success"):
return write_result
return {
"success": True,
"path": path,
"replacements": occurrences if replace_all else 1,
}
class _PosixShellFileSystem(FileSystemComponent):
def __init__(self, shell: CuaShellComponent, os_type: str) -> None:
self._shell = shell
self._os_type = os_type.lower()
def _ensure_posix(self, path: str) -> dict[str, Any] | None:
if _is_posix_os_type(self._os_type):
return None
return _non_posix_filesystem_result(path, self._os_type)
async def read_file(
self,
path: str,
encoding: str = "utf-8",
offset: int | None = None,
limit: int | None = None,
) -> dict[str, Any]:
_ = encoding
if error := self._ensure_posix(path):
return error
result = await self._shell.exec(f"cat {shlex.quote(path)}")
if result.get("stderr"):
return {"success": False, "path": path, "error": result["stderr"]}
return {
"success": True,
"path": path,
"content": _slice_content_by_lines(
str(result.get("stdout", "")), offset=offset, limit=limit
),
}
async def write_file(
self,
path: str,
content: str,
mode: str = "w",
encoding: str = "utf-8",
) -> dict[str, Any]:
_ = mode
if error := self._ensure_posix(path):
return error
result = await _write_base64_via_shell(
self._shell, path, content.encode(encoding)
)
return _write_result(path, result)
async def delete_file(self, path: str) -> dict[str, Any]:
if error := self._ensure_posix(path):
return error
result = await self._shell.exec(f"rm -rf {shlex.quote(path)}")
if result.get("stderr"):
return {"success": False, "path": path, "error": result["stderr"]}
return {"success": True, "path": path}
async def list_dir(
self,
path: str = ".",
show_hidden: bool = False,
) -> dict[str, Any]:
if error := self._ensure_posix(path):
return error
return await _list_dir_via_shell(self._shell, path, show_hidden)
async def search_files(
self,
pattern: str,
path: str | None = None,
glob: str | None = None,
after_context: int | None = None,
before_context: int | None = None,
) -> dict[str, Any]:
search_path = path or "."
if error := self._ensure_posix(search_path):
return error
return await search_files_via_shell(
self._shell,
pattern=pattern,
path=path,
glob=glob,
after_context=after_context,
before_context=before_context,
)
async def _list_dir_via_shell(
shell: CuaShellComponent,
path: str,
show_hidden: bool,
) -> dict[str, Any]:
flags = "-1A" if show_hidden else "-1"
result = await shell.exec(f"ls {flags} {shlex.quote(path)}")
stdout = result.get("stdout", "")
return {
"success": not bool(result.get("stderr")),
"path": path,
"entries": [line for line in stdout.splitlines() if line.strip()],
"error": result.get("stderr", ""),
}
class CuaGUIComponent(GUIComponent):
def __init__(self, sandbox: Any) -> None:
self._sandbox = sandbox
mouse = getattr(sandbox, "mouse", None)
keyboard = getattr(sandbox, "keyboard", None)
self._click = _resolve_component_method(mouse, "click")
self._type_text = _resolve_component_method(keyboard, "type")
self._press_key = _resolve_component_method(
keyboard, ("press", "key_press", "press_key")
)
async def screenshot(self, path: str | None = None) -> dict[str, Any]:
raw = await self._sandbox.screenshot()
data = _screenshot_to_bytes(raw)
if path:
Path(path).parent.mkdir(parents=True, exist_ok=True)
Path(path).write_bytes(data)
return {
"success": True,
"path": path,
"mime_type": "image/png",
"base64": base64.b64encode(data).decode("ascii"),
}
async def click(self, x: int, y: int, button: str = "left") -> dict[str, Any]:
if self._click is None:
raise _missing_component_method_error("mouse", "click")
result = await _maybe_await(self._click(x, y, button=button))
payload = _maybe_model_dump(result)
return {"success": bool(payload.get("success", True)), **payload}
async def type_text(self, text: str) -> dict[str, Any]:
if self._type_text is None:
raise _missing_component_method_error("keyboard", "type")
result = await _maybe_await(self._type_text(text))
payload = _maybe_model_dump(result)
return {"success": bool(payload.get("success", True)), **payload}
async def press_key(self, key: str) -> dict[str, Any]:
if self._press_key is None:
raise _missing_component_method_error(
"keyboard", ("press", "key_press", "press_key")
)
result = await _maybe_await(self._press_key(key))
payload = _maybe_model_dump(result)
return {"success": bool(payload.get("success", True)), **payload}
def _screenshot_to_bytes(raw: Any) -> bytes:
def from_str(value: str) -> bytes:
if value.startswith("data:image"):
value = value.split(",", 1)[1]
try:
return base64.b64decode(value, validate=True)
except Exception:
candidate = Path(value)
if candidate.is_file():
return candidate.read_bytes()
return value.encode("utf-8")
if isinstance(raw, (bytes, bytearray)):
return bytes(raw)
if isinstance(raw, str):
return from_str(raw)
if hasattr(raw, "save"):
import io
output = io.BytesIO()
raw.save(output, format="PNG")
return output.getvalue()
payload = _maybe_model_dump(raw)
for key in ("data", "base64", "image"):
value = payload.get(key)
if value:
return _screenshot_to_bytes(value)
raise TypeError(f"Unsupported CUA screenshot result: {type(raw)!r}")
@dataclass(slots=True)
class _CuaRuntime:
sandbox_cm: Any
sandbox: Any
shell: CuaShellComponent
python: CuaPythonComponent
fs: CuaFileSystemComponent
gui: CuaGUIComponent | None
class CuaBooter(ComputerBooter):
def __init__(
self,
image: str = CUA_DEFAULT_CONFIG["image"],
os_type: str = CUA_DEFAULT_CONFIG["os_type"],
ttl: int = CUA_DEFAULT_CONFIG["ttl"],
telemetry_enabled: bool = CUA_DEFAULT_CONFIG["telemetry_enabled"],
local: bool = CUA_DEFAULT_CONFIG["local"],
api_key: str = CUA_DEFAULT_CONFIG["api_key"],
) -> None:
self.image = image
self.os_type = os_type
self.ttl = ttl
self.telemetry_enabled = telemetry_enabled
self.local = local
self.api_key = api_key
self._runtime: _CuaRuntime | None = None
async def boot(self, session_id: str) -> None:
_ = session_id
try:
from cua import Image, Sandbox
except ImportError as exc:
raise RuntimeError(
"CUA sandbox support requires the optional `cua` package. "
"Install it with `pip install cua` in the AstrBot environment."
) from exc
image_obj = self._build_image(Image)
ephemeral_kwargs = self._build_ephemeral_kwargs(Sandbox.ephemeral)
sandbox_cm = Sandbox.ephemeral(image_obj, **ephemeral_kwargs)
sandbox = await sandbox_cm.__aenter__()
try:
self._runtime = _CuaRuntime(
sandbox_cm=sandbox_cm,
sandbox=sandbox,
shell=CuaShellComponent(sandbox, os_type=self.os_type),
python=CuaPythonComponent(sandbox, os_type=self.os_type),
fs=CuaFileSystemComponent(sandbox, os_type=self.os_type),
gui=CuaGUIComponent(sandbox),
)
except Exception:
await sandbox_cm.__aexit__(None, None, None)
self._runtime = None
raise
logger.info(
"[Computer] CUA sandbox booted: image=%s, os_type=%s",
self.image,
self.os_type,
)
def _build_image(self, image_cls: Any) -> Any:
image_name = (self.image or self.os_type or "linux").strip().lower()
factory = getattr(image_cls, image_name, None)
if callable(factory):
return factory()
os_factory = getattr(image_cls, (self.os_type or "linux").strip().lower(), None)
if callable(os_factory):
return os_factory()
return image_name
def _build_ephemeral_kwargs(self, ephemeral: Any) -> dict[str, Any]:
try:
parameters = inspect.signature(ephemeral).parameters
except (TypeError, ValueError):
return {}
kwargs: dict[str, Any] = {}
if "ttl" in parameters:
kwargs["ttl"] = self.ttl
if "telemetry_enabled" in parameters:
kwargs["telemetry_enabled"] = self.telemetry_enabled
if "local" in parameters:
kwargs["local"] = self.local
if "api_key" in parameters and self.api_key:
kwargs["api_key"] = self.api_key
return kwargs
async def shutdown(self) -> None:
if self._runtime is not None:
await self._runtime.sandbox_cm.__aexit__(None, None, None)
self._runtime = None
@property
def capabilities(self) -> tuple[str, ...] | None:
capabilities = ["python", "shell", "filesystem"]
if self._runtime is None:
return tuple(capabilities)
sandbox = self._runtime.sandbox
has_screenshot = getattr(sandbox, "screenshot", None) is not None
has_mouse = _has_component_method(sandbox, "mouse", "click")
has_keyboard = _has_component_method(sandbox, "keyboard", "type")
if has_screenshot or has_mouse or has_keyboard:
capabilities.append("gui")
if has_screenshot:
capabilities.append("screenshot")
if has_mouse:
capabilities.append("mouse")
if has_keyboard:
capabilities.append("keyboard")
return tuple(capabilities)
@property
def fs(self) -> FileSystemComponent:
if self._runtime is None:
raise RuntimeError("CuaBooter is not initialized.")
return self._runtime.fs
@property
def python(self) -> PythonComponent:
if self._runtime is None:
raise RuntimeError("CuaBooter is not initialized.")
return self._runtime.python
@property
def shell(self) -> ShellComponent:
if self._runtime is None:
raise RuntimeError("CuaBooter is not initialized.")
return self._runtime.shell
@property
def gui(self) -> GUIComponent | None:
return None if self._runtime is None else self._runtime.gui
async def upload_file(self, path: str, file_name: str) -> dict:
local_path = Path(path)
if not local_path.is_file():
return {"success": False, "error": f"File not found: {path}"}
sandbox = None if self._runtime is None else self._runtime.sandbox
if sandbox is not None and hasattr(sandbox, "upload_file"):
return _maybe_model_dump(
await sandbox.upload_file(str(local_path), file_name)
)
if not _is_posix_os_type(self.os_type):
return _non_posix_filesystem_result(file_name, self.os_type)
result = await _write_base64_via_shell(
self.shell, file_name, local_path.read_bytes()
)
return {
"success": not bool(result.get("stderr")),
"file_path": file_name,
**result,
}
async def download_file(self, remote_path: str, local_path: str) -> None:
sandbox = None if self._runtime is None else self._runtime.sandbox
if sandbox is not None and hasattr(sandbox, "download_file"):
await sandbox.download_file(remote_path, local_path)
return
if not _is_posix_os_type(self.os_type):
_raise_non_posix_filesystem_error(self.os_type)
result = await self.shell.exec(f"base64 {shlex.quote(remote_path)}")
if result.get("stderr"):
raise RuntimeError(result["stderr"])
Path(local_path).parent.mkdir(parents=True, exist_ok=True)
Path(local_path).write_bytes(base64.b64decode(result.get("stdout", "")))
async def available(self) -> bool:
return self._runtime is not None

View File

@@ -0,0 +1,17 @@
CUA_DEFAULT_CONFIG = {
"image": "linux",
"os_type": "linux",
"ttl": 3600,
"telemetry_enabled": False,
"local": True,
"api_key": "",
}
CUA_CONFIG_KEYS = {
"image": "cua_image",
"os_type": "cua_os_type",
"ttl": "cua_ttl",
"telemetry_enabled": "cua_telemetry_enabled",
"local": "cua_local",
"api_key": "cua_api_key",
}

View File

@@ -90,7 +90,7 @@ class LocalShellComponent(ShellComponent):
command: str,
cwd: str | None = None,
env: dict[str, str] | None = None,
timeout: int | None = 30,
timeout: int | None = 300,
shell: bool = True,
background: bool = False,
) -> dict[str, Any]:
@@ -123,7 +123,7 @@ class LocalShellComponent(ShellComponent):
shell=shell,
cwd=working_dir,
env=run_env,
timeout=timeout,
timeout=timeout or 300,
capture_output=True,
)
return {

View File

@@ -0,0 +1,18 @@
import shlex
_BACKGROUND_SPAWN_SCRIPT = (
"import subprocess, sys; "
"p = subprocess.Popen("
"['bash', '-lc', sys.argv[1]], "
"stdin=subprocess.DEVNULL, "
"stdout=subprocess.DEVNULL, "
"stderr=subprocess.DEVNULL, "
"start_new_session=True, "
"close_fds=True"
"); "
"print(p.pid)"
)
def build_detached_shell_command(command: str) -> str:
return f"python3 -c {shlex.quote(_BACKGROUND_SPAWN_SCRIPT)} {shlex.quote(command)}"

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import shlex
from typing import Any
from shipyard import FileSystemComponent as ShipyardFileSystemComponent
@@ -9,9 +10,93 @@ from astrbot.api import logger
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
from .base import ComputerBooter
from .shell_background import build_detached_shell_command
from .shipyard_search_file_util import search_files_via_shell
def _maybe_model_dump(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return value
if hasattr(value, "model_dump"):
dumped = value.model_dump()
if isinstance(dumped, dict):
return dumped
return {}
class ShipyardShellWrapper:
def __init__(self, _shipyard_shell: ShellComponent):
self._shell = _shipyard_shell
async def exec(
self,
command: str,
cwd: str | None = None,
env: dict[str, str] | None = None,
timeout: int | None = 300,
shell: bool = True,
background: bool = False,
) -> dict[str, Any]:
if not shell:
return {
"stdout": "",
"stderr": "error: only shell mode is supported in shipyard booter.",
"exit_code": 2,
"success": False,
}
run_command = command
if env:
env_prefix = " ".join(
f"{k}={shlex.quote(str(v))}" for k, v in sorted(env.items())
)
run_command = f"{env_prefix} {run_command}"
if background:
run_command = build_detached_shell_command(run_command)
result = await self._shell.exec(
run_command,
timeout=timeout or 300,
cwd=cwd,
)
payload = _maybe_model_dump(result)
stdout = payload.get("output", payload.get("stdout", "")) or ""
stderr = payload.get("error", payload.get("stderr", "")) or ""
exit_code = payload.get("exit_code")
if background:
pid: int | None = None
try:
pid = int(str(stdout).strip().splitlines()[-1])
except Exception:
pid = None
return {
"pid": pid,
"stdout": (
f"Command is running in the background. pid={pid}"
if pid is not None
else "Command was submitted in the background."
),
"stderr": stderr,
"exit_code": exit_code,
"success": bool(payload.get("success", not stderr)),
"execution_id": payload.get("execution_id"),
"execution_time_ms": payload.get("execution_time_ms"),
"command": payload.get("command"),
}
return {
"stdout": stdout,
"stderr": stderr,
"exit_code": exit_code,
"success": bool(payload.get("success", not stderr)),
"execution_id": payload.get("execution_id"),
"execution_time_ms": payload.get("execution_time_ms"),
"command": payload.get("command"),
}
class ShipyardFileSystemWrapper:
def __init__(
self, _shipyard_fs: ShipyardFileSystemComponent, _shipyard_shell: ShellComponent
@@ -107,7 +192,8 @@ class ShipyardBooter(ComputerBooter):
)
logger.info(f"Got sandbox ship: {ship.id} for session: {session_id}")
self._ship = ship
self._fs = ShipyardFileSystemWrapper(self._ship.fs, self._ship.shell)
self._shell = ShipyardShellWrapper(self._ship.shell)
self._fs = ShipyardFileSystemWrapper(self._ship.fs, self._shell)
async def shutdown(self) -> None:
logger.info("[Computer] Shipyard booter shutdown.")
@@ -122,7 +208,7 @@ class ShipyardBooter(ComputerBooter):
@property
def shell(self) -> ShellComponent:
return self._ship.shell
return self._shell
async def upload_file(self, path: str, file_name: str) -> dict:
"""Upload file to sandbox"""

View File

@@ -13,6 +13,7 @@ from ..olayer import (
ShellComponent,
)
from .base import ComputerBooter
from .shell_background import build_detached_shell_command
from .shipyard_search_file_util import search_files_via_shell
try:
@@ -96,7 +97,7 @@ class NeoShellComponent(ShellComponent):
command: str,
cwd: str | None = None,
env: dict[str, str] | None = None,
timeout: int | None = 30,
timeout: int | None = 300,
shell: bool = True,
background: bool = False,
) -> dict[str, Any]:
@@ -116,11 +117,11 @@ class NeoShellComponent(ShellComponent):
run_command = f"{env_prefix} {run_command}"
if background:
run_command = f"nohup sh -lc {shlex.quote(run_command)} >/tmp/astrbot_bg.log 2>&1 & echo $!"
run_command = build_detached_shell_command(run_command)
result = await self._sandbox.shell.exec(
run_command,
timeout=timeout or 30,
timeout=timeout or 300,
cwd=cwd,
)
payload = _maybe_model_dump(result)
@@ -136,7 +137,11 @@ class NeoShellComponent(ShellComponent):
pid = None
return {
"pid": pid,
"stdout": stdout,
"stdout": (
f"Command is running in the background. pid={pid}"
if pid is not None
else "Command was submitted in the background."
),
"stderr": stderr,
"exit_code": exit_code,
"success": bool(payload.get("success", not stderr)),

View File

@@ -484,6 +484,15 @@ async def get_booter(
profile=profile,
ttl=ttl,
)
elif booter_type == "cua":
from .booters.cua import CuaBooter, build_cua_booter_kwargs
cua_kwargs = build_cua_booter_kwargs(sandbox_cfg)
logger.info(
f"[Computer] CUA config: image={cua_kwargs['image']}, "
f"os_type={cua_kwargs['os_type']}, ttl={cua_kwargs['ttl']}"
)
client = CuaBooter(**cua_kwargs)
elif booter_type == "boxlite":
from .booters.boxlite import BoxliteBooter
@@ -499,6 +508,14 @@ async def get_booter(
await _sync_skills_to_sandbox(client)
except Exception as e:
logger.error(f"Error booting sandbox for session {session_id}: {e}")
try:
await client.shutdown()
except Exception as shutdown_error:
logger.warning(
"Failed to shutdown sandbox after boot error for session %s: %s",
session_id,
shutdown_error,
)
raise e
session_booter[session_id] = client

View File

@@ -1,5 +1,6 @@
from .browser import BrowserComponent
from .filesystem import FileSystemComponent
from .gui import GUIComponent
from .python import PythonComponent
from .shell import ShellComponent
@@ -8,4 +9,5 @@ __all__ = [
"ShellComponent",
"FileSystemComponent",
"BrowserComponent",
"GUIComponent",
]

View File

@@ -0,0 +1,25 @@
"""
GUI automation component.
"""
from typing import Any, Protocol
class GUIComponent(Protocol):
"""Desktop GUI operations component."""
async def screenshot(self, path: str | None = None) -> dict[str, Any]:
"""Capture a screenshot, optionally saving it to path."""
...
async def click(self, x: int, y: int, button: str = "left") -> dict[str, Any]:
"""Click at screen coordinates."""
...
async def type_text(self, text: str) -> dict[str, Any]:
"""Type text into the active UI target."""
...
async def press_key(self, key: str) -> dict[str, Any]:
"""Press a keyboard key or shortcut."""
...

View File

@@ -13,7 +13,7 @@ class ShellComponent(Protocol):
command: str,
cwd: str | None = None,
env: dict[str, str] | None = None,
timeout: int | None = 30,
timeout: int | None = 300,
shell: bool = True,
background: bool = False,
) -> dict[str, Any]:

View File

@@ -104,7 +104,7 @@ class AstrBotConfig(dict):
if key not in conf:
# 配置项不存在,插入默认值
path_ = path + "." + key if path else key
logger.info(f"检查到配置项 {path_} 不存在,已插入默认值 {value}")
logger.info("Config key missing; added default.")
new_conf[key] = value
has_new = True
elif conf[key] is None:
@@ -134,15 +134,15 @@ class AstrBotConfig(dict):
for key in list(conf.keys()):
if key not in refer_conf:
path_ = path + "." + key if path else key
logger.info(f"检查到配置项 {path_} 不存在,将从当前配置中删除")
logger.info("Config key removed: %s", path_)
has_new = True
# 顺序不一致也算作变更
if list(conf.keys()) != list(new_conf.keys()):
if path:
logger.info(f"检查到配置项 {path} 的子项顺序不一致,已重新排序")
logger.info("Config key order fixed: %s", path)
else:
logger.info("检查到配置项顺序不一致,已重新排序")
logger.info("Config key order fixed")
has_new = True
# 更新原始配置

View File

@@ -3,9 +3,10 @@
import os
from typing import Any, TypedDict
from astrbot.core.computer.booters.cua_defaults import CUA_DEFAULT_CONFIG
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.23.5"
VERSION = "4.23.6"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
PERSONAL_WECHAT_CONFIG_METADATA = {
"weixin_oc_base_url": {
@@ -175,6 +176,12 @@ DEFAULT_CONFIG = {
"shipyard_neo_access_token": "",
"shipyard_neo_profile": "python-default",
"shipyard_neo_ttl": 3600,
"cua_image": CUA_DEFAULT_CONFIG["image"],
"cua_os_type": CUA_DEFAULT_CONFIG["os_type"],
"cua_ttl": CUA_DEFAULT_CONFIG["ttl"],
"cua_telemetry_enabled": CUA_DEFAULT_CONFIG["telemetry_enabled"],
"cua_local": CUA_DEFAULT_CONFIG["local"],
"cua_api_key": CUA_DEFAULT_CONFIG["api_key"],
},
"image_compress_enabled": True,
"image_compress_options": {
@@ -3289,8 +3296,8 @@ CONFIG_METADATA_3 = {
"provider_settings.sandbox.booter": {
"description": "沙箱环境驱动器",
"type": "string",
"options": ["shipyard_neo", "shipyard"],
"labels": ["Shipyard Neo", "Shipyard"],
"options": ["shipyard_neo", "shipyard", "cua"],
"labels": ["Shipyard Neo", "Shipyard", "CUA"],
"condition": {
"provider_settings.computer_use_runtime": "sandbox",
},
@@ -3331,6 +3338,64 @@ CONFIG_METADATA_3 = {
"provider_settings.sandbox.booter": "shipyard_neo",
},
},
"provider_settings.sandbox.cua_image": {
"description": "CUA Image",
"type": "string",
"hint": "CUA 沙箱镜像/系统类型,默认 linux。可填写 linux、macos、windows、android具体取决于 CUA SDK 支持。",
"condition": {
"provider_settings.computer_use_runtime": "sandbox",
"provider_settings.sandbox.booter": "cua",
},
},
"provider_settings.sandbox.cua_os_type": {
"description": "CUA OS Type",
"type": "string",
"options": ["linux", "macos", "windows", "android"],
"labels": ["Linux", "macOS", "Windows", "Android"],
"hint": "CUA 沙箱操作系统类型,默认 linux。",
"condition": {
"provider_settings.computer_use_runtime": "sandbox",
"provider_settings.sandbox.booter": "cua",
},
},
"provider_settings.sandbox.cua_ttl": {
"description": "CUA Sandbox TTL",
"type": "int",
"hint": "CUA 沙箱生存时间(秒)。当前作为会话配置保存,具体生效取决于 CUA SDK。",
"condition": {
"provider_settings.computer_use_runtime": "sandbox",
"provider_settings.sandbox.booter": "cua",
},
},
"provider_settings.sandbox.cua_telemetry_enabled": {
"description": "CUA Telemetry",
"type": "bool",
"hint": "是否允许 CUA SDK 发送遥测数据。默认关闭。",
"condition": {
"provider_settings.computer_use_runtime": "sandbox",
"provider_settings.sandbox.booter": "cua",
},
},
"provider_settings.sandbox.cua_local": {
"description": "CUA Local Sandbox",
"type": "bool",
"hint": "是否优先使用 CUA 本地沙箱。默认开启,避免云端沙箱要求 CUA_API_KEY。关闭后可使用 CUA 云端沙箱。",
"condition": {
"provider_settings.computer_use_runtime": "sandbox",
"provider_settings.sandbox.booter": "cua",
},
},
"provider_settings.sandbox.cua_api_key": {
"description": "CUA API Key",
"type": "string",
"hint": "CUA 云端沙箱 API Key。仅在关闭本地沙箱时需要。也可以通过 CUA_API_KEY 环境变量提供。",
"obvious_hint": True,
"condition": {
"provider_settings.computer_use_runtime": "sandbox",
"provider_settings.sandbox.booter": "cua",
"provider_settings.sandbox.cua_local": False,
},
},
"provider_settings.sandbox.shipyard_endpoint": {
"description": "Shipyard API Endpoint",
"type": "string",

View File

@@ -294,7 +294,7 @@ class AstrBotCoreLifecycle:
用load加载事件总线和任务并初始化, 执行启动完成事件钩子
"""
self._load()
logger.info("AstrBot 启动完成。")
logger.info("AstrBot started.")
# 执行启动完成事件钩子
handlers = star_handlers_registry.get_handlers_by_event_type(

View File

@@ -27,9 +27,25 @@ class MessageChain:
chain: list[BaseMessageComponent] = field(default_factory=list)
use_t2i_: bool | None = None # None 为跟随用户设置
use_markdown_: bool | None = (
None # 是否使用 Markdown 发送消息。None 跟随平台默认True 强制 MarkdownFalse 强制纯文本。
)
type: str | None = None
"""消息链承载的消息的类型。可选,用于让消息平台区分不同业务场景的消息链。"""
def derive(self, chain: list[BaseMessageComponent] | None = None) -> "MessageChain":
"""基于当前消息链创建一个新的 MessageChain继承元数据use_t2i_、use_markdown_ 等)。
Args:
chain: 新消息链的组件列表。如果为 None则使用空列表。
"""
new = MessageChain(chain=chain if chain is not None else [])
new.use_t2i_ = self.use_t2i_
new.use_markdown_ = self.use_markdown_
new.type = self.type
return new
def message(self, message: str):
"""添加一条文本消息到消息链 `chain` 中。
@@ -118,6 +134,18 @@ class MessageChain:
self.use_t2i_ = use_t2i
return self
def use_markdown(self, use: bool | None = True):
"""设置是否使用 Markdown 发送消息。
仅对支持 Markdown 的平台生效(如 QQ Official不支持的平台会忽略此字段。
Args:
use: True 强制使用 MarkdownFalse 强制纯文本None 跟随平台默认行为。
"""
self.use_markdown_ = use
return self
def get_plain_text(self, with_other_comps_mark: bool = False) -> str:
"""获取纯文本消息。这个方法将获取 chain 中所有 Plain 组件的文本并拼接成一条消息。空格分隔。

View File

@@ -35,7 +35,7 @@ class PersonaManager:
async def initialize(self) -> None:
self.personas = await self.get_all_personas()
self.get_v3_persona_data()
logger.info(f"已加载 {len(self.personas)} 个人格。")
logger.info("Loaded %s personas.", len(self.personas))
async def get_persona(self, persona_id: str):
"""获取指定 persona 的信息"""

View File

@@ -246,9 +246,9 @@ class RespondStage(Stage):
await asyncio.sleep(i)
try:
if comp.type in need_separately:
await event.send(MessageChain([comp]))
await event.send(result.derive([comp]))
else:
await event.send(MessageChain([*header_comps, comp]))
await event.send(result.derive([*header_comps, comp]))
header_comps.clear()
except Exception as e:
logger.error(
@@ -271,7 +271,7 @@ class RespondStage(Stage):
modify_raw_chain=True,
)
for comp in sep_comps:
chain = MessageChain([comp])
chain = result.derive([comp])
try:
await event.send(chain)
except Exception as e:
@@ -279,7 +279,7 @@ class RespondStage(Stage):
f"发送消息链失败: chain = {chain}, error = {e}",
exc_info=True,
)
chain = MessageChain(result.chain)
chain = result.derive(result.chain)
if result.chain and len(result.chain) > 0:
try:
await event.send(chain)

View File

@@ -123,7 +123,9 @@ class PlatformManager:
return
logger.info(
f"载入 {platform_config['type']}({platform_config['id']}) 平台适配器 ...",
"Loading IM platform adapter %s(%s) ...",
platform_config["type"],
platform_config["id"],
)
match platform_config["type"]:
case "aiocqhttp":
@@ -201,7 +203,7 @@ class PlatformManager:
if platform_config["type"] not in platform_cls_map:
logger.error(
f"未找到适用于 {platform_config['type']}({platform_config['id']}) 平台适配器,请检查是否已经安装或者名称填写错误",
f"Platform adapter not found: {platform_config['type']}({platform_config['id']}).",
)
return
cls_type = platform_cls_map[platform_config["type"]]

View File

@@ -57,7 +57,7 @@ def register_platform_adapter(
)
platform_registry.append(pm)
platform_cls_map[adapter_name] = cls
logger.debug(f"平台适配器 {adapter_name} 已注册")
logger.debug("Platform adapter registered: %s", adapter_name)
return cls
return decorator

View File

@@ -235,12 +235,20 @@ class QQOfficialMessageEvent(AstrMessageEvent):
):
plain_text = plain_text + "\n"
payload: dict = {
# "content": plain_text,
"markdown": MarkdownPayload(content=plain_text) if plain_text else None,
"msg_type": 2,
"msg_id": self.message_obj.message_id,
}
# 根据消息链的 use_markdown_ 标记决定发送模式
use_md = getattr(self.send_buffer, "use_markdown_", None)
if use_md is False:
payload: dict = {
"content": plain_text,
"msg_type": 0,
"msg_id": self.message_obj.message_id,
}
else:
payload = {
"markdown": MarkdownPayload(content=plain_text) if plain_text else None,
"msg_type": 2,
"msg_id": self.message_obj.message_id,
}
if not isinstance(source, botpy.message.Message | botpy.message.DirectMessage):
payload["msg_seq"] = random.randint(1, 10000)

View File

@@ -570,7 +570,9 @@ class ProviderManager:
return
logger.info(
f"载入 {provider_config['type']}({provider_config['id']}) 服务提供商 ...",
"Loading model %s(%s) ...",
provider_config["type"],
provider_config["id"],
)
# 动态导入
@@ -591,7 +593,7 @@ class ProviderManager:
if provider_config["type"] not in provider_cls_map:
logger.error(
f"未找到适用于 {provider_config['type']}({provider_config['id']}) 的提供商适配器,请检查是否已经安装或者名称填写错误。已跳过。",
f"Provider adapter not found: {provider_config['type']}({provider_config['id']}). Skipped.",
exc_info=True,
)
return
@@ -625,7 +627,7 @@ class ProviderManager:
):
self.curr_stt_provider_inst = inst
logger.info(
f"已选择 {provider_config['type']}({provider_config['id']}) 作为当前语音转文本提供商适配器。",
f"Selected {provider_config['type']}({provider_config['id']}) as default STT provider",
)
if not self.curr_stt_provider_inst:
self.curr_stt_provider_inst = inst
@@ -648,7 +650,7 @@ class ProviderManager:
):
self.curr_tts_provider_inst = inst
logger.info(
f"已选择 {provider_config['type']}({provider_config['id']}) 作为当前文本转语音提供商适配器。",
f"Selected {provider_config['type']}({provider_config['id']}) as default TTS provider",
)
if not self.curr_tts_provider_inst:
self.curr_tts_provider_inst = inst
@@ -674,7 +676,7 @@ class ProviderManager:
):
self.curr_provider_inst = inst
logger.info(
f"已选择 {provider_config['type']}({provider_config['id']}) 作为当前提供商适配器。",
f"Selected {provider_config['type']}({provider_config['id']}) as default chat model provider",
)
if not self.curr_provider_inst:
self.curr_provider_inst = inst

View File

@@ -47,7 +47,7 @@ def register_provider_adapter(
)
provider_registry.append(pm)
provider_cls_map[provider_type_name] = pm
logger.debug(f"服务提供商 Provider {provider_type_name} 已注册")
logger.debug("Model provider registered: %s", provider_type_name)
return cls
return decorator

View File

@@ -570,7 +570,7 @@ class PluginManager:
except InvalidSpecifier:
return (
False,
"astrbot_version 格式无效,请使用 PEP 440 版本范围格式,例如 >=4.16,<5",
"Invalid astrbot_version. Use a PEP 440 range, e.g. >=4.16,<5.",
)
try:
@@ -578,13 +578,13 @@ class PluginManager:
except InvalidVersion:
return (
False,
f"AstrBot 当前版本 {VERSION} 无法被解析,无法校验插件版本范围。",
f"Invalid current AstrBot version: {VERSION}. Cannot check plugin version range.",
)
if not specifier.contains(current_version, prereleases=True):
return (
False,
f"当前 AstrBot 版本为 {VERSION},不满足插件要求的 astrbot_version: {normalized_spec}",
f"AstrBot {VERSION} does not satisfy plugin astrbot_version: {normalized_spec}",
)
return True, None
@@ -874,7 +874,7 @@ class PluginManager:
if specified_dir_name and root_dir_name != specified_dir_name:
continue
logger.info(f"正在载入插件 {root_dir_name} ...")
logger.info("Loading plugin %s ...", root_dir_name)
# 尝试导入模块
try:
@@ -993,7 +993,7 @@ class PluginManager:
setattr(metadata.star_cls, "author", p_author)
setattr(metadata.star_cls, "plugin_id", plugin_id)
else:
logger.info(f"插件 {metadata.name} 已被禁用。")
logger.info("Plugin %s is disabled.", metadata.name)
metadata.module = module
metadata.root_dir_name = root_dir_name

View File

@@ -1,3 +1,8 @@
from .cua import (
CuaKeyboardTypeTool,
CuaMouseClickTool,
CuaScreenshotTool,
)
from .fs import (
FileDownloadTool,
FileEditTool,
@@ -32,6 +37,9 @@ __all__ = [
"BrowserExecTool",
"CreateSkillCandidateTool",
"CreateSkillPayloadTool",
"CuaKeyboardTypeTool",
"CuaMouseClickTool",
"CuaScreenshotTool",
"EvaluateSkillCandidateTool",
"ExecuteShellTool",
"FileDownloadTool",

View File

@@ -0,0 +1,177 @@
from __future__ import annotations
import json
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import mcp
from astrbot.api import FunctionTool
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.computer.computer_client import get_booter
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.tools.computer_tools.util import check_admin_permission
from astrbot.core.tools.registry import builtin_tool
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
_CUA_TOOL_CONFIG = {
"provider_settings.computer_use_runtime": "sandbox",
"provider_settings.sandbox.booter": "cua",
}
def _to_json(data: Any) -> str:
return json.dumps(data, ensure_ascii=False, default=str)
def _exception_detail(error: Exception) -> str:
return str(error) or type(error).__name__
async def _get_gui_component(context: ContextWrapper[AstrAgentContext]) -> Any:
booter = await get_booter(
context.context.context,
context.context.event.unified_msg_origin,
)
gui = getattr(booter, "gui", None)
if gui is None:
raise RuntimeError(
"Current sandbox booter does not support CUA GUI capability. "
"Please switch sandbox booter to cua."
)
return gui
@builtin_tool(config=_CUA_TOOL_CONFIG)
@dataclass
class CuaScreenshotTool(FunctionTool):
name: str = "astrbot_cua_screenshot"
description: str = (
"Capture a screenshot from the CUA sandbox and optionally send it to the user."
)
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"send_to_user": {
"type": "boolean",
"description": "Whether to send the screenshot image to the current conversation.",
"default": True,
},
"return_image_to_llm": {
"type": "boolean",
"description": "Whether to include the screenshot image content in the tool result for model inspection.",
"default": True,
},
},
}
)
async def call(
self,
context: ContextWrapper[AstrAgentContext],
send_to_user: bool = True,
return_image_to_llm: bool = True,
) -> ToolExecResult:
if err := check_admin_permission(context, "Taking CUA screenshots"):
return err
try:
gui = await _get_gui_component(context)
path = _new_screenshot_path(context.context.event.unified_msg_origin)
result = await gui.screenshot(path)
payload = {"success": True, **result, "path": path}
if send_to_user:
await context.context.event.send(MessageChain().file_image(path))
payload["sent_to_user"] = True
image_data = payload.pop("base64", "")
content: list[mcp.types.TextContent | mcp.types.ImageContent] = [
mcp.types.TextContent(type="text", text=_to_json(payload))
]
if return_image_to_llm:
content.append(
mcp.types.ImageContent(
type="image",
data=str(image_data),
mimeType=str(payload.get("mime_type", "image/png")),
)
)
return mcp.types.CallToolResult(content=content)
except Exception as e:
return f"Error taking CUA screenshot: {_exception_detail(e)}"
@builtin_tool(config=_CUA_TOOL_CONFIG)
@dataclass
class CuaMouseClickTool(FunctionTool):
name: str = "astrbot_cua_mouse_click"
description: str = "Click a coordinate in the CUA sandbox desktop."
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"x": {"type": "integer", "description": "X coordinate."},
"y": {"type": "integer", "description": "Y coordinate."},
"button": {
"type": "string",
"description": "Mouse button, usually left, right, or middle.",
"default": "left",
},
},
"required": ["x", "y"],
}
)
async def call(
self,
context: ContextWrapper[AstrAgentContext],
x: int,
y: int,
button: str = "left",
) -> ToolExecResult:
if err := check_admin_permission(context, "Using CUA mouse"):
return err
try:
gui = await _get_gui_component(context)
return _to_json(await gui.click(x, y, button=button))
except Exception as e:
return f"Error clicking CUA desktop: {_exception_detail(e)}"
@builtin_tool(config=_CUA_TOOL_CONFIG)
@dataclass
class CuaKeyboardTypeTool(FunctionTool):
name: str = "astrbot_cua_keyboard_type"
description: str = "Type text into the CUA sandbox desktop."
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"text": {"type": "string", "description": "Text to type."},
},
"required": ["text"],
}
)
async def call(
self,
context: ContextWrapper[AstrAgentContext],
text: str,
) -> ToolExecResult:
if err := check_admin_permission(context, "Using CUA keyboard"):
return err
try:
gui = await _get_gui_component(context)
return _to_json(await gui.type_text(text))
except Exception as e:
return f"Error typing in CUA desktop: {_exception_detail(e)}"
def _new_screenshot_path(umo: str) -> str:
safe_prefix = uuid.uuid5(uuid.NAMESPACE_DNS, umo).hex[:12]
screenshot_dir = Path(get_astrbot_temp_path()) / "cua_screenshots"
screenshot_dir.mkdir(parents=True, exist_ok=True)
return str(screenshot_dir / f"{safe_prefix}-{uuid.uuid4().hex}.png")

View File

@@ -1,11 +1,17 @@
import json
import os
import shlex
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from astrbot.api import FunctionTool
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.computer.computer_client import get_booter
from astrbot.core.utils.astrbot_path import get_astrbot_system_tmp_path
from ..registry import builtin_tool
from .util import check_admin_permission, is_local_runtime, workspace_root
@@ -15,6 +21,32 @@ _COMPUTER_RUNTIME_TOOL_CONFIG = {
}
def _quote_redirect_path(path: str, *, local_runtime: bool) -> str:
if local_runtime and os.name == "nt":
escaped_path = path.replace('"', '""')
else:
escaped_path = path.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped_path}"'
def _build_background_output_path(*, local_runtime: bool) -> str:
file_name = f"astrbot_shell_stdout_{uuid.uuid4().hex[:8]}.log"
if local_runtime:
output_dir = Path(get_astrbot_system_tmp_path()) / "shell"
output_dir.mkdir(parents=True, exist_ok=True)
return str((output_dir / file_name).resolve(strict=False))
return f"/tmp/{file_name}"
def _redirect_background_stdout_command(
command: str,
*,
output_path: str,
local_runtime: bool,
) -> str:
return f"({command}) > {_quote_redirect_path(output_path, local_runtime=local_runtime)} 2>&1"
@builtin_tool(config=_COMPUTER_RUNTIME_TOOL_CONFIG)
@dataclass
class ExecuteShellTool(FunctionTool):
@@ -30,12 +62,17 @@ class ExecuteShellTool(FunctionTool):
},
"background": {
"type": "boolean",
"description": "Whether to run the command in the background.",
"description": "Run the command in the background. Use the file read tool to read the output later. For long running commands, using this option.",
"default": False,
},
"timeout": {
"type": "integer",
"description": "Optional timeout in seconds for the command execution.",
"default": 300,
},
"env": {
"type": "object",
"description": "Optional environment variables to set for the file creation process.",
"description": "Optional environment variables to set.",
"additionalProperties": {"type": "string"},
"default": {},
},
@@ -49,7 +86,8 @@ class ExecuteShellTool(FunctionTool):
context: ContextWrapper[AstrAgentContext],
command: str,
background: bool = False,
env: dict = {},
timeout: int | None = 300,
env: dict[str, Any] | None = None,
) -> ToolExecResult:
if permission_error := check_admin_permission(context, "Shell execution"):
return permission_error
@@ -67,12 +105,56 @@ class ExecuteShellTool(FunctionTool):
current_workspace_root.mkdir(parents=True, exist_ok=True)
cwd = str(current_workspace_root)
stdout_file: str | None = None
if background:
local_runtime = is_local_runtime(context)
stdout_file = _build_background_output_path(
local_runtime=local_runtime,
)
command = _redirect_background_stdout_command(
command,
output_path=stdout_file,
local_runtime=local_runtime,
)
env = dict(env or {})
effective_background = background and not _is_self_detached_command(command)
result = await sb.shell.exec(
command,
cwd=cwd,
background=background,
background=effective_background,
env=env,
timeout=timeout or 300,
)
if stdout_file:
result["stdout"] = (
f"Command is running in the background. stdout/stderr is being "
f"written to `{stdout_file}`. Use astrbot_file_read_tool to read it."
)
return json.dumps(result, ensure_ascii=False)
except Exception as e:
return f"Error executing command: {str(e)}"
detail = str(e) or type(e).__name__
return f"Error executing command: {detail}"
def _is_self_detached_command(command: str) -> bool:
lex = shlex.shlex(command, posix=False)
lex.whitespace_split = True
lex.commenters = ""
try:
tokens = list(lex)
except ValueError:
return False
comment_index = next(
(index for index, token in enumerate(tokens) if token.startswith("#")),
None,
)
if comment_index is not None:
tokens = tokens[:comment_index]
if not tokens:
return False
first = tokens[0].lower()
if first in {"nohup", "setsid", "disown", "start", "start-process"}:
return True
return tokens[-1] == "&"

View File

@@ -7,6 +7,7 @@ from collections.abc import Iterator
from packaging.requirements import Requirement
from astrbot.core.utils.desktop_core_lock import get_desktop_core_lock_constraints
from astrbot.core.utils.requirements_utils import (
canonicalize_distribution_name,
collect_installed_distribution_versions,
@@ -93,7 +94,14 @@ class CoreConstraintsProvider:
@contextlib.contextmanager
def constraints_file(self) -> Iterator[str | None]:
constraints = _get_core_constraints(self._core_dist_name)
constraints = tuple(
dict.fromkeys(
(
*_get_core_constraints(self._core_dist_name),
*get_desktop_core_lock_constraints(),
)
)
)
if not constraints:
yield None
return

View File

@@ -0,0 +1,108 @@
import json
import logging
import os
import re
from functools import lru_cache
from typing import Any
from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime
logger = logging.getLogger("astrbot")
DESKTOP_CORE_LOCK_PATH_ENV = "ASTRBOT_DESKTOP_CORE_LOCK_PATH"
def _canonicalize_distribution_name(name: str) -> str:
return re.sub(r"[-_.]+", "-", name).strip("-").lower()
def _safe_requirement_pin(name: str, version: str) -> str | None:
if not name or not version:
return None
if any(char.isspace() for char in name) or any(char.isspace() for char in version):
return None
return f"{name}=={version}"
def _fallback_module_name(name: str) -> str:
return _canonicalize_distribution_name(name).replace("-", "_")
def _iter_distribution_records(data: Any):
if not isinstance(data, dict):
return
distributions = data.get("distributions", [])
if not isinstance(distributions, list):
return
for record in distributions:
if isinstance(record, dict):
yield record
@lru_cache(maxsize=8)
def _load_lock_data(lock_path: str) -> dict[str, Any] | None:
try:
with open(lock_path, encoding="utf-8") as file:
data = json.load(file)
except FileNotFoundError:
logger.warning("桌面端核心依赖锁不存在: %s", lock_path)
return None
except Exception as exc:
logger.warning("读取桌面端核心依赖锁失败: %s", exc)
return None
if not isinstance(data, dict):
logger.warning("桌面端核心依赖锁格式无效: %s", lock_path)
return None
return data
def _resolve_lock_data() -> dict[str, Any] | None:
if not is_packaged_desktop_runtime():
return None
lock_path = os.environ.get(DESKTOP_CORE_LOCK_PATH_ENV, "").strip()
if not lock_path:
return None
return _load_lock_data(lock_path)
def get_desktop_core_lock_constraints() -> tuple[str, ...]:
data = _resolve_lock_data()
if not data:
return ()
constraints: dict[str, str] = {}
for record in _iter_distribution_records(data):
name = record.get("name")
version = record.get("version")
if not isinstance(name, str) or not isinstance(version, str):
continue
pin = _safe_requirement_pin(name, version)
if not pin:
continue
constraints.setdefault(_canonicalize_distribution_name(name), pin)
return tuple(constraints[key] for key in sorted(constraints))
def get_desktop_core_lock_modules() -> frozenset[str]:
data = _resolve_lock_data()
if not data:
return frozenset()
modules: set[str] = set()
for record in _iter_distribution_records(data):
name = record.get("name")
top_level_modules = record.get("top_level_modules", [])
if isinstance(top_level_modules, list):
for module_name in top_level_modules:
if isinstance(module_name, str) and module_name:
modules.add(module_name.split(".", 1)[0])
if isinstance(name, str):
fallback = _fallback_module_name(name)
if fallback:
modules.add(fallback)
return frozenset(modules)

View File

@@ -136,12 +136,14 @@ async def download_file(url: str, path: str, show_progress: bool = False) -> Non
) as session:
async with session.get(url, timeout=1800) as resp:
if resp.status != 200:
raise Exception(f"下载文件失败: {resp.status}")
logger.error(
f"Failed to download file from {url}. HTTP status code: {resp.status}"
)
total_size = int(resp.headers.get("content-length", 0))
downloaded_size = 0
start_time = time.time()
if show_progress:
print(f"文件大小: {total_size / 1024:.2f} KB | 文件地址: {url}")
print(f"Downloading: {url} | Size: {total_size / 1024:.2f} KB")
with open(path, "wb") as f:
while True:
chunk = await resp.content.read(8192)
@@ -157,13 +159,14 @@ async def download_file(url: str, path: str, show_progress: bool = False) -> Non
)
speed = downloaded_size / 1024 / elapsed_time # KB/s
print(
f"\r下载进度: {downloaded_size / total_size:.2%} 速度: {speed:.2f} KB/s",
f"\rProgress: {downloaded_size / total_size:.2%} Speed: {speed:.2f} KB/s",
end="",
)
except (aiohttp.ClientConnectorSSLError, aiohttp.ClientConnectorCertificateError):
# 关闭SSL验证仅在证书验证失败时作为fallback
logger.warning(
"SSL 证书验证失败,已关闭 SSL 验证(不安全,仅用于临时下载)。请检查目标服务器的证书配置。"
f"SSL certificate verification failed for {url}. "
"Falling back to unverified connection (CERT_NONE). "
)
logger.warning(
f"SSL certificate verification failed for {url}. "
@@ -180,7 +183,7 @@ async def download_file(url: str, path: str, show_progress: bool = False) -> Non
downloaded_size = 0
start_time = time.time()
if show_progress:
print(f"文件大小: {total_size / 1024:.2f} KB | 文件地址: {url}")
print(f"Size: {total_size / 1024:.2f} KB | URL: {url}")
with open(path, "wb") as f:
while True:
chunk = await resp.content.read(8192)
@@ -192,7 +195,7 @@ async def download_file(url: str, path: str, show_progress: bool = False) -> Non
elapsed_time = time.time() - start_time
speed = downloaded_size / 1024 / elapsed_time # KB/s
print(
f"\r下载进度: {downloaded_size / total_size:.2%} 速度: {speed:.2f} KB/s",
f"\rProgress: {downloaded_size / total_size:.2%} Speed: {speed:.2f} KB/s",
end="",
)
if show_progress:
@@ -252,7 +255,7 @@ async def download_dashboard(
ver_name = "latest" if latest else version
dashboard_release_url = f"https://astrbot-registry.soulter.top/download/astrbot-dashboard/{ver_name}/dist.zip"
logger.info(
f"准备下载指定发行版本的 AstrBot WebUI 文件: {dashboard_release_url}",
f"Downloading AstrBot WebUI from {dashboard_release_url}",
)
try:
await download_file(
@@ -274,7 +277,7 @@ async def download_dashboard(
)
else:
url = f"https://github.com/AstrBotDevs/astrbot-release-harbour/releases/download/release-{version}/dist.zip"
logger.info(f"准备下载指定版本的 AstrBot WebUI: {url}")
logger.info(f"Downloading AstrBot WebUI from {url}")
if proxy:
url = f"{proxy}/{url}"
await download_file(url, str(zip_path), show_progress=True)

View File

@@ -18,6 +18,7 @@ from urllib.parse import urlparse
from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path
from astrbot.core.utils.core_constraints import CoreConstraintsProvider
from astrbot.core.utils.desktop_core_lock import get_desktop_core_lock_modules
from astrbot.core.utils.requirements_utils import (
canonicalize_distribution_name as _canonicalize_distribution_name,
)
@@ -811,6 +812,12 @@ def _ensure_plugin_dependencies_preferred(
if not candidate_modules:
return
locked_modules = get_desktop_core_lock_modules()
if locked_modules:
candidate_modules = candidate_modules.difference(locked_modules)
if not candidate_modules:
return
_ensure_preferred_modules(candidate_modules, target_site_packages)

View File

@@ -1,7 +1,7 @@
import asyncio
import base64
import logging
import random
import re
from functools import lru_cache
from pathlib import Path
@@ -15,6 +15,11 @@ from astrbot.core.utils.t2i.template_manager import TemplateManager
from . import RenderStrategy
ASTRBOT_T2I_DEFAULT_ENDPOINT = "https://t2i.soulter.top/text2img"
SHIKI_RUNTIME_SCRIPT_ID = "astrbot-t2i-shiki-runtime"
SHIKI_RUNTIME_TEMPLATE_PATTERN = re.compile(r"\{\{\s*shiki_runtime\s*\|\s*safe\s*\}\}")
JINJA_SYNTAX_PATTERN = re.compile(r"\{[{%#]")
JINJA_RAW_OPEN_PATTERN = re.compile(r"{%-?\s*raw\s*-?%}")
JINJA_RAW_CLOSE_PATTERN = re.compile(r"{%-?\s*endraw\s*-?%}")
logger = logging.getLogger("astrbot")
@@ -41,7 +46,49 @@ def get_shiki_runtime() -> str:
)
return ""
return runtime.replace("</script", "<\\/script")
return re.sub(r"</(script)", r"<\/\1", runtime, flags=re.IGNORECASE)
def _is_inside_jinja_raw_block(tmpl_str: str, index: int) -> bool:
raw_open_index = -1
for match in JINJA_RAW_OPEN_PATTERN.finditer(tmpl_str, 0, index):
raw_open_index = match.start()
raw_close_index = -1
for match in JINJA_RAW_CLOSE_PATTERN.finditer(tmpl_str, 0, index):
raw_close_index = match.start()
return raw_open_index > raw_close_index
def _wrap_runtime_for_jinja(tmpl_str: str, script: str, index: int) -> str:
if not JINJA_SYNTAX_PATTERN.search(script) or _is_inside_jinja_raw_block(
tmpl_str,
index,
):
return script
return f"{{% raw %}}{script}{{% endraw %}}"
def inject_shiki_runtime(tmpl_str: str) -> str:
if SHIKI_RUNTIME_SCRIPT_ID in tmpl_str or SHIKI_RUNTIME_TEMPLATE_PATTERN.search(
tmpl_str,
):
return tmpl_str
runtime = get_shiki_runtime()
if not runtime:
return tmpl_str
script = f'<script id="{SHIKI_RUNTIME_SCRIPT_ID}">{runtime}</script>'
head_close = re.search(r"</head\s*>", tmpl_str, flags=re.IGNORECASE)
if head_close:
script = _wrap_runtime_for_jinja(tmpl_str, script, head_close.start())
return f"{tmpl_str[: head_close.start()]} {script}\n{tmpl_str[head_close.start() :]}"
script = _wrap_runtime_for_jinja(tmpl_str, script, 0)
return f"{script}\n{tmpl_str}"
class NetworkRenderStrategy(RenderStrategy):
@@ -101,11 +148,17 @@ class NetworkRenderStrategy(RenderStrategy):
options: dict | None = None,
) -> str:
"""使用自定义文转图模板"""
default_options = {"full_page": True, "type": "jpeg", "quality": 40}
default_options = {
"full_page": True,
"type": "jpeg",
"quality": 40,
}
if options:
default_options |= options
tmpl_data = {"shiki_runtime": get_shiki_runtime()} | tmpl_data
if SHIKI_RUNTIME_TEMPLATE_PATTERN.search(tmpl_str):
tmpl_data = {"shiki_runtime": get_shiki_runtime()} | tmpl_data
tmpl_str = inject_shiki_runtime(tmpl_str)
post_data = {
"tmpl": tmpl_str,
"json": return_url,
@@ -158,9 +211,11 @@ class NetworkRenderStrategy(RenderStrategy):
if not template_name:
template_name = "base"
tmpl_str = await self.get_template(name=template_name)
text_base64 = base64.b64encode(text.encode("utf-8")).decode("ascii")
return await self.render_custom_template(
tmpl_str,
{"text_base64": text_base64, "version": f"v{VERSION}"},
{
"text": text,
"version": f"v{VERSION}",
},
return_url,
)

View File

@@ -174,14 +174,14 @@
<div id="content"></div>
</main>
<script>{{ shiki_runtime | safe }}</script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.js" integrity="sha384-hIoBPJpTUs74ddyc4bFZSM1TVlQDA60VBbJS0oA934VSz82sBx1X7kSx2ATBDIyd" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/contrib/auto-render.min.js" integrity="sha384-43gviWU0YVjaDtb/GhzOouOXtZMP/7XUzwPTstBeZFe/+rCMvRwr4yROQP43s0Xk" crossorigin="anonymous"></script>
<textarea id="markdown-source" hidden>{{ text | safe }}</textarea>
<script>
(function () {
const contentElement = document.getElementById("content");
const source = decodeBase64Utf8("{{ text_base64 }}");
const source = document.getElementById("markdown-source").value;
contentElement.innerHTML = marked.parse(source);
@@ -198,20 +198,6 @@
});
}
function decodeBase64Utf8(base64Text) {
const binary = window.atob(base64Text || "");
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
if (window.TextDecoder) {
return new TextDecoder().decode(bytes);
}
let fallback = "";
bytes.forEach((byte) => {
fallback += String.fromCharCode(byte);
});
return decodeURIComponent(escape(fallback));
}
})();
</script>
</body>

View File

@@ -415,14 +415,14 @@
<footer class="vp-footer">Rendered by AstrBot {{ version }}</footer>
</div>
<script>{{ shiki_runtime | safe }}</script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.js" integrity="sha384-hIoBPJpTUs74ddyc4bFZSM1TVlQDA60VBbJS0oA934VSz82sBx1X7kSx2ATBDIyd" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/contrib/auto-render.min.js" integrity="sha384-43gviWU0YVjaDtb/GhzOouOXtZMP/7XUzwPTstBeZFe/+rCMvRwr4yROQP43s0Xk" crossorigin="anonymous"></script>
<textarea id="markdown-source" hidden>{{ text | safe }}</textarea>
<script>
(function () {
const contentElement = document.getElementById("content");
const source = decodeBase64Utf8("{{ text_base64 }}");
const source = document.getElementById("markdown-source").value;
marked.setOptions({
gfm: true,
@@ -446,21 +446,6 @@
const headings = collectHeadings(contentElement);
populateHero(contentElement, headings);
function decodeBase64Utf8(base64Text) {
const binary = window.atob(base64Text || "");
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
if (window.TextDecoder) {
return new TextDecoder().decode(bytes);
}
let fallback = "";
bytes.forEach((byte) => {
fallback += String.fromCharCode(byte);
});
return decodeURIComponent(escape(fallback));
}
function escapeHtml(value) {
return String(value || "")
.replaceAll("&", "&amp;")

View File

@@ -242,14 +242,14 @@
</div>
<article style="margin-top: 32px" id="content"></article>
<script>{{ shiki_runtime | safe }}</script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.js" integrity="sha384-hIoBPJpTUs74ddyc4bFZSM1TVlQDA60VBbJS0oA934VSz82sBx1X7kSx2ATBDIyd" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/contrib/auto-render.min.js" integrity="sha384-43gviWU0YVjaDtb/GhzOouOXtZMP/7XUzwPTstBeZFe/+rCMvRwr4yROQP43s0Xk" crossorigin="anonymous"></script>
<textarea id="markdown-source" hidden>{{ text | safe }}</textarea>
<script>
(function () {
const contentElement = document.getElementById("content");
const source = decodeBase64Utf8("{{ text_base64 }}");
const source = document.getElementById("markdown-source").value;
contentElement.innerHTML = marked.parse(source);
@@ -266,20 +266,6 @@
});
}
function decodeBase64Utf8(base64Text) {
const binary = window.atob(base64Text || "");
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
if (window.TextDecoder) {
return new TextDecoder().decode(bytes);
}
let fallback = "";
bytes.forEach((byte) => {
fallback += String.fromCharCode(byte);
});
return decodeURIComponent(escape(fallback));
}
})();
</script>
</body>

View File

@@ -819,6 +819,19 @@ class ChatRoute(Route):
refs = {}
return saved_record
def build_attachment_saved_event(part: dict | None) -> str | None:
if not part or not part.get("attachment_id") or not part.get("type"):
return None
payload = {
"type": "attachment_saved",
"data": {
"id": part["attachment_id"],
"type": part["type"],
},
}
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
try:
# Emit session_id first so clients can bind the stream immediately.
session_info = {
@@ -908,12 +921,20 @@ class ChatRoute(Route):
filename, "image"
)
message_accumulator.add_attachment(part)
if attachment_saved_event := build_attachment_saved_event(
part
):
yield attachment_saved_event
elif msg_type == "record":
filename = result_text.replace("[RECORD]", "")
part = await self._create_attachment_from_file(
filename, "record"
)
message_accumulator.add_attachment(part)
if attachment_saved_event := build_attachment_saved_event(
part
):
yield attachment_saved_event
elif msg_type == "file":
# 格式: [FILE]filename
filename = result_text.replace("[FILE]", "")
@@ -921,12 +942,20 @@ class ChatRoute(Route):
filename, "file"
)
message_accumulator.add_attachment(part)
if attachment_saved_event := build_attachment_saved_event(
part
):
yield attachment_saved_event
elif msg_type == "video":
filename = result_text.replace("[VIDEO]", "")
part = await self._create_attachment_from_file(
filename, "video"
)
message_accumulator.add_attachment(part)
if attachment_saved_event := build_attachment_saved_event(
part
):
yield attachment_saved_event
should_save = False
if msg_type == "end":

View File

@@ -537,6 +537,22 @@ class LiveChatRoute(Route):
pending_bot_message_flusher = flush_pending_bot_message
async def send_attachment_saved_event(part: dict | None) -> None:
if not part or not part.get("attachment_id") or not part.get("type"):
return
await self._send_chat_payload(
session,
{
"ct": "chat",
"type": "attachment_saved",
"data": {
"id": part["attachment_id"],
"type": part["type"],
},
},
)
while True:
if session.should_interrupt:
session.should_interrupt = False
@@ -586,18 +602,22 @@ class LiveChatRoute(Route):
filename = str(result_text).replace("[IMAGE]", "")
part = await self._create_attachment_from_file(filename, "image")
message_accumulator.add_attachment(part)
await send_attachment_saved_event(part)
elif msg_type == "record":
filename = str(result_text).replace("[RECORD]", "")
part = await self._create_attachment_from_file(filename, "record")
message_accumulator.add_attachment(part)
await send_attachment_saved_event(part)
elif msg_type == "file":
filename = str(result_text).replace("[FILE]", "").split("|", 1)[0]
part = await self._create_attachment_from_file(filename, "file")
message_accumulator.add_attachment(part)
await send_attachment_saved_event(part)
elif msg_type == "video":
filename = str(result_text).replace("[VIDEO]", "").split("|", 1)[0]
part = await self._create_attachment_from_file(filename, "video")
message_accumulator.add_attachment(part)
await send_attachment_saved_event(part)
should_save = False
if msg_type == "end":

View File

@@ -243,6 +243,7 @@ class StatRoute(Route):
total_by_umo: dict[str, int] = defaultdict(int)
total_by_bucket: dict[int, int] = defaultdict(int)
range_total_tokens = 0
range_total_output_tokens = 0
range_total_calls = 0
range_success_calls = 0
range_ttft_total_ms = 0.0
@@ -286,6 +287,7 @@ class StatRoute(Route):
record.end_time - record.start_time
) * 1000
range_duration_samples += 1
range_total_output_tokens += record.token_output
if created_at_local >= today_start_local:
today_total_calls += 1
@@ -371,7 +373,8 @@ class StatRoute(Route):
else 0
),
"range_avg_tpm": (
range_total_tokens / (range_duration_total_ms / 1000 / 60)
range_total_output_tokens
/ (range_duration_total_ms / 1000 / 60)
if range_duration_total_ms > 0
else 0
),

View File

@@ -322,7 +322,7 @@ class AstrBotDashboard:
if not cert_file or not key_file:
logger.warning(
"dashboard.ssl.enable 已启用,但未同时配置 cert_file key_fileSSL 配置将不会生效。",
"dashboard.ssl.enable is set, but cert_file or key_file is missing. SSL disabled.",
)
return False, {}
@@ -330,12 +330,12 @@ class AstrBotDashboard:
key_path = Path(key_file).expanduser()
if not cert_path.is_file():
logger.warning(
f"dashboard.ssl.enable 已启用,但 SSL 证书文件不存在: {cert_path}SSL 配置将不会生效。",
f"dashboard.ssl.enable is set, but cert file is missing: {cert_path}. SSL disabled.",
)
return False, {}
if not key_path.is_file():
logger.warning(
f"dashboard.ssl.enable 已启用,但 SSL 私钥文件不存在: {key_path}SSL 配置将不会生效。",
f"dashboard.ssl.enable is set, but key file is missing: {key_path}. SSL disabled.",
)
return False, {}
@@ -348,7 +348,7 @@ class AstrBotDashboard:
ca_path = Path(ca_certs).expanduser()
if not ca_path.is_file():
logger.warning(
f"dashboard.ssl.enable 已启用,但 SSL CA 证书文件不存在: {ca_path}SSL 配置将不会生效。",
f"dashboard.ssl.enable is set, but CA cert file is missing: {ca_path}. SSL disabled.",
)
return False, {}
resolved_ssl_config["ca_certs"] = str(ca_path.resolve())
@@ -385,13 +385,13 @@ class AstrBotDashboard:
scheme = "https" if ssl_enable else "http"
if not enable:
logger.info("WebUI 已被禁用")
logger.info("WebUI disabled.")
return None
logger.info(f"正在启动 WebUI, 监听地址: {scheme}://{host}:{port}")
logger.info("Starting WebUI at %s://%s:%s", scheme, host, port)
if host == "0.0.0.0":
logger.info(
"提示: WebUI 将监听所有网络接口,请注意安全。(可在 data/cmd_config.json 中配置 dashboard.host 以修改 host",
"WebUI listens on all interfaces. Check security. Set dashboard.host in data/cmd_config.json to change it.",
)
if host not in ["localhost", "127.0.0.1"]:
@@ -415,16 +415,16 @@ class AstrBotDashboard:
raise Exception(f"端口 {port} 已被占用")
parts = [f"\n ✨✨✨\n AstrBot v{VERSION} WebUI 已启动,可访问\n\n"]
parts.append(f"本地: {scheme}://localhost:{port}\n")
parts = [f"\n ✨✨✨\n AstrBot v{VERSION} WebUI is ready\n\n"]
parts.append(f"Local: {scheme}://localhost:{port}\n")
for ip in ip_addr:
parts.append(f"网络: {scheme}://{ip}:{port}\n")
parts.append("默认用户名和密码: astrbot\n ✨✨✨\n")
parts.append(f"Network: {scheme}://{ip}:{port}\n")
parts.append("Default username/password: astrbot / astrbot\n ✨✨✨\n")
display = "".join(parts)
if not ip_addr:
display += (
"可在 data/cmd_config.json 中配置 dashboard.host 以便远程访问。\n"
"Set dashboard.host in data/cmd_config.json to enable remote access.\n"
)
logger.info(display)

56
changelogs/v4.23.6.md Normal file
View File

@@ -0,0 +1,56 @@
- [更新日志(简体中文)](#chinese)
- [Changelog(English)](#english)
<a id="chinese"></a>
## What's Changed
### 新增
- 新增 `/stats` 命令,可查看当前会话的 Token 使用统计,并按总量、输入(缓存)、输入(其他)与输出拆分展示。([#7831](https://github.com/AstrBotDevs/AstrBot/pull/7831)
- 新增 Firecrawl Web 搜索与网页提取工具,支持搜索结果处理、网页内容提取、会话管理、请求校验与相关测试。([#7764](https://github.com/AstrBotDevs/AstrBot/pull/7764)
- 微信客服文本消息新增 15 秒内去重,减少重复消息处理。([#7788](https://github.com/AstrBotDevs/AstrBot/pull/7788)
### 优化
- 优化 Provider 配置界面性能与响应式显示,改善相关组件的字体和布局体验。([#7772](https://github.com/AstrBotDevs/AstrBot/pull/7772)
- 优化统计页 TPM 计算逻辑TPM 现在仅统计输出 Token并更新相关文案。[#7827](https://github.com/AstrBotDevs/AstrBot/pull/7827)
- 优化 OpenAI 兼容 Provider 的空 assistant 消息过滤逻辑,流式与非流式路径统一处理空字符串和空列表内容,避免严格 Provider 拒绝历史消息。([#7758](https://github.com/AstrBotDevs/AstrBot/pull/7758)
### 修复
- 修复 DeepSeek v4 与 reasoning content 相关处理,支持空字符串 reasoning 内容,并在 assistant 消息中保留 reasoning 字段。([#7823](https://github.com/AstrBotDevs/AstrBot/pull/7823), [#7830](https://github.com/AstrBotDevs/AstrBot/pull/7830)
- 修复 OpenRouter reasoning 字段属性名不正确的问题。([#7821](https://github.com/AstrBotDevs/AstrBot/pull/7821)
- 修复超大图片未压缩可能导致后续处理异常的问题,并复用图片最大尺寸检查工具。([#7807](https://github.com/AstrBotDevs/AstrBot/pull/7807)
- 修复 MiniMax TTS 默认输出 MP3 导致 QQ 官方平台语音转换出现 RIFF 错误的问题,默认输出格式改为 WAV。[#7797](https://github.com/AstrBotDevs/AstrBot/pull/7797)
- 修复 Computer 沙盒图片下载未按图片发送的问题。([#7785](https://github.com/AstrBotDevs/AstrBot/pull/7785)
- 修复 Windows 环境下部分 HTTPS 请求证书校验失败的问题,使用 certifi SSL context 提升兼容性。([#7778](https://github.com/AstrBotDevs/AstrBot/pull/7778)
- 修复非安全上下文或部分对话框中复制功能不可用的问题,抽取共享剪贴板工具并增加 fallback。[#7747](https://github.com/AstrBotDevs/AstrBot/pull/7747)
- 修复文件上传可能存在路径穿越的问题,并清理上传文件名中的 NUL 字节。([#7751](https://github.com/AstrBotDevs/AstrBot/pull/7751)
<a id="english"></a>
## What's Changed (EN)
### New Features
- Added a `/stats` command to show token usage for the current conversation, including total tokens, input cached tokens, input other tokens, and output tokens. ([#7831](https://github.com/AstrBotDevs/AstrBot/pull/7831))
- Added Firecrawl web search and web extract tools with result handling, content extraction, session management, payload validation, and tests. ([#7764](https://github.com/AstrBotDevs/AstrBot/pull/7764))
- Added 15-second deduplication for WeChat kefu text messages to reduce duplicate message handling. ([#7788](https://github.com/AstrBotDevs/AstrBot/pull/7788))
### Improvements
- Improved the Provider configuration UI performance and responsive layout, including font and component styling updates. ([#7772](https://github.com/AstrBotDevs/AstrBot/pull/7772))
- Updated stats-page TPM calculation so TPM only counts output tokens, with matching label updates. ([#7827](https://github.com/AstrBotDevs/AstrBot/pull/7827))
- Improved empty assistant message filtering for OpenAI-compatible providers by sharing the logic across streaming and non-streaming paths and handling empty string or empty list content. ([#7758](https://github.com/AstrBotDevs/AstrBot/pull/7758))
### Bug Fixes
- Fixed DeepSeek v4 and reasoning content handling by supporting empty-string reasoning content and preserving the reasoning field in assistant messages. ([#7823](https://github.com/AstrBotDevs/AstrBot/pull/7823), [#7830](https://github.com/AstrBotDevs/AstrBot/pull/7830))
- Fixed the reasoning field attribute used for OpenRouter. ([#7821](https://github.com/AstrBotDevs/AstrBot/pull/7821))
- Fixed oversized image handling by downscaling large images and sharing the image max-size check helper. ([#7807](https://github.com/AstrBotDevs/AstrBot/pull/7807))
- Fixed MiniMax TTS output for QQ Official voice conversion by changing the default output format from MP3 to WAV. ([#7797](https://github.com/AstrBotDevs/AstrBot/pull/7797))
- Fixed Computer sandbox image downloads so they are sent as images. ([#7785](https://github.com/AstrBotDevs/AstrBot/pull/7785))
- Fixed HTTPS certificate verification issues on Windows by using a certifi SSL context. ([#7778](https://github.com/AstrBotDevs/AstrBot/pull/7778))
- Fixed copy actions in insecure contexts and dialogs by extracting a shared clipboard utility with fallback behavior. ([#7747](https://github.com/AstrBotDevs/AstrBot/pull/7747))
- Fixed path traversal risks in file uploads and removed embedded NUL bytes from upload filenames. ([#7751](https://github.com/AstrBotDevs/AstrBot/pull/7751))

View File

@@ -112,6 +112,10 @@
ref="inputField"
v-model="localPrompt"
@keydown="handleKeyDown"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
@compositioncancel="handleCompositionEnd"
@blur="clearCompositionState()"
:disabled="disabled"
placeholder="Ask AstrBot..."
class="chat-textarea"
@@ -307,6 +311,7 @@ import {
import { useDisplay } from "vuetify";
import { useModuleI18n } from "@/i18n/composables";
import { useCustomizerStore } from "@/stores/customizer";
import { isComposingEnter } from "@/utils/imeInput.mjs";
import ConfigSelector from "./ConfigSelector.vue";
import ProviderModelMenu from "./ProviderModelMenu.vue";
import StyledMenu from "@/components/shared/StyledMenu.vue";
@@ -379,6 +384,8 @@ const providerModelMenuRef = ref<InstanceType<typeof ProviderModelMenu> | null>(
const showProviderSelector = ref(true);
const isReplyClosing = ref(false);
const isDragging = ref(false);
const isComposing = ref(false);
const lastCompositionEndAt = ref<number | null>(null);
let dragLeaveTimeout: number | null = null;
const localPrompt = computed({
@@ -514,6 +521,10 @@ function handleKeyDown(e: KeyboardEvent) {
return;
}
if (isComposingEnter(e, isComposing.value, lastCompositionEndAt.value)) {
return;
}
const isSendHotkey =
e.ctrlKey ||
e.metaKey ||
@@ -533,6 +544,23 @@ function handleKeyDown(e: KeyboardEvent) {
}
}
function handleCompositionStart() {
isComposing.value = true;
lastCompositionEndAt.value = null;
}
function handleCompositionEnd(e: CompositionEvent) {
lastCompositionEndAt.value = e.timeStamp;
clearCompositionState({ keepLastEndAt: true });
}
function clearCompositionState({ keepLastEndAt = false } = {}) {
isComposing.value = false;
if (!keepLastEndAt) {
lastCompositionEndAt.value = null;
}
}
function handleKeyUp(e: KeyboardEvent) {
if (e.keyCode === 66) {
ctrlKeyDown.value = false;
@@ -634,6 +662,7 @@ onBeforeUnmount(() => {
if (inputField.value) {
inputField.value.removeEventListener("paste", handlePaste);
}
clearCompositionState();
document.removeEventListener("keyup", handleKeyUp);
});

View File

@@ -141,7 +141,7 @@ const getRowProps = ({ item }: { item: CommandItem }) => {
</template>
<template v-slot:item.description="{ item }">
<div class="text-body-2 text-medium-emphasis" style="max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
<div class="text-body-2 text-medium-emphasis" style="max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" :title="item.description">
{{ item.description || '-' }}
</div>
</template>

View File

@@ -121,7 +121,7 @@ const enabledConfigTags = (tool: ToolItem): BuiltinToolConfigTag[] => {
</template>
<template #item.description="{ item }">
<div class="text-body-2 text-medium-emphasis" style="max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
<div class="text-body-2 text-medium-emphasis" style="max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" :title="item.description">
{{ item.description || '-' }}
</div>
</template>
@@ -133,7 +133,7 @@ const enabledConfigTags = (tool: ToolItem): BuiltinToolConfigTag[] => {
</template>
<template #item.origin_name="{ item }">
<div class="text-body-2 text-medium-emphasis" style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
<div class="text-body-2 text-medium-emphasis" style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" :title="item.origin_name">
{{ item.origin_name || '-' }}
</div>
</template>

View File

@@ -298,17 +298,82 @@ const syncPreviewVersion = async () => {
const previewData = computed(() => ({
text: tm('t2iTemplateEditor.previewText') || '这是一个示例文本,用于预览模板效果。\n\n这里可以包含多行文本支持换行和各种格式。',
version: previewVersion.value
version: previewVersion.value
}))
const injectShikiRuntime = (content) => {
if (content.includes('astrbot-t2i-shiki-runtime')) {
return content
}
const runtimeScript = getShikiRuntimeScript()
const headClose = content.search(/<\/head\s*>/i)
if (headClose >= 0) {
return `${content.slice(0, headClose)} ${runtimeScript}\n${content.slice(headClose)}`
}
return `${runtimeScript}\n${content}`
}
const getShikiRuntimeScript = () => '<script id="astrbot-t2i-shiki-runtime" src="/t2i/shiki_runtime.iife.js"></scr' + 'ipt>'
const hasMarkdownSource = (content) => /<[^>]+\bid=["']markdown-source["']/i.test(content)
const insertMarkdownSource = (content) => {
const sourceElement = ' <textarea id="markdown-source" hidden>{{ text | safe }}</textarea>\n'
const markedScript = content.search(/^[ \t]*<script\s+src=["']https:\/\/cdn\.jsdelivr\.net\/npm\/marked\/marked\.min\.js["']><\/script>[ \t]*\r?\n?/im)
if (markedScript >= 0) {
return `${content.slice(0, markedScript)}${sourceElement}${content.slice(markedScript)}`
}
const bodyClose = content.search(/<\/body\s*>/i)
if (bodyClose >= 0) {
return `${content.slice(0, bodyClose)}${sourceElement}${content.slice(bodyClose)}`
}
return `${sourceElement}${content}`
}
const normalizeMarkdownSource = (content) => {
let normalized = content.replace(
/<script\s+id=["']markdown-source["']\s+type=["']text\/plain["']>\s*\{\{\s*text\s*\|\s*safe\s*\}\}\s*<\/script>/gi,
'<textarea id="markdown-source" hidden>{{ text | safe }}</textarea>'
)
normalized = normalized.replace(
/decodeBase64Utf8\("\{\{\s*text_base64\s*\}\}"\)/g,
'document.getElementById("markdown-source").value'
)
normalized = normalized.replace(
/document\.getElementById\(["']markdown-source["']\)\.textContent/g,
'document.getElementById("markdown-source").value'
)
if (/\{\{\s*text_base64\s*\}\}/.test(normalized) && !hasMarkdownSource(normalized)) {
normalized = insertMarkdownSource(normalized)
}
return normalized
}
const previewContent = computed(() => {
try {
let content = templateContent.value
content = content.replace(/\{\{\s*text\s*\|\s*safe\s*\}\}/g, previewData.value.text)
content = content.replace(/\{\{\s*version\s*\}\}/g, previewData.value.version)
return content
let content = normalizeMarkdownSource(templateContent.value)
content = content.replace(/\{\{\s*text\s*\|\s*safe\s*\}\}/g, () => previewData.value.text)
content = content.replace(/\{\{\s*version\s*\}\}/g, () => previewData.value.version)
let usedLegacyShikiPlaceholder = false
content = content.replace(/<script\b[^>]*>\s*\{\{\s*shiki_runtime\s*\|\s*safe\s*\}\}\s*<\/script>/gi, () => {
usedLegacyShikiPlaceholder = true
return getShikiRuntimeScript()
})
content = content.replace(/\{\{\s*shiki_runtime\s*\|\s*safe\s*\}\}/g, () => {
usedLegacyShikiPlaceholder = true
return getShikiRuntimeScript()
})
return usedLegacyShikiPlaceholder ? content : injectShikiRuntime(content)
} catch (error) {
return `<div style="color: red; padding: 20px;">模板渲染错误: ${error.message}</div>`
const errorMessage = error instanceof Error ? error.message : String(error)
return `<div style="color: red; padding: 20px;">模板渲染错误: ${errorMessage}</div>`
}
})

View File

@@ -97,7 +97,7 @@
"livePreview": "Live Preview (may differ)",
"refreshPreview": "Refresh Preview",
"previewText": "This is a sample text used to preview the template output.\n\nIt can contain multiple lines and various formatting.",
"syntaxHint": "Supports jinja2 syntax. Available variables: text | safe (text to render), version (AstrBot version)",
"syntaxHint": "Supports jinja2 syntax. Available variables: text | safe (text to render), version (AstrBot version). If the preview test content does not load or looks wrong, click Reset Base to restore the default template.",
"saveAndApply": "Save and Apply Current Template",
"confirmReset": "Confirm Reset",
"confirmResetMessage": "Are you sure you want to reset the 'base' template to default content? Any unsaved changes in the editor will be lost. This action cannot be undone.",

View File

@@ -186,6 +186,30 @@
"description": "Shipyard Neo Sandbox TTL",
"hint": "Sandbox time-to-live in seconds."
},
"cua_image": {
"description": "CUA Image",
"hint": "CUA sandbox image or OS type. Defaults to linux. Supported values depend on the installed CUA SDK."
},
"cua_os_type": {
"description": "CUA OS Type",
"hint": "CUA sandbox operating system type. Defaults to linux."
},
"cua_ttl": {
"description": "CUA Sandbox TTL",
"hint": "CUA sandbox time-to-live in seconds. Actual behavior depends on the installed CUA SDK."
},
"cua_telemetry_enabled": {
"description": "CUA Telemetry",
"hint": "Allow the CUA SDK to send telemetry data. Disabled by default."
},
"cua_local": {
"description": "CUA Local Sandbox",
"hint": "Prefer a local CUA sandbox. Enabled by default to avoid requiring CUA_API_KEY for cloud sandboxes. Disable this to use CUA cloud sandboxes."
},
"cua_api_key": {
"description": "CUA API Key",
"hint": "CUA cloud sandbox API key. Required only when local sandbox is disabled. You can also provide it via the CUA_API_KEY environment variable."
},
"shipyard_endpoint": {
"description": "Shipyard API Endpoint",
"hint": "API access address for Shipyard service."

View File

@@ -67,7 +67,7 @@
"callCount": "{count} calls",
"avgTtft": "Average TTFT",
"avgDuration": "Average Response Time",
"avgTpm": "Average TPM",
"avgTpm": "Average Output TPM",
"successRate": "Success Rate"
},
"modelRanking": {

View File

@@ -98,7 +98,7 @@
"livePreview": "Предпросмотр (может отличаться)",
"refreshPreview": "Обновить",
"previewText": "Это пример текста для предпросмотра результата шаблона.\n\nОн может содержать несколько строк и различные форматы.",
"syntaxHint": "Поддерживается синтаксис jinja2. Переменные: text | safe (текст для рендеринга), version (версия AstrBot)",
"syntaxHint": "Поддерживается синтаксис jinja2. Переменные: text | safe (текст для рендеринга), version (версия AstrBot). Если тестовый текст предпросмотра не загружается или выглядит неверно, нажмите «Сбросить 'base'», чтобы восстановить шаблон по умолчанию.",
"saveAndApply": "Сохранить и применить текущий шаблон",
"confirmReset": "Подтверждение сброса",
"confirmResetMessage": "Вы уверены, что хотите сбросить шаблон 'base' до значений по умолчанию? Все несохраненные изменения будут потеряны. Это действие необратимо.",
@@ -109,4 +109,4 @@
"confirmAction": "Подтверждение действия",
"confirmApplyMessage": "Вы уверены, что хотите сохранить изменения в '{name}' и сделать его активным шаблоном?"
}
}
}

View File

@@ -186,6 +186,30 @@
"description": "TTL песочницы Shipyard Neo",
"hint": "Время жизни песочницы в секундах."
},
"cua_image": {
"description": "Образ CUA",
"hint": "Образ или тип ОС песочницы CUA. По умолчанию linux. Поддерживаемые значения зависят от установленного CUA SDK."
},
"cua_os_type": {
"description": "Тип ОС CUA",
"hint": "Тип операционной системы песочницы CUA. По умолчанию linux."
},
"cua_ttl": {
"description": "TTL песочницы CUA",
"hint": "Время жизни песочницы CUA в секундах. Фактическое поведение зависит от установленного CUA SDK."
},
"cua_telemetry_enabled": {
"description": "Телеметрия CUA",
"hint": "Разрешить CUA SDK отправлять телеметрию. По умолчанию выключено."
},
"cua_local": {
"description": "Локальная песочница CUA",
"hint": "Предпочитать локальную песочницу CUA. Включено по умолчанию, чтобы не требовать CUA_API_KEY для облачных песочниц. Отключите для использования облачных песочниц CUA."
},
"cua_api_key": {
"description": "CUA API Key",
"hint": "API key для облачной песочницы CUA. Требуется только если локальная песочница отключена. Также можно передать через переменную окружения CUA_API_KEY."
},
"shipyard_endpoint": {
"description": "Эндпоинт Shipyard API",
"hint": "Адрес API для доступа к сервису Shipyard."

View File

@@ -67,7 +67,7 @@
"callCount": "{count} вызовов",
"avgTtft": "Средний TTFT",
"avgDuration": "Среднее время ответа",
"avgTpm": "Средний TPM",
"avgTpm": "Средний Output TPM",
"successRate": "Доля успешных вызовов"
},
"modelRanking": {

View File

@@ -97,7 +97,7 @@
"livePreview": "实时预览(可能有差异)",
"refreshPreview": "刷新预览",
"previewText": "这是一个示例文本,用于预览模板效果。\n\n这里可以包含多行文本支持换行和各种格式。",
"syntaxHint": "支持 jinja2 语法。可用变量text | safe要渲染的文本, versionAstrBot 版本)",
"syntaxHint": "支持 jinja2 语法。可用变量text | safe要渲染的文本, versionAstrBot 版本)。如果预览测试内容未加载或显示异常请点击“重置Base”恢复默认模板。",
"saveAndApply": "保存应用当前编辑模板",
"confirmReset": "确认重置",
"confirmResetMessage": "确定要将 'base' 模板恢复为默认内容吗?当前编辑器中的任何未保存更改将丢失。此操作无法撤销。",

View File

@@ -188,6 +188,30 @@
"description": "Shipyard Neo Sandbox 存活时间(秒)",
"hint": "Shipyard Neo 沙箱的生存时间(秒)。"
},
"cua_image": {
"description": "CUA 镜像",
"hint": "CUA 沙箱镜像/系统类型,默认 linux。可填写 linux、macos、windows、android具体取决于 CUA SDK 支持。"
},
"cua_os_type": {
"description": "CUA 操作系统类型",
"hint": "CUA 沙箱操作系统类型,默认 linux。"
},
"cua_ttl": {
"description": "CUA Sandbox 存活时间(秒)",
"hint": "CUA 沙箱生存时间(秒)。当前作为会话配置保存,具体生效取决于 CUA SDK。"
},
"cua_telemetry_enabled": {
"description": "CUA 遥测",
"hint": "是否允许 CUA SDK 发送遥测数据。默认关闭。"
},
"cua_local": {
"description": "CUA 本地沙箱",
"hint": "是否优先使用 CUA 本地沙箱。默认开启,避免云端沙箱要求 CUA_API_KEY。关闭后可使用 CUA 云端沙箱。"
},
"cua_api_key": {
"description": "CUA API Key",
"hint": "CUA 云端沙箱 API Key。仅在关闭本地沙箱时需要。也可以通过 CUA_API_KEY 环境变量提供。"
},
"shipyard_endpoint": {
"description": "Shipyard API Endpoint",
"hint": "Shipyard 服务的 API 访问地址。"

View File

@@ -67,7 +67,7 @@
"callCount": "共 {count} 次调用",
"avgTtft": "平均首字延迟TTFT",
"avgDuration": "平均响应时间",
"avgTpm": "平均每分钟词元数TPM",
"avgTpm": "平均每分钟输出TPM",
"successRate": "调用成功率"
},
"modelRanking": {

View File

@@ -0,0 +1,31 @@
// Some IMEs emit Enter right after compositionend; treat that same-keystroke
// window as composition so selecting a candidate does not send the message.
const RECENT_COMPOSITION_END_THRESHOLD_MS = 100;
/**
* @param {KeyboardEvent} event
* @param {boolean} compositionActive
* @param {number | null} lastCompositionEndAt
*/
export function isComposingEnter(
event,
compositionActive,
lastCompositionEndAt = null,
) {
const hasLegacyCompositionKeyCode =
typeof event.keyCode === "number" && event.keyCode === 229;
const isAfterRecentCompositionEnd =
typeof event.timeStamp === "number" &&
typeof lastCompositionEndAt === "number" &&
event.timeStamp >= lastCompositionEndAt &&
event.timeStamp - lastCompositionEndAt <
RECENT_COMPOSITION_END_THRESHOLD_MS;
return (
event.key === "Enter" &&
(compositionActive ||
event.isComposing ||
hasLegacyCompositionKeyCode ||
isAfterRecentCompositionEnd)
);
}

View File

@@ -0,0 +1,36 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isComposingEnter } from "../src/utils/imeInput.mjs";
test("detects Enter while an IME composition is active", () => {
assert.equal(isComposingEnter({ key: "Enter", isComposing: true }, false), true);
assert.equal(isComposingEnter({ key: "Enter", isComposing: false }, true), true);
});
test("does not treat normal Enter as IME composition", () => {
assert.equal(isComposingEnter({ key: "Enter", isComposing: false }, false), false);
assert.equal(isComposingEnter({ key: "a", isComposing: true }, true), false);
});
test("detects Enter fired immediately after composition ended", () => {
assert.equal(
isComposingEnter(
{ key: "Enter", isComposing: false, timeStamp: 105 },
false,
100,
),
true,
);
});
test("does not treat delayed Enter after composition ended as IME composition", () => {
assert.equal(
isComposingEnter(
{ key: "Enter", isComposing: false, timeStamp: 250 },
false,
100,
),
false,
);
});

View File

@@ -1,11 +1,16 @@
import { readFileSync } from 'fs';
import { fileURLToPath, URL } from 'url';
import { defineConfig } from 'vite';
import { defineConfig, type Plugin } from 'vite';
import vue from '@vitejs/plugin-vue';
import vuetify from 'vite-plugin-vuetify';
import webfontDl from 'vite-plugin-webfont-dl';
// @ts-ignore — .mjs not in TS project scope; Vite resolves this at runtime
import { runMdiSubset } from './scripts/subset-mdi-font.mjs';
const t2iShikiRuntimePath = fileURLToPath(
new URL('../astrbot/core/utils/t2i/template/shiki_runtime.iife.js', import.meta.url)
);
// Vite plugin: run MDI icon font subsetting (build only)
function mdiSubset() {
return {
@@ -17,11 +22,45 @@ function mdiSubset() {
};
}
function t2iShikiRuntimeAsset(): Plugin {
return {
name: 'vite-plugin-t2i-shiki-runtime',
configureServer(server) {
server.middlewares.use('/t2i/shiki_runtime.iife.js', (_req, res) => {
try {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/javascript; charset=utf-8');
res.end(readFileSync(t2iShikiRuntimePath));
} catch (error) {
res.statusCode = 404;
res.end(`T2I Shiki runtime not found: ${error instanceof Error ? error.message : String(error)}`);
}
});
},
generateBundle() {
try {
this.emitFile({
type: 'asset',
fileName: 't2i/shiki_runtime.iife.js',
source: readFileSync(t2iShikiRuntimePath)
});
} catch (error) {
this.warn(
`Skipping T2I Shiki runtime asset because it could not be read: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
};
}
// https://vitejs.dev/config/
export default defineConfig(({ command }) => ({
plugins: [
// Only run MDI subsetting during production builds, skip in dev server
...(command === 'build' ? [mdiSubset()] : []),
t2iShikiRuntimeAsset(),
vue({
template: {
compilerOptions: {

86
docs/public/install.ps1 Normal file
View File

@@ -0,0 +1,86 @@
# deploy-cli.ps1 - Install astrbot with uv on Windows PowerShell.
#Requires -Version 7.0
$ErrorActionPreference = 'Stop'
$UseColor = [string]::IsNullOrEmpty($env:NO_COLOR) -and [Console]::IsOutputRedirected -eq $false
function Write-Status {
param(
[string]$Prefix,
[string]$Message,
[string]$Color
)
if ($UseColor) {
Write-Host "$Prefix $Message" -ForegroundColor $Color
} else {
Write-Host "$Prefix $Message"
}
}
function Info { Write-Status "[INFO]" "$args" "Cyan" }
function Warn { Write-Status "[WARN]" "$args" "Yellow" }
function Ok { Write-Status "[OK]" "$args" "Green" }
function Err { Write-Status "[ERROR]" "$args" "Red" }
function Test-Command {
param([string]$Name)
$null = Get-Command $Name -ErrorAction SilentlyContinue
return $?
}
function Update-UvPath {
$candidateDirs = @()
if ($HOME) {
$candidateDirs += Join-Path $HOME ".local\bin"
}
if ($env:USERPROFILE) {
$candidateDirs += Join-Path $env:USERPROFILE ".local\bin"
}
if ($env:LOCALAPPDATA) {
$candidateDirs += Join-Path $env:LOCALAPPDATA "uv"
}
foreach ($dir in $candidateDirs) {
if ((Test-Path $dir) -and (($env:PATH -split ';') -notcontains $dir)) {
$env:PATH = "$dir;$env:PATH"
}
}
}
function Install-Uv {
Info "uv was not found. Installing uv..."
$tempScript = [System.IO.Path]::GetTempFileName() + ".ps1"
try {
Invoke-WebRequest -Uri "https://astral.sh/uv/install.ps1" -OutFile $tempScript -UseBasicParsing
& $tempScript
Update-UvPath
} catch {
Err "Failed to install uv."
Err "Please install uv manually: https://docs.astral.sh/uv/getting-started/installation/"
exit 1
} finally {
Remove-Item $tempScript -ErrorAction SilentlyContinue
}
}
if (-not (Test-Command "uv")) {
Install-Uv
}
Update-UvPath
if (-not (Test-Command "uv")) {
Err "uv was not found after installation."
Err "Please install uv manually: https://docs.astral.sh/uv/getting-started/installation/"
exit 1
}
Ok (& uv --version)
Info "Installing AstrBot with Python 3.12..."
uv tool install --python 3.12 astrbot
Ok "AstrBot has been installed."

68
docs/public/install.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# deploy-cli.sh - Install astrbot with uv on Linux / macOS / WSL.
set -euo pipefail
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
else
RED=''
GREEN=''
YELLOW=''
CYAN=''
NC=''
fi
info() { echo -e "${CYAN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
err() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
has() {
command -v "$1" >/dev/null 2>&1
}
refresh_uv_path() {
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
}
install_uv() {
info "uv was not found. Installing uv..."
if has curl; then
curl -fsSL https://astral.sh/uv/install.sh | sh
refresh_uv_path
return
fi
if has wget; then
wget -qO- https://astral.sh/uv/install.sh | sh
refresh_uv_path
return
fi
err "curl or wget is required to install uv."
exit 1
}
if has uv; then
UV_BIN="uv"
else
install_uv
UV_BIN="uv"
fi
if ! has "$UV_BIN"; then
err "uv was not found after installation."
err "Please install uv manually: https://docs.astral.sh/uv/getting-started/installation/"
exit 1
fi
ok "$("$UV_BIN" --version)"
info "Installing AstrBot with Python 3.12..."
"$UV_BIN" tool install --python 3.12 astrbot
ok "AstrBot has been installed."

View File

@@ -5,13 +5,12 @@
>
> 以下教程默认您的设备上已经安装 Python并且版本 `>=3.10`
## 下载/克隆仓库
如果你的电脑上安装了 `git`,你可以通过以下命令来下载源码:
```bash
git clone https://github.com/AstrBotDevs/AstrBot.git
git clone https://github.com/AstrBotDevs/AstrBot
# 上面的代码默认会拉取最新的提交的源码,如果你需要拉取最新稳定发行版本的源码,可以使用以下命令:
# git clone --depth=1 --branch $(git ls-remote --tags --sort='-v:refname' https://github.com/AstrBotDevs/AstrBot.git | head -n1 | awk -F/ '{print $3}') https://github.com/AstrBotDevs/AstrBot.git
cd AstrBot

View File

@@ -13,11 +13,12 @@
- `Shipyard Neo`(当前推荐)
- `Shipyard`(旧方案,仍可继续使用)
- `CUA`(本地或云端电脑使用沙盒,适合需要桌面操作的场景)
在当前版本的 AstrBot 控制台中可在“AI 配置” -> “Agent Computer Use”中选择
- `Computer Use Runtime` = `sandbox`
- `沙箱环境驱动器` = `Shipyard Neo``Shipyard`
- `沙箱环境驱动器` = `Shipyard Neo``Shipyard``CUA`
其中,`Shipyard Neo` 是当前默认驱动器。它由 Bay、Ship、Gull 三部分组成:
@@ -30,6 +31,109 @@
> [!TIP]
> `Shipyard Neo` 下浏览器能力并不是所有 profile 都有。只有 profile 支持 `browser` capability 时AstrBot 才会挂载浏览器相关工具。典型 profile 如 `browser-python`。
## CUA 运行时
`CUA` 是一个面向电脑使用Computer Use的沙盒运行时。它可以通过统一的 Python SDK 创建 Linux、macOS、Windows、Android 等不同类型的沙盒,并暴露 Shell、截图、鼠标、键盘、文件系统等接口。
在 AstrBot 中选择 `CUA` 驱动器后Agent 可以在 CUA sandbox 中使用:
- Shell 工具
- Python 工具
- 文件读取、写入、编辑和搜索工具
- 截图工具
- 鼠标点击工具
- 键盘输入工具
- 沙盒文件上传与下载工具
> [!NOTE]
> CUA 是可选运行时AstrBot 默认安装不会强制安装它。如果选择了 `CUA` 但当前 Python 环境没有安装 `cua` 包,启动沙盒时会提示安装缺失。
### 安装 CUA 依赖
如果您通过源码或虚拟环境运行 AstrBot请在 AstrBot 使用的 Python 环境中安装 CUA
```bash
pip install cua
```
如果您使用 `uv` 管理 AstrBot 环境,可在 AstrBot 项目目录中执行:
```bash
uv pip install cua
```
CUA 本身还依赖具体运行方式:
- 本地 Linux 容器通常需要 Docker 可用。
- 本地 Linux/Windows VM 通常需要 QEMU 或 CUA 对应的本地运行时。
- macOS VM 通常依赖 CUA/Lume 相关运行时。
- 云端 CUA 需要可用的 CUA API Key。
具体宿主机要求、镜像支持情况和本地运行时安装方式,请参考 [CUA 官方文档](https://cua.ai/docs)。
### 在 AstrBot 中配置 CUA
进入 WebUI
- `配置 -> 普通配置 -> 使用电脑能力`
然后设置:
- `Computer Use Runtime` = `sandbox`
- `沙箱环境驱动器` = `CUA`
CUA 相关配置项包括:
- `CUA Image`:要启动的 CUA 镜像。常见值为 `linux``macos``windows``android`。默认 `linux`
- `CUA OS Type`:镜像的操作系统类型。默认 `linux`。它会影响 AstrBot 对 POSIX Shell fallback 的判断。
- `CUA Sandbox TTL`:沙盒生命周期,单位为秒。默认 `3600`
- `CUA Telemetry Enabled`:是否启用 CUA 侧遥测。默认关闭。
- `CUA Local Runtime`:是否使用本地运行时。默认开启。关闭后会按 CUA SDK 的云端方式创建沙盒。
- `CUA API Key`:云端 CUA 所需的 API Key。仅在使用云端运行时时填写。
一个最小本地 Linux 容器配置通常是:
```text
Computer Use Runtime = sandbox
沙箱环境驱动器 = CUA
CUA Image = linux
CUA OS Type = linux
CUA Local Runtime = true
CUA Sandbox TTL = 3600
```
如果使用云端 CUA可改为
```text
Computer Use Runtime = sandbox
沙箱环境驱动器 = CUA
CUA Image = linux
CUA OS Type = linux
CUA Local Runtime = false
CUA API Key = <your-cua-api-key>
```
> [!WARNING]
> 不要把 CUA API Key 写入公开日志、截图或 issue。AstrBot 的运行日志不会输出该字段但部署平台、Shell 历史和容器环境变量仍需自行保护。
### 使用 CUA 时的注意事项
- `linux` 镜像通常适合 Shell、Python、文件系统和桌面自动化测试。
- 非 POSIX 镜像(如 `windows``android`)不一定支持 `sh``cat``ls``rm``base64` 等命令。AstrBot 对需要这些命令的 fallback 操作会返回明确错误。
- 如果需要在 CUA sandbox 中打开浏览器或 GUI 程序,通常应使用 Shell 后台执行,例如显式传入 `background=true`,避免命令阻塞后续工具调用。
- 直接把 sandbox 内的文件路径发送给用户通常不可行。应优先使用 AstrBot 的沙盒下载工具,将文件下载到 AstrBot 临时目录后再发送。
- CUA 与 Shipyard Neo 的 workspace 语义不同。Shipyard Neo 固定使用 `/workspace`CUA 的工作目录和文件路径取决于镜像与运行时。
### 何时选择 CUA
建议在以下场景选择 `CUA`
- 需要桌面截图、鼠标点击、键盘输入等 GUI 自动化能力。
- 需要测试不同 OS 镜像中的行为,例如 Linux、Windows、Android。
- 已经在本机或云端部署好 CUA 运行环境。
如果只是需要稳定的 Python/Shell/文件系统沙盒,且不需要桌面 GUI 操作,通常优先选择 `Shipyard Neo`。它与 AstrBot 的 workspace、Skills 同步和长期运行模式更贴合。
## 性能要求
AstrBot 给每个沙盒环境限制最高 1 CPU 和 512 MB 内存。
@@ -388,4 +492,4 @@ Shipyard 会自动将沙盒环境中的 /home 目录挂载到宿主机的 `${PWD
### luosheng520qaq/astrobot_plugin_code_executor
如果您资源有限,不希望使用沙盒环境来执行代码,可以尝试 luosheng520qaq 开发的 [astrobot_plugin_code_executor](https://github.com/luosheng520qaq/astrobot_plugin_code_executor) 插件。该插件会直接在宿主机上执行代码。插件已经尽力提升安全性,但仍需留意代码安全性问题。
如果您资源有限,不希望使用沙盒环境来执行代码,可以尝试 luosheng520qaq 开发的 [astrobot_plugin_code_executor](https://github.com/luosheng520qaq/astrobot_plugin_code_executor) 插件。该插件会直接在宿主机上执行代码。插件已经尽力提升安全性,但仍需留意代码安全性问题。

View File

@@ -97,7 +97,12 @@ data/workspaces/{normalized_umo}/notes/todo.txt
在沙盒中Agent 仍然可以使用 Shell、Python、文件系统工具如果所选沙盒 profile 支持 `browser` capability还会挂载浏览器自动化工具。
使用 Shipyard Neo 时,沙盒 workspace 根目录通常是
沙盒环境驱动器可在 `配置 -> 普通配置 -> 使用电脑能力` 的沙箱配置中选择。当前常用选项包括
- `Shipyard Neo`AstrBot 推荐的远程/独立部署沙盒服务,适合长期运行和多人使用。
- `CUA`:基于 [CUA](https://github.com/trycua/cua) 的本地或云端电脑使用沙盒可提供桌面截图、鼠标、键盘、Shell、Python 和文件系统能力。
使用 `Shipyard Neo` 时,沙盒 workspace 根目录通常是:
```text
/workspace
@@ -115,7 +120,9 @@ result.txt
/workspace/result.txt
```
沙盒部署、profile、TTL、数据持久化、浏览器能力等内容请参考[Agent 沙盒环境](/use/astrbot-agent-sandbox)
使用 `CUA` 时,工作目录和可用命令取决于所选 CUA image 与运行方式。Linux CUA 容器通常提供类 Unix ShellWindows、Android 等非 POSIX 镜像不保证支持 `sh``ls``rm``base64` 等命令AstrBot 会对部分 shell fallback 操作返回明确错误
沙盒部署、驱动器选择、CUA 配置、profile、TTL、数据持久化、浏览器能力等内容请参考[Agent 沙盒环境](/use/astrbot-agent-sandbox)。
> [!NOTE]
> 即使在 `sandbox` 模式下,“需要 AstrBot 管理员权限”仍会影响 Shell、Python、浏览器、上传下载等工具的调用权限。具体权限取决于你的配置。

14
main.py
View File

@@ -70,9 +70,9 @@ async def check_dashboard_files(webui_dir: str | None = None):
# 指定webui目录
if webui_dir:
if os.path.exists(webui_dir):
logger.info(f"使用指定的 WebUI 目录: {webui_dir}")
logger.info("Using WebUI directory: %s", webui_dir)
return webui_dir
logger.warning(f"指定的 WebUI 目录 {webui_dir} 不存在,将使用默认逻辑。")
logger.warning("WebUI directory not found: %s. Using default.", webui_dir)
data_dist_path = os.path.join(get_astrbot_data_path(), "dist")
if os.path.exists(data_dist_path):
@@ -80,15 +80,17 @@ async def check_dashboard_files(webui_dir: str | None = None):
if v is not None:
# 存在文件
if v == f"v{VERSION}":
logger.info("WebUI 版本已是最新。")
logger.info("WebUI is up to date.")
else:
logger.warning(
f"检测到 WebUI 版本 ({v}) 与当前 AstrBot 版本 (v{VERSION}) 不符。",
"WebUI version mismatch: %s, expected v%s.",
v,
VERSION,
)
return data_dist_path
logger.info(
"开始下载管理面板文件...高峰期(晚上)可能导致较慢的速度。如多次下载失败,请前往 https://github.com/AstrBotDevs/AstrBot/releases/latest 下载 dist.zip并将其中的 dist 文件夹解压至 data 目录下。",
"Downloading WebUI. If it fails, download dist.zip from https://github.com/AstrBotDevs/AstrBot/releases/latest and extract dist to data/.",
)
try:
@@ -126,7 +128,7 @@ if __name__ == "__main__":
parser.add_argument(
"--webui-dir",
type=str,
help="指定 WebUI 静态文件目录路径",
help="Specify the directory path for WebUI static files",
default=None,
)
args = parser.parse_args()

View File

@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "4.23.5"
version = "4.23.6"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
license = { text = "AGPL-3.0-or-later" }

View File

@@ -124,6 +124,12 @@ async def test_dashboard_ssl_missing_cert_and_key_falls_back_to_http(
async def fake_serve(app, config, shutdown_trigger):
return config
def capture(messages):
def append(message, *args):
messages.append(message % args if args else message)
return append
try:
core_lifecycle_td.astrbot_config["dashboard"]["ssl"] = {
"enable": True,
@@ -134,21 +140,22 @@ async def test_dashboard_ssl_missing_cert_and_key_falls_back_to_http(
monkeypatch.setattr("astrbot.dashboard.server.serve", fake_serve)
monkeypatch.setattr(
"astrbot.dashboard.server.logger.warning",
lambda message: warning_messages.append(message),
capture(warning_messages),
)
monkeypatch.setattr(
"astrbot.dashboard.server.logger.info",
lambda message: info_messages.append(message),
capture(info_messages),
)
config = await server.run()
assert getattr(config, "certfile", None) is None
assert getattr(config, "keyfile", None) is None
assert any("cert_file 和 key_file" in message for message in warning_messages)
assert any(
"正在启动 WebUI, 监听地址: http://" in message for message in info_messages
"cert_file or key_file is missing" in message
for message in warning_messages
)
assert any("Starting WebUI at http://" in message for message in info_messages)
finally:
core_lifecycle_td.astrbot_config["dashboard"] = original_dashboard_config

View File

@@ -67,7 +67,9 @@ def test_check_env_appends_user_site_packages_after_runtime_paths(monkeypatch):
monkeypatch.setattr(sys, "version_info", _version_info(3, 12))
monkeypatch.setattr("main.get_astrbot_root", lambda: astrbot_root)
monkeypatch.setattr("main.get_astrbot_site_packages_path", lambda: site_packages_path)
monkeypatch.setattr(
"main.get_astrbot_site_packages_path", lambda: site_packages_path
)
monkeypatch.setattr("main.get_astrbot_config_path", lambda: "/tmp/config")
monkeypatch.setattr("main.get_astrbot_plugin_path", lambda: "/tmp/plugins")
monkeypatch.setattr("main.get_astrbot_temp_path", lambda: "/tmp/temp")
@@ -89,12 +91,16 @@ def test_check_env_does_not_append_duplicate_user_site_packages(monkeypatch):
monkeypatch.setattr(sys, "version_info", _version_info(3, 12))
monkeypatch.setattr("main.get_astrbot_root", lambda: astrbot_root)
monkeypatch.setattr("main.get_astrbot_site_packages_path", lambda: site_packages_path)
monkeypatch.setattr(
"main.get_astrbot_site_packages_path", lambda: site_packages_path
)
monkeypatch.setattr("main.get_astrbot_config_path", lambda: "/tmp/config")
monkeypatch.setattr("main.get_astrbot_plugin_path", lambda: "/tmp/plugins")
monkeypatch.setattr("main.get_astrbot_temp_path", lambda: "/tmp/temp")
monkeypatch.setattr("main.get_astrbot_knowledge_base_path", lambda: "/tmp/kb")
monkeypatch.setattr(sys, "path", [astrbot_root, *original_sys_path, site_packages_path])
monkeypatch.setattr(
sys, "path", [astrbot_root, *original_sys_path, site_packages_path]
)
with mock.patch("os.makedirs"):
check_env()
@@ -176,7 +182,7 @@ async def test_check_dashboard_files_exists_but_version_mismatch(monkeypatch):
await check_dashboard_files()
mock_logger_warning.assert_called_once()
call_args, _ = mock_logger_warning.call_args
assert "不符" in call_args[0]
assert "WebUI version mismatch" in call_args[0]
@pytest.mark.asyncio

View File

@@ -1,6 +1,8 @@
import asyncio
import json
import ntpath
import threading
from pathlib import Path
from unittest.mock import AsyncMock
import pytest
@@ -1061,6 +1063,100 @@ def test_core_constraints_file_propagates_inner_conflict_without_fake_warning(
assert warning_logs == []
@pytest.mark.asyncio
async def test_install_adds_desktop_core_lock_constraints_for_packaged_runtime(
monkeypatch, tmp_path
):
monkeypatch.setenv("ASTRBOT_DESKTOP_CLIENT", "1")
monkeypatch.delattr("sys.frozen", raising=False)
lock_path = tmp_path / "runtime-core-lock.json"
lock_path.write_text(
json.dumps(
{
"version": 1,
"distributions": [
{
"name": "desktop-only-core",
"version": "9.9.9",
"top_level_modules": ["desktop_only_core"],
}
],
}
),
encoding="utf-8",
)
monkeypatch.setenv("ASTRBOT_DESKTOP_CORE_LOCK_PATH", str(lock_path))
site_packages_path = tmp_path / "site-packages"
captured_constraints = []
async def capture_pip_args(self, args):
del self
constraints_path = args[args.index("-c") + 1]
captured_constraints.append(Path(constraints_path).read_text(encoding="utf-8"))
return 0
monkeypatch.setattr(PipInstaller, "_run_pip_in_process", capture_pip_args)
monkeypatch.setattr(
"astrbot.core.utils.pip_installer.get_astrbot_site_packages_path",
lambda: str(site_packages_path),
)
monkeypatch.setattr(
"astrbot.core.utils.pip_installer._ensure_plugin_dependencies_preferred",
lambda path, requirements: None,
)
installer = PipInstaller("")
await installer.install(package_name="Cua")
assert captured_constraints
assert "desktop-only-core==9.9.9" in captured_constraints[0]
def test_ensure_plugin_dependencies_preferred_skips_desktop_core_lock_modules(
monkeypatch, tmp_path
):
monkeypatch.setenv("ASTRBOT_DESKTOP_CLIENT", "1")
lock_path = tmp_path / "runtime-core-lock.json"
lock_path.write_text(
json.dumps(
{
"version": 1,
"distributions": [
{
"name": "openai",
"version": "2.32.0",
"top_level_modules": ["openai"],
}
],
}
),
encoding="utf-8",
)
monkeypatch.setenv("ASTRBOT_DESKTOP_CORE_LOCK_PATH", str(lock_path))
preferred_calls = []
monkeypatch.setattr(
pip_installer_module,
"_collect_candidate_modules",
lambda requirements, site_packages_path: {"openai", "cua_agent"},
)
monkeypatch.setattr(
pip_installer_module,
"_ensure_preferred_modules",
lambda modules, site_packages_path: preferred_calls.append(modules),
)
pip_installer_module._ensure_plugin_dependencies_preferred(
str(tmp_path / "site-packages"),
{"Cua"},
)
assert preferred_calls == [{"cua_agent"}]
def test_iter_requirement_lines_expands_nested_requirement_files(tmp_path):
base_requirements = tmp_path / "base.txt"
base_requirements.write_text("demo-package==1.0\n", encoding="utf-8")

View File

@@ -1561,6 +1561,36 @@ class TestApplySandboxTools:
assert "sandboxed environment" in req.system_prompt
def test_apply_sandbox_tools_with_cua_adds_gui_guidance(self, mock_context):
"""Test that CUA sandbox guidance nudges reliable GUI workflows."""
module = ama
config = module.MainAgentBuildConfig(
tool_call_timeout=60,
computer_use_runtime="sandbox",
sandbox_cfg={"booter": "cua"},
)
req = ProviderRequest(prompt="Test", system_prompt="Original prompt")
module._apply_sandbox_tools(config, req, "session-123")
assert req.func_tool is not None
tool_names = req.func_tool.names()
assert "astrbot_cua_screenshot" in tool_names
assert "astrbot_cua_mouse_click" in tool_names
assert "astrbot_cua_keyboard_type" in tool_names
assert "astrbot_cua_key_press" not in tool_names
assert "Firefox" in req.system_prompt
assert "background=true" in req.system_prompt
assert 'firefox "https://example.com"' in req.system_prompt
assert "astrbot_cua_screenshot" in req.system_prompt
assert "astrbot_cua_key_press" not in req.system_prompt
assert "return_image_to_llm" in req.system_prompt
assert "astrbot_execute_shell" in req.system_prompt
assert "\\n" in req.system_prompt
assert "send_to_user=true" in req.system_prompt
assert "focused and empty or safe to append" in req.system_prompt
def test_apply_sandbox_tools_with_shipyard_booter(self, monkeypatch, mock_context):
"""Test sandbox tools with shipyard booter configuration."""
module = ama

View File

@@ -291,6 +291,30 @@ class TestConfigValidation:
assert "level2" in config.nested["level1"]
assert config.nested["level1"]["level2"]["value"] == 42
def test_integrity_log_does_not_include_inserted_secret_value(
self, temp_config_path, monkeypatch
):
"""Default values may contain secrets and should not be logged."""
from astrbot.core.config import astrbot_config
existing_config = {}
default_config = {"api_key": "secret-value"}
messages = []
with open(temp_config_path, "w", encoding="utf-8-sig") as f:
json.dump(existing_config, f)
def capture_info(message, *args):
messages.append(message % args if args else message)
monkeypatch.setattr(astrbot_config.logger, "info", capture_info)
AstrBotConfig(config_path=temp_config_path, default_config=default_config)
assert messages
assert all("secret-value" not in message for message in messages)
assert all("api_key" not in message for message in messages)
assert any("Config key missing" in message for message in messages)
class TestConfigHotReload:
"""Tests for config hot reload functionality."""

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,15 @@
import json
import pytest
from astrbot.core import sp
from astrbot.core.provider.func_tool_manager import FunctionToolManager
from astrbot.core.tools.computer_tools.shell import ExecuteShellTool
from astrbot.core.tools.message_tools import SendMessageToUserTool
from astrbot.core.tools.web_search_tools import FirecrawlExtractWebPageTool
from astrbot.core.tools.web_search_tools import FirecrawlWebSearchTool
from astrbot.core.tools.web_search_tools import (
FirecrawlExtractWebPageTool,
FirecrawlWebSearchTool,
)
def test_get_builtin_tool_by_class_returns_cached_instance():
@@ -39,9 +45,284 @@ def test_computer_tools_are_registered_as_builtin_tools():
tool = manager.get_builtin_tool(ExecuteShellTool)
assert tool.name == "astrbot_execute_shell"
assert tool.parameters["properties"]["background"]["default"] is False
assert manager.is_builtin_tool("astrbot_execute_shell") is True
@pytest.mark.asyncio
async def test_execute_shell_defaults_to_foreground(monkeypatch):
from astrbot.core.tools.computer_tools import shell as shell_tools
calls = []
class FakeShell:
async def exec(self, command, cwd=None, background=False, env=None):
calls.append({"command": command, "background": background})
return {"success": True, "stdout": "", "stderr": "", "exit_code": 0}
class FakeBooter:
shell = FakeShell()
class FakeConfig:
def get_config(self, umo):
return {"provider_settings": {"computer_use_runtime": "sandbox"}}
class FakeEvent:
unified_msg_origin = "umo"
role = "admin"
class FakeAstrContext:
context = FakeConfig()
event = FakeEvent()
class FakeWrapper:
context = FakeAstrContext()
async def fake_get_booter(context, session_id):
return FakeBooter()
monkeypatch.setattr(shell_tools, "get_booter", fake_get_booter)
result = await ExecuteShellTool().call(
FakeWrapper(), command="chromium https://example.com"
)
assert json.loads(result)["success"] is True
assert calls == [{"command": "chromium https://example.com", "background": False}]
@pytest.mark.asyncio
async def test_execute_shell_uses_fresh_default_env_per_call(monkeypatch):
from astrbot.core.tools.computer_tools import shell as shell_tools
calls = []
class FakeShell:
async def exec(self, command, cwd=None, background=False, env=None):
env["MUTATED_BY_FAKE_SHELL"] = command
calls.append(env)
return {"success": True, "stdout": "", "stderr": "", "exit_code": 0}
class FakeBooter:
shell = FakeShell()
class FakeConfig:
def get_config(self, umo):
return {"provider_settings": {"computer_use_runtime": "sandbox"}}
class FakeEvent:
unified_msg_origin = "umo"
role = "admin"
class FakeAstrContext:
context = FakeConfig()
event = FakeEvent()
class FakeWrapper:
context = FakeAstrContext()
async def fake_get_booter(context, session_id):
return FakeBooter()
monkeypatch.setattr(shell_tools, "get_booter", fake_get_booter)
tool = ExecuteShellTool()
await tool.call(FakeWrapper(), command="first")
await tool.call(FakeWrapper(), command="second")
assert calls[0] is not calls[1]
assert calls[0]["MUTATED_BY_FAKE_SHELL"] == "first"
assert calls[1] == {"MUTATED_BY_FAKE_SHELL": "second"}
@pytest.mark.asyncio
async def test_execute_shell_copies_user_env_before_execution(monkeypatch):
from astrbot.core.tools.computer_tools import shell as shell_tools
calls = []
class FakeShell:
async def exec(self, command, cwd=None, background=False, env=None):
env["MUTATED_BY_FAKE_SHELL"] = command
calls.append(env)
return {"success": True, "stdout": "", "stderr": "", "exit_code": 0}
class FakeBooter:
shell = FakeShell()
class FakeConfig:
def get_config(self, umo):
return {"provider_settings": {"computer_use_runtime": "sandbox"}}
class FakeEvent:
unified_msg_origin = "umo"
role = "admin"
class FakeAstrContext:
context = FakeConfig()
event = FakeEvent()
class FakeWrapper:
context = FakeAstrContext()
async def fake_get_booter(context, session_id):
return FakeBooter()
monkeypatch.setattr(shell_tools, "get_booter", fake_get_booter)
original_env = {"FOO": "bar"}
await ExecuteShellTool().call(FakeWrapper(), command="first", env=original_env)
assert original_env == {"FOO": "bar"}
assert calls == [{"FOO": "bar", "MUTATED_BY_FAKE_SHELL": "first"}]
@pytest.mark.asyncio
async def test_execute_shell_avoids_double_background_for_detached_commands(
monkeypatch,
):
from astrbot.core.tools.computer_tools import shell as shell_tools
calls = []
class FakeShell:
async def exec(self, command, cwd=None, background=False, env=None):
calls.append({"command": command, "background": background})
return {"success": True, "stdout": "", "stderr": "", "exit_code": 0}
class FakeBooter:
shell = FakeShell()
class FakeConfig:
def get_config(self, umo):
return {"provider_settings": {"computer_use_runtime": "sandbox"}}
class FakeEvent:
unified_msg_origin = "umo"
role = "admin"
class FakeAstrContext:
context = FakeConfig()
event = FakeEvent()
class FakeWrapper:
context = FakeAstrContext()
async def fake_get_booter(context, session_id):
return FakeBooter()
monkeypatch.setattr(shell_tools, "get_booter", fake_get_booter)
command = "nohup firefox >/tmp/astrbot-firefox.log 2>&1 &"
result = await ExecuteShellTool().call(
FakeWrapper(), command=command, background=True
)
assert json.loads(result)["success"] is True
assert calls == [{"command": command, "background": False}]
@pytest.mark.asyncio
async def test_execute_shell_recognizes_commented_background_command(monkeypatch):
from astrbot.core.tools.computer_tools import shell as shell_tools
calls = []
class FakeShell:
async def exec(self, command, cwd=None, background=False, env=None):
calls.append({"command": command, "background": background})
return {"success": True, "stdout": "", "stderr": "", "exit_code": 0}
class FakeBooter:
shell = FakeShell()
class FakeConfig:
def get_config(self, umo):
return {"provider_settings": {"computer_use_runtime": "sandbox"}}
class FakeEvent:
unified_msg_origin = "umo"
role = "admin"
class FakeAstrContext:
context = FakeConfig()
event = FakeEvent()
class FakeWrapper:
context = FakeAstrContext()
async def fake_get_booter(context, session_id):
return FakeBooter()
monkeypatch.setattr(shell_tools, "get_booter", fake_get_booter)
command = "firefox & # already detached"
result = await ExecuteShellTool().call(
FakeWrapper(), command=command, background=True
)
assert json.loads(result)["success"] is True
assert calls == [{"command": command, "background": False}]
@pytest.mark.parametrize(
("command", "expected"),
[
("echo '#'", False),
("echo '&'", False),
("echo foo#bar &", True),
("echo 'unterminated", False),
("firefox & # already detached", True),
("nohup firefox >/tmp/astrbot-firefox.log 2>&1 &", True),
("firefox", False),
],
)
def test_is_self_detached_command_handles_quotes_and_comments(command, expected):
from astrbot.core.tools.computer_tools.shell import _is_self_detached_command
assert _is_self_detached_command(command) is expected
@pytest.mark.asyncio
async def test_execute_shell_reports_blank_exception_type(monkeypatch):
from astrbot.core.tools.computer_tools import shell as shell_tools
class BlankError(Exception):
def __str__(self):
return ""
class FakeShell:
async def exec(self, command, cwd=None, background=False, env=None):
raise BlankError()
class FakeBooter:
shell = FakeShell()
class FakeConfig:
def get_config(self, umo):
return {"provider_settings": {"computer_use_runtime": "sandbox"}}
class FakeEvent:
unified_msg_origin = "umo"
role = "admin"
class FakeAstrContext:
context = FakeConfig()
event = FakeEvent()
class FakeWrapper:
context = FakeAstrContext()
async def fake_get_booter(context, session_id):
return FakeBooter()
monkeypatch.setattr(shell_tools, "get_booter", fake_get_booter)
result = await ExecuteShellTool().call(FakeWrapper(), command="firefox")
assert result == "Error executing command: BlankError"
def test_firecrawl_tools_are_registered_as_builtin_tools():
manager = FunctionToolManager()