// 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 Screen chrome state lives in the model: debug panel and conversation sidebar are view-rendered only. // @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 -> [AgentChat.Types] // @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 { 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 { t } from "$lib/i18n/index.svelte.js"; 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, UIContext, } from "./AgentChatTypes.js"; // ═══ Main model ═════════════════════════════════════════════════ export interface AgentChatModelOptions { userId?: string; userJwt?: string; envId?: string; } export type AgentPhase = "ready" | "thinking" | "tooling" | "confirming" | "failed" | "offline"; export type AgentPhaseTone = "success" | "warning" | "destructive" | "info" | "muted"; export type ConfirmationRisk = "read" | "write" | "unknown"; export type AgentProcessStepState = "done" | "active" | "blocked" | "idle"; export interface AgentQuickAction { id: "dashboards" | "migration" | "tasks" | "health"; icon: string; labelKey: string; descriptionKey: string; prompt: string; } export interface AgentProcessStep { id: "context" | "reasoning" | "tools" | "confirmation" | "result"; label: string; state: AgentProcessStepState; } export class AgentChatModel { // ── Atoms ────────────────────────────────────────────────────── messages: AgentMessage[] = $state([]); conversations: Conversation[] = $state([]); currentConversationId: string | null = $state(null); isDebugPanelOpen: boolean = $state(false); isConversationSidebarOpen: boolean = $state(false); streamingState: StreamingState = $state("idle"); connectionState: ConnectionState = $state("connected"); // ── LLM provider health status ── llmStatus: "ok" | "unavailable" | "timeout" | "auth_error" | "unknown" = $state("unknown"); llmBannerDismissed: boolean = $state(false); llmRetryCountdown: number = $state(0); llmBannerMessage: string = $state(""); error: string | null = $state(null); /** Last backend error code — used by errorTitle to map to operator-visible label. */ _lastErrorCode: 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); /** Human-readable confirmation prompt (replaces misuse of `error` for this purpose). */ confirmationMessage: string | null = $state(null); /** Tool name being requested for HITL confirmation. */ pendingToolName: string | null = $state(null); /** Tool arguments for the pending HITL confirmation call. */ pendingToolArgs: Record = $state({}); /** Backend-provided coarse confirmation risk; heuristic is fallback only. */ pendingConfirmationRisk: ConfirmationRisk | null = $state(null); /** Backend-provided detailed risk level: safe, guarded, dangerous, unknown. */ pendingRiskLevel: string | null = $state(null); /** Backend-provided normalized confirmation environment: prod/staging/dev/null. */ pendingEnvContext: "prod" | "staging" | "dev" | null = $state(null); /** Set to true when user clicks Stop — prevents false 'agent unavailable' fallback. */ _userCancelled: boolean = $state(false); /** Populated by file_uploaded metadata — attached to user message on stream end. */ _pendingAttachment: { file_name: string; file_path: string; file_size: number } | null = $state(null); userId: string = $state(""); userJwt: string = $state(""); envId: string = $state(""); // ── UI Context (035-agent-chat-context) ──────────────────────── uiContext: UIContext | null = $state(null); dangerousCountdown: number = $state(0); dangerousCountdownActive: boolean = $state(false); _activeEnvId: string | null = $state(null); permissionAlternatives: Array<{ action: string; prompt: string }> | null = $state(null); effectiveToolPipeline: string[] = $state([]); // ── 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 _isLoadingConversations: boolean = false; private _historyPage: number = 1; private _historyHasNext: boolean = $state(false); private _messageQueue: Array<{ text: string; files?: File[] }> = $state([]); private _processingQueue: boolean = false; readonly quickActions: AgentQuickAction[] = [ { id: "dashboards", icon: "dashboard", labelKey: "quick_dashboards", descriptionKey: "quick_dashboards_desc", prompt: "Покажи доступные дашборды", }, { id: "migration", icon: "layers", labelKey: "quick_migration", descriptionKey: "quick_migration_desc", prompt: "Запусти миграцию", }, { id: "tasks", icon: "activity", labelKey: "quick_tasks", descriptionKey: "quick_tasks_desc", prompt: "Проверь статус текущих задач", }, { id: "health", icon: "warning", labelKey: "quick_health", descriptionKey: "quick_health_desc", prompt: "Проверь статус системы", }, ]; // ── 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); conversationIdLabel = $derived(this.currentConversationId || "new"); pendingThreadIdLabel = $derived(this.pendingThreadId || "none"); userIdLabel = $derived(this.userId || "anonymous"); envIdLabel = $derived(this.envId || "—"); lastMessageIdLabel = $derived(this.messages.at(-1)?.id || "none"); messageCountLabel = $derived(String(this.messages.length)); conversationListItems = $derived( this.conversations.map((conversation) => ({ ...conversation, title: this.normalizeConversationTitle(conversation.title), })), ); currentConversationTitle = $derived( this.currentConversationId ? (this.conversationListItems.find((c) => c.id === this.currentConversationId)?.title || "Агент-чат") : "Агент-чат", ); hasSilentStream = $derived( this.streamingState === "streaming" && this.partialText.length === 0 && this.partialTokens.length === 0 && this.activeToolCalls.length === 0, ); userFacingStatusTitle = $derived.by(() => { if (this.connectionState !== "connected") return "Нет соединения с агентом"; if (this.streamingState === "awaiting_confirmation") return "Требуется подтверждение"; if (this.streamingState === "error") return "Агент остановился"; if (this.activeToolCalls.some((tool) => tool.status === "executing")) return "Выполняется инструмент"; if (this.hasSilentStream) return "Ожидаю первый ответ"; if (this.streamingState === "streaming") return "Агент отвечает"; return "Готов к задаче"; }); userFacingStatusDetail = $derived.by(() => { if (this.connectionState !== "connected") return "Переподключитесь перед отправкой новой команды."; if (this.streamingState === "awaiting_confirmation") return "Проверьте действие и параметры перед продолжением."; if (this.streamingState === "error") return this.error || "Можно повторить последний запрос или начать новый диалог."; if (this.activeToolCalls.some((tool) => tool.status === "executing")) return "Действие уже передано инструменту, дождитесь результата."; if (this.hasSilentStream) return "Если upstream LLM не начнет отвечать, чат покажет восстановление вместо бесконечного ожидания."; if (this.streamingState === "streaming") return "Ответ поступает по стриму."; if (this.envId) return `Контекст готов: окружение ${this.envId}.`; if (this._activeEnvId) return `Окружение: ${this._activeEnvId}.`; if (this.uiContext) { const type = this.uiContext.objectType ? ` ${this.uiContext.objectType}#${this.uiContext.objectId}` : ""; const env = this.uiContext.envId ? ` · ${this.uiContext.envId}` : ""; return `Контекст${type}${env}.`; } return "Выберите окружение перед задачами, которые работают с Superset."; }); contextPillLabel = $derived.by((): string => { if (!this.uiContext) { return this._activeEnvId ? `🌐 ${this._activeEnvId}` : "⚪ Контекст не выбран"; } if (!this.uiContext.objectType) { return this._activeEnvId ? `🌐 ${this._activeEnvId}` : "⚪ Контекст не выбран"; } const typeIcons: Record = { dashboard: "📋", dataset: "📊", migration: "🔄" }; const icon = typeIcons[this.uiContext.objectType] || "📄"; const idLabel = this.uiContext.objectId ? ` #${this.uiContext.objectId}` : ""; const env = this._activeEnvId || this.uiContext.envId || ""; return `${icon} ${this.uiContext.objectType}${idLabel} · ${env}`; }); isProdContext = $derived( (this.uiContext?.envId?.toLowerCase().includes("prod") || false) || (this._activeEnvId?.toLowerCase().includes("prod") || false) ); showProdWarning = $derived(this.isProdContext); processSteps = $derived.by((): AgentProcessStep[] => { const hasTools = this.activeToolCalls.length > 0 || this.messages.some((msg) => (msg.toolCalls || []).length > 0); const hasAssistantResult = this.messages.some((msg) => msg.role === "assistant" && msg.text.trim().length > 0); const failed = this.streamingState === "error"; return [ { id: "context", label: this.contextPillLabel, state: (this.uiContext || this._activeEnvId) ? "done" : failed ? "blocked" : "idle", }, { id: "reasoning", label: this.hasSilentStream ? "Ожидание LLM" : "Понимание задачи", state: failed ? "blocked" : this.streamingState === "streaming" && !hasTools ? "active" : hasAssistantResult || hasTools ? "done" : "idle", }, { id: "tools", label: this.activeToolCalls.some((t) => t.status === "executing" || t.status === "retrying") ? `Инструменты (${this.activeToolCalls.filter((t) => t.status === "executing" || t.status === "retrying").length})` : "Инструменты", state: this.activeToolCalls.some((t) => t.status === "executing" || t.status === "retrying") ? "active" : hasTools ? "done" : "idle", }, { id: "confirmation", label: this.pendingRiskLevel === "permission_denied" ? "Нет доступа" : "Подтверждение", state: this.streamingState === "awaiting_confirmation" ? "active" : this.pendingRiskLevel === "permission_denied" ? "blocked" : this.pendingThreadId ? "done" : "idle", }, { id: "result", label: "Результат", state: failed ? "blocked" : this.streamingState === "idle" && hasAssistantResult ? "done" : "idle", }, ]; }); agentPhase = $derived.by((): AgentPhase => { if (this.connectionState !== "connected") return "offline"; if (this.streamingState === "awaiting_confirmation") return "confirming"; if (this.streamingState === "error") return "failed"; if (this.activeToolCalls.some((tool) => tool.status === "executing")) return "tooling"; if (this.streamingState === "streaming") return "thinking"; return "ready"; }); agentPhaseTone = $derived.by((): AgentPhaseTone => { if (this.agentPhase === "offline" || this.agentPhase === "failed") return "destructive"; if (this.agentPhase === "confirming" || this.agentPhase === "tooling") return "warning"; if (this.agentPhase === "ready") return "success"; return "muted"; }); confirmationRisk = $derived.by((): ConfirmationRisk => { if (this.pendingConfirmationRisk) return this.pendingConfirmationRisk; if (this.pendingRiskLevel === "safe") return "read"; if (this.pendingRiskLevel === "guarded" || this.pendingRiskLevel === "dangerous") return "write"; const tool = (this.pendingToolName || "").toLowerCase(); if (!tool) return "unknown"; if ( tool.startsWith("list_") || tool.startsWith("get_") || tool.startsWith("show_") || tool.startsWith("search_") || tool.startsWith("check_") || tool.includes("status") || tool.includes("summary") ) { return "read"; } return "write"; }); /** 5 semantic tones per spec 035 US2: read, write_dev, write_staging, write_prod, dangerous. */ confirmationTone = $derived.by((): AgentPhaseTone => { if (this.pendingRiskLevel === "dangerous") return "destructive"; if (this.confirmationRisk === "read") return "success"; if (this.confirmationRisk === "write") { if (this.pendingEnvContext === "prod") return "destructive"; if (this.pendingEnvContext === "staging") return "warning"; if (this.pendingEnvContext === "dev") return "info"; return "warning"; } return "muted"; }); confirmationTitleKey = $derived.by(() => { if (this.confirmationRisk === "read") return "confirmation_read_title"; if (this.confirmationRisk === "write") return "confirmation_write_title"; return "confirmation_card_title"; }); confirmationDescriptionKey = $derived.by(() => { if (!this.pendingThreadId) return "confirmation_missing_checkpoint"; if (this.confirmationRisk === "read") return "confirmation_read_description"; if (this.confirmationRisk === "write" && this.pendingEnvContext === "prod") return "confirmation_scope_fallback"; if (this.confirmationRisk === "write") return "confirmation_scope_fallback"; return "confirmation_unknown_description"; }); confirmationMessageOverride = $derived.by(() => { const message = (this.confirmationMessage || "").trim(); if (!message) return ""; const genericMessages = [ "Подтвердить операцию?", "Подтвердите действие", "Confirm operation?", "Confirm action", ]; return genericMessages.includes(message) ? "" : message; }); pendingToolLabel = $derived.by(() => { if (!this.pendingToolName) return ""; return this.pendingToolName .replace(/_/g, " ") .replace(/\b\w/g, (char) => char.toUpperCase()); }); /** Operator-visible error title — derived from backend error code. */ errorTitle = $derived.by((): string => { const code = this._lastErrorCode; if (!code) return "Агент не завершил ответ"; const titles: Record = { "LLM_PROVIDER_UNAVAILABLE": this.llmBannerMessage || "LLM провайдер недоступен", "LLM_TIMEOUT": this.llmBannerMessage || "LLM провайдер не отвечает", "LLM_AUTH_ERROR": this.llmBannerMessage || "Ошибка авторизации LLM", "LLM_RATE_LIMITED": this.llmBannerMessage || "Превышен лимит запросов к LLM", "LLM_MALFORMED_OUTPUT": "LLM вернул некорректный ответ", "CONCURRENT_SEND": "Запрос уже обрабатывается", "FILE_TOO_LARGE": "Файл превышает допустимый размер", "STREAM_CLEANUP_TIMEOUT": "Таймаут завершения потока", "EMPTY_AGENT_RESPONSE": "Агент не сформировал ответ", "CHECKPOINT_EXPIRED": "Контрольная точка устарела", "CHECKPOINT_NOT_FOUND": "Контрольная точка не найдена", "PROCESSING_ERROR": "Агент не завершил ответ", }; return titles[code] || "Агент не завершил ответ"; }); constructor(options?: AgentChatModelOptions) { if (options?.userId) this.userId = options.userId; if (options?.userJwt) this.userJwt = options.userJwt; if (options?.envId) this.envId = options.envId; const connectionCbs: ConnectionManagerCallbacks = { onConnected: (client) => { this._client = client; this.connectionState = "connected"; }, onDisconnected: () => { this.connectionState = "disconnected"; if (this.streamingState === "streaming") { this.streamingState = "disconnected"; } }, onDisconnectedPermanent: (error?: string) => { this.connectionState = "disconnected_permanent"; this.streamingState = "disconnected_permanent"; if (error) this.error = error; }, 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) ─────────────────────────────────── /** External callback to sync reactive values (userId, userJwt, envId) before each send */ onBeforeSend?: () => void; toggleDebugPanel(): void { this.isDebugPanelOpen = !this.isDebugPanelOpen; } setDebugPanelOpen(open: boolean): void { this.isDebugPanelOpen = open; } toggleConversationSidebar(): void { this.isConversationSidebarOpen = !this.isConversationSidebarOpen; } setConversationSidebarOpen(open: boolean): void { this.isConversationSidebarOpen = open; } async selectConversation(id: string, closeSidebar: boolean = false): Promise { // Commit partial streaming text to the CURRENT conversation BEFORE switching. // Without this, switching dialogs during active streaming silently loses // the in-progress response. if (this.streamingState === "streaming" && this.currentConversationId && id !== this.currentConversationId) { this._commitStreamingPartial(); } this.currentConversationId = id; await this.loadHistory(id); if (closeSidebar) this.isConversationSidebarOpen = false; } normalizeConversationTitle(rawTitle: unknown): string { const title = String(rawTitle ?? "") // Strip file upload markers (fallback for old conversations) .replace(/(?:^|\n)--- Uploaded file content ---[\s\S]*$/i, "") // Strip pre-fetched data blocks .replace(/\s*\[PRE-FETCHED DATA[\s\S]*$/i, "") .replace(/^✅\s*/, "") .replace(/^(list|get|show|check|search)_\w+$/i, "Системное действие") // Strip trailing "Available dashboards..." suffix .replace(/\s+Available(?:\s+\S+)*$/i, "") // Collapse whitespace .replace(/\s+/g, " ") .trim(); return title || "Новый диалог"; } cleanVisibleMessageText(rawText: unknown): string { return String(rawText ?? "") .replace(/(?:^|\n)--- Uploaded file content ---[\s\S]*$/i, "") .replace(/\n?\[PRE-FETCHED DATA[\s\S]*?```/i, "") .replace(/\n?\[PRE-FETCHED DATA[\s\S]*?<\/pre>/i, "") .replace(/\n?\[PRE-FETCHED DATA[\s\S]*$/i, "") .replace(/\n?\[\/PRE-FETCHED DATA\][\s\S]*$/i, "") .trim(); } _getLastCleanUserText(): string | null { const lastUserMessage = [...this.messages].reverse().find((message) => message.role === "user"); const text = this.cleanVisibleMessageText(lastUserMessage?.text || ""); return text || null; } async retryLastUserMessage(): Promise { const text = this._getLastCleanUserText(); if (!text) return; this.streamingState = "idle"; this.error = null; this._lastErrorCode = null; await this.sendMessage(text); } setUIContextFromParams(params: URLSearchParams): void { const rawType = params.get("objectType"); const validTypes = ["dashboard", "dataset", "migration"]; const objectType = (rawType && validTypes.includes(rawType)) ? (rawType as UIContext["objectType"]) : null; const objectId = params.get("objectId") || null; const objectName = params.get("objectName") || null; const envId = params.get("envId") || null; const route = params.get("route") || "/agent"; if (!objectType && !objectId && !objectName && !envId && !params.get("route")) { this.uiContext = null; this._activeEnvId = null; log("AgentChat.Model", "REASON", "UI context absent from params", {}); return; } this.uiContext = { objectType, objectId, objectName, envId, route, contextVersion: 1 }; this._activeEnvId = envId; log("AgentChat.Model", "REASON", "UI context set from params", { objectType, objectId, envId }); } updateActiveEnv(envId: string | null): void { this._activeEnvId = envId; if (this.uiContext) { this.uiContext.envId = envId; } } serializedUIContext(): string | null { return this.uiContext ? JSON.stringify(this.uiContext) : null; } /** Start 10s countdown for dangerous tool confirmation. Model owns the timer. */ startDangerousCountdown(): void { this.dangerousCountdownActive = false; this.dangerousCountdown = 10; this.dangerousCountdownActive = true; const interval = setInterval(() => { this.dangerousCountdown--; if (this.dangerousCountdown <= 0) { this.dangerousCountdownActive = false; clearInterval(interval); } }, 1000); } dismissPermissionDenied(): void { this.streamingState = "idle"; this.pendingThreadId = null; this.confirmationMessage = null; this.pendingToolName = null; this.pendingToolArgs = {}; this.pendingConfirmationRisk = null; this.pendingRiskLevel = null; this.pendingEnvContext = null; this.permissionAlternatives = null; } async sendMessage(text: string, files?: File[]): Promise { // Sync reactive values from the page component this.onBeforeSend?.(); 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[], fromQueue = false): Promise { if (this._processingQueue && !fromQueue) return; // Generate conversation ID on first send so it's known locally if (!this.currentConversationId) { this.currentConversationId = crypto.randomUUID(); } const convId = this.currentConversationId; this.streamingState = "streaming"; this._userCancelled = false; this.error = null; this._lastErrorCode = null; this.partialText = ""; this.partialTokens = []; this.activeToolCalls = []; this.confirmationMessage = null; this.pendingToolName = null; this.pendingToolArgs = {}; this.pendingConfirmationRisk = null; this.pendingRiskLevel = null; log("AgentChat.Model", "REASON", "Sending message", { text: text.slice(0, 100) }); try { // Guard: client may be null if connection reset during send attempt. if (!this._client) { this.streamingState = "error"; this.error = "Agent client not available — please reconnect."; log("AgentChat.Model", "EXPLORE", "Submit rejected: _client is null", {}, this.error); return; } this._submission = this._client.submit("chat", [{ text, files }, null, convId, null, this.userId, this.userJwt, this.envId, this.serializedUIContext()], ); // Process stream events with a 180s safety timeout. // processStream iterates all data events from the Gradio SSE stream. const streamDone = await Promise.race([ this.streamProcessor.processStream(this._submission, convId), this._firstActivityTimeout(60_000), this.streamProcessor.streamCloseWatcher(this._client!, 180_000), new Promise((resolve) => setTimeout(() => resolve(false), 180_000)), ]); if (streamDone === false) { try { this._submission?.return?.(); } catch { /* ignore */ } } if (this.streamingState !== "awaiting_confirmation") { this.streamingState = "idle"; } this._submission = null; this._persistMessages(); // Refresh conversation list after stream completes this.loadConversations(true); // Empty-response fallback is handled by the $effect in AgentChat.svelte // (streaming → idle with no content → fallback message + streamingState = "error"). // Do NOT duplicate that logic here — it would race with the $effect microtask. await this._drainQueue(); } catch (e: unknown) { this.streamingState = "error"; this.error = e instanceof Error ? e.message : "Stream failed"; this._persistMessages(); this.loadConversations(true); 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, true); } } finally { this._processingQueue = false; } } cancelGeneration(): void { if (this.streamingState === "idle") return; this._submission?.cancel(); this._userCancelled = true; this.streamingState = "idle"; this._submission = null; this.confirmationMessage = null; this.pendingToolName = null; this.pendingToolArgs = {}; this.pendingConfirmationRisk = null; this.pendingRiskLevel = null; log("AgentChat.Model", "REASON", "Generation cancelled"); } private _clearPendingConfirmation(): void { this.streamingState = "idle"; this._submission = null; this.pendingThreadId = null; this.confirmationMessage = null; this.pendingToolName = null; this.pendingToolArgs = {}; this.pendingConfirmationRisk = null; this.pendingRiskLevel = null; } private async _firstActivityTimeout(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)); const hasActivity = this.partialText.length > 0 || this.partialTokens.length > 0 || this.activeToolCalls.length > 0 || this.streamingState === "awaiting_confirmation" || Boolean(this.pendingThreadId); if (!hasActivity && this.streamingState === "streaming" && !this._userCancelled) { this.error = "Агент не начал отвечать за 60 секунд. Проверьте LLM/Gradio и попробуйте ещё раз."; return false; } return new Promise(() => {}); } /** Commit in-progress streaming text + tool calls as an assistant message. * Called before switching conversations or creating a new one while streaming. */ private _commitStreamingPartial(): void { const partialText = this.partialText; const toolCalls = [...this.activeToolCalls]; if (!partialText && toolCalls.length === 0) return; const text = partialText || toolCalls.map((tc) => `🛠️ ${tc.tool}${tc.output ? " ✅" : ""}`).join("\n") || "[Генерация прервана]"; this.messages = [ ...this.messages, { id: `msg-${Date.now()}`, conversation_id: this.currentConversationId || "", role: "assistant", text, toolCalls, // eslint-disable-next-line svelte/prefer-svelte-reactivity created_at: new Date().toISOString(), }, ]; this._saveToStorage(); log("AgentChat.Model", "REFLECT", "Committed streaming partial on context switch", { textLen: partialText.length, toolCalls: toolCalls.length, conversationId: this.currentConversationId, }); } private _appendLocalDeniedMessage(): void { this.messages = [ ...this.messages, { id: `deny-${Date.now()}`, conversation_id: this.currentConversationId || "", role: "assistant", text: "⏹️ Операция отменена", toolCalls: [], // eslint-disable-next-line svelte/prefer-svelte-reactivity created_at: new Date().toISOString(), }, ]; } async resumeConfirm(action: "confirm" | "deny"): Promise { if (this.streamingState !== "awaiting_confirmation") return; if (!this.pendingThreadId || !this.currentConversationId) { if (action === "deny") { this._appendLocalDeniedMessage(); this._clearPendingConfirmation(); this._persistMessages(); log("AgentChat.Model", "REASON", "HITL deny handled locally without checkpoint", { hasThreadId: Boolean(this.pendingThreadId), hasConversationId: Boolean(this.currentConversationId), }); return; } this.error = "Не удалось подтвердить действие: отсутствует checkpoint. Повторите запрос."; this._clearPendingConfirmation(); log("AgentChat.Model", "EXPLORE", "HITL confirm blocked: missing checkpoint", { hasThreadId: Boolean(this.pendingThreadId), hasConversationId: Boolean(this.currentConversationId), }, this.error); return; } log("AgentChat.Model", "REASON", `HITL resume: ${action}`, { threadId: this.pendingThreadId }); try { this._userCancelled = false; this.streamingState = "streaming"; if (!this._client) { this.streamingState = "error"; this.error = "Agent client not available — please reconnect."; log("AgentChat.Model", "EXPLORE", "HITL resume rejected: _client is null", {}, this.error); return; } this._submission = this._client.submit("chat", [ { text: action === "confirm" ? "confirm" : "deny", files: [] }, null, this.currentConversationId, action, this.userId, this.userJwt, this.envId, this.serializedUIContext(), ], ); // Safety timeout: same pattern as _sendNow — prevents hanging // if Gradio stream closes without a clean completion event. const streamDone = await Promise.race([ this.streamProcessor.processStream(this._submission, this.currentConversationId), this._firstActivityTimeout(60_000), this.streamProcessor.streamCloseWatcher(this._client!, 180_000), new Promise((resolve) => setTimeout(() => resolve(false), 180_000)), ]); if (streamDone === false) { try { this._submission?.return?.(); } catch { /* ignore */ } } if (this.streamingState !== "awaiting_confirmation") { this.streamingState = "idle"; } this._submission = null; this.pendingThreadId = null; this.confirmationMessage = null; this.pendingToolName = null; this.pendingToolArgs = {}; this.pendingConfirmationRisk = null; this.pendingRiskLevel = null; this._persistMessages(); this.loadConversations(true); } 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 { // Guard against concurrent calls — duplicate keys in the sidebar // each block would crash Svelte with each_key_duplicate. if (this._isLoadingConversations) { log("AgentChat.Model", "REFLECT", "Skipping loadConversations (already in progress)"); return; } this._isLoadingConversations = true; 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 || []; const mapped = items.map((item: Record) => ({ id: item.conversation_id ?? item.id ?? "", title: item.title ?? "", updated_at: item.updated_at ?? "", message_count: item.message_count ?? 0, last_role: item.last_role ?? null, has_tool_calls: item.has_tool_calls ?? false, has_error: item.has_error ?? false, risk_level: item.risk_level ?? null, })); if (reset) { this.conversations = mapped; } else { // Defensive dedup: skip items already present (prevents duplicates // from overlapping API pages or stale concurrent calls). const existingIds = this.conversations.map(c => c.id); const newItems = mapped.filter(item => !existingIds.includes(item.id)); this.conversations = [...this.conversations, ...newItems]; } 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); } finally { this._isLoadingConversations = false; } } async searchConversations(query: string): Promise { // Guard against concurrent loads; shares _conversationsPage state. if (this._isLoadingConversations) { log("AgentChat.Model", "REFLECT", "Skipping searchConversations (load already in progress)"); return; } this._isLoadingConversations = true; 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, last_role: item.last_role ?? null, has_tool_calls: item.has_tool_calls ?? false, has_error: item.has_error ?? false, risk_level: item.risk_level ?? null, })); 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); } finally { this._isLoadingConversations = false; } } async loadHistory(conversationId: string | null = null): Promise { this.isLoadingHistory = true; this.error = null; // Cancel any in-flight submission first to prevent stale continuation // from writing to the new conversation's localStorage. // _userCancelled suppresses fallback "Agent unavailable" messages. try { this._submission?.cancel(); } catch { /* ignore */ } this._userCancelled = true; this._submission = null; this.streamingState = "idle"; this.pendingThreadId = null; this.activeToolCalls = []; this.partialText = ""; this.confirmationMessage = null; this.pendingToolName = null; this.pendingToolArgs = {}; this.pendingConfirmationRisk = null; this.pendingRiskLevel = 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: this.cleanVisibleMessageText(msg.text), 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; } } // ── LLM provider health ───────────────────────────────────────── /** Check LLM provider connectivity via backend endpoint. */ async checkLlmStatus(): Promise { try { const resp = await fetch("/api/agent/llm-status"); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = await resp.json(); this.llmStatus = data.status || "unknown"; if (data.status !== "ok" && !this.llmBannerDismissed) { this.llmBannerMessage = this._bannerMessageForStatus(data.status); this._startRetryCountdown(data.retry_after_s || 30); } else if (data.status === "ok") { this.llmBannerDismissed = false; this.llmRetryCountdown = 0; this.llmBannerMessage = ""; } } catch { this.llmStatus = "unknown"; } } private _startRetryCountdown(seconds: number): void { this.llmRetryCountdown = seconds; const interval = setInterval(() => { this.llmRetryCountdown--; if (this.llmRetryCountdown <= 0) { clearInterval(interval); this.checkLlmStatus(); } }, 1000); } private _bannerMessageForStatus(status: string): string { switch (status) { case "unavailable": return t.assistant?.llm_unavailable || "LLM провайдер недоступен. Проверьте подключение к upstream API."; case "timeout": return t.assistant?.llm_timeout || "LLM провайдер не отвечает. Таймаут соединения."; case "auth_error": return t.assistant?.llm_auth_error || "API ключ LLM отклонён. Проверьте credentials."; default: return t.assistant?.llm_unknown_status || "LLM статус неизвестен."; } } async retryConnection(): Promise { return this.connection.retryConnection(); } createConversation(): void { // Commit streaming partial text before starting a new conversation if (this.streamingState === "streaming") { this._commitStreamingPartial(); this._userCancelled = true; try { this._submission?.cancel(); } catch { /* ignore */ } this._submission = null; } 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.confirmationMessage = null; this.pendingToolName = null; this.pendingToolArgs = {}; this.pendingConfirmationRisk = null; this.pendingRiskLevel = null; this._userCancelled = false; 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.map((message) => ({ ...message, text: this.cleanVisibleMessageText(message.text), })); this.currentConversationId = data.conversationId; return true; } return false; } } // #endregion AgentChat.Model