mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 08:48:48 +02:00
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:
15
frontend/.dockerignore
Normal file
15
frontend/.dockerignore
Normal 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
41
frontend/.gitignore
vendored
Normal 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
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
8476
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1 +1,3 @@
|
|||||||
onlyBuiltDependencies: '["@swc/core", "esbuild"]'
|
ignoredBuiltDependencies:
|
||||||
|
- sharp
|
||||||
|
- unrs-resolver
|
||||||
|
|||||||
@@ -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
388
frontend/server.ts
Normal 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"}]`);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
<AppRoutes />
|
<CopilotKit runtimeUrl="/api/copilotkit" agent="wealthysmart" a2ui={{}}>
|
||||||
|
<AppRoutes />
|
||||||
|
</CopilotKit>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</PrivacyProvider>
|
</PrivacyProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -121,13 +154,11 @@
|
|||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
font-family: var(--font-sans);
|
||||||
html {
|
}
|
||||||
@apply font-sans;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Privacy mode: blur sensitive financial data */
|
/* Privacy mode: blur sensitive financial data */
|
||||||
|
|||||||
@@ -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');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user