For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Agent Creator 01

A compact editor for an agent profile and avatar.

Installation

Set up the @cobalt registry first, as described in Installation, then add the block:

$ pnpm dlx shadcn@latest add @cobalt/agent-creator-01

Pass an agent and an onChange callback to manage the profile in your application. Preferences are values for the host application to use; the block does not save data or request permissions.

Source

"use client";

import {
  RiCloseLine,
  RiGlobalLine,
  RiNotification3Line,
  RiPauseFill,
  RiPlayFill,
  RiVolumeUpLine,
} from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";

import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Toggle } from "@/components/ui/toggle";

export const shapes = [
  "star",
  "heart",
  "cloud",
  "diamond",
  "shield",
  "circle",
  "square",
  "flower",
  "drop",
] as const;
export type Shape = (typeof shapes)[number];
const paths: Record<Shape, string> = {
  star: "M50 5 61 34 92 34 67 54 76 85 50 67 24 85 33 54 8 34 39 34Z",
  heart:
    "M50 88 C35 76 8 58 8 34 C8 10 37 8 50 28 C63 8 92 10 92 34 C92 58 65 76 50 88Z",
  cloud:
    "M21 79 C-3 79 2 42 24 41 C23 25 35 18 45 21 C50 4 75 8 79 27 C103 25 105 52 94 68 C89 76 82 79 70 79Z",
  diamond: "M50 4 91 50 50 96 9 50Z",
  shield:
    "M50 5 Q70 18 89 19 L89 55 Q87 78 50 95 Q13 78 11 55 L11 19 Q30 18 50 5Z",
  circle: "M50 5 A45 45 0 1 1 49.99 5Z",
  square:
    "M20 8 H80 Q92 8 92 20 V80 Q92 92 80 92 H20 Q8 92 8 80 V20 Q8 8 20 8Z",
  flower:
    "M50 9 C62 -1 69 13 68 22 C82 15 94 26 85 39 C101 44 99 59 84 63 C92 78 80 88 67 80 C65 97 49 99 44 84 C32 96 19 86 23 71 C7 72 2 57 16 49 C4 38 12 24 27 26 C27 11 41 4 50 9Z",
  drop: "M50 5 C63 25 87 46 87 64 A37 32 0 0 1 13 64 C13 46 37 25 50 5Z",
};
/** The swatch values the creator offers; agents must use these to show as selected. */
export const agentColors = [
  "var(--agent-avatar-1, var(--chart-1))",
  "var(--agent-avatar-2, var(--chart-2))",
  "var(--agent-avatar-3, var(--chart-3))",
  "var(--agent-avatar-4, var(--chart-4))",
  "var(--agent-avatar-5, var(--chart-5))",
  "var(--agent-avatar-6, var(--chart-1))",
  "var(--agent-avatar-7, var(--chart-2))",
  "var(--agent-avatar-8, var(--chart-3))",
  "var(--agent-avatar-9, var(--chart-4))",
  "rainbow",
];
/** Accessible names for the swatches, in the order of `agentColors`. */
const agentColorNames = [
  "Blue",
  "Teal",
  "Violet",
  "Pink",
  "Red",
  "Orange",
  "Cyan",
  "Lime",
  "Green",
  "Rainbow",
];
const fields = [
  ["name", "Name"],
  ["label", "Label"],
  ["description", "Description"],
] as const;
const languages = ["Auto-detect", "English", "French", "German"];
const voices = ["Alice", "Ben", "Clara"];
const speeds = [0.75, 1, 1.25, 1.5];

// 500ms, 12px rise with a 3px blur clearing, staggered per row. Set on a
// plain element, with the row's delay in `--delay`.
const enter = "agent-creator-01-enter";

export interface AgentProfile {
  name: string;
  label: string;
  description: string;
  shape: Shape;
  color: string;
  face: number;
  language: string;
  voice: string;
  notifications: boolean;
  /** Playback speed of the voice, from 0.75 to 1.5. Defaults to 1. */
  speed?: number;
}

