feat(webui): support image uploads in composer and message bubbles
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user