Merge remote-tracking branch 'origin/main' into alem/oss-360-sdk-foundations

# Conflicts:
#	packages/bot/src/create-bot.ts
This commit is contained in:
Alem Tuzlak
2026-06-29 14:01:14 +02:00
298 changed files with 34547 additions and 3604 deletions
+1
View File
@@ -5,6 +5,7 @@ examples/canvas/llamaindex/next.config.ts
examples/canvas/mastra-pm/next.config.ts
examples/canvas/mastra/next.config.ts
examples/canvas/pydantic-ai/next.config.ts
examples/shadcn/next.config.ts
examples/integrations/a2a-a2ui/next.config.js
examples/integrations/a2a-middleware/next.config.ts
examples/integrations/adk/next.config.ts
+20 -1
View File
@@ -123,7 +123,18 @@ jobs:
if [ -n "$py_files" ]; then
echo "$py_files" | xargs ruff format || echo "::warning::ruff format exited with error"
fi
if [ -n "$(git diff --name-only)" ]; then
# Trigger the auto-commit only when one of the SCOPED PR files
# actually changed. A whole-tree `git diff` here also trips on
# unrelated working-tree drift (e.g. an LFS smudge on a tracked
# `*.png filter=lfs` file), which would set format_fixed=true while
# the scoped `git add` below stages nothing — making `git commit`
# fail with "nothing to commit". Diffing only the scoped files keeps
# the trigger aligned with what the commit step can actually stage.
# shellcheck disable=SC2046 # intentional split: each path is a
# separate `git diff` pathspec arg; the `-s` guard rules out the
# empty-arg (whole-tree) case, and PR paths never contain spaces.
if [ -s .pr-format-files.existing.txt ] && \
! git diff --quiet -- $(cat .pr-format-files.existing.txt); then
echo "format_fixed=true" >> "$GITHUB_ENV"
fi
# Check mode: verify everything is formatted
@@ -161,6 +172,14 @@ jobs:
# leaving formatting violations on the PR branch and (post-
# merge) on main.
xargs -a .pr-format-files.existing.txt git add --
# Guard against an empty staged set: if the scoped `git add` staged
# nothing (e.g. the whole-tree drift that set format_fixed=true lives
# entirely outside the scoped files), `git commit` would exit 1 and
# fail the job. Treat an empty index as a no-op instead.
if git diff --cached --quiet; then
echo "No scoped formatting changes to commit"
exit 0
fi
git commit -m "style: auto-fix formatting"
git push
+2
View File
@@ -0,0 +1,2 @@
OPENAI_API_KEY=
COPILOTKIT_MODEL=openai/gpt-5.4
+8
View File
@@ -0,0 +1,8 @@
node_modules/
.pnpm-store/
.next/
out/
dist/
*.tsbuildinfo
.env*
!.env.example
+72
View File
@@ -0,0 +1,72 @@
# CopilotKit x ShadCN
https://github.com/user-attachments/assets/f0059b04-ad68-4563-ad7a-e574c64e42d0
A compact Next.js example showing how to build a custom CopilotKit chat UI with
ShadCN-style primitives. It uses `useAgent` with a CopilotKit Built-in Agent at
`/api/copilotkit`, renders assistant messages with local chat components, and
includes two frontend interactions:
- A generated line chart rendered through `useFrontendTool`
- A human-in-the-loop taco rain picker rendered through `useHumanInTheLoop`
The app intentionally keeps the chat prompt fixed to make the demo repeatable.
## Prerequisites
- Node.js 20 or newer
- pnpm, via Corepack or your local install. The example declares
`pnpm@10.33.4` in `package.json`.
- An OpenAI API key
## Environment Variables
Create a local environment file:
```bash
cp .env.example .env.local
```
Then set:
```bash
OPENAI_API_KEY=sk-...
COPILOTKIT_MODEL=openai/gpt-5.4
```
`OPENAI_API_KEY` is required for the Built-in Agent. `COPILOTKIT_MODEL` is
optional and defaults to `openai/gpt-5.4` in `app/api/copilotkit/route.ts`.
## Setup
From this example directory:
```bash
cd examples/shadcn
corepack enable
pnpm install
pnpm dev
```
Open the local URL printed by Next.js, usually
`http://localhost:3000`.
## Try It
Press the send button to run each queued example:
1. Ask for a brief explanation of ShadCN
2. Render a simple generated line chart
3. Open the taco rain picker, choose an emoji, and make it rain
Use the reset button in the chat header to replay the sequence.
## Available Checks
```bash
pnpm typecheck
pnpm lint
pnpm build
```
`pnpm check-types` is also available as an alias for `pnpm typecheck`.
@@ -0,0 +1,37 @@
import {
CopilotRuntime,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { BuiltInAgent } from "@copilotkit/runtime/v2";
const agent = new BuiltInAgent({
model: process.env.COPILOTKIT_MODEL ?? "openai/gpt-5.4",
prompt: `
You are a concise assistant for a CopilotKit + ShadCN demo.
Answer briefly. Use renderLineChart only when the user asks for a chart, and
call it exactly once. Use makeItRain only when the user asks for the taco rain
picker or emoji picker. Keep surrounding text short.
`,
});
const runtime = new CopilotRuntime({
agents: {
default: agent,
},
});
export const POST = async (request: Request) => {
if (!process.env.OPENAI_API_KEY) {
return new Response(
"Missing OPENAI_API_KEY. Set it in examples/shadcn/.env.local and restart the dev server.",
{ status: 500 },
);
}
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
runtime,
endpoint: "/api/copilotkit",
});
return handleRequest(request);
};
+152
View File
@@ -0,0 +1,152 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--font-heading: var(--font-sans);
--font-sans: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
@layer base {
:root {
--radius: 0.625rem;
--background: oklch(0.97 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.55 0.22 263);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.92 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.94 0 0);
--muted-foreground: oklch(0.48 0 0);
--accent: oklch(0.94 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.89 0 0);
--input: oklch(0.89 0 0);
--ring: oklch(0.55 0.22 263);
}
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground antialiased;
}
html {
@apply font-sans;
}
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.62 0.22 255);
--chart-2: oklch(0.66 0.18 155);
--chart-3: oklch(0.72 0.19 80);
--chart-4: oklch(0.64 0.2 330);
--chart-5: oklch(0.6 0.2 25);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.72 0.18 255);
--chart-2: oklch(0.74 0.15 155);
--chart-3: oklch(0.78 0.17 80);
--chart-4: oklch(0.74 0.17 330);
--chart-5: oklch(0.72 0.17 25);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
+27
View File
@@ -0,0 +1,27 @@
import type { Metadata } from "next";
import "./globals.css";
import { Inter } from "next/font/google";
import { Providers } from "./providers";
import { cn } from "@/lib/utils";
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
export const metadata: Metadata = {
title: "CopilotKit ShadCN Chat",
description:
"A minimal useAgent demo with chat, charts, and human-in-the-loop UI.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className={cn("font-sans", inter.variable)}>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { SimpleChat } from "@/components/simple-chat";
export default function Home() {
return <SimpleChat />;
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
import type { ReactNode } from "react";
import { CopilotKit } from "@copilotkit/react-core/v2";
import { TooltipProvider } from "@/components/ui/tooltip";
function Providers({ children }: { children: ReactNode }) {
return (
<CopilotKit runtimeUrl="/api/copilotkit" enableInspector={false}>
<TooltipProvider>{children}</TooltipProvider>
</CopilotKit>
);
}
export { Providers };
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-rhea",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide",
"rtl": false,
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
@@ -0,0 +1,245 @@
"use client";
import { motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
import { z } from "zod";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import type { ChartConfig } from "@/components/ui/chart";
import { Marker, MarkerContent } from "@/components/ui/marker";
import { Skeleton } from "@/components/ui/skeleton";
export const lineChartSchema = z.object({
title: z
.string()
.min(1)
.max(80)
.describe("A short chart title shown above the chart."),
description: z
.string()
.max(160)
.optional()
.describe("Optional one-sentence context for the chart."),
unit: z
.string()
.max(20)
.optional()
.describe("Optional unit label, such as score, count, or value."),
data: z
.array(
z.object({
label: z.string().min(1).max(32),
value: z.number().finite().min(-1000000).max(1000000),
}),
)
.min(2)
.max(12)
.describe("Between 2 and 12 ordered finite numeric points."),
});
export type LineChartCardProps = z.infer<typeof lineChartSchema>;
type RuntimeLineChartCardProps = Partial<Omit<LineChartCardProps, "data">> & {
data?: unknown;
};
type LinePoint = {
label: string;
value: number;
};
const chartConfig = {
value: {
color: "var(--chart-1)",
label: "Value",
},
} satisfies ChartConfig;
const LINE_CHART_HEIGHT = 180;
const MotionCard = motion.create(Card);
function LineChartCard(props: RuntimeLineChartCardProps) {
const prefersReducedMotion = useReducedMotion();
const title = textOrDefault(props.title, "Simple trend");
const description =
optionalText(props.description) ?? "A compact trend over time.";
const unit = optionalText(props.unit);
const data = normalizeData(props.data);
return (
<MotionCard
size="sm"
className="w-full max-w-full gap-3 border border-border/70 bg-card/95 shadow-none ring-0"
initial={prefersReducedMotion ? false : { opacity: 0, scale: 0.98, y: 8 }}
animate={
prefersReducedMotion ? undefined : { opacity: 1, scale: 1, y: 0 }
}
transition={
prefersReducedMotion
? undefined
: { duration: 0.28, ease: [0.23, 1, 0.32, 1] as const }
}
>
<CardHeader className="gap-1 pb-0">
<CardTitle className="text-base leading-tight">{title}</CardTitle>
{description ? (
<CardDescription className="line-clamp-2">
{description}
</CardDescription>
) : null}
</CardHeader>
<CardContent>
{data.length < 2 ? (
<Marker className="text-sm text-muted-foreground">
<MarkerContent>
Waiting for at least two ordered data points.
</MarkerContent>
</Marker>
) : (
<ChartContainer
config={chartConfig}
className="aspect-auto w-full"
style={{ height: LINE_CHART_HEIGHT }}
>
<LineChart
accessibilityLayer
data={data}
margin={{ top: 12, right: 8, bottom: 8, left: 8 }}
>
<CartesianGrid vertical={false} />
<XAxis hide axisLine={false} dataKey="label" tickLine={false} />
<YAxis hide axisLine={false} domain={["auto", "auto"]} />
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
hideLabel={false}
indicator="line"
labelFormatter={(label) => (
<span className="max-w-40 truncate">{label}</span>
)}
formatter={(value) => (
<span className="font-mono font-medium tabular-nums">
{formatTooltipValue(value, unit)}
</span>
)}
/>
}
/>
<Line
type="monotone"
dataKey="value"
stroke="var(--color-value)"
strokeWidth={2.5}
dot={{
fill: "var(--color-value)",
r: 3,
strokeWidth: 0,
}}
activeDot={{
fill: "var(--background)",
r: 5,
stroke: "var(--color-value)",
strokeWidth: 2,
}}
/>
</LineChart>
</ChartContainer>
)}
</CardContent>
</MotionCard>
);
}
function LineChartCardSkeleton() {
return (
<Card
size="sm"
className="w-full max-w-full gap-3 border border-border/70 bg-card/95 shadow-none ring-0"
aria-label="Loading line chart"
>
<CardHeader className="gap-2 pb-0">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-52" />
</CardHeader>
<CardContent>
<Skeleton className="w-full" style={{ height: LINE_CHART_HEIGHT }} />
</CardContent>
</Card>
);
}
function normalizeData(data: unknown): LinePoint[] {
if (!Array.isArray(data)) {
return [];
}
return data.slice(0, 12).flatMap((point, index) => {
if (!point || typeof point !== "object") {
return [];
}
const rawLabel = "label" in point ? point.label : undefined;
const rawValue = "value" in point ? point.value : undefined;
const value =
typeof rawValue === "number"
? rawValue
: typeof rawValue === "string"
? Number(rawValue)
: Number.NaN;
if (typeof rawLabel !== "string" || !Number.isFinite(value)) {
return [];
}
return [
{
label: rawLabel.trim().slice(0, 32) || `Point ${index + 1}`,
value,
},
];
});
}
function optionalText(value: unknown) {
return typeof value === "string" ? value.trim() || undefined : undefined;
}
function textOrDefault(value: unknown, fallback: string) {
return optionalText(value) ?? fallback;
}
function compactNumber(value: unknown) {
if (typeof value !== "number") {
return String(value);
}
return Intl.NumberFormat("en", {
maximumFractionDigits: Math.abs(value) < 10 ? 1 : 0,
notation: Math.abs(value) >= 1000 ? "compact" : "standard",
}).format(value);
}
function formatTooltipValue(value: unknown, unit?: string) {
if (typeof value !== "number") {
return String(value);
}
const formatted = compactNumber(value);
return unit ? `${formatted} ${unit}` : formatted;
}
export { LineChartCard, LineChartCardSkeleton };
@@ -0,0 +1,384 @@
"use client";
import { useHumanInTheLoop } from "@copilotkit/react-core/v2";
import { motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const emojiOptions = [
{ emoji: "🌮", label: "Taco" },
{ emoji: "✨", label: "Sparkles" },
{ emoji: "🚀", label: "Rocket" },
{ emoji: "🎉", label: "Party" },
{ emoji: "🔥", label: "Fire" },
{ emoji: "💜", label: "Heart" },
{ emoji: "⚡", label: "Bolt" },
] as const;
const emojiValues = emojiOptions.map((option) => option.emoji);
export const makeItRainSchema = z.object({
reason: z
.string()
.max(120)
.optional()
.describe("A short reason for showing the emoji picker."),
options: z
.array(z.string().min(1).max(8))
.min(2)
.max(6)
.optional()
.describe("Optional emoji choices for the user to pick from."),
});
type MakeItRainArgs = z.infer<typeof makeItRainSchema>;
type RainDrop = {
delay: number;
driftEnd: number;
driftStart: number;
duration: number;
emoji: string;
id: string;
left: number;
rotation: number;
size: number;
};
type RainShower = {
drops: RainDrop[];
id: string;
};
type CompletedRainResult = {
emoji?: unknown;
status?: unknown;
};
type RainPlaybackStatus = "idle" | "active" | "finished";
function MakeItRain() {
const [showers, setShowers] = React.useState<RainShower[]>([]);
const prefersReducedMotion = useReducedMotion();
const startRain = React.useCallback((emoji: string) => {
const id = crypto.randomUUID();
const drops = createRainDrops(id, emoji);
const longestDrop = Math.max(
...drops.map((drop) => drop.delay + drop.duration),
);
const rainDuration = longestDrop + 250;
setShowers((current) => [...current, { id, drops }]);
window.setTimeout(() => {
setShowers((current) => current.filter((shower) => shower.id !== id));
}, rainDuration);
return rainDuration;
}, []);
useHumanInTheLoop<MakeItRainArgs>(
{
name: "makeItRain",
description:
"Ask the user to pick an emoji, then rain that emoji across the screen.",
parameters: makeItRainSchema,
followUp: false,
render: (props) => <MakeItRainPicker {...props} onRain={startRain} />,
},
[startRain],
);
return (
<div
aria-hidden="true"
className="pointer-events-none fixed inset-0 z-50 overflow-hidden"
>
{showers.flatMap((shower) =>
shower.drops.map((drop) => (
<motion.span
key={drop.id}
data-rain-drop
className="fixed top-0 select-none will-change-transform"
initial={{
opacity: 0,
rotate: 0,
x: drop.driftStart,
y: "-16vh",
}}
animate={{
opacity: prefersReducedMotion ? [0, 1, 0] : [0, 1, 1, 0],
rotate: prefersReducedMotion ? 0 : drop.rotation,
x: prefersReducedMotion ? drop.driftStart : drop.driftEnd,
y: "112vh",
}}
transition={{
delay: drop.delay / 1000,
duration: drop.duration / 1000,
ease: "linear",
opacity: {
delay: drop.delay / 1000,
duration: drop.duration / 1000,
ease: "linear",
times: prefersReducedMotion ? [0, 0.2, 1] : [0, 0.12, 0.88, 1],
},
}}
style={{
fontSize: `${drop.size}px`,
left: `${drop.left}%`,
}}
>
{drop.emoji}
</motion.span>
)),
)}
</div>
);
}
function MakeItRainPicker({
args,
onRain,
respond,
result,
status,
toolCallId,
}: {
args: Partial<MakeItRainArgs>;
onRain: (emoji: string) => number;
respond?: (result: unknown) => Promise<void>;
result?: unknown;
status: string;
toolCallId: string;
}) {
const options = getEmojiOptions(args.options);
const [requestedEmoji, setRequestedEmoji] = React.useState(options[0].emoji);
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [rainedEmoji, setRainedEmoji] = React.useState<string | null>(null);
const [rainPlaybackStatus, setRainPlaybackStatus] =
React.useState<RainPlaybackStatus>("idle");
const rainedToolCallIdRef = React.useRef<string | null>(null);
const finishRainTimerRef = React.useRef<number | null>(null);
const selectedEmoji = options.some(
(option) => option.emoji === requestedEmoji,
)
? requestedEmoji
: options[0].emoji;
const completedResultEmoji = getCompletedEmoji(result);
const completedEmoji =
rainedEmoji ??
completedResultEmoji ??
(status === "complete" ? selectedEmoji : undefined);
const canSubmit = status === "executing" && Boolean(respond);
const triggerRain = React.useCallback(
(emoji: string) => {
if (rainedToolCallIdRef.current === toolCallId) {
return;
}
rainedToolCallIdRef.current = toolCallId;
setRainedEmoji(emoji);
setRainPlaybackStatus("active");
if (finishRainTimerRef.current !== null) {
window.clearTimeout(finishRainTimerRef.current);
}
const rainDuration = onRain(emoji);
finishRainTimerRef.current = window.setTimeout(() => {
setRainPlaybackStatus("finished");
finishRainTimerRef.current = null;
}, rainDuration);
},
[onRain, toolCallId],
);
React.useEffect(() => {
if (status === "complete" && completedEmoji) {
triggerRain(completedEmoji);
}
}, [completedEmoji, status, triggerRain]);
React.useEffect(() => {
return () => {
if (finishRainTimerRef.current !== null) {
window.clearTimeout(finishRainTimerRef.current);
}
};
}, []);
if (completedEmoji || status === "complete") {
return (
<Card
size="sm"
className="w-full max-w-full border border-border/70 bg-card/95 shadow-none"
>
<CardHeader className="gap-1">
<CardTitle className="text-base">
Made it rain {completedEmoji ?? selectedEmoji}
</CardTitle>
<CardDescription>
{rainPlaybackStatus === "active"
? "The animation is running."
: rainPlaybackStatus === "finished"
? "The animation has finished."
: "Starting the animation."}
</CardDescription>
</CardHeader>
</Card>
);
}
const reason =
typeof args.reason === "string" && args.reason.trim()
? args.reason.trim()
: "Pick the emoji for the full-screen effect.";
async function handleRain() {
setIsSubmitting(true);
triggerRain(selectedEmoji);
try {
await respond?.({ emoji: selectedEmoji, status: "raining" });
} finally {
setIsSubmitting(false);
}
}
return (
<Card
size="sm"
className="w-full max-w-full border border-border/70 bg-card/95 shadow-none"
>
<CardHeader className="gap-1">
<CardTitle className="text-base">Pick an emoji</CardTitle>
<CardDescription>{reason}</CardDescription>
</CardHeader>
<CardContent className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="w-full justify-start">
<span className="text-lg">{selectedEmoji}</span>
<span>Choose emoji</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-44 w-(--radix-dropdown-menu-trigger-width)">
<DropdownMenuLabel>Emoji</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={selectedEmoji}
onValueChange={setRequestedEmoji}
>
{options.map((option) => (
<DropdownMenuRadioItem key={option.emoji} value={option.emoji}>
<span className="text-base">{option.emoji}</span>
<span>{option.label}</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</CardContent>
<CardFooter>
<Button
type="button"
className="w-full"
disabled={isSubmitting || !canSubmit}
onClick={() => {
void handleRain();
}}
>
{isSubmitting
? "Raining..."
: canSubmit
? `Make it rain ${selectedEmoji}`
: "Waiting for the assistant"}
</Button>
</CardFooter>
</Card>
);
}
function getEmojiOptions(options: unknown) {
if (!Array.isArray(options)) {
return [...emojiOptions];
}
const allowedEmojiValues: readonly string[] = emojiValues;
const customOptions = options
.filter((emoji): emoji is string => typeof emoji === "string")
.filter((emoji) => allowedEmojiValues.includes(emoji));
if (customOptions.length < 2) {
return [...emojiOptions];
}
return customOptions.map((emoji) => {
const knownOption = emojiOptions.find((option) => option.emoji === emoji);
return knownOption ?? { emoji, label: "Custom" };
});
}
function createRainDrops(showerId: string, emoji: string): RainDrop[] {
return Array.from({ length: 88 }, (_, index) => ({
delay: Math.floor(Math.random() * 1600),
driftEnd: Math.round((Math.random() - 0.5) * 96),
driftStart: Math.round((Math.random() - 0.5) * 24),
duration: 6500 + Math.floor(Math.random() * 2200),
emoji,
id: `${showerId}-${index}`,
left: Math.round(Math.random() * 100),
rotation: Math.round((Math.random() - 0.5) * 240),
size: 18 + Math.floor(Math.random() * 14),
}));
}
function getCompletedEmoji(result: unknown) {
const parsedResult = parseCompletedRainResult(result);
return typeof parsedResult?.emoji === "string"
? parsedResult.emoji
: undefined;
}
function parseCompletedRainResult(result: unknown): CompletedRainResult | null {
if (!result) {
return null;
}
if (typeof result === "object") {
return result as CompletedRainResult;
}
if (typeof result !== "string") {
return null;
}
try {
return JSON.parse(result) as CompletedRainResult;
} catch {
return null;
}
}
export { MakeItRain };
@@ -0,0 +1,322 @@
"use client";
import { useRenderToolCall } from "@copilotkit/react-core/v2";
import { motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import { Message, MessageContent } from "@/components/ui/message";
import { MessageScrollerItem } from "@/components/ui/message-scroller";
type MessageAnimatedPart = {
text?: string;
type?: string;
};
type MessageAnimatedMessage = {
content?: unknown;
id?: string;
parts?: ReadonlyArray<MessageAnimatedPart>;
role?: string;
text?: string;
toolCallId?: string;
toolCalls?: MessageAnimatedToolCall[];
};
type MessageAnimatedToolCall = {
id: string;
type: "function";
function: {
arguments: string;
name: string;
};
};
type MessageAnimatedToolMessage = MessageAnimatedMessage & {
content: string;
id: string;
role: "tool";
toolCallId: string;
};
type MessageAnimatedTextPart = {
key: string;
text: string;
};
type MessageAnimatedScrollerItemProps = Omit<
React.ComponentProps<typeof MessageScrollerItem>,
"children" | "messageId"
>;
type MotionScrollerItemProps = Omit<
MessageAnimatedScrollerItemProps,
| "onAnimationEnd"
| "onAnimationIteration"
| "onAnimationStart"
| "onDrag"
| "onDragEnd"
| "onDragStart"
>;
const MessageAnimatedMessagesContext = React.createContext<
MessageAnimatedMessage[]
>([]);
// The animated wrapper still renders the installed ShadCN MessageScrollerItem.
const MotionMessageScrollerItem = motion.create(MessageScrollerItem);
function MessageAnimatedMessagesProvider({
children,
messages,
}: {
children: React.ReactNode;
messages: MessageAnimatedMessage[];
}) {
return (
<MessageAnimatedMessagesContext.Provider value={messages}>
{children}
</MessageAnimatedMessagesContext.Provider>
);
}
function MessageAnimated({
assistantVariant = "ghost",
message,
scrollAnchor,
userVariant = "muted",
...props
}: MessageAnimatedScrollerItemProps & {
assistantVariant?: React.ComponentProps<typeof Bubble>["variant"];
message: MessageAnimatedMessage;
userVariant?: React.ComponentProps<typeof Bubble>["variant"];
}) {
const isUserMessage = message.role === "user";
const prefersReducedMotion = useReducedMotion();
const motionItemProps = getMotionScrollerItemProps(props);
const row = (
<MessageAnimatedRow
message={message}
assistantVariant={assistantVariant}
userVariant={userVariant}
/>
);
if (isUserMessage && !prefersReducedMotion) {
return (
<MotionMessageScrollerItem
messageId={message.id}
scrollAnchor={scrollAnchor ?? true}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.24, ease: [0.23, 1, 0.32, 1] }}
{...motionItemProps}
>
{row}
</MotionMessageScrollerItem>
);
}
return (
<MessageScrollerItem
messageId={message.id}
scrollAnchor={scrollAnchor ?? isUserMessage}
{...props}
>
{row}
</MessageScrollerItem>
);
}
function MessageAnimatedLoading({
label = "Thinking and parsing...",
}: {
label?: string;
}) {
return (
<MessageScrollerItem messageId="assistant-loading" scrollAnchor>
<Message align="start" role="status" aria-live="polite">
<MessageContent>
<Bubble variant="ghost">
<BubbleContent>
<span className="shimmer shimmer-duration-1600 text-muted-foreground">
{label}
</span>
</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</MessageScrollerItem>
);
}
function getMotionScrollerItemProps(
props: MessageAnimatedScrollerItemProps,
): MotionScrollerItemProps {
const motionProps = { ...props };
delete motionProps.onAnimationEnd;
delete motionProps.onAnimationIteration;
delete motionProps.onAnimationStart;
delete motionProps.onDrag;
delete motionProps.onDragEnd;
delete motionProps.onDragStart;
return motionProps as MotionScrollerItemProps;
}
function MessageAnimatedRow({
assistantVariant,
message,
userVariant,
}: {
assistantVariant: React.ComponentProps<typeof Bubble>["variant"];
message: MessageAnimatedMessage;
userVariant: React.ComponentProps<typeof Bubble>["variant"];
}) {
const renderToolCall = useRenderToolCall();
const allMessages = React.useContext(MessageAnimatedMessagesContext);
const isUserMessage = message.role === "user";
const textParts = getMessageAnimatedTextParts(message);
const toolCalls = Array.isArray(message.toolCalls) ? message.toolCalls : [];
const visibleToolCalls = getVisibleToolCalls(toolCalls);
return (
<Message align={isUserMessage ? "end" : "start"}>
<MessageContent>
{textParts.map((part) => {
const paragraphs = part.text
.split(/\n\s*\n/)
.map((paragraph) => paragraph.trim())
.filter(Boolean);
return (
<Bubble
key={part.key}
variant={isUserMessage ? userVariant : assistantVariant}
>
<BubbleContent className="space-y-2">
{paragraphs.map((paragraph, paragraphIndex) => (
<p
key={`${part.key}-${paragraphIndex}`}
className="whitespace-pre-wrap"
>
{paragraph}
</p>
))}
</BubbleContent>
</Bubble>
);
})}
{visibleToolCalls.map((toolCall) => (
<div key={toolCall.id} className="w-full max-w-full">
{renderToolCall({
toolCall,
toolMessage: findToolMessage(allMessages, toolCall.id),
})}
</div>
))}
</MessageContent>
</Message>
);
}
function getVisibleToolCalls(toolCalls: MessageAnimatedToolCall[]) {
let hasRenderedChart = false;
return toolCalls.filter((toolCall) => {
if (!isChartToolCall(toolCall)) {
return true;
}
if (hasRenderedChart) {
return false;
}
hasRenderedChart = true;
return true;
});
}
function isChartToolCall(toolCall: MessageAnimatedToolCall) {
return toolCall.function.name === "renderLineChart";
}
function findToolMessage(
messages: MessageAnimatedMessage[],
toolCallId: string,
): MessageAnimatedToolMessage | undefined {
const message = messages.find(
(candidate): candidate is MessageAnimatedToolMessage =>
candidate.role === "tool" &&
typeof candidate.id === "string" &&
candidate.toolCallId === toolCallId &&
typeof candidate.content === "string",
);
return message;
}
function getMessageAnimatedTextParts(
message: MessageAnimatedMessage,
): MessageAnimatedTextPart[] {
if (message.parts) {
return message.parts.flatMap((part, index) => {
if (part.type !== "text" || typeof part.text !== "string") {
return [];
}
return [{ key: `${message.id ?? "message"}-${index}`, text: part.text }];
});
}
if (typeof message.text === "string") {
return [{ key: `${message.id ?? "message"}-text`, text: message.text }];
}
return contentToTextParts(message.id, message.content);
}
function contentToTextParts(
messageId: string | undefined,
content: unknown,
): MessageAnimatedTextPart[] {
if (typeof content === "string") {
return [{ key: `${messageId ?? "message"}-content`, text: content }];
}
if (Array.isArray(content)) {
return content.flatMap((part, index) => {
if (typeof part === "string") {
return [{ key: `${messageId ?? "message"}-${index}`, text: part }];
}
if (
part &&
typeof part === "object" &&
"text" in part &&
typeof part.text === "string"
) {
return [{ key: `${messageId ?? "message"}-${index}`, text: part.text }];
}
return [];
});
}
return content
? [
{
key: `${messageId ?? "message"}-json`,
text: JSON.stringify(content, null, 2),
},
]
: [];
}
export {
MessageAnimated,
MessageAnimatedLoading,
MessageAnimatedMessagesProvider,
type MessageAnimatedMessage,
};
+470
View File
@@ -0,0 +1,470 @@
"use client";
import * as React from "react";
import {
UseAgentUpdate,
useAgent,
useCopilotKit,
useFrontendTool,
} from "@copilotkit/react-core/v2";
import {
ArrowUpIcon,
MessageCircleDashedIcon,
PaperclipIcon,
PlusIcon,
RotateCwIcon,
} from "lucide-react";
import {
MessageAnimated,
MessageAnimatedLoading,
MessageAnimatedMessagesProvider,
} from "@/components/message-animated";
import {
LineChartCard,
LineChartCardSkeleton,
lineChartSchema,
} from "@/components/generative-ui/line-chart";
import { MakeItRain } from "@/components/generative-ui/make-it-rain";
import {
Attachment,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from "@/components/ui/attachment";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
} from "@/components/ui/input-group";
import { Marker, MarkerContent } from "@/components/ui/marker";
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/components/ui/message-scroller";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
type AgentMessage = {
id?: string;
role?: string;
content?: unknown;
toolCallId?: string;
toolCalls?: AgentToolCall[];
};
type AgentToolCall = {
id: string;
type: "function";
function: {
name: string;
arguments: string;
};
};
const queuedMessages = [
"Explain to me briefly what ShadCN is and how I can use it.",
"Render one simple line chart.",
"Show a small human-in-the-loop taco rain picker.",
];
const BASE_CHAT_WIDTH = 384;
const BASE_CARD_HEIGHT = 560;
const BASE_CHAT_STACK_HEIGHT = 608;
const VIEWPORT_MARGIN = 32;
function messageText(content: unknown) {
if (typeof content === "string") {
return content;
}
if (Array.isArray(content)) {
return content
.map((part) => {
if (typeof part === "string") {
return part;
}
if (
part &&
typeof part === "object" &&
"text" in part &&
typeof part.text === "string"
) {
return part.text;
}
return "";
})
.filter(Boolean)
.join("\n");
}
return content ? JSON.stringify(content, null, 2) : "";
}
function messageRole(message: AgentMessage): "user" | "assistant" | "system" {
if (message.role === "user") {
return "user";
}
if (message.role === "system") {
return "system";
}
return "assistant";
}
function hasToolCalls(message: AgentMessage) {
return Array.isArray(message.toolCalls) && message.toolCalls.length > 0;
}
function isVisibleMessage(message: AgentMessage) {
if (message.role === "tool") {
return false;
}
return (
messageText(message.content).trim().length > 0 || hasToolCalls(message)
);
}
function isWaitingForAssistant(messages: AgentMessage[]) {
const lastVisibleMessage = messages.at(-1);
return Boolean(
lastVisibleMessage && messageRole(lastVisibleMessage) === "user",
);
}
function calculateChatScale() {
if (typeof window === "undefined") {
return 1;
}
const targetHalfViewport = (window.innerHeight * 0.5) / BASE_CARD_HEIGHT;
const fitWidth = (window.innerWidth - VIEWPORT_MARGIN) / BASE_CHAT_WIDTH;
const fitHeight =
(window.innerHeight - VIEWPORT_MARGIN) / BASE_CHAT_STACK_HEIGHT;
return Math.max(
0.72,
Math.min(Math.max(1, targetHalfViewport), fitWidth, fitHeight),
);
}
function useResponsiveChatScale() {
const [scale, setScale] = React.useState(1);
React.useEffect(() => {
function updateScale() {
setScale(calculateChatScale());
}
updateScale();
window.addEventListener("resize", updateScale);
window.visualViewport?.addEventListener("resize", updateScale);
return () => {
window.removeEventListener("resize", updateScale);
window.visualViewport?.removeEventListener("resize", updateScale);
};
}, []);
return scale;
}
export function SimpleChat() {
return <ChatPanel />;
}
function ChatPanel() {
const [error, setError] = React.useState<string | null>(null);
const [uploadedFile, setUploadedFile] = React.useState<File | null>(null);
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const chatScale = useResponsiveChatScale();
const { copilotkit } = useCopilotKit();
const { agent } = useAgent({
agentId: "default",
updates: [
UseAgentUpdate.OnMessagesChanged,
UseAgentUpdate.OnRunStatusChanged,
],
throttleMs: 50,
});
useFrontendTool(
{
name: "renderLineChart",
agentId: "default",
parameters: lineChartSchema,
handler: async () => "Line chart rendered.",
render: ({ args, status }) =>
status === "complete" ? (
<LineChartCard {...args} />
) : (
<LineChartCardSkeleton />
),
followUp: false,
description:
"Render exactly one compact line chart. Use 2 to 12 ordered finite numeric points and short labels.",
},
[],
);
const messages = (agent.messages ?? []) as AgentMessage[];
const visibleMessages = messages.filter(isVisibleMessage);
const isRunning = Boolean(agent.isRunning);
const showAssistantLoading =
isRunning && isWaitingForAssistant(visibleMessages);
const nextMessage =
queuedMessages[
messages.filter((message) => messageRole(message) === "user").length
] ?? null;
async function sendMessage() {
if (!nextMessage || isRunning) {
return;
}
setError(null);
try {
agent.addMessage({
id: crypto.randomUUID(),
role: "user",
content: nextMessage,
} as never);
await copilotkit.runAgent({ agent });
} catch (caughtError) {
setError(
caughtError instanceof Error
? caughtError.message
: "The assistant could not be reached.",
);
}
}
function resetConversation() {
if (isRunning) {
agent.abortRun();
}
agent.setMessages([]);
setUploadedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
setError(null);
}
function handleFileUpload(event: React.ChangeEvent<HTMLInputElement>) {
setUploadedFile(event.currentTarget.files?.[0] ?? null);
}
return (
<MessageScrollerProvider>
<MakeItRain />
<main className="flex min-h-screen items-center justify-center overflow-hidden bg-background p-4">
<div
className="relative"
style={{
height: BASE_CHAT_STACK_HEIGHT * chatScale,
width: BASE_CHAT_WIDTH * chatScale,
}}
>
<div
className="relative flex origin-top-left flex-col gap-4"
style={{
transform: `scale(${chatScale})`,
width: BASE_CHAT_WIDTH,
}}
>
<Card className="mx-auto h-140 w-full max-w-sm gap-0">
<CardHeader className="gap-1 border-b">
<CardTitle>New Chat</CardTitle>
<CardDescription>How can I help you today?</CardDescription>
<CardAction>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
aria-label="Reset conversation"
onClick={resetConversation}
>
<RotateCwIcon />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Reset</p>
</TooltipContent>
</Tooltip>
</CardAction>
</CardHeader>
<CardContent className="flex-1 overflow-hidden p-0">
{visibleMessages.length === 0 ? (
<Empty className="h-full">
<EmptyHeader>
<EmptyMedia variant="icon">
<MessageCircleDashedIcon />
</EmptyMedia>
<EmptyTitle>Ready when you are</EmptyTitle>
<EmptyDescription>
Press send to run the first example.
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<MessageScroller>
<MessageScrollerViewport>
<MessageScrollerContent
aria-busy={isRunning}
className="p-(--card-spacing)"
>
<MessageAnimatedMessagesProvider messages={messages}>
{visibleMessages.map((message, index) => (
<MessageAnimated
key={message.id ?? `${message.role}-${index}`}
message={message}
scrollAnchor={messageRole(message) === "user"}
/>
))}
{showAssistantLoading ? (
<MessageAnimatedLoading />
) : null}
</MessageAnimatedMessagesProvider>
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
)}
</CardContent>
<CardFooter className="flex-col gap-2">
{uploadedFile ? (
<Attachment size="sm" className="w-full">
<AttachmentMedia>
<PaperclipIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>{uploadedFile.name}</AttachmentTitle>
<AttachmentDescription>
{formatFileSize(uploadedFile.size)}
</AttachmentDescription>
</AttachmentContent>
</Attachment>
) : null}
<form
onSubmit={(event) => {
event.preventDefault();
void sendMessage();
}}
className="w-full"
>
<input
ref={fileInputRef}
type="file"
className="sr-only"
onChange={handleFileUpload}
/>
<InputGroup>
<div className="h-14 w-full px-3 py-2.5">
<span
className="line-clamp-2 opacity-60 data-[status=ready]:opacity-100"
data-status={
nextMessage && !isRunning ? "ready" : "busy"
}
>
{nextMessage ? (
nextMessage
) : (
<span className="text-muted-foreground">
All examples complete. Reset to replay.
</span>
)}
</span>
</div>
<InputGroupAddon align="block-end" className="pt-1">
<InputGroupButton
aria-label="Upload file"
type="button"
size="icon-sm"
variant="outline"
onClick={() => fileInputRef.current?.click()}
>
<PlusIcon />
</InputGroupButton>
<InputGroupButton
type="submit"
variant="default"
size="icon-sm"
disabled={!nextMessage || isRunning}
className="ml-auto"
>
<ArrowUpIcon />
<span className="sr-only">Send</span>
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</form>
{error ? (
<Marker className="min-h-0 text-xs text-destructive">
<MarkerContent>{error}</MarkerContent>
</Marker>
) : null}
</CardFooter>
</Card>
<div className="px-0.5 text-center text-xs text-muted-foreground">
{nextMessage
? "Press send to run the next example."
: "Reset to replay the examples."}
</div>
</div>
</div>
</main>
</MessageScrollerProvider>
);
}
function formatFileSize(bytes: number) {
if (bytes < 1024) {
return `${bytes} B`;
}
const units = ["KB", "MB", "GB"] as const;
let size = bytes / 1024;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[unitIndex]}`;
}
@@ -0,0 +1,207 @@
import * as React from "react";
import { cva } from "class-variance-authority";
import type { VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
const attachmentVariants = cva(
"group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-2xl border bg-card text-card-foreground transition-colors focus-within:ring-1 focus-within:ring-ring/30 has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed",
{
variants: {
size: {
default:
"gap-2 text-sm has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2 has-data-[slot=attachment-media]:p-2",
sm: "gap-2.5 text-xs has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5",
xs: "gap-1.5 rounded-xl text-xs has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1",
},
orientation: {
horizontal: "min-w-40 items-center",
vertical: "w-24 flex-col has-data-[slot=attachment-content]:w-30",
},
},
},
);
function Attachment({
className,
state = "done",
size = "default",
orientation = "horizontal",
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof attachmentVariants> & {
state?: "idle" | "uploading" | "processing" | "error" | "done";
}) {
const resolvedOrientation = orientation ?? "horizontal";
return (
<div
data-slot="attachment"
data-state={state}
data-size={size}
data-orientation={resolvedOrientation}
className={cn(attachmentVariants({ size, orientation }), className)}
{...props}
/>
);
}
const attachmentMediaVariants = cva(
"relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5",
{
variants: {
variant: {
icon: "",
image:
"opacity-60 group-data-[state=done]/attachment:opacity-100 group-data-[state=idle]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover",
},
},
defaultVariants: {
variant: "icon",
},
},
);
function AttachmentMedia({
className,
variant = "icon",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof attachmentMediaVariants>) {
return (
<div
data-slot="attachment-media"
data-variant={variant}
className={cn(attachmentMediaVariants({ variant }), className)}
{...props}
/>
);
}
function AttachmentContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-content"
className={cn(
"max-w-full min-w-0 flex-1 leading-tight group-data-[orientation=vertical]/attachment:px-1",
className,
)}
{...props}
/>
);
}
function AttachmentTitle({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="attachment-title"
className={cn(
"block max-w-full min-w-0 truncate font-medium group-data-[state=processing]/attachment:shimmer group-data-[state=uploading]/attachment:shimmer",
className,
)}
{...props}
/>
);
}
function AttachmentDescription({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="attachment-description"
className={cn(
"mt-0.5 block min-w-0 truncate text-xs text-muted-foreground group-data-[state=error]/attachment:text-destructive/80",
"max-w-full",
className,
)}
{...props}
/>
);
}
function AttachmentActions({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-actions"
className={cn(
"relative z-20 flex shrink-0 items-center group-data-[orientation=vertical]/attachment:absolute group-data-[orientation=vertical]/attachment:top-3 group-data-[orientation=vertical]/attachment:right-3 group-data-[orientation=vertical]/attachment:gap-1",
className,
)}
{...props}
/>
);
}
function AttachmentAction({
className,
variant,
size = "icon-xs",
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="attachment-action"
variant={variant ?? "ghost"}
size={size}
className={cn(className)}
{...props}
/>
);
}
function AttachmentTrigger({
className,
asChild = false,
type,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="attachment-trigger"
type={asChild ? undefined : (type ?? "button")}
className={cn("absolute inset-0 z-10 outline-none", className)}
{...props}
/>
);
}
function AttachmentGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-group"
className={cn(
"flex min-w-0 scroll-fade-x snap-x snap-mandatory scroll-px-1 scrollbar-none gap-3 overflow-x-auto overscroll-x-contain py-1 *:data-[slot=attachment]:flex-none *:data-[slot=attachment]:snap-start",
className,
)}
{...props}
/>
);
}
export {
Attachment,
AttachmentGroup,
AttachmentMedia,
AttachmentContent,
AttachmentTitle,
AttachmentDescription,
AttachmentActions,
AttachmentAction,
AttachmentTrigger,
};
+126
View File
@@ -0,0 +1,126 @@
import * as React from "react";
import { cva } from "class-variance-authority";
import type { VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
function BubbleGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="bubble-group"
className={cn("flex min-w-0 flex-col gap-2", className)}
{...props}
/>
);
}
const bubbleVariants = cva(
"group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full",
{
variants: {
variant: {
default:
"*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80",
secondary:
"*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]",
muted:
"*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]",
tinted:
"*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]",
outline:
"*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30",
ghost:
"border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50",
destructive:
"*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Bubble({
variant = "default",
align = "start",
className,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof bubbleVariants> & {
align?: "start" | "end";
}) {
return (
<div
data-slot="bubble"
data-variant={variant}
data-align={align}
className={cn(bubbleVariants({ variant }), className)}
{...props}
/>
);
}
function BubbleContent({
asChild = false,
className,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "div";
return (
<Comp
data-slot="bubble-content"
className={cn(
"w-fit max-w-full min-w-0 overflow-hidden rounded-3xl border border-transparent px-3 py-2.5 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/30",
className,
)}
{...props}
/>
);
}
const bubbleReactionsVariants = cva(
"absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0",
{
variants: {
side: {
top: "top-0 -translate-y-3/4",
bottom: "bottom-0 translate-y-3/4",
},
align: {
start: "left-3",
end: "right-3",
},
},
defaultVariants: {
side: "bottom",
align: "end",
},
},
);
function BubbleReactions({
side = "bottom",
align = "end",
className,
...props
}: React.ComponentProps<"div"> & {
align?: "start" | "end";
side?: "top" | "bottom";
}) {
return (
<div
data-slot="bubble-reactions"
data-align={align}
data-side={side}
className={cn(bubbleReactionsVariants({ side, align }), className)}
{...props}
/>
);
}
export { BubbleGroup, Bubble, BubbleContent, BubbleReactions };
+66
View File
@@ -0,0 +1,66 @@
import * as React from "react";
import { cva } from "class-variance-authority";
import type { VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-2xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-transparent dark:hover:bg-input/30",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
lg: "h-9 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-7",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
+100
View File
@@ -0,0 +1,100 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-[min(var(--radius-4xl),24px)] bg-card py-(--card-spacing) text-sm text-card-foreground shadow-sm ring-1 ring-foreground/5 [--card-spacing:--spacing(5)] has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(4)] dark:ring-foreground/10 *:[img:first-child]:rounded-t-[min(var(--radius-4xl),24px)] *:[img:last-child]:rounded-b-[min(var(--radius-4xl),24px)]",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1.5 rounded-t-[min(var(--radius-4xl),24px)] px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("font-heading text-base font-medium", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-[min(var(--radius-4xl),24px)] px-(--card-spacing) [.border-t]:pt-(--card-spacing)",
className,
)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};
+373
View File
@@ -0,0 +1,373 @@
"use client";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import type { TooltipValueType } from "recharts";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
const INITIAL_DIMENSION = { width: 320, height: 200 } as const;
type TooltipNameType = number | string;
export type ChartConfig = Record<
string,
{
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
>;
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
initialDimension = INITIAL_DIMENSION,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
initialDimension?: {
width: number;
height: number;
};
}) {
const uniqueId = React.useId();
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer
initialDimension={initialDimension}
>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme ?? config.color,
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
} & Omit<
RechartsPrimitive.DefaultTooltipContentProps<
TooltipValueType,
TooltipNameType
>,
"accessibilityLayer"
>) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? (config[label]?.label ?? label)
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
className={cn(
"grid min-w-32 items-start gap-1.5 rounded-xl bg-popover px-2.5 py-1.5 text-xs text-popover-foreground shadow-lg ring-1 ring-foreground/5 dark:ring-foreground/10",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color ?? item.payload?.fill ?? item.color;
return (
<div
key={index}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label ?? item.name}
</span>
</div>
{item.value != null && (
<span className="font-mono font-medium text-foreground tabular-nums">
{typeof item.value === "number"
? item.value.toLocaleString()
: String(item.value)}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> & {
hideIcon?: boolean;
nameKey?: string;
} & RechartsPrimitive.DefaultLegendContentProps) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.dataKey ?? "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={index}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};
@@ -0,0 +1,273 @@
"use client";
import * as React from "react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { CheckIcon, ChevronRightIcon } from "lucide-react";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
function DropdownMenuContent({
className,
align = "start",
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
align={align}
className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-2xl bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex min-h-7 cursor-default items-center gap-2 rounded-xl px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className,
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex min-h-7 cursor-default items-center gap-2 rounded-xl py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex min-h-7 cursor-default items-center gap-2 rounded-xl py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1 text-xs text-muted-foreground data-inset:pl-7",
className,
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border/50", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex min-h-7 cursor-default items-center gap-2 rounded-xl px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-2xl bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};
+105
View File
@@ -0,0 +1,105 @@
import { cva } from "class-variance-authority";
import type { VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-3xl border-dashed p-12 text-center text-balance",
className,
)}
{...props}
/>
);
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
{...props}
/>
);
}
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted text-foreground [&_svg:not([class*='size-'])]:size-5",
},
},
defaultVariants: {
variant: "default",
},
},
);
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
);
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn(
"font-heading text-lg font-medium tracking-tight",
className,
)}
{...props}
/>
);
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className,
)}
{...props}
/>
);
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
className,
)}
{...props}
/>
);
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
};
@@ -0,0 +1,156 @@
"use client";
import * as React from "react";
import { cva } from "class-variance-authority";
import type { VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-2xl border border-transparent bg-input/50 transition-[color,box-shadow] duration-200 outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/30 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className,
)}
{...props}
/>
);
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 **:data-[slot=kbd]:rounded-2xl **:data-[slot=kbd]:bg-muted-foreground/10 **:data-[slot=kbd]:px-1.5 [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
},
);
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return;
}
e.currentTarget.parentElement?.querySelector("input")?.focus();
}}
{...props}
/>
);
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 rounded-2xl text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-xl px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs": "size-6 rounded-xl p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
},
);
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
);
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent",
className,
)}
{...props}
/>
);
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent",
className,
)}
{...props}
/>
);
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
};
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-2.5 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Input };
+70
View File
@@ -0,0 +1,70 @@
import * as React from "react";
import { cva } from "class-variance-authority";
import type { VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
const markerVariants = cva(
"group/marker relative flex min-h-4 w-full items-center gap-2 text-left text-sm text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [a]:underline [a]:underline-offset-3 [a]:hover:text-foreground",
{
variants: {
variant: {
default: "",
separator:
"before:mr-1 before:h-px before:min-w-0 before:flex-1 before:bg-border after:ml-1 after:h-px after:min-w-0 after:flex-1 after:bg-border",
border: "border-b border-border pb-2",
},
},
},
);
function Marker({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof markerVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "div";
return (
<Comp
data-slot="marker"
data-variant={variant}
className={cn(markerVariants({ variant, className }))}
{...props}
/>
);
}
function MarkerIcon({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="marker-icon"
aria-hidden="true"
className={cn(
"size-4 shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function MarkerContent({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="marker-content"
className={cn(
"min-w-0 wrap-break-word group-data-[variant=separator]/marker:flex-none group-data-[variant=separator]/marker:text-center *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className,
)}
{...props}
/>
);
}
export { Marker, MarkerIcon, MarkerContent, markerVariants };
@@ -0,0 +1,285 @@
"use client";
import * as React from "react";
import {
MessageScroller as MessageScrollerPrimitive,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
} from "@shadcn/react/message-scroller";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { ArrowDownIcon } from "lucide-react";
function MessageScrollerProvider(
props: React.ComponentProps<typeof MessageScrollerPrimitive.Provider>,
) {
return <MessageScrollerPrimitive.Provider {...props} />;
}
function MessageScroller({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Root>) {
return (
<MessageScrollerPrimitive.Root
data-slot="message-scroller"
className={cn(
"group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden",
className,
)}
{...props}
/>
);
}
function MessageScrollerViewport({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Viewport>) {
return (
<MessageScrollerPrimitive.Viewport
data-slot="message-scroller-viewport"
className={cn(
"size-full min-h-0 min-w-0 scroll-fade-b scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-none",
className,
)}
{...props}
/>
);
}
function MessageScrollerContent({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Content>) {
return (
<MessageScrollerPrimitive.Content
data-slot="message-scroller-content"
className={cn("flex h-max min-h-full flex-col gap-8", className)}
{...props}
/>
);
}
function MessageScrollerItem({
className,
scrollAnchor = false,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Item>) {
return (
<MessageScrollerPrimitive.Item
data-slot="message-scroller-item"
scrollAnchor={scrollAnchor}
className={cn(
"min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]",
className,
)}
{...props}
/>
);
}
function MessageScrollerButton({
direction = "end",
className,
children,
onClick,
render: _render,
variant = "secondary",
size = "icon-sm",
behavior = "smooth",
tabIndex,
type = "button",
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Button> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
void _render;
const buttonRef = React.useRef<HTMLButtonElement>(null);
const controls = useMessageScroller();
const isActive = useNativeScrollButtonActive(buttonRef, direction);
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (event.defaultPrevented) {
return;
}
event.preventDefault();
event.currentTarget.blur();
if (direction === "end") {
controls.scrollToEnd({ behavior });
} else {
controls.scrollToStart({ behavior });
}
const viewport = event.currentTarget
.closest<HTMLElement>('[data-slot="message-scroller"]')
?.querySelector<HTMLElement>('[data-slot="message-scroller-viewport"]');
if (!viewport) {
return;
}
scrollViewportToEdge(viewport, direction, behavior);
window.requestAnimationFrame(() =>
scrollViewportToEdge(viewport, direction, "auto"),
);
},
[behavior, controls, direction, onClick],
);
return (
<Button
ref={buttonRef}
data-slot="message-scroller-button"
data-active={isActive ? "true" : "false"}
data-direction={direction}
data-variant={variant}
data-size={size}
aria-hidden={!isActive}
tabIndex={isActive ? tabIndex : -1}
type={type}
className={cn(
"absolute inset-s-1/2 -translate-x-1/2 border-border bg-background text-foreground transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)] data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full rtl:translate-x-1/2 data-[direction=start]:[&_svg]:rotate-180",
className,
)}
variant={variant}
size={size}
onClick={handleClick}
{...props}
>
{children ?? (
<>
<ArrowDownIcon />
<span className="sr-only">
{direction === "end" ? "Scroll to end" : "Scroll to start"}
</span>
</>
)}
</Button>
);
}
function useNativeScrollButtonActive(
buttonRef: React.RefObject<HTMLButtonElement | null>,
direction: "start" | "end",
) {
const [isActive, setIsActive] = React.useState(false);
React.useLayoutEffect(() => {
const button = buttonRef.current;
const viewport = button
?.closest<HTMLElement>('[data-slot="message-scroller"]')
?.querySelector<HTMLElement>('[data-slot="message-scroller-viewport"]');
if (!viewport) {
setIsActive(false);
return;
}
const scrollViewport = viewport;
const content = scrollViewport.querySelector<HTMLElement>(
'[data-slot="message-scroller-content"]',
);
let frame: number | null = null;
function readActiveState() {
if (direction === "start") {
return scrollViewport.scrollTop > 8;
}
return (
scrollViewport.scrollHeight -
scrollViewport.scrollTop -
scrollViewport.clientHeight >
8
);
}
function updateActiveState() {
frame = null;
setIsActive(readActiveState());
}
function scheduleUpdate() {
if (frame !== null) {
return;
}
frame = window.requestAnimationFrame(updateActiveState);
}
updateActiveState();
scrollViewport.addEventListener("scroll", scheduleUpdate, {
passive: true,
});
window.addEventListener("resize", scheduleUpdate);
window.visualViewport?.addEventListener("resize", scheduleUpdate);
const resizeObserver =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(scheduleUpdate);
resizeObserver?.observe(scrollViewport);
if (content) {
resizeObserver?.observe(content);
}
const mutationObserver =
content && typeof MutationObserver !== "undefined"
? new MutationObserver(scheduleUpdate)
: null;
mutationObserver?.observe(content!, {
childList: true,
subtree: true,
});
return () => {
if (frame !== null) {
window.cancelAnimationFrame(frame);
}
scrollViewport.removeEventListener("scroll", scheduleUpdate);
window.removeEventListener("resize", scheduleUpdate);
window.visualViewport?.removeEventListener("resize", scheduleUpdate);
resizeObserver?.disconnect();
mutationObserver?.disconnect();
};
}, [buttonRef, direction]);
return isActive;
}
function scrollViewportToEdge(
viewport: HTMLElement,
direction: "start" | "end",
behavior: ScrollBehavior,
) {
const top = direction === "end" ? viewport.scrollHeight : 0;
if (behavior === "smooth") {
viewport.scrollTo({ top, behavior });
return;
}
viewport.scrollTop = top;
}
export {
MessageScrollerProvider,
MessageScroller,
MessageScrollerViewport,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerButton,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
};
+92
View File
@@ -0,0 +1,92 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function MessageGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-group"
className={cn("flex min-w-0 flex-col gap-2", className)}
{...props}
/>
);
}
function Message({
className,
align = "start",
...props
}: React.ComponentProps<"div"> & { align?: "start" | "end" }) {
return (
<div
data-slot="message"
data-align={align}
className={cn(
"group/message relative flex w-full min-w-0 gap-2 text-sm data-[align=end]:flex-row-reverse",
className,
)}
{...props}
/>
);
}
function MessageAvatar({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-avatar"
className={cn(
"flex w-fit min-w-8 shrink-0 items-center justify-center self-end overflow-hidden rounded-full bg-muted group-has-data-[slot=message-footer]/message:-translate-y-8",
className,
)}
{...props}
/>
);
}
function MessageContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-content"
className={cn(
"flex w-full min-w-0 flex-col gap-2.5 wrap-break-word group-data-[align=end]/message:*:data-slot:self-end",
className,
)}
{...props}
/>
);
}
function MessageHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-header"
className={cn(
"flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0",
className,
)}
{...props}
/>
);
}
function MessageFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-footer"
className={cn(
"flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0 group-data-[align=end]/message:justify-end",
className,
)}
{...props}
/>
);
}
export {
MessageGroup,
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageHeader,
};
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-2xl bg-muted", className)}
{...props}
/>
);
}
export { Skeleton };
@@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-2.5 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Textarea };
+57
View File
@@ -0,0 +1,57 @@
"use client";
import * as React from "react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-xl bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-lg data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=left]:translate-x-[-1.5px] data-[side=right]:translate-x-[1.5px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTypescript from "eslint-config-next/typescript";
export default defineConfig([
...nextVitals,
...nextTypescript,
globalIgnores([".next/**", "node_modules/**"]),
]);
+7
View File
@@ -0,0 +1,7 @@
import { clsx } from "clsx";
import type { ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+9
View File
@@ -0,0 +1,9 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
turbopack: {
root: process.cwd(),
},
};
export default nextConfig;
+46
View File
@@ -0,0 +1,46 @@
{
"name": "shadcn-example",
"version": "0.0.1",
"private": true,
"description": "CopilotKit useAgent chat example built with official ShadCN chat and chart components.",
"license": "MIT",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"check-types": "tsc --noEmit -p tsconfig.json",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@copilotkit/react-core": "1.61.2",
"@copilotkit/runtime": "1.61.2",
"@shadcn/react": "^0.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "2.1.1",
"lucide-react": "1.21.0",
"motion": "^12.42.0",
"next": "16.2.9",
"radix-ui": "^1.6.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"recharts": "3.8.0",
"tailwind-merge": "3.6.0",
"zod": "3.25.76",
"openai": "6.45.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.1",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9.39.2",
"eslint-config-next": "16.2.9",
"postcss": "^8.5.15",
"tailwindcss": "4.3.1",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"shadcn": "^4.12.0"
},
"packageManager": "pnpm@10.33.4"
}
+12592
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
packages:
- "."
minimumReleaseAgeExclude:
- "@shadcn/react@0.1.0"
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+57
View File
@@ -0,0 +1,57 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: "class",
content: [
"./app/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./lib/**/*.{ts,tsx}",
],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
fontFamily: {
mono: ["ui-monospace", "SFMono-Regular", "Menlo", "monospace"],
sans: ["ui-sans-serif", "system-ui", "sans-serif"],
},
},
},
plugins: [],
};
export default config;
+33
View File
@@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}
-8
View File
@@ -25,14 +25,6 @@ SLACK_APP_TOKEN=xapp-...
# (no public URL or webhook setup needed).
# TELEGRAM_BOT_TOKEN=
# ── Persistence (optional) ──────────────────────────────────────────────
# Optional Redis-backed durable store. Used by `pnpm demo:restart` (and any
# bot that passes `store: { adapter: createRedisStore({ url }) }`). Leave blank
# for the in-memory default. With it set, interactive actions (e.g. an approval
# card's button) survive a bot restart — see `app/demo-restart.tsx`.
# Start a local one with `docker compose up -d`.
# REDIS_URL=redis://localhost:6379
# ── Agent backend (runtime.ts) ──────────────────────────────────────────
# The AG-UI endpoint the bridge POSTs to. Default points at the local
# CopilotKit runtime started by `pnpm runtime`.
-217
View File
@@ -1,217 +0,0 @@
/**
* Durable-action restart demo proves an interactive action survives a bot
* restart. Minimal (Slack only, NO agent backend): on @mention it posts an
* approval card whose **Create button's onClick does the work directly**
* (self-contained no agent run, no awaitChoice). Because the action snapshot
* is persisted in the configured `store` and the component is registered at
* startup via `components`, the click re-fires after a process restart.
*
* Redis is OPTIONAL:
* No `REDIS_URL` -> in-memory store (default). Runs out of the box, but a
* click that lands after a restart degrades to "action expired".
* With `REDIS_URL` -> Redis-backed store. Kill + restart the bot between
* posting and clicking, and the action still fires (durable).
*
* Linear is OPTIONAL: with `LINEAR_API_KEY` the Create button files a real
* Linear issue; without it, it just resolves the card (so the demo runs with
* only Slack tokens).
*
* Run:
* pnpm demo:restart # in-memory, no Redis needed
* docker compose up -d && REDIS_URL=redis://localhost:6379 pnpm demo:restart
*/
import "dotenv/config";
import { createBot } from "@copilotkit/bot";
import type { StoreConfig } from "@copilotkit/bot";
import { slack } from "@copilotkit/bot-slack";
import { createRedisStore } from "@copilotkit/bot-store-redis";
import {
Message,
Header,
Section,
Context,
Actions,
Button,
} from "@copilotkit/bot-ui";
import type { InteractionContext } from "@copilotkit/bot-ui";
function required(name: string): string {
const v = process.env[name];
if (!v) {
console.error(`Missing required env var: ${name}`);
process.exit(1);
}
return v;
}
const LINEAR_API_KEY = process.env.LINEAR_API_KEY;
const REDIS_URL = process.env.REDIS_URL;
// --- Optional Linear write (direct GraphQL; no agent / MCP) ------------------
async function linearGraphQL<T>(query: string, variables: object): Promise<T> {
const res = await fetch("https://api.linear.app/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: LINEAR_API_KEY as string,
},
body: JSON.stringify({ query, variables }),
});
const json = (await res.json()) as { data?: T; errors?: unknown };
if (!res.ok || json.errors) {
throw new Error(
`Linear API error: ${JSON.stringify(json.errors ?? res.status)}`,
);
}
return json.data as T;
}
async function createLinearIssue(
title: string,
): Promise<{ identifier: string; url: string }> {
const teamKey = required("LINEAR_TEAM_KEY");
const teams = await linearGraphQL<{ teams: { nodes: { id: string }[] } }>(
`query($key: String!) { teams(filter: { key: { eq: $key } }) { nodes { id } } }`,
{ key: teamKey },
);
const teamId = teams.teams.nodes[0]?.id;
if (!teamId) throw new Error(`No Linear team with key ${teamKey}`);
const created = await linearGraphQL<{
issueCreate: {
success: boolean;
issue: { identifier: string; url: string };
};
}>(
`mutation($teamId: String!, $title: String!) {
issueCreate(input: { teamId: $teamId, title: $title }) {
success issue { identifier url }
}
}`,
{ teamId, title },
);
if (!created.issueCreate.success)
throw new Error("Linear issueCreate failed");
return created.issueCreate.issue;
}
// --- The durable HITL card (self-contained onClick does the write) -----------
interface ConfirmCreateIssueProps {
title: string;
}
export function ConfirmCreateIssue({ title }: ConfirmCreateIssueProps) {
return (
<Message accent="#E2B340">
<Header>{`📝 Create Linear issue?`}</Header>
<Section>{`**${title}**`}</Section>
<Context>
{REDIS_URL
? "🔒 Nothing is written until you click **Create**. Kill + restart the bot first to prove durability."
: "🔒 Nothing is written until you click **Create**. (Set REDIS_URL for restart-durable actions.)"}
</Context>
<Actions>
<Button
value={{ confirmed: true }}
style="primary"
onClick={async ({ thread, message }: InteractionContext) => {
try {
if (LINEAR_API_KEY) {
const issue = await createLinearIssue(title);
await thread.update(
message.ref,
<Message accent="#27AE60">
<Header>{`✅ Created ${issue.identifier}`}</Header>
<Section>{`**${title}**`}</Section>
<Context>{`${issue.url}`}</Context>
</Message>,
);
} else {
await thread.update(
message.ref,
<Message accent="#27AE60">
<Header>{`✅ Approved`}</Header>
<Section>{`**${title}**`}</Section>
<Context>
{
"✅ Approved (demo — set LINEAR_API_KEY to file a real issue)."
}
</Context>
</Message>,
);
}
} catch (err) {
await thread.update(
message.ref,
<Message accent="#EB5757">
<Header>{`⚠️ Create failed`}</Header>
<Context>{`${(err as Error).message}`}</Context>
</Message>,
);
}
}}
>
Create
</Button>
<Button
value={{ confirmed: false }}
style="danger"
onClick={async ({ thread, message }: InteractionContext) => {
await thread.update(
message.ref,
<Message accent="#EB5757">
<Header>{`🚫 Cancelled`}</Header>
<Context>{"🚫 Nothing was written."}</Context>
</Message>,
);
}}
>
Cancel
</Button>
</Actions>
</Message>
);
}
async function main() {
// Redis is optional: configure a durable backend only when REDIS_URL is set;
// otherwise omit `adapter` and the bot uses the in-memory default.
const store: StoreConfig | undefined = REDIS_URL
? { adapter: createRedisStore({ url: REDIS_URL }) }
: undefined;
const bot = createBot({
adapters: [
slack({
botToken: required("SLACK_BOT_TOKEN"),
appToken: required("SLACK_APP_TOKEN"),
}),
],
store,
// Registered at startup so a click landing AFTER a restart can re-render
// this component from the persisted snapshot and re-fire its onClick.
components: [ConfirmCreateIssue],
});
bot.onMention(async ({ thread, message }) => {
const title = message.text.trim().slice(0, 120) || "Untitled (demo)";
await thread.post(<ConfirmCreateIssue title={title} />);
});
await bot.start();
console.log(
`[demo] up (pid ${process.pid}) — store: ${REDIS_URL ? "redis (durable)" : "in-memory"}, ` +
`write: ${LINEAR_API_KEY ? "linear" : "demo (no LINEAR_API_KEY)"}. @mention the bot.`,
);
const stop = async () => {
await bot.stop();
process.exit(0);
};
process.on("SIGINT", () => void stop());
process.on("SIGTERM", () => void stop());
}
main().catch((err) => {
console.error("[demo] fatal", err);
process.exit(1);
});
-9
View File
@@ -1,9 +0,0 @@
# UNCOMMITTED demo helper — local Redis for the durable-action restart test.
# docker compose up -d # start Redis on localhost:6379
# docker compose down # stop it
services:
redis:
image: redis:7-alpine
container_name: slack-demo-redis
ports:
- "6379:6379"
-2
View File
@@ -8,7 +8,6 @@
"scripts": {
"dev": "tsx watch app/index.ts",
"start": "tsx app/index.ts",
"demo:restart": "tsx app/demo-restart.tsx",
"build": "pnpm exec nx run-many -t build -p \"@copilotkit/bot*\" @copilotkit/runtime",
"runtime": "tsx runtime.ts",
"notion-mcp": "tsx scripts/start-notion-mcp.ts",
@@ -22,7 +21,6 @@
"@copilotkit/bot": "workspace:*",
"@copilotkit/bot-discord": "workspace:*",
"@copilotkit/bot-slack": "workspace:*",
"@copilotkit/bot-store-redis": "workspace:*",
"@copilotkit/bot-telegram": "workspace:*",
"@copilotkit/bot-ui": "workspace:*",
"@copilotkit/bot-whatsapp": "workspace:*",
@@ -104,6 +104,20 @@ describe("decodeInteraction", () => {
expect(evt?.value).toBe("opt-a");
});
it("decodes a multi-select (maxValues > 1) into a string[] of chosen values", () => {
const evt = decodeInteraction({
isButton: () => false,
isStringSelectMenu: () => true,
customId: "ck:ms",
component: { maxValues: 5 },
values: ["core", "infra"],
message: baseMsg,
channelId: "c1",
user: { id: "u1" },
});
expect(evt?.value).toEqual(["core", "infra"]);
});
it("returns undefined for a non-component interaction", () => {
expect(
decodeInteraction({
+28 -8
View File
@@ -11,6 +11,12 @@ interface ComponentInteractionLike {
isStringSelectMenu(): boolean;
customId?: string;
values?: string[];
/**
* The resolved select component. A multi-select is marked by `maxValues > 1`
* OR `minValues === 0` (the renderer sets `minValues(0)` on every multi, which
* also catches a one-option multi-select whose `maxValues` is 1).
*/
component?: { maxValues?: number; minValues?: number };
message?: { id: string };
channelId?: string;
guildId?: string | null;
@@ -38,14 +44,15 @@ export function decodeInteraction(raw: unknown): InteractionEvent | undefined {
let id = customId;
let value: unknown;
if (isSelect) {
value = i.values?.[0];
if (typeof value === "string") {
try {
value = JSON.parse(value);
} catch {
// Not JSON — keep the raw string.
}
}
// Discord sends `values: string[]` for both single and multi selects; the
// unambiguous signal is the component's value bounds (the renderer sets
// maxValues > 1 and minValues 0 for multi). Multi → a string[] of all chosen
// values; single → the one value (mirrors bot-slack).
const c = i.component;
const multi = (c?.maxValues ?? 1) > 1 || c?.minValues === 0;
value = multi
? (i.values ?? []).map(parseSelectValue)
: parseSelectValue(i.values?.[0]);
} else {
const sep = customId.startsWith("ck:") ? customId.indexOf(";v:") : -1;
if (sep !== -1) {
@@ -69,6 +76,16 @@ export function decodeInteraction(raw: unknown): InteractionEvent | undefined {
};
}
/** JSON-parse a chosen select value so non-string option values round-trip; else keep the raw string. */
function parseSelectValue(raw: string | undefined): unknown {
if (typeof raw !== "string") return raw;
try {
return JSON.parse(raw);
} catch {
return raw;
}
}
/** A `v:<json>` custom_id carries a small bound value; anything else has none. */
function unpackValue(customId: string): unknown {
if (!customId.startsWith("v:")) return undefined;
@@ -168,6 +185,9 @@ export function decodeReaction(
...(r.message?.guildId ? { guildId: r.message.guildId } : {}),
},
messageId,
// Update-capable ref (channelId + message id) so an onReaction handler can
// edit the reacted message in place via thread.update.
messageRef: { id: messageId, channelId },
raw: reaction,
};
}
@@ -60,6 +60,52 @@ describe("renderComponents", () => {
expect(btn.style).toBe(ButtonStyle.Primary);
});
it("renders a url button as a Link-style button with no custom_id", () => {
const ir: BotNode[] = [
node("message", {
children: node("actions", {
children: node("button", {
children: text("Open"),
url: "https://dash/deploy/42",
}),
}),
}),
];
const json = renderComponents(ir).toJSON();
const row = json.components.find(
(c: any) => c.type === ComponentType.ActionRow,
);
const btn = (row as any).components[0];
expect(btn.style).toBe(ButtonStyle.Link);
expect(btn.url).toBe("https://dash/deploy/42");
expect(btn.custom_id).toBeUndefined();
});
it("sets max_values on a multi-select so the decoder reads an array", () => {
const ir: BotNode[] = [
node("message", {
children: node("actions", {
children: node("select", {
multi: true,
onSelect: { id: "ck:ms" },
options: [
{ label: "Core", value: "core" },
{ label: "Infra", value: "infra" },
],
}),
}),
}),
];
const json = renderComponents(ir).toJSON();
const row = json.components.find(
(c: any) => c.type === ComponentType.ActionRow,
);
const select = (row as any).components[0];
expect(select.type).toBe(ComponentType.StringSelect);
expect(select.max_values).toBe(2);
expect(select.min_values).toBe(0);
});
it("chunks more than 5 buttons into multiple action rows", () => {
const buttons = Array.from({ length: 7 }, (_, i) =>
node("button", { children: text(`b${i}`), onClick: { id: `ck:${i}` } }),
@@ -336,13 +336,22 @@ function buildActionRows(
function buildButton(node: BotNode): ButtonBuilder | undefined {
const props = node.props ?? {};
const label = truncateText(
collectText(node) || " ",
DISCORD_LIMITS.buttonLabel,
);
// Link button: opens a URL natively, carries no custom_id, never dispatches.
if (typeof props.url === "string" && props.url.length > 0) {
return new ButtonBuilder()
.setStyle(ButtonStyle.Link)
.setLabel(label)
.setURL(props.url);
}
const id = buttonCustomId(idFromHandler(props.onClick), props.value);
if (!id) return undefined;
const btn = new ButtonBuilder()
.setCustomId(truncateText(id, DISCORD_LIMITS.customId))
.setLabel(
truncateText(collectText(node) || " ", DISCORD_LIMITS.buttonLabel),
)
.setLabel(label)
.setStyle(buttonStyle(props.style));
return btn;
}
@@ -383,6 +392,11 @@ function buildSelect(node: BotNode): StringSelectMenuBuilder | undefined {
),
)
.addOptions(built);
// Multi-select: allow 0..N picks. maxValues > 1 is also the signal the decoder
// reads (interaction.component.maxValues) to return a string[] instead of one.
if (props.multi) {
select.setMinValues(0).setMaxValues(built.length);
}
return select;
}
@@ -73,6 +73,20 @@ describe("decodeInteraction", () => {
expect(evt!.user).toBeUndefined();
});
it("decodes a multi_static_select's selected_options into a string[] value", () => {
const evt = decodeInteraction({
type: "block_actions",
container: { channel_id: "C3", thread_ts: "200.0" },
actions: [
{
action_id: "ck:ms",
selected_options: [{ value: "core" }, { value: "infra" }],
},
],
});
expect(evt!.value).toEqual(["core", "infra"]);
});
it("returns undefined for non-block_actions or missing action_id", () => {
expect(decodeInteraction({ type: "view_submission" })).toBeUndefined();
expect(
+21 -9
View File
@@ -40,6 +40,7 @@ export function decodeInteraction(raw: unknown): InteractionEvent | undefined {
action_id?: string;
value?: string;
selected_option?: { value?: string };
selected_options?: Array<{ value?: string }>;
action_ts?: string;
}>;
};
@@ -74,15 +75,13 @@ export function decodeInteraction(raw: unknown): InteractionEvent | undefined {
};
// Tiny, non-sensitive value: the clicked button's value (or selected option
// value), JSON-parsed if it round-trips, otherwise the raw string.
const rawValue = action.value ?? action.selected_option?.value;
let value: unknown = rawValue;
if (typeof rawValue === "string") {
try {
value = JSON.parse(rawValue);
} catch {
value = rawValue;
}
// value), JSON-parsed if it round-trips, otherwise the raw string. A
// multi_static_select reports `selected_options` (an array) → a `string[]`.
let value: unknown;
if (action.selected_options) {
value = action.selected_options.map((o) => parseValue(o.value));
} else {
value = parseValue(action.value ?? action.selected_option?.value);
}
const user = body.user?.id
@@ -118,6 +117,16 @@ export function decodeInteraction(raw: unknown): InteractionEvent | undefined {
};
}
/** JSON-parse a control value so non-string option values round-trip; else keep the raw string. */
function parseValue(raw: string | undefined): unknown {
if (typeof raw !== "string") return raw;
try {
return JSON.parse(raw);
} catch {
return raw;
}
}
interface SlackReactionEvent {
user?: string;
reaction?: string;
@@ -154,6 +163,9 @@ export function decodeReaction(
...(e.user ? { recipientUserId: e.user } : {}),
},
messageId: ts,
// Update-capable ref (channel + ts) so an onReaction handler can swap the
// reacted message's UI in place via thread.update.
messageRef: { id: ts, channel },
threadId: ts,
raw: event,
};
+104 -7
View File
@@ -1,10 +1,5 @@
import {
Header,
Message,
Section,
renderToIR,
type BotNode,
} from "@copilotkit/bot-ui";
import { Header, Message, Section, renderToIR } from "@copilotkit/bot-ui";
import type { BotNode } from "@copilotkit/bot-ui";
import { describe, expect, it } from "vitest";
import { renderBlockKit, renderSlackMessage } from "./block-kit.js";
@@ -182,6 +177,108 @@ describe("renderBlockKit", () => {
]),
).toEqual([{ type: "section", text: { type: "mrkdwn", text: "native" } }]);
});
it("renders a link button with a url", () => {
const blocks = renderBlockKit([
{
type: "actions",
props: {
children: [
{
type: "button",
props: {
url: "https://dash/deploy/42",
children: [{ type: "text", props: { value: "Open" } }],
},
},
],
},
},
]);
const el = (blocks[0] as { elements: { url?: string }[] }).elements[0]!;
expect(el.url).toBe("https://dash/deploy/42");
});
it("renders a Field label as a bold mrkdwn line above the value", () => {
const blocks = renderBlockKit([
{
type: "field",
props: {
label: "Status",
children: [{ type: "text", props: { value: "Online" } }],
},
},
]);
const text = (blocks[0] as { fields: { text: string }[] }).fields[0]!.text;
expect(text).toBe("*Status*\nOnline");
});
it("renders a multi-select as its own input block, not inside actions", () => {
const blocks = renderBlockKit([
{
type: "actions",
props: {
children: [
{
type: "select",
props: {
multi: true,
onSelect: { id: "ck:ms" },
placeholder: "Pick teams",
options: [
{ label: "Core", value: "core" },
{ label: "Infra", value: "infra" },
],
},
},
],
},
},
]);
// No actions block is emitted (the only child was peeled into an input block).
expect(blocks).toHaveLength(1);
const block = blocks[0] as {
type: string;
dispatch_action: boolean;
element: { type: string; action_id: string };
};
expect(block.type).toBe("input");
expect(block.dispatch_action).toBe(true);
expect(block.element.type).toBe("multi_static_select");
expect(block.element.action_id).toBe("ck:ms");
});
it("keeps source order when a multi-select is mixed with a button", () => {
const blocks = renderBlockKit([
{
type: "actions",
props: {
children: [
{
type: "button",
props: {
onClick: { id: "ck:b" },
children: [{ type: "text", props: { value: "Go" } }],
},
},
{
type: "select",
props: {
multi: true,
onSelect: { id: "ck:ms" },
options: [{ label: "Core", value: "core" }],
},
},
],
},
},
]);
// The button's actions block comes first, then the multi-select input block.
expect(blocks.map((b) => (b as { type: string }).type)).toEqual([
"actions",
"input",
]);
});
});
describe("renderSlackMessage", () => {
+74 -12
View File
@@ -147,10 +147,7 @@ function renderNode(node: BotNode, out: KnownBlock[]): void {
type: "section",
fields: items.map((f) => ({
type: "mrkdwn",
text: truncateText(
markdownToMrkdwn(collectText(f)),
SLACK_LIMITS.fieldText,
),
text: truncateText(fieldMrkdwn(f), SLACK_LIMITS.fieldText),
})),
} as KnownBlock);
return;
@@ -162,10 +159,7 @@ function renderNode(node: BotNode, out: KnownBlock[]): void {
fields: [
{
type: "mrkdwn",
text: truncateText(
markdownToMrkdwn(collectText(node)),
SLACK_LIMITS.fieldText,
),
text: truncateText(fieldMrkdwn(node), SLACK_LIMITS.fieldText),
},
],
} as KnownBlock);
@@ -190,10 +184,28 @@ function renderNode(node: BotNode, out: KnownBlock[]): void {
childNodes(node),
SLACK_LIMITS.actionsElements,
);
const elements = items
.map(renderActionElement)
.filter((e): e is object => e !== null);
out.push({ type: "actions", elements } as KnownBlock);
// A multi-select can't live in an `actions` block (Slack allows
// multi_static_select only in section/input blocks), so peel each one off
// into its own dispatching input block; the rest stay as action elements.
// Flush the pending actions block BEFORE each peeled-off input so blocks
// stay in source order (e.g. [Button, Select multi] → actions, then input).
let elements: object[] = [];
const flush = () => {
if (elements.length > 0) {
out.push({ type: "actions", elements } as KnownBlock);
elements = [];
}
};
for (const child of items) {
if (child.type === "select" && child.props.multi) {
flush();
out.push(multiSelectInput(child));
continue;
}
const el = renderActionElement(child);
if (el !== null) elements.push(el);
}
flush();
return;
}
case "image": {
@@ -312,6 +324,11 @@ function renderActionElement(node: BotNode): object | null {
text: truncateText(collectText(node), SLACK_LIMITS.buttonText),
},
};
// Link button: opens the URL natively. Slack still requires an action_id
// (kept above); clicks on a url button are not dispatched as actions.
if (typeof props.url === "string" && props.url.length > 0) {
el.url = props.url;
}
if (props.value !== undefined) {
el.value = truncateText(
JSON.stringify(props.value),
@@ -351,6 +368,42 @@ function renderActionElement(node: BotNode): object | null {
}
}
/**
* Render a `<Select multi>` as a dispatching input block holding a
* `multi_static_select` (which Slack forbids inside an `actions` block). The
* block_actions payload carries `selected_options`, decoded to a `string[]`.
*/
function multiSelectInput(node: BotNode): KnownBlock {
const props = node.props ?? {};
const action_id = truncateText(
idFromHandler(props.onSelect) ?? "select",
SLACK_LIMITS.actionId,
);
const options =
(props.options as { label: string; value: unknown }[] | undefined) ?? [];
const { items } = clampArray(options, SLACK_LIMITS.selectOptions);
return {
type: "input",
dispatch_action: true,
element: {
type: "multi_static_select",
action_id,
placeholder: {
type: "plain_text",
text: String(props.placeholder ?? " "),
},
options: items.map((o) => ({
text: { type: "plain_text", text: truncateText(o.label, 75) },
value: truncateText(String(o.value), 150),
})),
},
label: {
type: "plain_text",
text: truncateText(String(props.placeholder ?? " "), 150),
},
} as KnownBlock;
}
/** Derive a button's `action_id`: prefer the registry-stamped id, else a stable fallback. */
function buttonActionId(props: Record<string, unknown>): string {
const fromHandler = idFromHandler(props.onClick);
@@ -381,6 +434,15 @@ function childNodes(node: BotNode): BotNode[] {
return [];
}
/** A field's mrkdwn text: a bold `label` line (when set) above the value. */
function fieldMrkdwn(node: BotNode): string {
const value = markdownToMrkdwn(collectText(node));
const label = (node.props as { label?: unknown }).label;
return typeof label === "string" && label.length > 0
? `*${label}*\n${value}`
: value;
}
/** Concatenate the `value` of all descendant `text` nodes (depth-first). */
function collectText(node: BotNode): string {
if (typeof node.type === "string" && node.type === "text") {
-55
View File
@@ -1,55 +0,0 @@
{
"name": "@copilotkit/bot-store-postgres",
"version": "0.0.2",
"description": "Postgres StateStore backend for @copilotkit/bot.",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/CopilotKit/CopilotKit.git",
"directory": "packages/bot-store-postgres"
},
"homepage": "https://github.com/CopilotKit/CopilotKit",
"keywords": [
"ai",
"agent",
"bot",
"postgres",
"statestore",
"copilotkit",
"ag-ui"
],
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"check-types": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.check.json",
"test": "vitest run",
"test:watch": "vitest",
"publint": "publint .",
"attw": "attw --pack . --profile esm-only"
},
"dependencies": {
"@copilotkit/bot": "workspace:~",
"pg": "^8"
},
"devDependencies": {
"@copilotkit/typescript-config": "workspace:^",
"@types/node": "^22.10.0",
"@types/pg": "^8",
"typescript": "^5.6.3",
"vitest": "^4.1.3"
}
}
-5
View File
@@ -1,5 +0,0 @@
export {
PostgresStore,
createPostgresStore,
migrate,
} from "./postgres-store.js";
@@ -1,25 +0,0 @@
import { describe } from "vitest";
import { runStateStoreConformance } from "@copilotkit/bot";
import { createPostgresStore } from "./postgres-store.js";
const url = process.env.TEST_POSTGRES_URL;
// All PostgresStore instances share the same tables, and the conformance suite
// reuses fixed keys ("a", "k", "L", "q", …) across tests. A unique keyPrefix per
// make() namespaces those keys so repeated keys never collide across tests. The
// pool is closed on teardown. Without TEST_POSTGRES_URL the suite skips so unit
// runs stay hermetic.
(url ? describe : describe.skip)("PostgresStore", () => {
runStateStoreConformance(
"PostgresStore",
async () =>
createPostgresStore({
connectionString: url,
autoMigrate: true,
keyPrefix: `t:${Math.random().toString(36).slice(2)}:`,
}),
async (s) => {
await (s as { end?: () => Promise<void> }).end?.();
},
);
});
@@ -1,397 +0,0 @@
import { randomUUID } from "node:crypto";
import pg from "pg";
import type { Pool } from "pg";
import type { StateStore } from "@copilotkit/bot";
const { Pool: PgPool } = pg;
/** Options for {@link createPostgresStore}. */
export interface CreatePostgresStoreOptions {
/** Connection string, e.g. `postgres://user:pw@localhost:5432/db`. Ignored when `pool` is supplied. */
connectionString?: string;
/** Pre-configured node-postgres pool to use instead of creating one from `connectionString`. */
pool?: Pool;
/**
* Reserved for future schema namespacing of the underlying tables. Currently
* unused; per-tenant isolation is achieved via `keyPrefix`.
*/
schema?: string;
/** Run {@link migrate} automatically on first use. Defaults to `false`. */
autoMigrate?: boolean;
/** Prefix prepended to every logical key. Defaults to `cpk:`. */
keyPrefix?: string;
}
const DEFAULT_LOCK_TTL_MS = 30_000;
/**
* Idempotent schema. Mirrors `schema.sql` (kept in sync). Embedded as a string
* so `migrate()` works at runtime without shipping the `.sql` file in `dist`.
*/
const SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS cpk_state_kv (
key text PRIMARY KEY,
value jsonb NOT NULL,
expires_at timestamptz
);
CREATE TABLE IF NOT EXISTS cpk_state_list (
key text NOT NULL,
seq bigserial PRIMARY KEY,
value jsonb NOT NULL,
expires_at timestamptz
);
CREATE INDEX IF NOT EXISTS cpk_state_list_key ON cpk_state_list(key, seq);
CREATE TABLE IF NOT EXISTS cpk_state_queue (
key text NOT NULL,
seq bigserial PRIMARY KEY,
value jsonb NOT NULL
);
CREATE INDEX IF NOT EXISTS cpk_state_queue_key ON cpk_state_queue(key, seq);
`;
/** Create the StateStore tables/indexes if they do not yet exist. Idempotent. */
export async function migrate(pool: Pool): Promise<void> {
await pool.query(SCHEMA_SQL);
}
/**
* Postgres-backed {@link StateStore}. Durable across restarts and shareable
* across processes/instances. Backs the `kv`/`list`/`lock`/`dedup`/`queue`
* primitives on three tables (`cpk_state_kv`, `cpk_state_list`,
* `cpk_state_queue`). Locks are modelled as TTL'd rows in `cpk_state_kv` (not pg
* advisory locks) so token/TTL fencing semantics match the Redis backend. All
* values are stored as JSONB.
*/
export class PostgresStore implements StateStore {
private readonly pool: Pool;
private readonly prefix: string;
private readonly ownsPool: boolean;
private readonly autoMigrate: boolean;
private migrating?: Promise<void>;
constructor(opts: CreatePostgresStoreOptions = {}) {
if (opts.pool) {
this.pool = opts.pool;
this.ownsPool = false;
} else {
this.pool = new PgPool({ connectionString: opts.connectionString });
this.ownsPool = true;
}
this.prefix = opts.keyPrefix ?? "cpk:";
this.autoMigrate = opts.autoMigrate ?? false;
}
private key(k: string): string {
return `${this.prefix}${k}`;
}
/** Lazily run the migration once (when autoMigrate is enabled) before first use. */
private async ready(): Promise<Pool> {
if (this.autoMigrate) {
this.migrating ??= migrate(this.pool).catch((e) => {
// Clear the cached rejection so a subsequent call retries the migration.
this.migrating = undefined;
throw e;
});
await this.migrating;
}
return this.pool;
}
kv = {
get: async <T>(key: string): Promise<T | undefined> => {
const pool = await this.ready();
const k = this.key(key);
const res = await pool.query(
"SELECT value, expires_at FROM cpk_state_kv WHERE key = $1",
[k],
);
const row = res.rows[0];
if (!row) return undefined;
if (
row.expires_at !== null &&
new Date(row.expires_at).getTime() <= Date.now()
) {
// Lazily reap the expired row; treat as absent.
await pool.query("DELETE FROM cpk_state_kv WHERE key = $1", [k]);
return undefined;
}
return row.value as T;
},
set: async <T>(key: string, value: T, ttlMs?: number): Promise<void> => {
const pool = await this.ready();
const expiresAt = ttlMs
? new Date(Date.now() + ttlMs).toISOString()
: null;
await pool.query(
`INSERT INTO cpk_state_kv (key, value, expires_at)
VALUES ($1, $2::jsonb, $3)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, expires_at = EXCLUDED.expires_at`,
[this.key(key), JSON.stringify(value), expiresAt],
);
},
delete: async (key: string): Promise<void> => {
const pool = await this.ready();
await pool.query("DELETE FROM cpk_state_kv WHERE key = $1", [
this.key(key),
]);
},
};
list = {
append: async <T>(
key: string,
value: T,
opts?: { maxLen?: number; ttlMs?: number },
): Promise<number> => {
const pool = await this.ready();
const k = this.key(key);
const client = await pool.connect();
try {
await client.query("BEGIN");
// Resolve the effective expiry for this list key as a whole:
// - if opts.ttlMs is given, use now() + ttlMs;
// - otherwise inherit whatever expiry the existing rows carry
// (so a non-ttl append after a ttl'd append keeps that expiry).
let expiresAt: string | null;
if (opts?.ttlMs) {
expiresAt = new Date(Date.now() + opts.ttlMs).toISOString();
} else {
const cur = await client.query<{ exp: string | null }>(
"SELECT max(expires_at)::text AS exp FROM cpk_state_list WHERE key = $1",
[k],
);
expiresAt = cur.rows[0]?.exp ?? null;
}
await client.query(
"INSERT INTO cpk_state_list (key, value, expires_at) VALUES ($1, $2::jsonb, $3)",
[k, JSON.stringify(value), expiresAt],
);
if (opts?.maxLen) {
await client.query(
`DELETE FROM cpk_state_list
WHERE key = $1 AND seq NOT IN (
SELECT seq FROM cpk_state_list WHERE key = $1 ORDER BY seq DESC LIMIT $2
)`,
[k, opts.maxLen],
);
}
// Always synchronise all rows in the list to the resolved expiry so
// the whole list expires as a unit (matches MemoryStore / Redis semantics).
await client.query(
"UPDATE cpk_state_list SET expires_at = $2 WHERE key = $1",
[k, expiresAt],
);
const res = await client.query<{ count: string }>(
"SELECT count(*)::text AS count FROM cpk_state_list WHERE key = $1",
[k],
);
await client.query("COMMIT");
return Number(res.rows[0]!.count);
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
},
range: async <T>(key: string, start = 0, stop?: number): Promise<T[]> => {
const pool = await this.ready();
const k = this.key(key);
// Lazily reap expired rows for this key.
await pool.query(
"DELETE FROM cpk_state_list WHERE key = $1 AND expires_at IS NOT NULL AND expires_at <= now()",
[k],
);
const res = await pool.query<{ value: T }>(
"SELECT value FROM cpk_state_list WHERE key = $1 ORDER BY seq",
[k],
);
const all = res.rows.map((r) => r.value);
// Inclusive stop, oldest-first; mirrors MemoryStore/Redis semantics.
return all.slice(start, stop === undefined ? undefined : stop + 1);
},
trim: async (key: string, maxLen: number): Promise<void> => {
const pool = await this.ready();
const k = this.key(key);
await pool.query(
`DELETE FROM cpk_state_list
WHERE key = $1 AND seq NOT IN (
SELECT seq FROM cpk_state_list WHERE key = $1 ORDER BY seq DESC LIMIT $2
)`,
[k, maxLen],
);
},
delete: async (key: string): Promise<void> => {
const pool = await this.ready();
await pool.query("DELETE FROM cpk_state_list WHERE key = $1", [
this.key(key),
]);
},
};
lock = {
acquire: async (
key: string,
opts?: { ttlMs?: number },
): Promise<{ token: string } | null> => {
const pool = await this.ready();
const k = this.key(`lock:${key}`);
const token = randomUUID();
const expiresAt = new Date(
Date.now() + (opts?.ttlMs ?? DEFAULT_LOCK_TTL_MS),
).toISOString();
// Insert if free; if a row exists but has expired, take it over. The
// token lives in `value`; a fresh token fences out stale releases.
const res = await pool.query<{ value: string }>(
`INSERT INTO cpk_state_kv (key, value, expires_at)
VALUES ($1, to_jsonb($2::text), $3)
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value, expires_at = EXCLUDED.expires_at
WHERE cpk_state_kv.expires_at IS NOT NULL AND cpk_state_kv.expires_at <= now()
RETURNING value`,
[k, token, expiresAt],
);
// A returned row means we inserted or took over an expired lock.
return res.rows.length > 0 ? { token } : null;
},
release: async (key: string, token: string): Promise<void> => {
const pool = await this.ready();
const k = this.key(`lock:${key}`);
// Only the current owner (matching token) may release.
await pool.query(
"DELETE FROM cpk_state_kv WHERE key = $1 AND value = to_jsonb($2::text)",
[k, token],
);
},
};
dedup = {
seen: async (key: string, ttlMs: number): Promise<boolean> => {
const pool = await this.ready();
const k = this.key(`dedup:${key}`);
const expiresAt = new Date(Date.now() + ttlMs).toISOString();
// Insert if absent; if a row exists but expired, refresh it (treat as not
// seen). rowCount === 0 ⇒ a live row already existed ⇒ already seen.
const res = await pool.query(
`INSERT INTO cpk_state_kv (key, value, expires_at)
VALUES ($1, '1'::jsonb, $2)
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value, expires_at = EXCLUDED.expires_at
WHERE cpk_state_kv.expires_at IS NOT NULL AND cpk_state_kv.expires_at <= now()`,
[k, expiresAt],
);
return (res.rowCount ?? 0) === 0;
},
};
queue = {
enqueue: async <T>(
key: string,
value: T,
opts?: { maxSize?: number; onFull?: "drop-oldest" | "drop-newest" },
): Promise<number> => {
const pool = await this.ready();
const k = this.key(key);
const onFull = opts?.onFull ?? "drop-oldest";
const client = await pool.connect();
try {
await client.query("BEGIN");
if (opts?.maxSize) {
// Serialize concurrent enqueues for this key (FOR UPDATE can't be used
// with count()); the advisory lock is released at transaction end.
await client.query("SELECT pg_advisory_xact_lock(hashtext($1))", [k]);
const cnt = await client.query<{ count: string }>(
"SELECT count(*)::text AS count FROM cpk_state_queue WHERE key = $1",
[k],
);
const depth = Number(cnt.rows[0]!.count);
if (depth >= opts.maxSize) {
if (onFull === "drop-newest") {
await client.query("COMMIT");
return depth;
}
// drop-oldest: remove the head before inserting.
await client.query(
`DELETE FROM cpk_state_queue
WHERE seq = (SELECT seq FROM cpk_state_queue WHERE key = $1 ORDER BY seq LIMIT 1)`,
[k],
);
}
}
await client.query(
"INSERT INTO cpk_state_queue (key, value) VALUES ($1, $2::jsonb)",
[k, JSON.stringify(value)],
);
const res = await client.query<{ count: string }>(
"SELECT count(*)::text AS count FROM cpk_state_queue WHERE key = $1",
[k],
);
await client.query("COMMIT");
return Number(res.rows[0]!.count);
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
},
dequeue: async <T>(key: string): Promise<T | undefined> => {
const pool = await this.ready();
const res = await pool.query<{ value: T }>(
`DELETE FROM cpk_state_queue
WHERE seq = (
SELECT seq FROM cpk_state_queue WHERE key = $1
ORDER BY seq FOR UPDATE SKIP LOCKED LIMIT 1
)
RETURNING value`,
[this.key(key)],
);
const row = res.rows[0];
return row ? (row.value as T) : undefined;
},
depth: async (key: string): Promise<number> => {
const pool = await this.ready();
const res = await pool.query<{ count: string }>(
"SELECT count(*)::text AS count FROM cpk_state_queue WHERE key = $1",
[this.key(key)],
);
return Number(res.rows[0]!.count);
},
};
/**
* Best-effort sweep of expired kv/list rows. Called opportunistically;
* reads also reap lazily so this is purely a housekeeping aid.
*/
async sweepExpired(): Promise<void> {
const pool = await this.ready();
await pool.query(
"DELETE FROM cpk_state_kv WHERE expires_at IS NOT NULL AND expires_at <= now()",
);
await pool.query(
"DELETE FROM cpk_state_list WHERE expires_at IS NOT NULL AND expires_at <= now()",
);
}
/** Close the underlying pool. No-op for an injected pool or an already-ended pool. */
async end(): Promise<void> {
if (this.ownsPool && !this.pool.ended) {
await this.pool.end();
}
}
/** Alias for {@link end}. */
async close(): Promise<void> {
await this.end();
}
}
/** Create a {@link PostgresStore} from a connection string or an injected pool. */
export function createPostgresStore(
opts: CreatePostgresStoreOptions = {},
): PostgresStore {
return new PostgresStore(opts);
}
@@ -1,20 +0,0 @@
CREATE TABLE IF NOT EXISTS cpk_state_kv (
key text PRIMARY KEY,
value jsonb NOT NULL,
expires_at timestamptz
);
CREATE TABLE IF NOT EXISTS cpk_state_list (
key text NOT NULL,
seq bigserial PRIMARY KEY,
value jsonb NOT NULL,
expires_at timestamptz
);
CREATE INDEX IF NOT EXISTS cpk_state_list_key ON cpk_state_list(key, seq);
CREATE TABLE IF NOT EXISTS cpk_state_queue (
key text NOT NULL,
seq bigserial PRIMARY KEY,
value jsonb NOT NULL
);
CREATE INDEX IF NOT EXISTS cpk_state_queue_key ON cpk_state_queue(key, seq);
@@ -1,10 +0,0 @@
{
"extends": "@copilotkit/typescript-config/base.json",
"compilerOptions": {
"noEmit": true,
"lib": ["es2022", "dom"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"]
}
-10
View File
@@ -1,10 +0,0 @@
{
"extends": "@copilotkit/typescript-config/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["es2022"]
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules", "**/__tests__/**", "src/**/*.test.ts"]
}
@@ -1,7 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
},
});
-54
View File
@@ -1,54 +0,0 @@
{
"name": "@copilotkit/bot-store-redis",
"version": "0.0.2",
"description": "Redis StateStore backend for @copilotkit/bot.",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/CopilotKit/CopilotKit.git",
"directory": "packages/bot-store-redis"
},
"homepage": "https://github.com/CopilotKit/CopilotKit",
"keywords": [
"ai",
"agent",
"bot",
"redis",
"statestore",
"copilotkit",
"ag-ui"
],
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"check-types": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.check.json",
"test": "vitest run",
"test:watch": "vitest",
"publint": "publint .",
"attw": "attw --pack . --profile esm-only"
},
"dependencies": {
"@copilotkit/bot": "workspace:~",
"redis": "^4"
},
"devDependencies": {
"@copilotkit/typescript-config": "workspace:^",
"@types/node": "^22.10.0",
"typescript": "^5.6.3",
"vitest": "^4.1.3"
}
}
-1
View File
@@ -1 +0,0 @@
export { RedisStore, createRedisStore } from "./redis-store.js";
@@ -1,22 +0,0 @@
import { describe } from "vitest";
import { runStateStoreConformance } from "@copilotkit/bot";
import { createRedisStore } from "./redis-store.js";
const url = process.env.TEST_REDIS_URL;
// Each run uses a unique prefix so concurrent/repeat runs don't collide; the
// store is closed on teardown. Without TEST_REDIS_URL the suite skips so unit
// runs stay hermetic.
(url ? describe : describe.skip)("RedisStore", () => {
runStateStoreConformance(
"RedisStore",
async () =>
createRedisStore({
url,
keyPrefix: `t:${Math.random().toString(36).slice(2)}:`,
}),
async (s) => {
await (s as { quit?: () => Promise<void> }).quit?.();
},
);
});
-226
View File
@@ -1,226 +0,0 @@
import { randomUUID } from "node:crypto";
import { createClient } from "redis";
import type { RedisClientType } from "redis";
import type { StateStore } from "@copilotkit/bot";
/** Options for {@link createRedisStore}. */
export interface CreateRedisStoreOptions {
/** Connection URL, e.g. `redis://localhost:6379`. Ignored when `client` is supplied. */
url?: string;
/** Pre-configured node-redis client to use instead of creating one from `url`. */
client?: RedisClientType;
/** Prefix prepended to every key. Defaults to `cpk:`. */
keyPrefix?: string;
}
const DEFAULT_LOCK_TTL_MS = 30_000;
// Atomic compare-and-delete: only release the lock if the caller still owns the token.
const RELEASE_LOCK_LUA =
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
// Bounded enqueue honoring maxSize/onFull, evaluated atomically.
// KEYS[1] = list key, ARGV[1] = JSON value, ARGV[2] = maxSize (0 = unbounded), ARGV[3] = onFull
// Returns the resulting list length.
const ENQUEUE_LUA = `
local maxSize = tonumber(ARGV[2])
if maxSize > 0 then
local len = redis.call('llen', KEYS[1])
if len >= maxSize then
if ARGV[3] == 'drop-newest' then
return len
end
redis.call('lpop', KEYS[1])
end
end
return redis.call('rpush', KEYS[1], ARGV[1])
`;
/**
* Redis-backed {@link StateStore}. Durable across restarts and shareable across
* processes/instances. Backs the `kv`/`list`/`lock`/`dedup`/`queue` primitives
* on standard Redis commands; lock-release and bounded-enqueue use Lua for
* atomicity. All values are JSON-encoded.
*/
export class RedisStore implements StateStore {
private readonly client: RedisClientType;
private readonly prefix: string;
private readonly ownsClient: boolean;
private connecting?: Promise<void>;
constructor(opts: CreateRedisStoreOptions = {}) {
if (opts.client) {
this.client = opts.client;
this.ownsClient = false;
} else {
this.client = createClient(
opts.url ? { url: opts.url } : {},
) as RedisClientType;
this.ownsClient = true;
}
this.prefix = opts.keyPrefix ?? "cpk:";
}
private key(k: string): string {
return `${this.prefix}${k}`;
}
/** Lazily ensure the client is connected before the first command. */
private async ready(): Promise<RedisClientType> {
if (!this.client.isOpen) {
this.connecting ??= this.client.connect().then(() => undefined);
await this.connecting;
}
return this.client;
}
kv = {
get: async <T>(key: string): Promise<T | undefined> => {
const c = await this.ready();
const raw = await c.get(this.key(key));
if (raw === null) return undefined;
try {
return JSON.parse(raw) as T;
} catch (cause) {
throw new Error(
`bot-store-redis: failed to parse stored value for key "${key}"`,
{ cause },
);
}
},
set: async <T>(key: string, value: T, ttlMs?: number): Promise<void> => {
const c = await this.ready();
const payload = JSON.stringify(value);
if (ttlMs) await c.set(this.key(key), payload, { PX: ttlMs });
else await c.set(this.key(key), payload);
},
delete: async (key: string): Promise<void> => {
const c = await this.ready();
await c.del(this.key(key));
},
};
list = {
append: async <T>(
key: string,
value: T,
opts?: { maxLen?: number; ttlMs?: number },
): Promise<number> => {
const c = await this.ready();
const k = this.key(key);
let len = await c.rPush(k, JSON.stringify(value));
if (opts?.maxLen && len > opts.maxLen) {
await c.lTrim(k, -opts.maxLen, -1);
len = opts.maxLen;
}
if (opts?.ttlMs) await c.pExpire(k, opts.ttlMs);
return len;
},
range: async <T>(key: string, start = 0, stop?: number): Promise<T[]> => {
const c = await this.ready();
const raw = await c.lRange(this.key(key), start, stop ?? -1);
return raw.map((r) => {
try {
return JSON.parse(r) as T;
} catch (cause) {
throw new Error(
`bot-store-redis: failed to parse stored value for key "${key}"`,
{ cause },
);
}
});
},
trim: async (key: string, maxLen: number): Promise<void> => {
const c = await this.ready();
await c.lTrim(this.key(key), -maxLen, -1);
},
delete: async (key: string): Promise<void> => {
const c = await this.ready();
await c.del(this.key(key));
},
};
lock = {
acquire: async (
key: string,
opts?: { ttlMs?: number },
): Promise<{ token: string } | null> => {
const c = await this.ready();
const token = randomUUID();
const reply = await c.set(this.key(`lock:${key}`), token, {
NX: true,
PX: opts?.ttlMs ?? DEFAULT_LOCK_TTL_MS,
});
return reply === "OK" ? { token } : null;
},
release: async (key: string, token: string): Promise<void> => {
const c = await this.ready();
await c.eval(RELEASE_LOCK_LUA, {
keys: [this.key(`lock:${key}`)],
arguments: [token],
});
},
};
dedup = {
seen: async (key: string, ttlMs: number): Promise<boolean> => {
const c = await this.ready();
const reply = await c.set(this.key(`dedup:${key}`), "1", {
NX: true,
PX: ttlMs,
});
// SET NX returns null when the key already existed → already seen.
return reply === null;
},
};
queue = {
enqueue: async <T>(
key: string,
value: T,
opts?: { maxSize?: number; onFull?: "drop-oldest" | "drop-newest" },
): Promise<number> => {
const c = await this.ready();
const result = await c.eval(ENQUEUE_LUA, {
keys: [this.key(key)],
arguments: [
JSON.stringify(value),
String(opts?.maxSize ?? 0),
opts?.onFull ?? "drop-oldest",
],
});
return Number(result);
},
dequeue: async <T>(key: string): Promise<T | undefined> => {
const c = await this.ready();
const raw = await c.lPop(this.key(key));
if (raw === null) return undefined;
try {
return JSON.parse(raw) as T;
} catch (cause) {
throw new Error(
`bot-store-redis: failed to parse stored value for key "${key}"`,
{ cause },
);
}
},
depth: async (key: string): Promise<number> => {
const c = await this.ready();
return c.lLen(this.key(key));
},
};
/** Close the underlying connection. No-op for an injected client. */
async quit(): Promise<void> {
if (this.ownsClient && this.client.isOpen) {
await this.client.quit();
}
}
}
/** Create a {@link RedisStore} from a URL or an injected node-redis client. */
export function createRedisStore(
opts: CreateRedisStoreOptions = {},
): RedisStore {
return new RedisStore(opts);
}
@@ -1,10 +0,0 @@
{
"extends": "@copilotkit/typescript-config/base.json",
"compilerOptions": {
"noEmit": true,
"lib": ["es2022", "dom"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"]
}
-10
View File
@@ -1,10 +0,0 @@
{
"extends": "@copilotkit/typescript-config/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["es2022"]
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules", "**/__tests__/**", "src/**/*.test.ts"]
}
@@ -1,7 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
},
});
@@ -80,6 +80,31 @@ describe("renderAdaptiveCard", () => {
]);
});
it("renders a url <Button> as an Action.OpenUrl", () => {
const card = renderAdaptiveCard([
el("actions", [
el("button", [text("Open")], { url: "https://dash/deploy/42" }),
]),
]);
expect(card.actions).toEqual([
{ type: "Action.OpenUrl", title: "Open", url: "https://dash/deploy/42" },
]);
});
it("marks a multi <Select> ChoiceSet as isMultiSelect", () => {
const card = renderAdaptiveCard([
el("select", [], {
multi: true,
onSelect: { id: "ck:pick" },
options: [{ label: "One", value: "1" }],
}),
]);
expect(card.body[0]).toMatchObject({
type: "Input.ChoiceSet",
isMultiSelect: true,
});
});
it("renders <Select>/<Input> as body inputs", () => {
const card = renderAdaptiveCard([
el("select", [], {
@@ -169,6 +169,14 @@ function factSet(fieldNodes: BotNode[]): CardElement {
function renderButton(node: BotNode): CardAction {
const props = node.props ?? {};
// Link button → Action.OpenUrl (opens the URL; carries no submit data).
if (typeof props.url === "string" && props.url.length > 0) {
return {
type: "Action.OpenUrl",
title: truncateText(collectText(node), TEAMS_LIMITS.buttonText),
url: props.url,
};
}
const action: CardAction = {
type: "Action.Submit",
title: truncateText(collectText(node), TEAMS_LIMITS.buttonText),
@@ -201,6 +209,8 @@ function renderSelect(node: BotNode): CardElement {
value: String(o.value),
})),
};
// Multi-select: Teams submits the chosen values as a comma-joined string.
if (props.multi) el.isMultiSelect = true;
if (props.placeholder) el.placeholder = String(props.placeholder);
return el;
}
+3
View File
@@ -135,6 +135,9 @@ export function decodeReaction(update: unknown): IncomingReaction[] {
conversationKey,
replyTarget,
messageId: String(mr.message_id),
// Update-capable ref (chatId + numeric messageId) so an onReaction handler
// can edit the reacted message in place via thread.update.
messageRef: { id: String(mr.message_id), chatId, messageId: mr.message_id },
raw: update,
};
const out: IncomingReaction[] = [];
+41 -15
View File
@@ -91,21 +91,21 @@ compile-time errors — `<Section bogus={1} />` or `<Button style="nope">` won't
type-check. There are no lowercase intrinsic tags; the vocabulary is the
capitalized component set below.
| Component | Purpose |
| ---------- | ----------------------------------------------------------------- | ---------- |
| `Message` | Root container for a single posted message. |
| `Header` | Bold header / title row. |
| `Section` | A block of (markdown) body text. |
| `Markdown` | Explicit markdown text block. |
| `Field` | One label/value cell inside `Fields`. |
| `Fields` | A grid of `Field`s (two-column key/value layout). |
| `Context` | Small, muted secondary text (footnotes, metadata). |
| `Actions` | Row container for interactive controls. |
| `Button` | Clickable button — `onClick`, `value`, `style: "primary" | "danger"`. |
| `Select` | Dropdown — `onSelect`, `placeholder`, `options: {label,value}[]`. |
| `Input` | Text input — `onSubmit`, `placeholder`, `multiline`, `name`. |
| `Image` | An image block. |
| `Divider` | A horizontal rule. |
| Component | Purpose |
| ---------- | -------------------------------------------------------------------------- |
| `Message` | Root container for a single posted message`accent`, `onReaction`. |
| `Header` | Bold header / title row. |
| `Section` | A block of (markdown) body text. |
| `Markdown` | Explicit markdown text block. |
| `Field` | One label/value cell inside `Fields` — optional `label`. |
| `Fields` | A grid of `Field`s (two-column key/value layout). |
| `Context` | Small, muted secondary text (footnotes, metadata). |
| `Actions` | Row container for interactive controls. |
| `Button` | Clickable button — `onClick`, `value`, `style`, or `url` (link button). |
| `Select` | Dropdown — `onSelect`, `placeholder`, `options: {label,value}[]`, `multi`. |
| `Input` | Text input — `onSubmit`, `placeholder`, `multiline`, `name`. |
| `Image` | An image block. |
| `Divider` | A horizontal rule. |
### Behavior props
@@ -115,6 +115,32 @@ Interactive components carry handler props typed as `ClickHandler`:
- `Select``onSelect`
- `Input``onSubmit`
`Message` also takes `onReaction`, fired when a user reacts to the posted
message (adds or removes). The first arg is the emoji; the second carries
`added`/`user`/`rawEmoji` plus a `thread` and the reacted message's
`messageRef` — the same surface an `onClick` gets, so a reaction can post new
UI, swap the message in place, or run a HITL flow:
```tsx
<Message
onReaction={async (emoji, r) => {
if (!r.added) return;
if (emoji === "bug") await r.thread.post(<FileBug />); // post new UI
if (emoji === "white_check_mark")
await r.thread.update(r.messageRef, <Resolved />); // swap UI in place
}}
>
</Message>
```
It's durable on the same terms as a component `onClick`: when the `<Message>`
comes from a component registered via `createBot({ components: [...] })` and a
durable `store` is configured, a reaction after a restart re-renders the
component to re-derive the handler. Inline handlers (and `<Message>` used
directly) route in-process but don't survive a restart. For durable, filtered
reaction routing across _all_ messages, use `bot.onReaction(...)`.
A `ClickHandler` receives an `InteractionContext`, both generic over the
clicked control's value type:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@copilotkit/bot-ui",
"version": "0.0.3",
"version": "0.1.0",
"description": "JSX runtime, IR, and cross-platform component vocabulary for CopilotKit bots.",
"license": "MIT",
"repository": {
+40 -5
View File
@@ -1,5 +1,5 @@
import type { BotNode } from "./ir.js";
import type { ClickHandler } from "./types.js";
import type { ClickHandler, MessageReactionHandler } from "./types.js";
/**
* Anything that can appear as a child in the component tree: nested elements,
@@ -27,12 +27,30 @@ interface WithChildren {
export interface MessageProps extends WithChildren {
/** Accent color (hex, e.g. `#27AE60`) for the message's colored rail. */
accent?: string;
/**
* Called when a user reacts to this message (add or remove). The first arg is
* the emoji, e.g. `onReaction={(r) => r === "bug" ? triage() : ack()}`; the
* second carries `added`/`user`/`rawEmoji` plus a `thread` and the reacted
* message's `messageRef` the same surface an `onClick` gets, so the handler
* can `thread.post(...)`, `thread.update(messageRef, ...)`, or run a HITL flow.
* Durable on the same terms as a component `onClick`: survives a restart when
* the `<Message>` comes from a registered component and a durable store is
* configured; inline handlers route in-process only.
*/
onReaction?: MessageReactionHandler;
}
export interface HeaderProps extends WithChildren {}
export interface SectionProps extends WithChildren {}
export interface MarkdownProps extends WithChildren {}
export interface FieldsProps extends WithChildren {}
export interface FieldProps extends WithChildren {}
export interface FieldProps extends WithChildren {
/**
* Bold label rendered before the value (e.g. `<Field label="Status">Online</Field>`).
* Rendered on Discord, Slack, and Telegram; surfaces without a field label
* concept fall back to the value text alone.
*/
label?: string;
}
export interface ContextProps extends WithChildren {}
export interface ActionsProps extends WithChildren {}
@@ -50,11 +68,17 @@ export interface ButtonProps<TValue = unknown> extends WithChildren {
/**
* Inline handler run when the button is clicked (bound by the action
* registry). Its `ctx.action.value` is typed as `TValue`, inferred from
* `value`.
* `value`. Ignored when `url` is set (a link button doesn't dispatch).
*/
onClick?: ClickHandler<TValue>;
/** Value echoed back to `onClick`/`awaitChoice` on click; drives `TValue`. */
value?: TValue;
/**
* Makes this a link button that opens `url` instead of dispatching a handler.
* Rendered natively on Slack, Discord, Teams, and Telegram; surfaces without
* link buttons skip it. When set, `onClick`/`value` are ignored.
*/
url?: string;
/** Slack button accent. */
style?: "primary" | "danger";
}
@@ -64,10 +88,21 @@ export interface SelectOption {
value: string;
}
export interface SelectProps {
/** Handler run on selection; `ctx.action.value` is the chosen option's `value`. */
onSelect?: ClickHandler<string>;
/**
* Handler run on selection. `ctx.action.value` is the chosen option's `value`
* (a `string`), or a `string[]` of chosen values when `multi` is set.
*/
onSelect?: ClickHandler<string | string[]>;
placeholder?: string;
options: SelectOption[];
/**
* Allow selecting multiple options. Rendered natively on Slack
* (`multi_static_select`), Discord (max-values), and Teams
* (`isMultiSelect`); surfaces that can only express a single choice
* (Telegram, WhatsApp) degrade to single-select. When set, `onSelect`
* receives a `string[]`.
*/
multi?: boolean;
}
export interface InputProps {
+41 -4
View File
@@ -1,5 +1,6 @@
import type { EmojiValue } from "./emoji.js";
import type { ModalView } from "./modal.js";
import type { Renderable } from "./ir.js";
export interface MessageRef {
id: string;
@@ -74,15 +75,15 @@ export interface ThreadMessage {
}
export interface Thread {
readonly platform: string;
post(ui: unknown): Promise<MessageRef>;
update(ref: MessageRef, ui: unknown): Promise<MessageRef>;
post(ui: Renderable): Promise<MessageRef>;
update(ref: MessageRef, ui: Renderable): Promise<MessageRef>;
delete(ref: MessageRef): Promise<void>;
/**
* Post a picker and block until an interaction resolves it to the clicked
* button's `value`. Pass the expected value type, e.g.
* `awaitChoice<{ confirmed: boolean }>(<Picker/>)`.
*/
awaitChoice<T = unknown>(ui: unknown): Promise<T>;
awaitChoice<T = unknown>(ui: Renderable): Promise<T>;
runAgent(input?: unknown): Promise<MessageRef | undefined>;
resume(value: unknown): Promise<MessageRef | undefined>;
stream(src: string | AsyncIterable<string>): Promise<MessageRef>;
@@ -120,7 +121,7 @@ export interface Thread {
*/
postEphemeral(
user: PlatformUser | string,
ui: unknown,
ui: Renderable,
opts: { fallbackToDM: boolean },
): Promise<EphemeralResult | null>;
/** Record this conversation as subscribed (persisted in state). Proactive delivery to subscribed conversations is not yet wired. */
@@ -153,3 +154,39 @@ export interface InteractionContext<TValue = unknown> {
export type ClickHandler<TValue = unknown> = (
ctx: InteractionContext<TValue>,
) => void | Promise<void>;
/** The reaction passed to a `<Message onReaction>` handler. */
export interface MessageReaction {
/** Normalized emoji name when recognized, else the raw platform token. */
emoji: EmojiValue;
/** Platform-native emoji token. */
rawEmoji: string;
/** `true` = added, `false` = removed. */
added: boolean;
/** The reacting user, when the platform reports one. */
user?: PlatformUser;
/** Id of the reacted-to message. */
messageId: string;
/**
* The conversation thread same surface an `onClick` gets via `ctx.thread`.
* Post new UI (`thread.post`), run the agent (`thread.runAgent`), block on a
* human choice (`thread.awaitChoice`, HITL), react back, etc.
*/
thread: Thread;
/**
* Ref to the reacted-to message, for swapping its UI in place:
* `thread.update(reaction.messageRef, <NewUi/>)`.
*/
messageRef: MessageRef;
}
/**
* Handler for reactions on a posted message, set via `<Message onReaction>`.
* Fires for both adds and removes (check `reaction.added`); the first arg is
* the emoji for the common `(reaction) => reaction === "bug"` shape. The second
* carries the full reaction including `thread`/`messageRef`, so a handler can
* post, swap UI, or run a HITL flow exactly like an `onClick`.
*/
export type MessageReactionHandler = (
emoji: EmojiValue,
reaction: MessageReaction,
) => void | Promise<void>;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@copilotkit/bot",
"version": "0.0.3",
"version": "0.1.0",
"description": "Platform-agnostic JSX bot engine for CopilotKit (createBot, Thread, PlatformAdapter, ActionStore).",
"license": "MIT",
"repository": {
+110 -12
View File
@@ -4,6 +4,7 @@ import type {
InteractionContext,
ComponentFn,
Renderable,
MessageReactionHandler,
} from "@copilotkit/bot-ui";
import { isBound, getBoundArgs, renderToIR } from "@copilotkit/bot-ui";
import { mintId } from "./mint-id.js";
@@ -36,11 +37,66 @@ export class ActionRegistry {
// payload can't carry it (e.g. Telegram's 64-byte callback_data only holds
// the action id), where `evt.value` arrives undefined.
private hot = new Map<string, { handler: ClickHandler; value: unknown }>();
// Same-process fast path for `<Message onReaction>` handlers, keyed by the
// posted message's id. Mirrors the `hot` action cache; the durable snapshot
// (below) is the cross-restart counterpart, exactly like onClick.
private messageReactions = new Map<string, MessageReactionHandler>();
constructor(opts: { store: ActionStore }) {
this.store = opts.store;
}
/** Cache a `<Message onReaction>` handler for the posted message (same-process). */
registerMessageReaction(
messageId: string,
handler: MessageReactionHandler,
): void {
this.messageReactions.set(messageId, handler);
}
/**
* Persist the message's reaction handler as a `{ component, props }` snapshot
* keyed by `messageId`, so a reaction after a restart re-renders the component
* and re-derives the handler durable exactly like a registered-component
* `onClick` (and degrading the same way for inline/anonymous components).
*/
async persistMessageReaction(
messageId: string,
snap: {
component: string;
props: Record<string, unknown>;
conversationKey: string;
},
): Promise<void> {
await this.store.put(reactionKey(messageId), {
component: snap.component,
props: snap.props,
path: [],
conversationKey: snap.conversationKey,
});
}
/**
* Resolve the `onReaction` handler for `messageId`: the hot cache first, then
* the durable snapshot (re-rendering the named component and re-plucking the
* root's handler). Returns `undefined` when neither resolves e.g. an inline
* handler whose closure can't be re-derived after a restart.
*/
async resolveMessageReaction(
messageId: string,
): Promise<MessageReactionHandler | undefined> {
const hot = this.messageReactions.get(messageId);
if (hot) return hot;
const snap = await this.store.get(reactionKey(messageId));
if (!snap?.component) return undefined;
const fn = this.components.get(snap.component);
if (!fn) return undefined;
const root = renderToIR(
fn(snap.props as Record<string, unknown>) as Renderable,
);
return takeMessageReaction(root);
}
registerComponent(name: string, fn: ComponentFn): void {
this.components.set(name, fn);
}
@@ -66,24 +122,42 @@ export class ActionRegistry {
// (`{ type: fn, props }`), it is registered + bound by name (cold-path
// re-render supported). Otherwise the IR is bound inline with `component:""`,
// meaning a cold-cache dispatch throws ActionExpiredError (intended
// degradation for inline handlers that can't be re-derived).
// degradation for inline handlers that can't be re-derived). A top-level
// `<Message onReaction>` handler is pulled off the IR (so it never reaches the
// adapter) and returned for the caller to associate with the posted message.
async bindRenderable(
ui: Renderable,
conversationKey: string,
): Promise<BotNode[]> {
): Promise<{
root: BotNode[];
onReaction?: MessageReactionHandler;
/**
* The component + props to persist for durable reaction routing, present
* only when `ui` was a component element with an `onReaction` (an inline IR
* tree has no component to re-render, so its handler stays in-memory).
*/
reactionComponent?: { component: string; props: Record<string, unknown> };
}> {
let root: BotNode[];
let component: string | undefined;
let props: Record<string, unknown> | undefined;
if (isComponentElement(ui)) {
const fn = ui.type;
const name = fn.name || "anonymous";
this.registerComponent(name, fn);
return this.bindTree(
name,
(ui.props ?? {}) as Record<string, unknown>,
conversationKey,
);
component = fn.name || "anonymous";
props = (ui.props ?? {}) as Record<string, unknown>;
this.registerComponent(component, fn);
root = await this.bindTree(component, props, conversationKey);
} else {
root = renderToIR(ui);
await this.walk(root, [], "", undefined, conversationKey);
}
const root = renderToIR(ui);
await this.walk(root, [], "", undefined, conversationKey);
return root;
const onReaction = takeMessageReaction(root);
return {
root,
onReaction,
reactionComponent:
onReaction && component && props ? { component, props } : undefined,
};
}
private async walk(
@@ -158,6 +232,30 @@ export class ActionRegistry {
}
}
/** Store key for a message's durable reaction snapshot (distinct from minted action ids). */
function reactionKey(messageId: string): string {
return `reaction:${messageId}`;
}
/**
* Pull a top-level `<Message onReaction>` handler off the IR, deleting the prop
* so it never reaches the adapter (a function can't be serialized to a native
* payload). Returns the handler when the single root node is a `message`.
*/
function takeMessageReaction(
root: BotNode[],
): MessageReactionHandler | undefined {
const node = root.length === 1 ? root[0] : undefined;
if (!node || node.type !== "message" || !("onReaction" in node.props)) {
return undefined;
}
const handler = node.props.onReaction;
delete node.props.onReaction;
return typeof handler === "function"
? (handler as MessageReactionHandler)
: undefined;
}
/** Navigate to the node owning the event-prop at `path` and read its `value`. */
function pluckValue(tree: BotNode[], path: (string | number)[]): unknown {
let cur: unknown = tree;
+89 -7
View File
@@ -30,6 +30,7 @@ import type {
EmojiPlatform,
ModalView,
ComponentFn,
MessageRef,
} from "@copilotkit/bot-ui";
import {
normalizeEmoji,
@@ -39,6 +40,22 @@ import {
import { Transcripts } from "./transcripts.js";
import type { Identity, TranscriptsConfig } from "./transcripts.js";
import type { StandardSchemaV1, InferSchemaOutput } from "./standard-schema.js";
import { BotTelemetry } from "./telemetry/bot-telemetry.js";
import { errorClass, normalizePlatform } from "./telemetry/sanitize-error.js";
import { createRequire } from "node:module";
const pkg = createRequire(import.meta.url)("../package.json") as {
name: string;
version: string;
};
function storeKind(s: StateStore): "memory" | "postgres" | "redis" | "custom" {
const n = s.constructor?.name;
if (n === "MemoryStore") return "memory";
if (n === "PostgresStore") return "postgres";
if (n === "RedisStore") return "redis";
return "custom";
}
/** Platforms whose tokens the emoji table can normalize. */
const EMOJI_PLATFORMS: ReadonlySet<EmojiPlatform> = new Set([
@@ -83,6 +100,8 @@ export interface ReactionEvent {
/** The reacting user, when the platform reports one. */
user?: PlatformUser;
messageId: string;
/** Update-capable ref to the reacted message (`thread.update(messageRef, ui)`). */
messageRef: MessageRef;
threadId?: string;
thread: Thread;
adapter: PlatformAdapter;
@@ -308,14 +327,15 @@ export function createBot<
assertExclusive(adapters);
let started = false;
// Backend, transcripts, the action registry, and component registration are
// resolved in `start()` — not at construction — so an adapter added via
// `addAdapter` after `createBot` can still supply the persistence backend
// (see `resolveBackend`). Nothing reads these before the first event, which
// can only arrive after `start()`.
// Backend, transcripts, telemetry, the action registry, and component
// registration are resolved in `start()` — not at construction — so an
// adapter added via `addAdapter` after `createBot` can still supply the
// persistence backend (see `resolveBackend`). Nothing reads these before the
// first event, which can only arrive after `start()`.
let backend: StateStore | undefined;
let transcripts: Transcripts | undefined;
let registry: ActionRegistry | undefined;
let telemetry: BotTelemetry | undefined;
const agentFactory: (threadId: string) => AbstractAgent = (() => {
const a = opts.agent;
@@ -364,7 +384,7 @@ export function createBot<
conversationKey: string,
extras?: { userKey?: string; message?: IncomingMessage },
): Thread {
if (!backend || !registry) {
if (!backend || !registry || !telemetry) {
throw new Error(
"bot not started: call bot.start() before handling events",
);
@@ -385,6 +405,7 @@ export function createBot<
transcripts,
userKey: extras?.userKey,
message: extras?.message,
telemetry,
};
return new Thread(deps);
}
@@ -620,12 +641,15 @@ export function createBot<
evt.replyTarget,
evt.conversationKey,
);
// Prefer the adapter's update-capable ref; fall back to the bare id.
const messageRef: MessageRef = evt.messageRef ?? { id: evt.messageId };
const reactionEvt: ReactionEvent = {
emoji: value,
rawEmoji: evt.rawEmoji,
added: evt.added,
user: evt.user,
messageId: evt.messageId,
messageRef,
threadId: evt.threadId,
thread,
adapter,
@@ -635,6 +659,22 @@ export function createBot<
if (!reg.emojis || reg.emojis.has(value))
await reg.handler(reactionEvt);
}
// Per-message handler set via `<Message onReaction>` on the posted
// message — hot cache, falling back to the durable snapshot after a restart.
const perMessage = await registry!.resolveMessageReaction(
evt.messageId,
);
if (perMessage) {
await perMessage(value, {
emoji: value,
rawEmoji: evt.rawEmoji,
added: evt.added,
user: evt.user,
messageId: evt.messageId,
thread,
messageRef,
});
}
},
async onModalSubmit(evt: IncomingModalSubmit) {
const handler = modalSubmitHandlers.get(evt.callbackId);
@@ -764,6 +804,12 @@ export function createBot<
// registry, and register components against it.
backend = resolveBackend(cfg.adapter, adapters);
transcripts = new Transcripts(backend, cfg.transcripts ?? {});
const tel = new BotTelemetry({
backend,
packageName: pkg.name,
packageVersion: pkg.version,
});
telemetry = tel;
const registryInstance = new ActionRegistry({
store: opts.actionStore ?? kvActionStore(backend),
});
@@ -778,6 +824,18 @@ export function createBot<
registryInstance.registerComponent(c.name, c as unknown as ComponentFn);
}
toolDescriptors = toAgentToolDescriptors([...toolMap.values()]);
tel.capture("oss.bot.configured", {
platforms: adapters.map((a) => normalizePlatform(a.platform)),
adapterCount: adapters.length,
store: storeKind(backend),
hasComponents: (opts.components?.length ?? 0) > 0,
componentsCount: opts.components?.length ?? 0,
toolsCount: toolMap.size,
commandsCount: commandHandlers.size,
contextCount: context.length,
transcripts: !!cfg.transcripts,
identity: !!cfg.identity,
});
// Isolate per-adapter startup failures: one adapter rejecting (e.g.
// Telegram's setMyCommands rejecting a hyphenated command name, a revoked
// token, a port already in use) must NOT crash the bot or prevent the
@@ -785,14 +843,38 @@ export function createBot<
const startResults = await Promise.allSettled(
adapters.map((a) => a.start(makeSink(a))),
);
const startedPlatforms: string[] = [];
const failedPlatforms: string[] = [];
startResults.forEach((r, i) => {
const rawPlatform = adapters[i]!.platform;
// Raw label for the human-facing log; normalized label for telemetry.
const platform = normalizePlatform(rawPlatform);
if (r.status === "rejected") {
failedPlatforms.push(platform);
console.error(
`[bot] adapter "${adapters[i]!.platform}" failed to start:`,
`[bot] adapter "${rawPlatform}" failed to start:`,
r.reason,
);
tel.capture("oss.bot.start_failed", {
platform,
errorClass: errorClass(r.reason),
});
} else {
startedPlatforms.push(platform);
}
});
if (startedPlatforms.length > 0) {
tel.capture("oss.bot.started", {
platforms: startedPlatforms,
startedCount: startedPlatforms.length,
failedCount: failedPlatforms.length,
hasMentionHandler: mentionHandlers.length > 0,
hasMessageHandler: messageHandlers.length > 0,
interruptHandlers: interruptHandlers.size,
commandsCount: commandHandlers.size,
toolsCount: toolMap.size,
});
}
// Hand declared commands to adapters that register them up front (e.g.
// Discord); adapters without `registerCommands` are skipped. Per-adapter
// failures are isolated the same way as start().
+7
View File
@@ -141,6 +141,13 @@ export interface IncomingReaction {
replyTarget: ReplyTarget;
/** Id of the reacted-to message. */
messageId: string;
/**
* Update-capable ref to the reacted message (the platform-specific shape the
* adapter's `update`/`delete` accept). Lets a `<Message onReaction>` handler
* swap the message's UI in place. Adapters that can edit messages should set
* this; the engine falls back to `{ id: messageId }` when omitted.
*/
messageRef?: MessageRef;
/** Containing thread/conversation id, when distinct from the message. */
threadId?: string;
/** Native payload. */
+122 -1
View File
@@ -1,8 +1,9 @@
// packages/bot/src/reactions.test.ts
import { describe, it, expect } from "vitest";
import { emoji } from "@copilotkit/bot-ui";
import { emoji, Message } from "@copilotkit/bot-ui";
import { createBot } from "./create-bot.js";
import { FakeAdapter } from "./testing/fake-adapter.js";
import { MemoryStore } from "./state/memory-store.js";
const tick = () => new Promise((r) => setTimeout(r, 0));
@@ -79,6 +80,126 @@ describe("bot.onReaction", () => {
expect(hits).toEqual(["thumbs_up"]);
});
it("routes a reaction on a posted message to its <Message onReaction>", async () => {
const fake = new FakeAdapter();
const bot = createBot({ adapters: [fake] });
const seen: { emoji: string; added: boolean }[] = [];
bot.onMessage(async ({ thread }) => {
await thread.post(
Message({
onReaction: (e, r) => {
seen.push({ emoji: e, added: r.added });
},
children: "hi",
}),
);
});
await bot.start();
fake.emitTurn({});
await tick();
// The handler is a closure, never serialized into the native payload.
expect(fake.posted[0]?.[0]?.props.onReaction).toBeUndefined();
// First post → "msg-1" (FakeAdapter counter).
fake.emitReaction({ rawEmoji: "🎉", added: true, messageId: "msg-1" });
fake.emitReaction({ rawEmoji: "🎉", added: false, messageId: "msg-1" });
await tick();
expect(seen).toEqual([
{ emoji: "🎉", added: true },
{ emoji: "🎉", added: false },
]);
});
it("re-derives a registered component's onReaction from the store after a restart", async () => {
const backend = new MemoryStore(); // shared store survives the simulated restart
const seen: string[] = [];
// A named component so it can be re-registered + re-rendered after restart.
const Card = () =>
Message({
onReaction: (e) => {
seen.push(e);
},
children: "deploy done",
});
// Bot 1 posts the component message, persisting a reaction snapshot.
const fake1 = new FakeAdapter();
const bot1 = createBot({
adapters: [fake1],
store: { adapter: backend },
components: [Card],
});
bot1.onMessage(async ({ thread }) => {
// A component element ({ type: fn }) — the path that persists, unlike a
// pre-rendered Message() node.
await thread.post({ type: Card, props: {} });
});
await bot1.start();
fake1.emitTurn({});
await tick();
// "Restart": a fresh bot + registry sharing the same store, Card re-registered.
// Its reaction hot cache is empty, so it must resolve via the durable snapshot.
const fake2 = new FakeAdapter();
const bot2 = createBot({
adapters: [fake2],
store: { adapter: backend },
components: [Card],
});
await bot2.start();
fake2.emitReaction({ rawEmoji: "🎉", added: true, messageId: "msg-1" });
await tick();
expect(seen).toEqual(["🎉"]);
});
it("gives the handler a thread to post new UI and the reacted message's ref", async () => {
const fake = new FakeAdapter();
const bot = createBot({ adapters: [fake] });
let seenRefId: string | undefined;
bot.onMessage(async ({ thread }) => {
await thread.post(
Message({
onReaction: async (_e, r) => {
seenRefId = r.messageRef.id;
await r.thread.post("thanks for the reaction"); // post new UI like onClick can
},
children: "hi",
}),
);
});
await bot.start();
fake.emitTurn({});
await tick();
const before = fake.posted.length;
fake.emitReaction({ rawEmoji: "🎉", added: true, messageId: "msg-1" });
await tick();
// The handler posted a second message via its thread.
expect(fake.posted.length).toBe(before + 1);
// …and received an update-capable ref to the reacted message (fallback id here).
expect(seenRefId).toBe("msg-1");
});
it("does not fire a message handler for a reaction on a different message", async () => {
const fake = new FakeAdapter();
const bot = createBot({ adapters: [fake] });
let fired = false;
bot.onMessage(async ({ thread }) => {
await thread.post(
Message({
onReaction: () => {
fired = true;
},
children: "hi",
}),
);
});
await bot.start();
fake.emitTurn({});
await tick();
fake.emitReaction({ rawEmoji: "🎉", added: true, messageId: "other" });
await tick();
expect(fired).toBe(false);
});
it("normalizes a raw-token filter (unicode / slack alias) to canonical", async () => {
// Caller registers a raw unicode token; ingress normalizes the inbound
// Slack alias to the canonical "thumbs_up", so the filter must too.
+53
View File
@@ -90,4 +90,57 @@ describe("runAgentLoop", () => {
expect(agent.runAgentCalls).toBe(1);
expect(agent.messages.some((m) => m.role === "tool")).toBe(false);
});
it("returns interrupted=true and an iteration count when the agent interrupts", async () => {
const renderer = makeFakeRunRenderer();
const tools = new Map<string, BotTool>();
const handleInterrupt = vi.fn<(i: CapturedInterrupt) => void>();
const agent = new FakeAgent([
(sub: AgentSubscriber) => {
sub.onCustomEvent?.({
event: { name: "on_interrupt", value: { q: 1 } },
} as never);
sub.onRunFinishedEvent?.({ event: {} } as never);
},
]);
const args = {
agent,
renderer,
tools,
toolDescriptors,
context,
makeToolCtx: () => ({ thread: {} as never, platform: "fake" }),
handleInterrupt,
};
const result = await runAgentLoop(args);
expect(result.interrupted).toBe(true);
expect(result.iterations).toBeGreaterThanOrEqual(1);
});
it("returns interrupted=false on a normal completion", async () => {
const renderer = makeFakeRunRenderer();
const tools = new Map<string, BotTool>();
const agent = new FakeAgent([
(sub: AgentSubscriber) => {
sub.onRunFinishedEvent?.({ event: {} } as never);
},
]);
const args = {
agent,
renderer,
tools,
toolDescriptors,
context,
makeToolCtx: () => ({ thread: {} as never, platform: "fake" }),
};
const result = await runAgentLoop(args);
expect(result.interrupted).toBe(false);
expect(result.iterations).toBeGreaterThanOrEqual(1);
});
});
+8 -4
View File
@@ -37,7 +37,9 @@ export interface RunLoopArgs {
* and returns immediately ("ack-first") `thread.resume` re-enters later
* with `initialResume` set.
*/
export async function runAgentLoop(args: RunLoopArgs): Promise<void> {
export async function runAgentLoop(
args: RunLoopArgs,
): Promise<{ iterations: number; interrupted: boolean }> {
const {
agent,
renderer,
@@ -66,19 +68,20 @@ export async function runAgentLoop(args: RunLoopArgs): Promise<void> {
renderer.subscriber,
);
}
if (isAborted?.()) return;
if (isAborted?.()) return { iterations: i + 1, interrupted: false };
const pending = renderer.getPendingInterrupt();
if (pending) {
renderer.clearPendingInterrupt();
if (handleInterrupt) await handleInterrupt(pending);
return; // ack-first: picker posted; thread.resume re-enters later
// ack-first: picker posted; thread.resume re-enters later
return { iterations: i + 1, interrupted: true };
}
const calls = renderer
.getCapturedToolCalls()
.filter((c) => tools.has(c.toolCallName) && !executed.has(c.toolCallId));
if (calls.length === 0) return;
if (calls.length === 0) return { iterations: i + 1, interrupted: false };
ensureAssistantToolCallMessage(agent, calls);
for (const call of calls) {
@@ -105,6 +108,7 @@ export async function runAgentLoop(args: RunLoopArgs): Promise<void> {
executed.add(call.toolCallId);
}
}
return { iterations: maxIterations, interrupted: false };
}
/**
@@ -0,0 +1,58 @@
import { describe, it, expect, vi } from "vitest";
import { BotTelemetry, BOT_TELEMETRY_EVENTS } from "./bot-telemetry.js";
import { MemoryStore } from "../state/memory-store.js";
const tick = () => new Promise((r) => setTimeout(r, 0));
const base = {
backend: new MemoryStore(),
packageName: "@copilotkit/bot",
packageVersion: "0.0.3",
};
describe("BotTelemetry", () => {
it("sends event with anonymous_id + bot_session_id in global_properties", async () => {
const send = vi.fn().mockResolvedValue(undefined);
const t = new BotTelemetry({
...base,
disabled: false,
send,
sessionId: "S1",
resolveId: async () => "ANON",
});
t.capture("oss.bot.configured", { platforms: ["slack"] });
await tick();
expect(send).toHaveBeenCalledTimes(1);
const arg = send.mock.calls[0]![0];
expect(arg.event).toBe("oss.bot.configured");
expect(arg.properties).toEqual({ platforms: ["slack"] });
expect(arg.globalProperties.anonymous_id).toBe("ANON");
expect(arg.globalProperties.bot_session_id).toBe("S1");
});
it("is a no-op when disabled", async () => {
const send = vi.fn();
const t = new BotTelemetry({ ...base, disabled: true, send });
t.capture("oss.bot.started", {});
await tick();
expect(send).not.toHaveBeenCalled();
});
it("never throws when send rejects", async () => {
const send = vi.fn().mockRejectedValue(new Error("boom"));
const t = new BotTelemetry({
...base,
disabled: false,
send,
resolveId: async () => "X",
});
expect(() => t.capture("oss.bot.agent_run", {})).not.toThrow();
await tick();
});
it("exposes the five event names", () => {
expect([...BOT_TELEMETRY_EVENTS].sort()).toEqual([
"oss.bot.agent_run",
"oss.bot.agent_run_failed",
"oss.bot.configured",
"oss.bot.start_failed",
"oss.bot.started",
]);
});
});
@@ -0,0 +1,91 @@
import { lambdaClient, isTelemetryDisabled } from "@copilotkit/shared";
import type { LambdaSendOptions } from "@copilotkit/shared";
import type { StateStore } from "../state/state-store.js";
import { resolveInstallId } from "./install-id.js";
export const BOT_TELEMETRY_EVENTS = [
"oss.bot.configured",
"oss.bot.started",
"oss.bot.start_failed",
"oss.bot.agent_run",
"oss.bot.agent_run_failed",
] as const;
export type BotTelemetryEvent = (typeof BOT_TELEMETRY_EVENTS)[number];
export function isTestEnv(): boolean {
const env = process.env as Record<string, string | undefined>;
return env.NODE_ENV === "test" || !!env.VITEST || !!env.JEST_WORKER_ID;
}
export function resolveEnvironment(): string {
const e = (process.env.NODE_ENV ?? "").toLowerCase();
if (e === "production" || e === "development" || e === "test") return e;
return "unknown";
}
export interface BotTelemetryOptions {
backend: StateStore;
packageName: string;
packageVersion: string;
environment?: string;
disabled?: boolean;
sessionId?: string;
send?: (o: LambdaSendOptions) => Promise<void>;
resolveId?: () => Promise<string>;
}
export class BotTelemetry {
private readonly disabled: boolean;
private readonly sendFn: (o: LambdaSendOptions) => Promise<void>;
private readonly sessionId: string;
private readonly environment: string;
private readonly resolveId: () => Promise<string>;
private idPromise?: Promise<string>;
private static disclosed = false;
constructor(private readonly opts: BotTelemetryOptions) {
this.disabled = opts.disabled ?? (isTelemetryDisabled() || isTestEnv());
this.sendFn = opts.send ?? lambdaClient.send;
this.sessionId = opts.sessionId ?? globalThis.crypto.randomUUID();
this.environment = opts.environment ?? resolveEnvironment();
this.resolveId =
opts.resolveId ?? (() => resolveInstallId({ backend: opts.backend }));
}
capture(event: BotTelemetryEvent, properties: Record<string, unknown>): void {
if (this.disabled) return;
this.disclose();
void this.dispatch(event, properties);
}
private disclose(): void {
if (BotTelemetry.disclosed) return;
BotTelemetry.disclosed = true;
console.info(
"[CopilotKit Bot] anonymous telemetry enabled — see https://docs.copilotkit.ai/telemetry to opt out (set COPILOTKIT_TELEMETRY_DISABLED=true).",
);
}
private async dispatch(
event: BotTelemetryEvent,
properties: Record<string, unknown>,
): Promise<void> {
try {
this.idPromise ??= this.resolveId();
const anonymous_id = await this.idPromise;
await this.sendFn({
event,
properties,
globalProperties: {
anonymous_id,
bot_session_id: this.sessionId,
environment: this.environment,
},
packageName: this.opts.packageName,
packageVersion: this.opts.packageVersion,
});
} catch {
/* best-effort: telemetry must not break the host app */
}
}
}
@@ -0,0 +1,84 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const capture = vi.fn();
vi.mock("./bot-telemetry.js", () => ({
// A `function` (not an arrow) so `new BotTelemetry(...)` in create-bot.ts is
// constructible under vitest's mock — an arrow implementation throws
// "is not a constructor".
BotTelemetry: vi.fn().mockImplementation(function () {
return { capture };
}),
BOT_TELEMETRY_EVENTS: [],
}));
import { createBot } from "../create-bot.js";
import { FakeAdapter } from "../testing/fake-adapter.js";
import { FakeAgent } from "../testing/fake-agent.js";
import { Section } from "@copilotkit/bot-ui";
const tick = () => new Promise((r) => setTimeout(r, 0));
describe("createBot telemetry wiring", () => {
beforeEach(() => capture.mockClear());
it("emits oss.bot.configured with a config snapshot", async () => {
// The config snapshot is captured at start() — the backend (and therefore
// telemetry) is resolved there, not at construction, so an adapter attached
// via addAdapter can still provide the persistence backend.
const bot = createBot({
adapters: [new FakeAdapter()],
components: [
function Card() {
return Section({ children: "x" });
},
],
});
await bot.start();
const call = capture.mock.calls.find((c) => c[0] === "oss.bot.configured");
expect(call).toBeDefined();
expect(call![1].platforms).toEqual(["custom"]); // FakeAdapter.platform "fake" → normalized
expect(call![1].store).toBe("memory");
expect(call![1].hasComponents).toBe(true);
});
it("emits oss.bot.started on start, start_failed (category only) on a throwing adapter", async () => {
const ok = new FakeAdapter();
const bot = createBot({ adapters: [ok] });
await bot.start();
expect(
capture.mock.calls.find((c) => c[0] === "oss.bot.started")?.[1]
.startedCount,
).toBe(1);
capture.mockClear();
const bad = new FakeAdapter();
bad.start = () =>
Promise.reject(
Object.assign(new Error("xoxb-SECRET token bad"), { code: "EAUTH" }),
);
const bot2 = createBot({ adapters: [bad] });
await bot2.start();
const f = capture.mock.calls.find((c) => c[0] === "oss.bot.start_failed");
expect(f).toBeDefined();
expect(f![1].errorClass).toBe("auth");
expect(JSON.stringify(f![1])).not.toContain("SECRET");
});
it("emits oss.bot.agent_run on a successful run", async () => {
const fake = new FakeAdapter();
const bot = createBot({ adapters: [fake], agent: () => new FakeAgent() });
bot.onMention(async ({ thread }) => {
await thread.runAgent();
});
await bot.start();
capture.mockClear();
fake.emitTurn({ userText: "hi", conversationKey: "c1" });
await tick();
await tick();
const run = capture.mock.calls.find((c) => c[0] === "oss.bot.agent_run");
expect(run).toBeDefined();
expect(run![1].platform).toBe("custom"); // "fake" → normalized
expect(typeof run![1].durationMs).toBe("number");
expect(run![1].interrupted).toBe(false);
});
});
@@ -0,0 +1,67 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// vi.hoisted so the spy exists before the hoisted vi.mock factory runs
// (otherwise "Cannot access 'sendSpy' before initialization").
const { sendSpy } = vi.hoisted(() => ({
sendSpy: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@copilotkit/shared", async (importOriginal) => {
const actual = await importOriginal<typeof import("@copilotkit/shared")>();
return { ...actual, lambdaClient: { send: sendSpy } };
});
import { createBot } from "../create-bot.js";
import { FakeAdapter } from "../testing/fake-adapter.js";
import { FakeAgent } from "../testing/fake-agent.js";
const waitFor = async (pred: () => boolean, ms = 1000) => {
const start = Date.now();
while (!pred() && Date.now() - start < ms)
await new Promise((r) => setTimeout(r, 10));
};
describe("oss.bot.* end-to-end (real BotTelemetry, only network boundary stubbed)", () => {
const saved = { ...process.env };
beforeEach(() => {
sendSpy.mockClear();
// Enable telemetry: clear the test-runner suppressors + any opt-out.
delete process.env.VITEST;
delete process.env.JEST_WORKER_ID;
delete process.env.COPILOTKIT_TELEMETRY_DISABLED;
delete process.env.DO_NOT_TRACK;
process.env.NODE_ENV = "production";
// Deliberately set NO COPILOTKIT_TELEMETRY_URL / license / API key — zero-config.
});
afterEach(() => {
process.env = { ...saved };
});
it("flows configured -> started -> agent_run with anonymous_id + bot_session_id and no env config", async () => {
const fake = new FakeAdapter();
const bot = createBot({ adapters: [fake], agent: () => new FakeAgent() });
bot.onMention(async ({ thread }) => {
await thread.runAgent();
});
await bot.start();
fake.emitTurn({ userText: "hi", conversationKey: "c1" });
await waitFor(() =>
sendSpy.mock.calls.some((c) => c[0].event === "oss.bot.agent_run"),
);
const events = sendSpy.mock.calls.map((c) => c[0].event);
expect(events).toContain("oss.bot.configured");
expect(events).toContain("oss.bot.started");
expect(events).toContain("oss.bot.agent_run");
for (const [arg] of sendSpy.mock.calls) {
expect(typeof arg.globalProperties.anonymous_id).toBe("string");
expect(typeof arg.globalProperties.bot_session_id).toBe("string");
expect(arg.licenseToken).toBeUndefined(); // anonymous: no license ever attached
}
const run = sendSpy.mock.calls.find(
(c) => c[0].event === "oss.bot.agent_run",
)![0];
expect(run.properties.platform).toBe("custom"); // "fake" → normalized
expect(typeof run.properties.durationMs).toBe("number");
});
});
@@ -0,0 +1,11 @@
import { describe, it, expect } from "vitest";
import catalog from "../../telemetry-events.json" with { type: "json" };
import { BOT_TELEMETRY_EVENTS } from "./bot-telemetry.js";
describe("telemetry-events.json", () => {
it("documents exactly the emitted events", () => {
expect(Object.keys(catalog.events).sort()).toEqual(
[...BOT_TELEMETRY_EVENTS].sort(),
);
});
});
@@ -0,0 +1,46 @@
import { describe, it, expect } from "vitest";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { resolveInstallId } from "./install-id.js";
import { MemoryStore } from "../state/memory-store.js";
import type { StateStore } from "../state/state-store.js";
class FakeDurableStore implements StateStore {
map = new Map<string, unknown>();
kv = {
get: async <T>(k: string) => this.map.get(k) as T | undefined,
set: async <T>(k: string, v: T) => void this.map.set(k, v),
delete: async (k: string) => void this.map.delete(k),
};
list = {} as StateStore["list"];
lock = {} as StateStore["lock"];
dedup = {} as StateStore["dedup"];
queue = {} as StateStore["queue"];
}
describe("resolveInstallId", () => {
it("persists + reuses in a durable store", async () => {
const backend = new FakeDurableStore();
const a = await resolveInstallId({ backend });
const b = await resolveInstallId({ backend });
expect(a).toBe(b);
expect(backend.map.get("cpk:telemetry:install_id")).toBe(a);
});
it("persists + reuses in a file for MemoryStore", async () => {
const cacheDir = mkdtempSync(join(tmpdir(), "cpk-"));
const backend = new MemoryStore();
const a = await resolveInstallId({ backend, cacheDir });
const b = await resolveInstallId({ backend, cacheDir });
expect(a).toBe(b);
});
it("falls back to a uuid when the file dir is unwritable", async () => {
const backend = new MemoryStore();
const id = await resolveInstallId({
backend,
cacheDir: "/dev/null/nope",
uuid: () => "FALLBACK",
});
expect(id).toBe("FALLBACK");
});
});
+50
View File
@@ -0,0 +1,50 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { MemoryStore } from "../state/memory-store.js";
import type { StateStore } from "../state/state-store.js";
const STORE_KEY = "cpk:telemetry:install_id";
const FILE_NAME = "telemetry-id";
const defaultUuid = () => globalThis.crypto.randomUUID();
const defaultCacheDir = () =>
join(process.cwd(), "node_modules", ".cache", "copilotkit");
export interface ResolveInstallIdDeps {
backend: StateStore;
cacheDir?: string;
uuid?: () => string;
}
export async function resolveInstallId(
deps: ResolveInstallIdDeps,
): Promise<string> {
const uuid = deps.uuid ?? defaultUuid;
if (!(deps.backend instanceof MemoryStore)) {
try {
const existing = await deps.backend.kv.get<string>(STORE_KEY);
if (existing) return existing;
const id = uuid();
await deps.backend.kv.set(STORE_KEY, id);
return id;
} catch {
/* fall through */
}
}
try {
const dir = deps.cacheDir ?? defaultCacheDir();
const file = join(dir, FILE_NAME);
try {
const existing = readFileSync(file, "utf8").trim();
if (existing) return existing;
} catch {
/* not created yet */
}
mkdirSync(dir, { recursive: true });
const id = uuid();
writeFileSync(file, id, "utf8");
return id;
} catch {
/* fall through */
}
return uuid();
}
@@ -0,0 +1,55 @@
import { describe, it, expect } from "vitest";
import { readFileSync, readdirSync, existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { join } from "node:path";
import { normalizePlatform } from "./sanitize-error.js";
// Monorepo invariant: every official bot adapter's `platform` label must be in
// normalizePlatform()'s allow-list, otherwise a newly-added adapter's events
// silently bucket to "custom" and we lose its per-platform telemetry signal.
//
// We can't import the adapter packages (they depend on @copilotkit/bot, not the
// reverse), so we discover them by scanning sibling bot-* packages on disk. This
// runs only in the monorepo (tests aren't published), which is exactly where the
// drift would be introduced.
// packages/bot/src/telemetry/ -> packages/
const packagesDir = fileURLToPath(new URL("../../../", import.meta.url));
function discoverAdapterPlatforms(): { pkg: string; platform: string }[] {
const found: { pkg: string; platform: string }[] = [];
for (const entry of readdirSync(packagesDir)) {
if (!entry.startsWith("bot-")) continue;
// Adapter packages declare `readonly platform = "x"` in src/adapter.ts;
// non-adapter bot-* packages (bot-ui, bot-store-*) have no such file/field.
const adapterFile = join(packagesDir, entry, "src", "adapter.ts");
if (!existsSync(adapterFile)) continue;
const src = readFileSync(adapterFile, "utf8");
const m = src.match(/readonly\s+platform\s*=\s*["']([^"']+)["']/);
if (m) found.push({ pkg: entry, platform: m[1]! });
}
return found;
}
describe("normalizePlatform allow-list coverage (monorepo invariant)", () => {
it("covers every official bot adapter's declared platform", () => {
const discovered = discoverAdapterPlatforms();
// Guard against a broken scan path silently passing: we ship at least
// slack/discord/telegram/whatsapp.
expect(
discovered.length,
`expected to discover the official bot adapters under ${packagesDir}`,
).toBeGreaterThanOrEqual(4);
const missing = discovered.filter(
({ platform }) => normalizePlatform(platform) !== platform,
);
expect(
missing,
`These adapter platforms are NOT in normalizePlatform's allow-list, so their ` +
`telemetry would bucket to "custom". Add them to KNOWN_PLATFORMS in ` +
`packages/bot/src/telemetry/sanitize-error.ts: ` +
missing.map((m) => `"${m.platform}" (${m.pkg})`).join(", "),
).toEqual([]);
});
});
@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import { errorClass, normalizePlatform } from "./sanitize-error.js";
describe("normalizePlatform", () => {
it("passes through known platforms and buckets the rest as custom", () => {
expect(normalizePlatform("slack")).toBe("slack");
expect(normalizePlatform("discord")).toBe("discord");
expect(normalizePlatform("telegram")).toBe("telegram");
expect(normalizePlatform("whatsapp")).toBe("whatsapp");
expect(normalizePlatform("teams")).toBe("teams");
// Free-form / custom adapter labels must not leak through.
expect(normalizePlatform("acme-internal-tenant")).toBe("custom");
expect(normalizePlatform("fake")).toBe("custom");
expect(normalizePlatform("")).toBe("custom");
});
});
describe("errorClass", () => {
it("never leaks the error message", () => {
const err = new Error("postgres://user:s3cret@db/prod failed");
const out = errorClass(err);
expect(out).not.toContain("s3cret");
expect(["auth", "network", "timeout", "validation", "unknown"]).toContain(
out,
);
});
it("categorizes by name/code", () => {
expect(
errorClass(
new (class extends Error {
name = "AbortError";
})(),
),
).toBe("timeout");
expect(
errorClass(Object.assign(new Error("x"), { code: "ENOTFOUND" })),
).toBe("network");
expect(
errorClass(
new (class extends Error {
name = "ZodError";
})(),
),
).toBe("validation");
expect(errorClass("plain string")).toBe("unknown");
});
});
@@ -0,0 +1,30 @@
// Map an arbitrary thrown value to a bounded, non-identifying category.
// NEVER returns the error message or stack — only a fixed-cardinality label.
export function errorClass(err: unknown): string {
const e = err as { name?: unknown; code?: unknown } | null;
const name = typeof e?.name === "string" ? e.name : "";
const code = typeof e?.code === "string" ? e.code : "";
const hay = `${name} ${code}`.toLowerCase();
if (/abort|timeout|etimedout|deadline/.test(hay)) return "timeout";
if (/network|fetch|econn|enotfound|socket|dns|epipe/.test(hay))
return "network";
if (/auth|unauthorized|forbidden|token|credential|401|403/.test(hay))
return "auth";
if (/zod|valid|schema|parse/.test(hay)) return "validation";
return "unknown";
}
// `Adapter.platform` is a free-form string an adapter author sets, so a custom
// third-party adapter could put a tenant/project name there. Bound it to the
// known platforms and bucket everything else as "custom" — prevents leaking
// caller-chosen labels and caps telemetry cardinality.
const KNOWN_PLATFORMS = new Set([
"slack",
"discord",
"telegram",
"whatsapp",
"teams",
]);
export function normalizePlatform(platform: string): string {
return KNOWN_PLATFORMS.has(platform) ? platform : "custom";
}
+110 -53
View File
@@ -12,6 +12,7 @@ import type {
EphemeralResult,
} from "@copilotkit/bot-ui";
import { runAgentLoop } from "./run-loop.js";
import { errorClass, normalizePlatform } from "./telemetry/sanitize-error.js";
import type { Transcripts } from "./transcripts.js";
import { toAgentToolDescriptors } from "./tools.js";
import type {
@@ -55,6 +56,13 @@ export interface ThreadDeps {
userKey?: string;
/** The inbound message that triggered this turn (for transcript bridging). */
message?: IncomingMessage;
/**
* Optional anonymous telemetry sink. Structural type (not the concrete
* BotTelemetry) avoids an import cycle; the real BotTelemetry satisfies it.
*/
telemetry?: {
capture(event: string, properties: Record<string, unknown>): void;
};
}
/** A concrete conversation thread: posts UI, runs the agent loop, and resolves HITL waiters. */
@@ -74,15 +82,37 @@ export class Thread implements ThreadInterface {
return this.deps.registry.bindRenderable(ui, this.deps.conversationKey);
}
/**
* Wire a posted message's `onReaction` to its returned id: cache it for this
* process and, when it came from a component, persist a durable snapshot so a
* reaction after a restart re-derives it (parity with a component `onClick`).
*/
private async bindReaction(
messageId: string,
bound: Awaited<ReturnType<Thread["bindForPost"]>>,
): Promise<void> {
if (bound.onReaction) {
this.deps.registry.registerMessageReaction(messageId, bound.onReaction);
}
if (bound.reactionComponent) {
await this.deps.registry.persistMessageReaction(messageId, {
...bound.reactionComponent,
conversationKey: this.deps.conversationKey,
});
}
}
async post(ui: Renderable): Promise<MessageRef> {
return this.deps.adapter.post(
this.deps.replyTarget,
await this.bindForPost(ui),
);
const bound = await this.bindForPost(ui);
const ref = await this.deps.adapter.post(this.deps.replyTarget, bound.root);
await this.bindReaction(ref.id, bound);
return ref;
}
async update(ref: MessageRef, ui: Renderable): Promise<MessageRef> {
await this.deps.adapter.update(ref, await this.bindForPost(ui));
const bound = await this.bindForPost(ui);
await this.deps.adapter.update(ref, bound.root);
await this.bindReaction(ref.id, bound);
return ref;
}
@@ -190,12 +220,10 @@ export class Thread implements ThreadInterface {
error: `${this.platform} does not support ephemeral messages`,
};
}
return adapter.postEphemeral(
this.deps.replyTarget,
user,
await this.bindForPost(ui),
opts,
);
// Ephemeral messages can't be reacted to, so any `onReaction` is dropped
// (stripped by bindForPost) rather than registered.
const { root } = await this.bindForPost(ui);
return adapter.postEphemeral(this.deps.replyTarget, user, root, opts);
}
// Subscription STORAGE lands here; subscription ROUTING (onSubscribedMessage) is deferred.
@@ -374,50 +402,79 @@ export class Thread implements ThreadInterface {
// assistant messages this run produced (step 4).
const messagesBefore = session.agent.messages.length;
await runAgentLoop({
agent: session.agent,
renderer,
tools,
toolDescriptors,
context,
makeToolCtx: (): BotToolContext => ({
thread: this,
platform: this.platform,
}),
handleInterrupt: async (interrupt) => {
const h = this.deps.interruptHandlers.get(interrupt.eventName);
if (h) await h({ payload: interrupt.value, thread: this });
},
initialResume,
});
// Transcript auto-bridge (step 4): capture the assistant text this run
// produced and append it. Only when the bridge actually applied (transcripts
// + userKey both present and `transcript` was requested).
if (extra?.transcript && transcripts && userKey) {
const produced = session.agent.messages.slice(messagesBefore);
const text = produced
.filter(
(m) =>
m.role === "assistant" &&
typeof m.content === "string" &&
m.content.trim().length > 0,
)
.map((m) => m.content as string)
.join("\n\n");
if (text.length > 0) {
await transcripts.append(
this,
{ role: "assistant", text },
{ userKey },
);
const startedAt = Date.now();
let loopResult: { iterations: number; interrupted: boolean };
// Telemetry stage: "agent" while the run loop runs, "finalize" for the
// transcript-append + renderer.finish() steps below. A throw in either is
// reported as agent_run_failed (with the right stage) instead of being
// hidden behind an already-sent success event.
let stage: "agent" | "finalize" = "agent";
try {
loopResult = await runAgentLoop({
agent: session.agent,
renderer,
tools,
toolDescriptors,
context,
makeToolCtx: (): BotToolContext => ({
thread: this,
platform: this.platform,
}),
handleInterrupt: async (interrupt) => {
const h = this.deps.interruptHandlers.get(interrupt.eventName);
if (h) await h({ payload: interrupt.value, thread: this });
},
initialResume,
});
stage = "finalize";
// Transcript auto-bridge (step 4): capture the assistant text this run
// produced and append it. Only when the bridge actually applied (transcripts
// + userKey both present and `transcript` was requested).
if (extra?.transcript && transcripts && userKey) {
const produced = session.agent.messages.slice(messagesBefore);
const text = produced
.filter(
(m) =>
m.role === "assistant" &&
typeof m.content === "string" &&
m.content.trim().length > 0,
)
.map((m) => m.content as string)
.join("\n\n");
if (text.length > 0) {
await transcripts.append(
this,
{ role: "assistant", text },
{ userKey },
);
}
}
}
// Turn-end hook: lets a renderer finalize any turn-scoped resource it kept
// open across runAgent iterations (e.g. a native streaming message). A
// no-op for renderers whose per-message streams already self-terminate, and
// for runs that were interrupted (the renderer guards that internally).
await renderer.finish?.();
// Turn-end hook: lets a renderer finalize any turn-scoped resource it kept
// open across runAgent iterations (e.g. a native streaming message). A
// no-op for renderers whose per-message streams already self-terminate, and
// for runs that were interrupted (the renderer guards that internally).
await renderer.finish?.();
} catch (err) {
// A throw is a run failure — in the agent loop (tool-handler errors are
// swallowed inside the loop, so a throw is agent-level) or in finalization.
// `stage` distinguishes the two.
this.deps.telemetry?.capture("oss.bot.agent_run_failed", {
platform: normalizePlatform(this.platform),
errorClass: errorClass(err),
stage,
});
throw err;
}
// Emit success ONLY after the loop AND finalization both completed, so a
// late transcript/finish rejection can never follow a success event.
this.deps.telemetry?.capture("oss.bot.agent_run", {
platform: normalizePlatform(this.platform),
durationMs: Date.now() - startedAt,
toolCallCount: renderer.getCapturedToolCalls().length,
iterations: loopResult.iterations,
interrupted: loopResult.interrupted,
});
return undefined;
}
}
+62
View File
@@ -0,0 +1,62 @@
{
"global_properties": {
"anonymous_id": "Persisted per-install UUID (durable store -> project-local cache file -> per-process).",
"bot_session_id": "Random UUID minted per createBot() call.",
"environment": "development | production | test | unknown"
},
"events": {
"oss.bot.configured": {
"description": "createBot() returned; repeats per process start.",
"properties": {
"platforms": "string[] (each normalized: slack|discord|telegram|whatsapp|teams|custom)",
"adapterCount": "number",
"store": "memory|postgres|redis|custom",
"hasComponents": "boolean",
"componentsCount": "number",
"toolsCount": "number",
"commandsCount": "number",
"contextCount": "number",
"transcripts": "boolean",
"identity": "boolean"
}
},
"oss.bot.started": {
"description": "bot.start() connected at least one adapter.",
"properties": {
"platforms": "string[] (each normalized: slack|discord|telegram|whatsapp|teams|custom)",
"startedCount": "number",
"failedCount": "number",
"hasMentionHandler": "boolean",
"hasMessageHandler": "boolean",
"interruptHandlers": "number",
"commandsCount": "number",
"toolsCount": "number"
}
},
"oss.bot.start_failed": {
"description": "An adapter threw during start().",
"properties": {
"platform": "string (normalized: slack|discord|telegram|whatsapp|teams|custom)",
"errorClass": "auth|network|timeout|validation|unknown"
}
},
"oss.bot.agent_run": {
"description": "A successful agent invocation completed (emitted after the run loop AND finalization/transcript steps succeed). Note: an interrupt->resume turn emits two agent_run events (interrupted:true at the ack-first return, then interrupted:false after resume); filter interrupted===false to count completed runs.",
"properties": {
"platform": "string (normalized: slack|discord|telegram|whatsapp|teams|custom)",
"durationMs": "number",
"toolCallCount": "number",
"iterations": "number",
"interrupted": "boolean"
}
},
"oss.bot.agent_run_failed": {
"description": "An agent run errored. Emitted instead of agent_run; stage=agent for run-loop failures, stage=finalize for transcript-append/renderer-finish failures after the loop.",
"properties": {
"platform": "string (normalized: slack|discord|telegram|whatsapp|teams|custom)",
"errorClass": "auth|network|timeout|validation|unknown",
"stage": "agent|finalize"
}
}
}
}
@@ -226,7 +226,19 @@ export function CopilotChat({
const isConnecting =
hasExplicitThreadId && lastConnectedThreadId !== resolvedThreadId;
// Tracks the threadId the connect effect last ran for, so it can tell a real
// thread SWITCH from an incidental re-render (agent identity change, etc.).
const previousThreadIdRef = useRef<string | null>(null);
// Latest explicitness, readable from an async connect that may resolve after
// the user has already switched threads (see the stale-connect guard below).
const hasExplicitThreadIdRef = useRef(hasExplicitThreadId);
hasExplicitThreadIdRef.current = hasExplicitThreadId;
useEffect(() => {
const threadChanged = previousThreadIdRef.current !== resolvedThreadId;
previousThreadIdRef.current = resolvedThreadId;
// Non-explicit threads skip /connect, but the first runAgent still has to
// ship the same SDK-generated threadId that the chat UI is rendering.
agent.threadId = resolvedThreadId;
@@ -236,7 +248,18 @@ export function CopilotChat({
// ThreadsProvider). The backend has never seen it, so /connect would
// always 404 — skip the call. A real thread is only created once the
// user runs the agent for the first time.
if (!hasExplicitThreadId) return;
if (!hasExplicitThreadId) {
// Switching to a fresh, non-backend thread (e.g. startNewThread / the
// drawer's "+ New"): there are no messages to /connect for, so drop any
// messages carried over from the previously-viewed thread and fall back
// to the welcome screen. Guard on an actual threadId change so re-renders
// of the current thread (including its first run) never wipe an
// in-progress conversation.
if (threadChanged && agent.messages.length > 0) {
agent.setMessages([]);
}
return;
}
let detached = false;
@@ -276,6 +299,14 @@ export function CopilotChat({
raf(() => {
if (!detached) setLastConnectedThreadId(resolvedThreadId);
});
} else if (!hasExplicitThreadIdRef.current) {
// This connect was superseded (the user switched away while it was
// still loading). If the now-current thread is a fresh non-explicit
// one (e.g. the drawer's "+ New"), any snapshot this connect managed
// to apply is stale — clear it so the welcome screen shows instead of
// the abandoned thread's messages. A switch to ANOTHER explicit thread
// is left alone: that thread's own connect owns the message reset.
agentToConnect.setMessages([]);
}
}
};
@@ -10,16 +10,19 @@ import React, {
import {
defineCopilotKitDrawer,
COPILOTKIT_DRAWER_TAG,
type CopilotKitDrawer as CopilotKitDrawerElement,
type DrawerThread,
type ThreadSelectedDetail,
type ArchiveDetail,
type UnarchiveDetail,
type DeleteDetail,
type OpenChangeDetail,
type RetryDetail,
} from "@copilotkit/web-components/drawer";
import { useThreads, type Thread } from "../../hooks/use-threads";
import type {
CopilotKitDrawer as CopilotKitDrawerElement,
DrawerThread,
ThreadSelectedDetail,
ArchiveDetail,
UnarchiveDetail,
DeleteDetail,
OpenChangeDetail,
RetryDetail,
} from "@copilotkit/web-components/drawer";
import { useThreads } from "../../hooks/use-threads";
import type { Thread } from "../../hooks/use-threads";
import { useLicenseContext } from "../../providers/CopilotKitProvider";
import { useCopilotChatConfiguration } from "../../providers/CopilotChatConfigurationProvider";
@@ -193,12 +196,21 @@ export function CopilotDrawer({
// license is configured, so it cannot by itself detect the no-license case.
// We therefore also require a positive license-present signal from the
// runtime-reported status. Only a "valid" or "expiring" license is treated
// as present; null/"none"/"unknown" (no/indeterminate license) and
// "expired"/"invalid" all gate the drawer to the upsell.
// as present; a resolved "none"/"expired"/"invalid" status gates the drawer
// to the upsell.
const licensePresent = status === "valid" || status === "expiring";
const featureLicensed = checkFeature("threads");
const licensed = licensePresent && featureLicensed;
// The runtime reports license status asynchronously: `status` is null until
// the first /info response lands. Treat that pending window as "not yet
// unlicensed" — show the loading state, never the upsell — so a licensed
// drawer doesn't flash the upgrade CTA before its license resolves (and never
// strands the CTA on screen when the status is slow or fails to arrive). Only
// a RESOLVED-negative status (`none`/`expired`/`invalid`, or a present license
// missing the `threads` feature) surfaces the upsell.
const licensePending = status === null;
const resolvedAgentId = agentId ?? configuration?.agentId ?? "default";
const activeThreadId = configuration?.threadId ?? null;
@@ -477,13 +489,18 @@ export function CopilotDrawer({
useEffect(() => {
const el = elementRef.current;
if (!el) return;
el.loading = isLoading;
// While the license is still resolving, force the loading state so the
// element shows its spinner instead of an empty/unlicensed body.
el.loading = isLoading || licensePending;
// Only genuine list-load/mutation errors reach the end user. Developer/
// config errors (missing runtime URL, runtime without thread endpoints) are
// excluded via `listError` so they never leak into the drawer's error UI.
el.error = listError ? listError.message : null;
el.activeThreadId = activeThreadId;
el.licensed = licensed;
// Pending counts as licensed for rendering: `_renderBody` shows the upsell
// only when `licensed` is false, so keeping it true until the status
// resolves prevents the upsell from flashing (or sticking) mid-resolution.
el.licensed = licensed || licensePending;
el.hasMore = hasMoreThreads;
el.fetchingMore = isFetchingMoreThreads;
}, [
@@ -491,6 +508,7 @@ export function CopilotDrawer({
listError,
activeThreadId,
licensed,
licensePending,
hasMoreThreads,
isFetchingMoreThreads,
mounted,
@@ -1,11 +1,12 @@
import React, { useCallback } from "react";
import React, { useCallback, useEffect, useState } from "react";
import { cn } from "../../lib/utils";
import {
useCopilotChatConfiguration,
CopilotChatDefaultLabels,
} from "../../providers/CopilotChatConfigurationProvider";
import { renderSlot, WithSlots } from "../../lib/slots";
import type { WithSlots } from "../../lib/slots";
import { renderSlot } from "../../lib/slots";
import { PanelLeftOpen, X } from "lucide-react";
type HeaderSlots = {
@@ -38,6 +39,30 @@ export type CopilotModalHeaderProps = Omit<
children?: (props: HeaderChildrenPayload) => React.ReactNode;
};
/**
* Reactively tracks whether the viewport is in the mobile range (767px) the
* same breakpoint the drawer + chat coordination use. SSR-safe: starts `false`
* (desktop) so the server render and first client render agree, then syncs on
* mount and on resize.
*/
function useIsMobileViewport(): boolean {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
if (
typeof window === "undefined" ||
typeof window.matchMedia !== "function"
) {
return;
}
const mql = window.matchMedia("(max-width: 767px)");
const update = () => setIsMobile(mql.matches);
update();
mql.addEventListener("change", update);
return () => mql.removeEventListener("change", update);
}, []);
return isMobile;
}
export function CopilotModalHeader({
title,
titleContent,
@@ -55,9 +80,14 @@ export function CopilotModalHeader({
const resolvedTitle = title ?? fallbackTitle;
// The thread-list launcher renders ONLY when a <CopilotDrawer> wrapper has
// registered with the chat configuration. Chats with no drawer get no
// launcher and no behavior change.
const drawerRegistered = configuration?.drawerRegistered ?? false;
// registered with the chat configuration AND the viewport is mobile. On
// desktop the drawer is an in-flow, persistent panel (it ignores `open`), so
// an "open the drawer" launcher there is a dead no-op — it only does anything
// for the mobile off-canvas drawer. Chats with no drawer get no launcher and
// no behavior change.
const isMobile = useIsMobileViewport();
const drawerRegistered =
(configuration?.drawerRegistered ?? false) && isMobile;
const handleClose = useCallback(() => {
configuration?.setModalOpen?.(false);
@@ -1,6 +1,12 @@
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { EMPTY, Observable } from "rxjs";
import { z } from "zod";
import type { BaseEvent, RunAgentInput } from "@ag-ui/client";
@@ -180,4 +186,45 @@ describe("CopilotChat avoids /connect for locally-generated threadIds (ENT-314)"
),
).toBe(true);
});
it("clears messages when switching to a fresh non-explicit thread ('+ New')", async () => {
// Switching to an existing thread replaces messages via /connect, but a
// fresh non-explicit thread skips /connect — so the previously-viewed
// thread's messages must be cleared explicitly, or "+ New" leaves the old
// conversation on screen instead of the welcome view.
const agent = new MockStepwiseAgent();
const { rerender } = render(
<CopilotKitProvider agents__unsafe_dev_only={{ default: agent }}>
<CopilotChatConfigurationProvider
threadId="thread-A"
hasExplicitThreadId={false}
>
<CopilotChat welcomeScreen={false} />
</CopilotChatConfigurationProvider>
</CopilotKitProvider>,
);
// Simulate an in-progress conversation on the current (non-explicit) thread.
act(() => {
agent.setMessages([
{ id: "m1", role: "assistant", content: "hi" } as never,
]);
});
expect(agent.messages.length).toBe(1);
// "+ New" mints a different non-explicit threadId.
rerender(
<CopilotKitProvider agents__unsafe_dev_only={{ default: agent }}>
<CopilotChatConfigurationProvider
threadId="thread-B"
hasExplicitThreadId={false}
>
<CopilotChat welcomeScreen={false} />
</CopilotChatConfigurationProvider>
</CopilotKitProvider>,
);
await waitFor(() => expect(agent.messages.length).toBe(0));
});
});
@@ -501,6 +501,26 @@ test("licensed drawer enables the thread fetch (enabled=true)", async () => {
expect(lastInput.enabled).toBe(true);
});
test("pending license (status null) shows loading, never the upsell", async () => {
// Before the runtime reports a license, `status` is null. The drawer must NOT
// flash (or strand) the upsell during this window: it renders as licensed
// (so `_renderBody` skips the upsell) with loading forced on, and holds the
// fetch until the status resolves.
licenseMock.mockReturnValue({
status: null,
license: null,
checkFeature: () => true,
getLimit: () => null,
});
await renderDrawer();
expect(getElement().licensed).toBe(true);
expect(getElement().loading).toBe(true);
const lastInput = useThreadsMock.mock.calls.at(-1)?.[0] as UseThreadsInput;
expect(lastInput.enabled).toBe(false);
});
test('projects per-row content into slot="row:{id}" when renderRow is provided', async () => {
await renderDrawer({
renderRow: (thread) => <span data-row={thread.id}>{thread.name}</span>,
@@ -1,12 +1,30 @@
import React from "react";
import { render, screen, fireEvent, act } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { CopilotModalHeader } from "../CopilotModalHeader";
import {
CopilotChatConfigurationProvider,
useCopilotChatConfiguration,
} from "../../../providers/CopilotChatConfigurationProvider";
/**
* The in-header launcher is mobile-only (on desktop the drawer is a persistent
* in-flow panel, so an "open the drawer" launcher there would be a dead no-op).
* Stub matchMedia so these tests run in a deterministic viewport.
*/
function mockViewport(isMobile: boolean) {
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: isMobile,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
}
/**
* Registers a drawer on mount so the header launcher's presence gate is
* satisfied. Mirrors what the future <CopilotDrawer> wrapper does.
@@ -24,6 +42,26 @@ function DrawerStateProbe() {
}
describe("CopilotModalHeader drawer launcher", () => {
const originalMatchMedia = window.matchMedia;
// Default to a mobile viewport so the launcher's presence tests below hold;
// the desktop case is asserted explicitly.
beforeEach(() => mockViewport(true));
afterEach(() => {
window.matchMedia = originalMatchMedia;
});
it("does NOT render the launcher on desktop even when a drawer is registered", () => {
mockViewport(false);
render(
<CopilotChatConfigurationProvider threadId="t">
<DrawerRegistrar />
<CopilotModalHeader title="Chat" />
</CopilotChatConfigurationProvider>,
);
expect(screen.queryByTestId("copilot-drawer-launcher")).toBeNull();
});
it("does NOT render the launcher when no drawer is registered", () => {
render(
<CopilotChatConfigurationProvider threadId="t">
@@ -1,8 +1,8 @@
import type { ReactNode } from "react";
import React, {
createContext,
useCallback,
useContext,
ReactNode,
useEffect,
useMemo,
useRef,
@@ -194,11 +194,21 @@ export const CopilotChatConfigurationProvider: React.FC<
const resolvedAgentId = agentId ?? parentConfig?.agentId ?? DEFAULT_AGENT_ID;
// Whether this provider's threadId is controlled by the consumer (supplied
// via the `threadId` prop). Mirrors the top-level `<CopilotKit>` provider's
// `props.threadId` guard: when controlled, the imperative active-thread
// setters below must not override the prop-driven value.
const isThreadIdControlled = threadId !== undefined;
// A threadId prop is "authoritative" (caller-chosen) only when it is present
// AND not explicitly flagged non-explicit. The v1 `<CopilotKit>` bridge pipes
// an auto-minted UUID through as `threadId` with `hasExplicitThreadId={false}`
// to SEED the thread without claiming the caller picked it; that seed must
// stay overridable so imperative callers (e.g. `<CopilotDrawer>` selecting a
// row, or `startNewThread`) can switch threads. A bare `threadId` prop (no
// `hasExplicitThreadId`) is still treated as a caller choice.
const threadIdPropIsAuthoritative =
threadId !== undefined && hasExplicitThreadId !== false;
// Whether this provider's threadId is controlled by the consumer. When
// controlled, the imperative active-thread setters below must not override
// the prop-driven value. A non-authoritative seed (v1 bridge auto-mint) is
// NOT controlled, so imperative selection still works underneath it.
const isThreadIdControlled = threadIdPropIsAuthoritative;
// Imperative active-thread override owned by the TOP-MOST provider (the one
// with no parent). A non-null override takes precedence over the auto-minted
@@ -211,28 +221,38 @@ export const CopilotChatConfigurationProvider: React.FC<
} | null>(null);
const resolvedThreadId = useMemo(() => {
if (threadId) {
return threadId;
// An authoritative (caller-chosen) threadId prop always wins.
if (threadIdPropIsAuthoritative) {
return threadId as string;
}
// Otherwise an imperative override (a picked row or freshly-started thread)
// beats both a non-authoritative seed (the v1 bridge's auto-minted UUID) and
// the thread inherited from a parent provider.
if (activeThreadOverride) {
return activeThreadOverride.threadId;
}
if (parentConfig?.threadId) {
return parentConfig.threadId;
}
if (activeThreadOverride) {
return activeThreadOverride.threadId;
if (threadId) {
return threadId;
}
return randomUUID();
}, [threadId, parentConfig?.threadId, activeThreadOverride]);
}, [
threadIdPropIsAuthoritative,
threadId,
parentConfig?.threadId,
activeThreadOverride,
]);
// If a caller passed `hasExplicitThreadId`, trust it verbatim (lets the v1
// bridge mark an auto-minted UUID as non-explicit). Otherwise: a threadId
// supplied as a prop here is by definition a caller choice; an imperative
// override carries its own explicitness.
const ownHasExplicitThreadId =
hasExplicitThreadId !== undefined
? hasExplicitThreadId
: threadId
? true
: (activeThreadOverride?.explicit ?? false);
// Explicitness of this provider's own thread, mirroring the resolution order
// above: an authoritative prop is a caller choice; otherwise an imperative
// override carries its own explicitness (a picked row is explicit, a fresh
// `startNewThread` is not); failing both, fall back to the (non-authoritative)
// prop flag, which is `false` for the v1 bridge seed.
const ownHasExplicitThreadId = threadIdPropIsAuthoritative
? true
: (activeThreadOverride?.explicit ?? hasExplicitThreadId ?? false);
const resolvedHasExplicitThreadId =
ownHasExplicitThreadId || !!parentConfig?.hasExplicitThreadId;

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