diff --git a/examples/ts-react-chat/package.json b/examples/ts-react-chat/package.json index 60cfe6836..25ca37e52 100644 --- a/examples/ts-react-chat/package.json +++ b/examples/ts-react-chat/package.json @@ -21,6 +21,7 @@ "@tanstack/ai-ollama": "workspace:*", "@tanstack/ai-openai": "workspace:*", "@tanstack/ai-openrouter": "workspace:*", + "@tanstack/ai-orchestration": "workspace:*", "@tanstack/ai-react": "workspace:*", "@tanstack/ai-react-ui": "workspace:*", "@tanstack/nitro-v2-vite-plugin": "^1.154.7", @@ -41,6 +42,7 @@ "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", + "shiki": "^4.0.2", "tailwindcss": "^4.1.18", "vite-tsconfig-paths": "^5.1.4", "zod": "^4.2.0" diff --git a/examples/ts-react-chat/src/components/ArticleModal.tsx b/examples/ts-react-chat/src/components/ArticleModal.tsx new file mode 100644 index 000000000..a8187d626 --- /dev/null +++ b/examples/ts-react-chat/src/components/ArticleModal.tsx @@ -0,0 +1,120 @@ +import { useEffect } from 'react' + +interface Article { + title: string + paragraphs: Array +} + +export function ArticleModal(props: { article: Article; onClose: () => void }) { + // Close on Escape, lock body scroll while open. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') props.onClose() + } + document.addEventListener('keydown', onKey) + const prev = document.body.style.overflow + document.body.style.overflow = 'hidden' + return () => { + document.removeEventListener('keydown', onKey) + document.body.style.overflow = prev + } + }, [props]) + + const date = new Date().toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }) + + return ( +
+ {/* backdrop */} +
+ + {/* page wrapper — scrollable */} +
+
+ {/* paper grain */} +
\")", + }} + /> + + {/* hazard tape header strip */} +
+ + {/* close button */} + + +
+ {/* masthead */} +
+ Published + {date} +
+ +

+ {props.article.title} +

+ + {/* article body — column layout for longer pieces */} +
+ {props.article.paragraphs.map((p, i) => ( +

+ {p} +

+ ))} +
+ + {/* colophon */} +
+ TanStack AI · Article Pipeline + —fin— +
+
+ +
+
+
+ + {/* corner hint */} +
+ press esc or click outside to close +
+
+ ) +} diff --git a/examples/ts-react-chat/src/components/CodeBlock.tsx b/examples/ts-react-chat/src/components/CodeBlock.tsx new file mode 100644 index 000000000..cdd37d53c --- /dev/null +++ b/examples/ts-react-chat/src/components/CodeBlock.tsx @@ -0,0 +1,104 @@ +import { useEffect, useState } from 'react' +import { + getHighlighter, + inferLangFromFilename, + normalizeLang, +} from '@/lib/shiki/highlighter' + +interface CodeBlockProps { + code: string + /** Explicit language override; takes precedence over `filename`. */ + lang?: string + /** Used to infer language when `lang` is omitted (e.g. `src/server.ts`). */ + filename?: string + /** Optional cap; longer code renders a scrollable region. */ + maxHeight?: string + /** Append a blinking caret while content is still streaming. */ + streaming?: boolean + className?: string +} + +/** + * Async-highlighted code block. Renders raw pre/code first (so the streaming + * patch text shows up immediately) then swaps to shiki's HTML output once the + * highlighter is ready and the language is loaded. Subsequent updates to + * `code` re-highlight without re-loading the highlighter. + * + * XSS note: the inner HTML below is the return value of shiki's `codeToHtml`, + * which runs the input through a textmate grammar and emits HTML-escaped + * tokens. The only attack surface would be a bug in shiki itself; the model- + * generated `code` string is otherwise opaque (no React render of raw user + * HTML happens). + */ +export function CodeBlock(props: CodeBlockProps) { + const lang = normalizeLang( + props.lang ?? inferLangFromFilename(props.filename), + ) + const [html, setHtml] = useState(null) + const [errored, setErrored] = useState(false) + + useEffect(() => { + // Object wrapper so the cleanup closure can mutate without ESLint's + // no-unnecessary-condition narrowing the bool to `false` at the check + // sites (it doesn't see the deferred cleanup mutation). + const ctl = { cancelled: false } + void (async () => { + try { + const highlighter = await getHighlighter() + if (!highlighter.getLoadedLanguages().includes(lang)) { + await highlighter.loadLanguage( + lang as Parameters[0], + ) + } + if (ctl.cancelled) return + const out = highlighter.codeToHtml(props.code, { + lang, + theme: 'tanstack-ink', + }) + setHtml(out) + } catch { + if (!ctl.cancelled) setErrored(true) + } + })() + return () => { + ctl.cancelled = true + } + }, [props.code, lang]) + + const containerStyle: React.CSSProperties = { + maxHeight: props.maxHeight, + } + + if (!html || errored) { + return ( +
+        {props.code}
+        {props.streaming && }
+      
+ ) + } + + return ( +
+ {/* HTML produced by shiki — see XSS note in the component docblock. */} +
+ {props.streaming && ( +
+ +
+ )} +
+ ) +} diff --git a/examples/ts-react-chat/src/components/DraftPreview.tsx b/examples/ts-react-chat/src/components/DraftPreview.tsx new file mode 100644 index 000000000..a8d25365c --- /dev/null +++ b/examples/ts-react-chat/src/components/DraftPreview.tsx @@ -0,0 +1,134 @@ +import { useEffect, useRef, useState } from 'react' + +interface Draft { + title?: string + paragraphs?: Array +} + +export function DraftPreview(props: { + draft: unknown + phase?: string + /** When true the draft is being assembled from a live structured-output stream. */ + streaming?: boolean +}) { + const draft = ( + props.draft && typeof props.draft === 'object' ? props.draft : null + ) as Draft | null + + // Pulse highlight when the draft content changes — gives a sense of life. + const [bumpKey, setBumpKey] = useState(0) + const lastSerialized = useRef('') + useEffect(() => { + const next = JSON.stringify(draft ?? {}) + if (next !== lastSerialized.current) { + lastSerialized.current = next + setBumpKey((k) => k + 1) + } + }, [draft]) + + const hasContent = + draft && (draft.title || (draft.paragraphs && draft.paragraphs.length > 0)) + + return ( + + ) +} + +function Empty() { + return ( +
+
+ no draft yet. +
+
awaiting writer
+
+ ) +} diff --git a/examples/ts-react-chat/src/components/FileTreePanel.tsx b/examples/ts-react-chat/src/components/FileTreePanel.tsx new file mode 100644 index 000000000..6ff6f87fc --- /dev/null +++ b/examples/ts-react-chat/src/components/FileTreePanel.tsx @@ -0,0 +1,227 @@ +import { useMemo, useState } from 'react' +import { CodeBlock } from './CodeBlock' +import { extractFileFromPatch } from '@/lib/diff-extract' + +export interface FileEntry { + filename: string + patch: string + /** Mark a file as still being written so the tree highlights it. */ + streaming?: boolean +} + +interface DirNode { + kind: 'dir' + name: string + path: string + children: Array +} +interface FileNode { + kind: 'file' + name: string + path: string + entry: FileEntry +} +type TreeNode = DirNode | FileNode + +/** + * Right-side panel listing every file the coder has touched in this run as a + * collapsible folder tree. Click a file to expand its diff inline, rendered + * through shiki. Streaming files (the one the coder is currently writing) + * stay flagged with a citron dot. + */ +export function FileTreePanel(props: { files: Array }) { + const root = useMemo(() => buildTree(props.files), [props.files]) + + return ( + + ) +} + +function EmptyState() { + return ( +
+ no files yet. +
+ + coder output will appear here. + +
+ ) +} + +function TreeLevel(props: { nodes: Array; depth: number }) { + return ( +
    + {props.nodes.map((node) => ( +
  • + {node.kind === 'dir' ? ( + + ) : ( + + )} +
  • + ))} +
+ ) +} + +function DirRow(props: { node: DirNode; depth: number }) { + // Folders default open — most demo runs touch only a handful of files, so + // expanding everything is more useful than gating it behind a click. + const [open, setOpen] = useState(true) + return ( +
+ + {open && ( + + )} +
+ ) +} + +function FileRow(props: { node: FileNode; depth: number }) { + const [open, setOpen] = useState(false) + const { entry } = props.node + // Re-extract on every patch update so the live view fills in as the + // streaming diff grows. extractFileFromPatch is pure + cheap (linear scan). + const fileBody = useMemo( + () => extractFileFromPatch(entry.patch), + [entry.patch], + ) + return ( +
+ + {open && ( +
+ +
+ )} +
+ ) +} + +// ============================================================================ +// Tree construction +// ============================================================================ + +function buildTree(files: Array): DirNode { + const root: DirNode = { kind: 'dir', name: '', path: '', children: [] } + for (const entry of files) { + const parts = entry.filename.split('/').filter(Boolean) + if (parts.length === 0) continue + insertEntry(root, parts, entry, '') + } + sortTree(root) + collapseSingleChildDirs(root) + return root +} + +function insertEntry( + parent: DirNode, + segments: Array, + entry: FileEntry, + prefix: string, +): void { + const [head, ...rest] = segments + if (!head) return + const path = prefix ? `${prefix}/${head}` : head + if (rest.length === 0) { + parent.children.push({ kind: 'file', name: head, path, entry }) + return + } + let dir = parent.children.find( + (c): c is DirNode => c.kind === 'dir' && c.name === head, + ) + if (!dir) { + dir = { kind: 'dir', name: head, path, children: [] } + parent.children.push(dir) + } + insertEntry(dir, rest, entry, path) +} + +function sortTree(node: DirNode): void { + node.children.sort((a, b) => { + // Directories first, then files; alphabetical within each group. + if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1 + return a.name.localeCompare(b.name) + }) + for (const child of node.children) { + if (child.kind === 'dir') sortTree(child) + } +} + +/** + * Collapse chains like `src › lib › workflows › foo.ts` where every directory + * has exactly one child directory — render them as `src/lib/workflows/` on a + * single row so the panel doesn't waste vertical space on stub folders. + */ +function collapseSingleChildDirs(node: DirNode): void { + for (const child of node.children) { + if (child.kind !== 'dir') continue + while (child.children.length === 1 && child.children[0].kind === 'dir') { + const only = child.children[0] + child.name = `${child.name}/${only.name}` + child.path = only.path + child.children = only.children + } + collapseSingleChildDirs(child) + } +} diff --git a/examples/ts-react-chat/src/components/Header.tsx b/examples/ts-react-chat/src/components/Header.tsx index 7dda9649a..0863536ed 100644 --- a/examples/ts-react-chat/src/components/Header.tsx +++ b/examples/ts-react-chat/src/components/Header.tsx @@ -5,12 +5,14 @@ import { Braces, FileAudio, FileText, + GitBranch, Guitar, Home, Image, Menu, Mic, Music, + Network, Video, X, } from 'lucide-react' @@ -188,15 +190,47 @@ export default function Header() { setIsOpen(false)} - className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2" + className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-1" activeProps={{ className: - 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2', + 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-1', }} > Voice Chat (Realtime) + +
+ +

