Better Rich Text Buy licence

AI assistance

On this page
  1. Setup
  2. On-device AI
  3. Structured results
  4. Server example
  5. Safety
  6. Hooks
  7. Agents and tool use

The AI plugin adds writing actions (improve, fix spelling and grammar, shorten, expand, simplify, change tone, translate, summarise, turn into a list, continue writing, and free-form "Ask AI"). Every result is a proposal: it streams into a review panel beside the original text and changes nothing until someone chooses Replace or Insert below. Accepting is one undoable step and is refused if the text changed while the model was working.

Setup

Pass an AiModel to choose how requests run. For a cloud provider, call your own server so model credentials stay out of the page:

TypeScript
import { createRichTextEditor, standardPreset } from '@better-rich-text/core';
import { aiFeature, createAiPlugin, type AiModel } from '@better-rich-text/plugin-ai';

const model: AiModel = {
  async complete(request, { signal }) {
    const response = await fetch('/api/ai', { method: 'POST', body: JSON.stringify(request), signal, headers: { 'content-type': 'application/json' } });
    if (!response.ok) throw new Error('The assistant is unavailable');
    return (await response.json()).text;
  },
};

createRichTextEditor('#editor', {
  plugins: [createAiPlugin({ model })],
  features: [...standardPreset.features, aiFeature],
  toolbar: `ai | ${standardPreset.toolbar}`,
});

request.messages contains a system message and a user message in which the document content is wrapped in <document> tags as data. request.action names the action, so your server can apply its own policy per action.

On-device AI

createOnDeviceAiModel() uses Chrome's built-in Prompt API and Gemini Nano. Document text is processed on the device, with no provider key or model endpoint. Chrome manages the model download. Browser, hardware and language support vary; see the Prompt API requirements.

TypeScript
import { createOnDeviceAiModel } from '@better-rich-text/plugin-ai';

const localModel = createOnDeviceAiModel();

createRichTextEditor('#editor', {
  plugins: [createAiPlugin({ model: localModel })],
  features: [...standardPreset.features, aiFeature],
  toolbar: `ai | ${standardPreset.toolbar}`,
});

Check availability before offering a download. availability() never downloads anything and returns:

StateMeaning
unavailable The browser, device or configured languages are unsupported
downloadable The model needs to be downloaded
downloading A download is already underway
available The model is ready for requests

Call prepare() directly from a user click to download the model or wait for an existing download. onDownloadProgress receives a fraction from 0 to 1.

HTML
<button id="download-ai" disabled>Download on-device AI</button>
<progress id="ai-download" max="1" hidden aria-label="Model download"></progress>
<p id="ai-status" role="status"></p>
TypeScript
const download = document.querySelector<HTMLButtonElement>('#download-ai')!;
const progress = document.querySelector<HTMLProgressElement>('#ai-download')!;
const status = document.querySelector<HTMLElement>('#ai-status')!;

async function checkAvailability() {
  try {
    const state = await localModel.availability();
    download.disabled = state === 'unavailable' || state === 'available';
    download.textContent = state === 'downloading' ? 'Show download progress' : 'Download on-device AI';
    status.textContent = state === 'available' ? 'On-device AI is ready.'
      : state === 'unavailable' ? 'On-device AI is unavailable in this browser or on this device.'
      : 'A model download is required before using on-device AI.';
  } catch (error) {
    download.disabled = true;
    status.textContent = error instanceof Error ? error.message : 'Could not check on-device AI.';
  }
}

download.onclick = async () => {
  download.disabled = true;
  progress.hidden = false;
  progress.removeAttribute('value');
  status.textContent = 'Downloading the on-device model…';
  try {
    await localModel.prepare({
      onDownloadProgress(value) {
        progress.value = value;
        status.textContent = `Downloading the on-device model: ${Math.round(value * 100)}%`;
      },
    });
    await checkAvailability();
  } catch (error) {
    download.disabled = false;
    status.textContent = error instanceof Error ? error.message : 'The model download failed. Try again.';
  } finally {
    progress.hidden = true;
  }
};

void checkAvailability();

Pass an AbortSignal to prepare({ signal, onDownloadProgress }) to cancel preparation. complete() requires the model to be available; it reports an error rather than starting a download. Requests stream into the same review panel, each in a fresh local session that is destroyed when the request finishes or is cancelled.

Input and output languages default to English. Set inputLanguages and outputLanguage when creating the model for other languages; English is always included for the editor's system prompt. Built-in translation actions select their requested output language and report unsupported languages as unavailable.

Choose cloud or on-device processing explicitly in your application. The on-device provider never falls back to a cloud provider, including when a download fails or a request is unsupported. A cloud option should run only after the user chooses it.

Structured results

The local model also exposes completeStructured(prompt, responseConstraint, { signal }). It returns JSON text using a JSON schema passed to Chrome's structured output API. This uses the same availability check and fresh-session cleanup as writing requests, and requires the model to be ready. It never starts a download or switches to a cloud provider.

For inline spelling and grammar checks, pass the model to onDeviceProvider({ model: localModel }) from @better-rich-text/plugin-proofing. That provider builds the correction schema, checks each textblock sequentially and validates every correction against the original text. The existing dictionary, ignore controls and undo history still apply. Start the proofing plugin with enabled: false and let the user opt in before enabling it. The proofing setup includes a download-progress and enable control.

Structured output constrains the format of a reply. Treat its contents as untrusted and keep source validation; a valid JSON correction can still be wrong.

Server example

A minimal Node handler using the Anthropic Messages API. Keep the key on the server, check the signed-in user, and limit request size and rate.

TypeScript
app.post('/api/ai', requireUser, async (req, res) => {
  const { messages } = req.body as { messages: { role: string; content: string }[] };
  const system = messages.find((m) => m.role === 'system')?.content ?? '';
  const turns = messages.filter((m) => m.role !== 'system').slice(-4);
  const reply = await fetch('https://api.anthropic.com/v1/messages', {
    method: 'POST',
    headers: { 'x-api-key': process.env.ANTHROPIC_API_KEY!, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
    body: JSON.stringify({ model: process.env.AI_MODEL, max_tokens: 2000, system, messages: turns }),
  });
  const data = await reply.json();
  res.json({ text: data.content?.[0]?.text ?? '' });
});

To stream, call onToken(text) from complete as chunks arrive; the review panel renders them live.

Safety

  • Replies are parsed as Markdown through the editor schema, and raw HTML in them goes through the same sanitising parser as paste, so a reply cannot add scripts, event handlers or unsupported content.

  • Document text is passed as data, and the system message tells the model not to follow instructions inside it. Treat output from both local and cloud models as untrusted.

  • Inputs are capped (20,000 characters by default, maxInputChars).

Hooks

HookKindUse
ai.request filter Add house style, glossary or context to the request
ai.output filter Post-process the model's reply
ai.proposalChanged action Track streaming and status
ai.accepted action Audit accepted suggestions

Agents and tool use

Separately from the assistant, editor.toolDefinitions() describes every installed command as a tool with a JSON schema, and editor.executeTool(name, input) runs a tool call through the same validation, permissions, hooks and undo history as a person's edit.