Files
ss-tools/frontend/src/lib/models/AgentChatModel.svelte.ts
2026-06-09 09:43:34 +03:00

326 lines
13 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.
// @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