1055 lines
46 KiB
TypeScript
1055 lines
46 KiB
TypeScript
// 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
|