AI assistance
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:
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.
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:
| State | Meaning |
|---|---|
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.
<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>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.
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
| Hook | Kind | Use |
|---|---|---|
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.