diff --git a/examples/ts-react-chat/package.json b/examples/ts-react-chat/package.json index 60cfe6836..45435b76c 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", 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/DraftPreview.tsx b/examples/ts-react-chat/src/components/DraftPreview.tsx new file mode 100644 index 000000000..32deabdae --- /dev/null +++ b/examples/ts-react-chat/src/components/DraftPreview.tsx @@ -0,0 +1,116 @@ +import { useEffect, useRef, useState } from 'react' + +interface Draft { + title?: string + paragraphs?: Array +} + +export function DraftPreview(props: { draft: unknown; phase?: string }) { + 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/Header.tsx b/examples/ts-react-chat/src/components/Header.tsx index 4cd9fc4d8..8b0dde409 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/workflows/article-workflow.ts b/examples/ts-react-chat/src/lib/workflows/article-workflow.ts new file mode 100644 index 000000000..7db5e8f01 --- /dev/null +++ b/examples/ts-react-chat/src/lib/workflows/article-workflow.ts @@ -0,0 +1,175 @@ +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, + 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, + 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, + 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..e86f8a137 --- /dev/null +++ b/examples/ts-react-chat/src/lib/workflows/orchestrator.ts @@ -0,0 +1,267 @@ +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(), + lastUserMessage: z.string().default(''), +}) + +const OrchestratorInput = z.object({ userMessage: z.string() }) +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(), + }), + systemPrompts: [ + 'Given a feature request, refine it into a concrete spec with title, summary, and files to change. Mark ready=true when the spec is implementation-ready.', + ], + messages: [ + { + role: 'user', + content: + `Feature request: ${input.userMessage}\n\n` + + (input.existingSpec + ? `Existing draft: ${JSON.stringify(input.existingSpec)}` + : ''), + }, + ], + }), +}) + +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(), + }), + 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, + systemPrompts: [ + 'Generate a unified-diff-style patch for the given file based on the spec. Use a markdown code block in the `patch` field.', + ], + 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(), + }), + 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({ + userMessage: 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(), + }), + systemPrompts: [ + 'Decide the next phase given current state. Phases: spec (refine the spec), await-approval (request user OK to implement), implement (run code generation), review (read user feedback), done (finish).', + ], + 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, input, state }) { + const triage = yield* agents.triage({ + hasResult: !!state.result, + hasSpec: !!state.spec, + phase: state.phase, + userMessage: state.lastUserMessage || input.userMessage, + }) + + 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.lastUserMessage } } + } + + if (triage.next === 'await-approval') { + const approval = yield* approve({ + description: state.spec + ? `Spec ready: "${state.spec.title}". Begin implementing?` + : '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 } } + } + state.phase = 'scoping' + return { agent: 'spec', input: { userMessage: state.lastUserMessage } } + } + + 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, + initialize: ({ input }) => ({ lastUserMessage: 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..86d50b337 --- /dev/null +++ b/examples/ts-react-chat/src/routes/api.orchestration.ts @@ -0,0 +1,26 @@ +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) + 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..facf7aa2b --- /dev/null +++ b/examples/ts-react-chat/src/routes/api.workflow.ts @@ -0,0 +1,27 @@ +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) + 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..fe0ebd92d --- /dev/null +++ b/examples/ts-react-chat/src/routes/orchestration.tsx @@ -0,0 +1,364 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useState } from 'react' +import { fetchWorkflowEvents, useOrchestration } from '@tanstack/ai-react' +import { StateInspector } from '@/components/StateInspector' +import { WorkflowTimeline } from '@/components/WorkflowTimeline' + +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 +} + +function OrchestrationPage() { + const [message, setMessage] = useState( + 'add a /metrics endpoint to my Express app', + ) + + const orch = useOrchestration<{ userMessage: string }, unknown, OrchState>({ + connection: fetchWorkflowEvents('/api/orchestration'), + }) + + const isRunning = orch.status === 'running' || orch.status === 'paused' + const phase = orch.state?.phase ?? 'idle' + + return ( +
    + + + orch.start({ userMessage: message })} + onStop={() => orch.stop()} + disabled={isRunning} + canStop={isRunning} + /> + + {orch.pendingApproval && ( + orch.approve(true)} + onDeny={() => orch.approve(false)} + /> + )} + +
    + + +
    + + {orch.state?.spec && phase !== 'done' && ( + + )} + + {orch.state?.result && ( + + )} + + {orch.error && ( +
    +
    runtime error
    +
    + {orch.error.message} +
    +
    + )} + + +
    + ) +} + +function Masthead(props: { + status: string + phase: string + runId: string | null +}) { + return ( +
    +
    + + Volume I · Orchestrator No. 02 + + + {props.runId ? props.runId.slice(-12) : '—'} + +
    +
    +

    + Feature +
    + + Orchestrator + +

    + +
    + +
    + + + status — {props.status} + +
    +
    + +
    +
    + ) +} + +function PhaseBadge(props: { phase: string }) { + const labels: Array<{ key: string; label: string }> = [ + { key: 'scoping', label: 'Scoping' }, + { key: 'awaiting-approval', label: 'Awaiting' }, + { key: 'implementing', label: 'Implementing' }, + { key: 'review', label: 'Review' }, + { key: 'done', label: 'Done' }, + ] + return ( +
      + {labels.map((l, i) => { + const isCurrent = l.key === props.phase + return ( +
    1. + + {String(i + 1).padStart(2, '0')} · {l.label} + + {i < labels.length - 1 && /} +
    2. + ) + })} +
    + ) +} + +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 Composer(props: { + message: string + onMessageChange: (m: string) => void + onRun: () => void + onStop: () => void + disabled: boolean + canStop: boolean +}) { + return ( +
    + +
    + props.onMessageChange(e.target.value)} + disabled={props.disabled} + className="flex-1 bg-transparent border-b-2 border-bone focus:border-citron outline-none px-1 py-3 text-2xl text-bone placeholder:text-taupe-deep transition-colors disabled:opacity-50" + style={{ + fontFamily: 'var(--font-display)', + fontVariationSettings: "'opsz' 36, 'SOFT' 50, 'WONK' 0", + }} + placeholder="Describe what you want built…" + /> + + {props.canStop && ( + + )} +
    +
    + ) +} + +function ApprovalBand(props: { + title: string + description?: string + onApprove: () => void + onDeny: () => void +}) { + return ( +
    +
    +
    +
    +
    decision required
    +

    + {props.title} +

    + {props.description && ( +

    + {props.description} +

    + )} +
    +
    + + +
    +
    +
    +
    + ) +} + +function SpecReadout(props: { + spec: { title: string; summary: string; files: Array } +}) { + return ( +
    +
    + Spec Draft + + {props.spec.files.length} files + +
    +

    + {props.spec.title} +

    +

    + {props.spec.summary} +

    +
      + {props.spec.files.map((f) => ( +
    • + + {f} +
    • + ))} +
    +
    + ) +} + +function ImplementationReadout(props: { + result: { + patches: Array<{ filename: string; patch: string }> + rationale: string + } +}) { + return ( +
    +
    + Implementation + + {props.result.patches.length} patches + +
    +

    + “{props.result.rationale}” +

    +
    + {props.result.patches.map((p, i) => ( +
    +
    + + {String(i + 1).padStart(2, '0')} + + {p.filename} +
    +
    +              {p.patch}
    +            
    +
    + ))} +
    +
    + ) +} + +function Colophon() { + return ( +
    +
    + TanStack AI · Orchestration + Set in Fraunces & JetBrains Mono +
    +
    + ) +} diff --git a/examples/ts-react-chat/src/routes/workflow.tsx b/examples/ts-react-chat/src/routes/workflow.tsx new file mode 100644 index 000000000..8136c69c8 --- /dev/null +++ b/examples/ts-react-chat/src/routes/workflow.tsx @@ -0,0 +1,360 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useEffect, useRef, useState } from 'react' +import { fetchWorkflowEvents, useWorkflow } from '@tanstack/ai-react' +import { ArticleModal } from '@/components/ArticleModal' +import { DraftPreview } from '@/components/DraftPreview' +import { StateInspector } from '@/components/StateInspector' +import { WorkflowTimeline } from '@/components/WorkflowTimeline' + +interface ArticleState { + phase?: string + draft?: { title: string; paragraphs: Array } +} + +export const Route = createFileRoute('/workflow')({ + component: WorkflowPage, +}) + +type ArticleOutput = + | { ok: true; article: { title: string; paragraphs: Array } } + | { ok: false; reason: string } + +function WorkflowPage() { + const [topic, setTopic] = useState('the cultural history of pufferfish') + + const wf = useWorkflow<{ topic: string }, ArticleOutput, ArticleState>({ + connection: fetchWorkflowEvents('/api/workflow'), + }) + + const isRunning = wf.status === 'running' || wf.status === 'paused' + const finalResult = ( + wf.status === 'finished' ? wf.output : null + ) as ArticleOutput | null + + // Auto-open the modal when the workflow finalizes successfully. Local + // dismiss state lets the user close it; re-running re-opens. + const [modalOpen, setModalOpen] = useState(false) + const lastRunIdRef = useRef(null) + useEffect(() => { + if ( + finalResult && + finalResult.ok && + wf.runId && + wf.runId !== lastRunIdRef.current + ) { + lastRunIdRef.current = wf.runId + setModalOpen(true) + } + if (wf.status === 'idle' || wf.status === 'running') { + // New run starting — clear the modal-shown marker so the next finish opens it. + if (wf.runId !== lastRunIdRef.current) { + // keep marker; only reset when user explicitly starts again with a different runId + } + } + }, [finalResult, wf.runId, wf.status]) + + return ( +
    + + + wf.start({ topic })} + onStop={() => wf.stop()} + disabled={isRunning} + canStop={isRunning} + /> + + {wf.pendingApproval && ( + wf.approve(true)} + onRevise={(feedback) => wf.approve(false, feedback)} + onDiscard={() => wf.approve(false)} + /> + )} + +
    + +
    + +
    + + + ▸ + + State Snapshot + +
    + +
    +
    +
    +
    + + {finalResult && finalResult.ok && ( + <> + setModalOpen(true)} + /> + {modalOpen && ( + setModalOpen(false)} + /> + )} + + )} + + {finalResult && finalResult.ok === false && ( + + )} + + {wf.error && ( +
    +
    runtime error
    +
    {wf.error.message}
    +
    + )} + + +
    + ) +} + +function Masthead(props: { status: string; runId: string | null }) { + return ( +
    +
    + + Volume I · Pipeline No. 01 + + + {props.runId ? props.runId.slice(-12) : '—'} + +
    +
    +

    + Article +
    + + Pipeline + +

    +
    + + + status — {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 Composer(props: { + topic: string + onTopicChange: (t: string) => void + onRun: () => void + onStop: () => void + disabled: boolean + canStop: boolean +}) { + return ( +
    + +
    + props.onTopicChange(e.target.value)} + disabled={props.disabled} + className="flex-1 bg-transparent border-b-2 border-bone focus:border-citron outline-none px-1 py-3 text-2xl text-bone placeholder:text-taupe-deep transition-colors disabled:opacity-50" + style={{ + fontFamily: 'var(--font-display)', + fontVariationSettings: "'opsz' 36, 'SOFT' 50, 'WONK' 0", + }} + placeholder="What should we write about?" + /> + + {props.canStop && ( + + )} +
    +
    + ) +} + +function ApprovalBand(props: { + title: string + description?: string + onPublish: () => void + onRevise: (feedback: string) => void + onDiscard: () => void +}) { + const textareaRef = useRef(null) + const [feedback, setFeedback] = useState('') + + return ( +
    +
    +
    +
    +
    decision required
    +

    + {props.title} +

    + {props.description && ( +

    + {props.description} +

    + )} +