tasks ready 1
This commit is contained in:
325
frontend/src/lib/models/AgentChatModel.svelte.ts
Normal file
325
frontend/src/lib/models/AgentChatModel.svelte.ts
Normal 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
|
||||
Reference in New Issue
Block a user