Revert "🐛 fix(agent-document): support image LiteXML in headless editor (#15764)"

This reverts commit 3f3f12dbd2.
This commit is contained in:
Arvin Xu
2026-06-13 20:29:35 +08:00
parent 81d40b90d4
commit 24e34c7545
5 changed files with 8 additions and 409 deletions
@@ -1,8 +0,0 @@
import { createHeadlessEditor } from '@lobehub/editor/headless';
import { AgentDocumentMediaPlugin } from './headlessMediaPlugin';
export const createAgentDocumentHeadlessEditor = () =>
createHeadlessEditor({
additionalPlugins: [AgentDocumentMediaPlugin],
});
@@ -30,13 +30,6 @@ const getSpanId = (litexml: string, text: string): string => {
return match![1];
};
const getParagraphId = (litexml: string, text: string): string => {
const match = litexml.match(new RegExp(`<p id="([^"]+)">\\s*<span id="[^"]+">${text}</span>`));
expect(match).not.toBeNull();
return match![1];
};
describe('agent document headless editor', () => {
it('should create a valid empty snapshot for whitespace-only markdown', async () => {
const snapshot = await createMarkdownEditorSnapshot(' \n ');
@@ -73,31 +66,4 @@ describe('agent document headless editor', () => {
// can render a review UI when the user next opens the document.
expect(hasNodeType(snapshot.editorData, 'diff')).toBe(true);
});
it('should apply LiteXML image insert operations as block images', async () => {
const imageUrl = 'https://example.com/diagram.png';
const initial = await exportEditorDataSnapshot({
fallbackContent: 'Before',
litexml: true,
});
const paragraphId = getParagraphId(initial.litexml!, 'Before');
const snapshot = await applyLiteXMLOperations({
editorData: initial.editorData,
fallbackContent: initial.content,
operations: [
{
action: 'insert',
afterId: paragraphId,
litexml: `<img src="${imageUrl}" alt="diagram" />`,
},
],
});
expect(snapshot.content).toContain(`![diagram](${imageUrl})`);
expect(snapshot.litexml).toContain('<img');
expect(snapshot.litexml).toContain(`src="${imageUrl}"`);
expect(hasNodeType(snapshot.editorData, 'block-image')).toBe(true);
expect(hasNodeType(snapshot.editorData, 'diff')).toBe(true);
});
});
@@ -5,8 +5,6 @@ import type { SerializedEditorState, SerializedLexicalNode } from 'lexical';
import { EMPTY_EDITOR_STATE } from '@/libs/editor/constants';
import { isValidEditorData } from '@/libs/editor/isValidEditorData';
import { createAgentDocumentHeadlessEditor } from './createHeadlessEditor';
export type AgentDocumentEditorData = Record<string, any>;
export type AgentDocumentLiteXMLOperation =
@@ -143,7 +141,8 @@ const loadEditorState = (
export const createMarkdownEditorSnapshot = async (
content: string,
): Promise<AgentDocumentEditorSnapshot> => {
const editor = createAgentDocumentHeadlessEditor();
const { createHeadlessEditor } = await import('@lobehub/editor/headless');
const editor = createHeadlessEditor();
try {
hydrateMarkdownOrEmptyState(editor, content);
@@ -156,7 +155,8 @@ export const createMarkdownEditorSnapshot = async (
export const exportEditorDataSnapshot = async (
params: LoadEditorStateParams & { litexml?: boolean },
): Promise<AgentDocumentEditorSnapshot> => {
const editor = createAgentDocumentHeadlessEditor();
const { createHeadlessEditor } = await import('@lobehub/editor/headless');
const editor = createHeadlessEditor();
try {
loadEditorState(editor, params);
@@ -173,7 +173,8 @@ export const applyLiteXMLOperations = async ({
}: LoadEditorStateParams & {
operations: AgentDocumentLiteXMLOperation[];
}): Promise<AgentDocumentEditorSnapshot> => {
const editor = createAgentDocumentHeadlessEditor();
const { createHeadlessEditor } = await import('@lobehub/editor/headless');
const editor = createHeadlessEditor();
try {
loadEditorState(editor, { editorData, fallbackContent });
@@ -1,359 +0,0 @@
import {
DecoratorNode,
type LexicalEditor,
type LexicalNode,
type LexicalNodeConfig,
type NodeKey,
} from 'lexical';
const IMAGE_NODE_TYPE = 'image';
const BLOCK_IMAGE_NODE_TYPE = 'block-image';
interface ServiceId<T> {
readonly __serviceId: string;
__serviceType?: T;
}
interface EditorKernel {
registerNodes: (nodes: LexicalNodeConfig[]) => void;
requireService: <T>(serviceId: ServiceId<T>) => T | null;
}
interface EditorPlugin {
destroy: () => void;
onInit?: (editor: LexicalEditor) => void;
}
interface LiteXMLWriterContext {
createXmlNode: (tagName: string, attributes?: Record<string, string | undefined>) => unknown;
}
interface LiteXMLService {
registerXMLReader: (
tagName: string,
reader: (xmlNode: Element, children: SerializedNodeRecord[]) => SerializedNodeRecord | false,
) => void;
registerXMLWriter: (
nodeType: string,
writer: (node: LexicalNode, ctx: LiteXMLWriterContext) => unknown | false,
) => void;
}
interface MarkdownWriterContext {
appendLine: (value: string) => void;
}
interface MarkdownImageNode {
alt?: string | null;
url?: string | null;
}
interface MarkdownService {
registerMarkdownReader: (
type: string,
reader: (node: MarkdownImageNode) => SerializedNodeRecord,
) => void;
registerMarkdownWriter: (
type: string,
writer: (ctx: MarkdownWriterContext, node: LexicalNode) => void,
) => void;
}
interface INodeService {
registerProcessNodeTree: (process: (tree: { root: SerializedNodeRecord }) => void) => void;
}
interface SerializedNodeRecord {
[key: string]: unknown;
children?: SerializedNodeRecord[];
type?: string;
}
interface SerializedImageNode extends SerializedNodeRecord {
altText: string;
height: number;
maxWidth?: number;
src: string;
type: typeof IMAGE_NODE_TYPE | typeof BLOCK_IMAGE_NODE_TYPE;
version: 1;
width: number;
}
const ILitexmlService: ServiceId<LiteXMLService> = { __serviceId: 'ILitexmlService' };
const IMarkdownShortCutService: ServiceId<MarkdownService> = {
__serviceId: 'MarkdownShortCutService',
};
const INodeService: ServiceId<INodeService> = { __serviceId: 'INodeService' };
const parseDimension = (value: string | null) => {
if (!value) return undefined;
const numberValue = Number.parseInt(value, 10);
return Number.isFinite(numberValue) ? numberValue : undefined;
};
const normalizeDimension = (value?: number | string | null): number | 'inherit' => {
if (typeof value === 'number' && value > 0) return value;
if (typeof value === 'string') {
const parsed = Number.parseInt(value, 10);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
return 'inherit';
};
const serializeDimension = (value: number | 'inherit') => (value === 'inherit' ? 0 : value);
const createSerializedImageNode = ({
altText = '',
block = true,
maxWidth,
src = '',
width,
}: {
altText?: string;
block?: boolean;
maxWidth?: number;
src?: string;
width?: number;
}): SerializedImageNode => ({
altText,
height: 0,
maxWidth,
src,
type: block ? BLOCK_IMAGE_NODE_TYPE : IMAGE_NODE_TYPE,
version: 1,
width: width ?? 0,
});
class BaseAgentDocumentImageNode extends DecoratorNode<null> {
protected __altText: string;
protected __height: number | 'inherit';
protected __maxWidth?: number;
protected __src: string;
protected __width: number | 'inherit';
constructor(
src: string,
altText: string,
maxWidth?: number,
width?: number | string | null,
height?: number | string | null,
key?: NodeKey,
) {
super(key);
this.__src = src;
this.__altText = altText;
this.__maxWidth = maxWidth;
this.__width = normalizeDimension(width);
this.__height = normalizeDimension(height);
}
createDOM(): HTMLElement {
if (typeof document === 'undefined') return {} as HTMLElement;
return document.createElement(this.isInline() ? 'span' : 'div');
}
decorate(): null {
return null;
}
exportJSON(): SerializedImageNode {
return {
...super.exportJSON(),
altText: this.__altText,
height: serializeDimension(this.__height),
maxWidth: this.__maxWidth,
src: this.__src,
width: serializeDimension(this.__width),
} as SerializedImageNode;
}
getAltText() {
return this.__altText;
}
getMaxWidth() {
return this.__maxWidth;
}
getSrc() {
return this.__src;
}
getWidth() {
return this.__width;
}
isInline() {
return true;
}
updateDOM(): false {
return false;
}
}
class AgentDocumentImageNode extends BaseAgentDocumentImageNode {
static clone(node: AgentDocumentImageNode): AgentDocumentImageNode {
return new AgentDocumentImageNode(
node.__src,
node.__altText,
node.__maxWidth,
node.__width,
node.__height,
node.__key,
);
}
static getType() {
return IMAGE_NODE_TYPE;
}
static importJSON(serializedNode: SerializedImageNode): AgentDocumentImageNode {
return new AgentDocumentImageNode(
serializedNode.src,
serializedNode.altText,
serializedNode.maxWidth,
serializedNode.width,
serializedNode.height,
);
}
}
class AgentDocumentBlockImageNode extends BaseAgentDocumentImageNode {
static clone(node: AgentDocumentBlockImageNode): AgentDocumentBlockImageNode {
return new AgentDocumentBlockImageNode(
node.__src,
node.__altText,
node.__maxWidth,
node.__width,
node.__height,
node.__key,
);
}
static getType() {
return BLOCK_IMAGE_NODE_TYPE;
}
static importJSON(serializedNode: SerializedImageNode): AgentDocumentBlockImageNode {
return new AgentDocumentBlockImageNode(
serializedNode.src,
serializedNode.altText,
serializedNode.maxWidth,
serializedNode.width,
serializedNode.height,
);
}
isInline() {
return false;
}
}
const isImageNode = (node: LexicalNode): node is BaseAgentDocumentImageNode =>
node.getType() === IMAGE_NODE_TYPE || node.getType() === BLOCK_IMAGE_NODE_TYPE;
const normalizeBlockImageParagraph = (node: SerializedNodeRecord): SerializedNodeRecord => {
if (Array.isArray(node.children)) {
const children = node.children.map(normalizeBlockImageParagraph);
if (
node.type === 'paragraph' &&
children.length === 1 &&
children[0].type === BLOCK_IMAGE_NODE_TYPE
) {
return children[0];
}
return { ...node, children };
}
return node;
};
export class AgentDocumentMediaPlugin implements EditorPlugin {
static readonly pluginName = 'AgentDocumentMediaPlugin';
constructor(private readonly kernel: EditorKernel) {
kernel.registerNodes([AgentDocumentImageNode, AgentDocumentBlockImageNode]);
}
destroy() {}
onInit(_editor: LexicalEditor) {
this.registerLiteXML();
this.registerMarkdown();
this.registerINode();
}
private registerINode() {
const service = this.kernel.requireService(INodeService);
if (!service) return;
service.registerProcessNodeTree(({ root }) => {
if (!Array.isArray(root.children)) return;
root.children = root.children.map(normalizeBlockImageParagraph);
});
}
private registerLiteXML() {
const service = this.kernel.requireService(ILitexmlService);
if (!service) return;
service.registerXMLReader('img', (xmlNode) => {
const explicitInline = xmlNode.getAttribute('block') === 'false';
return createSerializedImageNode({
altText: xmlNode.getAttribute('alt') || '',
block: !explicitInline,
maxWidth: parseDimension(xmlNode.getAttribute('max-width')),
src: xmlNode.getAttribute('src') || '',
width: parseDimension(xmlNode.getAttribute('width')),
});
});
const writeImage = (node: LexicalNode, ctx: LiteXMLWriterContext) => {
if (!isImageNode(node)) return false;
const attributes: Record<string, string | undefined> = {
src: node.getSrc(),
};
if (node.getAltText()) attributes.alt = node.getAltText();
if (node.getType() === BLOCK_IMAGE_NODE_TYPE) attributes.block = 'true';
if (typeof node.getMaxWidth() === 'number')
attributes['max-width'] = String(node.getMaxWidth());
if (typeof node.getWidth() === 'number') attributes.width = String(node.getWidth());
return ctx.createXmlNode('img', attributes);
};
service.registerXMLWriter(IMAGE_NODE_TYPE, writeImage);
service.registerXMLWriter(BLOCK_IMAGE_NODE_TYPE, writeImage);
}
private registerMarkdown() {
const service = this.kernel.requireService(IMarkdownShortCutService);
if (!service) return;
const writeImage = (ctx: MarkdownWriterContext, node: LexicalNode) => {
if (!isImageNode(node)) return;
const markdown = `![${node.getAltText()}](${node.getSrc()})`;
ctx.appendLine(node.getType() === BLOCK_IMAGE_NODE_TYPE ? `${markdown}\n\n` : markdown);
};
service.registerMarkdownWriter(IMAGE_NODE_TYPE, writeImage);
service.registerMarkdownWriter(BLOCK_IMAGE_NODE_TYPE, writeImage);
service.registerMarkdownReader('image', (node) =>
createSerializedImageNode({
altText: node.alt || '',
block: true,
src: node.url || '',
}),
);
}
}
@@ -5,13 +5,12 @@ import {
type PageAgentRuntimeService,
} from '@lobechat/builtin-tool-page-agent/executionRuntime';
import { EditorRuntime } from '@lobechat/editor-runtime';
import type { HeadlessEditor } from '@lobehub/editor/headless';
import { createHeadlessEditor, type HeadlessEditor } from '@lobehub/editor/headless';
import type { SerializedEditorState, SerializedLexicalNode } from 'lexical';
import { DocumentModel } from '@/database/models/document';
import { type LobeChatDatabase } from '@/database/type';
import { isValidEditorData } from '@/libs/editor/isValidEditorData';
import { createAgentDocumentHeadlessEditor } from '@/server/services/agentDocuments/createHeadlessEditor';
import { DocumentService } from '@/server/services/document';
import type { ServerRuntimeRegistration } from './types';
@@ -128,7 +127,7 @@ const loadSnapshot = async (
};
const buildEnv = (snapshot: DocumentSnapshot, documentId: string): InvocationEnv => {
const headless = createAgentDocumentHeadlessEditor();
const headless = createHeadlessEditor();
let title = snapshot.title;
if (isValidEditorData(snapshot.editorData)) {