// 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. // @INVARIANT DECOMPOSITION GATE (630→~340 lines): Stream processing → AgentChat.StreamProcessor; Connection lifecycle → AgentChat.ConnectionManager; Persistence → AgentChat.LocalStorage; Types → AgentChatTypes. // @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() — delegates to ConnectionManager.retryConnection(). // @RELATION BINDS_TO -> [AssistantChatStore] // @RELATION DEPENDS_ON -> [AgentChat.ConnectionManager] // @RELATION DEPENDS_ON -> [AgentChat.StreamProcessor] // @RELATION DEPENDS_ON -> [AgentChat.LocalStorage] // @RELATION DEPENDS_ON -> [AgentChatTypes] // @RATIONALE Model-first: extracted from AssistantChatPanel.svelte (1029 lines). Decomposed further per DECOMPOSITION GATE into sub-helpers (ConnectionManager, StreamProcessor, LocalStorage) to stay under 400-line limit. // @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 { 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"; import { type ConnectionManagerCallbacks, ConnectionManager } from "./AgentChat.ConnectionManager.svelte.js"; import { type StreamProcessorHost, StreamProcessor } from "./AgentChat.StreamProcessor.svelte.js"; import { LocalStorageManager } from "./AgentChat.LocalStorage.js"; import type { StreamingState, ConnectionState, StreamMetadata, ToolCall, AgentMessage, Conversation, } from "./AgentChatTypes.js"; // ═══ Main model ═════════════════════════════════════════════════ 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(""); partialTokens: string[] = $state([]); activeToolCalls: ToolCall[] = $state([]); isLoadingHistory: boolean = $state(false); inputText: string = $state(""); pendingThreadId: string | null = $state(null); // ── Private fields ───────────────────────────────────────────── _client: GradioClient | null = null; // non-private for legacy Object.assign usage private _submission: ReturnType | null = null; private _conversationsPage: number = 1; private _conversationsHasNext: boolean = $state(false); private _historyPage: number = 1; private _historyHasNext: boolean = $state(false); private _messageQueue: Array<{ text: string; files?: File[] }> = $state([]); private _processingQueue: boolean = false; // ── Sub-helpers ──────────────────────────────────────────────── readonly connection: ConnectionManager; private readonly streamProcessor: StreamProcessor; private readonly storage: LocalStorageManager; // ── 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", ); queuePosition = $derived(this._messageQueue.length); conversationsHasNext = $derived(this._conversationsHasNext); constructor() { const connectionCbs: ConnectionManagerCallbacks = { onConnected: (client) => { this._client = client; this.connectionState = "connected"; }, onDisconnected: () => { this.connectionState = "disconnected"; }, onDisconnectedPermanent: () => { this.connectionState = "disconnected_permanent"; }, onStreamingStateChange: (s) => { this.streamingState = s; }, }; this.connection = new ConnectionManager(connectionCbs); this.streamProcessor = new StreamProcessor(this as unknown as StreamProcessorHost); this.storage = new LocalStorageManager(); } // ── Actions — P1 (streaming) ─────────────────────────────────── async sendMessage(text: string, files?: File[]): Promise { if (!text.trim() && (!files || files.length === 0)) return; if (this.connectionState !== "connected") return; if (this.streamingState !== "idle") { this._messageQueue = [...this._messageQueue, { text, files }]; log("AgentChat.Model", "REASON", "Message queued", { queueSize: this._messageQueue.length }); return; } await this._sendNow(text, files); } private async _sendNow(text: string, files?: File[]): Promise { if (this._processingQueue) return; const convId = this.currentConversationId; this.streamingState = "streaming"; this.error = null; this.partialText = ""; this.partialTokens = []; this.activeToolCalls = []; log("AgentChat.Model", "REASON", "Sending message", { text: text.slice(0, 100) }); try { this._submission = this._client!.submit("chat", [{ text, files }, null, convId, null], ); // Stream watcher: polls Client.stream_status.open. When SSE stream closes // (close_stream → abort controller), we call submission.return() to // terminate the iterator. If stream doesn't close in 120s — absolute fallback. const streamDone = await Promise.race([ this.streamProcessor.processStream(this._submission, convId), this.streamProcessor.streamCloseWatcher(this._client, 120_000), ]); if (streamDone === false) { try { this._submission?.return?.(); } catch { /* ignore */ } } this.streamingState = "idle"; this._submission = null; this._persistMessages(); await this._drainQueue(); } catch (e: unknown) { this.streamingState = "error"; this.error = e instanceof Error ? e.message : "Stream failed"; this._persistMessages(); log("AgentChat.Model", "EXPLORE", "Stream failed", {}, this.error); } } private async _drainQueue(): Promise { if (this._processingQueue) return; this._processingQueue = true; try { while (this._messageQueue.length > 0 && this.streamingState === "idle") { const next = this._messageQueue[0]; this._messageQueue = this._messageQueue.slice(1); log("AgentChat.Model", "REASON", "Processing queued message", { remaining: this._messageQueue.length }); await this._sendNow(next.text, next.files); } } finally { this._processingQueue = false; } } 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", files: null }, null, this.currentConversationId, action], ); await this.streamProcessor.processStream(submission, this.currentConversationId); this.streamingState = "idle"; this.pendingThreadId = null; this._persistMessages(); } catch (e: unknown) { this.streamingState = "error"; this.error = e instanceof Error ? e.message : "Resume failed"; log("AgentChat.Model", "EXPLORE", "HITL resume failed", {}, this.error); } } // ── Actions — P2 (data + lifecycle) ──────────────────────────── async loadConversations(reset: boolean = false): Promise { log("AgentChat.Model", "REASON", "Loading conversations", { reset }); try { if (reset) this._conversationsPage = 1; const res = await getAssistantConversations(this._conversationsPage, 20, false, ""); const items = res.items || []; this.conversations = reset ? items.map((item: Record) => ({ id: item.conversation_id ?? item.id ?? "", title: item.title ?? "", updated_at: item.updated_at ?? "", message_count: item.message_count ?? 0, })) : [...this.conversations, ...items.map((item: Record) => ({ id: item.conversation_id ?? item.id ?? "", title: item.title ?? "", updated_at: item.updated_at ?? "", message_count: item.message_count ?? 0, }))]; this._conversationsHasNext = Boolean(res.has_next); this._conversationsPage++; } catch (e: unknown) { this.error = e instanceof Error ? e.message : "Failed to load conversations"; log("AgentChat.Model", "EXPLORE", "Load conversations failed", {}, this.error); } } async searchConversations(query: string): Promise { log("AgentChat.Model", "REASON", "Searching conversations", { query }); try { this._conversationsPage = 1; const res = await getAssistantConversations(1, 20, false, query); this.conversations = (res.items || []).map((item: Record) => ({ id: item.conversation_id ?? item.id ?? "", title: item.title ?? "", updated_at: item.updated_at ?? "", message_count: item.message_count ?? 0, })); this._conversationsHasNext = Boolean(res.has_next); this._conversationsPage++; } catch (e: unknown) { this.error = e instanceof Error ? e.message : "Failed to search conversations"; log("AgentChat.Model", "EXPLORE", "Search conversations failed", {}, this.error); } } async loadHistory(conversationId: string | null = null): Promise { this.isLoadingHistory = true; this.error = null; log("AgentChat.Model", "REASON", "Loading history", { conversationId }); try { const targetId = conversationId ?? this.currentConversationId; if (!targetId) { this.isLoadingHistory = false; return; } if (this._loadFromStorage(targetId)) { this.isLoadingHistory = false; return; } const res = await getAssistantHistory(1, 30, targetId); this.messages = (res.items || []).map((msg: Record) => ({ id: (msg.message_id as string) ?? (msg.id as string) ?? "", conversation_id: msg.conversation_id as string ?? "", role: msg.role as string ?? "assistant", text: msg.text as string ?? "", metadata: msg.metadata as StreamMetadata, toolCalls: (msg.tool_calls as ToolCall[]) ?? [], created_at: msg.created_at as string ?? "", })); if (res.conversation_id && !this.currentConversationId) { setAssistantConversationId(res.conversation_id as string); this.currentConversationId = res.conversation_id as string; } } catch (e: unknown) { this.error = e instanceof Error ? e.message : "Failed to load history"; log("AgentChat.Model", "EXPLORE", "Load history failed", {}, this.error); } finally { this.isLoadingHistory = false; } } async retryConnection(): Promise { return this.connection.retryConnection(); } createConversation(): void { if (this.currentConversationId && this.messages.length > 0) { this._saveToStorage(); } 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"); } async deleteConversation(id: string): Promise { const prevConversations = [...this.conversations]; this.conversations = this.conversations.filter((c) => c.id !== id); this.storage.clear(id); log("AgentChat.Model", "REASON", "Archiving conversation (optimistic)", { id }); try { await deleteAssistantConversation(id); addToast("Диалог архивирован", "success"); if (this.currentConversationId === id) this.createConversation(); } catch (e: unknown) { this.conversations = prevConversations; this.error = e instanceof Error ? e.message : "Failed to archive conversation"; addToast("Не удалось архивировать диалог", "error"); log("AgentChat.Model", "EXPLORE", "Archive failed, rolled back", {}, this.error); } } /** Extract conversation_id from stream metadata or event data */ _captureConversationId(meta: StreamMetadata | undefined, convIdFromEvent: string): void { this.captureConversationId(meta, convIdFromEvent); } /** Public alias for StreamProcessorHost interface contract */ captureConversationId(meta: StreamMetadata | undefined, convIdFromEvent: string): void { const newId = meta?.thread_id || convIdFromEvent || null; if (newId && !this.currentConversationId) { this.currentConversationId = newId; if (this.currentConversationId) setAssistantConversationId(this.currentConversationId); this._saveToStorage(); } } /** Save messages after streaming completes */ _persistMessages(): void { if (this.currentConversationId) this._saveToStorage(); } // ── Private — storage helpers ────────────────────────────────── private _saveToStorage(): void { if (this.currentConversationId) { this.storage.save(this.currentConversationId, this.messages, this.partialText); } } private _loadFromStorage(conversationId: string): boolean { const data = this.storage.load(conversationId); if (data) { this.messages = data.messages; this.currentConversationId = data.conversationId; return true; } return false; } } // #endregion AgentChat.Model