feat(webui): support image uploads in composer and message bubbles

This commit is contained in:
Xubin Ren
2026-04-23 00:07:27 +08:00
committed by Xubin Ren
parent c1e7aa5504
commit 61a28c2c0a
39 changed files with 3670 additions and 124 deletions
+13
View File
@@ -57,6 +57,16 @@ export async function listSessions(
}));
}
/** Signed image URL attached to a historical user message. The server
* emits these in place of raw on-disk paths so the client can render
* previews without learning where media lives on disk. Each URL is a
* self-authenticating ``/api/media/...`` route (see backend
* ``_sign_media_path``) safe to drop into an ``<img src>`` attribute. */
export interface SessionMediaUrl {
url: string;
name?: string;
}
export async function fetchSessionMessages(
token: string,
key: string,
@@ -72,6 +82,9 @@ export async function fetchSessionMessages(
tool_calls?: unknown;
tool_call_id?: string;
name?: string;
/** Present on ``user`` turns that attached images. Paths have already
* been stripped server-side; only the signed fetch URLs survive. */
media_urls?: SessionMediaUrl[];
}>;
}> {
return request(
+97
View File
@@ -0,0 +1,97 @@
/**
* Main-thread client for the image encoder Worker.
*
* Lazily boots a single ``imageEncode.worker`` and multiplexes requests onto
* it by a random request id. Falls back to an inline call when the Worker
* can't be constructed (tests, ancient browsers) so the Composer always has a
* working path.
*/
import {
encodeImageInWorker,
type EncodeResponse,
} from "@/workers/imageEncode.worker";
export type { EncodeResponse, EncodeSuccess, EncodeFailure } from "@/workers/imageEncode.worker";
export { TARGET_MAX_BYTES } from "@/workers/imageEncode.worker";
type Pending = {
resolve: (r: EncodeResponse) => void;
reject: (err: Error) => void;
};
let worker: Worker | null = null;
let bootAttempted = false;
const pending = new Map<string, Pending>();
function bootWorker(): Worker | null {
if (bootAttempted) return worker;
bootAttempted = true;
if (typeof Worker === "undefined") return null;
try {
worker = new Worker(
new URL("@/workers/imageEncode.worker.ts", import.meta.url),
{ type: "module" },
);
worker.addEventListener("message", (ev: MessageEvent<EncodeResponse>) => {
const entry = pending.get(ev.data.id);
if (!entry) return;
pending.delete(ev.data.id);
entry.resolve(ev.data);
});
worker.addEventListener("error", (ev) => {
// Cancel every in-flight request on a Worker crash.
for (const [, entry] of pending) {
entry.reject(new Error(`image encoder worker error: ${ev.message}`));
}
pending.clear();
worker?.terminate();
worker = null;
});
return worker;
} catch {
worker = null;
return null;
}
}
function newId(): string {
// ``crypto.randomUUID`` is widely available; fall back to Math.random if not.
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return (crypto as Crypto).randomUUID();
}
return `img-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
/** Encode ``file`` off the main thread when possible. Always resolves — errors
* are returned as ``{ok: false, reason}`` — so callers can render inline
* validation without wrapping in try/catch. */
export async function encodeImage(file: File): Promise<EncodeResponse> {
const id = newId();
const w = bootWorker();
if (!w) {
// Inline fallback: same logic, just on the main thread.
return encodeImageInWorker({ id, file });
}
return new Promise<EncodeResponse>((resolve, reject) => {
pending.set(id, { resolve, reject });
try {
w.postMessage({ id, file });
} catch (err) {
pending.delete(id);
reject(err instanceof Error ? err : new Error(String(err)));
}
});
}
/** Release the singleton Worker (tests / teardown). */
export function disposeImageEncoder(): void {
if (worker) {
worker.terminate();
worker = null;
}
bootAttempted = false;
for (const [, entry] of pending) {
entry.reject(new Error("image encoder disposed"));
}
pending.clear();
}
+60 -5
View File
@@ -1,4 +1,9 @@
import type { ConnectionStatus, InboundEvent, Outbound } from "./types";
import type {
ConnectionStatus,
InboundEvent,
Outbound,
OutboundMedia,
} from "./types";
/** WebSocket readyState constants, referenced by value to stay portable
* across runtimes that don't expose a global ``WebSocket`` (tests, SSR). */
@@ -9,6 +14,22 @@ type Unsubscribe = () => void;
type EventHandler = (ev: InboundEvent) => void;
type StatusHandler = (status: ConnectionStatus) => void;
/** Structured connection-level errors surfaced to the UI.
*
* These are *not* InboundEvent errors from the server application layer —
* those arrive as ``{event: "error"}`` messages via ``onChat``. These are
* transport-level or protocol-level faults the UI should make visible so
* the user understands *why* their action failed (as opposed to silently
* reconnecting under the hood).
*/
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. */
| { kind: "message_too_big" };
type ErrorHandler = (error: StreamError) => void;
interface PendingNewChat {
resolve: (chatId: string) => void;
reject: (err: Error) => void;
@@ -36,6 +57,7 @@ export interface NanobotClientOptions {
export class NanobotClient {
private socket: WebSocket | null = null;
private statusHandlers = new Set<StatusHandler>();
private errorHandlers = new Set<ErrorHandler>();
// chat_id -> handlers listening on it
private chatHandlers = new Map<string, Set<EventHandler>>();
// chat_ids we've attached to since connect; re-attached after reconnects
@@ -84,6 +106,14 @@ export class NanobotClient {
};
}
/** Subscribe to transport-level faults (see :type:`StreamError`). */
onError(handler: ErrorHandler): Unsubscribe {
this.errorHandlers.add(handler);
return () => {
this.errorHandlers.delete(handler);
};
}
/** Subscribe to events for a given chat_id. Auto-attaches on the next open. */
onChat(chatId: string, handler: EventHandler): Unsubscribe {
let handlers = this.chatHandlers.get(chatId);
@@ -110,7 +140,7 @@ export class NanobotClient {
sock.onopen = () => this.handleOpen();
sock.onmessage = (ev) => this.handleMessage(ev);
sock.onerror = () => this.setStatus("error");
sock.onclose = () => this.handleClose();
sock.onclose = (ev) => this.handleClose(ev);
}
close(): void {
@@ -151,9 +181,13 @@ export class NanobotClient {
}
}
sendMessage(chatId: string, content: string): void {
sendMessage(chatId: string, content: string, media?: OutboundMedia[]): void {
this.knownChats.add(chatId);
this.queueSend({ type: "message", chat_id: chatId, content });
const frame: Outbound =
media && media.length > 0
? { type: "message", chat_id: chatId, content, media }
: { type: "message", chat_id: chatId, content };
this.queueSend(frame);
}
// -- internals ---------------------------------------------------------
@@ -211,13 +245,20 @@ export class NanobotClient {
for (const h of handlers) h(ev);
}
private handleClose(): void {
private handleClose(event?: { code?: number }): void {
this.socket = null;
if (this.pendingNewChat) {
clearTimeout(this.pendingNewChat.timer);
this.pendingNewChat.reject(new Error("socket closed"));
this.pendingNewChat = null;
}
// Surface structured reasons *before* reconnect logic so the UI can
// display the error even while the client transparently reconnects.
// Browsers populate ``CloseEvent.code`` with the wire-level close code;
// 1009 = Message Too Big (server's max frame guard).
if (event?.code === 1009) {
this.emitError({ kind: "message_too_big" });
}
if (this.intentionallyClosed || !this.shouldReconnect) {
this.setStatus("closed");
return;
@@ -225,6 +266,20 @@ export class NanobotClient {
this.scheduleReconnect();
}
private emitError(error: StreamError): void {
// Isolate subscribers so a throwing handler cannot abort the surrounding
// ``handleClose`` flow (which still owes us a reconnect decision + status
// update). We deliberately swallow here: error reporting is best-effort
// and must never be allowed to compound the failure it's reporting.
for (const handler of this.errorHandlers) {
try {
handler(error);
} catch {
// best-effort: subscriber fault must not stall transport bookkeeping
}
}
}
private scheduleReconnect(): void {
this.setStatus("reconnecting");
const attempt = this.reconnectAttempts++;
+39 -1
View File
@@ -4,6 +4,24 @@ export type Role = "user" | "assistant" | "tool" | "system";
* progress pings) that should not be rendered as conversational replies. */
export type MessageKind = "message" | "trace";
/** One image attached to a UIMessage.
*
* ``url`` can arrive in three different shapes, which the bubble renders
* identically:
* - A ``data:image/...;base64,...`` URL generated by the Composer for the
* optimistic preview of an in-flight user turn. Self-contained, no
* lifecycle.
* - A signed ``/api/media/...`` URL attached to a historical user turn by
* the backend on session replay. Safe to drop into an ``<img src>``.
* - Absent. The backend couldn't resolve a stored path (file moved,
* deleted, or pre-media-persistence session). The bubble shows a
* placeholder tile with ``name`` as the label.
*/
export interface UIImage {
url?: string;
name?: string;
}
export interface UIMessage {
id: string;
role: Role;
@@ -14,6 +32,8 @@ export interface UIMessage {
/** For trace rows: each individual hint line, so consecutive hints can
* render as a single collapsible group. */
traces?: string[];
/** User turn: optimistic blob URLs for preview. Replay: placeholder chips. */
images?: UIImage[];
}
export interface ChatSummary {
@@ -68,7 +88,25 @@ export type InboundEvent =
}
| { event: "error"; chat_id?: string; detail?: string };
/** Base64-encoded image 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
* the session is replayed.
*/
export interface OutboundMedia {
data_url: string;
name?: string;
}
export type Outbound =
| { type: "new_chat" }
| { type: "attach"; chat_id: string }
| { type: "message"; chat_id: string; content: string };
| {
type: "message";
chat_id: string;
content: string;
media?: OutboundMedia[];
};