/** Ink, the shading and the rainbow come from the theme with fallbacks. */
const rainbowStops = [
  "var(--agent-avatar-5, var(--destructive))",
  "var(--agent-avatar-6, var(--chart-2))",
  "var(--agent-avatar-8, var(--chart-3))",
  "var(--agent-avatar-9, var(--chart-4))",
  "var(--agent-avatar-1, var(--primary))",
  "var(--agent-avatar-3, var(--chart-5))",
  "var(--agent-avatar-4, var(--chart-1))",
];
const rainbowBase = "var(--agent-avatar-3, var(--chart-5))";
const ink = "var(--agent-avatar-ink, var(--foreground))";
const shine = "var(--agent-avatar-shine, white)";
const shade = "var(--agent-avatar-shade, black)";

const eyeY = 57;
/** Sixteen expressions: eyes, brows and a mouth, in the avatar's ink. */
function Features({ index }: { index: number }) {
  const oval = (cx: number, rx = 5.5, ry = 8) => (
    <ellipse cx={cx} cy={eyeY} rx={rx} ry={ry} />
  );
  const glint = (cx: number) => (
    <circle
      cx={cx + 1.5}
      cy={eyeY - 3}
      r="1.7"
      fill={shine}
      stroke="none"
      opacity="0.75"
    />
  );
  const face = index % 16;
  const faces: React.ReactNode[] = [
    <>
      {oval(36)}
      {oval(64)}
      {glint(36)}
      {glint(64)}
    </>,
    <path
      key="happy-eyes"
      d="M24 58 Q33 44 42 58 M58 58 Q67 44 76 58"
      fill="none"
    />,
    <>
      {oval(36, 7, 9)}
      {oval(64, 7, 9)}
      {glint(36)}
      {glint(64)}
      <path d="M26 38 L44 44 M74 38 L56 44" fill="none" />
    </>,
    <>
      {oval(36, 4.5, 6)}
      <path d="M57 58 H72" fill="none" />
      <path d="M44 74 Q50 79 56 74" fill="none" />
    </>,
    <>
      <path d="M26 56 H44 M56 56 H74" fill="none" />
      <path d="M40 74 Q50 80 60 74" fill="none" />
    </>,
    <>
      {oval(34, 8, 8)}
      {oval(66, 8, 8)}
      <circle cx="37" cy={eyeY} r="3" fill={shine} stroke="none" />
      <circle cx="69" cy={eyeY} r="3" fill={shine} stroke="none" />
    </>,
    <>
      {oval(36, 4, 9)}
      {oval(64, 4, 9)}
      <path d="M28 38 Q36 33 44 38 M56 38 Q64 33 72 38" fill="none" />
    </>,
    <>
      <path d="M26 54 Q35 64 44 54 M56 54 Q65 64 74 54" fill="none" />
      <ellipse cx="50" cy="76" rx="5" ry="4" />
    </>,
    <>
      {oval(36, 6, 6)}
      <path d="M56 58 Q65 49 74 58" fill="none" />
      <path d="M42 74 Q50 80 58 74" fill="none" />
    </>,
    <>
      {oval(34, 5, 10)}
      {oval(66, 5, 10)}
      {glint(34)}
      {glint(66)}
    </>,
    <>
      <path d="M26 52 L44 60 L26 66 M74 52 L56 60 L74 66" fill="none" />
    </>,
    <>
      {oval(36, 7, 7)}
      {oval(64, 7, 7)}
      <path d="M26 44 Q36 40 44 46 M56 46 Q64 40 74 44" fill="none" />
    </>,
    <>
      <path d="M26 60 Q35 52 44 60 M56 60 Q65 52 74 60" fill="none" />
      <path d="M44 74 H56" fill="none" />
    </>,
    <>
      {oval(36, 5, 7)}
      {oval(64, 5, 7)}
      <ellipse cx="50" cy="76" rx="4" ry="5" />
    </>,
    <>
      <path d="M28 56 H44" fill="none" />
      {oval(64, 5, 7)}
      <path d="M42 74 Q50 79 58 72" fill="none" />
    </>,
    <>
      {oval(36, 6, 6)}
      {oval(64, 6, 6)}
      <path d="M28 44 L44 40 M72 44 L56 40" fill="none" />
      <path d="M42 76 Q50 70 58 76" fill="none" />
    </>,
  ];
  const content = faces[face];
  return (
    <g
      fill={ink}
      stroke={ink}
      strokeWidth="4"
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      {content}
    </g>
  );
}

