chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
|
||||
|
||||
const AGENT_URL =
|
||||
process.env.AGENT_URL ||
|
||||
process.env.LANGGRAPH_DEPLOYMENT_URL ||
|
||||
"http://localhost:8123";
|
||||
|
||||
if (!process.env.AGENT_URL && !process.env.LANGGRAPH_DEPLOYMENT_URL) {
|
||||
console.warn(
|
||||
"[copilotkit/route] WARNING: No AGENT_URL or LANGGRAPH_DEPLOYMENT_URL set, falling back to localhost:8123",
|
||||
);
|
||||
}
|
||||
|
||||
console.log("[copilotkit/route] Initializing CopilotKit runtime");
|
||||
console.log(`[copilotkit/route] AGENT_URL: ${AGENT_URL}`);
|
||||
|
||||
function createAgent(graphId: string = "sample_agent") {
|
||||
return new LangGraphAgent({
|
||||
deploymentUrl: AGENT_URL,
|
||||
graphId,
|
||||
langsmithApiKey: process.env.LANGSMITH_API_KEY || "",
|
||||
});
|
||||
}
|
||||
|
||||
const agentNames = [
|
||||
"sample_agent",
|
||||
"agentic_chat",
|
||||
"human_in_the_loop",
|
||||
"tool-rendering",
|
||||
"gen-ui-tool-based",
|
||||
"gen-ui-agent",
|
||||
"shared-state-read",
|
||||
"shared-state-write",
|
||||
"shared-state-streaming",
|
||||
"subagents",
|
||||
"default",
|
||||
];
|
||||
|
||||
const agents: Record<string, LangGraphAgent> = {};
|
||||
for (const name of agentNames) {
|
||||
agents[name] = createAgent();
|
||||
}
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime: new CopilotRuntime({
|
||||
// @ts-expect-error -- type wrapping mismatch, fixed in source pending release
|
||||
agents,
|
||||
}),
|
||||
});
|
||||
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
console.error("[copilotkit/route] ERROR:", error);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
export const GET = async () => {
|
||||
let agentStatus = "unknown";
|
||||
try {
|
||||
const res = await fetch(`${AGENT_URL}/health`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
agentStatus = res.ok ? "reachable" : `error (${res.status})`;
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
agentStatus = `unreachable (${msg})`;
|
||||
}
|
||||
|
||||
const topStatus = agentStatus.startsWith("unreachable") ? "degraded" : "ok";
|
||||
|
||||
return NextResponse.json({
|
||||
status: topStatus,
|
||||
agent_url: AGENT_URL,
|
||||
agent_status: agentStatus,
|
||||
env: {
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY ? "set" : "NOT SET",
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
integration: "{{SLUG}}",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/* CopilotKit overrides — EXACTLY matches Dojo, nothing more */
|
||||
|
||||
.copilotKitInput {
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--copilot-kit-separator-color) !important;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--copilot-kit-background-color: #fafaf9;
|
||||
--copilot-kit-primary-color: #0d6e3f;
|
||||
--copilot-kit-response-button-background-color: #f5f5f3;
|
||||
--copilot-kit-response-button-color: #1a1a18;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #fafaf9;
|
||||
color: #1a1a18;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Metadata } from "next";
|
||||
import "@copilotkit/react-core/v2/styles.css";
|
||||
import "./globals.css";
|
||||
import "./copilotkit-overrides.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "CopilotKit Showcase — {{NAME}}",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
console.log('[showcase] {{NAME}} demo loaded');
|
||||
console.log('[showcase] URL:', window.location.href);
|
||||
console.log('[showcase] In iframe:', window.self !== window.top);
|
||||
window.addEventListener('error', function(e) {
|
||||
console.error('[showcase] Uncaught error:', e.message, e.filename, e.lineno);
|
||||
});
|
||||
window.addEventListener('unhandledrejection', function(e) {
|
||||
console.error('[showcase] Unhandled rejection:', e.reason);
|
||||
});
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { CopilotKit } from "@copilotkit/react-core";
|
||||
import { CopilotSidebar } from "@copilotkit/react-core/v2";
|
||||
import { SalesDashboard } from "../components/sales-dashboard";
|
||||
import { RendererSelector } from "../components/renderers/renderer-selector";
|
||||
import { useRenderMode } from "../components/renderers/use-render-mode";
|
||||
import { useShowcaseHooks } from "../hooks/use-showcase-hooks";
|
||||
import { useShowcaseSuggestions } from "../hooks/use-showcase-suggestions";
|
||||
import { ToolBasedDashboard } from "../components/renderers/tool-based";
|
||||
import { A2UIDashboard } from "../components/renderers/a2ui";
|
||||
import {
|
||||
HashBrownDashboard,
|
||||
useHashBrownMessageRenderer,
|
||||
} from "../components/renderers/hashbrown";
|
||||
const AGENT_ID = "sample_agent";
|
||||
|
||||
function ToolBasedPage() {
|
||||
return <ToolBasedDashboard agentId={AGENT_ID} />;
|
||||
}
|
||||
|
||||
function A2UIPage() {
|
||||
return <A2UIDashboard agentId={AGENT_ID} />;
|
||||
}
|
||||
|
||||
function JsonRenderPage() {
|
||||
// json-render falls back to tool-based with a note
|
||||
return (
|
||||
<div>
|
||||
<div className="bg-yellow-50 border border-yellow-200 text-yellow-800 text-sm px-4 py-2 text-center">
|
||||
json-render is not yet available as a standalone starter. Showing
|
||||
tool-based rendering instead.
|
||||
</div>
|
||||
<ToolBasedDashboard agentId={AGENT_ID} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HashBrownInner() {
|
||||
const RenderMessage = useHashBrownMessageRenderer();
|
||||
useShowcaseHooks();
|
||||
useShowcaseSuggestions();
|
||||
|
||||
return (
|
||||
<CopilotKit runtimeUrl="/api/copilotkit" agent={AGENT_ID}>
|
||||
<div className="min-h-screen w-full flex items-center justify-center">
|
||||
<SalesDashboard agentId={AGENT_ID} />
|
||||
<CopilotSidebar
|
||||
defaultOpen={true}
|
||||
labels={{ modalHeaderTitle: "Sales Dashboard Assistant" }}
|
||||
RenderMessage={RenderMessage}
|
||||
/>
|
||||
</div>
|
||||
</CopilotKit>
|
||||
);
|
||||
}
|
||||
|
||||
function HashBrownPage() {
|
||||
return (
|
||||
<HashBrownDashboard>
|
||||
<HashBrownInner />
|
||||
</HashBrownDashboard>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const { mode, setMode } = useRenderMode();
|
||||
|
||||
const renderDashboard = () => {
|
||||
switch (mode) {
|
||||
case "tool-based":
|
||||
return <ToolBasedPage />;
|
||||
case "a2ui":
|
||||
return <A2UIPage />;
|
||||
case "json-render":
|
||||
return <JsonRenderPage />;
|
||||
case "hashbrown":
|
||||
return <HashBrownPage />;
|
||||
default:
|
||||
return <ToolBasedPage />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<header className="sticky top-0 z-[60] border-b border-[var(--border)] bg-[var(--card)] px-6 py-3 flex items-center justify-between shrink-0">
|
||||
<h1 className="text-sm font-bold text-[var(--foreground)]">
|
||||
CopilotKit Sales Dashboard
|
||||
</h1>
|
||||
<RendererSelector mode={mode} onModeChange={setMode} />
|
||||
</header>
|
||||
<main className="flex-1 overflow-hidden">{renderDashboard()}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useRef } from "react";
|
||||
import {
|
||||
BarChart as RechartsBarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Rectangle,
|
||||
} from "recharts";
|
||||
import { z } from "zod";
|
||||
import { CHART_COLORS, CHART_CONFIG } from "./chart-config";
|
||||
|
||||
export const BarChartProps = z.object({
|
||||
title: z.string().describe("Chart title"),
|
||||
description: z.string().describe("Brief description or subtitle"),
|
||||
data: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.number(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type BarChartPropsType = z.infer<typeof BarChartProps>;
|
||||
|
||||
/** Tracks seen indices so only NEW bars get the fade-in animation. */
|
||||
function useSeenIndices() {
|
||||
const seen = useRef(new Set<number>());
|
||||
return {
|
||||
isNew(index: number) {
|
||||
if (seen.current.has(index)) return false;
|
||||
seen.current.add(index);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function AnimatedBar(props: any) {
|
||||
const { isNew, ...rest } = props;
|
||||
return (
|
||||
<g
|
||||
style={
|
||||
isNew
|
||||
? {
|
||||
animation: "barSlideIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) both",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Rectangle {...rest} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function BarChart({ title, description, data }: BarChartPropsType) {
|
||||
const { isNew } = useSeenIndices();
|
||||
|
||||
if (!data || !Array.isArray(data) || data.length === 0) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto my-4 rounded-lg border border-[var(--border)] bg-[var(--card)]">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="h-4 w-4 text-[var(--muted-foreground)]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="20" x2="18" y2="10" />
|
||||
<line x1="12" y1="20" x2="12" y2="4" />
|
||||
<line x1="6" y1="20" x2="6" y2="14" />
|
||||
</svg>
|
||||
<h3 className="text-lg font-semibold leading-none tracking-tight text-[var(--foreground)]">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 pt-0">
|
||||
<p className="text-[var(--muted-foreground)] text-center py-8 text-sm">
|
||||
No data available
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto my-4 overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--card)]">
|
||||
{/* Scoped keyframe -- no globals.css needed */}
|
||||
<style>{`
|
||||
@keyframes barSlideIn {
|
||||
from { transform: translateY(40px); opacity: 0; }
|
||||
20% { opacity: 1; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
<div className="p-6 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center h-6 w-6 rounded-md bg-[var(--secondary)]">
|
||||
<svg
|
||||
className="h-3.5 w-3.5 text-[var(--muted-foreground)]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="20" x2="18" y2="10" />
|
||||
<line x1="12" y1="20" x2="12" y2="4" />
|
||||
<line x1="6" y1="20" x2="6" y2="14" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold leading-none tracking-tight text-[var(--foreground)]">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">{description}</p>
|
||||
</div>
|
||||
<div className="p-6 pt-2">
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<RechartsBarChart
|
||||
data={data}
|
||||
margin={{ top: 12, right: 12, bottom: 4, left: -8 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 12, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={CHART_CONFIG.tooltipStyle}
|
||||
cursor={{ fill: "var(--secondary)", opacity: 0.5 }}
|
||||
/>
|
||||
<Bar
|
||||
isAnimationActive={false}
|
||||
dataKey="value"
|
||||
radius={[6, 6, 0, 0]}
|
||||
maxBarSize={48}
|
||||
shape={(props: unknown) => {
|
||||
const p = props as Record<string, unknown>;
|
||||
return <AnimatedBar {...p} isNew={isNew(p.index as number)} />;
|
||||
}}
|
||||
>
|
||||
{data.map((_, index) => (
|
||||
<Cell
|
||||
key={index}
|
||||
fill={CHART_COLORS[index % CHART_COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</RechartsBarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* CopilotKit brand chart palette -- Plus Jakarta Sans / brand color system.
|
||||
*/
|
||||
export const CHART_COLORS = [
|
||||
"#BEC2FF", // lilac-400
|
||||
"#85ECCE", // mint-400
|
||||
"#FFAC4D", // orange-400
|
||||
"#FFF388", // yellow-400
|
||||
"#189370", // mint-800
|
||||
"#EEE6FE", // primary-100
|
||||
"#FA5F67", // red-400
|
||||
] as const;
|
||||
|
||||
export const CHART_CONFIG = {
|
||||
tooltipStyle: {
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "10px",
|
||||
padding: "10px 14px",
|
||||
color: "var(--foreground)",
|
||||
fontSize: "13px",
|
||||
fontFamily: "var(--font-body)",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.08)",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
import { z } from "zod";
|
||||
import { CHART_COLORS } from "./chart-config";
|
||||
|
||||
export const PieChartProps = z.object({
|
||||
title: z.string().describe("Chart title"),
|
||||
description: z.string().describe("Brief description or subtitle"),
|
||||
data: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.number(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type PieChartPropsType = z.infer<typeof PieChartProps>;
|
||||
|
||||
/** Custom SVG donut chart built with <circle> + stroke-dasharray. */
|
||||
function DonutChart({
|
||||
data,
|
||||
size = 240,
|
||||
strokeWidth = 40,
|
||||
}: {
|
||||
data: { label: string; value: number }[];
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
}) {
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const center = size / 2;
|
||||
|
||||
const total = data.reduce((sum, d) => sum + (Number(d.value) || 0), 0);
|
||||
|
||||
// Calculate each slice's arc length and starting position
|
||||
let accumulated = 0;
|
||||
const slices = data.map((item, index) => {
|
||||
const val = Number(item.value) || 0;
|
||||
const ratio = total > 0 ? val / total : 0;
|
||||
const arc = ratio * circumference;
|
||||
const startAt = accumulated;
|
||||
accumulated += arc;
|
||||
return {
|
||||
...item,
|
||||
arc,
|
||||
gap: circumference - arc,
|
||||
// Negative dashoffset shifts the dash forward (clockwise) to the correct position
|
||||
dashoffset: -startAt,
|
||||
color: CHART_COLORS[index % CHART_COLORS.length],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<svg
|
||||
width="100%"
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
className="block mx-auto"
|
||||
style={{ maxWidth: size, transform: "scaleX(-1)" }}
|
||||
>
|
||||
{/* Background ring */}
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="var(--secondary)"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
{/* Data slices */}
|
||||
{slices.map((slice, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={slice.color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={`${slice.arc} ${slice.gap}`}
|
||||
strokeDashoffset={slice.dashoffset}
|
||||
strokeLinecap="butt"
|
||||
transform={`rotate(-90 ${center} ${center})`}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PieChart({ title, description, data }: PieChartPropsType) {
|
||||
if (!data || !Array.isArray(data) || data.length === 0) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto my-4 rounded-lg border border-[var(--border)] bg-[var(--card)]">
|
||||
<div className="p-6 pb-0">
|
||||
<h3 className="text-lg font-semibold leading-none tracking-tight text-[var(--foreground)]">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<p className="text-[var(--muted-foreground)] text-center py-8 text-sm">
|
||||
No data available
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const total = data.reduce((sum, d) => sum + (Number(d.value) || 0), 0);
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto my-4 overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--card)]">
|
||||
<div className="p-6 pb-0">
|
||||
<h3 className="text-lg font-semibold leading-none tracking-tight text-[var(--foreground)]">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">{description}</p>
|
||||
</div>
|
||||
<div className="p-6 pt-4">
|
||||
<DonutChart data={data} />
|
||||
|
||||
{/* Legend */}
|
||||
<div className="space-y-2 pt-4">
|
||||
{data.map((item, index) => {
|
||||
const val = Number(item.value) || 0;
|
||||
const pct = total > 0 ? ((val / total) * 100).toFixed(0) : 0;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-3 text-sm transition-opacity duration-300 ease-out"
|
||||
style={{ opacity: 1 }}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-3 w-3 rounded-full shrink-0"
|
||||
style={{
|
||||
backgroundColor: CHART_COLORS[index % CHART_COLORS.length],
|
||||
}}
|
||||
/>
|
||||
<span className="flex-1 text-[var(--foreground)] truncate">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="text-[var(--muted-foreground)] tabular-nums">
|
||||
{val.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-[var(--muted-foreground)] text-sm w-10 text-right tabular-nums">
|
||||
{pct}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useState } from "react";
|
||||
|
||||
export interface TimeSlot {
|
||||
date: string;
|
||||
time: string;
|
||||
duration?: string;
|
||||
}
|
||||
|
||||
export interface MeetingTimePickerProps {
|
||||
status: "inProgress" | "executing" | "complete";
|
||||
respond?: (response: string) => void;
|
||||
reasonForScheduling?: string;
|
||||
meetingDuration?: number;
|
||||
title?: string;
|
||||
timeSlots?: TimeSlot[];
|
||||
}
|
||||
|
||||
export function MeetingTimePicker({
|
||||
status,
|
||||
respond,
|
||||
reasonForScheduling,
|
||||
meetingDuration,
|
||||
title = "Schedule a Meeting",
|
||||
timeSlots = [
|
||||
{ date: "Tomorrow", time: "2:00 PM", duration: "30 min" },
|
||||
{ date: "Friday", time: "10:00 AM", duration: "30 min" },
|
||||
{ date: "Next Monday", time: "3:00 PM", duration: "30 min" },
|
||||
],
|
||||
}: MeetingTimePickerProps) {
|
||||
const displayTitle = reasonForScheduling || title;
|
||||
const slots = meetingDuration
|
||||
? timeSlots.map((slot) => ({ ...slot, duration: `${meetingDuration} min` }))
|
||||
: timeSlots;
|
||||
const [selectedSlot, setSelectedSlot] = useState<TimeSlot | null>(null);
|
||||
const [declined, setDeclined] = useState(false);
|
||||
|
||||
const handleSelectSlot = (slot: TimeSlot) => {
|
||||
setSelectedSlot(slot);
|
||||
respond?.(
|
||||
`Meeting scheduled for ${slot.date} at ${slot.time}${slot.duration ? ` (${slot.duration})` : ""}.`,
|
||||
);
|
||||
};
|
||||
|
||||
const handleDecline = () => {
|
||||
setDeclined(true);
|
||||
respond?.(
|
||||
"The user declined all proposed meeting times. Please suggest alternative times or ask for their availability.",
|
||||
);
|
||||
};
|
||||
|
||||
// Confirmed state
|
||||
if (selectedSlot) {
|
||||
return (
|
||||
<div className="max-w-md w-full mx-auto mb-4 overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--card)]">
|
||||
<div className="p-6">
|
||||
<div className="flex flex-col items-center text-center gap-3">
|
||||
<div className="flex items-center justify-center h-10 w-10 rounded-full bg-[#189370]">
|
||||
<svg
|
||||
className="h-5 w-5 text-white"
|
||||
strokeWidth={3}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-[var(--foreground)]">
|
||||
Meeting Scheduled
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
||||
{selectedSlot.date} at {selectedSlot.time}
|
||||
</p>
|
||||
</div>
|
||||
{selectedSlot.duration && (
|
||||
<span className="inline-flex items-center rounded-full border border-[var(--border)] bg-[var(--secondary)] px-2.5 py-0.5 text-xs font-semibold text-[var(--secondary-foreground)]">
|
||||
<svg
|
||||
className="h-3 w-3 mr-1"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
{selectedSlot.duration}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Declined state
|
||||
if (declined) {
|
||||
return (
|
||||
<div className="max-w-md w-full mx-auto mb-4 overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--card)]">
|
||||
<div className="p-6">
|
||||
<div className="flex flex-col items-center text-center gap-3">
|
||||
<div className="flex items-center justify-center h-12 w-12 rounded-full bg-[var(--secondary)]">
|
||||
<svg
|
||||
className="h-6 w-6 text-[var(--muted-foreground)]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-[var(--foreground)]">
|
||||
No Time Selected
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
||||
Looking for a better time that works for you
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Selection state
|
||||
return (
|
||||
<div className="max-w-md w-full mx-auto mb-4 overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--card)]">
|
||||
<div className="p-6">
|
||||
<div className="flex flex-col items-center text-center mb-5">
|
||||
<div className="flex items-center justify-center h-12 w-12 rounded-full bg-[var(--accent)] mb-3">
|
||||
<svg
|
||||
className="h-6 w-6 text-[#BEC2FF]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-[var(--foreground)]">
|
||||
{displayTitle}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
||||
{status === "inProgress"
|
||||
? "Finding available times..."
|
||||
: "Pick a time that works for you"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{status === "inProgress" && (
|
||||
<div className="flex justify-center py-6">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-[var(--muted)] border-t-[var(--foreground)]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "executing" && (
|
||||
<div className="space-y-3">
|
||||
{slots.map((slot, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleSelectSlot(slot)}
|
||||
className="group w-full px-6 py-5 rounded-[var(--radius)]
|
||||
border border-[var(--border)]
|
||||
hover:border-[var(--ring)] hover:bg-[var(--accent)]
|
||||
transition-all duration-150 cursor-pointer
|
||||
flex items-center gap-4"
|
||||
>
|
||||
<div className="flex-1 text-left">
|
||||
<div className="font-semibold text-base text-[var(--foreground)]">
|
||||
{slot.date}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)] mt-0.5">
|
||||
{slot.time}
|
||||
</div>
|
||||
</div>
|
||||
{slot.duration && (
|
||||
<span className="shrink-0 text-sm px-3 py-1 inline-flex items-center rounded-full border border-[var(--border)] bg-[var(--secondary)] font-semibold text-[var(--secondary-foreground)]">
|
||||
{slot.duration}
|
||||
</span>
|
||||
)}
|
||||
<svg
|
||||
className="h-4 w-4 text-[var(--muted-foreground)] opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</button>
|
||||
))}
|
||||
|
||||
<button
|
||||
className="w-full mt-1 text-xs text-[var(--muted-foreground)] py-2 hover:bg-[var(--accent)] rounded-md transition-colors"
|
||||
onClick={handleDecline}
|
||||
>
|
||||
None of these work
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Demonstration Catalog -- Component Definitions
|
||||
*
|
||||
* Platform-agnostic definitions: component names, props (Zod), descriptions.
|
||||
* This is the contract between the app and the AI agent. Agents receive these
|
||||
* definitions as context so they know what components are available.
|
||||
*
|
||||
* Renderers (React, React Native, etc.) import these definitions and provide
|
||||
* platform-specific implementations, type-checked against the Zod schemas.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Dynamic string: accepts either a literal string or a data-model path binding
|
||||
* like `{ path: "airline" }`. The GenericBinder resolves path bindings to the
|
||||
* actual value at render time.
|
||||
*/
|
||||
const DynString = z.union([z.string(), z.object({ path: z.string() })]);
|
||||
|
||||
/**
|
||||
* Shared action schema used by interactive components (Button, FlightCard).
|
||||
* The GenericBinder resolves `{ event }` objects to callable `() => void` at
|
||||
* render time.
|
||||
*/
|
||||
const ActionSchema = z
|
||||
.union([
|
||||
z.object({
|
||||
event: z.object({
|
||||
name: z.string(),
|
||||
context: z.record(z.any()).optional(),
|
||||
}),
|
||||
}),
|
||||
z.null(),
|
||||
])
|
||||
.optional();
|
||||
|
||||
export const demonstrationCatalogDefinitions = {
|
||||
Title: {
|
||||
description: "A heading. Use for section titles and page headers.",
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
level: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
// Text: removed -- the basic catalog's Text uses DynamicStringSchema
|
||||
// which supports path bindings (e.g. { path: "flights[*].airline" }).
|
||||
// Overriding it with z.string() breaks fixed-schema data binding.
|
||||
|
||||
Row: {
|
||||
description: "Horizontal layout container.",
|
||||
props: z.object({
|
||||
gap: z.number().optional(),
|
||||
align: z.string().optional(),
|
||||
justify: z.string().optional(),
|
||||
// Union with { componentId, path } so GenericBinder treats this as
|
||||
// STRUCTURAL and resolves template children from the data model.
|
||||
children: z.union([
|
||||
z.array(z.string()),
|
||||
z.object({ componentId: z.string(), path: z.string() }),
|
||||
]),
|
||||
}),
|
||||
},
|
||||
|
||||
Column: {
|
||||
description: "Vertical layout container.",
|
||||
props: z.object({
|
||||
gap: z.number().optional(),
|
||||
align: z.string().optional(),
|
||||
// Same union as Row -- required for template children support.
|
||||
children: z.union([
|
||||
z.array(z.string()),
|
||||
z.object({ componentId: z.string(), path: z.string() }),
|
||||
]),
|
||||
}),
|
||||
},
|
||||
|
||||
DashboardCard: {
|
||||
description:
|
||||
"A card container with title and optional subtitle. Has a 'child' slot for content (chart, metrics, etc). Use 'child' with a single component ID.",
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
subtitle: z.string().optional(),
|
||||
child: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
Metric: {
|
||||
description:
|
||||
"A key metric display with label, value, and optional trend indicator. Great for KPIs and stats.",
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
trend: z.enum(["up", "down", "neutral"]).optional(),
|
||||
trendValue: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
PieChart: {
|
||||
description:
|
||||
"A pie/donut chart. Provide data as array of {label, value, color} objects.",
|
||||
props: z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.number(),
|
||||
color: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
innerRadius: z.number().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
BarChart: {
|
||||
description:
|
||||
"A bar chart. Provide data as array of {label, value} objects.",
|
||||
props: z.object({
|
||||
data: z.array(z.object({ label: z.string(), value: z.number() })),
|
||||
color: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
Badge: {
|
||||
description:
|
||||
"A small status badge/tag. Use for labels, statuses, categories.",
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
variant: z
|
||||
.enum(["success", "warning", "error", "info", "neutral"])
|
||||
.optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
DataTable: {
|
||||
description: "A data table with columns and rows.",
|
||||
props: z.object({
|
||||
columns: z.array(z.object({ key: z.string(), label: z.string() })),
|
||||
rows: z.array(z.record(z.any())),
|
||||
}),
|
||||
},
|
||||
|
||||
Button: {
|
||||
description:
|
||||
"An interactive button with an action event. Use 'child' with a Text component ID for the label. 'action' is dispatched on click.",
|
||||
props: z.object({
|
||||
child: z
|
||||
.string()
|
||||
.describe(
|
||||
"The ID of the child component (e.g. a Text component for the label).",
|
||||
),
|
||||
variant: z.enum(["primary", "secondary", "ghost"]).optional(),
|
||||
action: ActionSchema,
|
||||
}),
|
||||
},
|
||||
|
||||
FlightCard: {
|
||||
description:
|
||||
"A rich flight result card. Displays airline, flight number, route, times, duration, status, and price. Use inside a Row for side-by-side layout.",
|
||||
props: z.object({
|
||||
airline: DynString,
|
||||
airlineLogo: DynString,
|
||||
flightNumber: DynString,
|
||||
origin: DynString,
|
||||
destination: DynString,
|
||||
date: DynString,
|
||||
departureTime: DynString,
|
||||
arrivalTime: DynString,
|
||||
duration: DynString,
|
||||
status: DynString,
|
||||
statusColor: DynString.optional(),
|
||||
price: DynString,
|
||||
action: ActionSchema,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
/** Type helper for renderers */
|
||||
export type DemonstrationCatalogDefinitions =
|
||||
typeof demonstrationCatalogDefinitions;
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { CopilotKit } from "@copilotkit/react-core";
|
||||
import { CopilotSidebar } from "@copilotkit/react-core/v2";
|
||||
import { useShowcaseHooks } from "../../../hooks/use-showcase-hooks";
|
||||
import { useShowcaseSuggestions } from "../../../hooks/use-showcase-suggestions";
|
||||
import { SalesDashboard } from "../../sales-dashboard";
|
||||
import { demonstrationCatalog } from "./renderers";
|
||||
|
||||
interface A2UIDashboardProps {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
function DashboardContent({ agentId }: A2UIDashboardProps) {
|
||||
useShowcaseHooks();
|
||||
useShowcaseSuggestions();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full flex items-center justify-center">
|
||||
<SalesDashboard agentId={agentId} />
|
||||
<CopilotSidebar
|
||||
defaultOpen={true}
|
||||
labels={{
|
||||
modalHeaderTitle: "Sales Dashboard Assistant",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function A2UIDashboard({ agentId }: A2UIDashboardProps) {
|
||||
return (
|
||||
<CopilotKit
|
||||
runtimeUrl="/api/copilotkit"
|
||||
agent={agentId}
|
||||
a2ui={{ catalog: demonstrationCatalog }}
|
||||
>
|
||||
<DashboardContent agentId={agentId} />
|
||||
</CopilotKit>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
/**
|
||||
* A2UI Catalog -- React Renderers
|
||||
*
|
||||
* Each renderer maps a component name from definitions.ts to a React
|
||||
* implementation. Props are type-checked against the Zod schemas.
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
PieChart as RechartsPie,
|
||||
Pie,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
BarChart as RechartsBar,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
} from "recharts";
|
||||
import {
|
||||
createCatalog,
|
||||
type CatalogRenderers,
|
||||
} from "@copilotkit/a2ui-renderer";
|
||||
import {
|
||||
demonstrationCatalogDefinitions,
|
||||
type DemonstrationCatalogDefinitions,
|
||||
} from "./definitions";
|
||||
|
||||
// --- Theme-aware colors ---
|
||||
|
||||
const c = {
|
||||
card: "var(--card)",
|
||||
cardFg: "var(--card-foreground)",
|
||||
border: "var(--border)",
|
||||
muted: "var(--muted-foreground)",
|
||||
divider: "color-mix(in srgb, var(--border) 50%, var(--card))",
|
||||
shadow: "0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04)",
|
||||
btnBg: "color-mix(in srgb, var(--muted) 40%, var(--card))",
|
||||
btnDoneBg: "color-mix(in srgb, #22c55e 10%, var(--card))",
|
||||
};
|
||||
|
||||
function ActionButton({
|
||||
label,
|
||||
doneLabel,
|
||||
action,
|
||||
children: child,
|
||||
}: {
|
||||
label: string;
|
||||
doneLabel: string;
|
||||
action: unknown;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const [done, setDone] = useState(false);
|
||||
return (
|
||||
<button
|
||||
disabled={done}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px 16px",
|
||||
borderRadius: "10px",
|
||||
border: done ? "1px solid #bbf7d0" : `1px solid ${c.border}`,
|
||||
background: done ? c.btnDoneBg : c.btnBg,
|
||||
color: done ? "#059669" : c.cardFg,
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
cursor: done ? "default" : "pointer",
|
||||
transition: "all 0.2s ease",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "6px",
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!done) {
|
||||
(action as (() => void) | undefined)?.();
|
||||
setDone(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{done && (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#059669"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
{done ? doneLabel : (child ?? label)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Renderers (type-checked against schema definitions) ---
|
||||
|
||||
const demonstrationCatalogRenderers: CatalogRenderers<DemonstrationCatalogDefinitions> =
|
||||
{
|
||||
Title: ({ props }) => {
|
||||
const Tag = (
|
||||
props.level === "h1" ? "h1" : props.level === "h3" ? "h3" : "h2"
|
||||
) as keyof React.JSX.IntrinsicElements;
|
||||
const sizes: Record<string, string> = {
|
||||
h1: "1.75rem",
|
||||
h2: "1.25rem",
|
||||
h3: "1rem",
|
||||
};
|
||||
return (
|
||||
<Tag
|
||||
style={{
|
||||
margin: 0,
|
||||
fontWeight: 600,
|
||||
fontSize: sizes[props.level ?? "h2"],
|
||||
color: c.cardFg,
|
||||
letterSpacing: "-0.01em",
|
||||
}}
|
||||
>
|
||||
{props.text}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
|
||||
Row: ({ props, children }) => {
|
||||
const justifyMap: Record<string, string> = {
|
||||
start: "flex-start",
|
||||
center: "center",
|
||||
end: "flex-end",
|
||||
spaceBetween: "space-between",
|
||||
};
|
||||
const items = Array.isArray(props.children) ? props.children : [];
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
gap: `${props.gap ?? 16}px`,
|
||||
alignItems: props.align ?? "stretch",
|
||||
justifyContent:
|
||||
justifyMap[props.justify ?? "start"] ?? "flex-start",
|
||||
flexWrap: "wrap",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{items.map((item: unknown, i: number) => {
|
||||
if (typeof item === "string")
|
||||
return (
|
||||
<div
|
||||
key={`${item}-${i}`}
|
||||
style={{ flex: "1 1 0", minWidth: 0 }}
|
||||
>
|
||||
{children(item)}
|
||||
</div>
|
||||
);
|
||||
if (
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
"id" in (item as Record<string, unknown>)
|
||||
)
|
||||
return (
|
||||
<div
|
||||
key={`${(item as Record<string, unknown>).id}-${i}`}
|
||||
style={{ flex: "1 1 0", minWidth: 0 }}
|
||||
>
|
||||
{(
|
||||
children as (
|
||||
id: string,
|
||||
basePath?: string,
|
||||
) => React.ReactNode
|
||||
)(
|
||||
(item as Record<string, unknown>).id,
|
||||
(item as Record<string, unknown>).basePath,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Column: ({ props, children }) => {
|
||||
const items = Array.isArray(props.children) ? props.children : [];
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${props.gap ?? 12}px`,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{items.map((item: unknown, i: number) => {
|
||||
if (typeof item === "string")
|
||||
return (
|
||||
<React.Fragment key={`${item}-${i}`}>
|
||||
{children(item)}
|
||||
</React.Fragment>
|
||||
);
|
||||
if (
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
"id" in (item as Record<string, unknown>)
|
||||
)
|
||||
return (
|
||||
<React.Fragment
|
||||
key={`${(item as Record<string, unknown>).id}-${i}`}
|
||||
>
|
||||
{(
|
||||
children as (
|
||||
id: string,
|
||||
basePath?: string,
|
||||
) => React.ReactNode
|
||||
)(
|
||||
(item as Record<string, unknown>).id,
|
||||
(item as Record<string, unknown>).basePath,
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
DashboardCard: ({ props, children }) => (
|
||||
<div
|
||||
style={{
|
||||
background: c.card,
|
||||
borderRadius: "12px",
|
||||
border: `1px solid ${c.border}`,
|
||||
padding: "20px",
|
||||
boxShadow: c.shadow,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "12px",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: "0.9rem", color: c.cardFg }}>
|
||||
{props.title}
|
||||
</div>
|
||||
{props.subtitle && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
color: c.muted,
|
||||
marginTop: "2px",
|
||||
}}
|
||||
>
|
||||
{props.subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{props.child && children(props.child)}
|
||||
</div>
|
||||
),
|
||||
|
||||
Metric: ({ props }) => {
|
||||
const trendColors: Record<string, string> = {
|
||||
up: "#059669",
|
||||
down: "#dc2626",
|
||||
neutral: c.muted,
|
||||
};
|
||||
const trendIcons: Record<string, string> = {
|
||||
up: "\u2191",
|
||||
down: "\u2193",
|
||||
neutral: "\u2192",
|
||||
};
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
color: c.muted,
|
||||
fontWeight: 500,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</span>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: "8px" }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "1.5rem",
|
||||
fontWeight: 700,
|
||||
color: c.cardFg,
|
||||
letterSpacing: "-0.02em",
|
||||
}}
|
||||
>
|
||||
{props.value}
|
||||
</span>
|
||||
{props.trend && props.trendValue && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 500,
|
||||
color: trendColors[props.trend] ?? c.muted,
|
||||
}}
|
||||
>
|
||||
{trendIcons[props.trend]} {props.trendValue}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
PieChart: ({ props }) => {
|
||||
const COLORS = [
|
||||
"#3b82f6",
|
||||
"#8b5cf6",
|
||||
"#ec4899",
|
||||
"#f59e0b",
|
||||
"#10b981",
|
||||
"#6366f1",
|
||||
];
|
||||
const data = props.data ?? [];
|
||||
return (
|
||||
<div style={{ width: "100%", height: 200 }}>
|
||||
<ResponsiveContainer>
|
||||
<RechartsPie>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="value"
|
||||
nameKey="label"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={props.innerRadius ?? 40}
|
||||
outerRadius={80}
|
||||
paddingAngle={2}
|
||||
>
|
||||
{data.map((entry: Record<string, unknown>, i: number) => (
|
||||
<Cell
|
||||
key={i}
|
||||
fill={(entry.color as string) ?? COLORS[i % COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</RechartsPie>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
BarChart: ({ props }) => {
|
||||
const data = props.data ?? [];
|
||||
return (
|
||||
<div style={{ width: "100%", height: 200 }}>
|
||||
<ResponsiveContainer>
|
||||
<RechartsBar data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={c.divider} />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11, fill: c.muted }} />
|
||||
<YAxis tick={{ fontSize: 11, fill: c.muted }} />
|
||||
<Tooltip />
|
||||
<Bar
|
||||
dataKey="value"
|
||||
fill={props.color ?? "#3b82f6"}
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</RechartsBar>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Badge: ({ props }) => {
|
||||
const variants: Record<string, { bg: string; color: string }> = {
|
||||
success: { bg: "#dcfce7", color: "#166534" },
|
||||
warning: { bg: "#fef3c7", color: "#92400e" },
|
||||
error: { bg: "#fee2e2", color: "#991b1b" },
|
||||
info: { bg: "#dbeafe", color: "#1e40af" },
|
||||
neutral: { bg: "var(--muted)", color: c.cardFg },
|
||||
};
|
||||
const v = variants[props.variant ?? "neutral"] ?? variants.neutral;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "2px 8px",
|
||||
borderRadius: "9999px",
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 500,
|
||||
background: v.bg,
|
||||
color: v.color,
|
||||
}}
|
||||
>
|
||||
{props.text}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
|
||||
DataTable: ({ props }) => {
|
||||
const cols = props.columns ?? [];
|
||||
const rows = props.rows ?? [];
|
||||
return (
|
||||
<div style={{ overflowX: "auto", width: "100%" }}>
|
||||
<table
|
||||
style={{
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
fontSize: "0.8rem",
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
{cols.map((col: Record<string, unknown>) => (
|
||||
<th
|
||||
key={col.key as string}
|
||||
style={{
|
||||
textAlign: "left",
|
||||
padding: "8px 12px",
|
||||
borderBottom: `2px solid ${c.border}`,
|
||||
color: c.muted,
|
||||
fontWeight: 600,
|
||||
fontSize: "0.7rem",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
{col.label as string}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row: Record<string, unknown>, i: number) => (
|
||||
<tr key={i} style={{ borderBottom: `1px solid ${c.divider}` }}>
|
||||
{cols.map((col: Record<string, unknown>) => (
|
||||
<td
|
||||
key={col.key as string}
|
||||
style={{ padding: "8px 12px", color: c.cardFg }}
|
||||
>
|
||||
{String(row[col.key as string] ?? "")}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Button: ({ props, children }) => {
|
||||
return (
|
||||
<ActionButton label="Click" doneLabel="Done" action={props.action}>
|
||||
{props.child ? children(props.child) : null}
|
||||
</ActionButton>
|
||||
);
|
||||
},
|
||||
|
||||
FlightCard: ({ props: rawProps }) => {
|
||||
// The binder resolves path bindings to strings at runtime.
|
||||
const props = rawProps as Record<string, unknown>;
|
||||
const statusColors: Record<string, string> = {
|
||||
"On Time": "#22c55e",
|
||||
Delayed: "#eab308",
|
||||
Cancelled: "#ef4444",
|
||||
};
|
||||
const dotColor =
|
||||
(props.statusColor as string) ??
|
||||
statusColors[props.status as string] ??
|
||||
"#22c55e";
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: `1px solid ${c.border}`,
|
||||
borderRadius: "16px",
|
||||
padding: "20px",
|
||||
background: c.card,
|
||||
color: c.cardFg,
|
||||
minWidth: 260,
|
||||
maxWidth: 340,
|
||||
flex: "1 1 260px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "12px",
|
||||
boxShadow: c.shadow,
|
||||
}}
|
||||
>
|
||||
{/* Header: airline + price */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<img
|
||||
src={props.airlineLogo as string}
|
||||
alt={props.airline as string}
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: "50%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontWeight: 600, fontSize: "0.95rem" }}>
|
||||
{props.airline as string}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontWeight: 700, fontSize: "1.15rem" }}>
|
||||
{props.price as string}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: "0.8rem",
|
||||
color: c.muted,
|
||||
}}
|
||||
>
|
||||
<span>{props.flightNumber as string}</span>
|
||||
<span>{props.date as string}</span>
|
||||
</div>
|
||||
|
||||
<hr
|
||||
style={{
|
||||
border: "none",
|
||||
borderTop: `1px solid ${c.divider}`,
|
||||
margin: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Times */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 700, fontSize: "1.1rem" }}>
|
||||
{props.departureTime as string}
|
||||
</span>
|
||||
<span style={{ fontSize: "0.75rem", color: c.muted }}>
|
||||
{props.duration as string}
|
||||
</span>
|
||||
<span style={{ fontWeight: 700, fontSize: "1.1rem" }}>
|
||||
{props.arrivalTime as string}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Route */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
fontSize: "0.95rem",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<span>{props.origin as string}</span>
|
||||
<span style={{ color: c.muted }}>{"\u2192"}</span>
|
||||
<span>{props.destination as string}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: "auto",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "12px",
|
||||
}}
|
||||
>
|
||||
<hr
|
||||
style={{
|
||||
border: "none",
|
||||
borderTop: `1px solid ${c.divider}`,
|
||||
margin: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Status */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
||||
<span
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: dotColor,
|
||||
display: "inline-block",
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: "0.8rem", color: c.muted }}>
|
||||
{props.status as string}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ActionButton
|
||||
label="Select"
|
||||
doneLabel="Selected"
|
||||
action={props.action}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// --- Assembled Catalog ---
|
||||
|
||||
export const demonstrationCatalog = createCatalog(
|
||||
demonstrationCatalogDefinitions,
|
||||
demonstrationCatalogRenderers,
|
||||
{
|
||||
catalogId: "copilotkit://app-dashboard-catalog",
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,285 @@
|
||||
"use client";
|
||||
|
||||
import React, { memo } from "react";
|
||||
import { s, prompt } from "@hashbrownai/core";
|
||||
import {
|
||||
exposeComponent,
|
||||
exposeMarkdown,
|
||||
useUiKit,
|
||||
useJsonParser,
|
||||
} from "@hashbrownai/react";
|
||||
import type { RenderMessageProps } from "@copilotkit/react-ui";
|
||||
import type { AssistantMessage } from "@ag-ui/core";
|
||||
|
||||
import { PieChart } from "../../charts/pie-chart";
|
||||
import { BarChart } from "../../charts/bar-chart";
|
||||
import type { SalesStage } from "../../../types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simple MetricCard with flat string props (optimized for structured output)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface MetricCardProps {
|
||||
label: string;
|
||||
value: string;
|
||||
trend?: string;
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, trend }: MetricCardProps) {
|
||||
const isPositive =
|
||||
trend?.startsWith("+") || trend?.toLowerCase().includes("up");
|
||||
const isNegative =
|
||||
trend?.startsWith("-") || trend?.toLowerCase().includes("down");
|
||||
const trendColor = isPositive
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: isNegative
|
||||
? "text-red-600 dark:text-red-400"
|
||||
: "text-[var(--muted-foreground)]";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="metric-card"
|
||||
className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4"
|
||||
>
|
||||
<p className="text-xs text-[var(--muted-foreground)] uppercase tracking-wider font-medium">
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-[var(--foreground)] mt-1">
|
||||
{value}
|
||||
</p>
|
||||
{trend && (
|
||||
<p data-testid="metric-trend" className={`text-sm mt-1 ${trendColor}`}>
|
||||
{trend}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Standalone DealCard for the kit (flat props, no SalesTodo dependency)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface HashBrownDealCardProps {
|
||||
title: string;
|
||||
stage: SalesStage;
|
||||
value: number;
|
||||
assignee?: string;
|
||||
dueDate?: string;
|
||||
}
|
||||
|
||||
const STAGE_COLORS: Record<SalesStage, string> = {
|
||||
prospect: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
|
||||
qualified:
|
||||
"bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200",
|
||||
proposal: "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200",
|
||||
negotiation:
|
||||
"bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200",
|
||||
"closed-won":
|
||||
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
||||
"closed-lost": "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
|
||||
};
|
||||
|
||||
function DealCardComponent({
|
||||
title,
|
||||
stage,
|
||||
value,
|
||||
assignee,
|
||||
dueDate,
|
||||
}: HashBrownDealCardProps) {
|
||||
const badgeClass =
|
||||
STAGE_COLORS[stage] ??
|
||||
"bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="hashbrown-deal-card"
|
||||
className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4"
|
||||
>
|
||||
<h3 className="text-sm font-semibold leading-snug text-[var(--foreground)]">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${badgeClass}`}
|
||||
>
|
||||
{stage}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-[var(--foreground)]">
|
||||
${(value ?? 0).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-3 text-xs text-[var(--muted-foreground)]">
|
||||
{assignee && <span>{assignee}</span>}
|
||||
{dueDate && <span>Due {dueDate}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kit definition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useSalesDashboardKit() {
|
||||
return useUiKit({
|
||||
examples: prompt`
|
||||
# Mixing components and Markdown:
|
||||
<ui>
|
||||
<Markdown children="## Q4 Sales Summary" />
|
||||
<metric label="Total Revenue" value="$1.2M" trend="+12% vs Q3" />
|
||||
<Markdown children="Revenue breakdown by segment:" />
|
||||
<pieChart title="Revenue by Segment" data='[{"label":"Enterprise","value":600000},{"label":"SMB","value":400000},{"label":"Startup","value":200000}]' />
|
||||
<barChart title="Monthly Trend" data='[{"label":"Oct","value":350000},{"label":"Nov","value":400000},{"label":"Dec","value":450000}]' />
|
||||
<dealCard title="Acme Corp Renewal" stage="negotiation" value="250000" assignee="Jane" dueDate="2024-12-31" />
|
||||
</ui>
|
||||
|
||||
Hint: use Markdown for explanatory text between visual components.
|
||||
Hint: always include title and data for charts.
|
||||
`,
|
||||
components: [
|
||||
exposeMarkdown(),
|
||||
exposeComponent(MetricCard, {
|
||||
name: "metric",
|
||||
description: "A KPI metric card with label, value, and optional trend",
|
||||
props: {
|
||||
label: s.string("The metric label/name"),
|
||||
value: s.string("The metric value (formatted)"),
|
||||
trend: s.string("Optional trend indicator, e.g. +12%").optional(),
|
||||
},
|
||||
}),
|
||||
exposeComponent(PieChart, {
|
||||
name: "pieChart",
|
||||
description: "A donut/pie chart with title and data segments",
|
||||
props: {
|
||||
title: s.string("Chart title"),
|
||||
description: s.string("Brief description or subtitle").optional(),
|
||||
data: s.streaming.array(
|
||||
s.object({
|
||||
label: s.string("Segment label"),
|
||||
value: s.number("Segment value"),
|
||||
}),
|
||||
),
|
||||
},
|
||||
}),
|
||||
exposeComponent(BarChart, {
|
||||
name: "barChart",
|
||||
description: "A vertical bar chart with title and data bars",
|
||||
props: {
|
||||
title: s.string("Chart title"),
|
||||
description: s.string("Brief description or subtitle").optional(),
|
||||
data: s.streaming.array(
|
||||
s.object({
|
||||
label: s.string("Bar label"),
|
||||
value: s.number("Bar value"),
|
||||
}),
|
||||
),
|
||||
},
|
||||
}),
|
||||
exposeComponent(DealCardComponent, {
|
||||
name: "dealCard",
|
||||
description: "A sales deal card showing pipeline stage and value",
|
||||
props: {
|
||||
title: s.string("Deal title"),
|
||||
stage: s.string(
|
||||
"Pipeline stage: prospect | qualified | proposal | negotiation | closed-won | closed-lost",
|
||||
),
|
||||
value: s.number("Deal value in dollars"),
|
||||
assignee: s.string("Who owns this deal").optional(),
|
||||
dueDate: s.string("Expected close date").optional(),
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom message renderer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AssistantMessageRenderer = memo(function AssistantMessageRenderer({
|
||||
message,
|
||||
kit,
|
||||
}: {
|
||||
message: AssistantMessage;
|
||||
kit: ReturnType<typeof useSalesDashboardKit>;
|
||||
}) {
|
||||
const { value } = useJsonParser(message.content ?? "", kit.schema);
|
||||
|
||||
if (!value) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex w-full justify-start">
|
||||
<div className="w-full px-1 py-1">{kit.render(value)}</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exported dashboard component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface HashBrownDashboardProps {
|
||||
/**
|
||||
* Optional custom wrapper for the assistant message area.
|
||||
* Defaults to rendering messages inline.
|
||||
*/
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider that instantiates the HashBrown kit ONCE and shares it via context.
|
||||
* Both the dashboard layout and message renderer consume the same kit instance.
|
||||
*
|
||||
* The kit registers MetricCard, PieChart, BarChart, DealCard, and Markdown
|
||||
* components. Agent context forwarding for output_schema is omitted because
|
||||
* the npm-published react-core may not export useAgentContext yet.
|
||||
*/
|
||||
const HashBrownKitContext = React.createContext<ReturnType<
|
||||
typeof useSalesDashboardKit
|
||||
> | null>(null);
|
||||
|
||||
function useHashBrownKit() {
|
||||
const kit = React.useContext(HashBrownKitContext);
|
||||
if (!kit)
|
||||
throw new Error("useHashBrownKit must be used within HashBrownDashboard");
|
||||
return kit;
|
||||
}
|
||||
|
||||
export function HashBrownDashboard({ children }: HashBrownDashboardProps) {
|
||||
const kit = useSalesDashboardKit();
|
||||
|
||||
// Note: Agent context forwarding (useAgentContext) for output_schema is
|
||||
// omitted because the npm-published react-core may not export it yet.
|
||||
|
||||
return (
|
||||
<HashBrownKitContext.Provider value={kit}>
|
||||
{children}
|
||||
</HashBrownKitContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable message renderer component that consumes the kit from context.
|
||||
* Defined at module level to avoid unstable function identity.
|
||||
*/
|
||||
function HashBrownRenderMessage({ message }: RenderMessageProps) {
|
||||
const kit = useHashBrownKit();
|
||||
if (message.role === "assistant") {
|
||||
return (
|
||||
<AssistantMessageRenderer
|
||||
message={message as AssistantMessage}
|
||||
kit={kit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the stable HashBrownRenderMessage component.
|
||||
* Must be used within a HashBrownDashboard provider.
|
||||
*/
|
||||
export function useHashBrownMessageRenderer() {
|
||||
return HashBrownRenderMessage;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { RenderMode, RENDER_STRATEGIES } from "./types";
|
||||
|
||||
export interface RendererSelectorProps {
|
||||
/** The currently active render mode. */
|
||||
mode: RenderMode;
|
||||
/** Called when the user selects a different render mode. */
|
||||
onModeChange: (mode: RenderMode) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal pill-toggle for selecting a render strategy.
|
||||
*
|
||||
* Each pill shows the strategy icon and name. Hovering reveals a tooltip with
|
||||
* the one-line description. The active pill is visually highlighted.
|
||||
*/
|
||||
export function RendererSelector({
|
||||
mode,
|
||||
onModeChange,
|
||||
}: RendererSelectorProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap gap-2"
|
||||
role="radiogroup"
|
||||
aria-label="Render mode"
|
||||
>
|
||||
{RENDER_STRATEGIES.map((strategy) => {
|
||||
const isActive = strategy.mode === mode;
|
||||
return (
|
||||
<button
|
||||
key={strategy.mode}
|
||||
role="radio"
|
||||
aria-checked={isActive}
|
||||
title={strategy.description}
|
||||
onClick={() => onModeChange(strategy.mode)}
|
||||
className={[
|
||||
"inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium transition-colors",
|
||||
"focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500",
|
||||
isActive
|
||||
? "bg-blue-600 text-white shadow-sm"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700",
|
||||
].join(" ")}
|
||||
>
|
||||
<span aria-hidden="true">{strategy.icon}</span>
|
||||
{strategy.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { CopilotKit } from "@copilotkit/react-core";
|
||||
import { CopilotSidebar } from "@copilotkit/react-core/v2";
|
||||
import { useShowcaseHooks } from "../../../hooks/use-showcase-hooks";
|
||||
import { useShowcaseSuggestions } from "../../../hooks/use-showcase-suggestions";
|
||||
import { SalesDashboard } from "../../sales-dashboard";
|
||||
|
||||
interface ToolBasedDashboardProps {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
function DashboardContent({ agentId }: ToolBasedDashboardProps) {
|
||||
useShowcaseHooks();
|
||||
useShowcaseSuggestions();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full flex items-center justify-center">
|
||||
<SalesDashboard agentId={agentId} />
|
||||
<CopilotSidebar
|
||||
defaultOpen={true}
|
||||
labels={{
|
||||
modalHeaderTitle: "Sales Dashboard Assistant",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolBasedDashboard({ agentId }: ToolBasedDashboardProps) {
|
||||
return (
|
||||
<CopilotKit runtimeUrl="/api/copilotkit" agent={agentId}>
|
||||
<DashboardContent agentId={agentId} />
|
||||
</CopilotKit>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
export const RENDER_MODES = [
|
||||
"tool-based",
|
||||
"a2ui",
|
||||
"json-render",
|
||||
"hashbrown",
|
||||
] as const;
|
||||
export type RenderMode = (typeof RENDER_MODES)[number];
|
||||
|
||||
export interface RenderStrategyInfo {
|
||||
mode: RenderMode;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
features: {
|
||||
streaming: boolean;
|
||||
interactivity: boolean;
|
||||
sandbox: boolean;
|
||||
constraintLevel: "high" | "medium" | "low" | "none";
|
||||
};
|
||||
}
|
||||
|
||||
export const RENDER_STRATEGIES: RenderStrategyInfo[] = [
|
||||
{
|
||||
mode: "tool-based",
|
||||
name: "Tool-Based",
|
||||
description: "Agent calls typed tool functions",
|
||||
icon: "\u{1F527}",
|
||||
features: {
|
||||
streaming: false,
|
||||
interactivity: true,
|
||||
sandbox: false,
|
||||
constraintLevel: "high",
|
||||
},
|
||||
},
|
||||
{
|
||||
mode: "a2ui",
|
||||
name: "A2UI Catalog",
|
||||
description: "Component tree from predefined catalog",
|
||||
icon: "\u{1F4CB}",
|
||||
features: {
|
||||
streaming: false,
|
||||
interactivity: true,
|
||||
sandbox: false,
|
||||
constraintLevel: "medium",
|
||||
},
|
||||
},
|
||||
{
|
||||
mode: "json-render",
|
||||
name: "json-render",
|
||||
description: "JSONL patches with built-in state",
|
||||
icon: "\u{1F4C4}",
|
||||
features: {
|
||||
streaming: true,
|
||||
interactivity: true,
|
||||
sandbox: false,
|
||||
constraintLevel: "medium",
|
||||
},
|
||||
},
|
||||
{
|
||||
mode: "hashbrown",
|
||||
name: "HashBrown",
|
||||
description: "Streaming structured output",
|
||||
icon: "\u{1F954}",
|
||||
features: {
|
||||
streaming: true,
|
||||
interactivity: false,
|
||||
sandbox: false,
|
||||
constraintLevel: "high",
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { RENDER_MODES, type RenderMode } from "./types";
|
||||
|
||||
const STORAGE_KEY = "showcase-render-mode";
|
||||
|
||||
const VALID_MODES: Set<string> = new Set(RENDER_MODES);
|
||||
|
||||
/**
|
||||
* Manages the active render mode with localStorage persistence.
|
||||
*
|
||||
* Note: Agent context forwarding (useAgentContext) is omitted here because
|
||||
* the npm-published @copilotkit/react-core may not export it yet, which
|
||||
* causes prerender failures during Docker builds. The backend render_mode
|
||||
* middleware falls back to "tool-based" when no context is present.
|
||||
*/
|
||||
export function useRenderMode() {
|
||||
const [mode, setMode] = useState<RenderMode>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return VALID_MODES.has(stored ?? "")
|
||||
? (stored as RenderMode)
|
||||
: "tool-based";
|
||||
}
|
||||
return "tool-based";
|
||||
});
|
||||
|
||||
const setAndPersist = (newMode: RenderMode) => {
|
||||
setMode(newMode);
|
||||
localStorage.setItem(STORAGE_KEY, newMode);
|
||||
};
|
||||
|
||||
return { mode, setMode: setAndPersist };
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { SalesTodo } from "../../types";
|
||||
|
||||
const STAGE_COLORS: Record<SalesTodo["stage"], string> = {
|
||||
prospect: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
|
||||
qualified:
|
||||
"bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200",
|
||||
proposal: "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200",
|
||||
negotiation:
|
||||
"bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200",
|
||||
"closed-won":
|
||||
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
||||
"closed-lost": "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
|
||||
};
|
||||
|
||||
export interface DealCardProps {
|
||||
deal: SalesTodo;
|
||||
}
|
||||
|
||||
export function DealCard({ deal }: DealCardProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid="deal-card"
|
||||
className={`rounded-lg border border-[var(--border)] bg-[var(--card)] p-4 transition-all duration-150 ${
|
||||
deal.completed ? "opacity-60" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
{/* Title */}
|
||||
<h3
|
||||
className={`text-sm font-semibold leading-snug break-words ${
|
||||
deal.completed
|
||||
? "text-[var(--muted-foreground)] line-through"
|
||||
: "text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
{deal.title}
|
||||
</h3>
|
||||
|
||||
{/* Completion indicator */}
|
||||
<span
|
||||
data-testid="completion-indicator"
|
||||
className={`mt-0.5 h-2.5 w-2.5 shrink-0 rounded-full ${
|
||||
deal.completed ? "bg-[var(--muted-foreground)]" : "bg-green-500"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stage badge + value */}
|
||||
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
data-testid="stage-badge"
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STAGE_COLORS[deal.stage]}`}
|
||||
>
|
||||
{deal.stage}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-[var(--foreground)]">
|
||||
${deal.value.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Meta: assignee + due date */}
|
||||
<div className="mt-2 flex items-center gap-3 text-xs text-[var(--muted-foreground)]">
|
||||
{deal.assignee && <span>{deal.assignee}</span>}
|
||||
{deal.dueDate && <span>Due {deal.dueDate}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useAgent } from "@copilotkit/react-core/v2";
|
||||
import { TodoList } from "./todo-list";
|
||||
import type { SalesTodo } from "../../types";
|
||||
import { SALES_STAGES } from "../../types";
|
||||
|
||||
interface SalesDashboardProps {
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
export function SalesDashboard({ agentId }: SalesDashboardProps) {
|
||||
const { agent } = useAgent(agentId ? { agentId } : undefined);
|
||||
|
||||
const todos: SalesTodo[] = agent.state?.todos || [];
|
||||
|
||||
const onUpdate = (updatedTodos: SalesTodo[]) => {
|
||||
agent.setState({ todos: updatedTodos });
|
||||
};
|
||||
|
||||
// Pipeline summary
|
||||
const totalValue = todos.reduce((sum, t) => sum + t.value, 0);
|
||||
const byStage = SALES_STAGES.reduce(
|
||||
(acc, stage) => {
|
||||
const stageTodos = todos.filter((t) => t.stage === stage);
|
||||
acc[stage] = {
|
||||
count: stageTodos.length,
|
||||
value: stageTodos.reduce((sum, t) => sum + t.value, 0),
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, { count: number; value: number }>,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto bg-[var(--background)]">
|
||||
<div className="max-w-5xl mx-auto px-8 py-10 h-full">
|
||||
{/* Pipeline Summary */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-[var(--foreground)] mb-4">
|
||||
Sales Pipeline
|
||||
</h1>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4">
|
||||
<p className="text-xs text-[var(--muted-foreground)] uppercase tracking-wider font-medium">
|
||||
Total Pipeline
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-[var(--foreground)] mt-1">
|
||||
${totalValue.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
{SALES_STAGES.filter((s) => !s.startsWith("closed")).map(
|
||||
(stage) => (
|
||||
<div
|
||||
key={stage}
|
||||
className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4"
|
||||
>
|
||||
<p className="text-xs text-[var(--muted-foreground)] uppercase tracking-wider font-medium">
|
||||
{stage.charAt(0).toUpperCase() + stage.slice(1)}
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-[var(--foreground)] mt-1">
|
||||
{byStage[stage]?.count || 0} deals
|
||||
</p>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
${(byStage[stage]?.value || 0).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Todo List */}
|
||||
<TodoList
|
||||
todos={todos}
|
||||
onUpdate={onUpdate}
|
||||
isAgentRunning={agent.isRunning}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export interface MetricCardProps {
|
||||
label: string;
|
||||
value: string;
|
||||
trend?: {
|
||||
direction: "up" | "down" | "neutral";
|
||||
percentage: number;
|
||||
};
|
||||
}
|
||||
|
||||
const TREND_STYLES: Record<
|
||||
"up" | "down" | "neutral",
|
||||
{ color: string; arrow: string }
|
||||
> = {
|
||||
up: { color: "text-green-600 dark:text-green-400", arrow: "\u2191" },
|
||||
down: { color: "text-red-600 dark:text-red-400", arrow: "\u2193" },
|
||||
neutral: {
|
||||
color: "text-[var(--muted-foreground)]",
|
||||
arrow: "\u2192",
|
||||
},
|
||||
};
|
||||
|
||||
export function MetricCard({ label, value, trend }: MetricCardProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid="metric-card"
|
||||
className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4"
|
||||
>
|
||||
<p className="text-xs text-[var(--muted-foreground)] uppercase tracking-wider font-medium">
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-[var(--foreground)] mt-1">
|
||||
{value}
|
||||
</p>
|
||||
{trend && (
|
||||
<p
|
||||
data-testid="trend-indicator"
|
||||
className={`text-sm font-medium mt-1 ${TREND_STYLES[trend.direction].color}`}
|
||||
>
|
||||
{TREND_STYLES[trend.direction].arrow} {trend.percentage}%
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { DealCard } from "./deal-card";
|
||||
import type { SalesTodo } from "../../types";
|
||||
import { SALES_STAGES } from "../../types";
|
||||
|
||||
const STAGE_LABELS: Record<SalesTodo["stage"], string> = {
|
||||
prospect: "Prospect",
|
||||
qualified: "Qualified",
|
||||
proposal: "Proposal",
|
||||
negotiation: "Negotiation",
|
||||
"closed-won": "Closed Won",
|
||||
"closed-lost": "Closed Lost",
|
||||
};
|
||||
|
||||
export interface PipelineProps {
|
||||
deals: SalesTodo[];
|
||||
}
|
||||
|
||||
export function Pipeline({ deals }: PipelineProps) {
|
||||
const dealsByStage = SALES_STAGES.reduce(
|
||||
(acc, stage) => {
|
||||
acc[stage] = deals.filter((d) => d.stage === stage);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<SalesTodo["stage"], SalesTodo[]>,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="pipeline-board"
|
||||
className="flex gap-4 overflow-x-auto pb-4"
|
||||
>
|
||||
{SALES_STAGES.map((stage) => {
|
||||
const stageDeals = dealsByStage[stage];
|
||||
const totalValue = stageDeals.reduce((sum, d) => sum + d.value, 0);
|
||||
|
||||
return (
|
||||
<section
|
||||
key={stage}
|
||||
aria-label={`${STAGE_LABELS[stage]} column`}
|
||||
className="flex-shrink-0 w-64 min-w-[16rem]"
|
||||
>
|
||||
{/* Column header */}
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-bold text-[var(--foreground)]">
|
||||
{STAGE_LABELS[stage]}
|
||||
</h3>
|
||||
<span className="inline-flex items-center rounded-full border border-[var(--border)] bg-[var(--secondary)] px-2 py-0.5 text-xs font-semibold text-[var(--secondary-foreground)]">
|
||||
{stageDeals.length}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--muted-foreground)] mt-0.5">
|
||||
${totalValue.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="space-y-3">
|
||||
{stageDeals.length === 0 ? (
|
||||
<div className="text-center text-xs rounded-lg border-2 border-dashed border-[var(--border)] p-4 text-[var(--muted-foreground)]">
|
||||
No deals
|
||||
</div>
|
||||
) : (
|
||||
stageDeals.map((deal) => <DealCard key={deal.id} deal={deal} />)
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useState } from "react";
|
||||
import type { SalesTodo } from "../../types";
|
||||
|
||||
const STAGE_COLORS: Record<SalesTodo["stage"], string> = {
|
||||
prospect: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
|
||||
qualified:
|
||||
"bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200",
|
||||
proposal: "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200",
|
||||
negotiation:
|
||||
"bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200",
|
||||
"closed-won":
|
||||
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
||||
"closed-lost": "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
|
||||
};
|
||||
|
||||
interface TodoCardProps {
|
||||
todo: SalesTodo;
|
||||
onToggleCompleted: (todo: SalesTodo) => void;
|
||||
onDelete: (todo: SalesTodo) => void;
|
||||
onUpdateTitle: (todoId: string, title: string) => void;
|
||||
onUpdateStage: (todoId: string, stage: SalesTodo["stage"]) => void;
|
||||
onUpdateValue: (todoId: string, value: number) => void;
|
||||
}
|
||||
|
||||
export function TodoCard({
|
||||
todo,
|
||||
onToggleCompleted,
|
||||
onDelete,
|
||||
onUpdateTitle,
|
||||
onUpdateStage,
|
||||
onUpdateValue,
|
||||
}: TodoCardProps) {
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
const [titleValue, setTitleValue] = useState(todo.title);
|
||||
|
||||
const saveTitle = () => {
|
||||
if (titleValue.trim()) {
|
||||
onUpdateTitle(todo.id, titleValue.trim());
|
||||
}
|
||||
setEditingTitle(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="todo-card"
|
||||
className={`group relative rounded-lg border border-[var(--border)] bg-[var(--card)] p-4 transition-all duration-150 ${
|
||||
todo.completed ? "opacity-60" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Delete button - hover reveal */}
|
||||
<button
|
||||
onClick={() => onDelete(todo)}
|
||||
className="absolute top-3 right-3 h-7 w-7 flex items-center justify-center rounded-md opacity-0 group-hover:opacity-100 transition-opacity hover:bg-[var(--secondary)]"
|
||||
aria-label="Delete deal"
|
||||
>
|
||||
<svg
|
||||
className="h-3.5 w-3.5 text-[var(--muted-foreground)]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Completion checkbox */}
|
||||
<button
|
||||
data-testid="toggle-completed"
|
||||
onClick={() => onToggleCompleted(todo)}
|
||||
className={`mt-0.5 h-5 w-5 rounded border-2 flex items-center justify-center shrink-0 transition-colors ${
|
||||
todo.completed
|
||||
? "bg-[var(--primary)] border-[var(--primary)]"
|
||||
: "border-[var(--border)] hover:border-[var(--primary)]"
|
||||
}`}
|
||||
>
|
||||
{todo.completed && (
|
||||
<svg
|
||||
className="h-3 w-3 text-[var(--primary-foreground)]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Title */}
|
||||
{editingTitle ? (
|
||||
<input
|
||||
type="text"
|
||||
value={titleValue}
|
||||
onChange={(e) => setTitleValue(e.target.value)}
|
||||
onBlur={saveTitle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") saveTitle();
|
||||
if (e.key === "Escape") {
|
||||
setTitleValue(todo.title);
|
||||
setEditingTitle(false);
|
||||
}
|
||||
}}
|
||||
className="w-full text-sm font-semibold focus:outline-none bg-transparent text-[var(--foreground)] border-b-2 border-[var(--primary)] pb-[2px]"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => {
|
||||
setTitleValue(todo.title);
|
||||
setEditingTitle(true);
|
||||
}}
|
||||
className={`text-sm font-semibold cursor-text break-words leading-snug ${
|
||||
todo.completed
|
||||
? "text-[var(--muted-foreground)] line-through"
|
||||
: "text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
{todo.title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stage badge */}
|
||||
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STAGE_COLORS[todo.stage]}`}
|
||||
>
|
||||
{todo.stage}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-[var(--foreground)]">
|
||||
${todo.value.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Meta: assignee + due date */}
|
||||
<div className="mt-2 flex items-center gap-3 text-xs text-[var(--muted-foreground)]">
|
||||
{todo.assignee && <span>{todo.assignee}</span>}
|
||||
{todo.dueDate && <span>Due {todo.dueDate}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { TodoCard } from "./todo-card";
|
||||
import type { SalesTodo } from "../../types";
|
||||
|
||||
interface TodoColumnProps {
|
||||
title: string;
|
||||
todos: SalesTodo[];
|
||||
emptyMessage: string;
|
||||
showAddButton?: boolean;
|
||||
onAddTodo?: () => void;
|
||||
onToggleCompleted: (todo: SalesTodo) => void;
|
||||
onDelete: (todo: SalesTodo) => void;
|
||||
onUpdateTitle: (todoId: string, title: string) => void;
|
||||
onUpdateStage: (todoId: string, stage: SalesTodo["stage"]) => void;
|
||||
onUpdateValue: (todoId: string, value: number) => void;
|
||||
isAgentRunning: boolean;
|
||||
}
|
||||
|
||||
export function TodoColumn({
|
||||
title,
|
||||
todos,
|
||||
emptyMessage,
|
||||
showAddButton = false,
|
||||
onAddTodo,
|
||||
onToggleCompleted,
|
||||
onDelete,
|
||||
onUpdateTitle,
|
||||
onUpdateStage,
|
||||
onUpdateValue,
|
||||
isAgentRunning,
|
||||
}: TodoColumnProps) {
|
||||
return (
|
||||
<section aria-label={`${title} column`} className="flex-1 min-w-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-bold tracking-tight text-[var(--foreground)]">
|
||||
{title}
|
||||
</h2>
|
||||
<span className="inline-flex items-center rounded-full border border-[var(--border)] bg-[var(--secondary)] px-2.5 py-0.5 text-xs font-semibold text-[var(--secondary-foreground)]">
|
||||
{todos.length}
|
||||
</span>
|
||||
</div>
|
||||
{showAddButton && onAddTodo && (
|
||||
<button
|
||||
onClick={onAddTodo}
|
||||
disabled={isAgentRunning}
|
||||
aria-label="Add new deal"
|
||||
className="h-8 w-8 flex items-center justify-center rounded-md hover:bg-[var(--secondary)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4 text-[var(--foreground)]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="space-y-3">
|
||||
{todos.length === 0 ? (
|
||||
<div className="text-center text-sm rounded-lg border-2 border-dashed border-[var(--border)] p-5 min-h-[151px] flex items-center justify-center text-[var(--muted-foreground)]">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
) : (
|
||||
todos.map((todo) => (
|
||||
<TodoCard
|
||||
key={todo.id}
|
||||
todo={todo}
|
||||
onToggleCompleted={onToggleCompleted}
|
||||
onDelete={onDelete}
|
||||
onUpdateTitle={onUpdateTitle}
|
||||
onUpdateStage={onUpdateStage}
|
||||
onUpdateValue={onUpdateValue}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { TodoColumn } from "./todo-column";
|
||||
import type { SalesTodo } from "../../types";
|
||||
|
||||
interface TodoListProps {
|
||||
todos: SalesTodo[];
|
||||
onUpdate: (todos: SalesTodo[]) => void;
|
||||
isAgentRunning: boolean;
|
||||
}
|
||||
|
||||
export function TodoList({ todos, onUpdate, isAgentRunning }: TodoListProps) {
|
||||
const activeTodos = todos.filter((t) => !t.completed);
|
||||
const completedTodos = todos.filter((t) => t.completed);
|
||||
|
||||
const toggleCompleted = (todo: SalesTodo) => {
|
||||
const updated = todos.map((t) =>
|
||||
t.id === todo.id ? { ...t, completed: !t.completed } : t,
|
||||
);
|
||||
onUpdate(updated);
|
||||
};
|
||||
|
||||
const deleteTodo = (todo: SalesTodo) => {
|
||||
onUpdate(todos.filter((t) => t.id !== todo.id));
|
||||
};
|
||||
|
||||
const updateTitle = (todoId: string, title: string) => {
|
||||
const updated = todos.map((t) => (t.id === todoId ? { ...t, title } : t));
|
||||
onUpdate(updated);
|
||||
};
|
||||
|
||||
const updateStage = (todoId: string, stage: SalesTodo["stage"]) => {
|
||||
const updated = todos.map((t) => (t.id === todoId ? { ...t, stage } : t));
|
||||
onUpdate(updated);
|
||||
};
|
||||
|
||||
const updateValue = (todoId: string, value: number) => {
|
||||
const updated = todos.map((t) => (t.id === todoId ? { ...t, value } : t));
|
||||
onUpdate(updated);
|
||||
};
|
||||
|
||||
const addTodo = () => {
|
||||
const newTodo: SalesTodo = {
|
||||
id: crypto.randomUUID(),
|
||||
title: "New Deal",
|
||||
stage: "prospect",
|
||||
value: 0,
|
||||
dueDate: new Date().toISOString().split("T")[0],
|
||||
assignee: "",
|
||||
completed: false,
|
||||
};
|
||||
onUpdate([...todos, newTodo]);
|
||||
};
|
||||
|
||||
if (!todos || todos.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64 gap-4">
|
||||
<div className="text-5xl">{"\uD83D\uDCBC"}</div>
|
||||
<p className="text-base font-semibold text-[var(--foreground)]">
|
||||
No deals yet
|
||||
</p>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Create your first deal to get started
|
||||
</p>
|
||||
<button
|
||||
onClick={addTodo}
|
||||
disabled={isAgentRunning}
|
||||
className="mt-2 px-4 py-2 rounded-md bg-[var(--primary)] text-[var(--primary-foreground)] text-sm font-medium hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
Add a deal
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-8 h-full">
|
||||
<TodoColumn
|
||||
title="Active Deals"
|
||||
todos={activeTodos}
|
||||
emptyMessage="No active deals"
|
||||
showAddButton
|
||||
onAddTodo={addTodo}
|
||||
onToggleCompleted={toggleCompleted}
|
||||
onDelete={deleteTodo}
|
||||
onUpdateTitle={updateTitle}
|
||||
onUpdateStage={updateStage}
|
||||
onUpdateValue={updateValue}
|
||||
isAgentRunning={isAgentRunning}
|
||||
/>
|
||||
<TodoColumn
|
||||
title="Closed"
|
||||
todos={completedTodos}
|
||||
emptyMessage="No closed deals yet"
|
||||
onToggleCompleted={toggleCompleted}
|
||||
onDelete={deleteTodo}
|
||||
onUpdateTitle={updateTitle}
|
||||
onUpdateStage={updateStage}
|
||||
onUpdateValue={updateValue}
|
||||
isAgentRunning={isAgentRunning}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface ToolReasoningProps {
|
||||
name: string;
|
||||
args?: object | unknown;
|
||||
status: string;
|
||||
}
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.length} items]`;
|
||||
if (typeof value === "object" && value !== null)
|
||||
return `{${Object.keys(value).length} keys}`;
|
||||
if (typeof value === "string") return `"${value}"`;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function ToolReasoning({ name, args, status }: ToolReasoningProps) {
|
||||
const entries = args ? Object.entries(args as Record<string, unknown>) : [];
|
||||
const detailsRef = useRef<HTMLDetailsElement>(null);
|
||||
const isRunning = status === "executing" || status === "inProgress";
|
||||
|
||||
// Auto-open while executing, auto-close when complete
|
||||
useEffect(() => {
|
||||
if (!detailsRef.current) return;
|
||||
detailsRef.current.open = isRunning;
|
||||
}, [isRunning]);
|
||||
|
||||
const statusIcon = isRunning ? (
|
||||
<div className="h-3 w-3 animate-spin rounded-full border-2 border-[var(--muted)] border-t-[var(--foreground)]" />
|
||||
) : (
|
||||
<svg
|
||||
className="h-3 w-3 text-emerald-500"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="my-1.5">
|
||||
{entries.length > 0 ? (
|
||||
<details ref={detailsRef} open className="group">
|
||||
<summary className="flex items-center gap-2 cursor-pointer list-none text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors">
|
||||
{statusIcon}
|
||||
<svg
|
||||
className="h-3 w-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
|
||||
</svg>
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{ fontFamily: "var(--font-code)" }}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
<svg
|
||||
className="h-3 w-3 ml-auto transition-transform group-open:rotate-180"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</summary>
|
||||
<div className="ml-5 mt-1.5 rounded-md bg-[var(--secondary)] px-3 py-2 space-y-1">
|
||||
{entries.map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex gap-2 min-w-0 text-xs"
|
||||
style={{ fontFamily: "var(--font-code)" }}
|
||||
>
|
||||
<span className="text-[var(--muted-foreground)] shrink-0">
|
||||
{key}:
|
||||
</span>
|
||||
<span className="text-[var(--foreground)] truncate">
|
||||
{formatValue(value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--muted-foreground)]">
|
||||
{statusIcon}
|
||||
<svg
|
||||
className="h-3 w-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
|
||||
</svg>
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{ fontFamily: "var(--font-code)" }}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import React from "react";
|
||||
|
||||
export function getWeatherGradient(conditions: string): string {
|
||||
const c = conditions.toLowerCase();
|
||||
if (c.includes("clear") || c.includes("sunny"))
|
||||
return "linear-gradient(135deg, #667eea 0%, #764ba2 100%)";
|
||||
if (c.includes("rain") || c.includes("storm"))
|
||||
return "linear-gradient(135deg, #4A5568 0%, #2D3748 100%)";
|
||||
if (c.includes("cloud") || c.includes("overcast"))
|
||||
return "linear-gradient(135deg, #718096 0%, #4A5568 100%)";
|
||||
if (c.includes("snow"))
|
||||
return "linear-gradient(135deg, #63B3ED 0%, #4299E1 100%)";
|
||||
return "linear-gradient(135deg, #667eea 0%, #764ba2 100%)";
|
||||
}
|
||||
|
||||
export function getWeatherIcon(conditions: string): string {
|
||||
const c = conditions.toLowerCase();
|
||||
if (c.includes("clear") || c.includes("sunny")) return "\u2600\uFE0F";
|
||||
if (c.includes("rain") || c.includes("drizzle")) return "\uD83C\uDF27\uFE0F";
|
||||
if (c.includes("snow")) return "\u2744\uFE0F";
|
||||
if (c.includes("thunderstorm")) return "\u26C8\uFE0F";
|
||||
if (c.includes("cloud") || c.includes("overcast")) return "\u2601\uFE0F";
|
||||
if (c.includes("fog")) return "\uD83C\uDF2B\uFE0F";
|
||||
return "\uD83C\uDF24\uFE0F";
|
||||
}
|
||||
|
||||
export interface WeatherCardProps {
|
||||
location: string;
|
||||
temperature?: number;
|
||||
conditions?: string;
|
||||
humidity?: number;
|
||||
windSpeed?: number;
|
||||
feelsLike?: number;
|
||||
city?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function WeatherCard({
|
||||
location,
|
||||
temperature = 22,
|
||||
conditions = "Clear",
|
||||
humidity = 55,
|
||||
windSpeed = 12,
|
||||
feelsLike,
|
||||
city,
|
||||
loading = false,
|
||||
}: WeatherCardProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-3 px-5 py-4 rounded-2xl max-w-sm"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
}}
|
||||
>
|
||||
<div className="animate-pulse text-2xl">{"\uD83C\uDF24\uFE0F"}</div>
|
||||
<div>
|
||||
<p className="text-white font-medium text-sm">Checking weather...</p>
|
||||
<p className="text-white/60 text-xs">{location}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const temp = temperature;
|
||||
const cond = conditions;
|
||||
const hum = humidity;
|
||||
const wind = windSpeed;
|
||||
const feels = feelsLike ?? temp;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="weather-card"
|
||||
className="rounded-2xl overflow-hidden shadow-xl my-3"
|
||||
style={{ background: getWeatherGradient(cond), width: "320px" }}
|
||||
>
|
||||
<div className="px-5 pt-4 pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white capitalize tracking-tight">
|
||||
{city || location}
|
||||
</h3>
|
||||
<p className="text-white/50 text-[10px] font-medium uppercase tracking-wider">
|
||||
Current Weather
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-4xl leading-none">{getWeatherIcon(cond)}</span>
|
||||
</div>
|
||||
<div className="mt-3 flex items-baseline gap-1.5">
|
||||
<span className="text-4xl font-extralight text-white tracking-tighter">
|
||||
{temp}°
|
||||
</span>
|
||||
<span className="text-white/40 text-xs">
|
||||
{((temp * 9) / 5 + 32).toFixed(0)}°F
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-white/70 text-xs font-medium capitalize mt-0.5">
|
||||
{cond}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="grid grid-cols-3 text-center py-2.5 px-5"
|
||||
style={{ background: "rgba(0,0,0,0.15)" }}
|
||||
>
|
||||
<div>
|
||||
<p className="text-white/40 text-[9px] font-medium uppercase tracking-wider">
|
||||
Humidity
|
||||
</p>
|
||||
<p className="text-white text-xs font-semibold mt-0.5">{hum}%</p>
|
||||
</div>
|
||||
<div className="border-x border-white/10">
|
||||
<p className="text-white/40 text-[9px] font-medium uppercase tracking-wider">
|
||||
Wind
|
||||
</p>
|
||||
<p className="text-white text-xs font-semibold mt-0.5">{wind} mph</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/40 text-[9px] font-medium uppercase tracking-wider">
|
||||
Feels Like
|
||||
</p>
|
||||
<p className="text-white text-xs font-semibold mt-0.5">
|
||||
{feels}°
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
useComponent,
|
||||
useFrontendTool,
|
||||
useHumanInTheLoop,
|
||||
useDefaultRenderTool,
|
||||
} from "@copilotkit/react-core/v2";
|
||||
|
||||
import { PieChart, PieChartProps } from "../components/charts/pie-chart";
|
||||
import { BarChart, BarChartProps } from "../components/charts/bar-chart";
|
||||
import { MeetingTimePicker } from "../components/meeting-time-picker";
|
||||
import { ToolReasoning } from "../components/tool-reasoning";
|
||||
|
||||
export const useShowcaseHooks = () => {
|
||||
// Human-in-the-Loop (frontend tool requiring user decision)
|
||||
useHumanInTheLoop({
|
||||
name: "scheduleTime",
|
||||
description: "Use human-in-the-loop to schedule a meeting with the user.",
|
||||
parameters: z.object({
|
||||
reasonForScheduling: z
|
||||
.string()
|
||||
.describe("Reason for scheduling, very brief - 5 words."),
|
||||
meetingDuration: z
|
||||
.number()
|
||||
.describe("Duration of the meeting in minutes"),
|
||||
}),
|
||||
render: ({ respond, status, args }) => {
|
||||
return <MeetingTimePicker status={status} respond={respond} {...args} />;
|
||||
},
|
||||
});
|
||||
|
||||
// Controlled Generative UI (frontend-defined chart components)
|
||||
useComponent({
|
||||
name: "pieChart",
|
||||
description: "Controlled Generative UI that displays data as a pie chart.",
|
||||
parameters: PieChartProps,
|
||||
render: PieChart,
|
||||
});
|
||||
|
||||
useComponent({
|
||||
name: "barChart",
|
||||
description: "Controlled Generative UI that displays data as a bar chart.",
|
||||
parameters: BarChartProps,
|
||||
render: BarChart,
|
||||
});
|
||||
|
||||
// Default Tool Rendering (backend tool UI)
|
||||
const ignoredTools = [
|
||||
"render_a2ui", // Rendered by A2UI streaming, not as a tool card
|
||||
"generate_a2ui", // Rendered by A2UI, not as a tool card
|
||||
"log_a2ui_event", // Internal A2UI event tracker
|
||||
];
|
||||
useDefaultRenderTool({
|
||||
render: ({ name, status, parameters }) => {
|
||||
if (ignoredTools.includes(name)) return <></>;
|
||||
return <ToolReasoning name={name} status={status} args={parameters} />;
|
||||
},
|
||||
});
|
||||
|
||||
// Frontend Tools (direct frontend state manipulation)
|
||||
useFrontendTool(
|
||||
{
|
||||
name: "toggleTheme",
|
||||
description: "Frontend tool for toggling the theme of the app.",
|
||||
parameters: z.object({}),
|
||||
handler: async () => {
|
||||
const isDark = document.documentElement.classList.contains("dark");
|
||||
if (isDark) {
|
||||
document.documentElement.classList.remove("dark");
|
||||
document.documentElement.classList.add("light");
|
||||
} else {
|
||||
document.documentElement.classList.remove("light");
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
},
|
||||
},
|
||||
[],
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Suggestion pills shown in the chat UI. Each suggestion triggers a specific
|
||||
* demo feature when clicked.
|
||||
*
|
||||
* Ordered from most constrained (fixed UI) to most open (freeform UI).
|
||||
*/
|
||||
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
|
||||
|
||||
export const useShowcaseSuggestions = (options?: { showcaseMode?: string }) => {
|
||||
const showcase = options?.showcaseMode;
|
||||
|
||||
useConfigureSuggestions({
|
||||
suggestions: [
|
||||
{
|
||||
title: "Pie Chart (Controlled Generative UI)",
|
||||
message:
|
||||
"Show me a pie chart of our revenue distribution by category. Use the query_data tool to fetch the data first, then render it with the pieChart component.",
|
||||
},
|
||||
{
|
||||
title: "Bar Chart (Controlled Generative UI)",
|
||||
message:
|
||||
"Show me a bar chart of our expenses by category. Use the query_data tool to fetch the data first, then render it with the barChart component.",
|
||||
},
|
||||
{
|
||||
title: "Schedule Meeting (Human In The Loop)",
|
||||
message:
|
||||
"I'd like to schedule a 30-minute meeting to learn about CopilotKit. Please use the scheduleTime tool to let me pick a time.",
|
||||
},
|
||||
{
|
||||
title: "Search Flights (A2UI Fixed Schema)",
|
||||
message: "Find flights from SFO to JFK for next Tuesday.",
|
||||
className: showcase === "a2ui" ? "a2ui-highlight" : undefined,
|
||||
},
|
||||
{
|
||||
title: "Sales Dashboard (A2UI Dynamic)",
|
||||
message:
|
||||
"First use the query_data tool to fetch the financial sales data, then using A2UI, show me a sales dashboard with total revenue, new customers, and conversion rate metrics. Include a pie chart of revenue by category and a bar chart of monthly sales.",
|
||||
className: showcase === "a2ui" ? "a2ui-highlight" : undefined,
|
||||
},
|
||||
{
|
||||
title: "Excalidraw Diagram (MCP App)",
|
||||
message:
|
||||
"Use Excalidraw to create a simple network diagram showing a router connected to two switches, each connected to two computers.",
|
||||
},
|
||||
{
|
||||
title: "Toggle Theme (Frontend Tools)",
|
||||
message: "Toggle the app theme using the toggleTheme tool.",
|
||||
},
|
||||
{
|
||||
title: "Task Manager (Shared State)",
|
||||
message:
|
||||
"Enable app mode and add three todos about learning CopilotKit: one about reading the docs, one about building a prototype, and one about exploring agent state.",
|
||||
},
|
||||
],
|
||||
available: "always",
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
export const SALES_STAGES = [
|
||||
"prospect",
|
||||
"qualified",
|
||||
"proposal",
|
||||
"negotiation",
|
||||
"closed-won",
|
||||
"closed-lost",
|
||||
] as const;
|
||||
|
||||
export type SalesStage = (typeof SALES_STAGES)[number];
|
||||
|
||||
export interface SalesTodo {
|
||||
id: string;
|
||||
title: string;
|
||||
stage: SalesStage;
|
||||
value: number;
|
||||
dueDate: string;
|
||||
assignee: string;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
export const INITIAL_SALES_TODOS: SalesTodo[] = [
|
||||
{
|
||||
id: "1",
|
||||
title: "Follow up with Acme Corp",
|
||||
stage: "qualified",
|
||||
value: 50000,
|
||||
dueDate: "2026-04-20",
|
||||
assignee: "Alice",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "Send proposal to TechStart",
|
||||
stage: "proposal",
|
||||
value: 120000,
|
||||
dueDate: "2026-04-18",
|
||||
assignee: "Bob",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "Schedule demo for GlobalInc",
|
||||
stage: "prospect",
|
||||
value: 75000,
|
||||
dueDate: "2026-04-22",
|
||||
assignee: "Alice",
|
||||
completed: false,
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user