Ready
Network activity
0 requests
Connected - models run on-device
No recent network activity
0 groups0 total requests
Browse blocks
Agents
Two small agent blocks you can install on their own. Each one runs its own AI model in the browser and needs a graphics-capable device, and nothing downloads until you load it.
Research Agent
An agent that answers a question by using tools step by step, pausing for your approval before each tool runs, with a timeline of what it did.
Install this block
npx shadcn@latest add @localmode/ui/blocks/agents/research-agentidle - select a model and click Load
Medium (1-2.2GB)
Large (2.2GB+)
Checking WebGPU support…
Preparing…
'use client';/** * @file research-agent.tsx * @description Self-sufficient Research Agent block — its own WebGPU-only WebLLM load plus a tool-using ReAct loop with human-in-the-loop tool approval and a step timeline. */import { useRef, useState } from 'react';import { AlertTriangle } from 'lucide-react';import { defineTool, jsonSchema, type AgentStep, type LanguageModel, type ToolDefinition } from '@localmode/core';import { useAgent, 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 { AgentStepTimeline } from '@/components/agent-step-timeline';import { ToolView, type ToolCall } from '@/components/tool';import { ToolApproval } from '@/components/tool-approval';import { Task, TaskItem, type TaskStep } from '@/components/task';import { ChainOfThought, ChainOfThoughtHeader, ChainOfThoughtContent, ChainOfThoughtStep,} from '@/components/chain-of-thought';import { Reasoning, ReasoningTrigger, ReasoningContent } from '@/components/reasoning';import { MultiStepPipelineTracker } from '@/components/pipeline-tracker';import { PromptInput, PromptInputTextarea, PromptInputTools, PromptInputSubmit,} from '@/components/prompt-input';import { Suggestion } from '@/components/suggestions';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);}interface KnowledgeArticle { id: string; title: string; content: string; category: string;}export const KNOWLEDGE_BASE: readonly KnowledgeArticle[] = [ { id: 'qc-1', title: 'Introduction to Quantum Computing', category: 'quantum-computing', content: 'Quantum computing uses quantum bits (qubits) that can exist in superposition, representing both 0 and 1 simultaneously. This enables quantum computers to solve certain problems exponentially faster than classical computers. Key concepts include entanglement, where qubits become correlated, and quantum gates that manipulate qubit states. Current quantum computers have 50-1000+ qubits but are error-prone.', }, { id: 'qc-2', title: 'Quantum Computing Applications', category: 'quantum-computing', content: 'Quantum computing has promising applications in cryptography (breaking and creating encryption), drug discovery (simulating molecular interactions), optimization problems (logistics, finance), and machine learning (quantum neural networks). Google demonstrated quantum supremacy in 2019, and IBM offers cloud quantum computing services. However, practical quantum advantage for real-world problems remains limited.', }, { id: 'qc-3', title: 'Challenges in Quantum Computing', category: 'quantum-computing', content: 'Major challenges include quantum decoherence (qubits losing their quantum state), high error rates requiring error correction, extreme cooling requirements (near absolute zero), and the difficulty of scaling up qubit counts while maintaining coherence. The threshold for useful fault-tolerant quantum computing is estimated at millions of physical qubits.', }, { id: 'bio-1', title: 'Photosynthesis Process', category: 'biology', content: 'Photosynthesis converts sunlight, water, and CO2 into glucose and oxygen. It occurs in chloroplasts using chlorophyll pigments. The light-dependent reactions in the thylakoid membranes capture light energy to produce ATP and NADPH. The Calvin cycle in the stroma uses these to fix CO2 into glucose. Photosynthesis is approximately 3-6% efficient at converting solar energy to chemical energy.', }, { id: 'bio-2', title: 'Solar Panel Technology', category: 'energy', content: 'Solar panels use photovoltaic cells made of semiconductor materials (typically silicon) to convert sunlight directly into electricity. Modern panels achieve 20-25% efficiency, with lab records exceeding 47%. Types include monocrystalline (most efficient), polycrystalline (cost-effective), and thin-film (flexible). Solar energy is the fastest-growing renewable energy source globally.', }, { id: 'bio-3', title: 'CRISPR Gene Editing', category: 'genetics', content: "CRISPR-Cas9 is a gene editing tool adapted from bacterial immune systems. It uses a guide RNA to direct the Cas9 enzyme to a specific DNA sequence, where it makes a precise cut. The cell's repair mechanisms then modify the gene. Applications include treating genetic diseases (sickle cell, muscular dystrophy), creating disease-resistant crops, and studying gene function. Ethical concerns involve germline editing and designer babies.", }, { id: 'bio-4', title: 'CRISPR Applications and Ethics', category: 'genetics', content: 'Clinical trials are underway using CRISPR to treat sickle cell disease, certain cancers, and inherited blindness. In agriculture, CRISPR has created drought-resistant crops and disease-resistant livestock. The technology raises ethical questions about human germline editing (changes passed to future generations), equitable access, and potential misuse. The 2018 case of CRISPR-edited babies in China sparked global debate about regulation.', }, { id: 'ai-1', title: 'Machine Learning Fundamentals', category: 'artificial-intelligence', content: 'Machine learning enables computers to learn from data without explicit programming. Supervised learning uses labeled data to learn mappings (classification, regression). Unsupervised learning finds patterns in unlabeled data (clustering, dimensionality reduction). Reinforcement learning trains agents through rewards and penalties. Deep learning uses neural networks with many layers for complex pattern recognition.', },];export const RESEARCH_SYSTEM_PROMPT = 'You are a thorough research assistant. Search for information, take notes on key findings, and provide a comprehensive final answer. Always search before answering.';export const RESEARCH_MAX_STEPS = 8;export const SAMPLE_QUESTIONS: readonly string[] = [ 'What are the main benefits and challenges of quantum computing?', 'Compare photosynthesis and solar panels for energy conversion.', 'How does CRISPR gene editing work and what are its applications?',];export const TOOL_COLOR_MAP: Record<string, string> = { search: 'bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20', note: 'bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20', calculate: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20',};interface ResearchNote { text: string; source: string; timestamp: number;}export interface ResearchToolset { tools: ToolDefinition[]; reset: () => void; getNotes: () => ResearchNote[];}export function createResearchTools(): ResearchToolset { const notes: ResearchNote[] = []; const searchTool = defineTool({ name: 'search', description: 'Search the knowledge base for articles relevant to a query. Returns titles and content snippets.', parameters: jsonSchema( z.object({ query: z.string().describe('The search query'), maxResults: z.number().default(3).describe('Maximum results to return (default: 3)'), }), ), execute: async ({ query, maxResults }) => { const limit = maxResults > 0 ? maxResults : 3; const queryWords = query.toLowerCase().split(/\s+/).filter(Boolean); const scored = KNOWLEDGE_BASE.map((article) => { const text = `${article.title} ${article.content}`.toLowerCase(); const score = queryWords.reduce((sum, word) => sum + (text.includes(word) ? 1 : 0), 0); return { article, score }; }); const results = scored .filter((s) => s.score > 0) .sort((a, b) => b.score - a.score) .slice(0, limit) .map((s) => ({ title: s.article.title, content: s.article.content, category: s.article.category, })); if (results.length === 0) { return 'No relevant articles found. Try different search terms.'; } return results.map((r) => `[${r.title}] (${r.category})\n${r.content}`).join('\n\n'); }, }); const noteTool = defineTool({ name: 'note', description: 'Save a research finding or key insight as a note. Use this to accumulate knowledge before writing the final answer.', parameters: jsonSchema( z.object({ text: z.string().describe('The note content to save'), source: z.string().optional().describe('Source of the information'), }), ), execute: async ({ text, source }) => { notes.push({ text, source: source ?? 'research', timestamp: Date.now() }); return `Note saved (${notes.length} total notes).`; }, }); const calculateTool = defineTool({ name: 'calculate', description: 'Evaluate a mathematical expression or perform a simple calculation. Returns the numeric result.', parameters: jsonSchema( z.object({ expression: z.string().describe('Mathematical expression to evaluate'), }), ), execute: async ({ expression }) => { try { const sanitized = expression.replace(/[^0-9+\-*/().%\s]/g, ''); if (!sanitized.trim()) { return 'Invalid expression. Use numbers and operators: + - * / ()'; } const result = new Function(`return (${sanitized})`)() as number; return String(result); } catch { return `Could not evaluate: ${expression}`; } }, }); return { tools: [searchTool, noteTool, calculateTool], reset: () => { notes.length = 0; }, getNotes: () => [...notes], };}const FINISH_TREATMENT: Record< string, { label: string; tone: 'warning' | 'error' } | undefined> = { max_steps: { label: 'Reached maximum steps', tone: 'warning' }, timeout: { label: 'Timed out', tone: 'warning' }, loop_detected: { label: 'Loop detected - agent was repeating actions', tone: 'error' }, error: { label: 'Agent encountered an error', tone: 'error' }, aborted: { label: 'Stopped', tone: 'warning' },};function toolLabel(step: AgentStep): string { if (step.type === 'finish') return 'Final answer'; const primaryArg = step.toolArgs ? Object.values(step.toolArgs).find((v) => typeof v === 'string') : undefined; const arg = typeof primaryArg === 'string' ? `"${primaryArg.slice(0, 40)}"` : ''; return `${step.toolName ?? 'tool'}(${arg})`;}export function ResearchAgentBlock() { 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 toolsetRef = useRef<ResearchToolset | null>(null); if (!toolsetRef.current) toolsetRef.current = createResearchTools(); const toolset = toolsetRef.current; const [requireApproval, setRequireApproval] = useState(true); const [question, setQuestion] = useState(''); const [lastPrompt, setLastPrompt] = useState(''); const tools: ToolDefinition[] = requireApproval ? toolset.tools.map((t) => ({ ...t, requiresApproval: true })) : toolset.tools; const { steps, result, isRunning, error, pendingApproval, approve, deny, run, cancel, reset } = useAgent({ model: model as LanguageModel, tools, maxSteps: RESEARCH_MAX_STEPS, temperature: 0, systemPrompt: RESEARCH_SYSTEM_PROMPT, }); const startRun = (prompt: string) => { const trimmed = prompt.trim(); if (!trimmed || !modelReady || isRunning) return; toolset.reset(); setLastPrompt(trimmed); setQuestion(''); void run(trimmed); }; const finishReason = result?.finishReason; const finishTreatment = finishReason ? FINISH_TREATMENT[finishReason] : undefined; const toolSteps = steps.filter((s) => s.type === 'tool_call'); const gatedSteps = steps.filter((s) => s.approval); const taskSteps: TaskStep[] = [ ...steps.map((s) => ({ ...s, status: 'completed' as const })), ...(isRunning && !pendingApproval ? [ { index: steps.length, type: 'tool_call' as const, toolName: 'thinking', status: 'running' as const, }, ] : []), ]; const toolCalls: ToolCall[] = toolSteps.map((s) => ({ name: s.toolName ?? 'tool', input: s.toolArgs, output: s.observation, status: 'completed' as const, })); const latestObservation = [...steps].reverse().find((s) => s.observation)?.observation ?? 'Waiting for the model to choose the first tool…'; const pipelineLabels = Array.from({ length: RESEARCH_MAX_STEPS }, (_, i) => { const step = steps[i]; if (step?.type === 'finish') return `${i + 1} · answer`; if (step?.toolName) return `${i + 1} · ${step.toolName}`; return `${i + 1}`; }); const currentActivity = pendingApproval ? `Awaiting approval: ${pendingApproval.toolName}` : isRunning ? 'Thinking…' : finishReason ? finishReason === 'finish' ? 'Done' : (finishTreatment?.label ?? finishReason) : undefined; 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> )} {} {!webgpuUnsupported && ( <div className="min-h-48"> {model ? ( <div className="flex flex-col gap-4"> {} <div className="flex flex-col gap-3"> <PromptInput value={question} onValueChange={setQuestion} onSubmit={(text) => startRun(text)} streaming={isRunning} onStop={cancel} disabled={!modelReady} > <PromptInputTextarea aria-label="Research question" placeholder={ modelReady ? 'Ask a research question…' : 'Load the model to start researching…' } /> <PromptInputTools> <PromptInputSubmit disabled={!modelReady || !question.trim()} /> </PromptInputTools> </PromptInput> {} <div role="group" aria-label="Example research questions" className="flex flex-wrap gap-2"> {SAMPLE_QUESTIONS.map((q, i) => ( <Suggestion key={q} suggestion={q} onSelect={startRun} disabled={!modelReady || isRunning} className="h-auto min-w-0 max-w-none shrink whitespace-normal py-1.5 text-left" /> ))} </div> <div className="flex flex-wrap items-center gap-3"> <button type="button" role="switch" aria-checked={requireApproval} aria-label="Require tool approval" data-enabled={requireApproval ? 'on' : 'off'} onClick={() => setRequireApproval((v) => !v)} className={cn( 'inline-flex h-7 items-center gap-2 rounded-full border px-3 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50', requireApproval ? 'border-primary bg-primary/10 text-primary' : 'border-border bg-muted/40 text-muted-foreground', )} > <span className={cn( 'size-2 rounded-full', requireApproval ? 'bg-primary' : 'bg-muted-foreground/50', )} /> Require tool approval: {requireApproval ? 'On' : 'Off'} </button> {isRunning ? ( <button type="button" onClick={cancel} className="inline-flex h-7 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" > Stop </button> ) : ( (steps.length > 0 || result || error) && ( <button type="button" onClick={() => { reset(); toolset.reset(); }} className="inline-flex h-7 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" > New Research </button> ) )} </div> </div> {} <div className="sr-only"> <div aria-label="Agent run state" data-running={isRunning ? 'running' : 'idle'} data-finish={finishReason ?? ''} data-step-count={steps.length} data-tool-step-count={toolSteps.length} data-pending-tool={pendingApproval?.toolName ?? ''} /> {toolSteps.map((s) => ( <span key={s.index} aria-label="Executed tool step" data-tool={s.toolName ?? ''} data-observation={s.observation ?? ''} /> ))} </div> {} {(isRunning || steps.length > 0) && ( <MultiStepPipelineTracker steps={pipelineLabels} completed={Math.min(steps.length, RESEARCH_MAX_STEPS)} currentStep={currentActivity} /> )} {} {pendingApproval && ( <div role="group" aria-label="Pending tool approval" data-tool={pendingApproval.toolName} > <ToolApproval toolName={pendingApproval.toolName} args={pendingApproval.args} onApprove={() => approve()} onReject={() => deny('Denied by user')} /> </div> )} {} {(isRunning || steps.length > 0) && ( <Reasoning streaming={isRunning && !pendingApproval}> <ReasoningTrigger /> <ReasoningContent>{latestObservation}</ReasoningContent> </Reasoning> )} {} <div className="grid gap-4 lg:grid-cols-[1.6fr_1fr]"> <div className="min-w-0"> <AgentStepTimeline steps={steps} isRunning={isRunning} finishReason={finishReason} toolColorMap={TOOL_COLOR_MAP} /> </div> <div className="flex min-w-0 flex-col gap-4"> {} {steps.length > 0 && ( <ChainOfThought done={Boolean(result)}> <ChainOfThoughtHeader /> <ChainOfThoughtContent> {steps.map((s) => ( <ChainOfThoughtStep key={s.index} label={toolLabel(s)} status="complete" /> ))} {isRunning && !pendingApproval && ( <ChainOfThoughtStep label="Deciding next action…" status="active" /> )} </ChainOfThoughtContent> </ChainOfThought> )} {} {taskSteps.length > 0 && ( <Task> {taskSteps.map((s) => ( <TaskItem key={s.index} step={s} /> ))} </Task> )} {} {toolCalls.length > 0 && ( <div className="flex flex-col gap-2"> {toolCalls.map((call, i) => ( <ToolView key={`${call.name}-${i}`} call={call} /> ))} </div> )} {} {gatedSteps.length > 0 && ( <div className="flex flex-col gap-2" role="group" aria-label="Tool approval receipts" > {gatedSteps.map((s) => ( <ToolApproval key={s.index} toolName={s.toolName ?? 'tool'} args={s.toolArgs} decision={s.approval?.decision === 'approved' ? 'approved' : 'rejected'} /> ))} </div> )} </div> </div> {} {result && result.result && ( <div className="rounded-xl border border-border bg-card p-4"> <p className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground"> Final answer </p> <p className="whitespace-pre-wrap text-sm"> {result.result} </p> </div> )} {finishTreatment && ( <p data-reason={finishReason} className={cn( 'rounded-md border px-3 py-1.5 text-xs font-medium', finishTreatment.tone === 'warning' ? 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400' : 'border-destructive/30 bg-destructive/10 text-destructive', )} > {finishTreatment.label} </p> )} {} {error && ( <div> <InMessageError error={error} onRetry={lastPrompt ? () => startRun(lastPrompt) : undefined} /> </div> )} </div> ) : ( <p className="p-4 text-sm text-muted-foreground">Preparing…</p> )} </div> )} </div> );}Data Extractor
Pull structured data out of free text using ready-made templates, then view it as a sortable table and a chart built from the numbers it found.
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…
Preparing…
'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, }); 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> )} {} {!webgpuUnsupported && ( <div className="min-h-48"> {model ? ( <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> ) : ( <p className="p-4 text-sm text-muted-foreground">Preparing…</p> )} </div> )} </div> );}