Skip to content

Commit 7149fd7

Browse files
committed
feat(showcase/spring-ai): port byoc-hashbrown demo with hashbrown UI kit renderer
1 parent 3f3f2ca commit 7149fd7

9 files changed

Lines changed: 763 additions & 0 deletions

File tree

showcase/integrations/spring-ai/src/app/api/copilotkit/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const agentNames = [
5454
"reasoning-default-render",
5555
"tool-rendering-reasoning-chain",
5656
"mcp-apps",
57+
"byoc-hashbrown",
5758
];
5859

5960
const agents: Record<string, AbstractAgent> = {};
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"use client";
2+
3+
/**
4+
* BarChart for the byoc-hashbrown Spring AI demo.
5+
*/
6+
import { useRef } from "react";
7+
import {
8+
BarChart as RechartsBarChart,
9+
Bar,
10+
XAxis,
11+
YAxis,
12+
Tooltip,
13+
CartesianGrid,
14+
Cell,
15+
ResponsiveContainer,
16+
Rectangle,
17+
} from "recharts";
18+
import { z } from "zod";
19+
import { CHART_COLORS, CHART_CONFIG } from "./chart-config";
20+
21+
export const BarChartProps = z.object({
22+
title: z.string().describe("Chart title"),
23+
description: z.string().describe("Brief description or subtitle"),
24+
data: z.array(
25+
z.object({
26+
label: z.string(),
27+
value: z.number(),
28+
}),
29+
),
30+
});
31+
32+
type BarChartPropsType = z.infer<typeof BarChartProps>;
33+
34+
function useSeenIndices() {
35+
const seen = useRef(new Set<number>());
36+
return {
37+
isNew(index: number) {
38+
if (seen.current.has(index)) return false;
39+
seen.current.add(index);
40+
return true;
41+
},
42+
};
43+
}
44+
45+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
46+
function AnimatedBar(props: any) {
47+
const { isNew, ...rest } = props;
48+
return (
49+
<g
50+
style={
51+
isNew
52+
? {
53+
animation: "barSlideIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) both",
54+
}
55+
: undefined
56+
}
57+
>
58+
<Rectangle {...rest} />
59+
</g>
60+
);
61+
}
62+
63+
export function BarChart({ title, description, data }: BarChartPropsType) {
64+
const { isNew } = useSeenIndices();
65+
66+
if (!data || !Array.isArray(data) || data.length === 0) {
67+
return (
68+
<div
69+
data-testid="bar-chart"
70+
className="max-w-2xl mx-auto my-4 rounded-lg border border-[var(--border)] bg-[var(--card)]"
71+
>
72+
<div className="p-6">
73+
<h3 className="text-lg font-semibold leading-none tracking-tight text-[var(--foreground)]">
74+
{title}
75+
</h3>
76+
<p className="text-sm text-[var(--muted-foreground)]">
77+
{description}
78+
</p>
79+
</div>
80+
<div className="p-6 pt-0">
81+
<p className="text-[var(--muted-foreground)] text-center py-8 text-sm">
82+
No data available
83+
</p>
84+
</div>
85+
</div>
86+
);
87+
}
88+
89+
return (
90+
<div
91+
data-testid="bar-chart"
92+
className="max-w-2xl mx-auto my-4 overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--card)]"
93+
>
94+
<style>{`
95+
@keyframes barSlideIn {
96+
from { transform: translateY(40px); opacity: 0; }
97+
20% { opacity: 1; }
98+
to { transform: translateY(0); opacity: 1; }
99+
}
100+
`}</style>
101+
<div className="p-6 pb-2">
102+
<h3 className="text-lg font-semibold leading-none tracking-tight text-[var(--foreground)]">
103+
{title}
104+
</h3>
105+
<p className="text-sm text-[var(--muted-foreground)]">{description}</p>
106+
</div>
107+
<div className="p-6 pt-2">
108+
<ResponsiveContainer width="100%" height={280}>
109+
<RechartsBarChart
110+
data={data}
111+
margin={{ top: 12, right: 12, bottom: 4, left: -8 }}
112+
>
113+
<CartesianGrid
114+
strokeDasharray="3 3"
115+
stroke="var(--border)"
116+
vertical={false}
117+
/>
118+
<XAxis
119+
dataKey="label"
120+
tick={{ fontSize: 12, fill: "var(--muted-foreground)" }}
121+
stroke="var(--border)"
122+
tickLine={false}
123+
axisLine={false}
124+
/>
125+
<YAxis
126+
tick={{ fontSize: 12, fill: "var(--muted-foreground)" }}
127+
stroke="var(--border)"
128+
tickLine={false}
129+
axisLine={false}
130+
/>
131+
<Tooltip
132+
contentStyle={CHART_CONFIG.tooltipStyle}
133+
cursor={{ fill: "var(--secondary)", opacity: 0.5 }}
134+
/>
135+
<Bar
136+
isAnimationActive={false}
137+
dataKey="value"
138+
radius={[6, 6, 0, 0]}
139+
maxBarSize={48}
140+
shape={(props: unknown) => {
141+
const p = props as Record<string, unknown>;
142+
return <AnimatedBar {...p} isNew={isNew(p.index as number)} />;
143+
}}
144+
>
145+
{data.map((_, index) => (
146+
<Cell
147+
key={index}
148+
fill={CHART_COLORS[index % CHART_COLORS.length]}
149+
/>
150+
))}
151+
</Bar>
152+
</RechartsBarChart>
153+
</ResponsiveContainer>
154+
</div>
155+
</div>
156+
);
157+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* CopilotKit brand chart palette for the byoc-hashbrown demo.
3+
*/
4+
export const CHART_COLORS = [
5+
"#BEC2FF",
6+
"#85ECCE",
7+
"#FFAC4D",
8+
"#FFF388",
9+
"#189370",
10+
"#EEE6FE",
11+
"#FA5F67",
12+
] as const;
13+
14+
export const CHART_CONFIG = {
15+
tooltipStyle: {
16+
backgroundColor: "var(--card)",
17+
border: "1px solid var(--border)",
18+
borderRadius: "10px",
19+
padding: "10px 14px",
20+
color: "var(--foreground)",
21+
fontSize: "13px",
22+
fontFamily: "var(--font-body)",
23+
boxShadow: "0 4px 12px rgba(0,0,0,0.08)",
24+
},
25+
};
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"use client";
2+
3+
/**
4+
* PieChart for the byoc-hashbrown Spring AI demo.
5+
*/
6+
import { z } from "zod";
7+
import { CHART_COLORS } from "./chart-config";
8+
9+
export const PieChartProps = z.object({
10+
title: z.string().describe("Chart title"),
11+
description: z.string().describe("Brief description or subtitle"),
12+
data: z.array(
13+
z.object({
14+
label: z.string(),
15+
value: z.number(),
16+
}),
17+
),
18+
});
19+
20+
type PieChartPropsType = z.infer<typeof PieChartProps>;
21+
22+
function DonutChart({
23+
data,
24+
size = 240,
25+
strokeWidth = 40,
26+
}: {
27+
data: { label: string; value: number }[];
28+
size?: number;
29+
strokeWidth?: number;
30+
}) {
31+
const radius = (size - strokeWidth) / 2;
32+
const circumference = 2 * Math.PI * radius;
33+
const center = size / 2;
34+
const total = data.reduce((sum, d) => sum + (Number(d.value) || 0), 0);
35+
36+
let accumulated = 0;
37+
const slices = data.map((item, index) => {
38+
const val = Number(item.value) || 0;
39+
const ratio = total > 0 ? val / total : 0;
40+
const arc = ratio * circumference;
41+
const startAt = accumulated;
42+
accumulated += arc;
43+
return {
44+
...item,
45+
arc,
46+
gap: circumference - arc,
47+
dashoffset: -startAt,
48+
color: CHART_COLORS[index % CHART_COLORS.length],
49+
};
50+
});
51+
52+
return (
53+
<svg
54+
width="100%"
55+
viewBox={`0 0 ${size} ${size}`}
56+
className="block mx-auto"
57+
style={{ maxWidth: size, transform: "scaleX(-1)" }}
58+
>
59+
<circle
60+
cx={center}
61+
cy={center}
62+
r={radius}
63+
fill="none"
64+
stroke="var(--secondary)"
65+
strokeWidth={strokeWidth}
66+
/>
67+
{slices.map((slice, i) => (
68+
<circle
69+
key={i}
70+
cx={center}
71+
cy={center}
72+
r={radius}
73+
fill="none"
74+
stroke={slice.color}
75+
strokeWidth={strokeWidth}
76+
strokeDasharray={`${slice.arc} ${slice.gap}`}
77+
strokeDashoffset={slice.dashoffset}
78+
strokeLinecap="butt"
79+
transform={`rotate(-90 ${center} ${center})`}
80+
/>
81+
))}
82+
</svg>
83+
);
84+
}
85+
86+
export function PieChart({ title, description, data }: PieChartPropsType) {
87+
if (!data || !Array.isArray(data) || data.length === 0) {
88+
return (
89+
<div
90+
data-testid="pie-chart"
91+
className="max-w-lg mx-auto my-4 rounded-lg border border-[var(--border)] bg-[var(--card)]"
92+
>
93+
<div className="p-6 pb-0">
94+
<h3 className="text-lg font-semibold leading-none tracking-tight text-[var(--foreground)]">
95+
{title}
96+
</h3>
97+
<p className="text-sm text-[var(--muted-foreground)]">
98+
{description}
99+
</p>
100+
</div>
101+
<div className="p-6">
102+
<p className="text-[var(--muted-foreground)] text-center py-8 text-sm">
103+
No data available
104+
</p>
105+
</div>
106+
</div>
107+
);
108+
}
109+
110+
const total = data.reduce((sum, d) => sum + (Number(d.value) || 0), 0);
111+
112+
return (
113+
<div
114+
data-testid="pie-chart"
115+
className="max-w-lg mx-auto my-4 overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--card)]"
116+
>
117+
<div className="p-6 pb-0">
118+
<h3 className="text-lg font-semibold leading-none tracking-tight text-[var(--foreground)]">
119+
{title}
120+
</h3>
121+
<p className="text-sm text-[var(--muted-foreground)]">{description}</p>
122+
</div>
123+
<div className="p-6 pt-4">
124+
<DonutChart data={data} />
125+
<div className="space-y-2 pt-4">
126+
{data.map((item, index) => {
127+
const val = Number(item.value) || 0;
128+
const pct = total > 0 ? ((val / total) * 100).toFixed(0) : 0;
129+
return (
130+
<div
131+
key={index}
132+
className="flex items-center gap-3 text-sm transition-opacity duration-300 ease-out"
133+
style={{ opacity: 1 }}
134+
>
135+
<span
136+
className="inline-block h-3 w-3 rounded-full shrink-0"
137+
style={{
138+
backgroundColor: CHART_COLORS[index % CHART_COLORS.length],
139+
}}
140+
/>
141+
<span className="flex-1 text-[var(--foreground)] truncate">
142+
{item.label}
143+
</span>
144+
<span className="text-[var(--muted-foreground)] tabular-nums">
145+
{val.toLocaleString()}
146+
</span>
147+
<span className="text-[var(--muted-foreground)] text-sm w-10 text-right tabular-nums">
148+
{pct}%
149+
</span>
150+
</div>
151+
);
152+
})}
153+
</div>
154+
</div>
155+
</div>
156+
);
157+
}

0 commit comments

Comments
 (0)