fix(webui): move mutations to authenticated websocket requests

This commit is contained in:
chengyongru
2026-08-10 16:23:47 +08:00
committed by chengyongru
parent 71a99b0780
commit 5d733b1c7c
25 changed files with 2458 additions and 2056 deletions
@@ -15,6 +15,7 @@ import type {
NanobotFeatureInfo,
NanobotFeaturesPayload,
} from "@/lib/types";
import { useClient } from "@/providers/ClientProvider";
import { FeishuConnectFlow } from "./FeishuConnectFlow";
@@ -33,7 +34,6 @@ export function FeishuAssistantsPanel({
return (
<ChannelInstancesPanel
token={token}
feature={feature}
showBrandLogos={showBrandLogos}
chatAppsDocsUrl={chatAppsDocsUrl}
@@ -92,6 +92,7 @@ function FeishuInstanceAction({
instance: NanobotChannelInstanceInfo;
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
}) {
const { client } = useClient();
const { t } = useTranslation();
const tx = channelTranslator(t, "feishu");
const [busy, setBusy] = useState(false);
@@ -114,7 +115,7 @@ function FeishuInstanceAction({
setError(null);
try {
onFeaturesUpdate(
await enableNanobotFeature(token, "feishu", { instanceId: instance.id }),
await enableNanobotFeature(client, "feishu", { instanceId: instance.id }),
);
} catch (err) {
setError((err as Error).message);
+162
View File
@@ -373,6 +373,13 @@ class WebSocketChannel(BaseChannel):
self._conn_default: dict[ServerConnection, str] = {}
# Connections authenticated with a one-time token from /webui/bootstrap.
self._webui_connections: set[ServerConnection] = set()
# Request/reply mutations aren't replayed across reconnects. Tasks may
# finish after a client-side deadline so an already-started mutation
# isn't ambiguously cancelled halfway through.
self._webui_request_tasks: dict[
tuple[ServerConnection, str],
asyncio.Task[None],
] = {}
self._stop_event: asyncio.Event | None = None
self._server_task: asyncio.Task[None] | None = None
@@ -758,6 +765,9 @@ class WebSocketChannel(BaseChannel):
) -> None:
"""Route one typed inbound envelope (``new_chat`` / ``attach`` / ``message``)."""
t = envelope.get("type")
if t == "webui_request":
await self._start_webui_request(connection, envelope)
return
if t == "new_chat":
new_id = str(uuid.uuid4())
scope = await self._workspace_scope_or_error(
@@ -1105,6 +1115,152 @@ class WebSocketChannel(BaseChannel):
return
await self._send_event(connection, "error", detail=f"unknown type: {t!r}")
async def _start_webui_request(
self,
connection: ServerConnection,
envelope: dict[str, Any],
) -> None:
request_id = envelope.get("request_id")
if not isinstance(request_id, str) or re.fullmatch(
r"[A-Za-z0-9._:-]{1,128}",
request_id,
) is None:
await self._send_event(
connection,
"error",
detail="invalid webui request_id",
)
return
if connection not in self._webui_connections:
await self._send_webui_response(
connection,
request_id,
status=403,
message="access_denied",
)
return
action = envelope.get("action")
payload = envelope.get("payload")
if not isinstance(action, str) or re.fullmatch(
r"[a-z][a-z0-9_.]{0,127}",
action,
) is None:
await self._send_webui_response(
connection,
request_id,
status=400,
message="invalid WebUI mutation action",
)
return
if not isinstance(payload, dict):
await self._send_webui_response(
connection,
request_id,
status=400,
message="WebUI mutation payload must be an object",
)
return
key = (connection, request_id)
if key in self._webui_request_tasks:
await self._send_webui_response(
connection,
request_id,
status=409,
message="duplicate WebUI request_id",
)
return
task = asyncio.create_task(
self._complete_webui_request(
connection,
request_id,
action,
cast(dict[str, Any], payload),
)
)
self._webui_request_tasks[key] = task
async def _complete_webui_request(
self,
connection: ServerConnection,
request_id: str,
action: str,
payload: dict[str, Any],
) -> None:
try:
response = await self._http_router.dispatch_webui_mutation(
connection,
action,
payload,
)
status = response.status_code
body = bytes(response.body).decode("utf-8", errors="replace").strip()
if 200 <= status < 300:
try:
result = json.loads(body)
except json.JSONDecodeError:
await self._send_webui_response(
connection,
request_id,
status=502,
message="WebUI mutation returned an invalid response",
)
return
await self._send_webui_response(
connection,
request_id,
result=result,
)
return
await self._send_webui_response(
connection,
request_id,
status=status,
message=body or response.reason_phrase,
)
except asyncio.CancelledError:
raise
except Exception:
self.logger.exception("WebUI mutation '{}' failed", action)
await self._send_webui_response(
connection,
request_id,
status=500,
message="WebUI mutation failed",
)
finally:
self._webui_request_tasks.pop((connection, request_id), None)
async def _send_webui_response(
self,
connection: ServerConnection,
request_id: str,
*,
result: Any = None,
status: int | None = None,
message: str | None = None,
) -> None:
if status is None:
await self._send_event(
connection,
"webui_response",
request_id=request_id,
ok=True,
result=result,
)
return
await self._send_event(
connection,
"webui_response",
request_id=request_id,
ok=False,
error={
"status": status,
"message": message or "WebUI mutation failed",
},
)
async def _workspace_scope_or_error(
self,
connection: ServerConnection,
@@ -1145,6 +1301,12 @@ class WebSocketChannel(BaseChannel):
except Exception as e:
self.logger.warning("server task error during shutdown: {}", e)
self._server_task = None
mutation_tasks = tuple(self._webui_request_tasks.values())
for task in mutation_tasks:
task.cancel()
if mutation_tasks:
await asyncio.gather(*mutation_tasks, return_exceptions=True)
self._webui_request_tasks.clear()
self._subs.clear()
self._conn_chats.clear()
self._conn_default.clear()
@@ -3,12 +3,16 @@
import asyncio
import json
import time
import uuid
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
import websockets
from websockets.datastructures import Headers
from websockets.exceptions import ConnectionClosed
from websockets.frames import Close
@@ -42,6 +46,12 @@ from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
from nanobot.session import webui_turns as wth
from nanobot.session.manager import SessionManager
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
from nanobot.webui.http_utils import (
http_error as _http_error,
)
from nanobot.webui.http_utils import (
http_json_response as _http_json_response,
)
from nanobot.webui.http_utils import (
issue_route_secret_matches as _issue_route_secret_matches,
)
@@ -119,6 +129,38 @@ def _basic_handler(bus: Any, **kw: Any) -> GatewayServices:
)
async def _webui_mutate(
client: Any,
action: str,
payload: dict[str, Any] | None = None,
) -> httpx.Response:
request_id = f"test-{uuid.uuid4().hex}"
await client.send(json.dumps({
"type": "webui_request",
"request_id": request_id,
"action": action,
"payload": payload or {},
}))
while True:
envelope = json.loads(await asyncio.wait_for(client.recv(), timeout=5))
if envelope.get("event") != "webui_response":
continue
if envelope.get("request_id") != request_id:
continue
if envelope.get("ok") is True:
status = 200
body = envelope.get("result")
else:
error = envelope.get("error") or {}
status = int(error.get("status") or 500)
body = {"error": str(error.get("message") or "WebUI mutation failed")}
return httpx.Response(
status,
json=body,
request=httpx.Request("WS", "http://nanobot.local/webui-mutation"),
)
@pytest.mark.asyncio
async def test_stop_treats_cancelled_server_task_as_shutdown() -> None:
channel = _ch(MessageBus())
@@ -857,6 +899,98 @@ def test_only_bootstrap_tokens_mark_webui_connections(bus: MagicMock) -> None:
assert client_connection not in channel._webui_connections
@pytest.mark.asyncio
async def test_authenticated_webui_request_returns_correlated_success(bus: MagicMock) -> None:
channel = _ch(bus)
conn = AsyncMock()
channel._webui_connections.add(conn)
channel.gateway.http.dispatch_webui_mutation = AsyncMock(
return_value=_http_json_response({"saved": True})
)
await channel._dispatch_envelope(
conn,
"webui-client",
{
"type": "webui_request",
"request_id": "request-1",
"action": "settings.provider.update",
"payload": {"provider": "openrouter", "apiKey": "secret"},
},
)
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
channel.gateway.http.dispatch_webui_mutation.assert_awaited_once_with(
conn,
"settings.provider.update",
{"provider": "openrouter", "apiKey": "secret"},
)
assert json.loads(conn.send.await_args.args[0]) == {
"event": "webui_response",
"request_id": "request-1",
"ok": True,
"result": {"saved": True},
}
@pytest.mark.asyncio
async def test_webui_request_returns_correlated_route_error(bus: MagicMock) -> None:
channel = _ch(bus)
conn = AsyncMock()
channel._webui_connections.add(conn)
channel.gateway.http.dispatch_webui_mutation = AsyncMock(
return_value=_http_error(400, "invalid settings payload")
)
await channel._dispatch_envelope(
conn,
"webui-client",
{
"type": "webui_request",
"request_id": "request-2",
"action": "settings.agent.update",
"payload": {},
},
)
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
assert json.loads(conn.send.await_args.args[0]) == {
"event": "webui_response",
"request_id": "request-2",
"ok": False,
"error": {"status": 400, "message": "invalid settings payload"},
}
@pytest.mark.asyncio
async def test_webui_request_requires_bootstrap_authenticated_connection(
bus: MagicMock,
) -> None:
channel = _ch(bus)
conn = AsyncMock()
channel.gateway.http.dispatch_webui_mutation = AsyncMock()
await channel._dispatch_envelope(
conn,
"static-token-client",
{
"type": "webui_request",
"request_id": "request-3",
"action": "settings.agent.update",
"payload": {},
},
)
channel.gateway.http.dispatch_webui_mutation.assert_not_awaited()
assert json.loads(conn.send.await_args.args[0]) == {
"event": "webui_response",
"request_id": "request-3",
"ok": False,
"error": {"status": 403, "message": "access_denied"},
}
@pytest.mark.asyncio
async def test_webui_persists_sidebar_state_larger_than_http_request_line(
bus: MagicMock,
@@ -866,23 +1000,33 @@ async def test_webui_persists_sidebar_state_larger_than_http_request_line(
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
channel = _ch(bus)
conn = AsyncMock()
conn.request = SimpleNamespace(headers=Headers())
channel._webui_connections.add(conn)
session_order = [f"websocket:{index:04d}-{'x' * 48}" for index in range(160)]
request_id = "sidebar-large-state"
envelope = {
"type": "set_sidebar_state",
"state": {
"type": "webui_request",
"request_id": request_id,
"action": "sidebar.update",
"payload": {"state": {
"session_order": session_order,
"view": {"sort": "manual"},
},
}},
}
assert len(json.dumps(envelope).encode()) > 8_192
await channel._dispatch_envelope(conn, "webui-client", envelope)
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
saved = json.loads((tmp_path / "webui" / "sidebar-state.json").read_text(encoding="utf-8"))
assert saved["session_order"] == session_order
assert saved["view"]["sort"] == "manual"
conn.send.assert_not_awaited()
assert json.loads(conn.send.await_args.args[0]) == {
"event": "webui_response",
"request_id": request_id,
"ok": True,
"result": saved,
}
@pytest.mark.asyncio
@@ -2887,7 +3031,15 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
webui_client = None
try:
webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
webui_client = await websockets.connect(
f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=settings-test"
)
ready = json.loads(await asyncio.wait_for(webui_client.recv(), timeout=5))
assert ready["event"] == "ready"
settings = await _http_get(
f"http://127.0.0.1:{port}/api/settings",
headers={"Authorization": "Bearer tok"},
@@ -2971,11 +3123,14 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert unknown_api.status_code == 404
assert "<!doctype html>" not in unknown_api.text.lower()
provider_updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/provider/update?provider=openrouter"
"&api_key=sk-or-test&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1",
headers={"Authorization": "Bearer tok"},
provider_updated = await _webui_mutate(
webui_client,
"settings.provider.update",
{
"provider": "openrouter",
"apiKey": "sk-or-test",
"apiBase": "https://openrouter.ai/api/v1",
},
)
assert provider_updated.status_code == 200
provider_body = provider_updated.json()
@@ -2985,22 +3140,18 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert provider_body["image_generation"]["provider_configured"] is True
assert "sk-or-test" not in provider_updated.text
custom_provider_created = await _http_get(
f"http://127.0.0.1:{port}/api/settings/provider/create",
headers={
"Authorization": "Bearer tok",
"X-Nanobot-Provider-Values": json.dumps(
{
"name": "Company Gateway",
"apiBase": "https://gateway.example/v1",
"apiKey": "sk-company",
"extraHeaders": json.dumps({"X-Tenant": "engineering"}),
"extraBody": json.dumps({"service_tier": "priority"}),
"extraQuery": json.dumps({"api-version": "2026-01-01"}),
"proxy": "http://127.0.0.1:7890",
"thinkingStyle": "enable_thinking",
}
),
custom_provider_created = await _webui_mutate(
webui_client,
"settings.provider.create",
{
"name": "Company Gateway",
"apiBase": "https://gateway.example/v1",
"apiKey": "sk-company",
"extraHeaders": json.dumps({"X-Tenant": "engineering"}),
"extraBody": json.dumps({"service_tier": "priority"}),
"extraQuery": json.dumps({"api-version": "2026-01-01"}),
"proxy": "http://127.0.0.1:7890",
"thinkingStyle": "enable_thinking",
},
)
assert custom_provider_created.status_code == 200
@@ -3015,11 +3166,10 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
}
assert "sk-company" not in custom_provider_created.text
local_provider_updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/provider/update?provider=atomic_chat"
"&api_base=http%3A%2F%2Flocalhost%3A1337%2Fv1",
headers={"Authorization": "Bearer tok"},
local_provider_updated = await _webui_mutate(
webui_client,
"settings.provider.update",
{"provider": "atomic_chat", "apiBase": "http://localhost:1337/v1"},
)
assert local_provider_updated.status_code == 200
local_provider_body = local_provider_updated.json()
@@ -3029,38 +3179,44 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert local_provider_rows["atomic_chat"]["configured"] is True
assert "localhost:1337" in local_provider_updated.text
updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/update?model=atomic_chat/test"
"&provider=atomic_chat&timezone=Asia%2FShanghai"
"&bot_name=Nano&bot_icon=N&tool_hint_max_length=120",
headers={"Authorization": "Bearer tok"},
updated = await _webui_mutate(
webui_client,
"settings.agent.update",
{
"model": "atomic_chat/test",
"provider": "atomic_chat",
"timezone": "Asia/Shanghai",
"tool_hint_max_length": 120,
},
)
assert updated.status_code == 200
updated_body = updated.json()
assert updated_body["requires_restart"] is True
assert updated_body["restart_required_sections"] == ["runtime"]
preset_updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/update?model_preset=deep",
headers={"Authorization": "Bearer tok"},
preset_updated = await _webui_mutate(
webui_client,
"settings.agent.update",
{"model_preset": "deep"},
)
assert preset_updated.status_code == 200
assert preset_updated.json()["agent"]["model"] == "anthropic/claude-opus-4-5"
bad_preset = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/update?model_preset=missing",
headers={"Authorization": "Bearer tok"},
bad_preset = await _webui_mutate(
webui_client,
"settings.agent.update",
{"model_preset": "missing"},
)
assert bad_preset.status_code == 400
created_preset = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/model-configurations/create"
"?label=Fast%20writing&provider=openai&model=openai%2Fgpt-4.1-mini",
headers={"Authorization": "Bearer tok"},
created_preset = await _webui_mutate(
webui_client,
"settings.model_configuration.create",
{
"label": "Fast writing",
"provider": "openai",
"model": "openai/gpt-4.1-mini",
},
)
assert created_preset.status_code == 200
created_body = created_preset.json()
@@ -3074,11 +3230,15 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert created_presets["fast-writing"]["label"] == "Fast writing"
assert created_presets["fast-writing"]["provider"] == "openai"
updated_preset = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/model-configurations/update"
"?name=fast-writing&label=Codex&provider=openai&model=openai%2Fgpt-5.5",
headers={"Authorization": "Bearer tok"},
updated_preset = await _webui_mutate(
webui_client,
"settings.model_configuration.update",
{
"name": "fast-writing",
"label": "Codex",
"provider": "openai",
"model": "openai/gpt-5.5",
},
)
assert updated_preset.status_code == 200
updated_preset_body = updated_preset.json()
@@ -3089,11 +3249,10 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
}
assert updated_presets["fast-writing"]["label"] == "Codex"
call_order_updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/model-call-order/update"
"?order=%5B%22fast-writing%22%2C%22deep%22%5D",
headers={"Authorization": "Bearer tok"},
call_order_updated = await _webui_mutate(
webui_client,
"settings.model_call_order.update",
{"order": ["fast-writing", "deep"]},
)
assert call_order_updated.status_code == 200
call_order_body = call_order_updated.json()
@@ -3101,20 +3260,27 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert call_order_body["agent"]["model"] == "openai/gpt-5.5"
assert call_order_body["model_call_order"] == ["fast-writing", "deep"]
duplicate_preset = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/model-configurations/create"
"?label=Fast%20writing&provider=openai&model=openai%2Fgpt-4.1-mini",
headers={"Authorization": "Bearer tok"},
duplicate_preset = await _webui_mutate(
webui_client,
"settings.model_configuration.create",
{
"label": "Fast writing",
"provider": "openai",
"model": "openai/gpt-4.1-mini",
},
)
assert duplicate_preset.status_code == 409
search_updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/web-search/update?provider=searxng"
"&base_url=https%3A%2F%2Fsearch.example.com"
"&max_results=8&timeout=45&use_jina_reader=false",
headers={"Authorization": "Bearer tok"},
search_updated = await _webui_mutate(
webui_client,
"settings.web_search.update",
{
"provider": "searxng",
"base_url": "https://search.example.com",
"max_results": 8,
"timeout": 45,
"use_jina_reader": False,
},
)
assert search_updated.status_code == 200
search_body = search_updated.json()
@@ -3126,10 +3292,13 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert search_body["web_search"]["max_results"] == 8
assert search_body["web"]["fetch"]["use_jina_reader"] is False
network_safety_updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=full",
headers={"Authorization": "Bearer tok"},
network_safety_updated = await _webui_mutate(
webui_client,
"settings.network_safety.update",
{
"webui_allow_local_service_access": False,
"webui_default_access_mode": "full",
},
)
assert network_safety_updated.status_code == 200
network_safety_body = network_safety_updated.json()
@@ -3139,13 +3308,17 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert network_safety_body["advanced"]["webui_default_access_mode"] == "full"
assert network_safety_body["advanced"]["private_service_protection_enabled"] is True
image_updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/image-generation/update?enabled=true"
"&provider=openrouter&model=openai%2Fgpt-image-1"
"&default_aspect_ratio=16%3A9&default_image_size=2K"
"&max_images_per_turn=3",
headers={"Authorization": "Bearer tok"},
image_updated = await _webui_mutate(
webui_client,
"settings.image_generation.update",
{
"enabled": True,
"provider": "openrouter",
"model": "openai/gpt-image-1",
"default_aspect_ratio": "16:9",
"default_image_size": "2K",
"max_images_per_turn": 3,
},
)
assert image_updated.status_code == 200
image_body = image_updated.json()
@@ -3157,11 +3330,14 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert image_body["image_generation"]["default_image_size"] == "2K"
assert image_body["image_generation"]["max_images_per_turn"] == 3
image_provider_updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/provider/update?provider=openrouter"
"&api_key=sk-or-next&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1",
headers={"Authorization": "Bearer tok"},
image_provider_updated = await _webui_mutate(
webui_client,
"settings.provider.update",
{
"provider": "openrouter",
"apiKey": "sk-or-next",
"apiBase": "https://openrouter.ai/api/v1",
},
)
assert image_provider_updated.status_code == 200
assert image_provider_updated.json()["requires_restart"] is True
@@ -3169,17 +3345,17 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert "sk-or-next" not in image_provider_updated.text
assert image_reload.await_count == 2
bad_web = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/web-search/update?provider=duckduckgo&max_results=99",
headers={"Authorization": "Bearer tok"},
bad_web = await _webui_mutate(
webui_client,
"settings.web_search.update",
{"provider": "duckduckgo", "max_results": 99},
)
assert bad_web.status_code == 400
bad_image = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/image-generation/update?provider=missing",
headers={"Authorization": "Bearer tok"},
bad_image = await _webui_mutate(
webui_client,
"settings.image_generation.update",
{"provider": "missing"},
)
assert bad_image.status_code == 400
@@ -3216,6 +3392,8 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert saved.tools.image_generation.default_image_size == "2K"
assert saved.tools.image_generation.max_images_per_turn == 3
finally:
if webui_client is not None:
await webui_client.close()
await channel.stop()
await server_task
@@ -3248,11 +3426,17 @@ async def test_image_settings_hot_reload_without_restart(
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
webui_client = None
try:
response = await _http_get(
f"http://127.0.0.1:{port}/api/settings/image-generation/update"
"?enabled=true&provider=openrouter&model=openai%2Fgpt-image-1",
headers={"Authorization": "Bearer tok"},
webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
webui_client = await websockets.connect(
f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=image-reload-test"
)
assert json.loads(await webui_client.recv())["event"] == "ready"
response = await _webui_mutate(
webui_client,
"settings.image_generation.update",
{"enabled": True, "provider": "openrouter", "model": "openai/gpt-image-1"},
)
assert response.status_code == 200
@@ -3260,6 +3444,8 @@ async def test_image_settings_hot_reload_without_restart(
assert response.json()["restart_required_sections"] == []
image_reload.assert_awaited_once_with(bus)
finally:
if webui_client is not None:
await webui_client.close()
await channel.stop()
await server_task
@@ -3291,17 +3477,25 @@ async def test_image_settings_fall_back_to_restart_when_hot_reload_fails(
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
webui_client = None
try:
response = await _http_get(
f"http://127.0.0.1:{port}/api/settings/image-generation/update"
"?enabled=true&provider=openrouter&model=openai%2Fgpt-image-1",
headers={"Authorization": "Bearer tok"},
webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
webui_client = await websockets.connect(
f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=image-fallback-test"
)
assert json.loads(await webui_client.recv())["event"] == "ready"
response = await _webui_mutate(
webui_client,
"settings.image_generation.update",
{"enabled": True, "provider": "openrouter", "model": "openai/gpt-image-1"},
)
assert response.status_code == 200
assert response.json()["requires_restart"] is True
assert response.json()["restart_required_sections"] == ["image"]
finally:
if webui_client is not None:
await webui_client.close()
await channel.stop()
await server_task
File diff suppressed because it is too large Load Diff
@@ -27,6 +27,7 @@ import type {
NanobotFeatureInfo,
} from "@/lib/types";
import { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider";
import {
WEIXIN_AUTH_EXPIRED_MESSAGE,
@@ -64,6 +65,7 @@ export function WeixinPanel({
onAction,
onFeaturesUpdate,
}: ChannelPluginPanelProps) {
const { client } = useClient();
const { t, i18n } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const channelTx = channelTranslator(t, "weixin");
@@ -150,7 +152,7 @@ export function WeixinPanel({
setSaveState("idle");
try {
const payload = await configureChannel(
context.token,
client,
"weixin",
channelValuesForSave(editableFieldsRef.current, values),
{ enable: context.enabled },
@@ -168,7 +170,7 @@ export function WeixinPanel({
} finally {
setSaving(false);
}
}, []);
}, [client]);
useEffect(() => {
if (