jotdocs

Features / AI as a collaborator

AI as a collaborator

Ask AI, in the dock, rewrites the selected blocks, or the one with the caret, with a model that runs on your device: Chrome's built-in Gemini Nano, or a small open model in the browser through WebLLM. The answer isn't pasted in as your edit. jot AI writes it as a collaborator of its own, a Yjs peer inside your tab, so it merges with whatever anyone types meanwhile, reaches every window through the relay, and is named in blame, asked by you.

Three models, one setting

⋯ → AI model lists them, with where each runs, what it downloads and whether it's here. jot picks one that fits the device, and remembers your choice in this browser.

  • Chrome built-in: Gemini Nano through the Prompt API, in Chrome and Edge on desktop. Chrome downloads and keeps it. The default wherever it's ready.
  • Small: Qwen2.5 0.5B Instruct in WebLLM, 300 MB. The default on phones, tablets and devices with 4 GB of memory or less, wherever WebGPU runs.
  • Larger: Qwen3 1.7B in WebLLM, 990 MB. Better edits; the default on laptops and desktops without Chrome's model.

Without WebGPU or Chrome's model, Ask AI says why and offers nothing else. Small models are small: lists and spelling work, shortening and renumbering often don't.

Chrome's model

prompt.ts
/** Chrome's built-in model (the Prompt API). Not in TypeScript's DOM types yet. */
interface LanguageModelSession {
  prompt(input: string, options?: { signal?: AbortSignal }): Promise<string>;
  measureInputUsage?(input: string, options?: { signal?: AbortSignal }): Promise<number>;
  inputQuota?: number;
  inputUsage?: number;
  destroy(): void;
}
type Expectation = { type: 'text'; languages: string[] }[];
interface LanguageModelOptions {
  expectedInputs?: Expectation;
  expectedOutputs?: Expectation;
}
interface LanguageModelStatic {
  availability(
    options?: LanguageModelOptions,
  ): Promise<'unavailable' | 'downloadable' | 'downloading' | 'available'>;
  create(
    options?: LanguageModelOptions & {
      initialPrompts?: { role: 'system'; content: string }[];
      monitor?: (m: EventTarget) => void;
      signal?: AbortSignal;
    },
  ): Promise<LanguageModelSession>;
}
declare const LanguageModel: LanguageModelStatic | undefined;

// Availability is per configuration: ask with the same options you'll create with.
const ENGLISH = {
  expectedInputs: [{ type: 'text' as const, languages: ['en'] }],
  expectedOutputs: [{ type: 'text' as const, languages: ['en'] }],
};

/** Local: asks the browser whether the model is there, and sends nothing. */
export async function modelStatus() {
  if (typeof LanguageModel === 'undefined') return 'unavailable';
  return LanguageModel.availability(ENGLISH);
}

/** Only from a click (Ask): creating a 'downloadable' model starts its download. */
export async function ask(system: string, prompt: string, signal: AbortSignal) {
  const model = await LanguageModel!.create({
    ...ENGLISH,
    initialPrompts: [{ role: 'system', content: system }],
    signal,
    monitor: (m) =>
      m.addEventListener('downloadprogress', (e) => {
        console.log(`Downloading: ${Math.round((e as ProgressEvent).loaded * 100)}%`);
      }),
  });
  try {
    // Does the passage fit the context window? (Where the browser can tell.)
    if (model.measureInputUsage !== undefined && model.inputQuota !== undefined) {
      const usage = await model.measureInputUsage(prompt, { signal });
      if (usage > model.inputQuota - (model.inputUsage ?? 0)) return null;
    }
    return await model.prompt(prompt, { signal });
  } finally {
    model.destroy();
  }
}
  • Opening the field sends nothing. It asks the browser whether the model is there, for English text in and out.
  • Only Ask sends. Creating the model is what starts its download, which needs a click; the field shows the progress. One request runs at a time, and Esc cancels it (the abort signal reaches both create and prompt), writing nothing.
  • It must fit. Where the browser can tell, measureInputUsage() checks the passage against the context window, under a 12,000-character ceiling. A reply is capped at 20,000 characters and refused when the note has no room left for it.

