@oar-sh/oar 0.9.24 → 0.9.26

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.
Files changed (92) hide show
  1. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +3 -1
  2. package/CHANGELOG.md +284 -0
  3. package/README.md +12 -11
  4. package/package.json +5 -1
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +15 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +8 -4
  7. package/server/claude-worker/claude-session-process.mjs +4 -0
  8. package/server/claude-worker/claude-turn-publisher.mjs +25 -11
  9. package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
  10. package/server/copilot-worker/copilot-prompt-context.mjs +2 -1
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +11 -9
  12. package/server/cursor-worker/cursor-turn-runner.mjs +3 -1
  13. package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
  14. package/server/db-schema.mjs +19 -0
  15. package/server/grok-worker/grok-turn-runner.mjs +3 -1
  16. package/server/grok-worker/sdk-message-normalizer.mjs +3 -0
  17. package/server/migrations/0039-media-index.mjs +167 -0
  18. package/server/migrations/0040-image-session-defaults.mjs +93 -0
  19. package/server/public/app/api-client.js +58 -0
  20. package/server/public/app/ask-user-view.js +2 -1
  21. package/server/public/app/attachments-view.js +66 -9
  22. package/server/public/app/bootstrap.js +85 -25
  23. package/server/public/app/chat-actions-menu-state.mjs +1 -0
  24. package/server/public/app/context-media-tab.js +143 -0
  25. package/server/public/app/context-usage-view.mjs +2 -2
  26. package/server/public/app/conversation-view.js +90 -5
  27. package/server/public/app/image-composer-ui.js +518 -0
  28. package/server/public/app/image-turn-placeholder.mjs +233 -0
  29. package/server/public/app/journal-view.js +12 -2
  30. package/server/public/app/live-activity-cap.mjs +3 -3
  31. package/server/public/app/markdown-math.mjs +471 -0
  32. package/server/public/app/media-gallery-model.mjs +202 -0
  33. package/server/public/app/media-gallery-ui.js +524 -0
  34. package/server/public/app/media-view.js +79 -0
  35. package/server/public/app/openai-image-composer.mjs +492 -0
  36. package/server/public/app/openai-image-cost-ui.js +426 -0
  37. package/server/public/app/openai-image-spend.mjs +443 -0
  38. package/server/public/app/plan-usage-view.mjs +38 -6
  39. package/server/public/app/pwa-install.js +25 -4
  40. package/server/public/app/relay-error-ctas.mjs +5 -0
  41. package/server/public/app/relay-prompt-sanitizer.mjs +3 -0
  42. package/server/public/app/relay-restart-ui.js +2 -2
  43. package/server/public/app/remote-relays-settings.js +33 -6
  44. package/server/public/app/router.js +49 -237
  45. package/server/public/app/session-history-ui.js +2 -0
  46. package/server/public/app/settings-modal.js +4 -0
  47. package/server/public/app/settings-tabs.js +10 -1
  48. package/server/public/app/socket-handlers.js +26 -0
  49. package/server/public/app/store.js +2 -0
  50. package/server/public/app/stream-view-preference.mjs +1 -1
  51. package/server/public/app/tab-strip-fade.mjs +1 -1
  52. package/server/public/app/thoughts-preference.mjs +1 -1
  53. package/server/public/app/tmux-inspector-view.js +29 -16
  54. package/server/public/app/viewer-gallery.mjs +23 -1
  55. package/server/public/index.html +517 -123
  56. package/server/relay-tools.md +1 -0
  57. package/server/repositories/image-conversation-repository.mjs +69 -0
  58. package/server/repositories/media-repository.mjs +305 -0
  59. package/server/routes/media-routes.mjs +361 -0
  60. package/server/routes/messages-routes.mjs +1410 -360
  61. package/server/routes/relay-board-routes.mjs +46 -1
  62. package/server/routes/remote-relay-routes.mjs +1 -1
  63. package/server/routes/sessions-routes.mjs +361 -14
  64. package/server/server-runtime.mjs +21 -0
  65. package/server/services/database-file.mjs +6 -4
  66. package/server/services/generated-image-cleanup-service.mjs +2 -1
  67. package/server/services/generated-image-files.mjs +110 -0
  68. package/server/services/image-input-paths.mjs +389 -0
  69. package/server/services/image-operation-service.mjs +6 -2
  70. package/server/services/media-extract.mjs +653 -0
  71. package/server/services/media-index-service.mjs +221 -0
  72. package/server/services/openai-image-preview-turns.mjs +330 -0
  73. package/server/services/openai-image-spend-service.mjs +516 -0
  74. package/server/services/openai-image-stream.mjs +170 -0
  75. package/server/services/plan-usage-openai-image.mjs +148 -0
  76. package/server/services/relay-prompt-sanitizer.mjs +7 -0
  77. package/server/services/relay-restart-queue.mjs +1 -1
  78. package/server/services/remote-relay-caller-context.mjs +6 -0
  79. package/server/services/remote-relay-dispatcher.mjs +409 -23
  80. package/server/services/remote-relay-registry-service.mjs +10 -2
  81. package/server/services/session-worker-stop-service.mjs +50 -18
  82. package/server/services/token-usage-service.mjs +15 -0
  83. package/shared/image-header.mjs +90 -0
  84. package/shared/math-instructions.mjs +30 -0
  85. package/shared/openai-image-options.mjs +299 -0
  86. package/shared/openai-image-presets.mjs +175 -0
  87. package/shared/openai-image-previews.mjs +40 -0
  88. package/shared/openai-image-prices.mjs +291 -0
  89. package/shared/openai-image-spend.mjs +90 -0
  90. package/shared/remote-relay-contract.mjs +126 -4
  91. package/shared/worker-runtime/direct-openai-image.mjs +96 -50
  92. package/server/public/app/math-dollars.mjs +0 -89
