[example] Feature: Add a new agent-example using a local LLM#8281
Merged
etrepum merged 92 commits intofacebook:mainfrom Apr 5, 2026
Merged
[example] Feature: Add a new agent-example using a local LLM#8281etrepum merged 92 commits intofacebook:mainfrom
etrepum merged 92 commits intofacebook:mainfrom
Conversation
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
etrepum
commented
Apr 5, 2026
| import StackBlitzButton from './StackBlitzButton'; | ||
|
|
||
| const AgentEditor = React.lazy(() => | ||
| import('@examples/agent-example/Editor').then((m) => ({default: m.Editor})), |
Collaborator
Author
There was a problem hiding this comment.
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 | |||
Collaborator
Author
There was a problem hiding this comment.
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
zurfyx
approved these changes
Apr 5, 2026
This was referenced Apr 6, 2026
[lexical-react] Feature: Add @lexical/react/useExtensionSignalValue module for reading signals
#8286
Merged
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Adds a new agent-example that demonstrates using an in-browser LLM to interact with the lexical editor. Two use cases are presented:
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:
$collectTextNodeOffsetsused by Extract Entities allows the AI target specific locations in the document to replace based on the string we gave itAICursorNode(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 increateDOMand has a no-opdecorate.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.