function Face({ index, color }: { index: number; color: string }) {
  return (
    <span className="relative block size-full">
      <ShapeIcon shape="circle" color={color} />
      <svg
        viewBox="0 0 100 100"
        aria-hidden="true"
        className="absolute inset-0 size-full overflow-visible"
      >
        <Features index={index} />
      </svg>
    </span>
  );
}

/**
 * A shape with depth: a stack of darker copies for the thickness, a face lit
 * from the top left and a soft highlight on the edge. A face is drawn on it
 * when `face` is set. Fills its parent unless sized.
 */
export function ShapeIcon({
  shape,
  color,
  face,
  className,
}: {
  shape: Shape;
  color: string;
  face?: number;
  className?: string;
}) {
  const id = React.useId();
  const rainbow = color === "rainbow";
  const base = rainbow ? rainbowBase : color;
  const side = `color-mix(in oklab, ${base} 66%, ${shade})`;
  const d = paths[shape];
  return (
    <svg
      viewBox="0 0 100 100"
      aria-hidden="true"
      className={cn("size-full overflow-visible", className)}
    >
      <defs>
        {rainbow && (
          <radialGradient id={`${id}r`} cx="-0.2" cy="-0.2" r="1.6">
            {rainbowStops.map((stop, i) => (
              <stop
                key={stop}
                offset={`${(i / (rainbowStops.length - 1)) * 100}%`}
                stopColor={stop}
              />
            ))}
          </radialGradient>
        )}
        <radialGradient id={`${id}l`} cx="0.05" cy="-0.1" r="1.5">
          <stop offset="0" stopColor={shine} stopOpacity="0.42" />
          <stop offset="0.5" stopColor={shine} stopOpacity="0" />
          <stop offset="1" stopColor={shade} stopOpacity="0.2" />
        </radialGradient>
        <radialGradient id={`${id}h`} cx="0.3" cy="0.22" r="0.55">
          <stop offset="0" stopColor={shine} stopOpacity="0.4" />
          <stop offset="1" stopColor={shine} stopOpacity="0" />
        </radialGradient>
      </defs>
      <path d={d} fill={shade} opacity="0.14" transform="translate(6 9)" />
      {[5, 4, 3, 2, 1].map((step) => (
        <path
          key={step}
          d={d}
          fill={side}
          transform={`translate(${step * 1.1} ${step * 1.5})`}
        />
      ))}
      <path
        d={d}
        fill={rainbow ? `url(#${id}r)` : color}
        className="transition-colors duration-200 ease-out motion-reduce:transition-none"
      />
      <path d={d} fill={`url(#${id}l)`} />
      <path d={d} fill={`url(#${id}h)`} />
      <path
        d={d}
        fill="none"
        stroke={shine}
        strokeOpacity="0.28"
        strokeWidth="1.5"
      />
      {face !== undefined && (
        <svg x="22" y="20" width="56" height="60" viewBox="0 0 100 100">
          <Features index={face} />
        </svg>
      )}
    </svg>
  );
}

/** A glossy colour dot, or the rainbow one. */
function Swatch({ color }: { color: string }) {
  const rainbow = color === "rainbow";
  return (
    <span
      className="agent-creator-01-swatch relative block size-full rounded-full bg-(image:--swatch) group-hover/toggle:scale-110"
      style={
        {
          "--swatch": rainbow
            ? `conic-gradient(from 200deg, ${rainbowStops.join(", ")}, ${rainbowStops[0]})`
            : `linear-gradient(${color}, ${color})`,
        } as React.CSSProperties
      }
    >
      <span className="agent-creator-01-swatch-shine absolute inset-0 rounded-full" />
    </span>
  );
}

