Files
ss-tools/frontend/src/lib/models/AgentChatModel.svelte.ts
busya 5aa5fc1fc6 fix(agent): critical agent chat bugs — backend startup & frontend streaming state
Backend (tools.py):
- Add Python docstrings to all 17 @tool functions (LangChain ValueError)
- Add @INVARIANT ADR: docstring requirement documented in module header
- Fix 2 f-string escaped-quote syntax errors (Python 3.13)

Frontend — compile errors (+page.svelte):
- Fix mismatched <button>/</Button> tags
- Fix missing Button import for mobile sidebar close

Frontend — streaming state loss on conversation switch (AgentChatModel):
- Add _commitStreamingPartial() helper — saves in-progress text before cancelling
- selectConversation() commits partial text to OLD conversation before switching
- createConversation() commits partial text before clearing state
- loadHistory() sets _userCancelled=true to suppress fallback messages

Frontend — ConnectionManager:
- Pass error reason through onDisconnectedPermanent callback → model.error

Frontend — null safety:
- Guard _client.submit() calls against null _client in _sendNow and resumeConfirm
2026-06-30 13:25:28 +03:00

740 lines
31 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 {
assistantChatStore,
setAssistantConversationId,
} from "$lib/stores/assistantChat.svelte.js";
import {
getAssistantConversations,
getAssistantHistory,
deleteAssistantConversation,
} from "$lib/api/assistant.js";
import { addToast } from "$lib/toasts.svelte.js";
import { log } from "$lib/cot-logger";
import { type ConnectionManagerCallbacks, ConnectionManager } from "./AgentChat.ConnectionManager.svelte.js";
import { type StreamProcessorHost, StreamProcessor } from "./AgentChat.StreamProcessor.svelte.js";
import { LocalStorageManager } from "./AgentChat.LocalStorage.js";
import type {
StreamingState, ConnectionState, StreamMetadata, ToolCall, AgentMessage, Conversation,
} from "./AgentChatTypes.js";
// ═══ Main model ═════════════════════════════════════════════════
export interface AgentChatModelOptions {
userId?: string;
userJwt?: string;
envId?: string;
}
export type AgentPhase = "ready" | "thinking" | "tooling" | "confirming" | "failed" | "offline";
export type AgentPhaseTone = "success" | "warning" | "destructive" | "muted";
export type ConfirmationRisk = "read" | "write" | "unknown";
export interface AgentQuickAction {
id: "dashboards" | "migration" | "tasks" | "health";
icon: string;
labelKey: string;
descriptionKey: string;
prompt: string;
}
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");
error: string | null = $state(null);
partialText: string = $state("");
partialTokens: string[] = $state([]);
activeToolCalls: ToolCall[] = $state([]);
isLoadingHistory: boolean = $state(false);
inputText: string = $state("");
pendingThreadId: string | null = $state(null);
/** 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);
/** Set to true when user clicks Stop — prevents false 'agent unavailable' fallback. */
_userCancelled: boolean = $state(false);
userId: string = "";
userJwt: string = "";
envId: string = "";
// ── 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 _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 || "Агент-чат")
: "Агент-чат",
);
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";
});
confirmationTone = $derived.by((): AgentPhaseTone => {
if (this.confirmationRisk === "write") return "warning";
if (this.confirmationRisk === "read") return "success";
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") return "confirmation_scope_fallback";
return "confirmation_unknown_description";
});
confirmationMessageOverride = $derived.by(() => {
const message = (this.confirmationMessage || "").trim();
if (!message) return "";
const genericMessages = new Set([
"Подтвердить операцию?",
"Подтвердите действие",
"Confirm operation?",
"Confirm action",
]);
return genericMessages.has(message) ? "" : message;
});
pendingToolLabel = $derived.by(() => {
if (!this.pendingToolName) return "";
return this.pendingToolName
.replace(/_/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase());
});
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 ?? "")
.replace(/\s*\[PRE-FETCHED DATA[\s\S]*$/i, "")
.replace(/\s+Available(?:\s+\S+)*$/i, "")
.replace(/\s+/g, " ")
.trim();
return title || "Новый диалог";
}
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[]): Promise<void> {
if (this._processingQueue) 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.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],
);
// 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.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);
}
} 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;
}
/** 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,
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: [],
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],
);
// 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.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> {
log("AgentChat.Model", "REASON", "Loading conversations", { reset });
try {
if (reset) this._conversationsPage = 1;
const res = await getAssistantConversations(this._conversationsPage, 20, false, "");
const items = res.items || [];
this.conversations = reset
? items.map((item: Record<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.conversations, ...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 load conversations";
log("AgentChat.Model", "EXPLORE", "Load conversations failed", {}, this.error);
}
}
async searchConversations(query: string): Promise<void> {
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);
}
}
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: msg.text as string ?? "",
metadata: msg.metadata as StreamMetadata,
toolCalls: (msg.tool_calls as ToolCall[]) ?? [],
created_at: msg.created_at as string ?? "",
}));
if (res.conversation_id && !this.currentConversationId) {
setAssistantConversationId(res.conversation_id as string);
this.currentConversationId = res.conversation_id as string;
}
} catch (e: unknown) {
this.error = e instanceof Error ? e.message : "Failed to load history";
log("AgentChat.Model", "EXPLORE", "Load history failed", {}, this.error);
} finally {
this.isLoadingHistory = false;
}
}
async retryConnection(): Promise<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;
this.currentConversationId = data.conversationId;
return true;
}
return false;
}
}
// #endregion AgentChat.Model