diff --git a/.agents/skills/assistant-ui/SKILL.md b/.agents/skills/assistant-ui/SKILL.md new file mode 100644 index 0000000..7144855 --- /dev/null +++ b/.agents/skills/assistant-ui/SKILL.md @@ -0,0 +1,118 @@ +--- +name: assistant-ui +description: Guide for assistant-ui library - AI chat UI components. Use when asking about architecture, debugging, or understanding the codebase. +version: 0.0.1 +license: MIT +--- + +# assistant-ui + +**Always consult [assistant-ui.com/llms.txt](https://assistant-ui.com/llms.txt) for latest API.** + +React library for building AI chat interfaces with composable primitives. + +## References + +- [./references/architecture.md](./references/architecture.md) -- Core architecture and layered system +- [./references/packages.md](./references/packages.md) -- Package overview and selection guide + +## When to Use + +| Use Case | Best For | +|----------|----------| +| Chat UI from scratch | Full control over UX | +| Existing AI backend | Connects to any streaming backend | +| Custom message types | Tools, images, files, custom parts | +| Multi-thread apps | Built-in thread list management | +| Production apps | Cloud persistence, auth, analytics | + +## Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ UI Components (Primitives) │ +│ ThreadPrimitive, MessagePrimitive, ComposerPrimitive │ +└─────────────────────────┬───────────────────────────────┘ + │ +┌─────────────────────────▼───────────────────────────────┐ +│ Context Hooks │ +│ useAui, useAuiState, useAuiEvent │ +└─────────────────────────┬───────────────────────────────┘ + │ +┌─────────────────────────▼───────────────────────────────┐ +│ Runtime Layer │ +│ AssistantRuntime → ThreadRuntime → MessageRuntime │ +└─────────────────────────┬───────────────────────────────┘ + │ +┌─────────────────────────▼───────────────────────────────┐ +│ Adapters/Backend │ +│ AI SDK · LangGraph · Custom · Cloud Persistence │ +└─────────────────────────────────────────────────────────┘ +``` + +## Pick a Runtime + +``` +Using AI SDK? +├─ Yes → useChatRuntime (recommended) +└─ No + ├─ External state (Redux/Zustand)? → useExternalStoreRuntime + ├─ LangGraph agent? → useLangGraphRuntime + ├─ AG-UI protocol? → useAgUiRuntime + ├─ A2A protocol? → useA2ARuntime + └─ Custom API → useLocalRuntime +``` + +## Core Packages + +| Package | Purpose | +|---------|---------| +| `@assistant-ui/react` | UI primitives & hooks | +| `@assistant-ui/react-ai-sdk` | Vercel AI SDK v6 adapter | +| `@assistant-ui/react-langgraph` | LangGraph adapter | +| `@assistant-ui/react-markdown` | Markdown rendering | +| `assistant-stream` | Streaming protocol | +| `assistant-cloud` | Cloud persistence | + +## Quick Start + +```tsx +import { AssistantRuntimeProvider } from "@assistant-ui/react"; +import { Thread } from "@/components/assistant-ui/thread"; +import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/react-ai-sdk"; + +function App() { + const runtime = useChatRuntime({ + transport: new AssistantChatTransport({ api: "/api/chat" }), + }); + return ( + + + + ); +} +``` + +## State Access + +```tsx +import { useAui, useAuiState } from "@assistant-ui/react"; + +const api = useAui(); +api.thread().append({ role: "user", content: [{ type: "text", text: "Hi" }] }); +api.thread().cancelRun(); + +const messages = useAuiState(s => s.thread.messages); +const isRunning = useAuiState(s => s.thread.isRunning); +``` + +## Related Skills + +- `/setup` - Installation and configuration +- `/primitives` - UI component customization +- `/runtime` - State management deep dive +- `/tools` - Tool registration and UI +- `/streaming` - Streaming protocols +- `/cloud` - Persistence and auth +- `/thread-list` - Multi-thread management +- `/update` - Version updates and migrations diff --git a/.agents/skills/assistant-ui/references/architecture.md b/.agents/skills/assistant-ui/references/architecture.md new file mode 100644 index 0000000..215f056 --- /dev/null +++ b/.agents/skills/assistant-ui/references/architecture.md @@ -0,0 +1,175 @@ +# assistant-ui Architecture + +## Layered System + +assistant-ui follows a 4-layer architecture where each layer depends only on layers below it. + +### Layer 1: RuntimeCore (Internal) + +Internal implementations that manage state: + +- `LocalRuntimeCore` - In-browser state +- `ExternalStoreRuntimeCore` - External state sync +- `ThreadListRuntimeCore` - Thread management + +```typescript +// Internal - not directly used +interface ThreadRuntimeCore { + readonly messages: readonly ThreadMessage[]; + readonly isRunning: boolean; + append(message: AppendMessage): void; + cancelRun(): void; + subscribe(callback: () => void): Unsubscribe; +} +``` + +### Layer 2: Runtime (Public API) + +Public API exposed via hooks: + +```typescript +type AssistantRuntime = { + thread(): ThreadRuntime; + threads(): ThreadListRuntime; + getState(): AssistantState; + subscribe(callback: () => void): Unsubscribe; +}; + +type ThreadRuntime = { + getState(): ThreadState; + append(message: AppendMessage): void; + cancelRun(): void; + message(index: number): MessageRuntime; + composer(): ComposerRuntime; +}; + +type MessageRuntime = { + getState(): MessageState; + edit(message: EditMessage): void; + reload(): void; + part(index: number): MessagePartRuntime; +}; +``` + +### Layer 3: Context Hooks + +React hooks for accessing runtime: + +```tsx +// Modern API (recommended) +import { useAui, useAuiState, useAuiEvent } from "@assistant-ui/react"; + +// Get API for imperative actions +const api = useAui(); + +// Subscribe to state changes +const messages = useAuiState(s => s.thread.messages); + +// Listen to events +useAuiEvent("composer.send", (e) => console.log(e)); +``` + +### Layer 4: Primitives (UI) + +Composable UI components: + +```tsx +import { + ThreadPrimitive, + ComposerPrimitive, + MessagePrimitive, + ActionBarPrimitive, +} from "@assistant-ui/react"; +``` + +## Data Flow + +``` +User Action (send message) + │ + ▼ +Primitive captures event + │ + ▼ +Calls runtime API (thread.append) + │ + ▼ +RuntimeCore processes action + │ + ▼ +State updates + │ + ▼ +Subscribers notified + │ + ▼ +Primitives re-render with new state +``` + +## Message Model + +```typescript +type ThreadMessage = + | ThreadUserMessage + | ThreadAssistantMessage + | ThreadSystemMessage; + +interface ThreadUserMessage { + id: string; + role: "user"; + content: MessagePart[]; + attachments?: Attachment[]; + createdAt: Date; +} + +interface ThreadAssistantMessage { + id: string; + role: "assistant"; + content: MessagePart[]; + status: "running" | "complete" | "incomplete" | "requires-action"; + createdAt: Date; +} + +type MessagePart = + | { type: "text"; text: string } + | { type: "image"; image: string } + | { + type: "tool-call"; + toolCallId: string; + toolName: string; + args: unknown; + argsText: string; + result?: unknown; + isError?: boolean; + artifact?: unknown; + } + | { type: "reasoning"; text: string } + | { + type: "source"; + sourceType: "url"; + id: string; + url: string; + title?: string; + } + | { + type: "file"; + filename?: string; + data: string; + mimeType: string; + }; +``` + +## Branching Model + +Messages form a tree structure supporting edits: + +``` +User: "Hello" + └─ Assistant: "Hi there!" + └─ User: "Tell me a joke" ← Current branch + └─ Assistant: "Why did..." + └─ User: "Tell me a fact" (edit) ← Alternative branch + └─ Assistant: "The sun..." +``` + +Navigate branches with `BranchPickerPrimitive` or runtime API. diff --git a/.agents/skills/assistant-ui/references/packages.md b/.agents/skills/assistant-ui/references/packages.md new file mode 100644 index 0000000..2ae656d --- /dev/null +++ b/.agents/skills/assistant-ui/references/packages.md @@ -0,0 +1,152 @@ +# assistant-ui Packages + +## Published Packages + +**To check latest version:** Run `npm view version` or check the package on npmjs.com. + +- Most published packages only expose the `latest` dist-tag; always install from `latest`. +- Monorepo-only: `@assistant-ui/x-buildutils` (not on npm). + +| Package | Notes | +|---------|-------| +| @assistant-ui/react | Core UI library | +| @assistant-ui/react-ai-sdk | AI SDK v6 integration | +| @assistant-ui/react-langgraph | LangGraph integration | +| @assistant-ui/react-data-stream | Data stream utilities | +| @assistant-ui/react-markdown | Markdown rendering | +| @assistant-ui/react-syntax-highlighter | Code highlighting | +| @assistant-ui/store | State management | +| @assistant-ui/react-devtools | Developer tools | +| @assistant-ui/react-hook-form | React Hook Form integration | +| @assistant-ui/react-a2a | Agent-to-Agent protocol for multi-agent systems | +| @assistant-ui/react-ag-ui | AG-UI protocol adapter for agent backends | +| @assistant-ui/cloud-ai-sdk | AI SDK hooks for assistant-cloud persistence | +| @assistant-ui/core | Framework-agnostic core runtime | +| @assistant-ui/react-native | React Native bindings | +| @assistant-ui/react-o11y | Observability primitives | +| @assistant-ui/react-streamdown | Streamdown-based markdown rendering | +| @assistant-ui/tap | Reactive state management and testing | +| @assistant-ui/mcp-docs-server | MCP server for IDE integration | +| assistant-stream | Streaming protocol | +| assistant-cloud | Cloud persistence/auth | +| assistant-ui | CLI tool | +| create-assistant-ui | Project scaffolding | +| safe-content-frame | Sandboxed iframe content | +| tw-shimmer | Tailwind shimmer effects | +| tw-glass | Tailwind CSS v4 glass refraction effects | +| mcp-app-studio | MCP app builder | + +## Core Packages + +### @assistant-ui/react + +Main UI library with primitives and hooks. + +```bash +npm install @assistant-ui/react +``` + +**Exports:** +- Primitives: `ThreadPrimitive`, `MessagePrimitive`, `ComposerPrimitive`, `ActionBarPrimitive`, `BranchPickerPrimitive`, `AttachmentPrimitive`, `ThreadListPrimitive` +- Pre-built components are added via project templates in: + `@/components/assistant-ui/thread` and `@/components/assistant-ui/thread-list` +- Hooks: `useAui`, `useAuiState`, `useAuiEvent` +- Runtime: `useLocalRuntime`, `useExternalStoreRuntime` +- Tools: `makeAssistantTool`, `makeAssistantToolUI`, `useAssistantTool`, `useAssistantToolUI` +- Provider: `AssistantRuntimeProvider` + +### assistant-stream + +Streaming protocol for AI responses. + +```bash +npm install assistant-stream +``` + +**Exports:** +- `AssistantStream` - Core streaming abstraction +- `DataStreamEncoder/Decoder` - AI SDK format +- `AssistantTransportEncoder/Decoder` - Native format +- `PlainTextEncoder/Decoder` - Simple text streaming + +### assistant-cloud + +Cloud persistence and auth. + +```bash +npm install assistant-cloud +``` + +**Exports:** +- `AssistantCloud` - Main client class +- Thread management, file uploads, auth + +## Integration Packages + +### @assistant-ui/react-ai-sdk + +Vercel AI SDK v6 integration. + +```bash +npm install @assistant-ui/react-ai-sdk @ai-sdk/react +``` + +**Exports:** +- `useChatRuntime` - Main hook (recommended) +- `useAISDKRuntime` - Lower-level hook +- `AssistantChatTransport` - Custom transport class + +### @assistant-ui/react-langgraph + +LangGraph agent integration. + +```bash +npm install @assistant-ui/react-langgraph +``` + +**Exports:** +- `useLangGraphRuntime` - Main hook +- `useLangGraphSend`, `useLangGraphSendCommand` - Manual send control +- `useLangGraphInterruptState` - Interrupt state access +- `useLangGraphMessages` - Message state management +- `convertLangChainMessages`, `appendLangChainChunk` - Message converters +- `LangGraphMessageAccumulator` - Message accumulator + +## UI Enhancement Packages + +### @assistant-ui/react-markdown + +Markdown rendering with syntax highlighting support. + +```bash +npm install @assistant-ui/react-markdown +``` + +**Exports:** +- `MarkdownTextPrimitive` - Renders markdown content +- `useIsMarkdownCodeBlock` - Check if code block is inside markdown +- `unstable_memoizeMarkdownComponents` - Memoize markdown components for performance + +### @assistant-ui/react-syntax-highlighter + +Code block syntax highlighting. + +```bash +npm install @assistant-ui/react-syntax-highlighter +``` + +## Package Selection Guide + +| Scenario | Packages | +|----------|----------| +| Next.js + AI SDK | `@assistant-ui/react`, `@assistant-ui/react-ai-sdk`, `@ai-sdk/react` | +| LangGraph | `@assistant-ui/react`, `@assistant-ui/react-langgraph` | +| Custom backend | `@assistant-ui/react`, `assistant-stream` | +| With markdown | Add `@assistant-ui/react-markdown` | +| Production | Add `assistant-cloud` | + +## Version Compatibility + +- `@assistant-ui/react` requires React 18+ or 19 +- `@assistant-ui/react-ai-sdk` requires AI SDK v6 (`ai@^6`) +- Node.js >=24 recommended (monorepo requirement) diff --git a/.agents/skills/cloud/SKILL.md b/.agents/skills/cloud/SKILL.md new file mode 100644 index 0000000..f4a97e9 --- /dev/null +++ b/.agents/skills/cloud/SKILL.md @@ -0,0 +1,112 @@ +--- +name: cloud +description: Guide for assistant-cloud persistence and authorization. Use when setting up thread persistence, file uploads, or authentication. +version: 0.0.1 +license: MIT +--- + +# assistant-ui Cloud + +**Always consult [assistant-ui.com/llms.txt](https://assistant-ui.com/llms.txt) for latest API.** + +Cloud persistence for threads, messages, and files. + +## References + +- [./references/persistence.md](./references/persistence.md) -- Thread and message persistence +- [./references/authorization.md](./references/authorization.md) -- Authentication patterns + +## Installation + +```bash +npm install assistant-cloud +``` + +## Quick Start + +```tsx +import { AssistantCloud } from "assistant-cloud"; +import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/react-ai-sdk"; +import { AssistantRuntimeProvider } from "@assistant-ui/react"; +import { Thread } from "@/components/assistant-ui/thread"; +import { ThreadList } from "@/components/assistant-ui/thread-list"; + +const cloud = new AssistantCloud({ + baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL, + authToken: async () => getAuthToken(), +}); + +function Chat() { + const runtime = useChatRuntime({ + transport: new AssistantChatTransport({ api: "/api/chat" }), + cloud, + }); + + return ( + + + + + ); +} +``` + +## Authentication Options + +```tsx +// JWT Token (recommended) +const cloud = new AssistantCloud({ + baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL, + authToken: async () => session?.accessToken, +}); + +// API Key (server-side) +const cloud = new AssistantCloud({ + baseUrl: process.env.ASSISTANT_BASE_URL, + apiKey: process.env.ASSISTANT_API_KEY, + userId: user.id, + workspaceId: user.workspaceId, +}); + +// Anonymous (public apps) +const cloud = new AssistantCloud({ + baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL, + anonymous: true, +}); +``` + +## Cloud API + +```tsx +// Thread operations +const threads = await cloud.threads.list(); +await cloud.threads.create({ title: "New Chat" }); +await cloud.threads.update(threadId, { title: "Updated" }); +await cloud.threads.delete(threadId); + +// Message operations +const messages = await cloud.threads.messages(threadId).list(); + +// File uploads +const { signedUrl, publicUrl } = await cloud.files.generatePresignedUploadUrl({ + filename: "document.pdf", +}); +await fetch(signedUrl, { method: "PUT", body: file }); +``` + +## Environment Variables + +```env +NEXT_PUBLIC_ASSISTANT_BASE_URL=https://api.assistant-ui.com +ASSISTANT_API_KEY=your-api-key # Server-side only +``` + +## Common Gotchas + +**Threads not persisting** +- Pass `cloud` to runtime +- Check authentication + +**Auth errors** +- Verify `authToken` returns valid token +- Check `baseUrl` is correct diff --git a/.agents/skills/cloud/references/authorization.md b/.agents/skills/cloud/references/authorization.md new file mode 100644 index 0000000..a6b9921 --- /dev/null +++ b/.agents/skills/cloud/references/authorization.md @@ -0,0 +1,261 @@ +# Cloud Authorization + +Authentication and authorization patterns for assistant-cloud. + +## Auth Methods + +| Method | Use Case | Security | +|--------|----------|----------| +| JWT Token | Production apps | High | +| API Key | Server-side only | Medium | +| Anonymous | Public demos | Low | + +## JWT Token Authentication + +Recommended for production. Token is fetched dynamically. + +### Setup + +```tsx +const cloud = new AssistantCloud({ + baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL, + authToken: async () => { + // Return your JWT token + const token = await getAuthToken(); + return token; + }, +}); +``` + +### With NextAuth + +```tsx +import { useSession } from "next-auth/react"; + +function Chat() { + const { data: session, status } = useSession(); + + const cloud = useMemo(() => { + if (status !== "authenticated") return null; + + return new AssistantCloud({ + baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL, + authToken: async () => session.accessToken, + }); + }, [session, status]); + + const runtime = useChatRuntime({ + transport: new AssistantChatTransport({ + api: "/api/chat", + }), + cloud: cloud ?? undefined, + }); + + if (status === "loading") return ; + if (!session) return ; + + return ( + + + + ); +} +``` + +### With Clerk + +```tsx +import { useAuth } from "@clerk/nextjs"; + +function Chat() { + const { getToken, isSignedIn } = useAuth(); + + const cloud = useMemo(() => { + if (!isSignedIn) return null; + + return new AssistantCloud({ + baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL, + authToken: async () => getToken(), + }); + }, [isSignedIn, getToken]); + + // ... +} +``` + +### With Firebase + +```tsx +import { useAuth } from "reactfire"; + +function Chat() { + const { data: user } = useAuth(); + + const cloud = useMemo(() => { + if (!user) return null; + + return new AssistantCloud({ + baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL, + authToken: async () => user.getIdToken(), + }); + }, [user]); + + // ... +} +``` + +## API Key Authentication + +For server-side operations only. Never expose API keys to clients. + +### Server Component + +```tsx +// app/api/threads/route.ts +import { AssistantCloud } from "assistant-cloud"; + +const cloud = new AssistantCloud({ + baseUrl: process.env.ASSISTANT_BASE_URL, + apiKey: process.env.ASSISTANT_API_KEY, + userId: "system", + workspaceId: process.env.ASSISTANT_WORKSPACE_ID, +}); + +export async function GET() { + const threads = await cloud.threads.list(); + return Response.json(threads); +} +``` + +### Per-User Operations + +```tsx +// app/api/chat/threads/route.ts +import { getServerSession } from "next-auth"; + +export async function GET() { + const session = await getServerSession(); + if (!session) return new Response("Unauthorized", { status: 401 }); + + const cloud = new AssistantCloud({ + baseUrl: process.env.ASSISTANT_BASE_URL, + apiKey: process.env.ASSISTANT_API_KEY, + userId: session.user.id, + workspaceId: session.user.workspaceId, + }); + + const threads = await cloud.threads.list(); + return Response.json(threads); +} +``` + +## Anonymous Authentication + +For public demos or unauthenticated access. + +```tsx +const cloud = new AssistantCloud({ + baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL, + anonymous: true, +}); +``` + +**Limitations:** +- No user isolation +- Limited features +- No cross-device sync + +## Token Refresh + +Handle expired tokens: + +```tsx +const cloud = new AssistantCloud({ + baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL, + authToken: async () => { + const token = getStoredToken(); + + if (isTokenExpired(token)) { + const newToken = await refreshToken(); + setStoredToken(newToken); + return newToken; + } + + return token; + }, +}); +``` + +## Error Handling + +```tsx +const cloud = new AssistantCloud({ + baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL, + authToken: async () => { + try { + return await getToken(); + } catch (error) { + console.error("Auth error:", error); + + // Redirect to login + window.location.href = "/login"; + return null; + } + }, +}); +``` + +## Workspace Isolation + +Users in different workspaces have separate data: + +```tsx +const cloud = new AssistantCloud({ + baseUrl: process.env.ASSISTANT_BASE_URL, + apiKey: process.env.ASSISTANT_API_KEY, + userId: user.id, + workspaceId: user.organization.id, // Isolates data by org +}); +``` + +## Role-Based Access + +Implement in your backend: + +```tsx +// app/api/threads/[id]/route.ts +export async function DELETE(req: Request, { params }) { + const session = await getServerSession(); + + // Check permissions + const thread = await cloud.threads.get(params.id); + if (thread.metadata.ownerId !== session.user.id) { + if (!session.user.roles.includes("admin")) { + return new Response("Forbidden", { status: 403 }); + } + } + + await cloud.threads.delete(params.id); + return new Response(null, { status: 204 }); +} +``` + +## Environment Setup + +```env +# .env.local (client-side accessible) +NEXT_PUBLIC_ASSISTANT_BASE_URL=https://api.assistant-ui.com + +# .env (server-side only) +ASSISTANT_BASE_URL=https://api.assistant-ui.com +ASSISTANT_API_KEY=your-secret-key +ASSISTANT_WORKSPACE_ID=your-workspace +``` + +## Security Best Practices + +1. **Never expose API keys** - Use JWT tokens for client-side +2. **Validate tokens server-side** - Don't trust client tokens blindly +3. **Use short-lived tokens** - Implement refresh flow +4. **Scope workspaces** - Isolate user data by workspace +5. **Audit access** - Log thread operations for compliance diff --git a/.agents/skills/cloud/references/persistence.md b/.agents/skills/cloud/references/persistence.md new file mode 100644 index 0000000..0068f52 --- /dev/null +++ b/.agents/skills/cloud/references/persistence.md @@ -0,0 +1,268 @@ +# Cloud Persistence + +Thread and message persistence with assistant-cloud. + +## Overview + +Cloud persistence saves threads and messages to the assistant-ui cloud backend, enabling: +- Chat history across sessions +- Multi-device sync +- Thread management (archive, delete) +- Auto-generated titles + +## Basic Setup + +```tsx +import { AssistantCloud } from "assistant-cloud"; +import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/react-ai-sdk"; +import { AssistantRuntimeProvider } from "@assistant-ui/react"; +import { Thread } from "@/components/assistant-ui/thread"; +import { ThreadList } from "@/components/assistant-ui/thread-list"; + +const cloud = new AssistantCloud({ + baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL, + authToken: async () => getAuthToken(), +}); + +function Chat() { + const runtime = useChatRuntime({ + transport: new AssistantChatTransport({ + api: "/api/chat", + }), + cloud, // Enable persistence + }); + + return ( + + + + + ); +} +``` + +## Thread API + +### List Threads + +```tsx +const threads = await cloud.threads.list({ + status: "active", // "active" | "archived" | "all" + limit: 50, + offset: 0, +}); + +// threads: Array<{ +// id: string; +// title: string; +// created_at: Date; +// updated_at: Date; +// last_message_at: Date; +// is_archived: boolean; +// external_id?: string; +// metadata?: unknown; +// }> +``` + +### Get Thread + +```tsx +const thread = await cloud.threads.get(threadId); +``` + +### Create Thread + +```tsx +const { thread_id } = await cloud.threads.create({ + title: "My New Chat", + external_id: "custom-id-123", // Optional external reference + metadata: { // Optional custom data + source: "web", + category: "support", + }, +}); +``` + +### Update Thread + +```tsx +await cloud.threads.update(threadId, { + title: "Updated Title", + is_archived: true, + metadata: { priority: "high" }, +}); +``` + +### Delete Thread + +```tsx +await cloud.threads.delete(threadId); +``` + +## Message API + +### List Messages + +```tsx +const messages = await cloud.threads.messages(threadId).list({ + format: "aui/v0", // Message format +}); + +// messages: Array<{ +// id: string; +// parent_id: string | null; +// format: string; +// content: object; +// height: number; +// created_at: Date; +// }> +``` + +### Create Message + +```tsx +await cloud.threads.messages(threadId).create({ + parent_id: null, // Or parent message ID for branching + format: "aui/v0", + content: { + role: "user", + content: [{ type: "text", text: "Hello" }], + }, +}); +``` + +## Message Format + +assistant-ui uses `"aui/v0"` format: + +```typescript +interface AUIv0Message { + role: "user" | "assistant" | "system"; + content: MessagePart[]; + status?: "running" | "complete" | "incomplete" | "requires-action"; + attachments?: Attachment[]; +} + +type MessagePart = + | { type: "text"; text: string } + | { type: "image"; image: string } + | { + type: "tool-call"; + toolCallId: string; + toolName: string; + args: unknown; + argsText: string; + result?: unknown; + isError?: boolean; + artifact?: unknown; + } + | { type: "reasoning"; text: string } + | { + type: "source"; + sourceType: "url"; + id: string; + url: string; + title?: string; + }; +``` + +## Thread History Adapter + +For custom persistence with useLocalRuntime: + +```tsx +import { AssistantCloudThreadHistoryAdapter } from "assistant-cloud"; + +const historyAdapter = new AssistantCloudThreadHistoryAdapter(cloud, threadId); + +const runtime = useLocalRuntime({ + model: myModel, + adapters: { + threadHistory: historyAdapter, + }, +}); +``` + +## Auto-Save Behavior + +When `cloud` is passed to runtime: + +1. **New messages** are automatically saved +2. **Thread creation** happens on first message +3. **Thread metadata** (title, timestamps) updated automatically +4. **Message branching** (edits) preserved + +## Thread Title Generation + +Titles are auto-generated from conversation: + +```tsx +// Manual trigger +const item = api.threads().item({ id: threadId }); +await item.generateTitle(); +``` + +The cloud backend uses the conversation to generate a concise title. + +## External ID Mapping + +Link threads to your system: + +```tsx +// Create with external ID +await cloud.threads.create({ + external_id: "your-system-id-123", +}); + +// Find by external ID +const threads = await cloud.threads.list(); +const thread = threads.find(t => t.external_id === "your-system-id-123"); +``` + +## Metadata + +Store custom data with threads: + +```tsx +await cloud.threads.create({ + metadata: { + userId: user.id, + category: "sales", + priority: 1, + tags: ["important", "follow-up"], + }, +}); + +// Update metadata +await cloud.threads.update(threadId, { + metadata: { resolved: true }, +}); +``` + +## Caching and Sync + +Messages are loaded on thread switch: + +```tsx +// Thread list is cached in memory +// Messages loaded when switching threads +api.threads().switchToThread(threadId); +``` + +For real-time sync across devices, implement webhook handlers on your backend. + +## Error Handling + +```tsx +try { + const threads = await cloud.threads.list(); +} catch (error) { + if (error.status === 401) { + // Auth expired - refresh token + await refreshAuth(); + } else if (error.status === 429) { + // Rate limited + await delay(1000); + } +} +``` diff --git a/.agents/skills/primitives/SKILL.md b/.agents/skills/primitives/SKILL.md new file mode 100644 index 0000000..e32c114 --- /dev/null +++ b/.agents/skills/primitives/SKILL.md @@ -0,0 +1,130 @@ +--- +name: primitives +description: Guide for assistant-ui UI primitives - ThreadPrimitive, ComposerPrimitive, MessagePrimitive. Use when customizing chat UI components. +version: 0.0.1 +license: MIT +--- + +# assistant-ui Primitives + +**Always consult [assistant-ui.com/llms.txt](https://assistant-ui.com/llms.txt) for latest API.** + +Composable, unstyled components following Radix UI patterns. + +## References + +- [./references/thread.md](./references/thread.md) -- ThreadPrimitive deep dive +- [./references/composer.md](./references/composer.md) -- ComposerPrimitive deep dive +- [./references/message.md](./references/message.md) -- MessagePrimitive deep dive +- [./references/action-bar.md](./references/action-bar.md) -- ActionBarPrimitive deep dive + +## Import + +```tsx +import { + AuiIf, + ThreadPrimitive, + ComposerPrimitive, + MessagePrimitive, + ActionBarPrimitive, + BranchPickerPrimitive, + AttachmentPrimitive, + ThreadListPrimitive, + ThreadListItemPrimitive, +} from "@assistant-ui/react"; +``` + +## Primitive Parts + +| Primitive | Key Parts | +|-----------|-----------| +| `ThreadPrimitive` | `.Root`, `.Viewport`, `.Messages`, `.Empty`, `.ScrollToBottom` | +| `ComposerPrimitive` | `.Root`, `.Input`, `.Send`, `.Cancel`, `.Attachments` | +| `MessagePrimitive` | `.Root`, `.Parts`/`.Content`, `.If`, `.Error` | +| `ActionBarPrimitive` | `.Copy`, `.Edit`, `.Reload`, `.Speak`, `.FeedbackPositive`, `.FeedbackNegative`, `.ExportMarkdown` | +| `BranchPickerPrimitive` | `.Previous`, `.Next`, `.Number`, `.Count` | + +## Custom Thread Example + +```tsx +function CustomThread() { + return ( + + +
+ Start a conversation +
+
+ + + + + + + + + Send + + +
+ ); +} +``` + +## Conditional Rendering + +Prefer `AuiIf` for new code. Primitive `.If` components still exist but are deprecated. + +```tsx + message.role === "user"}> + User only + + thread.isRunning}> + Generating... + + message.branchCount > 1}> + Has edit history + + + thread.isRunning}> + Stop + + + thread.isEmpty}>No messages +``` + +## Content Parts + +```tsx +

