Skip to content

[example] Feature: Add a new agent-example using a local LLM#8281

Merged
etrepum merged 92 commits intofacebook:mainfrom
etrepum:claude/ai-agent-rich-text-D3N2G
Apr 5, 2026
Merged

[example] Feature: Add a new agent-example using a local LLM#8281
etrepum merged 92 commits intofacebook:mainfrom
etrepum:claude/ai-agent-rich-text-D3N2G

Conversation

@etrepum
Copy link
Copy Markdown
Collaborator

@etrepum etrepum commented Apr 3, 2026

Description

Adds a new agent-example that demonstrates using an in-browser LLM to interact with the lexical editor. Two use cases are presented:

  • Extract Entities: labels names and places found in the text as decorators
  • Generate: generates some gibberish (the model is small) based on the preceding text

Nearly all of this was "authored" by Claude but you can see from the commit history that it took a whole lot of guidance to get here 😆

stackblitz link
website preview

Uses all of the latest features from lexical including Extensions, DecoratorTextNode, NodeState, and $config. Also has some utilities in useExtensionHooks that we should probably ship in @lexical/react.

Some novel techniques used here:

  • $collectTextNodeOffsets used by Extract Entities allows the AI target specific locations in the document to replace based on the string we gave it
  • AICursorNode (a blinking cursor DecoratorNode) provides an insertion caret for Generate so the LLM has a stable place to insert text even if weird stuff is happening (TextNode merges, markdown shortcuts, whatever). This is temporary and is removed when the generation completes or is aborted. It's also a demonstration of a framework independent decorator that just does its job in createDOM and has a no-op decorate.
  • Generally most editor related code is moved into extensions and out of React which makes the lifecycle simpler and makes it trivial to port to any other frontend ecosystem
ai-agent-demo.mov

Test plan

The example has some unit tests for the more complex functionality it has. Claude wasn't able to successfully one-shot anything so I had to make it write tests.

claude added 30 commits April 1, 2026 19:16
New example demonstrating AI-assisted rich text editing using Lexical
with transformers.js (SmolLM2-135M-Instruct, q4 quantized). Features
include proofread and generate paragraph functionality running entirely
in the browser via WebAssembly in a Web Worker for iOS Safari compatibility.

https://claude.ai/code/session_01MBku4bN9ta1XkiB8J7NTXg
The loading and generating status indicators now pulse to draw attention,
and model download shows a visual progress bar with percentage.

https://claude.ai/code/session_01MBku4bN9ta1XkiB8J7NTXg
Replace inline toolbar status elements with a translucent overlay on the
editor area showing model loading progress and streaming generated text.
This avoids toolbar wrapping and makes the AI activity much more visible.

https://claude.ai/code/session_01MBku4bN9ta1XkiB8J7NTXg
- Use Tailwind CSS v4 instead of separate CSS file
- Use LexicalExtensionComposer + defineExtension (new API)
- Add dark mode support via ThemeToggle
- Stream generated tokens directly into the editor document
- Show model loading progress in a status bar below the editor
- Use proper Lexical public APIs ($getNodeByKey, $isTextNode)
- Match website-toolbar's icon system (SVG masks)
- Add block type selector (headings, quotes)
- Fix proofread/generate using same distinct prompts

https://claude.ai/code/session_01MBku4bN9ta1XkiB8J7NTXg
Empty TextNodes are GC'd by Lexical. Track the paragraph key instead
and append new TextNode children per token, letting Lexical merge them.

https://claude.ai/code/session_01MBku4bN9ta1XkiB8J7NTXg
- Replace self with globalThis cast to avoid no-restricted-globals
- Fix object key ordering (sort-keys-fix)
- Replace optional chaining with if-check (no-optional-chaining)
- Fix import sort order (simple-import-sort)
- Use any instead of Function type
- Add $isElementNode guard for paragraph.append()

https://claude.ai/code/session_01MBku4bN9ta1XkiB8J7NTXg
…globals

The worker file legitimately uses self for postMessage/onmessage. Disable
the no-restricted-globals rule at file level instead of casting globalThis.

https://claude.ai/code/session_01MBku4bN9ta1XkiB8J7NTXg
- useAI exposes abort() which marks the active request as cancelled
- Subsequent tokens/done messages for aborted requests are ignored
- ToolbarPlugin shows a red Stop button while AI is active
- Escape key triggers abort when generating
- proofread/generateParagraph return null when aborted
- Already-streamed text remains in the editor on abort

