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"
+ )
+
+ assert ".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"] == ""
+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 (
+
+
+ {label ? (
+
+ {label}
+
+ ) : null}
+
+ );
+ }
return (
{
);
expect(screen.getByText("Diagram")).toBeInTheDocument();
});
+
+ it("renders markdown videos as inline players", () => {
+ render();
+
+ 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();
+ });
});