{part.text}

, + Image: ({ part }) => , + ToolCall: ({ part }) =>
Tool: {part.toolName}
, + Reasoning: ({ part }) =>
Thinking{part.text}
, +}} /> +``` + +## Branch Picker + +```tsx + + + + / + + + +``` + +## Common Gotchas + +**Primitives not rendering** +- Wrap in `AssistantRuntimeProvider` +- Ensure parent primitive provides context + +**Styles not applying** +- Primitives are unstyled by default +- Add `className` and style with your app's Tailwind/CSS system diff --git a/.agents/skills/primitives/references/action-bar.md b/.agents/skills/primitives/references/action-bar.md new file mode 100644 index 0000000..232b051 --- /dev/null +++ b/.agents/skills/primitives/references/action-bar.md @@ -0,0 +1,236 @@ +# ActionBarPrimitive + +Message action buttons (copy, edit, reload, etc.). + +## Parts + +| Part | Description | +|------|-------------| +| `.Root` | Container | +| `.Copy` | Copy message to clipboard | +| `.Edit` | Enter edit mode | +| `.Reload` | Regenerate response | +| `.Speak` | Text-to-speech | +| `.StopSpeaking` | Stop TTS | +| `.FeedbackPositive` | Thumbs up | +| `.FeedbackNegative` | Thumbs down | +| `.ExportMarkdown` | Export message | + +## Basic Usage + +```tsx + + + + + +``` + +## ActionBarPrimitive.Root + +Container for action buttons. Usually placed inside a message. + +```tsx + + {children} + +``` + +### Props + +- `hideWhenRunning` - Hide while assistant is generating +- `autohide` - Only show on message hover +- `autohideFloat` - Float positioning mode + +## ActionBarPrimitive.Copy + +Copy message content to clipboard. + +```tsx + + {/* Default content */} + + + +// With copied state + + message.isCopied}> + + + !message.isCopied}> + + + +``` + +## ActionBarPrimitive.Reload + +Regenerate the assistant's response. + +```tsx + + + Regenerate + +``` + +## ActionBarPrimitive.Edit + +Enter edit mode for user messages. + +```tsx + message.role === "user"}> + + + Edit + + +``` + +## ActionBarPrimitive.Speak / StopSpeaking + +Text-to-speech controls. + +```tsx + message.speech == null}> + + 🔊 Read aloud + + + + message.speech != null}> + + ⏹️ Stop + + +``` + +## ActionBarPrimitive.FeedbackPositive / FeedbackNegative + +Thumbs up/down feedback buttons. + +```tsx + + 👍 + + + + 👎 + +``` + +Requires a feedback adapter in the runtime: + +```tsx +const runtime = useChatRuntime({ + transport: new AssistantChatTransport({ + api: "/api/chat", + }), + adapters: { + feedback: { + submit: async ({ messageId, type }) => { + await fetch("/api/feedback", { + method: "POST", + body: JSON.stringify({ messageId, type }), + }); + }, + }, + }, +}); +``` + +Use `AuiIf` for copy/speech conditional rendering instead of `ActionBarPrimitive.If`. + +## Complete Example + +```tsx +function MessageActionBar() { + return ( + + {/* Copy */} + + message.isCopied}> + + + !message.isCopied}> + + + + + {/* Regenerate (assistant only) */} + message.role === "assistant"}> + + + + + + {/* Edit (user only) */} + message.role === "user"}> + + + + + + {/* Text-to-speech */} + message.speech == null}> + + + + + message.speech != null}> + + + + + + {/* Feedback */} +
+ + + + + + +
+
+ ); +} +``` + +## Using with Messages + +```tsx +function AssistantMessage() { + return ( + + +
+ + +
+
+ ); +} +``` + +Note the `group` class on `.Root` to enable hover state propagation. diff --git a/.agents/skills/primitives/references/composer.md b/.agents/skills/primitives/references/composer.md new file mode 100644 index 0000000..fda7ad3 --- /dev/null +++ b/.agents/skills/primitives/references/composer.md @@ -0,0 +1,250 @@ +# ComposerPrimitive + +Message input form for sending messages. + +## Parts + +| Part | Description | +|------|-------------| +| `.Root` | Form container | +| `.Input` | Text input/textarea | +| `.Send` | Submit button | +| `.Cancel` | Cancel generation | +| `.AddAttachment` | Attach files button | +| `.Attachments` | Render attachments | +| `.AttachmentDropzone` | Drag-drop area | +| `.Dictate` | Start voice input | +| `.StopDictation` | Stop voice input | +| `.If` | Conditional rendering (deprecated; prefer `AuiIf`) | + +## Basic Structure + +```tsx + + + Send + +``` + +## ComposerPrimitive.Root + +Form element that handles submission. + +```tsx + console.log("Submitted")} +> + {children} + +``` + +## ComposerPrimitive.Input + +Auto-resizing textarea for message input. + +```tsx + +``` + +### Props + +- `placeholder` - Placeholder text +- `rows` - Initial row count (auto-resizes) +- `autoFocus` - Focus on mount +- `disabled` - Disable input +- Standard textarea props + +## ComposerPrimitive.Send + +Submit button. Disabled when input is empty or generating. + +```tsx + + Send + +``` + +## ComposerPrimitive.Cancel + +Cancel ongoing generation. + +```tsx + thread.isRunning}> + + Stop + + +``` + +## Conditional rendering with `AuiIf` + +Deprecated `ComposerPrimitive.If` supports only `editing` and `dictation` props. +Prefer `AuiIf` for richer state checks (`thread`, `composer`, etc.). + +```tsx +// While sending + thread.isRunning}> + Stop + + +// Not generating + !thread.isRunning}> + Send + + +// Has file attachments + composer.attachments.length > 0}> + + +``` + +### Available Conditions + +- `thread.isRunning` - Thread is currently generating +- `composer.attachments.length > 0` - Composer has file attachments +- `composer.isEditing` - Composer is in edit mode +- `composer.dictation != null` - Dictation is active + +## Attachments + +### Add Attachment Button + +```tsx + + 📎 Attach + +``` + +### Attachment List + +```tsx + + + + × + + +``` + +### Drag-Drop Zone + +```tsx + + Drop files here + +``` + +## Voice Input + +```tsx + composer.dictation == null}> + + 🎤 Voice + + + + composer.dictation != null}> + + ⏹️ Stop + + +``` + +## Complete Example + +```tsx +function CustomComposer() { + return ( + + {/* Drag-drop overlay */} + +

