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

* feat: support document attachments in webui

* fix(webui): normalize document attachment MIME

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

* fix(webui): reject oversized attachments before send

* fix(webui): align Portuguese attachment errors

* refactor(webui): separate ingress and transport limits

* fix(webui): reject malformed attachment payloads
This commit is contained in:
chengyongru
2026-07-14 14:47:42 +08:00
committed by GitHub
parent b2759e8a6b
commit b7048cf76a
36 changed files with 1398 additions and 332 deletions
+5
View File
@@ -38,6 +38,7 @@ import { deriveTitle } from "@/lib/format";
import { NanobotClient } from "@/lib/nanobot-client";
import { ClientProvider, useClient } from "@/providers/ClientProvider";
import type {
BootstrapResponse,
ChatSummary,
RuntimeSurface,
PairingRequestInfo,
@@ -71,6 +72,7 @@ type BootState =
token: string;
tokenExpiresAt: number;
modelName: string | null;
ingressLimits: BootstrapResponse["limits"] | null;
runtimeSurface: RuntimeSurface;
};
@@ -801,6 +803,7 @@ export default function App() {
token: boot.api_token,
tokenExpiresAt,
modelName: boot.model_name ?? current.modelName,
ingressLimits: boot.limits ?? current.ingressLimits,
runtimeSurface,
}
: current,
@@ -842,6 +845,7 @@ export default function App() {
token: boot.api_token,
tokenExpiresAt: bootstrapTokenExpiresAt(boot.expires_in),
modelName: boot.model_name ?? null,
ingressLimits: boot.limits ?? null,
runtimeSurface,
});
} catch (e) {
@@ -964,6 +968,7 @@ export default function App() {
client={state.client}
token={state.token}
modelName={state.modelName}
ingressLimits={state.ingressLimits}
>
<Shell
runtimeSurface={state.runtimeSurface}
+75 -23
View File
@@ -27,6 +27,7 @@ import {
ChevronUp,
CircleHelp,
CornerDownRight,
FileText,
GripVertical,
History,
ImageIcon,
@@ -58,15 +59,17 @@ import {
WorkspaceProjectPicker,
} from "@/components/thread/WorkspaceControls";
import {
ACCEPT_ATTR,
MAX_ATTACHMENTS_PER_MESSAGE,
useAttachedImages,
type AttachedImage,
type AttachmentError,
MAX_IMAGES_PER_MESSAGE,
type AttachmentKind,
type RestoredReadyImage,
} from "@/hooks/useAttachedImages";
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import type { SendImage, SendOptions } from "@/hooks/useNanobotStream";
import type { SendAttachment, SendOptions } from "@/hooks/useNanobotStream";
import { useVoiceRecorder, type VoiceRecorderErrorKey } from "@/hooks/useVoiceRecorder";
import type {
CliAppInfo,
@@ -76,6 +79,7 @@ import type {
OutboundMcpPresetMention,
SlashCommand,
SkillSummary,
WebUIIngressLimits,
WorkspaceScopePayload,
WorkspacesPayload,
} from "@/lib/types";
@@ -86,9 +90,6 @@ import {
} from "@/lib/provider-brand";
import { cn } from "@/lib/utils";
/** ``<input accept>``: aligned with the server's MIME whitelist. SVG is
* deliberately excluded to avoid an embedded-script XSS surface. */
const ACCEPT_ATTR = "image/png,image/jpeg,image/webp,image/gif";
const VOICE_SHORTCUT_CODE = "KeyD";
const VOICE_SHORTCUT_ARIA = "Control+Shift+D";
type VoiceShortcutPlatform = "apple" | "chromeos" | "linux" | "other" | "windows";
@@ -143,6 +144,10 @@ function formatBytes(n: number): string {
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
function utf8Bytes(value: string): number {
return new TextEncoder().encode(value).byteLength;
}
function isVoiceShortcutDown(event: KeyboardEvent): boolean {
return (
event.code === VOICE_SHORTCUT_CODE
@@ -192,7 +197,7 @@ function getVoiceShortcutLabel(): string {
}
interface ThreadComposerProps {
onSend: (content: string, images?: SendImage[], options?: SendOptions) => void;
onSend: (content: string, images?: SendAttachment[], options?: SendOptions) => void;
disabled?: boolean;
placeholder?: string;
isStreaming?: boolean;
@@ -220,6 +225,7 @@ interface ThreadComposerProps {
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
pendingQueueKey?: string | null;
transcriptionProvider?: string | null;
ingressLimits?: WebUIIngressLimits | null;
}
const COMMAND_ICONS: Record<string, LucideIcon> = {
@@ -301,6 +307,7 @@ interface QueuedPrompt {
interface QueuedPromptImage {
dataUrl: string;
name?: string;
kind?: AttachmentKind;
}
interface CliAppMentionQuery {
@@ -367,16 +374,22 @@ function normalizeQueuedPrompt(item: unknown, index: number): QueuedPrompt | nul
? record.images.flatMap((image) => {
if (!image || typeof image !== "object") return [];
const candidate = image as Partial<QueuedPromptImage>;
if (typeof candidate.dataUrl !== "string" || !candidate.dataUrl.startsWith("data:image/")) {
if (typeof candidate.dataUrl !== "string" || !candidate.dataUrl.startsWith("data:")) {
return [];
}
const kind = candidate.kind === "file" || candidate.kind === "image"
? candidate.kind
: candidate.dataUrl.startsWith("data:image/")
? "image"
: "file";
return [{
dataUrl: candidate.dataUrl,
kind,
...(typeof candidate.name === "string" && candidate.name.trim()
? { name: candidate.name.trim() }
: {}),
}];
}).slice(0, MAX_IMAGES_PER_MESSAGE)
}).slice(0, MAX_ATTACHMENTS_PER_MESSAGE)
: [];
if (!text && images.length === 0) return null;
const id = typeof record.id === "string" && record.id.trim()
@@ -413,7 +426,7 @@ function storeQueuedPrompts(storageKey: string, prompts: QueuedPrompt[]): void {
prompts.slice(0, QUEUED_PROMPTS_LIMIT).map((prompt) => ({
id: prompt.id,
text: prompt.text.slice(0, QUEUED_PROMPT_MAX_CHARS),
...(prompt.images?.length ? { images: prompt.images.slice(0, MAX_IMAGES_PER_MESSAGE) } : {}),
...(prompt.images?.length ? { images: prompt.images.slice(0, MAX_ATTACHMENTS_PER_MESSAGE) } : {}),
})),
),
);
@@ -427,11 +440,12 @@ function readyImagesToQueuedImages(
): QueuedPromptImage[] {
return images.map((img) => ({
dataUrl: img.dataUrl,
kind: img.kind,
name: img.file.name,
}));
}
function queuedImagesToSendImages(images?: QueuedPromptImage[]): SendImage[] | undefined {
function queuedImagesToSendImages(images?: QueuedPromptImage[]): SendAttachment[] | undefined {
if (!images?.length) return undefined;
return images.map((img) => ({
media: {
@@ -439,6 +453,7 @@ function queuedImagesToSendImages(images?: QueuedPromptImage[]): SendImage[] | u
...(img.name ? { name: img.name } : {}),
},
preview: {
kind: img.kind ?? (img.dataUrl.startsWith("data:image/") ? "image" : "file"),
url: img.dataUrl,
...(img.name ? { name: img.name } : {}),
},
@@ -448,7 +463,7 @@ function queuedImagesToSendImages(images?: QueuedPromptImage[]): SendImage[] | u
function queuedPromptLabel(prompt: QueuedPrompt): string {
const text = prompt.text.trim();
if (text) return text;
return prompt.images?.map((img) => img.name).filter(Boolean).join(", ") || "Image attachment";
return prompt.images?.map((img) => img.name).filter(Boolean).join(", ") || "File attachment";
}
function suppressNativeDragPreview(dataTransfer: DataTransfer): void {
@@ -837,6 +852,7 @@ export function ThreadComposer({
onWorkspaceScopeChange,
pendingQueueKey = null,
transcriptionProvider = null,
ingressLimits = null,
}: ThreadComposerProps) {
const { t } = useTranslation();
const [value, setValue] = useState("");
@@ -892,15 +908,37 @@ export function ThreadComposer({
? t("thread.composer.placeholderStreaming")
: placeholder ?? t("thread.composer.placeholderThread");
const maxAttachments = ingressLimits?.attachments.max_count
?? MAX_ATTACHMENTS_PER_MESSAGE;
const maxTextBytes = ingressLimits?.message.max_text_bytes ?? 64 * 1024;
const { images, enqueue, remove, clear, restoreReadyImages, encoding, full } =
useAttachedImages();
useAttachedImages({ ingressLimits });
const formatRejection = useCallback(
(reason: AttachmentError): string => {
const key = `thread.composer.imageRejected.${reason}`;
return t(key, { max: MAX_IMAGES_PER_MESSAGE });
const fallback = reason === "too_many_attachments"
? `Max ${maxAttachments} attachments per message`
: reason === "empty_file"
? "Empty files cannot be attached"
: reason === "total_too_large"
? "Attachments are too large together — remove some or use smaller files"
: reason === "transport_too_large"
? "This attachment would exceed the gateway transport limit"
: reason === "too_large"
? "File is too large"
: "Unsupported file type";
return t(key, { max: maxAttachments, defaultValue: fallback });
},
[t],
[maxAttachments, t],
);
const textTooLargeMessage = useCallback(
() => t("thread.composer.textTooLarge", {
max: formatBytes(maxTextBytes),
defaultValue: `Message text is too large (max ${formatBytes(maxTextBytes)})`,
}),
[maxTextBytes, t],
);
const addFiles = useCallback(
@@ -1411,6 +1449,10 @@ export function ThreadComposer({
const queueGuidancePrompt = useCallback(() => {
const text = value.trim();
if (!canQueueGuidance || (!text && readyImages.length === 0)) return;
if (utf8Bytes(text) > maxTextBytes) {
setInlineError(textTooLargeMessage());
return;
}
const queuedImages = readyImagesToQueuedImages(readyImages);
queuedPromptCounterRef.current += 1;
const id = `queued-prompt-${Date.now()}-${queuedPromptCounterRef.current}`;
@@ -1425,7 +1467,7 @@ export function ThreadComposer({
]);
clear();
clearComposerText();
}, [canQueueGuidance, clear, clearComposerText, readyImages, value]);
}, [canQueueGuidance, clear, clearComposerText, maxTextBytes, readyImages, textTooLargeMessage, value]);
const removeQueuedPrompt = useCallback((id: string) => {
secondEnterPromptIdRef.current = null;
@@ -1526,18 +1568,22 @@ export function ThreadComposer({
if (!canSend) return;
const trimmed = value.trim();
const content = trimmed;
// Share the same normalized ``data:`` URL with both the wire payload and
// the optimistic bubble preview: data URLs are self-contained (no blob
// lifetime, safe under React StrictMode double-mount) and keep the
// bubble in sync with whatever the backend actually sees.
const payload: SendImage[] | undefined =
if (utf8Bytes(content) > maxTextBytes) {
setInlineError(textTooLargeMessage());
return;
}
// Share the same ``data:`` URL with both the wire payload and the
// optimistic bubble preview: data URLs are self-contained (no blob
// lifetime, safe under React StrictMode double-mount) and keep the bubble
// in sync with whatever the backend actually sees.
const payload: SendAttachment[] | undefined =
readyImages.length > 0
? readyImages.map((img) => ({
media: {
data_url: img.dataUrl,
name: img.file.name,
},
preview: { url: img.dataUrl, name: img.file.name },
preview: { kind: img.kind, url: img.dataUrl, name: img.file.name },
}))
: undefined;
const attachedCliApps = activeCliMentionApps.map(cliAppMentionPayload);
@@ -1594,12 +1640,14 @@ export function ThreadComposer({
clearComposerText,
handleStop,
isStreaming,
maxTextBytes,
modelNeedsSetup,
onModelBadgeClick,
onSend,
onStop,
readyImages,
slashCommands,
textTooLargeMessage,
value,
]);
@@ -2673,7 +2721,7 @@ function AttachmentChip({
data-testid="composer-chip"
>
<div className="relative h-10 w-10 overflow-hidden rounded-md bg-background">
{image.previewUrl ? (
{image.kind === "image" && image.previewUrl ? (
<img
src={image.previewUrl}
alt=""
@@ -2684,7 +2732,11 @@ function AttachmentChip({
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<ImageIcon className="h-4 w-4 text-muted-foreground" aria-hidden />
{image.kind === "image" ? (
<ImageIcon className="h-4 w-4 text-muted-foreground" aria-hidden />
) : (
<FileText className="h-4 w-4 text-muted-foreground" aria-hidden />
)}
</div>
)}
{image.status === "encoding" ? (
+7 -5
View File
@@ -9,7 +9,7 @@ import { ThreadComposer } from "@/components/thread/ThreadComposer";
import { ThreadHeader } from "@/components/thread/ThreadHeader";
import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
import { ThreadViewport, type ThreadViewportHandle } from "@/components/thread/ThreadViewport";
import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useNanobotStream";
import { useNanobotStream, type SendAttachment, type SendOptions } from "@/hooks/useNanobotStream";
import { useSessionHistory } from "@/hooks/useSessions";
import {
fetchInstalledCliApps,
@@ -212,7 +212,7 @@ function randomHeroGreetingKey(): (typeof HERO_GREETING_KEYS)[number] {
interface PendingFirstMessage {
content: string;
images?: SendImage[];
images?: SendAttachment[];
options?: SendOptions;
}
@@ -311,7 +311,7 @@ export function ThreadShell({
version: historyVersion,
forkBoundaryMessageCount,
} = useSessionHistory(historyKey);
const { client, modelName, token } = useClient();
const { client, ingressLimits, modelName, token } = useClient();
const [booting, setBooting] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const cliApps = useInstalledSettingItems({
@@ -589,7 +589,7 @@ export function ThreadShell({
}, [token]);
const handleWelcomeSend = useCallback(
async (content: string, images?: SendImage[], options?: SendOptions) => {
async (content: string, images?: SendAttachment[], options?: SendOptions) => {
if (booting) return;
setBooting(true);
pendingFirstRef.current = { content, images, options: withWorkspaceScope(options) };
@@ -607,7 +607,7 @@ export function ThreadShell({
);
const handleThreadSend = useCallback(
(content: string, images?: SendImage[], options?: SendOptions) => {
(content: string, images?: SendAttachment[], options?: SendOptions) => {
setScrollToLatestUserPromptSignal((value) => value + 1);
send(content, images, withWorkspaceScope(options));
},
@@ -754,6 +754,7 @@ export function ThreadShell({
onWorkspaceScopeChange={onWorkspaceScopeChange}
pendingQueueKey={chatId}
transcriptionProvider={settingsSnapshot?.transcription?.provider}
ingressLimits={ingressLimits}
/>
) : (
<ThreadComposer
@@ -785,6 +786,7 @@ export function ThreadShell({
workspaceError={workspaceError}
onWorkspaceScopeChange={onWorkspaceScopeChange}
transcriptionProvider={settingsSnapshot?.transcription?.provider}
ingressLimits={ingressLimits}
/>
)}
</>
+254 -53
View File
@@ -1,21 +1,24 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { encodeImage, type EncodeFailure } from "@/lib/imageEncode";
import type { WebUIIngressLimits } from "@/lib/types";
/** Lifecycle stages of one attachment:
*
* - ``encoding`` — posted to the Worker; chip shows a spinner
* - ``encoding`` — posted to the Worker / read from disk; chip shows a spinner
* - ``ready`` — ``dataUrl`` available; safe to submit
* - ``error`` — validation / decode failure; chip shows inline error
*/
export type AttachmentStatus = "encoding" | "ready" | "error";
export type AttachmentKind = "image" | "file";
export interface AttachedImage {
export interface AttachedAttachment {
id: string;
kind: AttachmentKind;
file: File;
/** Optimistic ``blob:`` preview URL; revoked on ``remove`` / ``clear`` /
* unmount. */
previewUrl: string;
previewUrl?: string;
status: AttachmentStatus;
/** Populated when ``status === "ready"``. */
dataUrl?: string;
@@ -27,37 +30,131 @@ export interface AttachedImage {
error?: AttachmentError;
}
export interface RestoredReadyImage {
export type AttachedImage = AttachedAttachment;
export interface RestoredReadyAttachment {
dataUrl: string;
name?: string;
kind?: AttachmentKind;
}
export type RestoredReadyImage = RestoredReadyAttachment;
/** Machine-readable rejection reasons surfaced as inline chip errors.
*
* Callers localize these via the ``composer.imageRejected.*`` i18n table. */
export type AttachmentError =
| "unsupported_type" // server whitelist excludes this MIME
| "too_many_images" // per-message cap (4) reached before enqueue
| "empty_file" // backend data-URL decoder rejects empty payloads
| "too_many_attachments" // per-message cap (4) reached before enqueue
| "total_too_large" // decoded attachments exceed the business-policy total
| "transport_too_large" // projected JSON frame exceeds the transport guard
| "magic_mismatch" // extension lies about the real content
| "decode_failed" // Worker couldn't decode / re-encode
| "too_large" // even after normalization we exceed the budget
| "io"; // file read failed at the browser layer
export const MAX_IMAGES_PER_MESSAGE = 4;
export const MAX_ATTACHMENTS_PER_MESSAGE = 4;
export const MAX_IMAGES_PER_MESSAGE = MAX_ATTACHMENTS_PER_MESSAGE;
export const MAX_ATTACHMENT_BYTES = 6 * 1024 * 1024;
export const MAX_TOTAL_ATTACHMENT_BYTES = 24 * 1024 * 1024;
/** MIME whitelist — mirrors the server's and the ``<input accept>`` attr. */
const ACCEPTED_MIMES: ReadonlySet<string> = new Set([
const ACCEPTED_IMAGE_MIMES: ReadonlySet<string> = new Set([
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
]);
const DOCUMENT_MIME_BY_EXTENSION: ReadonlyMap<string, string> = new Map([
[".pdf", "application/pdf"],
[".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
[".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
[".pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"],
[".txt", "text/plain"],
[".md", "text/markdown"],
[".csv", "text/csv"],
[".json", "application/json"],
[".xml", "application/xml"],
[".html", "text/html"],
[".htm", "text/html"],
[".log", "text/plain"],
[".yaml", "application/yaml"],
[".yml", "application/yaml"],
[".toml", "application/toml"],
[".ini", "text/plain"],
[".cfg", "text/plain"],
]);
const ACCEPTED_DOCUMENT_MIMES: ReadonlySet<string> = new Set(DOCUMENT_MIME_BY_EXTENSION.values());
export const ACCEPT_ATTR = [
...ACCEPTED_IMAGE_MIMES,
...ACCEPTED_DOCUMENT_MIMES,
...DOCUMENT_MIME_BY_EXTENSION.keys(),
].join(",");
function extensionOf(name: string): string {
const dot = name.lastIndexOf(".");
return dot < 0 ? "" : name.slice(dot).toLowerCase();
}
function mimeForFile(file: File): string {
const byName = DOCUMENT_MIME_BY_EXTENSION.get(extensionOf(file.name));
if (byName) return byName;
if (!file.type || file.type === "application/octet-stream") {
return "application/octet-stream";
}
return file.type;
}
function projectedDataUrlBytes(
file: File,
kind: AttachmentKind,
maxFileBytes: number,
): number {
const prefixBytes = `data:${mimeForFile(file)};base64,`.length;
const decodedBytes = kind === "image" ? Math.min(file.size, maxFileBytes) : file.size;
return prefixBytes + 4 * Math.ceil(decodedBytes / 3);
}
function positiveLimit(value: number | null | undefined, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) && value > 0
? Math.floor(value)
: fallback;
}
function attachmentPayloadBudget(limits: WebUIIngressLimits | null | undefined): number | null {
const maxFrameBytes = limits?.transport.max_frame_bytes;
if (typeof maxFrameBytes !== "number" || !Number.isFinite(maxFrameBytes)) {
return null;
}
return Math.max(
0,
Math.floor(maxFrameBytes)
- positiveLimit(limits?.message.max_text_bytes, 0)
- positiveLimit(limits?.transport.envelope_reserve_bytes, 0),
);
}
export function acceptedAttachmentKind(file: File): AttachmentKind | null {
if (DOCUMENT_MIME_BY_EXTENSION.has(extensionOf(file.name))) return "file";
if (ACCEPTED_IMAGE_MIMES.has(file.type)) return "image";
const mime = mimeForFile(file);
if (ACCEPTED_DOCUMENT_MIMES.has(mime)) return "file";
return null;
}
function dataUrlMime(dataUrl: string): string {
const match = /^data:([^;,]+)[;,]/.exec(dataUrl);
return match?.[1] || "image/png";
}
function kindFromDataUrl(dataUrl: string): AttachmentKind {
return dataUrlMime(dataUrl).startsWith("image/") ? "image" : "file";
}
function dataUrlToFile(dataUrl: string, name?: string): File {
const mime = dataUrlMime(dataUrl);
const fallbackName = `image.${mime.split("/")[1] || "png"}`;
@@ -81,6 +178,40 @@ function uuid(): string {
return `img-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
function bufferToBase64(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf);
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode.apply(
null,
bytes.subarray(i, i + chunk) as unknown as number[],
);
}
return btoa(binary);
}
async function encodeFile(file: File, maxFileBytes: number): Promise<{
ok: true;
dataUrl: string;
bytes: number;
} | {
ok: false;
reason: AttachmentError;
}> {
if (file.size > maxFileBytes) return { ok: false, reason: "too_large" };
try {
const buffer = await file.arrayBuffer();
return {
ok: true,
dataUrl: `data:${mimeForFile(file)};base64,${bufferToBase64(buffer)}`,
bytes: file.size,
};
} catch {
return { ok: false, reason: "io" };
}
}
function mapEncodeFailure(reason: EncodeFailure["reason"]): AttachmentError {
switch (reason) {
case "invalid_mime":
@@ -97,11 +228,11 @@ function mapEncodeFailure(reason: EncodeFailure["reason"]): AttachmentError {
}
export interface UseAttachedImagesApi {
images: AttachedImage[];
images: AttachedAttachment[];
/** Enqueue new files. Returns the list of rejected files so the caller can
* surface inline errors. Files rejected client-side (wrong MIME, limit) are
* *not* added to ``images`` — only recoverable encoding failures show up as
* error chips. */
* *not* added to ``images`` — only recoverable read/encoding failures show
* up as error chips. */
enqueue: (files: Iterable<File>) => {
rejected: Array<{ file: File; reason: AttachmentError }>;
};
@@ -110,17 +241,21 @@ export interface UseAttachedImagesApi {
* successful submit — the optimistic bubble holds onto an independent
* ``data:`` URL so tearing down blob previews here is safe. */
clear: () => void;
/** Restore already-encoded images, e.g. a queued composer draft moving back
* into the input. These entries are immediately sendable and use their
* ``data:`` URL as a stable preview. */
restoreReadyImages: (images: RestoredReadyImage[]) => void;
/** ``true`` when at least one image is still encoding — Send should wait. */
/** Restore already-encoded attachments, e.g. a queued composer draft moving
* back into the input. These entries are immediately sendable and use image
* ``data:`` URLs as stable previews. */
restoreReadyImages: (images: RestoredReadyAttachment[]) => void;
/** ``true`` when at least one attachment is still encoding — Send should wait. */
encoding: boolean;
/** ``true`` when we've hit ``MAX_IMAGES_PER_MESSAGE``. */
/** ``true`` when we've hit ``MAX_ATTACHMENTS_PER_MESSAGE``. */
full: boolean;
}
/** Manage the lifecycle of images attached to the Composer.
interface UseAttachedImagesOptions {
ingressLimits?: WebUIIngressLimits | null;
}
/** Manage the lifecycle of attachments in the Composer.
*
* Responsibilities in one place:
* - validation (MIME whitelist, count cap)
@@ -128,15 +263,29 @@ export interface UseAttachedImagesApi {
* - Worker orchestration
* - focus bookkeeping so keyboard delete doesn't strand the user
*/
export function useAttachedImages(): UseAttachedImagesApi {
const [images, setImages] = useState<AttachedImage[]>([]);
export function useAttachedImages({
ingressLimits = null,
}: UseAttachedImagesOptions = {}): UseAttachedImagesApi {
const [images, setImages] = useState<AttachedAttachment[]>([]);
const maxAttachments = positiveLimit(
ingressLimits?.attachments.max_count,
MAX_ATTACHMENTS_PER_MESSAGE,
);
const maxFileBytes = positiveLimit(
ingressLimits?.attachments.max_file_bytes,
MAX_ATTACHMENT_BYTES,
);
const maxTotalBytes = positiveLimit(
ingressLimits?.attachments.max_total_bytes,
MAX_TOTAL_ATTACHMENT_BYTES,
);
// Ref mirror so ``enqueue`` can see the authoritative length when invoked
// multiple times in a single tick (rapid file selection, drag of many
// files, paste storms). ``state`` is stale for that second + call.
const imagesRef = useRef<AttachedImage[]>([]);
const imagesRef = useRef<AttachedAttachment[]>([]);
imagesRef.current = images;
const setEntry = useCallback((id: string, patch: Partial<AttachedImage>) => {
const setEntry = useCallback((id: string, patch: Partial<AttachedAttachment>) => {
setImages((prev) => {
const next = prev.map((img) => (img.id === id ? { ...img, ...patch } : img));
imagesRef.current = next;
@@ -147,23 +296,60 @@ export function useAttachedImages(): UseAttachedImagesApi {
const enqueue = useCallback(
(files: Iterable<File>) => {
const rejected: Array<{ file: File; reason: AttachmentError }> = [];
const toAdd: AttachedImage[] = [];
let slot = MAX_IMAGES_PER_MESSAGE - imagesRef.current.length;
const toAdd: AttachedAttachment[] = [];
let slot = maxAttachments - imagesRef.current.length;
const payloadBudget = attachmentPayloadBudget(ingressLimits);
let projectedWireBytes = imagesRef.current.reduce(
(total, image) => total + (
image.dataUrl?.length
?? projectedDataUrlBytes(image.file, image.kind, maxFileBytes)
),
0,
);
let projectedDecodedBytes = imagesRef.current.reduce(
(total, image) => total + (
image.encodedBytes
?? (image.kind === "image" ? Math.min(image.file.size, maxFileBytes) : image.file.size)
),
0,
);
for (const file of files) {
if (!ACCEPTED_MIMES.has(file.type)) {
const kind = acceptedAttachmentKind(file);
if (!kind) {
rejected.push({ file, reason: "unsupported_type" });
continue;
}
if (file.size === 0) {
rejected.push({ file, reason: "empty_file" });
continue;
}
if (kind === "file" && file.size > maxFileBytes) {
rejected.push({ file, reason: "too_large" });
continue;
}
if (slot <= 0) {
rejected.push({ file, reason: "too_many_images" });
rejected.push({ file, reason: "too_many_attachments" });
continue;
}
const nextDecodedBytes = kind === "image" ? Math.min(file.size, maxFileBytes) : file.size;
if (projectedDecodedBytes + nextDecodedBytes > maxTotalBytes) {
rejected.push({ file, reason: "total_too_large" });
continue;
}
const nextWireBytes = projectedDataUrlBytes(file, kind, maxFileBytes);
if (payloadBudget !== null && projectedWireBytes + nextWireBytes > payloadBudget) {
rejected.push({ file, reason: "transport_too_large" });
continue;
}
slot -= 1;
projectedDecodedBytes += nextDecodedBytes;
projectedWireBytes += nextWireBytes;
toAdd.push({
id: uuid(),
kind,
file,
previewUrl: URL.createObjectURL(file),
...(kind === "image" ? { previewUrl: URL.createObjectURL(file) } : {}),
status: "encoding",
});
}
@@ -175,19 +361,24 @@ export function useAttachedImages(): UseAttachedImagesApi {
// Fire the Worker after the commit so chips render first (good INP).
for (const entry of toAdd) {
queueMicrotask(() => {
encodeImage(entry.file).then(
const work = entry.kind === "image"
? encodeImage(entry.file)
: encodeFile(entry.file, maxFileBytes);
work.then(
(result) => {
if (result.ok) {
setEntry(entry.id, {
status: "ready",
dataUrl: result.dataUrl,
encodedBytes: result.bytes,
normalized: result.normalized,
normalized: "normalized" in result ? result.normalized : false,
});
} else {
setEntry(entry.id, {
status: "error",
error: mapEncodeFailure(result.reason),
error: entry.kind === "image"
? mapEncodeFailure(result.reason as EncodeFailure["reason"])
: result.reason as AttachmentError,
});
}
},
@@ -203,7 +394,7 @@ export function useAttachedImages(): UseAttachedImagesApi {
}
return { rejected };
},
[setEntry],
[ingressLimits, maxAttachments, maxFileBytes, maxTotalBytes, setEntry],
);
const remove = useCallback((id: string) => {
@@ -212,10 +403,12 @@ export function useAttachedImages(): UseAttachedImagesApi {
const idx = prev.findIndex((img) => img.id === id);
if (idx === -1) return prev;
const target = prev[idx];
try {
URL.revokeObjectURL(target.previewUrl);
} catch {
// No-op: previewUrl revocation is best-effort.
if (target.previewUrl) {
try {
URL.revokeObjectURL(target.previewUrl);
} catch {
// No-op: previewUrl revocation is best-effort.
}
}
const next = [...prev.slice(0, idx), ...prev.slice(idx + 1)];
imagesRef.current = next;
@@ -230,10 +423,12 @@ export function useAttachedImages(): UseAttachedImagesApi {
const clear = useCallback(() => {
setImages((prev) => {
for (const img of prev) {
try {
URL.revokeObjectURL(img.previewUrl);
} catch {
// revoke is best-effort
if (img.previewUrl) {
try {
URL.revokeObjectURL(img.previewUrl);
} catch {
// revoke is best-effort
}
}
}
imagesRef.current = [];
@@ -241,16 +436,18 @@ export function useAttachedImages(): UseAttachedImagesApi {
});
}, []);
const restoreReadyImages = useCallback((restored: RestoredReadyImage[]) => {
const restoreReadyImages = useCallback((restored: RestoredReadyAttachment[]) => {
const toRestore = restored
.filter((img) => ACCEPTED_MIMES.has(dataUrlMime(img.dataUrl)))
.slice(0, MAX_IMAGES_PER_MESSAGE)
.map((img): AttachedImage => {
.filter((img) => acceptedAttachmentKind(dataUrlToFile(img.dataUrl, img.name)))
.slice(0, maxAttachments)
.map((img): AttachedAttachment => {
const file = dataUrlToFile(img.dataUrl, img.name);
const kind = img.kind ?? kindFromDataUrl(img.dataUrl);
return {
id: uuid(),
kind,
file,
previewUrl: img.dataUrl,
...(kind === "image" ? { previewUrl: img.dataUrl } : {}),
status: "ready",
dataUrl: img.dataUrl,
encodedBytes: file.size,
@@ -258,16 +455,18 @@ export function useAttachedImages(): UseAttachedImagesApi {
});
setImages((prev) => {
for (const img of prev) {
try {
URL.revokeObjectURL(img.previewUrl);
} catch {
// revoke is best-effort
if (img.previewUrl) {
try {
URL.revokeObjectURL(img.previewUrl);
} catch {
// revoke is best-effort
}
}
}
imagesRef.current = toRestore;
return toRestore;
});
}, []);
}, [maxAttachments]);
// Final safety net: revoke any outstanding blob URLs on unmount. Safe
// under StrictMode double-invoke because revoked blob URLs are only
@@ -275,17 +474,19 @@ export function useAttachedImages(): UseAttachedImagesApi {
useEffect(() => {
return () => {
for (const img of imagesRef.current) {
try {
URL.revokeObjectURL(img.previewUrl);
} catch {
// best-effort cleanup on unmount
if (img.previewUrl) {
try {
URL.revokeObjectURL(img.previewUrl);
} catch {
// best-effort cleanup on unmount
}
}
}
};
}, []);
const encoding = images.some((img) => img.status === "encoding");
const full = images.length >= MAX_IMAGES_PER_MESSAGE;
const full = images.length >= maxAttachments;
return { images, enqueue, remove, clear, restoreReadyImages, encoding, full };
}
+12 -11
View File
@@ -1,12 +1,14 @@
import { useCallback, useRef, useState } from "react";
/** Extract image ``File``s from a paste / drop event.
import { acceptedAttachmentKind } from "@/hooks/useAttachedImages";
/** Extract supported attachment ``File``s from a paste / drop event.
*
* Deliberate behaviour:
* - Only items whose ``kind === "file"`` and ``type`` starts with
* ``image/`` are returned; ``<img>`` tags inside HTML fragments are
* ignored (defending against remote URL fetch + XSS surfaces).
* - Plain text pasted alongside images is *not* consumed by this helper,
* - Only items whose ``kind === "file"`` and match the Composer whitelist
* are returned; HTML fragments are ignored (defending against remote URL
* fetch + XSS surfaces).
* - Plain text pasted alongside attachments is *not* consumed by this helper,
* so the caller can still let the textarea receive it naturally.
*/
export function extractImageFilesFromPaste(
@@ -18,14 +20,13 @@ export function extractImageFilesFromPaste(
const files: File[] = [];
for (const item of Array.from(clipboard.items)) {
if (item.kind !== "file") continue;
if (!item.type.startsWith("image/")) continue;
const file = item.getAsFile();
if (file) files.push(file);
if (file && acceptedAttachmentKind(file)) files.push(file);
}
return files;
}
/** Extract dropped image files, mirroring ``extractImageFilesFromPaste``. */
/** Extract dropped attachment files, mirroring ``extractImageFilesFromPaste``. */
export function extractImageFilesFromDrop(
event: DragEvent | React.DragEvent,
): File[] {
@@ -34,7 +35,7 @@ export function extractImageFilesFromDrop(
if (!dt) return [];
const files: File[] = [];
for (const item of Array.from(dt.files)) {
if (item.type.startsWith("image/")) files.push(item);
if (acceptedAttachmentKind(item)) files.push(item);
}
return files;
}
@@ -67,8 +68,8 @@ export function useClipboardAndDrop(
(event: React.ClipboardEvent) => {
const files = extractImageFilesFromPaste(event);
if (files.length === 0) return;
// Consume only when an image is actually present; plain-text paste still
// reaches the textarea unmolested.
// Consume only when an attachment is actually present; plain-text paste
// still reaches the textarea unmolested.
event.preventDefault();
onImageFiles(files);
},
+16 -16
View File
@@ -17,7 +17,7 @@ import type {
OutboundMedia,
GoalStateWsPayload,
ToolProgressEvent,
UIImage,
UIMediaAttachment,
UIFileEdit,
UIMessage,
UITurnPhase,
@@ -464,15 +464,15 @@ function findFileEditTraceIndex(
* separately (e.g. via ``fetchWebuiThread``) since the server only replays
* live events.
*/
/** Payload passed to ``send`` when the user attaches one or more images.
/** Payload passed to ``send`` when the user attaches one or more files.
*
* ``media`` is handed to the wire client verbatim; ``preview`` powers the
* optimistic user bubble (blob URLs so the preview appears before the server
* acks the frame). Keeping the two separate lets the bubble re-use the local
* blob URL even after the server persists the file under a different name. */
export interface SendImage {
* optimistic user bubble. Keeping the two separate lets the bubble re-use the
* local data URL even after the server persists the file under a different
* name. */
export interface SendAttachment {
media: OutboundMedia;
preview: UIImage;
preview: UIMediaAttachment;
}
export interface SendOptions {
@@ -520,7 +520,7 @@ export function useNanobotStream(
runStartedAt: number | null;
/** Latest sustained goal for this ``chatId`` (``goal_state`` WS events). */
goalState: GoalStateWsPayload | undefined;
send: (content: string, images?: SendImage[], options?: SendOptions) => void;
send: (content: string, images?: SendAttachment[], options?: SendOptions) => void;
transcribeAudio: (dataUrl: string, options?: { durationMs?: number }) => Promise<string>;
stop: () => void;
setMessages: React.Dispatch<React.SetStateAction<UIMessage[]>>;
@@ -1135,12 +1135,12 @@ export function useNanobotStream(
]);
const send = useCallback(
(content: string, images?: SendImage[], options?: SendOptions) => {
(content: string, images?: SendAttachment[], options?: SendOptions) => {
if (!chatId) return;
const hasImages = !!images && images.length > 0;
// Text is optional when images are attached — the agent will still see
// the image blocks via ``media`` paths.
if (!hasImages && !content.trim()) return;
const hasAttachments = !!images && images.length > 0;
// Text is optional when files are attached — the agent will still see
// them via ``media`` paths.
if (!hasAttachments && !content.trim()) return;
const sideChannel = options?.sideChannel === true;
const finalizeActiveTurn = options?.finalizeActiveTurn === true;
@@ -1151,7 +1151,7 @@ export function useNanobotStream(
}
const turnId = crypto.randomUUID();
if (sideChannel) sideChannelTurnIdsRef.current.add(turnId);
const previews = hasImages ? images!.map((i) => i.preview) : undefined;
const previews = hasAttachments ? images!.map((i) => i.preview) : undefined;
setMessages((prev) => {
if (!sideChannel || finalizeActiveTurn) {
buffer.current = null;
@@ -1171,14 +1171,14 @@ export function useNanobotStream(
turnPhase: "user",
turnSeq: 0,
createdAt: Date.now(),
...(previews ? { images: previews } : {}),
...(previews ? { media: previews } : {}),
...(options?.cliApps?.length ? { cliApps: options.cliApps } : {}),
...(options?.mcpPresets?.length ? { mcpPresets: options.mcpPresets } : {}),
},
];
});
if (!sideChannel) setIsStreaming(true);
const wireMedia = hasImages ? images!.map((i) => i.media) : undefined;
const wireMedia = hasAttachments ? images!.map((i) => i.media) : undefined;
const wireOptions = { ...options, turnId };
delete wireOptions.sideChannel;
delete wireOptions.finalizeActiveTurn;
+7 -2
View File
@@ -909,7 +909,7 @@
"edit": "Edit guidance",
"drag": "Drag to reorder"
},
"attachImage": "Attach image",
"attachImage": "Attach files",
"imageMode": {
"label": "Image Generation",
"toggle": "Toggle image generation mode",
@@ -1034,12 +1034,17 @@
"encoding": "Encoding…",
"remove": "Remove attachment",
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
"textTooLarge": "Message text is too large (max {{max}})",
"imageRejected": {
"unsupported_type": "Unsupported file type",
"empty_file": "Empty files cannot be attached",
"too_many_attachments": "Max {{max}} attachments per message",
"too_many_images": "Max {{max}} images per message",
"magic_mismatch": "File doesn't look like a real image",
"decode_failed": "Couldn't decode this image",
"too_large": "Image is too large — try a smaller one",
"too_large": "File is too large — try a smaller one",
"total_too_large": "Attachments are too large together — remove some or use smaller files",
"transport_too_large": "This attachment would exceed the gateway transport limit",
"io": "Couldn't read this file"
},
"workspace": {
+7 -2
View File
@@ -896,7 +896,7 @@
"edit": "Editar guía",
"drag": "Arrastrar para reordenar"
},
"attachImage": "Adjuntar imagen",
"attachImage": "Adjuntar archivos",
"imageMode": {
"label": "Generar imagen",
"toggle": "Activar o desactivar modo de generación de imágenes",
@@ -1011,12 +1011,17 @@
"encoding": "Procesando…",
"remove": "Quitar adjunto",
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
"textTooLarge": "El texto del mensaje es demasiado grande (máximo {{max}})",
"imageRejected": {
"unsupported_type": "Tipo de archivo no compatible",
"empty_file": "No se pueden adjuntar archivos vacíos",
"too_many_attachments": "Máximo {{max}} adjuntos por mensaje",
"too_many_images": "Máximo {{max}} imágenes por mensaje",
"magic_mismatch": "El archivo no parece una imagen real",
"decode_failed": "No se pudo decodificar esta imagen",
"too_large": "Imagen demasiado grande — prueba una más pequeña",
"too_large": "Archivo demasiado grande — prueba uno más pequeño",
"total_too_large": "Los archivos adjuntos son demasiado grandes en conjunto; elimina algunos o usa archivos más pequeños",
"transport_too_large": "Este archivo adjunto superaría el límite de transporte de la puerta de enlace",
"io": "No se pudo leer este archivo"
},
"mentions": {
+7 -2
View File
@@ -895,7 +895,7 @@
"edit": "Modifier le guidage",
"drag": "Faire glisser pour réordonner"
},
"attachImage": "Joindre une image",
"attachImage": "Joindre des fichiers",
"imageMode": {
"label": "Génération dimage",
"toggle": "Activer ou désactiver le mode génération dimage",
@@ -1010,12 +1010,17 @@
"encoding": "Traitement…",
"remove": "Retirer la pièce jointe",
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
"textTooLarge": "Le texte du message est trop volumineux (maximum {{max}})",
"imageRejected": {
"unsupported_type": "Type de fichier non pris en charge",
"empty_file": "Impossible de joindre des fichiers vides",
"too_many_attachments": "Maximum {{max}} pièces jointes par message",
"too_many_images": "Maximum {{max}} images par message",
"magic_mismatch": "Ce fichier n'est pas une image",
"decode_failed": "Impossible de décoder cette image",
"too_large": "Image trop grande — essayez-en une plus petite",
"too_large": "Fichier trop volumineux — essayez-en un plus petit",
"total_too_large": "Les pièces jointes sont trop volumineuses ensemble — supprimez-en ou utilisez des fichiers plus petits",
"transport_too_large": "Cette pièce jointe dépasserait la limite de transport de la passerelle",
"io": "Impossible de lire ce fichier"
},
"mentions": {
+7 -2
View File
@@ -895,7 +895,7 @@
"edit": "Edit panduan",
"drag": "Seret untuk mengurutkan"
},
"attachImage": "Lampirkan gambar",
"attachImage": "Lampirkan file",
"imageMode": {
"label": "Buat gambar",
"toggle": "Alihkan mode pembuatan gambar",
@@ -1010,12 +1010,17 @@
"encoding": "Memproses…",
"remove": "Hapus lampiran",
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
"textTooLarge": "Teks pesan terlalu besar (maksimum {{max}})",
"imageRejected": {
"unsupported_type": "Tipe file tidak didukung",
"empty_file": "File kosong tidak dapat dilampirkan",
"too_many_attachments": "Maksimal {{max}} lampiran per pesan",
"too_many_images": "Maksimal {{max}} gambar per pesan",
"magic_mismatch": "File ini tampaknya bukan gambar asli",
"decode_failed": "Tidak dapat mendekode gambar ini",
"too_large": "Gambar terlalu besar — coba yang lebih kecil",
"too_large": "File terlalu besar — coba yang lebih kecil",
"total_too_large": "Total lampiran terlalu besar — hapus beberapa atau gunakan file yang lebih kecil",
"transport_too_large": "Lampiran ini akan melebihi batas transport gateway",
"io": "Tidak dapat membaca file ini"
},
"mentions": {
+7 -2
View File
@@ -895,7 +895,7 @@
"edit": "ガイドを編集",
"drag": "ドラッグして並べ替え"
},
"attachImage": "画像を添付",
"attachImage": "ファイルを添付",
"imageMode": {
"label": "画像生成",
"toggle": "画像生成モードを切り替え",
@@ -1010,12 +1010,17 @@
"encoding": "処理中…",
"remove": "添付を削除",
"normalizedSizeHint": "{{orig}} → {{current}}(自動圧縮)",
"textTooLarge": "メッセージ本文が大きすぎます(最大 {{max}})",
"imageRejected": {
"unsupported_type": "対応していないファイル形式です",
"empty_file": "空のファイルは添付できません",
"too_many_attachments": "1 メッセージにつき最大 {{max}} 件までです",
"too_many_images": "1 メッセージにつき最大 {{max}} 枚です",
"magic_mismatch": "画像ファイルではないようです",
"decode_failed": "この画像をデコードできません",
"too_large": "画像が大きすぎます。小さいものを選んでください",
"too_large": "ファイルが大きすぎます。小さいものを選んでください",
"total_too_large": "添付ファイルの合計サイズが大きすぎます。いくつか削除するか、より小さいファイルを使用してください",
"transport_too_large": "この添付ファイルはゲートウェイの転送上限を超えます",
"io": "このファイルを読み込めません"
},
"mentions": {
+7 -2
View File
@@ -895,7 +895,7 @@
"edit": "안내 수정",
"drag": "드래그하여 순서 변경"
},
"attachImage": "이미지 첨부",
"attachImage": "파일 첨부",
"imageMode": {
"label": "이미지 생성",
"toggle": "이미지 생성 모드 전환",
@@ -1010,12 +1010,17 @@
"encoding": "처리 중…",
"remove": "첨부 제거",
"normalizedSizeHint": "{{orig}} → {{current}} (자동 압축)",
"textTooLarge": "메시지 텍스트가 너무 큽니다(최대 {{max}})",
"imageRejected": {
"unsupported_type": "지원하지 않는 파일 형식입니다",
"empty_file": "빈 파일은 첨부할 수 없습니다",
"too_many_attachments": "메시지당 최대 {{max}}개까지 가능합니다",
"too_many_images": "메시지당 최대 {{max}}장까지 가능합니다",
"magic_mismatch": "이미지 파일이 아닌 것 같습니다",
"decode_failed": "이 이미지를 디코딩할 수 없습니다",
"too_large": "이미지가 너무 큽니다. 더 작은 걸로 시도해 주세요",
"too_large": "파일이 너무 큽니다. 더 작은 파일을 선택해 주세요",
"total_too_large": "첨부 파일의 전체 크기가 너무 큽니다. 일부를 제거하거나 더 작은 파일을 사용해 주세요",
"transport_too_large": "이 첨부 파일은 게이트웨이 전송 한도를 초과합니다",
"io": "이 파일을 읽을 수 없습니다"
},
"mentions": {
+5
View File
@@ -1034,12 +1034,17 @@
"encoding": "Codificando…",
"remove": "Remover anexo",
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
"textTooLarge": "O texto da mensagem é grande demais (máximo de {{max}})",
"imageRejected": {
"unsupported_type": "Tipo de arquivo não compatível",
"empty_file": "Arquivos vazios não podem ser anexados",
"too_many_attachments": "Máx. de {{max}} anexos por mensagem",
"too_many_images": "Máx. de {{max}} imagens por mensagem",
"magic_mismatch": "O arquivo não parece uma imagem real",
"decode_failed": "Não foi possível decodificar esta imagem",
"too_large": "A imagem é grande demais — tente uma menor",
"total_too_large": "Os anexos são grandes demais em conjunto — remova alguns ou use arquivos menores",
"transport_too_large": "Este anexo excederia o limite de transporte do gateway",
"io": "Não foi possível ler este arquivo"
},
"workspace": {
+7 -2
View File
@@ -895,7 +895,7 @@
"edit": "Sửa hướng dẫn",
"drag": "Kéo để sắp xếp"
},
"attachImage": "Đính kèm ảnh",
"attachImage": "Đính kèm tệp",
"imageMode": {
"label": "Tạo ảnh",
"toggle": "Bật/tắt chế độ tạo ảnh",
@@ -1010,12 +1010,17 @@
"encoding": "Đang xử lý…",
"remove": "Xóa tệp đính kèm",
"normalizedSizeHint": "{{orig}} → {{current}} (tự động)",
"textTooLarge": "Nội dung tin nhắn quá lớn (tối đa {{max}})",
"imageRejected": {
"unsupported_type": "Loại tệp không được hỗ trợ",
"empty_file": "Không thể đính kèm tệp trống",
"too_many_attachments": "Tối đa {{max}} tệp đính kèm mỗi tin nhắn",
"too_many_images": "Tối đa {{max}} ảnh mỗi tin nhắn",
"magic_mismatch": "Tệp này không phải là một ảnh thực",
"decode_failed": "Không thể giải mã ảnh này",
"too_large": "Ảnh quá lớn — hãy thử ảnh nhỏ hơn",
"too_large": "Tệp quá lớn — hãy thử tệp nhỏ hơn",
"total_too_large": "Tổng dung lượng tệp đính kèm quá lớn — hãy xóa bớt hoặc dùng tệp nhỏ hơn",
"transport_too_large": "Tệp đính kèm này sẽ vượt quá giới hạn truyền tải của gateway",
"io": "Không thể đọc tệp này"
},
"mentions": {
+7 -2
View File
@@ -908,7 +908,7 @@
"edit": "编辑引导",
"drag": "拖动排序"
},
"attachImage": "添加图片",
"attachImage": "添加文件",
"imageMode": {
"label": "图片生成",
"toggle": "切换图片生成模式",
@@ -1033,12 +1033,17 @@
"encoding": "处理中…",
"remove": "移除附件",
"normalizedSizeHint": "{{orig}} → {{current}}(已自动压缩)",
"textTooLarge": "消息文本过大(最大 {{max}}",
"imageRejected": {
"unsupported_type": "不支持的文件类型",
"empty_file": "不能附加空文件",
"too_many_attachments": "每条消息最多 {{max}} 个附件",
"too_many_images": "每条消息最多 {{max}} 张图片",
"magic_mismatch": "文件看起来不像真实的图片",
"decode_failed": "无法解码这张图片",
"too_large": "图片太大,请换一小一点的",
"too_large": "文件太大,请换一小一点的",
"total_too_large": "附件总大小过大,请移除部分文件或使用更小的文件",
"transport_too_large": "该附件会超过网关的传输上限",
"io": "无法读取该文件"
},
"goalStateCloseAria": "关闭目标",
+7 -2
View File
@@ -895,7 +895,7 @@
"edit": "編輯引導",
"drag": "拖曳排序"
},
"attachImage": "附加圖片",
"attachImage": "附加檔案",
"imageMode": {
"label": "圖片生成",
"toggle": "切換圖片生成模式",
@@ -1010,12 +1010,17 @@
"encoding": "處理中…",
"remove": "移除附件",
"normalizedSizeHint": "{{orig}} → {{current}}(已自動壓縮)",
"textTooLarge": "訊息文字過大(最大 {{max}}",
"imageRejected": {
"unsupported_type": "不支援的檔案類型",
"empty_file": "無法附加空白檔案",
"too_many_attachments": "每則訊息最多 {{max}} 個附件",
"too_many_images": "每則訊息最多 {{max}} 張圖片",
"magic_mismatch": "檔案看起來不像真正的圖片",
"decode_failed": "無法解碼這張圖片",
"too_large": "圖片太大,請換一小一點的",
"too_large": "檔案太大,請換一小一點的",
"total_too_large": "附件總大小過大,請移除部分檔案或使用更小的檔案",
"transport_too_large": "此附件會超過閘道的傳輸上限",
"io": "無法讀取這個檔案"
},
"mentions": {
+2 -2
View File
@@ -81,8 +81,8 @@ type RunStatusHandler = (chatId: string, startedAt: number | null) => void;
*/
export type StreamError =
/** Server rejected the inbound frame as too large (WS close code 1009).
* Typically means the user attached images whose base64 size exceeded
* ``maxMessageBytes`` on the server. */
* This is the transport fallback after text and attachment policies have
* already been checked independently. */
| { kind: "message_too_big" }
| { kind: "workspace_scope_rejected"; reason?: string; chatId?: string };
+26 -5
View File
@@ -308,11 +308,33 @@ export interface BootstrapResponse {
ws_path: string;
ws_url?: string | null;
expires_in: number;
limits?: WebUIIngressLimits;
model_name?: string | null;
runtime_surface?: RuntimeSurface;
runtime_capabilities?: RuntimeCapabilities;
}
export interface WebUITransportLimits {
max_frame_bytes: number;
envelope_reserve_bytes: number;
}
export interface WebUIMessageLimits {
max_text_bytes: number;
}
export interface WebUIAttachmentLimits {
max_count: number;
max_file_bytes: number;
max_total_bytes: number;
}
export interface WebUIIngressLimits {
transport: WebUITransportLimits;
message: WebUIMessageLimits;
attachments: WebUIAttachmentLimits;
}
export type RuntimeSurface = "browser" | "native";
export type RestartBehavior = "none" | "nextTurn" | "engineRestart" | "appRestart";
export type SettingsApplyStatus =
@@ -1050,12 +1072,11 @@ export type InboundEvent =
}
| { event: "error"; chat_id?: string; detail?: string; reason?: string };
/** Base64-encoded image attached to an outbound ``message`` envelope.
/** Base64-encoded file attached to an outbound ``message`` envelope.
*
* ``data_url`` must be a ``data:image/<png|jpeg|webp|gif>;base64,...`` string
* — the server whitelists those MIME types and rejects everything else
* (including SVG, to avoid an XSS surface). ``name`` is advisory: it's
* preserved for the file on disk and surfaced as the placeholder label when
* ``data_url`` must use a server-whitelisted image, video, or document MIME
* type. SVG remains rejected on ingress to avoid an embedded-script XSS
* surface. ``name`` is advisory and is surfaced as the placeholder label when
* the session is replayed.
*/
export interface OutboundMedia {
+5 -1
View File
@@ -1,11 +1,13 @@
import { createContext, useContext, type ReactNode } from "react";
import type { NanobotClient } from "@/lib/nanobot-client";
import type { WebUIIngressLimits } from "@/lib/types";
interface ClientContextValue {
client: NanobotClient;
token: string;
modelName: string | null;
ingressLimits: WebUIIngressLimits | null;
}
const ClientContext = createContext<ClientContextValue | null>(null);
@@ -14,15 +16,17 @@ export function ClientProvider({
client,
token,
modelName = null,
ingressLimits = null,
children,
}: {
client: NanobotClient;
token: string;
modelName?: string | null;
ingressLimits?: WebUIIngressLimits | null;
children: ReactNode;
}) {
return (
<ClientContext.Provider value={{ client, token, modelName }}>
<ClientContext.Provider value={{ client, token, modelName, ingressLimits }}>
{children}
</ClientContext.Provider>
);
+258 -2
View File
@@ -9,6 +9,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
import type { EncodeResponse } from "@/lib/imageEncode";
import type { WebUIIngressLimits } from "@/lib/types";
const encodeImage = vi.fn<(file: File) => Promise<EncodeResponse>>();
@@ -24,6 +25,14 @@ function pngFile(name = "a.png", size = 10) {
return new File([new Uint8Array(size)], name, { type: "image/png" });
}
function pdfFile(name = "report.pdf", size = 8) {
return new File([new Uint8Array(size)], name, { type: "application/pdf" });
}
function csvFile(name = "report.csv", type = "application/vnd.ms-excel") {
return new File(["name,value\nnanobot,1"], name, { type });
}
function resolveReady(file: File): EncodeResponse {
return {
id: "stub",
@@ -36,6 +45,31 @@ function resolveReady(file: File): EncodeResponse {
};
}
function ingressLimits({
maxFrameBytes = 36 * 1024 * 1024,
maxTextBytes = 64 * 1024,
maxFileBytes = 6 * 1024 * 1024,
maxTotalBytes = 24 * 1024 * 1024,
}: {
maxFrameBytes?: number;
maxTextBytes?: number;
maxFileBytes?: number;
maxTotalBytes?: number;
} = {}): WebUIIngressLimits {
return {
transport: {
max_frame_bytes: maxFrameBytes,
envelope_reserve_bytes: 64 * 1024,
},
message: { max_text_bytes: maxTextBytes },
attachments: {
max_count: 4,
max_file_bytes: maxFileBytes,
max_total_bytes: maxTotalBytes,
},
};
}
beforeEach(() => {
encodeImage.mockReset();
let id = 0;
@@ -50,7 +84,7 @@ beforeEach(() => {
}
});
describe("ThreadComposer — image attachments", () => {
describe("ThreadComposer — attachments", () => {
it("attaches a picked image and includes its data url on send", async () => {
const file = pngFile("a.png");
encodeImage.mockResolvedValueOnce(resolveReady(file));
@@ -83,6 +117,228 @@ describe("ThreadComposer — image attachments", () => {
expect(images[0].media.name).toBe("a.png");
});
it("attaches a picked PDF and includes its data url on send", async () => {
const file = pdfFile();
const onSend = vi.fn();
render(<ThreadComposer onSend={onSend} />);
const input = screen
.getByLabelText(/message input/i)
.closest("form")!
.querySelector('input[type="file"]') as HTMLInputElement;
await act(async () => {
fireEvent.change(input, { target: { files: [file] } });
});
await waitFor(() =>
expect(screen.getByTestId("composer-chip")).toHaveTextContent("report.pdf"),
);
const textarea = screen.getByLabelText(/message input/i);
fireEvent.change(textarea, { target: { value: "summarize" } });
fireEvent.keyDown(textarea, { key: "Enter" });
expect(encodeImage).not.toHaveBeenCalled();
const [content, attachments] = onSend.mock.calls[0];
expect(content).toBe("summarize");
expect(attachments).toHaveLength(1);
expect(attachments[0].media.data_url).toContain("data:application/pdf;base64,");
expect(attachments[0].media.name).toBe("report.pdf");
expect(attachments[0].preview.kind).toBe("file");
});
it.each(["application/vnd.ms-excel", "image/png"])(
"normalizes document MIME from the file extension when the browser reports %s",
async (browserMime) => {
const file = csvFile("report.csv", browserMime);
const onSend = vi.fn();
render(<ThreadComposer onSend={onSend} />);
const input = screen
.getByLabelText(/message input/i)
.closest("form")!
.querySelector('input[type="file"]') as HTMLInputElement;
await act(async () => {
fireEvent.change(input, { target: { files: [file] } });
});
await waitFor(() =>
expect(screen.getByTestId("composer-chip")).toHaveTextContent("report.csv"),
);
const textarea = screen.getByLabelText(/message input/i);
fireEvent.change(textarea, { target: { value: "summarize" } });
fireEvent.keyDown(textarea, { key: "Enter" });
const [, attachments] = onSend.mock.calls[0];
expect(attachments[0].media.data_url).toMatch(/^data:text\/csv;base64,/);
expect(encodeImage).not.toHaveBeenCalled();
},
);
it("rejects empty attachments before sending them to the gateway", async () => {
const file = new File([], "empty.csv", { type: "application/vnd.ms-excel" });
const onSend = vi.fn();
render(<ThreadComposer onSend={onSend} />);
const input = screen
.getByLabelText(/message input/i)
.closest("form")!
.querySelector('input[type="file"]') as HTMLInputElement;
await act(async () => {
fireEvent.change(input, { target: { files: [file] } });
});
expect(screen.getByText("Empty files cannot be attached")).toBeInTheDocument();
expect(screen.queryByTestId("composer-chip")).not.toBeInTheDocument();
expect(encodeImage).not.toHaveBeenCalled();
expect(onSend).not.toHaveBeenCalled();
});
it("rejects an oversized document before adding a chip", async () => {
const file = pdfFile("oversized.pdf", 6 * 1024 * 1024 + 1);
render(<ThreadComposer onSend={vi.fn()} />);
const input = screen
.getByLabelText(/message input/i)
.closest("form")!
.querySelector('input[type="file"]') as HTMLInputElement;
await act(async () => {
fireEvent.change(input, { target: { files: [file] } });
});
expect(screen.getByRole("alert")).toHaveTextContent("File is too large");
expect(screen.queryByTestId("composer-chip")).not.toBeInTheDocument();
});
it("reports a transport limit separately from attachment policy", async () => {
const first = pdfFile("first.pdf", 400 * 1024);
const second = pdfFile("second.pdf", 400 * 1024);
render(
<ThreadComposer
onSend={vi.fn()}
ingressLimits={ingressLimits({ maxFrameBytes: 1024 * 1024 })}
/>,
);
const input = screen
.getByLabelText(/message input/i)
.closest("form")!
.querySelector('input[type="file"]') as HTMLInputElement;
await act(async () => {
fireEvent.change(input, { target: { files: [first, second] } });
});
expect(screen.getByRole("alert")).toHaveTextContent(
"gateway transport limit",
);
expect(screen.getAllByTestId("composer-chip")).toHaveLength(1);
expect(screen.getByText("first.pdf")).toBeInTheDocument();
expect(screen.queryByText("second.pdf")).not.toBeInTheDocument();
});
it("enforces the decoded attachment-total policy independently", async () => {
const first = pdfFile("first.pdf", 400 * 1024);
const second = pdfFile("second.pdf", 400 * 1024);
render(
<ThreadComposer
onSend={vi.fn()}
ingressLimits={ingressLimits({ maxTotalBytes: 700 * 1024 })}
/>,
);
const input = screen
.getByLabelText(/message input/i)
.closest("form")!
.querySelector('input[type="file"]') as HTMLInputElement;
await act(async () => {
fireEvent.change(input, { target: { files: [first, second] } });
});
expect(screen.getByRole("alert")).toHaveTextContent(
"Attachments are too large together",
);
expect(screen.getAllByTestId("composer-chip")).toHaveLength(1);
});
it("enforces the text-byte policy without changing attachment limits", () => {
const onSend = vi.fn();
render(
<ThreadComposer
onSend={onSend}
ingressLimits={ingressLimits({ maxTextBytes: 4 })}
/>,
);
const textarea = screen.getByLabelText(/message input/i);
fireEvent.change(textarea, { target: { value: "你好" } });
fireEvent.keyDown(textarea, { key: "Enter" });
expect(screen.getByRole("alert")).toHaveTextContent(
"Message text is too large (max 4 B)",
);
expect(onSend).not.toHaveBeenCalled();
});
it("accepts supported documents from paste and drop", async () => {
const pasted = pdfFile("pasted.pdf");
const dropped = pdfFile("dropped.pdf");
const onSend = vi.fn();
render(<ThreadComposer onSend={onSend} />);
const textarea = screen.getByLabelText(/message input/i);
const form = textarea.closest("form")!;
await act(async () => {
fireEvent.paste(textarea, {
clipboardData: {
files: [pasted],
items: [{
kind: "file",
type: pasted.type,
getAsFile: () => pasted,
}],
types: ["Files"],
getData: () => "",
},
});
});
await waitFor(() =>
expect(screen.getByText("pasted.pdf")).toBeInTheDocument(),
);
await act(async () => {
fireEvent.drop(form, {
dataTransfer: {
files: [dropped],
items: [],
types: ["Files"],
dropEffect: "copy",
},
});
});
await waitFor(() =>
expect(screen.getByText("dropped.pdf")).toBeInTheDocument(),
);
expect(screen.getAllByTestId("composer-chip")).toHaveLength(2);
expect(encodeImage).not.toHaveBeenCalled();
});
it("blocks send while an image is still encoding", async () => {
const file = pngFile("slow.png");
let resolveEncode: (r: EncodeResponse) => void = () => {};
@@ -117,7 +373,7 @@ describe("ThreadComposer — image attachments", () => {
expect(onSend).toHaveBeenCalledTimes(1);
});
it("rejects a non-image paste silently without adding a chip", async () => {
it("keeps a plain-text paste untouched without adding a chip", async () => {
const onSend = vi.fn();
render(<ThreadComposer onSend={onSend} />);
const textarea = screen.getByLabelText(/message input/i);
+1 -1
View File
@@ -336,7 +336,7 @@ describe("ThreadComposer", () => {
expect(input.parentElement?.parentElement?.className).toContain("max-w-[49.5rem]");
expect(input.parentElement?.parentElement?.className).toContain("rounded-[22px]");
expect(input.parentElement?.parentElement?.className).toContain("shadow-[0_12px_30px_rgba(15,23,42,0.07)]");
expect(screen.getByRole("button", { name: "Attach image" }).className).toContain("bg-card");
expect(screen.getByRole("button", { name: "Attach files" }).className).toContain("bg-card");
expect(screen.getByRole("button", { name: "Send message" }).className).toContain("bg-foreground");
expect(screen.queryByText(/Enter to send/)).not.toBeInTheDocument();
});
+31
View File
@@ -1520,6 +1520,37 @@ describe("useNanobotStream", () => {
expect(result.current.messages[0].turnPhase).toBe("user");
});
it("adds optimistic user file attachments as media", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-file-send", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
const attachment = {
media: {
data_url: "data:application/pdf;base64,JVBERi0xLjQ=",
name: "report.pdf",
},
preview: {
kind: "file" as const,
url: "data:application/pdf;base64,JVBERi0xLjQ=",
name: "report.pdf",
},
};
act(() => {
result.current.send("summarize", [attachment]);
});
expect(result.current.messages[0].media).toEqual([attachment.preview]);
expect(result.current.messages[0].images).toBeUndefined();
expect(fake.client.sendMessage).toHaveBeenCalledWith(
"chat-file-send",
"summarize",
[attachment.media],
expect.objectContaining({ turnId: expect.any(String) }),
);
});
it("attaches assistant media_urls to complete messages", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-m", EMPTY_MESSAGES), {