feat: add image generation tool and WebUI mode
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
Xubin Ren
co-authored by
Cursor
parent
3a2f47d720
commit
e936ed48bd
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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), {
|
||||
|
||||
Reference in New Issue
Block a user