https://claude.ai/code/session_01MBku4bN9ta1XkiB8J7NTXg
The main thread now posts {type: 'abort'} to the worker, which aborts
the active AbortController. The worker checks the abort signal in the
streamer callback to stop emitting tokens, and after gen() completes
(or throws) it sends an 'aborted' confirmation back. This frees up
the worker for the next request immediately instead of letting the
old inference run to completion in the background.

https://claude.ai/code/session_01MBku4bN9ta1XkiB8J7NTXg
SmolLM2-135M struggles with proofreading (requires precise text
reproduction). Summarization is a better fit — the model compresses
text to key points rather than needing to faithfully reproduce it.

https://claude.ai/code/session_01MBku4bN9ta1XkiB8J7NTXg
- New "Bullet Points" button converts prose into a structured bullet
  list using $createListNode and $createListItemNode from @lexical/list
- Add ListExtension to the editor and list theme classes
- Pre-populate editor with sample text via $initialEditorState for
  quick demonstration
- Add @lexical/list dependency

https://claude.ai/code/session_01MBku4bN9ta1XkiB8J7NTXg
SmolLM2-135M was trained with Smol-Rewrite datasets making text
rewriting a core capability. Replace the bullet points feature with
a style-based rewrite: Formal, Casual, Concise, or Simpler. A
dropdown selector lets users pick the rewrite style before clicking
the Rewrite button.

Also removes @lexical/list dependency and ListExtension since list
nodes are no longer created.

https://claude.ai/code/session_01MBku4bN9ta1XkiB8J7NTXg
Move all React-independent AI logic (worker management, prompt
building, request lifecycle, abort) into a proper Lexical extension
using defineExtension with signals for reactive state.

- AIExtension.ts: new extension with build() creating worker state
  and register() handling KEY_ESCAPE_COMMAND + cleanup
- useAI.ts: thin React wrapper that reads extension output via
  getExtensionDependencyFromEditor and subscribes to signals with
  effect() for React re-renders
- Editor.tsx: adds AIExtension as a dependency
- ToolbarPlugin.tsx: removes manual document keydown listener

https://claude.ai/code/session_01MBku4bN9ta1XkiB8J7NTXg
editor.getEditorState().read() returns the callback value, so use
const with destructuring instead of declaring let variables outside.

https://claude.ai/code/session_01MBku4bN9ta1XkiB8J7NTXg
useAI() calls useLexicalComposerContext() but was invoked in Editor()
which renders the composer — so the context didn't exist yet. Split
into Editor (renders composer) and EditorContent (uses useAI inside
the composer tree).

Also adds lint and typecheck scripts to example package.json and
switches README to use pnpm.

https://claude.ai/code/session_01MBku4bN9ta1XkiB8J7NTXg
- Restyle tone <select> to neutral zinc colors so it looks like a form
  control rather than an action button (was identical indigo styling)
- Simplify rewrite prompt for SmolLM2-135M — shorter instructions work
  better with tiny models
- Add cleanRewriteOutput() to strip instruction preamble the model
  sometimes echoes back and detect verbatim-original-text failures

https://claude.ai/code/session_01MBku4bN9ta1XkiB8J7NTXg
SmolLM2-135M tends to ramble past a single paragraph and drift into
meta-commentary ("I think we can refine..."). Add a stopAt mechanism
in the worker that halts generation when a pattern (e.g. "\n\n") is
found in the accumulated output, keeping only the first paragraph.

https://claude.ai/code/session_01MBku4bN9ta1XkiB8J7NTXg
Adds a new AI-powered entity extraction feature that finds place names
in the editor text and replaces them with interactive PlaceNode
DecoratorNodes linked to Google Maps.

- PlaceNode: inline DecoratorNode rendering a styled chip with map pin
  icon and Google Maps link, registered via PlaceNodeExtension
- NER pipeline: adds Xenova/bert-base-NER (token-classification) to the
  web worker alongside the existing text-generation model, with BIO tag
  merging for multi-token entities
- AIExtension: new extractEntities() method that sends text to the NER
  model and returns typed entity spans with character offsets
- ToolbarPlugin: "Extract Places" button that runs NER, maps character
  offsets back to Lexical TextNodes, splits them, and replaces entity
  spans with PlaceNodes
- Sample text updated with real place names for easy demo

https://claude.ai/code/session_01MBku4bN9ta1XkiB8J7NTXg
Extends NER entity extraction to support three entity types:
- PlaceNode (LOC, emerald) → Google Maps link
- PersonNode (PER, blue) → Wikipedia search link
- OrgNode (ORG, violet) → Google search link