@@ -0,0 +1,471 @@
1
+ // Maths in Markdown: which stretches of a reply are formulas.
2
+ //
3
+ // A formula is read from its delimiters, never guessed from what stands in a
4
+ // parenthesis: `$…$` and `\(…\)` inline, `$$…$$` and `\[…\]` as a display, and
5
+ // a fenced `math` block. The rules are tokenizers of the Markdown library, so
6
+ // code spans, code blocks, link targets and HTML are out of reach by
7
+ // construction, and the formula text reaches KaTeX as it was written (Markdown
8
+ // never sees its `\,`, `*` or `_`).
9
+ //
10
+ // The rules, in full:
11
+ // - A boundary stands before an opening delimiter: the start of the text,
12
+ // whitespace, one of `( [ { " ' -`, a non-ASCII character, a line break or
13
+ // an HTML tag. `C:\Program Files\(x86)` and `A$B` open nothing.
14
+ // - `$…$`: a non-space after the opening sign; only the next unescaped `$`
15
+ // closes, after a non-space, and before the end, whitespace, punctuation, an
16
+ // HTML tag, a non-ASCII character or an ordinal ending (`$n$th`). At most one line break
17
+ // inside, no backtick. `$5 and $9`, `$HOME/$PATH`, `$(a)$(b)` and the two
18
+ // signs of `/^a$/ or /^b$/` stay text.
19
+ // - `$5 or 10$`: a number first, a plain word between spaces and nothing of
20
+ // TeX is a pair of prices. This is the one rule that looks at the content.
21
+ // - `\(…\)`: closes within one line break.
22
+ // - `$$…$$` inside a line: no space inside either end.
23
+ // - `\[ … \]` on one line, wherever it stands: a space inside both ends, as
24
+ // `\[1\]`, `\[x\]` and `## \[Unreleased\]` are escaped brackets.
25
+ // - A display that opens at the start of a line runs to a line that ends with
26
+ // its closer. One that opens at the end of a line ("So: $$") runs to a line
27
+ // that is the closer alone. One of `. , ; :` may follow the closer.
28
+ // - Nothing but dots is no formula: `$...$` is talk about the notation.
29
+ // What KaTeX then refuses is shown as the source it was (router.js).
30
+
31
+ const MAX_INLINE_CHARS = 1000;
32
+ const MAX_BRACKET_LINE_CHARS = 2000;
33
+ const MAX_DISPLAY_CHARS = 10_000;
34
+ /** A longer message is rendered without maths, at the Markdown library's own speed. */
35
+ export const MATH_MESSAGE_MAX_CHARS = 100_000;
36
+
37
+ const OPEN_BOUNDARY = /[\s(\[{"'\-]/;
38
+ const CLOSE_BOUNDARY = /^[\s.,;:!?)\]}"'\-<]/;
39
+ const ORDINAL_ENDING = /^(?:th|st|nd|rd)\b/;
40
+ const ONLY_DOTS = /^(?:[.\u2026\s]|\\l?dots)+$/;
41
+ const PRICE_START = /^\d/;
42
+ const TEX_CHARACTER = /[\\^_{}]/;
43
+ const PLAIN_WORD = /(?:^|\s)[A-Za-z]{2,}(?=\s|$)/;
44
+ const BLANK_REST = /^[ \t]*(?:\n|$)/;
45
+ const FENCE_LINE = /^\s*(?:`{3,}|~{3,})/;
46
+ const CLOSING_LINE = /^(.*?)(\$\$|\\\])([.,;:]?)[ \t]*$/;
47
+ const MATH_FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})[ \t]*math[ \t]*(?:\n|$)/;
48
+
49
+ function isSpace(char) {
50
+ return char === undefined || char === '' || /\s/.test(char);
51
+ }
52
+
53
+ function boundaryBefore(prev) {
54
+ return !prev || OPEN_BOUNDARY.test(prev) || prev.charCodeAt(0) > 127;
55
+ }
56
+
57
+ function boundaryAfter(rest) {
58
+ return rest === '' || CLOSE_BOUNDARY.test(rest) || rest.charCodeAt(0) > 127 || ORDINAL_ENDING.test(rest);
59
+ }
60
+
61
+ function isFormula(tex) {
62
+ return Boolean(tex) && !ONLY_DOTS.test(tex);
63
+ }
64
+
65
+ function looksLikePrices(tex) {
66
+ return PRICE_START.test(tex) && !TEX_CHARACTER.test(tex) && PLAIN_WORD.test(tex);
67
+ }
68
+
69
+ /**
70
+ * The index of `closer` behind `from`, or -1. `closer` is `$`, `$$`, `\)` or
71
+ * `\]`. A backslash escapes the next character. The search ends at the cap,
72
+ * at a second line break (`lineBreaks` allows that many), at a blank line and,
73
+ * for the dollar closers, at a backtick or at a `$` that is not the closer.
74
+ */
75
+ function findCloser(src, from, closer, maxChars, lineBreaks) {
76
+ const escapedCloser = closer[0] === '\\';
77
+ const end = Math.min(src.length, from + maxChars);
78
+ let breaks = 0;
79
+ for (let i = from; i < end; i += 1) {
80
+ const char = src[i];
81
+ if (char === '\n') {
82
+ breaks += 1;
83
+ if (breaks > lineBreaks || BLANK_REST.test(src.slice(i + 1, i + 200))) return -1;
84
+ continue;
85
+ }
86
+ if (char === '\\') {
87
+ if (escapedCloser && src[i + 1] === closer[1]) return i;
88
+ i += 1;
89
+ continue;
90
+ }
91
+ if (escapedCloser) continue;
92
+ if (char === '`') return -1;
93
+ if (char === '$') {
94
+ if (closer === '$') return i;
95
+ return src[i + 1] === '$' ? i : -1;
96
+ }
97
+ }
98
+ return -1;
99
+ }
100
+
101
+ /** The closing line `line` is for `closer`, as { texLength, trail }, or null. */
102
+ function closingOf(line, closer, closerAlone) {
103
+ const closing = CLOSING_LINE.exec(line);
104
+ if (!closing || closing[2] !== closer || closing[1].includes(closer)) return null;
105
+ if (closerAlone && closing[1].trim()) return null;
106
+ return { texLength: closing[1].length, trail: closing[3] };
107
+ }
108
+
109
+ /**
110
+ * Walks whole lines from `from` to the one that ends the display.
111
+ * @returns {{ texEnd: number, end: number, trail: string } | { stoppedAt: number, hard: boolean }}
112
+ * the hit, or where the search gave up. `hard`: a blank line, a code
113
+ * fence, a stray closer or the end of the text ended it, not the size cap.
114
+ */
115
+ function findClosingLine(src, from, closer, { closerAlone = false, limitFrom = from } = {}) {
116
+ let lineStart = from;
117
+ while (lineStart < src.length) {
118
+ if (lineStart - limitFrom > MAX_DISPLAY_CHARS) return { stoppedAt: lineStart, hard: false };
119
+ let lineEnd = src.indexOf('\n', lineStart);
120
+ if (lineEnd < 0) lineEnd = src.length;
121
+ const line = src.slice(lineStart, lineEnd);
122
+ if (!line.trim() || FENCE_LINE.test(line)) return { stoppedAt: lineStart, hard: true };
123
+ const closing = closingOf(line, closer, closerAlone);
124
+ if (closing) return { texEnd: lineStart + closing.texLength, end: lineEnd, trail: closing.trail };
125
+ // A closer in the middle of a line belongs to another formula.
126
+ if (line.includes(closer)) return { stoppedAt: lineStart, hard: true };
127
+ lineStart = lineEnd + 1;
128
+ }
129
+ return { stoppedAt: src.length, hard: true };
130
+ }
131
+
132
+ /**
133
+ * A display over lines, `src` starting with its opener: from the opener's
134
+ * own line (`closerAlone` false: it may close there, as in `$$ x $$`) to
135
+ * the line that ends with the closer.
136
+ *
137
+ * `memo` remembers, per closer, the whole lines a failed search found without
138
+ * a closing line, so a paragraph of many unclosed openers is walked once.
139
+ * Places are kept as the length of the text still ahead, which names a place
140
+ * whatever `src` is cut to.
141
+ */
142
+ function matchDisplayOverLines(src, closer, { closerAlone = false, memo = null } = {}) {
143
+ let firstEnd = src.indexOf('\n', 2);
144
+ if (firstEnd < 0) firstEnd = src.length;
145
+ const firstLine = src.slice(2, firstEnd);
146
+ let hit = null;
147
+ let closingFirst = closerAlone ? null : closingOf(firstLine, closer, false);
148
+ if (closingFirst && closer === '\\]') {
149
+ // On one line, brackets without blanks inside are escaped brackets.
150
+ const inner = firstLine.slice(0, closingFirst.texLength);
151
+ if (!(isSpace(inner[0]) && isSpace(inner[inner.length - 1]))) return null;
152
+ }
153
+ if (closingFirst) {
154
+ hit = { texEnd: 2 + closingFirst.texLength, end: firstEnd, trail: closingFirst.trail };
155
+ } else {
156
+ if (firstLine.includes(closer) || firstEnd >= src.length) return null;
157
+ let from = firstEnd + 1;
158
+ const ahead = src.length - from;
159
+ const known = closerAlone ? null : memo?.[closer];
160
+ const covered = Boolean(known) && known.until < ahead && ahead <= known.from;
161
+ if (covered) {
162
+ if (known.hard) return null;
163
+ from = src.length - known.until;
164
+ }
165
+ hit = findClosingLine(src, from, closer, { closerAlone, limitFrom: 2 });
166
+ if (hit.texEnd === undefined) {
167
+ if (memo && !closerAlone) {
168
+ memo[closer] = { from: covered ? known.from : ahead, until: src.length - hit.stoppedAt, hard: hit.hard };
169
+ }
170
+ return null;
171
+ }
172
+ }
173
+ const tex = src.slice(2, hit.texEnd).trim();
174
+ if (!isFormula(tex)) return null;
175
+ return { raw: src.slice(0, hit.texEnd + closer.length), rawEnd: hit.end, tex, display: true, trail: hit.trail };
176
+ }
177
+
178
+ /**
179
+ * The formula `src` starts with, or null. `prev` is the character before it
180
+ * ('' at the start of the text). `atLineStart` says whether only blanks stand
181
+ * between the last line break and `src`.
182
+ * @returns {{ raw: string, tex: string, display: boolean } | null}
183
+ */
184
+ export function matchInlineMath(src, prev = '', { atLineStart = !prev || prev === '\n', memo = null } = {}) {
185
+ const text = String(src ?? '');
186
+ const first = text[0];
187
+ if (first !== '$' && first !== '\\') return null;
188
+ if (!boundaryBefore(prev)) return null;
189
+ const opener = text.slice(0, 2);
190
+
191
+ if (opener === '\\(') {
192
+ const close = findCloser(text, 2, '\\)', MAX_INLINE_CHARS, 1);
193
+ if (close < 0) return null;
194
+ const tex = text.slice(2, close).trim();
195
+ return isFormula(tex) ? { raw: text.slice(0, close + 2), tex, display: false } : null;
196
+ }
197
+
198
+ if (opener === '$$' || opener === '\\[') {
199
+ const closer = opener === '$$' ? '$$' : '\\]';
200
+ // The opener starts its line, or ends it ("So: $$"): the display runs over lines.
201
+ const opensAtLineEnd = !atLineStart && BLANK_REST.test(text.slice(2, 200)) && text.indexOf('\n', 2) >= 0;
202
+ if (atLineStart || opensAtLineEnd) {
203
+ const overLines = matchDisplayOverLines(text, closer, { closerAlone: opensAtLineEnd, memo });
204
+ if (overLines) return { raw: overLines.raw, tex: overLines.tex, display: true };
205
+ }
206
+ const close = findCloser(text, 2, closer, opener === '$$' ? MAX_DISPLAY_CHARS : MAX_BRACKET_LINE_CHARS, 0);
207
+ if (close < 0) return null;
208
+ const inner = text.slice(2, close);
209
+ if (opener === '$$') {
210
+ if (isSpace(inner[0]) || isSpace(inner[inner.length - 1])) return null;
211
+ if (text[close + 2] === '$' || !boundaryAfter(text.slice(close + 2, close + 5))) return null;
212
+ } else if (!(isSpace(inner[0]) && isSpace(inner[inner.length - 1]))) {
213
+ return null;
214
+ }
215
+ const tex = inner.trim();
216
+ return isFormula(tex) ? { raw: text.slice(0, close + 2), tex, display: true } : null;
217
+ }
218
+
219
+ if (first !== '$' || isSpace(text[1])) return null;
220
+ const close = findCloser(text, 1, '$', MAX_INLINE_CHARS, 1);
221
+ if (close < 0 || isSpace(text[close - 1]) || text[close + 1] === '$') return null;
222
+ if (!boundaryAfter(text.slice(close + 1, close + 4))) return null;
223
+ const tex = text.slice(1, close);
224
+ if (!isFormula(tex) || looksLikePrices(tex)) return null;
225
+ return { raw: text.slice(0, close + 1), tex, display: false };
226
+ }
227
+
228
+ function matchMathFence(src) {
229
+ const open = MATH_FENCE_OPEN.exec(src);
230
+ if (!open) return null;
231
+ const fence = open[1];
232
+ let lineStart = open[0].length;
233
+ while (lineStart < src.length) {
234
+ let lineEnd = src.indexOf('\n', lineStart);
235
+ if (lineEnd < 0) lineEnd = src.length;
236
+ const line = src.slice(lineStart, lineEnd).trim();
237
+ if (line.length >= fence.length && line[0] === fence[0] && line === line[0].repeat(line.length)) {
238
+ const tex = src.slice(open[0].length, lineStart).trim();
239
+ if (!tex) return null;
240
+ return { raw: src.slice(0, Math.min(src.length, lineEnd + 1)), source: src.slice(0, lineEnd), tex, display: true, trail: '' };
241
+ }
242
+ lineStart = lineEnd + 1;
243
+ }
244
+ // Not closed yet (a reply still streaming): the library shows it as code.
245
+ return null;
246
+ }
247
+
248
+ /**
249
+ * The display block `src` starts with, or null: `$$` or `\[` at the start of
250
+ * a line up to the line that ends with the closer, or a fenced `math` block.
251
+ * `raw` takes the rest of the closing line with it; `source` is the formula
252
+ * as written, up to its closer, and `trail` the punctuation behind that.
253
+ * @returns {{ raw: string, source: string, tex: string, display: true, trail: string } | null}
254
+ */
255
+ export function matchMathBlock(src) {
256
+ const text = String(src ?? '');
257
+ const fence = matchMathFence(text);
258
+ if (fence) return fence;
259
+ const open = /^ {0,3}(\$\$|\\\[)/.exec(text);
260
+ if (!open) return null;
261
+ const closer = open[1] === '$$' ? '$$' : '\\]';
262
+ const indent = open[0].length - 2;
263
+ const match = matchDisplayOverLines(text.slice(indent), closer);
264
+ if (!match) return null;
265
+ return {
266
+ raw: text.slice(0, Math.min(text.length, indent + match.rawEnd + 1)),
267
+ source: match.raw,
268
+ tex: match.tex,
269
+ display: true,
270
+ trail: match.trail,
271
+ };
272
+ }
273
+
274
+ /**
275
+ * The TeX handed to KaTeX. In a one-line formula a bare `%` is escaped: TeX
276
+ * reads it as the start of a comment, so `$50%$` came out as "50".
277
+ */
278
+ export function texForKatex(tex) {
279
+ const text = String(tex ?? '');
280
+ if (text.includes('\n') || !text.includes('%')) return text;
281
+ return text.replace(/\\[\s\S]|%/g, (match) => (match === '%' ? '\\%' : match));
282
+ }
283
+
284
+ /** Whether `source` holds anything a formula could start with. */
285
+ export function sourceHasMathDelimiters(source) {
286
+ const text = String(source ?? '');
287
+ return text.includes('$') || text.includes('\\(') || text.includes('\\[') || /(?:`{3,}|~{3,})[ \t]*math/.test(text);
288
+ }
289
+
290
+ /** Maths applies to `source`: it has a delimiter and is not over the size cap. */
291
+ export function mathAppliesTo(source) {
292
+ const text = String(source ?? '');
293
+ return text.length <= MATH_MESSAGE_MAX_CHARS && sourceHasMathDelimiters(text);
294
+ }
295
+
296
+ let mathEnabled = true;
297
+
298
+ /** Switches the tokenizers off for the next parse (a message without maths, or one over the cap). */
299
+ export function setMathEnabled(enabled) {
300
+ mathEnabled = Boolean(enabled);
301
+ }
302
+
303
+ function escapeHtml(text) {
304
+ return String(text ?? '')
305
+ .replace(/&/g, '&amp;')
306
+ .replace(/</g, '&lt;')
307
+ .replace(/>/g, '&gt;')
308
+ .replace(/"/g, '&quot;');
309
+ }
310
+
311
+ /**
312
+ * A formula as a placeholder: its source as text, its TeX in `data-tex`.
313
+ * KaTeX fills it in after the HTML is sanitised (router.js); where KaTeX is
314
+ * missing or refuses the formula, the placeholder stays, and reads as the
315
+ * source that was written.
316
+ */
317
+ export function mathPlaceholderHtml({ raw, tex, display }) {
318
+ const mode = display ? 'display' : 'inline';
319
+ return `<span data-math="${mode}" data-tex="${escapeHtml(tex)}">${escapeHtml(String(raw ?? '').trim())}</span>`;
320
+ }
321
+
322
+ // Per list of inline tokens: the text the tokenizer was handed on its last
323
+ // call, and what failed searches for a closing line found. The library asks
324
+ // the tokenizer at every step of its inline loop, so the text handed over
325
+ // last ends where the current one begins, and the character before `src` is
326
+ // read in constant time. (The last token's own `raw` grows by concatenation;
327
+ // reading its end is quadratic over a long paragraph.)
328
+ const inlineRuns = new WeakMap();
329
+
330
+ function inlineMathTokenizer(src, tokens) {
331
+ if (!mathEnabled) return undefined;
332
+ let run = inlineRuns.get(tokens);
333
+ const before = run ? run.src : null;
334
+ if (!run) {
335
+ run = { src, memo: {}, nextOpenerAhead: undefined };
336
+ inlineRuns.set(tokens, run);
337
+ } else {
338
+ run.src = src;
339
+ }
340
+ activeRun = run;
341
+ const first = src[0];
342
+ if (first !== '$' && first !== '\\') return undefined;
343
+
344
+ let prev = '';
345
+ let atLineStart = true;
346
+ const last = tokens.length ? tokens[tokens.length - 1] : null;
347
+ if (before !== null && !(last && (last.type === 'html' || last.type === 'br'))) {
348
+ let index = before.length - src.length - 1;
349
+ prev = index >= 0 ? before[index] : '';
350
+ while (index >= 0 && (before[index] === ' ' || before[index] === '\t')) index -= 1;
351
+ atLineStart = index < 0 ? !prev : before[index] === '\n';
352
+ }
353
+ const match = matchInlineMath(src, prev, { atLineStart, memo: run.memo });
354
+ return match ? { type: 'mathInline', ...match } : undefined;
355
+ }
356
+
357
+ /** The index of the next `$`, `\(` or `\[` in `src`, or undefined. */
358
+ function nextOpenerIndex(src) {
359
+ const dollar = src.indexOf('$');
360
+ let backslash = src.indexOf('\\');
361
+ while (backslash >= 0 && (dollar < 0 || backslash < dollar) && src[backslash + 1] !== '(' && src[backslash + 1] !== '[') {
362
+ // `\\(` is an escaped backslash before a parenthesis, not an opener.
363
+ backslash = src.indexOf('\\', backslash + (src[backslash + 1] === '\\' ? 2 : 1));
364
+ }
365
+ if (backslash >= 0 && dollar >= 0 && backslash > dollar) backslash = -1;
366
+ if (dollar < 0) return backslash < 0 ? undefined : backslash;
367
+ return backslash < 0 ? dollar : Math.min(dollar, backslash);
368
+ }
369
+
370
+ // The run whose tokenizer call came last. The library asks `start` in the
371
+ // same step of the same inline loop, with that call's text less its first
372
+ // character, which is how the run is recognised here.
373
+ let activeRun = null;
374
+
375
+ /**
376
+ * Where the library's text rule has to stop: the next possible opener. The
377
+ * library asks at every step of a paragraph; the answer is kept per run as
378
+ * the length of the text behind the opener, so a long paragraph is searched
379
+ * once and not once per step.
380
+ */
381
+ function inlineMathStart(src) {
382
+ if (!mathEnabled) return undefined;
383
+ const run = activeRun && activeRun.src.length === src.length + 1 && activeRun.src[1] === src[0] ? activeRun : null;
384
+ if (run && run.nextOpenerAhead !== undefined) {
385
+ if (run.nextOpenerAhead < 0) return undefined;
386
+ if (run.nextOpenerAhead <= src.length) return src.length - run.nextOpenerAhead;
387
+ }
388
+ const index = nextOpenerIndex(src);
389
+ if (run) run.nextOpenerAhead = index === undefined ? -1 : src.length - index;
390
+ return index;
391
+ }
392
+
393
+ function blockMathTokenizer(src) {
394
+ if (!mathEnabled) return undefined;
395
+ const first = src[0];
396
+ if (first !== '$' && first !== '\\' && first !== ' ' && first !== '`' && first !== '~') return undefined;
397
+ const match = matchMathBlock(src);
398
+ return match ? { type: 'mathBlock', ...match } : undefined;
399
+ }
400
+
401
+ function holdsMath(tokens) {
402
+ return Array.isArray(tokens)
403
+ && tokens.some((token) => token.type === 'mathInline' || token.type === 'mathBlock' || holdsMath(token.tokens));
404
+ }
405
+
406
+ /**
407
+ * An image's alternative text is an attribute: a formula placeholder in it
408
+ * would end the attribute and spill its rest into the page. Such an alt text
409
+ * is read again without the maths rules, as the library reads it alone.
410
+ *
411
+ * Runs once over the finished token tree. (The library's own `walkTokens`
412
+ * gathers its callbacks' results by concatenation, which is quadratic in the
413
+ * number of tokens of a long message.)
414
+ */
415
+ function imageAltsWithoutMath(tokens, lexInline) {
416
+ if (!Array.isArray(tokens)) return;
417
+ for (const token of tokens) {
418
+ if (token.type === 'image') {
419
+ if (holdsMath(token.tokens)) token.tokens = lexInline(token.text);
420
+ continue;
421
+ }
422
+ imageAltsWithoutMath(token.tokens, lexInline);
423
+ imageAltsWithoutMath(token.items, lexInline);
424
+ if (token.type === 'table') {
425
+ for (const cell of token.header) imageAltsWithoutMath(cell.tokens, lexInline);
426
+ for (const row of token.rows) for (const cell of row) imageAltsWithoutMath(cell.tokens, lexInline);
427
+ }
428
+ }
429
+ }
430
+
431
+ /** Installs the maths rules on a Markdown library; false when it cannot take them. */
432
+ export function useMathMarkdown(markdown) {
433
+ if (!markdown || typeof markdown.use !== 'function') return false;
434
+ const Lexer = markdown.Lexer;
435
+ markdown.use({
436
+ hooks: {
437
+ processAllTokens(tokens) {
438
+ if (!mathEnabled) return tokens;
439
+ imageAltsWithoutMath(tokens, (text) => {
440
+ if (typeof Lexer !== 'function') return [{ type: 'text', raw: text, text }];
441
+ mathEnabled = false;
442
+ try {
443
+ // With the document's link definitions, as the first reading had them.
444
+ const lexer = new Lexer(this.options);
445
+ if (tokens.links) lexer.tokens.links = tokens.links;
446
+ return lexer.inlineTokens(text);
447
+ } finally {
448
+ mathEnabled = true;
449
+ }
450
+ });
451
+ return tokens;
452
+ },
453
+ },
454
+ extensions: [
455
+ {
456
+ name: 'mathBlock',
457
+ level: 'block',
458
+ tokenizer: blockMathTokenizer,
459
+ renderer: (token) => `<p>${mathPlaceholderHtml({ ...token, raw: token.source })}${escapeHtml(token.trail || '')}</p>\n`,
460
+ },
461
+ {
462
+ name: 'mathInline',
463
+ level: 'inline',
464
+ start: inlineMathStart,
465
+ tokenizer: inlineMathTokenizer,
466
+ renderer: (token) => mathPlaceholderHtml(token),
467
+ },
468
+ ],
469
+ });
470
+ return true;
471
+ }
@@ -0,0 +1,202 @@
1
+ // The media gallery's logic without the DOM: the query a view asks
2
+ // GET /api/media with, month sections, how a gallery item becomes an item of
3
+ // the file viewer, what the viewer says about it, and which buttons it gets.
4
+
5
+ export const MEDIA_TEXT = Object.freeze({
6
+ title: 'Media',
7
+ tabMedia: 'Media',
8
+ tabFiles: 'Files',
9
+ tabContext: 'Context',
10
+ sourceAll: 'All',
11
+ sourceMine: 'Mine',
12
+ sourceAgent: 'Agent',
13
+ sourceGenerated: 'Generated',
14
+ allProviders: 'All providers',
15
+ allSessions: 'All sessions',
16
+ hideArchived: 'Hide archived',
17
+ search: 'Search name, alt text, session',
18
+ emptyMedia: 'No pictures or videos yet.',
19
+ emptyFiles: 'No files yet.',
20
+ emptyFiltered: 'Nothing matches these filters.',
21
+ loading: 'Loading…',
22
+ loadMore: 'Load more',
23
+ failed: 'Could not load the media.',
24
+ indexing: (count) => `Indexing older messages… (${count >= 10001 ? '10,000+' : count} left)`,
25
+ missing: (count) => `${count} missing ${count === 1 ? 'file is' : 'files are'} not shown.`,
26
+ sourceLabel: 'Source',
27
+ providerLabel: 'Provider',
28
+ sessionLabel: 'Session',
29
+ untitled: 'picture',
30
+ copied: 'Copied.',
31
+ copyFailed: 'Could not copy.',
32
+ openInSession: 'Open in session',
33
+ copyPath: 'Copy path',
34
+ copyLink: 'Copy link',
35
+ fromYou: '👤 You',
36
+ fromAgent: '🤖 Agent',
37
+ generated: '✨ Generated',
38
+ remote: '🌐',
39
+ archived: 'archived',
40
+ inMessages: (count) => `in ${count} messages`,
41
+ });
42
+
43
+ export const MEDIA_PAGE_SIZE = 60;
44
+
45
+ /** The query string of one page of a gallery view. */
46
+ export function mediaQuery({
47
+ category = 'media',
48
+ conversationId = '',
49
+ source = 'all',
50
+ provider = '',
51
+ hideArchived = false,
52
+ q = '',
53
+ cursor = '',
54
+ limit = MEDIA_PAGE_SIZE,
55
+ } = {}) {
56
+ const params = new URLSearchParams();
57
+ params.set('category', category === 'file' ? 'file' : 'media');
58
+ if (conversationId) params.set('conversationId', conversationId);
59
+ if (source && source !== 'all') params.set('source', source);
60
+ if (provider) params.set('provider', provider);
61
+ if (hideArchived) params.set('archived', 'hide');
62
+ const text = String(q || '').trim();
63
+ if (text) params.set('q', text);
64
+ if (cursor) params.set('cursor', cursor);
65
+ params.set('limit', String(limit));
66
+ return params.toString();
67
+ }
68
+
69
+ /** Items grouped by the month of their message, newest first as they come: `[{ key, label, items }]`. */
70
+ export function groupByMonth(items, { locale, timeZone } = {}) {
71
+ const sections = [];
72
+ for (const item of Array.isArray(items) ? items : []) {
73
+ const date = new Date(item?.timestamp);
74
+ const valid = !Number.isNaN(date.getTime());
75
+ const parts = valid
76
+ ? new Intl.DateTimeFormat('en-CA', { year: 'numeric', month: '2-digit', timeZone }).formatToParts(date)
77
+ : [];
78
+ const year = parts.find((part) => part.type === 'year')?.value || '';
79
+ const month = parts.find((part) => part.type === 'month')?.value || '';
80
+ const key = valid ? `${year}-${month}` : 'unknown';
81
+ let section = sections[sections.length - 1];
82
+ if (!section || section.key !== key) {
83
+ const label = valid ? date.toLocaleDateString(locale, { year: 'numeric', month: 'long', timeZone }) : '';
84
+ section = { key, label, items: [] };
85
+ sections.push(section);
86
+ }
87
+ section.items.push(item);
88
+ }
89
+ return sections;
90
+ }
91
+
92
+ export function formatMediaSize(bytes) {
93
+ const value = Number(bytes);
94
+ if (!Number.isFinite(value) || value <= 0) return '';
95
+ if (value < 1024) return `${value} B`;
96
+ if (value < 1024 * 1024) return `${(value / 1024).toFixed(value < 10 * 1024 ? 1 : 0)} KB`;
97
+ return `${(value / (1024 * 1024)).toFixed(1)} MB`;
98
+ }
99
+
100
+ /** The icon of a row of the Files tab. */
101
+ export function mediaFileIcon(item) {
102
+ if (item?.mediaKind === 'pdf') return '📄';
103
+ if (item?.mediaKind === 'audio') return '🎵';
104
+ return '📎';
105
+ }
106
+
107
+ /** The badge of an item: who it came from. */
108
+ export function mediaSourceBadge(item) {
109
+ if (item?.origin === 'generated') return MEDIA_TEXT.generated;
110
+ return item?.sender === 'me' ? MEDIA_TEXT.fromYou : MEDIA_TEXT.fromAgent;
111
+ }
112
+
113
+ /** The address the viewer loads an item from (the relay's route, or the remote picture itself). */
114
+ export function mediaHref(item, base = '') {
115
+ if (item?.contentUrl) return `${base}${item.contentUrl}`;
116
+ // Only a web address is loaded from elsewhere (the server sends no other kind).
117
+ const remote = String(item?.remoteUrl || '');
118
+ return /^https?:\/\//i.test(remote) ? remote : '';
119
+ }
120
+
121
+ /** A file viewer gallery item for a media item. */
122
+ export function mediaViewerItem(item, base = '') {
123
+ return {
124
+ key: `media:${item.id}`,
125
+ source: 'upload',
126
+ href: mediaHref(item, base),
127
+ name: String(item?.name || item?.alt || MEDIA_TEXT.untitled),
128
+ mime: String(item?.mime || (item?.mediaKind === 'video' ? 'video/mp4' : item?.mediaKind === 'image' ? 'image/png' : '')),
129
+ size: Number.isFinite(item?.sizeBytes) ? item.sizeBytes : 0,
130
+ media: item,
131
+ };
132
+ }
133
+
134
+ export function formatMediaDate(timestamp, locale = undefined) {
135
+ const date = new Date(timestamp);
136
+ if (Number.isNaN(date.getTime())) return '';
137
+ return date.toLocaleString(locale, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
138
+ }
139
+
140
+ /** The caption line: session title · date · who · how often · archived. */
141
+ export function mediaCaption(item, { locale } = {}) {
142
+ if (!item) return '';
143
+ const parts = [
144
+ String(item.conversationTitle || '').trim(),
145
+ formatMediaDate(item.timestamp, locale),
146
+ mediaSourceBadge(item) + (item.remoteUrl ? ` ${MEDIA_TEXT.remote}` : ''),
147
+ ];
148
+ if (Number(item.occurrences) > 1) parts.push(MEDIA_TEXT.inMessages(Number(item.occurrences)));
149
+ if (item.archived) parts.push(MEDIA_TEXT.archived);
150
+ return parts.filter(Boolean).join(' · ');
151
+ }
152
+
153
+ /** What "Copy path / Copy link" copies, or '' when there is nothing useful (uploads, data images). */
154
+ export function mediaCopyTarget(item) {
155
+ if (item?.path) return { label: MEDIA_TEXT.copyPath, text: String(item.path) };
156
+ if (item?.remoteUrl) return { label: MEDIA_TEXT.copyLink, text: String(item.remoteUrl) };
157
+ return null;
158
+ }
159
+
160
+ /**
161
+ * The viewer gallery for a list of media items, open at `index`. `loadMore`
162
+ * resolves the next page of media items (or null); `actions` carries what
163
+ * the page does for "Open in session" and copying.
164
+ */
165
+ export function mediaViewerGallery(items, index, {
166
+ base = '',
167
+ hasMore = false,
168
+ loadMore = null,
169
+ openInSession = null,
170
+ copyText = null,
171
+ locale,
172
+ } = {}) {
173
+ const viewerItems = items.map((item) => mediaViewerItem(item, base));
174
+ return {
175
+ source: 'media',
176
+ items: viewerItems,
177
+ index,
178
+ hasMore: Boolean(hasMore && loadMore),
179
+ loadMore: loadMore
180
+ ? async () => {
181
+ const page = await loadMore();
182
+ return {
183
+ items: (page?.items || []).map((item) => mediaViewerItem(item, base)),
184
+ hasMore: Boolean(page?.hasMore),
185
+ };
186
+ }
187
+ : null,
188
+ describe(viewerItem) {
189
+ const item = viewerItem?.media;
190
+ if (!item) return null;
191
+ const actions = [];
192
+ if (typeof openInSession === 'function' && item.conversationId && item.messageId) {
193
+ actions.push({ id: 'open-in-session', label: MEDIA_TEXT.openInSession, run: () => openInSession(item) });
194
+ }
195
+ const copy = mediaCopyTarget(item);
196
+ if (copy && typeof copyText === 'function') {
197
+ actions.push({ id: 'copy-location', label: copy.label, run: () => copyText(copy.text) });
198
+ }
199
+ return { caption: mediaCaption(item, { locale }), actions };
200
+ },
201
+ };
202
+ }