bug fixes, and performance improvements, and drawer

This commit is contained in:
Zoe
2026-09-27 18:44:51 -05:00
parent d48d8163a7
commit 2b9f8d64d7
52 changed files with 4913 additions and 778 deletions
+82 -71
View File
@@ -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> = {};