Ready
Network activity
0 requests
Connected - models run on-device
No recent network activity
0 groups0 total requests
Browse blocks
Data Extractor
Pull structured data out of free text using ready-made templates, with automatic retries when the output does not fit. See the result as a sortable table and a chart built from the numbers it found. It runs its own AI model in the browser and needs a graphics-capable device, and nothing downloads until you load it.
Install this block
npx shadcn@latest add @localmode/ui/blocks/agents/data-extractoridle - select a model and click Load
Medium (1-2.2GB)
Large (2.2GB+)
Checking WebGPU support…
Schema
{ name, email, phone?, company? }Cmd/Ctrl+Enter to extract
Pick a template, load or paste text, and extract to see the validated JSON here.
'use client';/** * @file data-extractor.tsx * @description Self-sufficient Data Extractor block — its own WebGPU-only WebLLM load plus schema-validated JSON extraction (5 zod templates, retry/self-correction) docked into the artifacts canvas (sortable table + chart). */import { useState } from 'react';import { AlertTriangle } from 'lucide-react';import { jsonSchema, type LanguageModel, type ObjectSchema } from '@localmode/core';import { useGenerateObject, useModelLoad } from '@localmode/react';import { webllm, WEBLLM_MODELS, isModelCached as webllmIsModelCached } from '@localmode/webllm';import { z } from 'zod';import { ModelSelector, type SelectableModel } from '@/components/model-selector';import { ModelDownloader } from '@/components/model-downloader';import { CapabilityGate } from '@/components/capability-gate';import { StructuredOutputViewer } from '@/components/structured-output-viewer';import { Artifact, ArtifactHeader, ArtifactTitle, ArtifactDescription, ArtifactActions, ArtifactAction, ArtifactContent,} from '@/components/artifact';import { DataTableArtifact } from '@/components/data-table-artifact';import { ChartArtifact } from '@/components/chart-artifact';import { InMessageError } from '@/components/in-message-error';import { useCapabilities } from '@/lib/use-environment';import { cn } from '@/lib/utils';export const DEFAULT_MODEL_ID = 'Qwen3-1.7B-q4f16_1-MLC';export type ModelTier = 'medium' | 'large';export interface AgentModelEntry { id: string; name: string; size: string; sizeBytes: number; tier: ModelTier; contextLength: number; description?: string;}const CURATED_MODEL_IDS: readonly string[] = [ 'Qwen3-1.7B-q4f16_1-MLC', 'Qwen2.5-3B-Instruct-q4f16_1-MLC', 'Llama-3.2-3B-Instruct-q4f16_1-MLC', 'Hermes-3-Llama-3.2-3B-q4f16_1-MLC', 'Phi-3.5-mini-instruct-q4f16_1-MLC', 'Phi-3-mini-4k-instruct-q4f16_1-MLC', 'Qwen3-4B-q4f16_1-MLC', 'Mistral-7B-Instruct-v0.3-q4f16_1-MLC', 'Qwen2.5-7B-Instruct-q4f16_1-MLC', 'DeepSeek-R1-Distill-Qwen-7B-q4f16_1-MLC', 'DeepSeek-R1-Distill-Llama-8B-q4f16_1-MLC', 'Hermes-3-Llama-3.1-8B-q4f16_1-MLC', 'Llama-3.1-8B-Instruct-q4f16_1-MLC', 'Qwen3-8B-q4f16_1-MLC',];interface WebLLMCatalogEntry { name: string; contextLength: number; sizeBytes: number; size: string; description: string;}const WEBLLM_ENTRIES = WEBLLM_MODELS as Record<string, WebLLMCatalogEntry>;const LARGE_TIER_BYTES = 2.2 * 1024 * 1024 * 1024;let cachedCatalog: AgentModelEntry[] | null = null;export function getAgentModelCatalog(): AgentModelEntry[] { if (cachedCatalog) return cachedCatalog; const entries: AgentModelEntry[] = []; for (const id of CURATED_MODEL_IDS) { const entry = WEBLLM_ENTRIES[id]; if (!entry) continue; entries.push({ id, name: entry.name, size: entry.size, sizeBytes: entry.sizeBytes, tier: entry.sizeBytes >= LARGE_TIER_BYTES ? 'large' : 'medium', contextLength: entry.contextLength, description: entry.description, }); } cachedCatalog = entries; return entries;}export function getModelEntry(id: string): AgentModelEntry { const found = getAgentModelCatalog().find((m) => m.id === id); if (found) return found; const entry = WEBLLM_ENTRIES[id]; return { id, name: entry?.name ?? id, size: entry?.size ?? '', sizeBytes: entry?.sizeBytes ?? 0, tier: (entry?.sizeBytes ?? 0) >= LARGE_TIER_BYTES ? 'large' : 'medium', contextLength: entry?.contextLength ?? 4096, description: entry?.description, };}export function createAgentModel( modelId: string, onProgress?: (p: unknown) => void,): LanguageModel { return webllm.languageModel(modelId, { onProgress });}export function isAgentModelCached(modelId: string): Promise<boolean> { return webllmIsModelCached(modelId);}export type TemplateId = 'contact' | 'event' | 'review' | 'recipe' | 'job';export interface ExtractionTemplate { id: TemplateId; name: string; schema: ObjectSchema<unknown>; schemaDisplay: string; sampleText: string;}const contactSchema = z.object({ name: z.string().describe('Full name'), email: z.string().describe('Email address'), phone: z.string().optional().describe('Phone number'), company: z.string().optional().describe('Company name'),});const eventSchema = z.object({ title: z.string().describe('Event title'), date: z.string().describe('Event date'), location: z.string().describe('Event location'), description: z.string().optional().describe('Brief description'),});const reviewSchema = z.object({ product: z.string().describe('Product name'), rating: z.number().describe('Rating from 1 to 5'), pros: z.array(z.string()).describe('List of positive aspects'), cons: z.array(z.string()).describe('List of negative aspects'),});const recipeSchema = z.object({ name: z.string().describe('Recipe name'), servings: z.number().describe('Number of servings'), ingredients: z .array( z.object({ item: z.string().describe('Ingredient name'), amount: z.string().describe('Amount with unit'), }), ) .describe('List of ingredients'), steps: z.array(z.string()).describe('Cooking steps'),});const jobSchema = z.object({ title: z.string().describe('Job title'), company: z.string().describe('Company name'), salary: z.string().optional().describe('Salary range'), requirements: z.array(z.string()).describe('Job requirements'), location: z.string().describe('Job location'),});export const DEFAULT_TEMPLATE_ID: TemplateId = 'contact';export const TEMPLATES: readonly ExtractionTemplate[] = [ { id: 'contact', name: 'Contact Info', schema: jsonSchema(contactSchema), schemaDisplay: '{ name, email, phone?, company? }', sampleText: 'Hi, my name is Sarah Chen. You can reach me at sarah.chen@acme.co or call 555-0147. I work at Acme Corporation as a Senior Engineer.', }, { id: 'event', name: 'Event Details', schema: jsonSchema(eventSchema), schemaDisplay: '{ title, date, location, description? }', sampleText: 'Join us for the Annual Tech Summit on March 15, 2027 at the SF Convention Center. This year we focus on AI, privacy, and the future of local-first computing.', }, { id: 'review', name: 'Product Review', schema: jsonSchema(reviewSchema), schemaDisplay: '{ product, rating, pros[], cons[] }', sampleText: "I bought the NovaPhone X200 last month. It's fantastic: the camera is incredible, battery lasts two days, and the display is gorgeous. However, it's quite heavy and the price is steep at $1200. I'd give it 4 out of 5 stars.", }, { id: 'recipe', name: 'Recipe', schema: jsonSchema(recipeSchema), schemaDisplay: '{ name, servings, ingredients[{item,amount}], steps[] }', sampleText: 'Classic Pancakes (serves 4): Mix 1.5 cups flour, 3.5 tsp baking powder, 1 tbsp sugar, and a pinch of salt. In another bowl, combine 1.25 cups milk, 1 egg, and 3 tbsp melted butter. Mix wet into dry until smooth. Pour 1/4 cup batter onto a hot griddle. Cook until bubbles form, flip, cook until golden. Serve with maple syrup.', }, { id: 'job', name: 'Job Posting', schema: jsonSchema(jobSchema), schemaDisplay: '{ title, company, salary?, requirements[], location }', sampleText: 'We are hiring a Senior Frontend Engineer at CloudTech Inc. The position is based in Austin, TX with a salary range of $150K-$190K. Requirements: 5+ years of React experience, TypeScript proficiency, experience with Next.js, and familiarity with CI/CD pipelines.', },];export function getTemplate(id: TemplateId): ExtractionTemplate { return TEMPLATES.find((t) => t.id === id) ?? TEMPLATES[0];}export interface DerivedColumn { key: string; header: string;}export interface DerivedTable { columns: DerivedColumn[]; rows: Record<string, unknown>[];}export interface DerivedChart { type: 'gauge' | 'bar' | 'line' | 'area' | 'scatter' | 'radar'; data: Array<{ x?: number; y?: number; label?: string; value?: number }>; max?: number; title: string;}export interface DerivedArtifacts { table: DerivedTable; chart: DerivedChart | null; chartEmptyReason?: string;}function asText(value: unknown): string { if (value == null) return ''; if (typeof value === 'string') return value; if (typeof value === 'number' || typeof value === 'boolean') return String(value); return JSON.stringify(value);}function parseSalaries(text: string): number[] { const out: number[] = []; const re = /\$?\s?([\d,]+(?:\.\d+)?)\s?([kmKM])?/g; let match: RegExpExecArray | null; while ((match = re.exec(text)) !== null) { const base = Number.parseFloat(match[1].replace(/,/g, '')); if (!Number.isFinite(base)) continue; const suffix = (match[2] ?? '').toLowerCase(); const mult = suffix === 'k' ? 1_000 : suffix === 'm' ? 1_000_000 : 1; out.push(base * mult); } return out;}export function deriveArtifacts(templateId: TemplateId, object: unknown): DerivedArtifacts { const obj = (object ?? {}) as Record<string, unknown>; switch (templateId) { case 'review': { const pros = Array.isArray(obj.pros) ? obj.pros : []; const cons = Array.isArray(obj.cons) ? obj.cons : []; const rows: Record<string, unknown>[] = [ ...pros.map((p) => ({ aspect: asText(p), sentiment: 'pro' })), ...cons.map((c) => ({ aspect: asText(c), sentiment: 'con' })), ]; const rating = typeof obj.rating === 'number' ? obj.rating : Number.NaN; const chart: DerivedChart | null = Number.isFinite(rating) ? { type: 'gauge', data: [{ value: rating }], max: 5, title: 'Rating (out of 5)' } : null; return { table: { columns: [ { key: 'aspect', header: 'Aspect' }, { key: 'sentiment', header: 'Sentiment' }, ], rows, }, chart, ...(chart ? {} : { chartEmptyReason: 'No numeric rating was extracted.' }), }; } case 'recipe': { const ingredients = Array.isArray(obj.ingredients) ? obj.ingredients : []; const steps = Array.isArray(obj.steps) ? obj.steps : []; const rows: Record<string, unknown>[] = [ ...ingredients.map((ing) => { const record = (ing ?? {}) as Record<string, unknown>; return { entry: asText(record.item), detail: asText(record.amount), kind: 'ingredient' }; }), ...steps.map((s, i) => ({ entry: `Step ${i + 1}`, detail: asText(s), kind: 'step' })), ]; const servings = typeof obj.servings === 'number' ? obj.servings : Number.NaN; const chart: DerivedChart = { type: 'bar', data: [ { label: 'Servings', value: Number.isFinite(servings) ? servings : 0 }, { label: 'Ingredients', value: ingredients.length }, { label: 'Steps', value: steps.length }, ], title: 'Recipe counts', }; return { table: { columns: [ { key: 'entry', header: 'Item / Step' }, { key: 'detail', header: 'Detail' }, { key: 'kind', header: 'Kind' }, ], rows, }, chart, }; } case 'job': { const requirements = Array.isArray(obj.requirements) ? obj.requirements : []; const rows: Record<string, unknown>[] = requirements.map((r, i) => ({ index: i + 1, requirement: asText(r), })); const salaries = typeof obj.salary === 'string' ? parseSalaries(obj.salary) : []; let chart: DerivedChart | null = null; if (salaries.length >= 2) { chart = { type: 'bar', data: [ { label: 'Min', value: Math.min(...salaries) }, { label: 'Max', value: Math.max(...salaries) }, ], title: 'Salary range', }; } else if (salaries.length === 1) { chart = { type: 'bar', data: [{ label: 'Salary', value: salaries[0] }], title: 'Salary' }; } return { table: { columns: [ { key: 'index', header: '#' }, { key: 'requirement', header: 'Requirement' }, ], rows, }, chart, ...(chart ? {} : { chartEmptyReason: 'No parseable salary figure was extracted.' }), }; } case 'contact': case 'event': default: { const rows: Record<string, unknown>[] = Object.entries(obj).map(([field, value]) => ({ field, value: asText(value), })); return { table: { columns: [ { key: 'field', header: 'Field' }, { key: 'value', header: 'Value' }, ], rows, }, chart: null, chartEmptyReason: 'This template extracts no numeric fields.', }; } }}function errorHint(error: unknown): string | null { if (error && typeof error === 'object' && 'hint' in error) { const hint = (error as { hint?: unknown }).hint; if (typeof hint === 'string' && hint.length > 0) return hint; } return null;}export function DataExtractorBlock() { const [modelId, setModelId] = useState(DEFAULT_MODEL_ID); const { capabilities } = useCapabilities(); const hasWebGPU = Boolean(capabilities?.features.webgpu); const webgpuUnsupported = capabilities != null && !hasWebGPU; const modelEntry = getModelEntry(modelId); const catalog = getAgentModelCatalog(); const { model, status, progressValue, cached, error: modelError, load, } = useModelLoad<LanguageModel>({ key: `webllm:${modelId}`, create: (onProgress) => createAgentModel(modelId, (p) => onProgress(p as never)), isCached: () => isAgentModelCached(modelId), }); const modelReady = status === 'ready'; const selectorModels: SelectableModel[] = catalog.map((m) => ({ id: m.id, name: m.name, backend: 'webgpu', category: m.tier === 'large' ? 'Large (2.2GB+)' : 'Medium (1-2.2GB)', size: m.size, cached: m.id === modelId ? cached : undefined, })); const selectModel = (id: string) => { if (id === modelId || status === 'loading') return; setModelId(id); }; const statusText = status === 'ready' ? `ready - ${modelEntry.name}` : status === 'loading' ? `loading ${modelEntry.name}… ${Math.round(progressValue.percent * 100)}%` : status === 'error' ? 'error' : webgpuUnsupported ? 'WebGPU required - this device cannot run these models' : 'idle - select a model and click Load'; const [templateId, setTemplateId] = useState<TemplateId>(DEFAULT_TEMPLATE_ID); const [input, setInput] = useState(''); const template = getTemplate(templateId); const { data, error, isLoading, execute, cancel, reset } = useGenerateObject<unknown>({ model: model as LanguageModel, schema: template.schema, mode: 'json', temperature: 0, maxRetries: 3, providerOptions: { webllm: { response_format: { type: 'json_object', schema: JSON.stringify(template.schema.jsonSchema), }, }, }, }); const hasInput = input.trim().length > 0; const canExtract = hasInput && modelReady && !isLoading; const extract = () => { if (!canExtract) return; void execute(input); }; const switchTemplate = (id: TemplateId) => { if (id === templateId) return; cancel(); reset(); setTemplateId(id); }; const loadSample = () => setInput(template.sampleText); const object = data?.object; const attempts = data?.attempts ?? 0; const usage = data?.usage; const derived = data ? deriveArtifacts(templateId, object) : null; const jsonText = data ? JSON.stringify(object, null, 2) : ''; const webgpuGate = ( <div role="status" className="flex flex-col gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm" > <span className="flex items-center gap-2 font-semibold text-amber-700 dark:text-amber-400"> <AlertTriangle className="size-4 shrink-0" aria-hidden="true" /> WebGPU required </span> <span className="text-muted-foreground"> This block runs WebLLM models, which need WebGPU - a modern GPU plus a recent Chrome or Edge build. This browser or device exposes no WebGPU adapter, so these models cannot load here. On a supported device, a model selector and a Load button appear in this spot. </span> </div> ); const loadArea = ( <div className="flex flex-col gap-2"> <p className="text-sm text-muted-foreground"> <span className="font-medium text-foreground">{modelEntry.name}</span> {modelEntry.size ? ` (${modelEntry.size})` : ''} - not loaded. It downloads only when you click Load. </p> <button type="button" onClick={() => void load()} className="inline-flex h-9 w-fit items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > Load model </button> </div> ); const modelStatusGroup = ( <div role="group" aria-label="Model status" data-status={status} data-model-id={modelId} className="flex flex-col gap-2" > {status === 'idle' ? ( <CapabilityGate requires="webgpu" fallback={webgpuGate}> {loadArea} </CapabilityGate> ) : ( <ModelDownloader name={modelEntry.name} size={modelEntry.size || undefined} contextLength={modelEntry.contextLength} category="Chat" progress={progressValue} cached={cached} ready={modelReady} className="max-w-md" /> )} </div> ); return ( <div className="mx-auto flex max-w-5xl flex-col gap-4 p-4"> {} <p role="status" aria-live="polite" data-state={status} className="text-xs text-muted-foreground" > {statusText} </p> {modelError && ( <p role="status" className="text-xs text-destructive"> {modelError.message} </p> )} {} {status === 'idle' && webgpuUnsupported ? ( modelStatusGroup ) : ( <div className="grid gap-4 md:grid-cols-[minmax(0,20rem)_1fr]"> <div className="flex flex-col gap-3"> <ModelSelector models={selectorModels} selectedId={modelId} hasWebGPU={hasWebGPU} onSelect={selectModel} /> </div> {modelStatusGroup} </div> )} {} <div className="min-h-48"> <div className="flex flex-col gap-4"> {} <div className="flex flex-col gap-2"> <div className="flex flex-wrap gap-1.5" role="group" aria-label="Extraction template" data-template={templateId} > {TEMPLATES.map((t) => ( <button key={t.id} type="button" aria-pressed={t.id === templateId} onClick={() => switchTemplate(t.id)} className={cn( 'inline-flex h-8 items-center rounded-md border px-3 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50', t.id === templateId ? 'border-primary bg-primary/10 text-primary' : 'border-border bg-background text-muted-foreground hover:text-foreground', )} > {t.name} </button> ))} </div> <div className="flex items-center gap-2 text-xs text-muted-foreground"> <span className="font-medium">Schema</span> <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px]" > {template.schemaDisplay} </code> </div> </div> {} <div className="flex flex-col gap-2"> <textarea aria-label="Text to extract from" value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && canExtract) { e.preventDefault(); extract(); } }} rows={5} placeholder={ modelReady ? 'Paste free text to extract structured data from…' : 'Load the model to start extracting…' } className="w-full resize-y rounded-lg border border-border bg-background p-3 text-sm focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" /> <div className="flex flex-wrap items-center gap-2"> <button type="button" onClick={loadSample} className="inline-flex h-8 items-center rounded-md border border-border px-3 text-xs font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > Load Sample </button> {isLoading ? ( <button type="button" onClick={cancel} className="inline-flex h-8 items-center rounded-md border border-border px-3 text-xs font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > Cancel </button> ) : ( <button type="button" onClick={extract} disabled={!canExtract} className="inline-flex h-8 items-center rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > Extract </button> )} <span className="text-xs text-muted-foreground"> {isLoading ? 'Extracting…' : 'Cmd/Ctrl+Enter to extract'} </span> </div> </div> {} <div className="sr-only"> <div aria-label="Extraction state" data-loading={isLoading ? 'loading' : 'idle'} data-has-result={data ? 'true' : 'false'} data-attempts={attempts} data-template={templateId} data-object={jsonText} /> </div> {} {!data && !error && ( <p role="status" className="rounded-lg border border-dashed border-border p-6 text-center text-sm text-muted-foreground" > {isLoading ? 'Extracting structured data…' : 'Pick a template, load or paste text, and extract to see the validated JSON here.'} </p> )} {data && ( <> {} <div className="flex items-center gap-2"> <span data-attempts={attempts} className="inline-flex items-center rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2.5 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400" > Attempt {attempts}/3 </span> {attempts > 1 && ( <span className="text-xs text-muted-foreground"> Recovered via schema self-correction </span> )} </div> {} <div role="group" aria-label="Extracted data"> <StructuredOutputViewer object={object} usage={usage} durationMs={usage?.durationMs} attempts={attempts} /> </div> {} {derived && ( <Artifact className="w-full"> <ArtifactHeader> <div className="min-w-0"> <ArtifactTitle>{template.name}</ArtifactTitle> <ArtifactDescription> Extracted on-device • {derived.table.rows.length} row {derived.table.rows.length === 1 ? '' : 's'} </ArtifactDescription> </div> <ArtifactActions> <ArtifactAction label="Copy JSON" content={jsonText} /> <ArtifactAction label="Download JSON" content={jsonText} fileName={`${templateId}.json`} /> </ArtifactActions> </ArtifactHeader> <ArtifactContent> <div className="flex flex-col gap-4"> <div data-rows={derived.table.rows.length} > <DataTableArtifact rows={derived.table.rows} columns={derived.table.columns} /> </div> {derived.chart ? ( <div data-chart-type={derived.chart.type} > <ChartArtifact type={derived.chart.type} data={derived.chart.data} max={derived.chart.max} title={derived.chart.title} /> </div> ) : ( <p className="rounded-lg border border-dashed border-border p-4 text-center text-xs text-muted-foreground" > No chart -{' '} {derived.chartEmptyReason ?? 'this extraction has no numeric data.'} </p> )} </div> </ArtifactContent> </Artifact> )} </> )} {} {error && ( <div className="flex flex-col gap-2"> <InMessageError error={error} onRetry={hasInput ? extract : undefined} /> {errorHint(error) && ( <pre className="overflow-x-auto rounded-md border border-border bg-muted/40 p-2 text-[11px] text-muted-foreground"> <code>{errorHint(error)}</code> </pre> )} </div> )} </div> </div> </div> );}