WebLLM, in a worker

webllm-worker.ts
/**
 * A WebLLM model in a module worker, so the page never loads WebLLM (about
 * 2 MB compressed) and typing stays smooth while the model writes. Start it
 * only from a click: `new Worker(new URL('./webllm-worker.ts', import.meta.url), { type: 'module' })`.
 */
import { hasModelInCache, MLCEngine } from '@mlc-ai/web-llm';

const MODEL = 'Qwen2.5-0.5B-Instruct-q4f16_1-MLC'; // about 300 MB, from Hugging Face
const scope = self as unknown as { postMessage(message: unknown): void };

const engine = new MLCEngine({
  initProgressCallback: ({ progress, text }) =>
    scope.postMessage({ type: 'progress', progress, text }),
});

type Request = { type: 'has' } | { type: 'load' } | { type: 'ask'; system: string; prompt: string };

addEventListener('message', async (event: MessageEvent<Request>) => {
  const request = event.data;
  if (request.type === 'has') {
    // Ask the person before a download: is the model already in this browser?
    scope.postMessage({ type: 'has', value: await hasModelInCache(MODEL) });
  } else if (request.type === 'load') {
    await engine.reload(MODEL); // downloads what the cache lacks, then compiles for WebGPU
    scope.postMessage({ type: 'loaded' });
  } else {
    const reply = await engine.chat.completions.create({
      messages: [
        { role: 'system', content: request.system },
        { role: 'user', content: request.prompt },
      ],
      max_tokens: 1024,
    });
    const choice = reply.choices[0];
    // `length`: the reply ran out of room; don't write half an answer.
    scope.postMessage({
      type: 'reply',
      text: choice?.message.content,
      finish: choice?.finish_reason,
    });
  }
});
  • Nothing downloads without a yes. The first Ask with a WebLLM model asks "Ask AI needs a 300 MB model on this device (Qwen2.5 0.5B). Download it?", and warns about mobile data on a phone. The model comes from Hugging Face into the browser's cache, never from jot's server, with its progress in the field; Esc stops the worker and the download.
  • The page stays light. Only the worker imports WebLLM, and it starts on the first Ask, never on page load. What is downloaded the page reads from Cache Storage itself. The model stays loaded for five idle minutes, then the worker stops and the GPU memory is free.
  • It must fit. The models have a 4,096-token window, shared by the passage and its rewrite: the passage may be up to 5,544 characters, and a reply that ran out of room is refused rather than written half done.
  • Delete it. ⋯ → AI model deletes a downloaded model from the browser. If the browser evicted it (Safari does), the next Ask asks again before downloading.

What it rewrites

Whole top-level blocks: every block the selection touches, or the block with the caret. A caret anywhere in a list takes the whole list, and the field says so ("Rewrites the whole list") before anything is sent. Tables are refused, since Markdown can't carry them here. The target is kept as a Yjs cursor, so it survives other people's edits and resolves in jot AI's own editor too.

Markdown in, Markdown out

The blocks go to the model as Markdown, exported with the editor's own transformers, and the reply is parsed back with $generateNodesFromMarkdownString to replace them. A model may wrap its answer in a fence, so one is unwrapped only when it's tagged markdown and holds no other fence; an untagged fence stays a code block. Links the model writes keep only http, https and mailto targets.

src/client/editor/ai/markdown.ts
/**
 * The reply as Markdown to write: trimmed, without the passage markers echoed
 * back, and capped. A fence is unwrapped only when it is unambiguously a
 * wrapper: tagged `markdown` or `md`, around the whole reply, with no other
 * fence inside. An untagged fence is a code block, and stays one.
 */
