Migrate frontend to Vite + Hono CopilotKit runtime

Replaces the Next.js scaffold with a Vite SPA paired with a Hono
sidecar that hosts the CopilotKit runtime and proxies AG-UI traffic
to the MAF backend. Adds dev/prod Dockerfile, .dockerignore,
.gitignore, pnpm workspace config, and updates entrypoints
(main.tsx / App.tsx / index.css / index.html) plus the service
worker accordingly.

Server middleware reconciles MAF MESSAGES_SNAPSHOT id mismatches
so post-tool-call assistant text doesn't render twice, suppresses
duplicate text emitted alongside render tools, and strips OpenAI
training-token leaks from streamed deltas.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-04-29 22:01:40 -06:00
parent c4768e6912
commit 5f2a4105f3
14 changed files with 7182 additions and 2067 deletions

15
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,15 @@
node_modules
.next
out
build
coverage
.DS_Store
*.pem
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
.env*
.vercel
*.tsbuildinfo
next-env.d.ts

41
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -1,7 +1,37 @@
FROM node:20-slim # syntax=docker/dockerfile:1.7
RUN corepack enable && corepack prepare pnpm@latest --activate
FROM node:22-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app WORKDIR /app
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* ./
RUN pnpm install --frozen-lockfile RUN corepack enable && pnpm install --frozen-lockfile
# Dev: Vite HMR on port 3000 + Hono CK server on port 3001
FROM node:22-alpine AS dev
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
CMD ["pnpm", "run", "dev", "--", "--host"] ENV NODE_ENV=development
EXPOSE 3000
CMD ["sh", "-c", "corepack enable && pnpm dev"]
# Build Vite SPA
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN corepack enable && pnpm build
# Production: Hono serves dist/ + /api/copilotkit on port 3000
FROM node:22-alpine AS runner
RUN apk add --no-cache libc6-compat
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY server.ts package.json ./
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1
CMD ["sh", "-c", "corepack enable && tsx server.ts"]

View File

@@ -12,8 +12,6 @@
<meta name="description" content="WealthySmart — Smart personal finance management" /> <meta name="description" content="WealthySmart — Smart personal finance management" />
<meta name="theme-color" content="#0f172a" /> <meta name="theme-color" content="#0f172a" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<title>WealthySmart</title> <title>WealthySmart</title>
</head> </head>
<body> <body>

View File

@@ -1,36 +1,46 @@
{ {
"name": "wealthysmart-frontend", "name": "frontend",
"private": true,
"version": "0.1.0", "version": "0.1.0",
"private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "concurrently -k -n vite,ck -c cyan,magenta \"vite --host 0.0.0.0 --port 3000\" \"tsx watch server.ts\"",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "tsx server.ts",
"typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.3.0", "@ag-ui/client": "0.0.52",
"@base-ui/react": "^1.4.1",
"@copilotkit/react-core": "1.56.4",
"@copilotkit/react-ui": "1.56.4",
"@copilotkit/runtime": "1.56.4",
"@fontsource-variable/ibm-plex-sans": "^5.2.8", "@fontsource-variable/ibm-plex-sans": "^5.2.8",
"@fontsource-variable/noto-sans": "^5.2.10", "@fontsource-variable/noto-sans": "^5.2.10",
"@hono/node-server": "^1.14.4",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.562.0", "concurrently": "^9.1.2",
"react": "^19.2.0", "hono": "^4.12.15",
"react-dom": "^19.2.0", "lucide-react": "^1.12.0",
"react-router-dom": "^7.12.0", "react": "19.2.5",
"recharts": "^2.15.4", "react-dom": "19.2.5",
"shadcn": "^4.1.0", "react-router-dom": "^7.6.0",
"recharts": "^3.8.1",
"rxjs": "^7.8.1",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tsx": "^4.19.4",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4",
"@types/react": "^19.2.8", "@types/node": "^20",
"@types/react-dom": "^19.2.3", "@types/react": "^19",
"@vitejs/plugin-react-swc": "^4.2.2", "@types/react-dom": "^19",
"tailwindcss": "^4.1.18", "@vitejs/plugin-react-swc": "^3.9.0",
"typescript": "^5.9.3", "tailwindcss": "^4",
"vite": "^7.2.4" "typescript": "^5",
"vite": "^6.3.5"
} }
} }

