Network activity
Browse blocks
Writing Tools
Four small writing helpers you can install on their own. They use your browser's built-in AI when it is available and fall back to a downloadable model otherwise, and nothing runs until you ask.
Write
Rewrite or edit a draft with an AI suggestion you review as a before/after diff, then accept or reject the change.
Install this block
npx shadcn@latest add @localmode/ui/blocks/writing-tools/writeWrite - AI-edit draft editor. Models load only behind an explicit action.
Chrome Prompt API (Gemini Nano) is not available in this browser. Using Transformers.js (Llama 3.2 1B, ~380 MB) instead.
'use client';/** * @file write.tsx * @description Write block — a draft editor with live length feedback, preset + free-text edit instructions with prompt improvement, and an AI edit reviewed as a before/after diff with explicit Accept/Reject (Prompt API ⇄ Llama-3.2-1B fallback). */import { useEffect, useState } from 'react';import { useGenerateText, useProviderFallback, toAppError, providerName, type ResolvedModel,} from '@localmode/react';import type { LanguageModel } from '@localmode/core';import { isWebGPUSupported } from '@localmode/core';import { CharLimitIndicator } from '@/components/char-limit-indicator';import { PromptEnhanceButton } from '@/components/prompt-enhance-button';import { CodeDiffViewer } from '@/components/code-diff-viewer';import { CapabilityGate } from '@/components/capability-gate';import { ProviderBadge } from '@/components/provider-badge';import { ChromeAIDownloadGate } from '@/components/chrome-ai-download-gate';import { ErrorAlert } from '@/components/error-alert';import { cn } from '@/lib/utils';const EDIT_ENGINE_MODEL_ID = 'onnx-community/Llama-3.2-1B-Instruct-ONNX';const EDIT_ENGINE_MODEL_SIZE = '~380 MB';const EDIT_PRESETS = [ 'Fix grammar and spelling', 'Make it more concise', 'Use a friendlier tone', 'Make it more formal',];const WRITE_DEFAULT_TARGET = 280;const WRITE_SAMPLE = 'Artificial intelligence has transformed how we interact with technology. From voice assistants to recommendation systems, AI is now embedded in everyday tools. Recent advances in on-device AI have made it possible to run sophisticated models directly in web browsers, eliminating the need for cloud APIs and ensuring user data never leaves the device. This shift toward local-first AI represents a fundamental change in how we build privacy-respecting applications.';function buildEditPrompt(instruction: string, draft: string): string { return `You are a writing assistant. Rewrite the text below according to the instruction. Output only the rewritten text, with no preamble or explanation.\n\nInstruction: ${instruction}\n\nText:\n${draft}`;}function buildEnhancePrompt(instruction: string): string { return `Rewrite the following editing instruction so it is clearer and more specific. Output only the improved instruction, on a single line, with no preamble.\n\nInstruction: ${instruction}`;}function cleanProposal(text: string): string { let out = text.trim(); out = out.replace(/^(here('| i)s|sure[,!]?|rewritten text|revised text)[^\n:]*:\s*/i, ''); if ((out.startsWith('"') && out.endsWith('"')) || (out.startsWith('“') && out.endsWith('”'))) { out = out.slice(1, -1).trim(); } return out;}export function WriteBlock() { const [draft, setDraft] = useState(''); const [target, setTarget] = useState(WRITE_DEFAULT_TARGET); const [instruction, setInstruction] = useState(EDIT_PRESETS[0]); const [proposal, setProposal] = useState<string | null>(null); const [device, setDevice] = useState<'webgpu' | 'wasm' | null>(null); const [resolved, setResolved] = useState<ResolvedModel<LanguageModel> | null>(null); const { resolveEditEngine, chromeAvailability, refreshChromeAvailability, requestChromeDownload, chromeDownloadProgress, downloadingCapability, error: providerError, } = useProviderFallback({ loadChromeAI: () => import('@localmode/chrome-ai'), loadTransformers: () => import('@localmode/transformers'), }); useEffect(() => { let alive = true; void isWebGPUSupported().then((ok) => { if (alive) setDevice(ok ? 'webgpu' : 'wasm'); }); return () => { alive = false; }; }, []); const editAvailability = chromeAvailability.edit ?? 'unsupported'; useEffect(() => { void refreshChromeAvailability('edit'); }, [refreshChromeAvailability]); useEffect(() => { if (!device) return; let alive = true; void resolveEditEngine({ fallbackModelId: EDIT_ENGINE_MODEL_ID, device }).then((r) => { if (alive) setResolved(r); }); return () => { alive = false; }; }, [device, resolveEditEngine, editAvailability]); const { data, error, isLoading, execute, cancel, reset } = useGenerateText({ model: resolved?.model as LanguageModel, maxTokens: 220, temperature: 0.4, }); const ready = !!resolved; const appErr = toAppError(error); const modelId = data?.response.modelId ?? resolved?.modelId ?? null; const runEdit = async () => { if (!draft.trim() || !instruction.trim() || !ready || isLoading) return; setProposal(null); const r = await execute(buildEditPrompt(instruction, draft)); if (r) setProposal(cleanProposal(r.text)); }; const accept = () => { if (proposal === null) return; setDraft(proposal); setProposal(null); reset(); }; const reject = () => { setProposal(null); reset(); }; return ( <div className="mx-auto flex max-w-4xl flex-col gap-4 p-4"> <p className="text-xs text-muted-foreground"> Write - AI-edit draft editor. Models load only behind an explicit action. </p> <ChromeAIDownloadGate availability={editAvailability} label="Chrome Prompt API (Gemini Nano)" size="~1.5 GB, shared across every site" isDownloading={downloadingCapability === 'edit'} progress={chromeDownloadProgress?.progress} error={providerError?.message ?? null} fallbackLabel={`Transformers.js (Llama 3.2 1B, ${EDIT_ENGINE_MODEL_SIZE})`} onDownload={() => { void requestChromeDownload('edit'); }} /> <div className="flex flex-wrap items-center justify-between gap-2"> <span data-provider={resolved?.provider ?? 'resolving'}> <ProviderBadge providerName={resolved ? providerName(resolved.provider) : null} tier={resolved?.tier ?? 'download'} modelId={modelId} note={resolved?.provider === 'transformers' ? `Llama 3.2 1B · ${EDIT_ENGINE_MODEL_SIZE}` : undefined} /> </span> <span data-model-id={modelId ?? ''} className="sr-only"> {modelId ?? ''} </span> <CapabilityGate requires="webgpu" fallback={ <span className="text-[10px] text-muted-foreground"> WebGPU unavailable - the edit engine runs on WASM (slower). </span> } pending={<span className="text-[10px] text-muted-foreground">Checking WebGPU…</span>} > <span className="text-[10px] text-emerald-600 dark:text-emerald-500"> WebGPU detected - the edit engine is GPU-accelerated. </span> </CapabilityGate> </div> {} <div className="flex flex-col gap-1.5"> <div className="flex items-center justify-between"> <span className="text-xs font-medium text-muted-foreground">Draft</span> <div className="flex items-center gap-2"> <label className="flex items-center gap-1 text-[10px] text-muted-foreground"> Target <input type="number" min={40} max={2000} step={20} value={target} onChange={(e) => setTarget(Math.max(1, Number(e.target.value) || 1))} className="w-16 rounded border border-border bg-background px-1 py-0.5 text-right tabular-nums" /> chars </label> <span> <CharLimitIndicator charCount={draft.length} maxLength={target} /> </span> </div> </div> <textarea aria-label="Draft" value={draft} onChange={(e) => setDraft(e.target.value)} rows={6} placeholder="Write or paste a draft, then ask the AI to edit it…" className="w-full resize-y rounded-md border border-border bg-background p-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" /> <button type="button" onClick={() => setDraft(WRITE_SAMPLE)} className="self-start rounded text-xs text-muted-foreground underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > Load sample </button> </div> {} <div className="flex flex-col gap-1.5"> <span className="text-xs font-medium text-muted-foreground">Edit instruction</span> <div className="flex flex-wrap gap-1.5"> {EDIT_PRESETS.map((preset, i) => ( <button key={preset} type="button" onClick={() => setInstruction(preset)} className={cn( 'rounded-full border px-2 py-0.5 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', instruction === preset ? 'border-primary bg-primary/10 text-foreground' : 'border-border text-muted-foreground hover:bg-muted', )} > {preset} </button> ))} </div> <div className="flex items-center gap-2"> <input aria-label="Edit instruction" value={instruction} onChange={(e) => setInstruction(e.target.value)} placeholder="Describe the edit…" className="flex-1 rounded-md border border-border bg-background px-2 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" /> <span> <PromptEnhanceButton draft={instruction} onApply={setInstruction} disabled={!ready} onEnhance={async (draftInstruction: string) => { if (!resolved) return null; const { generateText } = await import('@localmode/core'); const r = await generateText({ model: resolved.model, prompt: buildEnhancePrompt(draftInstruction), maxTokens: 120, temperature: 0.5, }); return cleanProposal(r.text); }} /> </span> </div> </div> <div className="flex items-center gap-2"> <button type="button" onClick={isLoading ? cancel : runEdit} disabled={!ready || (!draft.trim() && !isLoading) || (!instruction.trim() && !isLoading)} className={cn( 'inline-flex h-8 items-center rounded-md px-3 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50', isLoading ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' : 'bg-primary text-primary-foreground hover:bg-primary/90', )} > {isLoading ? 'Stop' : ready ? 'AI edit' : 'Preparing…'} </button> {isLoading && <span className="text-xs text-muted-foreground">Generating edit…</span>} </div> {appErr && ( <span> <ErrorAlert message={appErr.message} onRetry={runEdit} onDismiss={reset} /> </span> )} {} {proposal !== null && ( <div className="flex flex-col gap-2"> <span className="text-xs font-medium text-muted-foreground">Review the proposed edit</span> <div> <CodeDiffViewer original={draft} modified={proposal} mode="split" originalLabel="Current draft" modifiedLabel="Proposed edit" showLineNumbers={false} /> </div> {} <span role="status" aria-label="Proposed edit" className="sr-only" > {proposal} </span> <div className="flex items-center gap-2"> <button type="button" onClick={accept} className="inline-flex h-8 items-center rounded-md bg-emerald-600 px-3 text-sm font-medium text-white transition-colors hover:bg-emerald-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > Accept </button> <button type="button" onClick={reject} className="inline-flex h-8 items-center rounded-md border border-border px-3 text-sm font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > Reject </button> </div> </div> )} </div> );}Translate
Translate text between 24 language pairs, swap the direction with one click, and copy the result.
Install this block
npx shadcn@latest add @localmode/ui/blocks/writing-tools/translateTranslate - 24 offline Opus-MT pairs. Models load only behind an explicit action.
Chrome Translator (en→de) is not available in this browser. Using Transformers.js (Opus-MT) instead.
'use client';/** * @file translate.tsx * @description Translate block — 24 directed offline Opus-MT pairs (English-centric, every model id verified) with swap-carries-output, per-panel char counts, copy, cancel, and truthful badging (Chrome Translator API ⇄ Opus-MT fallback). */import { useEffect, useState } from 'react';import { useTranslate, useProviderFallback, toAppError, providerName, type ResolvedModel,} from '@localmode/react';import type { TranslationModel } from '@localmode/core';import { LanguagePairSelector } from '@/components/language-pair-selector';import { ProviderBadge } from '@/components/provider-badge';import { ChromeAIDownloadGate } from '@/components/chrome-ai-download-gate';import { CopyButton } from '@/components/copy-button';import { ErrorAlert } from '@/components/error-alert';import { cn } from '@/lib/utils';interface Language { code: string; name: string; flag: string;}const LANGUAGES: Language[] = [ { code: 'en', name: 'English', flag: '🇬🇧' }, { code: 'de', name: 'German', flag: '🇩🇪' }, { code: 'fr', name: 'French', flag: '🇫🇷' }, { code: 'es', name: 'Spanish', flag: '🇪🇸' }, { code: 'it', name: 'Italian', flag: '🇮🇹' }, { code: 'nl', name: 'Dutch', flag: '🇳🇱' }, { code: 'ru', name: 'Russian', flag: '🇷🇺' }, { code: 'zh', name: 'Chinese', flag: '🇨🇳' }, { code: 'ar', name: 'Arabic', flag: '🇸🇦' }, { code: 'hi', name: 'Hindi', flag: '🇮🇳' }, { code: 'fi', name: 'Finnish', flag: '🇫🇮' }, { code: 'uk', name: 'Ukrainian', flag: '🇺🇦' }, { code: 'sv', name: 'Swedish', flag: '🇸🇪' },];const NON_ENGLISH_CODES = LANGUAGES.filter((l) => l.code !== 'en').map((l) => l.code);const DIRECTED_PAIR_COUNT = NON_ENGLISH_CODES.length * 2;const OPUS_MT_SIZE = '~80 MB per pair';function opusMtModelId(source: string, target: string): string { return `Xenova/opus-mt-${source}-${target}`;}function languageByCode(code: string): Language { return LANGUAGES.find((l) => l.code === code) ?? { code, name: code, flag: '' };}const TRANSLATE_SAMPLE = 'The weather is nice today and I want to go for a walk.';const FIRST_NON_EN = LANGUAGES.find((l) => l.code !== 'en')!.code;export function TranslateBlock() { const [source, setSource] = useState('en'); const [target, setTarget] = useState('de'); const [input, setInput] = useState(''); const [resolved, setResolved] = useState<ResolvedModel<TranslationModel> | null>(null); const { resolveTranslator, chromeAvailability, refreshChromeAvailability, requestChromeDownload, chromeDownloadProgress, downloadingCapability, error: providerError, } = useProviderFallback({ loadChromeAI: () => import('@localmode/chrome-ai'), loadTransformers: () => import('@localmode/transformers'), }); const { data, error, isLoading, execute, cancel, reset } = useTranslate({ model: resolved?.model as TranslationModel, }); const translateAvailability = chromeAvailability.translate ?? 'unsupported'; useEffect(() => { void refreshChromeAvailability('translate', { source, target }); }, [source, target, refreshChromeAvailability]); useEffect(() => { let alive = true; setResolved(null); void resolveTranslator({ source, target, fallbackModelId: opusMtModelId(source, target), }).then((r) => { if (alive) setResolved(r); }); return () => { alive = false; }; }, [source, target, resolveTranslator, translateAvailability]); const output = data?.translation ?? ''; const ready = !!resolved; const appErr = toAppError(error); const modelId = data?.response.modelId ?? resolved?.modelId ?? null; const selectSource = (code: string) => { reset(); if (code === 'en') { setSource('en'); if (target === 'en') setTarget(FIRST_NON_EN); } else { setSource(code); setTarget('en'); } }; const selectTarget = (code: string) => { reset(); if (code === 'en') { setTarget('en'); if (source === 'en') setSource(FIRST_NON_EN); } else { setTarget(code); setSource('en'); } }; const swap = () => { const carried = output; reset(); setSource(target); setTarget(source); if (carried) setInput(carried); }; const translate = () => { if (!input.trim() || !ready || isLoading) return; void execute({ text: input, sourceLanguage: source, targetLanguage: target }); }; const targetName = languageByCode(target).name; return ( <div className="mx-auto flex max-w-4xl flex-col gap-4 p-4"> <p className="text-xs text-muted-foreground"> Translate - 24 offline Opus-MT pairs. Models load only behind an explicit action. </p> <ChromeAIDownloadGate availability={translateAvailability} label={`Chrome Translator (${source}\u2192${target})`} size="one language pack" isDownloading={downloadingCapability === 'translate'} progress={chromeDownloadProgress?.progress} error={providerError?.message ?? null} fallbackLabel="Transformers.js (Opus-MT)" onDownload={() => { void requestChromeDownload('translate', { source, target }); }} /> <div className="flex flex-wrap items-center justify-between gap-2"> <span data-provider={resolved?.provider ?? 'resolving'}> <ProviderBadge providerName={resolved ? providerName(resolved.provider) : null} tier={resolved?.tier ?? 'download'} modelId={modelId} /> </span> <span data-model-id={modelId ?? ''} className="sr-only"> {modelId ?? ''} </span> <div className="flex flex-wrap items-center gap-1.5 text-[10px]"> {['Works offline', 'Runs locally', `${DIRECTED_PAIR_COUNT} pairs`, `Opus-MT · ${OPUS_MT_SIZE}`].map( (b) => ( <span key={b} className="rounded-full border border-border px-2 py-0.5 text-muted-foreground"> {b} </span> ), )} </div> </div> {} <div className="flex items-center gap-2"> <LanguagePairSelector languages={LANGUAGES} sourceCode={source} targetCode={target} onSelectSource={selectSource} onSelectTarget={selectTarget} onSwap={swap} /> </div> <div className="grid gap-3 sm:grid-cols-2"> {} <div className="flex flex-col gap-1.5"> <div className="flex items-center justify-between text-xs text-muted-foreground"> <span>{languageByCode(source).name}</span> <span>{input.length} chars</span> </div> <textarea aria-label="Text to translate" dir="auto" value={input} onChange={(e) => setInput(e.target.value)} disabled={isLoading} rows={6} placeholder="Enter text to translate…" className="w-full resize-y rounded-md border border-border bg-background p-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60" /> <button type="button" onClick={() => setInput(TRANSLATE_SAMPLE)} disabled={isLoading} className="self-start rounded text-xs text-muted-foreground underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50" > Load sample </button> </div> {} <div className="flex flex-col gap-1.5"> <div className="flex items-center justify-between text-xs text-muted-foreground"> <span>{targetName}</span> {output && <span>{output.length} chars</span>} </div> <div data-provider={resolved?.provider ?? ''} role="region" aria-label="Translation" aria-live="polite" dir="auto" className="min-h-[8.5rem] whitespace-pre-wrap rounded-md border border-border bg-muted/30 p-2 text-sm" > {isLoading ? ( <span className="flex flex-col gap-2" aria-label="Translating…"> {[0, 1, 2].map((i) => ( <span key={i} className="h-3 animate-pulse rounded bg-muted" style={{ width: `${90 - i * 15}%` }} /> ))} </span> ) : output ? ( output ) : ( <span className="text-muted-foreground">Translation will appear here…</span> )} </div> {output && !isLoading && ( <span className="self-start"> <CopyButton value={output} /> </span> )} </div> </div> <div className="flex items-center gap-2"> <button type="button" data-running={isLoading} onClick={isLoading ? cancel : translate} disabled={!ready || (!input.trim() && !isLoading)} className={cn( 'inline-flex h-8 items-center rounded-md px-3 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50', isLoading ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' : 'bg-primary text-primary-foreground hover:bg-primary/90', )} > {isLoading ? 'Stop' : ready ? `Translate to ${targetName}` : 'Preparing…'} </button> </div> {appErr && ( <span> <ErrorAlert message={appErr.message} onRetry={translate} onDismiss={reset} /> </span> )} </div> );}Summarize
Shorten long text into a summary, choosing a short, medium, or long length and either a pulled-from-the-text or reworded style.
Install this block
npx shadcn@latest add @localmode/ui/blocks/writing-tools/summarizeSummarize - extractive & abstractive modes. Models load only behind an explicit action.
Chrome Summarizer is not available in this browser. Using Transformers.js (DistilBART) instead.
Abstractive → DistilBART generated summary.
Chrome summary styles (TL;DR / Key Points / Teaser / Headline) appear when Chrome Built-in AI is available; this browser uses the Transformers.js path.
'use client';/** * @file summarize.tsx * @description Summarize block — extractive (on-device sentence extraction) & abstractive (DistilBART) modes with short/medium/long length presets, compression + reading-time-saved stats, copy, and truthful badging (Chrome Summarizer API ⇄ DistilBART fallback). */import { useEffect, useState } from 'react';import { useSummarize, useProviderFallback, toAppError, providerName, type ResolvedModel,} from '@localmode/react';import type { SummarizationModel } from '@localmode/core';import { SegmentedModePicker } from '@/components/segmented-mode-picker';import { TextProcessingPanel } from '@/components/text-processing-panel';import { ProviderBadge } from '@/components/provider-badge';import { ChromeAIDownloadGate } from '@/components/chrome-ai-download-gate';import { CopyButton } from '@/components/copy-button';import { ErrorAlert } from '@/components/error-alert';import { cn } from '@/lib/utils';type SummaryLength = 'short' | 'medium' | 'long';interface LengthPreset { label: string; minLength: number; maxLength: number; sentences: number;}const LENGTH_PRESETS: Record<SummaryLength, LengthPreset> = { short: { label: 'Short', minLength: 20, maxLength: 50, sentences: 2 }, medium: { label: 'Medium', minLength: 50, maxLength: 130, sentences: 3 }, long: { label: 'Long', minLength: 100, maxLength: 250, sentences: 5 },};type SummaryMode = 'extractive' | 'abstractive';type SummaryStyle = 'tldr' | 'key-points' | 'teaser' | 'headline';const SUMMARY_STYLES: { value: SummaryStyle; label: string }[] = [ { value: 'tldr', label: 'TL;DR' }, { value: 'key-points', label: 'Key Points' }, { value: 'teaser', label: 'Teaser' }, { value: 'headline', label: 'Headline' },];function modeToChromeStyle(mode: SummaryMode): SummaryStyle { return mode === 'extractive' ? 'key-points' : 'tldr';}const SUMMARIZER_MODEL_ID = 'Xenova/distilbart-cnn-6-6';const SUMMARIZER_MODEL_SIZE = '~120 MB';const READING_WPM = 238;function countWords(text: string): number { return text.trim().split(/\s+/).filter(Boolean).length;}function compressionRatio(original: string, summary: string): number { const o = countWords(original); if (o === 0) return 0; return Math.round((1 - countWords(summary) / o) * 100);}function timeSaved(original: string, summary: string): string { const saved = Math.max(0, countWords(original) - countWords(summary)); const minutes = Math.round(saved / READING_WPM); return minutes < 1 ? '< 1 min' : `${minutes} min`;}const STOPWORDS = new Set( 'a an and are as at be but by for from has have he her his i in is it its of on or she that the their them they this to was we were what when where which who will with you your'.split( ' ', ),);function splitSentences(text: string): string[] { return text .replace(/\s+/g, ' ') .trim() .split(/(?<=[.!?])\s+(?=[A-Z0-9"'À-ɏ])/) .map((s) => s.trim()) .filter(Boolean);}function extractiveSummarize(text: string, sentences: number): string { const source = splitSentences(text); if (source.length <= sentences) return source.join(' '); const freq = new Map<string, number>(); for (const w of text.toLowerCase().match(/[a-zÀ-ɏ']+/g) ?? []) { if (!STOPWORDS.has(w) && w.length > 2) freq.set(w, (freq.get(w) ?? 0) + 1); } const scored = source.map((sentence, index) => { const words = (sentence.toLowerCase().match(/[a-zÀ-ɏ']+/g) ?? []).filter( (w) => !STOPWORDS.has(w) && w.length > 2, ); const score = words.reduce((sum, w) => sum + (freq.get(w) ?? 0), 0) / Math.max(1, words.length); return { sentence, index, score }; }); return [...scored] .sort((a, b) => b.score - a.score) .slice(0, sentences) .sort((a, b) => a.index - b.index) .map((s) => s.sentence) .join(' ');}const EXTRACTIVE_MODEL_ID = 'local:extractive-frequency';const SUMMARIZE_SAMPLE = 'Artificial intelligence has transformed the way we interact with technology. Machine learning models can now understand natural language, recognize images, and even generate creative content. These advances have led to practical applications in healthcare, where AI assists in diagnosing diseases; in transportation, where self-driving cars are becoming a reality; and in education, where personalized learning experiences are being created. However, these developments also raise important ethical questions about privacy, bias, and the future of work. As AI continues to evolve, society must carefully consider how to harness its benefits while mitigating potential risks. The key challenge lies in developing AI systems that are not only powerful but also fair, transparent, and accountable.';const MODE_ITEMS: { id: SummaryMode; label: string }[] = [ { id: 'extractive', label: 'Extractive' }, { id: 'abstractive', label: 'Abstractive' },];const LENGTH_ITEMS: { id: SummaryLength; label: string }[] = ( Object.keys(LENGTH_PRESETS) as SummaryLength[]).map((id) => ({ id, label: LENGTH_PRESETS[id].label }));interface LocalSummary { summary: string; modelId: string;}export function SummarizeBlock() { const [input, setInput] = useState(''); const [mode, setMode] = useState<SummaryMode>('abstractive'); const [length, setLength] = useState<SummaryLength>('medium'); const [style, setStyle] = useState<SummaryStyle | null>(null); const [resolved, setResolved] = useState<ResolvedModel<SummarizationModel> | null>(null); const [local, setLocal] = useState<LocalSummary | null>(null); const { resolveSummarizer, chromeAvailability, refreshChromeAvailability, requestChromeDownload, chromeDownloadProgress, downloadingCapability, error: providerError, } = useProviderFallback({ loadChromeAI: () => import('@localmode/chrome-ai'), loadTransformers: () => import('@localmode/transformers'), }); const { data, error, isLoading, execute, cancel, reset } = useSummarize({ model: resolved?.model as SummarizationModel, }); const chromeStyle = style ?? modeToChromeStyle(mode); const summarizeAvailability = chromeAvailability.summarize ?? 'unsupported'; useEffect(() => { void refreshChromeAvailability('summarize', { chromeStyle, length }); }, [chromeStyle, length, refreshChromeAvailability]); useEffect(() => { let alive = true; setResolved(null); void resolveSummarizer({ chromeStyle, length, fallbackModelId: SUMMARIZER_MODEL_ID, }).then((r) => { if (alive) setResolved(r); }); return () => { alive = false; }; }, [chromeStyle, length, resolveSummarizer, summarizeAvailability]); const provider = resolved?.provider ?? null; const ready = !!resolved; const appErr = toAppError(error); const usesModel = mode === 'abstractive' || provider === 'chrome-ai'; const summary = local?.summary ?? data?.summary ?? ''; const modelId = local?.modelId ?? data?.response.modelId ?? resolved?.modelId ?? null; const run = () => { if (!input.trim() || !ready) return; const preset = LENGTH_PRESETS[length]; if (usesModel) { setLocal(null); void execute({ text: input, minLength: preset.minLength, maxLength: preset.maxLength }); } else { reset(); setLocal({ summary: extractiveSummarize(input, preset.sentences), modelId: EXTRACTIVE_MODEL_ID, }); } }; const clearAll = () => { cancel(); reset(); setLocal(null); setInput(''); }; const originalWords = countWords(input); const summaryWords = countWords(summary); const ratio = compressionRatio(input, summary); return ( <div className="mx-auto flex max-w-4xl flex-col gap-4 p-4"> <p className="text-xs text-muted-foreground"> Summarize - extractive & abstractive modes. Models load only behind an explicit action. </p> <div className="flex flex-col gap-3"> <TextProcessingPanel value={input} onChange={setInput} result={summary} isProcessing={isLoading} onRun={run} onCancel={cancel} onClear={clearAll} inputLabel="Original" resultLabel="Summary" placeholder="Paste an article or long passage to summarize…" runLabel="Summarize" emptyState={<span className="text-sm text-muted-foreground">Your summary will appear here.</span>} header={ <div className="flex flex-col gap-3"> <div className="flex flex-wrap items-center justify-between gap-2"> <span data-provider={provider ?? 'resolving'}> <ProviderBadge providerName={provider ? providerName(provider) : null} tier={resolved?.tier ?? 'download'} modelId={modelId} /> </span> <span data-model-id={modelId ?? ''} className="sr-only" > {modelId ?? ''} </span> <span className="text-[10px] text-muted-foreground"> DistilBART · {SUMMARIZER_MODEL_SIZE} </span> </div> <ChromeAIDownloadGate availability={summarizeAvailability} label="Chrome Summarizer" size="~1.5 GB, shared across every site" isDownloading={downloadingCapability === 'summarize'} progress={chromeDownloadProgress?.progress} error={providerError?.message ?? null} fallbackLabel="Transformers.js (DistilBART)" onDownload={() => { void requestChromeDownload('summarize', { chromeStyle, length }); }} /> <div className="flex flex-wrap items-center gap-4"> <div data-mode={mode} className="flex flex-col gap-1"> <span className="text-[10px] font-medium uppercase text-muted-foreground">Mode</span> <SegmentedModePicker<SummaryMode> items={MODE_ITEMS} selectedId={mode} onSelect={(m) => { setMode(m); setLocal(null); }} aria-label="Summary mode" /> </div> <div data-length={length} className="flex flex-col gap-1"> <span className="text-[10px] font-medium uppercase text-muted-foreground">Length</span> <SegmentedModePicker<SummaryLength> items={LENGTH_ITEMS} selectedId={length} onSelect={setLength} aria-label="Summary length" /> </div> <button type="button" onClick={() => setInput(SUMMARIZE_SAMPLE)} className="self-end rounded text-xs text-muted-foreground underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > Load sample </button> </div> {} <p className="text-[10px] text-muted-foreground"> {mode === 'extractive' ? provider === 'chrome-ai' ? 'Extractive → Chrome “key-points”.' : 'Extractive → on-device sentence extraction (DistilBART is abstractive-only).' : provider === 'chrome-ai' ? 'Abstractive → Chrome “tldr”.' : 'Abstractive → DistilBART generated summary.'} </p> {} {provider === 'chrome-ai' ? ( <div className="flex flex-col gap-1"> <span className="text-[10px] font-medium uppercase text-muted-foreground"> Chrome summary style </span> <SegmentedModePicker<SummaryStyle> items={SUMMARY_STYLES.map((s) => ({ id: s.value, label: s.label }))} selectedId={style ?? modeToChromeStyle(mode)} onSelect={setStyle} aria-label="Chrome summary style" /> </div> ) : ( <p className="text-[10px] text-muted-foreground" > Chrome summary styles (TL;DR / Key Points / Teaser / Headline) appear when Chrome Built-in AI is available; this browser uses the Transformers.js path. </p> )} </div> } /> </div> {appErr && ( <span> <ErrorAlert message={appErr.message} onRetry={run} onDismiss={reset} /> </span> )} {summary && !isLoading && ( <div className="flex flex-wrap items-center gap-3"> <div data-mode={mode} data-compression={ratio} data-original-words={originalWords} data-summary-words={summaryWords} data-time-saved={timeSaved(input, summary)} data-summary={summary} className="flex flex-1 flex-wrap items-center gap-2 text-xs text-muted-foreground" > <Stat label="Original" value={`${originalWords} words`} /> <Stat label="Summary" value={`${summaryWords} words`} /> <Stat label="Compression" value={`${ratio}% shorter`} /> <Stat label="Time saved" value={timeSaved(input, summary)} /> </div> <span> <CopyButton value={summary} /> </span> <button type="button" onClick={clearAll} className={cn( 'inline-flex h-7 items-center rounded-md border border-border px-2 text-xs font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', )} > Reset </button> </div> )} {} {summary && !isLoading && ( <div className="flex flex-col gap-1"> <div className="h-1.5 overflow-hidden rounded-full bg-muted"> <div className="h-full rounded-full bg-primary transition-all" style={{ width: `${Math.max(0, 100 - ratio)}%` }} /> </div> <span className="text-[10px] text-muted-foreground">{Math.max(0, 100 - ratio)}% of original</span> </div> )} </div> );}function Stat({ label, value }: { label: string; value: string }) { return ( <span className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5"> <span className="font-medium text-foreground">{value}</span> <span>{label}</span> </span> );}Complete
Fill in a blank word in your sentence with the top suggestions, then click one to apply it and keep going.
Install this block
npx shadcn@latest add @localmode/ui/blocks/writing-tools/completeComplete - ModernBERT fill-mask. Model loads only behind an explicit action.
'use client';/** * @file complete.tsx * @description Complete block — ModernBERT (topK 5) fill-mask word prediction with `[MASK]` authoring, ranked predictions, per-candidate substituted-sentence previews, and click-to-apply-and-iterate. Transformers.js-only — there is no Chrome AI fill-mask, so there is no provider to fall back from. */import { useEffect, useState } from 'react';import { useFillMask, toAppError } from '@localmode/react';import type { FillMaskModel } from '@localmode/core';import { MaskTokenInput } from '@/components/mask-token-input';import { ScoredResultBarList } from '@/components/scored-result-bar-list';import { ConfidenceScoreBadge } from '@/components/confidence-score-badge';import { ProviderBadge } from '@/components/provider-badge';import { ErrorAlert } from '@/components/error-alert';import { cn } from '@/lib/utils';const FILL_MASK_MODEL_ID = 'onnx-community/ModernBERT-base-ONNX';const FILL_MASK_MODEL_SIZE = '~150 MB';const MASK_TOKEN = '[MASK]';const COMPLETE_SAMPLES = [ 'The weather today is very [MASK].', 'I love to eat [MASK] for breakfast.', 'Paris is the capital of [MASK].', 'She is a very [MASK] person.', 'The cat sat on the [MASK].',];function confidenceLabel(score: number): 'High' | 'Good' | 'Fair' | 'Low' { if (score >= 0.3) return 'High'; if (score >= 0.1) return 'Good'; if (score >= 0.05) return 'Fair'; return 'Low';}const FILL_MASK_THRESHOLDS = { high: 0.3, medium: 0.1 };function replaceMask(text: string, word: string): string { return text.replace(MASK_TOKEN, word);}type ResolvedFillMask = { model: FillMaskModel; provider: 'transformers'; modelId: string };let fillMaskCache: ResolvedFillMask | null = null;async function resolveFillMask(modelId: string): Promise<ResolvedFillMask> { if (fillMaskCache) return fillMaskCache; const { transformers } = await import('@localmode/transformers'); const model = transformers.fillMask(modelId); fillMaskCache = { model, provider: 'transformers', modelId: model.modelId }; return fillMaskCache;}export function CompleteBlock() { const [input, setInput] = useState('The weather today is very [MASK].'); const [resolved, setResolved] = useState<ResolvedFillMask | null>(null); useEffect(() => { let alive = true; void resolveFillMask(FILL_MASK_MODEL_ID).then((r) => { if (alive) setResolved(r); }); return () => { alive = false; }; }, []); const { data, error, isLoading, execute, cancel, reset } = useFillMask({ model: resolved?.model as FillMaskModel, topK: 5, }); const hasMask = input.includes(MASK_TOKEN); const ready = !!resolved; const predictions = data?.predictions ?? []; const appErr = toAppError(error); const modelId = data?.response.modelId ?? resolved?.modelId ?? null; const predict = () => { if (!hasMask || !ready || isLoading) return; void execute(input); }; const apply = (token: string) => { setInput(replaceMask(input, token)); reset(); }; const top = predictions[0]; return ( <div className="mx-auto flex max-w-4xl flex-col gap-4 p-4"> <p className="text-xs text-muted-foreground"> Complete - ModernBERT fill-mask. Model loads only behind an explicit action. </p> <div className="flex flex-wrap items-center gap-2"> <span data-provider={resolved?.provider ?? 'resolving'}> <ProviderBadge providerName={resolved ? 'Transformers.js' : null} tier="download" modelId={modelId} note={`ModernBERT · ${FILL_MASK_MODEL_SIZE} · no Chrome AI fill-mask exists`} /> </span> <span data-model-id={modelId ?? ''} className="sr-only"> {modelId ?? ''} </span> </div> <div> <MaskTokenInput value={input} onChange={setInput} maskToken={MASK_TOKEN} ariaLabel="Sentence with a [MASK] token" samples={COMPLETE_SAMPLES} onSubmit={predict} placeholder="The weather today is very [MASK]." disabled={isLoading} /> </div> <div className="flex items-center gap-2"> <button type="button" onClick={isLoading ? cancel : predict} disabled={!ready || (!hasMask && !isLoading)} className={cn( 'inline-flex h-8 items-center rounded-md px-3 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50', isLoading ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' : 'bg-primary text-primary-foreground hover:bg-primary/90', )} > {isLoading ? 'Stop' : ready ? 'Predict' : 'Preparing…'} </button> {!hasMask && ( <span className="text-xs text-amber-600 dark:text-amber-500"> Add a {MASK_TOKEN} token to predict </span> )} </div> {appErr && ( <span> <ErrorAlert message={appErr.message} onRetry={predict} onDismiss={reset} /> </span> )} {(isLoading || predictions.length > 0) && ( <div className="flex flex-col gap-3"> <p className="text-xs font-medium text-muted-foreground"> {isLoading ? 'Predicting…' : `Top ${predictions.length} predictions`} </p> {} <ScoredResultBarList results={predictions.map((p) => ({ label: p.token, score: p.score }))} isLoading={isLoading} skeletonRows={5} limit={5} /> {} {!isLoading && predictions.length > 0 && ( <ul className="flex flex-col gap-1.5"> {predictions.map((p, i) => ( <li key={`${p.token}-${i}`}> <button type="button" onClick={() => apply(p.token)} className="flex w-full items-center gap-3 rounded-md border border-border p-2 text-left text-sm transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-semibold"> {i + 1} </span> <span className="min-w-0 flex-1"> <span className="font-medium">{p.token}</span> <span className="ml-2 text-muted-foreground"> “{replaceMask(input, p.token)}” </span> </span> <span className="shrink-0 text-xs text-muted-foreground"> {confidenceLabel(p.score)} </span> <ConfidenceScoreBadge score={p.score} thresholds={FILL_MASK_THRESHOLDS} /> </button> </li> ))} </ul> )} {} {top && ( <span role="status" aria-label="Top prediction" className="sr-only" > {top.token} </span> )} </div> )} </div> );}