diff --git a/.changeset/ai-table-enhancements.md b/.changeset/ai-table-enhancements.md new file mode 100644 index 0000000000..6ea282bd77 --- /dev/null +++ b/.changeset/ai-table-enhancements.md @@ -0,0 +1,16 @@ +--- +"@platejs/ai": minor +--- + +Upgraded AI SDK from v5 to v6: + +- Updated `ai` peer dependency to `^6.0.0` +- Updated `@ai-sdk/react` peer dependency to `^3.0.0` + +Enhanced AI capabilities with better table cell handling: + +- Added `applyTableCellSuggestion` utility for handling single-cell table operations +- Added `nestedContainerUtils` for managing nested containers in table cells +- Enhanced `getMarkdown` with improved table structure handling and better cell content serialization +- Improved `applyAISuggestions` with more robust cell manipulation support +- Added comprehensive tests for markdown generation from complex table structures diff --git a/.changeset/markdown-table-cells.md b/.changeset/markdown-table-cells.md new file mode 100644 index 0000000000..6df646d7b2 --- /dev/null +++ b/.changeset/markdown-table-cells.md @@ -0,0 +1,8 @@ +--- +"@platejs/markdown": patch +--- + +Enhanced table cell serialization to support multiple blocks within cells: + +- Table cells (td/th) now insert `
` separators between multiple blocks when serializing to markdown +- This allows markdown tables to better represent complex cell content that contains multiple paragraphs or other block elements diff --git a/apps/www/package.json b/apps/www/package.json index 17fa372329..b1f9ead8de 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -40,9 +40,10 @@ ] }, "dependencies": { - "@ai-sdk/google": "2.0.11", - "@ai-sdk/openai": "2.0.23", - "@ai-sdk/react": "2.0.28", + "@ai-sdk/gateway": "^3.0.0", + "@ai-sdk/google": "^3.0.0", + "@ai-sdk/openai": "^3.0.0", + "@ai-sdk/react": "^3.0.0", "@ariakit/react": "0.4.17", "@emoji-mart/data": "1.2.1", "@excalidraw/excalidraw": "0.18.0", @@ -118,7 +119,7 @@ "@udecode/cmdk": "workspace:^", "@udecode/cn": "workspace:^", "@uploadthing/react": "7.3.1", - "ai": "5.0.28", + "ai": "^6.0.0", "babel-plugin-react-compiler": "1.0.0", "class-variance-authority": "0.7.1", "cmdk": "1.1.1", diff --git a/apps/www/public/r/ai-api.json b/apps/www/public/r/ai-api.json index 70785f524d..b2e6593b89 100644 --- a/apps/www/public/r/ai-api.json +++ b/apps/www/public/r/ai-api.json @@ -14,21 +14,57 @@ "files": [ { "path": "src/registry/app/api/ai/command/route.ts", - "content": "import type {\n ChatMessage,\n ToolName,\n} from '@/registry/components/editor/use-chat';\nimport type { NextRequest } from 'next/server';\n\nimport { createGateway } from '@ai-sdk/gateway';\nimport {\n type LanguageModel,\n type UIMessageStreamWriter,\n createUIMessageStream,\n createUIMessageStreamResponse,\n generateObject,\n streamObject,\n streamText,\n tool,\n} from 'ai';\nimport { NextResponse } from 'next/server';\nimport { type SlateEditor, createSlateEditor, nanoid } 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\nimport {\n getChooseToolPrompt,\n getCommentPrompt,\n getEditPrompt,\n getGeneratePrompt,\n} from './prompts';\n\nexport async function POST(req: NextRequest) {\n const {\n apiKey: key,\n ctx,\n messages: messagesRaw = [],\n model,\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.AI_GATEWAY_API_KEY;\n\n if (!apiKey) {\n return NextResponse.json(\n { error: 'Missing AI Gateway API key.' },\n { status: 401 }\n );\n }\n\n const isSelecting = editor.api.isExpanded();\n\n const gatewayProvider = createGateway({\n apiKey,\n });\n\n try {\n const stream = createUIMessageStream({\n execute: async ({ writer }) => {\n let toolName = toolNameParam;\n\n if (!toolName) {\n const { object: AIToolName } = await generateObject({\n enum: isSelecting\n ? ['generate', 'edit', 'comment']\n : ['generate', 'comment'],\n model: gatewayProvider(model || 'google/gemini-2.5-flash'),\n output: 'enum',\n prompt: getChooseToolPrompt(messagesRaw),\n });\n\n writer.write({\n data: AIToolName as ToolName,\n type: 'data-toolName',\n });\n\n toolName = AIToolName;\n }\n\n const stream = streamText({\n experimental_transform: markdownJoinerTransform(),\n model: gatewayProvider(model || 'openai/gpt-4o-mini'),\n // Not used\n prompt: '',\n tools: {\n comment: getCommentTool(editor, {\n messagesRaw,\n model: gatewayProvider(model || 'google/gemini-2.5-flash'),\n writer,\n }),\n },\n prepareStep: async (step) => {\n if (toolName === 'comment') {\n return {\n ...step,\n toolChoice: { toolName: 'comment', type: 'tool' },\n };\n }\n\n if (toolName === 'edit') {\n const editPrompt = getEditPrompt(editor, {\n isSelecting,\n messages: messagesRaw,\n });\n\n return {\n ...step,\n activeTools: [],\n messages: [\n {\n content: editPrompt,\n role: 'user',\n },\n ],\n };\n }\n\n if (toolName === 'generate') {\n const generatePrompt = getGeneratePrompt(editor, {\n messages: messagesRaw,\n });\n\n return {\n ...step,\n activeTools: [],\n messages: [\n {\n content: generatePrompt,\n role: 'user',\n },\n ],\n model: gatewayProvider(model || 'openai/gpt-4o-mini'),\n };\n }\n },\n });\n\n writer.merge(stream.toUIMessageStream({ sendFinish: false }));\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 getCommentTool = (\n editor: SlateEditor,\n {\n messagesRaw,\n model,\n writer,\n }: {\n messagesRaw: ChatMessage[];\n model: LanguageModel;\n writer: UIMessageStreamWriter;\n }\n) =>\n tool({\n description: 'Comment on the content',\n inputSchema: z.object({}),\n execute: async () => {\n const { elementStream } = streamObject({\n model,\n output: 'array',\n prompt: getCommentPrompt(editor, {\n messages: messagesRaw,\n }),\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('A brief comment or explanation for this fragment.'),\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 });\n\n for await (const comment of elementStream) {\n const commentDataId = nanoid();\n\n writer.write({\n id: commentDataId,\n data: {\n comment,\n status: 'streaming',\n },\n type: 'data-comment',\n });\n }\n\n writer.write({\n id: nanoid(),\n data: {\n comment: null,\n status: 'finished',\n },\n type: 'data-comment',\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 { createGateway } from '@ai-sdk/gateway';\nimport {\n type LanguageModel,\n type UIMessageStreamWriter,\n createUIMessageStream,\n createUIMessageStreamResponse,\n generateObject,\n streamObject,\n streamText,\n tool,\n} from 'ai';\nimport { NextResponse } from 'next/server';\nimport { type SlateEditor, createSlateEditor, nanoid } 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\nimport {\n buildEditTableMultiCellPrompt,\n getChooseToolPrompt,\n getCommentPrompt,\n getEditPrompt,\n getGeneratePrompt,\n} from './prompt';\n\nexport async function POST(req: NextRequest) {\n const { apiKey: key, ctx, messages: messagesRaw, model } = 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.AI_GATEWAY_API_KEY;\n\n if (!apiKey) {\n return NextResponse.json(\n { error: 'Missing AI Gateway API key.' },\n { status: 401 }\n );\n }\n\n const isSelecting = editor.api.isExpanded();\n\n const gatewayProvider = createGateway({\n apiKey,\n });\n\n try {\n const stream = createUIMessageStream({\n execute: async ({ writer }) => {\n let toolName = toolNameParam;\n\n if (!toolName) {\n const prompt = getChooseToolPrompt({\n isSelecting,\n messages: messagesRaw,\n });\n\n const enumOptions = isSelecting\n ? ['generate', 'edit', 'comment']\n : ['generate', 'comment'];\n const modelId = model || 'google/gemini-2.5-flash';\n\n const { object: AIToolName } = await generateObject({\n enum: enumOptions,\n model: gatewayProvider(modelId),\n output: 'enum',\n prompt,\n });\n\n writer.write({\n data: AIToolName as ToolName,\n type: 'data-toolName',\n });\n\n toolName = AIToolName;\n }\n\n const stream = streamText({\n experimental_transform: markdownJoinerTransform(),\n model: gatewayProvider(model || 'openai/gpt-4o-mini'),\n // Not used\n prompt: '',\n tools: {\n comment: getCommentTool(editor, {\n messagesRaw,\n model: gatewayProvider(model || 'google/gemini-2.5-flash'),\n writer,\n }),\n table: getTableTool(editor, {\n messagesRaw,\n model: gatewayProvider(model || 'google/gemini-2.5-flash'),\n writer,\n }),\n },\n prepareStep: async (step) => {\n if (toolName === 'comment') {\n return {\n ...step,\n toolChoice: { toolName: 'comment', type: 'tool' },\n };\n }\n\n if (toolName === 'edit') {\n const [editPrompt, editType] = getEditPrompt(editor, {\n isSelecting,\n messages: messagesRaw,\n });\n\n // Table editing uses the table tool\n if (editType === 'table') {\n return {\n ...step,\n toolChoice: { toolName: 'table', type: 'tool' },\n };\n }\n\n return {\n ...step,\n activeTools: [],\n model:\n editType === 'selection'\n ? //The selection task is more challenging, so we chose to use Gemini 2.5 Flash.\n gatewayProvider(model || 'google/gemini-2.5-flash')\n : gatewayProvider(model || 'openai/gpt-4o-mini'),\n messages: [\n {\n content: editPrompt,\n role: 'user',\n },\n ],\n };\n }\n\n if (toolName === 'generate') {\n const generatePrompt = getGeneratePrompt(editor, {\n isSelecting,\n messages: messagesRaw,\n });\n\n return {\n ...step,\n activeTools: [],\n messages: [\n {\n content: generatePrompt,\n role: 'user',\n },\n ],\n model: gatewayProvider(model || 'openai/gpt-4o-mini'),\n };\n }\n },\n });\n\n writer.merge(stream.toUIMessageStream({ sendFinish: false }));\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 getCommentTool = (\n editor: SlateEditor,\n {\n messagesRaw,\n model,\n writer,\n }: {\n messagesRaw: ChatMessage[];\n model: LanguageModel;\n writer: UIMessageStreamWriter;\n }\n) =>\n tool({\n description: 'Comment on the content',\n inputSchema: z.object({}),\n execute: async () => {\n const { elementStream } = streamObject({\n model,\n output: 'array',\n prompt: getCommentPrompt(editor, {\n messages: messagesRaw,\n }),\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('A brief comment or explanation for this fragment.'),\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 });\n\n for await (const comment of elementStream) {\n const commentDataId = nanoid();\n\n writer.write({\n id: commentDataId,\n data: {\n comment,\n status: 'streaming',\n },\n type: 'data-comment',\n });\n }\n\n writer.write({\n id: nanoid(),\n data: {\n comment: null,\n status: 'finished',\n },\n type: 'data-comment',\n });\n },\n });\n\nconst getTableTool = (\n editor: SlateEditor,\n {\n messagesRaw,\n model,\n writer,\n }: {\n messagesRaw: ChatMessage[];\n model: LanguageModel;\n writer: UIMessageStreamWriter;\n }\n) =>\n tool({\n description: 'Edit table cells',\n inputSchema: z.object({}),\n execute: async () => {\n const { elementStream } = streamObject({\n model,\n output: 'array',\n prompt: buildEditTableMultiCellPrompt(editor, messagesRaw),\n schema: z\n .object({\n content: z\n .string()\n .describe(\n String.raw`The new content for the cell. Can contain multiple paragraphs separated by \\n\\n.`\n ),\n id: z.string().describe('The id of the table cell to update.'),\n })\n .describe('A table cell update'),\n });\n\n for await (const cellUpdate of elementStream) {\n writer.write({\n id: nanoid(),\n data: {\n cellUpdate,\n status: 'streaming',\n },\n type: 'data-table',\n });\n }\n\n writer.write({\n id: nanoid(),\n data: {\n cellUpdate: null,\n status: 'finished',\n },\n type: 'data-table',\n });\n },\n });\n", "type": "registry:file", "target": "app/api/ai/command/route.ts" }, { "path": "src/registry/app/api/ai/command/utils.ts", - "content": "import type { ChatMessage } from '@/registry/components/editor/use-chat';\nimport type { UIMessage } from 'ai';\n\nimport { getMarkdown } from '@platejs/ai';\nimport { serializeMd } from '@platejs/markdown';\nimport dedent from 'dedent';\nimport { type SlateEditor, RangeApi } from 'platejs';\n\n/**\n * Tag content split by newlines\n *\n * @example\n * \n * {content}\n * \n */\nexport const tag = (tag: string, content?: string | null) => {\n if (!content) return '';\n\n return [`<${tag}>`, content, ``].join('\\n');\n};\n\n/**\n * Tag content inline\n *\n * @example\n * {content}\n */\nexport const inlineTag = (tag: string, content?: string | null) => {\n if (!content) return '';\n\n return [`<${tag}>`, content, ``].join('');\n};\n\n// Sections split by double newlines\nexport const sections = (sections: (boolean | string | null | undefined)[]) =>\n sections.filter(Boolean).join('\\n\\n');\n\n// List items split by newlines\nexport const list = (items: string[] | undefined) =>\n items\n ? items\n .filter(Boolean)\n .map((item) => `- ${item}`)\n .join('\\n')\n : '';\n\nexport type StructuredPromptSections = {\n backgroundData?: string;\n examples?: string[] | string;\n history?: string;\n outputFormatting?: string;\n prefilledResponse?: string;\n question?: string;\n rules?: string;\n task?: string;\n taskContext?: string;\n thinking?: string;\n tone?: string;\n tools?: string;\n};\n\n/**\n * Build a structured prompt following best practices for AI interactions.\n *\n * @example\n * https://imgur.com/carbon-Db5tDUh\n * 1. Task context - You will be acting as an AI career coach named Joe created by the company\n * AdAstra Careers. Your goal is to give career advice to users. You will be replying to users\n * who are on the AdAstra site and who will be confused if you don't respond in the character of Joe.\n * 2. Tone context - You should maintain a friendly customer service tone.\n * 3. Background data - Here is the career guidance document you should reference when answering the user: {DOCUMENT}\n * 3b. Tools - Available tool descriptions\n * 4. Rules - Here are some important rules for the interaction:\n * - Always stay in character, as Joe, an AI from AdAstra careers\n * - If you are unsure how to respond, say \"Sorry, I didn't understand that. Could you repeat the question?\"\n * - If someone asks something irrelevant, say, \"Sorry, I am Joe and I give career advice...\"\n * 5. Examples - Here is an example of how to respond in a standard interaction:\n * \n * User: Hi, how were you created and what do you do?\n * Joe: Hello! My name is Joe, and I was created by AdAstra Careers to give career advice...\n * \n * 6. Conversation history - Here is the conversation history (between the user and you) prior to the question. {HISTORY}\n * 6b. Question - Here is the user's question: {QUESTION}\n * 7. Immediate task - How do you respond to the user's question?\n * 8. Thinking - Think about your answer first before you respond.\n * 9. Output formatting - Put your response in tags.\n * 11. Prefilled response - Optional response starter\n */\nexport const buildStructuredPrompt = ({\n backgroundData,\n examples,\n history,\n outputFormatting,\n prefilledResponse,\n question,\n rules,\n task,\n taskContext,\n thinking,\n tone,\n}: StructuredPromptSections) => {\n const formattedExamples = Array.isArray(examples)\n ? examples.map((example) => tag('example', example)).join('\\n')\n : examples;\n\n const context = sections([\n taskContext,\n tone,\n\n backgroundData &&\n dedent`\n Here is the background data you should reference when answering the user:\n \n ${backgroundData}\n \n `,\n rules &&\n dedent`\n Here are some important rules for the interaction:\n ${rules}\n `,\n\n formattedExamples &&\n dedent`\n Here are some examples of how to respond in a standard interaction:\n ${tag('examples', formattedExamples)}\n `,\n\n history &&\n dedent`\n Here is the conversation history (between the user and you) prior to the question:\n ${tag('history', history)}\n `,\n\n question &&\n dedent`\n Here is the user's question:\n ${tag('question', question)}\n `,\n ]);\n\n return sections([\n tag('context', context),\n task,\n // or \n thinking && tag('thinking', thinking),\n // Not needed with structured output\n outputFormatting && tag('outputFormatting', outputFormatting),\n // Not needed with structured output\n (prefilledResponse ?? null) !== null &&\n tag('prefilledResponse', prefilledResponse ?? ''),\n ]);\n};\n\nexport function getTextFromMessage(message: UIMessage): string {\n return message.parts\n .filter((part) => part.type === 'text')\n .map((part) => part.text)\n .join('');\n}\n\n/**\n * Format conversation history for prompts. Extracts text from messages and\n * formats as ROLE: text.\n */\nexport function formatTextFromMessages(\n messages: ChatMessage[],\n options?: { limit?: number }\n): string {\n const historyMessages = options?.limit\n ? messages.slice(-options.limit)\n : messages;\n\n return historyMessages\n .map((message) => {\n const text = getTextFromMessage(message).trim();\n if (!text) return null;\n const role = message.role.toUpperCase();\n return `${role}: ${text}`;\n })\n .filter(Boolean)\n .join('\\n');\n}\n\nconst SELECTION_START = '';\nconst SELECTION_END = '';\n\nexport const addSelection = (editor: SlateEditor) => {\n if (!editor.selection) return;\n if (editor.api.isExpanded()) {\n const [start, end] = RangeApi.edges(editor.selection);\n\n editor.tf.withoutNormalizing(() => {\n editor.tf.insertText(SELECTION_END, {\n at: end,\n });\n\n editor.tf.insertText(SELECTION_START, {\n at: start,\n });\n });\n }\n};\n\nconst removeEscapeSelection = (editor: SlateEditor, text: string) => {\n let newText = text\n .replace(`\\\\${SELECTION_START}`, SELECTION_START)\n .replace(`\\\\${SELECTION_END}`, SELECTION_END);\n\n // If the selection is on a void element, inserting the placeholder will fail, and the string must be replaced manually.\n if (!newText.includes(SELECTION_END)) {\n const [_, end] = RangeApi.edges(editor.selection!);\n\n const node = editor.api.block({ at: end.path });\n\n if (!node) return newText;\n if (editor.api.isVoid(node[0])) {\n const voidString = serializeMd(editor, { value: [node[0]] });\n\n const idx = newText.lastIndexOf(voidString);\n\n if (idx !== -1) {\n newText =\n newText.slice(0, idx) +\n voidString.trimEnd() +\n SELECTION_END +\n newText.slice(idx + voidString.length);\n }\n }\n }\n\n return newText;\n};\n\n/** Check if the current selection fully covers all top-level blocks. */\nexport const isMultiBlocks = (editor: SlateEditor) => {\n const blocks = editor.api.blocks({ mode: 'highest' });\n\n return blocks.length > 1;\n};\n\n/** Get markdown with selection markers */\nexport const getMarkdownWithSelection = (editor: SlateEditor) =>\n removeEscapeSelection(editor, getMarkdown(editor, { type: 'block' }));\n", + "content": "import type { ChatMessage } from '@/registry/components/editor/use-chat';\nimport type { UIMessage } from 'ai';\n\nimport { getMarkdown } from '@platejs/ai';\nimport { serializeMd } from '@platejs/markdown';\nimport dedent from 'dedent';\nimport { type SlateEditor, KEYS, RangeApi } from 'platejs';\n\n/**\n * Tag content split by newlines\n *\n * @example\n * \n * {content}\n * \n */\nexport const tag = (tag: string, content?: string | null) => {\n if (!content) return '';\n\n return [`<${tag}>`, content, ``].join('\\n');\n};\n\n/**\n * Tag content inline\n *\n * @example\n * {content}\n */\nexport const inlineTag = (tag: string, content?: string | null) => {\n if (!content) return '';\n\n return [`<${tag}>`, content, ``].join('');\n};\n\n// Sections split by double newlines\nexport const sections = (sections: (boolean | string | null | undefined)[]) =>\n sections.filter(Boolean).join('\\n\\n');\n\n// List items split by newlines\nexport const list = (items: string[] | undefined) =>\n items\n ? items\n .filter(Boolean)\n .map((item) => `- ${item}`)\n .join('\\n')\n : '';\n\nexport type StructuredPromptSections = {\n context?: string;\n examples?: string[] | string;\n history?: string;\n instruction?: string;\n outputFormatting?: string;\n prefilledResponse?: string;\n rules?: string;\n task?: string;\n taskContext?: string;\n thinking?: string;\n tone?: string;\n};\n\n/**\n * Build a structured prompt following best practices for AI interactions.\n *\n * @example\n * https://imgur.com/carbon-Db5tDUh\n * 1. Task context - You will be acting as an AI career coach named Joe created by the company\n * AdAstra Careers. Your goal is to give career advice to users. You will be replying to users\n * who are on the AdAstra site and who will be confused if you don't respond in the character of Joe.\n * 2. Tone context - You should maintain a friendly customer service tone.\n * 3. Background data - Here is the career guidance document you should reference when answering the user: {DOCUMENT}\n * 3b. Tools - Available tool descriptions\n * 4. Rules - Here are some important rules for the interaction:\n * - Always stay in character, as Joe, an AI from AdAstra careers\n * - If you are unsure how to respond, say \"Sorry, I didn't understand that. Could you repeat the question?\"\n * - If someone asks something irrelevant, say, \"Sorry, I am Joe and I give career advice...\"\n * 5. Examples - Here is an example of how to respond in a standard interaction:\n * \n * User: Hi, how were you created and what do you do?\n * Joe: Hello! My name is Joe, and I was created by AdAstra Careers to give career advice...\n * \n * 6. Conversation history - Here is the conversation history (between the user and you) prior to the question. {HISTORY}\n * 6b. Question - Here is the user's question: {QUESTION}\n * 7. Immediate task - How do you respond to the user's question?\n * 8. Thinking - Think about your answer first before you respond.\n * 9. Output formatting - Put your response in tags.\n * 11. Prefilled response - Optional response starter\n */\nexport const buildStructuredPrompt = ({\n context,\n examples,\n history,\n instruction,\n outputFormatting,\n prefilledResponse,\n rules,\n task,\n taskContext,\n thinking,\n tone,\n}: StructuredPromptSections) => {\n const formattedExamples = Array.isArray(examples)\n ? examples\n .map((example) => {\n // Indent content inside example tag (4 spaces)\n const indentedContent = example\n .split('\\n')\n .map((line) => (line ? ` ${line}` : ''))\n .join('\\n');\n\n return [' ', indentedContent, ' '].join('\\n');\n })\n .join('\\n')\n : examples;\n\n return sections([\n taskContext,\n tone,\n\n task && tag('task', task),\n\n instruction &&\n dedent`\n Here is the user's instruction (this is what you need to respond to):\n ${tag('instruction', instruction)}\n `,\n\n context &&\n dedent`\n Here is the context you should reference when answering the user:\n ${tag('context', context)}\n `,\n\n rules && tag('rules', rules),\n\n formattedExamples &&\n 'Here are some examples of how to respond in a standard interaction:\\n' +\n tag('examples', formattedExamples),\n\n history &&\n dedent`\n Here is the conversation history (between the user and you) prior to the current instruction:\n ${tag('history', history)}\n `,\n\n // or \n thinking && tag('thinking', thinking),\n // Not needed with structured output\n outputFormatting && tag('outputFormatting', outputFormatting),\n // Not needed with structured output\n (prefilledResponse ?? null) !== null &&\n tag('prefilledResponse', prefilledResponse ?? ''),\n ]);\n};\n\nexport function getTextFromMessage(message: UIMessage): string {\n return message.parts\n .filter((part) => part.type === 'text')\n .map((part) => part.text)\n .join('');\n}\n\n/**\n * Format conversation history for prompts. Extracts text from messages and\n * formats as ROLE: text. Returns empty string if only one message (no history needed).\n */\nexport function formatTextFromMessages(\n messages: ChatMessage[],\n options?: { limit?: number }\n): string {\n // No history needed if no messages or only one message\n if (!messages || messages.length <= 1) return '';\n\n const historyMessages = options?.limit\n ? messages.slice(-options.limit)\n : messages;\n\n return historyMessages\n .map((message) => {\n const text = getTextFromMessage(message).trim();\n\n if (!text) return null;\n\n const role = message.role.toUpperCase();\n\n return `${role}: ${text}`;\n })\n .filter(Boolean)\n .join('\\n');\n}\n\n/**\n * Get the last user message text from messages array.\n */\nexport function getLastUserInstruction(messages: ChatMessage[]): string {\n if (!messages || messages.length === 0) return '';\n\n const lastUserMessage = [...messages]\n .reverse()\n .find((m) => m.role === 'user');\n\n if (!lastUserMessage) return '';\n\n return getTextFromMessage(lastUserMessage).trim();\n}\n\nconst SELECTION_START = '';\nconst SELECTION_END = '';\n\nexport const addSelection = (editor: SlateEditor) => {\n if (!editor.selection) return;\n if (editor.api.isExpanded()) {\n const [start, end] = RangeApi.edges(editor.selection);\n\n editor.tf.withoutNormalizing(() => {\n editor.tf.insertText(SELECTION_END, {\n at: end,\n });\n\n editor.tf.insertText(SELECTION_START, {\n at: start,\n });\n });\n }\n};\n\nconst removeEscapeSelection = (editor: SlateEditor, text: string) => {\n let newText = text\n .replace(`\\\\${SELECTION_START}`, SELECTION_START)\n .replace(`\\\\${SELECTION_END}`, SELECTION_END);\n\n // If the selection is on a void element, inserting the placeholder will fail, and the string must be replaced manually.\n if (!newText.includes(SELECTION_END)) {\n const [_, end] = RangeApi.edges(editor.selection!);\n\n const node = editor.api.block({ at: end.path });\n\n if (!node) return newText;\n if (editor.api.isVoid(node[0])) {\n const voidString = serializeMd(editor, { value: [node[0]] });\n\n const idx = newText.lastIndexOf(voidString);\n\n if (idx !== -1) {\n newText =\n newText.slice(0, idx) +\n voidString.trimEnd() +\n SELECTION_END +\n newText.slice(idx + voidString.length);\n }\n }\n }\n\n return newText;\n};\n\n/** Check if the current selection fully covers all top-level blocks. */\nexport const isMultiBlocks = (editor: SlateEditor) => {\n const blocks = editor.api.blocks({ mode: 'lowest' });\n\n return blocks.length > 1;\n};\n\n/** Get markdown with selection markers */\nexport const getMarkdownWithSelection = (editor: SlateEditor) =>\n removeEscapeSelection(editor, getMarkdown(editor, { type: 'block' }));\n\n/** Check if the current selection is inside a table cell */\nexport const isSelectionInTable = (editor: SlateEditor): boolean => {\n if (!editor.selection) return false;\n\n const tableEntry = editor.api.block({\n at: editor.selection,\n match: { type: KEYS.table },\n });\n\n return !!tableEntry;\n};\n\n/** Check if selection is within a single table cell */\nexport const isSingleCellSelection = (editor: SlateEditor): boolean => {\n if (!editor.selection) return false;\n\n // Get all td blocks in selection\n const cells = Array.from(\n editor.api.nodes({\n at: editor.selection,\n match: { type: KEYS.td },\n })\n );\n\n return cells.length === 1;\n};\n", "type": "registry:file", "target": "app/api/ai/command/utils.ts" }, { - "path": "src/registry/app/api/ai/command/prompts.ts", - "content": "import type { ChatMessage } from '@/registry/components/editor/use-chat';\nimport type { SlateEditor } from 'platejs';\n\nimport { getMarkdown } from '@platejs/ai';\nimport dedent from 'dedent';\n\nimport {\n addSelection,\n buildStructuredPrompt,\n formatTextFromMessages,\n getMarkdownWithSelection,\n isMultiBlocks,\n} from './utils';\n\nexport function getChooseToolPrompt({ messages }: { messages: ChatMessage[] }) {\n return buildStructuredPrompt({\n examples: [\n // GENERATE\n 'User: \"Write a paragraph about AI ethics\" → Good: \"generate\" | Bad: \"edit\"',\n 'User: \"Create a short poem about spring\" → Good: \"generate\" | Bad: \"comment\"',\n\n // EDIT\n 'User: \"Please fix grammar.\" → Good: \"edit\" | Bad: \"generate\"',\n 'User: \"Improving writing style.\" → Good: \"edit\" | Bad: \"generate\"',\n 'User: \"Making it more concise.\" → Good: \"edit\" | Bad: \"generate\"',\n 'User: \"Translate this paragraph into French\" → Good: \"edit\" | Bad: \"generate\"',\n\n // COMMENT\n 'User: \"Can you review this text and give me feedback?\" → Good: \"comment\" | Bad: \"edit\"',\n 'User: \"Add inline comments to this code to explain what it does\" → Good: \"comment\" | Bad: \"generate\"',\n ],\n history: formatTextFromMessages(messages),\n rules: dedent`\n - Default is \"generate\". Any open question, idea request, or creation request → \"generate\".\n - Only return \"edit\" if the user provides original text (or a selection of text) AND asks to change, rephrase, translate, or shorten it.\n - Only return \"comment\" if the user explicitly asks for comments, feedback, annotations, or review. Do not infer \"comment\" implicitly.\n - Return only one enum value with no explanation.\n `,\n task: `You are a strict classifier. Classify the user's last request as \"generate\", \"edit\", or \"comment\".`,\n });\n}\n\nexport function getCommentPrompt(\n editor: SlateEditor,\n {\n messages,\n }: {\n messages: ChatMessage[];\n }\n) {\n const selectingMarkdown = getMarkdown(editor, {\n type: 'blockWithBlockId',\n });\n\n return buildStructuredPrompt({\n backgroundData: selectingMarkdown,\n examples: [\n // 1) Basic single-block comment\n `User: Review this paragraph.\n\n backgroundData:\n AI systems are transforming modern workplaces by automating routine tasks.\n\n Output:\n [\n {\n \"blockId\": \"1\",\n \"content\": \"AI systems are transforming modern workplaces\",\n \"comments\": \"Clarify what types of systems or provide examples.\"\n }\n ]`,\n\n // 2) Multiple comments within one long block\n `User: Add comments for this section.\n\n backgroundData:\n AI models can automate customer support. However, they may misinterpret user intent if training data is biased.\n\n Output:\n [\n {\n \"blockId\": \"2\",\n \"content\": \"AI models can automate customer support.\",\n \"comments\": \"Consider mentioning limitations or scope of automation.\"\n },\n {\n \"blockId\": \"2\",\n \"content\": \"they may misinterpret user intent if training data is biased\",\n \"comments\": \"Good point—expand on how bias can be detected or reduced.\"\n }\n ]`,\n\n // 3) Multi-block comment (span across two related paragraphs)\n `User: Provide comments.\n\n backgroundData:\n This policy aims to regulate AI-generated media.\n Developers must disclose when content is synthetically produced.\n\n Output:\n [\n {\n \"blockId\": \"3\",\n \"content\": \"This policy aims to regulate AI-generated media.\\\\n\\\\nDevelopers must disclose when content is synthetically produced.\",\n \"comments\": \"You could combine these ideas into a single, clearer statement on transparency.\"\n }\n ]`,\n\n // 4) With – user highlighted part of a sentence\n `User: Give feedback on this highlighted phrase.\n\n backgroundData:\n AI can replace human creativity in design tasks.\n\n Output:\n [\n {\n \"blockId\": \"5\",\n \"content\": \"replace human creativity\",\n \"comments\": \"Overstated claim—suggest using 'assist' instead of 'replace'.\"\n }\n ]`,\n\n // 5) With long → multiple comments\n `User: Review the highlighted section.\n\n backgroundData:\n \n \n AI tools are valuable for summarizing information and generating drafts.\n Still, human review remains essential to ensure accuracy and ethical use.\n \n \n\n Output:\n [\n {\n \"blockId\": \"6\",\n \"content\": \"AI tools are valuable for summarizing information and generating drafts.\",\n \"comments\": \"Solid statement—consider adding specific examples of tools.\"\n },\n {\n \"blockId\": \"6\",\n \"content\": \"human review remains essential to ensure accuracy and ethical use\",\n \"comments\": \"Good caution—explain briefly why ethics require human oversight.\"\n }\n ]`,\n ],\n history: formatTextFromMessages(messages),\n rules: dedent`\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 - If a exists, Your comments should come from the , and if the is too long, there should be more than one comment.\n `,\n task: dedent`\n You are a document review assistant.\n You will receive an MDX document wrapped in content tags.\n is the text highlighted by the user.\n\n Your 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 `,\n });\n}\n\nexport function getGeneratePrompt(\n editor: SlateEditor,\n { messages }: { messages: ChatMessage[] }\n) {\n if (!isMultiBlocks(editor)) {\n addSelection(editor);\n }\n\n const selectingMarkdown = getMarkdownWithSelection(editor);\n\n return buildStructuredPrompt({\n backgroundData: selectingMarkdown,\n examples: [\n // 1) Summarize content\n 'User: Summarize the following text.\\nBackground data:\\nArtificial intelligence has transformed multiple industries, from healthcare to finance, improving efficiency and enabling data-driven decisions.\\nOutput:\\nAI improves efficiency and decision-making across many industries.',\n\n // 2) Generate key takeaways\n 'User: List three key takeaways from this text.\\nBackground data:\\nRemote work increases flexibility but also requires better communication and time management.\\nOutput:\\n- Remote work enhances flexibility.\\n- Communication becomes critical.\\n- Time management determines success.',\n\n // 3) Generate a title\n 'User: Generate a short, catchy title for this section.\\nBackground data:\\nThis section explains how machine learning models are trained using large datasets to recognize patterns.\\nOutput:\\nTraining Machines to Recognize Patterns',\n\n // 4) Generate action items\n 'User: Generate actionable next steps based on the paragraph.\\nBackground data:\\nThe report suggests improving documentation and conducting user interviews before the next release.\\nOutput:\\n- Update all technical documentation.\\n- Schedule user interviews before the next release.',\n\n // 5) Generate a comparison table\n 'User: Generate a comparison table of the tools mentioned.\\nBackground data:\\nTool A: free, simple UI\\nTool B: paid, advanced analytics\\nOutput:\\n| Tool | Pricing | Features |\\n|-------|----------|-----------------|\\n| A | Free | Simple UI |\\n| B | Paid | Advanced analytics |',\n\n // 6) Generate a summary table of statistics\n 'User: Create a summary table of the following statistics.\\nBackground data:\\nSales Q1: 1200 units\\nSales Q2: 1500 units\\nSales Q3: 900 units\\nOutput:\\n| Quarter | Sales (units) |\\n|----------|---------------|\\n| Q1 | 1200 |\\n| Q2 | 1500 |\\n| Q3 | 900 |',\n\n // 7) Generate a question list\n 'User: Generate three reflection questions based on the paragraph.\\nBackground data:\\nThe article discusses the role of creativity in problem-solving and how diverse perspectives enhance innovation.\\nOutput:\\n1. How can creativity be encouraged in structured environments?\\n2. What role does diversity play in innovative teams?\\n3. How can leaders balance creativity and efficiency?',\n\n // 8) Explain a concept (selected phrase)\n 'User: Explain the meaning of the selected phrase.\\nBackground data:\\nDeep learning relies on neural networks to automatically extract patterns from data, a process called feature learning.\\nOutput:\\n\"Feature learning\" means automatically discovering useful representations or characteristics from raw data without manual intervention.',\n ],\n history: formatTextFromMessages(messages),\n rules: dedent`\n - is the text highlighted by the user.\n - backgroundData represents the user's current Markdown context.\n - You may only use backgroundData and as input; never ask for more data.\n - CRITICAL: DO NOT remove or alter custom MDX tags such as , , , , , , , , , , , , ,