mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 17:38:35 +00:00
* 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>
138 lines
3.9 KiB
Python
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")
|