feat(webui): support image uploads in composer and message bubbles
This commit is contained in:
+10
-8
@@ -858,15 +858,17 @@ class AgentLoop:
|
||||
)
|
||||
)
|
||||
|
||||
# Persist the triggering user message immediately, before running the
|
||||
# agent loop. If the process is killed mid-turn (OOM, SIGKILL, self-
|
||||
# restart, etc.), the existing runtime_checkpoint preserves the
|
||||
# in-flight assistant/tool state but NOT the user message itself, so
|
||||
# the user's prompt is silently lost on recovery. Saving it up front
|
||||
# makes recovery possible from the session log alone.
|
||||
# Persist the triggering user message up front so a mid-turn crash
|
||||
# doesn't silently lose the prompt on recovery. ``media`` rides along
|
||||
# as raw on-disk paths — sanitized image blocks are stripped from
|
||||
# JSONL, and webui replay needs the paths to mint signed URLs.
|
||||
user_persisted_early = False
|
||||
if isinstance(msg.content, str) and msg.content.strip():
|
||||
session.add_message("user", msg.content)
|
||||
media_paths = [p for p in (msg.media or []) if isinstance(p, str) and p]
|
||||
has_text = isinstance(msg.content, str) and msg.content.strip()
|
||||
if has_text or media_paths:
|
||||
extra: dict[str, Any] = {"media": list(media_paths)} if media_paths else {}
|
||||
text = msg.content if isinstance(msg.content, str) else ""
|
||||
session.add_message("user", text, **extra)
|
||||
self._mark_pending_user_turn(session)
|
||||
self.sessions.save(session)
|
||||
user_persisted_early = True
|
||||
|
||||
+12
-29
@@ -7,13 +7,9 @@ All requests route to a single persistent API session.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json as _json
|
||||
import mimetypes
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
@@ -21,14 +17,20 @@ from loguru import logger
|
||||
|
||||
from nanobot.config.paths import get_media_dir
|
||||
from nanobot.utils.helpers import safe_filename
|
||||
from nanobot.utils.media_decode import (
|
||||
FileSizeExceeded as _FileSizeExceeded,
|
||||
MAX_FILE_SIZE,
|
||||
save_base64_data_url as _save_base64_data_url,
|
||||
)
|
||||
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
|
||||
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
|
||||
_DATA_URL_RE = re.compile(r"^data:([^;]+);base64,(.+)$", re.DOTALL)
|
||||
|
||||
|
||||
class _FileSizeExceeded(Exception):
|
||||
"""Raised when an uploaded file exceeds the size limit."""
|
||||
__all__ = (
|
||||
"MAX_FILE_SIZE",
|
||||
"_FileSizeExceeded",
|
||||
"_save_base64_data_url",
|
||||
"create_app",
|
||||
"handle_chat_completions",
|
||||
)
|
||||
|
||||
|
||||
API_SESSION_KEY = "api:default"
|
||||
@@ -102,25 +104,6 @@ _SSE_DONE = b"data: [DONE]\n\n"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _save_base64_data_url(data_url: str, media_dir: Path) -> str | None:
|
||||
"""Decode a data:...;base64,... URL and save to disk."""
|
||||
m = _DATA_URL_RE.match(data_url)
|
||||
if not m:
|
||||
return None
|
||||
mime_type, b64_payload = m.group(1), m.group(2)
|
||||
try:
|
||||
raw = base64.b64decode(b64_payload)
|
||||
except Exception:
|
||||
return None
|
||||
if len(raw) > MAX_FILE_SIZE:
|
||||
raise _FileSizeExceeded(f"File exceeds {MAX_FILE_SIZE // (1024 * 1024)}MB limit")
|
||||
ext = mimetypes.guess_extension(mime_type) or ".bin"
|
||||
filename = f"{uuid.uuid4().hex[:12]}{ext}"
|
||||
dest = media_dir / safe_filename(filename)
|
||||
dest.write_bytes(raw)
|
||||
return str(dest)
|
||||
|
||||
|
||||
def _parse_json_content(body: dict) -> tuple[str, list[str]]:
|
||||
"""Parse JSON request body. Returns (text, media_paths)."""
|
||||
messages = body.get("messages")
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
import email.utils
|
||||
import hashlib
|
||||
import hmac
|
||||
import http
|
||||
import json
|
||||
@@ -28,7 +31,12 @@ from websockets.http11 import Response
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.paths import get_media_dir
|
||||
from nanobot.config.schema import Base
|
||||
from nanobot.utils.media_decode import (
|
||||
FileSizeExceeded,
|
||||
save_base64_data_url,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.session.manager import SessionManager
|
||||
@@ -75,7 +83,11 @@ class WebSocketConfig(Base):
|
||||
websocket_requires_token: bool = True
|
||||
allow_from: list[str] = Field(default_factory=lambda: ["*"])
|
||||
streaming: bool = True
|
||||
max_message_bytes: int = Field(default=1_048_576, ge=1024, le=16_777_216)
|
||||
# Default 36 MB, upper 40 MB: supports up to 4 images at ~6 MB each after
|
||||
# client-side Worker normalization (see webui Composer). 4 × 6 MB × 1.37
|
||||
# (base64 overhead) + envelope framing stays under 36 MB; the 40 MB ceiling
|
||||
# leaves a small margin for sender slop without opening a DoS avenue.
|
||||
max_message_bytes: int = Field(default=37_748_736, ge=1024, le=41_943_040)
|
||||
ping_interval_s: float = Field(default=20.0, ge=5.0, le=300.0)
|
||||
ping_timeout_s: float = Field(default=20.0, ge=5.0, le=300.0)
|
||||
ssl_certfile: str = ""
|
||||
@@ -206,6 +218,35 @@ def _parse_envelope(raw: str) -> dict[str, Any] | None:
|
||||
return data
|
||||
|
||||
|
||||
# Per-message image limits. The server-side guard is a touch looser than the
|
||||
# client's ``Worker`` normalization target (6 MB) — tolerate client slop, but
|
||||
# still cap total ingress at ``_MAX_IMAGES_PER_MESSAGE * _MAX_IMAGE_BYTES``
|
||||
# which fits comfortably inside ``max_message_bytes``.
|
||||
_MAX_IMAGES_PER_MESSAGE = 4
|
||||
_MAX_IMAGE_BYTES = 8 * 1024 * 1024
|
||||
|
||||
# Image MIME whitelist — matches the Composer's ``accept`` list. SVG is
|
||||
# explicitly excluded to avoid the XSS surface inside embedded scripts.
|
||||
_IMAGE_MIME_ALLOWED: frozenset[str] = frozenset({
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
})
|
||||
|
||||
_DATA_URL_MIME_RE = re.compile(r"^data:([^;]+);base64,", re.DOTALL)
|
||||
|
||||
|
||||
def _extract_data_url_mime(url: str) -> str | None:
|
||||
"""Return the MIME type of a ``data:<mime>;base64,...`` URL, else ``None``."""
|
||||
if not isinstance(url, str):
|
||||
return None
|
||||
m = _DATA_URL_MIME_RE.match(url)
|
||||
if not m:
|
||||
return None
|
||||
return m.group(1).strip().lower() or None
|
||||
|
||||
|
||||
_LOCALHOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
|
||||
|
||||
# Matches the legacy chat-id pattern but allows file-system-safe stems too,
|
||||
@@ -278,6 +319,29 @@ def _is_websocket_upgrade(request: WsRequest) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _b64url_encode(data: bytes) -> str:
|
||||
"""URL-safe base64 without padding — compact + friendly in URL paths."""
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _b64url_decode(s: str) -> bytes:
|
||||
"""Reverse of :func:`_b64url_encode`; caller handles ``ValueError``."""
|
||||
pad = "=" * (-len(s) % 4)
|
||||
return base64.urlsafe_b64decode(s + pad)
|
||||
|
||||
|
||||
# Allowed MIME types we actually serve from the media endpoint. Anything
|
||||
# outside this set is degraded to ``application/octet-stream`` so an
|
||||
# attacker who somehow gets a signed URL for an unexpected file type can't
|
||||
# trick the browser into sniffing executable content.
|
||||
_MEDIA_ALLOWED_MIMES: frozenset[str] = frozenset({
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
})
|
||||
|
||||
|
||||
def _issue_route_secret_matches(headers: Any, configured_secret: str) -> bool:
|
||||
"""Return True if the token-issue HTTP request carries credentials matching ``token_issue_secret``."""
|
||||
if not configured_secret:
|
||||
@@ -326,6 +390,11 @@ class WebSocketChannel(BaseChannel):
|
||||
self._static_dist_path: Path | None = (
|
||||
static_dist_path.resolve() if static_dist_path is not None else None
|
||||
)
|
||||
# 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
|
||||
# become self-expiring (callers just refresh the session list).
|
||||
self._media_secret: bytes = secrets.token_bytes(32)
|
||||
|
||||
# -- Subscription bookkeeping -------------------------------------------
|
||||
|
||||
@@ -457,6 +526,14 @@ class WebSocketChannel(BaseChannel):
|
||||
if m:
|
||||
return self._handle_session_delete(request, m.group(1))
|
||||
|
||||
# Signed media fetch: ``<sig>`` is an HMAC over ``<payload>``; the
|
||||
# payload decodes to a path inside :func:`get_media_dir`. See
|
||||
# :meth:`_sign_media_path` for the inverse direction used to build
|
||||
# these URLs when replaying a session.
|
||||
m = re.match(r"^/api/media/([A-Za-z0-9_-]+)/([A-Za-z0-9_-]+)$", got)
|
||||
if m:
|
||||
return self._handle_media_fetch(m.group(1), m.group(2))
|
||||
|
||||
# 4. WebSocket upgrade (the channel's primary purpose). Only run the
|
||||
# handshake gate on requests that actually ask to upgrade; otherwise
|
||||
# a bare ``GET /`` from the browser would be rejected as an
|
||||
@@ -568,8 +645,109 @@ class WebSocketChannel(BaseChannel):
|
||||
data = self._session_manager.read_session_file(decoded_key)
|
||||
if data is None:
|
||||
return _http_error(404, "session not found")
|
||||
# Decorate persisted user messages with signed media URLs so the
|
||||
# client can render previews. The raw on-disk ``media`` paths are
|
||||
# stripped on the way out — they leak server filesystem layout and
|
||||
# the client never needs them once it has the signed fetch URL.
|
||||
self._augment_media_urls(data)
|
||||
return _http_json_response(data)
|
||||
|
||||
def _augment_media_urls(self, payload: dict[str, Any]) -> None:
|
||||
"""Mutate *payload* in place: each message's ``media`` path list is
|
||||
replaced by a parallel ``media_urls`` list of signed fetch URLs.
|
||||
|
||||
Messages without media or with non-string path entries are left
|
||||
untouched. Paths that no longer live inside ``media_dir`` (e.g. the
|
||||
file was deleted, or the dir was relocated) are silently skipped;
|
||||
the client falls back to the historical-replay placeholder tile.
|
||||
"""
|
||||
messages = payload.get("messages")
|
||||
if not isinstance(messages, list):
|
||||
return
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
media = msg.get("media")
|
||||
if not isinstance(media, list) or not media:
|
||||
continue
|
||||
urls: list[dict[str, str]] = []
|
||||
for entry in media:
|
||||
if not isinstance(entry, str) or not entry:
|
||||
continue
|
||||
signed = self._sign_media_path(Path(entry))
|
||||
if signed is None:
|
||||
continue
|
||||
urls.append({"url": signed, "name": Path(entry).name})
|
||||
if urls:
|
||||
msg["media_urls"] = urls
|
||||
# Always drop the raw paths from the wire payload.
|
||||
msg.pop("media", None)
|
||||
|
||||
def _sign_media_path(self, abs_path: Path) -> str | None:
|
||||
"""Return a ``/api/media/<sig>/<payload>`` URL for *abs_path*, or
|
||||
``None`` when the path does not resolve inside the media root.
|
||||
|
||||
The URL is self-authenticating: the signature binds the payload to
|
||||
this process's ``_media_secret``, so only paths we chose to sign can
|
||||
be fetched. The returned path is relative to the server origin; the
|
||||
client joins it against the existing webui base.
|
||||
"""
|
||||
try:
|
||||
media_root = get_media_dir().resolve()
|
||||
rel = abs_path.resolve().relative_to(media_root)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
payload = _b64url_encode(rel.as_posix().encode("utf-8"))
|
||||
mac = hmac.new(
|
||||
self._media_secret, payload.encode("ascii"), hashlib.sha256
|
||||
).digest()[:16]
|
||||
return f"/api/media/{_b64url_encode(mac)}/{payload}"
|
||||
|
||||
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
|
||||
payload to a relative path, and streams the file bytes with a
|
||||
long-lived immutable cache header (the URL already encodes the
|
||||
file identity, so caches can be aggressive)."""
|
||||
try:
|
||||
provided_mac = _b64url_decode(sig)
|
||||
except (ValueError, binascii.Error):
|
||||
return _http_error(401, "invalid signature")
|
||||
expected_mac = hmac.new(
|
||||
self._media_secret, payload.encode("ascii"), hashlib.sha256
|
||||
).digest()[:16]
|
||||
if not hmac.compare_digest(expected_mac, provided_mac):
|
||||
return _http_error(401, "invalid signature")
|
||||
try:
|
||||
rel_bytes = _b64url_decode(payload)
|
||||
rel_str = rel_bytes.decode("utf-8")
|
||||
except (ValueError, binascii.Error, UnicodeDecodeError):
|
||||
return _http_error(400, "invalid payload")
|
||||
# An attacker who somehow bypassed the HMAC check would still need
|
||||
# the resolved path to escape the media root; guard defensively.
|
||||
try:
|
||||
media_root = get_media_dir().resolve()
|
||||
candidate = (media_root / rel_str).resolve()
|
||||
candidate.relative_to(media_root)
|
||||
except (OSError, ValueError):
|
||||
return _http_error(404, "not found")
|
||||
if not candidate.is_file():
|
||||
return _http_error(404, "not found")
|
||||
try:
|
||||
body = candidate.read_bytes()
|
||||
except OSError:
|
||||
return _http_error(500, "read error")
|
||||
mime, _ = mimetypes.guess_type(candidate.name)
|
||||
if mime not in _MEDIA_ALLOWED_MIMES:
|
||||
mime = "application/octet-stream"
|
||||
return _http_response(
|
||||
body,
|
||||
content_type=mime,
|
||||
extra_headers=[
|
||||
("Cache-Control", "private, max-age=31536000, immutable"),
|
||||
],
|
||||
)
|
||||
|
||||
def _handle_session_delete(self, request: WsRequest, key: str) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
@@ -755,6 +933,48 @@ class WebSocketChannel(BaseChannel):
|
||||
finally:
|
||||
self._cleanup_connection(connection)
|
||||
|
||||
@staticmethod
|
||||
def _save_envelope_media(
|
||||
media: list[Any],
|
||||
) -> tuple[list[str], str | None]:
|
||||
"""Decode and persist ``media`` items from a ``message`` envelope.
|
||||
|
||||
Returns ``(paths, None)`` on success or ``([], reason)`` on the first
|
||||
failure — the caller is expected to surface ``reason`` to the client
|
||||
and skip publishing so no half-formed message ever reaches the agent.
|
||||
``reason`` is a short, stable token suitable for UI localization.
|
||||
|
||||
Shape: ``list[{"data_url": str, "name"?: str | None}]``.
|
||||
"""
|
||||
if len(media) > _MAX_IMAGES_PER_MESSAGE:
|
||||
return [], "too_many_images"
|
||||
media_dir = get_media_dir("websocket")
|
||||
paths: list[str] = []
|
||||
for item in media:
|
||||
if not isinstance(item, dict):
|
||||
return [], "malformed"
|
||||
data_url = item.get("data_url")
|
||||
if not isinstance(data_url, str) or not data_url:
|
||||
return [], "malformed"
|
||||
mime = _extract_data_url_mime(data_url)
|
||||
if mime is None:
|
||||
return [], "decode"
|
||||
if mime not in _IMAGE_MIME_ALLOWED:
|
||||
return [], "mime"
|
||||
try:
|
||||
saved = save_base64_data_url(
|
||||
data_url, media_dir, max_bytes=_MAX_IMAGE_BYTES,
|
||||
)
|
||||
except FileSizeExceeded:
|
||||
return [], "size"
|
||||
except Exception as exc:
|
||||
logger.warning("websocket: media decode failed: {}", exc)
|
||||
return [], "decode"
|
||||
if saved is None:
|
||||
return [], "decode"
|
||||
paths.append(saved)
|
||||
return paths, None
|
||||
|
||||
async def _dispatch_envelope(
|
||||
self,
|
||||
connection: Any,
|
||||
@@ -782,15 +1002,39 @@ class WebSocketChannel(BaseChannel):
|
||||
if not _is_valid_chat_id(cid):
|
||||
await self._send_event(connection, "error", detail="invalid chat_id")
|
||||
return
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
if not isinstance(content, str):
|
||||
await self._send_event(connection, "error", detail="missing content")
|
||||
return
|
||||
|
||||
raw_media = envelope.get("media")
|
||||
media_paths: list[str] = []
|
||||
if raw_media is not None:
|
||||
if not isinstance(raw_media, list):
|
||||
await self._send_event(
|
||||
connection, "error",
|
||||
detail="image_rejected", reason="malformed",
|
||||
)
|
||||
return
|
||||
media_paths, reason = self._save_envelope_media(raw_media)
|
||||
if reason is not None:
|
||||
await self._send_event(
|
||||
connection, "error",
|
||||
detail="image_rejected", reason=reason,
|
||||
)
|
||||
return
|
||||
|
||||
# Allow image-only turns (content may be empty when media is attached).
|
||||
if not content.strip() and not media_paths:
|
||||
await self._send_event(connection, "error", detail="missing content")
|
||||
return
|
||||
|
||||
# Auto-attach on first use so clients can one-shot without a separate attach.
|
||||
self._attach(connection, cid)
|
||||
await self._handle_message(
|
||||
sender_id=client_id,
|
||||
chat_id=cid,
|
||||
content=content,
|
||||
media=media_paths or None,
|
||||
metadata={"remote": getattr(connection, "remote_address", None)},
|
||||
)
|
||||
return
|
||||
|
||||
@@ -11,7 +11,12 @@ from typing import Any
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.config.paths import get_legacy_sessions_dir
|
||||
from nanobot.utils.helpers import ensure_dir, find_legal_message_start, safe_filename
|
||||
from nanobot.utils.helpers import (
|
||||
ensure_dir,
|
||||
find_legal_message_start,
|
||||
image_placeholder_text,
|
||||
safe_filename,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -54,7 +59,19 @@ class Session:
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
for message in sliced:
|
||||
entry: dict[str, Any] = {"role": message["role"], "content": message.get("content", "")}
|
||||
content = message.get("content", "")
|
||||
# Synthesize an ``[image: path]`` breadcrumb from the persisted
|
||||
# ``media`` kwarg so LLM replay still sees *something* where the
|
||||
# image used to be. Without this, an image-only user turn
|
||||
# replays as an empty user message — the assistant's reply then
|
||||
# looks like it's responding to nothing.
|
||||
media = message.get("media")
|
||||
if isinstance(media, list) and media and isinstance(content, str):
|
||||
breadcrumbs = "\n".join(
|
||||
image_placeholder_text(p) for p in media if isinstance(p, str) and p
|
||||
)
|
||||
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
|
||||
entry: dict[str, Any] = {"role": message["role"], "content": content}
|
||||
for key in ("tool_calls", "tool_call_id", "name", "reasoning_content"):
|
||||
if key in message:
|
||||
entry[key] = message[key]
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Shared helpers for decoding ``data:...;base64,...`` URLs to disk.
|
||||
|
||||
Historically lived in ``nanobot.api.server``; now shared by the WebSocket
|
||||
channel so the ``api`` + ``websocket`` ingress paths apply the same parsing,
|
||||
size guard, and filesystem layout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import mimetypes
|
||||
import re
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from nanobot.utils.helpers import safe_filename
|
||||
|
||||
DEFAULT_MAX_BYTES = 10 * 1024 * 1024
|
||||
MAX_FILE_SIZE = DEFAULT_MAX_BYTES
|
||||
|
||||
_DATA_URL_RE = re.compile(r"^data:([^;]+);base64,(.+)$", re.DOTALL)
|
||||
|
||||
|
||||
class FileSizeExceeded(Exception):
|
||||
"""Raised when a decoded payload exceeds the caller's size limit."""
|
||||
|
||||
|
||||
def save_base64_data_url(
|
||||
data_url: str,
|
||||
media_dir: Path,
|
||||
*,
|
||||
max_bytes: int | None = None,
|
||||
) -> str | None:
|
||||
"""Decode a ``data:<mime>;base64,<payload>`` URL and persist it.
|
||||
|
||||
Returns the absolute path on success, ``None`` when the URL shape or the
|
||||
base64 payload itself is malformed. Raises :class:`FileSizeExceeded`
|
||||
when the decoded payload is larger than ``max_bytes`` (default 10 MB).
|
||||
"""
|
||||
m = _DATA_URL_RE.match(data_url)
|
||||
if not m:
|
||||
return None
|
||||
mime_type, b64_payload = m.group(1), m.group(2)
|
||||
try:
|
||||
raw = base64.b64decode(b64_payload)
|
||||
except Exception:
|
||||
return None
|
||||
limit = DEFAULT_MAX_BYTES if max_bytes is None else max_bytes
|
||||
if len(raw) > limit:
|
||||
raise FileSizeExceeded(f"File exceeds {limit // (1024 * 1024)}MB limit")
|
||||
ext = mimetypes.guess_extension(mime_type) or ".bin"
|
||||
filename = f"{uuid.uuid4().hex[:12]}{ext}"
|
||||
dest = media_dir / safe_filename(filename)
|
||||
dest.write_bytes(raw)
|
||||
return str(dest)
|
||||
Reference in New Issue
Block a user