diff --git a/examples/invoices-structured-output/.gitignore b/examples/invoices-structured-output/.gitignore new file mode 100644 index 000000000..5ef6a5207 --- /dev/null +++ b/examples/invoices-structured-output/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/examples/invoices-structured-output/README.md b/examples/invoices-structured-output/README.md new file mode 100644 index 000000000..31ef7f4e5 --- /dev/null +++ b/examples/invoices-structured-output/README.md @@ -0,0 +1,40 @@ +# Invoice Structured Output Example + +A Next.js app demonstrating `@openuidev/structured-output` — the standalone OpenUI Lang library for schema-driven structured output from LLMs. + +## What it does + +1. **Select an invoice format** — Standard, Freelance, or International +2. **Describe the invoice** — or use the provided sample prompt +3. **Watch the LLM stream** — the raw OpenUI Lang appears on the left as tokens arrive +4. **See parsed JSON** — the streaming parser progressively builds the validated JSON on the right + +The backend generates a system prompt from the Zod schema using `schema.prompt()`, sends it to the LLM, and streams back the raw OpenUI Lang. The frontend uses `schema.streamingParser()` to parse and validate the output in real time. + +## Setup + +```bash +# From the monorepo root +pnpm install + +# Add your OpenRouter API key +cp examples/invoices-structured-output/.env.example examples/invoices-structured-output/.env.local +# Edit .env.local with your key + +# Run the dev server +pnpm --filter invoices-structured-output dev +``` + +## Invoice Formats + +| Format | Description | +| --- | --- | +| **Standard** | Simple business invoice with line items, tax rate, and totals | +| **Freelance** | Consulting invoice with hourly time entries and project details | +| **International** | Multi-currency invoice with addresses, tax breakdown, and shipping | + +## Key files + +- `src/app/schemas.ts` — Invoice type definitions using `defineType` and `createSchema` +- `src/app/api/generate/route.ts` — API route that generates the prompt and streams LLM output +- `src/app/page.tsx` — Frontend with format picker, streaming display, and parsed JSON view diff --git a/examples/invoices-structured-output/eslint.config.mjs b/examples/invoices-structured-output/eslint.config.mjs new file mode 100644 index 000000000..05e726d1b --- /dev/null +++ b/examples/invoices-structured-output/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/examples/invoices-structured-output/next.config.ts b/examples/invoices-structured-output/next.config.ts new file mode 100644 index 000000000..596190563 --- /dev/null +++ b/examples/invoices-structured-output/next.config.ts @@ -0,0 +1,8 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + transpilePackages: ["@openuidev/structured-output"], + serverExternalPackages: ["openai"], +}; + +export default nextConfig; diff --git a/examples/invoices-structured-output/package.json b/examples/invoices-structured-output/package.json new file mode 100644 index 000000000..86ea4d38f --- /dev/null +++ b/examples/invoices-structured-output/package.json @@ -0,0 +1,29 @@ +{ + "name": "invoices-structured-output", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "@openuidev/structured-output": "workspace:^", + "next": "16.1.6", + "openai": "^6.27.0", + "react": "19.2.3", + "react-dom": "19.2.3", + "zod": "^4.3.6" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.1.6", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/examples/invoices-structured-output/postcss.config.mjs b/examples/invoices-structured-output/postcss.config.mjs new file mode 100644 index 000000000..61e36849c --- /dev/null +++ b/examples/invoices-structured-output/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/examples/invoices-structured-output/src/app/api/generate/route.ts b/examples/invoices-structured-output/src/app/api/generate/route.ts new file mode 100644 index 000000000..1d442d69e --- /dev/null +++ b/examples/invoices-structured-output/src/app/api/generate/route.ts @@ -0,0 +1,82 @@ +import { NextRequest } from "next/server"; +import OpenAI from "openai"; +import { invoiceSchemaMap } from "../../schemas"; + +let _client: OpenAI | null = null; +function getClient() { + if (!_client) { + _client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); + } + return _client; +} + +const BUSINESS_PREAMBLE = `You are an expert accounting assistant that generates structured invoice data. +Given a description of an invoice, extract or generate all relevant fields with realistic data. +Use plausible business names, dates, amounts, and tax calculations. +All monetary amounts should be numbers (not strings). Dates should be in YYYY-MM-DD format. +Ensure line item amounts equal quantity × unitPrice. Ensure totals are arithmetically correct.`; + +export async function POST(req: NextRequest) { + const { userPrompt } = (await req.json()) as { + userPrompt: string; + }; + + const systemPrompt = invoiceSchemaMap.prompt({ + preamble: BUSINESS_PREAMBLE, + additionalRules: [ + "All dates must be YYYY-MM-DD format", + "Monetary amounts must be numbers with up to 2 decimal places", + "NEVER use arithmetic expressions like 8*125 or 10+20 — always write the pre-computed numeric result (e.g. 1000, 30)", + "Ensure mathematical consistency: subtotal = sum of item amounts, total = subtotal + tax + shipping", + ], + }); + + console.log(systemPrompt); + + const stream = await getClient().chat.completions.create({ + model: "gpt-5", + stream: true, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt }, + ], + reasoning_effort: "minimal", + }); + + const encoder = new TextEncoder(); + + const readable = new ReadableStream({ + async start(controller) { + const send = (type: string, content: string) => + controller.enqueue(encoder.encode(JSON.stringify({ type, content }) + "\n")); + + try { + for await (const chunk of stream) { + const delta = chunk.choices?.[0]?.delta as Record | undefined; + if (!delta) continue; + + const thinking = + (delta.reasoning as string | undefined) ?? + (delta.reasoning_content as string | undefined); + if (thinking) send("thinking", thinking); + + const content = delta.content as string | undefined; + if (content) send("content", content); + } + } catch (err) { + const message = err instanceof Error ? err.message : "Stream error"; + send("error", message); + } finally { + controller.close(); + } + }, + }); + + return new Response(readable, { + headers: { + "Content-Type": "application/x-ndjson; charset=utf-8", + "Transfer-Encoding": "chunked", + "Cache-Control": "no-cache", + }, + }); +} diff --git a/examples/invoices-structured-output/src/app/favicon.ico b/examples/invoices-structured-output/src/app/favicon.ico new file mode 100644 index 000000000..718d6fea4 Binary files /dev/null and b/examples/invoices-structured-output/src/app/favicon.ico differ diff --git a/examples/invoices-structured-output/src/app/globals.css b/examples/invoices-structured-output/src/app/globals.css new file mode 100644 index 000000000..4d985b6e1 --- /dev/null +++ b/examples/invoices-structured-output/src/app/globals.css @@ -0,0 +1,5 @@ +@import "tailwindcss"; + +html { + font-size: 150%; +} diff --git a/examples/invoices-structured-output/src/app/layout.tsx b/examples/invoices-structured-output/src/app/layout.tsx new file mode 100644 index 000000000..d1e2d8562 --- /dev/null +++ b/examples/invoices-structured-output/src/app/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Invoice Structured Output", + description: "Generate structured invoice data using OpenUI Lang and streaming LLM output", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/examples/invoices-structured-output/src/app/page.tsx b/examples/invoices-structured-output/src/app/page.tsx new file mode 100644 index 000000000..e86b9c350 --- /dev/null +++ b/examples/invoices-structured-output/src/app/page.tsx @@ -0,0 +1,392 @@ +"use client"; + +import { useState, useRef, useCallback, useEffect } from "react"; +import type { ParseResult } from "@openuidev/structured-output"; +import { invoiceMeta, invoiceSchemaMap, type InvoiceFormat } from "./schemas"; + +type GenerationState = "idle" | "streaming" | "done" | "error"; + +const FORMAT_ICONS: Record = { + standard: "receipt", + freelance: "schedule", + international: "public", +}; + +export default function Page() { + const [format, setFormat] = useState("standard"); + const [userPrompt, setUserPrompt] = useState(invoiceMeta.standard.samplePrompt); + const [state, setState] = useState("idle"); + const [rawOutput, setRawOutput] = useState(""); + const [thinking, setThinking] = useState(""); + const [parsedResult, setParsedResult] = useState | null>(null); + const [errorMessage, setErrorMessage] = useState(""); + const [jsonTab, setJsonTab] = useState<"result" | "schema" | "systemPrompt">("result"); + + const abortRef = useRef(null); + const scrollRef = useRef(null); + + // Auto-scroll output when streaming + useEffect(() => { + if (state === "streaming" && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [rawOutput, thinking, parsedResult, state]); + + const handleGenerate = useCallback(async () => { + const entry = invoiceMeta[format]; + const prompt = userPrompt.trim() || entry.samplePrompt; + + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setState("streaming"); + setRawOutput(""); + setThinking(""); + setParsedResult(null); + setErrorMessage(""); + + try { + const res = await fetch("/api/generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ format, userPrompt: prompt }), + signal: controller.signal, + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error ?? `HTTP ${res.status}`); + } + + const reader = res.body?.getReader(); + if (!reader) throw new Error("No response body"); + + const decoder = new TextDecoder(); + const parser = invoiceSchemaMap.streamingParser(); + let accContent = ""; + let accThinking = ""; + let lineBuf = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + lineBuf += decoder.decode(value, { stream: true }); + const lines = lineBuf.split("\n"); + lineBuf = lines.pop() ?? ""; + + for (const line of lines) { + if (!line.trim()) continue; + try { + const evt = JSON.parse(line) as { type: string; content: string }; + if (evt.type === "thinking") { + accThinking += evt.content; + setThinking(accThinking); + } else if (evt.type === "content") { + accContent += evt.content; + setRawOutput(accContent); + const result = parser.push(evt.content); + setParsedResult({ ...result }); + } else if (evt.type === "error") { + throw new Error(evt.content); + } + } catch (e) { + if (e instanceof SyntaxError) continue; + throw e; + } + } + } + + const finalResult = parser.getResult(); + setParsedResult({ ...finalResult }); + setState("done"); + } catch (err) { + if ((err as Error).name === "AbortError") return; + setErrorMessage((err as Error).message); + setState("error"); + } + }, [format, userPrompt]); + + const handleStop = useCallback(() => { + abortRef.current?.abort(); + setState("done"); + }, []); + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + }; + + return ( +
+ + {/* Header */} +
+
+
+
+ + description + +
+
+

+ Invoice Extractor +

+

+ Natural language to structured output. Select an invoice type and click on "Extract Invoice" to see the structured JSON result. +

+
+
+ +
+
+ +
+ {/* Left Column: Input & Selection */} +
+ +
+
+

+ + Invoice in natural language +

+ +
+ + {/* Unified Input Card */} +
+