export function cleanReply(reply: string): string {
  let text = reply.replace(/\r\n?/g, '\n').trim();
  const fenced = /^```(?:markdown|md)[ \t]*\n([\s\S]*)\n```$/i.exec(text);
  if (fenced !== null && !/^[ \t]*```/m.test(fenced[1]!)) {
    text = fenced[1]!.trim();
  }
  text = text
    .replace(/^<<<\n?/, '')
    .replace(/\n?>>>$/, '')
    .trim();
  return text.slice(0, MAX_REPLY);
}

Never overwrite a change

The model can take seconds. When it's ready, jot AI reads the target from its own editor: the Markdown for the model, and a lossless snapshot of the blocks. When the reply comes, it writes only if the snapshot is unchanged; otherwise the field says the text changed and nothing is written. Markdown alone wouldn't notice an underline, an alignment or a run of spaces.

src/client/editor/ai/markdown.ts
/**
 * A lossless picture of the blocks (every node's JSON: text, formats, styles,
 * alignment, indents), to tell whether anything changed since. Markdown can't:
 * it drops underline, alignment and runs of spaces.
 */
export function $snapshotBlocks(blocks: readonly ElementNode[]): string {
  const walk = (node: LexicalNode): unknown => {
    const json = node.getLatest().exportJSON() as Record<string, unknown>;
    if ($isElementNode(node)) {
      json.children = node.getChildren().map(walk);
    }
    return json;
  };
  return JSON.stringify(blocks.map(walk));
}

jot AI, a peer in the tab

jot AI joins only once the model is ready, not during a download: a fresh Y.Doc, so a fresh client id, starting from a copy of the note, and a headless Lexical editor (no root element) running YCollabExtension. The two documents stay in step through update events, with origins so nothing echoes back. Its own Awareness is relayed into the note's, so its violet caret shows to everyone, on the blocks it's working on.

ai-peer.ts
import { buildEditorFromExtensions } from '@lexical/extension';
import { RichTextExtension } from '@lexical/rich-text';
import { createYDoc, YCollabExtension } from '@lexical/y';
import * as Y from '@y/y';
import { configExtension, defineExtension } from 'lexical';

/**
 * A second author inside the tab: its own Y.Doc (a fresh client id, so blame
 * can name it) and a headless editor, synced with the note both ways.
 */
export function createPeer(note: Y.Doc) {
  const doc = createYDoc();
  const fromNote = Symbol('from-note');
  const fromPeer = Symbol('from-peer');
  Y.applyUpdate(doc, Y.encodeStateAsUpdate(note), fromNote); // start from the note as it is
  const toPeer = (update: Uint8Array, origin: unknown) => {
    if (origin !== fromPeer) Y.applyUpdate(doc, update, fromNote);
  };
  const toNote = (update: Uint8Array, origin: unknown) => {
    if (origin !== fromNote) Y.applyUpdate(note, update, fromPeer); // never echo back
  };
  note.on('update', toPeer);
  doc.on('update', toNote);

  const editor = buildEditorFromExtensions(
    defineExtension({
      name: 'my-app/peer',
      // Already synced (a copy of the note), and no undo: a peer never undoes.
      dependencies: [
        RichTextExtension,
        configExtension(YCollabExtension, {
          doc,
          schemaVersion: 1, // the same as the note's other clients
          synced: true,
          captureTimeout: null,
        }),
      ],
    }),
  );
  return {
    doc,
    /** Edits as the peer: its writes reach the note before this returns. */
    edit(fn: () => void) {
      editor.update(fn, { discrete: true });
    },
    /** Leaves; what it wrote stays. */
    dispose() {
      note.off('update', toPeer);
      doc.off('update', toNote);
      editor.dispose();
      doc.destroy();
    },
  };
}
  • Blame. jot AI's registry entry has kind ai, one per asker (ai-<person id>) with askedBy, written with its first change, so a cancelled request leaves nothing in the note. When it writes, the asker is registered too (writeLocalEntry, as this browser's own session), so blame says "asked by You" in their tab and their name elsewhere. Its edit goes through its own binding, which keeps the characters a rewrite leaves unchanged, so blame credits jot AI with exactly what changed.
  • Leaving. A peer mirrors every change to the note while it lives, so jot AI leaves a minute after its last request, and joins again, with a new client id, for the next.
  • Undo. jot AI's edit is another client's edit, so your undo doesn't revert it.

The code is in src/client/editor/ai/ and src/client/editor/peers/peer.ts.