8476
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1,3 @@
onlyBuiltDependencies: '["@swc/core", "esbuild"]' ignoredBuiltDependencies:
- sharp
- unrs-resolver

View File

@@ -1,98 +1,4 @@
const CACHE_NAME = 'wealthysmart-v1'; self.addEventListener("install", () => self.skipWaiting());
const STATIC_ASSETS = ['/', '/index.html']; self.addEventListener("activate", async () => {
await self.registration.unregister();
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Network-first for API calls
if (url.pathname.startsWith('/api/')) {
event.respondWith(fetch(request).catch(() => caches.match(request)));
return;
}
// Only handle http(s) requests — skip chrome-extension:// etc.
if (!url.protocol.startsWith('http')) return;
// Cache-first for static assets
if (url.pathname.startsWith('/assets/')) {
event.respondWith(
caches.match(request).then((cached) => cached || fetch(request).then((res) => {
const clone = res.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
return res;
}))
);
return;
}
// Network-first for navigation, fallback to cached index.html
if (request.mode === 'navigate') {
event.respondWith(
fetch(request).catch(() => caches.match('/index.html'))
);
return;
}
// Default: network with cache fallback
event.respondWith(fetch(request).catch(() => caches.match(request)));
});
// --- Push Notifications ---
self.addEventListener('push', (event) => {
if (!event.data) return;
let data;
try {
data = event.data.json();
} catch {
// Fallback for plain-text pushes (e.g. browser test pushes)
data = { title: 'WealthySmart', body: event.data.text() };
}
const options = {
body: data.body,
icon: '/icons/icon-192.png',
badge: '/icons/icon-192.png',
data: { url: data.url || '/' },
vibrate: [200, 100, 200],
tag: 'transaction',
renotify: true,
};
event.waitUntil(self.registration.showNotification(data.title, options));
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = event.notification.data?.url || '/';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
for (const client of windowClients) {
if (client.url.includes(self.location.origin) && 'focus' in client) {
client.navigate(url);
return client.focus();
}
}
return clients.openWindow(url);
})
);
}); });

388
frontend/server.ts Normal file
View File

