# Writing Tools

4 on-device blocks in the Writing Tools category — each installs and runs on its own, entirely in the browser.

## Write

Rewrite or improve a draft with an AI edit you review as a before/after diff, then accept or reject it. Use quick presets or write your own instructions, with a live word count. It uses your browser's built-in AI when available, or a small downloadable model otherwise, and nothing downloads until you ask.

**Install**

```bash
npx shadcn@latest add @localmode/ui/blocks/writing-tools/write
```

**Full block (all files):** https://localmode.ai/r/ui/blocks/writing-tools/write.json

```tsx
'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 to carry the result back into the input, and copy or clear as you go. It uses your browser's built-in translator when available, or a downloadable model otherwise. Nothing downloads until you ask.

**Install**

```bash
npx shadcn@latest add @localmode/ui/blocks/writing-tools/translate
```

**Full block (all files):** https://localmode.ai/r/ui/blocks/writing-tools/translate.json

```tsx
'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

Turn long text into a shorter summary, choosing a short, medium, or long length and either a pulled-from-the-text or reworded style. See how much you shortened it and the reading time saved. It uses your browser's built-in summarizer when available, or a downloadable model otherwise, and nothing downloads until you ask.

**Install**

```bash
npx shadcn@latest add @localmode/ui/blocks/writing-tools/summarize
```

**Full block (all files):** https://localmode.ai/r/ui/blocks/writing-tools/summarize.json

```tsx
'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 ranked by likelihood, then click one to apply it and keep going. Runs entirely in your browser, and nothing downloads until you ask.

**Install**

```bash
npx shadcn@latest add @localmode/ui/blocks/writing-tools/complete
```

**Full block (all files):** https://localmode.ai/r/ui/blocks/writing-tools/complete.json

```tsx
'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>
  );
}
```
