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
740 lines
31 KiB
TypeScript
740 lines
31 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 {
|
||
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
|