mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-03 11:10:14 +08:00
Compare commits
25 Commits
feat/conv-
...
perf/shell
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fddfbb946c | ||
|
|
451d5450ee | ||
|
|
33eeed1739 | ||
|
|
2f33c34b5c | ||
|
|
d8de0035a9 | ||
|
|
1801834cac | ||
|
|
4d9340c216 | ||
|
|
9016a3b2c4 | ||
|
|
e4a9274b41 | ||
|
|
e218620a37 | ||
|
|
cb5c172e69 | ||
|
|
3f52c7aaa0 | ||
|
|
44e31bd49c | ||
|
|
ffa7508a8b | ||
|
|
988961a9a4 | ||
|
|
d7a54aed76 | ||
|
|
f1206d987b | ||
|
|
67c7445d25 | ||
|
|
72d65680b8 | ||
|
|
b711425b73 | ||
|
|
72f4e748e8 | ||
|
|
77040cabcd | ||
|
|
09ab45fcb5 | ||
|
|
1efe4fd60e | ||
|
|
c5ab4f7263 |
2
.github/workflows/build-docs.yml
vendored
2
.github/workflows/build-docs.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/dashboard_ci.yml
vendored
2
.github/workflows/dashboard_ci.yml
vendored
@@ -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
|
||||
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
|
||||
20
AGENTS.md
20
AGENTS.md
@@ -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.
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.23.5"
|
||||
__version__ = "4.23.6"
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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: ...
|
||||
|
||||
830
astrbot/core/computer/booters/cua.py
Normal file
830
astrbot/core/computer/booters/cua.py
Normal 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
|
||||
17
astrbot/core/computer/booters/cua_defaults.py
Normal file
17
astrbot/core/computer/booters/cua_defaults.py
Normal 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",
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
18
astrbot/core/computer/booters/shell_background.py
Normal file
18
astrbot/core/computer/booters/shell_background.py
Normal 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)}"
|
||||
@@ -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"""
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
25
astrbot/core/computer/olayer/gui.py
Normal file
25
astrbot/core/computer/olayer/gui.py
Normal 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."""
|
||||
...
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
# 更新原始配置
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 强制 Markdown,False 强制纯文本。
|
||||
)
|
||||
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 强制使用 Markdown,False 强制纯文本,None 跟随平台默认行为。
|
||||
|
||||
"""
|
||||
self.use_markdown_ = use
|
||||
return self
|
||||
|
||||
def get_plain_text(self, with_other_comps_mark: bool = False) -> str:
|
||||
"""获取纯文本消息。这个方法将获取 chain 中所有 Plain 组件的文本并拼接成一条消息。空格分隔。
|
||||
|
||||
|
||||
@@ -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 的信息"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
177
astrbot/core/tools/computer_tools/cua.py
Normal file
177
astrbot/core/tools/computer_tools/cua.py
Normal 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")
|
||||
@@ -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] == "&"
|
||||
|
||||
@@ -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
|
||||
|
||||
108
astrbot/core/utils/desktop_core_lock.py
Normal file
108
astrbot/core/utils/desktop_core_lock.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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("&", "&")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -322,7 +322,7 @@ class AstrBotDashboard:
|
||||
|
||||
if not cert_file or not key_file:
|
||||
logger.warning(
|
||||
"dashboard.ssl.enable 已启用,但未同时配置 cert_file 和 key_file,SSL 配置将不会生效。",
|
||||
"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
56
changelogs/v4.23.6.md
Normal 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))
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>`
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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}' и сделать его активным шаблоном?"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
"callCount": "{count} вызовов",
|
||||
"avgTtft": "Средний TTFT",
|
||||
"avgDuration": "Среднее время ответа",
|
||||
"avgTpm": "Средний TPM",
|
||||
"avgTpm": "Средний Output TPM",
|
||||
"successRate": "Доля успешных вызовов"
|
||||
},
|
||||
"modelRanking": {
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
"livePreview": "实时预览(可能有差异)",
|
||||
"refreshPreview": "刷新预览",
|
||||
"previewText": "这是一个示例文本,用于预览模板效果。\n\n这里可以包含多行文本,支持换行和各种格式。",
|
||||
"syntaxHint": "支持 jinja2 语法。可用变量:text | safe(要渲染的文本), version(AstrBot 版本)",
|
||||
"syntaxHint": "支持 jinja2 语法。可用变量:text | safe(要渲染的文本), version(AstrBot 版本)。如果预览测试内容未加载或显示异常,请点击“重置Base”恢复默认模板。",
|
||||
"saveAndApply": "保存应用当前编辑模板",
|
||||
"confirmReset": "确认重置",
|
||||
"confirmResetMessage": "确定要将 'base' 模板恢复为默认内容吗?当前编辑器中的任何未保存更改将丢失。此操作无法撤销。",
|
||||
|
||||
@@ -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 访问地址。"
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
"callCount": "共 {count} 次调用",
|
||||
"avgTtft": "平均首字延迟(TTFT)",
|
||||
"avgDuration": "平均响应时间",
|
||||
"avgTpm": "平均每分钟词元数(TPM)",
|
||||
"avgTpm": "平均每分钟输出(TPM)",
|
||||
"successRate": "调用成功率"
|
||||
},
|
||||
"modelRanking": {
|
||||
|
||||
31
dashboard/src/utils/imeInput.mjs
Normal file
31
dashboard/src/utils/imeInput.mjs
Normal 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)
|
||||
);
|
||||
}
|
||||
36
dashboard/tests/imeInput.test.mjs
Normal file
36
dashboard/tests/imeInput.test.mjs
Normal 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,
|
||||
);
|
||||
});
|
||||
@@ -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
86
docs/public/install.ps1
Normal 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
68
docs/public/install.sh
Executable 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."
|
||||
@@ -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
|
||||
|
||||
@@ -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) 插件。该插件会直接在宿主机上执行代码。插件已经尽力提升安全性,但仍需留意代码安全性问题。
|
||||
|
||||
@@ -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 Shell;Windows、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
14
main.py
@@ -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()
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
1458
tests/unit/test_cua_computer_use.py
Normal file
1458
tests/unit/test_cua_computer_use.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user