├── .node-version
├── agent
├── chatbot
│ ├── __init__.py
│ ├── db.py
│ ├── agent.py
│ ├── data.py
│ └── server.py
├── .gitignore
├── README.md
└── pyproject.toml
├── .npmrc
├── src
├── vite-env.d.ts
├── types.ts
├── lib
│ ├── utils.ts
│ └── tool-icons.tsx
├── main.tsx
├── components
│ ├── ui
│ │ ├── skeleton.tsx
│ │ ├── separator.tsx
│ │ ├── collapsible.tsx
│ │ ├── textarea.tsx
│ │ ├── input.tsx
│ │ ├── avatar.tsx
│ │ ├── sonner.tsx
│ │ ├── switch.tsx
│ │ ├── checkbox.tsx
│ │ ├── hover-card.tsx
│ │ ├── badge.tsx
│ │ ├── scroll-area.tsx
│ │ ├── tooltip.tsx
│ │ ├── button.tsx
│ │ ├── sheet.tsx
│ │ ├── dialog.tsx
│ │ ├── carousel.tsx
│ │ ├── select.tsx
│ │ ├── dropdown-menu.tsx
│ │ └── sidebar.tsx
│ ├── ai-elements
│ │ ├── image.tsx
│ │ ├── response.tsx
│ │ ├── suggestion.tsx
│ │ ├── actions.tsx
│ │ ├── message.tsx
│ │ ├── conversation.tsx
│ │ ├── loader.tsx
│ │ ├── sources.tsx
│ │ ├── task.tsx
│ │ ├── tool.tsx
│ │ ├── code-block.tsx
│ │ ├── reasoning.tsx
│ │ ├── branch.tsx
│ │ ├── prompt-input.tsx
│ │ ├── inline-citation.tsx
│ │ └── web-preview.tsx
│ ├── theme-provider.tsx
│ ├── mode-toggle.tsx
│ └── app-sidebar.tsx
├── hooks
│ ├── use-mobile.ts
│ └── useConversationIdFromUrl.tsx
├── assets
│ └── logo.svg
├── App.tsx
├── Part.tsx
├── index.css
└── Chat.tsx
├── .prettierignore
├── public
├── favicon.ico
├── favicon.png
├── apple-touch-icon.png
└── favicon.svg
├── .prettierrc.json
├── .zed
└── settings.json
├── specs
├── update-color-scheme.md
├── update-tools-dropdown.md
└── delete-past-chats.md
├── README.md
├── .gitignore
├── components.json
├── index.html
├── tsconfig.json
├── vite.config.ts
├── .pre-commit-config.yaml
├── eslint.config.ts
├── package.json
└── CLAUDE.md
/.node-version:
--------------------------------------------------------------------------------
1 | lts/latest
2 |
--------------------------------------------------------------------------------
/agent/chatbot/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | shamefully-hoist=true
2 | ignore-workspace=true
3 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | .venv/
2 | .dist/
3 | node_modules/
4 | __pycache__/
5 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pydantic/ai-chat-ui/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pydantic/ai-chat-ui/HEAD/public/favicon.png
--------------------------------------------------------------------------------
/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pydantic/ai-chat-ui/HEAD/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | export interface ConversationEntry {
2 | id: string
3 | firstMessage?: string
4 | timestamp: number
5 | }
6 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "semi": false,
4 | "trailingComma": "all",
5 | "tabWidth": 2,
6 | "printWidth": 119,
7 | "bracketSpacing": true
8 | }
9 |
--------------------------------------------------------------------------------
/agent/.gitignore:
--------------------------------------------------------------------------------
1 | typings/
2 |
3 | # Python-generated files
4 | __pycache__/
5 | *.py[oc]
6 | build/
7 | dist/
8 | wheels/
9 | *.egg-info
10 |
11 | # Virtual environments
12 | .venv
13 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from 'clsx'
2 | import { twMerge } from 'tailwind-merge'
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/.zed/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "languages": {
3 | "TSX": {
4 | "formatter": {
5 | "external": {
6 | "command": "npx",
7 | "arguments": ["prettier", "--stdin-filepath", "{buffer_path}"]
8 | }
9 | }
10 | }
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import './index.css'
4 | import App from './App.tsx'
5 |
6 | createRoot(document.getElementById('root')!).render(
7 |
8 |
9 | ,
10 | )
11 |
--------------------------------------------------------------------------------
/src/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { cn } from '@/lib/utils'
3 |
4 | function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
5 | return
6 | }
7 |
8 | export { Skeleton }
9 |
--------------------------------------------------------------------------------
/specs/update-color-scheme.md:
--------------------------------------------------------------------------------
1 | # Apply branded colors to the UI
2 |
3 | - Try to use `#04bd88` as a primary color in both dark and light mode.
4 |
5 | ```
6 | // ignore for now,
7 | // - Try `rgb(119 255 216)` as a primary color in dark mode.
8 | ```
9 |
10 | ## Implementation Details
11 |
12 | Convert the colors using makeshift node scripts.
13 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Pydantic AI Chat package
2 |
3 | Example React frontend for Pydantic AI Chat using [Vercel AI Elements](https://vercel.com/changelog/introducing-ai-elements).
4 |
5 | ## Dev
6 |
7 | ```sh
8 | npm install
9 | npm run dev
10 |
11 | # stop your logfire platform, to avoid port 8000 conflicts
12 |
13 | cd agent && uv run uvicorn chatbot.server:app
14 | ```
15 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 | /.claude/settings.local.json
26 | /scratch/
27 | /agent/.venv/
28 | /tsconfig.tsbuildinfo
29 | .env
30 | *.tgz
31 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "",
8 | "css": "src/index.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "iconLibrary": "lucide",
14 | "aliases": {
15 | "components": "@/components",
16 | "utils": "@/lib/utils",
17 | "ui": "@/components/ui",
18 | "lib": "@/lib",
19 | "hooks": "@/hooks"
20 | },
21 | "registries": {}
22 | }
23 |
--------------------------------------------------------------------------------
/specs/update-tools-dropdown.md:
--------------------------------------------------------------------------------
1 | # Improve the tools dropdown
2 |
3 | - Replace the trigger with a button that uses the lucide icon for settings, add tooltip for tools. Use shadcn for that.
4 | - Hard-code some icons for known tools. These are the tools we know right now. Pick some icons from lucide.
5 |
6 | ```ts
7 | ;['web_search', 'code_execution', 'image_generation']
8 | ```
9 |
10 | For other tools, use the default wrench icon.
11 |
12 | - Change the dropdown from checkboxes to icons on the left, and switch component on the right, use shadcn for that.
13 |
--------------------------------------------------------------------------------
/src/components/ai-elements/image.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable camelcase */
2 | import { cn } from '@/lib/utils'
3 | import type { Experimental_GeneratedImage } from 'ai'
4 |
5 | export type ImageProps = Experimental_GeneratedImage & {
6 | className?: string
7 | alt?: string
8 | }
9 |
10 | export const Image = ({ base64, uint8Array, mediaType, ...props }: ImageProps) => (
11 |
17 | )
18 |
--------------------------------------------------------------------------------
/src/lib/tool-icons.tsx:
--------------------------------------------------------------------------------
1 | import { CodeIcon, DownloadIcon, GlobeIcon, ImagePlusIcon, WrenchIcon } from 'lucide-react'
2 | import type { ReactNode } from 'react'
3 |
4 | export function getToolIcon(toolId: string, className = 'size-4') {
5 | const iconMap: Record = {
6 | web_search: ,
7 | web_fetch: ,
8 | code_execution: ,
9 | image_generation: ,
10 | }
11 | return iconMap[toolId] ??
12 | }
13 |
--------------------------------------------------------------------------------
/src/components/ai-elements/response.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@/lib/utils'
2 | import { type ComponentProps, memo } from 'react'
3 | import { Streamdown } from 'streamdown'
4 |
5 | type ResponseProps = ComponentProps
6 |
7 | export const Response = memo(
8 | ({ className, ...props }: ResponseProps) => (
9 | *:first-child]:mt-0 [&>*:last-child]:mb-0 code-bg ', className)}
11 | {...props}
12 | />
13 | ),
14 | (prevProps, nextProps) => prevProps.children === nextProps.children,
15 | )
16 |
17 | Response.displayName = 'Response'
18 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | Pydantic AI
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/agent/README.md:
--------------------------------------------------------------------------------
1 | # Logfire Docs chatbot
2 |
3 | ## Usage
4 |
5 | Make sure you have `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY` set in your environment variables.
6 |
7 | Run the agent backend + pre-packaged frontend:
8 |
9 | ```bash
10 | cd agent
11 | uv sync
12 | uv run uvicorn chatbot.server:app
13 | ```
14 |
15 | Then open your browser to `http://localhost:8000`.
16 |
17 | ### Frontend development
18 |
19 | Optionally run the frontend in dev mode, which will connect to the running agent backend:
20 |
21 | ```bash
22 | npm install
23 | npm run dev
24 | ```
25 |
26 | Then open your browser to `http://localhost:5173`.
--------------------------------------------------------------------------------
/src/hooks/use-mobile.ts:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | const MOBILE_BREAKPOINT = 768
4 |
5 | export function useIsMobile() {
6 | const [isMobile, setIsMobile] = React.useState(undefined)
7 |
8 | React.useEffect(() => {
9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
10 | const onChange = () => {
11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
12 | }
13 | mql.addEventListener('change', onChange)
14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
15 | return () => {
16 | mql.removeEventListener('change', onChange)
17 | }
18 | }, [])
19 |
20 | return !!isMobile
21 | }
22 |
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/specs/delete-past-chats.md:
--------------------------------------------------------------------------------
1 | # Add the ability to delete past chats
2 |
3 | ## Feature Description
4 |
5 | In the side panel, a list of previous chats is displayed. Each chat entry includes a delete button (represented by a trash can icon) next to it. When the user clicks the delete button, a confirmation dialog appears asking, "Are you sure you want to delete this chat?" with "Cancel" and "Delete" options. If the user confirms the deletion, the selected chat is removed from the list and any associated data is deleted from the local storage. If the user cancels, no action is taken and the dialog closes.
6 |
7 | ## Implementation Details
8 |
9 | - The chats are stored in local storage, just remove them from there.
10 | - Use shadcn/lucide for the UI. The button should be visible only on hover.
11 |
--------------------------------------------------------------------------------
/agent/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "logfire-docs-chatbot"
3 | version = "0.1.0"
4 | requires-python = ">=3.12"
5 | dependencies = [
6 | "bs4>=0.0.2",
7 | "fastapi>=0.117.1",
8 | "lancedb>=0.25.0",
9 | "langchain-text-splitters>=0.3.11",
10 | "logfire[fastapi]>=4.9.0",
11 | "markdown2>=2.5.4",
12 | "pip>=25.2",
13 | "pydantic-ai-slim[anthropic,openai,google,cli]>=1.14.0",
14 | "pyright>=1.1.405",
15 | "python-frontmatter>=1.1.0",
16 | "sentence-transformers>=5.1.1",
17 | "sse-starlette>=3.0.2",
18 | "starlette>=0.48.0",
19 | "uvicorn>=0.37.0",
20 | "watchfiles>=1.1.0",
21 | ]
22 |
23 | [tool.ruff.format]
24 | # don't format python in docstrings, pytest-examples takes care of it
25 | docstring-code-format = false
26 | quote-style = "single"
27 |
--------------------------------------------------------------------------------
/src/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import * as SeparatorPrimitive from '@radix-ui/react-separator'
3 |
4 | import { cn } from '@/lib/utils'
5 |
6 | function Separator({
7 | className,
8 | orientation = 'horizontal',
9 | decorative = true,
10 | ...props
11 | }: React.ComponentProps) {
12 | return (
13 |
23 | )
24 | }
25 |
26 | export { Separator }
27 |
--------------------------------------------------------------------------------
/src/components/ui/collapsible.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'
3 |
4 | function Collapsible({ ...props }: React.ComponentProps) {
5 | return
6 | }
7 |
8 | function CollapsibleTrigger({ ...props }: React.ComponentProps) {
9 | return
10 | }
11 |
12 | function CollapsibleContent({ ...props }: React.ComponentProps) {
13 | return
14 | }
15 |
16 | export { Collapsible, CollapsibleTrigger, CollapsibleContent }
17 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2022", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "verbatimModuleSyntax": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 | "jsx": "react-jsx",
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "erasableSyntaxOnly": true,
22 | "noFallthroughCasesInSwitch": true,
23 | "noUncheckedSideEffectImports": true,
24 | "paths": {
25 | "@/*": ["./src/*"]
26 | }
27 | },
28 | "include": ["src", "eslint.config.ts", "vite.config.ts"]
29 | }
30 |
--------------------------------------------------------------------------------
/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import { cn } from '@/lib/utils'
4 |
5 | function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
6 | return (
7 |
15 | )
16 | }
17 |
18 | export { Textarea }
19 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import tailwindcss from '@tailwindcss/vite'
2 | import react from '@vitejs/plugin-react'
3 | import { defineConfig } from 'vite'
4 | import tsconfigPaths from 'vite-tsconfig-paths'
5 | // import { analyzer } from 'vite-bundle-analyzer'
6 |
7 | // 8000 is quite common for backend, avoid the clash
8 | const BACKEND_DEV_SERVER_PORT = process.env.BACKEND_PORT ?? 38001
9 |
10 | // https://vite.dev/config/
11 | export default defineConfig(({ command }) => ({
12 | plugins: [react(), tailwindcss(), tsconfigPaths({ root: __dirname })],
13 | base: command === 'build' ? 'https://cdn.jsdelivr.net/npm/@pydantic/ai-chat-ui/dist/' : '',
14 | build: {
15 | assetsDir: 'assets',
16 | },
17 | server: {
18 | proxy: {
19 | '/api': {
20 | target: `http://localhost:${BACKEND_DEV_SERVER_PORT}/`,
21 | changeOrigin: true,
22 | },
23 | },
24 | },
25 | }))
26 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import { cn } from '@/lib/utils'
4 |
5 | function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
6 | return (
7 |
18 | )
19 | }
20 |
21 | export { Input }
22 |
--------------------------------------------------------------------------------
/src/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import * as AvatarPrimitive from '@radix-ui/react-avatar'
3 |
4 | import { cn } from '@/lib/utils'
5 |
6 | function Avatar({ className, ...props }: React.ComponentProps) {
7 | return (
8 |
13 | )
14 | }
15 |
16 | function AvatarImage({ className, ...props }: React.ComponentProps) {
17 | return (
18 |
19 | )
20 | }
21 |
22 | function AvatarFallback({ className, ...props }: React.ComponentProps) {
23 | return (
24 |
29 | )
30 | }
31 |
32 | export { Avatar, AvatarImage, AvatarFallback }
33 |
--------------------------------------------------------------------------------
/src/components/ui/sonner.tsx:
--------------------------------------------------------------------------------
1 | import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon } from 'lucide-react'
2 | import { useTheme } from 'next-themes'
3 | import { Toaster as Sonner, type ToasterProps } from 'sonner'
4 | import type React from 'react'
5 |
6 | const Toaster = ({ ...props }: ToasterProps) => {
7 | const { theme = 'system' } = useTheme()
8 |
9 | return (
10 | ,
15 | info: ,
16 | warning: ,
17 | error: ,
18 | loading: ,
19 | }}
20 | style={
21 | {
22 | '--normal-bg': 'var(--popover)',
23 | '--normal-text': 'var(--popover-foreground)',
24 | '--normal-border': 'var(--border)',
25 | '--border-radius': 'var(--radius)',
26 | } as React.CSSProperties
27 | }
28 | {...props}
29 | />
30 | )
31 | }
32 |
33 | export { Toaster }
34 |
--------------------------------------------------------------------------------
/src/hooks/useConversationIdFromUrl.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react'
2 |
3 | export function useConversationIdFromUrl(): [string, (id: string) => void] {
4 | const [conversationId, setConversationId] = useState(() => {
5 | return window.location.pathname
6 | })
7 |
8 | useEffect(() => {
9 | const handlePopState = () => {
10 | const newId = window.location.pathname
11 | console.log('popstate event detected', window.location.pathname)
12 | setConversationId(newId)
13 | }
14 |
15 | window.addEventListener('popstate', handlePopState)
16 | // local event to handle same-tab updates
17 | window.addEventListener('history-state-changed', handlePopState)
18 | return () => {
19 | window.removeEventListener('popstate', handlePopState)
20 | window.removeEventListener('history-state-changed', handlePopState)
21 | }
22 | }, [])
23 |
24 | const setConversationIdAndUrl = (id: string) => {
25 | setConversationId(id)
26 | const url = new URL(window.location.toString())
27 | url.pathname = id || '/'
28 | window.history.pushState({}, '', url.toString())
29 | }
30 |
31 | return [conversationId, setConversationIdAndUrl]
32 | }
33 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | repos:
2 | - repo: https://github.com/pre-commit/pre-commit-hooks
3 | rev: v5.0.0
4 | hooks:
5 | # - id: no-commit-to-branch # prevent direct commits to the `main` branch
6 | - id: check-yaml
7 | - id: check-toml
8 | - id: end-of-file-fixer
9 | - id: trailing-whitespace
10 |
11 | - repo: https://github.com/codespell-project/codespell
12 | # Configuration for codespell is in pyproject.toml
13 | rev: v2.3.0
14 | hooks:
15 | - id: codespell
16 | args: ['--skip=package-lock.json']
17 |
18 | - repo: local
19 | hooks:
20 | - id: format
21 | name: format
22 | entry: npm
23 | args: [run, format]
24 | language: system
25 | types_or: [javascript, ts, json]
26 | pass_filenames: false
27 | - id: lint-fix
28 | name: lint fix
29 | entry: npm
30 | args: [run, lint-fix]
31 | language: system
32 | types_or: [javascript, ts, json]
33 | pass_filenames: false
34 | - id: typecheck
35 | name: typecheck
36 | entry: npm
37 | args: [run, typecheck]
38 | language: system
39 | types_or: [javascript, ts]
40 | pass_filenames: false
41 |
--------------------------------------------------------------------------------
/src/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import * as SwitchPrimitive from '@radix-ui/react-switch'
3 |
4 | import { cn } from '@/lib/utils'
5 |
6 | function Switch({ className, ...props }: React.ComponentProps) {
7 | return (
8 |
16 |
22 |
23 | )
24 | }
25 |
26 | export { Switch }
27 |
--------------------------------------------------------------------------------
/src/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/src/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
3 | import { CheckIcon } from 'lucide-react'
4 |
5 | import { cn } from '@/lib/utils'
6 |
7 | function Checkbox({ className, ...props }: React.ComponentProps) {
8 | return (
9 |
17 |
21 |
22 |
23 |
24 | )
25 | }
26 |
27 | export { Checkbox }
28 |
--------------------------------------------------------------------------------
/agent/chatbot/db.py:
--------------------------------------------------------------------------------
1 | import lancedb
2 | from lancedb.embeddings import get_registry
3 | from lancedb.pydantic import LanceModel, Vector # type: ignore
4 |
5 | from chatbot.data import get_docs_rows, Repo
6 |
7 | db = lancedb.connect('/tmp/lancedb-pydantic-ai-chat')
8 |
9 |
10 | def create_table(repo: Repo):
11 | embeddings = get_registry().get('sentence-transformers').create() # type: ignore
12 |
13 | class Documents(LanceModel):
14 | path: str
15 | headers: list[str]
16 | count: int
17 | text: str = embeddings.SourceField() # type: ignore
18 | vector: Vector(embeddings.ndims()) = embeddings.VectorField() # type: ignore
19 |
20 | table = db.create_table(repo, schema=Documents, mode='overwrite') # type: ignore
21 | table.create_fts_index('text')
22 | return table
23 |
24 |
25 | def open_table(repo: Repo):
26 | try:
27 | return db.open_table(repo)
28 | except ValueError:
29 | return create_table(repo)
30 |
31 |
32 | def populate_table(repo: Repo):
33 | table = open_table(repo)
34 | rows = get_docs_rows(repo)
35 | table.add(data=rows) # type: ignore
36 |
37 |
38 | def open_populated_table(repo: Repo):
39 | table = open_table(repo)
40 | if table.count_rows() == 0:
41 | populate_table(repo)
42 | return table
43 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import Chat from './Chat.tsx'
2 | import { AppSidebar } from './components/app-sidebar.tsx'
3 | import { ThemeProvider } from './components/theme-provider.tsx'
4 | import { SidebarProvider } from './components/ui/sidebar.tsx'
5 | import { Toaster } from './components/ui/sonner.tsx'
6 | import { cn } from './lib/utils.ts'
7 |
8 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
9 |
10 | const queryClient = new QueryClient()
11 |
12 | export default function App() {
13 | return (
14 |
15 |
16 |
17 |
18 |
19 |
29 |
30 |
31 |
32 |
33 | )
34 | }
35 |
--------------------------------------------------------------------------------
/src/components/ai-elements/suggestion.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/ui/button'
2 | import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
3 | import { cn } from '@/lib/utils'
4 | import type { ComponentProps } from 'react'
5 |
6 | export type SuggestionsProps = ComponentProps
7 |
8 | export const Suggestions = ({ className, children, ...props }: SuggestionsProps) => (
9 |
10 | {children}
11 |
12 |
13 | )
14 |
15 | export type SuggestionProps = Omit, 'onClick'> & {
16 | suggestion: string
17 | onClick?: (suggestion: string) => void
18 | }
19 |
20 | export const Suggestion = ({
21 | suggestion,
22 | onClick,
23 | className,
24 | variant = 'outline',
25 | size = 'sm',
26 | children,
27 | ...props
28 | }: SuggestionProps) => {
29 | const handleClick = () => {
30 | onClick?.(suggestion)
31 | }
32 |
33 | return (
34 |
44 | )
45 | }
46 |
--------------------------------------------------------------------------------
/src/components/ai-elements/actions.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/ui/button'
2 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
3 | import { cn } from '@/lib/utils'
4 | import type { ComponentProps } from 'react'
5 |
6 | export type ActionsProps = ComponentProps<'div'>
7 |
8 | export const Actions = ({ className, children, ...props }: ActionsProps) => (
9 |
10 | {children}
11 |
12 | )
13 |
14 | export type ActionProps = ComponentProps & {
15 | tooltip?: string
16 | label?: string
17 | }
18 |
19 | export const Action = ({
20 | tooltip,
21 | children,
22 | label,
23 | className,
24 | variant = 'ghost',
25 | size = 'sm',
26 | ...props
27 | }: ActionProps) => {
28 | const button = (
29 |
39 | )
40 |
41 | if (tooltip) {
42 | return (
43 |
44 |
45 | {button}
46 |
47 | {tooltip}
48 |
49 |
50 |
51 | )
52 | }
53 |
54 | return button
55 | }
56 |
--------------------------------------------------------------------------------
/src/components/ui/hover-card.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as HoverCardPrimitive from '@radix-ui/react-hover-card'
5 |
6 | import { cn } from '@/lib/utils'
7 |
8 | function HoverCard({ ...props }: React.ComponentProps) {
9 | return
10 | }
11 |
12 | function HoverCardTrigger({ ...props }: React.ComponentProps) {
13 | return
14 | }
15 |
16 | function HoverCardContent({
17 | className,
18 | align = 'center',
19 | sideOffset = 4,
20 | ...props
21 | }: React.ComponentProps) {
22 | return (
23 |
24 |
34 |
35 | )
36 | }
37 |
38 | export { HoverCard, HoverCardTrigger, HoverCardContent }
39 |
--------------------------------------------------------------------------------
/src/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Slot } from '@radix-ui/react-slot'
3 | import { cva, type VariantProps } from 'class-variance-authority'
4 |
5 | import { cn } from '@/lib/utils'
6 |
7 | const badgeVariants = cva(
8 | 'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
9 | {
10 | variants: {
11 | variant: {
12 | default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
13 | secondary: 'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
14 | destructive:
15 | 'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
16 | outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
17 | },
18 | },
19 | defaultVariants: {
20 | variant: 'default',
21 | },
22 | },
23 | )
24 |
25 | function Badge({
26 | className,
27 | variant,
28 | asChild = false,
29 | ...props
30 | }: React.ComponentProps<'span'> & VariantProps & { asChild?: boolean }) {
31 | const Comp = asChild ? Slot : 'span'
32 |
33 | return
34 | }
35 |
36 | export { Badge, badgeVariants }
37 |
--------------------------------------------------------------------------------
/src/components/ai-elements/message.tsx:
--------------------------------------------------------------------------------
1 | import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
2 | import { cn } from '@/lib/utils'
3 | import type { UIMessage } from 'ai'
4 | import type { ComponentProps, HTMLAttributes } from 'react'
5 |
6 | export type MessageProps = HTMLAttributes & {
7 | from: UIMessage['role']
8 | }
9 |
10 | export const Message = ({ className, from, ...props }: MessageProps) => (
11 | div]:max-w-[80%]',
16 | className,
17 | )}
18 | {...props}
19 | />
20 | )
21 |
22 | export type MessageContentProps = HTMLAttributes
23 |
24 | export const MessageContent = ({ children, className, ...props }: MessageContentProps) => (
25 |
35 | {children}
36 |
37 | )
38 |
39 | export type MessageAvatarProps = ComponentProps & {
40 | src: string
41 | name?: string
42 | }
43 |
44 | export const MessageAvatar = ({ src, name, className, ...props }: MessageAvatarProps) => (
45 |
46 |
47 | {name?.slice(0, 2) ?? 'ME'}
48 |
49 | )
50 |
--------------------------------------------------------------------------------
/src/components/ui/scroll-area.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
5 |
6 | import { cn } from '@/lib/utils'
7 |
8 | function ScrollArea({ className, children, ...props }: React.ComponentProps) {
9 | return (
10 |
11 |
15 | {children}
16 |
17 |
18 |
19 |
20 | )
21 | }
22 |
23 | function ScrollBar({
24 | className,
25 | orientation = 'vertical',
26 | ...props
27 | }: React.ComponentProps) {
28 | return (
29 |
40 |
44 |
45 | )
46 | }
47 |
48 | export { ScrollArea, ScrollBar }
49 |
--------------------------------------------------------------------------------
/src/components/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { createContext, useContext, useEffect, useState } from 'react'
3 |
4 | type Theme = 'dark' | 'light' | 'system'
5 |
6 | interface ThemeProviderProps {
7 | children: React.ReactNode
8 | defaultTheme?: Theme
9 | storageKey?: string
10 | }
11 |
12 | interface ThemeProviderState {
13 | theme: Theme
14 | setTheme: (theme: Theme) => void
15 | }
16 |
17 | const initialState: ThemeProviderState = {
18 | theme: 'system',
19 | setTheme: () => null,
20 | }
21 |
22 | const ThemeProviderContext = createContext(initialState)
23 |
24 | export function ThemeProvider({
25 | children,
26 | defaultTheme = 'system',
27 | storageKey = 'vite-ui-theme',
28 | ...props
29 | }: ThemeProviderProps) {
30 | const [theme, setTheme] = useState(
31 | () => (window.localStorage.getItem(storageKey) as Theme | undefined) ?? defaultTheme,
32 | )
33 |
34 | useEffect(() => {
35 | const root = window.document.documentElement
36 |
37 | root.classList.remove('light', 'dark')
38 |
39 | if (theme === 'system') {
40 | const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
41 |
42 | root.classList.add(systemTheme)
43 | return
44 | }
45 |
46 | root.classList.add(theme)
47 | }, [theme])
48 |
49 | const value = {
50 | theme,
51 | setTheme: (theme: Theme) => {
52 | window.localStorage.setItem(storageKey, theme)
53 | setTheme(theme)
54 | },
55 | }
56 |
57 | return (
58 |
59 | {children}
60 |
61 | )
62 | }
63 |
64 | export const useTheme = () => {
65 | const context = useContext(ThemeProviderContext)
66 |
67 | return context
68 | }
69 |
--------------------------------------------------------------------------------
/src/components/mode-toggle.tsx:
--------------------------------------------------------------------------------
1 | import { Monitor, Moon, Sun } from 'lucide-react'
2 |
3 | import { Button } from '@/components/ui/button'
4 | import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
5 | import { useTheme } from '@/components/theme-provider'
6 | import type { FC } from 'react'
7 |
8 | export const ModeToggle: FC<{ className?: string }> = ({ className }) => {
9 | const { theme, setTheme } = useTheme()
10 |
11 | const toggleTheme = () => {
12 | if (theme === 'system') {
13 | setTheme('light')
14 | } else if (theme === 'light') {
15 | setTheme('dark')
16 | } else {
17 | setTheme('system')
18 | }
19 | }
20 |
21 | const getIcon = () => {
22 | switch (theme) {
23 | case 'light':
24 | return
25 | case 'dark':
26 | return
27 | case 'system':
28 | return
29 | default:
30 | return
31 | }
32 | }
33 |
34 | const getTooltipText = () => {
35 | switch (theme) {
36 | case 'light':
37 | return 'Switch to dark theme'
38 | case 'dark':
39 | return 'Switch to system theme'
40 | case 'system':
41 | return 'Switch to light theme'
42 | default:
43 | return 'Toggle theme'
44 | }
45 | }
46 |
47 | return (
48 |
49 |
50 |
54 |
55 | {getTooltipText()}
56 |
57 | )
58 | }
59 |
--------------------------------------------------------------------------------
/src/components/ai-elements/conversation.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/ui/button'
2 | import { cn } from '@/lib/utils'
3 | import { ArrowDownIcon } from 'lucide-react'
4 | import type { ComponentProps } from 'react'
5 | import { useCallback } from 'react'
6 | import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'
7 |
8 | export type ConversationProps = ComponentProps
9 |
10 | export const Conversation = ({ className, ...props }: ConversationProps) => (
11 |
18 | )
19 |
20 | export type ConversationContentProps = ComponentProps
21 |
22 | export const ConversationContent = ({ className, ...props }: ConversationContentProps) => (
23 |
24 | )
25 |
26 | export type ConversationScrollButtonProps = ComponentProps
27 |
28 | export const ConversationScrollButton = ({ className, ...props }: ConversationScrollButtonProps) => {
29 | const { isAtBottom, scrollToBottom } = useStickToBottomContext()
30 |
31 | const handleScrollToBottom = useCallback(() => {
32 | // eslint-disable-next-line @typescript-eslint/no-floating-promises
33 | scrollToBottom()
34 | }, [scrollToBottom])
35 |
36 | return (
37 | !isAtBottom && (
38 |
48 | )
49 | )
50 | }
51 |
--------------------------------------------------------------------------------
/eslint.config.ts:
--------------------------------------------------------------------------------
1 | import pluginJs from '@eslint/js'
2 | import eslintConfigPrettier from 'eslint-config-prettier/flat'
3 | import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
4 | import globals from 'globals'
5 | import neostandard from 'neostandard'
6 | import tseslint from 'typescript-eslint'
7 | import { defineConfig } from 'eslint/config'
8 |
9 | export default defineConfig(
10 | pluginJs.configs.recommended,
11 | tseslint.configs.strictTypeChecked,
12 | tseslint.configs.stylisticTypeChecked,
13 | neostandard({ noJsx: true, noStyle: true }),
14 | eslintPluginPrettierRecommended,
15 | eslintConfigPrettier,
16 | { files: ['src/*.{js,mjs,cjs,ts}'] },
17 | {
18 | languageOptions: {
19 | globals: globals.node,
20 | },
21 | },
22 | { ignores: ['dist/**', 'server/**', 'node_modules/**', 'scratch/**', 'agent/**'] },
23 | {
24 | rules: {
25 | '@typescript-eslint/no-unused-vars': [
26 | 'error',
27 | {
28 | argsIgnorePattern: '^_',
29 | caughtErrors: 'all',
30 | caughtErrorsIgnorePattern: '^_',
31 | destructuredArrayIgnorePattern: '^_',
32 | varsIgnorePattern: '^_',
33 | ignoreRestSiblings: true,
34 | },
35 | ],
36 | '@typescript-eslint/restrict-template-expressions': [
37 | 'error',
38 | {
39 | allow: [{ name: ['Error', 'URL', 'URLSearchParams'], from: 'lib' }],
40 | allowAny: true,
41 | allowBoolean: true,
42 | allowNullish: true,
43 | allowNumber: true,
44 | allowRegExp: true,
45 | },
46 | ],
47 | '@typescript-eslint/no-non-null-assertion': 'off',
48 | },
49 | },
50 | {
51 | languageOptions: {
52 | parserOptions: {
53 | projectService: true,
54 | tsconfigRootDir: import.meta.dirname,
55 | },
56 | },
57 | },
58 | )
59 |
--------------------------------------------------------------------------------
/src/components/ai-elements/loader.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@/lib/utils'
2 | import type { HTMLAttributes } from 'react'
3 |
4 | interface LoaderIconProps {
5 | size?: number
6 | }
7 |
8 | const LoaderIcon = ({ size = 16 }: LoaderIconProps) => (
9 |
29 | )
30 |
31 | export type LoaderProps = HTMLAttributes & {
32 | size?: number
33 | }
34 |
35 | export const Loader = ({ className, size = 16, ...props }: LoaderProps) => (
36 |
37 |
38 |
39 | )
40 |
--------------------------------------------------------------------------------
/src/components/ai-elements/sources.tsx:
--------------------------------------------------------------------------------
1 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
2 | import { cn } from '@/lib/utils'
3 | import { BookIcon, ChevronDownIcon } from 'lucide-react'
4 | import type { ComponentProps } from 'react'
5 |
6 | export type SourcesProps = ComponentProps<'div'>
7 |
8 | export const Sources = ({ className, ...props }: SourcesProps) => (
9 |
10 | )
11 |
12 | export type SourcesTriggerProps = ComponentProps & {
13 | count: number
14 | }
15 |
16 | export const SourcesTrigger = ({ className, count, children, ...props }: SourcesTriggerProps) => (
17 |
18 | {children ?? (
19 | <>
20 | Used {count} sources
21 |
22 | >
23 | )}
24 |
25 | )
26 |
27 | export type SourcesContentProps = ComponentProps
28 |
29 | export const SourcesContent = ({ className, ...props }: SourcesContentProps) => (
30 |
38 | )
39 |
40 | export type SourceProps = ComponentProps<'a'>
41 |
42 | export const Source = ({ href, title, children, ...props }: SourceProps) => (
43 |
44 | {children ?? (
45 | <>
46 |
47 | {title}
48 | >
49 | )}
50 |
51 | )
52 |
--------------------------------------------------------------------------------
/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import * as TooltipPrimitive from '@radix-ui/react-tooltip'
3 |
4 | import { cn } from '@/lib/utils'
5 |
6 | function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps) {
7 | return
8 | }
9 |
10 | function Tooltip({ ...props }: React.ComponentProps) {
11 | return (
12 |
13 |
14 |
15 | )
16 | }
17 |
18 | function TooltipTrigger({ ...props }: React.ComponentProps) {
19 | return
20 | }
21 |
22 | function TooltipContent({
23 | className,
24 | sideOffset = 0,
25 | children,
26 | ...props
27 | }: React.ComponentProps) {
28 | return (
29 |
30 |
39 | {children}
40 |
41 |
42 |
43 | )
44 | }
45 |
46 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
47 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Slot } from '@radix-ui/react-slot'
3 | import { cva, type VariantProps } from 'class-variance-authority'
4 |
5 | import { cn } from '@/lib/utils'
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
9 | {
10 | variants: {
11 | variant: {
12 | default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
13 | destructive:
14 | 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
15 | outline:
16 | 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
17 | secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
18 | ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
19 | link: 'text-primary underline-offset-4 hover:underline',
20 | },
21 | size: {
22 | default: 'h-9 px-4 py-2 has-[>svg]:px-3',
23 | sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
24 | lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
25 | icon: 'size-9',
26 | },
27 | },
28 | defaultVariants: {
29 | variant: 'default',
30 | size: 'default',
31 | },
32 | },
33 | )
34 |
35 | function Button({
36 | className,
37 | variant,
38 | size,
39 | asChild = false,
40 | ...props
41 | }: React.ComponentProps<'button'> &
42 | VariantProps & {
43 | asChild?: boolean
44 | }) {
45 | const Comp = asChild ? Slot : 'button'
46 |
47 | return
48 | }
49 |
50 | export { Button, buttonVariants }
51 |
--------------------------------------------------------------------------------
/src/components/ai-elements/task.tsx:
--------------------------------------------------------------------------------
1 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
2 | import { cn } from '@/lib/utils'
3 | import { ChevronDownIcon, SearchIcon } from 'lucide-react'
4 | import type { ComponentProps } from 'react'
5 |
6 | export type TaskItemFileProps = ComponentProps<'div'>
7 |
8 | export const TaskItemFile = ({ children, className, ...props }: TaskItemFileProps) => (
9 |
16 | {children}
17 |
18 | )
19 |
20 | export type TaskItemProps = ComponentProps<'div'>
21 |
22 | export const TaskItem = ({ children, className, ...props }: TaskItemProps) => (
23 |
24 | {children}
25 |
26 | )
27 |
28 | export type TaskProps = ComponentProps
29 |
30 | export const Task = ({ defaultOpen = true, className, ...props }: TaskProps) => (
31 |
39 | )
40 |
41 | export type TaskTriggerProps = ComponentProps & {
42 | title: string
43 | }
44 |
45 | export const TaskTrigger = ({ children, className, title, ...props }: TaskTriggerProps) => (
46 |
47 | {children ?? (
48 |
49 |
50 |
{title}
51 |
52 |
53 | )}
54 |
55 | )
56 |
57 | export type TaskContentProps = ComponentProps
58 |
59 | export const TaskContent = ({ children, className, ...props }: TaskContentProps) => (
60 |
67 | {children}
68 |
69 | )
70 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@pydantic/ai-chat-ui",
3 | "private": false,
4 | "publishConfig": {
5 | "access": "public"
6 | },
7 | "version": "0.0.4",
8 | "type": "module",
9 | "scripts": {
10 | "typecheck": "tsc --noEmit",
11 | "format": "prettier --write -- .",
12 | "lint": "eslint",
13 | "lint-fix": "eslint --fix --quiet",
14 | "dev": "BACKEND_PORT=7932 vite",
15 | "dev:server": "cd agent && uv run uvicorn chatbot.server:app --port 38001",
16 | "build": "tsc -b && vite build",
17 | "preview": "vite preview"
18 | },
19 | "files": [
20 | "dist"
21 | ],
22 | "dependencies": {
23 | "@ai-sdk/react": "^2.0.34",
24 | "@radix-ui/react-avatar": "^1.1.10",
25 | "@radix-ui/react-checkbox": "^1.3.3",
26 | "@radix-ui/react-collapsible": "^1.1.12",
27 | "@radix-ui/react-dialog": "^1.1.15",
28 | "@radix-ui/react-dropdown-menu": "^2.1.16",
29 | "@radix-ui/react-hover-card": "^1.1.15",
30 | "@radix-ui/react-scroll-area": "^1.2.10",
31 | "@radix-ui/react-select": "^2.2.6",
32 | "@radix-ui/react-separator": "^1.1.7",
33 | "@radix-ui/react-slot": "^1.2.3",
34 | "@radix-ui/react-switch": "^1.2.6",
35 | "@radix-ui/react-tooltip": "^1.2.8",
36 | "@radix-ui/react-use-controllable-state": "^1.2.2",
37 | "@tailwindcss/vite": "^4.1.13",
38 | "@tanstack/react-query": "^5.90.2",
39 | "@uidotdev/usehooks": "^2.4.1",
40 | "ai": "^5.0.34",
41 | "class-variance-authority": "^0.7.1",
42 | "clsx": "^2.1.1",
43 | "embla-carousel-react": "^8.6.0",
44 | "lucide-react": "^0.542.0",
45 | "nanoid": "^5.1.6",
46 | "next-themes": "^0.4.6",
47 | "react": "^19.1.1",
48 | "react-dom": "^19.1.1",
49 | "react-syntax-highlighter": "^15.6.6",
50 | "sonner": "^2.0.7",
51 | "streamdown": "^1.2.0",
52 | "tailwind-merge": "^3.3.1",
53 | "tailwindcss": "^4.1.13",
54 | "use-stick-to-bottom": "^1.1.1",
55 | "zod": "^4.1.5"
56 | },
57 | "devDependencies": {
58 | "@eslint/js": "^9.35.0",
59 | "@types/node": "^24.7.2",
60 | "@types/react": "^19.1.10",
61 | "@types/react-dom": "^19.1.7",
62 | "@types/react-syntax-highlighter": "^15.5.13",
63 | "@vitejs/plugin-react": "^5.0.0",
64 | "eslint": "^9.33.0",
65 | "eslint-config-prettier": "^10.1.8",
66 | "eslint-plugin-prettier": "^5.5.4",
67 | "eslint-plugin-react-hooks": "^5.2.0",
68 | "eslint-plugin-react-refresh": "^0.4.20",
69 | "globals": "^16.3.0",
70 | "neostandard": "^0.12.2",
71 | "prettier": "^3.6.2",
72 | "tw-animate-css": "^1.3.8",
73 | "typescript": "~5.8.3",
74 | "typescript-eslint": "^8.39.1",
75 | "vite": "^7.1.2",
76 | "vite-bundle-analyzer": "^1.2.3",
77 | "vite-tsconfig-paths": "^5.1.4"
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/agent/chatbot/agent.py:
--------------------------------------------------------------------------------
1 | from typing import Any, cast
2 |
3 | import pydantic_ai
4 |
5 | import logfire
6 | from chatbot.data import get_docs_dir, get_markdown, get_table_of_contents
7 | from chatbot.db import open_populated_table
8 |
9 | from chatbot.data import Repo
10 |
11 | logfire.configure(send_to_logfire='if-token-present', console=False)
12 | logfire.instrument_pydantic_ai()
13 |
14 |
15 | agent = pydantic_ai.Agent(
16 | 'anthropic:claude-sonnet-4-0',
17 | instructions="Help the user answer questions about two products ('repos'): Pydantic AI (pydantic-ai), an open source agent framework library, and Pydantic Logfire (logfire), an observability platform. Start by using the `search_docs` tool to search the relevant documentation and answer the question based on the search results. It uses a hybrid of semantic and keyword search, so writing either keywords or sentences may work. It's not searching google. Each search result starts with a path to a .md file. The file `foo/bar.md` corresponds to the URL `https://ai.pydantic.dev/foo/bar/` for Pydantic AI, `https://logfire.pydantic.dev/docs/foo/bar/` for Logfire. Include the URLs in your answer. The search results may not return complete files, or may not return the files you need. If they don't have what you need, you can use the `get_docs_file` tool. You probably only need to search once or twice, definitely not more than 3 times. The user doesn't see the search results, you need to actually return a summary of the info. To see the files that exist for the `get_docs_file` tool, along with a preview of the sections within, use the `get_table_of_contents` tool.",
18 | )
19 |
20 | agent.tool_plain(get_table_of_contents)
21 |
22 |
23 | @agent.tool_plain
24 | def get_docs_file(repo: Repo, filename: str):
25 | """Get the full text of a documentation file by its filename, e.g. `foo/bar.md`."""
26 | if not filename.endswith('.md'):
27 | filename += '.md'
28 | path = get_docs_dir(repo) / filename
29 | if not path.exists():
30 | return f'File {filename} does not exist'
31 | return get_markdown(path)
32 |
33 |
34 | @agent.tool_plain
35 | def search_docs(repo: Repo, query: str):
36 | results = cast(
37 | list[dict[str, Any]],
38 | open_populated_table(repo)
39 | .search( # type: ignore
40 | query,
41 | query_type='hybrid',
42 | vector_column_name='vector',
43 | fts_columns='text',
44 | )
45 | .limit(10)
46 | .to_list(),
47 | )
48 | results = [
49 | r
50 | for r in results
51 | if not any(
52 | r != r2
53 | and r['path'] == r2['path']
54 | and r['headers'][: len(r2['headers'])] == r2['headers']
55 | for r2 in results
56 | )
57 | ]
58 |
59 | return '\n\n---------\n\n'.join(r['text'] for r in results)
60 |
61 |
62 | if __name__ == '__main__':
63 | # print(agent.run_sync('how do i see errors').output)
64 | # search_docs("logfire", "errors debugging view errors logs")
65 | agent.to_cli_sync()
66 |
--------------------------------------------------------------------------------
/src/Part.tsx:
--------------------------------------------------------------------------------
1 | import { Message, MessageContent } from '@/components/ai-elements/message'
2 |
3 | import { Actions, Action } from '@/components/ai-elements/actions'
4 | import { Response } from '@/components/ai-elements/response'
5 | import { CopyIcon, RefreshCcwIcon } from 'lucide-react'
6 | import type { UIDataTypes, UIMessagePart, UITools, UIMessage } from 'ai'
7 | import { Reasoning, ReasoningContent, ReasoningTrigger } from '@/components/ai-elements/reasoning'
8 | import { Tool, ToolHeader, ToolInput, ToolOutput, ToolContent } from '@/components/ai-elements/tool'
9 | import { CodeBlock } from '@/components/ai-elements/code-block'
10 |
11 | interface PartProps {
12 | part: UIMessagePart
13 | message: UIMessage
14 | status: string
15 | regen: (id: string) => void
16 | index: number
17 | lastMessage: boolean
18 | }
19 |
20 | export function Part({ part, message, status, regen, index, lastMessage }: PartProps) {
21 | function copy(text: string) {
22 | navigator.clipboard.writeText(text).catch((error: unknown) => {
23 | console.error('Error copying text:', error)
24 | })
25 | }
26 |
27 | if (part.type === 'text') {
28 | return (
29 |
30 |
31 |
32 | {part.text}
33 |
34 |
35 | {message.role === 'assistant' && index === message.parts.length - 1 && (
36 |
37 | {
39 | regen(message.id)
40 | }}
41 | label="Retry"
42 | >
43 |
44 |
45 | {
47 | copy(part.text)
48 | }}
49 | label="Copy"
50 | >
51 |
52 |
53 |
54 | )}
55 |
56 | )
57 | } else if (part.type === 'reasoning') {
58 | return (
59 |
63 |
64 | {part.text}
65 |
66 | )
67 | } else if (part.type === 'dynamic-tool') {
68 | return <>Dynamic Tool, TODO {JSON.stringify(part)}>
69 | } else if ('toolCallId' in part) {
70 | // return {JSON.stringify(part)}
71 | return (
72 |
73 |
74 |
75 |
76 | {part.state === 'output-available' && (
77 | }
80 | />
81 | )}
82 |
83 |
84 | )
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/CLAUDE.md:
--------------------------------------------------------------------------------
1 | # CLAUDE.md
2 |
3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4 |
5 | ## Project Overview
6 |
7 | A React-based chat interface for Pydantic AI that uses Vercel AI SDK and Elements. The project consists of a frontend (Vite + React + TypeScript) and a Python backend (FastAPI + Pydantic AI).
8 |
9 | ## Development Commands
10 |
11 | **Frontend:**
12 |
13 | ```bash
14 | npm install
15 | npm run dev # Start dev server (proxies /api to localhost:8000)
16 | npm run build # Build for production (CDN deployment via jsdelivr)
17 | npm run typecheck # Type check without emitting
18 | npm run lint # Run ESLint
19 | npm run lint-fix # Fix ESLint issues
20 | npm run format # Format with Prettier
21 | ```
22 |
23 | **Backend:**
24 |
25 | ```bash
26 | cd agent
27 | uv run uvicorn chatbot.server:app # Start backend on port 8000
28 | ```
29 |
30 | Note: Stop any logfire platform instances to avoid port 8000 conflicts.
31 |
32 | ## Architecture
33 |
34 | ### Frontend Structure
35 |
36 | - **src/Chat.tsx**: Main chat component handling conversation state, message sending, and local storage persistence
37 | - **src/Part.tsx**: Renders individual message parts (text, reasoning, tools, etc.)
38 | - **src/App.tsx**: Root component with theme provider, sidebar, and React Query setup
39 | - **src/components/ai-elements/**: Vercel AI Elements wrappers (conversation, prompt-input, message, tool, reasoning, sources, etc.)
40 | - **src/components/ui/**: Radix UI and shadcn/ui components
41 |
42 | ### Key Frontend Concepts
43 |
44 | **Conversation Management:**
45 |
46 | - Conversations stored in localStorage by ID (nanoid)
47 | - URL-based routing: `/` for new chat, `/{nanoid}` for existing
48 | - Messages persisted via `useChat` hook and localStorage sync (throttled 500ms)
49 | - Conversation list stored in localStorage key `conversationIds`
50 |
51 | **Model & Tool Selection:**
52 |
53 | - Dynamic model/tool configuration fetched from `/api/configure`
54 | - Models and available builtin tools configured per-model
55 | - Tools toggled via checkboxes in prompt toolbar
56 |
57 | **Message Parts:**
58 |
59 | - Messages contain multiple parts: text, reasoning, tool calls, sources
60 | - Part rendering delegated to `Part.tsx` component
61 | - Tool calls show input/output with collapsible UI
62 |
63 | ### Backend Structure
64 |
65 | - **agent/chatbot/server.py**: FastAPI app with Vercel AI adapter, model/tool configuration
66 | - **agent/chatbot/agent.py**: Pydantic AI agent with documentation search tools
67 | - **agent/chatbot/db.py**: LanceDB vector store for documentation
68 | - **agent/chatbot/data.py**: Documentation loading and processing
69 |
70 | ### Backend Integration
71 |
72 | **Endpoints:**
73 |
74 | - `GET /api/configure`: Returns available models and builtin tools (camelCase)
75 | - `POST /api/chat`: Handles chat messages via `VercelAIAdapter`
76 | - Accepts `model` and `builtinTools` in request body extra data
77 | - Streams responses using SSE
78 |
79 | **Builtin Tools:**
80 |
81 | - `web_search`, `code_execution`, `image_generation`
82 | - Enabled per-model in AI_MODELS configuration
83 | - Selected tools passed to agent via `VercelAIAdapter.dispatch_request`
84 |
85 | ## Configuration
86 |
87 | - **TypeScript paths**: `@/*` maps to `./src/*`
88 | - **Vite base URL**: CDN path for production (`jsdelivr.net/npm/@pydantic/pydantic-ai-chat/dist/`)
89 | - **Dev proxy**: `/api` proxied to `localhost:8000`
90 | - **Package**: Published as `@pydantic/pydantic-ai-chat` (public npm package)
91 |
92 | ## Tech Stack
93 |
94 | - React 19, TypeScript, Vite, Tailwind CSS 4
95 | - Vercel AI SDK (`@ai-sdk/react`, `ai`)
96 | - Radix UI primitives
97 | - FastAPI, Pydantic AI, LanceDB
98 | - ESLint (neostandard), Prettier
99 |
--------------------------------------------------------------------------------
/agent/chatbot/data.py:
--------------------------------------------------------------------------------
1 | import re
2 | from collections import Counter
3 | from pathlib import Path
4 | from typing import Any, Literal
5 |
6 | import frontmatter
7 | import markdown2
8 | from bs4 import BeautifulSoup
9 | from langchain_text_splitters import MarkdownHeaderTextSplitter
10 |
11 | Repo = Literal['pydantic-ai', 'logfire']
12 | repos: tuple[Repo, ...] = 'pydantic-ai', 'logfire'
13 |
14 |
15 | def get_docs_dir(repo: Repo) -> Path:
16 | result = Path(__file__).parent.parent.parent.parent / repo / 'docs'
17 | if not result.exists():
18 | raise ValueError(f'This repo should live next to the {repo} repo')
19 | return result
20 |
21 |
22 | IGNORED_FILES = 'release-notes.md', 'help.md', '/api/', '/legal/'
23 |
24 |
25 | def get_docs_files(repo: Repo) -> list[Path]:
26 | return [
27 | file
28 | for file in get_docs_dir(repo).rglob('*.md')
29 | if not any(ignored in str(file) for ignored in IGNORED_FILES)
30 | ]
31 |
32 |
33 | def get_markdown(path: Path) -> str:
34 | markdown_string = path.read_text()
35 | markdown_string = frontmatter.loads(markdown_string).content
36 | return markdown_string
37 |
38 |
39 | def get_table_of_contents(repo: Repo):
40 | """Get a list of all docs files and a preview of the sections within."""
41 | result = ''
42 | for file in get_docs_files(repo):
43 | markdown_string = get_markdown(file)
44 | markdown_string = re.sub(
45 | r'^```\w+ [^\n]+$', '```', markdown_string, flags=re.MULTILINE
46 | )
47 | html_output = markdown2.markdown(markdown_string, extras=['fenced-code-blocks']) # type: ignore
48 | soup = BeautifulSoup(html_output, 'html.parser')
49 | headers = soup.find_all(['h1', 'h2', 'h3', 'h4'])
50 | result += f'{file.relative_to(get_docs_dir(repo))}\n'
51 | result += '\n'.join(
52 | '#'
53 | * int(
54 | header.name[1] # type: ignore
55 | )
56 | + ' '
57 | + header.get_text()
58 | for header in headers
59 | )
60 | result += '\n\n'
61 | return result
62 |
63 |
64 | headers_to_split_on = [('#' * n, f'H{n}') for n in range(1, 7)]
65 |
66 |
67 | def get_docs_rows(repo: Repo) -> list[dict[str, Any]]:
68 | data: list[dict[str, Any]] = []
69 | for file in get_docs_files(repo):
70 | markdown_document = get_markdown(file)
71 | rel_path = str(file.relative_to(get_docs_dir(repo)))
72 |
73 | unique: set[tuple[tuple[str, ...], str]] = set()
74 | for num_headers in range(len(headers_to_split_on)):
75 | splitter = MarkdownHeaderTextSplitter(headers_to_split_on[:num_headers])
76 | splits = splitter.split_text(markdown_document)
77 | for split in splits:
78 | metadata: dict[str, Any] = split.metadata # type: ignore
79 | headers = [
80 | f'{prefix} {metadata[header_type]}'
81 | for prefix, header_type in headers_to_split_on
82 | if header_type in metadata
83 | ]
84 | content = '\n\n'.join([rel_path, *headers, split.page_content])
85 | if len(content.encode()) > 16384:
86 | continue
87 | unique.add((tuple(headers), content))
88 |
89 | counts = Counter[tuple[str, ...]]()
90 | for headers, content in sorted(unique):
91 | counts[headers] += 1
92 | count = str(counts[headers])
93 | data.append(
94 | dict(
95 | path=rel_path,
96 | headers=headers,
97 | text=content,
98 | count=count,
99 | )
100 | )
101 |
102 | return data
103 |
104 |
105 | if __name__ == '__main__':
106 | print(get_table_of_contents('logfire'))
107 | rows = get_docs_rows('logfire')
108 | print(f'Generated {len(rows)} rows')
109 |
--------------------------------------------------------------------------------
/agent/chatbot/server.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations as _annotations
2 |
3 | from typing import Literal
4 |
5 | import fastapi
6 | import httpx
7 | import logfire
8 | from fastapi import Request, Response
9 | from fastapi.responses import HTMLResponse
10 | from pydantic import BaseModel
11 | from pydantic.alias_generators import to_camel
12 | from pydantic_ai.builtin_tools import (
13 | AbstractBuiltinTool,
14 | CodeExecutionTool,
15 | ImageGenerationTool,
16 | WebSearchTool,
17 | )
18 | from pydantic_ai.ui.vercel_ai import VercelAIAdapter
19 |
20 | from .agent import agent
21 |
22 | # 'if-token-present' means nothing will be sent (and the example will work) if you don't have logfire configured
23 | logfire.configure(send_to_logfire='if-token-present')
24 | logfire.instrument_pydantic_ai()
25 |
26 | app = fastapi.FastAPI()
27 | logfire.instrument_fastapi(app)
28 |
29 |
30 | @app.options('/api/chat')
31 | def options_chat():
32 | pass
33 |
34 |
35 | AIModelID = Literal[
36 | 'anthropic:claude-sonnet-4-5',
37 | 'openai-responses:gpt-5',
38 | 'google-gla:gemini-2.5-pro',
39 | ]
40 | BuiltinToolID = Literal['web_search', 'image_generation', 'code_execution']
41 |
42 |
43 | class AIModel(BaseModel):
44 | id: AIModelID
45 | name: str
46 | builtin_tools: list[BuiltinToolID]
47 |
48 |
49 | class BuiltinTool(BaseModel):
50 | id: BuiltinToolID
51 | name: str
52 |
53 |
54 | BUILTIN_TOOL_DEFS: list[BuiltinTool] = [
55 | BuiltinTool(id='web_search', name='Web Search'),
56 | BuiltinTool(id='code_execution', name='Code Execution'),
57 | BuiltinTool(id='image_generation', name='Image Generation'),
58 | ]
59 |
60 | BUILTIN_TOOLS: dict[BuiltinToolID, AbstractBuiltinTool] = {
61 | 'web_search': WebSearchTool(),
62 | 'code_execution': CodeExecutionTool(),
63 | 'image_generation': ImageGenerationTool(),
64 | }
65 |
66 | AI_MODELS: list[AIModel] = [
67 | AIModel(
68 | id='anthropic:claude-sonnet-4-5',
69 | name='Claude Sonnet 4.5',
70 | builtin_tools=[
71 | 'web_search',
72 | 'code_execution',
73 | ],
74 | ),
75 | AIModel(
76 | id='openai-responses:gpt-5',
77 | name='GPT 5',
78 | builtin_tools=[
79 | 'web_search',
80 | 'code_execution',
81 | 'image_generation',
82 | ],
83 | ),
84 | AIModel(
85 | id='google-gla:gemini-2.5-pro',
86 | name='Gemini 2.5 Pro',
87 | builtin_tools=[
88 | 'web_search',
89 | 'code_execution',
90 | ],
91 | ),
92 | ]
93 |
94 |
95 | class ConfigureFrontend(BaseModel, alias_generator=to_camel, populate_by_name=True):
96 | models: list[AIModel]
97 | builtin_tools: list[BuiltinTool]
98 |
99 |
100 | @app.get('/api/configure')
101 | async def configure_frontend() -> ConfigureFrontend:
102 | return ConfigureFrontend(
103 | models=AI_MODELS,
104 | builtin_tools=BUILTIN_TOOL_DEFS,
105 | )
106 |
107 |
108 | class ChatRequestExtra(BaseModel, extra='ignore', alias_generator=to_camel):
109 | model: AIModelID | None = None
110 | builtin_tools: list[BuiltinToolID] = []
111 |
112 |
113 | @app.post('/api/chat')
114 | async def post_chat(request: Request) -> Response:
115 | run_input = VercelAIAdapter.build_run_input(await request.body())
116 | extra_data = ChatRequestExtra.model_validate(run_input.__pydantic_extra__)
117 | return await VercelAIAdapter.dispatch_request(
118 | request,
119 | agent=agent,
120 | model=extra_data.model,
121 | builtin_tools=[BUILTIN_TOOLS[tool_id] for tool_id in extra_data.builtin_tools],
122 | )
123 |
124 |
125 | @app.get('/')
126 | @app.get('/{id}')
127 | async def index(request: Request):
128 | async with httpx.AsyncClient() as client:
129 | response = await client.get(
130 | 'https://cdn.jsdelivr.net/npm/@pydantic/ai-chat-ui@0.0.2/dist/index.html'
131 | )
132 | return HTMLResponse(content=response.content, status_code=response.status_code)
133 |
--------------------------------------------------------------------------------
/src/components/ai-elements/tool.tsx:
--------------------------------------------------------------------------------
1 | import { Badge } from '@/components/ui/badge'
2 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
3 | import { cn } from '@/lib/utils'
4 | import type { ToolUIPart } from 'ai'
5 | import { CheckCircleIcon, ChevronDownIcon, CircleIcon, ClockIcon, XCircleIcon } from 'lucide-react'
6 | import type { ComponentProps, ReactNode } from 'react'
7 | import { CodeBlock } from './code-block'
8 | import { getToolIcon } from '@/lib/tool-icons'
9 |
10 | export type ToolProps = ComponentProps
11 |
12 | export const Tool = ({ className, ...props }: ToolProps) => (
13 |
14 | )
15 |
16 | export interface ToolHeaderProps {
17 | type: ToolUIPart['type']
18 | state: ToolUIPart['state']
19 | className?: string
20 | }
21 |
22 | const getStatusBadge = (status: ToolUIPart['state']) => {
23 | const labels = {
24 | 'input-streaming': 'Pending',
25 | 'input-available': 'Running',
26 | 'output-available': 'Completed',
27 | 'output-error': 'Error',
28 | } as const
29 |
30 | const icons = {
31 | 'input-streaming': ,
32 | 'input-available': ,
33 | 'output-available': ,
34 | 'output-error': ,
35 | } as const
36 |
37 | return (
38 |
39 | {icons[status]}
40 | {labels[status]}
41 |
42 | )
43 | }
44 |
45 | export const ToolHeader = ({ className, type, state, ...props }: ToolHeaderProps) => {
46 | const toolId = type.slice(5) // Remove 'tool-' prefix
47 | const toolIcon = getToolIcon(toolId, 'size-4 text-muted-foreground')
48 |
49 | return (
50 |
51 |
52 | {toolIcon}
53 | {toolId}
54 | {getStatusBadge(state)}
55 |
56 |
57 |
58 | )
59 | }
60 |
61 | export type ToolContentProps = ComponentProps
62 |
63 | export const ToolContent = ({ className, ...props }: ToolContentProps) => (
64 |
71 | )
72 |
73 | export type ToolInputProps = ComponentProps<'div'> & {
74 | input: ToolUIPart['input']
75 | }
76 |
77 | export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
78 |
79 |
Parameters
80 |
81 |
82 |
83 |
84 | )
85 |
86 | export type ToolOutputProps = ComponentProps<'div'> & {
87 | output: ReactNode
88 | errorText: ToolUIPart['errorText']
89 | }
90 |
91 | export const ToolOutput = ({ className, output, errorText, ...props }: ToolOutputProps) => {
92 | if (!(output || errorText)) {
93 | return null
94 | }
95 |
96 | return (
97 |
98 |
99 | {errorText ? 'Error' : 'Result'}
100 |
101 |
107 | {errorText &&
{errorText}
}
108 | {output &&
{output}
}
109 |
110 |
111 | )
112 | }
113 |
--------------------------------------------------------------------------------
/src/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as SheetPrimitive from '@radix-ui/react-dialog'
5 | import { XIcon } from 'lucide-react'
6 |
7 | import { cn } from '@/lib/utils'
8 |
9 | function Sheet({ ...props }: React.ComponentProps) {
10 | return
11 | }
12 |
13 | function SheetTrigger({ ...props }: React.ComponentProps) {
14 | return
15 | }
16 |
17 | function SheetClose({ ...props }: React.ComponentProps) {
18 | return
19 | }
20 |
21 | function SheetPortal({ ...props }: React.ComponentProps) {
22 | return
23 | }
24 |
25 | function SheetOverlay({ className, ...props }: React.ComponentProps) {
26 | return (
27 |
35 | )
36 | }
37 |
38 | function SheetContent({
39 | className,
40 | children,
41 | side = 'right',
42 | ...props
43 | }: React.ComponentProps & {
44 | side?: 'top' | 'right' | 'bottom' | 'left'
45 | }) {
46 | return (
47 |
48 |
49 |
65 | {children}
66 |
67 |
68 | Close
69 |
70 |
71 |
72 | )
73 | }
74 |
75 | function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
76 | return
77 | }
78 |
79 | function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
80 | return
81 | }
82 |
83 | function SheetTitle({ className, ...props }: React.ComponentProps) {
84 | return (
85 |
90 | )
91 | }
92 |
93 | function SheetDescription({ className, ...props }: React.ComponentProps) {
94 | return (
95 |
100 | )
101 | }
102 |
103 | export { Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription }
104 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import * as DialogPrimitive from '@radix-ui/react-dialog'
3 | import { XIcon } from 'lucide-react'
4 |
5 | import { cn } from '@/lib/utils'
6 |
7 | function Dialog({ ...props }: React.ComponentProps) {
8 | return
9 | }
10 |
11 | function DialogTrigger({ ...props }: React.ComponentProps) {
12 | return
13 | }
14 |
15 | function DialogPortal({ ...props }: React.ComponentProps) {
16 | return
17 | }
18 |
19 | function DialogClose({ ...props }: React.ComponentProps) {
20 | return
21 | }
22 |
23 | function DialogOverlay({ className, ...props }: React.ComponentProps) {
24 | return (
25 |
33 | )
34 | }
35 |
36 | function DialogContent({
37 | className,
38 | children,
39 | showCloseButton = true,
40 | ...props
41 | }: React.ComponentProps & {
42 | showCloseButton?: boolean
43 | }) {
44 | return (
45 |
46 |
47 |
55 | {children}
56 | {showCloseButton && (
57 |
61 |
62 | Close
63 |
64 | )}
65 |
66 |
67 | )
68 | }
69 |
70 | function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
71 | return (
72 |
77 | )
78 | }
79 |
80 | function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
81 | return (
82 |
87 | )
88 | }
89 |
90 | function DialogTitle({ className, ...props }: React.ComponentProps) {
91 | return (
92 |
97 | )
98 | }
99 |
100 | function DialogDescription({ className, ...props }: React.ComponentProps) {
101 | return (
102 |
107 | )
108 | }
109 |
110 | export {
111 | Dialog,
112 | DialogClose,
113 | DialogContent,
114 | DialogDescription,
115 | DialogFooter,
116 | DialogHeader,
117 | DialogOverlay,
118 | DialogPortal,
119 | DialogTitle,
120 | DialogTrigger,
121 | }
122 |
--------------------------------------------------------------------------------
/src/components/ai-elements/code-block.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/ui/button'
2 | import { cn } from '@/lib/utils'
3 | import { CheckIcon, CopyIcon } from 'lucide-react'
4 | import type { ComponentProps, HTMLAttributes, ReactNode } from 'react'
5 | import { createContext, useContext, useState } from 'react'
6 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
7 | import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'
8 |
9 | interface CodeBlockContextType {
10 | code: string
11 | }
12 |
13 | const CodeBlockContext = createContext({
14 | code: '',
15 | })
16 |
17 | export type CodeBlockProps = HTMLAttributes & {
18 | code: string
19 | // TODO add any more languages we want here
20 | language: 'json'
21 | showLineNumbers?: boolean
22 | children?: ReactNode
23 | }
24 |
25 | export const CodeBlock = ({
26 | code,
27 | language,
28 | showLineNumbers = false,
29 | className,
30 | children,
31 | ...props
32 | }: CodeBlockProps) => (
33 |
34 |
38 |
39 |
60 | {code}
61 |
62 |
83 | {code}
84 |
85 | {children &&
{children}
}
86 |
87 |
88 |
89 | )
90 |
91 | export type CodeBlockCopyButtonProps = ComponentProps & {
92 | onCopy?: () => void
93 | onError?: (error: Error) => void
94 | timeout?: number
95 | }
96 |
97 | export const CodeBlockCopyButton = ({
98 | onCopy,
99 | onError,
100 | timeout = 2000,
101 | children,
102 | className,
103 | ...props
104 | }: CodeBlockCopyButtonProps) => {
105 | const [isCopied, setIsCopied] = useState(false)
106 | const { code } = useContext(CodeBlockContext)
107 |
108 | const copyToClipboard = () => {
109 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
110 | if (typeof window === 'undefined' || !navigator.clipboard.writeText) {
111 | onError?.(new Error('Clipboard API not available'))
112 | return
113 | }
114 |
115 | navigator.clipboard
116 | .writeText(code)
117 | .then(() => {
118 | setIsCopied(true)
119 | onCopy?.()
120 | setTimeout(() => {
121 | setIsCopied(false)
122 | }, timeout)
123 | })
124 | .catch((error: unknown) => {
125 | onError?.(error as Error)
126 | })
127 | }
128 |
129 | const Icon = isCopied ? CheckIcon : CopyIcon
130 |
131 | return (
132 |
135 | )
136 | }
137 |
--------------------------------------------------------------------------------
/src/components/ai-elements/reasoning.tsx:
--------------------------------------------------------------------------------
1 | import { useControllableState } from '@radix-ui/react-use-controllable-state'
2 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
3 | import { cn } from '@/lib/utils'
4 | import { BrainIcon, ChevronDownIcon } from 'lucide-react'
5 | import type { ComponentProps } from 'react'
6 | import { createContext, memo, useContext, useEffect, useState } from 'react'
7 | import { Response } from './response'
8 |
9 | interface ReasoningContextValue {
10 | isStreaming: boolean
11 | isOpen: boolean
12 | setIsOpen: (open: boolean) => void
13 | duration: number
14 | }
15 |
16 | const ReasoningContext = createContext(null)
17 |
18 | const useReasoning = () => {
19 | const context = useContext(ReasoningContext)
20 | if (!context) {
21 | throw new Error('Reasoning components must be used within Reasoning')
22 | }
23 | return context
24 | }
25 |
26 | export type ReasoningProps = ComponentProps & {
27 | isStreaming?: boolean
28 | open?: boolean
29 | defaultOpen?: boolean
30 | onOpenChange?: (open: boolean) => void
31 | duration?: number
32 | }
33 |
34 | const AUTO_CLOSE_DELAY = 1000
35 | const MS_IN_S = 1000
36 |
37 | export const Reasoning = memo(
38 | ({
39 | className,
40 | isStreaming = false,
41 | open,
42 | defaultOpen = true,
43 | // eslint-disable-next-line @typescript-eslint/unbound-method
44 | onOpenChange,
45 | duration: durationProp,
46 | children,
47 | ...props
48 | }: ReasoningProps) => {
49 | const [isOpen, setIsOpen] = useControllableState({
50 | prop: open,
51 | defaultProp: defaultOpen,
52 | onChange: onOpenChange,
53 | })
54 | const [duration, setDuration] = useControllableState({
55 | prop: durationProp,
56 | defaultProp: 0,
57 | })
58 |
59 | const [hasAutoClosedRef, setHasAutoClosedRef] = useState(false)
60 | const [startTime, setStartTime] = useState(null)
61 |
62 | // Track duration when streaming starts and ends
63 | useEffect(() => {
64 | if (isStreaming) {
65 | if (startTime === null) {
66 | setStartTime(Date.now())
67 | }
68 | } else if (startTime !== null) {
69 | setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S))
70 | setStartTime(null)
71 | }
72 | }, [isStreaming, startTime, setDuration])
73 |
74 | // Auto-open when streaming starts, auto-close when streaming ends (once only)
75 | useEffect(() => {
76 | if (defaultOpen && !isStreaming && isOpen && !hasAutoClosedRef) {
77 | // Add a small delay before closing to allow user to see the content
78 | const timer = setTimeout(() => {
79 | setIsOpen(false)
80 | setHasAutoClosedRef(true)
81 | }, AUTO_CLOSE_DELAY)
82 |
83 | return () => {
84 | clearTimeout(timer)
85 | }
86 | }
87 | }, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosedRef])
88 |
89 | const handleOpenChange = (newOpen: boolean) => {
90 | setIsOpen(newOpen)
91 | }
92 |
93 | return (
94 |
95 |
101 | {children}
102 |
103 |
104 | )
105 | },
106 | )
107 |
108 | export type ReasoningTriggerProps = ComponentProps
109 |
110 | export const ReasoningTrigger = memo(({ className, children, ...props }: ReasoningTriggerProps) => {
111 | const { isStreaming, isOpen, duration } = useReasoning()
112 |
113 | return (
114 |
115 | {children ?? (
116 | <>
117 |
118 | {isStreaming || duration === 0 ? (
119 | Thinking...
120 | ) : (
121 |
122 | Thought for {duration} {duration === 1 ? 'second' : 'seconds'}
123 |
124 | )}
125 |
128 | >
129 | )}
130 |
131 | )
132 | })
133 |
134 | export type ReasoningContentProps = ComponentProps & {
135 | children: string
136 | }
137 |
138 | export const ReasoningContent = memo(({ className, children, ...props }: ReasoningContentProps) => (
139 |
147 | {children}
148 |
149 | ))
150 |
151 | Reasoning.displayName = 'Reasoning'
152 | ReasoningTrigger.displayName = 'ReasoningTrigger'
153 | ReasoningContent.displayName = 'ReasoningContent'
154 |
--------------------------------------------------------------------------------
/src/components/ai-elements/branch.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/ui/button'
2 | import { cn } from '@/lib/utils'
3 | import type { UIMessage } from 'ai'
4 | import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
5 | import type { ComponentProps, HTMLAttributes, ReactElement } from 'react'
6 | import { createContext, useContext, useEffect, useState } from 'react'
7 |
8 | interface BranchContextType {
9 | currentBranch: number
10 | totalBranches: number
11 | goToPrevious: () => void
12 | goToNext: () => void
13 | branches: ReactElement[]
14 | setBranches: (branches: ReactElement[]) => void
15 | }
16 |
17 | const BranchContext = createContext(null)
18 |
19 | const useBranch = () => {
20 | const context = useContext(BranchContext)
21 |
22 | if (!context) {
23 | throw new Error('Branch components must be used within Branch')
24 | }
25 |
26 | return context
27 | }
28 |
29 | export type BranchProps = HTMLAttributes & {
30 | defaultBranch?: number
31 | onBranchChange?: (branchIndex: number) => void
32 | }
33 |
34 | export const Branch = ({ defaultBranch = 0, onBranchChange, className, ...props }: BranchProps) => {
35 | const [currentBranch, setCurrentBranch] = useState(defaultBranch)
36 | const [branches, setBranches] = useState([])
37 |
38 | const handleBranchChange = (newBranch: number) => {
39 | setCurrentBranch(newBranch)
40 | onBranchChange?.(newBranch)
41 | }
42 |
43 | const goToPrevious = () => {
44 | const newBranch = currentBranch > 0 ? currentBranch - 1 : branches.length - 1
45 | handleBranchChange(newBranch)
46 | }
47 |
48 | const goToNext = () => {
49 | const newBranch = currentBranch < branches.length - 1 ? currentBranch + 1 : 0
50 | handleBranchChange(newBranch)
51 | }
52 |
53 | const contextValue: BranchContextType = {
54 | currentBranch,
55 | totalBranches: branches.length,
56 | goToPrevious,
57 | goToNext,
58 | branches,
59 | setBranches,
60 | }
61 |
62 | return (
63 |
64 | div]:pb-0', className)} {...props} />
65 |
66 | )
67 | }
68 |
69 | export type BranchMessagesProps = HTMLAttributes
70 |
71 | export const BranchMessages = ({ children, ...props }: BranchMessagesProps) => {
72 | const { currentBranch, setBranches, branches } = useBranch()
73 | const childrenArray = (Array.isArray(children) ? children : [children]) as ReactElement[]
74 |
75 | // Use useEffect to update branches when they change
76 | useEffect(() => {
77 | if (branches.length !== childrenArray.length) {
78 | setBranches(childrenArray)
79 | }
80 | }, [childrenArray, branches, setBranches])
81 |
82 | return childrenArray.map((branch, index) => (
83 | div]:pb-0', index === currentBranch ? 'block' : 'hidden')}
85 | key={branch.key}
86 | {...props}
87 | >
88 | {branch}
89 |
90 | ))
91 | }
92 |
93 | export type BranchSelectorProps = HTMLAttributes & {
94 | from: UIMessage['role']
95 | }
96 |
97 | export const BranchSelector = ({ className, from, ...props }: BranchSelectorProps) => {
98 | const { totalBranches } = useBranch()
99 |
100 | // Don't render if there's only one branch
101 | if (totalBranches <= 1) {
102 | return null
103 | }
104 |
105 | return (
106 |
114 | )
115 | }
116 |
117 | export type BranchPreviousProps = ComponentProps
118 |
119 | export const BranchPrevious = ({ className, children, ...props }: BranchPreviousProps) => {
120 | const { goToPrevious, totalBranches } = useBranch()
121 |
122 | return (
123 |
140 | )
141 | }
142 |
143 | export type BranchNextProps = ComponentProps
144 |
145 | export const BranchNext = ({ className, children, ...props }: BranchNextProps) => {
146 | const { goToNext, totalBranches } = useBranch()
147 |
148 | return (
149 |
166 | )
167 | }
168 |
169 | export type BranchPageProps = HTMLAttributes
170 |
171 | export const BranchPage = ({ className, ...props }: BranchPageProps) => {
172 | const { currentBranch, totalBranches } = useBranch()
173 |
174 | return (
175 |
176 | {currentBranch + 1} of {totalBranches}
177 |
178 | )
179 | }
180 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @import 'tailwindcss';
2 | @import 'tw-animate-css';
3 |
4 | @source "../node_modules/streamdown/dist/index.js";
5 |
6 | @custom-variant dark (&:is(.dark *));
7 |
8 | @theme inline {
9 | --radius-sm: calc(var(--radius) - 4px);
10 | --radius-md: calc(var(--radius) - 2px);
11 | --radius-lg: var(--radius);
12 | --radius-xl: calc(var(--radius) + 4px);
13 | --color-background: var(--background);
14 | --color-foreground: var(--foreground);
15 | --color-card: var(--card);
16 | --color-card-foreground: var(--card-foreground);
17 | --color-popover: var(--popover);
18 | --color-popover-foreground: var(--popover-foreground);
19 | --color-primary: var(--primary);
20 | --color-primary-foreground: var(--primary-foreground);
21 | --color-secondary: var(--secondary);
22 | --color-secondary-foreground: var(--secondary-foreground);
23 | --color-muted: var(--muted);
24 | --color-muted-foreground: var(--muted-foreground);
25 | --color-accent: var(--accent);
26 | --color-accent-foreground: var(--accent-foreground);
27 | --color-destructive: var(--destructive);
28 | --color-border: var(--border);
29 | --color-input: var(--input);
30 | --color-ring: var(--ring);
31 | --color-chart-1: var(--chart-1);
32 | --color-chart-2: var(--chart-2);
33 | --color-chart-3: var(--chart-3);
34 | --color-chart-4: var(--chart-4);
35 | --color-chart-5: var(--chart-5);
36 | --color-sidebar: var(--sidebar);
37 | --color-sidebar-foreground: var(--sidebar-foreground);
38 | --color-sidebar-primary: var(--sidebar-primary);
39 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
40 | --color-sidebar-accent: var(--sidebar-accent);
41 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
42 | --color-sidebar-border: var(--sidebar-border);
43 | --color-sidebar-ring: var(--sidebar-ring);
44 | --color-sugar: var(--sugar);
45 | --color-pydantic-brand: var(--pydantic-brand);
46 | --text-tiny: 0.625rem;
47 | }
48 |
49 | :root {
50 | --radius: 0.65rem;
51 | --background: oklch(1 0 0);
52 | --foreground: oklch(0.141 0.005 285.823);
53 | --card: oklch(1 0 0);
54 | --card-foreground: oklch(0.141 0.005 285.823);
55 | --popover: oklch(1 0 0);
56 | --popover-foreground: oklch(0.141 0.005 285.823);
57 | --primary: oklch(0.707 0.109 164.4);
58 | --primary-foreground: oklch(0.969 0.015 12.422);
59 | --secondary: oklch(0.967 0.001 286.375);
60 | --secondary-foreground: oklch(0.21 0.006 285.885);
61 | --muted: oklch(0.967 0.001 286.375);
62 | --muted-foreground: oklch(0.552 0.016 285.938);
63 | --accent: oklch(0.956 0.048 115.7);
64 | --accent-foreground: oklch(0.21 0.006 285.885);
65 | --destructive: oklch(0.577 0.245 27.325);
66 | --border: oklch(0.92 0.004 286.32);
67 | --input: oklch(0.92 0.004 286.32);
68 | --ring: oklch(0.707 0.149 164.4);
69 | --chart-1: oklch(0.646 0.222 41.116);
70 | --chart-2: oklch(0.6 0.118 184.704);
71 | --chart-3: oklch(0.398 0.07 227.392);
72 | --chart-4: oklch(0.828 0.189 84.429);
73 | --chart-5: oklch(0.769 0.188 70.08);
74 | --sugar: oklch(0.991 0.006 115.7);
75 | --sidebar: var(--sugar);
76 | --sidebar-foreground: oklch(0.141 0.005 285.823);
77 | --sidebar-primary: oklch(0.707 0.149 164.4);
78 | --sidebar-primary-foreground: oklch(0.969 0.015 12.422);
79 | --sidebar-accent: oklch(0.956 0.048 115.7);
80 | --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
81 | --sidebar-border: oklch(0.92 0.004 286.32);
82 | --sidebar-ring: oklch(0.707 0.149 164.4);
83 | --pydantic-brand: oklch(0.69 0.3 328);
84 | }
85 |
86 | .dark {
87 | --background: oklch(0.141 0.005 285.823);
88 | --foreground: oklch(0.985 0 0);
89 | --card: oklch(0.21 0.006 285.885);
90 | --card-foreground: oklch(0.985 0 0);
91 | --popover: oklch(0.21 0.006 285.885);
92 | --popover-foreground: oklch(0.985 0 0);
93 | --primary: oklch(0.707 0.109 164.4);
94 | --primary-foreground: oklch(0.969 0.015 12.422);
95 | --secondary: oklch(0.274 0.006 286.033);
96 | --secondary-foreground: oklch(0.985 0 0);
97 | --muted: oklch(0.274 0.006 286.033);
98 | --muted-foreground: oklch(0.705 0.015 286.067);
99 | --accent: oklch(0.23 0.05 115.7);
100 | --accent-foreground: oklch(0.985 0 0);
101 | --destructive: oklch(0.704 0.191 22.216);
102 | --border: oklch(1 0 0 / 10%);
103 | --input: oklch(1 0 0 / 15%);
104 | --ring: oklch(0.707 0.149 164.4);
105 | --chart-1: oklch(0.488 0.243 264.376);
106 | --chart-2: oklch(0.696 0.17 162.48);
107 | --chart-3: oklch(0.769 0.188 70.08);
108 | --chart-4: oklch(0.627 0.265 303.9);
109 | --chart-5: oklch(0.707 0.149 164.4);
110 | --sugar: oklch(0.18 0.033 115.7);
111 | --sidebar: var(--sugar);
112 | --sidebar-foreground: oklch(0.985 0 0);
113 | --sidebar-primary: oklch(0.707 0.149 164.4);
114 | --sidebar-primary-foreground: oklch(0.969 0.015 12.422);
115 | --sidebar-accent: oklch(0.23 0.05 115.7);
116 | --sidebar-accent-foreground: oklch(0.985 0 0);
117 | --sidebar-border: oklch(1 0 0 / 10%);
118 | --sidebar-ring: oklch(0.707 0.149 164.4);
119 | --pydantic-brand: oklch(0.69 0.3 328);
120 | }
121 |
122 | @layer base {
123 | * {
124 | @apply border-border outline-ring/50;
125 | }
126 |
127 | body {
128 | @apply bg-background text-foreground;
129 | }
130 | }
131 |
132 | @utility custom-scrollbar {
133 | & > div::-webkit-scrollbar {
134 | width: 0.5rem;
135 | }
136 |
137 | & > div::-webkit-scrollbar-track {
138 | border-radius: 100vh;
139 | background: transparent;
140 | }
141 |
142 | & > div::-webkit-scrollbar-thumb {
143 | background: var(--accent);
144 | border-radius: 100vh;
145 | }
146 |
147 | & > div::-webkit-scrollbar-thumb:hover {
148 | background: #c0a0b9;
149 | }
150 | }
151 |
152 | /* for streamdown markdown content, background color should work for both light and dark content */
153 | .code-bg .bg-muted {
154 | background: #b0b8c442;
155 | }
156 |
157 | .rounded {
158 | border-radius: 4px;
159 | }
160 |
161 | html,
162 | body,
163 | #root {
164 | height: 100%;
165 | }
166 |
--------------------------------------------------------------------------------
/src/components/ai-elements/prompt-input.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/ui/button'
2 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
3 | import { Textarea } from '@/components/ui/textarea'
4 | import { cn } from '@/lib/utils'
5 | import type { ChatStatus } from 'ai'
6 | import { Loader2Icon, SendIcon, SquareIcon, XIcon } from 'lucide-react'
7 | import type { ComponentProps, HTMLAttributes, KeyboardEventHandler } from 'react'
8 | import { Children } from 'react'
9 |
10 | export type PromptInputProps = HTMLAttributes
11 |
12 | export const PromptInput = ({ className, ...props }: PromptInputProps) => (
13 |
14 |
21 |
22 | )
23 |
24 | export type PromptInputTextareaProps = ComponentProps & {
25 | minHeight?: number
26 | maxHeight?: number
27 | }
28 |
29 | export const PromptInputTextarea = ({
30 | onChange,
31 | className,
32 | placeholder = 'What would you like to know?',
33 | ...props
34 | }: PromptInputTextareaProps) => {
35 | const handleKeyDown: KeyboardEventHandler = (e) => {
36 | if (e.key === 'Enter') {
37 | // Don't submit if IME composition is in progress
38 | if (e.nativeEvent.isComposing) {
39 | return
40 | }
41 |
42 | if (e.shiftKey) {
43 | // Allow newline
44 | return
45 | }
46 |
47 | // Submit on Enter (without Shift)
48 | e.preventDefault()
49 | const form = e.currentTarget.form
50 | if (form) {
51 | form.requestSubmit()
52 | }
53 | }
54 | }
55 |
56 | return (
57 |