feat(webui): switch model presets from the composer (#5077)

This commit is contained in:
chengyongru
2026-07-24 17:34:26 +08:00
committed by GitHub
parent 9aab94c766
commit 5be176a6a0
16 changed files with 991 additions and 138 deletions
+3
View File
@@ -23,6 +23,7 @@ describe("ChatList", () => {
session({
chatId: "older",
title: "Older chat",
preview: "/model fast",
updatedAt: "2026-05-21T10:00:00Z",
}),
session({
@@ -46,6 +47,7 @@ describe("ChatList", () => {
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
showPreviews
/>,
);
@@ -54,6 +56,7 @@ describe("ChatList", () => {
expect(text.indexOf("Newest chat")).toBeLessThan(text.indexOf("Middle chat"));
expect(text.indexOf("Middle chat")).toBeLessThan(text.indexOf("Older chat"));
expect(screen.queryByText("/model fast")).not.toBeInTheDocument();
});
it("shows a pin indicator for pinned chats", () => {
+45
View File
@@ -552,6 +552,51 @@ describe("NanobotClient", () => {
});
});
it("handles the silent system-command lifecycle without hiding concurrent events", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const chatHandler = vi.fn();
client.onChat("chat-x", chatHandler);
client.connect();
lastSocket().fakeOpen();
const pending = client.sendSystemCommand("chat-x", " /model fast ", 1_000);
const frame = JSON.parse(lastSocket().sent.at(-1) as string);
expect(frame).toMatchObject({
type: "message",
chat_id: "chat-x",
content: "/model fast",
webui: true,
});
expect(frame.turn_id).toMatch(/^webui-system:/);
lastSocket().fakeMessage({
event: "message",
chat_id: "chat-x",
text: "normal reply",
turn_id: "normal-turn",
});
lastSocket().fakeMessage({
event: "message",
chat_id: "chat-x",
text: "Switched model preset to fast.",
turn_id: frame.turn_id,
});
await expect(pending).resolves.toBeUndefined();
expect(chatHandler).toHaveBeenCalledTimes(1);
expect(chatHandler).toHaveBeenCalledWith(expect.objectContaining({
text: "normal reply",
turn_id: "normal-turn",
}));
const interrupted = client.sendSystemCommand("chat-x", "/model fast", 1_000);
lastSocket().close();
await expect(interrupted).rejects.toThrow("socket closed");
});
it("sends selected assistant text as separate quoted context", () => {
const client = new NanobotClient({
url: "ws://test",
@@ -25,7 +25,7 @@ describe("SessionSearchDialog", () => {
render(
<SessionSearchDialog
open
sessions={[session(1)]}
sessions={[{ ...session(1), title: "Model chat", preview: "/model fast" }]}
activeKey={null}
loading={false}
onOpenChange={() => {}}
@@ -38,6 +38,11 @@ describe("SessionSearchDialog", () => {
expect(dialog.className).not.toContain("bg-popover/");
expect(dialog.className).not.toContain("backdrop-blur");
expect(screen.getByTestId("session-search-scroll")).toHaveClass("overflow-y-auto");
expect(screen.queryByText("/model fast")).not.toBeInTheDocument();
fireEvent.change(screen.getByRole("textbox", { name: "Search" }), {
target: { value: "model fast" },
});
expect(screen.queryByText("Model chat")).not.toBeInTheDocument();
});
it("keeps keyboard navigation scrollable through long result lists", () => {
+126
View File
@@ -164,6 +164,7 @@ function stubVisualViewport({
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.useRealTimers();
Reflect.deleteProperty(window, "nanobotHost");
if (ORIGINAL_MEDIA_DEVICES) {
Object.defineProperty(navigator, "mediaDevices", {
@@ -291,6 +292,49 @@ function ascii(bytes: Uint8Array, offset: number, length: number): string {
return String.fromCharCode(...bytes.slice(offset, offset + length));
}
const MODEL_PRESETS = [
{ name: "kimi", label: "Kimi", provider: "moonshot" },
{ name: "dflash", label: "DFlash", provider: "deepseek" },
{ name: "dspro", label: "DS Pro", provider: "deepseek" },
];
function renderPresetComposer(variant: "thread" | "hero" = "thread") {
const onPresetChange = vi.fn();
render(
<ThreadComposer
onSend={vi.fn()}
modelLabel="Kimi"
modelPreset="kimi"
modelProvider="moonshot"
modelPresets={MODEL_PRESETS}
onModelPresetChange={onPresetChange}
placeholder={variant === "hero" ? "Ask anything..." : "Type your message..."}
variant={variant}
/>,
);
return {
badge: screen.getByRole("spinbutton", { name: "Kimi" }),
onPresetChange,
};
}
function pointerDown(badge: HTMLElement, pointerId = 7, clientY = 100, button = 0) {
fireEvent.pointerDown(badge, {
button,
clientY,
isPrimary: true,
pointerId,
pointerType: "mouse",
});
}
function longPress(badge: HTMLElement, pointerId = 7) {
pointerDown(badge, pointerId);
act(() => {
vi.advanceTimersByTime(400);
});
}
describe("ThreadComposer", () => {
it("focuses and sends a removable quoted answer excerpt", async () => {
const onSend = vi.fn();
@@ -386,6 +430,88 @@ describe("ThreadComposer", () => {
expect(screen.queryByText(/Enter to send/)).not.toBeInTheDocument();
});
it("scrolls complete preset pills after a left-button long press and wraps", () => {
vi.useFakeTimers();
const { badge, onPresetChange } = renderPresetComposer();
expect(badge).toHaveClass("h-9");
expect(badge).toHaveStyle({ touchAction: "manipulation" });
const idleTouchMove = new Event("touchmove", {
bubbles: true,
cancelable: true,
});
badge.dispatchEvent(idleTouchMove);
expect(idleTouchMove.defaultPrevented).toBe(false);
fireEvent.click(badge);
pointerDown(badge);
fireEvent.pointerMove(badge, { clientY: 80, pointerId: 7, pointerType: "mouse" });
act(() => vi.advanceTimersByTime(500));
fireEvent.pointerUp(badge, { clientY: 80, pointerId: 7, pointerType: "mouse" });
expect(onPresetChange).not.toHaveBeenCalled();
longPress(badge);
expect(badge).toHaveAttribute("data-switching", "true");
const viewport = screen.getByTestId("composer-model-pill-viewport");
expect(viewport).toHaveClass("overflow-hidden", "-left-2", "-top-3", "-bottom-3");
const track = screen.getByTestId("composer-model-pill-track");
expect(track).toHaveClass("items-end", "gap-1");
const activeTouchMove = new Event("touchmove", {
bubbles: true,
cancelable: true,
});
badge.dispatchEvent(activeTouchMove);
expect(activeTouchMove.defaultPrevented).toBe(true);
const pills = track.querySelectorAll<HTMLElement>(".composer-model-pill");
expect(pills).toHaveLength(5);
expect(Array.from(pills).every((pill) => pill.classList.contains("w-fit"))).toBe(true);
expect(Array.from(pills).every((pill) => pill.querySelector("img"))).toBe(true);
expect(Array.from(badge.querySelectorAll("img")).every((image) => !image.draggable)).toBe(true);
const centeredPill = track.querySelector<HTMLElement>("[data-preset-offset='0']");
expect(centeredPill).toHaveTextContent("Kimi");
expect(centeredPill).toHaveStyle({ transform: "scale(1.0800)" });
expect(
track.querySelector<HTMLElement>("[data-preset-offset='1']"),
).toHaveStyle({ transform: "scale(1.0200)" });
fireEvent.pointerMove(badge, {
clientY: 122,
pointerId: 7,
pointerType: "mouse",
});
expect(track.querySelector("[data-preset-offset='0']")).toHaveTextContent("Kimi");
fireEvent.pointerMove(badge, {
clientY: 123,
pointerId: 7,
pointerType: "mouse",
});
expect(track.querySelector("[data-preset-offset='0']")).toHaveTextContent("DS Pro");
fireEvent.pointerUp(badge, {
clientY: 123,
pointerId: 7,
pointerType: "mouse",
});
expect(onPresetChange).toHaveBeenCalledWith("dspro");
expect(badge).toHaveAttribute("data-settling", "true");
expect(track).toHaveAttribute("data-settling", "true");
act(() => {
vi.advanceTimersByTime(260);
});
expect(badge).not.toHaveAttribute("data-switching");
expect(badge).not.toHaveAttribute("data-settling");
});
it("supports the same long-press switcher in hero mode and cancels pointercancel", () => {
vi.useFakeTimers();
const { badge, onPresetChange } = renderPresetComposer("hero");
expect(badge).toHaveClass("h-8");
longPress(badge, 9);
expect(badge).toHaveAttribute("data-switching", "true");
fireEvent.pointerMove(badge, { clientY: 75, pointerId: 9, pointerType: "mouse" });
fireEvent.pointerCancel(badge, { clientY: 75, pointerId: 9, pointerType: "mouse" });
expect(badge).not.toHaveAttribute("data-switching");
expect(onPresetChange).not.toHaveBeenCalled();
});
it("transcribes voice input into the composer without sending", async () => {
mockVoiceRecorder();
const onSend = vi.fn();
+30 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { normalizeLegacyLongTaskMessages } from "@/lib/thread-display-compat";
import { deriveTitle, isModelCommandText, visibleSessionPreview } from "@/lib/format";
import {
normalizeLegacyLongTaskMessages,
projectWebuiThreadMessages,
} from "@/lib/thread-display-compat";
import type { UIMessage } from "@/lib/types";
describe("normalizeLegacyLongTaskMessages", () => {
@@ -17,4 +21,29 @@ describe("normalizeLegacyLongTaskMessages", () => {
expect(out[0]!.role).toBe("tool");
expect(out[0]!.traces).toEqual(["long_task · done"]);
});
it("removes model and silent-command turns without hiding concurrent replies", () => {
const message = (
id: string,
role: UIMessage["role"],
content: string,
turnId?: string,
): UIMessage => ({ id, role, content, createdAt: 1, turnId });
const visible = projectWebuiThreadMessages([
message("model", "user", "/model fast", "model-turn"),
message("model-reply", "assistant", "Switched model preset to fast.", "model-turn"),
message("silent", "user", "/restart", "webui-system:restart"),
message("reply", "assistant", "This unrelated reply stays visible.", "other-turn"),
]);
expect(visible.map(({ content }) => content)).toEqual([
"This unrelated reply stays visible.",
]);
expect([
isModelCommandText("/MODEL@nanobot fast"),
isModelCommandText("/modelish"),
]).toEqual([true, false]);
expect(visibleSessionPreview("Switched model preset to `fast`.")).toBe("");
expect(deriveTitle("## Model\n- Current model: `gpt-5.5`", "New chat")).toBe("New chat");
});
});
+120 -10
View File
@@ -72,6 +72,7 @@ function makeClient() {
for (const h of sessionUpdateHandlers) h(chatId, scope);
},
sendMessage: vi.fn(),
sendSystemCommand: vi.fn().mockResolvedValue(undefined),
newChat: vi.fn(),
forkChat: vi.fn(),
attach: vi.fn(),
@@ -387,7 +388,7 @@ describe("ThreadShell", () => {
);
expect(await screen.findByTestId("composer-model-logo-openai_codex")).toBeInTheDocument();
expect(screen.getByText("gpt-5.5")).toBeInTheDocument();
expect(screen.getByText("Default")).toBeInTheDocument();
expect(screen.queryByText("ling-3.0-flash")).not.toBeInTheDocument();
});
@@ -406,8 +407,55 @@ describe("ThreadShell", () => {
),
);
expect(await screen.findByTitle("gpt-5.5 · OpenAI Codex")).toBeInTheDocument();
expect(screen.queryByTitle("deepseek-v4-pro · DeepSeek")).not.toBeInTheDocument();
expect(await screen.findByTitle("Fast · gpt-5.5 · OpenAI Codex")).toBeInTheDocument();
expect(screen.queryByTitle("Default · deepseek-v4-pro · DeepSeek")).not.toBeInTheDocument();
});
it("switches through every named preset while preserving call-order priority", async () => {
const client = makeClient();
const settings = settingsWithFastPreset();
settings.model_presets.push({
...settings.model_presets.at(-1)!,
name: "extra",
label: "Extra",
model: "deepseek/extra",
provider: "deepseek",
active: false,
is_default: false,
});
settings.model_call_order = ["fast"];
const view = (preset: string) => wrap(client, (
<ThreadShell
session={session("preset-order", preset)}
title="Preset order"
onToggleSidebar={() => {}}
settingsSnapshot={settings}
/>
));
const { rerender } = render(view("default"));
const badge = await screen.findByRole("spinbutton", { name: "Default" });
expect(badge).toHaveTextContent("Default");
fireEvent.keyDown(badge, { key: "ArrowDown" });
expect(client.sendSystemCommand).toHaveBeenCalledWith(
"preset-order",
"/model fast",
);
expect(await screen.findByText("Fast")).toBeInTheDocument();
fireEvent.keyDown(
screen.getByRole("spinbutton", { name: "Fast" }),
{ key: "End" },
);
expect(client.sendSystemCommand).toHaveBeenLastCalledWith(
"preset-order",
"/model extra",
);
expect(await screen.findByText("Extra")).toBeInTheDocument();
rerender(view("fast"));
expect(await screen.findByText("Fast")).toBeInTheDocument();
});
it("uses the backend-resolved provider for an auto session preset", async () => {
@@ -442,7 +490,7 @@ describe("ThreadShell", () => {
),
);
expect(await screen.findByTitle("gpt-4 · Company Proxy")).toBeInTheDocument();
expect(await screen.findByTitle("Fast · gpt-4 · Company Proxy")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Model not configured" })).not.toBeInTheDocument();
});
@@ -459,7 +507,7 @@ describe("ThreadShell", () => {
"openai-codex/gpt-5.5",
));
expect(await screen.findByText("gpt-5.5")).toBeInTheDocument();
expect(await screen.findByText("Default")).toBeInTheDocument();
const configuredBadge = screen.getByTestId("composer-model-logo-openai_codex").parentElement;
expect(configuredBadge).not.toBeNull();
expect(configuredBadge).toHaveClass("composer-model-badge");
@@ -477,7 +525,7 @@ describe("ThreadShell", () => {
const badge = logo.parentElement;
expect(badge).not.toBeNull();
expect(badge).toBe(configuredBadge);
expect(screen.getByText("gpt-5.5")).toBeInTheDocument();
expect(screen.getByText("Default")).toBeInTheDocument();
expect(screen.queryByText("deepseek-chat")).not.toBeInTheDocument();
expect(badge).toHaveAttribute("data-fallback", "true");
expect(badge).toHaveAttribute(
@@ -500,7 +548,7 @@ describe("ThreadShell", () => {
});
expect(
screen.getByTestId("composer-model-logo-openai_codex").parentElement,
).toHaveAttribute("title", "gpt-5.5 · OpenAI Codex");
).toHaveAttribute("title", "Default · gpt-5.5 · OpenAI Codex");
expect(
screen.getByTestId("composer-model-logo-openai_codex").parentElement,
).toBe(badge);
@@ -750,6 +798,57 @@ describe("ThreadShell", () => {
expect(onNewChat).not.toHaveBeenCalled();
});
it("applies the selected landing preset before sending the first prompt", async () => {
const client = makeClient();
const settings = settingsWithFastPreset();
settings.model_call_order = ["fast"];
let resolveModelCommand!: () => void;
client.sendSystemCommand.mockImplementation(
() => new Promise<void>((resolve) => {
resolveModelCommand = resolve;
}),
);
const onCreateChat = vi.fn().mockResolvedValue("chat-new");
const view = (currentSession: ReturnType<typeof session> | null) => wrap(client, (
<ThreadShell
session={currentSession}
title={currentSession ? "New chat" : "nanobot"}
onToggleSidebar={() => {}}
onCreateChat={onCreateChat}
settingsSnapshot={settings}
/>
));
const { rerender } = render(view(null));
fireEvent.keyDown(
await screen.findByRole("spinbutton", { name: "Default" }),
{ key: "ArrowDown" },
);
expect(await screen.findByText("Fast")).toBeInTheDocument();
expect(client.sendSystemCommand).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "use the selected model" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(client.sendSystemCommand).toHaveBeenCalledWith(
"chat-new",
"/model fast",
));
rerender(view(session("chat-new")));
expect(client.sendMessage).not.toHaveBeenCalled();
await act(async () => {
resolveModelCommand();
});
await waitFor(() => {
expectSendMessageWithTurn(client, "chat-new", "use the selected model");
});
});
it("binds a pending landing message to the chat created for it", async () => {
const client = makeClient();
let resolveCreate: ((chatId: string) => void) | null = null;
@@ -869,7 +968,7 @@ describe("ThreadShell", () => {
expect(screen.queryByText(HERO_GREETING_PATTERN)).not.toBeInTheDocument();
});
it("keeps a live first command reply when the initial history snapshot is stale", async () => {
it("hides a live first /model turn when the initial history snapshot is stale", async () => {
const client = makeClient();
const onCreateChat = vi.fn().mockResolvedValue("chat-new");
let resolveThread:
@@ -935,8 +1034,15 @@ describe("ThreadShell", () => {
chat_id: "chat-new",
text: "## Model\n- Current model: `Ring-2.6-1T`",
});
client._emitChat("chat-new", {
event: "message",
chat_id: "chat-new",
text: "This unrelated reply stays visible.",
});
});
expect(screen.getByText(/Current model/)).toBeInTheDocument();
expect(screen.queryByText("/model")).not.toBeInTheDocument();
expect(screen.queryByText(/Current model/)).not.toBeInTheDocument();
expect(screen.getByText("This unrelated reply stays visible.")).toBeInTheDocument();
await act(async () => {
resolveThread?.(
@@ -944,7 +1050,11 @@ describe("ThreadShell", () => {
);
});
await waitFor(() => expect(screen.getByText(/Current model/)).toBeInTheDocument());
await waitFor(() => {
expect(screen.queryByText("/model")).not.toBeInTheDocument();
expect(screen.queryByText(/Current model/)).not.toBeInTheDocument();
expect(screen.getByText("This unrelated reply stays visible.")).toBeInTheDocument();
});
});
it("keeps the empty thread landing focused on the composer", async () => {