Drop files to attach

+
+ + {/* Attached files */} + composer.attachments.length > 0}> + + + + + × + + + + + + {/* Input row */} +
+ + + + + + + composer.dictation == null}> + + + + + + composer.dictation != null}> + + + + + + thread.isRunning}> + + + + + + !thread.isRunning}> + + + + +
+
+ ); +} +``` + +## Accessing Composer State + +```tsx +import { useComposer, useComposerRuntime } from "@assistant-ui/react"; + +function ComposerInfo() { + // Reactive state + const { text, attachments, isSubmitting } = useComposer(); + + // Runtime API + const runtime = useComposerRuntime(); + const handleClear = () => runtime.setText(""); + + return ( +
+

Characters: {text.length}

+

Attachments: {attachments.length}

+ +
+ ); +} +``` diff --git a/.agents/skills/primitives/references/message.md b/.agents/skills/primitives/references/message.md new file mode 100644 index 0000000..19804b6 --- /dev/null +++ b/.agents/skills/primitives/references/message.md @@ -0,0 +1,211 @@ +# MessagePrimitive + +Individual message display. + +## Parts + +| Part | Description | +|------|-------------| +| `.Root` | Message container | +| `.Parts` | Message body with parts (canonical) | +| `.Content` | Message body with parts | +| `.If` | Conditional rendering (deprecated; prefer `AuiIf`) | +| `.Error` | Render fallback when message has an error | +| `.PartByIndex` | Render a single part by index | +| `.Attachments` | Render message attachments | +| `.AttachmentByIndex` | Render one attachment by index | + +## Basic Structure + +```tsx + + + + +``` + +## MessagePrimitive.Root + +Container for a single message. + +```tsx + + {children} + +``` + +## MessagePrimitive.Content + +Renders message content parts (text, images, tool calls, etc.). + +```tsx +// Simple usage - uses default rendering + + +// Custom part rendering + ( +

{part.text}

+ ), + Image: ({ part }) => ( + + ), + ToolCall: ({ part }) => ( +
+ {part.toolName} + {part.result &&
{JSON.stringify(part.result, null, 2)}
} +
+ ), + Reasoning: ({ part }) => ( +
+ Thinking... +

{part.text}

+
+ ), + Source: ({ part }) => ( + + {part.title} + + ), + File: ({ part }) => ( + + 📄 {part.filename ?? "file"} + + ), + }} +/> +``` + +### Part Types + +| Type | Description | Properties | +|------|-------------|------------| +| `Text` | Plain text | `text` | +| `Image` | Image attachment | `image` (URL) | +| `ToolCall` | Tool invocation | `toolName`, `args`, `argsText`, `result?`, `isError?`, `artifact?` | +| `Reasoning` | Chain-of-thought | `text` | +| `Source` | Citation/reference | `url`, `title` | +| `File` | File attachment | `filename?`, `data`, `mimeType` | + +## MessagePrimitive.If / AuiIf + +`MessagePrimitive.If` still exists but is deprecated. Prefer `AuiIf` for the most flexible state checks. + +```tsx +User message content +Assistant message content +System message content + + ... + + +Copied +Playing speech +Positive feedback +``` + +When you need custom conditions (for example branch metadata), use `AuiIf`: + +```tsx + message.branchCount > 1}> + ... + + + message.isCopied}> + + +``` + +## Complete Example + +```tsx +function CustomUserMessage() { + return ( + + +
+
+ +
+
+
+ ); +} + +function CustomAssistantMessage() { + return ( + + + +
+
+ +
+
+ + + + Copy + + + Regenerate + + + 🔊 + + +
+ ); +} +``` + +## Error and branching support + +Use `MessagePrimitive.Error` to render a fallback UI only when the message has an error: + +```tsx + + + + + +``` + +## Accessing Message State + +```tsx +import { useMessage, useMessageRuntime } from "@assistant-ui/react"; + +function MessageInfo() { + // Reactive state + const { role, content, status, createdAt } = useMessage(); + + // Runtime API + const runtime = useMessageRuntime(); + const handleEdit = () => runtime.edit({ + role: "user", + content: [{ type: "text", text: "New content" }], + }); + + return ( +
+

Role: {role}

+

Status: {status}

+
+ ); +} +``` diff --git a/.agents/skills/primitives/references/thread.md b/.agents/skills/primitives/references/thread.md new file mode 100644 index 0000000..997bab9 --- /dev/null +++ b/.agents/skills/primitives/references/thread.md @@ -0,0 +1,222 @@ +# ThreadPrimitive + +Container for the entire chat thread. + +## Parts + +| Part | Description | +|------|-------------| +| `.Root` | Outermost container element | +| `.Viewport` | Scrollable message area | +| `.Messages` | Renders message list | +| `.Empty` | Shown when no messages | +| `.ScrollToBottom` | Button to scroll down | +| `.Suggestions` | Quick reply suggestions | +| `.If` | Conditional rendering (deprecated; prefer `AuiIf`) | + +## Basic Structure + +```tsx + + + + + +``` + +## ThreadPrimitive.Root + +Container element. Accepts standard div props. + +```tsx + + {children} + +``` + +## ThreadPrimitive.Viewport + +Scrollable area containing messages. Handles auto-scroll on new messages. + +```tsx + + + +``` + +## ThreadPrimitive.Messages + +Renders the message list. + +```tsx + ..., + AssistantMessage: () => ..., + + // Optional: System message + SystemMessage: ({ message }) =>
{message.content}
, + + // Optional: Edit composer for message editing + EditComposer: () => ..., + }} +/> +``` + +## ThreadPrimitive.Empty + +Rendered when thread has no messages. + +```tsx + +
+

Welcome!

+

Start a conversation

+
+
+``` + +## ThreadPrimitive.ScrollToBottom + +Button that appears when scrolled up, scrolls to bottom on click. + +```tsx + + ↓ Scroll to bottom + +``` + +## ThreadPrimitive.Suggestions + +Renders suggested quick replies. + +```tsx + ( + + ), + }} +/> +``` + +## Conditional Rendering (`AuiIf`) + +`ThreadPrimitive.If` is deprecated. Prefer `AuiIf` with thread state: + +```tsx +// When no messages + thread.isEmpty}> + + + +// When has messages + !thread.isEmpty}> + + + +// While generating + thread.isRunning}> + + +``` + +### Available Conditions + +- `thread.isEmpty` - Thread has no messages +- `thread.isRunning` - Generation in progress +- `thread.isDisabled` - Thread is disabled + +## Complete Example + +```tsx +function CustomThread() { + return ( + + {/* Empty state */} + thread.isEmpty}> +
+
+

AI Assistant

+

How can I help you today?