// The shapes sit on a shallow arc of five slots. The window slides so the
// chosen shape is always in view; neighbours animate into their slots.
const arcSlots = 5;
const arcX = [-132, -68, 0, 68, 132];
const arcY = [0, 34, 48, 34, 0];
function arcPosition(slot: number) {
  const clamped = Math.min(Math.max(slot, 0), arcSlots - 1);
  return { x: arcX[clamped], y: arcY[clamped] };
}

export function AgentCreator({
  agent,
  onChange,
  className,
  popupClassName,
  onClose,
  bare = false,
}: {
  agent: AgentProfile;
  onChange: (agent: AgentProfile) => void;
  className?: string;
  popupClassName?: string;
  /** Shows a close button in the corner when set. */
  onClose?: () => void;
  /** Drops the card surface, for a column on the page itself. */
  bare?: boolean;
}) {
  const notificationId = React.useId();
  const [playing, setPlaying] = React.useState(false);
  const update = <K extends keyof AgentProfile>(
    key: K,
    value: AgentProfile[K]
  ) => onChange({ ...agent, [key]: value });
  const selected = shapes.indexOf(agent.shape);
  const start = Math.min(
    Math.max(selected - 2, 0),
    Math.max(shapes.length - arcSlots, 0)
  );
  const speed = agent.speed ?? 1;
  const language = languages.includes(agent.language)
    ? agent.language
    : languages[0];
  const voice = voices.includes(agent.voice) ? agent.voice : voices[0];
  return (
    <Card
      radius={18}
      size="flush"
      variant={bare ? "flat" : "default"}
      role="region"
      aria-label="Agent creator"
      className={cn(
        "@container h-[600px] w-[280px] max-w-full",
        // The inspector sits on the page, so the groups read against it.
        // oxlint-disable-next-line shadcn/no-restyle -- reference: the bare inspector has no card fill
        bare && "bg-transparent",
        className
      )}
    >
      <div className="shrink-0 px-3 pt-3">
        <CardHeader>
          <CardTitle
            // oxlint-disable-next-line shadcn/no-restyle -- reference: 14px title (the flush card does not shrink it)
            className="text-center text-sm"
          >
            {agent.name || "Untitled agent"}
          </CardTitle>
          {onClose && (
            <Button
              variant="close"
              size="icon-xs"
              tooltip="Close editor"
              className="absolute top-3 right-3 z-10"
              onClick={onClose}
            >
              <RiCloseLine />
            </Button>
          )}
        </CardHeader>
      </div>
      <div className="mx-auto mt-3 w-[320px] shrink-0 @max-[319px]:[zoom:0.86]">
        <div className="relative mx-auto aspect-square w-[260px]">
          {Array.from({ length: 16 }, (_, i) => {
            const angle = (i * 2 * Math.PI) / 16 - Math.PI / 2;
            return (
              <div
                key={i}
                style={
                  {
                    "--delay": `${i * 18}ms`,
                    "--x": `${Math.cos(angle) * 114}px`,
                    "--y": `${Math.sin(angle) * 114}px`,
                  } as React.CSSProperties
                }
                className={cn(
                  "absolute top-[calc(50%+var(--y)-17px)] left-[calc(50%+var(--x)-17px)] size-[34px]",
                  enter
                )}
              >
                <Toggle
                  aria-label={`Expression ${i + 1}`}
                  pressed={agent.face === i}
                  onPressedChange={(pressed) => {
                    if (pressed) {
                      update("face", i);
                    }
                  }}
                  variant="swatch"
                  size="icon"
                  className="size-full min-w-0"
                >
                  <span className="block size-full transition-all duration-150 ease-out group-hover/toggle:scale-110 motion-reduce:transition-none motion-reduce:group-hover/toggle:scale-100">
                    <Face index={i} color={agent.color} />
                  </span>
                </Toggle>
              </div>
            );
          })}
          <div className="absolute inset-[58px]">
            <div
              key={`${agent.shape}-${agent.face}`}
              className="agent-creator-01-shape-in size-full"
            >
              <ShapeIcon
                shape={agent.shape}
                color={agent.color}
                face={agent.face}
              />
            </div>
          </div>
        </div>
        <div
          className="relative mx-auto -mt-4 h-[112px] w-full"
          role="group"
          aria-label="Avatar shape"
        >
          {shapes.map((shape, index) => {
            const slot = index - start;
            const visible = slot >= 0 && slot < arcSlots;
            const { x, y } = arcPosition(
              Math.min(Math.max(slot, -1), arcSlots)
            );
            return (
              <div
                key={shape}
                style={
                  {
                    "--x": `${x}px`,
                    "--y": `${y}px`,
                  } as React.CSSProperties
                }
                className={cn(
                  "absolute top-[calc(var(--y)+2px)] left-[calc(50%+var(--x)-31px)] size-[62px] transition-all duration-300 ease-out motion-reduce:transition-none",
                  !visible && "pointer-events-none opacity-0"
                )}
              >
                <Toggle
                  aria-label={shape}
                  pressed={agent.shape === shape}
                  disabled={!visible}
                  onPressedChange={(pressed) => {
                    if (pressed) {
                      update("shape", shape);
                    }
                  }}
                  variant="ghost"
                  size="icon"
                  // oxlint-disable-next-line shadcn/no-restyle -- reference: the selected shape tile has a 1px border and 16px corners
                  className="size-full min-w-0 rounded-[16px] border border-transparent data-pressed:border-border"
                >
                  <span className="block size-full p-2.5">
                    <ShapeIcon shape={shape} color={agent.color} />
                  </span>
                </Toggle>
              </div>
            );
          })}
        </div>
      </div>
      <div
        className="mx-auto mt-3 flex shrink-0 justify-center gap-1.5 rounded-full bg-muted p-1.5"
        role="group"
        aria-label="Avatar color"
      >
        {agentColors.map((color, i) => (
          <div
            key={color}
            className={cn("size-6 @max-[299px]:size-5", enter)}
            style={{ "--delay": `${200 + i * 16}ms` } as React.CSSProperties}
          >
            <Toggle
              aria-label={agentColorNames[i]}
              pressed={agent.color === color}
              onPressedChange={(pressed) => {
                if (pressed) {
                  update("color", color);
                }
              }}
              variant="swatch"
              size="icon"
              className="size-full min-w-0"
            >
              <Swatch color={color} />
            </Toggle>
          </div>
        ))}
      </div>
      <div className="mt-3 min-h-0 flex-1 overflow-y-auto px-3 pb-3">
        <CardContent className="gap-4">
          <div className="rounded-[20px] bg-surface-sunken px-3">
            {fields.map(([key, label], index) => (
              <React.Fragment key={key}>
                {index > 0 && <Separator />}
                <div
                  className={cn("py-2", enter)}
                  style={
                    { "--delay": `${index * 40}ms` } as React.CSSProperties
                  }
                >
                  <Label
                    variant="inline"
                    className="grid grid-cols-[76px_1fr] items-start"
                  >
                    <span className="pt-2">{label}</span>
                    {key === "description" ? (
                      <Textarea
                        rows={2}
                        autoResize
                        maxRows={5}
                        className="min-h-[56px]"
                        value={agent[key]}
                        onChange={(event) => update(key, event.target.value)}
                      />
                    ) : (
                      <Input
                        value={agent[key]}
                        onChange={(event) => update(key, event.target.value)}
                      />
                    )}
                  </Label>
                </div>
              </React.Fragment>
            ))}
          </div>
          <div className="rounded-[20px] bg-surface-sunken px-3 text-sm">
            <div
              className={cn("flex items-center gap-2 py-3", enter)}
              style={{ "--delay": "120ms" } as React.CSSProperties}
            >
              <RiGlobalLine
                aria-hidden="true"
                className="size-5 shrink-0 text-muted-foreground @max-[248px]:hidden"
              />
              <span className="min-w-0 flex-1 truncate">Language</span>
              <PreferenceSelect
                label="Language"
                value={language}
                options={languages}
                onValueChange={(value) => update("language", value)}
                popupClassName={popupClassName}
              />
            </div>
            <Separator />
            <div
              className={cn("py-3", enter)}
              style={{ "--delay": "160ms" } as React.CSSProperties}
            >
              <Label htmlFor={notificationId} variant="inline">
                <RiNotification3Line
                  aria-hidden="true"
                  className="size-5 shrink-0 text-muted-foreground @max-[248px]:hidden"
                />
                <span className="min-w-0 flex-1">
                  <span className="block truncate">Notifications</span>
                  <span className="block truncate text-xs font-normal text-muted-foreground">
                    Show a notice when a reply is ready.
                  </span>
                </span>
                <Switch
                  id={notificationId}
                  size="sm"
                  checked={agent.notifications}
                  onCheckedChange={(checked) =>
                    update("notifications", checked)
                  }
                />
              </Label>
            </div>
          </div>
          <div className="rounded-[20px] bg-surface-sunken px-3 text-sm">
            <div
              className={cn("flex items-center gap-2 py-3", enter)}
              style={{ "--delay": "200ms" } as React.CSSProperties}
            >
              <RiVolumeUpLine
                aria-hidden="true"
                className="size-5 shrink-0 text-muted-foreground @max-[248px]:hidden"
              />
              <span className="min-w-0 flex-1 truncate">Voice</span>
              <Button
                variant="ghost-muted"
                size="icon-sm"
                aria-pressed={playing}
                tooltip={playing ? "Stop preview" : "Play preview"}
                onClick={() => setPlaying(!playing)}
              >
                {playing ? <RiPauseFill /> : <RiPlayFill />}
              </Button>
              <PreferenceSelect
                label="Voice"
                value={voice}
                options={voices}
                onValueChange={(value) => update("voice", value)}
                popupClassName={popupClassName}
              />
            </div>
            <Separator />
            <div
              className={cn("flex flex-col gap-2 py-3", enter)}
              style={{ "--delay": "240ms" } as React.CSSProperties}
            >
              <div className="flex items-center justify-between text-muted-foreground">
                <span>Playback speed</span>
                <span className="tabular-nums">{speed}x</span>
              </div>
              <Slider
                aria-label="Playback speed"
                min={speeds[0]}
                max={speeds.at(-1)}
                step={0.25}
                value={[speed]}
                showTooltip={false}
                onValueChange={(value) =>
                  update("speed", Array.isArray(value) ? value[0] : value)
                }
              />
            </div>
          </div>
        </CardContent>
      </div>
    </Card>
  );
}

function PreferenceSelect({
  label,
  value,
  options,
  onValueChange,
  popupClassName,
}: {
  label: string;
  value: string;
  options: string[];
  onValueChange: (value: string) => void;
  popupClassName?: string;
}) {
  return (
    <Select
      items={options.map((option) => ({ label: option, value: option }))}
      value={value}
      onValueChange={(next) => {
        if (next) {
          onValueChange(next);
        }
      }}
    >
      <SelectTrigger
        aria-label={label}
        size="sm"
        className="max-w-[130px] min-w-0"
      >
        <SelectValue />
      </SelectTrigger>
      <SelectContent
        side="top"
        align="end"
        alignItemWithTrigger={false}
        className={cn("w-[160px]", popupClassName)}
      >
        <SelectGroup>
          {options.map((option) => (
            <SelectItem key={option} value={option}>
              {option}
            </SelectItem>
          ))}
        </SelectGroup>
      </SelectContent>
    </Select>
  );
}