Paid features

AI

npm i @matrajs/ai

@matrajs/ai streams a rewrite into the selected range and keeps that range correct while it arrives. Someone can carry on typing above, below or inside the selection and the streamed text still lands on the words they picked, because every chunk is applied to a re-resolved range rather than to the two numbers captured when the request started. That is position mapping doing the work, and it is the whole reason this package exists.

This package is commercial. Free to evaluate, develop against, test with, teach with and use in personal projects · a subscription buys running it in production. Nothing phones home and there is no runtime licence check.

You supply the stream

The extension never talks to a model. It takes an AiStream — any function returning an async iterable of strings — so the key stays on your server and the provider stays yours to change.

import { createEditor, starterKit } from '@matrajs/core'
import { ai } from '@matrajs/ai'

const editor = createEditor({
  extensions: [
    ...starterKit,
    ai({
      async *stream({ text, instruction, signal }) {
        const response = await fetch('/api/rewrite', {
          method: 'POST',
          headers: { 'content-type': 'application/json' },
          body: JSON.stringify({ text, instruction }),
          signal,
        })

        const stream = response.body!.pipeThrough(new TextDecoderStream())
        for await (const chunk of stream) yield chunk
      },
    }),
  ] as const,
})

Each chunk you yield replaces the range with everything received so far, so yield deltas rather than the whole answer again. signal aborts when the session is cancelled or the editor is destroyed · pass it to fetch and a cancelled rewrite stops costing tokens straight away.

Commands

CommandWhat it does
askAi(instruction) Starts a session over the current selection. Returns false on an empty selection, and false if a session is already streaming — one at a time.
cancelAi() Aborts the stream. What already arrived stays in the document · nothing further is applied.
acceptAi() Ends the session and keeps the result. The document is not touched.
rejectAi() Aborts and puts the selection back over the rewritten range. The original text comes back by undoing.
editor.commands.select({ from, to })   // a rewrite needs a selection
editor.commands.askAi('make this shorter')
Chunks are applied as they arrive, so the stream is several history steps rather than one. A reject followed by undo restores the paragraph · that is the path to wire to a discard button.

Status, for spinners and toasts

onStatus is called on every transition, with the session as it stands.

ai({
  stream,
  onStatus: (session) => {
    setBusy(session.status === 'streaming')
    if (session.status === 'error') toast(session.error!.message)
  },
})
FieldType
idnumber One per session. A late chunk from a superseded session is discarded.
status 'idle' | 'streaming' | 'done' | 'error' | 'cancelled'
rangeRange Where the rewrite is going, re-resolved as of now.
receivedstring Everything that has arrived so far.
errorError? Set when status is 'error'.

What a failure does

A stream that throws reports 'error' with the thrown error, and stops. It does not throw out of the command and it does not tear the document up: whatever had already streamed stays, exactly as if the model had stopped early. A provider having a bad afternoon must not take an editor down with it.

Edit this page on GitHub