feat(webui): add initial webui with websocket chat flow
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import type { ChatSummary } from "./types";
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
url: string,
|
||||
token: string,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
...(init ?? {}),
|
||||
headers: {
|
||||
...(init?.headers ?? {}),
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new ApiError(res.status, `HTTP ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
function splitKey(key: string): { channel: string; chatId: string } {
|
||||
const idx = key.indexOf(":");
|
||||
if (idx === -1) return { channel: "", chatId: key };
|
||||
return { channel: key.slice(0, idx), chatId: key.slice(idx + 1) };
|
||||
}
|
||||
|
||||
export async function listSessions(
|
||||
token: string,
|
||||
base: string = "",
|
||||
): Promise<ChatSummary[]> {
|
||||
type Row = {
|
||||
key: string;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
preview?: string;
|
||||
};
|
||||
const body = await request<{ sessions: Row[] }>(
|
||||
`${base}/api/sessions`,
|
||||
token,
|
||||
);
|
||||
return body.sessions.map((s) => ({
|
||||
key: s.key,
|
||||
...splitKey(s.key),
|
||||
createdAt: s.created_at,
|
||||
updatedAt: s.updated_at,
|
||||
preview: s.preview ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchSessionMessages(
|
||||
token: string,
|
||||
key: string,
|
||||
base: string = "",
|
||||
): Promise<{
|
||||
key: string;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
messages: Array<{
|
||||
role: string;
|
||||
content: string;
|
||||
timestamp?: string;
|
||||
tool_calls?: unknown;
|
||||
tool_call_id?: string;
|
||||
name?: string;
|
||||
}>;
|
||||
}> {
|
||||
return request(
|
||||
`${base}/api/sessions/${encodeURIComponent(key)}/messages`,
|
||||
token,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteSession(
|
||||
token: string,
|
||||
key: string,
|
||||
base: string = "",
|
||||
): Promise<boolean> {
|
||||
const body = await request<{ deleted: boolean }>(
|
||||
`${base}/api/sessions/${encodeURIComponent(key)}/delete`,
|
||||
token,
|
||||
);
|
||||
return body.deleted;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { BootstrapResponse } from "./types";
|
||||
|
||||
/**
|
||||
* Fetch a short-lived token + the WebSocket path from the gateway's
|
||||
* ``/webui/bootstrap`` endpoint. Localhost-only on the server side.
|
||||
*/
|
||||
export async function fetchBootstrap(
|
||||
baseUrl: string = "",
|
||||
): Promise<BootstrapResponse> {
|
||||
const res = await fetch(`${baseUrl}/webui/bootstrap`, {
|
||||
method: "GET",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`bootstrap failed: HTTP ${res.status}`);
|
||||
}
|
||||
const body = (await res.json()) as BootstrapResponse;
|
||||
if (!body.token || !body.ws_path) {
|
||||
throw new Error("bootstrap response missing token or ws_path");
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/** Derive a WebSocket URL from the current window location and the server-provided path.
|
||||
*
|
||||
* Keeps the path segment exactly as the server registered it: the root ``/``
|
||||
* stays ``/`` and non-root paths are not given an extra trailing slash. This
|
||||
* matters because some WS servers dispatch handshakes based on the literal
|
||||
* path, not a normalised form.
|
||||
*/
|
||||
export function deriveWsUrl(wsPath: string, token: string): string {
|
||||
const path = wsPath && wsPath.startsWith("/") ? wsPath : `/${wsPath || ""}`;
|
||||
const query = `?token=${encodeURIComponent(token)}`;
|
||||
if (typeof window === "undefined") {
|
||||
return `ws://127.0.0.1:8765${path}${query}`;
|
||||
}
|
||||
const scheme = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const host = window.location.host;
|
||||
return `${scheme}://${host}${path}${query}`;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/** Truncate the first user message into a chat title. */
|
||||
export function deriveTitle(preview: string | undefined, fallback: string): string {
|
||||
if (!preview) return fallback;
|
||||
const oneLine = preview.replace(/\s+/g, " ").trim();
|
||||
if (!oneLine) return fallback;
|
||||
return oneLine.length > 60 ? `${oneLine.slice(0, 57)}…` : oneLine;
|
||||
}
|
||||
|
||||
/** Loose ISO-or-epoch parser; returns ``null`` for missing/invalid input. */
|
||||
function parseDate(value: string | number | null | undefined): Date | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
const RELATIVE_THRESHOLDS: [number, Intl.RelativeTimeFormatUnit][] = [
|
||||
[60, "second"],
|
||||
[60, "minute"],
|
||||
[24, "hour"],
|
||||
[7, "day"],
|
||||
[4.345, "week"],
|
||||
[12, "month"],
|
||||
[Number.POSITIVE_INFINITY, "year"],
|
||||
];
|
||||
|
||||
const RTF = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
|
||||
|
||||
export function relativeTime(value: string | number | null | undefined): string {
|
||||
const date = parseDate(value);
|
||||
if (!date) return "";
|
||||
let delta = (date.getTime() - Date.now()) / 1000;
|
||||
for (const [step, unit] of RELATIVE_THRESHOLDS) {
|
||||
if (Math.abs(delta) < step) {
|
||||
return RTF.format(Math.round(delta), unit);
|
||||
}
|
||||
delta /= step;
|
||||
}
|
||||
return RTF.format(Math.round(delta), "year");
|
||||
}
|
||||
|
||||
export function fmtDateTime(value: string | number | null | undefined): string {
|
||||
const date = parseDate(value);
|
||||
return date ? date.toLocaleString() : "";
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import type { ConnectionStatus, InboundEvent, Outbound } from "./types";
|
||||
|
||||
/** WebSocket readyState constants, referenced by value to stay portable
|
||||
* across runtimes that don't expose a global ``WebSocket`` (tests, SSR). */
|
||||
const WS_OPEN = 1;
|
||||
const WS_CLOSING = 2;
|
||||
|
||||
type Unsubscribe = () => void;
|
||||
type EventHandler = (ev: InboundEvent) => void;
|
||||
type StatusHandler = (status: ConnectionStatus) => void;
|
||||
|
||||
interface PendingNewChat {
|
||||
resolve: (chatId: string) => void;
|
||||
reject: (err: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
export interface NanobotClientOptions {
|
||||
url: string;
|
||||
reconnect?: boolean;
|
||||
/** Called when a connection drops so the app can refresh its token. */
|
||||
onReauth?: () => Promise<string | null>;
|
||||
/** Inject a custom WebSocket factory (used by unit tests). */
|
||||
socketFactory?: (url: string) => WebSocket;
|
||||
/** Delay-cap for reconnect backoff (ms). */
|
||||
maxBackoffMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton WebSocket client that multiplexes chat streams.
|
||||
*
|
||||
* One socket carries many chat_ids: the server tags every outbound event with
|
||||
* ``chat_id``, and this class fans those events out to handlers registered
|
||||
* per chat. Reconnects are transparent and re-attach every known chat_id.
|
||||
*/
|
||||
export class NanobotClient {
|
||||
private socket: WebSocket | null = null;
|
||||
private statusHandlers = new Set<StatusHandler>();
|
||||
// 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
|
||||
private knownChats = new Set<string>();
|
||||
private pendingNewChat: PendingNewChat | null = null;
|
||||
// Frames queued while the socket is not yet OPEN
|
||||
private sendQueue: Outbound[] = [];
|
||||
private reconnectAttempts = 0;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private readonly shouldReconnect: boolean;
|
||||
private readonly maxBackoffMs: number;
|
||||
private readonly socketFactory: (url: string) => WebSocket;
|
||||
private currentUrl: string;
|
||||
private status_: ConnectionStatus = "idle";
|
||||
private readyChatId: string | null = null;
|
||||
// Set by ``close()`` so the onclose handler knows the drop was intentional
|
||||
// and must not schedule a reconnect or flip status back to "reconnecting".
|
||||
private intentionallyClosed = false;
|
||||
|
||||
constructor(private options: NanobotClientOptions) {
|
||||
this.shouldReconnect = options.reconnect ?? true;
|
||||
this.maxBackoffMs = options.maxBackoffMs ?? 15_000;
|
||||
this.socketFactory =
|
||||
options.socketFactory ?? ((url) => new WebSocket(url));
|
||||
this.currentUrl = options.url;
|
||||
}
|
||||
|
||||
get status(): ConnectionStatus {
|
||||
return this.status_;
|
||||
}
|
||||
|
||||
get defaultChatId(): string | null {
|
||||
return this.readyChatId;
|
||||
}
|
||||
|
||||
/** Swap the URL (e.g. after fetching a fresh token) then reconnect. */
|
||||
updateUrl(url: string): void {
|
||||
this.currentUrl = url;
|
||||
}
|
||||
|
||||
onStatus(handler: StatusHandler): Unsubscribe {
|
||||
this.statusHandlers.add(handler);
|
||||
handler(this.status_);
|
||||
return () => {
|
||||
this.statusHandlers.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);
|
||||
if (!handlers) {
|
||||
handlers = new Set();
|
||||
this.chatHandlers.set(chatId, handlers);
|
||||
}
|
||||
handlers.add(handler);
|
||||
this.attach(chatId);
|
||||
return () => {
|
||||
const current = this.chatHandlers.get(chatId);
|
||||
if (!current) return;
|
||||
current.delete(handler);
|
||||
if (current.size === 0) this.chatHandlers.delete(chatId);
|
||||
};
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
if (this.socket && this.socket.readyState < WS_CLOSING) return;
|
||||
this.intentionallyClosed = false;
|
||||
this.setStatus("connecting");
|
||||
const sock = this.socketFactory(this.currentUrl);
|
||||
this.socket = sock;
|
||||
sock.onopen = () => this.handleOpen();
|
||||
sock.onmessage = (ev) => this.handleMessage(ev);
|
||||
sock.onerror = () => this.setStatus("error");
|
||||
sock.onclose = () => this.handleClose();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.intentionallyClosed = true;
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
const sock = this.socket;
|
||||
this.socket = null;
|
||||
try {
|
||||
sock?.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
this.setStatus("closed");
|
||||
}
|
||||
|
||||
/** Ask the server to provision a new chat_id; resolves with the assigned id. */
|
||||
newChat(timeoutMs: number = 5_000): Promise<string> {
|
||||
if (this.pendingNewChat) {
|
||||
return Promise.reject(new Error("newChat already in flight"));
|
||||
}
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingNewChat = null;
|
||||
reject(new Error("newChat timed out"));
|
||||
}, timeoutMs);
|
||||
this.pendingNewChat = { resolve, reject, timer };
|
||||
this.queueSend({ type: "new_chat" });
|
||||
});
|
||||
}
|
||||
|
||||
attach(chatId: string): void {
|
||||
this.knownChats.add(chatId);
|
||||
if (this.socket?.readyState === WS_OPEN) {
|
||||
this.queueSend({ type: "attach", chat_id: chatId });
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage(chatId: string, content: string): void {
|
||||
this.knownChats.add(chatId);
|
||||
this.queueSend({ type: "message", chat_id: chatId, content });
|
||||
}
|
||||
|
||||
// -- internals ---------------------------------------------------------
|
||||
|
||||
private setStatus(status: ConnectionStatus): void {
|
||||
if (this.status_ === status) return;
|
||||
this.status_ = status;
|
||||
for (const handler of this.statusHandlers) handler(status);
|
||||
}
|
||||
|
||||
private handleOpen(): void {
|
||||
this.setStatus("open");
|
||||
this.reconnectAttempts = 0;
|
||||
// Re-attach every known chat_id so deliveries continue routing after a drop.
|
||||
for (const chatId of this.knownChats) {
|
||||
this.rawSend({ type: "attach", chat_id: chatId });
|
||||
}
|
||||
// Flush anything queued during reconnect.
|
||||
const queued = this.sendQueue.splice(0);
|
||||
for (const frame of queued) this.rawSend(frame);
|
||||
}
|
||||
|
||||
private handleMessage(ev: MessageEvent): void {
|
||||
let parsed: InboundEvent;
|
||||
try {
|
||||
parsed = JSON.parse(typeof ev.data === "string" ? ev.data : "") as InboundEvent;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.event === "ready") {
|
||||
this.readyChatId = parsed.chat_id;
|
||||
this.knownChats.add(parsed.chat_id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.event === "attached") {
|
||||
this.knownChats.add(parsed.chat_id);
|
||||
if (this.pendingNewChat) {
|
||||
clearTimeout(this.pendingNewChat.timer);
|
||||
this.pendingNewChat.resolve(parsed.chat_id);
|
||||
this.pendingNewChat = null;
|
||||
}
|
||||
this.dispatch(parsed.chat_id, parsed);
|
||||
return;
|
||||
}
|
||||
|
||||
const chatId = (parsed as { chat_id?: string }).chat_id;
|
||||
if (chatId) this.dispatch(chatId, parsed);
|
||||
}
|
||||
|
||||
private dispatch(chatId: string, ev: InboundEvent): void {
|
||||
const handlers = this.chatHandlers.get(chatId);
|
||||
if (!handlers) return;
|
||||
for (const h of handlers) h(ev);
|
||||
}
|
||||
|
||||
private handleClose(): void {
|
||||
this.socket = null;
|
||||
if (this.pendingNewChat) {
|
||||
clearTimeout(this.pendingNewChat.timer);
|
||||
this.pendingNewChat.reject(new Error("socket closed"));
|
||||
this.pendingNewChat = null;
|
||||
}
|
||||
if (this.intentionallyClosed || !this.shouldReconnect) {
|
||||
this.setStatus("closed");
|
||||
return;
|
||||
}
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
this.setStatus("reconnecting");
|
||||
const attempt = this.reconnectAttempts++;
|
||||
// Exponential backoff: 0.5s, 1s, 2s, 4s, capped.
|
||||
const delay = Math.min(500 * 2 ** attempt, this.maxBackoffMs);
|
||||
this.reconnectTimer = setTimeout(async () => {
|
||||
this.reconnectTimer = null;
|
||||
if (this.options.onReauth) {
|
||||
try {
|
||||
const refreshed = await this.options.onReauth();
|
||||
if (refreshed) this.currentUrl = refreshed;
|
||||
} catch {
|
||||
// fall through to retry with current URL
|
||||
}
|
||||
}
|
||||
this.connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private queueSend(frame: Outbound): void {
|
||||
if (this.socket?.readyState === WS_OPEN) {
|
||||
this.rawSend(frame);
|
||||
} else {
|
||||
this.sendQueue.push(frame);
|
||||
}
|
||||
}
|
||||
|
||||
private rawSend(frame: Outbound): void {
|
||||
if (!this.socket) return;
|
||||
try {
|
||||
this.socket.send(JSON.stringify(frame));
|
||||
} catch {
|
||||
// Send failure will materialize as a close; queue the frame for retry.
|
||||
this.sendQueue.push(frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
export type Role = "user" | "assistant" | "tool" | "system";
|
||||
|
||||
/** "trace" rows are intermediate agent breadcrumbs (tool-call hints,
|
||||
* progress pings) that should not be rendered as conversational replies. */
|
||||
export type MessageKind = "message" | "trace";
|
||||
|
||||
export interface UIMessage {
|
||||
id: string;
|
||||
role: Role;
|
||||
content: string;
|
||||
kind?: MessageKind;
|
||||
isStreaming?: boolean;
|
||||
createdAt: number;
|
||||
/** For trace rows: each individual hint line, so consecutive hints can
|
||||
* render as a single collapsible group. */
|
||||
traces?: string[];
|
||||
}
|
||||
|
||||
export interface ChatSummary {
|
||||
/** Server-side session key, e.g. ``websocket:abcd-...``. */
|
||||
key: string;
|
||||
/** Local channel + chat_id parts derived from ``key`` for convenience. */
|
||||
channel: string;
|
||||
chatId: string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
preview: string;
|
||||
}
|
||||
|
||||
export interface BootstrapResponse {
|
||||
token: string;
|
||||
ws_path: string;
|
||||
expires_in: number;
|
||||
model_name?: string | null;
|
||||
}
|
||||
|
||||
export type ConnectionStatus =
|
||||
| "idle"
|
||||
| "connecting"
|
||||
| "open"
|
||||
| "reconnecting"
|
||||
| "closed"
|
||||
| "error";
|
||||
|
||||
export type InboundEvent =
|
||||
| { event: "ready"; chat_id: string; client_id: string }
|
||||
| { event: "attached"; chat_id: string }
|
||||
| {
|
||||
event: "message";
|
||||
chat_id: string;
|
||||
text: string;
|
||||
reply_to?: string;
|
||||
media?: string[];
|
||||
/** Present when the frame is an agent breadcrumb (e.g. tool hint,
|
||||
* generic progress line) rather than a conversational reply. */
|
||||
kind?: "tool_hint" | "progress";
|
||||
}
|
||||
| {
|
||||
event: "delta";
|
||||
chat_id: string;
|
||||
text: string;
|
||||
stream_id?: string;
|
||||
}
|
||||
| {
|
||||
event: "stream_end";
|
||||
chat_id: string;
|
||||
stream_id?: string;
|
||||
}
|
||||
| { event: "error"; chat_id?: string; detail?: string };
|
||||
|
||||
export type Outbound =
|
||||
| { type: "new_chat" }
|
||||
| { type: "attach"; chat_id: string }
|
||||
| { type: "message"; chat_id: string; content: string };
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Reference in New Issue
Block a user