diff --git a/src/components/landing/AiLanding.tsx b/src/components/landing/AiLanding.tsx index ea08d0d5b..daf8bcac4 100644 --- a/src/components/landing/AiLanding.tsx +++ b/src/components/landing/AiLanding.tsx @@ -11,6 +11,7 @@ import { CubeIcon, DatabaseIcon, HardDrivesIcon, + LayoutIcon, MicrophoneIcon, PlugIcon, RadioIcon, @@ -21,6 +22,9 @@ import { } from '@phosphor-icons/react' import { getLibrary } from '~/libraries' +import { CodeBlock } from '~/components/markdown/CodeBlock' +import { usePrefersReducedMotion } from '~/utils/usePrefersReducedMotion' +import { LandingPromptBox } from './LandingPromptBox' import { LandingSection, LandingSectionIntro, @@ -28,247 +32,122 @@ import { LibraryLandingShell, } from './LibraryLanding' -const aiPrompt = [ - 'Build me a TanStack Start app using TanStack AI as its driver to showcase AI features, if run inside of an existing app then add a single new page and endpoint to showcase the power of TanStack AI.', - 'Drive the agent loop with chat(): isomorphic tools via toolDefinition().server() / .client(), add at least 1 tool on the server and on the client, use the headless UI features to build the UI, add a tool that needs approval.', - 'Reach for the rest of the stack only when the task needs it: Code Mode in an isolate for multi-tool orchestration, a sandboxed coding-agent harness, @tanstack/ai-mcp for MCP servers, memoryMiddleware for cross-session recall, @tanstack/ai-persistence for durable threads and resumable streams.', - 'Never introduce a hosted gateway, a prescribed UI kit, or a provider-specific wire format. Keep provider capabilities honest: model options, tool support, and modality-specific results stay typed at the adapter boundary, and media or realtime primitives appear only where the selected model supports them.', -].join(' ') - -const providers = [ - { - name: 'OpenRouter', - model: 'any of 300+ models', - capabilities: ['text', 'reasoning', 'tools', 'image'], - }, - { - name: 'OpenAI', - model: 'gpt-5', - capabilities: ['text', 'reasoning', 'tools', 'image'], - }, - { - name: 'Anthropic', - model: 'claude-sonnet-4', - capabilities: ['text', 'reasoning', 'tools'], - }, - { - name: 'Gemini', - model: 'gemini-2.5-pro', - capabilities: ['text', 'reasoning', 'tools', 'media'], - }, - { - name: 'Ollama', - model: 'local model', - capabilities: ['text', 'tools'], - }, -] - -type AiHeroServer = { - detail?: string - dotted?: boolean - kind?: 'tanstack' - label: string -} - -type GraphNodePosition = { - height: number - label: string - width: number - x: number - y: number -} - -type GraphPoint = { - x: number - y: number -} - -const aiHeroClients = [ - 'Vanilla', - 'React', - 'Vue', - 'Solid', - 'Svelte', - 'Preact', - 'Angular', - 'Octane', -] -const aiHeroServers: Array = [ - { label: 'TanStack AI', detail: 'Server', kind: 'tanstack' }, - { label: 'Python', dotted: true }, - { label: 'Go', dotted: true }, - { label: 'PHP', dotted: true }, -] -const aiHeroProviders = ['OpenRouter', 'OpenAI', 'Anthropic', 'Gemini'] -// ponytail: 8 clients on a fixed 4x2 grid; recompute the columns if the list changes length -const graphClientNodes = aiHeroClients.map((label, index) => ({ - label, - x: [10, 112, 214, 316][index % 4] ?? 112, - y: index < 4 ? 36 : 84, - width: 94, - height: 36, -})) -const graphAgUiNode: GraphNodePosition & { - kind: 'tanstack' -} = { - label: 'TanStack AI Client', - kind: 'tanstack', - x: 142, - y: 138, - width: 136, - height: 58, -} -const graphServerNodes = aiHeroServers.map((server, index) => ({ - ...server, - x: [38, 178, 254, 326][index] ?? 178, - y: index === 0 ? 254 : 260, - width: index === 0 ? 124 : 56, - height: index === 0 ? 54 : 42, -})) -const graphProviderNodes = aiHeroProviders.map((label, index) => ({ - label, - x: 18 + index * 98, - y: 352, - width: 78, - height: 34, -})) -const aiHeroMessages = [ - { - user: 'Build the invoice agent on our stack, not yours.', - assistant: - 'Done. Headless client in your app, the agent loop on your server, AG-UI between them. No gateway, no hosted state.', - }, - { - user: 'It should ask before it charges a card.', - assistant: - 'chargeCard is marked needsApproval, so the run ends as an interrupt. Resolve it and the loop continues from that exact step.', - }, - { - user: 'And if we move off this provider?', - assistant: - 'Swap the adapter. Your tools, events, and UI never learn the difference.', - }, -] +const aiPrompt = + 'Install the agent skills from the skills folder of https://github.com/TanStack/ai for my user, read them, then ask me what AI features I want to build, or suggest some.' // ponytail: the shared --landing-accent-ink is pure black, which reads badly on the // orange accent fill. Darken the fill instead and use white text on it. const accentFillClass = 'bg-[linear-gradient(135deg,color-mix(in_srgb,var(--landing-accent)_84%,black),color-mix(in_srgb,var(--landing-accent)_52%,black))] text-white' -function AdapterDocsLink() { - const { version } = useParams({ strict: false }) - const library = getLibrary('ai') - - return ( - - See the adapter docs for more. - - ) -} - -type AiHeroChatMessage = { - assistant: string - id: string - isStreaming: boolean - user: string -} - export default function AiLanding() { return ( } - prompt={aiPrompt} - promptLabel="Copy AI prompt" + headline="AI building blocks for TypeScript. We build the hard parts, you keep your stack." + description="TanStack AI is a TypeScript library for building AI features and agents. It ships the agent loop, provider adapters, durability, interrupts, sandboxes, and tools, and plugs into the server, database, and UI you already have." + hero={} + beforeActions={} > - + + + +
+
-
- +
- + + + +
+
@@ -279,8 +158,9 @@ export default function AiLanding() {
@@ -291,657 +171,573 @@ export default function AiLanding() {
+ + +
) } -function CodeLine({ - children, - indent = 0, +const codeWindowClass = + 'm-0 min-w-0 rounded-none border-0 [&>div:first-child]:rounded-none [&_pre]:max-h-104 [&_pre]:overflow-auto [&_pre]:rounded-none [&_pre]:text-[11px] [&_pre]:leading-5 sm:[&_pre]:text-xs' + +function CodeTabs({ + preHeightClass, + label, + samples, }: { - children?: React.ReactNode - indent?: number + label: string + samples: Array<{ code: string; file: string; name: string }> + preHeightClass: string }) { - return

{children || ' '}

-} + const [activeIndex, setActiveIndex] = React.useState(0) + const sample = samples[activeIndex] ?? samples[0] -function Kw({ children }: { children: React.ReactNode }) { - return {children} + return ( + +
+ {samples.map((item, index) => ( + + ))} +
+ + {sample.code} + +
+ ) } -// ponytail: the code surface is always dark, so these use fixed token colors. -// --landing-accent-bright resolves to a dark terracotta in light mode and is -// unreadable here. -function Fn({ children }: { children: React.ReactNode }) { - return {children} -} +// Every server backend fills the same two methods. Only those lines change. +// localStorage and IndexedDB are client adapters with no server package, so +// they swap the whole window for the useChat call. +const persistenceStores: Array< + | { name: string; load: string; save: string } + | { name: string; client: string } +> = [ + { + name: 'Postgres', + load: 'sql`select messages from threads where id = ${threadId}`.then((rows) => rows[0]?.messages ?? [])', + save: 'sql`insert into threads (id, messages) values (${threadId}, ${sql.json(messages)})\n on conflict (id) do update set messages = excluded.messages`', + }, + { + name: 'MySQL', + load: "pool.query('select messages from threads where id = ?', [threadId]).then(([rows]) => rows[0]?.messages ?? [])", + save: "pool.query('replace into threads (id, messages) values (?, ?)', [threadId, JSON.stringify(messages)])", + }, + { + name: 'SQLite', + load: "JSON.parse(db.prepare('select messages from threads where id = ?').get(threadId)?.messages ?? '[]')", + save: "db.prepare('insert or replace into threads values (?, ?)').run(threadId, JSON.stringify(messages))", + }, + { + name: 'MongoDB', + load: 'threads.findOne({ _id: threadId }).then((doc) => doc?.messages ?? [])', + save: 'threads.updateOne({ _id: threadId }, { $set: { messages } }, { upsert: true })', + }, + { + name: 'Cloudflare D1', + load: "env.DB.prepare('select messages from threads where id = ?').bind(threadId).first('messages').then((json) => JSON.parse(json ?? '[]'))", + save: "env.DB.prepare('insert or replace into threads values (?, ?)').bind(threadId, JSON.stringify(messages)).run()", + }, + { + name: 'Redis', + load: "redis.get(`thread:${threadId}`).then((json) => JSON.parse(json ?? '[]'))", + save: 'redis.set(`thread:${threadId}`, JSON.stringify(messages))', + }, + { + name: 'Drizzle', + load: 'db.select().from(threads).where(eq(threads.id, threadId)).then((rows) => rows[0]?.messages ?? [])', + save: 'db.insert(threads).values({ id: threadId, messages }).onConflictDoUpdate({ target: threads.id, set: { messages } })', + }, + { + name: 'Prisma', + load: 'prisma.thread.findUnique({ where: { id: threadId } }).then((row) => row?.messages ?? [])', + save: 'prisma.thread.upsert({ where: { id: threadId }, create: { id: threadId, messages }, update: { messages } })', + }, + { name: 'localStorage', client: 'localStoragePersistence' }, + { name: 'IndexedDB', client: 'indexedDBPersistence' }, +] -function Str({ children }: { children: React.ReactNode }) { - return {children} -} +function persistenceSnippet(store: (typeof persistenceStores)[number]) { + if ('client' in store) { + return { + file: 'chat.tsx', + code: `import { useChat, fetchServerSentEvents, ${store.client} } from '@tanstack/ai-react' + +// No server package. The transcript lives in the browser and survives a reload. +const { messages, sendMessage } = useChat({ + threadId: 'support-chat', + connection: fetchServerSentEvents('/api/chat'), + persistence: ${store.client}(), +})`, + } + } + return { + file: 'persistence.ts', + code: `import { defineAIPersistence, defineMessageStore } from '@tanstack/ai-persistence' + +// The whole contract. Your tables, your columns, your types. +export const persistence = defineAIPersistence({ + stores: { + messages: defineMessageStore({ + loadThread: (threadId) => + ${store.load}, + saveThread: async (threadId, messages) => { + await ${store.save} + }, + }), + }, +}) -function Cmt({ children }: { children: React.ReactNode }) { - return {children} +// chat({ ..., middleware: [withPersistence(persistence)] })`, + } } -function CodeSurface({ children }: { children: React.ReactNode }) { - return ( -
- {children} -
- ) -} +function PersistenceContract() { + const [activeIndex, setActiveIndex] = React.useState(0) + const store = persistenceStores[activeIndex] ?? persistenceStores[0] + const snippet = persistenceSnippet(store) -function QuickStart() { return ( -
- +
- - - import {'{ chat, toServerSentEventsResponse }'}{' '} - from '@tanstack/ai' - - - import {'{ openRouterText }'} from{' '} - '@tanstack/ai-openrouter' - - - import {'{ createFileRoute }'} from{' '} - '@tanstack/react-router' - - - - export const Route = createFileRoute( - '/api/chat')({'{'} - - server: {'{'} - handlers: {'{'} - - POST: async ({'{ request }'}) => {'{'} - - - const {'{ messages }'} = await request. - json() - - - - const stream = chat({'{'} - - - adapter: openRouterText( - 'anthropic/claude-sonnet-4.5'), - - messages, - tools: [lookupInvoice], - {'})'} - - - // your route, your auth, your deploy target - - - return toServerSentEventsResponse(stream) - - {'},'} - {'},'} - {'},'} - {'})'} - - - - - - - import {'{ useChat, fetchServerSentEvents }'} from{' '} - '@tanstack/ai-react' - - - - export function Chat() {'{'} - - - const {'{ messages, sendMessage, interrupts }'} ={' '} - useChat({'{'} - - - connection: fetchServerSentEvents('/api/chat'), - - {'})'} - - - // typed state and events. no components, no styles. - - - return ( - - <> - - {'{'}messages.map((message) => ( - - - <Bubble key={'{'}message.id{'}'} {'{'}...message{'}'}{' '} - /> - - )){'}'} - - - - {'{/* the loop paused. you decide when it continues. */}'} - - - - {'{'}interrupts.map((interrupt) => ( - - - <button key={'{'}interrupt.id{'}'} - - - onClick={'{'}() => interrupt.resolveInterrupt( - true){'}'}> - - - Approve {'{'}interrupt.toolName{'}'} - - - </button> - - )){'}'} - </> - ) - {'}'} - + {persistenceStores.map((item, index) => ( + + ))} +
+ + + {snippet.code} + - -

- Swap React for any other framework and keep your server-side code - identical. -

) } -function AiGraphChatHero() { - const [activeClient, setActiveClient] = React.useState(0) - const [activeServer, setActiveServer] = React.useState(0) - const [activeProvider, setActiveProvider] = React.useState(0) - const [chatMessages, setChatMessages] = React.useState< - Array - >([]) - const [typingUserMessage, setTypingUserMessage] = React.useState('') - const activeServerNode = graphServerNodes[activeServer] ?? graphServerNodes[0] - const chatScrollRef = React.useRef(null) - const chatLockedToBottomRef = React.useRef(true) - - React.useEffect(() => { - if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { - return - } - - const clientIntervalId = window.setInterval(() => { - setActiveClient((current) => (current + 1) % aiHeroClients.length) - }, 2300) - const serverIntervalId = window.setInterval(() => { - setActiveServer((current) => (current + 1) % aiHeroServers.length) - }, 3300) - const providerIntervalId = window.setInterval(() => { - setActiveProvider((current) => (current + 1) % aiHeroProviders.length) - }, 4100) - - return () => { - window.clearInterval(clientIntervalId) - window.clearInterval(serverIntervalId) - window.clearInterval(providerIntervalId) - } - }, []) - - React.useEffect(() => { - if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { - const message = aiHeroMessages[0] - - setChatMessages([ - { - ...message, - id: 'reduced-motion-example', - isStreaming: false, - }, - ]) - return - } - - let cancelled = false - const timeouts: Array = [] - - const addTimeout = (callback: () => void, delay: number) => { - const timeoutId = window.setTimeout(callback, delay) - timeouts.push(timeoutId) - } - - const streamAssistantResponse = ( - id: string, - response: string, - onComplete: () => void, - ) => { - let currentIndex = 0 - - const streamChunk = () => { - if (cancelled) { - return - } - - if (currentIndex < response.length) { - const chunkSize = 2 + Math.floor(Math.random() * 7) - const nextIndex = Math.min(currentIndex + chunkSize, response.length) - const nextText = response.slice(0, nextIndex) - - setChatMessages((currentMessages) => - currentMessages.map((message) => - message.id === id - ? { ...message, assistant: nextText, isStreaming: true } - : message, - ), - ) - - currentIndex = nextIndex - addTimeout(streamChunk, 22 + Math.floor(Math.random() * 58)) - return - } - - setChatMessages((currentMessages) => - currentMessages.map((message) => - message.id === id ? { ...message, isStreaming: false } : message, - ), - ) - addTimeout(onComplete, 1600) - } - - addTimeout(streamChunk, 450) - } - - const typeUserMessage = ( - messageIndex: number, - onComplete: (id: string) => void, - ) => { - const message = aiHeroMessages[messageIndex] - let currentIndex = 0 - - setTypingUserMessage('') +const durabilityTiers = [ + { + name: 'In memory', + file: 'routes/api.chat.ts', + code: `import { chat, memoryStream, toServerSentEventsResponse } from '@tanstack/ai' + +// Development and single-process apps. Zero setup. +export async function POST(request: Request) { + const stream = chat({ /* ... */ }) + + return toServerSentEventsResponse(stream, { + durability: { adapter: memoryStream(request) }, + }) +}`, + }, + { + name: 'Hosted log', + file: 'routes/api.chat.ts', + code: `import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { durableStream } from '@tanstack/ai-durable-stream' + +// Many processes, many regions. The route does not change. +export async function POST(request: Request) { + const stream = chat({ /* ... */ }) + + return toServerSentEventsResponse(stream, { + durability: { + adapter: durableStream(request, { server: process.env.DURABLE_STREAMS_URL }), + }, + }) +}`, + }, + { + name: 'Your store', + file: 'redis-stream.ts', + code: `import type { StreamDurability } from '@tanstack/ai' - const typeChar = () => { - if (cancelled) { - return - } +// Five methods against anything: Redis, Postgres, a queue. +// Offsets are opaque strings. Core never reads your store. +export function redisStream(request: Request): StreamDurability { + const key = runKey(request) - if (currentIndex < message.user.length) { - currentIndex += 1 - setTypingUserMessage(message.user.slice(0, currentIndex)) - addTimeout(typeChar, 30 + Math.floor(Math.random() * 40)) - return - } + return { + resumeFrom: () => resumeOffset(request), + append: (chunks) => appendAll(key, chunks), + read: (offset, signal) => readAfter(key, offset, signal), + snapshot: () => readAll(key), + close: () => markDone(key), + } +}`, + }, +] - addTimeout(() => { - const id = `${messageIndex}-${Date.now()}` - - setTypingUserMessage('') - setChatMessages((currentMessages) => [ - ...currentMessages.slice(-1), - { - assistant: '', - id, - isStreaming: true, - user: message.user, - }, - ]) - onComplete(id) - }, 320) - } +function DurabilityTiers() { + return ( + + ) +} - typeChar() - } +const toolCallStates = [ + 'awaiting-input', + 'input-streaming', + 'input-complete', + 'approval-requested', + 'approval-responded', + 'complete', +] as const + +type ToolCallState = (typeof toolCallStates)[number] + +// Both snippets stay put while the cycle runs; only the highlighted line +// moves. A line tagged with a part lights up when that part is active, and a +// line tagged with a tool state only when the tool call is in that state. +type CodeLine = { + text: string + part?: 'thinking' | 'tool-call' | 'tool-result' | 'text' + toolState?: ToolCallState +} - const playMessage = (messageIndex: number) => { - if (cancelled) { - return - } +const toolBranches: Array = [ + { + text: "if (part.state === 'awaiting-input') return ", + part: 'tool-call', + toolState: 'awaiting-input', + }, + { + text: "if (part.state === 'input-streaming') return ", + part: 'tool-call', + toolState: 'input-streaming', + }, + { + text: "if (part.state === 'input-complete') return

Looking up {part.input?.id}

", + part: 'tool-call', + toolState: 'input-complete', + }, + { + text: "if (interrupt?.status === 'pending') return ", + part: 'tool-call', + toolState: 'approval-requested', + }, + { + text: "if (part.state === 'approval-responded') return

Approved

", + part: 'tool-call', + toolState: 'approval-responded', + }, + { + text: "if (part.state === 'error') return

Lookup failed

", + part: 'tool-call', + }, + { + text: 'return ', + part: 'tool-call', + toolState: 'complete', + }, +] - const nextMessageIndex = messageIndex % aiHeroMessages.length - const message = aiHeroMessages[nextMessageIndex] +const loopCode: Array = [ + { text: 'const { messages } = useChat({' }, + { text: " connection: fetchServerSentEvents('/api/chat')," }, + { text: '})' }, + { text: '' }, + { text: 'return messages.map((message) => (' }, + { text: '
' }, + { text: ' {message.parts.map((part, index) => {' }, + { text: ' switch (part.type) {' }, + { text: " case 'thinking':", part: 'thinking' }, + { + text: ' return
Thinking{part.content}
', + part: 'thinking', + }, + { text: " case 'tool-call':", part: 'tool-call' }, + { + text: ' return ', + part: 'tool-call', + }, + { text: " case 'tool-result':", part: 'tool-result' }, + { + text: ' return
{String(part.content)}
', + part: 'tool-result', + }, + { text: " case 'text':", part: 'text' }, + { + text: ' return {part.content}', + part: 'text', + }, + { text: ' default:' }, + { text: ' return null' }, + { text: ' }' }, + { text: ' })}' }, + { text: '
' }, + { text: '))' }, +] - typeUserMessage(nextMessageIndex, (id) => { - streamAssistantResponse(id, message.assistant, () => { - playMessage(nextMessageIndex + 1) - }) - }) - } +const hookCode: Array = [ + { text: 'const { useAppChat } = createChatHook({' }, + { text: ' options: chatOptions,' }, + { text: ' components: {' }, + { text: ' layout: ({ Messages }) =>
,' }, + { text: ' message: ({ Parts }) =>
,' }, + { text: ' },' }, + { text: ' partsComponents: {' }, + { + text: ' thinking: ({ part }) =>
Thinking{part.content}
,', + part: 'thinking', + }, + { + text: ' toolResult: ({ part }) =>
{String(part.content)}
,', + part: 'tool-result', + }, + { + text: ' text: ({ part }) => {part.content},', + part: 'text', + }, + { text: ' fallback: () => null,' }, + { text: ' },' }, + { text: ' toolsComponents: {' }, + { text: ' lookupInvoice: ({ part, interrupt }) => {' }, + ...toolBranches.map((line) => ({ ...line, text: ` ${line.text}` })), + { text: ' },' }, + { text: ' },' }, + { text: '})' }, + { text: '' }, + { text: 'const chat = useAppChat()' }, + { text: 'return ' }, +] - addTimeout(() => playMessage(0), 700) +// The cycle walks the message: thinking, then every tool-call state, then the +// result and the streamed reply. +const partSteps: Array< + | { part: 'thinking' | 'tool-result' | 'text' } + | { part: 'tool-call'; toolState: ToolCallState } +> = [ + { part: 'thinking' }, + ...toolCallStates.map((toolState) => ({ + part: 'tool-call' as const, + toolState, + })), + { part: 'tool-result' }, + { part: 'text' }, +] - return () => { - cancelled = true - timeouts.forEach((timeoutId) => window.clearTimeout(timeoutId)) - } - }, []) +function MessageParts() { + const [stepIndex, setStepIndex] = React.useState(partSteps.length - 1) + const [pinned, setPinned] = React.useState(false) + const [useHook, setUseHook] = React.useState(true) React.useEffect(() => { - const element = chatScrollRef.current - if (!element) { + if ( + pinned || + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ) { return } - const handleScroll = () => { - const distanceFromBottom = - element.scrollHeight - element.scrollTop - element.clientHeight - - chatLockedToBottomRef.current = distanceFromBottom < 72 - } + const intervalId = window.setInterval(() => { + setStepIndex((current) => (current + 1) % partSteps.length) + }, 2200) - element.addEventListener('scroll', handleScroll, { passive: true }) - return () => element.removeEventListener('scroll', handleScroll) - }, []) + return () => window.clearInterval(intervalId) + }, [pinned]) - React.useEffect(() => { - const frameId = window.requestAnimationFrame(() => { - const element = chatScrollRef.current + const pin = (index: number) => { + setPinned(true) + setStepIndex(index) + } - if (element && chatLockedToBottomRef.current) { - element.scrollTop = element.scrollHeight - } - }) + const step = partSteps[stepIndex] ?? { part: 'text' } + const toolState: ToolCallState | 'pending' = + step.part === 'thinking' + ? 'pending' + : step.part === 'tool-call' + ? step.toolState + : 'complete' + const parts = [ + { + type: 'thinking', + detail: 'Checking the invoice before answering.', + state: 'complete', + }, + // Rows after the active one stay mounted as pending so the list never + // changes height. + { + type: 'tool-call', + detail: 'lookupInvoice({ id: "inv_2231" })', + state: toolState, + }, + { + type: 'tool-result', + detail: '{ total: 1240, status: "paid" }', + state: toolState === 'complete' ? 'complete' : 'pending', + }, + { + type: 'text', + detail: 'Invoice 2231 was paid in full on', + state: + step.part === 'text' + ? 'streaming' + : toolState === 'complete' + ? 'complete' + : 'pending', + }, + ] - return () => window.cancelAnimationFrame(frameId) - }, [chatMessages]) + const code = useHook ? hookCode : loopCode + const isActive = (line: CodeLine) => + line.part === step.part && + (line.toolState === undefined || + (step.part === 'tool-call' && line.toolState === step.toolState)) return ( -
- - A client graph shows eight UI adapters converging on the TanStack AI - Client over AG-UI, then reaching an agent runtime in TypeScript, Python, - Go, or PHP, and interchangeable model providers. - - - + +

+ A message is a list of parts. A thinking part, then a tool call that + moves from awaiting input through approval to complete, then the tool + result and the streamed text reply. Below the list, the component + registered for the active part. +

+
+ {parts.map((part) => ( + + ))} +
+
+

+ tool-call lifecycle +

+
+
+ + +
- -
- ) -} - -function topAnchor(node: GraphNodePosition): GraphPoint { - return { - x: node.x + node.width / 2, - y: node.y, - } -} - -function bottomAnchor(node: GraphNodePosition): GraphPoint { - return { - x: node.x + node.width / 2, - y: node.y + node.height, - } -} - -function curveBetween(start: GraphPoint, end: GraphPoint, bend = 0.5): string { - if (Math.abs(end.y - start.y) > Math.abs(end.x - start.x)) { - const controlY = start.y + (end.y - start.y) * bend - - return `M ${start.x} ${start.y} C ${start.x} ${controlY}, ${end.x} ${controlY}, ${end.x} ${end.y}` - } - - const controlX = start.x + (end.x - start.x) * bend - return `M ${start.x} ${start.y} C ${controlX} ${start.y}, ${controlX} ${end.y}, ${end.x} ${end.y}` -} - -function graphStyle(node: GraphNodePosition): React.CSSProperties { - return { - height: `${(node.height / 420) * 100}%`, - left: `${(node.x / 420) * 100}%`, - top: `${(node.y / 420) * 100}%`, - width: `${(node.width / 420) * 100}%`, - } -} - -function GraphLine({ active, d }: { active?: boolean; d: string }) { - return ( - - ) -} - -function GraphLabel({ - children, - x, - y, -}: { - children: React.ReactNode - x: number - y: number -}) { - return ( -
- {children} -
- ) -} - -function GraphNode({ - active, - detail, - dotted, - kind, - label, - node, -}: { - active?: boolean - detail?: string - dotted?: boolean - kind?: 'tanstack' - label: string - node: GraphNodePosition -}) { - const isTanStack = kind === 'tanstack' - const className = isTanStack - ? active - ? `absolute z-20 flex flex-col items-center justify-center rounded-lg border-2 border-[var(--landing-accent)] px-2 text-center font-ds-mono text-ds-mono-2xs shadow-[0_12px_28px_rgb(var(--landing-glow)/0.28)] ring-2 ring-[color:rgb(var(--landing-glow)/0.24)] transition-all duration-500 motion-reduce:transition-none ${accentFillClass}` - : 'absolute z-20 flex flex-col items-center justify-center rounded-lg border-2 border-[var(--landing-accent)] bg-[color:rgb(var(--landing-glow)/0.15)] px-2 text-center font-ds-mono text-ds-mono-2xs text-[var(--landing-accent-bright)] transition-all duration-500 motion-reduce:transition-none' - : active - ? 'absolute z-20 flex flex-col items-center justify-center rounded-lg border border-text-primary bg-text-primary px-2 text-center font-ds-mono text-ds-mono-2xs text-background-default shadow-sm transition-all duration-500 motion-reduce:transition-none' - : dotted - ? 'absolute z-20 flex flex-col items-center justify-center rounded-lg border border-dashed border-text-primary/25 bg-background-subtle/80 px-2 text-center font-ds-mono text-ds-mono-2xs text-text-primary/30 transition-all duration-500 motion-reduce:transition-none' - : 'absolute z-20 flex flex-col items-center justify-center rounded-lg border border-border-default bg-background-subtle/90 px-2 text-center font-ds-mono text-ds-mono-2xs text-text-primary/40 transition-all duration-500 motion-reduce:transition-none' - - return ( -
- {label} - {detail ? ( - - {detail} - - ) : null} -
+
+
) } @@ -962,14 +758,14 @@ function ToolBoundary() { key={option} type="button" aria-pressed={boundary === option} - className="flex-1 rounded-lg border border-border-default px-3 py-2 text-ds-label-sm capitalize text-text-primary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--landing-accent-bright)] aria-pressed:border-[var(--landing-accent)] aria-pressed:bg-[color:rgb(var(--landing-glow)/0.14)] aria-pressed:text-[var(--landing-accent-bright)]" + className="flex-1 rounded-lg border border-border-default px-3 py-2 text-ds-label-sm capitalize text-text-primary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--landing-accent-bright) aria-pressed:border-(--landing-accent) aria-pressed:bg-[rgb(var(--landing-glow)/0.14)] aria-pressed:text-(--landing-accent-bright)" onClick={() => setBoundary(option)} > {option} ))}
-
+

const lookupInvoice = toolDefinition({'{'} @@ -983,108 +779,51 @@ function ToolBoundary() {

{'}'})

{boundary === 'client' ? ( <> -

+

lookupInvoice.client(async ({'{'} id {'}'}) => {'{'}

-

+

   {`const url = new URL(\`/invoices/\${id}\`, window.location.origin)`}

-

+

  window.history.pushState({'{'} id {'}'}, '', url)

-

+

  return {'{'} id, href: url.pathname {'}'}

-

{'})'}

+

{'})'}

) : ( - <> -

- lookupInvoice.server(async ({'{'} id {'}'}) => {'{'} -

-

-   return db.invoices.update({'{'} -

-

-     where: {'{'} id {'}'}, -

-

-     data: {'{'} lastViewedAt: new Date(){' '} - {'}'}, -

-

-   {'}'}) -

-

{'})'}

- - )} -
-

- {boundary === 'client' - ? 'The client implementation uses the typed id to call a browser API. The loop waits for it and feeds the result back to the model.' - : 'The server implementation uses the same typed id to update a row in your database. The model never sees your credentials.'} -

-
- - ) -} - -function ProviderWorkbench() { - const [activeIndex, setActiveIndex] = React.useState(0) - const provider = providers[activeIndex] ?? providers[0] - - return ( - -
-
- {providers.map((item, index) => ( - - ))} -
-
-

- selected model -

-

- {provider.model} -

-
- {['text', 'reasoning', 'tools', 'image', 'media'].map( - (capability) => { - const supported = provider.capabilities.includes(capability) - return ( - - {capability} - - ) - }, - )} -
-

- Types narrow to this exact model: its options, its capabilities, its - input modalities. Pass an image to a text-only model and it fails at - compile time, not in production. -

+ <> +

+ lookupInvoice.server(async ({'{'} id {'}'}) => {'{'} +

+

+   return db.invoices.update({'{'} +

+

+     where: {'{'} id {'}'}, +

+

+     data: {'{'} lastViewedAt: new Date(){' '} + {'}'}, +

+

+   {'}'}) +

+

{'})'}

+ + )}
+

+ {boundary === 'client' + ? 'The client implementation uses the typed id to call a browser API. The loop waits for it and feeds the result back to the model.' + : 'The server implementation uses the same typed id to update a row in your database. The model never sees your credentials.'} +

) @@ -1105,7 +844,7 @@ function ProtocolMap() { both directions. The server then talks to a provider such as OpenAI or Anthropic.

-
+
{nodes.map((node, index) => ( @@ -1131,10 +870,10 @@ function ProtocolCard({