fix(webui): render local CLI image artifacts
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user