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