mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-14 06:43:53 +00:00
fix(webui): persist markdown video previews
This commit is contained in:
parent
57563b671f
commit
a71e6a0ae8
@ -28,6 +28,12 @@ _INLINE_MARKDOWN_IMAGE_EXTS: frozenset[str] = frozenset({
|
|||||||
".webp",
|
".webp",
|
||||||
".gif",
|
".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({
|
_FILE_EDIT_TOOL_NAMES: frozenset[str] = frozenset({
|
||||||
"write_file",
|
"write_file",
|
||||||
"edit_file",
|
"edit_file",
|
||||||
@ -41,7 +47,7 @@ def rewrite_local_markdown_images(
|
|||||||
workspace_path: Path,
|
workspace_path: Path,
|
||||||
sign_path: Callable[[Path], Mapping[str, Any] | None],
|
sign_path: Callable[[Path], Mapping[str, Any] | None],
|
||||||
) -> str:
|
) -> 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:
|
if "![" not in text:
|
||||||
return text
|
return text
|
||||||
|
|
||||||
@ -55,7 +61,7 @@ def rewrite_local_markdown_images(
|
|||||||
if parsed.scheme or parsed.netloc or parsed.query or parsed.fragment:
|
if parsed.scheme or parsed.netloc or parsed.query or parsed.fragment:
|
||||||
return None
|
return None
|
||||||
path_text = unquote(url)
|
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
|
return None
|
||||||
candidate = Path(path_text).expanduser()
|
candidate = Path(path_text).expanduser()
|
||||||
if not candidate.is_absolute():
|
if not candidate.is_absolute():
|
||||||
@ -80,6 +86,10 @@ def rewrite_local_markdown_images(
|
|||||||
return _MARKDOWN_LOCAL_IMAGE_RE.sub(replace, text)
|
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:
|
def webui_transcript_path(session_key: str) -> Path:
|
||||||
stem = SessionManager.safe_key(session_key)
|
stem = SessionManager.safe_key(session_key)
|
||||||
return get_webui_dir() / f"{stem}.jsonl"
|
return get_webui_dir() / f"{stem}.jsonl"
|
||||||
@ -821,11 +831,12 @@ def replay_transcript_to_ui_messages(
|
|||||||
if isinstance(media_urls, list):
|
if isinstance(media_urls, list):
|
||||||
for m in media_urls:
|
for m in media_urls:
|
||||||
if isinstance(m, dict) and m.get("url"):
|
if isinstance(m, dict) and m.get("url"):
|
||||||
|
name = str(m.get("name") or "")
|
||||||
media.append(
|
media.append(
|
||||||
{
|
{
|
||||||
"kind": "image",
|
"kind": _media_kind_from_name(name),
|
||||||
"url": str(m["url"]),
|
"url": str(m["url"]),
|
||||||
"name": str(m.get("name") or ""),
|
"name": name,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
extra: dict[str, Any] = {"content": content_s}
|
extra: dict[str, Any] = {"content": content_s}
|
||||||
|
|||||||
@ -155,6 +155,28 @@ def test_local_markdown_image_is_staged_and_rewritten(
|
|||||||
assert staged[0].read_bytes() == _PNG_BYTES
|
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(
|
def test_local_markdown_image_rejects_workspace_escape(
|
||||||
bus: MagicMock,
|
bus: MagicMock,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
|
|||||||
@ -66,6 +66,24 @@ def test_replay_uses_stream_end_final_text() -> None:
|
|||||||
assert msgs[1]["content"] == ""
|
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:
|
def test_replay_file_edit_event_creates_file_activity(tmp_path, monkeypatch) -> None:
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||||
key = "websocket:t-file"
|
key = "websocket:t-file"
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import remarkMath from "remark-math";
|
|||||||
|
|
||||||
import { CodeBlock } from "@/components/CodeBlock";
|
import { CodeBlock } from "@/components/CodeBlock";
|
||||||
import { FileReferenceChip, isLikelyFilePath } from "@/components/FileReferenceChip";
|
import { FileReferenceChip, isLikelyFilePath } from "@/components/FileReferenceChip";
|
||||||
|
import { inferMediaKind } from "@/lib/media";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import "katex/dist/katex.min.css";
|
import "katex/dist/katex.min.css";
|
||||||
@ -114,6 +115,29 @@ export default function MarkdownTextRenderer({
|
|||||||
const source = typeof src === "string" ? src : "";
|
const source = typeof src === "string" ? src : "";
|
||||||
if (!source) return null;
|
if (!source) return null;
|
||||||
const label = typeof alt === "string" ? alt : "";
|
const label = typeof alt === "string" ? alt : "";
|
||||||
|
if (inferMediaKind({ url: source, name: label }) === "video") {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"not-prose my-3 block w-fit max-w-full overflow-hidden rounded-[14px]",
|
||||||
|
"border border-border/70 bg-background shadow-sm",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
src={source}
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
className="block max-h-[26rem] max-w-full bg-black"
|
||||||
|
aria-label={label ? `Video attachment: ${label}` : "Video attachment"}
|
||||||
|
/>
|
||||||
|
{label ? (
|
||||||
|
<span className="block max-w-full truncate px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@ -15,4 +15,14 @@ describe("MarkdownTextRenderer", () => {
|
|||||||
);
|
);
|
||||||
expect(screen.getByText("Diagram")).toBeInTheDocument();
|
expect(screen.getByText("Diagram")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders markdown videos as inline players", () => {
|
||||||
|
render(<MarkdownTextRenderer></MarkdownTextRenderer>);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user