Files
ss-tools/frontend/src/lib/models/AgentChatModel.svelte.ts

1055 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<string, unknown> = $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<GradioClient["submit"]> | 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<string, string> = { 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<string, string> = {
"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<void> {
// 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<void> {
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<void> {
// 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<void> {
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<false>((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<void> {
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<false> {
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<false>(() => {});
}
/** 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<void> {
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<false>((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<void> {
// 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<string, unknown>) => ({
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<void> {
// 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<string, unknown>) => ({
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<void> {
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<string, unknown>) => ({
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<void> {
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<void> {
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<void> {
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