// frontend/src/lib/models/AgentChatModel.svelte.ts // #region AgentChat.Model [C:4] [TYPE Model] [SEMANTICS agent-chat,model,gradio,langgraph] // @defgroup AgentChat State model for Gradio-powered agent chat — submit() streaming, structured metadata, LangGraph HITL resume, no WebSocket. // @INVARIANT Streaming state gates input: input disabled in streaming, awaiting_confirmation, disconnected states. // @INVARIANT Connection state gates send: send rejected when connectionState !== "connected". // @INVARIANT HITL resume: confirmation via second submit() with additional_inputs=[conversationId, "confirm"|"deny"]. Primary stream TERMINATES on confirm_required, second stream resumes from checkpoint. // @INVARIANT Optimistic cancel: submission.cancel() stops generation, partial text preserved. // @INVARIANT Conversation list loaded from FastAPI REST — Gradio serves only live agent streaming. // @STATE idle | streaming | awaiting_confirmation | error | disconnected | disconnected_permanent // @ACTION sendMessage(text, files?) — submit("/chat", {text, files}, [conversationId, null]), iterate stream events, process metadata. // @ACTION cancelGeneration() — submission.cancel(), optimistic UI reset. // @ACTION resumeConfirm(action) — second submit("/chat", {text:"confirm"}, [conversationId, action]) for HITL resume. // @ACTION loadConversations(reset?) — fetch list from REST, infinite scroll. // @ACTION loadHistory(conversationId?) — fetch messages from REST. // @ACTION deleteConversation(id) — optimistic archive, rollback on failure. // @ACTION createConversation() — clear state, start new. // @ACTION retryConnection() — Client.connect() retry. // @RELATION BINDS_TO -> [AssistantChatStore] // @RELATION DEPENDS_ON -> [GradioClient:@gradio/client] // @RELATION DEPENDS_ON -> [AssistantApi] // @RATIONALE Model-first: extracted from AssistantChatPanel.svelte (1029 lines). Centralizes Gradio submit() lifecycle, metadata parsing, HITL resume, and reconnect logic in a testable unit. Without a model, streaming + metadata dispatch + confirmation + reconnect logic scattered across event handlers would be untestable. // @REJECTED Inline $state in AssistantChatPanel.svelte — rejected because component already exceeds 400-line guideline. // @REJECTED WebSocket-based model — rejected with custom WebSocket protocol. // @REJECTED Active/follower multi-tab — rejected in favor of client-side gate. import { Client, type Client as GradioClient } from "@gradio/client"; import { assistantChatStore, setAssistantConversationId, } from "$lib/stores/assistantChat.svelte.js"; import { getAssistantConversations, getAssistantHistory, deleteAssistantConversation, } from "$lib/api/assistant.js"; import { addToast } from "$lib/toasts.svelte.js"; import { log } from "$lib/cot-logger"; // ── Type definitions ───────────────────────────────────────────── type StreamingState = | "idle" | "streaming" | "awaiting_confirmation" | "error" | "disconnected" | "disconnected_permanent"; type ConnectionState = | "connected" | "disconnected" | "disconnected_permanent"; interface StreamMetadata { type?: "stream_token" | "tool_start" | "tool_end" | "tool_error" | "confirm_required" | "confirm_resolved" | "error"; token?: string; tool?: string; input?: Record; output?: Record; error?: string; prompt?: string; thread_id?: string; result?: "confirmed" | "denied"; code?: string; detail?: string; } interface ToolCall { tool: string; input: Record; output?: Record; error?: string; status: "executing" | "completed" | "failed"; } interface AgentMessage { id: string; conversation_id: string; role: "user" | "assistant" | "system" | "tool"; text: string; metadata?: StreamMetadata; state?: string; task_id?: string; toolCalls: ToolCall[]; created_at: string; } interface Conversation { id: string; title: string; updated_at: string; message_count: number; } // ── Reconnect constants ────────────────────────────────────────── const RECONNECT_MAX_ATTEMPTS = 5; const RECONNECT_INTERVAL_MS = 5_000; export class AgentChatModel { // ── Atoms ────────────────────────────────────────────────────── messages: AgentMessage[] = $state([]); conversations: Conversation[] = $state([]); currentConversationId: string | null = $state(null); streamingState: StreamingState = $state("idle"); connectionState: ConnectionState = $state("connected"); error: string | null = $state(null); partialText: string = $state(""); activeToolCalls: ToolCall[] = $state([]); isLoadingHistory: boolean = $state(false); inputText: string = $state(""); pendingThreadId: string | null = $state(null); // for HITL resume // ── Private atoms ────────────────────────────────────────────── private _client: GradioClient | null = null; private _submission: ReturnType | null = null; private _conversationsPage: number = 1; private _conversationsHasNext: boolean = $state(false); private _historyPage: number = 1; private _historyHasNext: boolean = $state(false); private _reconnectAttempts: number = 0; private _reconnectTimer: ReturnType | null = null; // ── Derived ──────────────────────────────────────────────────── isStreaming = $derived( this.streamingState === "streaming" || this.streamingState === "awaiting_confirmation", ); isInputLocked = $derived( this.isStreaming || this.streamingState === "disconnected" || this.streamingState === "disconnected_permanent", ); connectionDotColor = $derived( this.connectionState === "connected" ? "success" : this.connectionState === "disconnected" ? "warning" : "destructive", ); // ── Actions — P1 ─────────────────────────────────────────────── async sendMessage(text: string, files?: File[]): Promise { if (this.streamingState !== "idle" || this.connectionState !== "connected") return; if (!text.trim() && (!files || files.length === 0)) return; const convId = this.currentConversationId; this.streamingState = "streaming"; this.error = null; this.partialText = ""; this.activeToolCalls = []; log("AgentChat.Model", "REASON", "Sending message", { text: text.slice(0, 100) }); try { this._submission = this._client!.submit("/chat", { text, files }, [convId, null], // additional_inputs: [conversation_id, action] ); for await (const event of this._submission) { const msg: AgentMessage = event.data; this._onStreamData(msg); } this.streamingState = "idle"; this._submission = null; } catch (e: unknown) { this.streamingState = "error"; this.error = e instanceof Error ? e.message : "Stream failed"; log("AgentChat.Model", "EXPLORE", "Stream failed", {}, this.error); } } cancelGeneration(): void { if (this.streamingState === "idle") return; this._submission?.cancel(); this.streamingState = "idle"; this._submission = null; log("AgentChat.Model", "REASON", "Generation cancelled"); } async resumeConfirm(action: "confirm" | "deny"): Promise { if (this.streamingState !== "awaiting_confirmation") return; if (!this.pendingThreadId || !this.currentConversationId) return; log("AgentChat.Model", "REASON", `HITL resume: ${action}`, { threadId: this.pendingThreadId }); try { const submission = this._client!.submit("/chat", { text: action === "confirm" ? "confirm" : "deny" }, [this.currentConversationId, action], ); for await (const event of submission) { const msg: AgentMessage = event.data; this._onStreamData(msg); } this.streamingState = "idle"; this.pendingThreadId = null; } catch (e: unknown) { this.streamingState = "error"; this.error = e instanceof Error ? e.message : "Resume failed"; log("AgentChat.Model", "EXPLORE", "HITL resume failed", {}, this.error); } } async loadConversations(reset: boolean = false): Promise { log("AgentChat.Model", "REASON", "Loading conversations", { reset }); throw new Error("TODO: implement loadConversations — GET /api/assistant/conversations, infinite scroll, merge or replace list"); } async loadHistory(conversationId: string | null = null): Promise { this.isLoadingHistory = true; this.error = null; log("AgentChat.Model", "REASON", "Loading history", { conversationId }); throw new Error("TODO: implement loadHistory — GET /api/assistant/history, render messages"); } async retryConnection(): Promise { log("AgentChat.Model", "REASON", "Manual reconnect"); this._reconnectAttempts = 0; throw new Error("TODO: implement retryConnection — Client.connect() to Gradio, reset reconnect counter"); } createConversation(): void { this.messages = []; this.currentConversationId = null; this.streamingState = "idle"; this.error = null; this.partialText = ""; this.activeToolCalls = []; this.pendingThreadId = null; this._historyPage = 1; this._historyHasNext = false; log("AgentChat.Model", "REASON", "New conversation created"); } // ── Actions — P2 ─────────────────────────────────────────────── async deleteConversation(id: string): Promise { this.conversations = this.conversations.filter((c) => c.id !== id); log("AgentChat.Model", "REASON", "Archiving conversation (optimistic)", { id }); throw new Error("TODO: implement deleteConversation — DELETE /api/assistant/conversations/{id}, soft-delete (archive), rollback on failure"); } // ── Private — Stream metadata handler ────────────────────────── private _onStreamData(msg: AgentMessage): void { const meta = msg.metadata; if (!meta || !meta.type) { // Plain text token — append to last assistant message this.partialText += msg.text; return; } switch (meta.type) { case "stream_token": this.partialText += meta.token ?? ""; break; case "tool_start": this.activeToolCalls = [...this.activeToolCalls, { tool: meta.tool ?? "unknown", input: meta.input ?? {}, status: "executing", }]; break; case "tool_end": this.activeToolCalls = this.activeToolCalls.map((tc) => tc.tool === meta.tool ? { ...tc, output: meta.output, status: "completed" } : tc ); break; case "tool_error": this.activeToolCalls = this.activeToolCalls.map((tc) => tc.tool === meta.tool ? { ...tc, error: meta.error, status: "failed" } : tc ); break; case "confirm_required": this.streamingState = "awaiting_confirmation"; this.pendingThreadId = meta.thread_id ?? null; // Primary stream terminates — model enters awaiting_confirmation // Second submit() will call resumeConfirm() break; case "confirm_resolved": this.streamingState = meta.result === "confirmed" ? "streaming" : "idle"; this.pendingThreadId = null; break; case "error": this.streamingState = "error"; this.error = meta.detail ?? meta.code ?? "Unknown error"; break; default: log("AgentChat.Model", "EXPLORE", "Unknown metadata type", { type: meta.type }); } } // ── Private — Connection lifecycle ───────────────────────────── private _onDisconnect(): void { this.connectionState = "disconnected"; this._reconnectAttempts = 0; log("AgentChat.Model", "EXPLORE", "Gradio disconnected"); throw new Error("TODO: implement _onDisconnect — start reconnect interval (5×5s), transition to connected or disconnected_permanent"); } private _onReconnect(): void { this.connectionState = "connected"; this.streamingState = "idle"; this._reconnectAttempts = 0; log("AgentChat.Model", "REFLECT", "Gradio reconnected"); throw new Error("TODO: implement _onReconnect — clear timer, restore connected state"); } } // #endregion AgentChat.Model