+ Orchestration +

+ + setIsOpen(false)} + className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-1" + activeProps={{ + className: + 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-1', + }} + > + + Article Workflow + + + setIsOpen(false)} + className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2" + activeProps={{ + className: + 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2', + }} + > + + Feature Orchestrator + diff --git a/examples/ts-react-chat/src/components/StateInspector.tsx b/examples/ts-react-chat/src/components/StateInspector.tsx new file mode 100644 index 000000000..cb4b176fd --- /dev/null +++ b/examples/ts-react-chat/src/components/StateInspector.tsx @@ -0,0 +1,105 @@ +import { useMemo } from 'react' + +export function StateInspector(props: { state: unknown }) { + const lines = useMemo(() => syntaxHighlight(props.state ?? {}), [props.state]) + const isEmpty = + props.state === null || + props.state === undefined || + (typeof props.state === 'object' && + Object.keys(props.state as object).length === 0) + + return ( + + ) +} + +/** Tiny syntax highlighter for pretty-printed JSON. */ +function syntaxHighlight(value: unknown): React.ReactNode { + const text = JSON.stringify(value, null, 2) + if (!text) return null + + const pattern = + /("(?:\\.|[^"\\])*"\s*:)|("(?:\\.|[^"\\])*")|\b(true|false|null)\b|(-?\d+\.?\d*(?:[eE][+-]?\d+)?)|([{}[\],])/g + + const tokens: Array = [] + let cursor = 0 + let key = 0 + + for (const match of text.matchAll(pattern)) { + const start = match.index ?? 0 + if (start > cursor) tokens.push(text.slice(cursor, start)) + + const [whole, propKey, str, kw, num, punc] = match + if (propKey) { + tokens.push( + + {propKey} + , + ) + } else if (str) { + tokens.push( + + {str} + , + ) + } else if (kw) { + tokens.push( + + {kw} + , + ) + } else if (num) { + tokens.push( + + {num} + , + ) + } else if (punc) { + tokens.push( + + {punc} + , + ) + } else { + tokens.push(whole) + } + cursor = start + whole.length + } + if (cursor < text.length) tokens.push(text.slice(cursor)) + return tokens +} diff --git a/examples/ts-react-chat/src/components/WorkflowTimeline.tsx b/examples/ts-react-chat/src/components/WorkflowTimeline.tsx new file mode 100644 index 000000000..0415ccc18 --- /dev/null +++ b/examples/ts-react-chat/src/components/WorkflowTimeline.tsx @@ -0,0 +1,186 @@ +import type { WorkflowStep } from '@tanstack/ai-client' + +export function WorkflowTimeline(props: { + steps: Array + currentStep: WorkflowStep | null + currentText?: string +}) { + return ( +
+
+ + {props.steps.length === 0 ? ( + + ) : ( +
    + {props.steps.map((step, i) => ( + + ))} +
+ )} +
+ ) +} + +function Header(props: { count: number }) { + return ( +
+ Pipeline Log + + {String(props.count).padStart(2, '0')} entries + +
+ ) +} + +function EmptyState() { + return ( +
+
+ nothing yet. +
+
awaiting first step
+
+ ) +} + +function Entry(props: { + ordinal: number + step: WorkflowStep + isActive: boolean + currentText?: string +}) { + const { ordinal, step, isActive, currentText } = props + const duration = + step.finishedAt && step.startedAt ? step.finishedAt - step.startedAt : null + + return ( +
  • +
    +
    + № {String(ordinal).padStart(2, '0')} +
    +
    + {isActive && ( +
    + )} +
    + +
    +
    +

    + {step.stepName} +

    + {step.stepType && ( + + {step.stepType.replace('-', ' · ')} + + )} + + {step.status === 'running' ? ( + <> + running + + + ) : step.status === 'failed' ? ( + 'failed' + ) : duration !== null ? ( + `${duration}ms` + ) : ( + 'finished' + )} + +
    + + {isActive && currentText && ( +
    +            {currentText}
    +            
    +          
    + )} + + {step.status === 'finished' && step.result !== undefined && ( + + )} + {step.status === 'failed' && step.result !== undefined && ( + + )} +
    +
  • + ) +} + +function ResultBlock(props: { result: unknown }) { + const text = typeof props.result === 'string' ? props.result : null + return ( +
    + + + ▸ + + result + +
    + {text !== null ? ( +

    + {text} +

    + ) : ( +
    +            {JSON.stringify(props.result, null, 2)}
    +          
    + )} +
    +
    + ) +} + +function FailureBlock(props: { result: unknown }) { + const result = props.result as { error?: { message?: string } } + const msg = result.error?.message ?? JSON.stringify(props.result) + return ( +
    +
    error
    +

    + {msg} +

    +
    + ) +} diff --git a/examples/ts-react-chat/src/lib/diff-extract.ts b/examples/ts-react-chat/src/lib/diff-extract.ts new file mode 100644 index 000000000..9c36944fd --- /dev/null +++ b/examples/ts-react-chat/src/lib/diff-extract.ts @@ -0,0 +1,101 @@ +/** + * Extract the "applied" file contents from a model-emitted patch. + * + * The coder agent is prompted to emit unified-diff-style patches inside a + * markdown code fence (e.g. ```diff … ```). For the file-tree panel we want + * to render the *resulting* source — what the file looks like after the + * patch is applied — and highlight it as TS/TSX/etc., not as a diff. + * + * Steps: + * 1. Strip a surrounding markdown code fence (any info string). + * 2. Drop unified-diff metadata lines: `diff --git`, `index …`, + * `--- a/…`, `+++ b/…`, `@@ -x,y +a,b @@`, and `new file mode`-style + * headers. + * 3. For the remaining diff body, keep additions (`+`) and context (` `) + * and drop removals (`-`), stripping the one-char prefix from kept + * lines. Lines with no diff prefix at all pass through verbatim — that + * covers the case where the model just emitted a plain file body + * instead of a real diff. + * + * Lenient by design: a streaming half-built patch may end mid-line or be + * missing its closing fence. Anything left over after the steps above is + * still returned to the caller so the live preview keeps growing. + */ +export function extractFileFromPatch(rawPatch: string): string { + if (!rawPatch) return '' + const text = stripCodeFence(rawPatch) + + const lines = text.split('\n') + const out: Array = [] + let inHunk = false + + for (const line of lines) { + if (isDiffHeader(line)) { + if (line.startsWith('@@')) inHunk = true + continue + } + if (!inHunk) { + // Before any hunk header we haven't seen a true diff body yet. If the + // line *looks* like a non-prefixed source line, keep it as-is. If it's + // a stray `+` / `-` (rare, but happens when the model skips headers), + // fall through to the body branch below. + const ch = line[0] + if (ch !== '+' && ch !== '-' && ch !== ' ') { + out.push(line) + continue + } + // Otherwise let the body branch handle it. + } + if (line.startsWith('+++') || line.startsWith('---')) continue + const ch = line[0] + if (ch === '+') { + out.push(line.slice(1)) + } else if (ch === '-') { + // Removal — drop entirely. + } else if (ch === ' ') { + out.push(line.slice(1)) + } else if (line === '') { + out.push('') + } else { + // Body line with no diff prefix — preserve as-is. + out.push(line) + } + } + + // Trim leading/trailing blank lines that the strip pass commonly leaves + // behind, but keep interior blank lines intact. + while (out.length > 0 && out[0]?.trim() === '') out.shift() + while (out.length > 0 && out[out.length - 1]?.trim() === '') out.pop() + return out.join('\n') +} + +function stripCodeFence(input: string): string { + const trimmed = input.trim() + // ```\n…\n``` + const fenceMatch = trimmed.match(/^```[^\n]*\n([\s\S]*?)```?\s*$/) + if (fenceMatch?.[1] !== undefined) { + return fenceMatch[1] + } + // Streaming case: opening fence present but no closing one yet. + if (trimmed.startsWith('```')) { + const firstNl = trimmed.indexOf('\n') + if (firstNl !== -1) return trimmed.slice(firstNl + 1) + } + return input +} + +function isDiffHeader(line: string): boolean { + return ( + line.startsWith('@@') || + line.startsWith('diff --git ') || + line.startsWith('index ') || + line.startsWith('new file mode ') || + line.startsWith('deleted file mode ') || + line.startsWith('old mode ') || + line.startsWith('new mode ') || + line.startsWith('similarity index ') || + line.startsWith('rename from ') || + line.startsWith('rename to ') || + line.startsWith('Binary files ') + ) +} diff --git a/examples/ts-react-chat/src/lib/shiki/highlighter.ts b/examples/ts-react-chat/src/lib/shiki/highlighter.ts new file mode 100644 index 000000000..20087ce30 --- /dev/null +++ b/examples/ts-react-chat/src/lib/shiki/highlighter.ts @@ -0,0 +1,72 @@ +import { tanstackInkTheme } from './theme' +import type { Highlighter } from 'shiki' + +/** + * Lazy shiki singleton. + * + * Uses shiki's high-level `createHighlighter` API rather than the + * fine-grained `shiki/core` entry — keeps us free of pinning to internal + * `@shikijs/langs` subpaths that aren't hoisted by pnpm. The dynamic import + * is only triggered the first time a `` mounts. + */ +let highlighterPromise: Promise | null = null + +export function getHighlighter(): Promise { + if (!highlighterPromise) { + highlighterPromise = (async () => { + const { createHighlighter } = await import('shiki') + return createHighlighter({ + themes: [tanstackInkTheme], + langs: ['diff', 'markdown', 'typescript', 'tsx', 'json', 'bash'], + }) + })() + } + return highlighterPromise +} + +const KNOWN_LANGS = new Set([ + 'diff', + 'markdown', + 'md', + 'typescript', + 'ts', + 'tsx', + 'json', + 'bash', + 'sh', +]) + +/** Map a filename extension to a shiki-known language id. Falls back to + * 'typescript' for unknown extensions — this helper is used by the file + * panel, which renders applied file content (not diffs); typescript is the + * realistic default for LLM-generated server code in this demo. Callers + * that want diff highlighting (e.g. the inline patch line in the log) + * should pass `lang="diff"` explicitly. */ +export function inferLangFromFilename(filename: string | undefined): string { + if (!filename) return 'typescript' + const ext = filename.slice(filename.lastIndexOf('.') + 1).toLowerCase() + switch (ext) { + case 'ts': + return 'typescript' + case 'tsx': + return 'tsx' + case 'js': + case 'mjs': + case 'cjs': + return 'typescript' + case 'json': + return 'json' + case 'md': + case 'mdx': + return 'markdown' + case 'sh': + return 'bash' + default: + return 'typescript' + } +} + +export function normalizeLang(lang: string | undefined): string { + if (!lang) return 'typescript' + return KNOWN_LANGS.has(lang) ? lang : 'typescript' +} diff --git a/examples/ts-react-chat/src/lib/shiki/theme.ts b/examples/ts-react-chat/src/lib/shiki/theme.ts new file mode 100644 index 000000000..5f660e800 --- /dev/null +++ b/examples/ts-react-chat/src/lib/shiki/theme.ts @@ -0,0 +1,113 @@ +import type { ThemeRegistration } from 'shiki' + +/** + * Custom shiki theme matched to the orchestrator terminal's palette + * (ink/bone/citron/rust/moss + warm taupes). Lifted from styles.css's + * `@theme` tokens so a palette change in CSS only needs to be mirrored + * here, not chased across both surfaces. + */ +export const tanstackInkTheme: ThemeRegistration = { + name: 'tanstack-ink', + type: 'dark', + colors: { + 'editor.background': '#1d1916', + 'editor.foreground': '#e8dfd1', + 'editorLineNumber.foreground': '#6a5f53', + }, + tokenColors: [ + { + scope: ['comment', 'punctuation.definition.comment'], + settings: { foreground: '#6a5f53', fontStyle: 'italic' }, + }, + { scope: ['variable'], settings: { foreground: '#e8dfd1' } }, + { scope: ['string', 'string.quoted'], settings: { foreground: '#a8b86b' } }, + { scope: ['string.regexp'], settings: { foreground: '#c84b1c' } }, + { + scope: ['constant.numeric', 'constant.language'], + settings: { foreground: '#c84b1c' }, + }, + { + scope: ['constant.character.escape'], + settings: { foreground: '#d8ad00' }, + }, + { + scope: ['keyword', 'storage.type', 'storage.modifier'], + settings: { foreground: '#ffce00' }, + }, + { + scope: ['keyword.control', 'keyword.operator'], + settings: { foreground: '#ffce00' }, + }, + { + scope: [ + 'entity.name.function', + 'meta.function-call.generic', + 'support.function', + ], + settings: { foreground: '#ffce00' }, + }, + { + scope: [ + 'entity.name.class', + 'entity.name.type', + 'support.class', + 'support.type', + ], + settings: { foreground: '#d8ad00' }, + }, + { + scope: ['entity.other.attribute-name'], + settings: { foreground: '#a8b86b' }, + }, + { scope: ['entity.name.tag'], settings: { foreground: '#ffce00' } }, + { scope: ['punctuation'], settings: { foreground: '#93887a' } }, + { + scope: [ + 'punctuation.section.embedded', + 'punctuation.definition.template-expression', + ], + settings: { foreground: '#c84b1c' }, + }, + { scope: ['variable.parameter'], settings: { foreground: '#e8dfd1' } }, + { + scope: ['variable.other.object', 'variable.other.property'], + settings: { foreground: '#e8dfd1' }, + }, + + // ── diff ─────────────────────────────────────────────────────────── + // shiki emits `markup.inserted` / `markup.deleted` / `markup.changed` + // for unified-diff bodies; the header (`@@ …`, `+++ b/foo`) uses + // `meta.diff.header`. + { + scope: ['markup.inserted', 'meta.diff.inserted'], + settings: { foreground: '#a8b86b' }, + }, + { + scope: ['markup.deleted', 'meta.diff.deleted'], + settings: { foreground: '#c84b1c' }, + }, + { scope: ['markup.changed'], settings: { foreground: '#d8ad00' } }, + { + scope: ['meta.diff.header', 'meta.diff.range'], + settings: { foreground: '#ffce00', fontStyle: 'italic' }, + }, + { scope: ['meta.diff.index'], settings: { foreground: '#6a5f53' } }, + + // ── markdown ─────────────────────────────────────────────────────── + { + scope: ['markup.heading'], + settings: { foreground: '#ffce00', fontStyle: 'bold' }, + }, + { scope: ['markup.italic'], settings: { fontStyle: 'italic' } }, + { scope: ['markup.bold'], settings: { fontStyle: 'bold' } }, + { + scope: ['markup.fenced_code', 'markup.inline.raw'], + settings: { foreground: '#a8b86b' }, + }, + { + scope: ['markup.list.numbered', 'markup.list.unnumbered'], + settings: { foreground: '#93887a' }, + }, + { scope: ['markup.underline.link'], settings: { foreground: '#d8ad00' } }, + ], +} diff --git a/examples/ts-react-chat/src/lib/workflows/article-workflow.ts b/examples/ts-react-chat/src/lib/workflows/article-workflow.ts new file mode 100644 index 000000000..87b235074 --- /dev/null +++ b/examples/ts-react-chat/src/lib/workflows/article-workflow.ts @@ -0,0 +1,178 @@ +import { z } from 'zod' +import { chat } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { + approve, + defineAgent, + defineWorkflow, + fail, + succeed, +} from '@tanstack/ai-orchestration' + +// ===== Schemas ===== +const Draft = z.object({ + title: z.string(), + paragraphs: z.array(z.string()), +}) + +const Review = z.object({ + verdict: z.enum(['pass', 'block']), + findings: z.array(z.string()), +}) + +const ArticleInput = z.object({ topic: z.string() }) + +const ArticleOutput = z.union([ + z.object({ + ok: z.literal(true), + article: Draft, + }), + z.object({ + ok: z.literal(false), + reason: z.string(), + }), +]) + +const ArticleState = z.object({ + phase: z + .enum([ + 'drafting', + 'reviewing', + 'editing', + 'awaiting-approval', + 'revising', + 'done', + ]) + .default('drafting'), + draft: Draft.optional(), + legalReview: Review.optional(), + skepticReview: Review.optional(), +}) + +// ===== Agents ===== +const writer = defineAgent({ + name: 'writer', + input: z.object({ topic: z.string() }), + output: Draft, + run: ({ input }) => + chat({ + adapter: openaiText('gpt-4o-mini'), + outputSchema: Draft, + stream: true, + systemPrompts: [ + 'You are a non-fiction writer. Produce a factual three-paragraph article on the topic. Reply only with valid JSON matching the schema.', + ], + messages: [{ role: 'user', content: input.topic }], + }), +}) + +function reviewerFor(role: 'legal' | 'skeptic') { + return defineAgent({ + name: `${role}Reviewer`, + input: z.object({ draft: Draft }), + output: Review, + run: ({ input }) => + chat({ + adapter: openaiText('gpt-4o-mini'), + outputSchema: Review, + stream: true, + systemPrompts: [ + role === 'legal' + ? 'You are a legal reviewer. Flag any compliance issues. Verdict "block" if issues, otherwise "pass".' + : 'You are a skeptic. Flag unsupported claims. Verdict "block" if claims are unsupported.', + ], + messages: [ + { + role: 'user', + content: `Title: ${input.draft.title}\n\n${input.draft.paragraphs.join('\n\n')}`, + }, + ], + }), + }) +} + +const editor = defineAgent({ + name: 'editor', + input: z.object({ + draft: Draft, + notes: z.array(z.string()), + }), + output: Draft, + run: ({ input }) => + chat({ + adapter: openaiText('gpt-4o-mini'), + outputSchema: Draft, + stream: true, + systemPrompts: [ + 'You are an editor. Polish the draft, addressing the reviewer notes. Reply with the polished JSON.', + ], + messages: [ + { + role: 'user', + content: `Draft: ${JSON.stringify(input.draft)}\n\nNotes: ${input.notes.join('; ')}`, + }, + ], + }), +}) + +// ===== Workflow ===== +export const articleWorkflow = defineWorkflow({ + name: 'article-workflow', + input: ArticleInput, + output: ArticleOutput, + state: ArticleState, + agents: { + writer, + legal: reviewerFor('legal'), + skeptic: reviewerFor('skeptic'), + editor, + }, + run: async function* ({ input, state, agents }) { + state.phase = 'drafting' + const draft = yield* agents.writer({ topic: input.topic }) + state.draft = draft + + state.phase = 'reviewing' + const legal = yield* agents.legal({ draft }) + state.legalReview = legal + if (legal.verdict === 'block') { + return fail(`legal: ${legal.findings.join('; ')}`) + } + + const skeptic = yield* agents.skeptic({ draft }) + state.skepticReview = skeptic + if (skeptic.verdict === 'block') { + return fail(`skeptic: ${skeptic.findings.join('; ')}`) + } + + state.phase = 'editing' + let current = yield* agents.editor({ + draft, + notes: [...legal.findings, ...skeptic.findings], + }) + state.draft = current + + for (let round = 0; round < 4; round++) { + state.phase = 'awaiting-approval' + const decision = yield* approve({ + title: round === 0 ? 'Publish this article?' : 'Publish the revision?', + description: current.title, + }) + if (decision.approved) { + state.phase = 'done' + return succeed({ article: current }) + } + if (!decision.feedback || !decision.feedback.trim()) { + state.phase = 'done' + return fail('user denied') + } + state.phase = 'revising' + current = yield* agents.editor({ + draft: current, + notes: [decision.feedback], + }) + state.draft = current + } + return fail('too many revision rounds') + }, +}) diff --git a/examples/ts-react-chat/src/lib/workflows/orchestrator.ts b/examples/ts-react-chat/src/lib/workflows/orchestrator.ts new file mode 100644 index 000000000..4a63964c3 --- /dev/null +++ b/examples/ts-react-chat/src/lib/workflows/orchestrator.ts @@ -0,0 +1,372 @@ +import { z } from 'zod' +import { chat } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { + approve, + defineAgent, + defineOrchestrator, + defineRouter, + defineWorkflow, +} from '@tanstack/ai-orchestration' + +// ===== Schemas ===== +const FeatureSpec = z.object({ + title: z.string(), + summary: z.string(), + files: z.array(z.string()), +}) + +const FilePatch = z.object({ + filename: z.string(), + patch: z.string(), +}) + +const ImplementResult = z.object({ + patches: z.array(FilePatch), + rationale: z.string(), +}) + +const OrchestratorState = z.object({ + phase: z + .enum(['scoping', 'awaiting-approval', 'implementing', 'review', 'done']) + .default('scoping'), + spec: FeatureSpec.optional(), + result: ImplementResult.optional(), + /** The original request, kept for UI display across the run. */ + lastUserMessage: z.string().default(''), + /** + * Free-text the orchestrator still needs to address: the initial request on + * turn 0, or a feedback note typed when the user denied approval. Cleared + * the moment the spec agent absorbs it, so triage doesn't keep routing back + * to 'spec' on every turn after the same message produced a spec. + */ + pendingFeedback: z.string().default(''), +}) + +const OrchestratorInput = z.object({ + userMessage: z.string(), + /** + * Spec carried over from a prior finished run. When provided, the + * orchestrator initializes mid-flow (phase: 'review') so the new + * `userMessage` is treated as refinement feedback rather than a fresh + * scoping request. + */ + previousSpec: FeatureSpec.optional(), + /** Implementation result carried over from a prior finished run. */ + previousResult: ImplementResult.optional(), +}) +const OrchestratorOutput = z.object({ + phase: z.enum(['scoping', 'implementing', 'review', 'done']), + result: ImplementResult.optional(), +}) + +// ===== Agents ===== +const specAgent = defineAgent({ + name: 'spec', + input: z.object({ + userMessage: z.string(), + existingSpec: FeatureSpec.optional(), + }), + output: z.object({ + spec: FeatureSpec, + ready: z.boolean(), + }), + run: ({ input }) => + chat({ + adapter: openaiText('gpt-4o-mini'), + outputSchema: z.object({ + spec: FeatureSpec, + ready: z.boolean(), + }), + stream: true, + systemPrompts: [ + // Two modes — make refinement *explicit* and authoritative so the + // model doesn't reinvent the stack. Without this guard gpt-4o-mini + // happily replaces a `src/server.ts`/Express spec with `app.py`/Flask + // when the new note doesn't repeat the original framework. + input.existingSpec + ? [ + 'You are refining an existing feature spec.', + 'The Current Spec is the authoritative source of truth: keep its language, framework, file paths, file extensions, and architectural decisions. Do not switch frameworks or rewrite in another language unless the New Requirement explicitly asks you to.', + 'Apply the New Requirement as a minimal extension of the Current Spec — add or modify only what it asks for.', + 'Return the complete updated spec (title, summary, files) and set ready=true when it is implementation-ready.', + ].join(' ') + : 'Given a feature request, draft a concrete spec with title, summary, and the list of files to change. Mark ready=true when the spec is implementation-ready.', + ], + messages: [ + { + role: 'user', + content: input.existingSpec + ? [ + 'Current Spec (authoritative — preserve language, framework, file extensions, and architecture):', + '```json', + JSON.stringify(input.existingSpec, null, 2), + '```', + '', + 'New Requirement to incorporate:', + input.userMessage, + ].join('\n') + : `Feature request: ${input.userMessage}`, + }, + ], + }), +}) + +const plannerAgent = defineAgent({ + name: 'planner', + input: z.object({ spec: FeatureSpec }), + output: z.object({ + files: z.array(z.string()), + rationale: z.string(), + }), + run: ({ input }) => + chat({ + adapter: openaiText('gpt-4o-mini'), + outputSchema: z.object({ + files: z.array(z.string()), + rationale: z.string(), + }), + stream: true, + systemPrompts: [ + 'Given a spec, list the exact files that need patching and a one-paragraph rationale.', + ], + messages: [{ role: 'user', content: JSON.stringify(input.spec) }], + }), +}) + +const coderAgent = defineAgent({ + name: 'coder', + input: z.object({ filename: z.string(), spec: FeatureSpec }), + output: FilePatch, + run: ({ input }) => + chat({ + adapter: openaiText('gpt-4o-mini'), + outputSchema: FilePatch, + stream: true, + systemPrompts: [ + "Generate a unified-diff-style patch for the given file based on the spec. Use a markdown code block in the `patch` field. The patch body MUST be valid source for the file's language (inferred from the extension — `.ts`/`.tsx` → TypeScript, `.js` → JavaScript, `.py` → Python, etc.). Do not switch languages or frameworks; match the conventions already implied by the file path.", + ], + messages: [ + { + role: 'user', + content: `File: ${input.filename}\nSpec: ${JSON.stringify(input.spec)}`, + }, + ], + }), +}) + +// ===== implement: sub-workflow used as an "agent" by the orchestrator ===== +export const implementWorkflow = defineWorkflow({ + name: 'implement', + input: z.object({ spec: FeatureSpec }), + output: ImplementResult, + state: z.object({}).default({}), + agents: { planner: plannerAgent, coder: coderAgent }, + run: async function* ({ input, agents }) { + const plan = yield* agents.planner({ spec: input.spec }) + const patches = [] + for (const filename of plan.files) { + const patch = yield* agents.coder({ filename, spec: input.spec }) + patches.push(patch) + } + return { patches, rationale: plan.rationale } + }, +}) + +const reviewAgent = defineAgent({ + name: 'review', + input: z.object({ result: ImplementResult, userMessage: z.string() }), + output: z.object({ + verdict: z.enum(['accept', 'refine', 'reject']), + notes: z.string(), + }), + run: ({ input }) => + chat({ + adapter: openaiText('gpt-4o-mini'), + outputSchema: z.object({ + verdict: z.enum(['accept', 'refine', 'reject']), + notes: z.string(), + }), + stream: true, + systemPrompts: [ + "Read the user's feedback on the implementation. Decide accept | refine | reject.", + ], + messages: [ + { + role: 'user', + content: `Implementation:\n${JSON.stringify(input.result)}\n\nUser feedback: ${input.userMessage}`, + }, + ], + }), +}) + +const triageAgent = defineAgent({ + name: 'triage', + input: z.object({ + pendingFeedback: z.string(), + phase: z.string(), + hasSpec: z.boolean(), + hasResult: z.boolean(), + }), + output: z.object({ + next: z.enum(['spec', 'await-approval', 'implement', 'review', 'done']), + reason: z.string(), + }), + run: ({ input }) => + chat({ + adapter: openaiText('gpt-4o-mini'), + outputSchema: z.object({ + next: z.enum(['spec', 'await-approval', 'implement', 'review', 'done']), + reason: z.string(), + }), + stream: true, + systemPrompts: [ + 'Decide the next phase. Phases: spec (draft or refine a spec from pendingFeedback), await-approval (request user OK to implement), implement (run code generation against the existing spec), review (consume user feedback after implementation), done (finish). Rules: if pendingFeedback is non-empty, return spec. If hasSpec=true and pendingFeedback is empty and hasResult=false, return await-approval. If hasResult=true and pendingFeedback is empty, return done.', + ], + messages: [{ role: 'user', content: JSON.stringify(input) }], + }), +}) + +// ===== Orchestrator ===== + +const orchestratorConfig = { + agents: { + implement: implementWorkflow, + review: reviewAgent, + spec: specAgent, + triage: triageAgent, + }, + input: OrchestratorInput, + output: OrchestratorOutput, + state: OrchestratorState, +} + +const featureRouter = defineRouter( + orchestratorConfig, + function* ({ agents, state, lastResult }) { + // Fold the previous turn's agent output into state. The orchestrator + // dispatches the chosen agent but doesn't know which slice of state its + // output belongs in — the router does. Without this, triage stays blind + // to its own decisions and loops forever between "spec" and "spec". + if (lastResult) { + if ( + state.phase === 'scoping' && + typeof lastResult === 'object' && + 'spec' in (lastResult as Record) + ) { + state.spec = (lastResult as { spec: typeof state.spec }).spec + // Spec just consumed pendingFeedback — clear it so the next triage + // turn doesn't keep routing back to 'spec' against the same note. + state.pendingFeedback = '' + // A new spec invalidates any prior implementation; without this, + // refinement runs (which carry over previousResult) would short- + // circuit to 'done' on the next triage instead of re-implementing + // against the refined spec. + state.result = undefined + } else if (state.phase === 'implementing') { + state.result = lastResult as typeof state.result + } + } + + const triage = yield* agents.triage({ + hasResult: !!state.result, + hasSpec: !!state.spec, + phase: state.phase, + pendingFeedback: state.pendingFeedback, + }) + + if (triage.next === 'done') { + state.phase = 'done' + return { + done: true, + output: { phase: state.phase, result: state.result }, + } + } + + if (triage.next === 'spec') { + state.phase = 'scoping' + return { + agent: 'spec', + input: { + userMessage: state.pendingFeedback || state.lastUserMessage, + // Feed the prior spec back in when refining so the model edits + // rather than rewrites from scratch. + existingSpec: state.spec, + }, + } + } + + if (triage.next === 'await-approval') { + const approval = yield* approve({ + description: state.spec + ? `Spec ready: "${state.spec.title}". Approve to implement, or deny with feedback to refine.` + : 'Begin implementing?', + title: 'Start implementation?', + }) + if (approval.approved) { + state.phase = 'implementing' + if (!state.spec) throw new Error('No spec to implement') + return { agent: 'implement', input: { spec: state.spec } } + } + // Deny: route back to spec carrying any free-text feedback the user + // typed. The spec agent receives the existing spec + the new note so it + // refines instead of restarting. We store the note in pendingFeedback, + // not lastUserMessage — lastUserMessage stays as the original request + // for UI display. + state.phase = 'scoping' + const feedback = approval.feedback?.trim() + state.pendingFeedback = feedback || 'refine the spec' + return { + agent: 'spec', + input: { + userMessage: state.pendingFeedback, + existingSpec: state.spec, + }, + } + } + + if (triage.next === 'implement') { + state.phase = 'implementing' + if (!state.spec) throw new Error('No spec to implement') + return { agent: 'implement', input: { spec: state.spec } } + } + + if (triage.next === 'review') { + state.phase = 'review' + if (!state.result) throw new Error('No result to review') + return { + agent: 'review', + input: { result: state.result, userMessage: state.lastUserMessage }, + } + } + + state.phase = 'done' + return { done: true, output: { phase: state.phase, result: state.result } } + }, +) + +export const featureOrchestrator = defineOrchestrator({ + ...orchestratorConfig, + // Seed state from the input so a refinement run picks up where the + // previous run left off. When the client passes `previousSpec` / + // `previousResult`, we start the new run in 'review' phase — triage then + // sees there's already a spec/result + a fresh user message and routes to + // 'spec' (refine) rather than treating the message as a brand-new request. + initialize: ({ input }) => { + if (input.previousSpec) { + return { + lastUserMessage: input.userMessage, + pendingFeedback: input.userMessage, + spec: input.previousSpec, + result: input.previousResult, + phase: 'review' as const, + } + } + return { + lastUserMessage: input.userMessage, + pendingFeedback: input.userMessage, + } + }, + name: 'feature-orchestrator', + router: featureRouter, +}) diff --git a/examples/ts-react-chat/src/routeTree.gen.ts b/examples/ts-react-chat/src/routeTree.gen.ts index f9b2ac825..726cc03c9 100644 --- a/examples/ts-react-chat/src/routeTree.gen.ts +++ b/examples/ts-react-chat/src/routeTree.gen.ts @@ -9,7 +9,9 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as WorkflowRouteImport } from './routes/workflow' import { Route as RealtimeRouteImport } from './routes/realtime' +import { Route as OrchestrationRouteImport } from './routes/orchestration' import { Route as ImageGenRouteImport } from './routes/image-gen' import { Route as IndexRouteImport } from './routes/index' import { Route as GenerationsVideoRouteImport } from './routes/generations.video' @@ -19,10 +21,12 @@ import { Route as GenerationsStructuredOutputRouteImport } from './routes/genera import { Route as GenerationsSpeechRouteImport } from './routes/generations.speech' import { Route as GenerationsImageRouteImport } from './routes/generations.image' import { Route as GenerationsAudioRouteImport } from './routes/generations.audio' +import { Route as ApiWorkflowRouteImport } from './routes/api.workflow' import { Route as ApiTranscribeRouteImport } from './routes/api.transcribe' import { Route as ApiTanchatRouteImport } from './routes/api.tanchat' import { Route as ApiSummarizeRouteImport } from './routes/api.summarize' import { Route as ApiStructuredOutputRouteImport } from './routes/api.structured-output' +import { Route as ApiOrchestrationRouteImport } from './routes/api.orchestration' import { Route as ApiImageGenRouteImport } from './routes/api.image-gen' import { Route as ExampleGuitarsIndexRouteImport } from './routes/example.guitars/index' import { Route as ExampleGuitarsGuitarIdRouteImport } from './routes/example.guitars/$guitarId' @@ -31,11 +35,21 @@ import { Route as ApiGenerateSpeechRouteImport } from './routes/api.generate.spe import { Route as ApiGenerateImageRouteImport } from './routes/api.generate.image' import { Route as ApiGenerateAudioRouteImport } from './routes/api.generate.audio' +const WorkflowRoute = WorkflowRouteImport.update({ + id: '/workflow', + path: '/workflow', + getParentRoute: () => rootRouteImport, +} as any) const RealtimeRoute = RealtimeRouteImport.update({ id: '/realtime', path: '/realtime', getParentRoute: () => rootRouteImport, } as any) +const OrchestrationRoute = OrchestrationRouteImport.update({ + id: '/orchestration', + path: '/orchestration', + getParentRoute: () => rootRouteImport, +} as any) const ImageGenRoute = ImageGenRouteImport.update({ id: '/image-gen', path: '/image-gen', @@ -83,6 +97,11 @@ const GenerationsAudioRoute = GenerationsAudioRouteImport.update({ path: '/generations/audio', getParentRoute: () => rootRouteImport, } as any) +const ApiWorkflowRoute = ApiWorkflowRouteImport.update({ + id: '/api/workflow', + path: '/api/workflow', + getParentRoute: () => rootRouteImport, +} as any) const ApiTranscribeRoute = ApiTranscribeRouteImport.update({ id: '/api/transcribe', path: '/api/transcribe', @@ -103,6 +122,11 @@ const ApiStructuredOutputRoute = ApiStructuredOutputRouteImport.update({ path: '/api/structured-output', getParentRoute: () => rootRouteImport, } as any) +const ApiOrchestrationRoute = ApiOrchestrationRouteImport.update({ + id: '/api/orchestration', + path: '/api/orchestration', + getParentRoute: () => rootRouteImport, +} as any) const ApiImageGenRoute = ApiImageGenRouteImport.update({ id: '/api/image-gen', path: '/api/image-gen', @@ -142,12 +166,16 @@ const ApiGenerateAudioRoute = ApiGenerateAudioRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/image-gen': typeof ImageGenRoute + '/orchestration': typeof OrchestrationRoute '/realtime': typeof RealtimeRoute + '/workflow': typeof WorkflowRoute '/api/image-gen': typeof ApiImageGenRoute + '/api/orchestration': typeof ApiOrchestrationRoute '/api/structured-output': typeof ApiStructuredOutputRoute '/api/summarize': typeof ApiSummarizeRoute '/api/tanchat': typeof ApiTanchatRoute '/api/transcribe': typeof ApiTranscribeRoute + '/api/workflow': typeof ApiWorkflowRoute '/generations/audio': typeof GenerationsAudioRoute '/generations/image': typeof GenerationsImageRoute '/generations/speech': typeof GenerationsSpeechRoute @@ -165,12 +193,16 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/image-gen': typeof ImageGenRoute + '/orchestration': typeof OrchestrationRoute '/realtime': typeof RealtimeRoute + '/workflow': typeof WorkflowRoute '/api/image-gen': typeof ApiImageGenRoute + '/api/orchestration': typeof ApiOrchestrationRoute '/api/structured-output': typeof ApiStructuredOutputRoute '/api/summarize': typeof ApiSummarizeRoute '/api/tanchat': typeof ApiTanchatRoute '/api/transcribe': typeof ApiTranscribeRoute + '/api/workflow': typeof ApiWorkflowRoute '/generations/audio': typeof GenerationsAudioRoute '/generations/image': typeof GenerationsImageRoute '/generations/speech': typeof GenerationsSpeechRoute @@ -189,12 +221,16 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/image-gen': typeof ImageGenRoute + '/orchestration': typeof OrchestrationRoute '/realtime': typeof RealtimeRoute + '/workflow': typeof WorkflowRoute '/api/image-gen': typeof ApiImageGenRoute + '/api/orchestration': typeof ApiOrchestrationRoute '/api/structured-output': typeof ApiStructuredOutputRoute '/api/summarize': typeof ApiSummarizeRoute '/api/tanchat': typeof ApiTanchatRoute '/api/transcribe': typeof ApiTranscribeRoute + '/api/workflow': typeof ApiWorkflowRoute '/generations/audio': typeof GenerationsAudioRoute '/generations/image': typeof GenerationsImageRoute '/generations/speech': typeof GenerationsSpeechRoute @@ -214,12 +250,16 @@ export interface FileRouteTypes { fullPaths: | '/' | '/image-gen' + | '/orchestration' | '/realtime' + | '/workflow' | '/api/image-gen' + | '/api/orchestration' | '/api/structured-output' | '/api/summarize' | '/api/tanchat' | '/api/transcribe' + | '/api/workflow' | '/generations/audio' | '/generations/image' | '/generations/speech' @@ -237,12 +277,16 @@ export interface FileRouteTypes { to: | '/' | '/image-gen' + | '/orchestration' | '/realtime' + | '/workflow' | '/api/image-gen' + | '/api/orchestration' | '/api/structured-output' | '/api/summarize' | '/api/tanchat' | '/api/transcribe' + | '/api/workflow' | '/generations/audio' | '/generations/image' | '/generations/speech' @@ -260,12 +304,16 @@ export interface FileRouteTypes { | '__root__' | '/' | '/image-gen' + | '/orchestration' | '/realtime' + | '/workflow' | '/api/image-gen' + | '/api/orchestration' | '/api/structured-output' | '/api/summarize' | '/api/tanchat' | '/api/transcribe' + | '/api/workflow' | '/generations/audio' | '/generations/image' | '/generations/speech' @@ -284,12 +332,16 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute ImageGenRoute: typeof ImageGenRoute + OrchestrationRoute: typeof OrchestrationRoute RealtimeRoute: typeof RealtimeRoute + WorkflowRoute: typeof WorkflowRoute ApiImageGenRoute: typeof ApiImageGenRoute + ApiOrchestrationRoute: typeof ApiOrchestrationRoute ApiStructuredOutputRoute: typeof ApiStructuredOutputRoute ApiSummarizeRoute: typeof ApiSummarizeRoute ApiTanchatRoute: typeof ApiTanchatRoute ApiTranscribeRoute: typeof ApiTranscribeRoute + ApiWorkflowRoute: typeof ApiWorkflowRoute GenerationsAudioRoute: typeof GenerationsAudioRoute GenerationsImageRoute: typeof GenerationsImageRoute GenerationsSpeechRoute: typeof GenerationsSpeechRoute @@ -307,6 +359,13 @@ export interface RootRouteChildren { declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/workflow': { + id: '/workflow' + path: '/workflow' + fullPath: '/workflow' + preLoaderRoute: typeof WorkflowRouteImport + parentRoute: typeof rootRouteImport + } '/realtime': { id: '/realtime' path: '/realtime' @@ -314,6 +373,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof RealtimeRouteImport parentRoute: typeof rootRouteImport } + '/orchestration': { + id: '/orchestration' + path: '/orchestration' + fullPath: '/orchestration' + preLoaderRoute: typeof OrchestrationRouteImport + parentRoute: typeof rootRouteImport + } '/image-gen': { id: '/image-gen' path: '/image-gen' @@ -377,6 +443,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof GenerationsAudioRouteImport parentRoute: typeof rootRouteImport } + '/api/workflow': { + id: '/api/workflow' + path: '/api/workflow' + fullPath: '/api/workflow' + preLoaderRoute: typeof ApiWorkflowRouteImport + parentRoute: typeof rootRouteImport + } '/api/transcribe': { id: '/api/transcribe' path: '/api/transcribe' @@ -405,6 +478,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiStructuredOutputRouteImport parentRoute: typeof rootRouteImport } + '/api/orchestration': { + id: '/api/orchestration' + path: '/api/orchestration' + fullPath: '/api/orchestration' + preLoaderRoute: typeof ApiOrchestrationRouteImport + parentRoute: typeof rootRouteImport + } '/api/image-gen': { id: '/api/image-gen' path: '/api/image-gen' @@ -460,12 +540,16 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, ImageGenRoute: ImageGenRoute, + OrchestrationRoute: OrchestrationRoute, RealtimeRoute: RealtimeRoute, + WorkflowRoute: WorkflowRoute, ApiImageGenRoute: ApiImageGenRoute, + ApiOrchestrationRoute: ApiOrchestrationRoute, ApiStructuredOutputRoute: ApiStructuredOutputRoute, ApiSummarizeRoute: ApiSummarizeRoute, ApiTanchatRoute: ApiTanchatRoute, ApiTranscribeRoute: ApiTranscribeRoute, + ApiWorkflowRoute: ApiWorkflowRoute, GenerationsAudioRoute: GenerationsAudioRoute, GenerationsImageRoute: GenerationsImageRoute, GenerationsSpeechRoute: GenerationsSpeechRoute, diff --git a/examples/ts-react-chat/src/routes/api.orchestration.ts b/examples/ts-react-chat/src/routes/api.orchestration.ts new file mode 100644 index 000000000..293b6e241 --- /dev/null +++ b/examples/ts-react-chat/src/routes/api.orchestration.ts @@ -0,0 +1,30 @@ +import { createFileRoute } from '@tanstack/react-router' +import { toServerSentEventsResponse } from '@tanstack/ai' +import { + inMemoryRunStore, + parseWorkflowRequest, + runWorkflow, +} from '@tanstack/ai-orchestration' +import { featureOrchestrator } from '@/lib/workflows/orchestrator' + +const runStore = inMemoryRunStore({ ttl: 60 * 60 * 1000 }) + +export const Route = createFileRoute('/api/orchestration')({ + server: { + handlers: { + POST: async ({ request }) => { + const params = await parseWorkflowRequest(request) + if (params.abort && params.runId) { + runStore.getLive(params.runId)?.abortController.abort() + return new Response(null, { status: 204 }) + } + const stream = runWorkflow({ + runStore, + workflow: featureOrchestrator, + ...params, + }) + return toServerSentEventsResponse(stream) + }, + }, + }, +}) diff --git a/examples/ts-react-chat/src/routes/api.workflow.ts b/examples/ts-react-chat/src/routes/api.workflow.ts new file mode 100644 index 000000000..f1581f934 --- /dev/null +++ b/examples/ts-react-chat/src/routes/api.workflow.ts @@ -0,0 +1,31 @@ +import { createFileRoute } from '@tanstack/react-router' +import { toServerSentEventsResponse } from '@tanstack/ai' +import { + inMemoryRunStore, + parseWorkflowRequest, + runWorkflow, +} from '@tanstack/ai-orchestration' +import { articleWorkflow } from '@/lib/workflows/article-workflow' + +// Process-local store. Survives across requests; lost on restart. +const runStore = inMemoryRunStore({ ttl: 60 * 60 * 1000 }) + +export const Route = createFileRoute('/api/workflow')({ + server: { + handlers: { + POST: async ({ request }) => { + const params = await parseWorkflowRequest(request) + if (params.abort && params.runId) { + runStore.getLive(params.runId)?.abortController.abort() + return new Response(null, { status: 204 }) + } + const stream = runWorkflow({ + runStore, + workflow: articleWorkflow, + ...params, + }) + return toServerSentEventsResponse(stream) + }, + }, + }, +}) diff --git a/examples/ts-react-chat/src/routes/orchestration.tsx b/examples/ts-react-chat/src/routes/orchestration.tsx new file mode 100644 index 000000000..5fc8cc874 --- /dev/null +++ b/examples/ts-react-chat/src/routes/orchestration.tsx @@ -0,0 +1,952 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' +import { parsePartialJSON } from '@tanstack/ai' +import { fetchWorkflowEvents, useOrchestration } from '@tanstack/ai-react' +import type { WorkflowStep } from '@tanstack/ai-client' +import type { FileEntry } from '@/components/FileTreePanel' +import { CodeBlock } from '@/components/CodeBlock' +import { FileTreePanel } from '@/components/FileTreePanel' + +export const Route = createFileRoute('/orchestration')({ + component: OrchestrationPage, +}) + +interface OrchState { + phase?: string + spec?: { title: string; summary: string; files: Array } + result?: { + patches: Array<{ filename: string; patch: string }> + rationale: string + } + lastUserMessage?: string +} + +interface SessionEntry { + id: string + userMessage: string + /** Captured at the moment the user submitted, so the log keeps prior runs intact. */ + steps: Array + /** Final orchestrator output, when the run finishes. */ + finalResult?: OrchState['result'] + /** Set if the run errored out. */ + error?: { message: string; code?: string } +} + +const PROMPT = '~/feature-orchestrator $' + +interface OrchestratorRequest { + userMessage: string + previousSpec?: OrchState['spec'] + previousResult?: OrchState['result'] +} + +function OrchestrationPage() { + const [input, setInput] = useState( + 'add a /metrics endpoint to my Express app', + ) + // Past completed/erred runs in this session. The active run is read straight + // from `orch.steps` etc.; once it finishes we snapshot it here so subsequent + // runs append rather than overwrite. + const [history, setHistory] = useState>([]) + // Carries the most recent successful run's spec/result so the NEXT + // submission is treated as a refinement (the orchestrator initializes + // mid-flow and triage routes the new message to 'spec' refine). + const [carryover, setCarryover] = useState<{ + spec?: OrchState['spec'] + result?: OrchState['result'] + } | null>(null) + + const orch = useOrchestration({ + connection: fetchWorkflowEvents('/api/orchestration'), + }) + + const isRunning = orch.status === 'running' + const isPaused = orch.status === 'paused' + const isBusy = isRunning || isPaused + const isDone = + orch.status === 'finished' || + orch.status === 'error' || + orch.status === 'aborted' + + // Snapshot the run into history on terminal status transitions so the log + // accumulates across sessions instead of being wiped on the next start(). + // Also stash the run's spec/result as `carryover` so the next submission + // can refine it instead of starting from scratch. + const snapshottedRunIdRef = useRef(null) + useEffect(() => { + if (!isDone) return + if (!orch.runId || snapshottedRunIdRef.current === orch.runId) return + snapshottedRunIdRef.current = orch.runId + setHistory((h) => [ + ...h, + { + id: orch.runId!, + userMessage: orch.state?.lastUserMessage ?? '(no message)', + steps: orch.steps, + finalResult: orch.state?.result, + error: orch.error + ? { message: orch.error.message, code: orch.error.code } + : undefined, + }, + ]) + if (orch.status === 'finished' && orch.state?.spec) { + setCarryover({ + spec: orch.state.spec, + result: orch.state.result, + }) + } + }, [ + isDone, + orch.runId, + orch.state?.lastUserMessage, + orch.state?.spec, + orch.state?.result, + orch.steps, + orch.status, + orch.error, + ]) + + // Partial-parse the active step's structured-output stream so spec/coder + // output fills in live as JSON arrives. + const liveSpec = useMemo(() => { + if (orch.currentStep?.stepName !== 'spec') return undefined + const parsed = parsePartialJSON(orch.currentText) as + | { spec?: { title?: string; summary?: string; files?: Array } } + | undefined + return parsed?.spec + }, [orch.currentStep, orch.currentText]) + + const liveCoderPatch = useMemo(() => { + if (orch.currentStep?.stepName !== 'coder') return undefined + return parsePartialJSON(orch.currentText) as + | { filename?: string; patch?: string } + | undefined + }, [orch.currentStep, orch.currentText]) + + // Patches that should appear in the right-side file tree: every finished + // coder step in the active run, plus the partial-parsed in-flight one (if + // any). Resets when a new run starts because `orch.steps` resets. + const filesForPanel = useMemo>(() => { + const files: Array = [] + for (const step of orch.steps) { + if ( + step.stepName === 'coder' && + step.status === 'finished' && + step.result && + typeof step.result === 'object' + ) { + const r = step.result as { filename?: string; patch?: string } + if (typeof r.filename === 'string' && typeof r.patch === 'string') { + files.push({ filename: r.filename, patch: r.patch }) + } + } + } + if ( + orch.currentStep?.stepName === 'coder' && + liveCoderPatch && + typeof liveCoderPatch.filename === 'string' + ) { + files.push({ + filename: liveCoderPatch.filename, + patch: liveCoderPatch.patch ?? '', + streaming: true, + }) + } + return files + }, [orch.steps, orch.currentStep, liveCoderPatch]) + + const submit = useCallback(() => { + if (!input.trim() || isBusy) return + const msg = input.trim() + // `:reset` / `:clear` drops the carryover so the next message starts a + // brand-new orchestration instead of refining the prior result. + if (msg === ':reset' || msg === ':clear') { + setCarryover(null) + setInput('') + return + } + setInput('') + void orch.start({ + userMessage: msg, + previousSpec: carryover?.spec, + previousResult: carryover?.result, + }) + }, [input, isBusy, orch, carryover]) + + const logRef = useRef(null) + useLayoutEffect(() => { + const el = logRef.current + if (!el) return + el.scrollTop = el.scrollHeight + }, [ + orch.steps.length, + orch.currentText, + orch.status, + history.length, + orch.pendingApproval, + ]) + + const inputRef = useRef(null) + useEffect(() => { + if (!isBusy && !orch.pendingApproval) inputRef.current?.focus() + }, [isBusy, orch.pendingApproval]) + + // Ctrl+C cancels the run. We only intercept the keystroke when (a) the run + // is busy and (b) the user isn't trying to copy a text selection — that + // way the conventional copy shortcut still works on the terminal log. + // Cmd+. is also accepted for ergonomics on macOS, where Cmd+C is firmly + // owned by copy. + useEffect(() => { + if (!isBusy) return + const onKey = (e: KeyboardEvent) => { + const cmdDot = e.metaKey && e.key === '.' + const ctrlC = + (e.ctrlKey || e.metaKey) && + (e.key === 'c' || e.key === 'C') && + !e.shiftKey && + !e.altKey + if (!cmdDot && !ctrlC) return + if (ctrlC) { + const selection = window.getSelection()?.toString() ?? '' + if (selection.trim().length > 0) return + } + e.preventDefault() + orch.stop() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [isBusy, orch]) + + return ( +
    + + +
    +
    + + + {history.map((entry) => ( + + ))} + + {orch.runId && !history.some((h) => h.id === orch.runId) && ( + + orch.approve(approved, feedback) + } + error={orch.error} + finalResult={ + orch.status === 'finished' ? orch.state?.result : undefined + } + phase={orch.state?.phase} + /> + )} + + orch.stop() : undefined} + disabled={isBusy || !!orch.pendingApproval} + refining={!!carryover && !isBusy} + onResetCarryover={carryover ? () => setCarryover(null) : undefined} + /> +
    + + +
    +
    + ) +} + +// ============================================================================ +// Terminal chrome +// ============================================================================ + +function TerminalChrome(props: { status: string; runId: string | null }) { + return ( +
    + + + + + + + feature-orchestrator — {props.runId ? props.runId.slice(-12) : 'idle'} + + + + {props.status} + +
    + ) +} + +function StatusDot(props: { status: string }) { + const cls = + props.status === 'running' + ? 'bg-citron anim-citron-pulse' + : props.status === 'paused' + ? 'bg-citron' + : props.status === 'error' || props.status === 'aborted' + ? 'bg-rust' + : props.status === 'finished' + ? 'bg-moss' + : 'bg-taupe-deep' + return +} + +function BootBanner() { + return ( +
    +
    TanStack AI · Feature Orchestrator v1.0
    +
    + Type a feature request below. The orchestrator will draft a spec, ask + for approval, generate patches, then review. +
    + +
    + ) +} + +function Divider() { + return ( +
    + ──────────────────────────────────────────────────────────────────── +
    + ) +} + +// ============================================================================ +// Session rendering +// ============================================================================ + +function ClosedSession(props: { entry: SessionEntry }) { + return ( +
    + + {props.entry.steps.map((step) => ( + + ))} + {props.entry.error ? ( +
    + ✗ run failed: {props.entry.error.message} +
    + ) : props.entry.finalResult ? ( + + ) : null} + +
    + ) +} + +function ActiveSession(props: { + userMessage: string | undefined + steps: Array + currentStep: WorkflowStep | null + currentText: string + liveSpec: + | { title?: string; summary?: string; files?: Array } + | undefined + liveCoderPatch: { filename?: string; patch?: string } | undefined + pendingApproval: { title: string; description?: string } | null | undefined + onApprove: (approved: boolean, feedback?: string) => void + error: { message: string; code?: string } | null | undefined + finalResult: OrchState['result'] + phase: string | undefined +}) { + return ( +
    + + + {props.steps.map((step) => { + const isActive = props.currentStep?.stepId === step.stepId + return ( + + ) + })} + + {props.pendingApproval && ( + props.onApprove(true, feedback)} + onDeny={(feedback) => props.onApprove(false, feedback)} + /> + )} + + {props.error && ( +
    + ✗ run failed: {props.error.message} +
    + )} + + {props.finalResult && } + + {props.phase === 'done' && !props.finalResult && ( +
    ✓ done
    + )} +
    + ) +} + +function UserPromptLine(props: { message: string }) { + return ( +
    + {PROMPT} + {props.message} +
    + ) +} + +// ============================================================================ +// Step rendering +// ============================================================================ + +function StepBlock(props: { + step: WorkflowStep + active: boolean + liveSpec?: + | { title?: string; summary?: string; files?: Array } + | undefined + liveCoderPatch?: { filename?: string; patch?: string } | undefined +}) { + const { step, active } = props + const duration = + step.finishedAt && step.startedAt ? step.finishedAt - step.startedAt : null + + return ( +
    + + +
    + ) +} + +function StepHeader(props: { + step: WorkflowStep + active: boolean + duration: number | null +}) { + const { step, active, duration } = props + const icon = + step.status === 'failed' ? '✗' : step.status === 'finished' ? '✓' : '›' + const iconColor = + step.status === 'failed' + ? 'text-rust' + : step.status === 'finished' + ? 'text-moss' + : 'text-citron' + return ( +
    + {icon} + [{step.stepName}] + + {step.stepType?.replace('-', '·')} + + {active && running…} + + {duration !== null ? `${duration}ms` : ''} + +
    + ) +} + +function StepBody(props: { + step: WorkflowStep + active: boolean + liveSpec?: + | { title?: string; summary?: string; files?: Array } + | undefined + liveCoderPatch?: { filename?: string; patch?: string } | undefined +}) { + const { step, active } = props + + // Active spec step — render the partial-parsed spec as it streams. + if (active && step.stepName === 'spec' && props.liveSpec) { + return + } + // Active coder step — render the partial patch. + if (active && step.stepName === 'coder' && props.liveCoderPatch) { + return + } + // Finished step result rendering. + if (step.status === 'finished' && step.result !== undefined) { + return + } + if (step.status === 'failed' && step.result !== undefined) { + return ( +
    {stringifyResult(step.result)}
    + ) + } + return null +} + +function FinishedStepBody(props: { step: WorkflowStep }) { + const { step } = props + const result = step.result as Record + + if (step.stepName === 'spec' && isSpecOutput(result)) { + return + } + if (step.stepName === 'coder' && isPatchOutput(result)) { + return + } + if (step.stepName === 'planner' && isPlannerOutput(result)) { + return ( +
    +
    + files: + {result.files.join(', ')} +
    +
    {result.rationale}
    +
    + ) + } + if (step.stepName === 'triage' && isTriageOutput(result)) { + return ( +
    + → next: {result.next} + — {result.reason} +
    + ) + } + if (step.stepName === 'review' && isReviewOutput(result)) { + return ( +
    + verdict:{' '} + + {result.verdict} + +
    {result.notes}
    +
    + ) + } + if (step.stepName === 'approval' && isApprovalOutput(result)) { + return ( +
    + {result.approved ? ( + ✓ approved + ) : ( + ✗ denied + )} + {result.feedback && ( + + — “{result.feedback}” + + )} +
    + ) + } + // Fallback: dim JSON + return ( +
    +      {stringifyResult(result)}
    +    
    + ) +} + +function SpecLine(props: { + spec: { title?: string; summary?: string; files?: Array } + streaming?: boolean +}) { + return ( +
    +
    + title: + {props.spec.title ?? } +
    + {props.spec.summary && ( +
    {props.spec.summary}
    + )} + {props.spec.files && props.spec.files.length > 0 && ( +
    + files: + {props.spec.files.map((f, i) => ( +
    + · {f} +
    + ))} +
    + )} + {props.streaming && } +
    + ) +} + +function PatchLine(props: { + patch: { filename?: string; patch?: string } + streaming?: boolean +}) { + return ( +
    +
    + file: + {props.patch.filename ?? '…'} +
    +
    + +
    +
    + ) +} + +// ============================================================================ +// Inline approval prompt +// ============================================================================ + +/** + * Approval prompt. + * + * Accepts free-text input. Parsing rules: + * - `y` / `yes` → approve + * - `n` / `no` → deny (no feedback) + * - `n: ` → deny with feedback (refines the spec) + * - `` → deny with that text as feedback + * + * Empty submits do nothing — the user must explicitly approve or deny. + */ +function ApprovalInline(props: { + title: string + description?: string + onApprove: (feedback?: string) => void + onDeny: (feedback?: string) => void +}) { + const inputRef = useRef(null) + const [value, setValue] = useState('') + useEffect(() => { + inputRef.current?.focus() + }, []) + + const parseAndDispatch = (raw: string) => { + const trimmed = raw.trim() + if (!trimmed) return + const lower = trimmed.toLowerCase() + if (lower === 'y' || lower === 'yes') { + props.onApprove() + return + } + if (lower === 'n' || lower === 'no') { + props.onDeny() + return + } + // `n: ` shorthand. + const denyPrefix = trimmed.match(/^n(?:o)?\s*[:,-]\s*(.+)$/i) + if (denyPrefix) { + props.onDeny(denyPrefix[1].trim()) + return + } + // Anything else = denial with the raw text as feedback. Lets the user + // just type "use fastify instead" without the `n:` prefix. + props.onDeny(trimmed) + } + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault() + parseAndDispatch(value) + } + + return ( +
    +
    + [?] + {props.title} +
    + {props.description && ( +
    {props.description}
    + )} +
    + type y to approve,{' '} + n to deny, or free text to deny with + refinement notes. +
    +
    + approve? › + + + +
    +
    + ) +} + +// ============================================================================ +// Final summary (rendered after a run finishes) +// ============================================================================ + +function FinalSummary(props: { result: OrchState['result'] }) { + if (!props.result) return null + return ( +
    +
    ✓ implementation finished
    +
    “{props.result.rationale}”
    +
    + {props.result.patches.length} patches —{' '} + {props.result.patches.map((p) => p.filename).join(', ')} +
    +
    + ) +} + +// ============================================================================ +// Prompt line (user input) +// ============================================================================ + +const PromptLine = forwardRefPromptLine() + +function forwardRefPromptLine() { + // Use a small helper so we can keep `forwardRef` import-free elsewhere in + // this file and still expose an imperative focus handle. + return function PromptLine(props: { + ref: React.RefObject + value: string + onValueChange: (v: string) => void + onSubmit: () => void + onStop?: (() => void) | undefined + disabled: boolean + refining: boolean + onResetCarryover?: (() => void) | undefined + }) { + return ( +
    + {props.refining && ( +
    + ✓ session carries previous spec + · + + your next message refines it (type{' '} + :reset to start fresh + {props.onResetCarryover && ( + <> + {' '} + or{' '} + + + )} + ) + +
    + )} +
    + + {props.refining ? '~/feature-orchestrator (refining) $' : PROMPT} + + + {props.onStop && ( + + )} +
    +
    + ) + } +} + +/** + * Single-line "terminal" input: renders the value in flow as plain text with a + * blinking caret directly after it, while an absolutely-positioned transparent + * textarea captures keystrokes. The native caret is hidden — the visible + * ▌ block is the cursor. + * + * Wraps on overflow (the textarea grows vertically); the caret is always + * positioned at the end of the value, not at the actual selection. Good + * enough for a terminal-style prompt where typing always appends. + */ +function InlineTerminalInput(props: { + inputRef: React.RefObject + value: string + onValueChange: (v: string) => void + onSubmit: () => void + disabled: boolean + placeholder: string +}) { + const showPlaceholder = !props.value && !props.disabled + return ( +