mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-13 22:34:06 +00:00
fix(webui): render local CLI image artifacts
This commit is contained in:
parent
9efdce276f
commit
c9ff64fc0f
@ -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)
|
||||
|
||||
@ -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""
|
||||
|
||||
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)
|
||||
|
||||
@ -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 ``."
|
||||
)
|
||||
return _truncate("\n".join(output))
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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", "
|
||||
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("
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_reasoning_delta_emits_streaming_frame() -> None:
|
||||
bus = MagicMock()
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||
assert ".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 = ""
|
||||
|
||||
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/<sig>/<payload>: the serving handler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@ -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 "" in result
|
||||
|
||||
|
||||
def test_run_blocks_working_dir_outside_workspace(tmp_path: Path) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
_seed_catalog(manager)
|
||||
|
||||
@ -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": ""},
|
||||
{"event": "stream_end", "chat_id": "t-img"},
|
||||
],
|
||||
augment_assistant_text=lambda text: text.replace("diagram.png", "/api/media/sig/payload"),
|
||||
)
|
||||
|
||||
assert msgs[1]["content"] == ""
|
||||
|
||||
|
||||
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"
|
||||
|
||||
@ -109,6 +109,46 @@ export default function MarkdownTextRenderer({
|
||||
</a>
|
||||
);
|
||||
},
|
||||
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 (
|
||||
<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",
|
||||
)}
|
||||
>
|
||||
<a
|
||||
href={source}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="block bg-muted/20"
|
||||
aria-label={label ? `Open ${label}` : "Open image"}
|
||||
>
|
||||
<img
|
||||
src={source}
|
||||
alt={label}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
className={cn(
|
||||
"block h-auto max-h-[34rem] max-w-full bg-background object-contain",
|
||||
imgClassName,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</a>
|
||||
{label ? (
|
||||
<span className="block max-w-full truncate px-3 py-2 text-xs text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
}),
|
||||
[highlightCode],
|
||||
);
|
||||
|
||||
@ -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<UIMessage, "id" | "role" | "createdAt">,
|
||||
@ -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
|
||||
|
||||
@ -378,6 +378,7 @@ export type InboundEvent =
|
||||
event: "stream_end";
|
||||
chat_id: string;
|
||||
stream_id?: string;
|
||||
text?: string;
|
||||
}
|
||||
| {
|
||||
event: "reasoning_delta";
|
||||
|
||||
18
webui/src/tests/markdown-text-renderer.test.tsx
Normal file
18
webui/src/tests/markdown-text-renderer.test.tsx
Normal file
@ -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(<MarkdownTextRenderer></MarkdownTextRenderer>);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@ -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: "",
|
||||
});
|
||||
});
|
||||
|
||||
await flushStreamFrame();
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-stream-final", {
|
||||
event: "stream_end",
|
||||
chat_id: "chat-stream-final",
|
||||
text: "",
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "",
|
||||
isStreaming: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("stamps latency on the last assistant bubble from turn_end", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-lat", EMPTY_MESSAGES), {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user