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.
@@ -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.*
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
|
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, "funnel
|
||||
of signups and revenue line chart").
|
||||
</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",
|
||||
]
|
||||
@@ -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;
|
||||
@@ -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
|
||||