mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-10 22:38:40 +03:00
fix(webui): move mutations to authenticated websocket requests
This commit is contained in:
@@ -15,6 +15,7 @@ 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";
|
||||||
|
|
||||||
@@ -33,7 +34,6 @@ export function FeishuAssistantsPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ChannelInstancesPanel
|
<ChannelInstancesPanel
|
||||||
token={token}
|
|
||||||
feature={feature}
|
feature={feature}
|
||||||
showBrandLogos={showBrandLogos}
|
showBrandLogos={showBrandLogos}
|
||||||
chatAppsDocsUrl={chatAppsDocsUrl}
|
chatAppsDocsUrl={chatAppsDocsUrl}
|
||||||
@@ -92,6 +92,7 @@ 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);
|
||||||
@@ -114,7 +115,7 @@ function FeishuInstanceAction({
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
onFeaturesUpdate(
|
onFeaturesUpdate(
|
||||||
await enableNanobotFeature(token, "feishu", { instanceId: instance.id }),
|
await enableNanobotFeature(client, "feishu", { instanceId: instance.id }),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message);
|
setError((err as Error).message);
|
||||||
|
|||||||
@@ -373,6 +373,13 @@ 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
|
||||||
|
|
||||||
@@ -758,6 +765,9 @@ 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(
|
||||||
@@ -1105,6 +1115,152 @@ 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,
|
||||||
@@ -1145,6 +1301,12 @@ 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,12 +3,16 @@
|
|||||||
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
|
||||||
|
|
||||||
@@ -42,6 +46,12 @@ from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
|
|||||||
from nanobot.session import webui_turns as wth
|
from nanobot.session 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,
|
||||||
)
|
)
|
||||||
@@ -119,6 +129,38 @@ def _basic_handler(bus: Any, **kw: Any) -> GatewayServices:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _webui_mutate(
|
||||||
|
client: Any,
|
||||||
|
action: str,
|
||||||
|
payload: dict[str, Any] | None = None,
|
||||||
|
) -> httpx.Response:
|
||||||
|
request_id = f"test-{uuid.uuid4().hex}"
|
||||||
|
await client.send(json.dumps({
|
||||||
|
"type": "webui_request",
|
||||||
|
"request_id": request_id,
|
||||||
|
"action": action,
|
||||||
|
"payload": payload or {},
|
||||||
|
}))
|
||||||
|
while True:
|
||||||
|
envelope = json.loads(await asyncio.wait_for(client.recv(), timeout=5))
|
||||||
|
if envelope.get("event") != "webui_response":
|
||||||
|
continue
|
||||||
|
if envelope.get("request_id") != request_id:
|
||||||
|
continue
|
||||||
|
if envelope.get("ok") is True:
|
||||||
|
status = 200
|
||||||
|
body = envelope.get("result")
|
||||||
|
else:
|
||||||
|
error = envelope.get("error") or {}
|
||||||
|
status = int(error.get("status") or 500)
|
||||||
|
body = {"error": str(error.get("message") or "WebUI mutation failed")}
|
||||||
|
return httpx.Response(
|
||||||
|
status,
|
||||||
|
json=body,
|
||||||
|
request=httpx.Request("WS", "http://nanobot.local/webui-mutation"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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())
|
||||||
@@ -857,6 +899,98 @@ 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,
|
||||||
@@ -866,23 +1000,33 @@ async def test_webui_persists_sidebar_state_larger_than_http_request_line(
|
|||||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
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": "set_sidebar_state",
|
"type": "webui_request",
|
||||||
"state": {
|
"request_id": request_id,
|
||||||
|
"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"
|
||||||
conn.send.assert_not_awaited()
|
assert json.loads(conn.send.await_args.args[0]) == {
|
||||||
|
"event": "webui_response",
|
||||||
|
"request_id": request_id,
|
||||||
|
"ok": True,
|
||||||
|
"result": saved,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -2887,7 +3031,15 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
server_task = asyncio.create_task(channel.start())
|
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"},
|
||||||
@@ -2971,11 +3123,14 @@ 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 _http_get(
|
provider_updated = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/provider/update?provider=openrouter"
|
"settings.provider.update",
|
||||||
"&api_key=sk-or-test&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1",
|
{
|
||||||
headers={"Authorization": "Bearer tok"},
|
"provider": "openrouter",
|
||||||
|
"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()
|
||||||
@@ -2985,22 +3140,18 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert provider_body["image_generation"]["provider_configured"] is True
|
assert 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 _http_get(
|
custom_provider_created = await _webui_mutate(
|
||||||
f"http://127.0.0.1:{port}/api/settings/provider/create",
|
webui_client,
|
||||||
headers={
|
"settings.provider.create",
|
||||||
"Authorization": "Bearer tok",
|
{
|
||||||
"X-Nanobot-Provider-Values": json.dumps(
|
"name": "Company Gateway",
|
||||||
{
|
"apiBase": "https://gateway.example/v1",
|
||||||
"name": "Company Gateway",
|
"apiKey": "sk-company",
|
||||||
"apiBase": "https://gateway.example/v1",
|
"extraHeaders": json.dumps({"X-Tenant": "engineering"}),
|
||||||
"apiKey": "sk-company",
|
"extraBody": json.dumps({"service_tier": "priority"}),
|
||||||
"extraHeaders": json.dumps({"X-Tenant": "engineering"}),
|
"extraQuery": json.dumps({"api-version": "2026-01-01"}),
|
||||||
"extraBody": json.dumps({"service_tier": "priority"}),
|
"proxy": "http://127.0.0.1:7890",
|
||||||
"extraQuery": json.dumps({"api-version": "2026-01-01"}),
|
"thinkingStyle": "enable_thinking",
|
||||||
"proxy": "http://127.0.0.1:7890",
|
|
||||||
"thinkingStyle": "enable_thinking",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert custom_provider_created.status_code == 200
|
assert custom_provider_created.status_code == 200
|
||||||
@@ -3015,11 +3166,10 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
}
|
}
|
||||||
assert "sk-company" not in custom_provider_created.text
|
assert "sk-company" not in custom_provider_created.text
|
||||||
|
|
||||||
local_provider_updated = await _http_get(
|
local_provider_updated = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/provider/update?provider=atomic_chat"
|
"settings.provider.update",
|
||||||
"&api_base=http%3A%2F%2Flocalhost%3A1337%2Fv1",
|
{"provider": "atomic_chat", "apiBase": "http://localhost:1337/v1"},
|
||||||
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()
|
||||||
@@ -3029,38 +3179,44 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert local_provider_rows["atomic_chat"]["configured"] is True
|
assert 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 _http_get(
|
updated = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/update?model=atomic_chat/test"
|
"settings.agent.update",
|
||||||
"&provider=atomic_chat&timezone=Asia%2FShanghai"
|
{
|
||||||
"&bot_name=Nano&bot_icon=N&tool_hint_max_length=120",
|
"model": "atomic_chat/test",
|
||||||
headers={"Authorization": "Bearer tok"},
|
"provider": "atomic_chat",
|
||||||
|
"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 _http_get(
|
preset_updated = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/update?model_preset=deep",
|
"settings.agent.update",
|
||||||
headers={"Authorization": "Bearer tok"},
|
{"model_preset": "deep"},
|
||||||
)
|
)
|
||||||
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 _http_get(
|
bad_preset = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/update?model_preset=missing",
|
"settings.agent.update",
|
||||||
headers={"Authorization": "Bearer tok"},
|
{"model_preset": "missing"},
|
||||||
)
|
)
|
||||||
assert bad_preset.status_code == 400
|
assert bad_preset.status_code == 400
|
||||||
|
|
||||||
created_preset = await _http_get(
|
created_preset = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/model-configurations/create"
|
"settings.model_configuration.create",
|
||||||
"?label=Fast%20writing&provider=openai&model=openai%2Fgpt-4.1-mini",
|
{
|
||||||
headers={"Authorization": "Bearer tok"},
|
"label": "Fast writing",
|
||||||
|
"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()
|
||||||
@@ -3074,11 +3230,15 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert created_presets["fast-writing"]["label"] == "Fast writing"
|
assert created_presets["fast-writing"]["label"] == "Fast writing"
|
||||||
assert created_presets["fast-writing"]["provider"] == "openai"
|
assert created_presets["fast-writing"]["provider"] == "openai"
|
||||||
|
|
||||||
updated_preset = await _http_get(
|
updated_preset = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/model-configurations/update"
|
"settings.model_configuration.update",
|
||||||
"?name=fast-writing&label=Codex&provider=openai&model=openai%2Fgpt-5.5",
|
{
|
||||||
headers={"Authorization": "Bearer tok"},
|
"name": "fast-writing",
|
||||||
|
"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()
|
||||||
@@ -3089,11 +3249,10 @@ 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 _http_get(
|
call_order_updated = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/model-call-order/update"
|
"settings.model_call_order.update",
|
||||||
"?order=%5B%22fast-writing%22%2C%22deep%22%5D",
|
{"order": ["fast-writing", "deep"]},
|
||||||
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()
|
||||||
@@ -3101,20 +3260,27 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert call_order_body["agent"]["model"] == "openai/gpt-5.5"
|
assert call_order_body["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 _http_get(
|
duplicate_preset = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/model-configurations/create"
|
"settings.model_configuration.create",
|
||||||
"?label=Fast%20writing&provider=openai&model=openai%2Fgpt-4.1-mini",
|
{
|
||||||
headers={"Authorization": "Bearer tok"},
|
"label": "Fast writing",
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "openai/gpt-4.1-mini",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert duplicate_preset.status_code == 409
|
assert duplicate_preset.status_code == 409
|
||||||
|
|
||||||
search_updated = await _http_get(
|
search_updated = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/web-search/update?provider=searxng"
|
"settings.web_search.update",
|
||||||
"&base_url=https%3A%2F%2Fsearch.example.com"
|
{
|
||||||
"&max_results=8&timeout=45&use_jina_reader=false",
|
"provider": "searxng",
|
||||||
headers={"Authorization": "Bearer tok"},
|
"base_url": "https://search.example.com",
|
||||||
|
"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()
|
||||||
@@ -3126,10 +3292,13 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert search_body["web_search"]["max_results"] == 8
|
assert search_body["web_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 _http_get(
|
network_safety_updated = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=full",
|
"settings.network_safety.update",
|
||||||
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()
|
||||||
@@ -3139,13 +3308,17 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert network_safety_body["advanced"]["webui_default_access_mode"] == "full"
|
assert network_safety_body["advanced"]["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 _http_get(
|
image_updated = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/image-generation/update?enabled=true"
|
"settings.image_generation.update",
|
||||||
"&provider=openrouter&model=openai%2Fgpt-image-1"
|
{
|
||||||
"&default_aspect_ratio=16%3A9&default_image_size=2K"
|
"enabled": True,
|
||||||
"&max_images_per_turn=3",
|
"provider": "openrouter",
|
||||||
headers={"Authorization": "Bearer tok"},
|
"model": "openai/gpt-image-1",
|
||||||
|
"default_aspect_ratio": "16:9",
|
||||||
|
"default_image_size": "2K",
|
||||||
|
"max_images_per_turn": 3,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert image_updated.status_code == 200
|
assert image_updated.status_code == 200
|
||||||
image_body = image_updated.json()
|
image_body = image_updated.json()
|
||||||
@@ -3157,11 +3330,14 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert image_body["image_generation"]["default_image_size"] == "2K"
|
assert image_body["image_generation"]["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 _http_get(
|
image_provider_updated = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/provider/update?provider=openrouter"
|
"settings.provider.update",
|
||||||
"&api_key=sk-or-next&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1",
|
{
|
||||||
headers={"Authorization": "Bearer tok"},
|
"provider": "openrouter",
|
||||||
|
"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
|
||||||
@@ -3169,17 +3345,17 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert "sk-or-next" not in image_provider_updated.text
|
assert "sk-or-next" not in image_provider_updated.text
|
||||||
assert image_reload.await_count == 2
|
assert image_reload.await_count == 2
|
||||||
|
|
||||||
bad_web = await _http_get(
|
bad_web = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/web-search/update?provider=duckduckgo&max_results=99",
|
"settings.web_search.update",
|
||||||
headers={"Authorization": "Bearer tok"},
|
{"provider": "duckduckgo", "max_results": 99},
|
||||||
)
|
)
|
||||||
assert bad_web.status_code == 400
|
assert bad_web.status_code == 400
|
||||||
|
|
||||||
bad_image = await _http_get(
|
bad_image = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/image-generation/update?provider=missing",
|
"settings.image_generation.update",
|
||||||
headers={"Authorization": "Bearer tok"},
|
{"provider": "missing"},
|
||||||
)
|
)
|
||||||
assert bad_image.status_code == 400
|
assert bad_image.status_code == 400
|
||||||
|
|
||||||
@@ -3216,6 +3392,8 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert saved.tools.image_generation.default_image_size == "2K"
|
assert saved.tools.image_generation.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
|
||||||
|
|
||||||
@@ -3248,11 +3426,17 @@ 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:
|
||||||
response = await _http_get(
|
webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
|
||||||
f"http://127.0.0.1:{port}/api/settings/image-generation/update"
|
webui_client = await websockets.connect(
|
||||||
"?enabled=true&provider=openrouter&model=openai%2Fgpt-image-1",
|
f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=image-reload-test"
|
||||||
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
|
||||||
@@ -3260,6 +3444,8 @@ 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
|
||||||
|
|
||||||
@@ -3291,17 +3477,25 @@ async def test_image_settings_fall_back_to_restart_when_hot_reload_fails(
|
|||||||
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
|
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:
|
||||||
response = await _http_get(
|
webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
|
||||||
f"http://127.0.0.1:{port}/api/settings/image-generation/update"
|
webui_client = await websockets.connect(
|
||||||
"?enabled=true&provider=openrouter&model=openai%2Fgpt-image-1",
|
f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=image-fallback-test"
|
||||||
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
@@ -27,6 +27,7 @@ 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,
|
||||||
@@ -64,6 +65,7 @@ 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");
|
||||||
@@ -150,7 +152,7 @@ export function WeixinPanel({
|
|||||||
setSaveState("idle");
|
setSaveState("idle");
|
||||||
try {
|
try {
|
||||||
const payload = await configureChannel(
|
const payload = await configureChannel(
|
||||||
context.token,
|
client,
|
||||||
"weixin",
|
"weixin",
|
||||||
channelValuesForSave(editableFieldsRef.current, values),
|
channelValuesForSave(editableFieldsRef.current, values),
|
||||||
{ enable: context.enabled },
|
{ enable: context.enabled },
|
||||||
@@ -168,7 +170,7 @@ export function WeixinPanel({
|
|||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [client]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import json
|
|||||||
import time
|
import time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
from urllib.parse import unquote
|
|
||||||
|
|
||||||
from websockets.http11 import Request as WsRequest
|
from websockets.http11 import Request as WsRequest
|
||||||
from websockets.http11 import Response
|
from websockets.http11 import Response
|
||||||
@@ -40,7 +39,6 @@ from nanobot.optional_features import (
|
|||||||
)
|
)
|
||||||
from nanobot.pairing import approve_code, deny_code, list_pending
|
from nanobot.pairing import approve_code, deny_code, list_pending
|
||||||
from nanobot.webui.cli_apps_api import cli_apps_action, cli_apps_payload
|
from nanobot.webui.cli_apps_api import cli_apps_action, cli_apps_payload
|
||||||
from nanobot.webui.http_utils import case_insensitive_header
|
|
||||||
from nanobot.webui.http_utils import is_local_browser_request as _is_local_browser_request
|
from nanobot.webui.http_utils import is_local_browser_request as _is_local_browser_request
|
||||||
from nanobot.webui.http_utils import query_first as _query_first
|
from nanobot.webui.http_utils import query_first as _query_first
|
||||||
from nanobot.webui.mcp_presets_api import mcp_presets_settings_action
|
from nanobot.webui.mcp_presets_api import mcp_presets_settings_action
|
||||||
@@ -76,17 +74,8 @@ from nanobot.webui.version_check import check_for_update
|
|||||||
|
|
||||||
QueryParams = dict[str, list[str]]
|
QueryParams = dict[str, list[str]]
|
||||||
|
|
||||||
_MCP_VALUES_HEADER = "X-Nanobot-MCP-Values"
|
_WEBUI_MUTATION_PAYLOAD_ATTR = "_nanobot_webui_mutation_payload"
|
||||||
_MCP_VALUES_HEADER_MAX_BYTES = 64 * 1024
|
_WEBUI_MUTATION_REQUEST_ATTR = "_nanobot_webui_mutation_request"
|
||||||
_PROVIDER_VALUES_HEADER = "X-Nanobot-Provider-Values"
|
|
||||||
_PROVIDER_VALUES_HEADER_MAX_BYTES = 64 * 1024
|
|
||||||
_CHANNEL_VALUES_HEADER = "X-Nanobot-Channel-Values"
|
|
||||||
_CHANNEL_VALUES_HEADER_MAX_BYTES = 64 * 1024
|
|
||||||
_API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values"
|
|
||||||
_API_SERVICE_VALUES_HEADER_MAX_BYTES = 8 * 1024
|
|
||||||
_OAUTH_CODE_HEADER = "X-Nanobot-OAuth-Code"
|
|
||||||
_OAUTH_CALLBACK_HEADER = "X-Nanobot-OAuth-Callback"
|
|
||||||
_OAUTH_RESPONSE_HEADER_MAX_BYTES = 8 * 1024
|
|
||||||
|
|
||||||
_SKIP_FIELD = object()
|
_SKIP_FIELD = object()
|
||||||
_CHANNEL_CONNECT_ACTIONS = frozenset({"start", "poll", "cancel"})
|
_CHANNEL_CONNECT_ACTIONS = frozenset({"start", "poll", "cancel"})
|
||||||
@@ -112,6 +101,63 @@ _MCP_PRESET_ACTIONS_BY_PATH = {
|
|||||||
"/api/settings/mcp-presets/tools": "tools",
|
"/api/settings/mcp-presets/tools": "tools",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_SETTINGS_MUTATION_PATHS = frozenset({
|
||||||
|
"/api/settings/update",
|
||||||
|
"/api/settings/model-configurations/create",
|
||||||
|
"/api/settings/model-configurations/update",
|
||||||
|
"/api/settings/model-configurations/delete",
|
||||||
|
"/api/settings/model-configurations/migrate",
|
||||||
|
"/api/settings/model-call-order/update",
|
||||||
|
"/api/settings/provider/update",
|
||||||
|
"/api/settings/provider/create",
|
||||||
|
"/api/settings/provider/oauth-login",
|
||||||
|
"/api/settings/provider/oauth-login/complete",
|
||||||
|
"/api/settings/provider/oauth-logout",
|
||||||
|
"/api/settings/web-search/update",
|
||||||
|
"/api/settings/api-service/start",
|
||||||
|
"/api/settings/api-service/stop",
|
||||||
|
"/api/settings/image-generation/update",
|
||||||
|
"/api/settings/transcription/update",
|
||||||
|
"/api/settings/network-safety/update",
|
||||||
|
"/api/settings/cli-apps/install",
|
||||||
|
"/api/settings/cli-apps/update",
|
||||||
|
"/api/settings/cli-apps/uninstall",
|
||||||
|
"/api/settings/cli-apps/test",
|
||||||
|
"/api/settings/nanobot-features/enable",
|
||||||
|
"/api/settings/nanobot-features/disable",
|
||||||
|
"/api/settings/channels/validate",
|
||||||
|
"/api/settings/channels/configure",
|
||||||
|
"/api/settings/pairing/approve",
|
||||||
|
"/api/settings/pairing/deny",
|
||||||
|
*_MCP_PRESET_ACTIONS_BY_PATH,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _mutation_payload(request: WsRequest) -> dict[str, Any] | None:
|
||||||
|
payload = getattr(request, _WEBUI_MUTATION_PAYLOAD_ATTR, None)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
return cast(dict[str, Any], payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _query_value(value: Any) -> str:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "true" if value else "false"
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, (dict, list)):
|
||||||
|
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _payload_query(payload: dict[str, Any]) -> QueryParams:
|
||||||
|
return {
|
||||||
|
key: [_query_value(value)]
|
||||||
|
for key, value in payload.items()
|
||||||
|
if key
|
||||||
|
and key not in {"authorization_response", "channel", "values"}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class WebUISettingsRouter:
|
class WebUISettingsRouter:
|
||||||
"""Route WebUI Settings HTTP requests behind a transport-neutral boundary."""
|
"""Route WebUI Settings HTTP requests behind a transport-neutral boundary."""
|
||||||
@@ -144,6 +190,15 @@ class WebUISettingsRouter:
|
|||||||
self._channel_connectors: dict[str, Any] = {}
|
self._channel_connectors: dict[str, Any] = {}
|
||||||
|
|
||||||
async def dispatch(self, connection: Any, request: WsRequest, path: str) -> Response | None:
|
async def dispatch(self, connection: Any, request: WsRequest, path: str) -> Response | None:
|
||||||
|
if self.is_mutation_path(path) and not getattr(
|
||||||
|
request,
|
||||||
|
_WEBUI_MUTATION_REQUEST_ATTR,
|
||||||
|
False,
|
||||||
|
):
|
||||||
|
return self._error_response(
|
||||||
|
405,
|
||||||
|
"WebUI mutations require an authenticated WebSocket",
|
||||||
|
)
|
||||||
if path == "/api/settings":
|
if path == "/api/settings":
|
||||||
return self._handle_settings(request)
|
return self._handle_settings(request)
|
||||||
if path == "/api/settings/usage":
|
if path == "/api/settings/usage":
|
||||||
@@ -230,7 +285,17 @@ class WebUISettingsRouter:
|
|||||||
return await self._handle_settings_mcp_presets(request, mcp_action)
|
return await self._handle_settings_mcp_presets(request, mcp_action)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_mutation_path(path: str) -> bool:
|
||||||
|
return (
|
||||||
|
path in _SETTINGS_MUTATION_PATHS
|
||||||
|
or _channel_connect_route(path) is not None
|
||||||
|
)
|
||||||
|
|
||||||
def _query(self, request: WsRequest) -> QueryParams:
|
def _query(self, request: WsRequest) -> QueryParams:
|
||||||
|
payload = _mutation_payload(request)
|
||||||
|
if payload is not None:
|
||||||
|
return _payload_query(payload)
|
||||||
return self._parse_query(request.path)
|
return self._parse_query(request.path)
|
||||||
|
|
||||||
def _authorized(self, request: WsRequest) -> bool:
|
def _authorized(self, request: WsRequest) -> bool:
|
||||||
@@ -260,63 +325,10 @@ class WebUISettingsRouter:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _parse_mcp_settings_query(self, request: WsRequest) -> QueryParams:
|
def _parse_mcp_settings_query(self, request: WsRequest) -> QueryParams:
|
||||||
query = self._query(request)
|
return self._query(request)
|
||||||
raw = request.headers.get(_MCP_VALUES_HEADER)
|
|
||||||
if not raw:
|
|
||||||
return query
|
|
||||||
if len(raw.encode("utf-8")) > _MCP_VALUES_HEADER_MAX_BYTES:
|
|
||||||
raise WebUISettingsError("MCP settings payload is too large")
|
|
||||||
try:
|
|
||||||
payload = json.loads(raw)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
raise WebUISettingsError("invalid MCP settings payload") from exc
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
raise WebUISettingsError("MCP settings payload must be a JSON object")
|
|
||||||
payload = cast(dict[object, Any], payload)
|
|
||||||
merged = {key: list(values) for key, values in query.items()}
|
|
||||||
for key, value in payload.items():
|
|
||||||
if not isinstance(key, str) or not key:
|
|
||||||
raise WebUISettingsError("MCP settings payload contains an invalid key")
|
|
||||||
if value is None:
|
|
||||||
continue
|
|
||||||
if isinstance(value, str):
|
|
||||||
text = value.strip()
|
|
||||||
else:
|
|
||||||
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
||||||
if text:
|
|
||||||
merged[key] = [text]
|
|
||||||
return merged
|
|
||||||
|
|
||||||
def _parse_provider_settings_query(self, request: WsRequest) -> QueryParams:
|
def _parse_provider_settings_query(self, request: WsRequest) -> QueryParams:
|
||||||
query = self._query(request)
|
return self._query(request)
|
||||||
raw = request.headers.get(_PROVIDER_VALUES_HEADER)
|
|
||||||
if not raw:
|
|
||||||
return query
|
|
||||||
if len(raw.encode("utf-8")) > _PROVIDER_VALUES_HEADER_MAX_BYTES:
|
|
||||||
raise WebUISettingsError("provider settings payload is too large")
|
|
||||||
try:
|
|
||||||
payload = json.loads(raw)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
try:
|
|
||||||
payload = json.loads(unquote(raw))
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
raise WebUISettingsError("invalid provider settings payload") from exc
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
raise WebUISettingsError("provider settings payload must be a JSON object")
|
|
||||||
payload = cast(dict[object, Any], payload)
|
|
||||||
|
|
||||||
merged = {key: list(values) for key, values in query.items()}
|
|
||||||
for key, value in payload.items():
|
|
||||||
if not isinstance(key, str) or not key:
|
|
||||||
raise WebUISettingsError("provider settings payload contains an invalid key")
|
|
||||||
if isinstance(value, str):
|
|
||||||
text = value
|
|
||||||
elif value is None:
|
|
||||||
text = ""
|
|
||||||
else:
|
|
||||||
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
||||||
merged[key] = [text]
|
|
||||||
return merged
|
|
||||||
|
|
||||||
def _handle_settings(self, request: WsRequest) -> Response:
|
def _handle_settings(self, request: WsRequest) -> Response:
|
||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
@@ -472,18 +484,12 @@ class WebUISettingsRouter:
|
|||||||
if action == "login":
|
if action == "login":
|
||||||
payload = await asyncio.to_thread(login_oauth_provider, query)
|
payload = await asyncio.to_thread(login_oauth_provider, query)
|
||||||
elif action == "complete":
|
elif action == "complete":
|
||||||
authorization_response = case_insensitive_header(
|
raw_response = (_mutation_payload(request) or {}).get(
|
||||||
request.headers,
|
"authorization_response"
|
||||||
_OAUTH_CALLBACK_HEADER,
|
|
||||||
) or case_insensitive_header(
|
|
||||||
request.headers,
|
|
||||||
_OAUTH_CODE_HEADER,
|
|
||||||
)
|
)
|
||||||
if (
|
if raw_response is not None and not isinstance(raw_response, str):
|
||||||
len(authorization_response.encode("utf-8"))
|
raise WebUISettingsError("OAuth authorization response must be a string")
|
||||||
> _OAUTH_RESPONSE_HEADER_MAX_BYTES
|
authorization_response = raw_response
|
||||||
):
|
|
||||||
raise WebUISettingsError("OAuth authorization response is too large")
|
|
||||||
payload = await asyncio.to_thread(
|
payload = await asyncio.to_thread(
|
||||||
complete_oauth_provider,
|
complete_oauth_provider,
|
||||||
query,
|
query,
|
||||||
@@ -549,33 +555,12 @@ class WebUISettingsRouter:
|
|||||||
return self._json_response(self._api_service_payload(last_action="started"))
|
return self._json_response(self._api_service_payload(last_action="started"))
|
||||||
|
|
||||||
def _parse_api_service_settings_query(self, request: WsRequest) -> QueryParams:
|
def _parse_api_service_settings_query(self, request: WsRequest) -> QueryParams:
|
||||||
query = self._query(request)
|
payload = _mutation_payload(request)
|
||||||
if "api_key" in query or "apiKey" in query:
|
if payload is not None:
|
||||||
raise WebUISettingsError("API service API key must be provided in the private header")
|
api_key = payload.get("api_key")
|
||||||
raw = request.headers.get(_API_SERVICE_VALUES_HEADER)
|
if api_key is not None and not isinstance(api_key, str):
|
||||||
if not raw:
|
raise WebUISettingsError("API service API key must be a string")
|
||||||
return query
|
return self._query(request)
|
||||||
if len(raw.encode("utf-8")) > _API_SERVICE_VALUES_HEADER_MAX_BYTES:
|
|
||||||
raise WebUISettingsError("API service settings payload is too large")
|
|
||||||
try:
|
|
||||||
payload = json.loads(raw)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
raise WebUISettingsError("invalid API service settings payload") from exc
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
raise WebUISettingsError("API service settings payload must be a JSON object")
|
|
||||||
payload = cast(dict[str, Any], payload)
|
|
||||||
|
|
||||||
unknown = set(payload) - {"api_key"}
|
|
||||||
if unknown:
|
|
||||||
raise WebUISettingsError("API service settings payload contains an invalid key")
|
|
||||||
api_key = payload.get("api_key")
|
|
||||||
if api_key is not None and not isinstance(api_key, str):
|
|
||||||
raise WebUISettingsError("API service API key must be a string")
|
|
||||||
|
|
||||||
merged = {key: list(values) for key, values in query.items() if key != "api_key"}
|
|
||||||
if api_key is not None:
|
|
||||||
merged["api_key"] = [api_key]
|
|
||||||
return merged
|
|
||||||
|
|
||||||
async def _handle_settings_api_service_stop(self, request: WsRequest) -> Response:
|
async def _handle_settings_api_service_stop(self, request: WsRequest) -> Response:
|
||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
@@ -850,7 +835,7 @@ class WebUISettingsRouter:
|
|||||||
saved = await asyncio.to_thread(
|
saved = await asyncio.to_thread(
|
||||||
self._save_channel_config_values,
|
self._save_channel_config_values,
|
||||||
name,
|
name,
|
||||||
self._parse_channel_values_header(request),
|
self._parse_channel_values(request),
|
||||||
instance_id,
|
instance_id,
|
||||||
)
|
)
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
@@ -906,7 +891,7 @@ class WebUISettingsRouter:
|
|||||||
payload = await asyncio.to_thread(
|
payload = await asyncio.to_thread(
|
||||||
validate_channel_config,
|
validate_channel_config,
|
||||||
name,
|
name,
|
||||||
self._parse_channel_values_header(request),
|
self._parse_channel_values(request),
|
||||||
instance_id=instance_id,
|
instance_id=instance_id,
|
||||||
)
|
)
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
@@ -916,19 +901,14 @@ class WebUISettingsRouter:
|
|||||||
return self._error_response(500, "failed to validate channel settings")
|
return self._error_response(500, "failed to validate channel settings")
|
||||||
return self._json_response(payload)
|
return self._json_response(payload)
|
||||||
|
|
||||||
def _parse_channel_values_header(self, request: WsRequest) -> dict[str, Any]:
|
def _parse_channel_values(self, request: WsRequest) -> dict[str, Any]:
|
||||||
raw = request.headers.get(_CHANNEL_VALUES_HEADER)
|
payload = _mutation_payload(request)
|
||||||
if not raw:
|
if payload is None or "values" not in payload:
|
||||||
return {}
|
return {}
|
||||||
if len(raw.encode("utf-8")) > _CHANNEL_VALUES_HEADER_MAX_BYTES:
|
values = payload.get("values")
|
||||||
raise WebUISettingsError("channel settings payload is too large")
|
if not isinstance(values, dict):
|
||||||
try:
|
|
||||||
payload = json.loads(raw)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
raise WebUISettingsError("invalid channel settings payload") from exc
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
raise WebUISettingsError("channel settings payload must be a JSON object")
|
raise WebUISettingsError("channel settings payload must be a JSON object")
|
||||||
return cast(dict[str, Any], payload)
|
return cast(dict[str, Any], values)
|
||||||
|
|
||||||
def _save_channel_config_values(
|
def _save_channel_config_values(
|
||||||
self,
|
self,
|
||||||
|
|||||||
+171
-25
@@ -17,9 +17,10 @@ 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 unquote
|
from urllib.parse import quote, 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
|
||||||
|
|
||||||
@@ -118,7 +119,60 @@ 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
|
||||||
_AUTOMATION_VALUES_HEADER = "X-Nanobot-Automation-Values"
|
_WEBUI_MUTATION_PAYLOAD_ATTR = "_nanobot_webui_mutation_payload"
|
||||||
|
_WEBUI_MUTATION_REQUEST_ATTR = "_nanobot_webui_mutation_request"
|
||||||
|
|
||||||
|
_WEBUI_MUTATION_PATHS = {
|
||||||
|
"automation.enable": "/api/webui/automations/enable",
|
||||||
|
"automation.disable": "/api/webui/automations/disable",
|
||||||
|
"automation.delete": "/api/webui/automations/delete",
|
||||||
|
"automation.run": "/api/webui/automations/run",
|
||||||
|
"automation.update": "/api/webui/automations/update",
|
||||||
|
"skill.install": "/api/webui/skills/install",
|
||||||
|
"skill.update": "/api/webui/skills/update",
|
||||||
|
"skill.delete": "/api/webui/skills/delete",
|
||||||
|
"sidebar.update": "/api/webui/sidebar-state/update",
|
||||||
|
"settings.agent.update": "/api/settings/update",
|
||||||
|
"settings.model_configuration.create": "/api/settings/model-configurations/create",
|
||||||
|
"settings.model_configuration.update": "/api/settings/model-configurations/update",
|
||||||
|
"settings.model_configuration.delete": "/api/settings/model-configurations/delete",
|
||||||
|
"settings.model_configuration.migrate": "/api/settings/model-configurations/migrate",
|
||||||
|
"settings.model_call_order.update": "/api/settings/model-call-order/update",
|
||||||
|
"settings.provider.update": "/api/settings/provider/update",
|
||||||
|
"settings.provider.create": "/api/settings/provider/create",
|
||||||
|
"settings.provider.oauth_login": "/api/settings/provider/oauth-login",
|
||||||
|
"settings.provider.oauth_complete": "/api/settings/provider/oauth-login/complete",
|
||||||
|
"settings.provider.oauth_logout": "/api/settings/provider/oauth-logout",
|
||||||
|
"settings.web_search.update": "/api/settings/web-search/update",
|
||||||
|
"settings.api_service.start": "/api/settings/api-service/start",
|
||||||
|
"settings.api_service.stop": "/api/settings/api-service/stop",
|
||||||
|
"settings.image_generation.update": "/api/settings/image-generation/update",
|
||||||
|
"settings.transcription.update": "/api/settings/transcription/update",
|
||||||
|
"settings.network_safety.update": "/api/settings/network-safety/update",
|
||||||
|
"settings.cli_app.install": "/api/settings/cli-apps/install",
|
||||||
|
"settings.cli_app.update": "/api/settings/cli-apps/update",
|
||||||
|
"settings.cli_app.uninstall": "/api/settings/cli-apps/uninstall",
|
||||||
|
"settings.cli_app.test": "/api/settings/cli-apps/test",
|
||||||
|
"settings.feature.enable": "/api/settings/nanobot-features/enable",
|
||||||
|
"settings.feature.disable": "/api/settings/nanobot-features/disable",
|
||||||
|
"settings.channel.validate": "/api/settings/channels/validate",
|
||||||
|
"settings.channel.configure": "/api/settings/channels/configure",
|
||||||
|
"settings.pairing.approve": "/api/settings/pairing/approve",
|
||||||
|
"settings.pairing.deny": "/api/settings/pairing/deny",
|
||||||
|
"settings.mcp.enable": "/api/settings/mcp-presets/enable",
|
||||||
|
"settings.mcp.remove": "/api/settings/mcp-presets/remove",
|
||||||
|
"settings.mcp.test": "/api/settings/mcp-presets/test",
|
||||||
|
"settings.mcp.custom": "/api/settings/mcp-presets/custom",
|
||||||
|
"settings.mcp.import": "/api/settings/mcp-presets/import",
|
||||||
|
"settings.mcp.import_cursor": "/api/settings/mcp-presets/import-cursor",
|
||||||
|
"settings.mcp.tools": "/api/settings/mcp-presets/tools",
|
||||||
|
}
|
||||||
|
|
||||||
|
_WEBUI_CHANNEL_CONNECT_ACTIONS = {
|
||||||
|
"settings.channel.connect.start": "start",
|
||||||
|
"settings.channel.connect.poll": "poll",
|
||||||
|
"settings.channel.connect.cancel": "cancel",
|
||||||
|
}
|
||||||
|
|
||||||
# Fix for #5190: On Windows, mimetypes.guess_type() reads the registry key
|
# 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'
|
||||||
@@ -159,6 +213,33 @@ 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
|
||||||
@@ -285,11 +366,86 @@ 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,
|
||||||
@@ -646,7 +802,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 = _parse_query(request.path)
|
query = _request_query(request)
|
||||||
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,
|
||||||
@@ -742,7 +898,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 = _parse_query(request.path)
|
query = _request_query(request)
|
||||||
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")
|
||||||
@@ -974,7 +1130,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 = _parse_query(request.path)
|
query = _request_query(request)
|
||||||
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 ""
|
||||||
@@ -1015,7 +1171,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 = _parse_query(request.path)
|
query = _request_query(request)
|
||||||
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"}:
|
||||||
@@ -1047,7 +1203,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(_parse_query(request.path), "name") or ""
|
name = _query_first(_request_query(request), "name") or ""
|
||||||
try:
|
try:
|
||||||
action = delete_webui_skill(
|
action = delete_webui_skill(
|
||||||
self.skills_workspace_path,
|
self.skills_workspace_path,
|
||||||
@@ -1094,18 +1250,14 @@ 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")
|
||||||
query = _parse_query(request.path)
|
payload = _mutation_payload(request)
|
||||||
raw_state = _query_first(query, "state")
|
state_value = payload.get("state") if payload is not None else None
|
||||||
if raw_state is None:
|
if state_value is None:
|
||||||
return _http_error(400, "missing state")
|
return _http_error(400, "missing state")
|
||||||
try:
|
if not isinstance(state_value, dict):
|
||||||
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], decoded))
|
state = write_webui_sidebar_state(cast(dict[str, Any], state_value))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return _http_error(400, str(e))
|
return _http_error(400, str(e))
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -1174,16 +1326,10 @@ class GatewayHTTPHandler:
|
|||||||
|
|
||||||
|
|
||||||
def _automation_values_from_request(request: WsRequest) -> dict[str, Any] | None:
|
def _automation_values_from_request(request: WsRequest) -> dict[str, Any] | None:
|
||||||
raw = _case_insensitive_header(request.headers, _AUTOMATION_VALUES_HEADER)
|
payload = _mutation_payload(request)
|
||||||
if not raw:
|
if payload is None or "values" not in payload:
|
||||||
return {}
|
return {}
|
||||||
try:
|
values = payload.get("values")
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -28,22 +28,28 @@ 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", "header_name", "authorization_response"),
|
("provider", "authorization_response"),
|
||||||
[
|
[
|
||||||
("xai_grok", "X-Nanobot-OAuth-Code", "secret"),
|
("xai_grok", "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_private_response_header(
|
async def test_oauth_completion_reads_websocket_payload(
|
||||||
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] = {}
|
||||||
@@ -58,19 +64,13 @@ async def test_oauth_completion_reads_private_response_header(
|
|||||||
|
|
||||||
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 = SimpleNamespace(
|
request = _mutation_request(
|
||||||
path=(
|
"/api/settings/provider/oauth-login/complete",
|
||||||
"/api/settings/provider/oauth-login/complete"
|
{
|
||||||
f"?provider={provider}&flow_id=flow-123"
|
"provider": provider,
|
||||||
),
|
"flow_id": "flow-123",
|
||||||
headers=Headers(
|
"authorization_response": authorization_response,
|
||||||
[
|
},
|
||||||
(
|
|
||||||
header_name,
|
|
||||||
authorization_response,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
response = await router.dispatch(
|
response = await router.dispatch(
|
||||||
@@ -90,28 +90,29 @@ async def test_oauth_completion_reads_private_response_header(
|
|||||||
"query": {"provider": [provider], "flow_id": ["flow-123"]},
|
"query": {"provider": [provider], "flow_id": ["flow-123"]},
|
||||||
"authorization_response": authorization_response,
|
"authorization_response": authorization_response,
|
||||||
}
|
}
|
||||||
assert authorization_response not in request.path
|
assert request.path == "/api/settings/provider/oauth-login/complete"
|
||||||
|
assert not request.headers
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("request_path", "route_path", "function_name", "expected_query"),
|
("route_path", "function_name", "payload", "expected_query"),
|
||||||
[
|
[
|
||||||
(
|
(
|
||||||
"/api/settings/model-configurations/delete?name=spare",
|
|
||||||
"/api/settings/model-configurations/delete",
|
"/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"]']},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -119,9 +120,9 @@ async def test_oauth_completion_reads_private_response_header(
|
|||||||
@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] = {}
|
||||||
@@ -131,7 +132,7 @@ async def test_model_preset_mutation_routes(
|
|||||||
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 = SimpleNamespace(path=request_path, headers=Headers())
|
request = _mutation_request(route_path, payload)
|
||||||
|
|
||||||
response = await _router().dispatch(None, request, route_path)
|
response = await _router().dispatch(None, request, route_path)
|
||||||
|
|
||||||
@@ -141,6 +142,23 @@ 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"),
|
||||||
[
|
[
|
||||||
|
|||||||
+2
-2
@@ -2081,7 +2081,7 @@ function Shell({
|
|||||||
setPairingBusyCode(code);
|
setPairingBusyCode(code);
|
||||||
setPairingError(null);
|
setPairingError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await runPairingAction(getToken(), action, code);
|
const payload = await runPairingAction(client, 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 +2096,7 @@ function Shell({
|
|||||||
setPairingBusyCode(null);
|
setPairingBusyCode(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[getToken, refreshPairingRequests],
|
[client, 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 { getToken, token } = useClient();
|
const { client, 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(
|
||||||
getToken(),
|
client,
|
||||||
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, closeProviderOAuthFlow, getToken, providerOAuthFlow]);
|
}, [applyPayload, client, closeProviderOAuthFlow, 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(token, {
|
const payload = await createModelConfiguration(client, {
|
||||||
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(token, nextOrder);
|
const orderedPayload = await updateModelCallOrder(client, 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(token, {
|
const payload = await updateModelConfiguration(client, {
|
||||||
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(token, nextOrder);
|
const payload = await updateModelCallOrder(client, 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(token);
|
const payload = await migrateModelConfigurations(client);
|
||||||
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(token, modelPresetPendingDelete.name);
|
const payload = await deleteModelConfiguration(client, 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(token, imageGenerationForm);
|
const payload = await updateImageGenerationSettings(client, 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(token, transcriptionForm);
|
const payload = await updateTranscriptionSettings(client, 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(token, networkSafetyForm);
|
const payload = await updateNetworkSafetySettings(client, 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(token, name);
|
latest = await enableNanobotFeature(client, 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(token, values!)
|
? await startApiService(client, values!)
|
||||||
: await stopApiService(token);
|
: await stopApiService(client);
|
||||||
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(token, update);
|
const payload = await updateProviderSettings(client, 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(token, {
|
const payload = await createProviderSettings(client, {
|
||||||
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,12 +1698,11 @@ export function SettingsView({
|
|||||||
const payload =
|
const payload =
|
||||||
action === "login"
|
action === "login"
|
||||||
? await loginProviderOAuth(
|
? await loginProviderOAuth(
|
||||||
token,
|
client,
|
||||||
providerName,
|
providerName,
|
||||||
"",
|
|
||||||
providerName === "openai_codex" && remoteBrowserAccess,
|
providerName === "openai_codex" && remoteBrowserAccess,
|
||||||
)
|
)
|
||||||
: await logoutProviderOAuth(token, providerName);
|
: await logoutProviderOAuth(client, 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;
|
||||||
@@ -1739,7 +1738,7 @@ export function SettingsView({
|
|||||||
setProviderOAuthDialogError(null);
|
setProviderOAuthDialogError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await completeProviderOAuth(
|
const payload = await completeProviderOAuth(
|
||||||
token,
|
client,
|
||||||
flow.provider,
|
flow.provider,
|
||||||
flow.flow_id,
|
flow.flow_id,
|
||||||
authorizationResponse,
|
authorizationResponse,
|
||||||
@@ -1798,7 +1797,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(token, update);
|
const payload = await updateWebSearchSettings(client, 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 }));
|
||||||
@@ -1903,7 +1902,7 @@ export function SettingsView({
|
|||||||
setCliAppsMessage(null);
|
setCliAppsMessage(null);
|
||||||
setCliAppsError(null);
|
setCliAppsError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await runCliAppAction(token, action, name);
|
const payload = await runCliAppAction(client, action, name);
|
||||||
setCliApps(payload);
|
setCliApps(payload);
|
||||||
if (action !== "test") {
|
if (action !== "test") {
|
||||||
notifyCliAppsChanged(payload);
|
notifyCliAppsChanged(payload);
|
||||||
@@ -1934,8 +1933,8 @@ export function SettingsView({
|
|||||||
setNanobotFeaturesError(null);
|
setNanobotFeaturesError(null);
|
||||||
try {
|
try {
|
||||||
const payload = action === "enable"
|
const payload = action === "enable"
|
||||||
? await enableNanobotFeature(token, name)
|
? await enableNanobotFeature(client, name)
|
||||||
: await disableNanobotFeature(token, name);
|
: await disableNanobotFeature(client, name);
|
||||||
setNanobotFeatures(payload);
|
setNanobotFeatures(payload);
|
||||||
if (payload.requires_restart) {
|
if (payload.requires_restart) {
|
||||||
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
|
setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
|
||||||
@@ -1955,7 +1954,7 @@ export function SettingsView({
|
|||||||
setAutomationAction(key);
|
setAutomationAction(key);
|
||||||
setAutomationsError(null);
|
setAutomationsError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await runAutomationAction(token, action, job.id);
|
const payload = await runAutomationAction(client, action, job.id);
|
||||||
setAutomations(payload);
|
setAutomations(payload);
|
||||||
if (action === "delete") setAutomationPendingDelete(null);
|
if (action === "delete") setAutomationPendingDelete(null);
|
||||||
if (action === "run") {
|
if (action === "run") {
|
||||||
@@ -1977,7 +1976,7 @@ export function SettingsView({
|
|||||||
setAutomationAction(key);
|
setAutomationAction(key);
|
||||||
setAutomationsError(null);
|
setAutomationsError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await updateAutomation(token, job.id, values);
|
const payload = await updateAutomation(client, job.id, values);
|
||||||
setAutomations(payload);
|
setAutomations(payload);
|
||||||
setAutomationPendingEdit(null);
|
setAutomationPendingEdit(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1997,7 +1996,7 @@ export function SettingsView({
|
|||||||
setMcpMessage(null);
|
setMcpMessage(null);
|
||||||
setMcpError(null);
|
setMcpError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await runMcpPresetAction(token, action, name, values);
|
const payload = await runMcpPresetAction(client, 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") {
|
||||||
@@ -2024,7 +2023,7 @@ export function SettingsView({
|
|||||||
setMcpMessage(null);
|
setMcpMessage(null);
|
||||||
setMcpError(null);
|
setMcpError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await saveCustomMcpServer(token, {
|
const payload = await saveCustomMcpServer(client, {
|
||||||
name,
|
name,
|
||||||
transport: customMcpForm.transport,
|
transport: customMcpForm.transport,
|
||||||
command: customMcpForm.command,
|
command: customMcpForm.command,
|
||||||
@@ -2054,7 +2053,7 @@ export function SettingsView({
|
|||||||
setMcpMessage(null);
|
setMcpMessage(null);
|
||||||
setMcpError(null);
|
setMcpError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await importMcpConfig(token, mcpConfigImport);
|
const payload = await importMcpConfig(client, mcpConfigImport);
|
||||||
setMcpPresets(payload);
|
setMcpPresets(payload);
|
||||||
setMcpMessage(payload.last_action?.message ?? null);
|
setMcpMessage(payload.last_action?.message ?? null);
|
||||||
notifyMcpPresetsChanged(payload);
|
notifyMcpPresetsChanged(payload);
|
||||||
@@ -2075,7 +2074,7 @@ export function SettingsView({
|
|||||||
setMcpMessage(null);
|
setMcpMessage(null);
|
||||||
setMcpError(null);
|
setMcpError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await updateMcpServerTools(token, name, enabledTools);
|
const payload = await updateMcpServerTools(client, name, enabledTools);
|
||||||
setMcpPresets(payload);
|
setMcpPresets(payload);
|
||||||
setMcpMessage(payload.last_action?.message ?? null);
|
setMcpMessage(payload.last_action?.message ?? null);
|
||||||
notifyMcpPresetsChanged(payload);
|
notifyMcpPresetsChanged(payload);
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ function SkillDetailSheet({
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const { getToken } = useClient();
|
const { client, 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(getToken(), activeSkill.name, !enabled);
|
const payload = await updateSkillEnabled(client, 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(getToken(), activeSkill.name);
|
const payload = await deleteSkill(client, 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 { getToken } = useClient();
|
const { client, 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(
|
||||||
getToken(),
|
client,
|
||||||
skill.provider,
|
skill.provider,
|
||||||
skill.source,
|
skill.source,
|
||||||
skill.skill_id,
|
skill.skill_id,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ 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;
|
||||||
@@ -50,7 +51,6 @@ export type ChannelInstancesPanelCustomization = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ChannelInstancesPanel({
|
export function ChannelInstancesPanel({
|
||||||
token,
|
|
||||||
feature,
|
feature,
|
||||||
showBrandLogos,
|
showBrandLogos,
|
||||||
chatAppsDocsUrl,
|
chatAppsDocsUrl,
|
||||||
@@ -58,7 +58,6 @@ export function ChannelInstancesPanel({
|
|||||||
onFeaturesUpdate,
|
onFeaturesUpdate,
|
||||||
customization = {},
|
customization = {},
|
||||||
}: {
|
}: {
|
||||||
token: string;
|
|
||||||
feature: NanobotFeatureInfo;
|
feature: NanobotFeatureInfo;
|
||||||
showBrandLogos: boolean;
|
showBrandLogos: boolean;
|
||||||
chatAppsDocsUrl?: string;
|
chatAppsDocsUrl?: string;
|
||||||
@@ -66,6 +65,7 @@ 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(token, feature.name, { instanceId: instance.id })
|
? await enableNanobotFeature(client, feature.name, { instanceId: instance.id })
|
||||||
: await disableNanobotFeature(token, feature.name, { instanceId: instance.id });
|
: await disableNanobotFeature(client, 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(
|
||||||
token,
|
client,
|
||||||
feature.name,
|
feature.name,
|
||||||
channelValuesForSave(instanceFields, fieldValues),
|
channelValuesForSave(instanceFields, fieldValues),
|
||||||
{ enable: selected.enabled, instanceId: selected.id },
|
{ enable: selected.enabled, instanceId: selected.id },
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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;
|
||||||
@@ -43,7 +44,6 @@ export type ChannelQrConnectPendingContext = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ChannelQrConnectFlow({
|
export function ChannelQrConnectFlow({
|
||||||
token,
|
|
||||||
channelName,
|
channelName,
|
||||||
startOptions = {},
|
startOptions = {},
|
||||||
idleLabel,
|
idleLabel,
|
||||||
@@ -69,6 +69,7 @@ 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 });
|
||||||
@@ -78,8 +79,6 @@ 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;
|
||||||
@@ -129,7 +128,7 @@ export function ChannelQrConnectFlow({
|
|||||||
pollInFlight.current = true;
|
pollInFlight.current = true;
|
||||||
try {
|
try {
|
||||||
const payload = await pollChannelConnect(
|
const payload = await pollChannelConnect(
|
||||||
tokenRef.current,
|
client,
|
||||||
channelName,
|
channelName,
|
||||||
sessionId,
|
sessionId,
|
||||||
);
|
);
|
||||||
@@ -163,6 +162,7 @@ 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(tokenRef.current, channelName, {
|
const payload = await startChannelConnect(client, 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, startDomain, startForce, startInstanceId, startMode]);
|
}, [channelName, client, 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(
|
||||||
tokenRef.current,
|
client,
|
||||||
channelName,
|
channelName,
|
||||||
connect.session_id,
|
connect.session_id,
|
||||||
);
|
);
|
||||||
@@ -223,10 +223,9 @@ export function ChannelQrConnectFlow({
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await pollChannelConnect(
|
const payload = await pollChannelConnect(
|
||||||
tokenRef.current,
|
client,
|
||||||
channelName,
|
channelName,
|
||||||
connect.session_id,
|
connect.session_id,
|
||||||
"",
|
|
||||||
params,
|
params,
|
||||||
);
|
);
|
||||||
setConnect((current) => ({
|
setConnect((current) => ({
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ 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,
|
||||||
@@ -148,7 +149,6 @@ 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,6 +269,7 @@ 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);
|
||||||
@@ -345,7 +346,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(token, feature.name, values);
|
const validationPayload = await validateChannel(client, feature.name, values);
|
||||||
setValidation(validationPayload);
|
setValidation(validationPayload);
|
||||||
if (!validationPayload.can_enable) {
|
if (!validationPayload.can_enable) {
|
||||||
setNotice(
|
setNotice(
|
||||||
@@ -355,7 +356,7 @@ function ChannelSetupSurface({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = await configureChannel(
|
const payload = await configureChannel(
|
||||||
token,
|
client,
|
||||||
feature.name,
|
feature.name,
|
||||||
values,
|
values,
|
||||||
{ enable: true },
|
{ enable: true },
|
||||||
@@ -377,7 +378,7 @@ function ChannelSetupSurface({
|
|||||||
setNotice(null);
|
setNotice(null);
|
||||||
try {
|
try {
|
||||||
const payload = await validateChannel(
|
const payload = await validateChannel(
|
||||||
token,
|
client,
|
||||||
feature.name,
|
feature.name,
|
||||||
channelValuesForSubmit(fields, fieldValues, touchedFields),
|
channelValuesForSubmit(fields, fieldValues, touchedFields),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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(tokenRef.current, key, options);
|
const result = await apiDeleteSession(client, 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,6 +144,8 @@ 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;
|
||||||
@@ -171,14 +173,32 @@ export function useSidebarState(
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const persist = useCallback((next: SidebarStatePayload) => {
|
||||||
|
if (!connectionOpenRef.current) {
|
||||||
|
pendingPersistenceRef.current = next;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void client.setSidebarState(next).catch(() => {
|
||||||
|
// Sidebar persistence is best-effort; the optimistic local state remains usable.
|
||||||
|
});
|
||||||
|
}, [client]);
|
||||||
|
|
||||||
|
useEffect(() => client.onStatus((status) => {
|
||||||
|
connectionOpenRef.current = status === "open";
|
||||||
|
if (status !== "open" || pendingPersistenceRef.current === null) return;
|
||||||
|
const pending = pendingPersistenceRef.current;
|
||||||
|
pendingPersistenceRef.current = null;
|
||||||
|
persist(pending);
|
||||||
|
}), [client, persist]);
|
||||||
|
|
||||||
const update = useCallback(
|
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);
|
||||||
client.setSidebarState(next);
|
persist(next);
|
||||||
},
|
},
|
||||||
[client],
|
[persist],
|
||||||
);
|
);
|
||||||
|
|
||||||
const pruned = useMemo(() => {
|
const pruned = useMemo(() => {
|
||||||
|
|||||||
+264
-360
@@ -44,6 +44,8 @@ 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",
|
||||||
@@ -58,12 +60,6 @@ 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) {
|
||||||
@@ -73,6 +69,14 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WebUIMutationTransport {
|
||||||
|
requestMutation<T>(
|
||||||
|
action: string,
|
||||||
|
payload?: Record<string, unknown>,
|
||||||
|
timeoutMs?: number,
|
||||||
|
): Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
async function request<T>(
|
async function request<T>(
|
||||||
url: string,
|
url: string,
|
||||||
token: string,
|
token: string,
|
||||||
@@ -109,7 +113,27 @@ async function request<T>(
|
|||||||
return (await res.json()) as T;
|
return (await res.json()) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mcpValuesHeader(values: Record<string, unknown>): HeadersInit | undefined {
|
async function mutation<T>(
|
||||||
|
transport: WebUIMutationTransport,
|
||||||
|
action: string,
|
||||||
|
payload: Record<string, unknown> = {},
|
||||||
|
timeoutMs: number = API_MUTATION_TIMEOUT_MS,
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await transport.requestMutation<T>(action, payload, timeoutMs);
|
||||||
|
} catch (reason) {
|
||||||
|
const status = (
|
||||||
|
typeof reason === "object"
|
||||||
|
&& reason !== null
|
||||||
|
&& "status" in reason
|
||||||
|
&& typeof reason.status === "number"
|
||||||
|
) ? reason.status : 500;
|
||||||
|
const message = reason instanceof Error ? reason.message : "WebUI mutation failed";
|
||||||
|
throw new ApiError(status, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactMcpValues(values: Record<string, unknown>): Record<string, unknown> {
|
||||||
const payload: Record<string, unknown> = {};
|
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;
|
||||||
@@ -120,12 +144,7 @@ function mcpValuesHeader(values: Record<string, unknown>): HeadersInit | undefin
|
|||||||
}
|
}
|
||||||
payload[key] = value;
|
payload[key] = value;
|
||||||
});
|
});
|
||||||
if (!Object.keys(payload).length) return undefined;
|
return payload;
|
||||||
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 } {
|
||||||
@@ -261,37 +280,19 @@ export async function fetchAutomations(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runAutomationAction(
|
export async function runAutomationAction(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
action: "enable" | "disable" | "delete" | "run",
|
action: "enable" | "disable" | "delete" | "run",
|
||||||
id: string,
|
id: string,
|
||||||
base: string = "",
|
|
||||||
): Promise<AutomationsPayload> {
|
): Promise<AutomationsPayload> {
|
||||||
const query = new URLSearchParams();
|
return mutation<AutomationsPayload>(transport, `automation.${action}`, { id });
|
||||||
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(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
id: string,
|
id: string,
|
||||||
values: AutomationUpdatePayload,
|
values: AutomationUpdatePayload,
|
||||||
base: string = "",
|
|
||||||
): Promise<AutomationsPayload> {
|
): Promise<AutomationsPayload> {
|
||||||
const query = new URLSearchParams();
|
return mutation<AutomationsPayload>(transport, "automation.update", { id, values });
|
||||||
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(
|
||||||
@@ -320,28 +321,18 @@ export async function fetchSkillDetail(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSkillEnabled(
|
export async function updateSkillEnabled(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
name: string,
|
name: string,
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
base: string = "",
|
|
||||||
): Promise<SkillActionPayload> {
|
): Promise<SkillActionPayload> {
|
||||||
const params = new URLSearchParams({ name, enabled: String(enabled) });
|
return mutation<SkillActionPayload>(transport, "skill.update", { name, enabled });
|
||||||
return request<SkillActionPayload>(
|
|
||||||
`${base}/api/webui/skills/update?${params}`,
|
|
||||||
token,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteSkill(
|
export async function deleteSkill(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
name: string,
|
name: string,
|
||||||
base: string = "",
|
|
||||||
): Promise<SkillActionPayload> {
|
): Promise<SkillActionPayload> {
|
||||||
const params = new URLSearchParams({ name });
|
return mutation<SkillActionPayload>(transport, "skill.delete", { name });
|
||||||
return request<SkillActionPayload>(
|
|
||||||
`${base}/api/webui/skills/delete?${params}`,
|
|
||||||
token,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchMarketplaceSkills(
|
export async function searchMarketplaceSkills(
|
||||||
@@ -389,37 +380,33 @@ export async function fetchMarketplaceSkillTrends(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function installMarketplaceSkill(
|
export async function installMarketplaceSkill(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
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> {
|
||||||
const params = new URLSearchParams({ provider, source, skill });
|
return mutation<SkillInstallPayload>(
|
||||||
if (version) params.set("version", version);
|
transport,
|
||||||
return request<SkillInstallPayload>(
|
"skill.install",
|
||||||
`${base}/api/webui/skills/install?${params}`,
|
{ provider, source, skill, ...(version ? { version } : {}) },
|
||||||
token,
|
PACKAGE_MUTATION_TIMEOUT_MS,
|
||||||
undefined,
|
|
||||||
150_000,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteSession(
|
export async function deleteSession(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
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;
|
||||||
const resolvedBase = typeof optionsOrBase === "string" ? optionsOrBase : base;
|
return mutation<SessionDeleteResult>(
|
||||||
const query = new URLSearchParams();
|
transport,
|
||||||
if (options?.deleteAutomations) query.set("delete_automations", "true");
|
"session.delete",
|
||||||
const suffix = query.toString() ? `?${query}` : "";
|
{
|
||||||
return request<SessionDeleteResult>(
|
key,
|
||||||
`${resolvedBase}/api/sessions/${encodeURIComponent(key)}/delete${suffix}`,
|
...(options?.deleteAutomations ? { delete_automations: true } : {}),
|
||||||
token,
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,56 +507,50 @@ export async function fetchApiService(token: string, base: string = ""): Promise
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function startApiService(
|
export async function startApiService(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
values: { host: string; port: number; timeout: number; apiKey?: string },
|
values: { host: string; port: number; timeout: number; apiKey?: string },
|
||||||
base: string = "",
|
|
||||||
): Promise<ApiServicePayload> {
|
): Promise<ApiServicePayload> {
|
||||||
const query = new URLSearchParams({
|
return mutation<ApiServicePayload>(
|
||||||
host: values.host,
|
transport,
|
||||||
port: String(values.port),
|
"settings.api_service.start",
|
||||||
timeout: String(values.timeout),
|
{
|
||||||
});
|
host: values.host,
|
||||||
const headers = values.apiKey === undefined
|
port: values.port,
|
||||||
? undefined
|
timeout: values.timeout,
|
||||||
: { [API_SERVICE_VALUES_HEADER]: JSON.stringify({ api_key: values.apiKey }) };
|
...(values.apiKey !== undefined ? { api_key: values.apiKey } : {}),
|
||||||
return request<ApiServicePayload>(
|
},
|
||||||
`${base}/api/settings/api-service/start?${query}`,
|
PACKAGE_MUTATION_TIMEOUT_MS,
|
||||||
token,
|
|
||||||
{ headers },
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function stopApiService(token: string, base: string = ""): Promise<ApiServicePayload> {
|
export async function stopApiService(
|
||||||
return request<ApiServicePayload>(`${base}/api/settings/api-service/stop`, token);
|
transport: WebUIMutationTransport,
|
||||||
|
): Promise<ApiServicePayload> {
|
||||||
|
return mutation<ApiServicePayload>(transport, "settings.api_service.stop");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function enableNanobotFeature(
|
export async function enableNanobotFeature(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
name: string,
|
name: string,
|
||||||
options: { instanceId?: string } = {},
|
options: { instanceId?: string } = {},
|
||||||
base: string = "",
|
|
||||||
): Promise<NanobotFeaturesPayload> {
|
): Promise<NanobotFeaturesPayload> {
|
||||||
const query = new URLSearchParams();
|
return mutation<NanobotFeaturesPayload>(
|
||||||
query.set("name", name);
|
transport,
|
||||||
if (options.instanceId) query.set("instance_id", options.instanceId);
|
"settings.feature.enable",
|
||||||
return request<NanobotFeaturesPayload>(
|
{ name, ...(options.instanceId ? { instance_id: options.instanceId } : {}) },
|
||||||
`${base}/api/settings/nanobot-features/enable?${query}`,
|
PACKAGE_MUTATION_TIMEOUT_MS,
|
||||||
token,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function disableNanobotFeature(
|
export async function disableNanobotFeature(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
name: string,
|
name: string,
|
||||||
options: { instanceId?: string } = {},
|
options: { instanceId?: string } = {},
|
||||||
base: string = "",
|
|
||||||
): Promise<NanobotFeaturesPayload> {
|
): Promise<NanobotFeaturesPayload> {
|
||||||
const query = new URLSearchParams();
|
return mutation<NanobotFeaturesPayload>(
|
||||||
query.set("name", name);
|
transport,
|
||||||
if (options.instanceId) query.set("instance_id", options.instanceId);
|
"settings.feature.disable",
|
||||||
return request<NanobotFeaturesPayload>(
|
{ name, ...(options.instanceId ? { instance_id: options.instanceId } : {}) },
|
||||||
`${base}/api/settings/nanobot-features/disable?${query}`,
|
|
||||||
token,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,21 +567,15 @@ export async function fetchPairingRequests(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runPairingAction(
|
export async function runPairingAction(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
action: "approve" | "deny",
|
action: "approve" | "deny",
|
||||||
code: string,
|
code: string,
|
||||||
base: string = "",
|
|
||||||
): Promise<PairingPayload> {
|
): Promise<PairingPayload> {
|
||||||
const query = new URLSearchParams();
|
return mutation<PairingPayload>(transport, `settings.pairing.${action}`, { code });
|
||||||
query.set("code", code);
|
|
||||||
return request<PairingPayload>(
|
|
||||||
`${base}/api/settings/pairing/${action}?${query}`,
|
|
||||||
token,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startChannelConnect(
|
export async function startChannelConnect(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
channel: string,
|
channel: string,
|
||||||
options: {
|
options: {
|
||||||
domain?: string;
|
domain?: string;
|
||||||
@@ -608,104 +583,95 @@ export async function startChannelConnect(
|
|||||||
mode?: "replace" | "create";
|
mode?: "replace" | "create";
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
} = {},
|
} = {},
|
||||||
base: string = "",
|
|
||||||
): Promise<ChannelConnectPayload> {
|
): Promise<ChannelConnectPayload> {
|
||||||
const query = new URLSearchParams();
|
return mutation<ChannelConnectPayload>(
|
||||||
if (options.domain) query.set("domain", options.domain);
|
transport,
|
||||||
if (options.instanceId) query.set("instance_id", options.instanceId);
|
"settings.channel.connect.start",
|
||||||
if (options.mode) query.set("mode", options.mode);
|
{
|
||||||
if (options.force) query.set("force", "true");
|
channel,
|
||||||
const suffix = query.toString();
|
...(options.domain ? { domain: options.domain } : {}),
|
||||||
return request<ChannelConnectPayload>(
|
...(options.instanceId ? { instance_id: options.instanceId } : {}),
|
||||||
`${base}/api/settings/channels/${channel}/connect/start${suffix ? `?${suffix}` : ""}`,
|
...(options.mode ? { mode: options.mode } : {}),
|
||||||
token,
|
...(options.force ? { force: true } : {}),
|
||||||
|
},
|
||||||
|
PACKAGE_MUTATION_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pollChannelConnect(
|
export async function pollChannelConnect(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
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 query = new URLSearchParams();
|
const values = Object.fromEntries(
|
||||||
query.set("session_id", sessionId);
|
Object.entries(params).filter(([key]) => key !== "session_id"),
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
);
|
||||||
if (key !== "session_id") query.set(key, value);
|
return mutation<ChannelConnectPayload>(
|
||||||
});
|
transport,
|
||||||
return request<ChannelConnectPayload>(
|
"settings.channel.connect.poll",
|
||||||
`${base}/api/settings/channels/${channel}/connect/poll?${query}`,
|
{ channel, session_id: sessionId, ...values },
|
||||||
token,
|
PACKAGE_MUTATION_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cancelChannelConnect(
|
export async function cancelChannelConnect(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
channel: string,
|
channel: string,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
base: string = "",
|
|
||||||
): Promise<ChannelConnectPayload> {
|
): Promise<ChannelConnectPayload> {
|
||||||
const query = new URLSearchParams();
|
return mutation<ChannelConnectPayload>(
|
||||||
query.set("session_id", sessionId);
|
transport,
|
||||||
return request<ChannelConnectPayload>(
|
"settings.channel.connect.cancel",
|
||||||
`${base}/api/settings/channels/${channel}/connect/cancel?${query}`,
|
{ channel, session_id: sessionId },
|
||||||
token,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function configureChannel(
|
export async function configureChannel(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
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> {
|
||||||
const query = new URLSearchParams();
|
return mutation<ChannelConfigurePayload>(
|
||||||
query.set("name", name);
|
transport,
|
||||||
if (options.enable !== undefined) query.set("enable", String(options.enable));
|
"settings.channel.configure",
|
||||||
if (options.instanceId) query.set("instance_id", options.instanceId);
|
|
||||||
return request<ChannelConfigurePayload>(
|
|
||||||
`${base}/api/settings/channels/configure?${query}`,
|
|
||||||
token,
|
|
||||||
{
|
{
|
||||||
headers: {
|
name,
|
||||||
[CHANNEL_VALUES_HEADER]: JSON.stringify(values),
|
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(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
name: string,
|
name: string,
|
||||||
values: Record<string, string> = {},
|
values: Record<string, string> = {},
|
||||||
options: { instanceId?: string } = {},
|
options: { instanceId?: string } = {},
|
||||||
base: string = "",
|
|
||||||
): Promise<ChannelValidationPayload> {
|
): Promise<ChannelValidationPayload> {
|
||||||
const query = new URLSearchParams();
|
return mutation<ChannelValidationPayload>(
|
||||||
query.set("name", name);
|
transport,
|
||||||
if (options.instanceId) query.set("instance_id", options.instanceId);
|
"settings.channel.validate",
|
||||||
return request<ChannelValidationPayload>(
|
{ name, values, ...(options.instanceId ? { instance_id: options.instanceId } : {}) },
|
||||||
`${base}/api/settings/channels/validate?${query}`,
|
|
||||||
token,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
[CHANNEL_VALUES_HEADER]: JSON.stringify(values),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runCliAppAction(
|
export async function runCliAppAction(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
action: "install" | "update" | "uninstall" | "test",
|
action: "install" | "update" | "uninstall" | "test",
|
||||||
name: string,
|
name: string,
|
||||||
base: string = "",
|
|
||||||
): Promise<CliAppsPayload> {
|
): Promise<CliAppsPayload> {
|
||||||
const query = new URLSearchParams();
|
return mutation<CliAppsPayload>(
|
||||||
query.set("name", name);
|
transport,
|
||||||
return request<CliAppsPayload>(`${base}/api/settings/cli-apps/${action}?${query}`, token);
|
`settings.cli_app.${action}`,
|
||||||
|
{ name },
|
||||||
|
action === "install" || action === "update"
|
||||||
|
? PACKAGE_MUTATION_TIMEOUT_MS
|
||||||
|
: API_MUTATION_TIMEOUT_MS,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchMcpPresets(
|
export async function fetchMcpPresets(
|
||||||
@@ -736,55 +702,45 @@ export async function fetchProviderModels(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runMcpPresetAction(
|
export async function runMcpPresetAction(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
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> {
|
||||||
const query = new URLSearchParams();
|
return mutation<McpPresetsPayload>(
|
||||||
query.set("name", name);
|
transport,
|
||||||
return request<McpPresetsPayload>(
|
`settings.mcp.${action}`,
|
||||||
`${base}/api/settings/mcp-presets/${action}?${query}`,
|
{ name, ...compactMcpValues(values) },
|
||||||
token,
|
|
||||||
{ headers: mcpValuesHeader(values) },
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveCustomMcpServer(
|
export async function saveCustomMcpServer(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
values: Record<string, string>,
|
values: Record<string, string>,
|
||||||
base: string = "",
|
|
||||||
): Promise<McpPresetsPayload> {
|
): Promise<McpPresetsPayload> {
|
||||||
return request<McpPresetsPayload>(
|
return mutation<McpPresetsPayload>(
|
||||||
`${base}/api/settings/mcp-presets/custom`,
|
transport,
|
||||||
token,
|
"settings.mcp.custom",
|
||||||
{ headers: mcpValuesHeader(values) },
|
compactMcpValues(values),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importMcpConfig(
|
export async function importMcpConfig(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
config: string,
|
config: string,
|
||||||
base: string = "",
|
|
||||||
): Promise<McpPresetsPayload> {
|
): Promise<McpPresetsPayload> {
|
||||||
return request<McpPresetsPayload>(
|
return mutation<McpPresetsPayload>(transport, "settings.mcp.import", { config });
|
||||||
`${base}/api/settings/mcp-presets/import`,
|
|
||||||
token,
|
|
||||||
{ headers: mcpValuesHeader({ config }) },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateMcpServerTools(
|
export async function updateMcpServerTools(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
name: string,
|
name: string,
|
||||||
enabledTools: string[],
|
enabledTools: string[],
|
||||||
base: string = "",
|
|
||||||
): Promise<McpPresetsPayload> {
|
): Promise<McpPresetsPayload> {
|
||||||
return request<McpPresetsPayload>(
|
return mutation<McpPresetsPayload>(
|
||||||
`${base}/api/settings/mcp-presets/tools`,
|
transport,
|
||||||
token,
|
"settings.mcp.tools",
|
||||||
{ headers: mcpValuesHeader({ name, enabled_tools: enabledTools }) },
|
{ name, enabled_tools: enabledTools },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -835,280 +791,228 @@ export async function fetchSidebarState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSidebarState(
|
export async function updateSidebarState(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
state: SidebarStatePayload,
|
state: SidebarStatePayload,
|
||||||
base: string = "",
|
|
||||||
): Promise<SidebarStatePayload> {
|
): Promise<SidebarStatePayload> {
|
||||||
const query = new URLSearchParams();
|
return mutation<SidebarStatePayload>(transport, "sidebar.update", { state });
|
||||||
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(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
update: SettingsUpdate,
|
update: SettingsUpdate,
|
||||||
base: string = "",
|
|
||||||
): Promise<SettingsPayload> {
|
): Promise<SettingsPayload> {
|
||||||
const query = new URLSearchParams();
|
const payload: Record<string, unknown> = {};
|
||||||
if (update.modelPreset !== undefined) {
|
if (update.modelPreset !== undefined) {
|
||||||
query.set("model_preset", update.modelPreset ?? "default");
|
payload.model_preset = update.modelPreset ?? "default";
|
||||||
}
|
}
|
||||||
if (update.model !== undefined) query.set("model", update.model);
|
if (update.model !== undefined) payload.model = update.model;
|
||||||
if (update.provider !== undefined) query.set("provider", update.provider);
|
if (update.provider !== undefined) payload.provider = update.provider;
|
||||||
if (update.contextWindowTokens !== undefined) {
|
if (update.contextWindowTokens !== undefined) {
|
||||||
query.set("context_window_tokens", String(update.contextWindowTokens));
|
payload.context_window_tokens = update.contextWindowTokens;
|
||||||
}
|
}
|
||||||
if (update.timezone !== undefined) query.set("timezone", update.timezone);
|
if (update.timezone !== undefined) payload.timezone = update.timezone;
|
||||||
if (update.toolHintMaxLength !== undefined) {
|
if (update.toolHintMaxLength !== undefined) {
|
||||||
query.set("tool_hint_max_length", String(update.toolHintMaxLength));
|
payload.tool_hint_max_length = update.toolHintMaxLength;
|
||||||
}
|
}
|
||||||
return request<SettingsPayload>(`${base}/api/settings/update?${query}`, token);
|
return mutation<SettingsPayload>(transport, "settings.agent.update", payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendModelGenerationSettings(
|
function modelGenerationSettingsPayload(
|
||||||
query: URLSearchParams,
|
|
||||||
configuration: Pick<
|
configuration: Pick<
|
||||||
ModelConfigurationCreate,
|
ModelConfigurationCreate,
|
||||||
"maxTokens" | "contextWindowTokens" | "temperature" | "reasoningEffort"
|
"maxTokens" | "contextWindowTokens" | "temperature" | "reasoningEffort"
|
||||||
>,
|
>,
|
||||||
): void {
|
): Record<string, unknown> {
|
||||||
|
const payload: Record<string, unknown> = {};
|
||||||
if (configuration.maxTokens !== undefined) {
|
if (configuration.maxTokens !== undefined) {
|
||||||
query.set("max_tokens", String(configuration.maxTokens));
|
payload.max_tokens = configuration.maxTokens;
|
||||||
}
|
}
|
||||||
if (configuration.contextWindowTokens !== undefined) {
|
if (configuration.contextWindowTokens !== undefined) {
|
||||||
query.set("context_window_tokens", String(configuration.contextWindowTokens));
|
payload.context_window_tokens = configuration.contextWindowTokens;
|
||||||
}
|
}
|
||||||
if (configuration.temperature !== undefined) {
|
if (configuration.temperature !== undefined) {
|
||||||
query.set("temperature", String(configuration.temperature));
|
payload.temperature = configuration.temperature;
|
||||||
}
|
}
|
||||||
if (configuration.reasoningEffort !== undefined) {
|
if (configuration.reasoningEffort !== undefined) {
|
||||||
query.set("reasoning_effort", configuration.reasoningEffort ?? "");
|
payload.reasoning_effort = configuration.reasoningEffort ?? "";
|
||||||
}
|
}
|
||||||
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createModelConfiguration(
|
export async function createModelConfiguration(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
configuration: ModelConfigurationCreate,
|
configuration: ModelConfigurationCreate,
|
||||||
base: string = "",
|
|
||||||
): Promise<SettingsPayload> {
|
): Promise<SettingsPayload> {
|
||||||
const query = new URLSearchParams();
|
return mutation<SettingsPayload>(
|
||||||
if (configuration.name !== undefined) query.set("name", configuration.name);
|
transport,
|
||||||
query.set("label", configuration.label);
|
"settings.model_configuration.create",
|
||||||
query.set("provider", configuration.provider);
|
{
|
||||||
query.set("model", configuration.model);
|
...(configuration.name !== undefined ? { name: configuration.name } : {}),
|
||||||
appendModelGenerationSettings(query, configuration);
|
label: configuration.label,
|
||||||
return request<SettingsPayload>(
|
provider: configuration.provider,
|
||||||
`${base}/api/settings/model-configurations/create?${query}`,
|
model: configuration.model,
|
||||||
token,
|
...modelGenerationSettingsPayload(configuration),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateModelConfiguration(
|
export async function updateModelConfiguration(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
configuration: ModelConfigurationUpdate,
|
configuration: ModelConfigurationUpdate,
|
||||||
base: string = "",
|
|
||||||
): Promise<SettingsPayload> {
|
): Promise<SettingsPayload> {
|
||||||
const query = new URLSearchParams();
|
return mutation<SettingsPayload>(
|
||||||
query.set("name", configuration.name);
|
transport,
|
||||||
if (configuration.label !== undefined) query.set("label", configuration.label);
|
"settings.model_configuration.update",
|
||||||
if (configuration.provider !== undefined) query.set("provider", configuration.provider);
|
{
|
||||||
if (configuration.model !== undefined) query.set("model", configuration.model);
|
name: configuration.name,
|
||||||
appendModelGenerationSettings(query, configuration);
|
...(configuration.label !== undefined ? { label: configuration.label } : {}),
|
||||||
return request<SettingsPayload>(
|
...(configuration.provider !== undefined ? { provider: configuration.provider } : {}),
|
||||||
`${base}/api/settings/model-configurations/update?${query}`,
|
...(configuration.model !== undefined ? { model: configuration.model } : {}),
|
||||||
token,
|
...modelGenerationSettingsPayload(configuration),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteModelConfiguration(
|
export async function deleteModelConfiguration(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
name: string,
|
name: string,
|
||||||
base: string = "",
|
|
||||||
): Promise<SettingsPayload> {
|
): Promise<SettingsPayload> {
|
||||||
const query = new URLSearchParams({ name });
|
return mutation<SettingsPayload>(
|
||||||
return request<SettingsPayload>(
|
transport,
|
||||||
`${base}/api/settings/model-configurations/delete?${query}`,
|
"settings.model_configuration.delete",
|
||||||
token,
|
{ name },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function migrateModelConfigurations(
|
export async function migrateModelConfigurations(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
base: string = "",
|
|
||||||
): Promise<SettingsPayload> {
|
): Promise<SettingsPayload> {
|
||||||
return request<SettingsPayload>(
|
return mutation<SettingsPayload>(transport, "settings.model_configuration.migrate");
|
||||||
`${base}/api/settings/model-configurations/migrate`,
|
|
||||||
token,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateModelCallOrder(
|
export async function updateModelCallOrder(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
order: string[],
|
order: string[],
|
||||||
base: string = "",
|
|
||||||
): Promise<SettingsPayload> {
|
): Promise<SettingsPayload> {
|
||||||
const query = new URLSearchParams({ order: JSON.stringify(order) });
|
return mutation<SettingsPayload>(transport, "settings.model_call_order.update", { order });
|
||||||
return request<SettingsPayload>(
|
|
||||||
`${base}/api/settings/model-call-order/update?${query}`,
|
|
||||||
token,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProviderSettings(
|
export async function updateProviderSettings(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
update: ProviderSettingsUpdate,
|
update: ProviderSettingsUpdate,
|
||||||
base: string = "",
|
|
||||||
): Promise<SettingsPayload> {
|
): Promise<SettingsPayload> {
|
||||||
const { provider, ...values } = update;
|
return mutation<SettingsPayload>(transport, "settings.provider.update", { ...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(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
update: ProviderCreationUpdate,
|
update: ProviderCreationUpdate,
|
||||||
base: string = "",
|
|
||||||
): Promise<SettingsPayload> {
|
): Promise<SettingsPayload> {
|
||||||
return request<SettingsPayload>(
|
return mutation<SettingsPayload>(transport, "settings.provider.create", { ...update });
|
||||||
`${base}/api/settings/provider/create`,
|
|
||||||
token,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
[PROVIDER_VALUES_HEADER]: encodeURIComponent(JSON.stringify(update)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginProviderOAuth(
|
export async function loginProviderOAuth(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
provider: string,
|
provider: string,
|
||||||
base: string = "",
|
|
||||||
remoteBrowserAccess: boolean = false,
|
remoteBrowserAccess: boolean = false,
|
||||||
): Promise<ProviderOAuthLoginResult> {
|
): Promise<ProviderOAuthLoginResult> {
|
||||||
const query = new URLSearchParams();
|
return mutation<ProviderOAuthLoginResult>(
|
||||||
query.set("provider", provider);
|
transport,
|
||||||
if (remoteBrowserAccess) query.set("remote_browser", "true");
|
"settings.provider.oauth_login",
|
||||||
return request<ProviderOAuthLoginResult>(
|
{ provider, ...(remoteBrowserAccess ? { remote_browser: true } : {}) },
|
||||||
`${base}/api/settings/provider/oauth-login?${query}`,
|
|
||||||
token,
|
|
||||||
{ cache: "no-store" },
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function completeProviderOAuth(
|
export async function completeProviderOAuth(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
provider: string,
|
provider: string,
|
||||||
flowId: string,
|
flowId: string,
|
||||||
authorizationResponse?: string,
|
authorizationResponse?: string,
|
||||||
base: string = "",
|
|
||||||
): Promise<ProviderOAuthCompletionResult> {
|
): Promise<ProviderOAuthCompletionResult> {
|
||||||
const query = new URLSearchParams();
|
return mutation<ProviderOAuthCompletionResult>(
|
||||||
query.set("provider", provider);
|
transport,
|
||||||
query.set("flow_id", flowId);
|
"settings.provider.oauth_complete",
|
||||||
const responseHeader = provider === "openai_codex"
|
{
|
||||||
? OAUTH_CALLBACK_HEADER
|
provider,
|
||||||
: OAUTH_CODE_HEADER;
|
flow_id: flowId,
|
||||||
const headers = authorizationResponse
|
...(authorizationResponse ? { authorization_response: 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(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
provider: string,
|
provider: string,
|
||||||
base: string = "",
|
|
||||||
): Promise<SettingsPayload> {
|
): Promise<SettingsPayload> {
|
||||||
const query = new URLSearchParams();
|
return mutation<SettingsPayload>(transport, "settings.provider.oauth_logout", { provider });
|
||||||
query.set("provider", provider);
|
|
||||||
return request<SettingsPayload>(
|
|
||||||
`${base}/api/settings/provider/oauth-logout?${query}`,
|
|
||||||
token,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateWebSearchSettings(
|
export async function updateWebSearchSettings(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
update: WebSearchSettingsUpdate,
|
update: WebSearchSettingsUpdate,
|
||||||
base: string = "",
|
|
||||||
): Promise<SettingsPayload> {
|
): Promise<SettingsPayload> {
|
||||||
const query = new URLSearchParams();
|
return mutation<SettingsPayload>(
|
||||||
query.set("provider", update.provider);
|
transport,
|
||||||
if (update.apiKey !== undefined) query.set("api_key", update.apiKey);
|
"settings.web_search.update",
|
||||||
if (update.baseUrl !== undefined) query.set("base_url", update.baseUrl);
|
{
|
||||||
if (update.maxResults !== undefined) query.set("max_results", String(update.maxResults));
|
provider: update.provider,
|
||||||
if (update.timeout !== undefined) query.set("timeout", String(update.timeout));
|
...(update.apiKey !== undefined ? { api_key: update.apiKey } : {}),
|
||||||
if (update.useJinaReader !== undefined) {
|
...(update.baseUrl !== undefined ? { base_url: update.baseUrl } : {}),
|
||||||
query.set("use_jina_reader", String(update.useJinaReader));
|
...(update.maxResults !== undefined ? { max_results: update.maxResults } : {}),
|
||||||
}
|
...(update.timeout !== undefined ? { timeout: update.timeout } : {}),
|
||||||
return request<SettingsPayload>(
|
...(update.useJinaReader !== undefined
|
||||||
`${base}/api/settings/web-search/update?${query}`,
|
? { use_jina_reader: update.useJinaReader }
|
||||||
token,
|
: {}),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateNetworkSafetySettings(
|
export async function updateNetworkSafetySettings(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
update: NetworkSafetySettingsUpdate,
|
update: NetworkSafetySettingsUpdate,
|
||||||
base: string = "",
|
|
||||||
): Promise<SettingsPayload> {
|
): Promise<SettingsPayload> {
|
||||||
const query = new URLSearchParams();
|
return mutation<SettingsPayload>(
|
||||||
query.set("webui_allow_local_service_access", String(update.webuiAllowLocalServiceAccess));
|
transport,
|
||||||
query.set("webui_default_access_mode", update.webuiDefaultAccessMode);
|
"settings.network_safety.update",
|
||||||
return request<SettingsPayload>(
|
{
|
||||||
`${base}/api/settings/network-safety/update?${query}`,
|
webui_allow_local_service_access: update.webuiAllowLocalServiceAccess,
|
||||||
token,
|
webui_default_access_mode: update.webuiDefaultAccessMode,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateImageGenerationSettings(
|
export async function updateImageGenerationSettings(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
update: ImageGenerationSettingsUpdate,
|
update: ImageGenerationSettingsUpdate,
|
||||||
base: string = "",
|
|
||||||
): Promise<SettingsPayload> {
|
): Promise<SettingsPayload> {
|
||||||
const query = new URLSearchParams();
|
return mutation<SettingsPayload>(
|
||||||
query.set("enabled", String(update.enabled));
|
transport,
|
||||||
query.set("provider", update.provider);
|
"settings.image_generation.update",
|
||||||
query.set("model", update.model);
|
{
|
||||||
query.set("default_aspect_ratio", update.defaultAspectRatio);
|
enabled: update.enabled,
|
||||||
query.set("default_image_size", update.defaultImageSize);
|
provider: update.provider,
|
||||||
query.set("max_images_per_turn", String(update.maxImagesPerTurn));
|
model: update.model,
|
||||||
return request<SettingsPayload>(
|
default_aspect_ratio: update.defaultAspectRatio,
|
||||||
`${base}/api/settings/image-generation/update?${query}`,
|
default_image_size: update.defaultImageSize,
|
||||||
token,
|
max_images_per_turn: update.maxImagesPerTurn,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateTranscriptionSettings(
|
export async function updateTranscriptionSettings(
|
||||||
token: string,
|
transport: WebUIMutationTransport,
|
||||||
update: TranscriptionSettingsUpdate,
|
update: TranscriptionSettingsUpdate,
|
||||||
base: string = "",
|
|
||||||
): Promise<SettingsPayload> {
|
): Promise<SettingsPayload> {
|
||||||
const query = new URLSearchParams();
|
return mutation<SettingsPayload>(
|
||||||
query.set("enabled", String(update.enabled));
|
transport,
|
||||||
query.set("provider", update.provider);
|
"settings.transcription.update",
|
||||||
query.set("model", update.model);
|
{
|
||||||
query.set("language", update.language);
|
enabled: update.enabled,
|
||||||
query.set("max_duration_sec", String(update.maxDurationSec));
|
provider: update.provider,
|
||||||
query.set("max_upload_mb", String(update.maxUploadMb));
|
model: update.model,
|
||||||
return request<SettingsPayload>(
|
language: update.language,
|
||||||
`${base}/api/settings/transcription/update?${query}`,
|
max_duration_sec: update.maxDurationSec,
|
||||||
token,
|
max_upload_mb: update.maxUploadMb,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,16 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -203,6 +213,7 @@ 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;
|
||||||
@@ -807,6 +818,60 @@ export class NanobotClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send one non-replayable WebUI mutation over the authenticated socket.
|
||||||
|
* A client-side timeout only abandons the reply; the server may finish work
|
||||||
|
* that already started, so timed-out requests are never retried automatically.
|
||||||
|
*/
|
||||||
|
requestMutation<T>(
|
||||||
|
action: string,
|
||||||
|
payload: Record<string, unknown> = {},
|
||||||
|
timeoutMs: number = 20_000,
|
||||||
|
): Promise<T> {
|
||||||
|
const socket = this.socket;
|
||||||
|
if (!socket || socket.readyState !== WS_OPEN) {
|
||||||
|
return Promise.reject(
|
||||||
|
new WebUIMutationError(503, "WebUI connection is not open"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const requestId = crypto.randomUUID();
|
||||||
|
const frame: Outbound = {
|
||||||
|
type: "webui_request",
|
||||||
|
request_id: requestId,
|
||||||
|
action,
|
||||||
|
payload,
|
||||||
|
};
|
||||||
|
if (!this.frameFitsTransport(frame)) {
|
||||||
|
return Promise.reject(
|
||||||
|
new WebUIMutationError(413, "WebUI mutation payload is too large"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.pendingWebUIRequests.delete(requestId);
|
||||||
|
reject(
|
||||||
|
new WebUIMutationError(
|
||||||
|
504,
|
||||||
|
`WebUI request timed out after ${timeoutMs}ms`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, timeoutMs);
|
||||||
|
this.pendingWebUIRequests.set(requestId, {
|
||||||
|
resolve: (value) => resolve(value as T),
|
||||||
|
reject,
|
||||||
|
timer,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
socket.send(JSON.stringify(frame));
|
||||||
|
} catch {
|
||||||
|
clearTimeout(timer);
|
||||||
|
this.pendingWebUIRequests.delete(requestId);
|
||||||
|
reject(new WebUIMutationError(503, "Could not send WebUI request"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Ask the server to create a non-destructive fork before a user-message index. */
|
/** Ask the server to create a non-destructive fork before a user-message index. */
|
||||||
forkChat(
|
forkChat(
|
||||||
sourceChatId: string,
|
sourceChatId: string,
|
||||||
@@ -914,8 +979,8 @@ export class NanobotClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setSidebarState(state: SidebarStatePayload): void {
|
setSidebarState(state: SidebarStatePayload): Promise<SidebarStatePayload> {
|
||||||
this.queueSend({ type: "set_sidebar_state", state });
|
return this.requestMutation<SidebarStatePayload>("sidebar.update", { state });
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- internals ---------------------------------------------------------
|
// -- internals ---------------------------------------------------------
|
||||||
@@ -965,6 +1030,23 @@ 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) {
|
||||||
@@ -1151,6 +1233,13 @@ 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"));
|
||||||
|
|||||||
@@ -1261,6 +1261,18 @@ 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;
|
||||||
@@ -1339,6 +1351,12 @@ 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 }
|
||||||
|
|||||||
+310
-359
@@ -59,8 +59,19 @@ 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({
|
||||||
@@ -184,88 +195,74 @@ describe("webui API helpers", () => {
|
|||||||
|
|
||||||
it("validates channel settings with form values", async () => {
|
it("validates channel settings with form values", async () => {
|
||||||
await validateChannel(
|
await validateChannel(
|
||||||
"tok",
|
mutationTransport,
|
||||||
"slack",
|
"slack",
|
||||||
{ "channels.slack.botToken": "xoxb-test" },
|
{ "channels.slack.botToken": "xoxb-test" },
|
||||||
{ instanceId: "default" },
|
{ instanceId: "default" },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(requestMutation).toHaveBeenCalledWith(
|
||||||
"/api/settings/channels/validate?name=slack&instance_id=default",
|
"settings.channel.validate",
|
||||||
expect.objectContaining({
|
{
|
||||||
headers: expect.objectContaining({
|
name: "slack",
|
||||||
Authorization: "Bearer tok",
|
instance_id: "default",
|
||||||
"X-Nanobot-Channel-Values": JSON.stringify({
|
values: { "channels.slack.botToken": "xoxb-test" },
|
||||||
"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 WebSocket HTTP shim", async () => {
|
it("configures channels through the authenticated WebSocket", async () => {
|
||||||
await configureChannel(
|
await configureChannel(
|
||||||
"tok",
|
mutationTransport,
|
||||||
"discord",
|
"discord",
|
||||||
{ "channels.discord.token": "saved-secret" },
|
{ "channels.discord.token": "saved-secret" },
|
||||||
{ enable: true },
|
{ enable: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(requestMutation).toHaveBeenCalledWith(
|
||||||
"/api/settings/channels/configure?name=discord&enable=true",
|
"settings.channel.configure",
|
||||||
expect.objectContaining({
|
{
|
||||||
headers: expect.objectContaining({
|
name: "discord",
|
||||||
Authorization: "Bearer tok",
|
enable: true,
|
||||||
"X-Nanobot-Channel-Values": JSON.stringify({
|
values: { "channels.discord.token": "saved-secret" },
|
||||||
"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 helpers", async () => {
|
it("serializes channel QR connect request envelopes", async () => {
|
||||||
await startChannelConnect("tok", "weixin", { force: true });
|
await startChannelConnect(mutationTransport, "weixin", { force: true });
|
||||||
expect(fetch).toHaveBeenLastCalledWith(
|
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||||
"/api/settings/channels/weixin/connect/start?force=true",
|
"settings.channel.connect.start",
|
||||||
expect.objectContaining({
|
{ channel: "weixin", force: true },
|
||||||
headers: { Authorization: "Bearer tok" },
|
150_000,
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await pollChannelConnect("tok", "weixin", "session+/=");
|
await pollChannelConnect(mutationTransport, "weixin", "session+/=");
|
||||||
expect(fetch).toHaveBeenLastCalledWith(
|
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||||
"/api/settings/channels/weixin/connect/poll?session_id=session%2B%2F%3D",
|
"settings.channel.connect.poll",
|
||||||
expect.objectContaining({
|
{ channel: "weixin", session_id: "session+/=" },
|
||||||
headers: { Authorization: "Bearer tok" },
|
150_000,
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await cancelChannelConnect("tok", "weixin", "session+/=");
|
await cancelChannelConnect(mutationTransport, "weixin", "session+/=");
|
||||||
expect(fetch).toHaveBeenLastCalledWith(
|
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||||
"/api/settings/channels/weixin/connect/cancel?session_id=session%2B%2F%3D",
|
"settings.channel.connect.cancel",
|
||||||
expect.objectContaining({
|
{ channel: "weixin", session_id: "session+/=" },
|
||||||
headers: { Authorization: "Bearer tok" },
|
20_000,
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serializes workspace automation actions", async () => {
|
it("serializes workspace automation actions", async () => {
|
||||||
await runAutomationAction("tok", "disable", "job 1/2");
|
await runAutomationAction(mutationTransport, "disable", "job 1/2");
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(requestMutation).toHaveBeenCalledWith(
|
||||||
"/api/webui/automations/disable?id=job+1%2F2",
|
"automation.disable",
|
||||||
expect.objectContaining({
|
{ id: "job 1/2" },
|
||||||
headers: { Authorization: "Bearer tok" },
|
20_000,
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -275,19 +272,14 @@ 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("tok", "job 1/2", values);
|
await updateAutomation(mutationTransport, "job 1/2", values);
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(requestMutation).toHaveBeenCalledWith(
|
||||||
"/api/webui/automations/update?id=job+1%2F2",
|
"automation.update",
|
||||||
expect.objectContaining({
|
{ id: "job 1/2", values },
|
||||||
headers: {
|
20_000,
|
||||||
Authorization: "Bearer tok",
|
|
||||||
"X-Nanobot-Automation-Values": encodeURIComponent(JSON.stringify(values)),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
const header = vi.mocked(fetch).mock.calls[0][1]?.headers as Record<string, string>;
|
expect(fetch).not.toHaveBeenCalled();
|
||||||
expect(header["X-Nanobot-Automation-Values"]).not.toContain("每日");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fetches the WebUI skill summary", async () => {
|
it("fetches the WebUI skill summary", async () => {
|
||||||
@@ -348,66 +340,66 @@ describe("webui API helpers", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("encodes provider install coordinates", async () => {
|
it("sends provider install coordinates without placing them in a URL", async () => {
|
||||||
await installMarketplaceSkill(
|
await installMarketplaceSkill(
|
||||||
"tok",
|
mutationTransport,
|
||||||
"skillhub",
|
"skillhub",
|
||||||
"@tencent/skills",
|
"@tencent/skills",
|
||||||
"ima-skills",
|
"ima-skills",
|
||||||
"1.1.8",
|
"1.1.8",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(requestMutation).toHaveBeenCalledWith(
|
||||||
"/api/webui/skills/install?provider=skillhub&source=%40tencent%2Fskills&skill=ima-skills&version=1.1.8",
|
"skill.install",
|
||||||
expect.objectContaining({
|
{
|
||||||
headers: { Authorization: "Bearer tok" },
|
provider: "skillhub",
|
||||||
}),
|
source: "@tencent/skills",
|
||||||
|
skill: "ima-skills",
|
||||||
|
version: "1.1.8",
|
||||||
|
},
|
||||||
|
150_000,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates and deletes installed skills with encoded names", async () => {
|
it("updates and deletes installed skills over the WebSocket", async () => {
|
||||||
await updateSkillEnabled("tok", "custom skill", false);
|
await updateSkillEnabled(mutationTransport, "custom skill", false);
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||||
"/api/webui/skills/update?name=custom+skill&enabled=false",
|
"skill.update",
|
||||||
expect.objectContaining({
|
{ name: "custom skill", enabled: false },
|
||||||
headers: { Authorization: "Bearer tok" },
|
20_000,
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await deleteSkill("tok", "custom skill");
|
await deleteSkill(mutationTransport, "custom skill");
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||||
"/api/webui/skills/delete?name=custom+skill",
|
"skill.delete",
|
||||||
expect.objectContaining({
|
{ name: "custom skill" },
|
||||||
headers: { Authorization: "Bearer tok" },
|
20_000,
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("percent-encodes websocket keys when deleting a session", async () => {
|
it("sends the session key in a mutation payload", async () => {
|
||||||
await deleteSession("tok", "websocket:chat-1");
|
await deleteSession(mutationTransport, "websocket:chat-1");
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(requestMutation).toHaveBeenCalledWith(
|
||||||
"/api/sessions/websocket%3Achat-1/delete",
|
"session.delete",
|
||||||
expect.objectContaining({
|
{ key: "websocket:chat-1" },
|
||||||
headers: { Authorization: "Bearer tok" },
|
20_000,
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes the automation cascade flag when deleting a session", async () => {
|
it("passes the automation cascade flag when deleting a session", async () => {
|
||||||
await deleteSession("tok", "websocket:chat-1", { deleteAutomations: true });
|
await deleteSession(mutationTransport, "websocket:chat-1", { deleteAutomations: true });
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(requestMutation).toHaveBeenCalledWith(
|
||||||
"/api/sessions/websocket%3Achat-1/delete?delete_automations=true",
|
"session.delete",
|
||||||
expect.objectContaining({
|
{ key: "websocket:chat-1", delete_automations: true },
|
||||||
headers: { Authorization: "Bearer tok" },
|
20_000,
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serializes settings updates as a narrow query string", async () => {
|
it("serializes settings updates as a narrow mutation payload", async () => {
|
||||||
await updateSettings("tok", {
|
await updateSettings(mutationTransport, {
|
||||||
modelPreset: "default",
|
modelPreset: "default",
|
||||||
model: "openrouter/test",
|
model: "openrouter/test",
|
||||||
provider: "openrouter",
|
provider: "openrouter",
|
||||||
@@ -416,11 +408,17 @@ describe("webui API helpers", () => {
|
|||||||
toolHintMaxLength: 120,
|
toolHintMaxLength: 120,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(requestMutation).toHaveBeenCalledWith(
|
||||||
"/api/settings/update?model_preset=default&model=openrouter%2Ftest&provider=openrouter&context_window_tokens=262144&timezone=Asia%2FShanghai&tool_hint_max_length=120",
|
"settings.agent.update",
|
||||||
expect.objectContaining({
|
{
|
||||||
headers: { Authorization: "Bearer tok" },
|
model_preset: "default",
|
||||||
}),
|
model: "openrouter/test",
|
||||||
|
provider: "openrouter",
|
||||||
|
context_window_tokens: 262144,
|
||||||
|
timezone: "Asia/Shanghai",
|
||||||
|
tool_hint_max_length: 120,
|
||||||
|
},
|
||||||
|
20_000,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -436,7 +434,7 @@ describe("webui API helpers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("serializes model configuration creation", async () => {
|
it("serializes model configuration creation", async () => {
|
||||||
await createModelConfiguration("tok", {
|
await createModelConfiguration(mutationTransport, {
|
||||||
label: "Fast writing",
|
label: "Fast writing",
|
||||||
provider: "openai",
|
provider: "openai",
|
||||||
model: "openai/gpt-4.1-mini",
|
model: "openai/gpt-4.1-mini",
|
||||||
@@ -446,16 +444,23 @@ describe("webui API helpers", () => {
|
|||||||
reasoningEffort: "high",
|
reasoningEffort: "high",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(requestMutation).toHaveBeenCalledWith(
|
||||||
"/api/settings/model-configurations/create?label=Fast+writing&provider=openai&model=openai%2Fgpt-4.1-mini&max_tokens=4096&context_window_tokens=128000&temperature=0.4&reasoning_effort=high",
|
"settings.model_configuration.create",
|
||||||
expect.objectContaining({
|
{
|
||||||
headers: { Authorization: "Bearer tok" },
|
label: "Fast writing",
|
||||||
}),
|
provider: "openai",
|
||||||
|
model: "openai/gpt-4.1-mini",
|
||||||
|
max_tokens: 4096,
|
||||||
|
context_window_tokens: 128000,
|
||||||
|
temperature: 0.4,
|
||||||
|
reasoning_effort: "high",
|
||||||
|
},
|
||||||
|
20_000,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serializes model configuration updates", async () => {
|
it("serializes model configuration updates", async () => {
|
||||||
await updateModelConfiguration("tok", {
|
await updateModelConfiguration(mutationTransport, {
|
||||||
name: "codex",
|
name: "codex",
|
||||||
label: "Codex",
|
label: "Codex",
|
||||||
provider: "openai_codex",
|
provider: "openai_codex",
|
||||||
@@ -466,42 +471,47 @@ describe("webui API helpers", () => {
|
|||||||
reasoningEffort: null,
|
reasoningEffort: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(requestMutation).toHaveBeenCalledWith(
|
||||||
"/api/settings/model-configurations/update?name=codex&label=Codex&provider=openai_codex&model=openai-codex%2Fgpt-5.5&max_tokens=8192&context_window_tokens=65536&temperature=0&reasoning_effort=",
|
"settings.model_configuration.update",
|
||||||
expect.objectContaining({
|
{
|
||||||
headers: { Authorization: "Bearer tok" },
|
name: "codex",
|
||||||
}),
|
label: "Codex",
|
||||||
|
provider: "openai_codex",
|
||||||
|
model: "openai-codex/gpt-5.5",
|
||||||
|
max_tokens: 8192,
|
||||||
|
context_window_tokens: 65536,
|
||||||
|
temperature: 0,
|
||||||
|
reasoning_effort: "",
|
||||||
|
},
|
||||||
|
20_000,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serializes model preset deletion and migration", async () => {
|
it("serializes model preset deletion and migration", async () => {
|
||||||
await deleteModelConfiguration("tok", "spare");
|
await deleteModelConfiguration(mutationTransport, "spare");
|
||||||
await migrateModelConfigurations("tok");
|
await migrateModelConfigurations(mutationTransport);
|
||||||
|
|
||||||
expect(fetch).toHaveBeenNthCalledWith(
|
expect(requestMutation).toHaveBeenNthCalledWith(
|
||||||
1,
|
1,
|
||||||
"/api/settings/model-configurations/delete?name=spare",
|
"settings.model_configuration.delete",
|
||||||
expect.objectContaining({
|
{ name: "spare" },
|
||||||
headers: { Authorization: "Bearer tok" },
|
20_000,
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
expect(fetch).toHaveBeenNthCalledWith(
|
expect(requestMutation).toHaveBeenNthCalledWith(
|
||||||
2,
|
2,
|
||||||
"/api/settings/model-configurations/migrate",
|
"settings.model_configuration.migrate",
|
||||||
expect.objectContaining({
|
{},
|
||||||
headers: { Authorization: "Bearer tok" },
|
20_000,
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serializes model call order as an ordered JSON array", async () => {
|
it("serializes model call order as an ordered JSON array", async () => {
|
||||||
await updateModelCallOrder("tok", ["backup", "primary"]);
|
await updateModelCallOrder(mutationTransport, ["backup", "primary"]);
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(requestMutation).toHaveBeenCalledWith(
|
||||||
"/api/settings/model-call-order/update?order=%5B%22backup%22%2C%22primary%22%5D",
|
"settings.model_call_order.update",
|
||||||
expect.objectContaining({
|
{ order: ["backup", "primary"] },
|
||||||
headers: { Authorization: "Bearer tok" },
|
20_000,
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -516,28 +526,20 @@ describe("webui API helpers", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(fetchApiService("tok")).rejects.toMatchObject({
|
||||||
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 API error response bodies", async () => {
|
it("surfaces correlated WebSocket mutation errors", async () => {
|
||||||
vi.stubGlobal(
|
requestMutation.mockRejectedValueOnce(
|
||||||
"fetch",
|
Object.assign(new Error("npm error ENOTEMPTY"), { status: 500 }),
|
||||||
vi.fn().mockResolvedValue({
|
|
||||||
ok: false,
|
|
||||||
status: 500,
|
|
||||||
text: async () => "npm error ENOTEMPTY",
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(runCliAppAction("tok", "install", "hyperframes")).rejects.toMatchObject({
|
await expect(
|
||||||
|
runCliAppAction(mutationTransport, "install", "hyperframes"),
|
||||||
|
).rejects.toMatchObject({
|
||||||
status: 500,
|
status: 500,
|
||||||
message: "npm error ENOTEMPTY",
|
message: "npm error ENOTEMPTY",
|
||||||
});
|
});
|
||||||
@@ -555,50 +557,45 @@ describe("webui API helpers", () => {
|
|||||||
await pending;
|
await pending;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serializes provider settings updates without returning secrets", async () => {
|
it("keeps provider secrets in the WebSocket payload", async () => {
|
||||||
await updateProviderSettings("tok", {
|
await updateProviderSettings(mutationTransport, {
|
||||||
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(fetch).toHaveBeenCalledWith(
|
expect(requestMutation).toHaveBeenCalledWith(
|
||||||
"/api/settings/provider/update?provider=openrouter",
|
"settings.provider.update",
|
||||||
expect.objectContaining({
|
{
|
||||||
headers: {
|
provider: "openrouter",
|
||||||
Authorization: "Bearer tok",
|
apiKey: "sk-or-test",
|
||||||
"X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({
|
apiBase: "https://openrouter.ai/api/v1",
|
||||||
apiKey: "sk-or-test",
|
},
|
||||||
apiBase: "https://openrouter.ai/api/v1",
|
20_000,
|
||||||
})),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
expect(fetch).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serializes OAuth provider advanced settings", async () => {
|
it("serializes OAuth provider advanced settings", async () => {
|
||||||
await updateProviderSettings("tok", {
|
await updateProviderSettings(mutationTransport, {
|
||||||
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(fetch).toHaveBeenCalledWith(
|
expect(requestMutation).toHaveBeenCalledWith(
|
||||||
"/api/settings/provider/update?provider=xai_grok",
|
"settings.provider.update",
|
||||||
expect.objectContaining({
|
{
|
||||||
headers: {
|
provider: "xai_grok",
|
||||||
Authorization: "Bearer tok",
|
proxy: "http://127.0.0.1:7890",
|
||||||
"X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({
|
extraBody: '{"tools":[]}',
|
||||||
proxy: "http://127.0.0.1:7890",
|
},
|
||||||
extraBody: '{"tools":[]}',
|
20_000,
|
||||||
})),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serializes custom provider creation with advanced settings", async () => {
|
it("serializes custom provider creation with advanced settings", async () => {
|
||||||
await createProviderSettings("tok", {
|
const update = {
|
||||||
name: "Company Gateway",
|
name: "Company Gateway",
|
||||||
apiKey: "sk-company",
|
apiKey: "sk-company",
|
||||||
apiBase: "https://gateway.example/v1",
|
apiBase: "https://gateway.example/v1",
|
||||||
@@ -607,25 +604,13 @@ 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(fetch).toHaveBeenCalledWith(
|
expect(requestMutation).toHaveBeenCalledWith(
|
||||||
"/api/settings/provider/create",
|
"settings.provider.create",
|
||||||
expect.objectContaining({
|
update,
|
||||||
headers: {
|
20_000,
|
||||||
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",
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -641,74 +626,65 @@ describe("webui API helpers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("serializes provider OAuth login and logout actions", async () => {
|
it("serializes provider OAuth login and logout actions", async () => {
|
||||||
await loginProviderOAuth("tok", "openai_codex");
|
await loginProviderOAuth(mutationTransport, "openai_codex");
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||||
"/api/settings/provider/oauth-login?provider=openai_codex",
|
"settings.provider.oauth_login",
|
||||||
expect.objectContaining({
|
{ provider: "openai_codex" },
|
||||||
headers: { Authorization: "Bearer tok" },
|
20_000,
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await loginProviderOAuth("tok", "openai_codex", "", true);
|
await loginProviderOAuth(mutationTransport, "openai_codex", true);
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||||
"/api/settings/provider/oauth-login?provider=openai_codex&remote_browser=true",
|
"settings.provider.oauth_login",
|
||||||
expect.objectContaining({
|
{ provider: "openai_codex", remote_browser: true },
|
||||||
headers: { Authorization: "Bearer tok" },
|
20_000,
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await completeProviderOAuth("tok", "xai_grok", "flow-123");
|
await completeProviderOAuth(mutationTransport, "xai_grok", "flow-123");
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||||
"/api/settings/provider/oauth-login/complete?provider=xai_grok&flow_id=flow-123",
|
"settings.provider.oauth_complete",
|
||||||
expect.objectContaining({
|
{ provider: "xai_grok", flow_id: "flow-123" },
|
||||||
headers: { Authorization: "Bearer tok" },
|
20_000,
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await completeProviderOAuth(
|
await completeProviderOAuth(
|
||||||
"tok",
|
mutationTransport,
|
||||||
"xai_grok",
|
"xai_grok",
|
||||||
"flow-123",
|
"flow-123",
|
||||||
"secret",
|
"secret",
|
||||||
);
|
);
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||||
"/api/settings/provider/oauth-login/complete?provider=xai_grok&flow_id=flow-123",
|
"settings.provider.oauth_complete",
|
||||||
expect.objectContaining({
|
{ provider: "xai_grok", flow_id: "flow-123", authorization_response: "secret" },
|
||||||
headers: {
|
20_000,
|
||||||
Authorization: "Bearer tok",
|
|
||||||
"X-Nanobot-OAuth-Code": "secret",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await completeProviderOAuth(
|
await completeProviderOAuth(
|
||||||
"tok",
|
mutationTransport,
|
||||||
"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(fetch).toHaveBeenCalledWith(
|
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||||
"/api/settings/provider/oauth-login/complete?provider=openai_codex&flow_id=flow-codex",
|
"settings.provider.oauth_complete",
|
||||||
expect.objectContaining({
|
{
|
||||||
headers: {
|
provider: "openai_codex",
|
||||||
Authorization: "Bearer tok",
|
flow_id: "flow-codex",
|
||||||
"X-Nanobot-OAuth-Callback":
|
authorization_response: "http://localhost:1455/auth/callback?code=secret&state=test",
|
||||||
"http://localhost:1455/auth/callback?code=secret&state=test",
|
},
|
||||||
},
|
20_000,
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await logoutProviderOAuth("tok", "openai_codex");
|
await logoutProviderOAuth(mutationTransport, "openai_codex");
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||||
"/api/settings/provider/oauth-logout?provider=openai_codex",
|
"settings.provider.oauth_logout",
|
||||||
expect.objectContaining({
|
{ provider: "openai_codex" },
|
||||||
headers: { Authorization: "Bearer tok" },
|
20_000,
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serializes web search settings updates", async () => {
|
it("serializes web search settings updates", async () => {
|
||||||
await updateWebSearchSettings("tok", {
|
await updateWebSearchSettings(mutationTransport, {
|
||||||
provider: "searxng",
|
provider: "searxng",
|
||||||
baseUrl: "https://search.example.com",
|
baseUrl: "https://search.example.com",
|
||||||
maxResults: 8,
|
maxResults: 8,
|
||||||
@@ -716,30 +692,37 @@ describe("webui API helpers", () => {
|
|||||||
useJinaReader: false,
|
useJinaReader: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(requestMutation).toHaveBeenCalledWith(
|
||||||
"/api/settings/web-search/update?provider=searxng&base_url=https%3A%2F%2Fsearch.example.com&max_results=8&timeout=45&use_jina_reader=false",
|
"settings.web_search.update",
|
||||||
expect.objectContaining({
|
{
|
||||||
headers: { Authorization: "Bearer tok" },
|
provider: "searxng",
|
||||||
}),
|
base_url: "https://search.example.com",
|
||||||
|
max_results: 8,
|
||||||
|
timeout: 45,
|
||||||
|
use_jina_reader: false,
|
||||||
|
},
|
||||||
|
20_000,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serializes network safety settings updates", async () => {
|
it("serializes network safety settings updates", async () => {
|
||||||
await updateNetworkSafetySettings("tok", {
|
await updateNetworkSafetySettings(mutationTransport, {
|
||||||
webuiAllowLocalServiceAccess: false,
|
webuiAllowLocalServiceAccess: false,
|
||||||
webuiDefaultAccessMode: "full",
|
webuiDefaultAccessMode: "full",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(requestMutation).toHaveBeenCalledWith(
|
||||||
"/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=full",
|
"settings.network_safety.update",
|
||||||
expect.objectContaining({
|
{
|
||||||
headers: { Authorization: "Bearer tok" },
|
webui_allow_local_service_access: false,
|
||||||
}),
|
webui_default_access_mode: "full",
|
||||||
|
},
|
||||||
|
20_000,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serializes image generation settings updates", async () => {
|
it("serializes image generation settings updates", async () => {
|
||||||
await updateImageGenerationSettings("tok", {
|
await updateImageGenerationSettings(mutationTransport, {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
provider: "openrouter",
|
provider: "openrouter",
|
||||||
model: "openai/gpt-5.4-image-2",
|
model: "openai/gpt-5.4-image-2",
|
||||||
@@ -748,11 +731,17 @@ describe("webui API helpers", () => {
|
|||||||
maxImagesPerTurn: 3,
|
maxImagesPerTurn: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(requestMutation).toHaveBeenCalledWith(
|
||||||
"/api/settings/image-generation/update?enabled=true&provider=openrouter&model=openai%2Fgpt-5.4-image-2&default_aspect_ratio=16%3A9&default_image_size=2K&max_images_per_turn=3",
|
"settings.image_generation.update",
|
||||||
expect.objectContaining({
|
{
|
||||||
headers: { Authorization: "Bearer tok" },
|
enabled: true,
|
||||||
}),
|
provider: "openrouter",
|
||||||
|
model: "openai/gpt-5.4-image-2",
|
||||||
|
default_aspect_ratio: "16:9",
|
||||||
|
default_image_size: "2K",
|
||||||
|
max_images_per_turn: 3,
|
||||||
|
},
|
||||||
|
20_000,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -774,12 +763,11 @@ describe("webui API helpers", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await runCliAppAction("tok", "install", "gimp");
|
await runCliAppAction(mutationTransport, "install", "gimp");
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(requestMutation).toHaveBeenCalledWith(
|
||||||
"/api/settings/cli-apps/install?name=gimp",
|
"settings.cli_app.install",
|
||||||
expect.objectContaining({
|
{ name: "gimp" },
|
||||||
headers: { Authorization: "Bearer tok" },
|
150_000,
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -819,20 +807,18 @@ describe("webui API helpers", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await enableNanobotFeature("tok", "matrix");
|
await enableNanobotFeature(mutationTransport, "matrix");
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||||
"/api/settings/nanobot-features/enable?name=matrix",
|
"settings.feature.enable",
|
||||||
expect.objectContaining({
|
{ name: "matrix" },
|
||||||
headers: { Authorization: "Bearer tok" },
|
150_000,
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await disableNanobotFeature("tok", "matrix");
|
await disableNanobotFeature(mutationTransport, "matrix");
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||||
"/api/settings/nanobot-features/disable?name=matrix",
|
"settings.feature.disable",
|
||||||
expect.objectContaining({
|
{ name: "matrix" },
|
||||||
headers: { Authorization: "Bearer tok" },
|
20_000,
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -843,34 +829,31 @@ describe("webui API helpers", () => {
|
|||||||
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
||||||
);
|
);
|
||||||
|
|
||||||
await startApiService("tok", { host: "127.0.0.1", port: 8900, timeout: 120 });
|
await startApiService(
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
mutationTransport,
|
||||||
"/api/settings/api-service/start?host=127.0.0.1&port=8900&timeout=120",
|
{ 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(
|
||||||
"tok",
|
mutationTransport,
|
||||||
{ 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(fetch).toHaveBeenCalledWith(
|
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||||
"/api/settings/api-service/start?host=0.0.0.0&port=8900&timeout=120",
|
"settings.api_service.start",
|
||||||
expect.objectContaining({
|
{ host: "0.0.0.0", port: 8900, timeout: 120, api_key: "secret-token" },
|
||||||
headers: {
|
150_000,
|
||||||
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("tok");
|
await stopApiService(mutationTransport);
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||||
"/api/settings/api-service/stop",
|
"settings.api_service.stop",
|
||||||
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
{},
|
||||||
|
20_000,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -891,71 +874,46 @@ describe("webui API helpers", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await runMcpPresetAction("tok", "enable", "browserbase", {
|
await runMcpPresetAction(mutationTransport, "enable", "browserbase", {
|
||||||
browserbase_api_key: "bb_live_test",
|
browserbase_api_key: "bb_live_test",
|
||||||
});
|
});
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(requestMutation).toHaveBeenCalledWith(
|
||||||
"/api/settings/mcp-presets/enable?name=browserbase",
|
"settings.mcp.enable",
|
||||||
expect.objectContaining({
|
{ name: "browserbase", browserbase_api_key: "bb_live_test" },
|
||||||
headers: expect.objectContaining({
|
20_000,
|
||||||
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 () => {
|
||||||
await saveCustomMcpServer("tok", {
|
const custom = {
|
||||||
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"}',
|
||||||
});
|
};
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
await saveCustomMcpServer(mutationTransport, custom);
|
||||||
"/api/settings/mcp-presets/custom",
|
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||||
expect.objectContaining({
|
"settings.mcp.custom",
|
||||||
headers: expect.objectContaining({
|
custom,
|
||||||
Authorization: "Bearer tok",
|
20_000,
|
||||||
"X-Nanobot-MCP-Values": JSON.stringify({
|
|
||||||
name: "docs",
|
|
||||||
transport: "stdio",
|
|
||||||
command: "npx",
|
|
||||||
args: '["-y","docs-mcp"]',
|
|
||||||
env: '{"API_KEY":"secret"}',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await importMcpConfig("tok", '{"mcpServers":{"docs":{"command":"npx"}}}');
|
await importMcpConfig(
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
mutationTransport,
|
||||||
"/api/settings/mcp-presets/import",
|
'{"mcpServers":{"docs":{"command":"npx"}}}',
|
||||||
expect.objectContaining({
|
);
|
||||||
headers: expect.objectContaining({
|
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||||
Authorization: "Bearer tok",
|
"settings.mcp.import",
|
||||||
"X-Nanobot-MCP-Values": JSON.stringify({
|
{ config: '{"mcpServers":{"docs":{"command":"npx"}}}' },
|
||||||
config: '{"mcpServers":{"docs":{"command":"npx"}}}',
|
20_000,
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await updateMcpServerTools("tok", "docs", ["search", "fetch"]);
|
await updateMcpServerTools(mutationTransport, "docs", ["search", "fetch"]);
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(requestMutation).toHaveBeenLastCalledWith(
|
||||||
"/api/settings/mcp-presets/tools",
|
"settings.mcp.tools",
|
||||||
expect.objectContaining({
|
{ name: "docs", enabled_tools: ["search", "fetch"] },
|
||||||
headers: expect.objectContaining({
|
20_000,
|
||||||
Authorization: "Bearer tok",
|
|
||||||
"X-Nanobot-MCP-Values": JSON.stringify({
|
|
||||||
name: "docs",
|
|
||||||
enabled_tools: ["search", "fetch"],
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -991,19 +949,12 @@ describe("webui API helpers", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await updateSidebarState("tok", state);
|
await updateSidebarState(mutationTransport, state);
|
||||||
const [url, init] = vi.mocked(fetch).mock.calls.at(-1)!;
|
expect(requestMutation).toHaveBeenCalledWith(
|
||||||
expect(String(url).startsWith("/api/webui/sidebar-state/update?")).toBe(true);
|
"sidebar.update",
|
||||||
expect(init).toEqual(expect.objectContaining({
|
{ state },
|
||||||
headers: { Authorization: "Bearer tok" },
|
20_000,
|
||||||
}));
|
);
|
||||||
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,6 +19,7 @@ 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();
|
||||||
@@ -242,6 +243,7 @@ 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;
|
||||||
@@ -270,7 +272,8 @@ describe("App layout", () => {
|
|||||||
getSessionAutomationsSpy.mockReset().mockResolvedValue([]);
|
getSessionAutomationsSpy.mockReset().mockResolvedValue([]);
|
||||||
toggleThemeSpy.mockReset();
|
toggleThemeSpy.mockReset();
|
||||||
attachSpy.mockReset();
|
attachSpy.mockReset();
|
||||||
setSidebarStateSpy.mockReset();
|
setSidebarStateSpy.mockReset().mockResolvedValue({});
|
||||||
|
requestMutationSpy.mockReset();
|
||||||
discardTemporaryChatSpy.mockReset();
|
discardTemporaryChatSpy.mockReset();
|
||||||
let temporaryChatCounter = 0;
|
let temporaryChatCounter = 0;
|
||||||
newTemporaryChatSpy.mockImplementation(async () => (
|
newTemporaryChatSpy.mockImplementation(async () => (
|
||||||
@@ -877,40 +880,36 @@ 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": {
|
});
|
||||||
skills: [
|
requestMutationSpy.mockResolvedValueOnce({
|
||||||
{
|
skills: [
|
||||||
name: "cron",
|
{
|
||||||
description: "Schedule reminders.",
|
name: "cron",
|
||||||
source: "builtin",
|
description: "Schedule reminders.",
|
||||||
enabled: true,
|
source: "builtin",
|
||||||
deletable: false,
|
enabled: true,
|
||||||
available: true,
|
deletable: false,
|
||||||
},
|
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",
|
|
||||||
enabled: false,
|
|
||||||
deleted: false,
|
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
|
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", enabled: false, deleted: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
@@ -1010,14 +1009,10 @@ 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": {
|
});
|
||||||
skills: [],
|
requestMutationSpy.mockResolvedValueOnce({
|
||||||
last_action: {
|
skills: [],
|
||||||
name: "custom-skill",
|
last_action: { name: "custom-skill", enabled: false, deleted: true },
|
||||||
enabled: false,
|
|
||||||
deleted: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
@@ -1149,9 +1144,8 @@ 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 />);
|
||||||
|
|
||||||
@@ -1199,11 +1193,14 @@ describe("App layout", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: "Install skill" }));
|
fireEvent.click(screen.getByRole("button", { name: "Install skill" }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(requestMutationSpy).toHaveBeenCalledWith(
|
||||||
"/api/webui/skills/install?provider=skills_sh&source=acme%2Fagent-skills&skill=react-testing",
|
"skill.install",
|
||||||
expect.objectContaining({
|
{
|
||||||
headers: { Authorization: expect.any(String) },
|
provider: "skills_sh",
|
||||||
}),
|
source: "acme/agent-skills",
|
||||||
|
skill: "react-testing",
|
||||||
|
},
|
||||||
|
150_000,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "Installed" }));
|
fireEvent.click(screen.getByRole("tab", { name: "Installed" }));
|
||||||
@@ -1361,14 +1358,12 @@ 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": {
|
});
|
||||||
jobs: [
|
requestMutationSpy.mockResolvedValueOnce({
|
||||||
{
|
jobs: [{
|
||||||
...pastOneShot,
|
...pastOneShot,
|
||||||
payload: { ...pastOneShot.payload, message: "Updated one-shot message" },
|
payload: { ...pastOneShot.payload, message: "Updated one-shot message" },
|
||||||
},
|
}],
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
@@ -1394,20 +1389,18 @@ describe("App layout", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(requestMutationSpy).toHaveBeenCalledWith(
|
||||||
"/api/webui/automations/update?id=past-one-shot",
|
"automation.update",
|
||||||
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 () => {
|
||||||
@@ -1829,6 +1822,9 @@ 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(),
|
||||||
@@ -2581,17 +2577,14 @@ 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(
|
||||||
fetchMock.mock.calls.filter(([input]) =>
|
requestMutationSpy.mock.calls.some(([action]) => action === "settings.agent.update"),
|
||||||
String(input).startsWith("/api/settings/update?timezone="),
|
).toBe(false);
|
||||||
),
|
|
||||||
).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."),
|
||||||
|
|||||||
@@ -71,6 +71,122 @@ 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",
|
||||||
@@ -1071,7 +1187,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 outside the HTTP request line", () => {
|
it("sends large sidebar ordering state as a correlated WebUI request", async () => {
|
||||||
const client = new NanobotClient({
|
const client = new NanobotClient({
|
||||||
url: "ws://test",
|
url: "ws://test",
|
||||||
reconnect: false,
|
reconnect: false,
|
||||||
@@ -1102,11 +1218,29 @@ describe("NanobotClient", () => {
|
|||||||
|
|
||||||
client.connect();
|
client.connect();
|
||||||
lastSocket().fakeOpen();
|
lastSocket().fakeOpen();
|
||||||
client.setSidebarState(state);
|
const pending = 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);
|
||||||
expect(JSON.parse(serialized)).toEqual({ type: "set_sidebar_state", state });
|
const request = JSON.parse(serialized) as {
|
||||||
|
type: string;
|
||||||
|
request_id: string;
|
||||||
|
action: string;
|
||||||
|
payload: { state: SidebarStatePayload };
|
||||||
|
};
|
||||||
|
expect(request).toEqual({
|
||||||
|
type: "webui_request",
|
||||||
|
request_id: expect.any(String),
|
||||||
|
action: "sidebar.update",
|
||||||
|
payload: { state },
|
||||||
|
});
|
||||||
|
lastSocket().fakeMessage({
|
||||||
|
event: "webui_response",
|
||||||
|
request_id: request.request_id,
|
||||||
|
ok: true,
|
||||||
|
result: state,
|
||||||
|
});
|
||||||
|
await expect(pending).resolves.toEqual(state);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not correlate a new-chat scope rejection to an unrelated sent turn", async () => {
|
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
@@ -109,8 +109,9 @@ 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(fakeClient()),
|
wrapper: wrap(client),
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.sessions).toHaveLength(2));
|
await waitFor(() => expect(result.current.sessions).toHaveLength(2));
|
||||||
@@ -119,7 +120,7 @@ describe("useSessions", () => {
|
|||||||
await result.current.deleteChat("websocket:chat-a");
|
await result.current.deleteChat("websocket:chat-a");
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(api.deleteSession).toHaveBeenCalledWith("tok", "websocket:chat-a", undefined);
|
expect(api.deleteSession).toHaveBeenCalledWith(client, "websocket:chat-a", undefined);
|
||||||
expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-b"]);
|
expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-b"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user