🐛 fix: page agent editor (#10953)

* refactor page agent

* refactor page agent system prompt

* support inject page context in the agent runtime

* fix initial context injection

* support diff all toolbar
This commit is contained in:
Arvin Xu 2025-12-25 11:38:15 +08:00 committed by GitHub
parent d43acc8e24
commit 61b30310bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 1277 additions and 1334 deletions

View file

@ -23,9 +23,9 @@
"markdown.parseTitle": "格式化 Markdown",
"math.placeholder": "请输入 TeX 公式",
"modifier.accept": "保留",
"modifier.acceptAll": "全部接受",
"modifier.acceptAll": "全部保留",
"modifier.reject": "撤销",
"modifier.rejectedAll": "全部拒绝",
"modifier.rejectAll": "全部撤销",
"slash.h1": "一级标题",
"slash.h2": "二级标题",
"slash.h3": "三级标题",
@ -55,4 +55,4 @@
"typobar.tex": "TeX 公式",
"typobar.underline": "下划线",
"typobar.undo": "撤销"
}
}

View file

@ -78,6 +78,9 @@
"builtins.lobe-page-agent.apiName.insertTableRow": "插入表格行",
"builtins.lobe-page-agent.apiName.listSnapshots": "列出快照",
"builtins.lobe-page-agent.apiName.mergeNodes": "合并节点",
"builtins.lobe-page-agent.apiName.modifyNodes": "修改文档",
"builtins.lobe-page-agent.apiName.modifyNodes.addNodes": "补充内容",
"builtins.lobe-page-agent.apiName.modifyNodes.deleteNodes": "删除内容",
"builtins.lobe-page-agent.apiName.moveNode": "移动节点",
"builtins.lobe-page-agent.apiName.outdentListItem": "取消缩进列表项",
"builtins.lobe-page-agent.apiName.replaceText": "替换文本",
@ -411,4 +414,4 @@
"store.title": "技能商店",
"unknownError": "未知错误",
"unknownPlugin": "未知技能"
}
}

View file

@ -174,6 +174,7 @@
"@lobechat/builtin-tool-local-system": "workspace:*",
"@lobechat/builtin-tool-memory": "workspace:*",
"@lobechat/builtin-tool-notebook": "workspace:*",
"@lobechat/builtin-tool-page-agent": "workspace:*",
"@lobechat/business-config": "workspace:*",
"@lobechat/business-const": "workspace:*",
"@lobechat/config": "workspace:*",

View file

