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
+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;