+ ( + + ), + }} + /> +
+
+
+ + {/* Messages */} + !thread.isEmpty}> + +
+ +
+
+
+ + {/* Scroll to bottom */} + + + + + {/* Composer */} +
+ +
+
+ ); +} +``` + +## Accessing Thread State + +```tsx +import { useThread, useThreadRuntime } from "@assistant-ui/react"; + +function ThreadInfo() { + // Reactive state + const { messages, isRunning } = useThread(); + + // Runtime API + const runtime = useThreadRuntime(); + const handleClear = () => runtime.startRun(); + + return ( +
+

{messages.length} messages

+ {isRunning &&

Generating...

} +
+ ); +} +``` diff --git a/.agents/skills/runtime/SKILL.md b/.agents/skills/runtime/SKILL.md new file mode 100644 index 0000000..efa6c28 --- /dev/null +++ b/.agents/skills/runtime/SKILL.md @@ -0,0 +1,138 @@ +--- +name: runtime +description: Guide for assistant-ui runtime system and state management. Use when working with runtimes, accessing state, or managing thread/message data. +version: 0.0.1 +license: MIT +--- + +# assistant-ui Runtime + +**Always consult [assistant-ui.com/llms.txt](https://assistant-ui.com/llms.txt) for latest API.** + +## References + +- [./references/local-runtime.md](./references/local-runtime.md) -- useLocalRuntime deep dive +- [./references/external-store.md](./references/external-store.md) -- useExternalStoreRuntime deep dive +- [./references/thread-list.md](./references/thread-list.md) -- Thread list management +- [./references/state-hooks.md](./references/state-hooks.md) -- State access hooks +- [./references/types.md](./references/types.md) -- Type definitions + +## Runtime Hierarchy + +``` +AssistantRuntime +├── ThreadListRuntime (thread management) +│ ├── ThreadListItemRuntime (per-thread item) +│ └── ... +└── ThreadRuntime (current thread) + ├── ComposerRuntime (input state) + └── MessageRuntime[] (per-message) + └── MessagePartRuntime[] (per-content-part) +``` + +## State Access (Modern API) + +```tsx +import { useAui, useAuiState, useAuiEvent } from "@assistant-ui/react"; + +function ChatControls() { + const api = useAui(); + const messages = useAuiState(s => s.thread.messages); + const isRunning = useAuiState(s => s.thread.isRunning); + + useAuiEvent("composer.send", (e) => { + console.log("Sent in thread:", e.threadId); + }); + + return ( +
+ + {isRunning && ( + + )} +
+ ); +} +``` + +## Thread Operations + +```tsx +const api = useAui(); +const thread = api.thread(); + +// Append message +thread.append({ role: "user", content: [{ type: "text", text: "Hello" }] }); + +// Cancel generation +thread.cancelRun(); + +// Get current state +const state = thread.getState(); // { messages, isRunning, ... } +``` + +## Message Operations + +```tsx +const message = api.thread().message(0); // By index + +message.edit({ role: "user", content: [{ type: "text", text: "Updated" }] }); +message.reload(); +``` + +## Events + +```tsx +useAuiEvent("thread.runStart", () => {}); +useAuiEvent("thread.runEnd", () => {}); +useAuiEvent("composer.send", ({ threadId }) => { + console.log("Sent in thread:", threadId); +}); +useAuiEvent("thread.modelContextUpdate", () => {}); +``` + +## Capabilities + +```tsx +const caps = useAuiState(s => s.thread.capabilities); +// { cancel, edit, reload, copy, speak, attachments } +``` + +## Quick Reference + +```tsx +// Get messages +const messages = useAuiState(s => s.thread.messages); + +// Check running state +const isRunning = useAuiState(s => s.thread.isRunning); + +// Append message +api.thread().append({ role: "user", content: [{ type: "text", text: "Hi" }] }); + +// Cancel generation +api.thread().cancelRun(); + +// Edit message +api.thread().message(index).edit({ ... }); + +// Reload message +api.thread().message(index).reload(); +``` + +## Common Gotchas + +**"Cannot read property of undefined"** +- Ensure hooks are called inside `AssistantRuntimeProvider` + +**State not updating** +- Use selectors with `useAuiState` to prevent unnecessary re-renders + +**Messages array empty** +- Check runtime is configured +- Verify API response format diff --git a/.agents/skills/runtime/references/external-store.md b/.agents/skills/runtime/references/external-store.md new file mode 100644 index 0000000..3ec3424 --- /dev/null +++ b/.agents/skills/runtime/references/external-store.md @@ -0,0 +1,321 @@ +# useExternalStoreRuntime + +Connect assistant-ui to custom message stores (Redux, Zustand, etc.). + +## Basic Usage + +```tsx +import { useExternalStoreRuntime, AssistantRuntimeProvider } from "@assistant-ui/react"; +import { Thread } from "@/components/assistant-ui/thread"; + +function App() { + // Your existing state + const [messages, setMessages] = useState([]); + const [isRunning, setIsRunning] = useState(false); + + const runtime = useExternalStoreRuntime({ + messages, + isRunning, + onNew: async (message) => { + setMessages((prev) => [...prev, message]); + setIsRunning(true); + + // Call your API + const response = await fetch("/api/chat", { + method: "POST", + body: JSON.stringify({ messages: [...messages, message] }), + }); + + const data = await response.json(); + setMessages((prev) => [...prev, { + id: crypto.randomUUID(), + role: "assistant", + content: [{ type: "text", text: data.text }], + status: "complete", + createdAt: new Date(), + }]); + setIsRunning(false); + }, + }); + + return ( + + + + ); +} +``` + +## Options + +```tsx +interface ExternalStoreRuntimeOptions { + // Required + messages: readonly T[]; + isRunning: boolean; + onNew: (message: AppendMessage) => Promise; + + // Optional callbacks + onEdit?: (message: AppendMessage) => Promise; + onReload?: (parentId: string | null) => Promise; + onCancel?: () => Promise; + + // Message conversion (for custom message formats) + convertMessage?: (message: T) => ThreadMessage; + + // Capabilities override + capabilities?: Partial; + + // Adapters + adapters?: { + attachments?: AttachmentAdapter; + feedback?: FeedbackAdapter; + speech?: SpeechSynthesisAdapter; + }; +} +``` + +## With Redux + +```tsx +import { useSelector, useDispatch } from "react-redux"; +import { addMessage, setRunning } from "./chatSlice"; + +function Chat() { + const dispatch = useDispatch(); + const messages = useSelector((state) => state.chat.messages); + const isRunning = useSelector((state) => state.chat.isRunning); + + const runtime = useExternalStoreRuntime({ + messages, + isRunning, + onNew: async (message) => { + dispatch(addMessage(message)); + dispatch(setRunning(true)); + + const response = await chatAPI(message); + + dispatch(addMessage(response)); + dispatch(setRunning(false)); + }, + onEdit: async (message) => { + dispatch(editMessage(message)); + // Re-generate response... + }, + onReload: async (parentId) => { + dispatch(regenerateFrom(parentId)); + }, + }); + + return ( + + + + ); +} +``` + +## With Zustand + +```tsx +import { create } from "zustand"; + +interface ChatStore { + messages: ThreadMessage[]; + isRunning: boolean; + addMessage: (msg: ThreadMessage) => void; + setRunning: (running: boolean) => void; +} + +const useChatStore = create((set) => ({ + messages: [], + isRunning: false, + addMessage: (msg) => set((s) => ({ messages: [...s.messages, msg] })), + setRunning: (running) => set({ isRunning: running }), +})); + +function Chat() { + const { messages, isRunning, addMessage, setRunning } = useChatStore(); + + const runtime = useExternalStoreRuntime({ + messages, + isRunning, + onNew: async (message) => { + addMessage(message); + setRunning(true); + + const response = await fetchChat(messages.concat(message)); + + addMessage(response); + setRunning(false); + }, + }); + + return ( + + + + ); +} +``` + +## Custom Message Format + +```tsx +// Your message format +interface MyMessage { + uuid: string; + sender: "human" | "ai"; + text: string; + timestamp: number; +} + +function Chat() { + const [messages, setMessages] = useState([]); + + const runtime = useExternalStoreRuntime({ + messages, + isRunning: false, + convertMessage: (msg: MyMessage): ThreadMessage => ({ + id: msg.uuid, + role: msg.sender === "human" ? "user" : "assistant", + content: [{ type: "text", text: msg.text }], + status: "complete", + createdAt: new Date(msg.timestamp), + }), + onNew: async (appendMessage) => { + // Convert back to your format + const myMessage: MyMessage = { + uuid: crypto.randomUUID(), + sender: "human", + text: appendMessage.content + .filter((p): p is { type: "text"; text: string } => p.type === "text") + .map((p) => p.text) + .join(""), + timestamp: Date.now(), + }; + setMessages((prev) => [...prev, myMessage]); + }, + }); + + return ( + + + + ); +} +``` + +## Streaming Updates + +```tsx +const runtime = useExternalStoreRuntime({ + messages, + isRunning, + onNew: async (message) => { + addUserMessage(message); + setRunning(true); + + // Create placeholder for assistant message + const assistantId = crypto.randomUUID(); + addMessage({ + id: assistantId, + role: "assistant", + content: [{ type: "text", text: "" }], + status: "running", + createdAt: new Date(), + }); + + // Stream response + const response = await fetch("/api/chat", { + method: "POST", + body: JSON.stringify({ messages }), + }); + + const reader = response.body?.getReader(); + const decoder = new TextDecoder(); + let fullText = ""; + + while (reader) { + const { done, value } = await reader.read(); + if (done) break; + + fullText += decoder.decode(value); + + // Update message in place + updateMessage(assistantId, { + content: [{ type: "text", text: fullText }], + status: "running", + }); + } + + // Mark complete + updateMessage(assistantId, { + content: [{ type: "text", text: fullText }], + status: "complete", + }); + setRunning(false); + }, +}); +``` + +## With Edit and Reload + +```tsx +const runtime = useExternalStoreRuntime({ + messages, + isRunning, + onNew: async (message) => { + // Handle new message + }, + onEdit: async (message) => { + // Find message by parentId and create branch + const parentIndex = messages.findIndex((m) => m.id === message.parentId); + if (parentIndex === -1) return; + + // Replace messages after parent with edited message + setMessages([ + ...messages.slice(0, parentIndex + 1), + { + id: crypto.randomUUID(), + role: "user", + content: message.content, + status: "complete", + createdAt: new Date(), + }, + ]); + + // Regenerate response + await generateResponse(); + }, + onReload: async (parentId) => { + // Remove assistant message and regenerate + const parentIndex = messages.findIndex((m) => m.id === parentId); + setMessages(messages.slice(0, parentIndex + 1)); + await generateResponse(); + }, + onCancel: async () => { + abortController.current?.abort(); + setRunning(false); + }, +}); +``` + +## Capabilities + +```tsx +const runtime = useExternalStoreRuntime({ + messages, + isRunning, + onNew: handleNew, + // Only enable capabilities you implement + capabilities: { + edit: false, // Disable edit if onEdit not provided + reload: true, + cancel: true, + copy: true, + speak: false, + attachments: false, + }, +}); +``` diff --git a/.agents/skills/runtime/references/local-runtime.md b/.agents/skills/runtime/references/local-runtime.md new file mode 100644 index 0000000..d89b0a3 --- /dev/null +++ b/.agents/skills/runtime/references/local-runtime.md @@ -0,0 +1,288 @@ +# useLocalRuntime + +In-browser chat with custom model adapter. + +## Basic Usage + +```tsx +import { useLocalRuntime, AssistantRuntimeProvider } from "@assistant-ui/react"; +import { Thread } from "@/components/assistant-ui/thread"; + +function App() { + const runtime = useLocalRuntime({ + model: { + async run({ messages, abortSignal }) { + const response = await fetch("/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ messages }), + signal: abortSignal, + }); + + const data = await response.json(); + return { + content: [{ type: "text", text: data.text }], + }; + }, + }, + }); + + return ( + + + + ); +} +``` + +## Streaming Response + +Use a generator and emit `ChatModelRunResult` chunks (append-only content parts): + +```tsx +const runtime = useLocalRuntime({ + model: { + async *run({ messages, abortSignal }) { + const response = await fetch("/api/chat", { + method: "POST", + body: JSON.stringify({ messages }), + signal: abortSignal, + }); + + const reader = response.body?.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (reader) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // Split on newlines for this plain-text example (not Data Stream) + const parts = buffer.split("\n"); + buffer = parts.pop() ?? ""; + + for (const textChunk of parts.filter(Boolean)) { + yield { + content: [{ type: "text", text: textChunk }], + }; + } + } + + if (buffer) { + yield { content: [{ type: "text", text: buffer }] }; + } + }, + }, +}); +``` + +## Options + +```tsx +interface LocalRuntimeOptions { + model: ChatModelAdapter; + initialMessages?: ThreadMessage[]; + adapters?: { + attachments?: AttachmentAdapter; + feedback?: FeedbackAdapter; + speech?: SpeechSynthesisAdapter; + }; +} +``` + +## ChatModelAdapter + +```tsx +interface ChatModelAdapter { + run(options: ChatModelRunOptions): Promise | AsyncGenerator; +} + +interface ChatModelRunOptions { + messages: ThreadMessage[]; + abortSignal: AbortSignal; + config?: Record; +} + +type ChatModelRunResult = + | ChatModelRunResultFinal + | ChatModelRunResultStream; + +interface ChatModelRunResultFinal { + content: MessagePart[]; +} + +// Streamed chunks are ChatModelRunResult objects +type ChatModelRunResultStream = ChatModelRunResult; +``` + +## With OpenAI Direct + +```tsx +import OpenAI from "openai"; + +const openai = new OpenAI({ + apiKey: process.env.NEXT_PUBLIC_OPENAI_API_KEY, + dangerouslyAllowBrowser: true, // Only for demos +}); + +const runtime = useLocalRuntime({ + model: { + async *run({ messages, abortSignal }) { + const stream = await openai.chat.completions.create({ + model: "gpt-4o", + messages: messages.map((m) => ({ + role: m.role, + content: m.content + .filter((p): p is { type: "text"; text: string } => p.type === "text") + .map((p) => p.text) + .join(""), + })), + stream: true, + }); + + for await (const chunk of stream) { + if (abortSignal.aborted) break; + const delta = chunk.choices[0]?.delta?.content; + if (delta) { + yield { content: [{ type: "text", text: delta }] }; + } + } + }, + }, +}); +``` + +## With Tools + +Emit tool calls as message parts (`type: "tool-call"`) and include `argsText` plus optional `result`: + +```tsx +const runtime = useLocalRuntime({ + model: { + async *run({ messages, abortSignal }) { + const toolCallId = "1"; + + // Yield tool call with parsed arguments + yield { + content: [ + { + type: "tool-call", + toolCallId, + toolName: "get_weather", + args: { city: "NYC" }, + argsText: '{"city":"NYC"}', + }, + ], + }; + + // Execute tool + const result = await getWeather({ city: "NYC" }); + + // Send result on the same tool-call part + yield { + content: [ + { + type: "tool-call", + toolCallId, + toolName: "get_weather", + args: { city: "NYC" }, + argsText: '{"city":"NYC"}', + result, + }, + { type: "text", text: `The weather in NYC is ${result.temp}°C` }, + ], + }; + }, + }, +}); +``` + +## With Attachments + +```tsx +const runtime = useLocalRuntime({ + model: { + async run({ messages }) { + // Access attachments from last message + const lastMessage = messages[messages.length - 1]; + const attachments = lastMessage.attachments || []; + + // Process attachments + for (const attachment of attachments) { + if (attachment.type === "image") { + // Handle image + } + } + + return { content: [{ type: "text", text: "Processed" }] }; + }, + }, + adapters: { + attachments: { + accept: "image/*,application/pdf", + async add({ file }) { + const url = URL.createObjectURL(file); + return { + id: crypto.randomUUID(), + name: file.name, + type: file.type.startsWith("image/") ? "image" : "file", + url, + }; + }, + async send(attachment) { + return attachment; + }, + async remove() {}, + }, + }, +}); +``` + +## With Initial Messages + +```tsx +const runtime = useLocalRuntime({ + model: { ... }, + initialMessages: [ + { + id: "1", + role: "assistant", + content: [{ type: "text", text: "Hello! How can I help you?" }], + status: "complete", + createdAt: new Date(), + }, + ], +}); +``` + +## Error Handling + +```tsx +const runtime = useLocalRuntime({ + model: { + async *run({ messages, abortSignal }) { + try { + const response = await fetch("/api/chat", { + method: "POST", + body: JSON.stringify({ messages }), + signal: abortSignal, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + // ... process response + } catch (error) { + if (error.name === "AbortError") { + // User cancelled - normal, don't throw + return; + } + throw error; // Re-throw to show error in UI + } + }, + }, +}); +``` diff --git a/.agents/skills/runtime/references/state-hooks.md b/.agents/skills/runtime/references/state-hooks.md new file mode 100644 index 0000000..9503f4e --- /dev/null +++ b/.agents/skills/runtime/references/state-hooks.md @@ -0,0 +1,283 @@ +# State Hooks + +Accessing assistant-ui runtime state. + +## Modern API (Recommended) + +### useAui + +Get the runtime API for imperative actions. + +```tsx +import { useAui } from "@assistant-ui/react"; + +function Controls() { + const api = useAui(); + + // Thread operations + const thread = api.thread(); + thread.append({ role: "user", content: [{ type: "text", text: "Hi" }] }); + thread.cancelRun(); + thread.startRun(); + + // Message operations + const message = thread.message(0); + message.edit({ ... }); + message.reload(); + + // Thread list operations + const threads = api.threads(); + threads.switchToThread(threadId); + threads.switchToNewThread(); + + // Get state snapshot + const state = api.getState(); +} +``` + +### useAuiState + +Subscribe to state changes with a selector. + +```tsx +import { useAuiState } from "@assistant-ui/react"; + +function MessageCount() { + // Re-renders when messages change + const messages = useAuiState((s) => s.thread.messages); + return
{messages.length} messages
; +} + +function RunningIndicator() { + // Only re-renders when isRunning changes + const isRunning = useAuiState((s) => s.thread.isRunning); + return isRunning ? : null; +} + +function ComposerText() { + const text = useAuiState((s) => s.thread.composer.text); + return
Typing: {text}
; +} + +function ThreadInfo() { + // Multiple values + const { messages, isRunning, capabilities } = useAuiState((s) => ({ + messages: s.thread.messages, + isRunning: s.thread.isRunning, + capabilities: s.thread.capabilities, + })); +} +``` + +### useAuiEvent + +Listen to runtime events. + +```tsx +import { useAuiEvent } from "@assistant-ui/react"; + +function Analytics() { + useAuiEvent("composer.send", (event) => { + analytics.track("message_sent", { + threadId: event.threadId, + messageId: event.messageId, // optional, may be undefined + }); + }); + + useAuiEvent("thread.runStart", () => { + console.log("Generation started"); + }); + + useAuiEvent("thread.runEnd", () => { + console.log("Generation completed"); + }); + + return null; +} +``` + +Available events: +- `composer.send` - Message submitted from composer +- `composer.attachmentAdd` - Attachment added in composer +- `thread.runStart` - Generation started +- `thread.runEnd` - Generation ended +- `thread.initialize` - Thread is initialized +- `thread.modelContextUpdate` - Thread model context updated +- `threadListItem.switchedTo` - Active thread changed +- `threadListItem.switchedAway` - Active thread changed away + +## State Shape + +```typescript +interface AssistantState { + thread: { + messages: ThreadMessage[]; + isRunning: boolean; + capabilities: RuntimeCapabilities; + composer: { + text: string; + attachments: Attachment[]; + }; + }; + threads: { + mainThreadId: string; + newThreadId: string | null; + threadIds: readonly string[]; + archivedThreadIds: readonly string[]; + isLoading: boolean; + threadItems: readonly ThreadListItemState[]; + main: ThreadState; + }; + threadListItem: { + id: string; + remoteId?: string; + externalId?: string; + title?: string; + status: "archived" | "regular" | "new" | "deleted"; + }; +} +``` + +## Legacy Hooks + +These still work but prefer the modern API: + +```tsx +// Runtime access +import { + useAssistantRuntime, + useThreadRuntime, + useMessageRuntime, + useComposerRuntime, +} from "@assistant-ui/react"; + +const assistantRuntime = useAssistantRuntime(); +const threadRuntime = useThreadRuntime(); +const messageRuntime = useMessageRuntime(); // Needs message context +const composerRuntime = useComposerRuntime(); + +// State subscriptions +import { + useThread, + useThreadMessages, + useComposer, + useMessage, + useThreadList, +} from "@assistant-ui/react"; + +const thread = useThread(); // { messages, isRunning, ... } +const messages = useThreadMessages(); // ThreadMessage[] +const composer = useComposer(); // { text, attachments, ... } +const message = useMessage(); // Current message (needs context) +const threadList = useThreadList(); // Thread list state +``` + +## Context Requirements + +Some hooks require being inside specific contexts: + +```tsx +// These work anywhere inside AssistantRuntimeProvider +useAui() +useAuiState() +useAuiEvent() +useAssistantRuntime() +useThreadRuntime() +useThread() +useThreadMessages() +useComposer() + +// These require message context (inside ThreadPrimitive.Messages) +useMessageRuntime() +useMessage() + +// These require message part context +useMessagePartRuntime() +``` + +## Performance Tips + +### Use Selectors + +```tsx +// Bad - re-renders on any state change +const state = useAuiState((s) => s); + +// Good - only re-renders when messages change +const messages = useAuiState((s) => s.thread.messages); + +// Better - only re-renders when message count changes +const count = useAuiState((s) => s.thread.messages.length); +``` + +### Memoize Derived Data + +```tsx +function MessageList() { + const messages = useAuiState((s) => s.thread.messages); + + // Memoize expensive computations + const userMessages = useMemo( + () => messages.filter((m) => m.role === "user"), + [messages] + ); + + return
{userMessages.length} user messages
; +} +``` + +### Split Components + +```tsx +// Bad - entire component re-renders +function Chat() { + const messages = useAuiState((s) => s.thread.messages); + const isRunning = useAuiState((s) => s.thread.isRunning); + return ( +
+ + +
+ ); +} + +// Good - components re-render independently +function Chat() { + return ( +
+ + +
+ ); +} + +function MessageList() { + const messages = useAuiState((s) => s.thread.messages); + return
...
; +} + +function RunningIndicator() { + const isRunning = useAuiState((s) => s.thread.isRunning); + return isRunning ? : null; +} +``` + +## Direct Subscription + +For non-React contexts: + +```tsx +const api = useAui(); + +useEffect(() => { + const runtime = api.thread(); + + // Subscribe to changes + const unsubscribe = runtime.subscribe(() => { + const state = runtime.getState(); + console.log("State changed:", state); + }); + + return unsubscribe; +}, [api]); +``` diff --git a/.agents/skills/runtime/references/thread-list.md b/.agents/skills/runtime/references/thread-list.md new file mode 100644 index 0000000..d75bb1f --- /dev/null +++ b/.agents/skills/runtime/references/thread-list.md @@ -0,0 +1,283 @@ +# Thread List Runtime + +Managing multiple chat threads. + +## Overview + +Thread list features are automatically available when using `useChatRuntime` with cloud persistence or `unstable_useRemoteThreadListRuntime`. + +## ThreadListRuntime API + +```typescript +type ThreadListRuntime = { + getState(): ThreadListState; + subscribe(callback: () => void): Unsubscribe; + + main: ThreadRuntime; // Current active thread + getById(threadId: string): ThreadRuntime; + + mainItem: ThreadListItemRuntime; // Current thread item + getItemById(threadId: string): ThreadListItemRuntime; + getItemByIndex(idx: number): ThreadListItemRuntime; + getArchivedItemByIndex(idx: number): ThreadListItemRuntime; + + switchToThread(threadId: string): Promise; + switchToNewThread(): Promise; +}; +``` + +## ThreadListState + +This is the state shape returned by `ThreadListRuntime.getState()` (runtime API). +For app-level state via `useAuiState((s) => s.threads)`, use the client `ThreadsState` shape (`newThreadId: string | null`, `threadItems: readonly ThreadListItemState[]`). + +```typescript +type ThreadListState = { + mainThreadId: string; // Current thread ID + newThreadId: string | undefined; // Pending new thread ID + threadIds: readonly string[]; // Regular thread IDs + archivedThreadIds: readonly string[]; + isLoading: boolean; + threadItems: Record>; +}; +``` + +## ThreadListItemRuntime API + +```typescript +type ThreadListItemRuntime = { + getState(): ThreadListItemState; + + switchTo(): Promise; + rename(newTitle: string): Promise; + archive(): Promise; + unarchive(): Promise; + delete(): Promise; + + initialize(): Promise<{ remoteId: string; externalId?: string }>; + generateTitle(): Promise; + + subscribe(callback: () => void): Unsubscribe; +}; +``` + +## Accessing Thread List + +```tsx +import { useAui, useAuiState } from "@assistant-ui/react"; + +function ThreadListComponent() { + const api = useAui(); + + // Get thread list state + const { threadIds, archivedThreadIds, isLoading } = useAuiState( + (s) => s.threads + ); + + // Switch threads + const handleSwitch = (threadId: string) => { + api.threads().switchToThread(threadId); + }; + + // Create new thread + const handleNew = () => { + api.threads().switchToNewThread(); + }; + + return ( +
+ + {threadIds.map((threadId) => ( + + ))} +
+ ); +} +``` + +## Thread Item Operations + +```tsx +function ThreadItem({ threadId }: { threadId: string }) { + const api = useAui(); + const item = api.threads().item({ id: threadId }); + + const handleRename = async () => { + await item.rename("New Title"); + }; + + const handleArchive = async () => { + await item.archive(); + }; + + const handleDelete = async () => { + await item.delete(); + }; + + return ( +
+ + + + +
+ ); +} +``` + +## Using ThreadList Primitives + +```tsx +import { + ThreadListPrimitive, + ThreadListItemPrimitive, +} from "@assistant-ui/react"; + +function ThreadList() { + return ( + + + + New Chat + + +
+ + + + + + +
+ + 📁 + + + 🗑️ + +
+
+
+
+
+ ); +} +``` + +## With Custom Thread List + +```tsx +function SidebarWithThreadList() { + const { threadIds, mainThreadId } = useAuiState((s) => ({ + threadIds: s.threads.threadIds, + mainThreadId: s.threads.mainThreadId, + })); + const api = useAui(); + + return ( + + ); +} +``` + +## Remote Thread List Adapter + +For custom persistence: + +```tsx +import { + AssistantRuntimeProvider, + unstable_useRemoteThreadListRuntime as useRemoteThreadListRuntime, + useLocalRuntime, +} from "@assistant-ui/react"; +import { Thread } from "@/components/assistant-ui/thread"; +import { ThreadList } from "@/components/assistant-ui/thread-list"; + +const adapter: RemoteThreadListAdapter = { + async list() { + const threads = await api.getThreads(); + return { + threads: threads.map((t) => ({ + remoteId: t.id, + status: t.archived ? "archived" : "regular", + title: t.title, + })), + }; + }, + + async initialize(threadId) { + const thread = await api.createThread({ localId: threadId }); + return { remoteId: thread.id }; + }, + + async rename(remoteId, newTitle) { + await api.updateThread(remoteId, { title: newTitle }); + }, + + async archive(remoteId) { + await api.updateThread(remoteId, { archived: true }); + }, + + async unarchive(remoteId) { + await api.updateThread(remoteId, { archived: false }); + }, + + async delete(remoteId) { + await api.deleteThread(remoteId); + }, + + async generateTitle(remoteId, messages) { + return api.generateTitle(remoteId, messages); + }, + + async fetch(threadId) { + const thread = await api.getThread(threadId); + return { + remoteId: thread.id, + status: thread.archived ? "archived" : "regular", + title: thread.title, + }; + }, +}; + +function App() { + const runtime = useRemoteThreadListRuntime({ + adapter, + runtimeHook: () => useLocalRuntime({ model: myModel }), + }); + + return ( + + + + + ); +} +``` diff --git a/.agents/skills/runtime/references/types.md b/.agents/skills/runtime/references/types.md new file mode 100644 index 0000000..5637b28 --- /dev/null +++ b/.agents/skills/runtime/references/types.md @@ -0,0 +1,203 @@ +# Runtime Types + +Type definitions for assistant-ui runtime system. + +## Message Types + +```typescript +type ThreadMessage = + | ThreadUserMessage + | ThreadAssistantMessage + | ThreadSystemMessage; + +interface ThreadUserMessage { + id: string; + role: "user"; + content: MessagePart[]; + attachments?: Attachment[]; + createdAt: Date; +} + +interface ThreadAssistantMessage { + id: string; + role: "assistant"; + content: MessagePart[]; + status: MessageStatus; + createdAt: Date; +} + +interface ThreadSystemMessage { + id: string; + role: "system"; + content: MessagePart[]; + createdAt: Date; +} +``` + +## Message Status + +```typescript +type MessageStatus = + | "running" // Generation in progress + | "complete" // Finished successfully + | "incomplete" // Stopped early + | "requires-action"; // Needs tool response +``` + +## Message Parts + +```typescript +type MessagePart = + | TextPart + | ImagePart + | ToolCallPart + | ReasoningPart + | SourcePart + | FilePart; + +interface TextPart { + type: "text"; + text: string; +} + +interface ImagePart { + type: "image"; + image: string; +} + +interface ToolCallPart { + type: "tool-call"; + toolCallId: string; + toolName: string; + args: unknown; + argsText: string; + result?: unknown; + isError?: boolean; + artifact?: unknown; +} + +interface ReasoningPart { + type: "reasoning"; + text: string; +} + +interface SourcePart { + type: "source"; + sourceType: "url"; + id: string; + url: string; + title?: string; +} + +interface FilePart { + type: "file"; + filename?: string; + data: string; + mimeType: string; +} +``` + +## Attachment Types + +```typescript +interface Attachment { + id: string; + type: "image" | "file" | "document"; + name: string; + file?: File; + content?: AttachmentContent[]; +} + +type AttachmentContent = + | { type: "text"; text: string } + | { type: "image"; image: string }; +``` + +## Runtime State Types + +```typescript +interface ThreadState { + threadId: string; + messages: ThreadMessage[]; + isRunning: boolean; + capabilities: ThreadCapabilities; +} + +interface ThreadCapabilities { + cancel: boolean; // Can cancel generation + edit: boolean; // Can edit messages + reload: boolean; // Can regenerate + copy: boolean; // Can copy messages + speak: boolean; // TTS support + attachments: boolean; // File uploads +} +``` + +## Thread List Types + +```typescript +interface ThreadListState { + threadIds: readonly string[]; // Active thread IDs + archivedThreadIds: readonly string[]; // Archived thread IDs + newThreadId: string | null; // Pending new thread ID + mainThreadId: string; // Current active thread + isLoading: boolean; + threadItems: readonly ThreadListItemState[]; +} + +interface ThreadListItemState { + id: string; + remoteId?: string; + externalId?: string; + title?: string; + status: "archived" | "regular" | "new" | "deleted"; +} +``` + +## Composer State + +```typescript +interface ComposerState { + text: string; + attachments: Attachment[]; + isEmpty: boolean; + isSubmitting: boolean; + isDictating: boolean; +} +``` + +## Tool Call Types + +```typescript +type ToolCallStatus = + | "running" // Tool executing + | "complete" // Finished + | "incomplete" // Stopped early + | "requires-action" // Needs user input + +interface ToolUIProps { + toolCallId: string; + toolName: string; + args: TArgs; + argsText: string; + result?: TResult; + status: ToolCallStatus; + submitResult: (result: unknown) => void; +} +``` + +## ChatModelRunResult + +Used by `useLocalRuntime` for streaming: + +```typescript +interface ChatModelRunResult { + content: MessagePart[]; +} + +// Yield content parts progressively: +async function* run({ messages }) { + yield { content: [{ type: "text", text: "Hello " }] }; + yield { content: [{ type: "text", text: "world!" }] }; +} +``` diff --git a/.agents/skills/setup/SKILL.md b/.agents/skills/setup/SKILL.md new file mode 100644 index 0000000..3299600 --- /dev/null +++ b/.agents/skills/setup/SKILL.md @@ -0,0 +1,104 @@ +--- +name: setup +description: Setup and configure assistant-ui in a project. Use when installing packages, configuring runtimes, setting up chat UI, or troubleshooting setup issues. +version: 0.1.0 +license: MIT +--- + +# assistant-ui Setup + +## CLI Commands + +### Quick Decision Flow + +- Existing Next.js app (`package.json` exists): use `npx assistant-ui@latest init` +- Existing app in CI/agent/non-interactive shell: use `npx assistant-ui@latest init --yes` +- Existing app + force overwrite of conflicts: add `--overwrite` +- New app / empty directory: use `npx assistant-ui@latest create ` +- Need specific starter template: add `-t ` +- Need a curated example: use `npx assistant-ui@latest create --example ` +- Need playground preset config: use `npx assistant-ui@latest create --preset ` + +### New Project (`create`) + +```bash +npx assistant-ui@latest create my-app -t minimal +npx assistant-ui@latest create my-app -t cloud-clerk +npx assistant-ui@latest create my-app --preset "https://www.assistant-ui.com/playground/init?preset=chatgpt" +``` + +Templates: + +| Template | Description | +|-------|-------| +| `default` | Default template with Vercel AI SDK | +| `minimal` | Bare-bones starting point | +| `cloud` | Cloud-backed persistence starter | +| `cloud-clerk` | Cloud-backed starter with Clerk auth | +| `langgraph` | LangGraph starter template | +| `mcp` | MCP starter template | + +When `-t` is omitted: +- Interactive shell (TTY): an interactive template picker is shown. +- Non-interactive shell (CI/agent): template defaults to `default`. + +If no project directory is provided in a non-interactive shell, `create` uses `my-aui-app`. + +### Existing Next.js Project (`init`) + +```bash +npx assistant-ui@latest init --yes +``` + +The `init` command is for **existing projects only** (requires `package.json`). +If no project is found, it automatically forwards to `create`. +Passing `--preset` to `init` also forwards to `create` (compatibility path). + +The `--yes` flag runs non-interactively (no prompts). + +### Add Registry Components + +```bash +npx assistant-ui@latest add markdown-text +npx assistant-ui@latest add thread-list +``` + +Registry: `https://r.assistant-ui.com/{name}.json` + +--- + +## Template Code Policy + +When using CLI templates (`npx assistant-ui@latest create`), **never modify generated code** unless explicitly requested. + +--- + +## Non-Default Setups + +For runtimes other than AI SDK or frameworks other than Next.js, consult the reference files: + +| Setup | Runtime Hook | Reference | +|-------|-------------|-----------| +| AI SDK advanced (tools, cloud, options) | `useChatRuntime` | [references/ai-sdk.md](./references/ai-sdk.md) | +| Styling and UI customization (shadcn pattern) | — | [references/styling.md](./references/styling.md) | +| LangGraph agents | `useLangGraphRuntime` | [references/langgraph.md](./references/langgraph.md) | +| AG-UI protocol | `useAgUiRuntime` | [references/ag-ui.md](./references/ag-ui.md) | +| A2A protocol | `useA2ARuntime` | [references/a2a.md](./references/a2a.md) | +| Custom streaming API | `useLocalRuntime` | [references/custom-backend.md](./references/custom-backend.md) | +| Existing state (Redux/Zustand) | `useExternalStoreRuntime` | [references/custom-backend.md](./references/custom-backend.md) | +| Vite / TanStack Start | — | [references/tanstack.md](./references/tanstack.md) | + +--- + +## Deprecated Packages + +NEVER install `@assistant-ui/styles` or `@assistant-ui/react-ui` — both are deprecated and deleted. + +--- + +## Troubleshooting + +For issues not covered by the reference files, use the docs website: + +1. **Fetch the index**: `https://www.assistant-ui.com/llms.txt` — compact table of contents +2. **Fetch specific pages**: Append `.mdx` to the docs URL, e.g. `https://www.assistant-ui.com/docs/runtimes/ai-sdk.mdx` diff --git a/.agents/skills/setup/references/a2a.md b/.agents/skills/setup/references/a2a.md new file mode 100644 index 0000000..23ecb0e --- /dev/null +++ b/.agents/skills/setup/references/a2a.md @@ -0,0 +1,161 @@ +# A2A Protocol Integration + +Connect assistant-ui to Agent-to-Agent (A2A) protocol backends for multi-agent systems. + +## Installation + +```bash +npm install @assistant-ui/react-a2a +``` + +## Exports + +```tsx +import { + useA2ARuntime, + useA2AMessages, + useA2ATaskState, + useA2AArtifacts, + useA2ASend, + convertA2AMessages, + A2AMessageAccumulator, + appendA2AChunk, +} from "@assistant-ui/react-a2a"; +``` + +## Basic Setup + +```tsx +import { AssistantRuntimeProvider } from "@assistant-ui/react"; +import { Thread } from "@/components/assistant-ui/thread"; +import { useA2ARuntime } from "@assistant-ui/react-a2a"; + +function Chat() { + const runtime = useA2ARuntime({ + stream: async function* (messages, config) { + const response = await fetch("/api/a2a", { + method: "POST", + body: JSON.stringify({ messages, config }), + }); + + const reader = response.body?.getReader(); + // ... yield A2A events + }, + }); + + return ( + + + + ); +} +``` + +## useA2ARuntime Options + +```tsx +const runtime = useA2ARuntime({ + stream: A2AStreamCallback, // Required: streaming function + contextId: "thread-id", // Optional: thread context ID (deprecated) + autoCancelPendingToolCalls: true, // Optional: auto-cancel pending tools + unstable_allowCancellation: false, // Optional: enable cancellation + onSwitchToNewThread: () => {}, // Optional: new thread handler (deprecated) + onSwitchToThread: async (id) => ({ // Optional: switch thread handler + messages: [], + artifacts: [], + }), + adapters: { + attachments: AttachmentAdapter, + speech: SpeechSynthesisAdapter, + feedback: FeedbackAdapter, + }, + eventHandlers: { // Optional: A2A event callbacks + onTaskUpdate: (event) => {}, + onArtifacts: (event) => {}, + onError: (event) => {}, + onStateUpdate: (event) => {}, + onCustomEvent: (event) => {}, + }, +}); +``` + +## Accessing A2A State + +```tsx +import { useA2ATaskState, useA2AArtifacts, useA2ASend } from "@assistant-ui/react-a2a"; + +function MyComponent() { + const taskState = useA2ATaskState(); // Current task state + const artifacts = useA2AArtifacts(); // Accumulated artifacts + const send = useA2ASend(); // Send function for manual control + + // Send messages manually + await send( + [{ role: "user", content: "Hello" }], + { contextId: "my-context" } + ); +} +``` + +## A2A Message Types + +```tsx +type A2AMessage = { + id?: string; + role: "user" | "assistant" | "system" | "tool"; + content: string | A2AMessageContent[]; + tool_calls?: A2AToolCall[]; + tool_call_id?: string; + artifacts?: A2AArtifact[]; + status?: MessageStatus; +}; + +type A2AMessageContent = + | { type: "text"; text: string } + | { type: "image_url"; image_url: string | { url: string } } + | { type: "data"; data: any }; + +type A2AToolCall = { + id: string; + name: string; + args: ReadonlyJSONObject; + argsText?: string; +}; + +type A2AArtifact = { + name: string; + parts: A2AArtifactPart[]; +}; +``` + +## With Cloud Thread Management + +For thread persistence, use `useCloudThreadListRuntime`: + +```tsx +import { useA2ARuntime } from "@assistant-ui/react-a2a"; +import { useCloudThreadListRuntime } from "assistant-cloud/react"; + +function Chat() { + const runtime = useA2ARuntime({ + stream: myStreamFunction, + // Don't use contextId/onSwitchToThread here + }); + + // Use cloud for thread management instead + const threadListRuntime = useCloudThreadListRuntime({ cloud }); + + return ( + + + + ); +} +``` + +## When to Use A2A + +- Multi-agent orchestration systems +- Agents with artifact generation (files, images, etc.) +- Complex task state tracking +- Human-in-the-loop tool execution diff --git a/.agents/skills/setup/references/ag-ui.md b/.agents/skills/setup/references/ag-ui.md new file mode 100644 index 0000000..342b6fd --- /dev/null +++ b/.agents/skills/setup/references/ag-ui.md @@ -0,0 +1,104 @@ +# AG-UI Protocol Integration + +Connect assistant-ui to [AG-UI](https://github.com/ag-ui-protocol/ag-ui) compatible agent backends. + +## Installation + +```bash +npm install @assistant-ui/react-ag-ui @ag-ui/client +``` + +## Basic Setup + +```tsx +"use client"; + +import { useMemo } from "react"; +import { AssistantRuntimeProvider } from "@assistant-ui/react"; +import { Thread } from "@/components/assistant-ui/thread"; +import { HttpAgent } from "@ag-ui/client"; +import { useAgUiRuntime } from "@assistant-ui/react-ag-ui"; + +function Chat() { + const agent = useMemo(() => { + return new HttpAgent({ + url: "http://localhost:8000/agent", + headers: { + Accept: "text/event-stream", + }, + }); + }, []); + + const runtime = useAgUiRuntime({ + agent, + logger: { + debug: (...args) => console.debug("[agui]", ...args), + error: (...args) => console.error("[agui]", ...args), + }, + }); + + return ( + + + + ); +} +``` + +## useAgUiRuntime Options + +```tsx +const runtime = useAgUiRuntime({ + agent: HttpAgent, // Required: AG-UI HttpAgent instance + logger: { // Optional: logging callbacks + debug: (...args) => {}, + error: (...args) => {}, + }, + showThinking: true, // Optional: show thinking content + onError: (e) => {}, // Optional: error handler + onCancel: () => {}, // Optional: cancel handler + adapters: { // Optional: assistant-ui adapters + attachments: AttachmentAdapter, + speech: SpeechSynthesisAdapter, + dictation: DictationAdapter, + feedback: FeedbackAdapter, + history: ThreadHistoryAdapter, + }, +}); +``` + +## HttpAgent Configuration + +```tsx +import { HttpAgent } from "@ag-ui/client"; + +const agent = new HttpAgent({ + url: process.env.NEXT_PUBLIC_AGUI_AGENT_URL ?? "http://localhost:8000/agent", + headers: { + Accept: "text/event-stream", + // Add auth headers if needed + }, +}); +``` + +## AG-UI Event Types + +The runtime handles these AG-UI events: + +- `RUN_STARTED` / `RUN_FINISHED` / `RUN_CANCELLED` / `RUN_ERROR` +- `TEXT_MESSAGE_START` / `TEXT_MESSAGE_CONTENT` / `TEXT_MESSAGE_END` +- `THINKING_START` / `THINKING_TEXT_MESSAGE_*` / `THINKING_END` +- `TOOL_CALL_START` / `TOOL_CALL_ARGS` / `TOOL_CALL_END` / `TOOL_CALL_RESULT` +- `STATE_SNAPSHOT` / `STATE_DELTA` / `MESSAGES_SNAPSHOT` + +## Environment Variables + +```env +NEXT_PUBLIC_AGUI_AGENT_URL=http://localhost:8000/agent +``` + +## When to Use AG-UI + +- Building agents with [AG-UI protocol](https://github.com/ag-ui-protocol/ag-ui) +- Need streaming support with thinking/reasoning visibility +- Want protocol-level compatibility across different agent frameworks diff --git a/.agents/skills/setup/references/ai-sdk.md b/.agents/skills/setup/references/ai-sdk.md new file mode 100644 index 0000000..af54cce --- /dev/null +++ b/.agents/skills/setup/references/ai-sdk.md @@ -0,0 +1,289 @@ +# AI SDK v6 Integration + +Use this when the app uses `@assistant-ui/react-ai-sdk` and an `/api/chat` route powered by `ai@^6`. + +## What Changed in AI SDK v6 + +Agents trained on older AI SDK versions will use outdated patterns. These are **AI SDK** breaking changes (not assistant-ui changes): + +| Concept | Old (v4/v5) | Current (v6) | +|---------|-------------|--------------| +| useChat import | `import { useChat } from "ai/react"` | `import { useChat } from "@ai-sdk/react"` | +| assistant-ui wiring | `useAISDKRuntime(chat)` | `useChatRuntime({ transport })` | +| Message conversion | Pass messages directly to `streamText` | `await convertToModelMessages(messages)` | +| Stream response | `result.toDataStreamResponse()` | `result.toUIMessageStreamResponse()` | +| Tool schema key | `parameters: z.object({...})` | `inputSchema: z.object({...})` | +| Multi-step tools | `maxSteps: n` | `stopWhen: stepCountIs(n)` | + +## Standard Setup + +**Frontend**: + +```tsx +"use client"; + +import { AssistantRuntimeProvider } from "@assistant-ui/react"; +import { useChatRuntime } from "@assistant-ui/react-ai-sdk"; +import { lastAssistantMessageIsCompleteWithToolCalls } from "ai"; +import { Thread } from "@/components/assistant-ui/thread"; + +export function Assistant() { + const runtime = useChatRuntime({ + sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls, + }); + + return ( + +
+ +
+
+ ); +} +``` + +**Backend** route (`app/api/chat/route.ts`): + +```ts +import { openai } from "@ai-sdk/openai"; +import { frontendTools } from "@assistant-ui/react-ai-sdk"; +import { streamText, convertToModelMessages, type UIMessage } from "ai"; + +export async function POST(req: Request) { + const { + messages, + system, + tools, + }: { + messages: UIMessage[]; + system?: string; + tools?: Record; + } = await req.json(); + + const result = streamText({ + model: openai("gpt-4o"), + system, + messages: await convertToModelMessages(messages), + tools: { + ...frontendTools(tools ?? {}), + // backend tools here... + }, + }); + + return result.toUIMessageStreamResponse(); +} +``` + +`AssistantChatTransport` (the default transport for `useChatRuntime`) automatically forwards `system` and `tools` from the frontend to the backend: + +- **`system`** — set via `useAssistantInstructions()` on the frontend, sent as a string in the request body. +- **`tools`** — registered via `makeAssistantTool()` or `useAssistantTool()` on the frontend, sent as JSON Schema definitions in the request body. +- **`frontendTools()`** — converts those JSON Schema definitions into the AI SDK tool format so `streamText` can use them alongside backend-defined tools. + +The route must destructure and use both `system` and `tools` for frontend tool forwarding to work. + +## Runtime Options + +`useChatRuntime` supports the underlying AI SDK chat options plus assistant-ui extensions like `cloud`, `adapters`, and `toCreateMessage`. + +```tsx +import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/react-ai-sdk"; +import { lastAssistantMessageIsCompleteWithToolCalls } from "ai"; + +const runtime = useChatRuntime({ + transport: new AssistantChatTransport({ + api: "/api/chat", + headers: { "X-Workspace": "acme" }, + body: { model: "gpt-4o-mini" }, + }), + messages: [ + { id: "1", role: "assistant", parts: [{ type: "text", text: "Hello! How can I help?" }] }, + ], + sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls, + onError: (error) => { + console.error(error); + }, + cloud, // optional AssistantCloud instance + adapters: { + attachments: attachmentAdapter, + feedback: feedbackAdapter, + }, +}); +``` + +If you explicitly need non-assistant transport behavior, pass a custom transport: + +```tsx +import { DefaultChatTransport } from "ai"; +import { useChatRuntime } from "@assistant-ui/react-ai-sdk"; + +const runtime = useChatRuntime({ + transport: new DefaultChatTransport({ api: "/api/chat" }), +}); +``` + +## Tools (AI SDK v6 shape) + +Use `tool({ inputSchema: z.object({...}) })` and `stopWhen: stepCountIs(...)` for multi-step tool loops. + +```ts +import { openai } from "@ai-sdk/openai"; +import { + streamText, + tool, + stepCountIs, + convertToModelMessages, + type UIMessage, +} from "ai"; +import { z } from "zod"; + +export async function POST(req: Request) { + const { messages }: { messages: UIMessage[] } = await req.json(); + + const result = streamText({ + model: openai("gpt-4o"), + messages: await convertToModelMessages(messages), + stopWhen: stepCountIs(10), + tools: { + get_weather: tool({ + description: "Get weather by city", + inputSchema: z.object({ city: z.string() }), + execute: async ({ city }) => ({ city, temperature: 22, unit: "C" }), + }), + }, + }); + + return result.toUIMessageStreamResponse(); +} +``` + +## Frontend Tool UI + +Use `makeAssistantToolUI` to render tool calls in the chat. Place the component inside `AssistantRuntimeProvider`. + +```tsx +import { makeAssistantToolUI } from "@assistant-ui/react"; + +const WeatherToolUI = makeAssistantToolUI({ + toolName: "get_weather", + render: ({ args, result, status }) => { + if (status.type === "running") { + return
Loading weather for {args.city}...
; + } + return ( +
+ {result?.city}: {result?.temperature}°{result?.unit} +
+ ); + }, +}); + +// Register inside the provider tree + + + + +``` + +## Using Different Providers + +Swap the model in `streamText()` — any `@ai-sdk/*` provider works: + +```ts +import { anthropic } from "@ai-sdk/anthropic"; +streamText({ model: anthropic("claude-sonnet-4-20250514"), ... }); + +import { google } from "@ai-sdk/google"; +streamText({ model: google("gemini-2.0-flash"), ... }); + +import { bedrock } from "@ai-sdk/amazon-bedrock"; +streamText({ model: bedrock("anthropic.claude-3-sonnet-20240229-v1:0"), ... }); +``` + +## Dynamic Model Selection + +Pass the model name from the frontend via `body`, then select the provider on the backend: + +```tsx +// Frontend +const runtime = useChatRuntime({ + transport: new AssistantChatTransport({ + api: "/api/chat", + body: { model: "gpt-4o-mini" }, + }), +}); +``` + +```ts +// Backend +const { messages, model } = await req.json(); + +const provider = model.startsWith("claude") + ? anthropic(model) + : openai(model); + +const result = streamText({ + model: provider, + messages: await convertToModelMessages(messages), +}); +``` + +## With Cloud Persistence + +Pass a `cloud` instance to `useChatRuntime` to enable thread persistence and history. Use `ThreadList` to display saved threads. + +```tsx +import { AssistantCloud, AssistantRuntimeProvider } from "@assistant-ui/react"; +import { Thread } from "@/components/assistant-ui/thread"; +import { ThreadList } from "@/components/assistant-ui/thread-list"; +import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/react-ai-sdk"; + +const cloud = new AssistantCloud({ + baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL, + authToken: async () => getAuthToken(), +}); + +function ChatPage() { + const runtime = useChatRuntime({ + transport: new AssistantChatTransport({ api: "/api/chat" }), + cloud, + }); + + return ( + + + + + ); +} +``` + +See the [cloud reference](./cloud.md) for authentication and configuration details. + +## Troubleshooting + +**"Module not found: @ai-sdk/react"** +```bash +npm install @ai-sdk/react +``` + +**"useChat is not a function"** +Mixing v5 and v6. Remove old imports: +```bash +npm uninstall ai/react # if present +npm install @ai-sdk/react@latest ai@latest +``` + +**Streaming stops mid-response** +Check `stopWhen` when using tools - use `stepCountIs(n)` to allow multi-step. + +**Tool results not showing** +Ensure you return from tool.execute(), not just mutate state. + +## Known Pitfalls + +- `convertToModelMessages` is async in AI SDK v6: always `await` it. +- Use `toUIMessageStreamResponse()` for route responses, NOT `toDataStreamResponse()`. +- In v6 tool definitions, use `inputSchema`, NOT `parameters`. +- `stopWhen: stepCountIs(n)` replaces `maxSteps: n` for multi-step tool loops. +- If you replace the transport, use `AssistantChatTransport` unless you intentionally want to disable assistant-tool/system forwarding. diff --git a/.agents/skills/setup/references/custom-backend.md b/.agents/skills/setup/references/custom-backend.md new file mode 100644 index 0000000..73f96a8 --- /dev/null +++ b/.agents/skills/setup/references/custom-backend.md @@ -0,0 +1,365 @@ +# Custom Backend Integration + +Connect assistant-ui to any backend using useLocalRuntime or useExternalStoreRuntime. + +## useLocalRuntime + +For backends that return streaming responses. Emit `ChatModelRunResult` chunks (append-only `content` parts). + +### Basic Setup + +Plain-text streaming only. For AI SDK Data Stream responses, use `toUIMessageStreamResponse()` + `useChatRuntime` or decode with `DataStreamDecoder`. + +```tsx +import { useLocalRuntime, AssistantRuntimeProvider } from "@assistant-ui/react"; +import { Thread } from "@/components/assistant-ui/thread"; + +function Chat() { + const runtime = useLocalRuntime({ + model: { + async *run({ messages, abortSignal }) { + const response = await fetch("/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ messages }), + signal: abortSignal, + }); + + const reader = response.body?.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (reader) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const parts = buffer.split("\n"); + buffer = parts.pop() ?? ""; + + for (const textChunk of parts.filter(Boolean)) { + yield { content: [{ type: "text", text: textChunk }] }; + } + } + + if (buffer) { + yield { content: [{ type: "text", text: buffer }] }; + } + }, + }, + }); + + return ( + + + + ); +} +``` + +### With SSE Parsing + +Simple SSE `data:` lines only (not AI SDK Data Stream prefixes like `0:`/`b:`/`c:`). + +```tsx +const runtime = useLocalRuntime({ + model: { + async *run({ messages, abortSignal }) { + const response = await fetch("/api/chat", { + method: "POST", + body: JSON.stringify({ messages }), + signal: abortSignal, + }); + + const reader = response.body?.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (reader) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + if (line === "data: [DONE]") return; + + const data = JSON.parse(line.slice(6)); + yield { content: [{ type: "text", text: data.content }] }; + } + } + }, + }, +}); +``` + +### With Tools + +```tsx +const runtime = useLocalRuntime({ + model: { + async *run({ messages, abortSignal }) { + const response = await fetch("/api/chat", { + method: "POST", + body: JSON.stringify({ messages }), + signal: abortSignal, + }); + + const toolCalls = new Map< + string, + { toolCallId: string; toolName: string; args: unknown; argsText: string } + >(); + + for await (const event of parseResponse(response)) { + if (event.type === "text") { + yield { content: [{ type: "text", text: event.content }] }; + } + + if (event.type === "tool_use") { + const toolCall = { + toolCallId: event.id, + toolName: event.name, + args: event.input ?? {}, + argsText: JSON.stringify(event.input ?? {}), + }; + toolCalls.set(event.id, toolCall); + yield { content: [{ type: "tool-call", ...toolCall }] }; + } + + if (event.type === "tool_result") { + const toolCall = toolCalls.get(event.tool_use_id); + yield { + content: [ + { + type: "tool-call", + toolCallId: event.tool_use_id, + toolName: toolCall?.toolName ?? "tool", + args: toolCall?.args ?? {}, + argsText: toolCall?.argsText ?? "{}", + result: event.content, + }, + ], + }; + } + } + }, + }, +}); +``` + +## useExternalStoreRuntime + +For apps with existing state management (Redux, Zustand, etc.). + +### Basic Setup + +```tsx +import { useExternalStoreRuntime, AssistantRuntimeProvider } from "@assistant-ui/react"; +import { Thread } from "@/components/assistant-ui/thread"; + +function Chat() { + const [messages, setMessages] = useState([]); + const [isRunning, setIsRunning] = useState(false); + + const runtime = useExternalStoreRuntime({ + messages, + isRunning, + onNew: async (message) => { + const userMessage: ThreadMessage = { + id: crypto.randomUUID(), + role: "user", + content: message.content, + createdAt: new Date(), + }; + setMessages((prev) => [...prev, userMessage]); + setIsRunning(true); + + const response = await myAPI.chat([...messages, userMessage]); + + const assistantMessage: ThreadMessage = { + id: crypto.randomUUID(), + role: "assistant", + content: [{ type: "text", text: response.text }], + status: "complete", + createdAt: new Date(), + }; + setMessages((prev) => [...prev, assistantMessage]); + setIsRunning(false); + }, + }); + + return ( + + + + ); +} +``` + +### With Redux + +```tsx +import { useSelector, useDispatch } from "react-redux"; + +function Chat() { + const dispatch = useDispatch(); + const messages = useSelector(selectMessages); + const isRunning = useSelector(selectIsRunning); + + const runtime = useExternalStoreRuntime({ + messages, + isRunning, + onNew: async (message) => { + dispatch(addMessage({ role: "user", content: message.content })); + dispatch(setRunning(true)); + + const response = await chatAPI(messages); + + dispatch(addMessage({ role: "assistant", content: response })); + dispatch(setRunning(false)); + }, + onReload: async (parentId) => { + dispatch(reloadFrom(parentId)); + }, + }); + + return ( + + + + ); +} +``` + +### With Zustand + +```tsx +import { create } from "zustand"; + +const useChatStore = create((set) => ({ + messages: [], + isRunning: false, + addMessage: (msg) => set((state) => ({ messages: [...state.messages, msg] })), + setRunning: (running) => set({ isRunning: running }), +})); + +function Chat() { + const { messages, isRunning, addMessage, setRunning } = useChatStore(); + + const runtime = useExternalStoreRuntime({ + messages, + isRunning, + onNew: async (message) => { + addMessage({ + id: crypto.randomUUID(), + role: "user", + content: message.content, + createdAt: new Date(), + }); + setRunning(true); + + const response = await myAPI.chat(messages); + + addMessage({ + id: crypto.randomUUID(), + role: "assistant", + content: [{ type: "text", text: response }], + status: "complete", + createdAt: new Date(), + }); + setRunning(false); + }, + }); + + return ( + + + + ); +} +``` + +### Custom Message Format + +```tsx +interface MyMessage { + uuid: string; + sender: "human" | "ai"; + text: string; + timestamp: number; +} + +const runtime = useExternalStoreRuntime({ + messages: myMessages, + isRunning, + convertMessage: (msg): ThreadMessage => ({ + id: msg.uuid, + role: msg.sender === "human" ? "user" : "assistant", + content: [{ type: "text", text: msg.text }], + status: "complete", + createdAt: new Date(msg.timestamp), + }), + onNew: async (appendMessage) => { + const text = appendMessage.content + .filter((p): p is { type: "text"; text: string } => p.type === "text") + .map((p) => p.text) + .join(""); + + const myMessage: MyMessage = { + uuid: crypto.randomUUID(), + sender: "human", + text, + timestamp: Date.now(), + }; + + addMyMessage(myMessage); + }, +}); +``` + +## Streaming Updates with External Store + +```tsx +const runtime = useExternalStoreRuntime({ + messages, + isRunning, + onNew: async (message) => { + addUserMessage(message); + setIsRunning(true); + + const assistantId = crypto.randomUUID(); + addMessage({ + id: assistantId, + role: "assistant", + content: [{ type: "text", text: "" }], + status: "running", + createdAt: new Date(), + }); + + const response = await fetch("/api/chat", { + method: "POST", + body: JSON.stringify({ messages }), + }); + + const reader = response.body?.getReader(); + let fullText = ""; + + while (reader) { + const { done, value } = await reader.read(); + if (done) break; + + fullText += new TextDecoder().decode(value); + + updateMessage(assistantId, { + content: [{ type: "text", text: fullText }], + }); + } + + updateMessage(assistantId, { status: "complete" }); + setIsRunning(false); + }, +}); +``` diff --git a/.agents/skills/setup/references/langgraph.md b/.agents/skills/setup/references/langgraph.md new file mode 100644 index 0000000..6f8d70a --- /dev/null +++ b/.agents/skills/setup/references/langgraph.md @@ -0,0 +1,211 @@ +# LangGraph Setup + +Integration with LangGraph Python agents. + +## Installation + +```bash +npm install @assistant-ui/react @assistant-ui/react-langgraph +``` + +## Basic Setup + +```tsx +import { AssistantRuntimeProvider } from "@assistant-ui/react"; +import { Thread } from "@/components/assistant-ui/thread"; +import { useLangGraphRuntime } from "@assistant-ui/react-langgraph"; + +function Chat() { + const runtime = useLangGraphRuntime({ + threadId: "my-thread-id", + stream: async function* (messages, config) { + const response = await fetch("/api/langgraph", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ messages, config }), + }); + + const reader = response.body?.getReader(); + const decoder = new TextDecoder(); + + while (reader) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value); + // Parse LangGraph events and yield + for (const event of parseLangGraphEvents(chunk)) { + yield event; + } + } + }, + }); + + return ( + + + + ); +} +``` + +## With LangGraph Client SDK + +```tsx +import { Client } from "@langchain/langgraph-sdk"; +import { useLangGraphRuntime } from "@assistant-ui/react-langgraph"; + +const client = new Client({ + apiUrl: process.env.NEXT_PUBLIC_LANGGRAPH_API_URL || "http://localhost:8123", +}); + +function Chat() { + const [threadId, setThreadId] = useState(null); + + const runtime = useLangGraphRuntime({ + threadId, + stream: async function* (messages, config) { + // Create thread if needed + let currentThreadId = threadId; + if (!currentThreadId) { + const thread = await client.threads.create(); + currentThreadId = thread.thread_id; + setThreadId(currentThreadId); + } + + // Stream from LangGraph + const stream = client.runs.stream( + currentThreadId, + "my-assistant", // Assistant name in LangGraph + { + input: { messages }, + config, + } + ); + + for await (const event of stream) { + yield event; + } + }, + }); + + return ( + + + + ); +} +``` + +## useLangGraphRuntime Options + +```tsx +const runtime = useLangGraphRuntime({ + // Thread identifier + threadId: string | undefined, + + // Streaming function (required) + stream: async function* ( + messages: ThreadMessage[], + config: LangGraphConfig + ): AsyncGenerator, + + // Message conversion (optional) + convertMessage?: (message: ThreadMessage) => LangGraphMessage, + + // Adapters (optional) + adapters?: { + attachments?: AttachmentAdapter, + feedback?: FeedbackAdapter, + }, +}); +``` + +## LangGraph Event Types + +The stream callback should yield append-only content updates (same shape as `ChatModelRunResult` content parts). Common cases: + +- Text: `{ content: [{ type: "text", text: "partial text" }] }` +- Tool call start/result (single part): `{ content: [{ type: "tool-call", toolCallId, toolName, args, argsText, result? }] }` + +## With Tool UI + +```tsx +import { makeAssistantToolUI } from "@assistant-ui/react"; + +// LangGraph tools can have custom UI +const SearchToolUI = makeAssistantToolUI({ + toolName: "tavily_search", + render: ({ args, result, status }) => { + if (status === "running") { + return
Searching for: {args.query}...
; + } + return ( +
+ {result?.results?.map((r: any) => ( + {r.title} + ))} +
+ ); + }, +}); +``` + +## Python Backend Example + +```python +# langgraph_server.py +from langgraph.graph import StateGraph, MessagesState +from langchain_openai import ChatOpenAI + +model = ChatOpenAI(model="gpt-4o") + +def chat_node(state: MessagesState): + response = model.invoke(state["messages"]) + return {"messages": [response]} + +graph = StateGraph(MessagesState) +graph.add_node("chat", chat_node) +graph.set_entry_point("chat") +graph.set_finish_point("chat") + +app = graph.compile() + +# Run with: langgraph serve +``` + +## Thread Persistence + +LangGraph handles thread persistence server-side. The `threadId` you pass to the runtime maps to LangGraph's thread management. + +```tsx +// Thread list with LangGraph +function ThreadSelector() { + const [threads, setThreads] = useState([]); + + useEffect(() => { + client.threads.list().then(setThreads); + }, []); + + return ( + + ); +} +``` + +## Troubleshooting + +**"Stream not yielding events"** +Ensure your stream function yields events in the correct format. Debug by logging events before yielding. + +**"Thread not persisting"** +LangGraph persistence is server-side. Check that your LangGraph server is configured with a checkpointer. + +**"Tool calls not rendering"** +Tool names must match between LangGraph and `makeAssistantToolUI`. diff --git a/.agents/skills/setup/references/styling.md b/.agents/skills/setup/references/styling.md new file mode 100644 index 0000000..e574e4d --- /dev/null +++ b/.agents/skills/setup/references/styling.md @@ -0,0 +1,91 @@ +# Styling and Customization (shadcn Pattern) + +assistant-ui UI components added by `init`/`create`/`add` are local source files. Style and customize them the same way you customize shadcn components: edit local TSX and theme tokens directly. + +## Where to Customize + +- `components/assistant-ui/*`: assistant-ui registry components (thread, tool-fallback, markdown-text, etc.) +- `components/ui/*`: shadcn base primitives (button, tooltip, dialog, sidebar, ...) +- `app/globals.css`: theme tokens (`:root`, `.dark`) and global overrides +- `lib/utils.ts`: `cn()` class merging helper used across components + +## Theme Tokens (Tailwind v4 + shadcn style) + +Components use shadcn theme tokens defined in `app/globals.css` and mapped to Tailwind v4 `@theme` variables. These help maintain a consistent style system. + +```css +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.21 0.006 285.885); + --radius: 0.625rem; + /* ...other theme tokens */ +} + +.dark { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --primary: oklch(0.9 0.001 286.0); + /* ...other theme tokens */ +} +``` + +## Dark Mode + +Dark mode is class-based: toggling the `dark` class on `` switches shadcn theme tokens to dark mode values. + +```tsx +// app/layout.tsx +import { ThemeProvider } from "next-themes"; + + + {children} + +``` + +## Common Layout Patterns + +```tsx +// Full-height thread +
+ +
+ +// Floating modal chat + + +// Constrained-width centered thread +
+ +
+``` + +## Component-Level Customization with `cn()` + +Use `cn()` when customizing local component styles (including child elements like a `Button` inside `Thread`) so you can layer conditional or external classes without breaking defaults. Keep edits in registry components (`components/assistant-ui/`) and rely on shadcn primitives for consistent composition. + +`cn()` keeps base styles, then resolves conflicts so later classes win. + +```tsx + + + + {/* Thread list */} + + + {/* Archived */} + {archivedThreads.length > 0 && ( +
+

Archived

+ {archivedThreads.map((threadId) => ( + + ))} +
+ )} + + ); +} + +function ThreadItem({ + id, + isActive = false, + archived = false, +}: { + id: string; + isActive?: boolean; + archived?: boolean; +}) { + const api = useAui(); + const item = api.threads().item({ id }); + const state = item.getState(); + const [isEditing, setIsEditing] = useState(false); + const [title, setTitle] = useState(state.title || ""); + + const handleRename = async () => { + await item.rename(title); + setIsEditing(false); + }; + + return ( +
item.switchTo()} + > + {isEditing ? ( + setTitle(e.target.value)} + onBlur={handleRename} + onKeyDown={(e) => e.key === "Enter" && handleRename()} + className="flex-1 px-2 py-1 text-sm border rounded" + autoFocus + onClick={(e) => e.stopPropagation()} + /> + ) : ( + <> + + {state.title || "Untitled"} + +
+ + {archived ? ( + + ) : ( + + )} + +
+ + )} +
+ ); +} +``` + +## With Search + +```tsx +function SearchableThreadList() { + const [search, setSearch] = useState(""); + const api = useAui(); + const { threads, mainThreadId } = useAuiState((s) => ({ + threads: s.threads.threadIds, + mainThreadId: s.threads.mainThreadId, + })); + + const filteredThreads = threads.filter((id) => { + if (!search) return true; + const item = api.threads().item({ id }).getState(); + return item.title?.toLowerCase().includes(search.toLowerCase()); + }); + + return ( +
+ {/* Search */} +
+ setSearch(e.target.value)} + placeholder="Search conversations..." + className="w-full px-3 py-2 border rounded-lg" + /> +
+ + {/* New button */} + + + {/* Results */} +
+ {filteredThreads.length === 0 ? ( +

No results

+ ) : ( + filteredThreads.map((id) => ( + + )) + )} +
+
+ ); +} +``` + +## With Drag and Drop + +```tsx +import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd"; + +function DraggableThreadList() { + const api = useAui(); + const { threads } = useAuiState((s) => ({ threads: s.threads.threadIds })); + const [orderedThreads, setOrderedThreads] = useState(threads); + + useEffect(() => { + setOrderedThreads(threads); + }, [threads]); + + const handleDragEnd = (result: any) => { + if (!result.destination) return; + + const items = Array.from(orderedThreads); + const [reordered] = items.splice(result.source.index, 1); + items.splice(result.destination.index, 0, reordered); + + setOrderedThreads(items); + // Optionally persist order to backend + }; + + return ( + + + {(provided) => ( +
+ {orderedThreads.map((id, index) => ( + + {(provided) => ( +
+ +
+ )} +
+ ))} + {provided.placeholder} +
+ )} +
+
+ ); +} +``` + +## Modal/Dropdown Style + +```tsx +function ThreadDropdown() { + const [open, setOpen] = useState(false); + const api = useAui(); + const { threads, mainThreadId } = useAuiState((s) => ({ + threads: s.threads.threadIds, + mainThreadId: s.threads.mainThreadId, + })); + const currentItem = api.threads().item("main").getState(); + + return ( +
+ + + {open && ( +
+ +
+ {threads.map((id) => { + const item = api.threads().item({ id }).getState(); + return ( + + ); + })} +
+
+ )} +
+ ); +} +``` + +## With Categories/Folders + +```tsx +function CategorizedThreadList() { + const api = useAui(); + const { threads } = useAuiState((s) => ({ threads: s.threads.threadIds })); + + // Group by title first letter + const grouped = threads.reduce((acc, id) => { + const item = api.threads().item({ id }).getState(); + const category = (item.title || "Untitled").charAt(0).toUpperCase(); + if (!acc[category]) acc[category] = []; + acc[category].push(id); + return acc; + }, {} as Record); + + return ( +
+ {Object.entries(grouped).map(([category, ids]) => ( +
+

+ {category} +

+ {ids.map((id) => ( + + ))} +
+ ))} +
+ ); +} +``` diff --git a/.agents/skills/thread-list/references/management.md b/.agents/skills/thread-list/references/management.md new file mode 100644 index 0000000..fec79a6 --- /dev/null +++ b/.agents/skills/thread-list/references/management.md @@ -0,0 +1,285 @@ +# Thread List Management + +CRUD operations for managing multiple chat threads. + +## Overview + +Thread list management allows users to: +- Create new conversations +- Switch between threads +- Rename, archive, and delete threads +- View thread history + +## Accessing Thread List API + +```tsx +import { useAui, useAuiState } from "@assistant-ui/react"; + +function ThreadManager() { + const api = useAui(); + + // Get thread list API + const threads = api.threads(); + + // Get current state + const { threadIds, mainThreadId } = useAuiState( + (s) => ({ + threadIds: s.threads.threadIds, + mainThreadId: s.threads.mainThreadId, + }) + ); +} +``` + +## Thread Operations + +### Create New Thread + +```tsx +const api = useAui(); + +// Switch to a new empty thread +await api.threads().switchToNewThread(); + +// Thread is created when first message is sent +``` + +### Switch Thread + +```tsx +// By thread ID +await api.threads().switchToThread(threadId); + +// Using item +const item = api.threads().item({ id: threadId }); +await item.switchTo(); +``` + +### Rename Thread + +```tsx +const item = api.threads().item({ id: threadId }); +await item.rename("New Chat Title"); +``` + +### Archive Thread + +```tsx +const item = api.threads().item({ id: threadId }); +await item.archive(); + +// Archived threads move to archivedThreads list +``` + +### Unarchive Thread + +```tsx +const item = api.threads().item({ id: threadId }); +await item.unarchive(); + +// Moves back to regular threads list +``` + +### Delete Thread + +```tsx +const item = api.threads().item({ id: threadId }); +await item.delete(); + +// Permanently removes thread +// If deleting current thread, switches to another +``` + +### Generate Title + +```tsx +const item = api.threads().item({ id: threadId }); +await item.generateTitle(); + +// Uses AI to generate title from conversation +``` + +## Thread List State + +```typescript +interface ThreadListState { + mainThreadId: string; // Current thread + newThreadId: string | null; // Pending new thread + threadIds: readonly string[]; // Regular thread IDs + archivedThreadIds: readonly string[]; + isLoading: boolean; + threadItems: readonly ThreadListItemState[]; +} + +interface ThreadListItemState { + id: string; + title?: string; + remoteId?: string; + externalId?: string; + status: "archived" | "regular" | "new" | "deleted"; +} +``` + +## Subscribing to Changes + +```tsx +import { useAuiState, useAuiEvent } from "@assistant-ui/react"; + +function ThreadWatcher() { + // Reactive state + const threads = useAuiState((s) => s.threads.threadIds); + + // Events + useAuiEvent("thread.initialize", () => { + console.log("New thread created"); + }); + + return
{threads.length} threads
; +} +``` + +## Item Access Patterns + +```tsx +const api = useAui(); +const threads = api.threads(); + +// By ID +const item1 = threads.item({ id: "thread-123" }); + +// By index (regular threads) +const item2 = threads.item({ index: 0 }); + +// By index (archived) +const item3 = threads.item({ index: 0, archived: true }); + +// Current thread +const mainItem = threads.item("main"); +``` + +## Batch Operations + +```tsx +async function archiveThreadsByTitlePrefix(prefix: string) { + const api = useAui(); + const { threadIds } = api.threads().getState(); + + for (const threadId of threadIds) { + const item = api.threads().item({ id: threadId }); + const state = item.getState(); + const title = (state.title || "").toLowerCase(); + + if (title.startsWith(prefix.toLowerCase())) { + await item.archive(); + } + } +} +``` + +## Thread Data + +Access thread metadata: + +```tsx +const item = api.threads().item({ id: threadId }); +const state = item.getState(); + +// { +// id: "thread-123", +// remoteId: "remote-123", +// externalId: "ext-123", +// title: "Chat about React", +// status: "regular", +// } +``` + +## Thread Initialization + +When using cloud persistence, threads are lazily initialized: + +```tsx +const item = api.threads().item({ id: localThreadId }); + +// Initialize creates remote mapping +const { remoteId, externalId } = await item.initialize(); + +// Now thread is persisted to cloud +``` + +## Error Handling + +```tsx +async function safeDelete(threadId: string) { + const api = useAui(); + const item = api.threads().item({ id: threadId }); + + try { + await item.delete(); + } catch (error) { + if (error.message.includes("not found")) { + // Thread already deleted + return; + } + throw error; + } +} +``` + +## Sorting and Filtering + +Thread list can be sorted by title or by ID for custom ordering: + +```tsx +function SortedThreadList({ sortBy }: { sortBy: "title" | "id" }) { + const { threads } = useAuiState((s) => ({ threads: s.threads.threadIds })); + const api = useAui(); + + const sorted = [...threads].sort((a, b) => { + const itemA = api.threads().item({ id: a }).getState(); + const itemB = api.threads().item({ id: b }).getState(); + + if (sortBy === "title") { + return (itemA.title || "").localeCompare(itemB.title || ""); + } + return b.localeCompare(a); + }); + + return ( +
+ {sorted.map((id) => ( + + ))} +
+ ); +} +``` + +## Keyboard Navigation + +```tsx +function KeyboardNav() { + const { threads, mainThreadId } = useAuiState((s) => ({ + threads: s.threads.threadIds, + mainThreadId: s.threads.mainThreadId, + })); + const api = useAui(); + + const currentIndex = threads.indexOf(mainThreadId); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "ArrowUp" && currentIndex > 0) { + api.threads().switchToThread(threads[currentIndex - 1]); + } + if (e.key === "ArrowDown" && currentIndex < threads.length - 1) { + api.threads().switchToThread(threads[currentIndex + 1]); + } + }; + + useEffect(() => { + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [currentIndex, threads]); + + return null; +} +``` diff --git a/.agents/skills/tools/SKILL.md b/.agents/skills/tools/SKILL.md new file mode 100644 index 0000000..92db90c --- /dev/null +++ b/.agents/skills/tools/SKILL.md @@ -0,0 +1,140 @@ +--- +name: tools +description: Guide for tool registration and tool UI in assistant-ui. Use when implementing LLM tools, tool call rendering, or human-in-the-loop patterns. +version: 0.0.1 +license: MIT +--- + +# assistant-ui Tools + +**Always consult [assistant-ui.com/llms.txt](https://assistant-ui.com/llms.txt) for latest API.** + +Tools let LLMs trigger actions with custom UI rendering. + +## References + +- [./references/make-tool.md](./references/make-tool.md) -- makeAssistantTool/useAssistantTool +- [./references/tool-ui.md](./references/tool-ui.md) -- makeAssistantToolUI rendering +- [./references/human-in-loop.md](./references/human-in-loop.md) -- Confirmation patterns + +## Tool Types + +``` +Where does the tool execute? +├─ Backend (LLM calls API) → AI SDK tool() +│ └─ Want custom UI? → makeAssistantToolUI +└─ Frontend (browser-only) → makeAssistantTool + └─ Want custom UI? → makeAssistantToolUI +``` + +## Backend Tool with UI + +```ts +// Backend (app/api/chat/route.ts) +import { tool, stepCountIs } from "ai"; +import { z } from "zod"; + +const tools = { + get_weather: tool({ + description: "Get weather for a city", + inputSchema: z.object({ city: z.string() }), + execute: async ({ city }) => ({ temp: 22, city }), + }), +}; + +const result = streamText({ + model: openai("gpt-4o"), + messages, + tools, + stopWhen: stepCountIs(5), +}); +``` + +```tsx +// Frontend +import { makeAssistantToolUI } from "@assistant-ui/react"; + +const WeatherToolUI = makeAssistantToolUI({ + toolName: "get_weather", + render: ({ args, result, status }) => { + if (status === "running") return
Loading weather...
; + return
{result?.city}: {result?.temp}°C
; + }, +}); + +// Register in app + + + + +``` + +## Frontend-Only Tool + +```tsx +import { makeAssistantTool } from "@assistant-ui/react"; +import { z } from "zod"; + +const CopyTool = makeAssistantTool({ + toolName: "copy_to_clipboard", + parameters: z.object({ text: z.string() }), + execute: async ({ text }) => { + await navigator.clipboard.writeText(text); + return { success: true }; + }, +}); + + + + + +``` + +## API Reference + +```tsx +// makeAssistantToolUI props +interface ToolUIProps { + toolCallId: string; + toolName: string; + args: Record; + argsText: string; + result?: unknown; + status: "running" | "complete" | "incomplete" | "requires-action"; + submitResult: (result: unknown) => void; // For interactive tools +} +``` + +## Human-in-the-Loop + +```tsx +const DeleteToolUI = makeAssistantToolUI({ + toolName: "delete_file", + render: ({ args, status, submitResult }) => { + if (status === "requires-action") { + return ( +
+

