feat(webui): support document attachments with ingress safeguards (#4771)

* feat: support document attachments in webui

* fix(webui): normalize document attachment MIME

* refactor(webui): move attachment policy out of channel

* fix(webui): reject oversized attachments before send

* fix(webui): align Portuguese attachment errors

* refactor(webui): separate ingress and transport limits

* fix(webui): reject malformed attachment payloads
This commit is contained in:
chengyongru
2026-07-14 14:47:42 +08:00
committed by GitHub
parent b2759e8a6b
commit b7048cf76a
36 changed files with 1398 additions and 332 deletions
+21 -118
View File
@@ -31,7 +31,6 @@ from nanobot.bus.outbound_events import (
) )
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base from nanobot.config.schema import Base
from nanobot.security.workspace_access import ( from nanobot.security.workspace_access import (
WORKSPACE_SCOPE_METADATA_KEY, WORKSPACE_SCOPE_METADATA_KEY,
@@ -39,10 +38,6 @@ from nanobot.security.workspace_access import (
) )
from nanobot.session.goal_state import goal_state_ws_blob from nanobot.session.goal_state import goal_state_ws_blob
from nanobot.session.webui_turns import websocket_turn_wall_started_at from nanobot.session.webui_turns import websocket_turn_wall_started_at
from nanobot.utils.media_decode import (
FileSizeExceeded,
save_base64_data_url,
)
from nanobot.webui.cli_apps_api import normalize_cli_app_mentions from nanobot.webui.cli_apps_api import normalize_cli_app_mentions
from nanobot.webui.forking import handle_webui_fork_chat from nanobot.webui.forking import handle_webui_fork_chat
from nanobot.webui.gateway_services import GatewayServices from nanobot.webui.gateway_services import GatewayServices
@@ -221,45 +216,6 @@ def _parse_envelope(raw: str) -> dict[str, Any] | None:
return data return data
# Per-message media 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
_MAX_VIDEOS_PER_MESSAGE = 1
_MAX_VIDEO_BYTES = 20 * 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",
})
_VIDEO_MIME_ALLOWED: frozenset[str] = frozenset({
"video/mp4",
"video/webm",
"video/quicktime",
})
_UPLOAD_MIME_ALLOWED: frozenset[str] = _IMAGE_MIME_ALLOWED | _VIDEO_MIME_ALLOWED
_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
def _is_websocket_upgrade(request: WsRequest) -> bool: def _is_websocket_upgrade(request: WsRequest) -> bool:
"""Detect an actual WS upgrade; plain HTTP GETs to the same path should fall through.""" """Detect an actual WS upgrade; plain HTTP GETs to the same path should fall through."""
upgrade = request.headers.get("Upgrade") or request.headers.get("upgrade") upgrade = request.headers.get("Upgrade") or request.headers.get("upgrade")
@@ -301,6 +257,7 @@ class WebSocketChannel(BaseChannel):
self._http_router = gateway.http self._http_router = gateway.http
self._tokens = gateway.tokens self._tokens = gateway.tokens
self._media = gateway.media self._media = gateway.media
self._ingress = gateway.ingress
self._transcripts = gateway.transcripts self._transcripts = gateway.transcripts
self._workspaces = gateway.workspaces self._workspaces = gateway.workspaces
@@ -582,74 +539,6 @@ class WebSocketChannel(BaseChannel):
# -- Inbound WebSocket envelopes --------------------------------------- # -- Inbound WebSocket envelopes ---------------------------------------
def _save_envelope_media(
self,
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.
On failure, any files already written to disk earlier in the same
call are unlinked so partial ingress doesn't leak orphan files.
``reason`` is a short, stable token suitable for UI localization.
Shape: ``list[{"data_url": str, "name"?: str | None}]``.
"""
image_count = 0
video_count = 0
for item in media:
mime = _extract_data_url_mime(item.get("data_url", "")) if isinstance(item, dict) else None
if mime in _VIDEO_MIME_ALLOWED:
video_count += 1
elif mime in _IMAGE_MIME_ALLOWED:
image_count += 1
if image_count > _MAX_IMAGES_PER_MESSAGE:
return [], "too_many_images"
if video_count > _MAX_VIDEOS_PER_MESSAGE:
return [], "too_many_videos"
media_dir = get_media_dir("websocket")
paths: list[str] = []
def _abort(reason: str) -> tuple[list[str], str]:
for p in paths:
try:
Path(p).unlink(missing_ok=True)
except OSError as exc:
self.logger.warning(
"failed to unlink partial media {}: {}", p, exc
)
return [], reason
for item in media:
if not isinstance(item, dict):
return _abort("malformed")
data_url = item.get("data_url")
if not isinstance(data_url, str) or not data_url:
return _abort("malformed")
mime = _extract_data_url_mime(data_url)
if mime is None:
return _abort("decode")
if mime not in _UPLOAD_MIME_ALLOWED:
return _abort("mime")
is_video = mime in _VIDEO_MIME_ALLOWED
max_bytes = _MAX_VIDEO_BYTES if is_video else _MAX_IMAGE_BYTES
try:
saved = save_base64_data_url(
data_url, media_dir, max_bytes=max_bytes,
)
except FileSizeExceeded:
return _abort("size")
except Exception as exc:
self.logger.warning("media decode failed: {}", exc)
return _abort("decode")
if saved is None:
return _abort("decode")
paths.append(saved)
return paths, None
async def _dispatch_envelope( async def _dispatch_envelope(
self, self,
connection: Any, connection: Any,
@@ -732,25 +621,39 @@ class WebSocketChannel(BaseChannel):
if not isinstance(content, str): if not isinstance(content, str):
await self._send_event(connection, "error", detail="missing content") await self._send_event(connection, "error", detail="missing content")
return return
message_rejection = self._ingress.validate_text(content)
if message_rejection is not None:
await self._send_event(
connection,
"error",
chat_id=cid,
detail="message_rejected",
reason=message_rejection,
)
return
raw_media = envelope.get("media") raw_media = envelope.get("media")
media_paths: list[str] = [] media_paths: list[str] = []
if raw_media is not None: if raw_media is not None:
if not isinstance(raw_media, list): if not isinstance(raw_media, list):
await self._send_event( await self._send_event(
connection, "error", connection,
detail="image_rejected", reason="malformed", "error",
detail="attachment_rejected",
reason="malformed",
) )
return return
media_paths, reason = self._save_envelope_media(raw_media) media_paths, reason = self._media.store_inbound_attachments(raw_media)
if reason is not None: if reason is not None:
await self._send_event( await self._send_event(
connection, "error", connection,
detail="image_rejected", reason=reason, "error",
detail="attachment_rejected",
reason=reason,
) )
return return
# Allow image-only turns (content may be empty when media is attached). # Allow media-only turns (content may be empty when attachments are present).
if not content.strip() and not media_paths: if not content.strip() and not media_paths:
await self._send_event(connection, "error", detail="missing content") await self._send_event(connection, "error", detail="missing content")
return return
+24 -3
View File
@@ -32,6 +32,22 @@ _MIME_EXTENSION_OVERRIDES = {
"audio/x-wav": ".wav", "audio/x-wav": ".wav",
"audio/vnd.wave": ".wav", "audio/vnd.wave": ".wav",
"video/webm": ".webm", "video/webm": ".webm",
"application/json": ".json",
"application/pdf": ".pdf",
"application/toml": ".toml",
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
"application/x-yaml": ".yaml",
"application/xhtml+xml": ".html",
"application/xml": ".xml",
"application/yaml": ".yaml",
"text/csv": ".csv",
"text/html": ".html",
"text/markdown": ".md",
"text/plain": ".txt",
"text/xml": ".xml",
"text/yaml": ".yaml",
} }
@@ -47,6 +63,7 @@ def save_base64_data_url(
media_dir: Path, media_dir: Path,
*, *,
max_bytes: int | None = None, max_bytes: int | None = None,
filename: str | None = None,
) -> str | None: ) -> str | None:
"""Decode a ``data:<mime>;base64,<payload>`` URL and persist it. """Decode a ``data:<mime>;base64,<payload>`` URL and persist it.
@@ -59,14 +76,18 @@ def save_base64_data_url(
return None return None
mime_type, b64_payload = m.group(1).strip().lower(), m.group(2) mime_type, b64_payload = m.group(1).strip().lower(), m.group(2)
try: try:
raw = base64.b64decode(b64_payload) raw = base64.b64decode(b64_payload, validate=True)
except Exception: except Exception:
return None return None
if not raw:
return None
limit = DEFAULT_MAX_BYTES if max_bytes is None else max_bytes limit = DEFAULT_MAX_BYTES if max_bytes is None else max_bytes
if len(raw) > limit: if len(raw) > limit:
raise FileSizeExceeded(f"File exceeds {limit // (1024 * 1024)}MB limit") raise FileSizeExceeded(f"File exceeds {limit // (1024 * 1024)}MB limit")
ext = _MIME_EXTENSION_OVERRIDES.get(mime_type) or mimetypes.guess_extension(mime_type) or ".bin" ext = _MIME_EXTENSION_OVERRIDES.get(mime_type) or mimetypes.guess_extension(mime_type) or ".bin"
filename = f"{uuid.uuid4().hex[:12]}{ext}" base = safe_filename(filename or "")
dest = media_dir / safe_filename(filename) stem = Path(base).stem[:80] if base else ""
saved_name = f"{uuid.uuid4().hex[:12]}_{stem}{ext}" if stem else f"{uuid.uuid4().hex[:12]}{ext}"
dest = media_dir / safe_filename(saved_name)
dest.write_bytes(raw) dest.write_bytes(raw)
return str(dest) return str(dest)
+170
View File
@@ -0,0 +1,170 @@
"""Validation and persistence for inbound WebUI message attachments."""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any, Literal
from nanobot.utils.media_decode import FileSizeExceeded, save_base64_data_url
from nanobot.webui.ingress_policy import (
DEFAULT_WEBUI_INGRESS_POLICY,
AttachmentIngressLimits,
)
AttachmentRejection = Literal[
"malformed",
"too_many_images",
"too_many_videos",
"too_many_attachments",
"total_size",
"mime",
"size",
"decode",
]
AttachmentIngressResult = tuple[list[str], AttachmentRejection | None]
_MAX_VIDEOS_PER_MESSAGE = 1
_MAX_VIDEO_BYTES = 20 * 1024 * 1024
_IMAGE_MIME_ALLOWED: frozenset[str] = frozenset({
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
})
_VIDEO_MIME_ALLOWED: frozenset[str] = frozenset({
"video/mp4",
"video/webm",
"video/quicktime",
})
_DOCUMENT_MIME_ALLOWED: frozenset[str] = frozenset({
"application/json",
"application/pdf",
"application/toml",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/x-yaml",
"application/xhtml+xml",
"application/xml",
"application/yaml",
"text/csv",
"text/html",
"text/markdown",
"text/plain",
"text/xml",
"text/yaml",
})
_UPLOAD_MIME_ALLOWED: frozenset[str] = (
_IMAGE_MIME_ALLOWED | _VIDEO_MIME_ALLOWED | _DOCUMENT_MIME_ALLOWED
)
_DATA_URL_MIME_RE = re.compile(r"^data:([^;,]+)(?:;[^,]*)*;base64,", re.DOTALL)
def extract_data_url_mime(url: Any) -> str | None:
"""Return the normalized MIME from a base64 data URL, else ``None``."""
if not isinstance(url, str):
return None
match = _DATA_URL_MIME_RE.match(url)
if match is None:
return None
return match.group(1).strip().lower() or None
def store_inbound_attachments(
media: list[Any],
*,
media_dir: Path,
logger: Any,
limits: AttachmentIngressLimits = DEFAULT_WEBUI_INGRESS_POLICY.attachments,
) -> AttachmentIngressResult:
"""Validate and atomically persist one WebUI message's attachments.
The caller owns transport-level error mapping. This function owns the
WebUI upload policy and removes files already written when a later item
makes the batch invalid.
"""
image_count = 0
video_count = 0
document_count = 0
for item in media:
mime = (
extract_data_url_mime(item.get("data_url", ""))
if isinstance(item, dict)
else None
)
if mime in _VIDEO_MIME_ALLOWED:
video_count += 1
elif mime in _IMAGE_MIME_ALLOWED:
image_count += 1
elif mime in _DOCUMENT_MIME_ALLOWED:
document_count += 1
if image_count > limits.max_count:
return [], "too_many_images"
if video_count > _MAX_VIDEOS_PER_MESSAGE:
return [], "too_many_videos"
if image_count + document_count > limits.max_count:
return [], "too_many_attachments"
paths: list[str] = []
total_attachment_bytes = 0
def abort(reason: AttachmentRejection) -> AttachmentIngressResult:
for path in paths:
try:
Path(path).unlink(missing_ok=True)
except OSError as exc:
logger.warning("failed to unlink partial media {}: {}", path, exc)
return [], reason
for item in media:
if not isinstance(item, dict):
return abort("malformed")
data_url = item.get("data_url")
if not isinstance(data_url, str) or not data_url:
return abort("malformed")
mime = extract_data_url_mime(data_url)
if mime is None:
return abort("decode")
if mime not in _UPLOAD_MIME_ALLOWED:
return abort("mime")
is_video = mime in _VIDEO_MIME_ALLOWED
is_document = mime in _DOCUMENT_MIME_ALLOWED
max_bytes = (
_MAX_VIDEO_BYTES if is_video
else limits.max_file_bytes
)
name = (
item.get("name")
if is_document and isinstance(item.get("name"), str)
else None
)
try:
saved = save_base64_data_url(
data_url,
media_dir,
max_bytes=max_bytes,
filename=name,
)
except FileSizeExceeded:
return abort("size")
except Exception as exc:
logger.warning("media decode failed: {}", exc)
return abort("decode")
if saved is None:
return abort("decode")
paths.append(saved)
if not is_video:
try:
total_attachment_bytes += Path(saved).stat().st_size
except OSError as exc:
logger.warning("failed to stat inbound attachment {}: {}", saved, exc)
return abort("decode")
if total_attachment_bytes > limits.max_total_bytes:
return abort("total_size")
return paths, None
+14
View File
@@ -9,6 +9,7 @@ from typing import Any, Callable
from loguru import logger as default_logger from loguru import logger as default_logger
from nanobot.webui.gateway_tokens import GatewayTokenStore from nanobot.webui.gateway_tokens import GatewayTokenStore
from nanobot.webui.ingress_policy import DEFAULT_WEBUI_INGRESS_POLICY, WebUIIngressPolicy
from nanobot.webui.media_gateway import WebUIMediaGateway from nanobot.webui.media_gateway import WebUIMediaGateway
from nanobot.webui.transcript import WebUITranscriptRecorder from nanobot.webui.transcript import WebUITranscriptRecorder
from nanobot.webui.workspaces import WebUIWorkspaceController from nanobot.webui.workspaces import WebUIWorkspaceController
@@ -22,6 +23,7 @@ class GatewayServices:
http: GatewayHTTPHandler http: GatewayHTTPHandler
tokens: GatewayTokenStore tokens: GatewayTokenStore
media: WebUIMediaGateway media: WebUIMediaGateway
ingress: WebUIIngressPolicy
transcripts: WebUITranscriptRecorder transcripts: WebUITranscriptRecorder
workspaces: WebUIWorkspaceController workspaces: WebUIWorkspaceController
session_manager: Any | None session_manager: Any | None
@@ -51,9 +53,19 @@ def build_gateway_services(
logger: Any = default_logger, logger: Any = default_logger,
) -> GatewayServices: ) -> GatewayServices:
tokens = GatewayTokenStore() tokens = GatewayTokenStore()
ingress = DEFAULT_WEBUI_INGRESS_POLICY
minimum_frame_bytes = ingress.minimum_full_policy_frame_bytes()
if config.max_message_bytes < minimum_frame_bytes:
logger.warning(
"WebSocket maxMessageBytes={} is below the WebUI ingress policy capacity={}; "
"policy-valid messages may still hit the transport frame guard",
config.max_message_bytes,
minimum_frame_bytes,
)
media = WebUIMediaGateway( media = WebUIMediaGateway(
workspace_path=workspace_path, workspace_path=workspace_path,
logger=logger, logger=logger,
attachment_limits=ingress.attachments,
) )
transcripts = WebUITranscriptRecorder(log=logger) transcripts = WebUITranscriptRecorder(log=logger)
workspaces = WebUIWorkspaceController( workspaces = WebUIWorkspaceController(
@@ -71,6 +83,7 @@ def build_gateway_services(
bus=bus, bus=bus,
tokens=tokens, tokens=tokens,
media=media, media=media,
ingress=ingress,
workspaces=workspaces, workspaces=workspaces,
skills_workspace_path=workspace_path, skills_workspace_path=workspace_path,
disabled_skills=disabled_skills, disabled_skills=disabled_skills,
@@ -85,6 +98,7 @@ def build_gateway_services(
http=http, http=http,
tokens=tokens, tokens=tokens,
media=media, media=media,
ingress=ingress,
transcripts=transcripts, transcripts=transcripts,
workspaces=workspaces, workspaces=workspaces,
session_manager=session_manager, session_manager=session_manager,
+70
View File
@@ -0,0 +1,70 @@
"""Business limits for inbound WebUI messages and attachments.
The WebSocket channel owns its raw frame limit. This module owns semantic
limits inside a decoded WebUI message so transport capacity isn't mistaken
for a text or attachment policy.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from typing import Literal
MessageRejection = Literal["text_too_large"]
@dataclass(frozen=True)
class MessageIngressLimits:
max_text_bytes: int = 64 * 1024
@dataclass(frozen=True)
class AttachmentIngressLimits:
max_count: int = 4
max_file_bytes: int = 6 * 1024 * 1024
max_total_bytes: int = 24 * 1024 * 1024
@dataclass(frozen=True)
class WebUIIngressPolicy:
"""Limits applied after the channel has decoded the transport envelope."""
message: MessageIngressLimits = field(default_factory=MessageIngressLimits)
attachments: AttachmentIngressLimits = field(default_factory=AttachmentIngressLimits)
# Covers JSON keys, IDs, attachment names, MIME prefixes, mentions, and
# other non-content fields when the browser estimates whether a frame fits.
envelope_reserve_bytes: int = 64 * 1024
def validate_text(self, content: str) -> MessageRejection | None:
if len(content.encode("utf-8")) > self.message.max_text_bytes:
return "text_too_large"
return None
def bootstrap_limits(self, *, max_frame_bytes: int) -> dict[str, object]:
return {
"transport": {
"max_frame_bytes": max_frame_bytes,
"envelope_reserve_bytes": self.envelope_reserve_bytes,
},
"message": {"max_text_bytes": self.message.max_text_bytes},
"attachments": {
"max_count": self.attachments.max_count,
"max_file_bytes": self.attachments.max_file_bytes,
"max_total_bytes": self.attachments.max_total_bytes,
},
}
def minimum_full_policy_frame_bytes(self) -> int:
"""Conservative frame size needed for every policy-valid message."""
encoded_attachments = 4 * math.ceil(self.attachments.max_total_bytes / 3)
data_url_allowance = self.attachments.max_count * 128
return (
encoded_attachments
+ data_url_allowance
+ self.message.max_text_bytes
+ self.envelope_reserve_bytes
)
DEFAULT_WEBUI_INGRESS_POLICY = WebUIIngressPolicy()
+16
View File
@@ -11,6 +11,11 @@ from websockets.http11 import Request as WsRequest
from websockets.http11 import Response from websockets.http11 import Response
from nanobot.config.paths import get_media_dir from nanobot.config.paths import get_media_dir
from nanobot.webui.attachment_ingress import (
AttachmentIngressResult,
store_inbound_attachments,
)
from nanobot.webui.ingress_policy import AttachmentIngressLimits
from nanobot.webui.media_api import ( from nanobot.webui.media_api import (
attach_signed_media_urls, attach_signed_media_urls,
serve_signed_media, serve_signed_media,
@@ -31,11 +36,22 @@ class WebUIMediaGateway:
logger: Any, logger: Any,
media_dir: Callable[[str | None], Path] | None = None, media_dir: Callable[[str | None], Path] | None = None,
secret: bytes | None = None, secret: bytes | None = None,
attachment_limits: AttachmentIngressLimits | None = None,
) -> None: ) -> None:
self.workspace_path = workspace_path self.workspace_path = workspace_path
self.logger = logger self.logger = logger
self._media_dir = media_dir or (lambda channel=None: get_media_dir(channel)) self._media_dir = media_dir or (lambda channel=None: get_media_dir(channel))
self.secret = secret or secrets.token_bytes(32) self.secret = secret or secrets.token_bytes(32)
self.attachment_limits = attachment_limits or AttachmentIngressLimits()
def store_inbound_attachments(self, media: list[Any]) -> AttachmentIngressResult:
"""Validate and persist attachments from an inbound WebUI message."""
return store_inbound_attachments(
media,
media_dir=self._media_dir("websocket"),
logger=self.logger,
limits=self.attachment_limits,
)
def serve_signed_media( def serve_signed_media(
self, self,
+6
View File
@@ -70,6 +70,7 @@ from nanobot.webui.http_utils import (
from nanobot.webui.http_utils import ( from nanobot.webui.http_utils import (
safe_host_header as _safe_host_header, safe_host_header as _safe_host_header,
) )
from nanobot.webui.ingress_policy import WebUIIngressPolicy
from nanobot.webui.media_gateway import WebUIMediaGateway from nanobot.webui.media_gateway import WebUIMediaGateway
from nanobot.webui.session_automations import ( from nanobot.webui.session_automations import (
all_automations_payload, all_automations_payload,
@@ -155,6 +156,7 @@ class GatewayHTTPHandler:
bus: MessageBus, bus: MessageBus,
tokens: GatewayTokenStore, tokens: GatewayTokenStore,
media: WebUIMediaGateway, media: WebUIMediaGateway,
ingress: WebUIIngressPolicy,
workspaces: WebUIWorkspaceController, workspaces: WebUIWorkspaceController,
skills_workspace_path: Path, skills_workspace_path: Path,
disabled_skills: set[str] | None = None, disabled_skills: set[str] | None = None,
@@ -172,6 +174,7 @@ class GatewayHTTPHandler:
self.bus = bus self.bus = bus
self.tokens = tokens self.tokens = tokens
self.media = media self.media = media
self.ingress = ingress
self.workspaces = workspaces self.workspaces = workspaces
self.skills_workspace_path = skills_workspace_path self.skills_workspace_path = skills_workspace_path
self.disabled_skills = disabled_skills or set() self.disabled_skills = disabled_skills or set()
@@ -340,6 +343,9 @@ class GatewayHTTPHandler:
"ws_path": expected_path, "ws_path": expected_path,
"ws_url": ws_url, "ws_url": ws_url,
"expires_in": self.config.token_ttl_s, "expires_in": self.config.token_ttl_s,
"limits": self.ingress.bootstrap_limits(
max_frame_bytes=self.config.max_message_bytes,
),
"model_name": _resolve_bootstrap_model_name(self.runtime_model_name), "model_name": _resolve_bootstrap_model_name(self.runtime_model_name),
"runtime_surface": self._runtime_surface, "runtime_surface": self._runtime_surface,
"runtime_capabilities": self._capabilities, "runtime_capabilities": self._capabilities,
-3
View File
@@ -1018,7 +1018,6 @@ async def test_send_stages_external_media_as_signed_url(monkeypatch, tmp_path) -
def fake_media_dir(channel: str | None = None): def fake_media_dir(channel: str | None = None):
return ws_media if channel == "websocket" else media_root return ws_media if channel == "websocket" else media_root
monkeypatch.setattr("nanobot.channels.websocket.get_media_dir", fake_media_dir)
monkeypatch.setattr("nanobot.webui.media_gateway.get_media_dir", fake_media_dir) monkeypatch.setattr("nanobot.webui.media_gateway.get_media_dir", fake_media_dir)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus)) channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
mock_ws = AsyncMock() mock_ws = AsyncMock()
@@ -1263,7 +1262,6 @@ async def test_send_delta_stream_end_rewrites_local_markdown_image(monkeypatch,
path.mkdir(parents=True, exist_ok=True) path.mkdir(parents=True, exist_ok=True)
return path return path
monkeypatch.setattr("nanobot.channels.websocket.get_media_dir", fake_media_dir)
monkeypatch.setattr("nanobot.webui.media_gateway.get_media_dir", fake_media_dir) monkeypatch.setattr("nanobot.webui.media_gateway.get_media_dir", fake_media_dir)
channel = WebSocketChannel( channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "streaming": True}, {"enabled": True, "allowFrom": ["*"], "streaming": True},
@@ -1296,7 +1294,6 @@ async def test_send_delta_stream_end_rewrites_inline_final_text(monkeypatch, tmp
path.mkdir(parents=True, exist_ok=True) path.mkdir(parents=True, exist_ok=True)
return path return path
monkeypatch.setattr("nanobot.channels.websocket.get_media_dir", fake_media_dir)
monkeypatch.setattr("nanobot.webui.media_gateway.get_media_dir", fake_media_dir) monkeypatch.setattr("nanobot.webui.media_gateway.get_media_dir", fake_media_dir)
channel = WebSocketChannel( channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "streaming": True}, {"enabled": True, "allowFrom": ["*"], "streaming": True},
+123 -45
View File
@@ -1,4 +1,4 @@
"""Tests for WS envelope media handling (client image upload path). """Tests for WS envelope media handling (client attachment upload path).
Exercises ``WebSocketChannel._dispatch_envelope`` for the ``message`` branch: Exercises ``WebSocketChannel._dispatch_envelope`` for the ``message`` branch:
decoding base64 data URLs, rejecting malformed / oversized / non-whitelisted decoding base64 data URLs, rejecting malformed / oversized / non-whitelisted
@@ -11,7 +11,6 @@ from __future__ import annotations
import base64 import base64
import json import json
from pathlib import Path from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@@ -19,7 +18,6 @@ import pytest
from nanobot.channels.websocket import ( from nanobot.channels.websocket import (
WebSocketChannel, WebSocketChannel,
WebSocketConfig, WebSocketConfig,
_extract_data_url_mime,
) )
from nanobot.webui.gateway_services import build_gateway_services from nanobot.webui.gateway_services import build_gateway_services
@@ -61,28 +59,6 @@ def _make_channel() -> WebSocketChannel:
return channel return channel
# -- Pure helpers --------------------------------------------------------------
@pytest.mark.parametrize(
("url", "expected"),
[
("data:image/png;base64,AAAA", "image/png"),
("data:image/jpeg;base64,AAAA", "image/jpeg"),
("data:audio/webm;codecs=opus;base64,AAAA", "audio/webm"),
("data:IMAGE/PNG;base64,AAAA", "image/png"),
("data:image/svg+xml;base64,AAAA", "image/svg+xml"),
("data:text/plain;base64,AAAA", "text/plain"),
("http://evil.example/x.png", None),
("data:image/png,AAAA", None), # missing `;base64`
("", None),
(None, None),
],
)
def test_extract_data_url_mime(url: Any, expected: str | None) -> None:
assert _extract_data_url_mime(url) == expected
# -- max_message_bytes bump ---------------------------------------------------- # -- max_message_bytes bump ----------------------------------------------------
@@ -118,6 +94,28 @@ async def test_message_without_media_backward_compatible() -> None:
assert call.kwargs["media"] is None assert call.kwargs["media"] is None
@pytest.mark.asyncio
async def test_message_text_policy_is_independent_from_transport_limit() -> None:
channel = _make_channel()
mock_conn = AsyncMock()
envelope = {
"type": "message",
"chat_id": "abc123",
"content": "" * 22_000,
}
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
channel._handle_message.assert_not_awaited()
err = json.loads(mock_conn.send.call_args[0][0])
assert err == {
"event": "error",
"chat_id": "abc123",
"detail": "message_rejected",
"reason": "text_too_large",
}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_message_forwards_normalized_cli_app_attachments() -> None: async def test_message_forwards_normalized_cli_app_attachments() -> None:
channel = _make_channel() channel = _make_channel()
@@ -167,7 +165,7 @@ async def test_message_with_single_image_forwards_saved_path(tmp_path) -> None:
} }
with patch( with patch(
"nanobot.channels.websocket.get_media_dir", return_value=tmp_path "nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path
): ):
await channel._dispatch_envelope(mock_conn, "client-1", envelope) await channel._dispatch_envelope(mock_conn, "client-1", envelope)
@@ -196,7 +194,7 @@ async def test_message_with_multiple_images(tmp_path) -> None:
} }
with patch( with patch(
"nanobot.channels.websocket.get_media_dir", return_value=tmp_path "nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path
): ):
await channel._dispatch_envelope(mock_conn, "client-1", envelope) await channel._dispatch_envelope(mock_conn, "client-1", envelope)
@@ -219,7 +217,7 @@ async def test_image_only_message_allows_empty_text(tmp_path) -> None:
} }
with patch( with patch(
"nanobot.channels.websocket.get_media_dir", return_value=tmp_path "nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path
): ):
await channel._dispatch_envelope(mock_conn, "client-1", envelope) await channel._dispatch_envelope(mock_conn, "client-1", envelope)
@@ -240,7 +238,7 @@ async def test_message_rejected_when_more_than_four_images(tmp_path) -> None:
} }
with patch( with patch(
"nanobot.channels.websocket.get_media_dir", return_value=tmp_path "nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path
): ):
await channel._dispatch_envelope(mock_conn, "client-1", envelope) await channel._dispatch_envelope(mock_conn, "client-1", envelope)
@@ -248,10 +246,38 @@ async def test_message_rejected_when_more_than_four_images(tmp_path) -> None:
mock_conn.send.assert_awaited_once() mock_conn.send.assert_awaited_once()
err = json.loads(mock_conn.send.call_args[0][0]) err = json.loads(mock_conn.send.call_args[0][0])
assert err["event"] == "error" assert err["event"] == "error"
assert err["detail"] == "image_rejected" assert err["detail"] == "attachment_rejected"
assert err["reason"] == "too_many_images" assert err["reason"] == "too_many_images"
@pytest.mark.asyncio
async def test_message_rejected_when_too_many_total_attachments(tmp_path) -> None:
channel = _make_channel()
mock_conn = AsyncMock()
envelope = {
"type": "message",
"chat_id": "abc123",
"content": "mixed",
"media": [
{"data_url": _tiny_png_data_url()},
{"data_url": _tiny_png_data_url()},
{"data_url": _tiny_png_data_url()},
{"data_url": _tiny_png_data_url()},
{"data_url": _data_url("application/pdf", b"%PDF-1.4"), "name": "report.pdf"},
],
}
with patch(
"nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path
):
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
channel._handle_message.assert_not_awaited()
err = json.loads(mock_conn.send.call_args[0][0])
assert err["detail"] == "attachment_rejected"
assert err["reason"] == "too_many_attachments"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_message_rejected_on_oversize_payload(tmp_path) -> None: async def test_message_rejected_on_oversize_payload(tmp_path) -> None:
channel = _make_channel() channel = _make_channel()
@@ -265,35 +291,87 @@ async def test_message_rejected_on_oversize_payload(tmp_path) -> None:
} }
with patch( with patch(
"nanobot.channels.websocket.get_media_dir", return_value=tmp_path "nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path
): ):
await channel._dispatch_envelope(mock_conn, "client-1", envelope) await channel._dispatch_envelope(mock_conn, "client-1", envelope)
channel._handle_message.assert_not_awaited() channel._handle_message.assert_not_awaited()
err = json.loads(mock_conn.send.call_args[0][0]) err = json.loads(mock_conn.send.call_args[0][0])
assert err["detail"] == "image_rejected" assert err["detail"] == "attachment_rejected"
assert err["reason"] == "size" assert err["reason"] == "size"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_message_rejected_on_non_image_mime(tmp_path) -> None: async def test_message_with_pdf_forwards_saved_path(tmp_path) -> None:
channel = _make_channel() channel = _make_channel()
mock_conn = AsyncMock() mock_conn = AsyncMock()
envelope = { envelope = {
"type": "message", "type": "message",
"chat_id": "abc123", "chat_id": "abc123",
"content": "pdf?", "content": "pdf?",
"media": [{"data_url": _data_url("application/pdf", b"%PDF-1.4")}], "media": [{"data_url": _data_url("application/pdf", b"%PDF-1.4"), "name": "report.pdf"}],
} }
with patch( with patch(
"nanobot.channels.websocket.get_media_dir", return_value=tmp_path "nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path
):
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
channel._handle_message.assert_awaited_once()
paths = channel._handle_message.call_args.kwargs["media"]
assert isinstance(paths, list) and len(paths) == 1
saved = Path(paths[0])
assert saved.exists()
assert saved.suffix == ".pdf"
assert saved.name.endswith("_report.pdf")
assert saved.read_bytes() == b"%PDF-1.4"
@pytest.mark.asyncio
async def test_message_with_csv_forwards_saved_path(tmp_path) -> None:
channel = _make_channel()
mock_conn = AsyncMock()
envelope = {
"type": "message",
"chat_id": "abc123",
"content": "summarize",
"media": [
{"data_url": _data_url("text/csv", b"name,value\nnanobot,1"), "name": "report.csv"}
],
}
with patch(
"nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path
):
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
channel._handle_message.assert_awaited_once()
paths = channel._handle_message.call_args.kwargs["media"]
saved = Path(paths[0])
assert saved.suffix == ".csv"
assert saved.name.endswith("_report.csv")
assert saved.read_bytes() == b"name,value\nnanobot,1"
@pytest.mark.asyncio
async def test_message_rejected_on_unsupported_file_mime(tmp_path) -> None:
channel = _make_channel()
mock_conn = AsyncMock()
envelope = {
"type": "message",
"chat_id": "abc123",
"content": "zip?",
"media": [{"data_url": _data_url("application/zip", b"PK")}],
}
with patch(
"nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path
): ):
await channel._dispatch_envelope(mock_conn, "client-1", envelope) await channel._dispatch_envelope(mock_conn, "client-1", envelope)
channel._handle_message.assert_not_awaited() channel._handle_message.assert_not_awaited()
err = json.loads(mock_conn.send.call_args[0][0]) err = json.loads(mock_conn.send.call_args[0][0])
assert err["detail"] == "image_rejected" assert err["detail"] == "attachment_rejected"
assert err["reason"] == "mime" assert err["reason"] == "mime"
@@ -310,7 +388,7 @@ async def test_message_rejected_on_svg_mime(tmp_path) -> None:
} }
with patch( with patch(
"nanobot.channels.websocket.get_media_dir", return_value=tmp_path "nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path
): ):
await channel._dispatch_envelope(mock_conn, "client-1", envelope) await channel._dispatch_envelope(mock_conn, "client-1", envelope)
@@ -331,7 +409,7 @@ async def test_message_rejected_on_malformed_data_url(tmp_path) -> None:
} }
with patch( with patch(
"nanobot.channels.websocket.get_media_dir", return_value=tmp_path "nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path
): ):
await channel._dispatch_envelope(mock_conn, "client-1", envelope) await channel._dispatch_envelope(mock_conn, "client-1", envelope)
@@ -352,7 +430,7 @@ async def test_message_rejected_on_broken_base64(tmp_path) -> None:
} }
with patch( with patch(
"nanobot.channels.websocket.get_media_dir", return_value=tmp_path "nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path
): ):
await channel._dispatch_envelope(mock_conn, "client-1", envelope) await channel._dispatch_envelope(mock_conn, "client-1", envelope)
@@ -374,7 +452,7 @@ async def test_message_rejected_when_media_item_shape_wrong(tmp_path) -> None:
} }
with patch( with patch(
"nanobot.channels.websocket.get_media_dir", return_value=tmp_path "nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path
): ):
await channel._dispatch_envelope(mock_conn, "client-1", envelope) await channel._dispatch_envelope(mock_conn, "client-1", envelope)
@@ -398,15 +476,15 @@ async def test_message_rejected_when_media_field_is_not_list() -> None:
channel._handle_message.assert_not_awaited() channel._handle_message.assert_not_awaited()
err = json.loads(mock_conn.send.call_args[0][0]) err = json.loads(mock_conn.send.call_args[0][0])
assert err["detail"] == "image_rejected" assert err["detail"] == "attachment_rejected"
assert err["reason"] == "malformed" assert err["reason"] == "malformed"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_failed_media_does_not_partially_persist(tmp_path) -> None: async def test_failed_media_does_not_partially_persist(tmp_path) -> None:
"""If the second image is invalid, the first must not be forwarded. """If the second attachment is invalid, the first must not be forwarded.
Also: images already written in this call are cleaned up on failure, so Also: files already written in this call are cleaned up on failure, so
a mixed-valid/invalid batch never leaves orphan files in the media dir. a mixed-valid/invalid batch never leaves orphan files in the media dir.
""" """
channel = _make_channel() channel = _make_channel()
@@ -417,12 +495,12 @@ async def test_failed_media_does_not_partially_persist(tmp_path) -> None:
"content": "mixed", "content": "mixed",
"media": [ "media": [
{"data_url": _tiny_png_data_url()}, {"data_url": _tiny_png_data_url()},
{"data_url": _data_url("application/pdf", b"%PDF-1.4")}, {"data_url": _data_url("image/svg+xml", b"<svg/>")},
], ],
} }
with patch( with patch(
"nanobot.channels.websocket.get_media_dir", return_value=tmp_path "nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path
): ):
await channel._dispatch_envelope(mock_conn, "client-1", envelope) await channel._dispatch_envelope(mock_conn, "client-1", envelope)
+20 -2
View File
@@ -204,7 +204,12 @@ async def test_bootstrap_returns_token_for_localhost(
bus: MagicMock, tmp_path: Path bus: MagicMock, tmp_path: Path
) -> None: ) -> None:
sm = _seed_session(tmp_path) sm = _seed_session(tmp_path)
channel = _ch(bus, session_manager=sm, port=29901) channel = _ch(
bus,
session_manager=sm,
port=29901,
maxMessageBytes=1_048_576,
)
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3) await asyncio.sleep(0.3)
try: try:
@@ -217,6 +222,19 @@ async def test_bootstrap_returns_token_for_localhost(
assert body["ws_path"] == "/" assert body["ws_path"] == "/"
assert body["ws_url"] == "ws://127.0.0.1:29901/" assert body["ws_url"] == "ws://127.0.0.1:29901/"
assert body["expires_in"] > 0 assert body["expires_in"] > 0
assert body["limits"] == {
"transport": {
"max_frame_bytes": 1_048_576,
"envelope_reserve_bytes": 65_536,
},
"message": {"max_text_bytes": 65_536},
"attachments": {
"max_count": 4,
"max_file_bytes": 6_291_456,
"max_total_bytes": 25_165_824,
},
}
assert "max_message_bytes" not in body
assert isinstance(body.get("model_name"), str) assert isinstance(body.get("model_name"), str)
finally: finally:
await channel.stop() await channel.stop()
@@ -2422,7 +2440,7 @@ async def test_webui_thread_resigns_assistant_media_urls(
def fake_media_dir(channel: str | None = None) -> Path: def fake_media_dir(channel: str | None = None) -> Path:
return websocket_media if channel == "websocket" else media_root return websocket_media if channel == "websocket" else media_root
monkeypatch.setattr("nanobot.channels.websocket.get_media_dir", fake_media_dir) monkeypatch.setattr("nanobot.webui.media_gateway.get_media_dir", fake_media_dir)
append_transcript_object( append_transcript_object(
"websocket:video-replay", "websocket:video-replay",
@@ -5,7 +5,6 @@ from __future__ import annotations
import pytest import pytest
from nanobot.channels.websocket import ( from nanobot.channels.websocket import (
_extract_data_url_mime,
_is_valid_chat_id, _is_valid_chat_id,
_parse_envelope, _parse_envelope,
) )
@@ -56,22 +55,3 @@ def test_parse_envelope_only_accepts_typed_json_objects(
else: else:
assert parsed is not None assert parsed is not None
assert parsed["type"] == expected_type assert parsed["type"] == expected_type
@pytest.mark.parametrize(
("url", "expected"),
[
("data:image/png;base64,AAAA", "image/png"),
("data:IMAGE/JPEG;charset=utf-8;base64,AAAA", "image/jpeg"),
("data:video/webm;codecs=vp9;base64,AAAA", "video/webm"),
("data:image/svg+xml;base64,AAAA", "image/svg+xml"),
("data:image/png,AAAA", None),
("data:;base64,AAAA", None),
("https://example.invalid/image.png", None),
],
)
def test_extract_data_url_mime_normalizes_only_base64_data_urls(
url: str,
expected: str | None,
) -> None:
assert _extract_data_url_mime(url) == expected
+29 -4
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import base64 import base64
from pathlib import Path
import pytest import pytest
@@ -50,14 +51,38 @@ def test_saves_common_audio_with_api_friendly_extension(
assert result.endswith(suffix) assert result.endswith(suffix)
def test_saves_document_with_safe_original_name(tmp_path) -> None:
result = save_base64_data_url(
_data_url(b"%PDF-1.4", mime="application/pdf"),
tmp_path,
filename="quarterly report.pdf",
)
assert result is not None
assert result.endswith("_quarterly report.pdf")
assert Path(result).read_bytes() == b"%PDF-1.4"
def test_document_filename_cannot_escape_media_dir(tmp_path) -> None:
result = save_base64_data_url(
_data_url(b"%PDF-1.4", mime="application/pdf"),
tmp_path,
filename="../../escape.pdf",
)
assert result is not None
saved = Path(result)
assert saved.parent == tmp_path
assert saved.suffix == ".pdf"
assert saved.read_bytes() == b"%PDF-1.4"
def test_returns_none_for_malformed_data_url(tmp_path) -> None: def test_returns_none_for_malformed_data_url(tmp_path) -> None:
assert save_base64_data_url("not-a-data-url", tmp_path) is None assert save_base64_data_url("not-a-data-url", tmp_path) is None
def test_returns_none_for_broken_base64(tmp_path) -> None: @pytest.mark.parametrize("payload", ["not-valid-base64!!!", "@@@@"])
# Python's b64decode strips non-alphabet chars by default, so we need a def test_returns_none_for_broken_base64(tmp_path, payload: str) -> None:
# payload whose alphabet-filtered length breaks padding. assert save_base64_data_url(f"data:image/png;base64,{payload}", tmp_path) is None
assert save_base64_data_url("data:image/png;base64,not-valid-base64!!!", tmp_path) is None assert list(tmp_path.iterdir()) == []
def test_unknown_mime_falls_back_to_bin(tmp_path) -> None: def test_unknown_mime_falls_back_to_bin(tmp_path) -> None:
+117
View File
@@ -0,0 +1,117 @@
from __future__ import annotations
import base64
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock
import pytest
from nanobot.webui.attachment_ingress import (
extract_data_url_mime,
store_inbound_attachments,
)
from nanobot.webui.ingress_policy import AttachmentIngressLimits
def _data_url(mime: str, payload: bytes) -> str:
encoded = base64.b64encode(payload).decode()
return f"data:{mime};base64,{encoded}"
@pytest.mark.parametrize(
("url", "expected"),
[
("data:image/png;base64,AAAA", "image/png"),
("data:IMAGE/JPEG;charset=utf-8;base64,AAAA", "image/jpeg"),
("data:video/webm;codecs=vp9;base64,AAAA", "video/webm"),
("data:text/plain;base64,AAAA", "text/plain"),
("data:image/svg+xml;base64,AAAA", "image/svg+xml"),
("data:image/png,AAAA", None),
("data:;base64,AAAA", None),
("https://example.invalid/image.png", None),
("", None),
(None, None),
],
)
def test_extract_data_url_mime_normalizes_only_base64_data_urls(
url: Any,
expected: str | None,
) -> None:
assert extract_data_url_mime(url) == expected
def test_store_inbound_document_preserves_safe_name(tmp_path: Path) -> None:
paths, rejection = store_inbound_attachments(
[
{
"data_url": _data_url("text/csv", b"name,value\nnanobot,1"),
"name": "report.csv",
},
],
media_dir=tmp_path,
logger=MagicMock(),
)
assert rejection is None
assert len(paths) == 1
saved = Path(paths[0])
assert saved.parent == tmp_path
assert saved.name.endswith("_report.csv")
assert saved.read_bytes() == b"name,value\nnanobot,1"
def test_invalid_batch_removes_files_already_persisted(tmp_path: Path) -> None:
paths, rejection = store_inbound_attachments(
[
{"data_url": _data_url("image/png", b"valid-first-item")},
{"data_url": _data_url("image/svg+xml", b"<svg/>")},
],
media_dir=tmp_path,
logger=MagicMock(),
)
assert paths == []
assert rejection == "mime"
assert list(tmp_path.iterdir()) == []
def test_invalid_base64_cannot_create_an_empty_attachment(tmp_path: Path) -> None:
paths, rejection = store_inbound_attachments(
[{"data_url": "data:text/plain;base64,@@@@", "name": "empty.txt"}],
media_dir=tmp_path,
logger=MagicMock(),
)
assert paths == []
assert rejection == "decode"
assert list(tmp_path.iterdir()) == []
def test_single_file_limit_is_attachment_policy_not_transport(tmp_path: Path) -> None:
paths, rejection = store_inbound_attachments(
[{"data_url": _data_url("text/plain", b"12345"), "name": "large.txt"}],
media_dir=tmp_path,
logger=MagicMock(),
limits=AttachmentIngressLimits(max_file_bytes=4, max_total_bytes=20),
)
assert paths == []
assert rejection == "size"
assert list(tmp_path.iterdir()) == []
def test_total_attachment_policy_rolls_back_the_batch(tmp_path: Path) -> None:
paths, rejection = store_inbound_attachments(
[
{"data_url": _data_url("text/plain", b"1234"), "name": "one.txt"},
{"data_url": _data_url("text/plain", b"5678"), "name": "two.txt"},
],
media_dir=tmp_path,
logger=MagicMock(),
limits=AttachmentIngressLimits(max_file_bytes=4, max_total_bytes=6),
)
assert paths == []
assert rejection == "total_size"
assert list(tmp_path.iterdir()) == []
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
from nanobot.webui.ingress_policy import WebUIIngressPolicy
def test_text_limit_counts_utf8_bytes() -> None:
policy = WebUIIngressPolicy()
assert policy.validate_text("x" * policy.message.max_text_bytes) is None
assert policy.validate_text("" * 22_000) == "text_too_large"
def test_bootstrap_keeps_transport_and_business_limits_separate() -> None:
policy = WebUIIngressPolicy()
payload = policy.bootstrap_limits(max_frame_bytes=1_048_576)
assert payload["transport"] == {
"max_frame_bytes": 1_048_576,
"envelope_reserve_bytes": 65_536,
}
assert payload["message"] == {"max_text_bytes": 65_536}
assert payload["attachments"] == {
"max_count": 4,
"max_file_bytes": 6_291_456,
"max_total_bytes": 25_165_824,
}
assert policy.minimum_full_policy_frame_bytes() < 36 * 1024 * 1024
+5
View File
@@ -38,6 +38,7 @@ import { deriveTitle } from "@/lib/format";
import { NanobotClient } from "@/lib/nanobot-client"; import { NanobotClient } from "@/lib/nanobot-client";
import { ClientProvider, useClient } from "@/providers/ClientProvider"; import { ClientProvider, useClient } from "@/providers/ClientProvider";
import type { import type {
BootstrapResponse,
ChatSummary, ChatSummary,
RuntimeSurface, RuntimeSurface,
PairingRequestInfo, PairingRequestInfo,
@@ -71,6 +72,7 @@ type BootState =
token: string; token: string;
tokenExpiresAt: number; tokenExpiresAt: number;
modelName: string | null; modelName: string | null;
ingressLimits: BootstrapResponse["limits"] | null;
runtimeSurface: RuntimeSurface; runtimeSurface: RuntimeSurface;
}; };
@@ -801,6 +803,7 @@ export default function App() {
token: boot.api_token, token: boot.api_token,
tokenExpiresAt, tokenExpiresAt,
modelName: boot.model_name ?? current.modelName, modelName: boot.model_name ?? current.modelName,
ingressLimits: boot.limits ?? current.ingressLimits,
runtimeSurface, runtimeSurface,
} }
: current, : current,
@@ -842,6 +845,7 @@ export default function App() {
token: boot.api_token, token: boot.api_token,
tokenExpiresAt: bootstrapTokenExpiresAt(boot.expires_in), tokenExpiresAt: bootstrapTokenExpiresAt(boot.expires_in),
modelName: boot.model_name ?? null, modelName: boot.model_name ?? null,
ingressLimits: boot.limits ?? null,
runtimeSurface, runtimeSurface,
}); });
} catch (e) { } catch (e) {
@@ -964,6 +968,7 @@ export default function App() {
client={state.client} client={state.client}
token={state.token} token={state.token}
modelName={state.modelName} modelName={state.modelName}
ingressLimits={state.ingressLimits}
> >
<Shell <Shell
runtimeSurface={state.runtimeSurface} runtimeSurface={state.runtimeSurface}
+75 -23
View File
@@ -27,6 +27,7 @@ import {
ChevronUp, ChevronUp,
CircleHelp, CircleHelp,
CornerDownRight, CornerDownRight,
FileText,
GripVertical, GripVertical,
History, History,
ImageIcon, ImageIcon,
@@ -58,15 +59,17 @@ import {
WorkspaceProjectPicker, WorkspaceProjectPicker,
} from "@/components/thread/WorkspaceControls"; } from "@/components/thread/WorkspaceControls";
import { import {
ACCEPT_ATTR,
MAX_ATTACHMENTS_PER_MESSAGE,
useAttachedImages, useAttachedImages,
type AttachedImage, type AttachedImage,
type AttachmentError, type AttachmentError,
MAX_IMAGES_PER_MESSAGE, type AttachmentKind,
type RestoredReadyImage, type RestoredReadyImage,
} from "@/hooks/useAttachedImages"; } from "@/hooks/useAttachedImages";
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop"; import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
import { useLogoFallback } from "@/hooks/useLogoFallback"; import { useLogoFallback } from "@/hooks/useLogoFallback";
import type { SendImage, SendOptions } from "@/hooks/useNanobotStream"; import type { SendAttachment, SendOptions } from "@/hooks/useNanobotStream";
import { useVoiceRecorder, type VoiceRecorderErrorKey } from "@/hooks/useVoiceRecorder"; import { useVoiceRecorder, type VoiceRecorderErrorKey } from "@/hooks/useVoiceRecorder";
import type { import type {
CliAppInfo, CliAppInfo,
@@ -76,6 +79,7 @@ import type {
OutboundMcpPresetMention, OutboundMcpPresetMention,
SlashCommand, SlashCommand,
SkillSummary, SkillSummary,
WebUIIngressLimits,
WorkspaceScopePayload, WorkspaceScopePayload,
WorkspacesPayload, WorkspacesPayload,
} from "@/lib/types"; } from "@/lib/types";
@@ -86,9 +90,6 @@ import {
} from "@/lib/provider-brand"; } from "@/lib/provider-brand";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
/** ``<input accept>``: aligned with the server's MIME whitelist. SVG is
* deliberately excluded to avoid an embedded-script XSS surface. */
const ACCEPT_ATTR = "image/png,image/jpeg,image/webp,image/gif";
const VOICE_SHORTCUT_CODE = "KeyD"; const VOICE_SHORTCUT_CODE = "KeyD";
const VOICE_SHORTCUT_ARIA = "Control+Shift+D"; const VOICE_SHORTCUT_ARIA = "Control+Shift+D";
type VoiceShortcutPlatform = "apple" | "chromeos" | "linux" | "other" | "windows"; type VoiceShortcutPlatform = "apple" | "chromeos" | "linux" | "other" | "windows";
@@ -143,6 +144,10 @@ function formatBytes(n: number): string {
return `${(n / (1024 * 1024)).toFixed(1)} MB`; return `${(n / (1024 * 1024)).toFixed(1)} MB`;
} }
function utf8Bytes(value: string): number {
return new TextEncoder().encode(value).byteLength;
}
function isVoiceShortcutDown(event: KeyboardEvent): boolean { function isVoiceShortcutDown(event: KeyboardEvent): boolean {
return ( return (
event.code === VOICE_SHORTCUT_CODE event.code === VOICE_SHORTCUT_CODE
@@ -192,7 +197,7 @@ function getVoiceShortcutLabel(): string {
} }
interface ThreadComposerProps { interface ThreadComposerProps {
onSend: (content: string, images?: SendImage[], options?: SendOptions) => void; onSend: (content: string, images?: SendAttachment[], options?: SendOptions) => void;
disabled?: boolean; disabled?: boolean;
placeholder?: string; placeholder?: string;
isStreaming?: boolean; isStreaming?: boolean;
@@ -220,6 +225,7 @@ interface ThreadComposerProps {
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void; onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
pendingQueueKey?: string | null; pendingQueueKey?: string | null;
transcriptionProvider?: string | null; transcriptionProvider?: string | null;
ingressLimits?: WebUIIngressLimits | null;
} }
const COMMAND_ICONS: Record<string, LucideIcon> = { const COMMAND_ICONS: Record<string, LucideIcon> = {
@@ -301,6 +307,7 @@ interface QueuedPrompt {
interface QueuedPromptImage { interface QueuedPromptImage {
dataUrl: string; dataUrl: string;
name?: string; name?: string;
kind?: AttachmentKind;
} }
interface CliAppMentionQuery { interface CliAppMentionQuery {
@@ -367,16 +374,22 @@ function normalizeQueuedPrompt(item: unknown, index: number): QueuedPrompt | nul
? record.images.flatMap((image) => { ? record.images.flatMap((image) => {
if (!image || typeof image !== "object") return []; if (!image || typeof image !== "object") return [];
const candidate = image as Partial<QueuedPromptImage>; const candidate = image as Partial<QueuedPromptImage>;
if (typeof candidate.dataUrl !== "string" || !candidate.dataUrl.startsWith("data:image/")) { if (typeof candidate.dataUrl !== "string" || !candidate.dataUrl.startsWith("data:")) {
return []; return [];
} }
const kind = candidate.kind === "file" || candidate.kind === "image"
? candidate.kind
: candidate.dataUrl.startsWith("data:image/")
? "image"
: "file";
return [{ return [{
dataUrl: candidate.dataUrl, dataUrl: candidate.dataUrl,
kind,
...(typeof candidate.name === "string" && candidate.name.trim() ...(typeof candidate.name === "string" && candidate.name.trim()
? { name: candidate.name.trim() } ? { name: candidate.name.trim() }
: {}), : {}),
}]; }];
}).slice(0, MAX_IMAGES_PER_MESSAGE) }).slice(0, MAX_ATTACHMENTS_PER_MESSAGE)
: []; : [];
if (!text && images.length === 0) return null; if (!text && images.length === 0) return null;
const id = typeof record.id === "string" && record.id.trim() const id = typeof record.id === "string" && record.id.trim()
@@ -413,7 +426,7 @@ function storeQueuedPrompts(storageKey: string, prompts: QueuedPrompt[]): void {
prompts.slice(0, QUEUED_PROMPTS_LIMIT).map((prompt) => ({ prompts.slice(0, QUEUED_PROMPTS_LIMIT).map((prompt) => ({
id: prompt.id, id: prompt.id,
text: prompt.text.slice(0, QUEUED_PROMPT_MAX_CHARS), text: prompt.text.slice(0, QUEUED_PROMPT_MAX_CHARS),
...(prompt.images?.length ? { images: prompt.images.slice(0, MAX_IMAGES_PER_MESSAGE) } : {}), ...(prompt.images?.length ? { images: prompt.images.slice(0, MAX_ATTACHMENTS_PER_MESSAGE) } : {}),
})), })),
), ),
); );
@@ -427,11 +440,12 @@ function readyImagesToQueuedImages(
): QueuedPromptImage[] { ): QueuedPromptImage[] {
return images.map((img) => ({ return images.map((img) => ({
dataUrl: img.dataUrl, dataUrl: img.dataUrl,
kind: img.kind,
name: img.file.name, name: img.file.name,
})); }));
} }
function queuedImagesToSendImages(images?: QueuedPromptImage[]): SendImage[] | undefined { function queuedImagesToSendImages(images?: QueuedPromptImage[]): SendAttachment[] | undefined {
if (!images?.length) return undefined; if (!images?.length) return undefined;
return images.map((img) => ({ return images.map((img) => ({
media: { media: {
@@ -439,6 +453,7 @@ function queuedImagesToSendImages(images?: QueuedPromptImage[]): SendImage[] | u
...(img.name ? { name: img.name } : {}), ...(img.name ? { name: img.name } : {}),
}, },
preview: { preview: {
kind: img.kind ?? (img.dataUrl.startsWith("data:image/") ? "image" : "file"),
url: img.dataUrl, url: img.dataUrl,
...(img.name ? { name: img.name } : {}), ...(img.name ? { name: img.name } : {}),
}, },
@@ -448,7 +463,7 @@ function queuedImagesToSendImages(images?: QueuedPromptImage[]): SendImage[] | u
function queuedPromptLabel(prompt: QueuedPrompt): string { function queuedPromptLabel(prompt: QueuedPrompt): string {
const text = prompt.text.trim(); const text = prompt.text.trim();
if (text) return text; if (text) return text;
return prompt.images?.map((img) => img.name).filter(Boolean).join(", ") || "Image attachment"; return prompt.images?.map((img) => img.name).filter(Boolean).join(", ") || "File attachment";
} }
function suppressNativeDragPreview(dataTransfer: DataTransfer): void { function suppressNativeDragPreview(dataTransfer: DataTransfer): void {
@@ -837,6 +852,7 @@ export function ThreadComposer({
onWorkspaceScopeChange, onWorkspaceScopeChange,
pendingQueueKey = null, pendingQueueKey = null,
transcriptionProvider = null, transcriptionProvider = null,
ingressLimits = null,
}: ThreadComposerProps) { }: ThreadComposerProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [value, setValue] = useState(""); const [value, setValue] = useState("");
@@ -892,15 +908,37 @@ export function ThreadComposer({
? t("thread.composer.placeholderStreaming") ? t("thread.composer.placeholderStreaming")
: placeholder ?? t("thread.composer.placeholderThread"); : placeholder ?? t("thread.composer.placeholderThread");
const maxAttachments = ingressLimits?.attachments.max_count
?? MAX_ATTACHMENTS_PER_MESSAGE;
const maxTextBytes = ingressLimits?.message.max_text_bytes ?? 64 * 1024;
const { images, enqueue, remove, clear, restoreReadyImages, encoding, full } = const { images, enqueue, remove, clear, restoreReadyImages, encoding, full } =
useAttachedImages(); useAttachedImages({ ingressLimits });
const formatRejection = useCallback( const formatRejection = useCallback(
(reason: AttachmentError): string => { (reason: AttachmentError): string => {
const key = `thread.composer.imageRejected.${reason}`; const key = `thread.composer.imageRejected.${reason}`;
return t(key, { max: MAX_IMAGES_PER_MESSAGE }); const fallback = reason === "too_many_attachments"
? `Max ${maxAttachments} attachments per message`
: reason === "empty_file"
? "Empty files cannot be attached"
: reason === "total_too_large"
? "Attachments are too large together — remove some or use smaller files"
: reason === "transport_too_large"
? "This attachment would exceed the gateway transport limit"
: reason === "too_large"
? "File is too large"
: "Unsupported file type";
return t(key, { max: maxAttachments, defaultValue: fallback });
}, },
[t], [maxAttachments, t],
);
const textTooLargeMessage = useCallback(
() => t("thread.composer.textTooLarge", {
max: formatBytes(maxTextBytes),
defaultValue: `Message text is too large (max ${formatBytes(maxTextBytes)})`,
}),
[maxTextBytes, t],
); );
const addFiles = useCallback( const addFiles = useCallback(
@@ -1411,6 +1449,10 @@ export function ThreadComposer({
const queueGuidancePrompt = useCallback(() => { const queueGuidancePrompt = useCallback(() => {
const text = value.trim(); const text = value.trim();
if (!canQueueGuidance || (!text && readyImages.length === 0)) return; if (!canQueueGuidance || (!text && readyImages.length === 0)) return;
if (utf8Bytes(text) > maxTextBytes) {
setInlineError(textTooLargeMessage());
return;
}
const queuedImages = readyImagesToQueuedImages(readyImages); const queuedImages = readyImagesToQueuedImages(readyImages);
queuedPromptCounterRef.current += 1; queuedPromptCounterRef.current += 1;
const id = `queued-prompt-${Date.now()}-${queuedPromptCounterRef.current}`; const id = `queued-prompt-${Date.now()}-${queuedPromptCounterRef.current}`;
@@ -1425,7 +1467,7 @@ export function ThreadComposer({
]); ]);
clear(); clear();
clearComposerText(); clearComposerText();
}, [canQueueGuidance, clear, clearComposerText, readyImages, value]); }, [canQueueGuidance, clear, clearComposerText, maxTextBytes, readyImages, textTooLargeMessage, value]);
const removeQueuedPrompt = useCallback((id: string) => { const removeQueuedPrompt = useCallback((id: string) => {
secondEnterPromptIdRef.current = null; secondEnterPromptIdRef.current = null;
@@ -1526,18 +1568,22 @@ export function ThreadComposer({
if (!canSend) return; if (!canSend) return;
const trimmed = value.trim(); const trimmed = value.trim();
const content = trimmed; const content = trimmed;
// Share the same normalized ``data:`` URL with both the wire payload and if (utf8Bytes(content) > maxTextBytes) {
// the optimistic bubble preview: data URLs are self-contained (no blob setInlineError(textTooLargeMessage());
// lifetime, safe under React StrictMode double-mount) and keep the return;
// bubble in sync with whatever the backend actually sees. }
const payload: SendImage[] | undefined = // Share the same ``data:`` URL with both the wire payload and the
// optimistic bubble preview: data URLs are self-contained (no blob
// lifetime, safe under React StrictMode double-mount) and keep the bubble
// in sync with whatever the backend actually sees.
const payload: SendAttachment[] | undefined =
readyImages.length > 0 readyImages.length > 0
? readyImages.map((img) => ({ ? readyImages.map((img) => ({
media: { media: {
data_url: img.dataUrl, data_url: img.dataUrl,
name: img.file.name, name: img.file.name,
}, },
preview: { url: img.dataUrl, name: img.file.name }, preview: { kind: img.kind, url: img.dataUrl, name: img.file.name },
})) }))
: undefined; : undefined;
const attachedCliApps = activeCliMentionApps.map(cliAppMentionPayload); const attachedCliApps = activeCliMentionApps.map(cliAppMentionPayload);
@@ -1594,12 +1640,14 @@ export function ThreadComposer({
clearComposerText, clearComposerText,
handleStop, handleStop,
isStreaming, isStreaming,
maxTextBytes,
modelNeedsSetup, modelNeedsSetup,
onModelBadgeClick, onModelBadgeClick,
onSend, onSend,
onStop, onStop,
readyImages, readyImages,
slashCommands, slashCommands,
textTooLargeMessage,
value, value,
]); ]);
@@ -2673,7 +2721,7 @@ function AttachmentChip({
data-testid="composer-chip" data-testid="composer-chip"
> >
<div className="relative h-10 w-10 overflow-hidden rounded-md bg-background"> <div className="relative h-10 w-10 overflow-hidden rounded-md bg-background">
{image.previewUrl ? ( {image.kind === "image" && image.previewUrl ? (
<img <img
src={image.previewUrl} src={image.previewUrl}
alt="" alt=""
@@ -2684,7 +2732,11 @@ function AttachmentChip({
/> />
) : ( ) : (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
<ImageIcon className="h-4 w-4 text-muted-foreground" aria-hidden /> {image.kind === "image" ? (
<ImageIcon className="h-4 w-4 text-muted-foreground" aria-hidden />
) : (
<FileText className="h-4 w-4 text-muted-foreground" aria-hidden />
)}
</div> </div>
)} )}
{image.status === "encoding" ? ( {image.status === "encoding" ? (
+7 -5
View File
@@ -9,7 +9,7 @@ import { ThreadComposer } from "@/components/thread/ThreadComposer";
import { ThreadHeader } from "@/components/thread/ThreadHeader"; import { ThreadHeader } from "@/components/thread/ThreadHeader";
import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice"; import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
import { ThreadViewport, type ThreadViewportHandle } from "@/components/thread/ThreadViewport"; import { ThreadViewport, type ThreadViewportHandle } from "@/components/thread/ThreadViewport";
import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useNanobotStream"; import { useNanobotStream, type SendAttachment, type SendOptions } from "@/hooks/useNanobotStream";
import { useSessionHistory } from "@/hooks/useSessions"; import { useSessionHistory } from "@/hooks/useSessions";
import { import {
fetchInstalledCliApps, fetchInstalledCliApps,
@@ -212,7 +212,7 @@ function randomHeroGreetingKey(): (typeof HERO_GREETING_KEYS)[number] {
interface PendingFirstMessage { interface PendingFirstMessage {
content: string; content: string;
images?: SendImage[]; images?: SendAttachment[];
options?: SendOptions; options?: SendOptions;
} }
@@ -311,7 +311,7 @@ export function ThreadShell({
version: historyVersion, version: historyVersion,
forkBoundaryMessageCount, forkBoundaryMessageCount,
} = useSessionHistory(historyKey); } = useSessionHistory(historyKey);
const { client, modelName, token } = useClient(); const { client, ingressLimits, modelName, token } = useClient();
const [booting, setBooting] = useState(false); const [booting, setBooting] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]); const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const cliApps = useInstalledSettingItems({ const cliApps = useInstalledSettingItems({
@@ -589,7 +589,7 @@ export function ThreadShell({
}, [token]); }, [token]);
const handleWelcomeSend = useCallback( const handleWelcomeSend = useCallback(
async (content: string, images?: SendImage[], options?: SendOptions) => { async (content: string, images?: SendAttachment[], options?: SendOptions) => {
if (booting) return; if (booting) return;
setBooting(true); setBooting(true);
pendingFirstRef.current = { content, images, options: withWorkspaceScope(options) }; pendingFirstRef.current = { content, images, options: withWorkspaceScope(options) };
@@ -607,7 +607,7 @@ export function ThreadShell({
); );
const handleThreadSend = useCallback( const handleThreadSend = useCallback(
(content: string, images?: SendImage[], options?: SendOptions) => { (content: string, images?: SendAttachment[], options?: SendOptions) => {
setScrollToLatestUserPromptSignal((value) => value + 1); setScrollToLatestUserPromptSignal((value) => value + 1);
send(content, images, withWorkspaceScope(options)); send(content, images, withWorkspaceScope(options));
}, },
@@ -754,6 +754,7 @@ export function ThreadShell({
onWorkspaceScopeChange={onWorkspaceScopeChange} onWorkspaceScopeChange={onWorkspaceScopeChange}
pendingQueueKey={chatId} pendingQueueKey={chatId}
transcriptionProvider={settingsSnapshot?.transcription?.provider} transcriptionProvider={settingsSnapshot?.transcription?.provider}
ingressLimits={ingressLimits}
/> />
) : ( ) : (
<ThreadComposer <ThreadComposer
@@ -785,6 +786,7 @@ export function ThreadShell({
workspaceError={workspaceError} workspaceError={workspaceError}
onWorkspaceScopeChange={onWorkspaceScopeChange} onWorkspaceScopeChange={onWorkspaceScopeChange}
transcriptionProvider={settingsSnapshot?.transcription?.provider} transcriptionProvider={settingsSnapshot?.transcription?.provider}
ingressLimits={ingressLimits}
/> />
)} )}
</> </>
+254 -53
View File
@@ -1,21 +1,24 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { encodeImage, type EncodeFailure } from "@/lib/imageEncode"; import { encodeImage, type EncodeFailure } from "@/lib/imageEncode";
import type { WebUIIngressLimits } from "@/lib/types";
/** Lifecycle stages of one attachment: /** Lifecycle stages of one attachment:
* *
* - ``encoding`` posted to the Worker; chip shows a spinner * - ``encoding`` posted to the Worker / read from disk; chip shows a spinner
* - ``ready`` ``dataUrl`` available; safe to submit * - ``ready`` ``dataUrl`` available; safe to submit
* - ``error`` validation / decode failure; chip shows inline error * - ``error`` validation / decode failure; chip shows inline error
*/ */
export type AttachmentStatus = "encoding" | "ready" | "error"; export type AttachmentStatus = "encoding" | "ready" | "error";
export type AttachmentKind = "image" | "file";
export interface AttachedImage { export interface AttachedAttachment {
id: string; id: string;
kind: AttachmentKind;
file: File; file: File;
/** Optimistic ``blob:`` preview URL; revoked on ``remove`` / ``clear`` / /** Optimistic ``blob:`` preview URL; revoked on ``remove`` / ``clear`` /
* unmount. */ * unmount. */
previewUrl: string; previewUrl?: string;
status: AttachmentStatus; status: AttachmentStatus;
/** Populated when ``status === "ready"``. */ /** Populated when ``status === "ready"``. */
dataUrl?: string; dataUrl?: string;
@@ -27,37 +30,131 @@ export interface AttachedImage {
error?: AttachmentError; error?: AttachmentError;
} }
export interface RestoredReadyImage { export type AttachedImage = AttachedAttachment;
export interface RestoredReadyAttachment {
dataUrl: string; dataUrl: string;
name?: string; name?: string;
kind?: AttachmentKind;
} }
export type RestoredReadyImage = RestoredReadyAttachment;
/** Machine-readable rejection reasons surfaced as inline chip errors. /** Machine-readable rejection reasons surfaced as inline chip errors.
* *
* Callers localize these via the ``composer.imageRejected.*`` i18n table. */ * Callers localize these via the ``composer.imageRejected.*`` i18n table. */
export type AttachmentError = export type AttachmentError =
| "unsupported_type" // server whitelist excludes this MIME | "unsupported_type" // server whitelist excludes this MIME
| "too_many_images" // per-message cap (4) reached before enqueue | "empty_file" // backend data-URL decoder rejects empty payloads
| "too_many_attachments" // per-message cap (4) reached before enqueue
| "total_too_large" // decoded attachments exceed the business-policy total
| "transport_too_large" // projected JSON frame exceeds the transport guard
| "magic_mismatch" // extension lies about the real content | "magic_mismatch" // extension lies about the real content
| "decode_failed" // Worker couldn't decode / re-encode | "decode_failed" // Worker couldn't decode / re-encode
| "too_large" // even after normalization we exceed the budget | "too_large" // even after normalization we exceed the budget
| "io"; // file read failed at the browser layer | "io"; // file read failed at the browser layer
export const MAX_IMAGES_PER_MESSAGE = 4; export const MAX_ATTACHMENTS_PER_MESSAGE = 4;
export const MAX_IMAGES_PER_MESSAGE = MAX_ATTACHMENTS_PER_MESSAGE;
export const MAX_ATTACHMENT_BYTES = 6 * 1024 * 1024;
export const MAX_TOTAL_ATTACHMENT_BYTES = 24 * 1024 * 1024;
/** MIME whitelist — mirrors the server's and the ``<input accept>`` attr. */ /** MIME whitelist — mirrors the server's and the ``<input accept>`` attr. */
const ACCEPTED_MIMES: ReadonlySet<string> = new Set([ const ACCEPTED_IMAGE_MIMES: ReadonlySet<string> = new Set([
"image/png", "image/png",
"image/jpeg", "image/jpeg",
"image/webp", "image/webp",
"image/gif", "image/gif",
]); ]);
const DOCUMENT_MIME_BY_EXTENSION: ReadonlyMap<string, string> = new Map([
[".pdf", "application/pdf"],
[".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
[".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
[".pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"],
[".txt", "text/plain"],
[".md", "text/markdown"],
[".csv", "text/csv"],
[".json", "application/json"],
[".xml", "application/xml"],
[".html", "text/html"],
[".htm", "text/html"],
[".log", "text/plain"],
[".yaml", "application/yaml"],
[".yml", "application/yaml"],
[".toml", "application/toml"],
[".ini", "text/plain"],
[".cfg", "text/plain"],
]);
const ACCEPTED_DOCUMENT_MIMES: ReadonlySet<string> = new Set(DOCUMENT_MIME_BY_EXTENSION.values());
export const ACCEPT_ATTR = [
...ACCEPTED_IMAGE_MIMES,
...ACCEPTED_DOCUMENT_MIMES,
...DOCUMENT_MIME_BY_EXTENSION.keys(),
].join(",");
function extensionOf(name: string): string {
const dot = name.lastIndexOf(".");
return dot < 0 ? "" : name.slice(dot).toLowerCase();
}
function mimeForFile(file: File): string {
const byName = DOCUMENT_MIME_BY_EXTENSION.get(extensionOf(file.name));
if (byName) return byName;
if (!file.type || file.type === "application/octet-stream") {
return "application/octet-stream";
}
return file.type;
}
function projectedDataUrlBytes(
file: File,
kind: AttachmentKind,
maxFileBytes: number,
): number {
const prefixBytes = `data:${mimeForFile(file)};base64,`.length;
const decodedBytes = kind === "image" ? Math.min(file.size, maxFileBytes) : file.size;
return prefixBytes + 4 * Math.ceil(decodedBytes / 3);
}
function positiveLimit(value: number | null | undefined, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) && value > 0
? Math.floor(value)
: fallback;
}
function attachmentPayloadBudget(limits: WebUIIngressLimits | null | undefined): number | null {
const maxFrameBytes = limits?.transport.max_frame_bytes;
if (typeof maxFrameBytes !== "number" || !Number.isFinite(maxFrameBytes)) {
return null;
}
return Math.max(
0,
Math.floor(maxFrameBytes)
- positiveLimit(limits?.message.max_text_bytes, 0)
- positiveLimit(limits?.transport.envelope_reserve_bytes, 0),
);
}
export function acceptedAttachmentKind(file: File): AttachmentKind | null {
if (DOCUMENT_MIME_BY_EXTENSION.has(extensionOf(file.name))) return "file";
if (ACCEPTED_IMAGE_MIMES.has(file.type)) return "image";
const mime = mimeForFile(file);
if (ACCEPTED_DOCUMENT_MIMES.has(mime)) return "file";
return null;
}
function dataUrlMime(dataUrl: string): string { function dataUrlMime(dataUrl: string): string {
const match = /^data:([^;,]+)[;,]/.exec(dataUrl); const match = /^data:([^;,]+)[;,]/.exec(dataUrl);
return match?.[1] || "image/png"; return match?.[1] || "image/png";
} }
function kindFromDataUrl(dataUrl: string): AttachmentKind {
return dataUrlMime(dataUrl).startsWith("image/") ? "image" : "file";
}
function dataUrlToFile(dataUrl: string, name?: string): File { function dataUrlToFile(dataUrl: string, name?: string): File {
const mime = dataUrlMime(dataUrl); const mime = dataUrlMime(dataUrl);
const fallbackName = `image.${mime.split("/")[1] || "png"}`; const fallbackName = `image.${mime.split("/")[1] || "png"}`;
@@ -81,6 +178,40 @@ function uuid(): string {
return `img-${Date.now()}-${Math.random().toString(36).slice(2)}`; return `img-${Date.now()}-${Math.random().toString(36).slice(2)}`;
} }
function bufferToBase64(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf);
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode.apply(
null,
bytes.subarray(i, i + chunk) as unknown as number[],
);
}
return btoa(binary);
}
async function encodeFile(file: File, maxFileBytes: number): Promise<{
ok: true;
dataUrl: string;
bytes: number;
} | {
ok: false;
reason: AttachmentError;
}> {
if (file.size > maxFileBytes) return { ok: false, reason: "too_large" };
try {
const buffer = await file.arrayBuffer();
return {
ok: true,
dataUrl: `data:${mimeForFile(file)};base64,${bufferToBase64(buffer)}`,
bytes: file.size,
};
} catch {
return { ok: false, reason: "io" };
}
}
function mapEncodeFailure(reason: EncodeFailure["reason"]): AttachmentError { function mapEncodeFailure(reason: EncodeFailure["reason"]): AttachmentError {
switch (reason) { switch (reason) {
case "invalid_mime": case "invalid_mime":
@@ -97,11 +228,11 @@ function mapEncodeFailure(reason: EncodeFailure["reason"]): AttachmentError {
} }
export interface UseAttachedImagesApi { export interface UseAttachedImagesApi {
images: AttachedImage[]; images: AttachedAttachment[];
/** Enqueue new files. Returns the list of rejected files so the caller can /** Enqueue new files. Returns the list of rejected files so the caller can
* surface inline errors. Files rejected client-side (wrong MIME, limit) are * surface inline errors. Files rejected client-side (wrong MIME, limit) are
* *not* added to ``images`` only recoverable encoding failures show up as * *not* added to ``images`` only recoverable read/encoding failures show
* error chips. */ * up as error chips. */
enqueue: (files: Iterable<File>) => { enqueue: (files: Iterable<File>) => {
rejected: Array<{ file: File; reason: AttachmentError }>; rejected: Array<{ file: File; reason: AttachmentError }>;
}; };
@@ -110,17 +241,21 @@ export interface UseAttachedImagesApi {
* successful submit the optimistic bubble holds onto an independent * successful submit the optimistic bubble holds onto an independent
* ``data:`` URL so tearing down blob previews here is safe. */ * ``data:`` URL so tearing down blob previews here is safe. */
clear: () => void; clear: () => void;
/** Restore already-encoded images, e.g. a queued composer draft moving back /** Restore already-encoded attachments, e.g. a queued composer draft moving
* into the input. These entries are immediately sendable and use their * back into the input. These entries are immediately sendable and use image
* ``data:`` URL as a stable preview. */ * ``data:`` URLs as stable previews. */
restoreReadyImages: (images: RestoredReadyImage[]) => void; restoreReadyImages: (images: RestoredReadyAttachment[]) => void;
/** ``true`` when at least one image is still encoding — Send should wait. */ /** ``true`` when at least one attachment is still encoding — Send should wait. */
encoding: boolean; encoding: boolean;
/** ``true`` when we've hit ``MAX_IMAGES_PER_MESSAGE``. */ /** ``true`` when we've hit ``MAX_ATTACHMENTS_PER_MESSAGE``. */
full: boolean; full: boolean;
} }
/** Manage the lifecycle of images attached to the Composer. interface UseAttachedImagesOptions {
ingressLimits?: WebUIIngressLimits | null;
}
/** Manage the lifecycle of attachments in the Composer.
* *
* Responsibilities in one place: * Responsibilities in one place:
* - validation (MIME whitelist, count cap) * - validation (MIME whitelist, count cap)
@@ -128,15 +263,29 @@ export interface UseAttachedImagesApi {
* - Worker orchestration * - Worker orchestration
* - focus bookkeeping so keyboard delete doesn't strand the user * - focus bookkeeping so keyboard delete doesn't strand the user
*/ */
export function useAttachedImages(): UseAttachedImagesApi { export function useAttachedImages({
const [images, setImages] = useState<AttachedImage[]>([]); ingressLimits = null,
}: UseAttachedImagesOptions = {}): UseAttachedImagesApi {
const [images, setImages] = useState<AttachedAttachment[]>([]);
const maxAttachments = positiveLimit(
ingressLimits?.attachments.max_count,
MAX_ATTACHMENTS_PER_MESSAGE,
);
const maxFileBytes = positiveLimit(
ingressLimits?.attachments.max_file_bytes,
MAX_ATTACHMENT_BYTES,
);
const maxTotalBytes = positiveLimit(
ingressLimits?.attachments.max_total_bytes,
MAX_TOTAL_ATTACHMENT_BYTES,
);
// Ref mirror so ``enqueue`` can see the authoritative length when invoked // Ref mirror so ``enqueue`` can see the authoritative length when invoked
// multiple times in a single tick (rapid file selection, drag of many // multiple times in a single tick (rapid file selection, drag of many
// files, paste storms). ``state`` is stale for that second + call. // files, paste storms). ``state`` is stale for that second + call.
const imagesRef = useRef<AttachedImage[]>([]); const imagesRef = useRef<AttachedAttachment[]>([]);
imagesRef.current = images; imagesRef.current = images;
const setEntry = useCallback((id: string, patch: Partial<AttachedImage>) => { const setEntry = useCallback((id: string, patch: Partial<AttachedAttachment>) => {
setImages((prev) => { setImages((prev) => {
const next = prev.map((img) => (img.id === id ? { ...img, ...patch } : img)); const next = prev.map((img) => (img.id === id ? { ...img, ...patch } : img));
imagesRef.current = next; imagesRef.current = next;
@@ -147,23 +296,60 @@ export function useAttachedImages(): UseAttachedImagesApi {
const enqueue = useCallback( const enqueue = useCallback(
(files: Iterable<File>) => { (files: Iterable<File>) => {
const rejected: Array<{ file: File; reason: AttachmentError }> = []; const rejected: Array<{ file: File; reason: AttachmentError }> = [];
const toAdd: AttachedImage[] = []; const toAdd: AttachedAttachment[] = [];
let slot = MAX_IMAGES_PER_MESSAGE - imagesRef.current.length; let slot = maxAttachments - imagesRef.current.length;
const payloadBudget = attachmentPayloadBudget(ingressLimits);
let projectedWireBytes = imagesRef.current.reduce(
(total, image) => total + (
image.dataUrl?.length
?? projectedDataUrlBytes(image.file, image.kind, maxFileBytes)
),
0,
);
let projectedDecodedBytes = imagesRef.current.reduce(
(total, image) => total + (
image.encodedBytes
?? (image.kind === "image" ? Math.min(image.file.size, maxFileBytes) : image.file.size)
),
0,
);
for (const file of files) { for (const file of files) {
if (!ACCEPTED_MIMES.has(file.type)) { const kind = acceptedAttachmentKind(file);
if (!kind) {
rejected.push({ file, reason: "unsupported_type" }); rejected.push({ file, reason: "unsupported_type" });
continue; continue;
} }
if (file.size === 0) {
rejected.push({ file, reason: "empty_file" });
continue;
}
if (kind === "file" && file.size > maxFileBytes) {
rejected.push({ file, reason: "too_large" });
continue;
}
if (slot <= 0) { if (slot <= 0) {
rejected.push({ file, reason: "too_many_images" }); rejected.push({ file, reason: "too_many_attachments" });
continue;
}
const nextDecodedBytes = kind === "image" ? Math.min(file.size, maxFileBytes) : file.size;
if (projectedDecodedBytes + nextDecodedBytes > maxTotalBytes) {
rejected.push({ file, reason: "total_too_large" });
continue;
}
const nextWireBytes = projectedDataUrlBytes(file, kind, maxFileBytes);
if (payloadBudget !== null && projectedWireBytes + nextWireBytes > payloadBudget) {
rejected.push({ file, reason: "transport_too_large" });
continue; continue;
} }
slot -= 1; slot -= 1;
projectedDecodedBytes += nextDecodedBytes;
projectedWireBytes += nextWireBytes;
toAdd.push({ toAdd.push({
id: uuid(), id: uuid(),
kind,
file, file,
previewUrl: URL.createObjectURL(file), ...(kind === "image" ? { previewUrl: URL.createObjectURL(file) } : {}),
status: "encoding", status: "encoding",
}); });
} }
@@ -175,19 +361,24 @@ export function useAttachedImages(): UseAttachedImagesApi {
// Fire the Worker after the commit so chips render first (good INP). // Fire the Worker after the commit so chips render first (good INP).
for (const entry of toAdd) { for (const entry of toAdd) {
queueMicrotask(() => { queueMicrotask(() => {
encodeImage(entry.file).then( const work = entry.kind === "image"
? encodeImage(entry.file)
: encodeFile(entry.file, maxFileBytes);
work.then(
(result) => { (result) => {
if (result.ok) { if (result.ok) {
setEntry(entry.id, { setEntry(entry.id, {
status: "ready", status: "ready",
dataUrl: result.dataUrl, dataUrl: result.dataUrl,
encodedBytes: result.bytes, encodedBytes: result.bytes,
normalized: result.normalized, normalized: "normalized" in result ? result.normalized : false,
}); });
} else { } else {
setEntry(entry.id, { setEntry(entry.id, {
status: "error", status: "error",
error: mapEncodeFailure(result.reason), error: entry.kind === "image"
? mapEncodeFailure(result.reason as EncodeFailure["reason"])
: result.reason as AttachmentError,
}); });
} }
}, },
@@ -203,7 +394,7 @@ export function useAttachedImages(): UseAttachedImagesApi {
} }
return { rejected }; return { rejected };
}, },
[setEntry], [ingressLimits, maxAttachments, maxFileBytes, maxTotalBytes, setEntry],
); );
const remove = useCallback((id: string) => { const remove = useCallback((id: string) => {
@@ -212,10 +403,12 @@ export function useAttachedImages(): UseAttachedImagesApi {
const idx = prev.findIndex((img) => img.id === id); const idx = prev.findIndex((img) => img.id === id);
if (idx === -1) return prev; if (idx === -1) return prev;
const target = prev[idx]; const target = prev[idx];
try { if (target.previewUrl) {
URL.revokeObjectURL(target.previewUrl); try {
} catch { URL.revokeObjectURL(target.previewUrl);
// No-op: previewUrl revocation is best-effort. } catch {
// No-op: previewUrl revocation is best-effort.
}
} }
const next = [...prev.slice(0, idx), ...prev.slice(idx + 1)]; const next = [...prev.slice(0, idx), ...prev.slice(idx + 1)];
imagesRef.current = next; imagesRef.current = next;
@@ -230,10 +423,12 @@ export function useAttachedImages(): UseAttachedImagesApi {
const clear = useCallback(() => { const clear = useCallback(() => {
setImages((prev) => { setImages((prev) => {
for (const img of prev) { for (const img of prev) {
try { if (img.previewUrl) {
URL.revokeObjectURL(img.previewUrl); try {
} catch { URL.revokeObjectURL(img.previewUrl);
// revoke is best-effort } catch {
// revoke is best-effort
}
} }
} }
imagesRef.current = []; imagesRef.current = [];
@@ -241,16 +436,18 @@ export function useAttachedImages(): UseAttachedImagesApi {
}); });
}, []); }, []);
const restoreReadyImages = useCallback((restored: RestoredReadyImage[]) => { const restoreReadyImages = useCallback((restored: RestoredReadyAttachment[]) => {
const toRestore = restored const toRestore = restored
.filter((img) => ACCEPTED_MIMES.has(dataUrlMime(img.dataUrl))) .filter((img) => acceptedAttachmentKind(dataUrlToFile(img.dataUrl, img.name)))
.slice(0, MAX_IMAGES_PER_MESSAGE) .slice(0, maxAttachments)
.map((img): AttachedImage => { .map((img): AttachedAttachment => {
const file = dataUrlToFile(img.dataUrl, img.name); const file = dataUrlToFile(img.dataUrl, img.name);
const kind = img.kind ?? kindFromDataUrl(img.dataUrl);
return { return {
id: uuid(), id: uuid(),
kind,
file, file,
previewUrl: img.dataUrl, ...(kind === "image" ? { previewUrl: img.dataUrl } : {}),
status: "ready", status: "ready",
dataUrl: img.dataUrl, dataUrl: img.dataUrl,
encodedBytes: file.size, encodedBytes: file.size,
@@ -258,16 +455,18 @@ export function useAttachedImages(): UseAttachedImagesApi {
}); });
setImages((prev) => { setImages((prev) => {
for (const img of prev) { for (const img of prev) {
try { if (img.previewUrl) {
URL.revokeObjectURL(img.previewUrl); try {
} catch { URL.revokeObjectURL(img.previewUrl);
// revoke is best-effort } catch {
// revoke is best-effort
}
} }
} }
imagesRef.current = toRestore; imagesRef.current = toRestore;
return toRestore; return toRestore;
}); });
}, []); }, [maxAttachments]);
// Final safety net: revoke any outstanding blob URLs on unmount. Safe // Final safety net: revoke any outstanding blob URLs on unmount. Safe
// under StrictMode double-invoke because revoked blob URLs are only // under StrictMode double-invoke because revoked blob URLs are only
@@ -275,17 +474,19 @@ export function useAttachedImages(): UseAttachedImagesApi {
useEffect(() => { useEffect(() => {
return () => { return () => {
for (const img of imagesRef.current) { for (const img of imagesRef.current) {
try { if (img.previewUrl) {
URL.revokeObjectURL(img.previewUrl); try {
} catch { URL.revokeObjectURL(img.previewUrl);
// best-effort cleanup on unmount } catch {
// best-effort cleanup on unmount
}
} }
} }
}; };
}, []); }, []);
const encoding = images.some((img) => img.status === "encoding"); const encoding = images.some((img) => img.status === "encoding");
const full = images.length >= MAX_IMAGES_PER_MESSAGE; const full = images.length >= maxAttachments;
return { images, enqueue, remove, clear, restoreReadyImages, encoding, full }; return { images, enqueue, remove, clear, restoreReadyImages, encoding, full };
} }
+12 -11
View File
@@ -1,12 +1,14 @@
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
/** Extract image ``File``s from a paste / drop event. import { acceptedAttachmentKind } from "@/hooks/useAttachedImages";
/** Extract supported attachment ``File``s from a paste / drop event.
* *
* Deliberate behaviour: * Deliberate behaviour:
* - Only items whose ``kind === "file"`` and ``type`` starts with * - Only items whose ``kind === "file"`` and match the Composer whitelist
* ``image/`` are returned; ``<img>`` tags inside HTML fragments are * are returned; HTML fragments are ignored (defending against remote URL
* ignored (defending against remote URL fetch + XSS surfaces). * fetch + XSS surfaces).
* - Plain text pasted alongside images is *not* consumed by this helper, * - Plain text pasted alongside attachments is *not* consumed by this helper,
* so the caller can still let the textarea receive it naturally. * so the caller can still let the textarea receive it naturally.
*/ */
export function extractImageFilesFromPaste( export function extractImageFilesFromPaste(
@@ -18,14 +20,13 @@ export function extractImageFilesFromPaste(
const files: File[] = []; const files: File[] = [];
for (const item of Array.from(clipboard.items)) { for (const item of Array.from(clipboard.items)) {
if (item.kind !== "file") continue; if (item.kind !== "file") continue;
if (!item.type.startsWith("image/")) continue;
const file = item.getAsFile(); const file = item.getAsFile();
if (file) files.push(file); if (file && acceptedAttachmentKind(file)) files.push(file);
} }
return files; return files;
} }
/** Extract dropped image files, mirroring ``extractImageFilesFromPaste``. */ /** Extract dropped attachment files, mirroring ``extractImageFilesFromPaste``. */
export function extractImageFilesFromDrop( export function extractImageFilesFromDrop(
event: DragEvent | React.DragEvent, event: DragEvent | React.DragEvent,
): File[] { ): File[] {
@@ -34,7 +35,7 @@ export function extractImageFilesFromDrop(
if (!dt) return []; if (!dt) return [];
const files: File[] = []; const files: File[] = [];
for (const item of Array.from(dt.files)) { for (const item of Array.from(dt.files)) {
if (item.type.startsWith("image/")) files.push(item); if (acceptedAttachmentKind(item)) files.push(item);
} }
return files; return files;
} }
@@ -67,8 +68,8 @@ export function useClipboardAndDrop(
(event: React.ClipboardEvent) => { (event: React.ClipboardEvent) => {
const files = extractImageFilesFromPaste(event); const files = extractImageFilesFromPaste(event);
if (files.length === 0) return; if (files.length === 0) return;
// Consume only when an image is actually present; plain-text paste still // Consume only when an attachment is actually present; plain-text paste
// reaches the textarea unmolested. // still reaches the textarea unmolested.
event.preventDefault(); event.preventDefault();
onImageFiles(files); onImageFiles(files);
}, },
+16 -16
View File
@@ -17,7 +17,7 @@ import type {
OutboundMedia, OutboundMedia,
GoalStateWsPayload, GoalStateWsPayload,
ToolProgressEvent, ToolProgressEvent,
UIImage, UIMediaAttachment,
UIFileEdit, UIFileEdit,
UIMessage, UIMessage,
UITurnPhase, UITurnPhase,
@@ -464,15 +464,15 @@ function findFileEditTraceIndex(
* separately (e.g. via ``fetchWebuiThread``) since the server only replays * separately (e.g. via ``fetchWebuiThread``) since the server only replays
* live events. * live events.
*/ */
/** Payload passed to ``send`` when the user attaches one or more images. /** Payload passed to ``send`` when the user attaches one or more files.
* *
* ``media`` is handed to the wire client verbatim; ``preview`` powers the * ``media`` is handed to the wire client verbatim; ``preview`` powers the
* optimistic user bubble (blob URLs so the preview appears before the server * optimistic user bubble. Keeping the two separate lets the bubble re-use the
* acks the frame). Keeping the two separate lets the bubble re-use the local * local data URL even after the server persists the file under a different
* blob URL even after the server persists the file under a different name. */ * name. */
export interface SendImage { export interface SendAttachment {
media: OutboundMedia; media: OutboundMedia;
preview: UIImage; preview: UIMediaAttachment;
} }
export interface SendOptions { export interface SendOptions {
@@ -520,7 +520,7 @@ export function useNanobotStream(
runStartedAt: number | null; runStartedAt: number | null;
/** Latest sustained goal for this ``chatId`` (``goal_state`` WS events). */ /** Latest sustained goal for this ``chatId`` (``goal_state`` WS events). */
goalState: GoalStateWsPayload | undefined; goalState: GoalStateWsPayload | undefined;
send: (content: string, images?: SendImage[], options?: SendOptions) => void; send: (content: string, images?: SendAttachment[], options?: SendOptions) => void;
transcribeAudio: (dataUrl: string, options?: { durationMs?: number }) => Promise<string>; transcribeAudio: (dataUrl: string, options?: { durationMs?: number }) => Promise<string>;
stop: () => void; stop: () => void;
setMessages: React.Dispatch<React.SetStateAction<UIMessage[]>>; setMessages: React.Dispatch<React.SetStateAction<UIMessage[]>>;
@@ -1135,12 +1135,12 @@ export function useNanobotStream(
]); ]);
const send = useCallback( const send = useCallback(
(content: string, images?: SendImage[], options?: SendOptions) => { (content: string, images?: SendAttachment[], options?: SendOptions) => {
if (!chatId) return; if (!chatId) return;
const hasImages = !!images && images.length > 0; const hasAttachments = !!images && images.length > 0;
// Text is optional when images are attached — the agent will still see // Text is optional when files are attached — the agent will still see
// the image blocks via ``media`` paths. // them via ``media`` paths.
if (!hasImages && !content.trim()) return; if (!hasAttachments && !content.trim()) return;
const sideChannel = options?.sideChannel === true; const sideChannel = options?.sideChannel === true;
const finalizeActiveTurn = options?.finalizeActiveTurn === true; const finalizeActiveTurn = options?.finalizeActiveTurn === true;
@@ -1151,7 +1151,7 @@ export function useNanobotStream(
} }
const turnId = crypto.randomUUID(); const turnId = crypto.randomUUID();
if (sideChannel) sideChannelTurnIdsRef.current.add(turnId); if (sideChannel) sideChannelTurnIdsRef.current.add(turnId);
const previews = hasImages ? images!.map((i) => i.preview) : undefined; const previews = hasAttachments ? images!.map((i) => i.preview) : undefined;
setMessages((prev) => { setMessages((prev) => {
if (!sideChannel || finalizeActiveTurn) { if (!sideChannel || finalizeActiveTurn) {
buffer.current = null; buffer.current = null;
@@ -1171,14 +1171,14 @@ export function useNanobotStream(
turnPhase: "user", turnPhase: "user",
turnSeq: 0, turnSeq: 0,
createdAt: Date.now(), createdAt: Date.now(),
...(previews ? { images: previews } : {}), ...(previews ? { media: previews } : {}),
...(options?.cliApps?.length ? { cliApps: options.cliApps } : {}), ...(options?.cliApps?.length ? { cliApps: options.cliApps } : {}),
...(options?.mcpPresets?.length ? { mcpPresets: options.mcpPresets } : {}), ...(options?.mcpPresets?.length ? { mcpPresets: options.mcpPresets } : {}),
}, },
]; ];
}); });
if (!sideChannel) setIsStreaming(true); if (!sideChannel) setIsStreaming(true);
const wireMedia = hasImages ? images!.map((i) => i.media) : undefined; const wireMedia = hasAttachments ? images!.map((i) => i.media) : undefined;
const wireOptions = { ...options, turnId }; const wireOptions = { ...options, turnId };
delete wireOptions.sideChannel; delete wireOptions.sideChannel;
delete wireOptions.finalizeActiveTurn; delete wireOptions.finalizeActiveTurn;
+7 -2
View File
@@ -909,7 +909,7 @@
"edit": "Edit guidance", "edit": "Edit guidance",
"drag": "Drag to reorder" "drag": "Drag to reorder"
}, },
"attachImage": "Attach image", "attachImage": "Attach files",
"imageMode": { "imageMode": {
"label": "Image Generation", "label": "Image Generation",
"toggle": "Toggle image generation mode", "toggle": "Toggle image generation mode",
@@ -1034,12 +1034,17 @@
"encoding": "Encoding…", "encoding": "Encoding…",
"remove": "Remove attachment", "remove": "Remove attachment",
"normalizedSizeHint": "{{orig}} → {{current}} (auto)", "normalizedSizeHint": "{{orig}} → {{current}} (auto)",
"textTooLarge": "Message text is too large (max {{max}})",
"imageRejected": { "imageRejected": {
"unsupported_type": "Unsupported file type", "unsupported_type": "Unsupported file type",
"empty_file": "Empty files cannot be attached",
"too_many_attachments": "Max {{max}} attachments per message",
"too_many_images": "Max {{max}} images per message", "too_many_images": "Max {{max}} images per message",
"magic_mismatch": "File doesn't look like a real image", "magic_mismatch": "File doesn't look like a real image",
"decode_failed": "Couldn't decode this image", "decode_failed": "Couldn't decode this image",
"too_large": "Image is too large — try a smaller one", "too_large": "File is too large — try a smaller one",
"total_too_large": "Attachments are too large together — remove some or use smaller files",
"transport_too_large": "This attachment would exceed the gateway transport limit",
"io": "Couldn't read this file" "io": "Couldn't read this file"
}, },
"workspace": { "workspace": {
+7 -2
View File
@@ -896,7 +896,7 @@
"edit": "Editar guía", "edit": "Editar guía",
"drag": "Arrastrar para reordenar" "drag": "Arrastrar para reordenar"
}, },
"attachImage": "Adjuntar imagen", "attachImage": "Adjuntar archivos",
"imageMode": { "imageMode": {
"label": "Generar imagen", "label": "Generar imagen",
"toggle": "Activar o desactivar modo de generación de imágenes", "toggle": "Activar o desactivar modo de generación de imágenes",
@@ -1011,12 +1011,17 @@
"encoding": "Procesando…", "encoding": "Procesando…",
"remove": "Quitar adjunto", "remove": "Quitar adjunto",
"normalizedSizeHint": "{{orig}} → {{current}} (auto)", "normalizedSizeHint": "{{orig}} → {{current}} (auto)",
"textTooLarge": "El texto del mensaje es demasiado grande (máximo {{max}})",
"imageRejected": { "imageRejected": {
"unsupported_type": "Tipo de archivo no compatible", "unsupported_type": "Tipo de archivo no compatible",
"empty_file": "No se pueden adjuntar archivos vacíos",
"too_many_attachments": "Máximo {{max}} adjuntos por mensaje",
"too_many_images": "Máximo {{max}} imágenes por mensaje", "too_many_images": "Máximo {{max}} imágenes por mensaje",
"magic_mismatch": "El archivo no parece una imagen real", "magic_mismatch": "El archivo no parece una imagen real",
"decode_failed": "No se pudo decodificar esta imagen", "decode_failed": "No se pudo decodificar esta imagen",
"too_large": "Imagen demasiado grande — prueba una más pequeña", "too_large": "Archivo demasiado grande — prueba uno más pequeño",
"total_too_large": "Los archivos adjuntos son demasiado grandes en conjunto; elimina algunos o usa archivos más pequeños",
"transport_too_large": "Este archivo adjunto superaría el límite de transporte de la puerta de enlace",
"io": "No se pudo leer este archivo" "io": "No se pudo leer este archivo"
}, },
"mentions": { "mentions": {
+7 -2
View File
@@ -895,7 +895,7 @@
"edit": "Modifier le guidage", "edit": "Modifier le guidage",
"drag": "Faire glisser pour réordonner" "drag": "Faire glisser pour réordonner"
}, },
"attachImage": "Joindre une image", "attachImage": "Joindre des fichiers",
"imageMode": { "imageMode": {
"label": "Génération dimage", "label": "Génération dimage",
"toggle": "Activer ou désactiver le mode génération dimage", "toggle": "Activer ou désactiver le mode génération dimage",
@@ -1010,12 +1010,17 @@
"encoding": "Traitement…", "encoding": "Traitement…",
"remove": "Retirer la pièce jointe", "remove": "Retirer la pièce jointe",
"normalizedSizeHint": "{{orig}} → {{current}} (auto)", "normalizedSizeHint": "{{orig}} → {{current}} (auto)",
"textTooLarge": "Le texte du message est trop volumineux (maximum {{max}})",
"imageRejected": { "imageRejected": {
"unsupported_type": "Type de fichier non pris en charge", "unsupported_type": "Type de fichier non pris en charge",
"empty_file": "Impossible de joindre des fichiers vides",
"too_many_attachments": "Maximum {{max}} pièces jointes par message",
"too_many_images": "Maximum {{max}} images par message", "too_many_images": "Maximum {{max}} images par message",
"magic_mismatch": "Ce fichier n'est pas une image", "magic_mismatch": "Ce fichier n'est pas une image",
"decode_failed": "Impossible de décoder cette image", "decode_failed": "Impossible de décoder cette image",
"too_large": "Image trop grande — essayez-en une plus petite", "too_large": "Fichier trop volumineux — essayez-en un plus petit",
"total_too_large": "Les pièces jointes sont trop volumineuses ensemble — supprimez-en ou utilisez des fichiers plus petits",
"transport_too_large": "Cette pièce jointe dépasserait la limite de transport de la passerelle",
"io": "Impossible de lire ce fichier" "io": "Impossible de lire ce fichier"
}, },
"mentions": { "mentions": {
+7 -2
View File
@@ -895,7 +895,7 @@
"edit": "Edit panduan", "edit": "Edit panduan",
"drag": "Seret untuk mengurutkan" "drag": "Seret untuk mengurutkan"
}, },
"attachImage": "Lampirkan gambar", "attachImage": "Lampirkan file",
"imageMode": { "imageMode": {
"label": "Buat gambar", "label": "Buat gambar",
"toggle": "Alihkan mode pembuatan gambar", "toggle": "Alihkan mode pembuatan gambar",
@@ -1010,12 +1010,17 @@
"encoding": "Memproses…", "encoding": "Memproses…",
"remove": "Hapus lampiran", "remove": "Hapus lampiran",
"normalizedSizeHint": "{{orig}} → {{current}} (auto)", "normalizedSizeHint": "{{orig}} → {{current}} (auto)",
"textTooLarge": "Teks pesan terlalu besar (maksimum {{max}})",
"imageRejected": { "imageRejected": {
"unsupported_type": "Tipe file tidak didukung", "unsupported_type": "Tipe file tidak didukung",
"empty_file": "File kosong tidak dapat dilampirkan",
"too_many_attachments": "Maksimal {{max}} lampiran per pesan",
"too_many_images": "Maksimal {{max}} gambar per pesan", "too_many_images": "Maksimal {{max}} gambar per pesan",
"magic_mismatch": "File ini tampaknya bukan gambar asli", "magic_mismatch": "File ini tampaknya bukan gambar asli",
"decode_failed": "Tidak dapat mendekode gambar ini", "decode_failed": "Tidak dapat mendekode gambar ini",
"too_large": "Gambar terlalu besar — coba yang lebih kecil", "too_large": "File terlalu besar — coba yang lebih kecil",
"total_too_large": "Total lampiran terlalu besar — hapus beberapa atau gunakan file yang lebih kecil",
"transport_too_large": "Lampiran ini akan melebihi batas transport gateway",
"io": "Tidak dapat membaca file ini" "io": "Tidak dapat membaca file ini"
}, },
"mentions": { "mentions": {
+7 -2
View File
@@ -895,7 +895,7 @@
"edit": "ガイドを編集", "edit": "ガイドを編集",
"drag": "ドラッグして並べ替え" "drag": "ドラッグして並べ替え"
}, },
"attachImage": "画像を添付", "attachImage": "ファイルを添付",
"imageMode": { "imageMode": {
"label": "画像生成", "label": "画像生成",
"toggle": "画像生成モードを切り替え", "toggle": "画像生成モードを切り替え",
@@ -1010,12 +1010,17 @@
"encoding": "処理中…", "encoding": "処理中…",
"remove": "添付を削除", "remove": "添付を削除",
"normalizedSizeHint": "{{orig}} → {{current}}(自動圧縮)", "normalizedSizeHint": "{{orig}} → {{current}}(自動圧縮)",
"textTooLarge": "メッセージ本文が大きすぎます(最大 {{max}})",
"imageRejected": { "imageRejected": {
"unsupported_type": "対応していないファイル形式です", "unsupported_type": "対応していないファイル形式です",
"empty_file": "空のファイルは添付できません",
"too_many_attachments": "1 メッセージにつき最大 {{max}} 件までです",
"too_many_images": "1 メッセージにつき最大 {{max}} 枚です", "too_many_images": "1 メッセージにつき最大 {{max}} 枚です",
"magic_mismatch": "画像ファイルではないようです", "magic_mismatch": "画像ファイルではないようです",
"decode_failed": "この画像をデコードできません", "decode_failed": "この画像をデコードできません",
"too_large": "画像が大きすぎます。小さいものを選んでください", "too_large": "ファイルが大きすぎます。小さいものを選んでください",
"total_too_large": "添付ファイルの合計サイズが大きすぎます。いくつか削除するか、より小さいファイルを使用してください",
"transport_too_large": "この添付ファイルはゲートウェイの転送上限を超えます",
"io": "このファイルを読み込めません" "io": "このファイルを読み込めません"
}, },
"mentions": { "mentions": {
+7 -2
View File
@@ -895,7 +895,7 @@
"edit": "안내 수정", "edit": "안내 수정",
"drag": "드래그하여 순서 변경" "drag": "드래그하여 순서 변경"
}, },
"attachImage": "이미지 첨부", "attachImage": "파일 첨부",
"imageMode": { "imageMode": {
"label": "이미지 생성", "label": "이미지 생성",
"toggle": "이미지 생성 모드 전환", "toggle": "이미지 생성 모드 전환",
@@ -1010,12 +1010,17 @@
"encoding": "처리 중…", "encoding": "처리 중…",
"remove": "첨부 제거", "remove": "첨부 제거",
"normalizedSizeHint": "{{orig}} → {{current}} (자동 압축)", "normalizedSizeHint": "{{orig}} → {{current}} (자동 압축)",
"textTooLarge": "메시지 텍스트가 너무 큽니다(최대 {{max}})",
"imageRejected": { "imageRejected": {
"unsupported_type": "지원하지 않는 파일 형식입니다", "unsupported_type": "지원하지 않는 파일 형식입니다",
"empty_file": "빈 파일은 첨부할 수 없습니다",
"too_many_attachments": "메시지당 최대 {{max}}개까지 가능합니다",
"too_many_images": "메시지당 최대 {{max}}장까지 가능합니다", "too_many_images": "메시지당 최대 {{max}}장까지 가능합니다",
"magic_mismatch": "이미지 파일이 아닌 것 같습니다", "magic_mismatch": "이미지 파일이 아닌 것 같습니다",
"decode_failed": "이 이미지를 디코딩할 수 없습니다", "decode_failed": "이 이미지를 디코딩할 수 없습니다",
"too_large": "이미지가 너무 큽니다. 더 작은 걸로 시도해 주세요", "too_large": "파일이 너무 큽니다. 더 작은 파일을 선택해 주세요",
"total_too_large": "첨부 파일의 전체 크기가 너무 큽니다. 일부를 제거하거나 더 작은 파일을 사용해 주세요",
"transport_too_large": "이 첨부 파일은 게이트웨이 전송 한도를 초과합니다",
"io": "이 파일을 읽을 수 없습니다" "io": "이 파일을 읽을 수 없습니다"
}, },
"mentions": { "mentions": {
+5
View File
@@ -1034,12 +1034,17 @@
"encoding": "Codificando…", "encoding": "Codificando…",
"remove": "Remover anexo", "remove": "Remover anexo",
"normalizedSizeHint": "{{orig}} → {{current}} (auto)", "normalizedSizeHint": "{{orig}} → {{current}} (auto)",
"textTooLarge": "O texto da mensagem é grande demais (máximo de {{max}})",
"imageRejected": { "imageRejected": {
"unsupported_type": "Tipo de arquivo não compatível", "unsupported_type": "Tipo de arquivo não compatível",
"empty_file": "Arquivos vazios não podem ser anexados",
"too_many_attachments": "Máx. de {{max}} anexos por mensagem",
"too_many_images": "Máx. de {{max}} imagens por mensagem", "too_many_images": "Máx. de {{max}} imagens por mensagem",
"magic_mismatch": "O arquivo não parece uma imagem real", "magic_mismatch": "O arquivo não parece uma imagem real",
"decode_failed": "Não foi possível decodificar esta imagem", "decode_failed": "Não foi possível decodificar esta imagem",
"too_large": "A imagem é grande demais — tente uma menor", "too_large": "A imagem é grande demais — tente uma menor",
"total_too_large": "Os anexos são grandes demais em conjunto — remova alguns ou use arquivos menores",
"transport_too_large": "Este anexo excederia o limite de transporte do gateway",
"io": "Não foi possível ler este arquivo" "io": "Não foi possível ler este arquivo"
}, },
"workspace": { "workspace": {
+7 -2
View File
@@ -895,7 +895,7 @@
"edit": "Sửa hướng dẫn", "edit": "Sửa hướng dẫn",
"drag": "Kéo để sắp xếp" "drag": "Kéo để sắp xếp"
}, },
"attachImage": "Đính kèm ảnh", "attachImage": "Đính kèm tệp",
"imageMode": { "imageMode": {
"label": "Tạo ảnh", "label": "Tạo ảnh",
"toggle": "Bật/tắt chế độ tạo ảnh", "toggle": "Bật/tắt chế độ tạo ảnh",
@@ -1010,12 +1010,17 @@
"encoding": "Đang xử lý…", "encoding": "Đang xử lý…",
"remove": "Xóa tệp đính kèm", "remove": "Xóa tệp đính kèm",
"normalizedSizeHint": "{{orig}} → {{current}} (tự động)", "normalizedSizeHint": "{{orig}} → {{current}} (tự động)",
"textTooLarge": "Nội dung tin nhắn quá lớn (tối đa {{max}})",
"imageRejected": { "imageRejected": {
"unsupported_type": "Loại tệp không được hỗ trợ", "unsupported_type": "Loại tệp không được hỗ trợ",
"empty_file": "Không thể đính kèm tệp trống",
"too_many_attachments": "Tối đa {{max}} tệp đính kèm mỗi tin nhắn",
"too_many_images": "Tối đa {{max}} ảnh mỗi tin nhắn", "too_many_images": "Tối đa {{max}} ảnh mỗi tin nhắn",
"magic_mismatch": "Tệp này không phải là một ảnh thực", "magic_mismatch": "Tệp này không phải là một ảnh thực",
"decode_failed": "Không thể giải mã ảnh này", "decode_failed": "Không thể giải mã ảnh này",
"too_large": "Ảnh quá lớn — hãy thử ảnh nhỏ hơn", "too_large": "Tệp quá lớn — hãy thử tệp nhỏ hơn",
"total_too_large": "Tổng dung lượng tệp đính kèm quá lớn — hãy xóa bớt hoặc dùng tệp nhỏ hơn",
"transport_too_large": "Tệp đính kèm này sẽ vượt quá giới hạn truyền tải của gateway",
"io": "Không thể đọc tệp này" "io": "Không thể đọc tệp này"
}, },
"mentions": { "mentions": {
+7 -2
View File
@@ -908,7 +908,7 @@
"edit": "编辑引导", "edit": "编辑引导",
"drag": "拖动排序" "drag": "拖动排序"
}, },
"attachImage": "添加图片", "attachImage": "添加文件",
"imageMode": { "imageMode": {
"label": "图片生成", "label": "图片生成",
"toggle": "切换图片生成模式", "toggle": "切换图片生成模式",
@@ -1033,12 +1033,17 @@
"encoding": "处理中…", "encoding": "处理中…",
"remove": "移除附件", "remove": "移除附件",
"normalizedSizeHint": "{{orig}} → {{current}}(已自动压缩)", "normalizedSizeHint": "{{orig}} → {{current}}(已自动压缩)",
"textTooLarge": "消息文本过大(最大 {{max}}",
"imageRejected": { "imageRejected": {
"unsupported_type": "不支持的文件类型", "unsupported_type": "不支持的文件类型",
"empty_file": "不能附加空文件",
"too_many_attachments": "每条消息最多 {{max}} 个附件",
"too_many_images": "每条消息最多 {{max}} 张图片", "too_many_images": "每条消息最多 {{max}} 张图片",
"magic_mismatch": "文件看起来不像真实的图片", "magic_mismatch": "文件看起来不像真实的图片",
"decode_failed": "无法解码这张图片", "decode_failed": "无法解码这张图片",
"too_large": "图片太大,请换一小一点的", "too_large": "文件太大,请换一小一点的",
"total_too_large": "附件总大小过大,请移除部分文件或使用更小的文件",
"transport_too_large": "该附件会超过网关的传输上限",
"io": "无法读取该文件" "io": "无法读取该文件"
}, },
"goalStateCloseAria": "关闭目标", "goalStateCloseAria": "关闭目标",
+7 -2
View File
@@ -895,7 +895,7 @@
"edit": "編輯引導", "edit": "編輯引導",
"drag": "拖曳排序" "drag": "拖曳排序"
}, },
"attachImage": "附加圖片", "attachImage": "附加檔案",
"imageMode": { "imageMode": {
"label": "圖片生成", "label": "圖片生成",
"toggle": "切換圖片生成模式", "toggle": "切換圖片生成模式",
@@ -1010,12 +1010,17 @@
"encoding": "處理中…", "encoding": "處理中…",
"remove": "移除附件", "remove": "移除附件",
"normalizedSizeHint": "{{orig}} → {{current}}(已自動壓縮)", "normalizedSizeHint": "{{orig}} → {{current}}(已自動壓縮)",
"textTooLarge": "訊息文字過大(最大 {{max}}",
"imageRejected": { "imageRejected": {
"unsupported_type": "不支援的檔案類型", "unsupported_type": "不支援的檔案類型",
"empty_file": "無法附加空白檔案",
"too_many_attachments": "每則訊息最多 {{max}} 個附件",
"too_many_images": "每則訊息最多 {{max}} 張圖片", "too_many_images": "每則訊息最多 {{max}} 張圖片",
"magic_mismatch": "檔案看起來不像真正的圖片", "magic_mismatch": "檔案看起來不像真正的圖片",
"decode_failed": "無法解碼這張圖片", "decode_failed": "無法解碼這張圖片",
"too_large": "圖片太大,請換一小一點的", "too_large": "檔案太大,請換一小一點的",
"total_too_large": "附件總大小過大,請移除部分檔案或使用更小的檔案",
"transport_too_large": "此附件會超過閘道的傳輸上限",
"io": "無法讀取這個檔案" "io": "無法讀取這個檔案"
}, },
"mentions": { "mentions": {
+2 -2
View File
@@ -81,8 +81,8 @@ type RunStatusHandler = (chatId: string, startedAt: number | null) => void;
*/ */
export type StreamError = export type StreamError =
/** Server rejected the inbound frame as too large (WS close code 1009). /** Server rejected the inbound frame as too large (WS close code 1009).
* Typically means the user attached images whose base64 size exceeded * This is the transport fallback after text and attachment policies have
* ``maxMessageBytes`` on the server. */ * already been checked independently. */
| { kind: "message_too_big" } | { kind: "message_too_big" }
| { kind: "workspace_scope_rejected"; reason?: string; chatId?: string }; | { kind: "workspace_scope_rejected"; reason?: string; chatId?: string };
+26 -5
View File
@@ -308,11 +308,33 @@ export interface BootstrapResponse {
ws_path: string; ws_path: string;
ws_url?: string | null; ws_url?: string | null;
expires_in: number; expires_in: number;
limits?: WebUIIngressLimits;
model_name?: string | null; model_name?: string | null;
runtime_surface?: RuntimeSurface; runtime_surface?: RuntimeSurface;
runtime_capabilities?: RuntimeCapabilities; runtime_capabilities?: RuntimeCapabilities;
} }
export interface WebUITransportLimits {
max_frame_bytes: number;
envelope_reserve_bytes: number;
}
export interface WebUIMessageLimits {
max_text_bytes: number;
}
export interface WebUIAttachmentLimits {
max_count: number;
max_file_bytes: number;
max_total_bytes: number;
}
export interface WebUIIngressLimits {
transport: WebUITransportLimits;
message: WebUIMessageLimits;
attachments: WebUIAttachmentLimits;
}
export type RuntimeSurface = "browser" | "native"; export type RuntimeSurface = "browser" | "native";
export type RestartBehavior = "none" | "nextTurn" | "engineRestart" | "appRestart"; export type RestartBehavior = "none" | "nextTurn" | "engineRestart" | "appRestart";
export type SettingsApplyStatus = export type SettingsApplyStatus =
@@ -1050,12 +1072,11 @@ export type InboundEvent =
} }
| { event: "error"; chat_id?: string; detail?: string; reason?: string }; | { event: "error"; chat_id?: string; detail?: string; reason?: string };
/** Base64-encoded image attached to an outbound ``message`` envelope. /** Base64-encoded file attached to an outbound ``message`` envelope.
* *
* ``data_url`` must be a ``data:image/<png|jpeg|webp|gif>;base64,...`` string * ``data_url`` must use a server-whitelisted image, video, or document MIME
* the server whitelists those MIME types and rejects everything else * type. SVG remains rejected on ingress to avoid an embedded-script XSS
* (including SVG, to avoid an XSS surface). ``name`` is advisory: it's * surface. ``name`` is advisory and is surfaced as the placeholder label when
* preserved for the file on disk and surfaced as the placeholder label when
* the session is replayed. * the session is replayed.
*/ */
export interface OutboundMedia { export interface OutboundMedia {
+5 -1
View File
@@ -1,11 +1,13 @@
import { createContext, useContext, type ReactNode } from "react"; import { createContext, useContext, type ReactNode } from "react";
import type { NanobotClient } from "@/lib/nanobot-client"; import type { NanobotClient } from "@/lib/nanobot-client";
import type { WebUIIngressLimits } from "@/lib/types";
interface ClientContextValue { interface ClientContextValue {
client: NanobotClient; client: NanobotClient;
token: string; token: string;
modelName: string | null; modelName: string | null;
ingressLimits: WebUIIngressLimits | null;
} }
const ClientContext = createContext<ClientContextValue | null>(null); const ClientContext = createContext<ClientContextValue | null>(null);
@@ -14,15 +16,17 @@ export function ClientProvider({
client, client,
token, token,
modelName = null, modelName = null,
ingressLimits = null,
children, children,
}: { }: {
client: NanobotClient; client: NanobotClient;
token: string; token: string;
modelName?: string | null; modelName?: string | null;
ingressLimits?: WebUIIngressLimits | null;
children: ReactNode; children: ReactNode;
}) { }) {
return ( return (
<ClientContext.Provider value={{ client, token, modelName }}> <ClientContext.Provider value={{ client, token, modelName, ingressLimits }}>
{children} {children}
</ClientContext.Provider> </ClientContext.Provider>
); );
+258 -2
View File
@@ -9,6 +9,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { ThreadComposer } from "@/components/thread/ThreadComposer"; import { ThreadComposer } from "@/components/thread/ThreadComposer";
import type { EncodeResponse } from "@/lib/imageEncode"; import type { EncodeResponse } from "@/lib/imageEncode";
import type { WebUIIngressLimits } from "@/lib/types";
const encodeImage = vi.fn<(file: File) => Promise<EncodeResponse>>(); const encodeImage = vi.fn<(file: File) => Promise<EncodeResponse>>();
@@ -24,6 +25,14 @@ function pngFile(name = "a.png", size = 10) {
return new File([new Uint8Array(size)], name, { type: "image/png" }); return new File([new Uint8Array(size)], name, { type: "image/png" });
} }
function pdfFile(name = "report.pdf", size = 8) {
return new File([new Uint8Array(size)], name, { type: "application/pdf" });
}
function csvFile(name = "report.csv", type = "application/vnd.ms-excel") {
return new File(["name,value\nnanobot,1"], name, { type });
}
function resolveReady(file: File): EncodeResponse { function resolveReady(file: File): EncodeResponse {
return { return {
id: "stub", id: "stub",
@@ -36,6 +45,31 @@ function resolveReady(file: File): EncodeResponse {
}; };
} }
function ingressLimits({
maxFrameBytes = 36 * 1024 * 1024,
maxTextBytes = 64 * 1024,
maxFileBytes = 6 * 1024 * 1024,
maxTotalBytes = 24 * 1024 * 1024,
}: {
maxFrameBytes?: number;
maxTextBytes?: number;
maxFileBytes?: number;
maxTotalBytes?: number;
} = {}): WebUIIngressLimits {
return {
transport: {
max_frame_bytes: maxFrameBytes,
envelope_reserve_bytes: 64 * 1024,
},
message: { max_text_bytes: maxTextBytes },
attachments: {
max_count: 4,
max_file_bytes: maxFileBytes,
max_total_bytes: maxTotalBytes,
},
};
}
beforeEach(() => { beforeEach(() => {
encodeImage.mockReset(); encodeImage.mockReset();
let id = 0; let id = 0;
@@ -50,7 +84,7 @@ beforeEach(() => {
} }
}); });
describe("ThreadComposer — image attachments", () => { describe("ThreadComposer — attachments", () => {
it("attaches a picked image and includes its data url on send", async () => { it("attaches a picked image and includes its data url on send", async () => {
const file = pngFile("a.png"); const file = pngFile("a.png");
encodeImage.mockResolvedValueOnce(resolveReady(file)); encodeImage.mockResolvedValueOnce(resolveReady(file));
@@ -83,6 +117,228 @@ describe("ThreadComposer — image attachments", () => {
expect(images[0].media.name).toBe("a.png"); expect(images[0].media.name).toBe("a.png");
}); });
it("attaches a picked PDF and includes its data url on send", async () => {
const file = pdfFile();
const onSend = vi.fn();
render(<ThreadComposer onSend={onSend} />);
const input = screen
.getByLabelText(/message input/i)
.closest("form")!
.querySelector('input[type="file"]') as HTMLInputElement;
await act(async () => {
fireEvent.change(input, { target: { files: [file] } });
});
await waitFor(() =>
expect(screen.getByTestId("composer-chip")).toHaveTextContent("report.pdf"),
);
const textarea = screen.getByLabelText(/message input/i);
fireEvent.change(textarea, { target: { value: "summarize" } });
fireEvent.keyDown(textarea, { key: "Enter" });
expect(encodeImage).not.toHaveBeenCalled();
const [content, attachments] = onSend.mock.calls[0];
expect(content).toBe("summarize");
expect(attachments).toHaveLength(1);
expect(attachments[0].media.data_url).toContain("data:application/pdf;base64,");
expect(attachments[0].media.name).toBe("report.pdf");
expect(attachments[0].preview.kind).toBe("file");
});
it.each(["application/vnd.ms-excel", "image/png"])(
"normalizes document MIME from the file extension when the browser reports %s",
async (browserMime) => {
const file = csvFile("report.csv", browserMime);
const onSend = vi.fn();
render(<ThreadComposer onSend={onSend} />);
const input = screen
.getByLabelText(/message input/i)
.closest("form")!
.querySelector('input[type="file"]') as HTMLInputElement;
await act(async () => {
fireEvent.change(input, { target: { files: [file] } });
});
await waitFor(() =>
expect(screen.getByTestId("composer-chip")).toHaveTextContent("report.csv"),
);
const textarea = screen.getByLabelText(/message input/i);
fireEvent.change(textarea, { target: { value: "summarize" } });
fireEvent.keyDown(textarea, { key: "Enter" });
const [, attachments] = onSend.mock.calls[0];
expect(attachments[0].media.data_url).toMatch(/^data:text\/csv;base64,/);
expect(encodeImage).not.toHaveBeenCalled();
},
);
it("rejects empty attachments before sending them to the gateway", async () => {
const file = new File([], "empty.csv", { type: "application/vnd.ms-excel" });
const onSend = vi.fn();
render(<ThreadComposer onSend={onSend} />);
const input = screen
.getByLabelText(/message input/i)
.closest("form")!
.querySelector('input[type="file"]') as HTMLInputElement;
await act(async () => {
fireEvent.change(input, { target: { files: [file] } });
});
expect(screen.getByText("Empty files cannot be attached")).toBeInTheDocument();
expect(screen.queryByTestId("composer-chip")).not.toBeInTheDocument();
expect(encodeImage).not.toHaveBeenCalled();
expect(onSend).not.toHaveBeenCalled();
});
it("rejects an oversized document before adding a chip", async () => {
const file = pdfFile("oversized.pdf", 6 * 1024 * 1024 + 1);
render(<ThreadComposer onSend={vi.fn()} />);
const input = screen
.getByLabelText(/message input/i)
.closest("form")!
.querySelector('input[type="file"]') as HTMLInputElement;
await act(async () => {
fireEvent.change(input, { target: { files: [file] } });
});
expect(screen.getByRole("alert")).toHaveTextContent("File is too large");
expect(screen.queryByTestId("composer-chip")).not.toBeInTheDocument();
});
it("reports a transport limit separately from attachment policy", async () => {
const first = pdfFile("first.pdf", 400 * 1024);
const second = pdfFile("second.pdf", 400 * 1024);
render(
<ThreadComposer
onSend={vi.fn()}
ingressLimits={ingressLimits({ maxFrameBytes: 1024 * 1024 })}
/>,
);
const input = screen
.getByLabelText(/message input/i)
.closest("form")!
.querySelector('input[type="file"]') as HTMLInputElement;
await act(async () => {
fireEvent.change(input, { target: { files: [first, second] } });
});
expect(screen.getByRole("alert")).toHaveTextContent(
"gateway transport limit",
);
expect(screen.getAllByTestId("composer-chip")).toHaveLength(1);
expect(screen.getByText("first.pdf")).toBeInTheDocument();
expect(screen.queryByText("second.pdf")).not.toBeInTheDocument();
});
it("enforces the decoded attachment-total policy independently", async () => {
const first = pdfFile("first.pdf", 400 * 1024);
const second = pdfFile("second.pdf", 400 * 1024);
render(
<ThreadComposer
onSend={vi.fn()}
ingressLimits={ingressLimits({ maxTotalBytes: 700 * 1024 })}
/>,
);
const input = screen
.getByLabelText(/message input/i)
.closest("form")!
.querySelector('input[type="file"]') as HTMLInputElement;
await act(async () => {
fireEvent.change(input, { target: { files: [first, second] } });
});
expect(screen.getByRole("alert")).toHaveTextContent(
"Attachments are too large together",
);
expect(screen.getAllByTestId("composer-chip")).toHaveLength(1);
});
it("enforces the text-byte policy without changing attachment limits", () => {
const onSend = vi.fn();
render(
<ThreadComposer
onSend={onSend}
ingressLimits={ingressLimits({ maxTextBytes: 4 })}
/>,
);
const textarea = screen.getByLabelText(/message input/i);
fireEvent.change(textarea, { target: { value: "你好" } });
fireEvent.keyDown(textarea, { key: "Enter" });
expect(screen.getByRole("alert")).toHaveTextContent(
"Message text is too large (max 4 B)",
);
expect(onSend).not.toHaveBeenCalled();
});
it("accepts supported documents from paste and drop", async () => {
const pasted = pdfFile("pasted.pdf");
const dropped = pdfFile("dropped.pdf");
const onSend = vi.fn();
render(<ThreadComposer onSend={onSend} />);
const textarea = screen.getByLabelText(/message input/i);
const form = textarea.closest("form")!;
await act(async () => {
fireEvent.paste(textarea, {
clipboardData: {
files: [pasted],
items: [{
kind: "file",
type: pasted.type,
getAsFile: () => pasted,
}],
types: ["Files"],
getData: () => "",
},
});
});
await waitFor(() =>
expect(screen.getByText("pasted.pdf")).toBeInTheDocument(),
);
await act(async () => {
fireEvent.drop(form, {
dataTransfer: {
files: [dropped],
items: [],
types: ["Files"],
dropEffect: "copy",
},
});
});
await waitFor(() =>
expect(screen.getByText("dropped.pdf")).toBeInTheDocument(),
);
expect(screen.getAllByTestId("composer-chip")).toHaveLength(2);
expect(encodeImage).not.toHaveBeenCalled();
});
it("blocks send while an image is still encoding", async () => { it("blocks send while an image is still encoding", async () => {
const file = pngFile("slow.png"); const file = pngFile("slow.png");
let resolveEncode: (r: EncodeResponse) => void = () => {}; let resolveEncode: (r: EncodeResponse) => void = () => {};
@@ -117,7 +373,7 @@ describe("ThreadComposer — image attachments", () => {
expect(onSend).toHaveBeenCalledTimes(1); expect(onSend).toHaveBeenCalledTimes(1);
}); });
it("rejects a non-image paste silently without adding a chip", async () => { it("keeps a plain-text paste untouched without adding a chip", async () => {
const onSend = vi.fn(); const onSend = vi.fn();
render(<ThreadComposer onSend={onSend} />); render(<ThreadComposer onSend={onSend} />);
const textarea = screen.getByLabelText(/message input/i); const textarea = screen.getByLabelText(/message input/i);
+1 -1
View File
@@ -336,7 +336,7 @@ describe("ThreadComposer", () => {
expect(input.parentElement?.parentElement?.className).toContain("max-w-[49.5rem]"); expect(input.parentElement?.parentElement?.className).toContain("max-w-[49.5rem]");
expect(input.parentElement?.parentElement?.className).toContain("rounded-[22px]"); expect(input.parentElement?.parentElement?.className).toContain("rounded-[22px]");
expect(input.parentElement?.parentElement?.className).toContain("shadow-[0_12px_30px_rgba(15,23,42,0.07)]"); expect(input.parentElement?.parentElement?.className).toContain("shadow-[0_12px_30px_rgba(15,23,42,0.07)]");
expect(screen.getByRole("button", { name: "Attach image" }).className).toContain("bg-card"); expect(screen.getByRole("button", { name: "Attach files" }).className).toContain("bg-card");
expect(screen.getByRole("button", { name: "Send message" }).className).toContain("bg-foreground"); expect(screen.getByRole("button", { name: "Send message" }).className).toContain("bg-foreground");
expect(screen.queryByText(/Enter to send/)).not.toBeInTheDocument(); expect(screen.queryByText(/Enter to send/)).not.toBeInTheDocument();
}); });
+31
View File
@@ -1520,6 +1520,37 @@ describe("useNanobotStream", () => {
expect(result.current.messages[0].turnPhase).toBe("user"); expect(result.current.messages[0].turnPhase).toBe("user");
}); });
it("adds optimistic user file attachments as media", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-file-send", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
const attachment = {
media: {
data_url: "data:application/pdf;base64,JVBERi0xLjQ=",
name: "report.pdf",
},
preview: {
kind: "file" as const,
url: "data:application/pdf;base64,JVBERi0xLjQ=",
name: "report.pdf",
},
};
act(() => {
result.current.send("summarize", [attachment]);
});
expect(result.current.messages[0].media).toEqual([attachment.preview]);
expect(result.current.messages[0].images).toBeUndefined();
expect(fake.client.sendMessage).toHaveBeenCalledWith(
"chat-file-send",
"summarize",
[attachment.media],
expect.objectContaining({ turnId: expect.any(String) }),
);
});
it("attaches assistant media_urls to complete messages", () => { it("attaches assistant media_urls to complete messages", () => {
const fake = fakeClient(); const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-m", EMPTY_MESSAGES), { const { result } = renderHook(() => useNanobotStream("chat-m", EMPTY_MESSAGES), {