bug fixes, and performance improvements, and drawer
This commit is contained in:
+82
-71
@@ -3,6 +3,7 @@ import { createSerializedMarkdownParser as createComarkParser } from 'comark';
|
||||
import breaks from 'comark/plugins/breaks';
|
||||
import footnotes from 'comark/plugins/footnotes';
|
||||
import math from 'comark/plugins/math';
|
||||
import { citationPlugin } from './citations';
|
||||
|
||||
export type MarkdownTree = MarkdownDocument;
|
||||
export type MarkdownAstNode = Node;
|
||||
@@ -39,10 +40,10 @@ const parserPlugins = [
|
||||
math(),
|
||||
] as const;
|
||||
|
||||
const createConfiguredParser = (autoClose: boolean) => createComarkParser({
|
||||
const createConfiguredParser = (autoClose: boolean, citations: boolean) => createComarkParser({
|
||||
registerDefaultPlugins: false,
|
||||
autoClose,
|
||||
plugins: parserPlugins,
|
||||
plugins: citations ? [...parserPlugins, citationPlugin] : parserPlugins,
|
||||
});
|
||||
|
||||
const protectDollarRunsInLine = (line: string): string => {
|
||||
@@ -89,6 +90,10 @@ const protectDollarRunsInLine = (line: string): string => {
|
||||
};
|
||||
|
||||
const protectDollarRuns = (content: string): string => {
|
||||
// Fast path: most messages contain no dollar runs; avoid line-splitting entirely.
|
||||
if (!content.includes('$')) {
|
||||
return content;
|
||||
}
|
||||
const lines = content.split('\n');
|
||||
let inFence = false;
|
||||
|
||||
@@ -107,13 +112,16 @@ const protectDollarRuns = (content: string): string => {
|
||||
}).join('\n');
|
||||
};
|
||||
|
||||
export const createMarkdownParser = () => {
|
||||
const streamingParser = createConfiguredParser(true);
|
||||
const finalParser = createConfiguredParser(false);
|
||||
export const createMarkdownParser = (options: { citations?: boolean } = {}) => {
|
||||
const citations = options.citations === true;
|
||||
const streamingParser = createConfiguredParser(true, citations);
|
||||
const finalParser = createConfiguredParser(false, citations);
|
||||
|
||||
return (content: string, options: { streaming?: boolean } = {}) => {
|
||||
const protectedContent = protectDollarRuns(content);
|
||||
if (options.streaming === true) {
|
||||
// Do not let auto-close complete an unfinished citation on the model's behalf.
|
||||
const incompleteCitation = citations && /\[\[cite:[a-zA-Z0-9_,\-]*\]?$/.test(protectedContent);
|
||||
if (options.streaming === true && !incompleteCitation) {
|
||||
return streamingParser(protectedContent, { streaming: true });
|
||||
}
|
||||
|
||||
@@ -121,6 +129,9 @@ export const createMarkdownParser = () => {
|
||||
};
|
||||
};
|
||||
|
||||
export const agentParser = createMarkdownParser({ citations: true });
|
||||
export const userParser = createMarkdownParser({ citations: false });
|
||||
|
||||
export const getNodeTag = (node: MarkdownAstNode): string | null => {
|
||||
if (!Array.isArray(node) || node[0] === null) {
|
||||
return null;
|
||||
@@ -157,29 +168,7 @@ export const textContent = (node: MarkdownAstNode): string => {
|
||||
return getNodeChildren(node).map(textContent).join('');
|
||||
};
|
||||
|
||||
const cloneNode = (node: MarkdownAstNode): MarkdownAstNode => {
|
||||
if (typeof node === 'string') {
|
||||
return node;
|
||||
}
|
||||
|
||||
if (!Array.isArray(node)) {
|
||||
return node;
|
||||
}
|
||||
|
||||
if (node[0] === null) {
|
||||
return [null, { ...node[1] }, node[2]];
|
||||
}
|
||||
|
||||
return [
|
||||
node[0],
|
||||
{ ...node[1] },
|
||||
...getNodeChildren(node).map(cloneNode),
|
||||
] as MarkdownElementNode;
|
||||
};
|
||||
|
||||
const splitParagraph = (node: MarkdownElementNode): MarkdownElementNode[] => {
|
||||
const attributes = { ...node[1] };
|
||||
const children = getNodeChildren(node);
|
||||
const splitParagraph = (attributes: Record<string, unknown>, children: MarkdownAstNode[]): MarkdownElementNode[] => {
|
||||
const paragraphs: MarkdownElementNode[] = [];
|
||||
let current: MarkdownAstNode[] = [];
|
||||
|
||||
@@ -200,58 +189,80 @@ const splitParagraph = (node: MarkdownElementNode): MarkdownElementNode[] => {
|
||||
return paragraphs;
|
||||
};
|
||||
|
||||
const transformChildren = (nodes: MarkdownAstNode[]): MarkdownAstNode[] => {
|
||||
const result: MarkdownAstNode[] = [];
|
||||
const hasBrChild = (children: MarkdownAstNode[]): boolean =>
|
||||
children.some((child) => getNodeTag(child) === 'br');
|
||||
|
||||
for (const originalNode of nodes) {
|
||||
const node = cloneNode(originalNode);
|
||||
if (typeof node === 'string' || !Array.isArray(node) || node[0] === null) {
|
||||
result.push(node);
|
||||
continue;
|
||||
/**
|
||||
* Transform a single element node into its replacement list. Returns a
|
||||
* single-element array holding the ORIGINAL node reference when nothing
|
||||
* changed, so callers can skip all allocation for unaffected subtrees.
|
||||
*/
|
||||
const transformElement = (node: MarkdownElementNode): MarkdownAstNode[] => {
|
||||
const tag = node[0];
|
||||
const children = getNodeChildren(node);
|
||||
const transformedChildren = transformChildren(children);
|
||||
|
||||
if (tag === 'p') {
|
||||
if (!hasBrChild(transformedChildren)) {
|
||||
if (transformedChildren === children) return [node];
|
||||
return [[tag, { ...node[1] }, ...transformedChildren] as MarkdownElementNode];
|
||||
}
|
||||
|
||||
const tag = node[0];
|
||||
const transformedChildren = transformChildren(getNodeChildren(node));
|
||||
let transformedNode = [tag, { ...node[1] }, ...transformedChildren] as MarkdownElementNode;
|
||||
|
||||
if (tag === 'p') {
|
||||
result.push(...splitParagraph(transformedNode));
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((tag === 'li' || tag === 'blockquote') && transformedChildren.some((child) => getNodeTag(child) === 'br')) {
|
||||
const segments: MarkdownAstNode[][] = [[]];
|
||||
for (const child of transformedChildren) {
|
||||
if (getNodeTag(child) === 'br') {
|
||||
segments.push([]);
|
||||
} else {
|
||||
segments[segments.length - 1]?.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
transformedNode = [
|
||||
tag,
|
||||
{ ...node[1] },
|
||||
...segments
|
||||
.filter((segment) => segment.length > 0)
|
||||
.map((segment) => ['p', {}, ...segment] as MarkdownElementNode),
|
||||
] as MarkdownElementNode;
|
||||
}
|
||||
|
||||
result.push(transformedNode);
|
||||
return splitParagraph({ ...node[1] }, transformedChildren);
|
||||
}
|
||||
|
||||
return result;
|
||||
if ((tag === 'li' || tag === 'blockquote') && hasBrChild(transformedChildren)) {
|
||||
const segments: MarkdownAstNode[][] = [[]];
|
||||
for (const child of transformedChildren) {
|
||||
if (getNodeTag(child) === 'br') {
|
||||
segments.push([]);
|
||||
} else {
|
||||
segments[segments.length - 1]?.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
return [[
|
||||
tag,
|
||||
{ ...node[1] },
|
||||
...segments
|
||||
.filter((segment) => segment.length > 0)
|
||||
.map((segment) => ['p', {}, ...segment] as MarkdownElementNode),
|
||||
] as MarkdownElementNode];
|
||||
}
|
||||
|
||||
if (transformedChildren === children) return [node];
|
||||
return [[tag, { ...node[1] }, ...transformedChildren] as MarkdownElementNode];
|
||||
};
|
||||
|
||||
const transformChildren = (nodes: MarkdownAstNode[]): MarkdownAstNode[] => {
|
||||
let result: MarkdownAstNode[] | null = null;
|
||||
|
||||
for (let index = 0; index < nodes.length; index++) {
|
||||
const node = nodes[index]!;
|
||||
const replacement = typeof node === 'string' || !Array.isArray(node) || node[0] === null
|
||||
? [node]
|
||||
: transformElement(node);
|
||||
|
||||
if (result === null) {
|
||||
if (replacement.length === 1 && replacement[0] === node) continue;
|
||||
result = nodes.slice(0, index);
|
||||
}
|
||||
result.push(...replacement);
|
||||
}
|
||||
|
||||
return result ?? nodes;
|
||||
};
|
||||
|
||||
/**
|
||||
* Comark's breaks plugin emits `br` nodes. Promote each hard break to a
|
||||
* sibling paragraph to keep the chat renderer's established layout.
|
||||
*
|
||||
* Copy-on-write: trees without `br` nodes are returned by reference with no
|
||||
* allocation, so the common case costs a single tag scan per node.
|
||||
*/
|
||||
export const splitParagraphBreaks = (tree: MarkdownTree): MarkdownTree => ({
|
||||
...tree,
|
||||
nodes: transformChildren(tree.nodes),
|
||||
});
|
||||
export const splitParagraphBreaks = (tree: MarkdownTree): MarkdownTree => {
|
||||
const nodes = transformChildren(tree.nodes);
|
||||
return nodes === tree.nodes ? tree : { ...tree, nodes };
|
||||
};
|
||||
|
||||
export const toVueAttributes = (attributes: Record<string, unknown>): Record<string, unknown> => {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
Reference in New Issue
Block a user