Delete {args.path}?

+ + +
+ ); + } + return
File deleted
; + }, +}); +``` + +## Common Gotchas + +**Tool UI not rendering** +- `toolName` must match exactly (case-sensitive) +- Register UI inside `AssistantRuntimeProvider` + +**Tool not being called** +- Check tool description is clear +- Use `stopWhen: stepCountIs(n)` to allow multi-step + +**Result not showing** +- Tool must return a value +- Check `status === "complete"` before accessing result diff --git a/.agents/skills/tools/references/human-in-loop.md b/.agents/skills/tools/references/human-in-loop.md new file mode 100644 index 0000000..e2dd342 --- /dev/null +++ b/.agents/skills/tools/references/human-in-loop.md @@ -0,0 +1,323 @@ +# Human-in-the-Loop Tools + +Tools that require user confirmation or input. + +## Overview + +Human-in-the-loop tools pause execution waiting for user input. Use `status === "requires-action"` to detect this state and `submitResult` to provide the user's response. + +## Confirmation Pattern + +Ask user to confirm before executing: + +```tsx +// Backend tool returns requires-action status +const deleteTool = tool({ + description: "Delete a file (requires user confirmation)", + inputSchema: z.object({ path: z.string() }), + execute: async ({ path }) => { + // Return requires-action to wait for confirmation + return { action: "confirm", path }; + }, +}); + +// Frontend shows confirmation UI +const DeleteToolUI = makeAssistantToolUI({ + toolName: "delete_file", + render: ({ args, result, status, submitResult }) => { + // Initial state - show confirmation + if (status === "requires-action" || !result?.confirmed) { + return ( +
+
+ + Confirm deletion +
+

