diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 37377b785..a1ac15495 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -110,6 +110,7 @@ class ChannelManager: static_path = _default_webui_dist() if static_path is not None: kwargs["static_dist_path"] = static_path + kwargs["workspace_path"] = self.config.workspace_path if self._webui_runtime_model_name is not None: kwargs["runtime_model_name"] = self._webui_runtime_model_name channel = cls(section, self.bus, **kwargs) diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 139e0060e..d0add2ccd 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -34,7 +34,7 @@ from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.command.builtin import builtin_command_palette -from nanobot.config.paths import get_media_dir +from nanobot.config.paths import get_media_dir, get_workspace_path from nanobot.config.schema import Base from nanobot.session.goal_state import goal_state_ws_blob from nanobot.session.webui_turns import websocket_turn_wall_started_at @@ -425,6 +425,16 @@ _MEDIA_ALLOWED_MIMES: frozenset[str] = frozenset({ "video/webm", "video/quicktime", }) +_MARKDOWN_LOCAL_IMAGE_RE = re.compile( + r"!\[([^\]]*)\]\((<[^>]+>|[^)\s]+)(\s+(?:\"[^\"]*\"|'[^']*'))?\)" +) +_INLINE_MARKDOWN_IMAGE_EXTS: frozenset[str] = frozenset({ + ".png", + ".jpg", + ".jpeg", + ".webp", + ".gif", +}) def _issue_route_secret_matches(headers: Any, configured_secret: str) -> bool: @@ -454,6 +464,7 @@ class WebSocketChannel(BaseChannel): *, session_manager: "SessionManager | None" = None, static_dist_path: Path | None = None, + workspace_path: Path | None = None, runtime_model_name: Callable[[], str | None] | None = None, ): if isinstance(config, dict): @@ -476,8 +487,14 @@ class WebSocketChannel(BaseChannel): self._static_dist_path: Path | None = ( static_dist_path.resolve() if static_dist_path is not None else None ) + self._workspace_path = ( + Path(workspace_path).expanduser() + if workspace_path is not None + else get_workspace_path() + ).resolve(strict=False) self._runtime_model_name = runtime_model_name self._settings_restart_sections: set[str] = set() + self._stream_text_buffers: dict[tuple[str, str], list[str]] = {} # Process-local secret used to HMAC-sign media URLs. The signed URL is # the capability — anyone who holds a valid URL can fetch that one # file, nothing else. The secret regenerates on restart so links @@ -961,6 +978,7 @@ class WebSocketChannel(BaseChannel): data = build_webui_thread_response( decoded_key, augment_user_media=self._augment_transcript_user_media, + augment_assistant_text=self._rewrite_local_markdown_images, ) if data is None: return _http_error(404, "webui thread not found") @@ -1099,6 +1117,46 @@ class WebSocketChannel(BaseChannel): return None return {"url": signed, "name": path.name} + def _markdown_image_url_for_local_path(self, raw_url: str) -> str | None: + url = raw_url.strip() + if url.startswith("<") and url.endswith(">"): + url = url[1:-1].strip() + if not url or url.startswith(("/api/media/", "#")): + return None + parsed = urlparse(url) + if parsed.scheme or parsed.netloc: + return None + if parsed.query or parsed.fragment: + return None + path_text = unquote(url) + if Path(path_text).suffix.lower() not in _INLINE_MARKDOWN_IMAGE_EXTS: + return None + candidate = Path(path_text).expanduser() + if not candidate.is_absolute(): + candidate = self._workspace_path / candidate + try: + resolved = candidate.resolve(strict=False) + resolved.relative_to(self._workspace_path) + except (OSError, ValueError): + return None + if not resolved.is_file(): + return None + signed = self._sign_or_stage_media_path(resolved) + return signed["url"] if signed else None + + def _rewrite_local_markdown_images(self, text: str) -> str: + if "![" not in text: + return text + + def replace(match: re.Match[str]) -> str: + signed_url = self._markdown_image_url_for_local_path(match.group(2)) + if not signed_url: + return match.group(0) + title = match.group(3) or "" + return f"![{match.group(1)}]({signed_url}{title})" + + return _MARKDOWN_LOCAL_IMAGE_RE.sub(replace, text) + def _handle_media_fetch(self, sig: str, payload: str) -> Response: """Serve a single media file previously signed via :meth:`_sign_media_path`. Validates the signature, decodes the @@ -1584,10 +1642,11 @@ class WebSocketChannel(BaseChannel): await self._safe_send_to(connection, raw, label=" ") return text = msg.content + wire_text = self._rewrite_local_markdown_images(text) payload: dict[str, Any] = { "event": "message", "chat_id": msg.chat_id, - "text": text, + "text": wire_text, } if msg.media: payload["media"] = msg.media @@ -1615,7 +1674,9 @@ class WebSocketChannel(BaseChannel): payload["kind"] = "tool_hint" elif msg.metadata.get("_progress"): payload["kind"] = "progress" - self._try_append_webui_transcript(msg.chat_id, payload) + transcript_payload = dict(payload) + transcript_payload["text"] = text + self._try_append_webui_transcript(msg.chat_id, transcript_payload) raw = json.dumps(payload, ensure_ascii=False) for connection in conns: await self._safe_send_to(connection, raw, label=" ") @@ -1680,14 +1741,20 @@ class WebSocketChannel(BaseChannel): if not conns: return meta = metadata or {} + stream_key = (chat_id, str(meta.get("_stream_id") or "")) if meta.get("_stream_end"): body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id} + full_text = "".join(self._stream_text_buffers.pop(stream_key, [])) + rewritten = self._rewrite_local_markdown_images(full_text) + if rewritten != full_text: + body["text"] = rewritten else: body = { "event": "delta", "chat_id": chat_id, "text": delta, } + self._stream_text_buffers.setdefault(stream_key, []).append(delta) if meta.get("_stream_id") is not None: body["stream_id"] = meta["_stream_id"] self._try_append_webui_transcript(chat_id, body) diff --git a/nanobot/cli_apps/service.py b/nanobot/cli_apps/service.py index 5ad257982..2dc3cb0a1 100644 --- a/nanobot/cli_apps/service.py +++ b/nanobot/cli_apps/service.py @@ -25,9 +25,43 @@ CLI_ANYTHING_RAW_BASE = "https://raw.githubusercontent.com/HKUDS/CLI-Anything/ma CLI_ANYTHING_RAW_SKILLS_BASE = f"{CLI_ANYTHING_RAW_BASE}/skills/" _MAX_TOOL_OUTPUT_CHARS = 12_000 +_MAX_ARTIFACT_SCAN_PATHS = 4_000 +_MAX_ARTIFACT_REPORT = 12 _SAFE_NAME_RE = re.compile(r"[^a-z0-9_-]+") _MENTION_RE = re.compile(r"(^|[\s([{])@([a-z0-9_-]+)\b", re.IGNORECASE) _SHELL_META_CHARS = ("|", "&&", "||", ";", "$(", "`", ">", "<") +_ARTIFACT_EXTENSIONS = frozenset({ + ".csv", + ".drawio", + ".gif", + ".html", + ".jpeg", + ".jpg", + ".json", + ".md", + ".pdf", + ".png", + ".svg", + ".txt", + ".vsdx", + ".webp", + ".xml", +}) +_INLINE_ARTIFACT_EXTENSIONS = frozenset({".gif", ".jpeg", ".jpg", ".png", ".webp"}) +_ARTIFACT_IGNORE_DIRS = frozenset({ + ".git", + ".hg", + ".mypy_cache", + ".nanobot", + ".pytest_cache", + ".ruff_cache", + ".venv", + "__pycache__", + "build", + "dist", + "node_modules", + "venv", +}) class CliAppError(ValueError): @@ -783,6 +817,87 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in raise CliAppError("working_dir is outside the configured workspace") return cwd + def _iter_artifact_candidates(self, cwd: Path) -> list[Path]: + if not cwd.is_dir(): + return [] + out: list[Path] = [] + stack = [cwd] + scanned = 0 + while stack and scanned < _MAX_ARTIFACT_SCAN_PATHS: + directory = stack.pop() + try: + entries = sorted(directory.iterdir(), key=lambda path: path.name.lower()) + except OSError: + continue + for path in entries: + if scanned >= _MAX_ARTIFACT_SCAN_PATHS: + break + scanned += 1 + try: + if path.is_dir() and not path.is_symlink(): + if path.name not in _ARTIFACT_IGNORE_DIRS: + stack.append(path) + continue + if path.is_file() and path.suffix.lower() in _ARTIFACT_EXTENSIONS: + out.append(path.resolve(strict=False)) + except OSError: + continue + return out + + def _artifact_snapshot(self, cwd: Path) -> dict[Path, tuple[int, int]]: + snapshot: dict[Path, tuple[int, int]] = {} + for path in self._iter_artifact_candidates(cwd): + try: + stat = path.stat() + except OSError: + continue + snapshot[path] = (stat.st_mtime_ns, stat.st_size) + return snapshot + + def _changed_artifacts( + self, + cwd: Path, + before: dict[Path, tuple[int, int]], + ) -> list[Path]: + changed: list[tuple[int, Path]] = [] + for path, stamp in self._artifact_snapshot(cwd).items(): + if before.get(path) == stamp: + continue + changed.append((stamp[0], path)) + changed.sort(key=lambda item: (item[0], item[1].name.lower())) + return [path for _, path in changed[-_MAX_ARTIFACT_REPORT:]] + + def _format_artifact_path(self, cwd: Path, path: Path) -> str: + try: + return path.relative_to(cwd).as_posix() + except ValueError: + return path.name + + @staticmethod + def _format_artifact_size(path: Path) -> str: + try: + size = path.stat().st_size + except OSError: + return "unknown size" + if size < 1024: + return f"{size} B" + if size < 1024 * 1024: + return f"{size / 1024:.1f} KB" + return f"{size / (1024 * 1024):.1f} MB" + + def _format_artifact_lines(self, cwd: Path, paths: list[Path]) -> list[str]: + lines: list[str] = [] + for path in paths: + rel = self._format_artifact_path(cwd, path) + ext = path.suffix.lower() + kind = ( + "previewable image" + if ext in _INLINE_ARTIFACT_EXTENSIONS + else ext.lstrip(".") or "file" + ) + lines.append(f"- {rel} ({kind}, {self._format_artifact_size(path)})") + return lines + def run( self, name: str, @@ -806,6 +921,7 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in if json_output and "--json" not in clean_args: clean_args = ["--json", *clean_args] effective_timeout = max(1, min(timeout or self.runtime.run_timeout, 600)) + artifact_snapshot = self._artifact_snapshot(cwd) try: result = subprocess.run( [resolved, *clean_args], @@ -825,4 +941,15 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in output.append("\nSTDOUT:\n" + result.stdout.rstrip()) if result.stderr: output.append("\nSTDERR:\n" + result.stderr.rstrip()) + artifacts = self._changed_artifacts(cwd, artifact_snapshot) + if artifacts: + output.append( + "\nArtifacts created or updated:\n" + + "\n".join(self._format_artifact_lines(cwd, artifacts)) + ) + if any(path.suffix.lower() in _INLINE_ARTIFACT_EXTENSIONS for path in artifacts): + output.append( + "\nTo show a preview in WebUI, reference a raster artifact with Markdown " + "using its workspace-relative path, for example `![diagram](diagram.png)`." + ) return _truncate("\n".join(output)) diff --git a/nanobot/webui/transcript.py b/nanobot/webui/transcript.py index 426930ff9..7a8073bdf 100644 --- a/nanobot/webui/transcript.py +++ b/nanobot/webui/transcript.py @@ -185,6 +185,7 @@ def replay_transcript_to_ui_messages( lines: list[dict[str, Any]], *, augment_user_media: Callable[[list[str]], list[dict[str, Any]]] | None = None, + augment_assistant_text: Callable[[str], str] | None = None, ) -> list[dict[str, Any]]: """Fold JSONL records into ``UIMessage``-shaped dicts for the WebUI. @@ -626,7 +627,14 @@ def replay_transcript_to_ui_messages( buffer_parts = [] continue - for m in messages: + for i, m in enumerate(messages): + if ( + augment_assistant_text is not None + and m.get("role") == "assistant" + and m.get("kind") != "trace" + and isinstance(m.get("content"), str) + ): + messages[i] = {**m, "content": augment_assistant_text(m["content"])} m.pop("isStreaming", None) m.pop("reasoningStreaming", None) return messages @@ -636,12 +644,17 @@ def build_webui_thread_response( session_key: str, *, augment_user_media: Callable[[list[str]], list[dict[str, Any]]] | None = None, + augment_assistant_text: Callable[[str], str] | None = None, ) -> dict[str, Any] | None: """Return a payload compatible with ``WebuiThreadPersistedPayload``.""" lines = read_transcript_lines(session_key) if not lines: return None - msgs = replay_transcript_to_ui_messages(lines, augment_user_media=augment_user_media) + msgs = replay_transcript_to_ui_messages( + lines, + augment_user_media=augment_user_media, + augment_assistant_text=augment_assistant_text, + ) return { "schemaVersion": WEBUI_TRANSCRIPT_SCHEMA_VERSION, "sessionKey": session_key, diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index 74a780c80..21f22d363 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -480,6 +480,38 @@ async def test_send_delta_emits_delta_and_stream_end() -> None: assert second["stream_id"] == "sid" +@pytest.mark.asyncio +async def test_send_delta_stream_end_rewrites_local_markdown_image(monkeypatch, tmp_path) -> None: + bus = MagicMock() + workspace = tmp_path / "workspace" + workspace.mkdir() + (workspace / "diagram.png").write_bytes(b"\x89PNG\r\n\x1a\nimage") + media = tmp_path / "media" + + def fake_media_dir(channel: str | None = None): + path = media / channel if channel else media + path.mkdir(parents=True, exist_ok=True) + return path + + monkeypatch.setattr("nanobot.channels.websocket.get_media_dir", fake_media_dir) + channel = WebSocketChannel( + {"enabled": True, "allowFrom": ["*"], "streaming": True}, + bus, + workspace_path=workspace, + ) + mock_ws = AsyncMock() + channel._attach(mock_ws, "chat-1") + + await channel.send_delta("chat-1", "![Diagram](", {"_stream_delta": True, "_stream_id": "sid"}) + await channel.send_delta("chat-1", "diagram.png)", {"_stream_delta": True, "_stream_id": "sid"}) + await channel.send_delta("chat-1", "", {"_stream_end": True, "_stream_id": "sid"}) + + assert mock_ws.send.await_count == 3 + final = json.loads(mock_ws.send.call_args_list[2][0][0]) + assert final["event"] == "stream_end" + assert final["text"].startswith("![Diagram](/api/media/") + + @pytest.mark.asyncio async def test_send_reasoning_delta_emits_streaming_frame() -> None: bus = MagicMock() diff --git a/tests/channels/test_websocket_media_route.py b/tests/channels/test_websocket_media_route.py index 0e08dc14b..48d102cd0 100644 --- a/tests/channels/test_websocket_media_route.py +++ b/tests/channels/test_websocket_media_route.py @@ -44,6 +44,7 @@ def _ch( bus: Any, *, session_manager: SessionManager | None = None, + workspace_path: Path | None = None, port: int, ) -> WebSocketChannel: return WebSocketChannel( @@ -57,6 +58,7 @@ def _ch( }, bus, session_manager=session_manager, + workspace_path=workspace_path, ) @@ -67,6 +69,15 @@ def bus() -> MagicMock: return b +def _fake_media_dir(root: Path): + def inner(channel: str | None = None) -> Path: + path = root / channel if channel else root + path.mkdir(parents=True, exist_ok=True) + return path + + return inner + + async def _http_get( url: str, headers: dict[str, str] | None = None ) -> httpx.Response: @@ -123,6 +134,45 @@ def test_sign_media_path_round_trips_via_hmac( assert _b64url_decode(payload).decode() == "a.png" +def test_local_markdown_image_is_staged_and_rewritten( + bus: MagicMock, + tmp_path: Path, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + (workspace / "demo_arch.png").write_bytes(_PNG_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![Cloud Architecture Diagram](demo_arch.png)" + ) + + assert "![Cloud Architecture Diagram](/api/media/" in rewritten + staged = list((media / "websocket").iterdir()) + assert len(staged) == 1 + assert staged[0].read_bytes() == _PNG_BYTES + + +def test_local_markdown_image_rejects_workspace_escape( + bus: MagicMock, + tmp_path: Path, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + outside = tmp_path / "outside.png" + outside.write_bytes(_PNG_BYTES) + media = tmp_path / "media" + channel = _ch(bus, workspace_path=workspace, port=0) + text = "![nope](../outside.png)" + + with patch("nanobot.channels.websocket.get_media_dir", side_effect=_fake_media_dir(media)): + assert channel._rewrite_local_markdown_images(text) == text + + assert not (media / "websocket").exists() + + # --------------------------------------------------------------------------- # /api/media//: the serving handler # --------------------------------------------------------------------------- diff --git a/tests/cli_apps/test_service.py b/tests/cli_apps/test_service.py index 3b6eb93ff..fda0199b5 100644 --- a/tests/cli_apps/test_service.py +++ b/tests/cli_apps/test_service.py @@ -372,6 +372,33 @@ def test_run_installed_cli_uses_argv_without_shell( assert "['--json', 'project', 'list']" in result +def test_run_reports_created_artifacts( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager = _manager(tmp_path) + _seed_catalog(manager) + resolved = str(tmp_path / "bin" / "cli-anything-gimp") + monkeypatch.setattr( + "nanobot.cli_apps.service.shutil.which", + lambda entry: resolved if entry == "cli-anything-gimp" else None, + ) + + def fake_run(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + cwd = Path(str(kwargs["cwd"])) + (cwd / "diagram.png").write_bytes(b"\x89PNG\r\n\x1a\nimage") + return subprocess.CompletedProcess(argv, 0, stdout="done", stderr="") + + monkeypatch.setattr("nanobot.cli_apps.service.subprocess.run", fake_run) + manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}}) + + result = manager.run("gimp", ["render"]) + + assert "Artifacts created or updated:" in result + assert "diagram.png (previewable image" in result + assert "![diagram](diagram.png)" in result + + def test_run_blocks_working_dir_outside_workspace(tmp_path: Path) -> None: manager = _manager(tmp_path) _seed_catalog(manager) diff --git a/tests/utils/test_webui_transcript.py b/tests/utils/test_webui_transcript.py index 6c855462c..b84035072 100644 --- a/tests/utils/test_webui_transcript.py +++ b/tests/utils/test_webui_transcript.py @@ -42,6 +42,19 @@ def test_replay_delta_and_turn_end(tmp_path, monkeypatch) -> None: assert msgs[1]["latencyMs"] == 42 +def test_replay_augments_assistant_text() -> None: + msgs = replay_transcript_to_ui_messages( + [ + {"event": "user", "chat_id": "t-img", "text": "draw"}, + {"event": "delta", "chat_id": "t-img", "text": "![Diagram](diagram.png)"}, + {"event": "stream_end", "chat_id": "t-img"}, + ], + augment_assistant_text=lambda text: text.replace("diagram.png", "/api/media/sig/payload"), + ) + + assert msgs[1]["content"] == "![Diagram](/api/media/sig/payload)" + + 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 0355b3176..2e18c3df4 100644 --- a/webui/src/components/MarkdownTextRenderer.tsx +++ b/webui/src/components/MarkdownTextRenderer.tsx @@ -109,6 +109,46 @@ export default function MarkdownTextRenderer({ ); }, + img({ src, alt, node: _node, className: imgClassName, ...props }) { + void _node; + const source = typeof src === "string" ? src : ""; + if (!source) return null; + const label = typeof alt === "string" ? alt : ""; + return ( + + + {label} + + {label ? ( + + {label} + + ) : null} + + ); + }, }), [highlightCode], ); diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts index 956eebd57..90be81733 100644 --- a/webui/src/hooks/useNanobotStream.ts +++ b/webui/src/hooks/useNanobotStream.ts @@ -193,6 +193,15 @@ function stampLastAssistantLatency(prev: UIMessage[], latencyMs: number): UIMess return prev; } +function findLatestAssistantAnswerIndex(prev: UIMessage[]): number | null { + for (let i = prev.length - 1; i >= 0; i -= 1) { + const m = prev[i]; + if (m.role === "assistant" && m.kind !== "trace") return i; + if (m.role === "user") break; + } + return null; +} + function absorbCompleteAssistantMessage( prev: UIMessage[], message: Omit, @@ -489,23 +498,41 @@ export function useNanobotStream( [appendAnswerChunk, ensureActivitySegmentId], ); - const flushPendingStreamEvents = useCallback((options?: { closeAnswerSegment?: boolean }) => { + const flushPendingStreamEvents = useCallback((options?: { + closeAnswerSegment?: boolean; + finalAnswerText?: string; + }) => { if (streamFrameRef.current !== null) { window.cancelAnimationFrame(streamFrameRef.current); streamFrameRef.current = null; } const events = pendingStreamEventsRef.current; - if (events.length === 0) { + const finalAnswerText = options?.finalAnswerText; + if (events.length === 0 && finalAnswerText === undefined) { if (options?.closeAnswerSegment) closeActiveAssistantStream(); return; } pendingStreamEventsRef.current = []; setMessages((prev) => { - const next = applyPendingStreamEvents(prev, events); + let next = events.length > 0 ? applyPendingStreamEvents(prev, events) : prev; + if (finalAnswerText !== undefined) { + const targetIndex = + resolveActiveAssistantIndex(next) + ?? findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current) + ?? findLatestAssistantAnswerIndex(next); + if (targetIndex !== null) { + const target = next[targetIndex]; + next = replaceMessageAt(next, targetIndex, { + ...target, + content: finalAnswerText, + isStreaming: true, + }); + } + } if (options?.closeAnswerSegment) closeActiveAssistantStream(); return next; }); - }, [applyPendingStreamEvents, closeActiveAssistantStream]); + }, [applyPendingStreamEvents, closeActiveAssistantStream, resolveActiveAssistantIndex]); const schedulePendingStreamFlush = useCallback(() => { if (streamFrameRef.current !== null) return; @@ -583,7 +610,10 @@ export function useNanobotStream( } if (ev.event === "stream_end") { - flushPendingStreamEvents({ closeAnswerSegment: true }); + flushPendingStreamEvents({ + closeAnswerSegment: true, + ...(typeof ev.text === "string" ? { finalAnswerText: ev.text } : {}), + }); if (suppressStreamUntilTurnEndRef.current) return; // stream_end only means the text segment finished — the model may // still be executing tools. Do NOT reset isStreaming here; the diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 52e13758f..05945720f 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -378,6 +378,7 @@ export type InboundEvent = event: "stream_end"; chat_id: string; stream_id?: string; + text?: string; } | { event: "reasoning_delta"; diff --git a/webui/src/tests/markdown-text-renderer.test.tsx b/webui/src/tests/markdown-text-renderer.test.tsx new file mode 100644 index 000000000..740c01a40 --- /dev/null +++ b/webui/src/tests/markdown-text-renderer.test.tsx @@ -0,0 +1,18 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import MarkdownTextRenderer from "@/components/MarkdownTextRenderer"; + +describe("MarkdownTextRenderer", () => { + it("renders markdown images as inline previews", () => { + render(![Diagram](/api/media/sig/payload)); + + const image = screen.getByRole("img", { name: "Diagram" }); + expect(image).toHaveAttribute("src", "/api/media/sig/payload"); + expect(screen.getByRole("link", { name: "Open Diagram" })).toHaveAttribute( + "href", + "/api/media/sig/payload", + ); + expect(screen.getByText("Diagram")).toBeInTheDocument(); + }); +}); diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx index d8a91bf35..8dd10e82a 100644 --- a/webui/src/tests/useNanobotStream.test.tsx +++ b/webui/src/tests/useNanobotStream.test.tsx @@ -1266,6 +1266,38 @@ describe("useNanobotStream", () => { expect(onTurnEnd).toHaveBeenCalledTimes(1); }); + it("replaces streamed content with final stream_end text when provided", async () => { + const fake = fakeClient(); + const { result } = renderHook(() => useNanobotStream("chat-stream-final", EMPTY_MESSAGES), { + wrapper: wrap(fake.client), + }); + + act(() => { + fake.emit("chat-stream-final", { + event: "delta", + chat_id: "chat-stream-final", + text: "![Diagram](diagram.png)", + }); + }); + + await flushStreamFrame(); + + act(() => { + fake.emit("chat-stream-final", { + event: "stream_end", + chat_id: "chat-stream-final", + text: "![Diagram](/api/media/sig/payload)", + }); + }); + + expect(result.current.messages).toHaveLength(1); + expect(result.current.messages[0]).toMatchObject({ + role: "assistant", + content: "![Diagram](/api/media/sig/payload)", + isStreaming: true, + }); + }); + it("stamps latency on the last assistant bubble from turn_end", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-lat", EMPTY_MESSAGES), {