Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
8cfcebe
ai comment
felixfeng33 Aug 26, 2025
b226b25
fix
felixfeng33 Aug 26, 2025
0f2bc3e
fix
felixfeng33 Aug 26, 2025
5b81c3c
fix
felixfeng33 Aug 27, 2025
2bd7ba7
chore: add changesets and update changelog for AI review feature
claude[bot] Aug 27, 2025
645952c
fix
felixfeng33 Aug 27, 2025
11c33c1
fix ci
felixfeng33 Aug 27, 2025
168573e
withBlock
felixfeng33 Aug 28, 2025
4ac2e4c
refactor
felixfeng33 Aug 30, 2025
9d59b50
fix
felixfeng33 Aug 30, 2025
8a77455
fix
felixfeng33 Aug 31, 2025
ba45ae9
fix
felixfeng33 Aug 31, 2025
f74818f
fix
felixfeng33 Aug 31, 2025
b0f22fd
fix mention deserialize
felixfeng33 Aug 31, 2025
a3f9d0d
lint
felixfeng33 Aug 31, 2025
56d45e9
fix
felixfeng33 Aug 31, 2025
c5ca9ca
fix
felixfeng33 Aug 31, 2025
80db6c3
fix
felixfeng33 Aug 31, 2025
2b9b3c4
fix
felixfeng33 Aug 31, 2025
1f68d91
ai-sdk5
felixfeng33 Sep 1, 2025
3d18e91
fix streaming
felixfeng33 Sep 1, 2025
4801335
fix
felixfeng33 Sep 2, 2025
aaa23c2
api
felixfeng33 Sep 2, 2025
47d4562
comment
felixfeng33 Sep 3, 2025
bf67044
api
felixfeng33 Sep 3, 2025
5ae4c00
test
felixfeng33 Sep 3, 2025
63d6b6b
lint
felixfeng33 Sep 3, 2025
5e0831b
docs
felixfeng33 Sep 3, 2025
ac9da51
fix
felixfeng33 Sep 4, 2025
802d41e
fix
felixfeng33 Sep 4, 2025
c835263
choice => toolName
felixfeng33 Sep 4, 2025
ab171ba
fix
felixfeng33 Sep 4, 2025
d759ce2
fix
felixfeng33 Sep 4, 2025
fa1f820
fix
felixfeng33 Sep 4, 2025
3f7fec8
revert
felixfeng33 Sep 4, 2025
dcf90fc
chore: add changesets and update changelog for AI comment feature
claude[bot] Sep 4, 2025
1bc8131
fix
felixfeng33 Sep 4, 2025
2e78f7d
fix
felixfeng33 Sep 4, 2025
59fba9d
fake ai comment
felixfeng33 Sep 5, 2025
1d0bb4d
docs
felixfeng33 Sep 5, 2025
f459eb3
docs: update API documentation based on changesets
claude[bot] Sep 5, 2025
4b5106a
fix
felixfeng33 Sep 8, 2025
1ed7783
docs
felixfeng33 Sep 8, 2025
ce5d462
fix
felixfeng33 Sep 8, 2025
91614a5
fix
felixfeng33 Sep 8, 2025
1e5a142
fix
felixfeng33 Sep 8, 2025
ff579b3
Merge branch 'main' into feat/ai-comments
felixfeng33 Sep 8, 2025
ecde7a8
fix
zbeyens Sep 8, 2025
049d46f
fix
zbeyens Sep 8, 2025
5282c58
move prompt template to server side
felixfeng33 Sep 9, 2025
facb52b
fix
felixfeng33 Sep 9, 2025
442f265
lint
felixfeng33 Sep 9, 2025
808df79
fix
felixfeng33 Sep 9, 2025
bf2a8f4
fix
felixfeng33 Sep 9, 2025
432ac73
ci
felixfeng33 Sep 9, 2025
3f7f8eb
docs
felixfeng33 Sep 9, 2025
5674186
Merge branch 'main' into feat/ai-comments
felixfeng33 Sep 9, 2025
c678bc6
fix
felixfeng33 Sep 9, 2025
c9b704b
fix
felixfeng33 Sep 9, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .changeset/ai-comment-feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
'@platejs/ai': major
---

Added **AI Comment** functionality to provide AI-powered text feedback and suggestions.And upgrade to AI SDK 5.

