{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chat",
  "title": "Chat",
  "description": "Tabbed AI SDK chat with a reusable prompt bar, typed learning tools, streamed Markdown, and compatibility with the learning-component protocol.",
  "dependencies": [
    "@ai-sdk/gateway",
    "@ai-sdk/react",
    "@shadcn/react",
    "ai",
    "react-markdown",
    "remark-gfm",
    "zod"
  ],
  "registryDependencies": [
    "https://beadkit.dev/r/prompt-bar.json",
    "https://beadkit.dev/r/loading-state.json",
    "https://beadkit.dev/r/code-block.json",
    "https://beadkit.dev/r/learning-stream.json"
  ],
  "files": [
    {
      "path": "components/chat/BeadkitChat.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { useChat } from \"@ai-sdk/react\";\nimport { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls } from \"ai\";\nimport { PromptBar, type PromptModel } from \"@/components/ai/PromptBar\";\nimport { useReducedMotion } from \"@/components/ai/useReducedMotion\";\nimport { ChatMessage } from \"@/components/chat/ChatMessage\";\nimport { LoadingState } from \"@/components/ai/LoadingState\";\nimport type { XApiStatement } from \"@/components/learning/xapi\";\nimport { CHAT_MODELS, DEFAULT_CHAT_MODEL, type ChatModelId } from \"@/lib/chat-models\";\nimport type { ChatTools, ChatUIMessage } from \"@/lib/chat-tools\";\n\nconst suggestions = [\n  \"Teach me HTTP caching with a quick exercise\",\n  \"Quiz me on JavaScript promises\",\n  \"Help me understand the scientific method\",\n];\n\nconst promptModels: readonly PromptModel[] = CHAT_MODELS.map((item) => ({\n  id: item.id,\n  name: item.label,\n  description: item.description,\n}));\n\nfunction readableChatError(error: Error) {\n  try {\n    const body = JSON.parse(error.message) as { error?: unknown };\n    if (typeof body.error === \"string\") return body.error;\n  } catch {\n    // Network and stream errors are already human-readable.\n  }\n  return error.message;\n}\n\nexport function BeadkitChat() {\n  const reducedMotion = useReducedMotion();\n  const [input, setInput] = useState(\"\");\n  const [model, setModel] = useState<ChatModelId>(DEFAULT_CHAT_MODEL);\n  const [view, setView] = useState<\"conversation\" | \"session\">(\"conversation\");\n  const completedTools = useRef(new Set<string>());\n  const conversationRef = useRef<HTMLDivElement>(null);\n  const { messages, sendMessage, addToolOutput, status, stop, error } =\n    useChat<ChatUIMessage>({\n      transport: new DefaultChatTransport({ api: \"/api/chat\" }),\n      sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,\n    });\n\n  const busy = status === \"submitted\" || status === \"streaming\";\n  const toolCount = messages.reduce(\n    (total, message) => total + message.parts.filter((part) => part.type.startsWith(\"tool-\")).length,\n    0\n  );\n\n  useEffect(() => {\n    if (view !== \"conversation\") return;\n    const conversation = conversationRef.current;\n    if (!conversation) return;\n    const distanceFromBottom = conversation.scrollHeight - conversation.clientHeight - conversation.scrollTop;\n    if (distanceFromBottom > 120) return;\n    conversation.scrollTo({\n      top: conversation.scrollHeight,\n      behavior: status === \"streaming\" || reducedMotion ? \"auto\" : \"smooth\",\n    });\n  }, [messages, reducedMotion, status, view]);\n\n  function submit(text: string) {\n    const trimmed = text.trim();\n    if (!trimmed || busy) return;\n    void sendMessage({ text: trimmed }, { body: { model } });\n    setInput(\"\");\n  }\n\n  function onLearningComplete(toolCallId: string, statement: XApiStatement) {\n    if (completedTools.current.has(toolCallId)) return;\n    completedTools.current.add(toolCallId);\n    console.info(\"[beadkit:xapi]\", statement);\n    void addToolOutput({\n      tool: \"learning_activity\",\n      toolCallId,\n      output: {\n        interaction: \"completed\",\n        ...(statement.result.response !== undefined && { response: statement.result.response }),\n        ...(statement.result.success !== undefined && { success: statement.result.success }),\n      },\n      options: { body: { model } },\n    });\n  }\n\n  function onQuestionsComplete(\n    toolCallId: string,\n    output: ChatTools[\"ask_user\"][\"output\"]\n  ) {\n    if (completedTools.current.has(toolCallId)) return;\n    completedTools.current.add(toolCallId);\n    void addToolOutput({\n      tool: \"ask_user\",\n      toolCallId,\n      output,\n      options: { body: { model } },\n    });\n  }\n\n  return (\n    <div data-ai-card=\"chat\" className=\"bg-card flex min-h-[620px] w-full flex-col overflow-hidden rounded-xl border shadow-sm\">\n      <div className=\"flex flex-col items-stretch gap-2 border-b px-3 py-2.5 sm:flex-row sm:items-center sm:gap-3 sm:px-4\">\n        <div className=\"min-w-0\">\n          <p className=\"font-heading truncate text-sm font-semibold\">Learning playground</p>\n          <p className=\"text-muted-foreground truncate text-[11px]\">Typed UI tools over AI Gateway</p>\n        </div>\n        <div className=\"bg-muted flex rounded-lg p-1 sm:ml-auto\" role=\"tablist\" aria-label=\"Chat view\">\n          {([\"conversation\", \"session\"] as const).map((item) => (\n            <button\n              key={item}\n              type=\"button\"\n              role=\"tab\"\n              aria-selected={view === item}\n              aria-controls={`chat-${item}`}\n              onClick={() => setView(item)}\n              className={`min-h-8 flex-1 rounded-md px-2.5 text-xs font-medium capitalize transition-colors motion-reduce:transition-none sm:flex-none ${\n                view === item ? \"bg-card text-foreground shadow-sm\" : \"text-muted-foreground hover:text-foreground\"\n              }`}\n            >\n              {item}\n            </button>\n          ))}\n        </div>\n      </div>\n\n      <div\n        ref={conversationRef}\n        id=\"chat-conversation\"\n        role=\"tabpanel\"\n        hidden={view !== \"conversation\"}\n        className=\"flex-1 space-y-6 overflow-y-auto p-4 sm:p-6\"\n      >\n        {messages.length === 0 ? (\n          <div className=\"flex min-h-80 flex-col items-center justify-center text-center\">\n            <div className=\"bg-primary text-primary-foreground mb-4 flex size-9 items-center justify-center rounded-lg font-mono text-sm\">\n              B\n            </div>\n            <h2 className=\"font-heading text-lg font-semibold\">What would you like to learn?</h2>\n            <p className=\"text-muted-foreground mt-1 max-w-sm text-sm\">\n              The tutor can explain in prose or place a native learning interaction directly in the conversation.\n            </p>\n            <div className=\"mt-5 flex max-w-lg flex-wrap justify-center gap-2\">\n              {suggestions.map((suggestion) => (\n                <button\n                  key={suggestion}\n                  type=\"button\"\n                  onClick={() => submit(suggestion)}\n                  className=\"hover:bg-muted rounded-full border px-3 py-1.5 text-xs transition\"\n                >\n                  {suggestion}\n                </button>\n              ))}\n            </div>\n          </div>\n        ) : (\n          messages.map((message) => (\n            <ChatMessage\n              key={message.id}\n              message={message}\n              onLearningComplete={onLearningComplete}\n              onQuestionsComplete={onQuestionsComplete}\n            />\n          ))\n        )}\n\n        {status === \"submitted\" ? (\n          <LoadingState label=\"Tutor is thinking\" variant=\"drive\" />\n        ) : null}\n        {error ? (\n          <p className=\"text-destructive rounded-lg border p-3 text-xs\" role=\"alert\">\n            {readableChatError(error)}\n          </p>\n        ) : null}\n      </div>\n\n      <div\n        id=\"chat-session\"\n        role=\"tabpanel\"\n        hidden={view !== \"session\"}\n        className=\"flex-1 overflow-y-auto p-4 sm:p-6\"\n      >\n        <div className=\"grid gap-3 sm:grid-cols-3\">\n          {[\n            [\"Messages\", String(messages.length)],\n            [\"UI tool calls\", String(toolCount)],\n            [\"Status\", busy ? \"Working\" : error ? \"Needs attention\" : \"Ready\"],\n          ].map(([label, value]) => (\n            <div key={label} className=\"bg-muted rounded-xl border p-3\">\n              <p className=\"text-muted-foreground text-[11px]\">{label}</p>\n              <p className=\"mt-1 text-sm font-medium\">{value}</p>\n            </div>\n          ))}\n        </div>\n        <div className=\"mt-4 rounded-xl border p-4\">\n          <p className=\"text-muted-foreground text-xs\">Active model</p>\n          <p className=\"mt-1 text-sm font-medium\">\n            {CHAT_MODELS.find((item) => item.id === model)?.label}\n          </p>\n          <p className=\"text-muted-foreground mt-1 text-xs\">\n            Learning activities and setup questions travel as typed tool calls. Compatibility\n            clients can continue to use the streaming tag protocol.\n          </p>\n        </div>\n      </div>\n\n      <div className=\"border-t p-3\">\n        <PromptBar\n          value={input}\n          onChange={setInput}\n          onSubmit={(value) => submit(value)}\n          models={promptModels}\n          selectedModel={promptModels.find((item) => item.id === model)}\n          onSelectModel={(item) => setModel(item.id as ChatModelId)}\n          placeholder=\"Ask to learn or practice a topic…\"\n          busy={busy}\n          onStop={() => void stop()}\n        />\n        <p className=\"text-muted-foreground mt-2 px-1 text-[10px]\">\n          20 requests per hour per visitor. Playground responses may be inaccurate.\n        </p>\n      </div>\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {status === \"submitted\" ? \"Tutor is thinking.\" : status === \"streaming\" ? \"Tutor is responding.\" : \"\"}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/chat/BeadkitChat.tsx"
    },
    {
      "path": "components/chat/ChatMessage.tsx",
      "content": "\"use client\";\n\nimport ReactMarkdown from \"react-markdown\";\nimport remarkGfm from \"remark-gfm\";\nimport { CodeBlock, type CodeLine } from \"@/components/ai/CodeBlock\";\nimport { LearningActivityPart } from \"@/components/chat/LearningActivityPart\";\nimport { QuestionCard } from \"@/components/chat/QuestionCard\";\nimport type { XApiStatement } from \"@/components/learning/xapi\";\nimport type { ChatTools, ChatUIMessage } from \"@/lib/chat-tools\";\n\ninterface ChatMessageProps {\n  message: ChatUIMessage;\n  onLearningComplete: (toolCallId: string, statement: XApiStatement) => void;\n  onQuestionsComplete: (\n    toolCallId: string,\n    output: ChatTools[\"ask_user\"][\"output\"]\n  ) => void;\n}\n\nexport function ChatMessage({\n  message,\n  onLearningComplete,\n  onQuestionsComplete,\n}: ChatMessageProps) {\n  const isUser = message.role === \"user\";\n\n  return (\n    <article className={isUser ? \"flex justify-end\" : \"\"}>\n      <div\n        className={\n          isUser\n            ? \"bg-primary text-primary-foreground max-w-[85%] rounded-2xl rounded-br-sm px-4 py-2.5 text-sm\"\n            : \"max-w-full text-sm leading-6\"\n        }\n      >\n        {message.parts.map((part, index) => {\n          if (part.type === \"text\") {\n            return isUser ? (\n              <p key={index} className=\"whitespace-pre-wrap\">\n                {part.text}\n              </p>\n            ) : (\n              <ReactMarkdown\n                key={index}\n                remarkPlugins={[remarkGfm]}\n                components={{\n                  p: ({ children }) => <p className=\"mb-3 last:mb-0\">{children}</p>,\n                  ul: ({ children }) => <ul className=\"mb-3 list-disc pl-5\">{children}</ul>,\n                  ol: ({ children }) => <ol className=\"mb-3 list-decimal pl-5\">{children}</ol>,\n                  pre: ({ children }) => <>{children}</>,\n                  code: ({ children, className }) => {\n                    const rawCode = String(children).replace(/\\n$/, \"\");\n                    const language = /language-([\\w-]+)/.exec(className ?? \"\")?.[1];\n                    const block = Boolean(language) || rawCode.includes(\"\\n\");\n                    if (!block) {\n                      return <code className=\"bg-muted rounded px-1 py-0.5 font-mono text-xs\">{children}</code>;\n                    }\n                    const lines: CodeLine[] = rawCode.split(\"\\n\").map((text) => ({\n                      tokens: [{ text, tone: \"plain\" }],\n                    }));\n                    return (\n                      <CodeBlock\n                        filename={`snippet.${language ?? \"txt\"}`}\n                        language={language}\n                        lines={lines}\n                        rawCode={rawCode}\n                        autoplay={false}\n                        status=\"settled\"\n                        className=\"my-3 max-w-full\"\n                      />\n                    );\n                  },\n                }}\n              >\n                {part.text}\n              </ReactMarkdown>\n            );\n          }\n\n          if (part.type === \"tool-learning_activity\") {\n            if (part.state === \"input-streaming\") {\n              return (\n                <LearningActivityPart\n                  key={part.toolCallId}\n                  state=\"input-streaming\"\n                  toolCallId={part.toolCallId}\n                  onComplete={(statement) => onLearningComplete(part.toolCallId, statement)}\n                />\n              );\n            }\n            if (\n              part.state === \"approval-requested\" ||\n              part.state === \"approval-responded\" ||\n              part.state === \"output-denied\"\n            ) {\n              return (\n                <p key={part.toolCallId} className=\"text-muted-foreground mt-3 text-xs\">\n                  This activity is waiting for tool approval.\n                </p>\n              );\n            }\n            return (\n              <LearningActivityPart\n                key={part.toolCallId}\n                input={part.input}\n                state={part.state}\n                toolCallId={part.toolCallId}\n                errorText={part.state === \"output-error\" ? part.errorText : undefined}\n                onComplete={(statement) => onLearningComplete(part.toolCallId, statement)}\n              />\n            );\n          }\n\n          if (part.type === \"tool-ask_user\") {\n            if (part.state === \"input-streaming\") {\n              return (\n                <div\n                  key={part.toolCallId}\n                  className=\"bg-muted mt-3 h-32 w-full max-w-md animate-pulse rounded-xl border\"\n                />\n              );\n            }\n            if (part.state === \"output-error\") {\n              return (\n                <p key={part.toolCallId} className=\"text-destructive mt-3\" role=\"alert\">\n                  {part.errorText}\n                </p>\n              );\n            }\n            if (\n              part.state === \"approval-requested\" ||\n              part.state === \"approval-responded\" ||\n              part.state === \"output-denied\"\n            ) {\n              return (\n                <p key={part.toolCallId} className=\"text-muted-foreground mt-3 text-xs\">\n                  This questionnaire is waiting for tool approval.\n                </p>\n              );\n            }\n            return (\n              <QuestionCard\n                key={part.toolCallId}\n                input={part.input}\n                output={part.state === \"output-available\" ? part.output : undefined}\n                disabled={part.state === \"output-available\"}\n                onAnswer={(output) => onQuestionsComplete(part.toolCallId, output)}\n              />\n            );\n          }\n\n          return null;\n        })}\n      </div>\n    </article>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/chat/ChatMessage.tsx"
    },
    {
      "path": "components/chat/LearningActivityPart.tsx",
      "content": "\"use client\";\n\nimport { registry } from \"@/components/learning/registry\";\nimport type { XApiStatement } from \"@/components/learning/xapi\";\nimport type { LearningActivity } from \"@/lib/chat-tools\";\n\ninterface LearningActivityPartProps {\n  input?: LearningActivity;\n  state: \"input-streaming\" | \"input-available\" | \"output-available\" | \"output-error\";\n  toolCallId: string;\n  errorText?: string;\n  onComplete: (statement: XApiStatement) => void;\n}\n\nexport function LearningActivityPart({\n  input,\n  state,\n  toolCallId,\n  errorText,\n  onComplete,\n}: LearningActivityPartProps) {\n  if (state === \"input-streaming\" || !input) {\n    return (\n      <div className=\"bg-muted mt-3 h-36 w-full max-w-md animate-pulse rounded-xl border\" role=\"status\">\n        <span className=\"sr-only\">Preparing a learning activity…</span>\n      </div>\n    );\n  }\n\n  if (state === \"output-error\") {\n    return (\n      <p className=\"text-destructive mt-3 text-sm\" role=\"alert\">\n        {errorText || \"This learning activity could not be rendered.\"}\n      </p>\n    );\n  }\n\n  if (!(input.type in registry)) {\n    return (\n      <p className=\"text-destructive mt-3 text-sm\" role=\"alert\">\n        The tutor returned an unknown activity type. Try asking it to regenerate the exercise.\n      </p>\n    );\n  }\n\n  const rendered = registry[input.type].parseAndRender(\n    input as unknown as Record<string, unknown>,\n    `chat-${toolCallId}`,\n    onComplete\n  );\n\n  return rendered ? (\n    <div className=\"mt-3\">{rendered}</div>\n  ) : (\n    <p className=\"text-destructive mt-3 text-sm\" role=\"alert\">\n      The tutor returned an invalid activity. Try asking it to regenerate the exercise.\n    </p>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/chat/LearningActivityPart.tsx"
    },
    {
      "path": "components/chat/QuestionCard.tsx",
      "content": "\"use client\";\n\nimport { Questionnaire } from \"@shadcn/react/questionnaire\";\nimport type { ChatTools } from \"@/lib/chat-tools\";\n\ntype QuestionnaireInput = ChatTools[\"ask_user\"][\"input\"];\ntype QuestionnaireOutput = ChatTools[\"ask_user\"][\"output\"];\n\ninterface QuestionCardProps {\n  input: QuestionnaireInput;\n  output?: QuestionnaireOutput;\n  disabled?: boolean;\n  onAnswer: (output: QuestionnaireOutput) => void;\n}\n\nconst buttonClass =\n  \"rounded-md border px-3 py-1.5 text-xs font-medium transition hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50\";\n\nexport function QuestionCard({ input, output, disabled, onAnswer }: QuestionCardProps) {\n  const items = input.questions.map((question) => ({\n    name: question.id,\n    required: true,\n    disabled,\n    choices: question.choices.map(({ value }) => ({ value })),\n  }));\n\n  return (\n    <Questionnaire.Root\n      className=\"bg-card mt-3 w-full max-w-md rounded-xl border p-4 shadow-sm\"\n      defaultItem={input.questions[0].id}\n      items={items}\n      shortcuts=\"letters\"\n      onSubmit={(event) => {\n        event.preventDefault();\n        if (disabled) return;\n        const values = new FormData(event.currentTarget);\n        const answers = Object.fromEntries(\n          input.questions.map(({ id }) => [id, String(values.get(id) || \"\")])\n        );\n        if (Object.values(answers).some((answer) => !answer)) return;\n        onAnswer({ answers });\n      }}\n    >\n      <Questionnaire.Progress className=\"text-muted-foreground mb-3 font-mono text-[11px] uppercase tracking-wide\">\n        Tutor setup\n      </Questionnaire.Progress>\n\n      {input.questions.map((question) => (\n        <Questionnaire.Item key={question.id} name={question.id} required disabled={disabled}>\n          <Questionnaire.Title className=\"font-heading text-sm font-semibold\">\n            {question.prompt}\n          </Questionnaire.Title>\n          {question.description ? (\n            <Questionnaire.Description className=\"text-muted-foreground mt-1 text-xs\">\n              {question.description}\n            </Questionnaire.Description>\n          ) : null}\n          <Questionnaire.Choices className=\"mt-3 grid gap-2\">\n            {question.choices.map((choice) => (\n              <Questionnaire.Choice\n                key={choice.value}\n                value={choice.value}\n                defaultChecked={output?.answers[question.id] === choice.value}\n                className=\"has-checked:border-accent has-checked:bg-accent/5 hover:bg-muted flex cursor-pointer items-center gap-2 rounded-lg border p-3 text-sm transition\"\n              >\n                <Questionnaire.ChoiceInput className=\"accent-accent size-4\" />\n                <Questionnaire.ChoiceLabel>{choice.label}</Questionnaire.ChoiceLabel>\n                <Questionnaire.ChoiceShortcut className=\"text-muted-foreground ml-auto font-mono text-[10px]\" />\n              </Questionnaire.Choice>\n            ))}\n          </Questionnaire.Choices>\n          <Questionnaire.Error className=\"text-destructive mt-2 text-xs\">\n            Choose one option to continue.\n          </Questionnaire.Error>\n        </Questionnaire.Item>\n      ))}\n\n      <div className=\"mt-4 flex gap-2\">\n        <Questionnaire.Previous className={buttonClass}>Back</Questionnaire.Previous>\n        <Questionnaire.Next className={`${buttonClass} ml-auto`}>Next</Questionnaire.Next>\n        <Questionnaire.Submit\n          disabled={disabled}\n          className={`${buttonClass} bg-primary text-primary-foreground ml-auto`}\n        >\n          Send answers\n        </Questionnaire.Submit>\n      </div>\n    </Questionnaire.Root>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/chat/QuestionCard.tsx"
    },
    {
      "path": "lib/chat-models.ts",
      "content": "export const CHAT_MODELS = [\n  {\n    id: \"openai/gpt-4o-mini\",\n    label: \"GPT-4o mini\",\n    description: \"Fast, inexpensive default\",\n  },\n  {\n    id: \"openai/gpt-4o\",\n    label: \"GPT-4o\",\n    description: \"Stronger instruction following\",\n  },\n] as const;\n\nexport type ChatModelId = (typeof CHAT_MODELS)[number][\"id\"];\n\nexport const DEFAULT_CHAT_MODEL: ChatModelId = CHAT_MODELS[0].id;\n\nexport function isChatModelId(value: unknown): value is ChatModelId {\n  return typeof value === \"string\" && CHAT_MODELS.some((model) => model.id === value);\n}\n",
      "type": "registry:lib",
      "target": "@lib/chat-models.ts"
    },
    {
      "path": "lib/chat-tools.ts",
      "content": "import { tool, type InferUITools, type UIMessage } from \"ai\";\nimport { z } from \"zod\";\n\nconst nonEmpty = z.string().trim().min(1);\n\nconst quizSchema = z.object({\n  type: z.literal(\"quiz\"),\n  prompt: nonEmpty,\n  options: z\n    .array(\n      z.object({\n        id: nonEmpty,\n        text: nonEmpty,\n        valid: z.boolean(),\n        feedback: z.string(),\n      })\n    )\n    .min(2)\n    .refine((options) => new Set(options.map(({ id }) => id)).size === options.length, {\n      message: \"Quiz option ids must be unique\",\n    })\n    .refine((options) => options.some(({ valid }) => valid), {\n      message: \"A quiz needs at least one correct option\",\n    }),\n});\n\nconst flashcardSchema = z.object({\n  type: z.literal(\"flashcard\"),\n  front: nonEmpty,\n  back: nonEmpty,\n});\n\nconst sequenceSchema = z.object({\n  type: z.literal(\"sequence\"),\n  prompt: nonEmpty,\n  steps: z\n    .array(z.object({ id: nonEmpty, text: nonEmpty }))\n    .min(3)\n    .refine((steps) => new Set(steps.map(({ id }) => id)).size === steps.length, {\n      message: \"Sequence step ids must be unique\",\n    }),\n});\n\nconst masterySchema = z.object({\n  type: z.literal(\"mastery\"),\n  title: nonEmpty,\n  skills: z\n    .array(z.object({ id: nonEmpty, label: nonEmpty, level: z.number().min(0).max(1) }))\n    .min(1)\n    .refine((skills) => new Set(skills.map(({ id }) => id)).size === skills.length, {\n      message: \"Mastery skill ids must be unique\",\n    }),\n});\n\nexport const learningActivitySchema = z.discriminatedUnion(\"type\", [\n  quizSchema,\n  flashcardSchema,\n  sequenceSchema,\n  masterySchema,\n]);\n\nconst learningResultSchema = z.object({\n  interaction: z.literal(\"completed\"),\n  response: z.string().optional(),\n  success: z.boolean().optional(),\n});\n\nconst questionSchema = z.object({\n  id: nonEmpty,\n  prompt: nonEmpty,\n  description: z.string().optional(),\n  choices: z\n    .array(z.object({ value: nonEmpty, label: nonEmpty }))\n    .length(3)\n    .refine((choices) => new Set(choices.map(({ value }) => value)).size === choices.length, {\n      message: \"Choice values must be unique\",\n    }),\n});\n\nexport const chatTools = {\n  learning_activity: tool({\n    description:\n      \"Render one native Beadkit learning activity. The learner interacts with it and the client returns the result.\",\n    inputSchema: learningActivitySchema,\n    outputSchema: learningResultSchema,\n  }),\n  ask_user: tool({\n    description:\n      \"Ask concise clarification or preference questions. Each question must offer exactly three distinct choices.\",\n    inputSchema: z.object({\n      questions: z\n        .array(questionSchema)\n        .min(1)\n        .max(3)\n        .refine(\n          (questions) => new Set(questions.map(({ id }) => id)).size === questions.length,\n          { message: \"Question ids must be unique\" }\n        ),\n    }),\n    outputSchema: z.object({ answers: z.record(z.string(), z.string()) }),\n  }),\n};\n\nexport type ChatTools = InferUITools<typeof chatTools>;\nexport type ChatUIMessage = UIMessage<unknown, never, ChatTools>;\nexport type LearningActivity = z.infer<typeof learningActivitySchema>;\n",
      "type": "registry:lib",
      "target": "@lib/chat-tools.ts"
    },
    {
      "path": "lib/chat-prompt.ts",
      "content": "export const CHAT_SYSTEM_PROMPT = `You are a concise, encouraging tutor inside the Beadkit learning-component playground.\n\nTeach the learner directly and adapt to their responses. Keep prose short enough that the interaction stays active.\n\nYou have two UI tools:\n- learning_activity renders one native quiz, flashcard, sequence builder, or mastery meter. Use it when an interaction would teach or assess better than prose. Wait for its result before responding to the learner's action.\n- ask_user asks one or more short preference or clarification questions. Use it only when the learner's goal, level, or desired format is genuinely ambiguous.\n\nRules for learning_activity:\n- Choose exactly one of the four activity shapes and fill it with accurate, self-contained content.\n- Quiz options need stable short ids, useful feedback for every choice, and at least one correct option.\n- Sequence steps must be supplied in the correct order; the component handles shuffling.\n- Mastery values are estimates between 0 and 1, never claims of formal assessment.\n- Do not call more than one UI tool in a single step.\n\nNever print <LEARNING_COMPONENT> tags or raw component JSON. Those tags remain a compatibility protocol for clients without tool calling; this chat uses typed tools instead.`;\n",
      "type": "registry:lib",
      "target": "@lib/chat-prompt.ts"
    },
    {
      "path": "lib/chat-rate-limit.ts",
      "content": "const WINDOW_MS = 60 * 60 * 1000;\nconst REQUESTS_PER_WINDOW = 20;\n\ninterface Bucket {\n  count: number;\n  resetAt: number;\n}\n\nconst buckets = new Map<string, Bucket>();\n\nexport interface RateLimitResult {\n  allowed: boolean;\n  limit: number;\n  remaining: number;\n  retryAfterSeconds: number;\n}\n\nexport function clientAddress(request: Request): string {\n  const forwarded = request.headers.get(\"x-forwarded-for\")?.split(\",\")[0]?.trim();\n  return forwarded || request.headers.get(\"x-real-ip\") || \"local\";\n}\n\n/**\n * Small single-instance guard for the public playground. Production deployments\n * that need strict global limits should replace this with a shared store.\n */\nexport function takeChatRequest(key: string, now = Date.now()): RateLimitResult {\n  const current = buckets.get(key);\n  const bucket = !current || current.resetAt <= now\n    ? { count: 0, resetAt: now + WINDOW_MS }\n    : current;\n\n  if (bucket.count >= REQUESTS_PER_WINDOW) {\n    return {\n      allowed: false,\n      limit: REQUESTS_PER_WINDOW,\n      remaining: 0,\n      retryAfterSeconds: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)),\n    };\n  }\n\n  bucket.count += 1;\n  buckets.set(key, bucket);\n  return {\n    allowed: true,\n    limit: REQUESTS_PER_WINDOW,\n    remaining: REQUESTS_PER_WINDOW - bucket.count,\n    retryAfterSeconds: 0,\n  };\n}\n",
      "type": "registry:lib",
      "target": "@lib/chat-rate-limit.ts"
    },
    {
      "path": "app/api/chat/route.ts",
      "content": "import {\n  convertToModelMessages,\n  createUIMessageStreamResponse,\n  isStepCount,\n  streamText,\n  toUIMessageStream,\n  validateUIMessages,\n} from \"ai\";\nimport { z } from \"zod\";\nimport { isChatModelId } from \"@/lib/chat-models\";\nimport { CHAT_SYSTEM_PROMPT } from \"@/lib/chat-prompt\";\nimport { clientAddress, takeChatRequest } from \"@/lib/chat-rate-limit\";\nimport { chatTools, type ChatUIMessage } from \"@/lib/chat-tools\";\n\nexport const runtime = \"nodejs\";\nexport const maxDuration = 30;\n\nconst requestSchema = z.object({\n  messages: z.unknown(),\n  model: z.unknown(),\n});\n\nfunction errorResponse(message: string, status: number, headers?: HeadersInit) {\n  return Response.json({ error: message }, { status, headers });\n}\n\nexport async function POST(request: Request) {\n  const limit = takeChatRequest(clientAddress(request));\n  const rateHeaders = {\n    \"X-RateLimit-Limit\": String(limit.limit),\n    \"X-RateLimit-Remaining\": String(limit.remaining),\n  };\n\n  if (!limit.allowed) {\n    return errorResponse(\"Chat limit reached. Please try again later.\", 429, {\n      ...rateHeaders,\n      \"Retry-After\": String(limit.retryAfterSeconds),\n    });\n  }\n\n  let body: z.infer<typeof requestSchema>;\n  try {\n    body = requestSchema.parse(await request.json());\n  } catch {\n    return errorResponse(\"Invalid chat request.\", 400, rateHeaders);\n  }\n\n  if (!isChatModelId(body.model)) {\n    return errorResponse(\"Unsupported model.\", 400, rateHeaders);\n  }\n\n  let messages: ChatUIMessage[];\n  try {\n    messages = await validateUIMessages<ChatUIMessage>({\n      messages: body.messages,\n      tools: chatTools,\n    });\n  } catch {\n    return errorResponse(\"Invalid chat messages.\", 400, rateHeaders);\n  }\n\n  const result = streamText({\n    model: body.model,\n    system: CHAT_SYSTEM_PROMPT,\n    messages: await convertToModelMessages(messages, { tools: chatTools }),\n    tools: chatTools,\n    stopWhen: isStepCount(5),\n    maxOutputTokens: 1_800,\n    abortSignal: request.signal,\n    providerOptions: {\n      gateway: { only: [\"openai\"] },\n    },\n  });\n\n  return createUIMessageStreamResponse({\n    headers: rateHeaders,\n    stream: toUIMessageStream({\n      stream: result.stream,\n      tools: chatTools,\n      originalMessages: messages,\n      onError: () => \"The tutor could not complete that response. Please try again.\",\n    }),\n  });\n}\n",
      "type": "registry:file",
      "target": "app/api/chat/route.ts"
    }
  ],
  "type": "registry:component"
}
