SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
4.9 KB · 106 lines typescript
Raw Blame History
1/**2 * Helpers for fast, stable streaming Markdown.3 *4 * `splitStreamingMarkdown` cuts a growing Markdown string into completed top-level blocks (rendered5 * once and memoized) and a live tail (re-rendered on every token). Cuts happen only on blank lines6 * outside code fences, and never between blocks that Markdown would join across a blank line7 * (loose lists, block quotes, indented continuations, tables), so the split output renders8 * identically to a single parse.9 *10 * `normalizeMath` rewrites the `\( … \)` / `\[ … \]` delimiters most models emit into the11 * `$ … $` / `$$ … $$` forms `remark-math` understands, leaving code spans and fences untouched.12 */13export interface MarkdownSplit {14  /** Completed blocks — each is a self-contained Markdown fragment. */15  blocks: string[];16  /** Still-growing tail (may be empty). */17  tail: string;18}1920const FENCE_RE = /^(\s{0,3})(`{3,}|~{3,})/;21const CONTINUATION_RE = /^(\s{2,}|\t|>|[-*+]\s|\d{1,3}[.)]\s|\|)/;22const LIST_OR_QUOTE_RE = /^(\s*)([-*+]\s|\d{1,3}[.)]\s|>)/;2324/** Whether `text` ends inside an open ``` / ~~~ fence. */25export function endsInsideFence(text: string): boolean {26  let open: string | null = null;27  for (const line of text.split("\n")) {28    const m = FENCE_RE.exec(line);29    if (!m) continue;30    const marker = m[2];31    if (!open) open = marker;32    else if (marker[0] === open[0] && marker.length >= open.length) open = null;33  }34  return open !== null;35}3637export function splitStreamingMarkdown(text: string, opts: { minTailChars?: number } = {}): MarkdownSplit {38  if (!text) return { blocks: [], tail: "" };39  const minTail = opts.minTailChars ?? 0;40  const lines = text.split("\n");41  const blocks: string[] = [];42  let current: string[] = [];43  let fence: string | null = null;4445  const flushIfSafe = (nextLine: string | undefined) => {46    // Only cut when the block is closed AND the next block does not continue this one.47    if (!current.length) return;48    if (fence) return;49    if (nextLine === undefined) return;50    const prevLast = [...current].reverse().find((l) => l.trim() !== "") ?? "";51    if (CONTINUATION_RE.test(nextLine)) return; // indented / list / quote / table continuation52    if (LIST_OR_QUOTE_RE.test(prevLast) && LIST_OR_QUOTE_RE.test(nextLine)) return;53    blocks.push(current.join("\n").replace(/\n+$/, ""));54    current = [];55  };5657  for (let i = 0; i < lines.length; i++) {58    const line = lines[i];59    const m = FENCE_RE.exec(line);60    if (m) {61      const marker = m[2];62      if (!fence) fence = marker;63      else if (marker[0] === fence[0] && marker.length >= fence.length) fence = null;64    }65    current.push(line);66    const isLast = i === lines.length - 1;67    if (isLast) break;68    // A blank line closes a block candidate (outside fences).69    if (line.trim() === "" && !fence) {70      // Skip following blank lines; peek at the first non-blank line.71      let j = i + 1;72      while (j < lines.length && lines[j].trim() === "") j++;73      flushIfSafe(lines[j]);74    }75  }76  let tail = current.join("\n");77  // Keep a minimum amount of live text so the caret doesn't jump between blocks too eagerly.78  while (tail.length < minTail && blocks.length) tail = `${blocks.pop()}\n\n${tail}`;79  return { blocks, tail };80}8182/** Rewrite `\( x \)` → `$x$` and `\[ x \]` → `$$x$$` outside code (inline spans and fences). */83export function normalizeMath(text: string): string {84  if (!text || !/\\[([]/.test(text)) return text;85  const out: string[] = [];86  const segments = text.split(/(```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]*`)/g);87  for (const seg of segments) {88    if (!seg) continue;89    if (seg.startsWith("```") || seg.startsWith("~~~") || (seg.startsWith("`") && seg.endsWith("`") && seg.length > 1)) {90      out.push(seg);91      continue;92    }93    out.push(seg.replace(/\\\[\s*([\s\S]*?)\s*\\\]/g, (_m, inner: string) => `$$\n${inner}\n$$`).replace(/\\\((.+?)\\\)/g, (_m, inner: string) => `$${inner.trim()}$`));94  }95  return out.join("");96}9798/** File extension for the code-block Download action. */99export function extensionForLanguage(lang: string | undefined): string {100  const l = (lang ?? "").toLowerCase();101  const map: Record<string, string> = {102    javascript: "js", js: "js", jsx: "jsx", typescript: "ts", ts: "ts", tsx: "tsx", python: "py", py: "py", bash: "sh", sh: "sh", shell: "sh", zsh: "sh", json: "json", yaml: "yml", yml: "yml", markdown: "md", md: "md", html: "html", css: "css", scss: "scss", sql: "sql", go: "go", rust: "rs", rs: "rs", java: "java", kotlin: "kt", kt: "kt", swift: "swift", ruby: "rb", rb: "rb", php: "php", c: "c", cpp: "cpp", "c++": "cpp", h: "h", csharp: "cs", cs: "cs", toml: "toml", xml: "xml", dockerfile: "Dockerfile", diff: "diff", text: "txt", plaintext: "txt", txt: "txt", r: "r", scala: "scala", lua: "lua", perl: "pl", dart: "dart", latex: "tex", tex: "tex", graphql: "graphql", ini: "ini", makefile: "mk", nginx: "conf", powershell: "ps1", ps1: "ps1",103  };104  return map[l] ?? "txt";105}106