feat(webui): render video media attachments

Add signed media URLs to live WebSocket replies and teach the WebUI to classify and render video attachments, so bot-sent videos can play inline in both live chats and session history.

Made-with: Cursor
This commit is contained in:
Xubin Ren
2026-04-25 03:20:40 +08:00
committed by Xubin Ren
parent be05189f39
commit e52fe2a8e2
10 changed files with 327 additions and 15 deletions
+24
View File
@@ -40,4 +40,28 @@ describe("MessageBubble", () => {
fireEvent.click(toggle);
expect(screen.queryByText('weather("get")')).not.toBeInTheDocument();
});
it("renders video media as an inline player", () => {
const message: UIMessage = {
id: "a1",
role: "assistant",
content: "here is the clip",
createdAt: Date.now(),
media: [
{
kind: "video",
url: "/api/media/sig/payload",
name: "demo.mp4",
},
],
};
const { container } = render(<MessageBubble message={message} />);
expect(screen.getByText("here is the clip")).toBeInTheDocument();
const video = screen.getByLabelText(/video attachment/i);
expect(video.tagName).toBe("VIDEO");
expect(video).toHaveAttribute("src", "/api/media/sig/payload");
expect(container.querySelector("video[controls]")).toBeInTheDocument();
});
});
+21
View File
@@ -92,4 +92,25 @@ describe("useNanobotStream", () => {
expect(result.current.messages[1].role).toBe("assistant");
expect(result.current.messages[1].kind).toBeUndefined();
});
it("attaches assistant media_urls to complete messages", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-m", []), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-m", {
event: "message",
chat_id: "chat-m",
text: "video ready",
media_urls: [{ url: "/api/media/sig/payload", name: "demo.mp4" }],
});
});
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].media).toEqual([
{ kind: "video", url: "/api/media/sig/payload", name: "demo.mp4" },
]);
});
});
+34
View File
@@ -130,12 +130,46 @@ describe("useSessions", () => {
{ url: "/api/media/sig-1/payload-1", name: "snap.png" },
{ url: "/api/media/sig-2/payload-2", name: "diag.jpg" },
]);
expect(first.media).toEqual([
{ kind: "image", url: "/api/media/sig-1/payload-1", name: "snap.png" },
{ kind: "image", url: "/api/media/sig-2/payload-2", name: "diag.jpg" },
]);
expect(second.role).toBe("assistant");
expect(second.images).toBeUndefined();
expect(third.role).toBe("user");
expect(third.images).toBeUndefined();
});
it("hydrates historical assistant video media_urls into media attachments", async () => {
vi.mocked(api.fetchSessionMessages).mockResolvedValue({
key: "websocket:chat-video",
created_at: "2026-04-20T10:00:00Z",
updated_at: "2026-04-20T10:05:00Z",
messages: [
{
role: "assistant",
content: "clip ready",
timestamp: "2026-04-20T10:00:01Z",
media_urls: [
{ url: "/api/media/sig-v/payload-v", name: "clip.mp4" },
],
},
],
});
const { result } = renderHook(() => useSessionHistory("websocket:chat-video"), {
wrapper: wrap(fakeClient()),
});
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.messages[0].role).toBe("assistant");
expect(result.current.messages[0].images).toBeUndefined();
expect(result.current.messages[0].media).toEqual([
{ kind: "video", url: "/api/media/sig-v/payload-v", name: "clip.mp4" },
]);
});
it("keeps the session in the list when delete fails", async () => {
vi.mocked(api.listSessions).mockResolvedValue([
{