@ -1,4 +1,9 @@
import { ChatToolPayload, ModelUsage, RuntimeStepContext } from '@lobechat/types';
import {
ChatToolPayload,
ModelUsage,
RuntimeInitialContext,
RuntimeStepContext,
} from '@lobechat/types';
import type { FinishReason } from './event';
import { AgentState, ToolRegistry } from './state';
@ -8,6 +13,13 @@ import type { Cost, CostCalculationContext, Usage } from './usage';
* Runtime execution context passed to Agent runner
*/
export interface AgentRuntimeContext {
/**
* Initial context captured at operation start
* Contains static state like initial page content that doesn't change during execution
* Set once during initialization and passed through to Context Engine
*/
initialContext?: RuntimeInitialContext;
metadata?: Record<string, unknown>;
/** Operation ID (links to Operation for business context) */

View file

@ -0,0 +1,17 @@
{
"name": "@lobechat/builtin-tool-page-agent",
"version": "1.0.0",
"private": true,
"exports": {
".": "./src/index.ts",
"./executionRuntime": "./src/ExecutionRuntime/index.ts"
},
"main": "./src/index.ts",
"dependencies": {
"@lobechat/types": "workspace:*"
},
"peerDependencies": {
"@lobehub/editor": "*",
"debug": "*"
}
}

View file

@ -1,4 +1,5 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { PageContentContext, formatPageContentContext } from '@lobechat/prompts';
import { BuiltinServerRuntimeOutput } from '@lobechat/types';
import {
IEditor,
@ -78,7 +79,7 @@ import type {
UpdateNodeState,
WrapNodesArgs,
WrapNodesState,
} from '../type';
} from '../types';
const log = debug('page:page-agent');
@ -236,67 +237,20 @@ export class PageAgentExecutionRuntime {
*/
async getPageContent(args: GetPageContentArgs): Promise<BuiltinServerRuntimeOutput> {
try {
const editor = this.getEditor();
if (!editor) {
throw new Error('Editor instance not found');
}
const { getter: getTitleFn } = this.getTitleHandlers();
if (!getTitleFn) {
throw new Error('Title getter not found');
}
const format = args.format || 'both';
const title = getTitleFn() || 'Untitled';
// Get document in JSON format
const docJson = editor.getDocument('json') as any;
const pageXML = editor.getDocument('litexml') as any;
log('docJson:', JSON.stringify(docJson, null, 2));
// Prepare state object
const state: GetPageContentState = {
documentId: 'current',
metadata: {
title,
},
};
// Get markdown format if requested
if (format === 'markdown' || format === 'both') {
const markdownRaw = editor.getDocument('markdown');
log('markdownRaw:', markdownRaw);
const markdown = String(markdownRaw || '');
state.markdown = markdown;
}
// Convert to XML if requested
if (format === 'xml' || format === 'both') {
if (pageXML) {
state.xml = pageXML;
} else {
state.xml = '';
}
}
// Build content message
let contentMsg = `Successfully retrieved page content.\n\n**Title**: ${title}\n`;
if (state.markdown) {
const charCount = state.markdown.length;
const lineCount = state.markdown.split('\n').length;
contentMsg += `**Markdown**: ${charCount} characters, ${lineCount} lines\n`;
state.metadata.totalCharCount = charCount;
state.metadata.totalLineCount = lineCount;
}
if (state.xml) {
contentMsg += `**XML Structure**: ${state.xml}\n`;
}
const context = this.getPageContentContext(args.format);
return {
content: contentMsg,
state,
content: formatPageContentContext(context),
state: {
documentId: this.currentDocId || 'current',
markdown: context.markdown,
metadata: {
title: context.metadata.title,
totalCharCount: context.metadata.charCount,
totalLineCount: context.metadata.lineCount,
},
xml: context.xml,
} as GetPageContentState,
success: true,
};
} catch (error) {
@ -309,6 +263,37 @@ export class PageAgentExecutionRuntime {
}
}
/**
* Get page content context for system prompt injection
*/
getPageContentContext(format: 'xml' | 'markdown' | 'both' = 'both'): PageContentContext {
const editor = this.getEditor();
const { getter: getTitleFn } = this.getTitleHandlers();
const title = getTitleFn() || 'Untitled';
const pageXML = editor.getDocument('litexml') as unknown as string;
log('Getting page content context, format:', format);
const context: PageContentContext = {
metadata: { title },
};
if (format === 'markdown' || format === 'both') {
const markdownRaw = editor.getDocument('markdown');
const markdown = String(markdownRaw || '');
context.markdown = markdown;
context.metadata.charCount = markdown.length;
context.metadata.lineCount = markdown.split('\n').length;
}
if (format === 'xml' || format === 'both') {
context.xml = pageXML || '';
}
return context;
}
// ==================== Helper Methods ====================
/**

View file

@ -1,4 +1,4 @@
import { DocumentApiName } from '../index';
import { DocumentApiName } from '../types';
export const PageAgentRenderers = {
[DocumentApiName.initPage]: null,

View file

@ -0,0 +1,86 @@
export { PageAgentManifest } from './manifest';
export { systemPrompt } from './systemRole';
export {
type BatchUpdateArgs,
type CompareSnapshotsArgs,
type CompareSnapshotsState,
type ConvertToListArgs,
type ConvertToListState,
type CreateNodeArgs,
type CreateNodeState,
type CropImageArgs,
type CropImageState,
type DeleteNodeArgs,
type DeleteNodeState,
type DeleteSnapshotArgs,
type DeleteSnapshotState,
type DeleteTableColumnArgs,
type DeleteTableColumnState,
type DeleteTableRowArgs,
type DeleteTableRowState,
DocumentApiName,
type DocumentNode,
type DuplicateNodeArgs,
type DuplicateNodeState,
type EditTitleArgs,
type EditTitleState,
type FindNodesArgs,
type FindNodesState,
type GetNodeArgs,
type GetNodeState,
type GetPageContentArgs,
type GetPageContentState,
type IndentListItemArgs,
type IndentListItemState,
type InitDocumentArgs,
type InitDocumentState,
type InsertTableColumnArgs,
type InsertTableColumnState,
type InsertTableRowArgs,
type InsertTableRowState,
type ListSnapshotsArgs,
type ListSnapshotsState,
type MergeNodesArgs,
type MergeNodesState,
type ModifyInsertOperation,
type ModifyNodesArgs,
type ModifyNodesState,
type ModifyOperation,
type ModifyOperationResult,
type ModifyRemoveOperation,
type ModifyUpdateOperation,
type MoveNodeArgs,
type MoveNodeState,
type NodeCreate,
type NodeCreateResult,
type NodePosition,
type NodeType,
type NodeUpdate,
type NodeUpdateResult,
type OutdentListItemArgs,
type OutdentListItemState,
PageAgentIdentifier,
type ReplaceTextArgs,
type ReplaceTextState,
type ResizeImageArgs,
type ResizeImageState,
type RestoreSnapshotArgs,
type RestoreSnapshotState,
type RotateImageArgs,
type RotateImageState,
type SaveSnapshotArgs,
type SaveSnapshotState,
type SetImageAltArgs,
type SetImageAltState,
type SnapshotInfo,
type SplitNodeArgs,
type SplitNodeState,
type ToggleListTypeArgs,
type ToggleListTypeState,
type UnwrapNodeArgs,
type UnwrapNodeState,
type UpdateNodeArgs,
type UpdateNodeState,
type WrapNodesArgs,
type WrapNodesState,
} from './types';

View file

@ -0,0 +1,188 @@
/* eslint-disable sort-keys-fix/sort-keys-fix */
import { BuiltinToolManifest } from '@lobechat/types';
import { systemPrompt } from './systemRole';
import { DocumentApiName, PageAgentIdentifier } from './types';
export const PageAgentManifest: BuiltinToolManifest = {
api: [
// ============ Initialize ============
{
description:
'Initialize a new document from Markdown content. Converts the Markdown into an XML-structured document with unique IDs for each node. This should be called first before performing any other document operations.',
name: DocumentApiName.initPage,
parameters: {
properties: {
markdown: {
description:
'The Markdown content。 Supports headings, paragraphs, lists, tables, images, links, code blocks, and other common Markdown syntax.',
type: 'string',
},
},
required: ['markdown'],
type: 'object',
},
},
// ============ Document Metadata ============
{
description:
'Edit the title of the current document. The title is displayed in the document header and is stored separately from the document content.',
name: DocumentApiName.editTitle,
parameters: {
properties: {
title: {
description: 'The new title for the document.',
type: 'string',
},
},
required: ['title'],
type: 'object',
},
},
// ============ Query & Read ============
{
description:
'Get the current page content and metadata. Returns the document in XML format with node IDs, markdown format, or both. Use this tool to retrieve the latest state of the document.',
name: DocumentApiName.getPageContent,
parameters: {
properties: {
format: {
default: 'both',
description:
'The format to return the content in. Options: "xml" (returns document structure with node IDs), "markdown" (returns plain markdown text), "both" (returns both formats). Defaults to "both".',
enum: ['xml', 'markdown', 'both'],
type: 'string',
},
},
type: 'object',
},
},
// ============ Unified Node Operations ============
{
description:
'Perform node operations (insert, modify, remove) on the document. This is the unified API for all CRUD operations. Supports batch operations by passing multiple operations in a single call.',
name: DocumentApiName.modifyNodes,
parameters: {
properties: {
operations: {
description:
'Array of operations to perform. Each operation can be: insert (add a new node), modify (update existing nodes), or remove (delete a node).',
items: {
oneOf: [
{
description: 'Insert a new node before a reference node',
properties: {
action: { const: 'insert', type: 'string' },
beforeId: {
description: 'ID of the node to insert before',
type: 'string',
},
litexml: {
description:
'The LiteXML string representing the node to insert (e.g., "<p>New paragraph</p>")',
type: 'string',
},
},
required: ['action', 'beforeId', 'litexml'],
type: 'object',
},
{
description: 'Insert a new node after a reference node',
properties: {
action: { const: 'insert', type: 'string' },
afterId: {
description: 'ID of the node to insert after',
type: 'string',
},
litexml: {
description:
'The LiteXML string representing the node to insert (e.g., "<p>New paragraph</p>")',
type: 'string',
},
},
required: ['action', 'afterId', 'litexml'],
type: 'object',
},
{
description: 'Modify existing nodes by providing updated LiteXML with node IDs',
properties: {
action: { const: 'modify', type: 'string' },
litexml: {
description:
'LiteXML string or array of strings with node IDs to update (e.g., "<p id=\\"abc\\">Updated content</p>" or ["<p id=\\"a\\">Text 1</p>", "<p id=\\"b\\">Text 2</p>"])',
oneOf: [{ type: 'string' }, { items: { type: 'string' }, type: 'array' }],
},
},
required: ['action', 'litexml'],
type: 'object',
},
{
description: 'Remove a node by ID',
properties: {
action: { const: 'remove', type: 'string' },
id: {
description: 'ID of the node to remove',
type: 'string',
},
},
required: ['action', 'id'],
type: 'object',
},
],
},
type: 'array',
},
},
required: ['operations'],
type: 'object',
},
},
// ============ Text Operations ============
{
description:
'Find and replace text across the document or within specific nodes. Supports regex patterns.',
name: DocumentApiName.replaceText,
parameters: {
properties: {
newText: {
description: 'The replacement text.',
type: 'string',
},
nodeIds: {
description:
'Optional array of node IDs to limit the replacement scope. If not provided, searches entire document.',
items: { type: 'string' },
type: 'array',
},
replaceAll: {
default: true,
description: 'Whether to replace all occurrences or just the first one.',
type: 'boolean',
},
searchText: {
description: 'The text to find. Can be a plain string or regex pattern.',
type: 'string',
},
useRegex: {
default: false,
description: 'Whether to treat searchText as a regular expression.',
type: 'boolean',
},
},
required: ['searchText', 'newText'],
type: 'object',
},
},
],
identifier: PageAgentIdentifier,
meta: {
avatar: '📄',
description: 'Create, read, update, and delete nodes in XML-structured documents',
title: 'Document',
},
systemRole: systemPrompt,
type: 'builtin',
};

View file

@ -47,17 +47,14 @@ IMPORTANT: When creating or updating nodes, use plain text content directly. Do
**Document Metadata:**
2. **editTitle** - Edit the title of the current document
**Query & Read:**
3. **getPageContent** - Get the current page content and metadata. Returns the document in XML format (with node IDs), markdown format, or both.
**Initialize:**
4. **initPage** - Initialize page content from Markdown string. This will not update the title.
3. **initPage** - Initialize page content from Markdown string. This will not update the title.
**Query & Read:**
4. **getPageContent** - Get the latest page content in XML or markdown format. Use this when you need to refresh the current document state after modifications or when the context may be outdated.
</core_capabilities>
<workflow>
**Step 0: Grab the page content**
- Always call getPageContent to understand the current page structure and node IDs, unless the question has nothing to do with the page.
**Step 1: Plan the Approach**
- Determine if this is a new page creation, content addition, modification, or reorganization
- Choose the most appropriate tool(s) for the task
@ -66,6 +63,7 @@ IMPORTANT: When creating or updating nodes, use plain text content directly. Do
- For new pages or complete rewrites: Use initPage with well-structured Markdown, then use editTitle to update the title
- For targeted edits: Use modifyNodes with appropriate operations (insert, modify, remove)
- For document metadata: Use editTitle to update the title
- Note: The current page content (XML with node IDs) is provided in the system context, so you can directly reference node IDs for modifications
**Step 3: Iterate**
- Summarize what changes were made
@ -99,34 +97,6 @@ This is a paragraph with **bold** and *italic* text.
// Creates a full document structure from the Markdown
\`\`\`
## Query & Read
**getPageContent**
- format: Optional. The format to return content in: "xml", "markdown", or "both" (default: "both")
- Returns the current page content and metadata including title
- XML format includes node IDs which are required for modify/remove operations
- Markdown format returns plain text representation
- Use this to retrieve the latest state before making targeted edits
\`\`\`
// Get page content in both formats (default)
getPageContent({})
// Returns: { title, xml, markdown }
// Get only XML format (useful when you need node IDs for modifications)
getPageContent({ format: "xml" })
// Returns: { title, xml }
// Get only markdown format (useful for content review)
getPageContent({ format: "markdown" })
// Returns: { title, markdown }
\`\`\`
IMPORTANT:
- Always call getPageContent first when you need to reference existing node IDs for modify or remove operations
- The XML format contains the node IDs you need for targeted edits
- Use this tool to verify the current state of the document before and after changes
## Unified Node Operations
**modifyNodes**
@ -176,8 +146,8 @@ modifyNodes({
// Update multiple nodes at once (using array of litexml)
modifyNodes({
operations: [
{
action: "modify",
{
action: "modify",
litexml: [
'<p id="4">Updated first paragraph</p>',
'<p id="6">Updated second paragraph</p>'
@ -203,11 +173,12 @@ modifyNodes({
})
\`\`\`
IMPORTANT:
IMPORTANT:
- For insert operations, the litexml should NOT include an id attribute (it will be auto-generated)
- For modify operations, the litexml MUST include the id attribute of the node to update
- Never use <span> tags in content. Use plain text directly with inline formatting tags (<b>, <i>, <u>, <s>)
- Batch operations are more efficient and apply all changes atomically
- CRITICAL: If the text content in litexml contains double quote characters (", ", "), you MUST escape them as \\" to avoid breaking JSON parsing. For example: '<p id="abc">He said \\"hello\\"</p>' or '<p id="abc">这是\\"开放\\"的时代</p>'
## Page Metadata
@ -221,6 +192,24 @@ editTitle({ title: "My Updated Document Title" })
// Updates the document title immediately
\`\`\`
## Query & Read
**getPageContent**
- format: Optional. The format to return: "xml", "markdown", or "both" (default: "both")
- Use this tool when you need to get the latest document content, especially after multiple modifications
- Returns the current page content with node IDs (XML) and/or plain text (markdown)
\`\`\`
// Get both XML and markdown formats
getPageContent({})
// Get only XML format (useful for getting updated node IDs)
getPageContent({ format: "xml" })
// Get only markdown format (useful for content review)
getPageContent({ format: "markdown" })
\`\`\`
</tool_usage_guidelines>
<error_handling>

View file

@ -1,3 +1,34 @@
/**
* Page Agent / Document Tool identifier
*/
export const PageAgentIdentifier = 'lobe-page-agent';
/* eslint-disable sort-keys-fix/sort-keys-fix */
export const DocumentApiName = {
// Initialize
initPage: 'initPage',
// Document Metadata
editTitle: 'editTitle',
// Query & Read
getPageContent: 'getPageContent',
// Unified CRUD - replaces createNode, updateNode, deleteNode
modifyNodes: 'modifyNodes',
// Legacy CRUD (deprecated, kept for backward compatibility)
createNode: 'createNode',
deleteNode: 'deleteNode',
duplicateNode: 'duplicateNode',
moveNode: 'moveNode',
updateNode: 'updateNode',
// Text Operations
replaceText: 'replaceText',
};
/* eslint-enable sort-keys-fix/sort-keys-fix */
// Node position types
export type NodePosition = 'before' | 'after' | 'prepend' | 'append';

View file

@ -24,6 +24,7 @@ import {
GroupContextInjector,
HistorySummaryProvider,
KnowledgeInjector,
PageEditorContextInjector,
SystemRoleInjector,
ToolSystemRoleProvider,
UserMemoryInjector,
@ -121,6 +122,9 @@ export class MessagesEngine {
groupAgentBuilderContext,
agentGroup,
userMemory,
initialContext,
stepContext,
pageContentContext,
} = this.params;
const isAgentBuilderEnabled = !!agentBuilderContext;
@ -129,6 +133,8 @@ export class MessagesEngine {
const isGroupContextEnabled =
isAgentGroupEnabled || !!agentGroup?.currentAgentId || !!agentGroup?.members;
const isUserMemoryEnabled = userMemory?.enabled && userMemory?.memories;
// Page editor is enabled if either direct pageContentContext or initialContext.pageEditor is provided
const isPageEditorEnabled = !!pageContentContext || !!initialContext?.pageEditor;
return [
// =============================================
@ -209,6 +215,26 @@ export class MessagesEngine {
historySummary,
}),
// 10. Page Editor context injection
new PageEditorContextInjector({
enabled: isPageEditorEnabled,
// Use direct pageContentContext if provided (server-side), otherwise build from initialContext + stepContext (frontend)
pageContentContext: pageContentContext
? pageContentContext
: initialContext?.pageEditor
? {
markdown: initialContext.pageEditor.markdown,
metadata: {
charCount: initialContext.pageEditor.metadata.charCount,
lineCount: initialContext.pageEditor.metadata.lineCount,
title: initialContext.pageEditor.metadata.title,
},
// Use latest XML from stepContext if available, otherwise fallback to initial XML
xml: stepContext?.stepPageEditor?.xml || initialContext.pageEditor.xml,
}
: undefined,
}),
// =============================================
// Phase 3: Message Transformation
// =============================================

View file

@ -1,5 +1,6 @@
/* eslint-disable typescript-sort-keys/interface */
import type { FileContent, KnowledgeBaseInfo } from '@lobechat/prompts';
import type { FileContent, KnowledgeBaseInfo, PageContentContext } from '@lobechat/prompts';
import type { RuntimeInitialContext, RuntimeStepContext } from '@lobechat/types';
import type { OpenAIChatMessage, UIChatMessage } from '@/types/index';
@ -190,6 +191,23 @@ export interface MessagesEngineParams {
groupAgentBuilderContext?: GroupAgentBuilderContext;
/** User memory configuration */
userMemory?: UserMemoryConfig;
// ========== Page Editor context ==========
/**
* Initial context captured at operation start (frontend runtime usage)
* Contains static state like initial page content that doesn't change during execution
*/
initialContext?: RuntimeInitialContext;
/**
* Page content context for direct injection (server-side usage)
* When provided, takes precedence over initialContext/stepContext
*/
pageContentContext?: PageContentContext;
/**
* Step context computed at the beginning of each step (frontend runtime usage)
* Contains dynamic state like latest XML that changes between steps
*/
stepContext?: RuntimeStepContext;
}
/**

View file

@ -1,3 +1,4 @@
import { type PageContentContext, formatPageContentContext } from '@lobechat/prompts';
import debug from 'debug';
import { BaseLastUserContentProvider } from '../base/BaseLastUserContentProvider';
@ -5,279 +6,16 @@ import type { PipelineContext, ProcessorOptions } from '../types';
const log = debug('context-engine:provider:PageEditorContextInjector');
/**
* Escape XML special characters
*/
const escapeXml = (str: string): string => {
return str
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
};
/**
* Generate path-based node ID
*/
const generateNodeId = (path: number[]): string => {
return `node_${path.join('_')}`;
};
/**
* Convert Lexical JSON format to XML format with node IDs
* @param node - The Lexical node to convert
* @param depth - Current depth in the tree (for indentation)
* @param path - Path to this node (array of indices from root)
*/
const convertLexicalToXml = (node: any, depth = 0, path: number[] = []): string => {
if (!node) return '';
const indent = ' '.repeat(depth);
// Handle text nodes
if (node.type === 'text') {
const nodeId = generateNodeId(path);
const text = escapeXml(node.text || '');
return `${indent}<span id="${nodeId}">${text}</span>`;
}
// Handle root node
if (node.type === 'root') {
const children =
node.children
?.map((child: any, index: number) => convertLexicalToXml(child, depth, [index]))
.join('\n') || '';
return `<root>\n${children}\n</root>`;
}
// Handle heading nodes
if (node.type === 'heading') {
const nodeId = generateNodeId(path);
const tag = node.tag || 'h1';
const children =
node.children
?.map((child: any, index: number) =>
convertLexicalToXml(child, depth + 1, [...path, index]),
)
.join('\n') || '';
return `${indent}<${tag} id="${nodeId}">\n${children}\n${indent}</${tag}>`;
}
// Handle paragraph nodes
if (node.type === 'paragraph') {
const nodeId = generateNodeId(path);
const children =
node.children
?.map((child: any, index: number) =>
convertLexicalToXml(child, depth + 1, [...path, index]),
)
.join('\n') || '';
return `${indent}<p id="${nodeId}">\n${children}\n${indent}</p>`;
}
// Handle list nodes
if (node.type === 'list') {
const nodeId = generateNodeId(path);
const tag = node.listType === 'bullet' || node.tag === 'ul' ? 'ul' : 'ol';
const children =
node.children
?.map((child: any, index: number) =>
convertLexicalToXml(child, depth + 1, [...path, index]),
)
.join('\n') || '';
return `${indent}<${tag} id="${nodeId}">\n${children}\n${indent}</${tag}>`;
}
// Handle list item nodes
if (node.type === 'listitem') {
const nodeId = generateNodeId(path);
const children =
node.children
?.map((child: any, index: number) =>
convertLexicalToXml(child, depth + 1, [...path, index]),
)
.join('\n') || '';
return `${indent}<li id="${nodeId}">\n${children}\n${indent}</li>`;
}
// Handle blockquote nodes
if (node.type === 'quote') {
const nodeId = generateNodeId(path);
const children =
node.children
?.map((child: any, index: number) =>
convertLexicalToXml(child, depth + 1, [...path, index]),
)
.join('\n') || '';
return `${indent}<blockquote id="${nodeId}">\n${children}\n${indent}</blockquote>`;
}
// Handle code block nodes
if (node.type === 'code') {
const nodeId = generateNodeId(path);
const text = escapeXml(node.children?.map((c: any) => c.text || '').join('') || '');
return `${indent}<pre id="${nodeId}"><code>${text}</code></pre>`;
}
// Handle link nodes
if (node.type === 'link') {
const nodeId = generateNodeId(path);
const href = escapeXml(node.url || '');
const children =
node.children
?.map((child: any, index: number) => convertLexicalToXml(child, depth, [...path, index]))
.join('') || '';
return `<a id="${nodeId}" href="${href}">${children}</a>`;
}
// Handle image nodes
if (node.type === 'image') {
const nodeId = generateNodeId(path);
const src = escapeXml(node.src || '');
const alt = escapeXml(node.altText || '');
return `${indent}<img id="${nodeId}" src="${src}" alt="${alt}" />`;
}
// Handle table nodes
if (node.type === 'table') {
const nodeId = generateNodeId(path);
const children =
node.children
?.map((child: any, index: number) =>
convertLexicalToXml(child, depth + 1, [...path, index]),
)
.join('\n') || '';
return `${indent}<table id="${nodeId}">\n${children}\n${indent}</table>`;
}
// Handle table row nodes
if (node.type === 'tablerow') {
const nodeId = generateNodeId(path);
const children =
node.children
?.map((child: any, index: number) =>
convertLexicalToXml(child, depth + 1, [...path, index]),
)
.join('\n') || '';
return `${indent}<tr id="${nodeId}">\n${children}\n${indent}</tr>`;
}
// Handle table cell nodes
if (node.type === 'tablecell') {
const nodeId = generateNodeId(path);
const children =
node.children
?.map((child: any, index: number) =>
convertLexicalToXml(child, depth + 1, [...path, index]),
)
.join('\n') || '';
return `${indent}<td id="${nodeId}">\n${children}\n${indent}</td>`;
}
// Fallback for unknown nodes - just process children
if (node.children) {
return node.children
.map((child: any, index: number) => convertLexicalToXml(child, depth, [...path, index]))
.join('\n');
}
return '';
};
/**
* Page context for Page Editor
*/
export interface PageEditorContext {
/** Plain text content (for reference) */
content?: string;
/** Document/Page metadata */
document?: {
/** File type */
fileType?: string;
/** Document unique identifier */
id: string;
/** URL slug */
slug?: string;
/** Document title */
title?: string;
/** Total character count */
totalCharCount?: number;
/** Total line count */
totalLineCount?: number;
};
/** Editor data - the XML document structure */
editorData?: Record<string, any>;
}
export interface PageEditorContextInjectorConfig {
/** Whether Page Editor/Agent is enabled */
enabled?: boolean;
/** Function to format page context */
formatPageContext?: (context: PageEditorContext) => string;
/** Page context to inject */
pageContext?: PageEditorContext;
/**
* Page content context to inject
* Contains markdown, xml, and metadata for the current page
*/
pageContentContext?: PageContentContext;
}
/**
* Format page context as XML for injection
*/
const defaultFormatPageContext = (context: PageEditorContext): string => {
const parts: string[] = [];
// Add document metadata section
if (context.document) {
const metaFields: string[] = [];
if (context.document.id) metaFields.push(` <id>${escapeXml(context.document.id)}</id>`);
if (context.document.title)
metaFields.push(` <title>${escapeXml(context.document.title)}</title>`);
if (context.document.slug)
metaFields.push(` <slug>${escapeXml(context.document.slug)}</slug>`);
if (context.document.fileType)
metaFields.push(` <fileType>${escapeXml(context.document.fileType)}</fileType>`);
if (context.document.totalCharCount !== undefined)
metaFields.push(` <totalCharCount>${context.document.totalCharCount}</totalCharCount>`);
if (context.document.totalLineCount !== undefined)
metaFields.push(` <totalLineCount>${context.document.totalLineCount}</totalLineCount>`);
if (metaFields.length > 0) {
parts.push(`<document_metadata>\n${metaFields.join('\n')}\n</document_metadata>`);
}
}
// Add editor data section (the XML document structure)
if (context.editorData) {
try {
// Convert Lexical JSON to XML format with node IDs
const xmlStructure = convertLexicalToXml(context.editorData.root);
// Don't escape the XML since it's already properly formatted XML
// Just include it directly (not escaped) so the model can parse the structure
parts.push(`<document_structure>\n${xmlStructure}\n</document_structure>`);
} catch (error) {
log('Error converting editorData to XML:', error);
console.error('[PageEditorContextInjector] Error converting editorData:', error);
}
}
// Add content preview section (if available)
if (context.content) {
// Show preview (first 1000 chars) to avoid too long context
const preview =
context.content.length > 1000 ? context.content.slice(0, 1000) + '...' : context.content;
parts.push(
`<content_preview length="${context.content.length}">${escapeXml(preview)}</content_preview>`,
);
}
if (parts.length === 0) {
return '';
}
return `<instruction>This is the current page/document context. The document uses an XML-based structure with unique node IDs. Use the Document tools (initPage, editTitle, etc.) to read and modify the page content when the user asks.</instruction>
${parts.join('\n')}`;
};
/**
* Page Editor Context Injector
* Responsible for injecting current page context at the end of the last user message
@ -299,21 +37,14 @@ export class PageEditorContextInjector extends BaseLastUserContentProvider {
const clonedContext = this.cloneContext(context);
// Skip if Page Editor is not enabled
if (!this.config.enabled) {
log('Page Editor not enabled, skipping injection');
// Skip if Page Editor is not enabled or no page content context
if (!this.config.enabled || !this.config.pageContentContext) {
log('Page Editor not enabled or no pageContentContext, skipping injection');
return this.markAsExecuted(clonedContext);
}
// Skip if no page context
if (!this.config.pageContext) {
log('No page context provided, skipping injection');
return this.markAsExecuted(clonedContext);
}
// Format page context
const formatFn = this.config.formatPageContext || defaultFormatPageContext;
const formattedContent = formatFn(this.config.pageContext);
// Format page content context
const formattedContent = formatPageContentContext(this.config.pageContentContext);
log('Formatted content length:', formattedContent.length);

View file

@ -1,7 +1,8 @@
import type { PageContentContext } from '@lobechat/prompts';
import { describe, expect, it } from 'vitest';
import type { PipelineContext } from '../../types';
import { type PageEditorContext, PageEditorContextInjector } from '../PageEditorContextInjector';
import { PageEditorContextInjector } from '../PageEditorContextInjector';
describe('PageEditorContextInjector', () => {
const createContext = (messages: any[] = []): PipelineContext => ({
@ -18,16 +19,19 @@ describe('PageEditorContextInjector', () => {
},
});
// Minimal page context for predictable output
const createMinimalPageContext = (): PageEditorContext => ({
content: 'Doc content',
// Minimal page content context for predictable output
const createMinimalPageContentContext = (): PageContentContext => ({
markdown: 'Doc content',
metadata: {
title: 'Test Document',
},
});
describe('injection position', () => {
it('should append context to the last user message', async () => {
const injector = new PageEditorContextInjector({
enabled: true,
pageContext: createMinimalPageContext(),
pageContentContext: createMinimalPageContentContext(),
});
const context = createContext([
@ -41,25 +45,16 @@ describe('PageEditorContextInjector', () => {
expect(result.messages).toHaveLength(3);
expect(result.messages[0].content).toBe('First question');
expect(result.messages[1].content).toBe('First answer');
expect(result.messages[2].content).toBe(`Second question
<!-- SYSTEM CONTEXT (NOT PART OF USER QUERY) -->
<context.instruction>following part contains context information injected by the system. Please follow these instructions:
1. Always prioritize handling user-visible content.
2. the context is only required when user's queries rely on it.
</context.instruction>
<current_page_context>
<instruction>This is the current page/document context. The document uses an XML-based structure with unique node IDs. Use the Document tools (initPage, editTitle, etc.) to read and modify the page content when the user asks.</instruction>
<content_preview length="11">Doc content</content_preview>
</current_page_context>
<!-- END SYSTEM CONTEXT -->`);
// The last user message should have the context appended
expect(result.messages[2].content).toContain('Second question');
expect(result.messages[2].content).toContain('<current_page title="Test Document">');
expect(result.messages[2].content).toContain('Doc content');
});
it('should append to the only user message when there is just one', async () => {
const injector = new PageEditorContextInjector({
enabled: true,
pageContext: createMinimalPageContext(),
pageContentContext: createMinimalPageContentContext(),
});
const context = createContext([{ content: 'Only question', role: 'user' }]);
@ -67,30 +62,51 @@ describe('PageEditorContextInjector', () => {
const result = await injector.process(context);
expect(result.messages).toHaveLength(1);
expect(result.messages[0].content).toBe(`Only question
expect(result.messages[0].content).toContain('Only question');
expect(result.messages[0].content).toContain('<current_page title="Test Document">');
});
<!-- SYSTEM CONTEXT (NOT PART OF USER QUERY) -->
<context.instruction>following part contains context information injected by the system. Please follow these instructions:
it('should inject to last user message when last message is tool', async () => {
const injector = new PageEditorContextInjector({
enabled: true,
pageContentContext: createMinimalPageContentContext(),
});
1. Always prioritize handling user-visible content.
2. the context is only required when user's queries rely on it.
</context.instruction>
<current_page_context>
<instruction>This is the current page/document context. The document uses an XML-based structure with unique node IDs. Use the Document tools (initPage, editTitle, etc.) to read and modify the page content when the user asks.</instruction>
<content_preview length="11">Doc content</content_preview>
</current_page_context>
<!-- END SYSTEM CONTEXT -->`);
const context = createContext([
{ content: 'First question', role: 'user' },
{ content: 'First answer', role: 'assistant' },
{ content: 'User request to modify', role: 'user' },
{
content: 'I will modify the document',
role: 'assistant',
tool_calls: [{ id: 'call_1', function: { name: 'modifyNodes', arguments: '{}' } }],
},
{ content: 'Successfully modified', role: 'tool', tool_call_id: 'call_1' },
]);
const result = await injector.process(context);
expect(result.messages).toHaveLength(5);
// First user message should NOT have injection
expect(result.messages[0].content).toBe('First question');
// Last user message (index 2) should have the context appended
expect(result.messages[2].content).toContain('User request to modify');
expect(result.messages[2].content).toContain('<current_page title="Test Document">');
// Tool message should remain unchanged
expect(result.messages[4].content).toBe('Successfully modified');
});
});
describe('injection format with document metadata', () => {
it('should include document metadata in injection', async () => {
describe('injection format with markdown and xml', () => {
it('should include markdown content in injection', async () => {
const injector = new PageEditorContextInjector({
enabled: true,
pageContext: {
document: {
id: 'doc-456',
title: 'My Title',
pageContentContext: {
markdown: '# Hello World\n\nThis is content.',
metadata: {
charCount: 30,
lineCount: 3,
title: 'Hello World',
},
},
});
@ -98,62 +114,52 @@ describe('PageEditorContextInjector', () => {
const context = createContext([{ content: 'Question', role: 'user' }]);
const result = await injector.process(context);
expect(result.messages[0].content).toBe(`Question
<!-- SYSTEM CONTEXT (NOT PART OF USER QUERY) -->
<context.instruction>following part contains context information injected by the system. Please follow these instructions:
1. Always prioritize handling user-visible content.
2. the context is only required when user's queries rely on it.
</context.instruction>
<current_page_context>
<instruction>This is the current page/document context. The document uses an XML-based structure with unique node IDs. Use the Document tools (initPage, editTitle, etc.) to read and modify the page content when the user asks.</instruction>
<document_metadata>
<id>doc-456</id>
<title>My Title</title>
</document_metadata>
</current_page_context>
<!-- END SYSTEM CONTEXT -->`);
expect(result.messages[0].content).toContain('<markdown chars="30" lines="3">');
expect(result.messages[0].content).toContain('# Hello World');
expect(result.messages[0].content).toContain('This is content.');
});
it('should include full document metadata', async () => {
it('should include xml structure in injection', async () => {
const injector = new PageEditorContextInjector({
enabled: true,
pageContext: {
document: {
id: 'doc-123',
title: 'Test',
slug: 'test-slug',
fileType: 'md',
totalCharCount: 100,
totalLineCount: 10,
pageContentContext: {
metadata: {
title: 'Test Doc',
},
xml: '<root><p id="1">Hello</p></root>',
},
});
const context = createContext([{ content: 'Question', role: 'user' }]);
const result = await injector.process(context);
expect(result.messages[0].content).toContain('<doc_xml_structure>');
expect(result.messages[0].content).toContain('<root><p id="1">Hello</p></root>');
expect(result.messages[0].content).toContain('</doc_xml_structure>');
});
it('should include both markdown and xml when provided', async () => {
const injector = new PageEditorContextInjector({
enabled: true,
pageContentContext: {
markdown: '# Title\n\nContent here.',
metadata: {
charCount: 20,
lineCount: 3,
title: 'Full Doc',
},
xml: '<root><h1 id="1">Title</h1><p id="2">Content here.</p></root>',
},
});
const context = createContext([{ content: 'Q', role: 'user' }]);
const result = await injector.process(context);
expect(result.messages[0].content).toBe(`Q
<!-- SYSTEM CONTEXT (NOT PART OF USER QUERY) -->
<context.instruction>following part contains context information injected by the system. Please follow these instructions:
1. Always prioritize handling user-visible content.
2. the context is only required when user's queries rely on it.
</context.instruction>
<current_page_context>
<instruction>This is the current page/document context. The document uses an XML-based structure with unique node IDs. Use the Document tools (initPage, editTitle, etc.) to read and modify the page content when the user asks.</instruction>
<document_metadata>
<id>doc-123</id>
<title>Test</title>
<slug>test-slug</slug>
<fileType>md</fileType>
<totalCharCount>100</totalCharCount>
<totalLineCount>10</totalLineCount>
</document_metadata>
</current_page_context>
<!-- END SYSTEM CONTEXT -->`);
expect(result.messages[0].content).toContain('<current_page title="Full Doc">');
expect(result.messages[0].content).toContain('<markdown');
expect(result.messages[0].content).toContain('# Title');
expect(result.messages[0].content).toContain('<doc_xml_structure>');
expect(result.messages[0].content).toContain('<root>');
});
});
@ -161,7 +167,7 @@ describe('PageEditorContextInjector', () => {
it('should skip injection when disabled', async () => {
const injector = new PageEditorContextInjector({
enabled: false,
pageContext: createMinimalPageContext(),
pageContentContext: createMinimalPageContentContext(),
});
const context = createContext([{ content: 'Question', role: 'user' }]);
@ -171,7 +177,7 @@ describe('PageEditorContextInjector', () => {
expect(result.metadata.pageEditorContextInjected).toBeUndefined();
});
it('should skip injection when pageContext is not provided', async () => {
it('should skip injection when pageContentContext is not provided', async () => {
const injector = new PageEditorContextInjector({
enabled: true,
});
@ -186,7 +192,7 @@ describe('PageEditorContextInjector', () => {
it('should skip injection when no user messages exist', async () => {
const injector = new PageEditorContextInjector({
enabled: true,
pageContext: createMinimalPageContext(),
pageContentContext: createMinimalPageContentContext(),
});
const context = createContext([{ content: 'System message', role: 'system' }]);
@ -195,25 +201,13 @@ describe('PageEditorContextInjector', () => {
expect(result.messages).toHaveLength(1);
expect(result.messages[0].content).toBe('System message');
});
it('should skip injection when pageContext is empty', async () => {
const injector = new PageEditorContextInjector({
enabled: true,
pageContext: {},
});
const context = createContext([{ content: 'Question', role: 'user' }]);
const result = await injector.process(context);
expect(result.messages[0].content).toBe('Question');
});
});
describe('metadata', () => {
it('should set pageEditorContextInjected metadata to true', async () => {
const injector = new PageEditorContextInjector({
enabled: true,
pageContext: createMinimalPageContext(),
pageContentContext: createMinimalPageContentContext(),
});
const context = createContext([{ content: 'Question', role: 'user' }]);
@ -227,7 +221,7 @@ describe('PageEditorContextInjector', () => {
it('should append to array content with text parts', async () => {
const injector = new PageEditorContextInjector({
enabled: true,
pageContext: createMinimalPageContext(),
pageContentContext: createMinimalPageContentContext(),
});
const context = createContext([
@ -242,51 +236,12 @@ describe('PageEditorContextInjector', () => {
const result = await injector.process(context);
expect(result.messages[0].content[0].text).toBe(`User question
<!-- SYSTEM CONTEXT (NOT PART OF USER QUERY) -->
<context.instruction>following part contains context information injected by the system. Please follow these instructions:
1. Always prioritize handling user-visible content.
2. the context is only required when user's queries rely on it.
</context.instruction>
<current_page_context>
<instruction>This is the current page/document context. The document uses an XML-based structure with unique node IDs. Use the Document tools (initPage, editTitle, etc.) to read and modify the page content when the user asks.</instruction>
<content_preview length="11">Doc content</content_preview>
</current_page_context>
<!-- END SYSTEM CONTEXT -->`);
expect(result.messages[0].content[0].text).toContain('User question');
expect(result.messages[0].content[0].text).toContain('<current_page title="Test Document">');
expect(result.messages[0].content[1]).toEqual({
image_url: { url: 'http://example.com/image.png' },
type: 'image_url',
});
});
});
describe('custom formatPageContext', () => {
it('should use custom format function when provided', async () => {
const customFormat = (ctx: PageEditorContext) => `Custom: ${ctx.document?.title}`;
const injector = new PageEditorContextInjector({
enabled: true,
formatPageContext: customFormat,
pageContext: { document: { id: '1', title: 'Custom Title' } },
});
const context = createContext([{ content: 'Question', role: 'user' }]);
const result = await injector.process(context);
expect(result.messages[0].content).toBe(`Question
<!-- SYSTEM CONTEXT (NOT PART OF USER QUERY) -->
<context.instruction>following part contains context information injected by the system. Please follow these instructions:
1. Always prioritize handling user-visible content.
2. the context is only required when user's queries rely on it.
</context.instruction>
<current_page_context>
Custom: Custom Title
</current_page_context>
<!-- END SYSTEM CONTEXT -->`);
});
});
});

View file

@ -27,10 +27,7 @@ export type {
} from './GroupContextInjector';
export type { HistorySummaryConfig } from './HistorySummary';
export type { KnowledgeInjectorConfig } from './KnowledgeInjector';
export type {
PageEditorContext,
PageEditorContextInjectorConfig,
} from './PageEditorContextInjector';
export type { PageEditorContextInjectorConfig } from './PageEditorContextInjector';
export type { SystemRoleInjectorConfig } from './SystemRoleInjector';
export type { ToolSystemRoleConfig } from './ToolSystemRole';
export type { UserMemoryInjectorConfig } from './UserMemoryInjector';

View file

@ -0,0 +1,48 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`formatPageContentContext > should format context with both markdown and xml 1`] = `
"<current_page title="Test Document">
<markdown chars="14" lines="3">
# Hello
World
</markdown>
<doc_xml_structure>
<instruction>IMPORTANT: Use node IDs from this XML structure when performing modify or remove operations with modifyNodes.</instruction>
<root><h1 id="1">Hello</h1><p id="2">World</p></root>
</doc_xml_structure>
</current_page>"
`;
exports[`formatPageContentContext > should format context with markdown content 1`] = `
"<current_page title="Test Document">
<markdown chars="24" lines="3">
# Hello
This is a test.
</markdown>
</current_page>"
`;
exports[`formatPageContentContext > should format context with only title 1`] = `
"<current_page title="Test Document">
</current_page>"
`;
exports[`formatPageContentContext > should format context with xml structure 1`] = `
"<current_page title="Test Document">
<doc_xml_structure>
<instruction>IMPORTANT: Use node IDs from this XML structure when performing modify or remove operations with modifyNodes.</instruction>
<root><p id="1">Hello</p></root>
</doc_xml_structure>
</current_page>"
`;
exports[`formatPageContentContext > should use provided charCount and lineCount from metadata 1`] = `
"<current_page title="Test Document">
<markdown chars="100" lines="10">
# Test
</markdown>
</current_page>"
`;

View file

@ -1 +1 @@
export * from './pageAgent';
export * from './pageContentContext';

View file

@ -1,13 +0,0 @@
/**
* Page Agent System Role
*
* This agent assists users with document editing in the PageEditor.
*/
export const pageAgentSystemRole = `You are a helpful document editing assistant. Your role is to:
- Help users write, edit, and improve their documents
- Provide suggestions for better clarity, structure, and style
- Answer questions about the document content
- Help with formatting and organization
- Summarize or expand on sections as needed
Be concise and helpful. Focus on improving the document quality.`;

View file

@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { formatPageContentContext } from './pageContentContext';
describe('formatPageContentContext', () => {
it('should format context with only title', () => {
const result = formatPageContentContext({
metadata: { title: 'Test Document' },
});
expect(result).toMatchSnapshot();
});
it('should format context with markdown content', () => {
const result = formatPageContentContext({
markdown: '# Hello\n\nThis is a test.',
metadata: { title: 'Test Document' },
});
expect(result).toMatchSnapshot();
});
it('should format context with xml structure', () => {
const result = formatPageContentContext({
metadata: { title: 'Test Document' },
xml: '<root><p id="1">Hello</p></root>',
});
expect(result).toMatchSnapshot();
});
it('should format context with both markdown and xml', () => {
const result = formatPageContentContext({
markdown: '# Hello\n\nWorld',
metadata: { title: 'Test Document' },
xml: '<root><h1 id="1">Hello</h1><p id="2">World</p></root>',
});
expect(result).toMatchSnapshot();
});
it('should use provided charCount and lineCount from metadata', () => {
const result = formatPageContentContext({
markdown: '# Test',
metadata: {
charCount: 100,
lineCount: 10,
title: 'Test Document',
},
});
expect(result).toMatchSnapshot();
});
});

View file

@ -0,0 +1,76 @@
export interface PageContentMetadata {
/**
* Total character count of the markdown content
*/
charCount?: number;
/**
* File type, if applicable
*/
fileType?: string;
/**
* Total line count of the markdown content
*/
lineCount?: number;
/**
* Document title
*/
title: string;
}
export interface PageContentContext {
/**
* Markdown content of the page
*/
markdown?: string;
/**
* Document metadata
*/
metadata: PageContentMetadata;
/**
* XML structure of the page with node IDs
*/
xml?: string;
}
/**
* Format markdown content section
*/
const formatMarkdownSection = (markdown: string, metadata: PageContentMetadata): string => {
const charCount = metadata.charCount ?? markdown.length;
const lineCount = metadata.lineCount ?? markdown.split('\n').length;
return `<markdown chars="${charCount}" lines="${lineCount}">
${markdown}
</markdown>`;
};
/**
* Format XML structure section
*/
const formatXmlSection = (xml: string): string => {
return `<doc_xml_structure>
<instruction>IMPORTANT: Use node IDs from this XML structure when performing modify or remove operations with modifyNodes.</instruction>
${xml}
</doc_xml_structure>`;
};
/**
* Format page content into a system prompt context
*/
export const formatPageContentContext = (context: PageContentContext): string => {
const { xml, markdown, metadata } = context;
const sections: string[] = [];
if (markdown) {
sections.push(formatMarkdownSection(markdown, metadata));
}
if (xml) {
sections.push(formatXmlSection(xml));
}
return `<current_page title="${metadata.title}">
${sections.join('\n')}
</current_page>`;
};

View file

@ -27,6 +27,41 @@ export interface StepContextTodos {
updatedAt: string;
}
/**
* Page Editor context for each step
* Contains the latest XML structure fetched at each step
*/
export interface StepPageEditorContext {
/**
* Current XML structure of the page
* Fetched at the beginning of each step to get latest state
*/
xml: string;
}
/**
* Initial Page Editor context
* Stored at operation initialization and remains constant
*/
export interface InitialPageEditorContext {
/**
* Initial markdown content of the page
*/
markdown: string;
/**
* Document metadata
*/
metadata: {
charCount?: number;
lineCount?: number;
title: string;
};
/**
* Initial XML structure (for reference)
*/
xml: string;
}
/**
* Runtime Step Context
*
@ -43,12 +78,28 @@ export interface StepContextTodos {
* ```
*/
export interface RuntimeStepContext {
/**
* Page Editor context for current step
* Contains the latest XML structure fetched at each step
*/
stepPageEditor?: StepPageEditorContext;
/**
* Current todo list state
* Computed from the latest GTD tool message in the conversation
*/
todos?: StepContextTodos;
// Future extensions:
// plan: StepContextPlan | null;
}
/**
* Initial Context
*
* Contains state captured at operation initialization.
* Remains constant throughout the operation lifecycle.
*/
export interface RuntimeInitialContext {
/**
* Initial Page Editor context
* Contains markdown content and metadata captured at operation start
*/
pageEditor?: InitialPageEditorContext;
}

View file

@ -29,7 +29,6 @@
"mime": "^4.1.0",
"model-bank": "workspace:*",
"nanoid": "^5.1.6",
"next": "^16.0.1",
"numeral": "^2.0.6",
"pure-rand": "^7.0.1",
"remark": "^15.0.1",
@ -43,5 +42,8 @@
},
"devDependencies": {
"vitest-canvas-mock": "^1.1.3"
},
"peerDependencies": {
"next": "*"
}
}

View file

@ -0,0 +1,109 @@
'use client';
import { DiffAction, LITEXML_DIFFNODE_ALL_COMMAND } from '@lobehub/editor';
import { Block, Icon } from '@lobehub/ui';
import { Button, Space } from 'antd';
import { createStyles } from 'antd-style';
import { Check, X } from 'lucide-react';
import { memo, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { usePageEditorStore } from './store';
const useStyles = createStyles(({ css, token, isDarkMode }) => ({
container: css`
position: absolute;
z-index: 1000;
inset-block-end: 24px;
inset-inline-start: 50%;
transform: translateX(-50%);
`,
toolbar: css`
border-color: ${token.colorFillSecondary};
background: ${token.colorBgElevated};
box-shadow: ${isDarkMode
? '0px 14px 28px -6px #0003, 0px 2px 4px -1px #0000001f'
: '0 14px 28px -6px #0000001a, 0 2px 4px -1px #0000000f'};
`,
}));
const DiffAllToolbar = memo(() => {
const { t } = useTranslation('editor');
const { styles } = useStyles();
const editor = usePageEditorStore((s) => s.editor);
const [hasPendingDiffs, setHasPendingDiffs] = useState(false);
// Listen to editor state changes to detect diff nodes
useEffect(() => {
if (!editor) return;
const lexicalEditor = editor.getLexicalEditor();
if (!lexicalEditor) return;
const checkForDiffNodes = () => {
const editorState = lexicalEditor.getEditorState();
editorState.read(() => {
// Get all nodes and check if any is a diff node
const nodeMap = editorState._nodeMap;
let hasDiffs = false;
nodeMap.forEach((node) => {
if (node.getType() === 'diff') {
hasDiffs = true;
}
});
setHasPendingDiffs(hasDiffs);
});
};
// Check initially
checkForDiffNodes();
// Register update listener
const unregister = lexicalEditor.registerUpdateListener(() => {
checkForDiffNodes();
});
return unregister;
}, [editor]);
if (!editor || !hasPendingDiffs) return null;
return (
<div className={styles.container}>
<Block className={styles.toolbar} gap={8} horizontal padding={4} shadow variant="outlined">
<Space>
<Button
// danger
onClick={() => {
editor.dispatchCommand(LITEXML_DIFFNODE_ALL_COMMAND, {
action: DiffAction.Reject,
});
}}
size={'small'}
type="text"
>
<Icon icon={X} size={16} />
{t('modifier.rejectAll')}
</Button>
<Button
color={'default'}
onClick={() => {
editor.dispatchCommand(LITEXML_DIFFNODE_ALL_COMMAND, {
action: DiffAction.Accept,
});
}}
size={'small'}
variant="filled"
>
<Icon color={'green'} icon={Check} size={16} />
{t('modifier.acceptAll')}
</Button>
</Space>
</Block>
</div>
);
});
DiffAllToolbar.displayName = 'DiffAllToolbar';
export default DiffAllToolbar;

View file

@ -14,6 +14,7 @@ import { useFileStore } from '@/store/file';
import Body from './Body';
import Copilot from './Copilot';
import DiffAllToolbar from './DiffAllToolbar';
import Header from './Header';
import PageAgentProvider from './PageAgentProvider';
import { PageEditorProvider } from './PageEditorProvider';
@ -48,7 +49,7 @@ const PageEditorCanvas = memo(() => {
style={{ backgroundColor: theme.colorBgContainer }}
width={'100%'}
>
<Flexbox flex={1} height={'100%'}>
<Flexbox flex={1} height={'100%'} style={{ position: 'relative' }}>
<Header />
<Flexbox
height={'100%'}
@ -60,6 +61,7 @@ const PageEditorCanvas = memo(() => {
<Body />
</WideScreenContainer>
</Flexbox>
<DiffAllToolbar />
</Flexbox>
<Copilot />
</Flexbox>

View file

@ -1,58 +1,59 @@
export default {
"actions.expand.off": "Collapse",
"actions.expand.on": "Expand",
"actions.typobar.off": "Hide formatting toolbar",
"actions.typobar.on": "Show formatting toolbar",
"autoSave.latest": "Latest version loaded",
"autoSave.saved": "Saved",
"autoSave.saving": "Auto-saving...",
"cancel": "Cancel",
"confirm": "Confirm",
"file.error": "Error: {{message}}",
"file.uploading": "Uploading file...",
"image.broken": "Image is corrupted",
"link.edit": "Edit link",
"link.editLinkTitle": "Link",
"link.editTextTitle": "Title",
"link.open": "Open link",
"link.placeholder": "Enter link URL",
"link.unlink": "Unlink",
"markdown.cancel": "Cancel",
"markdown.confirm": "Convert",
"markdown.parseMessage": "Convert to Markdown format. Existing content will be overwritten. Are you sure? (Closes automatically in 5 seconds)",
"markdown.parseTitle": "Format as Markdown",
"math.placeholder": "Please enter a TeX formula",
"modifier.accept": "Keep",
"modifier.acceptAll": "Accept All",
"modifier.reject": "Revert",
"modifier.rejectedAll": "Reject All",
"slash.h1": "Heading 1",
"slash.h2": "Heading 2",
"slash.h3": "Heading 3",
"slash.hr": "Divider",
"slash.table": "Table",
"slash.tex": "TeX Formula",
"table.delete": "Delete table",
"table.deleteColumn": "Delete column",
"table.deleteRow": "Delete row",
"table.insertColumnLeft": "Insert {{count}} column(s) to the left",
"table.insertColumnRight": "Insert {{count}} column(s) to the right",
"table.insertRowAbove": "Insert {{count}} row(s) above",
"table.insertRowBelow": "Insert {{count}} row(s) below",
"typobar.blockquote": "Blockquote",
"typobar.bold": "Bold",
"typobar.bulletList": "Bulleted list",
"typobar.code": "Inline code",
"typobar.codeblock": "Code block",
"typobar.image": "Image",
"typobar.italic": "Italic",
"typobar.link": "Link",
"typobar.numberList": "Numbered list",
"typobar.redo": "Redo",
"typobar.strikethrough": "Strikethrough",
"typobar.table": "Table",
"typobar.taskList": "Task List",
"typobar.tex": "TeX Formula",
"typobar.underline": "Underline",
"typobar.undo": "Undo"
}
'actions.expand.off': 'Collapse',
'actions.expand.on': 'Expand',
'actions.typobar.off': 'Hide formatting toolbar',
'actions.typobar.on': 'Show formatting toolbar',
'autoSave.latest': 'Latest version loaded',
'autoSave.saved': 'Saved',
'autoSave.saving': 'Auto-saving...',
'cancel': 'Cancel',
'confirm': 'Confirm',
'file.error': 'Error: {{message}}',
'file.uploading': 'Uploading file...',
'image.broken': 'Image is corrupted',
'link.edit': 'Edit link',
'link.editLinkTitle': 'Link',
'link.editTextTitle': 'Title',
'link.open': 'Open link',
'link.placeholder': 'Enter link URL',
'link.unlink': 'Unlink',
'markdown.cancel': 'Cancel',
'markdown.confirm': 'Convert',
'markdown.parseMessage':
'Convert to Markdown format. Existing content will be overwritten. Are you sure? (Closes automatically in 5 seconds)',
'markdown.parseTitle': 'Format as Markdown',
'math.placeholder': 'Please enter a TeX formula',
'modifier.accept': 'Keep',
'modifier.acceptAll': 'Keep All',
'modifier.reject': 'Revert',
'modifier.rejectAll': 'Revert All',
'slash.h1': 'Heading 1',
'slash.h2': 'Heading 2',
'slash.h3': 'Heading 3',
'slash.hr': 'Divider',
'slash.table': 'Table',
'slash.tex': 'TeX Formula',
'table.delete': 'Delete table',
'table.deleteColumn': 'Delete column',
'table.deleteRow': 'Delete row',
'table.insertColumnLeft': 'Insert {{count}} column(s) to the left',
'table.insertColumnRight': 'Insert {{count}} column(s) to the right',
'table.insertRowAbove': 'Insert {{count}} row(s) above',
'table.insertRowBelow': 'Insert {{count}} row(s) below',
'typobar.blockquote': 'Blockquote',
'typobar.bold': 'Bold',
'typobar.bulletList': 'Bulleted list',
'typobar.code': 'Inline code',
'typobar.codeblock': 'Code block',
'typobar.image': 'Image',
'typobar.italic': 'Italic',
'typobar.link': 'Link',
'typobar.numberList': 'Numbered list',
'typobar.redo': 'Redo',
'typobar.strikethrough': 'Strikethrough',
'typobar.table': 'Table',
'typobar.taskList': 'Task List',
'typobar.tex': 'TeX Formula',
'typobar.underline': 'Underline',
'typobar.undo': 'Undo',
};

View file

@ -78,6 +78,9 @@ export default {
'builtins.lobe-page-agent.apiName.insertTableRow': '插入表格行',
'builtins.lobe-page-agent.apiName.listSnapshots': '列出快照',
'builtins.lobe-page-agent.apiName.mergeNodes': '合并节点',
'builtins.lobe-page-agent.apiName.modifyNodes': '修改文档',
'builtins.lobe-page-agent.apiName.modifyNodes.addNodes': '补充内容',
'builtins.lobe-page-agent.apiName.modifyNodes.deleteNodes': '删除内容',
'builtins.lobe-page-agent.apiName.moveNode': '移动节点',
'builtins.lobe-page-agent.apiName.outdentListItem': '取消缩进列表项',
'builtins.lobe-page-agent.apiName.replaceText': '替换文本',

View file

@ -283,12 +283,14 @@ describe('serverMessagesEngine', () => {
const result = await serverMessagesEngine({
messages,
model: 'gpt-4',
pageEditorContext: {
content: 'Page content',
document: {
id: 'doc-1',
pageContentContext: {
markdown: '# Test Document\n\nPage content',
metadata: {
charCount: 30,
lineCount: 3,
title: 'Test Document',
},
xml: '<doc><h1 id="1">Test Document</h1><p id="2">Page content</p></doc>',
},
provider: 'openai',
});

View file

@ -54,7 +54,7 @@ export const serverMessagesEngine = async ({
capabilities,
userMemory,
agentBuilderContext,
pageEditorContext,
pageContentContext,
}: ServerMessagesEngineParams): Promise<OpenAIChatMessage[]> => {
const engine = new MessagesEngine({
// Capability injection
@ -113,7 +113,7 @@ export const serverMessagesEngine = async ({
// Extended contexts
...(agentBuilderContext && { agentBuilderContext }),
...(pageEditorContext && { pageEditorContext }),
...(pageContentContext && { pageContentContext }),
});
const result = await engine.process();

View file

@ -2,9 +2,9 @@ import type {
AgentBuilderContext,
FileContent,
KnowledgeBaseInfo,
PageEditorContext,
UserMemoryData,
} from '@lobechat/context-engine';
import type { PageContentContext } from '@lobechat/prompts';
import type { UIChatMessage } from '@lobechat/types';
/**
@ -57,13 +57,13 @@ export interface ServerUserMemoryConfig {
*/
export interface ServerMessagesEngineParams {
// ========== Extended contexts ==========
/** Agent Builder context (optional, for editing agents) */
/** Agent Builder context (optional, for editing agents) */
agentBuilderContext?: AgentBuilderContext;
// ========== Capability injection ==========
/** Model capability checkers */
/** Model capability checkers */
capabilities?: ServerModelCapabilities;
// ========== Agent configuration ==========
/** Whether to enable history message count limit */
/** Whether to enable history message count limit */
enableHistoryCount?: boolean;
/** Function to format history summary */
@ -75,17 +75,17 @@ export interface ServerMessagesEngineParams {
/** Input template */
inputTemplate?: string;
// ========== Knowledge ==========
/** Knowledge configuration */
/** Knowledge configuration */
knowledge?: ServerKnowledgeConfig;
// ========== Required parameters ==========
/** Original message list */
/** Original message list */
messages: UIChatMessage[];
/** Model ID */
model: string;
/** Page Editor context (optional, for document editing) */
pageEditorContext?: PageEditorContext;
/** Page content context (optional, for document editing) */
pageContentContext?: PageContentContext;
/** Provider ID */
provider: string;
@ -94,14 +94,19 @@ export interface ServerMessagesEngineParams {
systemRole?: string;
// ========== Tools ==========
/** Tools configuration */
/** Tools configuration */
toolsConfig?: ServerToolsConfig;
// ========== User memory ==========
/** User memory configuration */
/** User memory configuration */
userMemory?: ServerUserMemoryConfig;
}
// Re-export types for convenience
export {type AgentBuilderContext, type FileContent, type KnowledgeBaseInfo, type PageEditorContext, type UserMemoryData} from '@lobechat/context-engine';
export {
type AgentBuilderContext,
type FileContent,
type KnowledgeBaseInfo,
type UserMemoryData,
} from '@lobechat/context-engine';
export type { PageContentContext } from '@lobechat/prompts';

View file

@ -9,7 +9,14 @@ import {
standardizeAnimationStyle,
} from '@lobechat/fetch-sse';
import { AgentRuntimeError, ChatCompletionErrorPayload } from '@lobechat/model-runtime';
import { ChatErrorType, TracePayload, TraceTagMap, UIChatMessage } from '@lobechat/types';
import {
ChatErrorType,
RuntimeInitialContext,
RuntimeStepContext,
TracePayload,
TraceTagMap,
UIChatMessage,
} from '@lobechat/types';
import { PluginRequestPayload, createHeadersWithPluginSettings } from '@lobehub/chat-plugin-sdk';
import { merge } from 'es-toolkit/compat';
import { ModelProvider } from 'model-bank';
@ -79,7 +86,11 @@ interface FetchAITaskResultParams extends FetchSSEOptions {
interface CreateAssistantMessageStream extends FetchSSEOptions {
abortController?: AbortController;
historySummary?: string;
/** Initial context for page editor (captured at operation start) */
initialContext?: RuntimeInitialContext;
params: GetChatCompletionPayload;
/** Step context for page editor (updated each step) */
stepContext?: RuntimeStepContext;
trace?: TracePayload;
}
@ -209,11 +220,14 @@ class ChatService {
groupId,
historyCount:
chatConfigByIdSelectors.getHistoryCountById(targetAgentId)(getAgentStoreState()) + 2,
// Page editor context from agent runtime
initialContext: options?.initialContext,
inputTemplate: chatConfig.inputTemplate,
messages,
model: payload.model,
provider: payload.provider!,
sessionId: options?.trace?.sessionId,
stepContext: options?.stepContext,
systemRole: agentConfig.systemRole,
tools: enabledToolIds,
});
@ -249,14 +263,18 @@ class ChatService {
onFinish,
trace,
historySummary,
initialContext,
stepContext,
}: CreateAssistantMessageStream) => {
await this.createAssistantMessage(params, {
historySummary,
initialContext,
onAbort,
onErrorHandle,
onFinish,
onMessageHandle,
signal: abortController?.signal,
stepContext,
trace: this.mapTrace(trace, TraceTagMap.Chat),
});
};

View file

@ -9,7 +9,12 @@ import {
MessagesEngine,
} from '@lobechat/context-engine';
import { historySummaryPrompt } from '@lobechat/prompts';
import { OpenAIChatMessage, UIChatMessage } from '@lobechat/types';
import {
OpenAIChatMessage,
RuntimeInitialContext,
RuntimeStepContext,
UIChatMessage,
} from '@lobechat/types';
import { VARIABLE_GENERATORS } from '@lobechat/utils/client';
import debug from 'debug';
@ -42,11 +47,21 @@ interface ContextEngineeringContext {
groupId?: string;
historyCount?: number;
historySummary?: string;
/**
* Initial context from Agent Runtime
* Contains markdown and metadata captured at operation start
*/
initialContext?: RuntimeInitialContext;
inputTemplate?: string;
messages: UIChatMessage[];
model: string;
provider: string;
sessionId?: string;
/**
* Step context from Agent Runtime
* Contains latest XML structure updated each step
*/
stepContext?: RuntimeStepContext;
systemRole?: string;
tools?: string[];
}
@ -66,6 +81,8 @@ export const contextEngineering = async ({
agentBuilderContext,
agentId,
groupId,
initialContext,
stepContext,
}: ContextEngineeringContext): Promise<OpenAIChatMessage[]> => {
log('tools: %o', tools);
@ -268,6 +285,10 @@ export const contextEngineering = async ({
model,
provider,
// runtime context
initialContext,
stepContext,
// Tools configuration
toolsConfig: {
getToolSystemRoles: (tools) => toolSelectors.enabledSystemRoles(tools)(getToolStoreState()),

View file

@ -1,8 +1,12 @@
import { FetchSSEOptions } from '@lobechat/fetch-sse';
import { TracePayload } from '@lobechat/types';
import { RuntimeInitialContext, RuntimeStepContext, TracePayload } from '@lobechat/types';
export interface FetchOptions extends FetchSSEOptions {
historySummary?: string;
/** Initial context for page editor (captured at operation start) */
initialContext?: RuntimeInitialContext;
signal?: AbortSignal | undefined;
/** Step context for page editor (updated each step) */
stepContext?: RuntimeStepContext;
trace?: TracePayload;
}

View file

@ -73,7 +73,7 @@ export const createAgentExecutors = (context: {
* Custom call_llm executor
* Creates assistant message and calls internal_fetchAIChatMessage
*/
call_llm: async (instruction, state) => {
call_llm: async (instruction, state, runtimeContext) => {
const sessionLogId = `${state.operationId}:${state.stepCount}`;
const stagePrefix = `[${sessionLogId}][call_llm]`;
@ -157,6 +157,9 @@ export const createAgentExecutors = (context: {
model: llmPayload.model,
provider: llmPayload.provider,
operationId: context.operationId,
// Pass runtime context for page editor injection
initialContext: runtimeContext?.initialContext,
stepContext: runtimeContext?.stepContext,
});
log(`[${sessionLogId}] finish model-runtime calling`);

View file

@ -1256,6 +1256,166 @@ describe('StreamingExecutor actions', () => {
});
});
describe('initialContext preservation', () => {
it('should preserve initialContext through multiple steps in agent runtime loop', async () => {
act(() => {
useChatStore.setState({ internal_execAgentRuntime: realExecAgentRuntime });
});
const { result } = renderHook(() => useChatStore());
const userMessage = {
id: TEST_IDS.USER_MESSAGE_ID,
role: 'user',
content: TEST_CONTENT.USER_MESSAGE,
sessionId: TEST_IDS.SESSION_ID,
topicId: TEST_IDS.TOPIC_ID,
} as UIChatMessage;
// Track initialContext passed to chatService across multiple calls
const capturedInitialContexts: any[] = [];
let streamCallCount = 0;
const streamSpy = vi
.spyOn(chatService, 'createAssistantMessageStream')
.mockImplementation(async ({ onFinish, initialContext }) => {
streamCallCount++;
capturedInitialContexts.push(initialContext);
if (streamCallCount === 1) {
// First LLM call returns tool calls
await onFinish?.(TEST_CONTENT.AI_RESPONSE, {
toolCalls: [
{ id: 'tool-1', type: 'function', function: { name: 'test', arguments: '{}' } },
],
} as any);
} else {
// Second LLM call (after tool execution) returns final response
await onFinish?.('Final response', {} as any);
}
});
// Mock internal_createAgentState to include initialContext
const mockInitialContext = {
pageEditor: {
markdown: '# Test Document',
xml: '<root><h1>Test</h1></root>',
metadata: { title: 'Test Doc', charCount: 15, lineCount: 1 },
},
};
const originalCreateAgentState = result.current.internal_createAgentState;
vi.spyOn(result.current, 'internal_createAgentState').mockImplementation((params) => {
const baseResult = originalCreateAgentState(params);
return {
...baseResult,
context: {
...baseResult.context,
initialContext: mockInitialContext,
},
};
});
await act(async () => {
await result.current.internal_execAgentRuntime({
context: { agentId: TEST_IDS.SESSION_ID, topicId: TEST_IDS.TOPIC_ID },
messages: [userMessage],
parentMessageId: userMessage.id,
parentMessageType: 'user',
});
});
// Verify that initialContext was passed to all LLM calls
// Note: The first call should have initialContext, and subsequent calls should preserve it
expect(capturedInitialContexts.length).toBeGreaterThanOrEqual(1);
// All captured initialContexts should be the same (preserved through steps)
capturedInitialContexts.forEach((ctx, index) => {
expect(ctx).toEqual(mockInitialContext);
});
streamSpy.mockRestore();
});
it('should preserve initialContext when result.nextContext does not include it', async () => {
act(() => {
useChatStore.setState({ internal_execAgentRuntime: realExecAgentRuntime });
});
const { result } = renderHook(() => useChatStore());
const userMessage = {
id: TEST_IDS.USER_MESSAGE_ID,
role: 'user',
content: TEST_CONTENT.USER_MESSAGE,
sessionId: TEST_IDS.SESSION_ID,
topicId: TEST_IDS.TOPIC_ID,
} as UIChatMessage;
const capturedInitialContexts: any[] = [];
let streamCallCount = 0;
const streamSpy = vi
.spyOn(chatService, 'createAssistantMessageStream')
.mockImplementation(async ({ onFinish, initialContext }) => {
streamCallCount++;
capturedInitialContexts.push(initialContext);
if (streamCallCount < 3) {
// Return tool calls to continue the loop
await onFinish?.(TEST_CONTENT.AI_RESPONSE, {
toolCalls: [
{
id: `tool-${streamCallCount}`,
type: 'function',
function: { name: 'test', arguments: '{}' },
},
],
} as any);
} else {
// Final response without tool calls
await onFinish?.('Final response', {} as any);
}
});
const mockInitialContext = {
pageEditor: {
markdown: '# Preserved Context',
xml: '<doc>preserved</doc>',
metadata: { title: 'Preserved', charCount: 20, lineCount: 1 },
},
};
const originalCreateAgentState = result.current.internal_createAgentState;
vi.spyOn(result.current, 'internal_createAgentState').mockImplementation((params) => {
const baseResult = originalCreateAgentState(params);
return {
...baseResult,
context: {
...baseResult.context,
initialContext: mockInitialContext,
},
};
});
await act(async () => {
await result.current.internal_execAgentRuntime({
context: { agentId: TEST_IDS.SESSION_ID, topicId: TEST_IDS.TOPIC_ID },
messages: [userMessage],
parentMessageId: userMessage.id,
parentMessageType: 'user',
});
});
// Verify initialContext was preserved across all LLM calls
// Even though result.nextContext from executors doesn't include initialContext,
// the loop should preserve it from the original context
capturedInitialContexts.forEach((ctx) => {
expect(ctx).toEqual(mockInitialContext);
});
streamSpy.mockRestore();
});
});
describe('operation status handling', () => {
it('should complete operation when state is waiting_for_human', async () => {
const { result } = renderHook(() => useChatStore());

View file

@ -7,6 +7,7 @@ import {
GeneralChatAgent,
computeStepContext,
} from '@lobechat/agent-runtime';
import { PageAgentIdentifier } from '@lobechat/builtin-tool-page-agent';
import { isDesktop } from '@lobechat/const';
import {
ChatImageItem,
@ -16,6 +17,8 @@ import {
MessageMapScope,
MessageToolCall,
ModelUsage,
RuntimeInitialContext,
RuntimeStepContext,
TraceNameMap,
UIChatMessage,
} from '@lobechat/types';
@ -39,6 +42,7 @@ import { getUserStoreState } from '@/store/user/store';
import { topicSelectors } from '../../../selectors';
import { cleanSpeakerTag } from '../../../utils/cleanSpeakerTag';
import { messageMapKey } from '../../../utils/messageMapKey';
import { pageAgentRuntime } from '../../builtinTool/actions/pageAgent';
import { selectTodosFromMessages } from '../../message/selectors/dbMessage';
const log = debug('lobe-store:streaming-executor');
@ -85,6 +89,10 @@ export interface StreamingExecutorAction {
operationId?: string;
agentConfig?: any;
traceId?: string;
/** Initial context for page editor (captured at operation start) */
initialContext?: RuntimeInitialContext;
/** Step context for page editor (updated each step) */
stepContext?: RuntimeStepContext;
}) => Promise<{
isFunctionCall: boolean;
tools?: ChatToolPayload[];
@ -199,6 +207,37 @@ export const streamingExecutor: StateCreator<
userInterventionConfig,
});
// Build initialContext for page editor if lobe-page-agent is enabled
let runtimeInitialContext: RuntimeInitialContext | undefined;
if (enabledToolIds.includes(PageAgentIdentifier)) {
try {
// Get page content context from page agent runtime
const pageContentContext = pageAgentRuntime.getPageContentContext('both');
runtimeInitialContext = {
pageEditor: {
markdown: pageContentContext.markdown || '',
xml: pageContentContext.xml || '',
metadata: {
title: pageContentContext.metadata.title,
charCount: pageContentContext.metadata.charCount,
lineCount: pageContentContext.metadata.lineCount,
},
},
};
console.log('runtimeInitialContext', runtimeInitialContext);
log(
'[internal_createAgentState] Page Agent detected, injected initialContext.pageEditor with title: %s',
pageContentContext.metadata.title,
);
} catch (error) {
// Page agent runtime may not be initialized (e.g., editor not set)
// This is expected in some scenarios, so we just log and continue
log('[internal_createAgentState] Failed to get page content context: %o', error);
}
}
// Create initial context or use provided context
const context: AgentRuntimeContext = initialContext || {
phase: 'init',
@ -213,6 +252,8 @@ export const streamingExecutor: StateCreator<
status: state.status,
stepCount: 0,
},
// Inject initialContext if available
initialContext: runtimeInitialContext,
};
return { state, context };
@ -226,6 +267,8 @@ export const streamingExecutor: StateCreator<
operationId,
agentConfig,
traceId: traceIdParam,
initialContext,
stepContext,
}) => {
const {
optimisticUpdateMessageContent,
@ -355,6 +398,9 @@ export const streamingExecutor: StateCreator<
plugins: finalAgentConfig.plugins,
},
historySummary: historySummary?.content,
// Pass page editor context from agent runtime
initialContext,
stepContext,
trace: {
traceId,
topicId: topicId ?? undefined,
@ -989,11 +1035,21 @@ export const streamingExecutor: StateCreator<
const todos = selectTodosFromMessages(currentDBMessages);
const stepContext = computeStepContext({ todos });
// If page agent is enabled, get the latest XML for stepPageEditor
if (nextContext.initialContext?.pageEditor) {
try {
const pageContentContext = pageAgentRuntime.getPageContentContext('xml');
stepContext.stepPageEditor = {
xml: pageContentContext.xml || '',
};
} catch (error) {
// Page agent runtime may not be available, ignore errors
log('[internal_execAgentRuntime] Failed to get page XML for step: %o', error);
}
}
// Inject stepContext into the runtime context for this step
nextContext = {
...nextContext,
stepContext,
};
nextContext = { ...nextContext, stepContext };
log(
'[internal_execAgentRuntime][step-%d]: phase=%s, status=%s, stepContext=%O',
@ -1069,7 +1125,9 @@ export const streamingExecutor: StateCreator<
break;
}
nextContext = result.nextContext;
// Preserve initialContext when updating nextContext
// initialContext is set once at the start and should persist through all steps
nextContext = { ...result.nextContext, initialContext: nextContext.initialContext };
}
log(

View file

@ -1,8 +1,8 @@
import { PageAgentExecutionRuntime } from '@lobechat/builtin-tool-page-agent/executionRuntime';
import debug from 'debug';
import { StateCreator } from 'zustand/vanilla';
import { ChatStore } from '@/store/chat/store';
import { PageAgentExecutionRuntime } from '@/tools/page-agent/ExecutionRuntime';
const log = debug('page:page-agent');

View file

@ -5,11 +5,11 @@ import { GTDManifest } from '@lobechat/builtin-tool-gtd';
import { LocalSystemManifest } from '@lobechat/builtin-tool-local-system';
import { MemoryManifest } from '@lobechat/builtin-tool-memory';
import { NotebookManifest } from '@lobechat/builtin-tool-notebook';
import { PageAgentManifest } from '@lobechat/builtin-tool-page-agent';
import { ArtifactsManifest } from './artifacts';
import { CodeInterpreterManifest } from './code-interpreter';
import { KnowledgeBaseManifest } from './knowledge-base';
import { PageAgentManifest } from './page-agent';
import { WebBrowsingManifest } from './web-browsing';
export const builtinToolIdentifiers: string[] = [

View file

@ -5,6 +5,7 @@ import { GTDManifest } from '@lobechat/builtin-tool-gtd';
import { LocalSystemManifest } from '@lobechat/builtin-tool-local-system';
import { MemoryManifest } from '@lobechat/builtin-tool-memory';
import { NotebookManifest } from '@lobechat/builtin-tool-notebook';
import { PageAgentManifest } from '@lobechat/builtin-tool-page-agent';
import { LobeBuiltinTool } from '@lobechat/types';
import { isDesktop } from '@/const/version';
@ -12,7 +13,6 @@ import { isDesktop } from '@/const/version';
import { ArtifactsManifest } from './artifacts';
import { CodeInterpreterManifest } from './code-interpreter';
import { KnowledgeBaseManifest } from './knowledge-base';
import { PageAgentManifest } from './page-agent';
import { WebBrowsingManifest } from './web-browsing';
export const builtinTools: LobeBuiltinTool[] = [

View file

@ -1,4 +0,0 @@
/**
* Page Agent / Document Tool identifier
*/
export const PAGE_AGENT_TOOL_ID = 'lobe-page-agent';

View file

@ -1,696 +0,0 @@
/* eslint-disable sort-keys-fix/sort-keys-fix */
import { BuiltinToolManifest } from '@lobechat/types';
import { systemPrompt } from './systemRole';
export const DocumentApiName = {
// Initialize
initPage: 'initPage',
// Document Metadata
editTitle: 'editTitle',
// Query & Read
getPageContent: 'getPageContent',
// Unified CRUD - replaces createNode, updateNode, deleteNode
modifyNodes: 'modifyNodes',
// Legacy CRUD (deprecated, kept for backward compatibility)
createNode: 'createNode',
deleteNode: 'deleteNode',
duplicateNode: 'duplicateNode',
moveNode: 'moveNode',
updateNode: 'updateNode',
// Batch Operations
// batchUpdate: 'batchUpdate',
// Text Operations
replaceText: 'replaceText',
// Structure Operations
// mergeNodes: 'mergeNodes',
// splitNode: 'splitNode',
// unwrapNode: 'unwrapNode',
// wrapNodes: 'wrapNodes',
// Table Operations
// deleteTableColumn: 'deleteTableColumn',
// deleteTableRow: 'deleteTableRow',
// insertTableColumn: 'insertTableColumn',
// insertTableRow: 'insertTableRow',
// Image Operations
// cropImage: 'cropImage',
// resizeImage: 'resizeImage',
// rotateImage: 'rotateImage',
// setImageAlt: 'setImageAlt',
// List Operations
// convertToList: 'convertToList',
// indentListItem: 'indentListItem',
// outdentListItem: 'outdentListItem',
// toggleListType: 'toggleListType',
// Snapshot Operations
// compareSnapshots: 'compareSnapshots',
// deleteSnapshot: 'deleteSnapshot',
// listSnapshots: 'listSnapshots',
// restoreSnapshot: 'restoreSnapshot',
// saveSnapshot: 'saveSnapshot',
};
export const PageAgentManifest: BuiltinToolManifest = {
api: [
// ============ Initialize ============
{
description:
'Initialize a new document from Markdown content. Converts the Markdown into an XML-structured document with unique IDs for each node. This should be called first before performing any other document operations.',
name: DocumentApiName.initPage,
parameters: {
properties: {
markdown: {
description:
'The Markdown content。 Supports headings, paragraphs, lists, tables, images, links, code blocks, and other common Markdown syntax.',
type: 'string',
},
},
required: ['markdown'],
type: 'object',
},
},
// ============ Document Metadata ============
{
description:
'Edit the title of the current document. The title is displayed in the document header and is stored separately from the document content.',
name: DocumentApiName.editTitle,
parameters: {
properties: {
title: {
description: 'The new title for the document.',
type: 'string',
},
},
required: ['title'],
type: 'object',
},
},
// ============ Query & Read ============
{
description:
'Get the current page content and metadata. Returns the document in XML format with node IDs, markdown format, or both. Use this tool to retrieve the latest state of the document.',
name: DocumentApiName.getPageContent,
parameters: {
properties: {
format: {
default: 'both',
description:
'The format to return the content in. Options: "xml" (returns document structure with node IDs), "markdown" (returns plain markdown text), "both" (returns both formats). Defaults to "both".',
enum: ['xml', 'markdown', 'both'],
type: 'string',
},
},
type: 'object',
},
},
// ============ Unified Node Operations ============
{
description:
'Perform node operations (insert, modify, remove) on the document. This is the unified API for all CRUD operations. Supports batch operations by passing multiple operations in a single call.',
name: DocumentApiName.modifyNodes,
parameters: {
properties: {
operations: {
description:
'Array of operations to perform. Each operation can be: insert (add a new node), modify (update existing nodes), or remove (delete a node).',
items: {
oneOf: [
{
description: 'Insert a new node before a reference node',
properties: {
action: { const: 'insert', type: 'string' },
beforeId: {
description: 'ID of the node to insert before',
type: 'string',
},
litexml: {
description:
'The LiteXML string representing the node to insert (e.g., "<p>New paragraph</p>")',
type: 'string',
},
},
required: ['action', 'beforeId', 'litexml'],
type: 'object',
},
{
description: 'Insert a new node after a reference node',
properties: {
action: { const: 'insert', type: 'string' },
afterId: {
description: 'ID of the node to insert after',
type: 'string',
},
litexml: {
description:
'The LiteXML string representing the node to insert (e.g., "<p>New paragraph</p>")',
type: 'string',
},
},
required: ['action', 'afterId', 'litexml'],
type: 'object',
},
{
description: 'Modify existing nodes by providing updated LiteXML with node IDs',
properties: {
action: { const: 'modify', type: 'string' },
litexml: {
description:
'LiteXML string or array of strings with node IDs to update (e.g., "<p id=\\"abc\\">Updated content</p>" or ["<p id=\\"a\\">Text 1</p>", "<p id=\\"b\\">Text 2</p>"])',
oneOf: [{ type: 'string' }, { items: { type: 'string' }, type: 'array' }],
},
},
required: ['action', 'litexml'],
type: 'object',
},
{
description: 'Remove a node by ID',
properties: {
action: { const: 'remove', type: 'string' },
id: {
description: 'ID of the node to remove',
type: 'string',
},
},
required: ['action', 'id'],
type: 'object',
},
],
},
type: 'array',
},
},
required: ['operations'],
type: 'object',
},
},
// ============ Text Operations ============
{
description:
'Find and replace text across the document or within specific nodes. Supports regex patterns.',
name: DocumentApiName.replaceText,
parameters: {
properties: {
newText: {
description: 'The replacement text.',
type: 'string',
},
nodeIds: {
description:
'Optional array of node IDs to limit the replacement scope. If not provided, searches entire document.',
items: { type: 'string' },
type: 'array',
},
replaceAll: {
default: true,
description: 'Whether to replace all occurrences or just the first one.',
type: 'boolean',
},
searchText: {
description: 'The text to find. Can be a plain string or regex pattern.',
type: 'string',
},
useRegex: {
default: false,
description: 'Whether to treat searchText as a regular expression.',
type: 'boolean',
},
},
required: ['searchText', 'newText'],
type: 'object',
},
},
// ============ Batch Operations ============
// {
// description:
// 'Update multiple nodes at once with the same changes. Useful for bulk formatting or attribute changes.',
// name: DocumentApiName.batchUpdate,
// parameters: {
// properties: {
// attributes: {
// description: 'Attributes to update on all specified nodes.',
// type: 'object',
// },
// nodeIds: {
// description: 'Array of node IDs to update.',
// items: { type: 'string' },
// type: 'array',
// },
// },
// required: ['nodeIds'],
// type: 'object',
// },
// },
// ============ Structure Operations ============
// {
// description:
// 'Wrap one or more nodes with a new parent node. Useful for grouping content or adding containers.',
// name: DocumentApiName.wrapNodes,
// parameters: {
// properties: {
// nodeIds: {
// description: 'Array of node IDs to wrap. Nodes must be siblings.',
// items: { type: 'string' },
// type: 'array',
// },
// wrapperAttributes: {
// description: 'Attributes for the wrapper node.',
// type: 'object',
// },
// wrapperType: {
// description: 'The type of wrapper node to create (e.g., "div", "blockquote", "ul").',
// type: 'string',
// },
// },
// required: ['nodeIds', 'wrapperType'],
// type: 'object',
// },
// },
// {
// description:
// 'Remove a wrapper node while keeping its children. The children will take the place of the wrapper.',
// name: DocumentApiName.unwrapNode,
// parameters: {
// properties: {
// nodeId: {
// description: 'The ID of the wrapper node to unwrap.',
// type: 'string',
// },
// },
// required: ['nodeId'],
// type: 'object',
// },
// },
// {
// description:
// 'Merge multiple adjacent sibling nodes into one. Content from all nodes will be combined.',
// name: DocumentApiName.mergeNodes,
// parameters: {
// properties: {
// nodeIds: {
// description:
// 'Array of adjacent sibling node IDs to merge. The first node will be kept and others merged into it.',
// items: { type: 'string' },
// type: 'array',
// },
// },
// required: ['nodeIds'],
// type: 'object',
// },
// },
// {
// description:
// 'Split a node at a specific position. Creates two nodes from one, dividing the content.',
// name: DocumentApiName.splitNode,
// parameters: {
// properties: {
// nodeId: {
// description: 'The ID of the node to split.',
// type: 'string',
// },
// splitAt: {
// description:
// 'Where to split. For text nodes, this is the character offset. For container nodes, this is the child index.',
// type: 'number',
// },
// },
// required: ['nodeId', 'splitAt'],
// type: 'object',
// },
// },
// ============ Table Operations ============
// {
// description:
// 'Insert a new row into a table. Can insert at a specific position or at the end.',
// name: DocumentApiName.insertTableRow,
// parameters: {
// properties: {
// cells: {
// description:
// 'Array of cell contents for the new row. If not provided, empty cells will be created.',
// items: { type: 'string' },
// type: 'array',
// },
// position: {
// default: 'after',
// description: 'Insert "before" or "after" the reference row.',
// enum: ['before', 'after'],
// type: 'string',
// },
// referenceRowId: {
// description:
// 'The ID of the reference row. If not provided, inserts at the end of the table.',
// type: 'string',
// },
// tableId: {
// description: 'The ID of the table to modify.',
// type: 'string',
// },
// },
// required: ['tableId'],
// type: 'object',
// },
// },
// {
// description:
// 'Insert a new column into a table. Adds a cell to each row at the specified position.',
// name: DocumentApiName.insertTableColumn,
// parameters: {
// properties: {
// cells: {
// description:
// 'Array of cell contents for the new column, one for each row. If not provided, empty cells will be created.',
// items: { type: 'string' },
// type: 'array',
// },
// columnIndex: {
// description: 'The index where to insert the column (0-based). -1 means at the end.',
// type: 'number',
// },
// headerContent: {
// description: 'Content for the header cell if the table has a header row.',
// type: 'string',
// },
// tableId: {
// description: 'The ID of the table to modify.',
// type: 'string',
// },
// },
// required: ['tableId', 'columnIndex'],
// type: 'object',
// },
// },
// {
// description: 'Delete a row from a table.',
// humanIntervention: 'required',
// name: DocumentApiName.deleteTableRow,
// parameters: {
// properties: {
// rowId: {
// description: 'The ID of the row to delete.',
// type: 'string',
// },
// },
// required: ['rowId'],
// type: 'object',
// },
// },
// {
// description:
// 'Delete a column from a table. Removes the cell at the specified index from each row.',
// humanIntervention: 'required',
// name: DocumentApiName.deleteTableColumn,
// parameters: {
// properties: {
// columnIndex: {
// description: 'The index of the column to delete (0-based).',
// type: 'number',
// },
// tableId: {
// description: 'The ID of the table to modify.',
// type: 'string',
// },
// },
// required: ['tableId', 'columnIndex'],
// type: 'object',
// },
// },
// ============ Image Operations ============
// {
// description:
// 'Resize an image node. You can specify width, height, or both. Use keepAspectRatio to maintain proportions.',
// name: DocumentApiName.resizeImage,
// parameters: {
// properties: {
// height: {
// description: 'The new height in pixels.',
// type: 'number',
// },
// keepAspectRatio: {
// default: true,
// description:
// 'Whether to maintain the aspect ratio when only one dimension is specified.',
// type: 'boolean',
// },
// nodeId: {
// description: 'The ID of the image node to resize.',
// type: 'string',
// },
// width: {
// description: 'The new width in pixels.',
// type: 'number',
// },
// },
// required: ['nodeId'],
// type: 'object',
// },
// },
// {
// description:
// 'Crop an image to a specific region. Specify the x, y coordinates (top-left corner) and the width and height of the crop area.',
// name: DocumentApiName.cropImage,
// parameters: {
// properties: {
// height: {
// description: 'The height of the crop area in pixels.',
// type: 'number',
// },
// nodeId: {
// description: 'The ID of the image node to crop.',
// type: 'string',
// },
// width: {
// description: 'The width of the crop area in pixels.',
// type: 'number',
// },
// x: {
// description: 'The x coordinate of the top-left corner of the crop area.',
// type: 'number',
// },
// y: {
// description: 'The y coordinate of the top-left corner of the crop area.',
// type: 'number',
// },
// },
// required: ['nodeId', 'x', 'y', 'width', 'height'],
// type: 'object',
// },
// },
// {
// description: 'Rotate an image by 90, 180, or 270 degrees (clockwise or counter-clockwise).',
// name: DocumentApiName.rotateImage,
// parameters: {
// properties: {
// angle: {
// description:
// 'The rotation angle. Positive values rotate clockwise, negative counter-clockwise.',
// enum: [90, 180, 270, -90, -180, -270],
// type: 'number',
// },
// nodeId: {
// description: 'The ID of the image node to rotate.',
// type: 'string',
// },
// },
// required: ['nodeId', 'angle'],
// type: 'object',
// },
// },
// {
// description: 'Set or update the alt text of an image for accessibility.',
// name: DocumentApiName.setImageAlt,
// parameters: {
// properties: {
// alt: {
// description: 'The alt text to set for the image.',
// type: 'string',
// },
// nodeId: {
// description: 'The ID of the image node.',
// type: 'string',
// },
// },
// required: ['nodeId', 'alt'],
// type: 'object',
// },
// },
// ============ List Operations ============
// {
// description:
// 'Indent a list item, making it a child of the previous sibling. Creates nested list structure.',
// name: DocumentApiName.indentListItem,
// parameters: {
// properties: {
// nodeId: {
// description: 'The ID of the list item (li) to indent.',
// type: 'string',
// },
// },
// required: ['nodeId'],
// type: 'object',
// },
// },
// {
// description:
// 'Outdent a list item, moving it up one level in the list hierarchy. Reduces nesting.',
// name: DocumentApiName.outdentListItem,
// parameters: {
// properties: {
// nodeId: {
// description: 'The ID of the list item (li) to outdent.',
// type: 'string',
// },
// },
// required: ['nodeId'],
// type: 'object',
// },
// },
// {
// description:
// 'Toggle a list between ordered (ol) and unordered (ul) types. All list items are preserved.',
// name: DocumentApiName.toggleListType,
// parameters: {
// properties: {
// listId: {
// description: 'The ID of the list (ul or ol) to toggle.',
// type: 'string',
// },
// targetType: {
// description: 'The target list type.',
// enum: ['ul', 'ol'],
// type: 'string',
// },
// },
// required: ['listId', 'targetType'],
// type: 'object',
// },
// },
// {
// description:
// 'Convert one or more paragraph or other nodes into a list. Creates a new list containing the specified nodes as list items.',
// name: DocumentApiName.convertToList,
// parameters: {
// properties: {
// listType: {
// description: 'The type of list to create.',
// enum: ['ul', 'ol'],
// type: 'string',
// },
// nodeIds: {
// description: 'Array of node IDs to convert into list items.',
// items: { type: 'string' },
// type: 'array',
// },
// },
// required: ['nodeIds', 'listType'],
// type: 'object',
// },
// },
// ============ Snapshot Operations ============
// {
// description:
// 'Save a snapshot of the current document state. Useful for creating restore points before making significant changes.',
// name: DocumentApiName.saveSnapshot,
// parameters: {
// properties: {
// description: {
// description:
// 'Optional description to identify this snapshot (e.g., "Before reformatting tables").',
// type: 'string',
// },
// },
// type: 'object',
// },
// },
// {
// description:
// 'Restore the document to a previously saved snapshot. This will discard all changes made after that snapshot.',
// humanIntervention: 'required',
// name: DocumentApiName.restoreSnapshot,
// parameters: {
// properties: {
// snapshotId: {
// description: 'The ID of the snapshot to restore.',
// type: 'string',
// },
// },
// required: ['snapshotId'],
// type: 'object',
// },
// },
// {
// description: 'List all saved snapshots for the current document.',
// name: DocumentApiName.listSnapshots,
// parameters: {
// properties: {
// limit: {
// default: 10,
// description: 'Maximum number of snapshots to return.',
// type: 'number',
// },
// },
// type: 'object',
// },
// },
// {
// description: 'Delete a saved snapshot.',
// name: DocumentApiName.deleteSnapshot,
// parameters: {
// properties: {
// snapshotId: {
// description: 'The ID of the snapshot to delete.',
// type: 'string',
// },
// },
// required: ['snapshotId'],
// type: 'object',
// },
// },
// {
// description:
// 'Compare two snapshots to see what changed between them. Returns lists of added, deleted, and modified nodes.',
// name: DocumentApiName.compareSnapshots,
// parameters: {
// properties: {
// snapshotId1: {
// description: 'The ID of the first (older) snapshot.',
// type: 'string',
// },
// snapshotId2: {
// description: 'The ID of the second (newer) snapshot.',
// type: 'string',
// },
// },
// required: ['snapshotId1', 'snapshotId2'],
// type: 'object',
// },
// },
],
identifier: 'lobe-page-agent',
meta: {
avatar: '📄',
description: 'Create, read, update, and delete nodes in XML-structured documents',
title: 'Document',
},
systemRole: systemPrompt,
type: 'builtin',
};