mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-10 14:28:38 +03:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c281e090d0 | ||
|
|
85a452e5c7 | ||
|
|
05d73803e7 | ||
|
|
5d733b1c7c | ||
|
|
71a99b0780 | ||
|
|
43511decc9 | ||
|
|
8dd2059be3 | ||
|
|
7b1646f58c | ||
|
|
e620944150 | ||
|
|
66316f21da | ||
|
|
55ecda275d | ||
|
|
411d6061ae |
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
+11
-3
@@ -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")
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
|
||||
|
||||
+17
-27
@@ -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)
|
||||
async with AsyncExitStack() as stack:
|
||||
connected = await open_single_server(name, cfg, stack)
|
||||
if not ready.done():
|
||||
ready.set_result(stack is not None)
|
||||
if stack is not None:
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
@@ -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: ...
|
||||
+196
-165
@@ -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:
|
||||
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"
|
||||
else:
|
||||
obj = getattr(obj, part)
|
||||
except (KeyError, AttributeError) as e:
|
||||
return None, f"'{part}' not found: {e}"
|
||||
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():
|
||||
lines = [f"{prefix}{len(mapping)} subagent(s):"]
|
||||
for tid, st in mapping.items():
|
||||
if _is_subagent_status(st):
|
||||
detail = MyTool._format_status(st, " ")
|
||||
lines.append(f" [{tid}] '{st.label}'\n{detail}")
|
||||
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()
|
||||
if session_key:
|
||||
old = self._runtime_control.snapshot().model_preset
|
||||
try:
|
||||
runtime = self._runtime_state.set_session_model_preset(
|
||||
session_key,
|
||||
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:
|
||||
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)
|
||||
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 # int → float coercion allowed
|
||||
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__}")
|
||||
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}")
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,11 +3140,9 @@ 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(
|
||||
custom_provider_created = await _webui_mutate(
|
||||
webui_client,
|
||||
"settings.provider.create",
|
||||
{
|
||||
"name": "Company Gateway",
|
||||
"apiBase": "https://gateway.example/v1",
|
||||
@@ -2999,8 +3152,6 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
"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
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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={
|
||||
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={
|
||||
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)
|
||||
|
||||
@@ -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,9 +16,14 @@ from nanobot.webui.http_utils import query_first
|
||||
QueryParams = dict[str, list[str]]
|
||||
|
||||
|
||||
def nanobot_features_payload() -> dict[str, Any]:
|
||||
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:
|
||||
"""Preserve the difference between a global action and an explicit instance."""
|
||||
@@ -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)
|
||||
|
||||
+168
-135
@@ -14,11 +14,11 @@ import math
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Iterable
|
||||
from contextlib import suppress
|
||||
from typing import Any, Literal, cast
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
@@ -49,6 +49,9 @@ from nanobot.webui.workspaces import (
|
||||
QueryParams = dict[str, list[str]]
|
||||
RuntimeSurface = Literal["browser", "native"]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.webui.settings_services import WebUIOAuthFlowRegistry
|
||||
|
||||
|
||||
def _version_payload() -> dict[str, Any]:
|
||||
"""Return version info for the settings payload."""
|
||||
@@ -133,9 +136,6 @@ _IMAGE_GENERATION_ASPECT_RATIOS = {
|
||||
_CONTEXT_WINDOW_TOKEN_OPTIONS = {65_536, 200_000, 262_144, 500_000, 1_048_576}
|
||||
_OAUTH_PROXY_PROVIDERS = {"openai_codex", "xai_grok"}
|
||||
_WEBUI_OAUTH_TIMEOUT_S = 600
|
||||
_WEBUI_OAUTH_MAX_FLOWS = 8
|
||||
_webui_oauth_flows: dict[str, tuple[str, Any]] = {}
|
||||
_webui_oauth_flows_lock = threading.Lock()
|
||||
_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
|
||||
_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||
|
||||
@@ -148,6 +148,21 @@ class WebUISettingsError(ValueError):
|
||||
self.status = status
|
||||
|
||||
|
||||
def _load_settings_config(config_path: Path | None) -> Config:
|
||||
return load_config(config_path) if config_path is not None else load_config()
|
||||
|
||||
|
||||
def _save_settings_config(config: Config, config_path: Path | None) -> None:
|
||||
if config_path is None:
|
||||
save_config(config)
|
||||
else:
|
||||
save_config(config, config_path)
|
||||
|
||||
|
||||
def _settings_config_path(config_path: Path | None) -> Path:
|
||||
return config_path if config_path is not None else get_config_path()
|
||||
|
||||
|
||||
def _normalize_surface(surface: str | None) -> RuntimeSurface:
|
||||
return "native" if surface in {"native", "desktop"} else "browser"
|
||||
|
||||
@@ -764,7 +779,11 @@ def _extract_model_rows(body: Any) -> list[dict[str, Any]]:
|
||||
return rows
|
||||
|
||||
|
||||
def provider_models_payload(query: QueryParams) -> dict[str, Any]:
|
||||
def provider_models_payload(
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch an OpenAI-compatible provider's model list for Settings.
|
||||
|
||||
The result is advisory only: users can always type a custom model id. This
|
||||
@@ -775,7 +794,7 @@ def provider_models_payload(query: QueryParams) -> dict[str, Any]:
|
||||
if not provider_name:
|
||||
raise WebUISettingsError("provider is required")
|
||||
|
||||
config = load_config()
|
||||
config = _load_settings_config(config_path)
|
||||
resolved_provider = _resolve_settings_provider(config, provider_name)
|
||||
if resolved_provider is None:
|
||||
raise WebUISettingsError("unknown provider")
|
||||
@@ -1117,8 +1136,9 @@ def settings_payload(
|
||||
runtime_capability_overrides: dict[str, Any] | None = None,
|
||||
restart_required_sections: list[str] | None = None,
|
||||
apply_state: dict[str, Any] | None = None,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
config = load_config()
|
||||
config = _load_settings_config(config_path)
|
||||
defaults = config.agents.defaults
|
||||
active_preset_name = defaults.model_preset or "default"
|
||||
effective_preset = config.resolve_preset()
|
||||
@@ -1299,7 +1319,7 @@ def settings_payload(
|
||||
"providers": _transcription_provider_rows(config),
|
||||
},
|
||||
"runtime": {
|
||||
"config_path": str(get_config_path().expanduser()),
|
||||
"config_path": str(_settings_config_path(config_path).expanduser()),
|
||||
"workspace_path": str(config.workspace_path),
|
||||
"gateway_host": config.gateway.host,
|
||||
"gateway_port": config.gateway.port,
|
||||
@@ -1341,14 +1361,18 @@ def settings_payload(
|
||||
)
|
||||
|
||||
|
||||
def settings_usage_payload() -> dict[str, Any]:
|
||||
def settings_usage_payload(*, config_path: Path | None = None) -> dict[str, Any]:
|
||||
"""Return the lightweight token usage slice for Overview refreshes."""
|
||||
config = load_config()
|
||||
config = _load_settings_config(config_path)
|
||||
return token_usage_payload(timezone_name=config.agents.defaults.timezone)
|
||||
|
||||
|
||||
def update_agent_settings(query: QueryParams) -> dict[str, Any]:
|
||||
config = load_config()
|
||||
def update_agent_settings(
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
config = _load_settings_config(config_path)
|
||||
defaults = config.agents.defaults
|
||||
changed = False
|
||||
restart_required = False
|
||||
@@ -1425,11 +1449,15 @@ def update_agent_settings(query: QueryParams) -> dict[str, Any]:
|
||||
restart_required = True
|
||||
|
||||
if changed:
|
||||
save_config(config)
|
||||
return settings_payload(requires_restart=restart_required)
|
||||
_save_settings_config(config, config_path)
|
||||
return settings_payload(requires_restart=restart_required, config_path=config_path)
|
||||
|
||||
|
||||
def create_model_configuration(query: QueryParams) -> dict[str, Any]:
|
||||
def create_model_configuration(
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
label = (_query_first_alias(query, "label", "displayName") or "").strip()
|
||||
raw_name = (_query_first(query, "name") or label).strip()
|
||||
model = (_query_first(query, "model") or "").strip()
|
||||
@@ -1443,7 +1471,7 @@ def create_model_configuration(query: QueryParams) -> dict[str, Any]:
|
||||
raise WebUISettingsError("provider is required")
|
||||
|
||||
name = _model_configuration_slug(raw_name or label)
|
||||
config = load_config()
|
||||
config = _load_settings_config(config_path)
|
||||
if name in config.model_presets:
|
||||
raise WebUISettingsError("configuration already exists", status=409)
|
||||
_validate_configured_provider(config, provider)
|
||||
@@ -1476,18 +1504,22 @@ def create_model_configuration(query: QueryParams) -> dict[str, Any]:
|
||||
temperature=temperature if temperature is not None else base.temperature,
|
||||
reasoning_effort=reasoning_effort,
|
||||
)
|
||||
save_config(config)
|
||||
payload = settings_payload()
|
||||
_save_settings_config(config, config_path)
|
||||
payload = settings_payload(config_path=config_path)
|
||||
payload["created_model_preset"] = name
|
||||
return payload
|
||||
|
||||
|
||||
def update_model_configuration(query: QueryParams) -> dict[str, Any]:
|
||||
def update_model_configuration(
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
name = (_query_first(query, "name") or "").strip()
|
||||
if not name or name == "default":
|
||||
raise WebUISettingsError("model configuration is required")
|
||||
|
||||
config = load_config()
|
||||
config = _load_settings_config(config_path)
|
||||
preset = config.model_presets.get(name)
|
||||
if preset is None:
|
||||
raise WebUISettingsError("unknown model configuration")
|
||||
@@ -1554,11 +1586,15 @@ def update_model_configuration(query: QueryParams) -> dict[str, Any]:
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
save_config(config)
|
||||
return settings_payload()
|
||||
_save_settings_config(config, config_path)
|
||||
return settings_payload(config_path=config_path)
|
||||
|
||||
|
||||
def update_model_call_order(query: QueryParams) -> dict[str, Any]:
|
||||
def update_model_call_order(
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
raw_order = _query_first_alias(query, "order", "presetNames")
|
||||
if raw_order is None:
|
||||
raise WebUISettingsError("model call order is required")
|
||||
@@ -1580,7 +1616,7 @@ def update_model_call_order(query: QueryParams) -> dict[str, Any]:
|
||||
cast(str, name).strip()
|
||||
for name in cast(list[object], order)
|
||||
]
|
||||
config = load_config()
|
||||
config = _load_settings_config(config_path)
|
||||
_, editable = _model_call_order_state(config)
|
||||
if not editable:
|
||||
raise WebUISettingsError(
|
||||
@@ -1599,13 +1635,17 @@ def update_model_call_order(query: QueryParams) -> dict[str, Any]:
|
||||
):
|
||||
defaults.model_preset = normalized_order[0]
|
||||
defaults.fallback_models = fallback_models
|
||||
save_config(config)
|
||||
return settings_payload()
|
||||
_save_settings_config(config, config_path)
|
||||
return settings_payload(config_path=config_path)
|
||||
|
||||
|
||||
def migrate_model_configurations(_query: QueryParams | None = None) -> dict[str, Any]:
|
||||
def migrate_model_configurations(
|
||||
_query: QueryParams | None = None,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Materialize legacy primary/inline model settings as named presets."""
|
||||
config = load_config()
|
||||
config = _load_settings_config(config_path)
|
||||
defaults = config.agents.defaults
|
||||
primary = config.resolve_preset()
|
||||
created: list[str] = []
|
||||
@@ -1658,16 +1698,20 @@ def migrate_model_configurations(_query: QueryParams | None = None) -> dict[str,
|
||||
|
||||
if created:
|
||||
defaults.fallback_models = fallback_models
|
||||
save_config(config)
|
||||
return settings_payload()
|
||||
_save_settings_config(config, config_path)
|
||||
return settings_payload(config_path=config_path)
|
||||
|
||||
|
||||
def delete_model_configuration(query: QueryParams) -> dict[str, Any]:
|
||||
def delete_model_configuration(
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
name = (_query_first(query, "name") or "").strip()
|
||||
if not name or name == "default":
|
||||
raise WebUISettingsError("model configuration is required")
|
||||
|
||||
config = load_config()
|
||||
config = _load_settings_config(config_path)
|
||||
if name not in config.model_presets:
|
||||
raise WebUISettingsError("unknown model configuration")
|
||||
defaults = config.agents.defaults
|
||||
@@ -1681,11 +1725,15 @@ def delete_model_configuration(query: QueryParams) -> dict[str, Any]:
|
||||
)
|
||||
|
||||
del config.model_presets[name]
|
||||
save_config(config)
|
||||
return settings_payload()
|
||||
_save_settings_config(config, config_path)
|
||||
return settings_payload(config_path=config_path)
|
||||
|
||||
|
||||
def create_provider_settings(query: QueryParams) -> dict[str, Any]:
|
||||
def create_provider_settings(
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
display_name = (_query_first_alias(query, "name", "displayName") or "").strip()
|
||||
if not display_name:
|
||||
raise WebUISettingsError("provider name is required")
|
||||
@@ -1710,7 +1758,7 @@ def create_provider_settings(query: QueryParams) -> dict[str, Any]:
|
||||
if not api_base:
|
||||
raise WebUISettingsError("API base is required")
|
||||
|
||||
config = load_config()
|
||||
config = _load_settings_config(config_path)
|
||||
if _provider_display_name_exists(config, display_name):
|
||||
raise WebUISettingsError("provider already exists", status=409)
|
||||
|
||||
@@ -1719,18 +1767,22 @@ def create_provider_settings(query: QueryParams) -> dict[str, Any]:
|
||||
updates["api_type"] = "auto"
|
||||
provider_config = _validated_provider_config(None, updates)
|
||||
setattr(config.providers, provider_key, provider_config)
|
||||
save_config(config)
|
||||
payload = settings_payload()
|
||||
_save_settings_config(config, config_path)
|
||||
payload = settings_payload(config_path=config_path)
|
||||
payload["created_provider"] = provider_key
|
||||
return payload
|
||||
|
||||
|
||||
def update_provider_settings(query: QueryParams) -> dict[str, Any]:
|
||||
def update_provider_settings(
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
provider_name = (_query_first(query, "provider") or "").strip()
|
||||
if not provider_name:
|
||||
raise WebUISettingsError("provider is required")
|
||||
|
||||
config = load_config()
|
||||
config = _load_settings_config(config_path)
|
||||
resolved_provider = _resolve_settings_provider(config, provider_name)
|
||||
if resolved_provider is None:
|
||||
raise WebUISettingsError("unknown provider")
|
||||
@@ -1772,7 +1824,7 @@ def update_provider_settings(query: QueryParams) -> dict[str, Any]:
|
||||
changed = updated_provider_config != provider_config
|
||||
if changed:
|
||||
setattr(config.providers, provider_key, updated_provider_config)
|
||||
save_config(config)
|
||||
_save_settings_config(config, config_path)
|
||||
image_config = config.tools.image_generation
|
||||
restart_required = (
|
||||
changed
|
||||
@@ -1780,10 +1832,15 @@ def update_provider_settings(query: QueryParams) -> dict[str, Any]:
|
||||
and image_config.provider == provider_key
|
||||
and get_image_gen_provider(provider_key) is not None
|
||||
)
|
||||
return settings_payload(requires_restart=restart_required)
|
||||
return settings_payload(requires_restart=restart_required, config_path=config_path)
|
||||
|
||||
|
||||
def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
||||
def login_oauth_provider(
|
||||
query: QueryParams,
|
||||
*,
|
||||
oauth_flows: WebUIOAuthFlowRegistry,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
provider_name = (_query_first(query, "provider") or "").strip()
|
||||
if not provider_name:
|
||||
raise WebUISettingsError("provider is required")
|
||||
@@ -1798,7 +1855,10 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
||||
raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None
|
||||
|
||||
try:
|
||||
proxy = resolve_config_env_vars(load_config()).providers.openai_codex.proxy or None
|
||||
proxy = resolve_config_env_vars(
|
||||
_load_settings_config(config_path),
|
||||
config_path=config_path,
|
||||
).providers.openai_codex.proxy or None
|
||||
except ValueError as e:
|
||||
raise WebUISettingsError(str(e), status=400) from e
|
||||
remote_browser_value = _query_first(query, "remote_browser")
|
||||
@@ -1816,7 +1876,7 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
||||
except Exception as e:
|
||||
raise WebUISettingsError(f"OpenAI Codex OAuth login failed: {e}", status=502) from e
|
||||
flow_id = secrets.token_urlsafe(24)
|
||||
_register_webui_oauth_flow(spec.name, flow_id, flow)
|
||||
oauth_flows.register(spec.name, flow_id, flow)
|
||||
return {
|
||||
"status": "authorization_required",
|
||||
"provider": spec.name,
|
||||
@@ -1840,13 +1900,16 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
||||
token = login_github_copilot(print_fn=lambda _message: None)
|
||||
if not (token and token.access):
|
||||
raise WebUISettingsError("OAuth login failed", status=401)
|
||||
return settings_payload()
|
||||
return settings_payload(config_path=config_path)
|
||||
|
||||
if spec.name == "xai_grok":
|
||||
from nanobot.providers.xai_oauth import start_xai_oauth_login
|
||||
|
||||
try:
|
||||
proxy = resolve_config_env_vars(load_config()).providers.xai_grok.proxy or None
|
||||
proxy = resolve_config_env_vars(
|
||||
_load_settings_config(config_path),
|
||||
config_path=config_path,
|
||||
).providers.xai_grok.proxy or None
|
||||
except ValueError as e:
|
||||
raise WebUISettingsError(str(e), status=400) from e
|
||||
try:
|
||||
@@ -1857,7 +1920,7 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
||||
except Exception as e:
|
||||
raise WebUISettingsError(f"xAI OAuth login failed: {e}", status=502) from e
|
||||
flow_id = secrets.token_urlsafe(24)
|
||||
_register_webui_oauth_flow(spec.name, flow_id, flow)
|
||||
oauth_flows.register(spec.name, flow_id, flow)
|
||||
return {
|
||||
"status": "authorization_required",
|
||||
"provider": spec.name,
|
||||
@@ -1873,6 +1936,9 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
||||
def complete_oauth_provider(
|
||||
query: QueryParams,
|
||||
authorization_response: str | None = None,
|
||||
*,
|
||||
oauth_flows: WebUIOAuthFlowRegistry,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
provider_name = (_query_first(query, "provider") or "").strip()
|
||||
flow_id = (_query_first(query, "flow_id") or "").strip()
|
||||
@@ -1882,7 +1948,7 @@ def complete_oauth_provider(
|
||||
if not flow_id:
|
||||
raise WebUISettingsError("flow_id is required")
|
||||
|
||||
flow = _get_webui_oauth_flow(spec.name, flow_id)
|
||||
flow = oauth_flows.get(spec.name, flow_id)
|
||||
if flow is None:
|
||||
raise WebUISettingsError(f"{spec.label} sign-in expired. Start again.", status=410)
|
||||
|
||||
@@ -1904,7 +1970,7 @@ def complete_oauth_provider(
|
||||
except WebUISettingsError:
|
||||
raise
|
||||
except Exception as e:
|
||||
_remove_webui_oauth_flow(spec.name, flow_id, flow)
|
||||
oauth_flows.remove(spec.name, flow_id, flow)
|
||||
raise WebUISettingsError(f"{spec.label} OAuth login failed: {e}", status=502) from e
|
||||
if token is None:
|
||||
return {
|
||||
@@ -1912,13 +1978,18 @@ def complete_oauth_provider(
|
||||
"provider": spec.name,
|
||||
"flow_id": flow_id,
|
||||
}
|
||||
_remove_webui_oauth_flow(spec.name, flow_id, flow, cancel=False)
|
||||
oauth_flows.remove(spec.name, flow_id, flow, cancel=False)
|
||||
if not token.access:
|
||||
raise WebUISettingsError("OAuth login failed", status=401)
|
||||
return settings_payload()
|
||||
return settings_payload(config_path=config_path)
|
||||
|
||||
|
||||
def logout_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
||||
def logout_oauth_provider(
|
||||
query: QueryParams,
|
||||
*,
|
||||
oauth_flows: WebUIOAuthFlowRegistry,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
provider_name = (_query_first(query, "provider") or "").strip()
|
||||
if not provider_name:
|
||||
raise WebUISettingsError("provider is required")
|
||||
@@ -1932,7 +2003,7 @@ def logout_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
||||
from oauth_cli_kit.storage import FileTokenStorage
|
||||
except ImportError:
|
||||
raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None
|
||||
_clear_webui_oauth_flows(spec.name)
|
||||
oauth_flows.clear(spec.name)
|
||||
token_path = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename).get_token_path()
|
||||
elif spec.name == "github_copilot":
|
||||
try:
|
||||
@@ -1943,77 +2014,23 @@ def logout_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
||||
elif spec.name == "xai_grok":
|
||||
from nanobot.providers.xai_oauth import logout_xai_oauth
|
||||
|
||||
_clear_webui_oauth_flows(spec.name)
|
||||
oauth_flows.clear(spec.name)
|
||||
logout_xai_oauth()
|
||||
return settings_payload()
|
||||
return settings_payload(config_path=config_path)
|
||||
else:
|
||||
raise WebUISettingsError("OAuth logout is not supported for this provider")
|
||||
|
||||
for path in (token_path, token_path.with_suffix(".lock")):
|
||||
with suppress(FileNotFoundError):
|
||||
path.unlink()
|
||||
return settings_payload()
|
||||
return settings_payload(config_path=config_path)
|
||||
|
||||
|
||||
def _register_webui_oauth_flow(provider_name: str, flow_id: str, flow: Any) -> None:
|
||||
discarded: list[Any] = []
|
||||
with _webui_oauth_flows_lock:
|
||||
for existing_id, (_provider_name, existing) in list(_webui_oauth_flows.items()):
|
||||
if existing.expired:
|
||||
discarded.append(_webui_oauth_flows.pop(existing_id)[1])
|
||||
while len(_webui_oauth_flows) >= _WEBUI_OAUTH_MAX_FLOWS:
|
||||
oldest_id = next(iter(_webui_oauth_flows))
|
||||
discarded.append(_webui_oauth_flows.pop(oldest_id)[1])
|
||||
_webui_oauth_flows[flow_id] = (provider_name, flow)
|
||||
for existing in discarded:
|
||||
existing.cancel()
|
||||
|
||||
|
||||
def _get_webui_oauth_flow(provider_name: str, flow_id: str) -> Any | None:
|
||||
with _webui_oauth_flows_lock:
|
||||
registered = _webui_oauth_flows.get(flow_id)
|
||||
if registered is None or registered[0] != provider_name:
|
||||
return None
|
||||
flow = registered[1]
|
||||
if not flow.expired:
|
||||
return flow
|
||||
_webui_oauth_flows.pop(flow_id, None)
|
||||
flow.cancel()
|
||||
return None
|
||||
|
||||
|
||||
def _remove_webui_oauth_flow(
|
||||
provider_name: str,
|
||||
flow_id: str,
|
||||
flow: Any,
|
||||
def update_network_safety_settings(
|
||||
query: QueryParams,
|
||||
*,
|
||||
cancel: bool = True,
|
||||
) -> None:
|
||||
with _webui_oauth_flows_lock:
|
||||
registered = _webui_oauth_flows.get(flow_id)
|
||||
if (
|
||||
registered is not None
|
||||
and registered[0] == provider_name
|
||||
and registered[1] is flow
|
||||
):
|
||||
_webui_oauth_flows.pop(flow_id)
|
||||
if cancel:
|
||||
flow.cancel()
|
||||
|
||||
|
||||
def _clear_webui_oauth_flows(provider_name: str) -> None:
|
||||
with _webui_oauth_flows_lock:
|
||||
flow_ids = [
|
||||
flow_id
|
||||
for flow_id, (registered_provider, _flow) in _webui_oauth_flows.items()
|
||||
if registered_provider == provider_name
|
||||
]
|
||||
flows = [_webui_oauth_flows.pop(flow_id)[1] for flow_id in flow_ids]
|
||||
for flow in flows:
|
||||
flow.cancel()
|
||||
|
||||
|
||||
def update_network_safety_settings(query: QueryParams) -> dict[str, Any]:
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
raw_allow = (
|
||||
_query_first_alias(query, "webui_allow_local_service_access", "webuiAllowLocalServiceAccess")
|
||||
or _query_first_alias(query, "allow_local_preview_access", "allowLocalPreviewAccess")
|
||||
@@ -2022,7 +2039,7 @@ def update_network_safety_settings(query: QueryParams) -> dict[str, Any]:
|
||||
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")
|
||||
|
||||
config = load_config()
|
||||
config = _load_settings_config(config_path)
|
||||
changed = False
|
||||
if raw_allow is not None:
|
||||
webui_allow_local_service_access = _parse_bool(raw_allow, "webui_allow_local_service_access")
|
||||
@@ -2031,7 +2048,7 @@ def update_network_safety_settings(query: QueryParams) -> dict[str, Any]:
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
save_config(config)
|
||||
_save_settings_config(config, config_path)
|
||||
if raw_default_access_mode is not None:
|
||||
default_access_mode = raw_default_access_mode.strip().lower()
|
||||
if default_access_mode == "restricted":
|
||||
@@ -2042,16 +2059,20 @@ def update_network_safety_settings(query: QueryParams) -> dict[str, Any]:
|
||||
write_webui_default_access_mode(default_access_mode)
|
||||
except ValueError as exc:
|
||||
raise WebUISettingsError(str(exc)) from exc
|
||||
return settings_payload(requires_restart=changed)
|
||||
return settings_payload(requires_restart=changed, config_path=config_path)
|
||||
|
||||
|
||||
def update_web_search_settings(query: QueryParams) -> dict[str, Any]:
|
||||
def update_web_search_settings(
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
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")
|
||||
|
||||
config = load_config()
|
||||
config = _load_settings_config(config_path)
|
||||
search_config = config.tools.web.search
|
||||
web_config = config.tools.web
|
||||
previous_provider = search_config.provider
|
||||
@@ -2130,13 +2151,17 @@ def update_web_search_settings(query: QueryParams) -> dict[str, Any]:
|
||||
restart_required = True
|
||||
|
||||
if changed:
|
||||
save_config(config)
|
||||
return settings_payload(requires_restart=restart_required)
|
||||
_save_settings_config(config, config_path)
|
||||
return settings_payload(requires_restart=restart_required, config_path=config_path)
|
||||
|
||||
|
||||
def update_api_settings(query: QueryParams) -> dict[str, Any]:
|
||||
def update_api_settings(
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Update the managed OpenAI-compatible API configuration."""
|
||||
config = load_config()
|
||||
config = _load_settings_config(config_path)
|
||||
api = config.api
|
||||
|
||||
host = _query_first(query, "host")
|
||||
@@ -2173,12 +2198,16 @@ def update_api_settings(query: QueryParams) -> dict[str, Any]:
|
||||
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")
|
||||
|
||||
save_config(config)
|
||||
return settings_payload()
|
||||
_save_settings_config(config, config_path)
|
||||
return settings_payload(config_path=config_path)
|
||||
|
||||
|
||||
def update_image_generation_settings(query: QueryParams) -> dict[str, Any]:
|
||||
config = load_config()
|
||||
def update_image_generation_settings(
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
config = _load_settings_config(config_path)
|
||||
image_config = config.tools.image_generation
|
||||
changed = False
|
||||
|
||||
@@ -2271,12 +2300,16 @@ def update_image_generation_settings(query: QueryParams) -> dict[str, Any]:
|
||||
raise WebUISettingsError("image generation provider is not configured")
|
||||
|
||||
if changed:
|
||||
save_config(config)
|
||||
return settings_payload(requires_restart=changed)
|
||||
_save_settings_config(config, config_path)
|
||||
return settings_payload(requires_restart=changed, config_path=config_path)
|
||||
|
||||
|
||||
def update_transcription_settings(query: QueryParams) -> dict[str, Any]:
|
||||
config = load_config()
|
||||
def update_transcription_settings(
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
config = _load_settings_config(config_path)
|
||||
transcription = config.transcription
|
||||
changed = False
|
||||
|
||||
@@ -2341,5 +2374,5 @@ def update_transcription_settings(query: QueryParams) -> dict[str, Any]:
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
save_config(config)
|
||||
return settings_payload()
|
||||
_save_settings_config(config, config_path)
|
||||
return settings_payload(config_path=config_path)
|
||||
|
||||
+222
-157
@@ -13,7 +13,6 @@ import json
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import Any, cast
|
||||
from urllib.parse import unquote
|
||||
|
||||
from websockets.http11 import Request as WsRequest
|
||||
from websockets.http11 import Response
|
||||
@@ -31,7 +30,7 @@ from nanobot.channels.contracts import (
|
||||
)
|
||||
from nanobot.channels.registry import load_channel_plugin
|
||||
from nanobot.channels.validation import validate_channel_config
|
||||
from nanobot.config.loader import get_config_path, load_config, save_config
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.optional_features import (
|
||||
OptionalFeatureError,
|
||||
extra_installed,
|
||||
@@ -40,7 +39,6 @@ from nanobot.optional_features import (
|
||||
)
|
||||
from nanobot.pairing import approve_code, deny_code, list_pending
|
||||
from nanobot.webui.cli_apps_api import cli_apps_action, cli_apps_payload
|
||||
from nanobot.webui.http_utils import case_insensitive_header
|
||||
from nanobot.webui.http_utils import is_local_browser_request as _is_local_browser_request
|
||||
from nanobot.webui.http_utils import query_first as _query_first
|
||||
from nanobot.webui.mcp_presets_api import mcp_presets_settings_action
|
||||
@@ -72,21 +70,13 @@ from nanobot.webui.settings_api import (
|
||||
update_transcription_settings,
|
||||
update_web_search_settings,
|
||||
)
|
||||
from nanobot.webui.settings_services import WebUISettingsServices
|
||||
from nanobot.webui.version_check import check_for_update
|
||||
|
||||
QueryParams = dict[str, list[str]]
|
||||
|
||||
_MCP_VALUES_HEADER = "X-Nanobot-MCP-Values"
|
||||
_MCP_VALUES_HEADER_MAX_BYTES = 64 * 1024
|
||||
_PROVIDER_VALUES_HEADER = "X-Nanobot-Provider-Values"
|
||||
_PROVIDER_VALUES_HEADER_MAX_BYTES = 64 * 1024
|
||||
_CHANNEL_VALUES_HEADER = "X-Nanobot-Channel-Values"
|
||||
_CHANNEL_VALUES_HEADER_MAX_BYTES = 64 * 1024
|
||||
_API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values"
|
||||
_API_SERVICE_VALUES_HEADER_MAX_BYTES = 8 * 1024
|
||||
_OAUTH_CODE_HEADER = "X-Nanobot-OAuth-Code"
|
||||
_OAUTH_CALLBACK_HEADER = "X-Nanobot-OAuth-Callback"
|
||||
_OAUTH_RESPONSE_HEADER_MAX_BYTES = 8 * 1024
|
||||
_WEBUI_MUTATION_PAYLOAD_ATTR = "_nanobot_webui_mutation_payload"
|
||||
_WEBUI_MUTATION_REQUEST_ATTR = "_nanobot_webui_mutation_request"
|
||||
|
||||
_SKIP_FIELD = object()
|
||||
_CHANNEL_CONNECT_ACTIONS = frozenset({"start", "poll", "cancel"})
|
||||
@@ -112,6 +102,63 @@ _MCP_PRESET_ACTIONS_BY_PATH = {
|
||||
"/api/settings/mcp-presets/tools": "tools",
|
||||
}
|
||||
|
||||
_SETTINGS_MUTATION_PATHS = frozenset({
|
||||
"/api/settings/update",
|
||||
"/api/settings/model-configurations/create",
|
||||
"/api/settings/model-configurations/update",
|
||||
"/api/settings/model-configurations/delete",
|
||||
"/api/settings/model-configurations/migrate",
|
||||
"/api/settings/model-call-order/update",
|
||||
"/api/settings/provider/update",
|
||||
"/api/settings/provider/create",
|
||||
"/api/settings/provider/oauth-login",
|
||||
"/api/settings/provider/oauth-login/complete",
|
||||
"/api/settings/provider/oauth-logout",
|
||||
"/api/settings/web-search/update",
|
||||
"/api/settings/api-service/start",
|
||||
"/api/settings/api-service/stop",
|
||||
"/api/settings/image-generation/update",
|
||||
"/api/settings/transcription/update",
|
||||
"/api/settings/network-safety/update",
|
||||
"/api/settings/cli-apps/install",
|
||||
"/api/settings/cli-apps/update",
|
||||
"/api/settings/cli-apps/uninstall",
|
||||
"/api/settings/cli-apps/test",
|
||||
"/api/settings/nanobot-features/enable",
|
||||
"/api/settings/nanobot-features/disable",
|
||||
"/api/settings/channels/validate",
|
||||
"/api/settings/channels/configure",
|
||||
"/api/settings/pairing/approve",
|
||||
"/api/settings/pairing/deny",
|
||||
*_MCP_PRESET_ACTIONS_BY_PATH,
|
||||
})
|
||||
|
||||
|
||||
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 _query_value(value: Any) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, (dict, list)):
|
||||
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||
return str(value)
|
||||
|
||||
|
||||
def _payload_query(payload: dict[str, Any]) -> QueryParams:
|
||||
return {
|
||||
key: [_query_value(value)]
|
||||
for key, value in payload.items()
|
||||
if key
|
||||
and key not in {"authorization_response", "channel", "values"}
|
||||
}
|
||||
|
||||
|
||||
class WebUISettingsRouter:
|
||||
"""Route WebUI Settings HTTP requests behind a transport-neutral boundary."""
|
||||
@@ -119,6 +166,7 @@ class WebUISettingsRouter:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
settings: WebUISettingsServices,
|
||||
bus: MessageBus,
|
||||
logger: Any,
|
||||
check_api_token: Callable[[WsRequest], bool],
|
||||
@@ -130,6 +178,7 @@ class WebUISettingsRouter:
|
||||
channel_feature_action: Callable[..., Any] | None = None,
|
||||
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
self.settings = settings
|
||||
self.bus = bus
|
||||
self.logger = logger
|
||||
self._check_api_token = check_api_token
|
||||
@@ -144,6 +193,15 @@ class WebUISettingsRouter:
|
||||
self._channel_connectors: dict[str, Any] = {}
|
||||
|
||||
async def dispatch(self, connection: Any, request: WsRequest, path: str) -> Response | None:
|
||||
if self.is_mutation_path(path) and not getattr(
|
||||
request,
|
||||
_WEBUI_MUTATION_REQUEST_ATTR,
|
||||
False,
|
||||
):
|
||||
return self._error_response(
|
||||
405,
|
||||
"WebUI mutations require an authenticated WebSocket",
|
||||
)
|
||||
if path == "/api/settings":
|
||||
return self._handle_settings(request)
|
||||
if path == "/api/settings/usage":
|
||||
@@ -230,7 +288,17 @@ class WebUISettingsRouter:
|
||||
return await self._handle_settings_mcp_presets(request, mcp_action)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def is_mutation_path(path: str) -> bool:
|
||||
return (
|
||||
path in _SETTINGS_MUTATION_PATHS
|
||||
or _channel_connect_route(path) is not None
|
||||
)
|
||||
|
||||
def _query(self, request: WsRequest) -> QueryParams:
|
||||
payload = _mutation_payload(request)
|
||||
if payload is not None:
|
||||
return _payload_query(payload)
|
||||
return self._parse_query(request.path)
|
||||
|
||||
def _authorized(self, request: WsRequest) -> bool:
|
||||
@@ -260,70 +328,18 @@ class WebUISettingsRouter:
|
||||
)
|
||||
|
||||
def _parse_mcp_settings_query(self, request: WsRequest) -> QueryParams:
|
||||
query = self._query(request)
|
||||
raw = request.headers.get(_MCP_VALUES_HEADER)
|
||||
if not raw:
|
||||
return query
|
||||
if len(raw.encode("utf-8")) > _MCP_VALUES_HEADER_MAX_BYTES:
|
||||
raise WebUISettingsError("MCP settings payload is too large")
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise WebUISettingsError("invalid MCP settings payload") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise WebUISettingsError("MCP settings payload must be a JSON object")
|
||||
payload = cast(dict[object, Any], payload)
|
||||
merged = {key: list(values) for key, values in query.items()}
|
||||
for key, value in payload.items():
|
||||
if not isinstance(key, str) or not key:
|
||||
raise WebUISettingsError("MCP settings payload contains an invalid key")
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
else:
|
||||
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||
if text:
|
||||
merged[key] = [text]
|
||||
return merged
|
||||
return self._query(request)
|
||||
|
||||
def _parse_provider_settings_query(self, request: WsRequest) -> QueryParams:
|
||||
query = self._query(request)
|
||||
raw = request.headers.get(_PROVIDER_VALUES_HEADER)
|
||||
if not raw:
|
||||
return query
|
||||
if len(raw.encode("utf-8")) > _PROVIDER_VALUES_HEADER_MAX_BYTES:
|
||||
raise WebUISettingsError("provider settings payload is too large")
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
try:
|
||||
payload = json.loads(unquote(raw))
|
||||
except json.JSONDecodeError:
|
||||
raise WebUISettingsError("invalid provider settings payload") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise WebUISettingsError("provider settings payload must be a JSON object")
|
||||
payload = cast(dict[object, Any], payload)
|
||||
|
||||
merged = {key: list(values) for key, values in query.items()}
|
||||
for key, value in payload.items():
|
||||
if not isinstance(key, str) or not key:
|
||||
raise WebUISettingsError("provider settings payload contains an invalid key")
|
||||
if isinstance(value, str):
|
||||
text = value
|
||||
elif value is None:
|
||||
text = ""
|
||||
else:
|
||||
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||
merged[key] = [text]
|
||||
return merged
|
||||
return self._query(request)
|
||||
|
||||
def _handle_settings(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
return self._json_response(
|
||||
self._with_restart_state(
|
||||
settings_payload(
|
||||
self.settings.read(
|
||||
settings_payload,
|
||||
surface=self._runtime_surface,
|
||||
runtime_capability_overrides=self._runtime_capabilities,
|
||||
)
|
||||
@@ -333,7 +349,7 @@ class WebUISettingsRouter:
|
||||
def _handle_settings_usage(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
return self._json_response(settings_usage_payload())
|
||||
return self._json_response(self.settings.read(settings_usage_payload))
|
||||
|
||||
def _handle_settings_pairing(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
@@ -379,7 +395,7 @@ class WebUISettingsRouter:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = update_agent_settings(self._query(request))
|
||||
payload = self.settings.mutate(update_agent_settings, self._query(request))
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload, section="runtime"))
|
||||
@@ -388,7 +404,10 @@ class WebUISettingsRouter:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = create_model_configuration(self._query(request))
|
||||
payload = self.settings.mutate(
|
||||
create_model_configuration,
|
||||
self._query(request),
|
||||
)
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload))
|
||||
@@ -397,7 +416,10 @@ class WebUISettingsRouter:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = update_model_configuration(self._query(request))
|
||||
payload = self.settings.mutate(
|
||||
update_model_configuration,
|
||||
self._query(request),
|
||||
)
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload))
|
||||
@@ -406,7 +428,10 @@ class WebUISettingsRouter:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = delete_model_configuration(self._query(request))
|
||||
payload = self.settings.mutate(
|
||||
delete_model_configuration,
|
||||
self._query(request),
|
||||
)
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload))
|
||||
@@ -415,7 +440,10 @@ class WebUISettingsRouter:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = migrate_model_configurations(self._query(request))
|
||||
payload = self.settings.mutate(
|
||||
migrate_model_configurations,
|
||||
self._query(request),
|
||||
)
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload))
|
||||
@@ -424,7 +452,10 @@ class WebUISettingsRouter:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = update_model_call_order(self._query(request))
|
||||
payload = self.settings.mutate(
|
||||
update_model_call_order,
|
||||
self._query(request),
|
||||
)
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload))
|
||||
@@ -433,7 +464,10 @@ class WebUISettingsRouter:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = update_provider_settings(self._parse_provider_settings_query(request))
|
||||
payload = self.settings.mutate(
|
||||
update_provider_settings,
|
||||
self._parse_provider_settings_query(request)
|
||||
)
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
payload = await self._apply_image_generation_runtime_change(payload)
|
||||
@@ -443,7 +477,10 @@ class WebUISettingsRouter:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = create_provider_settings(self._parse_provider_settings_query(request))
|
||||
payload = self.settings.mutate(
|
||||
create_provider_settings,
|
||||
self._parse_provider_settings_query(request)
|
||||
)
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload))
|
||||
@@ -452,7 +489,11 @@ class WebUISettingsRouter:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = await asyncio.to_thread(provider_models_payload, self._query(request))
|
||||
payload = await asyncio.to_thread(
|
||||
self.settings.read,
|
||||
provider_models_payload,
|
||||
self._query(request),
|
||||
)
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
except Exception:
|
||||
@@ -470,27 +511,33 @@ class WebUISettingsRouter:
|
||||
query = self._query(request)
|
||||
try:
|
||||
if action == "login":
|
||||
payload = await asyncio.to_thread(login_oauth_provider, query)
|
||||
elif action == "complete":
|
||||
authorization_response = case_insensitive_header(
|
||||
request.headers,
|
||||
_OAUTH_CALLBACK_HEADER,
|
||||
) or case_insensitive_header(
|
||||
request.headers,
|
||||
_OAUTH_CODE_HEADER,
|
||||
)
|
||||
if (
|
||||
len(authorization_response.encode("utf-8"))
|
||||
> _OAUTH_RESPONSE_HEADER_MAX_BYTES
|
||||
):
|
||||
raise WebUISettingsError("OAuth authorization response is too large")
|
||||
payload = await asyncio.to_thread(
|
||||
self.settings.read,
|
||||
login_oauth_provider,
|
||||
query,
|
||||
oauth_flows=self.settings.oauth_flows,
|
||||
)
|
||||
elif action == "complete":
|
||||
raw_response = (_mutation_payload(request) or {}).get(
|
||||
"authorization_response"
|
||||
)
|
||||
if raw_response is not None and not isinstance(raw_response, str):
|
||||
raise WebUISettingsError("OAuth authorization response must be a string")
|
||||
authorization_response = raw_response
|
||||
payload = await asyncio.to_thread(
|
||||
self.settings.read,
|
||||
complete_oauth_provider,
|
||||
query,
|
||||
authorization_response or None,
|
||||
oauth_flows=self.settings.oauth_flows,
|
||||
)
|
||||
else:
|
||||
payload = await asyncio.to_thread(logout_oauth_provider, query)
|
||||
payload = await asyncio.to_thread(
|
||||
self.settings.read,
|
||||
logout_oauth_provider,
|
||||
query,
|
||||
oauth_flows=self.settings.oauth_flows,
|
||||
)
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
if payload.get("status") in {"authorization_required", "pending"}:
|
||||
@@ -501,7 +548,10 @@ class WebUISettingsRouter:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = update_web_search_settings(self._query(request))
|
||||
payload = self.settings.mutate(
|
||||
update_web_search_settings,
|
||||
self._query(request),
|
||||
)
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload, section="browser"))
|
||||
@@ -520,19 +570,22 @@ class WebUISettingsRouter:
|
||||
return self._unauthorized()
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
nanobot_features_action,
|
||||
self._nanobot_features_action,
|
||||
"enable",
|
||||
{"name": ["api"]},
|
||||
allow_install=self._allow_feature_package_install(connection, request),
|
||||
)
|
||||
update_api_settings(self._parse_api_service_settings_query(request))
|
||||
config = load_config()
|
||||
self.settings.mutate(
|
||||
update_api_settings,
|
||||
self._parse_api_service_settings_query(request),
|
||||
)
|
||||
config = self.settings.config.load()
|
||||
runtime = self._api_runtime()
|
||||
options = ApiStartOptions(
|
||||
host=config.api.host,
|
||||
port=config.api.port,
|
||||
workspace=str(config.workspace_path),
|
||||
config_path=str(get_config_path().expanduser().resolve(strict=False)),
|
||||
config_path=str(self.settings.config.path),
|
||||
)
|
||||
current = runtime.status()
|
||||
result = await asyncio.to_thread(
|
||||
@@ -549,33 +602,12 @@ class WebUISettingsRouter:
|
||||
return self._json_response(self._api_service_payload(last_action="started"))
|
||||
|
||||
def _parse_api_service_settings_query(self, request: WsRequest) -> QueryParams:
|
||||
query = self._query(request)
|
||||
if "api_key" in query or "apiKey" in query:
|
||||
raise WebUISettingsError("API service API key must be provided in the private header")
|
||||
raw = request.headers.get(_API_SERVICE_VALUES_HEADER)
|
||||
if not raw:
|
||||
return query
|
||||
if len(raw.encode("utf-8")) > _API_SERVICE_VALUES_HEADER_MAX_BYTES:
|
||||
raise WebUISettingsError("API service settings payload is too large")
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise WebUISettingsError("invalid API service settings payload") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise WebUISettingsError("API service settings payload must be a JSON object")
|
||||
payload = cast(dict[str, Any], payload)
|
||||
|
||||
unknown = set(payload) - {"api_key"}
|
||||
if unknown:
|
||||
raise WebUISettingsError("API service settings payload contains an invalid key")
|
||||
payload = _mutation_payload(request)
|
||||
if payload is not None:
|
||||
api_key = payload.get("api_key")
|
||||
if api_key is not None and not isinstance(api_key, str):
|
||||
raise WebUISettingsError("API service API key must be a string")
|
||||
|
||||
merged = {key: list(values) for key, values in query.items() if key != "api_key"}
|
||||
if api_key is not None:
|
||||
merged["api_key"] = [api_key]
|
||||
return merged
|
||||
return self._query(request)
|
||||
|
||||
async def _handle_settings_api_service_stop(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
@@ -589,13 +621,11 @@ class WebUISettingsRouter:
|
||||
return self._error_response(500, self._api_runtime_message(result.message))
|
||||
return self._json_response(self._api_service_payload(last_action="stopped"))
|
||||
|
||||
@staticmethod
|
||||
def _api_runtime() -> ApiRuntime:
|
||||
config_path = get_config_path().expanduser().resolve(strict=False)
|
||||
return ApiRuntime(paths=api_runtime_paths(config_path))
|
||||
def _api_runtime(self) -> ApiRuntime:
|
||||
return ApiRuntime(paths=api_runtime_paths(self.settings.config.path))
|
||||
|
||||
def _api_service_payload(self, *, last_action: str | None = None) -> dict[str, Any]:
|
||||
config = load_config()
|
||||
config = self.settings.config.load()
|
||||
status = self._api_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
|
||||
@@ -639,7 +669,10 @@ class WebUISettingsRouter:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = update_image_generation_settings(self._query(request))
|
||||
payload = self.settings.mutate(
|
||||
update_image_generation_settings,
|
||||
self._query(request),
|
||||
)
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
payload = await self._apply_image_generation_runtime_change(payload)
|
||||
@@ -674,7 +707,10 @@ class WebUISettingsRouter:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = update_transcription_settings(self._query(request))
|
||||
payload = self.settings.mutate(
|
||||
update_transcription_settings,
|
||||
self._query(request),
|
||||
)
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload))
|
||||
@@ -683,7 +719,10 @@ class WebUISettingsRouter:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = update_network_safety_settings(self._query(request))
|
||||
payload = self.settings.mutate(
|
||||
update_network_safety_settings,
|
||||
self._query(request),
|
||||
)
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload, section="runtime"))
|
||||
@@ -697,7 +736,10 @@ class WebUISettingsRouter:
|
||||
"yes",
|
||||
}
|
||||
try:
|
||||
payload = await cli_apps_payload(installed_only=installed_only)
|
||||
payload = await 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 self._error_response(500, "failed to load CLI Apps")
|
||||
@@ -711,7 +753,12 @@ class WebUISettingsRouter:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = await asyncio.to_thread(cli_apps_action, action, self._query(request))
|
||||
payload = await asyncio.to_thread(
|
||||
cli_apps_action,
|
||||
action,
|
||||
self._query(request),
|
||||
config_path=self.settings.config.path,
|
||||
)
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
except Exception as e:
|
||||
@@ -726,12 +773,29 @@ class WebUISettingsRouter:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = await asyncio.to_thread(nanobot_features_payload)
|
||||
payload = await asyncio.to_thread(self._nanobot_features_payload)
|
||||
except Exception:
|
||||
self.logger.exception("failed to load nanobot features")
|
||||
return self._error_response(500, "failed to load nanobot features")
|
||||
return self._json_response(self._with_channel_runtime_status(payload))
|
||||
|
||||
def _nanobot_features_payload(self) -> dict[str, Any]:
|
||||
return nanobot_features_payload(config_path=self.settings.config.path)
|
||||
|
||||
def _nanobot_features_action(
|
||||
self,
|
||||
action: str,
|
||||
query: QueryParams,
|
||||
*,
|
||||
allow_install: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
return self.settings.mutate(
|
||||
nanobot_features_action,
|
||||
action,
|
||||
query,
|
||||
allow_install=allow_install,
|
||||
)
|
||||
|
||||
async def _handle_settings_nanobot_features_action(
|
||||
self,
|
||||
connection: Any,
|
||||
@@ -742,7 +806,7 @@ class WebUISettingsRouter:
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = await asyncio.to_thread(
|
||||
nanobot_features_action,
|
||||
self._nanobot_features_action,
|
||||
action,
|
||||
self._query(request),
|
||||
allow_install=action != "enable"
|
||||
@@ -850,7 +914,7 @@ class WebUISettingsRouter:
|
||||
saved = await asyncio.to_thread(
|
||||
self._save_channel_config_values,
|
||||
name,
|
||||
self._parse_channel_values_header(request),
|
||||
self._parse_channel_values(request),
|
||||
instance_id,
|
||||
)
|
||||
except WebUISettingsError as e:
|
||||
@@ -865,7 +929,7 @@ class WebUISettingsRouter:
|
||||
"saved_keys": saved,
|
||||
}
|
||||
if not enable:
|
||||
features = await asyncio.to_thread(nanobot_features_payload)
|
||||
features = await asyncio.to_thread(self._nanobot_features_payload)
|
||||
features = self._with_channel_runtime_status(features)
|
||||
payload["nanobot_features"] = self._with_restart_state(features, section="runtime")
|
||||
return self._json_response(payload)
|
||||
@@ -876,7 +940,7 @@ class WebUISettingsRouter:
|
||||
|
||||
try:
|
||||
features = await asyncio.to_thread(
|
||||
nanobot_features_action,
|
||||
self._nanobot_features_action,
|
||||
"enable",
|
||||
feature_query,
|
||||
allow_install=self._allow_feature_package_install(connection, request),
|
||||
@@ -906,7 +970,7 @@ class WebUISettingsRouter:
|
||||
payload = await asyncio.to_thread(
|
||||
validate_channel_config,
|
||||
name,
|
||||
self._parse_channel_values_header(request),
|
||||
self._parse_channel_values(request),
|
||||
instance_id=instance_id,
|
||||
)
|
||||
except WebUISettingsError as e:
|
||||
@@ -916,19 +980,14 @@ class WebUISettingsRouter:
|
||||
return self._error_response(500, "failed to validate channel settings")
|
||||
return self._json_response(payload)
|
||||
|
||||
def _parse_channel_values_header(self, request: WsRequest) -> dict[str, Any]:
|
||||
raw = request.headers.get(_CHANNEL_VALUES_HEADER)
|
||||
if not raw:
|
||||
def _parse_channel_values(self, request: WsRequest) -> dict[str, Any]:
|
||||
payload = _mutation_payload(request)
|
||||
if payload is None or "values" not in payload:
|
||||
return {}
|
||||
if len(raw.encode("utf-8")) > _CHANNEL_VALUES_HEADER_MAX_BYTES:
|
||||
raise WebUISettingsError("channel settings payload is too large")
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise WebUISettingsError("invalid channel settings payload") from exc
|
||||
if not isinstance(payload, dict):
|
||||
values = payload.get("values")
|
||||
if not isinstance(values, dict):
|
||||
raise WebUISettingsError("channel settings payload must be a JSON object")
|
||||
return cast(dict[str, Any], payload)
|
||||
return cast(dict[str, Any], values)
|
||||
|
||||
def _save_channel_config_values(
|
||||
self,
|
||||
@@ -949,7 +1008,7 @@ class WebUISettingsRouter:
|
||||
if not raw_values:
|
||||
return []
|
||||
|
||||
config = load_config()
|
||||
def update(config: Config) -> list[str]:
|
||||
section = getattr(config.channels, name, None)
|
||||
channel_config = channel_instance_config(
|
||||
plugin,
|
||||
@@ -961,7 +1020,9 @@ class WebUISettingsRouter:
|
||||
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")
|
||||
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:
|
||||
@@ -985,9 +1046,10 @@ class WebUISettingsRouter:
|
||||
status=400,
|
||||
) from exc
|
||||
setattr(config.channels, name, updated_section)
|
||||
save_config(config)
|
||||
return saved
|
||||
|
||||
return self.settings.config.update(update)
|
||||
|
||||
@staticmethod
|
||||
def _coerce_channel_value(
|
||||
raw_key: str,
|
||||
@@ -1109,14 +1171,14 @@ class WebUISettingsRouter:
|
||||
target["instance_id"] = [str(payload["instance_id"])]
|
||||
try:
|
||||
features = await asyncio.to_thread(
|
||||
nanobot_features_action,
|
||||
self._nanobot_features_action,
|
||||
"enable",
|
||||
target,
|
||||
allow_install=self._allow_feature_package_install(connection, request),
|
||||
)
|
||||
except OptionalFeatureError as exc:
|
||||
features = self._feature_runtime_fallback(
|
||||
nanobot_features_payload(),
|
||||
self._nanobot_features_payload(),
|
||||
message=(
|
||||
f"{channel_name} connected, but enabling channel support failed: "
|
||||
f"{exc.message}"
|
||||
@@ -1137,7 +1199,9 @@ class WebUISettingsRouter:
|
||||
if _is_local_browser_request(connection, request.headers):
|
||||
return True
|
||||
try:
|
||||
return bool(load_config().tools.webui_allow_remote_package_install)
|
||||
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
|
||||
@@ -1154,6 +1218,7 @@ class WebUISettingsRouter:
|
||||
action,
|
||||
self._parse_mcp_settings_query(request),
|
||||
reload_mcp=lambda: request_mcp_reload(self.bus),
|
||||
config=self.settings.config,
|
||||
)
|
||||
except Exception as e:
|
||||
status = getattr(e, "status", 500)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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={},
|
||||
)
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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"]
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
@@ -36,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:
|
||||
@@ -1490,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"
|
||||
@@ -1522,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,
|
||||
@@ -1548,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 == {
|
||||
@@ -1574,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)
|
||||
@@ -1599,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
|
||||
@@ -1611,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__
|
||||
|
||||
@@ -1622,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. "
|
||||
@@ -1632,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__
|
||||
|
||||
@@ -1643,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. "
|
||||
@@ -1654,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"
|
||||
@@ -1675,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
|
||||
@@ -1699,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 == {
|
||||
@@ -1722,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)
|
||||
@@ -1734,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) == (
|
||||
@@ -1746,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)
|
||||
@@ -1759,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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -138,3 +149,79 @@ async def test_model_preset_mutation_routes(
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.body)["routed"] == function_name
|
||||
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(
|
||||
("update_info", "expected"),
|
||||
[
|
||||
(None, {"updateAvailable": None}),
|
||||
(
|
||||
{
|
||||
"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_version_check_route_returns_stable_payload(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
update_info: dict[str, str] | None,
|
||||
expected: dict[str, object],
|
||||
) -> None:
|
||||
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, request.path)
|
||||
|
||||
assert response is not None
|
||||
assert response.status_code == 200
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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=="],
|
||||
|
||||
Generated
+532
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+57
-17
@@ -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>
|
||||
{failed && (
|
||||
<p className="text-center text-sm text-destructive">
|
||||
{t("app.auth.invalid")}
|
||||
</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
|
||||
type="password"
|
||||
placeholder={t("app.auth.placeholder")}
|
||||
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)}
|
||||
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>
|
||||
<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) => {
|
||||
|
||||
@@ -724,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);
|
||||
@@ -872,7 +872,7 @@ export function SettingsView({
|
||||
const poll = async () => {
|
||||
try {
|
||||
const payload = await completeProviderOAuth(
|
||||
getToken(),
|
||||
client,
|
||||
providerOAuthFlow.provider,
|
||||
providerOAuthFlow.flow_id,
|
||||
);
|
||||
@@ -902,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;
|
||||
@@ -1301,7 +1301,7 @@ export function SettingsView({
|
||||
}
|
||||
setModelConfigurationSaving(true);
|
||||
try {
|
||||
const payload = await createModelConfiguration(token, {
|
||||
const payload = await createModelConfiguration(client, {
|
||||
label,
|
||||
provider,
|
||||
model,
|
||||
@@ -1319,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;
|
||||
}
|
||||
@@ -1345,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
|
||||
@@ -1431,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);
|
||||
@@ -1447,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);
|
||||
@@ -1469,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);
|
||||
@@ -1484,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 }));
|
||||
@@ -1502,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 }));
|
||||
@@ -1520,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 }));
|
||||
@@ -1544,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 }));
|
||||
}
|
||||
@@ -1568,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);
|
||||
@@ -1622,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 }));
|
||||
@@ -1656,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(),
|
||||
@@ -1698,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;
|
||||
@@ -1739,7 +1738,7 @@ export function SettingsView({
|
||||
setProviderOAuthDialogError(null);
|
||||
try {
|
||||
const payload = await completeProviderOAuth(
|
||||
token,
|
||||
client,
|
||||
flow.provider,
|
||||
flow.flow_id,
|
||||
authorizationResponse,
|
||||
@@ -1798,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 }));
|
||||
@@ -1903,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);
|
||||
@@ -1934,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 }));
|
||||
@@ -1955,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") {
|
||||
@@ -1977,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) {
|
||||
@@ -1997,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") {
|
||||
@@ -2024,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,
|
||||
@@ -2054,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);
|
||||
@@ -2075,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);
|
||||
@@ -2328,6 +2327,7 @@ export function SettingsView({
|
||||
onAction={handleAutomationAction}
|
||||
onRequestEdit={setAutomationPendingEdit}
|
||||
onRequestDelete={setAutomationPendingDelete}
|
||||
onBackToChat={onBackToChat}
|
||||
/>
|
||||
);
|
||||
case "skills":
|
||||
@@ -2448,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",
|
||||
@@ -2512,7 +2512,7 @@ export function SettingsView({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2578,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
|
||||
@@ -3042,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 }))}
|
||||
@@ -5488,6 +5494,7 @@ function AutomationsSettings({
|
||||
onAction,
|
||||
onRequestEdit,
|
||||
onRequestDelete,
|
||||
onBackToChat,
|
||||
}: {
|
||||
payload: AutomationsPayload | null;
|
||||
loading: boolean;
|
||||
@@ -5502,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>) =>
|
||||
@@ -5549,6 +5557,7 @@ function AutomationsSettings({
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{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">
|
||||
@@ -5617,6 +5626,7 @@ function AutomationsSettings({
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
@@ -5674,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.",
|
||||
"Create automations in a chat so they keep the right context.",
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<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>
|
||||
@@ -7290,10 +7322,6 @@ function ChannelsSettings({
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className={cn("shrink-0 pt-2", showingCompactDetail && "hidden")}>
|
||||
<ThirdPartyBrandNotice />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7396,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 ||
|
||||
@@ -7484,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>
|
||||
@@ -7500,8 +7573,6 @@ function AppsCatalogSettings({
|
||||
onImportConfig={onImportMcpConfig}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<ThirdPartyBrandNotice />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9326,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),
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
setMessages((prev) => closeReasoningStream(
|
||||
attachReasoningChunk(
|
||||
prev,
|
||||
line,
|
||||
{ ensure: ensureActivitySegmentId },
|
||||
turnFieldsFromEvent(ev, "reasoning"),
|
||||
)));
|
||||
),
|
||||
Date.now(),
|
||||
));
|
||||
return;
|
||||
}
|
||||
// Intermediate agent breadcrumbs (tool-call hints, raw progress).
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
@@ -595,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": {
|
||||
@@ -698,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",
|
||||
@@ -1147,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.",
|
||||
@@ -1380,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",
|
||||
|
||||
@@ -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",
|
||||
@@ -582,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": {
|
||||
@@ -685,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",
|
||||
@@ -1134,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.",
|
||||
@@ -1367,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ó",
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
"gatewayHint": "Assurez-vous que la gateway est en cours d’exé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",
|
||||
@@ -581,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 n’est 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": {
|
||||
@@ -684,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",
|
||||
@@ -1133,6 +1143,7 @@
|
||||
"recordingStatus": "Enregistrement {{time}}"
|
||||
},
|
||||
"voiceErrors": {
|
||||
"insecureContext": "Pour des raisons de sécurité, Chrome et d’autres navigateurs bloquent l’accè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.",
|
||||
@@ -1366,7 +1377,7 @@
|
||||
},
|
||||
"workspaceScopeRejected": {
|
||||
"title": "L’espace de travail n’a pas changé",
|
||||
"body": "La passerelle a refusé le projet ou le mode d’accès demandé ; Nanobot a conservé l’espace de travail précédent."
|
||||
"body": "La passerelle a refusé ce projet ou ce mode d’accès. Choisissez un projet existant ou un autre mode d’accès, puis réessayez."
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "Le message n’a pas été envoyé",
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
"gatewayHint": "Pastikan gateway sedang berjalan (`nanobot gateway`) dan halaman ini dibuka pada mesin yang sama."
|
||||
},
|
||||
"auth": {
|
||||
"title": "Autentikasi diperlukan",
|
||||
"hint": "Masukkan secret yang dikonfigurasi sebagai tokenIssueSecret di konfigurasi gateway.",
|
||||
"placeholder": "Kata sandi",
|
||||
"label": "Kata sandi",
|
||||
"showPassword": "Tampilkan kata sandi",
|
||||
"hidePassword": "Sembunyikan kata sandi",
|
||||
"submit": "Hubungkan",
|
||||
"invalid": "Kata sandi tidak valid. Coba lagi."
|
||||
"required": "Masukkan kata sandi.",
|
||||
"invalid": "Kata sandi salah. Coba lagi."
|
||||
},
|
||||
"account": {
|
||||
"section": "Akun",
|
||||
@@ -581,7 +582,14 @@
|
||||
"searchPlaceholder": "Cari aplikasi",
|
||||
"featured": "Alat",
|
||||
"loading": "Memuat aplikasi...",
|
||||
"empty": "Tidak ada alat yang cocok dengan tampilan ini.",
|
||||
"empty": "Tidak ada alat yang cocok dengan pencarian Anda.",
|
||||
"emptyApps": "Tidak ada aplikasi yang tersedia.",
|
||||
"emptyIntegrations": "Tidak ada integrasi yang tersedia.",
|
||||
"emptyReady": "Belum ada alat yang siap.",
|
||||
"clearSearch": "Hapus pencarian",
|
||||
"browseApps": "Jelajahi aplikasi",
|
||||
"browseIntegrations": "Jelajahi integrasi",
|
||||
"emptyIntegrationsHint": "Tambahkan integrasi khusus di bawah.",
|
||||
"restartRequired": "Mulai ulang nanobot untuk menerapkan aplikasi dan fitur yang diperbarui."
|
||||
},
|
||||
"channels": {
|
||||
@@ -684,7 +692,9 @@
|
||||
"loading": "Memuat otomasi...",
|
||||
"noMatches": "Tidak ada otomasi yang cocok dengan tampilan ini.",
|
||||
"empty": "Belum ada otomasi.",
|
||||
"emptyHint": "Buat dari tempat tugas ini berjalan agar nanobot menyimpan konteks yang tepat.",
|
||||
"emptyHint": "Buat otomatisasi di chat agar konteks yang tepat tetap tersimpan.",
|
||||
"emptyAction": "Buka chat",
|
||||
"clearFilters": "Hapus filter",
|
||||
"oneShot": "Satu kali",
|
||||
"systemTask": "Automasi yang dikelola sistem",
|
||||
"localTrigger": "Pemicu lokal",
|
||||
@@ -1133,6 +1143,7 @@
|
||||
"recordingStatus": "Merekam {{time}}"
|
||||
},
|
||||
"voiceErrors": {
|
||||
"insecureContext": "Demi keamanan, Chrome dan browser lain memblokir akses mikrofon pada halaman HTTP jarak jauh. Buka WebUI ini melalui HTTPS untuk menggunakan input suara.",
|
||||
"unsupported": "Input suara tidak didukung di browser ini.",
|
||||
"permission": "Izinkan mikrofon di bilah alamat, lalu coba lagi.",
|
||||
"notConfigured": "Konfigurasikan penyedia transkripsi terlebih dahulu.",
|
||||
@@ -1366,7 +1377,7 @@
|
||||
},
|
||||
"workspaceScopeRejected": {
|
||||
"title": "Ruang kerja tidak berubah",
|
||||
"body": "Gateway menolak proyek atau mode akses yang diminta, jadi Nanobot tetap memakai ruang kerja sebelumnya."
|
||||
"body": "Gateway menolak proyek atau mode akses ini. Pilih proyek yang sudah ada atau mode akses lain, lalu coba lagi."
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "Pesan tidak terkirim",
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
"gatewayHint": "gateway(`nanobot gateway`)が起動しており、このページが同じマシン上で開かれていることを確認してください。"
|
||||
},
|
||||
"auth": {
|
||||
"title": "認証が必要です",
|
||||
"hint": "gateway 設定の tokenIssueSecret に指定されたシークレットを入力してください。",
|
||||
"placeholder": "パスワード",
|
||||
"label": "パスワード",
|
||||
"showPassword": "パスワードを表示",
|
||||
"hidePassword": "パスワードを隠す",
|
||||
"submit": "接続",
|
||||
"invalid": "パスワードが無効です。もう一度お試しください。"
|
||||
"required": "パスワードを入力してください。",
|
||||
"invalid": "パスワードが正しくありません。もう一度お試しください。"
|
||||
},
|
||||
"account": {
|
||||
"section": "アカウント",
|
||||
@@ -581,7 +582,14 @@
|
||||
"searchPlaceholder": "アプリを検索",
|
||||
"featured": "ツール",
|
||||
"loading": "アプリを読み込み中...",
|
||||
"empty": "この表示に一致するツールはありません。",
|
||||
"empty": "検索条件に一致するツールはありません。",
|
||||
"emptyApps": "利用できるアプリはありません。",
|
||||
"emptyIntegrations": "利用できる連携はありません。",
|
||||
"emptyReady": "使用可能なツールはまだありません。",
|
||||
"clearSearch": "検索をクリア",
|
||||
"browseApps": "アプリを見る",
|
||||
"browseIntegrations": "連携を見る",
|
||||
"emptyIntegrationsHint": "下からカスタム連携を追加できます。",
|
||||
"restartRequired": "更新したアプリと機能を反映するには nanobot を再起動してください。"
|
||||
},
|
||||
"channels": {
|
||||
@@ -684,7 +692,9 @@
|
||||
"loading": "自動タスクを読み込み中...",
|
||||
"noMatches": "この表示に一致する自動タスクはありません。",
|
||||
"empty": "自動タスクはまだありません。",
|
||||
"emptyHint": "実行元から作成すると、nanobot が正しいコンテキストを保持できます。",
|
||||
"emptyHint": "正しいコンテキストを保持するには、チャットで自動化を作成してください。",
|
||||
"emptyAction": "チャットを開く",
|
||||
"clearFilters": "フィルターをクリア",
|
||||
"oneShot": "一回限り",
|
||||
"systemTask": "システム管理の自動タスク",
|
||||
"localTrigger": "ローカルトリガー",
|
||||
@@ -1133,6 +1143,7 @@
|
||||
"recordingStatus": "録音中 {{time}}"
|
||||
},
|
||||
"voiceErrors": {
|
||||
"insecureContext": "セキュリティ上、Chrome などのブラウザーはリモートの HTTP ページからのマイクアクセスをブロックします。音声入力を使用するには、この WebUI を HTTPS で開いてください。",
|
||||
"unsupported": "このブラウザーは音声入力に対応していません。",
|
||||
"permission": "アドレスバーでマイクを許可してから再試行してください。",
|
||||
"notConfigured": "先に文字起こしプロバイダーを設定してください。",
|
||||
@@ -1366,7 +1377,7 @@
|
||||
},
|
||||
"workspaceScopeRejected": {
|
||||
"title": "ワークスペースは変更されませんでした",
|
||||
"body": "要求されたプロジェクトまたはアクセスモードがゲートウェイで拒否されたため、Nanobot は以前のワークスペースをそのまま使用しています。"
|
||||
"body": "このプロジェクトまたはアクセスモードはゲートウェイに拒否されました。既存のプロジェクトまたは別のアクセスモードを選択して、もう一度お試しください。"
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "メッセージは送信されませんでした",
|
||||
|
||||
@@ -10,10 +10,11 @@
|
||||
"gatewayHint": "gateway(`nanobot gateway`)가 실행 중인지, 그리고 이 페이지가 같은 머신에서 열려 있는지 확인하세요."
|
||||
},
|
||||
"auth": {
|
||||
"title": "인증이 필요합니다",
|
||||
"hint": "gateway 설정의 tokenIssueSecret에 구성된 비밀 값을 입력하세요.",
|
||||
"placeholder": "비밀번호",
|
||||
"label": "비밀번호",
|
||||
"showPassword": "비밀번호 표시",
|
||||
"hidePassword": "비밀번호 숨기기",
|
||||
"submit": "연결",
|
||||
"required": "비밀번호를 입력하세요.",
|
||||
"invalid": "비밀번호가 올바르지 않습니다. 다시 시도하세요."
|
||||
},
|
||||
"account": {
|
||||
@@ -581,7 +582,14 @@
|
||||
"searchPlaceholder": "앱 검색",
|
||||
"featured": "도구",
|
||||
"loading": "앱을 불러오는 중...",
|
||||
"empty": "이 보기에 일치하는 도구가 없습니다.",
|
||||
"empty": "검색과 일치하는 도구가 없습니다.",
|
||||
"emptyApps": "사용 가능한 앱이 없습니다.",
|
||||
"emptyIntegrations": "사용 가능한 연동이 없습니다.",
|
||||
"emptyReady": "아직 준비된 도구가 없습니다.",
|
||||
"clearSearch": "검색 지우기",
|
||||
"browseApps": "앱 둘러보기",
|
||||
"browseIntegrations": "연동 둘러보기",
|
||||
"emptyIntegrationsHint": "아래에서 사용자 지정 연동을 추가하세요.",
|
||||
"restartRequired": "업데이트된 앱과 기능을 적용하려면 nanobot을 다시 시작하세요."
|
||||
},
|
||||
"channels": {
|
||||
@@ -684,7 +692,9 @@
|
||||
"loading": "자동화를 불러오는 중...",
|
||||
"noMatches": "이 보기와 일치하는 자동화가 없습니다.",
|
||||
"empty": "아직 자동화가 없습니다.",
|
||||
"emptyHint": "실행될 위치에서 만들면 nanobot이 올바른 컨텍스트를 유지합니다.",
|
||||
"emptyHint": "올바른 컨텍스트를 유지하려면 채팅에서 자동화를 만드세요.",
|
||||
"emptyAction": "채팅 열기",
|
||||
"clearFilters": "필터 지우기",
|
||||
"oneShot": "일회성",
|
||||
"systemTask": "시스템 관리 자동화",
|
||||
"localTrigger": "로컬 트리거",
|
||||
@@ -1133,6 +1143,7 @@
|
||||
"recordingStatus": "녹음 중 {{time}}"
|
||||
},
|
||||
"voiceErrors": {
|
||||
"insecureContext": "보안을 위해 Chrome 및 기타 브라우저는 원격 HTTP 페이지의 마이크 접근을 차단합니다. 음성 입력을 사용하려면 HTTPS로 이 WebUI를 여세요.",
|
||||
"unsupported": "이 브라우저는 음성 입력을 지원하지 않습니다.",
|
||||
"permission": "주소 표시줄에서 마이크를 허용한 후 다시 시도하세요.",
|
||||
"notConfigured": "먼저 음성 변환 제공업체를 설정하세요.",
|
||||
@@ -1366,7 +1377,7 @@
|
||||
},
|
||||
"workspaceScopeRejected": {
|
||||
"title": "작업공간이 변경되지 않았습니다",
|
||||
"body": "요청한 프로젝트 또는 접근 모드가 게이트웨이에서 거부되어 Nanobot이 이전 작업공간을 계속 사용합니다."
|
||||
"body": "게이트웨이가 이 프로젝트 또는 접근 모드를 거부했습니다. 기존 프로젝트나 다른 접근 모드를 선택한 후 다시 시도하세요."
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "메시지가 전송되지 않았습니다",
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
"gatewayHint": "Verifique se o gateway está em execução (`nanobot gateway`) e se esta página está aberta na mesma máquina."
|
||||
},
|
||||
"auth": {
|
||||
"title": "Autenticação necessária",
|
||||
"hint": "Informe o segredo configurado como tokenIssueSecret na configuração do gateway.",
|
||||
"placeholder": "Senha",
|
||||
"label": "Senha",
|
||||
"showPassword": "Mostrar senha",
|
||||
"hidePassword": "Ocultar senha",
|
||||
"submit": "Conectar",
|
||||
"invalid": "Senha inválida. Tente novamente."
|
||||
"required": "Digite a senha.",
|
||||
"invalid": "Senha incorreta. Tente novamente."
|
||||
},
|
||||
"account": {
|
||||
"section": "Conta",
|
||||
@@ -595,7 +596,14 @@
|
||||
"searchPlaceholder": "Buscar ferramentas",
|
||||
"featured": "Ferramentas",
|
||||
"loading": "Carregando aplicativos...",
|
||||
"empty": "Nenhuma ferramenta corresponde a esta visualização.",
|
||||
"empty": "Nenhuma ferramenta corresponde à sua busca.",
|
||||
"emptyApps": "Nenhum aplicativo disponível.",
|
||||
"emptyIntegrations": "Nenhuma integração disponível.",
|
||||
"emptyReady": "Ainda não há ferramentas prontas.",
|
||||
"clearSearch": "Limpar busca",
|
||||
"browseApps": "Explorar aplicativos",
|
||||
"browseIntegrations": "Explorar integrações",
|
||||
"emptyIntegrationsHint": "Adicione uma integração personalizada abaixo.",
|
||||
"restartRequired": "Reinicie o nanobot para aplicar os aplicativos e integrações atualizados."
|
||||
},
|
||||
"channels": {
|
||||
@@ -698,7 +706,9 @@
|
||||
"loading": "Carregando automações...",
|
||||
"noMatches": "Nenhuma automação corresponde a esta visualização.",
|
||||
"empty": "Nenhuma automação ainda.",
|
||||
"emptyHint": "Crie uma de onde ela deve rodar para que o nanobot mantenha o contexto correto.",
|
||||
"emptyHint": "Crie automações em uma conversa para que mantenham o contexto correto.",
|
||||
"emptyAction": "Abrir uma conversa",
|
||||
"clearFilters": "Limpar filtros",
|
||||
"oneShot": "Uma vez",
|
||||
"systemTask": "Automação gerenciada pelo sistema",
|
||||
"localTrigger": "Gatilho local",
|
||||
@@ -1147,6 +1157,7 @@
|
||||
"recordingStatus": "Gravando {{time}}"
|
||||
},
|
||||
"voiceErrors": {
|
||||
"insecureContext": "Por segurança, o Chrome e outros navegadores bloqueiam o acesso ao microfone em páginas HTTP remotas. Abra esta WebUI por HTTPS para usar a entrada de voz.",
|
||||
"unsupported": "A entrada de voz não é compatível com este navegador.",
|
||||
"permission": "Permita o microfone na barra de endereço e tente novamente.",
|
||||
"notConfigured": "Configure primeiro um provedor de transcrição.",
|
||||
@@ -1380,7 +1391,7 @@
|
||||
},
|
||||
"workspaceScopeRejected": {
|
||||
"title": "O espaço de trabalho não foi alterado",
|
||||
"body": "O nanobot manteve o espaço de trabalho anterior porque o projeto ou modo de acesso solicitado foi rejeitado pelo gateway."
|
||||
"body": "O gateway rejeitou este projeto ou modo de acesso. Escolha um projeto existente ou outro modo de acesso e tente novamente."
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "A mensagem não foi enviada",
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
"gatewayHint": "Hãy chắc chắn gateway đang chạy (`nanobot gateway`) và trang này được mở trên cùng máy."
|
||||
},
|
||||
"auth": {
|
||||
"title": "Cần xác thực",
|
||||
"hint": "Nhập secret được cấu hình là tokenIssueSecret trong cấu hình gateway.",
|
||||
"placeholder": "Mật khẩu",
|
||||
"label": "Mật khẩu",
|
||||
"showPassword": "Hiện mật khẩu",
|
||||
"hidePassword": "Ẩn mật khẩu",
|
||||
"submit": "Kết nối",
|
||||
"invalid": "Mật khẩu không hợp lệ. Hãy thử lại."
|
||||
"required": "Nhập mật khẩu.",
|
||||
"invalid": "Mật khẩu không đúng. Hãy thử lại."
|
||||
},
|
||||
"account": {
|
||||
"section": "Tài khoản",
|
||||
@@ -581,7 +582,14 @@
|
||||
"searchPlaceholder": "Tìm ứng dụng",
|
||||
"featured": "Công cụ",
|
||||
"loading": "Đang tải ứng dụng...",
|
||||
"empty": "Không có công cụ phù hợp với chế độ xem này.",
|
||||
"empty": "Không có công cụ phù hợp với tìm kiếm của bạn.",
|
||||
"emptyApps": "Không có ứng dụng nào.",
|
||||
"emptyIntegrations": "Không có tích hợp nào.",
|
||||
"emptyReady": "Chưa có công cụ nào sẵn sàng.",
|
||||
"clearSearch": "Xóa tìm kiếm",
|
||||
"browseApps": "Xem ứng dụng",
|
||||
"browseIntegrations": "Xem tích hợp",
|
||||
"emptyIntegrationsHint": "Thêm tích hợp tùy chỉnh ở bên dưới.",
|
||||
"restartRequired": "Khởi động lại nanobot để áp dụng ứng dụng và tính năng đã cập nhật."
|
||||
},
|
||||
"channels": {
|
||||
@@ -684,7 +692,9 @@
|
||||
"loading": "Đang tải tự động hóa...",
|
||||
"noMatches": "Không có tự động hóa phù hợp với chế độ xem này.",
|
||||
"empty": "Chưa có tự động hóa.",
|
||||
"emptyHint": "Tạo từ nơi tác vụ sẽ chạy để nanobot giữ đúng ngữ cảnh.",
|
||||
"emptyHint": "Tạo tác vụ tự động trong cuộc trò chuyện để giữ đúng ngữ cảnh.",
|
||||
"emptyAction": "Mở cuộc trò chuyện",
|
||||
"clearFilters": "Xóa bộ lọc",
|
||||
"oneShot": "Một lần",
|
||||
"systemTask": "Tự động hóa do hệ thống quản lý",
|
||||
"localTrigger": "Trình kích hoạt cục bộ",
|
||||
@@ -1133,6 +1143,7 @@
|
||||
"recordingStatus": "Đang ghi {{time}}"
|
||||
},
|
||||
"voiceErrors": {
|
||||
"insecureContext": "Vì lý do bảo mật, Chrome và các trình duyệt khác chặn quyền truy cập micrô trên các trang HTTP từ xa. Mở WebUI này qua HTTPS để dùng tính năng nhập bằng giọng nói.",
|
||||
"unsupported": "Trình duyệt này không hỗ trợ nhập bằng giọng nói.",
|
||||
"permission": "Cho phép micrô trong thanh địa chỉ rồi thử lại.",
|
||||
"notConfigured": "Hãy cấu hình nhà cung cấp chép lời trước.",
|
||||
@@ -1366,7 +1377,7 @@
|
||||
},
|
||||
"workspaceScopeRejected": {
|
||||
"title": "Không gian làm việc không thay đổi",
|
||||
"body": "Gateway đã từ chối dự án hoặc chế độ truy cập được yêu cầu, nên Nanobot giữ không gian làm việc trước đó."
|
||||
"body": "Gateway đã từ chối dự án hoặc chế độ truy cập này. Chọn dự án hiện có hoặc chế độ truy cập khác rồi thử lại."
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "Tin nhắn chưa được gửi",
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
"gatewayHint": "请确认网关已启动(`nanobot gateway`),并且当前页面与网关运行在同一台机器上。"
|
||||
},
|
||||
"auth": {
|
||||
"title": "需要验证",
|
||||
"hint": "请输入网关配置中的 tokenIssueSecret。",
|
||||
"placeholder": "密码",
|
||||
"label": "密码",
|
||||
"showPassword": "显示密码",
|
||||
"hidePassword": "隐藏密码",
|
||||
"submit": "连接",
|
||||
"invalid": "密码无效,请重试。"
|
||||
"required": "请输入密码。",
|
||||
"invalid": "密码错误,请重试。"
|
||||
},
|
||||
"account": {
|
||||
"section": "账户",
|
||||
@@ -595,7 +596,14 @@
|
||||
"searchPlaceholder": "搜索工具",
|
||||
"featured": "工具",
|
||||
"loading": "正在加载应用...",
|
||||
"empty": "当前视图没有匹配的工具。",
|
||||
"empty": "没有与搜索条件匹配的工具。",
|
||||
"emptyApps": "暂无可用应用。",
|
||||
"emptyIntegrations": "暂无可用集成。",
|
||||
"emptyReady": "还没有就绪的工具。",
|
||||
"clearSearch": "清除搜索",
|
||||
"browseApps": "浏览应用",
|
||||
"browseIntegrations": "浏览集成",
|
||||
"emptyIntegrationsHint": "可在下方添加自定义集成。",
|
||||
"restartRequired": "重启 nanobot 以应用更新后的应用和集成。"
|
||||
},
|
||||
"channels": {
|
||||
@@ -698,7 +706,9 @@
|
||||
"loading": "正在加载自动任务...",
|
||||
"noMatches": "当前视图没有匹配的自动任务。",
|
||||
"empty": "暂无自动任务。",
|
||||
"emptyHint": "请从它应该运行的来源处创建,这样 nanobot 才能保留正确上下文。",
|
||||
"emptyHint": "请在对话中创建自动任务,以便保留正确的上下文。",
|
||||
"emptyAction": "打开对话",
|
||||
"clearFilters": "清除筛选",
|
||||
"oneShot": "一次性",
|
||||
"systemTask": "系统管理的自动任务",
|
||||
"localTrigger": "本地触发器",
|
||||
@@ -1146,6 +1156,7 @@
|
||||
"recordingStatus": "正在录音 {{time}}"
|
||||
},
|
||||
"voiceErrors": {
|
||||
"insecureContext": "出于安全考虑,Chrome 等浏览器会阻止远程 HTTP 页面访问麦克风。请通过 HTTPS 打开此 WebUI 以使用语音输入。",
|
||||
"unsupported": "当前浏览器不支持语音输入。",
|
||||
"permission": "请在地址栏允许麦克风后重试。",
|
||||
"notConfigured": "请先配置转写提供商。",
|
||||
@@ -1380,7 +1391,7 @@
|
||||
},
|
||||
"workspaceScopeRejected": {
|
||||
"title": "工作区未更改",
|
||||
"body": "网关拒绝了请求的项目或访问权限,Nanobot 已继续使用之前的工作区。"
|
||||
"body": "网关拒绝了此项目或访问权限。请选择已存在的项目或其他访问权限,然后重试。"
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "消息未发送",
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
"gatewayHint": "請確認閘道已啟動(`nanobot gateway`),並且目前頁面與閘道在同一台機器上開啟。"
|
||||
},
|
||||
"auth": {
|
||||
"title": "需要驗證",
|
||||
"hint": "請輸入閘道設定中 tokenIssueSecret 所設定的金鑰。",
|
||||
"placeholder": "密碼",
|
||||
"label": "密碼",
|
||||
"showPassword": "顯示密碼",
|
||||
"hidePassword": "隱藏密碼",
|
||||
"submit": "連線",
|
||||
"invalid": "密碼無效,請再試一次。"
|
||||
"required": "請輸入密碼。",
|
||||
"invalid": "密碼錯誤,請再試一次。"
|
||||
},
|
||||
"account": {
|
||||
"section": "帳戶",
|
||||
@@ -581,7 +582,14 @@
|
||||
"searchPlaceholder": "搜尋工具",
|
||||
"featured": "工具",
|
||||
"loading": "正在載入應用程式…",
|
||||
"empty": "沒有符合條件的工具。",
|
||||
"empty": "沒有符合搜尋條件的工具。",
|
||||
"emptyApps": "沒有可用的應用程式。",
|
||||
"emptyIntegrations": "沒有可用的整合服務。",
|
||||
"emptyReady": "尚無就緒的工具。",
|
||||
"clearSearch": "清除搜尋",
|
||||
"browseApps": "瀏覽應用程式",
|
||||
"browseIntegrations": "瀏覽整合服務",
|
||||
"emptyIntegrationsHint": "可在下方新增自訂整合服務。",
|
||||
"restartRequired": "重新啟動 nanobot 以套用更新後的應用程式與整合服務。"
|
||||
},
|
||||
"channels": {
|
||||
@@ -684,7 +692,9 @@
|
||||
"loading": "正在載入自動任務…",
|
||||
"noMatches": "目前沒有符合條件的自動任務。",
|
||||
"empty": "尚無自動任務。",
|
||||
"emptyHint": "請從自動任務預定執行的對話中建立,讓 nanobot 保留正確的對話脈絡。",
|
||||
"emptyHint": "請在聊天中建立自動任務,以保留正確的對話脈絡。",
|
||||
"emptyAction": "開啟聊天",
|
||||
"clearFilters": "清除篩選",
|
||||
"oneShot": "單次",
|
||||
"systemTask": "系統管理的自動任務",
|
||||
"localTrigger": "本機觸發器",
|
||||
@@ -1133,6 +1143,7 @@
|
||||
"recordingStatus": "正在錄音 {{time}}"
|
||||
},
|
||||
"voiceErrors": {
|
||||
"insecureContext": "基於安全考量,Chrome 等瀏覽器會阻止遠端 HTTP 頁面存取麥克風。請透過 HTTPS 開啟此 WebUI 以使用語音輸入。",
|
||||
"unsupported": "目前瀏覽器不支援語音輸入。",
|
||||
"permission": "請在網址列允許麥克風後重試。",
|
||||
"notConfigured": "請先設定轉寫供應商。",
|
||||
@@ -1366,7 +1377,7 @@
|
||||
},
|
||||
"workspaceScopeRejected": {
|
||||
"title": "工作區未變更",
|
||||
"body": "閘道拒絕要求的專案或存取模式,因此 Nanobot 繼續使用先前的工作區。"
|
||||
"body": "閘道拒絕了此專案或存取模式。請選擇現有專案或其他存取模式,然後再試一次。"
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "訊息未傳送",
|
||||
|
||||
+263
-359
@@ -44,6 +44,8 @@ import type {
|
||||
import { fetchWithTimeout } from "./http";
|
||||
|
||||
const API_READ_TIMEOUT_MS = 20_000;
|
||||
const API_MUTATION_TIMEOUT_MS = 20_000;
|
||||
const PACKAGE_MUTATION_TIMEOUT_MS = 150_000;
|
||||
const SLASH_COMMAND_LIFECYCLES = new Set<SlashCommandLifecycle>([
|
||||
"side_channel",
|
||||
"finalize_active_turn",
|
||||
@@ -58,12 +60,6 @@ function isSlashCommandLifecycle(value: unknown): value is SlashCommandLifecycle
|
||||
&& SLASH_COMMAND_LIFECYCLES.has(value as SlashCommandLifecycle)
|
||||
);
|
||||
}
|
||||
const CHANNEL_VALUES_HEADER = "X-Nanobot-Channel-Values";
|
||||
const API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values";
|
||||
const OAUTH_CODE_HEADER = "X-Nanobot-OAuth-Code";
|
||||
const OAUTH_CALLBACK_HEADER = "X-Nanobot-OAuth-Callback";
|
||||
const PROVIDER_VALUES_HEADER = "X-Nanobot-Provider-Values";
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
@@ -73,6 +69,14 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export interface WebUIMutationTransport {
|
||||
requestMutation<T>(
|
||||
action: string,
|
||||
payload?: Record<string, unknown>,
|
||||
timeoutMs?: number,
|
||||
): Promise<T>;
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
url: string,
|
||||
token: string,
|
||||
@@ -109,7 +113,27 @@ async function request<T>(
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
function mcpValuesHeader(values: Record<string, unknown>): HeadersInit | undefined {
|
||||
async function mutation<T>(
|
||||
transport: WebUIMutationTransport,
|
||||
action: string,
|
||||
payload: Record<string, unknown> = {},
|
||||
timeoutMs: number = API_MUTATION_TIMEOUT_MS,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await transport.requestMutation<T>(action, payload, timeoutMs);
|
||||
} catch (reason) {
|
||||
const status = (
|
||||
typeof reason === "object"
|
||||
&& reason !== null
|
||||
&& "status" in reason
|
||||
&& typeof reason.status === "number"
|
||||
) ? reason.status : 500;
|
||||
const message = reason instanceof Error ? reason.message : "WebUI mutation failed";
|
||||
throw new ApiError(status, message);
|
||||
}
|
||||
}
|
||||
|
||||
function compactMcpValues(values: Record<string, unknown>): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {};
|
||||
Object.entries(values).forEach(([key, value]) => {
|
||||
if (value === null || value === undefined) return;
|
||||
@@ -120,12 +144,7 @@ function mcpValuesHeader(values: Record<string, unknown>): HeadersInit | undefin
|
||||
}
|
||||
payload[key] = value;
|
||||
});
|
||||
if (!Object.keys(payload).length) return undefined;
|
||||
return { "X-Nanobot-MCP-Values": JSON.stringify(payload) };
|
||||
}
|
||||
|
||||
function automationValuesHeader(values: AutomationUpdatePayload): HeadersInit {
|
||||
return { "X-Nanobot-Automation-Values": encodeURIComponent(JSON.stringify(values)) };
|
||||
return payload;
|
||||
}
|
||||
|
||||
function splitKey(key: string): { channel: string; chatId: string } {
|
||||
@@ -261,37 +280,19 @@ export async function fetchAutomations(
|
||||
}
|
||||
|
||||
export async function runAutomationAction(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
action: "enable" | "disable" | "delete" | "run",
|
||||
id: string,
|
||||
base: string = "",
|
||||
): Promise<AutomationsPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("id", id);
|
||||
return request<AutomationsPayload>(
|
||||
`${base}/api/webui/automations/${action}?${query}`,
|
||||
token,
|
||||
undefined,
|
||||
API_READ_TIMEOUT_MS,
|
||||
);
|
||||
return mutation<AutomationsPayload>(transport, `automation.${action}`, { id });
|
||||
}
|
||||
|
||||
export async function updateAutomation(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
id: string,
|
||||
values: AutomationUpdatePayload,
|
||||
base: string = "",
|
||||
): Promise<AutomationsPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("id", id);
|
||||
return request<AutomationsPayload>(
|
||||
`${base}/api/webui/automations/update?${query}`,
|
||||
token,
|
||||
{
|
||||
headers: automationValuesHeader(values),
|
||||
},
|
||||
API_READ_TIMEOUT_MS,
|
||||
);
|
||||
return mutation<AutomationsPayload>(transport, "automation.update", { id, values });
|
||||
}
|
||||
|
||||
export async function fetchSkills(
|
||||
@@ -320,28 +321,18 @@ export async function fetchSkillDetail(
|
||||
}
|
||||
|
||||
export async function updateSkillEnabled(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
name: string,
|
||||
enabled: boolean,
|
||||
base: string = "",
|
||||
): Promise<SkillActionPayload> {
|
||||
const params = new URLSearchParams({ name, enabled: String(enabled) });
|
||||
return request<SkillActionPayload>(
|
||||
`${base}/api/webui/skills/update?${params}`,
|
||||
token,
|
||||
);
|
||||
return mutation<SkillActionPayload>(transport, "skill.update", { name, enabled });
|
||||
}
|
||||
|
||||
export async function deleteSkill(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
name: string,
|
||||
base: string = "",
|
||||
): Promise<SkillActionPayload> {
|
||||
const params = new URLSearchParams({ name });
|
||||
return request<SkillActionPayload>(
|
||||
`${base}/api/webui/skills/delete?${params}`,
|
||||
token,
|
||||
);
|
||||
return mutation<SkillActionPayload>(transport, "skill.delete", { name });
|
||||
}
|
||||
|
||||
export async function searchMarketplaceSkills(
|
||||
@@ -389,37 +380,33 @@ export async function fetchMarketplaceSkillTrends(
|
||||
}
|
||||
|
||||
export async function installMarketplaceSkill(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
provider: Exclude<MarketplaceProvider, "all">,
|
||||
source: string,
|
||||
skill: string,
|
||||
version: string = "",
|
||||
base: string = "",
|
||||
): Promise<SkillInstallPayload> {
|
||||
const params = new URLSearchParams({ provider, source, skill });
|
||||
if (version) params.set("version", version);
|
||||
return request<SkillInstallPayload>(
|
||||
`${base}/api/webui/skills/install?${params}`,
|
||||
token,
|
||||
undefined,
|
||||
150_000,
|
||||
return mutation<SkillInstallPayload>(
|
||||
transport,
|
||||
"skill.install",
|
||||
{ provider, source, skill, ...(version ? { version } : {}) },
|
||||
PACKAGE_MUTATION_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteSession(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
key: string,
|
||||
optionsOrBase?: { deleteAutomations?: boolean } | string,
|
||||
base: string = "",
|
||||
): Promise<SessionDeleteResult> {
|
||||
const options = typeof optionsOrBase === "string" ? undefined : optionsOrBase;
|
||||
const resolvedBase = typeof optionsOrBase === "string" ? optionsOrBase : base;
|
||||
const query = new URLSearchParams();
|
||||
if (options?.deleteAutomations) query.set("delete_automations", "true");
|
||||
const suffix = query.toString() ? `?${query}` : "";
|
||||
return request<SessionDeleteResult>(
|
||||
`${resolvedBase}/api/sessions/${encodeURIComponent(key)}/delete${suffix}`,
|
||||
token,
|
||||
return mutation<SessionDeleteResult>(
|
||||
transport,
|
||||
"session.delete",
|
||||
{
|
||||
key,
|
||||
...(options?.deleteAutomations ? { delete_automations: true } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -520,56 +507,50 @@ export async function fetchApiService(token: string, base: string = ""): Promise
|
||||
}
|
||||
|
||||
export async function startApiService(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
values: { host: string; port: number; timeout: number; apiKey?: string },
|
||||
base: string = "",
|
||||
): Promise<ApiServicePayload> {
|
||||
const query = new URLSearchParams({
|
||||
return mutation<ApiServicePayload>(
|
||||
transport,
|
||||
"settings.api_service.start",
|
||||
{
|
||||
host: values.host,
|
||||
port: String(values.port),
|
||||
timeout: String(values.timeout),
|
||||
});
|
||||
const headers = values.apiKey === undefined
|
||||
? undefined
|
||||
: { [API_SERVICE_VALUES_HEADER]: JSON.stringify({ api_key: values.apiKey }) };
|
||||
return request<ApiServicePayload>(
|
||||
`${base}/api/settings/api-service/start?${query}`,
|
||||
token,
|
||||
{ headers },
|
||||
port: values.port,
|
||||
timeout: values.timeout,
|
||||
...(values.apiKey !== undefined ? { api_key: values.apiKey } : {}),
|
||||
},
|
||||
PACKAGE_MUTATION_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function stopApiService(token: string, base: string = ""): Promise<ApiServicePayload> {
|
||||
return request<ApiServicePayload>(`${base}/api/settings/api-service/stop`, token);
|
||||
export async function stopApiService(
|
||||
transport: WebUIMutationTransport,
|
||||
): Promise<ApiServicePayload> {
|
||||
return mutation<ApiServicePayload>(transport, "settings.api_service.stop");
|
||||
}
|
||||
|
||||
export async function enableNanobotFeature(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
name: string,
|
||||
options: { instanceId?: string } = {},
|
||||
base: string = "",
|
||||
): Promise<NanobotFeaturesPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("name", name);
|
||||
if (options.instanceId) query.set("instance_id", options.instanceId);
|
||||
return request<NanobotFeaturesPayload>(
|
||||
`${base}/api/settings/nanobot-features/enable?${query}`,
|
||||
token,
|
||||
return mutation<NanobotFeaturesPayload>(
|
||||
transport,
|
||||
"settings.feature.enable",
|
||||
{ name, ...(options.instanceId ? { instance_id: options.instanceId } : {}) },
|
||||
PACKAGE_MUTATION_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function disableNanobotFeature(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
name: string,
|
||||
options: { instanceId?: string } = {},
|
||||
base: string = "",
|
||||
): Promise<NanobotFeaturesPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("name", name);
|
||||
if (options.instanceId) query.set("instance_id", options.instanceId);
|
||||
return request<NanobotFeaturesPayload>(
|
||||
`${base}/api/settings/nanobot-features/disable?${query}`,
|
||||
token,
|
||||
return mutation<NanobotFeaturesPayload>(
|
||||
transport,
|
||||
"settings.feature.disable",
|
||||
{ name, ...(options.instanceId ? { instance_id: options.instanceId } : {}) },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -586,21 +567,15 @@ export async function fetchPairingRequests(
|
||||
}
|
||||
|
||||
export async function runPairingAction(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
action: "approve" | "deny",
|
||||
code: string,
|
||||
base: string = "",
|
||||
): Promise<PairingPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("code", code);
|
||||
return request<PairingPayload>(
|
||||
`${base}/api/settings/pairing/${action}?${query}`,
|
||||
token,
|
||||
);
|
||||
return mutation<PairingPayload>(transport, `settings.pairing.${action}`, { code });
|
||||
}
|
||||
|
||||
export async function startChannelConnect(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
channel: string,
|
||||
options: {
|
||||
domain?: string;
|
||||
@@ -608,104 +583,95 @@ export async function startChannelConnect(
|
||||
mode?: "replace" | "create";
|
||||
force?: boolean;
|
||||
} = {},
|
||||
base: string = "",
|
||||
): Promise<ChannelConnectPayload> {
|
||||
const query = new URLSearchParams();
|
||||
if (options.domain) query.set("domain", options.domain);
|
||||
if (options.instanceId) query.set("instance_id", options.instanceId);
|
||||
if (options.mode) query.set("mode", options.mode);
|
||||
if (options.force) query.set("force", "true");
|
||||
const suffix = query.toString();
|
||||
return request<ChannelConnectPayload>(
|
||||
`${base}/api/settings/channels/${channel}/connect/start${suffix ? `?${suffix}` : ""}`,
|
||||
token,
|
||||
return mutation<ChannelConnectPayload>(
|
||||
transport,
|
||||
"settings.channel.connect.start",
|
||||
{
|
||||
channel,
|
||||
...(options.domain ? { domain: options.domain } : {}),
|
||||
...(options.instanceId ? { instance_id: options.instanceId } : {}),
|
||||
...(options.mode ? { mode: options.mode } : {}),
|
||||
...(options.force ? { force: true } : {}),
|
||||
},
|
||||
PACKAGE_MUTATION_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function pollChannelConnect(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
channel: string,
|
||||
sessionId: string,
|
||||
base: string = "",
|
||||
params: Readonly<Record<string, string>> = {},
|
||||
): Promise<ChannelConnectPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("session_id", sessionId);
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (key !== "session_id") query.set(key, value);
|
||||
});
|
||||
return request<ChannelConnectPayload>(
|
||||
`${base}/api/settings/channels/${channel}/connect/poll?${query}`,
|
||||
token,
|
||||
const values = Object.fromEntries(
|
||||
Object.entries(params).filter(([key]) => key !== "session_id"),
|
||||
);
|
||||
return mutation<ChannelConnectPayload>(
|
||||
transport,
|
||||
"settings.channel.connect.poll",
|
||||
{ channel, session_id: sessionId, ...values },
|
||||
PACKAGE_MUTATION_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function cancelChannelConnect(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
channel: string,
|
||||
sessionId: string,
|
||||
base: string = "",
|
||||
): Promise<ChannelConnectPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("session_id", sessionId);
|
||||
return request<ChannelConnectPayload>(
|
||||
`${base}/api/settings/channels/${channel}/connect/cancel?${query}`,
|
||||
token,
|
||||
return mutation<ChannelConnectPayload>(
|
||||
transport,
|
||||
"settings.channel.connect.cancel",
|
||||
{ channel, session_id: sessionId },
|
||||
);
|
||||
}
|
||||
|
||||
export async function configureChannel(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
name: string,
|
||||
values: Record<string, string>,
|
||||
options: { enable?: boolean; instanceId?: string } = {},
|
||||
base: string = "",
|
||||
): Promise<ChannelConfigurePayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("name", name);
|
||||
if (options.enable !== undefined) query.set("enable", String(options.enable));
|
||||
if (options.instanceId) query.set("instance_id", options.instanceId);
|
||||
return request<ChannelConfigurePayload>(
|
||||
`${base}/api/settings/channels/configure?${query}`,
|
||||
token,
|
||||
return mutation<ChannelConfigurePayload>(
|
||||
transport,
|
||||
"settings.channel.configure",
|
||||
{
|
||||
headers: {
|
||||
[CHANNEL_VALUES_HEADER]: JSON.stringify(values),
|
||||
},
|
||||
name,
|
||||
values,
|
||||
...(options.enable !== undefined ? { enable: options.enable } : {}),
|
||||
...(options.instanceId ? { instance_id: options.instanceId } : {}),
|
||||
},
|
||||
PACKAGE_MUTATION_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function validateChannel(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
name: string,
|
||||
values: Record<string, string> = {},
|
||||
options: { instanceId?: string } = {},
|
||||
base: string = "",
|
||||
): Promise<ChannelValidationPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("name", name);
|
||||
if (options.instanceId) query.set("instance_id", options.instanceId);
|
||||
return request<ChannelValidationPayload>(
|
||||
`${base}/api/settings/channels/validate?${query}`,
|
||||
token,
|
||||
{
|
||||
headers: {
|
||||
[CHANNEL_VALUES_HEADER]: JSON.stringify(values),
|
||||
},
|
||||
},
|
||||
return mutation<ChannelValidationPayload>(
|
||||
transport,
|
||||
"settings.channel.validate",
|
||||
{ name, values, ...(options.instanceId ? { instance_id: options.instanceId } : {}) },
|
||||
);
|
||||
}
|
||||
|
||||
export async function runCliAppAction(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
action: "install" | "update" | "uninstall" | "test",
|
||||
name: string,
|
||||
base: string = "",
|
||||
): Promise<CliAppsPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("name", name);
|
||||
return request<CliAppsPayload>(`${base}/api/settings/cli-apps/${action}?${query}`, token);
|
||||
return mutation<CliAppsPayload>(
|
||||
transport,
|
||||
`settings.cli_app.${action}`,
|
||||
{ name },
|
||||
action === "install" || action === "update"
|
||||
? PACKAGE_MUTATION_TIMEOUT_MS
|
||||
: API_MUTATION_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchMcpPresets(
|
||||
@@ -736,55 +702,45 @@ export async function fetchProviderModels(
|
||||
}
|
||||
|
||||
export async function runMcpPresetAction(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
action: "enable" | "remove" | "test",
|
||||
name: string,
|
||||
values: Record<string, string> = {},
|
||||
base: string = "",
|
||||
): Promise<McpPresetsPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("name", name);
|
||||
return request<McpPresetsPayload>(
|
||||
`${base}/api/settings/mcp-presets/${action}?${query}`,
|
||||
token,
|
||||
{ headers: mcpValuesHeader(values) },
|
||||
return mutation<McpPresetsPayload>(
|
||||
transport,
|
||||
`settings.mcp.${action}`,
|
||||
{ name, ...compactMcpValues(values) },
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveCustomMcpServer(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
values: Record<string, string>,
|
||||
base: string = "",
|
||||
): Promise<McpPresetsPayload> {
|
||||
return request<McpPresetsPayload>(
|
||||
`${base}/api/settings/mcp-presets/custom`,
|
||||
token,
|
||||
{ headers: mcpValuesHeader(values) },
|
||||
return mutation<McpPresetsPayload>(
|
||||
transport,
|
||||
"settings.mcp.custom",
|
||||
compactMcpValues(values),
|
||||
);
|
||||
}
|
||||
|
||||
export async function importMcpConfig(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
config: string,
|
||||
base: string = "",
|
||||
): Promise<McpPresetsPayload> {
|
||||
return request<McpPresetsPayload>(
|
||||
`${base}/api/settings/mcp-presets/import`,
|
||||
token,
|
||||
{ headers: mcpValuesHeader({ config }) },
|
||||
);
|
||||
return mutation<McpPresetsPayload>(transport, "settings.mcp.import", { config });
|
||||
}
|
||||
|
||||
export async function updateMcpServerTools(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
name: string,
|
||||
enabledTools: string[],
|
||||
base: string = "",
|
||||
): Promise<McpPresetsPayload> {
|
||||
return request<McpPresetsPayload>(
|
||||
`${base}/api/settings/mcp-presets/tools`,
|
||||
token,
|
||||
{ headers: mcpValuesHeader({ name, enabled_tools: enabledTools }) },
|
||||
return mutation<McpPresetsPayload>(
|
||||
transport,
|
||||
"settings.mcp.tools",
|
||||
{ name, enabled_tools: enabledTools },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -835,280 +791,228 @@ export async function fetchSidebarState(
|
||||
}
|
||||
|
||||
export async function updateSidebarState(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
state: SidebarStatePayload,
|
||||
base: string = "",
|
||||
): Promise<SidebarStatePayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("state", JSON.stringify(state));
|
||||
return request<SidebarStatePayload>(
|
||||
`${base}/api/webui/sidebar-state/update?${query}`,
|
||||
token,
|
||||
);
|
||||
return mutation<SidebarStatePayload>(transport, "sidebar.update", { state });
|
||||
}
|
||||
|
||||
export async function updateSettings(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
update: SettingsUpdate,
|
||||
base: string = "",
|
||||
): Promise<SettingsPayload> {
|
||||
const query = new URLSearchParams();
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (update.modelPreset !== undefined) {
|
||||
query.set("model_preset", update.modelPreset ?? "default");
|
||||
payload.model_preset = update.modelPreset ?? "default";
|
||||
}
|
||||
if (update.model !== undefined) query.set("model", update.model);
|
||||
if (update.provider !== undefined) query.set("provider", update.provider);
|
||||
if (update.model !== undefined) payload.model = update.model;
|
||||
if (update.provider !== undefined) payload.provider = update.provider;
|
||||
if (update.contextWindowTokens !== undefined) {
|
||||
query.set("context_window_tokens", String(update.contextWindowTokens));
|
||||
payload.context_window_tokens = update.contextWindowTokens;
|
||||
}
|
||||
if (update.timezone !== undefined) query.set("timezone", update.timezone);
|
||||
if (update.timezone !== undefined) payload.timezone = update.timezone;
|
||||
if (update.toolHintMaxLength !== undefined) {
|
||||
query.set("tool_hint_max_length", String(update.toolHintMaxLength));
|
||||
payload.tool_hint_max_length = update.toolHintMaxLength;
|
||||
}
|
||||
return request<SettingsPayload>(`${base}/api/settings/update?${query}`, token);
|
||||
return mutation<SettingsPayload>(transport, "settings.agent.update", payload);
|
||||
}
|
||||
|
||||
function appendModelGenerationSettings(
|
||||
query: URLSearchParams,
|
||||
function modelGenerationSettingsPayload(
|
||||
configuration: Pick<
|
||||
ModelConfigurationCreate,
|
||||
"maxTokens" | "contextWindowTokens" | "temperature" | "reasoningEffort"
|
||||
>,
|
||||
): void {
|
||||
): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (configuration.maxTokens !== undefined) {
|
||||
query.set("max_tokens", String(configuration.maxTokens));
|
||||
payload.max_tokens = configuration.maxTokens;
|
||||
}
|
||||
if (configuration.contextWindowTokens !== undefined) {
|
||||
query.set("context_window_tokens", String(configuration.contextWindowTokens));
|
||||
payload.context_window_tokens = configuration.contextWindowTokens;
|
||||
}
|
||||
if (configuration.temperature !== undefined) {
|
||||
query.set("temperature", String(configuration.temperature));
|
||||
payload.temperature = configuration.temperature;
|
||||
}
|
||||
if (configuration.reasoningEffort !== undefined) {
|
||||
query.set("reasoning_effort", configuration.reasoningEffort ?? "");
|
||||
payload.reasoning_effort = configuration.reasoningEffort ?? "";
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function createModelConfiguration(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
configuration: ModelConfigurationCreate,
|
||||
base: string = "",
|
||||
): Promise<SettingsPayload> {
|
||||
const query = new URLSearchParams();
|
||||
if (configuration.name !== undefined) query.set("name", configuration.name);
|
||||
query.set("label", configuration.label);
|
||||
query.set("provider", configuration.provider);
|
||||
query.set("model", configuration.model);
|
||||
appendModelGenerationSettings(query, configuration);
|
||||
return request<SettingsPayload>(
|
||||
`${base}/api/settings/model-configurations/create?${query}`,
|
||||
token,
|
||||
return mutation<SettingsPayload>(
|
||||
transport,
|
||||
"settings.model_configuration.create",
|
||||
{
|
||||
...(configuration.name !== undefined ? { name: configuration.name } : {}),
|
||||
label: configuration.label,
|
||||
provider: configuration.provider,
|
||||
model: configuration.model,
|
||||
...modelGenerationSettingsPayload(configuration),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateModelConfiguration(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
configuration: ModelConfigurationUpdate,
|
||||
base: string = "",
|
||||
): Promise<SettingsPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("name", configuration.name);
|
||||
if (configuration.label !== undefined) query.set("label", configuration.label);
|
||||
if (configuration.provider !== undefined) query.set("provider", configuration.provider);
|
||||
if (configuration.model !== undefined) query.set("model", configuration.model);
|
||||
appendModelGenerationSettings(query, configuration);
|
||||
return request<SettingsPayload>(
|
||||
`${base}/api/settings/model-configurations/update?${query}`,
|
||||
token,
|
||||
return mutation<SettingsPayload>(
|
||||
transport,
|
||||
"settings.model_configuration.update",
|
||||
{
|
||||
name: configuration.name,
|
||||
...(configuration.label !== undefined ? { label: configuration.label } : {}),
|
||||
...(configuration.provider !== undefined ? { provider: configuration.provider } : {}),
|
||||
...(configuration.model !== undefined ? { model: configuration.model } : {}),
|
||||
...modelGenerationSettingsPayload(configuration),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteModelConfiguration(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
name: string,
|
||||
base: string = "",
|
||||
): Promise<SettingsPayload> {
|
||||
const query = new URLSearchParams({ name });
|
||||
return request<SettingsPayload>(
|
||||
`${base}/api/settings/model-configurations/delete?${query}`,
|
||||
token,
|
||||
return mutation<SettingsPayload>(
|
||||
transport,
|
||||
"settings.model_configuration.delete",
|
||||
{ name },
|
||||
);
|
||||
}
|
||||
|
||||
export async function migrateModelConfigurations(
|
||||
token: string,
|
||||
base: string = "",
|
||||
transport: WebUIMutationTransport,
|
||||
): Promise<SettingsPayload> {
|
||||
return request<SettingsPayload>(
|
||||
`${base}/api/settings/model-configurations/migrate`,
|
||||
token,
|
||||
);
|
||||
return mutation<SettingsPayload>(transport, "settings.model_configuration.migrate");
|
||||
}
|
||||
|
||||
export async function updateModelCallOrder(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
order: string[],
|
||||
base: string = "",
|
||||
): Promise<SettingsPayload> {
|
||||
const query = new URLSearchParams({ order: JSON.stringify(order) });
|
||||
return request<SettingsPayload>(
|
||||
`${base}/api/settings/model-call-order/update?${query}`,
|
||||
token,
|
||||
);
|
||||
return mutation<SettingsPayload>(transport, "settings.model_call_order.update", { order });
|
||||
}
|
||||
|
||||
export async function updateProviderSettings(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
update: ProviderSettingsUpdate,
|
||||
base: string = "",
|
||||
): Promise<SettingsPayload> {
|
||||
const { provider, ...values } = update;
|
||||
const query = new URLSearchParams({ provider });
|
||||
return request<SettingsPayload>(
|
||||
`${base}/api/settings/provider/update?${query}`,
|
||||
token,
|
||||
{
|
||||
headers: {
|
||||
[PROVIDER_VALUES_HEADER]: encodeURIComponent(JSON.stringify(values)),
|
||||
},
|
||||
},
|
||||
);
|
||||
return mutation<SettingsPayload>(transport, "settings.provider.update", { ...update });
|
||||
}
|
||||
|
||||
export async function createProviderSettings(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
update: ProviderCreationUpdate,
|
||||
base: string = "",
|
||||
): Promise<SettingsPayload> {
|
||||
return request<SettingsPayload>(
|
||||
`${base}/api/settings/provider/create`,
|
||||
token,
|
||||
{
|
||||
headers: {
|
||||
[PROVIDER_VALUES_HEADER]: encodeURIComponent(JSON.stringify(update)),
|
||||
},
|
||||
},
|
||||
);
|
||||
return mutation<SettingsPayload>(transport, "settings.provider.create", { ...update });
|
||||
}
|
||||
|
||||
export async function loginProviderOAuth(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
provider: string,
|
||||
base: string = "",
|
||||
remoteBrowserAccess: boolean = false,
|
||||
): Promise<ProviderOAuthLoginResult> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("provider", provider);
|
||||
if (remoteBrowserAccess) query.set("remote_browser", "true");
|
||||
return request<ProviderOAuthLoginResult>(
|
||||
`${base}/api/settings/provider/oauth-login?${query}`,
|
||||
token,
|
||||
{ cache: "no-store" },
|
||||
return mutation<ProviderOAuthLoginResult>(
|
||||
transport,
|
||||
"settings.provider.oauth_login",
|
||||
{ provider, ...(remoteBrowserAccess ? { remote_browser: true } : {}) },
|
||||
);
|
||||
}
|
||||
|
||||
export async function completeProviderOAuth(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
provider: string,
|
||||
flowId: string,
|
||||
authorizationResponse?: string,
|
||||
base: string = "",
|
||||
): Promise<ProviderOAuthCompletionResult> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("provider", provider);
|
||||
query.set("flow_id", flowId);
|
||||
const responseHeader = provider === "openai_codex"
|
||||
? OAUTH_CALLBACK_HEADER
|
||||
: OAUTH_CODE_HEADER;
|
||||
const headers = authorizationResponse
|
||||
? { [responseHeader]: authorizationResponse }
|
||||
: undefined;
|
||||
return request<ProviderOAuthCompletionResult>(
|
||||
`${base}/api/settings/provider/oauth-login/complete?${query}`,
|
||||
token,
|
||||
{ cache: "no-store", ...(headers ? { headers } : {}) },
|
||||
return mutation<ProviderOAuthCompletionResult>(
|
||||
transport,
|
||||
"settings.provider.oauth_complete",
|
||||
{
|
||||
provider,
|
||||
flow_id: flowId,
|
||||
...(authorizationResponse ? { authorization_response: authorizationResponse } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function logoutProviderOAuth(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
provider: string,
|
||||
base: string = "",
|
||||
): Promise<SettingsPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("provider", provider);
|
||||
return request<SettingsPayload>(
|
||||
`${base}/api/settings/provider/oauth-logout?${query}`,
|
||||
token,
|
||||
);
|
||||
return mutation<SettingsPayload>(transport, "settings.provider.oauth_logout", { provider });
|
||||
}
|
||||
|
||||
export async function updateWebSearchSettings(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
update: WebSearchSettingsUpdate,
|
||||
base: string = "",
|
||||
): Promise<SettingsPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("provider", update.provider);
|
||||
if (update.apiKey !== undefined) query.set("api_key", update.apiKey);
|
||||
if (update.baseUrl !== undefined) query.set("base_url", update.baseUrl);
|
||||
if (update.maxResults !== undefined) query.set("max_results", String(update.maxResults));
|
||||
if (update.timeout !== undefined) query.set("timeout", String(update.timeout));
|
||||
if (update.useJinaReader !== undefined) {
|
||||
query.set("use_jina_reader", String(update.useJinaReader));
|
||||
}
|
||||
return request<SettingsPayload>(
|
||||
`${base}/api/settings/web-search/update?${query}`,
|
||||
token,
|
||||
return mutation<SettingsPayload>(
|
||||
transport,
|
||||
"settings.web_search.update",
|
||||
{
|
||||
provider: update.provider,
|
||||
...(update.apiKey !== undefined ? { api_key: update.apiKey } : {}),
|
||||
...(update.baseUrl !== undefined ? { base_url: update.baseUrl } : {}),
|
||||
...(update.maxResults !== undefined ? { max_results: update.maxResults } : {}),
|
||||
...(update.timeout !== undefined ? { timeout: update.timeout } : {}),
|
||||
...(update.useJinaReader !== undefined
|
||||
? { use_jina_reader: update.useJinaReader }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateNetworkSafetySettings(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
update: NetworkSafetySettingsUpdate,
|
||||
base: string = "",
|
||||
): Promise<SettingsPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("webui_allow_local_service_access", String(update.webuiAllowLocalServiceAccess));
|
||||
query.set("webui_default_access_mode", update.webuiDefaultAccessMode);
|
||||
return request<SettingsPayload>(
|
||||
`${base}/api/settings/network-safety/update?${query}`,
|
||||
token,
|
||||
return mutation<SettingsPayload>(
|
||||
transport,
|
||||
"settings.network_safety.update",
|
||||
{
|
||||
webui_allow_local_service_access: update.webuiAllowLocalServiceAccess,
|
||||
webui_default_access_mode: update.webuiDefaultAccessMode,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateImageGenerationSettings(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
update: ImageGenerationSettingsUpdate,
|
||||
base: string = "",
|
||||
): Promise<SettingsPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("enabled", String(update.enabled));
|
||||
query.set("provider", update.provider);
|
||||
query.set("model", update.model);
|
||||
query.set("default_aspect_ratio", update.defaultAspectRatio);
|
||||
query.set("default_image_size", update.defaultImageSize);
|
||||
query.set("max_images_per_turn", String(update.maxImagesPerTurn));
|
||||
return request<SettingsPayload>(
|
||||
`${base}/api/settings/image-generation/update?${query}`,
|
||||
token,
|
||||
return mutation<SettingsPayload>(
|
||||
transport,
|
||||
"settings.image_generation.update",
|
||||
{
|
||||
enabled: update.enabled,
|
||||
provider: update.provider,
|
||||
model: update.model,
|
||||
default_aspect_ratio: update.defaultAspectRatio,
|
||||
default_image_size: update.defaultImageSize,
|
||||
max_images_per_turn: update.maxImagesPerTurn,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateTranscriptionSettings(
|
||||
token: string,
|
||||
transport: WebUIMutationTransport,
|
||||
update: TranscriptionSettingsUpdate,
|
||||
base: string = "",
|
||||
): Promise<SettingsPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("enabled", String(update.enabled));
|
||||
query.set("provider", update.provider);
|
||||
query.set("model", update.model);
|
||||
query.set("language", update.language);
|
||||
query.set("max_duration_sec", String(update.maxDurationSec));
|
||||
query.set("max_upload_mb", String(update.maxUploadMb));
|
||||
return request<SettingsPayload>(
|
||||
`${base}/api/settings/transcription/update?${query}`,
|
||||
token,
|
||||
return mutation<SettingsPayload>(
|
||||
transport,
|
||||
"settings.transcription.update",
|
||||
{
|
||||
enabled: update.enabled,
|
||||
provider: update.provider,
|
||||
model: update.model,
|
||||
language: update.language,
|
||||
max_duration_sec: update.maxDurationSec,
|
||||
max_upload_mb: update.maxUploadMb,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,6 +108,16 @@ interface PendingRequest<T> {
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
export class WebUIMutationError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.name = "WebUIMutationError";
|
||||
}
|
||||
}
|
||||
|
||||
interface PendingChatRequest extends PendingRequest<string> {
|
||||
temporary: boolean;
|
||||
}
|
||||
@@ -203,6 +213,7 @@ export class NanobotClient {
|
||||
private pendingNewChat: PendingChatRequest | null = null;
|
||||
private pendingTranscriptions = new Map<string, PendingRequest<string>>();
|
||||
private pendingSystemCommands = new Map<string, PendingRequest<void>>();
|
||||
private pendingWebUIRequests = new Map<string, PendingRequest<unknown>>();
|
||||
// Frames queued while the socket is not yet OPEN
|
||||
private sendQueue: Outbound[] = [];
|
||||
private reconnectAttempts = 0;
|
||||
@@ -807,6 +818,60 @@ export class NanobotClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one non-replayable WebUI mutation over the authenticated socket.
|
||||
* A client-side timeout only abandons the reply; the server may finish work
|
||||
* that already started, so timed-out requests are never retried automatically.
|
||||
*/
|
||||
requestMutation<T>(
|
||||
action: string,
|
||||
payload: Record<string, unknown> = {},
|
||||
timeoutMs: number = 20_000,
|
||||
): Promise<T> {
|
||||
const socket = this.socket;
|
||||
if (!socket || socket.readyState !== WS_OPEN) {
|
||||
return Promise.reject(
|
||||
new WebUIMutationError(503, "WebUI connection is not open"),
|
||||
);
|
||||
}
|
||||
const requestId = crypto.randomUUID();
|
||||
const frame: Outbound = {
|
||||
type: "webui_request",
|
||||
request_id: requestId,
|
||||
action,
|
||||
payload,
|
||||
};
|
||||
if (!this.frameFitsTransport(frame)) {
|
||||
return Promise.reject(
|
||||
new WebUIMutationError(413, "WebUI mutation payload is too large"),
|
||||
);
|
||||
}
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingWebUIRequests.delete(requestId);
|
||||
reject(
|
||||
new WebUIMutationError(
|
||||
504,
|
||||
`WebUI request timed out after ${timeoutMs}ms`,
|
||||
),
|
||||
);
|
||||
}, timeoutMs);
|
||||
this.pendingWebUIRequests.set(requestId, {
|
||||
resolve: (value) => resolve(value as T),
|
||||
reject,
|
||||
timer,
|
||||
});
|
||||
try {
|
||||
socket.send(JSON.stringify(frame));
|
||||
} catch {
|
||||
clearTimeout(timer);
|
||||
this.pendingWebUIRequests.delete(requestId);
|
||||
reject(new WebUIMutationError(503, "Could not send WebUI request"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Ask the server to create a non-destructive fork before a user-message index. */
|
||||
forkChat(
|
||||
sourceChatId: string,
|
||||
@@ -914,8 +979,8 @@ export class NanobotClient {
|
||||
});
|
||||
}
|
||||
|
||||
setSidebarState(state: SidebarStatePayload): void {
|
||||
this.queueSend({ type: "set_sidebar_state", state });
|
||||
setSidebarState(state: SidebarStatePayload): Promise<SidebarStatePayload> {
|
||||
return this.requestMutation<SidebarStatePayload>("sidebar.update", { state });
|
||||
}
|
||||
|
||||
// -- internals ---------------------------------------------------------
|
||||
@@ -965,6 +1030,23 @@ export class NanobotClient {
|
||||
console.log("[nanobot ws inbound]", summarizeInboundWsPayload(parsed));
|
||||
}
|
||||
|
||||
if (parsed.event === "webui_response") {
|
||||
const pending = this.pendingWebUIRequests.get(parsed.request_id);
|
||||
if (!pending) return;
|
||||
clearTimeout(pending.timer);
|
||||
this.pendingWebUIRequests.delete(parsed.request_id);
|
||||
if (parsed.ok) {
|
||||
pending.resolve(parsed.result);
|
||||
} else {
|
||||
const status = Number.isFinite(parsed.error?.status)
|
||||
? parsed.error.status
|
||||
: 500;
|
||||
const message = parsed.error?.message || "WebUI mutation failed";
|
||||
pending.reject(new WebUIMutationError(status, message));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.event === "error" && !parsed.turn_id) {
|
||||
const fallback = this.legacyRejectionTarget(parsed);
|
||||
if (fallback) {
|
||||
@@ -1151,6 +1233,13 @@ export class NanobotClient {
|
||||
this.pendingNewChat = null;
|
||||
}
|
||||
this.rejectAllTranscriptions("socket closed");
|
||||
for (const pending of this.pendingWebUIRequests.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(
|
||||
new WebUIMutationError(503, "Socket closed before WebUI response"),
|
||||
);
|
||||
}
|
||||
this.pendingWebUIRequests.clear();
|
||||
for (const pending of this.pendingSystemCommands.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(new Error("socket closed"));
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
import { toolTraceLinesFromEvents } from "@/lib/tool-traces";
|
||||
import type {
|
||||
ToolProgressEvent,
|
||||
UIFileEdit,
|
||||
UIMessage,
|
||||
UITurnPhase,
|
||||
} from "@/lib/types";
|
||||
|
||||
export type UIMessageTurnFields = Pick<UIMessage, "turnId" | "turnPhase" | "turnSeq">;
|
||||
|
||||
const FILE_EDIT_TOOL_NAMES = new Set(["write_file", "edit_file", "apply_patch"]);
|
||||
|
||||
/**
|
||||
* PR3 projection seam: replay can share these folds once GatewayContext exposes
|
||||
* an ordered canonical-event sequence and a monotonic per-thread revision.
|
||||
* Snapshot acceptance and revision comparison stay outside this projection;
|
||||
* until then, history continues to consume server-projected UIMessage snapshots.
|
||||
*/
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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. */
|
||||
export 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the most recent assistant placeholder that an incoming answer
|
||||
* delta should adopt instead of spawning a parallel row.
|
||||
*/
|
||||
export 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;
|
||||
}
|
||||
|
||||
export function replaceMessageAt(
|
||||
prev: UIMessage[],
|
||||
index: number,
|
||||
message: UIMessage,
|
||||
): UIMessage[] {
|
||||
const next = prev.slice();
|
||||
next[index] = message;
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Close the active reasoning stream segment. ``now`` is supplied by the caller
|
||||
* so the projection remains deterministic for replay and fixture tests. */
|
||||
export function closeReasoningStream(prev: UIMessage[], now: number): 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(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;
|
||||
}
|
||||
|
||||
export 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";
|
||||
}
|
||||
|
||||
export 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]);
|
||||
});
|
||||
}
|
||||
|
||||
export 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 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),
|
||||
);
|
||||
}
|
||||
|
||||
export 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
|
||||
);
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export function finalizeStreamedTurn(
|
||||
prev: UIMessage[],
|
||||
turn: UIMessageTurnFields = {},
|
||||
): UIMessage[] {
|
||||
return prev.map((m) =>
|
||||
m.isStreaming && matchesTurn(m, turn)
|
||||
? { ...m, isStreaming: false, reasoningStreaming: false }
|
||||
: m,
|
||||
);
|
||||
}
|
||||
@@ -1261,6 +1261,18 @@ export type InboundEvent =
|
||||
detail?: string;
|
||||
provider?: string;
|
||||
}
|
||||
| {
|
||||
event: "webui_response";
|
||||
request_id: string;
|
||||
ok: true;
|
||||
result: unknown;
|
||||
}
|
||||
| {
|
||||
event: "webui_response";
|
||||
request_id: string;
|
||||
ok: false;
|
||||
error: { status: number; message: string };
|
||||
}
|
||||
| {
|
||||
event: "error";
|
||||
chat_id?: string;
|
||||
@@ -1339,6 +1351,12 @@ export interface FilePreviewPayload {
|
||||
export type Outbound =
|
||||
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
|
||||
| { type: "new_temporary_chat" }
|
||||
| {
|
||||
type: "webui_request";
|
||||
request_id: string;
|
||||
action: string;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
|
||||
| { type: "attach"; chat_id: string }
|
||||
| { type: "set_sidebar_state"; state: SidebarStatePayload }
|
||||
|
||||
+303
-352
@@ -59,8 +59,19 @@ import {
|
||||
validateChannel,
|
||||
} from "@/lib/api";
|
||||
|
||||
const requestMutation = vi.fn();
|
||||
const mutationTransport = {
|
||||
requestMutation: <T>(
|
||||
action: string,
|
||||
payload?: Record<string, unknown>,
|
||||
timeoutMs?: number,
|
||||
) => requestMutation(action, payload, timeoutMs) as Promise<T>,
|
||||
};
|
||||
|
||||
describe("webui API helpers", () => {
|
||||
beforeEach(() => {
|
||||
requestMutation.mockReset();
|
||||
requestMutation.mockResolvedValue({});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
@@ -184,88 +195,74 @@ describe("webui API helpers", () => {
|
||||
|
||||
it("validates channel settings with form values", async () => {
|
||||
await validateChannel(
|
||||
"tok",
|
||||
mutationTransport,
|
||||
"slack",
|
||||
{ "channels.slack.botToken": "xoxb-test" },
|
||||
{ instanceId: "default" },
|
||||
);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/channels/validate?name=slack&instance_id=default",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-Channel-Values": JSON.stringify({
|
||||
"channels.slack.botToken": "xoxb-test",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"settings.channel.validate",
|
||||
{
|
||||
name: "slack",
|
||||
instance_id: "default",
|
||||
values: { "channels.slack.botToken": "xoxb-test" },
|
||||
},
|
||||
20_000,
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("configures channels through the WebSocket HTTP shim", async () => {
|
||||
it("configures channels through the authenticated WebSocket", async () => {
|
||||
await configureChannel(
|
||||
"tok",
|
||||
mutationTransport,
|
||||
"discord",
|
||||
{ "channels.discord.token": "saved-secret" },
|
||||
{ enable: true },
|
||||
);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/channels/configure?name=discord&enable=true",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-Channel-Values": JSON.stringify({
|
||||
"channels.discord.token": "saved-secret",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"settings.channel.configure",
|
||||
{
|
||||
name: "discord",
|
||||
enable: true,
|
||||
values: { "channels.discord.token": "saved-secret" },
|
||||
},
|
||||
150_000,
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serializes channel QR connect helpers", async () => {
|
||||
await startChannelConnect("tok", "weixin", { force: true });
|
||||
expect(fetch).toHaveBeenLastCalledWith(
|
||||
"/api/settings/channels/weixin/connect/start?force=true",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
it("serializes channel QR connect request envelopes", async () => {
|
||||
await startChannelConnect(mutationTransport, "weixin", { force: true });
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.channel.connect.start",
|
||||
{ channel: "weixin", force: true },
|
||||
150_000,
|
||||
);
|
||||
|
||||
await pollChannelConnect("tok", "weixin", "session+/=");
|
||||
expect(fetch).toHaveBeenLastCalledWith(
|
||||
"/api/settings/channels/weixin/connect/poll?session_id=session%2B%2F%3D",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
await pollChannelConnect(mutationTransport, "weixin", "session+/=");
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.channel.connect.poll",
|
||||
{ channel: "weixin", session_id: "session+/=" },
|
||||
150_000,
|
||||
);
|
||||
|
||||
await cancelChannelConnect("tok", "weixin", "session+/=");
|
||||
expect(fetch).toHaveBeenLastCalledWith(
|
||||
"/api/settings/channels/weixin/connect/cancel?session_id=session%2B%2F%3D",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
await cancelChannelConnect(mutationTransport, "weixin", "session+/=");
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.channel.connect.cancel",
|
||||
{ channel: "weixin", session_id: "session+/=" },
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes workspace automation actions", async () => {
|
||||
await runAutomationAction("tok", "disable", "job 1/2");
|
||||
await runAutomationAction(mutationTransport, "disable", "job 1/2");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/automations/disable?id=job+1%2F2",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"automation.disable",
|
||||
{ id: "job 1/2" },
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -275,19 +272,14 @@ describe("webui API helpers", () => {
|
||||
message: "Ask 今日 quiz",
|
||||
schedule: { kind: "cron", expr: "0 9 * * *", tz: "Asia/Shanghai" },
|
||||
} as const;
|
||||
await updateAutomation("tok", "job 1/2", values);
|
||||
await updateAutomation(mutationTransport, "job 1/2", values);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/automations/update?id=job+1%2F2",
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-Automation-Values": encodeURIComponent(JSON.stringify(values)),
|
||||
},
|
||||
}),
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"automation.update",
|
||||
{ id: "job 1/2", values },
|
||||
20_000,
|
||||
);
|
||||
const header = vi.mocked(fetch).mock.calls[0][1]?.headers as Record<string, string>;
|
||||
expect(header["X-Nanobot-Automation-Values"]).not.toContain("每日");
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches the WebUI skill summary", async () => {
|
||||
@@ -348,66 +340,66 @@ describe("webui API helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes provider install coordinates", async () => {
|
||||
it("sends provider install coordinates without placing them in a URL", async () => {
|
||||
await installMarketplaceSkill(
|
||||
"tok",
|
||||
mutationTransport,
|
||||
"skillhub",
|
||||
"@tencent/skills",
|
||||
"ima-skills",
|
||||
"1.1.8",
|
||||
);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/skills/install?provider=skillhub&source=%40tencent%2Fskills&skill=ima-skills&version=1.1.8",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"skill.install",
|
||||
{
|
||||
provider: "skillhub",
|
||||
source: "@tencent/skills",
|
||||
skill: "ima-skills",
|
||||
version: "1.1.8",
|
||||
},
|
||||
150_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("updates and deletes installed skills with encoded names", async () => {
|
||||
await updateSkillEnabled("tok", "custom skill", false);
|
||||
it("updates and deletes installed skills over the WebSocket", async () => {
|
||||
await updateSkillEnabled(mutationTransport, "custom skill", false);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/skills/update?name=custom+skill&enabled=false",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"skill.update",
|
||||
{ name: "custom skill", enabled: false },
|
||||
20_000,
|
||||
);
|
||||
|
||||
await deleteSkill("tok", "custom skill");
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/skills/delete?name=custom+skill",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
await deleteSkill(mutationTransport, "custom skill");
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"skill.delete",
|
||||
{ name: "custom skill" },
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("percent-encodes websocket keys when deleting a session", async () => {
|
||||
await deleteSession("tok", "websocket:chat-1");
|
||||
it("sends the session key in a mutation payload", async () => {
|
||||
await deleteSession(mutationTransport, "websocket:chat-1");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/sessions/websocket%3Achat-1/delete",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"session.delete",
|
||||
{ key: "websocket:chat-1" },
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes the automation cascade flag when deleting a session", async () => {
|
||||
await deleteSession("tok", "websocket:chat-1", { deleteAutomations: true });
|
||||
await deleteSession(mutationTransport, "websocket:chat-1", { deleteAutomations: true });
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/sessions/websocket%3Achat-1/delete?delete_automations=true",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"session.delete",
|
||||
{ key: "websocket:chat-1", delete_automations: true },
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes settings updates as a narrow query string", async () => {
|
||||
await updateSettings("tok", {
|
||||
it("serializes settings updates as a narrow mutation payload", async () => {
|
||||
await updateSettings(mutationTransport, {
|
||||
modelPreset: "default",
|
||||
model: "openrouter/test",
|
||||
provider: "openrouter",
|
||||
@@ -416,11 +408,17 @@ describe("webui API helpers", () => {
|
||||
toolHintMaxLength: 120,
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/update?model_preset=default&model=openrouter%2Ftest&provider=openrouter&context_window_tokens=262144&timezone=Asia%2FShanghai&tool_hint_max_length=120",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"settings.agent.update",
|
||||
{
|
||||
model_preset: "default",
|
||||
model: "openrouter/test",
|
||||
provider: "openrouter",
|
||||
context_window_tokens: 262144,
|
||||
timezone: "Asia/Shanghai",
|
||||
tool_hint_max_length: 120,
|
||||
},
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -436,7 +434,7 @@ describe("webui API helpers", () => {
|
||||
});
|
||||
|
||||
it("serializes model configuration creation", async () => {
|
||||
await createModelConfiguration("tok", {
|
||||
await createModelConfiguration(mutationTransport, {
|
||||
label: "Fast writing",
|
||||
provider: "openai",
|
||||
model: "openai/gpt-4.1-mini",
|
||||
@@ -446,16 +444,23 @@ describe("webui API helpers", () => {
|
||||
reasoningEffort: "high",
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/model-configurations/create?label=Fast+writing&provider=openai&model=openai%2Fgpt-4.1-mini&max_tokens=4096&context_window_tokens=128000&temperature=0.4&reasoning_effort=high",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"settings.model_configuration.create",
|
||||
{
|
||||
label: "Fast writing",
|
||||
provider: "openai",
|
||||
model: "openai/gpt-4.1-mini",
|
||||
max_tokens: 4096,
|
||||
context_window_tokens: 128000,
|
||||
temperature: 0.4,
|
||||
reasoning_effort: "high",
|
||||
},
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes model configuration updates", async () => {
|
||||
await updateModelConfiguration("tok", {
|
||||
await updateModelConfiguration(mutationTransport, {
|
||||
name: "codex",
|
||||
label: "Codex",
|
||||
provider: "openai_codex",
|
||||
@@ -466,42 +471,47 @@ describe("webui API helpers", () => {
|
||||
reasoningEffort: null,
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/model-configurations/update?name=codex&label=Codex&provider=openai_codex&model=openai-codex%2Fgpt-5.5&max_tokens=8192&context_window_tokens=65536&temperature=0&reasoning_effort=",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"settings.model_configuration.update",
|
||||
{
|
||||
name: "codex",
|
||||
label: "Codex",
|
||||
provider: "openai_codex",
|
||||
model: "openai-codex/gpt-5.5",
|
||||
max_tokens: 8192,
|
||||
context_window_tokens: 65536,
|
||||
temperature: 0,
|
||||
reasoning_effort: "",
|
||||
},
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes model preset deletion and migration", async () => {
|
||||
await deleteModelConfiguration("tok", "spare");
|
||||
await migrateModelConfigurations("tok");
|
||||
await deleteModelConfiguration(mutationTransport, "spare");
|
||||
await migrateModelConfigurations(mutationTransport);
|
||||
|
||||
expect(fetch).toHaveBeenNthCalledWith(
|
||||
expect(requestMutation).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"/api/settings/model-configurations/delete?name=spare",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
"settings.model_configuration.delete",
|
||||
{ name: "spare" },
|
||||
20_000,
|
||||
);
|
||||
expect(fetch).toHaveBeenNthCalledWith(
|
||||
expect(requestMutation).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"/api/settings/model-configurations/migrate",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
"settings.model_configuration.migrate",
|
||||
{},
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes model call order as an ordered JSON array", async () => {
|
||||
await updateModelCallOrder("tok", ["backup", "primary"]);
|
||||
await updateModelCallOrder(mutationTransport, ["backup", "primary"]);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/model-call-order/update?order=%5B%22backup%22%2C%22primary%22%5D",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"settings.model_call_order.update",
|
||||
{ order: ["backup", "primary"] },
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -516,28 +526,20 @@ describe("webui API helpers", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
updateModelConfiguration("tok", {
|
||||
name: "codex",
|
||||
model: "openai-codex/gpt-5.5",
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
await expect(fetchApiService("tok")).rejects.toMatchObject({
|
||||
status: 200,
|
||||
message: "Gateway returned WebUI HTML instead of JSON. Restart nanobot gateway and try again.",
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces API error response bodies", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => "npm error ENOTEMPTY",
|
||||
}),
|
||||
it("surfaces correlated WebSocket mutation errors", async () => {
|
||||
requestMutation.mockRejectedValueOnce(
|
||||
Object.assign(new Error("npm error ENOTEMPTY"), { status: 500 }),
|
||||
);
|
||||
|
||||
await expect(runCliAppAction("tok", "install", "hyperframes")).rejects.toMatchObject({
|
||||
await expect(
|
||||
runCliAppAction(mutationTransport, "install", "hyperframes"),
|
||||
).rejects.toMatchObject({
|
||||
status: 500,
|
||||
message: "npm error ENOTEMPTY",
|
||||
});
|
||||
@@ -555,50 +557,45 @@ describe("webui API helpers", () => {
|
||||
await pending;
|
||||
});
|
||||
|
||||
it("serializes provider settings updates without returning secrets", async () => {
|
||||
await updateProviderSettings("tok", {
|
||||
it("keeps provider secrets in the WebSocket payload", async () => {
|
||||
await updateProviderSettings(mutationTransport, {
|
||||
provider: "openrouter",
|
||||
apiKey: "sk-or-test",
|
||||
apiBase: "https://openrouter.ai/api/v1",
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/provider/update?provider=openrouter",
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"settings.provider.update",
|
||||
{
|
||||
provider: "openrouter",
|
||||
apiKey: "sk-or-test",
|
||||
apiBase: "https://openrouter.ai/api/v1",
|
||||
})),
|
||||
},
|
||||
}),
|
||||
20_000,
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serializes OAuth provider advanced settings", async () => {
|
||||
await updateProviderSettings("tok", {
|
||||
await updateProviderSettings(mutationTransport, {
|
||||
provider: "xai_grok",
|
||||
proxy: "http://127.0.0.1:7890",
|
||||
extraBody: '{"tools":[]}',
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/provider/update?provider=xai_grok",
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"settings.provider.update",
|
||||
{
|
||||
provider: "xai_grok",
|
||||
proxy: "http://127.0.0.1:7890",
|
||||
extraBody: '{"tools":[]}',
|
||||
})),
|
||||
},
|
||||
}),
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes custom provider creation with advanced settings", async () => {
|
||||
await createProviderSettings("tok", {
|
||||
const update = {
|
||||
name: "Company Gateway",
|
||||
apiKey: "sk-company",
|
||||
apiBase: "https://gateway.example/v1",
|
||||
@@ -607,25 +604,13 @@ describe("webui API helpers", () => {
|
||||
extraQuery: '{"api-version":"2026-01-01"}',
|
||||
proxy: "http://127.0.0.1:7890",
|
||||
thinkingStyle: "enable_thinking",
|
||||
});
|
||||
};
|
||||
await createProviderSettings(mutationTransport, update);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/provider/create",
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({
|
||||
name: "Company Gateway",
|
||||
apiKey: "sk-company",
|
||||
apiBase: "https://gateway.example/v1",
|
||||
extraHeaders: '{"X-Tenant":"engineering"}',
|
||||
extraBody: '{"service_tier":"priority"}',
|
||||
extraQuery: '{"api-version":"2026-01-01"}',
|
||||
proxy: "http://127.0.0.1:7890",
|
||||
thinkingStyle: "enable_thinking",
|
||||
})),
|
||||
},
|
||||
}),
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"settings.provider.create",
|
||||
update,
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -641,74 +626,65 @@ describe("webui API helpers", () => {
|
||||
});
|
||||
|
||||
it("serializes provider OAuth login and logout actions", async () => {
|
||||
await loginProviderOAuth("tok", "openai_codex");
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/provider/oauth-login?provider=openai_codex",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
await loginProviderOAuth(mutationTransport, "openai_codex");
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.provider.oauth_login",
|
||||
{ provider: "openai_codex" },
|
||||
20_000,
|
||||
);
|
||||
|
||||
await loginProviderOAuth("tok", "openai_codex", "", true);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/provider/oauth-login?provider=openai_codex&remote_browser=true",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
await loginProviderOAuth(mutationTransport, "openai_codex", true);
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.provider.oauth_login",
|
||||
{ provider: "openai_codex", remote_browser: true },
|
||||
20_000,
|
||||
);
|
||||
|
||||
await completeProviderOAuth("tok", "xai_grok", "flow-123");
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/provider/oauth-login/complete?provider=xai_grok&flow_id=flow-123",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
await completeProviderOAuth(mutationTransport, "xai_grok", "flow-123");
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.provider.oauth_complete",
|
||||
{ provider: "xai_grok", flow_id: "flow-123" },
|
||||
20_000,
|
||||
);
|
||||
|
||||
await completeProviderOAuth(
|
||||
"tok",
|
||||
mutationTransport,
|
||||
"xai_grok",
|
||||
"flow-123",
|
||||
"secret",
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/provider/oauth-login/complete?provider=xai_grok&flow_id=flow-123",
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-OAuth-Code": "secret",
|
||||
},
|
||||
}),
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.provider.oauth_complete",
|
||||
{ provider: "xai_grok", flow_id: "flow-123", authorization_response: "secret" },
|
||||
20_000,
|
||||
);
|
||||
|
||||
await completeProviderOAuth(
|
||||
"tok",
|
||||
mutationTransport,
|
||||
"openai_codex",
|
||||
"flow-codex",
|
||||
"http://localhost:1455/auth/callback?code=secret&state=test",
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/provider/oauth-login/complete?provider=openai_codex&flow_id=flow-codex",
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-OAuth-Callback":
|
||||
"http://localhost:1455/auth/callback?code=secret&state=test",
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.provider.oauth_complete",
|
||||
{
|
||||
provider: "openai_codex",
|
||||
flow_id: "flow-codex",
|
||||
authorization_response: "http://localhost:1455/auth/callback?code=secret&state=test",
|
||||
},
|
||||
}),
|
||||
20_000,
|
||||
);
|
||||
|
||||
await logoutProviderOAuth("tok", "openai_codex");
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/provider/oauth-logout?provider=openai_codex",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
await logoutProviderOAuth(mutationTransport, "openai_codex");
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.provider.oauth_logout",
|
||||
{ provider: "openai_codex" },
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes web search settings updates", async () => {
|
||||
await updateWebSearchSettings("tok", {
|
||||
await updateWebSearchSettings(mutationTransport, {
|
||||
provider: "searxng",
|
||||
baseUrl: "https://search.example.com",
|
||||
maxResults: 8,
|
||||
@@ -716,30 +692,37 @@ describe("webui API helpers", () => {
|
||||
useJinaReader: false,
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/web-search/update?provider=searxng&base_url=https%3A%2F%2Fsearch.example.com&max_results=8&timeout=45&use_jina_reader=false",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"settings.web_search.update",
|
||||
{
|
||||
provider: "searxng",
|
||||
base_url: "https://search.example.com",
|
||||
max_results: 8,
|
||||
timeout: 45,
|
||||
use_jina_reader: false,
|
||||
},
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes network safety settings updates", async () => {
|
||||
await updateNetworkSafetySettings("tok", {
|
||||
await updateNetworkSafetySettings(mutationTransport, {
|
||||
webuiAllowLocalServiceAccess: false,
|
||||
webuiDefaultAccessMode: "full",
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=full",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"settings.network_safety.update",
|
||||
{
|
||||
webui_allow_local_service_access: false,
|
||||
webui_default_access_mode: "full",
|
||||
},
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes image generation settings updates", async () => {
|
||||
await updateImageGenerationSettings("tok", {
|
||||
await updateImageGenerationSettings(mutationTransport, {
|
||||
enabled: true,
|
||||
provider: "openrouter",
|
||||
model: "openai/gpt-5.4-image-2",
|
||||
@@ -748,11 +731,17 @@ describe("webui API helpers", () => {
|
||||
maxImagesPerTurn: 3,
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/image-generation/update?enabled=true&provider=openrouter&model=openai%2Fgpt-5.4-image-2&default_aspect_ratio=16%3A9&default_image_size=2K&max_images_per_turn=3",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"settings.image_generation.update",
|
||||
{
|
||||
enabled: true,
|
||||
provider: "openrouter",
|
||||
model: "openai/gpt-5.4-image-2",
|
||||
default_aspect_ratio: "16:9",
|
||||
default_image_size: "2K",
|
||||
max_images_per_turn: 3,
|
||||
},
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -774,12 +763,11 @@ describe("webui API helpers", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await runCliAppAction("tok", "install", "gimp");
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/cli-apps/install?name=gimp",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
await runCliAppAction(mutationTransport, "install", "gimp");
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"settings.cli_app.install",
|
||||
{ name: "gimp" },
|
||||
150_000,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -819,20 +807,18 @@ describe("webui API helpers", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await enableNanobotFeature("tok", "matrix");
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/nanobot-features/enable?name=matrix",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
await enableNanobotFeature(mutationTransport, "matrix");
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.feature.enable",
|
||||
{ name: "matrix" },
|
||||
150_000,
|
||||
);
|
||||
|
||||
await disableNanobotFeature("tok", "matrix");
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/nanobot-features/disable?name=matrix",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
await disableNanobotFeature(mutationTransport, "matrix");
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.feature.disable",
|
||||
{ name: "matrix" },
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -843,34 +829,31 @@ describe("webui API helpers", () => {
|
||||
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
||||
);
|
||||
|
||||
await startApiService("tok", { host: "127.0.0.1", port: 8900, timeout: 120 });
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/api-service/start?host=127.0.0.1&port=8900&timeout=120",
|
||||
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
||||
await startApiService(
|
||||
mutationTransport,
|
||||
{ host: "127.0.0.1", port: 8900, timeout: 120 },
|
||||
);
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.api_service.start",
|
||||
{ host: "127.0.0.1", port: 8900, timeout: 120 },
|
||||
150_000,
|
||||
);
|
||||
|
||||
await startApiService(
|
||||
"tok",
|
||||
mutationTransport,
|
||||
{ host: "0.0.0.0", port: 8900, timeout: 120, apiKey: "secret-token" },
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/api-service/start?host=0.0.0.0&port=8900&timeout=120",
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-API-Service-Values": JSON.stringify({ api_key: "secret-token" }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("secret-token"),
|
||||
expect.anything(),
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.api_service.start",
|
||||
{ host: "0.0.0.0", port: 8900, timeout: 120, api_key: "secret-token" },
|
||||
150_000,
|
||||
);
|
||||
|
||||
await stopApiService("tok");
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/api-service/stop",
|
||||
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
||||
await stopApiService(mutationTransport);
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.api_service.stop",
|
||||
{},
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -891,71 +874,46 @@ describe("webui API helpers", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await runMcpPresetAction("tok", "enable", "browserbase", {
|
||||
await runMcpPresetAction(mutationTransport, "enable", "browserbase", {
|
||||
browserbase_api_key: "bb_live_test",
|
||||
});
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/mcp-presets/enable?name=browserbase",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-MCP-Values": JSON.stringify({
|
||||
browserbase_api_key: "bb_live_test",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"settings.mcp.enable",
|
||||
{ name: "browserbase", browserbase_api_key: "bb_live_test" },
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes custom MCP, mcp.json import, and tool allowlist actions", async () => {
|
||||
await saveCustomMcpServer("tok", {
|
||||
const custom = {
|
||||
name: "docs",
|
||||
transport: "stdio",
|
||||
command: "npx",
|
||||
args: '["-y","docs-mcp"]',
|
||||
env: '{"API_KEY":"secret"}',
|
||||
});
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/mcp-presets/custom",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-MCP-Values": JSON.stringify({
|
||||
name: "docs",
|
||||
transport: "stdio",
|
||||
command: "npx",
|
||||
args: '["-y","docs-mcp"]',
|
||||
env: '{"API_KEY":"secret"}',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
await saveCustomMcpServer(mutationTransport, custom);
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.mcp.custom",
|
||||
custom,
|
||||
20_000,
|
||||
);
|
||||
|
||||
await importMcpConfig("tok", '{"mcpServers":{"docs":{"command":"npx"}}}');
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/mcp-presets/import",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-MCP-Values": JSON.stringify({
|
||||
config: '{"mcpServers":{"docs":{"command":"npx"}}}',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
await importMcpConfig(
|
||||
mutationTransport,
|
||||
'{"mcpServers":{"docs":{"command":"npx"}}}',
|
||||
);
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.mcp.import",
|
||||
{ config: '{"mcpServers":{"docs":{"command":"npx"}}}' },
|
||||
20_000,
|
||||
);
|
||||
|
||||
await updateMcpServerTools("tok", "docs", ["search", "fetch"]);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/mcp-presets/tools",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-MCP-Values": JSON.stringify({
|
||||
name: "docs",
|
||||
enabled_tools: ["search", "fetch"],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
await updateMcpServerTools(mutationTransport, "docs", ["search", "fetch"]);
|
||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||
"settings.mcp.tools",
|
||||
{ name: "docs", enabled_tools: ["search", "fetch"] },
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -991,19 +949,12 @@ describe("webui API helpers", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await updateSidebarState("tok", state);
|
||||
const [url, init] = vi.mocked(fetch).mock.calls.at(-1)!;
|
||||
expect(String(url).startsWith("/api/webui/sidebar-state/update?")).toBe(true);
|
||||
expect(init).toEqual(expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}));
|
||||
const encodedState = new URLSearchParams(String(url).split("?", 2)[1]).get("state");
|
||||
expect(encodedState).toBeTruthy();
|
||||
expect(JSON.parse(encodedState ?? "{}")).toMatchObject({
|
||||
pinned_keys: ["websocket:chat-1"],
|
||||
title_overrides: { "websocket:chat-1": "Release" },
|
||||
project_name_overrides: { "/Users/me/nanobot": "Core" },
|
||||
});
|
||||
await updateSidebarState(mutationTransport, state);
|
||||
expect(requestMutation).toHaveBeenCalledWith(
|
||||
"sidebar.update",
|
||||
{ state },
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches workspace project state", async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -19,6 +19,7 @@ const toggleThemeSpy = vi.fn();
|
||||
const updateUrlSpy = vi.fn();
|
||||
const attachSpy = vi.fn();
|
||||
const setSidebarStateSpy = vi.fn();
|
||||
const requestMutationSpy = vi.fn();
|
||||
const discardTemporaryChatSpy = vi.fn();
|
||||
const newTemporaryChatSpy = vi.fn<() => Promise<string>>();
|
||||
const sendMessageSpy = vi.fn();
|
||||
@@ -242,6 +243,7 @@ vi.mock("@/lib/nanobot-client", async (importOriginal) => {
|
||||
newTemporaryChat = newTemporaryChatSpy;
|
||||
attach = attachSpy;
|
||||
setSidebarState = setSidebarStateSpy;
|
||||
requestMutation = requestMutationSpy;
|
||||
discardTemporaryChat = discardTemporaryChatSpy;
|
||||
close = vi.fn();
|
||||
updateUrl = updateUrlSpy;
|
||||
@@ -270,7 +272,8 @@ describe("App layout", () => {
|
||||
getSessionAutomationsSpy.mockReset().mockResolvedValue([]);
|
||||
toggleThemeSpy.mockReset();
|
||||
attachSpy.mockReset();
|
||||
setSidebarStateSpy.mockReset();
|
||||
setSidebarStateSpy.mockReset().mockResolvedValue({});
|
||||
requestMutationSpy.mockReset();
|
||||
discardTemporaryChatSpy.mockReset();
|
||||
let temporaryChatCounter = 0;
|
||||
newTemporaryChatSpy.mockImplementation(async () => (
|
||||
@@ -304,6 +307,7 @@ describe("App layout", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -314,11 +318,68 @@ describe("App layout", () => {
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByText("Authentication required")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Invalid password. Try again.")).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("heading", { level: 1, name: "Password" }))
|
||||
.toBeInTheDocument();
|
||||
const password = screen.getByLabelText("Password");
|
||||
expect(password).toHaveAttribute(
|
||||
"autocomplete",
|
||||
"current-password",
|
||||
);
|
||||
expect(password).not.toHaveAttribute("placeholder");
|
||||
expect(screen.queryByText("Authentication required")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Incorrect password. Try again."),
|
||||
).not.toBeInTheDocument();
|
||||
expect(connectSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("toggles password visibility without changing the password", async () => {
|
||||
vi.mocked(fetchBootstrap).mockRejectedValueOnce(
|
||||
new Error("bootstrap failed: HTTP 401"),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<App />);
|
||||
|
||||
const password = await screen.findByLabelText("Password");
|
||||
await user.type(password, "correct horse battery staple");
|
||||
expect(password).toHaveAttribute("type", "password");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Show password" }));
|
||||
|
||||
expect(password).toHaveAttribute("type", "text");
|
||||
expect(password).toHaveValue("correct horse battery staple");
|
||||
const hidePassword = screen.getByRole("button", { name: "Hide password" });
|
||||
expect(hidePassword).toHaveFocus();
|
||||
|
||||
await user.click(hidePassword);
|
||||
|
||||
expect(password).toHaveAttribute("type", "password");
|
||||
expect(password).toHaveValue("correct horse battery staple");
|
||||
expect(screen.getByRole("button", { name: "Show password" })).toHaveFocus();
|
||||
});
|
||||
|
||||
it("explains and focuses an empty auth password", async () => {
|
||||
vi.mocked(fetchBootstrap).mockRejectedValue(
|
||||
new Error("bootstrap failed: HTTP 401"),
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
|
||||
const password = await screen.findByLabelText("Password");
|
||||
const connect = screen.getByRole("button", { name: "Connect" });
|
||||
expect(connect).toBeEnabled();
|
||||
fireEvent.click(connect);
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Enter your password.",
|
||||
);
|
||||
expect(password).toHaveAttribute("aria-invalid", "true");
|
||||
expect(password).toHaveAttribute("aria-describedby", "webui-auth-error");
|
||||
expect(password).toHaveFocus();
|
||||
expect(fetchBootstrap).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows the auth form when bootstrap does not issue an API token", async () => {
|
||||
vi.mocked(fetchBootstrap).mockRejectedValueOnce(
|
||||
new BootstrapAuthRequiredError(
|
||||
@@ -328,8 +389,11 @@ describe("App layout", () => {
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByText("Authentication required")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Invalid password. Try again.")).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("heading", { level: 1, name: "Password" }))
|
||||
.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Incorrect password. Try again."),
|
||||
).not.toBeInTheDocument();
|
||||
expect(connectSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -340,11 +404,16 @@ describe("App layout", () => {
|
||||
|
||||
render(<App />);
|
||||
|
||||
const password = await screen.findByPlaceholderText("Password");
|
||||
const password = await screen.findByLabelText("Password");
|
||||
fireEvent.change(password, { target: { value: "wrong-password" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
|
||||
|
||||
expect(await screen.findByText("Invalid password. Try again.")).toBeInTheDocument();
|
||||
const retryPassword = await screen.findByLabelText("Password");
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Incorrect password. Try again.",
|
||||
);
|
||||
expect(retryPassword).toHaveAttribute("aria-invalid", "true");
|
||||
expect(retryPassword).toHaveFocus();
|
||||
expect(fetchBootstrap).toHaveBeenLastCalledWith("", "wrong-password");
|
||||
expect(connectSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -364,6 +433,21 @@ describe("App layout", () => {
|
||||
expect(asideClassNames.some((cls) => cls.includes("lg:block"))).toBe(true);
|
||||
});
|
||||
|
||||
it("uses one main landmark and a page heading in desktop settings", async () => {
|
||||
mockFetchRoutes({ "/api/settings": baseSettingsPayload() });
|
||||
const { container } = render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" }));
|
||||
|
||||
expect(
|
||||
await screen.findByRole("navigation", { name: "Settings sections" }),
|
||||
).toBeInTheDocument();
|
||||
expect(container.querySelectorAll("main")).toHaveLength(1);
|
||||
expect(screen.getByRole("heading", { level: 1, name: "Settings" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("places Automations after Skills in the main sidebar", async () => {
|
||||
render(<App />);
|
||||
|
||||
@@ -651,6 +735,57 @@ describe("App layout", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the first message when the gateway rejects a project", async () => {
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
createChatSpy.mockRejectedValueOnce(
|
||||
new Error("workspace_scope_rejected:project_path must be an existing directory"),
|
||||
);
|
||||
mockFetchRoutes({
|
||||
"/api/workspaces": {
|
||||
schema_version: 1,
|
||||
default_access_mode: "restricted",
|
||||
default_scope: {
|
||||
project_path: "C:\\workspace",
|
||||
project_name: "workspace",
|
||||
access_mode: "restricted",
|
||||
restrict_to_workspace: true,
|
||||
},
|
||||
controls: { can_change_project: true, can_use_full_access: true },
|
||||
},
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Choose project" }));
|
||||
fireEvent.change(await screen.findByLabelText("Paste path"), {
|
||||
target: { value: "C:\\missing-project" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Use Path" }));
|
||||
|
||||
const message = screen.getByLabelText("Message input");
|
||||
fireEvent.change(message, { target: { value: "keep this first message" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
await waitFor(() => expect(createChatSpy).toHaveBeenCalledTimes(1));
|
||||
expect(message).toHaveValue("keep this first message");
|
||||
const projectButton = screen.getByRole("button", { name: "Choose project" });
|
||||
await waitFor(() => expect(projectButton).toHaveFocus());
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"The gateway rejected this project or access mode. Choose an existing project or a different access mode, then try again.",
|
||||
);
|
||||
fireEvent.click(projectButton);
|
||||
const projectPath = await screen.findByLabelText("Paste path");
|
||||
expect(projectPath).toHaveValue("C:\\missing-project");
|
||||
expect(projectPath).toHaveAttribute("aria-invalid", "true");
|
||||
expect(projectPath).toHaveFocus();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"The gateway rejected this project or access mode. Choose an existing project or a different access mode, then try again.",
|
||||
);
|
||||
expect(window.location.hash).toBe("");
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it("restores the Settings route after a restart fallback hash", async () => {
|
||||
localStorage.setItem("nanobot-webui.restartStartedAt", String(Date.now()));
|
||||
localStorage.setItem("nanobot-webui.restartRoute", "#/settings?section=channels");
|
||||
@@ -745,7 +880,8 @@ describe("App layout", () => {
|
||||
}],
|
||||
raw_markdown: "---\nname: github\n---\nUse GitHub CLI.",
|
||||
},
|
||||
"/api/webui/skills/update?name=github&enabled=false": {
|
||||
});
|
||||
requestMutationSpy.mockResolvedValueOnce({
|
||||
skills: [
|
||||
{
|
||||
name: "cron",
|
||||
@@ -773,12 +909,7 @@ describe("App layout", () => {
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
last_action: {
|
||||
name: "github",
|
||||
enabled: false,
|
||||
deleted: false,
|
||||
},
|
||||
},
|
||||
last_action: { name: "github", enabled: false, deleted: false },
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
@@ -878,14 +1009,10 @@ describe("App layout", () => {
|
||||
},
|
||||
raw_markdown: "---\nname: custom-skill\n---\nWorkspace instructions.",
|
||||
},
|
||||
"/api/webui/skills/delete?name=custom-skill": {
|
||||
});
|
||||
requestMutationSpy.mockResolvedValueOnce({
|
||||
skills: [],
|
||||
last_action: {
|
||||
name: "custom-skill",
|
||||
enabled: false,
|
||||
deleted: true,
|
||||
},
|
||||
},
|
||||
last_action: { name: "custom-skill", enabled: false, deleted: true },
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
@@ -1017,9 +1144,8 @@ describe("App layout", () => {
|
||||
"/api/webui/skills/trends?id=acme%2Fagent-skills%2Freact-testing": {
|
||||
trends: { "acme/agent-skills/react-testing": [] },
|
||||
},
|
||||
"/api/webui/skills/install?provider=skills_sh&source=acme%2Fagent-skills&skill=react-testing":
|
||||
() => pendingInstall,
|
||||
});
|
||||
requestMutationSpy.mockImplementationOnce(() => pendingInstall);
|
||||
|
||||
render(<App />);
|
||||
|
||||
@@ -1067,11 +1193,14 @@ describe("App layout", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Install skill" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/skills/install?provider=skills_sh&source=acme%2Fagent-skills&skill=react-testing",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: expect.any(String) },
|
||||
}),
|
||||
expect(requestMutationSpy).toHaveBeenCalledWith(
|
||||
"skill.install",
|
||||
{
|
||||
provider: "skills_sh",
|
||||
source: "acme/agent-skills",
|
||||
skill: "react-testing",
|
||||
},
|
||||
150_000,
|
||||
);
|
||||
});
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Installed" }));
|
||||
@@ -1229,14 +1358,12 @@ describe("App layout", () => {
|
||||
mockFetchRoutes({
|
||||
"/api/settings": baseSettingsPayload(),
|
||||
"/api/webui/automations": { jobs: [pastOneShot] },
|
||||
"/api/webui/automations/update?id=past-one-shot": {
|
||||
jobs: [
|
||||
{
|
||||
});
|
||||
requestMutationSpy.mockResolvedValueOnce({
|
||||
jobs: [{
|
||||
...pastOneShot,
|
||||
payload: { ...pastOneShot.payload, message: "Updated one-shot message" },
|
||||
},
|
||||
],
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
@@ -1262,19 +1389,17 @@ describe("App layout", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/automations/update?id=past-one-shot",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
const updateCall = vi.mocked(fetch).mock.calls.find(
|
||||
([url]) => String(url) === "/api/webui/automations/update?id=past-one-shot",
|
||||
);
|
||||
expect(updateCall).toBeTruthy();
|
||||
const headers = updateCall?.[1]?.headers as Record<string, string>;
|
||||
expect(JSON.parse(decodeURIComponent(headers["X-Nanobot-Automation-Values"]))).toEqual({
|
||||
expect(requestMutationSpy).toHaveBeenCalledWith(
|
||||
"automation.update",
|
||||
{
|
||||
id: "past-one-shot",
|
||||
values: {
|
||||
name: "Past one-shot",
|
||||
message: "Updated one-shot message",
|
||||
},
|
||||
},
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1697,6 +1822,9 @@ describe("App layout", () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
act(() => {
|
||||
statusHandlers.forEach((handler) => handler("open"));
|
||||
});
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
await waitFor(() =>
|
||||
expect(within(sidebar).getByText("Pinned")).toBeInTheDocument(),
|
||||
@@ -2252,7 +2380,7 @@ describe("App layout", () => {
|
||||
const searchButton = within(sidebar).getByRole("button", { name: "Search" });
|
||||
const appsButton = within(sidebar).getByRole("button", { name: "Apps" });
|
||||
expect(searchButton.compareDocumentPosition(appsButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" }));
|
||||
await user.click(within(sidebar).getByRole("button", { name: "Settings" }));
|
||||
|
||||
expect(
|
||||
await screen.findByRole("navigation", { name: "Settings sections" }),
|
||||
@@ -2449,17 +2577,14 @@ describe("App layout", () => {
|
||||
mockFetchRoutes({
|
||||
"/api/settings": initialSettings,
|
||||
});
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
window.history.replaceState(null, "", "/#/settings?section=runtime");
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByText("UTC")).toBeInTheDocument();
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(([input]) =>
|
||||
String(input).startsWith("/api/settings/update?timezone="),
|
||||
),
|
||||
).toHaveLength(0);
|
||||
requestMutationSpy.mock.calls.some(([action]) => action === "settings.agent.update"),
|
||||
).toBe(false);
|
||||
expect(screen.queryByRole("heading", { name: "Regional" })).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Used for schedules and time-aware replies."),
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"name": "reasoning_then_streamed_answer",
|
||||
"chat_id": "fixture-reasoning",
|
||||
"initial_messages": [
|
||||
{
|
||||
"id": "fixture-user-reasoning",
|
||||
"role": "user",
|
||||
"content": "Explain event projection.",
|
||||
"turnId": "turn-reasoning",
|
||||
"turnPhase": "user",
|
||||
"turnSeq": 1,
|
||||
"createdAt": 1700000000000
|
||||
}
|
||||
],
|
||||
"live_events": [
|
||||
{
|
||||
"event": "reasoning_delta",
|
||||
"chat_id": "fixture-reasoning",
|
||||
"text": "Compare ",
|
||||
"turn_id": "turn-reasoning",
|
||||
"turn_phase": "reasoning",
|
||||
"turn_seq": 2
|
||||
},
|
||||
{
|
||||
"event": "reasoning_delta",
|
||||
"chat_id": "fixture-reasoning",
|
||||
"text": "state.",
|
||||
"turn_id": "turn-reasoning",
|
||||
"turn_phase": "reasoning",
|
||||
"turn_seq": 3
|
||||
},
|
||||
{
|
||||
"event": "reasoning_end",
|
||||
"chat_id": "fixture-reasoning",
|
||||
"turn_id": "turn-reasoning",
|
||||
"turn_phase": "reasoning",
|
||||
"turn_seq": 4
|
||||
},
|
||||
{
|
||||
"event": "delta",
|
||||
"chat_id": "fixture-reasoning",
|
||||
"text": "Use one ",
|
||||
"turn_id": "turn-reasoning",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 5
|
||||
},
|
||||
{
|
||||
"event": "delta",
|
||||
"chat_id": "fixture-reasoning",
|
||||
"text": "fold.",
|
||||
"turn_id": "turn-reasoning",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 6
|
||||
},
|
||||
{
|
||||
"event": "stream_end",
|
||||
"chat_id": "fixture-reasoning",
|
||||
"turn_id": "turn-reasoning",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 7
|
||||
},
|
||||
{
|
||||
"event": "turn_end",
|
||||
"chat_id": "fixture-reasoning",
|
||||
"latency_ms": 42,
|
||||
"turn_id": "turn-reasoning",
|
||||
"turn_phase": "complete",
|
||||
"turn_seq": 8
|
||||
}
|
||||
],
|
||||
"transcript": [
|
||||
{
|
||||
"event": "user",
|
||||
"chat_id": "fixture-reasoning",
|
||||
"text": "Explain event projection.",
|
||||
"turn_id": "turn-reasoning",
|
||||
"turn_phase": "user",
|
||||
"turn_seq": 1,
|
||||
"created_at_ms": 1700000000000
|
||||
},
|
||||
{
|
||||
"event": "reasoning_delta",
|
||||
"chat_id": "fixture-reasoning",
|
||||
"text": "Compare ",
|
||||
"turn_id": "turn-reasoning",
|
||||
"turn_phase": "reasoning",
|
||||
"turn_seq": 2
|
||||
},
|
||||
{
|
||||
"event": "reasoning_delta",
|
||||
"chat_id": "fixture-reasoning",
|
||||
"text": "state.",
|
||||
"turn_id": "turn-reasoning",
|
||||
"turn_phase": "reasoning",
|
||||
"turn_seq": 3
|
||||
},
|
||||
{
|
||||
"event": "reasoning_end",
|
||||
"chat_id": "fixture-reasoning",
|
||||
"turn_id": "turn-reasoning",
|
||||
"turn_phase": "reasoning",
|
||||
"turn_seq": 4
|
||||
},
|
||||
{
|
||||
"event": "delta",
|
||||
"chat_id": "fixture-reasoning",
|
||||
"text": "Use one ",
|
||||
"turn_id": "turn-reasoning",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 5
|
||||
},
|
||||
{
|
||||
"event": "delta",
|
||||
"chat_id": "fixture-reasoning",
|
||||
"text": "fold.",
|
||||
"turn_id": "turn-reasoning",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 6
|
||||
},
|
||||
{
|
||||
"event": "stream_end",
|
||||
"chat_id": "fixture-reasoning",
|
||||
"turn_id": "turn-reasoning",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 7
|
||||
},
|
||||
{
|
||||
"event": "turn_end",
|
||||
"chat_id": "fixture-reasoning",
|
||||
"latency_ms": 42,
|
||||
"turn_id": "turn-reasoning",
|
||||
"turn_phase": "complete",
|
||||
"turn_seq": 8
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Explain event projection.",
|
||||
"turnId": "turn-reasoning",
|
||||
"turnPhase": "user",
|
||||
"turnSeq": 1
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Use one fold.",
|
||||
"reasoning": "Compare state.",
|
||||
"activitySegmentId": "segment-1",
|
||||
"latencyMs": 42,
|
||||
"turnId": "turn-reasoning",
|
||||
"turnPhase": "answer",
|
||||
"turnSeq": 6
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "length_recovery_merges_answer_segments",
|
||||
"chat_id": "fixture-length",
|
||||
"initial_messages": [
|
||||
{
|
||||
"id": "fixture-user-length",
|
||||
"role": "user",
|
||||
"content": "Continue after the limit.",
|
||||
"turnId": "turn-length",
|
||||
"turnPhase": "user",
|
||||
"turnSeq": 1,
|
||||
"createdAt": 1700000001000
|
||||
}
|
||||
],
|
||||
"live_events": [
|
||||
{
|
||||
"event": "delta",
|
||||
"chat_id": "fixture-length",
|
||||
"text": "first ",
|
||||
"turn_id": "turn-length",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 2
|
||||
},
|
||||
{
|
||||
"event": "stream_end",
|
||||
"chat_id": "fixture-length",
|
||||
"text": "first ",
|
||||
"resuming": true,
|
||||
"merge_next": true,
|
||||
"turn_id": "turn-length",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 3
|
||||
},
|
||||
{
|
||||
"event": "delta",
|
||||
"chat_id": "fixture-length",
|
||||
"text": "second",
|
||||
"turn_id": "turn-length",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 4
|
||||
},
|
||||
{
|
||||
"event": "stream_end",
|
||||
"chat_id": "fixture-length",
|
||||
"turn_id": "turn-length",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 5
|
||||
},
|
||||
{
|
||||
"event": "turn_end",
|
||||
"chat_id": "fixture-length",
|
||||
"latency_ms": 17,
|
||||
"turn_id": "turn-length",
|
||||
"turn_phase": "complete",
|
||||
"turn_seq": 6
|
||||
}
|
||||
],
|
||||
"transcript": [
|
||||
{
|
||||
"event": "user",
|
||||
"chat_id": "fixture-length",
|
||||
"text": "Continue after the limit.",
|
||||
"turn_id": "turn-length",
|
||||
"turn_phase": "user",
|
||||
"turn_seq": 1,
|
||||
"created_at_ms": 1700000001000
|
||||
},
|
||||
{
|
||||
"event": "delta",
|
||||
"chat_id": "fixture-length",
|
||||
"text": "first ",
|
||||
"turn_id": "turn-length",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 2
|
||||
},
|
||||
{
|
||||
"event": "stream_end",
|
||||
"chat_id": "fixture-length",
|
||||
"text": "first ",
|
||||
"resuming": true,
|
||||
"merge_next": true,
|
||||
"turn_id": "turn-length",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 3
|
||||
},
|
||||
{
|
||||
"event": "delta",
|
||||
"chat_id": "fixture-length",
|
||||
"text": "second",
|
||||
"turn_id": "turn-length",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 4
|
||||
},
|
||||
{
|
||||
"event": "stream_end",
|
||||
"chat_id": "fixture-length",
|
||||
"turn_id": "turn-length",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 5
|
||||
},
|
||||
{
|
||||
"event": "turn_end",
|
||||
"chat_id": "fixture-length",
|
||||
"latency_ms": 17,
|
||||
"turn_id": "turn-length",
|
||||
"turn_phase": "complete",
|
||||
"turn_seq": 6
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Continue after the limit.",
|
||||
"turnId": "turn-length",
|
||||
"turnPhase": "user",
|
||||
"turnSeq": 1
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "first second",
|
||||
"latencyMs": 17,
|
||||
"turnId": "turn-length",
|
||||
"turnPhase": "answer",
|
||||
"turnSeq": 4
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "tool_activity_then_complete_answer",
|
||||
"chat_id": "fixture-activity",
|
||||
"initial_messages": [
|
||||
{
|
||||
"id": "fixture-user-activity",
|
||||
"role": "user",
|
||||
"content": "Inspect the projection code.",
|
||||
"turnId": "turn-activity",
|
||||
"turnPhase": "user",
|
||||
"turnSeq": 1,
|
||||
"createdAt": 1700000002000
|
||||
}
|
||||
],
|
||||
"live_events": [
|
||||
{
|
||||
"event": "message",
|
||||
"chat_id": "fixture-activity",
|
||||
"kind": "tool_hint",
|
||||
"text": "search projection helpers",
|
||||
"turn_id": "turn-activity",
|
||||
"turn_phase": "activity",
|
||||
"turn_seq": 2
|
||||
},
|
||||
{
|
||||
"event": "reasoning_delta",
|
||||
"chat_id": "fixture-activity",
|
||||
"text": "Review results.",
|
||||
"turn_id": "turn-activity",
|
||||
"turn_phase": "reasoning",
|
||||
"turn_seq": 4
|
||||
},
|
||||
{
|
||||
"event": "reasoning_end",
|
||||
"chat_id": "fixture-activity",
|
||||
"turn_id": "turn-activity",
|
||||
"turn_phase": "reasoning",
|
||||
"turn_seq": 5
|
||||
},
|
||||
{
|
||||
"event": "message",
|
||||
"chat_id": "fixture-activity",
|
||||
"text": "Projection matches.",
|
||||
"turn_id": "turn-activity",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 6
|
||||
},
|
||||
{
|
||||
"event": "turn_end",
|
||||
"chat_id": "fixture-activity",
|
||||
"latency_ms": 8,
|
||||
"turn_id": "turn-activity",
|
||||
"turn_phase": "complete",
|
||||
"turn_seq": 7
|
||||
}
|
||||
],
|
||||
"transcript": [
|
||||
{
|
||||
"event": "user",
|
||||
"chat_id": "fixture-activity",
|
||||
"text": "Inspect the projection code.",
|
||||
"turn_id": "turn-activity",
|
||||
"turn_phase": "user",
|
||||
"turn_seq": 1,
|
||||
"created_at_ms": 1700000002000
|
||||
},
|
||||
{
|
||||
"event": "message",
|
||||
"chat_id": "fixture-activity",
|
||||
"kind": "tool_hint",
|
||||
"text": "search projection helpers",
|
||||
"turn_id": "turn-activity",
|
||||
"turn_phase": "activity",
|
||||
"turn_seq": 2
|
||||
},
|
||||
{
|
||||
"event": "reasoning_delta",
|
||||
"chat_id": "fixture-activity",
|
||||
"text": "Review results.",
|
||||
"turn_id": "turn-activity",
|
||||
"turn_phase": "reasoning",
|
||||
"turn_seq": 4
|
||||
},
|
||||
{
|
||||
"event": "reasoning_end",
|
||||
"chat_id": "fixture-activity",
|
||||
"turn_id": "turn-activity",
|
||||
"turn_phase": "reasoning",
|
||||
"turn_seq": 5
|
||||
},
|
||||
{
|
||||
"event": "message",
|
||||
"chat_id": "fixture-activity",
|
||||
"text": "Projection matches.",
|
||||
"turn_id": "turn-activity",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 6
|
||||
},
|
||||
{
|
||||
"event": "turn_end",
|
||||
"chat_id": "fixture-activity",
|
||||
"latency_ms": 8,
|
||||
"turn_id": "turn-activity",
|
||||
"turn_phase": "complete",
|
||||
"turn_seq": 7
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Inspect the projection code.",
|
||||
"turnId": "turn-activity",
|
||||
"turnPhase": "user",
|
||||
"turnSeq": 1
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": "search projection helpers",
|
||||
"kind": "trace",
|
||||
"traces": [
|
||||
"search projection helpers"
|
||||
],
|
||||
"activitySegmentId": "segment-1",
|
||||
"turnId": "turn-activity",
|
||||
"turnPhase": "activity",
|
||||
"turnSeq": 2
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Projection matches.",
|
||||
"reasoning": "Review results.",
|
||||
"activitySegmentId": "segment-1",
|
||||
"latencyMs": 8,
|
||||
"turnId": "turn-activity",
|
||||
"turnPhase": "answer",
|
||||
"turnSeq": 6
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "file_edit_lifecycle_merges_by_call_and_path",
|
||||
"chat_id": "fixture-file-edit",
|
||||
"initial_messages": [
|
||||
{
|
||||
"id": "fixture-user-file-edit",
|
||||
"role": "user",
|
||||
"content": "Update app.py.",
|
||||
"turnId": "turn-file-edit",
|
||||
"turnPhase": "user",
|
||||
"turnSeq": 1,
|
||||
"createdAt": 1700000003000
|
||||
}
|
||||
],
|
||||
"live_events": [
|
||||
{
|
||||
"event": "file_edit",
|
||||
"chat_id": "fixture-file-edit",
|
||||
"edits": [
|
||||
{
|
||||
"version": 1,
|
||||
"call_id": "call-edit",
|
||||
"tool": "edit_file",
|
||||
"path": "app.py",
|
||||
"phase": "start",
|
||||
"added": 0,
|
||||
"deleted": 0,
|
||||
"status": "editing"
|
||||
}
|
||||
],
|
||||
"turn_id": "turn-file-edit",
|
||||
"turn_phase": "activity",
|
||||
"turn_seq": 2
|
||||
},
|
||||
{
|
||||
"event": "file_edit",
|
||||
"chat_id": "fixture-file-edit",
|
||||
"edits": [
|
||||
{
|
||||
"version": 1,
|
||||
"call_id": "call-edit",
|
||||
"tool": "edit_file",
|
||||
"path": "app.py",
|
||||
"phase": "end",
|
||||
"added": 3,
|
||||
"deleted": 1,
|
||||
"status": "done"
|
||||
}
|
||||
],
|
||||
"turn_id": "turn-file-edit",
|
||||
"turn_phase": "activity",
|
||||
"turn_seq": 3
|
||||
},
|
||||
{
|
||||
"event": "message",
|
||||
"chat_id": "fixture-file-edit",
|
||||
"text": "Updated app.py.",
|
||||
"turn_id": "turn-file-edit",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 4
|
||||
},
|
||||
{
|
||||
"event": "turn_end",
|
||||
"chat_id": "fixture-file-edit",
|
||||
"latency_ms": 9,
|
||||
"turn_id": "turn-file-edit",
|
||||
"turn_phase": "complete",
|
||||
"turn_seq": 5
|
||||
}
|
||||
],
|
||||
"transcript": [
|
||||
{
|
||||
"event": "user",
|
||||
"chat_id": "fixture-file-edit",
|
||||
"text": "Update app.py.",
|
||||
"turn_id": "turn-file-edit",
|
||||
"turn_phase": "user",
|
||||
"turn_seq": 1,
|
||||
"created_at_ms": 1700000003000
|
||||
},
|
||||
{
|
||||
"event": "file_edit",
|
||||
"chat_id": "fixture-file-edit",
|
||||
"edits": [
|
||||
{
|
||||
"version": 1,
|
||||
"call_id": "call-edit",
|
||||
"tool": "edit_file",
|
||||
"path": "app.py",
|
||||
"phase": "start",
|
||||
"added": 0,
|
||||
"deleted": 0,
|
||||
"status": "editing"
|
||||
}
|
||||
],
|
||||
"turn_id": "turn-file-edit",
|
||||
"turn_phase": "activity",
|
||||
"turn_seq": 2
|
||||
},
|
||||
{
|
||||
"event": "file_edit",
|
||||
"chat_id": "fixture-file-edit",
|
||||
"edits": [
|
||||
{
|
||||
"version": 1,
|
||||
"call_id": "call-edit",
|
||||
"tool": "edit_file",
|
||||
"path": "app.py",
|
||||
"phase": "end",
|
||||
"added": 3,
|
||||
"deleted": 1,
|
||||
"status": "done"
|
||||
}
|
||||
],
|
||||
"turn_id": "turn-file-edit",
|
||||
"turn_phase": "activity",
|
||||
"turn_seq": 3
|
||||
},
|
||||
{
|
||||
"event": "message",
|
||||
"chat_id": "fixture-file-edit",
|
||||
"text": "Updated app.py.",
|
||||
"turn_id": "turn-file-edit",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 4
|
||||
},
|
||||
{
|
||||
"event": "turn_end",
|
||||
"chat_id": "fixture-file-edit",
|
||||
"latency_ms": 9,
|
||||
"turn_id": "turn-file-edit",
|
||||
"turn_phase": "complete",
|
||||
"turn_seq": 5
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Update app.py.",
|
||||
"turnId": "turn-file-edit",
|
||||
"turnPhase": "user",
|
||||
"turnSeq": 1
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": "",
|
||||
"kind": "trace",
|
||||
"traces": [],
|
||||
"fileEdits": [
|
||||
{
|
||||
"version": 1,
|
||||
"call_id": "call-edit",
|
||||
"tool": "edit_file",
|
||||
"path": "app.py",
|
||||
"phase": "end",
|
||||
"added": 3,
|
||||
"deleted": 1,
|
||||
"status": "done"
|
||||
}
|
||||
],
|
||||
"activitySegmentId": "segment-1",
|
||||
"turnId": "turn-file-edit",
|
||||
"turnPhase": "activity",
|
||||
"turnSeq": 3
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Updated app.py.",
|
||||
"latencyMs": 9,
|
||||
"turnId": "turn-file-edit",
|
||||
"turnPhase": "answer",
|
||||
"turnSeq": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -71,6 +71,122 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("NanobotClient", () => {
|
||||
it("correlates successful WebUI mutation replies by request id", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
const socket = lastSocket();
|
||||
socket.fakeOpen();
|
||||
|
||||
const pending = client.requestMutation<{ saved: boolean }>(
|
||||
"settings.provider.update",
|
||||
{ provider: "openrouter", apiKey: "secret" },
|
||||
);
|
||||
const frame = JSON.parse(socket.sent.at(-1) as string);
|
||||
expect(frame).toMatchObject({
|
||||
type: "webui_request",
|
||||
action: "settings.provider.update",
|
||||
payload: { provider: "openrouter", apiKey: "secret" },
|
||||
});
|
||||
expect(frame.request_id).toEqual(expect.any(String));
|
||||
|
||||
socket.fakeMessage({
|
||||
event: "webui_response",
|
||||
request_id: frame.request_id,
|
||||
ok: true,
|
||||
result: { saved: true },
|
||||
});
|
||||
|
||||
await expect(pending).resolves.toEqual({ saved: true });
|
||||
});
|
||||
|
||||
it("surfaces correlated WebUI mutation errors with status", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
const socket = lastSocket();
|
||||
socket.fakeOpen();
|
||||
|
||||
const pending = client.requestMutation("settings.channel.configure", {});
|
||||
const requestId = JSON.parse(socket.sent.at(-1) as string).request_id;
|
||||
socket.fakeMessage({
|
||||
event: "webui_response",
|
||||
request_id: requestId,
|
||||
ok: false,
|
||||
error: { status: 400, message: "missing channel name" },
|
||||
});
|
||||
|
||||
await expect(pending).rejects.toMatchObject({
|
||||
status: 400,
|
||||
message: "missing channel name",
|
||||
});
|
||||
});
|
||||
|
||||
it("times out WebUI mutations without replaying them", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
const socket = lastSocket();
|
||||
socket.fakeOpen();
|
||||
|
||||
const pending = expect(
|
||||
client.requestMutation("skill.install", { skill: "docs" }, 25),
|
||||
).rejects.toMatchObject({
|
||||
status: 504,
|
||||
message: "WebUI request timed out after 25ms",
|
||||
});
|
||||
expect(socket.sent).toHaveLength(1);
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
await pending;
|
||||
expect(socket.sent).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects in-flight WebUI mutations when the socket closes", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
const socket = lastSocket();
|
||||
socket.fakeOpen();
|
||||
|
||||
const pending = client.requestMutation("session.delete", {
|
||||
key: "websocket:chat-1",
|
||||
});
|
||||
socket.fakeCloseWithCode(1006);
|
||||
|
||||
await expect(pending).rejects.toMatchObject({
|
||||
status: 503,
|
||||
message: "Socket closed before WebUI response",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not queue WebUI mutations before the authenticated socket opens", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
|
||||
await expect(client.requestMutation("settings.agent.update", {})).rejects.toMatchObject({
|
||||
status: 503,
|
||||
message: "WebUI connection is not open",
|
||||
});
|
||||
expect(lastSocket().sent).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps temporary chats out of attachment and reconnect state", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
@@ -1071,7 +1187,7 @@ describe("NanobotClient", () => {
|
||||
expect(client.hasUnsettledRun("chat-scope-control")).toBe(true);
|
||||
});
|
||||
|
||||
it("sends large sidebar ordering state outside the HTTP request line", () => {
|
||||
it("sends large sidebar ordering state as a correlated WebUI request", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
@@ -1102,11 +1218,29 @@ describe("NanobotClient", () => {
|
||||
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.setSidebarState(state);
|
||||
const pending = client.setSidebarState(state);
|
||||
|
||||
const [serialized] = lastSocket().sent;
|
||||
expect(new TextEncoder().encode(serialized).byteLength).toBeGreaterThan(8_192);
|
||||
expect(JSON.parse(serialized)).toEqual({ type: "set_sidebar_state", state });
|
||||
const request = JSON.parse(serialized) as {
|
||||
type: string;
|
||||
request_id: string;
|
||||
action: string;
|
||||
payload: { state: SidebarStatePayload };
|
||||
};
|
||||
expect(request).toEqual({
|
||||
type: "webui_request",
|
||||
request_id: expect.any(String),
|
||||
action: "sidebar.update",
|
||||
payload: { state },
|
||||
});
|
||||
lastSocket().fakeMessage({
|
||||
event: "webui_response",
|
||||
request_id: request.request_id,
|
||||
ok: true,
|
||||
result: state,
|
||||
});
|
||||
await expect(pending).resolves.toEqual(state);
|
||||
});
|
||||
|
||||
it("does not correlate a new-chat scope rejection to an unrelated sent turn", async () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -350,6 +350,30 @@ function longPress(badge: HTMLElement, pointerId = 7) {
|
||||
}
|
||||
|
||||
describe("ThreadComposer", () => {
|
||||
it("locks an async send and keeps the draft when it is rejected", async () => {
|
||||
let resolveSend!: (accepted: boolean) => void;
|
||||
const onSend = vi.fn(() => new Promise<boolean>((resolve) => {
|
||||
resolveSend = resolve;
|
||||
}));
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={onSend}
|
||||
placeholder="Type your message..."
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Message input");
|
||||
fireEvent.change(input, { target: { value: "keep this pending draft" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
expect(input).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Send message" })).toBeDisabled();
|
||||
await act(async () => resolveSend(false));
|
||||
|
||||
await waitFor(() => expect(input).toBeEnabled());
|
||||
expect(input).toHaveValue("keep this pending draft");
|
||||
});
|
||||
|
||||
it("dismisses the touch keyboard after a successful send", async () => {
|
||||
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
|
||||
matches: query === "(hover: none) and (pointer: coarse)",
|
||||
@@ -651,6 +675,49 @@ describe("ThreadComposer", () => {
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("explains the HTTPS requirement for voice input on an insecure origin", async () => {
|
||||
const { getUserMedia } = mockVoiceRecorder();
|
||||
vi.stubGlobal("isSecureContext", false);
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
onTranscribeAudio={vi.fn(async () => "unused")}
|
||||
placeholder="Type your message..."
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Voice input" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Chrome and other browsers block microphone access on remote HTTP pages for security. "
|
||||
+ "Open this WebUI over HTTPS to use voice input.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("alert")).toHaveClass("max-h-24");
|
||||
expect(getUserMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the unsupported-browser error for secure origins without recording support", async () => {
|
||||
const { getUserMedia } = mockVoiceRecorder();
|
||||
vi.stubGlobal("isSecureContext", true);
|
||||
vi.stubGlobal("MediaRecorder", undefined);
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
onTranscribeAudio={vi.fn(async () => "unused")}
|
||||
placeholder="Type your message..."
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Voice input" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("Voice input is not supported in this browser."),
|
||||
).toBeInTheDocument();
|
||||
expect(getUserMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("converts voice recordings to wav for Xiaomi MiMo transcription", async () => {
|
||||
mockVoiceRecorder(new Blob([new Uint8Array([1, 2, 3, 4])], { type: "audio/webm" }));
|
||||
const { decodeAudioData } = mockVoiceAudioInput(
|
||||
@@ -1070,6 +1137,36 @@ describe("ThreadComposer", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Windows", "D:\\Users\\test\\.nanobot\\workspace", "D:\\path\\to\\project"],
|
||||
["macOS", "/Users/test/.nanobot/workspace", "/Users/name/project"],
|
||||
["Linux", "/home/test/.nanobot/workspace", "/home/name/project"],
|
||||
])("uses a %s path example for the project picker", async (_, projectPath, placeholder) => {
|
||||
const user = userEvent.setup();
|
||||
const defaultScope = {
|
||||
project_path: projectPath,
|
||||
project_name: "workspace",
|
||||
access_mode: "restricted" as const,
|
||||
restrict_to_workspace: true,
|
||||
};
|
||||
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
placeholder="Ask anything..."
|
||||
variant="hero"
|
||||
workspaceScope={defaultScope}
|
||||
workspaceDefaultScope={defaultScope}
|
||||
workspaceControls={{ can_change_project: true, can_use_full_access: true }}
|
||||
onWorkspaceScopeChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Choose project" }));
|
||||
|
||||
expect(await screen.findByLabelText("Paste path")).toHaveAttribute("placeholder", placeholder);
|
||||
});
|
||||
|
||||
it("slides project controls closed without offering a compact replacement", () => {
|
||||
const defaultScope = {
|
||||
project_path: "/Users/test/.nanobot/workspace",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
@@ -56,7 +56,9 @@ describe("ThreadMessages", () => {
|
||||
});
|
||||
|
||||
it("preserves an answer's markdown tree across completion and the next prompt", async () => {
|
||||
await act(async () => {
|
||||
await preloadMarkdownText();
|
||||
});
|
||||
const turnId = "turn-1";
|
||||
const streaming: UIMessage[] = [
|
||||
{
|
||||
|
||||
@@ -4,10 +4,67 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useNanobotStream } from "@/hooks/useNanobotStream";
|
||||
import type { StreamError } from "@/lib/nanobot-client";
|
||||
import type { ConnectionStatus, InboundEvent, GoalStateWsPayload } from "@/lib/types";
|
||||
import type {
|
||||
ConnectionStatus,
|
||||
GoalStateWsPayload,
|
||||
InboundEvent,
|
||||
UIMessage,
|
||||
} from "@/lib/types";
|
||||
import { ClientProvider } from "@/providers/ClientProvider";
|
||||
import projectionFixture from "./fixtures/live-replay-event-projection.json";
|
||||
|
||||
const EMPTY_MESSAGES: import("@/lib/types").UIMessage[] = [];
|
||||
const EMPTY_MESSAGES: UIMessage[] = [];
|
||||
|
||||
interface ProjectionFixtureCase {
|
||||
name: string;
|
||||
chat_id: string;
|
||||
initial_messages: UIMessage[];
|
||||
live_events: InboundEvent[];
|
||||
expected: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
const PROJECTION_FIXTURE_CASES = (
|
||||
projectionFixture as unknown as { cases: ProjectionFixtureCase[] }
|
||||
).cases;
|
||||
const SEMANTIC_MESSAGE_FIELDS = [
|
||||
"role",
|
||||
"content",
|
||||
"kind",
|
||||
"traces",
|
||||
"toolEvents",
|
||||
"fileEdits",
|
||||
"images",
|
||||
"media",
|
||||
"cliApps",
|
||||
"mcpPresets",
|
||||
"sessionMentions",
|
||||
"reasoning",
|
||||
"latencyMs",
|
||||
"source",
|
||||
"turnId",
|
||||
"turnPhase",
|
||||
"turnSeq",
|
||||
] as const satisfies ReadonlyArray<keyof UIMessage>;
|
||||
|
||||
function normalizeProjection(messages: UIMessage[]): Array<Record<string, unknown>> {
|
||||
const segmentAliases = new Map<string, string>();
|
||||
return messages.map((message) => {
|
||||
const row: Record<string, unknown> = {};
|
||||
for (const field of SEMANTIC_MESSAGE_FIELDS) {
|
||||
const value = message[field];
|
||||
if (value !== undefined && value !== null) row[field] = value;
|
||||
}
|
||||
if (message.activitySegmentId) {
|
||||
let alias = segmentAliases.get(message.activitySegmentId);
|
||||
if (!alias) {
|
||||
alias = `segment-${segmentAliases.size + 1}`;
|
||||
segmentAliases.set(message.activitySegmentId, alias);
|
||||
}
|
||||
row.activitySegmentId = alias;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
function fakeClient() {
|
||||
const handlers = new Map<string, Set<(ev: InboundEvent) => void>>();
|
||||
@@ -2841,3 +2898,21 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("live/replay projection before canonical-event revision migration", () => {
|
||||
it.each(PROJECTION_FIXTURE_CASES)("matches the shared $name fixture", (fixtureCase) => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(
|
||||
() => useNanobotStream(fixtureCase.chat_id, fixtureCase.initial_messages),
|
||||
{ wrapper: wrap(fake.client) },
|
||||
);
|
||||
|
||||
for (const event of fixtureCase.live_events) {
|
||||
act(() => {
|
||||
fake.emit(fixtureCase.chat_id, event);
|
||||
});
|
||||
}
|
||||
|
||||
expect(normalizeProjection(result.current.messages)).toEqual(fixtureCase.expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,8 +109,9 @@ describe("useSessions", () => {
|
||||
]);
|
||||
vi.mocked(api.deleteSession).mockResolvedValue({ deleted: true });
|
||||
|
||||
const client = fakeClient();
|
||||
const { result } = renderHook(() => useSessions(), {
|
||||
wrapper: wrap(fakeClient()),
|
||||
wrapper: wrap(client),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.sessions).toHaveLength(2));
|
||||
@@ -119,7 +120,7 @@ describe("useSessions", () => {
|
||||
await result.current.deleteChat("websocket:chat-a");
|
||||
});
|
||||
|
||||
expect(api.deleteSession).toHaveBeenCalledWith("tok", "websocket:chat-a", undefined);
|
||||
expect(api.deleteSession).toHaveBeenCalledWith(client, "websocket:chat-a", undefined);
|
||||
expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-b"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -161,6 +161,18 @@ export default defineConfig(({ mode }) => {
|
||||
environment: "happy-dom",
|
||||
globals: true,
|
||||
setupFiles: ["./src/tests/setup.ts"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
include: ["src/**/*.{ts,tsx}"],
|
||||
exclude: ["src/tests/**", "src/**/*.d.ts"],
|
||||
reporter: ["text", "json-summary"],
|
||||
thresholds: {
|
||||
statements: 85,
|
||||
branches: 80,
|
||||
functions: 80,
|
||||
lines: 85,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user