Refactors the extraction logic:
- extractEntityNodes.ts: shared utility with $collectTextNodeOffsets()
  and $replaceTextWithEntityNodes() for reusable offset-mapping and
  text-splitting across any entity type
- Single "Extract Entities" button replaces "Extract Places", runs NER
  once with ['LOC', 'PER', 'ORG'] and dispatches to the correct node
  factory per entity label
- Sample text updated to include people (Sundar Pichai) and orgs
  (Google, Microsoft, Apple, European Space Agency, MIT) alongside
  place names

https://claude.ai/code/session_01MBku4bN9ta1XkiB8J7NTXg
Use people and organizations relevant to Lexical's history: Mark
Zuckerberg, Meta, Bloomberg, Jordan Walke (React creator), Sophie
Alpert, Microsoft, and MIT.

https://claude.ai/code/session_01MBku4bN9ta1XkiB8J7NTXg
Replace fabricated sample text with factual prose drawn from
team.json and git history. Mentions Dominic Gannaway (creator),
Bob Ippolito, Gerard Rovira, Maksim Horbachevsky, Ivaylo Pavlov,
James Fitzsimmons, and Alessio Gravili with their real locations
(London, San Francisco, New York, Melbourne, Vancouver) and
organizations (Meta, Bloomberg, Atticus, Figma).

https://claude.ai/code/session_01MBku4bN9ta1XkiB8J7NTXg
The description now covers all three AI capabilities: text rewriting,
paragraph generation, and named entity extraction (people, places,
organizations). README adds Extract Entities to the feature list and
documents the Xenova/bert-base-NER model alongside SmolLM2.

https://claude.ai/code/session_01MBku4bN9ta1XkiB8J7NTXg
When the NER model finished loading, the 'ner-ready' handler reset
loadProgress but never set modelStatus back to 'ready', leaving the
UI permanently in the loading state. Add the missing status update.

https://claude.ai/code/session_01MBku4bN9ta1XkiB8J7NTXg
The previous approach processed entities one at a time in reverse
order, but each splitText + replace invalidated the original node key
so subsequent entities in the same TextNode silently failed.

New approach: group entities by their parent TextNode, compute all
split points at once per node, split once, then replace each entity
segment. This keeps node references valid throughout.

https://claude.ai/code/session_01MBku4bN9ta1XkiB8J7NTXg
…t offsets

The root cause was that $collectTextNodeOffsets used character offsets
relative to $getRoot().getTextContent(), which inserts '\n\n' between
paragraphs. The offset counting tried to replicate this separator logic
but was fragile and incorrect for multi-paragraph documents.

New approach: $collectTextNodeOffsets now builds its own plain-text
string by concatenating TextNode contents with a single space between
block-level elements. NER runs on this joined string, and offsets map
directly back to TextNode positions without any separator arithmetic.

Also fixes $replaceTextWithEntityNodes to split each TextNode once
(computing all split points upfront) so node references stay valid
when multiple entities share the same TextNode.

Adds 13 unit tests covering:
- Single/multiple text node offset collection
- Paragraph break handling
- Entity replacement at start/middle/end of text
- Multiple entities in same text node
- Multiple entity types (PER, LOC, ORG)
- Adjacent entities with no gap
- Cross-paragraph entities
- Realistic sample text with mixed entity types

