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:
@@ -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}
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 d’image",
|
||||
"toggle": "Activer ou désactiver le mode génération d’image",
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": "关闭目标",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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), {
|
||||
|
||||
Reference in New Issue
Block a user