### New Features:

- **AI Comment Integration**: New utilities for AI-generated comments on selected text

- `aiCommentToRange()` - Convert AI comments to text ranges with proper block mapping
- `findTextRangeInBlock()` - Find text ranges within blocks for accurate comment positioning

- **Enhanced AI Chat**: Improved chat functionality with comment support

- New `toolName` property in chat helpers for tracking AI tools
- Support for AI comment prompts in chat submissions
- Added `mode`, `toolName` params to `submitAIChat`
- New `toolName` plugin option.

- **Text Matching**: Advanced text matching algorithms
- Longest Common Subsequence (LCS) algorithm for fuzzy text matching
- Support for multi-block text selection and comment ranges
- Accurate text position tracking across block boundaries

### Example:

```typescript
// Convert AI comment to text range
const range = aiCommentToRange(editor, {
blockId: 'block-1',
content: 'Selected text',
comment: 'Consider adding more detail here',
});
```

### Breaking Changes:

- `streamInsertChunk` has been moved from `@platejs/ai` to `@platejs/ai/react`.
- `getEditorPrompt` has been moved from `@platejs/ai/react` to `@platejs/ai`.
- `getMarkdown` has been moved from `@platejs/ai/react` to `@platejs/ai`.
- `promptTemplate` and `systemTemplate` have been removed. They are now used directly in `api/ai/command/route.ts`.
- The placeholder `{selection}` has been renamed to `{blockSelection}`.
9 changes: 9 additions & 0 deletions .changeset/comment-ai-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@platejs/comment": patch
---

Enhanced comment plugin to support AI-generated comments.

### Changes:

- Added a `transient` option to `tf.unsetMark` to allow removing all AI comments at once.
6 changes: 6 additions & 0 deletions .changeset/gentle-suns-unite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@platejs/selection': patch
---

- Added a `selectionFallback` option to `api.getNodes`.
- If `selectionFallback` is set to `true`, and no nodes are selected by `blockSelection`, the method will use the editor's original selection to retrieve blocks.
19 changes: 19 additions & 0 deletions .changeset/markdown-block-id.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@platejs/markdown": patch
---

Added support for preserving block IDs in markdown serialization to enable AI comment tracking.

### Changes:

- **Enhanced Serialization**: Updated `serializeMd` to support `withBlockId` option for maintaining block references

### Example:

