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