https://claude.ai/code/session_01MBku4bN9ta1XkiB8J7NTXg
- Add vitest and jsdom as devDependencies to agent-example
- Create examples/agent-example/vitest.config.mts for local test runs
- Add test and test:watch scripts to agent-example package.json
- Revert monorepo vitest.config.mts to only include packages/**

Tests now run via `cd examples/agent-example && pnpm test`.

https://claude.ai/code/session_01MBku4bN9ta1XkiB8J7NTXg
Logs at each stage: text collection, NER request to worker,
raw/merged/filtered entities in worker, entity response receipt
in AIExtension, and replacement in ToolbarPlugin. This helps
diagnose where the pipeline stalls.

https://claude.ai/code/session_01MBku4bN9ta1XkiB8J7NTXg
…okens

The transformers.js token-classification pipeline does not provide
start/end character offsets (it's a TODO in the library). The raw
tokens only have entity, score, index, and word (with WordPiece ##
prefixes for subword tokens).

Add computeTokenOffsets() that reconstructs character positions by
walking tokens in document order and finding each word sequentially
in the original text. Subword tokens (##prefix) continue immediately
from the cursor; full tokens advance past whitespace to the next match.

Also removes debug console.log statements from AIExtension,
ToolbarPlugin, and ai-worker.

https://claude.ai/code/session_01MBku4bN9ta1XkiB8J7NTXg
…subword merging

Three fixes:

1. Word-boundary matching: computeTokenOffsets now requires non-subword
   tokens to match at a word boundary (preceded by non-alphanumeric or
   start of text). Previously "At" in "Atticus" would match "at" inside
   "collaborative" due to case-insensitive indexOf.

2. Subword continuation: mergeEntities now always treats ## WordPiece
   tokens as continuations of the previous entity, regardless of B-/I-
   tag. The NER model sometimes incorrectly tags subwords like "##ks"
   as B-PER instead of I-PER (e.g. "Maksim" → B-PER "Ma", B-PER "##ks",
   B-PER "##im"). Since ## tokens are by definition subwords, they
   can never start a new entity.

3. Extracted NERToken interface, computeTokenOffsets, and mergeEntities
   from ai-worker.ts into ai/mergeEntities.ts so they can be imported
   by both the worker and the test suite.

Adds 16 unit tests for computeTokenOffsets and mergeEntities using the
real raw NER output from Xenova/bert-base-NER, verifying:
- Simple and WordPiece token offset computation
- Case-insensitive matching with word boundaries
- BIO tag merging (B-I sequences, separate B- tokens, label mismatches)
- Subword continuation override (## always continues)
- Score aggregation (minimum across merged tokens)
- Full real-data integration test with the sample text
- Offset consistency (sliced text matches, bounds valid, non-overlapping)

https://claude.ai/code/session_01MBku4bN9ta1XkiB8J7NTXg
CI fails because the agent-example's node_modules aren't installed
in the website workspace. Adding the dependency directly ensures
rspack can resolve the import in the worker bundle.

https://claude.ai/code/session_01MBku4bN9ta1XkiB8J7NTXg
Infima's global a tag styles (color, text-decoration) override
Tailwind utility classes on entity links because unlayered styles
have higher precedence than Tailwind's layered utilities.

Fix by resetting link styles inside [contenteditable] elements
to inherit, allowing the Tailwind classes to take effect.

Also expand the @source directive to scan all examples (not just
website-*) so Tailwind picks up classes from the agent example.

https://claude.ai/code/session_01MBku4bN9ta1XkiB8J7NTXg
Detect WebGPU support via navigator.gpu in the worker and use it
as the device for both text generation and NER pipelines. Falls
back to WASM when WebGPU is unavailable. WebGPU uses fp32 dtype
(required by the WebGPU backend) while WASM uses quantized types
(q4/q8) for smaller download size.

Also upgrade @huggingface/transformers from v3 to v4 in both the
agent-example and website packages.

https://claude.ai/code/session_01MBku4bN9ta1XkiB8J7NTXg
The agent-example is excluded from the pnpm workspace, so its
node_modules aren't installed during website CI builds. Alias
@huggingface/transformers to the web entry point from the
website's own node_modules, ensuring the worker always uses the
browser build (no node: imports) regardless of bundler context.

https://claude.ai/code/session_01MBku4bN9ta1XkiB8J7NTXg
WebGPU causes crashes on iOS Safari. Revert to WASM-only with
quantized dtypes (q4 for text generation, q8 for NER).

https://claude.ai/code/session_01MBku4bN9ta1XkiB8J7NTXg
import StackBlitzButton from './StackBlitzButton';

const AgentEditor = React.lazy(() =>
import('@examples/agent-example/Editor').then((m) => ({default: m.Editor})),
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is mostly to force transformers.js to be loaded browser only and to avoid having to add some SSR code to manage the preloaded editor content. I'm sure this could be worked around differently but it's below the fold so SSR is not very important anyhow.

@@ -5,14 +5,13 @@
* LICENSE file in the root directory of this source tree.
*
*/
// @ts-check
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file was refactored from js to mjs because generally we should be moving everything to esm, but the target change here was to fix a latent issue introduced with the new website where the next update-tsconfig would've removed some manually added paths to support the embedded example editors

@etrepum etrepum added this pull request to the merge queue Apr 5, 2026
Merged via the queue into facebook:main with commit ec73025 Apr 5, 2026
40 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. extended-tests Run extended e2e tests on a PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants