Compare commits

..
122 changed files with 11503 additions and 9080 deletions
+1 -1
View File
@@ -173,7 +173,7 @@ jobs:
- name: Test WebUI
working-directory: webui
run: bun run test
run: bun run test:coverage
- name: Build WebUI
working-directory: webui
+3 -2
View File
@@ -241,7 +241,7 @@ Prefer your own infrastructure? Follow the [deployment guide](./docs/deployment.
## 🌐 WebUI
The WebUI ships **inside the published wheel** with no separate frontend build. It is the browser workbench for persistent topics, visible agent activity, workspace controls, Apps, Skills, Automations, and settings.
The WebUI ships **inside the published wheel** with no separate frontend build. It is the browser workbench for persistent topics, temporary chats, visible agent activity, workspace controls, Apps, Skills, Automations, and settings.
<p align="center">
<img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900">
@@ -250,9 +250,10 @@ The WebUI ships **inside the published wheel** with no separate frontend build.
Use it to:
- keep separate topics for different tasks and projects;
- use temporary chats when a conversation should not be saved to history or memory;
- inspect reasoning, tool calls, file edits, diffs, command output, and generated artifacts;
- switch models and workspaces without leaving the conversation;
- configure providers, chat channels, Apps, Skills, and Automations from one place.
- configure providers and chat channels, connect Apps, discover Skills, and manage Automations from one place.
See the [WebUI guide](./docs/webui.md) for LAN access, background operation, workspace controls, and the full feature tour. Working on the frontend itself? Use [`webui/README.md`](./webui/README.md).
+1 -2
View File
@@ -125,7 +125,6 @@ Important files:
| Shell execution | `nanobot/agent/tools/shell.py` |
| Filesystem tools | `nanobot/agent/tools/filesystem.py` |
| Web search/fetch | `nanobot/agent/tools/web.py` |
| Browser and computer use | `nanobot/agent/tools/browser_tool.py`, `nanobot/agent/tools/computer_use.py` |
| MCP tools | `nanobot/agent/tools/mcp.py` |
| Cron | `nanobot/agent/tools/cron.py`, `nanobot/cron/` |
| Image generation | `nanobot/agent/tools/image_generation.py` |
@@ -189,7 +188,7 @@ Security-sensitive code paths include:
|---|---|
| Workspace scope | `nanobot/security/workspace_access.py`, `nanobot/security/workspace_policy.py` |
| Shell sandboxing | `nanobot/agent/tools/shell.py` |
| SSRF/network checks | `nanobot/security/network.py`, `nanobot/agent/tools/web.py`, `nanobot/agent/tools/computer_use_backends/browser_playwright.py` |
| SSRF/network checks | `nanobot/security/network.py`, `nanobot/agent/tools/web.py` |
| PTH guard and CLI startup security | `nanobot/security/` and CLI entrypoints |
| Channel access control | channel config in `nanobot/channels/*.py` |
-57
View File
@@ -42,7 +42,6 @@ the focused guides first and come back here for exact fields and defaults.
| Add fallback chains | [Model Fallbacks](#model-fallbacks) |
| Configure voice transcription | [Transcription Settings](#transcription-settings) |
| Tune channel defaults | [Channel Settings](#channel-settings) |
| Enable browser or desktop control | [Browser and Computer Use](#browser-and-computer-use) |
| Configure web search and fetch | [Web Tools](#web-tools) |
| Enable image generation | [Image Generation](#image-generation) |
| Add MCP servers | [MCP](#mcp-model-context-protocol) |
@@ -1671,62 +1670,6 @@ When a channel `send()` raises, nanobot retries at the channel-manager layer. By
>
> If a channel is completely unreachable, nanobot cannot notify the user through that same channel. Watch logs for `Failed to send to {channel} after N attempts` to spot persistent delivery failures.
## Browser and Computer Use
Browser and desktop control are optional and disabled by default. Install their runtime first:
```bash
pip install 'nanobot-ai[computer-use]'
playwright install chromium
```
For normal web interaction, prefer the DOM-based `browser` tool. It gives the model numbered
element references and works without vision. Use `computer_use` when the model must see and act
on pixels; its `desktop` backend controls the real local machine, while its `browser` backend
controls an isolated Playwright page.
```json
{
"tools": {
"browser": {
"enable": true,
"allowedDomains": ["example.com"]
},
"computerUse": {
"enable": false,
"backend": "desktop"
}
}
}
```
| Option | Default | Description |
|---|---|---|
| `tools.browser.enable` | `false` | Register the DOM-based `browser` tool |
| `tools.browser.allowedDomains` | `[]` | Optional top-level navigation allowlist; entries include subdomains |
| `tools.browser.includeScreenshot` | `false` | Attach a screenshot after browser actions |
| `tools.browser.maxSessions` | `8` | Maximum retained browser sessions; least-recently-used state is closed first |
| `tools.computerUse.enable` | `false` | Register pixel-based `computer_use` |
| `tools.computerUse.backend` | `"desktop"` | `"desktop"` or `"browser"` |
| `tools.computerUse.allowedDomains` | `[]` | Navigation allowlist for the browser backend |
| `tools.computerUse.targetWidth` / `targetHeight` | `1280` / `800` | Maximum screenshot dimensions exposed to the model |
| `tools.computerUse.maxSessions` | `8` | Maximum retained sessions for the browser backend |
Each nanobot session gets separate browser state. Browser HTTP and WebSocket traffic passes
through the shared SSRF policy; local, private, link-local, and metadata targets are blocked
unless explicitly permitted with `tools.ssrfWhitelist`. When `maxSessions` is reached, the
least-recently-used browser state is closed. `file:` URLs are not accepted.
> [!IMPORTANT]
> Browser URL checks are defense in depth, not an egress sandbox: Chromium performs its own DNS
> resolution after validation. Use OS/container network isolation when browsing hostile pages.
> [!WARNING]
> The desktop backend can click, type, and change state outside the workspace. Enabling it is an
> explicit trust decision: use a trusted model and input source, and run nanobot in a disposable
> OS account or VM when unattended. The workspace restriction is not an OS sandbox. Desktop text
> input supports ASCII key events; use the browser backend when Unicode text input is required.
## Web Tools
nanobot incorporates basic tools for accessing the web. These include searching via APIs, and fetching arbitrary web pages in Markdown format. They are enabled by default, and can be configured in `~/.nanobot/config.json` under `tools.web`.
+6
View File
@@ -270,6 +270,12 @@ http://127.0.0.1:8765
If accessing from another device, bind the WebSocket channel to `0.0.0.0` and set `token` or `tokenIssueSecret`. The WebSocket channel refuses public binds without a token or token issue secret.
| Symptom | Check |
|---|---|
| A temporary chat disappeared after a reload or reconnect | This is expected. Temporary chats exist only for the current WebUI connection and are not saved to history or memory. Use a regular topic for anything you need to retain. |
| A skills.sh install says that `npx` is required | Install Node.js with `npx` on the gateway machine, or choose a SkillHub skill that does not require `npx`. |
| A remote browser says skill installation is disabled | Install from a same-machine WebUI. For a private deployment where every authenticated user is trusted to install third-party skill instructions or scripts, explicitly enable `tools.webuiAllowRemotePackageInstall`. |
See [`webui.md#lan-access`](./webui.md#lan-access) for LAN setup and [`../webui/README.md`](../webui/README.md) for frontend development.
## Chat App Problems
+68 -19
View File
@@ -1,10 +1,10 @@
# Nanobot WebUI: Browser Workbench for Self-Hosted AI Agents
<!-- Meta description: Run nanobot from a browser WebUI with persistent topics, visible tool activity, workspace controls, Apps, MCP presets, Skills, settings, and Automations. -->
<!-- Meta description: Run nanobot from a browser WebUI with persistent and temporary chats, visible tool activity, workspace controls, Apps, skill discovery, settings, and Automations. -->
The WebUI is nanobot's browser workbench for persistent topics, visible
agent activity, workspace controls, Apps, Skills, settings, and Automations in
one place.
The WebUI is nanobot's browser workbench for persistent topics, temporary
chats, visible agent activity, workspace controls, Apps, skill discovery,
settings, and Automations in one place.
The published `nanobot-ai` wheel already includes the WebUI bundle. You only need
the `webui/` source directory when you are changing the frontend itself.
@@ -72,14 +72,14 @@ This path avoids hand-editing `config.json` for normal setup. Use the reference
| Area | Use it for |
|---|---|
| Topics | Start, switch, search, fork, and delete browser topics |
| Topics | Start persistent topics or temporary chats; switch, search, reorder, fork, or delete persistent topics |
| Agent activity | See thinking, tool calls, file edits with diffs, command output, and generated artifacts in context |
| Workspace | Pick the project workspace before asking for file or shell work |
| Access | Choose the access mode for local capabilities allowed by your gateway configuration |
| Composer | Send text, images, voice input, slash commands, and `@` mentions for topics, Apps, or MCP presets |
| Channels | Connect and validate chat platforms, install their optional support, and manage saved channel setup |
| Apps | Install, test, update, and use local CLI App adapters and MCP presets |
| Skills | Inspect available built-in and workspace skills before relying on them |
| Skills | Inspect and manage installed skills, or discover skills from supported marketplaces |
| Automations | Review, search, run, pause, edit, and delete scheduled and local-trigger agent turns |
| Settings | Adjust models, providers, image generation, voice, web tools, runtime, and safety options |
@@ -90,6 +90,10 @@ workspace selection, and linked automations. Use a new topic when you want a
separate context; use fork when you want to continue from an existing point
without changing the original thread.
Drag a topic within its current sidebar group to keep frequently used work in
your preferred order. Drag a topic from the sidebar into the composer when you
want to reference it in the next message instead of switching to it.
The message timeline shows both user-visible replies and agent activity. Long
tool or reasoning sections can be expanded when you need the details.
@@ -103,6 +107,28 @@ File previews follow the active session access mode. Restricted workspace access
previews only files under the selected workspace. Full Access can preview files
outside the workspace when that access mode is allowed by the gateway.
## Temporary Chats
Use a temporary chat for a conversation that should not be added to nanobot's
topic history or long-term memory:
1. Select **New topic**.
2. Select the **Temporary chat** control in the page header.
3. Send the first message.
You can keep more than one temporary chat open and switch between them under
**Temporary chats** in the sidebar while the current WebUI connection remains
open. Reloading or closing the page, restarting the gateway, or losing the
WebSocket connection ends all of them. They cannot be recovered afterward.
Temporary does not mean consequence-free. Requests still go to the configured
model provider, and tools can still change files, run commands, or affect
external services. Temporary chats always use the default workspace in
Restricted mode; the project picker and Full Access are unavailable. Commands
and tools that create durable goals, automations, or subagent work are also
unavailable. Use a regular topic when you need reusable context, scheduled work,
or a result you must retain.
## Workspace and Access
Use the workspace picker before starting project-specific work. This gives the
@@ -145,7 +171,8 @@ clients.
The composer supports plain messages, image attachments, voice input when
transcription is configured, slash commands, and `@` mentions for installed Apps
or MCP presets. Select another topic from the `@` menu to attach a stable
reference; plain text that happens to start with `@` does not attach history.
reference, or drag that topic from the sidebar into the composer. Plain text
that happens to start with `@` does not attach history.
Restricted chats offer topics from the same project, while Full Access chats can
reference any WebUI topic. Nanobot reads a referenced topic only when its history
is relevant and can link it in the response. The model badge shows the current
@@ -204,10 +231,20 @@ After an App or integration is available, mention it from the composer with
## Skills
The Skills view shows the skill instructions available to the agent, including
built-in skills and workspace-provided skills. Check this view when you want to
know whether nanobot already has a focused workflow for a task before you ask it
to perform that task.
Open **Skills → Installed** to review built-in and workspace-provided skills.
You can search and filter them, inspect their instructions and setup
requirements, enable or disable them, and delete workspace skills you no longer
want.
Open **Skills → Discover** to browse or search skills from skills.sh and
SkillHub. A marketplace skill is copied into the active agent workspace after
you confirm the installation. skills.sh installation requires Node.js with
`npx`; SkillHub installation does not.
Marketplace skills are third-party instructions and may include executable
scripts. Review the source and instructions before installing one, and enable
only skills you trust with the same files, tools, and credentials available to
your agent.
## Automations
@@ -288,10 +325,17 @@ The gateway refuses to start with `host` set to `"0.0.0.0"` unless `token` or
`http://<your-ip>:8765` from the other device and enter the secret in the login
form.
Remote WebUI clients with a valid token can view and use Apps. Actions that
install missing nanobot support packages, such as adding a channel dependency,
are blocked by default. To let trusted remote administrators change the Python
environment through the WebUI, opt in explicitly:
Plain HTTP is enough for basic WebUI access, but browsers expose microphone
capture only in secure contexts. Voice input works on same-machine localhost;
from another device, serve the WebUI over HTTPS with a certificate that device
trusts. Configure [`sslCertfile` and `sslKeyfile`](./websocket.md#tlsssl) on the
WebSocket channel and open `https://<your-host>:8765`, or terminate HTTPS at a
reverse proxy and use that proxy's HTTPS URL.
Remote WebUI clients with a valid token can view and use Apps and installed
skills. Actions that install missing nanobot support packages or third-party
marketplace skills are blocked by default. To let trusted remote administrators
perform those installations through the WebUI, opt in explicitly:
```json
{
@@ -302,12 +346,13 @@ environment through the WebUI, opt in explicitly:
```
Use this only for a private deployment where every authenticated WebUI user is
trusted to change the Python environment that nanobot runs in. If you publish
the WebUI through Nginx, Caddy, Cloudflare Tunnel, or a similar service, treat it
as remote access and leave package installs disabled unless that is intentional.
trusted to change nanobot's Python environment and install workspace skill
instructions or scripts. If you publish the WebUI through Nginx, Caddy,
Cloudflare Tunnel, or a similar service, treat it as remote access and leave
package and skill installs disabled unless that is intentional.
Optional feature installs use pip's configured package index, including
`PIP_INDEX_URL`.
`PIP_INDEX_URL`. skills.sh marketplace installs use `npx` instead.
Leave remote package installs disabled when the WebUI is exposed beyond a
private, trusted network.
@@ -322,6 +367,10 @@ If the page does not open, check these in order:
4. You are opening port `8765`, not the gateway health port.
5. LAN access uses `host: "0.0.0.0"` and a token or token issue secret.
If voice input asks for a secure connection, use HTTPS with a certificate the
device trusts. Browsers do not expose microphone capture to
`http://<your-ip>` origins.
For detailed diagnostics, see
[`troubleshooting.md#webui-problems`](./troubleshooting.md#webui-problems).
For frontend development, see [`../webui/README.md`](../webui/README.md).
-44
View File
@@ -33,11 +33,6 @@ COMPACTABLE_TOOLS = frozenset({
"read_file", "exec", "grep", "find_files",
"web_search", "web_fetch", "list_dir", "list_exec_sessions",
})
VISUAL_TOOLS = frozenset({"browser", "computer_use"})
STALE_SCREENSHOT_PLACEHOLDER = {
"type": "text",
"text": "[Earlier screenshot omitted; use the latest screenshot from this tool.]",
}
# read_file is the recovery path for persisted results; exempting it prevents persist->read->persist loops.
TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS = frozenset({"read_file"})
BACKFILL_CONTENT = "[Tool result unavailable — call was interrupted or lost]"
@@ -46,12 +41,6 @@ PLACEHOLDER_TEXTS = frozenset({
})
def _is_image_block(value: object) -> bool:
if not isinstance(value, dict):
return False
return cast(dict[str, Any], value).get("type") in {"image_url", "input_image"}
def _tool_call_name_is_valid(tool_call: Any) -> bool:
"""Whether a persisted OpenAI-style tool_call carries a usable name.
@@ -95,10 +84,6 @@ class ContextGovernor:
updated = self.drop_orphan_tool_results(updated)
updated = self.backfill_missing_tool_results(updated)
updated = self.apply_tool_result_budget(config, updated)
updated = self.drop_stale_visual_tool_images(
updated,
start_index=config.inflight_start_index,
)
updated = self.compact_inflight_overflow(config, updated, compacted_tool_call_ids)
updated = self.snip_history(config, updated)
updated = self.drop_orphan_tool_results(updated)
@@ -341,35 +326,6 @@ class ContextGovernor:
updated[idx]["content"] = normalized
return updated
@staticmethod
def drop_stale_visual_tool_images(
messages: list[dict[str, Any]],
*,
start_index: int,
) -> list[dict[str, Any]]:
"""Keep only the latest in-flight screenshot from each visual tool."""
seen: set[str] = set()
updated = messages
for idx in range(len(messages) - 1, start_index - 1, -1):
message = messages[idx]
name = str(message.get("name") or "")
content = message.get("content")
if message.get("role") != "tool" or name not in VISUAL_TOOLS:
continue
if not isinstance(content, list):
continue
content_blocks = cast(list[object], content)
blocks = [block for block in content_blocks if not _is_image_block(block)]
if len(blocks) == len(content_blocks):
continue
if name not in seen:
seen.add(name)
continue
if updated is messages:
updated = [dict(item) for item in messages]
updated[idx]["content"] = [dict(STALE_SCREENSHOT_PLACEHOLDER), *blocks]
return updated
def compact_inflight_overflow(
self,
config: ContextGovernanceConfig,
+11 -4
View File
@@ -36,6 +36,7 @@ from nanobot.agent.tools.exec_session import ExecSessionManager
from nanobot.agent.tools.file_state import FileStateStore, bind_file_states, reset_file_states
from nanobot.agent.tools.message import MessageTool
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.runtime_control import AgentRuntimeControl
from nanobot.agent.tools.self import MyTool
from nanobot.agent.turn_delivery import (
TurnDelivery,
@@ -197,6 +198,11 @@ class AgentLoop:
def tool_names(self) -> list[str]:
return self.tools.tool_names
@property
def last_usage(self) -> Mapping[str, int]:
"""Latest aggregate usage exposed through the runtime-control snapshot."""
return self._last_usage
@property
def provider(self) -> LLMProvider:
"""Provider selected for future turn admissions."""
@@ -448,7 +454,6 @@ class AgentLoop:
if model_preset:
self.set_model_preset(model_preset, publish_update=False)
self._register_default_tools(provider_snapshot_loader=provider_snapshot_loader)
self._runtime_vars: dict[str, Any] = {}
self._current_iteration: int = 0
self.commands = CommandRouter()
register_builtin_commands(self.commands)
@@ -623,10 +628,13 @@ class AgentLoop:
loader = ToolLoader()
registered = loader.load(ctx, self.tools)
# MyTool needs runtime state reference — manual registration
# MyTool receives only the explicit runtime-control capability.
if self.tools_config.my.enable:
self.tools.register(
MyTool(runtime_state=self, modify_allowed=self.tools_config.my.allow_set)
MyTool(
runtime_control=AgentRuntimeControl(self),
modify_allowed=self.tools_config.my.allow_set,
)
)
registered.append("my")
@@ -1397,7 +1405,6 @@ class AgentLoop:
cleanup_steps = (
self.subagents.close,
self._exec_session_manager.close_all,
*(() if not hasattr(self, "tools") else (self.tools.close,)),
lambda: agent_context.close_mcp(self),
)
for cleanup in cleanup_steps:
+5
View File
@@ -5,6 +5,7 @@ import json
import time
import uuid
import warnings
from collections.abc import Mapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, TypedDict
@@ -157,6 +158,10 @@ class SubagentManager:
self._task_statuses: dict[str, SubagentStatus] = {}
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
def runtime_statuses(self) -> Mapping[str, SubagentStatus]:
"""Return the observable task statuses used by runtime-control snapshots."""
return self._task_statuses
def set_provider(self, provider: LLMProvider, model: str) -> None:
"""Update the deprecated runtime source used by legacy ``spawn`` calls."""
warnings.warn(
-4
View File
@@ -220,10 +220,6 @@ class Tool(ABC):
"""Return optional per-turn prompt context owned by this tool."""
return None
async def close(self) -> None:
"""Release resources owned by the tool. Safe to call repeatedly."""
return None
@abstractmethod
async def execute(self, **kwargs: Any) -> Any:
"""Run the tool; return content, or ``ToolResult.error(...)`` for failures."""
-286
View File
@@ -1,286 +0,0 @@
"""DOM-based browser automation by element reference."""
# pyright: reportIncompatibleMethodOverride=false
from __future__ import annotations
import asyncio
from typing import Any
from pydantic import Field
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.computer_use_backends.base import SessionBackendPool
from nanobot.agent.tools.context import ToolContext
from nanobot.agent.tools.schema import (
BooleanSchema,
IntegerSchema,
StringSchema,
tool_parameters_schema,
)
from nanobot.config_base import Base
from nanobot.utils.helpers import build_image_content_blocks
_ACTIONS = [
"navigate",
"snapshot",
"click",
"type",
"select",
"scroll",
"key",
"back",
"read_text",
]
class BrowserToolConfig(Base):
"""browser (DOM) tool configuration."""
enable: bool = False
start_url: str = "about:blank"
headless: bool = True
width: int = Field(default=1280, ge=320, le=4096)
height: int = Field(default=800, ge=240, le=4096)
allowed_domains: list[str] = Field(default_factory=list)
include_screenshot: bool = False
max_elements: int = Field(default=200, ge=1, le=1000)
max_sessions: int = Field(default=8, ge=1, le=64)
def _format_elements(elements: list[dict[str, Any]]) -> str:
if not elements:
return "Interactive elements: (none found — try scrolling or read_text)"
lines: list[str] = []
for e in elements:
tag = str(e.get("tag") or "")
typ = str(e.get("type") or "")
label = tag + (f"[{typ}]" if typ else "")
line = f"[{e.get('ref')}] {label}"
name = str(e.get("name") or "").strip()
if name:
line += f' "{name}"'
href = str(e.get("href") or "")
if href and tag == "a":
line += f" -> {href[:60]}"
lines.append(line)
return "Interactive elements (act with the [ref] number):\n" + "\n".join(lines)
@tool_parameters(
tool_parameters_schema(
action=StringSchema("The action to perform.", enum=_ACTIONS),
ref=IntegerSchema(
description="Element ref number from the latest snapshot (click/type/select).",
minimum=1,
nullable=True,
),
text=StringSchema(
"Text to type (action=type) or key/combo like 'Enter'/'ctrl+a' (action=key).",
nullable=True,
),
url=StringSchema("URL to open (action=navigate).", nullable=True),
value=StringSchema("Option value/label to choose (action=select).", nullable=True),
submit=BooleanSchema(description="Press Enter after typing (action=type).", nullable=True),
scroll_direction=StringSchema(
"Scroll direction (action=scroll).", enum=["up", "down", "left", "right"], nullable=True
),
scroll_amount=IntegerSchema(
description="Scroll clicks (action=scroll).",
minimum=1,
maximum=100,
nullable=True,
),
required=["action"],
)
)
class BrowserTool(Tool):
"""Browse and act on web pages by element ref (DOM-based, works with any model)."""
_scopes = {"core"}
name = "browser" # pyright: ignore[reportIncompatibleMethodOverride, reportAssignmentType]
description = ( # pyright: ignore[reportIncompatibleMethodOverride, reportAssignmentType]
"Control a web browser by acting on page elements by their [ref] number. "
"Each call returns the current page URL plus a fresh numbered list of the page's "
"interactive elements; pick a [ref] to click/type/select — no pixel coordinates "
"needed. A page may already be open: call 'snapshot' FIRST to see it. Only use "
"'navigate' for a specific URL you were explicitly given — never guess a URL. "
"Move between pages by clicking links/buttons via their [ref]. Use 'read_text' to "
"read page text. Re-read the element list after each action; refs are reassigned."
)
config_key = "browser"
@classmethod
def config_cls(cls) -> type[BrowserToolConfig]:
return BrowserToolConfig
@classmethod
def enabled(cls, ctx: ToolContext) -> bool:
return bool(ctx.config.browser.enable)
@classmethod
def create(cls, ctx: ToolContext) -> Tool:
return cls(ctx.config.browser)
def __init__(
self,
config: BrowserToolConfig | None = None,
*,
backend_impl: Any = None,
) -> None:
self.config = config or BrowserToolConfig()
runtime = None
if backend_impl is None:
from nanobot.agent.tools.computer_use_backends.browser_playwright import BrowserRuntime
runtime = BrowserRuntime(headless=self.config.headless)
self._runtime = runtime
self._execution_lock = asyncio.Lock()
self._backends = SessionBackendPool(
self._make_backend,
backend_impl,
max_backends=self.config.max_sessions,
finalizer=runtime.close if runtime is not None else None,
)
@property
def read_only(self) -> bool:
return False
@property
def exclusive(self) -> bool:
return True
def _make_backend(self) -> Any:
from nanobot.agent.tools.computer_use_backends.browser_playwright import BrowserBackend
return BrowserBackend(
width=self.config.width,
height=self.config.height,
start_url=self.config.start_url,
allowed_domains=self.config.allowed_domains,
runtime=self._runtime,
)
@staticmethod
def _req_ref(params: dict[str, Any], action: str) -> Any:
ref = params.get("ref")
if ref is None:
raise ValueError(f"action '{action}' requires an element 'ref' from the snapshot")
return ref
async def _dispatch(self, backend: Any, action: str, p: dict[str, Any]) -> tuple[str, str | None]:
"""Return (status, direct_text). If direct_text is set, it is returned as-is
(no snapshot appended)."""
if action == "navigate":
url = p.get("url")
if not url:
raise ValueError("action 'navigate' requires 'url'")
await backend.navigate(str(url))
return f"Navigated to {url}", None
if action == "snapshot":
return "Snapshot of the current page", None
if action == "click":
ref = self._req_ref(p, action)
await backend.click_ref(ref)
return f"Clicked element [{ref}]", None
if action == "type":
ref = self._req_ref(p, action)
text = p.get("text")
if text is None:
raise ValueError("action 'type' requires 'text'")
submit = bool(p.get("submit"))
await backend.fill_ref(ref, str(text), submit=submit)
return f"Typed into [{ref}]" + (" and pressed Enter" if submit else ""), None
if action == "select":
ref = self._req_ref(p, action)
value = p.get("value")
if value is None:
raise ValueError("action 'select' requires 'value'")
await backend.select_ref(ref, str(value))
return f"Selected '{value}' in [{ref}]", None
if action == "scroll":
direction = str(p.get("scroll_direction") or "down").lower()
if direction not in ("up", "down", "left", "right"):
raise ValueError("'scroll_direction' must be up/down/left/right")
await backend.scroll_page(direction, int(p.get("scroll_amount") or 3))
return f"Scrolled {direction}", None
if action == "key":
combo = p.get("text")
if not combo:
raise ValueError("action 'key' requires 'text' (e.g. 'Enter')")
await backend.key(str(combo))
return f"Pressed {combo}", None
if action == "back":
await backend.go_back()
return "Navigated back", None
if action == "read_text":
txt = await backend.read_text()
return "", f"Page text:\n{txt}"
raise ValueError(f"unknown action '{action}'")
async def execute(self, action: str | None = None, **kwargs: Any) -> Any:
async with self._execution_lock:
return await self._execute(action, **kwargs)
async def _execute(self, action: str | None = None, **kwargs: Any) -> Any:
action = (action or "").strip()
if action not in _ACTIONS:
return ToolResult.error(
f"Error: unknown action '{action}'. Valid actions: {', '.join(_ACTIONS)}"
)
try:
backend = await self._backends.get()
except ImportError as exc:
return ToolResult.error(f"Error: {exc}")
except Exception as exc:
return ToolResult.error(
f"Error: could not initialize browser backend: {type(exc).__name__}: {exc}"
)
try:
status, direct = await self._dispatch(backend, action, kwargs)
if blocked := getattr(backend, "pop_blocked_navigation", lambda: None)():
raise ValueError(f"navigation was blocked: {blocked}")
except ValueError as exc:
return ToolResult.error(f"Error: {exc}")
except Exception as exc:
return ToolResult.error(
f"Error executing browser '{action}': {type(exc).__name__}: {exc}"
)
if direct is not None:
return direct
try:
elements = await backend.dom_snapshot(self.config.max_elements)
snapshot = _format_elements(elements)
except Exception as exc:
snapshot = f"(could not read page elements: {type(exc).__name__}: {exc})"
try:
current = await backend.current_url()
except Exception:
current = ""
header = f"{status}\nCurrent page: {current}" if current else status
text_out = f"{header}\n\n{snapshot}"
if self.config.include_screenshot:
try:
png = await backend.screenshot()
return build_image_content_blocks(png, "image/png", "", text_out)
except Exception:
return text_out
return text_out
async def close(self) -> None:
await self._backends.close()
-333
View File
@@ -1,333 +0,0 @@
"""Screenshot-based computer control."""
# pyright: reportIncompatibleMethodOverride=false
from __future__ import annotations
import asyncio
import io
from typing import Any, Literal
from pydantic import Field
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.computer_use_backends.base import SessionBackendPool
from nanobot.agent.tools.context import ToolContext
from nanobot.agent.tools.schema import (
IntegerSchema,
NumberSchema,
StringSchema,
tool_parameters_schema,
)
from nanobot.config_base import Base
from nanobot.utils.helpers import build_image_content_blocks
_ACTIONS = [
"screenshot",
"left_click",
"right_click",
"middle_click",
"double_click",
"triple_click",
"mouse_move",
"left_click_drag",
"scroll",
"type",
"key",
"wait",
"navigate",
]
_CLICK_BUTTONS = {
"left_click": "left",
"double_click": "left",
"triple_click": "left",
"right_click": "right",
"middle_click": "middle",
}
_CLICK_COUNTS = {"double_click": 2, "triple_click": 3}
_MAX_WAIT_S = 10.0
class ComputerUseToolConfig(Base):
"""computer_use tool configuration."""
enable: bool = False
backend: Literal["desktop", "browser"] = "desktop"
target_width: int = Field(default=1280, ge=320, le=4096)
target_height: int = Field(default=800, ge=240, le=4096)
allowed_domains: list[str] = Field(default_factory=list)
start_url: str = "about:blank"
headless: bool = True
max_sessions: int = Field(default=8, ge=1, le=64)
def _fit_size(width: int, height: int, max_width: int, max_height: int) -> tuple[int, int]:
if width <= 0 or height <= 0:
return max(1, max_width), max(1, max_height)
scale = min(max_width / width, max_height / height, 1.0)
return max(1, round(width * scale)), max(1, round(height * scale))
def _scale_point(
x: int,
y: int,
source: tuple[int, int],
target: tuple[int, int],
) -> tuple[int, int]:
width, height = source
target_width, target_height = target
real_x = round(x * width / target_width) if target_width else x
real_y = round(y * height / target_height) if target_height else y
return (
max(0, min(real_x, max(0, width - 1))),
max(0, min(real_y, max(0, height - 1))),
)
@tool_parameters(
tool_parameters_schema(
action=StringSchema("The action to perform.", enum=_ACTIONS),
x=IntegerSchema(
description="X coordinate in the pixel space of the screenshot you were last shown.",
nullable=True,
),
y=IntegerSchema(
description="Y coordinate in the pixel space of the screenshot you were last shown.",
nullable=True,
),
text=StringSchema(
"Text to type (action=type; desktop supports ASCII), or a key/combo like "
"'ctrl+s' or 'Enter' (action=key).",
nullable=True,
),
scroll_direction=StringSchema(
"Scroll direction (action=scroll).", enum=["up", "down", "left", "right"], nullable=True
),
scroll_amount=IntegerSchema(
description="Number of scroll clicks (action=scroll).",
minimum=1,
maximum=100,
nullable=True,
),
duration=NumberSchema(
description="Seconds to wait (action=wait).",
minimum=0,
maximum=_MAX_WAIT_S,
nullable=True,
),
url=StringSchema("URL to open (action=navigate, browser backend only).", nullable=True),
required=["action"],
)
)
class ComputerUseTool(Tool):
"""Control a computer (desktop or browser) by looking at screenshots and acting."""
_scopes = {"core"} # never exposed to subagents — security-sensitive
name = "computer_use" # pyright: ignore[reportIncompatibleMethodOverride, reportAssignmentType]
description = ( # pyright: ignore[reportIncompatibleMethodOverride, reportAssignmentType]
"Control a computer via screenshots and mouse/keyboard. Each call performs ONE "
"action and returns a fresh screenshot of the resulting screen. Coordinates (x, y) "
"are in the pixel space of the screenshot you were last shown (top-left is 0,0). "
"The 'browser' backend additionally supports the 'navigate' action. Always start "
"with a 'screenshot' to see the screen, then act based on what you observe; after "
"each action re-check the new screenshot before the next step."
)
config_key = "computer_use"
@classmethod
def config_cls(cls) -> type[ComputerUseToolConfig]:
return ComputerUseToolConfig
@classmethod
def enabled(cls, ctx: ToolContext) -> bool:
return bool(ctx.config.computer_use.enable)
@classmethod
def create(cls, ctx: ToolContext) -> Tool:
return cls(ctx.config.computer_use)
def __init__(
self,
config: ComputerUseToolConfig | None = None,
*,
backend_impl: Any = None,
) -> None:
self.config = config or ComputerUseToolConfig()
runtime = None
if backend_impl is None and self.config.backend == "browser":
from nanobot.agent.tools.computer_use_backends.browser_playwright import BrowserRuntime
runtime = BrowserRuntime(headless=self.config.headless)
self._runtime = runtime
self._execution_lock = asyncio.Lock()
self._backends = SessionBackendPool(
self._make_backend,
backend_impl,
max_backends=1 if self.config.backend == "desktop" else self.config.max_sessions,
finalizer=runtime.close if runtime is not None else None,
)
@property
def read_only(self) -> bool:
return False
@property
def exclusive(self) -> bool:
# Stateful single environment; must not run alongside other tools.
return True
def _make_backend(self) -> Any:
if self.config.backend == "browser":
from nanobot.agent.tools.computer_use_backends.browser_playwright import BrowserBackend
return BrowserBackend(
width=self.config.target_width,
height=self.config.target_height,
start_url=self.config.start_url,
allowed_domains=self.config.allowed_domains,
runtime=self._runtime,
)
from nanobot.agent.tools.computer_use_backends.desktop_pyautogui import DesktopBackend
return DesktopBackend()
@staticmethod
def _downscale_png(png: bytes, target: tuple[int, int]) -> bytes:
try:
from PIL import Image # noqa: PLC0415
except Exception as exc:
raise ImportError(
"Pillow is required for computer_use. Install: pip install 'nanobot-ai[computer-use]'"
) from exc
tw, th = target
with Image.open(io.BytesIO(png)) as img:
if (img.width, img.height) == (tw, th):
return png
resized = img.convert("RGB").resize((tw, th)) # pyright: ignore[reportUnknownMemberType]
out = io.BytesIO()
resized.save(out, format="PNG")
return out.getvalue()
async def _dispatch(
self,
backend: Any,
action: str,
params: dict[str, Any],
source: tuple[int, int],
target: tuple[int, int],
) -> str:
def _xy() -> tuple[int, int]:
x, y = params.get("x"), params.get("y")
if x is None or y is None:
raise ValueError(f"action '{action}' requires integer 'x' and 'y'")
return _scale_point(int(x), int(y), source, target)
if action == "screenshot":
return "Took a screenshot"
if action == "wait":
duration = params.get("duration")
secs = 1.0 if duration is None else float(duration)
secs = max(0.0, min(secs, _MAX_WAIT_S))
await asyncio.sleep(secs)
return f"Waited {secs:g}s"
if action in _CLICK_BUTTONS:
rx, ry = _xy()
await backend.click(rx, ry, _CLICK_BUTTONS[action], _CLICK_COUNTS.get(action, 1))
return f"{action} at ({rx}, {ry})"
if action == "mouse_move":
rx, ry = _xy()
await backend.move(rx, ry)
return f"Moved to ({rx}, {ry})"
if action == "left_click_drag":
rx, ry = _xy()
await backend.drag(rx, ry)
return f"Dragged to ({rx}, {ry})"
if action == "scroll":
rx, ry = _xy()
direction = str(params.get("scroll_direction") or "down").lower()
if direction not in ("up", "down", "left", "right"):
raise ValueError("'scroll_direction' must be up/down/left/right")
amount = int(params.get("scroll_amount") or 3)
await backend.scroll(rx, ry, direction, amount)
return f"Scrolled {direction} by {amount} at ({rx}, {ry})"
if action == "type":
text = params.get("text")
if not text:
raise ValueError("action 'type' requires 'text'")
await backend.type_text(str(text))
return f"Typed {len(str(text))} characters"
if action == "key":
combo = params.get("text")
if not combo:
raise ValueError("action 'key' requires 'text' (e.g. 'ctrl+s')")
await backend.key(str(combo))
return f"Pressed {combo}"
if action == "navigate":
url = params.get("url")
if not url:
raise ValueError("action 'navigate' requires 'url'")
await backend.navigate(str(url))
return f"Navigated to {url}"
raise ValueError(f"unknown action '{action}'")
async def execute(self, action: str | None = None, **kwargs: Any) -> Any:
async with self._execution_lock:
return await self._execute(action, **kwargs)
async def _execute(self, action: str | None = None, **kwargs: Any) -> Any:
action = (action or "").strip()
if action not in _ACTIONS:
return ToolResult.error(
f"Error: unknown action '{action}'. Valid actions: {', '.join(_ACTIONS)}"
)
try:
backend = await self._backends.get()
real_w, real_h = await backend.dimensions()
except ImportError as exc:
return ToolResult.error(f"Error: {exc}")
except Exception as exc:
return ToolResult.error(
f"Error: could not initialize computer_use backend: {type(exc).__name__}: {exc}"
)
source = (real_w, real_h)
target = _fit_size(real_w, real_h, self.config.target_width, self.config.target_height)
try:
status = await self._dispatch(backend, action, kwargs, source, target)
if blocked := getattr(backend, "pop_blocked_navigation", lambda: None)():
raise ValueError(f"navigation was blocked: {blocked}")
except ValueError as exc:
return ToolResult.error(f"Error: {exc}")
except NotImplementedError as exc:
return ToolResult.error(f"Error: {exc}")
except Exception as exc:
return ToolResult.error(
f"Error executing computer_use '{action}': {type(exc).__name__}: {exc}"
)
# Return a fresh screenshot so the model sees the result of its action.
try:
png = await backend.screenshot()
png = self._downscale_png(png, target)
except ImportError as exc:
return ToolResult.error(f"Error: {exc}")
except Exception as exc:
return f"{status}\n(Could not capture screenshot: {type(exc).__name__}: {exc})"
label = f"{status} | screen {target[0]}x{target[1]} ({backend.environment})"
return build_image_content_blocks(png, "image/png", "", label)
async def close(self) -> None:
await self._backends.close()
@@ -1 +0,0 @@
"""Computer-use backend adapters."""
@@ -1,139 +0,0 @@
"""Backend interface for the ``computer_use`` tool.
A backend is the *actuator* + *screenshot source* for one execution environment
(the local desktop, a headless browser, a VM, ...). The tool layer owns the
agent loop, coordinate scaling, screenshot downscaling and safety gating; a
backend only has to perform primitive actions and grab a screenshot.
Coordinate contract: every ``x``/``y`` passed to a backend is already in **real
device pixels** (the same pixel space as :meth:`screenshot`). The tool scales the
model's target-space coordinates to real pixels before calling the backend, so
backends never deal with the downscaled space.
"""
from __future__ import annotations
import asyncio
from abc import ABC, abstractmethod
from collections import OrderedDict
from collections.abc import Awaitable, Callable
from typing import Any
from nanobot.agent.tools.context import current_request_session_key
class ComputerBackend(ABC):
"""Primitive GUI actions + screenshot for one execution environment."""
#: "desktop" or "browser" — surfaced to the model so it knows the context.
environment: str = "desktop"
@abstractmethod
async def dimensions(self) -> tuple[int, int]:
"""Return the real screenshot pixel size as ``(width, height)``."""
@abstractmethod
async def screenshot(self) -> bytes:
"""Return a PNG screenshot of the current screen at real pixel size."""
@abstractmethod
async def click(self, x: int, y: int, button: str = "left", count: int = 1) -> None:
"""Click at ``(x, y)``. ``button`` in {left,right,middle}; ``count`` for double/triple."""
@abstractmethod
async def move(self, x: int, y: int) -> None:
"""Move the cursor to ``(x, y)`` without clicking."""
@abstractmethod
async def drag(self, x: int, y: int) -> None:
"""Press at the current cursor position and drag to ``(x, y)``, then release."""
@abstractmethod
async def scroll(self, x: int, y: int, direction: str, amount: int) -> None:
"""Scroll at ``(x, y)``. ``direction`` in {up,down,left,right}; ``amount`` in clicks."""
@abstractmethod
async def type_text(self, text: str) -> None:
"""Type ``text`` at the current focus."""
@abstractmethod
async def key(self, combo: str) -> None:
"""Press a key or combo, e.g. ``"ctrl+s"`` / ``"Enter"`` (backend-specific syntax)."""
async def navigate(self, url: str) -> None:
"""Navigate to ``url`` (browser backends only)."""
raise NotImplementedError(
f"'navigate' is not supported by the {self.environment} backend"
)
async def close(self) -> None:
"""Release any resources (browser process, etc.). Safe to call repeatedly."""
return None
class SessionBackendPool:
"""Keep stateful backends isolated by nanobot session."""
def __init__(
self,
factory: Callable[[], Any],
injected: Any = None,
*,
max_backends: int = 8,
finalizer: Callable[[], Awaitable[None]] | None = None,
) -> None:
if max_backends < 1:
raise ValueError("max_backends must be at least 1")
self._factory = factory
self._injected = injected
self._max_backends = max_backends
self._finalizer = finalizer
self._backends: OrderedDict[str, Any] = OrderedDict()
self._lock = asyncio.Lock()
self._closed = False
async def get(self) -> Any:
async with self._lock:
if self._closed:
raise RuntimeError("computer-use backend pool is closed")
if self._injected is not None:
return self._injected
key = current_request_session_key() or "default"
backend = self._backends.get(key)
if backend is not None:
self._backends.move_to_end(key)
return backend
if len(self._backends) >= self._max_backends:
_, stale = self._backends.popitem(last=False)
await stale.close()
backend = self._factory()
self._backends[key] = backend
return backend
async def close(self) -> None:
async with self._lock:
if self._closed:
return
self._closed = True
backends = (
[self._injected]
if self._injected is not None
else list(self._backends.values())
)
self._injected = None
self._backends.clear()
finalizer, self._finalizer = self._finalizer, None
results = await asyncio.gather(
*(backend.close() for backend in backends if backend is not None),
return_exceptions=True,
)
errors = [result for result in results if isinstance(result, BaseException)]
if finalizer is not None:
try:
await finalizer()
except BaseException as exc:
errors.append(exc)
if len(errors) == 1:
raise errors[0]
if errors:
raise BaseExceptionGroup("failed to close computer-use backends", errors)
@@ -1,382 +0,0 @@
"""Playwright backend shared by browser and computer_use."""
from __future__ import annotations
import asyncio
import importlib
from collections.abc import Sequence
from typing import Any, cast
from urllib.parse import urlparse, urlunparse
from loguru import logger
from nanobot.agent.tools.computer_use_backends.base import ComputerBackend
from nanobot.security.network import validate_url_target
_MISSING = (
"Browser computer-use backend needs 'playwright'. Install with: "
"pip install 'nanobot-ai[computer-use]' && playwright install chromium"
)
_SCROLL_PIXELS = 100 # one "scroll click" ~= this many pixels
# Tags visible interactive elements with data-nanobot-ref and returns a compact
# list. Refs are reassigned per call. Used by DOM/accessibility mode.
_SNAPSHOT_JS = r"""
(max) => {
const SEL = 'a,button,input,textarea,select,[role=button],[role=link],[role=checkbox],[role=radio],[role=tab],[role=menuitem],[role=switch],[onclick],[contenteditable=""],[contenteditable=true]';
const out = [];
let ref = 0;
for (const el of document.querySelectorAll(SEL)) {
const r = el.getBoundingClientRect();
const s = getComputedStyle(el);
if (r.width <= 0 || r.height <= 0) continue;
if (s.visibility === 'hidden' || s.display === 'none' || s.opacity === '0') continue;
ref++;
el.setAttribute('data-nanobot-ref', String(ref));
let name = (el.getAttribute('aria-label') || el.innerText || el.value ||
el.getAttribute('placeholder') || el.getAttribute('name') ||
el.getAttribute('title') || '');
name = name.replace(/\s+/g, ' ').trim().slice(0, 120);
out.push({
ref: ref,
tag: el.tagName.toLowerCase(),
role: el.getAttribute('role') || '',
type: el.getAttribute('type') || '',
name: name,
href: el.getAttribute('href') || ''
});
if (out.length >= max) break;
}
return out;
}
"""
# CUA/xdotool-ish modifier names -> Playwright modifiers.
_MODIFIERS = {
"ctrl": "Control", "control": "Control",
"alt": "Alt", "option": "Alt",
"shift": "Shift",
"cmd": "Meta", "meta": "Meta", "super": "Meta", "win": "Meta",
}
# Common single-key names -> Playwright key names.
_KEYS = {
"return": "Enter", "enter": "Enter", "tab": "Tab", "esc": "Escape",
"escape": "Escape", "backspace": "Backspace", "delete": "Delete",
"space": "Space", "up": "ArrowUp", "down": "ArrowDown",
"left": "ArrowLeft", "right": "ArrowRight",
"page_down": "PageDown", "pagedown": "PageDown",
"page_up": "PageUp", "pageup": "PageUp", "home": "Home", "end": "End",
}
def _validate_browser_url(
url: str,
allowed_domains: Sequence[str] = (),
*,
navigation: bool = True,
) -> tuple[bool, str]:
if url == "about:blank":
return True, ""
parsed = urlparse(url)
if not navigation and parsed.scheme in {"blob", "data"}:
return True, ""
target = url
if parsed.scheme in {"ws", "wss"}:
target = urlunparse(parsed._replace(scheme="https" if parsed.scheme == "wss" else "http"))
if navigation and allowed_domains:
host = (parsed.hostname or "").rstrip(".").lower()
allowed = any(
normalized and (host == normalized or host.endswith(f".{normalized}"))
for domain in allowed_domains
if (normalized := domain.strip().lstrip(".").rstrip(".").lower())
)
if not allowed:
return False, f"host {host or '<missing>'} is not in allowed_domains"
return validate_url_target(target)
def _playwright_key(combo: str) -> str:
parts = [p.strip() for p in combo.split("+") if p.strip()]
out: list[str] = []
for part in parts:
low = part.lower()
if low in _MODIFIERS:
out.append(_MODIFIERS[low])
elif low in _KEYS:
out.append(_KEYS[low])
elif len(part) == 1:
out.append(part)
else:
out.append(part.capitalize())
return "+".join(out)
class BrowserRuntime:
"""One lazily started browser process shared by isolated session contexts."""
def __init__(self, *, headless: bool = True) -> None:
self._headless = headless
self._lock = asyncio.Lock()
self._playwright: Any = None
self._browser: Any = None
async def get(self) -> Any:
if self._browser is not None:
return self._browser
async with self._lock:
if self._browser is not None:
return self._browser
try:
playwright = importlib.import_module("playwright.async_api")
async_playwright = cast(Any, playwright).async_playwright
except ImportError as exc:
raise ImportError(_MISSING) from exc
self._playwright = await async_playwright().start()
try:
self._browser = await self._playwright.chromium.launch(
headless=self._headless
)
except BaseException:
await self.close()
raise
return self._browser
async def close(self) -> None:
browser, playwright = self._browser, self._playwright
self._browser = self._playwright = None
errors: list[BaseException] = []
closers = (
browser.close if browser is not None else None,
playwright.stop if playwright is not None else None,
)
for close in closers:
if close is None:
continue
try:
await close()
except BaseException as exc:
errors.append(exc)
if len(errors) == 1:
raise errors[0]
if errors:
raise BaseExceptionGroup("failed to close browser runtime", errors)
class BrowserBackend(ComputerBackend):
environment = "browser"
def __init__(
self,
*,
width: int = 1280,
height: int = 800,
headless: bool = True,
start_url: str = "about:blank",
allowed_domains: Sequence[str] = (),
runtime: BrowserRuntime | None = None,
) -> None:
self._width = width
self._height = height
self._start_url = start_url
self._allowed_domains = tuple(allowed_domains)
self._runtime = runtime or BrowserRuntime(headless=headless)
self._owns_runtime = runtime is None
self._context: Any = None
self._page: Any = None
self._last_pos = (0, 0)
self._blocked_navigation: str | None = None
async def _require_url(self, url: str, label: str) -> None:
ok, error = await asyncio.to_thread(
_validate_browser_url,
url,
self._allowed_domains,
)
if not ok:
raise ValueError(f"{label} is blocked: {error}")
async def _route_request(self, route: Any) -> None:
request = route.request
navigation = bool(request.is_navigation_request())
ok, error = await asyncio.to_thread(
_validate_browser_url,
request.url,
self._allowed_domains,
navigation=navigation,
)
if ok:
await route.continue_()
return
if navigation:
self._blocked_navigation = error
logger.warning("Blocked browser request to {}: {}", request.url, error)
await route.abort("blockedbyclient")
async def _route_web_socket(self, web_socket: Any) -> None:
ok, error = await asyncio.to_thread(
_validate_browser_url,
web_socket.url,
self._allowed_domains,
navigation=False,
)
if not ok:
logger.warning("Blocked browser WebSocket to {}: {}", web_socket.url, error)
await web_socket.close(code=1008, reason="Blocked by nanobot network policy")
return
await web_socket.connect_to_server()
def pop_blocked_navigation(self) -> str | None:
error = self._blocked_navigation
self._blocked_navigation = None
return error
async def _ensure(self) -> Any:
if self._page is not None:
return self._page
await self._require_url(self._start_url, "start_url")
try:
browser = await self._runtime.get()
self._context = await browser.new_context(
viewport={"width": self._width, "height": self._height},
device_scale_factor=1,
service_workers="block",
)
await self._context.route("**/*", self._route_request)
await self._context.route_web_socket("**/*", self._route_web_socket)
self._page = await self._context.new_page()
if self._start_url != "about:blank":
await self._page.goto(self._start_url)
return self._page
except BaseException:
await self.close()
raise
async def dimensions(self) -> tuple[int, int]:
await self._ensure()
vp = self._page.viewport_size or {"width": self._width, "height": self._height}
return vp["width"], vp["height"]
async def screenshot(self) -> bytes:
page = await self._ensure()
return await page.screenshot()
async def click(self, x: int, y: int, button: str = "left", count: int = 1) -> None:
page = await self._ensure()
await page.mouse.click(x, y, button=button, click_count=count)
self._last_pos = (x, y)
async def move(self, x: int, y: int) -> None:
page = await self._ensure()
await page.mouse.move(x, y)
self._last_pos = (x, y)
async def drag(self, x: int, y: int) -> None:
page = await self._ensure()
sx, sy = self._last_pos
await page.mouse.move(sx, sy)
await page.mouse.down()
await page.mouse.move(x, y)
await page.mouse.up()
self._last_pos = (x, y)
async def scroll(self, x: int, y: int, direction: str, amount: int) -> None:
page = await self._ensure()
await page.mouse.move(x, y)
pixels = max(1, amount) * _SCROLL_PIXELS
dx = pixels if direction == "right" else -pixels if direction == "left" else 0
dy = pixels if direction == "down" else -pixels if direction == "up" else 0
await page.mouse.wheel(dx, dy)
async def type_text(self, text: str) -> None:
page = await self._ensure()
await page.keyboard.type(text)
async def key(self, combo: str) -> None:
page = await self._ensure()
key = _playwright_key(combo)
if key:
await page.keyboard.press(key)
async def navigate(self, url: str) -> None:
await self._require_url(url, "navigation")
page = await self._ensure()
await page.goto(url)
self._last_pos = (0, 0)
# --- DOM / accessibility mode (act by element ref, not pixels) ---
async def dom_snapshot(self, max_elements: int = 200) -> list[dict[str, Any]]:
"""Tag visible interactive elements with ``data-nanobot-ref`` and return them.
Each entry: ``{ref, tag, role, type, name, href}``. Refs are reassigned on
every snapshot, so callers should act on the latest snapshot.
"""
page = await self._ensure()
return cast(list[dict[str, Any]], await page.evaluate(_SNAPSHOT_JS, max_elements))
def _ref_selector(self, ref: int) -> str:
return f'[data-nanobot-ref="{int(ref)}"]'
async def click_ref(self, ref: int) -> None:
page = await self._ensure()
await page.click(self._ref_selector(ref), timeout=5000)
async def fill_ref(self, ref: int, text: str, submit: bool = False) -> None:
page = await self._ensure()
sel = self._ref_selector(ref)
await page.fill(sel, text, timeout=5000)
if submit:
await page.press(sel, "Enter")
async def select_ref(self, ref: int, value: str) -> None:
page = await self._ensure()
sel = self._ref_selector(ref)
try:
await page.select_option(sel, value, timeout=3000)
except Exception:
# Models usually pass the visible label, not the option value.
await page.select_option(sel, label=value, timeout=3000)
async def scroll_page(self, direction: str, amount: int) -> None:
page = await self._ensure()
pixels = max(1, amount) * _SCROLL_PIXELS
dx = pixels if direction == "right" else -pixels if direction == "left" else 0
dy = pixels if direction == "down" else -pixels if direction == "up" else 0
await page.evaluate("([x, y]) => window.scrollBy(x, y)", [dx, dy])
async def go_back(self) -> None:
page = await self._ensure()
await page.go_back()
async def read_text(self, max_chars: int = 4000) -> str:
page = await self._ensure()
txt = await page.evaluate("() => document.body ? document.body.innerText : ''")
return (txt or "")[:max_chars]
async def current_url(self) -> str:
page = await self._ensure()
return page.url
async def close(self) -> None:
context = self._context
self._context = self._page = None
error: BaseException | None = None
if context is not None:
try:
await context.close()
except BaseException as exc:
error = exc
if self._owns_runtime:
try:
await self._runtime.close()
except BaseException as exc:
if error is not None:
raise BaseExceptionGroup("failed to close browser backend", [error, exc])
raise
if error is not None:
raise error
@@ -1,134 +0,0 @@
"""PyAutoGUI desktop backend with HiDPI coordinate correction."""
from __future__ import annotations
import asyncio
import io
from typing import Any
from nanobot.agent.tools.computer_use_backends.base import ComputerBackend
_MISSING = (
"Desktop computer-use backend needs 'pyautogui' and 'pillow'. "
"Install with: pip install 'nanobot-ai[computer-use]'"
)
# xdotool/CUA-style key names -> PyAutoGUI key names.
_KEY_ALIASES = {
"return": "enter",
"ctrl": "ctrl",
"control": "ctrl",
"cmd": "command",
"super": "win",
"win": "win",
"page_down": "pagedown",
"page_up": "pageup",
"pagedown": "pagedown",
"pageup": "pageup",
"esc": "esc",
"escape": "esc",
}
class DesktopBackend(ComputerBackend):
environment = "desktop"
def __init__(self) -> None:
self._pg: Any = None
self._ratio_x = 1.0
self._ratio_y = 1.0
self._dims: tuple[int, int] | None = None
def _ensure(self) -> Any:
if self._pg is not None:
return self._pg
try:
import pyautogui # noqa: PLC0415
except Exception as exc: # ImportError, or platform display errors
raise ImportError(_MISSING) from exc
self._pg = pyautogui
return pyautogui
def _grab_png_and_size(self) -> tuple[bytes, int, int]:
pg = self._ensure()
img = pg.screenshot()
buf = io.BytesIO()
img.save(buf, format="PNG")
width, height = img.size
# Refresh logical<->physical ratio from the actual grab.
try:
logical_w, logical_h = pg.size()
self._ratio_x = (logical_w / width) if width else 1.0
self._ratio_y = (logical_h / height) if height else 1.0
except Exception:
self._ratio_x = self._ratio_y = 1.0
self._dims = (width, height)
return buf.getvalue(), width, height
def _to_logical(self, x: int, y: int) -> tuple[int, int]:
return round(x * self._ratio_x), round(y * self._ratio_y)
async def dimensions(self) -> tuple[int, int]:
if self._dims is not None:
return self._dims
_, w, h = await asyncio.to_thread(self._grab_png_and_size)
return w, h
async def screenshot(self) -> bytes:
png, _, _ = await asyncio.to_thread(self._grab_png_and_size)
return png
async def click(self, x: int, y: int, button: str = "left", count: int = 1) -> None:
pg = self._ensure()
lx, ly = self._to_logical(x, y)
await asyncio.to_thread(pg.click, lx, ly, clicks=count, button=button)
async def move(self, x: int, y: int) -> None:
pg = self._ensure()
lx, ly = self._to_logical(x, y)
await asyncio.to_thread(pg.moveTo, lx, ly)
async def drag(self, x: int, y: int) -> None:
pg = self._ensure()
lx, ly = self._to_logical(x, y)
await asyncio.to_thread(
pg.dragTo,
lx,
ly,
duration=0.3,
tween=pg.easeInOutQuad,
button="left",
)
async def scroll(self, x: int, y: int, direction: str, amount: int) -> None:
pg = self._ensure()
lx, ly = self._to_logical(x, y)
clicks = max(1, amount)
await asyncio.to_thread(pg.moveTo, lx, ly)
if direction in ("up", "down"):
await asyncio.to_thread(pg.scroll, clicks if direction == "up" else -clicks)
else:
await asyncio.to_thread(pg.hscroll, clicks if direction == "right" else -clicks)
async def type_text(self, text: str) -> None:
if not text.isascii():
raise ValueError(
"desktop text input supports ASCII key events only; "
"use the browser backend for Unicode text"
)
pg = self._ensure()
await asyncio.to_thread(pg.typewrite, text, 0.01)
async def key(self, combo: str) -> None:
pg = self._ensure()
keys = [
_KEY_ALIASES.get(part.strip().lower(), part.strip().lower())
for part in combo.split("+")
if part.strip()
]
if not keys:
return
if len(keys) == 1:
await asyncio.to_thread(pg.press, keys[0])
else:
await asyncio.to_thread(pg.hotkey, *keys)
+1 -4
View File
@@ -19,7 +19,7 @@ if TYPE_CHECKING:
_SKIP_MODULES = frozenset({
"base", "schema", "registry", "context", "loader", "config",
"file_state", "sandbox", "mcp", "__init__", "runtime_state",
"file_state", "sandbox", "mcp", "__init__", "runtime_control",
})
@@ -187,8 +187,5 @@ class _LegacyErrorPrefixTool(Tool):
return ToolResult.error(result)
return result
async def close(self) -> None:
await self._wrapped.close()
def __getattr__(self, name: str) -> Any:
return getattr(self._wrapped, name)
+19 -29
View File
@@ -975,11 +975,8 @@ async def connect_mcp_servers(
from mcp.client.streamable_http import streamable_http_client
async def open_single_server(
name: str, cfg: "MCPServerConfig"
) -> tuple[str, AsyncExitStack | None]:
server_stack = AsyncExitStack()
await server_stack.__aenter__()
name: str, cfg: "MCPServerConfig", server_stack: AsyncExitStack
) -> bool:
try:
transport_type = cfg.type
if not transport_type:
@@ -991,8 +988,7 @@ async def connect_mcp_servers(
)
else:
logger.warning("MCP server '{}': no command or url configured, skipping", name)
await server_stack.aclose()
return name, None
return False
if transport_type in {"sse", "streamableHttp"}:
ok, error = validate_url_target(cfg.url)
@@ -1003,8 +999,7 @@ async def connect_mcp_servers(
_redact_url(cfg.url),
error,
)
await server_stack.aclose()
return name, None
return False
if transport_type == "stdio":
command, args, env = _normalize_windows_stdio_command(
@@ -1022,8 +1017,7 @@ async def connect_mcp_servers(
elif transport_type == "sse":
if not await _probe_http_url(cfg.url):
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
await server_stack.aclose()
return name, None
return False
def httpx_client_factory(
headers: dict[str, str] | None = None,
@@ -1050,8 +1044,7 @@ async def connect_mcp_servers(
elif transport_type == "streamableHttp":
if not await _probe_http_url(cfg.url):
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
await server_stack.aclose()
return name, None
return False
http_client = await server_stack.enter_async_context(
httpx.AsyncClient(
@@ -1067,8 +1060,7 @@ async def connect_mcp_servers(
)
else:
logger.warning("MCP server '{}': unknown transport type '{}'", name, transport_type)
await server_stack.aclose()
return name, None
return False
read = _filter_malformed_mcp_progress_notifications(read, name)
session = await server_stack.enter_async_context(ClientSession(read, write))
@@ -1171,7 +1163,7 @@ async def connect_mcp_servers(
logger.info(
"MCP server '{}': connected, {} capabilities registered", name, registered_count
)
return name, server_stack
return True
except Exception as e:
hint = ""
@@ -1191,9 +1183,7 @@ async def connect_mcp_servers(
"only JSON-RPC to stdout and sends logs/debug output to stderr instead."
)
logger.exception("MCP server '{}': failed to connect: {}", name, hint)
with suppress(Exception):
await server_stack.aclose()
return name, None
return False
async def connect_single_server(
name: str, cfg: "MCPServerConfig"
@@ -1203,30 +1193,30 @@ async def connect_mcp_servers(
close_requested = asyncio.Event()
async def own_connection() -> None:
stack: AsyncExitStack | None = None
try:
_, stack = await open_single_server(name, cfg)
if not ready.done():
ready.set_result(stack is not None)
if stack is not None:
await close_requested.wait()
async with AsyncExitStack() as stack:
connected = await open_single_server(name, cfg, stack)
if not ready.done():
ready.set_result(connected)
if connected:
await close_requested.wait()
except BaseException as exc:
if not ready.done():
ready.set_exception(exc)
raise
finally:
if stack is not None:
await stack.aclose()
owner = asyncio.create_task(own_connection(), name=f"mcp:{name}")
connection = _OwnedMCPConnection(owner, close_requested)
try:
connected = await ready
except BaseException:
except BaseException as exc:
close_requested.set()
owner.cancel()
with suppress(BaseException):
await asyncio.shield(owner)
if isinstance(exc, asyncio.CancelledError) and not task_is_cancelling():
logger.warning("MCP server '{}': connection cancelled by server/SDK", name)
return name, None
raise
if not connected:
await connection.aclose()
-13
View File
@@ -200,19 +200,6 @@ class ToolRegistry:
except Exception as e:
return ToolResult.error(f"Error executing {name}: {str(e)}" + hint)
async def close(self) -> None:
"""Close every registered tool, attempting all cleanups."""
errors: list[BaseException] = []
for tool in self._tools.values():
try:
await tool.close()
except BaseException as exc:
errors.append(exc)
if len(errors) == 1:
raise errors[0]
if errors:
raise BaseExceptionGroup("failed to close tools", errors)
@property
def tool_names(self) -> list[str]:
"""Get list of registered tool names."""
+319
View File
@@ -0,0 +1,319 @@
"""Explicit runtime state boundary used by :class:`MyTool`."""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Protocol, TypeAlias, runtime_checkable
if TYPE_CHECKING:
from nanobot.agent.subagent import SubagentManager, SubagentStatus
from nanobot.agent.tools.shell import ExecToolConfig
from nanobot.agent.tools.web import WebToolsConfig
from nanobot.config.schema import ModelPresetConfig
from nanobot.utils.llm_runtime import LLMRuntime
JsonScalar: TypeAlias = str | int | float | bool | None
JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
RUNTIME_SNAPSHOT_KEYS = frozenset({
"model",
"model_preset",
"model_presets",
"max_iterations",
"context_window_tokens",
"workspace",
"provider_retry_mode",
"max_tool_result_chars",
"current_iteration",
"_current_iteration",
"tool_names",
"web_config",
"exec_config",
"subagents",
"_last_usage",
})
RUNTIME_COMMAND_KEYS = frozenset({
"model",
"model_preset",
"max_iterations",
"context_window_tokens",
"provider_retry_mode",
"max_tool_result_chars",
"workspace",
})
@dataclass(frozen=True, slots=True)
class RuntimeSnapshot:
"""Detached, allowlisted values available to self-inspection."""
model: str
model_preset: str | None
model_presets: dict[str, dict[str, object]]
max_iterations: int
context_window_tokens: int
workspace: Path | str
provider_retry_mode: str
max_tool_result_chars: int
current_iteration: int
tool_names: list[str]
web_config: dict[str, object]
exec_config: dict[str, object]
subagent_statuses: dict[str, dict[str, object]]
last_usage: dict[str, int]
scratchpad: dict[str, JsonValue]
def as_mapping(self) -> Mapping[str, object]:
"""Return the fixed public names understood by ``MyTool``."""
values: dict[str, object] = {
"model": self.model,
"model_preset": self.model_preset,
"model_presets": self.model_presets,
"max_iterations": self.max_iterations,
"context_window_tokens": self.context_window_tokens,
"workspace": self.workspace,
"provider_retry_mode": self.provider_retry_mode,
"max_tool_result_chars": self.max_tool_result_chars,
"current_iteration": self.current_iteration,
"_current_iteration": self.current_iteration,
"tool_names": self.tool_names,
"web_config": self.web_config,
"exec_config": self.exec_config,
"subagents": {"_task_statuses": self.subagent_statuses},
"_last_usage": self.last_usage,
}
assert values.keys() == RUNTIME_SNAPSHOT_KEYS
return values
@runtime_checkable
class RuntimeControl(Protocol):
"""The complete runtime capability exposed to ``MyTool``."""
def snapshot(self) -> RuntimeSnapshot: ...
def set_model(self, model: str) -> LLMRuntime: ...
def set_model_preset(
self,
name: str,
*,
session_key: str | None,
) -> LLMRuntime: ...
def set_max_iterations(self, value: int) -> None: ...
def set_context_window_tokens(self, value: int) -> LLMRuntime: ...
def set_provider_retry_mode(self, value: str) -> None: ...
def set_max_tool_result_chars(self, value: int) -> None: ...
def set_workspace_display(self, value: str) -> None: ...
def set_scratchpad(self, key: str, value: JsonValue, *, max_keys: int) -> None: ...
class _RuntimeControlTarget(Protocol):
"""Narrow structural dependency required by ``AgentRuntimeControl``."""
max_iterations: int
provider_retry_mode: str
max_tool_result_chars: int
web_config: WebToolsConfig
exec_config: ExecToolConfig
subagents: SubagentManager
@property
def model(self) -> str: ...
@property
def model_preset(self) -> str | None: ...
@property
def model_presets(self) -> Mapping[str, ModelPresetConfig]: ...
@property
def context_window_tokens(self) -> int: ...
@property
def workspace(self) -> Path: ...
@property
def current_iteration(self) -> int: ...
@property
def tool_names(self) -> list[str]: ...
@property
def last_usage(self) -> Mapping[str, int]: ...
def set_runtime_model(self, model: str) -> LLMRuntime: ...
def set_runtime_context_window(self, context_window_tokens: int) -> LLMRuntime: ...
def set_model_preset(self, name: str | None) -> LLMRuntime: ...
def set_session_model_preset(self, session_key: str, name: str) -> LLMRuntime: ...
class AgentRuntimeControl:
"""Allowlisted adapter from agent-loop state to ``RuntimeControl``."""
def __init__(self, target: _RuntimeControlTarget) -> None:
self.__target = target
self.__scratchpad: dict[str, JsonValue] = {}
self.__workspace_display: str | None = None
def snapshot(self) -> RuntimeSnapshot:
target = self.__target
return RuntimeSnapshot(
model=target.model,
model_preset=target.model_preset,
model_presets=_snapshot_model_presets(target.model_presets),
max_iterations=target.max_iterations,
context_window_tokens=target.context_window_tokens,
workspace=(
self.__workspace_display
if self.__workspace_display is not None
else target.workspace
),
provider_retry_mode=target.provider_retry_mode,
max_tool_result_chars=target.max_tool_result_chars,
current_iteration=target.current_iteration,
tool_names=list(target.tool_names),
web_config=_snapshot_web_config(target.web_config),
exec_config=_snapshot_exec_config(target.exec_config),
subagent_statuses=_snapshot_subagent_statuses(target.subagents),
last_usage=dict(target.last_usage),
scratchpad=_snapshot_json_mapping(self.__scratchpad),
)
def set_model(self, model: str) -> LLMRuntime:
return self.__target.set_runtime_model(model)
def set_model_preset(
self,
name: str,
*,
session_key: str | None,
) -> LLMRuntime:
if session_key is not None:
return self.__target.set_session_model_preset(session_key, name)
return self.__target.set_model_preset(name)
def set_max_iterations(self, value: int) -> None:
self.__target.max_iterations = value
self.__target.subagents.max_iterations = value
def set_context_window_tokens(self, value: int) -> LLMRuntime:
return self.__target.set_runtime_context_window(value)
def set_provider_retry_mode(self, value: str) -> None:
self.__target.provider_retry_mode = value
def set_max_tool_result_chars(self, value: int) -> None:
self.__target.max_tool_result_chars = value
def set_workspace_display(self, value: str) -> None:
"""Preserve MyTool display compatibility without changing path enforcement."""
self.__workspace_display = value
def set_scratchpad(self, key: str, value: JsonValue, *, max_keys: int) -> None:
if key not in self.__scratchpad and len(self.__scratchpad) >= max_keys:
raise ValueError(f"scratchpad is full (max {max_keys} keys)")
self.__scratchpad[key] = value
def _snapshot_model_presets(
presets: Mapping[str, ModelPresetConfig],
) -> dict[str, dict[str, object]]:
return {
name: {
"label": preset.label,
"model": preset.model,
"provider": preset.provider,
"max_tokens": preset.max_tokens,
"context_window_tokens": preset.context_window_tokens,
"temperature": preset.temperature,
"reasoning_effort": preset.reasoning_effort,
}
for name, preset in presets.items()
}
def _snapshot_web_config(config: WebToolsConfig) -> dict[str, object]:
return {
"enable": config.enable,
# Proxy URLs may embed credentials. Presence is enough for diagnosis.
"proxy": "<configured>" if config.proxy else config.proxy,
"user_agent": config.user_agent,
"search": {
"provider": config.search.provider,
"base_url": config.search.base_url,
"max_results": config.search.max_results,
"timeout": config.search.timeout,
},
"fetch": {
"use_jina_reader": config.fetch.use_jina_reader,
},
}
def _snapshot_exec_config(config: ExecToolConfig) -> dict[str, object]:
return {
"enable": config.enable,
"timeout": config.timeout,
"path_prepend": config.path_prepend,
"path_append": config.path_append,
"sandbox": config.sandbox,
"sandbox_ro_binds": list(config.sandbox_ro_binds),
"sandbox_rw_binds": list(config.sandbox_rw_binds),
"allowed_env_keys": list(config.allowed_env_keys),
"allow_patterns": list(config.allow_patterns),
"deny_patterns": list(config.deny_patterns),
}
def _snapshot_subagent_statuses(
manager: SubagentManager,
) -> dict[str, dict[str, object]]:
return {
task_id: _snapshot_subagent_status(status)
for task_id, status in manager.runtime_statuses().items()
}
def _snapshot_subagent_status(status: SubagentStatus) -> dict[str, object]:
return {
"task_id": status.task_id,
"label": status.label,
"task_description": status.task_description,
"started_at": status.started_at,
"phase": status.phase,
"iteration": status.iteration,
"tool_events": [dict(event) for event in status.tool_events],
"usage": dict(status.usage),
"stop_reason": status.stop_reason,
"error": status.error,
}
def _snapshot_json_mapping(values: Mapping[str, JsonValue]) -> dict[str, JsonValue]:
return {key: _snapshot_json_value(value) for key, value in values.items()}
def _snapshot_json_value(value: JsonValue) -> JsonValue:
if isinstance(value, list):
return [_snapshot_json_value(item) for item in value]
if isinstance(value, dict):
return {
key: _snapshot_json_value(item)
for key, item in value.items()
}
return value
-76
View File
@@ -1,76 +0,0 @@
"""RuntimeState protocol: agent loop state exposed to MyTool."""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol
if TYPE_CHECKING:
from nanobot.agent.subagent import SubagentManager
from nanobot.agent.tools.shell import ExecToolConfig
from nanobot.agent.tools.web import WebToolsConfig
from nanobot.utils.llm_runtime import LLMRuntime
class RuntimeState(Protocol):
"""Minimum contract that MyTool requires from its runtime state provider.
In practice, this is always satisfied by ``AgentLoop``. MyTool also
accesses arbitrary attributes dynamically (via ``getattr`` / ``setattr``)
for dot-path inspection and modification; those paths are validated at
runtime rather than by this protocol.
"""
@property
def model(self) -> str: ...
@property
def max_iterations(self) -> int: ...
@property
def current_iteration(self) -> int: ...
@property
def tool_names(self) -> list[str]: ...
@property
def workspace(self) -> Path: ...
@property
def provider_retry_mode(self) -> str: ...
@property
def max_tool_result_chars(self) -> int: ...
@property
def context_window_tokens(self) -> int: ...
@property
def web_config(self) -> WebToolsConfig: ...
@property
def exec_config(self) -> ExecToolConfig: ...
@property
def subagents(self) -> SubagentManager: ...
@property
def _runtime_vars(self) -> dict[str, Any]: ...
@property
def _last_usage(self) -> dict[str, int]: ...
def _sync_subagent_runtime_limits(self) -> None: ...
def set_runtime_model(self, model: str) -> LLMRuntime: ...
def set_runtime_context_window(self, context_window_tokens: int) -> LLMRuntime: ...
def set_session_model_preset(
self,
session_key: str,
name: str,
) -> LLMRuntime: ...
@property
def model_preset(self) -> str | None: ...
+213 -182
View File
@@ -1,8 +1,7 @@
"""MyTool: runtime state inspection and configuration for the agent loop."""
# RuntimeState intentionally exposes a narrow set of AgentLoop internals to
# this manually registered tool. Tool.execute accepts heterogeneous schemas.
# pyright: reportPrivateUsage=false, reportIncompatibleMethodOverride=false
# Tool.execute accepts heterogeneous schemas.
# pyright: reportIncompatibleMethodOverride=false
from __future__ import annotations
@@ -14,7 +13,13 @@ from loguru import logger
from nanobot.agent.tools.base import Tool, ToolResult
from nanobot.agent.tools.context import current_request_context, current_request_session_key
from nanobot.agent.tools.runtime_state import RuntimeState
from nanobot.agent.tools.runtime_control import (
RUNTIME_COMMAND_KEYS,
RUNTIME_SNAPSHOT_KEYS,
JsonValue,
RuntimeControl,
RuntimeSnapshot,
)
from nanobot.config_base import Base
if TYPE_CHECKING:
@@ -28,25 +33,28 @@ class MyToolConfig(Base):
allow_set: bool = False
def _has_real_attr(obj: Any, key: str) -> bool:
"""Check if obj has a real (explicitly set) attribute, not auto-generated by mock."""
if isinstance(obj, dict):
return key in obj
d = getattr(obj, "__dict__", None)
if d is not None and key in d:
return True
for cls in type(obj).__mro__:
if key in cls.__dict__:
return True
return False
def _is_subagent_status(value: object) -> TypeGuard[SubagentStatus]:
from nanobot.agent.subagent import SubagentStatus
return isinstance(value, SubagentStatus)
def _is_subagent_status_snapshot(value: object) -> TypeGuard[Mapping[str, object]]:
if not isinstance(value, Mapping):
return False
return all(
field in value
for field in ("task_id", "label", "task_description", "started_at", "phase")
)
def _is_string_mapping(value: object) -> TypeGuard[Mapping[str, object]]:
if not isinstance(value, Mapping):
return False
mapping = cast(Mapping[object, object], value)
return all(isinstance(key, str) for key in mapping)
class MyTool(Tool):
"""Check and set the agent loop's runtime configuration."""
@@ -79,7 +87,10 @@ class MyTool(Tool):
READ_ONLY = frozenset({
"subagents", # observable but replacing it would break the system
"tool_names",
"current_iteration",
"_current_iteration", # updated by runner only
"_last_usage",
"exec_config", # inspect allowed (e.g. check sandbox), modify blocked
"web_config", # inspect allowed (e.g. check enable), modify blocked
"model_presets", # config-derived catalog; changes require config reload
@@ -103,13 +114,6 @@ class MyTool(Tool):
"private_key", "access_token", "refresh_token", "auth",
})
@classmethod
def _is_sensitive_field_name(cls, name: str) -> bool:
lowered = name.lower()
return lowered in cls._SENSITIVE_NAMES or any(
part in cls._SENSITIVE_NAMES for part in lowered.split("_")
)
RESTRICTED: dict[str, dict[str, Any]] = {
"max_iterations": {"type": int, "min": 1, "max": 100},
"context_window_tokens": {"type": int, "min": 4096, "max": 1_000_000},
@@ -123,15 +127,15 @@ class MyTool(Tool):
"context_window_tokens",
})
def __init__(self, runtime_state: RuntimeState, modify_allowed: bool = True) -> None:
self._runtime_state = runtime_state
def __init__(self, runtime_control: RuntimeControl, modify_allowed: bool = True) -> None:
self._runtime_control = runtime_control
self._modify_allowed = modify_allowed
def __deepcopy__(self, memo: dict[int, Any]) -> MyTool:
cls = self.__class__
result = cls.__new__(cls)
memo[id(self)] = result
result._runtime_state = self._runtime_state
result._runtime_control = self._runtime_control
result._modify_allowed = self._modify_allowed
return result
@@ -208,9 +212,12 @@ class MyTool(Tool):
# Path resolution
# ------------------------------------------------------------------
def _resolve_path(self, path: str) -> tuple[Any, str | None]:
def _resolve_path(
self,
snapshot: RuntimeSnapshot,
path: str,
) -> tuple[object | None, str | None]:
parts = path.split(".")
obj: Any = self._runtime_state
for part in parts:
if part in self._DENIED_ATTRS or part.startswith("__"):
return None, f"'{part}' is not accessible"
@@ -218,17 +225,13 @@ class MyTool(Tool):
return None, f"'{part}' is not accessible"
if part.lower() in self._SENSITIVE_NAMES:
return None, f"'{part}' is not accessible"
try:
if isinstance(obj, Mapping):
mapping = cast(Mapping[str, Any], obj)
if part in mapping:
obj = mapping[part]
else:
return None, f"'{part}' not found in mapping"
else:
obj = getattr(obj, part)
except (KeyError, AttributeError) as e:
return None, f"'{part}' not found: {e}"
obj: object = snapshot.as_mapping()
for part in parts:
if not _is_string_mapping(obj):
return None, f"'{part}' not found"
if part not in obj:
return None, f"'{part}' not found in mapping"
obj = obj[part]
return obj, None
@staticmethod
@@ -242,20 +245,48 @@ class MyTool(Tool):
# ------------------------------------------------------------------
@staticmethod
def _format_status(st: "SubagentStatus", indent: str = " ") -> str:
elapsed = time.monotonic() - st.started_at
tool_summary = ", ".join(
f"{e.get('name', '?')}({e.get('status', '?')})" for e in st.tool_events[-5:]
) or "none"
def _format_status(
st: "SubagentStatus | Mapping[str, object]",
indent: str = " ",
) -> str:
if isinstance(st, Mapping):
started_at = st.get("started_at", time.monotonic())
raw_events = st.get("tool_events", [])
phase = st.get("phase", "unknown")
iteration = st.get("iteration", 0)
usage = st.get("usage", {})
error = st.get("error")
stop_reason = st.get("stop_reason")
else:
started_at = st.started_at
raw_events = st.tool_events
phase = st.phase
iteration = st.iteration
usage = st.usage
error = st.error
stop_reason = st.stop_reason
elapsed = time.monotonic() - (
float(started_at) if isinstance(started_at, (int, float)) else time.monotonic()
)
tool_events = cast(list[object], raw_events) if isinstance(raw_events, list) else []
tool_summaries: list[str] = []
for raw_event in tool_events[-5:]:
if not isinstance(raw_event, Mapping):
continue
event = cast(Mapping[str, object], raw_event)
tool_summaries.append(
f"{event.get('name', '?')}({event.get('status', '?')})"
)
tool_summary = ", ".join(tool_summaries) or "none"
lines = [
f"{indent}phase: {st.phase}, iteration: {st.iteration}, elapsed: {elapsed:.1f}s",
f"{indent}phase: {phase}, iteration: {iteration}, elapsed: {elapsed:.1f}s",
f"{indent}tools: {tool_summary}",
f"{indent}usage: {st.usage or 'n/a'}",
f"{indent}usage: {usage or 'n/a'}",
]
if st.error:
lines.append(f"{indent}error: {st.error}")
if st.stop_reason:
lines.append(f"{indent}stop_reason: {st.stop_reason}")
if error:
lines.append(f"{indent}error: {error}")
if stop_reason:
lines.append(f"{indent}stop_reason: {stop_reason}")
return "\n".join(lines)
@staticmethod
@@ -264,29 +295,38 @@ class MyTool(Tool):
header = f"Subagent [{val.task_id}] '{val.label}'"
detail = MyTool._format_status(val, " ")
return f"{header}\n task: {val.task_description}\n{detail}"
# SubagentManager: delegate to its _task_statuses dict
task_statuses = getattr(val, "_task_statuses", None)
if isinstance(task_statuses, dict):
return MyTool._format_value(task_statuses, key)
if _is_subagent_status_snapshot(val):
header = f"Subagent [{val['task_id']}] '{val['label']}'"
detail = MyTool._format_status(val, " ")
return f"{header}\n task: {val['task_description']}\n{detail}"
if isinstance(val, Mapping):
mapping = cast(Mapping[object, object], val)
else:
mapping = None
if mapping and set(mapping) == {"_task_statuses"}:
task_statuses = mapping["_task_statuses"]
if isinstance(task_statuses, Mapping):
return MyTool._format_value(task_statuses, key)
if (
mapping
and _is_subagent_status(next(iter(mapping.values())))
and (
_is_subagent_status(next(iter(mapping.values())))
or _is_subagent_status_snapshot(next(iter(mapping.values())))
)
):
status_mapping: Mapping[object, SubagentStatus] = cast(Any, mapping)
prefix = f"{key}: " if key else ""
lines = [f"{prefix}{len(status_mapping)} subagent(s):"]
for tid, st in status_mapping.items():
detail = MyTool._format_status(st, " ")
lines.append(f" [{tid}] '{st.label}'\n{detail}")
lines = [f"{prefix}{len(mapping)} subagent(s):"]
for tid, st in mapping.items():
if _is_subagent_status(st):
detail = MyTool._format_status(st, " ")
label = st.label
elif _is_subagent_status_snapshot(st):
detail = MyTool._format_status(st, " ")
label = st.get("label", "?")
else:
continue
lines.append(f" [{tid}] '{label}'\n{detail}")
return "\n".join(lines)
dynamic_value = cast(Any, val)
if hasattr(dynamic_value, "tool_names"):
tool_names: Any = getattr(dynamic_value, "tool_names")
return f"tools: {len(tool_names)} registered — {tool_names}"
# Scalar types — repr is fine
if isinstance(val, (str, int, float, bool, type(None))):
r = repr(val)
@@ -311,32 +351,6 @@ class MyTool(Tool):
return f"{key}: [{len(sequence)} items]" if key else f"[{len(sequence)} items]"
r = repr(sequence)
return f"{key}: {r}" if key else r
# Complex object — small Pydantic models: show values; others: show field names for navigation
value_type = type(cast(object, val))
cls_name = value_type.__name__
model_fields = cast(object, getattr(value_type, "model_fields", None))
if isinstance(model_fields, Mapping) and model_fields:
fields = list(cast(Mapping[str, object], model_fields).keys())
if len(fields) <= 8:
# Small config objects: show field=value pairs
pairs: list[str] = []
for f in fields:
fv = getattr(val, f, "?")
if MyTool._is_sensitive_field_name(f):
continue
if isinstance(fv, (str, int, float, bool, type(None))):
pairs.append(f"{f}={fv!r}")
else:
pairs.append(f"{f}=<{type(fv).__name__}>")
preview = ", ".join(pairs)
return f"{key}: {preview}" if key else preview
else:
attributes = cast(dict[str, Any], getattr(val, "__dict__", {}))
fields = [name for name in attributes if not name.startswith("__")]
if fields:
preview = ", ".join(str(f) for f in fields[:20])
suffix = ", ..." if len(fields) > 20 else ""
return f"{key}: <{cls_name}> [{preview}{suffix}]" if key else f"<{cls_name}> [{preview}{suffix}]"
r = repr(val)
return f"{key}: {r}" if key else r
@@ -366,7 +380,12 @@ class MyTool(Tool):
runtime = request_ctx.runtime if request_ctx is not None else None
if runtime is None or key not in self._MODEL_RUNTIME_FIELDS:
return False, None
return True, getattr(runtime, key)
values: dict[str, object] = {
"model": runtime.model,
"model_preset": runtime.model_preset,
"context_window_tokens": runtime.context_window_tokens,
}
return True, values[key]
def _inspect(self, key: str | None) -> str:
if not key:
@@ -375,62 +394,64 @@ class MyTool(Tool):
request_ctx = current_request_context()
if request_ctx is None:
return ToolResult.error("Error: current request context is unavailable")
request_values: dict[str, str | None] = {
"channel": request_ctx.channel,
"chat_id": request_ctx.chat_id,
"sender_id": request_ctx.sender_id,
}
if key == "request":
return self._format_value(
{field: getattr(request_ctx, field) for field in self._REQUEST_FIELDS},
key,
)
return self._format_value(request_values, key)
field = key.removeprefix("request.")
if field not in self._REQUEST_FIELDS:
return ToolResult.error(f"Error: '{key}' not found")
return self._format_value(getattr(request_ctx, field), key)
return self._format_value(request_values[field], key)
if "." not in key:
found, value = self._current_runtime_value(key)
if found:
return self._format_value(value, key)
snapshot = self._runtime_control.snapshot()
top = key.split(".")[0]
if top in self._DENIED_ATTRS or top.startswith("__"):
return ToolResult.error(f"Error: '{top}' is not accessible")
obj, err = self._resolve_path(key)
obj, err = self._resolve_path(snapshot, key)
if err:
# "scratchpad" alias for _runtime_vars
if key == "scratchpad":
rv = self._runtime_state._runtime_vars
return self._format_value(rv, "scratchpad") if rv else "scratchpad is empty"
# Fallback: check _runtime_vars for simple keys stored by modify
if "." not in key and key in self._runtime_state._runtime_vars:
return self._format_value(self._runtime_state._runtime_vars[key], key)
return (
self._format_value(snapshot.scratchpad, "scratchpad")
if snapshot.scratchpad
else "scratchpad is empty"
)
if "." not in key and key in snapshot.scratchpad:
return self._format_value(snapshot.scratchpad[key], key)
return ToolResult.error(f"Error: {err}")
# Guard against mock auto-generated attributes
if "." not in key and not _has_real_attr(self._runtime_state, key):
if key in self._runtime_state._runtime_vars:
return self._format_value(self._runtime_state._runtime_vars[key], key)
return ToolResult.error(f"Error: '{key}' not found")
return self._format_value(obj, key)
def _inspect_all(self) -> str:
state = self._runtime_state
snapshot = self._runtime_control.snapshot()
values = snapshot.as_mapping()
parts: list[str] = []
# RESTRICTED keys
for k in self.RESTRICTED:
found, value = self._current_runtime_value(k)
parts.append(self._format_value(value if found else getattr(state, k, None), k))
parts.append(self._format_value(value if found else values[k], k))
found, value = self._current_runtime_value("model_preset")
parts.append(self._format_value(
value if found else state.model_preset,
value if found else snapshot.model_preset,
"model_preset",
))
# Other useful top-level keys shown in description
for k in ("workspace", "provider_retry_mode", "max_tool_result_chars", "_current_iteration", "web_config", "exec_config", "workspace_sandbox", "subagents"):
if _has_real_attr(state, k):
parts.append(self._format_value(getattr(state, k, None), k))
# Token usage
usage = state._last_usage
if usage:
parts.append(self._format_value(usage, "_last_usage"))
rv = state._runtime_vars
if rv:
parts.append(self._format_value(rv, "scratchpad"))
for k in (
"workspace",
"provider_retry_mode",
"max_tool_result_chars",
"_current_iteration",
"web_config",
"exec_config",
"subagents",
):
parts.append(self._format_value(values[k], k))
if snapshot.last_usage:
parts.append(self._format_value(snapshot.last_usage, "_last_usage"))
if snapshot.scratchpad:
parts.append(self._format_value(snapshot.scratchpad, "scratchpad"))
return "\n".join(parts)
# -- modify --
@@ -454,48 +475,49 @@ class MyTool(Tool):
if leaf.lower() in self._SENSITIVE_NAMES:
self._audit("modify", f"BLOCKED sensitive leaf '{leaf}'")
return ToolResult.error(f"Error: '{leaf}' is not accessible")
parent, err = self._resolve_path(parent_path)
snapshot = self._runtime_control.snapshot()
_parent, err = self._resolve_path(snapshot, parent_path)
if err:
return ToolResult.error(f"Error: {err}")
if isinstance(parent, dict):
parent[leaf] = value
else:
setattr(parent, leaf, value)
self._audit("modify", f"{key} = {value!r}")
return f"Set {key} = {value!r}"
self._audit("modify", f"READ_ONLY {key}")
return ToolResult.error(f"Error: '{key}' is read-only and cannot be modified")
if key == "model_preset":
return self._modify_model_preset(value)
if key in self.RESTRICTED:
return self._modify_restricted(key, value)
return self._modify_free(key, value)
if key in RUNTIME_COMMAND_KEYS:
return self._modify_runtime_setting(key, value)
if key in RUNTIME_SNAPSHOT_KEYS:
self._audit("modify", f"READ_ONLY {key}")
return ToolResult.error(f"Error: '{key}' is read-only and cannot be modified")
return self._modify_scratchpad(key, value)
def _modify_model_preset(self, value: Any) -> str:
if not isinstance(value, str) or not value.strip():
return ToolResult.error("Error: 'model_preset' must be a non-empty string")
name = value.strip()
session_key = current_request_session_key()
old = self._runtime_control.snapshot().model_preset
try:
runtime = self._runtime_control.set_model_preset(
name,
session_key=session_key,
)
except (KeyError, ValueError) as exc:
message = str(exc.args[0]) if exc.args else str(exc)
punctuation = "" if message.endswith((".", "!", "?")) else "."
return ToolResult.error(f"Error: {message}{punctuation}")
if session_key:
try:
runtime = self._runtime_state.set_session_model_preset(
session_key,
name,
)
except (KeyError, ValueError) as exc:
message = str(exc.args[0]) if exc.args else str(exc)
punctuation = "" if message.endswith((".", "!", "?")) else "."
return ToolResult.error(f"Error: {message}{punctuation}")
self._audit("modify", f"model_preset = {name!r}")
return (
f"Set model_preset = {name!r} for the next turn; "
f"model will be {runtime.model!r}; "
f"context_window_tokens will be {runtime.context_window_tokens!r}"
)
result = self._modify_free("model_preset", name)
if isinstance(result, ToolResult) and result.is_error:
return result if result.endswith((".", "!", "?")) else ToolResult.error(f"{result}.")
self._audit("modify", f"model_preset: {old!r} -> {name!r}")
return (
f"{result}; model is now {self._runtime_state.model!r}; "
f"context_window_tokens is now {self._runtime_state.context_window_tokens!r}"
f"Set model_preset = {name!r} (was {old!r}); model is now {runtime.model!r}; "
f"context_window_tokens is now {runtime.context_window_tokens!r}"
)
def _modify_restricted(self, key: str, value: Any) -> str:
@@ -508,7 +530,7 @@ class MyTool(Tool):
value = expected(value)
except (ValueError, TypeError):
return ToolResult.error(f"Error: '{key}' must be {expected.__name__}, got {type(value).__name__}")
old = getattr(self._runtime_state, key)
old = self._runtime_control.snapshot().as_mapping()[key]
if "min" in spec and value < spec["min"]:
return ToolResult.error(f"Error: '{key}' must be >= {spec['min']}")
if "max" in spec and value > spec["max"]:
@@ -521,41 +543,46 @@ class MyTool(Tool):
"during an active session; use a configured model_preset"
)
if key == "model":
self._runtime_state.set_runtime_model(cast(str, value))
self._runtime_control.set_model(cast(str, value))
elif key == "context_window_tokens":
self._runtime_state.set_runtime_context_window(cast(int, value))
self._runtime_control.set_context_window_tokens(cast(int, value))
else:
setattr(self._runtime_state, key, value)
if key == "max_iterations" and hasattr(
self._runtime_state,
"_sync_subagent_runtime_limits",
):
self._runtime_state._sync_subagent_runtime_limits()
self._runtime_control.set_max_iterations(cast(int, value))
self._audit("modify", f"{key}: {old!r} -> {value!r}")
return f"Set {key} = {value!r} (was {old!r})"
def _modify_free(self, key: str, value: Any) -> str:
if _has_real_attr(self._runtime_state, key):
old = getattr(self._runtime_state, key)
if isinstance(old, (str, int, float, bool)):
old_t: type[Any] = type(old)
new_t = cast(type[Any], type(value))
if old_t is float and new_t is int:
pass # int → float coercion allowed
elif old_t is not new_t:
self._audit(
"modify",
f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}",
)
return ToolResult.error(f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}")
try:
setattr(self._runtime_state, key, value)
except (ValueError, KeyError) as e:
message = str(e.args[0] if isinstance(e, KeyError) and e.args else e).strip('"')
self._audit("modify", f"REJECTED {key}: {message}")
return ToolResult.error(f"Error: {message}")
self._audit("modify", f"{key}: {old!r} -> {value!r}")
return f"Set {key} = {value!r} (was {old!r})"
def _modify_runtime_setting(self, key: str, value: Any) -> str:
old = self._runtime_control.snapshot().as_mapping()[key]
if key == "workspace":
if not isinstance(value, str):
return ToolResult.error(
f"Error: 'workspace' expects str, got {type(value).__name__}"
)
self._runtime_control.set_workspace_display(value)
self._audit("modify", f"workspace: {old!r} -> {value!r}")
return f"Set workspace = {value!r} (was {old!r})"
old_t = type(old)
new_t = cast(type[Any], type(value))
if old_t is float and new_t is int:
pass
elif old_t is not new_t:
self._audit(
"modify",
f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}",
)
return ToolResult.error(
f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}"
)
if key == "provider_retry_mode":
self._runtime_control.set_provider_retry_mode(cast(str, value))
elif key == "max_tool_result_chars":
self._runtime_control.set_max_tool_result_chars(cast(int, value))
else:
raise AssertionError(f"Unhandled runtime command: {key}")
self._audit("modify", f"{key}: {old!r} -> {value!r}")
return f"Set {key} = {value!r} (was {old!r})"
def _modify_scratchpad(self, key: str, value: Any) -> str:
if callable(value):
self._audit("modify", f"REJECTED callable {key}")
return ToolResult.error("Error: cannot store callable values")
@@ -563,12 +590,16 @@ class MyTool(Tool):
if err:
self._audit("modify", f"REJECTED {key}: {err}")
return ToolResult.error(f"Error: {err}")
if key not in self._runtime_state._runtime_vars and len(self._runtime_state._runtime_vars) >= self._MAX_RUNTIME_KEYS:
try:
self._runtime_control.set_scratchpad(
key,
cast(JsonValue, value),
max_keys=self._MAX_RUNTIME_KEYS,
)
except ValueError as exc:
self._audit("modify", f"REJECTED {key}: max keys ({self._MAX_RUNTIME_KEYS}) reached")
return ToolResult.error(f"Error: scratchpad is full (max {self._MAX_RUNTIME_KEYS} keys). Remove unused keys first.")
old = self._runtime_state._runtime_vars.get(key)
self._runtime_state._runtime_vars[key] = value
self._audit("modify", f"scratchpad.{key}: {old!r} -> {value!r}")
return ToolResult.error(f"Error: {exc}. Remove unused keys first.")
self._audit("modify", f"scratchpad.{key} = {value!r}")
return f"Set scratchpad.{key} = {value!r}"
@classmethod
+2 -2
View File
@@ -453,9 +453,9 @@ class WebSearchTool(Tool):
async def _search_olostep(self, query: str, n: int) -> str:
try:
from olostep import ( # pyright: ignore[reportMissingImports]
from olostep import ( # pyright: ignore[reportMissingImports, reportMissingTypeStubs]
AsyncOlostep, # pyright: ignore[reportUnknownVariableType]
Olostep_BaseError, # pyright: ignore[reportUnknownVariableType]
Olostep_BaseError, # pyright: ignore[reportAttributeAccessIssue, reportUnknownVariableType]
)
except ImportError:
return ToolResult.error(
@@ -238,20 +238,6 @@ class TestStreamEndReactionCleanup:
ch._remove_reaction.assert_not_called()
@pytest.mark.asyncio
async def test_no_removal_when_both_ids_missing(self):
ch = _make_channel()
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Done", card_id="card_1", sequence=3, last_edit=0.0,
)
ch._client.cardkit.v1.card_element.content.return_value = MagicMock(success=MagicMock(return_value=True))
ch._client.cardkit.v1.card.settings.return_value = MagicMock(success=MagicMock(return_value=True))
ch._remove_reaction = AsyncMock()
await ch.send_delta("oc_chat1", "", stream_end=True)
ch._remove_reaction.assert_not_called()
@pytest.mark.asyncio
async def test_no_removal_when_not_stream_end(self):
ch = _make_channel()
@@ -15,6 +15,7 @@ import type {
NanobotFeatureInfo,
NanobotFeaturesPayload,
} from "@/lib/types";
import { useClient } from "@/providers/ClientProvider";
import { FeishuConnectFlow } from "./FeishuConnectFlow";
@@ -33,7 +34,6 @@ export function FeishuAssistantsPanel({
return (
<ChannelInstancesPanel
token={token}
feature={feature}
showBrandLogos={showBrandLogos}
chatAppsDocsUrl={chatAppsDocsUrl}
@@ -92,6 +92,7 @@ function FeishuInstanceAction({
instance: NanobotChannelInstanceInfo;
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
}) {
const { client } = useClient();
const { t } = useTranslation();
const tx = channelTranslator(t, "feishu");
const [busy, setBusy] = useState(false);
@@ -114,7 +115,7 @@ function FeishuInstanceAction({
setError(null);
try {
onFeaturesUpdate(
await enableNanobotFeature(token, "feishu", { instanceId: instance.id }),
await enableNanobotFeature(client, "feishu", { instanceId: instance.id }),
);
} catch (err) {
setError((err as Error).message);
+7
View File
@@ -101,8 +101,14 @@ class ChannelManager:
webui_runtime_surface: str = "browser",
webui_runtime_capabilities: dict[str, Any] | None = None,
webui_skill_state_action: Callable[[set[str]], None] | None = None,
config_path: Path | None = None,
):
if config_path is None:
from nanobot.config.loader import get_config_path
config_path = get_config_path()
self.config = config
self._config_path = config_path.expanduser().resolve(strict=False)
self.bus = bus
self._session_manager = session_manager
self._cron_service = cron_service
@@ -170,6 +176,7 @@ class ChannelManager:
static_dist_path=static_path,
workspace_path=workspace,
default_restrict_to_workspace=self.config.tools.restrict_to_workspace,
config_path=self._config_path,
disabled_skills=set(self.config.agents.defaults.disabled_skills),
runtime_model_name=self._webui_runtime_model_name,
runtime_surface=self._webui_runtime_surface,
+162
View File
@@ -373,6 +373,13 @@ class WebSocketChannel(BaseChannel):
self._conn_default: dict[ServerConnection, str] = {}
# Connections authenticated with a one-time token from /webui/bootstrap.
self._webui_connections: set[ServerConnection] = set()
# Request/reply mutations aren't replayed across reconnects. Tasks may
# finish after a client-side deadline so an already-started mutation
# isn't ambiguously cancelled halfway through.
self._webui_request_tasks: dict[
tuple[ServerConnection, str],
asyncio.Task[None],
] = {}
self._stop_event: asyncio.Event | None = None
self._server_task: asyncio.Task[None] | None = None
@@ -758,6 +765,9 @@ class WebSocketChannel(BaseChannel):
) -> None:
"""Route one typed inbound envelope (``new_chat`` / ``attach`` / ``message``)."""
t = envelope.get("type")
if t == "webui_request":
await self._start_webui_request(connection, envelope)
return
if t == "new_chat":
new_id = str(uuid.uuid4())
scope = await self._workspace_scope_or_error(
@@ -1105,6 +1115,152 @@ class WebSocketChannel(BaseChannel):
return
await self._send_event(connection, "error", detail=f"unknown type: {t!r}")
async def _start_webui_request(
self,
connection: ServerConnection,
envelope: dict[str, Any],
) -> None:
request_id = envelope.get("request_id")
if not isinstance(request_id, str) or re.fullmatch(
r"[A-Za-z0-9._:-]{1,128}",
request_id,
) is None:
await self._send_event(
connection,
"error",
detail="invalid webui request_id",
)
return
if connection not in self._webui_connections:
await self._send_webui_response(
connection,
request_id,
status=403,
message="access_denied",
)
return
action = envelope.get("action")
payload = envelope.get("payload")
if not isinstance(action, str) or re.fullmatch(
r"[a-z][a-z0-9_.]{0,127}",
action,
) is None:
await self._send_webui_response(
connection,
request_id,
status=400,
message="invalid WebUI mutation action",
)
return
if not isinstance(payload, dict):
await self._send_webui_response(
connection,
request_id,
status=400,
message="WebUI mutation payload must be an object",
)
return
key = (connection, request_id)
if key in self._webui_request_tasks:
await self._send_webui_response(
connection,
request_id,
status=409,
message="duplicate WebUI request_id",
)
return
task = asyncio.create_task(
self._complete_webui_request(
connection,
request_id,
action,
cast(dict[str, Any], payload),
)
)
self._webui_request_tasks[key] = task
async def _complete_webui_request(
self,
connection: ServerConnection,
request_id: str,
action: str,
payload: dict[str, Any],
) -> None:
try:
response = await self._http_router.dispatch_webui_mutation(
connection,
action,
payload,
)
status = response.status_code
body = bytes(response.body).decode("utf-8", errors="replace").strip()
if 200 <= status < 300:
try:
result = json.loads(body)
except json.JSONDecodeError:
await self._send_webui_response(
connection,
request_id,
status=502,
message="WebUI mutation returned an invalid response",
)
return
await self._send_webui_response(
connection,
request_id,
result=result,
)
return
await self._send_webui_response(
connection,
request_id,
status=status,
message=body or response.reason_phrase,
)
except asyncio.CancelledError:
raise
except Exception:
self.logger.exception("WebUI mutation '{}' failed", action)
await self._send_webui_response(
connection,
request_id,
status=500,
message="WebUI mutation failed",
)
finally:
self._webui_request_tasks.pop((connection, request_id), None)
async def _send_webui_response(
self,
connection: ServerConnection,
request_id: str,
*,
result: Any = None,
status: int | None = None,
message: str | None = None,
) -> None:
if status is None:
await self._send_event(
connection,
"webui_response",
request_id=request_id,
ok=True,
result=result,
)
return
await self._send_event(
connection,
"webui_response",
request_id=request_id,
ok=False,
error={
"status": status,
"message": message or "WebUI mutation failed",
},
)
async def _workspace_scope_or_error(
self,
connection: ServerConnection,
@@ -1145,6 +1301,12 @@ class WebSocketChannel(BaseChannel):
except Exception as e:
self.logger.warning("server task error during shutdown: {}", e)
self._server_task = None
mutation_tasks = tuple(self._webui_request_tasks.values())
for task in mutation_tasks:
task.cancel()
if mutation_tasks:
await asyncio.gather(*mutation_tasks, return_exceptions=True)
self._webui_request_tasks.clear()
self._subs.clear()
self._conn_chats.clear()
self._conn_default.clear()
@@ -3,12 +3,16 @@
import asyncio
import json
import time
import uuid
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
import websockets
from websockets.datastructures import Headers
from websockets.exceptions import ConnectionClosed
from websockets.frames import Close
@@ -42,6 +46,12 @@ from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
from nanobot.session import webui_turns as wth
from nanobot.session.manager import SessionManager
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
from nanobot.webui.http_utils import (
http_error as _http_error,
)
from nanobot.webui.http_utils import (
http_json_response as _http_json_response,
)
from nanobot.webui.http_utils import (
issue_route_secret_matches as _issue_route_secret_matches,
)
@@ -119,6 +129,38 @@ def _basic_handler(bus: Any, **kw: Any) -> GatewayServices:
)
async def _webui_mutate(
client: Any,
action: str,
payload: dict[str, Any] | None = None,
) -> httpx.Response:
request_id = f"test-{uuid.uuid4().hex}"
await client.send(json.dumps({
"type": "webui_request",
"request_id": request_id,
"action": action,
"payload": payload or {},
}))
while True:
envelope = json.loads(await asyncio.wait_for(client.recv(), timeout=5))
if envelope.get("event") != "webui_response":
continue
if envelope.get("request_id") != request_id:
continue
if envelope.get("ok") is True:
status = 200
body = envelope.get("result")
else:
error = envelope.get("error") or {}
status = int(error.get("status") or 500)
body = {"error": str(error.get("message") or "WebUI mutation failed")}
return httpx.Response(
status,
json=body,
request=httpx.Request("WS", "http://nanobot.local/webui-mutation"),
)
@pytest.mark.asyncio
async def test_stop_treats_cancelled_server_task_as_shutdown() -> None:
channel = _ch(MessageBus())
@@ -857,6 +899,98 @@ def test_only_bootstrap_tokens_mark_webui_connections(bus: MagicMock) -> None:
assert client_connection not in channel._webui_connections
@pytest.mark.asyncio
async def test_authenticated_webui_request_returns_correlated_success(bus: MagicMock) -> None:
channel = _ch(bus)
conn = AsyncMock()
channel._webui_connections.add(conn)
channel.gateway.http.dispatch_webui_mutation = AsyncMock(
return_value=_http_json_response({"saved": True})
)
await channel._dispatch_envelope(
conn,
"webui-client",
{
"type": "webui_request",
"request_id": "request-1",
"action": "settings.provider.update",
"payload": {"provider": "openrouter", "apiKey": "secret"},
},
)
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
channel.gateway.http.dispatch_webui_mutation.assert_awaited_once_with(
conn,
"settings.provider.update",
{"provider": "openrouter", "apiKey": "secret"},
)
assert json.loads(conn.send.await_args.args[0]) == {
"event": "webui_response",
"request_id": "request-1",
"ok": True,
"result": {"saved": True},
}
@pytest.mark.asyncio
async def test_webui_request_returns_correlated_route_error(bus: MagicMock) -> None:
channel = _ch(bus)
conn = AsyncMock()
channel._webui_connections.add(conn)
channel.gateway.http.dispatch_webui_mutation = AsyncMock(
return_value=_http_error(400, "invalid settings payload")
)
await channel._dispatch_envelope(
conn,
"webui-client",
{
"type": "webui_request",
"request_id": "request-2",
"action": "settings.agent.update",
"payload": {},
},
)
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
assert json.loads(conn.send.await_args.args[0]) == {
"event": "webui_response",
"request_id": "request-2",
"ok": False,
"error": {"status": 400, "message": "invalid settings payload"},
}
@pytest.mark.asyncio
async def test_webui_request_requires_bootstrap_authenticated_connection(
bus: MagicMock,
) -> None:
channel = _ch(bus)
conn = AsyncMock()
channel.gateway.http.dispatch_webui_mutation = AsyncMock()
await channel._dispatch_envelope(
conn,
"static-token-client",
{
"type": "webui_request",
"request_id": "request-3",
"action": "settings.agent.update",
"payload": {},
},
)
channel.gateway.http.dispatch_webui_mutation.assert_not_awaited()
assert json.loads(conn.send.await_args.args[0]) == {
"event": "webui_response",
"request_id": "request-3",
"ok": False,
"error": {"status": 403, "message": "access_denied"},
}
@pytest.mark.asyncio
async def test_webui_persists_sidebar_state_larger_than_http_request_line(
bus: MagicMock,
@@ -866,23 +1000,33 @@ async def test_webui_persists_sidebar_state_larger_than_http_request_line(
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
channel = _ch(bus)
conn = AsyncMock()
conn.request = SimpleNamespace(headers=Headers())
channel._webui_connections.add(conn)
session_order = [f"websocket:{index:04d}-{'x' * 48}" for index in range(160)]
request_id = "sidebar-large-state"
envelope = {
"type": "set_sidebar_state",
"state": {
"type": "webui_request",
"request_id": request_id,
"action": "sidebar.update",
"payload": {"state": {
"session_order": session_order,
"view": {"sort": "manual"},
},
}},
}
assert len(json.dumps(envelope).encode()) > 8_192
await channel._dispatch_envelope(conn, "webui-client", envelope)
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
saved = json.loads((tmp_path / "webui" / "sidebar-state.json").read_text(encoding="utf-8"))
assert saved["session_order"] == session_order
assert saved["view"]["sort"] == "manual"
conn.send.assert_not_awaited()
assert json.loads(conn.send.await_args.args[0]) == {
"event": "webui_response",
"request_id": request_id,
"ok": True,
"result": saved,
}
@pytest.mark.asyncio
@@ -2887,7 +3031,15 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
webui_client = None
try:
webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
webui_client = await websockets.connect(
f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=settings-test"
)
ready = json.loads(await asyncio.wait_for(webui_client.recv(), timeout=5))
assert ready["event"] == "ready"
settings = await _http_get(
f"http://127.0.0.1:{port}/api/settings",
headers={"Authorization": "Bearer tok"},
@@ -2971,11 +3123,14 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert unknown_api.status_code == 404
assert "<!doctype html>" not in unknown_api.text.lower()
provider_updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/provider/update?provider=openrouter"
"&api_key=sk-or-test&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1",
headers={"Authorization": "Bearer tok"},
provider_updated = await _webui_mutate(
webui_client,
"settings.provider.update",
{
"provider": "openrouter",
"apiKey": "sk-or-test",
"apiBase": "https://openrouter.ai/api/v1",
},
)
assert provider_updated.status_code == 200
provider_body = provider_updated.json()
@@ -2985,22 +3140,18 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert provider_body["image_generation"]["provider_configured"] is True
assert "sk-or-test" not in provider_updated.text
custom_provider_created = await _http_get(
f"http://127.0.0.1:{port}/api/settings/provider/create",
headers={
"Authorization": "Bearer tok",
"X-Nanobot-Provider-Values": json.dumps(
{
"name": "Company Gateway",
"apiBase": "https://gateway.example/v1",
"apiKey": "sk-company",
"extraHeaders": json.dumps({"X-Tenant": "engineering"}),
"extraBody": json.dumps({"service_tier": "priority"}),
"extraQuery": json.dumps({"api-version": "2026-01-01"}),
"proxy": "http://127.0.0.1:7890",
"thinkingStyle": "enable_thinking",
}
),
custom_provider_created = await _webui_mutate(
webui_client,
"settings.provider.create",
{
"name": "Company Gateway",
"apiBase": "https://gateway.example/v1",
"apiKey": "sk-company",
"extraHeaders": json.dumps({"X-Tenant": "engineering"}),
"extraBody": json.dumps({"service_tier": "priority"}),
"extraQuery": json.dumps({"api-version": "2026-01-01"}),
"proxy": "http://127.0.0.1:7890",
"thinkingStyle": "enable_thinking",
},
)
assert custom_provider_created.status_code == 200
@@ -3015,11 +3166,10 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
}
assert "sk-company" not in custom_provider_created.text
local_provider_updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/provider/update?provider=atomic_chat"
"&api_base=http%3A%2F%2Flocalhost%3A1337%2Fv1",
headers={"Authorization": "Bearer tok"},
local_provider_updated = await _webui_mutate(
webui_client,
"settings.provider.update",
{"provider": "atomic_chat", "apiBase": "http://localhost:1337/v1"},
)
assert local_provider_updated.status_code == 200
local_provider_body = local_provider_updated.json()
@@ -3029,38 +3179,44 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert local_provider_rows["atomic_chat"]["configured"] is True
assert "localhost:1337" in local_provider_updated.text
updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/update?model=atomic_chat/test"
"&provider=atomic_chat&timezone=Asia%2FShanghai"
"&bot_name=Nano&bot_icon=N&tool_hint_max_length=120",
headers={"Authorization": "Bearer tok"},
updated = await _webui_mutate(
webui_client,
"settings.agent.update",
{
"model": "atomic_chat/test",
"provider": "atomic_chat",
"timezone": "Asia/Shanghai",
"tool_hint_max_length": 120,
},
)
assert updated.status_code == 200
updated_body = updated.json()
assert updated_body["requires_restart"] is True
assert updated_body["restart_required_sections"] == ["runtime"]
preset_updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/update?model_preset=deep",
headers={"Authorization": "Bearer tok"},
preset_updated = await _webui_mutate(
webui_client,
"settings.agent.update",
{"model_preset": "deep"},
)
assert preset_updated.status_code == 200
assert preset_updated.json()["agent"]["model"] == "anthropic/claude-opus-4-5"
bad_preset = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/update?model_preset=missing",
headers={"Authorization": "Bearer tok"},
bad_preset = await _webui_mutate(
webui_client,
"settings.agent.update",
{"model_preset": "missing"},
)
assert bad_preset.status_code == 400
created_preset = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/model-configurations/create"
"?label=Fast%20writing&provider=openai&model=openai%2Fgpt-4.1-mini",
headers={"Authorization": "Bearer tok"},
created_preset = await _webui_mutate(
webui_client,
"settings.model_configuration.create",
{
"label": "Fast writing",
"provider": "openai",
"model": "openai/gpt-4.1-mini",
},
)
assert created_preset.status_code == 200
created_body = created_preset.json()
@@ -3074,11 +3230,15 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert created_presets["fast-writing"]["label"] == "Fast writing"
assert created_presets["fast-writing"]["provider"] == "openai"
updated_preset = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/model-configurations/update"
"?name=fast-writing&label=Codex&provider=openai&model=openai%2Fgpt-5.5",
headers={"Authorization": "Bearer tok"},
updated_preset = await _webui_mutate(
webui_client,
"settings.model_configuration.update",
{
"name": "fast-writing",
"label": "Codex",
"provider": "openai",
"model": "openai/gpt-5.5",
},
)
assert updated_preset.status_code == 200
updated_preset_body = updated_preset.json()
@@ -3089,11 +3249,10 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
}
assert updated_presets["fast-writing"]["label"] == "Codex"
call_order_updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/model-call-order/update"
"?order=%5B%22fast-writing%22%2C%22deep%22%5D",
headers={"Authorization": "Bearer tok"},
call_order_updated = await _webui_mutate(
webui_client,
"settings.model_call_order.update",
{"order": ["fast-writing", "deep"]},
)
assert call_order_updated.status_code == 200
call_order_body = call_order_updated.json()
@@ -3101,20 +3260,27 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert call_order_body["agent"]["model"] == "openai/gpt-5.5"
assert call_order_body["model_call_order"] == ["fast-writing", "deep"]
duplicate_preset = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/model-configurations/create"
"?label=Fast%20writing&provider=openai&model=openai%2Fgpt-4.1-mini",
headers={"Authorization": "Bearer tok"},
duplicate_preset = await _webui_mutate(
webui_client,
"settings.model_configuration.create",
{
"label": "Fast writing",
"provider": "openai",
"model": "openai/gpt-4.1-mini",
},
)
assert duplicate_preset.status_code == 409
search_updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/web-search/update?provider=searxng"
"&base_url=https%3A%2F%2Fsearch.example.com"
"&max_results=8&timeout=45&use_jina_reader=false",
headers={"Authorization": "Bearer tok"},
search_updated = await _webui_mutate(
webui_client,
"settings.web_search.update",
{
"provider": "searxng",
"base_url": "https://search.example.com",
"max_results": 8,
"timeout": 45,
"use_jina_reader": False,
},
)
assert search_updated.status_code == 200
search_body = search_updated.json()
@@ -3126,10 +3292,13 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert search_body["web_search"]["max_results"] == 8
assert search_body["web"]["fetch"]["use_jina_reader"] is False
network_safety_updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=full",
headers={"Authorization": "Bearer tok"},
network_safety_updated = await _webui_mutate(
webui_client,
"settings.network_safety.update",
{
"webui_allow_local_service_access": False,
"webui_default_access_mode": "full",
},
)
assert network_safety_updated.status_code == 200
network_safety_body = network_safety_updated.json()
@@ -3139,13 +3308,17 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert network_safety_body["advanced"]["webui_default_access_mode"] == "full"
assert network_safety_body["advanced"]["private_service_protection_enabled"] is True
image_updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/image-generation/update?enabled=true"
"&provider=openrouter&model=openai%2Fgpt-image-1"
"&default_aspect_ratio=16%3A9&default_image_size=2K"
"&max_images_per_turn=3",
headers={"Authorization": "Bearer tok"},
image_updated = await _webui_mutate(
webui_client,
"settings.image_generation.update",
{
"enabled": True,
"provider": "openrouter",
"model": "openai/gpt-image-1",
"default_aspect_ratio": "16:9",
"default_image_size": "2K",
"max_images_per_turn": 3,
},
)
assert image_updated.status_code == 200
image_body = image_updated.json()
@@ -3157,11 +3330,14 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert image_body["image_generation"]["default_image_size"] == "2K"
assert image_body["image_generation"]["max_images_per_turn"] == 3
image_provider_updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/provider/update?provider=openrouter"
"&api_key=sk-or-next&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1",
headers={"Authorization": "Bearer tok"},
image_provider_updated = await _webui_mutate(
webui_client,
"settings.provider.update",
{
"provider": "openrouter",
"apiKey": "sk-or-next",
"apiBase": "https://openrouter.ai/api/v1",
},
)
assert image_provider_updated.status_code == 200
assert image_provider_updated.json()["requires_restart"] is True
@@ -3169,17 +3345,17 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert "sk-or-next" not in image_provider_updated.text
assert image_reload.await_count == 2
bad_web = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/web-search/update?provider=duckduckgo&max_results=99",
headers={"Authorization": "Bearer tok"},
bad_web = await _webui_mutate(
webui_client,
"settings.web_search.update",
{"provider": "duckduckgo", "max_results": 99},
)
assert bad_web.status_code == 400
bad_image = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/image-generation/update?provider=missing",
headers={"Authorization": "Bearer tok"},
bad_image = await _webui_mutate(
webui_client,
"settings.image_generation.update",
{"provider": "missing"},
)
assert bad_image.status_code == 400
@@ -3216,6 +3392,8 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert saved.tools.image_generation.default_image_size == "2K"
assert saved.tools.image_generation.max_images_per_turn == 3
finally:
if webui_client is not None:
await webui_client.close()
await channel.stop()
await server_task
@@ -3248,11 +3426,17 @@ async def test_image_settings_hot_reload_without_restart(
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
webui_client = None
try:
response = await _http_get(
f"http://127.0.0.1:{port}/api/settings/image-generation/update"
"?enabled=true&provider=openrouter&model=openai%2Fgpt-image-1",
headers={"Authorization": "Bearer tok"},
webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
webui_client = await websockets.connect(
f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=image-reload-test"
)
assert json.loads(await webui_client.recv())["event"] == "ready"
response = await _webui_mutate(
webui_client,
"settings.image_generation.update",
{"enabled": True, "provider": "openrouter", "model": "openai/gpt-image-1"},
)
assert response.status_code == 200
@@ -3260,6 +3444,8 @@ async def test_image_settings_hot_reload_without_restart(
assert response.json()["restart_required_sections"] == []
image_reload.assert_awaited_once_with(bus)
finally:
if webui_client is not None:
await webui_client.close()
await channel.stop()
await server_task
@@ -3291,17 +3477,25 @@ async def test_image_settings_fall_back_to_restart_when_hot_reload_fails(
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
webui_client = None
try:
response = await _http_get(
f"http://127.0.0.1:{port}/api/settings/image-generation/update"
"?enabled=true&provider=openrouter&model=openai%2Fgpt-image-1",
headers={"Authorization": "Bearer tok"},
webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
webui_client = await websockets.connect(
f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=image-fallback-test"
)
assert json.loads(await webui_client.recv())["event"] == "ready"
response = await _webui_mutate(
webui_client,
"settings.image_generation.update",
{"enabled": True, "provider": "openrouter", "model": "openai/gpt-image-1"},
)
assert response.status_code == 200
assert response.json()["requires_restart"] is True
assert response.json()["restart_required_sections"] == ["image"]
finally:
if webui_client is not None:
await webui_client.close()
await channel.stop()
await server_task
File diff suppressed because it is too large Load Diff
+16 -3
View File
@@ -22,6 +22,7 @@ class WeixinConnectSession:
channel: WeixinChannel
current_poll_base_url: str
refresh_count: int
force: bool
created_wall: float
deadline: float
last_error: str | None = None
@@ -72,7 +73,7 @@ class WeixinConnectStore:
channel.connect_open_client()
try:
qrcode_id, qr_url = await channel.connect_fetch_qr_code()
qrcode_id, qr_url = await channel.connect_fetch_qr_code(force=force)
except Exception as exc:
await self._close_channel(channel)
raise ChannelConnectError(
@@ -89,6 +90,7 @@ class WeixinConnectStore:
channel=channel,
current_poll_base_url=channel.connect_base_url,
refresh_count=0,
force=force,
created_wall=now_wall,
deadline=time.monotonic() + 600,
)
@@ -187,7 +189,7 @@ class WeixinConnectStore:
}
try:
session.qrcode_id, session.qr_url = (
await session.channel.connect_fetch_qr_code()
await session.channel.connect_fetch_qr_code(force=session.force)
)
except Exception as exc:
self._sessions.pop(session_id, None)
@@ -204,6 +206,17 @@ class WeixinConnectStore:
)
if status == "binded_redirect":
if session.force:
self._sessions.pop(session_id, None)
await self._close_channel(session.channel)
return {
"session_id": session_id,
"status": "failed",
"message": (
"Unable to complete a new WeChat login. "
"Start again and scan with the account you want to connect."
),
}
if not session.channel.connect_load_state():
self._sessions.pop(session_id, None)
await self._close_channel(session.channel)
@@ -234,7 +247,7 @@ class WeixinConnectStore:
}
try:
session.qrcode_id, session.qr_url = (
await session.channel.connect_fetch_qr_code()
await session.channel.connect_fetch_qr_code(force=session.force)
)
except Exception as exc:
self._sessions.pop(session_id, None)
+17 -12
View File
@@ -726,9 +726,9 @@ class WeixinChannel(BaseChannel):
break
return tokens
async def _fetch_qr_code(self) -> tuple[str, str]:
"""Fetch a fresh QR code. Returns (qrcode_id, scan_url)."""
local_tokens = self._local_token_list()
async def _fetch_qr_code(self, *, force: bool = False) -> tuple[str, str]:
"""Fetch a QR code without existing credentials when forced."""
local_tokens = [] if force else self._local_token_list()
data = await self._api_post(
"ilink/bot/get_bot_qrcode?bot_type=3",
{"local_token_list": local_tokens},
@@ -755,11 +755,11 @@ class WeixinChannel(BaseChannel):
raise RuntimeError(f"Failed to get QR code from WeChat API: {data}")
return qrcode_id, (qrcode_img_content or qrcode_id)
async def _qr_login(self) -> bool:
"""Perform QR code login flow. Returns True on success."""
async def _qr_login(self, *, force: bool = False) -> bool:
"""Perform QR login; forced flows accept only newly confirmed credentials."""
try:
refresh_count = 0
qrcode_id, scan_url = await self._fetch_qr_code()
qrcode_id, scan_url = await self._fetch_qr_code(force=force)
self._print_qr_code(scan_url)
current_poll_base_url = self.config.base_url
verify_code = ""
@@ -825,11 +825,16 @@ class WeixinChannel(BaseChannel):
if refresh_count > MAX_QR_REFRESH_COUNT:
self.logger.warning("WeChat verification failed too many times")
return False
qrcode_id, scan_url = await self._fetch_qr_code()
qrcode_id, scan_url = await self._fetch_qr_code(force=force)
current_poll_base_url = self.config.base_url
self._print_qr_code(scan_url)
continue
elif status == "binded_redirect":
if force:
self.logger.error(
"Forced WeChat login returned an existing binding without new credentials"
)
return False
if self._token or self._load_state():
self.logger.info("WeChat account is already connected")
return True
@@ -846,7 +851,7 @@ class WeixinChannel(BaseChannel):
MAX_QR_REFRESH_COUNT,
)
return False
qrcode_id, scan_url = await self._fetch_qr_code()
qrcode_id, scan_url = await self._fetch_qr_code(force=force)
current_poll_base_url = self.config.base_url
verify_code = ""
self._print_qr_code(scan_url)
@@ -893,8 +898,8 @@ class WeixinChannel(BaseChannel):
self._client = self._new_http_client(httpx.Timeout(60, connect=30))
self._running = True
async def connect_fetch_qr_code(self) -> tuple[str, str]:
return await self._fetch_qr_code()
async def connect_fetch_qr_code(self, *, force: bool = False) -> tuple[str, str]:
return await self._fetch_qr_code(force=force)
async def connect_poll_qr_code(
self,
@@ -947,14 +952,14 @@ class WeixinChannel(BaseChannel):
if force:
self._token = ""
self._get_updates_buf = ""
if self._token or self._load_state():
if self._token or (not force and self._load_state()):
return True
# Initialize HTTP client for the login flow
self._client = self._new_http_client(httpx.Timeout(60, connect=30))
self._running = True # Enable polling loop in _qr_login()
try:
return await self._qr_login()
return await self._qr_login(force=force)
finally:
self._running = False
if self._client:
+41 -11
View File
@@ -25,7 +25,9 @@ async def test_weixin_connect_store_saves_confirmed_qr_login(
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
async def fake_fetch_qr_code(
self: WeixinChannel, **_kwargs: Any
) -> tuple[str, str]:
return "qr-1", "https://qr.example/1"
async def fake_api_get_with_base(
@@ -86,14 +88,31 @@ async def test_weixin_reconnect_keeps_existing_account_until_scan_succeeds(
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
return "qr-reconnect", "https://qr.example/reconnect"
observed_force: list[bool] = []
async def fake_fetch_qr_code(
self: WeixinChannel,
*,
force: bool = False,
) -> tuple[str, str]:
observed_force.append(force)
return f"qr-reconnect-{len(observed_force)}", "https://qr.example/reconnect"
async def fake_api_get_with_base(
self: WeixinChannel,
**_kwargs: Any,
) -> dict[str, str]:
return {"status": "expired"}
monkeypatch.setattr(WeixinChannel, "_fetch_qr_code", fake_fetch_qr_code)
monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base)
store = WeixinConnectStore()
started = await store.start(force=True)
refreshed = await store.poll(started["session_id"])
assert refreshed["status"] == "pending"
assert observed_force == [True, True]
assert json.loads(state_file.read_text(encoding="utf-8")) == existing
cancelled = await store.cancel(started["session_id"])
assert cancelled["status"] == "cancelled"
@@ -116,7 +135,9 @@ async def test_weixin_cancel_wins_over_inflight_confirmation(
poll_started = asyncio.Event()
release_poll = asyncio.Event()
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
async def fake_fetch_qr_code(
self: WeixinChannel, **_kwargs: Any
) -> tuple[str, str]:
return "qr-cancel", "https://qr.example/cancel"
async def fake_api_get_with_base(
@@ -162,7 +183,9 @@ async def test_weixin_connect_store_handles_verification_code(
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
async def fake_fetch_qr_code(
self: WeixinChannel, **_kwargs: Any
) -> tuple[str, str]:
return "qr-verify", "https://qr.example/verify"
responses = [
@@ -204,7 +227,7 @@ async def test_weixin_connect_store_handles_verification_code(
@pytest.mark.asyncio
async def test_weixin_connect_store_treats_existing_binding_as_success(
async def test_weixin_connect_store_rejects_existing_binding_during_forced_login(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -221,7 +244,12 @@ async def test_weixin_connect_store_treats_existing_binding_as_success(
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
async def fake_fetch_qr_code(
self: WeixinChannel,
*,
force: bool = False,
) -> tuple[str, str]:
assert force is True
return "qr-existing", "https://qr.example/existing"
async def fake_api_get_with_base(
@@ -237,8 +265,8 @@ async def test_weixin_connect_store_treats_existing_binding_as_success(
started = await store.start(force=True)
completed = await store.poll(started["session_id"])
assert completed["status"] == "succeeded"
assert "already connected" in completed["message"]
assert completed["status"] == "failed"
assert "new WeChat login" in completed["message"]
assert json.loads((state_dir / "account.json").read_text())["token"] == "working-token"
@@ -255,7 +283,9 @@ async def test_weixin_connect_store_rejects_existing_binding_without_local_crede
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
async def fake_fetch_qr_code(
self: WeixinChannel, **_kwargs: Any
) -> tuple[str, str]:
return "qr-missing", "https://qr.example/missing"
async def fake_api_get_with_base(
@@ -268,7 +298,7 @@ async def test_weixin_connect_store_rejects_existing_binding_without_local_crede
monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base)
store = WeixinConnectStore()
started = await store.start(force=True)
started = await store.start(force=False)
completed = await store.poll(started["session_id"])
assert completed["status"] == "failed"
@@ -196,6 +196,86 @@ def test_save_state_with_empty_runtime_token_preserves_persisted_account(tmp_pat
assert json.loads((tmp_path / "account.json").read_text()) == persisted
@pytest.mark.asyncio
async def test_login_force_ignores_persisted_account_through_qr_flow(tmp_path) -> None:
persisted = {
"token": "persisted-token",
"get_updates_buf": "persisted-cursor",
"context_tokens": {"wx-user": "ctx-persisted"},
"typing_tickets": {"wx-user": {"ticket": "ticket-persisted"}},
"base_url": "https://persisted.example",
}
channel = WeixinChannel(
WeixinConfig(
enabled=True,
allow_from=["*"],
token="configured-token",
state_dir=str(tmp_path),
),
MessageBus(),
)
(tmp_path / "account.json").write_text(
json.dumps(persisted),
encoding="utf-8",
)
channel._print_qr_code = lambda _url: None
channel._api_post = AsyncMock(
side_effect=[
{"qrcode": "qr-1", "qrcode_img_content": "url-1"},
{"qrcode": "qr-2", "qrcode_img_content": "url-2"},
]
)
channel._api_get_with_base = AsyncMock(
side_effect=[
{"status": "expired"},
{"status": "binded_redirect"},
]
)
ok = await channel.login(force=True)
assert ok is False
assert [call.args[1]["local_token_list"] for call in channel._api_post.await_args_list] == [
[],
[],
]
assert channel._token == ""
assert channel._get_updates_buf == ""
assert channel._context_tokens == {}
assert channel._typing_tickets == {}
assert channel.config.base_url == "https://ilinkai.weixin.qq.com"
assert json.loads((tmp_path / "account.json").read_text()) == persisted
@pytest.mark.asyncio
async def test_login_without_force_reuses_persisted_account(tmp_path) -> None:
channel = WeixinChannel(
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
MessageBus(),
)
(tmp_path / "account.json").write_text(
json.dumps(
{
"token": "persisted-token",
"get_updates_buf": "persisted-cursor",
"context_tokens": {"wx-user": "ctx-persisted"},
"base_url": "https://persisted.example",
}
),
encoding="utf-8",
)
channel._qr_login = AsyncMock(return_value=False)
ok = await channel.login(force=False)
assert ok is True
channel._qr_login.assert_not_awaited()
assert channel._token == "persisted-token"
assert channel._get_updates_buf == "persisted-cursor"
assert channel._context_tokens == {"wx-user": "ctx-persisted"}
assert channel.config.base_url == "https://persisted.example"
@pytest.mark.asyncio
async def test_process_message_deduplicates_inbound_ids() -> None:
channel, bus = _make_channel()
@@ -27,6 +27,7 @@ import type {
NanobotFeatureInfo,
} from "@/lib/types";
import { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider";
import {
WEIXIN_AUTH_EXPIRED_MESSAGE,
@@ -64,6 +65,7 @@ export function WeixinPanel({
onAction,
onFeaturesUpdate,
}: ChannelPluginPanelProps) {
const { client } = useClient();
const { t, i18n } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const channelTx = channelTranslator(t, "weixin");
@@ -150,7 +152,7 @@ export function WeixinPanel({
setSaveState("idle");
try {
const payload = await configureChannel(
context.token,
client,
"weixin",
channelValuesForSave(editableFieldsRef.current, values),
{ enable: context.enabled },
@@ -168,7 +170,7 @@ export function WeixinPanel({
} finally {
setSaving(false);
}
}, []);
}, [client]);
useEffect(() => {
if (
+1
View File
@@ -669,6 +669,7 @@ def _run_gateway(
webui_runtime_surface=webui_runtime_surface,
webui_runtime_capabilities=webui_runtime_capabilities,
webui_skill_state_action=_webui_skill_state_action,
config_path=Path(config_path),
)
def _pick_heartbeat_target() -> tuple[str, str]:
-16
View File
@@ -12,9 +12,7 @@ from nanobot.config_base import Base
from nanobot.cron.types import CronSchedule
if TYPE_CHECKING:
from nanobot.agent.tools.browser_tool import BrowserToolConfig
from nanobot.agent.tools.cli_apps import CliAppsToolConfig
from nanobot.agent.tools.computer_use import ComputerUseToolConfig
from nanobot.agent.tools.filesystem import FileToolsConfig
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
from nanobot.agent.tools.self import MyToolConfig
@@ -401,16 +399,6 @@ class ToolsConfig(Base):
"""
web: WebToolsConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.web", "WebToolsConfig"))
browser: BrowserToolConfig = Field(
default_factory=lambda: _lazy_default(
"nanobot.agent.tools.browser_tool", "BrowserToolConfig"
)
)
computer_use: ComputerUseToolConfig = Field(
default_factory=lambda: _lazy_default(
"nanobot.agent.tools.computer_use", "ComputerUseToolConfig"
)
)
exec: ExecToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.shell", "ExecToolConfig"))
file: FileToolsConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.filesystem", "FileToolsConfig"))
cli_apps: CliAppsToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.cli_apps", "CliAppsToolConfig"))
@@ -682,9 +670,7 @@ def _resolve_tool_config_refs() -> None:
"""
import sys
from nanobot.agent.tools.browser_tool import BrowserToolConfig
from nanobot.agent.tools.cli_apps import CliAppsToolConfig
from nanobot.agent.tools.computer_use import ComputerUseToolConfig
from nanobot.agent.tools.filesystem import FileToolsConfig
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
from nanobot.agent.tools.self import MyToolConfig
@@ -694,8 +680,6 @@ def _resolve_tool_config_refs() -> None:
# Re-export into this module's namespace
mod = sys.modules[__name__]
mod.ExecToolConfig = ExecToolConfig # type: ignore[attr-defined]
mod.BrowserToolConfig = BrowserToolConfig # type: ignore[attr-defined]
mod.ComputerUseToolConfig = ComputerUseToolConfig # type: ignore[attr-defined]
mod.FileToolsConfig = FileToolsConfig # type: ignore[attr-defined]
mod.CliAppsToolConfig = CliAppsToolConfig # type: ignore[attr-defined]
mod.WebToolsConfig = WebToolsConfig # type: ignore[attr-defined]
+1 -69
View File
@@ -671,73 +671,6 @@ class OpenAICompatProvider(LLMProvider):
dumped = str(content)
return dumped or "(empty)"
@classmethod
def _move_tool_images_to_user(
cls,
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Adapt multimodal tool results to Chat Completions' text-only tool role."""
updated: list[dict[str, Any]] = []
pending_images: list[dict[str, Any]] = []
def flush_images(next_message: dict[str, Any] | None = None) -> None:
if not pending_images:
if next_message is not None:
updated.append(next_message)
return
content: list[dict[str, Any]] = [
*pending_images,
{"type": "text", "text": "Images returned by the preceding tool call(s)."},
]
pending_images.clear()
if next_message is not None and next_message.get("role") == "user":
existing = next_message.get("content")
if isinstance(existing, str):
content.append({"type": "text", "text": existing})
elif isinstance(existing, list):
content.extend(cast(list[dict[str, Any]], existing))
updated.append({**next_message, "content": content})
else:
updated.append({"role": "user", "content": content})
if next_message is not None:
updated.append(next_message)
for message in messages:
content = message.get("content")
if message.get("role") == "tool" and isinstance(content, list):
blocks = cast(list[object], content)
images: list[dict[str, Any]] = []
text_blocks: list[object] = []
for block in blocks:
if isinstance(block, dict):
block_data = cast(dict[str, Any], block)
image_url = block_data.get("image_url")
if block_data.get("type") == "image_url" and isinstance(
image_url, dict
):
images.append({"type": "image_url", "image_url": image_url})
continue
text_blocks.append(block_data)
else:
text_blocks.append(block)
if images:
updated.append({
**message,
"content": (
cls._coerce_content_to_string(text_blocks)
if text_blocks
else "(image returned)"
),
})
pending_images.extend(images)
continue
if message.get("role") != "tool":
flush_images(message)
else:
updated.append(message)
flush_images()
return updated
def _sanitize_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Strip non-standard keys, normalize tool_call IDs."""
sanitized = LLMProvider._sanitize_request_messages(messages, _ALLOWED_MSG_KEYS)
@@ -891,10 +824,9 @@ class OpenAICompatProvider(LLMProvider):
model_name = self._request_model_name(model_name)
sanitized_messages = self._sanitize_messages(self._sanitize_empty_content(messages))
kwargs: dict[str, Any] = {
"model": model_name,
"messages": self._move_tool_images_to_user(sanitized_messages),
"messages": self._sanitize_messages(self._sanitize_empty_content(messages)),
}
# GPT-5 and reasoning models (o1/o3/o4) reject temperature when
+5 -12
View File
@@ -356,7 +356,6 @@ _TOOL_RESULT_PREVIEW_CHARS = 1200
_TOOL_RESULTS_DIR = ".nanobot/tool-results"
_TOOL_RESULT_RETENTION_SECS = 7 * 24 * 60 * 60
_TOOL_RESULT_MAX_BUCKETS = 32
_IMAGE_TOKEN_ESTIMATE = 2048
_TRUNCATED_SUFFIX = "\n... (truncated)"
@@ -677,7 +676,6 @@ def _estimate_prompt_tokens_with_source(
reasoning_content, tool_call_id, name, plus per-message framing overhead.
"""
parts: list[str] = []
image_tokens = 0
for msg in messages:
content = msg.get("content")
if isinstance(content, str):
@@ -689,8 +687,6 @@ def _estimate_prompt_tokens_with_source(
text = part.get("text", "")
if isinstance(text, str) and text:
parts.append(text)
elif part is not None and part.get("type") in {"image_url", "input_image"}:
image_tokens += _IMAGE_TOKEN_ESTIMATE
tc = msg.get("tool_calls")
if tc:
@@ -713,7 +709,7 @@ def _estimate_prompt_tokens_with_source(
_estimate_tools_tokens(enc, tools, leading_separator=bool(parts)) if tools else 0
)
message_tokens = len(enc.encode(message_payload)) if message_payload else 0
return message_tokens + image_tokens + tool_tokens + per_message_overhead, "tiktoken"
return message_tokens + tool_tokens + per_message_overhead, "tiktoken"
except Exception:
tool_payload = (
("\n" if message_payload else "") + json.dumps(tools, ensure_ascii=False)
@@ -722,7 +718,7 @@ def _estimate_prompt_tokens_with_source(
)
payload = message_payload + tool_payload
estimated = len(payload.encode("utf-8"))
return estimated + image_tokens + per_message_overhead, "heuristic"
return estimated + per_message_overhead, "heuristic"
def estimate_prompt_tokens(
@@ -738,7 +734,6 @@ def estimate_message_tokens(message: dict[str, Any]) -> int:
"""Estimate prompt tokens contributed by one persisted message."""
content = message.get("content")
parts: list[str] = []
image_tokens = 0
if isinstance(content, str):
parts.append(content)
elif isinstance(content, list):
@@ -748,8 +743,6 @@ def estimate_message_tokens(message: dict[str, Any]) -> int:
text = part.get("text", "")
if isinstance(text, str) and text:
parts.append(text)
elif part is not None and part.get("type") in {"image_url", "input_image"}:
image_tokens += _IMAGE_TOKEN_ESTIMATE
else:
parts.append(json.dumps(raw_part, ensure_ascii=False))
elif content is not None:
@@ -767,13 +760,13 @@ def estimate_message_tokens(message: dict[str, Any]) -> int:
parts.append(rc)
payload = "\n".join(parts)
if not payload and not image_tokens:
if not payload:
return 4
try:
enc = _get_token_encoding()
return max(4, len(enc.encode(payload)) + image_tokens + 4)
return max(4, len(enc.encode(payload)) + 4)
except Exception:
return max(4, len(payload.encode("utf-8")) + image_tokens + 4)
return max(4, len(payload.encode("utf-8")) + 4)
def estimate_prompt_tokens_chain(
+16 -6
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
import re
import time
from pathlib import Path
from typing import Any, cast
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
@@ -89,8 +90,8 @@ def _query_first(query: QueryParams, key: str) -> str | None:
return values[0] if values else None
def _manager() -> CliAppManager:
config = load_config()
def _manager(config_path: Path | None = None) -> CliAppManager:
config = load_config(config_path) if config_path is not None else load_config()
cli_cfg = config.tools.cli_apps
return CliAppManager(
workspace=config.workspace_path,
@@ -102,8 +103,12 @@ def _manager() -> CliAppManager:
)
async def cli_apps_payload(*, installed_only: bool = False) -> dict[str, Any]:
manager = _manager()
async def cli_apps_payload(
*,
installed_only: bool = False,
config_path: Path | None = None,
) -> dict[str, Any]:
manager = _manager(config_path) if config_path is not None else _manager()
if installed_only:
return manager.installed_payload()
payload = manager.payload(cache_only=True)
@@ -118,11 +123,16 @@ async def cli_apps_payload(*, installed_only: bool = False) -> dict[str, Any]:
return payload
def cli_apps_action(action: str, query: QueryParams) -> dict[str, Any]:
def cli_apps_action(
action: str,
query: QueryParams,
*,
config_path: Path | None = None,
) -> dict[str, Any]:
name = (_query_first(query, "name") or "").strip()
if not name:
raise CliAppError("missing CLI app name")
manager = _manager()
manager = _manager(config_path) if config_path is not None else _manager()
if action == "install":
return manager.install(name)
if action == "update":
+7
View File
@@ -8,9 +8,11 @@ from typing import TYPE_CHECKING, Any, Callable
from loguru import logger as default_logger
from nanobot.config.loader import get_config_path
from nanobot.webui.gateway_tokens import GatewayTokenStore
from nanobot.webui.ingress_policy import DEFAULT_WEBUI_INGRESS_POLICY, WebUIIngressPolicy
from nanobot.webui.media_gateway import WebUIMediaGateway
from nanobot.webui.settings_services import WebUISettingsServices
from nanobot.webui.temporary_chats import WebUITemporaryChats
from nanobot.webui.transcript import WebUITranscriptRecorder
from nanobot.webui.workspaces import WebUIWorkspaceController
@@ -29,6 +31,7 @@ class GatewayServices:
"""Explicit dependencies shared by WebSocket transport and HTTP routes."""
http: GatewayHTTPHandler
settings: WebUISettingsServices
tokens: GatewayTokenStore
media: WebUIMediaGateway
ingress: WebUIIngressPolicy
@@ -50,6 +53,7 @@ def build_gateway_services(
static_dist_path: Path | None,
workspace_path: Path,
default_restrict_to_workspace: bool,
config_path: Path | None = None,
runtime_model_name: Callable[[], str | None] | None,
runtime_surface: str,
runtime_capabilities_overrides: dict[str, Any] | None,
@@ -63,6 +67,7 @@ def build_gateway_services(
skill_state_action: Callable[[set[str]], None] | None = None,
logger: Any = default_logger,
) -> GatewayServices:
settings = WebUISettingsServices.create(config_path or get_config_path())
tokens = GatewayTokenStore()
ingress = DEFAULT_WEBUI_INGRESS_POLICY
minimum_frame_bytes = ingress.minimum_full_policy_frame_bytes()
@@ -102,6 +107,7 @@ def build_gateway_services(
media=media,
ingress=ingress,
workspaces=workspaces,
settings=settings,
skills_workspace_path=workspace_path,
disabled_skills=disabled_skills,
cron_service=cron_service,
@@ -115,6 +121,7 @@ def build_gateway_services(
)
return GatewayServices(
http=http,
settings=settings,
tokens=tokens,
media=media,
ingress=ingress,
+86 -35
View File
@@ -14,7 +14,7 @@ from contextlib import suppress
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Literal, Mapping, cast
from typing import TYPE_CHECKING, Any, Literal, Mapping, cast
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.apps.protocol import app_manifest, compact_dict
@@ -25,6 +25,9 @@ from nanobot.utils.helpers import ensure_dir
QueryParams = dict[str, list[str]]
if TYPE_CHECKING:
from nanobot.webui.settings_services import WebUISettingsConfig
_MCP_PRESET_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$", re.IGNORECASE)
_SECRET_QUERY_RE = re.compile(
r"([?&](?:[^=&]*(?:api[_-]?key|token|secret|password|bearer)[^=&]*)=)[^&#\s]+",
@@ -841,8 +844,9 @@ def mcp_presets_payload(
*,
last_action: dict[str, Any] | None = None,
tool_preview: Mapping[str, list[str]] | None = None,
config_path: Path | None = None,
) -> dict[str, Any]:
config = load_config()
config = load_config(config_path) if config_path is not None else load_config()
known = _known_preset_names()
preset_rows = [
_preset_payload(preset, config.tools.mcp_servers)
@@ -928,7 +932,11 @@ async def _close_mcp_stacks(stacks: Mapping[str, Any]) -> None:
await stack.aclose()
async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]:
async def mcp_presets_test_action(
query: QueryParams,
*,
config_path: Path | None = None,
) -> dict[str, Any]:
"""Connect to an enabled MCP preset and report its tool surface."""
from nanobot.agent.tools.mcp import connect_mcp_servers
@@ -941,16 +949,22 @@ async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]:
display_name = _display_name_for(name, preset)
try:
config = resolve_config_env_vars(load_config())
config = resolve_config_env_vars(
load_config(config_path) if config_path is not None else load_config(),
config_path=config_path,
)
except ValueError as exc:
return mcp_presets_payload(last_action={
"ok": False,
"message": _scrub_test_error(str(exc)),
"error": _scrub_test_error(str(exc)),
"tool_count": 0,
"tool_names": [],
"checked_at": _checked_at(),
})
return mcp_presets_payload(
last_action={
"ok": False,
"message": _scrub_test_error(str(exc)),
"error": _scrub_test_error(str(exc)),
"tool_count": 0,
"tool_names": [],
"checked_at": _checked_at(),
},
config_path=config_path,
)
cfg = config.tools.mcp_servers.get(name)
if cfg is None:
@@ -968,7 +982,7 @@ async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]:
"tool_names": [],
"checked_at": _checked_at(),
}
return mcp_presets_payload(last_action=last_action)
return mcp_presets_payload(last_action=last_action, config_path=config_path)
if cfg.command and not _command_available(cfg.command):
last_action = {
@@ -979,7 +993,7 @@ async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]:
"tool_names": [],
"checked_at": _checked_at(),
}
return mcp_presets_payload(last_action=last_action)
return mcp_presets_payload(last_action=last_action, config_path=config_path)
registry = ToolRegistry()
stacks: dict[str, Any] = {}
@@ -1040,7 +1054,11 @@ async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]:
tool_names = last_action.get("tool_names", [])
preview = {name: tool_names} if tool_names else None
return mcp_presets_payload(last_action=last_action, tool_preview=preview)
return mcp_presets_payload(
last_action=last_action,
tool_preview=preview,
config_path=config_path,
)
def _parse_json_value(raw: str | None, *, fallback: Any) -> Any:
@@ -1221,24 +1239,35 @@ def _import_mcp_servers(raw_json: str | None) -> dict[str, MCPServerConfig]:
return out
def custom_mcp_action(action: str, query: QueryParams) -> dict[str, Any]:
config = load_config()
def custom_mcp_action(
action: str,
query: QueryParams,
*,
config_path: Path | None = None,
) -> dict[str, Any]:
config = load_config(config_path) if config_path is not None else load_config()
if action == "custom":
name, cfg = _custom_server_from_query(query)
config.tools.mcp_servers[name] = cfg
save_config(config)
payload = mcp_presets_payload(last_action=_server_action_message(action, name))
save_config(config, config_path)
payload = mcp_presets_payload(
last_action=_server_action_message(action, name),
config_path=config_path,
)
payload["requires_restart"] = True
return payload
if action in {"import", "import-cursor"}:
servers = _import_mcp_servers(_query_first(query, "config"))
config.tools.mcp_servers.update(servers)
save_config(config)
payload = mcp_presets_payload(last_action={
"ok": True,
"message": f"Imported {len(servers)} MCP server(s).",
})
save_config(config, config_path)
payload = mcp_presets_payload(
last_action={
"ok": True,
"message": f"Imported {len(servers)} MCP server(s).",
},
config_path=config_path,
)
payload["requires_restart"] = True
return payload
@@ -1249,29 +1278,40 @@ def custom_mcp_action(action: str, query: QueryParams) -> dict[str, Any]:
raise McpPresetError("unknown MCP server", status=404)
cfg.enabled_tools = _parse_enabled_tools(_query_first(query, "enabled_tools"))
config.tools.mcp_servers[name] = cfg
save_config(config)
payload = mcp_presets_payload(last_action=_server_action_message(action, name))
save_config(config, config_path)
payload = mcp_presets_payload(
last_action=_server_action_message(action, name),
config_path=config_path,
)
payload["requires_restart"] = True
return payload
raise McpPresetError(f"unknown MCP action '{action}'", status=404)
def mcp_presets_action(action: str, query: QueryParams) -> dict[str, Any]:
def mcp_presets_action(
action: str,
query: QueryParams,
*,
config_path: Path | None = None,
) -> dict[str, Any]:
name = (_query_first(query, "name") or "").strip()
if not name:
raise McpPresetError("missing MCP preset name")
preset = _preset_by_name_optional(name)
config = load_config()
config = load_config(config_path) if config_path is not None else load_config()
existing = config.tools.mcp_servers.get(name)
if action == "enable":
if preset is None:
raise McpPresetError("unknown MCP preset", status=404)
config.tools.mcp_servers[preset.name] = _materialize_server(preset, query, existing)
save_config(config)
payload = mcp_presets_payload(last_action=_action_message(action, preset))
save_config(config, config_path)
payload = mcp_presets_payload(
last_action=_action_message(action, preset),
config_path=config_path,
)
payload["requires_restart"] = True
return payload
@@ -1287,7 +1327,7 @@ def mcp_presets_action(action: str, query: QueryParams) -> dict[str, Any]:
except OSError as exc:
cleanup_error = str(exc)
del config.tools.mcp_servers[name]
save_config(config)
save_config(config, config_path)
last_action = (
_action_message(action, preset)
if preset is not None
@@ -1303,7 +1343,10 @@ def mcp_presets_action(action: str, query: QueryParams) -> dict[str, Any]:
f"{last_action['message']} Could not remove managed runtime files: {cleanup_error}"
)
last_action["verification_failed"] = ["managed_paths_absent"]
payload = mcp_presets_payload(last_action=last_action)
payload = mcp_presets_payload(
last_action=last_action,
config_path=config_path,
)
payload["requires_restart"] = True
return payload
@@ -1339,13 +1382,21 @@ async def mcp_presets_settings_action(
query: QueryParams,
*,
reload_mcp: McpReload | None = None,
config: WebUISettingsConfig | None = None,
) -> dict[str, Any]:
"""Run a WebUI MCP preset action and hot-reload the agent when config changes."""
config_path = config.path if config is not None else None
if action is None:
return mcp_presets_payload()
return mcp_presets_payload(config_path=config_path)
if action == "test":
return await mcp_presets_test_action(query)
if action in _CUSTOM_ACTIONS:
return await mcp_presets_test_action(query, config_path=config_path)
if config is not None:
operation = custom_mcp_action if action in _CUSTOM_ACTIONS else mcp_presets_action
payload = await asyncio.to_thread(
config.run_serialized,
lambda path: operation(action, query, config_path=path),
)
elif action in _CUSTOM_ACTIONS:
payload = await asyncio.to_thread(custom_mcp_action, action, query)
else:
payload = await asyncio.to_thread(mcp_presets_action, action, query)
+20 -4
View File
@@ -1,6 +1,7 @@
"""Nanobot optional feature helpers for WebUI Settings."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from nanobot.channels.registry import load_channel_plugin
@@ -15,8 +16,13 @@ from nanobot.webui.http_utils import query_first
QueryParams = dict[str, list[str]]
def nanobot_features_payload() -> dict[str, Any]:
return optional_features_payload()
def nanobot_features_payload(*, config_path: Path | None = None) -> dict[str, Any]:
if config_path is None:
return optional_features_payload()
from nanobot.config.loader import load_config
return optional_features_payload(config=load_config(config_path))
def nanobot_feature_instance_target(query: QueryParams) -> str | None:
@@ -32,13 +38,19 @@ def nanobot_features_action(
query: QueryParams,
*,
allow_install: bool = True,
config_path: Path | None = None,
) -> dict[str, Any]:
name = (query_first(query, "name") or "").strip()
instance_id = nanobot_feature_instance_target(query)
if not name:
raise OptionalFeatureError("missing feature name")
if action == "enable":
return enable_optional_feature(name, allow_install=allow_install, instance_id=instance_id)
return enable_optional_feature(
name,
config_path=config_path,
allow_install=allow_install,
instance_id=instance_id,
)
if action == "disable":
try:
plugin = load_channel_plugin(name)
@@ -50,5 +62,9 @@ def nanobot_features_action(
f"Use `nanobot plugins disable {name}` from a terminal if you need to disable it.",
status=400,
)
return disable_optional_feature(name, instance_id=instance_id)
return disable_optional_feature(
name,
config_path=config_path,
instance_id=instance_id,
)
raise OptionalFeatureError(f"unknown feature action '{action}'", status=404)
File diff suppressed because it is too large Load Diff
+804
View File
@@ -0,0 +1,804 @@
"""Capability settings domain logic for Web, media, network, and API features."""
from __future__ import annotations
import asyncio
import os
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Awaitable, Callable, TypedDict
from nanobot.agent.tools.web import SEARCH_PROVIDER_OPTIONS
from nanobot.api.runtime import ApiRuntime, ApiStartOptions
from nanobot.audio.transcription import resolve_transcription_config
from nanobot.audio.transcription_registry import (
resolve_transcription_provider,
transcription_provider_names,
)
from nanobot.config.schema import Config
from nanobot.optional_features import (
OptionalFeatureError,
extra_installed,
optional_dependency_groups,
)
from nanobot.providers.image_generation import (
get_image_gen_provider,
image_gen_provider_names,
)
from nanobot.providers.registry import find_by_name
from nanobot.security.network import is_loopback_host
from nanobot.webui.settings_contracts import (
QueryParams,
SettingsRequest,
SettingsRouteResult,
WebUISettingsError,
parse_bool,
query_first,
query_first_alias,
)
from nanobot.webui.settings_models import (
OAuthStatusReader,
mask_secret_hint,
provider_configured_for_settings,
)
from nanobot.webui.workspaces import (
read_webui_default_access_mode,
)
if TYPE_CHECKING:
from nanobot.webui.settings_services import WebUISettingsServices
SettingsOperation = Callable[..., dict[str, Any]]
@dataclass(frozen=True)
class CapabilitySettingsOperations:
update_web_search: SettingsOperation
update_api: SettingsOperation
update_image: SettingsOperation
update_transcription: SettingsOperation
update_network: SettingsOperation
nanobot_features_action: SettingsOperation
api_runtime: Callable[[], ApiRuntime]
reload_image: Callable[[], Awaitable[dict[str, Any]]]
class CapabilitySettingsPayload(TypedDict):
web_search: dict[str, Any]
web: dict[str, Any]
api: dict[str, Any]
observability: dict[str, Any]
image_generation: dict[str, Any]
transcription: dict[str, Any]
_WEB_SEARCH_PROVIDER_OPTIONS = SEARCH_PROVIDER_OPTIONS
_WEB_SEARCH_PROVIDER_BY_NAME = {
provider["name"]: provider for provider in _WEB_SEARCH_PROVIDER_OPTIONS
}
_IMAGE_GENERATION_ASPECT_RATIOS = {
"1:1",
"3:4",
"9:16",
"4:3",
"16:9",
"3:2",
"2:3",
"21:9",
}
def _image_generation_provider_rows(
config: Config,
*,
oauth_status: OAuthStatusReader,
) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for name in image_gen_provider_names():
image_provider = get_image_gen_provider(name)
spec = find_by_name(name)
provider_config = getattr(config.providers, name, None)
configured = (
provider_configured_for_settings(spec, provider_config, oauth_status)
if spec is not None and provider_config is not None
else bool(getattr(provider_config, "api_key", None))
)
rows.append(
{
"name": name,
"label": spec.label if spec is not None else name,
"configured": configured,
"auth_type": "oauth" if spec is not None and spec.is_oauth else "api_key",
"api_key_hint": mask_secret_hint(getattr(provider_config, "api_key", None)),
"api_base": getattr(provider_config, "api_base", None),
"default_api_base": (
spec.default_api_base if spec and spec.default_api_base else None
),
"models": list(image_provider.model_options) if image_provider else [],
"default_model": (
image_provider.model_options[0]
if image_provider and image_provider.model_options
else None
),
}
)
return rows
def _transcription_provider_rows(config: Config) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for name in transcription_provider_names():
spec = find_by_name(name)
provider_config = getattr(config.providers, name, None)
rows.append(
{
"name": name,
"label": spec.label if spec is not None else name,
"configured": bool(getattr(provider_config, "api_key", None)),
"api_key_hint": mask_secret_hint(getattr(provider_config, "api_key", None)),
"api_base": getattr(provider_config, "api_base", None),
"default_api_base": (
spec.default_api_base if spec and spec.default_api_base else None
),
}
)
return rows
def capability_settings_payload(
config: Config,
*,
oauth_status: OAuthStatusReader,
) -> CapabilitySettingsPayload:
search_config = config.tools.web.search
image_config = config.tools.image_generation
transcription = resolve_transcription_config(config)
search_provider = (
search_config.provider
if search_config.provider in _WEB_SEARCH_PROVIDER_BY_NAME
else "duckduckgo"
)
image_providers = _image_generation_provider_rows(config, oauth_status=oauth_status)
selected_image_provider = next(
(
provider
for provider in image_providers
if provider["name"] == image_config.provider
),
None,
)
return {
"web_search": {
"provider": search_provider,
"api_key_hint": mask_secret_hint(search_config.api_key),
"base_url": search_config.base_url or None,
"max_results": search_config.max_results,
"timeout": search_config.timeout,
"providers": list(_WEB_SEARCH_PROVIDER_OPTIONS),
},
"web": {
"enable": config.tools.web.enable,
"proxy": config.tools.web.proxy,
"user_agent": config.tools.web.user_agent,
"search": {
"max_results": search_config.max_results,
"timeout": search_config.timeout,
},
"fetch": {
"use_jina_reader": config.tools.web.fetch.use_jina_reader,
},
},
"api": {
"host": config.api.host,
"port": config.api.port,
"timeout": config.api.timeout,
"api_key_hint": mask_secret_hint(config.api.api_key),
},
"observability": {
"provider": "langfuse",
"configured": bool(
os.environ.get("LANGFUSE_SECRET_KEY")
and os.environ.get("LANGFUSE_PUBLIC_KEY")
),
"base_url": os.environ.get("LANGFUSE_BASE_URL")
or "https://cloud.langfuse.com",
},
"image_generation": {
"enabled": image_config.enabled,
"provider": image_config.provider,
"provider_configured": bool(
selected_image_provider and selected_image_provider["configured"]
),
"model": image_config.model,
"default_aspect_ratio": image_config.default_aspect_ratio,
"default_image_size": image_config.default_image_size,
"max_images_per_turn": image_config.max_images_per_turn,
"save_dir": image_config.save_dir,
"providers": image_providers,
},
"transcription": {
"enabled": transcription.enabled,
"provider": transcription.provider,
"provider_configured": transcription.configured,
"model": transcription.model,
"language": transcription.language,
"max_duration_sec": transcription.max_duration_sec,
"max_upload_mb": transcription.max_upload_mb,
"providers": _transcription_provider_rows(config),
},
}
def update_network_safety_settings(
config: Config,
query: QueryParams,
) -> tuple[bool, str | None]:
raw_allow = (
query_first_alias(
query,
"webui_allow_local_service_access",
"webuiAllowLocalServiceAccess",
)
or query_first_alias(
query,
"allow_local_preview_access",
"allowLocalPreviewAccess",
)
)
raw_default_access_mode = query_first_alias(
query,
"webui_default_access_mode",
"webuiDefaultAccessMode",
)
if raw_allow is None and raw_default_access_mode is None:
raise WebUISettingsError(
"webui_allow_local_service_access or webui_default_access_mode is required"
)
changed = False
if raw_allow is not None:
allow_local = parse_bool(raw_allow, "webui_allow_local_service_access")
if config.tools.webui_allow_local_service_access != allow_local:
config.tools.webui_allow_local_service_access = allow_local
changed = True
default_access_mode: str | None = None
if raw_default_access_mode is not None:
default_access_mode = raw_default_access_mode.strip().lower()
if default_access_mode == "restricted":
default_access_mode = "default"
if default_access_mode not in {"default", "full"}:
raise WebUISettingsError(
"webui_default_access_mode must be default or full"
)
return changed, default_access_mode
def update_web_search_settings(config: Config, query: QueryParams) -> tuple[bool, bool]:
provider_name = (query_first(query, "provider") or "").strip().lower()
provider_option = _WEB_SEARCH_PROVIDER_BY_NAME.get(provider_name)
if provider_option is None:
raise WebUISettingsError("unknown web search provider")
search_config = config.tools.web.search
web_config = config.tools.web
previous_provider = search_config.provider
changed = False
restart_required = False
def set_search_value(attr: str, value: object) -> None:
nonlocal changed
if getattr(search_config, attr) != value:
setattr(search_config, attr, value)
changed = True
def set_fetch_value(attr: str, value: object) -> None:
nonlocal changed
if getattr(web_config.fetch, attr) != value:
setattr(web_config.fetch, attr, value)
changed = True
if search_config.provider != provider_name:
search_config.provider = provider_name
changed = True
credential = provider_option["credential"]
if credential == "none":
set_search_value("api_key", "")
set_search_value("base_url", "")
elif credential == "base_url":
base_url = query_first_alias(query, "base_url", "baseUrl")
base_url = base_url.strip() if base_url is not None else None
if not base_url and previous_provider == provider_name and search_config.base_url:
base_url = search_config.base_url
if not base_url:
raise WebUISettingsError("base_url is required")
set_search_value("base_url", base_url)
set_search_value("api_key", "")
elif credential in {"api_key", "optional_api_key"}:
raw_api_key = query_first_alias(query, "api_key", "apiKey")
api_key = raw_api_key.strip() if raw_api_key is not None else None
if api_key is None and previous_provider == provider_name and search_config.api_key:
api_key = search_config.api_key
if credential == "api_key" and not api_key:
raise WebUISettingsError("api_key is required")
set_search_value("api_key", api_key or "")
set_search_value("base_url", "")
else:
raise WebUISettingsError("unknown web search credential type")
max_results = query_first_alias(query, "max_results", "maxResults")
if max_results is not None:
try:
parsed = int(max_results)
except ValueError:
raise WebUISettingsError("max_results must be an integer") from None
if parsed < 1 or parsed > 10:
raise WebUISettingsError("max_results must be between 1 and 10")
set_search_value("max_results", parsed)
timeout = query_first(query, "timeout")
if timeout is not None:
try:
parsed_timeout = int(timeout)
except ValueError:
raise WebUISettingsError("timeout must be an integer") from None
if parsed_timeout < 1 or parsed_timeout > 120:
raise WebUISettingsError("timeout must be between 1 and 120")
set_search_value("timeout", parsed_timeout)
use_jina_reader = query_first_alias(query, "use_jina_reader", "useJinaReader")
if use_jina_reader is not None:
previous_jina_reader = web_config.fetch.use_jina_reader
set_fetch_value("use_jina_reader", parse_bool(use_jina_reader, "use_jina_reader"))
if web_config.fetch.use_jina_reader != previous_jina_reader:
restart_required = True
return changed, restart_required
def update_api_settings(config: Config, query: QueryParams) -> None:
"""Update the managed OpenAI-compatible API configuration."""
api = config.api
host = query_first(query, "host")
if host is not None:
host = host.strip()
if not host:
raise WebUISettingsError("host is required")
api.host = host
port = query_first(query, "port")
if port is not None:
try:
parsed_port = int(port)
except ValueError:
raise WebUISettingsError("port must be an integer") from None
if parsed_port < 1 or parsed_port > 65535:
raise WebUISettingsError("port must be between 1 and 65535")
api.port = parsed_port
timeout = query_first(query, "timeout")
if timeout is not None:
try:
parsed_timeout = float(timeout)
except ValueError:
raise WebUISettingsError("timeout must be a number") from None
if parsed_timeout < 1 or parsed_timeout > 3600:
raise WebUISettingsError("timeout must be between 1 and 3600")
api.timeout = parsed_timeout
api_key = query_first_alias(query, "api_key", "apiKey")
if api_key is not None:
api.api_key = api_key.strip()
if not is_loopback_host(api.host) and not api.api_key.strip():
raise WebUISettingsError(
"an API key is required when the API is available on the network"
)
def update_image_generation_settings(
config: Config,
query: QueryParams,
*,
oauth_status: OAuthStatusReader,
) -> bool:
image_config = config.tools.image_generation
changed = False
provider_name = query_first(query, "provider")
if provider_name is not None:
provider_name = provider_name.strip().lower()
if not provider_name:
raise WebUISettingsError("image generation provider is required")
if get_image_gen_provider(provider_name) is None:
raise WebUISettingsError("unknown image generation provider")
if image_config.provider != provider_name:
image_config.provider = provider_name
changed = True
enabled = query_first(query, "enabled")
if enabled is not None:
parsed_enabled = parse_bool(enabled, "enabled")
if image_config.enabled != parsed_enabled:
image_config.enabled = parsed_enabled
changed = True
model = query_first(query, "model")
if model is not None:
model = model.strip()
if not model:
raise WebUISettingsError("image generation model is required")
if len(model) > 200:
raise WebUISettingsError("image generation model is too long")
if image_config.model != model:
image_config.model = model
changed = True
default_aspect_ratio = query_first_alias(
query,
"default_aspect_ratio",
"defaultAspectRatio",
)
if default_aspect_ratio is not None:
default_aspect_ratio = default_aspect_ratio.strip()
if default_aspect_ratio not in _IMAGE_GENERATION_ASPECT_RATIOS:
raise WebUISettingsError("unsupported image generation aspect ratio")
if image_config.default_aspect_ratio != default_aspect_ratio:
image_config.default_aspect_ratio = default_aspect_ratio
changed = True
default_image_size = query_first_alias(
query,
"default_image_size",
"defaultImageSize",
)
if default_image_size is not None:
default_image_size = default_image_size.strip()
if not default_image_size:
raise WebUISettingsError("default image size is required")
if len(default_image_size) > 32 or not all(
char.isascii() and (char.isalnum() or char in {"x", "X", ":", "-", "_"})
for char in default_image_size
):
raise WebUISettingsError("unsupported image generation size")
if image_config.default_image_size != default_image_size:
image_config.default_image_size = default_image_size
changed = True
max_images_per_turn = query_first_alias(
query,
"max_images_per_turn",
"maxImagesPerTurn",
)
if max_images_per_turn is not None:
try:
parsed_max = int(max_images_per_turn)
except ValueError:
raise WebUISettingsError("max_images_per_turn must be an integer") from None
if parsed_max < 1 or parsed_max > 8:
raise WebUISettingsError("max_images_per_turn must be between 1 and 8")
if image_config.max_images_per_turn != parsed_max:
image_config.max_images_per_turn = parsed_max
changed = True
if image_config.enabled:
selected_provider = next(
(
provider
for provider in _image_generation_provider_rows(
config,
oauth_status=oauth_status,
)
if provider["name"] == image_config.provider
),
None,
)
if not selected_provider or not selected_provider["configured"]:
raise WebUISettingsError("image generation provider is not configured")
return changed
def update_transcription_settings(config: Config, query: QueryParams) -> bool:
transcription = config.transcription
changed = False
enabled = query_first(query, "enabled")
if enabled is not None:
parsed_enabled = parse_bool(enabled, "enabled")
if transcription.enabled != parsed_enabled:
transcription.enabled = parsed_enabled
changed = True
provider = query_first(query, "provider")
if provider is not None:
provider = provider.strip().lower()
provider_spec = resolve_transcription_provider(provider)
if provider_spec is None:
raise WebUISettingsError("unknown transcription provider")
provider = provider_spec.name
if transcription.provider != provider:
transcription.provider = provider
changed = True
model = query_first(query, "model")
if model is not None:
model = model.strip() or None
if model is not None and len(model) > 200:
raise WebUISettingsError("transcription model is too long")
if transcription.model != model:
transcription.model = model
changed = True
language = query_first(query, "language")
if language is not None:
language = language.strip().lower() or None
if language is not None and not re.fullmatch(r"[a-z]{2,3}", language):
raise WebUISettingsError(
"transcription language must be 2-3 lowercase letters"
)
if transcription.language != language:
transcription.language = language
changed = True
max_duration_sec = query_first_alias(query, "max_duration_sec", "maxDurationSec")
if max_duration_sec is not None:
try:
parsed_duration = int(max_duration_sec)
except ValueError:
raise WebUISettingsError("max_duration_sec must be an integer") from None
if parsed_duration < 1 or parsed_duration > 600:
raise WebUISettingsError("max_duration_sec must be between 1 and 600")
if transcription.max_duration_sec != parsed_duration:
transcription.max_duration_sec = parsed_duration
changed = True
max_upload_mb = query_first_alias(query, "max_upload_mb", "maxUploadMb")
if max_upload_mb is not None:
try:
parsed_upload = int(max_upload_mb)
except ValueError:
raise WebUISettingsError("max_upload_mb must be an integer") from None
if parsed_upload < 1 or parsed_upload > 100:
raise WebUISettingsError("max_upload_mb must be between 1 and 100")
if transcription.max_upload_mb != parsed_upload:
transcription.max_upload_mb = parsed_upload
changed = True
return changed
def network_safety_payload(config: Config) -> dict[str, Any]:
"""Return the network-related fields embedded in the advanced DTO."""
return {
"webui_allow_local_service_access": config.tools.webui_allow_local_service_access,
"allow_local_preview_access": config.tools.webui_allow_local_service_access,
"webui_default_access_mode": read_webui_default_access_mode(),
"private_service_protection_enabled": True,
"ssrf_whitelist_count": len(config.tools.ssrf_whitelist),
}
def masked_api_secret(value: str) -> str | None:
value = value.strip()
if not value:
return None
return f"{value[:3]}...{value[-4:]}" if len(value) > 8 else "configured"
def api_runtime_message(message: str) -> str:
known = {
"api_exited_during_startup": "API server exited during startup. Check its log for details.",
"api_stop_timeout": "API server did not stop in time.",
"api_state_stale": "API server state was stale; try starting it again.",
}
if message in known:
return known[message]
if message.startswith("api_"):
return f"API server {message.removeprefix('api_').replace('_', ' ')}"
return message.replace("_", " ")
def api_service_payload(
settings: WebUISettingsServices,
runtime: ApiRuntime,
*,
last_action: str | None = None,
) -> dict[str, Any]:
config = settings.config.load()
status = runtime.status()
extras = optional_dependency_groups()
connect_host = (
"127.0.0.1" if config.api.host in {"0.0.0.0", "::"} else config.api.host
)
payload = {
"installed": extra_installed("api", extras.get("api")),
"running": status.running,
"managed": status.running,
"host": config.api.host,
"port": config.api.port,
"timeout": config.api.timeout,
"api_key_hint": masked_api_secret(config.api.api_key),
"endpoint": f"http://{connect_host}:{config.api.port}/v1",
"command": "nanobot serve",
"log_path": str(status.log_path),
}
if last_action:
payload["last_action"] = last_action
return payload
class CapabilitySettingsHandler:
"""Handle capability commands after transport authentication and decoding."""
def __init__(self, settings: WebUISettingsServices, logger: Any) -> None:
self.settings = settings
self.logger = logger
async def handle(
self,
action: str,
request: SettingsRequest,
operations: CapabilitySettingsOperations,
) -> SettingsRouteResult:
if action == "api-status":
return SettingsRouteResult.success(
api_service_payload(self.settings, operations.api_runtime())
)
if action == "api-start":
return await self._start_api(request, operations)
if action == "api-stop":
return await self._stop_api(operations)
mutation = {
"web-search-update": (
operations.update_web_search,
"browser",
False,
),
"transcription-update": (
operations.update_transcription,
None,
False,
),
"network-update": (
operations.update_network,
"runtime",
False,
),
"image-update": (
operations.update_image,
"image",
True,
),
}.get(action)
if mutation is None:
return SettingsRouteResult.failure(404, "unknown settings action")
operation, section, apply_image_reload = mutation
try:
payload = self.settings.mutate(operation, request.query)
except WebUISettingsError as exc:
return SettingsRouteResult.failure(exc.status, exc.message)
if apply_image_reload:
payload, image_restart_cleared = await self.apply_image_runtime_change(
payload,
operations.reload_image,
)
else:
image_restart_cleared = False
return SettingsRouteResult.success(
payload,
decorate_restart=True,
restart_section=section,
clear_restart_section=("image" if image_restart_cleared else None),
)
async def apply_image_runtime_change(
self,
payload: dict[str, Any],
reload_image: Callable[[], Awaitable[dict[str, Any]]],
) -> tuple[dict[str, Any], bool]:
"""Hot-apply image settings, preserving restart fallback on failure."""
if not payload.get("requires_restart"):
return payload, False
try:
result = await reload_image()
except Exception:
self.logger.exception("failed to hot-reload image generation settings")
return payload, False
applied = bool(result.get("ok")) and not result.get("requires_restart")
updated = dict(payload)
updated["requires_restart"] = not applied
if not applied:
self.logger.warning(
"image generation settings were saved but require restart: {}",
result.get("message") or "hot reload failed",
)
return updated, applied
async def _start_api(
self,
request: SettingsRequest,
operations: CapabilitySettingsOperations,
) -> SettingsRouteResult:
api_key = (request.payload or {}).get("api_key")
if api_key is not None and not isinstance(api_key, str):
return SettingsRouteResult.failure(
400,
"API service API key must be a string",
)
try:
await asyncio.to_thread(
self.settings.mutate,
operations.nanobot_features_action,
"enable",
{"name": ["api"]},
allow_install=self._allow_feature_package_install(request),
)
self.settings.mutate(operations.update_api, request.query)
config = self.settings.config.load()
runtime = operations.api_runtime()
options = ApiStartOptions(
host=config.api.host,
port=config.api.port,
workspace=str(config.workspace_path),
config_path=str(self.settings.config.path),
)
current = runtime.status()
result = await asyncio.to_thread(
runtime.restart if current.running else runtime.start_background,
options,
)
if not result.ok:
return SettingsRouteResult.failure(
500,
api_runtime_message(result.message),
)
except (WebUISettingsError, OptionalFeatureError) as exc:
return SettingsRouteResult.failure(
getattr(exc, "status", 400),
getattr(exc, "message", str(exc)),
)
except Exception as exc:
self.logger.exception("failed to start managed API service")
return SettingsRouteResult.failure(500, str(exc))
return SettingsRouteResult.success(
api_service_payload(
self.settings,
operations.api_runtime(),
last_action="started",
)
)
async def _stop_api(
self,
operations: CapabilitySettingsOperations,
) -> SettingsRouteResult:
runtime = operations.api_runtime()
try:
result = await asyncio.to_thread(runtime.stop)
except Exception as exc:
self.logger.exception("failed to stop managed API service")
return SettingsRouteResult.failure(500, str(exc))
if not result.ok and result.message != "api_not_running":
return SettingsRouteResult.failure(
500,
api_runtime_message(result.message),
)
return SettingsRouteResult.success(
api_service_payload(
self.settings,
operations.api_runtime(),
last_action="stopped",
)
)
def _allow_feature_package_install(self, request: SettingsRequest) -> bool:
if request.local_browser:
return True
try:
return bool(
self.settings.config.load().tools.webui_allow_remote_package_install
)
except Exception:
self.logger.exception("failed to load remote package install policy")
return False
+82
View File
@@ -0,0 +1,82 @@
"""Stable request and error contracts shared by WebUI settings domains."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
QueryParams = dict[str, list[str]]
@dataclass(frozen=True)
class SettingsRequest:
"""Transport-neutral input decoded by the settings route facade."""
query: QueryParams
payload: dict[str, Any] | None = None
local_browser: bool = False
@dataclass(frozen=True)
class SettingsRouteResult:
"""Transport-neutral result returned by a settings domain handler."""
payload: dict[str, Any] | None = None
status: int = 200
error: str | None = None
decorate_restart: bool = False
restart_section: str | None = None
clear_restart_section: str | None = None
restart_payload_key: str | None = None
@classmethod
def success(
cls,
payload: dict[str, Any],
*,
decorate_restart: bool = False,
restart_section: str | None = None,
clear_restart_section: str | None = None,
restart_payload_key: str | None = None,
) -> SettingsRouteResult:
return cls(
payload=payload,
decorate_restart=decorate_restart,
restart_section=restart_section,
clear_restart_section=clear_restart_section,
restart_payload_key=restart_payload_key,
)
@classmethod
def failure(cls, status: int, error: str) -> SettingsRouteResult:
return cls(status=status, error=error)
class WebUISettingsError(ValueError):
"""User-facing settings validation failure."""
def __init__(self, message: str, *, status: int = 400) -> None:
super().__init__(message)
self.message = message
self.status = status
def query_first(query: QueryParams, key: str) -> str | None:
values = query.get(key)
return values[0] if values else None
def query_first_alias(query: QueryParams, snake: str, camel: str) -> str | None:
value = query_first(query, snake)
return query_first(query, camel) if value is None else value
def query_has_alias(query: QueryParams, snake: str, camel: str) -> bool:
return snake in query or camel in query
def parse_bool(value: str, field: str) -> bool:
normalized = value.strip().lower()
if normalized not in {"1", "0", "true", "false", "yes", "no"}:
raise WebUISettingsError(f"{field} must be boolean")
return normalized in {"1", "true", "yes"}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+148
View File
@@ -0,0 +1,148 @@
"""Gateway-owned state for the WebUI settings surface."""
from __future__ import annotations
import threading
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any, TypeVar
from nanobot.config.loader import load_config, save_config
from nanobot.config.schema import Config
_T = TypeVar("_T")
_WEBUI_OAUTH_MAX_FLOWS = 8
class WebUISettingsConfig:
"""Instance-scoped config access with serialized read-modify-write operations."""
def __init__(self, config_path: Path) -> None:
self.path = config_path.expanduser().resolve(strict=False)
self._lock = threading.RLock()
def load(self) -> Config:
"""Load this gateway's config without consulting the process-global path."""
with self._lock:
return load_config(self.path)
def update(self, mutation: Callable[[Config], _T]) -> _T:
"""Apply and atomically persist one in-process read-modify-write operation."""
with self._lock:
config = load_config(self.path)
result = mutation(config)
save_config(config, self.path)
return result
def run_serialized(self, operation: Callable[[Path], _T]) -> _T:
"""Run a path-aware read-modify-write operation under the instance lock."""
with self._lock:
return operation(self.path)
class WebUIOAuthFlowRegistry:
"""Bounded, thread-safe OAuth flows owned by one gateway instance."""
def __init__(self, *, max_flows: int = _WEBUI_OAUTH_MAX_FLOWS) -> None:
if max_flows < 1:
raise ValueError("max_flows must be at least one")
self._max_flows = max_flows
self._flows: dict[str, tuple[str, Any]] = {}
self._lock = threading.Lock()
def register(self, provider_name: str, flow_id: str, flow: Any) -> None:
discarded: list[Any] = []
with self._lock:
for existing_id, (_provider_name, existing) in list(self._flows.items()):
if existing.expired:
discarded.append(self._flows.pop(existing_id)[1])
while len(self._flows) >= self._max_flows:
oldest_id = next(iter(self._flows))
discarded.append(self._flows.pop(oldest_id)[1])
self._flows[flow_id] = (provider_name, flow)
for existing in discarded:
existing.cancel()
def get(self, provider_name: str, flow_id: str) -> Any | None:
with self._lock:
registered = self._flows.get(flow_id)
if registered is None or registered[0] != provider_name:
return None
flow = registered[1]
if not flow.expired:
return flow
self._flows.pop(flow_id, None)
flow.cancel()
return None
def remove(
self,
provider_name: str,
flow_id: str,
flow: Any,
*,
cancel: bool = True,
) -> None:
with self._lock:
registered = self._flows.get(flow_id)
if (
registered is not None
and registered[0] == provider_name
and registered[1] is flow
):
self._flows.pop(flow_id)
if cancel:
flow.cancel()
def clear(self, provider_name: str) -> None:
with self._lock:
flow_ids = [
flow_id
for flow_id, (registered_provider, _flow) in self._flows.items()
if registered_provider == provider_name
]
flows = [self._flows.pop(flow_id)[1] for flow_id in flow_ids]
for flow in flows:
flow.cancel()
@dataclass(frozen=True)
class WebUISettingsServices:
"""Settings dependencies composed once for a gateway instance."""
config: WebUISettingsConfig
oauth_flows: WebUIOAuthFlowRegistry
@classmethod
def create(cls, config_path: Path) -> WebUISettingsServices:
return cls(
config=WebUISettingsConfig(config_path),
oauth_flows=WebUIOAuthFlowRegistry(),
)
def read(
self,
operation: Callable[..., _T],
/,
*args: Any,
**kwargs: Any,
) -> _T:
"""Run a settings read against this gateway's explicit config path."""
return operation(*args, config_path=self.config.path, **kwargs)
def mutate(
self,
operation: Callable[..., _T],
/,
*args: Any,
**kwargs: Any,
) -> _T:
"""Serialize a path-aware settings read-modify-write operation."""
return self.config.run_serialized(
lambda config_path: operation(
*args,
config_path=config_path,
**kwargs,
)
)
+957
View File
@@ -0,0 +1,957 @@
"""System and channel settings domain logic."""
from __future__ import annotations
import asyncio
import inspect
import re
import time
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, TypedDict, cast
from zoneinfo import ZoneInfo
from nanobot.channels._setup import channel_setup_spec
from nanobot.channels.connect import ChannelConnectError
from nanobot.channels.contracts import (
RouteFieldType,
channel_instance_config,
channel_update_instance_config,
)
from nanobot.config.schema import Config
from nanobot.optional_features import OptionalFeatureError, with_channel_runtime_status
from nanobot.security.workspace_access import workspace_sandbox_status
from nanobot.webui.settings_capabilities import network_safety_payload
from nanobot.webui.settings_contracts import (
QueryParams,
SettingsRequest,
SettingsRouteResult,
WebUISettingsError,
query_first,
query_first_alias,
)
from nanobot.webui.token_usage import token_usage_payload
if TYPE_CHECKING:
from nanobot.webui.settings_services import WebUISettingsServices
LoadChannelPlugin = Callable[[str], Any]
ListPendingPairings = Callable[[], Iterable[dict[str, Any]]]
SettingsOperation = Callable[..., Any]
@dataclass(frozen=True)
class SystemSettingsOperations:
cli_apps_payload: SettingsOperation
cli_apps_action: SettingsOperation
nanobot_features_payload: SettingsOperation
nanobot_features_action: SettingsOperation
nanobot_feature_instance_target: SettingsOperation
validate_channel_config: SettingsOperation
load_channel_plugin: LoadChannelPlugin
list_pending: ListPendingPairings
approve_code: SettingsOperation
deny_code: SettingsOperation
mcp_presets_action: SettingsOperation
reload_mcp: SettingsOperation
check_for_update: SettingsOperation
channel_feature_action: SettingsOperation | None = None
channel_runtime_status: Callable[[], dict[str, Any]] | None = None
class SystemSettingsPayload(TypedDict):
runtime: dict[str, Any]
usage: dict[str, Any]
advanced: dict[str, Any]
version: dict[str, Any]
docs: dict[str, Any]
_DOCS_STABLE_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:\.post\d+)?$")
_DOCS_LATEST_URL = "https://nanobot.wiki/docs/latest"
_SKIP_FIELD = object()
def docs_version(version: str) -> str:
"""Map package versions to the matching public docs path."""
normalized = version.strip()
if _DOCS_STABLE_VERSION_RE.fullmatch(normalized):
return normalized
return "latest"
def docs_payload(version: str) -> dict[str, Any]:
selected_version = docs_version(version)
base_url = f"https://nanobot.wiki/docs/{selected_version}"
return {
"version": selected_version,
"base_url": base_url,
"chat_apps_url": f"{base_url}/getting-started/chat-apps",
"latest_url": _DOCS_LATEST_URL,
}
def system_settings_payload(
config: Config,
*,
config_path: Path,
version: str,
) -> SystemSettingsPayload:
defaults = config.agents.defaults
exec_config = config.tools.exec
sandbox_status = workspace_sandbox_status(
restrict_to_workspace=config.tools.restrict_to_workspace,
workspace=config.workspace_path,
)
return {
"runtime": {
"config_path": str(config_path.expanduser()),
"workspace_path": str(config.workspace_path),
"gateway_host": config.gateway.host,
"gateway_port": config.gateway.port,
"heartbeat": {
"enabled": config.gateway.heartbeat.enabled,
"interval_s": config.gateway.heartbeat.interval_s,
"keep_recent_messages": config.gateway.heartbeat.keep_recent_messages,
},
"dream": {
"schedule": defaults.dream.describe_schedule(),
},
"unified_session": defaults.unified_session,
},
"usage": token_usage_payload(timezone_name=defaults.timezone),
"advanced": {
"restrict_to_workspace": config.tools.restrict_to_workspace,
"workspace_sandbox": sandbox_status.as_dict(),
**network_safety_payload(config),
"mcp_server_count": len(config.tools.mcp_servers),
"exec_enabled": exec_config.enable,
"exec_sandbox": exec_config.sandbox or None,
"exec_path_prepend_set": bool(exec_config.path_prepend),
"exec_path_append_set": bool(exec_config.path_append),
},
"version": {"current": version},
"docs": docs_payload(version),
}
def settings_usage_payload(config: Config) -> dict[str, Any]:
"""Return the lightweight token usage slice for Overview refreshes."""
return token_usage_payload(timezone_name=config.agents.defaults.timezone)
def update_agent_system_settings(config: Config, query: QueryParams) -> tuple[bool, bool]:
defaults = config.agents.defaults
changed = False
restart_required = False
timezone = query_first(query, "timezone")
if timezone is not None:
timezone = timezone.strip()
if not timezone:
raise WebUISettingsError("timezone is required")
try:
ZoneInfo(timezone)
except Exception:
raise WebUISettingsError("invalid timezone") from None
timezone_changed = defaults.timezone != timezone
if timezone_changed or defaults.timezone_mode != "manual":
defaults.timezone = timezone
defaults.timezone_mode = "manual"
changed = True
restart_required = timezone_changed
tool_hint_max_length = query_first_alias(
query,
"tool_hint_max_length",
"toolHintMaxLength",
)
if tool_hint_max_length is not None:
try:
parsed = int(tool_hint_max_length)
except ValueError:
raise WebUISettingsError(
"tool_hint_max_length must be an integer"
) from None
if parsed < 20 or parsed > 500:
raise WebUISettingsError(
"tool_hint_max_length must be between 20 and 500"
)
if defaults.tool_hint_max_length != parsed:
defaults.tool_hint_max_length = parsed
changed = True
restart_required = True
return changed, restart_required
def save_channel_config_values(
config: Config,
name: str,
raw_values: dict[str, Any],
instance_id: str = "default",
*,
load_channel_plugin: LoadChannelPlugin,
) -> list[str]:
if not name:
raise WebUISettingsError("missing channel name")
try:
plugin = load_channel_plugin(name)
except ImportError:
raise WebUISettingsError(f"unknown channel '{name}'", status=404) from None
setup_spec = channel_setup_spec(name, plugin=plugin)
if setup_spec is None:
raise WebUISettingsError(
f"channel '{name}' cannot be configured from WebUI",
status=404,
)
field_types = setup_spec.route_field_types
if not raw_values:
return []
section = getattr(config.channels, name, None)
channel_config = channel_instance_config(
plugin,
section,
instance_id=instance_id,
)
saved: list[str] = []
prefix = f"channels.{name}."
for raw_key, raw_value in raw_values.items():
if not raw_key:
raise WebUISettingsError(
"channel settings payload contains an invalid key"
)
field = raw_key[len(prefix) :] if raw_key.startswith(prefix) else raw_key
value_type = field_types.get(field)
if value_type is None:
raise WebUISettingsError(f"'{raw_key}' cannot be configured from WebUI")
value = coerce_channel_value(raw_key, raw_value, value_type)
if value is _SKIP_FIELD:
continue
assign_channel_config_value(channel_config, field, value)
saved.append(raw_key)
try:
updated_section = channel_update_instance_config(
plugin,
section,
channel_config,
instance_id=instance_id,
)
except ValueError as exc:
raise WebUISettingsError(
f"Invalid {name} configuration: {exc}",
status=400,
) from exc
setattr(config.channels, name, updated_section)
return saved
def coerce_channel_value(
raw_key: str,
raw_value: Any,
value_type: RouteFieldType,
) -> Any:
if isinstance(value_type, tuple):
kind = value_type[0]
allowed = value_type[1]
else:
kind = value_type
allowed = None
if kind in {"string", "secret"}:
value = raw_value.strip() if isinstance(raw_value, str) else str(raw_value)
if kind == "secret" and not value:
return _SKIP_FIELD
return value
if kind == "list":
if raw_value is None:
return []
if isinstance(raw_value, str):
return [item.strip() for item in raw_value.split(",") if item.strip()]
if isinstance(raw_value, list):
return [
str(item).strip()
for item in cast(list[Any], raw_value)
if str(item).strip()
]
raise WebUISettingsError(f"'{raw_key}' must be a comma-separated list")
if kind == "int":
if raw_value in (None, ""):
return _SKIP_FIELD
try:
return int(raw_value)
except (TypeError, ValueError) as exc:
raise WebUISettingsError(f"'{raw_key}' must be a number") from exc
if kind == "bool":
if isinstance(raw_value, bool):
return raw_value
value = str(raw_value).strip().lower()
if value in {"true", "1", "yes", "on"}:
return True
if value in {"false", "0", "no", "off"}:
return False
raise WebUISettingsError(f"'{raw_key}' must be true or false")
if kind == "enum":
value = raw_value.strip() if isinstance(raw_value, str) else str(raw_value)
if not value:
return _SKIP_FIELD
if allowed is None or value not in allowed:
options = ", ".join(sorted(allowed or ()))
raise WebUISettingsError(f"'{raw_key}' must be one of: {options}")
return value
raise WebUISettingsError(f"'{raw_key}' has an unsupported field type")
def assign_channel_config_value(
channel_config: dict[str, Any],
field: str,
value: Any,
) -> None:
target = channel_config
parts = field.split(".")
for part in parts[:-1]:
current: object = target.get(part)
if not isinstance(current, dict):
current = {}
target[part] = current
target = cast(dict[str, Any], current)
target[parts[-1]] = value
def pairing_payload(
list_pending: ListPendingPairings,
last_action: dict[str, Any] | None = None,
*,
now: float | None = None,
) -> dict[str, Any]:
current_time = time.time() if now is None else now
requests: list[dict[str, Any]] = []
for item in list_pending():
expires_at = float(item.get("expires_at", 0) or 0)
created_at = float(item.get("created_at", 0) or 0)
requests.append(
{
"code": str(item.get("code", "")),
"channel": str(item.get("channel", "")),
"sender_id": str(item.get("sender_id", "")),
"created_at_ms": int(created_at * 1000) if created_at else None,
"expires_at_ms": int(expires_at * 1000) if expires_at else None,
"expires_in_seconds": (
max(0, int(expires_at - current_time)) if expires_at else None
),
}
)
payload: dict[str, Any] = {"requests": requests}
if last_action is not None:
payload["last_action"] = last_action
return payload
class SystemSettingsHandler:
"""Handle channel and system commands behind a transport-neutral request DTO."""
def __init__(self, settings: WebUISettingsServices, logger: Any) -> None:
self.settings = settings
self.logger = logger
self._channel_connectors: dict[str, Any] = {}
async def handle(
self,
action: str,
request: SettingsRequest,
operations: SystemSettingsOperations,
*,
channel_name: str | None = None,
connect_action: str | None = None,
) -> SettingsRouteResult:
if action == "cli-list":
return await self._cli_apps(request, operations)
if action.startswith("cli-"):
return await self._cli_apps_action(
request,
action.removeprefix("cli-"),
operations,
)
if action == "features-list":
return await self._features(operations)
if action in {"features-enable", "features-disable"}:
return await self._features_action(
request,
action.removeprefix("features-"),
operations,
)
if action == "channel-validate":
return await self._channel_validate(request, operations)
if action == "channel-configure":
return await self._channel_configure(request, operations)
if action == "channel-connect" and channel_name and connect_action:
return await self._channel_connect(
request,
channel_name,
connect_action,
operations,
)
if action == "pairing-list":
return SettingsRouteResult.success(pairing_payload(operations.list_pending))
if action in {"pairing-approve", "pairing-deny"}:
return self._pairing_action(
request,
action.removeprefix("pairing-"),
operations,
)
if action == "mcp-list":
return await self._mcp_presets(request, None, operations)
if action.startswith("mcp-"):
return await self._mcp_presets(
request,
action.removeprefix("mcp-"),
operations,
)
if action == "version-check":
return await self._version_check(operations)
return SettingsRouteResult.failure(404, "unknown settings action")
async def _cli_apps(
self,
request: SettingsRequest,
operations: SystemSettingsOperations,
) -> SettingsRouteResult:
installed_only = (query_first(request.query, "installed_only") or "").lower() in {
"1",
"true",
"yes",
}
try:
payload = await operations.cli_apps_payload(
installed_only=installed_only,
config_path=self.settings.config.path,
)
except Exception:
self.logger.exception("failed to load CLI Apps payload")
return SettingsRouteResult.failure(500, "failed to load CLI Apps")
return SettingsRouteResult.success(payload)
async def _cli_apps_action(
self,
request: SettingsRequest,
action: str,
operations: SystemSettingsOperations,
) -> SettingsRouteResult:
try:
payload = await asyncio.to_thread(
operations.cli_apps_action,
action,
request.query,
config_path=self.settings.config.path,
)
except WebUISettingsError as exc:
return SettingsRouteResult.failure(exc.status, exc.message)
except Exception as exc:
status = getattr(exc, "status", 500)
message = getattr(exc, "message", str(exc))
if status >= 500:
self.logger.exception("CLI Apps action '{}' failed", action)
return SettingsRouteResult.failure(status, message)
return SettingsRouteResult.success(payload)
async def _features(
self,
operations: SystemSettingsOperations,
) -> SettingsRouteResult:
try:
payload = await asyncio.to_thread(
operations.nanobot_features_payload,
config_path=self.settings.config.path,
)
except Exception:
self.logger.exception("failed to load nanobot features")
return SettingsRouteResult.failure(500, "failed to load nanobot features")
return SettingsRouteResult.success(
self._with_channel_runtime_status(payload, operations)
)
def _nanobot_features_payload(
self,
operations: SystemSettingsOperations,
) -> dict[str, Any]:
return operations.nanobot_features_payload(config_path=self.settings.config.path)
def _nanobot_features_action(
self,
action: str,
query: QueryParams,
operations: SystemSettingsOperations,
*,
allow_install: bool = True,
) -> dict[str, Any]:
return self.settings.mutate(
operations.nanobot_features_action,
action,
query,
allow_install=allow_install,
)
async def _features_action(
self,
request: SettingsRequest,
action: str,
operations: SystemSettingsOperations,
) -> SettingsRouteResult:
try:
payload = await asyncio.to_thread(
self._nanobot_features_action,
action,
request.query,
operations,
allow_install=(
action != "enable"
or self.allow_feature_package_install(request)
),
)
except OptionalFeatureError as exc:
return SettingsRouteResult.failure(exc.status, exc.message)
except Exception as exc:
status = getattr(exc, "status", 500)
message = getattr(exc, "message", str(exc))
if status >= 500:
self.logger.exception(
"nanobot feature action '{}' failed",
action,
)
return SettingsRouteResult.failure(status, message)
payload = await self._apply_feature_runtime_change(
action,
request.query,
payload,
operations,
)
payload = self._with_channel_runtime_status(payload, operations)
return SettingsRouteResult.success(
payload,
decorate_restart=True,
restart_section="runtime",
)
def _with_channel_runtime_status(
self,
payload: dict[str, Any],
operations: SystemSettingsOperations,
) -> dict[str, Any]:
if operations.channel_runtime_status is None:
return payload
try:
return with_channel_runtime_status(
payload,
operations.channel_runtime_status(),
)
except Exception:
self.logger.exception("failed to load channel runtime status")
return payload
async def _apply_feature_runtime_change(
self,
action: str,
query: QueryParams,
payload: dict[str, Any],
operations: SystemSettingsOperations,
) -> dict[str, Any]:
if operations.channel_feature_action is None:
return payload
name = (query_first(query, "name") or "").strip()
if not name:
return payload
try:
instance_id = operations.nanobot_feature_instance_target(query)
result = operations.channel_feature_action(action, name, instance_id)
if inspect.isawaitable(result):
result = await result
except Exception as exc:
self.logger.exception("failed to apply channel '{}' without restart", name)
return self.feature_runtime_fallback(
payload,
message=(
f"{name} channel config was saved, but hot reload failed: {exc}"
),
)
if not isinstance(result, dict):
return payload
result = cast(dict[str, Any], result)
if not result.get("handled"):
return payload
updated = dict(payload)
updated["requires_restart"] = bool(result.get("requires_restart"))
message = result.get("message")
if isinstance(message, str) and message:
last_action = dict(updated.get("last_action") or {})
previous = last_action.get("message")
last_action["message"] = (
f"{previous}. {message}"
if isinstance(previous, str) and previous
else message
)
last_action["hot_reload"] = not updated["requires_restart"]
if "ok" in result:
last_action["ok"] = bool(result["ok"])
updated["last_action"] = last_action
return updated
@staticmethod
def feature_runtime_fallback(
payload: dict[str, Any],
*,
message: str,
) -> dict[str, Any]:
updated = dict(payload)
updated["requires_restart"] = True
last_action = dict(updated.get("last_action") or {})
previous = last_action.get("message")
last_action["message"] = (
f"{previous}. {message}"
if isinstance(previous, str) and previous
else message
)
last_action["hot_reload"] = False
updated["last_action"] = last_action
return updated
async def _channel_configure(
self,
request: SettingsRequest,
operations: SystemSettingsOperations,
) -> SettingsRouteResult:
name = (query_first(request.query, "name") or "").strip()
instance_id = (
query_first(request.query, "instance_id") or "default"
).strip()
enable = (query_first(request.query, "enable") or "").strip().lower() in {
"1",
"true",
"yes",
}
try:
saved = await asyncio.to_thread(
self._save_channel_config_values,
name,
self.parse_channel_values(request),
instance_id,
operations,
)
except WebUISettingsError as exc:
return SettingsRouteResult.failure(exc.status, exc.message)
except Exception:
self.logger.exception("failed to save channel '{}' settings", name)
return SettingsRouteResult.failure(500, "failed to save channel settings")
payload: dict[str, Any] = {
"name": name,
"saved": True,
"saved_keys": saved,
}
if not enable:
features = await asyncio.to_thread(
self._nanobot_features_payload,
operations,
)
payload["nanobot_features"] = self._with_channel_runtime_status(
features,
operations,
)
return SettingsRouteResult.success(
payload,
decorate_restart=True,
restart_section="runtime",
restart_payload_key="nanobot_features",
)
feature_query = {"name": [name]}
if instance_id:
feature_query["instance_id"] = [instance_id]
try:
features = await asyncio.to_thread(
self._nanobot_features_action,
"enable",
feature_query,
operations,
allow_install=self.allow_feature_package_install(request),
)
except OptionalFeatureError as exc:
return SettingsRouteResult.failure(
exc.status,
f"Settings saved, but {exc.message}",
)
except Exception as exc:
self.logger.exception(
"failed to enable channel '{}' after settings save",
name,
)
return SettingsRouteResult.failure(
500,
f"Settings saved, but enabling {name} failed: {exc}",
)
features = await self._apply_feature_runtime_change(
"enable",
feature_query,
features,
operations,
)
payload["nanobot_features"] = self._with_channel_runtime_status(
features,
operations,
)
return SettingsRouteResult.success(
payload,
decorate_restart=True,
restart_section="runtime",
restart_payload_key="nanobot_features",
)
async def _channel_validate(
self,
request: SettingsRequest,
operations: SystemSettingsOperations,
) -> SettingsRouteResult:
name = (query_first(request.query, "name") or "").strip()
instance_id = (
query_first(request.query, "instance_id") or "default"
).strip()
try:
payload = await asyncio.to_thread(
operations.validate_channel_config,
name,
self.parse_channel_values(request),
instance_id=instance_id,
)
except WebUISettingsError as exc:
return SettingsRouteResult.failure(exc.status, exc.message)
except Exception:
self.logger.exception("failed to validate channel '{}' settings", name)
return SettingsRouteResult.failure(
500,
"failed to validate channel settings",
)
return SettingsRouteResult.success(payload)
@staticmethod
def parse_channel_values(request: SettingsRequest) -> dict[str, Any]:
if request.payload is None or "values" not in request.payload:
return {}
values = request.payload.get("values")
if not isinstance(values, dict):
raise WebUISettingsError(
"channel settings payload must be a JSON object"
)
return cast(dict[str, Any], values)
def _save_channel_config_values(
self,
name: str,
raw_values: dict[str, Any],
instance_id: str,
operations: SystemSettingsOperations,
) -> list[str]:
return self.settings.config.update(
lambda config: save_channel_config_values(
config,
name,
raw_values,
instance_id,
load_channel_plugin=operations.load_channel_plugin,
)
)
async def _channel_connect(
self,
request: SettingsRequest,
channel_name: str,
action: str,
operations: SystemSettingsOperations,
) -> SettingsRouteResult:
try:
connector = self._channel_connectors.get(channel_name)
if connector is None:
plugin = operations.load_channel_plugin(channel_name)
connector = plugin.load_connector()
self._channel_connectors[channel_name] = connector
except ImportError:
return SettingsRouteResult.failure(
404,
f"channel '{channel_name}' does not support connect",
)
try:
payload = await connector.handle(action, request.query)
except ChannelConnectError as exc:
return SettingsRouteResult.failure(exc.status, exc.message)
except Exception:
self.logger.exception(
"failed to run {} WebUI connect action for {}",
action,
channel_name,
)
return SettingsRouteResult.failure(
500,
f"failed to {action} {channel_name} connection",
)
if payload.get("status") != "succeeded":
return SettingsRouteResult.success(payload)
payload = await self._with_channel_connect_success(
request,
channel_name,
payload,
operations,
)
return SettingsRouteResult.success(
payload,
decorate_restart=True,
restart_section="runtime",
restart_payload_key="nanobot_features",
)
async def _with_channel_connect_success(
self,
request: SettingsRequest,
channel_name: str,
payload: dict[str, Any],
operations: SystemSettingsOperations,
) -> dict[str, Any]:
target = {"name": [channel_name]}
if payload.get("instance_id"):
target["instance_id"] = [str(payload["instance_id"])]
try:
features = await asyncio.to_thread(
self._nanobot_features_action,
"enable",
target,
operations,
allow_install=self.allow_feature_package_install(request),
)
except OptionalFeatureError as exc:
features = self.feature_runtime_fallback(
self._nanobot_features_payload(operations),
message=(
f"{channel_name} connected, but enabling channel support failed: "
f"{exc.message}"
),
)
else:
features = await self._apply_feature_runtime_change(
"enable",
target,
features,
operations,
)
updated = dict(payload)
updated["nanobot_features"] = self._with_channel_runtime_status(
features,
operations,
)
return updated
def allow_feature_package_install(self, request: SettingsRequest) -> bool:
if request.local_browser:
return True
try:
return bool(
self.settings.config.load().tools.webui_allow_remote_package_install
)
except Exception:
self.logger.exception("failed to load remote package install policy")
return False
def _pairing_action(
self,
request: SettingsRequest,
action: str,
operations: SystemSettingsOperations,
) -> SettingsRouteResult:
code = (query_first(request.query, "code") or "").strip()
if not code:
return SettingsRouteResult.failure(400, "Missing pairing code")
if action == "approve":
result = operations.approve_code(code)
if result is None:
return SettingsRouteResult.failure(
404,
"Pairing code not found or expired",
)
channel, sender_id = result
return SettingsRouteResult.success(
pairing_payload(
operations.list_pending,
{
"ok": True,
"action": "approve",
"message": f"Approved {sender_id} for {channel}",
"channel": channel,
"sender_id": sender_id,
"code": code,
},
)
)
if not operations.deny_code(code):
return SettingsRouteResult.failure(
404,
"Pairing code not found or expired",
)
return SettingsRouteResult.success(
pairing_payload(
operations.list_pending,
{
"ok": True,
"action": "deny",
"message": f"Denied pairing code {code}",
"code": code,
},
)
)
async def _mcp_presets(
self,
request: SettingsRequest,
action: str | None,
operations: SystemSettingsOperations,
) -> SettingsRouteResult:
try:
payload = await operations.mcp_presets_action(
action,
request.query,
reload_mcp=operations.reload_mcp,
config=self.settings.config,
)
except Exception as exc:
status = getattr(exc, "status", 500)
message = getattr(exc, "message", str(exc))
if status >= 500:
self.logger.exception(
"MCP preset action '{}' failed",
action or "list",
)
return SettingsRouteResult.failure(status, message)
return SettingsRouteResult.success(
payload,
decorate_restart=action is not None,
restart_section="runtime" if action is not None else None,
)
async def _version_check(
self,
operations: SystemSettingsOperations,
) -> SettingsRouteResult:
try:
update_info = await asyncio.to_thread(operations.check_for_update)
except Exception:
self.logger.exception("version check failed")
return SettingsRouteResult.failure(500, "version check failed")
return SettingsRouteResult.success({"updateAvailable": update_info})
+8 -1
View File
@@ -10,6 +10,7 @@ import time
from typing import Any
import httpx
from packaging.version import InvalidVersion, Version
from nanobot import __version__
@@ -42,7 +43,13 @@ def check_for_update() -> dict[str, Any] | None:
return None
_cache = (now, latest)
if not latest or latest == __version__:
if not isinstance(latest, str) or not latest:
return None
try:
if Version(latest) <= Version(__version__):
return None
except InvalidVersion:
logger.debug("PyPI returned an invalid nanobot version: %r", latest)
return None
return {
"currentVersion": __version__,
+175 -25
View File
@@ -17,9 +17,10 @@ import time
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
from urllib.parse import unquote
from urllib.parse import quote, unquote
from loguru import logger
from websockets.datastructures import Headers
from websockets.http11 import Request as WsRequest
from websockets.http11 import Response
@@ -118,7 +119,60 @@ from nanobot.webui.transcript import build_webui_thread_response
from nanobot.webui.workspaces import WebUIWorkspaceController
_SLOW_WEBUI_HTTP_LOG_MS = 1_000
_AUTOMATION_VALUES_HEADER = "X-Nanobot-Automation-Values"
_WEBUI_MUTATION_PAYLOAD_ATTR = "_nanobot_webui_mutation_payload"
_WEBUI_MUTATION_REQUEST_ATTR = "_nanobot_webui_mutation_request"
_WEBUI_MUTATION_PATHS = {
"automation.enable": "/api/webui/automations/enable",
"automation.disable": "/api/webui/automations/disable",
"automation.delete": "/api/webui/automations/delete",
"automation.run": "/api/webui/automations/run",
"automation.update": "/api/webui/automations/update",
"skill.install": "/api/webui/skills/install",
"skill.update": "/api/webui/skills/update",
"skill.delete": "/api/webui/skills/delete",
"sidebar.update": "/api/webui/sidebar-state/update",
"settings.agent.update": "/api/settings/update",
"settings.model_configuration.create": "/api/settings/model-configurations/create",
"settings.model_configuration.update": "/api/settings/model-configurations/update",
"settings.model_configuration.delete": "/api/settings/model-configurations/delete",
"settings.model_configuration.migrate": "/api/settings/model-configurations/migrate",
"settings.model_call_order.update": "/api/settings/model-call-order/update",
"settings.provider.update": "/api/settings/provider/update",
"settings.provider.create": "/api/settings/provider/create",
"settings.provider.oauth_login": "/api/settings/provider/oauth-login",
"settings.provider.oauth_complete": "/api/settings/provider/oauth-login/complete",
"settings.provider.oauth_logout": "/api/settings/provider/oauth-logout",
"settings.web_search.update": "/api/settings/web-search/update",
"settings.api_service.start": "/api/settings/api-service/start",
"settings.api_service.stop": "/api/settings/api-service/stop",
"settings.image_generation.update": "/api/settings/image-generation/update",
"settings.transcription.update": "/api/settings/transcription/update",
"settings.network_safety.update": "/api/settings/network-safety/update",
"settings.cli_app.install": "/api/settings/cli-apps/install",
"settings.cli_app.update": "/api/settings/cli-apps/update",
"settings.cli_app.uninstall": "/api/settings/cli-apps/uninstall",
"settings.cli_app.test": "/api/settings/cli-apps/test",
"settings.feature.enable": "/api/settings/nanobot-features/enable",
"settings.feature.disable": "/api/settings/nanobot-features/disable",
"settings.channel.validate": "/api/settings/channels/validate",
"settings.channel.configure": "/api/settings/channels/configure",
"settings.pairing.approve": "/api/settings/pairing/approve",
"settings.pairing.deny": "/api/settings/pairing/deny",
"settings.mcp.enable": "/api/settings/mcp-presets/enable",
"settings.mcp.remove": "/api/settings/mcp-presets/remove",
"settings.mcp.test": "/api/settings/mcp-presets/test",
"settings.mcp.custom": "/api/settings/mcp-presets/custom",
"settings.mcp.import": "/api/settings/mcp-presets/import",
"settings.mcp.import_cursor": "/api/settings/mcp-presets/import-cursor",
"settings.mcp.tools": "/api/settings/mcp-presets/tools",
}
_WEBUI_CHANNEL_CONNECT_ACTIONS = {
"settings.channel.connect.start": "start",
"settings.channel.connect.poll": "poll",
"settings.channel.connect.cancel": "cancel",
}
# Fix for #5190: On Windows, mimetypes.guess_type() reads the registry key
# HKEY_CLASSES_ROOT\.js\Content Type, which is commonly set to 'text/plain'
@@ -150,6 +204,7 @@ if TYPE_CHECKING:
from nanobot.cron.service import CronService
from nanobot.session.manager import SessionManager
from nanobot.triggers.local_store import LocalTriggerStore
from nanobot.webui.settings_services import WebUISettingsServices
def _decode_api_key(raw_key: str) -> str | None:
key = unquote(raw_key)
@@ -159,6 +214,33 @@ def _decode_api_key(raw_key: str) -> str | None:
return key
def _mutation_payload(request: WsRequest) -> dict[str, Any] | None:
payload = getattr(request, _WEBUI_MUTATION_PAYLOAD_ATTR, None)
if not isinstance(payload, dict):
return None
return cast(dict[str, Any], payload)
def _request_query(request: WsRequest) -> dict[str, list[str]]:
payload = _mutation_payload(request)
if payload is None:
return _parse_query(request.path)
query: dict[str, list[str]] = {}
for key, value in payload.items():
if not key:
continue
if isinstance(value, bool):
text = "true" if value else "false"
elif value is None:
text = ""
elif isinstance(value, (dict, list)):
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
else:
text = str(value)
query[key] = [text]
return query
def _default_model_name_from_config() -> str | None:
try:
from nanobot.config.loader import load_config
@@ -211,6 +293,7 @@ class GatewayHTTPHandler:
media: WebUIMediaGateway,
ingress: WebUIIngressPolicy,
workspaces: WebUIWorkspaceController,
settings: WebUISettingsServices,
skills_workspace_path: Path,
disabled_skills: set[str] | None = None,
cron_service: CronService | None = None,
@@ -231,6 +314,7 @@ class GatewayHTTPHandler:
self.media = media
self.ingress = ingress
self.workspaces = workspaces
self.settings = settings
self.skills_workspace_path = skills_workspace_path
self.disabled_skills: set[str] = (
disabled_skills if disabled_skills is not None else set()
@@ -249,6 +333,7 @@ class GatewayHTTPHandler:
self._capabilities = _rc(runtime_surface, runtime_capabilities_overrides or {})
self.settings_routes = WebUISettingsRouter(
settings=settings,
bus=bus,
logger=self._log,
check_api_token=self.check_api_token,
@@ -285,11 +370,86 @@ class GatewayHTTPHandler:
)
try:
if self._is_webui_mutation_path(got):
return _http_error(
405,
"WebUI mutations require an authenticated WebSocket",
)
response = await self._dispatch_resolved(connection, request, got)
return response
finally:
self._log_slow_http(got, response, started)
async def dispatch_webui_mutation(
self,
connection: Any,
action: str,
payload: dict[str, Any],
) -> Response:
"""Run one explicitly allowlisted mutation for an authenticated WebUI socket."""
path = self._webui_mutation_path(action, payload)
if isinstance(path, Response):
return path
source_request = getattr(connection, "request", None)
source_headers = getattr(source_request, "headers", None)
if source_headers is None:
headers = Headers()
else:
try:
headers = Headers(source_headers.raw_items())
except (AttributeError, TypeError):
try:
headers = Headers(source_headers)
except TypeError:
headers = Headers()
request = WsRequest(path, headers)
setattr(request, "_nanobot_trusted_proxy_authenticated", True)
setattr(request, _WEBUI_MUTATION_REQUEST_ATTR, True)
setattr(request, _WEBUI_MUTATION_PAYLOAD_ATTR, dict(payload))
response = await self._dispatch_resolved(connection, request, path)
if isinstance(response, Response):
return response
return _http_error(404, "WebUI mutation action not found")
def _is_webui_mutation_path(self, path: str) -> bool:
if self.settings_routes.is_mutation_path(path):
return True
if re.match(r"^/api/sessions/[^/]+/delete$", path):
return True
if re.match(r"^/api/webui/automations/(enable|disable|delete|run|update)$", path):
return True
return path in {
"/api/webui/skills/install",
"/api/webui/skills/update",
"/api/webui/skills/delete",
"/api/webui/sidebar-state/update",
}
@staticmethod
def _webui_mutation_path(
action: str,
payload: dict[str, Any],
) -> str | Response:
path = _WEBUI_MUTATION_PATHS.get(action)
if path is not None:
return path
if action == "session.delete":
key = payload.get("key")
if not isinstance(key, str) or not key.strip():
return _http_error(400, "missing session key")
return f"/api/sessions/{quote(key, safe='')}/delete"
connect_action = _WEBUI_CHANNEL_CONNECT_ACTIONS.get(action)
if connect_action is not None:
channel = payload.get("channel")
if not isinstance(channel, str) or re.fullmatch(
r"[A-Za-z0-9_-]{1,64}",
channel,
) is None:
return _http_error(400, "invalid channel name")
return f"/api/settings/channels/{channel}/connect/{connect_action}"
return _http_error(404, "unknown WebUI mutation action")
async def _dispatch_resolved(
self,
connection: Any,
@@ -646,7 +806,7 @@ class GatewayHTTPHandler:
return _http_error(400, "invalid session key")
if not _is_websocket_channel_session_key(decoded_key):
return _http_error(404, "session not found")
query = _parse_query(request.path)
query = _request_query(request)
delete_automations = (_query_first(query, "delete_automations") or "").lower()
automation_jobs = session_automation_jobs(
self.cron_service,
@@ -742,7 +902,7 @@ class GatewayHTTPHandler:
if self.cron_service is None and self.local_trigger_store is None:
return _http_error(503, "automation service unavailable")
query = _parse_query(request.path)
query = _request_query(request)
job_id = (_query_first(query, "id") or _query_first(query, "job_id") or "").strip()
if not job_id:
return _http_error(400, "missing automation id")
@@ -974,7 +1134,7 @@ class GatewayHTTPHandler:
if self._skill_install_lock.locked():
return _http_error(409, "another skill installation is already in progress")
query = _parse_query(request.path)
query = _request_query(request)
provider = _query_first(query, "provider") or "skills_sh"
source = _query_first(query, "source") or ""
skill_id = _query_first(query, "skill") or ""
@@ -1015,7 +1175,7 @@ class GatewayHTTPHandler:
def _handle_webui_skill_update(self, request: WsRequest) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
query = _request_query(request)
name = _query_first(query, "name") or ""
raw_enabled = (_query_first(query, "enabled") or "").lower()
if raw_enabled not in {"true", "false"}:
@@ -1047,7 +1207,7 @@ class GatewayHTTPHandler:
return _http_error(401, "Unauthorized")
if not _is_local_browser_request(connection, request.headers):
return _http_error(403, "remote skill deletion is disabled")
name = _query_first(_parse_query(request.path), "name") or ""
name = _query_first(_request_query(request), "name") or ""
try:
action = delete_webui_skill(
self.skills_workspace_path,
@@ -1094,18 +1254,14 @@ class GatewayHTTPHandler:
def _handle_webui_sidebar_state_update(self, request: WsRequest) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
raw_state = _query_first(query, "state")
if raw_state is None:
payload = _mutation_payload(request)
state_value = payload.get("state") if payload is not None else None
if state_value is None:
return _http_error(400, "missing state")
try:
decoded = json.loads(raw_state)
except json.JSONDecodeError:
return _http_error(400, "state must be JSON")
if not isinstance(decoded, dict):
if not isinstance(state_value, dict):
return _http_error(400, "state must be an object")
try:
state = write_webui_sidebar_state(cast(dict[str, Any], decoded))
state = write_webui_sidebar_state(cast(dict[str, Any], state_value))
except ValueError as e:
return _http_error(400, str(e))
except OSError:
@@ -1174,16 +1330,10 @@ class GatewayHTTPHandler:
def _automation_values_from_request(request: WsRequest) -> dict[str, Any] | None:
raw = _case_insensitive_header(request.headers, _AUTOMATION_VALUES_HEADER)
if not raw:
payload = _mutation_payload(request)
if payload is None or "values" not in payload:
return {}
try:
values = json.loads(raw)
except Exception:
try:
values = json.loads(unquote(raw))
except Exception:
return None
values = payload.get("values")
return cast(dict[str, Any], values) if isinstance(values, dict) else None
+1 -5
View File
@@ -37,6 +37,7 @@ dependencies = [
"readability-lxml>=0.8.4,<1.0.0",
"lxml-html-clean>=0.4.0,<1.0.0",
"rich>=14.0.0,<15.0.0",
"qrcode[pil]>=8.0",
"croniter>=6.0.0,<7.0.0",
"prompt-toolkit>=3.0.50,<4.0.0",
"questionary>=2.0.0,<3.0.0",
@@ -88,11 +89,6 @@ pdf = [
olostep = [
"olostep>=0.1.0; python_version < '3.14'",
]
computer-use = [
"pyautogui>=0.9.54",
"pillow>=10.0.0",
"playwright>=1.48.0",
]
dev = [
"pytest>=9.0.0,<10.0.0",
"pytest-asyncio>=1.3.0,<2.0.0",
-25
View File
@@ -1,13 +1,6 @@
from nanobot.agent.context_governance import ContextGovernor
def _image_result(label: str) -> list[dict]:
return [
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{label}"}},
{"type": "text", "text": label},
]
def _assistant_tool_call(call_id: str) -> dict:
return {
"role": "assistant",
@@ -44,21 +37,3 @@ def test_drop_orphan_tool_results_drops_duplicate_tool_result() -> None:
tool_results = [m for m in result if m.get("role") == "tool"]
assert len(tool_results) == 1
assert tool_results[0]["content"] == "first"
def test_drop_stale_visual_tool_images_keeps_latest_per_tool() -> None:
messages = [
{"role": "tool", "name": "computer_use", "content": _image_result("history")},
{"role": "tool", "name": "computer_use", "content": _image_result("old")},
{"role": "tool", "name": "browser", "content": _image_result("browser")},
{"role": "tool", "name": "computer_use", "content": _image_result("latest")},
]
result = ContextGovernor.drop_stale_visual_tool_images(messages, start_index=1)
assert result is not messages
assert result[0]["content"] == messages[0]["content"]
assert [block["type"] for block in result[1]["content"]] == ["text", "text"]
assert result[2]["content"] == messages[2]["content"]
assert result[3]["content"] == messages[3]["content"]
assert messages[1]["content"][0]["type"] == "image_url"
+16 -8
View File
@@ -5,6 +5,7 @@ import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.context import RequestContext, request_context
from nanobot.agent.tools.runtime_control import AgentRuntimeControl
from nanobot.agent.tools.self import MyTool
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import ModelPresetConfig
@@ -34,6 +35,13 @@ def _make_loop(tmp_path, presets=None, active_preset=None):
)
def _my_tool(loop: AgentLoop) -> MyTool:
return MyTool(
runtime_control=AgentRuntimeControl(loop),
modify_allowed=True,
)
def test_model_preset_getter_none_when_not_set(tmp_path) -> None:
loop = _make_loop(tmp_path)
assert loop.model_preset is None
@@ -240,7 +248,7 @@ def test_self_tool_inspect_shows_model_preset(tmp_path) -> None:
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
}
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
tool = MyTool(runtime_state=loop, modify_allowed=True)
tool = _my_tool(loop)
output = tool._inspect_all()
assert "model_preset: 'fast'" in output
@@ -250,7 +258,7 @@ def test_self_tool_set_model_preset_via_modify(tmp_path) -> None:
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
}
loop = _make_loop(tmp_path, presets=presets)
tool = MyTool(runtime_state=loop, modify_allowed=True)
tool = _my_tool(loop)
result = tool._modify("model_preset", "fast")
assert "Error" not in result
assert loop.model_preset == "fast"
@@ -263,7 +271,7 @@ def test_self_tool_set_model_preset_switches_back_to_default(tmp_path) -> None:
"fast": ModelPresetConfig(model="openai/gpt-4.1", context_window_tokens=32_768),
}
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
tool = MyTool(runtime_state=loop, modify_allowed=True)
tool = _my_tool(loop)
result = tool._modify("model_preset", "default")
@@ -280,7 +288,7 @@ def test_self_tool_set_model_preset_unknown_lists_available(tmp_path) -> None:
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
}
loop = _make_loop(tmp_path, presets=presets)
tool = MyTool(runtime_state=loop, modify_allowed=True)
tool = _my_tool(loop)
result = tool._modify("model_preset", "missing")
@@ -295,7 +303,7 @@ def test_self_tool_sets_model_preset_for_current_session(tmp_path) -> None:
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
}
loop = _make_loop(tmp_path, presets=presets)
tool = MyTool(runtime_state=loop, modify_allowed=True)
tool = _my_tool(loop)
with request_context(RequestContext(
channel="cli",
@@ -318,7 +326,7 @@ def test_self_tool_reports_session_preset_provider_configuration_error(tmp_path)
loop.set_session_model_preset = MagicMock(
side_effect=ValueError("No API key configured for provider 'openai'.")
)
tool = MyTool(runtime_state=loop, modify_allowed=True)
tool = _my_tool(loop)
with request_context(RequestContext(
channel="cli",
@@ -343,7 +351,7 @@ def test_self_tool_rejects_instance_runtime_changes_in_session(
value: object,
) -> None:
loop = _make_loop(tmp_path)
tool = MyTool(runtime_state=loop, modify_allowed=True)
tool = _my_tool(loop)
session = loop.sessions.get_or_create("cli:one")
with request_context(RequestContext(
@@ -366,7 +374,7 @@ def test_self_tool_set_model_clears_active_preset(tmp_path) -> None:
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
}
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
tool = MyTool(runtime_state=loop, modify_allowed=True)
tool = _my_tool(loop)
result = tool._modify("model", "anthropic/claude-opus-4-5")
assert "Error" not in result
assert loop.model_preset is None
-2
View File
@@ -76,13 +76,11 @@ class TestHandleStop:
loop.subagents.close = close_subagents
loop._exec_session_manager.close_all = AsyncMock()
loop.tools.close = AsyncMock()
with patch("nanobot.agent.loop.agent_context.close_mcp", AsyncMock()):
await loop.close_mcp()
assert events == ["turn_cancelled", "resources_closed"]
assert task.cancelled()
loop.tools.close.assert_awaited_once()
@pytest.mark.asyncio
async def test_close_mcp_serializes_duplicate_cleanup(self):
-4
View File
@@ -84,10 +84,6 @@ class TestToolHintKnownTools:
assert '"C:/Program Files/Git/project"' not in result
assert '"' in result
def test_exec_short_command_unchanged(self):
result = _hint([_tc("exec", {"command": "npm install typescript"})])
assert result == "$ npm install typescript"
def test_exec_chained_commands_truncated_not_mid_path(self):
"""Long chained commands should truncate preserving abbreviated paths."""
cmd = "cd D:\\Documents\\GitHub\\project && npm run build && npm test"
+225
View File
@@ -0,0 +1,225 @@
"""Contract and security regressions for the MyTool runtime boundary."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.runtime_control import (
RUNTIME_COMMAND_KEYS,
RUNTIME_SNAPSHOT_KEYS,
AgentRuntimeControl,
RuntimeControl,
)
from nanobot.agent.tools.self import MyTool, MyToolConfig
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import ToolsConfig
def _make_loop(tmp_path: Path, *, allow_set: bool = False) -> AgentLoop:
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
tools_config = ToolsConfig(my=MyToolConfig(allow_set=allow_set))
return AgentLoop(
bus=MessageBus(),
provider=provider,
workspace=tmp_path,
model="test-model",
tools_config=tools_config,
)
def _my_tool(loop: AgentLoop) -> MyTool:
tool = loop.tools.get("my")
assert isinstance(tool, MyTool)
return tool
def test_agent_loop_assembles_my_tool_with_runtime_control(tmp_path: Path) -> None:
loop = _make_loop(tmp_path)
tool = _my_tool(loop)
assert isinstance(tool._runtime_control, RuntimeControl)
assert isinstance(tool._runtime_control, AgentRuntimeControl)
assert tool._runtime_control is not loop
assert not hasattr(tool, "_runtime_state")
def test_runtime_snapshot_has_exact_allowlist_and_redacts_secrets(tmp_path: Path) -> None:
loop = _make_loop(tmp_path)
loop.web_config.search.api_key = "search-secret"
loop.web_config.proxy = "http://proxy-user:proxy-secret@proxy.example"
loop.unlisted_secret = "loop-secret"
snapshot = _my_tool(loop)._runtime_control.snapshot()
values = snapshot.as_mapping()
assert frozenset(values) == RUNTIME_SNAPSHOT_KEYS
assert RUNTIME_COMMAND_KEYS == frozenset({
"model",
"model_preset",
"max_iterations",
"context_window_tokens",
"provider_retry_mode",
"max_tool_result_chars",
"workspace",
})
assert "provider" not in values
assert "sessions" not in values
assert "restrict_to_workspace" not in values
assert "unlisted_secret" not in values
rendered = repr(values)
assert "search-secret" not in rendered
assert "proxy-secret" not in rendered
assert "loop-secret" not in rendered
assert snapshot.web_config["proxy"] == "<configured>"
def test_runtime_snapshot_is_detached_from_mutable_config(tmp_path: Path) -> None:
loop = _make_loop(tmp_path)
control = _my_tool(loop)._runtime_control
snapshot = control.snapshot()
search = snapshot.web_config["search"]
assert isinstance(search, dict)
search["provider"] = "mutated"
snapshot.exec_config["allow_patterns"] = ["mutated"]
snapshot.tool_names.append("mutated")
refreshed = control.snapshot()
refreshed_search = refreshed.web_config["search"]
assert isinstance(refreshed_search, dict)
assert refreshed_search["provider"] == loop.web_config.search.provider
assert refreshed.exec_config["allow_patterns"] == loop.exec_config.allow_patterns
assert "mutated" not in refreshed.tool_names
@pytest.mark.asyncio
async def test_unlisted_loop_attributes_cannot_be_read_or_modified(tmp_path: Path) -> None:
loop = _make_loop(tmp_path, allow_set=True)
loop.unlisted_control_plane = "internal-secret"
original_workspace_root = loop.workspace_scopes.default_workspace
tool = _my_tool(loop)
inspected = await tool.execute(action="check", key="unlisted_control_plane")
modified = await tool.execute(
action="set",
key="unlisted_control_plane",
value="scratch-value",
)
nested = await tool.execute(
action="set",
key="workspace_scopes.default_workspace",
value="elsewhere",
)
assert "internal-secret" not in inspected
assert "not found" in inspected
assert modified == "Set scratchpad.unlisted_control_plane = 'scratch-value'"
assert loop.unlisted_control_plane == "internal-secret"
assert "Error" in nested
assert loop.workspace_scopes.default_workspace == original_workspace_root
@pytest.mark.asyncio
async def test_default_allow_set_and_public_parameter_schema_are_unchanged(
tmp_path: Path,
) -> None:
loop = _make_loop(tmp_path)
tool = _my_tool(loop)
assert ToolsConfig().my.allow_set is False
assert tool.parameters == {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["check", "set"],
"description": "Action to perform",
},
"key": {
"type": "string",
"description": (
"Dot-path for check/set. Examples: 'max_iterations', 'workspace', "
"'provider_retry_mode'. Use 'request.channel', 'request.chat_id', or "
"'request.sender_id' for current routing metadata. Use 'model_preset' "
"to switch named model presets. For check without key, shows all "
"config values."
),
},
"value": {
"description": (
"New value (for set). Type must match target (int for "
"max_iterations/context_window_tokens, str for model/model_preset)."
),
},
},
"required": ["action"],
}
assert "READ-ONLY MODE" in tool.description
result = await tool.execute(action="set", key="max_iterations", value=80)
assert result == "Error: set is disabled (tools.my.allow_set is false)"
assert loop.max_iterations != 80
@pytest.mark.asyncio
async def test_allowlisted_commands_preserve_runtime_side_effects(tmp_path: Path) -> None:
loop = _make_loop(tmp_path, allow_set=True)
tool = _my_tool(loop)
max_iterations = await tool.execute(
action="set",
key="max_iterations",
value=80,
)
retry_mode = await tool.execute(
action="set",
key="provider_retry_mode",
value="persistent",
)
scratchpad = await tool.execute(
action="set",
key="preference",
value={"concise": True},
)
assert max_iterations == "Set max_iterations = 80 (was 200)"
assert retry_mode == "Set provider_retry_mode = 'persistent' (was 'standard')"
assert scratchpad == "Set scratchpad.preference = {'concise': True}"
assert loop.max_iterations == 80
assert loop.subagents.max_iterations == 80
assert loop.provider_retry_mode == "persistent"
assert tool._runtime_control.snapshot().scratchpad == {
"preference": {"concise": True},
}
@pytest.mark.asyncio
async def test_registry_exposes_unchanged_my_tool_actions(tmp_path: Path) -> None:
loop = _make_loop(tmp_path, allow_set=True)
checked = await loop.tools.execute("my", {"action": "check", "key": "model"})
changed = await loop.tools.execute(
"my",
{"action": "set", "key": "max_iterations", "value": 80},
)
assert checked == "model: 'test-model'"
assert changed == "Set max_iterations = 80 (was 200)"
assert loop.max_iterations == 80
@pytest.mark.asyncio
async def test_workspace_display_command_cannot_change_path_enforcement(tmp_path: Path) -> None:
loop = _make_loop(tmp_path, allow_set=True)
tool = _my_tool(loop)
result = await tool.execute(action="set", key="workspace", value="elsewhere")
assert "Set workspace" in result
assert tool._runtime_control.snapshot().workspace == "elsewhere"
assert loop.workspace == tmp_path
assert loop.workspace_scopes.default_workspace == tmp_path
+74 -69
View File
@@ -8,10 +8,12 @@ from types import MappingProxyType
from unittest.mock import MagicMock, patch
import pytest
from pydantic import BaseModel
from nanobot.agent.tools.context import RequestContext, request_context
from nanobot.agent.tools.runtime_control import AgentRuntimeControl
from nanobot.agent.tools.self import MyTool
from nanobot.agent.tools.shell import ExecToolConfig
from nanobot.agent.tools.web import WebSearchConfig, WebToolsConfig
from nanobot.config.schema import ModelPresetConfig
# ---------------------------------------------------------------------------
@@ -27,13 +29,16 @@ def _make_mock_loop(**overrides):
loop.workspace = Path("/tmp/workspace")
loop.restrict_to_workspace = False
loop._start_time = 1000.0
loop.exec_config = MagicMock()
loop.exec_config = ExecToolConfig()
loop.channels_config = MagicMock()
loop._last_usage = {"prompt_tokens": 100, "completion_tokens": 50}
loop._runtime_vars = {}
loop.last_usage = loop._last_usage
loop._current_iteration = 0
loop.current_iteration = loop._current_iteration
loop.provider_retry_mode = "standard"
loop.max_tool_result_chars = 16000
loop.model_preset = None
loop.model_presets = {}
loop._concurrency_gate = None
loop._unified_session = False
loop._extra_hooks = []
@@ -45,9 +50,7 @@ def _make_mock_loop(**overrides):
)
# web_config mock — needed for check tests
loop.web_config = MagicMock()
loop.web_config.enable = True
loop.web_config.search = MagicMock()
loop.web_config = WebToolsConfig()
loop.web_config.search.api_key = "sk-secret-key-12345"
# Tools registry mock
@@ -55,10 +58,13 @@ def _make_mock_loop(**overrides):
loop.tools.tool_names = ["read_file", "write_file", "exec", "web_search", "self"]
loop.tools.has.side_effect = lambda n: n in loop.tools.tool_names
loop.tools.get.return_value = None
loop.tool_names = loop.tools.tool_names
# SubagentManager mock
loop.subagents = MagicMock()
loop.subagents._running_tasks = {"abc123": MagicMock(done=MagicMock(return_value=False))}
loop.subagents._task_statuses = {}
loop.subagents.runtime_statuses.side_effect = lambda: loop.subagents._task_statuses
loop.subagents.get_running_count = MagicMock(return_value=1)
for k, v in overrides.items():
@@ -67,10 +73,10 @@ def _make_mock_loop(**overrides):
return loop
def _make_tool(runtime_state=None):
if runtime_state is None:
runtime_state = _make_mock_loop()
return MyTool(runtime_state=runtime_state)
def _make_tool(loop=None):
if loop is None:
loop = _make_mock_loop()
return MyTool(runtime_control=AgentRuntimeControl(loop))
# ---------------------------------------------------------------------------
@@ -87,10 +93,10 @@ class TestInspectSummary:
assert "context_window_tokens: 65536" in result
@pytest.mark.asyncio
async def test_inspect_includes_runtime_vars(self):
async def test_inspect_includes_scratchpad(self):
loop = _make_mock_loop()
loop._runtime_vars = {"task": "review"}
tool = _make_tool(runtime_state=loop)
tool = _make_tool(loop=loop)
tool._runtime_control.set_scratchpad("task", "review", max_keys=64)
result = await tool.execute(action="check")
assert "task" in result
@@ -150,9 +156,7 @@ class TestInspectPathNavigation:
@pytest.mark.asyncio
async def test_inspect_config_subfield(self):
loop = _make_mock_loop()
loop.web_config = MagicMock()
loop.web_config.enable = True
tool = _make_tool(runtime_state=loop)
tool = _make_tool(loop=loop)
result = await tool.execute(action="check", key="web_config.enable")
assert "True" in result
@@ -160,7 +164,7 @@ class TestInspectPathNavigation:
async def test_inspect_dict_key_via_dotpath(self):
loop = _make_mock_loop()
loop._last_usage = {"prompt_tokens": 100, "completion_tokens": 50}
tool = _make_tool(runtime_state=loop)
tool = _make_tool(loop=loop)
result = await tool.execute(action="check", key="_last_usage.prompt_tokens")
assert "100" in result
@@ -179,20 +183,16 @@ class TestInspectPathNavigation:
@pytest.mark.asyncio
async def test_inspect_nested_config_redacts_sensitive_scalar_fields(self):
class SearchConfig(BaseModel):
provider: str = "tavily"
api_key: str = "sk-test-secret"
base_url: str = ""
max_results: int = 5
loop = _make_mock_loop()
loop.web_config = MagicMock()
loop.web_config.search = SearchConfig()
loop.web_config.search = WebSearchConfig(
provider="tavily",
api_key="sk-test-secret",
)
tool = _make_tool(loop)
result = await tool.execute(action="check", key="web_config.search")
assert "provider='tavily'" in result
assert "tavily" in result
assert "sk-test-secret" not in result
assert "api_key" not in result.lower()
@@ -209,14 +209,14 @@ class TestModifyRestricted:
tool = _make_tool()
result = await tool.execute(action="set", key="max_iterations", value=80)
assert "Set max_iterations = 80" in result
assert tool._runtime_state.max_iterations == 80
assert tool._runtime_control.snapshot().max_iterations == 80
@pytest.mark.asyncio
async def test_modify_restricted_out_of_range(self):
tool = _make_tool()
result = await tool.execute(action="set", key="max_iterations", value=0)
assert "Error" in result
assert tool._runtime_state.max_iterations == 40
assert tool._runtime_control.snapshot().max_iterations == 40
@pytest.mark.asyncio
async def test_modify_restricted_max_exceeded(self):
@@ -241,12 +241,12 @@ class TestModifyRestricted:
tool = _make_tool()
result = await tool.execute(action="set", key="max_iterations", value="80")
assert "Set max_iterations" in result
assert tool._runtime_state.max_iterations == 80
assert tool._runtime_control.snapshot().max_iterations == 80
@pytest.mark.asyncio
async def test_modify_context_window_valid(self):
loop = _make_mock_loop()
tool = _make_tool(runtime_state=loop)
tool = _make_tool(loop=loop)
result = await tool.execute(action="set", key="context_window_tokens", value=131072)
assert "Set context_window_tokens" in result
assert loop.context_window_tokens == 131072
@@ -324,15 +324,15 @@ class TestModifyFree:
tool = _make_tool()
result = await tool.execute(action="set", key="provider_retry_mode", value="persistent")
assert "Set provider_retry_mode" in result
assert tool._runtime_state.provider_retry_mode == "persistent"
assert tool._runtime_control.snapshot().provider_retry_mode == "persistent"
@pytest.mark.asyncio
async def test_modify_new_key_stores_in_runtime_vars(self):
"""Modifying a non-existing attribute should store in _runtime_vars."""
async def test_modify_new_key_stores_in_scratchpad(self):
"""Modifying an unknown key should store it in the scratchpad."""
tool = _make_tool()
result = await tool.execute(action="set", key="my_custom_var", value="hello")
assert "my_custom_var" in result
assert tool._runtime_state._runtime_vars["my_custom_var"] == "hello"
assert tool._runtime_control.snapshot().scratchpad["my_custom_var"] == "hello"
@pytest.mark.asyncio
async def test_modify_rejects_callable(self):
@@ -351,14 +351,14 @@ class TestModifyFree:
tool = _make_tool()
result = await tool.execute(action="set", key="items", value=[1, 2, 3])
assert result == "Set scratchpad.items = [1, 2, 3]"
assert tool._runtime_state._runtime_vars["items"] == [1, 2, 3]
assert tool._runtime_control.snapshot().scratchpad["items"] == [1, 2, 3]
@pytest.mark.asyncio
async def test_modify_allows_dict(self):
tool = _make_tool()
result = await tool.execute(action="set", key="data", value={"a": 1})
assert result == "Set scratchpad.data = {'a': 1}"
assert tool._runtime_state._runtime_vars["data"] == {"a": 1}
assert tool._runtime_control.snapshot().scratchpad["data"] == {"a": 1}
@pytest.mark.asyncio
async def test_modify_whitespace_key_rejected(self):
@@ -396,7 +396,7 @@ class TestModifyFree:
result = await tool.execute(action="set", key="provider_retry_mode", value=42)
assert "Error" in result
assert "str" in result
assert tool._runtime_state.provider_retry_mode == "standard"
assert tool._runtime_control.snapshot().provider_retry_mode == "standard"
@pytest.mark.asyncio
async def test_modify_existing_int_attr_wrong_type_rejected(self):
@@ -404,7 +404,7 @@ class TestModifyFree:
tool = _make_tool()
result = await tool.execute(action="set", key="max_tool_result_chars", value="big")
assert "Error" in result
assert tool._runtime_state.max_tool_result_chars == 16000
assert tool._runtime_control.snapshot().max_tool_result_chars == 16000
# ---------------------------------------------------------------------------
@@ -486,11 +486,12 @@ class TestModifyOpen:
assert "protected" in result
@pytest.mark.asyncio
async def test_modify_workspace_allowed(self):
"""workspace was READONLY in v1, now freely modifiable."""
async def test_modify_workspace_preserves_display_compatibility(self):
"""The compatibility value is isolated from filesystem security boundaries."""
tool = _make_tool()
result = await tool.execute(action="set", key="workspace", value="/new/path")
assert "Set workspace" in result
assert tool._runtime_control.snapshot().workspace == "/new/path"
@pytest.mark.asyncio
async def test_modify_mcp_servers_blocked(self):
@@ -584,28 +585,28 @@ class TestUnknownAction:
# ---------------------------------------------------------------------------
# runtime_vars limits (from code review)
# scratchpad limits
# ---------------------------------------------------------------------------
class TestRuntimeVarsLimits:
class TestScratchpadLimits:
@pytest.mark.asyncio
async def test_runtime_vars_rejects_at_max_keys(self):
loop = _make_mock_loop()
loop._runtime_vars = {f"key_{i}": i for i in range(64)}
tool = _make_tool(runtime_state=loop)
async def test_scratchpad_rejects_at_max_keys(self):
tool = _make_tool()
for i in range(64):
tool._runtime_control.set_scratchpad(f"key_{i}", i, max_keys=64)
result = await tool.execute(action="set", key="overflow", value="data")
assert "full" in result
assert "overflow" not in loop._runtime_vars
assert "overflow" not in tool._runtime_control.snapshot().scratchpad
@pytest.mark.asyncio
async def test_runtime_vars_allows_update_existing_key_at_max(self):
loop = _make_mock_loop()
loop._runtime_vars = {f"key_{i}": i for i in range(64)}
tool = _make_tool(runtime_state=loop)
async def test_scratchpad_allows_update_existing_key_at_max(self):
tool = _make_tool()
for i in range(64):
tool._runtime_control.set_scratchpad(f"key_{i}", i, max_keys=64)
result = await tool.execute(action="set", key="key_0", value="updated")
assert "Error" not in result
assert loop._runtime_vars["key_0"] == "updated"
assert tool._runtime_control.snapshot().scratchpad["key_0"] == "updated"
# ---------------------------------------------------------------------------
@@ -844,7 +845,7 @@ class TestInspectTaskStatuses:
usage={"prompt_tokens": 500, "completion_tokens": 100},
),
}
tool = _make_tool(runtime_state=loop)
tool = _make_tool(loop=loop)
result = await tool.execute(action="check", key="subagents._task_statuses")
assert "abc12345" in result
assert "read logs" in result
@@ -865,7 +866,7 @@ class TestInspectTaskStatuses:
stop_reason="completed",
)
loop.subagents._task_statuses = {"xyz": status}
tool = _make_tool(runtime_state=loop)
tool = _make_tool(loop=loop)
result = await tool.execute(action="check", key="subagents._task_statuses.xyz")
assert "search code" in result
assert "completed" in result
@@ -879,7 +880,10 @@ class TestReadOnlyMode:
def _make_readonly_tool(self):
loop = _make_mock_loop()
return MyTool(runtime_state=loop, modify_allowed=False)
return MyTool(
runtime_control=AgentRuntimeControl(loop),
modify_allowed=False,
)
@pytest.mark.asyncio
async def test_inspect_allowed_in_readonly(self):
@@ -904,13 +908,13 @@ class TestReadOnlyMode:
# ---------------------------------------------------------------------------
# runtime vars check fallback (Fix #1: cross-turn memory)
# scratchpad inspection
# ---------------------------------------------------------------------------
class TestRuntimeVarsInspectFallback:
class TestScratchpadInspection:
@pytest.mark.asyncio
async def test_inspect_runtime_var_after_modify(self):
async def test_inspect_scratchpad_value_after_modify(self):
"""Design doc scenario: set then check should return the value."""
tool = _make_tool()
await tool.execute(action="set", key="user_prefers_concise", value=True)
@@ -918,14 +922,14 @@ class TestRuntimeVarsInspectFallback:
assert "True" in result
@pytest.mark.asyncio
async def test_inspect_runtime_var_string(self):
async def test_inspect_scratchpad_string(self):
tool = _make_tool()
await tool.execute(action="set", key="current_project", value="nanobot")
result = await tool.execute(action="check", key="current_project")
assert "nanobot" in result
@pytest.mark.asyncio
async def test_inspect_runtime_var_dict(self):
async def test_inspect_scratchpad_dict(self):
tool = _make_tool()
await tool.execute(action="set", key="task_meta", value={"step": 2, "total": 5})
result = await tool.execute(action="check", key="task_meta")
@@ -958,7 +962,7 @@ class TestSensitiveSubFieldBlocking:
loop = _make_mock_loop()
loop.some_config = MagicMock()
loop.some_config.password = "hunter2"
tool = _make_tool(runtime_state=loop)
tool = _make_tool(loop=loop)
result = await tool.execute(action="check", key="some_config.password")
assert "not accessible" in result
@@ -967,7 +971,7 @@ class TestSensitiveSubFieldBlocking:
loop = _make_mock_loop()
loop.vault = MagicMock()
loop.vault.secret = "classified"
tool = _make_tool(runtime_state=loop)
tool = _make_tool(loop=loop)
result = await tool.execute(action="check", key="vault.secret")
assert "not accessible" in result
@@ -976,7 +980,7 @@ class TestSensitiveSubFieldBlocking:
loop = _make_mock_loop()
loop.auth_data = MagicMock()
loop.auth_data.token = "jwt-payload"
tool = _make_tool(runtime_state=loop)
tool = _make_tool(loop=loop)
result = await tool.execute(action="check", key="auth_data.token")
assert "not accessible" in result
@@ -992,7 +996,7 @@ class TestSensitiveSubFieldBlocking:
async def test_modify_password_blocked(self):
loop = _make_mock_loop()
loop.some_config = MagicMock()
tool = _make_tool(runtime_state=loop)
tool = _make_tool(loop=loop)
result = await tool.execute(action="set", key="some_config.password", value="evil")
assert "not accessible" in result
@@ -1083,8 +1087,8 @@ class TestSecurityAttributeProtection:
@pytest.mark.asyncio
async def test_modify_model_presets_dotpath_blocked(self):
"""The config-derived model preset catalog is inspectable but not mutable."""
presets = {"fast": {"model": "fast-model"}}
tool = _make_tool(runtime_state=_make_mock_loop(model_presets=presets))
presets = {"fast": ModelPresetConfig(model="fast-model")}
tool = _make_tool(loop=_make_mock_loop(model_presets=presets))
result = await tool.execute(
action="set",
@@ -1093,14 +1097,14 @@ class TestSecurityAttributeProtection:
)
assert "read-only" in result
assert presets == {"fast": {"model": "fast-model"}}
assert presets == {"fast": ModelPresetConfig(model="fast-model")}
@pytest.mark.asyncio
async def test_inspect_read_only_model_preset_dotpath(self):
presets = MappingProxyType({
"fast": ModelPresetConfig(model="fast-model"),
})
tool = _make_tool(runtime_state=_make_mock_loop(model_presets=presets))
tool = _make_tool(loop=_make_mock_loop(model_presets=presets))
result = await tool.execute(action="check", key="model_presets.fast.model")
@@ -1150,7 +1154,8 @@ class TestLastUsageInSummary:
async def test_last_usage_not_shown_when_empty(self):
loop = _make_mock_loop()
loop._last_usage = {}
tool = _make_tool(runtime_state=loop)
loop.last_usage = loop._last_usage
tool = _make_tool(loop=loop)
result = await tool.execute(action="check")
assert "_last_usage" not in result
@@ -4,23 +4,23 @@ from unittest.mock import MagicMock
import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.runtime_control import AgentRuntimeControl
from nanobot.agent.tools.self import MyTool
from nanobot.bus.queue import MessageBus
@pytest.mark.asyncio
async def test_my_tool_max_iterations_syncs_subagent_limit() -> None:
loop = MagicMock()
loop.max_iterations = 40
loop._runtime_vars = {}
loop.subagents = MagicMock()
loop.subagents.max_iterations = loop.max_iterations
def _sync_subagent_runtime_limits() -> None:
loop.subagents.max_iterations = loop.max_iterations
loop._sync_subagent_runtime_limits = _sync_subagent_runtime_limits
tool = MyTool(runtime_state=loop)
async def test_my_tool_max_iterations_syncs_subagent_limit(tmp_path) -> None:
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
loop = AgentLoop(
bus=MessageBus(),
provider=provider,
workspace=tmp_path,
max_iterations=40,
)
tool = MyTool(runtime_control=AgentRuntimeControl(loop))
result = await tool.execute(action="set", key="max_iterations", value=80)
@@ -241,24 +241,6 @@ async def test_file_edit_events_route_to_channel_capability(manager):
channel._send_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_typed_file_edit_event_routes_to_channel_capability(manager):
channel = manager.channels["mock"]
edits = [{"version": 1, "phase": "start", "path": "src/app.py"}]
msg = outbound_message_for_event(
channel="mock",
chat_id="c1",
event=ProgressEvent(file_edit_events=edits),
)
await manager._send_once(channel, msg)
channel._file_edit_mock.assert_awaited_once_with(
"c1", edits, msg.metadata
)
channel._send_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_base_channel_file_edit_events_are_noop_safe():
class _Plain(BaseChannel):
+4
View File
@@ -651,6 +651,7 @@ def test_plugin_setup_contract_drives_save_and_validation(
from nanobot.channels.validation import validate_channel_config
from nanobot.config import loader
from nanobot.webui.settings_routes import WebUISettingsRouter
from nanobot.webui.settings_services import WebUISettingsServices
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
@@ -660,6 +661,7 @@ def test_plugin_setup_contract_drives_save_and_validation(
_channel_plugin(_SetupPlugin, setup=_SETUP_PLUGIN_SPEC),
)
router = object.__new__(WebUISettingsRouter)
router.settings = WebUISettingsServices.create(config_path)
saved = router._save_channel_config_values(
"setupplugin",
@@ -738,6 +740,7 @@ def test_webui_save_rejects_duplicate_feishu_ids_without_writing(monkeypatch, tm
from nanobot.config import loader
from nanobot.webui.settings_api import WebUISettingsError
from nanobot.webui.settings_routes import WebUISettingsRouter
from nanobot.webui.settings_services import WebUISettingsServices
config_path = tmp_path / "config.json"
config_path.write_text(
@@ -756,6 +759,7 @@ def test_webui_save_rejects_duplicate_feishu_ids_without_writing(monkeypatch, tm
before = config_path.read_text(encoding="utf-8")
monkeypatch.setattr(loader, "_current_config_path", config_path)
router = object.__new__(WebUISettingsRouter)
router.settings = WebUISettingsServices.create(config_path)
with pytest.raises(WebUISettingsError, match="duplicate Feishu instance id 'default'") as error:
router._save_channel_config_values(
+155
View File
@@ -0,0 +1,155 @@
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, MagicMock
import pytest
from typer.testing import CliRunner
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.outbound_events import (
StreamDeltaEvent,
StreamedResponseEvent,
StreamEndEvent,
outbound_message_for_event,
)
from nanobot.cli.commands import app
from nanobot.config.schema import Config
runner = CliRunner()
@pytest.mark.parametrize("streamed", [False, True])
def test_interactive_agent_routes_a_complete_user_turn(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
streamed: bool,
) -> None:
config = Config()
config.agents.defaults.workspace = str(tmp_path / "workspace")
seen: dict[str, object] = {}
renderers: list[object] = []
class _Renderer:
def __init__(self, **_kwargs: object) -> None:
self.streamed = False
self.header_printed = False
self.deltas: list[str] = []
self.ends: list[bool] = []
self.closed = 0
renderers.append(self)
async def on_delta(self, content: str) -> None:
self.streamed = True
self.deltas.append(content)
async def on_end(self, *, resuming: bool = False) -> None:
self.ends.append(resuming)
async def close(self) -> None:
self.closed += 1
def stop_for_input(self) -> None:
return None
class _AgentLoop:
channels_config = None
@classmethod
def from_config(cls, _config, bus, **_kwargs):
instance = cls(bus)
seen["loop"] = instance
return instance
def __init__(self, bus) -> None:
self.bus = bus
self.stopped = asyncio.Event()
self.close_mcp_calls = 0
async def run(self) -> None:
message = await self.bus.consume_inbound()
seen["inbound"] = message
if streamed:
for event in (
StreamDeltaEvent(content="hello "),
StreamDeltaEvent(content="world"),
StreamEndEvent(),
StreamedResponseEvent(),
):
await self.bus.publish_outbound(
outbound_message_for_event(
channel=message.channel,
chat_id=message.chat_id,
event=event,
content="hello world" if isinstance(event, StreamedResponseEvent) else None,
)
)
else:
await self.bus.publish_outbound(
OutboundMessage(
channel=message.channel,
chat_id=message.chat_id,
content="hello world",
)
)
await self.stopped.wait()
def stop(self) -> None:
self.stopped.set()
async def close_mcp(self) -> None:
self.close_mcp_calls += 1
read_input = AsyncMock(side_effect=["hello nanobot", "exit"])
print_response = MagicMock()
monkeypatch.setattr("nanobot.cli.agent._load_runtime_config", lambda *_args: config)
monkeypatch.setattr("nanobot.cli.agent.sync_workspace_templates", lambda *_args: None)
monkeypatch.setattr("nanobot.cli.agent.is_default_workspace", lambda *_args: False)
monkeypatch.setattr("nanobot.cli.agent._set_nanobot_logs", lambda *_args: None)
monkeypatch.setattr("nanobot.cli.agent._model_display", lambda *_args: ("test-model", ""))
monkeypatch.setattr("nanobot.cli.agent.consume_restart_notice_from_env", lambda: None)
monkeypatch.setattr("nanobot.cli.agent.AgentLoop", _AgentLoop)
monkeypatch.setattr("nanobot.cli.agent.StreamRenderer", _Renderer)
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda *_args: object())
monkeypatch.setattr(
"nanobot.providers.image_generation.image_gen_provider_configs",
lambda *_args: [],
)
monkeypatch.setattr("nanobot.cron.service.CronService", lambda *_args: object())
monkeypatch.setattr("nanobot.cli.agent.signal.signal", lambda *_args: None)
monkeypatch.setattr("nanobot.cli.terminal._init_prompt_session", lambda: None)
monkeypatch.setattr("nanobot.cli.terminal._flush_pending_tty_input", lambda: None)
monkeypatch.setattr("nanobot.cli.terminal._restore_terminal", lambda: None)
monkeypatch.setattr("nanobot.cli.terminal._read_interactive_input_async", read_input)
monkeypatch.setattr("nanobot.cli.terminal._print_agent_response", print_response)
result = runner.invoke(app, ["agent", "--session", "cli:journey"])
assert result.exit_code == 0, result.output
inbound = seen["inbound"]
assert isinstance(inbound, InboundMessage)
assert (inbound.channel, inbound.chat_id, inbound.content) == (
"cli",
"journey",
"hello nanobot",
)
assert inbound.metadata == {"_wants_stream": True}
loop = seen["loop"]
assert isinstance(loop, _AgentLoop)
assert loop.close_mcp_calls == 1
assert len(renderers) == 1
renderer = renderers[0]
assert isinstance(renderer, _Renderer)
if streamed:
assert renderer.deltas == ["hello ", "world"]
assert renderer.ends == [False]
assert renderer.closed == 0
print_response.assert_not_called()
else:
assert renderer.deltas == []
assert renderer.closed == 1
print_response.assert_called_once_with(
"hello world",
render_markdown=True,
metadata={},
)
-21
View File
@@ -1231,27 +1231,6 @@ def test_openai_compat_provider_passes_model_through():
assert provider.get_default_model() == "github-copilot/gpt-5.3-codex"
def test_make_provider_uses_github_copilot_backend():
from nanobot.config.schema import Config
from nanobot.providers.factory import make_provider
config = Config.model_validate(
{
"agents": {
"defaults": {
"provider": "github-copilot",
"model": "github-copilot/gpt-4.1",
}
}
}
)
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = make_provider(config)
assert provider.__class__.__name__ == "GitHubCopilotProvider"
def test_openai_codex_proxy_config_affects_provider_and_signature():
def config_with_proxy(proxy: str) -> Config:
return Config.model_validate(
@@ -1,50 +0,0 @@
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
def test_chat_completions_moves_tool_images_after_parallel_results():
image = {
"type": "image_url",
"image_url": {"url": "data:image/png;base64,AAAA"},
"_meta": {"path": "screen.png"},
}
messages = [
{"role": "assistant", "content": None, "tool_calls": [{"id": "a"}, {"id": "b"}]},
{
"role": "tool",
"tool_call_id": "a",
"content": [image, {"type": "text", "text": "clicked"}],
},
{"role": "tool", "tool_call_id": "b", "content": "other result"},
{"role": "assistant", "content": "done"},
]
result = OpenAICompatProvider._move_tool_images_to_user(messages)
assert result[1]["content"] == "clicked"
assert result[2] == messages[2]
assert result[3]["role"] == "user"
assert result[3]["content"][0] == {
"type": "image_url",
"image_url": {"url": "data:image/png;base64,AAAA"},
}
assert result[4] == messages[3]
def test_chat_completions_merges_tool_images_into_following_user_message():
messages = [
{
"role": "tool",
"tool_call_id": "a",
"content": [
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
],
},
{"role": "user", "content": "continue"},
]
result = OpenAICompatProvider._move_tool_images_to_user(messages)
assert len(result) == 2
assert result[0]["content"] == "(image returned)"
assert result[1]["role"] == "user"
assert result[1]["content"][-1] == {"type": "text", "text": "continue"}
-21
View File
@@ -243,27 +243,6 @@ def test_workspace_override(tmp_path):
assert bot._loop.workspace == custom_ws
def test_sdk_make_provider_uses_github_copilot_backend():
from nanobot.config.schema import Config
from nanobot.providers.factory import make_provider
config = Config.model_validate(
{
"agents": {
"defaults": {
"provider": "github-copilot",
"model": "github-copilot/gpt-4.1",
}
}
}
)
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = make_provider(config)
assert provider.__class__.__name__ == "GitHubCopilotProvider"
@pytest.mark.asyncio
async def test_run_custom_session_key(tmp_path):
from nanobot.bus.events import OutboundMessage
-316
View File
@@ -1,316 +0,0 @@
"""Tests for DOM-based browser control."""
from __future__ import annotations
import asyncio
import io
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.agent.tools.browser_tool import BrowserTool, BrowserToolConfig
from nanobot.agent.tools.computer_use_backends import browser_playwright
from nanobot.agent.tools.computer_use_backends.browser_playwright import BrowserBackend
from nanobot.agent.tools.context import RequestContext, request_context
class _FakeDomBackend:
environment = "browser"
def __init__(self):
self.calls: list[tuple] = []
self.elements = [
{"ref": 1, "tag": "button", "role": "", "type": "", "name": "Submit", "href": ""},
{"ref": 2, "tag": "input", "role": "", "type": "text", "name": "your name", "href": ""},
]
async def navigate(self, url):
self.calls.append(("navigate", url))
async def dom_snapshot(self, max_elements=200):
return self.elements
async def click_ref(self, ref):
self.calls.append(("click", ref))
async def fill_ref(self, ref, text, submit=False):
self.calls.append(("fill", ref, text, submit))
async def select_ref(self, ref, value):
self.calls.append(("select", ref, value))
async def scroll_page(self, direction, amount):
self.calls.append(("scroll", direction, amount))
async def key(self, combo):
self.calls.append(("key", combo))
async def go_back(self):
self.calls.append(("back",))
async def read_text(self, max_chars=4000):
return "the number is 42"
async def current_url(self):
return "http://test.local/page"
async def screenshot(self):
from PIL import Image
buf = io.BytesIO()
Image.new("RGB", (1280, 800), (0, 0, 0)).save(buf, format="PNG")
return buf.getvalue()
async def close(self):
self.calls.append(("close",))
def _tool(**kw):
fb = _FakeDomBackend()
return BrowserTool(BrowserToolConfig(**kw), backend_impl=fb), fb
def _route(url: str, *, navigation: bool):
return SimpleNamespace(
request=SimpleNamespace(
url=url,
is_navigation_request=MagicMock(return_value=navigation),
),
abort=AsyncMock(),
continue_=AsyncMock(),
)
class TestConfigAndMetadata:
def test_defaults_off(self):
cfg = BrowserToolConfig()
assert cfg.enable is False
assert cfg.headless is True
assert cfg.include_screenshot is False
assert cfg.max_elements == 200
assert cfg.max_sessions == 8
def test_enabled_reads_config(self):
ctx = MagicMock()
ctx.config.browser.enable = True
assert BrowserTool.enabled(ctx) is True
ctx.config.browser.enable = False
assert BrowserTool.enabled(ctx) is False
def test_create_from_ctx(self):
ctx = MagicMock()
ctx.config.browser = BrowserToolConfig(enable=True, allowed_domains=["example.com"])
tool = BrowserTool.create(ctx)
assert isinstance(tool, BrowserTool)
assert tool.config.allowed_domains == ["example.com"]
def test_metadata(self):
tool, _ = _tool()
assert tool.name == "browser"
assert tool.exclusive is True
assert tool.read_only is False
assert "subagent" not in tool._scopes
def test_schema_actions(self):
tool, _ = _tool()
enum = tool.parameters["properties"]["action"]["enum"]
for a in ("navigate", "snapshot", "click", "type", "read_text"):
assert a in enum
class TestDispatch:
@pytest.mark.asyncio
async def test_navigate_returns_snapshot(self):
tool, fb = _tool()
result = await tool.execute(action="navigate", url="https://example.com")
assert ("navigate", "https://example.com") in fb.calls
assert isinstance(result, str)
assert "Navigated to https://example.com" in result
# snapshot of interactive elements is appended
assert '[1] button "Submit"' in result
assert '[2] input[text] "your name"' in result
@pytest.mark.parametrize(
("action", "kwargs", "expected"),
[
("click", {"ref": 1}, ("click", 1)),
("type", {"ref": 2, "text": "Ada", "submit": True}, ("fill", 2, "Ada", True)),
("select", {"ref": 2, "value": "opt1"}, ("select", 2, "opt1")),
],
)
@pytest.mark.asyncio
async def test_element_actions(self, action, kwargs, expected):
tool, fb = _tool()
result = await tool.execute(action=action, **kwargs)
assert expected in fb.calls
assert "Interactive elements" in result
@pytest.mark.asyncio
async def test_scroll_and_key_and_back(self):
tool, fb = _tool()
await tool.execute(action="scroll", scroll_direction="down", scroll_amount=4)
await tool.execute(action="key", text="Enter")
await tool.execute(action="back")
assert ("scroll", "down", 4) in fb.calls
assert ("key", "Enter") in fb.calls
assert ("back",) in fb.calls
@pytest.mark.asyncio
async def test_read_text_returns_text_no_snapshot(self):
tool, _ = _tool()
result = await tool.execute(action="read_text")
assert isinstance(result, str)
assert "the number is 42" in result
assert "Interactive elements" not in result
@pytest.mark.asyncio
async def test_include_screenshot_returns_blocks(self):
tool, _ = _tool(include_screenshot=True)
result = await tool.execute(action="click", ref=1)
assert isinstance(result, list)
imgs = [b for b in result if b.get("type") == "image_url"]
texts = [b for b in result if b.get("type") == "text"]
assert imgs and texts
assert "Clicked element [1]" in texts[-1]["text"]
@pytest.mark.asyncio
async def test_calls_are_serialized_across_sessions(self):
class SlowBackend(_FakeDomBackend):
active = 0
max_active = 0
async def dom_snapshot(self, max_elements=200):
self.active += 1
self.max_active = max(self.max_active, self.active)
await asyncio.sleep(0.01)
self.active -= 1
return await super().dom_snapshot(max_elements)
backend = SlowBackend()
tool = BrowserTool(backend_impl=backend)
async def snapshot(session: str):
with request_context(
RequestContext(channel="test", chat_id=session, session_key=session)
):
return await tool.execute(action="snapshot")
await asyncio.gather(snapshot("a"), snapshot("b"))
assert backend.max_active == 1
class TestErrorsAndPolicy:
@pytest.mark.parametrize(
("kwargs", "error"),
[
({"action": "teleport"}, "unknown action"),
({"action": "click"}, "requires an element 'ref'"),
],
)
@pytest.mark.asyncio
async def test_tool_errors_are_returned_to_model(self, kwargs, error):
tool, _ = _tool()
result = await tool.execute(**kwargs)
assert isinstance(result, str) and error in result
assert result.is_error is True
@pytest.mark.asyncio
async def test_backend_blocks_disallowed_navigation(self):
backend = BrowserBackend(allowed_domains=["example.com"])
page = SimpleNamespace(goto=AsyncMock())
backend._page = page
with pytest.raises(ValueError, match="allowed_domains"):
await backend.navigate("https://evil.test/")
page.goto.assert_not_awaited()
@pytest.mark.asyncio
async def test_backend_allows_subdomain_navigation(self, monkeypatch: pytest.MonkeyPatch):
check = MagicMock(return_value=(True, ""))
monkeypatch.setattr(browser_playwright, "validate_url_target", check)
backend = BrowserBackend(allowed_domains=["example.com"])
page = SimpleNamespace(goto=AsyncMock())
backend._page = page
await backend.navigate("https://app.example.com/x")
page.goto.assert_awaited_once_with("https://app.example.com/x")
check.assert_called_once()
@pytest.mark.parametrize(
"url",
[
"file:///etc/passwd",
"http://127.0.0.1/",
"http://169.254.169.254/latest/meta-data/",
"ws://localhost/socket",
],
)
@pytest.mark.asyncio
async def test_browser_network_policy_blocks_local_targets(self, url: str):
backend = BrowserBackend()
with pytest.raises(ValueError, match="blocked"):
await backend.navigate(url)
@pytest.mark.asyncio
async def test_backend_intercepts_blocked_navigation(self):
backend = BrowserBackend(allowed_domains=["example.com"])
route = _route("https://evil.test/", navigation=True)
await backend._route_request(route)
route.abort.assert_awaited_once_with("blockedbyclient")
route.continue_.assert_not_awaited()
assert "allowed_domains" in (backend.pop_blocked_navigation() or "")
@pytest.mark.asyncio
async def test_backend_intercepts_private_subresource(self):
backend = BrowserBackend()
route = _route(
"http://169.254.169.254/latest/meta-data/",
navigation=False,
)
await backend._route_request(route)
route.abort.assert_awaited_once_with("blockedbyclient")
assert backend.pop_blocked_navigation() is None
@pytest.mark.asyncio
async def test_backend_does_not_apply_navigation_allowlist_to_subresources(
self, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setattr(
browser_playwright,
"validate_url_target",
MagicMock(return_value=(True, "")),
)
backend = BrowserBackend(allowed_domains=["example.com"])
route = _route("https://cdn.other.test/app.js", navigation=False)
await backend._route_request(route)
route.continue_.assert_awaited_once()
route.abort.assert_not_awaited()
@pytest.mark.asyncio
async def test_backend_intercepts_private_websocket(self):
backend = BrowserBackend()
web_socket = SimpleNamespace(
url="ws://127.0.0.1/socket",
close=AsyncMock(),
connect_to_server=AsyncMock(),
)
await backend._route_web_socket(web_socket)
web_socket.close.assert_awaited_once()
web_socket.connect_to_server.assert_not_awaited()
@pytest.mark.asyncio
async def test_backend_rejects_file_start_url_before_launch(self):
backend = BrowserBackend(start_url="file:///etc/passwd")
with pytest.raises(ValueError, match="start_url is blocked"):
await backend.dimensions()
-330
View File
@@ -1,330 +0,0 @@
"""Tests for screenshot-based computer control."""
from __future__ import annotations
import asyncio
import io
import sys
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from nanobot.agent.tools.computer_use import ComputerUseTool, ComputerUseToolConfig
from nanobot.agent.tools.computer_use_backends.base import ComputerBackend, SessionBackendPool
from nanobot.agent.tools.computer_use_backends.desktop_pyautogui import DesktopBackend
from nanobot.agent.tools.context import RequestContext, request_context
from nanobot.config.schema import ToolsConfig
class _FakeBackend(ComputerBackend):
"""Records actuation calls and serves a solid-colour PNG of a fixed size."""
environment = "desktop"
def __init__(self, width: int = 2560, height: int = 1600):
self.calls: list[tuple] = []
self._w, self._h = width, height
self.closed = False
async def dimensions(self) -> tuple[int, int]:
return (self._w, self._h)
async def screenshot(self) -> bytes:
from PIL import Image
img = Image.new("RGB", (self._w, self._h), (10, 20, 30))
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
async def click(self, x, y, button="left", count=1):
self.calls.append(("click", x, y, button, count))
async def move(self, x, y):
self.calls.append(("move", x, y))
async def drag(self, x, y):
self.calls.append(("drag", x, y))
async def scroll(self, x, y, direction, amount):
self.calls.append(("scroll", x, y, direction, amount))
async def type_text(self, text):
self.calls.append(("type", text))
async def key(self, combo):
self.calls.append(("key", combo))
async def close(self):
self.closed = True
# navigate() inherited -> raises NotImplementedError (desktop has no navigate)
def _split(result):
assert isinstance(result, list), f"expected content blocks, got {result!r}"
images = [b for b in result if isinstance(b, dict) and b.get("type") == "image_url"]
texts = [b for b in result if isinstance(b, dict) and b.get("type") == "text"]
return images, texts
def _tool(**kw):
fb = _FakeBackend(width=kw.pop("w", 2560), height=kw.pop("h", 1600))
config = ComputerUseToolConfig(target_width=1280, target_height=800, **kw)
tool = ComputerUseTool(config, backend_impl=fb)
return tool, fb
# --------------------------- config + metadata ---------------------------
class TestConfigAndMetadata:
def test_defaults_off(self):
cfg = ComputerUseToolConfig()
assert cfg.enable is False
assert cfg.backend == "desktop"
assert (cfg.target_width, cfg.target_height) == (1280, 800)
assert cfg.max_sessions == 8
assert "require_approval" not in type(cfg).model_fields
def test_tools_config_accepts_camel_case(self):
cfg = ToolsConfig.model_validate({
"browser": {"enable": True, "maxSessions": 4},
"computerUse": {"enable": True, "backend": "browser", "maxSessions": 6},
})
assert cfg.browser.enable is True
assert cfg.browser.max_sessions == 4
assert cfg.computer_use.enable is True
assert cfg.computer_use.backend == "browser"
assert cfg.computer_use.max_sessions == 6
dumped = cfg.model_dump(by_alias=True)
assert "computerUse" in dumped
assert dumped["computerUse"]["maxSessions"] == 6
def test_enabled_reads_config(self):
ctx = MagicMock()
ctx.config.computer_use.enable = True
assert ComputerUseTool.enabled(ctx) is True
ctx.config.computer_use.enable = False
assert ComputerUseTool.enabled(ctx) is False
def test_create_from_ctx(self):
ctx = MagicMock()
ctx.config.computer_use = ComputerUseToolConfig(
enable=True, backend="browser", target_width=1024, target_height=768
)
tool = ComputerUseTool.create(ctx)
assert isinstance(tool, ComputerUseTool)
assert tool.config.backend == "browser"
assert (tool.config.target_width, tool.config.target_height) == (1024, 768)
def test_tool_metadata(self):
tool, _ = _tool()
assert tool.name == "computer_use"
assert tool.exclusive is True
assert tool.read_only is False
assert tool.concurrency_safe is False
# not exposed to subagents
assert "subagent" not in tool._scopes
def test_schema_has_action_enum(self):
tool, _ = _tool()
action = tool.parameters["properties"]["action"]
assert "screenshot" in action["enum"]
assert "left_click" in action["enum"]
assert tool.parameters["required"] == ["action"]
# --------------------------- execute dispatch ---------------------------
class TestExecute:
@pytest.mark.asyncio
async def test_screenshot_returns_image_blocks(self):
tool, fb = _tool()
result = await tool.execute(action="screenshot")
images, texts = _split(result)
assert len(images) == 1
assert images[0]["image_url"]["url"].startswith("data:image/png;base64,")
assert "1280x800" in texts[-1]["text"]
assert fb.calls == [] # screenshot performs no actuation
@pytest.mark.asyncio
async def test_left_click_scales_coordinates(self):
tool, fb = _tool() # real 2560x1600 -> target 1280x800 (2x)
result = await tool.execute(action="left_click", x=100, y=50)
assert fb.calls == [("click", 200, 100, "left", 1)]
_, texts = _split(result)
assert "left_click at (200, 100)" in texts[-1]["text"]
@pytest.mark.asyncio
async def test_click_clamps_coordinates_to_screen(self):
tool, fb = _tool()
await tool.execute(action="left_click", x=5000, y=-10)
assert fb.calls == [("click", 2559, 0, "left", 1)]
@pytest.mark.parametrize(
("action", "kwargs", "expected"),
[
("double_click", {"x": 10, "y": 10}, ("click", 20, 20, "left", 2)),
("triple_click", {"x": 10, "y": 10}, ("click", 20, 20, "left", 3)),
("right_click", {"x": 5, "y": 5}, ("click", 10, 10, "right", 1)),
("middle_click", {"x": 5, "y": 5}, ("click", 10, 10, "middle", 1)),
(
"scroll",
{"x": 100, "y": 100, "scroll_direction": "down", "scroll_amount": 5},
("scroll", 200, 200, "down", 5),
),
("type", {"text": "hello"}, ("type", "hello")),
("key", {"text": "ctrl+s"}, ("key", "ctrl+s")),
("mouse_move", {"x": 10, "y": 10}, ("move", 20, 20)),
("left_click_drag", {"x": 20, "y": 30}, ("drag", 40, 60)),
],
)
@pytest.mark.asyncio
async def test_actions_dispatch_to_backend(self, action, kwargs, expected):
tool, fb = _tool()
await tool.execute(action=action, **kwargs)
assert fb.calls == [expected]
@pytest.mark.asyncio
async def test_wait(self):
tool, fb = _tool()
result = await tool.execute(action="wait", duration=0.0)
_, texts = _split(result)
assert "Waited" in texts[-1]["text"]
@pytest.mark.parametrize(
("kwargs", "error"),
[
({"action": "frobnicate"}, "unknown action"),
({"action": "left_click"}, "requires"),
({"action": "navigate", "url": "https://example.com"}, "Error"),
],
)
@pytest.mark.asyncio
async def test_errors_are_returned_to_model(self, kwargs, error):
tool, _ = _tool()
result = await tool.execute(**kwargs)
assert isinstance(result, str) and error in result
assert result.is_error is True
@pytest.mark.asyncio
async def test_backend_pool_isolates_sessions_and_closes_all():
created: list[_FakeBackend] = []
finalized: list[bool] = []
def factory():
backend = _FakeBackend()
created.append(backend)
return backend
async def finalize():
finalized.append(all(backend.closed for backend in created))
pool = SessionBackendPool(factory, finalizer=finalize)
with request_context(RequestContext(channel="test", chat_id="a", session_key="test:a")):
first = await pool.get()
assert await pool.get() is first
with request_context(RequestContext(channel="test", chat_id="b", session_key="test:b")):
second = await pool.get()
assert first is not second
await pool.close()
assert len(created) == 2
assert all(backend.closed for backend in created)
assert finalized == [True]
await pool.close()
assert finalized == [True]
@pytest.mark.asyncio
async def test_backend_pool_evicts_least_recently_used_session():
created: list[_FakeBackend] = []
def factory():
backend = _FakeBackend()
created.append(backend)
return backend
pool = SessionBackendPool(factory, max_backends=2)
contexts = [
RequestContext(channel="test", chat_id=key, session_key=f"test:{key}")
for key in ("a", "b", "c")
]
with request_context(contexts[0]):
first = await pool.get()
with request_context(contexts[1]):
second = await pool.get()
with request_context(contexts[0]):
assert await pool.get() is first
with request_context(contexts[2]):
await pool.get()
assert first.closed is False
assert second.closed is True
await pool.close()
@pytest.mark.asyncio
async def test_desktop_tool_serializes_calls_across_sessions():
class SlowBackend(_FakeBackend):
active = 0
max_active = 0
async def dimensions(self):
self.active += 1
self.max_active = max(self.max_active, self.active)
await asyncio.sleep(0.01)
self.active -= 1
return await super().dimensions()
backend = SlowBackend(width=1280, height=800)
tool = ComputerUseTool(backend_impl=backend)
async def screenshot(session: str):
with request_context(RequestContext(channel="test", chat_id=session, session_key=session)):
return await tool.execute(action="screenshot")
await asyncio.gather(screenshot("a"), screenshot("b"))
assert backend.max_active == 1
@pytest.mark.asyncio
async def test_desktop_backend_uses_safe_pyautogui_calls():
pg = MagicMock()
pg.easeInOutQuad = object()
backend = DesktopBackend()
backend._pg = pg
await backend.drag(10, 20)
await backend.scroll(10, 20, "down", 3)
assert pg.dragTo.call_args.kwargs == {
"duration": 0.3,
"tween": pg.easeInOutQuad,
"button": "left",
}
pg.scroll.assert_called_once_with(-3)
@pytest.mark.asyncio
async def test_desktop_backend_rejects_unicode_instead_of_typing_incorrect_keys():
pg = MagicMock()
backend = DesktopBackend()
backend._pg = pg
with pytest.raises(ValueError, match="ASCII"):
await backend.type_text("你好")
pg.typewrite.assert_not_called()
def test_desktop_backend_preserves_pyautogui_failsafe(monkeypatch):
pg = SimpleNamespace(FAILSAFE=True)
monkeypatch.setitem(sys.modules, "pyautogui", pg)
backend = DesktopBackend()
assert backend._ensure() is pg
assert pg.FAILSAFE is True
+55
View File
@@ -5,10 +5,13 @@ import asyncio
import socket
from unittest.mock import MagicMock, patch
import httpx
import pytest
from nanobot.agent.tools import mcp as mcp_mod
from nanobot.agent.tools.mcp import _probe_http_url, connect_mcp_servers
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.config.schema import MCPServerConfig
from nanobot.security.network import configure_ssrf_whitelist
_PROXY_ENV_VARS = ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy")
@@ -171,6 +174,58 @@ async def test_connect_skips_unreachable_sse():
assert len(registry._tools) == 0
@pytest.mark.asyncio
async def test_connect_isolates_streamable_http_status_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A reachable endpoint returning HTTP 530 must not poison the event loop."""
async def _reachable(_url: str) -> bool:
return True
def _return_http_530(request: httpx.Request) -> httpx.Response:
return httpx.Response(530, text="cloudflare error 1033", request=request)
monkeypatch.setattr(mcp_mod, "validate_url_target", lambda _url: (True, ""))
monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable)
monkeypatch.setattr(
mcp_mod,
"PinnedDNSAsyncTransport",
lambda: httpx.MockTransport(_return_http_530),
)
loop = asyncio.get_running_loop()
previous_exception_handler = loop.get_exception_handler()
unhandled: list[BaseException] = []
def _capture_unhandled(_loop: asyncio.AbstractEventLoop, context: dict) -> None:
if isinstance(context.get("exception"), BaseException):
unhandled.append(context["exception"])
loop.set_exception_handler(_capture_unhandled)
try:
registry = ToolRegistry()
stacks = await asyncio.wait_for(
connect_mcp_servers(
{
"cloudflare": MCPServerConfig(
type="streamableHttp",
url="https://mcp.example.com/mcp",
)
},
registry,
),
timeout=5.0,
)
await asyncio.sleep(0)
assert stacks == {}
assert registry.tool_names == []
assert unhandled == []
assert not any(task.get_name() == "mcp:cloudflare" for task in asyncio.all_tasks())
finally:
loop.set_exception_handler(previous_exception_handler)
@pytest.mark.asyncio
async def test_probe_not_called_for_stdio():
"""stdio transport should not be probed — it spawns a local process."""
+45 -3
View File
@@ -1082,10 +1082,22 @@ async def test_connect_mcp_servers_http_clients_reject_unsafe_redirect_targets(
@pytest.mark.asyncio
@pytest.mark.parametrize("failure_mode", ["exception", "cancellation"])
async def test_connect_mcp_servers_one_failure_does_not_block_others(
monkeypatch: pytest.MonkeyPatch,
failure_mode: str,
) -> None:
sessions = {"good": _make_fake_session(["demo"])}
bad_session = _make_fake_session([])
async def _cancel_initialize() -> None:
raise asyncio.CancelledError("cancelled by SDK")
if failure_mode == "cancellation":
bad_session.initialize = _cancel_initialize
sessions = {
"bad": bad_session,
"good": _make_fake_session(["demo"]),
}
class _SelectiveClientSession:
def __init__(self, read: object, _write: object) -> None:
@@ -1099,7 +1111,7 @@ async def test_connect_mcp_servers_one_failure_does_not_block_others(
@asynccontextmanager
async def _selective_stdio_client(params: object):
if params.command == "bad":
if params.command == "bad" and failure_mode == "exception":
raise RuntimeError("boom")
yield params.command, object()
@@ -1109,8 +1121,8 @@ async def test_connect_mcp_servers_one_failure_does_not_block_others(
registry = ToolRegistry()
stacks = await connect_mcp_servers(
{
"good": MCPServerConfig(command="good"),
"bad": MCPServerConfig(command="bad"),
"good": MCPServerConfig(command="good"),
},
registry,
)
@@ -1121,6 +1133,36 @@ async def test_connect_mcp_servers_one_failure_does_not_block_others(
assert set(stacks) == {"good"}
@pytest.mark.asyncio
async def test_connect_mcp_servers_propagates_external_cancellation(
monkeypatch: pytest.MonkeyPatch,
) -> None:
started = asyncio.Event()
closed = asyncio.Event()
@asynccontextmanager
async def _blocking_stdio_client(_params: object):
try:
started.set()
await asyncio.Event().wait()
yield object(), object()
finally:
closed.set()
monkeypatch.setattr(sys.modules["mcp.client.stdio"], "stdio_client", _blocking_stdio_client)
task = asyncio.create_task(
connect_mcp_servers({"slow": MCPServerConfig(command="slow")}, ToolRegistry())
)
await asyncio.wait_for(started.wait(), timeout=1.0)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
await asyncio.wait_for(closed.wait(), timeout=1.0)
@pytest.mark.asyncio
async def test_connect_mcp_servers_streamable_http_uses_finite_timeout(
fake_mcp_runtime: dict[str, object | None],
-30
View File
@@ -28,18 +28,6 @@ class _FakeTool(Tool):
async def execute(self, **kwargs: Any) -> Any:
return kwargs
class _ClosableTool(_FakeTool):
def __init__(self, name: str, *, error: BaseException | None = None):
super().__init__(name)
self.closed = False
self.error = error
async def close(self) -> None:
self.closed = True
if self.error is not None:
raise self.error
def _tool_names(definitions: list[dict[str, Any]]) -> list[str]:
names: list[str] = []
for definition in definitions:
@@ -70,24 +58,6 @@ def test_get_definitions_orders_builtins_then_mcp_tools() -> None:
]
async def test_close_attempts_every_registered_tool() -> None:
registry = ToolRegistry()
broken = _ClosableTool("broken", error=RuntimeError("close failed"))
healthy = _ClosableTool("healthy")
registry.register(broken)
registry.register(healthy)
try:
await registry.close()
except RuntimeError as exc:
assert str(exc) == "close failed"
else:
raise AssertionError("expected close failure")
assert broken.closed is True
assert healthy.closed is True
def test_prepare_call_rejects_near_miss_tool_name_with_suggestion() -> None:
registry = ToolRegistry()
registry.register(_FakeTool("read_file"))
-30
View File
@@ -29,36 +29,6 @@ def test_estimate_prompt_tokens_chain_falls_back_without_provider_counter() -> N
assert source == "tiktoken"
def test_image_blocks_have_bounded_token_cost() -> None:
text = [{"role": "tool", "content": [{"type": "text", "text": "screen"}]}]
small_image = [{
"role": "tool",
"content": [
{"type": "image_url", "image_url": {"url": "data:image/png;base64,A"}},
{"type": "text", "text": "screen"},
],
}]
large_image = [{
"role": "tool",
"content": [
{
"type": "image_url",
"image_url": {"url": "data:image/png;base64," + "A" * 100_000},
},
{"type": "text", "text": "screen"},
],
}]
text_tokens = estimate_prompt_tokens(text)
small_tokens = estimate_prompt_tokens(small_image)
large_tokens = estimate_prompt_tokens(large_image)
assert small_tokens >= text_tokens + 2_000
assert large_tokens == small_tokens
assert estimate_message_tokens(large_image[0]) >= text_tokens + 2_000
assert estimate_message_tokens({"role": "user", "content": small_image[0]["content"][:1]}) > 2_000
def test_estimate_prompt_tokens_chain_falls_back_when_provider_counter_fails() -> None:
tokens, source = estimate_prompt_tokens_chain(
_BrokenCounterProvider(),
@@ -0,0 +1,65 @@
"""Shared characterization cases for live and persisted WebUI projection."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from nanobot.webui.transcript import replay_transcript_to_ui_messages
_FIXTURE_PATH = (
Path(__file__).parents[2]
/ "webui"
/ "src"
/ "tests"
/ "fixtures"
/ "live-replay-event-projection.json"
)
_SEMANTIC_MESSAGE_FIELDS = (
"role",
"content",
"kind",
"traces",
"toolEvents",
"fileEdits",
"images",
"media",
"cliApps",
"mcpPresets",
"sessionMentions",
"reasoning",
"latencyMs",
"source",
"turnId",
"turnPhase",
"turnSeq",
)
def _normalize_projection(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
segment_aliases: dict[str, str] = {}
normalized: list[dict[str, Any]] = []
for message in messages:
row = {
field: message[field]
for field in _SEMANTIC_MESSAGE_FIELDS
if field in message and message[field] is not None
}
segment_id = message.get("activitySegmentId")
if isinstance(segment_id, str) and segment_id:
row["activitySegmentId"] = segment_aliases.setdefault(
segment_id,
f"segment-{len(segment_aliases) + 1}",
)
normalized.append(row)
return normalized
def test_replay_matches_shared_live_projection_before_canonical_revision_migration() -> None:
"""Lock the known-equivalent subset without defining the future snapshot protocol."""
fixture = json.loads(_FIXTURE_PATH.read_text(encoding="utf-8"))
for case in fixture["cases"]:
actual = replay_transcript_to_ui_messages(case["transcript"])
assert _normalize_projection(actual) == case["expected"], case["name"]
+162
View File
@@ -0,0 +1,162 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import ANY, AsyncMock, MagicMock
import pytest
import nanobot.webui.forking as forking
from nanobot.session.webui_turns import WEBUI_TITLE_METADATA_KEY
def test_create_fork_rebuilds_missing_transcript_and_saves_clean_title(
monkeypatch: pytest.MonkeyPatch,
) -> None:
forked = SimpleNamespace(messages=[{"role": "user", "content": "hello"}], metadata={})
manager = MagicMock()
manager.fork_session_before_user_index.return_value = forked
rebuild = MagicMock()
marker = MagicMock()
monkeypatch.setattr(forking.uuid, "uuid4", lambda: "fork-id")
monkeypatch.setattr(forking, "fork_transcript_before_user_index", lambda *_args: False)
monkeypatch.setattr(forking, "write_session_messages_as_transcript", rebuild)
monkeypatch.setattr(forking, "append_fork_marker", marker)
result = forking.create_webui_chat_fork(
manager,
source_chat_id="source",
before_user_index=2,
title=" Useful fork ",
)
assert result == ("fork-id", "websocket:fork-id")
manager.fork_session_before_user_index.assert_called_once_with(
"websocket:source",
"websocket:fork-id",
2,
)
rebuild.assert_called_once_with("websocket:fork-id", forked.messages)
marker.assert_called_once_with("websocket:fork-id")
assert forked.metadata[WEBUI_TITLE_METADATA_KEY] == "Useful fork"
manager.save.assert_called_once_with(forked, fsync=True)
def test_create_fork_rolls_back_session_and_transcript_together(
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = MagicMock()
manager.fork_session_before_user_index.return_value = SimpleNamespace(
messages=[],
metadata={},
)
delete_transcript = MagicMock()
monkeypatch.setattr(forking.uuid, "uuid4", lambda: "failed-fork")
monkeypatch.setattr(
forking,
"fork_transcript_before_user_index",
MagicMock(side_effect=OSError("disk full")),
)
monkeypatch.setattr(forking, "delete_webui_transcript", delete_transcript)
with pytest.raises(OSError, match="disk full"):
forking.create_webui_chat_fork(
manager,
source_chat_id="source",
before_user_index=1,
)
delete_transcript.assert_called_once_with("websocket:failed-fork")
manager.delete_session.assert_called_once_with("websocket:failed-fork")
def test_create_fork_stops_before_transcript_work_when_source_is_invalid(
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = MagicMock()
manager.fork_session_before_user_index.return_value = None
fork_transcript = MagicMock()
monkeypatch.setattr(forking, "fork_transcript_before_user_index", fork_transcript)
result = forking.create_webui_chat_fork(
manager,
source_chat_id="missing",
before_user_index=1,
)
assert result is None
fork_transcript.assert_not_called()
@pytest.mark.parametrize(
("envelope", "detail"),
[
({"source_chat_id": "bad/id", "before_user_index": 0}, "invalid source_chat_id"),
({"source_chat_id": "source", "before_user_index": True}, "invalid before_user_index"),
({"source_chat_id": "source", "before_user_index": -1}, "invalid before_user_index"),
],
)
@pytest.mark.asyncio
async def test_fork_handler_rejects_invalid_protocol_input(
envelope: dict[str, object],
detail: str,
) -> None:
connection = object()
channel = SimpleNamespace(
send_webui_protocol_error=AsyncMock(),
gateway=SimpleNamespace(session_manager=MagicMock()),
)
await forking.handle_webui_fork_chat(channel, connection, envelope)
channel.send_webui_protocol_error.assert_awaited_once_with(connection, detail)
@pytest.mark.asyncio
async def test_fork_handler_reports_unavailable_session_manager() -> None:
connection = object()
channel = SimpleNamespace(
send_webui_protocol_error=AsyncMock(),
gateway=SimpleNamespace(session_manager=None),
)
await forking.handle_webui_fork_chat(
channel,
connection,
{"source_chat_id": "source", "before_user_index": 0},
)
channel.send_webui_protocol_error.assert_awaited_once_with(
connection,
"session_manager_unavailable",
)
@pytest.mark.asyncio
async def test_fork_handler_maps_invalid_source_and_internal_failure_to_stable_errors(
monkeypatch: pytest.MonkeyPatch,
) -> None:
connection = object()
channel = SimpleNamespace(
send_webui_protocol_error=AsyncMock(),
gateway=SimpleNamespace(session_manager=MagicMock()),
logger=SimpleNamespace(warning=MagicMock()),
)
envelope = {"source_chat_id": "source", "before_user_index": 0}
monkeypatch.setattr(forking, "create_webui_chat_fork", lambda *_args, **_kwargs: None)
await forking.handle_webui_fork_chat(channel, connection, envelope)
channel.send_webui_protocol_error.assert_awaited_once_with(
connection,
"invalid fork source or index",
)
channel.send_webui_protocol_error.reset_mock()
monkeypatch.setattr(
forking,
"create_webui_chat_fork",
MagicMock(side_effect=RuntimeError("broken transcript")),
)
await forking.handle_webui_fork_chat(channel, connection, envelope)
channel.logger.warning.assert_called_once_with("fork_chat failed: {}", ANY)
channel.send_webui_protocol_error.assert_awaited_once_with(connection, "fork_chat_failed")
+46 -59
View File
@@ -12,7 +12,6 @@ from nanobot.config.schema import Config, InlineFallbackConfig, ModelPresetConfi
from nanobot.providers.registry import find_by_name
from nanobot.webui.settings_api import (
WebUISettingsError,
_clear_webui_oauth_flows,
_docs_version,
_model_catalog_kind,
_oauth_provider_status,
@@ -29,7 +28,6 @@ from nanobot.webui.settings_api import (
settings_usage_payload,
update_agent_settings,
update_api_settings,
update_computer_use_settings,
update_model_call_order,
update_model_configuration,
update_network_safety_settings,
@@ -37,11 +35,17 @@ from nanobot.webui.settings_api import (
update_transcription_settings,
update_web_search_settings,
)
from nanobot.webui.settings_services import WebUIOAuthFlowRegistry
DYNAMIC_PROVIDER_NAME = "my-company-api"
DYNAMIC_PROVIDER_API_BASE = "https://example.test/v1"
@pytest.fixture
def oauth_flows() -> WebUIOAuthFlowRegistry:
return WebUIOAuthFlowRegistry()
def test_settings_payload_propagates_preset_resolution_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -1008,27 +1012,6 @@ def test_settings_payload_includes_network_safety_fields(
assert payload["advanced"]["ssrf_whitelist_count"] == 1
def test_settings_payload_includes_computer_use_tools(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.tools.browser.enable = True
config.tools.computer_use.enable = True
config.tools.computer_use.backend = "browser"
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = settings_payload()
assert payload["computer_use"] == {
"browser_enabled": True,
"enabled": True,
"backend": "browser",
}
def test_settings_payload_includes_exec_path_flags(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
@@ -1396,32 +1379,6 @@ def test_update_network_safety_settings_writes_local_service_flag(
assert payload["requires_restart"] is True
def test_update_computer_use_settings_writes_only_requested_switches(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
browser_payload = update_computer_use_settings({"browser_enabled": ["true"]})
saved = load_config(config_path)
assert saved.tools.browser.enable is True
assert saved.tools.computer_use.enable is False
assert browser_payload["requires_restart"] is True
computer_payload = update_computer_use_settings({"computerEnabled": ["true"]})
saved = load_config(config_path)
assert saved.tools.browser.enable is True
assert saved.tools.computer_use.enable is True
assert computer_payload["computer_use"]["enabled"] is True
def test_update_computer_use_settings_requires_a_switch() -> None:
with pytest.raises(WebUISettingsError, match="browser_enabled or enabled"):
update_computer_use_settings({})
def test_update_network_safety_settings_accepts_legacy_restricted_default_access(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
@@ -1538,6 +1495,7 @@ def test_xai_grok_status_accepts_refreshable_login(
def test_openai_codex_oauth_login_passes_configured_proxy(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
oauth_flows: WebUIOAuthFlowRegistry,
) -> None:
proxy = "http://127.0.0.1:23458"
config_path = tmp_path / "config.json"
@@ -1570,7 +1528,10 @@ def test_openai_codex_oauth_login_passes_configured_proxy(
fake_start,
)
payload = login_oauth_provider({"provider": ["openai-codex"]})
payload = login_oauth_provider(
{"provider": ["openai-codex"]},
oauth_flows=oauth_flows,
)
assert captured == {
"proxy": proxy,
@@ -1596,15 +1557,17 @@ def test_openai_codex_oauth_login_passes_configured_proxy(
)
monkeypatch.setattr(
"nanobot.webui.settings_api.settings_payload",
lambda: {"settings": "ready"},
lambda **_kwargs: {"settings": "ready"},
)
pending = complete_oauth_provider(
{"provider": ["openai-codex"], "flow_id": [payload["flow_id"]]},
oauth_flows=oauth_flows,
)
completed = complete_oauth_provider(
{"provider": ["openai-codex"], "flow_id": [payload["flow_id"]]},
"http://localhost:1455/auth/callback?code=secret&state=test",
oauth_flows=oauth_flows,
)
assert pending == {
@@ -1622,6 +1585,7 @@ def test_openai_codex_oauth_login_passes_configured_proxy(
def test_openai_codex_remote_login_uses_headless_dependency_mode(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
oauth_flows: WebUIOAuthFlowRegistry,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
@@ -1647,10 +1611,11 @@ def test_openai_codex_remote_login_uses_headless_dependency_mode(
try:
payload = login_oauth_provider(
{"provider": ["openai-codex"], "remote_browser": ["true"]}
{"provider": ["openai-codex"], "remote_browser": ["true"]},
oauth_flows=oauth_flows,
)
finally:
_clear_webui_oauth_flows("openai_codex")
oauth_flows.clear("openai_codex")
assert payload["completion_input"] == "callback_url"
assert captured["open_browser"] is False
@@ -1659,6 +1624,7 @@ def test_openai_codex_remote_login_uses_headless_dependency_mode(
def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit(
monkeypatch: pytest.MonkeyPatch,
oauth_flows: WebUIOAuthFlowRegistry,
) -> None:
real_import = builtins.__import__
@@ -1670,7 +1636,10 @@ def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit(
monkeypatch.setattr(builtins, "__import__", fake_import)
with pytest.raises(WebUISettingsError) as exc:
login_oauth_provider({"provider": ["openai-codex"]})
login_oauth_provider(
{"provider": ["openai-codex"]},
oauth_flows=oauth_flows,
)
assert str(exc.value) == (
"This nanobot installation is missing the required oauth-cli-kit package. "
@@ -1680,6 +1649,7 @@ def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit(
def test_github_copilot_oauth_login_reports_missing_oauth_cli_kit(
monkeypatch: pytest.MonkeyPatch,
oauth_flows: WebUIOAuthFlowRegistry,
) -> None:
real_import = builtins.__import__
@@ -1691,7 +1661,10 @@ def test_github_copilot_oauth_login_reports_missing_oauth_cli_kit(
monkeypatch.setattr(builtins, "__import__", fake_import)
with pytest.raises(WebUISettingsError) as exc:
login_oauth_provider({"provider": ["github-copilot"]})
login_oauth_provider(
{"provider": ["github-copilot"]},
oauth_flows=oauth_flows,
)
assert str(exc.value) == (
"This nanobot installation is missing the required oauth-cli-kit package. "
@@ -1702,6 +1675,7 @@ def test_github_copilot_oauth_login_reports_missing_oauth_cli_kit(
def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
oauth_flows: WebUIOAuthFlowRegistry,
) -> None:
proxy = "http://127.0.0.1:23458"
config_path = tmp_path / "config.json"
@@ -1723,7 +1697,10 @@ def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
monkeypatch.setattr("nanobot.providers.xai_oauth.start_xai_oauth_login", fake_start)
payload = login_oauth_provider({"provider": ["xai-grok"]})
payload = login_oauth_provider(
{"provider": ["xai-grok"]},
oauth_flows=oauth_flows,
)
assert captured["proxy"] == proxy
assert captured["timeout_s"] == 600
@@ -1747,15 +1724,17 @@ def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
)
monkeypatch.setattr(
"nanobot.webui.settings_api.settings_payload",
lambda: {"settings": "ready"},
lambda **_kwargs: {"settings": "ready"},
)
pending = complete_oauth_provider(
{"provider": ["xai-grok"], "flow_id": [payload["flow_id"]]},
oauth_flows=oauth_flows,
)
completed = complete_oauth_provider(
{"provider": ["xai-grok"], "flow_id": [payload["flow_id"]]},
"secret",
oauth_flows=oauth_flows,
)
assert pending == {
@@ -1770,6 +1749,7 @@ def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
def test_xai_grok_login_reports_upstream_failure_as_bad_gateway(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
oauth_flows: WebUIOAuthFlowRegistry,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
@@ -1782,7 +1762,10 @@ def test_xai_grok_login_reports_upstream_failure_as_bad_gateway(
monkeypatch.setattr("nanobot.providers.xai_oauth.start_xai_oauth_login", fake_start)
with pytest.raises(WebUISettingsError) as exc:
login_oauth_provider({"provider": ["xai-grok"]})
login_oauth_provider(
{"provider": ["xai-grok"]},
oauth_flows=oauth_flows,
)
assert exc.value.status == 502
assert str(exc.value) == (
@@ -1794,6 +1777,7 @@ def test_xai_grok_login_reports_upstream_failure_as_bad_gateway(
def test_xai_grok_logout_removes_token_through_shared_lock(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
oauth_flows: WebUIOAuthFlowRegistry,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
@@ -1807,7 +1791,10 @@ def test_xai_grok_logout_removes_token_through_shared_lock(
lambda: token_path,
)
logout_oauth_provider({"provider": ["xai-grok"]})
logout_oauth_provider(
{"provider": ["xai-grok"]},
oauth_flows=oauth_flows,
)
assert not token_path.exists()
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
from typing import Any
from nanobot.config.schema import Config
from nanobot.webui.settings_capabilities import (
capability_settings_payload,
update_api_settings,
update_image_generation_settings,
update_network_safety_settings,
update_transcription_settings,
update_web_search_settings,
)
def _oauth_status(_spec: Any) -> dict[str, Any]:
return {"configured": False}
def test_capability_domain_updates_representative_settings() -> None:
config = Config()
config.providers.openrouter.api_key = "sk-test"
web_changed, web_restart = update_web_search_settings(
config,
{
"provider": ["duckduckgo"],
"max_results": ["7"],
"use_jina_reader": ["false"],
},
)
update_api_settings(
config,
{"host": ["127.0.0.2"], "port": ["8900"], "timeout": ["90"]},
)
image_changed = update_image_generation_settings(
config,
{"enabled": ["true"], "provider": ["openrouter"]},
oauth_status=_oauth_status,
)
transcription_changed = update_transcription_settings(
config,
{"provider": ["openrouter"], "model": ["openai/whisper-large-v3"]},
)
network_changed, access_mode = update_network_safety_settings(
config,
{
"webui_allow_local_service_access": ["false"],
"webui_default_access_mode": ["restricted"],
},
)
payload = capability_settings_payload(config, oauth_status=_oauth_status)
assert (web_changed, web_restart) == (True, True)
assert image_changed is True
assert transcription_changed is True
assert (network_changed, access_mode) == (True, "default")
assert payload["web_search"]["max_results"] == 7
assert payload["api"]["host"] == "127.0.0.2"
assert payload["api"]["port"] == 8900
assert payload["image_generation"]["enabled"] is True
assert payload["transcription"]["provider"] == "openrouter"
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
from typing import Any
from nanobot.config.schema import Config
from nanobot.webui.settings_models import (
model_settings_payload,
update_agent_model_settings,
update_provider_settings,
)
def _oauth_status(_spec: Any) -> dict[str, Any]:
return {
"configured": False,
"account": None,
"expires_at": None,
"login_supported": True,
}
def test_model_domain_owns_dto_and_config_updates() -> None:
config = Config()
config.providers.openrouter.api_key = "sk-before"
agent_changed = update_agent_model_settings(
config,
{
"model": ["openai/gpt-5.4"],
"provider": ["openrouter"],
"context_window_tokens": ["200000"],
},
oauth_status=_oauth_status,
)
provider_changed, restart_required = update_provider_settings(
config,
{
"provider": ["openrouter"],
"api_key": ["sk-after"],
},
)
payload = model_settings_payload(config, oauth_status=_oauth_status)
assert agent_changed is True
assert provider_changed is True
assert restart_required is False
assert config.agents.defaults.model == "openai/gpt-5.4"
assert config.agents.defaults.provider == "openrouter"
assert config.agents.defaults.context_window_tokens == 200_000
assert config.providers.openrouter.api_key == "sk-after"
assert set(payload) == {
"agent",
"model_presets",
"model_call_order",
"model_call_order_editable",
"providers",
}
assert payload["agent"]["model"] == "openai/gpt-5.4"
+99 -63
View File
@@ -2,17 +2,21 @@ from __future__ import annotations
import json
from types import SimpleNamespace
from unittest.mock import MagicMock
from urllib.parse import parse_qs, urlsplit
import pytest
from websockets.datastructures import Headers
from nanobot.config.loader import get_config_path
from nanobot.webui.http_utils import http_json_response
from nanobot.webui.settings_routes import WebUISettingsRouter
from nanobot.webui.settings_services import WebUISettingsServices
def _router(*, authorized: bool = True) -> WebUISettingsRouter:
return WebUISettingsRouter(
settings=WebUISettingsServices.create(get_config_path()),
bus=SimpleNamespace(),
logger=SimpleNamespace(exception=lambda *_args: None),
check_api_token=lambda _request: authorized,
@@ -27,27 +31,39 @@ def _router(*, authorized: bool = True) -> WebUISettingsRouter:
)
def _mutation_request(path: str, payload: dict[str, object]) -> SimpleNamespace:
request = SimpleNamespace(path=path, headers=Headers())
request._nanobot_webui_mutation_request = True
request._nanobot_webui_mutation_payload = payload
request._nanobot_trusted_proxy_authenticated = True
return request
@pytest.mark.parametrize(
("provider", "header_name", "authorization_response"),
("provider", "authorization_response"),
[
("xai_grok", "X-Nanobot-OAuth-Code", "secret"),
("xai_grok", "secret"),
(
"openai_codex",
"X-Nanobot-OAuth-Callback",
"http://localhost:1455/auth/callback?code=secret&state=test",
),
],
)
@pytest.mark.asyncio
async def test_oauth_completion_reads_private_response_header(
async def test_oauth_completion_reads_websocket_payload(
monkeypatch,
provider: str,
header_name: str,
authorization_response: str,
) -> None:
captured: dict[str, object] = {}
def complete(query, authorization_response=None):
def complete(
query,
authorization_response=None,
*,
oauth_flows=None,
config_path=None,
):
captured.update(query=query, authorization_response=authorization_response)
return {
"status": "pending",
@@ -57,19 +73,13 @@ async def test_oauth_completion_reads_private_response_header(
monkeypatch.setattr("nanobot.webui.settings_routes.complete_oauth_provider", complete)
router = _router()
request = SimpleNamespace(
path=(
"/api/settings/provider/oauth-login/complete"
f"?provider={provider}&flow_id=flow-123"
),
headers=Headers(
[
(
header_name,
authorization_response,
)
]
),
request = _mutation_request(
"/api/settings/provider/oauth-login/complete",
{
"provider": provider,
"flow_id": "flow-123",
"authorization_response": authorization_response,
},
)
response = await router.dispatch(
@@ -89,28 +99,29 @@ async def test_oauth_completion_reads_private_response_header(
"query": {"provider": [provider], "flow_id": ["flow-123"]},
"authorization_response": authorization_response,
}
assert authorization_response not in request.path
assert request.path == "/api/settings/provider/oauth-login/complete"
assert not request.headers
@pytest.mark.parametrize(
("request_path", "route_path", "function_name", "expected_query"),
("route_path", "function_name", "payload", "expected_query"),
[
(
"/api/settings/model-configurations/delete?name=spare",
"/api/settings/model-configurations/delete",
"delete_model_configuration",
{"name": "spare"},
{"name": ["spare"]},
),
(
"/api/settings/model-configurations/migrate",
"/api/settings/model-configurations/migrate",
"migrate_model_configurations",
{},
{},
),
(
"/api/settings/model-call-order/update?order=%5B%22backup%22%5D",
"/api/settings/model-call-order/update",
"update_model_call_order",
{"order": ["backup"]},
{"order": ['["backup"]']},
),
],
@@ -118,19 +129,19 @@ async def test_oauth_completion_reads_private_response_header(
@pytest.mark.asyncio
async def test_model_preset_mutation_routes(
monkeypatch,
request_path: str,
route_path: str,
function_name: str,
payload: dict[str, object],
expected_query: dict[str, list[str]],
) -> None:
captured: dict[str, object] = {}
def mutate(query):
def mutate(query, *, config_path=None):
captured["query"] = query
return {"routed": function_name}
monkeypatch.setattr(f"nanobot.webui.settings_routes.{function_name}", mutate)
request = SimpleNamespace(path=request_path, headers=Headers())
request = _mutation_request(route_path, payload)
response = await _router().dispatch(None, request, route_path)
@@ -140,52 +151,77 @@ async def test_model_preset_mutation_routes(
assert captured["query"] == expected_query
@pytest.mark.asyncio
async def test_settings_get_mutation_route_is_method_not_allowed() -> None:
path = "/api/settings/provider/update"
request = SimpleNamespace(
path=f"{path}?provider=openrouter&api_key=must-not-run",
headers=Headers(),
)
response = await _router().dispatch(None, request, path)
assert response is not None
assert response.status_code == 405
assert json.loads(response.body) == {
"error": "WebUI mutations require an authenticated WebSocket"
}
@pytest.mark.parametrize(
("query", "expected_query", "expected_sections"),
("update_info", "expected"),
[
(None, {"updateAvailable": None}),
(
"browser_enabled=true",
{"browser_enabled": ["true"]},
["browser"],
),
(
"computerEnabled=true",
{"computerEnabled": ["true"]},
["runtime"],
),
(
"browserEnabled=true&enabled=true",
{"browserEnabled": ["true"], "enabled": ["true"]},
["browser", "runtime"],
{
"currentVersion": "1.2.0",
"latestVersion": "1.3.0",
"pypiUrl": "https://pypi.org/project/nanobot-ai/",
},
{
"updateAvailable": {
"currentVersion": "1.2.0",
"latestVersion": "1.3.0",
"pypiUrl": "https://pypi.org/project/nanobot-ai/",
}
},
),
],
)
@pytest.mark.asyncio
async def test_computer_use_update_route(
monkeypatch,
query: str,
expected_query: dict[str, list[str]],
expected_sections: list[str],
async def test_version_check_route_returns_stable_payload(
monkeypatch: pytest.MonkeyPatch,
update_info: dict[str, str] | None,
expected: dict[str, object],
) -> None:
captured: dict[str, object] = {}
def update(query):
captured["query"] = query
return {"requires_restart": True}
monkeypatch.setattr("nanobot.webui.settings_routes.update_computer_use_settings", update)
request = SimpleNamespace(
path=f"/api/settings/computer-use/update?{query}",
headers=Headers(),
monkeypatch.setattr(
"nanobot.webui.settings_routes.check_for_update",
lambda: update_info,
)
request = SimpleNamespace(path="/api/settings/version-check", headers=Headers())
response = await _router().dispatch(
None,
request,
"/api/settings/computer-use/update",
)
response = await _router().dispatch(None, request, request.path)
assert response is not None
assert response.status_code == 200
assert captured["query"] == expected_query
assert json.loads(response.body)["restart_required_sections"] == expected_sections
assert json.loads(response.body) == expected
@pytest.mark.asyncio
async def test_version_check_route_enforces_auth_and_bounds_failures(
monkeypatch: pytest.MonkeyPatch,
) -> None:
check = MagicMock(side_effect=RuntimeError("upstream secret body"))
monkeypatch.setattr("nanobot.webui.settings_routes.check_for_update", check)
request = SimpleNamespace(path="/api/settings/version-check", headers=Headers())
unauthorized = await _router(authorized=False).dispatch(None, request, request.path)
assert unauthorized is not None
assert unauthorized.status_code == 401
check.assert_not_called()
failed = await _router().dispatch(None, request, request.path)
assert failed is not None
assert failed.status_code == 500
assert json.loads(failed.body) == {"error": "version check failed"}
assert "upstream secret body" not in failed.body.decode()
+170
View File
@@ -0,0 +1,170 @@
from __future__ import annotations
import threading
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from nanobot.channels.websocket.runtime import WebSocketConfig
from nanobot.config.loader import load_config, save_config
from nanobot.config.schema import Config
from nanobot.webui.gateway_services import build_gateway_services
from nanobot.webui.settings_api import settings_payload, update_agent_settings, update_api_settings
from nanobot.webui.settings_services import (
WebUIOAuthFlowRegistry,
WebUISettingsServices,
)
class _Flow:
def __init__(self, *, expired: bool = False) -> None:
self.expired = expired
self.cancel_count = 0
def cancel(self) -> None:
self.cancel_count += 1
def _gateway(config_path: Path, workspace: Path):
return build_gateway_services(
config=WebSocketConfig(),
bus=MagicMock(),
session_manager=None,
static_dist_path=None,
workspace_path=workspace,
default_restrict_to_workspace=False,
config_path=config_path,
runtime_model_name=None,
runtime_surface="browser",
runtime_capabilities_overrides=None,
)
def test_gateway_settings_services_isolate_config_paths_and_oauth_flows(
tmp_path: Path,
) -> None:
first_path = tmp_path / "first" / "config.json"
second_path = tmp_path / "second" / "config.json"
first_config = Config()
first_config.api.host = "127.0.0.2"
second_config = Config()
second_config.api.host = "127.0.0.3"
save_config(first_config, first_path)
save_config(second_config, second_path)
first = _gateway(first_path, tmp_path / "first-workspace")
second = _gateway(second_path, tmp_path / "second-workspace")
assert first.settings.config.path == first_path.resolve()
assert second.settings.config.path == second_path.resolve()
assert first.http.settings_routes.settings is first.settings
assert second.http.settings_routes.settings is second.settings
assert first.settings.config.load().api.host == "127.0.0.2"
assert second.settings.config.load().api.host == "127.0.0.3"
assert first.settings.read(settings_payload)["api"]["host"] == "127.0.0.2"
assert second.settings.read(settings_payload)["api"]["host"] == "127.0.0.3"
first.settings.mutate(update_api_settings, {"port": ["19001"]})
assert load_config(first_path).api.port == 19001
assert load_config(second_path).api.port != 19001
first_flow = _Flow()
second_flow = _Flow()
first.settings.oauth_flows.register("openai_codex", "same-id", first_flow)
second.settings.oauth_flows.register("openai_codex", "same-id", second_flow)
assert first.settings.oauth_flows.get("openai_codex", "same-id") is first_flow
assert second.settings.oauth_flows.get("openai_codex", "same-id") is second_flow
first.settings.oauth_flows.clear("openai_codex")
assert first_flow.cancel_count == 1
assert second_flow.cancel_count == 0
def test_settings_mutations_serialize_read_modify_write(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
services = WebUISettingsServices.create(config_path)
first_loaded = threading.Event()
release_first = threading.Event()
second_started = threading.Event()
second_loaded = threading.Event()
errors: list[BaseException] = []
from nanobot.webui import settings_api
original_load = settings_api._load_settings_config
def controlled_load(path: Path | None) -> Config:
config = original_load(path)
if threading.current_thread().name == "settings-first":
first_loaded.set()
if not release_first.wait(timeout=2):
raise TimeoutError("timed out waiting to release first settings mutation")
elif threading.current_thread().name == "settings-second":
second_loaded.set()
return config
monkeypatch.setattr(settings_api, "_load_settings_config", controlled_load)
def run_first() -> None:
try:
services.mutate(update_agent_settings, {"timezone": ["Asia/Tokyo"]})
except BaseException as exc: # noqa: BLE001 - re-raised in the test thread
errors.append(exc)
def run_second() -> None:
try:
second_started.set()
services.mutate(update_api_settings, {"host": ["127.0.0.9"]})
except BaseException as exc: # noqa: BLE001 - re-raised in the test thread
errors.append(exc)
first = threading.Thread(target=run_first, name="settings-first")
second = threading.Thread(target=run_second, name="settings-second")
first.start()
assert first_loaded.wait(timeout=2)
second.start()
assert second_started.wait(timeout=2)
assert not second_loaded.wait(timeout=0.1)
release_first.set()
first.join(timeout=2)
second.join(timeout=2)
assert not first.is_alive()
assert not second.is_alive()
assert not errors
saved = load_config(config_path)
assert saved.agents.defaults.timezone == "Asia/Tokyo"
assert saved.api.host == "127.0.0.9"
def test_oauth_registry_preserves_expiry_capacity_completion_and_cancel() -> None:
registry = WebUIOAuthFlowRegistry(max_flows=2)
expired = _Flow(expired=True)
oldest = _Flow()
newest = _Flow()
replacement = _Flow()
registry.register("openai_codex", "expired", expired)
registry.register("openai_codex", "oldest", oldest)
assert expired.cancel_count == 1
assert registry.get("openai_codex", "expired") is None
registry.register("xai_grok", "newest", newest)
registry.register("openai_codex", "replacement", replacement)
assert oldest.cancel_count == 1
assert registry.get("openai_codex", "oldest") is None
assert registry.get("xai_grok", "newest") is newest
assert registry.get("openai_codex", "newest") is None
registry.remove("xai_grok", "newest", newest, cancel=False)
assert newest.cancel_count == 0
assert registry.get("xai_grok", "newest") is None
registry.clear("openai_codex")
assert replacement.cancel_count == 1
assert registry.get("openai_codex", "replacement") is None
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
from nanobot.config.schema import Config
from nanobot.webui.settings_system import (
coerce_channel_value,
system_settings_payload,
update_agent_system_settings,
)
def test_system_domain_owns_runtime_dto_and_agent_updates(tmp_path) -> None:
config = Config()
changed, restart_required = update_agent_system_settings(
config,
{
"timezone": ["Asia/Shanghai"],
"tool_hint_max_length": ["120"],
},
)
payload = system_settings_payload(
config,
config_path=tmp_path / "config.json",
version="0.3.0",
)
assert changed is True
assert restart_required is True
assert config.agents.defaults.timezone == "Asia/Shanghai"
assert config.agents.defaults.timezone_mode == "manual"
assert config.agents.defaults.tool_hint_max_length == 120
assert payload["runtime"]["config_path"] == str(tmp_path / "config.json")
assert payload["version"] == {"current": "0.3.0"}
assert payload["docs"]["version"] == "0.3.0"
assert set(payload) == {"runtime", "usage", "advanced", "version", "docs"}
def test_system_domain_validates_channel_field_values() -> None:
assert coerce_channel_value("allow_from", "alice, bob", "list") == [
"alice",
"bob",
]
assert coerce_channel_value("enabled", "yes", "bool") is True
assert coerce_channel_value("port", "8765", "int") == 8765
+64
View File
@@ -0,0 +1,64 @@
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
import nanobot.webui.version_check as version_check
@pytest.fixture(autouse=True)
def _reset_version_cache(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(version_check, "_cache", (0.0, None))
monkeypatch.setattr(version_check.time, "monotonic", lambda: 1_000.0)
def _pypi_response(latest: object) -> MagicMock:
response = MagicMock()
response.json.return_value = {"info": {"version": latest}}
return response
def test_version_check_reports_only_a_newer_release_and_caches_it(
monkeypatch: pytest.MonkeyPatch,
) -> None:
get = MagicMock(return_value=_pypi_response("1.3.0"))
monkeypatch.setattr(version_check, "__version__", "1.2.0")
monkeypatch.setattr(version_check.httpx, "get", get)
expected = {
"currentVersion": "1.2.0",
"latestVersion": "1.3.0",
"pypiUrl": "https://pypi.org/project/nanobot-ai/",
}
assert version_check.check_for_update() == expected
assert version_check.check_for_update() == expected
get.assert_called_once_with(
"https://pypi.org/pypi/nanobot-ai/json",
timeout=5.0,
follow_redirects=True,
)
@pytest.mark.parametrize("latest", ["1.2.0", "1.1.9", "not-a-version", 42, None])
def test_version_check_ignores_non_newer_or_invalid_releases(
monkeypatch: pytest.MonkeyPatch,
latest: object,
) -> None:
monkeypatch.setattr(version_check, "__version__", "1.2.0")
monkeypatch.setattr(version_check.httpx, "get", lambda *_args, **_kwargs: _pypi_response(latest))
assert version_check.check_for_update() is None
def test_version_check_treats_network_failure_as_best_effort(
monkeypatch: pytest.MonkeyPatch,
) -> None:
get = MagicMock(side_effect=TimeoutError("offline"))
monkeypatch.setattr(version_check.httpx, "get", get)
assert version_check.check_for_update() is None
# Failures are not cached, so a later explicit check can recover.
assert version_check.check_for_update() is None
assert get.call_count == 2
+79
View File
@@ -40,6 +40,7 @@
"@types/react-dom": "^18.3.5",
"@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "2.1.9",
"autoprefixer": "^10.4.20",
"eslint": "^10.4.0",
"eslint-plugin-react-hooks": "^7.1.1",
@@ -61,6 +62,8 @@
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
"@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="],
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
@@ -103,6 +106,8 @@
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@0.2.3", "", {}, "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw=="],
"@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="],
"@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="],
@@ -191,6 +196,10 @@
"@iconify/utils": ["@iconify/utils@3.1.4", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw=="],
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@istanbuljs/schema": ["@istanbuljs/schema@0.1.6", "", {}, "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@@ -209,6 +218,8 @@
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="],
@@ -467,6 +478,8 @@
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"@vitest/coverage-v8": ["@vitest/coverage-v8@2.1.9", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^0.2.3", "debug": "^4.3.7", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", "magic-string": "^0.30.12", "magicast": "^0.3.5", "std-env": "^3.8.0", "test-exclude": "^7.0.1", "tinyrainbow": "^1.2.0" }, "peerDependencies": { "@vitest/browser": "2.1.9", "vitest": "2.1.9" }, "optionalPeers": ["@vitest/browser"] }, "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ=="],
"@vitest/expect": ["@vitest/expect@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw=="],
"@vitest/mocker": ["@vitest/mocker@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.12" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg=="],
@@ -673,6 +686,8 @@
"dompurify": ["dompurify@3.4.12", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg=="],
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.340", "", {}, "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -741,6 +756,8 @@
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="],
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
@@ -755,6 +772,8 @@
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@17.7.0", "", {}, "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg=="],
@@ -763,6 +782,8 @@
"happy-dom": ["happy-dom@16.8.1", "", { "dependencies": { "webidl-conversions": "^7.0.0", "whatwg-mimetype": "^3.0.0" } }, "sha512-n0QrmT9lD81rbpKsyhnlz3DgnMZlaOkJPpgi746doA+HvaMC79bdWkwjrNnGJRvDrWTI8iOcJiVTJ5CdT/AZRw=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
"hast-util-from-dom": ["hast-util-from-dom@5.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hastscript": "^9.0.0", "web-namespaces": "^2.0.0" } }, "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q=="],
@@ -799,6 +820,8 @@
"highlightjs-vue": ["highlightjs-vue@1.0.0", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="],
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
@@ -845,6 +868,16 @@
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
"istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="],
"istanbul-lib-source-maps": ["istanbul-lib-source-maps@5.0.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.23", "debug": "^4.1.1", "istanbul-lib-coverage": "^3.0.0" } }, "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A=="],
"istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="],
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
@@ -893,6 +926,10 @@
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"magicast": ["magicast@0.3.5", "", { "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", "source-map-js": "^1.2.0" } }, "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ=="],
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="],
@@ -999,6 +1036,8 @@
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
@@ -1023,6 +1062,8 @@
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
"package-manager-detector": ["package-manager-detector@1.7.0", "", {}, "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ=="],
"parse-entities": ["parse-entities@2.0.0", "", { "dependencies": { "character-entities": "^1.0.0", "character-entities-legacy": "^1.0.0", "character-reference-invalid": "^1.0.0", "is-alphanumerical": "^1.0.0", "is-decimal": "^1.0.0", "is-hexadecimal": "^1.0.0" } }, "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ=="],
@@ -1037,6 +1078,8 @@
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
@@ -1163,6 +1206,8 @@
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
@@ -1175,10 +1220,14 @@
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
@@ -1189,6 +1238,8 @@
"sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tailwind-merge": ["tailwind-merge@2.6.1", "", {}, "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ=="],
@@ -1197,6 +1248,8 @@
"tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="],
"test-exclude": ["test-exclude@7.0.2", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^10.4.1", "minimatch": "^10.2.2" } }, "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw=="],
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
@@ -1295,6 +1348,8 @@
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
@@ -1317,6 +1372,12 @@
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
@@ -1353,6 +1414,8 @@
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
"hast-util-from-dom/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
"hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
@@ -1365,12 +1428,16 @@
"hastscript/space-separated-tokens": ["space-separated-tokens@1.1.5", "", {}, "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA=="],
"make-dir/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"mdast-util-mdx-jsx/parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
"mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
"refractor/prismjs": ["prismjs@1.27.0", "", {}, "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA=="],
@@ -1387,14 +1454,24 @@
"wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="],
"d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="],
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
"glob/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="],
"hast-util-from-dom/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
"hast-util-from-parse5/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
@@ -1415,6 +1492,8 @@
"yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"mdast-util-mdx-jsx/parse-entities/is-alphanumerical/is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
"yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
+532
View File
@@ -44,6 +44,7 @@
"@types/react-dom": "^18.3.5",
"@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "2.1.9",
"autoprefixer": "^10.4.20",
"eslint": "^10.4.0",
"eslint-plugin-react-hooks": "^7.1.1",
@@ -75,6 +76,20 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@antfu/install-pkg": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz",
@@ -348,6 +363,13 @@
"node": ">=6.9.0"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"dev": true,
"license": "MIT"
},
"node_modules/@braintree/sanitize-url": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz",
@@ -990,6 +1012,119 @@
"import-meta-resolve": "^4.2.0"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^5.1.2",
"string-width-cjs": "npm:string-width@^4.2.0",
"strip-ansi": "^7.0.1",
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
"wrap-ansi": "^8.1.0",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@isaacs/cliui/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/@isaacs/cliui/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@isaacs/cliui/node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true,
"license": "MIT"
},
"node_modules/@isaacs/cliui/node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"eastasianwidth": "^0.2.0",
"emoji-regex": "^9.2.2",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@isaacs/cliui/node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/@isaacs/cliui/node_modules/wrap-ansi": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.1.0",
"string-width": "^5.0.1",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/@istanbuljs/schema": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
"integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"dev": true,
@@ -1071,6 +1206,17 @@
"node": ">= 8"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=14"
}
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"license": "MIT"
@@ -3003,6 +3149,39 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@vitest/coverage-v8": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz",
"integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.3.0",
"@bcoe/v8-coverage": "^0.2.3",
"debug": "^4.3.7",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-lib-source-maps": "^5.0.6",
"istanbul-reports": "^3.1.7",
"magic-string": "^0.30.12",
"magicast": "^0.3.5",
"std-env": "^3.8.0",
"test-exclude": "^7.0.1",
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "2.1.9",
"vitest": "2.1.9"
},
"peerDependenciesMeta": {
"@vitest/browser": {
"optional": true
}
}
},
"node_modules/@vitest/expect": {
"version": "2.1.9",
"dev": true,
@@ -4247,6 +4426,13 @@
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"dev": true,
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.340",
"dev": true,
@@ -4703,6 +4889,23 @@
"dev": true,
"license": "ISC"
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"dev": true,
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/format": {
"version": "0.2.2",
"engines": {
@@ -4768,6 +4971,27 @@
"node": ">=6"
}
},
"node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
"dev": true,
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"dev": true,
@@ -4779,6 +5003,39 @@
"node": ">=10.13.0"
}
},
"node_modules/glob/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/glob/node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/globals": {
"version": "17.6.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz",
@@ -4810,6 +5067,16 @@
"node": ">=18.0.0"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/hasown": {
"version": "2.0.3",
"dev": true,
@@ -5143,6 +5410,13 @@
"version": "1.0.0",
"license": "CC0-1.0"
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
@@ -5378,6 +5652,76 @@
"dev": true,
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-lib-source-maps": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
"integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.23",
"debug": "^4.1.1",
"istanbul-lib-coverage": "^3.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
},
"optionalDependencies": {
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/jiti": {
"version": "1.21.7",
"dev": true,
@@ -5587,6 +5931,47 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/magicast": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz",
"integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.25.4",
"@babel/types": "^7.25.4",
"source-map-js": "^1.2.0"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/make-dir/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/markdown-table": {
"version": "3.0.4",
"license": "MIT",
@@ -6573,6 +6958,16 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/ms": {
"version": "2.1.3",
"license": "MIT"
@@ -6699,6 +7094,13 @@
"node": ">=6"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/package-manager-detector": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.7.0.tgz",
@@ -6761,6 +7163,30 @@
"dev": true,
"license": "MIT"
},
"node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-scurry/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/pathe": {
"version": "1.1.2",
"dev": true,
@@ -7619,6 +8045,19 @@
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"dev": true,
@@ -7697,6 +8136,22 @@
"node": ">=8"
}
},
"node_modules/string-width-cjs": {
"name": "string-width",
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/stringify-entities": {
"version": "4.0.4",
"license": "MIT",
@@ -7729,6 +8184,20 @@
"node": ">=8"
}
},
"node_modules/strip-ansi-cjs": {
"name": "strip-ansi",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-indent": {
"version": "3.0.0",
"dev": true,
@@ -7789,6 +8258,19 @@
"node": ">= 6"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"dev": true,
@@ -7864,6 +8346,21 @@
"node": ">=4"
}
},
"node_modules/test-exclude": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz",
"integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==",
"dev": true,
"license": "ISC",
"dependencies": {
"@istanbuljs/schema": "^0.1.2",
"glob": "^10.4.1",
"minimatch": "^10.2.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/thenify": {
"version": "3.3.1",
"dev": true,
@@ -8521,6 +9018,41 @@
"node": ">=8"
}
},
"node_modules/wrap-ansi-cjs": {
"name": "wrap-ansi",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/wrap-ansi/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+2
View File
@@ -8,6 +8,7 @@
"build": "tsc -p tsconfig.build.json && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest",
"lint": "cd .. && eslint --config webui/eslint.config.js webui/src \"nanobot/channels/*/webui/**/*.{ts,tsx}\" --max-warnings 0"
},
@@ -47,6 +48,7 @@
"@types/react-dom": "^18.3.5",
"@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "2.1.9",
"autoprefixer": "^10.4.20",
"eslint": "^10.4.0",
"eslint-plugin-react-hooks": "^7.1.1",
+61 -21
View File
@@ -8,7 +8,7 @@ import {
useState,
type ReactNode,
} from "react";
import { Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react";
import { Eye, EyeOff, Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { channelUiPresentation } from "@/channel-plugins/registry";
import { Sidebar } from "@/components/Sidebar";
@@ -316,13 +316,23 @@ function AuthForm({
onSecret: (secret: string) => void;
}) {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const [value, setValue] = useState("");
const [passwordVisible, setPasswordVisible] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [validationError, setValidationError] = useState<"required" | "invalid" | null>(
failed ? "invalid" : null,
);
const errorMessage = validationError ? t(`app.auth.${validationError}`) : null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const secret = value.trim();
if (!secret) return;
if (!secret) {
setValidationError("required");
inputRef.current?.focus();
return;
}
setSubmitting(true);
onSecret(secret);
};
@@ -333,27 +343,57 @@ function AuthForm({
onSubmit={handleSubmit}
className="flex w-full max-w-sm flex-col gap-4"
>
<div className="flex flex-col items-center gap-1 text-center">
<p className="text-lg font-semibold">{t("app.auth.title")}</p>
<p className="text-sm text-muted-foreground">{t("app.auth.hint")}</p>
<div className="space-y-2">
<h1 className="text-sm font-medium text-foreground">
<label htmlFor="webui-access-password">{t("app.auth.label")}</label>
</h1>
<div className="relative">
<Input
ref={inputRef}
id="webui-access-password"
name="webui-access-password"
type={passwordVisible ? "text" : "password"}
autoComplete="current-password"
value={value}
onChange={(e) => {
setValue(e.target.value);
setValidationError(null);
}}
disabled={submitting}
aria-invalid={validationError ? true : undefined}
aria-describedby={validationError ? "webui-auth-error" : undefined}
className="pr-10 focus-visible:ring-1 focus-visible:ring-ring/30 focus-visible:ring-offset-0"
autoFocus
/>
<Button
type="button"
variant="ghost"
size="icon"
disabled={submitting}
aria-label={t(
passwordVisible ? "app.auth.hidePassword" : "app.auth.showPassword",
)}
aria-controls="webui-access-password"
onClick={() => setPasswordVisible((visible) => !visible)}
className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{passwordVisible ? (
<EyeOff className="h-4 w-4" strokeWidth={1.75} aria-hidden />
) : (
<Eye className="h-4 w-4" strokeWidth={1.75} aria-hidden />
)}
</Button>
</div>
{errorMessage ? (
<p id="webui-auth-error" role="alert" className="text-sm text-destructive">
{errorMessage}
</p>
) : null}
</div>
{failed && (
<p className="text-center text-sm text-destructive">
{t("app.auth.invalid")}
</p>
)}
<Input
type="password"
placeholder={t("app.auth.placeholder")}
value={value}
onChange={(e) => setValue(e.target.value)}
disabled={submitting}
autoFocus
/>
<Button
type="submit"
className="w-full"
disabled={!value.trim() || submitting}
disabled={submitting}
>
{t("app.auth.submit")}
</Button>
@@ -2041,7 +2081,7 @@ function Shell({
setPairingBusyCode(code);
setPairingError(null);
try {
const payload = await runPairingAction(getToken(), action, code);
const payload = await runPairingAction(client, action, code);
setPairingRequests(Array.isArray(payload.requests) ? payload.requests : []);
setSnoozedPairingCodes((current) => {
if (!current.has(code)) return current;
@@ -2056,7 +2096,7 @@ function Shell({
setPairingBusyCode(null);
}
},
[getToken, refreshPairingRequests],
[client, refreshPairingRequests],
);
const onDismissPairingRequest = useCallback((code: string) => {
+185 -272
View File
@@ -139,7 +139,6 @@ import {
startApiService,
stopApiService,
updateAutomation,
updateComputerUseSettings,
updateImageGenerationSettings,
updateMcpServerTools,
updateModelCallOrder,
@@ -180,7 +179,6 @@ import type {
AutomationUpdatePayload,
CliAppInfo,
CliAppsPayload,
ComputerUseSettingsUpdate,
ImageGenerationSettingsUpdate,
McpPresetInfo,
McpPresetsPayload,
@@ -726,7 +724,7 @@ export function SettingsView({
hostChromeInset = false,
}: SettingsViewProps) {
const { t } = useTranslation();
const { getToken, token } = useClient();
const { client, getToken, token } = useClient();
const pageVisible = usePageVisibility();
const remoteBrowserAccess =
typeof window !== "undefined" && !isLoopbackHost(window.location.hostname);
@@ -764,7 +762,6 @@ export function SettingsView({
const [imageGenerationSaving, setImageGenerationSaving] = useState(false);
const [transcriptionSaving, setTranscriptionSaving] = useState(false);
const [networkSafetySaving, setNetworkSafetySaving] = useState(false);
const [computerUseSaving, setComputerUseSaving] = useState<"browser" | "computer" | null>(null);
const [apiService, setApiService] = useState<ApiServicePayload | null>(null);
const [apiServiceLoading, setApiServiceLoading] = useState(false);
const [apiServiceAction, setApiServiceAction] = useState<"start" | "stop" | null>(null);
@@ -875,7 +872,7 @@ export function SettingsView({
const poll = async () => {
try {
const payload = await completeProviderOAuth(
getToken(),
client,
providerOAuthFlow.provider,
providerOAuthFlow.flow_id,
);
@@ -905,7 +902,7 @@ export function SettingsView({
cancelled = true;
if (timer !== null) window.clearTimeout(timer);
};
}, [applyPayload, closeProviderOAuthFlow, getToken, providerOAuthFlow]);
}, [applyPayload, client, closeProviderOAuthFlow, providerOAuthFlow]);
useEffect(() => {
if (!initialSettings || settings !== null) return;
@@ -1005,7 +1002,7 @@ export function SettingsView({
useEffect(() => {
if (
!pageVisible
|| !["channels", "models", "browser", "runtime", "advanced"].includes(activeSection)
|| !["channels", "models", "browser", "runtime"].includes(activeSection)
) {
return;
}
@@ -1304,7 +1301,7 @@ export function SettingsView({
}
setModelConfigurationSaving(true);
try {
const payload = await createModelConfiguration(token, {
const payload = await createModelConfiguration(client, {
label,
provider,
model,
@@ -1322,7 +1319,7 @@ export function SettingsView({
let finalPayload = payload;
if (nextOrder) {
const orderedPayload = await updateModelCallOrder(token, nextOrder);
const orderedPayload = await updateModelCallOrder(client, nextOrder);
applyPayload(orderedPayload);
finalPayload = orderedPayload;
}
@@ -1348,7 +1345,7 @@ export function SettingsView({
const reasoningEffort = form.reasoningEffort || null;
setSaving(true);
try {
const payload = await updateModelConfiguration(token, {
const payload = await updateModelConfiguration(client, {
name: selectedPreset.name,
label:
form.presetLabel.trim() !== selectedPreset.label
@@ -1434,7 +1431,7 @@ export function SettingsView({
setModelCallOrder(nextOrder);
setModelCallOrderSaving(true);
try {
const payload = await updateModelCallOrder(token, nextOrder);
const payload = await updateModelCallOrder(client, nextOrder);
applyPayload(payload, { preserveAgentForm: true });
onModelNameChange(payload.agent.model || null);
setError(null);
@@ -1450,7 +1447,7 @@ export function SettingsView({
if (modelMigrationSaving) return;
setModelMigrationSaving(true);
try {
const payload = await migrateModelConfigurations(token);
const payload = await migrateModelConfigurations(client);
applyPayload(payload);
onModelNameChange(payload.agent.model || null);
setError(null);
@@ -1472,7 +1469,7 @@ export function SettingsView({
}
setSaving(true);
try {
const payload = await deleteModelConfiguration(token, modelPresetPendingDelete.name);
const payload = await deleteModelConfiguration(client, modelPresetPendingDelete.name);
applyPayload(payload);
setModelPresetPendingDelete(null);
setError(null);
@@ -1487,7 +1484,7 @@ export function SettingsView({
if (!settings || !imageGenerationDirty || imageGenerationSaving) return;
setImageGenerationSaving(true);
try {
const payload = await updateImageGenerationSettings(token, imageGenerationForm);
const payload = await updateImageGenerationSettings(client, imageGenerationForm);
applyPayload(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, image: true }));
@@ -1505,7 +1502,7 @@ export function SettingsView({
if (!settings || !transcriptionDirty || transcriptionSaving) return;
setTranscriptionSaving(true);
try {
const payload = await updateTranscriptionSettings(token, transcriptionForm);
const payload = await updateTranscriptionSettings(client, transcriptionForm);
applyPayload(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, browser: true }));
@@ -1523,7 +1520,7 @@ export function SettingsView({
if (!settings || !networkSafetyDirty || networkSafetySaving) return;
setNetworkSafetySaving(true);
try {
const payload = await updateNetworkSafetySettings(token, networkSafetyForm);
const payload = await updateNetworkSafetySettings(client, networkSafetyForm);
applyPayload(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
@@ -1547,7 +1544,7 @@ export function SettingsView({
try {
let latest = nanobotFeatures;
for (const name of missing) {
latest = await enableNanobotFeature(token, name);
latest = await enableNanobotFeature(client, name);
if (latest.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
}
@@ -1562,34 +1559,6 @@ export function SettingsView({
}
};
const setComputerUseEnabled = async (
target: "browser" | "computer",
enabled: boolean,
) => {
if (!settings || computerUseSaving) return;
setComputerUseSaving(target);
try {
if (enabled && !(await installCapabilities(["computer-use"]))) return;
const update: ComputerUseSettingsUpdate = target === "browser"
? { browserEnabled: enabled }
: { computerEnabled: enabled };
const payload = await updateComputerUseSettings(token, update);
applyPayload(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({
...prev,
[target === "browser" ? "browser" : "runtime"]: true,
}));
}
await maybeRestartHostEngine(payload);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setComputerUseSaving(null);
}
};
const handleApiServiceAction = async (
action: "start" | "stop",
values?: { host: string; port: number; timeout: number; apiKey?: string },
@@ -1599,8 +1568,8 @@ export function SettingsView({
setApiServiceError(null);
try {
const payload = action === "start"
? await startApiService(token, values!)
: await stopApiService(token);
? await startApiService(client, values!)
: await stopApiService(client);
setApiService(payload);
const refreshed = await fetchNanobotFeatures(token);
setNanobotFeatures(refreshed);
@@ -1653,7 +1622,7 @@ export function SettingsView({
if (field === "region") update.region = providerForm.region.trim();
if (field === "profile") update.profile = providerForm.profile.trim();
}
const payload = await updateProviderSettings(token, update);
const payload = await updateProviderSettings(client, update);
applyPayload(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, image: true }));
@@ -1687,7 +1656,7 @@ export function SettingsView({
if (providerSaving) return false;
setProviderSaving(CUSTOM_PROVIDER_CREATION_KEY);
try {
const payload = await createProviderSettings(token, {
const payload = await createProviderSettings(client, {
name: draft.name.trim(),
apiKey: draft.apiKey.trim() || undefined,
apiBase: draft.apiBase.trim(),
@@ -1729,12 +1698,11 @@ export function SettingsView({
const payload =
action === "login"
? await loginProviderOAuth(
token,
client,
providerName,
"",
providerName === "openai_codex" && remoteBrowserAccess,
)
: await logoutProviderOAuth(token, providerName);
: await logoutProviderOAuth(client, providerName);
if (isProviderOAuthAuthorizationRequired(payload)) {
try {
if (popup && !popup.closed) popup.location.href = payload.authorization_url;
@@ -1770,7 +1738,7 @@ export function SettingsView({
setProviderOAuthDialogError(null);
try {
const payload = await completeProviderOAuth(
token,
client,
flow.provider,
flow.flow_id,
authorizationResponse,
@@ -1829,7 +1797,7 @@ export function SettingsView({
update.apiKey = apiKey;
}
if (provider.credential === "base_url") update.baseUrl = baseUrl;
const payload = await updateWebSearchSettings(token, update);
const payload = await updateWebSearchSettings(client, update);
applyPayload(payload);
if (payload.requires_restart || webFetchRestartRequired) {
setPendingRestartSections((prev) => ({ ...prev, browser: true }));
@@ -1934,7 +1902,7 @@ export function SettingsView({
setCliAppsMessage(null);
setCliAppsError(null);
try {
const payload = await runCliAppAction(token, action, name);
const payload = await runCliAppAction(client, action, name);
setCliApps(payload);
if (action !== "test") {
notifyCliAppsChanged(payload);
@@ -1965,8 +1933,8 @@ export function SettingsView({
setNanobotFeaturesError(null);
try {
const payload = action === "enable"
? await enableNanobotFeature(token, name)
: await disableNanobotFeature(token, name);
? await enableNanobotFeature(client, name)
: await disableNanobotFeature(client, name);
setNanobotFeatures(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
@@ -1986,7 +1954,7 @@ export function SettingsView({
setAutomationAction(key);
setAutomationsError(null);
try {
const payload = await runAutomationAction(token, action, job.id);
const payload = await runAutomationAction(client, action, job.id);
setAutomations(payload);
if (action === "delete") setAutomationPendingDelete(null);
if (action === "run") {
@@ -2008,7 +1976,7 @@ export function SettingsView({
setAutomationAction(key);
setAutomationsError(null);
try {
const payload = await updateAutomation(token, job.id, values);
const payload = await updateAutomation(client, job.id, values);
setAutomations(payload);
setAutomationPendingEdit(null);
} catch (err) {
@@ -2028,7 +1996,7 @@ export function SettingsView({
setMcpMessage(null);
setMcpError(null);
try {
const payload = await runMcpPresetAction(token, action, name, values);
const payload = await runMcpPresetAction(client, action, name, values);
setMcpPresets(payload);
setMcpMessage(payload.last_action?.message ?? null);
if (action !== "test") {
@@ -2055,7 +2023,7 @@ export function SettingsView({
setMcpMessage(null);
setMcpError(null);
try {
const payload = await saveCustomMcpServer(token, {
const payload = await saveCustomMcpServer(client, {
name,
transport: customMcpForm.transport,
command: customMcpForm.command,
@@ -2085,7 +2053,7 @@ export function SettingsView({
setMcpMessage(null);
setMcpError(null);
try {
const payload = await importMcpConfig(token, mcpConfigImport);
const payload = await importMcpConfig(client, mcpConfigImport);
setMcpPresets(payload);
setMcpMessage(payload.last_action?.message ?? null);
notifyMcpPresetsChanged(payload);
@@ -2106,7 +2074,7 @@ export function SettingsView({
setMcpMessage(null);
setMcpError(null);
try {
const payload = await updateMcpServerTools(token, name, enabledTools);
const payload = await updateMcpServerTools(client, name, enabledTools);
setMcpPresets(payload);
setMcpMessage(payload.last_action?.message ?? null);
notifyMcpPresetsChanged(payload);
@@ -2269,11 +2237,6 @@ export function SettingsView({
olostepFeature={featureCatalog.find((feature) => feature.name === "olostep")}
olostepInstalling={nanobotFeatureAction === "enable:olostep"}
capabilityError={nanobotFeaturesError}
browserAutomationEnabled={settings.computer_use?.browser_enabled ?? false}
computerUseFeature={featureCatalog.find((feature) => feature.name === "computer-use")}
computerUseSaving={computerUseSaving === "browser"}
computerUseInstalling={nanobotFeatureAction === "enable:computer-use"}
onToggleBrowserAutomation={(enabled) => void setComputerUseEnabled("browser", enabled)}
/>
);
case "channels":
@@ -2364,6 +2327,7 @@ export function SettingsView({
onAction={handleAutomationAction}
onRequestEdit={setAutomationPendingEdit}
onRequestDelete={setAutomationPendingDelete}
onBackToChat={onBackToChat}
/>
);
case "skills":
@@ -2400,13 +2364,6 @@ export function SettingsView({
onRestart={restartViaSettingsSurface}
isRestarting={isRestarting || hostEngineApplying}
requiresRestartPending={pendingRestartSections.runtime}
computerControlEnabled={settings.computer_use?.enabled ?? false}
computerUseBackend={settings.computer_use?.backend ?? "desktop"}
computerUseFeature={featureCatalog.find((feature) => feature.name === "computer-use")}
computerUseSaving={computerUseSaving === "computer"}
computerUseInstalling={nanobotFeatureAction === "enable:computer-use"}
capabilityError={nanobotFeaturesError}
onToggleComputerControl={(enabled) => void setComputerUseEnabled("computer", enabled)}
/>
);
default:
@@ -2491,7 +2448,7 @@ export function SettingsView({
onSave={handleAutomationEdit}
/>
<main
<div
className={cn(
"min-w-0 flex-1 bg-settings-canvas [scrollbar-gutter:stable]",
activeSection === "channels" ? "overflow-y-auto xl:overflow-hidden" : "overflow-y-auto",
@@ -2555,7 +2512,7 @@ export function SettingsView({
</div>
) : null}
</div>
</main>
</div>
</div>
);
}
@@ -2621,9 +2578,9 @@ function SettingsSidebar({
{t("settings.backToChat")}
</button>
<div className="mb-3 px-1 lg:mb-4 lg:px-2">
<h2 className="text-[18px] font-normal tracking-normal text-foreground">
<h1 className="text-[18px] font-normal tracking-normal text-foreground">
{t("settings.sidebar.title")}
</h2>
</h1>
</div>
<nav
@@ -3085,7 +3042,13 @@ function AppearanceSettings({
label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
<SettingsRow title={tx("settings.rows.brandLogos", "Brand logos")}>
<SettingsRow
title={tx("settings.rows.brandLogos", "Brand logos")}
description={tx(
"settings.legal.thirdPartyBrands",
"Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement.",
)}
>
<ToggleButton
checked={localPrefs.brandLogos}
onChange={(brandLogos) => onChangeLocalPrefs((prev) => ({ ...prev, brandLogos }))}
@@ -5298,11 +5261,6 @@ function WebSettings({
olostepFeature,
olostepInstalling,
capabilityError,
browserAutomationEnabled,
computerUseFeature,
computerUseSaving,
computerUseInstalling,
onToggleBrowserAutomation,
}: {
settings: SettingsPayload;
form: WebSearchSettingsUpdate;
@@ -5322,11 +5280,6 @@ function WebSettings({
olostepFeature?: NanobotFeatureInfo;
olostepInstalling: boolean;
capabilityError: string | null;
browserAutomationEnabled: boolean;
computerUseFeature?: NanobotFeatureInfo;
computerUseSaving: boolean;
computerUseInstalling: boolean;
onToggleBrowserAutomation: (enabled: boolean) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
@@ -5355,46 +5308,9 @@ function WebSettings({
: selectedProvider?.credential === "base_url"
? !baseUrl
: false;
const computerUseInstalled = computerUseFeature?.installed ?? true;
const browserAutomationDescription = computerUseInstalling
? tx("settings.help.computerUseInstalling", "Installing computer-use support...")
: computerUseInstalled
? tx(
"settings.help.browserAutomation",
"Let nanobot navigate and act on web pages using structured page elements. Requires Playwright Chromium.",
)
: tx(
"settings.help.computerUseInstall",
"Required Python support will be installed when you turn this on.",
);
return (
<div className="space-y-7">
<section>
<SettingsSectionTitle>
{tx("settings.sections.browserAutomation", "Browser automation")}
</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow
title={tx("settings.rows.browserAutomation", "Browser automation")}
description={browserAutomationDescription}
>
<ToggleButton
checked={browserAutomationEnabled}
disabled={computerUseSaving || computerUseInstalling}
onChange={onToggleBrowserAutomation}
ariaLabel={tx("settings.rows.browserAutomation", "Browser automation")}
label={browserAutomationEnabled
? tx("settings.values.on", "On")
: tx("settings.values.off", "Off")}
/>
</SettingsRow>
</SettingsGroup>
{capabilityError ? (
<p className="mt-2 px-1 text-[12px] text-destructive">{capabilityError}</p>
) : null}
</section>
<section>
<SettingsSectionTitle>{tx("settings.sections.webSearch", "Web search")}</SettingsSectionTitle>
{form.provider === "olostep" && olostepFeature && !olostepFeature.installed ? (
@@ -5578,6 +5494,7 @@ function AutomationsSettings({
onAction,
onRequestEdit,
onRequestDelete,
onBackToChat,
}: {
payload: AutomationsPayload | null;
loading: boolean;
@@ -5592,6 +5509,7 @@ function AutomationsSettings({
onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise<void>;
onRequestEdit: (job: SessionAutomationJob) => void;
onRequestDelete: (job: SessionAutomationJob) => void;
onBackToChat: () => void;
}) {
const { t, i18n } = useTranslation();
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
@@ -5639,74 +5557,76 @@ function AutomationsSettings({
return (
<div className="space-y-5">
<section className="shrink-0">
<div className="mx-auto flex w-full max-w-[56rem] flex-col gap-3">
<div className="-mx-1 overflow-x-auto px-1 pb-0.5">
<div className="grid w-full min-w-[36rem] grid-cols-5 gap-1 rounded-[15px] bg-muted p-1">
{summaryOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => onFilterChange(option.value)}
className={cn(
"inline-flex h-8 min-w-0 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-[11px] px-3 text-[12px] font-medium text-muted-foreground transition-colors",
filter === option.value && "bg-background text-foreground",
automationFilterToneClass(option.value, option.count, filter === option.value),
)}
>
<span>{option.label}</span>
<span
{jobs.length ? (
<section className="shrink-0">
<div className="mx-auto flex w-full max-w-[56rem] flex-col gap-3">
<div className="-mx-1 overflow-x-auto px-1 pb-0.5">
<div className="grid w-full min-w-[36rem] grid-cols-5 gap-1 rounded-[15px] bg-muted p-1">
{summaryOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => onFilterChange(option.value)}
className={cn(
"min-w-5 shrink-0 rounded-full bg-background/75 px-1.5 py-0.5 text-center text-[11px] tabular-nums text-muted-foreground",
automationFilterCountClass(option.value, option.count),
"inline-flex h-8 min-w-0 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-[11px] px-3 text-[12px] font-medium text-muted-foreground transition-colors",
filter === option.value && "bg-background text-foreground",
automationFilterToneClass(option.value, option.count, filter === option.value),
)}
>
{option.count}
</span>
</button>
))}
</div>
</div>
<div className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<div className="relative min-w-0">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/70" />
<Input
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder={tx(
"settings.automations.search",
"Search task, message, linked chat, or schedule",
)}
className={cn(
"h-9 w-full rounded-[13px] pl-9 text-[13px]",
SETTINGS_SEARCH_INPUT_CLASS,
)}
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex h-9 min-w-[8.5rem] items-center justify-center gap-1.5 whitespace-nowrap rounded-[13px] border border-border/45 bg-settings-surface px-3 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground sm:w-auto"
>
<ArrowUpDown className="h-3.5 w-3.5" aria-hidden />
<span>{sortLabel[sort]}</span>
<ChevronDown className="h-3.5 w-3.5" aria-hidden />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-40">
{(Object.keys(sortLabel) as AutomationSort[]).map((value) => (
<DropdownMenuItem key={value} onClick={() => onSortChange(value)}>
<span>{sortLabel[value]}</span>
{sort === value ? <Check className="ml-auto h-3.5 w-3.5" aria-hidden /> : null}
</DropdownMenuItem>
<span>{option.label}</span>
<span
className={cn(
"min-w-5 shrink-0 rounded-full bg-background/75 px-1.5 py-0.5 text-center text-[11px] tabular-nums text-muted-foreground",
automationFilterCountClass(option.value, option.count),
)}
>
{option.count}
</span>
</button>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<div className="relative min-w-0">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/70" />
<Input
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder={tx(
"settings.automations.search",
"Search task, message, linked chat, or schedule",
)}
className={cn(
"h-9 w-full rounded-[13px] pl-9 text-[13px]",
SETTINGS_SEARCH_INPUT_CLASS,
)}
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex h-9 min-w-[8.5rem] items-center justify-center gap-1.5 whitespace-nowrap rounded-[13px] border border-border/45 bg-settings-surface px-3 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground sm:w-auto"
>
<ArrowUpDown className="h-3.5 w-3.5" aria-hidden />
<span>{sortLabel[sort]}</span>
<ChevronDown className="h-3.5 w-3.5" aria-hidden />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-40">
{(Object.keys(sortLabel) as AutomationSort[]).map((value) => (
<DropdownMenuItem key={value} onClick={() => onSortChange(value)}>
<span>{sortLabel[value]}</span>
{sort === value ? <Check className="ml-auto h-3.5 w-3.5" aria-hidden /> : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</section>
</section>
) : null}
{error ? (
<div className="flex items-center gap-2 rounded-[18px] border border-destructive/20 bg-destructive/5 px-4 py-3 text-[13px] text-destructive">
@@ -5764,13 +5684,35 @@ function AutomationsSettings({
: tx("settings.automations.empty", "No automations yet.")}
</div>
{!jobs.length ? (
<div className="mx-auto mt-2 max-w-[28rem] text-[12px] leading-5">
{tx(
"settings.automations.emptyHint",
"Create one from where it should run so nanobot keeps the right context.",
)}
</div>
) : null}
<>
<div className="mx-auto mt-2 max-w-[28rem] text-[12px] leading-5">
{tx(
"settings.automations.emptyHint",
"Create automations in a chat so they keep the right context.",
)}
</div>
<Button
type="button"
variant="outline"
className="mt-4 rounded-full"
onClick={onBackToChat}
>
{tx("settings.automations.emptyAction", "Open a chat")}
</Button>
</>
) : (
<Button
type="button"
variant="outline"
className="mt-4 rounded-full"
onClick={() => {
onQueryChange("");
onFilterChange("all");
}}
>
{tx("settings.automations.clearFilters", "Clear filters")}
</Button>
)}
</div>
)}
</div>
@@ -7380,10 +7322,6 @@ function ChannelsSettings({
</div>
)}
</section>
<div className={cn("shrink-0 pt-2", showingCompactDetail && "hidden")}>
<ThirdPartyBrandNotice />
</div>
</div>
);
}
@@ -7486,6 +7424,23 @@ function AppsCatalogSettings({
(cliAppsLoading || mcpPresetsLoading) &&
!cliApps &&
!mcpPresets;
const cliAppCount = cliApps?.apps.length ?? 0;
const emptyTitle = normalizedQuery
? tx("settings.apps.empty", "No tools match your search.")
: filter === "cli"
? tx("settings.apps.emptyApps", "No apps available.")
: filter === "mcp"
? tx("settings.apps.emptyIntegrations", "No integrations available.")
: tx("settings.apps.emptyReady", "No tools are ready yet.");
const emptyBrowseTarget: AppsKindFilter | null = normalizedQuery
? null
: filter === "cli"
? "mcp"
: filter === "mcp"
? (cliAppCount ? "cli" : null)
: cliAppCount
? "cli"
: "mcp";
const statusMessage =
cliError ||
mcpError ||
@@ -7574,7 +7529,35 @@ function AppsCatalogSettings({
</div>
) : (
<div className="px-3 py-12 text-center text-sm text-muted-foreground">
{tx("settings.apps.empty", "No tools match this view.")}
<p>{emptyTitle}</p>
{normalizedQuery ? (
<Button
type="button"
variant="outline"
className="mt-4 rounded-full"
onClick={() => onQueryChange("")}
>
{tx("settings.apps.clearSearch", "Clear search")}
</Button>
) : emptyBrowseTarget ? (
<Button
type="button"
variant="outline"
className="mt-4 rounded-full"
onClick={() => onFilterChange(emptyBrowseTarget)}
>
{emptyBrowseTarget === "cli"
? tx("settings.apps.browseApps", "Browse apps")
: tx("settings.apps.browseIntegrations", "Browse integrations")}
</Button>
) : (
<p className="mx-auto mt-2 max-w-[28rem] text-[12px] leading-5">
{tx(
"settings.apps.emptyIntegrationsHint",
"Add a custom integration below.",
)}
</p>
)}
</div>
)}
</section>
@@ -7590,8 +7573,6 @@ function AppsCatalogSettings({
onImportConfig={onImportMcpConfig}
/>
) : null}
<ThirdPartyBrandNotice />
</div>
);
}
@@ -8855,13 +8836,6 @@ function AdvancedSettings({
onSave,
onRestart,
isRestarting,
computerControlEnabled,
computerUseBackend,
computerUseFeature,
computerUseSaving,
computerUseInstalling,
capabilityError,
onToggleComputerControl,
}: {
form: NetworkSafetySettingsUpdate;
dirty: boolean;
@@ -8872,60 +8846,11 @@ function AdvancedSettings({
onSave: () => void;
onRestart?: () => void;
isRestarting?: boolean;
computerControlEnabled: boolean;
computerUseBackend: "desktop" | "browser";
computerUseFeature?: NanobotFeatureInfo;
computerUseSaving: boolean;
computerUseInstalling: boolean;
capabilityError: string | null;
onToggleComputerControl: (enabled: boolean) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const computerUseInstalled = computerUseFeature?.installed ?? true;
const computerControlDescription = computerUseInstalling
? tx("settings.help.computerUseInstalling", "Installing computer-use support...")
: !computerUseInstalled
? tx(
"settings.help.computerUseInstall",
"Required Python support will be installed when you turn this on.",
)
: computerUseBackend === "browser"
? tx(
"settings.help.computerControlBrowser",
"Pixel-based control currently targets an isolated browser, as configured in config.json.",
)
: tx(
"settings.help.computerControl",
"Let nanobot see and control the computer running its engine. macOS requires Screen Recording and Accessibility access.",
);
return (
<div className="space-y-7">
<section>
<SettingsSectionTitle>
{tx("settings.sections.computerControl", "Computer control")}
</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow
title={tx("settings.rows.computerControl", "Computer control")}
description={computerControlDescription}
>
<ToggleButton
checked={computerControlEnabled}
disabled={computerUseSaving || computerUseInstalling}
onChange={onToggleComputerControl}
ariaLabel={tx("settings.rows.computerControl", "Computer control")}
label={computerControlEnabled
? tx("settings.values.on", "On")
: tx("settings.values.off", "Off")}
/>
</SettingsRow>
</SettingsGroup>
{capabilityError ? (
<p className="mt-2 px-1 text-[12px] text-destructive">{capabilityError}</p>
) : null}
</section>
<section>
<SettingsSectionTitle>
{isNativeHostSurface
@@ -9472,18 +9397,6 @@ function ProviderPickerIcon({
);
}
function ThirdPartyBrandNotice() {
const { t } = useTranslation();
return (
<p className="px-1 text-[11.5px] leading-5 text-muted-foreground/75">
{t("settings.legal.thirdPartyBrands", {
defaultValue:
"Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement.",
})}
</p>
);
}
function orderUnconfiguredProviders(
providers: SettingsPayload["providers"],
): SettingsPayload["providers"] {
@@ -269,7 +269,7 @@ function SkillDetailSheet({
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { getToken } = useClient();
const { client, getToken } = useClient();
const { t } = useTranslation();
const [detail, setDetail] = useState<SkillDetail | null>(null);
const [loading, setLoading] = useState(false);
@@ -321,7 +321,7 @@ function SkillDetailSheet({
setActionBusy(true);
setActionError("");
try {
const payload = await updateSkillEnabled(getToken(), activeSkill.name, !enabled);
const payload = await updateSkillEnabled(client, activeSkill.name, !enabled);
notifySkillsChanged(payload);
const updated = payload.skills.find((item) => item.name === activeSkill.name);
if (updated) {
@@ -345,7 +345,7 @@ function SkillDetailSheet({
setActionBusy(true);
setActionError("");
try {
const payload = await deleteSkill(getToken(), activeSkill.name);
const payload = await deleteSkill(client, activeSkill.name);
notifySkillsChanged(payload);
onOpenChange(false);
} catch (reason) {
@@ -46,7 +46,7 @@ export function SkillsMarketplace({
installing: string;
onInstallingChange: (skillId: string) => void;
}) {
const { getToken } = useClient();
const { client, getToken } = useClient();
const { t } = useTranslation();
const [query, setQuery] = useState("");
const [results, setResults] = useState<MarketplaceSkillSummary[]>([]);
@@ -161,7 +161,7 @@ export function SkillsMarketplace({
setError("");
try {
const payload = await installMarketplaceSkill(
getToken(),
client,
skill.provider,
skill.source,
skill.skill_id,
@@ -38,6 +38,7 @@ import type {
NanobotFeaturesPayload,
} from "@/lib/types";
import { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider";
export type ChannelInstancesPanelCustomization = {
countLabel?: (runningCount: number) => string;
@@ -50,7 +51,6 @@ export type ChannelInstancesPanelCustomization = {
};
export function ChannelInstancesPanel({
token,
feature,
showBrandLogos,
chatAppsDocsUrl,
@@ -58,7 +58,6 @@ export function ChannelInstancesPanel({
onFeaturesUpdate,
customization = {},
}: {
token: string;
feature: NanobotFeatureInfo;
showBrandLogos: boolean;
chatAppsDocsUrl?: string;
@@ -66,6 +65,7 @@ export function ChannelInstancesPanel({
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
customization?: ChannelInstancesPanelCustomization;
}) {
const { client } = useClient();
const { t, i18n } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const displayName = localizedChannelDisplayName(feature, t);
@@ -111,8 +111,8 @@ export function ChannelInstancesPanel({
setNotice(null);
try {
const payload = checked
? await enableNanobotFeature(token, feature.name, { instanceId: instance.id })
: await disableNanobotFeature(token, feature.name, { instanceId: instance.id });
? await enableNanobotFeature(client, feature.name, { instanceId: instance.id })
: await disableNanobotFeature(client, feature.name, { instanceId: instance.id });
onFeaturesUpdate(payload);
} catch (err) {
setNotice((err as Error).message);
@@ -127,7 +127,7 @@ export function ChannelInstancesPanel({
setNotice(null);
try {
const payload = await configureChannel(
token,
client,
feature.name,
channelValuesForSave(instanceFields, fieldValues),
{ enable: selected.enabled, instanceId: selected.id },
@@ -14,6 +14,7 @@ import type {
ChannelConnectPayload,
NanobotFeaturesPayload,
} from "@/lib/types";
import { useClient } from "@/providers/ClientProvider";
export type ChannelQrConnectLabels = {
qrAlt: string;
@@ -43,7 +44,6 @@ export type ChannelQrConnectPendingContext = {
};
export function ChannelQrConnectFlow({
token,
channelName,
startOptions = {},
idleLabel,
@@ -69,6 +69,7 @@ export function ChannelQrConnectFlow({
resolveMessage?: (payload: ChannelConnectPayload) => string | undefined;
suppressSucceeded?: boolean;
}) {
const { client } = useClient();
const pageVisible = usePageVisibility();
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
@@ -78,8 +79,6 @@ export function ChannelQrConnectFlow({
const [error, setError] = useState<string | null>(null);
const [handledRequestId, setHandledRequestId] = useState(0);
const pollInFlight = useRef(false);
const tokenRef = useRef(token);
tokenRef.current = token;
const startDomain = startOptions.domain;
const startInstanceId = startOptions.instanceId;
const startMode = startOptions.mode;
@@ -129,7 +128,7 @@ export function ChannelQrConnectFlow({
pollInFlight.current = true;
try {
const payload = await pollChannelConnect(
tokenRef.current,
client,
channelName,
sessionId,
);
@@ -163,6 +162,7 @@ export function ChannelQrConnectFlow({
};
}, [
channelName,
client,
connect?.interval_ms,
connect?.session_id,
connect?.status,
@@ -175,7 +175,7 @@ export function ChannelQrConnectFlow({
setBusy(true);
setError(null);
try {
const payload = await startChannelConnect(tokenRef.current, channelName, {
const payload = await startChannelConnect(client, channelName, {
domain: startDomain,
instanceId: startInstanceId,
mode: startMode,
@@ -187,7 +187,7 @@ export function ChannelQrConnectFlow({
} finally {
setBusy(false);
}
}, [channelName, startDomain, startForce, startInstanceId, startMode]);
}, [channelName, client, startDomain, startForce, startInstanceId, startMode]);
useEffect(() => {
if (!connectRequestId || connectRequestId === handledRequestId) return;
@@ -203,7 +203,7 @@ export function ChannelQrConnectFlow({
setBusy(true);
try {
const payload = await cancelChannelConnect(
tokenRef.current,
client,
channelName,
connect.session_id,
);
@@ -223,10 +223,9 @@ export function ChannelQrConnectFlow({
setError(null);
try {
const payload = await pollChannelConnect(
tokenRef.current,
client,
channelName,
connect.session_id,
"",
params,
);
setConnect((current) => ({
@@ -54,6 +54,7 @@ import type {
NanobotFeaturesPayload,
} from "@/lib/types";
import { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider";
export function ChannelCatalogRow({
feature,
@@ -148,7 +149,6 @@ export function ChannelSetupPanel({
if (feature.instances !== undefined) {
return (
<ChannelInstancesPanel
token={token}
feature={feature}
showBrandLogos={showBrandLogos}
chatAppsDocsUrl={chatAppsDocsUrl}
@@ -269,6 +269,7 @@ function ChannelSetupSurface({
ConnectFlow?: ComponentType<ChannelPluginConnectFlowProps>;
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
}) {
const { client } = useClient();
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const [notice, setNotice] = useState<string | null>(null);
@@ -345,7 +346,7 @@ function ChannelSetupSurface({
setNotice(null);
const values = channelValuesForSubmit(fields, fieldValues, touchedFields);
try {
const validationPayload = await validateChannel(token, feature.name, values);
const validationPayload = await validateChannel(client, feature.name, values);
setValidation(validationPayload);
if (!validationPayload.can_enable) {
setNotice(
@@ -355,7 +356,7 @@ function ChannelSetupSurface({
return;
}
const payload = await configureChannel(
token,
client,
feature.name,
values,
{ enable: true },
@@ -377,7 +378,7 @@ function ChannelSetupSurface({
setNotice(null);
try {
const payload = await validateChannel(
token,
client,
feature.name,
channelValuesForSubmit(fields, fieldValues, touchedFields),
);
+67 -36
View File
@@ -179,7 +179,11 @@ function getVoiceShortcutLabel(): string {
}
interface ThreadComposerProps {
onSend: (content: string, images?: SendAttachment[], options?: SendOptions) => void;
onSend: (
content: string,
images?: SendAttachment[],
options?: SendOptions,
) => boolean | void | Promise<boolean | void>;
disabled?: boolean;
placeholder?: string;
isStreaming?: boolean;
@@ -981,6 +985,8 @@ export function ThreadComposer({
end: number;
} | null>(null);
const [inlineError, setInlineError] = useState<string | null>(null);
const [sendPending, setSendPending] = useState(false);
const interactionDisabled = !!disabled || sendPending;
const [voiceErrorFading, setVoiceErrorFading] = useState(false);
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
@@ -1071,7 +1077,7 @@ export function ThreadComposer({
const addFiles = useCallback(
(files: File[]) => {
if (files.length === 0) return;
if (interactionDisabled || files.length === 0) return;
secondEnterPromptIdRef.current = null;
const { rejected } = enqueue(files);
if (rejected.length > 0) {
@@ -1080,7 +1086,7 @@ export function ThreadComposer({
setInlineError(null);
}
},
[enqueue, formatRejection],
[enqueue, formatRejection, interactionDisabled],
);
const {
@@ -1093,18 +1099,20 @@ export function ThreadComposer({
} = useClipboardAndDrop(addFiles);
useEffect(() => {
if (disabled || hasTouchPrimaryPointer) return;
if (interactionDisabled || hasTouchPrimaryPointer || (workspaceError && showProjectPicker)) {
return;
}
const el = textareaRef.current;
if (!el) return;
const id = requestAnimationFrame(() => el.focus());
return () => cancelAnimationFrame(id);
}, [disabled, hasTouchPrimaryPointer]);
}, [hasTouchPrimaryPointer, interactionDisabled, showProjectPicker, workspaceError]);
useEffect(() => {
if (!focusRequest || disabled) return;
if (!focusRequest || interactionDisabled) return;
const id = requestAnimationFrame(() => textareaRef.current?.focus());
return () => cancelAnimationFrame(id);
}, [disabled, focusRequest]);
}, [focusRequest, interactionDisabled]);
const normalizedQuotedContext = quotedContext?.trim().slice(0, QUEUED_PROMPT_MAX_CHARS) || null;
@@ -1118,15 +1126,17 @@ export function ThreadComposer({
const hasComposerContent = value.trim().length > 0 || readyImages.length > 0;
const canSend =
!disabled
!interactionDisabled
&& !modelNeedsSetup
&& !encoding
&& !hasErrors
&& hasComposerContent;
const canOpenModelSettings = Boolean(modelNeedsSetup && onModelBadgeClick && !disabled);
const canOpenModelSettings = Boolean(
modelNeedsSetup && onModelBadgeClick && !interactionDisabled,
);
const canQueueGuidance =
isStreaming
&& !disabled
&& !interactionDisabled
&& !modelNeedsSetup
&& !encoding
&& !hasErrors
@@ -1134,14 +1144,14 @@ export function ThreadComposer({
&& !value.trimStart().startsWith("/");
const slashQuery = useMemo(() => {
if (disabled || slashMenuDismissed || !value.startsWith("/")) return null;
if (interactionDisabled || slashMenuDismissed || !value.startsWith("/")) return null;
const commandToken = value.slice(1);
if (/\s/.test(commandToken)) return null;
return commandToken.toLowerCase();
}, [disabled, slashMenuDismissed, value]);
}, [interactionDisabled, slashMenuDismissed, value]);
const skillQuery = useMemo(() => {
if (disabled || slashMenuDismissed) return null;
if (interactionDisabled || slashMenuDismissed) return null;
const caret = Math.min(Math.max(cursorPosition, 0), value.length);
const beforeCaret = value.slice(0, caret);
const match = /\$([A-Za-z0-9_-]*)$/i.exec(beforeCaret);
@@ -1151,7 +1161,7 @@ export function ThreadComposer({
start: match.index,
text: match[1].toLowerCase(),
};
}, [cursorPosition, disabled, slashMenuDismissed, value]);
}, [cursorPosition, interactionDisabled, slashMenuDismissed, value]);
const visibleSlashCommands = useMemo(() => {
if (!(isStreaming && onStop)) return slashCommands;
@@ -1279,7 +1289,7 @@ export function ThreadComposer({
const showSlashMenu = filteredSlashCommands.length > 0;
const cliAppMention = useMemo<CliAppMentionQuery | null>(() => {
if (disabled || cliAppMenuDismissed) return null;
if (interactionDisabled || cliAppMenuDismissed) return null;
const caret = Math.min(Math.max(cursorPosition, 0), value.length);
const beforeCaret = value.slice(0, caret);
const match = /(?:^|\s)@([\p{L}\p{N}_-]*)$/iu.exec(beforeCaret);
@@ -1290,7 +1300,7 @@ export function ThreadComposer({
start: caret - query.length - 1,
end: caret,
};
}, [cliAppMenuDismissed, cursorPosition, disabled, value]);
}, [cliAppMenuDismissed, cursorPosition, interactionDisabled, value]);
const availableSessionMentions = useMemo(
() => sessionMentionOptions(
@@ -1580,7 +1590,7 @@ export function ThreadComposer({
}, VOICE_ERROR_VISIBLE_MS);
}, [clearVoiceErrorTimers, t]);
const voiceRecorder = useVoiceRecorder({
disabled,
disabled: interactionDisabled,
onClearError: clearInlineError,
onError: setVoiceError,
onTranscript: appendTranscription,
@@ -1714,7 +1724,7 @@ export function ThreadComposer({
clearDraggedSession();
const preview = sessionDragPreview;
setSessionDragPreview(null);
if (disabled) return true;
if (interactionDisabled) return true;
const sessionKey = readDraggedSession(event.dataTransfer);
const mention = availableSessionMentions.find(
(candidate) => candidate.session_key === (sessionKey ?? preview?.mention.session_key),
@@ -1732,11 +1742,17 @@ export function ThreadComposer({
preview?.end ?? textareaRef.current?.selectionEnd ?? caret,
);
return true;
}, [availableSessionMentions, disabled, insertMentionCandidate, sessionDragPreview, value.length]);
}, [
availableSessionMentions,
insertMentionCandidate,
interactionDisabled,
sessionDragPreview,
value.length,
]);
const previewSessionDrop = useCallback((event: React.DragEvent) => {
if (!hasDraggedSession(event.dataTransfer)) return false;
if (disabled) {
if (interactionDisabled) {
event.dataTransfer.dropEffect = "none";
setSessionDragPreview(null);
return true;
@@ -1765,7 +1781,7 @@ export function ThreadComposer({
: { mention, start, end }
));
return true;
}, [activeSessionMentions, availableSessionMentions, disabled, value.length]);
}, [activeSessionMentions, availableSessionMentions, interactionDisabled, value.length]);
useEffect(() => {
if (!sessionDragPreview) return;
@@ -2008,7 +2024,16 @@ export function ThreadComposer({
const isSlashSideChannel = isSideChannelLifecycle(slashLifecycle);
const finalizeActiveTurn =
slashLifecycle === "finalize_active_turn";
onSend(
const finishSend = () => {
if (hasTouchPrimaryPointer) textareaRef.current?.blur();
setQueuedPrompts([]);
// Bubble owns the data URL copy; safe to revoke every staged blob
// preview here without affecting the rendered message.
clear();
clearComposerText(!hasTouchPrimaryPointer);
onQuotedContextChange?.(null);
};
const result = onSend(
content,
payload,
isSlashSideChannel
@@ -2019,13 +2044,19 @@ export function ThreadComposer({
}
: options,
);
if (hasTouchPrimaryPointer) textareaRef.current?.blur();
setQueuedPrompts([]);
// Bubble owns the data URL copy; safe to revoke every staged blob
// preview here without affecting the rendered message.
clear();
clearComposerText(!hasTouchPrimaryPointer);
onQuotedContextChange?.(null);
if (result instanceof Promise) {
setSendPending(true);
void result
.then((accepted) => {
if (accepted !== false) finishSend();
})
.catch((error: unknown) => {
console.error("Failed to send message", error);
})
.finally(() => setSendPending(false));
return;
}
if (result !== false) finishSend();
}, [
activeCliMentionApps,
activeMcpPresetMentions,
@@ -2168,7 +2199,7 @@ export function ThreadComposer({
[removeChip],
);
const attachButtonDisabled = disabled || full;
const attachButtonDisabled = interactionDisabled || full;
const showVoiceButton = Boolean(onTranscribeAudio);
const voiceRecordingStatusLabel = t("thread.composer.voice.recordingStatus", {
time: voiceRecorder.elapsedLabel,
@@ -2253,7 +2284,7 @@ export function ThreadComposer({
isHero
? "max-w-[58rem] rounded-[28px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]"
: "max-w-[49.5rem] rounded-[22px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]",
disabled && "opacity-60",
interactionDisabled && "opacity-60",
sessionDragPreview && "ring-1 ring-primary/25",
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
goalState?.active &&
@@ -2368,7 +2399,7 @@ export function ThreadComposer({
onPaste={onPaste}
rows={1}
placeholder={sessionDragPreview ? "" : resolvedPlaceholder}
disabled={disabled}
disabled={interactionDisabled}
aria-label={t("thread.composer.inputAria")}
className={cn(
inputTextClasses,
@@ -2383,7 +2414,7 @@ export function ThreadComposer({
<div
role="alert"
className={cn(
"mx-3 mb-1 max-h-10 overflow-hidden rounded-md border border-destructive/40 bg-destructive/8 px-2.5 py-1",
"mx-3 mb-1 max-h-24 overflow-hidden rounded-md border border-destructive/40 bg-destructive/8 px-2.5 py-1",
"text-[11.5px] font-medium text-destructive transition-[max-height,margin,padding,opacity] [transition-duration:220ms] ease-out motion-reduce:transition-none",
voiceErrorFading && "mb-0 max-h-0 border-transparent py-0 opacity-0",
)}
@@ -2443,7 +2474,7 @@ export function ThreadComposer({
) : workspaceScope && !workspaceControlsHidden ? (
<WorkspaceAccessMenu
scope={workspaceScope}
disabled={disabled || workspaceScopeDisabled}
disabled={interactionDisabled || workspaceScopeDisabled}
canUseFullAccess={workspaceControls?.can_use_full_access !== false}
isHero={isHero}
onChange={onWorkspaceScopeChange}
@@ -2521,7 +2552,7 @@ export function ThreadComposer({
<Button
type={showStopButton || modelNeedsSetup ? "button" : "submit"}
size="icon"
disabled={showStopButton ? disabled : !canSend && !canOpenModelSettings}
disabled={showStopButton ? interactionDisabled : !canSend && !canOpenModelSettings}
aria-label={
showStopButton
? t("thread.composer.stop")
@@ -2562,7 +2593,7 @@ export function ThreadComposer({
<div className="composer-workspace-drawer-content">
<WorkspaceProjectPicker
isHero={isHero}
disabled={disabled || workspaceScopeDisabled || !showProjectPicker}
disabled={interactionDisabled || workspaceScopeDisabled || !showProjectPicker}
scope={workspaceScope}
defaultScope={workspaceDefaultScope}
controls={workspaceControls}
+3 -2
View File
@@ -1261,7 +1261,7 @@ export function ThreadShell({
const handleWelcomeSend = useCallback(
async (content: string, images?: SendAttachment[], options?: SendOptions) => {
if (booting) return;
if (booting) return false;
setBooting(true);
pendingFirstRef.current = { content, images, options: withWorkspaceScope(options) };
setPendingFirstTargetChatId(null);
@@ -1270,12 +1270,13 @@ export function ThreadShell({
pendingFirstRef.current = null;
setPendingFirstTargetChatId(null);
setBooting(false);
return;
return false;
}
if (localModelPreset) {
await client.sendSystemCommand(newId, `/model ${localModelPreset}`).catch(() => {});
}
setPendingFirstTargetChatId(newId);
return true;
},
[booting, client, localModelPreset, onCreateChat, withWorkspaceScope, workspaceScope],
);
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
import { AlertTriangle, Check, ChevronDown, Folder, Hand } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -34,6 +34,14 @@ import {
shortWorkspacePath,
} from "@/lib/workspace";
function workspacePathPlaceholder(defaultWorkspacePath: string, macPlaceholder: string): string {
const normalized = defaultWorkspacePath.trim().replace(/\\/g, "/");
const windowsDrive = normalized.match(/^([A-Za-z]):\//)?.[1];
if (windowsDrive) return `${windowsDrive.toUpperCase()}:\\path\\to\\project`;
if (normalized.startsWith("/Users/")) return macPlaceholder;
return "/home/name/project";
}
export function WorkspaceProjectPicker({
isHero,
compact = false,
@@ -60,6 +68,9 @@ export function WorkspaceProjectPicker({
const [pathDraft, setPathDraft] = useState("");
const [pathError, setPathError] = useState<string | null>(null);
const [pickingFolder, setPickingFolder] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const pathInputRef = useRef<HTMLInputElement>(null);
const pathErrorId = useId();
const currentProjectScope = selectedProjectScope(scope, defaultScope);
const projectLabel = currentProjectScope
? currentProjectScope.project_name || projectNameFromPath(currentProjectScope.project_path)
@@ -82,9 +93,17 @@ export function WorkspaceProjectPicker({
}, [disabled]);
useEffect(() => {
if (error && visible && !disabled) setOpen(true);
if (!error || !visible || disabled) return;
const frame = window.requestAnimationFrame(() => triggerRef.current?.focus());
return () => window.cancelAnimationFrame(frame);
}, [disabled, error, visible]);
useEffect(() => {
if (!open || !error) return;
const frame = window.requestAnimationFrame(() => pathInputRef.current?.focus());
return () => window.cancelAnimationFrame(frame);
}, [error, open]);
const applyProjectPath = useCallback(
(projectPath: string, projectName?: string) => {
const base = scope ?? defaultScope;
@@ -129,6 +148,7 @@ export function WorkspaceProjectPicker({
: "flex min-w-0 items-center rounded-b-[28px] bg-muted/45 px-3 py-1.5 dark:bg-white/[0.045] sm:px-4",
)}>
<button
ref={triggerRef}
type="button"
disabled={disabled || pickingFolder}
aria-label={t("thread.composer.workspace.projectAria")}
@@ -164,6 +184,7 @@ export function WorkspaceProjectPicker({
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
ref={triggerRef}
type="button"
disabled={disabled}
aria-label={t("thread.composer.workspace.projectAria")}
@@ -221,14 +242,20 @@ export function WorkspaceProjectPicker({
}}
>
<Input
ref={pathInputRef}
value={pathDraft}
disabled={disabled}
onChange={(event) => {
setPathDraft(event.target.value);
setPathError(null);
}}
placeholder={t("workspace.dialog.manualPlaceholder")}
placeholder={workspacePathPlaceholder(
defaultScope.project_path,
t("workspace.dialog.manualPlaceholder"),
)}
aria-label={t("workspace.dialog.manual")}
aria-invalid={pathError || error ? true : undefined}
aria-describedby={pathError || error ? pathErrorId : undefined}
className={cn(
"h-9 rounded-full border-border/55 bg-background/80 px-3 text-[12.5px]",
"focus-visible:ring-1 focus-visible:ring-foreground/10 focus-visible:ring-offset-0",
@@ -243,13 +270,22 @@ export function WorkspaceProjectPicker({
</Button>
</form>
{pathError || error ? (
<p role="alert" className="px-1 text-[11.5px] font-medium text-destructive">
<p
id={pathErrorId}
role="alert"
className="px-1 text-[11.5px] font-medium text-destructive"
>
{pathError ?? error}
</p>
) : null}
</div>
</PopoverContent>
</Popover>
{!compact && error && !open ? (
<span role="alert" className="ml-2 min-w-0 truncate text-[11.5px] font-medium text-destructive">
{error}
</span>
) : null}
</div>
);
}
+27 -349
View File
@@ -10,6 +10,23 @@ import {
} from "@/lib/tool-traces";
import { hasPendingAgentActivity } from "@/lib/activity-timeline";
import type { StreamError } from "@/lib/nanobot-client";
import {
closeReasoningStream,
filterCoveredFileEditToolEvents,
finalizeStreamedTurn,
findActiveAssistantPlaceholderIndex,
findFileEditTraceIndex,
findStreamingAssistantIndex,
isReasoningOnlyPlaceholder,
matchesTurn,
mergeFileEdits,
pruneReasoningOnlyPlaceholders,
replaceMessageAt,
stampLastAssistantCompletion,
stripCoveredFileEditToolHintsFromMessages,
turnFieldsFromEvent,
} from "@/lib/thread-event-projection";
import type { UIMessageTurnFields } from "@/lib/thread-event-projection";
import { formatQuotedUserMessage } from "@/lib/user-message-quote";
import type {
InboundEvent,
@@ -19,11 +36,8 @@ import type {
SessionMention,
GoalStateWsPayload,
MessageDeliveryStatus,
ToolProgressEvent,
UIMediaAttachment,
UIFileEdit,
UIMessage,
UITurnPhase,
WorkspaceScopePayload,
} from "@/lib/types";
@@ -41,54 +55,9 @@ type PendingStreamEvent =
| { kind: "delta"; text: string; turn: UIMessageTurnFields; source?: UIMessage["source"] }
| { kind: "reasoning"; text: string; turn: UIMessageTurnFields };
type UIMessageTurnFields = Pick<UIMessage, "turnId" | "turnPhase" | "turnSeq">;
const FILE_EDIT_TOOL_NAMES = new Set(["write_file", "edit_file", "apply_patch"]);
const STREAM_END_IDLE_DELAY_MS = 1000;
const BACKGROUND_STREAM_FLUSH_INTERVAL_MS = 1_000;
function turnFieldsFromEvent(
ev: { turn_id?: string; turn_phase?: UITurnPhase; turn_seq?: number },
fallbackPhase?: UITurnPhase,
): UIMessageTurnFields {
const fields: UIMessageTurnFields = {};
if (typeof ev.turn_id === "string" && ev.turn_id.length > 0) {
fields.turnId = ev.turn_id;
}
const phase = ev.turn_phase ?? fallbackPhase;
if (phase) fields.turnPhase = phase;
if (typeof ev.turn_seq === "number" && Number.isFinite(ev.turn_seq)) {
fields.turnSeq = ev.turn_seq;
}
return fields;
}
function matchesTurn(message: UIMessage, turn: UIMessageTurnFields): boolean {
return !turn.turnId || !message.turnId || message.turnId === turn.turnId;
}
/** Find a still-open streamed assistant turn. Closed stream segments stay visible
* as streaming until ``turn_end`` for visual continuity, but they must not
* receive later delta segments. */
function findStreamingAssistantIndex(
prev: UIMessage[],
closedStreamIds: ReadonlySet<string>,
turn: UIMessageTurnFields = {},
): number | null {
for (let i = prev.length - 1; i >= 0; i -= 1) {
const m = prev[i];
if (m.kind === "trace") continue;
if (
m.role === "assistant"
&& m.isStreaming
&& !closedStreamIds.has(m.id)
&& matchesTurn(m, turn)
) return i;
if (m.role === "user") break;
}
return null;
}
/**
* Append a reasoning chunk to the last open reasoning stream in ``prev``.
*
@@ -152,102 +121,6 @@ function attachReasoningChunk(
];
}
/**
* Find the most recent assistant placeholder that an incoming answer
* delta should adopt instead of spawning a parallel row. We look for an
* empty-content assistant turn that is still marked ``isStreaming``
* typically created earlier by ``reasoning_delta``. Anything else means
* the model already produced an answer in a previous turn, so the new
* delta belongs in a fresh row.
*/
function findActiveAssistantPlaceholderIndex(
prev: UIMessage[],
turn: UIMessageTurnFields = {},
): number | null {
const last = prev[prev.length - 1];
if (!last) return null;
if (last.role !== "assistant" || last.kind === "trace") return null;
if (last.content.length > 0) return null;
if (!last.isStreaming) return null;
if (!matchesTurn(last, turn)) return null;
return prev.length - 1;
}
function replaceMessageAt(prev: UIMessage[], index: number, message: UIMessage): UIMessage[] {
const next = prev.slice();
next[index] = message;
return next;
}
/**
* Close the active reasoning stream segment, if any. Idempotent: a
* ``reasoning_end`` with no preceding deltas is a harmless no-op.
*/
function closeReasoningStream(prev: UIMessage[]): UIMessage[] {
for (let i = prev.length - 1; i >= 0; i -= 1) {
const candidate = prev[i];
if (!candidate.reasoningStreaming) continue;
const latencyMs =
candidate.latencyMs === undefined
&& Number.isFinite(candidate.createdAt)
&& candidate.createdAt > 1_000_000_000_000
? Math.max(0, Math.round(Date.now() - candidate.createdAt))
: candidate.latencyMs;
const merged: UIMessage = {
...candidate,
reasoningStreaming: false,
...(latencyMs !== undefined ? { latencyMs } : {}),
};
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
}
return prev;
}
function isReasoningOnlyPlaceholder(message: UIMessage): boolean {
return (
message.role === "assistant"
&& message.kind !== "trace"
&& message.content.trim().length === 0
&& !!message.reasoning
&& !message.reasoningStreaming
&& !message.media?.length
);
}
function isToolTrace(message: UIMessage | undefined): boolean {
return message?.kind === "trace";
}
function pruneReasoningOnlyPlaceholders(prev: UIMessage[]): UIMessage[] {
return prev.filter((message, index) => {
if (!isReasoningOnlyPlaceholder(message)) return true;
// A reasoning-only assistant row immediately followed by tool traces is
// the live equivalent of a persisted assistant tool-call message with
// empty content, reasoning_content, and tool_calls. Keep it so live render
// and history replay stay isomorphic.
return isToolTrace(prev[index + 1]);
});
}
function stampLastAssistantCompletion(
prev: UIMessage[],
completion: Pick<UIMessage, "latencyMs" | "completedAt">,
turnId?: string,
): UIMessage[] {
for (let i = prev.length - 1; i >= 0; i -= 1) {
const m = prev[i];
if (
m.role === "assistant"
&& m.kind !== "trace"
&& (!turnId || !m.turnId || m.turnId === turnId)
) {
const merged: UIMessage = { ...m, ...completion, isStreaming: false };
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
}
}
return prev;
}
function absorbCompleteAssistantMessage(
prev: UIMessage[],
message: Omit<UIMessage, "id" | "role" | "createdAt">,
@@ -275,193 +148,6 @@ function absorbCompleteAssistantMessage(
];
}
function fileEditKey(edit: Pick<UIFileEdit, "call_id" | "tool" | "path">): string {
if (edit.call_id && edit.path) return `${edit.call_id}|${edit.tool}|${edit.path}`;
if (edit.call_id) return `${edit.call_id}|${edit.tool}`;
return `${edit.tool}|${edit.path}`;
}
function fileEditToolEventKey(edit: Pick<UIFileEdit, "call_id" | "tool" | "path">): string {
if (edit.call_id) return `${edit.call_id}|${edit.tool}`;
return fileEditKey(edit);
}
function toolEventFileEditKey(event: ToolProgressEvent): string | null {
const fn = (event as { function?: { name?: unknown } }).function;
const name = typeof event.name === "string"
? event.name
: typeof fn?.name === "string"
? fn.name
: "";
const callId = typeof event.call_id === "string" ? event.call_id : "";
if (!name || !callId || !FILE_EDIT_TOOL_NAMES.has(name)) return null;
return `${callId}|${name}`;
}
function hasFileEditForToolEvent(messages: UIMessage[], event: ToolProgressEvent): boolean {
const key = toolEventFileEditKey(event);
if (!key) return false;
return messages.some((message) =>
message.fileEdits?.some((edit) => fileEditToolEventKey(edit) === key),
);
}
function filterCoveredFileEditToolEvents(
messages: UIMessage[],
events: ToolProgressEvent[],
): ToolProgressEvent[] {
if (events.length === 0) return events;
return events.filter((event) => !hasFileEditForToolEvent(messages, event));
}
function stripCoveredFileEditToolHints(message: UIMessage, edits: UIFileEdit[]): UIMessage {
const incomingKeys = new Set(edits.map(fileEditToolEventKey));
const events = message.toolEvents ?? [];
if (!events.length || incomingKeys.size === 0) return message;
const removedTraceLines = new Set<string>();
const keptEvents: ToolProgressEvent[] = [];
let changed = false;
for (const event of events) {
const key = toolEventFileEditKey(event);
if (key && incomingKeys.has(key)) {
changed = true;
for (const line of toolTraceLinesFromEvents([event])) {
removedTraceLines.add(line);
}
continue;
}
keptEvents.push(event);
}
if (!changed) return message;
const previousTraces = message.traces?.length
? message.traces
: message.content
? [message.content]
: [];
const nextTraces = previousTraces.filter((line) => !removedTraceLines.has(line));
return {
...message,
traces: nextTraces,
content: nextTraces[nextTraces.length - 1] ?? "",
toolEvents: keptEvents.length ? keptEvents : undefined,
};
}
function traceMessageIsEmpty(message: UIMessage): boolean {
const traces = message.traces;
const hasTrace = traces?.length
? traces.some((line) => line.trim().length > 0)
: (message.content ?? "").trim().length > 0;
return (
message.kind === "trace"
&& !hasTrace
&& !message.toolEvents?.length
&& !message.fileEdits?.length
&& !message.media?.length
);
}
function stripCoveredFileEditToolHintsFromMessages(
messages: UIMessage[],
edits: UIFileEdit[],
turn: UIMessageTurnFields,
): UIMessage[] {
if (edits.length === 0) return messages;
let next = messages;
for (let i = next.length - 1; i >= 0; i -= 1) {
const candidate = next[i];
if (candidate.role === "user") break;
if (candidate.kind !== "trace") continue;
if (!matchesTurn(candidate, turn)) continue;
const cleaned = stripCoveredFileEditToolHints(candidate, edits);
if (cleaned === candidate) continue;
if (next === messages) next = [...messages];
if (traceMessageIsEmpty(cleaned)) {
next.splice(i, 1);
} else {
next[i] = cleaned;
}
}
return next;
}
function normalizeFileEdit(edit: UIFileEdit): UIFileEdit | null {
if (!edit || !edit.tool || (!edit.path && !edit.pending)) return null;
const inferredStatus =
edit.phase === "error"
? "error"
: edit.phase === "end"
? "done"
: "editing";
const normalized: UIFileEdit = {
...edit,
call_id: edit.call_id || `${edit.tool}:${edit.path}`,
added: Number.isFinite(edit.added) ? Math.max(0, Math.round(edit.added)) : 0,
deleted: Number.isFinite(edit.deleted) ? Math.max(0, Math.round(edit.deleted)) : 0,
status: edit.status === "error" || edit.status === "done" || edit.status === "editing"
? edit.status
: inferredStatus,
};
if (edit.pending && !edit.path) normalized.pending = true;
return normalized;
}
function mergeFileEdits(existing: UIFileEdit[] | undefined, incoming: UIFileEdit[]): UIFileEdit[] {
const next = [...(existing ?? [])];
const indexByKey = new Map(next.map((edit, index) => [fileEditKey(edit), index]));
for (const raw of incoming) {
const edit = normalizeFileEdit(raw);
if (!edit) continue;
const key = fileEditKey(edit);
let existingIndex = indexByKey.get(key);
if (existingIndex === undefined && edit.path) {
const eventKey = fileEditToolEventKey(edit);
const pendingIndex = next.findIndex((existing) =>
!existing.path && existing.pending && fileEditToolEventKey(existing) === eventKey,
);
if (pendingIndex >= 0) existingIndex = pendingIndex;
}
if (existingIndex === undefined) {
indexByKey.set(key, next.length);
next.push(edit);
continue;
}
const merged = { ...next[existingIndex], ...edit };
if (edit.path && !edit.pending) delete merged.pending;
next[existingIndex] = merged;
indexByKey.set(key, existingIndex);
}
return next;
}
function findFileEditTraceIndex(
prev: UIMessage[],
segmentId: string | null,
incoming: UIFileEdit[],
): number | null {
const incomingKeys = new Set(incoming.map(fileEditKey));
const incomingToolEventKeys = new Set(incoming.map(fileEditToolEventKey));
for (let i = prev.length - 1; i >= 0; i -= 1) {
const candidate = prev[i];
if (candidate.role === "user") break;
if (candidate.kind !== "trace") continue;
if (segmentId && candidate.activitySegmentId === segmentId) return i;
for (const existing of candidate.fileEdits ?? []) {
if (
incomingKeys.has(fileEditKey(existing))
|| (
!existing.path
&& existing.pending
&& incomingToolEventKeys.has(fileEditToolEventKey(existing))
)
) return i;
}
}
return null;
}
/**
* Subscribe to a chat by ID. Returns the in-memory message list for the chat,
* a streaming flag, and a ``send`` function. Initial history must be seeded
@@ -507,17 +193,6 @@ function eventExtendsModelActivity(ev: InboundEvent): boolean {
&& (ev.kind === "tool_hint" || ev.kind === "progress" || ev.kind === "reasoning");
}
function finalizeStreamedTurn(
prev: UIMessage[],
turn: UIMessageTurnFields = {},
): UIMessage[] {
return prev.map((m) =>
m.isStreaming && matchesTurn(m, turn)
? { ...m, isStreaming: false, reasoningStreaming: false }
: m,
);
}
function eventTurnId(ev: InboundEvent): string | undefined {
return "turn_id" in ev && typeof ev.turn_id === "string" ? ev.turn_id : undefined;
}
@@ -1104,7 +779,7 @@ export function useNanobotStream(
if (ev.event === "reasoning_end") {
if (suppressStreamUntilTurnEndRef.current) return;
setMessages((prev) => closeReasoningStream(prev));
setMessages((prev) => closeReasoningStream(prev, Date.now()));
return;
}
@@ -1174,12 +849,15 @@ export function useNanobotStream(
const line = ev.text;
if (!line) return;
if (fileEditSegmentRef.current) clearActivitySegment();
setMessages((prev) => closeReasoningStream(attachReasoningChunk(
prev,
line,
{ ensure: ensureActivitySegmentId },
turnFieldsFromEvent(ev, "reasoning"),
)));
setMessages((prev) => closeReasoningStream(
attachReasoningChunk(
prev,
line,
{ ensure: ensureActivitySegmentId },
turnFieldsFromEvent(ev, "reasoning"),
),
Date.now(),
));
return;
}
// Intermediate agent breadcrumbs (tool-call hints, raw progress).
+2 -2
View File
@@ -257,13 +257,13 @@ export function useSessions(): {
const deleteChat = useCallback(
async (key: string, options?: { deleteAutomations?: boolean }) => {
const result = await apiDeleteSession(tokenRef.current, key, options);
const result = await apiDeleteSession(client, key, options);
if (!result.deleted) return result;
optimisticKeysRef.current.delete(key);
setSessions((prev) => prev.filter((s) => s.key !== key));
return result;
},
[],
[client],
);
const getSessionAutomations = useCallback(async (key: string) => {
+22 -2
View File
@@ -144,6 +144,8 @@ export function useSidebarState(
const { client, token } = useClient();
const tokenRef = useRef(token);
const stateRef = useRef(DEFAULT_SIDEBAR_STATE);
const connectionOpenRef = useRef(client.status === "open");
const pendingPersistenceRef = useRef<SidebarStatePayload | null>(null);
const [state, setState] = useState<SidebarStatePayload>(DEFAULT_SIDEBAR_STATE);
const [loading, setLoading] = useState(true);
tokenRef.current = token;
@@ -171,14 +173,32 @@ export function useSidebarState(
};
}, []);
const persist = useCallback((next: SidebarStatePayload) => {
if (!connectionOpenRef.current) {
pendingPersistenceRef.current = next;
return;
}
void client.setSidebarState(next).catch(() => {
// Sidebar persistence is best-effort; the optimistic local state remains usable.
});
}, [client]);
useEffect(() => client.onStatus((status) => {
connectionOpenRef.current = status === "open";
if (status !== "open" || pendingPersistenceRef.current === null) return;
const pending = pendingPersistenceRef.current;
pendingPersistenceRef.current = null;
persist(pending);
}), [client, persist]);
const update = useCallback(
async (updater: (current: SidebarStatePayload) => SidebarStatePayload) => {
const next = normalizeSidebarState(updater(stateRef.current));
stateRef.current = next;
setState(next);
client.setSidebarState(next);
persist(next);
},
[client],
[persist],
);
const pruned = useMemo(() => {
+5
View File
@@ -28,6 +28,7 @@ const VOICE_MIME_CANDIDATES = [
export type VoiceRecorderState = "idle" | "recording" | "transcribing";
export type VoiceRecorderErrorKey =
| "failed"
| "insecureContext"
| "noDevice"
| "notConfigured"
| "permission"
@@ -163,6 +164,10 @@ export function useVoiceRecorder({
const startRecording = useCallback(async () => {
if (!onTranscribeAudio || state !== "idle" || startPendingRef.current) return;
onClearError();
if (window.isSecureContext === false) {
onError("insecureContext");
return;
}
const mediaDevices = navigator.mediaDevices;
const MediaRecorderCtor = mediaRecorderConstructor();
if (!mediaDevices?.getUserMedia || !MediaRecorderCtor) {
+18 -16
View File
@@ -10,11 +10,12 @@
"gatewayHint": "Make sure the gateway is running (`nanobot gateway`) and that this page is open on the same machine."
},
"auth": {
"title": "Authentication required",
"hint": "Enter the secret configured as tokenIssueSecret in your gateway config.",
"placeholder": "Password",
"label": "Password",
"showPassword": "Show password",
"hidePassword": "Hide password",
"submit": "Connect",
"invalid": "Invalid password. Try again."
"required": "Enter your password.",
"invalid": "Incorrect password. Try again."
},
"account": {
"section": "Account",
@@ -115,8 +116,6 @@
"imageDefaults": "Defaults",
"webSearch": "Web search",
"webBehavior": "Behavior",
"browserAutomation": "Browser automation",
"computerControl": "Computer control",
"cliApps": "CLI apps",
"mcp": "MCP services",
"regional": "Regional",
@@ -201,8 +200,6 @@
"maxResults": "Max results",
"timeout": "Timeout",
"jinaReader": "Jina reader",
"browserAutomation": "Browser automation",
"computerControl": "Computer control",
"imageGeneration": "Image generation",
"imageProvider": "Image provider",
"imageProviderStatus": "Provider status",
@@ -248,11 +245,6 @@
"maxResults": "Results returned by each web_search call.",
"timeout": "Seconds before a search provider request times out.",
"jinaReader": "Use Jina Reader for web_fetch when available.",
"browserAutomation": "Let nanobot navigate and act on web pages using structured page elements. Requires Playwright Chromium.",
"computerControl": "Let nanobot see and control the computer running its engine. macOS requires Screen Recording and Accessibility access.",
"computerControlBrowser": "Pixel-based control currently targets an isolated browser, as configured in config.json.",
"computerUseInstall": "Required Python support will be installed when you turn this on.",
"computerUseInstalling": "Installing computer-use support...",
"imageGeneration": "Expose generate_image in chats when a configured image provider is available.",
"imageProvider": "Choose the registry provider used by generate_image.",
"imageProviderStatus": "Image generation reuses provider credentials from Providers.",
@@ -604,7 +596,14 @@
"searchPlaceholder": "Search tools",
"featured": "Tools",
"loading": "Loading Apps...",
"empty": "No tools match this view.",
"empty": "No tools match your search.",
"emptyApps": "No apps available.",
"emptyIntegrations": "No integrations available.",
"emptyReady": "No tools are ready yet.",
"clearSearch": "Clear search",
"browseApps": "Browse apps",
"browseIntegrations": "Browse integrations",
"emptyIntegrationsHint": "Add a custom integration below.",
"restartRequired": "Restart nanobot to apply updated apps and integrations."
},
"channels": {
@@ -707,7 +706,9 @@
"loading": "Loading automations...",
"noMatches": "No automations match this view.",
"empty": "No automations yet.",
"emptyHint": "Create one from where it should run so nanobot keeps the right context.",
"emptyHint": "Create automations in a chat so they keep the right context.",
"emptyAction": "Open a chat",
"clearFilters": "Clear filters",
"oneShot": "One-time",
"systemTask": "System-managed automation",
"localTrigger": "Local trigger",
@@ -1156,6 +1157,7 @@
"recordingStatus": "Recording {{time}}"
},
"voiceErrors": {
"insecureContext": "Chrome and other browsers block microphone access on remote HTTP pages for security. Open this WebUI over HTTPS to use voice input.",
"unsupported": "Voice input is not supported in this browser.",
"permission": "Allow microphone access in the address bar, then retry.",
"notConfigured": "Configure a transcription provider first.",
@@ -1389,7 +1391,7 @@
},
"workspaceScopeRejected": {
"title": "Workspace was not changed",
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
"body": "The gateway rejected this project or access mode. Choose an existing project or a different access mode, then try again."
},
"turnRejected": {
"title": "Message was not sent",
+18 -16
View File
@@ -10,11 +10,12 @@
"gatewayHint": "Asegúrate de que la gateway esté en ejecución (`nanobot gateway`) y de que esta página esté abierta en la misma máquina."
},
"auth": {
"title": "Autenticación requerida",
"hint": "Introduce el secreto configurado como tokenIssueSecret en la configuración del gateway.",
"placeholder": "Contraseña",
"label": "Contraseña",
"showPassword": "Mostrar contraseña",
"hidePassword": "Ocultar contraseña",
"submit": "Conectar",
"invalid": "Contraseña no válida. Inténtalo de nuevo."
"required": "Introduce la contraseña.",
"invalid": "Contraseña incorrecta. Inténtalo de nuevo."
},
"account": {
"section": "Cuenta",
@@ -115,8 +116,6 @@
"imageDefaults": "Valores predeterminados",
"webSearch": "Búsqueda web",
"webBehavior": "Comportamiento",
"browserAutomation": "Automatización del navegador",
"computerControl": "Control del ordenador",
"regional": "Configuración regional",
"webuiSafety": "Seguridad de WebUI",
"capabilities": "Capacidades",
@@ -147,8 +146,6 @@
"maxResults": "Resultados máximos",
"timeout": "Tiempo de espera",
"jinaReader": "Lector Jina",
"browserAutomation": "Automatización del navegador",
"computerControl": "Control del ordenador",
"imageGeneration": "Generación de imágenes",
"imageProvider": "Proveedor de imágenes",
"imageProviderStatus": "Estado del proveedor",
@@ -192,11 +189,6 @@
"maxResults": "Resultados devueltos por cada llamada web_search.",
"timeout": "Segundos antes de que una solicitud de búsqueda expire.",
"jinaReader": "Usa Jina Reader para web_fetch cuando esté disponible.",
"browserAutomation": "Permite que nanobot navegue y actúe en páginas web mediante elementos estructurados. Requiere Chromium de Playwright.",
"computerControl": "Permite que nanobot vea y controle el ordenador que ejecuta su motor. macOS requiere permisos de grabación de pantalla y accesibilidad.",
"computerControlBrowser": "El control por píxeles apunta actualmente a un navegador aislado, según config.json.",
"computerUseInstall": "El soporte de Python necesario se instalará al activarlo.",
"computerUseInstalling": "Instalando componentes de control...",
"imageGeneration": "Expone generate_image en chats cuando hay un proveedor de imagen configurado.",
"imageProvider": "Elige el proveedor registrado usado por generate_image.",
"imageProviderStatus": "La generación de imágenes reutiliza las credenciales de los proveedores.",
@@ -591,7 +583,14 @@
"searchPlaceholder": "Buscar aplicaciones",
"featured": "Herramientas",
"loading": "Cargando aplicaciones...",
"empty": "Ninguna herramienta coincide con esta vista.",
"empty": "Ninguna herramienta coincide con tu búsqueda.",
"emptyApps": "No hay aplicaciones disponibles.",
"emptyIntegrations": "No hay integraciones disponibles.",
"emptyReady": "Todavía no hay herramientas listas.",
"clearSearch": "Borrar búsqueda",
"browseApps": "Explorar aplicaciones",
"browseIntegrations": "Explorar integraciones",
"emptyIntegrationsHint": "Añade una integración personalizada abajo.",
"restartRequired": "Reinicia nanobot para aplicar las aplicaciones y funciones actualizadas."
},
"channels": {
@@ -694,7 +693,9 @@
"loading": "Cargando automatizaciones...",
"noMatches": "No hay automatizaciones que coincidan con esta vista.",
"empty": "Aún no hay automatizaciones.",
"emptyHint": "Créala desde donde debe ejecutarse para que nanobot conserve el contexto correcto.",
"emptyHint": "Crea automatizaciones en un chat para que conserven el contexto correcto.",
"emptyAction": "Abrir un chat",
"clearFilters": "Borrar filtros",
"oneShot": "Una vez",
"systemTask": "Automatización administrada por el sistema",
"localTrigger": "Activador local",
@@ -1143,6 +1144,7 @@
"recordingStatus": "Grabando {{time}}"
},
"voiceErrors": {
"insecureContext": "Por seguridad, Chrome y otros navegadores bloquean el acceso al micrófono en páginas HTTP remotas. Abre esta WebUI mediante HTTPS para usar la entrada de voz.",
"unsupported": "Este navegador no admite entrada de voz.",
"permission": "Permite el micrófono en la barra de direcciones y vuelve a intentarlo.",
"notConfigured": "Configura primero un proveedor de transcripción.",
@@ -1376,7 +1378,7 @@
},
"workspaceScopeRejected": {
"title": "El espacio de trabajo no cambió",
"body": "El gateway rechazó el proyecto o modo de acceso solicitado, así que Nanobot conservó el espacio de trabajo anterior."
"body": "El gateway rechazó este proyecto o modo de acceso. Elige un proyecto existente u otro modo de acceso e inténtalo de nuevo."
},
"turnRejected": {
"title": "El mensaje no se envió",
+18 -16
View File
@@ -10,11 +10,12 @@
"gatewayHint": "Assurez-vous que la gateway est en cours dexécution (`nanobot gateway`) et que cette page est ouverte sur la même machine."
},
"auth": {
"title": "Authentification requise",
"hint": "Saisissez le secret configuré comme tokenIssueSecret dans la configuration de votre gateway.",
"placeholder": "Mot de passe",
"label": "Mot de passe",
"showPassword": "Afficher le mot de passe",
"hidePassword": "Masquer le mot de passe",
"submit": "Se connecter",
"invalid": "Mot de passe invalide. Réessayez."
"required": "Saisissez le mot de passe.",
"invalid": "Mot de passe incorrect. Réessayez."
},
"account": {
"section": "Compte",
@@ -115,8 +116,6 @@
"imageDefaults": "Valeurs par défaut",
"webSearch": "Recherche web",
"webBehavior": "Comportement",
"browserAutomation": "Automatisation du navigateur",
"computerControl": "Contrôle de lordinateur",
"regional": "Paramètres régionaux",
"webuiSafety": "Sécurité WebUI",
"capabilities": "Capacités",
@@ -147,8 +146,6 @@
"maxResults": "Résultats max.",
"timeout": "Délai dattente",
"jinaReader": "Lecteur Jina",
"browserAutomation": "Automatisation du navigateur",
"computerControl": "Contrôle de lordinateur",
"imageGeneration": "Génération dimages",
"imageProvider": "Fournisseur dimages",
"imageProviderStatus": "État du fournisseur",
@@ -192,11 +189,6 @@
"maxResults": "Résultats renvoyés par chaque appel web_search.",
"timeout": "Nombre de secondes avant lexpiration dune requête de recherche.",
"jinaReader": "Utilise Jina Reader pour web_fetch lorsque disponible.",
"browserAutomation": "Permet à nanobot de parcourir et manipuler les pages web à partir de leurs éléments structurés. Nécessite Chromium de Playwright.",
"computerControl": "Permet à nanobot de voir et contrôler lordinateur qui exécute son moteur. macOS exige les autorisations Enregistrement de l’écran et Accessibilité.",
"computerControlBrowser": "Le contrôle par pixels cible actuellement un navigateur isolé, conformément à config.json.",
"computerUseInstall": "Les composants Python requis seront installés lors de lactivation.",
"computerUseInstalling": "Installation des composants de contrôle...",
"imageGeneration": "Expose generate_image dans les chats lorsquun fournisseur dimage configuré est disponible.",
"imageProvider": "Choisissez le fournisseur inscrit utilisé par generate_image.",
"imageProviderStatus": "La génération dimages réutilise les identifiants des fournisseurs.",
@@ -590,7 +582,14 @@
"searchPlaceholder": "Rechercher des applications",
"featured": "Outils",
"loading": "Chargement des applications...",
"empty": "Aucun outil ne correspond à cette vue.",
"empty": "Aucun outil ne correspond à votre recherche.",
"emptyApps": "Aucune application disponible.",
"emptyIntegrations": "Aucune intégration disponible.",
"emptyReady": "Aucun outil nest encore prêt.",
"clearSearch": "Effacer la recherche",
"browseApps": "Parcourir les applications",
"browseIntegrations": "Parcourir les intégrations",
"emptyIntegrationsHint": "Ajoutez une intégration personnalisée ci-dessous.",
"restartRequired": "Redémarrez nanobot pour appliquer les applications et fonctions mises à jour."
},
"channels": {
@@ -693,7 +692,9 @@
"loading": "Chargement des automatisations...",
"noMatches": "Aucune automatisation ne correspond à cette vue.",
"empty": "Aucune automatisation pour le moment.",
"emptyHint": "Créez-la depuis son point d'exécution pour que nanobot conserve le bon contexte.",
"emptyHint": "Créez les automatisations dans un chat afin de conserver le bon contexte.",
"emptyAction": "Ouvrir un chat",
"clearFilters": "Effacer les filtres",
"oneShot": "Ponctuelle",
"systemTask": "Automatisation gérée par le système",
"localTrigger": "Déclencheur local",
@@ -1142,6 +1143,7 @@
"recordingStatus": "Enregistrement {{time}}"
},
"voiceErrors": {
"insecureContext": "Pour des raisons de sécurité, Chrome et dautres navigateurs bloquent laccès au microphone sur les pages HTTP distantes. Ouvrez cette WebUI en HTTPS pour utiliser la saisie vocale.",
"unsupported": "La saisie vocale n'est pas prise en charge par ce navigateur.",
"permission": "Autorisez le microphone dans la barre d'adresse, puis réessayez.",
"notConfigured": "Configurez d'abord un fournisseur de transcription.",
@@ -1375,7 +1377,7 @@
},
"workspaceScopeRejected": {
"title": "Lespace de travail na pas changé",
"body": "La passerelle a refusé le projet ou le mode daccès demandé ; Nanobot a conservé lespace de travail précédent."
"body": "La passerelle a refusé ce projet ou ce mode daccès. Choisissez un projet existant ou un autre mode daccès, puis réessayez."
},
"turnRejected": {
"title": "Le message na pas été envoyé",

Some files were not shown because too many files have changed in this diff Show More