nanobot/nanobot/webui/file_preview.py
Xubin Ren ab9f49970d
feat(desktop): polish desktop shell and shared WebUI surfaces (#4195)
* feat(desktop): add native host scaffold

* feat(webui): track turns and usage in gateway

* feat(webui): polish desktop chat experience

* feat(apps): add ArcGIS and Joplin logos

* feat(desktop): polish shell and shared surfaces

* fix(webui): avoid preview chips for glob references

* test: align CI expectations for token fallback

* feat(webui): preview prompt rail entries

* feat(webui): add prompt navigator drawer

* style(webui): refine prompt navigator placement

* style(webui): align prompt navigator with header actions

* style(webui): simplify prompt navigator header

* refactor(webui): clean thread resource refresh

* feat(desktop): add native reply notifications

* fix(webui): preserve desktop restart and replay state

* fix(desktop): harden gateway proxy startup

* fix(web): fall back when readability is unavailable

* fix(desktop): hide window instead of closing on macos

* fix(webui): unify desktop header actions

* fix(webui): simplify prompt history rows

* fix(desktop): log notification delivery failures

* chore(desktop): clean source package artifacts

* fix(cron): support one-time relative reminders

* fix(webui): reveal scroll button in place

* Revert "fix(cron): support one-time relative reminders"

This reverts commit 4c4661da120a3c7283e0768412bae48604e7390b.

* refactor(webui): extract token usage heatmap

* docs(desktop): clarify contributor guides

---------

Co-authored-by: chengyongru <2755839590@qq.com>
2026-06-06 19:49:33 +08:00

138 lines
3.9 KiB
Python

"""Workspace-scoped source preview payloads for the WebUI."""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
from urllib.parse import unquote, urlparse
from nanobot.security.workspace_access import WorkspaceScope
from nanobot.security.workspace_policy import WorkspaceBoundaryError, resolve_allowed_path
MAX_FILE_PREVIEW_BYTES = 384 * 1024
class WebUIFilePreviewError(ValueError):
"""Raised when a file cannot be previewed through the WebUI."""
def __init__(self, status: int, message: str) -> None:
super().__init__(message)
self.status = status
self.message = message
def file_preview_payload(
raw_path: str | None,
*,
scope: WorkspaceScope,
max_bytes: int = MAX_FILE_PREVIEW_BYTES,
) -> dict[str, Any]:
"""Return a text preview for a file inside the session workspace."""
path = _clean_preview_path(raw_path)
if not path:
raise WebUIFilePreviewError(400, "missing path")
if len(path) > 4096:
raise WebUIFilePreviewError(400, "path is too long")
try:
resolved = resolve_allowed_path(
path,
workspace=scope.project_path,
allowed_root=scope.project_path,
strict=True,
)
except FileNotFoundError as e:
raise WebUIFilePreviewError(404, "file not found") from e
except WorkspaceBoundaryError as e:
raise WebUIFilePreviewError(403, "file is outside the current workspace") from e
except OSError as e:
raise WebUIFilePreviewError(400, "invalid path") from e
if not resolved.is_file():
raise WebUIFilePreviewError(404, "file not found")
try:
with open(resolved, "rb") as f:
raw = f.read(max_bytes + 1)
except OSError as e:
raise WebUIFilePreviewError(500, "failed to read file") from e
if b"\0" in raw[:4096]:
raise WebUIFilePreviewError(415, "binary files cannot be previewed")
truncated = len(raw) > max_bytes
preview_bytes = raw[:max_bytes]
try:
content = preview_bytes.decode("utf-8")
except UnicodeDecodeError:
content = preview_bytes.decode("utf-8", errors="replace")
display_path = _display_path(resolved, scope.project_path)
return {
"path": str(resolved),
"display_path": display_path,
"project_path": str(scope.project_path),
"language": _language_for_path(resolved),
"content": content,
"size": resolved.stat().st_size,
"truncated": truncated,
}
def _clean_preview_path(raw_path: str | None) -> str:
if raw_path is None:
return ""
value = raw_path.strip()
if not value:
return ""
if value.startswith("file://"):
parsed = urlparse(value)
value = unquote(parsed.path)
if re.match(r"^/[A-Za-z]:[\\/]", value):
value = value[1:]
else:
value = unquote(value)
value = value.split("?", 1)[0].split("#", 1)[0].strip()
if not re.match(r"^[A-Za-z]:[\\/]", value):
value = re.sub(r":\d+(?::\d+)?$", "", value)
return value
def _display_path(path: Path, root: Path) -> str:
try:
return path.relative_to(root).as_posix()
except ValueError:
return path.as_posix()
def _language_for_path(path: Path) -> str:
name = path.name.lower()
ext = path.suffix.lower().lstrip(".")
if name == "dockerfile":
return "dockerfile"
return {
"cjs": "javascript",
"css": "css",
"cts": "typescript",
"html": "html",
"js": "javascript",
"json": "json",
"jsonl": "json",
"jsx": "jsx",
"md": "markdown",
"mdx": "markdown",
"mjs": "javascript",
"mts": "typescript",
"py": "python",
"pyi": "python",
"scss": "scss",
"sh": "bash",
"toml": "toml",
"ts": "typescript",
"tsx": "tsx",
"yaml": "yaml",
"yml": "yaml",
}.get(ext, ext or "text")