@mlx-bun/module-chat 0.0.0-stage → 1.0.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.
- package/LICENSE +21 -0
- package/README.md +85 -2
- package/package.json +39 -4
- package/src/app-tools.ts +101 -0
- package/src/backend.ts +108 -0
- package/src/describe.ts +47 -0
- package/src/events.ts +75 -0
- package/src/extensions.ts +44 -0
- package/src/history.ts +188 -0
- package/src/index.ts +74 -0
- package/src/manifest.ts +26 -0
- package/src/model-bridge.ts +75 -0
- package/src/panel/adapters-panel.ts +287 -0
- package/src/panel/assistant.ts +469 -0
- package/src/panel/chat.ts +1076 -0
- package/src/panel/composer.ts +947 -0
- package/src/panel/dom.ts +94 -0
- package/src/panel/host.ts +28 -0
- package/src/panel/index.ts +100 -0
- package/src/panel/markdown.ts +547 -0
- package/src/panel/popovers.ts +17 -0
- package/src/panel/rag.ts +274 -0
- package/src/panel/sessions.ts +470 -0
- package/src/panel/style.ts +532 -0
- package/src/panel/template.ts +189 -0
- package/src/panel/ui-catalog.ts +77 -0
- package/src/panel/voice.ts +141 -0
- package/src/pi-backend.ts +989 -0
- package/src/policy.ts +417 -0
- package/src/protocol.ts +265 -0
- package/src/provider.ts +72 -0
- package/src/routes.ts +49 -0
- package/src/session-files.ts +36 -0
- package/src/session-search.ts +175 -0
- package/src/surface.ts +26 -0
- package/src/tool-approvals.ts +108 -0
- package/src/web-tools.ts +478 -0
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
// Part of the chat panel (`@mlx-bun/module-chat`), bundled into the app's browser build.
|
|
2
|
+
//
|
|
3
|
+
// The hand-rolled markdown renderer + streaming block-memoization +
|
|
4
|
+
// syntax-highlighting glue. Behavior-identical port of the original inline
|
|
5
|
+
// <script> in app.html. This module is the streaming-parity oracle for
|
|
6
|
+
// packages/module-chat/tests/panel.test.ts: renderBlocksIncremental fed char-by-char must
|
|
7
|
+
// converge to the exact same innerHTML as one-shot mdToHtml.
|
|
8
|
+
|
|
9
|
+
/** Escape the four HTML-significant characters. Used on every interpolation
|
|
10
|
+
* site across the app — the single discipline that keeps user/model text
|
|
11
|
+
* from ever being interpreted as markup. */
|
|
12
|
+
export const esc = (s: unknown): string =>
|
|
13
|
+
String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ } as Record<string, string>)[c]!);
|
|
14
|
+
|
|
15
|
+
/** Allow only safe link targets (http/https/mailto/anchor/relative). */
|
|
16
|
+
export function mdSafeUrl(u: unknown): string | null {
|
|
17
|
+
const s = String(u).trim();
|
|
18
|
+
return /^(https?:\/\/|mailto:|#|\/|\.{1,2}\/)/i.test(s) ? s : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Inline markdown -> HTML. Escapes first, then shields code spans from
|
|
22
|
+
* further markup so backticked text is never mangled or unsafe. */
|
|
23
|
+
export function mdInline(t: unknown): string {
|
|
24
|
+
const codes: string[] = [];
|
|
25
|
+
// Pull code spans out first (any run of backticks), then escape the rest.
|
|
26
|
+
let src = String(t).replace(/(`+)([\s\S]+?)\1/g, (_m, _ticks, c: string) => {
|
|
27
|
+
codes.push(c.replace(/^ | $/g, ""));
|
|
28
|
+
return "\0C" + (codes.length - 1) + "\0";
|
|
29
|
+
});
|
|
30
|
+
let s = esc(src);
|
|
31
|
+
// [text](url "title") — title optional and ignored.
|
|
32
|
+
s = s.replace(/\[([^\]]+)\]\(\s*([^)\s]+)(?:\s+"[^)]*")?\s*\)/g, (m, text: string, url: string) => {
|
|
33
|
+
const safe = mdSafeUrl(url);
|
|
34
|
+
return safe ? '<a href="' + safe + '" target="_blank" rel="noopener noreferrer">' + text + "</a>" : m;
|
|
35
|
+
});
|
|
36
|
+
s = s
|
|
37
|
+
.replace(/\*\*\*([^*]+)\*\*\*/g, "<strong><em>$1</em></strong>")
|
|
38
|
+
.replace(/\*\*([^*]+?)\*\*/g, "<strong>$1</strong>")
|
|
39
|
+
.replace(/(^|[^_\w])__([^_]+?)__(?![\w])/g, "$1<strong>$2</strong>")
|
|
40
|
+
.replace(/(^|[^*\w])\*([^*\s][^*]*?)\*/g, "$1<em>$2</em>")
|
|
41
|
+
.replace(/(^|[^_\w])_([^_\s][^_]*?)_(?![\w])/g, "$1<em>$2</em>")
|
|
42
|
+
.replace(/~~([^~]+?)~~/g, "<del>$1</del>");
|
|
43
|
+
// Autolink bare URLs that markdown didn't already wrap.
|
|
44
|
+
s = s.replace(/(^|[\s(])(https?:\/\/[^\s<)]+[^\s<).,;:!?'"])/g,
|
|
45
|
+
(_m, pre: string, url: string) => pre + '<a href="' + url + '" target="_blank" rel="noopener noreferrer">' + url + "</a>");
|
|
46
|
+
return s.replace(/\0C(\d+)\0/g, (_m, i: string) => "<code>" + esc(codes[+i]) + "</code>");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Pure: does this fence's language tag qualify for the Canvas v1
|
|
50
|
+
* Preview|Source toggle? Only html/svg — js/ts/etc. fences never get a
|
|
51
|
+
* toggle (running arbitrary script output isn't the feature; rendering a
|
|
52
|
+
* markup/vector document is). Exported so packages/module-chat/tests/panel.test.ts can check
|
|
53
|
+
* the detection logic directly without a DOM. */
|
|
54
|
+
export function isCanvasFence(lang: string): boolean {
|
|
55
|
+
return /^(html?|svg)$/i.test(lang.trim());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function mdCodeBlock(lang: string, code: string): string {
|
|
59
|
+
// language-<lang> on <code> is the de-facto convention hljs's own
|
|
60
|
+
// language-detection-from-class reads (see highlightIn() below); an empty
|
|
61
|
+
// fence info string just omits the class and highlightIn() skips it.
|
|
62
|
+
const langClass = lang ? ' class="language-' + esc(lang) + '"' : "";
|
|
63
|
+
const canvas = isCanvasFence(lang);
|
|
64
|
+
// Canvas v1 (`02d723a:docs/design/web-chat-redesign.md` Appendix A,
|
|
65
|
+
// beat-matrix Axis 2): a Preview|Source toggle
|
|
66
|
+
// next to Copy for completed html/svg fences. The toggle markup is always
|
|
67
|
+
// emitted for a qualifying fence (even while the block is still streaming
|
|
68
|
+
// — mdCodeBlock only ever runs on a CLOSED fence per mdToHtml/splitBlocks,
|
|
69
|
+
// so "qualifying" already implies "complete"); the iframe itself is never
|
|
70
|
+
// created here — see wireCanvasToggle's lazy srcdoc creation below, so an
|
|
71
|
+
// unopened Preview never executes model-generated script.
|
|
72
|
+
const toggleHtml = canvas
|
|
73
|
+
? '<span class="cbtoggle" role="group" aria-label="View">' +
|
|
74
|
+
'<button class="cbview cbview-source active" type="button" aria-pressed="true">Source</button>' +
|
|
75
|
+
'<button class="cbview cbview-preview" type="button" aria-pressed="false">Preview</button>' +
|
|
76
|
+
"</span>"
|
|
77
|
+
: "";
|
|
78
|
+
// The raw fence text (pre-escape) is stashed verbatim in a hidden <div>
|
|
79
|
+
// sibling so wireCanvasToggle can read it back via .textContent (the
|
|
80
|
+
// browser does entity-decoding for .textContent on ordinary elements, so
|
|
81
|
+
// no manual unescape needed) without re-parsing the highlighted/escaped
|
|
82
|
+
// <code> body. NOT a <script> tag: script/style are HTML "raw text"
|
|
83
|
+
// elements whose content the parser does NOT entity-decode (textContent
|
|
84
|
+
// would come back as the literal "<h1>" instead of "<h1>") — a plain
|
|
85
|
+
// element sidesteps that without needing any special-case unescaping, and
|
|
86
|
+
// `hidden` (not display:none, matching convention elsewhere in this file)
|
|
87
|
+
// keeps it out of layout and never executed either way.
|
|
88
|
+
const srcStash = canvas ? '<div class="cbsrc" hidden>' + esc(code) + "</div>" : "";
|
|
89
|
+
return '<div class="codeblock' + (canvas ? " has-canvas" : "") + '"><div class="cbhead"><span class="cblang">' + esc(lang || "") +
|
|
90
|
+
"</span>" + toggleHtml + '<button class="cbcopy" type="button">Copy</button></div>' +
|
|
91
|
+
"<pre><code" + langClass + ">" + esc(code) + "</code></pre>" + srcStash +
|
|
92
|
+
(canvas ? '<div class="cbcanvas" hidden></div>' : "") + "</div>";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* ────────────────────────────────────────────────────────────────────
|
|
96
|
+
CANVAS V1 (`02d723a:docs/design/web-chat-redesign.md` Appendix A,
|
|
97
|
+
beat-matrix Axis 2 — "I miss optiq's Canvas"
|
|
98
|
+
is a disallowed outcome): completed html/svg fenced code blocks get a
|
|
99
|
+
Preview|Source toggle in the existing .cbhead bar. Preview renders the
|
|
100
|
+
fence content in a sandboxed <iframe>.
|
|
101
|
+
|
|
102
|
+
SECURITY: the iframe sandbox is `allow-scripts` ONLY — never
|
|
103
|
+
`allow-same-origin` together with `allow-scripts`. Combining them would
|
|
104
|
+
let a model-generated <script> read this iframe's effective origin,
|
|
105
|
+
which the browser resolves to the PARENT document's origin for a
|
|
106
|
+
srcdoc/about:blank frame with allow-same-origin — i.e. the sandboxed
|
|
107
|
+
content could reach window.parent's DOM, localStorage, and the live
|
|
108
|
+
WebSocket. allow-scripts alone keeps the frame in a unique opaque
|
|
109
|
+
origin: scripts run, but same-origin-gated APIs (parent access,
|
|
110
|
+
storage, cookies) are denied by the browser regardless of what the
|
|
111
|
+
content tries. This is the one non-negotiable line in this feature.
|
|
112
|
+
|
|
113
|
+
Lazy: the iframe element is only created on the first "Preview" click
|
|
114
|
+
(never auto-run) — a model that emits an html fence should not get a
|
|
115
|
+
free script execution just because a message rendered.
|
|
116
|
+
──────────────────────────────────────────────────────────────────── */
|
|
117
|
+
|
|
118
|
+
/** Wire the Preview/Source toggle + Copy button for every codeblock inside
|
|
119
|
+
* `container` via ONE delegated click listener — safe to call once on a
|
|
120
|
+
* stable ancestor (chat's `thread()` element, the memory panel's
|
|
121
|
+
* `mem-body`) whose innerHTML gets replaced/extended repeatedly; delegation
|
|
122
|
+
* means newly rendered codeblocks are wired for free, no per-render
|
|
123
|
+
* re-attachment needed. Copy-button wiring already lives inline in
|
|
124
|
+
* chat.ts's own delegated listener — this function only owns the Canvas
|
|
125
|
+
* toggle so it can be shared between chat.ts and memory-panel.ts without
|
|
126
|
+
* duplicating the Copy logic in two places. */
|
|
127
|
+
export function wireCanvasToggle(container: HTMLElement): void {
|
|
128
|
+
container.addEventListener("click", (e) => {
|
|
129
|
+
const btn = (e.target as HTMLElement).closest?.(".cbview") as HTMLElement | null;
|
|
130
|
+
if (!btn || !container.contains(btn)) return;
|
|
131
|
+
const block = btn.closest(".codeblock");
|
|
132
|
+
if (!block) return;
|
|
133
|
+
const showPreview = btn.classList.contains("cbview-preview");
|
|
134
|
+
setCanvasView(block as HTMLElement, showPreview ? "preview" : "source");
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Capture which Canvas-eligible codeblocks (in document order) are
|
|
139
|
+
* currently showing Preview, keyed by their positional index within
|
|
140
|
+
* `container` — NOT by fence content, since two identical fences would
|
|
141
|
+
* collide on a content key and streamed text can still be mutating right
|
|
142
|
+
* up to the caller's re-render. Positional index is stable across a
|
|
143
|
+
* finishStreaming-style "replace innerHTML with a fresh render of the
|
|
144
|
+
* same final text" pass because mdToHtml walks the same fences in the
|
|
145
|
+
* same order both times. Returns an empty array cheaply when nothing is
|
|
146
|
+
* in Preview (the common case) — callers should skip the restore pass
|
|
147
|
+
* entirely when the array is empty. */
|
|
148
|
+
export function captureCanvasViewStates(container: HTMLElement): number[] {
|
|
149
|
+
const previewIdx: number[] = [];
|
|
150
|
+
const blocks = container.querySelectorAll(".codeblock.has-canvas");
|
|
151
|
+
blocks.forEach((block, i) => {
|
|
152
|
+
if (block.querySelector(".cbview-preview")?.classList.contains("active")) previewIdx.push(i);
|
|
153
|
+
});
|
|
154
|
+
return previewIdx;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Inverse of captureCanvasViewStates: re-apply Preview to the codeblocks
|
|
158
|
+
* at the given positional indices after a full re-render replaced the
|
|
159
|
+
* container's innerHTML (which always resets every qualifying fence back
|
|
160
|
+
* to its default Source view — see mdCodeBlock). A no-op for any index
|
|
161
|
+
* past the end of the freshly rendered set (final text had fewer
|
|
162
|
+
* qualifying fences than the mid-stream snapshot did — nothing sensible
|
|
163
|
+
* to restore, so it's silently skipped rather than throwing). */
|
|
164
|
+
export function restoreCanvasViewStates(container: HTMLElement, previewIdx: readonly number[]): void {
|
|
165
|
+
if (!previewIdx.length) return;
|
|
166
|
+
const blocks = container.querySelectorAll(".codeblock.has-canvas");
|
|
167
|
+
for (const i of previewIdx) {
|
|
168
|
+
const block = blocks[i] as HTMLElement | undefined;
|
|
169
|
+
if (block) setCanvasView(block, "preview");
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function setCanvasView(block: HTMLElement, view: "preview" | "source"): void {
|
|
174
|
+
const sourceBtn = block.querySelector(".cbview-source");
|
|
175
|
+
const previewBtn = block.querySelector(".cbview-preview");
|
|
176
|
+
const pre = block.querySelector("pre");
|
|
177
|
+
const canvasEl = block.querySelector(".cbcanvas") as HTMLElement | null;
|
|
178
|
+
if (!sourceBtn || !previewBtn || !pre || !canvasEl) return;
|
|
179
|
+
sourceBtn.classList.toggle("active", view === "source");
|
|
180
|
+
sourceBtn.setAttribute("aria-pressed", String(view === "source"));
|
|
181
|
+
previewBtn.classList.toggle("active", view === "preview");
|
|
182
|
+
previewBtn.setAttribute("aria-pressed", String(view === "preview"));
|
|
183
|
+
if (view === "source") {
|
|
184
|
+
canvasEl.hidden = true;
|
|
185
|
+
(pre as HTMLElement).hidden = false;
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
(pre as HTMLElement).hidden = true;
|
|
189
|
+
canvasEl.hidden = false;
|
|
190
|
+
// Lazy iframe creation: only build it the first time Preview is opened,
|
|
191
|
+
// never on render. srcdoc (not src=data:) keeps everything same-tab, no
|
|
192
|
+
// network hop, and no history entry.
|
|
193
|
+
if (!canvasEl.querySelector("iframe")) {
|
|
194
|
+
const srcEl = block.querySelector(".cbsrc");
|
|
195
|
+
const code = srcEl ? srcEl.textContent || "" : "";
|
|
196
|
+
const frame = document.createElement("iframe");
|
|
197
|
+
frame.className = "cbframe";
|
|
198
|
+
// allow-scripts ONLY — see the file-level comment above this section.
|
|
199
|
+
frame.setAttribute("sandbox", "allow-scripts");
|
|
200
|
+
frame.setAttribute("loading", "lazy");
|
|
201
|
+
frame.title = "Canvas preview";
|
|
202
|
+
// A neutral light background regardless of app theme: model-authored
|
|
203
|
+
// HTML/SVG rarely sets its own background, and an unstyled fragment on
|
|
204
|
+
// a dark app background reads as broken; a plain white canvas is the
|
|
205
|
+
// one assumption that's safe for both arbitrary markup and dark/light
|
|
206
|
+
// app themes alike (this iframe is content the model wrote, not app
|
|
207
|
+
// chrome, so it doesn't need to follow the app's theme toggle).
|
|
208
|
+
frame.srcdoc = code;
|
|
209
|
+
canvasEl.appendChild(frame);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/* ────────────────────────────────────────────────────────────────────
|
|
214
|
+
CHAT-WITH-FILES RAG v1 — [n] citation markers (`02d723a:docs/design/web-chat-redesign.md`
|
|
215
|
+
Appendix A, beat-matrix Axis 5): when a turn's prompt carried retrieved chunks
|
|
216
|
+
(composer.ts's buildMessageText, retrieval-mode branch), the model is
|
|
217
|
+
asked to cite them inline as `[n]`. This turns each SURVIVING `[n]`
|
|
218
|
+
marker in the assistant's rendered HTML into a clickable superscript
|
|
219
|
+
link that expands/scrolls the Sources panel (chat.ts) — but ONLY when a
|
|
220
|
+
citation map for that exact n was actually attached to this message
|
|
221
|
+
(never linkify `[n]` in an ordinary reply, e.g. "see note [1] below" in
|
|
222
|
+
a message with no attachments at all — that would be a false citation).
|
|
223
|
+
──────────────────────────────────────────────────────────────────── */
|
|
224
|
+
|
|
225
|
+
/** Pure: rewrite bracketed `[n]` markers in already-rendered HTML text into
|
|
226
|
+
* clickable citation links, but only for n's present in `validNs` — every
|
|
227
|
+
* other `[...]` run (including `[n]` for an n outside the cited set) is
|
|
228
|
+
* left untouched. Operates on HTML (post mdToHtml/mdInline), not raw
|
|
229
|
+
* markdown source, so it must NOT rewrite inside tag markup — an `<a
|
|
230
|
+
* href="...[1]...">` (a URL that happens to contain a literal "[1]" in its
|
|
231
|
+
* path, or any other attribute value) would otherwise get a `<button>`
|
|
232
|
+
* spliced into the middle of the attribute string, corrupting the tag.
|
|
233
|
+
* Splits the string on tag boundaries first and only replaces `[n]` runs
|
|
234
|
+
* in the text-node segments between tags. */
|
|
235
|
+
export function linkifyCitations(html: string, validNs: ReadonlySet<number>): string {
|
|
236
|
+
if (!validNs.size) return html;
|
|
237
|
+
const replaceMarkers = (text: string): string =>
|
|
238
|
+
text.replace(/\[(\d{1,3})\]/g, (m, d: string) => {
|
|
239
|
+
const n = Number(d);
|
|
240
|
+
if (!validNs.has(n)) return m;
|
|
241
|
+
return '<button type="button" class="cite-mark" data-cite="' + n + '" aria-label="Source ' + n + '">' + n + "</button>";
|
|
242
|
+
});
|
|
243
|
+
// Split into alternating [text, tag, text, tag, ...] segments — capturing
|
|
244
|
+
// group in split() keeps the tag delimiters in the output array — and
|
|
245
|
+
// only rewrite the (even-indexed) text segments.
|
|
246
|
+
return html.split(/(<[^>]*>)/g).map((seg, i) => (i % 2 === 0 ? replaceMarkers(seg) : seg)).join("");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function mdTableSep(s: string): boolean { return /^[\s|:-]+$/.test(s) && /-/.test(s) && /\|/.test(s); }
|
|
250
|
+
function mdSplitRow(s: string): string[] { return s.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim()); }
|
|
251
|
+
function mdBlockStart(s: string): boolean {
|
|
252
|
+
return /^\s*(`{3,}|~{3,})/.test(s) || /^(#{1,6})\s/.test(s) || /^\s*>/.test(s) ||
|
|
253
|
+
/^\s*[-*+]\s+/.test(s) || /^\s*\d+[.)]\s+/.test(s) || /^\s*([-*_])\s*(\1\s*){2,}$/.test(s);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Safe markdown renderer: code fences (w/ copy), headings, lists, task
|
|
257
|
+
* lists, tables, blockquotes, rules, links, emphasis, inline code. */
|
|
258
|
+
export function mdToHtml(src: unknown): string {
|
|
259
|
+
const lines = String(src).replace(/\r\n?/g, "\n").split("\n");
|
|
260
|
+
let html = "", i = 0, list: string | null = null;
|
|
261
|
+
const closeList = () => { if (list) { html += "</" + list + ">"; list = null; } };
|
|
262
|
+
|
|
263
|
+
while (i < lines.length) {
|
|
264
|
+
const ln = lines[i]!;
|
|
265
|
+
|
|
266
|
+
// fenced code block
|
|
267
|
+
const fence = ln.match(/^\s*(`{3,}|~{3,})\s*([\w+#.-]*)/);
|
|
268
|
+
if (fence) {
|
|
269
|
+
closeList();
|
|
270
|
+
const tick = fence[1]![0], lang = fence[2] || "", buf: string[] = [];
|
|
271
|
+
const closeRe = new RegExp("^\\s*" + (tick === "`" ? "`{3,}" : "~{3,}") + "\\s*$");
|
|
272
|
+
i++;
|
|
273
|
+
while (i < lines.length && !closeRe.test(lines[i]!)) { buf.push(lines[i]!); i++; }
|
|
274
|
+
i++; // consume closing fence (or run off the end mid-stream)
|
|
275
|
+
html += mdCodeBlock(lang, buf.join("\n"));
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// GFM table: a row with pipes followed by a separator row
|
|
280
|
+
if (ln.includes("|") && i + 1 < lines.length && mdTableSep(lines[i + 1]!)) {
|
|
281
|
+
closeList();
|
|
282
|
+
const header = mdSplitRow(ln);
|
|
283
|
+
const aligns = mdSplitRow(lines[i + 1]!).map((c) => {
|
|
284
|
+
const l = c.startsWith(":"), r = c.endsWith(":");
|
|
285
|
+
return l && r ? "center" : r ? "right" : l ? "left" : "";
|
|
286
|
+
});
|
|
287
|
+
i += 2;
|
|
288
|
+
const rows: string[][] = [];
|
|
289
|
+
while (i < lines.length && lines[i]!.includes("|") && lines[i]!.trim() !== "") { rows.push(mdSplitRow(lines[i]!)); i++; }
|
|
290
|
+
const al = (k: number) => (aligns[k] ? ' style="text-align:' + aligns[k] + '"' : "");
|
|
291
|
+
let tbl = '<div class="md-tablewrap"><table class="md-table"><thead><tr>';
|
|
292
|
+
header.forEach((c, k) => { tbl += "<th" + al(k) + ">" + mdInline(c) + "</th>"; });
|
|
293
|
+
tbl += "</tr></thead><tbody>";
|
|
294
|
+
rows.forEach((r) => {
|
|
295
|
+
tbl += "<tr>";
|
|
296
|
+
for (let k = 0; k < header.length; k++) tbl += "<td" + al(k) + ">" + mdInline(r[k] || "") + "</td>";
|
|
297
|
+
tbl += "</tr>";
|
|
298
|
+
});
|
|
299
|
+
html += tbl + "</tbody></table></div>";
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// heading
|
|
304
|
+
const h = ln.match(/^(#{1,6})\s+(.*)$/);
|
|
305
|
+
if (h) { closeList(); const n = h[1]!.length; html += "<h" + n + ">" + mdInline(h[2]!.replace(/\s+#+\s*$/, "")) + "</h" + n + ">"; i++; continue; }
|
|
306
|
+
|
|
307
|
+
// horizontal rule
|
|
308
|
+
if (/^\s*([-*_])\s*(\1\s*){2,}$/.test(ln)) { closeList(); html += "<hr>"; i++; continue; }
|
|
309
|
+
|
|
310
|
+
// blockquote (consecutive '>' lines, rendered recursively)
|
|
311
|
+
if (/^\s*>/.test(ln)) {
|
|
312
|
+
closeList();
|
|
313
|
+
const buf: string[] = [];
|
|
314
|
+
while (i < lines.length && /^\s*>/.test(lines[i]!)) { buf.push(lines[i]!.replace(/^\s*>\s?/, "")); i++; }
|
|
315
|
+
html += "<blockquote>" + mdToHtml(buf.join("\n")) + "</blockquote>";
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// task list / unordered / ordered list items
|
|
320
|
+
const task = ln.match(/^\s*[-*+]\s+\[([ xX])\]\s+(.*)$/);
|
|
321
|
+
const ul = ln.match(/^\s*[-*+]\s+(.*)$/);
|
|
322
|
+
const ol = ln.match(/^\s*\d+[.)]\s+(.*)$/);
|
|
323
|
+
if (task) {
|
|
324
|
+
if (list !== "ul") { closeList(); html += "<ul>"; list = "ul"; }
|
|
325
|
+
html += '<li class="task"><input type="checkbox" disabled' + (task[1]!.toLowerCase() === "x" ? " checked" : "") + "> " + mdInline(task[2]!) + "</li>";
|
|
326
|
+
i++; continue;
|
|
327
|
+
}
|
|
328
|
+
if (ul) { if (list !== "ul") { closeList(); html += "<ul>"; list = "ul"; } html += "<li>" + mdInline(ul[1]!) + "</li>"; i++; continue; }
|
|
329
|
+
if (ol) { if (list !== "ol") { closeList(); html += "<ol>"; list = "ol"; } html += "<li>" + mdInline(ol[1]!) + "</li>"; i++; continue; }
|
|
330
|
+
|
|
331
|
+
if (ln.trim() === "") { closeList(); i++; continue; }
|
|
332
|
+
|
|
333
|
+
// paragraph: merge following plain lines (soft breaks -> <br>)
|
|
334
|
+
closeList();
|
|
335
|
+
const para = [ln];
|
|
336
|
+
i++;
|
|
337
|
+
while (i < lines.length && lines[i]!.trim() !== "" && !mdBlockStart(lines[i]!)) { para.push(lines[i]!); i++; }
|
|
338
|
+
html += "<p>" + para.map(mdInline).join("<br>") + "</p>";
|
|
339
|
+
}
|
|
340
|
+
closeList();
|
|
341
|
+
return html;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/* ────────────────────────────────────────────────────────────────────
|
|
345
|
+
STREAMING BLOCK MEMOIZATION (`02d723a:docs/design/web-chat-redesign.md` §2.2)
|
|
346
|
+
────────────────────────────────────────────────────────────────────
|
|
347
|
+
Naively doing `textNode.innerHTML = mdToHtml(fullText)` on every token
|
|
348
|
+
is O(n^2) over a response: every delta re-parses and re-renders the
|
|
349
|
+
*entire* message from scratch, and innerHTML-replace also blows away
|
|
350
|
+
and recreates every DOM node (losing e.g. text selection) each time.
|
|
351
|
+
|
|
352
|
+
The fix, ported from the Streamdown/t3.chat "block memoization"
|
|
353
|
+
pattern into this vanilla renderer: split accumulated text at stable
|
|
354
|
+
block boundaries (blank lines; a fenced code block counts as one
|
|
355
|
+
block and only becomes "stable" once its closing fence has arrived).
|
|
356
|
+
Every block except the last is done streaming and gets parsed with
|
|
357
|
+
mdToHtml exactly ONCE — its DOM node is then left completely alone.
|
|
358
|
+
Only the last ("live tail") block gets re-parsed on each delta, and
|
|
359
|
+
even that is throttled to once per animation frame so a burst of
|
|
360
|
+
fast tokens (e.g. spec-decode) coalesces into one reflow instead of
|
|
361
|
+
one per token.
|
|
362
|
+
|
|
363
|
+
INVARIANT (this is the correctness test for this whole scheme, and the
|
|
364
|
+
thing packages/module-chat/tests/panel.test.ts's streaming-parity fixtures check): the
|
|
365
|
+
memoized incremental render must be pixel-for-pixel/byte-for-byte
|
|
366
|
+
identical to a single `mdToHtml(fullText)` pass once the message is
|
|
367
|
+
complete. We guarantee this by (a) never touching a block's DOM once
|
|
368
|
+
it's marked done, using the same mdToHtml on that exact substring
|
|
369
|
+
that a full pass would have used, and (b) doing one full non-
|
|
370
|
+
memoized mdToHtml(fullText) pass at turn-end (see finishStreaming in
|
|
371
|
+
chat.ts) to paper over any edge case where incremental block splitting
|
|
372
|
+
disagrees with whole-text parsing (e.g. a list that only "closes"
|
|
373
|
+
because a later blank line arrives, or a paragraph the tail-block
|
|
374
|
+
join glued differently than the full parse would). That final pass
|
|
375
|
+
is the ground truth; the incremental renders are a fast-path
|
|
376
|
+
approximation of it while streaming is in flight. */
|
|
377
|
+
|
|
378
|
+
export interface MdBlock {
|
|
379
|
+
text: string;
|
|
380
|
+
done: boolean;
|
|
381
|
+
node?: HTMLElement;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export interface BlockState {
|
|
385
|
+
blocks: MdBlock[];
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Split text into blocks at blank lines. A fenced code block (opened by
|
|
389
|
+
* a ``` or ~~~ fence) is always kept as a single block and is only
|
|
390
|
+
* "closed" (safe to memoize) once its matching closing fence has been
|
|
391
|
+
* seen — while the fence is still open we keep extending the same
|
|
392
|
+
* block on every call, exactly like mdToHtml's own fence scanner does.
|
|
393
|
+
* Returns [{text, done}]; every element except possibly the last has
|
|
394
|
+
* done:true (the last block is "done" too if the text itself ends
|
|
395
|
+
* with a blank line — i.e. the block truly can't grow anymore given
|
|
396
|
+
* what's arrived so far). */
|
|
397
|
+
export function splitBlocks(src: unknown): MdBlock[] {
|
|
398
|
+
const lines = String(src).replace(/\r\n?/g, "\n").split("\n");
|
|
399
|
+
const blocks: MdBlock[] = [];
|
|
400
|
+
let buf: string[] = [], i = 0;
|
|
401
|
+
const flush = (done: boolean) => { if (buf.length) { blocks.push({ text: buf.join("\n"), done }); buf = []; } };
|
|
402
|
+
|
|
403
|
+
while (i < lines.length) {
|
|
404
|
+
const ln = lines[i]!;
|
|
405
|
+
const fence = ln.match(/^\s*(`{3,}|~{3,})/);
|
|
406
|
+
if (fence) {
|
|
407
|
+
// A fence starts a code block: flush whatever came before it as its
|
|
408
|
+
// own (done) block, then greedily consume lines until the matching
|
|
409
|
+
// closing fence — the whole fenced region is one block.
|
|
410
|
+
flush(true);
|
|
411
|
+
const tick = fence[1]![0];
|
|
412
|
+
const closeRe = new RegExp("^\\s*" + (tick === "`" ? "`{3,}" : "~{3,}") + "\\s*$");
|
|
413
|
+
buf.push(ln); i++;
|
|
414
|
+
let closed = false;
|
|
415
|
+
while (i < lines.length) {
|
|
416
|
+
buf.push(lines[i]!);
|
|
417
|
+
if (closeRe.test(lines[i]!)) { closed = true; i++; break; }
|
|
418
|
+
i++;
|
|
419
|
+
}
|
|
420
|
+
flush(closed); // still-open fence -> not done; more deltas may extend it
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
if (ln.trim() === "") { flush(true); i++; continue; }
|
|
424
|
+
buf.push(ln); i++;
|
|
425
|
+
}
|
|
426
|
+
// Trailing buffered lines with no closing blank line yet: this is the
|
|
427
|
+
// live tail, not done — it may still grow on the next delta.
|
|
428
|
+
flush(false);
|
|
429
|
+
return blocks;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Incrementally (re)render an accumulated markdown string into `container`
|
|
434
|
+
* using block memoization. `state` is a small cache object stashed on the
|
|
435
|
+
* caller (e.g. `a.blockState`) shaped `{ blocks: [{text, done, node}] }`,
|
|
436
|
+
* created fresh per message. Completed blocks whose text hasn't changed
|
|
437
|
+
* since last render are left untouched (their DOM node is reused as-is);
|
|
438
|
+
* only new/changed/live-tail blocks get an mdToHtml call.
|
|
439
|
+
*
|
|
440
|
+
* Completed block nodes get `class="md-block done"` and, once rendered,
|
|
441
|
+
* have any fenced code inside them run through highlightIn() (syntax
|
|
442
|
+
* highlighting only ever touches finished blocks — never the streaming
|
|
443
|
+
* live tail, to avoid re-highlighting on every token).
|
|
444
|
+
*/
|
|
445
|
+
export function renderBlocksIncremental(container: HTMLElement, text: string, state: BlockState): void {
|
|
446
|
+
const next = splitBlocks(text);
|
|
447
|
+
const prev = state.blocks || [];
|
|
448
|
+
|
|
449
|
+
for (let k = 0; k < next.length; k++) {
|
|
450
|
+
const nb = next[k]!;
|
|
451
|
+
const pb = prev[k];
|
|
452
|
+
if (pb && pb.done && pb.text === nb.text && pb.node) {
|
|
453
|
+
// Unchanged completed block: reuse the existing node untouched.
|
|
454
|
+
nb.node = pb.node;
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
// (Re)render this block. Reuse the DOM node in place if one already
|
|
458
|
+
// exists at this index (keeps node identity stable across re-renders
|
|
459
|
+
// of the live tail instead of thrashing the DOM), otherwise create it.
|
|
460
|
+
const node = pb && pb.node ? pb.node : document.createElement("div");
|
|
461
|
+
node.className = "md-block" + (nb.done ? " done" : "");
|
|
462
|
+
node.innerHTML = mdToHtml(nb.text);
|
|
463
|
+
nb.node = node;
|
|
464
|
+
if (!node.parentNode) container.appendChild(node);
|
|
465
|
+
if (nb.done) highlightIn(node);
|
|
466
|
+
}
|
|
467
|
+
// Drop any now-stale trailing nodes (text shrank — shouldn't normally
|
|
468
|
+
// happen during streaming, but keep the DOM honest if it ever does).
|
|
469
|
+
for (let k = next.length; k < prev.length; k++) {
|
|
470
|
+
const pk = prev[k];
|
|
471
|
+
if (pk && pk.node && pk.node.parentNode) pk.node.remove();
|
|
472
|
+
}
|
|
473
|
+
state.blocks = next;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/* ────────────────────────────────────────────────────────────────────
|
|
477
|
+
SYNTAX HIGHLIGHTING
|
|
478
|
+
────────────────────────────────────────────────────────────────────
|
|
479
|
+
Vendored highlight.js (apps/mlx-bun/src/web/public/vendor/hljs.js, no CDN — see that file's
|
|
480
|
+
header) applied to fenced code blocks. mdCodeBlock() above stamps
|
|
481
|
+
`class="language-<fence-info-string>"` onto the <code> element when the
|
|
482
|
+
fence declares one (```ts, ```python, ...); highlight.js reads that class
|
|
483
|
+
itself (its own `language-xxx` convention), so no lookup table is needed
|
|
484
|
+
here — an empty/unrecognized language just falls through to hljs's
|
|
485
|
+
plaintext handling.
|
|
486
|
+
|
|
487
|
+
Called from exactly three places:
|
|
488
|
+
1. renderBlocksIncremental(), only on blocks freshly marked `done`
|
|
489
|
+
(the live streaming tail is never highlighted — that would mean
|
|
490
|
+
re-highlighting on every token, the same O(n^2) shape the block-
|
|
491
|
+
memoization pass just fixed).
|
|
492
|
+
2. finishStreaming() in chat.ts, the end-of-turn full mdToHtml pass
|
|
493
|
+
(ground truth — catches any block whose "done" transition happened
|
|
494
|
+
without a fresh render, e.g. the last block if it ends exactly on
|
|
495
|
+
a blank line).
|
|
496
|
+
3. renderAssistantStatic() in chat.ts, for replayed history.
|
|
497
|
+
Idempotent via hljs's own `data-highlighted` marker (set on the <code>
|
|
498
|
+
element after its first highlightElement() call) — highlightElement()
|
|
499
|
+
itself throws "Element previously highlighted" if called twice on the
|
|
500
|
+
same node without clearing that marker, so we check for it explicitly
|
|
501
|
+
rather than relying on the library to no-op. */
|
|
502
|
+
|
|
503
|
+
/** The vendored highlight.js global (apps/mlx-bun/src/web/public/vendor/hljs.js), loaded via a
|
|
504
|
+
* plain <script defer src="/assets/hljs.js">, not an ES module — so it's
|
|
505
|
+
* a `window` global, not an import. Declared loosely (not the full hljs
|
|
506
|
+
* typing) since this app only calls the one method. */
|
|
507
|
+
declare const hljs: { highlightElement(el: Element): void } | undefined;
|
|
508
|
+
|
|
509
|
+
export function highlightIn(container: HTMLElement | null): void {
|
|
510
|
+
if (typeof hljs === "undefined" || !container) return;
|
|
511
|
+
const blocks = container.querySelectorAll("pre code");
|
|
512
|
+
for (const block of blocks) {
|
|
513
|
+
const el = block as HTMLElement;
|
|
514
|
+
if (el.dataset.highlighted) continue; // already done — idempotent
|
|
515
|
+
// Skip auto-detect on huge blocks: hljs's language guesser runs every
|
|
516
|
+
// registered grammar against the text, which is fine for a fenced
|
|
517
|
+
// ```lang block (single grammar) but expensive with no declared
|
|
518
|
+
// language on a very large block. A declared language always runs
|
|
519
|
+
// (fixed grammar, no guessing), regardless of size.
|
|
520
|
+
const hasLang = /\blanguage-/.test(el.className);
|
|
521
|
+
if (!hasLang && (el.textContent || "").length > 20_000) continue;
|
|
522
|
+
try { hljs!.highlightElement(el); } catch { /* leave as plain text */ }
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** rAF-throttle `render` so a burst of calls within one frame collapses into
|
|
527
|
+
* a single DOM update. `shouldStick`/`doStick` (both optional) let a caller
|
|
528
|
+
* autoscroll correctly across the coalesced burst: `shouldStick` is sampled
|
|
529
|
+
* fresh at the START of each queued frame (i.e. right before `render` runs,
|
|
530
|
+
* against whatever scrollHeight the LAST completed render left behind —
|
|
531
|
+
* never the stale pre-burst height a synchronous caller-side check would
|
|
532
|
+
* see), and `doStick` runs immediately after `render` grows the DOM in that
|
|
533
|
+
* same frame, so the pin decision and the scroll it acts on are always the
|
|
534
|
+
* correct before/after pair regardless of how many calls got coalesced. */
|
|
535
|
+
export function makeFrameScheduler(render: () => void, shouldStick?: () => boolean, doStick?: () => void): () => void {
|
|
536
|
+
let queued = false;
|
|
537
|
+
return function schedule() {
|
|
538
|
+
if (queued) return;
|
|
539
|
+
queued = true;
|
|
540
|
+
requestAnimationFrame(() => {
|
|
541
|
+
queued = false;
|
|
542
|
+
const stickAfter = shouldStick ? shouldStick() : false;
|
|
543
|
+
render();
|
|
544
|
+
if (stickAfter && doStick) doStick();
|
|
545
|
+
});
|
|
546
|
+
};
|
|
547
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The panel's open popovers, for the host's Escape sweep: each registers how to close itself when its controls are
|
|
2
|
+
// wired, and `closeTopPopover` closes the first one open (in the order the host page's sweep always used).
|
|
3
|
+
const ORDER = ["chat-sampling-pop", "chat-sysprompt-pop", "adapters-overlay"] as const;
|
|
4
|
+
type PopoverId = (typeof ORDER)[number];
|
|
5
|
+
const closers: Partial<Record<PopoverId, () => void>> = {};
|
|
6
|
+
|
|
7
|
+
export function setPopoverClose(id: PopoverId, close: () => void): void { closers[id] = close; }
|
|
8
|
+
|
|
9
|
+
const isOpen = (id: PopoverId): boolean => !!closers[id] && !!document.getElementById(id)?.classList.contains("open");
|
|
10
|
+
|
|
11
|
+
export function hasOpenPopover(): boolean { return ORDER.some(isOpen); }
|
|
12
|
+
|
|
13
|
+
/** Closes the first open popover; false when none was open. */
|
|
14
|
+
export function closeTopPopover(): boolean {
|
|
15
|
+
for (const id of ORDER) if (isOpen(id)) { closers[id]!(); return true; }
|
|
16
|
+
return false;
|
|
17
|
+
}
|