feat: add image generation tool and WebUI mode

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Xubin Ren
2026-05-08 20:06:23 +08:00
committed by Xubin Ren
co-authored by Cursor
parent 3a2f47d720
commit e936ed48bd
45 changed files with 2979 additions and 89 deletions
+12
View File
@@ -84,6 +84,18 @@ describe("webui API helpers", () => {
ok: true,
json: async () => ({
commands: [
{
command: "/stop",
title: "Stop current task",
description: "Cancel the active task.",
icon: "square",
},
{
command: "/restart",
title: "Restart nanobot",
description: "Restart the bot process.",
icon: "rotate-cw",
},
{
command: "/history",
title: "Show conversation history",
+6
View File
@@ -7,6 +7,7 @@ import { ThreadComposer } from "@/components/thread/ThreadComposer";
import { resources } from "@/i18n";
const QUICK_ACTION_KEYS = ["plan", "analyze", "brainstorm", "code", "summarize", "more"];
const IMAGE_QUICK_ACTION_KEYS = ["icon", "sticker", "poster", "product", "portrait", "edit"];
describe("webui i18n", () => {
it("switches UI copy and document locale through the language switcher", async () => {
@@ -54,6 +55,11 @@ describe("webui i18n", () => {
expect(action.title).toBeTruthy();
expect(action.prompt).toBeTruthy();
}
for (const key of IMAGE_QUICK_ACTION_KEYS) {
const action = empty.imageQuickActions[key as keyof typeof empty.imageQuickActions];
expect(action.title).toBeTruthy();
expect(action.prompt).toBeTruthy();
}
}
});
});
+22
View File
@@ -102,4 +102,26 @@ describe("MessageBubble", () => {
expect(video).toHaveAttribute("src", "/api/media/sig/payload");
expect(container.querySelector("video[controls]")).toBeInTheDocument();
});
it("renders assistant image media as a larger generated result", () => {
const message: UIMessage = {
id: "a-image",
role: "assistant",
content: "done",
createdAt: Date.now(),
media: [
{
kind: "image",
url: "/api/media/sig/image",
name: "generated.png",
},
],
};
const { container } = render(<MessageBubble message={message} />);
const imageButton = screen.getByRole("button", { name: /view image/i });
expect(imageButton).toHaveClass("h-56", "sm:h-72");
expect(container.querySelector("img")).toHaveClass("object-contain");
});
});
+27
View File
@@ -120,6 +120,33 @@ describe("NanobotClient", () => {
);
});
it("includes image generation options in outbound messages", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
client.sendMessage(
"chat-img",
"draw a banner",
undefined,
{ imageGeneration: { enabled: true, aspect_ratio: "16:9" } },
);
expect(lastSocket().sent).toContain(
JSON.stringify({
type: "message",
chat_id: "chat-img",
content: "draw a banner",
image_generation: { enabled: true, aspect_ratio: "16:9" },
webui: true,
}),
);
});
it("re-attaches known chats after a reconnect", async () => {
const client = new NanobotClient({
url: "ws://test",
+112
View File
@@ -91,4 +91,116 @@ describe("ThreadComposer", () => {
expect(onSend).not.toHaveBeenCalled();
expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
});
it("sends image generation mode with automatic aspect ratio", () => {
const onSend = vi.fn();
render(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Toggle image generation mode" }));
expect(screen.getByPlaceholderText("Describe or edit an image…")).toBeInTheDocument();
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "Draw a friendly robot" } });
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
expect(onSend).toHaveBeenCalledWith(
"Draw a friendly robot",
undefined,
{ imageGeneration: { enabled: true, aspect_ratio: null } },
);
});
it("shows a stop button while streaming", () => {
const onStop = vi.fn();
render(
<ThreadComposer
onSend={vi.fn()}
onStop={onStop}
isStreaming
placeholder="Type your message..."
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Stop response" }));
expect(onStop).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("button", { name: "Send message" })).not.toBeInTheDocument();
});
it("lets users select a concrete image aspect ratio", () => {
const onSend = vi.fn();
render(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Toggle image generation mode" }));
fireEvent.click(screen.getByRole("button", { name: "Image aspect ratio" }));
expect(screen.getByRole("listbox", { name: "Image aspect ratio" }).className).toContain(
"bottom-full",
);
fireEvent.mouseDown(screen.getByRole("option", { name: "Wide 16:9" }));
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "Draw a banner" } });
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
expect(onSend).toHaveBeenCalledWith(
"Draw a banner",
undefined,
{ imageGeneration: { enabled: true, aspect_ratio: "16:9" } },
);
});
it("opens the hero image aspect menu downward", () => {
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Ask anything..."
variant="hero"
imageMode
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Image aspect ratio" }));
expect(screen.getByRole("listbox", { name: "Image aspect ratio" }).className).toContain(
"top-full",
);
});
it("dismisses the image aspect menu on outside click, escape, and wheel", () => {
render(
<div>
<button type="button">outside</button>
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
imageMode
/>
</div>,
);
const aspectButton = screen.getByRole("button", { name: "Image aspect ratio" });
fireEvent.click(aspectButton);
expect(screen.getByRole("listbox", { name: "Image aspect ratio" })).toBeInTheDocument();
fireEvent.pointerDown(screen.getByRole("button", { name: "outside" }));
expect(screen.queryByRole("listbox", { name: "Image aspect ratio" })).not.toBeInTheDocument();
fireEvent.click(aspectButton);
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("listbox", { name: "Image aspect ratio" })).not.toBeInTheDocument();
fireEvent.click(aspectButton);
fireEvent.wheel(screen.getByRole("listbox", { name: "Image aspect ratio" }), { deltaY: 120 });
expect(screen.queryByRole("listbox", { name: "Image aspect ratio" })).not.toBeInTheDocument();
});
});
+82
View File
@@ -250,6 +250,64 @@ describe("ThreadShell", () => {
expect(onNewChat).not.toHaveBeenCalled();
});
it("keeps the first landing message when new chat history is still empty", async () => {
const client = makeClient();
const onCreateChat = vi.fn().mockResolvedValue("chat-new");
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
ok: false,
status: 404,
json: async () => ({}),
})),
);
const { rerender } = render(
wrap(
client,
<ThreadShell
session={null}
title="nanobot"
onToggleSidebar={() => {}}
onCreateChat={onCreateChat}
/>,
),
);
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "first message should stay" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(onCreateChat).toHaveBeenCalledTimes(1));
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={session("chat-new")}
title="Chat chat-new"
onToggleSidebar={() => {}}
onCreateChat={onCreateChat}
/>,
),
);
});
await waitFor(() =>
expect(client.sendMessage).toHaveBeenCalledWith(
"chat-new",
"first message should stay",
undefined,
),
);
await waitFor(() =>
expect(screen.getByText("first message should stay")).toBeInTheDocument(),
);
expect(screen.queryByText("What can I do for you?")).not.toBeInTheDocument();
});
it("sends quick action prompts from the empty thread landing", async () => {
const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-a");
@@ -566,6 +624,30 @@ describe("ThreadShell", () => {
expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
});
it("switches welcome quick actions when image mode is enabled", async () => {
const client = makeClient();
render(
wrap(
client,
<ThreadShell
session={null}
title="nanobot"
onToggleSidebar={() => {}}
onNewChat={() => {}}
/>,
),
);
await act(async () => {});
expect(screen.getByText("Write code")).toBeInTheDocument();
expect(screen.queryByText("Design an app icon")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Toggle image generation mode" }));
expect(screen.getByText("Design an app icon")).toBeInTheDocument();
expect(screen.queryByText("Write code")).not.toBeInTheDocument();
});
it("surfaces a dismissible banner when the stream reports message_too_big", async () => {
const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-a");
+83
View File
@@ -134,6 +134,89 @@ describe("useNanobotStream", () => {
]);
});
it("suppresses redundant stream confirmation after assistant media", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-img-result", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-img-result", {
event: "message",
chat_id: "chat-img-result",
text: "image ready",
media_urls: [{ url: "/api/media/sig/image", name: "generated.png" }],
});
fake.emit("chat-img-result", {
event: "message",
chat_id: "chat-img-result",
text: "message()",
kind: "tool_hint",
});
fake.emit("chat-img-result", {
event: "delta",
chat_id: "chat-img-result",
text: "发送成功",
});
fake.emit("chat-img-result", {
event: "stream_end",
chat_id: "chat-img-result",
});
fake.emit("chat-img-result", {
event: "turn_end",
chat_id: "chat-img-result",
});
});
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].content).toBe("image ready");
expect(result.current.messages[0].media).toHaveLength(1);
});
it("passes image generation options to the websocket client", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-img", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
result.current.send(
"draw a square icon",
undefined,
{ imageGeneration: { enabled: true, aspect_ratio: "1:1" } },
);
});
expect(fake.client.sendMessage).toHaveBeenCalledWith(
"chat-img",
"draw a square icon",
undefined,
{ imageGeneration: { enabled: true, aspect_ratio: "1:1" } },
);
});
it("stops the active turn without adding a user slash command bubble", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-stop", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
result.current.send("long task");
});
expect(result.current.messages).toHaveLength(1);
expect(result.current.isStreaming).toBe(true);
act(() => {
result.current.stop();
});
expect(fake.client.sendMessage).toHaveBeenLastCalledWith("chat-stop", "/stop");
expect(result.current.isStreaming).toBe(false);
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].content).toBe("long task");
});
it("keeps assistant buttons on complete messages", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-q", EMPTY_MESSAGES), {