Merge remote-tracking branch 'origin/main' into alem/oss-360-sdk-foundations
# Conflicts: # packages/bot/src/create-bot.ts
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
OPENAI_API_KEY=
|
||||
COPILOTKIT_MODEL=openai/gpt-5.4
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
.next/
|
||||
out/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.env*
|
||||
!.env.example
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SimpleChat } from "@/components/simple-chat";
|
||||
|
||||
export default function Home() {
|
||||
return <SimpleChat />;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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/**"]),
|
||||
]);
|
||||
@@ -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));
|
||||
}
|
||||
Vendored
+6
@@ -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.
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
turbopack: {
|
||||
root: process.cwd(),
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -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"
|
||||
}
|
||||
Generated
+12592
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
packages:
|
||||
- "."
|
||||
minimumReleaseAgeExclude:
|
||||
- "@shadcn/react@0.1.0"
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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`.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
});
|
||||
@@ -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 +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?.();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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().
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+48
-1
@@ -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>,
|
||||
|
||||
+39
-1
@@ -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
Reference in New Issue
Block a user