tasks ready 1

This commit is contained in:
2026-06-09 09:43:34 +03:00
parent 8e8a3c3235
commit a7fb06cd8e
23 changed files with 2179 additions and 45 deletions

View File

@@ -0,0 +1,325 @@
// 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.
// @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() — Client.connect() retry.
// @RELATION BINDS_TO -> [AssistantChatStore]
// @RELATION DEPENDS_ON -> [GradioClient:@gradio/client]
// @RELATION DEPENDS_ON -> [AssistantApi]
// @RATIONALE Model-first: extracted from AssistantChatPanel.svelte (1029 lines). Centralizes Gradio submit() lifecycle, metadata parsing, HITL resume, and reconnect logic in a testable unit. Without a model, streaming + metadata dispatch + confirmation + reconnect logic scattered across event handlers would be untestable.
// @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 { Client, 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";
// ── Type definitions ─────────────────────────────────────────────
type StreamingState =
| "idle"
| "streaming"
| "awaiting_confirmation"
| "error"
| "disconnected"
| "disconnected_permanent";
type ConnectionState =
| "connected"
| "disconnected"
| "disconnected_permanent";
interface StreamMetadata {
type?: "stream_token" | "tool_start" | "tool_end" | "tool_error"
| "confirm_required" | "confirm_resolved" | "error";
token?: string;
tool?: string;
input?: Record<string, unknown>;
output?: Record<string, unknown>;
error?: string;
prompt?: string;
thread_id?: string;
result?: "confirmed" | "denied";
code?: string;
detail?: string;
}
interface ToolCall {
tool: string;
input: Record<string, unknown>;
output?: Record<string, unknown>;
error?: string;
status: "executing" | "completed" | "failed";
}
interface AgentMessage {
id: string;
conversation_id: string;
role: "user" | "assistant" | "system" | "tool";
text: string;
metadata?: StreamMetadata;
state?: string;
task_id?: string;
toolCalls: ToolCall[];
created_at: string;
}
interface Conversation {
id: string;
title: string;
updated_at: string;
message_count: number;
}
// ── Reconnect constants ──────────────────────────────────────────
const RECONNECT_MAX_ATTEMPTS = 5;
const RECONNECT_INTERVAL_MS = 5_000;
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("");
activeToolCalls: ToolCall[] = $state([]);
isLoadingHistory: boolean = $state(false);
inputText: string = $state("");
pendingThreadId: string | null = $state(null); // for HITL resume
// ── Private atoms ──────────────────────────────────────────────
private _client: GradioClient | null = null;
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 _reconnectAttempts: number = 0;
private _reconnectTimer: ReturnType<typeof setInterval> | null = null;
// ── 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",
);
// ── Actions — P1 ───────────────────────────────────────────────
async sendMessage(text: string, files?: File[]): Promise<void> {
if (this.streamingState !== "idle" || this.connectionState !== "connected") return;
if (!text.trim() && (!files || files.length === 0)) return;
const convId = this.currentConversationId;
this.streamingState = "streaming";
this.error = null;
this.partialText = "";
this.activeToolCalls = [];
log("AgentChat.Model", "REASON", "Sending message", { text: text.slice(0, 100) });
try {
this._submission = this._client!.submit("/chat",
{ text, files },
[convId, null], // additional_inputs: [conversation_id, action]
);
for await (const event of this._submission) {
const msg: AgentMessage = event.data;
this._onStreamData(msg);
}
this.streamingState = "idle";
this._submission = null;
} catch (e: unknown) {
this.streamingState = "error";
this.error = e instanceof Error ? e.message : "Stream failed";
log("AgentChat.Model", "EXPLORE", "Stream failed", {}, this.error);
}
}
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" },
[this.currentConversationId, action],
);
for await (const event of submission) {
const msg: AgentMessage = event.data;
this._onStreamData(msg);
}
this.streamingState = "idle";
this.pendingThreadId = null;
} catch (e: unknown) {
this.streamingState = "error";
this.error = e instanceof Error ? e.message : "Resume failed";
log("AgentChat.Model", "EXPLORE", "HITL resume failed", {}, this.error);
}
}
async loadConversations(reset: boolean = false): Promise<void> {
log("AgentChat.Model", "REASON", "Loading conversations", { reset });
throw new Error("TODO: implement loadConversations — GET /api/assistant/conversations, infinite scroll, merge or replace list");
}
async loadHistory(conversationId: string | null = null): Promise<void> {
this.isLoadingHistory = true;
this.error = null;
log("AgentChat.Model", "REASON", "Loading history", { conversationId });
throw new Error("TODO: implement loadHistory — GET /api/assistant/history, render messages");
}
async retryConnection(): Promise<void> {
log("AgentChat.Model", "REASON", "Manual reconnect");
this._reconnectAttempts = 0;
throw new Error("TODO: implement retryConnection — Client.connect() to Gradio, reset reconnect counter");
}
createConversation(): void {
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");
}
// ── Actions — P2 ───────────────────────────────────────────────
async deleteConversation(id: string): Promise<void> {
this.conversations = this.conversations.filter((c) => c.id !== id);
log("AgentChat.Model", "REASON", "Archiving conversation (optimistic)", { id });
throw new Error("TODO: implement deleteConversation — DELETE /api/assistant/conversations/{id}, soft-delete (archive), rollback on failure");
}
// ── Private — Stream metadata handler ──────────────────────────
private _onStreamData(msg: AgentMessage): void {
const meta = msg.metadata;
if (!meta || !meta.type) {
// Plain text token — append to last assistant message
this.partialText += msg.text;
return;
}
switch (meta.type) {
case "stream_token":
this.partialText += meta.token ?? "";
break;
case "tool_start":
this.activeToolCalls = [...this.activeToolCalls, {
tool: meta.tool ?? "unknown",
input: meta.input ?? {},
status: "executing",
}];
break;
case "tool_end":
this.activeToolCalls = this.activeToolCalls.map((tc) =>
tc.tool === meta.tool
? { ...tc, output: meta.output, status: "completed" }
: tc
);
break;
case "tool_error":
this.activeToolCalls = this.activeToolCalls.map((tc) =>
tc.tool === meta.tool
? { ...tc, error: meta.error, status: "failed" }
: tc
);
break;
case "confirm_required":
this.streamingState = "awaiting_confirmation";
this.pendingThreadId = meta.thread_id ?? null;
// Primary stream terminates — model enters awaiting_confirmation
// Second submit() will call resumeConfirm()
break;
case "confirm_resolved":
this.streamingState = meta.result === "confirmed" ? "streaming" : "idle";
this.pendingThreadId = null;
break;
case "error":
this.streamingState = "error";
this.error = meta.detail ?? meta.code ?? "Unknown error";
break;
default:
log("AgentChat.Model", "EXPLORE", "Unknown metadata type", { type: meta.type });
}
}
// ── Private — Connection lifecycle ─────────────────────────────
private _onDisconnect(): void {
this.connectionState = "disconnected";
this._reconnectAttempts = 0;
log("AgentChat.Model", "EXPLORE", "Gradio disconnected");
throw new Error("TODO: implement _onDisconnect — start reconnect interval (5×5s), transition to connected or disconnected_permanent");
}
private _onReconnect(): void {
this.connectionState = "connected";
this.streamingState = "idle";
this._reconnectAttempts = 0;
log("AgentChat.Model", "REFLECT", "Gradio reconnected");
throw new Error("TODO: implement _onReconnect — clear timer, restore connected state");
}
}
// #endregion AgentChat.Model