@@ -0,0 +1,388 @@
import { serve } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent, Middleware, EventType } from "@ag-ui/client";
import { Observable } from "rxjs";
import { Hono } from "hono";
const AGENT_URL =
process.env.AGENT_URL ?? "http://backend:8000/api/v1/agent/agui";
const isProd = process.env.NODE_ENV === "production";
const PORT = parseInt(process.env.PORT ?? (isProd ? "3000" : "3001"));
// ── pairOrphanToolCalls ──────────────────────────────────────────────────────
// CopilotKit's browser-side store sometimes drops the tool-role message
// between turns, producing assistant(toolCalls=[X]) → assistant(text) which
// OpenAI rejects. Inject a synthetic empty tool response for each orphan id.
interface AGUIMessage {
id?: string;
role?: string;
content?: unknown;
toolCalls?: Array<{ id?: string }>;
tool_calls?: Array<{ id?: string }>;
toolCallId?: string;
tool_call_id?: string;
}
function pairOrphanToolCalls(messages: AGUIMessage[]): AGUIMessage[] {
const out: AGUIMessage[] = [];
let pending: string[] = [];
const flush = () => {
for (const callId of pending) {
out.push({ id: `synth-${Math.random().toString(36).slice(2)}`, role: "tool", toolCallId: callId, content: "" });
}
pending = [];
};
for (const msg of messages) {
const role = String(msg?.role ?? "");
if (role === "tool") {
const callId = msg.toolCallId ?? msg.tool_call_id;
if (callId) pending = pending.filter((p) => p !== callId);
out.push(msg);
continue;
}
if (role === "assistant") {
flush();
const toolCalls = msg.toolCalls ?? msg.tool_calls ?? [];
out.push(msg);
for (const tc of toolCalls) {
if (tc?.id) pending.push(String(tc.id));
}
continue;
}
flush();
out.push(msg);
}
flush();
return out;
}
// ── DeduplicateToolCallMiddleware ─────────────────────────────────────────────
// MAF re-emits TOOL_CALL_START for declaration-only calls when they are invoked
// in parallel with other tools, causing verifyEvents to throw an AGUIError.
// This middleware tracks completed tool calls and silently drops any duplicate
// START/ARGS/END events for a call ID that has already been closed.
//
// NOTE: runNextWithState emits { event, messages, state } wrappers; always
// extract `.event` and forward the raw BaseEvent — never the wrapper.
// ── DebugLogMiddleware ──────────────────────────────────────────────────────
// Temporary: logs every event type flowing out to diagnose double-response bug.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class DebugLogMiddleware extends (Middleware as any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
run(input: any, next: any): Observable<any> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new Observable<any>((observer) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sub = (this as any).runNextWithState(input, next).subscribe({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
next: (eventWithState: any) => {
const event = eventWithState.event;
const type: string = event?.type ?? "";
const extra = event?.toolCallName ? ` [${event.toolCallName}]`
: event?.activityType ? ` [${event.activityType}]`
: event?.messageId ? ` [msg:${event.messageId.slice(0, 8)}]`
: "";
console.log(`[DBG] ${type}${extra}`);
if (type === EventType.MESSAGES_SNAPSHOT) {
const msgs = (event as any)?.messages ?? [];
console.log(`[DBG] SNAPSHOT msgs: ${msgs.map((m: any) => `${m.role}:${(m.id ?? "").slice(0,8)}:${typeof m.content === "string" ? m.content.length : "?"}c`).join(" | ")}`);
}
observer.next(event);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: (err: any) => observer.error(err),
complete: () => observer.complete(),
});
return () => sub.unsubscribe();
});
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class DeduplicateToolCallMiddleware extends (Middleware as any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
run(input: any, next: any): Observable<any> {
const open = new Set<string>(); // tool call IDs currently in progress
const closed = new Set<string>(); // tool call IDs that already received END
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new Observable<any>((observer) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sub = (this as any).runNextWithState(input, next).subscribe({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
next: (eventWithState: any) => {
const event = eventWithState.event; // raw BaseEvent
const type: string = event?.type ?? "";
const id: string | undefined = event?.toolCallId;
if (type === EventType.TOOL_CALL_START) {
if (!id || closed.has(id) || open.has(id)) return; // duplicate
open.add(id);
} else if (type === EventType.TOOL_CALL_ARGS || type === EventType.TOOL_CALL_END) {
if (id && closed.has(id)) return; // already completed, drop
if (type === EventType.TOOL_CALL_END && id) {
open.delete(id);
closed.add(id);
}
}
observer.next(event); // emit raw BaseEvent
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: (err: any) => observer.error(err),
complete: () => observer.complete(),
});
return () => sub.unsubscribe();
});
}
}
// ── StripModelArtifactsMiddleware ────────────────────────────────────────────
// Some OpenAI models occasionally leak training-data special tokens such as
// `<|ipynb_marker|>` into completion text. Filter them out of streamed deltas
// before they reach the chat UI.
const ARTIFACT_RE = /<\|[^|>]+\|>/g;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class StripModelArtifactsMiddleware extends (Middleware as any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
run(input: any, next: any): Observable<any> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new Observable<any>((observer) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sub = (this as any).runNextWithState(input, next).subscribe({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
next: (eventWithState: any) => {
const event = eventWithState.event;
if (
event?.type === EventType.TEXT_MESSAGE_CONTENT &&
typeof event?.delta === "string" &&
ARTIFACT_RE.test(event.delta)
) {
const cleaned = event.delta.replace(ARTIFACT_RE, "");
if (cleaned.length === 0) return;
observer.next({ ...event, delta: cleaned });
return;
}
observer.next(event);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: (err: any) => observer.error(err),
complete: () => observer.complete(),
});
return () => sub.unsubscribe();
});
}
}
// ── ReconcileSnapshotMiddleware ──────────────────────────────────────────────
// MAF's `_build_messages_snapshot` (agent_framework_ag_ui/_agent_run.py:686)
// mints a fresh UUID for the post-tool-call assistant text instead of reusing
// the streamed TEXT_MESSAGE_START id. The resulting MESSAGES_SNAPSHOT then
// contains TWO assistant entries: the streamed id (holding the toolCalls) and
// a brand-new id (holding the duplicated text). ag-ui's snapshot merge replaces
// by id then APPENDS unknown ids, so the browser ends up with two assistant
// bubbles for the same answer. Dropping the snapshot entirely fixes the dupe
// but breaks render_a2ui card persistence (cards rely on the snapshot to keep
// the assistant-with-toolCalls message in state past the run). The right fix
// is to drop just the orphan text-only assistant message that has no streamed
// counterpart. Remove once `_agent_run.py:686` reuses `flow.message_id`.
// 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<any> {
const streamedTextIds = new Set<string>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new Observable<any>((observer) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sub = (this as any).runNextWithState(input, next).subscribe({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
next: (eventWithState: any) => {
const event = eventWithState.event;
const type: string = event?.type ?? "";
if (type === EventType.TEXT_MESSAGE_START && event?.messageId) {
streamedTextIds.add(String(event.messageId));
}
if (type === EventType.MESSAGES_SNAPSHOT) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const msgs: any[] = Array.isArray(event?.messages) ? event.messages : [];
const filtered = msgs.filter((m) => {
if (m?.role !== "assistant") return true;
const id = String(m?.id ?? "");
const hasText = typeof m?.content === "string" && m.content.length > 0;
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)) {
return false; // drop orphan text-only assistant duplicate
}
return true;
});
if (filtered.length !== msgs.length) {
observer.next({ ...event, messages: filtered });
return;
}
}
observer.next(event);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: (err: any) => observer.error(err),
complete: () => observer.complete(),
});
return () => sub.unsubscribe();
});
}
}
// ── SuppressRenderToolTextMiddleware ──────────────────────────────────────────
// When the LLM emits text content in the same response as a render tool call
// (render_a2ui or render_spending_summary), the text appears as a duplicate
// below the card. This middleware buffers TEXT_MESSAGE_* events and discards
// them if any render tool call is detected in the same turn.
const RENDER_TOOLS = new Set(["render_a2ui", "render_spending_summary"]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class SuppressRenderToolTextMiddleware extends (Middleware as any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
run(input: any, next: any): Observable<any> {
let renderToolSeen = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const textBuffer: any[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new Observable<any>((observer) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sub = (this as any).runNextWithState(input, next).subscribe({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
next: (eventWithState: any) => {
const event = eventWithState.event; // raw BaseEvent
const type: string = event?.type ?? "";
if (type === EventType.TOOL_CALL_START) {
const toolName: string = event?.toolCallName ?? "";
if (RENDER_TOOLS.has(toolName)) {
renderToolSeen = true;
textBuffer.length = 0; // discard text buffered before we saw the tool call
}
}
if (
type === EventType.TEXT_MESSAGE_START ||
type === EventType.TEXT_MESSAGE_CONTENT ||
type === EventType.TEXT_MESSAGE_END
) {
if (!renderToolSeen) textBuffer.push(event); // buffer raw event
return; // always hold — flush at turn end
}
if (type === EventType.RUN_FINISHED || type === EventType.RUN_ERROR) {
if (!renderToolSeen) {
for (const e of textBuffer) observer.next(e); // flush raw events
}
textBuffer.length = 0;
observer.next(event); // emit raw event
return;
}
observer.next(event); // emit raw event
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: (err: any) => observer.error(err),
complete: () => {
if (!renderToolSeen) {
for (const e of textBuffer) observer.next(e);
}
observer.complete();
},
});
return () => sub.unsubscribe();
});
}
}
// ── Hono app ─────────────────────────────────────────────────────────────────
const app = new Hono();
app.all("/api/copilotkit/*", async (c) => {
const cookieHeader = c.req.header("cookie") ?? "";
const match = cookieHeader.match(/(?:^|;\s*)ws_token=([^;]+)/);
const token = match?.[1];
const agentHeaders: Record<string, string> = token
? { Authorization: `Bearer ${token}` }
: {};
const agent = new HttpAgent({ url: AGENT_URL, headers: agentHeaders });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
agent.use(new SuppressRenderToolTextMiddleware() as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
agent.use(new StripModelArtifactsMiddleware() as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
agent.use(new ReconcileSnapshotMiddleware() as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
agent.use(new DeduplicateToolCallMiddleware() as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
agent.use(new DebugLogMiddleware() as any);
const runtime = new CopilotRuntime({
agents: { wealthysmart: agent },
a2ui: { injectA2UITool: true },
beforeRequestMiddleware: async ({ request: outbound }) => {
if (outbound.method !== "POST") return;
const ct = outbound.headers.get("content-type") ?? "";
if (!ct.includes("application/json")) return;
try {
const body = (await outbound.clone().json()) as { messages?: AGUIMessage[] };
if (!Array.isArray(body.messages)) return;
const paired = pairOrphanToolCalls(body.messages);
if (paired.length === body.messages.length) return;
return new Request(outbound.url, {
method: outbound.method,
headers: outbound.headers,
body: JSON.stringify({ ...body, messages: paired }),
});
} catch {
return;
}
},
});
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
runtime,
serviceAdapter: new ExperimentalEmptyAdapter(),
endpoint: "/api/copilotkit",
});
return handleRequest(c.req.raw as Parameters<typeof handleRequest>[0]);
});
app.get("/api/health", (c) => c.json({ ok: true }));
// In production, serve the Vite build output.
if (isProd) {
app.use("/*", serveStatic({ root: "./dist" }));
// SPA fallback: any non-asset path returns index.html
app.get("/*", serveStatic({ path: "./dist/index.html" }));
}
serve({ fetch: app.fetch, port: PORT }, (info) => {
console.log(`CopilotKit server on port ${info.port} [${isProd ? "prod" : "dev"}]`);
});

View File

@@ -1,30 +1,34 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { AuthProvider, useAuth } from './AuthContext'; import { CopilotKit } from "@copilotkit/react-core";
import { ThemeProvider } from './ThemeContext'; import { AuthProvider, useAuth } from "./AuthContext";
import { PrivacyProvider } from './PrivacyContext'; import { ThemeProvider } from "./contexts/theme-context";
import Layout from './components/Layout'; import { PrivacyProvider } from "./contexts/privacy-context";
import Login from './pages/Login'; import Layout from "./components/Layout";
import Dashboard from './pages/Dashboard'; import LoginPage from "./pages/Login";
import Budget from './pages/Budget'; import Asistente from "./pages/Asistente";
import Analytics from './pages/Analytics'; import Analytics from "./pages/Analytics";
import Salarios from './pages/Salarios'; import Budget from "./pages/Budget";
import Pensions from './pages/Pensions'; import Salarios from "./pages/Salarios";
import Proyecciones from './pages/Proyecciones'; import Pensions from "./pages/Pensions";
import ServiciosMunicipales from './pages/ServiciosMunicipales'; import Proyecciones from "./pages/Proyecciones";
import ServiciosMunicipales from "./pages/ServiciosMunicipales";
function ProtectedRoute({ children }: { children: React.ReactNode }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuth(); const { isAuthenticated, isLoading } = useAuth();
if (isLoading) return null;
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />; return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
} }
function AppRoutes() { function AppRoutes() {
const { isAuthenticated } = useAuth(); const { isAuthenticated, isLoading } = useAuth();
if (isLoading) return null;
return ( return (
<Routes> <Routes>
<Route <Route
path="/login" path="/login"
element={isAuthenticated ? <Navigate to="/" replace /> : <Login />} element={isAuthenticated ? <Navigate to="/asistente" replace /> : <LoginPage />}
/> />
<Route <Route
element={ element={
@@ -33,14 +37,14 @@ function AppRoutes() {
</ProtectedRoute> </ProtectedRoute>
} }
> >
<Route path="/" element={<Dashboard />} /> <Route index element={<Navigate to="/asistente" replace />} />
<Route path="/asistente" element={<Asistente />} />
<Route path="/budget" element={<Budget />} /> <Route path="/budget" element={<Budget />} />
<Route path="/analytics" element={<Analytics />} /> <Route path="/analytics" element={<Analytics />} />
<Route path="/proyecciones" element={<Proyecciones />} /> <Route path="/proyecciones" element={<Proyecciones />} />
<Route path="/salarios" element={<Salarios />} /> <Route path="/salarios" element={<Salarios />} />
<Route path="/pensions" element={<Pensions />} /> <Route path="/pensions" element={<Pensions />} />
<Route path="/servicios-municipales" element={<ServiciosMunicipales />} /> <Route path="/servicios-municipales" element={<ServiciosMunicipales />} />
{/* Redirect old routes */}
<Route path="/transactions" element={<Navigate to="/budget" replace />} /> <Route path="/transactions" element={<Navigate to="/budget" replace />} />
<Route path="/transfers" element={<Navigate to="/budget" replace />} /> <Route path="/transfers" element={<Navigate to="/budget" replace />} />
</Route> </Route>
@@ -54,7 +58,9 @@ export default function App() {
<ThemeProvider> <ThemeProvider>
<PrivacyProvider> <PrivacyProvider>
<AuthProvider> <AuthProvider>
<CopilotKit runtimeUrl="/api/copilotkit" agent="wealthysmart" a2ui={{}}>
<AppRoutes /> <AppRoutes />
</CopilotKit>
</AuthProvider> </AuthProvider>
</PrivacyProvider> </PrivacyProvider>
</ThemeProvider> </ThemeProvider>

View File

@@ -1,12 +1,15 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/noto-sans"; @import "@fontsource-variable/noto-sans";
@import "@fontsource-variable/ibm-plex-sans"; @import "@fontsource-variable/ibm-plex-sans";
@import "tailwindcss";
@import "tw-animate-css";
@import "@copilotkit/react-core/v2/styles.css";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
:root { :root {
--font-sans: "Noto Sans Variable", sans-serif;
--font-heading: "IBM Plex Sans Variable", sans-serif;
--background: oklch(1 0 0); --background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823); --foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0); --card: oklch(1 0 0);
@@ -39,6 +42,8 @@
--sidebar-accent-foreground: oklch(0.21 0.006 285.885); --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32); --sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067); --sidebar-ring: oklch(0.705 0.015 286.067);
--copilot-kit-primary-color: var(--primary);
} }
.dark { .dark {
@@ -73,11 +78,39 @@
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938); --sidebar-ring: oklch(0.552 0.016 285.938);
--copilot-kit-primary-color: var(--primary);
}
/* Wire CopilotKit v2 CSS variables to WealthySmart's dark palette.
The v2 CSS sets --background/--muted/etc directly on [data-copilotkit]
elements (unlayered), overriding inherited values from .dark on <html>.
Using html.dark [data-copilotkit] (specificity 0,2,1) beats the v2's
own .dark [data-copilotkit] (specificity 0,2,0) and restores dark mode. */
html.dark [data-copilotkit] {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.437 0.078 188.216);
--primary-foreground: oklch(0.984 0.014 180.72);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
} }
@theme inline { @theme inline {
--font-sans: 'Noto Sans Variable', sans-serif; --font-sans: var(--font-sans);
--font-heading: 'IBM Plex Sans Variable', sans-serif; --font-heading: var(--font-heading);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -124,9 +157,7 @@
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} font-family: var(--font-sans);
html {
@apply font-sans;
} }
} }

View File

@@ -1,14 +1,10 @@
import { StrictMode } from 'react'; import { StrictMode } from "react";
import { createRoot } from 'react-dom/client'; import { createRoot } from "react-dom/client";
import App from './App'; import App from "./App";
import './index.css'; import "./index.css";
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<App /> <App />
</StrictMode>, </StrictMode>,
); );
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}

View File

@@ -7,17 +7,15 @@
"skipLibCheck": true, "skipLibCheck": true,
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"moduleDetection": "force",
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"strict": true, "strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }
}, },
"include": ["src"] "include": ["src", "server.ts"],
"exclude": ["node_modules"]
} }

View File

@@ -1,19 +1,25 @@
import { defineConfig } from 'vite'; import { defineConfig } from "vite";
import react from '@vitejs/plugin-react-swc'; import react from "@vitejs/plugin-react-swc";
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from "@tailwindcss/vite";
import path from 'path'; import path from "path";
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, './src'), "@": path.resolve(__dirname, "./src"),
}, },
}, },
server: { server: {
proxy: { proxy: {
'/api': { // CopilotKit runtime (Hono server, dev only)
target: 'http://localhost:8001', "/api/copilotkit": {
target: "http://localhost:3001",
changeOrigin: true,
},
// All other API calls → Python backend
"/api": {
target: "http://localhost:8001",
changeOrigin: true, changeOrigin: true,
}, },
}, },