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.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base
from nanobot.security.workspace_access import (
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.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.forking import handle_webui_fork_chat
from nanobot.webui.gateway_services import GatewayServices
@@ -221,45 +216,6 @@ def _parse_envelope(raw: str) -> dict[str, Any] | None:
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:
"""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")
@@ -301,6 +257,7 @@ class WebSocketChannel(BaseChannel):
self._http_router = gateway.http
self._tokens = gateway.tokens
self._media = gateway.media
self._ingress = gateway.ingress
self._transcripts = gateway.transcripts
self._workspaces = gateway.workspaces
@@ -582,74 +539,6 @@ class WebSocketChannel(BaseChannel):
# -- 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(
self,
connection: Any,
@@ -732,25 +621,39 @@ class WebSocketChannel(BaseChannel):
if not isinstance(content, str):
await self._send_event(connection, "error", detail="missing content")
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")
media_paths: list[str] = []
if raw_media is not None:
if not isinstance(raw_media, list):
await self._send_event(
connection, "error",
detail="image_rejected", reason="malformed",
connection,
"error",
detail="attachment_rejected",
reason="malformed",
)
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:
await self._send_event(
connection, "error",
detail="image_rejected", reason=reason,
connection,
"error",
detail="attachment_rejected",
reason=reason,
)
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:
await self._send_event(connection, "error", detail="missing content")
return
+24 -3
View File
@@ -32,6 +32,22 @@ _MIME_EXTENSION_OVERRIDES = {
"audio/x-wav": ".wav",
"audio/vnd.wave": ".wav",
"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,
*,
max_bytes: int | None = None,
filename: str | None = None,
) -> str | None:
"""Decode a ``data:<mime>;base64,<payload>`` URL and persist it.
@@ -59,14 +76,18 @@ def save_base64_data_url(
return None
mime_type, b64_payload = m.group(1).strip().lower(), m.group(2)
try:
raw = base64.b64decode(b64_payload)
raw = base64.b64decode(b64_payload, validate=True)
except Exception:
return None
if not raw:
return None
limit = DEFAULT_MAX_BYTES if max_bytes is None else max_bytes
if len(raw) > limit:
raise FileSizeExceeded(f"File exceeds {limit // (1024 * 1024)}MB limit")
ext = _MIME_EXTENSION_OVERRIDES.get(mime_type) or mimetypes.guess_extension(mime_type) or ".bin"
filename = f"{uuid.uuid4().hex[:12]}{ext}"
dest = media_dir / safe_filename(filename)
base = safe_filename(filename or "")
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)
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 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.transcript import WebUITranscriptRecorder
from nanobot.webui.workspaces import WebUIWorkspaceController
@@ -22,6 +23,7 @@ class GatewayServices:
http: GatewayHTTPHandler
tokens: GatewayTokenStore
media: WebUIMediaGateway
ingress: WebUIIngressPolicy
transcripts: WebUITranscriptRecorder
workspaces: WebUIWorkspaceController
session_manager: Any | None
@@ -51,9 +53,19 @@ def build_gateway_services(
logger: Any = default_logger,
) -> GatewayServices:
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(
workspace_path=workspace_path,
logger=logger,
attachment_limits=ingress.attachments,
)
transcripts = WebUITranscriptRecorder(log=logger)
workspaces = WebUIWorkspaceController(
@@ -71,6 +83,7 @@ def build_gateway_services(
bus=bus,
tokens=tokens,
media=media,
ingress=ingress,
workspaces=workspaces,
skills_workspace_path=workspace_path,
disabled_skills=disabled_skills,
@@ -85,6 +98,7 @@ def build_gateway_services(
http=http,
tokens=tokens,
media=media,
ingress=ingress,
transcripts=transcripts,
workspaces=workspaces,
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 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 (
attach_signed_media_urls,
serve_signed_media,
@@ -31,11 +36,22 @@ class WebUIMediaGateway:
logger: Any,
media_dir: Callable[[str | None], Path] | None = None,
secret: bytes | None = None,
attachment_limits: AttachmentIngressLimits | None = None,
) -> None:
self.workspace_path = workspace_path
self.logger = logger
self._media_dir = media_dir or (lambda channel=None: get_media_dir(channel))
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(
self,
+6
View File
@@ -70,6 +70,7 @@ from nanobot.webui.http_utils import (
from nanobot.webui.http_utils import (
safe_host_header as _safe_host_header,
)
from nanobot.webui.ingress_policy import WebUIIngressPolicy
from nanobot.webui.media_gateway import WebUIMediaGateway
from nanobot.webui.session_automations import (
all_automations_payload,
@@ -155,6 +156,7 @@ class GatewayHTTPHandler:
bus: MessageBus,
tokens: GatewayTokenStore,
media: WebUIMediaGateway,
ingress: WebUIIngressPolicy,
workspaces: WebUIWorkspaceController,
skills_workspace_path: Path,
disabled_skills: set[str] | None = None,
@@ -172,6 +174,7 @@ class GatewayHTTPHandler:
self.bus = bus
self.tokens = tokens
self.media = media
self.ingress = ingress
self.workspaces = workspaces
self.skills_workspace_path = skills_workspace_path
self.disabled_skills = disabled_skills or set()
@@ -340,6 +343,9 @@ class GatewayHTTPHandler:
"ws_path": expected_path,
"ws_url": ws_url,
"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),
"runtime_surface": self._runtime_surface,
"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):
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)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
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)
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)
channel = WebSocketChannel(
{"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)
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)
channel = WebSocketChannel(
{"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:
decoding base64 data URLs, rejecting malformed / oversized / non-whitelisted
@@ -11,7 +11,6 @@ from __future__ import annotations
import base64
import json
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -19,7 +18,6 @@ import pytest
from nanobot.channels.websocket import (
WebSocketChannel,
WebSocketConfig,
_extract_data_url_mime,
)
from nanobot.webui.gateway_services import build_gateway_services
@@ -61,28 +59,6 @@ def _make_channel() -> WebSocketChannel:
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 ----------------------------------------------------
@@ -118,6 +94,28 @@ async def test_message_without_media_backward_compatible() -> 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
async def test_message_forwards_normalized_cli_app_attachments() -> None:
channel = _make_channel()
@@ -167,7 +165,7 @@ async def test_message_with_single_image_forwards_saved_path(tmp_path) -> None:
}
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)
@@ -196,7 +194,7 @@ async def test_message_with_multiple_images(tmp_path) -> None:
}
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)
@@ -219,7 +217,7 @@ async def test_image_only_message_allows_empty_text(tmp_path) -> None:
}
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)
@@ -240,7 +238,7 @@ async def test_message_rejected_when_more_than_four_images(tmp_path) -> None:
}
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)
@@ -248,10 +246,38 @@ async def test_message_rejected_when_more_than_four_images(tmp_path) -> None:
mock_conn.send.assert_awaited_once()
err = json.loads(mock_conn.send.call_args[0][0])
assert err["event"] == "error"
assert err["detail"] == "image_rejected"
assert err["detail"] == "attachment_rejected"
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
async def test_message_rejected_on_oversize_payload(tmp_path) -> None:
channel = _make_channel()
@@ -265,35 +291,87 @@ async def test_message_rejected_on_oversize_payload(tmp_path) -> None:
}
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_not_awaited()
err = json.loads(mock_conn.send.call_args[0][0])
assert err["detail"] == "image_rejected"
assert err["detail"] == "attachment_rejected"
assert err["reason"] == "size"
@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()
mock_conn = AsyncMock()
envelope = {
"type": "message",
"chat_id": "abc123",
"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(
"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)
channel._handle_message.assert_not_awaited()
err = json.loads(mock_conn.send.call_args[0][0])
assert err["detail"] == "image_rejected"
assert err["detail"] == "attachment_rejected"
assert err["reason"] == "mime"
@@ -310,7 +388,7 @@ async def test_message_rejected_on_svg_mime(tmp_path) -> None:
}
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)
@@ -331,7 +409,7 @@ async def test_message_rejected_on_malformed_data_url(tmp_path) -> None:
}
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)
@@ -352,7 +430,7 @@ async def test_message_rejected_on_broken_base64(tmp_path) -> None:
}
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)
@@ -374,7 +452,7 @@ async def test_message_rejected_when_media_item_shape_wrong(tmp_path) -> None:
}
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)
@@ -398,15 +476,15 @@ async def test_message_rejected_when_media_field_is_not_list() -> None:
channel._handle_message.assert_not_awaited()
err = json.loads(mock_conn.send.call_args[0][0])
assert err["detail"] == "image_rejected"
assert err["detail"] == "attachment_rejected"
assert err["reason"] == "malformed"
@pytest.mark.asyncio
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.
"""
channel = _make_channel()
@@ -417,12 +495,12 @@ async def test_failed_media_does_not_partially_persist(tmp_path) -> None:
"content": "mixed",
"media": [
{"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(
"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)
+20 -2
View File
@@ -204,7 +204,12 @@ async def test_bootstrap_returns_token_for_localhost(
bus: MagicMock, tmp_path: Path
) -> None:
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())
await asyncio.sleep(0.3)
try:
@@ -217,6 +222,19 @@ async def test_bootstrap_returns_token_for_localhost(
assert body["ws_path"] == "/"
assert body["ws_url"] == "ws://127.0.0.1:29901/"
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)
finally:
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:
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(
"websocket:video-replay",
@@ -5,7 +5,6 @@ from __future__ import annotations
import pytest
from nanobot.channels.websocket import (
_extract_data_url_mime,
_is_valid_chat_id,
_parse_envelope,
)
@@ -56,22 +55,3 @@ def test_parse_envelope_only_accepts_typed_json_objects(
else:
assert parsed is not None
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
import base64
from pathlib import Path
import pytest
@@ -50,14 +51,38 @@ def test_saves_common_audio_with_api_friendly_extension(
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:
assert save_base64_data_url("not-a-data-url", tmp_path) is None
def test_returns_none_for_broken_base64(tmp_path) -> None:
# Python's b64decode strips non-alphabet chars by default, so we need a
# payload whose alphabet-filtered length breaks padding.
assert save_base64_data_url("data:image/png;base64,not-valid-base64!!!", tmp_path) is None
@pytest.mark.parametrize("payload", ["not-valid-base64!!!", "@@@@"])
def test_returns_none_for_broken_base64(tmp_path, payload: str) -> None:
assert save_base64_data_url(f"data:image/png;base64,{payload}", tmp_path) is None
assert list(tmp_path.iterdir()) == []
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 { ClientProvider, useClient } from "@/providers/ClientProvider";
import type {
BootstrapResponse,
ChatSummary,
RuntimeSurface,
PairingRequestInfo,
@@ -71,6 +72,7 @@ type BootState =
token: string;
tokenExpiresAt: number;
modelName: string | null;
ingressLimits: BootstrapResponse["limits"] | null;
runtimeSurface: RuntimeSurface;
};
@@ -801,6 +803,7 @@ export default function App() {
token: boot.api_token,
tokenExpiresAt,
modelName: boot.model_name ?? current.modelName,
ingressLimits: boot.limits ?? current.ingressLimits,
runtimeSurface,
}
: current,
@@ -842,6 +845,7 @@ export default function App() {
token: boot.api_token,
tokenExpiresAt: bootstrapTokenExpiresAt(boot.expires_in),
modelName: boot.model_name ?? null,
ingressLimits: boot.limits ?? null,
runtimeSurface,
});
} catch (e) {
@@ -964,6 +968,7 @@ export default function App() {
client={state.client}
token={state.token}
modelName={state.modelName}
ingressLimits={state.ingressLimits}
>
<Shell
runtimeSurface={state.runtimeSurface}
+75 -23
View File
@@ -27,6 +27,7 @@ import {
ChevronUp,
CircleHelp,
CornerDownRight,
FileText,
GripVertical,
History,
ImageIcon,
@@ -58,15 +59,17 @@ import {
WorkspaceProjectPicker,
} from "@/components/thread/WorkspaceControls";
import {
ACCEPT_ATTR,
MAX_ATTACHMENTS_PER_MESSAGE,
useAttachedImages,
type AttachedImage,
type AttachmentError,
MAX_IMAGES_PER_MESSAGE,
type AttachmentKind,
type RestoredReadyImage,
} from "@/hooks/useAttachedImages";
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
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 type {
CliAppInfo,
@@ -76,6 +79,7 @@ import type {
OutboundMcpPresetMention,
SlashCommand,
SkillSummary,
WebUIIngressLimits,
WorkspaceScopePayload,
WorkspacesPayload,
} from "@/lib/types";
@@ -86,9 +90,6 @@ import {
} from "@/lib/provider-brand";
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_ARIA = "Control+Shift+D";
type VoiceShortcutPlatform = "apple" | "chromeos" | "linux" | "other" | "windows";
@@ -143,6 +144,10 @@ function formatBytes(n: number): string {
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
function utf8Bytes(value: string): number {
return new TextEncoder().encode(value).byteLength;
}
function isVoiceShortcutDown(event: KeyboardEvent): boolean {
return (
event.code === VOICE_SHORTCUT_CODE
@@ -192,7 +197,7 @@ function getVoiceShortcutLabel(): string {
}
interface ThreadComposerProps {
onSend: (content: string, images?: SendImage[], options?: SendOptions) => void;
onSend: (content: string, images?: SendAttachment[], options?: SendOptions) => void;
disabled?: boolean;
placeholder?: string;
isStreaming?: boolean;
@@ -220,6 +225,7 @@ interface ThreadComposerProps {
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
pendingQueueKey?: string | null;
transcriptionProvider?: string | null;
ingressLimits?: WebUIIngressLimits | null;
}
const COMMAND_ICONS: Record<string, LucideIcon> = {
@@ -301,6 +307,7 @@ interface QueuedPrompt {
interface QueuedPromptImage {
dataUrl: string;
name?: string;
kind?: AttachmentKind;
}
interface CliAppMentionQuery {
@@ -367,16 +374,22 @@ function normalizeQueuedPrompt(item: unknown, index: number): QueuedPrompt | nul
? record.images.flatMap((image) => {
if (!image || typeof image !== "object") return [];
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 [];
}
const kind = candidate.kind === "file" || candidate.kind === "image"
? candidate.kind
: candidate.dataUrl.startsWith("data:image/")
? "image"
: "file";
return [{
dataUrl: candidate.dataUrl,
kind,
...(typeof candidate.name === "string" && 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;
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) => ({
id: prompt.id,
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[] {
return images.map((img) => ({
dataUrl: img.dataUrl,
kind: img.kind,
name: img.file.name,
}));
}
function queuedImagesToSendImages(images?: QueuedPromptImage[]): SendImage[] | undefined {
function queuedImagesToSendImages(images?: QueuedPromptImage[]): SendAttachment[] | undefined {
if (!images?.length) return undefined;
return images.map((img) => ({
media: {
@@ -439,6 +453,7 @@ function queuedImagesToSendImages(images?: QueuedPromptImage[]): SendImage[] | u
...(img.name ? { name: img.name } : {}),
},
preview: {
kind: img.kind ?? (img.dataUrl.startsWith("data:image/") ? "image" : "file"),
url: img.dataUrl,
...(img.name ? { name: img.name } : {}),
},
@@ -448,7 +463,7 @@ function queuedImagesToSendImages(images?: QueuedPromptImage[]): SendImage[] | u
function queuedPromptLabel(prompt: QueuedPrompt): string {
const text = prompt.text.trim();
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 {
@@ -837,6 +852,7 @@ export function ThreadComposer({
onWorkspaceScopeChange,
pendingQueueKey = null,
transcriptionProvider = null,
ingressLimits = null,
}: ThreadComposerProps) {
const { t } = useTranslation();
const [value, setValue] = useState("");
@@ -892,15 +908,37 @@ export function ThreadComposer({
? t("thread.composer.placeholderStreaming")
: 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 } =
useAttachedImages();
useAttachedImages({ ingressLimits });
const formatRejection = useCallback(
(reason: AttachmentError): string => {
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(
@@ -1411,6 +1449,10 @@ export function ThreadComposer({
const queueGuidancePrompt = useCallback(() => {
const text = value.trim();
if (!canQueueGuidance || (!text && readyImages.length === 0)) return;
if (utf8Bytes(text) > maxTextBytes) {
setInlineError(textTooLargeMessage());
return;
}
const queuedImages = readyImagesToQueuedImages(readyImages);
queuedPromptCounterRef.current += 1;
const id = `queued-prompt-${Date.now()}-${queuedPromptCounterRef.current}`;
@@ -1425,7 +1467,7 @@ export function ThreadComposer({
]);
clear();
clearComposerText();
}, [canQueueGuidance, clear, clearComposerText, readyImages, value]);
}, [canQueueGuidance, clear, clearComposerText, maxTextBytes, readyImages, textTooLargeMessage, value]);
const removeQueuedPrompt = useCallback((id: string) => {
secondEnterPromptIdRef.current = null;
@@ -1526,18 +1568,22 @@ export function ThreadComposer({
if (!canSend) return;
const trimmed = value.trim();
const content = trimmed;
// Share the same normalized ``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: SendImage[] | undefined =
if (utf8Bytes(content) > maxTextBytes) {
setInlineError(textTooLargeMessage());
return;
}
// 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.map((img) => ({
media: {
data_url: img.dataUrl,
name: img.file.name,
},
preview: { url: img.dataUrl, name: img.file.name },
preview: { kind: img.kind, url: img.dataUrl, name: img.file.name },
}))
: undefined;
const attachedCliApps = activeCliMentionApps.map(cliAppMentionPayload);
@@ -1594,12 +1640,14 @@ export function ThreadComposer({
clearComposerText,
handleStop,
isStreaming,
maxTextBytes,
modelNeedsSetup,
onModelBadgeClick,
onSend,
onStop,
readyImages,
slashCommands,
textTooLargeMessage,
value,
]);
@@ -2673,7 +2721,7 @@ function AttachmentChip({
data-testid="composer-chip"
>
<div className="relative h-10 w-10 overflow-hidden rounded-md bg-background">
{image.previewUrl ? (
{image.kind === "image" && image.previewUrl ? (
<img
src={image.previewUrl}
alt=""
@@ -2684,7 +2732,11 @@ function AttachmentChip({
/>
) : (
<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>
)}
{image.status === "encoding" ? (
+7 -5
View File
@@ -9,7 +9,7 @@ import { ThreadComposer } from "@/components/thread/ThreadComposer";
import { ThreadHeader } from "@/components/thread/ThreadHeader";
import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
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 {
fetchInstalledCliApps,
@@ -212,7 +212,7 @@ function randomHeroGreetingKey(): (typeof HERO_GREETING_KEYS)[number] {
interface PendingFirstMessage {
content: string;
images?: SendImage[];
images?: SendAttachment[];
options?: SendOptions;
}
@@ -311,7 +311,7 @@ export function ThreadShell({
version: historyVersion,
forkBoundaryMessageCount,
} = useSessionHistory(historyKey);
const { client, modelName, token } = useClient();
const { client, ingressLimits, modelName, token } = useClient();
const [booting, setBooting] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const cliApps = useInstalledSettingItems({
@@ -589,7 +589,7 @@ export function ThreadShell({
}, [token]);
const handleWelcomeSend = useCallback(
async (content: string, images?: SendImage[], options?: SendOptions) => {
async (content: string, images?: SendAttachment[], options?: SendOptions) => {
if (booting) return;
setBooting(true);
pendingFirstRef.current = { content, images, options: withWorkspaceScope(options) };
@@ -607,7 +607,7 @@ export function ThreadShell({
);
const handleThreadSend = useCallback(
(content: string, images?: SendImage[], options?: SendOptions) => {
(content: string, images?: SendAttachment[], options?: SendOptions) => {
setScrollToLatestUserPromptSignal((value) => value + 1);
send(content, images, withWorkspaceScope(options));
},
@@ -754,6 +754,7 @@ export function ThreadShell({
onWorkspaceScopeChange={onWorkspaceScopeChange}
pendingQueueKey={chatId}
transcriptionProvider={settingsSnapshot?.transcription?.provider}
ingressLimits={ingressLimits}
/>
) : (
<ThreadComposer
@@ -785,6 +786,7 @@ export function ThreadShell({
workspaceError={workspaceError}
onWorkspaceScopeChange={onWorkspaceScopeChange}
transcriptionProvider={settingsSnapshot?.transcription?.provider}
ingressLimits={ingressLimits}
/>
)}
</>
+254 -53
View File
@@ -1,21 +1,24 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { encodeImage, type EncodeFailure } from "@/lib/imageEncode";
import type { WebUIIngressLimits } from "@/lib/types";
/** 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
* - ``error`` validation / decode failure; chip shows inline error
*/
export type AttachmentStatus = "encoding" | "ready" | "error";
export type AttachmentKind = "image" | "file";
export interface AttachedImage {
export interface AttachedAttachment {
id: string;
kind: AttachmentKind;
file: File;
/** Optimistic ``blob:`` preview URL; revoked on ``remove`` / ``clear`` /
* unmount. */
previewUrl: string;
previewUrl?: string;
status: AttachmentStatus;
/** Populated when ``status === "ready"``. */
dataUrl?: string;
@@ -27,37 +30,131 @@ export interface AttachedImage {
error?: AttachmentError;
}
export interface RestoredReadyImage {
export type AttachedImage = AttachedAttachment;
export interface RestoredReadyAttachment {
dataUrl: string;
name?: string;
kind?: AttachmentKind;
}
export type RestoredReadyImage = RestoredReadyAttachment;
/** Machine-readable rejection reasons surfaced as inline chip errors.
*
* Callers localize these via the ``composer.imageRejected.*`` i18n table. */
export type AttachmentError =
| "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
| "decode_failed" // Worker couldn't decode / re-encode
| "too_large" // even after normalization we exceed the budget
| "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. */
const ACCEPTED_MIMES: ReadonlySet<string> = new Set([
const ACCEPTED_IMAGE_MIMES: ReadonlySet<string> = new Set([
"image/png",
"image/jpeg",
"image/webp",
"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 {
const match = /^data:([^;,]+)[;,]/.exec(dataUrl);
return match?.[1] || "image/png";
}
function kindFromDataUrl(dataUrl: string): AttachmentKind {
return dataUrlMime(dataUrl).startsWith("image/") ? "image" : "file";
}
function dataUrlToFile(dataUrl: string, name?: string): File {
const mime = dataUrlMime(dataUrl);
const fallbackName = `image.${mime.split("/")[1] || "png"}`;
@@ -81,6 +178,40 @@ function uuid(): string {
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 {
switch (reason) {
case "invalid_mime":
@@ -97,11 +228,11 @@ function mapEncodeFailure(reason: EncodeFailure["reason"]): AttachmentError {
}
export interface UseAttachedImagesApi {
images: AttachedImage[];
images: AttachedAttachment[];
/** Enqueue new files. Returns the list of rejected files so the caller can
* surface inline errors. Files rejected client-side (wrong MIME, limit) are
* *not* added to ``images`` only recoverable encoding failures show up as
* error chips. */
* *not* added to ``images`` only recoverable read/encoding failures show
* up as error chips. */
enqueue: (files: Iterable<File>) => {
rejected: Array<{ file: File; reason: AttachmentError }>;
};
@@ -110,17 +241,21 @@ export interface UseAttachedImagesApi {
* successful submit the optimistic bubble holds onto an independent
* ``data:`` URL so tearing down blob previews here is safe. */
clear: () => void;
/** Restore already-encoded images, e.g. a queued composer draft moving back
* into the input. These entries are immediately sendable and use their
* ``data:`` URL as a stable preview. */
restoreReadyImages: (images: RestoredReadyImage[]) => void;
/** ``true`` when at least one image is still encoding — Send should wait. */
/** Restore already-encoded attachments, e.g. a queued composer draft moving
* back into the input. These entries are immediately sendable and use image
* ``data:`` URLs as stable previews. */
restoreReadyImages: (images: RestoredReadyAttachment[]) => void;
/** ``true`` when at least one attachment is still encoding — Send should wait. */
encoding: boolean;
/** ``true`` when we've hit ``MAX_IMAGES_PER_MESSAGE``. */
/** ``true`` when we've hit ``MAX_ATTACHMENTS_PER_MESSAGE``. */
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:
* - validation (MIME whitelist, count cap)
@@ -128,15 +263,29 @@ export interface UseAttachedImagesApi {
* - Worker orchestration
* - focus bookkeeping so keyboard delete doesn't strand the user
*/
export function useAttachedImages(): UseAttachedImagesApi {
const [images, setImages] = useState<AttachedImage[]>([]);
export function useAttachedImages({
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
// multiple times in a single tick (rapid file selection, drag of many
// files, paste storms). ``state`` is stale for that second + call.
const imagesRef = useRef<AttachedImage[]>([]);
const imagesRef = useRef<AttachedAttachment[]>([]);
imagesRef.current = images;
const setEntry = useCallback((id: string, patch: Partial<AttachedImage>) => {
const setEntry = useCallback((id: string, patch: Partial<AttachedAttachment>) => {
setImages((prev) => {
const next = prev.map((img) => (img.id === id ? { ...img, ...patch } : img));
imagesRef.current = next;
@@ -147,23 +296,60 @@ export function useAttachedImages(): UseAttachedImagesApi {
const enqueue = useCallback(
(files: Iterable<File>) => {
const rejected: Array<{ file: File; reason: AttachmentError }> = [];
const toAdd: AttachedImage[] = [];
let slot = MAX_IMAGES_PER_MESSAGE - imagesRef.current.length;
const toAdd: AttachedAttachment[] = [];
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) {
if (!ACCEPTED_MIMES.has(file.type)) {
const kind = acceptedAttachmentKind(file);
if (!kind) {
rejected.push({ file, reason: "unsupported_type" });
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) {
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;
}
slot -= 1;
projectedDecodedBytes += nextDecodedBytes;
projectedWireBytes += nextWireBytes;
toAdd.push({
id: uuid(),
kind,
file,
previewUrl: URL.createObjectURL(file),
...(kind === "image" ? { previewUrl: URL.createObjectURL(file) } : {}),
status: "encoding",
});
}
@@ -175,19 +361,24 @@ export function useAttachedImages(): UseAttachedImagesApi {
// Fire the Worker after the commit so chips render first (good INP).
for (const entry of toAdd) {
queueMicrotask(() => {
encodeImage(entry.file).then(
const work = entry.kind === "image"
? encodeImage(entry.file)
: encodeFile(entry.file, maxFileBytes);
work.then(
(result) => {
if (result.ok) {
setEntry(entry.id, {
status: "ready",
dataUrl: result.dataUrl,
encodedBytes: result.bytes,
normalized: result.normalized,
normalized: "normalized" in result ? result.normalized : false,
});
} else {
setEntry(entry.id, {
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 };
},
[setEntry],
[ingressLimits, maxAttachments, maxFileBytes, maxTotalBytes, setEntry],
);
const remove = useCallback((id: string) => {
@@ -212,10 +403,12 @@ export function useAttachedImages(): UseAttachedImagesApi {
const idx = prev.findIndex((img) => img.id === id);
if (idx === -1) return prev;
const target = prev[idx];
try {
URL.revokeObjectURL(target.previewUrl);
} catch {
// No-op: previewUrl revocation is best-effort.
if (target.previewUrl) {
try {
URL.revokeObjectURL(target.previewUrl);
} catch {
// No-op: previewUrl revocation is best-effort.
}
}
const next = [...prev.slice(0, idx), ...prev.slice(idx + 1)];
imagesRef.current = next;
@@ -230,10 +423,12 @@ export function useAttachedImages(): UseAttachedImagesApi {
const clear = useCallback(() => {
setImages((prev) => {
for (const img of prev) {
try {
URL.revokeObjectURL(img.previewUrl);
} catch {
// revoke is best-effort
if (img.previewUrl) {
try {
URL.revokeObjectURL(img.previewUrl);
} catch {
// revoke is best-effort
}
}
}
imagesRef.current = [];
@@ -241,16 +436,18 @@ export function useAttachedImages(): UseAttachedImagesApi {
});
}, []);
const restoreReadyImages = useCallback((restored: RestoredReadyImage[]) => {
const restoreReadyImages = useCallback((restored: RestoredReadyAttachment[]) => {
const toRestore = restored
.filter((img) => ACCEPTED_MIMES.has(dataUrlMime(img.dataUrl)))
.slice(0, MAX_IMAGES_PER_MESSAGE)
.map((img): AttachedImage => {
.filter((img) => acceptedAttachmentKind(dataUrlToFile(img.dataUrl, img.name)))
.slice(0, maxAttachments)
.map((img): AttachedAttachment => {
const file = dataUrlToFile(img.dataUrl, img.name);
const kind = img.kind ?? kindFromDataUrl(img.dataUrl);
return {
id: uuid(),
kind,
file,
previewUrl: img.dataUrl,
...(kind === "image" ? { previewUrl: img.dataUrl } : {}),
status: "ready",
dataUrl: img.dataUrl,
encodedBytes: file.size,
@@ -258,16 +455,18 @@ export function useAttachedImages(): UseAttachedImagesApi {
});
setImages((prev) => {
for (const img of prev) {
try {
URL.revokeObjectURL(img.previewUrl);
} catch {
// revoke is best-effort
if (img.previewUrl) {
try {
URL.revokeObjectURL(img.previewUrl);
} catch {
// revoke is best-effort
}
}
}
imagesRef.current = toRestore;
return toRestore;
});
}, []);
}, [maxAttachments]);
// Final safety net: revoke any outstanding blob URLs on unmount. Safe
// under StrictMode double-invoke because revoked blob URLs are only
@@ -275,17 +474,19 @@ export function useAttachedImages(): UseAttachedImagesApi {
useEffect(() => {
return () => {
for (const img of imagesRef.current) {
try {
URL.revokeObjectURL(img.previewUrl);
} catch {
// best-effort cleanup on unmount
if (img.previewUrl) {
try {
URL.revokeObjectURL(img.previewUrl);
} catch {
// best-effort cleanup on unmount
}
}
}
};
}, []);
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 };
}
+12 -11
View File
@@ -1,12 +1,14 @@
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:
* - Only items whose ``kind === "file"`` and ``type`` starts with
* ``image/`` are returned; ``<img>`` tags inside HTML fragments are
* ignored (defending against remote URL fetch + XSS surfaces).
* - Plain text pasted alongside images is *not* consumed by this helper,
* - Only items whose ``kind === "file"`` and match the Composer whitelist
* are returned; HTML fragments are ignored (defending against remote URL
* fetch + XSS surfaces).
* - Plain text pasted alongside attachments is *not* consumed by this helper,
* so the caller can still let the textarea receive it naturally.
*/
export function extractImageFilesFromPaste(
@@ -18,14 +20,13 @@ export function extractImageFilesFromPaste(
const files: File[] = [];
for (const item of Array.from(clipboard.items)) {
if (item.kind !== "file") continue;
if (!item.type.startsWith("image/")) continue;
const file = item.getAsFile();
if (file) files.push(file);
if (file && acceptedAttachmentKind(file)) files.push(file);
}
return files;
}
/** Extract dropped image files, mirroring ``extractImageFilesFromPaste``. */
/** Extract dropped attachment files, mirroring ``extractImageFilesFromPaste``. */
export function extractImageFilesFromDrop(
event: DragEvent | React.DragEvent,
): File[] {
@@ -34,7 +35,7 @@ export function extractImageFilesFromDrop(
if (!dt) return [];
const files: File[] = [];
for (const item of Array.from(dt.files)) {
if (item.type.startsWith("image/")) files.push(item);
if (acceptedAttachmentKind(item)) files.push(item);
}
return files;
}
@@ -67,8 +68,8 @@ export function useClipboardAndDrop(
(event: React.ClipboardEvent) => {
const files = extractImageFilesFromPaste(event);
if (files.length === 0) return;
// Consume only when an image is actually present; plain-text paste still
// reaches the textarea unmolested.
// Consume only when an attachment is actually present; plain-text paste
// still reaches the textarea unmolested.
event.preventDefault();
onImageFiles(files);
},
+16 -16
View File
@@ -17,7 +17,7 @@ import type {
OutboundMedia,
GoalStateWsPayload,
ToolProgressEvent,
UIImage,
UIMediaAttachment,
UIFileEdit,
UIMessage,
UITurnPhase,
@@ -464,15 +464,15 @@ function findFileEditTraceIndex(
* separately (e.g. via ``fetchWebuiThread``) since the server only replays
* 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
* optimistic user bubble (blob URLs so the preview appears before the server
* acks the frame). Keeping the two separate lets the bubble re-use the local
* blob URL even after the server persists the file under a different name. */
export interface SendImage {
* optimistic user bubble. Keeping the two separate lets the bubble re-use the
* local data URL even after the server persists the file under a different
* name. */
export interface SendAttachment {
media: OutboundMedia;
preview: UIImage;
preview: UIMediaAttachment;
}
export interface SendOptions {
@@ -520,7 +520,7 @@ export function useNanobotStream(
runStartedAt: number | null;
/** Latest sustained goal for this ``chatId`` (``goal_state`` WS events). */
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>;
stop: () => void;
setMessages: React.Dispatch<React.SetStateAction<UIMessage[]>>;
@@ -1135,12 +1135,12 @@ export function useNanobotStream(
]);
const send = useCallback(
(content: string, images?: SendImage[], options?: SendOptions) => {
(content: string, images?: SendAttachment[], options?: SendOptions) => {
if (!chatId) return;
const hasImages = !!images && images.length > 0;
// Text is optional when images are attached — the agent will still see
// the image blocks via ``media`` paths.
if (!hasImages && !content.trim()) return;
const hasAttachments = !!images && images.length > 0;
// Text is optional when files are attached — the agent will still see
// them via ``media`` paths.
if (!hasAttachments && !content.trim()) return;
const sideChannel = options?.sideChannel === true;
const finalizeActiveTurn = options?.finalizeActiveTurn === true;
@@ -1151,7 +1151,7 @@ export function useNanobotStream(
}
const turnId = crypto.randomUUID();
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) => {
if (!sideChannel || finalizeActiveTurn) {
buffer.current = null;
@@ -1171,14 +1171,14 @@ export function useNanobotStream(
turnPhase: "user",
turnSeq: 0,
createdAt: Date.now(),
...(previews ? { images: previews } : {}),
...(previews ? { media: previews } : {}),
...(options?.cliApps?.length ? { cliApps: options.cliApps } : {}),
...(options?.mcpPresets?.length ? { mcpPresets: options.mcpPresets } : {}),
},
];
});
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 };
delete wireOptions.sideChannel;
delete wireOptions.finalizeActiveTurn;
+7 -2
View File
@@ -909,7 +909,7 @@
"edit": "Edit guidance",
"drag": "Drag to reorder"
},
"attachImage": "Attach image",
"attachImage": "Attach files",
"imageMode": {
"label": "Image Generation",
"toggle": "Toggle image generation mode",
@@ -1034,12 +1034,17 @@
"encoding": "Encoding…",
"remove": "Remove attachment",
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
"textTooLarge": "Message text is too large (max {{max}})",
"imageRejected": {
"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",
"magic_mismatch": "File doesn't look like a real 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"
},
"workspace": {
+7 -2
View File
@@ -896,7 +896,7 @@
"edit": "Editar guía",
"drag": "Arrastrar para reordenar"
},
"attachImage": "Adjuntar imagen",
"attachImage": "Adjuntar archivos",
"imageMode": {
"label": "Generar imagen",
"toggle": "Activar o desactivar modo de generación de imágenes",
@@ -1011,12 +1011,17 @@
"encoding": "Procesando…",
"remove": "Quitar adjunto",
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
"textTooLarge": "El texto del mensaje es demasiado grande (máximo {{max}})",
"imageRejected": {
"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",
"magic_mismatch": "El archivo no parece una imagen real",
"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"
},
"mentions": {
+7 -2
View File
@@ -895,7 +895,7 @@
"edit": "Modifier le guidage",
"drag": "Faire glisser pour réordonner"
},
"attachImage": "Joindre une image",
"attachImage": "Joindre des fichiers",
"imageMode": {
"label": "Génération dimage",
"toggle": "Activer ou désactiver le mode génération dimage",
@@ -1010,12 +1010,17 @@
"encoding": "Traitement…",
"remove": "Retirer la pièce jointe",
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
"textTooLarge": "Le texte du message est trop volumineux (maximum {{max}})",
"imageRejected": {
"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",
"magic_mismatch": "Ce fichier n'est pas une 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"
},
"mentions": {
+7 -2
View File
@@ -895,7 +895,7 @@
"edit": "Edit panduan",
"drag": "Seret untuk mengurutkan"
},
"attachImage": "Lampirkan gambar",
"attachImage": "Lampirkan file",
"imageMode": {
"label": "Buat gambar",
"toggle": "Alihkan mode pembuatan gambar",
@@ -1010,12 +1010,17 @@
"encoding": "Memproses…",
"remove": "Hapus lampiran",
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
"textTooLarge": "Teks pesan terlalu besar (maksimum {{max}})",
"imageRejected": {
"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",
"magic_mismatch": "File ini tampaknya bukan gambar asli",
"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"
},
"mentions": {
+7 -2
View File
@@ -895,7 +895,7 @@
"edit": "ガイドを編集",
"drag": "ドラッグして並べ替え"
},
"attachImage": "画像を添付",
"attachImage": "ファイルを添付",
"imageMode": {
"label": "画像生成",
"toggle": "画像生成モードを切り替え",
@@ -1010,12 +1010,17 @@
"encoding": "処理中…",
"remove": "添付を削除",
"normalizedSizeHint": "{{orig}} → {{current}}(自動圧縮)",
"textTooLarge": "メッセージ本文が大きすぎます(最大 {{max}})",
"imageRejected": {
"unsupported_type": "対応していないファイル形式です",
"empty_file": "空のファイルは添付できません",
"too_many_attachments": "1 メッセージにつき最大 {{max}} 件までです",
"too_many_images": "1 メッセージにつき最大 {{max}} 枚です",
"magic_mismatch": "画像ファイルではないようです",
"decode_failed": "この画像をデコードできません",
"too_large": "画像が大きすぎます。小さいものを選んでください",
"too_large": "ファイルが大きすぎます。小さいものを選んでください",
"total_too_large": "添付ファイルの合計サイズが大きすぎます。いくつか削除するか、より小さいファイルを使用してください",
"transport_too_large": "この添付ファイルはゲートウェイの転送上限を超えます",
"io": "このファイルを読み込めません"
},
"mentions": {
+7 -2
View File
@@ -895,7 +895,7 @@
"edit": "안내 수정",
"drag": "드래그하여 순서 변경"
},
"attachImage": "이미지 첨부",
"attachImage": "파일 첨부",
"imageMode": {
"label": "이미지 생성",
"toggle": "이미지 생성 모드 전환",
@@ -1010,12 +1010,17 @@
"encoding": "처리 중…",
"remove": "첨부 제거",
"normalizedSizeHint": "{{orig}} → {{current}} (자동 압축)",
"textTooLarge": "메시지 텍스트가 너무 큽니다(최대 {{max}})",
"imageRejected": {
"unsupported_type": "지원하지 않는 파일 형식입니다",
"empty_file": "빈 파일은 첨부할 수 없습니다",
"too_many_attachments": "메시지당 최대 {{max}}개까지 가능합니다",
"too_many_images": "메시지당 최대 {{max}}장까지 가능합니다",
"magic_mismatch": "이미지 파일이 아닌 것 같습니다",
"decode_failed": "이 이미지를 디코딩할 수 없습니다",
"too_large": "이미지가 너무 큽니다. 더 작은 걸로 시도해 주세요",
"too_large": "파일이 너무 큽니다. 더 작은 파일을 선택해 주세요",
"total_too_large": "첨부 파일의 전체 크기가 너무 큽니다. 일부를 제거하거나 더 작은 파일을 사용해 주세요",
"transport_too_large": "이 첨부 파일은 게이트웨이 전송 한도를 초과합니다",
"io": "이 파일을 읽을 수 없습니다"
},
"mentions": {
+5
View File
@@ -1034,12 +1034,17 @@
"encoding": "Codificando…",
"remove": "Remover anexo",
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
"textTooLarge": "O texto da mensagem é grande demais (máximo de {{max}})",
"imageRejected": {
"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",
"magic_mismatch": "O arquivo não parece uma imagem real",
"decode_failed": "Não foi possível decodificar esta imagem",
"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"
},
"workspace": {
+7 -2
View File
@@ -895,7 +895,7 @@
"edit": "Sửa hướng dẫn",
"drag": "Kéo để sắp xếp"
},
"attachImage": "Đính kèm ảnh",
"attachImage": "Đính kèm tệp",
"imageMode": {
"label": "Tạo ảnh",
"toggle": "Bật/tắt chế độ tạo ảnh",
@@ -1010,12 +1010,17 @@
"encoding": "Đang xử lý…",
"remove": "Xóa tệp đính kèm",
"normalizedSizeHint": "{{orig}} → {{current}} (tự động)",
"textTooLarge": "Nội dung tin nhắn quá lớn (tối đa {{max}})",
"imageRejected": {
"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",
"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",
"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"
},
"mentions": {
+7 -2
View File
@@ -908,7 +908,7 @@
"edit": "编辑引导",
"drag": "拖动排序"
},
"attachImage": "添加图片",
"attachImage": "添加文件",
"imageMode": {
"label": "图片生成",
"toggle": "切换图片生成模式",
@@ -1033,12 +1033,17 @@
"encoding": "处理中…",
"remove": "移除附件",
"normalizedSizeHint": "{{orig}} → {{current}}(已自动压缩)",
"textTooLarge": "消息文本过大(最大 {{max}}",
"imageRejected": {
"unsupported_type": "不支持的文件类型",
"empty_file": "不能附加空文件",
"too_many_attachments": "每条消息最多 {{max}} 个附件",
"too_many_images": "每条消息最多 {{max}} 张图片",
"magic_mismatch": "文件看起来不像真实的图片",
"decode_failed": "无法解码这张图片",
"too_large": "图片太大,请换一小一点的",
"too_large": "文件太大,请换一小一点的",
"total_too_large": "附件总大小过大,请移除部分文件或使用更小的文件",
"transport_too_large": "该附件会超过网关的传输上限",
"io": "无法读取该文件"
},
"goalStateCloseAria": "关闭目标",
+7 -2
View File
@@ -895,7 +895,7 @@
"edit": "編輯引導",
"drag": "拖曳排序"
},
"attachImage": "附加圖片",
"attachImage": "附加檔案",
"imageMode": {
"label": "圖片生成",
"toggle": "切換圖片生成模式",
@@ -1010,12 +1010,17 @@
"encoding": "處理中…",
"remove": "移除附件",
"normalizedSizeHint": "{{orig}} → {{current}}(已自動壓縮)",
"textTooLarge": "訊息文字過大(最大 {{max}}",
"imageRejected": {
"unsupported_type": "不支援的檔案類型",
"empty_file": "無法附加空白檔案",
"too_many_attachments": "每則訊息最多 {{max}} 個附件",
"too_many_images": "每則訊息最多 {{max}} 張圖片",
"magic_mismatch": "檔案看起來不像真正的圖片",
"decode_failed": "無法解碼這張圖片",
"too_large": "圖片太大,請換一小一點的",
"too_large": "檔案太大,請換一小一點的",
"total_too_large": "附件總大小過大,請移除部分檔案或使用更小的檔案",
"transport_too_large": "此附件會超過閘道的傳輸上限",
"io": "無法讀取這個檔案"
},
"mentions": {
+2 -2
View File
@@ -81,8 +81,8 @@ type RunStatusHandler = (chatId: string, startedAt: number | null) => void;
*/
export type StreamError =
/** Server rejected the inbound frame as too large (WS close code 1009).
* Typically means the user attached images whose base64 size exceeded
* ``maxMessageBytes`` on the server. */
* This is the transport fallback after text and attachment policies have
* already been checked independently. */
| { kind: "message_too_big" }
| { kind: "workspace_scope_rejected"; reason?: string; chatId?: string };
+26 -5
View File
@@ -308,11 +308,33 @@ export interface BootstrapResponse {
ws_path: string;
ws_url?: string | null;
expires_in: number;
limits?: WebUIIngressLimits;
model_name?: string | null;
runtime_surface?: RuntimeSurface;
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 RestartBehavior = "none" | "nextTurn" | "engineRestart" | "appRestart";
export type SettingsApplyStatus =
@@ -1050,12 +1072,11 @@ export type InboundEvent =
}
| { 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
* the server whitelists those MIME types and rejects everything else
* (including SVG, to avoid an XSS surface). ``name`` is advisory: it's
* preserved for the file on disk and surfaced as the placeholder label when
* ``data_url`` must use a server-whitelisted image, video, or document MIME
* type. SVG remains rejected on ingress to avoid an embedded-script XSS
* surface. ``name`` is advisory and is surfaced as the placeholder label when
* the session is replayed.
*/
export interface OutboundMedia {
+5 -1
View File
@@ -1,11 +1,13 @@
import { createContext, useContext, type ReactNode } from "react";
import type { NanobotClient } from "@/lib/nanobot-client";
import type { WebUIIngressLimits } from "@/lib/types";
interface ClientContextValue {
client: NanobotClient;
token: string;
modelName: string | null;
ingressLimits: WebUIIngressLimits | null;
}
const ClientContext = createContext<ClientContextValue | null>(null);
@@ -14,15 +16,17 @@ export function ClientProvider({
client,
token,
modelName = null,
ingressLimits = null,
children,
}: {
client: NanobotClient;
token: string;
modelName?: string | null;
ingressLimits?: WebUIIngressLimits | null;
children: ReactNode;
}) {
return (
<ClientContext.Provider value={{ client, token, modelName }}>
<ClientContext.Provider value={{ client, token, modelName, ingressLimits }}>
{children}
</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 type { EncodeResponse } from "@/lib/imageEncode";
import type { WebUIIngressLimits } from "@/lib/types";
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" });
}
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 {
return {
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(() => {
encodeImage.mockReset();
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 () => {
const file = pngFile("a.png");
encodeImage.mockResolvedValueOnce(resolveReady(file));
@@ -83,6 +117,228 @@ describe("ThreadComposer — image attachments", () => {
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 () => {
const file = pngFile("slow.png");
let resolveEncode: (r: EncodeResponse) => void = () => {};
@@ -117,7 +373,7 @@ describe("ThreadComposer — image attachments", () => {
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();
render(<ThreadComposer onSend={onSend} />);
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("rounded-[22px]");
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.queryByText(/Enter to send/)).not.toBeInTheDocument();
});
+31
View File
@@ -1520,6 +1520,37 @@ describe("useNanobotStream", () => {
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", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-m", EMPTY_MESSAGES), {