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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user