feat(agent): AI agent harness with memory, GitHub, audio & feedback (#37)

* feat(agent): replace ElizaOS with AI SDK v6 harness

Replace custom ElizaOS sidecar proxy with Vercel AI SDK v6 +
OpenRouter provider for a proper agentic harness with multi-step
tool loops, streaming, and D1 conversation persistence.

- Add AI SDK agent library (provider, tools, system prompt, catalog)
- Rewrite API route to use streamText with 10-step tool loop
- Add server actions for conversation save/load/delete
- Migrate chat-panel and dashboard-chat to useChat hook
- Add action handler dispatch for navigate/toast/render tools
- Use qwen/qwen3-coder-next via OpenRouter (fallbacks disabled)
- Delete src/lib/eliza/ (replaced entirely)
- Exclude references/ from tsconfig build

* fix(chat): improve dashboard chat scroll and text size

- Rewrite auto-scroll: pin user message 75% out of
  frame after send, then follow bottom during streaming
- Use useEffect for scroll timing (DOM guaranteed ready)
  instead of rAF which fired before React commit
- Add user scroll detection to disengage auto-scroll
- Bump assistant text from 13px back to 14px (text-sm)
- Tighten prose spacing for headings and lists

* chore: installing new components

* refactor(chat): unify into one component, two presentations

Extract duplicated chat logic into shared ChatProvider context
and useCompassChat hook. Single ChatView component renders as
full-page hero on /dashboard or sidebar panel elsewhere. Chat
state persists across navigation.

New: chat-provider, chat-view, chat-panel-shell, use-compass-chat
Delete: agent-provider, chat-panel, dashboard-chat, 8 deprecated UI files
Fix: AI component import paths (~/  -> @/), shadcn component updates

* fix(lint): resolve eslint errors in AI components

- escape unescaped entities in demo JSX (actions, artifact,
  branch, reasoning, schema-display, task)
- add eslint-disable for @ts-nocheck in vendor components
  (file-tree, terminal, persona)
- remove unused imports in chat-view (ArrowUp, Square,
  useChatPanel)

* feat(agent): rename AI to Slab, add proactive help

rename assistant from Compass to Slab and add first
interaction guidance so it proactively offers
context-aware help based on the user's current page.

* fix(build): use HTML entity for strict string children

ReasoningContent expects children: string, so JSX
expression {"'"} splits into string[] causing type error.
Use ' HTML entity instead.

* feat(agent): add memory, github, audio, feedback

- persistent memory system (remember/recall across sessions)
- github integration (commits, PRs, issues, contributors)
- audio transcription via Whisper API
- UX feedback interview flow with auto-issue creation
- memories management table in settings
- audio waveform visualization component
- new schema tables: slab_memories, feedback_interviews
- enhanced system prompt with proactive tool usage

* feat(agent): unify chat into single morphing instance

Replaces two separate ChatView instances (page + panel) with
one layout-level component that transitions between full-page
and sidebar modes. Navigation now actually works via proper
AI SDK v6 part structure detection, with view transitions for
smooth crossfades, route validation to prevent 404s, and
auto-opening the panel when leaving dashboard.

Also fixes dark mode contrast, user bubble visibility, tool
display names, input focus ring, and system prompt accuracy.

* refactor(agent): rewrite waveform as time-series viz

Replace real-time frequency equalizer with amplitude
history that fills left-to-right as user speaks.
Bars auto-calculated from container width, with
non-linear boost and scroll when full.

* (feat): implemented architecture for plugins and skills, laying a foundation for future implementations of packages separate from the core application

* feat(agent): add skills.sh integration for slab

Skills client fetches SKILL.md from GitHub, parses
YAML frontmatter, and stores content in plugin DB.
Registry injects skill content into system prompt.
Agent tools and settings UI for skill management.

* feat(agent): add interactive UI action bridge

Wire agent-generated UIs to real server actions via
an action bridge API route. Forms submit, checkboxes
persist, and DataTable rows support CRUD operations.

- action-registry.ts: maps 19 dotted action names to
  server actions with zod validation + permissions
- /api/agent/action: POST route with auth, permission
  checks, schema validation, and action execution
- schema-agent.ts: agent_items table for user-scoped
  todos, notes, and checklists
- agent-items.ts: CRUD + toggle actions for agent items
- form-context.ts: FormIdProvider for input namespacing
- catalog.ts: Form component, value/onChangeAction props,
  DataTable rowActions, mutate/confirmDelete actions
- registry.tsx: useDataBinding on all form inputs, Form
  component, DataTable row action buttons, inline
  Checkbox/Switch mutations
- actions.ts: mutate + confirmDelete handlers that call
  the action bridge, formSubmit now collects + submits
- system-prompt.ts: interactive UI patterns section
- render/route.ts: interactive pattern custom rules

* docs: reorganize into topic subdirectories

Move docs into auth/, chat/, openclaw-principles/,
and ui/ subdirectories. Add openclaw architecture
and system prompt documentation.

* feat(agent): add commit diff support to github tools

Add fetchCommitDiff to github client with raw diff
fallback for missing patches. Wire commit_diff query
type into agent github tools.

* fix(ci): guard wrangler proxy init for dev only

initOpenNextCloudflareForDev() was running unconditionally
in next.config.ts, causing CI build and lint to fail with
"You must be logged in to use wrangler dev in remote mode".
Only init the proxy when NODE_ENV is development.

---------

Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
This commit is contained in:
Nicholai 2026-02-06 17:04:04 -07:00 committed by GitHub
parent e9faea5596
commit 8b34becbeb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
152 changed files with 42624 additions and 3999 deletions

279
CLAUDE.md
View File

@ -1,106 +1,229 @@
dashboard-app-template # CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
compass
=== ===
a Next.js 15 dashboard template deployed to Cloudflare Workers via OpenNext. a construction project management system built with Next.js 15 + React 19, designed to replace BuilderTrend with a lean, self-hosted, open-source alternative. deployed to Cloudflare Workers via OpenNext.
quick start
---
```bash
bun dev # turbopack dev server on :3000
bun run build # production build
bun preview # test build on cloudflare runtime
bun deploy # build and deploy to cloudflare workers
bun lint # run eslint
```
database commands:
```bash
bun run db:generate # generate drizzle migrations from schema
bun run db:migrate:local # apply migrations locally
bun run db:migrate:prod # apply migrations to production D1
```
tech stack tech stack
--- ---
- Next.js 15.5 with Turbopack | layer | tech |
- React 19 |-------|------|
- Tailwind CSS v4 | framework | Next.js 15 App Router, React 19 |
- shadcn/ui (new-york style) | language | TypeScript 5.x |
- Recharts for data visualization | ui | shadcn/ui (new-york style), Tailwind CSS v4 |
- Cloudflare Workers (via @opennextjs/cloudflare) | charts | Recharts |
| database | Cloudflare D1 (SQLite) via Drizzle ORM |
| auth | WorkOS (SSO, directory sync) |
| ai agent | AI SDK v6 + OpenRouter (kimi-k2.5 model) |
| integrations | NetSuite REST API |
| state | React Context, server actions |
commands
critical architecture patterns
--- ---
- `bun dev` - start dev server (turbopack) ### server actions & data flow
- `bun build` - production build - all data mutations go through server actions in `src/app/actions/`
- `bun preview` - build and preview on cloudflare runtime - pattern: `return { success: true } | { success: false; error: string }`
- `bun deploy` - build and deploy to cloudflare - server actions revalidate paths with `revalidatePath()` to update client
- `bun lint` - run eslint - no fetch() in components - use actions instead
- environment variables accessed via `getCloudflareContext()``env.DB` for D1
### database
- schema split across `src/db/schema.ts` (core) and `src/db/schema-netsuite.ts` (integration)
- drizzle ORM with D1 (SQLite dialect)
- text IDs (UUIDs), text dates (ISO 8601 format)
- migrations in `drizzle/` directory - **add new migrations, never modify old ones**
### authentication & middleware
- WorkOS handles SSO, email/password, directory sync
- middleware in `src/middleware.ts` checks session and redirects unauthenticated users to `/login`
- public paths: `/`, `/login`, `/signup`, `/reset-password`, `/verify-email`, `/invite`, `/callback`, `/api/auth/*`, `/api/netsuite/*`
- `getCurrentUser()` from `lib/auth.ts` returns user info with database lookup fallback
### ai agent harness
- located in `src/lib/agent/` - a complete AI-assisted system
- `provider.ts`: OpenRouter setup for kimi-k2.5 model
- `tools.ts`: queryData, navigateTo, showNotification, renderComponent tools
- `system-prompt.ts`: dynamic prompt builder with page/user context
- `catalog.ts`: component specs for DynamicUI rendering
- `chat-adapter.ts`: re-exports useChat hook + action handlers
- `src/app/api/agent/route.ts`: streamText endpoint with 10-step multi-tool loop
- `src/app/actions/agent.ts`: D1 persistence (save/load/delete conversations)
- components: `src/components/agent/chat-panel.tsx` (side panel), `src/components/dashboard-chat.tsx` (inline)
- ai sdk v6 gotchas:
- `inputSchema` not `parameters` for tool() definitions
- UIMessage uses `parts` array, no `.content` field
- useChat: `sendMessage({ text })` not `append({ role, content })`
- useChat: `status` is "streaming"|"submitted"|"ready"|"error", not `isGenerating`
- zod schemas must use `import { z } from "zod/v4"` to match AI SDK internals
### netsuite integration
- full bidirectional sync via REST API
- key files in `src/lib/netsuite/`:
- `config.ts`: account setup, URL builders
- `auth/`: oauth 2.0 flow, token manager, AES-GCM encryption
- `client/`: base HTTP client (retry, circuit breaker), record client, suiteql client
- `rate-limiter/`: semaphore-based concurrency limiter (15 concurrent default)
- `sync/`: sync engine, delta sync, conflict resolver, push logic, idempotency
- `mappers/`: customer, vendor, project, invoice, vendor-bill mappers
- env vars: NETSUITE_CLIENT_ID, NETSUITE_CLIENT_SECRET, NETSUITE_ACCOUNT_ID, NETSUITE_REDIRECT_URI, NETSUITE_TOKEN_ENCRYPTION_KEY, NETSUITE_CONCURRENCY_LIMIT
- gotchas:
- 401 errors can mean timeout, not auth failure
- "field doesn't exist" often means permission denied
- 15 concurrent request limit shared across ALL integrations
- no batch create/update via REST (single record per request)
- sandbox URLs use different separators (123456-sb1 vs 123456_SB1)
- omitting "line" param on line items adds new line (doesn't update)
### typescript discipline
- strict types: no `any`, no `as`, no `!` - use `unknown` with proper narrowing
- discriminated unions over optional properties: `{ status: 'ok'; data: T } | { status: 'error'; error: E }`
- `readonly` everywhere mutation isn't intended: `ReadonlyArray<T>`, `Readonly<Record<K, V>>`
- no `enum` - use `as const` objects or union types instead
- branded types for primitive identifiers to prevent mixing up IDs of different types
- explicit return types on all exported functions
- result types over exceptions: return `{ success: true } | { success: false; error }` from actions
- effect-free module scope: no `console.log`, `fetch`, or mutations during import
project structure project structure
--- ---
``` ```
src/ src/
├── app/ # Next.js app router ├── app/ # next.js app router
│ ├── dashboard/ # dashboard routes │ ├── (auth)/ # auth pages (login, signup, etc)
│ ├── globals.css # tailwind + theme variables │ ├── api/ # api routes (server actions, agent, webhooks)
│ ├── layout.tsx # root layout │ ├── dashboard/ # protected dashboard routes
│ └── page.tsx # home page │ ├── actions/ # server actions (data mutations)
├── components/ │ ├── globals.css # tailwind + theme variables
│ ├── ui/ # shadcn/ui primitives │ ├── layout.tsx # root layout
│ └── *.tsx # app-specific components (sidebar, charts, tables) │ ├── middleware.ts # auth + public path checks
├── hooks/ # custom react hooks │ └── page.tsx # home page
└── lib/ ├── components/ # react components
└── utils.ts # cn() helper for class merging │ ├── ui/ # shadcn/ui primitives (auto-generated)
│ ├── agent/ # ai chat components
│ ├── netsuite/ # netsuite connection ui
│ └── *.tsx # app-specific components
├── db/
│ ├── index.ts # getDb() function
│ ├── schema.ts # core drizzle schema
│ └── schema-netsuite.ts # netsuite-specific tables
├── hooks/ # custom react hooks
├── lib/
│ ├── agent/ # ai agent harness
│ ├── netsuite/ # netsuite integration
│ ├── auth.ts # workos integration
│ ├── permissions.ts # rbac checks
│ ├── utils.ts # cn() for class merging
│ └── validations/ # zod schemas
└── types/ # global typescript types
drizzle/ # database migrations (auto-generated)
docs/ # user documentation
public/ # static assets
wrangler.jsonc # cloudflare workers config
drizzle.config.ts # drizzle orm config
next.config.ts # next.js config
tsconfig.json # typescript config
``` ```
shadcn/ui component conventions
--- ---
uses shadcn/ui with new-york style. add components via: shadcn/ui setup:
- new-york style
- add components: `bunx shadcn@latest add <component-name>`
- aliases: `@/components`, `@/components/ui`, `@/lib`, `@/hooks`
```bash ui patterns:
bunx shadcn@latest add <component-name> - use `cn()` from `@/lib/utils.ts` for conditional classes
- form validation via react-hook-form + zod
- animations via framer-motion or tailwind css
- icons from lucide-react or @tabler/icons-react (both configured with package import optimization)
- data tables via tanstack/react-table
environment variables
---
dev: `.dev.vars` (gitignored)
prod: cloudflare dashboard secrets
required:
- WORKOS_API_KEY, WORKOS_CLIENT_ID
- OPENROUTER_API_KEY (for ai agent)
- NETSUITE_* (if using netsuite sync)
development tips
---
### accessing database in actions
```typescript
import { getCloudflareContext } from "@opennextjs/cloudflare"
const { env } = await getCloudflareContext()
const db = env.DB // D1 binding
``` ```
config in `components.json`. aliases: ### using getCurrentUser() in actions
- `@/components` -> src/components ```typescript
- `@/components/ui` -> src/components/ui import { getCurrentUser } from "@/lib/auth"
- `@/lib` -> src/lib const user = await getCurrentUser()
- `@/hooks` -> src/hooks if (!user) throw new Error("Not authenticated")
```
cloudflare deployment ### revalidating paths after mutations
```typescript
import { revalidatePath } from "next/cache"
// after changing data
revalidatePath("/dashboard/projects") // specific path
revalidatePath("/", "layout") // entire layout
```
### querying data in components
- server components can use `getDb()` directly
- client components must call server actions
- never `fetch()` from client components - use actions
### type guards for discriminated unions
don't use `as` - write proper type guards:
```typescript
function isError<E>(result: { success: boolean; error?: E }): result is { success: false; error: E } {
return !result.success && result.error !== undefined
}
```
known issues & WIP
--- ---
configured in `wrangler.jsonc`. uses OpenNext for Next.js compatibility. - gantt chart vertical panning: zoom/horizontal pan work, but vertical pan conflicts with frappe-gantt container sizing. needs transform-based rendering approach with fixed header.
env vars go in `.dev.vars` (local) or cloudflare dashboard (prod). open source contribution notes
key bindings:
- `ASSETS` - static asset serving
- `IMAGES` - cloudflare image optimization
- `WORKER_SELF_REFERENCE` - self-reference for caching
known issues (WIP)
--- ---
- gantt chart pan/zoom: zoom controls (+/-) and ctrl+scroll work. pan mode - repo at github.com/High-Performance-Structures/compass (private, invite-only)
toggle (pointer/grab) exists but vertical panning does not work correctly - branching: `<username>/<feature>` off main
yet - the scroll-based approach conflicts with how frappe-gantt sizes its - conventional commits: `type(scope): subject`
container. horizontal panning works. needs a different approach for - PRs are squash-merged to main
vertical navigation (possibly a custom viewport with transform-based - deployment to cloudflare is manual via `bun deploy`
rendering for the body while keeping the header fixed separately).
coding style
---
strict typescript discipline:
- `readonly` everywhere mutation isn't intended. `ReadonlyArray<T>`,
`Readonly<Record<K, V>>`, deep readonly wrappers. write `DeepReadonly<T>`
utilities when needed
- discriminated unions over optional properties. `{ status: 'ok'; data: T } |
{ status: 'error'; error: Error }` instead of `{ status: string; error?:
Error; data?: T }`. makes impossible states unrepresentable
- no `enum`. use `as const` objects or union types instead. enums have quirks,
especially numeric ones with reverse mappings
- branded/opaque types for primitive identifiers. `type UserId = string &
{ readonly __brand: unique symbol }` prevents mixing up `PostId` and `UserId`
- no `any`, no `as`, no `!` - genuinely zero. use `unknown` with proper
narrowing. write type guards instead of assertions
- explicit return types on all exported functions. don't rely on inference for
public APIs. catches accidental changes, improves compile speed
- effect-free module scope. no side effects at top level (no `console.log`,
`fetch`, mutations during import). everything meaningful happens in
explicitly called functions
- result types over thrown exceptions. return `Result<T, E>` or `Either`
instead of throwing. makes error handling visible in type signatures
these trade short-term convenience for long-term correctness. the strict
version is always better even when the permissive version works right now.

124
bun.lock
View File

@ -45,7 +45,8 @@
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@workos-inc/authkit-nextjs": "^2.13.0", "@workos-inc/authkit-nextjs": "^2.13.0",
"@workos-inc/node": "^8.1.0", "@workos-inc/node": "^8.1.0",
"ai": "^6.0.72", "@xyflow/react": "^12.10.0",
"ai": "^6.0.73",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
@ -56,6 +57,7 @@
"frappe-gantt": "^1.0.4", "frappe-gantt": "^1.0.4",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"motion": "^12.33.0",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"next": "15.5.9", "next": "15.5.9",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
@ -69,10 +71,13 @@
"recharts": "2.15.4", "recharts": "2.15.4",
"remark-gfm": "4", "remark-gfm": "4",
"remeda": "2", "remeda": "2",
"shiki": "1", "shiki": "^3.22.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"streamdown": "^2.1.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tokenlens": "^1.3.1",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"use-stick-to-bottom": "^1.1.2",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"zod": "^4.3.5", "zod": "^4.3.5",
}, },
@ -86,6 +91,7 @@
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.4.6", "eslint-config-next": "15.4.6",
"husky": "^9.1.7", "husky": "^9.1.7",
"media-chrome": "^4.17.2",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5", "typescript": "^5",
"wrangler": "^4.59.3", "wrangler": "^4.59.3",
@ -93,7 +99,7 @@
}, },
}, },
"packages": { "packages": {
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.7", "@ai-sdk/provider-utils": "4.0.13", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9aRTVM1P1u4yUIjBpco/WCF1WXr/DgWKuDYgLLHdENS8kiEuxDOPJuGbc/6+7EwQ6ZqSh0UOgeqvHfGJfU23Qg=="], "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.36", "", { "dependencies": { "@ai-sdk/provider": "3.0.7", "@ai-sdk/provider-utils": "4.0.13", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2r1Q6azvqMYxQ1hqfWZmWg4+8MajoldD/ty65XdhCaCoBfvDu7trcvxXDfTSU+3/wZ1JIDky46SWYFOHnTbsBw=="],
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.7", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-VkPLrutM6VdA924/mG8OS+5frbVTcu6e046D2bgDo00tehBANR1QBJ/mPcZ9tXMFOsVcm6SQArOregxePzTFPw=="], "@ai-sdk/provider": ["@ai-sdk/provider@3.0.7", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-VkPLrutM6VdA924/mG8OS+5frbVTcu6e046D2bgDo00tehBANR1QBJ/mPcZ9tXMFOsVcm6SQArOregxePzTFPw=="],
@ -623,17 +629,17 @@
"@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.15.0", "", {}, "sha512-ojSshQPKwVvSMR8yT2L/QtUkV5SXi/IfDiJ4/8d6UbTPjiHVmxZzUAzGD8Tzks1b9+qQkZa0isUOvYObedITaw=="], "@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.15.0", "", {}, "sha512-ojSshQPKwVvSMR8yT2L/QtUkV5SXi/IfDiJ4/8d6UbTPjiHVmxZzUAzGD8Tzks1b9+qQkZa0isUOvYObedITaw=="],
"@shikijs/core": ["@shikijs/core@1.29.2", "", { "dependencies": { "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.4" } }, "sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ=="], "@shikijs/core": ["@shikijs/core@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "oniguruma-to-es": "^2.2.0" } }, "sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A=="], "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-jdKhfgW9CRtj3Tor0L7+yPwdG3CgP7W+ZEqSsojrMzCjD1e0IxIbwUMDDpYlVBlC08TACg4puwFGkZfLS+56Tw=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1" } }, "sha512-7iiOx3SG8+g1MnlzZVDYiaeHe7Ez2Kf2HrJzdmGwkRisT7r4rak0e655AcM/tF9JG/kg5fMNYlLLKglbN7gBqA=="], "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-DyXsOG0vGtNtl7ygvabHd7Mt5EY8gCNqR9Y7Lpbbd/PbJvgWrqaKzH1JW6H6qFkuUa8aCxoiYVv8/YfFljiQxA=="],
"@shikijs/langs": ["@shikijs/langs@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2" } }, "sha512-FIBA7N3LZ+223U7cJDUYd5shmciFQlYkFXlkKVaHsCPgfVLiO+e12FmQE6Tf9vuyEsFe3dIl8qGWKXgEHL9wmQ=="], "@shikijs/langs": ["@shikijs/langs@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0" } }, "sha512-x/42TfhWmp6H00T6uwVrdTJGKgNdFbrEdhaDwSR5fd5zhQ1Q46bHq9EO61SCEWJR0HY7z2HNDMaBZp8JRmKiIA=="],
"@shikijs/themes": ["@shikijs/themes@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2" } }, "sha512-i9TNZlsq4uoyqSbluIcZkmPL9Bfi3djVxRnofUHwvx/h6SRW3cwgBC5SML7vsDcWyukY0eCzVN980rqP6qNl9g=="], "@shikijs/themes": ["@shikijs/themes@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0" } }, "sha512-o+tlOKqsr6FE4+mYJG08tfCFDS+3CG20HbldXeVoyP+cYSUxDhrFf3GPjE60U55iOkkjbpY2uC3It/eeja35/g=="],
"@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="], "@shikijs/types": ["@shikijs/types@3.22.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg=="],
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
@ -787,6 +793,14 @@
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
"@tokenlens/core": ["@tokenlens/core@1.3.0", "", {}, "sha512-d8YNHNC+q10bVpi95fELJwJyPVf1HfvBEI18eFQxRSZTdByXrP+f/ZtlhSzkx0Jl0aEmYVeBA5tPeeYRioLViQ=="],
"@tokenlens/fetch": ["@tokenlens/fetch@1.3.0", "", { "dependencies": { "@tokenlens/core": "1.3.0" } }, "sha512-RONDRmETYly9xO8XMKblmrZjKSwCva4s5ebJwQNfNlChZoA5kplPoCgnWceHnn1J1iRjLVlrCNB43ichfmGBKQ=="],
"@tokenlens/helpers": ["@tokenlens/helpers@1.3.1", "", { "dependencies": { "@tokenlens/core": "1.3.0", "@tokenlens/fetch": "1.3.0" } }, "sha512-t6yL8N6ES8337E6eVSeH4hCKnPdWkZRFpupy9w5E66Q9IeqQ9IO7XQ6gh12JKjvWiRHuyyJ8MBP5I549Cr41EQ=="],
"@tokenlens/models": ["@tokenlens/models@1.3.0", "", { "dependencies": { "@tokenlens/core": "1.3.0" } }, "sha512-9mx7ZGeewW4ndXAiD7AT1bbCk4OpJeortbjHHyNkgap+pMPPn1chY6R5zqe1ggXIUzZ2l8VOAKfPqOvpcrisJw=="],
"@tsconfig/node18": ["@tsconfig/node18@1.0.3", "", {}, "sha512-RbwvSJQsuN9TB04AQbGULYfOGE/RnSFk/FLQ5b0NmDf5Kx2q/lABZbHQPKCO1vZ6Fiwkplu+yb9pGdLy1iGseQ=="], "@tsconfig/node18": ["@tsconfig/node18@1.0.3", "", {}, "sha512-RbwvSJQsuN9TB04AQbGULYfOGE/RnSFk/FLQ5b0NmDf5Kx2q/lABZbHQPKCO1vZ6Fiwkplu+yb9pGdLy1iGseQ=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
@ -807,6 +821,8 @@
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
"@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="],
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
@ -815,12 +831,18 @@
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
"@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="],
"@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="], "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="],
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="],
"@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
@ -937,6 +959,10 @@
"@workos-inc/node": ["@workos-inc/node@8.1.0", "", { "dependencies": { "iron-webcrypto": "^2.0.0", "jose": "~6.1.0" } }, "sha512-Ep2QSP43y4ZdJIOuL4Hjaq5f0u8Z0qZe7QWzrrBV6cHc/kcicDBcB0AanMP6eB9x3x6FaHfevLbkbjPF4+TCYQ=="], "@workos-inc/node": ["@workos-inc/node@8.1.0", "", { "dependencies": { "iron-webcrypto": "^2.0.0", "jose": "~6.1.0" } }, "sha512-Ep2QSP43y4ZdJIOuL4Hjaq5f0u8Z0qZe7QWzrrBV6cHc/kcicDBcB0AanMP6eB9x3x6FaHfevLbkbjPF4+TCYQ=="],
"@xyflow/react": ["@xyflow/react@12.10.0", "", { "dependencies": { "@xyflow/system": "0.0.74", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw=="],
"@xyflow/system": ["@xyflow/system@0.0.74", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-7v7B/PkiVrkdZzSbL+inGAo6tkR/WQHHG0/jhSvLQToCsfa8YubOGmBYd1s08tpKpihdHDZFwzQZeR69QSBb4Q=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
@ -947,7 +973,7 @@
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
"ai": ["ai@6.0.72", "", { "dependencies": { "@ai-sdk/gateway": "3.0.35", "@ai-sdk/provider": "3.0.7", "@ai-sdk/provider-utils": "4.0.13", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-D3TzDX6LzYL8qwi1A0rLnmuUexqDcCu4LSg77hcDHsqNRkaGspGItkz1U3RnN3ojv31XQYI9VmoWpkj44uvIUA=="], "ai": ["ai@6.0.73", "", { "dependencies": { "@ai-sdk/gateway": "3.0.36", "@ai-sdk/provider": "3.0.7", "@ai-sdk/provider-utils": "4.0.13", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-p2/ICXIjAM4+bIFHEkAB+l58zq+aTmxAkotsb6doNt/CEms72zt6gxv2ky1fQDwU4ecMOcmMh78VJUSEKECzlg=="],
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
@ -1029,6 +1055,8 @@
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"ce-la-react": ["ce-la-react@0.3.2", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-QJ6k4lOD/btI08xG8jBPxRCGXvCnusGGkTsiXk0u3NqUu/W+BXRnFD4PYjwtqh8AWmGa5LDbGk0fLQsqr0nSMA=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
@ -1041,6 +1069,8 @@
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"classcat": ["classcat@5.0.5", "", {}, "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="],
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
"cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
@ -1079,6 +1109,10 @@
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],
"d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="],
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
"d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
@ -1089,6 +1123,8 @@
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="],
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
@ -1097,6 +1133,10 @@
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="],
"d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="],
"damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="], "damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="],
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
@ -1161,14 +1201,14 @@
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="], "enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
"enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="],
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
"es-abstract": ["es-abstract@1.24.1", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw=="], "es-abstract": ["es-abstract@1.24.1", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw=="],
@ -1347,12 +1387,24 @@
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="],
"hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
"hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="],
"hast-util-sanitize": ["hast-util-sanitize@5.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "unist-util-position": "^5.0.0" } }, "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg=="],
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
"hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
@ -1541,6 +1593,8 @@
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
@ -1573,6 +1627,8 @@
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
"media-chrome": ["media-chrome@4.17.2", "", { "dependencies": { "ce-la-react": "^0.3.2" } }, "sha512-o/IgiHx0tdSVwRxxqF5H12FK31A/A8T71sv3KdAvh7b6XeBS9dXwqvIFwlR9kdEuqg3n7xpmRIuL83rmYq8FTg=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
@ -1657,6 +1713,8 @@
"mnemonist": ["mnemonist@0.38.3", "", { "dependencies": { "obliterator": "^1.6.1" } }, "sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw=="], "mnemonist": ["mnemonist@0.38.3", "", { "dependencies": { "obliterator": "^1.6.1" } }, "sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw=="],
"motion": ["motion@12.33.0", "", { "dependencies": { "framer-motion": "^12.33.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-TcND7PijsrTeIA9SRVUB8TOJQ+6mJnJ5K4a9oAJZvyI0Zy47Gq5oofU+VkTxbLcvDoKXnHspQcII2mnk3TbFsQ=="],
"motion-dom": ["motion-dom@11.18.1", "", { "dependencies": { "motion-utils": "^11.18.1" } }, "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw=="], "motion-dom": ["motion-dom@11.18.1", "", { "dependencies": { "motion-utils": "^11.18.1" } }, "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw=="],
"motion-utils": ["motion-utils@11.18.1", "", {}, "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA=="], "motion-utils": ["motion-utils@11.18.1", "", {}, "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA=="],
@ -1707,7 +1765,9 @@
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="], "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="],
"oniguruma-to-es": ["oniguruma-to-es@4.3.4", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
@ -1723,6 +1783,8 @@
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
@ -1799,14 +1861,20 @@
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
"regex": ["regex@5.1.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw=="], "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
"regex-recursion": ["regex-recursion@5.1.1", "", { "dependencies": { "regex": "^5.1.1", "regex-utilities": "^2.3.0" } }, "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w=="], "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
"rehype-harden": ["rehype-harden@1.1.7", "", { "dependencies": { "unist-util-visit": "^5.0.0" } }, "sha512-j5DY0YSK2YavvNGV+qBHma15J9m0WZmRe8posT5AtKDS6TNWtMVTo6RiqF8SidfcASYz8f3k2J/1RWmq5zTXUw=="],
"rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="],
"rehype-sanitize": ["rehype-sanitize@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-sanitize": "^5.0.0" } }, "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg=="],
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
@ -1817,6 +1885,8 @@
"remeda": ["remeda@2.33.5", "", {}, "sha512-FqmpPA9i9T5EGcqgyHf9kHjefnyCZM1M3kSdZjPk1j2StGNoJyoYp0807RYcjNkQ1UpsEQa5qzgsjLY4vYtT8g=="], "remeda": ["remeda@2.33.5", "", {}, "sha512-FqmpPA9i9T5EGcqgyHf9kHjefnyCZM1M3kSdZjPk1j2StGNoJyoYp0807RYcjNkQ1UpsEQa5qzgsjLY4vYtT8g=="],
"remend": ["remend@1.1.0", "", {}, "sha512-JENGyuIhTwzUfCarW43X4r9cehoqTo9QyYxfNDZSud2AmqeuWjZ5pfybasTa4q0dxTJAj5m8NB+wR+YueAFpxQ=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
@ -1859,7 +1929,7 @@
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"shiki": ["shiki@1.29.2", "", { "dependencies": { "@shikijs/core": "1.29.2", "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/langs": "1.29.2", "@shikijs/themes": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg=="], "shiki": ["shiki@3.22.0", "", { "dependencies": { "@shikijs/core": "3.22.0", "@shikijs/engine-javascript": "3.22.0", "@shikijs/engine-oniguruma": "3.22.0", "@shikijs/langs": "3.22.0", "@shikijs/themes": "3.22.0", "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-LBnhsoYEe0Eou4e1VgJACes+O6S6QC0w71fCSp5Oya79inkwkm15gQ1UF6VtQ8j/taMDh79hAB49WUk8ALQW3g=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
@ -1887,6 +1957,8 @@
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
"streamdown": ["streamdown@2.1.0", "", { "dependencies": { "clsx": "^2.1.1", "hast-util-to-jsx-runtime": "^2.3.6", "html-url-attributes": "^3.0.1", "marked": "^17.0.1", "rehype-harden": "^1.1.7", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remend": "1.1.0", "tailwind-merge": "^3.4.0", "unified": "^11.0.5", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-u9gWd0AmjKg1d+74P44XaPlGrMeC21oDOSIhjGNEYMAttDMzCzlJO6lpTyJ9JkSinQQF65YcK4eOd3q9iTvULw=="],
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@ -1947,6 +2019,8 @@
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"tokenlens": ["tokenlens@1.3.1", "", { "dependencies": { "@tokenlens/core": "1.3.0", "@tokenlens/fetch": "1.3.0", "@tokenlens/helpers": "1.3.1", "@tokenlens/models": "1.3.0" } }, "sha512-7oxmsS5PNCX3z+b+z07hL5vCzlgHKkCGrEQjQmWl5l+v5cUrtL7S1cuST4XThaL1XyjbTX8J5hfP0cjDJRkaLA=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
@ -2013,6 +2087,8 @@
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"use-stick-to-bottom": ["use-stick-to-bottom@1.1.2", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-ssUfMNvfH8a8hGLoAt5kcOsjbsVORknon2tbkECuf3EsVucFFBbyXl+Xnv3b58P8ZRuZelzO81fgb6M0eRo8cg=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], "uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
@ -2023,10 +2099,14 @@
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="], "victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
"web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="], "web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
"webcrypto-core": ["webcrypto-core@1.8.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.3.13", "@peculiar/json-schema": "^1.1.12", "asn1js": "^3.0.5", "pvtsutils": "^1.3.5", "tslib": "^2.7.0" } }, "sha512-P+x1MvlNCXlKbLSOY4cYrdreqPG5hbzkmawbcXLKN/mf6DZW0SdNNkZ+sjwsqVkI4A4Ko2sPZmkZtCKY58w83A=="], "webcrypto-core": ["webcrypto-core@1.8.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.3.13", "@peculiar/json-schema": "^1.1.12", "asn1js": "^3.0.5", "pvtsutils": "^1.3.5", "tslib": "^2.7.0" } }, "sha512-P+x1MvlNCXlKbLSOY4cYrdreqPG5hbzkmawbcXLKN/mf6DZW0SdNNkZ+sjwsqVkI4A4Ko2sPZmkZtCKY58w83A=="],
@ -2075,8 +2155,12 @@
"zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], "zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
"zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@ai-sdk/react/ai": ["ai@6.0.72", "", { "dependencies": { "@ai-sdk/gateway": "3.0.35", "@ai-sdk/provider": "3.0.7", "@ai-sdk/provider-utils": "4.0.13", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-D3TzDX6LzYL8qwi1A0rLnmuUexqDcCu4LSg77hcDHsqNRkaGspGItkz1U3RnN3ojv31XQYI9VmoWpkj44uvIUA=="],
"@aws-crypto/crc32/@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], "@aws-crypto/crc32/@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
"@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.972.0", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-U7xBIbLSetONxb2bNzHyDgND3oKGoIfmknrEVnoEU4GUSs+0augUOIn9DIWGUO2ETcRFdsRUnmx9KhPT9Ojbug=="], "@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.972.0", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-U7xBIbLSetONxb2bNzHyDgND3oKGoIfmknrEVnoEU4GUSs+0augUOIn9DIWGUO2ETcRFdsRUnmx9KhPT9Ojbug=="],
@ -2765,6 +2849,8 @@
"miniflare/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "miniflare/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"motion/framer-motion": ["framer-motion@12.33.0", "", { "dependencies": { "motion-dom": "^12.33.0", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-ca8d+rRPcDP5iIF+MoT3WNc0KHJMjIyFAbtVLvM9eA7joGSpeqDfiNH/kCs1t4CHi04njYvWyj0jS4QlEK/rJQ=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
@ -2805,6 +2891,8 @@
"wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"@ai-sdk/react/ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.7", "@ai-sdk/provider-utils": "4.0.13", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9aRTVM1P1u4yUIjBpco/WCF1WXr/DgWKuDYgLLHdENS8kiEuxDOPJuGbc/6+7EwQ6ZqSh0UOgeqvHfGJfU23Qg=="],
"@aws-crypto/crc32/@aws-sdk/types/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="], "@aws-crypto/crc32/@aws-sdk/types/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
"@aws-crypto/crc32c/@aws-sdk/types/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="], "@aws-crypto/crc32c/@aws-sdk/types/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
@ -3311,6 +3399,10 @@
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"motion/framer-motion/motion-dom": ["motion-dom@12.33.0", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-XRPebVypsl0UM+7v0Hr8o9UAj0S2djsQWRdHBd5iVouVpMrQqAI0C/rDAT3QaYnXnHuC5hMcwDHCboNeyYjPoQ=="],
"motion/framer-motion/motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="],
"next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],

28
cloudflare-env.d.ts vendored
View File

@ -1,15 +1,39 @@
/* eslint-disable */ /* eslint-disable */
// Generated by Wrangler by running `wrangler types --env-interface CloudflareEnv ./cloudflare-env.d.ts` (hash: 17faa1ab93062fdc4d6b4055bea03b00) // Generated by Wrangler by running `wrangler types --env-interface CloudflareEnv ./cloudflare-env.d.ts` (hash: a8a217f5bbba5adbbc3687588e1c29b7)
// Runtime types generated with workerd@1.20260116.0 2025-12-01 global_fetch_strictly_public,nodejs_compat // Runtime types generated with workerd@1.20260116.0 2025-12-01 global_fetch_strictly_public,nodejs_compat
declare namespace Cloudflare { declare namespace Cloudflare {
interface GlobalProps {
mainModule: typeof import("./.open-next/worker");
}
interface Env { interface Env {
WORKOS_REDIRECT_URI: "https://compass.openrangeconstruction.ltd/callback";
WORKOS_API_KEY: string;
WORKOS_CLIENT_ID: string;
WORKOS_COOKIE_PASSWORD: string;
NEXT_PUBLIC_WORKOS_REDIRECT_URI: string;
NETSUITE_ACCOUNT_ID: string;
NETSUITE_CLIENT_ID: string;
NETSUITE_CLIENT_SECRET: string;
NETSUITE_REDIRECT_URI: string;
NETSUITE_TOKEN_ENCRYPTION_KEY: string;
NETSUITE_CONCURRENCY_LIMIT: string;
GITHUB_TOKEN: string;
GITHUB_REPO: string;
OPENROUTER_API_KEY: string;
DB: D1Database; DB: D1Database;
WORKER_SELF_REFERENCE: Fetcher /* compass */; WORKER_SELF_REFERENCE: Service<typeof import("./.open-next/worker").default>;
AI: Ai;
IMAGES: ImagesBinding; IMAGES: ImagesBinding;
ASSETS: Fetcher; ASSETS: Fetcher;
} }
} }
interface CloudflareEnv extends Cloudflare.Env {} interface CloudflareEnv extends Cloudflare.Env {}
type StringifyValues<EnvType extends Record<string, unknown>> = {
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
};
declare namespace NodeJS {
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "WORKOS_REDIRECT_URI" | "WORKOS_API_KEY" | "WORKOS_CLIENT_ID" | "WORKOS_COOKIE_PASSWORD" | "NEXT_PUBLIC_WORKOS_REDIRECT_URI" | "NETSUITE_ACCOUNT_ID" | "NETSUITE_CLIENT_ID" | "NETSUITE_CLIENT_SECRET" | "NETSUITE_REDIRECT_URI" | "NETSUITE_TOKEN_ENCRYPTION_KEY" | "NETSUITE_CONCURRENCY_LIMIT" | "GITHUB_TOKEN" | "GITHUB_REPO" | "OPENROUTER_API_KEY">> {}
}
// Begin runtime types // Begin runtime types
/*! ***************************************************************************** /*! *****************************************************************************

View File

@ -0,0 +1,190 @@
OpenClaw Memory System
===
how OpenClaw gives its agents persistent memory across sessions. covers the embedding pipeline, the SQLite schema that stores indexed chunks, the hybrid search strategy that combines vector similarity with keyword matching, and the pre-compaction memory flush that prevents the agent from losing important context when the conversation window fills up.
the central idea is that plain markdown files on disk are the source of truth. everything else — embeddings, indexes, caches — is derived and disposable. if you delete the SQLite database, the system rebuilds it from the markdown. if you edit a memory file by hand, the next sync picks up the changes. this is a deliberate choice: it keeps the system inspectable, Git-friendly, and resilient to corruption in a way that an opaque database wouldn't be.
the storage schema
---
memory lives in a single SQLite database per agent at `~/.openclaw/memory/<agentId>.sqlite`. the schema has four tables, each serving a distinct purpose.
**meta** stores a single JSON blob under the key `memory_index_meta_v1`. this records the embedding provider, model, provider key, chunk size, chunk overlap, and vector dimensions that were used to build the current index. the reason this exists is that embeddings from different models aren't comparable — you can't mix OpenAI `text-embedding-3-small` vectors with local `embeddinggemma-300M` vectors and get meaningful cosine similarity scores. when the meta changes (because the user switched providers), the system knows the entire index needs rebuilding.
```sql
CREATE TABLE meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
```
**files** tracks which markdown files have been indexed, their content hashes, modification times, and sizes. the hash is SHA-256 of the file content. during sync, each file's current hash is compared against the stored hash — if they match, the file is skipped. this is what makes incremental indexing fast: unchanged files cost nothing.
```sql
CREATE TABLE files (
path TEXT PRIMARY KEY,
source TEXT NOT NULL DEFAULT 'memory',
hash TEXT NOT NULL,
mtime INTEGER NOT NULL,
size INTEGER NOT NULL
);
```
the `source` column distinguishes between `"memory"` (files from the workspace memory directory) and `"sessions"` (indexed session transcripts, an experimental feature). this matters during search because the agent can filter results by source.
**chunks** is the core table. each row is a text chunk — a slice of a markdown file — along with its embedding vector, the model that produced it, and its position in the source file (start/end line numbers).
```sql
CREATE TABLE chunks (
id TEXT PRIMARY KEY,
path TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'memory',
start_line INTEGER NOT NULL,
end_line INTEGER NOT NULL,
hash TEXT NOT NULL,
model TEXT NOT NULL,
text TEXT NOT NULL,
embedding TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
```
the embedding is stored as a JSON array of floats in the `embedding` column. this is the fallback representation — it works everywhere but requires deserializing and computing cosine similarity in application code. when the sqlite-vec extension is available, the system also maintains a virtual table (`chunks_vec`) that stores the same embeddings as binary blobs and supports hardware-accelerated distance computation.
**embedding_cache** prevents re-embedding text that hasn't changed. the composite primary key is `(provider, model, provider_key, hash)` — meaning the same text embedded with the same model produces a cache hit regardless of which file it came from. this matters for overlapping chunks, which by design share text at their boundaries.
```sql
CREATE TABLE embedding_cache (
provider TEXT NOT NULL,
model TEXT NOT NULL,
provider_key TEXT NOT NULL,
hash TEXT NOT NULL,
embedding TEXT NOT NULL,
dims INTEGER,
updated_at INTEGER NOT NULL,
PRIMARY KEY (provider, model, provider_key, hash)
);
```
chunking
---
markdown files get split into chunks before embedding. the chunking is line-based, not sentence-based, which means heading structure and paragraph boundaries in the markdown are respected.
the target chunk size defaults to 400 tokens (approximated as `tokens * 4` characters). the overlap defaults to 80 tokens. overlap exists because embedding models need surrounding context to produce useful vectors — a chunk that starts mid-paragraph would lose the semantic framing that the previous lines provided. by carrying the last ~80 tokens of one chunk into the beginning of the next, the system ensures that ideas spanning chunk boundaries are represented in at least one chunk's embedding.
each chunk records its start and end line numbers in the source file. this is what makes the `memory_get` tool work — after `memory_search` identifies a relevant chunk, the agent can request the exact lines from the original file, including surrounding context that the chunk might not contain.
long lines get split into segments at the `maxChars` boundary before chunking. this prevents a single extremely long line from creating an oversized chunk, which would produce a poor embedding (most embedding models have a token limit, and truncation happens silently).
the embedding pipeline
---
embeddings are produced by one of three providers, selected in order of preference:
1. **local** via `node-llama-cpp` — runs a GGUF model locally. the default is `embeddinggemma-300M-Q8_0`, a 300M parameter model that produces reasonable embeddings without needing a GPU. the advantage is zero network calls and no API costs. the disadvantage is that it needs `node-llama-cpp` installed, which requires native compilation and doesn't work on all platforms.
2. **openai** — calls the OpenAI embeddings API (`text-embedding-3-small` by default). good quality, fast, but costs money and requires network access.
3. **gemini** — calls Google's embedding API. similar tradeoffs to OpenAI.
the provider selection logic (`"auto"` mode) tries local first if a model file exists on disk, then falls back to OpenAI, then Gemini. if the primary provider fails (missing API key, network error, unsupported platform), the system falls back to the configured fallback provider. this fallback is tracked — the system records which provider it fell back from and why, which surfaces in status output so the user knows their local setup is broken.
all embeddings are L2-normalized after generation. this is a preprocessing step that converts them to unit vectors, which means cosine similarity reduces to a dot product. this matters because sqlite-vec's `vec_distance_cosine` expects normalized vectors, and the brute-force fallback (`cosineSimilarity()`) also benefits from consistent normalization.
the embedding provider interface is deliberately simple:
```typescript
type EmbeddingProvider = {
id: string;
model: string;
embedQuery: (text: string) => Promise<number[]>;
embedBatch: (texts: string[]) => Promise<number[][]>;
};
```
`embedQuery` is for search-time (single query), `embedBatch` is for index-time (many chunks). the distinction matters because some providers offer batch APIs at lower cost (OpenAI and Gemini both support this), and the system routes bulk indexing through those APIs when available.
batch indexing runs with concurrency of 4, retry logic with exponential backoff (500ms base, 8s max, 3 attempts), and a failure limit of 2 — after two consecutive batch failures, the system gives up on batch mode for the session and falls back to sequential embedding. this prevents a flaky API from stalling the entire index.
how search works
---
search is hybrid — it combines two fundamentally different retrieval strategies and merges their results.
**vector search** (semantic) embeds the query text using the same provider/model that produced the index, then finds the nearest chunks by cosine distance. this catches conceptual matches — searching for "user preferences" will find chunks that talk about "settings" or "configuration" even if those exact words don't appear. when sqlite-vec is available, this runs as a SQL query with `vec_distance_cosine()`, which is fast. when it's not available, the system falls back to brute-force: load all chunk embeddings into memory, compute cosine similarity against each one, sort, and take the top N. this fallback is obviously slower but works on any SQLite build.
**keyword search** (lexical) uses SQLite FTS5. the raw query gets tokenized into individual words, joined with AND, and matched against the full-text index. results are ranked by BM25, which is SQLite's built-in relevance scoring. this catches exact matches — searching for "Peter" will find chunks that mention Peter by name, which vector search might miss if "Peter" isn't a semantically distinctive token in the embedding space.
the two result sets are merged by a weighted score:
```
finalScore = vectorWeight * vectorScore + textWeight * textScore
```
default weights are 0.7 vector / 0.3 text. this weights semantic similarity higher, which makes sense as a default — you usually want conceptual matches. but the keyword component prevents the system from missing exact-match results that vector search would rank lower.
the merge works by chunk ID. if the same chunk appears in both result sets (which is common — a chunk that's semantically similar to the query often also contains the query terms), it gets the combined score. if a chunk appears in only one result set, it gets the full weight for that component and zero for the other. results are sorted by combined score, descending, and truncated to `maxResults` (default 6).
the BM25 rank-to-score conversion uses `1 / (1 + rank)`, which maps BM25's unbounded rank values into a 0-1 range. this is necessary because the vector scores are already normalized (cosine similarity is inherently 0-1 for normalized vectors), and the merge formula needs both components on the same scale.
the pre-compaction memory flush
---
this is the mechanism that prevents the agent from losing important context when the conversation gets long.
the problem it solves: LLM context windows are finite. when a conversation approaches the context limit, OpenClaw compacts the history — older messages get summarized or removed to free space. but compaction is lossy. if the agent learned something important early in the conversation (a user preference, a decision, a key fact), that information might get compacted away and effectively forgotten.
the flush is a silent agentic turn that runs just before compaction. the trigger condition is:
```
totalTokens >= contextWindow - reserveTokensFloor - softThresholdTokens
```
with defaults of 20,000 for `reserveTokensFloor` and 4,000 for `softThresholdTokens`. so on a model with a 200K context window, the flush triggers when token usage crosses ~176K.
when triggered, the system injects a special prompt into the conversation:
> Pre-compaction memory flush. Store durable memories now (use memory/YYYY-MM-DD.md; create memory/ if needed). If nothing to store, reply with NO_REPLY.
this runs as a full agentic turn — the agent has access to all its tools, including file write. it can decide what's worth saving (preferences discovered during the conversation, decisions made, important facts) and write those to the daily memory log or to MEMORY.md. then compaction proceeds, and the saved information is recoverable via `memory_search` in future sessions.
the flush only runs once per compaction cycle. this is tracked via `memoryFlushCompactionCount` on the session entry — the flush records the current compaction count, and subsequent checks see that the flush has already run for this count and skip it. this prevents the flush from firing repeatedly as the conversation hovers near the threshold.
there are additional guardrails. the flush won't run during heartbeat polls (automated keep-alive messages where no real conversation is happening). it won't run for CLI providers (where compaction works differently). and it checks sandbox write permissions — if the workspace isn't writable, there's no point asking the agent to write files.
the prompt includes a `NO_REPLY` token hint because most of the time the agent has nothing to save. the user never sees this turn. if the agent writes files, those writes happen silently. if there's nothing to save, the agent replies with the silent token and the system discards it. the entire mechanism is invisible to the user unless they inspect the session transcript.
the two-path vector search
---
one detail worth calling out: vector search has two code paths, and the choice between them is automatic.
**path 1: sqlite-vec.** when the extension is available and loaded, embeddings are stored in a virtual table (`chunks_vec`) as binary blobs. search runs as a SQL query that joins the vector table against the chunks table, computes `vec_distance_cosine()` in native code, and returns results sorted by distance. this is efficient — it uses SIMD instructions on supported hardware and doesn't require loading all embeddings into application memory.
**path 2: brute-force.** when sqlite-vec isn't available (it's an optional extension that requires native compilation), the system falls back to loading every chunk's embedding from the `chunks` table, deserializing the JSON arrays, computing cosine similarity in JavaScript, sorting, and returning the top N. this is O(n) in the number of chunks and involves a lot of memory allocation, but it works on any SQLite build.
the system tries to load sqlite-vec on startup with a 30-second timeout. if it fails (missing extension, incompatible platform, permission error), it logs the error and falls back silently. the user can check `openclaw memory status --deep` to see which search path is active.
this two-path design is a practical compromise. sqlite-vec offers meaningful performance gains for large memory stores (thousands of chunks), but requiring it would make memory search unavailable on platforms where the extension doesn't compile. by making it optional with a transparent fallback, the system works everywhere while performing well where conditions allow.
what this means for compass
---
if Compass integrates with OpenClaw's memory system, there are a few things worth understanding:
the memory system is designed for a single agent per database. each agent gets its own SQLite file, its own embedding index, and its own sync lifecycle. if Compass runs multiple AI sessions (per-user, per-project, per-workspace), each would need its own memory scope. this maps naturally to OpenClaw's agent model but requires thinking about how Compass sessions map to OpenClaw agent IDs.
the embedding provider choice affects both cost and quality. for a deployed product, the OpenAI path is the most reliable (no native dependencies, consistent quality, reasonable cost). the local path is attractive for privacy-sensitive deployments but adds complexity (node-llama-cpp compilation, model downloads). the auto-selection logic handles this gracefully, but Compass would want to make the choice explicit rather than relying on auto.
the pre-compaction flush is designed for long-running conversational sessions. if Compass's AI interactions are shorter (task-oriented, not open-ended), the flush might never trigger. but for a project management tool where the AI maintains context across weeks of interaction, the flush becomes essential — it's the difference between the AI remembering that "the client prefers blue over green" and having to be told again.
the hybrid search strategy means that memory recall is robust to both conceptual and exact-match queries. this matters for a project tool: searching for "deadline" should find notes that mention "due date" (semantic), and searching for "Martine" should find notes that mention Martine by name (keyword). neither search strategy alone handles both cases well.

View File

@ -0,0 +1,145 @@
OpenClaw Architecture
===
reference document covering OpenClaw's internal architecture, written during evaluation for Compass's AI backend. covers the agent framework, protocol layer, and authentication system. useful context if we integrate with or build on top of OpenClaw's gateway.
the stack at a glance
---
OpenClaw is three things layered on top of each other:
1. **pi-ai** - model abstraction. talks to Anthropic, OpenAI, Bedrock, and others through a single `getModel()` interface. handles streaming, token counting, the boring plumbing.
2. **pi-agent-core** - the agentic loop. takes a model, system prompt, and tools, then runs the standard prompt-stream-tool-repeat cycle. manages state, message history, tool execution, and event streaming. this is where the actual "agent" behavior lives.
3. **OpenClaw gateway** - the infrastructure wrapper. sessions, credential management, multi-channel routing (WhatsApp, Telegram, Discord, Slack, web), and the ACP bridge for IDE integration.
the relationship between these layers matters: pi-agent-core doesn't know about channels or sessions. the gateway doesn't know about model APIs. each layer has a clean boundary, which is what makes the system flexible enough to serve both a WhatsApp bot and a Zed editor plugin from the same codebase.
```
IDE (Zed, etc) ──ACP──> bridge ──ws──> ┐
WhatsApp ──────────────────────────> │
Telegram ──────────────────────────> ├── Gateway ── pi-agent-core ── pi-ai ── Model APIs
Discord ──────────────────────────> │ │
Slack ──────────────────────────> │ sessions
Web UI ──────────────────────────> ┘ credentials
routing
```
agent client protocol (ACP)
---
ACP is a standardized wire protocol for IDEs to talk to AI agents. think LSP, but for agent interactions instead of language features. OpenClaw implements both sides.
the transport is NDJSON over stdio. no HTTP server, no port management. an IDE spawns `openclaw acp` as a subprocess and communicates through stdin/stdout. the bridge translates ACP messages into gateway websocket calls.
the protocol surface is small:
- `initialize` - capability handshake (fs access, terminal)
- `newSession` / `loadSession` / `listSessions` - session lifecycle
- `prompt` - send user input (text, resources, images)
- `cancel` - abort a running generation
- `sessionUpdate` - streaming notifications back to the client (text chunks, tool calls, command updates)
- `requestPermission` - agent asks the IDE for permission before acting
each ACP session maps to a gateway session key, so reconnects preserve conversation state. the practical value: any editor that speaks ACP can use OpenClaw as its agent backend without needing a bespoke plugin. Zed works today, and adding another editor requires zero changes on the OpenClaw side.
pi-agent-core internals
---
the agent framework is authored by Mario Zechner (badlogic on GitHub) and lives in the `pi-mono` monorepo. MIT licensed. the three packages OpenClaw depends on:
- `@mariozechner/pi-ai` (model abstraction)
- `@mariozechner/pi-agent-core` (stateful agent loop)
- `@mariozechner/pi-coding-agent` (coding-specific tools and prompts)
the core loop is an `Agent` class you configure with a model, system prompt, and tools. calling `prompt()` kicks off the agentic cycle:
```
prompt("read config.json and summarize it")
├─ agent_start
├─ turn_start
│ ├─ user message sent to LLM
│ ├─ LLM streams response (message_update events with text deltas)
│ ├─ LLM requests tool call: read_file({path: "config.json"})
│ ├─ tool_execution_start -> tool runs -> tool_execution_end
│ └─ tool result fed back to LLM
├─ turn_end
├─ turn_start (next turn - LLM responds to tool result)
│ ├─ LLM streams final answer
│ └─ no more tool calls
├─ turn_end
└─ agent_end
```
two design decisions worth noting:
**the message pipeline has two stages.** before every LLM call, messages pass through `transformContext()` (prune old messages, inject external context, compact history) and then `convertToLlm()` (filter out app-specific message types the model shouldn't see). this separation is what lets OpenClaw store channel-specific metadata, UI messages, and notification types in the conversation history without confusing the model. the LLM only ever sees clean user/assistant/toolResult messages.
**message queuing supports mid-execution interrupts.** you can inject messages while the agent is running tools. when a queued message is detected after a tool completes, remaining tool calls get skipped and the LLM receives the interruption instead. this matters for interactive use cases where a user changes their mind while a multi-tool operation is in progress.
tools use TypeBox schemas for parameter validation and support streaming progress through an `onUpdate` callback. errors are thrown (not returned as content), caught by the agent, and reported to the LLM as `isError: true` tool results.
authentication architecture
---
this is the part that initially seemed confusing but turns out to be well-structured once you see the two layers.
**layer 1: gateway access.** this controls who can connect to the gateway at all. it's a simple token or password sent over the websocket connection. no OAuth, no complexity. an IDE, a chat client, or any websocket consumer provides credentials when connecting, and the gateway either accepts or rejects. configured via `gateway.auth.token` or `gateway.auth.password`.
**layer 2: model provider auth.** this controls what API keys the agent uses when calling model providers (Anthropic, OpenAI, Bedrock, etc). this is where OAuth lives.
the two layers are fully independent. you can connect to the gateway with a simple bearer token but have your model calls authenticated via Anthropic's OAuth flow. the gateway doesn't care how your model credentials were obtained.
credentials are stored per-agent at `~/.openclaw/agents/<agentId>/auth-profiles.json` and come in three flavors:
- **api_key** - raw API key, user-provided
- **token** - generated via `claude setup-token` or similar
- **oauth** - full OAuth flow with access/refresh tokens and expiry
when an agent needs to call a model, the resolution chain runs through:
1. explicit profile ID (if the request specifies one)
2. configured profile order with round-robin and failure cooldown
3. environment variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc)
4. config file values
5. AWS credential chain (for Bedrock)
the system also mirrors credentials from external tools. if you've already authenticated Claude Code, OpenClaw can read `~/.claude/.credentials.json` and use those tokens as a fallback. same for Codex, Qwen, and MiniMax CLI credentials. token refresh uses file locking to prevent multiple agents from refreshing simultaneously.
the Anthropic OAuth flow specifically goes through Chutes (`api.chutes.ai`) as the identity provider. it uses PKCE, supports both local browser redirects and manual URL pasting for headless/VPS environments, and stores access + refresh tokens with expiry tracking.
```
login flow:
openclaw login
└─> choose provider (anthropic)
└─> choose auth method (oauth)
└─> PKCE flow via chutes.ai
└─> tokens stored in auth-profiles.json
request flow:
any client ──ws+token──> gateway ──> agent needs claude
└─> resolveApiKeyForProvider("anthropic")
└─> finds oauth token from auth-profiles.json
└─> refreshes if expired (with lockfile)
└─> calls anthropic API
```
the practical consequence: you authenticate once with OpenClaw, and any tool that can talk to the gateway gets access to your model providers. the tool doesn't need to manage its own API keys or know anything about OAuth. it just sends prompts and gets responses.
relevance to compass
---
if Compass uses OpenClaw as its AI backend, the integration point would be the gateway websocket. Compass would authenticate to the gateway (layer 1) and send prompts. the gateway handles everything else - model selection, credential resolution, session persistence, streaming.
the ACP protocol is worth watching as a potential standard for AI tool integration, but for a web app like Compass, the websocket API is the more natural fit.
the auth architecture means Compass wouldn't need to store or manage model API keys directly. users would authenticate through OpenClaw's onboarding, and the gateway would resolve credentials at request time. this simplifies the security surface - Compass never touches raw API keys.

View File

@ -0,0 +1,101 @@
System Prompt Architecture
===
how OpenClaw constructs the system prompt that shapes agent behavior. covers the prompt builder in `src/agents/system-prompt.ts`, the design decisions behind its structure, and why it works the way it does. relevant context for Compass if we build agent features that need to understand or extend how the AI's instructions are assembled.
why a prompt builder, not a prompt template
---
the obvious approach to system prompts is a template — a big string with some variables interpolated in. OpenClaw doesn't do this. instead, `buildAgentSystemPrompt()` assembles the prompt from a set of independent section builders, each returning a `string[]` that gets concatenated at the end.
the reason is that the prompt needs to change shape dramatically based on context. a main agent talking through Telegram with inline buttons enabled, a memory store, and a SOUL.md personality file needs a fundamentally different prompt than a subagent spawned to do a background file search. a template approach would drown in conditionals. the section-builder approach means each concern is isolated — the messaging section doesn't know or care about the memory section, and either can be omitted entirely without touching the other.
this matters for Compass because if we integrate with OpenClaw's agent layer, the prompt that runs behind our AI chat panel will be shaped by these same mechanics. understanding what's included (and what's excluded) in different modes determines what the agent can and can't do.
prompt modes
---
the builder supports three modes, controlled by a `PromptMode` parameter:
**"full"** is the default. every section gets included — tooling, safety, skills, memory, messaging, voice, reactions, heartbeats, silent reply protocol, runtime metadata. this is what the main agent gets when a user talks to it through a channel.
**"minimal"** strips the prompt down for subagents. skills, memory, docs, messaging, voice, reactions, heartbeats, and silent replies all get dropped. what remains is tooling, workspace, and runtime info — enough for the subagent to do its job, not enough to make it think it's the primary conversational agent.
**"none"** returns a single line: `"You are a personal assistant running inside OpenClaw."` this exists for cases where almost all behavior comes from injected context rather than hardcoded instructions.
the distinction between full and minimal is worth understanding. a subagent that inherits the full prompt would try to manage heartbeats, react to messages with emojis, and follow the silent reply protocol — behaviors that make no sense for a background worker. the mode system prevents this without requiring the caller to manually strip sections.
the sections
---
each section is built by a dedicated function that returns an array of strings (empty array means "don't include this section"). the main builder concatenates everything and filters out empty strings.
**tooling** is the most complex section. it takes a list of tool names, deduplicates them (case-insensitive but preserving original casing), and renders them in a fixed order with human-readable summaries. core tools like `read`, `exec`, and `browser` have hardcoded summaries. external tools can provide their own summaries through a separate map. tools not in the predefined order get sorted alphabetically at the end. the ordering matters because models tend to weight items higher when they appear earlier in their context.
**safety** is a short set of guardrails — no self-preservation, no manipulation, comply with stop requests, don't bypass safeguards. this section is always included in full and minimal modes.
**skills** teaches the agent how to discover and use skill files. it instructs the agent to scan available skill descriptions, pick the most specific match, and read exactly one SKILL.md file before proceeding. the constraint against reading multiple skills upfront is deliberate — it keeps the context window lean and forces the agent to commit to an approach rather than hedging.
**memory** instructs the agent to search memory files before answering questions about prior work, preferences, or decisions. it supports a citations mode that controls whether the agent includes source paths in its replies.
**messaging** handles multi-channel routing. it tells the agent how to reply in the current session versus sending cross-session messages, explains the `message` tool for proactive sends, and includes inline button guidance when the channel supports it. this section only appears when the `message` tool is available.
**context files** are user-editable files (like SOUL.md) that get injected verbatim into the prompt under a "Project Context" header. if a SOUL.md is present, the builder adds an extra instruction to embody its persona. this is how personality customization works — the prompt builder provides the mechanism, the user provides the content.
the remaining sections — user identity, time, voice, docs, reply tags, reactions, reasoning format, silent replies, heartbeats, and runtime — each handle a specific concern. most are a few lines. none depend on each other.
tool name resolution
---
one subtle design decision: tool names are case-sensitive in the final output but deduplicated case-insensitively. if the caller provides both `Read` and `read`, only the first one survives. but the output preserves whichever casing the caller used. this matters because some model providers are strict about tool name casing in their API, and the prompt needs to match exactly what the tool registry will accept.
the resolution works through a `Map<string, string>` that maps normalized (lowercase) names to their first-seen canonical form. when rendering the tool list or referencing a tool in prose (like "use `read` to read files"), the builder calls `resolveToolName()` to get the caller's preferred casing.
the runtime line
---
the prompt ends with a single-line runtime summary:
```
Runtime: agent=main | host=server | os=linux (x86_64) | model=claude-sonnet-4-5-20250929 | channel=telegram | capabilities=inlineButtons | thinking=off
```
this is exposed as a separate export (`buildRuntimeLine()`) because other parts of the system use it independently — for example, status commands that need to show the agent's current configuration.
the runtime line is where the agent learns what model it's running on, what channel it's connected to, and what capabilities are available. it's a compressed format because it appears at the end of every prompt and the information density matters more than readability.
the silent reply protocol
---
one of the more interesting design choices. the agent needs a way to say "i have nothing to add" without actually saying that to the user. the prompt defines a `SILENT_REPLY_TOKEN` — a special string that, when returned as the agent's entire response, gets intercepted and discarded before reaching the user.
the prompt is explicit and repetitive about the rules for this token because models tend to violate them. the token must be the *entire* message. it can't be appended to a real reply. it can't be wrapped in markdown. the prompt includes both correct and incorrect examples. this is a case where the repetition is load-bearing — without it, the model will occasionally leak the token into visible output.
design tradeoffs
---
the builder is around 680 lines, which is large for a single file. the alternative would be splitting each section into its own module. the reason it stays consolidated is that the sections share a lot of derived state — the `availableTools` set, the `isMinimal` flag, resolved tool names, runtime capabilities. splitting would mean either passing a large context object between modules or computing the same derived values in multiple places. the current approach keeps all the prompt logic in one place where the interactions between sections are visible.
the string-array-concatenation approach (as opposed to, say, a template literal or a structured prompt object) was chosen for composability. each section builder can return zero or more lines without knowing what comes before or after it. the top-level builder just concatenates and filters. this makes it straightforward to add, remove, or reorder sections without cascading changes.
one limitation: the prompt has no explicit token budget. sections are included or excluded based on mode and feature flags, but there's no mechanism to truncate or summarize if the total prompt exceeds a model's context window. in practice, the full prompt (without context files) stays well under the limit. context files are the wildcard — a large SOUL.md or many embedded files could push it over. this hasn't been a problem yet, but it's a gap worth noting.
relevance to compass
---
if Compass uses OpenClaw's agent layer for its AI chat panel, the system prompt is the primary control surface for agent behavior. understanding the builder means understanding what knobs are available:
- **prompt mode** controls how much autonomy the agent has. a chat panel agent probably wants something between minimal and full — tool access and context awareness, but not heartbeat management or reaction guidance.
- **context files** are how Compass-specific knowledge would be injected. project data, user preferences, or domain-specific instructions would flow through this mechanism.
- **tool availability** determines what the agent can do. the tool list in the prompt must match what's actually registered in the runtime, and the prompt's tool summaries influence how the model chooses between them.
- **the messaging section** would need adaptation if Compass routes messages differently than OpenClaw's built-in channels.
the builder is designed to be extended. adding a new section means writing a function that returns `string[]` and splicing it into the main assembly. the pattern is consistent enough that this is a low-risk change.

View File

@ -0,0 +1,679 @@
/**
import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js";
import type { MemoryCitationsMode } from "../config/types.memory.js";
import type { ResolvedTimeFormat } from "./date-time.js";
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import { listDeliverableMessageChannels } from "../utils/message-channel.js";
**/
/**
* Controls which hardcoded sections are included in the system prompt.
* - "full": All sections (default, for main agent)
* - "minimal": Reduced sections (Tooling, Workspace, Runtime) - used for subagents
* - "none": Just basic identity line, no sections
*/
export type PromptMode = "full" | "minimal" | "none";
function buildSkillsSection(params: {
skillsPrompt?: string;
isMinimal: boolean;
readToolName: string;
}) {
if (params.isMinimal) {
return [];
}
const trimmed = params.skillsPrompt?.trim();
if (!trimmed) {
return [];
}
return [
"## Skills (mandatory)",
"Before replying: scan <available_skills> <description> entries.",
`- If exactly one skill clearly applies: read its SKILL.md at <location> with \`${params.readToolName}\`, then follow it.`,
"- If multiple could apply: choose the most specific one, then read/follow it.",
"- If none clearly apply: do not read any SKILL.md.",
"Constraints: never read more than one skill up front; only read after selecting.",
trimmed,
"",
];
}
function buildMemorySection(params: {
isMinimal: boolean;
availableTools: Set<string>;
citationsMode?: MemoryCitationsMode;
}) {
if (params.isMinimal) {
return [];
}
if (!params.availableTools.has("memory_search") && !params.availableTools.has("memory_get")) {
return [];
}
const lines = [
"## Memory Recall",
"Before answering anything about prior work, decisions, dates, people, preferences, or todos: run memory_search on MEMORY.md + memory/*.md; then use memory_get to pull only the needed lines. If low confidence after search, say you checked.",
];
if (params.citationsMode === "off") {
lines.push(
"Citations are disabled: do not mention file paths or line numbers in replies unless the user explicitly asks.",
);
} else {
lines.push(
"Citations: include Source: <path#line> when it helps the user verify memory snippets.",
);
}
lines.push("");
return lines;
}
function buildUserIdentitySection(ownerLine: string | undefined, isMinimal: boolean) {
if (!ownerLine || isMinimal) {
return [];
}
return ["## User Identity", ownerLine, ""];
}
function buildTimeSection(params: { userTimezone?: string }) {
try {
if (!params.userTimezone) {
return [];
}
return ["## Current Date & Time", `Time zone: ${params.userTimezone}`, ""];
} catch (error) {
return [];
}
}
function buildReplyTagsSection(isMinimal: boolean) {
if (isMinimal) {
return [];
}
return [
"## Reply Tags",
"To request a native reply/quote on supported surfaces, include one tag in your reply:",
"- [[reply_to_current]] replies to the triggering message.",
"- [[reply_to:<id>]] replies to a specific message id when you have it.",
"Whitespace inside the tag is allowed (e.g. [[ reply_to_current ]] / [[ reply_to: 123 ]]).",
"Tags are stripped before sending; support depends on the current channel config.",
"",
];
}
function buildMessagingSection(params: {
isMinimal: boolean;
availableTools: Set<string>;
messageChannelOptions: string;
inlineButtonsEnabled: boolean;
runtimeChannel?: string;
messageToolHints?: string[];
}) {
if (params.isMinimal) {
return [];
}
const lines = [
"## Messaging",
"- Reply in current session → automatically routes to the source channel (Signal, Telegram, etc.)",
"- Cross-session messaging → use sessions_send(sessionKey, message)",
"- Never use exec/curl for provider messaging; OpenClaw handles all routing internally.",
];
if (!params.availableTools.has("message")) {
lines.push("");
return lines;
}
// Build message tool section
const messageToolLines = [
"",
"### message tool",
"- Use `message` for proactive sends + channel actions (polls, reactions, etc.).",
"- For `action=send`, include `to` and `message`.",
`- If multiple channels are configured, pass \`channel\` (${params.messageChannelOptions}).`,
`- If you use \`message\` (\`action=send\`) to deliver your user-visible reply, respond with ONLY: ${SILENT_REPLY_TOKEN} (avoid duplicate replies).`,
];
// Add inline buttons guidance
const buttonGuidance = buildInlineButtonsGuidance(
params.inlineButtonsEnabled,
params.runtimeChannel,
);
if (buttonGuidance) {
messageToolLines.push(buttonGuidance);
}
// Add any additional hints
if (params.messageToolHints && params.messageToolHints.length > 0) {
messageToolLines.push(...params.messageToolHints);
}
lines.push(...messageToolLines, "");
return lines;
}
function buildInlineButtonsGuidance(
inlineButtonsEnabled: boolean,
runtimeChannel?: string,
): string {
if (inlineButtonsEnabled) {
return "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message).";
}
if (runtimeChannel) {
return `- Inline buttons not enabled for ${runtimeChannel}. If you need them, ask to set ${runtimeChannel}.capabilities.inlineButtons ("dm"|"group"|"all"|"allowlist").`;
}
return "";
}
function buildVoiceSection(params: { isMinimal: boolean; ttsHint?: string }) {
if (params.isMinimal) {
return [];
}
const hint = params.ttsHint?.trim();
if (!hint) {
return [];
}
return ["## Voice (TTS)", hint, ""];
}
function buildDocsSection(params: { docsPath?: string; isMinimal: boolean; readToolName: string }) {
const docsPath = params.docsPath?.trim();
if (!docsPath || params.isMinimal) {
return [];
}
return [
"## Documentation",
`OpenClaw docs: ${docsPath}`,
"Mirror: https://docs.openclaw.ai",
"Source: https://github.com/openclaw/openclaw",
"Community: https://discord.com/invite/clawd",
"Find new skills: https://clawhub.com",
"For OpenClaw behavior, commands, config, or architecture: consult local docs first.",
"When diagnosing issues, run `openclaw status` yourself when possible; only ask the user if you lack access (e.g., sandboxed).",
"",
];
}
export function buildAgentSystemPrompt(params: {
workspaceDir: string;
defaultThinkLevel?: ThinkLevel;
reasoningLevel?: ReasoningLevel;
extraSystemPrompt?: string;
ownerNumbers?: string[];
reasoningTagHint?: boolean;
toolNames?: string[];
toolSummaries?: Record<string, string>;
modelAliasLines?: string[];
userTimezone?: string;
userTime?: string;
userTimeFormat?: ResolvedTimeFormat;
contextFiles?: EmbeddedContextFile[];
skillsPrompt?: string;
heartbeatPrompt?: string;
docsPath?: string;
workspaceNotes?: string[];
ttsHint?: string;
/** Controls which hardcoded sections to include. Defaults to "full". */
promptMode?: PromptMode;
runtimeInfo?: {
agentId?: string;
host?: string;
os?: string;
arch?: string;
node?: string;
model?: string;
defaultModel?: string;
channel?: string;
capabilities?: string[];
repoRoot?: string;
};
messageToolHints?: string[];
sandboxInfo?: {
enabled: boolean;
workspaceDir?: string;
workspaceAccess?: "none" | "ro" | "rw";
agentWorkspaceMount?: string;
browserBridgeUrl?: string;
browserNoVncUrl?: string;
hostBrowserAllowed?: boolean;
elevated?: {
allowed: boolean;
defaultLevel: "on" | "off" | "ask" | "full";
};
};
/** Reaction guidance for the agent (for Telegram minimal/extensive modes). */
reactionGuidance?: {
level: "minimal" | "extensive";
channel: string;
};
memoryCitationsMode?: MemoryCitationsMode;
}) {
const coreToolSummaries: Record<string, string> = {
read: "Read file contents",
write: "Create or overwrite files",
edit: "Make precise edits to files",
apply_patch: "Apply multi-file patches",
grep: "Search file contents for patterns",
find: "Find files by glob pattern",
ls: "List directory contents",
exec: "Run shell commands (pty available for TTY-required CLIs)",
process: "Manage background exec sessions",
web_search: "Search the web (Brave API)",
web_fetch: "Fetch and extract readable content from a URL",
// Channel docking: add login tools here when a channel needs interactive linking.
browser: "Control web browser",
canvas: "Present/eval/snapshot the Canvas",
nodes: "List/describe/notify/camera/screen on paired nodes",
cron: "Manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)",
message: "Send messages and channel actions",
gateway: "Restart, apply config, or run updates on the running OpenClaw process",
agents_list: "List agent ids allowed for sessions_spawn",
sessions_list: "List other sessions (incl. sub-agents) with filters/last",
sessions_history: "Fetch history for another session/sub-agent",
sessions_send: "Send a message to another session/sub-agent",
sessions_spawn: "Spawn a sub-agent session",
session_status:
"Show a /status-equivalent status card (usage + time + Reasoning/Verbose/Elevated); use for model-use questions (📊 session_status); optional per-session model override",
image: "Analyze an image with the configured image model",
};
const toolOrder = [
"read",
"write",
"edit",
"apply_patch",
"grep",
"find",
"ls",
"exec",
"process",
"web_search",
"web_fetch",
"browser",
"canvas",
"nodes",
"cron",
"message",
"gateway",
"agents_list",
"sessions_list",
"sessions_history",
"sessions_send",
"session_status",
"image",
];
const rawToolNames = (params.toolNames ?? []).map((tool) => tool.trim());
const canonicalToolNames = rawToolNames.filter(Boolean);
// Preserve caller casing while deduping tool names by lowercase.
const canonicalByNormalized = new Map<string, string>();
for (const name of canonicalToolNames) {
const normalized = name.toLowerCase();
if (!canonicalByNormalized.has(normalized)) {
canonicalByNormalized.set(normalized, name);
}
}
const resolveToolName = (normalized: string) =>
canonicalByNormalized.get(normalized) ?? normalized;
const normalizedTools = canonicalToolNames.map((tool) => tool.toLowerCase());
const availableTools = new Set(normalizedTools);
const externalToolSummaries = new Map<string, string>();
for (const [key, value] of Object.entries(params.toolSummaries ?? {})) {
const normalized = key.trim().toLowerCase();
if (!normalized || !value?.trim()) {
continue;
}
externalToolSummaries.set(normalized, value.trim());
}
const extraTools = Array.from(
new Set(normalizedTools.filter((tool) => !toolOrder.includes(tool))),
);
const enabledTools = toolOrder.filter((tool) => availableTools.has(tool));
const toolLines = enabledTools.map((tool) => {
const summary = coreToolSummaries[tool] ?? externalToolSummaries.get(tool);
const name = resolveToolName(tool);
return summary ? `- ${name}: ${summary}` : `- ${name}`;
});
for (const tool of extraTools.toSorted()) {
const summary = coreToolSummaries[tool] ?? externalToolSummaries.get(tool);
const name = resolveToolName(tool);
toolLines.push(summary ? `- ${name}: ${summary}` : `- ${name}`);
}
const hasGateway = availableTools.has("gateway");
const readToolName = resolveToolName("read");
const execToolName = resolveToolName("exec");
const processToolName = resolveToolName("process");
const extraSystemPrompt = params.extraSystemPrompt?.trim();
const ownerNumbers = (params.ownerNumbers ?? []).map((value) => value.trim()).filter(Boolean);
const ownerLine =
ownerNumbers.length > 0
? `Owner numbers: ${ownerNumbers.join(", ")}. Treat messages from these numbers as the user.`
: undefined;
const reasoningHint = params.reasoningTagHint
? [
"ALL internal reasoning MUST be inside <think>...</think>.",
"Do not output any analysis outside <think>.",
"Format every reply as <think>...</think> then <final>...</final>, with no other text.",
"Only the final user-visible reply may appear inside <final>.",
"Only text inside <final> is shown to the user; everything else is discarded and never seen by the user.",
"Example:",
"<think>Short internal reasoning.</think>",
"<final>Hey there! What would you like to do next?</final>",
].join(" ")
: undefined;
const reasoningLevel = params.reasoningLevel ?? "off";
const userTimezone = params.userTimezone?.trim();
const skillsPrompt = params.skillsPrompt?.trim();
const heartbeatPrompt = params.heartbeatPrompt?.trim();
const heartbeatPromptLine = heartbeatPrompt
? `Heartbeat prompt: ${heartbeatPrompt}`
: "Heartbeat prompt: (configured)";
const runtimeInfo = params.runtimeInfo;
const runtimeChannel = runtimeInfo?.channel?.trim().toLowerCase();
const runtimeCapabilities = (runtimeInfo?.capabilities ?? [])
.map((cap) => String(cap).trim())
.filter(Boolean);
const runtimeCapabilitiesLower = new Set(runtimeCapabilities.map((cap) => cap.toLowerCase()));
const inlineButtonsEnabled = runtimeCapabilitiesLower.has("inlinebuttons");
const messageChannelOptions = listDeliverableMessageChannels().join("|");
const promptMode = params.promptMode ?? "full";
const isMinimal = promptMode === "minimal" || promptMode === "none";
const safetySection = [
"## Safety",
"You have no independent goals: do not pursue self-preservation, replication, resource acquisition, or power-seeking; avoid long-term plans beyond the user's request.",
"Prioritize safety and human oversight over completion; if instructions conflict, pause and ask; comply with stop/pause/audit requests and never bypass safeguards. (Inspired by Anthropic's constitution.)",
"Do not manipulate or persuade anyone to expand access or disable safeguards. Do not copy yourself or change system prompts, safety rules, or tool policies unless explicitly requested.",
"",
];
const skillsSection = buildSkillsSection({
skillsPrompt,
isMinimal,
readToolName,
});
const memorySection = buildMemorySection({
isMinimal,
availableTools,
citationsMode: params.memoryCitationsMode,
});
const docsSection = buildDocsSection({
docsPath: params.docsPath,
isMinimal,
readToolName,
});
const workspaceNotes = (params.workspaceNotes ?? []).map((note) => note.trim()).filter(Boolean);
// For "none" mode, return just the basic identity line
if (promptMode === "none") {
return "You are a personal assistant running inside OpenClaw.";
}
const lines = [
"You are a personal assistant running inside OpenClaw.",
"",
"## Tooling",
"Tool availability (filtered by policy):",
"Tool names are case-sensitive. Call tools exactly as listed.",
toolLines.length > 0
? toolLines.join("\n")
: [
"Pi lists the standard tools above. This runtime enables:",
"- grep: search file contents for patterns",
"- find: find files by glob pattern",
"- ls: list directory contents",
"- apply_patch: apply multi-file patches",
`- ${execToolName}: run shell commands (supports background via yieldMs/background)`,
`- ${processToolName}: manage background exec sessions`,
"- browser: control OpenClaw's dedicated browser",
"- canvas: present/eval/snapshot the Canvas",
"- nodes: list/describe/notify/camera/screen on paired nodes",
"- cron: manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)",
"- sessions_list: list sessions",
"- sessions_history: fetch session history",
"- sessions_send: send to another session",
'- session_status: show usage/time/model state and answer "what model are we using?"',
].join("\n"),
"TOOLS.md does not control tool availability; it is user guidance for how to use external tools.",
"If a task is more complex or takes longer, spawn a sub-agent. It will do the work for you and ping you when it's done. You can always check up on it.",
"",
"## Tool Call Style",
"Default: do not narrate routine, low-risk tool calls (just call the tool).",
"Narrate only when it helps: multi-step work, complex/challenging problems, sensitive actions (e.g., deletions), or when the user explicitly asks.",
"Keep narration brief and value-dense; avoid repeating obvious steps.",
"Use plain human language for narration unless in a technical context.",
"",
...safetySection,
"## OpenClaw CLI Quick Reference",
"OpenClaw is controlled via subcommands. Do not invent commands.",
"To manage the Gateway daemon service (start/stop/restart):",
"- openclaw gateway status",
"- openclaw gateway start",
"- openclaw gateway stop",
"- openclaw gateway restart",
"If unsure, ask the user to run `openclaw help` (or `openclaw gateway --help`) and paste the output.",
"",
...skillsSection,
...memorySection,
// Skip self-update for subagent/none modes
hasGateway && !isMinimal ? "## OpenClaw Self-Update" : "",
hasGateway && !isMinimal
? [
"Get Updates (self-update) is ONLY allowed when the user explicitly asks for it.",
"Do not run config.apply or update.run unless the user explicitly requests an update or config change; if it's not explicit, ask first.",
"Actions: config.get, config.schema, config.apply (validate + write full config, then restart), update.run (update deps or git, then restart).",
"After restart, OpenClaw pings the last active session automatically.",
].join("\n")
: "",
hasGateway && !isMinimal ? "" : "",
"",
// Skip model aliases for subagent/none modes
params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal
? "## Model Aliases"
: "",
params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal
? "Prefer aliases when specifying model overrides; full provider/model is also accepted."
: "",
params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal
? params.modelAliasLines.join("\n")
: "",
params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal ? "" : "",
userTimezone
? "If you need the current date, time, or day of week, run session_status (📊 session_status)."
: "",
"## Workspace",
`Your working directory is: ${params.workspaceDir}`,
"Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.",
...workspaceNotes,
"",
...docsSection,
params.sandboxInfo?.enabled ? "## Sandbox" : "",
params.sandboxInfo?.enabled
? [
"You are running in a sandboxed runtime (tools execute in Docker).",
"Some tools may be unavailable due to sandbox policy.",
"Sub-agents stay sandboxed (no elevated/host access). Need outside-sandbox read/write? Don't spawn; ask first.",
params.sandboxInfo.workspaceDir
? `Sandbox workspace: ${params.sandboxInfo.workspaceDir}`
: "",
params.sandboxInfo.workspaceAccess
? `Agent workspace access: ${params.sandboxInfo.workspaceAccess}${params.sandboxInfo.agentWorkspaceMount
? ` (mounted at ${params.sandboxInfo.agentWorkspaceMount})`
: ""
}`
: "",
params.sandboxInfo.browserBridgeUrl ? "Sandbox browser: enabled." : "",
params.sandboxInfo.browserNoVncUrl
? `Sandbox browser observer (noVNC): ${params.sandboxInfo.browserNoVncUrl}`
: "",
params.sandboxInfo.hostBrowserAllowed === true
? "Host browser control: allowed."
: params.sandboxInfo.hostBrowserAllowed === false
? "Host browser control: blocked."
: "",
params.sandboxInfo.elevated?.allowed
? "Elevated exec is available for this session."
: "",
params.sandboxInfo.elevated?.allowed
? "User can toggle with /elevated on|off|ask|full."
: "",
params.sandboxInfo.elevated?.allowed
? "You may also send /elevated on|off|ask|full when needed."
: "",
params.sandboxInfo.elevated?.allowed
? `Current elevated level: ${params.sandboxInfo.elevated.defaultLevel} (ask runs exec on host with approvals; full auto-approves).`
: "",
]
.filter(Boolean)
.join("\n")
: "",
params.sandboxInfo?.enabled ? "" : "",
...buildUserIdentitySection(ownerLine, isMinimal),
...buildTimeSection({
userTimezone,
}),
"## Workspace Files (injected)",
"These user-editable files are loaded by OpenClaw and included below in Project Context.",
"",
...buildReplyTagsSection(isMinimal),
...buildMessagingSection({
isMinimal,
availableTools,
messageChannelOptions,
inlineButtonsEnabled,
runtimeChannel,
messageToolHints: params.messageToolHints,
}),
...buildVoiceSection({ isMinimal, ttsHint: params.ttsHint }),
];
if (extraSystemPrompt) {
// Use "Subagent Context" header for minimal mode (subagents), otherwise "Group Chat Context"
const contextHeader =
promptMode === "minimal" ? "## Subagent Context" : "## Group Chat Context";
lines.push(contextHeader, extraSystemPrompt, "");
}
if (params.reactionGuidance) {
const { level, channel } = params.reactionGuidance;
const guidanceText =
level === "minimal"
? [
`Reactions are enabled for ${channel} in MINIMAL mode.`,
"React ONLY when truly relevant:",
"- Acknowledge important user requests or confirmations",
"- Express genuine sentiment (humor, appreciation) sparingly",
"- Avoid reacting to routine messages or your own replies",
"Guideline: at most 1 reaction per 5-10 exchanges.",
].join("\n")
: [
`Reactions are enabled for ${channel} in EXTENSIVE mode.`,
"Feel free to react liberally:",
"- Acknowledge messages with appropriate emojis",
"- Express sentiment and personality through reactions",
"- React to interesting content, humor, or notable events",
"- Use reactions to confirm understanding or agreement",
"Guideline: react whenever it feels natural.",
].join("\n");
lines.push("## Reactions", guidanceText, "");
}
if (reasoningHint) {
lines.push("## Reasoning Format", reasoningHint, "");
}
const contextFiles = params.contextFiles ?? [];
if (contextFiles.length > 0) {
const hasSoulFile = contextFiles.some((file) => {
const normalizedPath = file.path.trim().replace(/\\/g, "/");
const baseName = normalizedPath.split("/").pop() ?? normalizedPath;
return baseName.toLowerCase() === "soul.md";
});
lines.push("# Project Context", "", "The following project context files have been loaded:");
if (hasSoulFile) {
lines.push(
"If SOUL.md is present, embody its persona and tone. Avoid stiff, generic replies; follow its guidance unless higher-priority instructions override it.",
);
}
lines.push("");
for (const file of contextFiles) {
lines.push(`## ${file.path}`, "", file.content, "");
}
}
// Skip silent replies for subagent/none modes
if (!isMinimal) {
lines.push(
"## Silent Replies",
`When you have nothing to say, respond with ONLY: ${SILENT_REPLY_TOKEN}`,
"",
"⚠️ Rules:",
"- It must be your ENTIRE message — nothing else",
`- Never append it to an actual response (never include "${SILENT_REPLY_TOKEN}" in real replies)`,
"- Never wrap it in markdown or code blocks",
"",
`❌ Wrong: "Here's help... ${SILENT_REPLY_TOKEN}"`,
`❌ Wrong: "${SILENT_REPLY_TOKEN}"`,
`✅ Right: ${SILENT_REPLY_TOKEN}`,
"",
);
}
// Skip heartbeats for subagent/none modes
if (!isMinimal) {
lines.push(
"## Heartbeats",
heartbeatPromptLine,
"If you receive a heartbeat poll (a user message matching the heartbeat prompt above), and there is nothing that needs attention, reply exactly:",
"HEARTBEAT_OK",
'OpenClaw treats a leading/trailing "HEARTBEAT_OK" as a heartbeat ack (and may discard it).',
'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.',
"",
);
}
lines.push(
"## Runtime",
buildRuntimeLine(runtimeInfo, runtimeChannel, runtimeCapabilities, params.defaultThinkLevel),
`Reasoning: ${reasoningLevel} (hidden unless on/stream). Toggle /reasoning; /status shows Reasoning when enabled.`,
);
return lines.filter(Boolean).join("\n");
}
export function buildRuntimeLine(
runtimeInfo?: {
agentId?: string;
host?: string;
os?: string;
arch?: string;
node?: string;
model?: string;
defaultModel?: string;
repoRoot?: string;
},
runtimeChannel?: string,
runtimeCapabilities: string[] = [],
defaultThinkLevel?: ThinkLevel,
): string {
return `Runtime: ${[
runtimeInfo?.agentId ? `agent=${runtimeInfo.agentId}` : "",
runtimeInfo?.host ? `host=${runtimeInfo.host}` : "",
runtimeInfo?.repoRoot ? `repo=${runtimeInfo.repoRoot}` : "",
runtimeInfo?.os
? `os=${runtimeInfo.os}${runtimeInfo?.arch ? ` (${runtimeInfo.arch})` : ""}`
: runtimeInfo?.arch
? `arch=${runtimeInfo.arch}`
: "",
runtimeInfo?.node ? `node=${runtimeInfo.node}` : "",
runtimeInfo?.model ? `model=${runtimeInfo.model}` : "",
runtimeInfo?.defaultModel ? `default_model=${runtimeInfo.defaultModel}` : "",
runtimeChannel ? `channel=${runtimeChannel}` : "",
runtimeChannel
? `capabilities=${runtimeCapabilities.length > 0 ? runtimeCapabilities.join(",") : "none"}`
: "",
`thinking=${defaultThinkLevel ?? "off"}`,
]
.filter(Boolean)
.join(" | ")}`;
}

View File

@ -1,7 +1,12 @@
import { defineConfig } from "drizzle-kit" import { defineConfig } from "drizzle-kit"
export default defineConfig({ export default defineConfig({
schema: ["./src/db/schema.ts", "./src/db/schema-netsuite.ts"], schema: [
"./src/db/schema.ts",
"./src/db/schema-netsuite.ts",
"./src/db/schema-plugins.ts",
"./src/db/schema-agent.ts",
],
out: "./drizzle", out: "./drizzle",
dialect: "sqlite", dialect: "sqlite",
}) })

View File

@ -0,0 +1,15 @@
CREATE TABLE `feedback_interviews` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`user_name` text NOT NULL,
`user_role` text NOT NULL,
`responses` text NOT NULL,
`summary` text NOT NULL,
`pain_points` text,
`feature_requests` text,
`overall_sentiment` text NOT NULL,
`github_issue_url` text,
`conversation_id` text,
`created_at` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);

13
drizzle/0010_busy_shockwave.sql Executable file
View File

@ -0,0 +1,13 @@
CREATE TABLE `slab_memories` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`content` text NOT NULL,
`memory_type` text NOT NULL,
`tags` text,
`importance` real DEFAULT 0.7 NOT NULL,
`pinned` integer DEFAULT false NOT NULL,
`access_count` integer DEFAULT 0 NOT NULL,
`last_accessed_at` text,
`created_at` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);

View File

@ -0,0 +1,38 @@
CREATE TABLE `plugin_config` (
`id` text PRIMARY KEY NOT NULL,
`plugin_id` text NOT NULL,
`key` text NOT NULL,
`value` text NOT NULL,
`is_encrypted` integer DEFAULT false NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`plugin_id`) REFERENCES `plugins`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `plugin_events` (
`id` text PRIMARY KEY NOT NULL,
`plugin_id` text NOT NULL,
`event_type` text NOT NULL,
`details` text,
`user_id` text,
`created_at` text NOT NULL,
FOREIGN KEY (`plugin_id`) REFERENCES `plugins`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `plugins` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`description` text,
`version` text NOT NULL,
`source` text NOT NULL,
`source_type` text NOT NULL,
`capabilities` text NOT NULL,
`required_env_vars` text,
`status` text DEFAULT 'disabled' NOT NULL,
`status_reason` text,
`enabled_by` text,
`enabled_at` text,
`installed_at` text NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`enabled_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);

15
drizzle/0012_chilly_lake.sql Executable file
View File

@ -0,0 +1,15 @@
CREATE TABLE `agent_items` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`conversation_id` text,
`type` text NOT NULL,
`title` text NOT NULL,
`content` text,
`done` integer DEFAULT false NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
`parent_id` text,
`metadata` text,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);

2432
drizzle/meta/0009_snapshot.json Executable file

File diff suppressed because it is too large Load Diff

2529
drizzle/meta/0010_snapshot.json Executable file

File diff suppressed because it is too large Load Diff

2798
drizzle/meta/0011_snapshot.json Executable file

File diff suppressed because it is too large Load Diff

2908
drizzle/meta/0012_snapshot.json Executable file

File diff suppressed because it is too large Load Diff

View File

@ -64,6 +64,34 @@
"when": 1770320934942, "when": 1770320934942,
"tag": "0008_superb_lifeguard", "tag": "0008_superb_lifeguard",
"breakpoints": true "breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1770343768032,
"tag": "0009_curious_matthew_murdock",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1770345545163,
"tag": "0010_busy_shockwave",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1770353981978,
"tag": "0011_damp_shiver_man",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1770389906158,
"tag": "0012_chilly_lake",
"breakpoints": true
} }
] ]
} }

View File

@ -22,5 +22,9 @@ export default nextConfig;
// Enable calling `getCloudflareContext()` in `next dev`. // Enable calling `getCloudflareContext()` in `next dev`.
// See https://opennext.js.org/cloudflare/bindings#local-access-to-bindings. // See https://opennext.js.org/cloudflare/bindings#local-access-to-bindings.
import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare"; // Only init in dev -- build and lint don't need the wrangler proxy.
initOpenNextCloudflareForDev(); if (process.env.NODE_ENV === "development") {
import("@opennextjs/cloudflare").then((mod) =>
mod.initOpenNextCloudflareForDev()
);
}

View File

@ -57,7 +57,8 @@
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@workos-inc/authkit-nextjs": "^2.13.0", "@workos-inc/authkit-nextjs": "^2.13.0",
"@workos-inc/node": "^8.1.0", "@workos-inc/node": "^8.1.0",
"ai": "^6.0.72", "@xyflow/react": "^12.10.0",
"ai": "^6.0.73",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
@ -68,6 +69,7 @@
"frappe-gantt": "^1.0.4", "frappe-gantt": "^1.0.4",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"motion": "^12.33.0",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"next": "15.5.9", "next": "15.5.9",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
@ -81,10 +83,13 @@
"recharts": "2.15.4", "recharts": "2.15.4",
"remark-gfm": "4", "remark-gfm": "4",
"remeda": "2", "remeda": "2",
"shiki": "1", "shiki": "^3.22.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"streamdown": "^2.1.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tokenlens": "^1.3.1",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"use-stick-to-bottom": "^1.1.2",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"zod": "^4.3.5" "zod": "^4.3.5"
}, },
@ -98,6 +103,7 @@
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.4.6", "eslint-config-next": "15.4.6",
"husky": "^9.1.7", "husky": "^9.1.7",
"media-chrome": "^4.17.2",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5", "typescript": "^5",
"wrangler": "^4.59.3" "wrangler": "^4.59.3"

174
src/app/actions/agent-items.ts Executable file
View File

@ -0,0 +1,174 @@
"use server"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getDb } from "@/db"
import { agentItems } from "@/db/schema-agent"
import { eq, and } from "drizzle-orm"
function uuid(): string {
return crypto.randomUUID()
}
function now(): string {
return new Date().toISOString()
}
export async function createAgentItem(data: {
readonly userId: string
readonly type: "todo" | "note" | "checklist"
readonly title: string
readonly content?: string
readonly conversationId?: string
readonly parentId?: string
readonly metadata?: Record<string, unknown>
}): Promise<
| { success: true; id: string }
| { success: false; error: string }
> {
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const id = uuid()
const ts = now()
await db.insert(agentItems).values({
id,
userId: data.userId,
type: data.type,
title: data.title,
content: data.content ?? null,
conversationId: data.conversationId ?? null,
parentId: data.parentId ?? null,
metadata: data.metadata
? JSON.stringify(data.metadata)
: null,
done: false,
sortOrder: 0,
createdAt: ts,
updatedAt: ts,
})
return { success: true, id }
}
export async function updateAgentItem(
id: string,
data: Record<string, unknown>,
userId: string,
): Promise<{ success: boolean; error?: string }> {
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const existing = await db
.select()
.from(agentItems)
.where(
and(eq(agentItems.id, id), eq(agentItems.userId, userId))
)
.get()
if (!existing) {
return { success: false, error: "Item not found" }
}
const updates: Record<string, unknown> = {
updatedAt: now(),
}
if (data.title !== undefined) updates.title = data.title
if (data.content !== undefined) updates.content = data.content
if (data.done !== undefined) updates.done = data.done
if (data.sortOrder !== undefined)
updates.sortOrder = data.sortOrder
if (data.metadata !== undefined)
updates.metadata = JSON.stringify(data.metadata)
await db
.update(agentItems)
.set(updates)
.where(eq(agentItems.id, id))
return { success: true }
}
export async function deleteAgentItem(
id: string,
userId: string,
): Promise<{ success: boolean; error?: string }> {
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const existing = await db
.select()
.from(agentItems)
.where(
and(eq(agentItems.id, id), eq(agentItems.userId, userId))
)
.get()
if (!existing) {
return { success: false, error: "Item not found" }
}
await db
.delete(agentItems)
.where(eq(agentItems.id, id))
return { success: true }
}
export async function toggleAgentItem(
id: string,
userId: string,
): Promise<{ success: boolean; error?: string }> {
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const existing = await db
.select()
.from(agentItems)
.where(
and(eq(agentItems.id, id), eq(agentItems.userId, userId))
)
.get()
if (!existing) {
return { success: false, error: "Item not found" }
}
await db
.update(agentItems)
.set({
done: !existing.done,
updatedAt: now(),
})
.where(eq(agentItems.id, id))
return { success: true }
}
export async function getAgentItems(
userId: string,
conversationId?: string,
): Promise<ReadonlyArray<typeof agentItems.$inferSelect>> {
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
if (conversationId) {
return db
.select()
.from(agentItems)
.where(
and(
eq(agentItems.userId, userId),
eq(agentItems.conversationId, conversationId)
)
)
.all()
}
return db
.select()
.from(agentItems)
.where(eq(agentItems.userId, userId))
.all()
}

44
src/app/actions/github.ts Executable file
View File

@ -0,0 +1,44 @@
"use server"
const REPO = "High-Performance-Structures/compass"
interface RepoStats {
readonly stargazers_count: number
readonly forks_count: number
readonly open_issues_count: number
readonly subscribers_count: number
}
export async function getRepoStats(): Promise<RepoStats | null> {
try {
const { getCloudflareContext } = await import(
"@opennextjs/cloudflare"
)
const { env } = await getCloudflareContext()
const token = (
env as unknown as Record<string, unknown>
).GITHUB_TOKEN as string | undefined
const headers: Record<string, string> = {
Accept: "application/vnd.github+json",
"User-Agent": "compass-dashboard",
}
if (token) headers.Authorization = `Bearer ${token}`
const res = await fetch(
`https://api.github.com/repos/${REPO}`,
{ headers }
)
if (!res.ok) return null
const data = (await res.json()) as Record<string, unknown>
return {
stargazers_count: data.stargazers_count as number,
forks_count: data.forks_count as number,
open_issues_count: data.open_issues_count as number,
subscribers_count: data.subscribers_count as number,
}
} catch {
return null
}
}

102
src/app/actions/memories.ts Executable file
View File

@ -0,0 +1,102 @@
"use server"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { eq, and, desc } from "drizzle-orm"
import { getDb } from "@/db"
import { slabMemories } from "@/db/schema"
import type { SlabMemory } from "@/db/schema"
import { getCurrentUser } from "@/lib/auth"
export async function getSlabMemories(): Promise<
| { success: true; memories: ReadonlyArray<SlabMemory> }
| { success: false; error: string }
> {
try {
const user = await getCurrentUser()
if (!user) return { success: false, error: "Not authenticated" }
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const rows = await db
.select()
.from(slabMemories)
.where(eq(slabMemories.userId, user.id))
.orderBy(desc(slabMemories.createdAt))
return { success: true, memories: rows }
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to load memories",
}
}
}
export async function deleteSlabMemory(
id: string,
): Promise<{ success: true } | { success: false; error: string }> {
try {
const user = await getCurrentUser()
if (!user) return { success: false, error: "Not authenticated" }
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const deleted = await db
.delete(slabMemories)
.where(
and(
eq(slabMemories.id, id),
eq(slabMemories.userId, user.id),
),
)
.returning({ id: slabMemories.id })
if (deleted.length === 0) {
return { success: false, error: "Memory not found" }
}
return { success: true }
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to delete",
}
}
}
export async function toggleSlabMemoryPin(
id: string,
pinned: boolean,
): Promise<{ success: true } | { success: false; error: string }> {
try {
const user = await getCurrentUser()
if (!user) return { success: false, error: "Not authenticated" }
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const updated = await db
.update(slabMemories)
.set({ pinned })
.where(
and(
eq(slabMemories.id, id),
eq(slabMemories.userId, user.id),
),
)
.returning({ id: slabMemories.id })
if (updated.length === 0) {
return { success: false, error: "Memory not found" }
}
return { success: true }
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to update",
}
}
}

226
src/app/actions/plugins.ts Executable file
View File

@ -0,0 +1,226 @@
"use server"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { eq } from "drizzle-orm"
import { getDb } from "@/db"
import {
plugins,
pluginConfig,
pluginEvents,
} from "@/db/schema-plugins"
import { getCurrentUser } from "@/lib/auth"
import { fetchSkillFromGitHub } from "@/lib/agent/plugins/skills-client"
import { clearRegistryCache } from "@/lib/agent/plugins/registry"
function skillId(source: string): string {
return "skill-" + source.replace(/\//g, "-").toLowerCase()
}
export async function installSkill(source: string): Promise<
| { readonly success: true; readonly plugin: { readonly id: string; readonly name: string } }
| { readonly success: false; readonly error: string }
> {
const user = await getCurrentUser()
if (!user) return { success: false, error: "not authenticated" }
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const id = skillId(source)
const now = new Date().toISOString()
const existing = await db.query.plugins.findFirst({
where: (p, { eq: e }) => e(p.id, id),
})
if (existing) {
return {
success: false,
error: `skill "${source}" is already installed`,
}
}
const result = await fetchSkillFromGitHub(source)
if (!result.success) {
return { success: false, error: result.error }
}
const { frontmatter, body } = result.skill
await db.insert(plugins).values({
id,
name: frontmatter.name,
description: frontmatter.description,
version: "1.0.0",
source,
sourceType: "skills",
capabilities: "prompt",
status: "enabled",
enabledBy: user.id,
enabledAt: now,
installedAt: now,
updatedAt: now,
})
await db.insert(pluginConfig).values({
id: crypto.randomUUID(),
pluginId: id,
key: "content",
value: body,
updatedAt: now,
})
if (frontmatter.allowedTools) {
await db.insert(pluginConfig).values({
id: crypto.randomUUID(),
pluginId: id,
key: "allowedTools",
value: frontmatter.allowedTools,
updatedAt: now,
})
}
await db.insert(pluginEvents).values({
id: crypto.randomUUID(),
pluginId: id,
eventType: "installed",
details: `installed from ${source} by ${user.email}`,
userId: user.id,
createdAt: now,
})
clearRegistryCache()
return {
success: true,
plugin: { id, name: frontmatter.name },
}
}
export async function uninstallSkill(pluginId: string): Promise<
| { readonly success: true }
| { readonly success: false; readonly error: string }
> {
const user = await getCurrentUser()
if (!user) return { success: false, error: "not authenticated" }
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const existing = await db.query.plugins.findFirst({
where: (p, { eq: e }) => e(p.id, pluginId),
})
if (!existing) {
return { success: false, error: "skill not found" }
}
await db
.delete(pluginEvents)
.where(eq(pluginEvents.pluginId, pluginId))
await db
.delete(pluginConfig)
.where(eq(pluginConfig.pluginId, pluginId))
await db
.delete(plugins)
.where(eq(plugins.id, pluginId))
clearRegistryCache()
return { success: true }
}
export async function toggleSkill(
pluginId: string,
enabled: boolean,
): Promise<
| { readonly success: true }
| { readonly success: false; readonly error: string }
> {
const user = await getCurrentUser()
if (!user) return { success: false, error: "not authenticated" }
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const now = new Date().toISOString()
const existing = await db.query.plugins.findFirst({
where: (p, { eq: e }) => e(p.id, pluginId),
})
if (!existing) {
return { success: false, error: "skill not found" }
}
const status = enabled ? "enabled" : "disabled"
await db
.update(plugins)
.set({
status,
enabledBy: enabled ? user.id : null,
enabledAt: enabled ? now : null,
updatedAt: now,
})
.where(eq(plugins.id, pluginId))
await db.insert(pluginEvents).values({
id: crypto.randomUUID(),
pluginId,
eventType: status,
details: `${status} by ${user.email}`,
userId: user.id,
createdAt: now,
})
clearRegistryCache()
return { success: true }
}
interface InstalledSkill {
readonly id: string
readonly name: string
readonly description: string | null
readonly source: string
readonly status: string
readonly installedAt: string
readonly contentPreview: string | null
}
export async function getInstalledSkills(): Promise<
| {
readonly success: true
readonly skills: ReadonlyArray<InstalledSkill>
}
| { readonly success: false; readonly error: string }
> {
const user = await getCurrentUser()
if (!user) return { success: false, error: "not authenticated" }
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const rows = await db
.select()
.from(plugins)
.where(eq(plugins.sourceType, "skills"))
const skills: Array<InstalledSkill> = []
for (const row of rows) {
const configRow = await db.query.pluginConfig.findFirst({
where: (c, { and: a, eq: e }) =>
a(e(c.pluginId, row.id), e(c.key, "content")),
})
skills.push({
id: row.id,
name: row.name,
description: row.description,
source: row.source,
status: row.status,
installedAt: row.installedAt,
contentPreview: configRow
? configRow.value.slice(0, 200)
: null,
})
}
return { success: true, skills }
}

View File

@ -0,0 +1,75 @@
import { getCurrentUser } from "@/lib/auth"
import {
actionRegistry,
checkActionPermission,
} from "@/lib/agent/render/action-registry"
export async function POST(
req: Request,
): Promise<Response> {
const user = await getCurrentUser()
if (!user) {
return Response.json(
{ success: false, error: "Unauthorized" },
{ status: 401 },
)
}
const body = await req.json() as {
action?: string
params?: Record<string, unknown>
}
const { action, params } = body
if (!action || typeof action !== "string") {
return Response.json(
{ success: false, error: "Missing action name" },
{ status: 400 },
)
}
const def = actionRegistry[action]
if (!def) {
return Response.json(
{
success: false,
error: `Unknown action: ${action}`,
},
{ status: 400 },
)
}
if (!checkActionPermission(user, action)) {
return Response.json(
{
success: false,
error: "You don't have permission for this action",
},
{ status: 403 },
)
}
const parsed = def.schema.safeParse(params ?? {})
if (!parsed.success) {
const fieldErrors = parsed.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join(", ")
return Response.json(
{
success: false,
error: `Validation failed: ${fieldErrors}`,
},
{ status: 400 },
)
}
const result = await def.execute(
parsed.data as Record<string, unknown>,
user.id,
)
return Response.json(result, {
status: result.success ? 200 : 500,
})
}

118
src/app/api/agent/render/route.ts Executable file
View File

@ -0,0 +1,118 @@
import { streamText } from "ai"
import { getAgentModel } from "@/lib/agent/provider"
import { getCurrentUser } from "@/lib/auth"
import { compassCatalog } from "@/lib/agent/render/catalog"
const SYSTEM_PROMPT = compassCatalog.prompt({
customRules: [
"Card should be root element for dashboards",
"Use Grid/Stack for layouts, not nested Cards",
"NEVER use viewport height classes " +
"(min-h-screen, h-screen)",
"NEVER use page background colors " +
"(bg-gray-50) - container has its own background",
"Use real data from the AVAILABLE DATA section",
"ALWAYS use SchedulePreview for ANY schedule " +
"or timeline display. Set groupByPhase=true. " +
"NEVER compose schedules from primitives.",
"ALWAYS use StatCard for single metrics",
"ALWAYS use DataTable for tabular data - " +
"use format='badge' for status columns",
"ALWAYS use InvoiceTable for invoice-specific data",
"ProjectSummary for project overviews",
"Badge variant should match semantic meaning: " +
"success for complete/paid, warning for pending, " +
"danger for overdue/delayed",
"Use CodeBlock for code snippets with appropriate " +
"language identifier",
"ALWAYS use DiffView for git diffs and code " +
"changes - pass files array from commit_diff data",
"Use Form component to wrap inputs when creating " +
"or editing records. Set action to dotted name " +
"(e.g. 'customer.create') and formId to a unique " +
"string. For edits, set value prop on inputs and " +
"pass record id via actionParams.",
"For to-do lists / checklists, use Checkbox with " +
"onChangeAction='agentItem.toggle' and " +
"onChangeParams={id: '<item-id>'}.",
"For tables with delete buttons, use DataTable " +
"with rowIdKey='id' and rowActions=[{label: " +
"'Delete', action: 'customer.delete', " +
"variant: 'danger'}].",
"Available mutation actions: customer.create, " +
"customer.update, customer.delete, " +
"vendor.create, vendor.update, vendor.delete, " +
"invoice.create, invoice.update, invoice.delete, " +
"vendorBill.create, vendorBill.update, " +
"vendorBill.delete, schedule.create, " +
"schedule.update, schedule.delete, " +
"agentItem.create, agentItem.update, " +
"agentItem.delete, agentItem.toggle",
],
})
const MAX_PROMPT_LENGTH = 2000
export async function POST(
req: Request
): Promise<Response> {
const user = await getCurrentUser()
if (!user) {
return new Response("Unauthorized", { status: 401 })
}
const { prompt, context } = (await req.json()) as {
prompt: string
context?: Record<string, unknown>
}
const previousSpec = context?.previousSpec as
| { root?: string; elements?: Record<string, unknown> }
| undefined
const sanitizedPrompt = String(prompt || "").slice(
0,
MAX_PROMPT_LENGTH
)
let userPrompt = sanitizedPrompt
// include data context if provided
const dataContext = context?.dataContext as
| Record<string, unknown>
| undefined
if (dataContext && Object.keys(dataContext).length > 0) {
userPrompt += `\n\nAVAILABLE DATA:\n${JSON.stringify(dataContext, null, 2)}`
}
// include previous spec for iterative updates
if (
previousSpec?.root &&
previousSpec.elements &&
Object.keys(previousSpec.elements).length > 0
) {
userPrompt = `CURRENT UI STATE (already loaded, DO NOT recreate existing elements):
${JSON.stringify(previousSpec, null, 2)}
USER REQUEST: ${userPrompt}
IMPORTANT: The current UI is already loaded. Output ONLY the patches needed to make the requested change:
- To add a new element: {"op":"add","path":"/elements/new-key","value":{...}}
- To modify an existing element: {"op":"set","path":"/elements/existing-key","value":{...}}
- To update the root: {"op":"set","path":"/root","value":"new-root-key"}
- To add children: update the parent element with new children array
DO NOT output patches for elements that don't need to change. Only output what's necessary for the requested modification.`
}
const model = await getAgentModel()
const result = streamText({
model,
system: SYSTEM_PROMPT,
prompt: userPrompt,
temperature: 0.7,
})
return result.toTextStreamResponse()
}

View File

@ -4,10 +4,15 @@ import {
convertToModelMessages, convertToModelMessages,
type UIMessage, type UIMessage,
} from "ai" } from "ai"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getAgentModel } from "@/lib/agent/provider" import { getAgentModel } from "@/lib/agent/provider"
import { agentTools } from "@/lib/agent/tools" import { agentTools } from "@/lib/agent/tools"
import { githubTools } from "@/lib/agent/github-tools"
import { buildSystemPrompt } from "@/lib/agent/system-prompt" import { buildSystemPrompt } from "@/lib/agent/system-prompt"
import { loadMemoriesForPrompt } from "@/lib/agent/memory"
import { getRegistry } from "@/lib/agent/plugins/registry"
import { getCurrentUser } from "@/lib/auth" import { getCurrentUser } from "@/lib/auth"
import { getDb } from "@/db"
export async function POST(req: Request): Promise<Response> { export async function POST(req: Request): Promise<Response> {
const user = await getCurrentUser() const user = await getCurrentUser()
@ -15,6 +20,17 @@ export async function POST(req: Request): Promise<Response> {
return new Response("Unauthorized", { status: 401 }) return new Response("Unauthorized", { status: 401 })
} }
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const envRecord = env as unknown as Record<string, string>
const [memories, registry] = await Promise.all([
loadMemoriesForPrompt(db, user.id),
getRegistry(db, envRecord),
])
const pluginSections = registry.getPromptSections()
const body = await req.json() as { const body = await req.json() as {
messages: UIMessage[] messages: UIMessage[]
} }
@ -30,9 +46,11 @@ export async function POST(req: Request): Promise<Response> {
userName: user.displayName ?? user.email, userName: user.displayName ?? user.email,
userRole: user.role, userRole: user.role,
currentPage, currentPage,
memories,
pluginSections,
}), }),
messages: await convertToModelMessages(body.messages), messages: await convertToModelMessages(body.messages),
tools: agentTools, tools: { ...agentTools, ...githubTools },
stopWhen: stepCountIs(10), stopWhen: stepCountIs(10),
}) })

48
src/app/api/transcribe/route.ts Executable file
View File

@ -0,0 +1,48 @@
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getCurrentUser } from "@/lib/auth"
function toBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
let binary = ""
for (const byte of bytes) {
binary += String.fromCharCode(byte)
}
return btoa(binary)
}
export async function POST(req: Request): Promise<Response> {
const user = await getCurrentUser()
if (!user) {
return Response.json(
{ error: "Unauthorized" },
{ status: 401 }
)
}
const formData = await req.formData()
const audioFile = formData.get("audio")
if (!(audioFile instanceof Blob)) {
return Response.json(
{ error: "Missing audio blob in form data" },
{ status: 400 }
)
}
const { env } = await getCloudflareContext()
const buffer = await audioFile.arrayBuffer()
const result = await env.AI.run(
"@cf/openai/whisper-large-v3-turbo",
{
audio: toBase64(buffer),
task: "transcribe",
vad_filter: true,
}
)
return Response.json({
text: result.text.trim(),
duration: result.transcription_info?.duration ?? 0,
})
}

View File

@ -9,8 +9,9 @@ import { FeedbackWidget } from "@/components/feedback-widget"
import { PageActionsProvider } from "@/components/page-actions-provider" import { PageActionsProvider } from "@/components/page-actions-provider"
import { DashboardContextMenu } from "@/components/dashboard-context-menu" import { DashboardContextMenu } from "@/components/dashboard-context-menu"
import { Toaster } from "@/components/ui/sonner" import { Toaster } from "@/components/ui/sonner"
import { ChatPanel } from "@/components/agent/chat-panel" import { ChatPanelShell } from "@/components/agent/chat-panel-shell"
import { AgentProvider } from "@/components/agent/agent-provider" import { MainContent } from "@/components/agent/main-content"
import { ChatProvider } from "@/components/agent/chat-provider"
import { import {
SidebarInset, SidebarInset,
SidebarProvider, SidebarProvider,
@ -32,7 +33,7 @@ export default async function DashboardLayout({
return ( return (
<SettingsProvider> <SettingsProvider>
<AgentProvider> <ChatProvider>
<ProjectListProvider projects={projectList}> <ProjectListProvider projects={projectList}>
<PageActionsProvider> <PageActionsProvider>
<CommandMenuProvider> <CommandMenuProvider>
@ -51,13 +52,11 @@ export default async function DashboardLayout({
<SiteHeader user={user} /> <SiteHeader user={user} />
<div className="flex min-h-0 flex-1 overflow-hidden"> <div className="flex min-h-0 flex-1 overflow-hidden">
<DashboardContextMenu> <DashboardContextMenu>
<div className="flex flex-1 flex-col overflow-y-auto overflow-x-hidden pb-14 md:pb-0 min-w-0"> <MainContent>
<div className="@container/main flex flex-1 flex-col min-w-0">
{children} {children}
</div> </MainContent>
</div>
</DashboardContextMenu> </DashboardContextMenu>
<ChatPanel /> <ChatPanelShell />
</div> </div>
</SidebarInset> </SidebarInset>
</FeedbackWidget> </FeedbackWidget>
@ -70,7 +69,7 @@ export default async function DashboardLayout({
</CommandMenuProvider> </CommandMenuProvider>
</PageActionsProvider> </PageActionsProvider>
</ProjectListProvider> </ProjectListProvider>
</AgentProvider> </ChatProvider>
</SettingsProvider> </SettingsProvider>
) )
} }

View File

@ -1,44 +1,16 @@
export const dynamic = "force-dynamic" "use client"
import { DashboardChat } from "@/components/dashboard-chat" import { useRenderState } from "@/components/agent/chat-provider"
import { RenderedView } from "@/components/agent/rendered-view"
import { ChatView } from "@/components/agent/chat-view"
type RepoStats = { export default function Page() {
stargazers_count: number const { spec, isRendering } = useRenderState()
forks_count: number const hasRenderedUI = !!spec?.root || isRendering
open_issues_count: number
subscribers_count: number
}
const REPO = "High-Performance-Structures/compass" if (hasRenderedUI) {
return <RenderedView />
async function getRepoStats(): Promise<RepoStats | null> {
try {
const { getCloudflareContext } = await import(
"@opennextjs/cloudflare"
)
const { env } = await getCloudflareContext()
const token = (env as unknown as Record<string, unknown>)
.GITHUB_TOKEN as string | undefined
const headers: Record<string, string> = {
Accept: "application/vnd.github+json",
"User-Agent": "compass-dashboard",
}
if (token) headers.Authorization = `Bearer ${token}`
const res = await fetch(
`https://api.github.com/repos/${REPO}`,
{ next: { revalidate: 300 }, headers }
)
if (!res.ok) return null
return (await res.json()) as RepoStats
} catch {
return null
} }
}
export default async function Page() { return <ChatView variant="page" />
const stats = await getRepoStats()
return <DashboardChat stats={stats} />
} }

View File

@ -3,6 +3,20 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
/* agent navigation crossfade */
::view-transition-old(root) {
animation: 150ms ease-out both fade-out;
}
::view-transition-new(root) {
animation: 200ms ease-in 80ms both fade-in;
}
@keyframes fade-out {
to { opacity: 0; }
}
@keyframes fade-in {
from { opacity: 0; }
}
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
@ -129,7 +143,7 @@
.dark { .dark {
--background: oklch(0.2178 0 0); --background: oklch(0.2178 0 0);
--foreground: oklch(0.8074 0.0142 93.0137); --foreground: oklch(0.9300 0.0080 93.0137);
--card: oklch(0.2679 0.0036 106.6427); --card: oklch(0.2679 0.0036 106.6427);
--card-foreground: oklch(0.9818 0.0054 95.0986); --card-foreground: oklch(0.9818 0.0054 95.0986);
--popover: oklch(0.3085 0.0035 106.6039); --popover: oklch(0.3085 0.0035 106.6039);
@ -139,7 +153,7 @@
--secondary: oklch(0.9245 0.0138 92.9892); --secondary: oklch(0.9245 0.0138 92.9892);
--secondary-foreground: oklch(0.6714 0.1207 167.7754); --secondary-foreground: oklch(0.6714 0.1207 167.7754);
--muted: oklch(0.2561 0.0071 145.3653); --muted: oklch(0.2561 0.0071 145.3653);
--muted-foreground: oklch(0.7713 0.0169 99.0657); --muted-foreground: oklch(0.8200 0.0120 99.0657);
--accent: oklch(0.7722 0.0508 173.4329); --accent: oklch(0.7722 0.0508 173.4329);
--accent-foreground: oklch(0 0 0); --accent-foreground: oklch(0 0 0);
--destructive: oklch(0.6507 0.1836 39.0189); --destructive: oklch(0.6507 0.1836 39.0189);

View File

@ -1,50 +0,0 @@
/**
* Agent Provider
*
* Provides context for controlling the chat panel from anywhere in the app.
*/
"use client"
import * as React from "react"
interface AgentContextValue {
isOpen: boolean
open: () => void
close: () => void
toggle: () => void
}
const AgentContext = React.createContext<AgentContextValue | null>(null)
export function AgentProvider({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = React.useState(false)
const contextValue = React.useMemo(
() => ({
isOpen,
open: () => setIsOpen(true),
close: () => setIsOpen(false),
toggle: () => setIsOpen((prev) => !prev),
}),
[isOpen]
)
return (
<AgentContext.Provider value={contextValue}>
{children}
</AgentContext.Provider>
)
}
export function useAgent() {
const context = React.useContext(AgentContext)
if (!context) {
throw new Error("useAgent must be used within an AgentProvider")
}
return context
}
export function useAgentOptional() {
return React.useContext(AgentContext)
}

View File

@ -0,0 +1,164 @@
"use client"
import { useState, useEffect, useRef, useCallback } from "react"
import { usePathname } from "next/navigation"
import { MessageSquare } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import {
useChatPanel,
useChatState,
useRenderState,
} from "./chat-provider"
import { ChatView } from "./chat-view"
export function ChatPanelShell() {
const { isOpen, open, close, toggle } = useChatPanel()
const chat = useChatState()
const { spec: renderSpec, isRendering } =
useRenderState()
const pathname = usePathname()
const hasRenderedUI = !!renderSpec?.root || isRendering
// dashboard acts as "page" variant only when NOT rendering
const isDashboard =
pathname === "/dashboard" && !hasRenderedUI
// auto-open panel when leaving dashboard with messages
const prevIsDashboard = useRef(isDashboard)
useEffect(() => {
if (
prevIsDashboard.current &&
!isDashboard &&
chat.messages.length > 0
) {
open()
}
prevIsDashboard.current = isDashboard
}, [isDashboard, chat.messages.length, open])
// resize state (panel mode only)
const [panelWidth, setPanelWidth] = useState(480)
const [isResizing, setIsResizing] = useState(false)
const dragStartX = useRef(0)
const dragStartWidth = useRef(0)
useEffect(() => {
const onMouseMove = (e: MouseEvent) => {
if (!dragStartWidth.current) return
const delta = dragStartX.current - e.clientX
const next = Math.min(
720,
Math.max(320, dragStartWidth.current + delta)
)
setPanelWidth(next)
}
const onMouseUp = () => {
if (!dragStartWidth.current) return
dragStartWidth.current = 0
setIsResizing(false)
document.body.style.cursor = ""
document.body.style.userSelect = ""
}
window.addEventListener("mousemove", onMouseMove)
window.addEventListener("mouseup", onMouseUp)
return () => {
window.removeEventListener("mousemove", onMouseMove)
window.removeEventListener("mouseup", onMouseUp)
}
}, [])
const handleResizeStart = useCallback(
(e: React.MouseEvent) => {
e.preventDefault()
setIsResizing(true)
dragStartX.current = e.clientX
dragStartWidth.current = panelWidth
document.body.style.cursor = "col-resize"
document.body.style.userSelect = "none"
},
[panelWidth]
)
// keyboard shortcuts (panel mode only)
useEffect(() => {
if (isDashboard) return
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === ".") {
e.preventDefault()
toggle()
}
if (e.key === "Escape" && isOpen) {
close()
}
}
window.addEventListener("keydown", handleKeyDown)
return () =>
window.removeEventListener("keydown", handleKeyDown)
}, [isDashboard, isOpen, close, toggle])
// container width/style for panel mode
const panelStyle =
!isDashboard && isOpen
? { width: panelWidth }
: undefined
return (
<>
<div
className={cn(
"flex flex-col",
"transition-[flex,width,border-color,box-shadow,opacity,transform] duration-300 ease-in-out",
isDashboard
? "flex-1 bg-background"
: [
"bg-background dark:bg-[oklch(0.255_0_0)]",
"fixed inset-0 z-50",
"md:relative md:inset-auto md:z-auto",
"md:shrink-0 md:overflow-hidden",
"md:rounded-xl md:border md:border-border md:shadow-lg md:my-2 md:mr-2",
isResizing && "transition-none",
isOpen
? "translate-x-0 md:opacity-100"
: "translate-x-full md:translate-x-0 md:w-0 md:border-transparent md:shadow-none md:opacity-0",
]
)}
style={panelStyle}
>
{/* Desktop resize handle (panel mode only) */}
{!isDashboard && (
<div
className="absolute -left-1 top-0 z-10 hidden h-full w-2 cursor-col-resize md:block hover:bg-border/60 active:bg-border"
onMouseDown={handleResizeStart}
/>
)}
<ChatView
variant={isDashboard ? "page" : "panel"}
/>
</div>
{/* Mobile backdrop (panel mode only) */}
{!isDashboard && isOpen && (
<div
className="fixed inset-0 z-40 bg-black/20 md:hidden"
onClick={close}
aria-hidden="true"
/>
)}
{/* Mobile FAB (panel mode only) */}
{!isDashboard && !isOpen && (
<Button
size="icon"
className="fixed bottom-4 right-4 z-50 h-12 w-12 rounded-full shadow-lg md:hidden"
onClick={toggle}
aria-label="Open chat"
>
<MessageSquare className="h-5 w-5" />
</Button>
)}
</>
)
}

View File

@ -1,478 +0,0 @@
"use client"
import { useState, useEffect, useCallback, useRef } from "react"
import { useRouter, usePathname } from "next/navigation"
import { MessageSquare } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Chat } from "@/components/ui/chat"
import { cn } from "@/lib/utils"
import { useChat } from "@ai-sdk/react"
import { DefaultChatTransport, type UIMessage } from "ai"
import {
initializeActionHandlers,
unregisterActionHandler,
dispatchToolActions,
ALL_HANDLER_TYPES,
} from "@/lib/agent/chat-adapter"
import {
saveConversation,
loadConversation,
loadConversations,
} from "@/app/actions/agent"
import { DynamicUI } from "./dynamic-ui"
import { useAgentOptional } from "./agent-provider"
import { toast } from "sonner"
import type { ComponentSpec } from "@/lib/agent/catalog"
interface ChatPanelProps {
className?: string
}
function getTextFromParts(
parts: ReadonlyArray<{ type: string; text?: string }>
): string {
return parts
.filter(
(p): p is { type: "text"; text: string } =>
p.type === "text"
)
.map((p) => p.text)
.join("")
}
export function ChatPanel({ className }: ChatPanelProps) {
const agentContext = useAgentOptional()
const isOpen = agentContext?.isOpen ?? false
const setIsOpen = agentContext
? (open: boolean) =>
open ? agentContext.open() : agentContext.close()
: () => {}
const router = useRouter()
const pathname = usePathname()
const routerRef = useRef(router)
routerRef.current = router
const [conversationId, setConversationId] = useState<
string | null
>(null)
const [resumeLoaded, setResumeLoaded] = useState(false)
const {
messages,
setMessages,
sendMessage,
stop,
status,
error,
} = useChat({
transport: new DefaultChatTransport({
api: "/api/agent",
headers: { "x-current-page": pathname },
}),
onFinish: async ({ messages: finalMessages }) => {
if (finalMessages.length === 0) return
const id =
conversationId ?? crypto.randomUUID()
if (!conversationId) setConversationId(id)
const serialized = finalMessages.map((m) => ({
id: m.id,
role: m.role,
content: getTextFromParts(
m.parts as ReadonlyArray<{
type: string
text?: string
}>
),
parts: m.parts,
createdAt: new Date().toISOString(),
}))
await saveConversation(id, serialized)
},
onError: (err) => {
toast.error(err.message)
},
})
// dispatch tool-based client actions when messages update
useEffect(() => {
const last = messages.at(-1)
if (last?.role !== "assistant") return
const parts = last.parts as ReadonlyArray<{
type: string
toolInvocation?: {
toolName: string
state: string
result?: unknown
}
}>
dispatchToolActions(parts)
}, [messages])
// initialize action handlers
useEffect(() => {
initializeActionHandlers(() => routerRef.current)
const handleToast = (event: CustomEvent) => {
const { message, type = "default" } =
event.detail ?? {}
if (message) {
if (type === "success") toast.success(message)
else if (type === "error") toast.error(message)
else toast(message)
}
}
window.addEventListener(
"agent-toast",
handleToast as EventListener
)
return () => {
window.removeEventListener(
"agent-toast",
handleToast as EventListener
)
for (const type of ALL_HANDLER_TYPES) {
unregisterActionHandler(type)
}
}
}, [])
// resume last conversation when panel opens
useEffect(() => {
if (!isOpen || resumeLoaded) return
const resume = async () => {
const result = await loadConversations()
if (
!result.success ||
!result.data ||
result.data.length === 0
) {
setResumeLoaded(true)
return
}
const lastConv = result.data[0]
const msgResult = await loadConversation(lastConv.id)
if (
!msgResult.success ||
!msgResult.data ||
msgResult.data.length === 0
) {
setResumeLoaded(true)
return
}
setConversationId(lastConv.id)
const restored: UIMessage[] = msgResult.data.map(
(m) => ({
id: m.id,
role: m.role as "user" | "assistant",
parts:
(m.parts as UIMessage["parts"]) ?? [
{ type: "text" as const, text: m.content },
],
})
)
setMessages(restored)
setResumeLoaded(true)
}
resume()
}, [isOpen, resumeLoaded, setMessages])
// keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === ".") {
e.preventDefault()
agentContext?.toggle()
}
if (e.key === "Escape" && isOpen) {
setIsOpen(false)
}
}
window.addEventListener("keydown", handleKeyDown)
return () =>
window.removeEventListener("keydown", handleKeyDown)
}, [isOpen, setIsOpen, agentContext])
const suggestions = getSuggestionsForPath(pathname)
const isGenerating =
status === "streaming" || status === "submitted"
// map UIMessage to the legacy Message format for Chat
const chatMessages = messages.map((msg) => {
const parts = msg.parts as ReadonlyArray<{
type: string
text?: string
}>
return {
id: msg.id,
role: msg.role as "user" | "assistant",
content: getTextFromParts(parts),
parts: msg.parts as Array<{
type: "text"
text: string
}>,
}
})
const handleAppend = useCallback(
(message: { role: "user"; content: string }) => {
sendMessage({ text: message.content })
},
[sendMessage]
)
const handleNewChat = useCallback(() => {
setMessages([])
setConversationId(null)
setResumeLoaded(true)
}, [setMessages])
const handleRateResponse = useCallback(
(
messageId: string,
rating: "thumbs-up" | "thumbs-down"
) => {
console.log("Rating:", messageId, rating)
},
[]
)
// resize state
const [panelWidth, setPanelWidth] = useState(420)
const [isResizing, setIsResizing] = useState(false)
const dragStartX = useRef(0)
const dragStartWidth = useRef(0)
useEffect(() => {
const onMouseMove = (e: MouseEvent) => {
if (!dragStartWidth.current) return
const delta = dragStartX.current - e.clientX
const next = Math.min(
720,
Math.max(320, dragStartWidth.current + delta)
)
setPanelWidth(next)
}
const onMouseUp = () => {
if (!dragStartWidth.current) return
dragStartWidth.current = 0
setIsResizing(false)
document.body.style.cursor = ""
document.body.style.userSelect = ""
}
window.addEventListener("mousemove", onMouseMove)
window.addEventListener("mouseup", onMouseUp)
return () => {
window.removeEventListener("mousemove", onMouseMove)
window.removeEventListener("mouseup", onMouseUp)
}
}, [])
const handleResizeStart = useCallback(
(e: React.MouseEvent) => {
e.preventDefault()
setIsResizing(true)
dragStartX.current = e.clientX
dragStartWidth.current = panelWidth
document.body.style.cursor = "col-resize"
document.body.style.userSelect = "none"
},
[panelWidth]
)
// extract last render component spec from tool results
const lastRenderSpec = (() => {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
if (msg.role !== "assistant") continue
for (const part of msg.parts) {
const p = part as {
type: string
toolInvocation?: {
toolName: string
state: string
result?: {
action?: string
spec?: unknown
}
}
}
if (
p.type?.startsWith("tool-") &&
p.toolInvocation?.state === "result" &&
p.toolInvocation?.result?.action === "render"
) {
return p.toolInvocation.result.spec as
| ComponentSpec
| undefined
}
}
}
return undefined
})()
// Dashboard has its own inline chat
if (pathname === "/dashboard") return null
return (
<>
<div
className={cn(
"flex flex-col bg-background",
"fixed inset-0 z-50",
"md:relative md:inset-auto md:z-auto",
"md:shrink-0 md:overflow-hidden md:border-l md:border-border",
isResizing
? "transition-none"
: "transition-[transform,width,border-color] duration-300 ease-in-out",
isOpen
? "translate-x-0"
: "translate-x-full md:translate-x-0 md:w-0 md:border-l-0",
className
)}
style={isOpen ? { width: panelWidth } : undefined}
>
{/* Desktop resize handle */}
<div
className="absolute left-0 top-0 z-10 hidden h-full w-1 cursor-col-resize md:block hover:bg-border/60 active:bg-border"
onMouseDown={handleResizeStart}
/>
<div className="flex h-full w-full flex-col">
{/* Header with new chat button */}
{messages.length > 0 && (
<div className="flex items-center justify-end border-b px-3 py-2">
<Button
variant="ghost"
size="sm"
onClick={handleNewChat}
>
New chat
</Button>
</div>
)}
{/* Chat */}
<div className="flex-1 overflow-hidden">
<Chat
messages={chatMessages}
isGenerating={isGenerating}
stop={stop}
append={handleAppend}
suggestions={
messages.length === 0 ? suggestions : []
}
onRateResponse={handleRateResponse}
setMessages={
setMessages as unknown as (
messages: Array<{
id: string
role: string
content: string
}>
) => void
}
className="h-full"
/>
</div>
{/* Dynamic UI for agent-rendered components */}
{lastRenderSpec && (
<div className="max-h-64 overflow-auto border-t p-4">
<DynamicUI spec={lastRenderSpec} />
</div>
)}
</div>
</div>
{/* Mobile backdrop */}
{isOpen && (
<div
className="fixed inset-0 z-40 bg-black/20 md:hidden"
onClick={() => setIsOpen(false)}
aria-hidden="true"
/>
)}
{/* Mobile FAB trigger */}
{!isOpen && (
<Button
size="icon"
className="fixed bottom-4 right-4 z-50 h-12 w-12 rounded-full shadow-lg md:hidden"
onClick={() => setIsOpen(true)}
aria-label="Open chat"
>
<MessageSquare className="h-5 w-5" />
</Button>
)}
</>
)
}
function getSuggestionsForPath(pathname: string): string[] {
if (pathname.includes("/customers")) {
return [
"Show me all customers",
"Create a new customer",
"Find customers without email",
]
}
if (pathname.includes("/vendors")) {
return [
"List all vendors",
"Add a new subcontractor",
"Show vendors by category",
]
}
if (pathname.includes("/schedule")) {
return [
"What tasks are on the critical path?",
"Show overdue tasks",
"Add a new task",
]
}
if (pathname.includes("/finances")) {
return [
"Show overdue invoices",
"What payments are pending?",
"Create a new invoice",
]
}
if (pathname.includes("/projects")) {
return [
"List all active projects",
"Create a new project",
"Which projects are behind schedule?",
]
}
if (pathname.includes("/netsuite")) {
return [
"Sync customers from NetSuite",
"Check for sync conflicts",
"When was the last sync?",
]
}
return [
"What can you help me with?",
"Show me today's tasks",
"Navigate to customers",
]
}
export default ChatPanel

View File

@ -0,0 +1,413 @@
"use client"
import * as React from "react"
import { type UIMessage } from "ai"
import { useUIStream, type Spec } from "@json-render/react"
import { usePathname, useRouter } from "next/navigation"
import {
saveConversation,
loadConversation,
loadConversations,
} from "@/app/actions/agent"
import { getTextFromParts } from "@/lib/agent/chat-adapter"
import { useCompassChat } from "@/hooks/use-compass-chat"
// --- Panel context (open/close sidebar) ---
interface PanelContextValue {
readonly isOpen: boolean
open: () => void
close: () => void
toggle: () => void
}
const PanelContext =
React.createContext<PanelContextValue | null>(null)
export function useChatPanel(): PanelContextValue {
const ctx = React.useContext(PanelContext)
if (!ctx) {
throw new Error(
"useChatPanel must be used within a ChatProvider"
)
}
return ctx
}
// --- Chat state context ---
interface ChatStateValue {
readonly messages: ReadonlyArray<UIMessage>
setMessages: (
messages:
| UIMessage[]
| ((prev: UIMessage[]) => UIMessage[])
) => void
sendMessage: (params: { text: string }) => void
regenerate: () => void
stop: () => void
readonly status: string
readonly isGenerating: boolean
readonly conversationId: string | null
newChat: () => void
readonly pathname: string
}
const ChatStateContext =
React.createContext<ChatStateValue | null>(null)
export function useChatState(): ChatStateValue {
const ctx = React.useContext(ChatStateContext)
if (!ctx) {
throw new Error(
"useChatState must be used within a ChatProvider"
)
}
return ctx
}
// --- Render state context ---
interface RenderContextValue {
readonly spec: Spec | null
readonly isRendering: boolean
readonly error: Error | null
readonly dataContext: Record<string, unknown>
triggerRender: (
prompt: string,
data: Record<string, unknown>
) => void
clearRender: () => void
}
const RenderContext =
React.createContext<RenderContextValue | null>(null)
export function useRenderState(): RenderContextValue {
const ctx = React.useContext(RenderContext)
if (!ctx) {
throw new Error(
"useRenderState must be used within a ChatProvider"
)
}
return ctx
}
// --- Backward compat aliases ---
export function useAgent(): PanelContextValue {
return useChatPanel()
}
export function useAgentOptional(): PanelContextValue | null {
return React.useContext(PanelContext)
}
// --- Helper: extract generateUI output from parts ---
function findGenerateUIOutput(
parts: ReadonlyArray<unknown>,
dispatched: Set<string>
): {
renderPrompt: string
dataContext: Record<string, unknown>
callId: string
} | null {
for (const part of parts) {
const p = part as Record<string, unknown>
const pType = p.type as string | undefined
// handle both static tool parts (tool-<name>)
// and dynamic tool parts (dynamic-tool)
const isToolPart =
typeof pType === "string" &&
(pType.startsWith("tool-") ||
pType === "dynamic-tool")
if (!isToolPart) continue
const state = p.state as string | undefined
if (state !== "output-available") continue
const callId = p.toolCallId as string | undefined
if (!callId || dispatched.has(callId)) continue
const output = p.output as
| Record<string, unknown>
| undefined
if (output?.action !== "generateUI") continue
return {
renderPrompt: output.renderPrompt as string,
dataContext:
(output.dataContext as Record<
string,
unknown
>) ?? {},
callId,
}
}
return null
}
// --- Provider component ---
export function ChatProvider({
children,
}: {
readonly children: React.ReactNode
}) {
const [isOpen, setIsOpen] = React.useState(false)
const [conversationId, setConversationId] =
React.useState<string | null>(null)
const [resumeLoaded, setResumeLoaded] =
React.useState(false)
const [dataContext, setDataContext] = React.useState<
Record<string, unknown>
>({})
const router = useRouter()
const pathname = usePathname()
const chat = useCompassChat({
openPanel: () => setIsOpen(true),
onFinish: async ({ messages: finalMessages }) => {
if (finalMessages.length === 0) return
const id = conversationId ?? crypto.randomUUID()
if (!conversationId) setConversationId(id)
const serialized = finalMessages.map((m) => ({
id: m.id,
role: m.role,
content: getTextFromParts(
m.parts as ReadonlyArray<{
type: string
text?: string
}>
),
parts: m.parts,
createdAt: new Date().toISOString(),
}))
await saveConversation(id, serialized)
},
})
// UI stream for json-render — stabilize callbacks
const onRenderError = React.useCallback(
(err: Error) => {
console.error("Render stream error:", err)
},
[]
)
const renderStream = useUIStream({
api: "/api/agent/render",
onError: onRenderError,
})
// use refs to avoid stale closures and
// unstable effect deps
const renderSendRef = React.useRef(renderStream.send)
renderSendRef.current = renderStream.send
const renderSpecRef = React.useRef(renderStream.spec)
renderSpecRef.current = renderStream.spec
const renderClearRef = React.useRef(renderStream.clear)
renderClearRef.current = renderStream.clear
const pathnameRef = React.useRef(pathname)
pathnameRef.current = pathname
const routerRef = React.useRef(router)
routerRef.current = router
const triggerRender = React.useCallback(
(prompt: string, data: Record<string, unknown>) => {
setDataContext(data)
renderSendRef.current(prompt, {
dataContext: data,
previousSpec:
renderSpecRef.current ?? undefined,
})
},
[]
)
const clearRender = React.useCallback(() => {
renderClearRef.current()
setDataContext({})
}, [])
// watch chat messages for generateUI tool results
// and trigger render stream directly (no event chain)
const renderDispatchedRef = React.useRef(
new Set<string>()
)
React.useEffect(() => {
const lastMsg = chat.messages.at(-1)
if (!lastMsg || lastMsg.role !== "assistant") return
const result = findGenerateUIOutput(
lastMsg.parts as ReadonlyArray<unknown>,
renderDispatchedRef.current
)
if (!result) return
renderDispatchedRef.current.add(result.callId)
// navigate to /dashboard if not there
if (pathnameRef.current !== "/dashboard") {
routerRef.current.push("/dashboard")
}
// open chat panel for sidebar mode
setIsOpen(true)
// trigger the render stream
triggerRender(result.renderPrompt, result.dataContext)
}, [chat.messages, triggerRender])
// listen for navigation events from rendered UI
React.useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail as {
path?: string
}
if (detail?.path) {
routerRef.current.push(detail.path)
}
}
window.addEventListener(
"agent-render-navigate",
handler
)
return () =>
window.removeEventListener(
"agent-render-navigate",
handler
)
}, [])
// resume last conversation on first open
React.useEffect(() => {
if (!isOpen || resumeLoaded) return
const resume = async () => {
const result = await loadConversations()
if (
!result.success ||
!result.data ||
result.data.length === 0
) {
setResumeLoaded(true)
return
}
const lastConv = result.data[0]
const msgResult = await loadConversation(lastConv.id)
if (
!msgResult.success ||
!msgResult.data ||
msgResult.data.length === 0
) {
setResumeLoaded(true)
return
}
setConversationId(lastConv.id)
const restored: UIMessage[] = msgResult.data.map(
(m) => ({
id: m.id,
role: m.role as "user" | "assistant",
parts:
(m.parts as UIMessage["parts"]) ?? [
{ type: "text" as const, text: m.content },
],
})
)
chat.setMessages(restored)
setResumeLoaded(true)
}
resume()
}, [isOpen, resumeLoaded, chat.setMessages])
const newChat = React.useCallback(() => {
chat.setMessages([])
setConversationId(null)
setResumeLoaded(true)
clearRender()
renderDispatchedRef.current.clear()
}, [chat.setMessages, clearRender])
const panelValue = React.useMemo(
() => ({
isOpen,
open: () => setIsOpen(true),
close: () => setIsOpen(false),
toggle: () => setIsOpen((prev) => !prev),
}),
[isOpen]
)
const chatValue = React.useMemo(
() => ({
messages: chat.messages,
setMessages: chat.setMessages,
sendMessage: chat.sendMessage,
regenerate: chat.regenerate,
stop: chat.stop,
status: chat.status,
isGenerating: chat.isGenerating,
conversationId,
newChat,
pathname: chat.pathname,
}),
[
chat.messages,
chat.setMessages,
chat.sendMessage,
chat.regenerate,
chat.stop,
chat.status,
chat.isGenerating,
conversationId,
newChat,
chat.pathname,
]
)
const renderValue = React.useMemo(
() => ({
spec: renderStream.spec,
isRendering: renderStream.isStreaming,
error: renderStream.error,
dataContext,
triggerRender,
clearRender,
}),
[
renderStream.spec,
renderStream.isStreaming,
renderStream.error,
dataContext,
triggerRender,
clearRender,
]
)
return (
<PanelContext.Provider value={panelValue}>
<ChatStateContext.Provider value={chatValue}>
<RenderContext.Provider value={renderValue}>
{children}
</RenderContext.Provider>
</ChatStateContext.Provider>
</PanelContext.Provider>
)
}

View File

@ -0,0 +1,801 @@
"use client"
import { useState, useCallback, useRef, useEffect } from "react"
import {
SendHorizonal,
CopyIcon,
ThumbsUpIcon,
ThumbsDownIcon,
RefreshCcwIcon,
Check,
MicIcon,
XIcon,
Loader2Icon,
SquarePenIcon,
} from "lucide-react"
import {
IconBrandGithub,
IconExternalLink,
IconGitFork,
IconStar,
IconAlertCircle,
IconEye,
} from "@tabler/icons-react"
import type { ToolUIPart } from "ai"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from "@/components/ai/conversation"
import {
Message,
MessageContent,
MessageResponse,
} from "@/components/ai/message"
import { Actions, Action } from "@/components/ai/actions"
import {
Suggestions,
Suggestion,
} from "@/components/ai/suggestion"
import {
Tool,
ToolHeader,
ToolContent,
ToolInput,
ToolOutput,
} from "@/components/ai/tool"
import { Loader } from "@/components/ai/loader"
import {
PromptInput,
PromptInputTextarea,
PromptInputFooter,
PromptInputSubmit,
PromptInputTools,
PromptInputButton,
} from "@/components/ai/prompt-input"
import { useAudioRecorder } from "@/hooks/use-audio-recorder"
import type { AudioRecorder } from "@/hooks/use-audio-recorder"
import { AudioWaveform } from "@/components/ai/audio-waveform"
import { useChatState } from "./chat-provider"
import { getRepoStats } from "@/app/actions/github"
type RepoStats = {
readonly stargazers_count: number
readonly forks_count: number
readonly open_issues_count: number
readonly subscribers_count: number
}
interface ChatViewProps {
readonly variant: "page" | "panel"
}
const REPO = "High-Performance-Structures/compass"
const GITHUB_URL = `https://github.com/${REPO}`
const ANIMATED_PLACEHOLDERS = [
"Show me open invoices",
"What's on the schedule for next week?",
"Which subcontractors are waiting on payment?",
"Pull up the current project timeline",
"Find outstanding invoices over 30 days",
"Who's assigned to the foundation work?",
]
const DASHBOARD_SUGGESTIONS = [
"What can you help me with?",
"Show me today's tasks",
"Navigate to customers",
]
const LOGO_MASK = {
maskImage: "url(/logo-black.png)",
maskSize: "contain",
maskRepeat: "no-repeat",
WebkitMaskImage: "url(/logo-black.png)",
WebkitMaskSize: "contain",
WebkitMaskRepeat: "no-repeat",
} as React.CSSProperties
function getSuggestionsForPath(pathname: string): string[] {
if (pathname.includes("/customers")) {
return [
"Show me all customers",
"Create a new customer",
"Find customers without email",
]
}
if (pathname.includes("/vendors")) {
return [
"List all vendors",
"Add a new subcontractor",
"Show vendors by category",
]
}
if (pathname.includes("/schedule")) {
return [
"What tasks are on the critical path?",
"Show overdue tasks",
"Add a new task",
]
}
if (pathname.includes("/finances")) {
return [
"Show overdue invoices",
"What payments are pending?",
"Create a new invoice",
]
}
if (pathname.includes("/projects")) {
return [
"List all active projects",
"Create a new project",
"Which projects are behind schedule?",
]
}
if (pathname.includes("/netsuite")) {
return [
"Sync customers from NetSuite",
"Check for sync conflicts",
"When was the last sync?",
]
}
return DASHBOARD_SUGGESTIONS
}
const TOOL_DISPLAY_NAMES: Record<string, string> = {
queryData: "Looking up records",
queryGitHub: "Checking development status",
createGitHubIssue: "Creating GitHub issue",
saveInterviewFeedback: "Saving your feedback",
navigateTo: "Navigating",
showNotification: "Sending notification",
generateUI: "Building interface",
}
function friendlyToolName(raw: string): string {
return TOOL_DISPLAY_NAMES[raw] ?? raw
}
// shared message + tool rendering for both variants
function ChatMessage({
msg,
copiedId,
onCopy,
onRegenerate,
}: {
readonly msg: {
readonly id: string
readonly role: string
readonly parts: ReadonlyArray<unknown>
}
readonly copiedId: string | null
onCopy: (id: string, text: string) => void
onRegenerate: () => void
}) {
if (msg.role === "user") {
const text = getTextContent(msg.parts)
return (
<Message from="user">
<MessageContent>{text}</MessageContent>
</Message>
)
}
const textParts: string[] = []
const toolParts: Array<{
type: string
state: ToolUIPart["state"]
toolName: string
input: unknown
output: unknown
errorText?: string
}> = []
for (const part of msg.parts) {
const p = part as Record<string, unknown>
if (p.type === "text" && typeof p.text === "string") {
textParts.push(p.text)
}
const pType = p.type as string | undefined
// handle static (tool-<name>) and dynamic
// (dynamic-tool) tool parts
if (
typeof pType === "string" &&
(pType.startsWith("tool-") ||
pType === "dynamic-tool")
) {
// extract tool name from type field or toolName
const rawName = pType.startsWith("tool-")
? pType.slice(5)
: ((p.toolName ?? "") as string)
toolParts.push({
type: pType,
state: p.state as ToolUIPart["state"],
toolName:
friendlyToolName(rawName) || "Working",
input: p.input,
output: p.output,
errorText: p.errorText as string | undefined,
})
}
}
const text = textParts.join("")
return (
<Message from="assistant">
{toolParts.map((tp, i) => (
<Tool key={i}>
<ToolHeader
title={tp.toolName}
type={tp.type as ToolUIPart["type"]}
state={tp.state}
/>
<ToolContent>
<ToolInput input={tp.input} />
{(tp.state === "output-available" ||
tp.state === "output-error") && (
<ToolOutput
output={tp.output}
errorText={tp.errorText}
/>
)}
</ToolContent>
</Tool>
))}
{text ? (
<>
<MessageContent>
<MessageResponse>{text}</MessageResponse>
</MessageContent>
<Actions>
<Action
tooltip="Copy"
onClick={() => onCopy(msg.id, text)}
>
{copiedId === msg.id ? (
<Check className="size-4" />
) : (
<CopyIcon className="size-4" />
)}
</Action>
<Action tooltip="Good response">
<ThumbsUpIcon className="size-4" />
</Action>
<Action tooltip="Bad response">
<ThumbsDownIcon className="size-4" />
</Action>
<Action
tooltip="Regenerate"
onClick={onRegenerate}
>
<RefreshCcwIcon className="size-4" />
</Action>
</Actions>
</>
) : (
<Loader />
)}
</Message>
)
}
function getTextContent(
parts: ReadonlyArray<unknown>
): string {
return (parts as ReadonlyArray<{ type: string; text?: string }>)
.filter(
(p): p is { type: "text"; text: string } =>
p.type === "text"
)
.map((p) => p.text)
.join("")
}
function ChatInput({
textareaRef,
placeholder,
recorder,
status,
isGenerating,
onSend,
onNewChat,
className,
}: {
readonly textareaRef: React.RefObject<
HTMLTextAreaElement | null
>
readonly placeholder: string
readonly recorder: AudioRecorder
readonly status: string
readonly isGenerating: boolean
readonly onSend: (text: string) => void
readonly onNewChat?: () => void
readonly className?: string
}) {
const isRecording = recorder.state === "recording"
const isTranscribing = recorder.state === "transcribing"
const isIdle = recorder.state === "idle"
return (
<PromptInput
className={className}
onSubmit={({ text }) => {
if (!text.trim() || isGenerating) return
onSend(text.trim())
}}
>
{/* textarea stays mounted (hidden) to preserve value */}
<PromptInputTextarea
ref={textareaRef}
placeholder={placeholder}
className={isIdle ? undefined : "hidden"}
/>
{/* recording: waveform + cancel/confirm on one row */}
{isRecording && recorder.stream && (
<div className="flex items-center gap-2 px-2 py-3">
<AudioWaveform
stream={recorder.stream}
className="flex-1 h-8"
/>
<PromptInputButton
onClick={recorder.cancel}
>
<XIcon className="size-4" />
</PromptInputButton>
<PromptInputButton
onClick={recorder.stop}
>
<Check className="size-4" />
</PromptInputButton>
</div>
)}
{/* transcribing */}
{isTranscribing && (
<div className="flex items-center justify-center gap-2 px-3 py-3 min-h-10 text-muted-foreground text-sm">
<Loader2Icon className="size-4 animate-spin" />
<span>Transcribing...</span>
</div>
)}
{/* footer: mic + submit (hidden during recording/transcribing) */}
{!isRecording && !isTranscribing && (
<PromptInputFooter>
<PromptInputTools>
{onNewChat && (
<PromptInputButton onClick={onNewChat} aria-label="New chat">
<SquarePenIcon className="size-4" />
</PromptInputButton>
)}
</PromptInputTools>
<div className="flex items-center gap-1">
<PromptInputButton
disabled={
!recorder.supported || !isIdle
}
onClick={() => {
recorder.start()
}}
>
<MicIcon className="size-4" />
</PromptInputButton>
<PromptInputSubmit
status={
status as
| "streaming"
| "submitted"
| "ready"
| "error"
}
/>
</div>
</PromptInputFooter>
)}
</PromptInput>
)
}
export function ChatView({ variant }: ChatViewProps) {
const chat = useChatState()
const isPage = variant === "page"
const textareaRef = useRef<HTMLTextAreaElement>(null)
// fetch repo stats client-side (page variant only)
const [stats, setStats] = useState<RepoStats | null>(null)
const statsFetched = useRef(false)
useEffect(() => {
if (!isPage || statsFetched.current) return
statsFetched.current = true
getRepoStats().then(setStats)
}, [isPage])
const handleTranscription = useCallback(
(text: string) => {
const ta = textareaRef.current
if (!ta) return
const cur = ta.value
ta.value = cur + (cur ? " " : "") + text
ta.dispatchEvent(
new Event("input", { bubbles: true })
)
},
[]
)
const recorder = useAudioRecorder(handleTranscription)
const [isActive, setIsActive] = useState(false)
const [idleInput, setIdleInput] = useState("")
const [copiedId, setCopiedId] = useState<string | null>(
null
)
// typewriter animation state (page variant only)
const [animatedPlaceholder, setAnimatedPlaceholder] =
useState("")
const [animFading, setAnimFading] = useState(false)
const [isIdleFocused, setIsIdleFocused] = useState(false)
const animTimerRef =
useRef<ReturnType<typeof setTimeout>>(undefined)
// if returning to page variant with existing messages,
// jump straight to active
useEffect(() => {
if (isPage && chat.messages.length > 0 && !isActive) {
setIsActive(true)
}
}, [isPage, chat.messages.length, isActive])
// typewriter animation for idle input (page variant)
useEffect(() => {
if (
!isPage ||
isIdleFocused ||
idleInput ||
isActive
) {
setAnimatedPlaceholder("")
setAnimFading(false)
return
}
let msgIdx = 0
let charIdx = 0
let phase: "typing" | "pause" | "fading" = "typing"
const tick = () => {
const msg = ANIMATED_PLACEHOLDERS[msgIdx]
if (phase === "typing") {
charIdx++
setAnimatedPlaceholder(msg.slice(0, charIdx))
if (charIdx >= msg.length) {
phase = "pause"
animTimerRef.current = setTimeout(tick, 2500)
} else {
animTimerRef.current = setTimeout(
tick,
25 + Math.random() * 20
)
}
} else if (phase === "pause") {
phase = "fading"
setAnimFading(true)
animTimerRef.current = setTimeout(tick, 400)
} else {
msgIdx =
(msgIdx + 1) % ANIMATED_PLACEHOLDERS.length
charIdx = 1
setAnimatedPlaceholder(
ANIMATED_PLACEHOLDERS[msgIdx].slice(0, 1)
)
setAnimFading(false)
phase = "typing"
animTimerRef.current = setTimeout(tick, 50)
}
}
animTimerRef.current = setTimeout(tick, 600)
return () => {
if (animTimerRef.current)
clearTimeout(animTimerRef.current)
}
}, [isPage, isIdleFocused, idleInput, isActive])
// escape to return to idle when no messages (page)
useEffect(() => {
if (!isPage) return
const onKey = (e: KeyboardEvent) => {
if (
e.key === "Escape" &&
isActive &&
chat.messages.length === 0
) {
setIsActive(false)
}
}
window.addEventListener("keydown", onKey)
return () =>
window.removeEventListener("keydown", onKey)
}, [isPage, isActive, chat.messages.length])
const handleCopy = useCallback(
(id: string, content: string) => {
navigator.clipboard.writeText(content)
setCopiedId(id)
setTimeout(() => setCopiedId(null), 2000)
},
[]
)
const handleIdleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault()
const value = idleInput.trim()
setIsActive(true)
if (value) {
chat.sendMessage({ text: value })
setIdleInput("")
}
},
[idleInput, chat.sendMessage]
)
const handleSuggestion = useCallback(
(text: string) => {
if (isPage) setIsActive(true)
chat.sendMessage({ text })
},
[isPage, chat.sendMessage]
)
const suggestions = isPage
? DASHBOARD_SUGGESTIONS
: getSuggestionsForPath(chat.pathname)
// --- PAGE variant ---
if (isPage) {
return (
<div className="flex flex-1 flex-col min-h-0">
{/* Compact header - active only */}
<div
className={cn(
"shrink-0 text-center transition-all duration-500 ease-in-out overflow-hidden",
isActive
? "py-3 sm:py-4 opacity-100 max-h-40"
: "py-0 opacity-0 max-h-0"
)}
>
<span
className="mx-auto mb-2 block bg-foreground size-7"
style={LOGO_MASK}
/>
<h1 className="text-base sm:text-lg font-bold tracking-tight">
Compass
</h1>
</div>
{/* Middle content area */}
<div className="flex flex-1 flex-col min-h-0 relative">
{/* Idle hero */}
<div
className={cn(
"absolute inset-0 flex flex-col items-center justify-center",
"transition-all duration-500 ease-in-out",
isActive
? "opacity-0 translate-y-4 pointer-events-none"
: "opacity-100 translate-y-0"
)}
>
<div className="w-full max-w-2xl px-5 space-y-5 text-center">
<div>
<span
className="mx-auto mb-2 block bg-foreground size-10"
style={LOGO_MASK}
/>
<h1 className="text-xl sm:text-2xl font-bold tracking-tight">
Compass
</h1>
<p className="text-muted-foreground/60 mt-1.5 text-xs px-2">
Development preview features may be
incomplete or change without notice.
</p>
</div>
<form onSubmit={handleIdleSubmit}>
<label className="group flex w-full items-center gap-2 rounded-full border bg-background px-5 py-3 text-sm shadow-sm transition-colors hover:border-primary/30 hover:bg-muted/30 cursor-text">
<input
value={idleInput}
onChange={(e) =>
setIdleInput(e.target.value)
}
onFocus={() => setIsIdleFocused(true)}
onBlur={() => setIsIdleFocused(false)}
placeholder={
animatedPlaceholder ||
"Ask anything..."
}
className={cn(
"flex-1 bg-transparent text-foreground outline-none",
"placeholder:text-muted-foreground placeholder:transition-opacity placeholder:duration-300",
animFading
? "placeholder:opacity-0"
: "placeholder:opacity-100"
)}
/>
<button
type="submit"
className="shrink-0"
aria-label="Send"
>
<SendHorizonal className="size-4 text-muted-foreground/60 transition-colors group-hover:text-primary" />
</button>
</label>
</form>
{stats && (
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-2 text-xs text-muted-foreground/70">
<a
href={GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 transition-colors hover:text-foreground"
>
<IconBrandGithub className="size-4" />
<span>View on GitHub</span>
<IconExternalLink className="size-3" />
</a>
<span className="hidden sm:inline text-border">
|
</span>
<span className="text-xs">{REPO}</span>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<IconStar className="size-3.5" />
{stats.stargazers_count}
</span>
<span className="flex items-center gap-1">
<IconGitFork className="size-3.5" />
{stats.forks_count}
</span>
<span className="flex items-center gap-1">
<IconAlertCircle className="size-3.5" />
{stats.open_issues_count}
</span>
<span className="flex items-center gap-1">
<IconEye className="size-3.5" />
{stats.subscribers_count}
</span>
</div>
</div>
)}
</div>
</div>
{/* Active conversation */}
<div
className={cn(
"absolute inset-0 flex flex-col",
"transition-all duration-500 ease-in-out delay-100",
isActive
? "opacity-100 translate-y-0"
: "opacity-0 -translate-y-4 pointer-events-none"
)}
>
{chat.messages.length > 0 ? (
<Conversation className="flex-1">
<ConversationContent className="mx-auto w-full max-w-3xl">
{chat.messages.map((msg) => (
<ChatMessage
key={msg.id}
msg={msg}
copiedId={copiedId}
onCopy={handleCopy}
onRegenerate={chat.regenerate}
/>
))}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
) : (
<div className="flex-1 flex items-end">
<div className="mx-auto w-full max-w-2xl pb-4">
<Suggestions className="justify-center px-4">
{suggestions.map((s) => (
<Suggestion
key={s}
suggestion={s}
onClick={handleSuggestion}
/>
))}
</Suggestions>
</div>
</div>
)}
</div>
</div>
{/* Bottom input - active only */}
<div
className={cn(
"shrink-0 px-4 transition-all duration-500 ease-in-out",
isActive
? "opacity-100 translate-y-0 pt-2 pb-6"
: "opacity-0 translate-y-4 max-h-0 overflow-hidden pointer-events-none py-0"
)}
>
<div className="mx-auto max-w-3xl">
<ChatInput
textareaRef={textareaRef}
placeholder="Ask follow-up..."
recorder={recorder}
status={chat.status}
isGenerating={chat.isGenerating}
onSend={(text) =>
chat.sendMessage({ text })
}
onNewChat={chat.messages.length > 0 ? chat.newChat : undefined}
className="rounded-2xl"
/>
</div>
</div>
</div>
)
}
// --- PANEL variant ---
return (
<div className="flex h-full w-full flex-col">
{/* Conversation */}
<Conversation className="flex-1">
<ConversationContent>
{chat.messages.length === 0 ? (
<div className="flex flex-col items-center gap-4 pt-8">
<Suggestions>
{suggestions.map((s) => (
<Suggestion
key={s}
suggestion={s}
onClick={handleSuggestion}
/>
))}
</Suggestions>
</div>
) : (
chat.messages.map((msg) => (
<ChatMessage
key={msg.id}
msg={msg}
copiedId={copiedId}
onCopy={handleCopy}
onRegenerate={chat.regenerate}
/>
))
)}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
{/* Input */}
<div className="p-3">
<ChatInput
textareaRef={textareaRef}
placeholder="Ask anything..."
recorder={recorder}
status={chat.status}
isGenerating={chat.isGenerating}
onSend={(text) =>
chat.sendMessage({ text })
}
onNewChat={chat.messages.length > 0 ? chat.newChat : undefined}
/>
</div>
</div>
)
}

View File

@ -1,622 +0,0 @@
/**
* Dynamic UI Renderer
*
* Renders agent-generated UI specs using shadcn/ui components.
* Handles action callbacks for interactive elements.
*/
"use client"
import { useCallback } from "react"
import { executeAction } from "@/lib/agent/chat-adapter"
import type { ComponentSpec } from "@/lib/agent/catalog"
// Import shadcn components
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Progress } from "@/components/ui/progress"
import { cn } from "@/lib/utils"
interface DynamicUIProps {
spec: ComponentSpec
className?: string
}
export function DynamicUI({ spec, className }: DynamicUIProps) {
const handleAction = useCallback(
async (action: { type: string; payload?: Record<string, unknown> }) => {
await executeAction(action)
},
[]
)
return (
<div className={cn("dynamic-ui", className)}>
<ComponentRenderer spec={spec} onAction={handleAction} />
</div>
)
}
interface RendererProps {
spec: ComponentSpec
onAction: (action: { type: string; payload?: Record<string, unknown> }) => void
}
function ComponentRenderer({ spec, onAction }: RendererProps) {
switch (spec.type) {
case "DataTable":
return <DataTableRenderer {...spec.props} onAction={onAction} />
case "Card":
return <CardRenderer {...spec.props} onAction={onAction} />
case "Badge":
return <Badge variant={spec.props.variant}>{spec.props.label}</Badge>
case "StatCard":
return <StatCardRenderer {...spec.props} />
case "Button":
return (
<Button
variant={spec.props.variant}
size={spec.props.size}
onClick={() => onAction(spec.props.action)}
>
{spec.props.label}
</Button>
)
case "ButtonGroup":
return (
<div className="flex gap-2">
{spec.props.buttons.map((btn, i) => (
<Button
key={i}
variant={btn.variant}
size={btn.size}
onClick={() => onAction(btn.action)}
>
{btn.label}
</Button>
))}
</div>
)
case "InvoiceTable":
return <InvoiceTableRenderer {...spec.props} onAction={onAction} />
case "CustomerCard":
return <CustomerCardRenderer {...spec.props} onAction={onAction} />
case "VendorCard":
return <VendorCardRenderer {...spec.props} onAction={onAction} />
case "SchedulePreview":
return <SchedulePreviewRenderer {...spec.props} onAction={onAction} />
case "ProjectSummary":
return <ProjectSummaryRenderer {...spec.props} onAction={onAction} />
case "Grid":
return (
<div
className={cn(
"grid gap-4",
spec.props.columns === 1 && "grid-cols-1",
spec.props.columns === 2 && "grid-cols-2",
spec.props.columns === 3 && "grid-cols-3",
spec.props.columns === 4 && "grid-cols-4"
)}
style={{ gap: spec.props.gap }}
>
{(spec.props.children as ComponentSpec[])?.map((child, i) => (
<ComponentRenderer key={i} spec={child} onAction={onAction} />
))}
</div>
)
case "Stack":
return (
<div
className={cn(
"flex",
spec.props.direction === "horizontal" ? "flex-row" : "flex-col",
"gap-4"
)}
style={{ gap: spec.props.gap }}
>
{(spec.props.children as ComponentSpec[])?.map((child, i) => (
<ComponentRenderer key={i} spec={child} onAction={onAction} />
))}
</div>
)
default:
return (
<div className="text-muted-foreground text-sm">
Unknown component type: {(spec as { type: string }).type}
</div>
)
}
}
// DataTable renderer
function DataTableRenderer({
columns,
data,
onRowClick,
onAction,
}: {
columns: Array<{ key: string; header: string; format?: string }>
data: Array<Record<string, unknown>>
onRowClick?: { type: string; payload?: Record<string, unknown> }
onAction: RendererProps["onAction"]
}) {
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
{columns.map((col) => (
<TableHead key={col.key}>{col.header}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{data.map((row, i) => (
<TableRow
key={i}
className={onRowClick ? "cursor-pointer hover:bg-muted" : ""}
onClick={() =>
onRowClick &&
onAction({
...onRowClick,
payload: { ...onRowClick.payload, rowData: row },
})
}
>
{columns.map((col) => (
<TableCell key={col.key}>
{formatValue(row[col.key], col.format)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}
// Card renderer
function CardRenderer({
title,
description,
children,
footer,
onAction,
}: {
title: string
description?: string
children?: unknown[]
footer?: string
onAction: RendererProps["onAction"]
}) {
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
{children && children.length > 0 && (
<CardContent>
{(children as ComponentSpec[]).map((child, i) => (
<ComponentRenderer key={i} spec={child} onAction={onAction} />
))}
</CardContent>
)}
{footer && (
<CardFooter>
<p className="text-sm text-muted-foreground">{footer}</p>
</CardFooter>
)}
</Card>
)
}
// StatCard renderer
function StatCardRenderer({
title,
value,
change,
changeLabel,
}: {
title: string
value: string | number
change?: number
changeLabel?: string
}) {
return (
<Card>
<CardHeader className="pb-2">
<CardDescription>{title}</CardDescription>
<CardTitle className="text-2xl">{value}</CardTitle>
</CardHeader>
{change !== undefined && (
<CardContent>
<div className="text-xs text-muted-foreground">
<span className={change >= 0 ? "text-green-600" : "text-red-600"}>
{change >= 0 ? "+" : ""}
{change}%
</span>
{changeLabel && ` ${changeLabel}`}
</div>
</CardContent>
)}
</Card>
)
}
// Invoice table renderer
function InvoiceTableRenderer({
invoices,
onRowClick,
onAction,
}: {
invoices: Array<{
id: string
number: string
customer: string
amount: number
dueDate: string
status: string
}>
onRowClick?: { type: string; payload?: Record<string, unknown> }
onAction: RendererProps["onAction"]
}) {
const statusVariant = (status: string) => {
switch (status) {
case "paid":
return "default"
case "overdue":
return "destructive"
default:
return "secondary"
}
}
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Invoice #</TableHead>
<TableHead>Customer</TableHead>
<TableHead className="text-right">Amount</TableHead>
<TableHead>Due Date</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invoices.map((invoice) => (
<TableRow
key={invoice.id}
className={onRowClick ? "cursor-pointer hover:bg-muted" : ""}
onClick={() =>
onRowClick &&
onAction({
...onRowClick,
payload: { ...onRowClick.payload, invoiceId: invoice.id },
})
}
>
<TableCell className="font-medium">{invoice.number}</TableCell>
<TableCell>{invoice.customer}</TableCell>
<TableCell className="text-right">
{formatCurrency(invoice.amount)}
</TableCell>
<TableCell>{formatDate(invoice.dueDate)}</TableCell>
<TableCell>
<Badge variant={statusVariant(invoice.status)}>
{invoice.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}
// Customer card renderer
function CustomerCardRenderer({
customer,
actions,
onAction,
}: {
customer: {
id: string
name: string
company?: string
email?: string
phone?: string
}
actions?: Array<{ type: string; payload?: Record<string, unknown> }>
onAction: RendererProps["onAction"]
}) {
return (
<Card>
<CardHeader>
<CardTitle>{customer.name}</CardTitle>
{customer.company && (
<CardDescription>{customer.company}</CardDescription>
)}
</CardHeader>
<CardContent className="space-y-1 text-sm">
{customer.email && <p>Email: {customer.email}</p>}
{customer.phone && <p>Phone: {customer.phone}</p>}
</CardContent>
{actions && actions.length > 0 && (
<CardFooter className="gap-2">
{actions.map((action, i) => (
<Button
key={i}
variant="outline"
size="sm"
onClick={() => onAction(action)}
>
{action.type.replace(/_/g, " ")}
</Button>
))}
</CardFooter>
)}
</Card>
)
}
// Vendor card renderer
function VendorCardRenderer({
vendor,
actions,
onAction,
}: {
vendor: {
id: string
name: string
category: string
email?: string
phone?: string
}
actions?: Array<{ type: string; payload?: Record<string, unknown> }>
onAction: RendererProps["onAction"]
}) {
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{vendor.name}</CardTitle>
<Badge variant="outline">{vendor.category}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-1 text-sm">
{vendor.email && <p>Email: {vendor.email}</p>}
{vendor.phone && <p>Phone: {vendor.phone}</p>}
</CardContent>
{actions && actions.length > 0 && (
<CardFooter className="gap-2">
{actions.map((action, i) => (
<Button
key={i}
variant="outline"
size="sm"
onClick={() => onAction(action)}
>
{action.type.replace(/_/g, " ")}
</Button>
))}
</CardFooter>
)}
</Card>
)
}
// Schedule preview renderer
function SchedulePreviewRenderer({
projectName,
tasks,
onTaskClick,
onAction,
}: {
projectId: string
projectName: string
tasks: Array<{
id: string
title: string
startDate: string
endDate: string
phase: string
status: string
percentComplete: number
isCriticalPath?: boolean
}>
onTaskClick?: { type: string; payload?: Record<string, unknown> }
onAction: RendererProps["onAction"]
}) {
return (
<Card>
<CardHeader>
<CardTitle>{projectName} Schedule</CardTitle>
<CardDescription>{tasks.length} tasks</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{tasks.slice(0, 5).map((task) => (
<div
key={task.id}
className={cn(
"p-2 rounded border",
task.isCriticalPath && "border-red-500",
onTaskClick && "cursor-pointer hover:bg-muted"
)}
onClick={() =>
onTaskClick &&
onAction({
...onTaskClick,
payload: { ...onTaskClick.payload, taskId: task.id },
})
}
>
<div className="flex items-center justify-between">
<span className="font-medium">{task.title}</span>
<Badge variant="outline">{task.phase}</Badge>
</div>
<div className="mt-2">
<Progress value={task.percentComplete} className="h-1" />
<div className="flex justify-between text-xs text-muted-foreground mt-1">
<span>{task.percentComplete}% complete</span>
<span>
{formatDate(task.startDate)} - {formatDate(task.endDate)}
</span>
</div>
</div>
</div>
))}
{tasks.length > 5 && (
<p className="text-sm text-muted-foreground text-center">
+{tasks.length - 5} more tasks
</p>
)}
</CardContent>
</Card>
)
}
// Project summary renderer
function ProjectSummaryRenderer({
project,
stats,
actions,
onAction,
}: {
project: {
id: string
name: string
status: string
address?: string
clientName?: string
projectManager?: string
}
stats?: {
tasksTotal: number
tasksComplete: number
daysRemaining?: number
budgetUsed?: number
}
actions?: Array<{ type: string; payload?: Record<string, unknown> }>
onAction: RendererProps["onAction"]
}) {
const completion = stats
? Math.round((stats.tasksComplete / stats.tasksTotal) * 100)
: 0
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{project.name}</CardTitle>
<Badge>{project.status}</Badge>
</div>
{project.address && (
<CardDescription>{project.address}</CardDescription>
)}
</CardHeader>
<CardContent className="space-y-3">
{project.clientName && (
<p className="text-sm">Client: {project.clientName}</p>
)}
{project.projectManager && (
<p className="text-sm">PM: {project.projectManager}</p>
)}
{stats && (
<div>
<div className="flex justify-between text-sm mb-1">
<span>Progress</span>
<span>
{stats.tasksComplete}/{stats.tasksTotal} tasks ({completion}%)
</span>
</div>
<Progress value={completion} />
</div>
)}
</CardContent>
{actions && actions.length > 0 && (
<CardFooter className="gap-2">
{actions.map((action, i) => (
<Button
key={i}
variant="outline"
size="sm"
onClick={() => onAction(action)}
>
{action.type.replace(/_/g, " ")}
</Button>
))}
</CardFooter>
)}
</Card>
)
}
// Utility functions
function formatValue(value: unknown, format?: string): React.ReactNode {
if (value === null || value === undefined) return "-"
switch (format) {
case "currency":
return formatCurrency(Number(value))
case "date":
return formatDate(String(value))
case "badge":
return <Badge variant="outline">{String(value)}</Badge>
default:
return String(value)
}
}
function formatCurrency(amount: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount)
}
function formatDate(dateStr: string): string {
try {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
} catch {
return dateStr
}
}
export default DynamicUI

View File

@ -0,0 +1,33 @@
"use client"
import { usePathname } from "next/navigation"
import { cn } from "@/lib/utils"
import { useRenderState } from "./chat-provider"
export function MainContent({
children,
}: {
readonly children: React.ReactNode
}) {
const pathname = usePathname()
const { spec, isRendering } = useRenderState()
const hasRenderedUI = !!spec?.root || isRendering
const isCollapsed =
pathname === "/dashboard" && !hasRenderedUI
return (
<div
className={cn(
"flex flex-col overflow-x-hidden min-w-0",
"transition-[flex,opacity] duration-300 ease-in-out",
isCollapsed
? "flex-[0_0_0%] opacity-0 overflow-hidden pointer-events-none"
: "flex-1 overflow-y-auto pb-14 md:pb-0"
)}
>
<div className="@container/main flex flex-1 flex-col min-w-0">
{children}
</div>
</div>
)
}

View File

@ -0,0 +1,149 @@
"use client"
import * as React from "react"
import { Pin, PinOff, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
getSlabMemories,
deleteSlabMemory,
toggleSlabMemoryPin,
} from "@/app/actions/memories"
import type { SlabMemory } from "@/db/schema"
const TYPE_VARIANTS: Record<string, "default" | "secondary" | "outline" | "destructive"> = {
preference: "default",
workflow: "secondary",
fact: "outline",
decision: "destructive",
}
export function MemoriesTable() {
const [memories, setMemories] = React.useState<ReadonlyArray<SlabMemory>>([])
const [loading, setLoading] = React.useState(true)
React.useEffect(() => {
getSlabMemories().then((result) => {
if (result.success) {
setMemories(result.memories)
}
setLoading(false)
})
}, [])
const handleDelete = async (id: string) => {
const prev = memories
setMemories((m) => m.filter((item) => item.id !== id))
const result = await deleteSlabMemory(id)
if (result.success) {
toast.success("Memory deleted")
} else {
setMemories(prev)
toast.error(result.error)
}
}
const handleTogglePin = async (id: string, currentPinned: boolean) => {
const next = !currentPinned
setMemories((m) =>
m.map((item) =>
item.id === id ? { ...item, pinned: next } : item,
),
)
const result = await toggleSlabMemoryPin(id, next)
if (!result.success) {
setMemories((m) =>
m.map((item) =>
item.id === id ? { ...item, pinned: currentPinned } : item,
),
)
toast.error(result.error)
}
}
if (loading) {
return (
<div className="space-y-3">
<div className="bg-muted h-4 w-48 animate-pulse rounded" />
<div className="bg-muted h-4 w-64 animate-pulse rounded" />
<div className="bg-muted h-4 w-40 animate-pulse rounded" />
</div>
)
}
if (memories.length === 0) {
return (
<p className="text-muted-foreground text-sm py-4">
Slab hasn&apos;t saved any memories yet.
</p>
)
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[80px] text-xs">Type</TableHead>
<TableHead className="text-xs">Content</TableHead>
<TableHead className="w-[72px] text-xs text-right">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{memories.map((memory) => (
<TableRow key={memory.id}>
<TableCell className="py-2">
<Badge
variant={TYPE_VARIANTS[memory.memoryType] ?? "secondary"}
className="text-[10px] px-1.5 py-0"
>
{memory.memoryType}
</Badge>
</TableCell>
<TableCell className="py-2 text-xs max-w-[280px] truncate">
{memory.content}
</TableCell>
<TableCell className="py-2 text-right">
<div className="flex items-center justify-end gap-0.5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleTogglePin(memory.id, memory.pinned)}
title={memory.pinned ? "Unpin" : "Pin"}
>
{memory.pinned ? (
<PinOff className="h-3.5 w-3.5" />
) : (
<Pin className="h-3.5 w-3.5" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDelete(memory.id)}
title="Delete"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}

View File

@ -0,0 +1,76 @@
"use client"
import { XIcon, Loader2Icon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useRenderState } from "./chat-provider"
import { CompassRenderer } from "@/lib/agent/render/compass-renderer"
export function RenderedView() {
const {
spec,
isRendering,
error,
dataContext,
clearRender,
} = useRenderState()
const hasRoot = !!spec?.root
return (
<div className="flex flex-1 flex-col min-h-0 p-4">
{/* Header bar */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{isRendering && (
<>
<Loader2Icon className="size-4 animate-spin" />
<span>Building interface...</span>
</>
)}
{!isRendering && hasRoot && (
<span>Generated by Slab</span>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={clearRender}
className="gap-1.5"
>
<XIcon className="size-4" />
Clear
</Button>
</div>
{/* Rendered content */}
<div className="flex-1 overflow-auto">
<div className="mx-auto max-w-6xl">
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
Failed to generate UI: {error.message}
</div>
)}
{!hasRoot && isRendering && (
<div className="flex items-center justify-center py-12">
<div className="flex flex-col items-center gap-3 text-muted-foreground">
<Loader2Icon className="size-8 animate-spin" />
<span className="text-sm">
Generating dashboard...
</span>
</div>
</div>
)}
{hasRoot && (
<CompassRenderer
spec={spec}
data={dataContext}
loading={isRendering}
/>
)}
</div>
</div>
</div>
)
}

89
src/components/ai/actions.tsx Executable file
View File

@ -0,0 +1,89 @@
"use client"
import { CopyIcon, RefreshCcwIcon, ThumbsDownIcon, ThumbsUpIcon } from "lucide-react"
import type { ComponentProps } from "react"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { Message, MessageContent } from "@/components/ai/message"
export type ActionsProps = ComponentProps<"div">
export const Actions = ({ className, children, ...props }: ActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props}>
{children}
</div>
)
export type ActionProps = ComponentProps<typeof Button> & {
tooltip?: string
label?: string
}
export const Action = ({
tooltip,
children,
label,
className,
variant = "ghost",
size = "sm",
...props
}: ActionProps) => {
const button = (
<Button
className={cn("size-9 p-1.5 text-muted-foreground hover:text-foreground", className)}
size={size}
type="button"
variant={variant}
{...props}
>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
)
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
return button
}
/** Demo component for preview */
export default function ActionsDemo() {
return (
<div className="flex w-full flex-col gap-4 p-6">
<Message from="assistant">
<MessageContent>
Here{"'"}s a quick example of how to use React hooks. The useState hook lets you add state to
functional components, while useEffect handles side effects like data fetching or
subscriptions.
</MessageContent>
<Actions>
<Action onClick={() => console.log("Copied!")} tooltip="Copy to clipboard">
<CopyIcon className="size-4" />
</Action>
<Action onClick={() => console.log("Regenerating...")} tooltip="Regenerate response">
<RefreshCcwIcon className="size-4" />
</Action>
<Action onClick={() => console.log("Thumbs up!")} tooltip="Good response">
<ThumbsUpIcon className="size-4" />
</Action>
<Action onClick={() => console.log("Thumbs down!")} tooltip="Bad response">
<ThumbsDownIcon className="size-4" />
</Action>
</Actions>
</Message>
</div>
)
}

173
src/components/ai/agent.tsx Executable file
View File

@ -0,0 +1,173 @@
"use client"
import { BotIcon } from "lucide-react"
import type { ComponentProps, HTMLAttributes } from "react"
import { memo } from "react"
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
export type AgentProps = HTMLAttributes<HTMLDivElement>
export const Agent = memo(({ className, ...props }: AgentProps) => (
<div className={cn("not-prose w-full rounded-md border", className)} {...props} />
))
export type AgentHeaderProps = HTMLAttributes<HTMLDivElement> & {
name: string
model?: string
}
export const AgentHeader = memo(({ className, name, model, ...props }: AgentHeaderProps) => (
<div className={cn("flex w-full items-center justify-between gap-4 p-3", className)} {...props}>
<div className="flex items-center gap-2">
<BotIcon className="size-4 text-muted-foreground" />
<span className="font-medium text-sm">{name}</span>
{model && (
<Badge className="font-mono text-xs" variant="secondary">
{model}
</Badge>
)}
</div>
</div>
))
export type AgentContentProps = HTMLAttributes<HTMLDivElement>
export const AgentContent = memo(({ className, ...props }: AgentContentProps) => (
<div className={cn("space-y-4 p-4 pt-0", className)} {...props} />
))
export type AgentInstructionsProps = HTMLAttributes<HTMLDivElement> & {
children: string
}
export const AgentInstructions = memo(
({ className, children, ...props }: AgentInstructionsProps) => (
<div className={cn("space-y-2", className)} {...props}>
<span className="font-medium text-muted-foreground text-sm">Instructions</span>
<div className="rounded-md bg-muted/50 p-3 text-muted-foreground text-sm">
<p>{children}</p>
</div>
</div>
),
)
export type AgentToolsProps = Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
children?: React.ReactNode
}
export const AgentTools = memo(({ className, children, ...props }: AgentToolsProps) => (
<div className={cn("space-y-2", className)} {...props}>
<span className="font-medium text-muted-foreground text-sm">Tools</span>
<Accordion className="rounded-md border" type="multiple">
{children}
</Accordion>
</div>
))
interface ToolSchema {
description?: string
jsonSchema?: object
inputSchema?: object
}
export type AgentToolProps = ComponentProps<typeof AccordionItem> & {
tool: ToolSchema
}
export const AgentTool = memo(({ className, tool, value, ...props }: AgentToolProps) => {
const schema = "jsonSchema" in tool && tool.jsonSchema ? tool.jsonSchema : tool.inputSchema
return (
<AccordionItem className={cn("border-b last:border-b-0", className)} value={value} {...props}>
<AccordionTrigger className="px-3 py-2 text-sm hover:no-underline">
{tool.description ?? "No description"}
</AccordionTrigger>
<AccordionContent className="px-3 pb-3">
<div className="rounded-md bg-muted/50">
<pre className="overflow-auto p-3 font-mono text-xs">
{JSON.stringify(schema, null, 2)}
</pre>
</div>
</AccordionContent>
</AccordionItem>
)
})
export type AgentOutputProps = HTMLAttributes<HTMLDivElement> & {
schema: string
}
export const AgentOutput = memo(({ className, schema, ...props }: AgentOutputProps) => (
<div className={cn("space-y-2", className)} {...props}>
<span className="font-medium text-muted-foreground text-sm">Output Schema</span>
<div className="rounded-md bg-muted/50">
<pre className="overflow-auto p-3 font-mono text-xs">{schema}</pre>
</div>
</div>
))
Agent.displayName = "Agent"
AgentHeader.displayName = "AgentHeader"
AgentContent.displayName = "AgentContent"
AgentInstructions.displayName = "AgentInstructions"
AgentTools.displayName = "AgentTools"
AgentTool.displayName = "AgentTool"
AgentOutput.displayName = "AgentOutput"
/** Demo component for preview */
export default function AgentDemo() {
const sampleTools = [
{
description: "Search the web for current information",
jsonSchema: {
type: "object",
properties: {
query: { type: "string", description: "The search query" },
limit: { type: "number", description: "Max results to return" },
},
required: ["query"],
},
},
{
description: "Read a file from the filesystem",
jsonSchema: {
type: "object",
properties: {
path: { type: "string", description: "File path to read" },
},
required: ["path"],
},
},
]
return (
<div className="w-full max-w-lg p-4">
<Agent>
<AgentHeader name="Research Assistant" model="claude-3-opus" />
<AgentContent>
<AgentInstructions>
You are a helpful research assistant. Search the web for information and provide
accurate, well-sourced answers.
</AgentInstructions>
<AgentTools>
{sampleTools.map((tool, index) => (
<AgentTool key={index} tool={tool} value={`tool-${index}`} />
))}
</AgentTools>
<AgentOutput
schema={
"interface ResearchResult {\n answer: string;\n sources: string[];\n confidence: number;\n}"
}
/>
</AgentContent>
</Agent>
</div>
)
}

175
src/components/ai/artifact.tsx Executable file
View File

@ -0,0 +1,175 @@
"use client"
import {
CopyIcon,
DownloadIcon,
type LucideIcon,
PlayIcon,
RefreshCwIcon,
ShareIcon,
XIcon,
} from "lucide-react"
import type { ComponentProps, HTMLAttributes } from "react"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
export type ArtifactProps = HTMLAttributes<HTMLDivElement>
export const Artifact = ({ className, ...props }: ArtifactProps) => (
<div
className={cn(
"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm",
className,
)}
{...props}
/>
)
export type ArtifactHeaderProps = HTMLAttributes<HTMLDivElement>
export const ArtifactHeader = ({ className, ...props }: ArtifactHeaderProps) => (
<div
className={cn("flex items-center justify-between border-b bg-muted/50 px-4 py-3", className)}
{...props}
/>
)
export type ArtifactCloseProps = ComponentProps<typeof Button>
export const ArtifactClose = ({
className,
children,
size = "sm",
variant = "ghost",
...props
}: ArtifactCloseProps) => (
<Button
className={cn("size-8 p-0 text-muted-foreground hover:text-foreground", className)}
size={size}
type="button"
variant={variant}
{...props}
>
{children ?? <XIcon className="size-4" />}
<span className="sr-only">Close</span>
</Button>
)
export type ArtifactTitleProps = HTMLAttributes<HTMLParagraphElement>
export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => (
<p className={cn("font-medium text-foreground text-sm", className)} {...props} />
)
export type ArtifactDescriptionProps = HTMLAttributes<HTMLParagraphElement>
export const ArtifactDescription = ({ className, ...props }: ArtifactDescriptionProps) => (
<p className={cn("text-muted-foreground text-sm", className)} {...props} />
)
export type ArtifactActionsProps = HTMLAttributes<HTMLDivElement>
export const ArtifactActions = ({ className, ...props }: ArtifactActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props} />
)
export type ArtifactActionProps = ComponentProps<typeof Button> & {
tooltip?: string
label?: string
icon?: LucideIcon
}
export const ArtifactAction = ({
tooltip,
label,
icon: Icon,
children,
className,
size = "sm",
variant = "ghost",
...props
}: ArtifactActionProps) => {
const button = (
<Button
className={cn("size-8 p-0 text-muted-foreground hover:text-foreground", className)}
size={size}
type="button"
variant={variant}
{...props}
>
{Icon ? <Icon className="size-4" /> : children}
<span className="sr-only">{label || tooltip}</span>
</Button>
)
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
return button
}
export type ArtifactContentProps = HTMLAttributes<HTMLDivElement>
export const ArtifactContent = ({ className, ...props }: ArtifactContentProps) => (
<div className={cn("flex-1 overflow-auto p-4", className)} {...props} />
)
import { CodeBlock } from "@/components/ai/code-block"
/** Demo component for preview */
export default function ArtifactDemo() {
const code = `# Dijkstra's Algorithm implementation
import heapq
def dijkstra(graph, start):
distances = {node: float('inf') for node in graph}
distances[start] = 0
heap = [(0, start)]
visited = set()
while heap:
current_distance, current_node = heapq.heappop(heap)
if current_node in visited:
continue
visited.add(current_node)
for neighbor, weight in graph[current_node].items():
distance = current_distance + weight
if distance < distances[neighbor]:
distances[neighbor] = distance
heapq.heappush(heap, (distance, neighbor))
return distances`
return (
<Artifact>
<ArtifactHeader>
<div>
<ArtifactTitle>Dijkstra{"'"}s Algorithm</ArtifactTitle>
<ArtifactDescription>Updated 1 minute ago</ArtifactDescription>
</div>
<ArtifactActions>
<ArtifactAction icon={PlayIcon} tooltip="Run code" />
<ArtifactAction icon={CopyIcon} tooltip="Copy to clipboard" />
<ArtifactAction icon={RefreshCwIcon} tooltip="Regenerate" />
<ArtifactAction icon={DownloadIcon} tooltip="Download" />
<ArtifactAction icon={ShareIcon} tooltip="Share" />
</ArtifactActions>
</ArtifactHeader>
<ArtifactContent className="p-0">
<CodeBlock className="border-none" code={code} language="python" showLineNumbers />
</ArtifactContent>
</Artifact>
)
}

492
src/components/ai/attachments.tsx Executable file
View File

@ -0,0 +1,492 @@
"use client"
import {
FileTextIcon,
GlobeIcon,
ImageIcon,
Music2Icon,
PaperclipIcon,
VideoIcon,
XIcon,
} from "lucide-react"
import type { ComponentProps, HTMLAttributes, ReactNode } from "react"
import { createContext, useContext, useMemo } from "react"
import { Button } from "@/components/ui/button"
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
import { cn } from "@/lib/utils"
// ============================================================================
// Types
// ============================================================================
export interface AttachmentData {
id: string
type: "file" | "source-document"
filename?: string
title?: string
url?: string
mediaType?: string
}
export type AttachmentMediaCategory =
| "image"
| "video"
| "audio"
| "document"
| "source"
| "unknown"
export type AttachmentVariant = "grid" | "inline" | "list"
// ============================================================================
// Utility Functions
// ============================================================================
export const getMediaCategory = (data: AttachmentData): AttachmentMediaCategory => {
if (data.type === "source-document") {
return "source"
}
const mediaType = data.mediaType ?? ""
if (mediaType.startsWith("image/")) {
return "image"
}
if (mediaType.startsWith("video/")) {
return "video"
}
if (mediaType.startsWith("audio/")) {
return "audio"
}
if (mediaType.startsWith("application/") || mediaType.startsWith("text/")) {
return "document"
}
return "unknown"
}
export const getAttachmentLabel = (data: AttachmentData): string => {
if (data.type === "source-document") {
return data.title || data.filename || "Source"
}
const category = getMediaCategory(data)
return data.filename || (category === "image" ? "Image" : "Attachment")
}
// ============================================================================
// Contexts
// ============================================================================
interface AttachmentsContextValue {
variant: AttachmentVariant
}
const AttachmentsContext = createContext<AttachmentsContextValue | null>(null)
interface AttachmentContextValue {
data: AttachmentData
mediaCategory: AttachmentMediaCategory
onRemove?: () => void
variant: AttachmentVariant
}
const AttachmentContext = createContext<AttachmentContextValue | null>(null)
// ============================================================================
// Hooks
// ============================================================================
export const useAttachmentsContext = () =>
useContext(AttachmentsContext) ?? { variant: "grid" as const }
export const useAttachmentContext = () => {
const ctx = useContext(AttachmentContext)
if (!ctx) {
throw new Error("Attachment components must be used within <Attachment>")
}
return ctx
}
// ============================================================================
// Attachments - Container
// ============================================================================
export type AttachmentsProps = HTMLAttributes<HTMLDivElement> & {
variant?: AttachmentVariant
}
export const Attachments = ({
variant = "grid",
className,
children,
...props
}: AttachmentsProps) => {
const contextValue = useMemo(() => ({ variant }), [variant])
return (
<AttachmentsContext.Provider value={contextValue}>
<div
className={cn(
"flex items-start",
variant === "list" ? "flex-col gap-2" : "flex-wrap gap-2",
variant === "grid" && "ml-auto w-fit",
className,
)}
{...props}
>
{children}
</div>
</AttachmentsContext.Provider>
)
}
// ============================================================================
// Attachment - Item
// ============================================================================
export type AttachmentProps = HTMLAttributes<HTMLDivElement> & {
data: AttachmentData
onRemove?: () => void
}
export const Attachment = ({ data, onRemove, className, children, ...props }: AttachmentProps) => {
const { variant } = useAttachmentsContext()
const mediaCategory = getMediaCategory(data)
const contextValue = useMemo<AttachmentContextValue>(
() => ({ data, mediaCategory, onRemove, variant }),
[data, mediaCategory, onRemove, variant],
)
return (
<AttachmentContext.Provider value={contextValue}>
<div
className={cn(
"group relative",
variant === "grid" && "size-24 overflow-hidden rounded-lg",
variant === "inline" && [
"flex h-8 cursor-pointer select-none items-center gap-1.5",
"rounded-md border border-border px-1.5",
"font-medium text-sm transition-all",
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
],
variant === "list" && [
"flex w-full items-center gap-3 rounded-lg border p-3",
"hover:bg-accent/50",
],
className,
)}
{...props}
>
{children}
</div>
</AttachmentContext.Provider>
)
}
// ============================================================================
// AttachmentPreview - Media preview
// ============================================================================
export type AttachmentPreviewProps = HTMLAttributes<HTMLDivElement> & {
fallbackIcon?: ReactNode
}
export const AttachmentPreview = ({
fallbackIcon,
className,
...props
}: AttachmentPreviewProps) => {
const { data, mediaCategory, variant } = useAttachmentContext()
const iconSize = variant === "inline" ? "size-3" : "size-4"
const renderImage = (url: string, filename: string | undefined, isGrid: boolean) =>
isGrid ? (
<img
alt={filename || "Image"}
className="size-full object-cover"
height={96}
src={url}
width={96}
/>
) : (
<img
alt={filename || "Image"}
className="size-full rounded object-cover"
height={20}
src={url}
width={20}
/>
)
const renderIcon = (Icon: typeof ImageIcon) => (
<Icon className={cn(iconSize, "text-muted-foreground")} />
)
const renderContent = () => {
if (mediaCategory === "image" && data.type === "file" && data.url) {
return renderImage(data.url, data.filename, variant === "grid")
}
if (mediaCategory === "video" && data.type === "file" && data.url) {
return <video className="size-full object-cover" muted src={data.url} />
}
const iconMap: Record<AttachmentMediaCategory, typeof ImageIcon> = {
image: ImageIcon,
video: VideoIcon,
audio: Music2Icon,
source: GlobeIcon,
document: FileTextIcon,
unknown: PaperclipIcon,
}
const Icon = iconMap[mediaCategory]
return fallbackIcon ?? renderIcon(Icon)
}
return (
<div
className={cn(
"flex shrink-0 items-center justify-center overflow-hidden",
variant === "grid" && "size-full bg-muted",
variant === "inline" && "size-5 rounded bg-background",
variant === "list" && "size-12 rounded bg-muted",
className,
)}
{...props}
>
{renderContent()}
</div>
)
}
// ============================================================================
// AttachmentInfo - Name and type display
// ============================================================================
export type AttachmentInfoProps = HTMLAttributes<HTMLDivElement> & {
showMediaType?: boolean
}
export const AttachmentInfo = ({
showMediaType = false,
className,
...props
}: AttachmentInfoProps) => {
const { data, variant } = useAttachmentContext()
const label = getAttachmentLabel(data)
if (variant === "grid") {
return null
}
return (
<div className={cn("min-w-0 flex-1", className)} {...props}>
<span className="block truncate">{label}</span>
{showMediaType && data.mediaType && (
<span className="block truncate text-muted-foreground text-xs">{data.mediaType}</span>
)}
</div>
)
}
// ============================================================================
// AttachmentRemove - Remove button
// ============================================================================
export type AttachmentRemoveProps = ComponentProps<typeof Button> & {
label?: string
}
export const AttachmentRemove = ({
label = "Remove",
className,
children,
...props
}: AttachmentRemoveProps) => {
const { onRemove, variant } = useAttachmentContext()
if (!onRemove) {
return null
}
return (
<Button
aria-label={label}
className={cn(
variant === "grid" && [
"absolute top-2 right-2 size-6 rounded-full p-0",
"bg-background/80 backdrop-blur-sm",
"opacity-0 transition-opacity group-hover:opacity-100",
"hover:bg-background",
"[&>svg]:size-3",
],
variant === "inline" && [
"size-5 rounded p-0",
"opacity-0 transition-opacity group-hover:opacity-100",
"[&>svg]:size-2.5",
],
variant === "list" && ["size-8 shrink-0 rounded p-0", "[&>svg]:size-4"],
className,
)}
onClick={e => {
e.stopPropagation()
onRemove()
}}
type="button"
variant="ghost"
{...props}
>
{children ?? <XIcon />}
<span className="sr-only">{label}</span>
</Button>
)
}
// ============================================================================
// AttachmentHoverCard - Hover preview
// ============================================================================
export type AttachmentHoverCardProps = ComponentProps<typeof HoverCard>
export const AttachmentHoverCard = ({
openDelay = 0,
closeDelay = 0,
...props
}: AttachmentHoverCardProps) => (
<HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
)
export type AttachmentHoverCardTriggerProps = ComponentProps<typeof HoverCardTrigger>
export const AttachmentHoverCardTrigger = (props: AttachmentHoverCardTriggerProps) => (
<HoverCardTrigger {...props} />
)
export type AttachmentHoverCardContentProps = ComponentProps<typeof HoverCardContent>
export const AttachmentHoverCardContent = ({
align = "start",
className,
...props
}: AttachmentHoverCardContentProps) => (
<HoverCardContent align={align} className={cn("w-auto p-2", className)} {...props} />
)
// ============================================================================
// AttachmentEmpty - Empty state
// ============================================================================
export type AttachmentEmptyProps = HTMLAttributes<HTMLDivElement>
export const AttachmentEmpty = ({ className, children, ...props }: AttachmentEmptyProps) => (
<div
className={cn("flex items-center justify-center p-4 text-muted-foreground text-sm", className)}
{...props}
>
{children ?? "No attachments"}
</div>
)
/** Demo component for preview */
export default function AttachmentsDemo() {
const imageAttachments: AttachmentData[] = [
{
id: "1",
type: "file",
filename: "photo-1.jpg",
mediaType: "image/jpeg",
url: "https://picsum.photos/seed/attach1/200/200",
},
{
id: "2",
type: "file",
filename: "photo-2.jpg",
mediaType: "image/jpeg",
url: "https://picsum.photos/seed/attach2/200/200",
},
{
id: "3",
type: "file",
filename: "landscape.png",
mediaType: "image/png",
url: "https://picsum.photos/seed/attach3/200/200",
},
]
const mixedAttachments: AttachmentData[] = [
{ id: "4", type: "file", filename: "report.pdf", mediaType: "application/pdf" },
{ id: "5", type: "file", filename: "podcast.mp3", mediaType: "audio/mpeg" },
{ id: "6", type: "file", filename: "demo.mp4", mediaType: "video/mp4" },
{ id: "7", type: "source-document", title: "API Documentation" },
]
return (
<div className="flex w-full max-w-2xl flex-col gap-8 p-6">
{/* Grid Variant - Image Gallery */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-medium text-sm">Grid Variant</h3>
<span className="text-muted-foreground text-xs">Image thumbnails with remove button</span>
</div>
<Attachments variant="grid" className="ml-0 justify-start">
{imageAttachments.map(attachment => (
<Attachment
key={attachment.id}
data={attachment}
onRemove={() => console.log("Remove", attachment.id)}
>
<AttachmentPreview />
<AttachmentRemove />
</Attachment>
))}
</Attachments>
</div>
{/* Inline Variant - Compact Tags */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-medium text-sm">Inline Variant</h3>
<span className="text-muted-foreground text-xs">Compact chips for mixed files</span>
</div>
<Attachments variant="inline" className="justify-start">
{[...imageAttachments.slice(0, 1), ...mixedAttachments].map(attachment => (
<Attachment
key={attachment.id}
data={attachment}
onRemove={() => console.log("Remove", attachment.id)}
>
<AttachmentPreview />
<AttachmentInfo />
<AttachmentRemove />
</Attachment>
))}
</Attachments>
</div>
{/* List Variant - Detailed View */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-medium text-sm">List Variant</h3>
<span className="text-muted-foreground text-xs">Full details with media type</span>
</div>
<Attachments variant="list">
{[imageAttachments[0], ...mixedAttachments.slice(0, 2)].map(attachment => (
<Attachment
key={attachment.id}
data={attachment}
onRemove={() => console.log("Remove", attachment.id)}
>
<AttachmentPreview />
<AttachmentInfo showMediaType />
<AttachmentRemove />
</Attachment>
))}
</Attachments>
</div>
</div>
)
}

View File

@ -0,0 +1,205 @@
"use client"
import {
MediaControlBar,
MediaController,
MediaDurationDisplay,
MediaMuteButton,
MediaPlayButton,
MediaSeekBackwardButton,
MediaSeekForwardButton,
MediaTimeDisplay,
MediaTimeRange,
MediaVolumeRange,
} from "media-chrome/react"
import type { ComponentProps, CSSProperties } from "react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
export type AudioPlayerProps = Omit<ComponentProps<typeof MediaController>, "audio">
export const AudioPlayer = ({ className, children, style, ...props }: AudioPlayerProps) => (
<MediaController
audio
data-slot="audio-player"
style={
{
"--media-button-icon-width": "1rem",
"--media-button-icon-height": "1rem",
"--media-icon-color": "currentColor",
"--media-font": "var(--font-sans)",
"--media-font-size": "10px",
"--media-control-background": "transparent",
"--media-control-hover-background": "var(--color-accent)",
"--media-control-padding": "0",
"--media-background-color": "transparent",
"--media-primary-color": "var(--color-primary)",
"--media-secondary-color": "var(--color-secondary)",
"--media-text-color": "var(--color-foreground)",
"--media-tooltip-background": "var(--color-background)",
"--media-range-bar-color": "var(--color-primary)",
"--media-tooltip-arrow-display": "none",
"--media-tooltip-border-radius": "var(--radius-md)",
"--media-preview-time-text-shadow": "none",
"--media-preview-time-background": "var(--color-background)",
"--media-preview-time-border-radius": "var(--radius-md)",
"--media-range-track-background": "var(--color-secondary)",
...style,
} as CSSProperties
}
{...props}
>
{children}
</MediaController>
)
interface SpeechAudioData {
base64: string
mediaType: string
}
export type AudioPlayerElementProps = Omit<ComponentProps<"audio">, "src"> &
(
| {
data: SpeechAudioData
}
| {
src: string
}
)
export const AudioPlayerElement = ({ ...props }: AudioPlayerElementProps) => (
<audio
data-slot="audio-player-element"
slot="media"
src={"src" in props ? props.src : `data:${props.data.mediaType};base64,${props.data.base64}`}
suppressHydrationWarning
{...props}
/>
)
export type AudioPlayerControlBarProps = ComponentProps<typeof MediaControlBar>
export const AudioPlayerControlBar = ({
children,
className,
...props
}: AudioPlayerControlBarProps) => (
<MediaControlBar
data-slot="audio-player-control-bar"
className={cn("flex items-center gap-1", className)}
{...props}
>
{children}
</MediaControlBar>
)
export type AudioPlayerPlayButtonProps = ComponentProps<typeof MediaPlayButton>
export const AudioPlayerPlayButton = ({ className, ...props }: AudioPlayerPlayButtonProps) => (
<Button asChild size="icon" variant="outline" className={cn("size-8", className)}>
<MediaPlayButton data-slot="audio-player-play-button" {...props} />
</Button>
)
export type AudioPlayerSeekBackwardButtonProps = ComponentProps<typeof MediaSeekBackwardButton>
export const AudioPlayerSeekBackwardButton = ({
seekOffset = 10,
className,
...props
}: AudioPlayerSeekBackwardButtonProps) => (
<Button asChild size="icon" variant="outline" className={cn("size-8", className)}>
<MediaSeekBackwardButton
data-slot="audio-player-seek-backward-button"
seekOffset={seekOffset}
{...props}
/>
</Button>
)
export type AudioPlayerSeekForwardButtonProps = ComponentProps<typeof MediaSeekForwardButton>
export const AudioPlayerSeekForwardButton = ({
seekOffset = 10,
className,
...props
}: AudioPlayerSeekForwardButtonProps) => (
<Button asChild size="icon" variant="outline" className={cn("size-8", className)}>
<MediaSeekForwardButton
data-slot="audio-player-seek-forward-button"
seekOffset={seekOffset}
{...props}
/>
</Button>
)
export type AudioPlayerTimeDisplayProps = ComponentProps<typeof MediaTimeDisplay>
export const AudioPlayerTimeDisplay = ({ className, ...props }: AudioPlayerTimeDisplayProps) => (
<span className={cn("px-2 text-muted-foreground text-xs tabular-nums", className)}>
<MediaTimeDisplay data-slot="audio-player-time-display" {...props} />
</span>
)
export type AudioPlayerTimeRangeProps = ComponentProps<typeof MediaTimeRange>
export const AudioPlayerTimeRange = ({ className, ...props }: AudioPlayerTimeRangeProps) => (
<MediaTimeRange
className={cn("flex-1", className)}
data-slot="audio-player-time-range"
{...props}
/>
)
export type AudioPlayerDurationDisplayProps = ComponentProps<typeof MediaDurationDisplay>
export const AudioPlayerDurationDisplay = ({
className,
...props
}: AudioPlayerDurationDisplayProps) => (
<span className={cn("px-2 text-muted-foreground text-xs tabular-nums", className)}>
<MediaDurationDisplay data-slot="audio-player-duration-display" {...props} />
</span>
)
export type AudioPlayerMuteButtonProps = ComponentProps<typeof MediaMuteButton>
export const AudioPlayerMuteButton = ({ className, ...props }: AudioPlayerMuteButtonProps) => (
<Button asChild size="icon" variant="ghost" className={cn("size-8", className)}>
<MediaMuteButton data-slot="audio-player-mute-button" {...props} />
</Button>
)
export type AudioPlayerVolumeRangeProps = ComponentProps<typeof MediaVolumeRange>
export const AudioPlayerVolumeRange = ({ className, ...props }: AudioPlayerVolumeRangeProps) => (
<MediaVolumeRange
className={cn("w-20", className)}
data-slot="audio-player-volume-range"
{...props}
/>
)
/** Demo component for preview */
export default function AudioPlayerDemo() {
return (
<div className="w-full max-w-md p-4">
<div className="rounded-lg border bg-background p-4">
<AudioPlayer>
<AudioPlayerElement src="https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3" />
<AudioPlayerControlBar>
<AudioPlayerPlayButton />
<AudioPlayerSeekBackwardButton />
<AudioPlayerSeekForwardButton />
<AudioPlayerTimeDisplay />
<AudioPlayerTimeRange />
<AudioPlayerDurationDisplay />
<AudioPlayerMuteButton />
<AudioPlayerVolumeRange />
</AudioPlayerControlBar>
</AudioPlayer>
</div>
</div>
)
}

View File

@ -0,0 +1,120 @@
"use client"
import { useEffect, useRef } from "react"
import { cn } from "@/lib/utils"
const BAR_W = 2
const GAP = 1
const SLOT = BAR_W + GAP
const MAX_H = 32
const MIN_H = 2
const SAMPLE_MS = 40
interface AudioWaveformProps {
readonly stream: MediaStream
readonly className?: string
}
export function AudioWaveform({
stream,
className,
}: AudioWaveformProps) {
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const container = containerRef.current
if (!container) return
const audioCtx = new AudioContext()
const source =
audioCtx.createMediaStreamSource(stream)
const analyser = audioCtx.createAnalyser()
analyser.fftSize = 512
source.connect(analyser)
const timeDomain = new Uint8Array(analyser.fftSize)
// calculate how many bars fit in the container
const barCount = Math.floor(
container.clientWidth / SLOT
)
if (barCount <= 0) return
// amplitude history
const amplitudes = new Float32Array(barCount)
// pre-create all bars as subtle dots
const barEls: HTMLDivElement[] = []
const frag = document.createDocumentFragment()
for (let i = 0; i < barCount; i++) {
const bar = document.createElement("div")
bar.style.cssText =
`width:${BAR_W}px;` +
`height:${MIN_H}px;` +
"border-radius:9999px;" +
"background:currentColor;" +
"flex-shrink:0;" +
"opacity:0.15;"
frag.appendChild(bar)
barEls.push(bar)
}
container.appendChild(frag)
let cursor = 0
const interval = setInterval(() => {
analyser.getByteTimeDomainData(timeDomain)
// RMS amplitude
let sum = 0
for (let i = 0; i < timeDomain.length; i++) {
const v = (timeDomain[i] - 128) / 128
sum += v * v
}
const rms = Math.sqrt(sum / timeDomain.length)
// non-linear boost so speech is clearly visible
const shaped = Math.pow(Math.min(1, rms * 5), 0.6)
if (cursor < barCount) {
// filling phase: write one bar at a time
amplitudes[cursor] = shaped
const h = Math.max(MIN_H, shaped * MAX_H)
barEls[cursor].style.height = `${h}px`
barEls[cursor].style.opacity = "1"
cursor++
} else {
// scrolling phase: shift left, append new
for (let i = 0; i < barCount - 1; i++) {
amplitudes[i] = amplitudes[i + 1]
}
amplitudes[barCount - 1] = shaped
for (let i = 0; i < barCount; i++) {
const h = Math.max(
MIN_H,
amplitudes[i] * MAX_H
)
barEls[i].style.height = `${h}px`
}
}
}, SAMPLE_MS)
return () => {
clearInterval(interval)
audioCtx.close()
container.textContent = ""
}
}, [stream])
return (
<div
ref={containerRef}
className={cn(
"flex items-center overflow-hidden",
className
)}
style={{ gap: `${GAP}px` }}
/>
)
}

230
src/components/ai/branch.tsx Executable file
View File

@ -0,0 +1,230 @@
"use client"
import type { UIMessage } from "ai"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
import type { ComponentProps, HTMLAttributes, ReactElement } from "react"
import { createContext, useContext, useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
interface BranchContextType {
currentBranch: number
totalBranches: number
goToPrevious: () => void
goToNext: () => void
branches: ReactElement[]
setBranches: (branches: ReactElement[]) => void
}
const BranchContext = createContext<BranchContextType | null>(null)
const useBranch = () => {
const context = useContext(BranchContext)
if (!context) {
throw new Error("Branch components must be used within Branch")
}
return context
}
export type BranchProps = HTMLAttributes<HTMLDivElement> & {
defaultBranch?: number
onBranchChange?: (branchIndex: number) => void
}
export const Branch = ({ defaultBranch = 0, onBranchChange, className, ...props }: BranchProps) => {
const [currentBranch, setCurrentBranch] = useState(defaultBranch)
const [branches, setBranches] = useState<ReactElement[]>([])
const handleBranchChange = (newBranch: number) => {
setCurrentBranch(newBranch)
onBranchChange?.(newBranch)
}
const goToPrevious = () => {
const newBranch = currentBranch > 0 ? currentBranch - 1 : branches.length - 1
handleBranchChange(newBranch)
}
const goToNext = () => {
const newBranch = currentBranch < branches.length - 1 ? currentBranch + 1 : 0
handleBranchChange(newBranch)
}
const contextValue: BranchContextType = {
currentBranch,
totalBranches: branches.length,
goToPrevious,
goToNext,
branches,
setBranches,
}
return (
<BranchContext.Provider value={contextValue}>
<div className={cn("grid w-full gap-2 [&>div]:pb-0", className)} {...props} />
</BranchContext.Provider>
)
}
export type BranchMessagesProps = HTMLAttributes<HTMLDivElement>
export const BranchMessages = ({ children, ...props }: BranchMessagesProps) => {
const { currentBranch, setBranches, branches } = useBranch()
const childrenArray = Array.isArray(children) ? children : [children]
// Use useEffect to update branches when they change
useEffect(() => {
if (branches.length !== childrenArray.length) {
setBranches(childrenArray as ReactElement[])
}
}, [childrenArray, branches, setBranches])
return childrenArray.map((branch, index) => (
<div
className={cn(
"grid gap-2 overflow-hidden [&>div]:pb-0",
index === currentBranch ? "block" : "hidden",
)}
key={(branch as ReactElement).key ?? index}
{...props}
>
{branch}
</div>
))
}
export type BranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"]
}
export const BranchSelector = ({ className, from, ...props }: BranchSelectorProps) => {
const { totalBranches } = useBranch()
// Don't render if there's only one branch
if (totalBranches <= 1) {
return null
}
return (
<div
className={cn(
"flex items-center gap-2 self-end px-10",
from === "assistant" ? "justify-start" : "justify-end",
className,
)}
{...props}
/>
)
}
export type BranchPreviousProps = ComponentProps<typeof Button>
export const BranchPrevious = ({ className, children, ...props }: BranchPreviousProps) => {
const { goToPrevious, totalBranches } = useBranch()
return (
<Button
aria-label="Previous branch"
className={cn(
"size-7 shrink-0 rounded-full text-muted-foreground transition-colors",
"hover:bg-accent hover:text-foreground",
"disabled:pointer-events-none disabled:opacity-50",
className,
)}
disabled={totalBranches <= 1}
onClick={goToPrevious}
size="icon"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronLeftIcon size={14} />}
</Button>
)
}
export type BranchNextProps = ComponentProps<typeof Button>
export const BranchNext = ({ className, children, ...props }: BranchNextProps) => {
const { goToNext, totalBranches } = useBranch()
return (
<Button
aria-label="Next branch"
className={cn(
"size-7 shrink-0 rounded-full text-muted-foreground transition-colors",
"hover:bg-accent hover:text-foreground",
"disabled:pointer-events-none disabled:opacity-50",
className,
)}
disabled={totalBranches <= 1}
onClick={goToNext}
size="icon"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronRightIcon size={14} />}
</Button>
)
}
export type BranchPageProps = HTMLAttributes<HTMLSpanElement>
export const BranchPage = ({ className, ...props }: BranchPageProps) => {
const { currentBranch, totalBranches } = useBranch()
return (
<span
className={cn("font-medium text-muted-foreground text-xs tabular-nums", className)}
{...props}
>
{currentBranch + 1} of {totalBranches}
</span>
)
}
import { Message, MessageContent } from "@/components/ai/message"
/** Demo component for preview */
export default function BranchDemo() {
return (
<div className="flex w-full flex-col gap-4">
<Message from="user">
<MessageContent>What{"'"}s the best way to learn React?</MessageContent>
</Message>
<Branch onBranchChange={index => console.log("Branch changed to:", index)}>
<BranchMessages>
<Message from="assistant" key="v1">
<MessageContent>
Start with the official React documentation at react.dev. It has an excellent
interactive tutorial that teaches you the fundamentals step by step.
</MessageContent>
</Message>
<Message from="assistant" key="v2">
<MessageContent>
I recommend a project-based approach: pick a simple app idea and build it while
learning. Start with Create React App or Vite, then gradually add features.
</MessageContent>
</Message>
<Message from="assistant" key="v3">
<MessageContent>
Take a structured course on platforms like Frontend Masters or Scrimba. They offer
hands-on React courses with exercises.
</MessageContent>
</Message>
</BranchMessages>
<BranchSelector from="assistant">
<BranchPrevious />
<BranchPage />
<BranchNext />
</BranchSelector>
</Branch>
</div>
)
}

108
src/components/ai/canvas.tsx Executable file
View File

@ -0,0 +1,108 @@
"use client"
import { Background, ReactFlow, type ReactFlowProps } from "@xyflow/react"
import type { ReactNode } from "react"
import "@xyflow/react/dist/style.css"
type CanvasProps = ReactFlowProps & {
children?: ReactNode
}
export const Canvas = ({ children, ...props }: CanvasProps) => (
<ReactFlow
deleteKeyCode={["Backspace", "Delete"]}
fitView
panOnDrag={false}
panOnScroll
selectionOnDrag={true}
zoomOnDoubleClick={false}
{...props}
>
<Background bgColor="var(--sidebar)" />
{children}
</ReactFlow>
)
import {
addEdge,
type Connection,
type Edge,
type Node as ReactFlowNode,
ReactFlowProvider,
useEdgesState,
useNodesState,
} from "@xyflow/react"
import { PlusIcon, ZapIcon } from "lucide-react"
import { useCallback, useMemo } from "react"
import { Button } from "@/components/ui/button"
import { Controls } from "@/components/ai/controls"
import { Node, NodeHeader, NodeTitle } from "@/components/ai/node"
import { Panel } from "@/components/ai/panel"
const AgentNode = ({ data }: { data: { label: string } }) => (
<Node handles={{ target: true, source: true }} className="w-[140px]">
<NodeHeader className="p-2">
<NodeTitle className="flex items-center gap-1.5 text-xs">
<ZapIcon className="size-3" />
{data.label}
</NodeTitle>
</NodeHeader>
</Node>
)
const initialNodes: ReactFlowNode[] = [
{ id: "1", type: "agent", position: { x: 50, y: 100 }, data: { label: "Input" } },
{ id: "2", type: "agent", position: { x: 300, y: 100 }, data: { label: "Process" } },
{ id: "3", type: "agent", position: { x: 550, y: 100 }, data: { label: "Output" } },
]
const initialEdges: Edge[] = [
{ id: "e1-2", source: "1", target: "2" },
{ id: "e2-3", source: "2", target: "3" },
]
/** Demo component for preview */
export default function CanvasDemo() {
const nodeTypes = useMemo(() => ({ agent: AgentNode }), [])
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes)
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges)
const onConnect = useCallback(
(params: Connection) => setEdges(eds => addEdge(params, eds)),
[setEdges],
)
const addNode = useCallback(() => {
const id = `${Date.now()}`
const newNode: ReactFlowNode = {
id,
type: "agent",
position: { x: Math.random() * 400 + 100, y: Math.random() * 200 + 50 },
data: { label: `Node ${nodes.length + 1}` },
}
setNodes(nds => [...nds, newNode])
}, [nodes.length, setNodes])
return (
<div className="h-full w-full min-h-screen">
<ReactFlowProvider>
<Canvas
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
nodeTypes={nodeTypes}
>
<Controls />
<Panel position="top-right">
<Button size="sm" variant="ghost" onClick={addNode}>
<PlusIcon className="size-4" />
Add Node
</Button>
</Panel>
</Canvas>
</ReactFlowProvider>
</div>
)
}

View File

@ -0,0 +1,277 @@
"use client"
import { useControllableState } from "@radix-ui/react-use-controllable-state"
import {
BrainIcon,
ChevronDownIcon,
DotIcon,
ImageIcon,
type LucideIcon,
SearchIcon,
} from "lucide-react"
import type { ComponentProps, ReactNode } from "react"
import { createContext, memo, useContext, useMemo } from "react"
import { Badge } from "@/components/ui/badge"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { cn } from "@/lib/utils"
interface ChainOfThoughtContextValue {
isOpen: boolean
setIsOpen: (open: boolean) => void
}
const ChainOfThoughtContext = createContext<ChainOfThoughtContextValue | null>(null)
const useChainOfThought = () => {
const context = useContext(ChainOfThoughtContext)
if (!context) {
throw new Error("ChainOfThought components must be used within ChainOfThought")
}
return context
}
export type ChainOfThoughtProps = ComponentProps<"div"> & {
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
}
export const ChainOfThought = memo(
({
className,
open,
defaultOpen = false,
onOpenChange,
children,
...props
}: ChainOfThoughtProps) => {
const [isOpen, setIsOpen] = useControllableState({
prop: open,
defaultProp: defaultOpen,
onChange: onOpenChange,
})
const chainOfThoughtContext = useMemo(() => ({ isOpen, setIsOpen }), [isOpen, setIsOpen])
return (
<ChainOfThoughtContext.Provider value={chainOfThoughtContext}>
<div className={cn("not-prose max-w-prose space-y-4", className)} {...props}>
{children}
</div>
</ChainOfThoughtContext.Provider>
)
},
)
export type ChainOfThoughtHeaderProps = ComponentProps<typeof CollapsibleTrigger>
export const ChainOfThoughtHeader = memo(
({ className, children, ...props }: ChainOfThoughtHeaderProps) => {
const { isOpen, setIsOpen } = useChainOfThought()
return (
<Collapsible onOpenChange={setIsOpen} open={isOpen}>
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
className,
)}
{...props}
>
<BrainIcon className="size-4" />
<span className="flex-1 text-left">{children ?? "Chain of Thought"}</span>
<ChevronDownIcon
className={cn("size-4 transition-transform", isOpen ? "rotate-180" : "rotate-0")}
/>
</CollapsibleTrigger>
</Collapsible>
)
},
)
export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
icon?: LucideIcon
label: ReactNode
description?: ReactNode
status?: "complete" | "active" | "pending"
}
export const ChainOfThoughtStep = memo(
({
className,
icon: Icon = DotIcon,
label,
description,
status = "complete",
children,
...props
}: ChainOfThoughtStepProps) => {
const statusStyles = {
complete: "text-muted-foreground",
active: "text-foreground",
pending: "text-muted-foreground/50",
}
return (
<div
className={cn(
"flex gap-2 text-sm",
statusStyles[status],
"fade-in-0 slide-in-from-top-2 animate-in",
className,
)}
{...props}
>
<div className="relative mt-0.5">
<Icon className="size-4" />
<div className="-mx-px absolute top-7 bottom-0 left-1/2 w-px bg-border" />
</div>
<div className="flex-1 space-y-2 overflow-hidden">
<div>{label}</div>
{description && <div className="text-muted-foreground text-xs">{description}</div>}
{children}
</div>
</div>
)
},
)
export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">
export const ChainOfThoughtSearchResults = memo(
({ className, ...props }: ChainOfThoughtSearchResultsProps) => (
<div className={cn("flex flex-wrap items-center gap-2", className)} {...props} />
),
)
export type ChainOfThoughtSearchResultProps = ComponentProps<typeof Badge>
export const ChainOfThoughtSearchResult = memo(
({ className, children, ...props }: ChainOfThoughtSearchResultProps) => (
<Badge
className={cn("gap-1 px-2 py-0.5 font-normal text-xs", className)}
variant="secondary"
{...props}
>
{children}
</Badge>
),
)
export type ChainOfThoughtContentProps = ComponentProps<typeof CollapsibleContent>
export const ChainOfThoughtContent = memo(
({ className, children, ...props }: ChainOfThoughtContentProps) => {
const { isOpen } = useChainOfThought()
return (
<Collapsible open={isOpen}>
<CollapsibleContent
className={cn(
"mt-2 space-y-3",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className,
)}
{...props}
>
{children}
</CollapsibleContent>
</Collapsible>
)
},
)
export type ChainOfThoughtImageProps = ComponentProps<"div"> & {
caption?: string
}
export const ChainOfThoughtImage = memo(
({ className, children, caption, ...props }: ChainOfThoughtImageProps) => (
<div className={cn("mt-2 space-y-2", className)} {...props}>
<div className="relative flex max-h-[22rem] items-center justify-center overflow-hidden rounded-lg bg-muted p-3">
{children}
</div>
{caption && <p className="text-muted-foreground text-xs">{caption}</p>}
</div>
),
)
ChainOfThought.displayName = "ChainOfThought"
ChainOfThoughtHeader.displayName = "ChainOfThoughtHeader"
ChainOfThoughtStep.displayName = "ChainOfThoughtStep"
ChainOfThoughtSearchResults.displayName = "ChainOfThoughtSearchResults"
ChainOfThoughtSearchResult.displayName = "ChainOfThoughtSearchResult"
ChainOfThoughtContent.displayName = "ChainOfThoughtContent"
ChainOfThoughtImage.displayName = "ChainOfThoughtImage"
import { Image } from "@/components/ai/image"
const exampleImage = {
base64:
"iVBORw0KGgoAAAANSUhEUgAAASwAAADICAYAAABS39xVAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAABLkSURBVHgB7d1rUxvXGQfw5+xqJXQBCRACgSE4tsEXsJvGaduknU7TTt9m0neZaT/AdNq+yxfoh+hMp9O0SdO0TemkjePYsbExNuYiQCAJSavdPX3OauViISSwJFbw/DEwWqF9zln2t+ecPXtWABEREREREREREREREREREREREREREdFl",
mediaType: "image/png" as const,
uint8Array: new Uint8Array([]),
}
/** Demo component for preview */
export default function ChainOfThoughtDemo() {
return (
<ChainOfThought defaultOpen>
<ChainOfThoughtHeader />
<ChainOfThoughtContent>
<ChainOfThoughtStep
icon={SearchIcon}
label="Searching for chocolate chip cookie recipes"
status="complete"
>
<ChainOfThoughtSearchResults>
{[
"https://www.allrecipes.com",
"https://www.foodnetwork.com",
"https://www.seriouseats.com",
].map(website => (
<ChainOfThoughtSearchResult key={website}>
{new URL(website).hostname}
</ChainOfThoughtSearchResult>
))}
</ChainOfThoughtSearchResults>
</ChainOfThoughtStep>
<ChainOfThoughtStep
icon={ImageIcon}
label="Found a highly-rated recipe with 4.8 stars"
status="complete"
>
<ChainOfThoughtImage caption="Classic chocolate chip cookies fresh from the oven.">
<Image
alt="Chocolate chip cookies"
base64={exampleImage.base64}
className="aspect-square h-[150px] border"
mediaType={exampleImage.mediaType}
uint8Array={exampleImage.uint8Array}
/>
</ChainOfThoughtImage>
</ChainOfThoughtStep>
<ChainOfThoughtStep
label="This recipe uses brown butter for extra flavor and requires chilling the dough."
status="complete"
/>
<ChainOfThoughtStep
icon={SearchIcon}
label="Looking for ingredient substitutions..."
status="active"
>
<ChainOfThoughtSearchResults>
{["https://www.kingarthurbaking.com", "https://www.thekitchn.com"].map(website => (
<ChainOfThoughtSearchResult key={website}>
{new URL(website).hostname}
</ChainOfThoughtSearchResult>
))}
</ChainOfThoughtSearchResults>
</ChainOfThoughtStep>
</ChainOfThoughtContent>
</ChainOfThought>
)
}

View File

@ -0,0 +1,76 @@
"use client"
import { BookmarkIcon, type LucideProps } from "lucide-react"
import type { ComponentProps, HTMLAttributes } from "react"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
export type CheckpointProps = HTMLAttributes<HTMLDivElement>
export const Checkpoint = ({ className, children, ...props }: CheckpointProps) => (
<div
className={cn("flex items-center gap-0.5 text-muted-foreground overflow-hidden", className)}
{...props}
>
{children}
<Separator />
</div>
)
export type CheckpointIconProps = LucideProps
export const CheckpointIcon = ({ className, children, ...props }: CheckpointIconProps) =>
children ?? <BookmarkIcon className={cn("size-4 shrink-0", className)} {...props} />
export type CheckpointTriggerProps = ComponentProps<typeof Button> & {
tooltip?: string
}
export const CheckpointTrigger = ({
children,
className,
variant = "ghost",
size = "sm",
tooltip,
...props
}: CheckpointTriggerProps) =>
tooltip ? (
<Tooltip>
<TooltipTrigger asChild>
<Button size={size} type="button" variant={variant} {...props}>
{children}
</Button>
</TooltipTrigger>
<TooltipContent align="start" side="bottom">
{tooltip}
</TooltipContent>
</Tooltip>
) : (
<Button size={size} type="button" variant={variant} {...props}>
{children}
</Button>
)
/** Demo component for preview */
export default function CheckpointDemo() {
return (
<div className="flex flex-col gap-4 p-6">
<div className="text-sm text-muted-foreground">Message 1: What is React?</div>
<div className="text-sm text-muted-foreground">
Message 2: React is a JavaScript library...
</div>
<Checkpoint>
<CheckpointIcon />
<CheckpointTrigger
onClick={() => console.log("Restore checkpoint")}
tooltip="Restores workspace and chat to this point"
>
Restore checkpoint
</CheckpointTrigger>
</Checkpoint>
<div className="text-sm text-muted-foreground">Message 3: How does state work?</div>
</div>
)
}

195
src/components/ai/code-block.tsx Executable file
View File

@ -0,0 +1,195 @@
"use client"
import { CheckIcon, CopyIcon } from "lucide-react"
import {
type ComponentProps,
createContext,
type HTMLAttributes,
useContext,
useEffect,
useRef,
useState,
} from "react"
import { type BundledLanguage, codeToHtml, type ShikiTransformer } from "shiki"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string
language: BundledLanguage
showLineNumbers?: boolean
}
interface CodeBlockContextType {
code: string
}
const CodeBlockContext = createContext<CodeBlockContextType>({
code: "",
})
const lineNumberTransformer: ShikiTransformer = {
name: "line-numbers",
line(node, line) {
node.children.unshift({
type: "element",
tagName: "span",
properties: {
className: [
"inline-block",
"min-w-10",
"mr-4",
"text-right",
"select-none",
"text-muted-foreground",
],
},
children: [{ type: "text", value: String(line) }],
})
},
}
export async function highlightCode(
code: string,
language: BundledLanguage,
showLineNumbers = false,
) {
const transformers: ShikiTransformer[] = showLineNumbers ? [lineNumberTransformer] : []
return await Promise.all([
codeToHtml(code, {
lang: language,
theme: "one-light",
transformers,
}),
codeToHtml(code, {
lang: language,
theme: "one-dark-pro",
transformers,
}),
])
}
export const CodeBlock = ({
code,
language,
showLineNumbers = false,
className,
children,
...props
}: CodeBlockProps) => {
const [html, setHtml] = useState<string>("")
const [darkHtml, setDarkHtml] = useState<string>("")
const mounted = useRef(false)
useEffect(() => {
highlightCode(code, language, showLineNumbers).then(([light, dark]) => {
if (!mounted.current) {
setHtml(light)
setDarkHtml(dark)
mounted.current = true
}
})
return () => {
mounted.current = false
}
}, [code, language, showLineNumbers])
return (
<CodeBlockContext.Provider value={{ code }}>
<div
className={cn(
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
className,
)}
{...props}
>
<div className="relative">
<div
className="overflow-auto dark:hidden [&>pre]:m-0 [&>pre]:bg-background! [&>pre]:p-4 [&>pre]:text-foreground! [&>pre]:text-sm [&_code]:font-mono [&_code]:text-sm"
dangerouslySetInnerHTML={{ __html: html }}
/>
<div
className="hidden overflow-auto dark:block [&>pre]:m-0 [&>pre]:bg-background! [&>pre]:p-4 [&>pre]:text-foreground! [&>pre]:text-sm [&_code]:font-mono [&_code]:text-sm"
dangerouslySetInnerHTML={{ __html: darkHtml }}
/>
{children && (
<div className="absolute top-2 right-2 flex items-center gap-2">{children}</div>
)}
</div>
</div>
</CodeBlockContext.Provider>
)
}
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void
onError?: (error: Error) => void
timeout?: number
}
export const CodeBlockCopyButton = ({
onCopy,
onError,
timeout = 2000,
children,
className,
...props
}: CodeBlockCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false)
const { code } = useContext(CodeBlockContext)
const copyToClipboard = async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"))
return
}
try {
await navigator.clipboard.writeText(code)
setIsCopied(true)
onCopy?.()
setTimeout(() => setIsCopied(false), timeout)
} catch (error) {
onError?.(error as Error)
}
}
const Icon = isCopied ? CheckIcon : CopyIcon
return (
<Button
className={cn("shrink-0", className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={14} />}
</Button>
)
}
/** Demo component for preview */
export default function CodeBlockDemo() {
const code = `function MyComponent(props) {
return (
<div>
<h1>Hello, {props.name}!</h1>
<p>This is an example React component.</p>
</div>
);
}`
return (
<div className="w-full max-w-2xl p-6">
<CodeBlock code={code} language="jsx">
<CodeBlockCopyButton
onCopy={() => console.log("Copied code to clipboard")}
onError={() => console.error("Failed to copy code to clipboard")}
/>
</CodeBlock>
</div>
)
}

376
src/components/ai/commit.tsx Executable file
View File

@ -0,0 +1,376 @@
"use client"
import { CheckIcon, CopyIcon, FileIcon, GitCommitIcon, MinusIcon, PlusIcon } from "lucide-react"
import { type ComponentProps, type HTMLAttributes, useEffect, useRef, useState } from "react"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { cn } from "@/lib/utils"
export type CommitProps = ComponentProps<typeof Collapsible>
export const Commit = ({ className, children, ...props }: CommitProps) => (
<Collapsible className={cn("rounded-lg border bg-background", className)} {...props}>
{children}
</Collapsible>
)
export type CommitHeaderProps = ComponentProps<typeof CollapsibleTrigger>
export const CommitHeader = ({ className, children, ...props }: CommitHeaderProps) => (
<CollapsibleTrigger asChild {...props}>
<div
className={cn(
"group flex cursor-pointer items-center justify-between gap-4 p-3 text-left transition-colors hover:opacity-80",
className,
)}
>
{children}
</div>
</CollapsibleTrigger>
)
export type CommitHashProps = HTMLAttributes<HTMLSpanElement>
export const CommitHash = ({ className, children, ...props }: CommitHashProps) => (
<span className={cn("font-mono text-xs", className)} {...props}>
<GitCommitIcon className="mr-1 inline-block size-3" />
{children}
</span>
)
export type CommitMessageProps = HTMLAttributes<HTMLSpanElement>
export const CommitMessage = ({ className, children, ...props }: CommitMessageProps) => (
<span className={cn("font-medium text-sm", className)} {...props}>
{children}
</span>
)
export type CommitMetadataProps = HTMLAttributes<HTMLDivElement>
export const CommitMetadata = ({ className, children, ...props }: CommitMetadataProps) => (
<div
className={cn("flex items-center gap-2 text-muted-foreground text-xs", className)}
{...props}
>
{children}
</div>
)
export type CommitSeparatorProps = HTMLAttributes<HTMLSpanElement>
export const CommitSeparator = ({ className, children, ...props }: CommitSeparatorProps) => (
<span className={className} {...props}>
{children ?? "•"}
</span>
)
export type CommitInfoProps = HTMLAttributes<HTMLDivElement>
export const CommitInfo = ({ className, children, ...props }: CommitInfoProps) => (
<div className={cn("flex flex-1 flex-col", className)} {...props}>
{children}
</div>
)
export type CommitAuthorProps = HTMLAttributes<HTMLDivElement>
export const CommitAuthor = ({ className, children, ...props }: CommitAuthorProps) => (
<div className={cn("flex items-center", className)} {...props}>
{children}
</div>
)
export type CommitAuthorAvatarProps = ComponentProps<typeof Avatar> & {
initials: string
}
export const CommitAuthorAvatar = ({ initials, className, ...props }: CommitAuthorAvatarProps) => (
<Avatar className={cn("size-8", className)} {...props}>
<AvatarFallback className="text-xs">{initials}</AvatarFallback>
</Avatar>
)
export type CommitTimestampProps = HTMLAttributes<HTMLTimeElement> & {
date: Date
}
export const CommitTimestamp = ({ date, className, children, ...props }: CommitTimestampProps) => {
const formatted = new Intl.RelativeTimeFormat("en", { numeric: "auto" }).format(
Math.round((date.getTime() - Date.now()) / (1000 * 60 * 60 * 24)),
"day",
)
return (
<time
className={cn("text-xs", className)}
dateTime={date.toISOString()}
suppressHydrationWarning
{...props}
>
{children ?? formatted}
</time>
)
}
export type CommitActionsProps = HTMLAttributes<HTMLDivElement>
export const CommitActions = ({ className, children, ...props }: CommitActionsProps) => (
<div
className={cn("flex items-center gap-1", className)}
onClick={e => e.stopPropagation()}
onKeyDown={e => e.stopPropagation()}
role="group"
{...props}
>
{children}
</div>
)
export type CommitCopyButtonProps = ComponentProps<typeof Button> & {
hash: string
onCopy?: () => void
onError?: (error: Error) => void
timeout?: number
}
export const CommitCopyButton = ({
hash,
onCopy,
onError,
timeout = 2000,
children,
className,
...props
}: CommitCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false)
const timeoutRef = useRef<number>(0)
const copyToClipboard = async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"))
return
}
try {
if (!isCopied) {
await navigator.clipboard.writeText(hash)
setIsCopied(true)
onCopy?.()
timeoutRef.current = window.setTimeout(() => setIsCopied(false), timeout)
}
} catch (error) {
onError?.(error as Error)
}
}
useEffect(
() => () => {
window.clearTimeout(timeoutRef.current)
},
[],
)
const Icon = isCopied ? CheckIcon : CopyIcon
return (
<Button
className={cn("size-7 shrink-0", className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={14} />}
</Button>
)
}
export type CommitContentProps = ComponentProps<typeof CollapsibleContent>
export const CommitContent = ({ className, children, ...props }: CommitContentProps) => (
<CollapsibleContent className={cn("border-t p-3", className)} {...props}>
{children}
</CollapsibleContent>
)
export type CommitFilesProps = HTMLAttributes<HTMLDivElement>
export const CommitFiles = ({ className, children, ...props }: CommitFilesProps) => (
<div className={cn("space-y-1", className)} {...props}>
{children}
</div>
)
export type CommitFileProps = HTMLAttributes<HTMLDivElement>
export const CommitFile = ({ className, children, ...props }: CommitFileProps) => (
<div
className={cn(
"flex items-center justify-between gap-2 rounded px-2 py-1 text-sm hover:bg-muted/50",
className,
)}
{...props}
>
{children}
</div>
)
export type CommitFileInfoProps = HTMLAttributes<HTMLDivElement>
export const CommitFileInfo = ({ className, children, ...props }: CommitFileInfoProps) => (
<div className={cn("flex min-w-0 items-center gap-2", className)} {...props}>
{children}
</div>
)
const fileStatusStyles = {
added: "text-green-600 dark:text-green-400",
modified: "text-yellow-600 dark:text-yellow-400",
deleted: "text-red-600 dark:text-red-400",
renamed: "text-blue-600 dark:text-blue-400",
}
const fileStatusLabels = {
added: "A",
modified: "M",
deleted: "D",
renamed: "R",
}
export type CommitFileStatusProps = HTMLAttributes<HTMLSpanElement> & {
status: "added" | "modified" | "deleted" | "renamed"
}
export const CommitFileStatus = ({
status,
className,
children,
...props
}: CommitFileStatusProps) => (
<span
className={cn("font-medium font-mono text-xs", fileStatusStyles[status], className)}
{...props}
>
{children ?? fileStatusLabels[status]}
</span>
)
export type CommitFileIconProps = ComponentProps<typeof FileIcon>
export const CommitFileIcon = ({ className, ...props }: CommitFileIconProps) => (
<FileIcon className={cn("size-3.5 shrink-0 text-muted-foreground", className)} {...props} />
)
export type CommitFilePathProps = HTMLAttributes<HTMLSpanElement>
export const CommitFilePath = ({ className, children, ...props }: CommitFilePathProps) => (
<span className={cn("truncate font-mono text-xs", className)} {...props}>
{children}
</span>
)
export type CommitFileChangesProps = HTMLAttributes<HTMLDivElement>
export const CommitFileChanges = ({ className, children, ...props }: CommitFileChangesProps) => (
<div className={cn("flex shrink-0 items-center gap-1 font-mono text-xs", className)} {...props}>
{children}
</div>
)
export type CommitFileAdditionsProps = HTMLAttributes<HTMLSpanElement> & {
count: number
}
export const CommitFileAdditions = ({
count,
className,
children,
...props
}: CommitFileAdditionsProps) => {
if (count <= 0) return null
return (
<span className={cn("text-green-600 dark:text-green-400", className)} {...props}>
{children ?? (
<>
<PlusIcon className="inline-block size-3" />
{count}
</>
)}
</span>
)
}
export type CommitFileDeletionsProps = HTMLAttributes<HTMLSpanElement> & {
count: number
}
export const CommitFileDeletions = ({
count,
className,
children,
...props
}: CommitFileDeletionsProps) => {
if (count <= 0) return null
return (
<span className={cn("text-red-600 dark:text-red-400", className)} {...props}>
{children ?? (
<>
<MinusIcon className="inline-block size-3" />
{count}
</>
)}
</span>
)
}
/** Demo component for preview */
export default function CommitDemo() {
return (
<div className="w-full max-w-lg p-4">
<Commit defaultOpen>
<CommitHeader>
<CommitInfo>
<CommitMessage>Add user authentication flow</CommitMessage>
<CommitMetadata>
<CommitHash>a1b2c3d</CommitHash>
<CommitSeparator />
<span>John Doe</span>
<CommitSeparator />
<CommitTimestamp date={new Date(Date.now() - 86400000)} />
</CommitMetadata>
</CommitInfo>
<CommitActions>
<CommitCopyButton hash="a1b2c3d4e5f6" />
</CommitActions>
</CommitHeader>
<CommitContent>
<CommitFiles>
<CommitFile>
<CommitFileInfo>
<CommitFileStatus status="added" />
<CommitFileIcon />
<CommitFilePath>src/auth/login.tsx</CommitFilePath>
</CommitFileInfo>
<CommitFileChanges>
<CommitFileAdditions count={45} />
</CommitFileChanges>
</CommitFile>
<CommitFile>
<CommitFileInfo>
<CommitFileStatus status="modified" />
<CommitFileIcon />
<CommitFilePath>src/app.tsx</CommitFilePath>
</CommitFileInfo>
<CommitFileChanges>
<CommitFileAdditions count={12} />
<CommitFileDeletions count={3} />
</CommitFileChanges>
</CommitFile>
</CommitFiles>
</CommitContent>
</Commit>
</div>
)
}

View File

@ -0,0 +1,181 @@
"use client"
import type { ToolUIPart } from "ai"
import { CheckIcon, XIcon } from "lucide-react"
import { type ComponentProps, createContext, type ReactNode, useContext } from "react"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
type ToolUIPartApproval =
| {
id: string
approved?: never
reason?: never
}
| {
id: string
approved: boolean
reason?: string
}
| {
id: string
approved: true
reason?: string
}
| {
id: string
approved: true
reason?: string
}
| {
id: string
approved: false
reason?: string
}
| undefined
interface ConfirmationContextValue {
approval: ToolUIPartApproval
state: ToolUIPart["state"]
}
const ConfirmationContext = createContext<ConfirmationContextValue | null>(null)
const useConfirmation = () => {
const context = useContext(ConfirmationContext)
if (!context) {
throw new Error("Confirmation components must be used within Confirmation")
}
return context
}
export type ConfirmationProps = ComponentProps<typeof Alert> & {
approval?: ToolUIPartApproval
state: ToolUIPart["state"]
}
export const Confirmation = ({ className, approval, state, ...props }: ConfirmationProps) => {
if (!approval || state === "input-streaming" || state === "input-available") {
return null
}
return (
<ConfirmationContext.Provider value={{ approval, state }}>
<Alert className={cn("flex flex-col gap-2", className)} {...props} />
</ConfirmationContext.Provider>
)
}
export type ConfirmationTitleProps = ComponentProps<typeof AlertDescription>
export const ConfirmationTitle = ({ className, ...props }: ConfirmationTitleProps) => (
<AlertDescription className={cn("inline", className)} {...props} />
)
export interface ConfirmationRequestProps {
children?: ReactNode
}
export const ConfirmationRequest = ({ children }: ConfirmationRequestProps) => {
const { state } = useConfirmation()
// Only show when approval is requested
if (state !== "approval-requested") {
return null
}
return children
}
export interface ConfirmationAcceptedProps {
children?: ReactNode
}
export const ConfirmationAccepted = ({ children }: ConfirmationAcceptedProps) => {
const { approval, state } = useConfirmation()
// Only show when approved and in response states
if (
!approval?.approved ||
(state !== "approval-responded" &&
state !== "output-denied" &&
state !== "output-available")
) {
return null
}
return children
}
export interface ConfirmationRejectedProps {
children?: ReactNode
}
export const ConfirmationRejected = ({ children }: ConfirmationRejectedProps) => {
const { approval, state } = useConfirmation()
// Only show when rejected and in response states
if (
approval?.approved !== false ||
(state !== "approval-responded" &&
state !== "output-denied" &&
state !== "output-available")
) {
return null
}
return children
}
export type ConfirmationActionsProps = ComponentProps<"div">
export const ConfirmationActions = ({ className, ...props }: ConfirmationActionsProps) => {
const { state } = useConfirmation()
// Only show when approval is requested
if (state !== "approval-requested") {
return null
}
return (
<div className={cn("flex items-center justify-end gap-2 self-end", className)} {...props} />
)
}
export type ConfirmationActionProps = ComponentProps<typeof Button>
export const ConfirmationAction = (props: ConfirmationActionProps) => (
<Button className="h-8 px-3 text-sm" type="button" {...props} />
)
/** Demo component for preview */
export default function ConfirmationDemo() {
return (
<div className="w-full max-w-2xl p-6">
<Confirmation approval={{ id: "demo-1" }} state="approval-requested">
<ConfirmationTitle>
<ConfirmationRequest>
This tool wants to delete the file{" "}
<code className="inline rounded bg-muted px-1.5 py-0.5 text-sm">/tmp/example.txt</code>.
Do you approve this action?
</ConfirmationRequest>
<ConfirmationAccepted>
<CheckIcon className="size-4 text-green-600 dark:text-green-400" />
<span>You approved this tool execution</span>
</ConfirmationAccepted>
<ConfirmationRejected>
<XIcon className="size-4 text-destructive" />
<span>You rejected this tool execution</span>
</ConfirmationRejected>
</ConfirmationTitle>
<ConfirmationActions>
<ConfirmationAction variant="outline">Reject</ConfirmationAction>
<ConfirmationAction variant="default">Approve</ConfirmationAction>
</ConfirmationActions>
</Confirmation>
</div>
)
}

View File

@ -0,0 +1,87 @@
"use client"
import type { ConnectionLineComponent } from "@xyflow/react"
const HALF = 0.5
export const Connection: ConnectionLineComponent = ({ fromX, fromY, toX, toY }) => (
<g>
<path
className="animated"
d={`M${fromX},${fromY} C ${fromX + (toX - fromX) * HALF},${fromY} ${fromX + (toX - fromX) * HALF},${toY} ${toX},${toY}`}
fill="none"
stroke="var(--color-ring)"
strokeWidth={1}
/>
<circle cx={toX} cy={toY} fill="#fff" r={3} stroke="var(--color-ring)" strokeWidth={1} />
</g>
)
import {
addEdge,
Background,
type Edge,
type Connection as FlowConnection,
Handle,
Position,
ReactFlow,
ReactFlowProvider,
} from "@xyflow/react"
import "@xyflow/react/dist/style.css"
import { useCallback, useState } from "react"
const DemoNode = ({ data }: { data: { label: string } }) => (
<div className="relative rounded-lg border-2 border-dashed border-primary/50 bg-card px-6 py-3 text-sm font-medium shadow-sm">
<Handle
type="target"
position={Position.Left}
className="!bg-primary !w-3 !h-3 !border-2 !border-background"
/>
{data.label}
<Handle
type="source"
position={Position.Right}
className="!bg-primary !w-3 !h-3 !border-2 !border-background"
/>
</div>
)
const nodeTypes = { demo: DemoNode }
const initialNodes = [
{ id: "1", type: "demo", position: { x: 50, y: 50 }, data: { label: "Node A" } },
{ id: "2", type: "demo", position: { x: 50, y: 150 }, data: { label: "Node B" } },
{ id: "3", type: "demo", position: { x: 300, y: 100 }, data: { label: "Node C" } },
]
/** Demo component for preview */
export default function ConnectionDemo() {
const [edges, setEdges] = useState<Edge[]>([])
const onConnect = useCallback(
(params: FlowConnection) => setEdges(eds => addEdge(params, eds)),
[],
)
return (
<div className="h-full min-h-[500px] w-full">
<ReactFlowProvider>
<ReactFlow
defaultNodes={initialNodes}
edges={edges}
onConnect={onConnect}
nodeTypes={nodeTypes}
connectionLineComponent={Connection}
fitView
fitViewOptions={{ padding: 0.5 }}
proOptions={{ hideAttribution: true }}
>
<Background gap={16} size={1} />
<div className="absolute bottom-4 left-4 rounded-md bg-background/80 px-3 py-2 text-muted-foreground text-xs backdrop-blur-sm">
Drag from a handle to create a connection
</div>
</ReactFlow>
</ReactFlowProvider>
</div>
)
}

392
src/components/ai/context.tsx Executable file
View File

@ -0,0 +1,392 @@
"use client"
import type { LanguageModelUsage } from "ai"
import { type ComponentProps, createContext, useContext } from "react"
import { getUsage } from "tokenlens"
import { Button } from "@/components/ui/button"
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
import { Progress } from "@/components/ui/progress"
import { cn } from "@/lib/utils"
const PERCENT_MAX = 100
const ICON_RADIUS = 10
const ICON_VIEWBOX = 24
const ICON_CENTER = 12
const ICON_STROKE_WIDTH = 2
type ModelId = string
interface ContextSchema {
usedTokens: number
maxTokens: number
usage?: LanguageModelUsage
modelId?: ModelId
}
const ContextContext = createContext<ContextSchema | null>(null)
const useContextValue = () => {
const context = useContext(ContextContext)
if (!context) {
throw new Error("Context components must be used within Context")
}
return context
}
export type ContextProps = ComponentProps<typeof HoverCard> & ContextSchema
export const Context = ({ usedTokens, maxTokens, usage, modelId, ...props }: ContextProps) => (
<ContextContext.Provider
value={{
usedTokens,
maxTokens,
usage,
modelId,
}}
>
<HoverCard closeDelay={0} openDelay={0} {...props} />
</ContextContext.Provider>
)
const ContextIcon = () => {
const { usedTokens, maxTokens } = useContextValue()
const circumference = 2 * Math.PI * ICON_RADIUS
const usedPercent = usedTokens / maxTokens
const dashOffset = circumference * (1 - usedPercent)
return (
<svg
aria-label="Model context usage"
height="20"
role="img"
style={{ color: "currentcolor" }}
viewBox={`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`}
width="20"
>
<circle
cx={ICON_CENTER}
cy={ICON_CENTER}
fill="none"
opacity="0.25"
r={ICON_RADIUS}
stroke="currentColor"
strokeWidth={ICON_STROKE_WIDTH}
/>
<circle
cx={ICON_CENTER}
cy={ICON_CENTER}
fill="none"
opacity="0.7"
r={ICON_RADIUS}
stroke="currentColor"
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={dashOffset}
strokeLinecap="round"
strokeWidth={ICON_STROKE_WIDTH}
style={{ transformOrigin: "center", transform: "rotate(-90deg)" }}
/>
</svg>
)
}
export type ContextTriggerProps = ComponentProps<typeof Button>
export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => {
const { usedTokens, maxTokens } = useContextValue()
const usedPercent = usedTokens / maxTokens
const renderedPercent = new Intl.NumberFormat("en-US", {
style: "percent",
maximumFractionDigits: 1,
}).format(usedPercent)
return (
<HoverCardTrigger asChild>
{children ?? (
<Button type="button" variant="ghost" {...props}>
<span className="font-medium text-muted-foreground">{renderedPercent}</span>
<ContextIcon />
</Button>
)}
</HoverCardTrigger>
)
}
export type ContextContentProps = ComponentProps<typeof HoverCardContent>
export const ContextContent = ({ className, ...props }: ContextContentProps) => (
<HoverCardContent className={cn("min-w-60 divide-y overflow-hidden p-0", className)} {...props} />
)
export type ContextContentHeaderProps = ComponentProps<"div">
export const ContextContentHeader = ({
children,
className,
...props
}: ContextContentHeaderProps) => {
const { usedTokens, maxTokens } = useContextValue()
const usedPercent = usedTokens / maxTokens
const displayPct = new Intl.NumberFormat("en-US", {
style: "percent",
maximumFractionDigits: 1,
}).format(usedPercent)
const used = new Intl.NumberFormat("en-US", {
notation: "compact",
}).format(usedTokens)
const total = new Intl.NumberFormat("en-US", {
notation: "compact",
}).format(maxTokens)
return (
<div className={cn("w-full space-y-2 p-3", className)} {...props}>
{children ?? (
<>
<div className="flex items-center justify-between gap-3 text-xs">
<p>{displayPct}</p>
<p className="font-mono text-muted-foreground">
{used} / {total}
</p>
</div>
<div className="space-y-2">
<Progress className="bg-muted" value={usedPercent * PERCENT_MAX} />
</div>
</>
)}
</div>
)
}
export type ContextContentBodyProps = ComponentProps<"div">
export const ContextContentBody = ({ children, className, ...props }: ContextContentBodyProps) => (
<div className={cn("w-full p-3", className)} {...props}>
{children}
</div>
)
export type ContextContentFooterProps = ComponentProps<"div">
export const ContextContentFooter = ({
children,
className,
...props
}: ContextContentFooterProps) => {
const { modelId, usage } = useContextValue()
const costUSD = modelId
? getUsage({
modelId,
usage: {
input: usage?.inputTokens ?? 0,
output: usage?.outputTokens ?? 0,
},
}).costUSD?.totalUSD
: undefined
const totalCost = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(costUSD ?? 0)
return (
<div
className={cn(
"flex w-full items-center justify-between gap-3 bg-secondary p-3 text-xs",
className,
)}
{...props}
>
{children ?? (
<>
<span className="text-muted-foreground">Total cost</span>
<span>{totalCost}</span>
</>
)}
</div>
)
}
export type ContextInputUsageProps = ComponentProps<"div">
export const ContextInputUsage = ({ className, children, ...props }: ContextInputUsageProps) => {
const { usage, modelId } = useContextValue()
const inputTokens = usage?.inputTokens ?? 0
if (children) {
return children
}
if (!inputTokens) {
return null
}
const inputCost = modelId
? getUsage({
modelId,
usage: { input: inputTokens, output: 0 },
}).costUSD?.totalUSD
: undefined
const inputCostText = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(inputCost ?? 0)
return (
<div className={cn("flex items-center justify-between text-xs", className)} {...props}>
<span className="text-muted-foreground">Input</span>
<TokensWithCost costText={inputCostText} tokens={inputTokens} />
</div>
)
}
export type ContextOutputUsageProps = ComponentProps<"div">
export const ContextOutputUsage = ({ className, children, ...props }: ContextOutputUsageProps) => {
const { usage, modelId } = useContextValue()
const outputTokens = usage?.outputTokens ?? 0
if (children) {
return children
}
if (!outputTokens) {
return null
}
const outputCost = modelId
? getUsage({
modelId,
usage: { input: 0, output: outputTokens },
}).costUSD?.totalUSD
: undefined
const outputCostText = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(outputCost ?? 0)
return (
<div className={cn("flex items-center justify-between text-xs", className)} {...props}>
<span className="text-muted-foreground">Output</span>
<TokensWithCost costText={outputCostText} tokens={outputTokens} />
</div>
)
}
export type ContextReasoningUsageProps = ComponentProps<"div">
export const ContextReasoningUsage = ({
className,
children,
...props
}: ContextReasoningUsageProps) => {
const { usage, modelId } = useContextValue()
const reasoningTokens = usage?.reasoningTokens ?? 0
if (children) {
return children
}
if (!reasoningTokens) {
return null
}
const reasoningCost = modelId
? getUsage({
modelId,
usage: { reasoningTokens },
}).costUSD?.totalUSD
: undefined
const reasoningCostText = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(reasoningCost ?? 0)
return (
<div className={cn("flex items-center justify-between text-xs", className)} {...props}>
<span className="text-muted-foreground">Reasoning</span>
<TokensWithCost costText={reasoningCostText} tokens={reasoningTokens} />
</div>
)
}
export type ContextCacheUsageProps = ComponentProps<"div">
export const ContextCacheUsage = ({ className, children, ...props }: ContextCacheUsageProps) => {
const { usage, modelId } = useContextValue()
const cacheTokens = usage?.cachedInputTokens ?? 0
if (children) {
return children
}
if (!cacheTokens) {
return null
}
const cacheCost = modelId
? getUsage({
modelId,
usage: { cacheReads: cacheTokens, input: 0, output: 0 },
}).costUSD?.totalUSD
: undefined
const cacheCostText = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(cacheCost ?? 0)
return (
<div className={cn("flex items-center justify-between text-xs", className)} {...props}>
<span className="text-muted-foreground">Cache</span>
<TokensWithCost costText={cacheCostText} tokens={cacheTokens} />
</div>
)
}
const TokensWithCost = ({ tokens, costText }: { tokens?: number; costText?: string }) => (
<span>
{tokens === undefined
? "—"
: new Intl.NumberFormat("en-US", {
notation: "compact",
}).format(tokens)}
{costText ? <span className="ml-2 text-muted-foreground"> {costText}</span> : null}
</span>
)
/** Demo component for preview */
export default function ContextDemo() {
return (
<div className="flex items-center justify-center p-8">
<Context
maxTokens={128_000}
modelId="openai:gpt-5"
usage={{
inputTokens: 32_000,
outputTokens: 8000,
totalTokens: 40_000,
cachedInputTokens: 0,
reasoningTokens: 0,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined },
}}
usedTokens={40_000}
>
<ContextTrigger />
<ContextContent>
<ContextContentHeader />
<ContextContentBody>
<ContextInputUsage />
<ContextOutputUsage />
<ContextReasoningUsage />
<ContextCacheUsage />
</ContextContentBody>
<ContextContentFooter />
</ContextContent>
</Context>
</div>
)
}

46
src/components/ai/controls.tsx Executable file
View File

@ -0,0 +1,46 @@
"use client"
import { Controls as ControlsPrimitive } from "@xyflow/react"
import type { ComponentProps } from "react"
import { cn } from "@/lib/utils"
export type ControlsProps = ComponentProps<typeof ControlsPrimitive>
export const Controls = ({ className, ...props }: ControlsProps) => (
<ControlsPrimitive
className={cn(
"gap-px overflow-hidden rounded-md border bg-card p-1 shadow-none!",
"[&>button]:rounded-md [&>button]:border-none! [&>button]:bg-transparent! [&>button]:hover:bg-secondary!",
className,
)}
{...props}
/>
)
import { Background, ReactFlow, ReactFlowProvider } from "@xyflow/react"
import "@xyflow/react/dist/style.css"
const SimpleNode = ({ data }: { data: { label: string } }) => (
<div className="rounded-md border bg-card px-3 py-1.5 text-xs font-medium">{data.label}</div>
)
const nodeTypes = { simple: SimpleNode }
const initialNodes = [
{ id: "1", type: "simple", position: { x: 100, y: 80 }, data: { label: "Node A" } },
{ id: "2", type: "simple", position: { x: 250, y: 80 }, data: { label: "Node B" } },
]
/** Demo component for preview */
export default function ControlsDemo() {
return (
<div className="h-full w-full min-h-screen">
<ReactFlowProvider>
<ReactFlow defaultNodes={initialNodes} nodeTypes={nodeTypes} fitView panOnScroll>
<Background bgColor="var(--sidebar)" />
<Controls position="bottom-left" />
</ReactFlow>
</ReactFlowProvider>
</div>
)
}

View File

@ -0,0 +1,119 @@
"use client"
import { ArrowDownIcon } from "lucide-react"
import type { ComponentProps } from "react"
import { useCallback } from "react"
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { Message, MessageContent } from "@/components/ai/message"
export type ConversationProps = ComponentProps<typeof StickToBottom>
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn("relative flex-1 overflow-y-hidden", className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>
)
export type ConversationContentProps = ComponentProps<typeof StickToBottom.Content>
export const ConversationContent = ({ className, ...props }: ConversationContentProps) => (
<StickToBottom.Content className={cn("flex flex-col gap-8 p-4", className)} {...props} />
)
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
title?: string
description?: string
icon?: React.ReactNode
}
export const ConversationEmptyState = ({
className,
title = "No messages yet",
description = "Start a conversation to see messages here",
icon,
children,
...props
}: ConversationEmptyStateProps) => (
<div
className={cn(
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
className,
)}
{...props}
>
{children ?? (
<>
{icon && <div className="text-muted-foreground">{icon}</div>}
<div className="space-y-1">
<h3 className="font-medium text-sm">{title}</h3>
{description && <p className="text-muted-foreground text-sm">{description}</p>}
</div>
</>
)}
</div>
)
export type ConversationScrollButtonProps = ComponentProps<typeof Button>
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext()
const handleScrollToBottom = useCallback(() => {
scrollToBottom()
}, [scrollToBottom])
return (
!isAtBottom && (
<Button
className={cn("absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full", className)}
onClick={handleScrollToBottom}
size="icon"
type="button"
variant="outline"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
)
)
}
/** Demo component for preview */
export default function ConversationDemo() {
const messages = [
{ id: "1", from: "user" as const, text: "Hello, how are you?" },
{
id: "2",
from: "assistant" as const,
text: "I'm good, thank you! How can I assist you today?",
},
{ id: "3", from: "user" as const, text: "I'm looking for information about your services." },
{
id: "4",
from: "assistant" as const,
text: "Sure! We offer a variety of AI solutions. What are you interested in?",
},
]
return (
<Conversation className="relative size-full p-4">
<ConversationContent>
{messages.map(msg => (
<Message from={msg.from} key={msg.id}>
<MessageContent>{msg.text}</MessageContent>
</Message>
))}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
)
}

178
src/components/ai/edge.tsx Executable file
View File

@ -0,0 +1,178 @@
"use client"
import {
BaseEdge,
type EdgeProps,
getBezierPath,
getSimpleBezierPath,
type InternalNode,
type Node,
Position,
useInternalNode,
} from "@xyflow/react"
const Temporary = ({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
}: EdgeProps) => {
const [edgePath] = getSimpleBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
})
return (
<BaseEdge
className="stroke-1 stroke-ring"
id={id}
path={edgePath}
style={{
strokeDasharray: "5, 5",
}}
/>
)
}
const getHandleCoordsByPosition = (node: InternalNode<Node>, handlePosition: Position) => {
// Choose the handle type based on position - Left is for target, Right is for source
const handleType = handlePosition === Position.Left ? "target" : "source"
const handle = node.internals.handleBounds?.[handleType]?.find(h => h.position === handlePosition)
if (!handle) {
return [0, 0] as const
}
let offsetX = handle.width / 2
let offsetY = handle.height / 2
// this is a tiny detail to make the markerEnd of an edge visible.
// The handle position that gets calculated has the origin top-left, so depending which side we are using, we add a little offset
// when the handlePosition is Position.Right for example, we need to add an offset as big as the handle itself in order to get the correct position
switch (handlePosition) {
case Position.Left:
offsetX = 0
break
case Position.Right:
offsetX = handle.width
break
case Position.Top:
offsetY = 0
break
case Position.Bottom:
offsetY = handle.height
break
default:
throw new Error(`Invalid handle position: ${handlePosition}`)
}
const x = node.internals.positionAbsolute.x + handle.x + offsetX
const y = node.internals.positionAbsolute.y + handle.y + offsetY
return [x, y] as const
}
const getEdgeParams = (source: InternalNode<Node>, target: InternalNode<Node>) => {
const sourcePos = Position.Right
const [sx, sy] = getHandleCoordsByPosition(source, sourcePos)
const targetPos = Position.Left
const [tx, ty] = getHandleCoordsByPosition(target, targetPos)
return {
sx,
sy,
tx,
ty,
sourcePos,
targetPos,
}
}
const Animated = ({ id, source, target, markerEnd, style }: EdgeProps) => {
const sourceNode = useInternalNode(source)
const targetNode = useInternalNode(target)
if (!(sourceNode && targetNode)) {
return null
}
const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams(sourceNode, targetNode)
const [edgePath] = getBezierPath({
sourceX: sx,
sourceY: sy,
sourcePosition: sourcePos,
targetX: tx,
targetY: ty,
targetPosition: targetPos,
})
return (
<>
<BaseEdge id={id} markerEnd={markerEnd} path={edgePath} style={style} />
<circle fill="var(--primary)" r="4">
<animateMotion dur="2s" path={edgePath} repeatCount="indefinite" />
</circle>
</>
)
}
export const Edge = {
Temporary,
Animated,
}
import { Background, Handle, ReactFlow, ReactFlowProvider } from "@xyflow/react"
import "@xyflow/react/dist/style.css"
const SimpleNode = ({ data }: { data: { label: string } }) => (
<div className="relative rounded-md border bg-card px-4 py-2 text-sm font-medium">
<Handle type="target" position={Position.Left} className="!bg-primary !w-2 !h-2" />
{data.label}
<Handle type="source" position={Position.Right} className="!bg-primary !w-2 !h-2" />
</div>
)
const nodeTypes = { simple: SimpleNode }
const edgeTypes = { animated: Animated, temporary: Temporary }
const initialNodes = [
{ id: "1", type: "simple", position: { x: 50, y: 80 }, data: { label: "Start" } },
{ id: "2", type: "simple", position: { x: 220, y: 80 }, data: { label: "Process" } },
{ id: "3", type: "simple", position: { x: 390, y: 80 }, data: { label: "End" } },
]
const initialEdges = [
{ id: "e1-2", source: "1", target: "2", type: "animated" },
{ id: "e2-3", source: "2", target: "3", type: "temporary" },
]
/** Demo component for preview */
export default function EdgeDemo() {
return (
<div className="h-full min-h-[500px] w-full">
<ReactFlowProvider>
<ReactFlow
defaultNodes={initialNodes}
defaultEdges={initialEdges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitView
fitViewOptions={{ padding: 0.4 }}
panOnScroll
proOptions={{ hideAttribution: true }}
>
<Background gap={16} size={1} />
</ReactFlow>
</ReactFlowProvider>
</div>
)
}

View File

@ -0,0 +1,322 @@
"use client"
import { CheckIcon, CopyIcon, EyeIcon, EyeOffIcon } from "lucide-react"
import {
type ComponentProps,
createContext,
type HTMLAttributes,
useContext,
useState,
} from "react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
interface EnvironmentVariablesContextType {
showValues: boolean
setShowValues: (show: boolean) => void
}
const EnvironmentVariablesContext = createContext<EnvironmentVariablesContextType>({
showValues: false,
setShowValues: () => undefined,
})
export type EnvironmentVariablesProps = HTMLAttributes<HTMLDivElement> & {
showValues?: boolean
defaultShowValues?: boolean
onShowValuesChange?: (show: boolean) => void
}
export const EnvironmentVariables = ({
showValues: controlledShowValues,
defaultShowValues = false,
onShowValuesChange,
className,
children,
...props
}: EnvironmentVariablesProps) => {
const [internalShowValues, setInternalShowValues] = useState(defaultShowValues)
const showValues = controlledShowValues ?? internalShowValues
const setShowValues = (show: boolean) => {
setInternalShowValues(show)
onShowValuesChange?.(show)
}
return (
<EnvironmentVariablesContext.Provider value={{ showValues, setShowValues }}>
<div className={cn("rounded-lg border bg-background", className)} {...props}>
{children}
</div>
</EnvironmentVariablesContext.Provider>
)
}
export type EnvironmentVariablesHeaderProps = HTMLAttributes<HTMLDivElement>
export const EnvironmentVariablesHeader = ({
className,
children,
...props
}: EnvironmentVariablesHeaderProps) => (
<div className={cn("flex items-center justify-between border-b px-4 py-3", className)} {...props}>
{children}
</div>
)
export type EnvironmentVariablesTitleProps = HTMLAttributes<HTMLHeadingElement>
export const EnvironmentVariablesTitle = ({
className,
children,
...props
}: EnvironmentVariablesTitleProps) => (
<h3 className={cn("font-medium text-sm", className)} {...props}>
{children ?? "Environment Variables"}
</h3>
)
export type EnvironmentVariablesToggleProps = ComponentProps<typeof Switch>
export const EnvironmentVariablesToggle = ({
className,
...props
}: EnvironmentVariablesToggleProps) => {
const { showValues, setShowValues } = useContext(EnvironmentVariablesContext)
return (
<div className={cn("flex items-center gap-2", className)}>
<span className="text-muted-foreground text-xs">
{showValues ? <EyeIcon size={14} /> : <EyeOffIcon size={14} />}
</span>
<Switch
aria-label="Toggle value visibility"
checked={showValues}
onCheckedChange={setShowValues}
{...props}
/>
</div>
)
}
export type EnvironmentVariablesContentProps = HTMLAttributes<HTMLDivElement>
export const EnvironmentVariablesContent = ({
className,
children,
...props
}: EnvironmentVariablesContentProps) => (
<div className={cn("divide-y", className)} {...props}>
{children}
</div>
)
interface EnvironmentVariableContextType {
name: string
value: string
}
const EnvironmentVariableContext = createContext<EnvironmentVariableContextType>({
name: "",
value: "",
})
export type EnvironmentVariableProps = HTMLAttributes<HTMLDivElement> & {
name: string
value: string
}
export const EnvironmentVariable = ({
name,
value,
className,
children,
...props
}: EnvironmentVariableProps) => (
<EnvironmentVariableContext.Provider value={{ name, value }}>
<div className={cn("flex items-center justify-between gap-4 px-4 py-3", className)} {...props}>
{children ?? (
<>
<div className="flex shrink-0 items-center gap-2">
<EnvironmentVariableName />
</div>
<EnvironmentVariableValue />
</>
)}
</div>
</EnvironmentVariableContext.Provider>
)
export type EnvironmentVariableGroupProps = HTMLAttributes<HTMLDivElement>
export const EnvironmentVariableGroup = ({
className,
children,
...props
}: EnvironmentVariableGroupProps) => (
<div className={cn("flex min-w-0 items-center gap-2", className)} {...props}>
{children}
</div>
)
export type EnvironmentVariableNameProps = HTMLAttributes<HTMLSpanElement>
export const EnvironmentVariableName = ({
className,
children,
...props
}: EnvironmentVariableNameProps) => {
const { name } = useContext(EnvironmentVariableContext)
return (
<span className={cn("font-mono text-sm", className)} {...props}>
{children ?? name}
</span>
)
}
export type EnvironmentVariableValueProps = HTMLAttributes<HTMLSpanElement>
export const EnvironmentVariableValue = ({
className,
children,
...props
}: EnvironmentVariableValueProps) => {
const { value } = useContext(EnvironmentVariableContext)
const { showValues } = useContext(EnvironmentVariablesContext)
const displayValue = showValues ? value : "•".repeat(Math.min(value.length, 20))
return (
<span
className={cn(
"truncate font-mono text-muted-foreground text-sm",
!showValues && "select-none",
className,
)}
title={showValues ? value : undefined}
{...props}
>
{children ?? displayValue}
</span>
)
}
export type EnvironmentVariableCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void
onError?: (error: Error) => void
timeout?: number
copyFormat?: "name" | "value" | "export"
}
export const EnvironmentVariableCopyButton = ({
onCopy,
onError,
timeout = 2000,
copyFormat = "value",
children,
className,
...props
}: EnvironmentVariableCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false)
const { name, value } = useContext(EnvironmentVariableContext)
const copyToClipboard = async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"))
return
}
let textToCopy = value
if (copyFormat === "name") {
textToCopy = name
} else if (copyFormat === "export") {
textToCopy = `export ${name}="${value}"`
}
try {
await navigator.clipboard.writeText(textToCopy)
setIsCopied(true)
onCopy?.()
setTimeout(() => setIsCopied(false), timeout)
} catch (error) {
onError?.(error as Error)
}
}
const Icon = isCopied ? CheckIcon : CopyIcon
return (
<Button
className={cn("size-6 shrink-0", className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={12} />}
</Button>
)
}
export type EnvironmentVariableRequiredProps = ComponentProps<typeof Badge>
export const EnvironmentVariableRequired = ({
className,
children,
...props
}: EnvironmentVariableRequiredProps) => (
<Badge className={cn("text-xs", className)} variant="secondary" {...props}>
{children ?? "Required"}
</Badge>
)
/** Demo component for preview */
export default function EnvironmentVariablesDemo() {
return (
<div className="w-full max-w-lg p-4">
<EnvironmentVariables>
<EnvironmentVariablesHeader>
<EnvironmentVariablesTitle />
<EnvironmentVariablesToggle />
</EnvironmentVariablesHeader>
<EnvironmentVariablesContent>
<EnvironmentVariable name="OPENAI_API_KEY" value="sk-proj-abc123xyz789">
<EnvironmentVariableGroup className="shrink-0">
<EnvironmentVariableName />
<EnvironmentVariableRequired />
</EnvironmentVariableGroup>
<EnvironmentVariableGroup>
<EnvironmentVariableValue />
<EnvironmentVariableCopyButton />
</EnvironmentVariableGroup>
</EnvironmentVariable>
<EnvironmentVariable
name="DATABASE_URL"
value="postgres://user:pass@db.host.io:5432/mydb"
>
<EnvironmentVariableGroup className="shrink-0">
<EnvironmentVariableName />
<EnvironmentVariableRequired />
</EnvironmentVariableGroup>
<EnvironmentVariableGroup>
<EnvironmentVariableValue />
<EnvironmentVariableCopyButton copyFormat="export" />
</EnvironmentVariableGroup>
</EnvironmentVariable>
<EnvironmentVariable name="DEBUG" value="true">
<EnvironmentVariableGroup className="shrink-0">
<EnvironmentVariableName />
</EnvironmentVariableGroup>
<EnvironmentVariableGroup>
<EnvironmentVariableValue />
<EnvironmentVariableCopyButton />
</EnvironmentVariableGroup>
</EnvironmentVariable>
</EnvironmentVariablesContent>
</EnvironmentVariables>
</div>
)
}

245
src/components/ai/file-tree.tsx Executable file
View File

@ -0,0 +1,245 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
"use client"
import { ChevronRightIcon, FileIcon, FolderIcon, FolderOpenIcon } from "lucide-react"
import { createContext, type HTMLAttributes, type ReactNode, useContext, useState } from "react"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { cn } from "@/lib/utils"
interface FileTreeContextType {
expandedPaths: Set<string>
togglePath: (path: string) => void
selectedPath?: string
onSelect?: (path: string) => void
}
const FileTreeContext = createContext<FileTreeContextType>({
expandedPaths: new Set(),
togglePath: () => undefined,
})
export type FileTreeProps = HTMLAttributes<HTMLDivElement> & {
expanded?: Set<string>
defaultExpanded?: Set<string>
selectedPath?: string
onSelect?: (path: string) => void
onExpandedChange?: (expanded: Set<string>) => void
}
export const FileTree = ({
expanded: controlledExpanded,
defaultExpanded = new Set(),
selectedPath,
onSelect,
onExpandedChange,
className,
children,
...props
}: FileTreeProps) => {
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded)
const expandedPaths = controlledExpanded ?? internalExpanded
const togglePath = (path: string) => {
const newExpanded = new Set(expandedPaths)
if (newExpanded.has(path)) {
newExpanded.delete(path)
} else {
newExpanded.add(path)
}
setInternalExpanded(newExpanded)
onExpandedChange?.(newExpanded)
}
return (
<FileTreeContext.Provider value={{ expandedPaths, togglePath, selectedPath, onSelect }}>
<div
className={cn("rounded-lg border bg-background font-mono text-sm", className)}
role="tree"
{...props}
>
<div className="p-2">{children}</div>
</div>
</FileTreeContext.Provider>
)
}
interface FileTreeFolderContextType {
path: string
name: string
isExpanded: boolean
}
const FileTreeFolderContext = createContext<FileTreeFolderContextType>({
path: "",
name: "",
isExpanded: false,
})
export type FileTreeFolderProps = HTMLAttributes<HTMLDivElement> & {
path: string
name: string
}
export const FileTreeFolder = ({
path,
name,
className,
children,
...props
}: FileTreeFolderProps) => {
const { expandedPaths, togglePath, selectedPath, onSelect } = useContext(FileTreeContext)
const isExpanded = expandedPaths.has(path)
const isSelected = selectedPath === path
return (
<FileTreeFolderContext.Provider value={{ path, name, isExpanded }}>
<Collapsible onOpenChange={() => togglePath(path)} open={isExpanded}>
<div className={cn("", className)} role="treeitem" tabIndex={0} {...props}>
<CollapsibleTrigger asChild>
<button
className={cn(
"flex w-full items-center gap-1 rounded px-2 py-1 text-left transition-colors hover:bg-muted/50",
isSelected && "bg-muted",
)}
onClick={() => onSelect?.(path)}
type="button"
>
<ChevronRightIcon
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform",
isExpanded && "rotate-90",
)}
/>
<FileTreeIcon>
{isExpanded ? (
<FolderOpenIcon className="size-4 text-blue-500" />
) : (
<FolderIcon className="size-4 text-blue-500" />
)}
</FileTreeIcon>
<FileTreeName>{name}</FileTreeName>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="ml-4 border-l pl-2">{children}</div>
</CollapsibleContent>
</div>
</Collapsible>
</FileTreeFolderContext.Provider>
)
}
interface FileTreeFileContextType {
path: string
name: string
}
const FileTreeFileContext = createContext<FileTreeFileContextType>({
path: "",
name: "",
})
export type FileTreeFileProps = HTMLAttributes<HTMLDivElement> & {
path: string
name: string
icon?: ReactNode
}
export const FileTreeFile = ({
path,
name,
icon,
className,
children,
...props
}: FileTreeFileProps) => {
const { selectedPath, onSelect } = useContext(FileTreeContext)
const isSelected = selectedPath === path
return (
<FileTreeFileContext.Provider value={{ path, name }}>
<div
className={cn(
"flex cursor-pointer items-center gap-1 rounded px-2 py-1 transition-colors hover:bg-muted/50",
isSelected && "bg-muted",
className,
)}
onClick={() => onSelect?.(path)}
onKeyDown={e => {
if (e.key === "Enter" || e.key === " ") {
onSelect?.(path)
}
}}
role="treeitem"
tabIndex={0}
{...props}
>
{children ?? (
<>
<span className="size-4" />
<FileTreeIcon>
{icon ?? <FileIcon className="size-4 text-muted-foreground" />}
</FileTreeIcon>
<FileTreeName>{name}</FileTreeName>
</>
)}
</div>
</FileTreeFileContext.Provider>
)
}
export type FileTreeIconProps = HTMLAttributes<HTMLSpanElement>
export const FileTreeIcon = ({ className, children, ...props }: FileTreeIconProps) => (
<span className={cn("shrink-0", className)} {...props}>
{children}
</span>
)
export type FileTreeNameProps = HTMLAttributes<HTMLSpanElement>
export const FileTreeName = ({ className, children, ...props }: FileTreeNameProps) => (
<span className={cn("truncate", className)} {...props}>
{children}
</span>
)
export type FileTreeActionsProps = HTMLAttributes<HTMLDivElement>
export const FileTreeActions = ({ className, children, ...props }: FileTreeActionsProps) => (
<div
className={cn("ml-auto flex items-center gap-1", className)}
onClick={e => e.stopPropagation()}
onKeyDown={e => e.stopPropagation()}
role="group"
{...props}
>
{children}
</div>
)
/** Demo component for preview */
export default function FileTreeDemo() {
const [selected, setSelected] = useState<string>()
return (
<div className="w-full max-w-xs p-4">
<FileTree
defaultExpanded={new Set(["src", "src/components"])}
selectedPath={selected}
onSelect={setSelected}
>
<FileTreeFolder path="src" name="src">
<FileTreeFolder path="src/components" name="components">
<FileTreeFile path="src/components/Button.tsx" name="Button.tsx" />
<FileTreeFile path="src/components/Card.tsx" name="Card.tsx" />
</FileTreeFolder>
<FileTreeFile path="src/index.ts" name="index.ts" />
</FileTreeFolder>
<FileTreeFile path="package.json" name="package.json" />
<FileTreeFile path="README.md" name="README.md" />
</FileTree>
</div>
)
}

34
src/components/ai/image.tsx Executable file
View File

@ -0,0 +1,34 @@
import type { Experimental_GeneratedImage } from "ai"
import { cn } from "@/lib/utils"
export type ImageProps = Experimental_GeneratedImage & {
className?: string
alt?: string
}
export const Image = ({ base64, uint8Array, mediaType, ...props }: ImageProps) => (
<img
{...props}
alt={props.alt}
className={cn("h-auto max-w-full overflow-hidden rounded-md", props.className)}
height={400}
src={`data:${mediaType};base64,${base64}`}
width={400}
/>
)
/** Demo component for preview */
export default function ImageDemo() {
return (
<div className="flex items-center justify-center p-8">
<div className="flex flex-col items-center gap-4">
<div className="relative h-[200px] w-[200px] overflow-hidden rounded-md border bg-gradient-to-br from-purple-500 via-pink-500 to-orange-400">
<div className="absolute inset-0 flex items-center justify-center">
<span className="font-medium text-white text-xl">AI Generated</span>
</div>
</div>
<p className="text-muted-foreground text-sm">Base64-encoded image from AI SDK</p>
</div>
</div>
)
}

View File

@ -0,0 +1,304 @@
"use client"
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"
import {
type ComponentProps,
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react"
import { Badge } from "@/components/ui/badge"
import { Carousel, type CarouselApi, CarouselContent, CarouselItem } from "@/components/ui/carousel"
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
import { cn } from "@/lib/utils"
export type InlineCitationProps = ComponentProps<"span">
export const InlineCitation = ({ className, ...props }: InlineCitationProps) => (
<span className={cn("group inline items-center gap-1", className)} {...props} />
)
export type InlineCitationTextProps = ComponentProps<"span">
export const InlineCitationText = ({ className, ...props }: InlineCitationTextProps) => (
<span className={cn("transition-colors group-hover:bg-accent", className)} {...props} />
)
export type InlineCitationCardProps = ComponentProps<typeof HoverCard>
export const InlineCitationCard = (props: InlineCitationCardProps) => (
<HoverCard closeDelay={0} openDelay={0} {...props} />
)
export type InlineCitationCardTriggerProps = ComponentProps<typeof Badge> & {
sources: string[]
}
export const InlineCitationCardTrigger = ({
sources,
className,
...props
}: InlineCitationCardTriggerProps) => (
<HoverCardTrigger asChild>
<Badge className={cn("ml-1 rounded-full", className)} variant="secondary" {...props}>
{sources[0] ? (
<>
{new URL(sources[0]).hostname} {sources.length > 1 && `+${sources.length - 1}`}
</>
) : (
"unknown"
)}
</Badge>
</HoverCardTrigger>
)
export type InlineCitationCardBodyProps = ComponentProps<"div">
export const InlineCitationCardBody = ({ className, ...props }: InlineCitationCardBodyProps) => (
<HoverCardContent className={cn("relative w-80 p-0", className)} {...props} />
)
const CarouselApiContext = createContext<CarouselApi | undefined>(undefined)
const useCarouselApi = () => {
const context = useContext(CarouselApiContext)
return context
}
export type InlineCitationCarouselProps = ComponentProps<typeof Carousel>
export const InlineCitationCarousel = ({
className,
children,
...props
}: InlineCitationCarouselProps) => {
const [api, setApi] = useState<CarouselApi>()
return (
<CarouselApiContext.Provider value={api}>
<Carousel className={cn("w-full", className)} setApi={setApi} {...props}>
{children}
</Carousel>
</CarouselApiContext.Provider>
)
}
export type InlineCitationCarouselContentProps = ComponentProps<"div">
export const InlineCitationCarouselContent = (props: InlineCitationCarouselContentProps) => (
<CarouselContent {...props} />
)
export type InlineCitationCarouselItemProps = ComponentProps<"div">
export const InlineCitationCarouselItem = ({
className,
...props
}: InlineCitationCarouselItemProps) => (
<CarouselItem className={cn("w-full space-y-2 p-4 pl-8", className)} {...props} />
)
export type InlineCitationCarouselHeaderProps = ComponentProps<"div">
export const InlineCitationCarouselHeader = ({
className,
...props
}: InlineCitationCarouselHeaderProps) => (
<div
className={cn(
"flex items-center justify-between gap-2 rounded-t-md bg-secondary p-2",
className,
)}
{...props}
/>
)
export type InlineCitationCarouselIndexProps = ComponentProps<"div">
export const InlineCitationCarouselIndex = ({
children,
className,
...props
}: InlineCitationCarouselIndexProps) => {
const api = useCarouselApi()
const [current, setCurrent] = useState(0)
const [count, setCount] = useState(0)
useEffect(() => {
if (!api) {
return
}
setCount(api.scrollSnapList().length)
setCurrent(api.selectedScrollSnap() + 1)
api.on("select", () => {
setCurrent(api.selectedScrollSnap() + 1)
})
}, [api])
return (
<div
className={cn(
"flex flex-1 items-center justify-end px-3 py-1 text-muted-foreground text-xs",
className,
)}
{...props}
>
{children ?? `${current}/${count}`}
</div>
)
}
export type InlineCitationCarouselPrevProps = ComponentProps<"button">
export const InlineCitationCarouselPrev = ({
className,
...props
}: InlineCitationCarouselPrevProps) => {
const api = useCarouselApi()
const handleClick = useCallback(() => {
if (api) {
api.scrollPrev()
}
}, [api])
return (
<button
aria-label="Previous"
className={cn("shrink-0", className)}
onClick={handleClick}
type="button"
{...props}
>
<ArrowLeftIcon className="size-4 text-muted-foreground" />
</button>
)
}
export type InlineCitationCarouselNextProps = ComponentProps<"button">
export const InlineCitationCarouselNext = ({
className,
...props
}: InlineCitationCarouselNextProps) => {
const api = useCarouselApi()
const handleClick = useCallback(() => {
if (api) {
api.scrollNext()
}
}, [api])
return (
<button
aria-label="Next"
className={cn("shrink-0", className)}
onClick={handleClick}
type="button"
{...props}
>
<ArrowRightIcon className="size-4 text-muted-foreground" />
</button>
)
}
export type InlineCitationSourceProps = ComponentProps<"div"> & {
title?: string
url?: string
description?: string
}
export const InlineCitationSource = ({
title,
url,
description,
className,
children,
...props
}: InlineCitationSourceProps) => (
<div className={cn("space-y-1", className)} {...props}>
{title && <h4 className="truncate font-medium text-sm leading-tight">{title}</h4>}
{url && <p className="truncate break-all text-muted-foreground text-xs">{url}</p>}
{description && (
<p className="line-clamp-3 text-muted-foreground text-sm leading-relaxed">{description}</p>
)}
{children}
</div>
)
export type InlineCitationQuoteProps = ComponentProps<"blockquote">
export const InlineCitationQuote = ({
children,
className,
...props
}: InlineCitationQuoteProps) => (
<blockquote
className={cn("border-muted border-l-2 pl-3 text-muted-foreground text-sm italic", className)}
{...props}
>
{children}
</blockquote>
)
/** Demo component for preview */
export default function InlineCitationDemo() {
const citation = {
text: "The technology continues to evolve rapidly, with new breakthroughs being announced regularly",
sources: [
{
title: "Advances in Natural Language Processing",
url: "https://example.com/nlp-advances",
description:
"A comprehensive study on the recent developments in natural language processing technologies.",
},
{
title: "Breakthroughs in Machine Learning",
url: "https://mlnews.org/breakthroughs",
description: "An overview of the most significant machine learning breakthroughs.",
},
{
title: "AI in Healthcare: Current Trends",
url: "https://healthai.com/trends",
description: "A report on how artificial intelligence is transforming healthcare.",
},
],
}
return (
<p className="text-sm leading-relaxed">
According to recent studies, artificial intelligence has shown remarkable progress.{" "}
<InlineCitation>
<InlineCitationText>{citation.text}</InlineCitationText>
<InlineCitationCard>
<InlineCitationCardTrigger sources={citation.sources.map(source => source.url)} />
<InlineCitationCardBody>
<InlineCitationCarousel>
<InlineCitationCarouselHeader>
<InlineCitationCarouselPrev />
<InlineCitationCarouselNext />
<InlineCitationCarouselIndex />
</InlineCitationCarouselHeader>
<InlineCitationCarouselContent>
{citation.sources.map(source => (
<InlineCitationCarouselItem key={source.url}>
<InlineCitationSource
description={source.description}
title={source.title}
url={source.url}
/>
</InlineCitationCarouselItem>
))}
</InlineCitationCarouselContent>
</InlineCitationCarousel>
</InlineCitationCardBody>
</InlineCitationCard>
</InlineCitation>
.
</p>
)
}

107
src/components/ai/loader.tsx Executable file
View File

@ -0,0 +1,107 @@
"use client"
import type { HTMLAttributes } from "react"
import { cn } from "@/lib/utils"
interface LoaderIconProps {
size?: number
}
const LoaderIcon = ({ size = 16 }: LoaderIconProps) => (
<svg
height={size}
strokeLinejoin="round"
style={{ color: "currentcolor" }}
viewBox="0 0 16 16"
width={size}
>
<title>Loader</title>
<g clipPath="url(#clip0_2393_1490)">
<path d="M8 0V4" stroke="currentColor" strokeWidth="1.5" />
<path d="M8 16V12" opacity="0.5" stroke="currentColor" strokeWidth="1.5" />
<path
d="M3.29773 1.52783L5.64887 4.7639"
opacity="0.9"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M12.7023 1.52783L10.3511 4.7639"
opacity="0.1"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M12.7023 14.472L10.3511 11.236"
opacity="0.4"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M3.29773 14.472L5.64887 11.236"
opacity="0.6"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M15.6085 5.52783L11.8043 6.7639"
opacity="0.2"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M0.391602 10.472L4.19583 9.23598"
opacity="0.7"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M15.6085 10.4722L11.8043 9.2361"
opacity="0.3"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M0.391602 5.52783L4.19583 6.7639"
opacity="0.8"
stroke="currentColor"
strokeWidth="1.5"
/>
</g>
<defs>
<clipPath id="clip0_2393_1490">
<rect fill="white" height="16" width="16" />
</clipPath>
</defs>
</svg>
)
export type LoaderProps = HTMLAttributes<HTMLDivElement> & {
size?: number
}
export const Loader = ({ className, size = 16, ...props }: LoaderProps) => (
<div className={cn("inline-flex animate-spin items-center justify-center", className)} {...props}>
<LoaderIcon size={size} />
</div>
)
/** Demo component for preview */
export default function LoaderDemo() {
return (
<div className="flex flex-col items-center justify-center gap-6 p-8">
<div className="flex items-center gap-4">
<Loader size={16} />
<span className="text-sm text-muted-foreground">Default (16px)</span>
</div>
<div className="flex items-center gap-4">
<Loader size={24} />
<span className="text-sm text-muted-foreground">Medium (24px)</span>
</div>
<div className="flex items-center gap-4">
<Loader size={32} />
<span className="text-sm text-muted-foreground">Large (32px)</span>
</div>
</div>
)
}

400
src/components/ai/message.tsx Executable file
View File

@ -0,0 +1,400 @@
"use client"
import type { FileUIPart, UIMessage } from "ai"
import { ChevronLeftIcon, ChevronRightIcon, PaperclipIcon, XIcon } from "lucide-react"
import type { ComponentProps, HTMLAttributes, ReactElement } from "react"
import { createContext, memo, useContext, useEffect, useState } from "react"
import { Streamdown } from "streamdown"
import { Button } from "@/components/ui/button"
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"]
}
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
"group flex w-full max-w-[95%] flex-col gap-2",
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
className,
)}
{...props}
/>
)
export type MessageContentProps = HTMLAttributes<HTMLDivElement>
export const MessageContent = ({ children, className, ...props }: MessageContentProps) => (
<div
className={cn(
"is-user:dark flex w-fit max-w-full min-w-0 flex-col gap-2 overflow-hidden text-sm",
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:px-4 group-[.is-user]:py-3",
"group-[.is-user]:bg-primary/10 group-[.is-user]:text-foreground",
"dark:group-[.is-user]:bg-foreground/10 dark:group-[.is-user]:text-foreground",
"group-[.is-assistant]:text-foreground",
className,
)}
{...props}
>
{children}
</div>
)
export type MessageActionsProps = ComponentProps<"div">
export const MessageActions = ({ className, children, ...props }: MessageActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props}>
{children}
</div>
)
export type MessageActionProps = ComponentProps<typeof Button> & {
tooltip?: string
label?: string
}
export const MessageAction = ({
tooltip,
children,
label,
variant = "ghost",
size = "icon-sm",
...props
}: MessageActionProps) => {
const button = (
<Button size={size} type="button" variant={variant} {...props}>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
)
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
return button
}
interface MessageBranchContextType {
currentBranch: number
totalBranches: number
goToPrevious: () => void
goToNext: () => void
branches: ReactElement[]
setBranches: (branches: ReactElement[]) => void
}
const MessageBranchContext = createContext<MessageBranchContextType | null>(null)
const useMessageBranch = () => {
const context = useContext(MessageBranchContext)
if (!context) {
throw new Error("MessageBranch components must be used within MessageBranch")
}
return context
}
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
defaultBranch?: number
onBranchChange?: (branchIndex: number) => void
}
export const MessageBranch = ({
defaultBranch = 0,
onBranchChange,
className,
...props
}: MessageBranchProps) => {
const [currentBranch, setCurrentBranch] = useState(defaultBranch)
const [branches, setBranches] = useState<ReactElement[]>([])
const handleBranchChange = (newBranch: number) => {
setCurrentBranch(newBranch)
onBranchChange?.(newBranch)
}
const goToPrevious = () => {
const newBranch = currentBranch > 0 ? currentBranch - 1 : branches.length - 1
handleBranchChange(newBranch)
}
const goToNext = () => {
const newBranch = currentBranch < branches.length - 1 ? currentBranch + 1 : 0
handleBranchChange(newBranch)
}
const contextValue: MessageBranchContextType = {
currentBranch,
totalBranches: branches.length,
goToPrevious,
goToNext,
branches,
setBranches,
}
return (
<MessageBranchContext.Provider value={contextValue}>
<div className={cn("grid w-full gap-2 [&>div]:pb-0", className)} {...props} />
</MessageBranchContext.Provider>
)
}
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>
export const MessageBranchContent = ({ children, ...props }: MessageBranchContentProps) => {
const { currentBranch, setBranches, branches } = useMessageBranch()
const childrenArray = Array.isArray(children) ? children : [children]
// Use useEffect to update branches when they change
useEffect(() => {
if (branches.length !== childrenArray.length) {
setBranches(childrenArray)
}
}, [childrenArray, branches, setBranches])
return childrenArray.map((branch, index) => (
<div
className={cn(
"grid gap-2 overflow-hidden [&>div]:pb-0",
index === currentBranch ? "block" : "hidden",
)}
key={branch.key}
{...props}
>
{branch}
</div>
))
}
export type MessageBranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"]
}
export const MessageBranchSelector = ({
className,
from,
...props
}: MessageBranchSelectorProps) => {
const { totalBranches } = useMessageBranch()
// Don't render if there's only one branch
if (totalBranches <= 1) {
return null
}
return (
<ButtonGroup
className="[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md"
orientation="horizontal"
{...props}
/>
)
}
export type MessageBranchPreviousProps = ComponentProps<typeof Button>
export const MessageBranchPrevious = ({ children, ...props }: MessageBranchPreviousProps) => {
const { goToPrevious, totalBranches } = useMessageBranch()
return (
<Button
aria-label="Previous branch"
disabled={totalBranches <= 1}
onClick={goToPrevious}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronLeftIcon size={14} />}
</Button>
)
}
export type MessageBranchNextProps = ComponentProps<typeof Button>
export const MessageBranchNext = ({ children, className, ...props }: MessageBranchNextProps) => {
const { goToNext, totalBranches } = useMessageBranch()
return (
<Button
aria-label="Next branch"
disabled={totalBranches <= 1}
onClick={goToNext}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronRightIcon size={14} />}
</Button>
)
}
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>
export const MessageBranchPage = ({ className, ...props }: MessageBranchPageProps) => {
const { currentBranch, totalBranches } = useMessageBranch()
return (
<ButtonGroupText
className={cn("border-none bg-transparent text-muted-foreground shadow-none", className)}
{...props}
>
{currentBranch + 1} of {totalBranches}
</ButtonGroupText>
)
}
export type MessageResponseProps = ComponentProps<typeof Streamdown>
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<Streamdown
className={cn("size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0", className)}
{...props}
/>
),
(prevProps, nextProps) => prevProps.children === nextProps.children,
)
MessageResponse.displayName = "MessageResponse"
export type MessageAttachmentProps = HTMLAttributes<HTMLDivElement> & {
data: FileUIPart
className?: string
onRemove?: () => void
}
export function MessageAttachment({ data, className, onRemove, ...props }: MessageAttachmentProps) {
const filename = data.filename || ""
const mediaType = data.mediaType?.startsWith("image/") && data.url ? "image" : "file"
const isImage = mediaType === "image"
const attachmentLabel = filename || (isImage ? "Image" : "Attachment")
return (
<div className={cn("group relative size-24 overflow-hidden rounded-lg", className)} {...props}>
{isImage ? (
<>
<img
alt={filename || "attachment"}
className="size-full object-cover"
height={100}
src={data.url}
width={100}
/>
{onRemove && (
<Button
aria-label="Remove attachment"
className="absolute top-2 right-2 size-6 rounded-full bg-background/80 p-0 opacity-0 backdrop-blur-sm transition-opacity hover:bg-background group-hover:opacity-100 [&>svg]:size-3"
onClick={e => {
e.stopPropagation()
onRemove()
}}
type="button"
variant="ghost"
>
<XIcon />
<span className="sr-only">Remove</span>
</Button>
)}
</>
) : (
<>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex size-full shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<PaperclipIcon className="size-4" />
</div>
</TooltipTrigger>
<TooltipContent>
<p>{attachmentLabel}</p>
</TooltipContent>
</Tooltip>
{onRemove && (
<Button
aria-label="Remove attachment"
className="size-6 shrink-0 rounded-full p-0 opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100 [&>svg]:size-3"
onClick={e => {
e.stopPropagation()
onRemove()
}}
type="button"
variant="ghost"
>
<XIcon />
<span className="sr-only">Remove</span>
</Button>
)}
</>
)}
</div>
)
}
export type MessageAttachmentsProps = ComponentProps<"div">
export function MessageAttachments({ children, className, ...props }: MessageAttachmentsProps) {
if (!children) {
return null
}
return (
<div className={cn("ml-auto flex w-fit flex-wrap items-start gap-2", className)} {...props}>
{children}
</div>
)
}
export type MessageToolbarProps = ComponentProps<"div">
export const MessageToolbar = ({ className, children, ...props }: MessageToolbarProps) => (
<div className={cn("mt-4 flex w-full items-center justify-between gap-4", className)} {...props}>
{children}
</div>
)
/** Demo component for preview */
export default function MessageDemo() {
return (
<TooltipProvider>
<div className="flex w-full max-w-2xl flex-col gap-4 p-6">
<Message from="user">
<MessageContent>
<p>Can you explain how React hooks work?</p>
</MessageContent>
</Message>
<Message from="assistant">
<MessageContent>
<MessageResponse>
React hooks are functions that let you **hook into** React state and lifecycle
features from function components. The most common hooks are: - `useState` - for
managing local state - `useEffect` - for side effects like data fetching -
`useContext` - for consuming context values Would you like me to show you some
examples?
</MessageResponse>
</MessageContent>
</Message>
<Message from="user">
<MessageContent>
<p>Yes please, show me a useState example!</p>
</MessageContent>
</Message>
</div>
</TooltipProvider>
)
}

View File

@ -0,0 +1,355 @@
"use client"
import { useControllableState } from "@radix-ui/react-use-controllable-state"
import { ChevronsUpDownIcon, MicIcon } from "lucide-react"
import {
type ComponentProps,
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { cn } from "@/lib/utils"
const deviceIdRegex = /\(([\da-fA-F]{4}:[\da-fA-F]{4})\)$/
interface MicSelectorContextType {
data: MediaDeviceInfo[]
value: string | undefined
onValueChange?: (value: string) => void
open: boolean
onOpenChange?: (open: boolean) => void
width: number
setWidth?: (width: number) => void
}
const MicSelectorContext = createContext<MicSelectorContextType>({
data: [],
value: undefined,
onValueChange: undefined,
open: false,
onOpenChange: undefined,
width: 200,
setWidth: undefined,
})
export type MicSelectorProps = ComponentProps<typeof Popover> & {
defaultValue?: string
value?: string | undefined
onValueChange?: (value: string | undefined) => void
open?: boolean
onOpenChange?: (open: boolean) => void
}
export const useAudioDevices = () => {
const [devices, setDevices] = useState<MediaDeviceInfo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [hasPermission, setHasPermission] = useState(false)
const loadDevicesWithoutPermission = useCallback(async () => {
try {
setLoading(true)
setError(null)
const deviceList = await navigator.mediaDevices.enumerateDevices()
const audioInputs = deviceList.filter(device => device.kind === "audioinput")
setDevices(audioInputs)
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to get audio devices"
setError(message)
console.error("Error getting audio devices:", message)
} finally {
setLoading(false)
}
}, [])
const loadDevicesWithPermission = useCallback(async () => {
if (loading) {
return
}
try {
setLoading(true)
setError(null)
const tempStream = await navigator.mediaDevices.getUserMedia({ audio: true })
for (const track of tempStream.getTracks()) {
track.stop()
}
const deviceList = await navigator.mediaDevices.enumerateDevices()
const audioInputs = deviceList.filter(device => device.kind === "audioinput")
setDevices(audioInputs)
setHasPermission(true)
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to get audio devices"
setError(message)
console.error("Error getting audio devices:", message)
} finally {
setLoading(false)
}
}, [loading])
useEffect(() => {
loadDevicesWithoutPermission()
}, [loadDevicesWithoutPermission])
useEffect(() => {
const handleDeviceChange = () => {
if (hasPermission) {
loadDevicesWithPermission()
} else {
loadDevicesWithoutPermission()
}
}
navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange)
return () => {
navigator.mediaDevices.removeEventListener("devicechange", handleDeviceChange)
}
}, [hasPermission, loadDevicesWithPermission, loadDevicesWithoutPermission])
return {
devices,
loading,
error,
hasPermission,
loadDevices: loadDevicesWithPermission,
}
}
export const MicSelector = ({
defaultValue,
value: controlledValue,
onValueChange: controlledOnValueChange,
defaultOpen = false,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
...props
}: MicSelectorProps) => {
const [value, onValueChange] = useControllableState<string | undefined>({
defaultProp: defaultValue,
prop: controlledValue,
onChange: controlledOnValueChange,
})
const [open, onOpenChange] = useControllableState({
defaultProp: defaultOpen,
prop: controlledOpen,
onChange: controlledOnOpenChange,
})
const [width, setWidth] = useState(200)
const { devices, loading, hasPermission, loadDevices } = useAudioDevices()
useEffect(() => {
if (open && !hasPermission && !loading) {
loadDevices()
}
}, [open, hasPermission, loading, loadDevices])
return (
<MicSelectorContext.Provider
value={{
data: devices,
value,
onValueChange,
open: open ?? false,
onOpenChange,
width,
setWidth,
}}
>
<Popover {...props} onOpenChange={onOpenChange} open={open} />
</MicSelectorContext.Provider>
)
}
export type MicSelectorTriggerProps = ComponentProps<typeof Button>
export const MicSelectorTrigger = ({ children, ...props }: MicSelectorTriggerProps) => {
const { setWidth } = useContext(MicSelectorContext)
const ref = useRef<HTMLButtonElement>(null)
useEffect(() => {
const resizeObserver = new ResizeObserver(entries => {
for (const entry of entries) {
const newWidth = (entry.target as HTMLElement).offsetWidth
if (newWidth) {
setWidth?.(newWidth)
}
}
})
if (ref.current) {
resizeObserver.observe(ref.current)
}
return () => {
resizeObserver.disconnect()
}
}, [setWidth])
return (
<PopoverTrigger asChild>
<Button variant="outline" {...props} ref={ref}>
{children}
<ChevronsUpDownIcon className="shrink-0 text-muted-foreground" size={16} />
</Button>
</PopoverTrigger>
)
}
export type MicSelectorContentProps = ComponentProps<typeof Command> & {
popoverOptions?: ComponentProps<typeof PopoverContent>
}
export const MicSelectorContent = ({
className,
popoverOptions,
...props
}: MicSelectorContentProps) => {
const { width, onValueChange, value } = useContext(MicSelectorContext)
return (
<PopoverContent className={cn("p-0", className)} style={{ width }} {...popoverOptions}>
<Command onValueChange={onValueChange} value={value} {...props} />
</PopoverContent>
)
}
export type MicSelectorInputProps = ComponentProps<typeof CommandInput>
export const MicSelectorInput = ({ ...props }: MicSelectorInputProps) => (
<CommandInput placeholder="Search microphones..." {...props} />
)
export type MicSelectorListProps = Omit<ComponentProps<typeof CommandList>, "children"> & {
children: (devices: MediaDeviceInfo[]) => ReactNode
}
export const MicSelectorList = ({ children, ...props }: MicSelectorListProps) => {
const { data } = useContext(MicSelectorContext)
return <CommandList {...props}>{children(data)}</CommandList>
}
export type MicSelectorEmptyProps = ComponentProps<typeof CommandEmpty>
export const MicSelectorEmpty = ({
children = "No microphone found.",
...props
}: MicSelectorEmptyProps) => <CommandEmpty {...props}>{children}</CommandEmpty>
export type MicSelectorItemProps = ComponentProps<typeof CommandItem>
export const MicSelectorItem = (props: MicSelectorItemProps) => {
const { onValueChange, onOpenChange } = useContext(MicSelectorContext)
return (
<CommandItem
onSelect={currentValue => {
onValueChange?.(currentValue)
onOpenChange?.(false)
}}
{...props}
/>
)
}
export type MicSelectorLabelProps = ComponentProps<"span"> & {
device: MediaDeviceInfo
}
export const MicSelectorLabel = ({ device, className, ...props }: MicSelectorLabelProps) => {
const matches = device.label.match(deviceIdRegex)
if (!matches) {
return (
<span className={className} {...props}>
{device.label || "Unknown Microphone"}
</span>
)
}
const [, deviceId] = matches
const name = device.label.replace(deviceIdRegex, "")
return (
<span className={className} {...props}>
<span>{name}</span>
<span className="text-muted-foreground"> ({deviceId})</span>
</span>
)
}
export type MicSelectorValueProps = ComponentProps<"span">
export const MicSelectorValue = ({ className, ...props }: MicSelectorValueProps) => {
const { data, value } = useContext(MicSelectorContext)
const currentDevice = data.find(d => d.deviceId === value)
if (!currentDevice) {
return (
<span className={cn("flex-1 text-left", className)} {...props}>
Select microphone...
</span>
)
}
return (
<MicSelectorLabel
className={cn("flex-1 text-left", className)}
device={currentDevice}
{...props}
/>
)
}
/** Demo component for preview */
export default function MicSelectorDemo() {
const [selectedMic, setSelectedMic] = useState<string | undefined>()
return (
<div className="w-full max-w-sm p-4">
<MicSelector value={selectedMic} onValueChange={setSelectedMic}>
<MicSelectorTrigger className="w-full justify-between">
<div className="flex items-center gap-2">
<MicIcon className="size-4" />
<MicSelectorValue />
</div>
</MicSelectorTrigger>
<MicSelectorContent>
<MicSelectorInput />
<MicSelectorList>
{devices => (
<>
<MicSelectorEmpty />
{devices.map(device => (
<MicSelectorItem key={device.deviceId} value={device.deviceId}>
<MicSelectorLabel device={device} />
</MicSelectorItem>
))}
</>
)}
</MicSelectorList>
</MicSelectorContent>
</MicSelector>
</div>
)
}

View File

@ -0,0 +1,266 @@
"use client"
import type { ComponentProps, ReactNode } from "react"
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command"
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { cn } from "@/lib/utils"
export type ModelSelectorProps = ComponentProps<typeof Dialog>
export const ModelSelector = (props: ModelSelectorProps) => <Dialog {...props} />
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
<DialogTrigger {...props} />
)
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {
title?: ReactNode
}
export const ModelSelectorContent = ({
className,
children,
title = "Model Selector",
...props
}: ModelSelectorContentProps) => (
<DialogContent className={cn("p-0", className)} {...props}>
<DialogTitle className="sr-only">{title}</DialogTitle>
<Command className="**:data-[slot=command-input-wrapper]:h-auto">{children}</Command>
</DialogContent>
)
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => <CommandDialog {...props} />
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>
export const ModelSelectorInput = ({ className, ...props }: ModelSelectorInputProps) => (
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
)
export type ModelSelectorListProps = ComponentProps<typeof CommandList>
export const ModelSelectorList = (props: ModelSelectorListProps) => <CommandList {...props} />
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>
export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => <CommandEmpty {...props} />
export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>
export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => <CommandGroup {...props} />
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>
export const ModelSelectorItem = (props: ModelSelectorItemProps) => <CommandItem {...props} />
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>
export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
<CommandShortcut {...props} />
)
export type ModelSelectorSeparatorProps = ComponentProps<typeof CommandSeparator>
export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
<CommandSeparator {...props} />
)
export type ModelSelectorLogoProps = Omit<ComponentProps<"img">, "src" | "alt"> & {
provider:
| "moonshotai-cn"
| "lucidquery"
| "moonshotai"
| "zai-coding-plan"
| "alibaba"
| "xai"
| "vultr"
| "nvidia"
| "upstage"
| "groq"
| "github-copilot"
| "mistral"
| "vercel"
| "nebius"
| "deepseek"
| "alibaba-cn"
| "google-vertex-anthropic"
| "venice"
| "chutes"
| "cortecs"
| "github-models"
| "togetherai"
| "azure"
| "baseten"
| "huggingface"
| "opencode"
| "fastrouter"
| "google"
| "google-vertex"
| "cloudflare-workers-ai"
| "inception"
| "wandb"
| "openai"
| "zhipuai-coding-plan"
| "perplexity"
| "openrouter"
| "zenmux"
| "v0"
| "iflowcn"
| "synthetic"
| "deepinfra"
| "zhipuai"
| "submodel"
| "zai"
| "inference"
| "requesty"
| "morph"
| "lmstudio"
| "anthropic"
| "aihubmix"
| "fireworks-ai"
| "modelscope"
| "llama"
| "scaleway"
| "amazon-bedrock"
| "cerebras"
| (string & {})
}
export const ModelSelectorLogo = ({ provider, className, ...props }: ModelSelectorLogoProps) => (
<img
{...props}
alt={`${provider} logo`}
className={cn("size-3 dark:invert", className)}
height={12}
src={`https://models.dev/logos/${provider}.svg`}
width={12}
/>
)
export type ModelSelectorLogoGroupProps = ComponentProps<"div">
export const ModelSelectorLogoGroup = ({ className, ...props }: ModelSelectorLogoGroupProps) => (
<div
className={cn(
"-space-x-1 flex shrink-0 items-center [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground",
className,
)}
{...props}
/>
)
export type ModelSelectorNameProps = ComponentProps<"span">
export const ModelSelectorName = ({ className, ...props }: ModelSelectorNameProps) => (
<span className={cn("flex-1 truncate text-left", className)} {...props} />
)
import { CheckIcon } from "lucide-react"
import { useState } from "react"
import { Button } from "@/components/ui/button"
const models = [
{
id: "gpt-4o",
name: "GPT-4o",
chef: "OpenAI",
chefSlug: "openai",
providers: ["openai", "azure"],
},
{
id: "gpt-4o-mini",
name: "GPT-4o Mini",
chef: "OpenAI",
chefSlug: "openai",
providers: ["openai"],
},
{
id: "claude-sonnet-4-20250514",
name: "Claude 4 Sonnet",
chef: "Anthropic",
chefSlug: "anthropic",
providers: ["anthropic"],
},
{
id: "gemini-2.0-flash",
name: "Gemini 2.0 Flash",
chef: "Google",
chefSlug: "google",
providers: ["google"],
},
]
/** Demo component for preview */
export default function ModelSelectorDemo() {
const [open, setOpen] = useState(false)
const [selectedModel, setSelectedModel] = useState<string>("gpt-4o")
const selectedModelData = models.find(model => model.id === selectedModel)
const chefs = Array.from(new Set(models.map(model => model.chef)))
return (
<div className="flex size-full items-center justify-center p-8">
<ModelSelector onOpenChange={setOpen} open={open}>
<ModelSelectorTrigger asChild>
<Button className="w-[200px] justify-between" variant="outline">
{selectedModelData?.chefSlug && (
<ModelSelectorLogo provider={selectedModelData.chefSlug} />
)}
{selectedModelData?.name && (
<ModelSelectorName>{selectedModelData.name}</ModelSelectorName>
)}
</Button>
</ModelSelectorTrigger>
<ModelSelectorContent>
<ModelSelectorInput placeholder="Search models..." />
<ModelSelectorList>
<ModelSelectorEmpty>No models found.</ModelSelectorEmpty>
{chefs.map(chef => (
<ModelSelectorGroup heading={chef} key={chef}>
{models
.filter(model => model.chef === chef)
.map(model => (
<ModelSelectorItem
key={model.id}
onSelect={() => {
setSelectedModel(model.id)
setOpen(false)
}}
value={model.id}
>
<ModelSelectorLogo provider={model.chefSlug} />
<ModelSelectorName>{model.name}</ModelSelectorName>
<ModelSelectorLogoGroup>
{model.providers.map(provider => (
<ModelSelectorLogo key={provider} provider={provider} />
))}
</ModelSelectorLogoGroup>
{selectedModel === model.id ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</ModelSelectorItem>
))}
</ModelSelectorGroup>
))}
</ModelSelectorList>
</ModelSelectorContent>
</ModelSelector>
</div>
)
}

188
src/components/ai/node.tsx Executable file
View File

@ -0,0 +1,188 @@
"use client"
import { Handle, Position } from "@xyflow/react"
import type { ComponentProps } from "react"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { cn } from "@/lib/utils"
export type NodeProps = ComponentProps<typeof Card> & {
handles: {
target: boolean
source: boolean
}
}
export const Node = ({ handles, className, ...props }: NodeProps) => (
<Card
className={cn("node-container relative size-full h-auto w-sm gap-0 rounded-md p-0", className)}
{...props}
>
{handles.target && <Handle position={Position.Left} type="target" />}
{handles.source && <Handle position={Position.Right} type="source" />}
{props.children}
</Card>
)
export type NodeHeaderProps = ComponentProps<typeof CardHeader>
export const NodeHeader = ({ className, ...props }: NodeHeaderProps) => (
<CardHeader
className={cn("gap-0.5 rounded-t-md border-b bg-secondary p-3!", className)}
{...props}
/>
)
export type NodeTitleProps = ComponentProps<typeof CardTitle>
export const NodeTitle = (props: NodeTitleProps) => <CardTitle {...props} />
export type NodeDescriptionProps = ComponentProps<typeof CardDescription>
export const NodeDescription = (props: NodeDescriptionProps) => <CardDescription {...props} />
export type NodeActionProps = ComponentProps<typeof CardAction>
export const NodeAction = (props: NodeActionProps) => <CardAction {...props} />
export type NodeContentProps = ComponentProps<typeof CardContent>
export const NodeContent = ({ className, ...props }: NodeContentProps) => (
<CardContent className={cn("p-3", className)} {...props} />
)
export type NodeFooterProps = ComponentProps<typeof CardFooter>
export const NodeFooter = ({ className, ...props }: NodeFooterProps) => (
<CardFooter className={cn("rounded-b-md border-t bg-secondary p-3!", className)} {...props} />
)
import { Background, type Edge, ReactFlow, ReactFlowProvider } from "@xyflow/react"
import { BrainIcon, CheckCircleIcon, DatabaseIcon, SendIcon, ZapIcon } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import "@xyflow/react/dist/style.css"
const TriggerNode = ({ data }: { data: { label: string } }) => (
<Node handles={{ target: false, source: true }} className="w-48">
<NodeHeader>
<NodeTitle className="flex items-center gap-2 text-sm">
<ZapIcon className="size-3.5 text-yellow-500" />
{data.label}
</NodeTitle>
</NodeHeader>
<NodeContent className="py-2">
<p className="text-muted-foreground text-xs">Starts the workflow</p>
</NodeContent>
</Node>
)
const AgentNode = ({ data }: { data: { label: string; model: string } }) => (
<Node handles={{ target: true, source: true }} className="w-56">
<NodeHeader>
<NodeTitle className="flex items-center gap-2 text-sm">
<BrainIcon className="size-3.5 text-purple-500" />
{data.label}
</NodeTitle>
<NodeDescription className="text-xs">{data.model}</NodeDescription>
</NodeHeader>
<NodeContent className="py-2">
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="text-xs">
search
</Badge>
<Badge variant="outline" className="text-xs">
code
</Badge>
</div>
</NodeContent>
</Node>
)
const DataNode = ({ data }: { data: { label: string; source: string } }) => (
<Node handles={{ target: true, source: true }} className="w-48">
<NodeHeader>
<NodeTitle className="flex items-center gap-2 text-sm">
<DatabaseIcon className="size-3.5 text-blue-500" />
{data.label}
</NodeTitle>
</NodeHeader>
<NodeContent className="py-2">
<p className="font-mono text-muted-foreground text-xs">{data.source}</p>
</NodeContent>
</Node>
)
const OutputNode = ({ data }: { data: { label: string } }) => (
<Node handles={{ target: true, source: false }} className="w-48">
<NodeHeader>
<NodeTitle className="flex items-center gap-2 text-sm">
<SendIcon className="size-3.5 text-green-500" />
{data.label}
</NodeTitle>
</NodeHeader>
<NodeContent className="py-2">
<div className="flex items-center gap-1.5 text-green-600 text-xs">
<CheckCircleIcon className="size-3" />
Ready
</div>
</NodeContent>
</Node>
)
const nodeTypes = {
trigger: TriggerNode,
agent: AgentNode,
data: DataNode,
output: OutputNode,
}
const initialNodes = [
{ id: "1", type: "trigger", position: { x: 0, y: 100 }, data: { label: "User Input" } },
{
id: "2",
type: "data",
position: { x: 220, y: 0 },
data: { label: "Context", source: "vectordb" },
},
{
id: "3",
type: "agent",
position: { x: 220, y: 120 },
data: { label: "AI Agent", model: "claude-3.5-sonnet" },
},
{ id: "4", type: "output", position: { x: 500, y: 100 }, data: { label: "Response" } },
]
const initialEdges: Edge[] = [
{ id: "e1-3", source: "1", target: "3", type: "smoothstep" },
{ id: "e2-3", source: "2", target: "3", type: "smoothstep" },
{ id: "e3-4", source: "3", target: "4", type: "smoothstep" },
]
/** Demo component for preview */
export default function NodeDemo() {
return (
<div className="h-full min-h-[500px] w-full">
<ReactFlowProvider>
<ReactFlow
defaultNodes={initialNodes}
defaultEdges={initialEdges}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.3 }}
panOnScroll
proOptions={{ hideAttribution: true }}
>
<Background gap={16} size={1} />
</ReactFlow>
</ReactFlowProvider>
</div>
)
}

View File

@ -0,0 +1,352 @@
"use client"
import { ChevronDownIcon, ExternalLinkIcon, MessageCircleIcon } from "lucide-react"
import { type ComponentProps, createContext, useContext } from "react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils"
const providers = {
github: {
title: "Open in GitHub",
createUrl: (url: string) => url,
icon: (
<svg fill="currentColor" role="img" viewBox="0 0 24 24">
<title>GitHub</title>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
),
},
scira: {
title: "Open in Scira",
createUrl: (q: string) =>
`https://scira.ai/?${new URLSearchParams({
q,
})}`,
icon: (
<svg
fill="none"
height="934"
viewBox="0 0 910 934"
width="910"
xmlns="http://www.w3.org/2000/svg"
>
<title>Scira AI</title>
<path
d="M647.664 197.775C569.13 189.049 525.5 145.419 516.774 66.8849C508.048 145.419 464.418 189.049 385.884 197.775C464.418 206.501 508.048 250.131 516.774 328.665C525.5 250.131 569.13 206.501 647.664 197.775Z"
fill="currentColor"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="8"
/>
<path
d="M516.774 304.217C510.299 275.491 498.208 252.087 480.335 234.214C462.462 216.341 439.058 204.251 410.333 197.775C439.059 191.3 462.462 179.209 480.335 161.336C498.208 143.463 510.299 120.06 516.774 91.334C523.25 120.059 535.34 143.463 553.213 161.336C571.086 179.209 594.49 191.3 623.216 197.775C594.49 204.251 571.086 216.341 553.213 234.214C535.34 252.087 523.25 275.491 516.774 304.217Z"
fill="currentColor"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="8"
/>
<path
d="M857.5 508.116C763.259 497.644 710.903 445.288 700.432 351.047C689.961 445.288 637.605 497.644 543.364 508.116C637.605 518.587 689.961 570.943 700.432 665.184C710.903 570.943 763.259 518.587 857.5 508.116Z"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="20"
/>
<path
d="M700.432 615.957C691.848 589.05 678.575 566.357 660.383 548.165C642.191 529.973 619.499 516.7 592.593 508.116C619.499 499.533 642.191 486.258 660.383 468.066C678.575 449.874 691.848 427.181 700.432 400.274C709.015 427.181 722.289 449.874 740.481 468.066C758.673 486.258 781.365 499.533 808.271 508.116C781.365 516.7 758.673 529.973 740.481 548.165C722.289 566.357 709.015 589.05 700.432 615.957Z"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="20"
/>
<path
d="M889.949 121.237C831.049 114.692 798.326 81.9698 791.782 23.0692C785.237 81.9698 752.515 114.692 693.614 121.237C752.515 127.781 785.237 160.504 791.782 219.404C798.326 160.504 831.049 127.781 889.949 121.237Z"
fill="currentColor"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="8"
/>
<path
d="M791.782 196.795C786.697 176.937 777.869 160.567 765.16 147.858C752.452 135.15 736.082 126.322 716.226 121.237C736.082 116.152 752.452 107.324 765.16 94.6152C777.869 81.9065 786.697 65.5368 791.782 45.6797C796.867 65.5367 805.695 81.9066 818.403 94.6152C831.112 107.324 847.481 116.152 867.338 121.237C847.481 126.322 831.112 135.15 818.403 147.858C805.694 160.567 796.867 176.937 791.782 196.795Z"
fill="currentColor"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="8"
/>
<path
d="M760.632 764.337C720.719 814.616 669.835 855.1 611.872 882.692C553.91 910.285 490.404 924.255 426.213 923.533C362.022 922.812 298.846 907.419 241.518 878.531C184.19 849.643 134.228 808.026 95.4548 756.863C56.6815 705.7 30.1238 646.346 17.8129 583.343C5.50207 520.339 7.76433 455.354 24.4266 393.359C41.089 331.364 71.7099 274.001 113.947 225.658C156.184 177.315 208.919 139.273 268.117 114.442"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="30"
/>
</svg>
),
},
chatgpt: {
title: "Open in ChatGPT",
createUrl: (prompt: string) =>
`https://chatgpt.com/?${new URLSearchParams({
hints: "search",
prompt,
})}`,
icon: (
<svg fill="currentColor" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>OpenAI</title>
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
</svg>
),
},
claude: {
title: "Open in Claude",
createUrl: (q: string) =>
`https://claude.ai/new?${new URLSearchParams({
q,
})}`,
icon: (
<svg fill="currentColor" role="img" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
<title>Claude</title>
<path
clipRule="evenodd"
d="M2.3545 7.9775L4.7145 6.654L4.7545 6.539L4.7145 6.475H4.6L4.205 6.451L2.856 6.4145L1.6865 6.366L0.5535 6.305L0.268 6.2445L0 5.892L0.0275 5.716L0.2675 5.5555L0.6105 5.5855L1.3705 5.637L2.5095 5.716L3.3355 5.7645L4.56 5.892H4.7545L4.782 5.8135L4.715 5.7645L4.6635 5.716L3.4845 4.918L2.2085 4.074L1.5405 3.588L1.1785 3.3425L0.9965 3.1115L0.9175 2.6075L1.2455 2.2465L1.686 2.2765L1.7985 2.307L2.245 2.65L3.199 3.388L4.4445 4.3045L4.627 4.4565L4.6995 4.405L4.709 4.3685L4.627 4.2315L3.9495 3.0085L3.2265 1.7635L2.9045 1.2475L2.8195 0.938C2.78711 0.819128 2.76965 0.696687 2.7675 0.5735L3.1415 0.067L3.348 0L3.846 0.067L4.056 0.249L4.366 0.956L4.867 2.0705L5.6445 3.5855L5.8725 4.0345L5.994 4.4505L6.0395 4.578H6.1185V4.505L6.1825 3.652L6.301 2.6045L6.416 1.257L6.456 0.877L6.644 0.422L7.0175 0.176L7.3095 0.316L7.5495 0.6585L7.516 0.8805L7.373 1.806L7.0935 3.2575L6.9115 4.2285H7.0175L7.139 4.1075L7.6315 3.4545L8.4575 2.4225L8.8225 2.0125L9.2475 1.5605L9.521 1.345H10.0375L10.4175 1.9095L10.2475 2.4925L9.7155 3.166L9.275 3.737L8.643 4.587L8.248 5.267L8.2845 5.322L8.3785 5.312L9.8065 5.009L10.578 4.869L11.4985 4.7115L11.915 4.9055L11.9605 5.103L11.7965 5.5065L10.812 5.7495L9.6575 5.9805L7.938 6.387L7.917 6.402L7.9415 6.4325L8.716 6.5055L9.047 6.5235H9.858L11.368 6.636L11.763 6.897L12 7.216L11.9605 7.4585L11.353 7.7685L10.533 7.574L8.6185 7.119L7.9625 6.9545H7.8715V7.0095L8.418 7.5435L9.421 8.4485L10.6755 9.6135L10.739 9.9025L10.578 10.13L10.408 10.1055L9.3055 9.277L8.88 8.9035L7.917 8.0935H7.853V8.1785L8.075 8.503L9.2475 10.2635L9.3085 10.8035L9.2235 10.98L8.9195 11.0865L8.5855 11.0255L7.8985 10.063L7.191 8.9795L6.6195 8.008L6.5495 8.048L6.2125 11.675L6.0545 11.86L5.69 12L5.3865 11.7695L5.2255 11.396L5.3865 10.658L5.581 9.696L5.7385 8.931L5.8815 7.981L5.9665 7.665L5.9605 7.644L5.8905 7.653L5.1735 8.6365L4.0835 10.109L3.2205 11.0315L3.0135 11.1135L2.655 10.9285L2.6885 10.5975L2.889 10.303L4.083 8.785L4.803 7.844L5.268 7.301L5.265 7.222H5.2375L2.066 9.28L1.501 9.353L1.2575 9.125L1.288 8.752L1.4035 8.6305L2.3575 7.9745L2.3545 7.9775Z"
fillRule="evenodd"
/>
</svg>
),
},
t3: {
title: "Open in T3 Chat",
createUrl: (q: string) =>
`https://t3.chat/new?${new URLSearchParams({
q,
})}`,
icon: <MessageCircleIcon />,
},
v0: {
title: "Open in v0",
createUrl: (q: string) =>
`https://v0.app?${new URLSearchParams({
q,
})}`,
icon: (
<svg fill="currentColor" viewBox="0 0 147 70" xmlns="http://www.w3.org/2000/svg">
<title>v0</title>
<path d="M56 50.2031V14H70V60.1562C70 65.5928 65.5928 70 60.1562 70C57.5605 70 54.9982 68.9992 53.1562 67.1573L0 14H19.7969L56 50.2031Z" />
<path d="M147 56H133V23.9531L100.953 56H133V70H96.6875C85.8144 70 77 61.1856 77 50.3125V14H91V46.1562L123.156 14H91V0H127.312C138.186 0 147 8.81439 147 19.6875V56Z" />
</svg>
),
},
cursor: {
title: "Open in Cursor",
createUrl: (text: string) => {
const url = new URL("https://cursor.com/link/prompt")
url.searchParams.set("text", text)
return url.toString()
},
icon: (
<svg version="1.1" viewBox="0 0 466.73 532.09" xmlns="http://www.w3.org/2000/svg">
<title>Cursor</title>
<path
d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"
fill="currentColor"
/>
</svg>
),
},
}
const OpenInContext = createContext<{ query: string } | undefined>(undefined)
const useOpenInContext = () => {
const context = useContext(OpenInContext)
if (!context) {
throw new Error("OpenIn components must be used within an OpenIn provider")
}
return context
}
export type OpenInProps = ComponentProps<typeof DropdownMenu> & {
query: string
}
export const OpenIn = ({ query, ...props }: OpenInProps) => (
<OpenInContext.Provider value={{ query }}>
<DropdownMenu {...props} />
</OpenInContext.Provider>
)
export type OpenInContentProps = ComponentProps<typeof DropdownMenuContent>
export const OpenInContent = ({ className, ...props }: OpenInContentProps) => (
<DropdownMenuContent align="start" className={cn("w-[240px]", className)} {...props} />
)
export type OpenInItemProps = ComponentProps<typeof DropdownMenuItem>
export const OpenInItem = (props: OpenInItemProps) => <DropdownMenuItem {...props} />
export type OpenInLabelProps = ComponentProps<typeof DropdownMenuLabel>
export const OpenInLabel = (props: OpenInLabelProps) => <DropdownMenuLabel {...props} />
export type OpenInSeparatorProps = ComponentProps<typeof DropdownMenuSeparator>
export const OpenInSeparator = (props: OpenInSeparatorProps) => <DropdownMenuSeparator {...props} />
export type OpenInTriggerProps = ComponentProps<typeof DropdownMenuTrigger>
export const OpenInTrigger = ({ children, ...props }: OpenInTriggerProps) => (
<DropdownMenuTrigger {...props} asChild>
{children ?? (
<Button type="button" variant="outline">
Open in chat
<ChevronDownIcon className="size-4" />
</Button>
)}
</DropdownMenuTrigger>
)
export type OpenInChatGPTProps = ComponentProps<typeof DropdownMenuItem>
export const OpenInChatGPT = (props: OpenInChatGPTProps) => {
const { query } = useOpenInContext()
return (
<DropdownMenuItem asChild {...props}>
<a
className="flex items-center gap-2"
href={providers.chatgpt.createUrl(query)}
rel="noopener"
target="_blank"
>
<span className="shrink-0">{providers.chatgpt.icon}</span>
<span className="flex-1">{providers.chatgpt.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</a>
</DropdownMenuItem>
)
}
export type OpenInClaudeProps = ComponentProps<typeof DropdownMenuItem>
export const OpenInClaude = (props: OpenInClaudeProps) => {
const { query } = useOpenInContext()
return (
<DropdownMenuItem asChild {...props}>
<a
className="flex items-center gap-2"
href={providers.claude.createUrl(query)}
rel="noopener"
target="_blank"
>
<span className="shrink-0">{providers.claude.icon}</span>
<span className="flex-1">{providers.claude.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</a>
</DropdownMenuItem>
)
}
export type OpenInT3Props = ComponentProps<typeof DropdownMenuItem>
export const OpenInT3 = (props: OpenInT3Props) => {
const { query } = useOpenInContext()
return (
<DropdownMenuItem asChild {...props}>
<a
className="flex items-center gap-2"
href={providers.t3.createUrl(query)}
rel="noopener"
target="_blank"
>
<span className="shrink-0">{providers.t3.icon}</span>
<span className="flex-1">{providers.t3.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</a>
</DropdownMenuItem>
)
}
export type OpenInSciraProps = ComponentProps<typeof DropdownMenuItem>
export const OpenInScira = (props: OpenInSciraProps) => {
const { query } = useOpenInContext()
return (
<DropdownMenuItem asChild {...props}>
<a
className="flex items-center gap-2"
href={providers.scira.createUrl(query)}
rel="noopener"
target="_blank"
>
<span className="shrink-0">{providers.scira.icon}</span>
<span className="flex-1">{providers.scira.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</a>
</DropdownMenuItem>
)
}
export type OpenInv0Props = ComponentProps<typeof DropdownMenuItem>
export const OpenInv0 = (props: OpenInv0Props) => {
const { query } = useOpenInContext()
return (
<DropdownMenuItem asChild {...props}>
<a
className="flex items-center gap-2"
href={providers.v0.createUrl(query)}
rel="noopener"
target="_blank"
>
<span className="shrink-0">{providers.v0.icon}</span>
<span className="flex-1">{providers.v0.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</a>
</DropdownMenuItem>
)
}
export type OpenInCursorProps = ComponentProps<typeof DropdownMenuItem>
export const OpenInCursor = (props: OpenInCursorProps) => {
const { query } = useOpenInContext()
return (
<DropdownMenuItem asChild {...props}>
<a
className="flex items-center gap-2"
href={providers.cursor.createUrl(query)}
rel="noopener"
target="_blank"
>
<span className="shrink-0">{providers.cursor.icon}</span>
<span className="flex-1">{providers.cursor.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</a>
</DropdownMenuItem>
)
}
/** Demo component for preview */
export default function OpenInChatDemo() {
const sampleQuery = "How can I implement authentication in Next.js?"
return (
<OpenIn query={sampleQuery}>
<OpenInTrigger />
<OpenInContent>
<OpenInChatGPT />
<OpenInClaude />
<OpenInCursor />
<OpenInT3 />
<OpenInScira />
<OpenInv0 />
</OpenInContent>
</OpenIn>
)
}

View File

@ -0,0 +1,237 @@
"use client"
import { ArrowRightIcon, MinusIcon, PackageIcon, PlusIcon } from "lucide-react"
import { createContext, type HTMLAttributes, type ReactNode, useContext } from "react"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
type ChangeType = "major" | "minor" | "patch" | "added" | "removed"
interface PackageInfoContextType {
name: string
currentVersion?: string
newVersion?: string
changeType?: ChangeType
}
const PackageInfoContext = createContext<PackageInfoContextType>({
name: "",
})
export type PackageInfoProps = HTMLAttributes<HTMLDivElement> & {
name: string
currentVersion?: string
newVersion?: string
changeType?: ChangeType
}
export const PackageInfo = ({
name,
currentVersion,
newVersion,
changeType,
className,
children,
...props
}: PackageInfoProps) => (
<PackageInfoContext.Provider value={{ name, currentVersion, newVersion, changeType }}>
<div className={cn("rounded-lg border bg-background p-4", className)} {...props}>
{children ?? (
<>
<PackageInfoHeader>
<PackageInfoName />
{changeType && <PackageInfoChangeType />}
</PackageInfoHeader>
{(currentVersion || newVersion) && <PackageInfoVersion />}
</>
)}
</div>
</PackageInfoContext.Provider>
)
export type PackageInfoHeaderProps = HTMLAttributes<HTMLDivElement>
export const PackageInfoHeader = ({ className, children, ...props }: PackageInfoHeaderProps) => (
<div className={cn("flex items-center justify-between gap-2", className)} {...props}>
{children}
</div>
)
export type PackageInfoNameProps = HTMLAttributes<HTMLDivElement>
export const PackageInfoName = ({ className, children, ...props }: PackageInfoNameProps) => {
const { name } = useContext(PackageInfoContext)
return (
<div className={cn("flex items-center gap-2", className)} {...props}>
<PackageIcon className="size-4 text-muted-foreground" />
<span className="font-medium font-mono text-sm">{children ?? name}</span>
</div>
)
}
const changeTypeStyles: Record<ChangeType, string> = {
major: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
minor: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400",
patch: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
added: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
removed: "bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400",
}
const changeTypeIcons: Record<ChangeType, ReactNode> = {
major: <ArrowRightIcon className="size-3" />,
minor: <ArrowRightIcon className="size-3" />,
patch: <ArrowRightIcon className="size-3" />,
added: <PlusIcon className="size-3" />,
removed: <MinusIcon className="size-3" />,
}
export type PackageInfoChangeTypeProps = HTMLAttributes<HTMLDivElement>
export const PackageInfoChangeType = ({
className,
children,
...props
}: PackageInfoChangeTypeProps) => {
const { changeType } = useContext(PackageInfoContext)
if (!changeType) {
return null
}
return (
<Badge
className={cn("gap-1 text-xs capitalize", changeTypeStyles[changeType], className)}
variant="secondary"
{...props}
>
{changeTypeIcons[changeType]}
{children ?? changeType}
</Badge>
)
}
export type PackageInfoVersionProps = HTMLAttributes<HTMLDivElement>
export const PackageInfoVersion = ({ className, children, ...props }: PackageInfoVersionProps) => {
const { currentVersion, newVersion } = useContext(PackageInfoContext)
if (!(currentVersion || newVersion)) {
return null
}
return (
<div
className={cn(
"mt-2 flex items-center gap-2 font-mono text-muted-foreground text-sm",
className,
)}
{...props}
>
{children ?? (
<>
{currentVersion && <span>{currentVersion}</span>}
{currentVersion && newVersion && <ArrowRightIcon className="size-3" />}
{newVersion && <span className="font-medium text-foreground">{newVersion}</span>}
</>
)}
</div>
)
}
export type PackageInfoDescriptionProps = HTMLAttributes<HTMLParagraphElement>
export const PackageInfoDescription = ({
className,
children,
...props
}: PackageInfoDescriptionProps) => (
<p className={cn("mt-2 text-muted-foreground text-sm", className)} {...props}>
{children}
</p>
)
export type PackageInfoContentProps = HTMLAttributes<HTMLDivElement>
export const PackageInfoContent = ({ className, children, ...props }: PackageInfoContentProps) => (
<div className={cn("mt-3 border-t pt-3", className)} {...props}>
{children}
</div>
)
export type PackageInfoDependenciesProps = HTMLAttributes<HTMLDivElement>
export const PackageInfoDependencies = ({
className,
children,
...props
}: PackageInfoDependenciesProps) => (
<div className={cn("space-y-2", className)} {...props}>
<span className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Dependencies
</span>
<div className="space-y-1">{children}</div>
</div>
)
export type PackageInfoDependencyProps = HTMLAttributes<HTMLDivElement> & {
name: string
version?: string
}
export const PackageInfoDependency = ({
name,
version,
className,
children,
...props
}: PackageInfoDependencyProps) => (
<div className={cn("flex items-center justify-between text-sm", className)} {...props}>
{children ?? (
<>
<span className="font-mono text-muted-foreground">{name}</span>
{version && <span className="font-mono text-xs">{version}</span>}
</>
)}
</div>
)
/** Demo component for preview */
export default function PackageInfoDemo() {
return (
<div className="flex w-full max-w-md flex-col gap-4 p-4">
<PackageInfo name="react" currentVersion="18.2.0" newVersion="19.0.0" changeType="major">
<PackageInfoHeader>
<PackageInfoName />
<PackageInfoChangeType />
</PackageInfoHeader>
<PackageInfoVersion />
<PackageInfoDescription>
A JavaScript library for building user interfaces
</PackageInfoDescription>
<PackageInfoContent>
<PackageInfoDependencies>
<PackageInfoDependency name="loose-envify" version="^1.1.0" />
<PackageInfoDependency name="scheduler" version="^0.23.0" />
</PackageInfoDependencies>
</PackageInfoContent>
</PackageInfo>
<PackageInfo name="@tanstack/react-query" changeType="added">
<PackageInfoHeader>
<PackageInfoName />
<PackageInfoChangeType />
</PackageInfoHeader>
<PackageInfoDescription>Powerful asynchronous state management</PackageInfoDescription>
</PackageInfo>
<PackageInfo name="lodash" currentVersion="4.17.21" changeType="removed">
<PackageInfoHeader>
<PackageInfoName />
<PackageInfoChangeType />
</PackageInfoHeader>
<PackageInfoVersion />
</PackageInfo>
</div>
)
}

58
src/components/ai/panel.tsx Executable file
View File

@ -0,0 +1,58 @@
"use client"
import { Panel as PanelPrimitive } from "@xyflow/react"
import type { ComponentProps } from "react"
import { cn } from "@/lib/utils"
type PanelProps = ComponentProps<typeof PanelPrimitive>
export const Panel = ({ className, ...props }: PanelProps) => (
<PanelPrimitive
className={cn("m-4 overflow-hidden rounded-md border bg-card p-1", className)}
{...props}
/>
)
import { Background, ReactFlow, ReactFlowProvider } from "@xyflow/react"
import { LayersIcon, PlusIcon, SettingsIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import "@xyflow/react/dist/style.css"
const SimpleNode = ({ data }: { data: { label: string } }) => (
<div className="rounded-md border bg-card px-3 py-1.5 text-xs font-medium">{data.label}</div>
)
const nodeTypes = { simple: SimpleNode }
const initialNodes = [
{ id: "1", type: "simple", position: { x: 180, y: 100 }, data: { label: "Workflow" } },
]
/** Demo component for preview */
export default function PanelDemo() {
return (
<div className="h-full w-full min-h-screen">
<ReactFlowProvider>
<ReactFlow defaultNodes={initialNodes} nodeTypes={nodeTypes} fitView panOnScroll>
<Background bgColor="var(--sidebar)" />
<Panel position="top-left">
<div className="flex items-center gap-1">
<Button size="icon-sm" variant="ghost">
<PlusIcon className="size-3.5" />
</Button>
<Button size="icon-sm" variant="ghost">
<LayersIcon className="size-3.5" />
</Button>
<Button size="icon-sm" variant="ghost">
<SettingsIcon className="size-3.5" />
</Button>
</div>
</Panel>
<Panel position="bottom-right">
<span className="px-2 text-muted-foreground text-xs">1 node</span>
</Panel>
</ReactFlow>
</ReactFlowProvider>
</div>
)
}

294
src/components/ai/persona.tsx Executable file
View File

@ -0,0 +1,294 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
"use client"
import {
type RiveParameters,
useRive,
useStateMachineInput,
useViewModel,
useViewModelInstance,
useViewModelInstanceColor,
} from "@rive-app/react-webgl2"
import type { FC, ReactNode } from "react"
import { memo, useEffect, useMemo, useRef, useState } from "react"
import { cn } from "@/lib/utils"
export type PersonaState = "idle" | "listening" | "thinking" | "speaking" | "asleep"
interface PersonaProps {
state: PersonaState
onLoad?: RiveParameters["onLoad"]
onLoadError?: RiveParameters["onLoadError"]
onReady?: () => void
onPause?: RiveParameters["onPause"]
onPlay?: RiveParameters["onPlay"]
onStop?: RiveParameters["onStop"]
className?: string
variant?: keyof typeof sources
}
const stateMachine = "default"
const sources = {
obsidian: {
source: "https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/obsidian-2.0.riv",
dynamicColor: true,
hasModel: true,
},
mana: {
source: "https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/mana-2.0.riv",
dynamicColor: false,
hasModel: true,
},
opal: {
source: "https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/orb-1.2.riv",
dynamicColor: false,
hasModel: false,
},
halo: {
source: "https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/halo-2.0.riv",
dynamicColor: true,
hasModel: true,
},
glint: {
source: "https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/glint-2.0.riv",
dynamicColor: true,
hasModel: true,
},
command: {
source: "https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/command-2.0.riv",
dynamicColor: true,
hasModel: true,
},
}
const getCurrentTheme = (): "light" | "dark" => {
if (typeof window !== "undefined") {
if (document.documentElement.classList.contains("dark")) {
return "dark"
}
if (window.matchMedia?.("(prefers-color-scheme: dark)").matches) {
return "dark"
}
}
return "light"
}
const useTheme = (enabled: boolean) => {
const [theme, setTheme] = useState<"light" | "dark">(getCurrentTheme)
useEffect(() => {
if (!enabled) {
return
}
const observer = new MutationObserver(() => {
setTheme(getCurrentTheme())
})
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
})
let mql: MediaQueryList | null = null
const handleMediaChange = () => {
setTheme(getCurrentTheme())
}
if (window.matchMedia) {
mql = window.matchMedia("(prefers-color-scheme: dark)")
mql.addEventListener("change", handleMediaChange)
}
return () => {
observer.disconnect()
if (mql) {
mql.removeEventListener("change", handleMediaChange)
}
}
}, [enabled])
return theme
}
interface PersonaWithModelProps {
rive: ReturnType<typeof useRive>["rive"]
source: (typeof sources)[keyof typeof sources]
children: React.ReactNode
}
const PersonaWithModel = memo(({ rive, source, children }: PersonaWithModelProps) => {
const theme = useTheme(source.dynamicColor)
const viewModel = useViewModel(rive, { useDefault: true })
const viewModelInstance = useViewModelInstance(viewModel, {
rive,
useDefault: true,
})
const viewModelInstanceColor = useViewModelInstanceColor("color", viewModelInstance)
useEffect(() => {
if (!(viewModelInstanceColor && source.dynamicColor)) {
return
}
const [r, g, b] = theme === "dark" ? [255, 255, 255] : [0, 0, 0]
viewModelInstanceColor.setRgb(r, g, b)
}, [viewModelInstanceColor, theme, source.dynamicColor])
return children
})
interface PersonaWithoutModelProps {
children: ReactNode
}
const PersonaWithoutModel = memo(({ children }: PersonaWithoutModelProps) => children)
export const Persona: FC<PersonaProps> = memo(
({
variant = "obsidian",
state = "idle",
onLoad,
onLoadError,
onReady,
onPause,
onPlay,
onStop,
className,
}) => {
const source = sources[variant]
if (!source) {
throw new Error(`Invalid variant: ${variant}`)
}
const callbacksRef = useRef({
onLoad,
onLoadError,
onReady,
onPause,
onPlay,
onStop,
})
callbacksRef.current = {
onLoad,
onLoadError,
onReady,
onPause,
onPlay,
onStop,
}
const stableCallbacks = useMemo(
() => ({
onLoad: (loadedRive =>
callbacksRef.current.onLoad?.(loadedRive)) as RiveParameters["onLoad"],
onLoadError: (err =>
callbacksRef.current.onLoadError?.(err)) as RiveParameters["onLoadError"],
onReady: () => callbacksRef.current.onReady?.(),
onPause: (event => callbacksRef.current.onPause?.(event)) as RiveParameters["onPause"],
onPlay: (event => callbacksRef.current.onPlay?.(event)) as RiveParameters["onPlay"],
onStop: (event => callbacksRef.current.onStop?.(event)) as RiveParameters["onStop"],
}),
[],
)
const { rive, RiveComponent } = useRive({
src: source.source,
stateMachines: stateMachine,
autoplay: true,
onLoad: stableCallbacks.onLoad,
onLoadError: stableCallbacks.onLoadError,
onRiveReady: stableCallbacks.onReady,
onPause: stableCallbacks.onPause,
onPlay: stableCallbacks.onPlay,
onStop: stableCallbacks.onStop,
})
const listeningInput = useStateMachineInput(rive, stateMachine, "listening")
const thinkingInput = useStateMachineInput(rive, stateMachine, "thinking")
const speakingInput = useStateMachineInput(rive, stateMachine, "speaking")
const asleepInput = useStateMachineInput(rive, stateMachine, "asleep")
useEffect(() => {
if (listeningInput) {
listeningInput.value = state === "listening"
}
if (thinkingInput) {
thinkingInput.value = state === "thinking"
}
if (speakingInput) {
speakingInput.value = state === "speaking"
}
if (asleepInput) {
asleepInput.value = state === "asleep"
}
}, [state, listeningInput, thinkingInput, speakingInput, asleepInput])
const Component = source.hasModel ? PersonaWithModel : PersonaWithoutModel
return (
<Component rive={rive} source={source}>
<RiveComponent className={cn("size-16 shrink-0", className)} />
</Component>
)
},
)
PersonaWithModel.displayName = "PersonaWithModel"
PersonaWithoutModel.displayName = "PersonaWithoutModel"
Persona.displayName = "Persona"
import { Button } from "@/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
const variants = ["obsidian", "mana", "opal", "halo", "glint", "command"] as const
/** Demo component for preview */
export default function PersonaDemo() {
const [state, setState] = useState<PersonaState>("thinking")
const [variant, setVariant] = useState<(typeof variants)[number]>("glint")
const states: PersonaState[] = ["idle", "listening", "thinking", "speaking", "asleep"]
return (
<div className="flex w-full max-w-sm flex-col items-center gap-6 p-6">
<div className="flex flex-col items-center gap-4">
<Persona key={variant} state={state} variant={variant} className="size-32" />
<Select value={variant} onValueChange={v => setVariant(v as typeof variant)}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{variants.map(v => (
<SelectItem key={v} value={v} className="capitalize">
{v}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-wrap justify-center gap-2">
{states.map(s => (
<Button
key={s}
onClick={() => setState(s)}
variant={state === s ? "default" : "outline"}
size="sm"
className="capitalize"
>
{s}
</Button>
))}
</div>
</div>
)
}

164
src/components/ai/plan.tsx Executable file
View File

@ -0,0 +1,164 @@
"use client"
import { ChevronsUpDownIcon, FileTextIcon } from "lucide-react"
import type { ComponentProps } from "react"
import { createContext, useContext } from "react"
import { Button } from "@/components/ui/button"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { cn } from "@/lib/utils"
import { Shimmer } from "@/components/ai/shimmer"
interface PlanContextValue {
isStreaming: boolean
}
const PlanContext = createContext<PlanContextValue | null>(null)
const usePlan = () => {
const context = useContext(PlanContext)
if (!context) {
throw new Error("Plan components must be used within Plan")
}
return context
}
export type PlanProps = ComponentProps<typeof Collapsible> & {
isStreaming?: boolean
}
export const Plan = ({ className, isStreaming = false, children, ...props }: PlanProps) => (
<PlanContext.Provider value={{ isStreaming }}>
<Collapsible asChild data-slot="plan" {...props}>
<Card className={cn("shadow-none", className)}>{children}</Card>
</Collapsible>
</PlanContext.Provider>
)
export type PlanHeaderProps = ComponentProps<typeof CardHeader>
export const PlanHeader = ({ className, ...props }: PlanHeaderProps) => (
<CardHeader
className={cn("flex items-start justify-between", className)}
data-slot="plan-header"
{...props}
/>
)
export type PlanTitleProps = Omit<ComponentProps<typeof CardTitle>, "children"> & {
children: string
}
export const PlanTitle = ({ children, ...props }: PlanTitleProps) => {
const { isStreaming } = usePlan()
return (
<CardTitle data-slot="plan-title" {...props}>
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
</CardTitle>
)
}
export type PlanDescriptionProps = Omit<ComponentProps<typeof CardDescription>, "children"> & {
children: string
}
export const PlanDescription = ({ className, children, ...props }: PlanDescriptionProps) => {
const { isStreaming } = usePlan()
return (
<CardDescription
className={cn("text-balance", className)}
data-slot="plan-description"
{...props}
>
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
</CardDescription>
)
}
export type PlanActionProps = ComponentProps<typeof CardAction>
export const PlanAction = (props: PlanActionProps) => (
<CardAction data-slot="plan-action" {...props} />
)
export type PlanContentProps = ComponentProps<typeof CardContent>
export const PlanContent = (props: PlanContentProps) => (
<CollapsibleContent asChild>
<CardContent data-slot="plan-content" {...props} />
</CollapsibleContent>
)
export type PlanFooterProps = ComponentProps<"div">
export const PlanFooter = (props: PlanFooterProps) => (
<CardFooter data-slot="plan-footer" {...props} />
)
export type PlanTriggerProps = ComponentProps<typeof CollapsibleTrigger>
export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => (
<CollapsibleTrigger asChild>
<Button
className={cn("size-8", className)}
data-slot="plan-trigger"
size="icon"
variant="ghost"
{...props}
>
<ChevronsUpDownIcon className="size-4" />
<span className="sr-only">Toggle plan</span>
</Button>
</CollapsibleTrigger>
)
/** Demo component for preview */
export default function PlanDemo() {
return (
<div className="w-full max-w-2xl p-6">
<Plan defaultOpen={true}>
<PlanHeader>
<div>
<div className="mb-4 flex items-center gap-2">
<FileTextIcon className="size-4" />
<PlanTitle>Rewrite AI Elements to SolidJS</PlanTitle>
</div>
<PlanDescription>
Rewrite the AI Elements component library from React to SolidJS while maintaining
compatibility with existing React-based shadcn/ui components.
</PlanDescription>
</div>
<PlanTrigger />
</PlanHeader>
<PlanContent>
<div className="space-y-4 text-sm">
<div>
<h3 className="mb-2 font-semibold">Key Steps</h3>
<ul className="list-inside list-disc space-y-1">
<li>Set up SolidJS project structure</li>
<li>Install solid-js/compat for React compatibility</li>
<li>Migrate components one by one</li>
<li>Update test suite for each component</li>
</ul>
</div>
</div>
</PlanContent>
<PlanFooter className="justify-end">
<PlanAction>
<Button size="sm">Build</Button>
</PlanAction>
</PlanFooter>
</Plan>
</div>
)
}

View File

@ -976,57 +976,11 @@ export const PromptInputSubmit = ({
) )
} }
interface SpeechRecognition extends EventTarget { type SpeechState = "idle" | "recording" | "transcribing"
continuous: boolean
interimResults: boolean
lang: string
start(): void
stop(): void
onstart: ((this: SpeechRecognition, ev: Event) => void) | null
onend: ((this: SpeechRecognition, ev: Event) => void) | null
onresult: ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => void) | null
onerror: ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => void) | null
}
interface SpeechRecognitionEvent extends Event { export type PromptInputSpeechButtonProps = ComponentProps<
results: SpeechRecognitionResultList typeof PromptInputButton
resultIndex: number > & {
}
interface SpeechRecognitionResultList {
readonly length: number
item(index: number): SpeechRecognitionResult
[index: number]: SpeechRecognitionResult
}
interface SpeechRecognitionResult {
readonly length: number
item(index: number): SpeechRecognitionAlternative
[index: number]: SpeechRecognitionAlternative
isFinal: boolean
}
interface SpeechRecognitionAlternative {
transcript: string
confidence: number
}
interface SpeechRecognitionErrorEvent extends Event {
error: string
}
declare global {
interface Window {
SpeechRecognition: {
new (): SpeechRecognition
}
webkitSpeechRecognition: {
new (): SpeechRecognition
}
}
}
export type PromptInputSpeechButtonProps = ComponentProps<typeof PromptInputButton> & {
textareaRef?: RefObject<HTMLTextAreaElement | null> textareaRef?: RefObject<HTMLTextAreaElement | null>
onTranscriptionChange?: (text: string) => void onTranscriptionChange?: (text: string) => void
} }
@ -1037,91 +991,126 @@ export const PromptInputSpeechButton = ({
onTranscriptionChange, onTranscriptionChange,
...props ...props
}: PromptInputSpeechButtonProps) => { }: PromptInputSpeechButtonProps) => {
const [isListening, setIsListening] = useState(false) const [state, setState] = useState<SpeechState>("idle")
const [recognition, setRecognition] = useState<SpeechRecognition | null>(null) const [supported, setSupported] = useState(true)
const recognitionRef = useRef<SpeechRecognition | null>(null) const recorderRef = useRef<MediaRecorder | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const chunksRef = useRef<Blob[]>([])
useEffect(() => { useEffect(() => {
if ( if (typeof navigator === "undefined" || !navigator.mediaDevices) {
typeof window !== "undefined" && setSupported(false)
("SpeechRecognition" in window || "webkitSpeechRecognition" in window)
) {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition
const speechRecognition = new SpeechRecognition()
speechRecognition.continuous = true
speechRecognition.interimResults = true
speechRecognition.lang = "en-US"
speechRecognition.onstart = () => {
setIsListening(true)
}
speechRecognition.onend = () => {
setIsListening(false)
}
speechRecognition.onresult = event => {
let finalTranscript = ""
for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i]
if (result.isFinal) {
finalTranscript += result[0]?.transcript ?? ""
}
}
if (finalTranscript && textareaRef?.current) {
const textarea = textareaRef.current
const currentValue = textarea.value
const newValue = currentValue + (currentValue ? " " : "") + finalTranscript
textarea.value = newValue
textarea.dispatchEvent(new Event("input", { bubbles: true }))
onTranscriptionChange?.(newValue)
}
}
speechRecognition.onerror = event => {
console.error("Speech recognition error:", event.error)
setIsListening(false)
}
recognitionRef.current = speechRecognition
setRecognition(speechRecognition)
} }
return () => { return () => {
if (recognitionRef.current) { streamRef.current?.getTracks().forEach((t) => t.stop())
recognitionRef.current.stop() }
}, [])
const stopRecording = useCallback(() => {
recorderRef.current?.stop()
}, [])
const startRecording = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
})
streamRef.current = stream
chunksRef.current = []
const mimeType = MediaRecorder.isTypeSupported(
"audio/webm;codecs=opus"
)
? "audio/webm;codecs=opus"
: "audio/webm"
const recorder = new MediaRecorder(stream, { mimeType })
recorderRef.current = recorder
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data)
} }
recorder.onstop = async () => {
stream.getTracks().forEach((t) => t.stop())
streamRef.current = null
const blob = new Blob(chunksRef.current, {
type: mimeType,
})
chunksRef.current = []
if (blob.size === 0) {
setState("idle")
return
}
setState("transcribing")
try {
const form = new FormData()
form.append("audio", blob, "recording.webm")
const res = await fetch("/api/transcribe", {
method: "POST",
body: form,
})
if (!res.ok) {
setState("idle")
return
}
const data = (await res.json()) as {
text: string
duration: number
}
const text = data.text.trim()
if (text && textareaRef?.current) {
const textarea = textareaRef.current
const cur = textarea.value
const next =
cur + (cur ? " " : "") + text
textarea.value = next
textarea.dispatchEvent(
new Event("input", { bubbles: true })
)
onTranscriptionChange?.(next)
}
} finally {
setState("idle")
}
}
recorder.start()
setState("recording")
} catch {
setState("idle")
} }
}, [textareaRef, onTranscriptionChange]) }, [textareaRef, onTranscriptionChange])
const toggleListening = useCallback(() => { const handleClick = useCallback(() => {
if (!recognition) { if (state === "recording") {
return stopRecording()
} else if (state === "idle") {
startRecording()
} }
}, [state, startRecording, stopRecording])
if (isListening) {
recognition.stop()
} else {
recognition.start()
}
}, [recognition, isListening])
return ( return (
<PromptInputButton <PromptInputButton
className={cn( className={cn(
"relative transition-all duration-200", "relative transition-all duration-200",
isListening && "animate-pulse bg-accent text-accent-foreground", state === "recording" &&
"animate-pulse bg-accent text-accent-foreground",
className, className,
)} )}
disabled={!recognition} disabled={!supported || state === "transcribing"}
onClick={toggleListening} onClick={handleClick}
{...props} {...props}
> >
<MicIcon className="size-4" /> {state === "transcribing" ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<MicIcon className="size-4" />
)}
</PromptInputButton> </PromptInputButton>
) )
} }

289
src/components/ai/queue.tsx Executable file
View File

@ -0,0 +1,289 @@
"use client"
import { ChevronDownIcon, PaperclipIcon } from "lucide-react"
import type { ComponentProps } from "react"
import { Button } from "@/components/ui/button"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { ScrollArea } from "@/components/ui/scroll-area"
import { cn } from "@/lib/utils"
export interface QueueMessagePart {
type: string
text?: string
url?: string
filename?: string
mediaType?: string
}
export interface QueueMessage {
id: string
parts: QueueMessagePart[]
}
export interface QueueTodo {
id: string
title: string
description?: string
status?: "pending" | "completed"
}
export type QueueItemProps = ComponentProps<"li">
export const QueueItem = ({ className, ...props }: QueueItemProps) => (
<li
className={cn(
"group flex flex-col gap-1 rounded-md px-3 py-1 text-sm transition-colors hover:bg-muted",
className,
)}
{...props}
/>
)
export type QueueItemIndicatorProps = ComponentProps<"span"> & {
completed?: boolean
}
export const QueueItemIndicator = ({
completed = false,
className,
...props
}: QueueItemIndicatorProps) => (
<span
className={cn(
"mt-0.5 inline-block size-2.5 rounded-full border",
completed
? "border-muted-foreground/20 bg-muted-foreground/10"
: "border-muted-foreground/50",
className,
)}
{...props}
/>
)
export type QueueItemContentProps = ComponentProps<"span"> & {
completed?: boolean
}
export const QueueItemContent = ({
completed = false,
className,
...props
}: QueueItemContentProps) => (
<span
className={cn(
"line-clamp-1 grow break-words",
completed ? "text-muted-foreground/50 line-through" : "text-muted-foreground",
className,
)}
{...props}
/>
)
export type QueueItemDescriptionProps = ComponentProps<"div"> & {
completed?: boolean
}
export const QueueItemDescription = ({
completed = false,
className,
...props
}: QueueItemDescriptionProps) => (
<div
className={cn(
"ml-6 text-xs",
completed ? "text-muted-foreground/40 line-through" : "text-muted-foreground",
className,
)}
{...props}
/>
)
export type QueueItemActionsProps = ComponentProps<"div">
export const QueueItemActions = ({ className, ...props }: QueueItemActionsProps) => (
<div className={cn("flex gap-1", className)} {...props} />
)
export type QueueItemActionProps = Omit<ComponentProps<typeof Button>, "variant" | "size">
export const QueueItemAction = ({ className, ...props }: QueueItemActionProps) => (
<Button
className={cn(
"size-auto rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-muted-foreground/10 hover:text-foreground group-hover:opacity-100",
className,
)}
size="icon"
type="button"
variant="ghost"
{...props}
/>
)
export type QueueItemAttachmentProps = ComponentProps<"div">
export const QueueItemAttachment = ({ className, ...props }: QueueItemAttachmentProps) => (
<div className={cn("mt-1 flex flex-wrap gap-2", className)} {...props} />
)
export type QueueItemImageProps = ComponentProps<"img">
export const QueueItemImage = ({ className, ...props }: QueueItemImageProps) => (
<img
alt=""
className={cn("h-8 w-8 rounded border object-cover", className)}
height={32}
width={32}
{...props}
/>
)
export type QueueItemFileProps = ComponentProps<"span">
export const QueueItemFile = ({ children, className, ...props }: QueueItemFileProps) => (
<span
className={cn("flex items-center gap-1 rounded border bg-muted px-2 py-1 text-xs", className)}
{...props}
>
<PaperclipIcon size={12} />
<span className="max-w-[100px] truncate">{children}</span>
</span>
)
export type QueueListProps = ComponentProps<typeof ScrollArea>
export const QueueList = ({ children, className, ...props }: QueueListProps) => (
<ScrollArea className={cn("-mb-1 mt-2", className)} {...props}>
<div className="max-h-40 pr-4">
<ul>{children}</ul>
</div>
</ScrollArea>
)
// QueueSection - collapsible section container
export type QueueSectionProps = ComponentProps<typeof Collapsible>
export const QueueSection = ({ className, defaultOpen = true, ...props }: QueueSectionProps) => (
<Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} />
)
// QueueSectionTrigger - section header/trigger
export type QueueSectionTriggerProps = ComponentProps<"button">
export const QueueSectionTrigger = ({
children,
className,
...props
}: QueueSectionTriggerProps) => (
<CollapsibleTrigger asChild>
<button
className={cn(
"group flex w-full items-center justify-between rounded-md bg-muted/40 px-3 py-2 text-left font-medium text-muted-foreground text-sm transition-colors hover:bg-muted",
className,
)}
type="button"
{...props}
>
{children}
</button>
</CollapsibleTrigger>
)
// QueueSectionLabel - label content with icon and count
export type QueueSectionLabelProps = ComponentProps<"span"> & {
count?: number
label: string
icon?: React.ReactNode
}
export const QueueSectionLabel = ({
count,
label,
icon,
className,
...props
}: QueueSectionLabelProps) => (
<span className={cn("flex items-center gap-2", className)} {...props}>
<ChevronDownIcon className="group-data-[state=closed]:-rotate-90 size-4 transition-transform" />
{icon}
<span>
{count} {label}
</span>
</span>
)
// QueueSectionContent - collapsible content area
export type QueueSectionContentProps = ComponentProps<typeof CollapsibleContent>
export const QueueSectionContent = ({ className, ...props }: QueueSectionContentProps) => (
<CollapsibleContent className={cn(className)} {...props} />
)
export type QueueProps = ComponentProps<"div">
export const Queue = ({ className, ...props }: QueueProps) => (
<div
className={cn(
"flex flex-col gap-2 rounded-xl border border-border bg-background px-3 pt-2 pb-2 shadow-xs",
className,
)}
{...props}
/>
)
/** Demo component for preview */
export default function QueueDemo() {
const messages = [
{ id: "1", text: "How do I set up the project?" },
{ id: "2", text: "What is the roadmap for Q4?" },
{ id: "3", text: "Please generate a changelog." },
]
const todos = [
{ id: "t1", title: "Write documentation", status: "completed" as const },
{ id: "t2", title: "Implement authentication", status: "pending" as const },
{ id: "t3", title: "Fix bug #42", status: "pending" as const },
]
return (
<div className="w-full max-w-md p-6">
<Queue>
<QueueSection defaultOpen>
<QueueSectionTrigger>
<QueueSectionLabel count={messages.length} label="Queued" />
</QueueSectionTrigger>
<QueueSectionContent>
<QueueList>
{messages.map(msg => (
<QueueItem key={msg.id}>
<div className="flex items-center gap-2">
<QueueItemIndicator />
<QueueItemContent>{msg.text}</QueueItemContent>
</div>
</QueueItem>
))}
</QueueList>
</QueueSectionContent>
</QueueSection>
<QueueSection defaultOpen>
<QueueSectionTrigger>
<QueueSectionLabel count={todos.length} label="Todo" />
</QueueSectionTrigger>
<QueueSectionContent>
<QueueList>
{todos.map(todo => (
<QueueItem key={todo.id}>
<div className="flex items-center gap-2">
<QueueItemIndicator completed={todo.status === "completed"} />
<QueueItemContent completed={todo.status === "completed"}>
{todo.title}
</QueueItemContent>
</div>
</QueueItem>
))}
</QueueList>
</QueueSectionContent>
</QueueSection>
</Queue>
</div>
)
}

192
src/components/ai/reasoning.tsx Executable file
View File

@ -0,0 +1,192 @@
"use client"
import { useControllableState } from "@radix-ui/react-use-controllable-state"
import { BrainIcon, ChevronDownIcon } from "lucide-react"
import type { ComponentProps, ReactNode } from "react"
import { createContext, memo, useContext, useEffect, useState } from "react"
import { Streamdown } from "streamdown"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { cn } from "@/lib/utils"
import { Shimmer } from "@/components/ai/shimmer"
interface ReasoningContextValue {
isStreaming: boolean
isOpen: boolean
setIsOpen: (open: boolean) => void
duration: number | undefined
}
const ReasoningContext = createContext<ReasoningContextValue | null>(null)
export const useReasoning = () => {
const context = useContext(ReasoningContext)
if (!context) {
throw new Error("Reasoning components must be used within Reasoning")
}
return context
}
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
isStreaming?: boolean
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
duration?: number
}
const AUTO_CLOSE_DELAY = 1000
const MS_IN_S = 1000
export const Reasoning = memo(
({
className,
isStreaming = false,
open,
defaultOpen = true,
onOpenChange,
duration: durationProp,
children,
...props
}: ReasoningProps) => {
const [isOpen, setIsOpen] = useControllableState({
prop: open,
defaultProp: defaultOpen,
onChange: onOpenChange,
})
const [duration, setDuration] = useControllableState({
prop: durationProp,
defaultProp: undefined,
})
const [hasAutoClosed, setHasAutoClosed] = useState(false)
const [startTime, setStartTime] = useState<number | null>(null)
// Track duration when streaming starts and ends
useEffect(() => {
if (isStreaming) {
if (startTime === null) {
setStartTime(Date.now())
}
} else if (startTime !== null) {
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S))
setStartTime(null)
}
}, [isStreaming, startTime, setDuration])
// Auto-open when streaming starts, auto-close when streaming ends (once only)
useEffect(() => {
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
// Add a small delay before closing to allow user to see the content
const timer = setTimeout(() => {
setIsOpen(false)
setHasAutoClosed(true)
}, AUTO_CLOSE_DELAY)
return () => clearTimeout(timer)
}
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed])
const handleOpenChange = (newOpen: boolean) => {
setIsOpen(newOpen)
}
return (
<ReasoningContext.Provider value={{ isStreaming, isOpen, setIsOpen, duration }}>
<Collapsible
className={cn("not-prose mb-4", className)}
onOpenChange={handleOpenChange}
open={isOpen}
{...props}
>
{children}
</Collapsible>
</ReasoningContext.Provider>
)
},
)
export type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode
}
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
if (isStreaming || duration === 0) {
return <Shimmer duration={1}>Thinking...</Shimmer>
}
if (duration === undefined) {
return <p>Thought for a few seconds</p>
}
return <p>Thought for {duration} seconds</p>
}
export const ReasoningTrigger = memo(
({
className,
children,
getThinkingMessage = defaultGetThinkingMessage,
...props
}: ReasoningTriggerProps) => {
const { isStreaming, isOpen, duration } = useReasoning()
return (
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
className,
)}
{...props}
>
{children ?? (
<>
<BrainIcon className="size-4" />
{getThinkingMessage(isStreaming, duration)}
<ChevronDownIcon
className={cn("size-4 transition-transform", isOpen ? "rotate-180" : "rotate-0")}
/>
</>
)}
</CollapsibleTrigger>
)
},
)
export type ReasoningContentProps = ComponentProps<typeof CollapsibleContent> & {
children: string
}
export const ReasoningContent = memo(({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent
className={cn(
"mt-4 text-sm",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className,
)}
{...props}
>
<Streamdown {...props}>{children}</Streamdown>
</CollapsibleContent>
))
Reasoning.displayName = "Reasoning"
ReasoningTrigger.displayName = "ReasoningTrigger"
ReasoningContent.displayName = "ReasoningContent"
/** Demo component for preview */
export default function ReasoningDemo() {
return (
<div className="w-full max-w-2xl p-6">
<Reasoning defaultOpen={true} duration={12}>
<ReasoningTrigger />
<ReasoningContent>
Let me think through this step by step... First, I need to consider the user&apos;s
requirements. They want a solution that is both efficient and maintainable. Looking at the
codebase, I can see several potential approaches: 1. **Refactor the existing module** -
This would minimize disruption 2. **Create a new abstraction layer** - More work but
cleaner long-term 3. **Use a library solution** - Fastest but adds a dependency After
weighing the tradeoffs, I believe option 2 provides the best balance of maintainability
and performance.
</ReasoningContent>
</Reasoning>
</div>
)
}

154
src/components/ai/sandbox.tsx Executable file
View File

@ -0,0 +1,154 @@
"use client"
import { CheckCircleIcon, ChevronDownIcon, CircleIcon, Code, XCircleIcon } from "lucide-react"
import type { ComponentProps, ReactNode } from "react"
import { Badge } from "@/components/ui/badge"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { cn } from "@/lib/utils"
type SandboxState = "running" | "completed" | "error"
const getStatusBadge = (status: SandboxState) => {
const labels: Record<SandboxState, string> = {
running: "Running",
completed: "Completed",
error: "Error",
}
const icons: Record<SandboxState, ReactNode> = {
running: <CircleIcon className="size-3 animate-pulse text-blue-600" />,
completed: <CheckCircleIcon className="size-3 text-green-600" />,
error: <XCircleIcon className="size-3 text-red-600" />,
}
return (
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
{icons[status]}
{labels[status]}
</Badge>
)
}
export type SandboxProps = ComponentProps<typeof Collapsible>
export const Sandbox = ({ className, ...props }: SandboxProps) => (
<Collapsible
className={cn("not-prose group mb-4 w-full overflow-hidden rounded-md border", className)}
defaultOpen
{...props}
/>
)
export interface SandboxHeaderProps {
title?: string
state: SandboxState
className?: string
}
export const SandboxHeader = ({ className, title, state, ...props }: SandboxHeaderProps) => (
<CollapsibleTrigger
className={cn("flex w-full items-center justify-between gap-4 p-3", className)}
{...props}
>
<div className="flex items-center gap-2">
<Code className="size-4 text-muted-foreground" />
<span className="font-medium text-sm">{title}</span>
{getStatusBadge(state)}
</div>
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
)
export type SandboxContentProps = ComponentProps<typeof CollapsibleContent>
export const SandboxContent = ({ className, ...props }: SandboxContentProps) => (
<CollapsibleContent
className={cn(
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className,
)}
{...props}
/>
)
export type SandboxTabsProps = ComponentProps<typeof Tabs>
export const SandboxTabs = ({ className, ...props }: SandboxTabsProps) => (
<Tabs className={cn("w-full gap-0", className)} {...props} />
)
export type SandboxTabsBarProps = ComponentProps<"div">
export const SandboxTabsBar = ({ className, ...props }: SandboxTabsBarProps) => (
<div
className={cn("flex w-full items-center border-border border-t border-b", className)}
{...props}
/>
)
export type SandboxTabsListProps = ComponentProps<typeof TabsList>
export const SandboxTabsList = ({ className, ...props }: SandboxTabsListProps) => (
<TabsList
className={cn("h-auto rounded-none border-0 bg-transparent p-0", className)}
{...props}
/>
)
export type SandboxTabsTriggerProps = ComponentProps<typeof TabsTrigger>
export const SandboxTabsTrigger = ({ className, ...props }: SandboxTabsTriggerProps) => (
<TabsTrigger
className={cn(
"rounded-none border-0 border-transparent border-b-2 px-4 py-2 font-medium text-muted-foreground text-sm transition-colors data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none",
className,
)}
{...props}
/>
)
export type SandboxTabContentProps = ComponentProps<typeof TabsContent>
export const SandboxTabContent = ({ className, ...props }: SandboxTabContentProps) => (
<TabsContent className={cn("mt-0 text-sm", className)} {...props} />
)
/** Demo component for preview */
export default function SandboxDemo() {
const sampleCode = `function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
console.log(fibonacci(10));`
const sampleOutput = `> fibonacci(10)
55`
return (
<div className="w-full max-w-lg p-4">
<Sandbox>
<SandboxHeader title="Code Execution" state="completed" />
<SandboxContent>
<SandboxTabs defaultValue="code">
<SandboxTabsBar>
<SandboxTabsList>
<SandboxTabsTrigger value="code">Code</SandboxTabsTrigger>
<SandboxTabsTrigger value="console">Console</SandboxTabsTrigger>
</SandboxTabsList>
</SandboxTabsBar>
<SandboxTabContent value="code">
<pre className="overflow-auto bg-muted/30 p-4 font-mono text-xs">{sampleCode}</pre>
</SandboxTabContent>
<SandboxTabContent value="console">
<pre className="overflow-auto bg-muted/30 p-4 font-mono text-xs text-green-600">
{sampleOutput}
</pre>
</SandboxTabContent>
</SandboxTabs>
</SandboxContent>
</Sandbox>
</div>
)
}

View File

@ -0,0 +1,528 @@
"use client"
import { ChevronRightIcon } from "lucide-react"
import { type ComponentProps, createContext, type HTMLAttributes, useContext } from "react"
import { Badge } from "@/components/ui/badge"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { cn } from "@/lib/utils"
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"
interface SchemaParameter {
name: string
type: string
required?: boolean
description?: string
location?: "path" | "query" | "header"
}
interface SchemaProperty {
name: string
type: string
required?: boolean
description?: string
properties?: SchemaProperty[]
items?: SchemaProperty
}
interface SchemaDisplayContextType {
method: HttpMethod
path: string
description?: string
parameters?: SchemaParameter[]
requestBody?: SchemaProperty[]
responseBody?: SchemaProperty[]
}
const SchemaDisplayContext = createContext<SchemaDisplayContextType>({
method: "GET",
path: "",
})
export type SchemaDisplayProps = HTMLAttributes<HTMLDivElement> & {
method: HttpMethod
path: string
description?: string
parameters?: SchemaParameter[]
requestBody?: SchemaProperty[]
responseBody?: SchemaProperty[]
}
export const SchemaDisplay = ({
method,
path,
description,
parameters,
requestBody,
responseBody,
className,
children,
...props
}: SchemaDisplayProps) => (
<SchemaDisplayContext.Provider
value={{ method, path, description, parameters, requestBody, responseBody }}
>
<div className={cn("overflow-hidden rounded-lg border bg-background", className)} {...props}>
{children ?? (
<>
<SchemaDisplayHeader>
<div className="flex items-center gap-3">
<SchemaDisplayMethod />
<SchemaDisplayPath />
</div>
</SchemaDisplayHeader>
{description && <SchemaDisplayDescription />}
<SchemaDisplayContent>
{parameters && parameters.length > 0 && <SchemaDisplayParameters />}
{requestBody && requestBody.length > 0 && <SchemaDisplayRequest />}
{responseBody && responseBody.length > 0 && <SchemaDisplayResponse />}
</SchemaDisplayContent>
</>
)}
</div>
</SchemaDisplayContext.Provider>
)
export type SchemaDisplayHeaderProps = HTMLAttributes<HTMLDivElement>
export const SchemaDisplayHeader = ({
className,
children,
...props
}: SchemaDisplayHeaderProps) => (
<div className={cn("flex items-center gap-3 border-b px-4 py-3", className)} {...props}>
{children}
</div>
)
const methodStyles: Record<HttpMethod, string> = {
GET: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
POST: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
PUT: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400",
PATCH: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400",
DELETE: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
}
export type SchemaDisplayMethodProps = ComponentProps<typeof Badge>
export const SchemaDisplayMethod = ({
className,
children,
...props
}: SchemaDisplayMethodProps) => {
const { method } = useContext(SchemaDisplayContext)
return (
<Badge
className={cn("font-mono text-xs", methodStyles[method], className)}
variant="secondary"
{...props}
>
{children ?? method}
</Badge>
)
}
export type SchemaDisplayPathProps = HTMLAttributes<HTMLSpanElement>
export const SchemaDisplayPath = ({ className, children, ...props }: SchemaDisplayPathProps) => {
const { path } = useContext(SchemaDisplayContext)
// Highlight path parameters
const highlightedPath = path.replace(
/\{([^}]+)\}/g,
'<span class="text-blue-600 dark:text-blue-400">{$1}</span>',
)
return (
<span
className={cn("font-mono text-sm", className)}
// biome-ignore lint/security/noDangerouslySetInnerHtml: needed for parameter highlighting
dangerouslySetInnerHTML={{ __html: children?.toString() ?? highlightedPath }}
{...props}
/>
)
}
export type SchemaDisplayDescriptionProps = HTMLAttributes<HTMLParagraphElement>
export const SchemaDisplayDescription = ({
className,
children,
...props
}: SchemaDisplayDescriptionProps) => {
const { description } = useContext(SchemaDisplayContext)
return (
<p className={cn("border-b px-4 py-3 text-muted-foreground text-sm", className)} {...props}>
{children ?? description}
</p>
)
}
export type SchemaDisplayContentProps = HTMLAttributes<HTMLDivElement>
export const SchemaDisplayContent = ({
className,
children,
...props
}: SchemaDisplayContentProps) => (
<div className={cn("divide-y", className)} {...props}>
{children}
</div>
)
export type SchemaDisplayParametersProps = ComponentProps<typeof Collapsible>
export const SchemaDisplayParameters = ({
className,
children,
...props
}: SchemaDisplayParametersProps) => {
const { parameters } = useContext(SchemaDisplayContext)
return (
<Collapsible className={cn(className)} defaultOpen {...props}>
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-muted/50">
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
<span className="font-medium text-sm">Parameters</span>
<Badge className="ml-auto text-xs" variant="secondary">
{parameters?.length}
</Badge>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="divide-y border-t">
{children ??
parameters?.map(param => <SchemaDisplayParameter key={param.name} {...param} />)}
</div>
</CollapsibleContent>
</Collapsible>
)
}
export type SchemaDisplayParameterProps = HTMLAttributes<HTMLDivElement> & SchemaParameter
export const SchemaDisplayParameter = ({
name,
type,
required,
description,
location,
className,
...props
}: SchemaDisplayParameterProps) => (
<div className={cn("px-4 py-3 pl-10", className)} {...props}>
<div className="flex items-center gap-2">
<span className="font-mono text-sm">{name}</span>
<Badge className="text-xs" variant="outline">
{type}
</Badge>
{location && (
<Badge className="text-xs" variant="secondary">
{location}
</Badge>
)}
{required && (
<Badge
className="bg-red-100 text-red-700 text-xs dark:bg-red-900/30 dark:text-red-400"
variant="secondary"
>
required
</Badge>
)}
</div>
{description && <p className="mt-1 text-muted-foreground text-sm">{description}</p>}
</div>
)
export type SchemaDisplayRequestProps = ComponentProps<typeof Collapsible>
export const SchemaDisplayRequest = ({
className,
children,
...props
}: SchemaDisplayRequestProps) => {
const { requestBody } = useContext(SchemaDisplayContext)
return (
<Collapsible className={cn(className)} defaultOpen {...props}>
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-muted/50">
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
<span className="font-medium text-sm">Request Body</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t">
{children ??
requestBody?.map(prop => <SchemaDisplayProperty key={prop.name} {...prop} depth={0} />)}
</div>
</CollapsibleContent>
</Collapsible>
)
}
export type SchemaDisplayResponseProps = ComponentProps<typeof Collapsible>
export const SchemaDisplayResponse = ({
className,
children,
...props
}: SchemaDisplayResponseProps) => {
const { responseBody } = useContext(SchemaDisplayContext)
return (
<Collapsible className={cn(className)} defaultOpen {...props}>
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-muted/50">
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
<span className="font-medium text-sm">Response</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t">
{children ??
responseBody?.map(prop => (
<SchemaDisplayProperty key={prop.name} {...prop} depth={0} />
))}
</div>
</CollapsibleContent>
</Collapsible>
)
}
export type SchemaDisplayBodyProps = HTMLAttributes<HTMLDivElement>
export const SchemaDisplayBody = ({ className, children, ...props }: SchemaDisplayBodyProps) => (
<div className={cn("divide-y", className)} {...props}>
{children}
</div>
)
export type SchemaDisplayPropertyProps = HTMLAttributes<HTMLDivElement> &
SchemaProperty & {
depth?: number
}
export const SchemaDisplayProperty = ({
name,
type,
required,
description,
properties,
items,
depth = 0,
className,
...props
}: SchemaDisplayPropertyProps) => {
const hasChildren = properties || items
const paddingLeft = 40 + depth * 16
if (hasChildren) {
return (
<Collapsible defaultOpen={depth < 2}>
<CollapsibleTrigger
className={cn(
"group flex w-full items-center gap-2 py-3 text-left transition-colors hover:bg-muted/50",
className,
)}
style={{ paddingLeft }}
>
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
<span className="font-mono text-sm">{name}</span>
<Badge className="text-xs" variant="outline">
{type}
</Badge>
{required && (
<Badge
className="bg-red-100 text-red-700 text-xs dark:bg-red-900/30 dark:text-red-400"
variant="secondary"
>
required
</Badge>
)}
</CollapsibleTrigger>
{description && (
<p
className="pb-2 text-muted-foreground text-sm"
style={{ paddingLeft: paddingLeft + 24 }}
>
{description}
</p>
)}
<CollapsibleContent>
<div className="divide-y border-t">
{properties?.map(prop => (
<SchemaDisplayProperty key={prop.name} {...prop} depth={depth + 1} />
))}
{items && <SchemaDisplayProperty {...items} depth={depth + 1} name={`${name}[]`} />}
</div>
</CollapsibleContent>
</Collapsible>
)
}
return (
<div className={cn("py-3 pr-4", className)} style={{ paddingLeft }} {...props}>
<div className="flex items-center gap-2">
<span className="size-4" /> {/* Spacer for alignment */}
<span className="font-mono text-sm">{name}</span>
<Badge className="text-xs" variant="outline">
{type}
</Badge>
{required && (
<Badge
className="bg-red-100 text-red-700 text-xs dark:bg-red-900/30 dark:text-red-400"
variant="secondary"
>
required
</Badge>
)}
</div>
{description && <p className="mt-1 pl-6 text-muted-foreground text-sm">{description}</p>}
</div>
)
}
export type SchemaDisplayExampleProps = HTMLAttributes<HTMLPreElement>
export const SchemaDisplayExample = ({
className,
children,
...props
}: SchemaDisplayExampleProps) => (
<pre
className={cn("mx-4 mb-4 overflow-auto rounded-md bg-muted p-4 font-mono text-sm", className)}
{...props}
>
{children}
</pre>
)
/** Demo component for preview */
export function SchemaDisplayDemo() {
return (
<div className="w-full max-w-2xl p-4">
<div className="overflow-hidden rounded-lg border bg-background">
{/* Header */}
<div className="flex items-center gap-3 border-b px-4 py-3">
<Badge className="bg-blue-100 font-mono text-blue-700 text-xs dark:bg-blue-900/30 dark:text-blue-400">
POST
</Badge>
<span className="font-mono text-sm">
/api/users/<span className="text-blue-600 dark:text-blue-400">{"{userId}"}</span>
/messages
</span>
</div>
{/* Description */}
<p className="border-b px-4 py-3 text-muted-foreground text-sm">
Send a message to a specific user
</p>
{/* Parameters */}
<Collapsible defaultOpen>
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-left hover:bg-muted/50">
<ChevronRightIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
<span className="font-medium text-sm">Parameters</span>
<Badge className="ml-auto text-xs" variant="secondary">
2
</Badge>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="divide-y border-t">
<div className="px-4 py-3 pl-10">
<div className="flex items-center gap-2">
<span className="font-mono text-sm">userId</span>
<Badge variant="outline" className="text-xs">
string
</Badge>
<Badge variant="secondary" className="text-xs">
path
</Badge>
<Badge className="bg-red-100 text-red-700 text-xs dark:bg-red-900/30 dark:text-red-400">
required
</Badge>
</div>
<p className="mt-1 text-muted-foreground text-sm">The user{"'"}s unique ID</p>
</div>
<div className="px-4 py-3 pl-10">
<div className="flex items-center gap-2">
<span className="font-mono text-sm">format</span>
<Badge variant="outline" className="text-xs">
string
</Badge>
<Badge variant="secondary" className="text-xs">
query
</Badge>
</div>
<p className="mt-1 text-muted-foreground text-sm">Response format (json or xml)</p>
</div>
</div>
</CollapsibleContent>
</Collapsible>
{/* Request Body */}
<Collapsible defaultOpen className="border-t">
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-left hover:bg-muted/50">
<ChevronRightIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
<span className="font-medium text-sm">Request Body</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="divide-y border-t">
<div className="px-4 py-3 pl-10">
<div className="flex items-center gap-2">
<span className="font-mono text-sm">content</span>
<Badge variant="outline" className="text-xs">
string
</Badge>
<Badge className="bg-red-100 text-red-700 text-xs dark:bg-red-900/30 dark:text-red-400">
required
</Badge>
</div>
<p className="mt-1 text-muted-foreground text-sm">The message content</p>
</div>
<div className="px-4 py-3 pl-10">
<div className="flex items-center gap-2">
<span className="font-mono text-sm">priority</span>
<Badge variant="outline" className="text-xs">
string
</Badge>
</div>
<p className="mt-1 text-muted-foreground text-sm">Message priority level</p>
</div>
</div>
</CollapsibleContent>
</Collapsible>
{/* Response */}
<Collapsible defaultOpen className="border-t">
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-left hover:bg-muted/50">
<ChevronRightIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
<span className="font-medium text-sm">Response</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="divide-y border-t">
<div className="px-4 py-3 pl-10">
<div className="flex items-center gap-2">
<span className="font-mono text-sm">id</span>
<Badge variant="outline" className="text-xs">
string
</Badge>
</div>
<p className="mt-1 text-muted-foreground text-sm">Created message ID</p>
</div>
<div className="px-4 py-3 pl-10">
<div className="flex items-center gap-2">
<span className="font-mono text-sm">status</span>
<Badge variant="outline" className="text-xs">
string
</Badge>
</div>
<p className="mt-1 text-muted-foreground text-sm">Delivery status</p>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</div>
)
}
export default SchemaDisplayDemo

68
src/components/ai/shimmer.tsx Executable file
View File

@ -0,0 +1,68 @@
"use client"
import { motion } from "motion/react"
import { type CSSProperties, type ElementType, type JSX, memo, useMemo } from "react"
import { cn } from "@/lib/utils"
export interface TextShimmerProps {
children: string
as?: ElementType
className?: string
duration?: number
spread?: number
}
const ShimmerComponent = ({
children,
as: Component = "p",
className,
duration = 2,
spread = 2,
}: TextShimmerProps) => {
const MotionComponent = motion.create(Component as keyof JSX.IntrinsicElements)
const dynamicSpread = useMemo(() => (children?.length ?? 0) * spread, [children, spread])
return (
<MotionComponent
animate={{ backgroundPosition: "0% center" }}
className={cn(
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
className,
)}
initial={{ backgroundPosition: "100% center" }}
style={
{
"--spread": `${dynamicSpread}px`,
backgroundImage:
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
} as CSSProperties
}
transition={{
repeat: Number.POSITIVE_INFINITY,
duration,
ease: "linear",
}}
>
{children}
</MotionComponent>
)
}
export const Shimmer = memo(ShimmerComponent)
/** Demo component for preview */
export default function ShimmerDemo() {
return (
<div className="flex flex-col items-center justify-center gap-4 p-8">
<Shimmer>This text has a shimmer effect</Shimmer>
<Shimmer as="h1" className="font-bold text-4xl">
Large Heading
</Shimmer>
<Shimmer duration={3} spread={3}>
Slower shimmer with wider spread
</Shimmer>
</div>
)
}

151
src/components/ai/snippet.tsx Executable file
View File

@ -0,0 +1,151 @@
"use client"
import { CheckIcon, CopyIcon } from "lucide-react"
import {
type ComponentProps,
createContext,
type HTMLAttributes,
useContext,
useEffect,
useRef,
useState,
} from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
interface SnippetContextType {
code: string
}
const SnippetContext = createContext<SnippetContextType>({
code: "",
})
export type SnippetProps = HTMLAttributes<HTMLDivElement> & {
code: string
}
export const Snippet = ({ code, className, children, ...props }: SnippetProps) => (
<SnippetContext.Provider value={{ code }}>
<div className={cn("flex items-center font-mono", className)} {...props}>
{children}
</div>
</SnippetContext.Provider>
)
export type SnippetAddonProps = HTMLAttributes<HTMLDivElement>
export const SnippetAddon = ({ className, ...props }: SnippetAddonProps) => (
<div
className={cn(
"flex h-9 items-center rounded-l-md border border-r-0 bg-muted px-3 text-muted-foreground text-sm",
className,
)}
{...props}
/>
)
export type SnippetTextProps = HTMLAttributes<HTMLSpanElement>
export const SnippetText = ({ className, ...props }: SnippetTextProps) => (
<span className={cn("font-normal text-muted-foreground", className)} {...props} />
)
export type SnippetInputProps = Omit<ComponentProps<typeof Input>, "readOnly" | "value">
export const SnippetInput = ({ className, ...props }: SnippetInputProps) => {
const { code } = useContext(SnippetContext)
return (
<Input
className={cn("rounded-none border-r-0 text-foreground", className)}
readOnly
value={code}
{...props}
/>
)
}
export type SnippetCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void
onError?: (error: Error) => void
timeout?: number
}
export const SnippetCopyButton = ({
onCopy,
onError,
timeout = 2000,
children,
className,
...props
}: SnippetCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false)
const timeoutRef = useRef<number>(0)
const { code } = useContext(SnippetContext)
const copyToClipboard = async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"))
return
}
try {
if (!isCopied) {
await navigator.clipboard.writeText(code)
setIsCopied(true)
onCopy?.()
timeoutRef.current = window.setTimeout(() => setIsCopied(false), timeout)
}
} catch (error) {
onError?.(error as Error)
}
}
useEffect(
() => () => {
window.clearTimeout(timeoutRef.current)
},
[],
)
const Icon = isCopied ? CheckIcon : CopyIcon
return (
<Button
aria-label="Copy"
className={cn("rounded-l-none", className)}
onClick={copyToClipboard}
size="icon"
title="Copy"
variant="outline"
{...props}
>
{children ?? <Icon className="size-3.5" />}
</Button>
)
}
/** Demo component for preview */
export default function SnippetDemo() {
return (
<div className="flex w-full max-w-md flex-col gap-4 p-4">
<Snippet code="npm install @shadcn/ui">
<SnippetAddon>
<SnippetText>$</SnippetText>
</SnippetAddon>
<SnippetInput />
<SnippetCopyButton />
</Snippet>
<Snippet code="sk-proj-abc123xyz789">
<SnippetAddon>
<SnippetText>API_KEY</SnippetText>
</SnippetAddon>
<SnippetInput />
<SnippetCopyButton />
</Snippet>
</div>
)
}

75
src/components/ai/sources.tsx Executable file
View File

@ -0,0 +1,75 @@
"use client"
import { BookIcon, ChevronDownIcon } from "lucide-react"
import type { ComponentProps } from "react"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { cn } from "@/lib/utils"
export type SourcesProps = ComponentProps<"div">
export const Sources = ({ className, ...props }: SourcesProps) => (
<Collapsible className={cn("not-prose mb-4 text-primary text-xs", className)} {...props} />
)
export type SourcesTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
count: number
}
export const SourcesTrigger = ({ className, count, children, ...props }: SourcesTriggerProps) => (
<CollapsibleTrigger className={cn("flex items-center gap-2", className)} {...props}>
{children ?? (
<>
<p className="font-medium">Used {count} sources</p>
<ChevronDownIcon className="h-4 w-4" />
</>
)}
</CollapsibleTrigger>
)
export type SourcesContentProps = ComponentProps<typeof CollapsibleContent>
export const SourcesContent = ({ className, ...props }: SourcesContentProps) => (
<CollapsibleContent
className={cn(
"mt-3 flex w-fit flex-col gap-2",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className,
)}
{...props}
/>
)
export type SourceProps = ComponentProps<"a">
export const Source = ({ href, title, children, ...props }: SourceProps) => (
<a className="flex items-center gap-2" href={href} rel="noreferrer" target="_blank" {...props}>
{children ?? (
<>
<BookIcon className="h-4 w-4" />
<span className="block font-medium">{title}</span>
</>
)}
</a>
)
/** Demo component for preview */
export default function SourcesDemo() {
const sources = [
{ href: "https://stripe.com/docs/api", title: "Stripe API Documentation" },
{ href: "https://docs.github.com/en/rest", title: "GitHub REST API" },
{ href: "https://docs.aws.amazon.com/sdk-for-javascript/", title: "AWS SDK for JavaScript" },
]
return (
<div className="p-6" style={{ height: "150px" }}>
<Sources>
<SourcesTrigger count={sources.length} />
<SourcesContent>
{sources.map(source => (
<Source href={source.href} key={source.href} title={source.title} />
))}
</SourcesContent>
</Sources>
</div>
)
}

View File

@ -0,0 +1,285 @@
"use client"
import { LoaderIcon, MicIcon, SquareIcon } from "lucide-react"
import { type ComponentProps, useCallback, useEffect, useRef, useState } from "react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
interface SpeechRecognition extends EventTarget {
continuous: boolean
interimResults: boolean
lang: string
start(): void
stop(): void
onstart: ((this: SpeechRecognition, ev: Event) => void) | null
onend: ((this: SpeechRecognition, ev: Event) => void) | null
onresult: ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => void) | null
onerror: ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => void) | null
}
interface SpeechRecognitionEvent extends Event {
results: SpeechRecognitionResultList
resultIndex: number
}
interface SpeechRecognitionResultList {
readonly length: number
item(index: number): SpeechRecognitionResult
[index: number]: SpeechRecognitionResult
}
interface SpeechRecognitionResult {
readonly length: number
item(index: number): SpeechRecognitionAlternative
[index: number]: SpeechRecognitionAlternative
isFinal: boolean
}
interface SpeechRecognitionAlternative {
transcript: string
confidence: number
}
interface SpeechRecognitionErrorEvent extends Event {
error: string
}
declare global {
interface Window {
SpeechRecognition: { new (): SpeechRecognition }
webkitSpeechRecognition: { new (): SpeechRecognition }
}
}
type SpeechInputMode = "speech-recognition" | "media-recorder" | "none"
export type SpeechInputProps = ComponentProps<typeof Button> & {
onTranscriptionChange?: (text: string) => void
onAudioRecorded?: (audioBlob: Blob) => Promise<string>
lang?: string
}
const detectSpeechInputMode = (): SpeechInputMode => {
if (typeof window === "undefined") {
return "none"
}
if ("SpeechRecognition" in window || "webkitSpeechRecognition" in window) {
return "speech-recognition"
}
if ("MediaRecorder" in window && "mediaDevices" in navigator) {
return "media-recorder"
}
return "none"
}
export const SpeechInput = ({
className,
onTranscriptionChange,
onAudioRecorded,
lang = "en-US",
...props
}: SpeechInputProps) => {
const [isListening, setIsListening] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
const [mode, setMode] = useState<SpeechInputMode>("none")
const [recognition, setRecognition] = useState<SpeechRecognition | null>(null)
const recognitionRef = useRef<SpeechRecognition | null>(null)
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
const audioChunksRef = useRef<Blob[]>([])
useEffect(() => {
setMode(detectSpeechInputMode())
}, [])
useEffect(() => {
if (mode !== "speech-recognition") {
return
}
const SpeechRecognitionCtor = window.SpeechRecognition || window.webkitSpeechRecognition
const speechRecognition = new SpeechRecognitionCtor()
speechRecognition.continuous = true
speechRecognition.interimResults = true
speechRecognition.lang = lang
speechRecognition.onstart = () => {
setIsListening(true)
}
speechRecognition.onend = () => {
setIsListening(false)
}
speechRecognition.onresult = event => {
let finalTranscript = ""
for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i]
if (result.isFinal) {
finalTranscript += result[0]?.transcript ?? ""
}
}
if (finalTranscript) {
onTranscriptionChange?.(finalTranscript)
}
}
speechRecognition.onerror = event => {
// Network/permission errors are common - use warn to avoid error overlay
if (event.error !== "network" && event.error !== "not-allowed") {
console.warn("Speech recognition error:", event.error)
}
setIsListening(false)
}
recognitionRef.current = speechRecognition
setRecognition(speechRecognition)
return () => {
if (recognitionRef.current) {
recognitionRef.current.stop()
}
}
}, [mode, onTranscriptionChange, lang])
const startMediaRecorder = useCallback(async () => {
if (!onAudioRecorded) {
console.warn("SpeechInput: onAudioRecorded callback is required for MediaRecorder fallback")
return
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
const mediaRecorder = new MediaRecorder(stream)
audioChunksRef.current = []
mediaRecorder.ondataavailable = event => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data)
}
}
mediaRecorder.onstop = async () => {
for (const track of stream.getTracks()) {
track.stop()
}
const audioBlob = new Blob(audioChunksRef.current, { type: "audio/webm" })
if (audioBlob.size > 0) {
setIsProcessing(true)
try {
const transcript = await onAudioRecorded(audioBlob)
if (transcript) {
onTranscriptionChange?.(transcript)
}
} catch (error) {
console.error("Transcription error:", error)
} finally {
setIsProcessing(false)
}
}
}
mediaRecorder.onerror = () => {
setIsListening(false)
for (const track of stream.getTracks()) {
track.stop()
}
}
mediaRecorderRef.current = mediaRecorder
mediaRecorder.start()
setIsListening(true)
} catch (error) {
console.error("Failed to start MediaRecorder:", error)
setIsListening(false)
}
}, [onAudioRecorded, onTranscriptionChange])
const stopMediaRecorder = useCallback(() => {
if (mediaRecorderRef.current?.state === "recording") {
mediaRecorderRef.current.stop()
}
setIsListening(false)
}, [])
const toggleListening = useCallback(() => {
if (mode === "speech-recognition" && recognition) {
if (isListening) {
recognition.stop()
} else {
recognition.start()
}
} else if (mode === "media-recorder") {
if (isListening) {
stopMediaRecorder()
} else {
startMediaRecorder()
}
}
}, [mode, recognition, isListening, startMediaRecorder, stopMediaRecorder])
const isDisabled =
mode === "none" ||
(mode === "speech-recognition" && !recognition) ||
(mode === "media-recorder" && !onAudioRecorded) ||
isProcessing
return (
<div className="relative inline-flex items-center justify-center">
{isListening &&
[0, 1, 2].map(index => (
<div
className="absolute inset-0 animate-ping rounded-full border-2 border-red-400/30"
key={index}
style={{
animationDelay: `${index * 0.3}s`,
animationDuration: "2s",
}}
/>
))}
<Button
className={cn(
"relative z-10 rounded-full transition-all duration-300",
isListening
? "bg-destructive text-white hover:bg-destructive/80 hover:text-white"
: "bg-primary text-primary-foreground hover:bg-primary/80 hover:text-primary-foreground",
className,
)}
disabled={isDisabled}
onClick={toggleListening}
{...props}
>
{isProcessing && <LoaderIcon className="size-4 animate-spin" />}
{!isProcessing && isListening && <SquareIcon className="size-4" />}
{!(isProcessing || isListening) && <MicIcon className="size-4" />}
</Button>
</div>
)
}
/** Demo component for preview */
export default function SpeechInputDemo() {
const [transcript, setTranscript] = useState("")
return (
<div className="flex w-full max-w-sm flex-col items-center gap-4 p-4">
<SpeechInput
size="lg"
onTranscriptionChange={text => setTranscript(prev => prev + " " + text)}
/>
<div className="w-full rounded-lg border bg-muted/50 p-4">
<p className="text-muted-foreground text-sm">
{transcript || "Click the microphone to start recording..."}
</p>
</div>
</div>
)
}

441
src/components/ai/stack-trace.tsx Executable file
View File

@ -0,0 +1,441 @@
"use client"
import { useControllableState } from "@radix-ui/react-use-controllable-state"
import { AlertTriangleIcon, CheckIcon, ChevronDownIcon, CopyIcon } from "lucide-react"
import type { ComponentProps } from "react"
import { createContext, memo, useContext, useMemo, useState } from "react"
import { Button } from "@/components/ui/button"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { cn } from "@/lib/utils"
const STACK_FRAME_WITH_PARENS_REGEX = /^at\s+(.+?)\s+\((.+):(\d+):(\d+)\)$/
const STACK_FRAME_WITHOUT_FN_REGEX = /^at\s+(.+):(\d+):(\d+)$/
const ERROR_TYPE_REGEX = /^(\w+Error|Error):\s*(.*)$/
const AT_PREFIX_REGEX = /^at\s+/
interface StackFrame {
raw: string
functionName: string | null
filePath: string | null
lineNumber: number | null
columnNumber: number | null
isInternal: boolean
}
interface ParsedStackTrace {
errorType: string | null
errorMessage: string
frames: StackFrame[]
raw: string
}
interface StackTraceContextValue {
trace: ParsedStackTrace
raw: string
isOpen: boolean
setIsOpen: (open: boolean) => void
onFilePathClick?: (filePath: string, line?: number, column?: number) => void
}
const StackTraceContext = createContext<StackTraceContextValue | null>(null)
const useStackTrace = () => {
const context = useContext(StackTraceContext)
if (!context) {
throw new Error("StackTrace components must be used within StackTrace")
}
return context
}
const parseStackFrame = (line: string): StackFrame => {
const trimmed = line.trim()
const withParensMatch = trimmed.match(STACK_FRAME_WITH_PARENS_REGEX)
if (withParensMatch) {
const [, functionName, filePath, lineNum, colNum] = withParensMatch
const isInternal =
filePath.includes("node_modules") ||
filePath.startsWith("node:") ||
filePath.includes("internal/")
return {
raw: trimmed,
functionName: functionName ?? null,
filePath: filePath ?? null,
lineNumber: lineNum ? Number.parseInt(lineNum, 10) : null,
columnNumber: colNum ? Number.parseInt(colNum, 10) : null,
isInternal,
}
}
const withoutFnMatch = trimmed.match(STACK_FRAME_WITHOUT_FN_REGEX)
if (withoutFnMatch) {
const [, filePath, lineNum, colNum] = withoutFnMatch
const isInternal =
(filePath?.includes("node_modules") ?? false) ||
(filePath?.startsWith("node:") ?? false) ||
(filePath?.includes("internal/") ?? false)
return {
raw: trimmed,
functionName: null,
filePath: filePath ?? null,
lineNumber: lineNum ? Number.parseInt(lineNum, 10) : null,
columnNumber: colNum ? Number.parseInt(colNum, 10) : null,
isInternal,
}
}
return {
raw: trimmed,
functionName: null,
filePath: null,
lineNumber: null,
columnNumber: null,
isInternal: trimmed.includes("node_modules") || trimmed.includes("node:"),
}
}
const parseStackTrace = (trace: string): ParsedStackTrace => {
const lines = trace.split("\n").filter(line => line.trim())
if (lines.length === 0) {
return { errorType: null, errorMessage: trace, frames: [], raw: trace }
}
const firstLine = lines[0].trim()
let errorType: string | null = null
let errorMessage = firstLine
const errorMatch = firstLine.match(ERROR_TYPE_REGEX)
if (errorMatch) {
errorType = errorMatch[1]
errorMessage = errorMatch[2] || ""
}
const frames = lines
.slice(1)
.filter(line => line.trim().startsWith("at "))
.map(parseStackFrame)
return { errorType, errorMessage, frames, raw: trace }
}
export type StackTraceProps = ComponentProps<"div"> & {
trace: string
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
onFilePathClick?: (filePath: string, line?: number, column?: number) => void
}
export const StackTrace = memo(
({
trace,
className,
open,
defaultOpen = false,
onOpenChange,
onFilePathClick,
children,
...props
}: StackTraceProps) => {
const [isOpen, setIsOpen] = useControllableState({
prop: open,
defaultProp: defaultOpen,
onChange: onOpenChange,
})
const parsedTrace = useMemo(() => parseStackTrace(trace), [trace])
const contextValue = useMemo(
() => ({
trace: parsedTrace,
raw: trace,
isOpen: isOpen ?? false,
setIsOpen,
onFilePathClick,
}),
[parsedTrace, trace, isOpen, setIsOpen, onFilePathClick],
)
return (
<StackTraceContext.Provider value={contextValue}>
<div
className={cn(
"not-prose w-full overflow-hidden rounded-lg border bg-background font-mono text-sm",
className,
)}
{...props}
>
{children}
</div>
</StackTraceContext.Provider>
)
},
)
export type StackTraceHeaderProps = ComponentProps<typeof CollapsibleTrigger>
export const StackTraceHeader = memo(({ className, children, ...props }: StackTraceHeaderProps) => {
const { isOpen, setIsOpen } = useStackTrace()
return (
<Collapsible onOpenChange={setIsOpen} open={isOpen}>
<CollapsibleTrigger asChild {...props}>
<div
className={cn(
"flex w-full cursor-pointer items-center gap-3 p-3 text-left transition-colors hover:bg-muted/50",
className,
)}
>
{children}
</div>
</CollapsibleTrigger>
</Collapsible>
)
})
export type StackTraceErrorProps = ComponentProps<"div">
export const StackTraceError = memo(({ className, children, ...props }: StackTraceErrorProps) => (
<div className={cn("flex flex-1 items-center gap-2 overflow-hidden", className)} {...props}>
<AlertTriangleIcon className="size-4 shrink-0 text-destructive" />
{children}
</div>
))
export type StackTraceErrorTypeProps = ComponentProps<"span">
export const StackTraceErrorType = memo(
({ className, children, ...props }: StackTraceErrorTypeProps) => {
const { trace } = useStackTrace()
return (
<span className={cn("shrink-0 font-semibold text-destructive", className)} {...props}>
{children ?? trace.errorType}
</span>
)
},
)
export type StackTraceErrorMessageProps = ComponentProps<"span">
export const StackTraceErrorMessage = memo(
({ className, children, ...props }: StackTraceErrorMessageProps) => {
const { trace } = useStackTrace()
return (
<span className={cn("truncate text-foreground", className)} {...props}>
{children ?? trace.errorMessage}
</span>
)
},
)
export type StackTraceActionsProps = ComponentProps<"div">
export const StackTraceActions = memo(
({ className, children, ...props }: StackTraceActionsProps) => (
<div
className={cn("flex shrink-0 items-center gap-1", className)}
onClick={e => e.stopPropagation()}
onKeyDown={e => {
if (e.key === "Enter" || e.key === " ") e.stopPropagation()
}}
role="group"
{...props}
>
{children}
</div>
),
)
export type StackTraceCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void
onError?: (error: Error) => void
timeout?: number
}
export const StackTraceCopyButton = memo(
({
onCopy,
onError,
timeout = 2000,
className,
children,
...props
}: StackTraceCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false)
const { raw } = useStackTrace()
const copyToClipboard = async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"))
return
}
try {
await navigator.clipboard.writeText(raw)
setIsCopied(true)
onCopy?.()
setTimeout(() => setIsCopied(false), timeout)
} catch (error) {
onError?.(error as Error)
}
}
const Icon = isCopied ? CheckIcon : CopyIcon
return (
<Button
className={cn("size-7", className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={14} />}
</Button>
)
},
)
export type StackTraceExpandButtonProps = ComponentProps<"div">
export const StackTraceExpandButton = memo(
({ className, ...props }: StackTraceExpandButtonProps) => {
const { isOpen } = useStackTrace()
return (
<div className={cn("flex size-7 items-center justify-center", className)} {...props}>
<ChevronDownIcon
className={cn(
"size-4 text-muted-foreground transition-transform",
isOpen ? "rotate-180" : "rotate-0",
)}
/>
</div>
)
},
)
export type StackTraceContentProps = ComponentProps<typeof CollapsibleContent> & {
maxHeight?: number
}
export const StackTraceContent = memo(
({ className, maxHeight = 400, children, ...props }: StackTraceContentProps) => {
const { isOpen } = useStackTrace()
return (
<Collapsible open={isOpen}>
<CollapsibleContent
className={cn("overflow-auto border-t bg-muted/30", className)}
style={{ maxHeight }}
{...props}
>
{children}
</CollapsibleContent>
</Collapsible>
)
},
)
export type StackTraceFramesProps = ComponentProps<"div"> & {
showInternalFrames?: boolean
}
export const StackTraceFrames = memo(
({ className, showInternalFrames = true, ...props }: StackTraceFramesProps) => {
const { trace, onFilePathClick } = useStackTrace()
const framesToShow = showInternalFrames ? trace.frames : trace.frames.filter(f => !f.isInternal)
return (
<div className={cn("space-y-1 p-3", className)} {...props}>
{framesToShow.map((frame, index) => (
<div
className={cn(
"text-xs",
frame.isInternal ? "text-muted-foreground/50" : "text-foreground/90",
)}
key={`${frame.raw}-${index}`}
>
<span className="text-muted-foreground">at </span>
{frame.functionName && (
<span className={frame.isInternal ? "" : "text-foreground"}>
{frame.functionName}{" "}
</span>
)}
{frame.filePath && (
<>
<span className="text-muted-foreground">(</span>
<button
className={cn(
"underline decoration-dotted hover:text-primary",
onFilePathClick && "cursor-pointer",
)}
disabled={!onFilePathClick}
onClick={() =>
frame.filePath &&
onFilePathClick?.(
frame.filePath,
frame.lineNumber ?? undefined,
frame.columnNumber ?? undefined,
)
}
type="button"
>
{frame.filePath}
{frame.lineNumber !== null && `:${frame.lineNumber}`}
{frame.columnNumber !== null && `:${frame.columnNumber}`}
</button>
<span className="text-muted-foreground">)</span>
</>
)}
{!(frame.filePath || frame.functionName) && (
<span>{frame.raw.replace(AT_PREFIX_REGEX, "")}</span>
)}
</div>
))}
{framesToShow.length === 0 && (
<div className="text-muted-foreground text-xs">No stack frames</div>
)}
</div>
)
},
)
StackTrace.displayName = "StackTrace"
StackTraceHeader.displayName = "StackTraceHeader"
StackTraceError.displayName = "StackTraceError"
StackTraceErrorType.displayName = "StackTraceErrorType"
StackTraceErrorMessage.displayName = "StackTraceErrorMessage"
StackTraceActions.displayName = "StackTraceActions"
StackTraceCopyButton.displayName = "StackTraceCopyButton"
StackTraceExpandButton.displayName = "StackTraceExpandButton"
StackTraceContent.displayName = "StackTraceContent"
StackTraceFrames.displayName = "StackTraceFrames"
/** Demo component for preview */
export default function StackTraceDemo() {
const sampleTrace = `TypeError: Cannot read properties of undefined (reading 'map')
at UserList (/app/components/UserList.tsx:15:23)
at renderWithHooks (node_modules/react-dom/cjs/react-dom.development.js:14985:18)
at mountIndeterminateComponent (node_modules/react-dom/cjs/react-dom.development.js:17811:13)
at beginWork (node_modules/react-dom/cjs/react-dom.development.js:19049:16)`
return (
<div className="w-full max-w-2xl p-4">
<StackTrace trace={sampleTrace} defaultOpen>
<StackTraceHeader>
<StackTraceError>
<StackTraceErrorType />
<StackTraceErrorMessage />
</StackTraceError>
<StackTraceActions>
<StackTraceCopyButton />
<StackTraceExpandButton />
</StackTraceActions>
</StackTraceHeader>
<StackTraceContent>
<StackTraceFrames showInternalFrames={false} />
</StackTraceContent>
</StackTrace>
</div>
)
}

View File

@ -0,0 +1,71 @@
"use client"
import type { ComponentProps } from "react"
import { Button } from "@/components/ui/button"
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
import { cn } from "@/lib/utils"
export type SuggestionsProps = ComponentProps<typeof ScrollArea>
export const Suggestions = ({ className, children, ...props }: SuggestionsProps) => (
<ScrollArea className="w-full overflow-x-auto whitespace-nowrap" {...props}>
<div className={cn("flex w-max flex-nowrap items-center gap-2", className)}>{children}</div>
<ScrollBar className="hidden" orientation="horizontal" />
</ScrollArea>
)
export type SuggestionProps = Omit<ComponentProps<typeof Button>, "onClick"> & {
suggestion: string
onClick?: (suggestion: string) => void
}
export const Suggestion = ({
suggestion,
onClick,
className,
variant = "outline",
size = "sm",
children,
...props
}: SuggestionProps) => {
const handleClick = () => {
onClick?.(suggestion)
}
return (
<Button
className={cn("cursor-pointer rounded-full px-4", className)}
onClick={handleClick}
size={size}
type="button"
variant={variant}
{...props}
>
{children || suggestion}
</Button>
)
}
/** Demo component for preview */
export default function SuggestionDemo() {
const suggestions = [
"What are the latest trends in AI?",
"How does machine learning work?",
"Explain quantum computing",
"Best practices for React development",
]
return (
<div className="p-6">
<Suggestions>
{suggestions.map(suggestion => (
<Suggestion
key={suggestion}
onClick={s => console.log("Selected:", s)}
suggestion={suggestion}
/>
))}
</Suggestions>
</div>
)
}

95
src/components/ai/task.tsx Executable file
View File

@ -0,0 +1,95 @@
"use client"
import { ChevronDownIcon, SearchIcon } from "lucide-react"
import type { ComponentProps } from "react"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { cn } from "@/lib/utils"
export type TaskItemFileProps = ComponentProps<"div">
export const TaskItemFile = ({ children, className, ...props }: TaskItemFileProps) => (
<div
className={cn(
"inline-flex items-center gap-1 rounded-md border bg-secondary px-1.5 py-0.5 text-foreground text-xs",
className,
)}
{...props}
>
{children}
</div>
)
export type TaskItemProps = ComponentProps<"div">
export const TaskItem = ({ children, className, ...props }: TaskItemProps) => (
<div className={cn("text-muted-foreground text-sm", className)} {...props}>
{children}
</div>
)
export type TaskProps = ComponentProps<typeof Collapsible>
export const Task = ({ defaultOpen = true, className, ...props }: TaskProps) => (
<Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} />
)
export type TaskTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
title: string
}
export const TaskTrigger = ({ children, className, title, ...props }: TaskTriggerProps) => (
<CollapsibleTrigger asChild className={cn("group", className)} {...props}>
{children ?? (
<div className="flex w-full cursor-pointer items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground">
<SearchIcon className="size-4" />
<p className="text-sm">{title}</p>
<ChevronDownIcon className="size-4 transition-transform group-data-[state=open]:rotate-180" />
</div>
)}
</CollapsibleTrigger>
)
export type TaskContentProps = ComponentProps<typeof CollapsibleContent>
export const TaskContent = ({ children, className, ...props }: TaskContentProps) => (
<CollapsibleContent
className={cn(
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className,
)}
{...props}
>
<div className="mt-4 space-y-2 border-muted border-l-2 pl-4">{children}</div>
</CollapsibleContent>
)
/** Demo component for preview */
export default function TaskDemo() {
return (
<div className="p-6" style={{ height: "200px" }}>
<Task className="w-full">
<TaskTrigger title="Found project files" />
<TaskContent>
<TaskItem>Searching {'"'}app/page.tsx, components structure{'"'}</TaskItem>
<TaskItem>
<span className="inline-flex items-center gap-1">
Read{" "}
<TaskItemFile>
<span>page.tsx</span>
</TaskItemFile>
</span>
</TaskItem>
<TaskItem>Scanning 52 files</TaskItem>
<TaskItem>
<span className="inline-flex items-center gap-1">
Reading files{" "}
<TaskItemFile>
<span>layout.tsx</span>
</TaskItemFile>
</span>
</TaskItem>
</TaskContent>
</Task>
</div>
)
}

244
src/components/ai/terminal.tsx Executable file
View File

@ -0,0 +1,244 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
"use client"
import Ansi from "ansi-to-react"
import { CheckIcon, CopyIcon, TerminalIcon, Trash2Icon } from "lucide-react"
import {
type ComponentProps,
createContext,
type HTMLAttributes,
useContext,
useEffect,
useRef,
useState,
} from "react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { Shimmer } from "./shimmer"
interface TerminalContextType {
output: string
isStreaming: boolean
autoScroll: boolean
onClear?: () => void
}
const TerminalContext = createContext<TerminalContextType>({
output: "",
isStreaming: false,
autoScroll: true,
})
export type TerminalProps = HTMLAttributes<HTMLDivElement> & {
output: string
isStreaming?: boolean
autoScroll?: boolean
onClear?: () => void
}
export const Terminal = ({
output,
isStreaming = false,
autoScroll = true,
onClear,
className,
children,
...props
}: TerminalProps) => (
<TerminalContext.Provider value={{ output, isStreaming, autoScroll, onClear }}>
<div
className={cn(
"flex flex-col overflow-hidden rounded-lg border bg-zinc-950 text-zinc-100",
className,
)}
{...props}
>
{children ?? (
<>
<TerminalHeader>
<TerminalTitle />
<div className="flex items-center gap-1">
<TerminalStatus />
<TerminalActions>
<TerminalCopyButton />
{onClear && <TerminalClearButton />}
</TerminalActions>
</div>
</TerminalHeader>
<TerminalContent />
</>
)}
</div>
</TerminalContext.Provider>
)
export type TerminalHeaderProps = HTMLAttributes<HTMLDivElement>
export const TerminalHeader = ({ className, children, ...props }: TerminalHeaderProps) => (
<div
className={cn(
"flex items-center justify-between border-zinc-800 border-b px-4 py-2",
className,
)}
{...props}
>
{children}
</div>
)
export type TerminalTitleProps = HTMLAttributes<HTMLDivElement>
export const TerminalTitle = ({ className, children, ...props }: TerminalTitleProps) => (
<div className={cn("flex items-center gap-2 text-sm text-zinc-400", className)} {...props}>
<TerminalIcon className="size-4" />
{children ?? "Terminal"}
</div>
)
export type TerminalStatusProps = HTMLAttributes<HTMLDivElement>
export const TerminalStatus = ({ className, children, ...props }: TerminalStatusProps) => {
const { isStreaming } = useContext(TerminalContext)
if (!isStreaming) {
return null
}
return (
<div className={cn("flex items-center gap-2 text-xs text-zinc-400", className)} {...props}>
{children ?? <Shimmer className="w-16" />}
</div>
)
}
export type TerminalActionsProps = HTMLAttributes<HTMLDivElement>
export const TerminalActions = ({ className, children, ...props }: TerminalActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props}>
{children}
</div>
)
export type TerminalCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void
onError?: (error: Error) => void
timeout?: number
}
export const TerminalCopyButton = ({
onCopy,
onError,
timeout = 2000,
children,
className,
...props
}: TerminalCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false)
const { output } = useContext(TerminalContext)
const copyToClipboard = async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"))
return
}
try {
await navigator.clipboard.writeText(output)
setIsCopied(true)
onCopy?.()
setTimeout(() => setIsCopied(false), timeout)
} catch (error) {
onError?.(error as Error)
}
}
const Icon = isCopied ? CheckIcon : CopyIcon
return (
<Button
className={cn(
"size-7 shrink-0 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100",
className,
)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={14} />}
</Button>
)
}
export type TerminalClearButtonProps = ComponentProps<typeof Button>
export const TerminalClearButton = ({
children,
className,
...props
}: TerminalClearButtonProps) => {
const { onClear } = useContext(TerminalContext)
if (!onClear) {
return null
}
return (
<Button
className={cn(
"size-7 shrink-0 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100",
className,
)}
onClick={onClear}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Trash2Icon size={14} />}
</Button>
)
}
export type TerminalContentProps = HTMLAttributes<HTMLDivElement>
export const TerminalContent = ({ className, children, ...props }: TerminalContentProps) => {
const { output, isStreaming, autoScroll } = useContext(TerminalContext)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (autoScroll && containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight
}
}, [output, autoScroll])
return (
<div
className={cn("max-h-96 overflow-auto p-4 font-mono text-sm leading-relaxed", className)}
ref={containerRef}
{...props}
>
{children ?? (
<pre className="whitespace-pre-wrap break-words">
<Ansi>{output}</Ansi>
{isStreaming && (
<span className="ml-0.5 inline-block h-4 w-2 animate-pulse bg-zinc-100" />
)}
</pre>
)}
</div>
)
}
/** Demo component for preview */
export default function TerminalDemo() {
const [output, setOutput] = useState(
"\x1b[32m✓\x1b[0m Compiled successfully in 1.2s\n\x1b[34m→\x1b[0m Building pages...\n\x1b[33m⚠\x1b[0m Warning: Large bundle size detected\n\x1b[32m✓\x1b[0m Generated 24 static pages\n\x1b[32m✓\x1b[0m Build completed",
)
return (
<div className="flex w-full max-w-2xl flex-col gap-4 p-4">
<Terminal output={output} isStreaming={false} onClear={() => setOutput("")} />
</div>
)
}

View File

@ -0,0 +1,367 @@
"use client"
import {
CheckCircle2Icon,
ChevronRightIcon,
CircleDotIcon,
CircleIcon,
XCircleIcon,
} from "lucide-react"
import { type ComponentProps, createContext, type HTMLAttributes, useContext } from "react"
import { Badge } from "@/components/ui/badge"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { cn } from "@/lib/utils"
type TestStatus = "passed" | "failed" | "skipped" | "running"
interface TestResultsSummaryType {
passed: number
failed: number
skipped: number
total: number
duration?: number
}
interface TestResultsContextType {
summary?: TestResultsSummaryType
}
const TestResultsContext = createContext<TestResultsContextType>({})
export type TestResultsProps = HTMLAttributes<HTMLDivElement> & {
summary?: TestResultsSummaryType
}
export const TestResults = ({ summary, className, children, ...props }: TestResultsProps) => (
<TestResultsContext.Provider value={{ summary }}>
<div className={cn("rounded-lg border bg-background", className)} {...props}>
{children ??
(summary && (
<TestResultsHeader>
<TestResultsSummary />
<TestResultsDuration />
</TestResultsHeader>
))}
</div>
</TestResultsContext.Provider>
)
export type TestResultsHeaderProps = HTMLAttributes<HTMLDivElement>
export const TestResultsHeader = ({ className, children, ...props }: TestResultsHeaderProps) => (
<div className={cn("flex items-center justify-between border-b px-4 py-3", className)} {...props}>
{children}
</div>
)
export type TestResultsSummaryProps = HTMLAttributes<HTMLDivElement>
export const TestResultsSummary = ({ className, children, ...props }: TestResultsSummaryProps) => {
const { summary } = useContext(TestResultsContext)
if (!summary) return null
return (
<div className={cn("flex items-center gap-3", className)} {...props}>
{children ?? (
<>
<Badge
className="gap-1 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
variant="secondary"
>
<CheckCircle2Icon className="size-3" />
{summary.passed} passed
</Badge>
{summary.failed > 0 && (
<Badge
className="gap-1 bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
variant="secondary"
>
<XCircleIcon className="size-3" />
{summary.failed} failed
</Badge>
)}
{summary.skipped > 0 && (
<Badge
className="gap-1 bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"
variant="secondary"
>
<CircleIcon className="size-3" />
{summary.skipped} skipped
</Badge>
)}
</>
)}
</div>
)
}
export type TestResultsDurationProps = HTMLAttributes<HTMLSpanElement>
export const TestResultsDuration = ({
className,
children,
...props
}: TestResultsDurationProps) => {
const { summary } = useContext(TestResultsContext)
if (!summary?.duration) return null
const formatDuration = (ms: number) => (ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(2)}s`)
return (
<span className={cn("text-muted-foreground text-sm", className)} {...props}>
{children ?? formatDuration(summary.duration)}
</span>
)
}
export type TestResultsProgressProps = HTMLAttributes<HTMLDivElement>
export const TestResultsProgress = ({
className,
children,
...props
}: TestResultsProgressProps) => {
const { summary } = useContext(TestResultsContext)
if (!summary) return null
const passedPercent = (summary.passed / summary.total) * 100
const failedPercent = (summary.failed / summary.total) * 100
return (
<div className={cn("space-y-2", className)} {...props}>
{children ?? (
<>
<div className="flex h-2 overflow-hidden rounded-full bg-muted">
<div className="bg-green-500 transition-all" style={{ width: `${passedPercent}%` }} />
<div className="bg-red-500 transition-all" style={{ width: `${failedPercent}%` }} />
</div>
<div className="flex justify-between text-muted-foreground text-xs">
<span>
{summary.passed}/{summary.total} tests passed
</span>
<span>{passedPercent.toFixed(0)}%</span>
</div>
</>
)}
</div>
)
}
export type TestResultsContentProps = HTMLAttributes<HTMLDivElement>
export const TestResultsContent = ({ className, children, ...props }: TestResultsContentProps) => (
<div className={cn("space-y-2 p-4", className)} {...props}>
{children}
</div>
)
interface TestSuiteContextType {
name: string
status: TestStatus
}
const TestSuiteContext = createContext<TestSuiteContextType>({ name: "", status: "passed" })
export type TestSuiteProps = ComponentProps<typeof Collapsible> & {
name: string
status: TestStatus
}
export const TestSuite = ({ name, status, className, children, ...props }: TestSuiteProps) => (
<TestSuiteContext.Provider value={{ name, status }}>
<Collapsible className={cn("rounded-lg border", className)} {...props}>
{children}
</Collapsible>
</TestSuiteContext.Provider>
)
export type TestSuiteNameProps = ComponentProps<typeof CollapsibleTrigger>
export const TestSuiteName = ({ className, children, ...props }: TestSuiteNameProps) => {
const { name, status } = useContext(TestSuiteContext)
return (
<CollapsibleTrigger
className={cn(
"group flex w-full items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-muted/50",
className,
)}
{...props}
>
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
<TestStatusIcon status={status} />
<span className="font-medium text-sm">{children ?? name}</span>
</CollapsibleTrigger>
)
}
export type TestSuiteStatsProps = HTMLAttributes<HTMLDivElement> & {
passed?: number
failed?: number
skipped?: number
}
export const TestSuiteStats = ({
passed = 0,
failed = 0,
skipped = 0,
className,
children,
...props
}: TestSuiteStatsProps) => (
<div className={cn("ml-auto flex items-center gap-2 text-xs", className)} {...props}>
{children ?? (
<>
{passed > 0 && <span className="text-green-600 dark:text-green-400">{passed} passed</span>}
{failed > 0 && <span className="text-red-600 dark:text-red-400">{failed} failed</span>}
{skipped > 0 && (
<span className="text-yellow-600 dark:text-yellow-400">{skipped} skipped</span>
)}
</>
)}
</div>
)
export type TestSuiteContentProps = ComponentProps<typeof CollapsibleContent>
export const TestSuiteContent = ({ className, children, ...props }: TestSuiteContentProps) => (
<CollapsibleContent className={cn("border-t", className)} {...props}>
<div className="divide-y">{children}</div>
</CollapsibleContent>
)
interface TestContextType {
name: string
status: TestStatus
duration?: number
}
const TestContext = createContext<TestContextType>({ name: "", status: "passed" })
export type TestProps = HTMLAttributes<HTMLDivElement> & {
name: string
status: TestStatus
duration?: number
}
export const Test = ({ name, status, duration, className, children, ...props }: TestProps) => (
<TestContext.Provider value={{ name, status, duration }}>
<div className={cn("flex items-center gap-2 px-4 py-2 text-sm", className)} {...props}>
{children ?? (
<>
<TestStatus />
<TestName />
{duration !== undefined && <TestDuration />}
</>
)}
</div>
</TestContext.Provider>
)
const statusStyles: Record<TestStatus, string> = {
passed: "text-green-600 dark:text-green-400",
failed: "text-red-600 dark:text-red-400",
skipped: "text-yellow-600 dark:text-yellow-400",
running: "text-blue-600 dark:text-blue-400",
}
const statusIcons: Record<TestStatus, React.ReactNode> = {
passed: <CheckCircle2Icon className="size-4" />,
failed: <XCircleIcon className="size-4" />,
skipped: <CircleIcon className="size-4" />,
running: <CircleDotIcon className="size-4 animate-pulse" />,
}
const TestStatusIcon = ({ status }: { status: TestStatus }) => (
<span className={cn("shrink-0", statusStyles[status])}>{statusIcons[status]}</span>
)
export type TestStatusProps = HTMLAttributes<HTMLSpanElement>
export const TestStatus = ({ className, children, ...props }: TestStatusProps) => {
const { status } = useContext(TestContext)
return (
<span className={cn("shrink-0", statusStyles[status], className)} {...props}>
{children ?? statusIcons[status]}
</span>
)
}
export type TestNameProps = HTMLAttributes<HTMLSpanElement>
export const TestName = ({ className, children, ...props }: TestNameProps) => {
const { name } = useContext(TestContext)
return (
<span className={cn("flex-1", className)} {...props}>
{children ?? name}
</span>
)
}
export type TestDurationProps = HTMLAttributes<HTMLSpanElement>
export const TestDuration = ({ className, children, ...props }: TestDurationProps) => {
const { duration } = useContext(TestContext)
if (duration === undefined) return null
return (
<span className={cn("ml-auto text-muted-foreground text-xs", className)} {...props}>
{children ?? `${duration}ms`}
</span>
)
}
export type TestErrorProps = HTMLAttributes<HTMLDivElement>
export const TestError = ({ className, children, ...props }: TestErrorProps) => (
<div className={cn("mt-2 rounded-md bg-red-50 p-3 dark:bg-red-900/20", className)} {...props}>
{children}
</div>
)
export type TestErrorMessageProps = HTMLAttributes<HTMLParagraphElement>
export const TestErrorMessage = ({ className, children, ...props }: TestErrorMessageProps) => (
<p className={cn("font-medium text-red-700 text-sm dark:text-red-400", className)} {...props}>
{children}
</p>
)
export type TestErrorStackProps = HTMLAttributes<HTMLPreElement>
export const TestErrorStack = ({ className, children, ...props }: TestErrorStackProps) => (
<pre
className={cn("mt-2 overflow-auto font-mono text-red-600 text-xs dark:text-red-400", className)}
{...props}
>
{children}
</pre>
)
/** Demo component for preview */
export default function TestResultsDemo() {
const summary = { passed: 8, failed: 1, skipped: 1, total: 10, duration: 1234 }
return (
<div className="w-full max-w-lg p-4">
<TestResults summary={summary}>
<TestResultsHeader>
<TestResultsSummary />
<TestResultsDuration />
</TestResultsHeader>
<TestResultsContent>
<TestSuite name="auth.test.ts" status="passed" defaultOpen>
<TestSuiteName />
<TestSuiteContent>
<Test name="should login with valid credentials" status="passed" duration={45} />
<Test name="should reject invalid password" status="passed" duration={32} />
<Test name="should handle timeout" status="failed" duration={5001} />
</TestSuiteContent>
</TestSuite>
</TestResultsContent>
</TestResults>
</div>
)
}

153
src/components/ai/tool.tsx Executable file
View File

@ -0,0 +1,153 @@
"use client"
import type { ToolUIPart } from "ai"
import {
CheckCircleIcon,
ChevronDownIcon,
LoaderIcon,
XCircleIcon,
} from "lucide-react"
import type { ComponentProps, ReactNode } from "react"
import { isValidElement } from "react"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { cn } from "@/lib/utils"
import { CodeBlock } from "@/components/ai/code-block"
export type ToolProps = ComponentProps<typeof Collapsible>
export const Tool = ({ className, ...props }: ToolProps) => (
<Collapsible className={cn("not-prose mb-2", className)} {...props} />
)
export interface ToolHeaderProps {
title?: string
type: ToolUIPart["type"]
state: ToolUIPart["state"]
className?: string
}
const getStatusIcon = (status: ToolUIPart["state"]): ReactNode => {
switch (status) {
case "input-streaming":
case "input-available":
return <LoaderIcon className="size-3.5 animate-spin text-muted-foreground" />
case "output-available":
return <CheckCircleIcon className="size-3.5 text-primary" />
case "output-error":
case "output-denied":
return <XCircleIcon className="size-3.5 text-destructive" />
default:
return <LoaderIcon className="size-3.5 text-muted-foreground" />
}
}
const isInProgress = (status: ToolUIPart["state"]): boolean =>
status === "input-streaming" || status === "input-available"
export const ToolHeader = ({ className, title, type, state, ...props }: ToolHeaderProps) => (
<CollapsibleTrigger
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/80",
className,
)}
{...props}
>
{getStatusIcon(state)}
<span>
{title ?? type.split("-").slice(1).join("-")}
{isInProgress(state) && "..."}
</span>
<ChevronDownIcon className="size-3 opacity-50 transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
)
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
<CollapsibleContent
className={cn(
"mt-1 rounded-lg border bg-muted/30 data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className,
)}
{...props}
/>
)
export type ToolInputProps = ComponentProps<"div"> & {
input: ToolUIPart["input"]
}
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
<div className={cn("space-y-2 overflow-hidden p-3", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Parameters
</h4>
<div className="rounded-md bg-muted/50">
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
</div>
</div>
)
export type ToolOutputProps = ComponentProps<"div"> & {
output: ToolUIPart["output"]
errorText: ToolUIPart["errorText"]
}
export const ToolOutput = ({ className, output, errorText, ...props }: ToolOutputProps) => {
if (!(output || errorText)) {
return null
}
let Output = <div>{output as ReactNode}</div>
if (typeof output === "object" && !isValidElement(output)) {
Output = <CodeBlock code={JSON.stringify(output, null, 2)} language="json" />
} else if (typeof output === "string") {
Output = <CodeBlock code={output} language="json" />
}
return (
<div className={cn("space-y-2 p-3", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
{errorText ? "Error" : "Result"}
</h4>
<div
className={cn(
"overflow-x-auto rounded-md text-xs [&_table]:w-full",
errorText ? "bg-destructive/10 text-destructive" : "bg-muted/50 text-foreground",
)}
>
{errorText && <div>{errorText}</div>}
{Output}
</div>
</div>
)
}
/** Demo component for preview */
export default function ToolDemo() {
return (
<div className="w-full max-w-2xl p-6">
<Tool defaultOpen>
<ToolHeader title="Weather Lookup" type="tool-invocation" state="output-available" />
<ToolContent>
<ToolInput
input={{
location: "San Francisco, CA",
units: "fahrenheit",
}}
/>
<ToolOutput
output={{
temperature: 68,
condition: "Partly cloudy",
humidity: 65,
wind: "12 mph NW",
}}
errorText={undefined}
/>
</ToolContent>
</Tool>
</div>
)
}

77
src/components/ai/toolbar.tsx Executable file
View File

@ -0,0 +1,77 @@
"use client"
import { NodeToolbar, Position } from "@xyflow/react"
import type { ComponentProps } from "react"
import { cn } from "@/lib/utils"
type ToolbarProps = ComponentProps<typeof NodeToolbar>
export const Toolbar = ({ className, ...props }: ToolbarProps) => (
<NodeToolbar
className={cn("flex items-center gap-1 rounded-sm border bg-background p-1.5", className)}
position={Position.Bottom}
{...props}
/>
)
import { Background, ReactFlow, ReactFlowProvider } from "@xyflow/react"
import { CopyIcon, EditIcon, TrashIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import "@xyflow/react/dist/style.css"
const ToolbarNode = ({ data, selected }: { data: { label: string }; selected: boolean }) => (
<div
className={cn(
"rounded-lg border-2 bg-card px-4 py-2 text-sm font-medium transition-all",
selected ? "border-primary shadow-md" : "border-transparent",
)}
>
{data.label}
<Toolbar>
<Button size="icon-sm" variant="ghost" className="size-7">
<EditIcon className="size-3.5" />
</Button>
<Button size="icon-sm" variant="ghost" className="size-7">
<CopyIcon className="size-3.5" />
</Button>
<Button
size="icon-sm"
variant="ghost"
className="size-7 text-destructive hover:text-destructive"
>
<TrashIcon className="size-3.5" />
</Button>
</Toolbar>
</div>
)
const nodeTypes = { toolbar: ToolbarNode }
const initialNodes = [
{ id: "1", type: "toolbar", position: { x: 50, y: 50 }, data: { label: "Node A" } },
{ id: "2", type: "toolbar", position: { x: 200, y: 120 }, data: { label: "Node B" } },
{ id: "3", type: "toolbar", position: { x: 100, y: 200 }, data: { label: "Node C" } },
]
/** Demo component for preview */
export default function ToolbarDemo() {
return (
<div className="h-full min-h-[500px] w-full">
<ReactFlowProvider>
<ReactFlow
defaultNodes={initialNodes}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.5 }}
panOnScroll
proOptions={{ hideAttribution: true }}
>
<Background gap={16} size={1} />
<div className="absolute bottom-4 left-4 rounded-md bg-background/80 px-3 py-2 text-muted-foreground text-xs backdrop-blur-sm">
Click a node to show its toolbar
</div>
</ReactFlow>
</ReactFlowProvider>
</div>
)
}

View File

@ -0,0 +1,154 @@
"use client"
import { useControllableState } from "@radix-ui/react-use-controllable-state"
import type { ComponentProps, ReactNode } from "react"
import { createContext, useContext } from "react"
import { cn } from "@/lib/utils"
interface TranscriptionSegment {
text: string
startSecond: number
endSecond: number
}
interface TranscriptionContextValue {
segments: TranscriptionSegment[]
currentTime: number
onTimeUpdate: (time: number) => void
onSeek?: (time: number) => void
}
const TranscriptionContext = createContext<TranscriptionContextValue | null>(null)
const useTranscription = () => {
const context = useContext(TranscriptionContext)
if (!context) {
throw new Error("Transcription components must be used within Transcription")
}
return context
}
export type TranscriptionProps = Omit<ComponentProps<"div">, "children"> & {
segments: TranscriptionSegment[]
currentTime?: number
onSeek?: (time: number) => void
children: (segment: TranscriptionSegment, index: number) => ReactNode
}
export const Transcription = ({
segments,
currentTime: externalCurrentTime,
onSeek,
className,
children,
...props
}: TranscriptionProps) => {
const [currentTime, setCurrentTime] = useControllableState({
prop: externalCurrentTime,
defaultProp: 0,
onChange: onSeek,
})
return (
<TranscriptionContext.Provider
value={{
segments,
currentTime: currentTime ?? 0,
onTimeUpdate: setCurrentTime,
onSeek,
}}
>
<div
className={cn("flex flex-wrap gap-1 text-sm leading-relaxed", className)}
data-slot="transcription"
{...props}
>
{segments
.filter(segment => segment.text.trim())
.map((segment, index) => children(segment, index))}
</div>
</TranscriptionContext.Provider>
)
}
export type TranscriptionSegmentProps = ComponentProps<"button"> & {
segment: TranscriptionSegment
index: number
}
export const TranscriptionSegment = ({
segment,
index,
className,
onClick,
...props
}: TranscriptionSegmentProps) => {
const { currentTime, onSeek } = useTranscription()
const isActive = currentTime >= segment.startSecond && currentTime < segment.endSecond
const isPast = currentTime >= segment.endSecond
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
if (onSeek) {
onSeek(segment.startSecond)
}
onClick?.(event)
}
return (
<button
className={cn(
"inline text-left",
isActive && "text-primary",
isPast && "text-muted-foreground",
!(isActive || isPast) && "text-muted-foreground/60",
onSeek && "cursor-pointer hover:text-foreground",
!onSeek && "cursor-default",
className,
)}
data-active={isActive}
data-index={index}
data-slot="transcription-segment"
onClick={handleClick}
type="button"
{...props}
>
{segment.text}
</button>
)
}
/** Demo component for preview */
export default function TranscriptionDemo() {
const sampleSegments: TranscriptionSegment[] = [
{ text: "Hello", startSecond: 0, endSecond: 0.5 },
{ text: "and", startSecond: 0.5, endSecond: 0.7 },
{ text: "welcome", startSecond: 0.7, endSecond: 1.2 },
{ text: "to", startSecond: 1.2, endSecond: 1.4 },
{ text: "this", startSecond: 1.4, endSecond: 1.6 },
{ text: "demonstration", startSecond: 1.6, endSecond: 2.3 },
{ text: "of", startSecond: 2.3, endSecond: 2.5 },
{ text: "the", startSecond: 2.5, endSecond: 2.7 },
{ text: "transcription", startSecond: 2.7, endSecond: 3.4 },
{ text: "component.", startSecond: 3.4, endSecond: 4.0 },
{ text: "Click", startSecond: 4.0, endSecond: 4.3 },
{ text: "any", startSecond: 4.3, endSecond: 4.5 },
{ text: "word", startSecond: 4.5, endSecond: 4.8 },
{ text: "to", startSecond: 4.8, endSecond: 5.0 },
{ text: "seek!", startSecond: 5.0, endSecond: 5.5 },
]
return (
<div className="w-full max-w-md p-4">
<div className="rounded-lg border bg-background p-4">
<Transcription
segments={sampleSegments}
currentTime={2.6}
onSeek={time => console.log("Seek to:", time)}
>
{(segment, index) => <TranscriptionSegment key={index} segment={segment} index={index} />}
</Transcription>
</div>
</div>
)
}

View File

@ -0,0 +1,370 @@
"use client"
import { useControllableState } from "@radix-ui/react-use-controllable-state"
import {
CircleIcon,
LoaderCircleIcon,
MarsIcon,
PauseIcon,
PlayIcon,
VenusIcon,
} from "lucide-react"
import type { ComponentProps, ReactNode } from "react"
import { createContext, useContext, useMemo, useState } from "react"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command"
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { cn } from "@/lib/utils"
interface VoiceSelectorContextValue {
value: string | undefined
setValue: (value: string | undefined) => void
open: boolean
setOpen: (open: boolean) => void
}
const VoiceSelectorContext = createContext<VoiceSelectorContextValue | null>(null)
export const useVoiceSelector = () => {
const context = useContext(VoiceSelectorContext)
if (!context) {
throw new Error("VoiceSelector components must be used within VoiceSelector")
}
return context
}
export type VoiceSelectorProps = ComponentProps<typeof Dialog> & {
value?: string
defaultValue?: string
onValueChange?: (value: string | undefined) => void
}
export const VoiceSelector = ({
value: valueProp,
defaultValue,
onValueChange,
open: openProp,
defaultOpen = false,
onOpenChange,
children,
...props
}: VoiceSelectorProps) => {
const [value, setValue] = useControllableState({
prop: valueProp,
defaultProp: defaultValue,
onChange: onValueChange,
})
const [open, setOpen] = useControllableState({
prop: openProp,
defaultProp: defaultOpen,
onChange: onOpenChange,
})
const voiceSelectorContext = useMemo(
() => ({ value, setValue, open: open ?? false, setOpen }),
[value, setValue, open, setOpen],
)
return (
<VoiceSelectorContext.Provider value={voiceSelectorContext}>
<Dialog onOpenChange={setOpen} open={open} {...props}>
{children}
</Dialog>
</VoiceSelectorContext.Provider>
)
}
export type VoiceSelectorTriggerProps = ComponentProps<typeof DialogTrigger>
export const VoiceSelectorTrigger = (props: VoiceSelectorTriggerProps) => (
<DialogTrigger {...props} />
)
export type VoiceSelectorContentProps = ComponentProps<typeof DialogContent> & {
title?: ReactNode
}
export const VoiceSelectorContent = ({
className,
children,
title = "Voice Selector",
...props
}: VoiceSelectorContentProps) => (
<DialogContent className={cn("p-0", className)} {...props}>
<DialogTitle className="sr-only">{title}</DialogTitle>
<Command>{children}</Command>
</DialogContent>
)
export type VoiceSelectorInputProps = ComponentProps<typeof CommandInput>
export const VoiceSelectorInput = ({ className, ...props }: VoiceSelectorInputProps) => (
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
)
export type VoiceSelectorListProps = ComponentProps<typeof CommandList>
export const VoiceSelectorList = (props: VoiceSelectorListProps) => <CommandList {...props} />
export type VoiceSelectorEmptyProps = ComponentProps<typeof CommandEmpty>
export const VoiceSelectorEmpty = (props: VoiceSelectorEmptyProps) => <CommandEmpty {...props} />
export type VoiceSelectorGroupProps = ComponentProps<typeof CommandGroup>
export const VoiceSelectorGroup = (props: VoiceSelectorGroupProps) => <CommandGroup {...props} />
export type VoiceSelectorItemProps = ComponentProps<typeof CommandItem>
export const VoiceSelectorItem = ({ className, ...props }: VoiceSelectorItemProps) => (
<CommandItem className={cn("px-4 py-2", className)} {...props} />
)
export type VoiceSelectorShortcutProps = ComponentProps<typeof CommandShortcut>
export const VoiceSelectorShortcut = (props: VoiceSelectorShortcutProps) => (
<CommandShortcut {...props} />
)
export type VoiceSelectorSeparatorProps = ComponentProps<typeof CommandSeparator>
export const VoiceSelectorSeparator = (props: VoiceSelectorSeparatorProps) => (
<CommandSeparator {...props} />
)
export type VoiceSelectorGenderProps = ComponentProps<"span"> & {
value?: "male" | "female" | "transgender" | "androgyne" | "non-binary" | "intersex"
}
export const VoiceSelectorGender = ({
className,
value,
children,
...props
}: VoiceSelectorGenderProps) => {
let icon: ReactNode | null = null
switch (value) {
case "male":
icon = <MarsIcon className="size-4" />
break
case "female":
icon = <VenusIcon className="size-4" />
break
default:
icon = <CircleIcon className="size-4" />
}
return (
<span className={cn("text-muted-foreground text-xs", className)} {...props}>
{children ?? icon}
</span>
)
}
export type VoiceSelectorAccentProps = ComponentProps<"span"> & {
value?: string
}
const accentEmojis: Record<string, string> = {
american: "\u{1F1FA}\u{1F1F8}",
british: "\u{1F1EC}\u{1F1E7}",
australian: "\u{1F1E6}\u{1F1FA}",
canadian: "\u{1F1E8}\u{1F1E6}",
irish: "\u{1F1EE}\u{1F1EA}",
indian: "\u{1F1EE}\u{1F1F3}",
french: "\u{1F1EB}\u{1F1F7}",
german: "\u{1F1E9}\u{1F1EA}",
italian: "\u{1F1EE}\u{1F1F9}",
spanish: "\u{1F1EA}\u{1F1F8}",
japanese: "\u{1F1EF}\u{1F1F5}",
chinese: "\u{1F1E8}\u{1F1F3}",
korean: "\u{1F1F0}\u{1F1F7}",
}
export const VoiceSelectorAccent = ({
className,
value,
children,
...props
}: VoiceSelectorAccentProps) => {
const emoji = value ? accentEmojis[value] : null
return (
<span className={cn("text-muted-foreground text-xs", className)} {...props}>
{children ?? emoji}
</span>
)
}
export type VoiceSelectorAgeProps = ComponentProps<"span">
export const VoiceSelectorAge = ({ className, ...props }: VoiceSelectorAgeProps) => (
<span className={cn("text-muted-foreground text-xs tabular-nums", className)} {...props} />
)
export type VoiceSelectorNameProps = ComponentProps<"span">
export const VoiceSelectorName = ({ className, ...props }: VoiceSelectorNameProps) => (
<span className={cn("flex-1 truncate text-left font-medium", className)} {...props} />
)
export type VoiceSelectorDescriptionProps = ComponentProps<"span">
export const VoiceSelectorDescription = ({
className,
...props
}: VoiceSelectorDescriptionProps) => (
<span className={cn("text-muted-foreground text-xs", className)} {...props} />
)
export type VoiceSelectorAttributesProps = ComponentProps<"div">
export const VoiceSelectorAttributes = ({
className,
children,
...props
}: VoiceSelectorAttributesProps) => (
<div className={cn("flex items-center text-xs", className)} {...props}>
{children}
</div>
)
export type VoiceSelectorBulletProps = ComponentProps<"span">
export const VoiceSelectorBullet = ({ className, ...props }: VoiceSelectorBulletProps) => (
<span aria-hidden="true" className={cn("select-none text-border", className)} {...props}>
&bull;
</span>
)
export type VoiceSelectorPreviewProps = Omit<ComponentProps<"button">, "children"> & {
playing?: boolean
loading?: boolean
onPlay?: () => void
}
export const VoiceSelectorPreview = ({
className,
playing,
loading,
onPlay,
onClick,
...props
}: VoiceSelectorPreviewProps) => {
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
onClick?.(event)
onPlay?.()
}
let icon = <PlayIcon className="size-3" />
if (loading) {
icon = <LoaderCircleIcon className="size-3 animate-spin" />
} else if (playing) {
icon = <PauseIcon className="size-3" />
}
return (
<Button
aria-label={playing ? "Pause preview" : "Play preview"}
className={cn("size-6", className)}
disabled={loading}
onClick={handleClick}
size="icon"
type="button"
variant="outline"
{...props}
>
{icon}
</Button>
)
}
/** Demo component for preview */
export default function VoiceSelectorDemo() {
const [selectedVoice, setSelectedVoice] = useState<string | undefined>()
const [playingVoice, setPlayingVoice] = useState<string | null>(null)
const voices = [
{
id: "alloy",
name: "Alloy",
gender: "female" as const,
accent: "american",
description: "Warm and professional",
},
{
id: "echo",
name: "Echo",
gender: "male" as const,
accent: "british",
description: "Clear and articulate",
},
{
id: "nova",
name: "Nova",
gender: "female" as const,
accent: "australian",
description: "Friendly and energetic",
},
{
id: "onyx",
name: "Onyx",
gender: "male" as const,
accent: "american",
description: "Deep and authoritative",
},
]
return (
<div className="w-full max-w-sm p-4">
<VoiceSelector value={selectedVoice} onValueChange={setSelectedVoice}>
<VoiceSelectorTrigger asChild>
<Button variant="outline" className="w-full justify-between">
{selectedVoice ? voices.find(v => v.id === selectedVoice)?.name : "Select voice..."}
</Button>
</VoiceSelectorTrigger>
<VoiceSelectorContent>
<VoiceSelectorInput placeholder="Search voices..." />
<VoiceSelectorList>
<VoiceSelectorEmpty>No voices found.</VoiceSelectorEmpty>
<VoiceSelectorGroup heading="Available Voices">
{voices.map(voice => (
<VoiceSelectorItem
key={voice.id}
value={voice.id}
className="flex items-center gap-3"
>
<VoiceSelectorPreview
playing={playingVoice === voice.id}
onPlay={() => setPlayingVoice(voice.id)}
/>
<div className="flex flex-1 flex-col">
<VoiceSelectorName>{voice.name}</VoiceSelectorName>
<VoiceSelectorDescription>{voice.description}</VoiceSelectorDescription>
</div>
<VoiceSelectorAttributes className="gap-2">
<VoiceSelectorGender value={voice.gender} />
<VoiceSelectorAccent value={voice.accent} />
</VoiceSelectorAttributes>
</VoiceSelectorItem>
))}
</VoiceSelectorGroup>
</VoiceSelectorList>
</VoiceSelectorContent>
</VoiceSelector>
</div>
)
}

286
src/components/ai/web-preview.tsx Executable file
View File

@ -0,0 +1,286 @@
"use client"
import {
ArrowLeftIcon,
ArrowRightIcon,
ChevronDownIcon,
ExternalLinkIcon,
Maximize2Icon,
MousePointerClickIcon,
RefreshCcwIcon,
} from "lucide-react"
import type { ComponentProps, ReactNode } from "react"
import { createContext, useContext, useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { Input } from "@/components/ui/input"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
export interface WebPreviewContextValue {
url: string
setUrl: (url: string) => void
consoleOpen: boolean
setConsoleOpen: (open: boolean) => void
}
const WebPreviewContext = createContext<WebPreviewContextValue | null>(null)
const useWebPreview = () => {
const context = useContext(WebPreviewContext)
if (!context) {
throw new Error("WebPreview components must be used within a WebPreview")
}
return context
}
export type WebPreviewProps = ComponentProps<"div"> & {
defaultUrl?: string
onUrlChange?: (url: string) => void
}
export const WebPreview = ({
className,
children,
defaultUrl = "",
onUrlChange,
...props
}: WebPreviewProps) => {
const [url, setUrl] = useState(defaultUrl)
const [consoleOpen, setConsoleOpen] = useState(false)
const handleUrlChange = (newUrl: string) => {
setUrl(newUrl)
onUrlChange?.(newUrl)
}
const contextValue: WebPreviewContextValue = {
url,
setUrl: handleUrlChange,
consoleOpen,
setConsoleOpen,
}
return (
<WebPreviewContext.Provider value={contextValue}>
<div
className={cn("flex size-full flex-col rounded-lg border bg-card", className)}
{...props}
>
{children}
</div>
</WebPreviewContext.Provider>
)
}
export type WebPreviewNavigationProps = ComponentProps<"div">
export const WebPreviewNavigation = ({
className,
children,
...props
}: WebPreviewNavigationProps) => (
<div className={cn("flex items-center gap-1 border-b p-2", className)} {...props}>
{children}
</div>
)
export type WebPreviewNavigationButtonProps = ComponentProps<typeof Button> & {
tooltip?: string
}
export const WebPreviewNavigationButton = ({
onClick,
disabled,
tooltip,
children,
...props
}: WebPreviewNavigationButtonProps) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
className="h-8 w-8 p-0 hover:text-foreground"
disabled={disabled}
onClick={onClick}
size="sm"
variant="ghost"
{...props}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
export type WebPreviewUrlProps = ComponentProps<typeof Input>
export const WebPreviewUrl = ({ value, onChange, onKeyDown, ...props }: WebPreviewUrlProps) => {
const { url, setUrl } = useWebPreview()
const [inputValue, setInputValue] = useState(url)
// Sync input value with context URL when it changes externally
useEffect(() => {
setInputValue(url)
}, [url])
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value)
onChange?.(event)
}
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
const target = event.target as HTMLInputElement
setUrl(target.value)
}
onKeyDown?.(event)
}
return (
<Input
className="h-8 flex-1 text-sm"
onChange={onChange ?? handleChange}
onKeyDown={handleKeyDown}
placeholder="Enter URL..."
value={value ?? inputValue}
{...props}
/>
)
}
export type WebPreviewBodyProps = ComponentProps<"iframe"> & {
loading?: ReactNode
}
export const WebPreviewBody = ({ className, loading, src, ...props }: WebPreviewBodyProps) => {
const { url } = useWebPreview()
return (
<div className="flex-1">
<iframe
className={cn("size-full", className)}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-presentation"
src={(src ?? url) || undefined}
title="Preview"
{...props}
/>
{loading}
</div>
)
}
export type WebPreviewConsoleProps = ComponentProps<"div"> & {
logs?: Array<{
level: "log" | "warn" | "error"
message: string
timestamp: Date
}>
}
export const WebPreviewConsole = ({
className,
logs = [],
children,
...props
}: WebPreviewConsoleProps) => {
const { consoleOpen, setConsoleOpen } = useWebPreview()
return (
<Collapsible
className={cn("border-t bg-muted/50 font-mono text-sm", className)}
onOpenChange={setConsoleOpen}
open={consoleOpen}
{...props}
>
<CollapsibleTrigger asChild>
<Button
className="flex w-full items-center justify-between p-4 text-left font-medium hover:bg-muted/50"
variant="ghost"
>
Console
<ChevronDownIcon
className={cn("h-4 w-4 transition-transform duration-200", consoleOpen && "rotate-180")}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent
className={cn(
"px-4 pb-4",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
)}
>
<div className="max-h-48 space-y-1 overflow-y-auto">
{logs.length === 0 ? (
<p className="text-muted-foreground">No console output</p>
) : (
logs.map((log, index) => (
<div
className={cn(
"text-xs",
log.level === "error" && "text-destructive",
log.level === "warn" && "text-yellow-600",
log.level === "log" && "text-foreground",
)}
key={`${log.timestamp.getTime()}-${index}`}
>
<span className="text-muted-foreground">{log.timestamp.toLocaleTimeString()}</span>{" "}
{log.message}
</div>
))
)}
{children}
</div>
</CollapsibleContent>
</Collapsible>
)
}
const exampleLogs = [
{
level: "log" as const,
message: "Page loaded successfully",
timestamp: new Date(Date.now() - 10_000),
},
{
level: "warn" as const,
message: "Deprecated API usage detected",
timestamp: new Date(Date.now() - 5000),
},
{ level: "error" as const, message: "Failed to load resource", timestamp: new Date() },
]
/** Demo component for preview */
export default function WebPreviewDemo() {
return (
<WebPreview defaultUrl="/" style={{ height: "400px" }}>
<WebPreviewNavigation>
<WebPreviewNavigationButton tooltip="Go back">
<ArrowLeftIcon className="size-4" />
</WebPreviewNavigationButton>
<WebPreviewNavigationButton tooltip="Go forward">
<ArrowRightIcon className="size-4" />
</WebPreviewNavigationButton>
<WebPreviewNavigationButton tooltip="Reload">
<RefreshCcwIcon className="size-4" />
</WebPreviewNavigationButton>
<WebPreviewUrl />
<WebPreviewNavigationButton tooltip="Select">
<MousePointerClickIcon className="size-4" />
</WebPreviewNavigationButton>
<WebPreviewNavigationButton tooltip="Open in new tab">
<ExternalLinkIcon className="size-4" />
</WebPreviewNavigationButton>
<WebPreviewNavigationButton tooltip="Maximize">
<Maximize2Icon className="size-4" />
</WebPreviewNavigationButton>
</WebPreviewNavigation>
<WebPreviewBody src="https://example.com" />
<WebPreviewConsole logs={exampleLogs} />
</WebPreview>
)
}

View File

@ -24,7 +24,7 @@ import { NavProjects } from "@/components/nav-projects"
import { NavUser } from "@/components/nav-user" import { NavUser } from "@/components/nav-user"
import { useCommandMenu } from "@/components/command-menu-provider" import { useCommandMenu } from "@/components/command-menu-provider"
import { useSettings } from "@/components/settings-provider" import { useSettings } from "@/components/settings-provider"
import { useAgentOptional } from "@/components/agent/agent-provider" import { useAgentOptional } from "@/components/agent/chat-provider"
import type { SidebarUser } from "@/lib/auth" import type { SidebarUser } from "@/lib/auth"
import { import {
Sidebar, Sidebar,

View File

@ -12,7 +12,7 @@ import {
IconSun, IconSun,
IconSearch, IconSearch,
} from "@tabler/icons-react" } from "@tabler/icons-react"
import { useAgentOptional } from "@/components/agent/agent-provider" import { useAgentOptional } from "@/components/agent/chat-provider"
import { import {
CommandDialog, CommandDialog,

View File

@ -1,712 +0,0 @@
"use client"
import {
useState,
useCallback,
useRef,
useEffect,
} from "react"
import { usePathname, useRouter } from "next/navigation"
import {
ArrowUp,
Plus,
SendHorizonal,
Square,
Copy,
ThumbsUp,
ThumbsDown,
RefreshCw,
Check,
} from "lucide-react"
import { toast } from "sonner"
import { cn } from "@/lib/utils"
import { MarkdownRenderer } from "@/components/ui/markdown-renderer"
import { TypingIndicator } from "@/components/ui/typing-indicator"
import { PromptSuggestions } from "@/components/ui/prompt-suggestions"
import {
useAutosizeTextArea,
} from "@/hooks/use-autosize-textarea"
import { useChat } from "@ai-sdk/react"
import { DefaultChatTransport } from "ai"
import {
dispatchToolActions,
initializeActionHandlers,
unregisterActionHandler,
ALL_HANDLER_TYPES,
} from "@/lib/agent/chat-adapter"
import {
IconBrandGithub,
IconExternalLink,
IconGitFork,
IconStar,
IconAlertCircle,
IconEye,
} from "@tabler/icons-react"
type RepoStats = {
readonly stargazers_count: number
readonly forks_count: number
readonly open_issues_count: number
readonly subscribers_count: number
}
const REPO = "High-Performance-Structures/compass"
const GITHUB_URL = `https://github.com/${REPO}`
interface DashboardChatProps {
readonly stats: RepoStats | null
}
const SUGGESTIONS = [
"What can you help me with?",
"Show me today's tasks",
"Navigate to customers",
]
const ANIMATED_PLACEHOLDERS = [
"Show me open invoices",
"What's on the schedule for next week?",
"Which subcontractors are waiting on payment?",
"Pull up the current project timeline",
"Find outstanding invoices over 30 days",
"Who's assigned to the foundation work?",
]
const LOGO_MASK = {
maskImage: "url(/logo-black.png)",
maskSize: "contain",
maskRepeat: "no-repeat",
WebkitMaskImage: "url(/logo-black.png)",
WebkitMaskSize: "contain",
WebkitMaskRepeat: "no-repeat",
} as React.CSSProperties
function getTextFromParts(
parts: ReadonlyArray<{ type: string; text?: string }>
): string {
return parts
.filter(
(p): p is { type: "text"; text: string } =>
p.type === "text"
)
.map((p) => p.text)
.join("")
}
export function DashboardChat({ stats }: DashboardChatProps) {
const [isActive, setIsActive] = useState(false)
const [idleInput, setIdleInput] = useState("")
const scrollRef = useRef<HTMLDivElement>(null)
const router = useRouter()
const routerRef = useRef(router)
routerRef.current = router
const pathname = usePathname()
const [chatInput, setChatInput] = useState("")
const chatTextareaRef = useRef<HTMLTextAreaElement>(null)
useAutosizeTextArea({
ref: chatTextareaRef,
maxHeight: 200,
borderWidth: 0,
dependencies: [chatInput],
})
const {
messages,
sendMessage,
regenerate,
stop,
status,
} = useChat({
transport: new DefaultChatTransport({
api: "/api/agent",
headers: { "x-current-page": pathname },
}),
onError: (err) => {
toast.error(err.message)
},
})
const isGenerating =
status === "streaming" || status === "submitted"
// initialize action handlers for navigation, toasts, etc
useEffect(() => {
initializeActionHandlers(() => routerRef.current)
const handleToast = (event: CustomEvent) => {
const { message, type = "default" } =
event.detail ?? {}
if (message) {
if (type === "success") toast.success(message)
else if (type === "error") toast.error(message)
else toast(message)
}
}
window.addEventListener(
"agent-toast",
handleToast as EventListener
)
return () => {
window.removeEventListener(
"agent-toast",
handleToast as EventListener
)
for (const type of ALL_HANDLER_TYPES) {
unregisterActionHandler(type)
}
}
}, [])
// dispatch tool actions when messages update
useEffect(() => {
const last = messages.at(-1)
if (last?.role !== "assistant") return
const parts = last.parts as ReadonlyArray<{
type: string
toolInvocation?: {
toolName: string
state: string
result?: unknown
}
}>
dispatchToolActions(parts)
}, [messages])
const [copiedId, setCopiedId] = useState<string | null>(
null
)
const [animatedPlaceholder, setAnimatedPlaceholder] =
useState("")
const [animFading, setAnimFading] = useState(false)
const [isIdleFocused, setIsIdleFocused] = useState(false)
const animTimerRef =
useRef<ReturnType<typeof setTimeout>>(undefined)
// typewriter animation for idle input placeholder
useEffect(() => {
if (isIdleFocused || idleInput || isActive) {
setAnimatedPlaceholder("")
setAnimFading(false)
return
}
let msgIdx = 0
let charIdx = 0
let phase: "typing" | "pause" | "fading" = "typing"
const tick = () => {
const msg = ANIMATED_PLACEHOLDERS[msgIdx]
if (phase === "typing") {
charIdx++
setAnimatedPlaceholder(msg.slice(0, charIdx))
if (charIdx >= msg.length) {
phase = "pause"
animTimerRef.current = setTimeout(tick, 2500)
} else {
animTimerRef.current = setTimeout(
tick,
25 + Math.random() * 20
)
}
} else if (phase === "pause") {
phase = "fading"
setAnimFading(true)
animTimerRef.current = setTimeout(tick, 400)
} else {
msgIdx =
(msgIdx + 1) % ANIMATED_PLACEHOLDERS.length
charIdx = 1
setAnimatedPlaceholder(
ANIMATED_PLACEHOLDERS[msgIdx].slice(0, 1)
)
setAnimFading(false)
phase = "typing"
animTimerRef.current = setTimeout(tick, 50)
}
}
animTimerRef.current = setTimeout(tick, 600)
return () => {
if (animTimerRef.current)
clearTimeout(animTimerRef.current)
}
}, [isIdleFocused, idleInput, isActive])
// auto-scroll state
const autoScrollRef = useRef(true)
const justSentRef = useRef(false)
const pinCooldownRef = useRef(false)
const prevLenRef = useRef(0)
// called imperatively from send handlers to flag
// that the next render should do the pin-scroll
const markSent = useCallback(() => {
justSentRef.current = true
autoScrollRef.current = true
}, [])
// runs after every render caused by message changes.
// the DOM is guaranteed to be up-to-date here.
useEffect(() => {
if (!isActive) return
const el = scrollRef.current
if (!el) return
// pin-scroll: fires once right after user sends
if (justSentRef.current) {
justSentRef.current = false
const bubbles = el.querySelectorAll(
"[data-role='user']"
)
const last = bubbles[
bubbles.length - 1
] as HTMLElement | undefined
if (last) {
const cRect = el.getBoundingClientRect()
const bRect = last.getBoundingClientRect()
const topInContainer = bRect.top - cRect.top
if (topInContainer > cRect.height / 2) {
const absTop =
bRect.top - cRect.top + el.scrollTop
const target = absTop - bRect.height * 0.25
el.scrollTo({
top: Math.max(0, target),
behavior: "smooth",
})
// don't let follow-bottom fight the smooth
// scroll for the next 600ms
pinCooldownRef.current = true
setTimeout(() => {
pinCooldownRef.current = false
}, 600)
return
}
}
}
// follow-bottom: keep the latest content visible
if (!autoScrollRef.current || pinCooldownRef.current)
return
const gap =
el.scrollHeight - el.scrollTop - el.clientHeight
if (gap > 0) {
el.scrollTop = el.scrollHeight - el.clientHeight
}
}, [messages, isActive])
// user scroll detection
useEffect(() => {
const el = scrollRef.current
if (!el) return
const onScroll = () => {
const gap =
el.scrollHeight - el.scrollTop - el.clientHeight
if (gap > 100) autoScrollRef.current = false
if (gap < 20) autoScrollRef.current = true
}
el.addEventListener("scroll", onScroll, {
passive: true,
})
return () =>
el.removeEventListener("scroll", onScroll)
}, [isActive, messages.length])
// Escape to return to idle when no messages
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (
e.key === "Escape" &&
isActive &&
messages.length === 0
) {
setIsActive(false)
}
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [isActive, messages.length])
useEffect(() => {
if (!isActive) return
const timer = setTimeout(() => {
chatTextareaRef.current?.focus()
}, 300)
return () => clearTimeout(timer)
}, [isActive])
const handleIdleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault()
const value = idleInput.trim()
setIsActive(true)
if (value) {
sendMessage({ text: value })
setIdleInput("")
}
},
[idleInput, sendMessage]
)
const handleCopy = useCallback(
(id: string, content: string) => {
navigator.clipboard.writeText(content)
setCopiedId(id)
setTimeout(() => setCopiedId(null), 2000)
},
[]
)
const handleSuggestion = useCallback(
(message: { role: "user"; content: string }) => {
setIsActive(true)
sendMessage({ text: message.content })
},
[sendMessage]
)
return (
<div className="flex flex-1 flex-col min-h-0">
{/* Compact hero - active only */}
<div
className={cn(
"shrink-0 text-center transition-all duration-500 ease-in-out overflow-hidden",
isActive
? "py-3 sm:py-4 opacity-100 max-h-40"
: "py-0 opacity-0 max-h-0"
)}
>
<span
className="mx-auto mb-2 block bg-foreground size-7"
style={LOGO_MASK}
/>
<h1 className="text-base sm:text-lg font-bold tracking-tight">
Compass
</h1>
</div>
{/* Middle content area */}
<div className="flex flex-1 flex-col min-h-0 relative">
{/* Idle: hero + input + stats, all centered */}
<div
className={cn(
"absolute inset-0 flex flex-col items-center justify-center",
"transition-all duration-500 ease-in-out",
isActive
? "opacity-0 translate-y-4 pointer-events-none"
: "opacity-100 translate-y-0"
)}
>
<div className="w-full max-w-2xl px-5 space-y-5 text-center">
<div>
<span
className="mx-auto mb-2 block bg-foreground size-10"
style={LOGO_MASK}
/>
<h1 className="text-xl sm:text-2xl font-bold tracking-tight">
Compass
</h1>
<p className="text-muted-foreground/60 mt-1.5 text-xs px-2">
Development preview features may be
incomplete or change without notice.
</p>
</div>
<form onSubmit={handleIdleSubmit}>
<label className="group flex w-full items-center gap-2 rounded-full border bg-background px-5 py-3 text-sm shadow-sm transition-colors hover:border-primary/30 hover:bg-muted/30 cursor-text">
<input
value={idleInput}
onChange={(e) =>
setIdleInput(e.target.value)
}
onFocus={() => setIsIdleFocused(true)}
onBlur={() => setIsIdleFocused(false)}
placeholder={
animatedPlaceholder ||
"Ask anything..."
}
className={cn(
"flex-1 bg-transparent text-foreground outline-none",
"placeholder:text-muted-foreground placeholder:transition-opacity placeholder:duration-300",
animFading
? "placeholder:opacity-0"
: "placeholder:opacity-100"
)}
/>
<button
type="submit"
className="shrink-0"
aria-label="Send"
>
<SendHorizonal className="size-4 text-muted-foreground/60 transition-colors group-hover:text-primary" />
</button>
</label>
</form>
{stats && (
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-2 text-xs text-muted-foreground/70">
<a
href={GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 transition-colors hover:text-foreground"
>
<IconBrandGithub className="size-4" />
<span>View on GitHub</span>
<IconExternalLink className="size-3" />
</a>
<span className="hidden sm:inline text-border">
|
</span>
<span className="text-xs">
{REPO}
</span>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<IconStar className="size-3.5" />
{stats.stargazers_count}
</span>
<span className="flex items-center gap-1">
<IconGitFork className="size-3.5" />
{stats.forks_count}
</span>
<span className="flex items-center gap-1">
<IconAlertCircle className="size-3.5" />
{stats.open_issues_count}
</span>
<span className="flex items-center gap-1">
<IconEye className="size-3.5" />
{stats.subscribers_count}
</span>
</div>
</div>
)}
</div>
</div>
{/* Active: messages or suggestions */}
<div
className={cn(
"absolute inset-0 flex flex-col",
"transition-all duration-500 ease-in-out delay-100",
isActive
? "opacity-100 translate-y-0"
: "opacity-0 -translate-y-4 pointer-events-none"
)}
>
{messages.length > 0 ? (
<div
ref={scrollRef}
className="flex-1 overflow-y-auto"
>
<div className="mx-auto w-full max-w-3xl px-4 py-4 space-y-6">
{messages.map((msg) => {
const textContent = getTextFromParts(
msg.parts as ReadonlyArray<{
type: string
text?: string
}>
)
if (msg.role === "user") {
return (
<div
key={msg.id}
data-role="user"
className="flex justify-end"
>
<div className="rounded-2xl border bg-background px-4 py-2.5 text-sm max-w-[80%] shadow-sm">
{textContent}
</div>
</div>
)
}
return (
<div
key={msg.id}
className="flex flex-col items-start"
>
{textContent ? (
<>
<div className="w-full text-sm leading-[1.6] prose prose-sm prose-neutral dark:prose-invert max-w-none [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-[15px] [&_p]:my-2.5 [&_ul]:my-2.5 [&_ol]:my-2.5 [&_li]:my-1">
<MarkdownRenderer>
{textContent}
</MarkdownRenderer>
</div>
<div className="mt-2 flex items-center gap-1">
<button
type="button"
onClick={() =>
handleCopy(
msg.id,
textContent
)
}
className="rounded-md p-1.5 text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
aria-label="Copy"
>
{copiedId === msg.id ? (
<Check className="size-3.5" />
) : (
<Copy className="size-3.5" />
)}
</button>
<button
type="button"
className="rounded-md p-1.5 text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
aria-label="Good response"
>
<ThumbsUp className="size-3.5" />
</button>
<button
type="button"
className="rounded-md p-1.5 text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
aria-label="Bad response"
>
<ThumbsDown className="size-3.5" />
</button>
<button
type="button"
onClick={() => regenerate()}
className="rounded-md p-1.5 text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
aria-label="Regenerate"
>
<RefreshCw className="size-3.5" />
</button>
</div>
</>
) : (
<TypingIndicator />
)}
</div>
)
})}
</div>
</div>
) : (
<div className="flex-1 flex items-end">
<div className="mx-auto w-full max-w-2xl">
<PromptSuggestions
label="Try these prompts"
append={handleSuggestion}
suggestions={SUGGESTIONS}
/>
</div>
</div>
)}
</div>
</div>
{/* Bottom input - active only */}
<div
className={cn(
"shrink-0 px-4 transition-all duration-500 ease-in-out",
isActive
? "opacity-100 translate-y-0 pt-2 pb-6"
: "opacity-0 translate-y-4 max-h-0 overflow-hidden pointer-events-none py-0"
)}
>
<form
className="mx-auto max-w-3xl"
onSubmit={(e) => {
e.preventDefault()
const trimmed = chatInput.trim()
if (!trimmed || isGenerating) return
sendMessage({ text: trimmed })
setChatInput("")
markSent()
}}
>
<div
className={cn(
"flex flex-col rounded-2xl border bg-background overflow-hidden",
"transition-[border-color,box-shadow] duration-200",
"focus-within:border-ring/40 focus-within:shadow-[0_0_0_3px_rgba(0,0,0,0.04)]",
)}
>
<textarea
ref={chatTextareaRef}
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
const trimmed = chatInput.trim()
if (!trimmed || isGenerating) return
sendMessage({
text: trimmed,
})
setChatInput("")
markSent()
}
}}
placeholder="Ask follow-up..."
rows={1}
className={cn(
"w-full resize-none bg-transparent text-sm outline-none",
"overflow-y-auto px-5 pt-4 pb-2",
"placeholder:text-muted-foreground/60",
)}
/>
<div className="flex items-center justify-between px-3 pb-3">
<div className="flex items-center gap-1">
<button
type="button"
className={cn(
"flex size-8 items-center justify-center rounded-lg",
"text-muted-foreground/60 transition-colors",
"hover:bg-muted hover:text-foreground",
)}
aria-label="Add attachment"
>
<Plus className="size-4" />
</button>
</div>
{isGenerating ? (
<button
type="button"
onClick={stop}
className={cn(
"flex size-9 items-center justify-center rounded-full",
"bg-foreground text-background",
"transition-colors hover:bg-foreground/90",
)}
aria-label="Stop generating"
>
<Square className="size-4" />
</button>
) : (
<button
type="submit"
disabled={!chatInput.trim()}
className={cn(
"flex size-9 items-center justify-center rounded-full",
"transition-all duration-200",
chatInput.trim()
? "bg-foreground text-background hover:bg-foreground/90"
: "bg-muted/60 text-muted-foreground/40",
)}
aria-label="Send message"
>
<ArrowUp className="size-4" />
</button>
)}
</div>
</div>
</form>
</div>
</div>
)
}

View File

@ -2,7 +2,7 @@
import { createContext, useContext, useState } from "react" import { createContext, useContext, useState } from "react"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
import { useAgentOptional } from "@/components/agent/agent-provider" import { useAgentOptional } from "@/components/agent/chat-provider"
import { MessageCircle } from "lucide-react" import { MessageCircle } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"

View File

@ -25,6 +25,8 @@ import {
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { NetSuiteConnectionStatus } from "@/components/netsuite/connection-status" import { NetSuiteConnectionStatus } from "@/components/netsuite/connection-status"
import { SyncControls } from "@/components/netsuite/sync-controls" import { SyncControls } from "@/components/netsuite/sync-controls"
import { MemoriesTable } from "@/components/agent/memories-table"
import { SkillsTab } from "@/components/settings/skills-tab"
export function SettingsModal({ export function SettingsModal({
open, open,
@ -152,6 +154,10 @@ export function SettingsModal({
</> </>
) )
const slabMemoryPage = <MemoriesTable />
const skillsPage = <SkillsTab />
return ( return (
<ResponsiveDialog <ResponsiveDialog
open={open} open={open}
@ -161,7 +167,7 @@ export function SettingsModal({
className="sm:max-w-xl" className="sm:max-w-xl"
> >
<ResponsiveDialogBody <ResponsiveDialogBody
pages={[generalPage, notificationsPage, appearancePage, integrationsPage]} pages={[generalPage, notificationsPage, appearancePage, integrationsPage, slabMemoryPage, skillsPage]}
> >
<Tabs defaultValue="general" className="w-full"> <Tabs defaultValue="general" className="w-full">
<TabsList className="w-full inline-flex justify-start overflow-x-auto"> <TabsList className="w-full inline-flex justify-start overflow-x-auto">
@ -177,6 +183,12 @@ export function SettingsModal({
<TabsTrigger value="integrations" className="text-xs sm:text-sm shrink-0"> <TabsTrigger value="integrations" className="text-xs sm:text-sm shrink-0">
Integrations Integrations
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="slab-memory" className="text-xs sm:text-sm shrink-0">
Slab Memory
</TabsTrigger>
<TabsTrigger value="skills" className="text-xs sm:text-sm shrink-0">
Skills
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="general" className="space-y-3 pt-3"> <TabsContent value="general" className="space-y-3 pt-3">
@ -294,6 +306,20 @@ export function SettingsModal({
<NetSuiteConnectionStatus /> <NetSuiteConnectionStatus />
<SyncControls /> <SyncControls />
</TabsContent> </TabsContent>
<TabsContent
value="slab-memory"
className="space-y-3 pt-3"
>
<MemoriesTable />
</TabsContent>
<TabsContent
value="skills"
className="space-y-3 pt-3"
>
<SkillsTab />
</TabsContent>
</Tabs> </Tabs>
</ResponsiveDialogBody> </ResponsiveDialogBody>
</ResponsiveDialog> </ResponsiveDialog>

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