From a71e6a0ae8275683067c647b09c1efa696c86380 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Fri, 29 May 2026 14:54:46 +0800 Subject: [PATCH] fix(webui): persist markdown video previews --- nanobot/webui/transcript.py | 19 +++++++++++---- tests/channels/test_websocket_media_route.py | 22 +++++++++++++++++ tests/utils/test_webui_transcript.py | 18 ++++++++++++++ webui/src/components/MarkdownTextRenderer.tsx | 24 +++++++++++++++++++ .../src/tests/markdown-text-renderer.test.tsx | 10 ++++++++ 5 files changed, 89 insertions(+), 4 deletions(-) diff --git a/nanobot/webui/transcript.py b/nanobot/webui/transcript.py index 69ef4e471..059c5f6f2 100644 --- a/nanobot/webui/transcript.py +++ b/nanobot/webui/transcript.py @@ -28,6 +28,12 @@ _INLINE_MARKDOWN_IMAGE_EXTS: frozenset[str] = frozenset({ ".webp", ".gif", }) +_INLINE_MARKDOWN_VIDEO_EXTS: frozenset[str] = frozenset({ + ".mp4", + ".mov", + ".webm", +}) +_INLINE_MARKDOWN_MEDIA_EXTS = _INLINE_MARKDOWN_IMAGE_EXTS | _INLINE_MARKDOWN_VIDEO_EXTS _FILE_EDIT_TOOL_NAMES: frozenset[str] = frozenset({ "write_file", "edit_file", @@ -41,7 +47,7 @@ def rewrite_local_markdown_images( workspace_path: Path, sign_path: Callable[[Path], Mapping[str, Any] | None], ) -> str: - """Rewrite markdown image paths inside the workspace to signed WebUI media URLs.""" + """Rewrite markdown media paths inside the workspace to signed WebUI media URLs.""" if "![" not in text: return text @@ -55,7 +61,7 @@ def rewrite_local_markdown_images( if parsed.scheme or parsed.netloc or parsed.query or parsed.fragment: return None path_text = unquote(url) - if Path(path_text).suffix.lower() not in _INLINE_MARKDOWN_IMAGE_EXTS: + if Path(path_text).suffix.lower() not in _INLINE_MARKDOWN_MEDIA_EXTS: return None candidate = Path(path_text).expanduser() if not candidate.is_absolute(): @@ -80,6 +86,10 @@ def rewrite_local_markdown_images( return _MARKDOWN_LOCAL_IMAGE_RE.sub(replace, text) +def _media_kind_from_name(name: str) -> str: + return "video" if Path(name).suffix.lower() in _INLINE_MARKDOWN_VIDEO_EXTS else "image" + + def webui_transcript_path(session_key: str) -> Path: stem = SessionManager.safe_key(session_key) return get_webui_dir() / f"{stem}.jsonl" @@ -821,11 +831,12 @@ def replay_transcript_to_ui_messages( if isinstance(media_urls, list): for m in media_urls: if isinstance(m, dict) and m.get("url"): + name = str(m.get("name") or "") media.append( { - "kind": "image", + "kind": _media_kind_from_name(name), "url": str(m["url"]), - "name": str(m.get("name") or ""), + "name": name, }, ) extra: dict[str, Any] = {"content": content_s} diff --git a/tests/channels/test_websocket_media_route.py b/tests/channels/test_websocket_media_route.py index 48d102cd0..34d5556cb 100644 --- a/tests/channels/test_websocket_media_route.py +++ b/tests/channels/test_websocket_media_route.py @@ -155,6 +155,28 @@ def test_local_markdown_image_is_staged_and_rewritten( assert staged[0].read_bytes() == _PNG_BYTES +def test_local_markdown_video_is_staged_and_rewritten( + bus: MagicMock, + tmp_path: Path, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + video_bytes = b"fake mp4" + (workspace / "nanobot-intro.mp4").write_bytes(video_bytes) + media = tmp_path / "media" + channel = _ch(bus, workspace_path=workspace, port=0) + + with patch("nanobot.channels.websocket.get_media_dir", side_effect=_fake_media_dir(media)): + rewritten = channel._rewrite_local_markdown_images( + "The result:\n![nanobot-intro.mp4](nanobot-intro.mp4)" + ) + + assert "![nanobot-intro.mp4](/api/media/" in rewritten + staged = list((media / "websocket").iterdir()) + assert len(staged) == 1 + assert staged[0].read_bytes() == video_bytes + + def test_local_markdown_image_rejects_workspace_escape( bus: MagicMock, tmp_path: Path, diff --git a/tests/utils/test_webui_transcript.py b/tests/utils/test_webui_transcript.py index f676c0486..22ce9893d 100644 --- a/tests/utils/test_webui_transcript.py +++ b/tests/utils/test_webui_transcript.py @@ -66,6 +66,24 @@ def test_replay_uses_stream_end_final_text() -> None: assert msgs[1]["content"] == "![Diagram](/api/media/sig/payload)" +def test_replay_infers_video_media_from_attachment_name() -> None: + msgs = replay_transcript_to_ui_messages( + [ + {"event": "user", "chat_id": "t-video", "text": "render"}, + { + "event": "message", + "chat_id": "t-video", + "text": "video ready", + "media_urls": [{"url": "/api/media/sig/payload", "name": "intro.mp4"}], + }, + ], + ) + + assert msgs[1]["media"] == [ + {"kind": "video", "url": "/api/media/sig/payload", "name": "intro.mp4"}, + ] + + def test_replay_file_edit_event_creates_file_activity(tmp_path, monkeypatch) -> None: monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) key = "websocket:t-file" diff --git a/webui/src/components/MarkdownTextRenderer.tsx b/webui/src/components/MarkdownTextRenderer.tsx index 2e18c3df4..a47786d17 100644 --- a/webui/src/components/MarkdownTextRenderer.tsx +++ b/webui/src/components/MarkdownTextRenderer.tsx @@ -8,6 +8,7 @@ import remarkMath from "remark-math"; import { CodeBlock } from "@/components/CodeBlock"; import { FileReferenceChip, isLikelyFilePath } from "@/components/FileReferenceChip"; +import { inferMediaKind } from "@/lib/media"; import { cn } from "@/lib/utils"; import "katex/dist/katex.min.css"; @@ -114,6 +115,29 @@ export default function MarkdownTextRenderer({ const source = typeof src === "string" ? src : ""; if (!source) return null; const label = typeof alt === "string" ? alt : ""; + if (inferMediaKind({ url: source, name: label }) === "video") { + return ( + + + ); + } return ( { ); expect(screen.getByText("Diagram")).toBeInTheDocument(); }); + + it("renders markdown videos as inline players", () => { + render(![nanobot-intro.mp4](/api/media/sig/video)); + + const video = screen.getByLabelText("Video attachment: nanobot-intro.mp4"); + expect(video.tagName).toBe("VIDEO"); + expect(video).toHaveAttribute("src", "/api/media/sig/video"); + expect(video).toHaveAttribute("controls"); + expect(screen.queryByRole("img", { name: "nanobot-intro.mp4" })).not.toBeInTheDocument(); + }); });