diff --git a/frontend/server.ts b/frontend/server.ts index e854757..ff2b6f1 100644 --- a/frontend/server.ts +++ b/frontend/server.ts @@ -201,12 +201,21 @@ class StripModelArtifactsMiddleware extends (Middleware as any) { // counterpart. Caveat: interfered with a2ui retry runs (duplicated the user // message) — a2ui is disabled while that pipeline matures; revisit both // together. +// +// Thread persistence (2026-07-04) adds two exemptions: a hydration replay +// (empty input.messages) is passed through untouched — nothing in it was +// streamed this run — and messages whose id the client already sent in +// input.messages are history, not duplicates. // eslint-disable-next-line @typescript-eslint/no-explicit-any class ReconcileSnapshotMiddleware extends (Middleware as any) { // eslint-disable-next-line @typescript-eslint/no-explicit-any run(input: any, next: any): Observable { const streamedTextIds = new Set(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const inputMessages: any[] = Array.isArray(input?.messages) ? input.messages : []; + const isHydration = inputMessages.length === 0; + const clientKnownIds = new Set(inputMessages.map((m) => String(m?.id ?? ""))); // eslint-disable-next-line @typescript-eslint/no-explicit-any return new Observable((observer) => { @@ -221,7 +230,7 @@ class ReconcileSnapshotMiddleware extends (Middleware as any) { streamedTextIds.add(String(event.messageId)); } - if (type === EventType.MESSAGES_SNAPSHOT) { + if (type === EventType.MESSAGES_SNAPSHOT && !isHydration) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const msgs: any[] = Array.isArray(event?.messages) ? event.messages : []; const filtered = msgs.filter((m) => { @@ -231,7 +240,12 @@ class ReconcileSnapshotMiddleware extends (Middleware as any) { const hasToolCalls = (Array.isArray(m?.toolCalls) && m.toolCalls.length > 0) || (Array.isArray(m?.tool_calls) && m.tool_calls.length > 0); - if (hasText && !hasToolCalls && !streamedTextIds.has(id)) { + if ( + hasText && + !hasToolCalls && + !streamedTextIds.has(id) && + !clientKnownIds.has(id) + ) { return false; // drop orphan text-only assistant duplicate } return true; @@ -384,6 +398,10 @@ app.all("/api/copilotkit/*", async (c) => { context?: { description?: string; value?: string }[]; }; if (!Array.isArray(body.messages)) return; + // An empty messages array is MAF's hydration trigger (known threadId + // + no messages → replay the stored snapshot without invoking the + // LLM). Injecting anything would turn it into a normal run. + if (body.messages.length === 0) return; let messages = pairOrphanToolCalls(body.messages); // MAF's AG-UI endpoint declares `context` on its request model but diff --git a/frontend/src/pages/Asistente.tsx b/frontend/src/pages/Asistente.tsx index 690e573..4c0c307 100644 --- a/frontend/src/pages/Asistente.tsx +++ b/frontend/src/pages/Asistente.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { CopilotChat, @@ -7,10 +7,17 @@ import { useCopilotKit, } from "@copilotkit/react-core/v2"; import { useCopilotAction } from "@copilotkit/react-core"; -import { Sparkles } from "lucide-react"; +import { MessageSquarePlus, Sparkles } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import ConfirmDialog from "@/components/ConfirmDialog"; import { PageHeader } from '@/components/page-header'; import { SpendingSummaryCard, type SpendingSummaryArgs } from "@/components/chat/ChatCards"; +// Single persistent conversation: the backend snapshot store keys on this +// same id (backend/app/api/v1/endpoints/chat.py). An explicit, stable +// threadId also stops CopilotChat from wiping messages on remount. +const CHAT_THREAD_ID = "main"; + const STATIC_SUGGESTIONS = { available: "before-first-message" as const, suggestions: [ @@ -24,28 +31,66 @@ const STATIC_SUGGESTIONS = { export default function Asistente() { useConfigureSuggestions(STATIC_SUGGESTIONS); - // A question typed on the Inicio dashboard arrives via router state: - // append it as a user message and run the agent once. const location = useLocation(); const navigate = useNavigate(); const { agent } = useAgent({ agentId: "wealthysmart" }); const { copilotkit } = useCopilotKit(); - const sentRef = useRef(false); + const [confirmClear, setConfirmClear] = useState(false); + const [clearing, setClearing] = useState(false); + + // Boot sequence, once per page load: hydrate the persisted thread, then + // send any question that arrived from the Inicio ask-box via router state. + // Order matters — hydration first keeps the invariant that every normal + // run's input carries the full client-known history (the BFF's snapshot + // reconciliation relies on it). + const bootRef = useRef(false); useEffect(() => { + if (!agent || bootRef.current) return; + bootRef.current = true; const ask = (location.state as { ask?: string } | null)?.ask; - if (!ask || sentRef.current || !agent) return; - sentRef.current = true; - // Clear the state so back/refresh doesn't re-send the question. - navigate(location.pathname, { replace: true, state: null }); - agent.addMessage({ - id: crypto.randomUUID(), - role: "user", - content: ask, - }); - void copilotkit.runAgent({ agent }); + if (ask) { + // Clear the state so back/refresh doesn't re-send the question. + navigate(location.pathname, { replace: true, state: null }); + } + void (async () => { + agent.threadId = CHAT_THREAD_ID; + if (agent.messages.length === 0) { + // Empty-messages run = MAF's hydration trigger: replays the stored + // snapshot as MESSAGES_SNAPSHOT without invoking the LLM. + try { + await copilotkit.runAgent({ agent }); + } catch (err) { + console.error("[asistente] thread hydration failed:", err); + } + } + if (ask) { + agent.addMessage({ + id: crypto.randomUUID(), + role: "user", + content: ask, + }); + void copilotkit.runAgent({ agent }); + } + })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [agent]); + const clearThread = async () => { + setClearing(true); + try { + await fetch("/api/v1/chat/thread", { + method: "DELETE", + credentials: "include", + }); + agent?.setMessages([]); + setConfirmClear(false); + } catch (err) { + console.error("[asistente] clear thread failed:", err); + } finally { + setClearing(false); + } + }; + useCopilotAction({ name: "render_spending_summary", description: @@ -92,12 +137,35 @@ export default function Asistente() { icon={Sparkles} title="Asistente" subtitle="Pregúntale a WealthySmart sobre tus finanzas." + actions={ + + } /> + {confirmClear && ( + setConfirmClear(false)} + loading={clearing} + /> + )} +