Are you sure you want to delete {args.path}?

+
+ + +
+
+ ); + } + + // After user responds + if (result?.confirmed) { + return ( +
+ + File deleted: {args.path} +
+ ); + } + + return ( +
+ Deletion cancelled +
+ ); + }, +}); +``` + +## Selection Pattern + +Let user choose from options: + +```tsx +const SelectToolUI = makeAssistantToolUI({ + toolName: "select_option", + render: ({ args, result, status, submitResult }) => { + if (status !== "complete") { + return ( +
+

{args.prompt}

+
+ {args.options.map((option: any) => ( + + ))} +
+
+ ); + } + + return ( +
+ Selected: {args.options.find((o: any) => o.id === result?.selected)?.label} +
+ ); + }, +}); +``` + +## Form Input Pattern + +Collect structured data from user: + +```tsx +const FormToolUI = makeAssistantToolUI({ + toolName: "collect_info", + render: ({ args, status, submitResult }) => { + const [formData, setFormData] = useState({}); + + if (status !== "complete") { + return ( +
{ + e.preventDefault(); + submitResult(formData); + }} + className="p-4 bg-gray-50 rounded-lg space-y-4" + > +

{args.title}

+ + {args.fields.map((field: any) => ( +
+ + + setFormData((d) => ({ ...d, [field.name]: e.target.value })) + } + className="w-full border rounded px-3 py-2" + /> +
+ ))} + + +
+ ); + } + + return
Information collected
; + }, +}); +``` + +## Multi-Step Workflow + +Chain multiple interactions: + +```tsx +const WizardToolUI = makeAssistantToolUI({ + toolName: "setup_wizard", + render: ({ args, result, status, submitResult }) => { + const [step, setStep] = useState(0); + const [data, setData] = useState({}); + + const steps = args.steps || []; + const currentStep = steps[step]; + + if (status === "complete") { + return
Setup complete!
; + } + + return ( +
+
+
+ Step {step + 1} of {steps.length} +
+

{currentStep.title}

+
+ +
+ {currentStep.type === "select" && ( +
+ {currentStep.options.map((opt: any) => ( + + ))} +
+ )} +
+ + {step > 0 && ( + + )} +
+ ); + }, +}); +``` + +## Rating/Feedback Pattern + +```tsx +const RatingToolUI = makeAssistantToolUI({ + toolName: "request_rating", + render: ({ args, status, submitResult }) => { + const [rating, setRating] = useState(0); + const [comment, setComment] = useState(""); + + if (status === "complete") { + return
Thank you for your feedback!
; + } + + return ( +
+

{args.prompt}

+ +
+ {[1, 2, 3, 4, 5].map((star) => ( + + ))} +
+ +