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
+45
View File
@@ -0,0 +1,45 @@
import type { ComarkPlugin } from "comark";
import type { SearchSource, StoredToolValue } from "./tool-rendering";
import { parseToolValue, readSearchSources } from "./tool-rendering";
export const citationPlugin: ComarkPlugin = {
name: "source-citations",
markdownItPlugins: [
(markdown) => {
markdown.inline.ruler.before("link", "source_citation", (state, silent) => {
if (state.linkLevel > 0 || !state.src.startsWith("[[cite:", state.pos)) {
return false;
}
const match = /^\[\[cite:([a-zA-Z0-9_-]+(?:,[a-zA-Z0-9_-]+)*)\]\]/.exec(state.src.slice(state.pos));
if (!match) {
return false;
}
if (silent) {
return false;
}
const token = state.push("mdc_inline_component", "source-cite", 0);
token.attrSet("ids", match[1] ?? "");
state.pos += match[0].length;
return true;
});
},
],
};
export const collectCitationSources = (
parts: readonly { toolCall?: { toolName: string; output?: StoredToolValue } | null }[],
): ReadonlyMap<string, SearchSource> => {
const sources = new Map<string, SearchSource>();
for (const part of parts) {
if (part.toolCall?.toolName !== "search") {
continue;
}
const results = readSearchSources(parseToolValue(part.toolCall.output), { deduplicate: false });
for (const source of results ?? []) {
if (source.sourceId) {
sources.set(source.sourceId, source);
}
}
}
return sources;
};
+11
View File
@@ -0,0 +1,11 @@
export const getComposerHeight = (
scrollHeight: number,
lineHeight: number,
padding: number,
borders: number,
borderBox: boolean,
): number => {
const contentHeight = Math.min(Math.max(scrollHeight - padding, lineHeight * 2), lineHeight * 10);
// A stable CSS-pixel boundary avoids 47.999… / 48.01 oscillations.
return Math.round(contentHeight + (borderBox ? padding + borders : 0));
};
+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> = {};
+4 -2
View File
@@ -21,6 +21,7 @@ export const formatToolValue = (stored: StoredToolValue): string => {
};
export interface SearchSource {
sourceId?: string;
title: string;
url: string;
hostname: string;
@@ -41,7 +42,7 @@ export const readSearchWarnings = (value: unknown): { engine: string; reason: st
});
};
export const readSearchSources = (value: unknown): SearchSource[] | null => {
export const readSearchSources = (value: unknown, options: { deduplicate?: boolean } = {}): SearchSource[] | null => {
// Older tool calls stored the results array directly.
if (typeof value === "object" && value !== null && "results" in value) {
value = value.results;
@@ -65,11 +66,12 @@ export const readSearchSources = (value: unknown): SearchSource[] | null => {
continue;
}
url.hash = "";
if (seen.has(url.href)) {
if (options.deduplicate !== false && seen.has(url.href)) {
continue;
}
seen.add(url.href);
sources.push({
...("sourceId" in item && typeof item.sourceId === "string" ? { sourceId: item.sourceId } : {}),
title: "title" in item && typeof item.title === "string" ? item.title : url.hostname,
url: url.href,
hostname: url.hostname,