mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 10:28:47 +02:00
Assistant: hydrate the persisted thread in the UI
- Asistente.tsx pins threadId "main" on CopilotChat (an explicit stable id stops the per-mount message wipe), runs a hydration pass on page load — an empty-messages run makes MAF replay the stored snapshot as MESSAGES_SNAPSHOT with no LLM call — and sends the Inicio ask-box question only after hydration so every normal run carries the full client-known history. "Nueva conversación" clears the stored thread behind a ConfirmDialog. - server.ts: the BFF no longer injects context into empty-messages requests (that would defeat MAF's hydration trigger), and the snapshot reconciliation passes hydration replays through untouched and treats ids the client already sent as history rather than duplicates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -201,12 +201,21 @@ class StripModelArtifactsMiddleware extends (Middleware as any) {
|
|||||||
// counterpart. Caveat: interfered with a2ui retry runs (duplicated the user
|
// counterpart. Caveat: interfered with a2ui retry runs (duplicated the user
|
||||||
// message) — a2ui is disabled while that pipeline matures; revisit both
|
// message) — a2ui is disabled while that pipeline matures; revisit both
|
||||||
// together.
|
// 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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
class ReconcileSnapshotMiddleware extends (Middleware as any) {
|
class ReconcileSnapshotMiddleware extends (Middleware as any) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
run(input: any, next: any): Observable<any> {
|
run(input: any, next: any): Observable<any> {
|
||||||
const streamedTextIds = new Set<string>();
|
const streamedTextIds = new Set<string>();
|
||||||
|
// 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<string>(inputMessages.map((m) => String(m?.id ?? "")));
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
return new Observable<any>((observer) => {
|
return new Observable<any>((observer) => {
|
||||||
@@ -221,7 +230,7 @@ class ReconcileSnapshotMiddleware extends (Middleware as any) {
|
|||||||
streamedTextIds.add(String(event.messageId));
|
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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const msgs: any[] = Array.isArray(event?.messages) ? event.messages : [];
|
const msgs: any[] = Array.isArray(event?.messages) ? event.messages : [];
|
||||||
const filtered = msgs.filter((m) => {
|
const filtered = msgs.filter((m) => {
|
||||||
@@ -231,7 +240,12 @@ class ReconcileSnapshotMiddleware extends (Middleware as any) {
|
|||||||
const hasToolCalls =
|
const hasToolCalls =
|
||||||
(Array.isArray(m?.toolCalls) && m.toolCalls.length > 0) ||
|
(Array.isArray(m?.toolCalls) && m.toolCalls.length > 0) ||
|
||||||
(Array.isArray(m?.tool_calls) && m.tool_calls.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 false; // drop orphan text-only assistant duplicate
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -384,6 +398,10 @@ app.all("/api/copilotkit/*", async (c) => {
|
|||||||
context?: { description?: string; value?: string }[];
|
context?: { description?: string; value?: string }[];
|
||||||
};
|
};
|
||||||
if (!Array.isArray(body.messages)) return;
|
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);
|
let messages = pairOrphanToolCalls(body.messages);
|
||||||
|
|
||||||
// MAF's AG-UI endpoint declares `context` on its request model but
|
// MAF's AG-UI endpoint declares `context` on its request model but
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
CopilotChat,
|
CopilotChat,
|
||||||
@@ -7,10 +7,17 @@ import {
|
|||||||
useCopilotKit,
|
useCopilotKit,
|
||||||
} from "@copilotkit/react-core/v2";
|
} from "@copilotkit/react-core/v2";
|
||||||
import { useCopilotAction } from "@copilotkit/react-core";
|
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 { PageHeader } from '@/components/page-header';
|
||||||
import { SpendingSummaryCard, type SpendingSummaryArgs } from "@/components/chat/ChatCards";
|
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 = {
|
const STATIC_SUGGESTIONS = {
|
||||||
available: "before-first-message" as const,
|
available: "before-first-message" as const,
|
||||||
suggestions: [
|
suggestions: [
|
||||||
@@ -24,28 +31,66 @@ const STATIC_SUGGESTIONS = {
|
|||||||
export default function Asistente() {
|
export default function Asistente() {
|
||||||
useConfigureSuggestions(STATIC_SUGGESTIONS);
|
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 location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { agent } = useAgent({ agentId: "wealthysmart" });
|
const { agent } = useAgent({ agentId: "wealthysmart" });
|
||||||
const { copilotkit } = useCopilotKit();
|
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(() => {
|
useEffect(() => {
|
||||||
|
if (!agent || bootRef.current) return;
|
||||||
|
bootRef.current = true;
|
||||||
const ask = (location.state as { ask?: string } | null)?.ask;
|
const ask = (location.state as { ask?: string } | null)?.ask;
|
||||||
if (!ask || sentRef.current || !agent) return;
|
if (ask) {
|
||||||
sentRef.current = true;
|
|
||||||
// Clear the state so back/refresh doesn't re-send the question.
|
// Clear the state so back/refresh doesn't re-send the question.
|
||||||
navigate(location.pathname, { replace: true, state: null });
|
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({
|
agent.addMessage({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
role: "user",
|
role: "user",
|
||||||
content: ask,
|
content: ask,
|
||||||
});
|
});
|
||||||
void copilotkit.runAgent({ agent });
|
void copilotkit.runAgent({ agent });
|
||||||
|
}
|
||||||
|
})();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [agent]);
|
}, [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({
|
useCopilotAction({
|
||||||
name: "render_spending_summary",
|
name: "render_spending_summary",
|
||||||
description:
|
description:
|
||||||
@@ -92,12 +137,35 @@ export default function Asistente() {
|
|||||||
icon={Sparkles}
|
icon={Sparkles}
|
||||||
title="Asistente"
|
title="Asistente"
|
||||||
subtitle="Pregúntale a WealthySmart sobre tus finanzas."
|
subtitle="Pregúntale a WealthySmart sobre tus finanzas."
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setConfirmClear(true)}
|
||||||
|
disabled={!agent || agent.messages.length === 0}
|
||||||
|
>
|
||||||
|
<MessageSquarePlus />
|
||||||
|
Nueva conversación
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{confirmClear && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title="¿Nueva conversación?"
|
||||||
|
message="Se borrará el historial del chat guardado. Esta acción no se puede deshacer."
|
||||||
|
confirmLabel="Borrar historial"
|
||||||
|
onConfirm={clearThread}
|
||||||
|
onCancel={() => setConfirmClear(false)}
|
||||||
|
loading={clearing}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 rounded-xl border border-border overflow-hidden bg-card">
|
<div className="flex-1 min-h-0 rounded-xl border border-border overflow-hidden bg-card">
|
||||||
<CopilotChat
|
<CopilotChat
|
||||||
className="h-full"
|
className="h-full"
|
||||||
|
threadId={CHAT_THREAD_ID}
|
||||||
labels={{
|
labels={{
|
||||||
modalHeaderTitle: "WealthySmart",
|
modalHeaderTitle: "WealthySmart",
|
||||||
welcomeMessageText: "¿Qué quieres saber sobre tus finanzas?",
|
welcomeMessageText: "¿Qué quieres saber sobre tus finanzas?",
|
||||||
|
|||||||
Reference in New Issue
Block a user