mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-10 22:38:40 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2cf5403cb |
@@ -241,7 +241,7 @@ Prefer your own infrastructure? Follow the [deployment guide](./docs/deployment.
|
|||||||
|
|
||||||
## 🌐 WebUI
|
## 🌐 WebUI
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900">
|
<img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900">
|
||||||
@@ -250,10 +250,9 @@ The WebUI ships **inside the published wheel** with no separate frontend build.
|
|||||||
Use it to:
|
Use it to:
|
||||||
|
|
||||||
- keep separate topics for different tasks and projects;
|
- 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;
|
- inspect reasoning, tool calls, file edits, diffs, command output, and generated artifacts;
|
||||||
- switch models and workspaces without leaving the conversation;
|
- switch models and workspaces without leaving the conversation;
|
||||||
- configure providers and chat channels, connect Apps, discover Skills, and manage Automations from one place.
|
- configure providers, chat channels, Apps, Skills, and 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).
|
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,12 +270,6 @@ 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.
|
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.
|
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
|
## Chat App Problems
|
||||||
|
|||||||
+19
-57
@@ -1,10 +1,10 @@
|
|||||||
# Nanobot WebUI: Browser Workbench for Self-Hosted AI Agents
|
# Nanobot WebUI: Browser Workbench for Self-Hosted AI Agents
|
||||||
|
|
||||||
<!-- Meta description: Run nanobot from a browser WebUI with persistent and temporary chats, visible tool activity, workspace controls, Apps, skill discovery, settings, and Automations. -->
|
<!-- Meta description: Run nanobot from a browser WebUI with persistent topics, visible tool activity, workspace controls, Apps, MCP presets, Skills, settings, and Automations. -->
|
||||||
|
|
||||||
The WebUI is nanobot's browser workbench for persistent topics, temporary
|
The WebUI is nanobot's browser workbench for persistent topics, visible
|
||||||
chats, visible agent activity, workspace controls, Apps, skill discovery,
|
agent activity, workspace controls, Apps, Skills, settings, and Automations in
|
||||||
settings, and Automations in one place.
|
one place.
|
||||||
|
|
||||||
The published `nanobot-ai` wheel already includes the WebUI bundle. You only need
|
The published `nanobot-ai` wheel already includes the WebUI bundle. You only need
|
||||||
the `webui/` source directory when you are changing the frontend itself.
|
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 |
|
| Area | Use it for |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Topics | Start persistent topics or temporary chats; switch, search, reorder, fork, or delete persistent topics |
|
| Topics | Start, switch, search, fork, and delete browser topics |
|
||||||
| Agent activity | See thinking, tool calls, file edits with diffs, command output, and generated artifacts in context |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Apps | Install, test, update, and use local CLI App adapters and MCP presets |
|
||||||
| Skills | Inspect and manage installed skills, or discover skills from supported marketplaces |
|
| Skills | Inspect available built-in and workspace skills before relying on them |
|
||||||
| Automations | Review, search, run, pause, edit, and delete scheduled and local-trigger agent turns |
|
| 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 |
|
| Settings | Adjust models, providers, image generation, voice, web tools, runtime, and safety options |
|
||||||
|
|
||||||
@@ -90,10 +90,6 @@ 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
|
separate context; use fork when you want to continue from an existing point
|
||||||
without changing the original thread.
|
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
|
The message timeline shows both user-visible replies and agent activity. Long
|
||||||
tool or reasoning sections can be expanded when you need the details.
|
tool or reasoning sections can be expanded when you need the details.
|
||||||
|
|
||||||
@@ -107,28 +103,6 @@ File previews follow the active session access mode. Restricted workspace access
|
|||||||
previews only files under the selected workspace. Full Access can preview files
|
previews only files under the selected workspace. Full Access can preview files
|
||||||
outside the workspace when that access mode is allowed by the gateway.
|
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
|
## Workspace and Access
|
||||||
|
|
||||||
Use the workspace picker before starting project-specific work. This gives the
|
Use the workspace picker before starting project-specific work. This gives the
|
||||||
@@ -171,8 +145,7 @@ clients.
|
|||||||
The composer supports plain messages, image attachments, voice input when
|
The composer supports plain messages, image attachments, voice input when
|
||||||
transcription is configured, slash commands, and `@` mentions for installed Apps
|
transcription is configured, slash commands, and `@` mentions for installed Apps
|
||||||
or MCP presets. Select another topic from the `@` menu to attach a stable
|
or MCP presets. Select another topic from the `@` menu to attach a stable
|
||||||
reference, or drag that topic from the sidebar into the composer. Plain text
|
reference; plain text that happens to start with `@` does not attach history.
|
||||||
that happens to start with `@` does not attach history.
|
|
||||||
Restricted chats offer topics from the same project, while Full Access chats can
|
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
|
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
|
is relevant and can link it in the response. The model badge shows the current
|
||||||
@@ -231,20 +204,10 @@ After an App or integration is available, mention it from the composer with
|
|||||||
|
|
||||||
## Skills
|
## Skills
|
||||||
|
|
||||||
Open **Skills → Installed** to review built-in and workspace-provided skills.
|
The Skills view shows the skill instructions available to the agent, including
|
||||||
You can search and filter them, inspect their instructions and setup
|
built-in skills and workspace-provided skills. Check this view when you want to
|
||||||
requirements, enable or disable them, and delete workspace skills you no longer
|
know whether nanobot already has a focused workflow for a task before you ask it
|
||||||
want.
|
to perform that task.
|
||||||
|
|
||||||
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
|
## Automations
|
||||||
|
|
||||||
@@ -332,10 +295,10 @@ trusts. Configure [`sslCertfile` and `sslKeyfile`](./websocket.md#tlsssl) on the
|
|||||||
WebSocket channel and open `https://<your-host>:8765`, or terminate HTTPS at a
|
WebSocket channel and open `https://<your-host>:8765`, or terminate HTTPS at a
|
||||||
reverse proxy and use that proxy's HTTPS URL.
|
reverse proxy and use that proxy's HTTPS URL.
|
||||||
|
|
||||||
Remote WebUI clients with a valid token can view and use Apps and installed
|
Remote WebUI clients with a valid token can view and use Apps. Actions that
|
||||||
skills. Actions that install missing nanobot support packages or third-party
|
install missing nanobot support packages, such as adding a channel dependency,
|
||||||
marketplace skills are blocked by default. To let trusted remote administrators
|
are blocked by default. To let trusted remote administrators change the Python
|
||||||
perform those installations through the WebUI, opt in explicitly:
|
environment through the WebUI, opt in explicitly:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -346,13 +309,12 @@ perform those installations through the WebUI, opt in explicitly:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Use this only for a private deployment where every authenticated WebUI user is
|
Use this only for a private deployment where every authenticated WebUI user is
|
||||||
trusted to change nanobot's Python environment and install workspace skill
|
trusted to change the Python environment that nanobot runs in. If you publish
|
||||||
instructions or scripts. If you publish the WebUI through Nginx, Caddy,
|
the WebUI through Nginx, Caddy, Cloudflare Tunnel, or a similar service, treat it
|
||||||
Cloudflare Tunnel, or a similar service, treat it as remote access and leave
|
as remote access and leave package installs disabled unless that is intentional.
|
||||||
package and skill installs disabled unless that is intentional.
|
|
||||||
|
|
||||||
Optional feature installs use pip's configured package index, including
|
Optional feature installs use pip's configured package index, including
|
||||||
`PIP_INDEX_URL`. skills.sh marketplace installs use `npx` instead.
|
`PIP_INDEX_URL`.
|
||||||
|
|
||||||
Leave remote package installs disabled when the WebUI is exposed beyond a
|
Leave remote package installs disabled when the WebUI is exposed beyond a
|
||||||
private, trusted network.
|
private, trusted network.
|
||||||
|
|||||||
+3
-11
@@ -36,7 +36,6 @@ 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.file_state import FileStateStore, bind_file_states, reset_file_states
|
||||||
from nanobot.agent.tools.message import MessageTool
|
from nanobot.agent.tools.message import MessageTool
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
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.tools.self import MyTool
|
||||||
from nanobot.agent.turn_delivery import (
|
from nanobot.agent.turn_delivery import (
|
||||||
TurnDelivery,
|
TurnDelivery,
|
||||||
@@ -198,11 +197,6 @@ class AgentLoop:
|
|||||||
def tool_names(self) -> list[str]:
|
def tool_names(self) -> list[str]:
|
||||||
return self.tools.tool_names
|
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
|
@property
|
||||||
def provider(self) -> LLMProvider:
|
def provider(self) -> LLMProvider:
|
||||||
"""Provider selected for future turn admissions."""
|
"""Provider selected for future turn admissions."""
|
||||||
@@ -454,6 +448,7 @@ class AgentLoop:
|
|||||||
if model_preset:
|
if model_preset:
|
||||||
self.set_model_preset(model_preset, publish_update=False)
|
self.set_model_preset(model_preset, publish_update=False)
|
||||||
self._register_default_tools(provider_snapshot_loader=provider_snapshot_loader)
|
self._register_default_tools(provider_snapshot_loader=provider_snapshot_loader)
|
||||||
|
self._runtime_vars: dict[str, Any] = {}
|
||||||
self._current_iteration: int = 0
|
self._current_iteration: int = 0
|
||||||
self.commands = CommandRouter()
|
self.commands = CommandRouter()
|
||||||
register_builtin_commands(self.commands)
|
register_builtin_commands(self.commands)
|
||||||
@@ -628,13 +623,10 @@ class AgentLoop:
|
|||||||
loader = ToolLoader()
|
loader = ToolLoader()
|
||||||
registered = loader.load(ctx, self.tools)
|
registered = loader.load(ctx, self.tools)
|
||||||
|
|
||||||
# MyTool receives only the explicit runtime-control capability.
|
# MyTool needs runtime state reference — manual registration
|
||||||
if self.tools_config.my.enable:
|
if self.tools_config.my.enable:
|
||||||
self.tools.register(
|
self.tools.register(
|
||||||
MyTool(
|
MyTool(runtime_state=self, modify_allowed=self.tools_config.my.allow_set)
|
||||||
runtime_control=AgentRuntimeControl(self),
|
|
||||||
modify_allowed=self.tools_config.my.allow_set,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
registered.append("my")
|
registered.append("my")
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import json
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
import warnings
|
import warnings
|
||||||
from collections.abc import Mapping
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, TypedDict
|
from typing import Any, Callable, TypedDict
|
||||||
@@ -158,10 +157,6 @@ class SubagentManager:
|
|||||||
self._task_statuses: dict[str, SubagentStatus] = {}
|
self._task_statuses: dict[str, SubagentStatus] = {}
|
||||||
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
|
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:
|
def set_provider(self, provider: LLMProvider, model: str) -> None:
|
||||||
"""Update the deprecated runtime source used by legacy ``spawn`` calls."""
|
"""Update the deprecated runtime source used by legacy ``spawn`` calls."""
|
||||||
warnings.warn(
|
warnings.warn(
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
_SKIP_MODULES = frozenset({
|
_SKIP_MODULES = frozenset({
|
||||||
"base", "schema", "registry", "context", "loader", "config",
|
"base", "schema", "registry", "context", "loader", "config",
|
||||||
"file_state", "sandbox", "mcp", "__init__", "runtime_control",
|
"file_state", "sandbox", "mcp", "__init__", "runtime_state",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+29
-19
@@ -975,8 +975,11 @@ async def connect_mcp_servers(
|
|||||||
from mcp.client.streamable_http import streamable_http_client
|
from mcp.client.streamable_http import streamable_http_client
|
||||||
|
|
||||||
async def open_single_server(
|
async def open_single_server(
|
||||||
name: str, cfg: "MCPServerConfig", server_stack: AsyncExitStack
|
name: str, cfg: "MCPServerConfig"
|
||||||
) -> bool:
|
) -> tuple[str, AsyncExitStack | None]:
|
||||||
|
server_stack = AsyncExitStack()
|
||||||
|
await server_stack.__aenter__()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
transport_type = cfg.type
|
transport_type = cfg.type
|
||||||
if not transport_type:
|
if not transport_type:
|
||||||
@@ -988,7 +991,8 @@ async def connect_mcp_servers(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning("MCP server '{}': no command or url configured, skipping", name)
|
logger.warning("MCP server '{}': no command or url configured, skipping", name)
|
||||||
return False
|
await server_stack.aclose()
|
||||||
|
return name, None
|
||||||
|
|
||||||
if transport_type in {"sse", "streamableHttp"}:
|
if transport_type in {"sse", "streamableHttp"}:
|
||||||
ok, error = validate_url_target(cfg.url)
|
ok, error = validate_url_target(cfg.url)
|
||||||
@@ -999,7 +1003,8 @@ async def connect_mcp_servers(
|
|||||||
_redact_url(cfg.url),
|
_redact_url(cfg.url),
|
||||||
error,
|
error,
|
||||||
)
|
)
|
||||||
return False
|
await server_stack.aclose()
|
||||||
|
return name, None
|
||||||
|
|
||||||
if transport_type == "stdio":
|
if transport_type == "stdio":
|
||||||
command, args, env = _normalize_windows_stdio_command(
|
command, args, env = _normalize_windows_stdio_command(
|
||||||
@@ -1017,7 +1022,8 @@ async def connect_mcp_servers(
|
|||||||
elif transport_type == "sse":
|
elif transport_type == "sse":
|
||||||
if not await _probe_http_url(cfg.url):
|
if not await _probe_http_url(cfg.url):
|
||||||
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
||||||
return False
|
await server_stack.aclose()
|
||||||
|
return name, None
|
||||||
|
|
||||||
def httpx_client_factory(
|
def httpx_client_factory(
|
||||||
headers: dict[str, str] | None = None,
|
headers: dict[str, str] | None = None,
|
||||||
@@ -1044,7 +1050,8 @@ async def connect_mcp_servers(
|
|||||||
elif transport_type == "streamableHttp":
|
elif transport_type == "streamableHttp":
|
||||||
if not await _probe_http_url(cfg.url):
|
if not await _probe_http_url(cfg.url):
|
||||||
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
||||||
return False
|
await server_stack.aclose()
|
||||||
|
return name, None
|
||||||
|
|
||||||
http_client = await server_stack.enter_async_context(
|
http_client = await server_stack.enter_async_context(
|
||||||
httpx.AsyncClient(
|
httpx.AsyncClient(
|
||||||
@@ -1060,7 +1067,8 @@ async def connect_mcp_servers(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning("MCP server '{}': unknown transport type '{}'", name, transport_type)
|
logger.warning("MCP server '{}': unknown transport type '{}'", name, transport_type)
|
||||||
return False
|
await server_stack.aclose()
|
||||||
|
return name, None
|
||||||
|
|
||||||
read = _filter_malformed_mcp_progress_notifications(read, name)
|
read = _filter_malformed_mcp_progress_notifications(read, name)
|
||||||
session = await server_stack.enter_async_context(ClientSession(read, write))
|
session = await server_stack.enter_async_context(ClientSession(read, write))
|
||||||
@@ -1163,7 +1171,7 @@ async def connect_mcp_servers(
|
|||||||
logger.info(
|
logger.info(
|
||||||
"MCP server '{}': connected, {} capabilities registered", name, registered_count
|
"MCP server '{}': connected, {} capabilities registered", name, registered_count
|
||||||
)
|
)
|
||||||
return True
|
return name, server_stack
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
hint = ""
|
hint = ""
|
||||||
@@ -1183,7 +1191,9 @@ async def connect_mcp_servers(
|
|||||||
"only JSON-RPC to stdout and sends logs/debug output to stderr instead."
|
"only JSON-RPC to stdout and sends logs/debug output to stderr instead."
|
||||||
)
|
)
|
||||||
logger.exception("MCP server '{}': failed to connect: {}", name, hint)
|
logger.exception("MCP server '{}': failed to connect: {}", name, hint)
|
||||||
return False
|
with suppress(Exception):
|
||||||
|
await server_stack.aclose()
|
||||||
|
return name, None
|
||||||
|
|
||||||
async def connect_single_server(
|
async def connect_single_server(
|
||||||
name: str, cfg: "MCPServerConfig"
|
name: str, cfg: "MCPServerConfig"
|
||||||
@@ -1193,30 +1203,30 @@ async def connect_mcp_servers(
|
|||||||
close_requested = asyncio.Event()
|
close_requested = asyncio.Event()
|
||||||
|
|
||||||
async def own_connection() -> None:
|
async def own_connection() -> None:
|
||||||
|
stack: AsyncExitStack | None = None
|
||||||
try:
|
try:
|
||||||
async with AsyncExitStack() as stack:
|
_, stack = await open_single_server(name, cfg)
|
||||||
connected = await open_single_server(name, cfg, stack)
|
if not ready.done():
|
||||||
if not ready.done():
|
ready.set_result(stack is not None)
|
||||||
ready.set_result(connected)
|
if stack is not None:
|
||||||
if connected:
|
await close_requested.wait()
|
||||||
await close_requested.wait()
|
|
||||||
except BaseException as exc:
|
except BaseException as exc:
|
||||||
if not ready.done():
|
if not ready.done():
|
||||||
ready.set_exception(exc)
|
ready.set_exception(exc)
|
||||||
raise
|
raise
|
||||||
|
finally:
|
||||||
|
if stack is not None:
|
||||||
|
await stack.aclose()
|
||||||
|
|
||||||
owner = asyncio.create_task(own_connection(), name=f"mcp:{name}")
|
owner = asyncio.create_task(own_connection(), name=f"mcp:{name}")
|
||||||
connection = _OwnedMCPConnection(owner, close_requested)
|
connection = _OwnedMCPConnection(owner, close_requested)
|
||||||
try:
|
try:
|
||||||
connected = await ready
|
connected = await ready
|
||||||
except BaseException as exc:
|
except BaseException:
|
||||||
close_requested.set()
|
close_requested.set()
|
||||||
owner.cancel()
|
owner.cancel()
|
||||||
with suppress(BaseException):
|
with suppress(BaseException):
|
||||||
await asyncio.shield(owner)
|
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
|
raise
|
||||||
if not connected:
|
if not connected:
|
||||||
await connection.aclose()
|
await connection.aclose()
|
||||||
|
|||||||
@@ -1,319 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""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: ...
|
||||||
+182
-213
@@ -1,7 +1,8 @@
|
|||||||
"""MyTool: runtime state inspection and configuration for the agent loop."""
|
"""MyTool: runtime state inspection and configuration for the agent loop."""
|
||||||
|
|
||||||
# Tool.execute accepts heterogeneous schemas.
|
# RuntimeState intentionally exposes a narrow set of AgentLoop internals to
|
||||||
# pyright: reportIncompatibleMethodOverride=false
|
# this manually registered tool. Tool.execute accepts heterogeneous schemas.
|
||||||
|
# pyright: reportPrivateUsage=false, reportIncompatibleMethodOverride=false
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -13,13 +14,7 @@ from loguru import logger
|
|||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult
|
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.context import current_request_context, current_request_session_key
|
||||||
from nanobot.agent.tools.runtime_control import (
|
from nanobot.agent.tools.runtime_state import RuntimeState
|
||||||
RUNTIME_COMMAND_KEYS,
|
|
||||||
RUNTIME_SNAPSHOT_KEYS,
|
|
||||||
JsonValue,
|
|
||||||
RuntimeControl,
|
|
||||||
RuntimeSnapshot,
|
|
||||||
)
|
|
||||||
from nanobot.config_base import Base
|
from nanobot.config_base import Base
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -33,28 +28,25 @@ class MyToolConfig(Base):
|
|||||||
allow_set: bool = False
|
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]:
|
def _is_subagent_status(value: object) -> TypeGuard[SubagentStatus]:
|
||||||
from nanobot.agent.subagent import SubagentStatus
|
from nanobot.agent.subagent import SubagentStatus
|
||||||
|
|
||||||
return isinstance(value, 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):
|
class MyTool(Tool):
|
||||||
"""Check and set the agent loop's runtime configuration."""
|
"""Check and set the agent loop's runtime configuration."""
|
||||||
|
|
||||||
@@ -87,10 +79,7 @@ class MyTool(Tool):
|
|||||||
|
|
||||||
READ_ONLY = frozenset({
|
READ_ONLY = frozenset({
|
||||||
"subagents", # observable but replacing it would break the system
|
"subagents", # observable but replacing it would break the system
|
||||||
"tool_names",
|
|
||||||
"current_iteration",
|
|
||||||
"_current_iteration", # updated by runner only
|
"_current_iteration", # updated by runner only
|
||||||
"_last_usage",
|
|
||||||
"exec_config", # inspect allowed (e.g. check sandbox), modify blocked
|
"exec_config", # inspect allowed (e.g. check sandbox), modify blocked
|
||||||
"web_config", # inspect allowed (e.g. check enable), modify blocked
|
"web_config", # inspect allowed (e.g. check enable), modify blocked
|
||||||
"model_presets", # config-derived catalog; changes require config reload
|
"model_presets", # config-derived catalog; changes require config reload
|
||||||
@@ -114,6 +103,13 @@ class MyTool(Tool):
|
|||||||
"private_key", "access_token", "refresh_token", "auth",
|
"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]] = {
|
RESTRICTED: dict[str, dict[str, Any]] = {
|
||||||
"max_iterations": {"type": int, "min": 1, "max": 100},
|
"max_iterations": {"type": int, "min": 1, "max": 100},
|
||||||
"context_window_tokens": {"type": int, "min": 4096, "max": 1_000_000},
|
"context_window_tokens": {"type": int, "min": 4096, "max": 1_000_000},
|
||||||
@@ -127,15 +123,15 @@ class MyTool(Tool):
|
|||||||
"context_window_tokens",
|
"context_window_tokens",
|
||||||
})
|
})
|
||||||
|
|
||||||
def __init__(self, runtime_control: RuntimeControl, modify_allowed: bool = True) -> None:
|
def __init__(self, runtime_state: RuntimeState, modify_allowed: bool = True) -> None:
|
||||||
self._runtime_control = runtime_control
|
self._runtime_state = runtime_state
|
||||||
self._modify_allowed = modify_allowed
|
self._modify_allowed = modify_allowed
|
||||||
|
|
||||||
def __deepcopy__(self, memo: dict[int, Any]) -> MyTool:
|
def __deepcopy__(self, memo: dict[int, Any]) -> MyTool:
|
||||||
cls = self.__class__
|
cls = self.__class__
|
||||||
result = cls.__new__(cls)
|
result = cls.__new__(cls)
|
||||||
memo[id(self)] = result
|
memo[id(self)] = result
|
||||||
result._runtime_control = self._runtime_control
|
result._runtime_state = self._runtime_state
|
||||||
result._modify_allowed = self._modify_allowed
|
result._modify_allowed = self._modify_allowed
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -212,12 +208,9 @@ class MyTool(Tool):
|
|||||||
# Path resolution
|
# Path resolution
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _resolve_path(
|
def _resolve_path(self, path: str) -> tuple[Any, str | None]:
|
||||||
self,
|
|
||||||
snapshot: RuntimeSnapshot,
|
|
||||||
path: str,
|
|
||||||
) -> tuple[object | None, str | None]:
|
|
||||||
parts = path.split(".")
|
parts = path.split(".")
|
||||||
|
obj: Any = self._runtime_state
|
||||||
for part in parts:
|
for part in parts:
|
||||||
if part in self._DENIED_ATTRS or part.startswith("__"):
|
if part in self._DENIED_ATTRS or part.startswith("__"):
|
||||||
return None, f"'{part}' is not accessible"
|
return None, f"'{part}' is not accessible"
|
||||||
@@ -225,13 +218,17 @@ class MyTool(Tool):
|
|||||||
return None, f"'{part}' is not accessible"
|
return None, f"'{part}' is not accessible"
|
||||||
if part.lower() in self._SENSITIVE_NAMES:
|
if part.lower() in self._SENSITIVE_NAMES:
|
||||||
return None, f"'{part}' is not accessible"
|
return None, f"'{part}' is not accessible"
|
||||||
obj: object = snapshot.as_mapping()
|
try:
|
||||||
for part in parts:
|
if isinstance(obj, Mapping):
|
||||||
if not _is_string_mapping(obj):
|
mapping = cast(Mapping[str, Any], obj)
|
||||||
return None, f"'{part}' not found"
|
if part in mapping:
|
||||||
if part not in obj:
|
obj = mapping[part]
|
||||||
return None, f"'{part}' not found in mapping"
|
else:
|
||||||
obj = obj[part]
|
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}"
|
||||||
return obj, None
|
return obj, None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -245,48 +242,20 @@ class MyTool(Tool):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_status(
|
def _format_status(st: "SubagentStatus", indent: str = " ") -> str:
|
||||||
st: "SubagentStatus | Mapping[str, object]",
|
elapsed = time.monotonic() - st.started_at
|
||||||
indent: str = " ",
|
tool_summary = ", ".join(
|
||||||
) -> str:
|
f"{e.get('name', '?')}({e.get('status', '?')})" for e in st.tool_events[-5:]
|
||||||
if isinstance(st, Mapping):
|
) or "none"
|
||||||
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 = [
|
lines = [
|
||||||
f"{indent}phase: {phase}, iteration: {iteration}, elapsed: {elapsed:.1f}s",
|
f"{indent}phase: {st.phase}, iteration: {st.iteration}, elapsed: {elapsed:.1f}s",
|
||||||
f"{indent}tools: {tool_summary}",
|
f"{indent}tools: {tool_summary}",
|
||||||
f"{indent}usage: {usage or 'n/a'}",
|
f"{indent}usage: {st.usage or 'n/a'}",
|
||||||
]
|
]
|
||||||
if error:
|
if st.error:
|
||||||
lines.append(f"{indent}error: {error}")
|
lines.append(f"{indent}error: {st.error}")
|
||||||
if stop_reason:
|
if st.stop_reason:
|
||||||
lines.append(f"{indent}stop_reason: {stop_reason}")
|
lines.append(f"{indent}stop_reason: {st.stop_reason}")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -295,38 +264,29 @@ class MyTool(Tool):
|
|||||||
header = f"Subagent [{val.task_id}] '{val.label}'"
|
header = f"Subagent [{val.task_id}] '{val.label}'"
|
||||||
detail = MyTool._format_status(val, " ")
|
detail = MyTool._format_status(val, " ")
|
||||||
return f"{header}\n task: {val.task_description}\n{detail}"
|
return f"{header}\n task: {val.task_description}\n{detail}"
|
||||||
if _is_subagent_status_snapshot(val):
|
# SubagentManager: delegate to its _task_statuses dict
|
||||||
header = f"Subagent [{val['task_id']}] '{val['label']}'"
|
task_statuses = getattr(val, "_task_statuses", None)
|
||||||
detail = MyTool._format_status(val, " ")
|
if isinstance(task_statuses, dict):
|
||||||
return f"{header}\n task: {val['task_description']}\n{detail}"
|
return MyTool._format_value(task_statuses, key)
|
||||||
if isinstance(val, Mapping):
|
if isinstance(val, Mapping):
|
||||||
mapping = cast(Mapping[object, object], val)
|
mapping = cast(Mapping[object, object], val)
|
||||||
else:
|
else:
|
||||||
mapping = None
|
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 (
|
if (
|
||||||
mapping
|
mapping
|
||||||
and (
|
and _is_subagent_status(next(iter(mapping.values())))
|
||||||
_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 ""
|
prefix = f"{key}: " if key else ""
|
||||||
lines = [f"{prefix}{len(mapping)} subagent(s):"]
|
lines = [f"{prefix}{len(status_mapping)} subagent(s):"]
|
||||||
for tid, st in mapping.items():
|
for tid, st in status_mapping.items():
|
||||||
if _is_subagent_status(st):
|
detail = MyTool._format_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)
|
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
|
# Scalar types — repr is fine
|
||||||
if isinstance(val, (str, int, float, bool, type(None))):
|
if isinstance(val, (str, int, float, bool, type(None))):
|
||||||
r = repr(val)
|
r = repr(val)
|
||||||
@@ -351,6 +311,32 @@ class MyTool(Tool):
|
|||||||
return f"{key}: [{len(sequence)} items]" if key else f"[{len(sequence)} items]"
|
return f"{key}: [{len(sequence)} items]" if key else f"[{len(sequence)} items]"
|
||||||
r = repr(sequence)
|
r = repr(sequence)
|
||||||
return f"{key}: {r}" if key else r
|
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)
|
r = repr(val)
|
||||||
return f"{key}: {r}" if key else r
|
return f"{key}: {r}" if key else r
|
||||||
|
|
||||||
@@ -380,12 +366,7 @@ class MyTool(Tool):
|
|||||||
runtime = request_ctx.runtime if request_ctx is not None else None
|
runtime = request_ctx.runtime if request_ctx is not None else None
|
||||||
if runtime is None or key not in self._MODEL_RUNTIME_FIELDS:
|
if runtime is None or key not in self._MODEL_RUNTIME_FIELDS:
|
||||||
return False, None
|
return False, None
|
||||||
values: dict[str, object] = {
|
return True, getattr(runtime, key)
|
||||||
"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:
|
def _inspect(self, key: str | None) -> str:
|
||||||
if not key:
|
if not key:
|
||||||
@@ -394,64 +375,62 @@ class MyTool(Tool):
|
|||||||
request_ctx = current_request_context()
|
request_ctx = current_request_context()
|
||||||
if request_ctx is None:
|
if request_ctx is None:
|
||||||
return ToolResult.error("Error: current request context is unavailable")
|
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":
|
if key == "request":
|
||||||
return self._format_value(request_values, key)
|
return self._format_value(
|
||||||
|
{field: getattr(request_ctx, field) for field in self._REQUEST_FIELDS},
|
||||||
|
key,
|
||||||
|
)
|
||||||
field = key.removeprefix("request.")
|
field = key.removeprefix("request.")
|
||||||
if field not in self._REQUEST_FIELDS:
|
if field not in self._REQUEST_FIELDS:
|
||||||
return ToolResult.error(f"Error: '{key}' not found")
|
return ToolResult.error(f"Error: '{key}' not found")
|
||||||
return self._format_value(request_values[field], key)
|
return self._format_value(getattr(request_ctx, field), key)
|
||||||
if "." not in key:
|
if "." not in key:
|
||||||
found, value = self._current_runtime_value(key)
|
found, value = self._current_runtime_value(key)
|
||||||
if found:
|
if found:
|
||||||
return self._format_value(value, key)
|
return self._format_value(value, key)
|
||||||
snapshot = self._runtime_control.snapshot()
|
|
||||||
top = key.split(".")[0]
|
top = key.split(".")[0]
|
||||||
if top in self._DENIED_ATTRS or top.startswith("__"):
|
if top in self._DENIED_ATTRS or top.startswith("__"):
|
||||||
return ToolResult.error(f"Error: '{top}' is not accessible")
|
return ToolResult.error(f"Error: '{top}' is not accessible")
|
||||||
obj, err = self._resolve_path(snapshot, key)
|
obj, err = self._resolve_path(key)
|
||||||
if err:
|
if err:
|
||||||
|
# "scratchpad" alias for _runtime_vars
|
||||||
if key == "scratchpad":
|
if key == "scratchpad":
|
||||||
return (
|
rv = self._runtime_state._runtime_vars
|
||||||
self._format_value(snapshot.scratchpad, "scratchpad")
|
return self._format_value(rv, "scratchpad") if rv else "scratchpad is empty"
|
||||||
if snapshot.scratchpad
|
# Fallback: check _runtime_vars for simple keys stored by modify
|
||||||
else "scratchpad is empty"
|
if "." not in key and key in self._runtime_state._runtime_vars:
|
||||||
)
|
return self._format_value(self._runtime_state._runtime_vars[key], key)
|
||||||
if "." not in key and key in snapshot.scratchpad:
|
|
||||||
return self._format_value(snapshot.scratchpad[key], key)
|
|
||||||
return ToolResult.error(f"Error: {err}")
|
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)
|
return self._format_value(obj, key)
|
||||||
|
|
||||||
def _inspect_all(self) -> str:
|
def _inspect_all(self) -> str:
|
||||||
snapshot = self._runtime_control.snapshot()
|
state = self._runtime_state
|
||||||
values = snapshot.as_mapping()
|
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
|
# RESTRICTED keys
|
||||||
for k in self.RESTRICTED:
|
for k in self.RESTRICTED:
|
||||||
found, value = self._current_runtime_value(k)
|
found, value = self._current_runtime_value(k)
|
||||||
parts.append(self._format_value(value if found else values[k], k))
|
parts.append(self._format_value(value if found else getattr(state, k, None), k))
|
||||||
found, value = self._current_runtime_value("model_preset")
|
found, value = self._current_runtime_value("model_preset")
|
||||||
parts.append(self._format_value(
|
parts.append(self._format_value(
|
||||||
value if found else snapshot.model_preset,
|
value if found else state.model_preset,
|
||||||
"model_preset",
|
"model_preset",
|
||||||
))
|
))
|
||||||
for k in (
|
# Other useful top-level keys shown in description
|
||||||
"workspace",
|
for k in ("workspace", "provider_retry_mode", "max_tool_result_chars", "_current_iteration", "web_config", "exec_config", "workspace_sandbox", "subagents"):
|
||||||
"provider_retry_mode",
|
if _has_real_attr(state, k):
|
||||||
"max_tool_result_chars",
|
parts.append(self._format_value(getattr(state, k, None), k))
|
||||||
"_current_iteration",
|
# Token usage
|
||||||
"web_config",
|
usage = state._last_usage
|
||||||
"exec_config",
|
if usage:
|
||||||
"subagents",
|
parts.append(self._format_value(usage, "_last_usage"))
|
||||||
):
|
rv = state._runtime_vars
|
||||||
parts.append(self._format_value(values[k], k))
|
if rv:
|
||||||
if snapshot.last_usage:
|
parts.append(self._format_value(rv, "scratchpad"))
|
||||||
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)
|
return "\n".join(parts)
|
||||||
|
|
||||||
# -- modify --
|
# -- modify --
|
||||||
@@ -475,49 +454,48 @@ class MyTool(Tool):
|
|||||||
if leaf.lower() in self._SENSITIVE_NAMES:
|
if leaf.lower() in self._SENSITIVE_NAMES:
|
||||||
self._audit("modify", f"BLOCKED sensitive leaf '{leaf}'")
|
self._audit("modify", f"BLOCKED sensitive leaf '{leaf}'")
|
||||||
return ToolResult.error(f"Error: '{leaf}' is not accessible")
|
return ToolResult.error(f"Error: '{leaf}' is not accessible")
|
||||||
snapshot = self._runtime_control.snapshot()
|
parent, err = self._resolve_path(parent_path)
|
||||||
_parent, err = self._resolve_path(snapshot, parent_path)
|
|
||||||
if err:
|
if err:
|
||||||
return ToolResult.error(f"Error: {err}")
|
return ToolResult.error(f"Error: {err}")
|
||||||
self._audit("modify", f"READ_ONLY {key}")
|
if isinstance(parent, dict):
|
||||||
return ToolResult.error(f"Error: '{key}' is read-only and cannot be modified")
|
parent[leaf] = value
|
||||||
|
else:
|
||||||
|
setattr(parent, leaf, value)
|
||||||
|
self._audit("modify", f"{key} = {value!r}")
|
||||||
|
return f"Set {key} = {value!r}"
|
||||||
if key == "model_preset":
|
if key == "model_preset":
|
||||||
return self._modify_model_preset(value)
|
return self._modify_model_preset(value)
|
||||||
if key in self.RESTRICTED:
|
if key in self.RESTRICTED:
|
||||||
return self._modify_restricted(key, value)
|
return self._modify_restricted(key, value)
|
||||||
if key in RUNTIME_COMMAND_KEYS:
|
return self._modify_free(key, value)
|
||||||
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:
|
def _modify_model_preset(self, value: Any) -> str:
|
||||||
if not isinstance(value, str) or not value.strip():
|
if not isinstance(value, str) or not value.strip():
|
||||||
return ToolResult.error("Error: 'model_preset' must be a non-empty string")
|
return ToolResult.error("Error: 'model_preset' must be a non-empty string")
|
||||||
name = value.strip()
|
name = value.strip()
|
||||||
session_key = current_request_session_key()
|
session_key = current_request_session_key()
|
||||||
old = self._runtime_control.snapshot().model_preset
|
|
||||||
try:
|
|
||||||
runtime = self._runtime_control.set_model_preset(
|
|
||||||
name,
|
|
||||||
session_key=session_key,
|
|
||||||
)
|
|
||||||
except (KeyError, ValueError) as exc:
|
|
||||||
message = str(exc.args[0]) if exc.args else str(exc)
|
|
||||||
punctuation = "" if message.endswith((".", "!", "?")) else "."
|
|
||||||
return ToolResult.error(f"Error: {message}{punctuation}")
|
|
||||||
if session_key:
|
if session_key:
|
||||||
|
try:
|
||||||
|
runtime = self._runtime_state.set_session_model_preset(
|
||||||
|
session_key,
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
except (KeyError, ValueError) as exc:
|
||||||
|
message = str(exc.args[0]) if exc.args else str(exc)
|
||||||
|
punctuation = "" if message.endswith((".", "!", "?")) else "."
|
||||||
|
return ToolResult.error(f"Error: {message}{punctuation}")
|
||||||
self._audit("modify", f"model_preset = {name!r}")
|
self._audit("modify", f"model_preset = {name!r}")
|
||||||
return (
|
return (
|
||||||
f"Set model_preset = {name!r} for the next turn; "
|
f"Set model_preset = {name!r} for the next turn; "
|
||||||
f"model will be {runtime.model!r}; "
|
f"model will be {runtime.model!r}; "
|
||||||
f"context_window_tokens will be {runtime.context_window_tokens!r}"
|
f"context_window_tokens will be {runtime.context_window_tokens!r}"
|
||||||
)
|
)
|
||||||
self._audit("modify", f"model_preset: {old!r} -> {name!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}.")
|
||||||
return (
|
return (
|
||||||
f"Set model_preset = {name!r} (was {old!r}); model is now {runtime.model!r}; "
|
f"{result}; model is now {self._runtime_state.model!r}; "
|
||||||
f"context_window_tokens is now {runtime.context_window_tokens!r}"
|
f"context_window_tokens is now {self._runtime_state.context_window_tokens!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _modify_restricted(self, key: str, value: Any) -> str:
|
def _modify_restricted(self, key: str, value: Any) -> str:
|
||||||
@@ -530,7 +508,7 @@ class MyTool(Tool):
|
|||||||
value = expected(value)
|
value = expected(value)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return ToolResult.error(f"Error: '{key}' must be {expected.__name__}, got {type(value).__name__}")
|
return ToolResult.error(f"Error: '{key}' must be {expected.__name__}, got {type(value).__name__}")
|
||||||
old = self._runtime_control.snapshot().as_mapping()[key]
|
old = getattr(self._runtime_state, key)
|
||||||
if "min" in spec and value < spec["min"]:
|
if "min" in spec and value < spec["min"]:
|
||||||
return ToolResult.error(f"Error: '{key}' must be >= {spec['min']}")
|
return ToolResult.error(f"Error: '{key}' must be >= {spec['min']}")
|
||||||
if "max" in spec and value > spec["max"]:
|
if "max" in spec and value > spec["max"]:
|
||||||
@@ -543,46 +521,41 @@ class MyTool(Tool):
|
|||||||
"during an active session; use a configured model_preset"
|
"during an active session; use a configured model_preset"
|
||||||
)
|
)
|
||||||
if key == "model":
|
if key == "model":
|
||||||
self._runtime_control.set_model(cast(str, value))
|
self._runtime_state.set_runtime_model(cast(str, value))
|
||||||
elif key == "context_window_tokens":
|
elif key == "context_window_tokens":
|
||||||
self._runtime_control.set_context_window_tokens(cast(int, value))
|
self._runtime_state.set_runtime_context_window(cast(int, value))
|
||||||
else:
|
else:
|
||||||
self._runtime_control.set_max_iterations(cast(int, value))
|
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._audit("modify", f"{key}: {old!r} -> {value!r}")
|
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
||||||
return f"Set {key} = {value!r} (was {old!r})"
|
return f"Set {key} = {value!r} (was {old!r})"
|
||||||
|
|
||||||
def _modify_runtime_setting(self, key: str, value: Any) -> str:
|
def _modify_free(self, key: str, value: Any) -> str:
|
||||||
old = self._runtime_control.snapshot().as_mapping()[key]
|
if _has_real_attr(self._runtime_state, key):
|
||||||
if key == "workspace":
|
old = getattr(self._runtime_state, key)
|
||||||
if not isinstance(value, str):
|
if isinstance(old, (str, int, float, bool)):
|
||||||
return ToolResult.error(
|
old_t: type[Any] = type(old)
|
||||||
f"Error: 'workspace' expects str, got {type(value).__name__}"
|
new_t = cast(type[Any], type(value))
|
||||||
)
|
if old_t is float and new_t is int:
|
||||||
self._runtime_control.set_workspace_display(value)
|
pass # int → float coercion allowed
|
||||||
self._audit("modify", f"workspace: {old!r} -> {value!r}")
|
elif old_t is not new_t:
|
||||||
return f"Set workspace = {value!r} (was {old!r})"
|
self._audit(
|
||||||
old_t = type(old)
|
"modify",
|
||||||
new_t = cast(type[Any], type(value))
|
f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}",
|
||||||
if old_t is float and new_t is int:
|
)
|
||||||
pass
|
return ToolResult.error(f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}")
|
||||||
elif old_t is not new_t:
|
try:
|
||||||
self._audit(
|
setattr(self._runtime_state, key, value)
|
||||||
"modify",
|
except (ValueError, KeyError) as e:
|
||||||
f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}",
|
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(
|
return ToolResult.error(f"Error: {message}")
|
||||||
f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}"
|
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
||||||
)
|
return f"Set {key} = {value!r} (was {old!r})"
|
||||||
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):
|
if callable(value):
|
||||||
self._audit("modify", f"REJECTED callable {key}")
|
self._audit("modify", f"REJECTED callable {key}")
|
||||||
return ToolResult.error("Error: cannot store callable values")
|
return ToolResult.error("Error: cannot store callable values")
|
||||||
@@ -590,16 +563,12 @@ class MyTool(Tool):
|
|||||||
if err:
|
if err:
|
||||||
self._audit("modify", f"REJECTED {key}: {err}")
|
self._audit("modify", f"REJECTED {key}: {err}")
|
||||||
return ToolResult.error(f"Error: {err}")
|
return ToolResult.error(f"Error: {err}")
|
||||||
try:
|
if key not in self._runtime_state._runtime_vars and len(self._runtime_state._runtime_vars) >= self._MAX_RUNTIME_KEYS:
|
||||||
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")
|
self._audit("modify", f"REJECTED {key}: max keys ({self._MAX_RUNTIME_KEYS}) reached")
|
||||||
return ToolResult.error(f"Error: {exc}. Remove unused keys first.")
|
return ToolResult.error(f"Error: scratchpad is full (max {self._MAX_RUNTIME_KEYS} keys). Remove unused keys first.")
|
||||||
self._audit("modify", f"scratchpad.{key} = {value!r}")
|
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 f"Set scratchpad.{key} = {value!r}"
|
return f"Set scratchpad.{key} = {value!r}"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import type {
|
|||||||
NanobotFeatureInfo,
|
NanobotFeatureInfo,
|
||||||
NanobotFeaturesPayload,
|
NanobotFeaturesPayload,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
import { useClient } from "@/providers/ClientProvider";
|
|
||||||
|
|
||||||
import { FeishuConnectFlow } from "./FeishuConnectFlow";
|
import { FeishuConnectFlow } from "./FeishuConnectFlow";
|
||||||
|
|
||||||
@@ -34,6 +33,7 @@ export function FeishuAssistantsPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ChannelInstancesPanel
|
<ChannelInstancesPanel
|
||||||
|
token={token}
|
||||||
feature={feature}
|
feature={feature}
|
||||||
showBrandLogos={showBrandLogos}
|
showBrandLogos={showBrandLogos}
|
||||||
chatAppsDocsUrl={chatAppsDocsUrl}
|
chatAppsDocsUrl={chatAppsDocsUrl}
|
||||||
@@ -92,7 +92,6 @@ function FeishuInstanceAction({
|
|||||||
instance: NanobotChannelInstanceInfo;
|
instance: NanobotChannelInstanceInfo;
|
||||||
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
||||||
}) {
|
}) {
|
||||||
const { client } = useClient();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const tx = channelTranslator(t, "feishu");
|
const tx = channelTranslator(t, "feishu");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -115,7 +114,7 @@ function FeishuInstanceAction({
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
onFeaturesUpdate(
|
onFeaturesUpdate(
|
||||||
await enableNanobotFeature(client, "feishu", { instanceId: instance.id }),
|
await enableNanobotFeature(token, "feishu", { instanceId: instance.id }),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message);
|
setError((err as Error).message);
|
||||||
|
|||||||
@@ -101,14 +101,8 @@ class ChannelManager:
|
|||||||
webui_runtime_surface: str = "browser",
|
webui_runtime_surface: str = "browser",
|
||||||
webui_runtime_capabilities: dict[str, Any] | None = None,
|
webui_runtime_capabilities: dict[str, Any] | None = None,
|
||||||
webui_skill_state_action: Callable[[set[str]], None] | 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 = config
|
||||||
self._config_path = config_path.expanduser().resolve(strict=False)
|
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
self._session_manager = session_manager
|
self._session_manager = session_manager
|
||||||
self._cron_service = cron_service
|
self._cron_service = cron_service
|
||||||
@@ -176,7 +170,6 @@ class ChannelManager:
|
|||||||
static_dist_path=static_path,
|
static_dist_path=static_path,
|
||||||
workspace_path=workspace,
|
workspace_path=workspace,
|
||||||
default_restrict_to_workspace=self.config.tools.restrict_to_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),
|
disabled_skills=set(self.config.agents.defaults.disabled_skills),
|
||||||
runtime_model_name=self._webui_runtime_model_name,
|
runtime_model_name=self._webui_runtime_model_name,
|
||||||
runtime_surface=self._webui_runtime_surface,
|
runtime_surface=self._webui_runtime_surface,
|
||||||
|
|||||||
@@ -373,13 +373,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._conn_default: dict[ServerConnection, str] = {}
|
self._conn_default: dict[ServerConnection, str] = {}
|
||||||
# Connections authenticated with a one-time token from /webui/bootstrap.
|
# Connections authenticated with a one-time token from /webui/bootstrap.
|
||||||
self._webui_connections: set[ServerConnection] = set()
|
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._stop_event: asyncio.Event | None = None
|
||||||
self._server_task: asyncio.Task[None] | None = None
|
self._server_task: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
@@ -765,9 +758,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Route one typed inbound envelope (``new_chat`` / ``attach`` / ``message``)."""
|
"""Route one typed inbound envelope (``new_chat`` / ``attach`` / ``message``)."""
|
||||||
t = envelope.get("type")
|
t = envelope.get("type")
|
||||||
if t == "webui_request":
|
|
||||||
await self._start_webui_request(connection, envelope)
|
|
||||||
return
|
|
||||||
if t == "new_chat":
|
if t == "new_chat":
|
||||||
new_id = str(uuid.uuid4())
|
new_id = str(uuid.uuid4())
|
||||||
scope = await self._workspace_scope_or_error(
|
scope = await self._workspace_scope_or_error(
|
||||||
@@ -1115,152 +1105,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
await self._send_event(connection, "error", detail=f"unknown type: {t!r}")
|
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(
|
async def _workspace_scope_or_error(
|
||||||
self,
|
self,
|
||||||
connection: ServerConnection,
|
connection: ServerConnection,
|
||||||
@@ -1301,12 +1145,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning("server task error during shutdown: {}", e)
|
self.logger.warning("server task error during shutdown: {}", e)
|
||||||
self._server_task = None
|
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._subs.clear()
|
||||||
self._conn_chats.clear()
|
self._conn_chats.clear()
|
||||||
self._conn_default.clear()
|
self._conn_default.clear()
|
||||||
|
|||||||
@@ -3,16 +3,12 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
import uuid
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pytest
|
import pytest
|
||||||
import websockets
|
import websockets
|
||||||
from websockets.datastructures import Headers
|
|
||||||
from websockets.exceptions import ConnectionClosed
|
from websockets.exceptions import ConnectionClosed
|
||||||
from websockets.frames import Close
|
from websockets.frames import Close
|
||||||
|
|
||||||
@@ -46,12 +42,6 @@ from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
|
|||||||
from nanobot.session import webui_turns as wth
|
from nanobot.session import webui_turns as wth
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
|
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 (
|
from nanobot.webui.http_utils import (
|
||||||
issue_route_secret_matches as _issue_route_secret_matches,
|
issue_route_secret_matches as _issue_route_secret_matches,
|
||||||
)
|
)
|
||||||
@@ -129,38 +119,6 @@ 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
|
@pytest.mark.asyncio
|
||||||
async def test_stop_treats_cancelled_server_task_as_shutdown() -> None:
|
async def test_stop_treats_cancelled_server_task_as_shutdown() -> None:
|
||||||
channel = _ch(MessageBus())
|
channel = _ch(MessageBus())
|
||||||
@@ -899,98 +857,6 @@ def test_only_bootstrap_tokens_mark_webui_connections(bus: MagicMock) -> None:
|
|||||||
assert client_connection not in channel._webui_connections
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_webui_persists_sidebar_state_larger_than_http_request_line(
|
async def test_webui_persists_sidebar_state_larger_than_http_request_line(
|
||||||
bus: MagicMock,
|
bus: MagicMock,
|
||||||
@@ -1000,33 +866,23 @@ async def test_webui_persists_sidebar_state_larger_than_http_request_line(
|
|||||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||||
channel = _ch(bus)
|
channel = _ch(bus)
|
||||||
conn = AsyncMock()
|
conn = AsyncMock()
|
||||||
conn.request = SimpleNamespace(headers=Headers())
|
|
||||||
channel._webui_connections.add(conn)
|
channel._webui_connections.add(conn)
|
||||||
session_order = [f"websocket:{index:04d}-{'x' * 48}" for index in range(160)]
|
session_order = [f"websocket:{index:04d}-{'x' * 48}" for index in range(160)]
|
||||||
request_id = "sidebar-large-state"
|
|
||||||
envelope = {
|
envelope = {
|
||||||
"type": "webui_request",
|
"type": "set_sidebar_state",
|
||||||
"request_id": request_id,
|
"state": {
|
||||||
"action": "sidebar.update",
|
|
||||||
"payload": {"state": {
|
|
||||||
"session_order": session_order,
|
"session_order": session_order,
|
||||||
"view": {"sort": "manual"},
|
"view": {"sort": "manual"},
|
||||||
}},
|
},
|
||||||
}
|
}
|
||||||
assert len(json.dumps(envelope).encode()) > 8_192
|
assert len(json.dumps(envelope).encode()) > 8_192
|
||||||
|
|
||||||
await channel._dispatch_envelope(conn, "webui-client", envelope)
|
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"))
|
saved = json.loads((tmp_path / "webui" / "sidebar-state.json").read_text(encoding="utf-8"))
|
||||||
assert saved["session_order"] == session_order
|
assert saved["session_order"] == session_order
|
||||||
assert saved["view"]["sort"] == "manual"
|
assert saved["view"]["sort"] == "manual"
|
||||||
assert json.loads(conn.send.await_args.args[0]) == {
|
conn.send.assert_not_awaited()
|
||||||
"event": "webui_response",
|
|
||||||
"request_id": request_id,
|
|
||||||
"ok": True,
|
|
||||||
"result": saved,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -3031,15 +2887,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
server_task = asyncio.create_task(channel.start())
|
server_task = asyncio.create_task(channel.start())
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
webui_client = None
|
|
||||||
try:
|
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(
|
settings = await _http_get(
|
||||||
f"http://127.0.0.1:{port}/api/settings",
|
f"http://127.0.0.1:{port}/api/settings",
|
||||||
headers={"Authorization": "Bearer tok"},
|
headers={"Authorization": "Bearer tok"},
|
||||||
@@ -3123,14 +2971,11 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert unknown_api.status_code == 404
|
assert unknown_api.status_code == 404
|
||||||
assert "<!doctype html>" not in unknown_api.text.lower()
|
assert "<!doctype html>" not in unknown_api.text.lower()
|
||||||
|
|
||||||
provider_updated = await _webui_mutate(
|
provider_updated = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.provider.update",
|
f"{port}/api/settings/provider/update?provider=openrouter"
|
||||||
{
|
"&api_key=sk-or-test&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1",
|
||||||
"provider": "openrouter",
|
headers={"Authorization": "Bearer tok"},
|
||||||
"apiKey": "sk-or-test",
|
|
||||||
"apiBase": "https://openrouter.ai/api/v1",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
assert provider_updated.status_code == 200
|
assert provider_updated.status_code == 200
|
||||||
provider_body = provider_updated.json()
|
provider_body = provider_updated.json()
|
||||||
@@ -3140,18 +2985,22 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert provider_body["image_generation"]["provider_configured"] is True
|
assert provider_body["image_generation"]["provider_configured"] is True
|
||||||
assert "sk-or-test" not in provider_updated.text
|
assert "sk-or-test" not in provider_updated.text
|
||||||
|
|
||||||
custom_provider_created = await _webui_mutate(
|
custom_provider_created = await _http_get(
|
||||||
webui_client,
|
f"http://127.0.0.1:{port}/api/settings/provider/create",
|
||||||
"settings.provider.create",
|
headers={
|
||||||
{
|
"Authorization": "Bearer tok",
|
||||||
"name": "Company Gateway",
|
"X-Nanobot-Provider-Values": json.dumps(
|
||||||
"apiBase": "https://gateway.example/v1",
|
{
|
||||||
"apiKey": "sk-company",
|
"name": "Company Gateway",
|
||||||
"extraHeaders": json.dumps({"X-Tenant": "engineering"}),
|
"apiBase": "https://gateway.example/v1",
|
||||||
"extraBody": json.dumps({"service_tier": "priority"}),
|
"apiKey": "sk-company",
|
||||||
"extraQuery": json.dumps({"api-version": "2026-01-01"}),
|
"extraHeaders": json.dumps({"X-Tenant": "engineering"}),
|
||||||
"proxy": "http://127.0.0.1:7890",
|
"extraBody": json.dumps({"service_tier": "priority"}),
|
||||||
"thinkingStyle": "enable_thinking",
|
"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
|
assert custom_provider_created.status_code == 200
|
||||||
@@ -3166,10 +3015,11 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
}
|
}
|
||||||
assert "sk-company" not in custom_provider_created.text
|
assert "sk-company" not in custom_provider_created.text
|
||||||
|
|
||||||
local_provider_updated = await _webui_mutate(
|
local_provider_updated = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.provider.update",
|
f"{port}/api/settings/provider/update?provider=atomic_chat"
|
||||||
{"provider": "atomic_chat", "apiBase": "http://localhost:1337/v1"},
|
"&api_base=http%3A%2F%2Flocalhost%3A1337%2Fv1",
|
||||||
|
headers={"Authorization": "Bearer tok"},
|
||||||
)
|
)
|
||||||
assert local_provider_updated.status_code == 200
|
assert local_provider_updated.status_code == 200
|
||||||
local_provider_body = local_provider_updated.json()
|
local_provider_body = local_provider_updated.json()
|
||||||
@@ -3179,44 +3029,38 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert local_provider_rows["atomic_chat"]["configured"] is True
|
assert local_provider_rows["atomic_chat"]["configured"] is True
|
||||||
assert "localhost:1337" in local_provider_updated.text
|
assert "localhost:1337" in local_provider_updated.text
|
||||||
|
|
||||||
updated = await _webui_mutate(
|
updated = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.agent.update",
|
f"{port}/api/settings/update?model=atomic_chat/test"
|
||||||
{
|
"&provider=atomic_chat&timezone=Asia%2FShanghai"
|
||||||
"model": "atomic_chat/test",
|
"&bot_name=Nano&bot_icon=N&tool_hint_max_length=120",
|
||||||
"provider": "atomic_chat",
|
headers={"Authorization": "Bearer tok"},
|
||||||
"timezone": "Asia/Shanghai",
|
|
||||||
"tool_hint_max_length": 120,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
assert updated.status_code == 200
|
assert updated.status_code == 200
|
||||||
updated_body = updated.json()
|
updated_body = updated.json()
|
||||||
assert updated_body["requires_restart"] is True
|
assert updated_body["requires_restart"] is True
|
||||||
assert updated_body["restart_required_sections"] == ["runtime"]
|
assert updated_body["restart_required_sections"] == ["runtime"]
|
||||||
|
|
||||||
preset_updated = await _webui_mutate(
|
preset_updated = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.agent.update",
|
f"{port}/api/settings/update?model_preset=deep",
|
||||||
{"model_preset": "deep"},
|
headers={"Authorization": "Bearer tok"},
|
||||||
)
|
)
|
||||||
assert preset_updated.status_code == 200
|
assert preset_updated.status_code == 200
|
||||||
assert preset_updated.json()["agent"]["model"] == "anthropic/claude-opus-4-5"
|
assert preset_updated.json()["agent"]["model"] == "anthropic/claude-opus-4-5"
|
||||||
|
|
||||||
bad_preset = await _webui_mutate(
|
bad_preset = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.agent.update",
|
f"{port}/api/settings/update?model_preset=missing",
|
||||||
{"model_preset": "missing"},
|
headers={"Authorization": "Bearer tok"},
|
||||||
)
|
)
|
||||||
assert bad_preset.status_code == 400
|
assert bad_preset.status_code == 400
|
||||||
|
|
||||||
created_preset = await _webui_mutate(
|
created_preset = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.model_configuration.create",
|
f"{port}/api/settings/model-configurations/create"
|
||||||
{
|
"?label=Fast%20writing&provider=openai&model=openai%2Fgpt-4.1-mini",
|
||||||
"label": "Fast writing",
|
headers={"Authorization": "Bearer tok"},
|
||||||
"provider": "openai",
|
|
||||||
"model": "openai/gpt-4.1-mini",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
assert created_preset.status_code == 200
|
assert created_preset.status_code == 200
|
||||||
created_body = created_preset.json()
|
created_body = created_preset.json()
|
||||||
@@ -3230,15 +3074,11 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert created_presets["fast-writing"]["label"] == "Fast writing"
|
assert created_presets["fast-writing"]["label"] == "Fast writing"
|
||||||
assert created_presets["fast-writing"]["provider"] == "openai"
|
assert created_presets["fast-writing"]["provider"] == "openai"
|
||||||
|
|
||||||
updated_preset = await _webui_mutate(
|
updated_preset = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.model_configuration.update",
|
f"{port}/api/settings/model-configurations/update"
|
||||||
{
|
"?name=fast-writing&label=Codex&provider=openai&model=openai%2Fgpt-5.5",
|
||||||
"name": "fast-writing",
|
headers={"Authorization": "Bearer tok"},
|
||||||
"label": "Codex",
|
|
||||||
"provider": "openai",
|
|
||||||
"model": "openai/gpt-5.5",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
assert updated_preset.status_code == 200
|
assert updated_preset.status_code == 200
|
||||||
updated_preset_body = updated_preset.json()
|
updated_preset_body = updated_preset.json()
|
||||||
@@ -3249,10 +3089,11 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
}
|
}
|
||||||
assert updated_presets["fast-writing"]["label"] == "Codex"
|
assert updated_presets["fast-writing"]["label"] == "Codex"
|
||||||
|
|
||||||
call_order_updated = await _webui_mutate(
|
call_order_updated = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.model_call_order.update",
|
f"{port}/api/settings/model-call-order/update"
|
||||||
{"order": ["fast-writing", "deep"]},
|
"?order=%5B%22fast-writing%22%2C%22deep%22%5D",
|
||||||
|
headers={"Authorization": "Bearer tok"},
|
||||||
)
|
)
|
||||||
assert call_order_updated.status_code == 200
|
assert call_order_updated.status_code == 200
|
||||||
call_order_body = call_order_updated.json()
|
call_order_body = call_order_updated.json()
|
||||||
@@ -3260,27 +3101,20 @@ 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["agent"]["model"] == "openai/gpt-5.5"
|
||||||
assert call_order_body["model_call_order"] == ["fast-writing", "deep"]
|
assert call_order_body["model_call_order"] == ["fast-writing", "deep"]
|
||||||
|
|
||||||
duplicate_preset = await _webui_mutate(
|
duplicate_preset = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.model_configuration.create",
|
f"{port}/api/settings/model-configurations/create"
|
||||||
{
|
"?label=Fast%20writing&provider=openai&model=openai%2Fgpt-4.1-mini",
|
||||||
"label": "Fast writing",
|
headers={"Authorization": "Bearer tok"},
|
||||||
"provider": "openai",
|
|
||||||
"model": "openai/gpt-4.1-mini",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
assert duplicate_preset.status_code == 409
|
assert duplicate_preset.status_code == 409
|
||||||
|
|
||||||
search_updated = await _webui_mutate(
|
search_updated = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.web_search.update",
|
f"{port}/api/settings/web-search/update?provider=searxng"
|
||||||
{
|
"&base_url=https%3A%2F%2Fsearch.example.com"
|
||||||
"provider": "searxng",
|
"&max_results=8&timeout=45&use_jina_reader=false",
|
||||||
"base_url": "https://search.example.com",
|
headers={"Authorization": "Bearer tok"},
|
||||||
"max_results": 8,
|
|
||||||
"timeout": 45,
|
|
||||||
"use_jina_reader": False,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
assert search_updated.status_code == 200
|
assert search_updated.status_code == 200
|
||||||
search_body = search_updated.json()
|
search_body = search_updated.json()
|
||||||
@@ -3292,13 +3126,10 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert search_body["web_search"]["max_results"] == 8
|
assert search_body["web_search"]["max_results"] == 8
|
||||||
assert search_body["web"]["fetch"]["use_jina_reader"] is False
|
assert search_body["web"]["fetch"]["use_jina_reader"] is False
|
||||||
|
|
||||||
network_safety_updated = await _webui_mutate(
|
network_safety_updated = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.network_safety.update",
|
f"{port}/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=full",
|
||||||
{
|
headers={"Authorization": "Bearer tok"},
|
||||||
"webui_allow_local_service_access": False,
|
|
||||||
"webui_default_access_mode": "full",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
assert network_safety_updated.status_code == 200
|
assert network_safety_updated.status_code == 200
|
||||||
network_safety_body = network_safety_updated.json()
|
network_safety_body = network_safety_updated.json()
|
||||||
@@ -3308,17 +3139,13 @@ 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"]["webui_default_access_mode"] == "full"
|
||||||
assert network_safety_body["advanced"]["private_service_protection_enabled"] is True
|
assert network_safety_body["advanced"]["private_service_protection_enabled"] is True
|
||||||
|
|
||||||
image_updated = await _webui_mutate(
|
image_updated = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.image_generation.update",
|
f"{port}/api/settings/image-generation/update?enabled=true"
|
||||||
{
|
"&provider=openrouter&model=openai%2Fgpt-image-1"
|
||||||
"enabled": True,
|
"&default_aspect_ratio=16%3A9&default_image_size=2K"
|
||||||
"provider": "openrouter",
|
"&max_images_per_turn=3",
|
||||||
"model": "openai/gpt-image-1",
|
headers={"Authorization": "Bearer tok"},
|
||||||
"default_aspect_ratio": "16:9",
|
|
||||||
"default_image_size": "2K",
|
|
||||||
"max_images_per_turn": 3,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
assert image_updated.status_code == 200
|
assert image_updated.status_code == 200
|
||||||
image_body = image_updated.json()
|
image_body = image_updated.json()
|
||||||
@@ -3330,14 +3157,11 @@ 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"]["default_image_size"] == "2K"
|
||||||
assert image_body["image_generation"]["max_images_per_turn"] == 3
|
assert image_body["image_generation"]["max_images_per_turn"] == 3
|
||||||
|
|
||||||
image_provider_updated = await _webui_mutate(
|
image_provider_updated = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.provider.update",
|
f"{port}/api/settings/provider/update?provider=openrouter"
|
||||||
{
|
"&api_key=sk-or-next&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1",
|
||||||
"provider": "openrouter",
|
headers={"Authorization": "Bearer tok"},
|
||||||
"apiKey": "sk-or-next",
|
|
||||||
"apiBase": "https://openrouter.ai/api/v1",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
assert image_provider_updated.status_code == 200
|
assert image_provider_updated.status_code == 200
|
||||||
assert image_provider_updated.json()["requires_restart"] is True
|
assert image_provider_updated.json()["requires_restart"] is True
|
||||||
@@ -3345,17 +3169,17 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert "sk-or-next" not in image_provider_updated.text
|
assert "sk-or-next" not in image_provider_updated.text
|
||||||
assert image_reload.await_count == 2
|
assert image_reload.await_count == 2
|
||||||
|
|
||||||
bad_web = await _webui_mutate(
|
bad_web = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.web_search.update",
|
f"{port}/api/settings/web-search/update?provider=duckduckgo&max_results=99",
|
||||||
{"provider": "duckduckgo", "max_results": 99},
|
headers={"Authorization": "Bearer tok"},
|
||||||
)
|
)
|
||||||
assert bad_web.status_code == 400
|
assert bad_web.status_code == 400
|
||||||
|
|
||||||
bad_image = await _webui_mutate(
|
bad_image = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.image_generation.update",
|
f"{port}/api/settings/image-generation/update?provider=missing",
|
||||||
{"provider": "missing"},
|
headers={"Authorization": "Bearer tok"},
|
||||||
)
|
)
|
||||||
assert bad_image.status_code == 400
|
assert bad_image.status_code == 400
|
||||||
|
|
||||||
@@ -3392,8 +3216,6 @@ 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.default_image_size == "2K"
|
||||||
assert saved.tools.image_generation.max_images_per_turn == 3
|
assert saved.tools.image_generation.max_images_per_turn == 3
|
||||||
finally:
|
finally:
|
||||||
if webui_client is not None:
|
|
||||||
await webui_client.close()
|
|
||||||
await channel.stop()
|
await channel.stop()
|
||||||
await server_task
|
await server_task
|
||||||
|
|
||||||
@@ -3426,17 +3248,11 @@ async def test_image_settings_hot_reload_without_restart(
|
|||||||
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
|
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
|
||||||
server_task = asyncio.create_task(channel.start())
|
server_task = asyncio.create_task(channel.start())
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
webui_client = None
|
|
||||||
try:
|
try:
|
||||||
webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
|
response = await _http_get(
|
||||||
webui_client = await websockets.connect(
|
f"http://127.0.0.1:{port}/api/settings/image-generation/update"
|
||||||
f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=image-reload-test"
|
"?enabled=true&provider=openrouter&model=openai%2Fgpt-image-1",
|
||||||
)
|
headers={"Authorization": "Bearer tok"},
|
||||||
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.status_code == 200
|
||||||
@@ -3444,8 +3260,6 @@ async def test_image_settings_hot_reload_without_restart(
|
|||||||
assert response.json()["restart_required_sections"] == []
|
assert response.json()["restart_required_sections"] == []
|
||||||
image_reload.assert_awaited_once_with(bus)
|
image_reload.assert_awaited_once_with(bus)
|
||||||
finally:
|
finally:
|
||||||
if webui_client is not None:
|
|
||||||
await webui_client.close()
|
|
||||||
await channel.stop()
|
await channel.stop()
|
||||||
await server_task
|
await server_task
|
||||||
|
|
||||||
@@ -3477,25 +3291,17 @@ async def test_image_settings_fall_back_to_restart_when_hot_reload_fails(
|
|||||||
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
|
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
|
||||||
server_task = asyncio.create_task(channel.start())
|
server_task = asyncio.create_task(channel.start())
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
webui_client = None
|
|
||||||
try:
|
try:
|
||||||
webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
|
response = await _http_get(
|
||||||
webui_client = await websockets.connect(
|
f"http://127.0.0.1:{port}/api/settings/image-generation/update"
|
||||||
f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=image-fallback-test"
|
"?enabled=true&provider=openrouter&model=openai%2Fgpt-image-1",
|
||||||
)
|
headers={"Authorization": "Bearer tok"},
|
||||||
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.status_code == 200
|
||||||
assert response.json()["requires_restart"] is True
|
assert response.json()["requires_restart"] is True
|
||||||
assert response.json()["restart_required_sections"] == ["image"]
|
assert response.json()["restart_required_sections"] == ["image"]
|
||||||
finally:
|
finally:
|
||||||
if webui_client is not None:
|
|
||||||
await webui_client.close()
|
|
||||||
await channel.stop()
|
await channel.stop()
|
||||||
await server_task
|
await server_task
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,6 @@ class WeixinConnectSession:
|
|||||||
channel: WeixinChannel
|
channel: WeixinChannel
|
||||||
current_poll_base_url: str
|
current_poll_base_url: str
|
||||||
refresh_count: int
|
refresh_count: int
|
||||||
force: bool
|
|
||||||
created_wall: float
|
created_wall: float
|
||||||
deadline: float
|
deadline: float
|
||||||
last_error: str | None = None
|
last_error: str | None = None
|
||||||
@@ -73,7 +72,7 @@ class WeixinConnectStore:
|
|||||||
|
|
||||||
channel.connect_open_client()
|
channel.connect_open_client()
|
||||||
try:
|
try:
|
||||||
qrcode_id, qr_url = await channel.connect_fetch_qr_code(force=force)
|
qrcode_id, qr_url = await channel.connect_fetch_qr_code()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
await self._close_channel(channel)
|
await self._close_channel(channel)
|
||||||
raise ChannelConnectError(
|
raise ChannelConnectError(
|
||||||
@@ -90,7 +89,6 @@ class WeixinConnectStore:
|
|||||||
channel=channel,
|
channel=channel,
|
||||||
current_poll_base_url=channel.connect_base_url,
|
current_poll_base_url=channel.connect_base_url,
|
||||||
refresh_count=0,
|
refresh_count=0,
|
||||||
force=force,
|
|
||||||
created_wall=now_wall,
|
created_wall=now_wall,
|
||||||
deadline=time.monotonic() + 600,
|
deadline=time.monotonic() + 600,
|
||||||
)
|
)
|
||||||
@@ -189,7 +187,7 @@ class WeixinConnectStore:
|
|||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
session.qrcode_id, session.qr_url = (
|
session.qrcode_id, session.qr_url = (
|
||||||
await session.channel.connect_fetch_qr_code(force=session.force)
|
await session.channel.connect_fetch_qr_code()
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._sessions.pop(session_id, None)
|
self._sessions.pop(session_id, None)
|
||||||
@@ -206,17 +204,6 @@ class WeixinConnectStore:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if status == "binded_redirect":
|
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():
|
if not session.channel.connect_load_state():
|
||||||
self._sessions.pop(session_id, None)
|
self._sessions.pop(session_id, None)
|
||||||
await self._close_channel(session.channel)
|
await self._close_channel(session.channel)
|
||||||
@@ -247,7 +234,7 @@ class WeixinConnectStore:
|
|||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
session.qrcode_id, session.qr_url = (
|
session.qrcode_id, session.qr_url = (
|
||||||
await session.channel.connect_fetch_qr_code(force=session.force)
|
await session.channel.connect_fetch_qr_code()
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._sessions.pop(session_id, None)
|
self._sessions.pop(session_id, None)
|
||||||
|
|||||||
@@ -726,9 +726,9 @@ class WeixinChannel(BaseChannel):
|
|||||||
break
|
break
|
||||||
return tokens
|
return tokens
|
||||||
|
|
||||||
async def _fetch_qr_code(self, *, force: bool = False) -> tuple[str, str]:
|
async def _fetch_qr_code(self) -> tuple[str, str]:
|
||||||
"""Fetch a QR code without existing credentials when forced."""
|
"""Fetch a fresh QR code. Returns (qrcode_id, scan_url)."""
|
||||||
local_tokens = [] if force else self._local_token_list()
|
local_tokens = self._local_token_list()
|
||||||
data = await self._api_post(
|
data = await self._api_post(
|
||||||
"ilink/bot/get_bot_qrcode?bot_type=3",
|
"ilink/bot/get_bot_qrcode?bot_type=3",
|
||||||
{"local_token_list": local_tokens},
|
{"local_token_list": local_tokens},
|
||||||
@@ -755,11 +755,11 @@ class WeixinChannel(BaseChannel):
|
|||||||
raise RuntimeError(f"Failed to get QR code from WeChat API: {data}")
|
raise RuntimeError(f"Failed to get QR code from WeChat API: {data}")
|
||||||
return qrcode_id, (qrcode_img_content or qrcode_id)
|
return qrcode_id, (qrcode_img_content or qrcode_id)
|
||||||
|
|
||||||
async def _qr_login(self, *, force: bool = False) -> bool:
|
async def _qr_login(self) -> bool:
|
||||||
"""Perform QR login; forced flows accept only newly confirmed credentials."""
|
"""Perform QR code login flow. Returns True on success."""
|
||||||
try:
|
try:
|
||||||
refresh_count = 0
|
refresh_count = 0
|
||||||
qrcode_id, scan_url = await self._fetch_qr_code(force=force)
|
qrcode_id, scan_url = await self._fetch_qr_code()
|
||||||
self._print_qr_code(scan_url)
|
self._print_qr_code(scan_url)
|
||||||
current_poll_base_url = self.config.base_url
|
current_poll_base_url = self.config.base_url
|
||||||
verify_code = ""
|
verify_code = ""
|
||||||
@@ -825,16 +825,11 @@ class WeixinChannel(BaseChannel):
|
|||||||
if refresh_count > MAX_QR_REFRESH_COUNT:
|
if refresh_count > MAX_QR_REFRESH_COUNT:
|
||||||
self.logger.warning("WeChat verification failed too many times")
|
self.logger.warning("WeChat verification failed too many times")
|
||||||
return False
|
return False
|
||||||
qrcode_id, scan_url = await self._fetch_qr_code(force=force)
|
qrcode_id, scan_url = await self._fetch_qr_code()
|
||||||
current_poll_base_url = self.config.base_url
|
current_poll_base_url = self.config.base_url
|
||||||
self._print_qr_code(scan_url)
|
self._print_qr_code(scan_url)
|
||||||
continue
|
continue
|
||||||
elif status == "binded_redirect":
|
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():
|
if self._token or self._load_state():
|
||||||
self.logger.info("WeChat account is already connected")
|
self.logger.info("WeChat account is already connected")
|
||||||
return True
|
return True
|
||||||
@@ -851,7 +846,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
MAX_QR_REFRESH_COUNT,
|
MAX_QR_REFRESH_COUNT,
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
qrcode_id, scan_url = await self._fetch_qr_code(force=force)
|
qrcode_id, scan_url = await self._fetch_qr_code()
|
||||||
current_poll_base_url = self.config.base_url
|
current_poll_base_url = self.config.base_url
|
||||||
verify_code = ""
|
verify_code = ""
|
||||||
self._print_qr_code(scan_url)
|
self._print_qr_code(scan_url)
|
||||||
@@ -898,8 +893,8 @@ class WeixinChannel(BaseChannel):
|
|||||||
self._client = self._new_http_client(httpx.Timeout(60, connect=30))
|
self._client = self._new_http_client(httpx.Timeout(60, connect=30))
|
||||||
self._running = True
|
self._running = True
|
||||||
|
|
||||||
async def connect_fetch_qr_code(self, *, force: bool = False) -> tuple[str, str]:
|
async def connect_fetch_qr_code(self) -> tuple[str, str]:
|
||||||
return await self._fetch_qr_code(force=force)
|
return await self._fetch_qr_code()
|
||||||
|
|
||||||
async def connect_poll_qr_code(
|
async def connect_poll_qr_code(
|
||||||
self,
|
self,
|
||||||
@@ -952,14 +947,14 @@ class WeixinChannel(BaseChannel):
|
|||||||
if force:
|
if force:
|
||||||
self._token = ""
|
self._token = ""
|
||||||
self._get_updates_buf = ""
|
self._get_updates_buf = ""
|
||||||
if self._token or (not force and self._load_state()):
|
if self._token or self._load_state():
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Initialize HTTP client for the login flow
|
# Initialize HTTP client for the login flow
|
||||||
self._client = self._new_http_client(httpx.Timeout(60, connect=30))
|
self._client = self._new_http_client(httpx.Timeout(60, connect=30))
|
||||||
self._running = True # Enable polling loop in _qr_login()
|
self._running = True # Enable polling loop in _qr_login()
|
||||||
try:
|
try:
|
||||||
return await self._qr_login(force=force)
|
return await self._qr_login()
|
||||||
finally:
|
finally:
|
||||||
self._running = False
|
self._running = False
|
||||||
if self._client:
|
if self._client:
|
||||||
|
|||||||
@@ -25,9 +25,7 @@ async def test_weixin_connect_store_saves_confirmed_qr_login(
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
async def fake_fetch_qr_code(
|
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||||
self: WeixinChannel, **_kwargs: Any
|
|
||||||
) -> tuple[str, str]:
|
|
||||||
return "qr-1", "https://qr.example/1"
|
return "qr-1", "https://qr.example/1"
|
||||||
|
|
||||||
async def fake_api_get_with_base(
|
async def fake_api_get_with_base(
|
||||||
@@ -88,31 +86,14 @@ async def test_weixin_reconnect_keeps_existing_account_until_scan_succeeds(
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
observed_force: list[bool] = []
|
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||||
|
return "qr-reconnect", "https://qr.example/reconnect"
|
||||||
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, "_fetch_qr_code", fake_fetch_qr_code)
|
||||||
monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base)
|
|
||||||
|
|
||||||
store = WeixinConnectStore()
|
store = WeixinConnectStore()
|
||||||
started = await store.start(force=True)
|
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
|
assert json.loads(state_file.read_text(encoding="utf-8")) == existing
|
||||||
cancelled = await store.cancel(started["session_id"])
|
cancelled = await store.cancel(started["session_id"])
|
||||||
assert cancelled["status"] == "cancelled"
|
assert cancelled["status"] == "cancelled"
|
||||||
@@ -135,9 +116,7 @@ async def test_weixin_cancel_wins_over_inflight_confirmation(
|
|||||||
poll_started = asyncio.Event()
|
poll_started = asyncio.Event()
|
||||||
release_poll = asyncio.Event()
|
release_poll = asyncio.Event()
|
||||||
|
|
||||||
async def fake_fetch_qr_code(
|
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||||
self: WeixinChannel, **_kwargs: Any
|
|
||||||
) -> tuple[str, str]:
|
|
||||||
return "qr-cancel", "https://qr.example/cancel"
|
return "qr-cancel", "https://qr.example/cancel"
|
||||||
|
|
||||||
async def fake_api_get_with_base(
|
async def fake_api_get_with_base(
|
||||||
@@ -183,9 +162,7 @@ async def test_weixin_connect_store_handles_verification_code(
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
async def fake_fetch_qr_code(
|
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||||
self: WeixinChannel, **_kwargs: Any
|
|
||||||
) -> tuple[str, str]:
|
|
||||||
return "qr-verify", "https://qr.example/verify"
|
return "qr-verify", "https://qr.example/verify"
|
||||||
|
|
||||||
responses = [
|
responses = [
|
||||||
@@ -227,7 +204,7 @@ async def test_weixin_connect_store_handles_verification_code(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_weixin_connect_store_rejects_existing_binding_during_forced_login(
|
async def test_weixin_connect_store_treats_existing_binding_as_success(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -244,12 +221,7 @@ async def test_weixin_connect_store_rejects_existing_binding_during_forced_login
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
async def fake_fetch_qr_code(
|
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||||
self: WeixinChannel,
|
|
||||||
*,
|
|
||||||
force: bool = False,
|
|
||||||
) -> tuple[str, str]:
|
|
||||||
assert force is True
|
|
||||||
return "qr-existing", "https://qr.example/existing"
|
return "qr-existing", "https://qr.example/existing"
|
||||||
|
|
||||||
async def fake_api_get_with_base(
|
async def fake_api_get_with_base(
|
||||||
@@ -265,8 +237,8 @@ async def test_weixin_connect_store_rejects_existing_binding_during_forced_login
|
|||||||
started = await store.start(force=True)
|
started = await store.start(force=True)
|
||||||
completed = await store.poll(started["session_id"])
|
completed = await store.poll(started["session_id"])
|
||||||
|
|
||||||
assert completed["status"] == "failed"
|
assert completed["status"] == "succeeded"
|
||||||
assert "new WeChat login" in completed["message"]
|
assert "already connected" in completed["message"]
|
||||||
assert json.loads((state_dir / "account.json").read_text())["token"] == "working-token"
|
assert json.loads((state_dir / "account.json").read_text())["token"] == "working-token"
|
||||||
|
|
||||||
|
|
||||||
@@ -283,9 +255,7 @@ async def test_weixin_connect_store_rejects_existing_binding_without_local_crede
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
async def fake_fetch_qr_code(
|
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||||
self: WeixinChannel, **_kwargs: Any
|
|
||||||
) -> tuple[str, str]:
|
|
||||||
return "qr-missing", "https://qr.example/missing"
|
return "qr-missing", "https://qr.example/missing"
|
||||||
|
|
||||||
async def fake_api_get_with_base(
|
async def fake_api_get_with_base(
|
||||||
@@ -298,7 +268,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)
|
monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base)
|
||||||
|
|
||||||
store = WeixinConnectStore()
|
store = WeixinConnectStore()
|
||||||
started = await store.start(force=False)
|
started = await store.start(force=True)
|
||||||
completed = await store.poll(started["session_id"])
|
completed = await store.poll(started["session_id"])
|
||||||
|
|
||||||
assert completed["status"] == "failed"
|
assert completed["status"] == "failed"
|
||||||
|
|||||||
@@ -196,86 +196,6 @@ def test_save_state_with_empty_runtime_token_preserves_persisted_account(tmp_pat
|
|||||||
assert json.loads((tmp_path / "account.json").read_text()) == persisted
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_process_message_deduplicates_inbound_ids() -> None:
|
async def test_process_message_deduplicates_inbound_ids() -> None:
|
||||||
channel, bus = _make_channel()
|
channel, bus = _make_channel()
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import type {
|
|||||||
NanobotFeatureInfo,
|
NanobotFeatureInfo,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useClient } from "@/providers/ClientProvider";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
WEIXIN_AUTH_EXPIRED_MESSAGE,
|
WEIXIN_AUTH_EXPIRED_MESSAGE,
|
||||||
@@ -65,7 +64,6 @@ export function WeixinPanel({
|
|||||||
onAction,
|
onAction,
|
||||||
onFeaturesUpdate,
|
onFeaturesUpdate,
|
||||||
}: ChannelPluginPanelProps) {
|
}: ChannelPluginPanelProps) {
|
||||||
const { client } = useClient();
|
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||||
const channelTx = channelTranslator(t, "weixin");
|
const channelTx = channelTranslator(t, "weixin");
|
||||||
@@ -152,7 +150,7 @@ export function WeixinPanel({
|
|||||||
setSaveState("idle");
|
setSaveState("idle");
|
||||||
try {
|
try {
|
||||||
const payload = await configureChannel(
|
const payload = await configureChannel(
|
||||||
client,
|
context.token,
|
||||||
"weixin",
|
"weixin",
|
||||||
channelValuesForSave(editableFieldsRef.current, values),
|
channelValuesForSave(editableFieldsRef.current, values),
|
||||||
{ enable: context.enabled },
|
{ enable: context.enabled },
|
||||||
@@ -170,7 +168,7 @@ export function WeixinPanel({
|
|||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [client]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -669,7 +669,6 @@ def _run_gateway(
|
|||||||
webui_runtime_surface=webui_runtime_surface,
|
webui_runtime_surface=webui_runtime_surface,
|
||||||
webui_runtime_capabilities=webui_runtime_capabilities,
|
webui_runtime_capabilities=webui_runtime_capabilities,
|
||||||
webui_skill_state_action=_webui_skill_state_action,
|
webui_skill_state_action=_webui_skill_state_action,
|
||||||
config_path=Path(config_path),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _pick_heartbeat_target() -> tuple[str, str]:
|
def _pick_heartbeat_target() -> tuple[str, str]:
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ class ProviderSnapshot:
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class _ProviderSetup:
|
class _ProviderSetup:
|
||||||
model: str
|
model: str
|
||||||
provider_name: str
|
|
||||||
provider_config: ProviderConfig | None
|
provider_config: ProviderConfig | None
|
||||||
spec: ProviderSpec | None
|
spec: ProviderSpec | None
|
||||||
backend: str
|
backend: str
|
||||||
@@ -100,7 +99,6 @@ def _resolve_provider_setup(
|
|||||||
|
|
||||||
return _ProviderSetup(
|
return _ProviderSetup(
|
||||||
model=model,
|
model=model,
|
||||||
provider_name=provider_name,
|
|
||||||
provider_config=p,
|
provider_config=p,
|
||||||
spec=spec,
|
spec=spec,
|
||||||
backend=backend,
|
backend=backend,
|
||||||
@@ -136,7 +134,6 @@ def _make_provider_core(
|
|||||||
model=model,
|
model=model,
|
||||||
)
|
)
|
||||||
model = setup.model
|
model = setup.model
|
||||||
provider_name = setup.provider_name
|
|
||||||
p = setup.provider_config
|
p = setup.provider_config
|
||||||
spec = setup.spec
|
spec = setup.spec
|
||||||
backend = setup.backend
|
backend = setup.backend
|
||||||
@@ -201,7 +198,7 @@ def _make_provider_core(
|
|||||||
extra_headers=_provider_extra_headers(spec, p),
|
extra_headers=_provider_extra_headers(spec, p),
|
||||||
spec=spec,
|
spec=spec,
|
||||||
extra_body=p.extra_body if p else None,
|
extra_body=p.extra_body if p else None,
|
||||||
api_type=p.api_type if p and provider_name == "openai" else "auto",
|
api_type=p.api_type if p else "auto",
|
||||||
extra_query=p.extra_query if p else None,
|
extra_query=p.extra_query if p else None,
|
||||||
proxy=p.proxy if p else None,
|
proxy=p.proxy if p else None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ from nanobot.providers.openai_responses import (
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from openai import AsyncOpenAI as AsyncOpenAIType
|
from openai import AsyncOpenAI as AsyncOpenAIType
|
||||||
|
|
||||||
from nanobot.providers.registry import ProviderSpec
|
from nanobot.providers.registry import ProviderSpec, ResponsesCapabilities
|
||||||
|
|
||||||
# Module-level placeholder — set lazily by _ensure_client on first real
|
# Module-level placeholder — set lazily by _ensure_client on first real
|
||||||
# use, or replaced by tests via ``patch(...)``. Kept as a plain name so
|
# use, or replaced by tests via ``patch(...)``. Kept as a plain name so
|
||||||
@@ -496,7 +496,12 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
self.extra_headers = extra_headers or {}
|
self.extra_headers = extra_headers or {}
|
||||||
self._spec = spec
|
self._spec = spec
|
||||||
self._extra_body = dict(extra_body or {})
|
self._extra_body = dict(extra_body or {})
|
||||||
self._api_type = api_type if spec and spec.name == "openai" else "auto"
|
responses = spec.responses if spec is not None else None
|
||||||
|
self._api_type = (
|
||||||
|
api_type
|
||||||
|
if responses is not None and responses.allows_api_type_override
|
||||||
|
else "auto"
|
||||||
|
)
|
||||||
self._extra_query = extra_query or {}
|
self._extra_query = extra_query or {}
|
||||||
self._proxy = proxy or None
|
self._proxy = proxy or None
|
||||||
self._native_compaction_available = True
|
self._native_compaction_available = True
|
||||||
@@ -987,35 +992,33 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
"""Choose Responses for providers/models that explicitly support it."""
|
"""Choose Responses for providers/models that explicitly support it."""
|
||||||
if self._api_type == "chat_completions":
|
if self._api_type == "chat_completions":
|
||||||
return False
|
return False
|
||||||
spec_name = self._spec.name if self._spec is not None else None
|
capabilities = self._responses_capabilities()
|
||||||
model_name = self._request_model_name(model or self.default_model).lower()
|
if capabilities is None:
|
||||||
supported_models = {
|
|
||||||
supported.lower()
|
|
||||||
for supported in getattr(self._spec, "responses_models", ())
|
|
||||||
}
|
|
||||||
model_responses = any(
|
|
||||||
model_name == supported or model_name.endswith(f"/{supported}")
|
|
||||||
for supported in supported_models
|
|
||||||
)
|
|
||||||
provider_responses = spec_name in ("openai", "github_copilot")
|
|
||||||
if not provider_responses and not model_responses:
|
|
||||||
return False
|
return False
|
||||||
if self._responses_is_required():
|
model_name = self._request_model_name(model or self.default_model).lower()
|
||||||
# Explicit Responses-only request fields are mandatory; do not
|
if self._api_type == "responses":
|
||||||
|
# Explicit configuration means Responses is mandatory; do not
|
||||||
# consult the circuit breaker or fall back to Chat Completions.
|
# consult the circuit breaker or fall back to Chat Completions.
|
||||||
return True
|
return True
|
||||||
if provider_responses and (self._spec is None or self._spec.name != "github_copilot"):
|
|
||||||
if not _is_direct_openai_base(self._effective_base):
|
|
||||||
return False
|
|
||||||
|
|
||||||
wants = False
|
explicitly_supported = capabilities.matches_model(model_name)
|
||||||
if model_responses:
|
if self._hosted_web_search_enabled() and (
|
||||||
wants = True
|
capabilities.auto_route or explicitly_supported
|
||||||
elif reasoning_effort and reasoning_effort.lower() != "none":
|
):
|
||||||
wants = True
|
# Provider-hosted tools require Responses on models that the
|
||||||
elif any(token in model_name for token in ("gpt-5", "o1", "o3", "o4")):
|
# capability profile declares eligible for that transport.
|
||||||
wants = True
|
return True
|
||||||
if not wants:
|
if (
|
||||||
|
capabilities.requires_direct_openai_base
|
||||||
|
and not _is_direct_openai_base(self._effective_base)
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
|
||||||
|
wants_auto_route = capabilities.auto_route and (
|
||||||
|
(reasoning_effort is not None and reasoning_effort.lower() != "none")
|
||||||
|
or any(token in model_name for token in ("gpt-5", "o1", "o3", "o4"))
|
||||||
|
)
|
||||||
|
if not explicitly_supported and not wants_auto_route:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return self._responses_circuit_allows_probe(model, reasoning_effort)
|
return self._responses_circuit_allows_probe(model, reasoning_effort)
|
||||||
@@ -1039,6 +1042,9 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _responses_capabilities(self) -> ResponsesCapabilities | None:
|
||||||
|
return self._spec.responses if self._spec is not None else None
|
||||||
|
|
||||||
def _responses_state_provider(self) -> str:
|
def _responses_state_provider(self) -> str:
|
||||||
spec_name = self._spec.name if self._spec is not None else "custom"
|
spec_name = self._spec.name if self._spec is not None else "custom"
|
||||||
effective_base = self._effective_base or "https://api.openai.com/v1"
|
effective_base = self._effective_base or "https://api.openai.com/v1"
|
||||||
@@ -1061,14 +1067,20 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
def supports_native_compaction(self, model: str | None = None) -> bool:
|
def supports_native_compaction(self, model: str | None = None) -> bool:
|
||||||
"""Enable server compaction only on direct OpenAI Responses endpoints."""
|
"""Enable server compaction only on direct OpenAI Responses endpoints."""
|
||||||
_ = model
|
_ = model
|
||||||
|
capabilities = self._responses_capabilities()
|
||||||
if (
|
if (
|
||||||
not self._native_compaction_available
|
not self._native_compaction_available
|
||||||
or self._api_type == "chat_completions"
|
or self._api_type == "chat_completions"
|
||||||
|
or capabilities is None
|
||||||
|
or not capabilities.supports_native_compaction
|
||||||
):
|
):
|
||||||
return False
|
return False
|
||||||
if self._spec is not None and self._spec.name != "openai":
|
if (
|
||||||
|
capabilities.requires_direct_openai_base
|
||||||
|
and not _is_direct_openai_base(self._effective_base)
|
||||||
|
):
|
||||||
return False
|
return False
|
||||||
return _is_direct_openai_base(self._effective_base)
|
return True
|
||||||
|
|
||||||
def _responses_circuit_allows_probe(
|
def _responses_circuit_allows_probe(
|
||||||
self,
|
self,
|
||||||
@@ -1156,7 +1168,10 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
self._sanitize_empty_content(sanitized_state.pending_messages)
|
self._sanitize_empty_content(sanitized_state.pending_messages)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
preserve_reasoning = bool(self._spec and self._spec.name == "deepseek")
|
capabilities = self._responses_capabilities()
|
||||||
|
preserve_reasoning = (
|
||||||
|
capabilities is not None and capabilities.reasoning_replay == "plaintext"
|
||||||
|
)
|
||||||
instructions, input_items, replayed = prepare_responses_input(
|
instructions, input_items, replayed = prepare_responses_input(
|
||||||
sanitized_messages,
|
sanitized_messages,
|
||||||
state=sanitized_state,
|
state=sanitized_state,
|
||||||
@@ -1187,10 +1202,15 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
"compact_threshold": compact_threshold,
|
"compact_threshold": compact_threshold,
|
||||||
}]
|
}]
|
||||||
|
|
||||||
if self._supports_temperature(model_name, reasoning_effort):
|
supports_temperature = self._supports_temperature(model_name, reasoning_effort)
|
||||||
|
if supports_temperature:
|
||||||
body["temperature"] = temperature
|
body["temperature"] = temperature
|
||||||
|
|
||||||
if not self._supports_temperature(model_name, reasoning_effort) and not preserve_reasoning:
|
if (
|
||||||
|
not supports_temperature
|
||||||
|
and capabilities is not None
|
||||||
|
and capabilities.reasoning_replay == "encrypted"
|
||||||
|
):
|
||||||
body["include"] = ["reasoning.encrypted_content"]
|
body["include"] = ["reasoning.encrypted_content"]
|
||||||
if reasoning_effort and reasoning_effort.lower() != "none":
|
if reasoning_effort and reasoning_effort.lower() != "none":
|
||||||
body["reasoning"] = {"effort": reasoning_effort}
|
body["reasoning"] = {"effort": reasoning_effort}
|
||||||
@@ -1840,10 +1860,8 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
self._record_responses_success(model, reasoning_effort)
|
self._record_responses_success(model, reasoning_effort)
|
||||||
return result
|
return result
|
||||||
except Exception as responses_error:
|
except Exception as responses_error:
|
||||||
if self._spec and self._spec.name == "github_copilot":
|
capabilities = self._responses_capabilities()
|
||||||
# Copilot gateway exposes GPT-5/o-series only via /responses;
|
if capabilities is not None and not capabilities.allows_chat_fallback:
|
||||||
# falling back to /chat/completions cannot succeed and would
|
|
||||||
# hide the real error.
|
|
||||||
raise
|
raise
|
||||||
if self._responses_is_required():
|
if self._responses_is_required():
|
||||||
raise
|
raise
|
||||||
@@ -1936,10 +1954,8 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
except Exception as responses_error:
|
except Exception as responses_error:
|
||||||
if self._spec and self._spec.name == "github_copilot":
|
capabilities = self._responses_capabilities()
|
||||||
# Copilot gateway exposes GPT-5/o-series only via /responses;
|
if capabilities is not None and not capabilities.allows_chat_fallback:
|
||||||
# falling back to /chat/completions cannot succeed and would
|
|
||||||
# hide the real error.
|
|
||||||
raise
|
raise
|
||||||
if self._responses_is_required():
|
if self._responses_is_required():
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Every entry writes out all fields so you can copy-paste as a template.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic.alias_generators import to_snake
|
from pydantic.alias_generators import to_snake
|
||||||
|
|
||||||
@@ -28,6 +28,32 @@ class ProviderModelSpec:
|
|||||||
context_window: int | None = None
|
context_window: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ResponsesCapabilities:
|
||||||
|
"""Provider capabilities for the shared OpenAI Responses execution path.
|
||||||
|
|
||||||
|
``reasoning_replay`` selects whether multi-turn reasoning is retained as
|
||||||
|
encrypted server content, plaintext local history, or not requested.
|
||||||
|
"""
|
||||||
|
|
||||||
|
models: tuple[str, ...] = ()
|
||||||
|
auto_route: bool = False
|
||||||
|
requires_direct_openai_base: bool = False
|
||||||
|
allows_api_type_override: bool = False
|
||||||
|
reasoning_replay: Literal["none", "encrypted", "plaintext"] = "none"
|
||||||
|
supports_native_compaction: bool = False
|
||||||
|
allows_chat_fallback: bool = True
|
||||||
|
|
||||||
|
def matches_model(self, model: str) -> bool:
|
||||||
|
"""Return whether *model* is explicitly routed through Responses."""
|
||||||
|
model_name = model.lower()
|
||||||
|
return any(
|
||||||
|
model_name == supported.lower()
|
||||||
|
or model_name.endswith(f"/{supported.lower()}")
|
||||||
|
for supported in self.models
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ProviderSpec:
|
class ProviderSpec:
|
||||||
"""One LLM provider's metadata. See PROVIDERS below for real examples.
|
"""One LLM provider's metadata. See PROVIDERS below for real examples.
|
||||||
@@ -111,10 +137,8 @@ class ProviderSpec:
|
|||||||
# Substring match against the wire model name (lowercased).
|
# Substring match against the wire model name (lowercased).
|
||||||
implicit_reasoning_models: tuple[str, ...] = ()
|
implicit_reasoning_models: tuple[str, ...] = ()
|
||||||
|
|
||||||
# Models that expose the OpenAI Responses wire format. This is model-level
|
# Capabilities for providers/models served through the shared Responses path.
|
||||||
# because providers may add Responses support incrementally (DeepSeek V4
|
responses: ResponsesCapabilities | None = None
|
||||||
# Flash is supported before V4 Pro).
|
|
||||||
responses_models: tuple[str, ...] = ()
|
|
||||||
|
|
||||||
# Provider-hosted Responses tools sent unless extraBody.tools explicitly
|
# Provider-hosted Responses tools sent unless extraBody.tools explicitly
|
||||||
# supplies the hosted-tool selection. Values are raw Responses tool types.
|
# supplies the hosted-tool selection. Values are raw Responses tool types.
|
||||||
@@ -389,6 +413,13 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
|||||||
display_name="OpenAI",
|
display_name="OpenAI",
|
||||||
backend="openai_compat",
|
backend="openai_compat",
|
||||||
supports_max_completion_tokens=True,
|
supports_max_completion_tokens=True,
|
||||||
|
responses=ResponsesCapabilities(
|
||||||
|
auto_route=True,
|
||||||
|
requires_direct_openai_base=True,
|
||||||
|
allows_api_type_override=True,
|
||||||
|
reasoning_replay="encrypted",
|
||||||
|
supports_native_compaction=True,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
# OpenAI Codex: OAuth-based, dedicated provider
|
# OpenAI Codex: OAuth-based, dedicated provider
|
||||||
ProviderSpec(
|
ProviderSpec(
|
||||||
@@ -472,6 +503,11 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
|||||||
strip_model_prefix=True,
|
strip_model_prefix=True,
|
||||||
is_oauth=True,
|
is_oauth=True,
|
||||||
supports_max_completion_tokens=True,
|
supports_max_completion_tokens=True,
|
||||||
|
responses=ResponsesCapabilities(
|
||||||
|
auto_route=True,
|
||||||
|
reasoning_replay="encrypted",
|
||||||
|
allows_chat_fallback=False,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
# DeepSeek: OpenAI-compatible at api.deepseek.com
|
# DeepSeek: OpenAI-compatible at api.deepseek.com
|
||||||
ProviderSpec(
|
ProviderSpec(
|
||||||
@@ -482,7 +518,10 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
|||||||
backend="openai_compat",
|
backend="openai_compat",
|
||||||
default_api_base="https://api.deepseek.com",
|
default_api_base="https://api.deepseek.com",
|
||||||
thinking_style="thinking_type",
|
thinking_style="thinking_type",
|
||||||
responses_models=("deepseek-v4-flash",),
|
responses=ResponsesCapabilities(
|
||||||
|
models=("deepseek-v4-flash",),
|
||||||
|
reasoning_replay="plaintext",
|
||||||
|
),
|
||||||
responses_default_tools=("web_search",),
|
responses_default_tools=("web_search",),
|
||||||
),
|
),
|
||||||
# Gemini: Google's OpenAI-compatible endpoint
|
# Gemini: Google's OpenAI-compatible endpoint
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||||
@@ -90,8 +89,8 @@ def _query_first(query: QueryParams, key: str) -> str | None:
|
|||||||
return values[0] if values else None
|
return values[0] if values else None
|
||||||
|
|
||||||
|
|
||||||
def _manager(config_path: Path | None = None) -> CliAppManager:
|
def _manager() -> CliAppManager:
|
||||||
config = load_config(config_path) if config_path is not None else load_config()
|
config = load_config()
|
||||||
cli_cfg = config.tools.cli_apps
|
cli_cfg = config.tools.cli_apps
|
||||||
return CliAppManager(
|
return CliAppManager(
|
||||||
workspace=config.workspace_path,
|
workspace=config.workspace_path,
|
||||||
@@ -103,12 +102,8 @@ def _manager(config_path: Path | None = None) -> CliAppManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def cli_apps_payload(
|
async def cli_apps_payload(*, installed_only: bool = False) -> dict[str, Any]:
|
||||||
*,
|
manager = _manager()
|
||||||
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:
|
if installed_only:
|
||||||
return manager.installed_payload()
|
return manager.installed_payload()
|
||||||
payload = manager.payload(cache_only=True)
|
payload = manager.payload(cache_only=True)
|
||||||
@@ -123,16 +118,11 @@ async def cli_apps_payload(
|
|||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def cli_apps_action(
|
def cli_apps_action(action: str, query: QueryParams) -> dict[str, Any]:
|
||||||
action: str,
|
|
||||||
query: QueryParams,
|
|
||||||
*,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
name = (_query_first(query, "name") or "").strip()
|
name = (_query_first(query, "name") or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise CliAppError("missing CLI app name")
|
raise CliAppError("missing CLI app name")
|
||||||
manager = _manager(config_path) if config_path is not None else _manager()
|
manager = _manager()
|
||||||
if action == "install":
|
if action == "install":
|
||||||
return manager.install(name)
|
return manager.install(name)
|
||||||
if action == "update":
|
if action == "update":
|
||||||
|
|||||||
@@ -8,11 +8,9 @@ from typing import TYPE_CHECKING, Any, Callable
|
|||||||
|
|
||||||
from loguru import logger as default_logger
|
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.gateway_tokens import GatewayTokenStore
|
||||||
from nanobot.webui.ingress_policy import DEFAULT_WEBUI_INGRESS_POLICY, WebUIIngressPolicy
|
from nanobot.webui.ingress_policy import DEFAULT_WEBUI_INGRESS_POLICY, WebUIIngressPolicy
|
||||||
from nanobot.webui.media_gateway import WebUIMediaGateway
|
from nanobot.webui.media_gateway import WebUIMediaGateway
|
||||||
from nanobot.webui.settings_services import WebUISettingsServices
|
|
||||||
from nanobot.webui.temporary_chats import WebUITemporaryChats
|
from nanobot.webui.temporary_chats import WebUITemporaryChats
|
||||||
from nanobot.webui.transcript import WebUITranscriptRecorder
|
from nanobot.webui.transcript import WebUITranscriptRecorder
|
||||||
from nanobot.webui.workspaces import WebUIWorkspaceController
|
from nanobot.webui.workspaces import WebUIWorkspaceController
|
||||||
@@ -31,7 +29,6 @@ class GatewayServices:
|
|||||||
"""Explicit dependencies shared by WebSocket transport and HTTP routes."""
|
"""Explicit dependencies shared by WebSocket transport and HTTP routes."""
|
||||||
|
|
||||||
http: GatewayHTTPHandler
|
http: GatewayHTTPHandler
|
||||||
settings: WebUISettingsServices
|
|
||||||
tokens: GatewayTokenStore
|
tokens: GatewayTokenStore
|
||||||
media: WebUIMediaGateway
|
media: WebUIMediaGateway
|
||||||
ingress: WebUIIngressPolicy
|
ingress: WebUIIngressPolicy
|
||||||
@@ -53,7 +50,6 @@ def build_gateway_services(
|
|||||||
static_dist_path: Path | None,
|
static_dist_path: Path | None,
|
||||||
workspace_path: Path,
|
workspace_path: Path,
|
||||||
default_restrict_to_workspace: bool,
|
default_restrict_to_workspace: bool,
|
||||||
config_path: Path | None = None,
|
|
||||||
runtime_model_name: Callable[[], str | None] | None,
|
runtime_model_name: Callable[[], str | None] | None,
|
||||||
runtime_surface: str,
|
runtime_surface: str,
|
||||||
runtime_capabilities_overrides: dict[str, Any] | None,
|
runtime_capabilities_overrides: dict[str, Any] | None,
|
||||||
@@ -67,7 +63,6 @@ def build_gateway_services(
|
|||||||
skill_state_action: Callable[[set[str]], None] | None = None,
|
skill_state_action: Callable[[set[str]], None] | None = None,
|
||||||
logger: Any = default_logger,
|
logger: Any = default_logger,
|
||||||
) -> GatewayServices:
|
) -> GatewayServices:
|
||||||
settings = WebUISettingsServices.create(config_path or get_config_path())
|
|
||||||
tokens = GatewayTokenStore()
|
tokens = GatewayTokenStore()
|
||||||
ingress = DEFAULT_WEBUI_INGRESS_POLICY
|
ingress = DEFAULT_WEBUI_INGRESS_POLICY
|
||||||
minimum_frame_bytes = ingress.minimum_full_policy_frame_bytes()
|
minimum_frame_bytes = ingress.minimum_full_policy_frame_bytes()
|
||||||
@@ -107,7 +102,6 @@ def build_gateway_services(
|
|||||||
media=media,
|
media=media,
|
||||||
ingress=ingress,
|
ingress=ingress,
|
||||||
workspaces=workspaces,
|
workspaces=workspaces,
|
||||||
settings=settings,
|
|
||||||
skills_workspace_path=workspace_path,
|
skills_workspace_path=workspace_path,
|
||||||
disabled_skills=disabled_skills,
|
disabled_skills=disabled_skills,
|
||||||
cron_service=cron_service,
|
cron_service=cron_service,
|
||||||
@@ -121,7 +115,6 @@ def build_gateway_services(
|
|||||||
)
|
)
|
||||||
return GatewayServices(
|
return GatewayServices(
|
||||||
http=http,
|
http=http,
|
||||||
settings=settings,
|
|
||||||
tokens=tokens,
|
tokens=tokens,
|
||||||
media=media,
|
media=media,
|
||||||
ingress=ingress,
|
ingress=ingress,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from contextlib import suppress
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Literal, Mapping, cast
|
from typing import Any, Literal, Mapping, cast
|
||||||
|
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.apps.protocol import app_manifest, compact_dict
|
from nanobot.apps.protocol import app_manifest, compact_dict
|
||||||
@@ -25,9 +25,6 @@ from nanobot.utils.helpers import ensure_dir
|
|||||||
|
|
||||||
QueryParams = dict[str, list[str]]
|
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)
|
_MCP_PRESET_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$", re.IGNORECASE)
|
||||||
_SECRET_QUERY_RE = re.compile(
|
_SECRET_QUERY_RE = re.compile(
|
||||||
r"([?&](?:[^=&]*(?:api[_-]?key|token|secret|password|bearer)[^=&]*)=)[^&#\s]+",
|
r"([?&](?:[^=&]*(?:api[_-]?key|token|secret|password|bearer)[^=&]*)=)[^&#\s]+",
|
||||||
@@ -844,9 +841,8 @@ def mcp_presets_payload(
|
|||||||
*,
|
*,
|
||||||
last_action: dict[str, Any] | None = None,
|
last_action: dict[str, Any] | None = None,
|
||||||
tool_preview: Mapping[str, list[str]] | None = None,
|
tool_preview: Mapping[str, list[str]] | None = None,
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
config = load_config(config_path) if config_path is not None else load_config()
|
config = load_config()
|
||||||
known = _known_preset_names()
|
known = _known_preset_names()
|
||||||
preset_rows = [
|
preset_rows = [
|
||||||
_preset_payload(preset, config.tools.mcp_servers)
|
_preset_payload(preset, config.tools.mcp_servers)
|
||||||
@@ -932,11 +928,7 @@ async def _close_mcp_stacks(stacks: Mapping[str, Any]) -> None:
|
|||||||
await stack.aclose()
|
await stack.aclose()
|
||||||
|
|
||||||
|
|
||||||
async def mcp_presets_test_action(
|
async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]:
|
||||||
query: QueryParams,
|
|
||||||
*,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Connect to an enabled MCP preset and report its tool surface."""
|
"""Connect to an enabled MCP preset and report its tool surface."""
|
||||||
from nanobot.agent.tools.mcp import connect_mcp_servers
|
from nanobot.agent.tools.mcp import connect_mcp_servers
|
||||||
|
|
||||||
@@ -949,22 +941,16 @@ async def mcp_presets_test_action(
|
|||||||
display_name = _display_name_for(name, preset)
|
display_name = _display_name_for(name, preset)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
config = resolve_config_env_vars(
|
config = resolve_config_env_vars(load_config())
|
||||||
load_config(config_path) if config_path is not None else load_config(),
|
|
||||||
config_path=config_path,
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return mcp_presets_payload(
|
return mcp_presets_payload(last_action={
|
||||||
last_action={
|
"ok": False,
|
||||||
"ok": False,
|
"message": _scrub_test_error(str(exc)),
|
||||||
"message": _scrub_test_error(str(exc)),
|
"error": _scrub_test_error(str(exc)),
|
||||||
"error": _scrub_test_error(str(exc)),
|
"tool_count": 0,
|
||||||
"tool_count": 0,
|
"tool_names": [],
|
||||||
"tool_names": [],
|
"checked_at": _checked_at(),
|
||||||
"checked_at": _checked_at(),
|
})
|
||||||
},
|
|
||||||
config_path=config_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
cfg = config.tools.mcp_servers.get(name)
|
cfg = config.tools.mcp_servers.get(name)
|
||||||
if cfg is None:
|
if cfg is None:
|
||||||
@@ -982,7 +968,7 @@ async def mcp_presets_test_action(
|
|||||||
"tool_names": [],
|
"tool_names": [],
|
||||||
"checked_at": _checked_at(),
|
"checked_at": _checked_at(),
|
||||||
}
|
}
|
||||||
return mcp_presets_payload(last_action=last_action, config_path=config_path)
|
return mcp_presets_payload(last_action=last_action)
|
||||||
|
|
||||||
if cfg.command and not _command_available(cfg.command):
|
if cfg.command and not _command_available(cfg.command):
|
||||||
last_action = {
|
last_action = {
|
||||||
@@ -993,7 +979,7 @@ async def mcp_presets_test_action(
|
|||||||
"tool_names": [],
|
"tool_names": [],
|
||||||
"checked_at": _checked_at(),
|
"checked_at": _checked_at(),
|
||||||
}
|
}
|
||||||
return mcp_presets_payload(last_action=last_action, config_path=config_path)
|
return mcp_presets_payload(last_action=last_action)
|
||||||
|
|
||||||
registry = ToolRegistry()
|
registry = ToolRegistry()
|
||||||
stacks: dict[str, Any] = {}
|
stacks: dict[str, Any] = {}
|
||||||
@@ -1054,11 +1040,7 @@ async def mcp_presets_test_action(
|
|||||||
|
|
||||||
tool_names = last_action.get("tool_names", [])
|
tool_names = last_action.get("tool_names", [])
|
||||||
preview = {name: tool_names} if tool_names else None
|
preview = {name: tool_names} if tool_names else None
|
||||||
return mcp_presets_payload(
|
return mcp_presets_payload(last_action=last_action, tool_preview=preview)
|
||||||
last_action=last_action,
|
|
||||||
tool_preview=preview,
|
|
||||||
config_path=config_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_json_value(raw: str | None, *, fallback: Any) -> Any:
|
def _parse_json_value(raw: str | None, *, fallback: Any) -> Any:
|
||||||
@@ -1239,35 +1221,24 @@ def _import_mcp_servers(raw_json: str | None) -> dict[str, MCPServerConfig]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def custom_mcp_action(
|
def custom_mcp_action(action: str, query: QueryParams) -> dict[str, Any]:
|
||||||
action: str,
|
config = load_config()
|
||||||
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":
|
if action == "custom":
|
||||||
name, cfg = _custom_server_from_query(query)
|
name, cfg = _custom_server_from_query(query)
|
||||||
config.tools.mcp_servers[name] = cfg
|
config.tools.mcp_servers[name] = cfg
|
||||||
save_config(config, config_path)
|
save_config(config)
|
||||||
payload = mcp_presets_payload(
|
payload = mcp_presets_payload(last_action=_server_action_message(action, name))
|
||||||
last_action=_server_action_message(action, name),
|
|
||||||
config_path=config_path,
|
|
||||||
)
|
|
||||||
payload["requires_restart"] = True
|
payload["requires_restart"] = True
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
if action in {"import", "import-cursor"}:
|
if action in {"import", "import-cursor"}:
|
||||||
servers = _import_mcp_servers(_query_first(query, "config"))
|
servers = _import_mcp_servers(_query_first(query, "config"))
|
||||||
config.tools.mcp_servers.update(servers)
|
config.tools.mcp_servers.update(servers)
|
||||||
save_config(config, config_path)
|
save_config(config)
|
||||||
payload = mcp_presets_payload(
|
payload = mcp_presets_payload(last_action={
|
||||||
last_action={
|
"ok": True,
|
||||||
"ok": True,
|
"message": f"Imported {len(servers)} MCP server(s).",
|
||||||
"message": f"Imported {len(servers)} MCP server(s).",
|
})
|
||||||
},
|
|
||||||
config_path=config_path,
|
|
||||||
)
|
|
||||||
payload["requires_restart"] = True
|
payload["requires_restart"] = True
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
@@ -1278,40 +1249,29 @@ def custom_mcp_action(
|
|||||||
raise McpPresetError("unknown MCP server", status=404)
|
raise McpPresetError("unknown MCP server", status=404)
|
||||||
cfg.enabled_tools = _parse_enabled_tools(_query_first(query, "enabled_tools"))
|
cfg.enabled_tools = _parse_enabled_tools(_query_first(query, "enabled_tools"))
|
||||||
config.tools.mcp_servers[name] = cfg
|
config.tools.mcp_servers[name] = cfg
|
||||||
save_config(config, config_path)
|
save_config(config)
|
||||||
payload = mcp_presets_payload(
|
payload = mcp_presets_payload(last_action=_server_action_message(action, name))
|
||||||
last_action=_server_action_message(action, name),
|
|
||||||
config_path=config_path,
|
|
||||||
)
|
|
||||||
payload["requires_restart"] = True
|
payload["requires_restart"] = True
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
raise McpPresetError(f"unknown MCP action '{action}'", status=404)
|
raise McpPresetError(f"unknown MCP action '{action}'", status=404)
|
||||||
|
|
||||||
|
|
||||||
def mcp_presets_action(
|
def mcp_presets_action(action: str, query: QueryParams) -> dict[str, Any]:
|
||||||
action: str,
|
|
||||||
query: QueryParams,
|
|
||||||
*,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
name = (_query_first(query, "name") or "").strip()
|
name = (_query_first(query, "name") or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise McpPresetError("missing MCP preset name")
|
raise McpPresetError("missing MCP preset name")
|
||||||
preset = _preset_by_name_optional(name)
|
preset = _preset_by_name_optional(name)
|
||||||
|
|
||||||
config = load_config(config_path) if config_path is not None else load_config()
|
config = load_config()
|
||||||
existing = config.tools.mcp_servers.get(name)
|
existing = config.tools.mcp_servers.get(name)
|
||||||
|
|
||||||
if action == "enable":
|
if action == "enable":
|
||||||
if preset is None:
|
if preset is None:
|
||||||
raise McpPresetError("unknown MCP preset", status=404)
|
raise McpPresetError("unknown MCP preset", status=404)
|
||||||
config.tools.mcp_servers[preset.name] = _materialize_server(preset, query, existing)
|
config.tools.mcp_servers[preset.name] = _materialize_server(preset, query, existing)
|
||||||
save_config(config, config_path)
|
save_config(config)
|
||||||
payload = mcp_presets_payload(
|
payload = mcp_presets_payload(last_action=_action_message(action, preset))
|
||||||
last_action=_action_message(action, preset),
|
|
||||||
config_path=config_path,
|
|
||||||
)
|
|
||||||
payload["requires_restart"] = True
|
payload["requires_restart"] = True
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
@@ -1327,7 +1287,7 @@ def mcp_presets_action(
|
|||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
cleanup_error = str(exc)
|
cleanup_error = str(exc)
|
||||||
del config.tools.mcp_servers[name]
|
del config.tools.mcp_servers[name]
|
||||||
save_config(config, config_path)
|
save_config(config)
|
||||||
last_action = (
|
last_action = (
|
||||||
_action_message(action, preset)
|
_action_message(action, preset)
|
||||||
if preset is not None
|
if preset is not None
|
||||||
@@ -1343,10 +1303,7 @@ def mcp_presets_action(
|
|||||||
f"{last_action['message']} Could not remove managed runtime files: {cleanup_error}"
|
f"{last_action['message']} Could not remove managed runtime files: {cleanup_error}"
|
||||||
)
|
)
|
||||||
last_action["verification_failed"] = ["managed_paths_absent"]
|
last_action["verification_failed"] = ["managed_paths_absent"]
|
||||||
payload = mcp_presets_payload(
|
payload = mcp_presets_payload(last_action=last_action)
|
||||||
last_action=last_action,
|
|
||||||
config_path=config_path,
|
|
||||||
)
|
|
||||||
payload["requires_restart"] = True
|
payload["requires_restart"] = True
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
@@ -1382,21 +1339,13 @@ async def mcp_presets_settings_action(
|
|||||||
query: QueryParams,
|
query: QueryParams,
|
||||||
*,
|
*,
|
||||||
reload_mcp: McpReload | None = None,
|
reload_mcp: McpReload | None = None,
|
||||||
config: WebUISettingsConfig | None = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Run a WebUI MCP preset action and hot-reload the agent when config changes."""
|
"""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:
|
if action is None:
|
||||||
return mcp_presets_payload(config_path=config_path)
|
return mcp_presets_payload()
|
||||||
if action == "test":
|
if action == "test":
|
||||||
return await mcp_presets_test_action(query, config_path=config_path)
|
return await mcp_presets_test_action(query)
|
||||||
if config is not None:
|
if action in _CUSTOM_ACTIONS:
|
||||||
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)
|
payload = await asyncio.to_thread(custom_mcp_action, action, query)
|
||||||
else:
|
else:
|
||||||
payload = await asyncio.to_thread(mcp_presets_action, action, query)
|
payload = await asyncio.to_thread(mcp_presets_action, action, query)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Nanobot optional feature helpers for WebUI Settings."""
|
"""Nanobot optional feature helpers for WebUI Settings."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from nanobot.channels.registry import load_channel_plugin
|
from nanobot.channels.registry import load_channel_plugin
|
||||||
@@ -16,13 +15,8 @@ from nanobot.webui.http_utils import query_first
|
|||||||
QueryParams = dict[str, list[str]]
|
QueryParams = dict[str, list[str]]
|
||||||
|
|
||||||
|
|
||||||
def nanobot_features_payload(*, config_path: Path | None = None) -> dict[str, Any]:
|
def nanobot_features_payload() -> dict[str, Any]:
|
||||||
if config_path is None:
|
return optional_features_payload()
|
||||||
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:
|
def nanobot_feature_instance_target(query: QueryParams) -> str | None:
|
||||||
@@ -38,19 +32,13 @@ def nanobot_features_action(
|
|||||||
query: QueryParams,
|
query: QueryParams,
|
||||||
*,
|
*,
|
||||||
allow_install: bool = True,
|
allow_install: bool = True,
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
name = (query_first(query, "name") or "").strip()
|
name = (query_first(query, "name") or "").strip()
|
||||||
instance_id = nanobot_feature_instance_target(query)
|
instance_id = nanobot_feature_instance_target(query)
|
||||||
if not name:
|
if not name:
|
||||||
raise OptionalFeatureError("missing feature name")
|
raise OptionalFeatureError("missing feature name")
|
||||||
if action == "enable":
|
if action == "enable":
|
||||||
return enable_optional_feature(
|
return enable_optional_feature(name, allow_install=allow_install, instance_id=instance_id)
|
||||||
name,
|
|
||||||
config_path=config_path,
|
|
||||||
allow_install=allow_install,
|
|
||||||
instance_id=instance_id,
|
|
||||||
)
|
|
||||||
if action == "disable":
|
if action == "disable":
|
||||||
try:
|
try:
|
||||||
plugin = load_channel_plugin(name)
|
plugin = load_channel_plugin(name)
|
||||||
@@ -62,9 +50,5 @@ def nanobot_features_action(
|
|||||||
f"Use `nanobot plugins disable {name}` from a terminal if you need to disable it.",
|
f"Use `nanobot plugins disable {name}` from a terminal if you need to disable it.",
|
||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
return disable_optional_feature(
|
return disable_optional_feature(name, instance_id=instance_id)
|
||||||
name,
|
|
||||||
config_path=config_path,
|
|
||||||
instance_id=instance_id,
|
|
||||||
)
|
|
||||||
raise OptionalFeatureError(f"unknown feature action '{action}'", status=404)
|
raise OptionalFeatureError(f"unknown feature action '{action}'", status=404)
|
||||||
|
|||||||
+2148
-239
File diff suppressed because it is too large
Load Diff
@@ -1,804 +0,0 @@
|
|||||||
"""Capability settings domain logic for Web, media, network, and API features."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable, TypedDict
|
|
||||||
|
|
||||||
from nanobot.agent.tools.web import SEARCH_PROVIDER_OPTIONS
|
|
||||||
from nanobot.api.runtime import ApiRuntime, ApiStartOptions
|
|
||||||
from nanobot.audio.transcription import resolve_transcription_config
|
|
||||||
from nanobot.audio.transcription_registry import (
|
|
||||||
resolve_transcription_provider,
|
|
||||||
transcription_provider_names,
|
|
||||||
)
|
|
||||||
from nanobot.config.schema import Config
|
|
||||||
from nanobot.optional_features import (
|
|
||||||
OptionalFeatureError,
|
|
||||||
extra_installed,
|
|
||||||
optional_dependency_groups,
|
|
||||||
)
|
|
||||||
from nanobot.providers.image_generation import (
|
|
||||||
get_image_gen_provider,
|
|
||||||
image_gen_provider_names,
|
|
||||||
)
|
|
||||||
from nanobot.providers.registry import find_by_name
|
|
||||||
from nanobot.security.network import is_loopback_host
|
|
||||||
from nanobot.webui.settings_contracts import (
|
|
||||||
QueryParams,
|
|
||||||
SettingsRequest,
|
|
||||||
SettingsRouteResult,
|
|
||||||
WebUISettingsError,
|
|
||||||
parse_bool,
|
|
||||||
query_first,
|
|
||||||
query_first_alias,
|
|
||||||
)
|
|
||||||
from nanobot.webui.settings_models import (
|
|
||||||
OAuthStatusReader,
|
|
||||||
mask_secret_hint,
|
|
||||||
provider_configured_for_settings,
|
|
||||||
)
|
|
||||||
from nanobot.webui.workspaces import (
|
|
||||||
read_webui_default_access_mode,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from nanobot.webui.settings_services import WebUISettingsServices
|
|
||||||
|
|
||||||
SettingsOperation = Callable[..., dict[str, Any]]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CapabilitySettingsOperations:
|
|
||||||
update_web_search: SettingsOperation
|
|
||||||
update_api: SettingsOperation
|
|
||||||
update_image: SettingsOperation
|
|
||||||
update_transcription: SettingsOperation
|
|
||||||
update_network: SettingsOperation
|
|
||||||
nanobot_features_action: SettingsOperation
|
|
||||||
api_runtime: Callable[[], ApiRuntime]
|
|
||||||
reload_image: Callable[[], Awaitable[dict[str, Any]]]
|
|
||||||
|
|
||||||
|
|
||||||
class CapabilitySettingsPayload(TypedDict):
|
|
||||||
web_search: dict[str, Any]
|
|
||||||
web: dict[str, Any]
|
|
||||||
api: dict[str, Any]
|
|
||||||
observability: dict[str, Any]
|
|
||||||
image_generation: dict[str, Any]
|
|
||||||
transcription: dict[str, Any]
|
|
||||||
|
|
||||||
|
|
||||||
_WEB_SEARCH_PROVIDER_OPTIONS = SEARCH_PROVIDER_OPTIONS
|
|
||||||
_WEB_SEARCH_PROVIDER_BY_NAME = {
|
|
||||||
provider["name"]: provider for provider in _WEB_SEARCH_PROVIDER_OPTIONS
|
|
||||||
}
|
|
||||||
_IMAGE_GENERATION_ASPECT_RATIOS = {
|
|
||||||
"1:1",
|
|
||||||
"3:4",
|
|
||||||
"9:16",
|
|
||||||
"4:3",
|
|
||||||
"16:9",
|
|
||||||
"3:2",
|
|
||||||
"2:3",
|
|
||||||
"21:9",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _image_generation_provider_rows(
|
|
||||||
config: Config,
|
|
||||||
*,
|
|
||||||
oauth_status: OAuthStatusReader,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
rows: list[dict[str, Any]] = []
|
|
||||||
for name in image_gen_provider_names():
|
|
||||||
image_provider = get_image_gen_provider(name)
|
|
||||||
spec = find_by_name(name)
|
|
||||||
provider_config = getattr(config.providers, name, None)
|
|
||||||
configured = (
|
|
||||||
provider_configured_for_settings(spec, provider_config, oauth_status)
|
|
||||||
if spec is not None and provider_config is not None
|
|
||||||
else bool(getattr(provider_config, "api_key", None))
|
|
||||||
)
|
|
||||||
rows.append(
|
|
||||||
{
|
|
||||||
"name": name,
|
|
||||||
"label": spec.label if spec is not None else name,
|
|
||||||
"configured": configured,
|
|
||||||
"auth_type": "oauth" if spec is not None and spec.is_oauth else "api_key",
|
|
||||||
"api_key_hint": mask_secret_hint(getattr(provider_config, "api_key", None)),
|
|
||||||
"api_base": getattr(provider_config, "api_base", None),
|
|
||||||
"default_api_base": (
|
|
||||||
spec.default_api_base if spec and spec.default_api_base else None
|
|
||||||
),
|
|
||||||
"models": list(image_provider.model_options) if image_provider else [],
|
|
||||||
"default_model": (
|
|
||||||
image_provider.model_options[0]
|
|
||||||
if image_provider and image_provider.model_options
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def _transcription_provider_rows(config: Config) -> list[dict[str, Any]]:
|
|
||||||
rows: list[dict[str, Any]] = []
|
|
||||||
for name in transcription_provider_names():
|
|
||||||
spec = find_by_name(name)
|
|
||||||
provider_config = getattr(config.providers, name, None)
|
|
||||||
rows.append(
|
|
||||||
{
|
|
||||||
"name": name,
|
|
||||||
"label": spec.label if spec is not None else name,
|
|
||||||
"configured": bool(getattr(provider_config, "api_key", None)),
|
|
||||||
"api_key_hint": mask_secret_hint(getattr(provider_config, "api_key", None)),
|
|
||||||
"api_base": getattr(provider_config, "api_base", None),
|
|
||||||
"default_api_base": (
|
|
||||||
spec.default_api_base if spec and spec.default_api_base else None
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def capability_settings_payload(
|
|
||||||
config: Config,
|
|
||||||
*,
|
|
||||||
oauth_status: OAuthStatusReader,
|
|
||||||
) -> CapabilitySettingsPayload:
|
|
||||||
search_config = config.tools.web.search
|
|
||||||
image_config = config.tools.image_generation
|
|
||||||
transcription = resolve_transcription_config(config)
|
|
||||||
search_provider = (
|
|
||||||
search_config.provider
|
|
||||||
if search_config.provider in _WEB_SEARCH_PROVIDER_BY_NAME
|
|
||||||
else "duckduckgo"
|
|
||||||
)
|
|
||||||
image_providers = _image_generation_provider_rows(config, oauth_status=oauth_status)
|
|
||||||
selected_image_provider = next(
|
|
||||||
(
|
|
||||||
provider
|
|
||||||
for provider in image_providers
|
|
||||||
if provider["name"] == image_config.provider
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"web_search": {
|
|
||||||
"provider": search_provider,
|
|
||||||
"api_key_hint": mask_secret_hint(search_config.api_key),
|
|
||||||
"base_url": search_config.base_url or None,
|
|
||||||
"max_results": search_config.max_results,
|
|
||||||
"timeout": search_config.timeout,
|
|
||||||
"providers": list(_WEB_SEARCH_PROVIDER_OPTIONS),
|
|
||||||
},
|
|
||||||
"web": {
|
|
||||||
"enable": config.tools.web.enable,
|
|
||||||
"proxy": config.tools.web.proxy,
|
|
||||||
"user_agent": config.tools.web.user_agent,
|
|
||||||
"search": {
|
|
||||||
"max_results": search_config.max_results,
|
|
||||||
"timeout": search_config.timeout,
|
|
||||||
},
|
|
||||||
"fetch": {
|
|
||||||
"use_jina_reader": config.tools.web.fetch.use_jina_reader,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"api": {
|
|
||||||
"host": config.api.host,
|
|
||||||
"port": config.api.port,
|
|
||||||
"timeout": config.api.timeout,
|
|
||||||
"api_key_hint": mask_secret_hint(config.api.api_key),
|
|
||||||
},
|
|
||||||
"observability": {
|
|
||||||
"provider": "langfuse",
|
|
||||||
"configured": bool(
|
|
||||||
os.environ.get("LANGFUSE_SECRET_KEY")
|
|
||||||
and os.environ.get("LANGFUSE_PUBLIC_KEY")
|
|
||||||
),
|
|
||||||
"base_url": os.environ.get("LANGFUSE_BASE_URL")
|
|
||||||
or "https://cloud.langfuse.com",
|
|
||||||
},
|
|
||||||
"image_generation": {
|
|
||||||
"enabled": image_config.enabled,
|
|
||||||
"provider": image_config.provider,
|
|
||||||
"provider_configured": bool(
|
|
||||||
selected_image_provider and selected_image_provider["configured"]
|
|
||||||
),
|
|
||||||
"model": image_config.model,
|
|
||||||
"default_aspect_ratio": image_config.default_aspect_ratio,
|
|
||||||
"default_image_size": image_config.default_image_size,
|
|
||||||
"max_images_per_turn": image_config.max_images_per_turn,
|
|
||||||
"save_dir": image_config.save_dir,
|
|
||||||
"providers": image_providers,
|
|
||||||
},
|
|
||||||
"transcription": {
|
|
||||||
"enabled": transcription.enabled,
|
|
||||||
"provider": transcription.provider,
|
|
||||||
"provider_configured": transcription.configured,
|
|
||||||
"model": transcription.model,
|
|
||||||
"language": transcription.language,
|
|
||||||
"max_duration_sec": transcription.max_duration_sec,
|
|
||||||
"max_upload_mb": transcription.max_upload_mb,
|
|
||||||
"providers": _transcription_provider_rows(config),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def update_network_safety_settings(
|
|
||||||
config: Config,
|
|
||||||
query: QueryParams,
|
|
||||||
) -> tuple[bool, str | None]:
|
|
||||||
raw_allow = (
|
|
||||||
query_first_alias(
|
|
||||||
query,
|
|
||||||
"webui_allow_local_service_access",
|
|
||||||
"webuiAllowLocalServiceAccess",
|
|
||||||
)
|
|
||||||
or query_first_alias(
|
|
||||||
query,
|
|
||||||
"allow_local_preview_access",
|
|
||||||
"allowLocalPreviewAccess",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
raw_default_access_mode = query_first_alias(
|
|
||||||
query,
|
|
||||||
"webui_default_access_mode",
|
|
||||||
"webuiDefaultAccessMode",
|
|
||||||
)
|
|
||||||
if raw_allow is None and raw_default_access_mode is None:
|
|
||||||
raise WebUISettingsError(
|
|
||||||
"webui_allow_local_service_access or webui_default_access_mode is required"
|
|
||||||
)
|
|
||||||
|
|
||||||
changed = False
|
|
||||||
if raw_allow is not None:
|
|
||||||
allow_local = parse_bool(raw_allow, "webui_allow_local_service_access")
|
|
||||||
if config.tools.webui_allow_local_service_access != allow_local:
|
|
||||||
config.tools.webui_allow_local_service_access = allow_local
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
default_access_mode: str | None = None
|
|
||||||
if raw_default_access_mode is not None:
|
|
||||||
default_access_mode = raw_default_access_mode.strip().lower()
|
|
||||||
if default_access_mode == "restricted":
|
|
||||||
default_access_mode = "default"
|
|
||||||
if default_access_mode not in {"default", "full"}:
|
|
||||||
raise WebUISettingsError(
|
|
||||||
"webui_default_access_mode must be default or full"
|
|
||||||
)
|
|
||||||
return changed, default_access_mode
|
|
||||||
|
|
||||||
|
|
||||||
def update_web_search_settings(config: Config, query: QueryParams) -> tuple[bool, bool]:
|
|
||||||
provider_name = (query_first(query, "provider") or "").strip().lower()
|
|
||||||
provider_option = _WEB_SEARCH_PROVIDER_BY_NAME.get(provider_name)
|
|
||||||
if provider_option is None:
|
|
||||||
raise WebUISettingsError("unknown web search provider")
|
|
||||||
|
|
||||||
search_config = config.tools.web.search
|
|
||||||
web_config = config.tools.web
|
|
||||||
previous_provider = search_config.provider
|
|
||||||
changed = False
|
|
||||||
restart_required = False
|
|
||||||
|
|
||||||
def set_search_value(attr: str, value: object) -> None:
|
|
||||||
nonlocal changed
|
|
||||||
if getattr(search_config, attr) != value:
|
|
||||||
setattr(search_config, attr, value)
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
def set_fetch_value(attr: str, value: object) -> None:
|
|
||||||
nonlocal changed
|
|
||||||
if getattr(web_config.fetch, attr) != value:
|
|
||||||
setattr(web_config.fetch, attr, value)
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
if search_config.provider != provider_name:
|
|
||||||
search_config.provider = provider_name
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
credential = provider_option["credential"]
|
|
||||||
if credential == "none":
|
|
||||||
set_search_value("api_key", "")
|
|
||||||
set_search_value("base_url", "")
|
|
||||||
elif credential == "base_url":
|
|
||||||
base_url = query_first_alias(query, "base_url", "baseUrl")
|
|
||||||
base_url = base_url.strip() if base_url is not None else None
|
|
||||||
if not base_url and previous_provider == provider_name and search_config.base_url:
|
|
||||||
base_url = search_config.base_url
|
|
||||||
if not base_url:
|
|
||||||
raise WebUISettingsError("base_url is required")
|
|
||||||
set_search_value("base_url", base_url)
|
|
||||||
set_search_value("api_key", "")
|
|
||||||
elif credential in {"api_key", "optional_api_key"}:
|
|
||||||
raw_api_key = query_first_alias(query, "api_key", "apiKey")
|
|
||||||
api_key = raw_api_key.strip() if raw_api_key is not None else None
|
|
||||||
if api_key is None and previous_provider == provider_name and search_config.api_key:
|
|
||||||
api_key = search_config.api_key
|
|
||||||
if credential == "api_key" and not api_key:
|
|
||||||
raise WebUISettingsError("api_key is required")
|
|
||||||
set_search_value("api_key", api_key or "")
|
|
||||||
set_search_value("base_url", "")
|
|
||||||
else:
|
|
||||||
raise WebUISettingsError("unknown web search credential type")
|
|
||||||
|
|
||||||
max_results = query_first_alias(query, "max_results", "maxResults")
|
|
||||||
if max_results is not None:
|
|
||||||
try:
|
|
||||||
parsed = int(max_results)
|
|
||||||
except ValueError:
|
|
||||||
raise WebUISettingsError("max_results must be an integer") from None
|
|
||||||
if parsed < 1 or parsed > 10:
|
|
||||||
raise WebUISettingsError("max_results must be between 1 and 10")
|
|
||||||
set_search_value("max_results", parsed)
|
|
||||||
|
|
||||||
timeout = query_first(query, "timeout")
|
|
||||||
if timeout is not None:
|
|
||||||
try:
|
|
||||||
parsed_timeout = int(timeout)
|
|
||||||
except ValueError:
|
|
||||||
raise WebUISettingsError("timeout must be an integer") from None
|
|
||||||
if parsed_timeout < 1 or parsed_timeout > 120:
|
|
||||||
raise WebUISettingsError("timeout must be between 1 and 120")
|
|
||||||
set_search_value("timeout", parsed_timeout)
|
|
||||||
|
|
||||||
use_jina_reader = query_first_alias(query, "use_jina_reader", "useJinaReader")
|
|
||||||
if use_jina_reader is not None:
|
|
||||||
previous_jina_reader = web_config.fetch.use_jina_reader
|
|
||||||
set_fetch_value("use_jina_reader", parse_bool(use_jina_reader, "use_jina_reader"))
|
|
||||||
if web_config.fetch.use_jina_reader != previous_jina_reader:
|
|
||||||
restart_required = True
|
|
||||||
return changed, restart_required
|
|
||||||
|
|
||||||
|
|
||||||
def update_api_settings(config: Config, query: QueryParams) -> None:
|
|
||||||
"""Update the managed OpenAI-compatible API configuration."""
|
|
||||||
api = config.api
|
|
||||||
host = query_first(query, "host")
|
|
||||||
if host is not None:
|
|
||||||
host = host.strip()
|
|
||||||
if not host:
|
|
||||||
raise WebUISettingsError("host is required")
|
|
||||||
api.host = host
|
|
||||||
|
|
||||||
port = query_first(query, "port")
|
|
||||||
if port is not None:
|
|
||||||
try:
|
|
||||||
parsed_port = int(port)
|
|
||||||
except ValueError:
|
|
||||||
raise WebUISettingsError("port must be an integer") from None
|
|
||||||
if parsed_port < 1 or parsed_port > 65535:
|
|
||||||
raise WebUISettingsError("port must be between 1 and 65535")
|
|
||||||
api.port = parsed_port
|
|
||||||
|
|
||||||
timeout = query_first(query, "timeout")
|
|
||||||
if timeout is not None:
|
|
||||||
try:
|
|
||||||
parsed_timeout = float(timeout)
|
|
||||||
except ValueError:
|
|
||||||
raise WebUISettingsError("timeout must be a number") from None
|
|
||||||
if parsed_timeout < 1 or parsed_timeout > 3600:
|
|
||||||
raise WebUISettingsError("timeout must be between 1 and 3600")
|
|
||||||
api.timeout = parsed_timeout
|
|
||||||
|
|
||||||
api_key = query_first_alias(query, "api_key", "apiKey")
|
|
||||||
if api_key is not None:
|
|
||||||
api.api_key = api_key.strip()
|
|
||||||
if not is_loopback_host(api.host) and not api.api_key.strip():
|
|
||||||
raise WebUISettingsError(
|
|
||||||
"an API key is required when the API is available on the network"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def update_image_generation_settings(
|
|
||||||
config: Config,
|
|
||||||
query: QueryParams,
|
|
||||||
*,
|
|
||||||
oauth_status: OAuthStatusReader,
|
|
||||||
) -> bool:
|
|
||||||
image_config = config.tools.image_generation
|
|
||||||
changed = False
|
|
||||||
|
|
||||||
provider_name = query_first(query, "provider")
|
|
||||||
if provider_name is not None:
|
|
||||||
provider_name = provider_name.strip().lower()
|
|
||||||
if not provider_name:
|
|
||||||
raise WebUISettingsError("image generation provider is required")
|
|
||||||
if get_image_gen_provider(provider_name) is None:
|
|
||||||
raise WebUISettingsError("unknown image generation provider")
|
|
||||||
if image_config.provider != provider_name:
|
|
||||||
image_config.provider = provider_name
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
enabled = query_first(query, "enabled")
|
|
||||||
if enabled is not None:
|
|
||||||
parsed_enabled = parse_bool(enabled, "enabled")
|
|
||||||
if image_config.enabled != parsed_enabled:
|
|
||||||
image_config.enabled = parsed_enabled
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
model = query_first(query, "model")
|
|
||||||
if model is not None:
|
|
||||||
model = model.strip()
|
|
||||||
if not model:
|
|
||||||
raise WebUISettingsError("image generation model is required")
|
|
||||||
if len(model) > 200:
|
|
||||||
raise WebUISettingsError("image generation model is too long")
|
|
||||||
if image_config.model != model:
|
|
||||||
image_config.model = model
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
default_aspect_ratio = query_first_alias(
|
|
||||||
query,
|
|
||||||
"default_aspect_ratio",
|
|
||||||
"defaultAspectRatio",
|
|
||||||
)
|
|
||||||
if default_aspect_ratio is not None:
|
|
||||||
default_aspect_ratio = default_aspect_ratio.strip()
|
|
||||||
if default_aspect_ratio not in _IMAGE_GENERATION_ASPECT_RATIOS:
|
|
||||||
raise WebUISettingsError("unsupported image generation aspect ratio")
|
|
||||||
if image_config.default_aspect_ratio != default_aspect_ratio:
|
|
||||||
image_config.default_aspect_ratio = default_aspect_ratio
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
default_image_size = query_first_alias(
|
|
||||||
query,
|
|
||||||
"default_image_size",
|
|
||||||
"defaultImageSize",
|
|
||||||
)
|
|
||||||
if default_image_size is not None:
|
|
||||||
default_image_size = default_image_size.strip()
|
|
||||||
if not default_image_size:
|
|
||||||
raise WebUISettingsError("default image size is required")
|
|
||||||
if len(default_image_size) > 32 or not all(
|
|
||||||
char.isascii() and (char.isalnum() or char in {"x", "X", ":", "-", "_"})
|
|
||||||
for char in default_image_size
|
|
||||||
):
|
|
||||||
raise WebUISettingsError("unsupported image generation size")
|
|
||||||
if image_config.default_image_size != default_image_size:
|
|
||||||
image_config.default_image_size = default_image_size
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
max_images_per_turn = query_first_alias(
|
|
||||||
query,
|
|
||||||
"max_images_per_turn",
|
|
||||||
"maxImagesPerTurn",
|
|
||||||
)
|
|
||||||
if max_images_per_turn is not None:
|
|
||||||
try:
|
|
||||||
parsed_max = int(max_images_per_turn)
|
|
||||||
except ValueError:
|
|
||||||
raise WebUISettingsError("max_images_per_turn must be an integer") from None
|
|
||||||
if parsed_max < 1 or parsed_max > 8:
|
|
||||||
raise WebUISettingsError("max_images_per_turn must be between 1 and 8")
|
|
||||||
if image_config.max_images_per_turn != parsed_max:
|
|
||||||
image_config.max_images_per_turn = parsed_max
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
if image_config.enabled:
|
|
||||||
selected_provider = next(
|
|
||||||
(
|
|
||||||
provider
|
|
||||||
for provider in _image_generation_provider_rows(
|
|
||||||
config,
|
|
||||||
oauth_status=oauth_status,
|
|
||||||
)
|
|
||||||
if provider["name"] == image_config.provider
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if not selected_provider or not selected_provider["configured"]:
|
|
||||||
raise WebUISettingsError("image generation provider is not configured")
|
|
||||||
return changed
|
|
||||||
|
|
||||||
|
|
||||||
def update_transcription_settings(config: Config, query: QueryParams) -> bool:
|
|
||||||
transcription = config.transcription
|
|
||||||
changed = False
|
|
||||||
|
|
||||||
enabled = query_first(query, "enabled")
|
|
||||||
if enabled is not None:
|
|
||||||
parsed_enabled = parse_bool(enabled, "enabled")
|
|
||||||
if transcription.enabled != parsed_enabled:
|
|
||||||
transcription.enabled = parsed_enabled
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
provider = query_first(query, "provider")
|
|
||||||
if provider is not None:
|
|
||||||
provider = provider.strip().lower()
|
|
||||||
provider_spec = resolve_transcription_provider(provider)
|
|
||||||
if provider_spec is None:
|
|
||||||
raise WebUISettingsError("unknown transcription provider")
|
|
||||||
provider = provider_spec.name
|
|
||||||
if transcription.provider != provider:
|
|
||||||
transcription.provider = provider
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
model = query_first(query, "model")
|
|
||||||
if model is not None:
|
|
||||||
model = model.strip() or None
|
|
||||||
if model is not None and len(model) > 200:
|
|
||||||
raise WebUISettingsError("transcription model is too long")
|
|
||||||
if transcription.model != model:
|
|
||||||
transcription.model = model
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
language = query_first(query, "language")
|
|
||||||
if language is not None:
|
|
||||||
language = language.strip().lower() or None
|
|
||||||
if language is not None and not re.fullmatch(r"[a-z]{2,3}", language):
|
|
||||||
raise WebUISettingsError(
|
|
||||||
"transcription language must be 2-3 lowercase letters"
|
|
||||||
)
|
|
||||||
if transcription.language != language:
|
|
||||||
transcription.language = language
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
max_duration_sec = query_first_alias(query, "max_duration_sec", "maxDurationSec")
|
|
||||||
if max_duration_sec is not None:
|
|
||||||
try:
|
|
||||||
parsed_duration = int(max_duration_sec)
|
|
||||||
except ValueError:
|
|
||||||
raise WebUISettingsError("max_duration_sec must be an integer") from None
|
|
||||||
if parsed_duration < 1 or parsed_duration > 600:
|
|
||||||
raise WebUISettingsError("max_duration_sec must be between 1 and 600")
|
|
||||||
if transcription.max_duration_sec != parsed_duration:
|
|
||||||
transcription.max_duration_sec = parsed_duration
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
max_upload_mb = query_first_alias(query, "max_upload_mb", "maxUploadMb")
|
|
||||||
if max_upload_mb is not None:
|
|
||||||
try:
|
|
||||||
parsed_upload = int(max_upload_mb)
|
|
||||||
except ValueError:
|
|
||||||
raise WebUISettingsError("max_upload_mb must be an integer") from None
|
|
||||||
if parsed_upload < 1 or parsed_upload > 100:
|
|
||||||
raise WebUISettingsError("max_upload_mb must be between 1 and 100")
|
|
||||||
if transcription.max_upload_mb != parsed_upload:
|
|
||||||
transcription.max_upload_mb = parsed_upload
|
|
||||||
changed = True
|
|
||||||
return changed
|
|
||||||
|
|
||||||
|
|
||||||
def network_safety_payload(config: Config) -> dict[str, Any]:
|
|
||||||
"""Return the network-related fields embedded in the advanced DTO."""
|
|
||||||
return {
|
|
||||||
"webui_allow_local_service_access": config.tools.webui_allow_local_service_access,
|
|
||||||
"allow_local_preview_access": config.tools.webui_allow_local_service_access,
|
|
||||||
"webui_default_access_mode": read_webui_default_access_mode(),
|
|
||||||
"private_service_protection_enabled": True,
|
|
||||||
"ssrf_whitelist_count": len(config.tools.ssrf_whitelist),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def masked_api_secret(value: str) -> str | None:
|
|
||||||
value = value.strip()
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
return f"{value[:3]}...{value[-4:]}" if len(value) > 8 else "configured"
|
|
||||||
|
|
||||||
|
|
||||||
def api_runtime_message(message: str) -> str:
|
|
||||||
known = {
|
|
||||||
"api_exited_during_startup": "API server exited during startup. Check its log for details.",
|
|
||||||
"api_stop_timeout": "API server did not stop in time.",
|
|
||||||
"api_state_stale": "API server state was stale; try starting it again.",
|
|
||||||
}
|
|
||||||
if message in known:
|
|
||||||
return known[message]
|
|
||||||
if message.startswith("api_"):
|
|
||||||
return f"API server {message.removeprefix('api_').replace('_', ' ')}"
|
|
||||||
return message.replace("_", " ")
|
|
||||||
|
|
||||||
|
|
||||||
def api_service_payload(
|
|
||||||
settings: WebUISettingsServices,
|
|
||||||
runtime: ApiRuntime,
|
|
||||||
*,
|
|
||||||
last_action: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
config = settings.config.load()
|
|
||||||
status = runtime.status()
|
|
||||||
extras = optional_dependency_groups()
|
|
||||||
connect_host = (
|
|
||||||
"127.0.0.1" if config.api.host in {"0.0.0.0", "::"} else config.api.host
|
|
||||||
)
|
|
||||||
payload = {
|
|
||||||
"installed": extra_installed("api", extras.get("api")),
|
|
||||||
"running": status.running,
|
|
||||||
"managed": status.running,
|
|
||||||
"host": config.api.host,
|
|
||||||
"port": config.api.port,
|
|
||||||
"timeout": config.api.timeout,
|
|
||||||
"api_key_hint": masked_api_secret(config.api.api_key),
|
|
||||||
"endpoint": f"http://{connect_host}:{config.api.port}/v1",
|
|
||||||
"command": "nanobot serve",
|
|
||||||
"log_path": str(status.log_path),
|
|
||||||
}
|
|
||||||
if last_action:
|
|
||||||
payload["last_action"] = last_action
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
class CapabilitySettingsHandler:
|
|
||||||
"""Handle capability commands after transport authentication and decoding."""
|
|
||||||
|
|
||||||
def __init__(self, settings: WebUISettingsServices, logger: Any) -> None:
|
|
||||||
self.settings = settings
|
|
||||||
self.logger = logger
|
|
||||||
|
|
||||||
async def handle(
|
|
||||||
self,
|
|
||||||
action: str,
|
|
||||||
request: SettingsRequest,
|
|
||||||
operations: CapabilitySettingsOperations,
|
|
||||||
) -> SettingsRouteResult:
|
|
||||||
if action == "api-status":
|
|
||||||
return SettingsRouteResult.success(
|
|
||||||
api_service_payload(self.settings, operations.api_runtime())
|
|
||||||
)
|
|
||||||
if action == "api-start":
|
|
||||||
return await self._start_api(request, operations)
|
|
||||||
if action == "api-stop":
|
|
||||||
return await self._stop_api(operations)
|
|
||||||
|
|
||||||
mutation = {
|
|
||||||
"web-search-update": (
|
|
||||||
operations.update_web_search,
|
|
||||||
"browser",
|
|
||||||
False,
|
|
||||||
),
|
|
||||||
"transcription-update": (
|
|
||||||
operations.update_transcription,
|
|
||||||
None,
|
|
||||||
False,
|
|
||||||
),
|
|
||||||
"network-update": (
|
|
||||||
operations.update_network,
|
|
||||||
"runtime",
|
|
||||||
False,
|
|
||||||
),
|
|
||||||
"image-update": (
|
|
||||||
operations.update_image,
|
|
||||||
"image",
|
|
||||||
True,
|
|
||||||
),
|
|
||||||
}.get(action)
|
|
||||||
if mutation is None:
|
|
||||||
return SettingsRouteResult.failure(404, "unknown settings action")
|
|
||||||
|
|
||||||
operation, section, apply_image_reload = mutation
|
|
||||||
try:
|
|
||||||
payload = self.settings.mutate(operation, request.query)
|
|
||||||
except WebUISettingsError as exc:
|
|
||||||
return SettingsRouteResult.failure(exc.status, exc.message)
|
|
||||||
if apply_image_reload:
|
|
||||||
payload, image_restart_cleared = await self.apply_image_runtime_change(
|
|
||||||
payload,
|
|
||||||
operations.reload_image,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
image_restart_cleared = False
|
|
||||||
return SettingsRouteResult.success(
|
|
||||||
payload,
|
|
||||||
decorate_restart=True,
|
|
||||||
restart_section=section,
|
|
||||||
clear_restart_section=("image" if image_restart_cleared else None),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def apply_image_runtime_change(
|
|
||||||
self,
|
|
||||||
payload: dict[str, Any],
|
|
||||||
reload_image: Callable[[], Awaitable[dict[str, Any]]],
|
|
||||||
) -> tuple[dict[str, Any], bool]:
|
|
||||||
"""Hot-apply image settings, preserving restart fallback on failure."""
|
|
||||||
if not payload.get("requires_restart"):
|
|
||||||
return payload, False
|
|
||||||
try:
|
|
||||||
result = await reload_image()
|
|
||||||
except Exception:
|
|
||||||
self.logger.exception("failed to hot-reload image generation settings")
|
|
||||||
return payload, False
|
|
||||||
|
|
||||||
applied = bool(result.get("ok")) and not result.get("requires_restart")
|
|
||||||
updated = dict(payload)
|
|
||||||
updated["requires_restart"] = not applied
|
|
||||||
if not applied:
|
|
||||||
self.logger.warning(
|
|
||||||
"image generation settings were saved but require restart: {}",
|
|
||||||
result.get("message") or "hot reload failed",
|
|
||||||
)
|
|
||||||
return updated, applied
|
|
||||||
|
|
||||||
async def _start_api(
|
|
||||||
self,
|
|
||||||
request: SettingsRequest,
|
|
||||||
operations: CapabilitySettingsOperations,
|
|
||||||
) -> SettingsRouteResult:
|
|
||||||
api_key = (request.payload or {}).get("api_key")
|
|
||||||
if api_key is not None and not isinstance(api_key, str):
|
|
||||||
return SettingsRouteResult.failure(
|
|
||||||
400,
|
|
||||||
"API service API key must be a string",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
await asyncio.to_thread(
|
|
||||||
self.settings.mutate,
|
|
||||||
operations.nanobot_features_action,
|
|
||||||
"enable",
|
|
||||||
{"name": ["api"]},
|
|
||||||
allow_install=self._allow_feature_package_install(request),
|
|
||||||
)
|
|
||||||
self.settings.mutate(operations.update_api, request.query)
|
|
||||||
config = self.settings.config.load()
|
|
||||||
runtime = operations.api_runtime()
|
|
||||||
options = ApiStartOptions(
|
|
||||||
host=config.api.host,
|
|
||||||
port=config.api.port,
|
|
||||||
workspace=str(config.workspace_path),
|
|
||||||
config_path=str(self.settings.config.path),
|
|
||||||
)
|
|
||||||
current = runtime.status()
|
|
||||||
result = await asyncio.to_thread(
|
|
||||||
runtime.restart if current.running else runtime.start_background,
|
|
||||||
options,
|
|
||||||
)
|
|
||||||
if not result.ok:
|
|
||||||
return SettingsRouteResult.failure(
|
|
||||||
500,
|
|
||||||
api_runtime_message(result.message),
|
|
||||||
)
|
|
||||||
except (WebUISettingsError, OptionalFeatureError) as exc:
|
|
||||||
return SettingsRouteResult.failure(
|
|
||||||
getattr(exc, "status", 400),
|
|
||||||
getattr(exc, "message", str(exc)),
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
self.logger.exception("failed to start managed API service")
|
|
||||||
return SettingsRouteResult.failure(500, str(exc))
|
|
||||||
return SettingsRouteResult.success(
|
|
||||||
api_service_payload(
|
|
||||||
self.settings,
|
|
||||||
operations.api_runtime(),
|
|
||||||
last_action="started",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _stop_api(
|
|
||||||
self,
|
|
||||||
operations: CapabilitySettingsOperations,
|
|
||||||
) -> SettingsRouteResult:
|
|
||||||
runtime = operations.api_runtime()
|
|
||||||
try:
|
|
||||||
result = await asyncio.to_thread(runtime.stop)
|
|
||||||
except Exception as exc:
|
|
||||||
self.logger.exception("failed to stop managed API service")
|
|
||||||
return SettingsRouteResult.failure(500, str(exc))
|
|
||||||
if not result.ok and result.message != "api_not_running":
|
|
||||||
return SettingsRouteResult.failure(
|
|
||||||
500,
|
|
||||||
api_runtime_message(result.message),
|
|
||||||
)
|
|
||||||
return SettingsRouteResult.success(
|
|
||||||
api_service_payload(
|
|
||||||
self.settings,
|
|
||||||
operations.api_runtime(),
|
|
||||||
last_action="stopped",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _allow_feature_package_install(self, request: SettingsRequest) -> bool:
|
|
||||||
if request.local_browser:
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
return bool(
|
|
||||||
self.settings.config.load().tools.webui_allow_remote_package_install
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
self.logger.exception("failed to load remote package install policy")
|
|
||||||
return False
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
"""Stable request and error contracts shared by WebUI settings domains."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
QueryParams = dict[str, list[str]]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SettingsRequest:
|
|
||||||
"""Transport-neutral input decoded by the settings route facade."""
|
|
||||||
|
|
||||||
query: QueryParams
|
|
||||||
payload: dict[str, Any] | None = None
|
|
||||||
local_browser: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SettingsRouteResult:
|
|
||||||
"""Transport-neutral result returned by a settings domain handler."""
|
|
||||||
|
|
||||||
payload: dict[str, Any] | None = None
|
|
||||||
status: int = 200
|
|
||||||
error: str | None = None
|
|
||||||
decorate_restart: bool = False
|
|
||||||
restart_section: str | None = None
|
|
||||||
clear_restart_section: str | None = None
|
|
||||||
restart_payload_key: str | None = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def success(
|
|
||||||
cls,
|
|
||||||
payload: dict[str, Any],
|
|
||||||
*,
|
|
||||||
decorate_restart: bool = False,
|
|
||||||
restart_section: str | None = None,
|
|
||||||
clear_restart_section: str | None = None,
|
|
||||||
restart_payload_key: str | None = None,
|
|
||||||
) -> SettingsRouteResult:
|
|
||||||
return cls(
|
|
||||||
payload=payload,
|
|
||||||
decorate_restart=decorate_restart,
|
|
||||||
restart_section=restart_section,
|
|
||||||
clear_restart_section=clear_restart_section,
|
|
||||||
restart_payload_key=restart_payload_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def failure(cls, status: int, error: str) -> SettingsRouteResult:
|
|
||||||
return cls(status=status, error=error)
|
|
||||||
|
|
||||||
|
|
||||||
class WebUISettingsError(ValueError):
|
|
||||||
"""User-facing settings validation failure."""
|
|
||||||
|
|
||||||
def __init__(self, message: str, *, status: int = 400) -> None:
|
|
||||||
super().__init__(message)
|
|
||||||
self.message = message
|
|
||||||
self.status = status
|
|
||||||
|
|
||||||
|
|
||||||
def query_first(query: QueryParams, key: str) -> str | None:
|
|
||||||
values = query.get(key)
|
|
||||||
return values[0] if values else None
|
|
||||||
|
|
||||||
|
|
||||||
def query_first_alias(query: QueryParams, snake: str, camel: str) -> str | None:
|
|
||||||
value = query_first(query, snake)
|
|
||||||
return query_first(query, camel) if value is None else value
|
|
||||||
|
|
||||||
|
|
||||||
def query_has_alias(query: QueryParams, snake: str, camel: str) -> bool:
|
|
||||||
return snake in query or camel in query
|
|
||||||
|
|
||||||
|
|
||||||
def parse_bool(value: str, field: str) -> bool:
|
|
||||||
normalized = value.strip().lower()
|
|
||||||
if normalized not in {"1", "0", "true", "false", "yes", "no"}:
|
|
||||||
raise WebUISettingsError(f"{field} must be boolean")
|
|
||||||
return normalized in {"1", "true", "yes"}
|
|
||||||
File diff suppressed because it is too large
Load Diff
+1030
-407
File diff suppressed because it is too large
Load Diff
@@ -1,148 +0,0 @@
|
|||||||
"""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,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@@ -1,957 +0,0 @@
|
|||||||
"""System and channel settings domain logic."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import inspect
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
from collections.abc import Callable, Iterable
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import TYPE_CHECKING, Any, TypedDict, cast
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
from nanobot.channels._setup import channel_setup_spec
|
|
||||||
from nanobot.channels.connect import ChannelConnectError
|
|
||||||
from nanobot.channels.contracts import (
|
|
||||||
RouteFieldType,
|
|
||||||
channel_instance_config,
|
|
||||||
channel_update_instance_config,
|
|
||||||
)
|
|
||||||
from nanobot.config.schema import Config
|
|
||||||
from nanobot.optional_features import OptionalFeatureError, with_channel_runtime_status
|
|
||||||
from nanobot.security.workspace_access import workspace_sandbox_status
|
|
||||||
from nanobot.webui.settings_capabilities import network_safety_payload
|
|
||||||
from nanobot.webui.settings_contracts import (
|
|
||||||
QueryParams,
|
|
||||||
SettingsRequest,
|
|
||||||
SettingsRouteResult,
|
|
||||||
WebUISettingsError,
|
|
||||||
query_first,
|
|
||||||
query_first_alias,
|
|
||||||
)
|
|
||||||
from nanobot.webui.token_usage import token_usage_payload
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from nanobot.webui.settings_services import WebUISettingsServices
|
|
||||||
|
|
||||||
LoadChannelPlugin = Callable[[str], Any]
|
|
||||||
ListPendingPairings = Callable[[], Iterable[dict[str, Any]]]
|
|
||||||
SettingsOperation = Callable[..., Any]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SystemSettingsOperations:
|
|
||||||
cli_apps_payload: SettingsOperation
|
|
||||||
cli_apps_action: SettingsOperation
|
|
||||||
nanobot_features_payload: SettingsOperation
|
|
||||||
nanobot_features_action: SettingsOperation
|
|
||||||
nanobot_feature_instance_target: SettingsOperation
|
|
||||||
validate_channel_config: SettingsOperation
|
|
||||||
load_channel_plugin: LoadChannelPlugin
|
|
||||||
list_pending: ListPendingPairings
|
|
||||||
approve_code: SettingsOperation
|
|
||||||
deny_code: SettingsOperation
|
|
||||||
mcp_presets_action: SettingsOperation
|
|
||||||
reload_mcp: SettingsOperation
|
|
||||||
check_for_update: SettingsOperation
|
|
||||||
channel_feature_action: SettingsOperation | None = None
|
|
||||||
channel_runtime_status: Callable[[], dict[str, Any]] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class SystemSettingsPayload(TypedDict):
|
|
||||||
runtime: dict[str, Any]
|
|
||||||
usage: dict[str, Any]
|
|
||||||
advanced: dict[str, Any]
|
|
||||||
version: dict[str, Any]
|
|
||||||
docs: dict[str, Any]
|
|
||||||
|
|
||||||
|
|
||||||
_DOCS_STABLE_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:\.post\d+)?$")
|
|
||||||
_DOCS_LATEST_URL = "https://nanobot.wiki/docs/latest"
|
|
||||||
_SKIP_FIELD = object()
|
|
||||||
|
|
||||||
|
|
||||||
def docs_version(version: str) -> str:
|
|
||||||
"""Map package versions to the matching public docs path."""
|
|
||||||
normalized = version.strip()
|
|
||||||
if _DOCS_STABLE_VERSION_RE.fullmatch(normalized):
|
|
||||||
return normalized
|
|
||||||
return "latest"
|
|
||||||
|
|
||||||
|
|
||||||
def docs_payload(version: str) -> dict[str, Any]:
|
|
||||||
selected_version = docs_version(version)
|
|
||||||
base_url = f"https://nanobot.wiki/docs/{selected_version}"
|
|
||||||
return {
|
|
||||||
"version": selected_version,
|
|
||||||
"base_url": base_url,
|
|
||||||
"chat_apps_url": f"{base_url}/getting-started/chat-apps",
|
|
||||||
"latest_url": _DOCS_LATEST_URL,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def system_settings_payload(
|
|
||||||
config: Config,
|
|
||||||
*,
|
|
||||||
config_path: Path,
|
|
||||||
version: str,
|
|
||||||
) -> SystemSettingsPayload:
|
|
||||||
defaults = config.agents.defaults
|
|
||||||
exec_config = config.tools.exec
|
|
||||||
sandbox_status = workspace_sandbox_status(
|
|
||||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
|
||||||
workspace=config.workspace_path,
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"runtime": {
|
|
||||||
"config_path": str(config_path.expanduser()),
|
|
||||||
"workspace_path": str(config.workspace_path),
|
|
||||||
"gateway_host": config.gateway.host,
|
|
||||||
"gateway_port": config.gateway.port,
|
|
||||||
"heartbeat": {
|
|
||||||
"enabled": config.gateway.heartbeat.enabled,
|
|
||||||
"interval_s": config.gateway.heartbeat.interval_s,
|
|
||||||
"keep_recent_messages": config.gateway.heartbeat.keep_recent_messages,
|
|
||||||
},
|
|
||||||
"dream": {
|
|
||||||
"schedule": defaults.dream.describe_schedule(),
|
|
||||||
},
|
|
||||||
"unified_session": defaults.unified_session,
|
|
||||||
},
|
|
||||||
"usage": token_usage_payload(timezone_name=defaults.timezone),
|
|
||||||
"advanced": {
|
|
||||||
"restrict_to_workspace": config.tools.restrict_to_workspace,
|
|
||||||
"workspace_sandbox": sandbox_status.as_dict(),
|
|
||||||
**network_safety_payload(config),
|
|
||||||
"mcp_server_count": len(config.tools.mcp_servers),
|
|
||||||
"exec_enabled": exec_config.enable,
|
|
||||||
"exec_sandbox": exec_config.sandbox or None,
|
|
||||||
"exec_path_prepend_set": bool(exec_config.path_prepend),
|
|
||||||
"exec_path_append_set": bool(exec_config.path_append),
|
|
||||||
},
|
|
||||||
"version": {"current": version},
|
|
||||||
"docs": docs_payload(version),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def settings_usage_payload(config: Config) -> dict[str, Any]:
|
|
||||||
"""Return the lightweight token usage slice for Overview refreshes."""
|
|
||||||
return token_usage_payload(timezone_name=config.agents.defaults.timezone)
|
|
||||||
|
|
||||||
|
|
||||||
def update_agent_system_settings(config: Config, query: QueryParams) -> tuple[bool, bool]:
|
|
||||||
defaults = config.agents.defaults
|
|
||||||
changed = False
|
|
||||||
restart_required = False
|
|
||||||
|
|
||||||
timezone = query_first(query, "timezone")
|
|
||||||
if timezone is not None:
|
|
||||||
timezone = timezone.strip()
|
|
||||||
if not timezone:
|
|
||||||
raise WebUISettingsError("timezone is required")
|
|
||||||
try:
|
|
||||||
ZoneInfo(timezone)
|
|
||||||
except Exception:
|
|
||||||
raise WebUISettingsError("invalid timezone") from None
|
|
||||||
timezone_changed = defaults.timezone != timezone
|
|
||||||
if timezone_changed or defaults.timezone_mode != "manual":
|
|
||||||
defaults.timezone = timezone
|
|
||||||
defaults.timezone_mode = "manual"
|
|
||||||
changed = True
|
|
||||||
restart_required = timezone_changed
|
|
||||||
|
|
||||||
tool_hint_max_length = query_first_alias(
|
|
||||||
query,
|
|
||||||
"tool_hint_max_length",
|
|
||||||
"toolHintMaxLength",
|
|
||||||
)
|
|
||||||
if tool_hint_max_length is not None:
|
|
||||||
try:
|
|
||||||
parsed = int(tool_hint_max_length)
|
|
||||||
except ValueError:
|
|
||||||
raise WebUISettingsError(
|
|
||||||
"tool_hint_max_length must be an integer"
|
|
||||||
) from None
|
|
||||||
if parsed < 20 or parsed > 500:
|
|
||||||
raise WebUISettingsError(
|
|
||||||
"tool_hint_max_length must be between 20 and 500"
|
|
||||||
)
|
|
||||||
if defaults.tool_hint_max_length != parsed:
|
|
||||||
defaults.tool_hint_max_length = parsed
|
|
||||||
changed = True
|
|
||||||
restart_required = True
|
|
||||||
return changed, restart_required
|
|
||||||
|
|
||||||
|
|
||||||
def save_channel_config_values(
|
|
||||||
config: Config,
|
|
||||||
name: str,
|
|
||||||
raw_values: dict[str, Any],
|
|
||||||
instance_id: str = "default",
|
|
||||||
*,
|
|
||||||
load_channel_plugin: LoadChannelPlugin,
|
|
||||||
) -> list[str]:
|
|
||||||
if not name:
|
|
||||||
raise WebUISettingsError("missing channel name")
|
|
||||||
try:
|
|
||||||
plugin = load_channel_plugin(name)
|
|
||||||
except ImportError:
|
|
||||||
raise WebUISettingsError(f"unknown channel '{name}'", status=404) from None
|
|
||||||
setup_spec = channel_setup_spec(name, plugin=plugin)
|
|
||||||
if setup_spec is None:
|
|
||||||
raise WebUISettingsError(
|
|
||||||
f"channel '{name}' cannot be configured from WebUI",
|
|
||||||
status=404,
|
|
||||||
)
|
|
||||||
field_types = setup_spec.route_field_types
|
|
||||||
if not raw_values:
|
|
||||||
return []
|
|
||||||
|
|
||||||
section = getattr(config.channels, name, None)
|
|
||||||
channel_config = channel_instance_config(
|
|
||||||
plugin,
|
|
||||||
section,
|
|
||||||
instance_id=instance_id,
|
|
||||||
)
|
|
||||||
saved: list[str] = []
|
|
||||||
prefix = f"channels.{name}."
|
|
||||||
for raw_key, raw_value in raw_values.items():
|
|
||||||
if not raw_key:
|
|
||||||
raise WebUISettingsError(
|
|
||||||
"channel settings payload contains an invalid key"
|
|
||||||
)
|
|
||||||
field = raw_key[len(prefix) :] if raw_key.startswith(prefix) else raw_key
|
|
||||||
value_type = field_types.get(field)
|
|
||||||
if value_type is None:
|
|
||||||
raise WebUISettingsError(f"'{raw_key}' cannot be configured from WebUI")
|
|
||||||
value = coerce_channel_value(raw_key, raw_value, value_type)
|
|
||||||
if value is _SKIP_FIELD:
|
|
||||||
continue
|
|
||||||
assign_channel_config_value(channel_config, field, value)
|
|
||||||
saved.append(raw_key)
|
|
||||||
|
|
||||||
try:
|
|
||||||
updated_section = channel_update_instance_config(
|
|
||||||
plugin,
|
|
||||||
section,
|
|
||||||
channel_config,
|
|
||||||
instance_id=instance_id,
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise WebUISettingsError(
|
|
||||||
f"Invalid {name} configuration: {exc}",
|
|
||||||
status=400,
|
|
||||||
) from exc
|
|
||||||
setattr(config.channels, name, updated_section)
|
|
||||||
return saved
|
|
||||||
|
|
||||||
|
|
||||||
def coerce_channel_value(
|
|
||||||
raw_key: str,
|
|
||||||
raw_value: Any,
|
|
||||||
value_type: RouteFieldType,
|
|
||||||
) -> Any:
|
|
||||||
if isinstance(value_type, tuple):
|
|
||||||
kind = value_type[0]
|
|
||||||
allowed = value_type[1]
|
|
||||||
else:
|
|
||||||
kind = value_type
|
|
||||||
allowed = None
|
|
||||||
|
|
||||||
if kind in {"string", "secret"}:
|
|
||||||
value = raw_value.strip() if isinstance(raw_value, str) else str(raw_value)
|
|
||||||
if kind == "secret" and not value:
|
|
||||||
return _SKIP_FIELD
|
|
||||||
return value
|
|
||||||
|
|
||||||
if kind == "list":
|
|
||||||
if raw_value is None:
|
|
||||||
return []
|
|
||||||
if isinstance(raw_value, str):
|
|
||||||
return [item.strip() for item in raw_value.split(",") if item.strip()]
|
|
||||||
if isinstance(raw_value, list):
|
|
||||||
return [
|
|
||||||
str(item).strip()
|
|
||||||
for item in cast(list[Any], raw_value)
|
|
||||||
if str(item).strip()
|
|
||||||
]
|
|
||||||
raise WebUISettingsError(f"'{raw_key}' must be a comma-separated list")
|
|
||||||
|
|
||||||
if kind == "int":
|
|
||||||
if raw_value in (None, ""):
|
|
||||||
return _SKIP_FIELD
|
|
||||||
try:
|
|
||||||
return int(raw_value)
|
|
||||||
except (TypeError, ValueError) as exc:
|
|
||||||
raise WebUISettingsError(f"'{raw_key}' must be a number") from exc
|
|
||||||
|
|
||||||
if kind == "bool":
|
|
||||||
if isinstance(raw_value, bool):
|
|
||||||
return raw_value
|
|
||||||
value = str(raw_value).strip().lower()
|
|
||||||
if value in {"true", "1", "yes", "on"}:
|
|
||||||
return True
|
|
||||||
if value in {"false", "0", "no", "off"}:
|
|
||||||
return False
|
|
||||||
raise WebUISettingsError(f"'{raw_key}' must be true or false")
|
|
||||||
|
|
||||||
if kind == "enum":
|
|
||||||
value = raw_value.strip() if isinstance(raw_value, str) else str(raw_value)
|
|
||||||
if not value:
|
|
||||||
return _SKIP_FIELD
|
|
||||||
if allowed is None or value not in allowed:
|
|
||||||
options = ", ".join(sorted(allowed or ()))
|
|
||||||
raise WebUISettingsError(f"'{raw_key}' must be one of: {options}")
|
|
||||||
return value
|
|
||||||
|
|
||||||
raise WebUISettingsError(f"'{raw_key}' has an unsupported field type")
|
|
||||||
|
|
||||||
|
|
||||||
def assign_channel_config_value(
|
|
||||||
channel_config: dict[str, Any],
|
|
||||||
field: str,
|
|
||||||
value: Any,
|
|
||||||
) -> None:
|
|
||||||
target = channel_config
|
|
||||||
parts = field.split(".")
|
|
||||||
for part in parts[:-1]:
|
|
||||||
current: object = target.get(part)
|
|
||||||
if not isinstance(current, dict):
|
|
||||||
current = {}
|
|
||||||
target[part] = current
|
|
||||||
target = cast(dict[str, Any], current)
|
|
||||||
target[parts[-1]] = value
|
|
||||||
|
|
||||||
|
|
||||||
def pairing_payload(
|
|
||||||
list_pending: ListPendingPairings,
|
|
||||||
last_action: dict[str, Any] | None = None,
|
|
||||||
*,
|
|
||||||
now: float | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
current_time = time.time() if now is None else now
|
|
||||||
requests: list[dict[str, Any]] = []
|
|
||||||
for item in list_pending():
|
|
||||||
expires_at = float(item.get("expires_at", 0) or 0)
|
|
||||||
created_at = float(item.get("created_at", 0) or 0)
|
|
||||||
requests.append(
|
|
||||||
{
|
|
||||||
"code": str(item.get("code", "")),
|
|
||||||
"channel": str(item.get("channel", "")),
|
|
||||||
"sender_id": str(item.get("sender_id", "")),
|
|
||||||
"created_at_ms": int(created_at * 1000) if created_at else None,
|
|
||||||
"expires_at_ms": int(expires_at * 1000) if expires_at else None,
|
|
||||||
"expires_in_seconds": (
|
|
||||||
max(0, int(expires_at - current_time)) if expires_at else None
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
payload: dict[str, Any] = {"requests": requests}
|
|
||||||
if last_action is not None:
|
|
||||||
payload["last_action"] = last_action
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
class SystemSettingsHandler:
|
|
||||||
"""Handle channel and system commands behind a transport-neutral request DTO."""
|
|
||||||
|
|
||||||
def __init__(self, settings: WebUISettingsServices, logger: Any) -> None:
|
|
||||||
self.settings = settings
|
|
||||||
self.logger = logger
|
|
||||||
self._channel_connectors: dict[str, Any] = {}
|
|
||||||
|
|
||||||
async def handle(
|
|
||||||
self,
|
|
||||||
action: str,
|
|
||||||
request: SettingsRequest,
|
|
||||||
operations: SystemSettingsOperations,
|
|
||||||
*,
|
|
||||||
channel_name: str | None = None,
|
|
||||||
connect_action: str | None = None,
|
|
||||||
) -> SettingsRouteResult:
|
|
||||||
if action == "cli-list":
|
|
||||||
return await self._cli_apps(request, operations)
|
|
||||||
if action.startswith("cli-"):
|
|
||||||
return await self._cli_apps_action(
|
|
||||||
request,
|
|
||||||
action.removeprefix("cli-"),
|
|
||||||
operations,
|
|
||||||
)
|
|
||||||
if action == "features-list":
|
|
||||||
return await self._features(operations)
|
|
||||||
if action in {"features-enable", "features-disable"}:
|
|
||||||
return await self._features_action(
|
|
||||||
request,
|
|
||||||
action.removeprefix("features-"),
|
|
||||||
operations,
|
|
||||||
)
|
|
||||||
if action == "channel-validate":
|
|
||||||
return await self._channel_validate(request, operations)
|
|
||||||
if action == "channel-configure":
|
|
||||||
return await self._channel_configure(request, operations)
|
|
||||||
if action == "channel-connect" and channel_name and connect_action:
|
|
||||||
return await self._channel_connect(
|
|
||||||
request,
|
|
||||||
channel_name,
|
|
||||||
connect_action,
|
|
||||||
operations,
|
|
||||||
)
|
|
||||||
if action == "pairing-list":
|
|
||||||
return SettingsRouteResult.success(pairing_payload(operations.list_pending))
|
|
||||||
if action in {"pairing-approve", "pairing-deny"}:
|
|
||||||
return self._pairing_action(
|
|
||||||
request,
|
|
||||||
action.removeprefix("pairing-"),
|
|
||||||
operations,
|
|
||||||
)
|
|
||||||
if action == "mcp-list":
|
|
||||||
return await self._mcp_presets(request, None, operations)
|
|
||||||
if action.startswith("mcp-"):
|
|
||||||
return await self._mcp_presets(
|
|
||||||
request,
|
|
||||||
action.removeprefix("mcp-"),
|
|
||||||
operations,
|
|
||||||
)
|
|
||||||
if action == "version-check":
|
|
||||||
return await self._version_check(operations)
|
|
||||||
return SettingsRouteResult.failure(404, "unknown settings action")
|
|
||||||
|
|
||||||
async def _cli_apps(
|
|
||||||
self,
|
|
||||||
request: SettingsRequest,
|
|
||||||
operations: SystemSettingsOperations,
|
|
||||||
) -> SettingsRouteResult:
|
|
||||||
installed_only = (query_first(request.query, "installed_only") or "").lower() in {
|
|
||||||
"1",
|
|
||||||
"true",
|
|
||||||
"yes",
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
payload = await operations.cli_apps_payload(
|
|
||||||
installed_only=installed_only,
|
|
||||||
config_path=self.settings.config.path,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
self.logger.exception("failed to load CLI Apps payload")
|
|
||||||
return SettingsRouteResult.failure(500, "failed to load CLI Apps")
|
|
||||||
return SettingsRouteResult.success(payload)
|
|
||||||
|
|
||||||
async def _cli_apps_action(
|
|
||||||
self,
|
|
||||||
request: SettingsRequest,
|
|
||||||
action: str,
|
|
||||||
operations: SystemSettingsOperations,
|
|
||||||
) -> SettingsRouteResult:
|
|
||||||
try:
|
|
||||||
payload = await asyncio.to_thread(
|
|
||||||
operations.cli_apps_action,
|
|
||||||
action,
|
|
||||||
request.query,
|
|
||||||
config_path=self.settings.config.path,
|
|
||||||
)
|
|
||||||
except WebUISettingsError as exc:
|
|
||||||
return SettingsRouteResult.failure(exc.status, exc.message)
|
|
||||||
except Exception as exc:
|
|
||||||
status = getattr(exc, "status", 500)
|
|
||||||
message = getattr(exc, "message", str(exc))
|
|
||||||
if status >= 500:
|
|
||||||
self.logger.exception("CLI Apps action '{}' failed", action)
|
|
||||||
return SettingsRouteResult.failure(status, message)
|
|
||||||
return SettingsRouteResult.success(payload)
|
|
||||||
|
|
||||||
async def _features(
|
|
||||||
self,
|
|
||||||
operations: SystemSettingsOperations,
|
|
||||||
) -> SettingsRouteResult:
|
|
||||||
try:
|
|
||||||
payload = await asyncio.to_thread(
|
|
||||||
operations.nanobot_features_payload,
|
|
||||||
config_path=self.settings.config.path,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
self.logger.exception("failed to load nanobot features")
|
|
||||||
return SettingsRouteResult.failure(500, "failed to load nanobot features")
|
|
||||||
return SettingsRouteResult.success(
|
|
||||||
self._with_channel_runtime_status(payload, operations)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _nanobot_features_payload(
|
|
||||||
self,
|
|
||||||
operations: SystemSettingsOperations,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return operations.nanobot_features_payload(config_path=self.settings.config.path)
|
|
||||||
|
|
||||||
def _nanobot_features_action(
|
|
||||||
self,
|
|
||||||
action: str,
|
|
||||||
query: QueryParams,
|
|
||||||
operations: SystemSettingsOperations,
|
|
||||||
*,
|
|
||||||
allow_install: bool = True,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return self.settings.mutate(
|
|
||||||
operations.nanobot_features_action,
|
|
||||||
action,
|
|
||||||
query,
|
|
||||||
allow_install=allow_install,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _features_action(
|
|
||||||
self,
|
|
||||||
request: SettingsRequest,
|
|
||||||
action: str,
|
|
||||||
operations: SystemSettingsOperations,
|
|
||||||
) -> SettingsRouteResult:
|
|
||||||
try:
|
|
||||||
payload = await asyncio.to_thread(
|
|
||||||
self._nanobot_features_action,
|
|
||||||
action,
|
|
||||||
request.query,
|
|
||||||
operations,
|
|
||||||
allow_install=(
|
|
||||||
action != "enable"
|
|
||||||
or self.allow_feature_package_install(request)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
except OptionalFeatureError as exc:
|
|
||||||
return SettingsRouteResult.failure(exc.status, exc.message)
|
|
||||||
except Exception as exc:
|
|
||||||
status = getattr(exc, "status", 500)
|
|
||||||
message = getattr(exc, "message", str(exc))
|
|
||||||
if status >= 500:
|
|
||||||
self.logger.exception(
|
|
||||||
"nanobot feature action '{}' failed",
|
|
||||||
action,
|
|
||||||
)
|
|
||||||
return SettingsRouteResult.failure(status, message)
|
|
||||||
payload = await self._apply_feature_runtime_change(
|
|
||||||
action,
|
|
||||||
request.query,
|
|
||||||
payload,
|
|
||||||
operations,
|
|
||||||
)
|
|
||||||
payload = self._with_channel_runtime_status(payload, operations)
|
|
||||||
return SettingsRouteResult.success(
|
|
||||||
payload,
|
|
||||||
decorate_restart=True,
|
|
||||||
restart_section="runtime",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _with_channel_runtime_status(
|
|
||||||
self,
|
|
||||||
payload: dict[str, Any],
|
|
||||||
operations: SystemSettingsOperations,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
if operations.channel_runtime_status is None:
|
|
||||||
return payload
|
|
||||||
try:
|
|
||||||
return with_channel_runtime_status(
|
|
||||||
payload,
|
|
||||||
operations.channel_runtime_status(),
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
self.logger.exception("failed to load channel runtime status")
|
|
||||||
return payload
|
|
||||||
|
|
||||||
async def _apply_feature_runtime_change(
|
|
||||||
self,
|
|
||||||
action: str,
|
|
||||||
query: QueryParams,
|
|
||||||
payload: dict[str, Any],
|
|
||||||
operations: SystemSettingsOperations,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
if operations.channel_feature_action is None:
|
|
||||||
return payload
|
|
||||||
name = (query_first(query, "name") or "").strip()
|
|
||||||
if not name:
|
|
||||||
return payload
|
|
||||||
try:
|
|
||||||
instance_id = operations.nanobot_feature_instance_target(query)
|
|
||||||
result = operations.channel_feature_action(action, name, instance_id)
|
|
||||||
if inspect.isawaitable(result):
|
|
||||||
result = await result
|
|
||||||
except Exception as exc:
|
|
||||||
self.logger.exception("failed to apply channel '{}' without restart", name)
|
|
||||||
return self.feature_runtime_fallback(
|
|
||||||
payload,
|
|
||||||
message=(
|
|
||||||
f"{name} channel config was saved, but hot reload failed: {exc}"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if not isinstance(result, dict):
|
|
||||||
return payload
|
|
||||||
result = cast(dict[str, Any], result)
|
|
||||||
if not result.get("handled"):
|
|
||||||
return payload
|
|
||||||
|
|
||||||
updated = dict(payload)
|
|
||||||
updated["requires_restart"] = bool(result.get("requires_restart"))
|
|
||||||
message = result.get("message")
|
|
||||||
if isinstance(message, str) and message:
|
|
||||||
last_action = dict(updated.get("last_action") or {})
|
|
||||||
previous = last_action.get("message")
|
|
||||||
last_action["message"] = (
|
|
||||||
f"{previous}. {message}"
|
|
||||||
if isinstance(previous, str) and previous
|
|
||||||
else message
|
|
||||||
)
|
|
||||||
last_action["hot_reload"] = not updated["requires_restart"]
|
|
||||||
if "ok" in result:
|
|
||||||
last_action["ok"] = bool(result["ok"])
|
|
||||||
updated["last_action"] = last_action
|
|
||||||
return updated
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def feature_runtime_fallback(
|
|
||||||
payload: dict[str, Any],
|
|
||||||
*,
|
|
||||||
message: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
updated = dict(payload)
|
|
||||||
updated["requires_restart"] = True
|
|
||||||
last_action = dict(updated.get("last_action") or {})
|
|
||||||
previous = last_action.get("message")
|
|
||||||
last_action["message"] = (
|
|
||||||
f"{previous}. {message}"
|
|
||||||
if isinstance(previous, str) and previous
|
|
||||||
else message
|
|
||||||
)
|
|
||||||
last_action["hot_reload"] = False
|
|
||||||
updated["last_action"] = last_action
|
|
||||||
return updated
|
|
||||||
|
|
||||||
async def _channel_configure(
|
|
||||||
self,
|
|
||||||
request: SettingsRequest,
|
|
||||||
operations: SystemSettingsOperations,
|
|
||||||
) -> SettingsRouteResult:
|
|
||||||
name = (query_first(request.query, "name") or "").strip()
|
|
||||||
instance_id = (
|
|
||||||
query_first(request.query, "instance_id") or "default"
|
|
||||||
).strip()
|
|
||||||
enable = (query_first(request.query, "enable") or "").strip().lower() in {
|
|
||||||
"1",
|
|
||||||
"true",
|
|
||||||
"yes",
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
saved = await asyncio.to_thread(
|
|
||||||
self._save_channel_config_values,
|
|
||||||
name,
|
|
||||||
self.parse_channel_values(request),
|
|
||||||
instance_id,
|
|
||||||
operations,
|
|
||||||
)
|
|
||||||
except WebUISettingsError as exc:
|
|
||||||
return SettingsRouteResult.failure(exc.status, exc.message)
|
|
||||||
except Exception:
|
|
||||||
self.logger.exception("failed to save channel '{}' settings", name)
|
|
||||||
return SettingsRouteResult.failure(500, "failed to save channel settings")
|
|
||||||
|
|
||||||
payload: dict[str, Any] = {
|
|
||||||
"name": name,
|
|
||||||
"saved": True,
|
|
||||||
"saved_keys": saved,
|
|
||||||
}
|
|
||||||
if not enable:
|
|
||||||
features = await asyncio.to_thread(
|
|
||||||
self._nanobot_features_payload,
|
|
||||||
operations,
|
|
||||||
)
|
|
||||||
payload["nanobot_features"] = self._with_channel_runtime_status(
|
|
||||||
features,
|
|
||||||
operations,
|
|
||||||
)
|
|
||||||
return SettingsRouteResult.success(
|
|
||||||
payload,
|
|
||||||
decorate_restart=True,
|
|
||||||
restart_section="runtime",
|
|
||||||
restart_payload_key="nanobot_features",
|
|
||||||
)
|
|
||||||
|
|
||||||
feature_query = {"name": [name]}
|
|
||||||
if instance_id:
|
|
||||||
feature_query["instance_id"] = [instance_id]
|
|
||||||
try:
|
|
||||||
features = await asyncio.to_thread(
|
|
||||||
self._nanobot_features_action,
|
|
||||||
"enable",
|
|
||||||
feature_query,
|
|
||||||
operations,
|
|
||||||
allow_install=self.allow_feature_package_install(request),
|
|
||||||
)
|
|
||||||
except OptionalFeatureError as exc:
|
|
||||||
return SettingsRouteResult.failure(
|
|
||||||
exc.status,
|
|
||||||
f"Settings saved, but {exc.message}",
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
self.logger.exception(
|
|
||||||
"failed to enable channel '{}' after settings save",
|
|
||||||
name,
|
|
||||||
)
|
|
||||||
return SettingsRouteResult.failure(
|
|
||||||
500,
|
|
||||||
f"Settings saved, but enabling {name} failed: {exc}",
|
|
||||||
)
|
|
||||||
|
|
||||||
features = await self._apply_feature_runtime_change(
|
|
||||||
"enable",
|
|
||||||
feature_query,
|
|
||||||
features,
|
|
||||||
operations,
|
|
||||||
)
|
|
||||||
payload["nanobot_features"] = self._with_channel_runtime_status(
|
|
||||||
features,
|
|
||||||
operations,
|
|
||||||
)
|
|
||||||
return SettingsRouteResult.success(
|
|
||||||
payload,
|
|
||||||
decorate_restart=True,
|
|
||||||
restart_section="runtime",
|
|
||||||
restart_payload_key="nanobot_features",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _channel_validate(
|
|
||||||
self,
|
|
||||||
request: SettingsRequest,
|
|
||||||
operations: SystemSettingsOperations,
|
|
||||||
) -> SettingsRouteResult:
|
|
||||||
name = (query_first(request.query, "name") or "").strip()
|
|
||||||
instance_id = (
|
|
||||||
query_first(request.query, "instance_id") or "default"
|
|
||||||
).strip()
|
|
||||||
try:
|
|
||||||
payload = await asyncio.to_thread(
|
|
||||||
operations.validate_channel_config,
|
|
||||||
name,
|
|
||||||
self.parse_channel_values(request),
|
|
||||||
instance_id=instance_id,
|
|
||||||
)
|
|
||||||
except WebUISettingsError as exc:
|
|
||||||
return SettingsRouteResult.failure(exc.status, exc.message)
|
|
||||||
except Exception:
|
|
||||||
self.logger.exception("failed to validate channel '{}' settings", name)
|
|
||||||
return SettingsRouteResult.failure(
|
|
||||||
500,
|
|
||||||
"failed to validate channel settings",
|
|
||||||
)
|
|
||||||
return SettingsRouteResult.success(payload)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def parse_channel_values(request: SettingsRequest) -> dict[str, Any]:
|
|
||||||
if request.payload is None or "values" not in request.payload:
|
|
||||||
return {}
|
|
||||||
values = request.payload.get("values")
|
|
||||||
if not isinstance(values, dict):
|
|
||||||
raise WebUISettingsError(
|
|
||||||
"channel settings payload must be a JSON object"
|
|
||||||
)
|
|
||||||
return cast(dict[str, Any], values)
|
|
||||||
|
|
||||||
def _save_channel_config_values(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
raw_values: dict[str, Any],
|
|
||||||
instance_id: str,
|
|
||||||
operations: SystemSettingsOperations,
|
|
||||||
) -> list[str]:
|
|
||||||
return self.settings.config.update(
|
|
||||||
lambda config: save_channel_config_values(
|
|
||||||
config,
|
|
||||||
name,
|
|
||||||
raw_values,
|
|
||||||
instance_id,
|
|
||||||
load_channel_plugin=operations.load_channel_plugin,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _channel_connect(
|
|
||||||
self,
|
|
||||||
request: SettingsRequest,
|
|
||||||
channel_name: str,
|
|
||||||
action: str,
|
|
||||||
operations: SystemSettingsOperations,
|
|
||||||
) -> SettingsRouteResult:
|
|
||||||
try:
|
|
||||||
connector = self._channel_connectors.get(channel_name)
|
|
||||||
if connector is None:
|
|
||||||
plugin = operations.load_channel_plugin(channel_name)
|
|
||||||
connector = plugin.load_connector()
|
|
||||||
self._channel_connectors[channel_name] = connector
|
|
||||||
except ImportError:
|
|
||||||
return SettingsRouteResult.failure(
|
|
||||||
404,
|
|
||||||
f"channel '{channel_name}' does not support connect",
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
payload = await connector.handle(action, request.query)
|
|
||||||
except ChannelConnectError as exc:
|
|
||||||
return SettingsRouteResult.failure(exc.status, exc.message)
|
|
||||||
except Exception:
|
|
||||||
self.logger.exception(
|
|
||||||
"failed to run {} WebUI connect action for {}",
|
|
||||||
action,
|
|
||||||
channel_name,
|
|
||||||
)
|
|
||||||
return SettingsRouteResult.failure(
|
|
||||||
500,
|
|
||||||
f"failed to {action} {channel_name} connection",
|
|
||||||
)
|
|
||||||
|
|
||||||
if payload.get("status") != "succeeded":
|
|
||||||
return SettingsRouteResult.success(payload)
|
|
||||||
payload = await self._with_channel_connect_success(
|
|
||||||
request,
|
|
||||||
channel_name,
|
|
||||||
payload,
|
|
||||||
operations,
|
|
||||||
)
|
|
||||||
return SettingsRouteResult.success(
|
|
||||||
payload,
|
|
||||||
decorate_restart=True,
|
|
||||||
restart_section="runtime",
|
|
||||||
restart_payload_key="nanobot_features",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _with_channel_connect_success(
|
|
||||||
self,
|
|
||||||
request: SettingsRequest,
|
|
||||||
channel_name: str,
|
|
||||||
payload: dict[str, Any],
|
|
||||||
operations: SystemSettingsOperations,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
target = {"name": [channel_name]}
|
|
||||||
if payload.get("instance_id"):
|
|
||||||
target["instance_id"] = [str(payload["instance_id"])]
|
|
||||||
try:
|
|
||||||
features = await asyncio.to_thread(
|
|
||||||
self._nanobot_features_action,
|
|
||||||
"enable",
|
|
||||||
target,
|
|
||||||
operations,
|
|
||||||
allow_install=self.allow_feature_package_install(request),
|
|
||||||
)
|
|
||||||
except OptionalFeatureError as exc:
|
|
||||||
features = self.feature_runtime_fallback(
|
|
||||||
self._nanobot_features_payload(operations),
|
|
||||||
message=(
|
|
||||||
f"{channel_name} connected, but enabling channel support failed: "
|
|
||||||
f"{exc.message}"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
features = await self._apply_feature_runtime_change(
|
|
||||||
"enable",
|
|
||||||
target,
|
|
||||||
features,
|
|
||||||
operations,
|
|
||||||
)
|
|
||||||
updated = dict(payload)
|
|
||||||
updated["nanobot_features"] = self._with_channel_runtime_status(
|
|
||||||
features,
|
|
||||||
operations,
|
|
||||||
)
|
|
||||||
return updated
|
|
||||||
|
|
||||||
def allow_feature_package_install(self, request: SettingsRequest) -> bool:
|
|
||||||
if request.local_browser:
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
return bool(
|
|
||||||
self.settings.config.load().tools.webui_allow_remote_package_install
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
self.logger.exception("failed to load remote package install policy")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _pairing_action(
|
|
||||||
self,
|
|
||||||
request: SettingsRequest,
|
|
||||||
action: str,
|
|
||||||
operations: SystemSettingsOperations,
|
|
||||||
) -> SettingsRouteResult:
|
|
||||||
code = (query_first(request.query, "code") or "").strip()
|
|
||||||
if not code:
|
|
||||||
return SettingsRouteResult.failure(400, "Missing pairing code")
|
|
||||||
if action == "approve":
|
|
||||||
result = operations.approve_code(code)
|
|
||||||
if result is None:
|
|
||||||
return SettingsRouteResult.failure(
|
|
||||||
404,
|
|
||||||
"Pairing code not found or expired",
|
|
||||||
)
|
|
||||||
channel, sender_id = result
|
|
||||||
return SettingsRouteResult.success(
|
|
||||||
pairing_payload(
|
|
||||||
operations.list_pending,
|
|
||||||
{
|
|
||||||
"ok": True,
|
|
||||||
"action": "approve",
|
|
||||||
"message": f"Approved {sender_id} for {channel}",
|
|
||||||
"channel": channel,
|
|
||||||
"sender_id": sender_id,
|
|
||||||
"code": code,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if not operations.deny_code(code):
|
|
||||||
return SettingsRouteResult.failure(
|
|
||||||
404,
|
|
||||||
"Pairing code not found or expired",
|
|
||||||
)
|
|
||||||
return SettingsRouteResult.success(
|
|
||||||
pairing_payload(
|
|
||||||
operations.list_pending,
|
|
||||||
{
|
|
||||||
"ok": True,
|
|
||||||
"action": "deny",
|
|
||||||
"message": f"Denied pairing code {code}",
|
|
||||||
"code": code,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _mcp_presets(
|
|
||||||
self,
|
|
||||||
request: SettingsRequest,
|
|
||||||
action: str | None,
|
|
||||||
operations: SystemSettingsOperations,
|
|
||||||
) -> SettingsRouteResult:
|
|
||||||
try:
|
|
||||||
payload = await operations.mcp_presets_action(
|
|
||||||
action,
|
|
||||||
request.query,
|
|
||||||
reload_mcp=operations.reload_mcp,
|
|
||||||
config=self.settings.config,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
status = getattr(exc, "status", 500)
|
|
||||||
message = getattr(exc, "message", str(exc))
|
|
||||||
if status >= 500:
|
|
||||||
self.logger.exception(
|
|
||||||
"MCP preset action '{}' failed",
|
|
||||||
action or "list",
|
|
||||||
)
|
|
||||||
return SettingsRouteResult.failure(status, message)
|
|
||||||
return SettingsRouteResult.success(
|
|
||||||
payload,
|
|
||||||
decorate_restart=action is not None,
|
|
||||||
restart_section="runtime" if action is not None else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _version_check(
|
|
||||||
self,
|
|
||||||
operations: SystemSettingsOperations,
|
|
||||||
) -> SettingsRouteResult:
|
|
||||||
try:
|
|
||||||
update_info = await asyncio.to_thread(operations.check_for_update)
|
|
||||||
except Exception:
|
|
||||||
self.logger.exception("version check failed")
|
|
||||||
return SettingsRouteResult.failure(500, "version check failed")
|
|
||||||
return SettingsRouteResult.success({"updateAvailable": update_info})
|
|
||||||
+25
-175
@@ -17,10 +17,9 @@ import time
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
from urllib.parse import quote, unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from websockets.datastructures import Headers
|
|
||||||
from websockets.http11 import Request as WsRequest
|
from websockets.http11 import Request as WsRequest
|
||||||
from websockets.http11 import Response
|
from websockets.http11 import Response
|
||||||
|
|
||||||
@@ -119,60 +118,7 @@ from nanobot.webui.transcript import build_webui_thread_response
|
|||||||
from nanobot.webui.workspaces import WebUIWorkspaceController
|
from nanobot.webui.workspaces import WebUIWorkspaceController
|
||||||
|
|
||||||
_SLOW_WEBUI_HTTP_LOG_MS = 1_000
|
_SLOW_WEBUI_HTTP_LOG_MS = 1_000
|
||||||
_WEBUI_MUTATION_PAYLOAD_ATTR = "_nanobot_webui_mutation_payload"
|
_AUTOMATION_VALUES_HEADER = "X-Nanobot-Automation-Values"
|
||||||
_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
|
# 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'
|
# HKEY_CLASSES_ROOT\.js\Content Type, which is commonly set to 'text/plain'
|
||||||
@@ -204,7 +150,6 @@ if TYPE_CHECKING:
|
|||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
from nanobot.triggers.local_store import LocalTriggerStore
|
from nanobot.triggers.local_store import LocalTriggerStore
|
||||||
from nanobot.webui.settings_services import WebUISettingsServices
|
|
||||||
|
|
||||||
def _decode_api_key(raw_key: str) -> str | None:
|
def _decode_api_key(raw_key: str) -> str | None:
|
||||||
key = unquote(raw_key)
|
key = unquote(raw_key)
|
||||||
@@ -214,33 +159,6 @@ def _decode_api_key(raw_key: str) -> str | None:
|
|||||||
return key
|
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:
|
def _default_model_name_from_config() -> str | None:
|
||||||
try:
|
try:
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_config
|
||||||
@@ -293,7 +211,6 @@ class GatewayHTTPHandler:
|
|||||||
media: WebUIMediaGateway,
|
media: WebUIMediaGateway,
|
||||||
ingress: WebUIIngressPolicy,
|
ingress: WebUIIngressPolicy,
|
||||||
workspaces: WebUIWorkspaceController,
|
workspaces: WebUIWorkspaceController,
|
||||||
settings: WebUISettingsServices,
|
|
||||||
skills_workspace_path: Path,
|
skills_workspace_path: Path,
|
||||||
disabled_skills: set[str] | None = None,
|
disabled_skills: set[str] | None = None,
|
||||||
cron_service: CronService | None = None,
|
cron_service: CronService | None = None,
|
||||||
@@ -314,7 +231,6 @@ class GatewayHTTPHandler:
|
|||||||
self.media = media
|
self.media = media
|
||||||
self.ingress = ingress
|
self.ingress = ingress
|
||||||
self.workspaces = workspaces
|
self.workspaces = workspaces
|
||||||
self.settings = settings
|
|
||||||
self.skills_workspace_path = skills_workspace_path
|
self.skills_workspace_path = skills_workspace_path
|
||||||
self.disabled_skills: set[str] = (
|
self.disabled_skills: set[str] = (
|
||||||
disabled_skills if disabled_skills is not None else set()
|
disabled_skills if disabled_skills is not None else set()
|
||||||
@@ -333,7 +249,6 @@ class GatewayHTTPHandler:
|
|||||||
|
|
||||||
self._capabilities = _rc(runtime_surface, runtime_capabilities_overrides or {})
|
self._capabilities = _rc(runtime_surface, runtime_capabilities_overrides or {})
|
||||||
self.settings_routes = WebUISettingsRouter(
|
self.settings_routes = WebUISettingsRouter(
|
||||||
settings=settings,
|
|
||||||
bus=bus,
|
bus=bus,
|
||||||
logger=self._log,
|
logger=self._log,
|
||||||
check_api_token=self.check_api_token,
|
check_api_token=self.check_api_token,
|
||||||
@@ -370,86 +285,11 @@ class GatewayHTTPHandler:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
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)
|
response = await self._dispatch_resolved(connection, request, got)
|
||||||
return response
|
return response
|
||||||
finally:
|
finally:
|
||||||
self._log_slow_http(got, response, started)
|
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(
|
async def _dispatch_resolved(
|
||||||
self,
|
self,
|
||||||
connection: Any,
|
connection: Any,
|
||||||
@@ -806,7 +646,7 @@ class GatewayHTTPHandler:
|
|||||||
return _http_error(400, "invalid session key")
|
return _http_error(400, "invalid session key")
|
||||||
if not _is_websocket_channel_session_key(decoded_key):
|
if not _is_websocket_channel_session_key(decoded_key):
|
||||||
return _http_error(404, "session not found")
|
return _http_error(404, "session not found")
|
||||||
query = _request_query(request)
|
query = _parse_query(request.path)
|
||||||
delete_automations = (_query_first(query, "delete_automations") or "").lower()
|
delete_automations = (_query_first(query, "delete_automations") or "").lower()
|
||||||
automation_jobs = session_automation_jobs(
|
automation_jobs = session_automation_jobs(
|
||||||
self.cron_service,
|
self.cron_service,
|
||||||
@@ -902,7 +742,7 @@ class GatewayHTTPHandler:
|
|||||||
if self.cron_service is None and self.local_trigger_store is None:
|
if self.cron_service is None and self.local_trigger_store is None:
|
||||||
return _http_error(503, "automation service unavailable")
|
return _http_error(503, "automation service unavailable")
|
||||||
|
|
||||||
query = _request_query(request)
|
query = _parse_query(request.path)
|
||||||
job_id = (_query_first(query, "id") or _query_first(query, "job_id") or "").strip()
|
job_id = (_query_first(query, "id") or _query_first(query, "job_id") or "").strip()
|
||||||
if not job_id:
|
if not job_id:
|
||||||
return _http_error(400, "missing automation id")
|
return _http_error(400, "missing automation id")
|
||||||
@@ -1134,7 +974,7 @@ class GatewayHTTPHandler:
|
|||||||
if self._skill_install_lock.locked():
|
if self._skill_install_lock.locked():
|
||||||
return _http_error(409, "another skill installation is already in progress")
|
return _http_error(409, "another skill installation is already in progress")
|
||||||
|
|
||||||
query = _request_query(request)
|
query = _parse_query(request.path)
|
||||||
provider = _query_first(query, "provider") or "skills_sh"
|
provider = _query_first(query, "provider") or "skills_sh"
|
||||||
source = _query_first(query, "source") or ""
|
source = _query_first(query, "source") or ""
|
||||||
skill_id = _query_first(query, "skill") or ""
|
skill_id = _query_first(query, "skill") or ""
|
||||||
@@ -1175,7 +1015,7 @@ class GatewayHTTPHandler:
|
|||||||
def _handle_webui_skill_update(self, request: WsRequest) -> Response:
|
def _handle_webui_skill_update(self, request: WsRequest) -> Response:
|
||||||
if not self.check_api_token(request):
|
if not self.check_api_token(request):
|
||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
query = _request_query(request)
|
query = _parse_query(request.path)
|
||||||
name = _query_first(query, "name") or ""
|
name = _query_first(query, "name") or ""
|
||||||
raw_enabled = (_query_first(query, "enabled") or "").lower()
|
raw_enabled = (_query_first(query, "enabled") or "").lower()
|
||||||
if raw_enabled not in {"true", "false"}:
|
if raw_enabled not in {"true", "false"}:
|
||||||
@@ -1207,7 +1047,7 @@ class GatewayHTTPHandler:
|
|||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
if not _is_local_browser_request(connection, request.headers):
|
if not _is_local_browser_request(connection, request.headers):
|
||||||
return _http_error(403, "remote skill deletion is disabled")
|
return _http_error(403, "remote skill deletion is disabled")
|
||||||
name = _query_first(_request_query(request), "name") or ""
|
name = _query_first(_parse_query(request.path), "name") or ""
|
||||||
try:
|
try:
|
||||||
action = delete_webui_skill(
|
action = delete_webui_skill(
|
||||||
self.skills_workspace_path,
|
self.skills_workspace_path,
|
||||||
@@ -1254,14 +1094,18 @@ class GatewayHTTPHandler:
|
|||||||
def _handle_webui_sidebar_state_update(self, request: WsRequest) -> Response:
|
def _handle_webui_sidebar_state_update(self, request: WsRequest) -> Response:
|
||||||
if not self.check_api_token(request):
|
if not self.check_api_token(request):
|
||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
payload = _mutation_payload(request)
|
query = _parse_query(request.path)
|
||||||
state_value = payload.get("state") if payload is not None else None
|
raw_state = _query_first(query, "state")
|
||||||
if state_value is None:
|
if raw_state is None:
|
||||||
return _http_error(400, "missing state")
|
return _http_error(400, "missing state")
|
||||||
if not isinstance(state_value, dict):
|
try:
|
||||||
|
decoded = json.loads(raw_state)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return _http_error(400, "state must be JSON")
|
||||||
|
if not isinstance(decoded, dict):
|
||||||
return _http_error(400, "state must be an object")
|
return _http_error(400, "state must be an object")
|
||||||
try:
|
try:
|
||||||
state = write_webui_sidebar_state(cast(dict[str, Any], state_value))
|
state = write_webui_sidebar_state(cast(dict[str, Any], decoded))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return _http_error(400, str(e))
|
return _http_error(400, str(e))
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -1330,10 +1174,16 @@ class GatewayHTTPHandler:
|
|||||||
|
|
||||||
|
|
||||||
def _automation_values_from_request(request: WsRequest) -> dict[str, Any] | None:
|
def _automation_values_from_request(request: WsRequest) -> dict[str, Any] | None:
|
||||||
payload = _mutation_payload(request)
|
raw = _case_insensitive_header(request.headers, _AUTOMATION_VALUES_HEADER)
|
||||||
if payload is None or "values" not in payload:
|
if not raw:
|
||||||
return {}
|
return {}
|
||||||
values = payload.get("values")
|
try:
|
||||||
|
values = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
values = json.loads(unquote(raw))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
return cast(dict[str, Any], values) if isinstance(values, dict) else None
|
return cast(dict[str, Any], values) if isinstance(values, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ dependencies = [
|
|||||||
"readability-lxml>=0.8.4,<1.0.0",
|
"readability-lxml>=0.8.4,<1.0.0",
|
||||||
"lxml-html-clean>=0.4.0,<1.0.0",
|
"lxml-html-clean>=0.4.0,<1.0.0",
|
||||||
"rich>=14.0.0,<15.0.0",
|
"rich>=14.0.0,<15.0.0",
|
||||||
"qrcode[pil]>=8.0",
|
|
||||||
"croniter>=6.0.0,<7.0.0",
|
"croniter>=6.0.0,<7.0.0",
|
||||||
"prompt-toolkit>=3.0.50,<4.0.0",
|
"prompt-toolkit>=3.0.50,<4.0.0",
|
||||||
"questionary>=2.0.0,<3.0.0",
|
"questionary>=2.0.0,<3.0.0",
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import pytest
|
|||||||
|
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.agent.tools.context import RequestContext, request_context
|
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.self import MyTool
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.config.schema import ModelPresetConfig
|
from nanobot.config.schema import ModelPresetConfig
|
||||||
@@ -35,13 +34,6 @@ 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:
|
def test_model_preset_getter_none_when_not_set(tmp_path) -> None:
|
||||||
loop = _make_loop(tmp_path)
|
loop = _make_loop(tmp_path)
|
||||||
assert loop.model_preset is None
|
assert loop.model_preset is None
|
||||||
@@ -248,7 +240,7 @@ def test_self_tool_inspect_shows_model_preset(tmp_path) -> None:
|
|||||||
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||||
}
|
}
|
||||||
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
|
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
|
||||||
tool = _my_tool(loop)
|
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||||
output = tool._inspect_all()
|
output = tool._inspect_all()
|
||||||
assert "model_preset: 'fast'" in output
|
assert "model_preset: 'fast'" in output
|
||||||
|
|
||||||
@@ -258,7 +250,7 @@ def test_self_tool_set_model_preset_via_modify(tmp_path) -> None:
|
|||||||
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||||
}
|
}
|
||||||
loop = _make_loop(tmp_path, presets=presets)
|
loop = _make_loop(tmp_path, presets=presets)
|
||||||
tool = _my_tool(loop)
|
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||||
result = tool._modify("model_preset", "fast")
|
result = tool._modify("model_preset", "fast")
|
||||||
assert "Error" not in result
|
assert "Error" not in result
|
||||||
assert loop.model_preset == "fast"
|
assert loop.model_preset == "fast"
|
||||||
@@ -271,7 +263,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),
|
"fast": ModelPresetConfig(model="openai/gpt-4.1", context_window_tokens=32_768),
|
||||||
}
|
}
|
||||||
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
|
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
|
||||||
tool = _my_tool(loop)
|
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||||
|
|
||||||
result = tool._modify("model_preset", "default")
|
result = tool._modify("model_preset", "default")
|
||||||
|
|
||||||
@@ -288,7 +280,7 @@ def test_self_tool_set_model_preset_unknown_lists_available(tmp_path) -> None:
|
|||||||
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||||
}
|
}
|
||||||
loop = _make_loop(tmp_path, presets=presets)
|
loop = _make_loop(tmp_path, presets=presets)
|
||||||
tool = _my_tool(loop)
|
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||||
|
|
||||||
result = tool._modify("model_preset", "missing")
|
result = tool._modify("model_preset", "missing")
|
||||||
|
|
||||||
@@ -303,7 +295,7 @@ def test_self_tool_sets_model_preset_for_current_session(tmp_path) -> None:
|
|||||||
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||||
}
|
}
|
||||||
loop = _make_loop(tmp_path, presets=presets)
|
loop = _make_loop(tmp_path, presets=presets)
|
||||||
tool = _my_tool(loop)
|
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||||
|
|
||||||
with request_context(RequestContext(
|
with request_context(RequestContext(
|
||||||
channel="cli",
|
channel="cli",
|
||||||
@@ -326,7 +318,7 @@ def test_self_tool_reports_session_preset_provider_configuration_error(tmp_path)
|
|||||||
loop.set_session_model_preset = MagicMock(
|
loop.set_session_model_preset = MagicMock(
|
||||||
side_effect=ValueError("No API key configured for provider 'openai'.")
|
side_effect=ValueError("No API key configured for provider 'openai'.")
|
||||||
)
|
)
|
||||||
tool = _my_tool(loop)
|
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||||
|
|
||||||
with request_context(RequestContext(
|
with request_context(RequestContext(
|
||||||
channel="cli",
|
channel="cli",
|
||||||
@@ -351,7 +343,7 @@ def test_self_tool_rejects_instance_runtime_changes_in_session(
|
|||||||
value: object,
|
value: object,
|
||||||
) -> None:
|
) -> None:
|
||||||
loop = _make_loop(tmp_path)
|
loop = _make_loop(tmp_path)
|
||||||
tool = _my_tool(loop)
|
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||||
session = loop.sessions.get_or_create("cli:one")
|
session = loop.sessions.get_or_create("cli:one")
|
||||||
|
|
||||||
with request_context(RequestContext(
|
with request_context(RequestContext(
|
||||||
@@ -374,7 +366,7 @@ def test_self_tool_set_model_clears_active_preset(tmp_path) -> None:
|
|||||||
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||||
}
|
}
|
||||||
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
|
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
|
||||||
tool = _my_tool(loop)
|
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||||
result = tool._modify("model", "anthropic/claude-opus-4-5")
|
result = tool._modify("model", "anthropic/claude-opus-4-5")
|
||||||
assert "Error" not in result
|
assert "Error" not in result
|
||||||
assert loop.model_preset is None
|
assert loop.model_preset is None
|
||||||
|
|||||||
@@ -1,225 +0,0 @@
|
|||||||
"""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,12 +8,10 @@ from types import MappingProxyType
|
|||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from nanobot.agent.tools.context import RequestContext, request_context
|
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.self import MyTool
|
||||||
from nanobot.agent.tools.shell import ExecToolConfig
|
|
||||||
from nanobot.agent.tools.web import WebSearchConfig, WebToolsConfig
|
|
||||||
from nanobot.config.schema import ModelPresetConfig
|
from nanobot.config.schema import ModelPresetConfig
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -29,16 +27,13 @@ def _make_mock_loop(**overrides):
|
|||||||
loop.workspace = Path("/tmp/workspace")
|
loop.workspace = Path("/tmp/workspace")
|
||||||
loop.restrict_to_workspace = False
|
loop.restrict_to_workspace = False
|
||||||
loop._start_time = 1000.0
|
loop._start_time = 1000.0
|
||||||
loop.exec_config = ExecToolConfig()
|
loop.exec_config = MagicMock()
|
||||||
loop.channels_config = MagicMock()
|
loop.channels_config = MagicMock()
|
||||||
loop._last_usage = {"prompt_tokens": 100, "completion_tokens": 50}
|
loop._last_usage = {"prompt_tokens": 100, "completion_tokens": 50}
|
||||||
loop.last_usage = loop._last_usage
|
loop._runtime_vars = {}
|
||||||
loop._current_iteration = 0
|
loop._current_iteration = 0
|
||||||
loop.current_iteration = loop._current_iteration
|
|
||||||
loop.provider_retry_mode = "standard"
|
loop.provider_retry_mode = "standard"
|
||||||
loop.max_tool_result_chars = 16000
|
loop.max_tool_result_chars = 16000
|
||||||
loop.model_preset = None
|
|
||||||
loop.model_presets = {}
|
|
||||||
loop._concurrency_gate = None
|
loop._concurrency_gate = None
|
||||||
loop._unified_session = False
|
loop._unified_session = False
|
||||||
loop._extra_hooks = []
|
loop._extra_hooks = []
|
||||||
@@ -50,7 +45,9 @@ def _make_mock_loop(**overrides):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# web_config mock — needed for check tests
|
# web_config mock — needed for check tests
|
||||||
loop.web_config = WebToolsConfig()
|
loop.web_config = MagicMock()
|
||||||
|
loop.web_config.enable = True
|
||||||
|
loop.web_config.search = MagicMock()
|
||||||
loop.web_config.search.api_key = "sk-secret-key-12345"
|
loop.web_config.search.api_key = "sk-secret-key-12345"
|
||||||
|
|
||||||
# Tools registry mock
|
# Tools registry mock
|
||||||
@@ -58,13 +55,10 @@ def _make_mock_loop(**overrides):
|
|||||||
loop.tools.tool_names = ["read_file", "write_file", "exec", "web_search", "self"]
|
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.has.side_effect = lambda n: n in loop.tools.tool_names
|
||||||
loop.tools.get.return_value = None
|
loop.tools.get.return_value = None
|
||||||
loop.tool_names = loop.tools.tool_names
|
|
||||||
|
|
||||||
# SubagentManager mock
|
# SubagentManager mock
|
||||||
loop.subagents = MagicMock()
|
loop.subagents = MagicMock()
|
||||||
loop.subagents._running_tasks = {"abc123": MagicMock(done=MagicMock(return_value=False))}
|
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)
|
loop.subagents.get_running_count = MagicMock(return_value=1)
|
||||||
|
|
||||||
for k, v in overrides.items():
|
for k, v in overrides.items():
|
||||||
@@ -73,10 +67,10 @@ def _make_mock_loop(**overrides):
|
|||||||
return loop
|
return loop
|
||||||
|
|
||||||
|
|
||||||
def _make_tool(loop=None):
|
def _make_tool(runtime_state=None):
|
||||||
if loop is None:
|
if runtime_state is None:
|
||||||
loop = _make_mock_loop()
|
runtime_state = _make_mock_loop()
|
||||||
return MyTool(runtime_control=AgentRuntimeControl(loop))
|
return MyTool(runtime_state=runtime_state)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -93,10 +87,10 @@ class TestInspectSummary:
|
|||||||
assert "context_window_tokens: 65536" in result
|
assert "context_window_tokens: 65536" in result
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_inspect_includes_scratchpad(self):
|
async def test_inspect_includes_runtime_vars(self):
|
||||||
loop = _make_mock_loop()
|
loop = _make_mock_loop()
|
||||||
tool = _make_tool(loop=loop)
|
loop._runtime_vars = {"task": "review"}
|
||||||
tool._runtime_control.set_scratchpad("task", "review", max_keys=64)
|
tool = _make_tool(runtime_state=loop)
|
||||||
result = await tool.execute(action="check")
|
result = await tool.execute(action="check")
|
||||||
assert "task" in result
|
assert "task" in result
|
||||||
|
|
||||||
@@ -156,7 +150,9 @@ class TestInspectPathNavigation:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_inspect_config_subfield(self):
|
async def test_inspect_config_subfield(self):
|
||||||
loop = _make_mock_loop()
|
loop = _make_mock_loop()
|
||||||
tool = _make_tool(loop=loop)
|
loop.web_config = MagicMock()
|
||||||
|
loop.web_config.enable = True
|
||||||
|
tool = _make_tool(runtime_state=loop)
|
||||||
result = await tool.execute(action="check", key="web_config.enable")
|
result = await tool.execute(action="check", key="web_config.enable")
|
||||||
assert "True" in result
|
assert "True" in result
|
||||||
|
|
||||||
@@ -164,7 +160,7 @@ class TestInspectPathNavigation:
|
|||||||
async def test_inspect_dict_key_via_dotpath(self):
|
async def test_inspect_dict_key_via_dotpath(self):
|
||||||
loop = _make_mock_loop()
|
loop = _make_mock_loop()
|
||||||
loop._last_usage = {"prompt_tokens": 100, "completion_tokens": 50}
|
loop._last_usage = {"prompt_tokens": 100, "completion_tokens": 50}
|
||||||
tool = _make_tool(loop=loop)
|
tool = _make_tool(runtime_state=loop)
|
||||||
result = await tool.execute(action="check", key="_last_usage.prompt_tokens")
|
result = await tool.execute(action="check", key="_last_usage.prompt_tokens")
|
||||||
assert "100" in result
|
assert "100" in result
|
||||||
|
|
||||||
@@ -183,16 +179,20 @@ class TestInspectPathNavigation:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_inspect_nested_config_redacts_sensitive_scalar_fields(self):
|
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 = _make_mock_loop()
|
||||||
loop.web_config.search = WebSearchConfig(
|
loop.web_config = MagicMock()
|
||||||
provider="tavily",
|
loop.web_config.search = SearchConfig()
|
||||||
api_key="sk-test-secret",
|
|
||||||
)
|
|
||||||
tool = _make_tool(loop)
|
tool = _make_tool(loop)
|
||||||
|
|
||||||
result = await tool.execute(action="check", key="web_config.search")
|
result = await tool.execute(action="check", key="web_config.search")
|
||||||
|
|
||||||
assert "tavily" in result
|
assert "provider='tavily'" in result
|
||||||
assert "sk-test-secret" not in result
|
assert "sk-test-secret" not in result
|
||||||
assert "api_key" not in result.lower()
|
assert "api_key" not in result.lower()
|
||||||
|
|
||||||
@@ -209,14 +209,14 @@ class TestModifyRestricted:
|
|||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="max_iterations", value=80)
|
result = await tool.execute(action="set", key="max_iterations", value=80)
|
||||||
assert "Set max_iterations = 80" in result
|
assert "Set max_iterations = 80" in result
|
||||||
assert tool._runtime_control.snapshot().max_iterations == 80
|
assert tool._runtime_state.max_iterations == 80
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_restricted_out_of_range(self):
|
async def test_modify_restricted_out_of_range(self):
|
||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="max_iterations", value=0)
|
result = await tool.execute(action="set", key="max_iterations", value=0)
|
||||||
assert "Error" in result
|
assert "Error" in result
|
||||||
assert tool._runtime_control.snapshot().max_iterations == 40
|
assert tool._runtime_state.max_iterations == 40
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_restricted_max_exceeded(self):
|
async def test_modify_restricted_max_exceeded(self):
|
||||||
@@ -241,12 +241,12 @@ class TestModifyRestricted:
|
|||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="max_iterations", value="80")
|
result = await tool.execute(action="set", key="max_iterations", value="80")
|
||||||
assert "Set max_iterations" in result
|
assert "Set max_iterations" in result
|
||||||
assert tool._runtime_control.snapshot().max_iterations == 80
|
assert tool._runtime_state.max_iterations == 80
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_context_window_valid(self):
|
async def test_modify_context_window_valid(self):
|
||||||
loop = _make_mock_loop()
|
loop = _make_mock_loop()
|
||||||
tool = _make_tool(loop=loop)
|
tool = _make_tool(runtime_state=loop)
|
||||||
result = await tool.execute(action="set", key="context_window_tokens", value=131072)
|
result = await tool.execute(action="set", key="context_window_tokens", value=131072)
|
||||||
assert "Set context_window_tokens" in result
|
assert "Set context_window_tokens" in result
|
||||||
assert loop.context_window_tokens == 131072
|
assert loop.context_window_tokens == 131072
|
||||||
@@ -324,15 +324,15 @@ class TestModifyFree:
|
|||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="provider_retry_mode", value="persistent")
|
result = await tool.execute(action="set", key="provider_retry_mode", value="persistent")
|
||||||
assert "Set provider_retry_mode" in result
|
assert "Set provider_retry_mode" in result
|
||||||
assert tool._runtime_control.snapshot().provider_retry_mode == "persistent"
|
assert tool._runtime_state.provider_retry_mode == "persistent"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_new_key_stores_in_scratchpad(self):
|
async def test_modify_new_key_stores_in_runtime_vars(self):
|
||||||
"""Modifying an unknown key should store it in the scratchpad."""
|
"""Modifying a non-existing attribute should store in _runtime_vars."""
|
||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="my_custom_var", value="hello")
|
result = await tool.execute(action="set", key="my_custom_var", value="hello")
|
||||||
assert "my_custom_var" in result
|
assert "my_custom_var" in result
|
||||||
assert tool._runtime_control.snapshot().scratchpad["my_custom_var"] == "hello"
|
assert tool._runtime_state._runtime_vars["my_custom_var"] == "hello"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_rejects_callable(self):
|
async def test_modify_rejects_callable(self):
|
||||||
@@ -351,14 +351,14 @@ class TestModifyFree:
|
|||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="items", value=[1, 2, 3])
|
result = await tool.execute(action="set", key="items", value=[1, 2, 3])
|
||||||
assert result == "Set scratchpad.items = [1, 2, 3]"
|
assert result == "Set scratchpad.items = [1, 2, 3]"
|
||||||
assert tool._runtime_control.snapshot().scratchpad["items"] == [1, 2, 3]
|
assert tool._runtime_state._runtime_vars["items"] == [1, 2, 3]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_allows_dict(self):
|
async def test_modify_allows_dict(self):
|
||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="data", value={"a": 1})
|
result = await tool.execute(action="set", key="data", value={"a": 1})
|
||||||
assert result == "Set scratchpad.data = {'a': 1}"
|
assert result == "Set scratchpad.data = {'a': 1}"
|
||||||
assert tool._runtime_control.snapshot().scratchpad["data"] == {"a": 1}
|
assert tool._runtime_state._runtime_vars["data"] == {"a": 1}
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_whitespace_key_rejected(self):
|
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)
|
result = await tool.execute(action="set", key="provider_retry_mode", value=42)
|
||||||
assert "Error" in result
|
assert "Error" in result
|
||||||
assert "str" in result
|
assert "str" in result
|
||||||
assert tool._runtime_control.snapshot().provider_retry_mode == "standard"
|
assert tool._runtime_state.provider_retry_mode == "standard"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_existing_int_attr_wrong_type_rejected(self):
|
async def test_modify_existing_int_attr_wrong_type_rejected(self):
|
||||||
@@ -404,7 +404,7 @@ class TestModifyFree:
|
|||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="max_tool_result_chars", value="big")
|
result = await tool.execute(action="set", key="max_tool_result_chars", value="big")
|
||||||
assert "Error" in result
|
assert "Error" in result
|
||||||
assert tool._runtime_control.snapshot().max_tool_result_chars == 16000
|
assert tool._runtime_state.max_tool_result_chars == 16000
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -486,12 +486,11 @@ class TestModifyOpen:
|
|||||||
assert "protected" in result
|
assert "protected" in result
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_workspace_preserves_display_compatibility(self):
|
async def test_modify_workspace_allowed(self):
|
||||||
"""The compatibility value is isolated from filesystem security boundaries."""
|
"""workspace was READONLY in v1, now freely modifiable."""
|
||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="workspace", value="/new/path")
|
result = await tool.execute(action="set", key="workspace", value="/new/path")
|
||||||
assert "Set workspace" in result
|
assert "Set workspace" in result
|
||||||
assert tool._runtime_control.snapshot().workspace == "/new/path"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_mcp_servers_blocked(self):
|
async def test_modify_mcp_servers_blocked(self):
|
||||||
@@ -585,28 +584,28 @@ class TestUnknownAction:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# scratchpad limits
|
# runtime_vars limits (from code review)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestScratchpadLimits:
|
class TestRuntimeVarsLimits:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_scratchpad_rejects_at_max_keys(self):
|
async def test_runtime_vars_rejects_at_max_keys(self):
|
||||||
tool = _make_tool()
|
loop = _make_mock_loop()
|
||||||
for i in range(64):
|
loop._runtime_vars = {f"key_{i}": i for i in range(64)}
|
||||||
tool._runtime_control.set_scratchpad(f"key_{i}", i, max_keys=64)
|
tool = _make_tool(runtime_state=loop)
|
||||||
result = await tool.execute(action="set", key="overflow", value="data")
|
result = await tool.execute(action="set", key="overflow", value="data")
|
||||||
assert "full" in result
|
assert "full" in result
|
||||||
assert "overflow" not in tool._runtime_control.snapshot().scratchpad
|
assert "overflow" not in loop._runtime_vars
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_scratchpad_allows_update_existing_key_at_max(self):
|
async def test_runtime_vars_allows_update_existing_key_at_max(self):
|
||||||
tool = _make_tool()
|
loop = _make_mock_loop()
|
||||||
for i in range(64):
|
loop._runtime_vars = {f"key_{i}": i for i in range(64)}
|
||||||
tool._runtime_control.set_scratchpad(f"key_{i}", i, max_keys=64)
|
tool = _make_tool(runtime_state=loop)
|
||||||
result = await tool.execute(action="set", key="key_0", value="updated")
|
result = await tool.execute(action="set", key="key_0", value="updated")
|
||||||
assert "Error" not in result
|
assert "Error" not in result
|
||||||
assert tool._runtime_control.snapshot().scratchpad["key_0"] == "updated"
|
assert loop._runtime_vars["key_0"] == "updated"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -845,7 +844,7 @@ class TestInspectTaskStatuses:
|
|||||||
usage={"prompt_tokens": 500, "completion_tokens": 100},
|
usage={"prompt_tokens": 500, "completion_tokens": 100},
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
tool = _make_tool(loop=loop)
|
tool = _make_tool(runtime_state=loop)
|
||||||
result = await tool.execute(action="check", key="subagents._task_statuses")
|
result = await tool.execute(action="check", key="subagents._task_statuses")
|
||||||
assert "abc12345" in result
|
assert "abc12345" in result
|
||||||
assert "read logs" in result
|
assert "read logs" in result
|
||||||
@@ -866,7 +865,7 @@ class TestInspectTaskStatuses:
|
|||||||
stop_reason="completed",
|
stop_reason="completed",
|
||||||
)
|
)
|
||||||
loop.subagents._task_statuses = {"xyz": status}
|
loop.subagents._task_statuses = {"xyz": status}
|
||||||
tool = _make_tool(loop=loop)
|
tool = _make_tool(runtime_state=loop)
|
||||||
result = await tool.execute(action="check", key="subagents._task_statuses.xyz")
|
result = await tool.execute(action="check", key="subagents._task_statuses.xyz")
|
||||||
assert "search code" in result
|
assert "search code" in result
|
||||||
assert "completed" in result
|
assert "completed" in result
|
||||||
@@ -880,10 +879,7 @@ class TestReadOnlyMode:
|
|||||||
|
|
||||||
def _make_readonly_tool(self):
|
def _make_readonly_tool(self):
|
||||||
loop = _make_mock_loop()
|
loop = _make_mock_loop()
|
||||||
return MyTool(
|
return MyTool(runtime_state=loop, modify_allowed=False)
|
||||||
runtime_control=AgentRuntimeControl(loop),
|
|
||||||
modify_allowed=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_inspect_allowed_in_readonly(self):
|
async def test_inspect_allowed_in_readonly(self):
|
||||||
@@ -908,13 +904,13 @@ class TestReadOnlyMode:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# scratchpad inspection
|
# runtime vars check fallback (Fix #1: cross-turn memory)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestScratchpadInspection:
|
class TestRuntimeVarsInspectFallback:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_inspect_scratchpad_value_after_modify(self):
|
async def test_inspect_runtime_var_after_modify(self):
|
||||||
"""Design doc scenario: set then check should return the value."""
|
"""Design doc scenario: set then check should return the value."""
|
||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
await tool.execute(action="set", key="user_prefers_concise", value=True)
|
await tool.execute(action="set", key="user_prefers_concise", value=True)
|
||||||
@@ -922,14 +918,14 @@ class TestScratchpadInspection:
|
|||||||
assert "True" in result
|
assert "True" in result
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_inspect_scratchpad_string(self):
|
async def test_inspect_runtime_var_string(self):
|
||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
await tool.execute(action="set", key="current_project", value="nanobot")
|
await tool.execute(action="set", key="current_project", value="nanobot")
|
||||||
result = await tool.execute(action="check", key="current_project")
|
result = await tool.execute(action="check", key="current_project")
|
||||||
assert "nanobot" in result
|
assert "nanobot" in result
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_inspect_scratchpad_dict(self):
|
async def test_inspect_runtime_var_dict(self):
|
||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
await tool.execute(action="set", key="task_meta", value={"step": 2, "total": 5})
|
await tool.execute(action="set", key="task_meta", value={"step": 2, "total": 5})
|
||||||
result = await tool.execute(action="check", key="task_meta")
|
result = await tool.execute(action="check", key="task_meta")
|
||||||
@@ -962,7 +958,7 @@ class TestSensitiveSubFieldBlocking:
|
|||||||
loop = _make_mock_loop()
|
loop = _make_mock_loop()
|
||||||
loop.some_config = MagicMock()
|
loop.some_config = MagicMock()
|
||||||
loop.some_config.password = "hunter2"
|
loop.some_config.password = "hunter2"
|
||||||
tool = _make_tool(loop=loop)
|
tool = _make_tool(runtime_state=loop)
|
||||||
result = await tool.execute(action="check", key="some_config.password")
|
result = await tool.execute(action="check", key="some_config.password")
|
||||||
assert "not accessible" in result
|
assert "not accessible" in result
|
||||||
|
|
||||||
@@ -971,7 +967,7 @@ class TestSensitiveSubFieldBlocking:
|
|||||||
loop = _make_mock_loop()
|
loop = _make_mock_loop()
|
||||||
loop.vault = MagicMock()
|
loop.vault = MagicMock()
|
||||||
loop.vault.secret = "classified"
|
loop.vault.secret = "classified"
|
||||||
tool = _make_tool(loop=loop)
|
tool = _make_tool(runtime_state=loop)
|
||||||
result = await tool.execute(action="check", key="vault.secret")
|
result = await tool.execute(action="check", key="vault.secret")
|
||||||
assert "not accessible" in result
|
assert "not accessible" in result
|
||||||
|
|
||||||
@@ -980,7 +976,7 @@ class TestSensitiveSubFieldBlocking:
|
|||||||
loop = _make_mock_loop()
|
loop = _make_mock_loop()
|
||||||
loop.auth_data = MagicMock()
|
loop.auth_data = MagicMock()
|
||||||
loop.auth_data.token = "jwt-payload"
|
loop.auth_data.token = "jwt-payload"
|
||||||
tool = _make_tool(loop=loop)
|
tool = _make_tool(runtime_state=loop)
|
||||||
result = await tool.execute(action="check", key="auth_data.token")
|
result = await tool.execute(action="check", key="auth_data.token")
|
||||||
assert "not accessible" in result
|
assert "not accessible" in result
|
||||||
|
|
||||||
@@ -996,7 +992,7 @@ class TestSensitiveSubFieldBlocking:
|
|||||||
async def test_modify_password_blocked(self):
|
async def test_modify_password_blocked(self):
|
||||||
loop = _make_mock_loop()
|
loop = _make_mock_loop()
|
||||||
loop.some_config = MagicMock()
|
loop.some_config = MagicMock()
|
||||||
tool = _make_tool(loop=loop)
|
tool = _make_tool(runtime_state=loop)
|
||||||
result = await tool.execute(action="set", key="some_config.password", value="evil")
|
result = await tool.execute(action="set", key="some_config.password", value="evil")
|
||||||
assert "not accessible" in result
|
assert "not accessible" in result
|
||||||
|
|
||||||
@@ -1087,8 +1083,8 @@ class TestSecurityAttributeProtection:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_model_presets_dotpath_blocked(self):
|
async def test_modify_model_presets_dotpath_blocked(self):
|
||||||
"""The config-derived model preset catalog is inspectable but not mutable."""
|
"""The config-derived model preset catalog is inspectable but not mutable."""
|
||||||
presets = {"fast": ModelPresetConfig(model="fast-model")}
|
presets = {"fast": {"model": "fast-model"}}
|
||||||
tool = _make_tool(loop=_make_mock_loop(model_presets=presets))
|
tool = _make_tool(runtime_state=_make_mock_loop(model_presets=presets))
|
||||||
|
|
||||||
result = await tool.execute(
|
result = await tool.execute(
|
||||||
action="set",
|
action="set",
|
||||||
@@ -1097,14 +1093,14 @@ class TestSecurityAttributeProtection:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert "read-only" in result
|
assert "read-only" in result
|
||||||
assert presets == {"fast": ModelPresetConfig(model="fast-model")}
|
assert presets == {"fast": {"model": "fast-model"}}
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_inspect_read_only_model_preset_dotpath(self):
|
async def test_inspect_read_only_model_preset_dotpath(self):
|
||||||
presets = MappingProxyType({
|
presets = MappingProxyType({
|
||||||
"fast": ModelPresetConfig(model="fast-model"),
|
"fast": ModelPresetConfig(model="fast-model"),
|
||||||
})
|
})
|
||||||
tool = _make_tool(loop=_make_mock_loop(model_presets=presets))
|
tool = _make_tool(runtime_state=_make_mock_loop(model_presets=presets))
|
||||||
|
|
||||||
result = await tool.execute(action="check", key="model_presets.fast.model")
|
result = await tool.execute(action="check", key="model_presets.fast.model")
|
||||||
|
|
||||||
@@ -1154,8 +1150,7 @@ class TestLastUsageInSummary:
|
|||||||
async def test_last_usage_not_shown_when_empty(self):
|
async def test_last_usage_not_shown_when_empty(self):
|
||||||
loop = _make_mock_loop()
|
loop = _make_mock_loop()
|
||||||
loop._last_usage = {}
|
loop._last_usage = {}
|
||||||
loop.last_usage = loop._last_usage
|
tool = _make_tool(runtime_state=loop)
|
||||||
tool = _make_tool(loop=loop)
|
|
||||||
result = await tool.execute(action="check")
|
result = await tool.execute(action="check")
|
||||||
assert "_last_usage" not in result
|
assert "_last_usage" not in result
|
||||||
|
|
||||||
|
|||||||
@@ -4,23 +4,23 @@ from unittest.mock import MagicMock
|
|||||||
|
|
||||||
import pytest
|
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.agent.tools.self import MyTool
|
||||||
from nanobot.bus.queue import MessageBus
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_my_tool_max_iterations_syncs_subagent_limit(tmp_path) -> None:
|
async def test_my_tool_max_iterations_syncs_subagent_limit() -> None:
|
||||||
provider = MagicMock()
|
loop = MagicMock()
|
||||||
provider.get_default_model.return_value = "test-model"
|
loop.max_iterations = 40
|
||||||
loop = AgentLoop(
|
loop._runtime_vars = {}
|
||||||
bus=MessageBus(),
|
loop.subagents = MagicMock()
|
||||||
provider=provider,
|
loop.subagents.max_iterations = loop.max_iterations
|
||||||
workspace=tmp_path,
|
|
||||||
max_iterations=40,
|
def _sync_subagent_runtime_limits() -> None:
|
||||||
)
|
loop.subagents.max_iterations = loop.max_iterations
|
||||||
tool = MyTool(runtime_control=AgentRuntimeControl(loop))
|
|
||||||
|
loop._sync_subagent_runtime_limits = _sync_subagent_runtime_limits
|
||||||
|
|
||||||
|
tool = MyTool(runtime_state=loop)
|
||||||
|
|
||||||
result = await tool.execute(action="set", key="max_iterations", value=80)
|
result = await tool.execute(action="set", key="max_iterations", value=80)
|
||||||
|
|
||||||
|
|||||||
@@ -651,7 +651,6 @@ def test_plugin_setup_contract_drives_save_and_validation(
|
|||||||
from nanobot.channels.validation import validate_channel_config
|
from nanobot.channels.validation import validate_channel_config
|
||||||
from nanobot.config import loader
|
from nanobot.config import loader
|
||||||
from nanobot.webui.settings_routes import WebUISettingsRouter
|
from nanobot.webui.settings_routes import WebUISettingsRouter
|
||||||
from nanobot.webui.settings_services import WebUISettingsServices
|
|
||||||
|
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
save_config(Config(), config_path)
|
save_config(Config(), config_path)
|
||||||
@@ -661,7 +660,6 @@ def test_plugin_setup_contract_drives_save_and_validation(
|
|||||||
_channel_plugin(_SetupPlugin, setup=_SETUP_PLUGIN_SPEC),
|
_channel_plugin(_SetupPlugin, setup=_SETUP_PLUGIN_SPEC),
|
||||||
)
|
)
|
||||||
router = object.__new__(WebUISettingsRouter)
|
router = object.__new__(WebUISettingsRouter)
|
||||||
router.settings = WebUISettingsServices.create(config_path)
|
|
||||||
|
|
||||||
saved = router._save_channel_config_values(
|
saved = router._save_channel_config_values(
|
||||||
"setupplugin",
|
"setupplugin",
|
||||||
@@ -740,7 +738,6 @@ def test_webui_save_rejects_duplicate_feishu_ids_without_writing(monkeypatch, tm
|
|||||||
from nanobot.config import loader
|
from nanobot.config import loader
|
||||||
from nanobot.webui.settings_api import WebUISettingsError
|
from nanobot.webui.settings_api import WebUISettingsError
|
||||||
from nanobot.webui.settings_routes import WebUISettingsRouter
|
from nanobot.webui.settings_routes import WebUISettingsRouter
|
||||||
from nanobot.webui.settings_services import WebUISettingsServices
|
|
||||||
|
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
@@ -759,7 +756,6 @@ def test_webui_save_rejects_duplicate_feishu_ids_without_writing(monkeypatch, tm
|
|||||||
before = config_path.read_text(encoding="utf-8")
|
before = config_path.read_text(encoding="utf-8")
|
||||||
monkeypatch.setattr(loader, "_current_config_path", config_path)
|
monkeypatch.setattr(loader, "_current_config_path", config_path)
|
||||||
router = object.__new__(WebUISettingsRouter)
|
router = object.__new__(WebUISettingsRouter)
|
||||||
router.settings = WebUISettingsServices.create(config_path)
|
|
||||||
|
|
||||||
with pytest.raises(WebUISettingsError, match="duplicate Feishu instance id 'default'") as error:
|
with pytest.raises(WebUISettingsError, match="duplicate Feishu instance id 'default'") as error:
|
||||||
router._save_channel_config_values(
|
router._save_channel_config_values(
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ def test_build_responses_body_strips_github_copilot_prefix():
|
|||||||
provider_context=ProviderCallContext(context_window_tokens=128_000),
|
provider_context=ProviderCallContext(context_window_tokens=128_000),
|
||||||
)
|
)
|
||||||
assert body["model"] == "gpt-5.4-mini"
|
assert body["model"] == "gpt-5.4-mini"
|
||||||
|
assert body["include"] == ["reasoning.encrypted_content"]
|
||||||
assert "context_management" not in body
|
assert "context_management" not in body
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ from nanobot.providers.openai_compat_provider import (
|
|||||||
OpenAICompatProvider,
|
OpenAICompatProvider,
|
||||||
)
|
)
|
||||||
from nanobot.providers.openai_responses.state import build_responses_state
|
from nanobot.providers.openai_responses.state import build_responses_state
|
||||||
|
from nanobot.providers.registry import (
|
||||||
|
ProviderSpec,
|
||||||
|
ResponsesCapabilities,
|
||||||
|
find_by_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
@@ -18,7 +23,7 @@ def provider():
|
|||||||
"""A direct-OpenAI provider with Responses API support."""
|
"""A direct-OpenAI provider with Responses API support."""
|
||||||
p = OpenAICompatProvider.__new__(OpenAICompatProvider)
|
p = OpenAICompatProvider.__new__(OpenAICompatProvider)
|
||||||
p.default_model = "gpt-5"
|
p.default_model = "gpt-5"
|
||||||
p._spec = type("Spec", (), {"name": "openai"})()
|
p._spec = find_by_name("openai")
|
||||||
p._effective_base = "https://api.openai.com/v1"
|
p._effective_base = "https://api.openai.com/v1"
|
||||||
p._api_type = "auto"
|
p._api_type = "auto"
|
||||||
p._responses_failures = {}
|
p._responses_failures = {}
|
||||||
@@ -31,12 +36,7 @@ def test_responses_api_available_by_default(provider):
|
|||||||
|
|
||||||
|
|
||||||
def test_deepseek_v4_flash_uses_responses_by_model(provider):
|
def test_deepseek_v4_flash_uses_responses_by_model(provider):
|
||||||
provider._spec = type("Spec", (), {
|
provider._spec = find_by_name("deepseek")
|
||||||
"name": "deepseek",
|
|
||||||
"responses_models": ("deepseek-v4-flash",),
|
|
||||||
"strip_model_prefix": False,
|
|
||||||
"strip_model_prefixes": (),
|
|
||||||
})()
|
|
||||||
provider._effective_base = "https://api.deepseek.com"
|
provider._effective_base = "https://api.deepseek.com"
|
||||||
provider.default_model = "deepseek-v4-flash"
|
provider.default_model = "deepseek-v4-flash"
|
||||||
|
|
||||||
@@ -45,17 +45,51 @@ def test_deepseek_v4_flash_uses_responses_by_model(provider):
|
|||||||
|
|
||||||
|
|
||||||
def test_deepseek_v4_flash_matches_provider_prefixed_model(provider):
|
def test_deepseek_v4_flash_matches_provider_prefixed_model(provider):
|
||||||
provider._spec = type("Spec", (), {
|
provider._spec = find_by_name("deepseek")
|
||||||
"name": "deepseek",
|
|
||||||
"responses_models": ("deepseek-v4-flash",),
|
|
||||||
"strip_model_prefix": False,
|
|
||||||
"strip_model_prefixes": (),
|
|
||||||
})()
|
|
||||||
provider._effective_base = "https://api.deepseek.com"
|
provider._effective_base = "https://api.deepseek.com"
|
||||||
|
|
||||||
assert provider._should_use_responses_api("deepseek/deepseek-v4-flash", None) is True
|
assert provider._should_use_responses_api("deepseek/deepseek-v4-flash", None) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_responses_behavior_is_declared_by_capabilities(provider):
|
||||||
|
provider._spec = ProviderSpec(
|
||||||
|
name="example",
|
||||||
|
keywords=("example",),
|
||||||
|
env_key="EXAMPLE_API_KEY",
|
||||||
|
responses=ResponsesCapabilities(
|
||||||
|
models=("example-o3",),
|
||||||
|
reasoning_replay="plaintext",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
provider._effective_base = "https://example.test"
|
||||||
|
|
||||||
|
assert provider._should_use_responses_api("example-o3", None) is True
|
||||||
|
|
||||||
|
body = provider._build_responses_body(
|
||||||
|
messages=[
|
||||||
|
{"role": "user", "content": "question"},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"reasoning_content": "think first",
|
||||||
|
"content": "answer",
|
||||||
|
},
|
||||||
|
{"role": "user", "content": "follow-up"},
|
||||||
|
],
|
||||||
|
tools=None,
|
||||||
|
model="example-o3",
|
||||||
|
max_tokens=100,
|
||||||
|
temperature=0.1,
|
||||||
|
reasoning_effort="high",
|
||||||
|
tool_choice=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert {
|
||||||
|
"type": "reasoning",
|
||||||
|
"content": [{"type": "output_text", "text": "think first"}],
|
||||||
|
} in body["input"]
|
||||||
|
assert "include" not in body
|
||||||
|
|
||||||
|
|
||||||
def test_direct_openai_enables_server_compaction(provider):
|
def test_direct_openai_enables_server_compaction(provider):
|
||||||
provider._extra_body = {}
|
provider._extra_body = {}
|
||||||
|
|
||||||
@@ -74,6 +108,7 @@ def test_direct_openai_enables_server_compaction(provider):
|
|||||||
"type": "compaction",
|
"type": "compaction",
|
||||||
"compact_threshold": 70_000,
|
"compact_threshold": 70_000,
|
||||||
}]
|
}]
|
||||||
|
assert body["include"] == ["reasoning.encrypted_content"]
|
||||||
|
|
||||||
|
|
||||||
def test_api_type_chat_completions_disables_responses(provider):
|
def test_api_type_chat_completions_disables_responses(provider):
|
||||||
@@ -97,7 +132,7 @@ def test_api_type_responses_ignores_circuit_breaker(provider):
|
|||||||
|
|
||||||
|
|
||||||
def test_api_type_responses_does_not_force_non_openai(provider):
|
def test_api_type_responses_does_not_force_non_openai(provider):
|
||||||
provider._spec = type("Spec", (), {"name": "custom"})()
|
provider._spec = find_by_name("custom")
|
||||||
provider._api_type = "responses"
|
provider._api_type = "responses"
|
||||||
|
|
||||||
assert provider._should_use_responses_api("gpt-4o", None) is False
|
assert provider._should_use_responses_api("gpt-4o", None) is False
|
||||||
@@ -192,12 +227,15 @@ def test_legacy_compatibility_markers_still_trigger_fallback():
|
|||||||
|
|
||||||
|
|
||||||
def _deepseek_provider(provider):
|
def _deepseek_provider(provider):
|
||||||
provider._spec = type("Spec", (), {
|
provider._spec = ProviderSpec(
|
||||||
"name": "deepseek",
|
name="deepseek",
|
||||||
"responses_models": ("deepseek-v4-flash",),
|
keywords=("deepseek",),
|
||||||
"strip_model_prefix": False,
|
env_key="DEEPSEEK_API_KEY",
|
||||||
"strip_model_prefixes": (),
|
responses=ResponsesCapabilities(
|
||||||
})()
|
models=("deepseek-v4-flash",),
|
||||||
|
reasoning_replay="plaintext",
|
||||||
|
),
|
||||||
|
)
|
||||||
provider._effective_base = "https://api.deepseek.com"
|
provider._effective_base = "https://api.deepseek.com"
|
||||||
provider.default_model = "deepseek-v4-flash"
|
provider.default_model = "deepseek-v4-flash"
|
||||||
provider._extra_body = {}
|
provider._extra_body = {}
|
||||||
|
|||||||
@@ -5,13 +5,10 @@ import asyncio
|
|||||||
import socket
|
import socket
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pytest
|
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.mcp import _probe_http_url, connect_mcp_servers
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.config.schema import MCPServerConfig
|
|
||||||
from nanobot.security.network import configure_ssrf_whitelist
|
from nanobot.security.network import configure_ssrf_whitelist
|
||||||
|
|
||||||
_PROXY_ENV_VARS = ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy")
|
_PROXY_ENV_VARS = ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy")
|
||||||
@@ -174,58 +171,6 @@ async def test_connect_skips_unreachable_sse():
|
|||||||
assert len(registry._tools) == 0
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_probe_not_called_for_stdio():
|
async def test_probe_not_called_for_stdio():
|
||||||
"""stdio transport should not be probed — it spawns a local process."""
|
"""stdio transport should not be probed — it spawns a local process."""
|
||||||
|
|||||||
@@ -1082,22 +1082,10 @@ async def test_connect_mcp_servers_http_clients_reject_unsafe_redirect_targets(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize("failure_mode", ["exception", "cancellation"])
|
|
||||||
async def test_connect_mcp_servers_one_failure_does_not_block_others(
|
async def test_connect_mcp_servers_one_failure_does_not_block_others(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
failure_mode: str,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
bad_session = _make_fake_session([])
|
sessions = {"good": _make_fake_session(["demo"])}
|
||||||
|
|
||||||
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:
|
class _SelectiveClientSession:
|
||||||
def __init__(self, read: object, _write: object) -> None:
|
def __init__(self, read: object, _write: object) -> None:
|
||||||
@@ -1111,7 +1099,7 @@ async def test_connect_mcp_servers_one_failure_does_not_block_others(
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def _selective_stdio_client(params: object):
|
async def _selective_stdio_client(params: object):
|
||||||
if params.command == "bad" and failure_mode == "exception":
|
if params.command == "bad":
|
||||||
raise RuntimeError("boom")
|
raise RuntimeError("boom")
|
||||||
yield params.command, object()
|
yield params.command, object()
|
||||||
|
|
||||||
@@ -1121,8 +1109,8 @@ async def test_connect_mcp_servers_one_failure_does_not_block_others(
|
|||||||
registry = ToolRegistry()
|
registry = ToolRegistry()
|
||||||
stacks = await connect_mcp_servers(
|
stacks = await connect_mcp_servers(
|
||||||
{
|
{
|
||||||
"bad": MCPServerConfig(command="bad"),
|
|
||||||
"good": MCPServerConfig(command="good"),
|
"good": MCPServerConfig(command="good"),
|
||||||
|
"bad": MCPServerConfig(command="bad"),
|
||||||
},
|
},
|
||||||
registry,
|
registry,
|
||||||
)
|
)
|
||||||
@@ -1133,36 +1121,6 @@ async def test_connect_mcp_servers_one_failure_does_not_block_others(
|
|||||||
assert set(stacks) == {"good"}
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_connect_mcp_servers_streamable_http_uses_finite_timeout(
|
async def test_connect_mcp_servers_streamable_http_uses_finite_timeout(
|
||||||
fake_mcp_runtime: dict[str, object | None],
|
fake_mcp_runtime: dict[str, object | None],
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
"""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"]
|
|
||||||
@@ -12,6 +12,7 @@ from nanobot.config.schema import Config, InlineFallbackConfig, ModelPresetConfi
|
|||||||
from nanobot.providers.registry import find_by_name
|
from nanobot.providers.registry import find_by_name
|
||||||
from nanobot.webui.settings_api import (
|
from nanobot.webui.settings_api import (
|
||||||
WebUISettingsError,
|
WebUISettingsError,
|
||||||
|
_clear_webui_oauth_flows,
|
||||||
_docs_version,
|
_docs_version,
|
||||||
_model_catalog_kind,
|
_model_catalog_kind,
|
||||||
_oauth_provider_status,
|
_oauth_provider_status,
|
||||||
@@ -35,17 +36,11 @@ from nanobot.webui.settings_api import (
|
|||||||
update_transcription_settings,
|
update_transcription_settings,
|
||||||
update_web_search_settings,
|
update_web_search_settings,
|
||||||
)
|
)
|
||||||
from nanobot.webui.settings_services import WebUIOAuthFlowRegistry
|
|
||||||
|
|
||||||
DYNAMIC_PROVIDER_NAME = "my-company-api"
|
DYNAMIC_PROVIDER_NAME = "my-company-api"
|
||||||
DYNAMIC_PROVIDER_API_BASE = "https://example.test/v1"
|
DYNAMIC_PROVIDER_API_BASE = "https://example.test/v1"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def oauth_flows() -> WebUIOAuthFlowRegistry:
|
|
||||||
return WebUIOAuthFlowRegistry()
|
|
||||||
|
|
||||||
|
|
||||||
def test_settings_payload_propagates_preset_resolution_failure(
|
def test_settings_payload_propagates_preset_resolution_failure(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1495,7 +1490,6 @@ def test_xai_grok_status_accepts_refreshable_login(
|
|||||||
def test_openai_codex_oauth_login_passes_configured_proxy(
|
def test_openai_codex_oauth_login_passes_configured_proxy(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
oauth_flows: WebUIOAuthFlowRegistry,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
proxy = "http://127.0.0.1:23458"
|
proxy = "http://127.0.0.1:23458"
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
@@ -1528,10 +1522,7 @@ def test_openai_codex_oauth_login_passes_configured_proxy(
|
|||||||
fake_start,
|
fake_start,
|
||||||
)
|
)
|
||||||
|
|
||||||
payload = login_oauth_provider(
|
payload = login_oauth_provider({"provider": ["openai-codex"]})
|
||||||
{"provider": ["openai-codex"]},
|
|
||||||
oauth_flows=oauth_flows,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert captured == {
|
assert captured == {
|
||||||
"proxy": proxy,
|
"proxy": proxy,
|
||||||
@@ -1557,17 +1548,15 @@ def test_openai_codex_oauth_login_passes_configured_proxy(
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.webui.settings_api.settings_payload",
|
"nanobot.webui.settings_api.settings_payload",
|
||||||
lambda **_kwargs: {"settings": "ready"},
|
lambda: {"settings": "ready"},
|
||||||
)
|
)
|
||||||
|
|
||||||
pending = complete_oauth_provider(
|
pending = complete_oauth_provider(
|
||||||
{"provider": ["openai-codex"], "flow_id": [payload["flow_id"]]},
|
{"provider": ["openai-codex"], "flow_id": [payload["flow_id"]]},
|
||||||
oauth_flows=oauth_flows,
|
|
||||||
)
|
)
|
||||||
completed = complete_oauth_provider(
|
completed = complete_oauth_provider(
|
||||||
{"provider": ["openai-codex"], "flow_id": [payload["flow_id"]]},
|
{"provider": ["openai-codex"], "flow_id": [payload["flow_id"]]},
|
||||||
"http://localhost:1455/auth/callback?code=secret&state=test",
|
"http://localhost:1455/auth/callback?code=secret&state=test",
|
||||||
oauth_flows=oauth_flows,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert pending == {
|
assert pending == {
|
||||||
@@ -1585,7 +1574,6 @@ def test_openai_codex_oauth_login_passes_configured_proxy(
|
|||||||
def test_openai_codex_remote_login_uses_headless_dependency_mode(
|
def test_openai_codex_remote_login_uses_headless_dependency_mode(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
oauth_flows: WebUIOAuthFlowRegistry,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
save_config(Config(), config_path)
|
save_config(Config(), config_path)
|
||||||
@@ -1611,11 +1599,10 @@ def test_openai_codex_remote_login_uses_headless_dependency_mode(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
payload = login_oauth_provider(
|
payload = login_oauth_provider(
|
||||||
{"provider": ["openai-codex"], "remote_browser": ["true"]},
|
{"provider": ["openai-codex"], "remote_browser": ["true"]}
|
||||||
oauth_flows=oauth_flows,
|
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
oauth_flows.clear("openai_codex")
|
_clear_webui_oauth_flows("openai_codex")
|
||||||
|
|
||||||
assert payload["completion_input"] == "callback_url"
|
assert payload["completion_input"] == "callback_url"
|
||||||
assert captured["open_browser"] is False
|
assert captured["open_browser"] is False
|
||||||
@@ -1624,7 +1611,6 @@ def test_openai_codex_remote_login_uses_headless_dependency_mode(
|
|||||||
|
|
||||||
def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit(
|
def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
oauth_flows: WebUIOAuthFlowRegistry,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
real_import = builtins.__import__
|
real_import = builtins.__import__
|
||||||
|
|
||||||
@@ -1636,10 +1622,7 @@ def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit(
|
|||||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||||
|
|
||||||
with pytest.raises(WebUISettingsError) as exc:
|
with pytest.raises(WebUISettingsError) as exc:
|
||||||
login_oauth_provider(
|
login_oauth_provider({"provider": ["openai-codex"]})
|
||||||
{"provider": ["openai-codex"]},
|
|
||||||
oauth_flows=oauth_flows,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert str(exc.value) == (
|
assert str(exc.value) == (
|
||||||
"This nanobot installation is missing the required oauth-cli-kit package. "
|
"This nanobot installation is missing the required oauth-cli-kit package. "
|
||||||
@@ -1649,7 +1632,6 @@ def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit(
|
|||||||
|
|
||||||
def test_github_copilot_oauth_login_reports_missing_oauth_cli_kit(
|
def test_github_copilot_oauth_login_reports_missing_oauth_cli_kit(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
oauth_flows: WebUIOAuthFlowRegistry,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
real_import = builtins.__import__
|
real_import = builtins.__import__
|
||||||
|
|
||||||
@@ -1661,10 +1643,7 @@ def test_github_copilot_oauth_login_reports_missing_oauth_cli_kit(
|
|||||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||||
|
|
||||||
with pytest.raises(WebUISettingsError) as exc:
|
with pytest.raises(WebUISettingsError) as exc:
|
||||||
login_oauth_provider(
|
login_oauth_provider({"provider": ["github-copilot"]})
|
||||||
{"provider": ["github-copilot"]},
|
|
||||||
oauth_flows=oauth_flows,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert str(exc.value) == (
|
assert str(exc.value) == (
|
||||||
"This nanobot installation is missing the required oauth-cli-kit package. "
|
"This nanobot installation is missing the required oauth-cli-kit package. "
|
||||||
@@ -1675,7 +1654,6 @@ def test_github_copilot_oauth_login_reports_missing_oauth_cli_kit(
|
|||||||
def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
|
def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
oauth_flows: WebUIOAuthFlowRegistry,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
proxy = "http://127.0.0.1:23458"
|
proxy = "http://127.0.0.1:23458"
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
@@ -1697,10 +1675,7 @@ def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
|
|||||||
|
|
||||||
monkeypatch.setattr("nanobot.providers.xai_oauth.start_xai_oauth_login", fake_start)
|
monkeypatch.setattr("nanobot.providers.xai_oauth.start_xai_oauth_login", fake_start)
|
||||||
|
|
||||||
payload = login_oauth_provider(
|
payload = login_oauth_provider({"provider": ["xai-grok"]})
|
||||||
{"provider": ["xai-grok"]},
|
|
||||||
oauth_flows=oauth_flows,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert captured["proxy"] == proxy
|
assert captured["proxy"] == proxy
|
||||||
assert captured["timeout_s"] == 600
|
assert captured["timeout_s"] == 600
|
||||||
@@ -1724,17 +1699,15 @@ def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.webui.settings_api.settings_payload",
|
"nanobot.webui.settings_api.settings_payload",
|
||||||
lambda **_kwargs: {"settings": "ready"},
|
lambda: {"settings": "ready"},
|
||||||
)
|
)
|
||||||
|
|
||||||
pending = complete_oauth_provider(
|
pending = complete_oauth_provider(
|
||||||
{"provider": ["xai-grok"], "flow_id": [payload["flow_id"]]},
|
{"provider": ["xai-grok"], "flow_id": [payload["flow_id"]]},
|
||||||
oauth_flows=oauth_flows,
|
|
||||||
)
|
)
|
||||||
completed = complete_oauth_provider(
|
completed = complete_oauth_provider(
|
||||||
{"provider": ["xai-grok"], "flow_id": [payload["flow_id"]]},
|
{"provider": ["xai-grok"], "flow_id": [payload["flow_id"]]},
|
||||||
"secret",
|
"secret",
|
||||||
oauth_flows=oauth_flows,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert pending == {
|
assert pending == {
|
||||||
@@ -1749,7 +1722,6 @@ def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
|
|||||||
def test_xai_grok_login_reports_upstream_failure_as_bad_gateway(
|
def test_xai_grok_login_reports_upstream_failure_as_bad_gateway(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
oauth_flows: WebUIOAuthFlowRegistry,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
save_config(Config(), config_path)
|
save_config(Config(), config_path)
|
||||||
@@ -1762,10 +1734,7 @@ def test_xai_grok_login_reports_upstream_failure_as_bad_gateway(
|
|||||||
monkeypatch.setattr("nanobot.providers.xai_oauth.start_xai_oauth_login", fake_start)
|
monkeypatch.setattr("nanobot.providers.xai_oauth.start_xai_oauth_login", fake_start)
|
||||||
|
|
||||||
with pytest.raises(WebUISettingsError) as exc:
|
with pytest.raises(WebUISettingsError) as exc:
|
||||||
login_oauth_provider(
|
login_oauth_provider({"provider": ["xai-grok"]})
|
||||||
{"provider": ["xai-grok"]},
|
|
||||||
oauth_flows=oauth_flows,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert exc.value.status == 502
|
assert exc.value.status == 502
|
||||||
assert str(exc.value) == (
|
assert str(exc.value) == (
|
||||||
@@ -1777,7 +1746,6 @@ def test_xai_grok_login_reports_upstream_failure_as_bad_gateway(
|
|||||||
def test_xai_grok_logout_removes_token_through_shared_lock(
|
def test_xai_grok_logout_removes_token_through_shared_lock(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
oauth_flows: WebUIOAuthFlowRegistry,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
save_config(Config(), config_path)
|
save_config(Config(), config_path)
|
||||||
@@ -1791,10 +1759,7 @@ def test_xai_grok_logout_removes_token_through_shared_lock(
|
|||||||
lambda: token_path,
|
lambda: token_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
logout_oauth_provider(
|
logout_oauth_provider({"provider": ["xai-grok"]})
|
||||||
{"provider": ["xai-grok"]},
|
|
||||||
oauth_flows=oauth_flows,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert not token_path.exists()
|
assert not token_path.exists()
|
||||||
|
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from nanobot.config.schema import Config
|
|
||||||
from nanobot.webui.settings_capabilities import (
|
|
||||||
capability_settings_payload,
|
|
||||||
update_api_settings,
|
|
||||||
update_image_generation_settings,
|
|
||||||
update_network_safety_settings,
|
|
||||||
update_transcription_settings,
|
|
||||||
update_web_search_settings,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _oauth_status(_spec: Any) -> dict[str, Any]:
|
|
||||||
return {"configured": False}
|
|
||||||
|
|
||||||
|
|
||||||
def test_capability_domain_updates_representative_settings() -> None:
|
|
||||||
config = Config()
|
|
||||||
config.providers.openrouter.api_key = "sk-test"
|
|
||||||
|
|
||||||
web_changed, web_restart = update_web_search_settings(
|
|
||||||
config,
|
|
||||||
{
|
|
||||||
"provider": ["duckduckgo"],
|
|
||||||
"max_results": ["7"],
|
|
||||||
"use_jina_reader": ["false"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
update_api_settings(
|
|
||||||
config,
|
|
||||||
{"host": ["127.0.0.2"], "port": ["8900"], "timeout": ["90"]},
|
|
||||||
)
|
|
||||||
image_changed = update_image_generation_settings(
|
|
||||||
config,
|
|
||||||
{"enabled": ["true"], "provider": ["openrouter"]},
|
|
||||||
oauth_status=_oauth_status,
|
|
||||||
)
|
|
||||||
transcription_changed = update_transcription_settings(
|
|
||||||
config,
|
|
||||||
{"provider": ["openrouter"], "model": ["openai/whisper-large-v3"]},
|
|
||||||
)
|
|
||||||
network_changed, access_mode = update_network_safety_settings(
|
|
||||||
config,
|
|
||||||
{
|
|
||||||
"webui_allow_local_service_access": ["false"],
|
|
||||||
"webui_default_access_mode": ["restricted"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
payload = capability_settings_payload(config, oauth_status=_oauth_status)
|
|
||||||
|
|
||||||
assert (web_changed, web_restart) == (True, True)
|
|
||||||
assert image_changed is True
|
|
||||||
assert transcription_changed is True
|
|
||||||
assert (network_changed, access_mode) == (True, "default")
|
|
||||||
assert payload["web_search"]["max_results"] == 7
|
|
||||||
assert payload["api"]["host"] == "127.0.0.2"
|
|
||||||
assert payload["api"]["port"] == 8900
|
|
||||||
assert payload["image_generation"]["enabled"] is True
|
|
||||||
assert payload["transcription"]["provider"] == "openrouter"
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from nanobot.config.schema import Config
|
|
||||||
from nanobot.webui.settings_models import (
|
|
||||||
model_settings_payload,
|
|
||||||
update_agent_model_settings,
|
|
||||||
update_provider_settings,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _oauth_status(_spec: Any) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"configured": False,
|
|
||||||
"account": None,
|
|
||||||
"expires_at": None,
|
|
||||||
"login_supported": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_model_domain_owns_dto_and_config_updates() -> None:
|
|
||||||
config = Config()
|
|
||||||
config.providers.openrouter.api_key = "sk-before"
|
|
||||||
|
|
||||||
agent_changed = update_agent_model_settings(
|
|
||||||
config,
|
|
||||||
{
|
|
||||||
"model": ["openai/gpt-5.4"],
|
|
||||||
"provider": ["openrouter"],
|
|
||||||
"context_window_tokens": ["200000"],
|
|
||||||
},
|
|
||||||
oauth_status=_oauth_status,
|
|
||||||
)
|
|
||||||
provider_changed, restart_required = update_provider_settings(
|
|
||||||
config,
|
|
||||||
{
|
|
||||||
"provider": ["openrouter"],
|
|
||||||
"api_key": ["sk-after"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
payload = model_settings_payload(config, oauth_status=_oauth_status)
|
|
||||||
|
|
||||||
assert agent_changed is True
|
|
||||||
assert provider_changed is True
|
|
||||||
assert restart_required is False
|
|
||||||
assert config.agents.defaults.model == "openai/gpt-5.4"
|
|
||||||
assert config.agents.defaults.provider == "openrouter"
|
|
||||||
assert config.agents.defaults.context_window_tokens == 200_000
|
|
||||||
assert config.providers.openrouter.api_key == "sk-after"
|
|
||||||
assert set(payload) == {
|
|
||||||
"agent",
|
|
||||||
"model_presets",
|
|
||||||
"model_call_order",
|
|
||||||
"model_call_order_editable",
|
|
||||||
"providers",
|
|
||||||
}
|
|
||||||
assert payload["agent"]["model"] == "openai/gpt-5.4"
|
|
||||||
@@ -8,15 +8,12 @@ from urllib.parse import parse_qs, urlsplit
|
|||||||
import pytest
|
import pytest
|
||||||
from websockets.datastructures import Headers
|
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.http_utils import http_json_response
|
||||||
from nanobot.webui.settings_routes import WebUISettingsRouter
|
from nanobot.webui.settings_routes import WebUISettingsRouter
|
||||||
from nanobot.webui.settings_services import WebUISettingsServices
|
|
||||||
|
|
||||||
|
|
||||||
def _router(*, authorized: bool = True) -> WebUISettingsRouter:
|
def _router(*, authorized: bool = True) -> WebUISettingsRouter:
|
||||||
return WebUISettingsRouter(
|
return WebUISettingsRouter(
|
||||||
settings=WebUISettingsServices.create(get_config_path()),
|
|
||||||
bus=SimpleNamespace(),
|
bus=SimpleNamespace(),
|
||||||
logger=SimpleNamespace(exception=lambda *_args: None),
|
logger=SimpleNamespace(exception=lambda *_args: None),
|
||||||
check_api_token=lambda _request: authorized,
|
check_api_token=lambda _request: authorized,
|
||||||
@@ -31,39 +28,27 @@ 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(
|
@pytest.mark.parametrize(
|
||||||
("provider", "authorization_response"),
|
("provider", "header_name", "authorization_response"),
|
||||||
[
|
[
|
||||||
("xai_grok", "secret"),
|
("xai_grok", "X-Nanobot-OAuth-Code", "secret"),
|
||||||
(
|
(
|
||||||
"openai_codex",
|
"openai_codex",
|
||||||
|
"X-Nanobot-OAuth-Callback",
|
||||||
"http://localhost:1455/auth/callback?code=secret&state=test",
|
"http://localhost:1455/auth/callback?code=secret&state=test",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_oauth_completion_reads_websocket_payload(
|
async def test_oauth_completion_reads_private_response_header(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
provider: str,
|
provider: str,
|
||||||
|
header_name: str,
|
||||||
authorization_response: str,
|
authorization_response: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
captured: dict[str, object] = {}
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
def complete(
|
def complete(query, authorization_response=None):
|
||||||
query,
|
|
||||||
authorization_response=None,
|
|
||||||
*,
|
|
||||||
oauth_flows=None,
|
|
||||||
config_path=None,
|
|
||||||
):
|
|
||||||
captured.update(query=query, authorization_response=authorization_response)
|
captured.update(query=query, authorization_response=authorization_response)
|
||||||
return {
|
return {
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
@@ -73,13 +58,19 @@ async def test_oauth_completion_reads_websocket_payload(
|
|||||||
|
|
||||||
monkeypatch.setattr("nanobot.webui.settings_routes.complete_oauth_provider", complete)
|
monkeypatch.setattr("nanobot.webui.settings_routes.complete_oauth_provider", complete)
|
||||||
router = _router()
|
router = _router()
|
||||||
request = _mutation_request(
|
request = SimpleNamespace(
|
||||||
"/api/settings/provider/oauth-login/complete",
|
path=(
|
||||||
{
|
"/api/settings/provider/oauth-login/complete"
|
||||||
"provider": provider,
|
f"?provider={provider}&flow_id=flow-123"
|
||||||
"flow_id": "flow-123",
|
),
|
||||||
"authorization_response": authorization_response,
|
headers=Headers(
|
||||||
},
|
[
|
||||||
|
(
|
||||||
|
header_name,
|
||||||
|
authorization_response,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
response = await router.dispatch(
|
response = await router.dispatch(
|
||||||
@@ -99,29 +90,28 @@ async def test_oauth_completion_reads_websocket_payload(
|
|||||||
"query": {"provider": [provider], "flow_id": ["flow-123"]},
|
"query": {"provider": [provider], "flow_id": ["flow-123"]},
|
||||||
"authorization_response": authorization_response,
|
"authorization_response": authorization_response,
|
||||||
}
|
}
|
||||||
assert request.path == "/api/settings/provider/oauth-login/complete"
|
assert authorization_response not in request.path
|
||||||
assert not request.headers
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("route_path", "function_name", "payload", "expected_query"),
|
("request_path", "route_path", "function_name", "expected_query"),
|
||||||
[
|
[
|
||||||
(
|
(
|
||||||
|
"/api/settings/model-configurations/delete?name=spare",
|
||||||
"/api/settings/model-configurations/delete",
|
"/api/settings/model-configurations/delete",
|
||||||
"delete_model_configuration",
|
"delete_model_configuration",
|
||||||
{"name": "spare"},
|
|
||||||
{"name": ["spare"]},
|
{"name": ["spare"]},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
"/api/settings/model-configurations/migrate",
|
||||||
"/api/settings/model-configurations/migrate",
|
"/api/settings/model-configurations/migrate",
|
||||||
"migrate_model_configurations",
|
"migrate_model_configurations",
|
||||||
{},
|
{},
|
||||||
{},
|
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
"/api/settings/model-call-order/update?order=%5B%22backup%22%5D",
|
||||||
"/api/settings/model-call-order/update",
|
"/api/settings/model-call-order/update",
|
||||||
"update_model_call_order",
|
"update_model_call_order",
|
||||||
{"order": ["backup"]},
|
|
||||||
{"order": ['["backup"]']},
|
{"order": ['["backup"]']},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -129,19 +119,19 @@ async def test_oauth_completion_reads_websocket_payload(
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_model_preset_mutation_routes(
|
async def test_model_preset_mutation_routes(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
|
request_path: str,
|
||||||
route_path: str,
|
route_path: str,
|
||||||
function_name: str,
|
function_name: str,
|
||||||
payload: dict[str, object],
|
|
||||||
expected_query: dict[str, list[str]],
|
expected_query: dict[str, list[str]],
|
||||||
) -> None:
|
) -> None:
|
||||||
captured: dict[str, object] = {}
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
def mutate(query, *, config_path=None):
|
def mutate(query):
|
||||||
captured["query"] = query
|
captured["query"] = query
|
||||||
return {"routed": function_name}
|
return {"routed": function_name}
|
||||||
|
|
||||||
monkeypatch.setattr(f"nanobot.webui.settings_routes.{function_name}", mutate)
|
monkeypatch.setattr(f"nanobot.webui.settings_routes.{function_name}", mutate)
|
||||||
request = _mutation_request(route_path, payload)
|
request = SimpleNamespace(path=request_path, headers=Headers())
|
||||||
|
|
||||||
response = await _router().dispatch(None, request, route_path)
|
response = await _router().dispatch(None, request, route_path)
|
||||||
|
|
||||||
@@ -151,23 +141,6 @@ async def test_model_preset_mutation_routes(
|
|||||||
assert captured["query"] == expected_query
|
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(
|
@pytest.mark.parametrize(
|
||||||
("update_info", "expected"),
|
("update_info", "expected"),
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -1,170 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from nanobot.config.schema import Config
|
|
||||||
from nanobot.webui.settings_system import (
|
|
||||||
coerce_channel_value,
|
|
||||||
system_settings_payload,
|
|
||||||
update_agent_system_settings,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_system_domain_owns_runtime_dto_and_agent_updates(tmp_path) -> None:
|
|
||||||
config = Config()
|
|
||||||
|
|
||||||
changed, restart_required = update_agent_system_settings(
|
|
||||||
config,
|
|
||||||
{
|
|
||||||
"timezone": ["Asia/Shanghai"],
|
|
||||||
"tool_hint_max_length": ["120"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
payload = system_settings_payload(
|
|
||||||
config,
|
|
||||||
config_path=tmp_path / "config.json",
|
|
||||||
version="0.3.0",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert changed is True
|
|
||||||
assert restart_required is True
|
|
||||||
assert config.agents.defaults.timezone == "Asia/Shanghai"
|
|
||||||
assert config.agents.defaults.timezone_mode == "manual"
|
|
||||||
assert config.agents.defaults.tool_hint_max_length == 120
|
|
||||||
assert payload["runtime"]["config_path"] == str(tmp_path / "config.json")
|
|
||||||
assert payload["version"] == {"current": "0.3.0"}
|
|
||||||
assert payload["docs"]["version"] == "0.3.0"
|
|
||||||
assert set(payload) == {"runtime", "usage", "advanced", "version", "docs"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_system_domain_validates_channel_field_values() -> None:
|
|
||||||
assert coerce_channel_value("allow_from", "alice, bob", "list") == [
|
|
||||||
"alice",
|
|
||||||
"bob",
|
|
||||||
]
|
|
||||||
assert coerce_channel_value("enabled", "yes", "bool") is True
|
|
||||||
assert coerce_channel_value("port", "8765", "int") == 8765
|
|
||||||
+21
-61
@@ -8,7 +8,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Eye, EyeOff, Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react";
|
import { Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { channelUiPresentation } from "@/channel-plugins/registry";
|
import { channelUiPresentation } from "@/channel-plugins/registry";
|
||||||
import { Sidebar } from "@/components/Sidebar";
|
import { Sidebar } from "@/components/Sidebar";
|
||||||
@@ -316,23 +316,13 @@ function AuthForm({
|
|||||||
onSecret: (secret: string) => void;
|
onSecret: (secret: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
|
||||||
const [submitting, setSubmitting] = 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) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const secret = value.trim();
|
const secret = value.trim();
|
||||||
if (!secret) {
|
if (!secret) return;
|
||||||
setValidationError("required");
|
|
||||||
inputRef.current?.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
onSecret(secret);
|
onSecret(secret);
|
||||||
};
|
};
|
||||||
@@ -343,57 +333,27 @@ function AuthForm({
|
|||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="flex w-full max-w-sm flex-col gap-4"
|
className="flex w-full max-w-sm flex-col gap-4"
|
||||||
>
|
>
|
||||||
<div className="space-y-2">
|
<div className="flex flex-col items-center gap-1 text-center">
|
||||||
<h1 className="text-sm font-medium text-foreground">
|
<p className="text-lg font-semibold">{t("app.auth.title")}</p>
|
||||||
<label htmlFor="webui-access-password">{t("app.auth.label")}</label>
|
<p className="text-sm text-muted-foreground">{t("app.auth.hint")}</p>
|
||||||
</h1>
|
|
||||||
<div className="relative">
|
|
||||||
<Input
|
|
||||||
ref={inputRef}
|
|
||||||
id="webui-access-password"
|
|
||||||
name="webui-access-password"
|
|
||||||
type={passwordVisible ? "text" : "password"}
|
|
||||||
autoComplete="current-password"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => {
|
|
||||||
setValue(e.target.value);
|
|
||||||
setValidationError(null);
|
|
||||||
}}
|
|
||||||
disabled={submitting}
|
|
||||||
aria-invalid={validationError ? true : undefined}
|
|
||||||
aria-describedby={validationError ? "webui-auth-error" : undefined}
|
|
||||||
className="pr-10 focus-visible:ring-1 focus-visible:ring-ring/30 focus-visible:ring-offset-0"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
disabled={submitting}
|
|
||||||
aria-label={t(
|
|
||||||
passwordVisible ? "app.auth.hidePassword" : "app.auth.showPassword",
|
|
||||||
)}
|
|
||||||
aria-controls="webui-access-password"
|
|
||||||
onClick={() => setPasswordVisible((visible) => !visible)}
|
|
||||||
className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
{passwordVisible ? (
|
|
||||||
<EyeOff className="h-4 w-4" strokeWidth={1.75} aria-hidden />
|
|
||||||
) : (
|
|
||||||
<Eye className="h-4 w-4" strokeWidth={1.75} aria-hidden />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{errorMessage ? (
|
|
||||||
<p id="webui-auth-error" role="alert" className="text-sm text-destructive">
|
|
||||||
{errorMessage}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
{failed && (
|
||||||
|
<p className="text-center text-sm text-destructive">
|
||||||
|
{t("app.auth.invalid")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder={t("app.auth.placeholder")}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
disabled={submitting}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={submitting}
|
disabled={!value.trim() || submitting}
|
||||||
>
|
>
|
||||||
{t("app.auth.submit")}
|
{t("app.auth.submit")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -2081,7 +2041,7 @@ function Shell({
|
|||||||
setPairingBusyCode(code);
|
setPairingBusyCode(code);
|
||||||
setPairingError(null);
|
setPairingError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await runPairingAction(client, action, code);
|
const payload = await runPairingAction(getToken(), action, code);
|
||||||
setPairingRequests(Array.isArray(payload.requests) ? payload.requests : []);
|
setPairingRequests(Array.isArray(payload.requests) ? payload.requests : []);
|
||||||
setSnoozedPairingCodes((current) => {
|
setSnoozedPairingCodes((current) => {
|
||||||
if (!current.has(code)) return current;
|
if (!current.has(code)) return current;
|
||||||
@@ -2096,7 +2056,7 @@ function Shell({
|
|||||||
setPairingBusyCode(null);
|
setPairingBusyCode(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[client, refreshPairingRequests],
|
[getToken, refreshPairingRequests],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onDismissPairingRequest = useCallback((code: string) => {
|
const onDismissPairingRequest = useCallback((code: string) => {
|
||||||
|
|||||||
@@ -724,7 +724,7 @@ export function SettingsView({
|
|||||||
hostChromeInset = false,
|
hostChromeInset = false,
|
||||||
}: SettingsViewProps) {
|
}: SettingsViewProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { client, getToken, token } = useClient();
|
const { getToken, token } = useClient();
|
||||||
const pageVisible = usePageVisibility();
|
const pageVisible = usePageVisibility();
|
||||||
const remoteBrowserAccess =
|
const remoteBrowserAccess =
|
||||||
typeof window !== "undefined" && !isLoopbackHost(window.location.hostname);
|
typeof window !== "undefined" && !isLoopbackHost(window.location.hostname);
|
||||||
@@ -872,7 +872,7 @@ export function SettingsView({
|
|||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
try {
|
try {
|
||||||
const payload = await completeProviderOAuth(
|
const payload = await completeProviderOAuth(
|
||||||
client,
|
getToken(),
|
||||||
providerOAuthFlow.provider,
|
providerOAuthFlow.provider,
|
||||||
providerOAuthFlow.flow_id,
|
providerOAuthFlow.flow_id,
|
||||||
);
|
);
|
||||||
@@ -902,7 +902,7 @@ export function SettingsView({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (timer !== null) window.clearTimeout(timer);
|
if (timer !== null) window.clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}, [applyPayload, client, closeProviderOAuthFlow, providerOAuthFlow]);
|
}, [applyPayload, closeProviderOAuthFlow, getToken, providerOAuthFlow]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialSettings || settings !== null) return;
|
if (!initialSettings || settings !== null) return;
|
||||||
@@ -1301,7 +1301,7 @@ export function SettingsView({
|
|||||||
}
|
}
|
||||||
setModelConfigurationSaving(true);
|
setModelConfigurationSaving(true);
|
||||||
try {
|
try {
|
||||||
const payload = await createModelConfiguration(client, {
|
const payload = await createModelConfiguration(token, {
|
||||||
label,
|
label,
|
||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
@@ -1319,7 +1319,7 @@ export function SettingsView({
|
|||||||
|
|
||||||
let finalPayload = payload;
|
let finalPayload = payload;
|
||||||
if (nextOrder) {
|
if (nextOrder) {
|
||||||
const orderedPayload = await updateModelCallOrder(client, nextOrder);
|
const orderedPayload = await updateModelCallOrder(token, nextOrder);
|
||||||
applyPayload(orderedPayload);
|
applyPayload(orderedPayload);
|
||||||
finalPayload = orderedPayload;
|
finalPayload = orderedPayload;
|
||||||
}
|
}
|
||||||
@@ -1345,7 +1345,7 @@ export function SettingsView({
|
|||||||
const reasoningEffort = form.reasoningEffort || null;
|
const reasoningEffort = form.reasoningEffort || null;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const payload = await updateModelConfiguration(client, {
|
const payload = await updateModelConfiguration(token, {
|
||||||
name: selectedPreset.name,
|
name: selectedPreset.name,
|
||||||
label:
|
label:
|
||||||
form.presetLabel.trim() !== selectedPreset.label
|
form.presetLabel.trim() !== selectedPreset.label
|
||||||
@@ -1431,7 +1431,7 @@ export function SettingsView({
|
|||||||
setModelCallOrder(nextOrder);
|
setModelCallOrder(nextOrder);
|
||||||
setModelCallOrderSaving(true);
|
setModelCallOrderSaving(true);
|
||||||
try {
|
try {
|
||||||
const payload = await updateModelCallOrder(client, nextOrder);
|
const payload = await updateModelCallOrder(token, nextOrder);
|
||||||
applyPayload(payload, { preserveAgentForm: true });
|
applyPayload(payload, { preserveAgentForm: true });
|
||||||
onModelNameChange(payload.agent.model || null);
|
onModelNameChange(payload.agent.model || null);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -1447,7 +1447,7 @@ export function SettingsView({
|
|||||||
if (modelMigrationSaving) return;
|
if (modelMigrationSaving) return;
|
||||||
setModelMigrationSaving(true);
|
setModelMigrationSaving(true);
|
||||||
try {
|
try {
|
||||||
const payload = await migrateModelConfigurations(client);
|
const payload = await migrateModelConfigurations(token);
|
||||||
applyPayload(payload);
|
applyPayload(payload);
|
||||||
onModelNameChange(payload.agent.model || null);
|
onModelNameChange(payload.agent.model || null);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -1469,7 +1469,7 @@ export function SettingsView({
|
|||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const payload = await deleteModelConfiguration(client, modelPresetPendingDelete.name);
|
const payload = await deleteModelConfiguration(token, modelPresetPendingDelete.name);
|
||||||
applyPayload(payload);
|
applyPayload(payload);
|
||||||
setModelPresetPendingDelete(null);
|
setModelPresetPendingDelete(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -1484,7 +1484,7 @@ export function SettingsView({
|
|||||||
if (!settings || !imageGenerationDirty || imageGenerationSaving) return;
|
if (!settings || !imageGenerationDirty || imageGenerationSaving) return;
|
||||||
setImageGenerationSaving(true);
|
setImageGenerationSaving(true);
|
||||||
try {
|
try {
|
||||||
const payload = await updateImageGenerationSettings(client, imageGenerationForm);
|
const payload = await updateImageGenerationSettings(token, imageGenerationForm);
|
||||||
applyPayload(payload);
|
applyPayload(payload);
|
||||||
if (payload.requires_restart) {
|
if (payload.requires_restart) {
|
||||||
setPendingRestartSections((prev) => ({ ...prev, image: true }));
|
setPendingRestartSections((prev) => ({ ...prev, image: true }));
|
||||||
@@ -1502,7 +1502,7 @@ export function SettingsView({
|
|||||||
if (!settings || !transcriptionDirty || transcriptionSaving) return;
|
if (!settings || !transcriptionDirty || transcriptionSaving) return;
|
||||||
setTranscriptionSaving(true);
|
setTranscriptionSaving(true);
|
||||||
try {
|
try {
|
||||||
const payload = await updateTranscriptionSettings(client, transcriptionForm);
|
const payload = await updateTranscriptionSettings(token, transcriptionForm);
|
||||||
applyPayload(payload);
|
applyPayload(payload);
|
||||||
if (payload.requires_restart) {
|
if (payload.requires_restart) {
|
||||||
setPendingRestartSections((prev) => ({ ...prev, browser: true }));
|
setPendingRestartSections((prev) => ({ ...prev, browser: true }));
|
||||||
@@ -1520,7 +1520,7 @@ export function SettingsView({
|
|||||||
if (!settings || !networkSafetyDirty || networkSafetySaving) return;
|
if (!settings || !networkSafetyDirty || networkSafetySaving) return;
|
||||||
setNetworkSafetySaving(true);
|
setNetworkSafetySaving(true);
|
||||||
try {
|
try {
|
||||||
const payload = await updateNetworkSafetySettings(client, networkSafetyForm);
|
const payload = await updateNetworkSafetySettings(token, networkSafetyForm);
|
||||||
applyPayload(payload);
|
applyPayload(payload);
|
||||||
if (payload.requires_restart) {
|
if (payload.requires_restart) {
|
||||||
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
|
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
|
||||||
@@ -1544,7 +1544,7 @@ export function SettingsView({
|
|||||||
try {
|
try {
|
||||||
let latest = nanobotFeatures;
|
let latest = nanobotFeatures;
|
||||||
for (const name of missing) {
|
for (const name of missing) {
|
||||||
latest = await enableNanobotFeature(client, name);
|
latest = await enableNanobotFeature(token, name);
|
||||||
if (latest.requires_restart) {
|
if (latest.requires_restart) {
|
||||||
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
|
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
|
||||||
}
|
}
|
||||||
@@ -1568,8 +1568,8 @@ export function SettingsView({
|
|||||||
setApiServiceError(null);
|
setApiServiceError(null);
|
||||||
try {
|
try {
|
||||||
const payload = action === "start"
|
const payload = action === "start"
|
||||||
? await startApiService(client, values!)
|
? await startApiService(token, values!)
|
||||||
: await stopApiService(client);
|
: await stopApiService(token);
|
||||||
setApiService(payload);
|
setApiService(payload);
|
||||||
const refreshed = await fetchNanobotFeatures(token);
|
const refreshed = await fetchNanobotFeatures(token);
|
||||||
setNanobotFeatures(refreshed);
|
setNanobotFeatures(refreshed);
|
||||||
@@ -1622,7 +1622,7 @@ export function SettingsView({
|
|||||||
if (field === "region") update.region = providerForm.region.trim();
|
if (field === "region") update.region = providerForm.region.trim();
|
||||||
if (field === "profile") update.profile = providerForm.profile.trim();
|
if (field === "profile") update.profile = providerForm.profile.trim();
|
||||||
}
|
}
|
||||||
const payload = await updateProviderSettings(client, update);
|
const payload = await updateProviderSettings(token, update);
|
||||||
applyPayload(payload);
|
applyPayload(payload);
|
||||||
if (payload.requires_restart) {
|
if (payload.requires_restart) {
|
||||||
setPendingRestartSections((prev) => ({ ...prev, image: true }));
|
setPendingRestartSections((prev) => ({ ...prev, image: true }));
|
||||||
@@ -1656,7 +1656,7 @@ export function SettingsView({
|
|||||||
if (providerSaving) return false;
|
if (providerSaving) return false;
|
||||||
setProviderSaving(CUSTOM_PROVIDER_CREATION_KEY);
|
setProviderSaving(CUSTOM_PROVIDER_CREATION_KEY);
|
||||||
try {
|
try {
|
||||||
const payload = await createProviderSettings(client, {
|
const payload = await createProviderSettings(token, {
|
||||||
name: draft.name.trim(),
|
name: draft.name.trim(),
|
||||||
apiKey: draft.apiKey.trim() || undefined,
|
apiKey: draft.apiKey.trim() || undefined,
|
||||||
apiBase: draft.apiBase.trim(),
|
apiBase: draft.apiBase.trim(),
|
||||||
@@ -1698,11 +1698,12 @@ export function SettingsView({
|
|||||||
const payload =
|
const payload =
|
||||||
action === "login"
|
action === "login"
|
||||||
? await loginProviderOAuth(
|
? await loginProviderOAuth(
|
||||||
client,
|
token,
|
||||||
providerName,
|
providerName,
|
||||||
|
"",
|
||||||
providerName === "openai_codex" && remoteBrowserAccess,
|
providerName === "openai_codex" && remoteBrowserAccess,
|
||||||
)
|
)
|
||||||
: await logoutProviderOAuth(client, providerName);
|
: await logoutProviderOAuth(token, providerName);
|
||||||
if (isProviderOAuthAuthorizationRequired(payload)) {
|
if (isProviderOAuthAuthorizationRequired(payload)) {
|
||||||
try {
|
try {
|
||||||
if (popup && !popup.closed) popup.location.href = payload.authorization_url;
|
if (popup && !popup.closed) popup.location.href = payload.authorization_url;
|
||||||
@@ -1738,7 +1739,7 @@ export function SettingsView({
|
|||||||
setProviderOAuthDialogError(null);
|
setProviderOAuthDialogError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await completeProviderOAuth(
|
const payload = await completeProviderOAuth(
|
||||||
client,
|
token,
|
||||||
flow.provider,
|
flow.provider,
|
||||||
flow.flow_id,
|
flow.flow_id,
|
||||||
authorizationResponse,
|
authorizationResponse,
|
||||||
@@ -1797,7 +1798,7 @@ export function SettingsView({
|
|||||||
update.apiKey = apiKey;
|
update.apiKey = apiKey;
|
||||||
}
|
}
|
||||||
if (provider.credential === "base_url") update.baseUrl = baseUrl;
|
if (provider.credential === "base_url") update.baseUrl = baseUrl;
|
||||||
const payload = await updateWebSearchSettings(client, update);
|
const payload = await updateWebSearchSettings(token, update);
|
||||||
applyPayload(payload);
|
applyPayload(payload);
|
||||||
if (payload.requires_restart || webFetchRestartRequired) {
|
if (payload.requires_restart || webFetchRestartRequired) {
|
||||||
setPendingRestartSections((prev) => ({ ...prev, browser: true }));
|
setPendingRestartSections((prev) => ({ ...prev, browser: true }));
|
||||||
@@ -1902,7 +1903,7 @@ export function SettingsView({
|
|||||||
setCliAppsMessage(null);
|
setCliAppsMessage(null);
|
||||||
setCliAppsError(null);
|
setCliAppsError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await runCliAppAction(client, action, name);
|
const payload = await runCliAppAction(token, action, name);
|
||||||
setCliApps(payload);
|
setCliApps(payload);
|
||||||
if (action !== "test") {
|
if (action !== "test") {
|
||||||
notifyCliAppsChanged(payload);
|
notifyCliAppsChanged(payload);
|
||||||
@@ -1933,8 +1934,8 @@ export function SettingsView({
|
|||||||
setNanobotFeaturesError(null);
|
setNanobotFeaturesError(null);
|
||||||
try {
|
try {
|
||||||
const payload = action === "enable"
|
const payload = action === "enable"
|
||||||
? await enableNanobotFeature(client, name)
|
? await enableNanobotFeature(token, name)
|
||||||
: await disableNanobotFeature(client, name);
|
: await disableNanobotFeature(token, name);
|
||||||
setNanobotFeatures(payload);
|
setNanobotFeatures(payload);
|
||||||
if (payload.requires_restart) {
|
if (payload.requires_restart) {
|
||||||
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
|
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
|
||||||
@@ -1954,7 +1955,7 @@ export function SettingsView({
|
|||||||
setAutomationAction(key);
|
setAutomationAction(key);
|
||||||
setAutomationsError(null);
|
setAutomationsError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await runAutomationAction(client, action, job.id);
|
const payload = await runAutomationAction(token, action, job.id);
|
||||||
setAutomations(payload);
|
setAutomations(payload);
|
||||||
if (action === "delete") setAutomationPendingDelete(null);
|
if (action === "delete") setAutomationPendingDelete(null);
|
||||||
if (action === "run") {
|
if (action === "run") {
|
||||||
@@ -1976,7 +1977,7 @@ export function SettingsView({
|
|||||||
setAutomationAction(key);
|
setAutomationAction(key);
|
||||||
setAutomationsError(null);
|
setAutomationsError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await updateAutomation(client, job.id, values);
|
const payload = await updateAutomation(token, job.id, values);
|
||||||
setAutomations(payload);
|
setAutomations(payload);
|
||||||
setAutomationPendingEdit(null);
|
setAutomationPendingEdit(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1996,7 +1997,7 @@ export function SettingsView({
|
|||||||
setMcpMessage(null);
|
setMcpMessage(null);
|
||||||
setMcpError(null);
|
setMcpError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await runMcpPresetAction(client, action, name, values);
|
const payload = await runMcpPresetAction(token, action, name, values);
|
||||||
setMcpPresets(payload);
|
setMcpPresets(payload);
|
||||||
setMcpMessage(payload.last_action?.message ?? null);
|
setMcpMessage(payload.last_action?.message ?? null);
|
||||||
if (action !== "test") {
|
if (action !== "test") {
|
||||||
@@ -2023,7 +2024,7 @@ export function SettingsView({
|
|||||||
setMcpMessage(null);
|
setMcpMessage(null);
|
||||||
setMcpError(null);
|
setMcpError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await saveCustomMcpServer(client, {
|
const payload = await saveCustomMcpServer(token, {
|
||||||
name,
|
name,
|
||||||
transport: customMcpForm.transport,
|
transport: customMcpForm.transport,
|
||||||
command: customMcpForm.command,
|
command: customMcpForm.command,
|
||||||
@@ -2053,7 +2054,7 @@ export function SettingsView({
|
|||||||
setMcpMessage(null);
|
setMcpMessage(null);
|
||||||
setMcpError(null);
|
setMcpError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await importMcpConfig(client, mcpConfigImport);
|
const payload = await importMcpConfig(token, mcpConfigImport);
|
||||||
setMcpPresets(payload);
|
setMcpPresets(payload);
|
||||||
setMcpMessage(payload.last_action?.message ?? null);
|
setMcpMessage(payload.last_action?.message ?? null);
|
||||||
notifyMcpPresetsChanged(payload);
|
notifyMcpPresetsChanged(payload);
|
||||||
@@ -2074,7 +2075,7 @@ export function SettingsView({
|
|||||||
setMcpMessage(null);
|
setMcpMessage(null);
|
||||||
setMcpError(null);
|
setMcpError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await updateMcpServerTools(client, name, enabledTools);
|
const payload = await updateMcpServerTools(token, name, enabledTools);
|
||||||
setMcpPresets(payload);
|
setMcpPresets(payload);
|
||||||
setMcpMessage(payload.last_action?.message ?? null);
|
setMcpMessage(payload.last_action?.message ?? null);
|
||||||
notifyMcpPresetsChanged(payload);
|
notifyMcpPresetsChanged(payload);
|
||||||
@@ -2327,7 +2328,6 @@ export function SettingsView({
|
|||||||
onAction={handleAutomationAction}
|
onAction={handleAutomationAction}
|
||||||
onRequestEdit={setAutomationPendingEdit}
|
onRequestEdit={setAutomationPendingEdit}
|
||||||
onRequestDelete={setAutomationPendingDelete}
|
onRequestDelete={setAutomationPendingDelete}
|
||||||
onBackToChat={onBackToChat}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "skills":
|
case "skills":
|
||||||
@@ -2448,7 +2448,7 @@ export function SettingsView({
|
|||||||
onSave={handleAutomationEdit}
|
onSave={handleAutomationEdit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<main
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-0 flex-1 bg-settings-canvas [scrollbar-gutter:stable]",
|
"min-w-0 flex-1 bg-settings-canvas [scrollbar-gutter:stable]",
|
||||||
activeSection === "channels" ? "overflow-y-auto xl:overflow-hidden" : "overflow-y-auto",
|
activeSection === "channels" ? "overflow-y-auto xl:overflow-hidden" : "overflow-y-auto",
|
||||||
@@ -2512,7 +2512,7 @@ export function SettingsView({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2578,9 +2578,9 @@ function SettingsSidebar({
|
|||||||
{t("settings.backToChat")}
|
{t("settings.backToChat")}
|
||||||
</button>
|
</button>
|
||||||
<div className="mb-3 px-1 lg:mb-4 lg:px-2">
|
<div className="mb-3 px-1 lg:mb-4 lg:px-2">
|
||||||
<h1 className="text-[18px] font-normal tracking-normal text-foreground">
|
<h2 className="text-[18px] font-normal tracking-normal text-foreground">
|
||||||
{t("settings.sidebar.title")}
|
{t("settings.sidebar.title")}
|
||||||
</h1>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
@@ -3042,13 +3042,7 @@ function AppearanceSettings({
|
|||||||
label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<SettingsRow
|
<SettingsRow title={tx("settings.rows.brandLogos", "Brand logos")}>
|
||||||
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
|
<ToggleButton
|
||||||
checked={localPrefs.brandLogos}
|
checked={localPrefs.brandLogos}
|
||||||
onChange={(brandLogos) => onChangeLocalPrefs((prev) => ({ ...prev, brandLogos }))}
|
onChange={(brandLogos) => onChangeLocalPrefs((prev) => ({ ...prev, brandLogos }))}
|
||||||
@@ -5494,7 +5488,6 @@ function AutomationsSettings({
|
|||||||
onAction,
|
onAction,
|
||||||
onRequestEdit,
|
onRequestEdit,
|
||||||
onRequestDelete,
|
onRequestDelete,
|
||||||
onBackToChat,
|
|
||||||
}: {
|
}: {
|
||||||
payload: AutomationsPayload | null;
|
payload: AutomationsPayload | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -5509,7 +5502,6 @@ function AutomationsSettings({
|
|||||||
onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise<void>;
|
onAction: (action: AutomationAction, job: SessionAutomationJob) => void | Promise<void>;
|
||||||
onRequestEdit: (job: SessionAutomationJob) => void;
|
onRequestEdit: (job: SessionAutomationJob) => void;
|
||||||
onRequestDelete: (job: SessionAutomationJob) => void;
|
onRequestDelete: (job: SessionAutomationJob) => void;
|
||||||
onBackToChat: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
|
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
|
||||||
@@ -5557,76 +5549,74 @@ function AutomationsSettings({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{jobs.length ? (
|
<section className="shrink-0">
|
||||||
<section className="shrink-0">
|
<div className="mx-auto flex w-full max-w-[56rem] flex-col gap-3">
|
||||||
<div className="mx-auto flex w-full max-w-[56rem] flex-col gap-3">
|
<div className="-mx-1 overflow-x-auto px-1 pb-0.5">
|
||||||
<div className="-mx-1 overflow-x-auto px-1 pb-0.5">
|
<div className="grid w-full min-w-[36rem] grid-cols-5 gap-1 rounded-[15px] bg-muted p-1">
|
||||||
<div className="grid w-full min-w-[36rem] grid-cols-5 gap-1 rounded-[15px] bg-muted p-1">
|
{summaryOptions.map((option) => (
|
||||||
{summaryOptions.map((option) => (
|
<button
|
||||||
<button
|
key={option.value}
|
||||||
key={option.value}
|
type="button"
|
||||||
type="button"
|
onClick={() => onFilterChange(option.value)}
|
||||||
onClick={() => onFilterChange(option.value)}
|
className={cn(
|
||||||
|
"inline-flex h-8 min-w-0 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-[11px] px-3 text-[12px] font-medium text-muted-foreground transition-colors",
|
||||||
|
filter === option.value && "bg-background text-foreground",
|
||||||
|
automationFilterToneClass(option.value, option.count, filter === option.value),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-8 min-w-0 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-[11px] px-3 text-[12px] font-medium text-muted-foreground transition-colors",
|
"min-w-5 shrink-0 rounded-full bg-background/75 px-1.5 py-0.5 text-center text-[11px] tabular-nums text-muted-foreground",
|
||||||
filter === option.value && "bg-background text-foreground",
|
automationFilterCountClass(option.value, option.count),
|
||||||
automationFilterToneClass(option.value, option.count, filter === option.value),
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span>{option.label}</span>
|
{option.count}
|
||||||
<span
|
</span>
|
||||||
className={cn(
|
</button>
|
||||||
"min-w-5 shrink-0 rounded-full bg-background/75 px-1.5 py-0.5 text-center text-[11px] tabular-nums text-muted-foreground",
|
))}
|
||||||
automationFilterCountClass(option.value, option.count),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{option.count}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
|
||||||
<div className="relative min-w-0">
|
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/70" />
|
|
||||||
<Input
|
|
||||||
value={query}
|
|
||||||
onChange={(event) => onQueryChange(event.target.value)}
|
|
||||||
placeholder={tx(
|
|
||||||
"settings.automations.search",
|
|
||||||
"Search task, message, linked chat, or schedule",
|
|
||||||
)}
|
|
||||||
className={cn(
|
|
||||||
"h-9 w-full rounded-[13px] pl-9 text-[13px]",
|
|
||||||
SETTINGS_SEARCH_INPUT_CLASS,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex h-9 min-w-[8.5rem] items-center justify-center gap-1.5 whitespace-nowrap rounded-[13px] border border-border/45 bg-settings-surface px-3 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground sm:w-auto"
|
|
||||||
>
|
|
||||||
<ArrowUpDown className="h-3.5 w-3.5" aria-hidden />
|
|
||||||
<span>{sortLabel[sort]}</span>
|
|
||||||
<ChevronDown className="h-3.5 w-3.5" aria-hidden />
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="min-w-40">
|
|
||||||
{(Object.keys(sortLabel) as AutomationSort[]).map((value) => (
|
|
||||||
<DropdownMenuItem key={value} onClick={() => onSortChange(value)}>
|
|
||||||
<span>{sortLabel[value]}</span>
|
|
||||||
{sort === value ? <Check className="ml-auto h-3.5 w-3.5" aria-hidden /> : null}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
) : null}
|
<div className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
||||||
|
<div className="relative min-w-0">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/70" />
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => onQueryChange(event.target.value)}
|
||||||
|
placeholder={tx(
|
||||||
|
"settings.automations.search",
|
||||||
|
"Search task, message, linked chat, or schedule",
|
||||||
|
)}
|
||||||
|
className={cn(
|
||||||
|
"h-9 w-full rounded-[13px] pl-9 text-[13px]",
|
||||||
|
SETTINGS_SEARCH_INPUT_CLASS,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex h-9 min-w-[8.5rem] items-center justify-center gap-1.5 whitespace-nowrap rounded-[13px] border border-border/45 bg-settings-surface px-3 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground sm:w-auto"
|
||||||
|
>
|
||||||
|
<ArrowUpDown className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
<span>{sortLabel[sort]}</span>
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="min-w-40">
|
||||||
|
{(Object.keys(sortLabel) as AutomationSort[]).map((value) => (
|
||||||
|
<DropdownMenuItem key={value} onClick={() => onSortChange(value)}>
|
||||||
|
<span>{sortLabel[value]}</span>
|
||||||
|
{sort === value ? <Check className="ml-auto h-3.5 w-3.5" aria-hidden /> : null}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{error ? (
|
{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">
|
<div className="flex items-center gap-2 rounded-[18px] border border-destructive/20 bg-destructive/5 px-4 py-3 text-[13px] text-destructive">
|
||||||
@@ -5684,35 +5674,13 @@ function AutomationsSettings({
|
|||||||
: tx("settings.automations.empty", "No automations yet.")}
|
: tx("settings.automations.empty", "No automations yet.")}
|
||||||
</div>
|
</div>
|
||||||
{!jobs.length ? (
|
{!jobs.length ? (
|
||||||
<>
|
<div className="mx-auto mt-2 max-w-[28rem] text-[12px] leading-5">
|
||||||
<div className="mx-auto mt-2 max-w-[28rem] text-[12px] leading-5">
|
{tx(
|
||||||
{tx(
|
"settings.automations.emptyHint",
|
||||||
"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>
|
||||||
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -7322,6 +7290,10 @@ function ChannelsSettings({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div className={cn("shrink-0 pt-2", showingCompactDetail && "hidden")}>
|
||||||
|
<ThirdPartyBrandNotice />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -7424,23 +7396,6 @@ function AppsCatalogSettings({
|
|||||||
(cliAppsLoading || mcpPresetsLoading) &&
|
(cliAppsLoading || mcpPresetsLoading) &&
|
||||||
!cliApps &&
|
!cliApps &&
|
||||||
!mcpPresets;
|
!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 =
|
const statusMessage =
|
||||||
cliError ||
|
cliError ||
|
||||||
mcpError ||
|
mcpError ||
|
||||||
@@ -7529,35 +7484,7 @@ function AppsCatalogSettings({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="px-3 py-12 text-center text-sm text-muted-foreground">
|
<div className="px-3 py-12 text-center text-sm text-muted-foreground">
|
||||||
<p>{emptyTitle}</p>
|
{tx("settings.apps.empty", "No tools match this view.")}
|
||||||
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -7573,6 +7500,8 @@ function AppsCatalogSettings({
|
|||||||
onImportConfig={onImportMcpConfig}
|
onImportConfig={onImportMcpConfig}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<ThirdPartyBrandNotice />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -9397,6 +9326,18 @@ 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(
|
function orderUnconfiguredProviders(
|
||||||
providers: SettingsPayload["providers"],
|
providers: SettingsPayload["providers"],
|
||||||
): SettingsPayload["providers"] {
|
): SettingsPayload["providers"] {
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ function SkillDetailSheet({
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const { client, getToken } = useClient();
|
const { getToken } = useClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [detail, setDetail] = useState<SkillDetail | null>(null);
|
const [detail, setDetail] = useState<SkillDetail | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -321,7 +321,7 @@ function SkillDetailSheet({
|
|||||||
setActionBusy(true);
|
setActionBusy(true);
|
||||||
setActionError("");
|
setActionError("");
|
||||||
try {
|
try {
|
||||||
const payload = await updateSkillEnabled(client, activeSkill.name, !enabled);
|
const payload = await updateSkillEnabled(getToken(), activeSkill.name, !enabled);
|
||||||
notifySkillsChanged(payload);
|
notifySkillsChanged(payload);
|
||||||
const updated = payload.skills.find((item) => item.name === activeSkill.name);
|
const updated = payload.skills.find((item) => item.name === activeSkill.name);
|
||||||
if (updated) {
|
if (updated) {
|
||||||
@@ -345,7 +345,7 @@ function SkillDetailSheet({
|
|||||||
setActionBusy(true);
|
setActionBusy(true);
|
||||||
setActionError("");
|
setActionError("");
|
||||||
try {
|
try {
|
||||||
const payload = await deleteSkill(client, activeSkill.name);
|
const payload = await deleteSkill(getToken(), activeSkill.name);
|
||||||
notifySkillsChanged(payload);
|
notifySkillsChanged(payload);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function SkillsMarketplace({
|
|||||||
installing: string;
|
installing: string;
|
||||||
onInstallingChange: (skillId: string) => void;
|
onInstallingChange: (skillId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { client, getToken } = useClient();
|
const { getToken } = useClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [results, setResults] = useState<MarketplaceSkillSummary[]>([]);
|
const [results, setResults] = useState<MarketplaceSkillSummary[]>([]);
|
||||||
@@ -161,7 +161,7 @@ export function SkillsMarketplace({
|
|||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const payload = await installMarketplaceSkill(
|
const payload = await installMarketplaceSkill(
|
||||||
client,
|
getToken(),
|
||||||
skill.provider,
|
skill.provider,
|
||||||
skill.source,
|
skill.source,
|
||||||
skill.skill_id,
|
skill.skill_id,
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ import type {
|
|||||||
NanobotFeaturesPayload,
|
NanobotFeaturesPayload,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useClient } from "@/providers/ClientProvider";
|
|
||||||
|
|
||||||
export type ChannelInstancesPanelCustomization = {
|
export type ChannelInstancesPanelCustomization = {
|
||||||
countLabel?: (runningCount: number) => string;
|
countLabel?: (runningCount: number) => string;
|
||||||
@@ -51,6 +50,7 @@ export type ChannelInstancesPanelCustomization = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ChannelInstancesPanel({
|
export function ChannelInstancesPanel({
|
||||||
|
token,
|
||||||
feature,
|
feature,
|
||||||
showBrandLogos,
|
showBrandLogos,
|
||||||
chatAppsDocsUrl,
|
chatAppsDocsUrl,
|
||||||
@@ -58,6 +58,7 @@ export function ChannelInstancesPanel({
|
|||||||
onFeaturesUpdate,
|
onFeaturesUpdate,
|
||||||
customization = {},
|
customization = {},
|
||||||
}: {
|
}: {
|
||||||
|
token: string;
|
||||||
feature: NanobotFeatureInfo;
|
feature: NanobotFeatureInfo;
|
||||||
showBrandLogos: boolean;
|
showBrandLogos: boolean;
|
||||||
chatAppsDocsUrl?: string;
|
chatAppsDocsUrl?: string;
|
||||||
@@ -65,7 +66,6 @@ export function ChannelInstancesPanel({
|
|||||||
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
||||||
customization?: ChannelInstancesPanelCustomization;
|
customization?: ChannelInstancesPanelCustomization;
|
||||||
}) {
|
}) {
|
||||||
const { client } = useClient();
|
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||||
const displayName = localizedChannelDisplayName(feature, t);
|
const displayName = localizedChannelDisplayName(feature, t);
|
||||||
@@ -111,8 +111,8 @@ export function ChannelInstancesPanel({
|
|||||||
setNotice(null);
|
setNotice(null);
|
||||||
try {
|
try {
|
||||||
const payload = checked
|
const payload = checked
|
||||||
? await enableNanobotFeature(client, feature.name, { instanceId: instance.id })
|
? await enableNanobotFeature(token, feature.name, { instanceId: instance.id })
|
||||||
: await disableNanobotFeature(client, feature.name, { instanceId: instance.id });
|
: await disableNanobotFeature(token, feature.name, { instanceId: instance.id });
|
||||||
onFeaturesUpdate(payload);
|
onFeaturesUpdate(payload);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setNotice((err as Error).message);
|
setNotice((err as Error).message);
|
||||||
@@ -127,7 +127,7 @@ export function ChannelInstancesPanel({
|
|||||||
setNotice(null);
|
setNotice(null);
|
||||||
try {
|
try {
|
||||||
const payload = await configureChannel(
|
const payload = await configureChannel(
|
||||||
client,
|
token,
|
||||||
feature.name,
|
feature.name,
|
||||||
channelValuesForSave(instanceFields, fieldValues),
|
channelValuesForSave(instanceFields, fieldValues),
|
||||||
{ enable: selected.enabled, instanceId: selected.id },
|
{ enable: selected.enabled, instanceId: selected.id },
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import type {
|
|||||||
ChannelConnectPayload,
|
ChannelConnectPayload,
|
||||||
NanobotFeaturesPayload,
|
NanobotFeaturesPayload,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
import { useClient } from "@/providers/ClientProvider";
|
|
||||||
|
|
||||||
export type ChannelQrConnectLabels = {
|
export type ChannelQrConnectLabels = {
|
||||||
qrAlt: string;
|
qrAlt: string;
|
||||||
@@ -44,6 +43,7 @@ export type ChannelQrConnectPendingContext = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ChannelQrConnectFlow({
|
export function ChannelQrConnectFlow({
|
||||||
|
token,
|
||||||
channelName,
|
channelName,
|
||||||
startOptions = {},
|
startOptions = {},
|
||||||
idleLabel,
|
idleLabel,
|
||||||
@@ -69,7 +69,6 @@ export function ChannelQrConnectFlow({
|
|||||||
resolveMessage?: (payload: ChannelConnectPayload) => string | undefined;
|
resolveMessage?: (payload: ChannelConnectPayload) => string | undefined;
|
||||||
suppressSucceeded?: boolean;
|
suppressSucceeded?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { client } = useClient();
|
|
||||||
const pageVisible = usePageVisibility();
|
const pageVisible = usePageVisibility();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||||
@@ -79,6 +78,8 @@ export function ChannelQrConnectFlow({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [handledRequestId, setHandledRequestId] = useState(0);
|
const [handledRequestId, setHandledRequestId] = useState(0);
|
||||||
const pollInFlight = useRef(false);
|
const pollInFlight = useRef(false);
|
||||||
|
const tokenRef = useRef(token);
|
||||||
|
tokenRef.current = token;
|
||||||
const startDomain = startOptions.domain;
|
const startDomain = startOptions.domain;
|
||||||
const startInstanceId = startOptions.instanceId;
|
const startInstanceId = startOptions.instanceId;
|
||||||
const startMode = startOptions.mode;
|
const startMode = startOptions.mode;
|
||||||
@@ -128,7 +129,7 @@ export function ChannelQrConnectFlow({
|
|||||||
pollInFlight.current = true;
|
pollInFlight.current = true;
|
||||||
try {
|
try {
|
||||||
const payload = await pollChannelConnect(
|
const payload = await pollChannelConnect(
|
||||||
client,
|
tokenRef.current,
|
||||||
channelName,
|
channelName,
|
||||||
sessionId,
|
sessionId,
|
||||||
);
|
);
|
||||||
@@ -162,7 +163,6 @@ export function ChannelQrConnectFlow({
|
|||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
channelName,
|
channelName,
|
||||||
client,
|
|
||||||
connect?.interval_ms,
|
connect?.interval_ms,
|
||||||
connect?.session_id,
|
connect?.session_id,
|
||||||
connect?.status,
|
connect?.status,
|
||||||
@@ -175,7 +175,7 @@ export function ChannelQrConnectFlow({
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await startChannelConnect(client, channelName, {
|
const payload = await startChannelConnect(tokenRef.current, channelName, {
|
||||||
domain: startDomain,
|
domain: startDomain,
|
||||||
instanceId: startInstanceId,
|
instanceId: startInstanceId,
|
||||||
mode: startMode,
|
mode: startMode,
|
||||||
@@ -187,7 +187,7 @@ export function ChannelQrConnectFlow({
|
|||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}, [channelName, client, startDomain, startForce, startInstanceId, startMode]);
|
}, [channelName, startDomain, startForce, startInstanceId, startMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!connectRequestId || connectRequestId === handledRequestId) return;
|
if (!connectRequestId || connectRequestId === handledRequestId) return;
|
||||||
@@ -203,7 +203,7 @@ export function ChannelQrConnectFlow({
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const payload = await cancelChannelConnect(
|
const payload = await cancelChannelConnect(
|
||||||
client,
|
tokenRef.current,
|
||||||
channelName,
|
channelName,
|
||||||
connect.session_id,
|
connect.session_id,
|
||||||
);
|
);
|
||||||
@@ -223,9 +223,10 @@ export function ChannelQrConnectFlow({
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await pollChannelConnect(
|
const payload = await pollChannelConnect(
|
||||||
client,
|
tokenRef.current,
|
||||||
channelName,
|
channelName,
|
||||||
connect.session_id,
|
connect.session_id,
|
||||||
|
"",
|
||||||
params,
|
params,
|
||||||
);
|
);
|
||||||
setConnect((current) => ({
|
setConnect((current) => ({
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ import type {
|
|||||||
NanobotFeaturesPayload,
|
NanobotFeaturesPayload,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useClient } from "@/providers/ClientProvider";
|
|
||||||
|
|
||||||
export function ChannelCatalogRow({
|
export function ChannelCatalogRow({
|
||||||
feature,
|
feature,
|
||||||
@@ -149,6 +148,7 @@ export function ChannelSetupPanel({
|
|||||||
if (feature.instances !== undefined) {
|
if (feature.instances !== undefined) {
|
||||||
return (
|
return (
|
||||||
<ChannelInstancesPanel
|
<ChannelInstancesPanel
|
||||||
|
token={token}
|
||||||
feature={feature}
|
feature={feature}
|
||||||
showBrandLogos={showBrandLogos}
|
showBrandLogos={showBrandLogos}
|
||||||
chatAppsDocsUrl={chatAppsDocsUrl}
|
chatAppsDocsUrl={chatAppsDocsUrl}
|
||||||
@@ -269,7 +269,6 @@ function ChannelSetupSurface({
|
|||||||
ConnectFlow?: ComponentType<ChannelPluginConnectFlowProps>;
|
ConnectFlow?: ComponentType<ChannelPluginConnectFlowProps>;
|
||||||
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
||||||
}) {
|
}) {
|
||||||
const { client } = useClient();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||||
const [notice, setNotice] = useState<string | null>(null);
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
@@ -346,7 +345,7 @@ function ChannelSetupSurface({
|
|||||||
setNotice(null);
|
setNotice(null);
|
||||||
const values = channelValuesForSubmit(fields, fieldValues, touchedFields);
|
const values = channelValuesForSubmit(fields, fieldValues, touchedFields);
|
||||||
try {
|
try {
|
||||||
const validationPayload = await validateChannel(client, feature.name, values);
|
const validationPayload = await validateChannel(token, feature.name, values);
|
||||||
setValidation(validationPayload);
|
setValidation(validationPayload);
|
||||||
if (!validationPayload.can_enable) {
|
if (!validationPayload.can_enable) {
|
||||||
setNotice(
|
setNotice(
|
||||||
@@ -356,7 +355,7 @@ function ChannelSetupSurface({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = await configureChannel(
|
const payload = await configureChannel(
|
||||||
client,
|
token,
|
||||||
feature.name,
|
feature.name,
|
||||||
values,
|
values,
|
||||||
{ enable: true },
|
{ enable: true },
|
||||||
@@ -378,7 +377,7 @@ function ChannelSetupSurface({
|
|||||||
setNotice(null);
|
setNotice(null);
|
||||||
try {
|
try {
|
||||||
const payload = await validateChannel(
|
const payload = await validateChannel(
|
||||||
client,
|
token,
|
||||||
feature.name,
|
feature.name,
|
||||||
channelValuesForSubmit(fields, fieldValues, touchedFields),
|
channelValuesForSubmit(fields, fieldValues, touchedFields),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -179,11 +179,7 @@ function getVoiceShortcutLabel(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ThreadComposerProps {
|
interface ThreadComposerProps {
|
||||||
onSend: (
|
onSend: (content: string, images?: SendAttachment[], options?: SendOptions) => void;
|
||||||
content: string,
|
|
||||||
images?: SendAttachment[],
|
|
||||||
options?: SendOptions,
|
|
||||||
) => boolean | void | Promise<boolean | void>;
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
@@ -985,8 +981,6 @@ export function ThreadComposer({
|
|||||||
end: number;
|
end: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [inlineError, setInlineError] = useState<string | null>(null);
|
const [inlineError, setInlineError] = useState<string | null>(null);
|
||||||
const [sendPending, setSendPending] = useState(false);
|
|
||||||
const interactionDisabled = !!disabled || sendPending;
|
|
||||||
const [voiceErrorFading, setVoiceErrorFading] = useState(false);
|
const [voiceErrorFading, setVoiceErrorFading] = useState(false);
|
||||||
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
|
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
|
||||||
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
|
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
|
||||||
@@ -1077,7 +1071,7 @@ export function ThreadComposer({
|
|||||||
|
|
||||||
const addFiles = useCallback(
|
const addFiles = useCallback(
|
||||||
(files: File[]) => {
|
(files: File[]) => {
|
||||||
if (interactionDisabled || files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
secondEnterPromptIdRef.current = null;
|
secondEnterPromptIdRef.current = null;
|
||||||
const { rejected } = enqueue(files);
|
const { rejected } = enqueue(files);
|
||||||
if (rejected.length > 0) {
|
if (rejected.length > 0) {
|
||||||
@@ -1086,7 +1080,7 @@ export function ThreadComposer({
|
|||||||
setInlineError(null);
|
setInlineError(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[enqueue, formatRejection, interactionDisabled],
|
[enqueue, formatRejection],
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -1099,20 +1093,18 @@ export function ThreadComposer({
|
|||||||
} = useClipboardAndDrop(addFiles);
|
} = useClipboardAndDrop(addFiles);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (interactionDisabled || hasTouchPrimaryPointer || (workspaceError && showProjectPicker)) {
|
if (disabled || hasTouchPrimaryPointer) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const el = textareaRef.current;
|
const el = textareaRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const id = requestAnimationFrame(() => el.focus());
|
const id = requestAnimationFrame(() => el.focus());
|
||||||
return () => cancelAnimationFrame(id);
|
return () => cancelAnimationFrame(id);
|
||||||
}, [hasTouchPrimaryPointer, interactionDisabled, showProjectPicker, workspaceError]);
|
}, [disabled, hasTouchPrimaryPointer]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!focusRequest || interactionDisabled) return;
|
if (!focusRequest || disabled) return;
|
||||||
const id = requestAnimationFrame(() => textareaRef.current?.focus());
|
const id = requestAnimationFrame(() => textareaRef.current?.focus());
|
||||||
return () => cancelAnimationFrame(id);
|
return () => cancelAnimationFrame(id);
|
||||||
}, [focusRequest, interactionDisabled]);
|
}, [disabled, focusRequest]);
|
||||||
|
|
||||||
const normalizedQuotedContext = quotedContext?.trim().slice(0, QUEUED_PROMPT_MAX_CHARS) || null;
|
const normalizedQuotedContext = quotedContext?.trim().slice(0, QUEUED_PROMPT_MAX_CHARS) || null;
|
||||||
|
|
||||||
@@ -1126,17 +1118,15 @@ export function ThreadComposer({
|
|||||||
|
|
||||||
const hasComposerContent = value.trim().length > 0 || readyImages.length > 0;
|
const hasComposerContent = value.trim().length > 0 || readyImages.length > 0;
|
||||||
const canSend =
|
const canSend =
|
||||||
!interactionDisabled
|
!disabled
|
||||||
&& !modelNeedsSetup
|
&& !modelNeedsSetup
|
||||||
&& !encoding
|
&& !encoding
|
||||||
&& !hasErrors
|
&& !hasErrors
|
||||||
&& hasComposerContent;
|
&& hasComposerContent;
|
||||||
const canOpenModelSettings = Boolean(
|
const canOpenModelSettings = Boolean(modelNeedsSetup && onModelBadgeClick && !disabled);
|
||||||
modelNeedsSetup && onModelBadgeClick && !interactionDisabled,
|
|
||||||
);
|
|
||||||
const canQueueGuidance =
|
const canQueueGuidance =
|
||||||
isStreaming
|
isStreaming
|
||||||
&& !interactionDisabled
|
&& !disabled
|
||||||
&& !modelNeedsSetup
|
&& !modelNeedsSetup
|
||||||
&& !encoding
|
&& !encoding
|
||||||
&& !hasErrors
|
&& !hasErrors
|
||||||
@@ -1144,14 +1134,14 @@ export function ThreadComposer({
|
|||||||
&& !value.trimStart().startsWith("/");
|
&& !value.trimStart().startsWith("/");
|
||||||
|
|
||||||
const slashQuery = useMemo(() => {
|
const slashQuery = useMemo(() => {
|
||||||
if (interactionDisabled || slashMenuDismissed || !value.startsWith("/")) return null;
|
if (disabled || slashMenuDismissed || !value.startsWith("/")) return null;
|
||||||
const commandToken = value.slice(1);
|
const commandToken = value.slice(1);
|
||||||
if (/\s/.test(commandToken)) return null;
|
if (/\s/.test(commandToken)) return null;
|
||||||
return commandToken.toLowerCase();
|
return commandToken.toLowerCase();
|
||||||
}, [interactionDisabled, slashMenuDismissed, value]);
|
}, [disabled, slashMenuDismissed, value]);
|
||||||
|
|
||||||
const skillQuery = useMemo(() => {
|
const skillQuery = useMemo(() => {
|
||||||
if (interactionDisabled || slashMenuDismissed) return null;
|
if (disabled || slashMenuDismissed) return null;
|
||||||
const caret = Math.min(Math.max(cursorPosition, 0), value.length);
|
const caret = Math.min(Math.max(cursorPosition, 0), value.length);
|
||||||
const beforeCaret = value.slice(0, caret);
|
const beforeCaret = value.slice(0, caret);
|
||||||
const match = /\$([A-Za-z0-9_-]*)$/i.exec(beforeCaret);
|
const match = /\$([A-Za-z0-9_-]*)$/i.exec(beforeCaret);
|
||||||
@@ -1161,7 +1151,7 @@ export function ThreadComposer({
|
|||||||
start: match.index,
|
start: match.index,
|
||||||
text: match[1].toLowerCase(),
|
text: match[1].toLowerCase(),
|
||||||
};
|
};
|
||||||
}, [cursorPosition, interactionDisabled, slashMenuDismissed, value]);
|
}, [cursorPosition, disabled, slashMenuDismissed, value]);
|
||||||
|
|
||||||
const visibleSlashCommands = useMemo(() => {
|
const visibleSlashCommands = useMemo(() => {
|
||||||
if (!(isStreaming && onStop)) return slashCommands;
|
if (!(isStreaming && onStop)) return slashCommands;
|
||||||
@@ -1289,7 +1279,7 @@ export function ThreadComposer({
|
|||||||
|
|
||||||
const showSlashMenu = filteredSlashCommands.length > 0;
|
const showSlashMenu = filteredSlashCommands.length > 0;
|
||||||
const cliAppMention = useMemo<CliAppMentionQuery | null>(() => {
|
const cliAppMention = useMemo<CliAppMentionQuery | null>(() => {
|
||||||
if (interactionDisabled || cliAppMenuDismissed) return null;
|
if (disabled || cliAppMenuDismissed) return null;
|
||||||
const caret = Math.min(Math.max(cursorPosition, 0), value.length);
|
const caret = Math.min(Math.max(cursorPosition, 0), value.length);
|
||||||
const beforeCaret = value.slice(0, caret);
|
const beforeCaret = value.slice(0, caret);
|
||||||
const match = /(?:^|\s)@([\p{L}\p{N}_-]*)$/iu.exec(beforeCaret);
|
const match = /(?:^|\s)@([\p{L}\p{N}_-]*)$/iu.exec(beforeCaret);
|
||||||
@@ -1300,7 +1290,7 @@ export function ThreadComposer({
|
|||||||
start: caret - query.length - 1,
|
start: caret - query.length - 1,
|
||||||
end: caret,
|
end: caret,
|
||||||
};
|
};
|
||||||
}, [cliAppMenuDismissed, cursorPosition, interactionDisabled, value]);
|
}, [cliAppMenuDismissed, cursorPosition, disabled, value]);
|
||||||
|
|
||||||
const availableSessionMentions = useMemo(
|
const availableSessionMentions = useMemo(
|
||||||
() => sessionMentionOptions(
|
() => sessionMentionOptions(
|
||||||
@@ -1590,7 +1580,7 @@ export function ThreadComposer({
|
|||||||
}, VOICE_ERROR_VISIBLE_MS);
|
}, VOICE_ERROR_VISIBLE_MS);
|
||||||
}, [clearVoiceErrorTimers, t]);
|
}, [clearVoiceErrorTimers, t]);
|
||||||
const voiceRecorder = useVoiceRecorder({
|
const voiceRecorder = useVoiceRecorder({
|
||||||
disabled: interactionDisabled,
|
disabled,
|
||||||
onClearError: clearInlineError,
|
onClearError: clearInlineError,
|
||||||
onError: setVoiceError,
|
onError: setVoiceError,
|
||||||
onTranscript: appendTranscription,
|
onTranscript: appendTranscription,
|
||||||
@@ -1724,7 +1714,7 @@ export function ThreadComposer({
|
|||||||
clearDraggedSession();
|
clearDraggedSession();
|
||||||
const preview = sessionDragPreview;
|
const preview = sessionDragPreview;
|
||||||
setSessionDragPreview(null);
|
setSessionDragPreview(null);
|
||||||
if (interactionDisabled) return true;
|
if (disabled) return true;
|
||||||
const sessionKey = readDraggedSession(event.dataTransfer);
|
const sessionKey = readDraggedSession(event.dataTransfer);
|
||||||
const mention = availableSessionMentions.find(
|
const mention = availableSessionMentions.find(
|
||||||
(candidate) => candidate.session_key === (sessionKey ?? preview?.mention.session_key),
|
(candidate) => candidate.session_key === (sessionKey ?? preview?.mention.session_key),
|
||||||
@@ -1742,17 +1732,11 @@ export function ThreadComposer({
|
|||||||
preview?.end ?? textareaRef.current?.selectionEnd ?? caret,
|
preview?.end ?? textareaRef.current?.selectionEnd ?? caret,
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}, [
|
}, [availableSessionMentions, disabled, insertMentionCandidate, sessionDragPreview, value.length]);
|
||||||
availableSessionMentions,
|
|
||||||
insertMentionCandidate,
|
|
||||||
interactionDisabled,
|
|
||||||
sessionDragPreview,
|
|
||||||
value.length,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const previewSessionDrop = useCallback((event: React.DragEvent) => {
|
const previewSessionDrop = useCallback((event: React.DragEvent) => {
|
||||||
if (!hasDraggedSession(event.dataTransfer)) return false;
|
if (!hasDraggedSession(event.dataTransfer)) return false;
|
||||||
if (interactionDisabled) {
|
if (disabled) {
|
||||||
event.dataTransfer.dropEffect = "none";
|
event.dataTransfer.dropEffect = "none";
|
||||||
setSessionDragPreview(null);
|
setSessionDragPreview(null);
|
||||||
return true;
|
return true;
|
||||||
@@ -1781,7 +1765,7 @@ export function ThreadComposer({
|
|||||||
: { mention, start, end }
|
: { mention, start, end }
|
||||||
));
|
));
|
||||||
return true;
|
return true;
|
||||||
}, [activeSessionMentions, availableSessionMentions, interactionDisabled, value.length]);
|
}, [activeSessionMentions, availableSessionMentions, disabled, value.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sessionDragPreview) return;
|
if (!sessionDragPreview) return;
|
||||||
@@ -2024,16 +2008,7 @@ export function ThreadComposer({
|
|||||||
const isSlashSideChannel = isSideChannelLifecycle(slashLifecycle);
|
const isSlashSideChannel = isSideChannelLifecycle(slashLifecycle);
|
||||||
const finalizeActiveTurn =
|
const finalizeActiveTurn =
|
||||||
slashLifecycle === "finalize_active_turn";
|
slashLifecycle === "finalize_active_turn";
|
||||||
const finishSend = () => {
|
onSend(
|
||||||
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,
|
content,
|
||||||
payload,
|
payload,
|
||||||
isSlashSideChannel
|
isSlashSideChannel
|
||||||
@@ -2044,19 +2019,13 @@ export function ThreadComposer({
|
|||||||
}
|
}
|
||||||
: options,
|
: options,
|
||||||
);
|
);
|
||||||
if (result instanceof Promise) {
|
if (hasTouchPrimaryPointer) textareaRef.current?.blur();
|
||||||
setSendPending(true);
|
setQueuedPrompts([]);
|
||||||
void result
|
// Bubble owns the data URL copy; safe to revoke every staged blob
|
||||||
.then((accepted) => {
|
// preview here without affecting the rendered message.
|
||||||
if (accepted !== false) finishSend();
|
clear();
|
||||||
})
|
clearComposerText(!hasTouchPrimaryPointer);
|
||||||
.catch((error: unknown) => {
|
onQuotedContextChange?.(null);
|
||||||
console.error("Failed to send message", error);
|
|
||||||
})
|
|
||||||
.finally(() => setSendPending(false));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (result !== false) finishSend();
|
|
||||||
}, [
|
}, [
|
||||||
activeCliMentionApps,
|
activeCliMentionApps,
|
||||||
activeMcpPresetMentions,
|
activeMcpPresetMentions,
|
||||||
@@ -2199,7 +2168,7 @@ export function ThreadComposer({
|
|||||||
[removeChip],
|
[removeChip],
|
||||||
);
|
);
|
||||||
|
|
||||||
const attachButtonDisabled = interactionDisabled || full;
|
const attachButtonDisabled = disabled || full;
|
||||||
const showVoiceButton = Boolean(onTranscribeAudio);
|
const showVoiceButton = Boolean(onTranscribeAudio);
|
||||||
const voiceRecordingStatusLabel = t("thread.composer.voice.recordingStatus", {
|
const voiceRecordingStatusLabel = t("thread.composer.voice.recordingStatus", {
|
||||||
time: voiceRecorder.elapsedLabel,
|
time: voiceRecorder.elapsedLabel,
|
||||||
@@ -2284,7 +2253,7 @@ export function ThreadComposer({
|
|||||||
isHero
|
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-[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]",
|
: "max-w-[49.5rem] rounded-[22px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]",
|
||||||
interactionDisabled && "opacity-60",
|
disabled && "opacity-60",
|
||||||
sessionDragPreview && "ring-1 ring-primary/25",
|
sessionDragPreview && "ring-1 ring-primary/25",
|
||||||
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
|
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
|
||||||
goalState?.active &&
|
goalState?.active &&
|
||||||
@@ -2399,7 +2368,7 @@ export function ThreadComposer({
|
|||||||
onPaste={onPaste}
|
onPaste={onPaste}
|
||||||
rows={1}
|
rows={1}
|
||||||
placeholder={sessionDragPreview ? "" : resolvedPlaceholder}
|
placeholder={sessionDragPreview ? "" : resolvedPlaceholder}
|
||||||
disabled={interactionDisabled}
|
disabled={disabled}
|
||||||
aria-label={t("thread.composer.inputAria")}
|
aria-label={t("thread.composer.inputAria")}
|
||||||
className={cn(
|
className={cn(
|
||||||
inputTextClasses,
|
inputTextClasses,
|
||||||
@@ -2474,7 +2443,7 @@ export function ThreadComposer({
|
|||||||
) : workspaceScope && !workspaceControlsHidden ? (
|
) : workspaceScope && !workspaceControlsHidden ? (
|
||||||
<WorkspaceAccessMenu
|
<WorkspaceAccessMenu
|
||||||
scope={workspaceScope}
|
scope={workspaceScope}
|
||||||
disabled={interactionDisabled || workspaceScopeDisabled}
|
disabled={disabled || workspaceScopeDisabled}
|
||||||
canUseFullAccess={workspaceControls?.can_use_full_access !== false}
|
canUseFullAccess={workspaceControls?.can_use_full_access !== false}
|
||||||
isHero={isHero}
|
isHero={isHero}
|
||||||
onChange={onWorkspaceScopeChange}
|
onChange={onWorkspaceScopeChange}
|
||||||
@@ -2552,7 +2521,7 @@ export function ThreadComposer({
|
|||||||
<Button
|
<Button
|
||||||
type={showStopButton || modelNeedsSetup ? "button" : "submit"}
|
type={showStopButton || modelNeedsSetup ? "button" : "submit"}
|
||||||
size="icon"
|
size="icon"
|
||||||
disabled={showStopButton ? interactionDisabled : !canSend && !canOpenModelSettings}
|
disabled={showStopButton ? disabled : !canSend && !canOpenModelSettings}
|
||||||
aria-label={
|
aria-label={
|
||||||
showStopButton
|
showStopButton
|
||||||
? t("thread.composer.stop")
|
? t("thread.composer.stop")
|
||||||
@@ -2593,7 +2562,7 @@ export function ThreadComposer({
|
|||||||
<div className="composer-workspace-drawer-content">
|
<div className="composer-workspace-drawer-content">
|
||||||
<WorkspaceProjectPicker
|
<WorkspaceProjectPicker
|
||||||
isHero={isHero}
|
isHero={isHero}
|
||||||
disabled={interactionDisabled || workspaceScopeDisabled || !showProjectPicker}
|
disabled={disabled || workspaceScopeDisabled || !showProjectPicker}
|
||||||
scope={workspaceScope}
|
scope={workspaceScope}
|
||||||
defaultScope={workspaceDefaultScope}
|
defaultScope={workspaceDefaultScope}
|
||||||
controls={workspaceControls}
|
controls={workspaceControls}
|
||||||
|
|||||||
@@ -1261,7 +1261,7 @@ export function ThreadShell({
|
|||||||
|
|
||||||
const handleWelcomeSend = useCallback(
|
const handleWelcomeSend = useCallback(
|
||||||
async (content: string, images?: SendAttachment[], options?: SendOptions) => {
|
async (content: string, images?: SendAttachment[], options?: SendOptions) => {
|
||||||
if (booting) return false;
|
if (booting) return;
|
||||||
setBooting(true);
|
setBooting(true);
|
||||||
pendingFirstRef.current = { content, images, options: withWorkspaceScope(options) };
|
pendingFirstRef.current = { content, images, options: withWorkspaceScope(options) };
|
||||||
setPendingFirstTargetChatId(null);
|
setPendingFirstTargetChatId(null);
|
||||||
@@ -1270,13 +1270,12 @@ export function ThreadShell({
|
|||||||
pendingFirstRef.current = null;
|
pendingFirstRef.current = null;
|
||||||
setPendingFirstTargetChatId(null);
|
setPendingFirstTargetChatId(null);
|
||||||
setBooting(false);
|
setBooting(false);
|
||||||
return false;
|
return;
|
||||||
}
|
}
|
||||||
if (localModelPreset) {
|
if (localModelPreset) {
|
||||||
await client.sendSystemCommand(newId, `/model ${localModelPreset}`).catch(() => {});
|
await client.sendSystemCommand(newId, `/model ${localModelPreset}`).catch(() => {});
|
||||||
}
|
}
|
||||||
setPendingFirstTargetChatId(newId);
|
setPendingFirstTargetChatId(newId);
|
||||||
return true;
|
|
||||||
},
|
},
|
||||||
[booting, client, localModelPreset, onCreateChat, withWorkspaceScope, workspaceScope],
|
[booting, client, localModelPreset, onCreateChat, withWorkspaceScope, workspaceScope],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||||
import { AlertTriangle, Check, ChevronDown, Folder, Hand } from "lucide-react";
|
import { AlertTriangle, Check, ChevronDown, Folder, Hand } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -34,14 +34,6 @@ import {
|
|||||||
shortWorkspacePath,
|
shortWorkspacePath,
|
||||||
} from "@/lib/workspace";
|
} 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({
|
export function WorkspaceProjectPicker({
|
||||||
isHero,
|
isHero,
|
||||||
compact = false,
|
compact = false,
|
||||||
@@ -68,9 +60,6 @@ export function WorkspaceProjectPicker({
|
|||||||
const [pathDraft, setPathDraft] = useState("");
|
const [pathDraft, setPathDraft] = useState("");
|
||||||
const [pathError, setPathError] = useState<string | null>(null);
|
const [pathError, setPathError] = useState<string | null>(null);
|
||||||
const [pickingFolder, setPickingFolder] = useState(false);
|
const [pickingFolder, setPickingFolder] = useState(false);
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const pathInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const pathErrorId = useId();
|
|
||||||
const currentProjectScope = selectedProjectScope(scope, defaultScope);
|
const currentProjectScope = selectedProjectScope(scope, defaultScope);
|
||||||
const projectLabel = currentProjectScope
|
const projectLabel = currentProjectScope
|
||||||
? currentProjectScope.project_name || projectNameFromPath(currentProjectScope.project_path)
|
? currentProjectScope.project_name || projectNameFromPath(currentProjectScope.project_path)
|
||||||
@@ -93,17 +82,9 @@ export function WorkspaceProjectPicker({
|
|||||||
}, [disabled]);
|
}, [disabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!error || !visible || disabled) return;
|
if (error && visible && !disabled) setOpen(true);
|
||||||
const frame = window.requestAnimationFrame(() => triggerRef.current?.focus());
|
|
||||||
return () => window.cancelAnimationFrame(frame);
|
|
||||||
}, [disabled, error, visible]);
|
}, [disabled, error, visible]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open || !error) return;
|
|
||||||
const frame = window.requestAnimationFrame(() => pathInputRef.current?.focus());
|
|
||||||
return () => window.cancelAnimationFrame(frame);
|
|
||||||
}, [error, open]);
|
|
||||||
|
|
||||||
const applyProjectPath = useCallback(
|
const applyProjectPath = useCallback(
|
||||||
(projectPath: string, projectName?: string) => {
|
(projectPath: string, projectName?: string) => {
|
||||||
const base = scope ?? defaultScope;
|
const base = scope ?? defaultScope;
|
||||||
@@ -148,7 +129,6 @@ 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",
|
: "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
|
<button
|
||||||
ref={triggerRef}
|
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled || pickingFolder}
|
disabled={disabled || pickingFolder}
|
||||||
aria-label={t("thread.composer.workspace.projectAria")}
|
aria-label={t("thread.composer.workspace.projectAria")}
|
||||||
@@ -184,7 +164,6 @@ export function WorkspaceProjectPicker({
|
|||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-label={t("thread.composer.workspace.projectAria")}
|
aria-label={t("thread.composer.workspace.projectAria")}
|
||||||
@@ -242,20 +221,14 @@ export function WorkspaceProjectPicker({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
ref={pathInputRef}
|
|
||||||
value={pathDraft}
|
value={pathDraft}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setPathDraft(event.target.value);
|
setPathDraft(event.target.value);
|
||||||
setPathError(null);
|
setPathError(null);
|
||||||
}}
|
}}
|
||||||
placeholder={workspacePathPlaceholder(
|
placeholder={t("workspace.dialog.manualPlaceholder")}
|
||||||
defaultScope.project_path,
|
|
||||||
t("workspace.dialog.manualPlaceholder"),
|
|
||||||
)}
|
|
||||||
aria-label={t("workspace.dialog.manual")}
|
aria-label={t("workspace.dialog.manual")}
|
||||||
aria-invalid={pathError || error ? true : undefined}
|
|
||||||
aria-describedby={pathError || error ? pathErrorId : undefined}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-9 rounded-full border-border/55 bg-background/80 px-3 text-[12.5px]",
|
"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",
|
"focus-visible:ring-1 focus-visible:ring-foreground/10 focus-visible:ring-offset-0",
|
||||||
@@ -270,22 +243,13 @@ export function WorkspaceProjectPicker({
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
{pathError || error ? (
|
{pathError || error ? (
|
||||||
<p
|
<p role="alert" className="px-1 text-[11.5px] font-medium text-destructive">
|
||||||
id={pathErrorId}
|
|
||||||
role="alert"
|
|
||||||
className="px-1 text-[11.5px] font-medium text-destructive"
|
|
||||||
>
|
|
||||||
{pathError ?? error}
|
{pathError ?? error}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,23 +10,6 @@ import {
|
|||||||
} from "@/lib/tool-traces";
|
} from "@/lib/tool-traces";
|
||||||
import { hasPendingAgentActivity } from "@/lib/activity-timeline";
|
import { hasPendingAgentActivity } from "@/lib/activity-timeline";
|
||||||
import type { StreamError } from "@/lib/nanobot-client";
|
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 { formatQuotedUserMessage } from "@/lib/user-message-quote";
|
||||||
import type {
|
import type {
|
||||||
InboundEvent,
|
InboundEvent,
|
||||||
@@ -36,8 +19,11 @@ import type {
|
|||||||
SessionMention,
|
SessionMention,
|
||||||
GoalStateWsPayload,
|
GoalStateWsPayload,
|
||||||
MessageDeliveryStatus,
|
MessageDeliveryStatus,
|
||||||
|
ToolProgressEvent,
|
||||||
UIMediaAttachment,
|
UIMediaAttachment,
|
||||||
|
UIFileEdit,
|
||||||
UIMessage,
|
UIMessage,
|
||||||
|
UITurnPhase,
|
||||||
WorkspaceScopePayload,
|
WorkspaceScopePayload,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
|
||||||
@@ -55,9 +41,54 @@ type PendingStreamEvent =
|
|||||||
| { kind: "delta"; text: string; turn: UIMessageTurnFields; source?: UIMessage["source"] }
|
| { kind: "delta"; text: string; turn: UIMessageTurnFields; source?: UIMessage["source"] }
|
||||||
| { kind: "reasoning"; text: string; turn: UIMessageTurnFields };
|
| { 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 STREAM_END_IDLE_DELAY_MS = 1000;
|
||||||
const BACKGROUND_STREAM_FLUSH_INTERVAL_MS = 1_000;
|
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``.
|
* Append a reasoning chunk to the last open reasoning stream in ``prev``.
|
||||||
*
|
*
|
||||||
@@ -121,6 +152,102 @@ 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(
|
function absorbCompleteAssistantMessage(
|
||||||
prev: UIMessage[],
|
prev: UIMessage[],
|
||||||
message: Omit<UIMessage, "id" | "role" | "createdAt">,
|
message: Omit<UIMessage, "id" | "role" | "createdAt">,
|
||||||
@@ -148,6 +275,193 @@ 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,
|
* 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
|
* a streaming flag, and a ``send`` function. Initial history must be seeded
|
||||||
@@ -193,6 +507,17 @@ function eventExtendsModelActivity(ev: InboundEvent): boolean {
|
|||||||
&& (ev.kind === "tool_hint" || ev.kind === "progress" || ev.kind === "reasoning");
|
&& (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 {
|
function eventTurnId(ev: InboundEvent): string | undefined {
|
||||||
return "turn_id" in ev && typeof ev.turn_id === "string" ? ev.turn_id : undefined;
|
return "turn_id" in ev && typeof ev.turn_id === "string" ? ev.turn_id : undefined;
|
||||||
}
|
}
|
||||||
@@ -779,7 +1104,7 @@ export function useNanobotStream(
|
|||||||
|
|
||||||
if (ev.event === "reasoning_end") {
|
if (ev.event === "reasoning_end") {
|
||||||
if (suppressStreamUntilTurnEndRef.current) return;
|
if (suppressStreamUntilTurnEndRef.current) return;
|
||||||
setMessages((prev) => closeReasoningStream(prev, Date.now()));
|
setMessages((prev) => closeReasoningStream(prev));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -849,15 +1174,12 @@ export function useNanobotStream(
|
|||||||
const line = ev.text;
|
const line = ev.text;
|
||||||
if (!line) return;
|
if (!line) return;
|
||||||
if (fileEditSegmentRef.current) clearActivitySegment();
|
if (fileEditSegmentRef.current) clearActivitySegment();
|
||||||
setMessages((prev) => closeReasoningStream(
|
setMessages((prev) => closeReasoningStream(attachReasoningChunk(
|
||||||
attachReasoningChunk(
|
prev,
|
||||||
prev,
|
line,
|
||||||
line,
|
{ ensure: ensureActivitySegmentId },
|
||||||
{ ensure: ensureActivitySegmentId },
|
turnFieldsFromEvent(ev, "reasoning"),
|
||||||
turnFieldsFromEvent(ev, "reasoning"),
|
)));
|
||||||
),
|
|
||||||
Date.now(),
|
|
||||||
));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Intermediate agent breadcrumbs (tool-call hints, raw progress).
|
// Intermediate agent breadcrumbs (tool-call hints, raw progress).
|
||||||
|
|||||||
@@ -257,13 +257,13 @@ export function useSessions(): {
|
|||||||
|
|
||||||
const deleteChat = useCallback(
|
const deleteChat = useCallback(
|
||||||
async (key: string, options?: { deleteAutomations?: boolean }) => {
|
async (key: string, options?: { deleteAutomations?: boolean }) => {
|
||||||
const result = await apiDeleteSession(client, key, options);
|
const result = await apiDeleteSession(tokenRef.current, key, options);
|
||||||
if (!result.deleted) return result;
|
if (!result.deleted) return result;
|
||||||
optimisticKeysRef.current.delete(key);
|
optimisticKeysRef.current.delete(key);
|
||||||
setSessions((prev) => prev.filter((s) => s.key !== key));
|
setSessions((prev) => prev.filter((s) => s.key !== key));
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
[client],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getSessionAutomations = useCallback(async (key: string) => {
|
const getSessionAutomations = useCallback(async (key: string) => {
|
||||||
|
|||||||
@@ -144,8 +144,6 @@ export function useSidebarState(
|
|||||||
const { client, token } = useClient();
|
const { client, token } = useClient();
|
||||||
const tokenRef = useRef(token);
|
const tokenRef = useRef(token);
|
||||||
const stateRef = useRef(DEFAULT_SIDEBAR_STATE);
|
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 [state, setState] = useState<SidebarStatePayload>(DEFAULT_SIDEBAR_STATE);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
tokenRef.current = token;
|
tokenRef.current = token;
|
||||||
@@ -173,32 +171,14 @@ 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(
|
const update = useCallback(
|
||||||
async (updater: (current: SidebarStatePayload) => SidebarStatePayload) => {
|
async (updater: (current: SidebarStatePayload) => SidebarStatePayload) => {
|
||||||
const next = normalizeSidebarState(updater(stateRef.current));
|
const next = normalizeSidebarState(updater(stateRef.current));
|
||||||
stateRef.current = next;
|
stateRef.current = next;
|
||||||
setState(next);
|
setState(next);
|
||||||
persist(next);
|
client.setSidebarState(next);
|
||||||
},
|
},
|
||||||
[persist],
|
[client],
|
||||||
);
|
);
|
||||||
|
|
||||||
const pruned = useMemo(() => {
|
const pruned = useMemo(() => {
|
||||||
|
|||||||
@@ -10,12 +10,11 @@
|
|||||||
"gatewayHint": "Make sure the gateway is running (`nanobot gateway`) and that this page is open on the same machine."
|
"gatewayHint": "Make sure the gateway is running (`nanobot gateway`) and that this page is open on the same machine."
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"label": "Password",
|
"title": "Authentication required",
|
||||||
"showPassword": "Show password",
|
"hint": "Enter the secret configured as tokenIssueSecret in your gateway config.",
|
||||||
"hidePassword": "Hide password",
|
"placeholder": "Password",
|
||||||
"submit": "Connect",
|
"submit": "Connect",
|
||||||
"required": "Enter your password.",
|
"invalid": "Invalid password. Try again."
|
||||||
"invalid": "Incorrect password. Try again."
|
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"section": "Account",
|
"section": "Account",
|
||||||
@@ -596,14 +595,7 @@
|
|||||||
"searchPlaceholder": "Search tools",
|
"searchPlaceholder": "Search tools",
|
||||||
"featured": "Tools",
|
"featured": "Tools",
|
||||||
"loading": "Loading Apps...",
|
"loading": "Loading Apps...",
|
||||||
"empty": "No tools match your search.",
|
"empty": "No tools match this view.",
|
||||||
"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."
|
"restartRequired": "Restart nanobot to apply updated apps and integrations."
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
@@ -706,9 +698,7 @@
|
|||||||
"loading": "Loading automations...",
|
"loading": "Loading automations...",
|
||||||
"noMatches": "No automations match this view.",
|
"noMatches": "No automations match this view.",
|
||||||
"empty": "No automations yet.",
|
"empty": "No automations yet.",
|
||||||
"emptyHint": "Create automations in a chat so they keep the right context.",
|
"emptyHint": "Create one from where it should run so nanobot keeps the right context.",
|
||||||
"emptyAction": "Open a chat",
|
|
||||||
"clearFilters": "Clear filters",
|
|
||||||
"oneShot": "One-time",
|
"oneShot": "One-time",
|
||||||
"systemTask": "System-managed automation",
|
"systemTask": "System-managed automation",
|
||||||
"localTrigger": "Local trigger",
|
"localTrigger": "Local trigger",
|
||||||
@@ -1391,7 +1381,7 @@
|
|||||||
},
|
},
|
||||||
"workspaceScopeRejected": {
|
"workspaceScopeRejected": {
|
||||||
"title": "Workspace was not changed",
|
"title": "Workspace was not changed",
|
||||||
"body": "The gateway rejected this project or access mode. Choose an existing project or a different access mode, then try again."
|
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
|
||||||
},
|
},
|
||||||
"turnRejected": {
|
"turnRejected": {
|
||||||
"title": "Message was not sent",
|
"title": "Message was not sent",
|
||||||
|
|||||||
@@ -10,12 +10,11 @@
|
|||||||
"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."
|
"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": {
|
"auth": {
|
||||||
"label": "Contraseña",
|
"title": "Autenticación requerida",
|
||||||
"showPassword": "Mostrar contraseña",
|
"hint": "Introduce el secreto configurado como tokenIssueSecret en la configuración del gateway.",
|
||||||
"hidePassword": "Ocultar contraseña",
|
"placeholder": "Contraseña",
|
||||||
"submit": "Conectar",
|
"submit": "Conectar",
|
||||||
"required": "Introduce la contraseña.",
|
"invalid": "Contraseña no válida. Inténtalo de nuevo."
|
||||||
"invalid": "Contraseña incorrecta. Inténtalo de nuevo."
|
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"section": "Cuenta",
|
"section": "Cuenta",
|
||||||
@@ -583,14 +582,7 @@
|
|||||||
"searchPlaceholder": "Buscar aplicaciones",
|
"searchPlaceholder": "Buscar aplicaciones",
|
||||||
"featured": "Herramientas",
|
"featured": "Herramientas",
|
||||||
"loading": "Cargando aplicaciones...",
|
"loading": "Cargando aplicaciones...",
|
||||||
"empty": "Ninguna herramienta coincide con tu búsqueda.",
|
"empty": "Ninguna herramienta coincide con esta vista.",
|
||||||
"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."
|
"restartRequired": "Reinicia nanobot para aplicar las aplicaciones y funciones actualizadas."
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
@@ -693,9 +685,7 @@
|
|||||||
"loading": "Cargando automatizaciones...",
|
"loading": "Cargando automatizaciones...",
|
||||||
"noMatches": "No hay automatizaciones que coincidan con esta vista.",
|
"noMatches": "No hay automatizaciones que coincidan con esta vista.",
|
||||||
"empty": "Aún no hay automatizaciones.",
|
"empty": "Aún no hay automatizaciones.",
|
||||||
"emptyHint": "Crea automatizaciones en un chat para que conserven el contexto correcto.",
|
"emptyHint": "Créala desde donde debe ejecutarse para que nanobot conserve el contexto correcto.",
|
||||||
"emptyAction": "Abrir un chat",
|
|
||||||
"clearFilters": "Borrar filtros",
|
|
||||||
"oneShot": "Una vez",
|
"oneShot": "Una vez",
|
||||||
"systemTask": "Automatización administrada por el sistema",
|
"systemTask": "Automatización administrada por el sistema",
|
||||||
"localTrigger": "Activador local",
|
"localTrigger": "Activador local",
|
||||||
@@ -1378,7 +1368,7 @@
|
|||||||
},
|
},
|
||||||
"workspaceScopeRejected": {
|
"workspaceScopeRejected": {
|
||||||
"title": "El espacio de trabajo no cambió",
|
"title": "El espacio de trabajo no cambió",
|
||||||
"body": "El gateway rechazó este proyecto o modo de acceso. Elige un proyecto existente u otro modo de acceso e inténtalo de nuevo."
|
"body": "El gateway rechazó el proyecto o modo de acceso solicitado, así que Nanobot conservó el espacio de trabajo anterior."
|
||||||
},
|
},
|
||||||
"turnRejected": {
|
"turnRejected": {
|
||||||
"title": "El mensaje no se envió",
|
"title": "El mensaje no se envió",
|
||||||
|
|||||||
@@ -10,12 +10,11 @@
|
|||||||
"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."
|
"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": {
|
"auth": {
|
||||||
"label": "Mot de passe",
|
"title": "Authentification requise",
|
||||||
"showPassword": "Afficher le mot de passe",
|
"hint": "Saisissez le secret configuré comme tokenIssueSecret dans la configuration de votre gateway.",
|
||||||
"hidePassword": "Masquer le mot de passe",
|
"placeholder": "Mot de passe",
|
||||||
"submit": "Se connecter",
|
"submit": "Se connecter",
|
||||||
"required": "Saisissez le mot de passe.",
|
"invalid": "Mot de passe invalide. Réessayez."
|
||||||
"invalid": "Mot de passe incorrect. Réessayez."
|
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"section": "Compte",
|
"section": "Compte",
|
||||||
@@ -582,14 +581,7 @@
|
|||||||
"searchPlaceholder": "Rechercher des applications",
|
"searchPlaceholder": "Rechercher des applications",
|
||||||
"featured": "Outils",
|
"featured": "Outils",
|
||||||
"loading": "Chargement des applications...",
|
"loading": "Chargement des applications...",
|
||||||
"empty": "Aucun outil ne correspond à votre recherche.",
|
"empty": "Aucun outil ne correspond à cette vue.",
|
||||||
"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."
|
"restartRequired": "Redémarrez nanobot pour appliquer les applications et fonctions mises à jour."
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
@@ -692,9 +684,7 @@
|
|||||||
"loading": "Chargement des automatisations...",
|
"loading": "Chargement des automatisations...",
|
||||||
"noMatches": "Aucune automatisation ne correspond à cette vue.",
|
"noMatches": "Aucune automatisation ne correspond à cette vue.",
|
||||||
"empty": "Aucune automatisation pour le moment.",
|
"empty": "Aucune automatisation pour le moment.",
|
||||||
"emptyHint": "Créez les automatisations dans un chat afin de conserver le bon contexte.",
|
"emptyHint": "Créez-la depuis son point d'exécution pour que nanobot conserve le bon contexte.",
|
||||||
"emptyAction": "Ouvrir un chat",
|
|
||||||
"clearFilters": "Effacer les filtres",
|
|
||||||
"oneShot": "Ponctuelle",
|
"oneShot": "Ponctuelle",
|
||||||
"systemTask": "Automatisation gérée par le système",
|
"systemTask": "Automatisation gérée par le système",
|
||||||
"localTrigger": "Déclencheur local",
|
"localTrigger": "Déclencheur local",
|
||||||
@@ -1377,7 +1367,7 @@
|
|||||||
},
|
},
|
||||||
"workspaceScopeRejected": {
|
"workspaceScopeRejected": {
|
||||||
"title": "L’espace de travail n’a pas changé",
|
"title": "L’espace de travail n’a pas changé",
|
||||||
"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."
|
"body": "La passerelle a refusé le projet ou le mode d’accès demandé ; Nanobot a conservé l’espace de travail précédent."
|
||||||
},
|
},
|
||||||
"turnRejected": {
|
"turnRejected": {
|
||||||
"title": "Le message n’a pas été envoyé",
|
"title": "Le message n’a pas été envoyé",
|
||||||
|
|||||||
@@ -10,12 +10,11 @@
|
|||||||
"gatewayHint": "Pastikan gateway sedang berjalan (`nanobot gateway`) dan halaman ini dibuka pada mesin yang sama."
|
"gatewayHint": "Pastikan gateway sedang berjalan (`nanobot gateway`) dan halaman ini dibuka pada mesin yang sama."
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"label": "Kata sandi",
|
"title": "Autentikasi diperlukan",
|
||||||
"showPassword": "Tampilkan kata sandi",
|
"hint": "Masukkan secret yang dikonfigurasi sebagai tokenIssueSecret di konfigurasi gateway.",
|
||||||
"hidePassword": "Sembunyikan kata sandi",
|
"placeholder": "Kata sandi",
|
||||||
"submit": "Hubungkan",
|
"submit": "Hubungkan",
|
||||||
"required": "Masukkan kata sandi.",
|
"invalid": "Kata sandi tidak valid. Coba lagi."
|
||||||
"invalid": "Kata sandi salah. Coba lagi."
|
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"section": "Akun",
|
"section": "Akun",
|
||||||
@@ -582,14 +581,7 @@
|
|||||||
"searchPlaceholder": "Cari aplikasi",
|
"searchPlaceholder": "Cari aplikasi",
|
||||||
"featured": "Alat",
|
"featured": "Alat",
|
||||||
"loading": "Memuat aplikasi...",
|
"loading": "Memuat aplikasi...",
|
||||||
"empty": "Tidak ada alat yang cocok dengan pencarian Anda.",
|
"empty": "Tidak ada alat yang cocok dengan tampilan ini.",
|
||||||
"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."
|
"restartRequired": "Mulai ulang nanobot untuk menerapkan aplikasi dan fitur yang diperbarui."
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
@@ -692,9 +684,7 @@
|
|||||||
"loading": "Memuat otomasi...",
|
"loading": "Memuat otomasi...",
|
||||||
"noMatches": "Tidak ada otomasi yang cocok dengan tampilan ini.",
|
"noMatches": "Tidak ada otomasi yang cocok dengan tampilan ini.",
|
||||||
"empty": "Belum ada otomasi.",
|
"empty": "Belum ada otomasi.",
|
||||||
"emptyHint": "Buat otomatisasi di chat agar konteks yang tepat tetap tersimpan.",
|
"emptyHint": "Buat dari tempat tugas ini berjalan agar nanobot menyimpan konteks yang tepat.",
|
||||||
"emptyAction": "Buka chat",
|
|
||||||
"clearFilters": "Hapus filter",
|
|
||||||
"oneShot": "Satu kali",
|
"oneShot": "Satu kali",
|
||||||
"systemTask": "Automasi yang dikelola sistem",
|
"systemTask": "Automasi yang dikelola sistem",
|
||||||
"localTrigger": "Pemicu lokal",
|
"localTrigger": "Pemicu lokal",
|
||||||
@@ -1377,7 +1367,7 @@
|
|||||||
},
|
},
|
||||||
"workspaceScopeRejected": {
|
"workspaceScopeRejected": {
|
||||||
"title": "Ruang kerja tidak berubah",
|
"title": "Ruang kerja tidak berubah",
|
||||||
"body": "Gateway menolak proyek atau mode akses ini. Pilih proyek yang sudah ada atau mode akses lain, lalu coba lagi."
|
"body": "Gateway menolak proyek atau mode akses yang diminta, jadi Nanobot tetap memakai ruang kerja sebelumnya."
|
||||||
},
|
},
|
||||||
"turnRejected": {
|
"turnRejected": {
|
||||||
"title": "Pesan tidak terkirim",
|
"title": "Pesan tidak terkirim",
|
||||||
|
|||||||
@@ -10,12 +10,11 @@
|
|||||||
"gatewayHint": "gateway(`nanobot gateway`)が起動しており、このページが同じマシン上で開かれていることを確認してください。"
|
"gatewayHint": "gateway(`nanobot gateway`)が起動しており、このページが同じマシン上で開かれていることを確認してください。"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"label": "パスワード",
|
"title": "認証が必要です",
|
||||||
"showPassword": "パスワードを表示",
|
"hint": "gateway 設定の tokenIssueSecret に指定されたシークレットを入力してください。",
|
||||||
"hidePassword": "パスワードを隠す",
|
"placeholder": "パスワード",
|
||||||
"submit": "接続",
|
"submit": "接続",
|
||||||
"required": "パスワードを入力してください。",
|
"invalid": "パスワードが無効です。もう一度お試しください。"
|
||||||
"invalid": "パスワードが正しくありません。もう一度お試しください。"
|
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"section": "アカウント",
|
"section": "アカウント",
|
||||||
@@ -582,14 +581,7 @@
|
|||||||
"searchPlaceholder": "アプリを検索",
|
"searchPlaceholder": "アプリを検索",
|
||||||
"featured": "ツール",
|
"featured": "ツール",
|
||||||
"loading": "アプリを読み込み中...",
|
"loading": "アプリを読み込み中...",
|
||||||
"empty": "検索条件に一致するツールはありません。",
|
"empty": "この表示に一致するツールはありません。",
|
||||||
"emptyApps": "利用できるアプリはありません。",
|
|
||||||
"emptyIntegrations": "利用できる連携はありません。",
|
|
||||||
"emptyReady": "使用可能なツールはまだありません。",
|
|
||||||
"clearSearch": "検索をクリア",
|
|
||||||
"browseApps": "アプリを見る",
|
|
||||||
"browseIntegrations": "連携を見る",
|
|
||||||
"emptyIntegrationsHint": "下からカスタム連携を追加できます。",
|
|
||||||
"restartRequired": "更新したアプリと機能を反映するには nanobot を再起動してください。"
|
"restartRequired": "更新したアプリと機能を反映するには nanobot を再起動してください。"
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
@@ -692,9 +684,7 @@
|
|||||||
"loading": "自動タスクを読み込み中...",
|
"loading": "自動タスクを読み込み中...",
|
||||||
"noMatches": "この表示に一致する自動タスクはありません。",
|
"noMatches": "この表示に一致する自動タスクはありません。",
|
||||||
"empty": "自動タスクはまだありません。",
|
"empty": "自動タスクはまだありません。",
|
||||||
"emptyHint": "正しいコンテキストを保持するには、チャットで自動化を作成してください。",
|
"emptyHint": "実行元から作成すると、nanobot が正しいコンテキストを保持できます。",
|
||||||
"emptyAction": "チャットを開く",
|
|
||||||
"clearFilters": "フィルターをクリア",
|
|
||||||
"oneShot": "一回限り",
|
"oneShot": "一回限り",
|
||||||
"systemTask": "システム管理の自動タスク",
|
"systemTask": "システム管理の自動タスク",
|
||||||
"localTrigger": "ローカルトリガー",
|
"localTrigger": "ローカルトリガー",
|
||||||
@@ -1377,7 +1367,7 @@
|
|||||||
},
|
},
|
||||||
"workspaceScopeRejected": {
|
"workspaceScopeRejected": {
|
||||||
"title": "ワークスペースは変更されませんでした",
|
"title": "ワークスペースは変更されませんでした",
|
||||||
"body": "このプロジェクトまたはアクセスモードはゲートウェイに拒否されました。既存のプロジェクトまたは別のアクセスモードを選択して、もう一度お試しください。"
|
"body": "要求されたプロジェクトまたはアクセスモードがゲートウェイで拒否されたため、Nanobot は以前のワークスペースをそのまま使用しています。"
|
||||||
},
|
},
|
||||||
"turnRejected": {
|
"turnRejected": {
|
||||||
"title": "メッセージは送信されませんでした",
|
"title": "メッセージは送信されませんでした",
|
||||||
|
|||||||
@@ -10,11 +10,10 @@
|
|||||||
"gatewayHint": "gateway(`nanobot gateway`)가 실행 중인지, 그리고 이 페이지가 같은 머신에서 열려 있는지 확인하세요."
|
"gatewayHint": "gateway(`nanobot gateway`)가 실행 중인지, 그리고 이 페이지가 같은 머신에서 열려 있는지 확인하세요."
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"label": "비밀번호",
|
"title": "인증이 필요합니다",
|
||||||
"showPassword": "비밀번호 표시",
|
"hint": "gateway 설정의 tokenIssueSecret에 구성된 비밀 값을 입력하세요.",
|
||||||
"hidePassword": "비밀번호 숨기기",
|
"placeholder": "비밀번호",
|
||||||
"submit": "연결",
|
"submit": "연결",
|
||||||
"required": "비밀번호를 입력하세요.",
|
|
||||||
"invalid": "비밀번호가 올바르지 않습니다. 다시 시도하세요."
|
"invalid": "비밀번호가 올바르지 않습니다. 다시 시도하세요."
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
@@ -582,14 +581,7 @@
|
|||||||
"searchPlaceholder": "앱 검색",
|
"searchPlaceholder": "앱 검색",
|
||||||
"featured": "도구",
|
"featured": "도구",
|
||||||
"loading": "앱을 불러오는 중...",
|
"loading": "앱을 불러오는 중...",
|
||||||
"empty": "검색과 일치하는 도구가 없습니다.",
|
"empty": "이 보기에 일치하는 도구가 없습니다.",
|
||||||
"emptyApps": "사용 가능한 앱이 없습니다.",
|
|
||||||
"emptyIntegrations": "사용 가능한 연동이 없습니다.",
|
|
||||||
"emptyReady": "아직 준비된 도구가 없습니다.",
|
|
||||||
"clearSearch": "검색 지우기",
|
|
||||||
"browseApps": "앱 둘러보기",
|
|
||||||
"browseIntegrations": "연동 둘러보기",
|
|
||||||
"emptyIntegrationsHint": "아래에서 사용자 지정 연동을 추가하세요.",
|
|
||||||
"restartRequired": "업데이트된 앱과 기능을 적용하려면 nanobot을 다시 시작하세요."
|
"restartRequired": "업데이트된 앱과 기능을 적용하려면 nanobot을 다시 시작하세요."
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
@@ -692,9 +684,7 @@
|
|||||||
"loading": "자동화를 불러오는 중...",
|
"loading": "자동화를 불러오는 중...",
|
||||||
"noMatches": "이 보기와 일치하는 자동화가 없습니다.",
|
"noMatches": "이 보기와 일치하는 자동화가 없습니다.",
|
||||||
"empty": "아직 자동화가 없습니다.",
|
"empty": "아직 자동화가 없습니다.",
|
||||||
"emptyHint": "올바른 컨텍스트를 유지하려면 채팅에서 자동화를 만드세요.",
|
"emptyHint": "실행될 위치에서 만들면 nanobot이 올바른 컨텍스트를 유지합니다.",
|
||||||
"emptyAction": "채팅 열기",
|
|
||||||
"clearFilters": "필터 지우기",
|
|
||||||
"oneShot": "일회성",
|
"oneShot": "일회성",
|
||||||
"systemTask": "시스템 관리 자동화",
|
"systemTask": "시스템 관리 자동화",
|
||||||
"localTrigger": "로컬 트리거",
|
"localTrigger": "로컬 트리거",
|
||||||
@@ -1377,7 +1367,7 @@
|
|||||||
},
|
},
|
||||||
"workspaceScopeRejected": {
|
"workspaceScopeRejected": {
|
||||||
"title": "작업공간이 변경되지 않았습니다",
|
"title": "작업공간이 변경되지 않았습니다",
|
||||||
"body": "게이트웨이가 이 프로젝트 또는 접근 모드를 거부했습니다. 기존 프로젝트나 다른 접근 모드를 선택한 후 다시 시도하세요."
|
"body": "요청한 프로젝트 또는 접근 모드가 게이트웨이에서 거부되어 Nanobot이 이전 작업공간을 계속 사용합니다."
|
||||||
},
|
},
|
||||||
"turnRejected": {
|
"turnRejected": {
|
||||||
"title": "메시지가 전송되지 않았습니다",
|
"title": "메시지가 전송되지 않았습니다",
|
||||||
|
|||||||
@@ -10,12 +10,11 @@
|
|||||||
"gatewayHint": "Verifique se o gateway está em execução (`nanobot gateway`) e se esta página está aberta na mesma máquina."
|
"gatewayHint": "Verifique se o gateway está em execução (`nanobot gateway`) e se esta página está aberta na mesma máquina."
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"label": "Senha",
|
"title": "Autenticação necessária",
|
||||||
"showPassword": "Mostrar senha",
|
"hint": "Informe o segredo configurado como tokenIssueSecret na configuração do gateway.",
|
||||||
"hidePassword": "Ocultar senha",
|
"placeholder": "Senha",
|
||||||
"submit": "Conectar",
|
"submit": "Conectar",
|
||||||
"required": "Digite a senha.",
|
"invalid": "Senha inválida. Tente novamente."
|
||||||
"invalid": "Senha incorreta. Tente novamente."
|
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"section": "Conta",
|
"section": "Conta",
|
||||||
@@ -596,14 +595,7 @@
|
|||||||
"searchPlaceholder": "Buscar ferramentas",
|
"searchPlaceholder": "Buscar ferramentas",
|
||||||
"featured": "Ferramentas",
|
"featured": "Ferramentas",
|
||||||
"loading": "Carregando aplicativos...",
|
"loading": "Carregando aplicativos...",
|
||||||
"empty": "Nenhuma ferramenta corresponde à sua busca.",
|
"empty": "Nenhuma ferramenta corresponde a esta visualização.",
|
||||||
"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."
|
"restartRequired": "Reinicie o nanobot para aplicar os aplicativos e integrações atualizados."
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
@@ -706,9 +698,7 @@
|
|||||||
"loading": "Carregando automações...",
|
"loading": "Carregando automações...",
|
||||||
"noMatches": "Nenhuma automação corresponde a esta visualização.",
|
"noMatches": "Nenhuma automação corresponde a esta visualização.",
|
||||||
"empty": "Nenhuma automação ainda.",
|
"empty": "Nenhuma automação ainda.",
|
||||||
"emptyHint": "Crie automações em uma conversa para que mantenham o contexto correto.",
|
"emptyHint": "Crie uma de onde ela deve rodar para que o nanobot mantenha o contexto correto.",
|
||||||
"emptyAction": "Abrir uma conversa",
|
|
||||||
"clearFilters": "Limpar filtros",
|
|
||||||
"oneShot": "Uma vez",
|
"oneShot": "Uma vez",
|
||||||
"systemTask": "Automação gerenciada pelo sistema",
|
"systemTask": "Automação gerenciada pelo sistema",
|
||||||
"localTrigger": "Gatilho local",
|
"localTrigger": "Gatilho local",
|
||||||
@@ -1391,7 +1381,7 @@
|
|||||||
},
|
},
|
||||||
"workspaceScopeRejected": {
|
"workspaceScopeRejected": {
|
||||||
"title": "O espaço de trabalho não foi alterado",
|
"title": "O espaço de trabalho não foi alterado",
|
||||||
"body": "O gateway rejeitou este projeto ou modo de acesso. Escolha um projeto existente ou outro modo de acesso e tente novamente."
|
"body": "O nanobot manteve o espaço de trabalho anterior porque o projeto ou modo de acesso solicitado foi rejeitado pelo gateway."
|
||||||
},
|
},
|
||||||
"turnRejected": {
|
"turnRejected": {
|
||||||
"title": "A mensagem não foi enviada",
|
"title": "A mensagem não foi enviada",
|
||||||
|
|||||||
@@ -10,12 +10,11 @@
|
|||||||
"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."
|
"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": {
|
"auth": {
|
||||||
"label": "Mật khẩu",
|
"title": "Cần xác thực",
|
||||||
"showPassword": "Hiện mật khẩu",
|
"hint": "Nhập secret được cấu hình là tokenIssueSecret trong cấu hình gateway.",
|
||||||
"hidePassword": "Ẩn mật khẩu",
|
"placeholder": "Mật khẩu",
|
||||||
"submit": "Kết nối",
|
"submit": "Kết nối",
|
||||||
"required": "Nhập mật khẩu.",
|
"invalid": "Mật khẩu không hợp lệ. Hãy thử lại."
|
||||||
"invalid": "Mật khẩu không đúng. Hãy thử lại."
|
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"section": "Tài khoản",
|
"section": "Tài khoản",
|
||||||
@@ -582,14 +581,7 @@
|
|||||||
"searchPlaceholder": "Tìm ứng dụng",
|
"searchPlaceholder": "Tìm ứng dụng",
|
||||||
"featured": "Công cụ",
|
"featured": "Công cụ",
|
||||||
"loading": "Đang tải ứng dụng...",
|
"loading": "Đang tải ứng dụng...",
|
||||||
"empty": "Không có công cụ phù hợp với tìm kiếm của bạn.",
|
"empty": "Không có công cụ phù hợp với chế độ xem này.",
|
||||||
"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."
|
"restartRequired": "Khởi động lại nanobot để áp dụng ứng dụng và tính năng đã cập nhật."
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
@@ -692,9 +684,7 @@
|
|||||||
"loading": "Đang tải tự động hóa...",
|
"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.",
|
"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.",
|
"empty": "Chưa có tự động hóa.",
|
||||||
"emptyHint": "Tạo tác vụ tự động trong cuộc trò chuyện để giữ đúng ngữ cảnh.",
|
"emptyHint": "Tạo từ nơi tác vụ sẽ chạy để nanobot giữ đúng ngữ cảnh.",
|
||||||
"emptyAction": "Mở cuộc trò chuyện",
|
|
||||||
"clearFilters": "Xóa bộ lọc",
|
|
||||||
"oneShot": "Một lần",
|
"oneShot": "Một lần",
|
||||||
"systemTask": "Tự động hóa do hệ thống quản lý",
|
"systemTask": "Tự động hóa do hệ thống quản lý",
|
||||||
"localTrigger": "Trình kích hoạt cục bộ",
|
"localTrigger": "Trình kích hoạt cục bộ",
|
||||||
@@ -1377,7 +1367,7 @@
|
|||||||
},
|
},
|
||||||
"workspaceScopeRejected": {
|
"workspaceScopeRejected": {
|
||||||
"title": "Không gian làm việc không thay đổi",
|
"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 này. Chọn dự án hiện có hoặc chế độ truy cập khác rồi thử lạ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 đó."
|
||||||
},
|
},
|
||||||
"turnRejected": {
|
"turnRejected": {
|
||||||
"title": "Tin nhắn chưa được gửi",
|
"title": "Tin nhắn chưa được gửi",
|
||||||
|
|||||||
@@ -10,12 +10,11 @@
|
|||||||
"gatewayHint": "请确认网关已启动(`nanobot gateway`),并且当前页面与网关运行在同一台机器上。"
|
"gatewayHint": "请确认网关已启动(`nanobot gateway`),并且当前页面与网关运行在同一台机器上。"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"label": "密码",
|
"title": "需要验证",
|
||||||
"showPassword": "显示密码",
|
"hint": "请输入网关配置中的 tokenIssueSecret。",
|
||||||
"hidePassword": "隐藏密码",
|
"placeholder": "密码",
|
||||||
"submit": "连接",
|
"submit": "连接",
|
||||||
"required": "请输入密码。",
|
"invalid": "密码无效,请重试。"
|
||||||
"invalid": "密码错误,请重试。"
|
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"section": "账户",
|
"section": "账户",
|
||||||
@@ -596,14 +595,7 @@
|
|||||||
"searchPlaceholder": "搜索工具",
|
"searchPlaceholder": "搜索工具",
|
||||||
"featured": "工具",
|
"featured": "工具",
|
||||||
"loading": "正在加载应用...",
|
"loading": "正在加载应用...",
|
||||||
"empty": "没有与搜索条件匹配的工具。",
|
"empty": "当前视图没有匹配的工具。",
|
||||||
"emptyApps": "暂无可用应用。",
|
|
||||||
"emptyIntegrations": "暂无可用集成。",
|
|
||||||
"emptyReady": "还没有就绪的工具。",
|
|
||||||
"clearSearch": "清除搜索",
|
|
||||||
"browseApps": "浏览应用",
|
|
||||||
"browseIntegrations": "浏览集成",
|
|
||||||
"emptyIntegrationsHint": "可在下方添加自定义集成。",
|
|
||||||
"restartRequired": "重启 nanobot 以应用更新后的应用和集成。"
|
"restartRequired": "重启 nanobot 以应用更新后的应用和集成。"
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
@@ -706,9 +698,7 @@
|
|||||||
"loading": "正在加载自动任务...",
|
"loading": "正在加载自动任务...",
|
||||||
"noMatches": "当前视图没有匹配的自动任务。",
|
"noMatches": "当前视图没有匹配的自动任务。",
|
||||||
"empty": "暂无自动任务。",
|
"empty": "暂无自动任务。",
|
||||||
"emptyHint": "请在对话中创建自动任务,以便保留正确的上下文。",
|
"emptyHint": "请从它应该运行的来源处创建,这样 nanobot 才能保留正确上下文。",
|
||||||
"emptyAction": "打开对话",
|
|
||||||
"clearFilters": "清除筛选",
|
|
||||||
"oneShot": "一次性",
|
"oneShot": "一次性",
|
||||||
"systemTask": "系统管理的自动任务",
|
"systemTask": "系统管理的自动任务",
|
||||||
"localTrigger": "本地触发器",
|
"localTrigger": "本地触发器",
|
||||||
@@ -1391,7 +1381,7 @@
|
|||||||
},
|
},
|
||||||
"workspaceScopeRejected": {
|
"workspaceScopeRejected": {
|
||||||
"title": "工作区未更改",
|
"title": "工作区未更改",
|
||||||
"body": "网关拒绝了此项目或访问权限。请选择已存在的项目或其他访问权限,然后重试。"
|
"body": "网关拒绝了请求的项目或访问权限,Nanobot 已继续使用之前的工作区。"
|
||||||
},
|
},
|
||||||
"turnRejected": {
|
"turnRejected": {
|
||||||
"title": "消息未发送",
|
"title": "消息未发送",
|
||||||
|
|||||||
@@ -10,12 +10,11 @@
|
|||||||
"gatewayHint": "請確認閘道已啟動(`nanobot gateway`),並且目前頁面與閘道在同一台機器上開啟。"
|
"gatewayHint": "請確認閘道已啟動(`nanobot gateway`),並且目前頁面與閘道在同一台機器上開啟。"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"label": "密碼",
|
"title": "需要驗證",
|
||||||
"showPassword": "顯示密碼",
|
"hint": "請輸入閘道設定中 tokenIssueSecret 所設定的金鑰。",
|
||||||
"hidePassword": "隱藏密碼",
|
"placeholder": "密碼",
|
||||||
"submit": "連線",
|
"submit": "連線",
|
||||||
"required": "請輸入密碼。",
|
"invalid": "密碼無效,請再試一次。"
|
||||||
"invalid": "密碼錯誤,請再試一次。"
|
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"section": "帳戶",
|
"section": "帳戶",
|
||||||
@@ -582,14 +581,7 @@
|
|||||||
"searchPlaceholder": "搜尋工具",
|
"searchPlaceholder": "搜尋工具",
|
||||||
"featured": "工具",
|
"featured": "工具",
|
||||||
"loading": "正在載入應用程式…",
|
"loading": "正在載入應用程式…",
|
||||||
"empty": "沒有符合搜尋條件的工具。",
|
"empty": "沒有符合條件的工具。",
|
||||||
"emptyApps": "沒有可用的應用程式。",
|
|
||||||
"emptyIntegrations": "沒有可用的整合服務。",
|
|
||||||
"emptyReady": "尚無就緒的工具。",
|
|
||||||
"clearSearch": "清除搜尋",
|
|
||||||
"browseApps": "瀏覽應用程式",
|
|
||||||
"browseIntegrations": "瀏覽整合服務",
|
|
||||||
"emptyIntegrationsHint": "可在下方新增自訂整合服務。",
|
|
||||||
"restartRequired": "重新啟動 nanobot 以套用更新後的應用程式與整合服務。"
|
"restartRequired": "重新啟動 nanobot 以套用更新後的應用程式與整合服務。"
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
@@ -692,9 +684,7 @@
|
|||||||
"loading": "正在載入自動任務…",
|
"loading": "正在載入自動任務…",
|
||||||
"noMatches": "目前沒有符合條件的自動任務。",
|
"noMatches": "目前沒有符合條件的自動任務。",
|
||||||
"empty": "尚無自動任務。",
|
"empty": "尚無自動任務。",
|
||||||
"emptyHint": "請在聊天中建立自動任務,以保留正確的對話脈絡。",
|
"emptyHint": "請從自動任務預定執行的對話中建立,讓 nanobot 保留正確的對話脈絡。",
|
||||||
"emptyAction": "開啟聊天",
|
|
||||||
"clearFilters": "清除篩選",
|
|
||||||
"oneShot": "單次",
|
"oneShot": "單次",
|
||||||
"systemTask": "系統管理的自動任務",
|
"systemTask": "系統管理的自動任務",
|
||||||
"localTrigger": "本機觸發器",
|
"localTrigger": "本機觸發器",
|
||||||
@@ -1377,7 +1367,7 @@
|
|||||||
},
|
},
|
||||||
"workspaceScopeRejected": {
|
"workspaceScopeRejected": {
|
||||||
"title": "工作區未變更",
|
"title": "工作區未變更",
|
||||||
"body": "閘道拒絕了此專案或存取模式。請選擇現有專案或其他存取模式,然後再試一次。"
|
"body": "閘道拒絕要求的專案或存取模式,因此 Nanobot 繼續使用先前的工作區。"
|
||||||
},
|
},
|
||||||
"turnRejected": {
|
"turnRejected": {
|
||||||
"title": "訊息未傳送",
|
"title": "訊息未傳送",
|
||||||
|
|||||||
+360
-264
@@ -44,8 +44,6 @@ import type {
|
|||||||
import { fetchWithTimeout } from "./http";
|
import { fetchWithTimeout } from "./http";
|
||||||
|
|
||||||
const API_READ_TIMEOUT_MS = 20_000;
|
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>([
|
const SLASH_COMMAND_LIFECYCLES = new Set<SlashCommandLifecycle>([
|
||||||
"side_channel",
|
"side_channel",
|
||||||
"finalize_active_turn",
|
"finalize_active_turn",
|
||||||
@@ -60,6 +58,12 @@ function isSlashCommandLifecycle(value: unknown): value is SlashCommandLifecycle
|
|||||||
&& SLASH_COMMAND_LIFECYCLES.has(value as 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 {
|
export class ApiError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
constructor(status: number, message: string) {
|
constructor(status: number, message: string) {
|
||||||
@@ -69,14 +73,6 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WebUIMutationTransport {
|
|
||||||
requestMutation<T>(
|
|
||||||
action: string,
|
|
||||||
payload?: Record<string, unknown>,
|
|
||||||
timeoutMs?: number,
|
|
||||||
): Promise<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function request<T>(
|
async function request<T>(
|
||||||
url: string,
|
url: string,
|
||||||
token: string,
|
token: string,
|
||||||
@@ -113,27 +109,7 @@ async function request<T>(
|
|||||||
return (await res.json()) as T;
|
return (await res.json()) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mutation<T>(
|
function mcpValuesHeader(values: Record<string, unknown>): HeadersInit | undefined {
|
||||||
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> = {};
|
const payload: Record<string, unknown> = {};
|
||||||
Object.entries(values).forEach(([key, value]) => {
|
Object.entries(values).forEach(([key, value]) => {
|
||||||
if (value === null || value === undefined) return;
|
if (value === null || value === undefined) return;
|
||||||
@@ -144,7 +120,12 @@ function compactMcpValues(values: Record<string, unknown>): Record<string, unkno
|
|||||||
}
|
}
|
||||||
payload[key] = value;
|
payload[key] = value;
|
||||||
});
|
});
|
||||||
return payload;
|
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)) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitKey(key: string): { channel: string; chatId: string } {
|
function splitKey(key: string): { channel: string; chatId: string } {
|
||||||
@@ -280,19 +261,37 @@ export async function fetchAutomations(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runAutomationAction(
|
export async function runAutomationAction(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
action: "enable" | "disable" | "delete" | "run",
|
action: "enable" | "disable" | "delete" | "run",
|
||||||
id: string,
|
id: string,
|
||||||
|
base: string = "",
|
||||||
): Promise<AutomationsPayload> {
|
): Promise<AutomationsPayload> {
|
||||||
return mutation<AutomationsPayload>(transport, `automation.${action}`, { id });
|
const query = new URLSearchParams();
|
||||||
|
query.set("id", id);
|
||||||
|
return request<AutomationsPayload>(
|
||||||
|
`${base}/api/webui/automations/${action}?${query}`,
|
||||||
|
token,
|
||||||
|
undefined,
|
||||||
|
API_READ_TIMEOUT_MS,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateAutomation(
|
export async function updateAutomation(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
id: string,
|
id: string,
|
||||||
values: AutomationUpdatePayload,
|
values: AutomationUpdatePayload,
|
||||||
|
base: string = "",
|
||||||
): Promise<AutomationsPayload> {
|
): Promise<AutomationsPayload> {
|
||||||
return mutation<AutomationsPayload>(transport, "automation.update", { id, values });
|
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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSkills(
|
export async function fetchSkills(
|
||||||
@@ -321,18 +320,28 @@ export async function fetchSkillDetail(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSkillEnabled(
|
export async function updateSkillEnabled(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
name: string,
|
name: string,
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
|
base: string = "",
|
||||||
): Promise<SkillActionPayload> {
|
): Promise<SkillActionPayload> {
|
||||||
return mutation<SkillActionPayload>(transport, "skill.update", { name, enabled });
|
const params = new URLSearchParams({ name, enabled: String(enabled) });
|
||||||
|
return request<SkillActionPayload>(
|
||||||
|
`${base}/api/webui/skills/update?${params}`,
|
||||||
|
token,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteSkill(
|
export async function deleteSkill(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
name: string,
|
name: string,
|
||||||
|
base: string = "",
|
||||||
): Promise<SkillActionPayload> {
|
): Promise<SkillActionPayload> {
|
||||||
return mutation<SkillActionPayload>(transport, "skill.delete", { name });
|
const params = new URLSearchParams({ name });
|
||||||
|
return request<SkillActionPayload>(
|
||||||
|
`${base}/api/webui/skills/delete?${params}`,
|
||||||
|
token,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchMarketplaceSkills(
|
export async function searchMarketplaceSkills(
|
||||||
@@ -380,33 +389,37 @@ export async function fetchMarketplaceSkillTrends(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function installMarketplaceSkill(
|
export async function installMarketplaceSkill(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
provider: Exclude<MarketplaceProvider, "all">,
|
provider: Exclude<MarketplaceProvider, "all">,
|
||||||
source: string,
|
source: string,
|
||||||
skill: string,
|
skill: string,
|
||||||
version: string = "",
|
version: string = "",
|
||||||
|
base: string = "",
|
||||||
): Promise<SkillInstallPayload> {
|
): Promise<SkillInstallPayload> {
|
||||||
return mutation<SkillInstallPayload>(
|
const params = new URLSearchParams({ provider, source, skill });
|
||||||
transport,
|
if (version) params.set("version", version);
|
||||||
"skill.install",
|
return request<SkillInstallPayload>(
|
||||||
{ provider, source, skill, ...(version ? { version } : {}) },
|
`${base}/api/webui/skills/install?${params}`,
|
||||||
PACKAGE_MUTATION_TIMEOUT_MS,
|
token,
|
||||||
|
undefined,
|
||||||
|
150_000,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteSession(
|
export async function deleteSession(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
key: string,
|
key: string,
|
||||||
optionsOrBase?: { deleteAutomations?: boolean } | string,
|
optionsOrBase?: { deleteAutomations?: boolean } | string,
|
||||||
|
base: string = "",
|
||||||
): Promise<SessionDeleteResult> {
|
): Promise<SessionDeleteResult> {
|
||||||
const options = typeof optionsOrBase === "string" ? undefined : optionsOrBase;
|
const options = typeof optionsOrBase === "string" ? undefined : optionsOrBase;
|
||||||
return mutation<SessionDeleteResult>(
|
const resolvedBase = typeof optionsOrBase === "string" ? optionsOrBase : base;
|
||||||
transport,
|
const query = new URLSearchParams();
|
||||||
"session.delete",
|
if (options?.deleteAutomations) query.set("delete_automations", "true");
|
||||||
{
|
const suffix = query.toString() ? `?${query}` : "";
|
||||||
key,
|
return request<SessionDeleteResult>(
|
||||||
...(options?.deleteAutomations ? { delete_automations: true } : {}),
|
`${resolvedBase}/api/sessions/${encodeURIComponent(key)}/delete${suffix}`,
|
||||||
},
|
token,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,50 +520,56 @@ export async function fetchApiService(token: string, base: string = ""): Promise
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function startApiService(
|
export async function startApiService(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
values: { host: string; port: number; timeout: number; apiKey?: string },
|
values: { host: string; port: number; timeout: number; apiKey?: string },
|
||||||
|
base: string = "",
|
||||||
): Promise<ApiServicePayload> {
|
): Promise<ApiServicePayload> {
|
||||||
return mutation<ApiServicePayload>(
|
const query = new URLSearchParams({
|
||||||
transport,
|
host: values.host,
|
||||||
"settings.api_service.start",
|
port: String(values.port),
|
||||||
{
|
timeout: String(values.timeout),
|
||||||
host: values.host,
|
});
|
||||||
port: values.port,
|
const headers = values.apiKey === undefined
|
||||||
timeout: values.timeout,
|
? undefined
|
||||||
...(values.apiKey !== undefined ? { api_key: values.apiKey } : {}),
|
: { [API_SERVICE_VALUES_HEADER]: JSON.stringify({ api_key: values.apiKey }) };
|
||||||
},
|
return request<ApiServicePayload>(
|
||||||
PACKAGE_MUTATION_TIMEOUT_MS,
|
`${base}/api/settings/api-service/start?${query}`,
|
||||||
|
token,
|
||||||
|
{ headers },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function stopApiService(
|
export async function stopApiService(token: string, base: string = ""): Promise<ApiServicePayload> {
|
||||||
transport: WebUIMutationTransport,
|
return request<ApiServicePayload>(`${base}/api/settings/api-service/stop`, token);
|
||||||
): Promise<ApiServicePayload> {
|
|
||||||
return mutation<ApiServicePayload>(transport, "settings.api_service.stop");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function enableNanobotFeature(
|
export async function enableNanobotFeature(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
name: string,
|
name: string,
|
||||||
options: { instanceId?: string } = {},
|
options: { instanceId?: string } = {},
|
||||||
|
base: string = "",
|
||||||
): Promise<NanobotFeaturesPayload> {
|
): Promise<NanobotFeaturesPayload> {
|
||||||
return mutation<NanobotFeaturesPayload>(
|
const query = new URLSearchParams();
|
||||||
transport,
|
query.set("name", name);
|
||||||
"settings.feature.enable",
|
if (options.instanceId) query.set("instance_id", options.instanceId);
|
||||||
{ name, ...(options.instanceId ? { instance_id: options.instanceId } : {}) },
|
return request<NanobotFeaturesPayload>(
|
||||||
PACKAGE_MUTATION_TIMEOUT_MS,
|
`${base}/api/settings/nanobot-features/enable?${query}`,
|
||||||
|
token,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function disableNanobotFeature(
|
export async function disableNanobotFeature(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
name: string,
|
name: string,
|
||||||
options: { instanceId?: string } = {},
|
options: { instanceId?: string } = {},
|
||||||
|
base: string = "",
|
||||||
): Promise<NanobotFeaturesPayload> {
|
): Promise<NanobotFeaturesPayload> {
|
||||||
return mutation<NanobotFeaturesPayload>(
|
const query = new URLSearchParams();
|
||||||
transport,
|
query.set("name", name);
|
||||||
"settings.feature.disable",
|
if (options.instanceId) query.set("instance_id", options.instanceId);
|
||||||
{ name, ...(options.instanceId ? { instance_id: options.instanceId } : {}) },
|
return request<NanobotFeaturesPayload>(
|
||||||
|
`${base}/api/settings/nanobot-features/disable?${query}`,
|
||||||
|
token,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,15 +586,21 @@ export async function fetchPairingRequests(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runPairingAction(
|
export async function runPairingAction(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
action: "approve" | "deny",
|
action: "approve" | "deny",
|
||||||
code: string,
|
code: string,
|
||||||
|
base: string = "",
|
||||||
): Promise<PairingPayload> {
|
): Promise<PairingPayload> {
|
||||||
return mutation<PairingPayload>(transport, `settings.pairing.${action}`, { code });
|
const query = new URLSearchParams();
|
||||||
|
query.set("code", code);
|
||||||
|
return request<PairingPayload>(
|
||||||
|
`${base}/api/settings/pairing/${action}?${query}`,
|
||||||
|
token,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startChannelConnect(
|
export async function startChannelConnect(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
channel: string,
|
channel: string,
|
||||||
options: {
|
options: {
|
||||||
domain?: string;
|
domain?: string;
|
||||||
@@ -583,95 +608,104 @@ export async function startChannelConnect(
|
|||||||
mode?: "replace" | "create";
|
mode?: "replace" | "create";
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
} = {},
|
} = {},
|
||||||
|
base: string = "",
|
||||||
): Promise<ChannelConnectPayload> {
|
): Promise<ChannelConnectPayload> {
|
||||||
return mutation<ChannelConnectPayload>(
|
const query = new URLSearchParams();
|
||||||
transport,
|
if (options.domain) query.set("domain", options.domain);
|
||||||
"settings.channel.connect.start",
|
if (options.instanceId) query.set("instance_id", options.instanceId);
|
||||||
{
|
if (options.mode) query.set("mode", options.mode);
|
||||||
channel,
|
if (options.force) query.set("force", "true");
|
||||||
...(options.domain ? { domain: options.domain } : {}),
|
const suffix = query.toString();
|
||||||
...(options.instanceId ? { instance_id: options.instanceId } : {}),
|
return request<ChannelConnectPayload>(
|
||||||
...(options.mode ? { mode: options.mode } : {}),
|
`${base}/api/settings/channels/${channel}/connect/start${suffix ? `?${suffix}` : ""}`,
|
||||||
...(options.force ? { force: true } : {}),
|
token,
|
||||||
},
|
|
||||||
PACKAGE_MUTATION_TIMEOUT_MS,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pollChannelConnect(
|
export async function pollChannelConnect(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
channel: string,
|
channel: string,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
base: string = "",
|
||||||
params: Readonly<Record<string, string>> = {},
|
params: Readonly<Record<string, string>> = {},
|
||||||
): Promise<ChannelConnectPayload> {
|
): Promise<ChannelConnectPayload> {
|
||||||
const values = Object.fromEntries(
|
const query = new URLSearchParams();
|
||||||
Object.entries(params).filter(([key]) => key !== "session_id"),
|
query.set("session_id", sessionId);
|
||||||
);
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
return mutation<ChannelConnectPayload>(
|
if (key !== "session_id") query.set(key, value);
|
||||||
transport,
|
});
|
||||||
"settings.channel.connect.poll",
|
return request<ChannelConnectPayload>(
|
||||||
{ channel, session_id: sessionId, ...values },
|
`${base}/api/settings/channels/${channel}/connect/poll?${query}`,
|
||||||
PACKAGE_MUTATION_TIMEOUT_MS,
|
token,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cancelChannelConnect(
|
export async function cancelChannelConnect(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
channel: string,
|
channel: string,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
base: string = "",
|
||||||
): Promise<ChannelConnectPayload> {
|
): Promise<ChannelConnectPayload> {
|
||||||
return mutation<ChannelConnectPayload>(
|
const query = new URLSearchParams();
|
||||||
transport,
|
query.set("session_id", sessionId);
|
||||||
"settings.channel.connect.cancel",
|
return request<ChannelConnectPayload>(
|
||||||
{ channel, session_id: sessionId },
|
`${base}/api/settings/channels/${channel}/connect/cancel?${query}`,
|
||||||
|
token,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function configureChannel(
|
export async function configureChannel(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
name: string,
|
name: string,
|
||||||
values: Record<string, string>,
|
values: Record<string, string>,
|
||||||
options: { enable?: boolean; instanceId?: string } = {},
|
options: { enable?: boolean; instanceId?: string } = {},
|
||||||
|
base: string = "",
|
||||||
): Promise<ChannelConfigurePayload> {
|
): Promise<ChannelConfigurePayload> {
|
||||||
return mutation<ChannelConfigurePayload>(
|
const query = new URLSearchParams();
|
||||||
transport,
|
query.set("name", name);
|
||||||
"settings.channel.configure",
|
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,
|
||||||
{
|
{
|
||||||
name,
|
headers: {
|
||||||
values,
|
[CHANNEL_VALUES_HEADER]: JSON.stringify(values),
|
||||||
...(options.enable !== undefined ? { enable: options.enable } : {}),
|
},
|
||||||
...(options.instanceId ? { instance_id: options.instanceId } : {}),
|
|
||||||
},
|
},
|
||||||
PACKAGE_MUTATION_TIMEOUT_MS,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function validateChannel(
|
export async function validateChannel(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
name: string,
|
name: string,
|
||||||
values: Record<string, string> = {},
|
values: Record<string, string> = {},
|
||||||
options: { instanceId?: string } = {},
|
options: { instanceId?: string } = {},
|
||||||
|
base: string = "",
|
||||||
): Promise<ChannelValidationPayload> {
|
): Promise<ChannelValidationPayload> {
|
||||||
return mutation<ChannelValidationPayload>(
|
const query = new URLSearchParams();
|
||||||
transport,
|
query.set("name", name);
|
||||||
"settings.channel.validate",
|
if (options.instanceId) query.set("instance_id", options.instanceId);
|
||||||
{ name, values, ...(options.instanceId ? { instance_id: options.instanceId } : {}) },
|
return request<ChannelValidationPayload>(
|
||||||
|
`${base}/api/settings/channels/validate?${query}`,
|
||||||
|
token,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
[CHANNEL_VALUES_HEADER]: JSON.stringify(values),
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runCliAppAction(
|
export async function runCliAppAction(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
action: "install" | "update" | "uninstall" | "test",
|
action: "install" | "update" | "uninstall" | "test",
|
||||||
name: string,
|
name: string,
|
||||||
|
base: string = "",
|
||||||
): Promise<CliAppsPayload> {
|
): Promise<CliAppsPayload> {
|
||||||
return mutation<CliAppsPayload>(
|
const query = new URLSearchParams();
|
||||||
transport,
|
query.set("name", name);
|
||||||
`settings.cli_app.${action}`,
|
return request<CliAppsPayload>(`${base}/api/settings/cli-apps/${action}?${query}`, token);
|
||||||
{ name },
|
|
||||||
action === "install" || action === "update"
|
|
||||||
? PACKAGE_MUTATION_TIMEOUT_MS
|
|
||||||
: API_MUTATION_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchMcpPresets(
|
export async function fetchMcpPresets(
|
||||||
@@ -702,45 +736,55 @@ export async function fetchProviderModels(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runMcpPresetAction(
|
export async function runMcpPresetAction(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
action: "enable" | "remove" | "test",
|
action: "enable" | "remove" | "test",
|
||||||
name: string,
|
name: string,
|
||||||
values: Record<string, string> = {},
|
values: Record<string, string> = {},
|
||||||
|
base: string = "",
|
||||||
): Promise<McpPresetsPayload> {
|
): Promise<McpPresetsPayload> {
|
||||||
return mutation<McpPresetsPayload>(
|
const query = new URLSearchParams();
|
||||||
transport,
|
query.set("name", name);
|
||||||
`settings.mcp.${action}`,
|
return request<McpPresetsPayload>(
|
||||||
{ name, ...compactMcpValues(values) },
|
`${base}/api/settings/mcp-presets/${action}?${query}`,
|
||||||
|
token,
|
||||||
|
{ headers: mcpValuesHeader(values) },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveCustomMcpServer(
|
export async function saveCustomMcpServer(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
values: Record<string, string>,
|
values: Record<string, string>,
|
||||||
|
base: string = "",
|
||||||
): Promise<McpPresetsPayload> {
|
): Promise<McpPresetsPayload> {
|
||||||
return mutation<McpPresetsPayload>(
|
return request<McpPresetsPayload>(
|
||||||
transport,
|
`${base}/api/settings/mcp-presets/custom`,
|
||||||
"settings.mcp.custom",
|
token,
|
||||||
compactMcpValues(values),
|
{ headers: mcpValuesHeader(values) },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importMcpConfig(
|
export async function importMcpConfig(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
config: string,
|
config: string,
|
||||||
|
base: string = "",
|
||||||
): Promise<McpPresetsPayload> {
|
): Promise<McpPresetsPayload> {
|
||||||
return mutation<McpPresetsPayload>(transport, "settings.mcp.import", { config });
|
return request<McpPresetsPayload>(
|
||||||
|
`${base}/api/settings/mcp-presets/import`,
|
||||||
|
token,
|
||||||
|
{ headers: mcpValuesHeader({ config }) },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateMcpServerTools(
|
export async function updateMcpServerTools(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
name: string,
|
name: string,
|
||||||
enabledTools: string[],
|
enabledTools: string[],
|
||||||
|
base: string = "",
|
||||||
): Promise<McpPresetsPayload> {
|
): Promise<McpPresetsPayload> {
|
||||||
return mutation<McpPresetsPayload>(
|
return request<McpPresetsPayload>(
|
||||||
transport,
|
`${base}/api/settings/mcp-presets/tools`,
|
||||||
"settings.mcp.tools",
|
token,
|
||||||
{ name, enabled_tools: enabledTools },
|
{ headers: mcpValuesHeader({ name, enabled_tools: enabledTools }) },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -791,228 +835,280 @@ export async function fetchSidebarState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSidebarState(
|
export async function updateSidebarState(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
state: SidebarStatePayload,
|
state: SidebarStatePayload,
|
||||||
|
base: string = "",
|
||||||
): Promise<SidebarStatePayload> {
|
): Promise<SidebarStatePayload> {
|
||||||
return mutation<SidebarStatePayload>(transport, "sidebar.update", { state });
|
const query = new URLSearchParams();
|
||||||
|
query.set("state", JSON.stringify(state));
|
||||||
|
return request<SidebarStatePayload>(
|
||||||
|
`${base}/api/webui/sidebar-state/update?${query}`,
|
||||||
|
token,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSettings(
|
export async function updateSettings(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
update: SettingsUpdate,
|
update: SettingsUpdate,
|
||||||
|
base: string = "",
|
||||||
): Promise<SettingsPayload> {
|
): Promise<SettingsPayload> {
|
||||||
const payload: Record<string, unknown> = {};
|
const query = new URLSearchParams();
|
||||||
if (update.modelPreset !== undefined) {
|
if (update.modelPreset !== undefined) {
|
||||||
payload.model_preset = update.modelPreset ?? "default";
|
query.set("model_preset", update.modelPreset ?? "default");
|
||||||
}
|
}
|
||||||
if (update.model !== undefined) payload.model = update.model;
|
if (update.model !== undefined) query.set("model", update.model);
|
||||||
if (update.provider !== undefined) payload.provider = update.provider;
|
if (update.provider !== undefined) query.set("provider", update.provider);
|
||||||
if (update.contextWindowTokens !== undefined) {
|
if (update.contextWindowTokens !== undefined) {
|
||||||
payload.context_window_tokens = update.contextWindowTokens;
|
query.set("context_window_tokens", String(update.contextWindowTokens));
|
||||||
}
|
}
|
||||||
if (update.timezone !== undefined) payload.timezone = update.timezone;
|
if (update.timezone !== undefined) query.set("timezone", update.timezone);
|
||||||
if (update.toolHintMaxLength !== undefined) {
|
if (update.toolHintMaxLength !== undefined) {
|
||||||
payload.tool_hint_max_length = update.toolHintMaxLength;
|
query.set("tool_hint_max_length", String(update.toolHintMaxLength));
|
||||||
}
|
}
|
||||||
return mutation<SettingsPayload>(transport, "settings.agent.update", payload);
|
return request<SettingsPayload>(`${base}/api/settings/update?${query}`, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
function modelGenerationSettingsPayload(
|
function appendModelGenerationSettings(
|
||||||
|
query: URLSearchParams,
|
||||||
configuration: Pick<
|
configuration: Pick<
|
||||||
ModelConfigurationCreate,
|
ModelConfigurationCreate,
|
||||||
"maxTokens" | "contextWindowTokens" | "temperature" | "reasoningEffort"
|
"maxTokens" | "contextWindowTokens" | "temperature" | "reasoningEffort"
|
||||||
>,
|
>,
|
||||||
): Record<string, unknown> {
|
): void {
|
||||||
const payload: Record<string, unknown> = {};
|
|
||||||
if (configuration.maxTokens !== undefined) {
|
if (configuration.maxTokens !== undefined) {
|
||||||
payload.max_tokens = configuration.maxTokens;
|
query.set("max_tokens", String(configuration.maxTokens));
|
||||||
}
|
}
|
||||||
if (configuration.contextWindowTokens !== undefined) {
|
if (configuration.contextWindowTokens !== undefined) {
|
||||||
payload.context_window_tokens = configuration.contextWindowTokens;
|
query.set("context_window_tokens", String(configuration.contextWindowTokens));
|
||||||
}
|
}
|
||||||
if (configuration.temperature !== undefined) {
|
if (configuration.temperature !== undefined) {
|
||||||
payload.temperature = configuration.temperature;
|
query.set("temperature", String(configuration.temperature));
|
||||||
}
|
}
|
||||||
if (configuration.reasoningEffort !== undefined) {
|
if (configuration.reasoningEffort !== undefined) {
|
||||||
payload.reasoning_effort = configuration.reasoningEffort ?? "";
|
query.set("reasoning_effort", configuration.reasoningEffort ?? "");
|
||||||
}
|
}
|
||||||
return payload;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createModelConfiguration(
|
export async function createModelConfiguration(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
configuration: ModelConfigurationCreate,
|
configuration: ModelConfigurationCreate,
|
||||||
|
base: string = "",
|
||||||
): Promise<SettingsPayload> {
|
): Promise<SettingsPayload> {
|
||||||
return mutation<SettingsPayload>(
|
const query = new URLSearchParams();
|
||||||
transport,
|
if (configuration.name !== undefined) query.set("name", configuration.name);
|
||||||
"settings.model_configuration.create",
|
query.set("label", configuration.label);
|
||||||
{
|
query.set("provider", configuration.provider);
|
||||||
...(configuration.name !== undefined ? { name: configuration.name } : {}),
|
query.set("model", configuration.model);
|
||||||
label: configuration.label,
|
appendModelGenerationSettings(query, configuration);
|
||||||
provider: configuration.provider,
|
return request<SettingsPayload>(
|
||||||
model: configuration.model,
|
`${base}/api/settings/model-configurations/create?${query}`,
|
||||||
...modelGenerationSettingsPayload(configuration),
|
token,
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateModelConfiguration(
|
export async function updateModelConfiguration(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
configuration: ModelConfigurationUpdate,
|
configuration: ModelConfigurationUpdate,
|
||||||
|
base: string = "",
|
||||||
): Promise<SettingsPayload> {
|
): Promise<SettingsPayload> {
|
||||||
return mutation<SettingsPayload>(
|
const query = new URLSearchParams();
|
||||||
transport,
|
query.set("name", configuration.name);
|
||||||
"settings.model_configuration.update",
|
if (configuration.label !== undefined) query.set("label", configuration.label);
|
||||||
{
|
if (configuration.provider !== undefined) query.set("provider", configuration.provider);
|
||||||
name: configuration.name,
|
if (configuration.model !== undefined) query.set("model", configuration.model);
|
||||||
...(configuration.label !== undefined ? { label: configuration.label } : {}),
|
appendModelGenerationSettings(query, configuration);
|
||||||
...(configuration.provider !== undefined ? { provider: configuration.provider } : {}),
|
return request<SettingsPayload>(
|
||||||
...(configuration.model !== undefined ? { model: configuration.model } : {}),
|
`${base}/api/settings/model-configurations/update?${query}`,
|
||||||
...modelGenerationSettingsPayload(configuration),
|
token,
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteModelConfiguration(
|
export async function deleteModelConfiguration(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
name: string,
|
name: string,
|
||||||
|
base: string = "",
|
||||||
): Promise<SettingsPayload> {
|
): Promise<SettingsPayload> {
|
||||||
return mutation<SettingsPayload>(
|
const query = new URLSearchParams({ name });
|
||||||
transport,
|
return request<SettingsPayload>(
|
||||||
"settings.model_configuration.delete",
|
`${base}/api/settings/model-configurations/delete?${query}`,
|
||||||
{ name },
|
token,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function migrateModelConfigurations(
|
export async function migrateModelConfigurations(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
|
base: string = "",
|
||||||
): Promise<SettingsPayload> {
|
): Promise<SettingsPayload> {
|
||||||
return mutation<SettingsPayload>(transport, "settings.model_configuration.migrate");
|
return request<SettingsPayload>(
|
||||||
|
`${base}/api/settings/model-configurations/migrate`,
|
||||||
|
token,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateModelCallOrder(
|
export async function updateModelCallOrder(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
order: string[],
|
order: string[],
|
||||||
|
base: string = "",
|
||||||
): Promise<SettingsPayload> {
|
): Promise<SettingsPayload> {
|
||||||
return mutation<SettingsPayload>(transport, "settings.model_call_order.update", { order });
|
const query = new URLSearchParams({ order: JSON.stringify(order) });
|
||||||
|
return request<SettingsPayload>(
|
||||||
|
`${base}/api/settings/model-call-order/update?${query}`,
|
||||||
|
token,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProviderSettings(
|
export async function updateProviderSettings(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
update: ProviderSettingsUpdate,
|
update: ProviderSettingsUpdate,
|
||||||
|
base: string = "",
|
||||||
): Promise<SettingsPayload> {
|
): Promise<SettingsPayload> {
|
||||||
return mutation<SettingsPayload>(transport, "settings.provider.update", { ...update });
|
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)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createProviderSettings(
|
export async function createProviderSettings(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
update: ProviderCreationUpdate,
|
update: ProviderCreationUpdate,
|
||||||
|
base: string = "",
|
||||||
): Promise<SettingsPayload> {
|
): Promise<SettingsPayload> {
|
||||||
return mutation<SettingsPayload>(transport, "settings.provider.create", { ...update });
|
return request<SettingsPayload>(
|
||||||
|
`${base}/api/settings/provider/create`,
|
||||||
|
token,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
[PROVIDER_VALUES_HEADER]: encodeURIComponent(JSON.stringify(update)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginProviderOAuth(
|
export async function loginProviderOAuth(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
provider: string,
|
provider: string,
|
||||||
|
base: string = "",
|
||||||
remoteBrowserAccess: boolean = false,
|
remoteBrowserAccess: boolean = false,
|
||||||
): Promise<ProviderOAuthLoginResult> {
|
): Promise<ProviderOAuthLoginResult> {
|
||||||
return mutation<ProviderOAuthLoginResult>(
|
const query = new URLSearchParams();
|
||||||
transport,
|
query.set("provider", provider);
|
||||||
"settings.provider.oauth_login",
|
if (remoteBrowserAccess) query.set("remote_browser", "true");
|
||||||
{ provider, ...(remoteBrowserAccess ? { remote_browser: true } : {}) },
|
return request<ProviderOAuthLoginResult>(
|
||||||
|
`${base}/api/settings/provider/oauth-login?${query}`,
|
||||||
|
token,
|
||||||
|
{ cache: "no-store" },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function completeProviderOAuth(
|
export async function completeProviderOAuth(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
provider: string,
|
provider: string,
|
||||||
flowId: string,
|
flowId: string,
|
||||||
authorizationResponse?: string,
|
authorizationResponse?: string,
|
||||||
|
base: string = "",
|
||||||
): Promise<ProviderOAuthCompletionResult> {
|
): Promise<ProviderOAuthCompletionResult> {
|
||||||
return mutation<ProviderOAuthCompletionResult>(
|
const query = new URLSearchParams();
|
||||||
transport,
|
query.set("provider", provider);
|
||||||
"settings.provider.oauth_complete",
|
query.set("flow_id", flowId);
|
||||||
{
|
const responseHeader = provider === "openai_codex"
|
||||||
provider,
|
? OAUTH_CALLBACK_HEADER
|
||||||
flow_id: flowId,
|
: OAUTH_CODE_HEADER;
|
||||||
...(authorizationResponse ? { authorization_response: authorizationResponse } : {}),
|
const headers = authorizationResponse
|
||||||
},
|
? { [responseHeader]: authorizationResponse }
|
||||||
|
: undefined;
|
||||||
|
return request<ProviderOAuthCompletionResult>(
|
||||||
|
`${base}/api/settings/provider/oauth-login/complete?${query}`,
|
||||||
|
token,
|
||||||
|
{ cache: "no-store", ...(headers ? { headers } : {}) },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logoutProviderOAuth(
|
export async function logoutProviderOAuth(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
provider: string,
|
provider: string,
|
||||||
|
base: string = "",
|
||||||
): Promise<SettingsPayload> {
|
): Promise<SettingsPayload> {
|
||||||
return mutation<SettingsPayload>(transport, "settings.provider.oauth_logout", { provider });
|
const query = new URLSearchParams();
|
||||||
|
query.set("provider", provider);
|
||||||
|
return request<SettingsPayload>(
|
||||||
|
`${base}/api/settings/provider/oauth-logout?${query}`,
|
||||||
|
token,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateWebSearchSettings(
|
export async function updateWebSearchSettings(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
update: WebSearchSettingsUpdate,
|
update: WebSearchSettingsUpdate,
|
||||||
|
base: string = "",
|
||||||
): Promise<SettingsPayload> {
|
): Promise<SettingsPayload> {
|
||||||
return mutation<SettingsPayload>(
|
const query = new URLSearchParams();
|
||||||
transport,
|
query.set("provider", update.provider);
|
||||||
"settings.web_search.update",
|
if (update.apiKey !== undefined) query.set("api_key", update.apiKey);
|
||||||
{
|
if (update.baseUrl !== undefined) query.set("base_url", update.baseUrl);
|
||||||
provider: update.provider,
|
if (update.maxResults !== undefined) query.set("max_results", String(update.maxResults));
|
||||||
...(update.apiKey !== undefined ? { api_key: update.apiKey } : {}),
|
if (update.timeout !== undefined) query.set("timeout", String(update.timeout));
|
||||||
...(update.baseUrl !== undefined ? { base_url: update.baseUrl } : {}),
|
if (update.useJinaReader !== undefined) {
|
||||||
...(update.maxResults !== undefined ? { max_results: update.maxResults } : {}),
|
query.set("use_jina_reader", String(update.useJinaReader));
|
||||||
...(update.timeout !== undefined ? { timeout: update.timeout } : {}),
|
}
|
||||||
...(update.useJinaReader !== undefined
|
return request<SettingsPayload>(
|
||||||
? { use_jina_reader: update.useJinaReader }
|
`${base}/api/settings/web-search/update?${query}`,
|
||||||
: {}),
|
token,
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateNetworkSafetySettings(
|
export async function updateNetworkSafetySettings(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
update: NetworkSafetySettingsUpdate,
|
update: NetworkSafetySettingsUpdate,
|
||||||
|
base: string = "",
|
||||||
): Promise<SettingsPayload> {
|
): Promise<SettingsPayload> {
|
||||||
return mutation<SettingsPayload>(
|
const query = new URLSearchParams();
|
||||||
transport,
|
query.set("webui_allow_local_service_access", String(update.webuiAllowLocalServiceAccess));
|
||||||
"settings.network_safety.update",
|
query.set("webui_default_access_mode", update.webuiDefaultAccessMode);
|
||||||
{
|
return request<SettingsPayload>(
|
||||||
webui_allow_local_service_access: update.webuiAllowLocalServiceAccess,
|
`${base}/api/settings/network-safety/update?${query}`,
|
||||||
webui_default_access_mode: update.webuiDefaultAccessMode,
|
token,
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateImageGenerationSettings(
|
export async function updateImageGenerationSettings(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
update: ImageGenerationSettingsUpdate,
|
update: ImageGenerationSettingsUpdate,
|
||||||
|
base: string = "",
|
||||||
): Promise<SettingsPayload> {
|
): Promise<SettingsPayload> {
|
||||||
return mutation<SettingsPayload>(
|
const query = new URLSearchParams();
|
||||||
transport,
|
query.set("enabled", String(update.enabled));
|
||||||
"settings.image_generation.update",
|
query.set("provider", update.provider);
|
||||||
{
|
query.set("model", update.model);
|
||||||
enabled: update.enabled,
|
query.set("default_aspect_ratio", update.defaultAspectRatio);
|
||||||
provider: update.provider,
|
query.set("default_image_size", update.defaultImageSize);
|
||||||
model: update.model,
|
query.set("max_images_per_turn", String(update.maxImagesPerTurn));
|
||||||
default_aspect_ratio: update.defaultAspectRatio,
|
return request<SettingsPayload>(
|
||||||
default_image_size: update.defaultImageSize,
|
`${base}/api/settings/image-generation/update?${query}`,
|
||||||
max_images_per_turn: update.maxImagesPerTurn,
|
token,
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateTranscriptionSettings(
|
export async function updateTranscriptionSettings(
|
||||||
transport: WebUIMutationTransport,
|
token: string,
|
||||||
update: TranscriptionSettingsUpdate,
|
update: TranscriptionSettingsUpdate,
|
||||||
|
base: string = "",
|
||||||
): Promise<SettingsPayload> {
|
): Promise<SettingsPayload> {
|
||||||
return mutation<SettingsPayload>(
|
const query = new URLSearchParams();
|
||||||
transport,
|
query.set("enabled", String(update.enabled));
|
||||||
"settings.transcription.update",
|
query.set("provider", update.provider);
|
||||||
{
|
query.set("model", update.model);
|
||||||
enabled: update.enabled,
|
query.set("language", update.language);
|
||||||
provider: update.provider,
|
query.set("max_duration_sec", String(update.maxDurationSec));
|
||||||
model: update.model,
|
query.set("max_upload_mb", String(update.maxUploadMb));
|
||||||
language: update.language,
|
return request<SettingsPayload>(
|
||||||
max_duration_sec: update.maxDurationSec,
|
`${base}/api/settings/transcription/update?${query}`,
|
||||||
max_upload_mb: update.maxUploadMb,
|
token,
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,16 +108,6 @@ interface PendingRequest<T> {
|
|||||||
timer: ReturnType<typeof setTimeout>;
|
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> {
|
interface PendingChatRequest extends PendingRequest<string> {
|
||||||
temporary: boolean;
|
temporary: boolean;
|
||||||
}
|
}
|
||||||
@@ -213,7 +203,6 @@ export class NanobotClient {
|
|||||||
private pendingNewChat: PendingChatRequest | null = null;
|
private pendingNewChat: PendingChatRequest | null = null;
|
||||||
private pendingTranscriptions = new Map<string, PendingRequest<string>>();
|
private pendingTranscriptions = new Map<string, PendingRequest<string>>();
|
||||||
private pendingSystemCommands = new Map<string, PendingRequest<void>>();
|
private pendingSystemCommands = new Map<string, PendingRequest<void>>();
|
||||||
private pendingWebUIRequests = new Map<string, PendingRequest<unknown>>();
|
|
||||||
// Frames queued while the socket is not yet OPEN
|
// Frames queued while the socket is not yet OPEN
|
||||||
private sendQueue: Outbound[] = [];
|
private sendQueue: Outbound[] = [];
|
||||||
private reconnectAttempts = 0;
|
private reconnectAttempts = 0;
|
||||||
@@ -818,60 +807,6 @@ 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. */
|
/** Ask the server to create a non-destructive fork before a user-message index. */
|
||||||
forkChat(
|
forkChat(
|
||||||
sourceChatId: string,
|
sourceChatId: string,
|
||||||
@@ -979,8 +914,8 @@ export class NanobotClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setSidebarState(state: SidebarStatePayload): Promise<SidebarStatePayload> {
|
setSidebarState(state: SidebarStatePayload): void {
|
||||||
return this.requestMutation<SidebarStatePayload>("sidebar.update", { state });
|
this.queueSend({ type: "set_sidebar_state", state });
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- internals ---------------------------------------------------------
|
// -- internals ---------------------------------------------------------
|
||||||
@@ -1030,23 +965,6 @@ export class NanobotClient {
|
|||||||
console.log("[nanobot ws inbound]", summarizeInboundWsPayload(parsed));
|
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) {
|
if (parsed.event === "error" && !parsed.turn_id) {
|
||||||
const fallback = this.legacyRejectionTarget(parsed);
|
const fallback = this.legacyRejectionTarget(parsed);
|
||||||
if (fallback) {
|
if (fallback) {
|
||||||
@@ -1233,13 +1151,6 @@ export class NanobotClient {
|
|||||||
this.pendingNewChat = null;
|
this.pendingNewChat = null;
|
||||||
}
|
}
|
||||||
this.rejectAllTranscriptions("socket closed");
|
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()) {
|
for (const pending of this.pendingSystemCommands.values()) {
|
||||||
clearTimeout(pending.timer);
|
clearTimeout(pending.timer);
|
||||||
pending.reject(new Error("socket closed"));
|
pending.reject(new Error("socket closed"));
|
||||||
|
|||||||
@@ -1,357 +0,0 @@
|
|||||||
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,18 +1261,6 @@ export type InboundEvent =
|
|||||||
detail?: string;
|
detail?: string;
|
||||||
provider?: 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";
|
event: "error";
|
||||||
chat_id?: string;
|
chat_id?: string;
|
||||||
@@ -1351,12 +1339,6 @@ export interface FilePreviewPayload {
|
|||||||
export type Outbound =
|
export type Outbound =
|
||||||
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
|
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
|
||||||
| { type: "new_temporary_chat" }
|
| { 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: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
|
||||||
| { type: "attach"; chat_id: string }
|
| { type: "attach"; chat_id: string }
|
||||||
| { type: "set_sidebar_state"; state: SidebarStatePayload }
|
| { type: "set_sidebar_state"; state: SidebarStatePayload }
|
||||||
|
|||||||
+359
-310
@@ -59,19 +59,8 @@ import {
|
|||||||
validateChannel,
|
validateChannel,
|
||||||
} from "@/lib/api";
|
} 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", () => {
|
describe("webui API helpers", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
requestMutation.mockReset();
|
|
||||||
requestMutation.mockResolvedValue({});
|
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
vi.fn().mockResolvedValue({
|
vi.fn().mockResolvedValue({
|
||||||
@@ -195,74 +184,88 @@ describe("webui API helpers", () => {
|
|||||||
|
|
||||||
it("validates channel settings with form values", async () => {
|
it("validates channel settings with form values", async () => {
|
||||||
await validateChannel(
|
await validateChannel(
|
||||||
mutationTransport,
|
"tok",
|
||||||
"slack",
|
"slack",
|
||||||
{ "channels.slack.botToken": "xoxb-test" },
|
{ "channels.slack.botToken": "xoxb-test" },
|
||||||
{ instanceId: "default" },
|
{ instanceId: "default" },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(requestMutation).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"settings.channel.validate",
|
"/api/settings/channels/validate?name=slack&instance_id=default",
|
||||||
{
|
expect.objectContaining({
|
||||||
name: "slack",
|
headers: expect.objectContaining({
|
||||||
instance_id: "default",
|
Authorization: "Bearer tok",
|
||||||
values: { "channels.slack.botToken": "xoxb-test" },
|
"X-Nanobot-Channel-Values": JSON.stringify({
|
||||||
},
|
"channels.slack.botToken": "xoxb-test",
|
||||||
20_000,
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(fetch).not.toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ method: "POST" }),
|
||||||
);
|
);
|
||||||
expect(fetch).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("configures channels through the authenticated WebSocket", async () => {
|
it("configures channels through the WebSocket HTTP shim", async () => {
|
||||||
await configureChannel(
|
await configureChannel(
|
||||||
mutationTransport,
|
"tok",
|
||||||
"discord",
|
"discord",
|
||||||
{ "channels.discord.token": "saved-secret" },
|
{ "channels.discord.token": "saved-secret" },
|
||||||
{ enable: true },
|
{ enable: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(requestMutation).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"settings.channel.configure",
|
"/api/settings/channels/configure?name=discord&enable=true",
|
||||||
{
|
expect.objectContaining({
|
||||||
name: "discord",
|
headers: expect.objectContaining({
|
||||||
enable: true,
|
Authorization: "Bearer tok",
|
||||||
values: { "channels.discord.token": "saved-secret" },
|
"X-Nanobot-Channel-Values": JSON.stringify({
|
||||||
},
|
"channels.discord.token": "saved-secret",
|
||||||
150_000,
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(fetch).not.toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ method: "POST" }),
|
||||||
);
|
);
|
||||||
expect(fetch).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serializes channel QR connect request envelopes", async () => {
|
it("serializes channel QR connect helpers", async () => {
|
||||||
await startChannelConnect(mutationTransport, "weixin", { force: true });
|
await startChannelConnect("tok", "weixin", { force: true });
|
||||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
expect(fetch).toHaveBeenLastCalledWith(
|
||||||
"settings.channel.connect.start",
|
"/api/settings/channels/weixin/connect/start?force=true",
|
||||||
{ channel: "weixin", force: true },
|
expect.objectContaining({
|
||||||
150_000,
|
headers: { Authorization: "Bearer tok" },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await pollChannelConnect(mutationTransport, "weixin", "session+/=");
|
await pollChannelConnect("tok", "weixin", "session+/=");
|
||||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
expect(fetch).toHaveBeenLastCalledWith(
|
||||||
"settings.channel.connect.poll",
|
"/api/settings/channels/weixin/connect/poll?session_id=session%2B%2F%3D",
|
||||||
{ channel: "weixin", session_id: "session+/=" },
|
expect.objectContaining({
|
||||||
150_000,
|
headers: { Authorization: "Bearer tok" },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await cancelChannelConnect(mutationTransport, "weixin", "session+/=");
|
await cancelChannelConnect("tok", "weixin", "session+/=");
|
||||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
expect(fetch).toHaveBeenLastCalledWith(
|
||||||
"settings.channel.connect.cancel",
|
"/api/settings/channels/weixin/connect/cancel?session_id=session%2B%2F%3D",
|
||||||
{ channel: "weixin", session_id: "session+/=" },
|
expect.objectContaining({
|
||||||
20_000,
|
headers: { Authorization: "Bearer tok" },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serializes workspace automation actions", async () => {
|
it("serializes workspace automation actions", async () => {
|
||||||
await runAutomationAction(mutationTransport, "disable", "job 1/2");
|
await runAutomationAction("tok", "disable", "job 1/2");
|
||||||
|
|
||||||
expect(requestMutation).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"automation.disable",
|
"/api/webui/automations/disable?id=job+1%2F2",
|
||||||
{ id: "job 1/2" },
|
expect.objectContaining({
|
||||||
20_000,
|
headers: { Authorization: "Bearer tok" },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -272,14 +275,19 @@ describe("webui API helpers", () => {
|
|||||||
message: "Ask 今日 quiz",
|
message: "Ask 今日 quiz",
|
||||||
schedule: { kind: "cron", expr: "0 9 * * *", tz: "Asia/Shanghai" },
|
schedule: { kind: "cron", expr: "0 9 * * *", tz: "Asia/Shanghai" },
|
||||||
} as const;
|
} as const;
|
||||||
await updateAutomation(mutationTransport, "job 1/2", values);
|
await updateAutomation("tok", "job 1/2", values);
|
||||||
|
|
||||||
expect(requestMutation).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"automation.update",
|
"/api/webui/automations/update?id=job+1%2F2",
|
||||||
{ id: "job 1/2", values },
|
expect.objectContaining({
|
||||||
20_000,
|
headers: {
|
||||||
|
Authorization: "Bearer tok",
|
||||||
|
"X-Nanobot-Automation-Values": encodeURIComponent(JSON.stringify(values)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
expect(fetch).not.toHaveBeenCalled();
|
const header = vi.mocked(fetch).mock.calls[0][1]?.headers as Record<string, string>;
|
||||||
|
expect(header["X-Nanobot-Automation-Values"]).not.toContain("每日");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fetches the WebUI skill summary", async () => {
|
it("fetches the WebUI skill summary", async () => {
|
||||||
@@ -340,66 +348,66 @@ describe("webui API helpers", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends provider install coordinates without placing them in a URL", async () => {
|
it("encodes provider install coordinates", async () => {
|
||||||
await installMarketplaceSkill(
|
await installMarketplaceSkill(
|
||||||
mutationTransport,
|
"tok",
|
||||||
"skillhub",
|
"skillhub",
|
||||||
"@tencent/skills",
|
"@tencent/skills",
|
||||||
"ima-skills",
|
"ima-skills",
|
||||||
"1.1.8",
|
"1.1.8",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(requestMutation).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"skill.install",
|
"/api/webui/skills/install?provider=skillhub&source=%40tencent%2Fskills&skill=ima-skills&version=1.1.8",
|
||||||
{
|
expect.objectContaining({
|
||||||
provider: "skillhub",
|
headers: { Authorization: "Bearer tok" },
|
||||||
source: "@tencent/skills",
|
}),
|
||||||
skill: "ima-skills",
|
|
||||||
version: "1.1.8",
|
|
||||||
},
|
|
||||||
150_000,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates and deletes installed skills over the WebSocket", async () => {
|
it("updates and deletes installed skills with encoded names", async () => {
|
||||||
await updateSkillEnabled(mutationTransport, "custom skill", false);
|
await updateSkillEnabled("tok", "custom skill", false);
|
||||||
|
|
||||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"skill.update",
|
"/api/webui/skills/update?name=custom+skill&enabled=false",
|
||||||
{ name: "custom skill", enabled: false },
|
expect.objectContaining({
|
||||||
20_000,
|
headers: { Authorization: "Bearer tok" },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await deleteSkill(mutationTransport, "custom skill");
|
await deleteSkill("tok", "custom skill");
|
||||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"skill.delete",
|
"/api/webui/skills/delete?name=custom+skill",
|
||||||
{ name: "custom skill" },
|
expect.objectContaining({
|
||||||
20_000,
|
headers: { Authorization: "Bearer tok" },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends the session key in a mutation payload", async () => {
|
it("percent-encodes websocket keys when deleting a session", async () => {
|
||||||
await deleteSession(mutationTransport, "websocket:chat-1");
|
await deleteSession("tok", "websocket:chat-1");
|
||||||
|
|
||||||
expect(requestMutation).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"session.delete",
|
"/api/sessions/websocket%3Achat-1/delete",
|
||||||
{ key: "websocket:chat-1" },
|
expect.objectContaining({
|
||||||
20_000,
|
headers: { Authorization: "Bearer tok" },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes the automation cascade flag when deleting a session", async () => {
|
it("passes the automation cascade flag when deleting a session", async () => {
|
||||||
await deleteSession(mutationTransport, "websocket:chat-1", { deleteAutomations: true });
|
await deleteSession("tok", "websocket:chat-1", { deleteAutomations: true });
|
||||||
|
|
||||||
expect(requestMutation).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"session.delete",
|
"/api/sessions/websocket%3Achat-1/delete?delete_automations=true",
|
||||||
{ key: "websocket:chat-1", delete_automations: true },
|
expect.objectContaining({
|
||||||
20_000,
|
headers: { Authorization: "Bearer tok" },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serializes settings updates as a narrow mutation payload", async () => {
|
it("serializes settings updates as a narrow query string", async () => {
|
||||||
await updateSettings(mutationTransport, {
|
await updateSettings("tok", {
|
||||||
modelPreset: "default",
|
modelPreset: "default",
|
||||||
model: "openrouter/test",
|
model: "openrouter/test",
|
||||||
provider: "openrouter",
|
provider: "openrouter",
|
||||||
@@ -408,17 +416,11 @@ describe("webui API helpers", () => {
|
|||||||
toolHintMaxLength: 120,
|
toolHintMaxLength: 120,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(requestMutation).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"settings.agent.update",
|
"/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({
|
||||||
model_preset: "default",
|
headers: { Authorization: "Bearer tok" },
|
||||||
model: "openrouter/test",
|
}),
|
||||||
provider: "openrouter",
|
|
||||||
context_window_tokens: 262144,
|
|
||||||
timezone: "Asia/Shanghai",
|
|
||||||
tool_hint_max_length: 120,
|
|
||||||
},
|
|
||||||
20_000,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -434,7 +436,7 @@ describe("webui API helpers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("serializes model configuration creation", async () => {
|
it("serializes model configuration creation", async () => {
|
||||||
await createModelConfiguration(mutationTransport, {
|
await createModelConfiguration("tok", {
|
||||||
label: "Fast writing",
|
label: "Fast writing",
|
||||||
provider: "openai",
|
provider: "openai",
|
||||||
model: "openai/gpt-4.1-mini",
|
model: "openai/gpt-4.1-mini",
|
||||||
@@ -444,23 +446,16 @@ describe("webui API helpers", () => {
|
|||||||
reasoningEffort: "high",
|
reasoningEffort: "high",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(requestMutation).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"settings.model_configuration.create",
|
"/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({
|
||||||
label: "Fast writing",
|
headers: { Authorization: "Bearer tok" },
|
||||||
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 () => {
|
it("serializes model configuration updates", async () => {
|
||||||
await updateModelConfiguration(mutationTransport, {
|
await updateModelConfiguration("tok", {
|
||||||
name: "codex",
|
name: "codex",
|
||||||
label: "Codex",
|
label: "Codex",
|
||||||
provider: "openai_codex",
|
provider: "openai_codex",
|
||||||
@@ -471,47 +466,42 @@ describe("webui API helpers", () => {
|
|||||||
reasoningEffort: null,
|
reasoningEffort: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(requestMutation).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"settings.model_configuration.update",
|
"/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({
|
||||||
name: "codex",
|
headers: { Authorization: "Bearer tok" },
|
||||||
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 () => {
|
it("serializes model preset deletion and migration", async () => {
|
||||||
await deleteModelConfiguration(mutationTransport, "spare");
|
await deleteModelConfiguration("tok", "spare");
|
||||||
await migrateModelConfigurations(mutationTransport);
|
await migrateModelConfigurations("tok");
|
||||||
|
|
||||||
expect(requestMutation).toHaveBeenNthCalledWith(
|
expect(fetch).toHaveBeenNthCalledWith(
|
||||||
1,
|
1,
|
||||||
"settings.model_configuration.delete",
|
"/api/settings/model-configurations/delete?name=spare",
|
||||||
{ name: "spare" },
|
expect.objectContaining({
|
||||||
20_000,
|
headers: { Authorization: "Bearer tok" },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
expect(requestMutation).toHaveBeenNthCalledWith(
|
expect(fetch).toHaveBeenNthCalledWith(
|
||||||
2,
|
2,
|
||||||
"settings.model_configuration.migrate",
|
"/api/settings/model-configurations/migrate",
|
||||||
{},
|
expect.objectContaining({
|
||||||
20_000,
|
headers: { Authorization: "Bearer tok" },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serializes model call order as an ordered JSON array", async () => {
|
it("serializes model call order as an ordered JSON array", async () => {
|
||||||
await updateModelCallOrder(mutationTransport, ["backup", "primary"]);
|
await updateModelCallOrder("tok", ["backup", "primary"]);
|
||||||
|
|
||||||
expect(requestMutation).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"settings.model_call_order.update",
|
"/api/settings/model-call-order/update?order=%5B%22backup%22%2C%22primary%22%5D",
|
||||||
{ order: ["backup", "primary"] },
|
expect.objectContaining({
|
||||||
20_000,
|
headers: { Authorization: "Bearer tok" },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -526,20 +516,28 @@ describe("webui API helpers", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(fetchApiService("tok")).rejects.toMatchObject({
|
await expect(
|
||||||
|
updateModelConfiguration("tok", {
|
||||||
|
name: "codex",
|
||||||
|
model: "openai-codex/gpt-5.5",
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
status: 200,
|
status: 200,
|
||||||
message: "Gateway returned WebUI HTML instead of JSON. Restart nanobot gateway and try again.",
|
message: "Gateway returned WebUI HTML instead of JSON. Restart nanobot gateway and try again.",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("surfaces correlated WebSocket mutation errors", async () => {
|
it("surfaces API error response bodies", async () => {
|
||||||
requestMutation.mockRejectedValueOnce(
|
vi.stubGlobal(
|
||||||
Object.assign(new Error("npm error ENOTEMPTY"), { status: 500 }),
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
text: async () => "npm error ENOTEMPTY",
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(runCliAppAction("tok", "install", "hyperframes")).rejects.toMatchObject({
|
||||||
runCliAppAction(mutationTransport, "install", "hyperframes"),
|
|
||||||
).rejects.toMatchObject({
|
|
||||||
status: 500,
|
status: 500,
|
||||||
message: "npm error ENOTEMPTY",
|
message: "npm error ENOTEMPTY",
|
||||||
});
|
});
|
||||||
@@ -557,45 +555,50 @@ describe("webui API helpers", () => {
|
|||||||
await pending;
|
await pending;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps provider secrets in the WebSocket payload", async () => {
|
it("serializes provider settings updates without returning secrets", async () => {
|
||||||
await updateProviderSettings(mutationTransport, {
|
await updateProviderSettings("tok", {
|
||||||
provider: "openrouter",
|
provider: "openrouter",
|
||||||
apiKey: "sk-or-test",
|
apiKey: "sk-or-test",
|
||||||
apiBase: "https://openrouter.ai/api/v1",
|
apiBase: "https://openrouter.ai/api/v1",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(requestMutation).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"settings.provider.update",
|
"/api/settings/provider/update?provider=openrouter",
|
||||||
{
|
expect.objectContaining({
|
||||||
provider: "openrouter",
|
headers: {
|
||||||
apiKey: "sk-or-test",
|
Authorization: "Bearer tok",
|
||||||
apiBase: "https://openrouter.ai/api/v1",
|
"X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({
|
||||||
},
|
apiKey: "sk-or-test",
|
||||||
20_000,
|
apiBase: "https://openrouter.ai/api/v1",
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
expect(fetch).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serializes OAuth provider advanced settings", async () => {
|
it("serializes OAuth provider advanced settings", async () => {
|
||||||
await updateProviderSettings(mutationTransport, {
|
await updateProviderSettings("tok", {
|
||||||
provider: "xai_grok",
|
provider: "xai_grok",
|
||||||
proxy: "http://127.0.0.1:7890",
|
proxy: "http://127.0.0.1:7890",
|
||||||
extraBody: '{"tools":[]}',
|
extraBody: '{"tools":[]}',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(requestMutation).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"settings.provider.update",
|
"/api/settings/provider/update?provider=xai_grok",
|
||||||
{
|
expect.objectContaining({
|
||||||
provider: "xai_grok",
|
headers: {
|
||||||
proxy: "http://127.0.0.1:7890",
|
Authorization: "Bearer tok",
|
||||||
extraBody: '{"tools":[]}',
|
"X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({
|
||||||
},
|
proxy: "http://127.0.0.1:7890",
|
||||||
20_000,
|
extraBody: '{"tools":[]}',
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serializes custom provider creation with advanced settings", async () => {
|
it("serializes custom provider creation with advanced settings", async () => {
|
||||||
const update = {
|
await createProviderSettings("tok", {
|
||||||
name: "Company Gateway",
|
name: "Company Gateway",
|
||||||
apiKey: "sk-company",
|
apiKey: "sk-company",
|
||||||
apiBase: "https://gateway.example/v1",
|
apiBase: "https://gateway.example/v1",
|
||||||
@@ -604,13 +607,25 @@ describe("webui API helpers", () => {
|
|||||||
extraQuery: '{"api-version":"2026-01-01"}',
|
extraQuery: '{"api-version":"2026-01-01"}',
|
||||||
proxy: "http://127.0.0.1:7890",
|
proxy: "http://127.0.0.1:7890",
|
||||||
thinkingStyle: "enable_thinking",
|
thinkingStyle: "enable_thinking",
|
||||||
};
|
});
|
||||||
await createProviderSettings(mutationTransport, update);
|
|
||||||
|
|
||||||
expect(requestMutation).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"settings.provider.create",
|
"/api/settings/provider/create",
|
||||||
update,
|
expect.objectContaining({
|
||||||
20_000,
|
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",
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -626,65 +641,74 @@ describe("webui API helpers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("serializes provider OAuth login and logout actions", async () => {
|
it("serializes provider OAuth login and logout actions", async () => {
|
||||||
await loginProviderOAuth(mutationTransport, "openai_codex");
|
await loginProviderOAuth("tok", "openai_codex");
|
||||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"settings.provider.oauth_login",
|
"/api/settings/provider/oauth-login?provider=openai_codex",
|
||||||
{ provider: "openai_codex" },
|
expect.objectContaining({
|
||||||
20_000,
|
headers: { Authorization: "Bearer tok" },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await loginProviderOAuth(mutationTransport, "openai_codex", true);
|
await loginProviderOAuth("tok", "openai_codex", "", true);
|
||||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"settings.provider.oauth_login",
|
"/api/settings/provider/oauth-login?provider=openai_codex&remote_browser=true",
|
||||||
{ provider: "openai_codex", remote_browser: true },
|
expect.objectContaining({
|
||||||
20_000,
|
headers: { Authorization: "Bearer tok" },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await completeProviderOAuth(mutationTransport, "xai_grok", "flow-123");
|
await completeProviderOAuth("tok", "xai_grok", "flow-123");
|
||||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"settings.provider.oauth_complete",
|
"/api/settings/provider/oauth-login/complete?provider=xai_grok&flow_id=flow-123",
|
||||||
{ provider: "xai_grok", flow_id: "flow-123" },
|
expect.objectContaining({
|
||||||
20_000,
|
headers: { Authorization: "Bearer tok" },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await completeProviderOAuth(
|
await completeProviderOAuth(
|
||||||
mutationTransport,
|
"tok",
|
||||||
"xai_grok",
|
"xai_grok",
|
||||||
"flow-123",
|
"flow-123",
|
||||||
"secret",
|
"secret",
|
||||||
);
|
);
|
||||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"settings.provider.oauth_complete",
|
"/api/settings/provider/oauth-login/complete?provider=xai_grok&flow_id=flow-123",
|
||||||
{ provider: "xai_grok", flow_id: "flow-123", authorization_response: "secret" },
|
expect.objectContaining({
|
||||||
20_000,
|
headers: {
|
||||||
|
Authorization: "Bearer tok",
|
||||||
|
"X-Nanobot-OAuth-Code": "secret",
|
||||||
|
},
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await completeProviderOAuth(
|
await completeProviderOAuth(
|
||||||
mutationTransport,
|
"tok",
|
||||||
"openai_codex",
|
"openai_codex",
|
||||||
"flow-codex",
|
"flow-codex",
|
||||||
"http://localhost:1455/auth/callback?code=secret&state=test",
|
"http://localhost:1455/auth/callback?code=secret&state=test",
|
||||||
);
|
);
|
||||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"settings.provider.oauth_complete",
|
"/api/settings/provider/oauth-login/complete?provider=openai_codex&flow_id=flow-codex",
|
||||||
{
|
expect.objectContaining({
|
||||||
provider: "openai_codex",
|
headers: {
|
||||||
flow_id: "flow-codex",
|
Authorization: "Bearer tok",
|
||||||
authorization_response: "http://localhost:1455/auth/callback?code=secret&state=test",
|
"X-Nanobot-OAuth-Callback":
|
||||||
},
|
"http://localhost:1455/auth/callback?code=secret&state=test",
|
||||||
20_000,
|
},
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await logoutProviderOAuth(mutationTransport, "openai_codex");
|
await logoutProviderOAuth("tok", "openai_codex");
|
||||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"settings.provider.oauth_logout",
|
"/api/settings/provider/oauth-logout?provider=openai_codex",
|
||||||
{ provider: "openai_codex" },
|
expect.objectContaining({
|
||||||
20_000,
|
headers: { Authorization: "Bearer tok" },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serializes web search settings updates", async () => {
|
it("serializes web search settings updates", async () => {
|
||||||
await updateWebSearchSettings(mutationTransport, {
|
await updateWebSearchSettings("tok", {
|
||||||
provider: "searxng",
|
provider: "searxng",
|
||||||
baseUrl: "https://search.example.com",
|
baseUrl: "https://search.example.com",
|
||||||
maxResults: 8,
|
maxResults: 8,
|
||||||
@@ -692,37 +716,30 @@ describe("webui API helpers", () => {
|
|||||||
useJinaReader: false,
|
useJinaReader: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(requestMutation).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"settings.web_search.update",
|
"/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({
|
||||||
provider: "searxng",
|
headers: { Authorization: "Bearer tok" },
|
||||||
base_url: "https://search.example.com",
|
}),
|
||||||
max_results: 8,
|
|
||||||
timeout: 45,
|
|
||||||
use_jina_reader: false,
|
|
||||||
},
|
|
||||||
20_000,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serializes network safety settings updates", async () => {
|
it("serializes network safety settings updates", async () => {
|
||||||
await updateNetworkSafetySettings(mutationTransport, {
|
await updateNetworkSafetySettings("tok", {
|
||||||
webuiAllowLocalServiceAccess: false,
|
webuiAllowLocalServiceAccess: false,
|
||||||
webuiDefaultAccessMode: "full",
|
webuiDefaultAccessMode: "full",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(requestMutation).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"settings.network_safety.update",
|
"/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=full",
|
||||||
{
|
expect.objectContaining({
|
||||||
webui_allow_local_service_access: false,
|
headers: { Authorization: "Bearer tok" },
|
||||||
webui_default_access_mode: "full",
|
}),
|
||||||
},
|
|
||||||
20_000,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serializes image generation settings updates", async () => {
|
it("serializes image generation settings updates", async () => {
|
||||||
await updateImageGenerationSettings(mutationTransport, {
|
await updateImageGenerationSettings("tok", {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
provider: "openrouter",
|
provider: "openrouter",
|
||||||
model: "openai/gpt-5.4-image-2",
|
model: "openai/gpt-5.4-image-2",
|
||||||
@@ -731,17 +748,11 @@ describe("webui API helpers", () => {
|
|||||||
maxImagesPerTurn: 3,
|
maxImagesPerTurn: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(requestMutation).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"settings.image_generation.update",
|
"/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({
|
||||||
enabled: true,
|
headers: { Authorization: "Bearer tok" },
|
||||||
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,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -763,11 +774,12 @@ describe("webui API helpers", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await runCliAppAction(mutationTransport, "install", "gimp");
|
await runCliAppAction("tok", "install", "gimp");
|
||||||
expect(requestMutation).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"settings.cli_app.install",
|
"/api/settings/cli-apps/install?name=gimp",
|
||||||
{ name: "gimp" },
|
expect.objectContaining({
|
||||||
150_000,
|
headers: { Authorization: "Bearer tok" },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -807,18 +819,20 @@ describe("webui API helpers", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await enableNanobotFeature(mutationTransport, "matrix");
|
await enableNanobotFeature("tok", "matrix");
|
||||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"settings.feature.enable",
|
"/api/settings/nanobot-features/enable?name=matrix",
|
||||||
{ name: "matrix" },
|
expect.objectContaining({
|
||||||
150_000,
|
headers: { Authorization: "Bearer tok" },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await disableNanobotFeature(mutationTransport, "matrix");
|
await disableNanobotFeature("tok", "matrix");
|
||||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"settings.feature.disable",
|
"/api/settings/nanobot-features/disable?name=matrix",
|
||||||
{ name: "matrix" },
|
expect.objectContaining({
|
||||||
20_000,
|
headers: { Authorization: "Bearer tok" },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -829,31 +843,34 @@ describe("webui API helpers", () => {
|
|||||||
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
||||||
);
|
);
|
||||||
|
|
||||||
await startApiService(
|
await startApiService("tok", { host: "127.0.0.1", port: 8900, timeout: 120 });
|
||||||
mutationTransport,
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
{ host: "127.0.0.1", port: 8900, timeout: 120 },
|
"/api/settings/api-service/start?host=127.0.0.1&port=8900&timeout=120",
|
||||||
);
|
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
||||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
|
||||||
"settings.api_service.start",
|
|
||||||
{ host: "127.0.0.1", port: 8900, timeout: 120 },
|
|
||||||
150_000,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await startApiService(
|
await startApiService(
|
||||||
mutationTransport,
|
"tok",
|
||||||
{ host: "0.0.0.0", port: 8900, timeout: 120, apiKey: "secret-token" },
|
{ host: "0.0.0.0", port: 8900, timeout: 120, apiKey: "secret-token" },
|
||||||
);
|
);
|
||||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"settings.api_service.start",
|
"/api/settings/api-service/start?host=0.0.0.0&port=8900&timeout=120",
|
||||||
{ host: "0.0.0.0", port: 8900, timeout: 120, api_key: "secret-token" },
|
expect.objectContaining({
|
||||||
150_000,
|
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(),
|
||||||
);
|
);
|
||||||
|
|
||||||
await stopApiService(mutationTransport);
|
await stopApiService("tok");
|
||||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"settings.api_service.stop",
|
"/api/settings/api-service/stop",
|
||||||
{},
|
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
||||||
20_000,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -874,46 +891,71 @@ describe("webui API helpers", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await runMcpPresetAction(mutationTransport, "enable", "browserbase", {
|
await runMcpPresetAction("tok", "enable", "browserbase", {
|
||||||
browserbase_api_key: "bb_live_test",
|
browserbase_api_key: "bb_live_test",
|
||||||
});
|
});
|
||||||
expect(requestMutation).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"settings.mcp.enable",
|
"/api/settings/mcp-presets/enable?name=browserbase",
|
||||||
{ name: "browserbase", browserbase_api_key: "bb_live_test" },
|
expect.objectContaining({
|
||||||
20_000,
|
headers: expect.objectContaining({
|
||||||
|
Authorization: "Bearer tok",
|
||||||
|
"X-Nanobot-MCP-Values": JSON.stringify({
|
||||||
|
browserbase_api_key: "bb_live_test",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serializes custom MCP, mcp.json import, and tool allowlist actions", async () => {
|
it("serializes custom MCP, mcp.json import, and tool allowlist actions", async () => {
|
||||||
const custom = {
|
await saveCustomMcpServer("tok", {
|
||||||
name: "docs",
|
name: "docs",
|
||||||
transport: "stdio",
|
transport: "stdio",
|
||||||
command: "npx",
|
command: "npx",
|
||||||
args: '["-y","docs-mcp"]',
|
args: '["-y","docs-mcp"]',
|
||||||
env: '{"API_KEY":"secret"}',
|
env: '{"API_KEY":"secret"}',
|
||||||
};
|
});
|
||||||
await saveCustomMcpServer(mutationTransport, custom);
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
"/api/settings/mcp-presets/custom",
|
||||||
"settings.mcp.custom",
|
expect.objectContaining({
|
||||||
custom,
|
headers: expect.objectContaining({
|
||||||
20_000,
|
Authorization: "Bearer tok",
|
||||||
|
"X-Nanobot-MCP-Values": JSON.stringify({
|
||||||
|
name: "docs",
|
||||||
|
transport: "stdio",
|
||||||
|
command: "npx",
|
||||||
|
args: '["-y","docs-mcp"]',
|
||||||
|
env: '{"API_KEY":"secret"}',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await importMcpConfig(
|
await importMcpConfig("tok", '{"mcpServers":{"docs":{"command":"npx"}}}');
|
||||||
mutationTransport,
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
'{"mcpServers":{"docs":{"command":"npx"}}}',
|
"/api/settings/mcp-presets/import",
|
||||||
);
|
expect.objectContaining({
|
||||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
headers: expect.objectContaining({
|
||||||
"settings.mcp.import",
|
Authorization: "Bearer tok",
|
||||||
{ config: '{"mcpServers":{"docs":{"command":"npx"}}}' },
|
"X-Nanobot-MCP-Values": JSON.stringify({
|
||||||
20_000,
|
config: '{"mcpServers":{"docs":{"command":"npx"}}}',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await updateMcpServerTools(mutationTransport, "docs", ["search", "fetch"]);
|
await updateMcpServerTools("tok", "docs", ["search", "fetch"]);
|
||||||
expect(requestMutation).toHaveBeenLastCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"settings.mcp.tools",
|
"/api/settings/mcp-presets/tools",
|
||||||
{ name: "docs", enabled_tools: ["search", "fetch"] },
|
expect.objectContaining({
|
||||||
20_000,
|
headers: expect.objectContaining({
|
||||||
|
Authorization: "Bearer tok",
|
||||||
|
"X-Nanobot-MCP-Values": JSON.stringify({
|
||||||
|
name: "docs",
|
||||||
|
enabled_tools: ["search", "fetch"],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -949,12 +991,19 @@ describe("webui API helpers", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await updateSidebarState(mutationTransport, state);
|
await updateSidebarState("tok", state);
|
||||||
expect(requestMutation).toHaveBeenCalledWith(
|
const [url, init] = vi.mocked(fetch).mock.calls.at(-1)!;
|
||||||
"sidebar.update",
|
expect(String(url).startsWith("/api/webui/sidebar-state/update?")).toBe(true);
|
||||||
{ state },
|
expect(init).toEqual(expect.objectContaining({
|
||||||
20_000,
|
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" },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fetches workspace project state", async () => {
|
it("fetches workspace project state", async () => {
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ const toggleThemeSpy = vi.fn();
|
|||||||
const updateUrlSpy = vi.fn();
|
const updateUrlSpy = vi.fn();
|
||||||
const attachSpy = vi.fn();
|
const attachSpy = vi.fn();
|
||||||
const setSidebarStateSpy = vi.fn();
|
const setSidebarStateSpy = vi.fn();
|
||||||
const requestMutationSpy = vi.fn();
|
|
||||||
const discardTemporaryChatSpy = vi.fn();
|
const discardTemporaryChatSpy = vi.fn();
|
||||||
const newTemporaryChatSpy = vi.fn<() => Promise<string>>();
|
const newTemporaryChatSpy = vi.fn<() => Promise<string>>();
|
||||||
const sendMessageSpy = vi.fn();
|
const sendMessageSpy = vi.fn();
|
||||||
@@ -243,7 +242,6 @@ vi.mock("@/lib/nanobot-client", async (importOriginal) => {
|
|||||||
newTemporaryChat = newTemporaryChatSpy;
|
newTemporaryChat = newTemporaryChatSpy;
|
||||||
attach = attachSpy;
|
attach = attachSpy;
|
||||||
setSidebarState = setSidebarStateSpy;
|
setSidebarState = setSidebarStateSpy;
|
||||||
requestMutation = requestMutationSpy;
|
|
||||||
discardTemporaryChat = discardTemporaryChatSpy;
|
discardTemporaryChat = discardTemporaryChatSpy;
|
||||||
close = vi.fn();
|
close = vi.fn();
|
||||||
updateUrl = updateUrlSpy;
|
updateUrl = updateUrlSpy;
|
||||||
@@ -272,8 +270,7 @@ describe("App layout", () => {
|
|||||||
getSessionAutomationsSpy.mockReset().mockResolvedValue([]);
|
getSessionAutomationsSpy.mockReset().mockResolvedValue([]);
|
||||||
toggleThemeSpy.mockReset();
|
toggleThemeSpy.mockReset();
|
||||||
attachSpy.mockReset();
|
attachSpy.mockReset();
|
||||||
setSidebarStateSpy.mockReset().mockResolvedValue({});
|
setSidebarStateSpy.mockReset();
|
||||||
requestMutationSpy.mockReset();
|
|
||||||
discardTemporaryChatSpy.mockReset();
|
discardTemporaryChatSpy.mockReset();
|
||||||
let temporaryChatCounter = 0;
|
let temporaryChatCounter = 0;
|
||||||
newTemporaryChatSpy.mockImplementation(async () => (
|
newTemporaryChatSpy.mockImplementation(async () => (
|
||||||
@@ -318,68 +315,11 @@ describe("App layout", () => {
|
|||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
expect(await screen.findByRole("heading", { level: 1, name: "Password" }))
|
expect(await screen.findByText("Authentication required")).toBeInTheDocument();
|
||||||
.toBeInTheDocument();
|
expect(screen.queryByText("Invalid password. Try again.")).not.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();
|
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 () => {
|
it("shows the auth form when bootstrap does not issue an API token", async () => {
|
||||||
vi.mocked(fetchBootstrap).mockRejectedValueOnce(
|
vi.mocked(fetchBootstrap).mockRejectedValueOnce(
|
||||||
new BootstrapAuthRequiredError(
|
new BootstrapAuthRequiredError(
|
||||||
@@ -389,11 +329,8 @@ describe("App layout", () => {
|
|||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
expect(await screen.findByRole("heading", { level: 1, name: "Password" }))
|
expect(await screen.findByText("Authentication required")).toBeInTheDocument();
|
||||||
.toBeInTheDocument();
|
expect(screen.queryByText("Invalid password. Try again.")).not.toBeInTheDocument();
|
||||||
expect(
|
|
||||||
screen.queryByText("Incorrect password. Try again."),
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
expect(connectSpy).not.toHaveBeenCalled();
|
expect(connectSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -404,16 +341,11 @@ describe("App layout", () => {
|
|||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
const password = await screen.findByLabelText("Password");
|
const password = await screen.findByPlaceholderText("Password");
|
||||||
fireEvent.change(password, { target: { value: "wrong-password" } });
|
fireEvent.change(password, { target: { value: "wrong-password" } });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
|
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
|
||||||
|
|
||||||
const retryPassword = await screen.findByLabelText("Password");
|
expect(await screen.findByText("Invalid password. Try again.")).toBeInTheDocument();
|
||||||
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(fetchBootstrap).toHaveBeenLastCalledWith("", "wrong-password");
|
||||||
expect(connectSpy).not.toHaveBeenCalled();
|
expect(connectSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -433,21 +365,6 @@ describe("App layout", () => {
|
|||||||
expect(asideClassNames.some((cls) => cls.includes("lg:block"))).toBe(true);
|
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 () => {
|
it("places Automations after Skills in the main sidebar", async () => {
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
@@ -735,57 +652,6 @@ 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 () => {
|
it("restores the Settings route after a restart fallback hash", async () => {
|
||||||
localStorage.setItem("nanobot-webui.restartStartedAt", String(Date.now()));
|
localStorage.setItem("nanobot-webui.restartStartedAt", String(Date.now()));
|
||||||
localStorage.setItem("nanobot-webui.restartRoute", "#/settings?section=channels");
|
localStorage.setItem("nanobot-webui.restartRoute", "#/settings?section=channels");
|
||||||
@@ -880,36 +746,40 @@ describe("App layout", () => {
|
|||||||
}],
|
}],
|
||||||
raw_markdown: "---\nname: github\n---\nUse GitHub CLI.",
|
raw_markdown: "---\nname: github\n---\nUse GitHub CLI.",
|
||||||
},
|
},
|
||||||
});
|
"/api/webui/skills/update?name=github&enabled=false": {
|
||||||
requestMutationSpy.mockResolvedValueOnce({
|
skills: [
|
||||||
skills: [
|
{
|
||||||
{
|
name: "cron",
|
||||||
name: "cron",
|
description: "Schedule reminders.",
|
||||||
description: "Schedule reminders.",
|
source: "builtin",
|
||||||
source: "builtin",
|
enabled: true,
|
||||||
enabled: true,
|
deletable: false,
|
||||||
deletable: false,
|
available: true,
|
||||||
available: true,
|
},
|
||||||
},
|
{
|
||||||
{
|
name: "github",
|
||||||
|
description: "Work with GitHub.",
|
||||||
|
source: "builtin",
|
||||||
|
enabled: false,
|
||||||
|
deletable: false,
|
||||||
|
available: false,
|
||||||
|
unavailable_reason: "CLI: gh",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "custom-skill",
|
||||||
|
description: "A workspace skill.",
|
||||||
|
source: "workspace",
|
||||||
|
enabled: true,
|
||||||
|
deletable: true,
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
last_action: {
|
||||||
name: "github",
|
name: "github",
|
||||||
description: "Work with GitHub.",
|
|
||||||
source: "builtin",
|
|
||||||
enabled: false,
|
enabled: false,
|
||||||
deletable: false,
|
deleted: false,
|
||||||
available: false,
|
|
||||||
unavailable_reason: "CLI: gh",
|
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
name: "custom-skill",
|
|
||||||
description: "A workspace skill.",
|
|
||||||
source: "workspace",
|
|
||||||
enabled: true,
|
|
||||||
deletable: true,
|
|
||||||
available: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
last_action: { name: "github", enabled: false, deleted: false },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
@@ -1009,10 +879,14 @@ describe("App layout", () => {
|
|||||||
},
|
},
|
||||||
raw_markdown: "---\nname: custom-skill\n---\nWorkspace instructions.",
|
raw_markdown: "---\nname: custom-skill\n---\nWorkspace instructions.",
|
||||||
},
|
},
|
||||||
});
|
"/api/webui/skills/delete?name=custom-skill": {
|
||||||
requestMutationSpy.mockResolvedValueOnce({
|
skills: [],
|
||||||
skills: [],
|
last_action: {
|
||||||
last_action: { name: "custom-skill", enabled: false, deleted: true },
|
name: "custom-skill",
|
||||||
|
enabled: false,
|
||||||
|
deleted: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
@@ -1144,8 +1018,9 @@ describe("App layout", () => {
|
|||||||
"/api/webui/skills/trends?id=acme%2Fagent-skills%2Freact-testing": {
|
"/api/webui/skills/trends?id=acme%2Fagent-skills%2Freact-testing": {
|
||||||
trends: { "acme/agent-skills/react-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 />);
|
render(<App />);
|
||||||
|
|
||||||
@@ -1193,14 +1068,11 @@ describe("App layout", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: "Install skill" }));
|
fireEvent.click(screen.getByRole("button", { name: "Install skill" }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(requestMutationSpy).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"skill.install",
|
"/api/webui/skills/install?provider=skills_sh&source=acme%2Fagent-skills&skill=react-testing",
|
||||||
{
|
expect.objectContaining({
|
||||||
provider: "skills_sh",
|
headers: { Authorization: expect.any(String) },
|
||||||
source: "acme/agent-skills",
|
}),
|
||||||
skill: "react-testing",
|
|
||||||
},
|
|
||||||
150_000,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "Installed" }));
|
fireEvent.click(screen.getByRole("tab", { name: "Installed" }));
|
||||||
@@ -1358,12 +1230,14 @@ describe("App layout", () => {
|
|||||||
mockFetchRoutes({
|
mockFetchRoutes({
|
||||||
"/api/settings": baseSettingsPayload(),
|
"/api/settings": baseSettingsPayload(),
|
||||||
"/api/webui/automations": { jobs: [pastOneShot] },
|
"/api/webui/automations": { jobs: [pastOneShot] },
|
||||||
});
|
"/api/webui/automations/update?id=past-one-shot": {
|
||||||
requestMutationSpy.mockResolvedValueOnce({
|
jobs: [
|
||||||
jobs: [{
|
{
|
||||||
...pastOneShot,
|
...pastOneShot,
|
||||||
payload: { ...pastOneShot.payload, message: "Updated one-shot message" },
|
payload: { ...pastOneShot.payload, message: "Updated one-shot message" },
|
||||||
}],
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
@@ -1389,18 +1263,20 @@ describe("App layout", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(requestMutationSpy).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"automation.update",
|
"/api/webui/automations/update?id=past-one-shot",
|
||||||
{
|
expect.any(Object),
|
||||||
id: "past-one-shot",
|
|
||||||
values: {
|
|
||||||
name: "Past one-shot",
|
|
||||||
message: "Updated one-shot message",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
20_000,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
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({
|
||||||
|
name: "Past one-shot",
|
||||||
|
message: "Updated one-shot message",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps long automation details expandable without nested scrolling", async () => {
|
it("keeps long automation details expandable without nested scrolling", async () => {
|
||||||
@@ -1822,9 +1698,6 @@ describe("App layout", () => {
|
|||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||||
act(() => {
|
|
||||||
statusHandlers.forEach((handler) => handler("open"));
|
|
||||||
});
|
|
||||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(within(sidebar).getByText("Pinned")).toBeInTheDocument(),
|
expect(within(sidebar).getByText("Pinned")).toBeInTheDocument(),
|
||||||
@@ -2577,14 +2450,17 @@ describe("App layout", () => {
|
|||||||
mockFetchRoutes({
|
mockFetchRoutes({
|
||||||
"/api/settings": initialSettings,
|
"/api/settings": initialSettings,
|
||||||
});
|
});
|
||||||
|
const fetchMock = vi.mocked(fetch);
|
||||||
window.history.replaceState(null, "", "/#/settings?section=runtime");
|
window.history.replaceState(null, "", "/#/settings?section=runtime");
|
||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
expect(await screen.findByText("UTC")).toBeInTheDocument();
|
expect(await screen.findByText("UTC")).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
requestMutationSpy.mock.calls.some(([action]) => action === "settings.agent.update"),
|
fetchMock.mock.calls.filter(([input]) =>
|
||||||
).toBe(false);
|
String(input).startsWith("/api/settings/update?timezone="),
|
||||||
|
),
|
||||||
|
).toHaveLength(0);
|
||||||
expect(screen.queryByRole("heading", { name: "Regional" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("heading", { name: "Regional" })).not.toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.queryByText("Used for schedules and time-aware replies."),
|
screen.queryByText("Used for schedules and time-aware replies."),
|
||||||
|
|||||||
@@ -1,600 +0,0 @@
|
|||||||
{
|
|
||||||
"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,122 +71,6 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("NanobotClient", () => {
|
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 () => {
|
it("keeps temporary chats out of attachment and reconnect state", async () => {
|
||||||
const client = new NanobotClient({
|
const client = new NanobotClient({
|
||||||
url: "ws://test",
|
url: "ws://test",
|
||||||
@@ -1187,7 +1071,7 @@ describe("NanobotClient", () => {
|
|||||||
expect(client.hasUnsettledRun("chat-scope-control")).toBe(true);
|
expect(client.hasUnsettledRun("chat-scope-control")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends large sidebar ordering state as a correlated WebUI request", async () => {
|
it("sends large sidebar ordering state outside the HTTP request line", () => {
|
||||||
const client = new NanobotClient({
|
const client = new NanobotClient({
|
||||||
url: "ws://test",
|
url: "ws://test",
|
||||||
reconnect: false,
|
reconnect: false,
|
||||||
@@ -1218,29 +1102,11 @@ describe("NanobotClient", () => {
|
|||||||
|
|
||||||
client.connect();
|
client.connect();
|
||||||
lastSocket().fakeOpen();
|
lastSocket().fakeOpen();
|
||||||
const pending = client.setSidebarState(state);
|
client.setSidebarState(state);
|
||||||
|
|
||||||
const [serialized] = lastSocket().sent;
|
const [serialized] = lastSocket().sent;
|
||||||
expect(new TextEncoder().encode(serialized).byteLength).toBeGreaterThan(8_192);
|
expect(new TextEncoder().encode(serialized).byteLength).toBeGreaterThan(8_192);
|
||||||
const request = JSON.parse(serialized) as {
|
expect(JSON.parse(serialized)).toEqual({ type: "set_sidebar_state", state });
|
||||||
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 () => {
|
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,30 +350,6 @@ function longPress(badge: HTMLElement, pointerId = 7) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("ThreadComposer", () => {
|
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 () => {
|
it("dismisses the touch keyboard after a successful send", async () => {
|
||||||
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
|
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
|
||||||
matches: query === "(hover: none) and (pointer: coarse)",
|
matches: query === "(hover: none) and (pointer: coarse)",
|
||||||
@@ -1137,36 +1113,6 @@ 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", () => {
|
it("slides project controls closed without offering a compact replacement", () => {
|
||||||
const defaultScope = {
|
const defaultScope = {
|
||||||
project_path: "/Users/test/.nanobot/workspace",
|
project_path: "/Users/test/.nanobot/workspace",
|
||||||
|
|||||||
@@ -4,67 +4,10 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import { useNanobotStream } from "@/hooks/useNanobotStream";
|
import { useNanobotStream } from "@/hooks/useNanobotStream";
|
||||||
import type { StreamError } from "@/lib/nanobot-client";
|
import type { StreamError } from "@/lib/nanobot-client";
|
||||||
import type {
|
import type { ConnectionStatus, InboundEvent, GoalStateWsPayload } from "@/lib/types";
|
||||||
ConnectionStatus,
|
|
||||||
GoalStateWsPayload,
|
|
||||||
InboundEvent,
|
|
||||||
UIMessage,
|
|
||||||
} from "@/lib/types";
|
|
||||||
import { ClientProvider } from "@/providers/ClientProvider";
|
import { ClientProvider } from "@/providers/ClientProvider";
|
||||||
import projectionFixture from "./fixtures/live-replay-event-projection.json";
|
|
||||||
|
|
||||||
const EMPTY_MESSAGES: UIMessage[] = [];
|
const EMPTY_MESSAGES: import("@/lib/types").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() {
|
function fakeClient() {
|
||||||
const handlers = new Map<string, Set<(ev: InboundEvent) => void>>();
|
const handlers = new Map<string, Set<(ev: InboundEvent) => void>>();
|
||||||
@@ -2898,21 +2841,3 @@ 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,9 +109,8 @@ describe("useSessions", () => {
|
|||||||
]);
|
]);
|
||||||
vi.mocked(api.deleteSession).mockResolvedValue({ deleted: true });
|
vi.mocked(api.deleteSession).mockResolvedValue({ deleted: true });
|
||||||
|
|
||||||
const client = fakeClient();
|
|
||||||
const { result } = renderHook(() => useSessions(), {
|
const { result } = renderHook(() => useSessions(), {
|
||||||
wrapper: wrap(client),
|
wrapper: wrap(fakeClient()),
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.sessions).toHaveLength(2));
|
await waitFor(() => expect(result.current.sessions).toHaveLength(2));
|
||||||
@@ -120,7 +119,7 @@ describe("useSessions", () => {
|
|||||||
await result.current.deleteChat("websocket:chat-a");
|
await result.current.deleteChat("websocket:chat-a");
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(api.deleteSession).toHaveBeenCalledWith(client, "websocket:chat-a", undefined);
|
expect(api.deleteSession).toHaveBeenCalledWith("tok", "websocket:chat-a", undefined);
|
||||||
expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-b"]);
|
expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-b"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user