Группы исправлений: - Группа 1 (async/await misuse): MagicMock → AsyncMock для get_dashboards, export_dashboard, import_dashboard, sync_environment, get_run_detail, list_all_runs, create_task и др. — 23 теста - Группа 2 (runner.run deadlock): добавлены моки get_async_job_runner + IdMappingService/AsyncSupersetClient в migration plugin + API tests для предотвращения вечной блокировки future.result() — 16 тестов - Группа 3 (SyntaxError): исправлены 7 незакрытых скобок ')' в test_validation_tasks_comprehensive.py (QA-агент оставил AsyncMock без закрывающих скобок) - Группа 4 (mock verification): logger mock error→explore, scheduler tests skipped (удалён из production), dataset mapper — 11 тестов - Группа 5 (search/assistant): MagicMock → AsyncMock — 9 тестов - Группа 6 (extractor parsing): AsyncMock для async методов — 9 тестов Итого: 61 ранее FAILED → 274 passed, 4 skipped, 0 failed
362 lines
16 KiB
TypeScript
362 lines
16 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 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 -> [AgentChatTypes]
|
||
// @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 class AgentChatModel {
|
||
// ── Atoms ──────────────────────────────────────────────────────
|
||
messages: AgentMessage[] = $state([]);
|
||
conversations: Conversation[] = $state([]);
|
||
currentConversationId: string | null = $state(null);
|
||
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);
|
||
|
||
// ── 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;
|
||
|
||
// ── 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);
|
||
|
||
constructor() {
|
||
const connectionCbs: ConnectionManagerCallbacks = {
|
||
onConnected: (client) => { this._client = client; this.connectionState = "connected"; },
|
||
onDisconnected: () => { this.connectionState = "disconnected"; },
|
||
onDisconnectedPermanent: () => { this.connectionState = "disconnected_permanent"; },
|
||
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) ───────────────────────────────────
|
||
|
||
async sendMessage(text: string, files?: File[]): Promise<void> {
|
||
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;
|
||
const convId = this.currentConversationId;
|
||
this.streamingState = "streaming";
|
||
this.error = null;
|
||
this.partialText = "";
|
||
this.partialTokens = [];
|
||
this.activeToolCalls = [];
|
||
log("AgentChat.Model", "REASON", "Sending message", { text: text.slice(0, 100) });
|
||
|
||
try {
|
||
this._submission = this._client!.submit("chat",
|
||
[{ text, files }, null, convId, null],
|
||
);
|
||
|
||
// Stream watcher: polls Client.stream_status.open. When SSE stream closes
|
||
// (close_stream → abort controller), we call submission.return() to
|
||
// terminate the iterator. If stream doesn't close in 120s — absolute fallback.
|
||
const streamDone = await Promise.race([
|
||
this.streamProcessor.processStream(this._submission, convId),
|
||
this.streamProcessor.streamCloseWatcher(this._client, 120_000),
|
||
]);
|
||
|
||
if (streamDone === false) {
|
||
try { this._submission?.return?.(); } catch { /* ignore */ }
|
||
}
|
||
this.streamingState = "idle";
|
||
this._submission = null;
|
||
this._persistMessages();
|
||
await this._drainQueue();
|
||
} catch (e: unknown) {
|
||
this.streamingState = "error";
|
||
this.error = e instanceof Error ? e.message : "Stream failed";
|
||
this._persistMessages();
|
||
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.streamingState = "idle";
|
||
this._submission = null;
|
||
log("AgentChat.Model", "REASON", "Generation cancelled");
|
||
}
|
||
|
||
async resumeConfirm(action: "confirm" | "deny"): Promise<void> {
|
||
if (this.streamingState !== "awaiting_confirmation") return;
|
||
if (!this.pendingThreadId || !this.currentConversationId) return;
|
||
|
||
log("AgentChat.Model", "REASON", `HITL resume: ${action}`, { threadId: this.pendingThreadId });
|
||
|
||
try {
|
||
const submission = this._client!.submit("chat",
|
||
[{ text: action === "confirm" ? "confirm" : "deny", files: null }, null, this.currentConversationId, action],
|
||
);
|
||
|
||
await this.streamProcessor.processStream(submission, this.currentConversationId);
|
||
this.streamingState = "idle";
|
||
this.pendingThreadId = null;
|
||
this._persistMessages();
|
||
} 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,
|
||
}))
|
||
: [...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,
|
||
}))];
|
||
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,
|
||
}));
|
||
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;
|
||
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 {
|
||
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._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
|