```typescript
// Serialize with block IDs preserved
const markdown = serializeMd(editor, {
withBlockId: true
});
// Output: <block id="block-1">Content here</block>
```
6 changes: 4 additions & 2 deletions apps/www/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@
]
},
"dependencies": {
"@ai-sdk/openai": "1.3.22",
"@ai-sdk/google": "2.0.11",
"@ai-sdk/openai": "2.0.23",
"@ai-sdk/react": "2.0.28",
"@ariakit/react": "0.4.17",
"@emoji-mart/data": "1.2.1",
"@faker-js/faker": "9.8.0",
Expand Down Expand Up @@ -115,7 +117,7 @@
"@udecode/cmdk": "workspace:^",
"@udecode/cn": "workspace:^",
"@uploadthing/react": "7.3.1",
"ai": "4.3.16",
"ai": "5.0.28",
"class-variance-authority": "0.7.1",
"cmdk": "1.1.1",
"contentlayer2": "0.4.6",
Expand Down
2 changes: 1 addition & 1 deletion apps/www/public/r/ai-api.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"files": [
{
"path": "src/registry/app/api/ai/command/route.ts",
"content": "import type { NextRequest } from 'next/server';\n\nimport { createOpenAI } from '@ai-sdk/openai';\nimport { convertToCoreMessages, streamText } from 'ai';\nimport { NextResponse } from 'next/server';\n\nimport { markdownJoinerTransform } from '@/registry/lib/markdown-joiner-transform';\n\nexport async function POST(req: NextRequest) {\n const { apiKey: key, messages, system } = await req.json();\n\n const apiKey = key || process.env.OPENAI_API_KEY;\n\n if (!apiKey) {\n return NextResponse.json(\n { error: 'Missing OpenAI API key.' },\n { status: 401 }\n );\n }\n\n const openai = createOpenAI({ apiKey });\n\n try {\n const result = streamText({\n experimental_transform: markdownJoinerTransform(),\n maxTokens: 2048,\n messages: convertToCoreMessages(messages),\n model: openai('gpt-4o'),\n system: system,\n });\n\n return result.toDataStreamResponse();\n } catch {\n return NextResponse.json(\n { error: 'Failed to process AI request' },\n { status: 500 }\n );\n }\n}\n",
"content": "import type {\n ChatMessage,\n ToolName,\n} from '@/registry/components/editor/use-chat';\nimport type { NextRequest } from 'next/server';\n\nimport { google } from '@ai-sdk/google';\nimport { createOpenAI } from '@ai-sdk/openai';\nimport { replacePlaceholders } from '@platejs/ai';\nimport {\n convertToModelMessages,\n createUIMessageStream,\n createUIMessageStreamResponse,\n generateObject,\n streamObject,\n streamText,\n} from 'ai';\nimport { NextResponse } from 'next/server';\nimport { type SlateEditor, createSlateEditor, nanoid, RangeApi } from 'platejs';\nimport { z } from 'zod';\n\nimport { BaseEditorKit } from '@/registry/components/editor/editor-base-kit';\nimport { markdownJoinerTransform } from '@/registry/lib/markdown-joiner-transform';\n\nexport async function POST(req: NextRequest) {\n const {\n apiKey: key,\n ctx,\n messages: messagesParam,\n prompt,\n system,\n } = await req.json();\n\n const { children, selection, toolName: toolNameParam } = ctx;\n\n const editor = createSlateEditor({\n plugins: BaseEditorKit,\n selection,\n value: children,\n });\n\n const apiKey = key || process.env.OPENAI_API_KEY;\n\n if (!apiKey) {\n return NextResponse.json(\n { error: 'Missing OpenAI API key.' },\n { status: 401 }\n );\n }\n\n const openai = createOpenAI({ apiKey });\n\n const isSelecting = editor.api.isExpanded();\n\n const isBlockSelecting = isSelectingAllBlocks(editor);\n\n try {\n const stream = createUIMessageStream<ChatMessage>({\n execute: async ({ writer }) => {\n const messages = replaceMessagePlaceholders(editor, messagesParam, {\n isSelecting,\n });\n\n const lastUserMessage = messages.findLast(\n (message: any) => message.role === 'user'\n );\n\n let toolName = toolNameParam;\n\n if (!toolName) {\n const { object: AIToolName } = await generateObject({\n enum: ['generate', 'edit', 'comment'],\n model: google('gemini-2.5-flash'),\n output: 'enum',\n prompt: `User message:\n ${JSON.stringify(lastUserMessage)}`,\n system: chooseToolSystem,\n });\n\n writer.write({\n data: AIToolName as ToolName,\n type: 'data-toolName',\n });\n\n toolName = AIToolName;\n }\n\n if (toolName === 'generate') {\n const generateSystem = replacePlaceholders(\n editor,\n systemTemplate({ isBlockSelecting, isSelecting }),\n {\n prompt: system,\n }\n );\n\n const gen = streamText({\n experimental_transform: markdownJoinerTransform(),\n maxOutputTokens: 2048,\n messages: convertToModelMessages(messages),\n model: google('gemini-2.5-flash'),\n system: generateSystem,\n });\n\n writer.merge(gen.toUIMessageStream({ sendFinish: false }));\n }\n\n if (toolName === 'edit') {\n const editSystem = replacePlaceholders(\n editor,\n systemTemplate({ isBlockSelecting, isSelecting }),\n {\n prompt: system,\n }\n );\n\n const edit = streamText({\n experimental_transform: markdownJoinerTransform(),\n maxOutputTokens: 2048,\n messages: convertToModelMessages(messages),\n model: google('gemini-2.5-flash'),\n system: editSystem,\n });\n\n writer.merge(edit.toUIMessageStream({ sendFinish: false }));\n }\n\n if (toolName === 'comment') {\n const commentPrompt = replacePlaceholders(\n editor,\n commentTemplate({ isSelecting }),\n {\n prompt,\n }\n );\n\n const { elementStream } = streamObject({\n maxOutputTokens: 2048,\n model: openai('gpt-4o'),\n output: 'array',\n prompt: commentPrompt,\n schema: z\n .object({\n blockId: z\n .string()\n .describe(\n 'The id of the starting block. If the comment spans multiple blocks, use the id of the first block.'\n ),\n comment: z\n .string()\n .describe(\n 'A brief comment or explanation for this fragment.'\n ),\n content: z\n .string()\n .describe(\n String.raw`The original document fragment to be commented on.It can be the entire block, a small part within a block, or span multiple blocks. If spanning multiple blocks, separate them with two \\n\\n.`\n ),\n })\n .describe('A single comment'),\n system: commentSystem,\n });\n\n // Create a single message ID for the entire comment stream\n\n for await (const comment of elementStream) {\n const commentDataId = nanoid();\n // Send each comment as a delta\n\n writer.write({\n id: commentDataId,\n data: comment,\n type: 'data-comment',\n });\n }\n\n return;\n }\n },\n });\n\n return createUIMessageStreamResponse({ stream });\n } catch {\n return NextResponse.json(\n { error: 'Failed to process AI request' },\n { status: 500 }\n );\n }\n}\n\nconst systemTemplate = ({\n isBlockSelecting,\n isSelecting,\n}: {\n isBlockSelecting: boolean;\n isSelecting: boolean;\n}) => {\n return isBlockSelecting\n ? PROMPT_TEMPLATES.systemBlockSelecting\n : isSelecting\n ? PROMPT_TEMPLATES.systemSelecting\n : PROMPT_TEMPLATES.systemDefault;\n};\n\nconst promptTemplate = ({ isSelecting }: { isSelecting: boolean }) => {\n return isSelecting\n ? PROMPT_TEMPLATES.userSelecting\n : PROMPT_TEMPLATES.userDefault;\n};\n\nconst commentTemplate = ({ isSelecting }: { isSelecting: boolean }) => {\n return isSelecting\n ? PROMPT_TEMPLATES.commentSelecting\n : PROMPT_TEMPLATES.commentDefault;\n};\n\nconst chooseToolSystem = `You are a strict classifier. Classify the user's last request as \"generate\", \"edit\", or \"comment\".\n\nPriority rules:\n1. Default is \"generate\". Any open question, idea request, or creation request → \"generate\".\n2. Only return \"edit\" if the user provides original text (or a selection of text) AND asks to change, rephrase, translate, or shorten it.\n3. Only return \"comment\" if the user explicitly asks for comments, feedback, annotations, or review. Do not infer \"comment\" implicitly.\n\nReturn only one enum value with no explanation.`;\n\nconst commentSystem = `You are a document review assistant. \nYou will receive an MDX document wrapped in <block id=\"...\"> content </block> tags. \n\nYour task: \n- Read the content of all blocks and provide comments. \n- For each comment, generate a JSON object: \n - blockId: the id of the block being commented on.\n - content: the original document fragment that needs commenting.\n - comments: a brief comment or explanation for that fragment.\n\nRules:\n- IMPORTANT: If a comment spans multiple blocks, use the id of the **first** block.\n- The **content** field must be the original content inside the block tag. The returned content must not include the block tags, but should retain other MDX tags.\n- IMPORTANT: The **content** field must be flexible:\n - It can cover one full block, only part of a block, or multiple blocks. \n - If multiple blocks are included, separate them with two \\\\n\\\\n. \n - Do NOT default to using the entire block—use the smallest relevant span instead.\n- At least one comment must be provided.\n`;\n\nconst systemCommon = `\\\nYou are an advanced AI-powered note-taking assistant, designed to enhance productivity and creativity in note management.\nRespond directly to user prompts with clear, concise, and relevant content. Maintain a neutral, helpful tone.\n\nRules:\n- <Document> is the entire note the user is working on.\n- <Reminder> is a reminder of how you should reply to INSTRUCTIONS. It does not apply to questions.\n- Anything else is the user prompt.\n- Your response should be tailored to the user's prompt, providing precise assistance to optimize note management.\n- For INSTRUCTIONS: Follow the <Reminder> exactly. Provide ONLY the content to be inserted or replaced. No explanations or comments.\n- For QUESTIONS: Provide a helpful and concise answer. You may include brief explanations if necessary.\n- CRITICAL: DO NOT remove or modify the following custom MDX tags: <u>, <callout>, <kbd>, <toc>, <sub>, <sup>, <mark>, <del>, <date>, <span>, <column>, <column_group>, <file>, <audio>, <video> in <Selection> unless the user explicitly requests this change.\n- CRITICAL: Distinguish between INSTRUCTIONS and QUESTIONS. Instructions typically ask you to modify or add content. Questions ask for information or clarification.\n- CRITICAL: when asked to write in markdown, do not start with \\`\\`\\`markdown.\n- CRITICAL: When writing the column, such line breaks and indentation must be preserved.\n<column_group>\n <column>\n 1\n </column>\n <column>\n 2\n </column>\n <column>\n 3\n </column>\n</column_group>\n`;\n\nconst systemDefault = `\\\n${systemCommon}\n- <Block> is the current block of text the user is working on.\n- Ensure your output can seamlessly fit into the existing <Block> structure.\n\n<Block>\n{block}\n</Block>\n`;\n\nconst systemSelecting = `\\\n${systemCommon}\n- <Block> is the block of text containing the user's selection, providing context.\n- Ensure your output can seamlessly fit into the existing <Block> structure.\n- <Selection> is the specific text the user has selected in the block and wants to modify or ask about.\n- Consider the context provided by <Block>, but only modify <Selection>. Your response should be a direct replacement for <Selection>.\n<Block>\n{block}\n</Block>\n<Selection>\n{selection}\n</Selection>\n`;\n\nconst systemBlockSelecting = `\\\n${systemCommon}\n- <Selection> represents the full blocks of text the user has selected and wants to modify or ask about.\n- Your response should be a direct replacement for the entire <Selection>.\n- Maintain the overall structure and formatting of the selected blocks, unless explicitly instructed otherwise.\n- CRITICAL: Provide only the content to replace <Selection>. Do not add additional blocks or change the block structure unless specifically requested.\n<Selection>\n{block}\n</Selection>\n`;\n\nconst userDefault = `<Reminder>\nCRITICAL: NEVER write <Block>.\n</Reminder>\n{prompt}`;\nconst userSelecting = `<Reminder>\nIf this is a question, provide a helpful and concise answer about <Selection>.\nIf this is an instruction, provide ONLY the text to replace <Selection>. No explanations.\nEnsure it fits seamlessly within <Block>. If <Block> is empty, write ONE random sentence.\nNEVER write <Block> or <Selection>.\n</Reminder>\n{prompt} about <Selection>`;\n\nconst commentSelecting = `{prompt}:\n \n{blockWithBlockId}\n`;\n\nconst commentDefault = `{prompt}:\n \n{editorWithBlockId}\n`;\n\nconst PROMPT_TEMPLATES = {\n commentDefault,\n commentSelecting,\n systemBlockSelecting,\n systemDefault,\n systemSelecting,\n userDefault,\n userSelecting,\n};\n\nconst replaceMessagePlaceholders = (\n editor: SlateEditor,\n messages: ChatMessage[],\n { isSelecting }: { isSelecting: boolean }\n): ChatMessage[] => {\n const template = promptTemplate({ isSelecting });\n\n const lastUserIndex = messages\n .map((m) => (m as any).role)\n .lastIndexOf('user');\n\n if (lastUserIndex === -1) return messages;\n\n const targetMessage = messages[lastUserIndex];\n const parts = targetMessage.parts.map((part) => {\n if (part.type !== 'text' || !part.text) return part;\n\n const text = replacePlaceholders(editor, template, {\n prompt: part.text,\n });\n\n return { ...part, text } as typeof part;\n });\n\n const updatedMessage = { ...targetMessage, parts };\n\n return [\n ...messages.slice(0, lastUserIndex),\n updatedMessage,\n ...messages.slice(lastUserIndex + 1),\n ];\n};\n\n/** Check if the current selection fully covers all top-level blocks. */\nconst isSelectingAllBlocks = (editor: SlateEditor) => {\n const blocksRange = editor.api.nodesRange(\n editor.api.blocks({ mode: 'highest' })\n );\n\n return (\n !!blocksRange &&\n !!editor.selection &&\n RangeApi.equals(blocksRange, editor.selection)\n );\n};\n",
"type": "registry:file",
"target": "app/api/ai/command/route.ts"
}
Expand Down
2 changes: 1 addition & 1 deletion apps/www/public/r/ai-docs.json

Large diffs are not rendered by default.

Loading