@filigran/chatbot 3.18.1 → 3.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"markdown.js","sources":["../../src/utils/index.ts"],"sourcesContent":["import type { ChatArtifact, ChatArtifactKind, Translate } from '../types';\n\nexport function hexAlpha(hex: string, alpha: number): string {\n const a = Math.round(alpha * 255)\n .toString(16)\n .padStart(2, '0');\n return `${hex}${a}`;\n}\n\n/**\n * Markdown has no native support for nesting fenced code blocks of the same\n * length: per the CommonMark spec, the first inner ``` closes the outer block,\n * so everything after it renders *outside* the code block. LLMs constantly hit\n * this — when an agent shows a prompt or a full markdown document inside a\n * ```markdown … ``` fence, that document's own ``` fences shatter the snippet\n * into alternating code / prose fragments.\n *\n * The robust, spec-compliant fix is to make the *outer* fence longer than any\n * fence it contains: a 4-backtick fence is only closed by a run of ≥4\n * backticks, so all inner 3-backtick fences become literal content and the\n * whole document renders as one clean, copyable code block.\n *\n * We act only on the unambiguous, dominant case — a 3-backtick opener whose\n * info string is a markup language (markdown / md / mdx / markup) that actually\n * contains nested fences — so already-correct markdown is never rewritten\n * (a correctly authored nested block already uses a 4+-backtick opener, which\n * we skip).\n */\nexport function hardenNestedCodeFences(raw: string): string {\n if (!raw) return raw;\n const lines = raw.split('\\n');\n const fenceRe = /^(\\s*)(`{3,})(.*)$/;\n const markupLang = /^(markdown|md|mdx|markup)\\b/i;\n\n let openerIdx = -1;\n for (let i = 0; i < lines.length; i++) {\n const m = lines[i].match(fenceRe);\n if (m && m[2].length === 3 && markupLang.test(m[3].trim())) {\n openerIdx = i;\n break;\n }\n }\n if (openerIdx === -1) return raw;\n\n // Find the outer block's *matching* closer rather than the last bare fence in\n // the message. Same-length fences are ambiguous — an inner ``` looks exactly\n // like the outer closer — so inner fences are paired off with two structural\n // signals:\n // • a labelled fence (```lang) opens a nested block, and the next bare\n // fence closes it (depth tracking); and\n // • two consecutive *bare* fences wrapping a non-blank body form an\n // unlabelled nested code block — a ``` … ``` example inside the document.\n // A bare fence at depth 0 is the outer closer when the next fence is\n // labelled, absent, or bare-but-separated-by-only-blank-lines: that last case\n // is a block boundary (this closer followed by a *separate* block), not\n // nesting, so the separate block is never absorbed.\n //\n // Taking the last bare fence instead swallowed everything up to it — trailing\n // prose and unrelated code blocks alike — whenever a later block followed the\n // markdown one.\n const fences: { idx: number; run: number; bare: boolean }[] = [];\n for (let i = openerIdx + 1; i < lines.length; i++) {\n const m = lines[i].match(fenceRe);\n if (m) fences.push({ idx: i, run: m[2].length, bare: m[3].trim() === '' });\n }\n\n // True when every line strictly between two fence lines is blank — which\n // separates a nested code block (real body) from a mere block boundary.\n const blankBetween = (a: number, b: number): boolean => {\n for (let k = a + 1; k < b; k++) {\n if (lines[k].trim() !== '') return false;\n }\n return true;\n };\n\n let depth = 0;\n let maxRun = 3;\n let nestedCount = 0;\n let closerIdx = -1;\n for (let j = 0; j < fences.length; j++) {\n const f = fences[j];\n if (f.bare && depth === 0) {\n const next = fences[j + 1];\n if (!next || !next.bare || blankBetween(f.idx, next.idx)) {\n closerIdx = f.idx;\n break;\n }\n depth++; // opens an unlabelled nested code block\n } else {\n // A labelled fence opens a nested block; a bare fence at depth > 0 closes\n // the innermost one.\n depth = f.bare ? Math.max(0, depth - 1) : depth + 1;\n }\n nestedCount++;\n maxRun = Math.max(maxRun, f.run);\n }\n\n // Only harden when the block really contains nested fences AND its own closer\n // was found — otherwise leave the text alone rather than extending the block\n // over whatever follows.\n if (closerIdx === -1 || nestedCount === 0) return raw;\n\n const fence = '`'.repeat(Math.max(maxRun + 1, 4));\n const om = lines[openerIdx].match(fenceRe)!;\n lines[openerIdx] = `${om[1]}${fence}${om[3]}`;\n const cm = lines[closerIdx].match(fenceRe)!;\n lines[closerIdx] = `${cm[1]}${fence}`;\n return lines.join('\\n');\n}\n\n/**\n * GFM renders a pipe table only when the delimiter row (`|---|---|`) has the\n * SAME number of columns as the header row. LLMs frequently miscount (e.g. a\n * 4-column header followed by a 3-column delimiter), and a server-side guard can\n * corrupt the delimiter — in either case the whole table silently degrades to\n * raw `| … |` text. This repairs a mismatched delimiter row to the header's\n * column count (preserving any alignment colons) so the table renders.\n *\n * It only rewrites a delimiter that is ACTUALLY mismatched, so already-valid\n * tables are never touched. Fenced code blocks are skipped, and setext headings\n * (underlines with no `|`) are never mistaken for a table.\n */\nexport function normalizeMarkdownTables(raw: string): string {\n if (!raw || raw.indexOf('|') === -1) return raw;\n const lines = raw.split('\\n');\n\n // Splits on unescaped `|` only. A manual walk (rather than a negative\n // lookbehind, which is unsupported on older engines and would throw at parse\n // time in an untranspiled ESNext bundle) keeps `\\|` inside a cell intact.\n const splitCells = (row: string): string[] => {\n let s = row.trim();\n if (s.startsWith('|')) s = s.slice(1);\n if (s.endsWith('|')) s = s.slice(0, -1);\n const cells: string[] = [];\n let current = '';\n for (let i = 0; i < s.length; i++) {\n const ch = s[i];\n if (ch === '\\\\' && i + 1 < s.length) {\n current += ch + s[i + 1];\n i++;\n } else if (ch === '|') {\n cells.push(current);\n current = '';\n } else {\n current += ch;\n }\n }\n cells.push(current);\n return cells;\n };\n const isDelimiterRow = (row: string): boolean => row.includes('|') && /^\\s*\\|?\\s*:?-+:?\\s*(\\|\\s*:?-+:?\\s*)*\\|?\\s*$/.test(row);\n // Emit the canonical 3-hyphen delimiter form. remark-gfm accepts a single\n // hyphen, but `---` (with optional alignment colons) is the portable form\n // every Markdown renderer agrees on, so prefer it.\n const alignOf = (cell: string): string => {\n const c = cell.trim();\n const left = c.startsWith(':');\n const right = c.endsWith(':');\n return left && right ? ':---:' : right ? '---:' : left ? ':---' : '---';\n };\n\n // Track both the fence character AND its run length: per CommonMark a closing\n // fence must use the same character and be at least as long as the opener, so\n // a shorter run (```) must not close a longer one (````), and a closing fence\n // carries no info string. Optional blockquote / list-item markers are allowed\n // before the run so fences nested in those containers (e.g. `- ```) are still\n // recognised and the table inside them is left untouched.\n const fenceRe = /^\\s*(?:(?:>\\s?)|(?:[-*+]\\s+)|(?:\\d{1,9}[.)]\\s+))*(`{3,}|~{3,})(.*)$/;\n let fenceChar: string | null = null;\n let fenceLen = 0;\n // A pipe-table header can share its line with a list-item marker (e.g.\n // `- | a | b |`). Strip a leading list marker before counting columns so the\n // count matches the cells GFM sees inside the list item, not the marker.\n const listMarkerRe = /^\\s*(?:[-*+]\\s+|\\d{1,9}[.)]\\s+)/;\n for (let i = 0; i < lines.length - 1; i++) {\n const fenceMatch = lines[i].match(fenceRe);\n if (fenceMatch) {\n const run = fenceMatch[1];\n if (fenceChar === null) {\n fenceChar = run[0];\n fenceLen = run.length;\n } else if (run[0] === fenceChar && run.length >= fenceLen && fenceMatch[2].trim() === '') {\n fenceChar = null;\n fenceLen = 0;\n }\n continue;\n }\n if (fenceChar !== null) continue;\n\n const header = lines[i];\n const delim = lines[i + 1];\n if (!header.includes('|') || isDelimiterRow(header) || !isDelimiterRow(delim)) continue;\n\n // A table can start on a list-item line, so both the cells and the\n // delimiter's alignment live AFTER the marker. Anchor the rewritten\n // delimiter to that content offset (padding the marker width with spaces) so\n // it stays a continuation line of the list item: GFM drops a table whose\n // delimiter dedents away from its header. Without a marker this is just the\n // header's leading whitespace, so top-level tables are emitted unchanged.\n const markerMatch = header.match(listMarkerRe);\n const offset = markerMatch ? markerMatch[0].length : header.length - header.trimStart().length;\n const indent = markerMatch ? ' '.repeat(offset) : header.slice(0, offset);\n\n const headerCols = splitCells(header.slice(offset)).length;\n const delimCells = splitCells(delim);\n if (headerCols < 2 || delimCells.length === headerCols) continue;\n\n const aligns: string[] = [];\n for (let c = 0; c < headerCols; c++) aligns.push(delimCells[c] ? alignOf(delimCells[c]) : '---');\n lines[i + 1] = `${indent}| ${aligns.join(' | ')} |`;\n }\n return lines.join('\\n');\n}\n\n/**\n * When the ENTIRE message is a raw JSON object/array (and not already fenced),\n * wrap it in a ```json fence so it renders as a proper, copyable code block\n * instead of one long line of mangled prose — markdown collapses the newlines\n * and eats the `*`/`_` inside string values otherwise.\n *\n * Deliberately conservative: it parses the payload first, so a message that\n * merely *starts* with `{` (e.g. prose about an object) is left untouched.\n */\nexport function wrapBareJson(raw: string): string {\n const trimmed = raw.trim();\n if ((!trimmed.startsWith('{') && !trimmed.startsWith('[')) || trimmed.startsWith('```')) return raw;\n try {\n JSON.parse(trimmed);\n return '```json\\n' + trimmed + '\\n```';\n } catch {\n return raw;\n }\n}\n\n/**\n * Models routinely emit markdown images whose alt text is the whole generation\n * prompt, spread over several lines. CommonMark has no multi-line `![…](…)`, so\n * the image syntax breaks apart and the prompt renders as literal paragraphs\n * followed by a stray link.\n *\n * This collapses the alt text of such an image onto a single line (whitespace\n * runs → one space) so the parser emits a real `<img>` node. Single-line images\n * are returned byte-identical, and the URL is never rewritten — the URL may\n * contain anything, and only the alt text is at fault.\n *\n * The URL part excludes `)` and whitespace, so a link title\n * (`![alt](url \"title\")`) or a parenthesised URL is left alone rather than\n * mis-parsed.\n */\nexport function normalizeImageMarkdown(raw: string): string {\n if (!raw.includes('![')) return raw;\n return raw.replace(/!\\[([^\\]]*?\\n[^\\]]*?)\\]\\(([^)\\s]+)\\)/g, (_match, alt: string, url: string) => {\n const flat = alt.replace(/\\s+/g, ' ').trim();\n return `![${flat}](${url})`;\n });\n}\n\n/**\n * The text the panel hands to its markdown renderer. Order matters — image\n * alt-text is flattened before anything else looks at line structure, and the\n * JSON wrap must see the raw payload before fences are hardened.\n */\nexport function prepareMarkdown(content: string): string {\n return hardenNestedCodeFences(normalizeMarkdownTables(wrapBareJson(normalizeImageMarkdown(content))));\n}\n\n/** Schemes react-markdown's own sanitizer allows, minus the `data:` special case below. */\nconst SAFE_URL_PROTOCOLS = new Set(['http', 'https', 'mailto', 'tel']);\n\n/**\n * URL sanitizer for react-markdown that additionally preserves `data:image/*`\n * URIs.\n *\n * react-markdown v9+ ships a default `urlTransform` allowing only\n * http / https / mailto / tel. Agents with a code interpreter return charts as\n * `data:image/png;base64,…`, which that default strips — leaving a broken\n * `<img>`. Every other scheme (notably `javascript:` / `vbscript:`) is still\n * blocked, and non-image `data:` URIs are blocked too, so this widens the\n * allow-list by exactly one safe, inert case.\n */\nexport function markdownUrlTransform(url: string): string {\n const colon = url.indexOf(':');\n if (colon < 0) return url; // relative URL — always safe\n\n const slash = url.indexOf('/');\n const question = url.indexOf('?');\n const hash = url.indexOf('#');\n\n // A `/`, `?` or `#` before the colon means this is a path, not a scheme\n // (e.g. `./a:b`), so there is nothing to sanitize.\n if ((slash > -1 && colon > slash) || (question > -1 && colon > question) || (hash > -1 && colon > hash)) {\n return url;\n }\n\n const protocol = url.slice(0, colon).toLowerCase();\n if (SAFE_URL_PROTOCOLS.has(protocol)) return url;\n if (protocol === 'data' && /^data:image\\//i.test(url)) return url;\n return '';\n}\n\nexport const identity: Translate = (key) => key;\n\n/**\n * Translates `key`, then splices `values` into the result's `{name}` slots.\n *\n * The point is that the key stays a whole sentence. Building a phrase out of\n * translated fragments — \"Waiting for\" + count + \"tasks\" — freezes English word\n * order and English plural rules into the layout, and a translator sees each\n * fragment with no sentence around it. Here the locale owns the whole sentence\n * and decides where the number goes; the host's `t` still does nothing but look\n * a key up, so this needs no support from it.\n *\n * Plurals stay two explicit keys (`'1 tool call'` / `'{count} tool calls'`) for\n * the same reason: a lookup cannot select a plural form, so the call site picks\n * the sentence and the locale translates it whole.\n */\nexport function translate(t: Translate, key: string, values: Record<string, string | number>): string {\n // One pass over the sentence rather than one pass per value: a value that\n // happens to contain `{something}` is then never mistaken for a slot of its\n // own. A slot with no value keeps its braces, so a stray one is visible\n // rather than silently blank.\n return t(key).replace(/\\{(\\w+)\\}/g, (slot, name) => (Object.hasOwn(values, name) ? String(values[name] ?? '') : slot));\n}\n\n/**\n * Same as `translate`, but leaves one slot unfilled and returns the text on\n * either side of it — for the case where the value has to be markup (a coloured\n * agent name, a link) and so cannot be spliced into a string.\n *\n * Without this the sentence gets cut into \"How can \" + node + \" help you, \",\n * which is untranslatable in any language that would not put the node in that\n * spot. Here the locale still owns the whole sentence and decides where the slot\n * sits.\n *\n * `hasSlot` is false for a translation that dropped the slot — a locale that\n * reworded the sentence so it no longer names the value. The caller then renders\n * the sentence without the node instead of letting it dangle off the end.\n */\nexport function translateAround(\n t: Translate,\n key: string,\n slot: string,\n values: Record<string, string | number> = {},\n): { before: string; after: string; hasSlot: boolean } {\n const sentence = translate(t, key, values);\n const token = `{${slot}}`;\n const at = sentence.indexOf(token);\n if (at === -1) return { before: sentence, after: '', hasSlot: false };\n return { before: sentence.slice(0, at), after: sentence.slice(at + token.length), hasSlot: true };\n}\n\n/**\n * Nearest chatbot panel root (`.filigran-chatbot`) for portal-based overlays\n * (tooltips, dropdowns, dialogs), so they stay inside the panel's stacking\n * context instead of competing with the host app's z-indexes. Falls back to\n * `document.body` when rendered outside a panel.\n */\nexport function findChatbotRoot(el: HTMLElement | null): HTMLElement {\n let node = el;\n while (node) {\n if (node.classList.contains('filigran-chatbot')) return node;\n node = node.parentElement;\n }\n return document.body;\n}\n\n/**\n * Compact count for the composer's status readouts: 1200 → \"1.2k\", so neither\n * the quota nor the context gauge can widen the toolbar as the numbers grow.\n */\nexport function compactCount(n: number): string {\n if (n < 1000) return `${n}`;\n if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0).replace(/\\.0$/, '')}k`;\n return `${(n / 1_000_000).toFixed(1).replace(/\\.0$/, '')}M`;\n}\n\n/**\n * Compact relative-time label for the conversation history menu\n * (\"just now\", \"5m ago\", \"3h ago\", \"2d ago\", then a short date).\n * Returns an empty string for missing/unparseable timestamps so the row\n * simply omits the label instead of showing \"Invalid Date\".\n */\nexport function timeAgo(iso: string | undefined, t: Translate): string {\n if (!iso) return '';\n const then = new Date(iso).getTime();\n if (Number.isNaN(then)) return '';\n const diffMs = Date.now() - then;\n const minutes = Math.floor(diffMs / 60_000);\n if (minutes < 1) return t('just now');\n if (minutes < 60) return translate(t, '{count}m ago', { count: minutes });\n const hours = Math.floor(minutes / 60);\n if (hours < 24) return translate(t, '{count}h ago', { count: hours });\n const days = Math.floor(hours / 24);\n if (days < 7) return translate(t, '{count}d ago', { count: days });\n return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });\n}\n\n/**\n * A complete marker an agent embeds in prose: a file (`[[FILE:<id>]]`), or a\n * deck, report or video it made (`[[PRESENTATION:<id>]]`, optionally naming a\n * slide as `#/<slide id>`, `[[REPORT:<id>]]`, `[[VIDEO:<id>]]`). The backend\n * matches the names case-insensitively, and so does this.\n */\nconst BUILT_IN_MARKERS = ['FILE', 'PRESENTATION', 'REPORT', 'VIDEO'] as const;\nconst BUILT_IN_MARKER_SOURCE = `(${BUILT_IN_MARKERS.join('|')}):([^\\\\]#]+)(?:#\\\\/([A-Za-z0-9_-]{1,64}))?`;\n\n/** A host marker name: upper-case letters, digits and underscores. */\nconst MARKER_NAME = /^[A-Z][A-Z0-9_]*$/;\n\n/** The host's marker names, upper-cased, valid and deduplicated. */\nexport function markerKinds(names: Iterable<string> | undefined): string[] {\n const out = new Set<string>();\n for (const name of names ?? []) {\n const upper = name.toUpperCase();\n if (MARKER_NAME.test(upper)) out.add(upper);\n }\n return [...out].sort();\n}\n\nconst escapeRe = (text: string) => text.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n\ninterface MarkerGrammar {\n complete: RegExp;\n partial: RegExp;\n heads: string[];\n longestHead: number;\n}\n\nconst grammars = new Map<string, MarkerGrammar>();\n/** Grammars kept, one per set of host kinds. */\nconst MAX_GRAMMARS = 32;\n\n/**\n * The marker grammar for the built-in names plus *hostKinds*. A host name\n * takes everything up to `]]` as its arguments; one that is also built in is\n * read with the built-in grammar and handed over whole.\n *\n * The partial pattern matches an INCOMPLETE marker at the very end of the\n * string: SSE streams can split a marker before the closing `]]` arrives, so\n * the tail may be `[[FILE`, `[[VIDEO:`, `[[REPORT:abc` or `[[FILE:abc]`. It is\n * anchored on a whole marker name, vanishingly unlikely to end real prose, so\n * it never clips real content.\n */\nfunction markerGrammar(hostKinds: readonly string[]): MarkerGrammar {\n const extra = hostKinds.filter((k) => !(BUILT_IN_MARKERS as readonly string[]).includes(k));\n const key = extra.join(',');\n const cached = grammars.get(key);\n if (cached) return cached;\n const names = [...BUILT_IN_MARKERS, ...extra].map(escapeRe).join('|');\n const hostSource = extra.length > 0 ? `|(${extra.map(escapeRe).join('|')}):([^\\\\]]+)` : '';\n const heads = [...BUILT_IN_MARKERS, ...extra].map((name) => `[[${name}:`);\n const grammar = {\n complete: new RegExp(`\\\\[\\\\[(?:${BUILT_IN_MARKER_SOURCE}${hostSource})\\\\]\\\\]`, 'gi'),\n partial: new RegExp(`\\\\[\\\\[(?:${names})(?::[^\\\\]]*)?\\\\]?$`, 'i'),\n heads,\n longestHead: Math.max(...heads.map((head) => head.length)),\n };\n // A host whose kinds keep changing would grow the cache without bound: the\n // oldest grammar goes first.\n if (grammars.size >= MAX_GRAMMARS) grammars.delete(grammars.keys().next().value as string);\n grammars.set(key, grammar);\n return grammar;\n}\n\n/**\n * *text* without a trailing start of a marker name (`[`, `[[`, `[[PRES`). Only\n * for an answer still streaming, where the next chunk may complete it: once\n * the answer is done, the same text is prose.\n */\nfunction withoutMarkerHead(text: string, grammar: MarkerGrammar): string {\n for (let i = Math.max(0, text.length - grammar.longestHead); i < text.length; i++) {\n if (text[i] !== '[') continue;\n const tail = text.slice(i).toUpperCase();\n if (grammar.heads.some((head) => head.startsWith(tail))) return text.slice(0, i);\n }\n return text;\n}\n\n/**\n * Strip the markers an agent embeds in its reply to point at the files, decks,\n * reports and videos it made (and the host's own, *hostKinds*). They render as\n * cards of their own, so the raw markers must be removed from the prose.\n * Complete markers are removed anywhere; an incomplete marker at the end is\n * also removed so a partially streamed token never flickers as raw\n * `[[FILE:...` text.\n *\n * When no marker is present the content is returned **untouched** — we must\n * not trim or collapse blank lines on ordinary prose, which would clobber\n * intentional leading/trailing whitespace (e.g. indented Markdown / code).\n * Whitespace is only normalized when a marker was actually removed.\n *\n * Applied to assistant content only — user-typed text is never touched, so a\n * user who literally types `[[FILE:x]]` still sees their own text.\n */\nexport function stripAnswerMarkers(content: string, hostKinds: readonly string[] = []): string {\n if (!content) return content;\n const grammar = markerGrammar(hostKinds);\n // `replace` with a global pattern starts from the beginning, whatever `lastIndex` holds.\n const stripped = content.replace(grammar.complete, '').replace(grammar.partial, '');\n if (stripped === content) return content;\n return stripped\n .replace(/[ \\t]+\\n/g, '\\n')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim();\n}\n\n/** An ordered piece of assistant content: prose, a file marker, an artifact marker or a host marker. */\nexport type AnswerPart =\n | { type: 'text'; value: string }\n | { type: 'file'; fileId: string }\n | { type: 'artifact'; kind: ChatArtifactKind; id: string; slideRef?: string }\n | { type: 'marker'; kind: string; args: string };\n\n/**\n * Split assistant content into ordered parts around complete markers, so the\n * renderer can place each card at its marker's source position (preserving\n * interleaved order). An artifact id is lowercased, the form the backend\n * describes it in. A marker named in *hostKinds* is a `marker` part with its\n * arguments as written, a built-in one included. A trailing incomplete marker\n * (an SSE token split mid-stream) is removed from the final text part so it\n * never shows as raw `[[FILE:...` text, and so is, while *streaming*, a chunk\n * that stopped inside a marker's name (`[[PRES`).\n */\nexport function splitAnswerMarkers(content: string, streaming = false, hostKinds: readonly string[] = []): AnswerPart[] {\n if (!content) return [];\n const grammar = markerGrammar(hostKinds);\n const parts: AnswerPart[] = [];\n let lastIndex = 0;\n // `matchAll` walks a copy of the cached pattern: no state of its own to reset.\n for (const match of content.matchAll(grammar.complete)) {\n if (match.index > lastIndex) {\n parts.push({ type: 'text', value: content.slice(lastIndex, match.index) });\n }\n if (match[1]) {\n const kind = match[1].toUpperCase();\n if (hostKinds.includes(kind)) {\n parts.push({ type: 'marker', kind, args: match[3] ? `${match[2]}#/${match[3]}` : match[2] });\n } else if (kind === 'FILE') {\n parts.push({ type: 'file', fileId: match[2] });\n } else {\n parts.push({ type: 'artifact', kind: kind.toLowerCase() as ChatArtifactKind, id: match[2].toLowerCase(), slideRef: match[3] || undefined });\n }\n } else {\n parts.push({ type: 'marker', kind: match[4].toUpperCase(), args: match[5] });\n }\n lastIndex = match.index + match[0].length;\n }\n const rest = content.slice(lastIndex).replace(grammar.partial, '');\n const tail = streaming ? withoutMarkerHead(rest, grammar) : rest;\n if (tail) parts.push({ type: 'text', value: tail });\n return parts;\n}\n\n/**\n * The markdown documents an assistant answer is drawn from, in order: each\n * prose part between its markers (`splitAnswerMarkers`), prepared as\n * `MarkdownMessage` prepares it (`prepareMarkdown`). The transcript parses each\n * one as a document of its own, and so must anything that reads the answer\n * (read aloud) to read what the screen shows.\n */\nexport function answerMarkdownSources(content: string, hostKinds: readonly string[] = []): string[] {\n const sources: string[] = [];\n for (const part of splitAnswerMarkers(content, false, hostKinds)) {\n if (part.type === 'text' && part.value.trim()) sources.push(prepareMarkdown(part.value));\n }\n return sources;\n}\n\n/** `kind:id`, how an artifact marker finds the descriptor it renders. */\nexport const artifactKey = (kind: string, id: string): string => `${kind}:${id.toLowerCase()}`;\n\n/** A video whose newest render has not finished: its card follows it. */\nexport const isRenderInProgress = (artifact: ChatArtifact): boolean =>\n artifact.kind === 'video' && (artifact.status === 'pending' || artifact.status === 'running');\n\n/** The kind's name, for a card with no title and for its accessible label. */\nexport function artifactKindLabel(kind: ChatArtifactKind, t: Translate): string {\n if (kind === 'presentation') return t('Presentation');\n if (kind === 'report') return t('Report');\n return t('Video');\n}\n\n/** The facts line under a card's title: its slides, its sections, or its scenes and length. */\nexport function artifactFacts(artifact: ChatArtifact, t: Translate): string {\n const parts: string[] = [];\n if (artifact.kind === 'presentation' && artifact.slideCount !== undefined) {\n parts.push(translate(t, '{count} slides', { count: artifact.slideCount }));\n }\n if (artifact.kind === 'report' && artifact.sectionCount !== undefined) {\n parts.push(translate(t, '{count} sections', { count: artifact.sectionCount }));\n }\n if (artifact.kind === 'video') {\n if (artifact.sceneCount !== undefined) parts.push(translate(t, '{count} scenes', { count: artifact.sceneCount }));\n const duration = formatVideoDuration(artifact.durationMs);\n if (duration) parts.push(duration);\n }\n return parts.join(' · ');\n}\n\n/** What a video's render is doing, when the card has something to say about it. */\nexport function videoRenderLabel(artifact: ChatArtifact, t: Translate): string | undefined {\n if (artifact.kind !== 'video') return undefined;\n if (artifact.status === 'running') {\n return translate(t, 'Rendering {progress}%', { progress: Math.round(artifact.progress ?? 0) });\n }\n if (artifact.status === 'pending') return t('Waiting to render');\n if (artifact.downloads.length > 0) return undefined;\n if (artifact.status === 'failed' || artifact.status === 'cancelled') return t('The render did not finish');\n return t('Not rendered yet');\n}\n\n/** The label of a download button. */\nexport function artifactDownloadLabel(format: string, t: Translate): string {\n if (format === 'pdf') return 'PDF';\n if (format === 'mp4') return 'MP4';\n if (format === 'pptx') return t('PowerPoint');\n if (format === 'pptx_native') return t('PowerPoint (editable)');\n return format.toUpperCase();\n}\n\n/** The XTM One page of a card, linked only when it is http(s). */\nexport function artifactLink(artifact: ChatArtifact): string | undefined {\n if (!artifact.url) return undefined;\n try {\n const url = new URL(artifact.url);\n return url.protocol === 'http:' || url.protocol === 'https:' ? url.toString() : undefined;\n } catch {\n return undefined;\n }\n}\n\n/** The extension a download in *format* is saved with. */\nexport function artifactFileExtension(format: string): string {\n return format === 'pptx_native' ? 'pptx' : format;\n}\n\n/** `m:ss`, the length of a video; `undefined` for nothing to show. */\nexport function formatVideoDuration(ms: number | undefined): string | undefined {\n if (!ms || ms <= 0) return undefined;\n const total = Math.round(ms / 1000);\n return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;\n}\n\n/**\n * The file name a `Content-Disposition` header names: the RFC 5987\n * `filename*=UTF-8''...` form first, else the quoted `filename=\"...\"`.\n */\nexport function filenameFromContentDisposition(header: string | null | undefined): string | undefined {\n if (!header) return undefined;\n const extended = /filename\\*\\s*=\\s*(?:UTF-8|utf-8)''([^;]+)/.exec(header);\n if (extended) {\n try {\n return decodeURIComponent(extended[1].trim().replace(/^\"|\"$/g, '')) || undefined;\n } catch {\n // A malformed escape: fall back to the plain name.\n }\n }\n const plain = /filename\\s*=\\s*(\"([^\"]*)\"|[^;]+)/.exec(header);\n const name = plain ? (plain[2] ?? plain[1]).trim() : '';\n return name || undefined;\n}\n\n/**\n * How an agent's `icon` shows: an image URL as an image, an emoji as text, and\n * anything else (an icon library's name the panel cannot draw) not at all.\n */\nexport function agentIconKind(icon: string | null | undefined): 'image' | 'emoji' | null {\n const value = icon?.trim();\n if (!value) return null;\n if (/^(https:\\/\\/|data:image\\/)/i.test(value)) return 'image';\n if (value.length <= 8 && !/[\\p{L}\\p{N}]/u.test(value)) return 'emoji';\n return null;\n}\n\n/** Agents under their `group` heading, in first-seen order; the ungrouped first, under none. */\nexport function groupAgents<T extends { group?: string }>(agents: readonly T[]): { group: string | null; agents: T[] }[] {\n const groups = new Map<string | null, T[]>();\n for (const agent of agents) {\n const key = agent.group?.trim() || null;\n if (!groups.has(key)) groups.set(key, []);\n groups.get(key)!.push(agent);\n }\n const ungrouped = groups.get(null);\n groups.delete(null);\n return [...(ungrouped ? [{ group: null, agents: ungrouped }] : []), ...[...groups].map(([group, list]) => ({ group, agents: list }))];\n}\n"],"names":["hardenNestedCodeFences","raw","lines","split","fenceRe","markupLang","openerIdx","i","length","m","match","test","trim","fences","push","idx","run","bare","blankBetween","a","b","k","depth","maxRun","nestedCount","closerIdx","j","f","next","Math","max","fence","repeat","om","cm","join","normalizeMarkdownTables","indexOf","splitCells","row","s","startsWith","slice","endsWith","cells","current","ch","isDelimiterRow","includes","alignOf","cell","c","left","right","fenceChar","fenceLen","listMarkerRe","fenceMatch","header","delim","markerMatch","offset","trimStart","indent","headerCols","delimCells","aligns","wrapBareJson","trimmed","JSON","parse","normalizeImageMarkdown","replace","_match","alt","url","prepareMarkdown","content","SAFE_URL_PROTOCOLS","Set","markdownUrlTransform","colon","slash","question","hash","protocol","toLowerCase","has","BUILT_IN_MARKERS","BUILT_IN_MARKER_SOURCE","MARKER_NAME","markerKinds","names","out","name","upper","toUpperCase","add","sort","escapeRe","text","grammars","Map","markerGrammar","hostKinds","extra","filter","key","cached","get","map","hostSource","heads","grammar","complete","RegExp","partial","longestHead","head","size","delete","keys","value","set","stripAnswerMarkers","stripped","splitAnswerMarkers","streaming","parts","lastIndex","matchAll","index","type","kind","args","fileId","id","slideRef","undefined","rest","tail","some","withoutMarkerHead"],"mappings":"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"}
1
+ {"version":3,"file":"markdown.js","sources":["../../src/utils/index.ts"],"sourcesContent":["import type { ChatArtifact, ChatArtifactKind, Translate } from '../types';\n\nexport function hexAlpha(hex: string, alpha: number): string {\n const a = Math.round(alpha * 255)\n .toString(16)\n .padStart(2, '0');\n return `${hex}${a}`;\n}\n\n/**\n * Markdown has no native support for nesting fenced code blocks of the same\n * length: per the CommonMark spec, the first inner ``` closes the outer block,\n * so everything after it renders *outside* the code block. LLMs constantly hit\n * this — when an agent shows a prompt or a full markdown document inside a\n * ```markdown … ``` fence, that document's own ``` fences shatter the snippet\n * into alternating code / prose fragments.\n *\n * The robust, spec-compliant fix is to make the *outer* fence longer than any\n * fence it contains: a 4-backtick fence is only closed by a run of ≥4\n * backticks, so all inner 3-backtick fences become literal content and the\n * whole document renders as one clean, copyable code block.\n *\n * We act only on the unambiguous, dominant case — a 3-backtick opener whose\n * info string is a markup language (markdown / md / mdx / markup) that actually\n * contains nested fences — so already-correct markdown is never rewritten\n * (a correctly authored nested block already uses a 4+-backtick opener, which\n * we skip).\n */\nexport function hardenNestedCodeFences(raw: string): string {\n if (!raw) return raw;\n const lines = raw.split('\\n');\n const fenceRe = /^(\\s*)(`{3,})(.*)$/;\n const markupLang = /^(markdown|md|mdx|markup)\\b/i;\n\n let openerIdx = -1;\n for (let i = 0; i < lines.length; i++) {\n const m = lines[i].match(fenceRe);\n if (m && m[2].length === 3 && markupLang.test(m[3].trim())) {\n openerIdx = i;\n break;\n }\n }\n if (openerIdx === -1) return raw;\n\n // Find the outer block's *matching* closer rather than the last bare fence in\n // the message. Same-length fences are ambiguous — an inner ``` looks exactly\n // like the outer closer — so inner fences are paired off with two structural\n // signals:\n // • a labelled fence (```lang) opens a nested block, and the next bare\n // fence closes it (depth tracking); and\n // • two consecutive *bare* fences wrapping a non-blank body form an\n // unlabelled nested code block — a ``` … ``` example inside the document.\n // A bare fence at depth 0 is the outer closer when the next fence is\n // labelled, absent, or bare-but-separated-by-only-blank-lines: that last case\n // is a block boundary (this closer followed by a *separate* block), not\n // nesting, so the separate block is never absorbed.\n //\n // Taking the last bare fence instead swallowed everything up to it — trailing\n // prose and unrelated code blocks alike — whenever a later block followed the\n // markdown one.\n const fences: { idx: number; run: number; bare: boolean }[] = [];\n for (let i = openerIdx + 1; i < lines.length; i++) {\n const m = lines[i].match(fenceRe);\n if (m) fences.push({ idx: i, run: m[2].length, bare: m[3].trim() === '' });\n }\n\n // True when every line strictly between two fence lines is blank — which\n // separates a nested code block (real body) from a mere block boundary.\n const blankBetween = (a: number, b: number): boolean => {\n for (let k = a + 1; k < b; k++) {\n if (lines[k].trim() !== '') return false;\n }\n return true;\n };\n\n let depth = 0;\n let maxRun = 3;\n let nestedCount = 0;\n let closerIdx = -1;\n for (let j = 0; j < fences.length; j++) {\n const f = fences[j];\n if (f.bare && depth === 0) {\n const next = fences[j + 1];\n if (!next || !next.bare || blankBetween(f.idx, next.idx)) {\n closerIdx = f.idx;\n break;\n }\n depth++; // opens an unlabelled nested code block\n } else {\n // A labelled fence opens a nested block; a bare fence at depth > 0 closes\n // the innermost one.\n depth = f.bare ? Math.max(0, depth - 1) : depth + 1;\n }\n nestedCount++;\n maxRun = Math.max(maxRun, f.run);\n }\n\n // Only harden when the block really contains nested fences AND its own closer\n // was found — otherwise leave the text alone rather than extending the block\n // over whatever follows.\n if (closerIdx === -1 || nestedCount === 0) return raw;\n\n const fence = '`'.repeat(Math.max(maxRun + 1, 4));\n const om = lines[openerIdx].match(fenceRe)!;\n lines[openerIdx] = `${om[1]}${fence}${om[3]}`;\n const cm = lines[closerIdx].match(fenceRe)!;\n lines[closerIdx] = `${cm[1]}${fence}`;\n return lines.join('\\n');\n}\n\n/**\n * GFM renders a pipe table only when the delimiter row (`|---|---|`) has the\n * SAME number of columns as the header row. LLMs frequently miscount (e.g. a\n * 4-column header followed by a 3-column delimiter), and a server-side guard can\n * corrupt the delimiter — in either case the whole table silently degrades to\n * raw `| … |` text. This repairs a mismatched delimiter row to the header's\n * column count (preserving any alignment colons) so the table renders.\n *\n * It only rewrites a delimiter that is ACTUALLY mismatched, so already-valid\n * tables are never touched. Fenced code blocks are skipped, and setext headings\n * (underlines with no `|`) are never mistaken for a table.\n */\nexport function normalizeMarkdownTables(raw: string): string {\n if (!raw || raw.indexOf('|') === -1) return raw;\n const lines = raw.split('\\n');\n\n // Splits on unescaped `|` only. A manual walk (rather than a negative\n // lookbehind, which is unsupported on older engines and would throw at parse\n // time in an untranspiled ESNext bundle) keeps `\\|` inside a cell intact.\n const splitCells = (row: string): string[] => {\n let s = row.trim();\n if (s.startsWith('|')) s = s.slice(1);\n if (s.endsWith('|')) s = s.slice(0, -1);\n const cells: string[] = [];\n let current = '';\n for (let i = 0; i < s.length; i++) {\n const ch = s[i];\n if (ch === '\\\\' && i + 1 < s.length) {\n current += ch + s[i + 1];\n i++;\n } else if (ch === '|') {\n cells.push(current);\n current = '';\n } else {\n current += ch;\n }\n }\n cells.push(current);\n return cells;\n };\n const isDelimiterRow = (row: string): boolean => row.includes('|') && /^\\s*\\|?\\s*:?-+:?\\s*(\\|\\s*:?-+:?\\s*)*\\|?\\s*$/.test(row);\n // Emit the canonical 3-hyphen delimiter form. remark-gfm accepts a single\n // hyphen, but `---` (with optional alignment colons) is the portable form\n // every Markdown renderer agrees on, so prefer it.\n const alignOf = (cell: string): string => {\n const c = cell.trim();\n const left = c.startsWith(':');\n const right = c.endsWith(':');\n return left && right ? ':---:' : right ? '---:' : left ? ':---' : '---';\n };\n\n // Track both the fence character AND its run length: per CommonMark a closing\n // fence must use the same character and be at least as long as the opener, so\n // a shorter run (```) must not close a longer one (````), and a closing fence\n // carries no info string. Optional blockquote / list-item markers are allowed\n // before the run so fences nested in those containers (e.g. `- ```) are still\n // recognised and the table inside them is left untouched.\n const fenceRe = /^\\s*(?:(?:>\\s?)|(?:[-*+]\\s+)|(?:\\d{1,9}[.)]\\s+))*(`{3,}|~{3,})(.*)$/;\n let fenceChar: string | null = null;\n let fenceLen = 0;\n // A pipe-table header can share its line with a list-item marker (e.g.\n // `- | a | b |`). Strip a leading list marker before counting columns so the\n // count matches the cells GFM sees inside the list item, not the marker.\n const listMarkerRe = /^\\s*(?:[-*+]\\s+|\\d{1,9}[.)]\\s+)/;\n for (let i = 0; i < lines.length - 1; i++) {\n const fenceMatch = lines[i].match(fenceRe);\n if (fenceMatch) {\n const run = fenceMatch[1];\n if (fenceChar === null) {\n fenceChar = run[0];\n fenceLen = run.length;\n } else if (run[0] === fenceChar && run.length >= fenceLen && fenceMatch[2].trim() === '') {\n fenceChar = null;\n fenceLen = 0;\n }\n continue;\n }\n if (fenceChar !== null) continue;\n\n const header = lines[i];\n const delim = lines[i + 1];\n if (!header.includes('|') || isDelimiterRow(header) || !isDelimiterRow(delim)) continue;\n\n // A table can start on a list-item line, so both the cells and the\n // delimiter's alignment live AFTER the marker. Anchor the rewritten\n // delimiter to that content offset (padding the marker width with spaces) so\n // it stays a continuation line of the list item: GFM drops a table whose\n // delimiter dedents away from its header. Without a marker this is just the\n // header's leading whitespace, so top-level tables are emitted unchanged.\n const markerMatch = header.match(listMarkerRe);\n const offset = markerMatch ? markerMatch[0].length : header.length - header.trimStart().length;\n const indent = markerMatch ? ' '.repeat(offset) : header.slice(0, offset);\n\n const headerCols = splitCells(header.slice(offset)).length;\n const delimCells = splitCells(delim);\n if (headerCols < 2 || delimCells.length === headerCols) continue;\n\n const aligns: string[] = [];\n for (let c = 0; c < headerCols; c++) aligns.push(delimCells[c] ? alignOf(delimCells[c]) : '---');\n lines[i + 1] = `${indent}| ${aligns.join(' | ')} |`;\n }\n return lines.join('\\n');\n}\n\n/**\n * When the ENTIRE message is a raw JSON object/array (and not already fenced),\n * wrap it in a ```json fence so it renders as a proper, copyable code block\n * instead of one long line of mangled prose — markdown collapses the newlines\n * and eats the `*`/`_` inside string values otherwise.\n *\n * Deliberately conservative: it parses the payload first, so a message that\n * merely *starts* with `{` (e.g. prose about an object) is left untouched.\n */\nexport function wrapBareJson(raw: string): string {\n const trimmed = raw.trim();\n if ((!trimmed.startsWith('{') && !trimmed.startsWith('[')) || trimmed.startsWith('```')) return raw;\n try {\n JSON.parse(trimmed);\n return '```json\\n' + trimmed + '\\n```';\n } catch {\n return raw;\n }\n}\n\n/**\n * Models routinely emit markdown images whose alt text is the whole generation\n * prompt, spread over several lines. CommonMark has no multi-line `![…](…)`, so\n * the image syntax breaks apart and the prompt renders as literal paragraphs\n * followed by a stray link.\n *\n * This collapses the alt text of such an image onto a single line (whitespace\n * runs → one space) so the parser emits a real `<img>` node. Single-line images\n * are returned byte-identical, and the URL is never rewritten — the URL may\n * contain anything, and only the alt text is at fault.\n *\n * The URL part excludes `)` and whitespace, so a link title\n * (`![alt](url \"title\")`) or a parenthesised URL is left alone rather than\n * mis-parsed.\n */\nexport function normalizeImageMarkdown(raw: string): string {\n if (!raw.includes('![')) return raw;\n return raw.replace(/!\\[([^\\]]*?\\n[^\\]]*?)\\]\\(([^)\\s]+)\\)/g, (_match, alt: string, url: string) => {\n const flat = alt.replace(/\\s+/g, ' ').trim();\n return `![${flat}](${url})`;\n });\n}\n\n/**\n * The text the panel hands to its markdown renderer. Order matters — image\n * alt-text is flattened before anything else looks at line structure, and the\n * JSON wrap must see the raw payload before fences are hardened.\n */\nexport function prepareMarkdown(content: string): string {\n return hardenNestedCodeFences(normalizeMarkdownTables(wrapBareJson(normalizeImageMarkdown(content))));\n}\n\n/** Schemes react-markdown's own sanitizer allows, minus the `data:` special case below. */\nconst SAFE_URL_PROTOCOLS = new Set(['http', 'https', 'mailto', 'tel']);\n\n/**\n * URL sanitizer for react-markdown that additionally preserves `data:image/*`\n * URIs.\n *\n * react-markdown v9+ ships a default `urlTransform` allowing only\n * http / https / mailto / tel. Agents with a code interpreter return charts as\n * `data:image/png;base64,…`, which that default strips — leaving a broken\n * `<img>`. Every other scheme (notably `javascript:` / `vbscript:`) is still\n * blocked, and non-image `data:` URIs are blocked too, so this widens the\n * allow-list by exactly one safe, inert case.\n */\nexport function markdownUrlTransform(url: string): string {\n const colon = url.indexOf(':');\n if (colon < 0) return url; // relative URL — always safe\n\n const slash = url.indexOf('/');\n const question = url.indexOf('?');\n const hash = url.indexOf('#');\n\n // A `/`, `?` or `#` before the colon means this is a path, not a scheme\n // (e.g. `./a:b`), so there is nothing to sanitize.\n if ((slash > -1 && colon > slash) || (question > -1 && colon > question) || (hash > -1 && colon > hash)) {\n return url;\n }\n\n const protocol = url.slice(0, colon).toLowerCase();\n if (SAFE_URL_PROTOCOLS.has(protocol)) return url;\n if (protocol === 'data' && /^data:image\\//i.test(url)) return url;\n return '';\n}\n\nexport const identity: Translate = (key) => key;\n\n/**\n * Translates `key`, then splices `values` into the result's `{name}` slots.\n *\n * The point is that the key stays a whole sentence. Building a phrase out of\n * translated fragments — \"Waiting for\" + count + \"tasks\" — freezes English word\n * order and English plural rules into the layout, and a translator sees each\n * fragment with no sentence around it. Here the locale owns the whole sentence\n * and decides where the number goes; the host's `t` still does nothing but look\n * a key up, so this needs no support from it.\n *\n * Plurals stay two explicit keys (`'1 tool call'` / `'{count} tool calls'`) for\n * the same reason: a lookup cannot select a plural form, so the call site picks\n * the sentence and the locale translates it whole.\n */\nexport function translate(t: Translate, key: string, values: Record<string, string | number>): string {\n // One pass over the sentence rather than one pass per value: a value that\n // happens to contain `{something}` is then never mistaken for a slot of its\n // own. A slot with no value keeps its braces, so a stray one is visible\n // rather than silently blank.\n return t(key).replace(/\\{(\\w+)\\}/g, (slot, name) => (Object.hasOwn(values, name) ? String(values[name] ?? '') : slot));\n}\n\n/**\n * Same as `translate`, but leaves one slot unfilled and returns the text on\n * either side of it — for the case where the value has to be markup (a coloured\n * agent name, a link) and so cannot be spliced into a string.\n *\n * Without this the sentence gets cut into \"How can \" + node + \" help you, \",\n * which is untranslatable in any language that would not put the node in that\n * spot. Here the locale still owns the whole sentence and decides where the slot\n * sits.\n *\n * `hasSlot` is false for a translation that dropped the slot — a locale that\n * reworded the sentence so it no longer names the value. The caller then renders\n * the sentence without the node instead of letting it dangle off the end.\n */\nexport function translateAround(\n t: Translate,\n key: string,\n slot: string,\n values: Record<string, string | number> = {},\n): { before: string; after: string; hasSlot: boolean } {\n const sentence = translate(t, key, values);\n const token = `{${slot}}`;\n const at = sentence.indexOf(token);\n if (at === -1) return { before: sentence, after: '', hasSlot: false };\n return { before: sentence.slice(0, at), after: sentence.slice(at + token.length), hasSlot: true };\n}\n\n/**\n * Nearest chatbot panel root (`.filigran-chatbot`) for portal-based overlays\n * (tooltips, dropdowns, dialogs), so they stay inside the panel's stacking\n * context instead of competing with the host app's z-indexes. Falls back to\n * `document.body` when rendered outside a panel.\n */\nexport function findChatbotRoot(el: HTMLElement | null): HTMLElement {\n let node = el;\n while (node) {\n if (node.classList.contains('filigran-chatbot')) return node;\n node = node.parentElement;\n }\n return document.body;\n}\n\n/** *bytes* as a file chip reads them: `512 B`, `200 KB`, `1.5 MB`; empty for none. */\nexport function formatFileSize(bytes?: number): string {\n if (!bytes || bytes <= 0) return '';\n if (bytes < 1024) return `${bytes} B`;\n if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;\n return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n}\n\n/**\n * *bytes* as a limit reads: floored to one decimal, never rounded up past\n * what it allows (1536 B is `1.5 KB`, 2 GiB is `2 GB`).\n */\nexport function formatSizeLimit(bytes: number): string {\n const units: [string, number][] = [\n ['GB', 1024 ** 3],\n ['MB', 1024 ** 2],\n ['KB', 1024],\n ];\n for (const [unit, size] of units) {\n if (bytes >= size) return `${Math.floor((bytes / size) * 10) / 10} ${unit}`;\n }\n return `${Math.floor(bytes)} B`;\n}\n\n/**\n * Compact count for the composer's status readouts: 1200 → \"1.2k\", so neither\n * the quota nor the context gauge can widen the toolbar as the numbers grow.\n */\nexport function compactCount(n: number): string {\n if (n < 1000) return `${n}`;\n if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0).replace(/\\.0$/, '')}k`;\n return `${(n / 1_000_000).toFixed(1).replace(/\\.0$/, '')}M`;\n}\n\n/**\n * Compact relative-time label for the conversation history menu\n * (\"just now\", \"5m ago\", \"3h ago\", \"2d ago\", then a short date).\n * Returns an empty string for missing/unparseable timestamps so the row\n * simply omits the label instead of showing \"Invalid Date\".\n */\nexport function timeAgo(iso: string | undefined, t: Translate): string {\n if (!iso) return '';\n const then = new Date(iso).getTime();\n if (Number.isNaN(then)) return '';\n const diffMs = Date.now() - then;\n const minutes = Math.floor(diffMs / 60_000);\n if (minutes < 1) return t('just now');\n if (minutes < 60) return translate(t, '{count}m ago', { count: minutes });\n const hours = Math.floor(minutes / 60);\n if (hours < 24) return translate(t, '{count}h ago', { count: hours });\n const days = Math.floor(hours / 24);\n if (days < 7) return translate(t, '{count}d ago', { count: days });\n return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });\n}\n\n/**\n * A complete marker an agent embeds in prose: a file (`[[FILE:<id>]]`), or a\n * deck, report or video it made (`[[PRESENTATION:<id>]]`, optionally naming a\n * slide as `#/<slide id>`, `[[REPORT:<id>]]`, `[[VIDEO:<id>]]`). The backend\n * matches the names case-insensitively, and so does this.\n */\nconst BUILT_IN_MARKERS = ['FILE', 'PRESENTATION', 'REPORT', 'VIDEO'] as const;\nconst BUILT_IN_MARKER_SOURCE = `(${BUILT_IN_MARKERS.join('|')}):([^\\\\]#]+)(?:#\\\\/([A-Za-z0-9_-]{1,64}))?`;\n\n/** A host marker name: upper-case letters, digits and underscores. */\nconst MARKER_NAME = /^[A-Z][A-Z0-9_]*$/;\n\n/** The host's marker names, upper-cased, valid and deduplicated. */\nexport function markerKinds(names: Iterable<string> | undefined): string[] {\n const out = new Set<string>();\n for (const name of names ?? []) {\n const upper = name.toUpperCase();\n if (MARKER_NAME.test(upper)) out.add(upper);\n }\n return [...out].sort();\n}\n\nconst escapeRe = (text: string) => text.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n\ninterface MarkerGrammar {\n complete: RegExp;\n partial: RegExp;\n heads: string[];\n longestHead: number;\n}\n\nconst grammars = new Map<string, MarkerGrammar>();\n/** Grammars kept, one per set of host kinds. */\nconst MAX_GRAMMARS = 32;\n\n/**\n * The marker grammar for the built-in names plus *hostKinds*. A host name\n * takes everything up to `]]` as its arguments; one that is also built in is\n * read with the built-in grammar and handed over whole.\n *\n * The partial pattern matches an INCOMPLETE marker at the very end of the\n * string: SSE streams can split a marker before the closing `]]` arrives, so\n * the tail may be `[[FILE`, `[[VIDEO:`, `[[REPORT:abc` or `[[FILE:abc]`. It is\n * anchored on a whole marker name, vanishingly unlikely to end real prose, so\n * it never clips real content.\n */\nfunction markerGrammar(hostKinds: readonly string[]): MarkerGrammar {\n const extra = hostKinds.filter((k) => !(BUILT_IN_MARKERS as readonly string[]).includes(k));\n const key = extra.join(',');\n const cached = grammars.get(key);\n if (cached) return cached;\n const names = [...BUILT_IN_MARKERS, ...extra].map(escapeRe).join('|');\n const hostSource = extra.length > 0 ? `|(${extra.map(escapeRe).join('|')}):([^\\\\]]+)` : '';\n const heads = [...BUILT_IN_MARKERS, ...extra].map((name) => `[[${name}:`);\n const grammar = {\n complete: new RegExp(`\\\\[\\\\[(?:${BUILT_IN_MARKER_SOURCE}${hostSource})\\\\]\\\\]`, 'gi'),\n partial: new RegExp(`\\\\[\\\\[(?:${names})(?::[^\\\\]]*)?\\\\]?$`, 'i'),\n heads,\n longestHead: Math.max(...heads.map((head) => head.length)),\n };\n // A host whose kinds keep changing would grow the cache without bound: the\n // oldest grammar goes first.\n if (grammars.size >= MAX_GRAMMARS) grammars.delete(grammars.keys().next().value as string);\n grammars.set(key, grammar);\n return grammar;\n}\n\n/**\n * *text* without a trailing start of a marker name (`[`, `[[`, `[[PRES`). Only\n * for an answer still streaming, where the next chunk may complete it: once\n * the answer is done, the same text is prose.\n */\nfunction withoutMarkerHead(text: string, grammar: MarkerGrammar): string {\n for (let i = Math.max(0, text.length - grammar.longestHead); i < text.length; i++) {\n if (text[i] !== '[') continue;\n const tail = text.slice(i).toUpperCase();\n if (grammar.heads.some((head) => head.startsWith(tail))) return text.slice(0, i);\n }\n return text;\n}\n\n/**\n * Strip the markers an agent embeds in its reply to point at the files, decks,\n * reports and videos it made (and the host's own, *hostKinds*). They render as\n * cards of their own, so the raw markers must be removed from the prose.\n * Complete markers are removed anywhere; an incomplete marker at the end is\n * also removed so a partially streamed token never flickers as raw\n * `[[FILE:...` text.\n *\n * When no marker is present the content is returned **untouched** — we must\n * not trim or collapse blank lines on ordinary prose, which would clobber\n * intentional leading/trailing whitespace (e.g. indented Markdown / code).\n * Whitespace is only normalized when a marker was actually removed.\n *\n * Applied to assistant content only — user-typed text is never touched, so a\n * user who literally types `[[FILE:x]]` still sees their own text.\n */\nexport function stripAnswerMarkers(content: string, hostKinds: readonly string[] = []): string {\n if (!content) return content;\n const grammar = markerGrammar(hostKinds);\n // `replace` with a global pattern starts from the beginning, whatever `lastIndex` holds.\n const stripped = content.replace(grammar.complete, '').replace(grammar.partial, '');\n if (stripped === content) return content;\n return stripped\n .replace(/[ \\t]+\\n/g, '\\n')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim();\n}\n\n/** An ordered piece of assistant content: prose, a file marker, an artifact marker or a host marker. */\nexport type AnswerPart =\n | { type: 'text'; value: string }\n | { type: 'file'; fileId: string }\n | { type: 'artifact'; kind: ChatArtifactKind; id: string; slideRef?: string }\n | { type: 'marker'; kind: string; args: string };\n\n/**\n * Split assistant content into ordered parts around complete markers, so the\n * renderer can place each card at its marker's source position (preserving\n * interleaved order). An artifact id is lowercased, the form the backend\n * describes it in. A marker named in *hostKinds* is a `marker` part with its\n * arguments as written, a built-in one included. A trailing incomplete marker\n * (an SSE token split mid-stream) is removed from the final text part so it\n * never shows as raw `[[FILE:...` text, and so is, while *streaming*, a chunk\n * that stopped inside a marker's name (`[[PRES`).\n */\nexport function splitAnswerMarkers(content: string, streaming = false, hostKinds: readonly string[] = []): AnswerPart[] {\n if (!content) return [];\n const grammar = markerGrammar(hostKinds);\n const parts: AnswerPart[] = [];\n let lastIndex = 0;\n // `matchAll` walks a copy of the cached pattern: no state of its own to reset.\n for (const match of content.matchAll(grammar.complete)) {\n if (match.index > lastIndex) {\n parts.push({ type: 'text', value: content.slice(lastIndex, match.index) });\n }\n if (match[1]) {\n const kind = match[1].toUpperCase();\n if (hostKinds.includes(kind)) {\n parts.push({ type: 'marker', kind, args: match[3] ? `${match[2]}#/${match[3]}` : match[2] });\n } else if (kind === 'FILE') {\n parts.push({ type: 'file', fileId: match[2] });\n } else {\n parts.push({ type: 'artifact', kind: kind.toLowerCase() as ChatArtifactKind, id: match[2].toLowerCase(), slideRef: match[3] || undefined });\n }\n } else {\n parts.push({ type: 'marker', kind: match[4].toUpperCase(), args: match[5] });\n }\n lastIndex = match.index + match[0].length;\n }\n const rest = content.slice(lastIndex).replace(grammar.partial, '');\n const tail = streaming ? withoutMarkerHead(rest, grammar) : rest;\n if (tail) parts.push({ type: 'text', value: tail });\n return parts;\n}\n\n/**\n * The markdown documents an assistant answer is drawn from, in order: each\n * prose part between its markers (`splitAnswerMarkers`), prepared as\n * `MarkdownMessage` prepares it (`prepareMarkdown`). The transcript parses each\n * one as a document of its own, and so must anything that reads the answer\n * (read aloud) to read what the screen shows.\n */\nexport function answerMarkdownSources(content: string, hostKinds: readonly string[] = []): string[] {\n const sources: string[] = [];\n for (const part of splitAnswerMarkers(content, false, hostKinds)) {\n if (part.type === 'text' && part.value.trim()) sources.push(prepareMarkdown(part.value));\n }\n return sources;\n}\n\n/** `kind:id`, how an artifact marker finds the descriptor it renders. */\nexport const artifactKey = (kind: string, id: string): string => `${kind}:${id.toLowerCase()}`;\n\n/** A video whose newest render has not finished: its card follows it. */\nexport const isRenderInProgress = (artifact: ChatArtifact): boolean =>\n artifact.kind === 'video' && (artifact.status === 'pending' || artifact.status === 'running');\n\n/** The kind's name, for a card with no title and for its accessible label. */\nexport function artifactKindLabel(kind: ChatArtifactKind, t: Translate): string {\n if (kind === 'presentation') return t('Presentation');\n if (kind === 'report') return t('Report');\n return t('Video');\n}\n\n/** The facts line under a card's title: its slides, its sections, or its scenes and length. */\nexport function artifactFacts(artifact: ChatArtifact, t: Translate): string {\n const parts: string[] = [];\n if (artifact.kind === 'presentation' && artifact.slideCount !== undefined) {\n parts.push(translate(t, '{count} slides', { count: artifact.slideCount }));\n }\n if (artifact.kind === 'report' && artifact.sectionCount !== undefined) {\n parts.push(translate(t, '{count} sections', { count: artifact.sectionCount }));\n }\n if (artifact.kind === 'video') {\n if (artifact.sceneCount !== undefined) parts.push(translate(t, '{count} scenes', { count: artifact.sceneCount }));\n const duration = formatVideoDuration(artifact.durationMs);\n if (duration) parts.push(duration);\n }\n return parts.join(' · ');\n}\n\n/** What a video's render is doing, when the card has something to say about it. */\nexport function videoRenderLabel(artifact: ChatArtifact, t: Translate): string | undefined {\n if (artifact.kind !== 'video') return undefined;\n if (artifact.status === 'running') {\n return translate(t, 'Rendering {progress}%', { progress: Math.round(artifact.progress ?? 0) });\n }\n if (artifact.status === 'pending') return t('Waiting to render');\n if (artifact.downloads.length > 0) return undefined;\n if (artifact.status === 'failed' || artifact.status === 'cancelled') return t('The render did not finish');\n return t('Not rendered yet');\n}\n\n/** The label of a download button. */\nexport function artifactDownloadLabel(format: string, t: Translate): string {\n if (format === 'pdf') return 'PDF';\n if (format === 'mp4') return 'MP4';\n if (format === 'pptx') return t('PowerPoint');\n if (format === 'pptx_native') return t('PowerPoint (editable)');\n return format.toUpperCase();\n}\n\n/** The XTM One page of a card, linked only when it is http(s). */\nexport function artifactLink(artifact: ChatArtifact): string | undefined {\n if (!artifact.url) return undefined;\n try {\n const url = new URL(artifact.url);\n return url.protocol === 'http:' || url.protocol === 'https:' ? url.toString() : undefined;\n } catch {\n return undefined;\n }\n}\n\n/** The extension a download in *format* is saved with. */\nexport function artifactFileExtension(format: string): string {\n return format === 'pptx_native' ? 'pptx' : format;\n}\n\n/** `m:ss`, the length of a video; `undefined` for nothing to show. */\nexport function formatVideoDuration(ms: number | undefined): string | undefined {\n if (!ms || ms <= 0) return undefined;\n const total = Math.round(ms / 1000);\n return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;\n}\n\n/**\n * The file name a `Content-Disposition` header names: the RFC 5987\n * `filename*=UTF-8''...` form first, else the quoted `filename=\"...\"`.\n */\nexport function filenameFromContentDisposition(header: string | null | undefined): string | undefined {\n if (!header) return undefined;\n const extended = /filename\\*\\s*=\\s*(?:UTF-8|utf-8)''([^;]+)/.exec(header);\n if (extended) {\n try {\n return decodeURIComponent(extended[1].trim().replace(/^\"|\"$/g, '')) || undefined;\n } catch {\n // A malformed escape: fall back to the plain name.\n }\n }\n const plain = /filename\\s*=\\s*(\"([^\"]*)\"|[^;]+)/.exec(header);\n const name = plain ? (plain[2] ?? plain[1]).trim() : '';\n return name || undefined;\n}\n\n/**\n * How an agent's `icon` shows: an image URL as an image, an emoji as text, and\n * anything else (an icon library's name the panel cannot draw) not at all.\n */\nexport function agentIconKind(icon: string | null | undefined): 'image' | 'emoji' | null {\n const value = icon?.trim();\n if (!value) return null;\n if (/^(https:\\/\\/|data:image\\/)/i.test(value)) return 'image';\n if (value.length <= 8 && !/[\\p{L}\\p{N}]/u.test(value)) return 'emoji';\n return null;\n}\n\n/** Agents under their `group` heading, in first-seen order; the ungrouped first, under none. */\nexport function groupAgents<T extends { group?: string }>(agents: readonly T[]): { group: string | null; agents: T[] }[] {\n const groups = new Map<string | null, T[]>();\n for (const agent of agents) {\n const key = agent.group?.trim() || null;\n if (!groups.has(key)) groups.set(key, []);\n groups.get(key)!.push(agent);\n }\n const ungrouped = groups.get(null);\n groups.delete(null);\n return [...(ungrouped ? [{ group: null, agents: ungrouped }] : []), ...[...groups].map(([group, list]) => ({ group, agents: list }))];\n}\n"],"names":["hardenNestedCodeFences","raw","lines","split","fenceRe","markupLang","openerIdx","i","length","m","match","test","trim","fences","push","idx","run","bare","blankBetween","a","b","k","depth","maxRun","nestedCount","closerIdx","j","f","next","Math","max","fence","repeat","om","cm","join","normalizeMarkdownTables","indexOf","splitCells","row","s","startsWith","slice","endsWith","cells","current","ch","isDelimiterRow","includes","alignOf","cell","c","left","right","fenceChar","fenceLen","listMarkerRe","fenceMatch","header","delim","markerMatch","offset","trimStart","indent","headerCols","delimCells","aligns","wrapBareJson","trimmed","JSON","parse","normalizeImageMarkdown","replace","_match","alt","url","prepareMarkdown","content","SAFE_URL_PROTOCOLS","Set","markdownUrlTransform","colon","slash","question","hash","protocol","toLowerCase","has","BUILT_IN_MARKERS","BUILT_IN_MARKER_SOURCE","MARKER_NAME","markerKinds","names","out","name","upper","toUpperCase","add","sort","escapeRe","text","grammars","Map","markerGrammar","hostKinds","extra","filter","key","cached","get","map","hostSource","heads","grammar","complete","RegExp","partial","longestHead","head","size","delete","keys","value","set","stripAnswerMarkers","stripped","splitAnswerMarkers","streaming","parts","lastIndex","matchAll","index","type","kind","args","fileId","id","slideRef","undefined","rest","tail","some","withoutMarkerHead"],"mappings":"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"}