fix(webui): broadcast runtime model updates

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Xubin Ren
2026-05-12 20:06:22 +08:00
committed by Xubin Ren
co-authored by Cursor
parent c92345bbb1
commit bcc4b97183
16 changed files with 152 additions and 51 deletions
+6 -1
View File
@@ -355,6 +355,12 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
client.sendMessage(chatId, "/restart");
}, [activeSession?.chatId, client]);
useEffect(() => {
return client.onRuntimeModelUpdate((modelName) => {
onModelNameChange(modelName);
});
}, [client, onModelNameChange]);
useEffect(() => {
return client.onStatus((status) => {
let startedAt = 0;
@@ -492,7 +498,6 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
onNewChat={onNewChat}
onCreateChat={onCreateChat}
onTurnEnd={onTurnEnd}
onModelNameChange={onModelNameChange}
theme={theme}
onToggleTheme={toggle}
hideSidebarToggleOnDesktop={desktopSidebarOpen}
+1 -3
View File
@@ -32,7 +32,6 @@ interface ThreadShellProps {
onNewChat?: () => void;
onCreateChat?: () => Promise<string | null>;
onTurnEnd?: () => void;
onModelNameChange?: (modelName: string | null) => void;
theme?: "light" | "dark";
onToggleTheme?: () => void;
hideSidebarToggleOnDesktop?: boolean;
@@ -76,7 +75,6 @@ export function ThreadShell({
onToggleSidebar,
onCreateChat,
onTurnEnd,
onModelNameChange,
theme = "light",
onToggleTheme = () => {},
hideSidebarToggleOnDesktop = false,
@@ -105,7 +103,7 @@ export function ThreadShell({
setMessages,
streamError,
dismissStreamError,
} = useNanobotStream(chatId, initial, hasPendingToolCalls, onTurnEnd, onModelNameChange);
} = useNanobotStream(chatId, initial, hasPendingToolCalls, onTurnEnd);
const showHeroComposer = messages.length === 0 && !loading;
const pendingAsk = useMemo(() => {
for (let index = messages.length - 1; index >= 0; index -= 1) {
-4
View File
@@ -44,7 +44,6 @@ export function useNanobotStream(
initialMessages: UIMessage[] = [],
hasPendingToolCalls = false,
onTurnEnd?: () => void,
onModelNameChange?: (modelName: string | null) => void,
): {
messages: UIMessage[];
isStreaming: boolean;
@@ -182,9 +181,6 @@ 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")
+20
View File
@@ -14,6 +14,7 @@ const WS_CLOSING = 2;
type Unsubscribe = () => void;
type EventHandler = (ev: InboundEvent) => void;
type StatusHandler = (status: ConnectionStatus) => void;
type RuntimeModelHandler = (modelName: string | null, modelPreset?: string | null) => void;
/** Structured connection-level errors surfaced to the UI.
*
@@ -58,6 +59,7 @@ export interface NanobotClientOptions {
export class NanobotClient {
private socket: WebSocket | null = null;
private statusHandlers = new Set<StatusHandler>();
private runtimeModelHandlers = new Set<RuntimeModelHandler>();
private errorHandlers = new Set<ErrorHandler>();
// chat_id -> handlers listening on it
private chatHandlers = new Map<string, Set<EventHandler>>();
@@ -107,6 +109,13 @@ export class NanobotClient {
};
}
onRuntimeModelUpdate(handler: RuntimeModelHandler): Unsubscribe {
this.runtimeModelHandlers.add(handler);
return () => {
this.runtimeModelHandlers.delete(handler);
};
}
/** Subscribe to transport-level faults (see :type:`StreamError`). */
onError(handler: ErrorHandler): Unsubscribe {
this.errorHandlers.add(handler);
@@ -245,10 +254,21 @@ export class NanobotClient {
return;
}
if (parsed.event === "runtime_model_updated") {
this.emitRuntimeModelUpdate(parsed.model_name || null, parsed.model_preset ?? null);
return;
}
const chatId = (parsed as { chat_id?: string }).chat_id;
if (chatId) this.dispatch(chatId, parsed);
}
private emitRuntimeModelUpdate(modelName: string | null, modelPreset?: string | null): void {
for (const handler of this.runtimeModelHandlers) {
handler(modelName, modelPreset);
}
}
private dispatch(chatId: string, ev: InboundEvent): void {
const handlers = this.chatHandlers.get(chatId);
if (!handlers) return;
+5 -2
View File
@@ -147,8 +147,6 @@ 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";
@@ -161,6 +159,11 @@ export type InboundEvent =
chat_id: string;
stream_id?: string;
}
| {
event: "runtime_model_updated";
model_name: string;
model_preset?: string | null;
}
| { event: "turn_end"; chat_id: string }
| { event: "session_updated"; chat_id: string }
| { event: "error"; chat_id?: string; detail?: string };
+1
View File
@@ -57,6 +57,7 @@ vi.mock("@/lib/nanobot-client", () => {
defaultChatId: string | null = null;
connect = connectSpy;
onStatus = () => () => {};
onRuntimeModelUpdate = () => () => {};
onError = () => () => {};
onChat = () => () => {};
sendMessage = vi.fn();
+20
View File
@@ -89,6 +89,26 @@ describe("NanobotClient", () => {
});
});
it("dispatches runtime model updates globally", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const handler = vi.fn();
client.onRuntimeModelUpdate(handler);
client.connect();
lastSocket().fakeOpen();
lastSocket().fakeMessage({
event: "runtime_model_updated",
model_name: "openai/gpt-4.1",
model_preset: "fast",
});
expect(handler).toHaveBeenCalledWith("openai/gpt-4.1", "fast");
});
it("resolves newChat() via the server-assigned chat_id", async () => {
const client = new NanobotClient({
url: "ws://test",
+1
View File
@@ -12,6 +12,7 @@ function makeClient() {
status: "open" as const,
defaultChatId: null as string | null,
onStatus: () => () => {},
onRuntimeModelUpdate: () => () => {},
onChat: (chatId: string, handler: (ev: import("@/lib/types").InboundEvent) => void) => {
let handlers = chatHandlers.get(chatId);
if (!handlers) {
-22
View File
@@ -134,28 +134,6 @@ 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), {