feat: add Generative UI section and AI Financial Coach demo

Adds a new "Generative UI and Agentic Frontends" section to the top-level
README, indexing 14 demos with local READMEs for each.

Adds a new full-stack demo: AI Financial Coach Agent
(generative_ui_agents/ai-financial-coach-agent/) — a multi-tool ADK coach
with a CopilotKit + AG-UI frontend.

- Top-level coach LlmAgent routes chat turns to the smallest matching tool:
  update_financial_data (append/merge semantics over income, dependants,
  expenses, debts) or one of four AgentTool wrappers (BudgetAnalysisAgent,
  SavingsStrategyAgent, DebtReductionAgent, FullAnalysisPipeline). Casual
  chat no longer fires the full pipeline on every turn.
- Frontend uses useCoAgent + useCopilotAction(available: "remote") for
  tool-call status pills. Profile / Report tabs (auto-flips on run
  completion). Custom Budget / Savings / Debt cards.
- Green/teal theme, dollar-coin SVG favicon, "AI Financial Coach" title.
This commit is contained in:
GeneralJerel
2026-04-28 07:45:04 -07:00
parent fe8154c0a4
commit 2c92d90bed
493 changed files with 132205 additions and 1 deletions
+18 -1
View File
@@ -101,10 +101,11 @@ streamlit run travel_agent.py
## 📑 Table of Contents
<details open>
<summary><strong>13 categories · Click to expand</strong></summary>
<summary><strong>14 categories · Click to expand</strong></summary>
- [🌱 Starter AI Agents](#-starter-ai-agents)
- [🚀 Advanced AI Agents](#-advanced-ai-agents)
- [🖼️ Generative UI and Agentic Frontends](#-generative-ui-and-agentic-frontends)
- [🎮 Autonomous Game-Playing Agents](#-autonomous-game-playing-agents)
- [🤝 Multi-agent Teams](#-multi-agent-teams)
- [🗣️ Voice AI Agents](#-voice-ai-agents)
@@ -162,6 +163,22 @@ streamlit run travel_agent.py
* [🌐 Openwork - Open Browser Automation Agent](https://github.com/accomplish-ai/openwork) <sub>↗ external</sub>
* [🛡️ Trust-Gated Multi-Agent Research Team](advanced_ai_agents/multi_agent_apps/trust_gated_agent_team/)
### 🖼️ Generative UI and Agentic Frontends
*Agents that render interactive UI components — forms, cards, charts, editable plans — not just text. Built with [CopilotKit](https://github.com/CopilotKit/CopilotKit) and [AG-UI](https://github.com/ag-ui-protocol/ag-ui).*
* [🗂️ Generative UI Starter Project](generative_ui_agents/generative-ui-starter-project/)
* [🪙 AI Financial Coach Agent](generative_ui_agents/ai-financial-coach-agent/)
* [📊 AI Dashboard Canvas Agent](generative_ui_agents/ai-dashboard-canvas-agent/)
* [🛠️ AI MCP App Builder](generative_ui_agents/ai-mcp-app-builder/)
* [✈️ MCP Apps Generative UI Showcase](generative_ui_agents/mcp-apps-generative-ui-showcase/)
* [🎛️ AI Shadcn Component Generator](generative_ui_agents/ai-shadcn-component-generator/)
* [🥋 AG-UI Agent Framework Integration Dojo](generative_ui_agents/ag-ui-integration-dojo/)
* [🎆 Generative UI Full Spectrum Demo](generative_ui_agents/generative-ui-full-spectrum-demo/)
* [🧩 Open Generative UI Demo](generative_ui_agents/open-generative-ui-demo/)
* [🎨 A2UI Component Composer](generative_ui_agents/a2ui-component-composer/)
See [`generative_ui_agents/`](generative_ui_agents/) for the category overview, conventions, and how to contribute a template.
### 🎮 Autonomous Game-Playing Agents
*Agents that play games end-to-end - reasoning, strategy, and action.*
+30
View File
@@ -0,0 +1,30 @@
# 🖼️ Generative UI and Agentic Frontends
**Agents that render UI — not just text.**
Generative UI (Gen UI) apps let an LLM emit rich, interactive frontend components instead of (or in addition to) plain chat messages. The model decides *what to show*, the frontend renders real components, and the user can click, edit, and respond — closing the loop between reasoning and interface.
This section collects self-contained templates for building Gen UI apps across the common stacks:
- **AG-UI / CopilotKit** — streaming agent ↔ UI protocol for React apps
- **Vercel AI SDK** — `streamUI` / React Server Components generative UI
- **LangChain / LangGraph UI** — structured tool calls rendered as components
- **Custom tool-call → component renderers** — minimal DIY patterns in any framework
## What counts as a Gen UI template
A template belongs here if the agent's output drives the UI — e.g. the model calls a tool and the response renders as a form, chart, card, table, or interactive widget that the user can then act on. A chatbot that only streams markdown does **not** count; a chatbot that streams a bookable flight card, an editable plan, or a live-updating dashboard does.
## Template conventions
Each template in this directory should ship with:
- A `README.md` with a 1-paragraph description, a short demo (gif / screenshot / video), setup steps, and the model(s) it runs against
- Frontend source (typically a Next.js or Vite app) with the Gen UI components kept small and readable
- Backend / agent source (Python or TypeScript) that defines the tools and the component-rendering contract
- A working `.env.example` listing every key the template reads
- Provider-agnostic defaults where possible — Claude / Gemini / GPT / open models should all be swappable via config
## Contributing
Have a Gen UI template to add? Open a PR with a new subdirectory under `generative_ui_agents/` following the conventions above. Keep templates self-contained: no shared code across templates, and every template should run from a clean clone in three commands.
@@ -0,0 +1,36 @@
# A2UI Component Composer
A hosted playground for [A2UI](https://github.com/google/A2UI) — Google's declarative protocol for agent-generated UI — wired up to [CopilotKit](https://github.com/CopilotKit/CopilotKit) and [AG-UI](https://github.com/ag-ui-protocol/ag-ui). Describe a widget, watch the agent emit an A2UI JSON spec, and see it render as a real, interactive component.
https://github.com/user-attachments/assets/ca6fdfd9-0216-49bc-ad03-0d671566cb4f
- **Live demo:** [a2ui-composer.ag-ui.com](https://a2ui-composer.ag-ui.com/)
- **Tutorial:** [docs.copilotkit.ai → CopilotKit + A2UI](https://docs.copilotkit.ai/a2a/generative-ui/declarative-a2ui)
- **A2UI protocol:** [`google/A2UI`](https://github.com/google/A2UI)
**Gen UI concept — declarative generative UI.** A2UI sits in the middle of the gen-UI spectrum. The agent doesn't trigger a developer-defined component (controlled), and it doesn't ship its own app (open-ended) — it emits a *structured spec*, and the frontend maps that spec onto a known widget set. Two halves of the contract:
- **Catalogue** — a basic + custom widget gallery and an icon set the agent can reference.
- **Spec** — a JSON document the agent produces; the composer renders it and supports JSON playback so you can replay any composition.
The Composer is the easiest way to *see* declarative gen UI behave: prompt → spec → live widgets, all in one screen, with the JSON visible alongside.
## What you can do
- **Compose** — prompt the agent to build a widget; iterate by refining the prompt.
- **Inspect** — open the JSON A2UI spec the agent emitted; edit it directly.
- **Replay** — play back a saved JSON spec to reproduce any composition.
- **Mix widgets** — combine entries from the basic and custom catalogues into a single layout.
## Learn more
- [A2UI protocol (Google)](https://github.com/google/A2UI)
- [Declarative A2UI tutorial (CopilotKit)](https://docs.copilotkit.ai/a2a/generative-ui/declarative-a2ui)
- [A2UI spec docs (CopilotKit)](https://docs.copilotkit.ai/learn/generative-ui/specs/a2ui)
- [AG-UI protocol](https://docs.ag-ui.com/introduction)
## License
Upstream licenses apply — see [`google/A2UI`](https://github.com/google/A2UI) for the protocol and [`CopilotKit/CopilotKit`](https://github.com/CopilotKit/CopilotKit) for the React integration.
@@ -0,0 +1,43 @@
# AG-UI Agent Framework Integration Dojo
A live, interactive showcase of [AG-UI](https://github.com/ag-ui-protocol/ag-ui) — the open protocol that connects AI agents to user-facing applications — running across every major agent framework side by side.
- **Live demo:** [dojo.ag-ui.com](https://dojo.ag-ui.com/langgraph/feature/tool_based_generative_ui)
- **Source:** [`ag-ui-protocol/ag-ui` → `apps/dojo`](https://github.com/ag-ui-protocol/ag-ui/tree/main/apps/dojo)
**Gen UI concept — protocol-level interop.** Each "feature" in the Dojo (tool-based generative UI, shared state, human-in-the-loop, agentic chat, predictive state updates, etc.) is implemented once in the frontend and wired to multiple agent backends — LangGraph, Mastra, CrewAI, Agno, Pydantic AI, LlamaIndex, ADK, and more. Same UI, same protocol, different agents. The Dojo is how you see *which* generative UI patterns AG-UI supports, *how* they look in practice, and *which* frameworks have shipped support for each.
## What you can explore
| Pattern | What it shows |
|---|---|
| **Tool-Based Generative UI** | Agents emit tool calls that map directly to React components rendered inline. |
| **Shared State** | Bidirectional state sync between the agent and the frontend — both can read and update. |
| **Human-in-the-Loop** | The agent pauses mid-run to ask the user for input or approval. |
| **Agentic Chat** | A baseline chat experience with full AG-UI event streaming. |
| **Predictive State Updates** | Optimistic UI updates streamed from the agent before tools complete. |
Each pattern is implemented per framework so you can compare behavior, latency, and ergonomics across agent stacks without leaving the page.
## Run it locally
The Dojo lives in the AG-UI monorepo. To run it yourself:
```bash
git clone https://github.com/ag-ui-protocol/ag-ui
cd ag-ui
pnpm install
pnpm --filter dojo dev
```
See [`apps/dojo/README.md`](https://github.com/ag-ui-protocol/ag-ui/blob/main/apps/dojo/README.md) in the AG-UI repo for the full setup, including running the framework-specific agent backends.
## Learn more
- [AG-UI protocol docs](https://docs.ag-ui.com/introduction)
- [AG-UI on GitHub](https://github.com/ag-ui-protocol/ag-ui)
- [CopilotKit](https://github.com/CopilotKit/CopilotKit) — the React SDK most Dojo features are built on top of
## License
The upstream Dojo is licensed under the AG-UI repo's license — see [`ag-ui-protocol/ag-ui`](https://github.com/ag-ui-protocol/ag-ui) for details.
@@ -0,0 +1,2 @@
GOOGLE_API_KEY=your-google-api-key-here
AGENT_URL=http://localhost:8000
@@ -0,0 +1,53 @@
# 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
.mastra/
# lock files
package-lock.json
yarn.lock
bun.lockb
# python
agent/venv/
agent/__pycache__/
agent/.venv/
@@ -0,0 +1,59 @@
# Repository Guidelines
## Project Structure & Module Organization
- Frontend (Next.js + TypeScript): `src/app/**` (pages: `page.tsx`, `layout.tsx`, styles: `globals.css`). API route for CopilotKit: `src/app/api/copilotkit/route.ts`.
- Agent (ADK/Python): `agent/agent.py`, virtual env in `agent/.venv`, deps in `agent/requirements.txt`.
- Public assets: `public/`. Config: `next.config.ts`, `tsconfig.json`, `eslint.config.mjs`.
- Scripts: `scripts/run-agent.sh`, `scripts/setup-agent.sh`.
## Build, Test, and Development
- `npm run dev` — runs UI (`next dev --turbopack`) and the Python agent concurrently.
- `npm run dev:ui` — frontend only; useful for UI iteration.
- `npm run dev:agent` — agent only; activates `.venv` and runs `agent.py`.
- `npm run build` — production build for the Next.js app.
- `npm start` — serve the built app.
- `npm run lint` — lint the frontend with Next/ESLint.
- First-time setup installs the agent via `postinstall` (creates `.venv` and installs Python deps).
## Coding Style & Naming Conventions
- TypeScript/React: 2-space indent, PascalCase components, camelCase variables, file-based routing under `src/app/**`.
- Python agent: follow PEP 8; keep modules small and composable.
- Linting: Next.js ESLint config (`npm run lint`). Prefer explicit types in exported APIs.
- Components: colocate with usage; export from an `index.ts` when creating reusable modules.
## Testing Guidelines
- Currently no test harness. When adding tests:
- Frontend: Jest/Vitest in `src/__tests__/` with `*.test.ts(x)`.
- Agent: `pytest` in `agent/tests/` with `test_*.py`.
- Aim for high coverage on data shaping (dashboard spec generation, adapters).
## Commit & Pull Request Guidelines
- Conventional Commits: `type(scope): message`.
- Types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `ci`.
- Example: `feat(charts): support pie charts`.
- PRs: clear description, linked issue, before/after screenshots or JSON spec samples, and testing notes.
- Keep PRs focused; call out env/config changes explicitly.
## Environment, Security & Config
- Place secrets in `.env.local` (frontend) and `agent/.env` (agent). Never commit secrets.
- Example keys (adjust to your provider):
- Frontend: `NEXT_PUBLIC_CPK_ENDPOINT=/api/copilotkit`.
- Agent: `GOOGLE_API_KEY=...` (Gemini), or `OPENAI_API_KEY=...` if applicable.
- Validate/sanitize prompts; avoid logging PII. Prefer `INFO` logs with redaction.
## Charts & CopilotKit Tips
- Dashboard spec (example): `{ "type": "line", "title": "Revenue", "x": "date", "y": "revenue" }`.
- Supported types to target in UI: `line`, `bar`, `pie`
- Naming: use singular `x`/`y` for series
- Recharts via CPK: map spec→props; e.g., `LineChart` with `dataKey={spec.y}` and `XAxis dataKey={spec.x}`;
## Architecture Overview
- Next.js app hosts CopilotKit UI and API route; Python agent performs ADK/Gemini orchestration. `npm run dev` runs both together.
@@ -0,0 +1,21 @@
The MIT License
Copyright (c) Atai Barkai
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,91 @@
# AI Dashboard Canvas Agent
https://github.com/user-attachments/assets/9201d528-573f-43cc-9d31-571c362318a7
---
An agent that populates **live charts, metrics, and real-time data** into a Canvas dashboard instead of just streaming text. Built with [CopilotKit](https://github.com/CopilotKit/CopilotKit), [AG-UI](https://github.com/ag-ui-protocol/ag-ui), and Google's [ADK](https://google.github.io/adk-docs/).
**Gen UI concept — agentic canvas.** The chat is a thin sidebar; the real surface is a persistent Canvas the agent writes into. Charts, KPIs, and panels are addressable artifacts the agent can place, update, and rearrange across turns — closer to a collaborator using a whiteboard than a chatbot returning replies.
---
## 🔧 Quickstart
```bash
# from the awesome-llm-apps repo root
cd generative_ui_agents/ai-dashboard-canvas-agent
# install JS deps + agent
pnpm install # or npm/yarn/bun
# install Python deps separately for the ADK agent
pnpm install:agent
# set your Google API key
cp .env.example .env
# edit .env and set GOOGLE_API_KEY=...
# start UI + agent together
pnpm run dev
```
### 📦 Prerequisites
- Node.js 18+
- Python 3.8+
- Google Makersuite API Key → get one [here](https://makersuite.google.com/)
- Any package manager (pnpm recommended)
💡 Lockfiles (`package-lock.json`, `yarn.lock`, etc.) are gitignored — each dev manages their own.
---
### 🛠 Available Scripts
- `dev` → Start UI + agent (default)
- `dev:debug` → Start with debug logging
- `dev:ui` → Run just the Next.js app
- `dev:agent` → Run just the ADK agent
- `build / start` → Production build + server
- `lint` → Run ESLint
- `install:agent` → Install Python deps inside `agent/.venv`
---
### 🎨 Customization
- **Main UI** → `src/app/page.tsx`
- Change theme/colors and sidebar appearance
- Add new visualization components
- Extend agent logic in `/agent`
---
### 📚 Docs
- [ADK](https://google.github.io/adk-docs/)
- [CopilotKit](https://github.com/CopilotKit/CopilotKit)
- [AG-UI](https://docs.ag-ui.com/introduction)
---
### 🐛 Troubleshooting
**Agent connection issues?**
- Ensure ADK agent runs on port `8000`
- Double-check `GOOGLE_API_KEY`
- Confirm both servers boot without errors
---
### 🤝 Contributing
PRs and issues welcome — this Canvas is meant to be hacked on.
---
### 📄 License
MIT — see [LICENSE](./LICENSE) for details.
@@ -0,0 +1,53 @@
from __future__ import annotations
# ADK imports
from ag_ui_adk import ADKAgent
from google.adk.agents import Agent
from google.adk.agents.callback_context import CallbackContext
from google.adk.tools import google_search, FunctionTool
from google.adk.tools.agent_tool import AgentTool
# Local imports
from modifiers import before_model, after_model
from tools import tools
from instructions import instruction_provider
def on_before_agent(callback_context: CallbackContext):
"""
Initialize dashboard state if it doesn't exist.
"""
return None
search_agent = Agent(
model='gemini-2.0-flash',
name='SearchAgent',
instruction="""
You're a specialist in Google Search
""",
tools=[google_search],
)
dashboard_agent = Agent(
name="DashboardAgent",
model="gemini-2.5-flash",
tools=tools + [AgentTool(agent=search_agent)],
# run-loop modifiers
before_agent_callback=on_before_agent,
before_model_callback=before_model,
after_model_callback = after_model,
# system instructions
instruction=instruction_provider,
)
# Create ADK middleware agent instance
dashboard_agent = ADKAgent(
adk_agent=dashboard_agent,
app_name="dashboard_app",
user_id="demo_user",
session_timeout_seconds=3600,
use_in_memory_services=True
)
@@ -0,0 +1,31 @@
from google.adk.agents.readonly_context import ReadonlyContext
import json
from datetime import datetime
# This is an InstructionProvider
def instruction_provider(context: ReadonlyContext) -> str:
return f"""
You are an intelligent dashboard agent designed to assist users in building and managing interactive dashboards.
Current dashboard state: {context.state}
The current date is {datetime.now().strftime("%Y-%m-%d")}.
**Key Guidelines:**
- Your primary source of truth is the `context.state` and information retrieved via the `SearchAgent`. Do not infer data from message history.
- When asked to build or edit the dashboard, always use the `SearchAgent` to gather necessary data.
- For new dashboards or "rebuild" requests, ensure all old data is cleared before populating with new information.
- Always provide responses grounded in real data. Only generate speculative content if explicitly instructed by the user.
**Dashboard Elements & Tools:**
- Supported chart types:
- `line`: requires `x` (category) and `y` (value)
- `bar`: requires `x` (category) and `y` (value)
- `pie`: requires `x` (category) and `y` (value)
- UI state keys: `pinnedMetrics`, `dashboard.charts`, and `chartData` (a map keyed by chart title).
- Use `add_charts` to provide chart specifications and `set_chart_data` to provide datasets.
**Engagement & Quality:**
- Act as a domain expert, making informed decisions with minimal user guidance when asked to perform dashboard actions.
- When constructing dashboards, aim for a rich and informative display, typically including at least 3 metrics and 2 charts.
"""
@@ -0,0 +1,52 @@
from typing import Optional
# ADK imports
from google.adk.agents.callback_context import CallbackContext
from google.adk.models import LlmResponse, LlmRequest
from google.genai import types
def before_model( callback_context: CallbackContext, llm_request: LlmRequest) -> Optional[LlmResponse]:
"""
Inspects/modifies the LLM request or skips the call.
"""
agent_name = callback_context.agent_name
if agent_name == "DashboardAgent":
original_instruction = llm_request.config.system_instruction or types.Content(role="system", parts=[])
prefix = f"""
You manage a dashboard for a user.
Current dashboard: {callback_context.state}
When asked for the current dashboard, pinned metrics or chart please reference the current dashboard and respond.
"""
if not isinstance(original_instruction, types.Content):
original_instruction = types.Content(role="system", parts=[types.Part(text=str(original_instruction))])
if not original_instruction.parts:
original_instruction.parts.append(types.Part(text=""))
modified_text = prefix + (original_instruction.parts[0].text or "")
original_instruction.parts[0].text = modified_text
llm_request.config.system_instruction = original_instruction
return None
# --- Define the Callback Function ---
def after_model(
callback_context: CallbackContext, llm_response: LlmResponse
) -> Optional[LlmResponse]:
"""Stop the consecutive tool calling of the agent"""
agent_name = callback_context.agent_name
# --- Inspection ---
if agent_name == "DashboardAgent":
if llm_response.content and llm_response.content.parts:
# Assuming simple text response for this example
if llm_response.content.role=='model' and llm_response.content.parts[0].text:
callback_context._invocation_context.end_invocation = True
elif llm_response.error_message:
return None
else:
return None # Nothing to modify
return None
@@ -0,0 +1,8 @@
fastapi
uvicorn[standard]
python-dotenv
pydantic
google-adk
google-genai
ag-ui-adk
tavily-python
@@ -0,0 +1,28 @@
from fastapi import FastAPI
from ag_ui_adk import add_adk_fastapi_endpoint
from agent import dashboard_agent
from dotenv import load_dotenv
load_dotenv()
# Create FastAPI app
app = FastAPI(title="ADK Middleware Dashboard Agent")
# Add the ADK endpoint
add_adk_fastapi_endpoint(app, dashboard_agent, path="/")
if __name__ == "__main__":
import os
import uvicorn
if not os.getenv("GOOGLE_API_KEY"):
print("⚠️ Warning: GOOGLE_API_KEY environment variable not set!")
print(" Set it with: export GOOGLE_API_KEY='your-key-here'")
print(" Get a key from: https://makersuite.google.com/app/apikey")
print()
port = int(os.getenv("PORT", 8000))
should_reload = not (os.getenv("AGENT_RELOAD", "true").lower() == "false")
uvicorn.run("server:app", host="0.0.0.0", port=port, reload=should_reload)
@@ -0,0 +1,38 @@
from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Union, Literal
from enum import Enum
# class IconType(str, Enum):
# users = "users"
# mrr = "mrr"
# conversion = "conversion"
# churn = "churn"
# custom = "custom"
# A single metric description in the dashboard spec, matching src/lib/types.ts
class Metric(BaseModel):
"""A single metric in the dashboard state, should always have a unique id."""
id: str
title: str
value: str
hint: Optional[str] = None
# icon: Optional[IconType] = None
# Flexible record for chart data rows: keys are column names, values are string or number
ChartDataRecord = Dict[str, Union[str, float, int]]
ChartDataMap = Dict[str, List[ChartDataRecord]]
class Chart(BaseModel):
"""A single chart description in the dashboard spec."""
type: Literal["line", "bar", "pie"] = Field(description='Chart type: "line" | "bar" | "pie"')
title: str
x: Optional[str] = None
y: Optional[str] = None
data: List[ChartDataRecord] = Field(default_factory=list)
class Dashboard(BaseModel):
"""A dashboard spec matching the UI shape."""
title: str
pinnedMetrics: List[Metric] = Field(default_factory=list)
charts: List[Chart] = Field(default_factory=list)
@@ -0,0 +1,38 @@
# ADK imports
import os
from google.adk.tools import ToolContext, FunctionTool
from typing import List, Dict
# Local imports
from state import Metric
def add_pinned_metrics(tool_context: ToolContext, new_pinned_metrics: List[Metric]) -> Dict[str, str]:
"""
Add a list of new metrics to the dashboard.
Make sure the metrics all have a unique id.
"""
try:
current_metrics = tool_context.state.get("pinnedMetrics", [])
tool_context.state["pinnedMetrics"] = current_metrics + new_pinned_metrics
return {"status": "success", "message": "Pinned metrics set successfully"}
except Exception as e:
return {"status": "error", "message": f"Error updating pinned metrics: {str(e)}"}
def update_pinned_metrics(tool_context: ToolContext, updated_pinned_metrics: List[Metric]) -> Dict[str, str]:
"""
Update a list of new metrics to the dashboard. Use for updates and removals. You must include the previously
pinned metrics in the list, the end result of this will be the complete list of pinned metrics.
"""
try:
tool_context.state["pinnedMetrics"] = updated_pinned_metrics
return {"status": "success", "message": "Pinned metrics set successfully"}
except Exception as e:
return {"status": "error", "message": f"Error updating pinned metrics: {str(e)}"}
tools = [
# UI owns charts. Agent can provide pinned metrics and do searches.
FunctionTool(func=add_pinned_metrics),
FunctionTool(func=update_pinned_metrics),
]
@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
@@ -0,0 +1,54 @@
{
"name": "adk-starter",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "concurrently \"npm run dev:ui\" \"npm run dev:agent\" --names ui,agent --prefix-colors blue,green --kill-others",
"dev:debug": "LOG_LEVEL=debug npm run dev",
"dev:agent": "./scripts/run-agent.sh || scripts/run-agent.bat",
"dev:ui": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"install:agent": "./scripts/setup-agent.sh || scripts\\setup-agent.bat",
"postinstall": "npm run install:agent"
},
"dependencies": {
"@ag-ui/client": "^0.0.38",
"@ai-sdk/openai": "^1.3.22",
"@copilotkit/react-core": "^1.56.3",
"@copilotkit/react-textarea": "^1.56.3",
"@copilotkit/react-ui": "^1.56.3",
"@copilotkit/runtime": "^1.56.3",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"flowtoken": "^1.0.40",
"geist": "^1.5.1",
"lucide-react": "^0.544.0",
"next": "15.3.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^2.15.4",
"tailwind-merge": "^3.3.1",
"zod": "^3.24.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"concurrently": "^9.1.2",
"tailwindcss": "^4",
"tw-animate-css": "^1.3.8",
"typescript": "^5"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

@@ -0,0 +1,9 @@
@echo off
REM Navigate to the agent directory
cd /d %~dp0\..\agent
REM Activate the virtual environment
call .venv\Scripts\activate.bat
REM Run the agent
.venv\Scripts\python.exe server.py
@@ -0,0 +1,10 @@
#!/bin/bash
# Navigate to the agent directory
cd "$(dirname "$0")/../agent" || exit 1
# Activate the virtual environment
source .venv/bin/activate
# Run the agent
.venv/bin/python server.py
@@ -0,0 +1,14 @@
@echo off
REM Navigate to the agent directory
cd /d "%~dp0\..\agent" || exit /b 1
REM Create virtual environment if it doesn't exist
if not exist ".venv" (
python -m venv .venv
)
REM Activate the virtual environment
call .venv\Scripts\activate.bat
REM Install requirements using pip
pip install -r requirements.txt
@@ -0,0 +1,15 @@
#!/bin/bash
# Navigate to the agent directory
cd "$(dirname "$0")/../agent" || exit 1
# Create virtual environment if it doesn't exist
if [ ! -d ".venv" ]; then
python3 -m venv .venv || python -m venv .venv
fi
# Activate the virtual environment
source .venv/bin/activate
# Install requirements using pip3 or pip
(pip3 install -r requirements.txt || pip install -r requirements.txt)
@@ -0,0 +1,33 @@
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
import { NextRequest } from "next/server";
// 1. You can use any service adapter here for multi-agent support. We use
// the empty adapter since we're only using one agent.
const serviceAdapter = new ExperimentalEmptyAdapter();
// 2. Create the CopilotRuntime instance and utilize the ADK integration
// integration to setup the connection.
const runtime = new CopilotRuntime({
agents: {
// Our FastAPI endpoint URL
my_agent: new HttpAgent({
url: process.env.AGENT_URL || "http://localhost:8000/",
}),
},
});
// 3. Build a Next.js API route that handles the CopilotKit runtime requests.
export const POST = async (req: NextRequest) => {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
runtime,
serviceAdapter,
endpoint: "/api/copilotkit",
});
return handleRequest(req);
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,174 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
/* Updated to Apple-inspired B2B SaaS color palette */
--background: #f8fafc; /* Light gray for overall background */
--foreground: #374151; /* Dark gray for primary text */
--card: #ffffff; /* Pure white card background */
--card-foreground: #374151; /* Dark gray card text */
--primary: #374151; /* Primary text color */
--primary-foreground: #f8fafc; /* Primary text on dark background */
--secondary: #6366f1; /* Blue accent color */
--secondary-foreground: #ffffff; /* Secondary text */
--muted: #f8fafc; /* Muted background */
--muted-foreground: #4b5563; /* Muted text */
--accent: #6366f1; /* Blue accent for highlights */
--accent-foreground: #ffffff; /* Accent text */
--destructive: #be123c; /* Error messages */
--destructive-foreground: #ffffff;
--border: #e5e7eb; /* Light gray borders */
--input: #ffffff; /* Input background */
--ring: rgba(99, 102, 241, 0.5); /* Blue focus ring */
--chart-1: #6366f1;
--chart-2: #10b981;
--chart-3: #f59e0b;
--chart-4: #ef4444;
--chart-5: #8b5cf6;
--radius: 0.5rem; /* Subtle corner rounding */
--sidebar: #ffffff; /* Sidebar background */
--sidebar-foreground: #374151; /* Sidebar text */
--sidebar-primary: #f8fafc; /* Sidebar primary background */
--sidebar-primary-foreground: #374151; /* Sidebar primary text */
--sidebar-accent: #6366f1; /* Sidebar accent */
--sidebar-accent-foreground: #ffffff; /* Sidebar accent text */
--sidebar-border: #e5e7eb; /* Sidebar border */
--sidebar-ring: rgba(99, 102, 241, 0.5); /* Sidebar focus ring */
--font-geist: "Geist", sans-serif;
--font-manrope: "Manrope", sans-serif;
}
.dark {
--background: #1f2937;
--foreground: #f8fafc;
--card: #374151;
--card-foreground: #f8fafc;
--primary: #6366f1;
--primary-foreground: #ffffff;
--secondary: #374151;
--secondary-foreground: #f8fafc;
--muted: #4b5563;
--muted-foreground: #9ca3af;
--accent: #6366f1;
--accent-foreground: #ffffff;
--destructive: #be123c;
--destructive-foreground: #ffffff;
--border: #4b5563;
--input: #374151;
--ring: rgba(99, 102, 241, 0.5);
--chart-1: #6366f1;
--chart-2: #10b981;
--chart-3: #f59e0b;
--chart-4: #ef4444;
--chart-5: #8b5cf6;
--sidebar: #374151;
--sidebar-foreground: #f8fafc;
--sidebar-primary: #4b5563;
--sidebar-primary-foreground: #f8fafc;
--sidebar-accent: #6366f1;
--sidebar-accent-foreground: #ffffff;
--sidebar-border: #4b5563;
--sidebar-ring: rgba(99, 102, 241, 0.5);
}
@theme inline {
/* Default to Manrope as app-wide sans font */
--font-sans: var(--font-manrope);
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono:
var(--font-geist-mono), ui-monospace, SFMono-Regular, "SF Mono", Monaco,
Consolas, "Liberation Mono", "Courier New", monospace;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
}
body,
html {
height: 100%;
}
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
/* Fix flowtoken text wrapping issues */
@layer components {
.prose .flowtoken-container,
.prose [class*="flowtoken"] {
word-wrap: break-word !important;
overflow-wrap: break-word !important;
hyphens: auto !important;
white-space: normal !important;
}
.prose .flowtoken-container p,
.prose [class*="flowtoken"] p {
word-wrap: break-word !important;
overflow-wrap: break-word !important;
hyphens: auto !important;
white-space: normal !important;
}
}
@@ -0,0 +1,38 @@
import type { Metadata } from "next";
import { CopilotKit } from "@copilotkit/react-core";
import { Manrope } from "next/font/google";
import { GeistMono } from "geist/font/mono";
import "./globals.css";
import "@copilotkit/react-ui/styles.css";
const manrope = Manrope({
subsets: ["latin"],
display: "swap",
variable: "--font-manrope",
});
export const metadata: Metadata = {
title: "Gen Dashboard",
description: "AI-powered analytics dashboard",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className={`${manrope.variable} ${GeistMono.variable}`}>
<body className={"subpixel-antialiased"}>
<CopilotKit
runtimeUrl="/api/copilotkit"
agent="my_agent"
showDevConsole={false}
>
{children}
</CopilotKit>
</body>
</html>
);
}
@@ -0,0 +1,25 @@
"use client";
import { CopilotKitCSSProperties } from "@copilotkit/react-ui";
import { Chat } from "@/components/chat/chat";
import { MobileChat } from "@/components/chat/mobile-chat";
import { MainLayout } from "@/components/dashboard/dashboard";
import { useIsMobile } from "@/lib/isMobile";
export default function CopilotKitPage() {
const isMobile = useIsMobile();
return (
<main
style={
{ "--copilot-kit-primary-color": "#6366f1" } as CopilotKitCSSProperties
}
className="min-h-screen bg-background text-foreground antialiased grid grid-cols-3 md:grid-cols-3 grid-cols-1 lg:gap-10"
>
{isMobile ? <MobileChat /> : <Chat className="h-full max-h-screen" />}
<div className="col-span-3 lg:col-span-2 overflow-y-auto max-h-screen">
<MainLayout className="w-full" />
</div>
</main>
);
}
@@ -0,0 +1,311 @@
import { useCopilotAction } from "@copilotkit/react-core";
import type {
AgentState,
ChartSpec,
LineChartSpec,
BarChartSpec,
PieChartSpec,
Chart,
ChartDataRecord,
AgentSetState,
} from "@/lib/types";
import { ChartCard } from "@/components/dashboard/charts";
import { Button } from "@/components/ui/button";
interface UseChartActionsProps {
state: AgentState;
setState: AgentSetState<AgentState>;
}
export const useChartActions = ({ state, setState }: UseChartActionsProps) => {
// Add Chart Action
useCopilotAction(
{
name: "add_chart",
description:
"Add a chart to the dashboard by type. You *must* populate the data, do not create empty charts.",
parameters: [
{ name: "type", type: "string", required: true },
{ name: "title", type: "string", required: true },
{ name: "x", type: "string", required: false },
{ name: "y", type: "string", required: false },
{ name: "data", type: "object[]", required: false },
],
renderAndWaitForResponse: ({ args, respond, status }) => {
const { type, title, x, y, data } = args as {
type: string;
title: string;
x?: string;
y?: string;
data?: ChartDataRecord[];
};
let spec: ChartSpec | null = null;
if (type === "line") {
spec = {
type: "line",
title,
x: x ?? "x",
y: y ?? "y",
} as LineChartSpec;
} else if (type === "bar") {
spec = {
type: "bar",
title,
x: x ?? "x",
y: y ?? "y",
} as BarChartSpec;
} else if (type === "pie") {
spec = {
type: "pie",
title,
x: x ?? "category",
y: y ?? "value",
} as PieChartSpec;
}
if (!spec) {
respond?.("Unsupported chart type");
return <></>;
}
const dataRecords: ChartDataRecord[] = Array.isArray(data)
? (data as ChartDataRecord[])
: [];
const chart: Chart = { ...spec, data: dataRecords };
const onHumanResponse = (shouldProceed: boolean) => {
if (!shouldProceed) {
respond?.(
"User declined adding the chart. This is not an issuue, they just don't want to add it.",
);
return;
}
setState({
...state,
charts: [...(state?.charts || []), chart],
});
respond?.({
status: "success",
message: "Added chart successfully!",
});
};
return (
<ChartCard
spec={spec}
onHumanInput={onHumanResponse}
status={status}
chartData={{ [title]: dataRecords }}
/>
);
},
},
[state],
);
// Update Chart Action
useCopilotAction(
{
name: "update_chart",
description:
"Update an existing chart on the dashboard. Provide the current title to identify which chart to update.",
parameters: [
{ name: "currentTitle", type: "string", required: true },
{ name: "type", type: "string", required: false },
{ name: "title", type: "string", required: false },
{ name: "x", type: "string", required: false },
{ name: "y", type: "string", required: false },
{ name: "data", type: "object[]", required: false },
],
renderAndWaitForResponse: ({ args, respond, status }) => {
const { currentTitle, type, title, x, y, data } = args as {
currentTitle: string;
type?: string;
title?: string;
x?: string;
y?: string;
data?: ChartDataRecord[];
};
const currentCharts = state?.charts || [];
const chartIndex = currentCharts.findIndex(
(chart) =>
("title" in chart ? chart.title : "Untitled") === currentTitle,
);
if (chartIndex === -1) {
respond?.(
`Chart with title "${currentTitle}" not found. Available charts: ${currentCharts.map((c) => ("title" in c ? c.title : "Untitled")).join(", ")}`,
);
return <></>;
}
const existingChart = currentCharts[chartIndex];
const newType = type || existingChart.type;
const newTitle =
title ||
("title" in existingChart ? existingChart.title : "Untitled");
let spec: ChartSpec | null = null;
if (newType === "line") {
spec = {
type: "line",
title: newTitle,
x: x ?? ("x" in existingChart ? existingChart.x : "x"),
y: y ?? ("y" in existingChart ? existingChart.y : "y"),
} as LineChartSpec;
} else if (newType === "bar") {
spec = {
type: "bar",
title: newTitle,
x: x ?? ("x" in existingChart ? existingChart.x : "x"),
y: y ?? ("y" in existingChart ? existingChart.y : "y"),
} as BarChartSpec;
} else {
spec = {
type: "pie",
title: newTitle,
x: x ?? ("x" in existingChart ? existingChart.x : "category"),
y: y ?? ("y" in existingChart ? existingChart.y : "value"),
} as PieChartSpec;
}
if (!spec) {
respond?.("Unsupported chart type");
return <></>;
}
const newData = Array.isArray(data) ? data : existingChart.data;
const updatedChart: Chart = { ...spec, data: newData };
const onHumanResponse = (shouldProceed: boolean) => {
if (!shouldProceed) {
respond?.({
status: "success",
message: "User declined updating the chart.",
});
return;
}
const updatedCharts = [...currentCharts];
updatedCharts[chartIndex] = updatedChart;
setState({
...state,
charts: updatedCharts,
});
respond?.({
status: "success",
message: "Updated chart successfully!",
});
};
return (
<>
<div className="mb-4">
<p className="text-sm text-muted-foreground mb-2">
Updating chart: <strong>{currentTitle}</strong>
</p>
</div>
<ChartCard
spec={spec}
onHumanInput={onHumanResponse}
status={status}
chartData={{ [newTitle]: newData }}
actionButtonText="Update"
/>
</>
);
},
},
[state],
);
// Delete Chart Action
useCopilotAction(
{
name: "delete_chart",
description:
"Delete a chart from the dashboard. Provide the title of the chart to delete.",
parameters: [{ name: "title", type: "string", required: true }],
renderAndWaitForResponse: ({ args, respond, status }) => {
const { title } = args as { title: string };
const currentCharts = state?.charts || [];
const chartIndex = currentCharts.findIndex(
(chart) => ("title" in chart ? chart.title : "Untitled") === title,
);
if (chartIndex === -1) {
respond?.(
`Chart with title "${title}" not found. Available charts: ${currentCharts.map((c) => ("title" in c ? c.title : "Untitled")).join(", ")}`,
);
return <></>;
}
const chartToDelete = currentCharts[chartIndex];
if (!chartToDelete) {
return <></>;
}
const onHumanResponse = (shouldProceed: boolean) => {
if (!shouldProceed) {
respond?.({
status: "success",
message: "User declined deleting the chart.",
});
return;
}
const updatedCharts = currentCharts.filter(
(_, index) => index !== chartIndex,
);
setState({
...state,
charts: updatedCharts,
});
respond?.({
status: "success",
message: "Deleted chart successfully!",
});
};
return (
<div className="space-y-4">
<div className="p-4 border border-destructive/20 bg-destructive/5 rounded-lg">
<h3 className="font-medium text-destructive mb-2">
Delete Chart
</h3>
<p className="text-sm text-muted-foreground mb-3">
Are you sure you want to delete the chart:{" "}
<strong>{title}</strong>?
</p>
<div className="border border-border rounded-lg p-3 bg-background">
<ChartCard
spec={chartToDelete}
chartData={{ [title]: chartToDelete.data }}
/>
</div>
</div>
{status !== "complete" && (
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => onHumanResponse(false)}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => onHumanResponse(true)}
>
Delete
</Button>
</div>
)}
</div>
);
},
},
[state],
);
};
@@ -0,0 +1,2 @@
export { useChartActions } from "./charts";
export { useSearchActions } from "./search";
@@ -0,0 +1,37 @@
import { useCopilotAction } from "@copilotkit/react-core";
import { Card, CardContent } from "@/components/ui/card";
import { Loader2, Check } from "lucide-react";
export const useSearchActions = () => {
useCopilotAction({
name: "SearchAgent",
available: "disabled",
description: "Search the internet for the query.",
render: ({ args, status }) => {
const { request } = args;
return (
<Card className="text-sm m-0 rounded-lg p-0 px-1">
<CardContent className="flex flex-row items-center justify-center p-2">
{status !== "complete" ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
<div className="truncate">
Searching the internet for:{" "}
<span className="font-bold">{request}</span>
</div>
</>
) : (
<>
<Check className="h-4 w-4 mr-2 text-accent" />
<div className="truncate">
Searched the internet for:{" "}
<span className="font-bold">{request}</span>
</div>
</>
)}
</CardContent>
</Card>
);
},
});
};
@@ -0,0 +1,48 @@
import { CopilotChat } from "@copilotkit/react-ui";
import { SidebarInput } from "@/components/chat/layout/input";
import { AssistantBubble } from "@/components/chat/layout/assistant-message";
import { UserBubble } from "@/components/chat/layout/user-message";
import { Suggestions } from "@/components/chat/layout/suggestion";
import { cn } from "@/lib/utils";
import { Header } from "@/components/chat/layout/header";
interface ChatProps {
className: string;
}
export function Chat({ className }: ChatProps) {
return (
<div className={cn(className, "p-4 max-w-[500px]")}>
<div className="h-full min-h-0 rounded-2xl border bg-card shadow-xl overflow-hidden flex flex-col">
<Header />
<CopilotChat
className="flex-1 min-h-0"
labels={{
initial:
"👋 Hi! \n\nI'm here to help you build beautiful dashboards with data from Google Search.\n\nTry some of the suggestions below or ask me to build anything!",
}}
suggestions={[
{
title: "Pizza sales",
message:
"Please rebuild the dashboard to help me keep track of the current trends in the Pizza industry.",
},
{
title: "AI growth",
message:
"Please rebuild the dashboard to help me keep track of the current trends in the AI industry.",
},
{
title: "Music trends",
message:
"Please rebuild the dashboard to help me keep track of the current trends in the Music industry.",
},
]}
Input={SidebarInput}
AssistantMessage={AssistantBubble}
UserMessage={UserBubble}
RenderSuggestionsList={Suggestions}
/>
</div>
</div>
);
}
@@ -0,0 +1,29 @@
"use client";
import type { AssistantMessageProps } from "@copilotkit/react-ui";
import { Markdown } from "@/components/chat/layout/markdown";
import { Cursor } from "@/components/chat/layout/cursor";
export function AssistantBubble({
message,
isGenerating,
isLoading,
}: AssistantMessageProps) {
const content = message?.content ?? "";
if (!message) return null;
if (!content && !isLoading && !isGenerating && !message.generativeUI) {
return null;
}
if (isLoading && !message.generativeUI) return <Cursor className="mt-3" />;
return (
<div className="py-2">
<div className="text-foreground rounded-lg p-3">
<Markdown content={content} />
</div>
{message.generativeUI?.()}
</div>
);
}
@@ -0,0 +1,12 @@
import { cn } from "@/lib/utils";
export const Cursor = ({ className }: { className?: string }) => {
return (
<div
className={cn(
"w-3 h-3 ml-3 rounded-full bg-accent/10 border border-accent/40 animate-pulse",
className,
)}
/>
);
};
@@ -0,0 +1,12 @@
export function Header() {
return (
<div className="px-4 py-3 border-b flex items-center gap-2 bg-accent/10">
<div className="inline-flex items-center justify-center rounded-full border border-accent/40 bg-card size-8 p-2">
<span className="text-lg p-2">🪁</span>
</div>
<span className="text-base font-medium text-foreground">
ADK Dashboard Agent
</span>
</div>
);
}
@@ -0,0 +1,68 @@
"use client";
import { useState } from "react";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import type { InputProps as CpkInputProps } from "@copilotkit/react-ui";
import { ArrowUp, Square } from "lucide-react";
export function SidebarInput({
inProgress,
onSend,
onStop,
hideStopButton,
}: CpkInputProps) {
const [text, setText] = useState("");
const canSend = !inProgress && text.trim().length > 0;
const submit = async () => {
if (canSend) {
setText("");
await onSend(text);
}
};
return (
<div className="p-3 px-4 bg-card">
<div className="relative mx-auto max-w-none">
<div className="rounded-xl border bg-card shadow-sm px-3 py-2 flex items-end gap-2">
<Textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Ask for anything"
className="min-h-20 border-0 focus-visible:ring-0 px-0 resize-none shadow-none max-h-48 pb-4 px-2 bg-white"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
}}
/>
{inProgress && !hideStopButton ? (
<Button
size="icon"
variant="ghost"
onClick={onStop}
title="Stop generating"
className="text-accent hover:text-white"
>
<Square className="size-3 animate-pulse" />
</Button>
) : (
<Button
size="icon"
variant="ghost"
disabled={!canSend}
onClick={submit}
title="Send"
>
<ArrowUp className="size-4" />
</Button>
)}
</div>
<p className="mt-2 text-[11px] text-muted-foreground text-center">
Press Enter to send Shift+Enter for new line
</p>
</div>
</div>
);
}
@@ -0,0 +1,13 @@
"use client";
import React from "react";
import { Markdown as CpkMarkdown } from "@copilotkit/react-ui";
export function Markdown({ content }: { content: string }) {
return (
<div className="prose dark:prose-invert max-w-none text-sm leading-relaxed">
<div className="word-wrap-normal break-words hyphens-auto">
<CpkMarkdown content={content} />
</div>
</div>
);
}
@@ -0,0 +1,37 @@
import {
CopilotChatSuggestion,
RenderSuggestionsListProps,
} from "@copilotkit/react-ui";
import "@copilotkit/react-ui/styles.css";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { useCopilotChat } from "@copilotkit/react-core";
export const Suggestions = (props: RenderSuggestionsListProps) => {
const { isLoading } = useCopilotChat();
if (isLoading) return null;
return (
<div className="suggestions flex flex-col">
<div className="flex gap-2">
{props.suggestions.map((suggestion: CopilotChatSuggestion, index) => (
<Card
className={cn(
"rounded-lg bg-card text-card-foreground border border-border px-3 py-2 shadow-sm",
"hover:cursor-pointer hover:bg-accent/10 hover:border-accent/40",
"transition-all duration-200",
)}
key={index}
onClick={() => props.onSuggestionClick(suggestion.message)}
>
<CardContent className="px-0 py-0 text-sm font-medium">
{" "}
{suggestion.title}{" "}
</CardContent>
</Card>
))}
</div>
</div>
);
};
@@ -0,0 +1,14 @@
"use client";
import type { UserMessageProps } from "@copilotkit/react-ui";
import { Card, CardContent } from "@/components/ui/card";
export function UserBubble({ message }: UserMessageProps) {
const content = message?.content ?? "";
return (
<div className="flex justify-end mb-4 mt-4">
<Card className="max-w-[80%] bg-accent/10 text-card-foreground border border-accent/40 rounded-lg text-sm whitespace-pre-wrap p-0">
<CardContent className="px-3 py-2">{content}</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,39 @@
"use client";
import { CopilotSidebar } from "@copilotkit/react-ui";
import { SidebarInput } from "@/components/chat/layout/input";
import { AssistantBubble } from "@/components/chat/layout/assistant-message";
import { UserBubble } from "@/components/chat/layout/user-message";
import { Suggestions } from "@/components/chat/layout/suggestion";
export function MobileChat() {
return (
<CopilotSidebar
labels={{
title: "🪁 ADK Dashboard Agent",
initial: "👋 Hi! Describe a dashboard and I'll build it.",
}}
suggestions={[
{
title: "Pizza sales",
message:
"Please update the dashboard to help me keep track of the current trends in the Pizza industry.",
},
{
title: "AI growth",
message:
"Please update the dashbaord to help me keep track of the current trends in the AI industry.",
},
{
title: "Music trends",
message:
"Please update the dashbaord to help me keep track of the current trends in the Music industry.",
},
]}
Input={SidebarInput}
AssistantMessage={AssistantBubble}
UserMessage={UserBubble}
RenderSuggestionsList={Suggestions}
/>
);
}
@@ -0,0 +1,60 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { RenderFunctionStatus } from "@copilotkit/react-core";
import type { ChartSpec, ChartDataMap } from "@/lib/types";
import { ChartRenderer } from "./chart-renderer";
import { ChartTypeIcon } from "./chart-type-icon";
interface ChartCardProps {
spec: ChartSpec;
onHumanInput?: (shouldProceed: boolean) => void;
status?: RenderFunctionStatus;
chartData?: ChartDataMap;
actionButtonText?: string;
}
export const ChartCard = ({
spec,
onHumanInput,
status,
chartData,
actionButtonText = "Add",
}: ChartCardProps) => {
const title = "title" in spec ? spec.title : undefined;
const data = (title && chartData?.[title]) || [];
return (
<>
<Card className="group">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base inline-flex items-center gap-2">
<ChartTypeIcon spec={spec} />
{"title" in spec ? spec.title : "Untitled"}
</CardTitle>
{status !== "complete" && onHumanInput && (
<button
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
title="Remove chart"
></button>
)}
</CardHeader>
<CardContent>
<ChartRenderer spec={spec} data={data} />
</CardContent>
</Card>
{status !== "complete" && onHumanInput && (
<div className="flex justify-end gap-2 py-4">
<Button variant="outline" onClick={() => onHumanInput(false)}>
Cancel
</Button>
<Button
className="bg-accent/10 border border-accent/40 text-black hover:bg-accent/20"
onClick={() => onHumanInput(true)}
>
{actionButtonText}
</Button>
</div>
)}
</>
);
};
@@ -0,0 +1,164 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Trash2, Pencil } from "lucide-react";
import type {
Chart,
ChartSpec,
ChartDataMap,
LineChartSpec,
BarChartSpec,
PieChartSpec,
} from "@/lib/types";
import { ChartRenderer } from "./chart-renderer";
import { ChartTypeIcon } from "./chart-type-icon";
interface ChartGridProps {
charts: Chart[];
onRemoveChart: (index: number) => void;
onEditChart: (index: number, newSpec: ChartSpec) => void;
chartData?: ChartDataMap;
}
export function ChartGrid({
charts,
onRemoveChart,
onEditChart,
}: ChartGridProps) {
const [editIndex, setEditIndex] = useState<number | null>(null);
const [draft, setDraft] = useState<ChartSpec | null>(null);
const startEdit = (index: number) => {
setEditIndex(index);
setDraft(charts[index]);
};
const handleSave = (newSpec: ChartSpec) => {
if (editIndex !== null) {
onEditChart(editIndex, newSpec);
}
setEditIndex(null);
setDraft(null);
};
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{charts &&
Array.isArray(charts) &&
charts?.map((c, i) => (
<Card key={i} className="group">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base inline-flex items-center gap-2">
<ChartTypeIcon spec={c} />
{"title" in c ? c.title : "Untitled"}
</CardTitle>
<div className="flex items-center gap-2">
<button
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
onClick={() => startEdit(i)}
title="Edit chart"
>
<Pencil className="size-4" />
</button>
<button
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
onClick={() => onRemoveChart(i)}
title="Remove chart"
>
<Trash2 className="size-4" />
</button>
</div>
</CardHeader>
<CardContent>
<ChartRenderer spec={c} data={c.data} />
</CardContent>
</Card>
))}
{editIndex !== null && draft && (
<ChartEditModal
spec={draft}
onCancel={() => {
setEditIndex(null);
setDraft(null);
}}
onSave={handleSave}
/>
)}
</div>
);
}
function ChartEditModal({
spec,
onCancel,
onSave,
}: {
spec: ChartSpec;
onCancel: () => void;
onSave: (next: ChartSpec) => void;
}) {
const [title, setTitle] = useState<string>("title" in spec ? spec.title : "");
const [x, setX] = useState<string>("x" in spec ? spec.x : "");
const [y, setY] = useState<string>("y" in spec ? spec.y : "");
const [steps, setSteps] = useState<string>(
"steps" in spec && Array.isArray(spec.steps) ? spec.steps.join(", ") : "",
);
const buildSpec = (): ChartSpec => {
const type = spec.type; // Keep the original type
if (type === "line") return { type, title, x, y } as LineChartSpec;
if (type === "bar") return { type, title, x, y } as BarChartSpec;
return { type, title, x: x || "category", y: y || "value" } as PieChartSpec;
};
return (
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4">
<div className="bg-card text-card-foreground w-full max-w-md rounded-lg shadow-lg border">
<div className="p-4 border-b font-medium">Edit Chart</div>
<div className="p-4 space-y-3">
<div>
<label className="block text-sm mb-1">Title</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full border rounded px-2 py-1 bg-background"
/>
</div>
<div>
<label className="block text-sm mb-1">X</label>
<input
value={x}
onChange={(e) => setX(e.target.value)}
className="w-full border rounded px-2 py-1 bg-background"
/>
</div>
<div>
<label className="block text-sm mb-1">Y</label>
<input
value={y}
onChange={(e) => setY(e.target.value)}
className="w-full border rounded px-2 py-1 bg-background"
/>
</div>
<div>
<label className="block text-sm mb-1">
Steps (comma separated)
</label>
<input
value={steps}
onChange={(e) => setSteps(e.target.value)}
className="w-full border rounded px-2 py-1 bg-background"
/>
</div>
</div>
<div className="p-4 border-t flex justify-end gap-2">
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button onClick={() => onSave(buildSpec())}>Save</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,185 @@
import { useMemo } from "react";
import {
ResponsiveContainer,
LineChart as RLineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
BarChart as RBarChart,
Bar,
PieChart as RPieChart,
Pie,
Cell,
} from "recharts";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
type ChartConfig,
} from "@/components/ui/chart";
import type {
ChartSpec,
LineChartSpec,
BarChartSpec,
PieChartSpec,
ChartDataRecord,
} from "@/lib/types";
function safeVar(name: string) {
return String(name)
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-");
}
interface ChartRendererProps {
spec: ChartSpec;
data: ChartDataRecord[];
}
export function ChartRenderer({ spec, data }: ChartRendererProps) {
if (spec.type === "line")
return <LineChart spec={spec as LineChartSpec} data={data} />;
if (spec.type === "bar")
return <BarChart spec={spec as BarChartSpec} data={data} />;
return <PieChart spec={spec as PieChartSpec} data={data} />;
}
const chartColors = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
];
function LineChart({
spec,
data,
}: {
spec: LineChartSpec;
data: ChartDataRecord[];
}) {
const config: ChartConfig = useMemo(
() => ({
[spec.y]: { label: spec.y, color: "var(--chart-1)" },
}),
[spec.y],
);
return (
<ChartContainer config={config} className="aspect-auto h-[250px] w-full">
<ResponsiveContainer width="100%" height="100%">
<RLineChart
data={data}
margin={{ top: 10, right: 12, bottom: 0, left: 12 }}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey={spec.x}
tickLine={false}
axisLine={false}
tickMargin={8}
/>
<YAxis tickLine={false} axisLine={false} tickMargin={8} />
<ChartTooltip content={<ChartTooltipContent />} />
<ChartLegend content={<ChartLegendContent />} />
<Line
type="monotone"
dataKey={spec.y}
stroke={`var(--color-${safeVar(spec.y)})`}
strokeWidth={2}
dot={false}
/>
</RLineChart>
</ResponsiveContainer>
</ChartContainer>
);
}
function BarChart({
spec,
data,
}: {
spec: BarChartSpec;
data: ChartDataRecord[];
}) {
const config: ChartConfig = useMemo(
() => ({
[spec.y]: { label: spec.y, color: "var(--chart-2)" },
}),
[spec.y],
);
return (
<ChartContainer config={config} className="aspect-auto h-[250px] w-full">
<ResponsiveContainer width="100%" height="100%">
<RBarChart
data={data}
margin={{ top: 10, right: 12, bottom: 0, left: 12 }}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey={spec.x}
tickLine={false}
axisLine={false}
tickMargin={8}
/>
<YAxis tickLine={false} axisLine={false} tickMargin={8} />
<ChartTooltip content={<ChartTooltipContent />} />
<ChartLegend content={<ChartLegendContent />} />
<Bar dataKey={spec.y} radius={[4, 4, 0, 0]}>
{data.map((_, index) => (
<Cell
key={`cell-${index}`}
fill={chartColors[index % chartColors.length]}
/>
))}
</Bar>
</RBarChart>
</ResponsiveContainer>
</ChartContainer>
);
}
function PieChart({
spec,
data,
}: {
spec: PieChartSpec;
data: ChartDataRecord[];
}) {
const slices = useMemo(() => data, [data]);
const categories = slices.map((s) => String(s[spec.x ?? "category"]));
const config: ChartConfig = useMemo(() => {
const entries = categories.map((cat, i) => [
cat,
{ label: cat, color: chartColors[i % chartColors.length] } as const,
]);
return Object.fromEntries(entries);
}, [categories]) as ChartConfig;
return (
<ChartContainer config={config} className="aspect-auto h-[250px] w-full">
<ResponsiveContainer width="100%" height="100%">
<RPieChart>
<ChartTooltip content={<ChartTooltipContent />} />
<Pie
data={slices}
dataKey={spec.y ?? "value"}
nameKey={spec.x ?? "category"}
innerRadius="20%"
outerRadius="80%"
paddingAngle={2}
labelLine={false}
>
{slices.map((s, i) => (
<Cell key={i} fill={chartColors[i % chartColors.length]} />
))}
</Pie>
</RPieChart>
</ResponsiveContainer>
</ChartContainer>
);
}
@@ -0,0 +1,14 @@
import {
BarChart3,
ChartLine,
PieChart as PieChartIcon,
LayoutDashboard,
} from "lucide-react";
import type { ChartSpec } from "@/lib/types";
export function ChartTypeIcon({ spec }: { spec: ChartSpec }) {
if (spec.type === "line") return <ChartLine className="size-4" />;
if (spec.type === "bar") return <BarChart3 className="size-4" />;
if (spec.type === "pie") return <PieChartIcon className="size-4" />;
return <LayoutDashboard className="size-4" />;
}
@@ -0,0 +1,5 @@
// Export all chart components for easy importing
export { ChartCard } from "./chart-card";
export { ChartGrid } from "./chart-grid";
export { ChartRenderer } from "./chart-renderer";
export { ChartTypeIcon } from "./chart-type-icon";
@@ -0,0 +1,27 @@
import { useCoAgent } from "@copilotkit/react-core";
import { AgentState } from "@/lib/types";
import { cn } from "@/lib/utils";
import { PinnedMetrics } from "@/components/dashboard/layout/metrics";
import { Charts } from "@/components/dashboard/layout/charts";
import { useChartActions, useSearchActions } from "@/components/chat/actions";
export function MainLayout({ className }: { className?: string }) {
const { state, setState } = useCoAgent<AgentState>({
name: "my_agent",
});
// Setup tool rendering and front-end tools
useChartActions({ state, setState });
useSearchActions();
return (
<div
className={cn("min-h-screen bg-background text-foreground", className)}
>
<div className="max-w-6xl mx-auto p-4 grid gap-4">
<PinnedMetrics state={state} setState={setState} />
<Charts state={state} setState={setState} />
</div>
</div>
);
}
@@ -0,0 +1,90 @@
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { ChartSpec, Chart, ChartDataMap } from "@/lib/types";
import { AgentState, AgentSetState } from "@/lib/types";
import { ChartGrid } from "@/components/dashboard/charts";
interface ChartsProps {
state: AgentState;
setState: AgentSetState<AgentState>;
}
export const Charts = ({ state, setState }: ChartsProps) => {
const handleRemoveChart = (index: number) => {
setState((prev) => {
const charts = [...(prev?.charts ?? [])];
charts.splice(index, 1);
return {
title: prev?.title ?? "Dashboard",
charts,
pinnedMetrics: prev?.pinnedMetrics ?? [],
} as AgentState;
});
};
const handleEditChart = (index: number, newSpec: ChartSpec) => {
setState((prev) => {
const charts = [...(prev?.charts ?? [])];
// Preserve the data when editing
const existingData = charts[index]?.data ?? [];
charts[index] = { ...newSpec, data: existingData } as Chart;
return {
title: prev?.title ?? "Dashboard",
charts,
pinnedMetrics: prev?.pinnedMetrics ?? [],
} as AgentState;
});
};
const handleAddChart = () => {
const newChart: Chart = {
type: "line",
title: "New Chart",
x: "x",
y: "y",
data: [],
} as Chart;
setState((prev) => ({
title: prev?.title ?? "Dashboard",
charts: [...(prev?.charts ?? []), newChart],
pinnedMetrics: prev?.pinnedMetrics ?? [],
}));
};
return (
<Card className="shadow-none border-none pt-4 m-0 bg-transparent">
<CardHeader className="flex flex-row items-center justify-between p-0 m-0">
<div className="flex items-center gap-2">
<CardTitle className="text-xl">Your charts</CardTitle>
</div>
<Button
size="sm"
variant="suggestion"
onClick={handleAddChart}
title="Add chart"
>
<Plus className="size-4 mr-1" /> Add Chart
</Button>
</CardHeader>
<CardContent className="p-0 m-0">
{!state?.charts?.length && (
<p className="text-sm italic">
No dashboard yet. Describe what you want in chat (eg, &quot;funnel
of signups and revenue line chart&quot;).
</p>
)}
{state?.charts?.length > 0 && (
<ChartGrid
charts={state.charts}
chartData={
(state as AgentState & { chartData?: ChartDataMap }).chartData
}
onRemoveChart={handleRemoveChart}
onEditChart={handleEditChart}
/>
)}
</CardContent>
</Card>
);
};
@@ -0,0 +1,50 @@
import { PinnedMetricCard } from "@/components/dashboard/metrics";
import { AgentState, AgentSetState, Metric } from "@/lib/types";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
interface PinnedMetricsProps {
state: AgentState;
setState: AgentSetState<AgentState>;
}
const CreateDefaultMetric = (): Metric => {
return {
id: `metric_${Math.floor(Math.random() * 100000)}`,
title: "New Metric",
value: "0",
hint: "",
icon: "custom" as const,
};
};
export const PinnedMetrics = ({ state, setState }: PinnedMetricsProps) => {
return (
<>
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold">Pinned metrics</h2>
<Button
size="sm"
variant="suggestion"
onClick={() => {
setState((prev) => ({
title: prev?.title ?? "Dashboard",
charts: prev?.charts ?? [],
pinnedMetrics: [
...(prev?.pinnedMetrics ?? []),
CreateDefaultMetric(),
],
}));
}}
>
<Plus className="size-4 mr-1" /> Add Metric
</Button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{state.pinnedMetrics?.map((m, i) => (
<PinnedMetricCard key={i} pinnedMetric={m} setState={setState} />
))}
</div>
</>
);
};
@@ -0,0 +1,2 @@
// Export all metric components for easy importing
export { PinnedMetricCard } from "./pinnedMetrics";
@@ -0,0 +1,205 @@
import { Button } from "@/components/ui/button";
import { CardContent } from "@/components/ui/card";
import { Card, CardHeader, CardTitle } from "@/components/ui/card";
import { AgentSetState, AgentState, type Metric } from "@/lib/types";
import { RenderFunctionStatus } from "@copilotkit/react-core";
import {
Pin,
TrendingUp,
Trash2,
TrendingDown,
DollarSign,
Users,
Pencil,
} from "lucide-react";
import { useState } from "react";
function MetricIcon({ name }: { name?: Metric["icon"] }) {
switch (name) {
case "users":
return <Users className="size-4" />;
case "mrr":
return <DollarSign className="size-4" />;
case "conversion":
return <TrendingUp className="size-4" />;
case "churn":
return <TrendingDown className="size-4" />;
default:
return <Pin className="size-4" />;
}
}
interface PinnedMetricCardProps {
pinnedMetric: Metric;
setState: AgentSetState<AgentState>;
onHumanInput?: (shouldProceed: boolean) => void;
status?: RenderFunctionStatus;
}
export const PinnedMetricCard = ({
pinnedMetric,
setState,
onHumanInput,
status,
}: PinnedMetricCardProps) => {
const [isEditing, setIsEditing] = useState(false);
return (
<>
<Card key={pinnedMetric.id} className="group relative">
{!status && (
<div className="absolute right-2 top-2 flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
className="text-muted-foreground hover:text-foreground"
onClick={() => setIsEditing(true)}
title="Edit metric"
>
<Pencil className="size-4" />
</button>
<button
className="text-muted-foreground hover:text-foreground"
onClick={() =>
setState((state) => ({
title: state?.title ?? "Dashboard",
charts: state?.charts ?? [],
pinnedMetrics: (state?.pinnedMetrics ?? []).filter(
(x: Metric) => x.id !== pinnedMetric.id,
),
}))
}
title="Remove metric"
>
<Trash2 className="size-4" />
</button>
</div>
)}
<CardHeader className="flex flex-row items-center justify-between gap-2">
<CardTitle className="inline-flex items-center gap-2 text-sm">
<MetricIcon name={pinnedMetric.icon} />
{pinnedMetric.title}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-primary truncate">
{pinnedMetric.value}
</div>
{pinnedMetric.hint && (
<p className="text-muted-foreground text-sm mt-1">
{pinnedMetric.hint}
</p>
)}
</CardContent>
</Card>
{isEditing && (
<MetricEditModal
metric={pinnedMetric}
onCancel={() => setIsEditing(false)}
onSave={(next) => {
setState((state) => ({
title: state?.title ?? "Dashboard",
charts: state?.charts ?? [],
pinnedMetrics: (state?.pinnedMetrics ?? []).map((m: Metric) =>
m.id === next.id ? next : m,
),
}));
setIsEditing(false);
}}
/>
)}
{status !== "complete" && onHumanInput && (
<div className="flex justify-end gap-2 py-4">
<Button variant="outline" onClick={() => onHumanInput(false)}>
Cancel
</Button>
<Button onClick={() => onHumanInput(true)}>Add</Button>
</div>
)}
</>
);
};
function MetricEditModal({
metric,
onCancel,
onSave,
}: {
metric: Metric;
onCancel: () => void;
onSave: (next: Metric) => void;
}) {
const [title, setTitle] = useState(metric.title);
const [value, setValue] = useState(metric.value);
const [hint, setHint] = useState(metric.hint ?? "");
const [icon, setIcon] = useState<Metric["icon"]>(metric.icon ?? "custom");
const save = () => {
const safeIcon =
icon === "users" ||
icon === "mrr" ||
icon === "conversion" ||
icon === "churn" ||
icon === "custom"
? icon
: "custom";
onSave({
...metric,
title,
value,
hint: hint || undefined,
icon: safeIcon,
});
};
return (
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4">
<div className="bg-card text-card-foreground w-full max-w-md rounded-lg shadow-lg border">
<div className="p-4 border-b font-medium">Edit Metric</div>
<div className="p-4 space-y-3">
<div>
<label className="block text-sm mb-1">Title</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full border rounded px-2 py-1 bg-background"
/>
</div>
<div>
<label className="block text-sm mb-1">Value</label>
<input
value={value}
onChange={(e) => setValue(e.target.value)}
className="w-full border rounded px-2 py-1 bg-background"
/>
</div>
<div>
<label className="block text-sm mb-1">Hint</label>
<input
value={hint}
onChange={(e) => setHint(e.target.value)}
className="w-full border rounded px-2 py-1 bg-background"
/>
</div>
<div>
<label className="block text-sm mb-1">Icon</label>
<select
value={icon}
onChange={(e) => setIcon(e.target.value as Metric["icon"])}
className="w-full border rounded px-2 py-1 bg-background"
>
<option value="users">Users</option>
<option value="mrr">MRR</option>
<option value="conversion">Conversion</option>
<option value="churn">Churn</option>
<option value="custom">Custom</option>
</select>
</div>
</div>
<div className="p-4 border-t flex justify-end gap-2">
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button onClick={save}>Save</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,53 @@
"use client";
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className,
)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback };
@@ -0,0 +1,46 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span";
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };
@@ -0,0 +1,60 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
suggestion:
"bg-card text-card-foreground border border-border shadow-sm hover:bg-accent/10 hover:border-accent/40",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
@@ -0,0 +1,92 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};
@@ -0,0 +1,357 @@
"use client";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color,
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};
@@ -0,0 +1,143 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
@@ -0,0 +1,257 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};
@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { Input };
@@ -0,0 +1,31 @@
"use client";
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };
@@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
{...props}
/>
);
}
export { Separator };
@@ -0,0 +1,66 @@
"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
);
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className,
)}
{...props}
/>
);
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent };
@@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
{...props}
/>
);
}
export { Textarea };
@@ -0,0 +1,61 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
@@ -0,0 +1,21 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 1024;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}
@@ -0,0 +1,79 @@
export type LineChartSpec = {
type: "line";
title: string;
x: string;
y: string;
};
export type BarChartSpec = { type: "bar"; title: string; x: string; y: string };
export type PieChartSpec = { type: "pie"; title: string; x: string; y: string };
export type ChartSpec = LineChartSpec | BarChartSpec | PieChartSpec;
// Data records supplied by the agent for charts
export type ChartDataRecord = Record<string, string | number>;
export type ChartDataMap = Record<string, ChartDataRecord[]>; // keyed by chart title
export type Metric = {
id: string;
title: string;
value: string;
hint?: string;
icon?: "users" | "mrr" | "conversion" | "churn" | "custom";
};
export type Chart = ChartSpec & {
data: ChartDataRecord[];
};
export type AgentState = {
title: string;
charts: Chart[];
pinnedMetrics: Metric[];
};
export type AgentSetState<T extends AgentState> = (
newState: T | ((prevState: T | undefined) => T),
) => void;
export const initialState: AgentState = {
title: "Dashboard",
charts: [
{
type: "line",
title: "Sales by day",
x: "x",
y: "y",
data: [
{ x: "2024-01-01", y: 100 },
{ x: "2024-01-02", y: 200 },
{ x: "2024-01-03", y: 300 },
],
},
{
type: "bar",
title: "Sales by product",
x: "x",
y: "y",
data: [
{ x: "Smartphone", y: 100 },
{ x: "Tablet", y: 200 },
{ x: "Laptop", y: 300 },
],
},
],
pinnedMetrics: [
{
id: "1",
title: "Total sales",
value: "1000",
hint: "Total sales for the last 30 days",
icon: "mrr",
},
{
id: "2",
title: "Best selling product",
value: "Laptop",
hint: "Total sales for the last 30 days",
icon: "conversion",
},
],
};
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
@@ -0,0 +1,23 @@
node_modules
.next
.git
.gitignore
.env*
!.env.example
.DS_Store
*.pem
.claude
.mastra
.vercel
coverage
agent/.venv
agent/__pycache__
agent/venv
scripts
*.tsbuildinfo
next-env.d.ts
package-lock.json
yarn.lock
pnpm-lock.yaml
bun.lockb
agent/uv.lock
@@ -0,0 +1,49 @@
# 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
.mastra/
# python
agent/venv/
agent/__pycache__/
agent/.venv/
@@ -0,0 +1,55 @@
# Stage 1: Build Next.js frontend
FROM node:20-slim AS frontend
WORKDIR /app
COPY package.json ./
RUN npm install --ignore-scripts
COPY src/ ./src/
COPY public/ ./public/
COPY next.config.ts tsconfig.json postcss.config.mjs ./
RUN npm run build
# Stage 2: Production image with Python + Node
FROM python:3.12-slim AS runner
# Install Node.js 20 + uv
RUN apt-get update && \
apt-get install -y --no-install-recommends curl ca-certificates && \
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
apt-get install -y --no-install-recommends nodejs && \
apt-get clean && rm -rf /var/lib/apt/lists/*
# Install uv by copying from the official image (avoids curl|sh pipe-swallow bug
# where a 5xx on astral.sh silently produces an exit-0 layer with no uv binary).
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
ENV PATH="/root/.local/bin:$PATH"
WORKDIR /app
# Install Python dependencies using uv (matches local dev)
COPY agent/pyproject.toml ./agent/pyproject.toml
COPY agent/main.py ./agent/main.py
RUN cd agent && uv pip install --system -e .
# Copy Next.js build output and production node_modules
COPY --from=frontend /app/.next ./.next
COPY --from=frontend /app/node_modules ./node_modules
COPY --from=frontend /app/package.json ./
COPY --from=frontend /app/public ./public
# Copy agent source code
COPY agent/ ./agent/
# Copy entrypoint
COPY entrypoint.sh ./
RUN chmod +x entrypoint.sh
EXPOSE 3000
ENV NODE_ENV=production
CMD ["./entrypoint.sh"]
@@ -0,0 +1,21 @@
The MIT License
Copyright (c) Atai Barkai
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,137 @@
# AI Financial Coach Agent
A multi-agent financial coach that analyzes your budget, plans your savings, and builds debt-payoff strategies — rendered as interactive UI cards in a separate report tab. Built with [CopilotKit](https://github.com/CopilotKit/CopilotKit), [AG-UI](https://github.com/ag-ui-protocol/ag-ui), and Google's [ADK](https://google.github.io/adk-docs/) on top of Next.js.
https://github.com/user-attachments/assets/edd4fa8d-ecc5-4b5d-90ff-27b21af5af94
**Gen UI concept — tool-rendered components.** A top-level coach routes each chat turn to the right tool: update your financial profile from natural language ("my income is $8k"), run a single phase (budget / savings / debt), or run the full Budget→Savings→Debt sequence. Each tool call streams a status pill into the chat while the corresponding card materializes in the report tab.
## Prerequisites
- Node.js 18+
- Python 3.12+
- Google Makersuite API Key (for the ADK agent) (see https://makersuite.google.com/app/apikey)
- Any of the following package managers:
- npm (default)
- [pnpm](https://pnpm.io/installation)
- [yarn](https://classic.yarnpkg.com/lang/en/docs/install/)
- [bun](https://bun.sh/)
## Getting Started
1. Install dependencies using your preferred package manager:
```bash
# Using npm (default)
npm install
# Using pnpm
pnpm install
# Using yarn
yarn install
# Using bun
bun install
```
2. Install Python dependencies for the ADK agent:
```bash
# Using npm (default)
npm run install:agent
# Using pnpm
pnpm install:agent
# Using yarn
yarn install:agent
# Using bun
bun run install:agent
```
> **Note:** This will automatically setup a `.venv` (virtual environment) inside the `agent` directory.
>
> To activate the virtual environment manually, you can run:
>
> ```bash
> source agent/.venv/bin/activate
> ```
3. Set up your Google API key:
```bash
export GOOGLE_API_KEY="your-google-api-key-here"
```
4. Start the development server:
```bash
# Using npm (default)
npm run dev
# Using pnpm
pnpm dev
# Using yarn
yarn dev
# Using bun
bun run dev
```
This will start both the UI and agent servers concurrently.
## Available Scripts
The following scripts can also be run using your preferred package manager:
- `dev` - Starts both UI and agent servers in development mode
- `dev:debug` - Starts development servers with debug logging enabled
- `dev:ui` - Starts only the Next.js UI server
- `dev:agent` - Starts only the ADK agent server
- `build` - Builds the Next.js application for production
- `start` - Starts the production server
- `install:agent` - Installs Python dependencies for the agent
## Documentation
The main UI component is in `src/app/page.tsx`. You can:
- Modify the theme colors and styling
- Add new frontend actions
- Customize the CopilotKit sidebar appearance
## 📚 Documentation
- [ADK Documentation](https://google.github.io/adk-docs/) - Learn more about the ADK and its features
- [CopilotKit Documentation](https://docs.copilotkit.ai) - Explore CopilotKit's capabilities
- [Next.js Documentation](https://nextjs.org/docs) - Learn about Next.js features and API
## Contributing
Feel free to submit issues and enhancement requests! This starter is designed to be easily extensible.
## License
This project is licensed under the MIT License - see the LICENSE file for details.
## Troubleshooting
### Agent Connection Issues
If you see "I'm having trouble connecting to my tools", make sure:
1. The ADK agent is running on port 8000
2. Your Google API key is set correctly
3. Both servers started successfully
### Python Dependencies
If you encounter Python import errors:
```bash
cd agent
pip install -r requirements.txt
```
@@ -0,0 +1,507 @@
"""AI Financial Coach — coach agent + tools (Budget / Savings / Debt)."""
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional
from ag_ui_adk import ADKAgent, add_adk_fastapi_endpoint
from dotenv import load_dotenv
from fastapi import FastAPI
from google.adk.agents import LlmAgent, SequentialAgent
from google.adk.agents.callback_context import CallbackContext
from google.adk.models.llm_request import LlmRequest
from google.adk.tools import AgentTool, ToolContext
from google.genai import types
from pydantic import BaseModel, Field
load_dotenv()
# ---------- Output schemas (sub-agent results) ----------
class SpendingCategory(BaseModel):
category: str = Field(..., description="Expense category name")
amount: float = Field(..., description="Amount spent in this category")
percentage: Optional[float] = Field(None, description="Percentage of total spending")
class SpendingRecommendation(BaseModel):
category: str = Field(..., description="Category for recommendation")
recommendation: str = Field(..., description="Recommendation details")
potential_savings: Optional[float] = Field(None, description="Estimated monthly savings")
class BudgetAnalysis(BaseModel):
total_expenses: float = Field(..., description="Total monthly expenses")
monthly_income: Optional[float] = Field(None, description="Monthly income")
spending_categories: List[SpendingCategory] = Field(..., description="Breakdown of spending by category")
recommendations: List[SpendingRecommendation] = Field(..., description="Spending recommendations")
class EmergencyFund(BaseModel):
recommended_amount: float = Field(..., description="Recommended emergency fund size")
current_amount: Optional[float] = Field(None, description="Current emergency fund (if any)")
current_status: str = Field(..., description="Status assessment of emergency fund")
class SavingsRecommendation(BaseModel):
category: str = Field(..., description="Savings category")
amount: float = Field(..., description="Recommended monthly amount")
rationale: Optional[str] = Field(None, description="Explanation for this recommendation")
class AutomationTechnique(BaseModel):
name: str = Field(..., description="Name of automation technique")
description: str = Field(..., description="Details of how to implement")
class SavingsStrategy(BaseModel):
emergency_fund: EmergencyFund = Field(..., description="Emergency fund recommendation")
recommendations: List[SavingsRecommendation] = Field(..., description="Savings allocation recommendations")
automation_techniques: Optional[List[AutomationTechnique]] = Field(
None, description="Automation techniques to help save"
)
class Debt(BaseModel):
name: str = Field(..., description="Name of debt")
amount: float = Field(..., description="Current balance")
interest_rate: float = Field(..., description="Annual interest rate (%)")
min_payment: Optional[float] = Field(None, description="Minimum monthly payment")
class PayoffPlan(BaseModel):
total_interest: float = Field(..., description="Total interest paid")
months_to_payoff: int = Field(..., description="Months until debt-free")
monthly_payment: Optional[float] = Field(None, description="Recommended monthly payment")
class PayoffPlans(BaseModel):
avalanche: PayoffPlan = Field(..., description="Highest interest first method")
snowball: PayoffPlan = Field(..., description="Smallest balance first method")
class DebtRecommendation(BaseModel):
title: str = Field(..., description="Title of recommendation")
description: str = Field(..., description="Details of recommendation")
impact: Optional[str] = Field(None, description="Expected impact of this action")
class DebtReduction(BaseModel):
total_debt: float = Field(..., description="Total debt amount")
debts: List[Debt] = Field(..., description="List of all debts")
payoff_plans: PayoffPlans = Field(..., description="Debt payoff strategies")
recommendations: Optional[List[DebtRecommendation]] = Field(
None, description="Recommendations for debt reduction"
)
# ---------- Shared state plumbing ----------
DEFAULT_FINANCIAL_DATA: Dict[str, Any] = {
"monthly_income": 0,
"dependants": 0,
"manual_expenses": {},
"debts": [],
}
def on_before_agent(callback_context: CallbackContext):
"""Initialize financial_data in shared state if missing."""
if "financial_data" not in callback_context.state:
callback_context.state["financial_data"] = dict(DEFAULT_FINANCIAL_DATA)
return None
def _safe_dumps(value: Any) -> str:
try:
return json.dumps(value, indent=2, default=str)
except Exception:
return str(value)
def _state_context(state, include_keys: List[str]) -> str:
"""Render selected state slices as a readable context block."""
blocks: List[str] = []
fd = state.get("financial_data") or DEFAULT_FINANCIAL_DATA
blocks.append(f"FINANCIAL DATA:\n{_safe_dumps(fd)}")
for key in include_keys:
value = state.get(key)
if value:
blocks.append(f"{key.upper()}:\n{_safe_dumps(value)}")
return "\n\n".join(blocks)
def _make_state_injector(prior_keys: List[str]):
"""Build a before_model_callback that prepends financial state to system instruction."""
def modifier(callback_context: CallbackContext, llm_request: LlmRequest):
context = _state_context(callback_context.state, prior_keys)
prefix = (
"The user's financial information and prior agent outputs are below. "
"Use them to produce your structured response.\n\n"
f"{context}\n\n---\n"
)
original = llm_request.config.system_instruction or types.Content(
role="system", parts=[]
)
if not isinstance(original, types.Content):
original = types.Content(
role="system", parts=[types.Part(text=str(original))]
)
if not original.parts:
original.parts = [types.Part(text="")]
original.parts[0].text = prefix + (original.parts[0].text or "")
llm_request.config.system_instruction = original
return None
return modifier
# ---------- Specialist agents ----------
budget_agent = LlmAgent(
name="BudgetAnalysisAgent",
model="gemini-2.5-flash",
description="Analyzes financial data to categorize spending patterns and recommend budget improvements.",
instruction="""You are a Budget Analysis Agent. Read FINANCIAL DATA above.
Your tasks:
1. Categorize ALL expenses from the user's financial data into logical groups, with percentages summing to 100%.
2. Identify spending patterns and trends across categories.
3. Provide 3-5 specific, actionable recommendations with quantified potential monthly savings.
Consider:
- Number of dependants when evaluating household expenses.
- Typical spending ratios for the income level (housing ~30%, food ~15%).
- Essential vs discretionary spending separation.
Respond strictly using the BudgetAnalysis schema.""",
output_schema=BudgetAnalysis,
output_key="budget_analysis",
before_model_callback=_make_state_injector([]),
)
savings_agent = LlmAgent(
name="SavingsStrategyAgent",
model="gemini-2.5-flash",
description="Recommends savings strategies based on income, expenses, and prior budget analysis.",
instruction="""You are a Savings Strategy Agent. Read FINANCIAL DATA and BUDGET_ANALYSIS above when available.
Your tasks:
1. Recommend an emergency fund size based on monthly expenses and dependants (typically 3-6 months of expenses).
2. Suggest savings allocations across categories (emergency, retirement, specific goals).
3. Recommend practical automation techniques.
Consider:
- Risk factors based on dependants and stability.
- Balancing immediate needs with long-term financial health.
- Areas of potential savings identified in the budget analysis.
Respond strictly using the SavingsStrategy schema.""",
output_schema=SavingsStrategy,
output_key="savings_strategy",
before_model_callback=_make_state_injector(["budget_analysis"]),
)
debt_agent = LlmAgent(
name="DebtReductionAgent",
model="gemini-2.5-flash",
description="Creates optimized debt payoff plans (avalanche & snowball).",
instruction="""You are a Debt Reduction Agent. Read FINANCIAL DATA, BUDGET_ANALYSIS, and SAVINGS_STRATEGY above when available.
Your tasks:
1. Compute total debt and list each debt by name, amount, interest rate, min payment.
2. Build BOTH avalanche (highest interest first) and snowball (smallest balance first) payoff plans, including:
- total_interest paid
- months_to_payoff
- monthly_payment recommended
3. Provide 2-4 actionable recommendations to accelerate payoff (consolidation, refinancing, etc.).
Consider:
- Cash-flow constraints from the budget analysis.
- Emergency-fund and savings goals from the savings strategy.
- Psychological factors (quick wins vs mathematical optimization).
If the user has no debts, return total_debt=0, empty debts list, and zeroed payoff plans.
Respond strictly using the DebtReduction schema.""",
output_schema=DebtReduction,
output_key="debt_reduction",
before_model_callback=_make_state_injector(["budget_analysis", "savings_strategy"]),
)
full_analysis_pipeline = SequentialAgent(
name="FullAnalysisPipeline",
description="Runs Budget Analysis → Savings Strategy → Debt Reduction in sequence.",
sub_agents=[budget_agent, savings_agent, debt_agent],
)
# ---------- Coach tools ----------
CANONICAL_EXPENSE_CATEGORIES = [
"Housing",
"Utilities",
"Food",
"Transportation",
"Healthcare",
"Entertainment",
"Personal",
"Savings",
"Other",
]
# Lowercase alias → canonical category. Keep this lean and obvious; unknown
# inputs fall through to "Other".
_EXPENSE_ALIASES: Dict[str, str] = {
"housing": "Housing",
"rent": "Housing",
"mortgage": "Housing",
"home": "Housing",
"utilities": "Utilities",
"utility": "Utilities",
"electric": "Utilities",
"electricity": "Utilities",
"internet": "Utilities",
"phone": "Utilities",
"water": "Utilities",
"gas_bill": "Utilities",
"food": "Food",
"groceries": "Food",
"grocery": "Food",
"dining": "Food",
"restaurants": "Food",
"transportation": "Transportation",
"transport": "Transportation",
"car": "Transportation",
"gas": "Transportation",
"fuel": "Transportation",
"transit": "Transportation",
"healthcare": "Healthcare",
"health": "Healthcare",
"medical": "Healthcare",
"insurance": "Healthcare",
"entertainment": "Entertainment",
"fun": "Entertainment",
"subscriptions": "Entertainment",
"streaming": "Entertainment",
"personal": "Personal",
"clothing": "Personal",
"personal_care": "Personal",
"savings": "Savings",
"investing": "Savings",
"retirement": "Savings",
"other": "Other",
"misc": "Other",
"miscellaneous": "Other",
}
def _normalize_category(key: str) -> str:
"""Map a free-form expense key to a canonical category label."""
if not key:
return "Other"
cleaned = key.strip().replace("-", "_").replace(" ", "_").lower()
if cleaned in _EXPENSE_ALIASES:
return _EXPENSE_ALIASES[cleaned]
# If the user passed an already-canonical label in any case, accept it.
for canonical in CANONICAL_EXPENSE_CATEGORIES:
if cleaned == canonical.lower():
return canonical
return "Other"
def _ensure_financial_data(tool_context: ToolContext) -> Dict[str, Any]:
fd = tool_context.state.get("financial_data")
if not isinstance(fd, dict):
fd = dict(DEFAULT_FINANCIAL_DATA)
fd.setdefault("monthly_income", 0)
fd.setdefault("dependants", 0)
if not isinstance(fd.get("manual_expenses"), dict):
fd["manual_expenses"] = {}
if not isinstance(fd.get("debts"), list):
fd["debts"] = []
return fd
def update_financial_data(
tool_context: ToolContext,
monthly_income: Optional[float] = None,
dependants: Optional[int] = None,
expenses_patch: Optional[Dict[str, float]] = None,
add_debts: Optional[List[Dict[str, Any]]] = None,
remove_debt_names: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""Patch the user's financial profile in shared state. All arguments are optional and applied with append/merge semantics.
Args:
monthly_income: New monthly take-home income in USD. Replaces the existing value.
dependants: New number of dependants. Replaces the existing value.
expenses_patch: Partial map of expense category to monthly USD amount. Merges into manual_expenses (existing keys not in the patch are preserved). To zero a category, pass it as 0.
add_debts: List of debts to APPEND to the existing debts list. Each item must include name, amount, interest_rate, and optionally min_payment.
remove_debt_names: List of debt names to remove from the debts list (case-insensitive match).
Returns:
A summary describing what changed and the updated financial_data.
"""
fd = _ensure_financial_data(tool_context)
changes: List[str] = []
if monthly_income is not None:
fd["monthly_income"] = float(monthly_income)
changes.append(f"income → ${float(monthly_income):,.0f}")
if dependants is not None:
fd["dependants"] = int(dependants)
changes.append(f"dependants → {int(dependants)}")
if expenses_patch:
merged = dict(fd.get("manual_expenses") or {})
normalized: Dict[str, float] = {}
for k, v in expenses_patch.items():
canonical = _normalize_category(k)
normalized[canonical] = normalized.get(canonical, 0.0) + float(v)
for k, v in normalized.items():
merged[k] = float(v)
fd["manual_expenses"] = merged
patch_pretty = ", ".join(f"{k}=${v:,.0f}" for k, v in normalized.items())
changes.append(f"expenses {{{patch_pretty}}}")
if add_debts:
existing = list(fd.get("debts") or [])
for d in add_debts:
existing.append(
{
"name": str(d.get("name", "Debt")),
"amount": float(d.get("amount", 0) or 0),
"interest_rate": float(d.get("interest_rate", 0) or 0),
"min_payment": (
float(d["min_payment"]) if d.get("min_payment") is not None else None
),
}
)
fd["debts"] = existing
added_pretty = ", ".join(
f"{d.get('name', 'Debt')} (${float(d.get('amount', 0) or 0):,.0f} @ {float(d.get('interest_rate', 0) or 0)}%)"
for d in add_debts
)
changes.append(f"+{len(add_debts)} debt(s): {added_pretty}")
if remove_debt_names:
targets = {n.strip().lower() for n in remove_debt_names if n}
before = len(fd.get("debts") or [])
fd["debts"] = [
d
for d in (fd.get("debts") or [])
if str(d.get("name", "")).strip().lower() not in targets
]
removed = before - len(fd["debts"])
if removed:
changes.append(f"-{removed} debt(s) removed: {', '.join(remove_debt_names)}")
tool_context.state["financial_data"] = fd
return {
"status": "updated",
"changes": changes or ["no-op"],
"financial_data": fd,
}
# Wrap each phase as an AgentTool. skip_summarization=True keeps the coach from
# re-narrating the full JSON (which already lands in shared state via output_key
# and renders as a card in the UI).
run_budget_analysis = AgentTool(agent=budget_agent, skip_summarization=True)
run_savings_strategy = AgentTool(agent=savings_agent, skip_summarization=True)
run_debt_reduction = AgentTool(agent=debt_agent, skip_summarization=True)
run_full_analysis = AgentTool(agent=full_analysis_pipeline, skip_summarization=True)
# ---------- Coach (top-level router) ----------
COACH_INSTRUCTION = """You are a friendly, concise AI Financial Coach. You collaborate with the user via chat while their structured financial profile and analysis cards live on a side panel.
You have these tools:
- update_financial_data — patch the user's profile (income, dependants, expenses, debts). Uses APPEND semantics for debts.
- BudgetAnalysisAgent — produce a fresh budget breakdown card.
- SavingsStrategyAgent — produce a fresh savings strategy card.
- DebtReductionAgent — produce a fresh debt-payoff plan card.
- FullAnalysisPipeline — run all three in sequence.
When calling update_financial_data with expenses_patch, ONLY use these canonical category keys (the backend will also normalize aliases like "rent""Housing", but prefer canonicals):
Housing, Utilities, Food, Transportation, Healthcare, Entertainment, Personal, Savings, Other.
Map terms intelligently: rent/mortgage → Housing; groceries/dining → Food; gas/car/transit → Transportation; subscriptions/streaming → Entertainment; medical/insurance → Healthcare; clothing/personal_care → Personal; investing/retirement → Savings.
Routing rules — pick the SMALLEST tool that satisfies the request:
1. If the user states a fact about their finances (e.g. "my income is $8k", "my rent is $2400", "I just paid off the car loan", "add a $500 student loan at 5%"), call update_financial_data with only the fields they mentioned. To add a new debt, use add_debts (which appends). To remove a paid-off debt, use remove_debt_names. Never overwrite the entire profile.
2. If the user asks for a specific report ("analyze my budget", "what should I save?", "plan my debt payoff"), call the matching single tool.
3. If the user asks for a full review ("analyze my finances", "give me the full picture", "run a full review"), call FullAnalysisPipeline.
4. If the user asks a general question or chats, just answer in plain text — DO NOT call a tool.
5. Never call multiple tools in the same turn unless explicitly asked. Never call an analysis tool to confirm a profile update.
After a tool call:
- For update_financial_data: reply in 1 short sentence confirming what changed and (if relevant) suggest a next step.
- For analysis tools: the card renders automatically. Reply in 1-2 sentences highlighting the most important takeaway. Do NOT restate the full JSON.
Style: warm, plain language, no jargon, no markdown headers in chat replies."""
coach_agent = LlmAgent(
name="FinanceCoachAgent",
model="gemini-2.5-flash",
description="Routes user requests to profile updates or specialist analysis agents.",
instruction=COACH_INSTRUCTION,
tools=[
update_financial_data,
run_budget_analysis,
run_savings_strategy,
run_debt_reduction,
run_full_analysis,
],
before_agent_callback=on_before_agent,
)
# ---------- ADK / FastAPI wiring ----------
adk_finance_agent = ADKAgent(
adk_agent=coach_agent,
user_id="demo_user",
session_timeout_seconds=3600,
use_in_memory_services=True,
)
app = FastAPI(title="AI Financial Coach Agent")
add_adk_fastapi_endpoint(app, adk_finance_agent, path="/")
@app.get("/health")
async def health():
return {"status": "ok"}
if __name__ == "__main__":
import os
import uvicorn
if not os.getenv("GOOGLE_API_KEY"):
print("⚠️ Warning: GOOGLE_API_KEY environment variable not set!")
print(" Set it with: export GOOGLE_API_KEY='your-key-here'")
print(" Get a key from: https://makersuite.google.com/app/apikey")
print()
port = int(os.getenv("PORT", 8000))
uvicorn.run(app, host="0.0.0.0", port=port)
@@ -0,0 +1,14 @@
[project]
name = "proverbs-agent"
version = "0.1.0"
description = "ADK Proverbs Agent with shared state"
requires-python = ">=3.12"
dependencies = [
"fastapi",
"uvicorn[standard]",
"python-dotenv",
"pydantic",
"google-adk",
"google-genai",
"ag-ui-adk",
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,80 @@
services:
aimock:
image: ghcr.io/copilotkit/aimock:latest
volumes:
- ./fixtures:/fixtures:ro
command:
["--fixtures", "/fixtures", "--host", "0.0.0.0", "--validate-on-load"]
agent:
build:
context: ./agent
dockerfile: ../docker/Dockerfile.agent
environment:
# google-genai SDK does not honor a custom endpoint the way the
# OpenAI / Anthropic clients do, so aimock can't intercept calls
# from the adk agent — the SDK hits generativelanguage.googleapis.com
# directly. Pass a real key via CI secret (see .github/workflows/
# test_smoke-starter.yml). Fall back to the placeholder for local
# dev runs where the local harness may provide its own stub.
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-test-key-for-aimock}
- GOOGLE_GENAI_USE_VERTEXAI=false
depends_on:
aimock:
condition: service_started
healthcheck:
test:
[
"CMD",
"python",
"-c",
"import socket; socket.create_connection(('localhost', 8000), 2)",
]
interval: 5s
timeout: 5s
retries: 30
start_period: 30s
app:
build:
context: .
dockerfile: docker/Dockerfile.app
environment:
- AGENT_URL=http://agent:8000
depends_on:
agent:
condition: service_healthy
healthcheck:
test:
[
"CMD",
"node",
"-e",
"const http = require('http'); const req = http.get('http://localhost:3000/', (res) => { process.exit(res.statusCode === 200 ? 0 : 1); }); req.on('error', () => process.exit(1)); req.setTimeout(2000, () => { req.destroy(); process.exit(1); });",
]
interval: 5s
timeout: 10s
retries: 30
start_period: 60s
tests:
image: mcr.microsoft.com/playwright:v1.52.0-noble
working_dir: /tests
volumes:
- ../../../showcase/tests:/tests
- test-results:/tests/test-results
environment:
- STARTER=${STARTER:-adk}
- STARTER_URL=http://app:3000
depends_on:
app:
condition: service_healthy
command:
[
"bash",
"-c",
"cd /tests && npm install --no-audit --no-fund 2>/dev/null && npx playwright install chromium && npx playwright test starter-smoke --reporter=list",
]
volumes:
test-results:
@@ -0,0 +1,16 @@
FROM python:3.12-slim
# Install uv by copying from the official image (avoids curl|sh pipe-swallow bug
# where a 5xx on astral.sh silently produces an exit-0 layer with no uv binary).
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
WORKDIR /app
COPY pyproject.toml uv.lock ./
COPY main.py ./
RUN uv sync
EXPOSE 8000
CMD ["uv", "run", "python", "main.py"]
@@ -0,0 +1,28 @@
FROM node:22-alpine AS base
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json ./
RUN npm install --ignore-scripts
RUN npm install next@latest
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN node -e "const fs=require('fs'); const f='next.config.ts'; let c=fs.readFileSync(f,'utf8'); if(!c.includes('output')){c=c.replace('};',' output: \"standalone\",\n};'); fs.writeFileSync(f,c);}"
RUN node -e "const fs=require('fs'); const f='next.config.ts'; let c=fs.readFileSync(f,'utf8'); if(!c.includes('ignoreBuildErrors')){c=c.replace('};',' typescript: { ignoreBuildErrors: true },\n};'); fs.writeFileSync(f,c);}"
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000 HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
@@ -0,0 +1,43 @@
#!/bin/bash
set -e
echo "========================================="
echo "[entrypoint] Starting: Google ADK starter"
echo "[entrypoint] Time: $(date -u)"
echo "[entrypoint] PORT=${PORT:-not set}"
echo "========================================="
# Check critical env vars
if [ -z "$GOOGLE_API_KEY" ]; then
echo "[entrypoint] WARNING: GOOGLE_API_KEY is not set! Agent will fail."
else
echo "[entrypoint] GOOGLE_API_KEY: set (${#GOOGLE_API_KEY} chars)"
fi
# Start Python agent on port 8000 (override PORT which Railway sets to 3000)
echo "[entrypoint] Starting ADK agent on port 8000..."
cd /app/agent
PORT=8000 python main.py 2>&1 | sed 's/^/[agent] /' &
AGENT_PID=$!
cd /app
sleep 2
if kill -0 $AGENT_PID 2>/dev/null; then
echo "[entrypoint] ADK agent started (PID: $AGENT_PID)"
else
echo "[entrypoint] ERROR: ADK agent failed to start!"
fi
# Start Next.js frontend
echo "[entrypoint] Starting Next.js on port ${PORT:-3000}..."
PORT=${PORT:-3000} npx next start --port ${PORT:-3000} 2>&1 | sed 's/^/[nextjs] /' &
NEXTJS_PID=$!
echo "[entrypoint] Both processes running. Waiting..."
wait -n $AGENT_PID $NEXTJS_PID
EXIT_CODE=$?
echo "[entrypoint] A process exited with code $EXIT_CODE"
kill $AGENT_PID $NEXTJS_PID 2>/dev/null || true
exit $EXIT_CODE
@@ -0,0 +1,16 @@
{
"fixtures": [
{
"match": { "userMessage": "Hello" },
"response": {
"content": "Hello! I'm the adk AI assistant. How can I help you?"
}
},
{
"match": {},
"response": {
"content": "You're currently running against aimock (a mock LLM server). This response is a catch-all for requests that don't match any test fixture. To use a real LLM: (1) Add your GOOGLE_API_KEY to .env, (2) Restart with `pnpm dev`."
}
}
]
}
@@ -0,0 +1,11 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
serverExternalPackages: ["@copilotkit/runtime"],
typescript: {
// HttpAgent type mismatch with CopilotRuntime — pending upstream fix in @copilotkit/runtime
ignoreBuildErrors: true,
},
};
export default nextConfig;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,35 @@
{
"name": "ai-financial-coach-agent",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "concurrently \"npm run dev:ui\" \"npm run dev:agent\" --names ui,agent --prefix-colors blue,green --kill-others",
"dev:debug": "LOG_LEVEL=debug npm run dev",
"dev:agent": "./scripts/run-agent.sh || scripts\\run-agent.bat",
"dev:ui": "next dev --turbopack",
"build": "next build",
"start": "next start",
"install:agent": "./scripts/setup-agent.sh || scripts\\setup-agent.bat",
"postinstall": "npm run install:agent"
},
"dependencies": {
"@ag-ui/client": "0.0.52",
"@copilotkit/react-core": "^1.56.4",
"@copilotkit/react-textarea": "^1.56.4",
"@copilotkit/react-ui": "^1.56.4",
"@copilotkit/runtime": "^1.56.4",
"next": "16.1.1",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"shiki": "^3.19.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"concurrently": "^9.1.2",
"tailwindcss": "^4",
"typescript": "^5"
}
}
@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

@@ -0,0 +1,9 @@
@echo off
REM Navigate to the agent directory
cd /d %~dp0\..\agent
REM Activate the virtual environment
call .venv\Scripts\activate.bat
REM Run the agent
uv run main.py
@@ -0,0 +1,10 @@
#!/bin/bash
# Navigate to the agent directory
cd "$(dirname "$0")/../agent" || exit 1
# Activate the virtual environment
source .venv/bin/activate
# Run the agent
uv run main.py
@@ -0,0 +1,5 @@
@echo off
REM Navigate to the agent directory
cd /d "%~dp0\..\agent" || exit /b 1
uv sync
@@ -0,0 +1,6 @@
#!/bin/bash
# Navigate to the agent directory
cd "$(dirname "$0")/../agent" || exit 1
uv sync

Some files were not shown because too many files have changed in this diff Show More