From c92345bbb10d2541177eadcac4b3b64c9b0a7c09 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Tue, 12 May 2026 08:17:44 +0000 Subject: [PATCH] fix(webui): sync model badge after preset switch Co-authored-by: Cursor --- nanobot/channels/websocket.py | 3 +++ nanobot/command/builtin.py | 4 ++-- tests/channels/test_websocket_channel.py | 20 +++++++++++++++++++ tests/command/test_model_command.py | 5 ++++- webui/src/App.tsx | 1 + webui/src/components/thread/ThreadShell.tsx | 4 +++- webui/src/hooks/useNanobotStream.ts | 4 ++++ webui/src/lib/types.ts | 2 ++ webui/src/tests/useNanobotStream.test.tsx | 22 +++++++++++++++++++++ 9 files changed, 61 insertions(+), 4 deletions(-) diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index d68bd3521..b419742c6 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -1471,6 +1471,9 @@ class WebSocketChannel(BaseChannel): payload["kind"] = "tool_hint" elif msg.metadata.get("_progress"): payload["kind"] = "progress" + webui_model_name = msg.metadata.get("_webui_model_name") + if isinstance(webui_model_name, str) and webui_model_name.strip(): + payload["model_name"] = webui_model_name.strip() raw = json.dumps(payload, ensure_ascii=False) for connection in conns: await self._safe_send_to(connection, raw, label=" ") diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index 2310be181..5a54dab0a 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -225,7 +225,7 @@ async def cmd_model(ctx: CommandContext) -> OutboundMessage: channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, content=_model_command_status(loop), - metadata=metadata, + metadata={**metadata, "_webui_model_name": loop.model}, ) parts = args.split() @@ -264,7 +264,7 @@ async def cmd_model(ctx: CommandContext) -> OutboundMessage: channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, content="\n".join(lines), - metadata=metadata, + metadata={**metadata, "_webui_model_name": loop.model}, ) diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index de008c36b..933ac8f1a 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -229,6 +229,26 @@ async def test_send_delivers_json_message_with_media_and_reply() -> None: assert payload["buttons"] == [["Yes", "No"]] +@pytest.mark.asyncio +async def test_send_includes_webui_model_name_metadata() -> None: + bus = MagicMock() + channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus) + mock_ws = AsyncMock() + channel._attach(mock_ws, "chat-1") + + await channel.send( + OutboundMessage( + channel="websocket", + chat_id="chat-1", + content="switched", + metadata={"_webui_model_name": "openai/gpt-4.1"}, + ) + ) + + payload = json.loads(mock_ws.send.call_args[0][0]) + assert payload["model_name"] == "openai/gpt-4.1" + + @pytest.mark.asyncio async def test_send_stages_external_media_as_signed_url(monkeypatch, tmp_path) -> None: bus = MagicMock() diff --git a/tests/command/test_model_command.py b/tests/command/test_model_command.py index f81fb0226..d743de9ab 100644 --- a/tests/command/test_model_command.py +++ b/tests/command/test_model_command.py @@ -64,7 +64,8 @@ async def test_model_command_lists_current_and_available_presets(tmp_path) -> No assert "Active preset: `(none)`" in out.content assert "`default`" in out.content assert "`fast`" in out.content - assert out.metadata == {"render_as": "text"} + assert out.metadata["render_as"] == "text" + assert out.metadata["_webui_model_name"] == "base-model" @pytest.mark.asyncio @@ -75,6 +76,7 @@ async def test_model_command_switches_preset(tmp_path) -> None: assert "Switched model preset to `fast`." in out.content assert "Model: `openai/gpt-4.1`" in out.content + assert out.metadata["_webui_model_name"] == "openai/gpt-4.1" assert loop.model_preset == "fast" assert loop.model == "openai/gpt-4.1" assert loop.subagents.model == "openai/gpt-4.1" @@ -90,6 +92,7 @@ async def test_model_command_switches_back_to_default(tmp_path) -> None: out = await cmd_model(_ctx(loop, "/model default", args="default")) assert "Switched model preset to `default`." in out.content + assert out.metadata["_webui_model_name"] == "base-model" assert loop.model_preset == "default" assert loop.model == "base-model" assert loop.context_window_tokens == 1000 diff --git a/webui/src/App.tsx b/webui/src/App.tsx index ce8e838b7..66218cd3e 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -492,6 +492,7 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName: onNewChat={onNewChat} onCreateChat={onCreateChat} onTurnEnd={onTurnEnd} + onModelNameChange={onModelNameChange} theme={theme} onToggleTheme={toggle} hideSidebarToggleOnDesktop={desktopSidebarOpen} diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index 948161072..c1360e52c 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -32,6 +32,7 @@ interface ThreadShellProps { onNewChat?: () => void; onCreateChat?: () => Promise; onTurnEnd?: () => void; + onModelNameChange?: (modelName: string | null) => void; theme?: "light" | "dark"; onToggleTheme?: () => void; hideSidebarToggleOnDesktop?: boolean; @@ -75,6 +76,7 @@ export function ThreadShell({ onToggleSidebar, onCreateChat, onTurnEnd, + onModelNameChange, theme = "light", onToggleTheme = () => {}, hideSidebarToggleOnDesktop = false, @@ -103,7 +105,7 @@ export function ThreadShell({ setMessages, streamError, dismissStreamError, - } = useNanobotStream(chatId, initial, hasPendingToolCalls, onTurnEnd); + } = useNanobotStream(chatId, initial, hasPendingToolCalls, onTurnEnd, onModelNameChange); const showHeroComposer = messages.length === 0 && !loading; const pendingAsk = useMemo(() => { for (let index = messages.length - 1; index >= 0; index -= 1) { diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts index e69676721..dda2b95a7 100644 --- a/webui/src/hooks/useNanobotStream.ts +++ b/webui/src/hooks/useNanobotStream.ts @@ -44,6 +44,7 @@ export function useNanobotStream( initialMessages: UIMessage[] = [], hasPendingToolCalls = false, onTurnEnd?: () => void, + onModelNameChange?: (modelName: string | null) => void, ): { messages: UIMessage[]; isStreaming: boolean; @@ -181,6 +182,9 @@ export function useNanobotStream( } if (ev.event === "message") { + if (ev.model_name !== undefined) { + onModelNameChange?.(ev.model_name || null); + } if ( suppressStreamUntilTurnEndRef.current && (ev.kind === "tool_hint" || ev.kind === "progress") diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index d3489b8de..ceab671cc 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -147,6 +147,8 @@ export type InboundEvent = /** Present when the frame is an agent breadcrumb (e.g. tool hint, * generic progress line) rather than a conversational reply. */ kind?: "tool_hint" | "progress"; + /** Runtime model name after commands like `/model fast` update it. */ + model_name?: string | null; } | { event: "delta"; diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx index a9e92086f..605ad9565 100644 --- a/webui/src/tests/useNanobotStream.test.tsx +++ b/webui/src/tests/useNanobotStream.test.tsx @@ -134,6 +134,28 @@ describe("useNanobotStream", () => { ]); }); + it("reports runtime model name updates from message frames", () => { + const fake = fakeClient(); + const onModelNameChange = vi.fn(); + renderHook( + () => useNanobotStream("chat-model", EMPTY_MESSAGES, false, undefined, onModelNameChange), + { + wrapper: wrap(fake.client), + }, + ); + + act(() => { + fake.emit("chat-model", { + event: "message", + chat_id: "chat-model", + text: "Switched model preset to `fast`.", + model_name: "openai/gpt-4.1", + }); + }); + + expect(onModelNameChange).toHaveBeenCalledWith("openai/gpt-4.1"); + }); + it("suppresses redundant stream confirmation after assistant media", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-img-result", EMPTY_MESSAGES), {