@oar-sh/oar 0.9.25 → 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.
- package/CHANGELOG.md +142 -0
- package/README.md +9 -9
- package/package.json +3 -1
- package/server/claude-worker/claude-sdk-adapter.mjs +8 -4
- package/server/claude-worker/claude-session-process.mjs +4 -0
- package/server/claude-worker/claude-turn-publisher.mjs +25 -11
- package/server/copilot-worker/copilot-prompt-context.mjs +2 -1
- package/server/cursor-worker/cursor-turn-runner.mjs +3 -1
- package/server/grok-worker/grok-turn-runner.mjs +3 -1
- package/server/public/app/context-usage-view.mjs +2 -2
- package/server/public/app/live-activity-cap.mjs +3 -3
- package/server/public/app/markdown-math.mjs +471 -0
- package/server/public/app/pwa-install.js +25 -4
- package/server/public/app/relay-prompt-sanitizer.mjs +3 -0
- package/server/public/app/relay-restart-ui.js +2 -2
- package/server/public/app/remote-relays-settings.js +33 -6
- package/server/public/app/router.js +49 -237
- package/server/public/app/settings-tabs.js +10 -1
- package/server/public/app/store.js +2 -0
- package/server/public/app/stream-view-preference.mjs +1 -1
- package/server/public/app/thoughts-preference.mjs +1 -1
- package/server/public/app/tmux-inspector-view.js +29 -16
- package/server/public/index.html +134 -116
- package/server/routes/relay-board-routes.mjs +46 -1
- package/server/routes/remote-relay-routes.mjs +1 -1
- package/server/server-runtime.mjs +2 -0
- package/server/services/relay-prompt-sanitizer.mjs +7 -0
- package/server/services/relay-restart-queue.mjs +1 -1
- package/server/services/remote-relay-dispatcher.mjs +23 -1
- package/server/services/remote-relay-registry-service.mjs +7 -1
- package/server/services/session-worker-stop-service.mjs +50 -18
- package/shared/math-instructions.mjs +30 -0
- 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, '&')
|
|
306
|
+
.replace(/</g, '<')
|
|
307
|
+
.replace(/>/g, '>')
|
|
308
|
+
.replace(/"/g, '"');
|
|
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
|
+
}
|
|
@@ -17,6 +17,7 @@ let pwaAppName = '';
|
|
|
17
17
|
// background GET must never repaint over a just-saved name, and a second edit
|
|
18
18
|
// must never be dropped in favor of an older in-flight save.
|
|
19
19
|
let pwaAppNameRequestSeq = 0;
|
|
20
|
+
let pwaAppNameSavesInFlight = 0;
|
|
20
21
|
|
|
21
22
|
function matchesDisplayMode(query) {
|
|
22
23
|
try {
|
|
@@ -354,20 +355,40 @@ export function syncPwaAppNameInput() {
|
|
|
354
355
|
});
|
|
355
356
|
}
|
|
356
357
|
|
|
358
|
+
/** The relay name as this page last read or saved it ('' for none). */
|
|
359
|
+
export function currentPwaAppName() {
|
|
360
|
+
return pwaAppName;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** True while this page's own rename is on its way to the relay. */
|
|
364
|
+
export function isPwaAppNameSaveInFlight() {
|
|
365
|
+
return pwaAppNameSavesInFlight > 0;
|
|
366
|
+
}
|
|
367
|
+
|
|
357
368
|
export async function updatePwaAppName(rawValue) {
|
|
358
369
|
const seq = ++pwaAppNameRequestSeq;
|
|
359
|
-
|
|
370
|
+
pwaAppNameSavesInFlight += 1;
|
|
371
|
+
let result;
|
|
372
|
+
try {
|
|
373
|
+
result = await updatePwaAppNameSetting(rawValue);
|
|
374
|
+
} finally {
|
|
375
|
+
pwaAppNameSavesInFlight -= 1;
|
|
376
|
+
}
|
|
360
377
|
if (!result || typeof result.appName !== 'string') {
|
|
361
378
|
alert('Failed to update the relay name.');
|
|
362
379
|
if (seq === pwaAppNameRequestSeq) paintPwaAppNameInput();
|
|
363
380
|
return;
|
|
364
381
|
}
|
|
365
|
-
|
|
366
|
-
|
|
382
|
+
// The rename is saved whatever came in meanwhile (a newer edit, or a read
|
|
383
|
+
// the relay's own announcement of it started): say so either way. Only the
|
|
384
|
+
// field is left to whichever request is newest.
|
|
385
|
+
if (seq === pwaAppNameRequestSeq) {
|
|
386
|
+
pwaAppName = result.appName;
|
|
387
|
+
paintPwaAppNameInput();
|
|
388
|
+
}
|
|
367
389
|
showTransientRelayNotice(result.appName
|
|
368
390
|
? `Relay name updated to "${result.appName}".`
|
|
369
391
|
: 'Relay name reset to default.');
|
|
370
|
-
paintPwaAppNameInput();
|
|
371
392
|
// The same name is what paired relays see (GET /api/relay/identity); the
|
|
372
393
|
// Relays tab re-reads it, including the host-name fallback for an empty one.
|
|
373
394
|
window.dispatchEvent(new CustomEvent('oar:relay-name-changed', { detail: { name: result.appName } }));
|
|
@@ -123,6 +123,8 @@ function stripAttachmentPromptArtifacts(text) {
|
|
|
123
123
|
// served to the browser and cannot import shared/, so the block is matched
|
|
124
124
|
// structurally — the heading plus its single paragraph — rather than verbatim.
|
|
125
125
|
const MEDIA_EMBED_BLOCK_PATTERN = /##\s*Embedding media in replies\s+[\s\S]*?(?=\n\s*\n|$)/gi;
|
|
126
|
+
// The formula guidance (shared/math-instructions.mjs) follows it, in the same shape.
|
|
127
|
+
const MATH_BLOCK_PATTERN = /##\s*Formulas in replies\s+[\s\S]*?(?=\n\s*\n|$)/gi;
|
|
126
128
|
|
|
127
129
|
// A steered message's hidden note (shared/steer-note.mjs) leads the prompt the
|
|
128
130
|
// runtime stored, right after any mode marker. Matched by its stable opening
|
|
@@ -166,6 +168,7 @@ export function stripRelayPromptContext(text, relayMode = '', options = {}) {
|
|
|
166
168
|
const origin = options && typeof options === 'object' ? options.origin : null;
|
|
167
169
|
const value = stripAttachmentPromptArtifacts(text)
|
|
168
170
|
.replace(MEDIA_EMBED_BLOCK_PATTERN, '')
|
|
171
|
+
.replace(MATH_BLOCK_PATTERN, '')
|
|
169
172
|
.trim();
|
|
170
173
|
if (!value) return '';
|
|
171
174
|
const patterns = buildPromptPrefixPatterns(relayMode);
|
|
@@ -265,7 +265,7 @@ export function renderRestartConfirmBody(picture = {}, { maxWaitMs = 0 } = {}) {
|
|
|
265
265
|
parts.push(`<p>The restart waits until these running turns end:</p>`);
|
|
266
266
|
parts.push(listHtml(sessions.map((entry) => sessionItemHtml(entry, Number(entry?.turns) > 1 ? `(${entry.turns} turns)` : ''))));
|
|
267
267
|
parts.push(`<p>Until then new messages are held${heldCount ? ` (${plural(heldCount, 'message')} already waiting)` : ''}; they run after the restart. `
|
|
268
|
-
+ `${Number(maxWaitMs) > 0 ? `After ${escHtml(formatMaxWait(maxWaitMs))} the banner asks whether to restart now, keep waiting or cancel.` : 'There is no time limit on the wait (Settings ›
|
|
268
|
+
+ `${Number(maxWaitMs) > 0 ? `After ${escHtml(formatMaxWait(maxWaitMs))} the banner asks whether to restart now, keep waiting or cancel.` : 'There is no time limit on the wait (Settings › Relays).'}</p>`);
|
|
269
269
|
} else {
|
|
270
270
|
parts.push(`<p>Nothing is running: the relay restarts at once${heldCount ? `, and the ${plural(heldCount, 'waiting message')} run after it` : ''}.</p>`);
|
|
271
271
|
}
|
|
@@ -503,7 +503,7 @@ export async function cancelQueuedRelayRestart() {
|
|
|
503
503
|
});
|
|
504
504
|
}
|
|
505
505
|
|
|
506
|
-
// ─── Settings ›
|
|
506
|
+
// ─── Settings › Relays: the maximum wait ─────────────────────────────────────
|
|
507
507
|
// Mirrors server/services/relay-restart-queue.mjs (the browser cannot import
|
|
508
508
|
// it); the select's options are the allowed values.
|
|
509
509
|
let maxWaitMinutes = 30;
|
|
@@ -23,6 +23,8 @@ import {
|
|
|
23
23
|
upsertRemoteRelay,
|
|
24
24
|
} from './remote-relays-store.mjs';
|
|
25
25
|
import { REMOTE_RELAY_PERMISSIONS, normalizeRemoteRelayPermission } from './remote-relay-shared.mjs';
|
|
26
|
+
import { currentPwaAppName, isPwaAppNameSaveInFlight, syncPwaAppNameInput } from './pwa-install.js';
|
|
27
|
+
import { SETTINGS_TAB_SHOWN_EVENT } from './settings-tabs.js';
|
|
26
28
|
|
|
27
29
|
export const REMOTE_RELAY_PERMISSION_LABELS = Object.freeze({
|
|
28
30
|
read: 'Read only',
|
|
@@ -54,6 +56,9 @@ let listLoadFailed = false;
|
|
|
54
56
|
const rowBusy = new Map();
|
|
55
57
|
let listRenderDeferred = false;
|
|
56
58
|
let listFocusBound = false;
|
|
59
|
+
// The relay's name as the last snapshot had it: a change made elsewhere is
|
|
60
|
+
// read into the Relay name field.
|
|
61
|
+
let lastSelfName = null;
|
|
57
62
|
|
|
58
63
|
function el(id) {
|
|
59
64
|
return document.getElementById(id);
|
|
@@ -164,12 +169,10 @@ function setStatusLine(id, state, text) {
|
|
|
164
169
|
}
|
|
165
170
|
|
|
166
171
|
function renderSelfSection() {
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
nameEl.textContent = name || '—';
|
|
172
|
-
}
|
|
172
|
+
// Empty, the field shows the name the relay goes by then (its host name).
|
|
173
|
+
const nameInput = el('pwa-app-name-input');
|
|
174
|
+
const defaultName = String(getRemoteRelaysSnapshot()?.self?.defaultName || '').trim();
|
|
175
|
+
if (nameInput && defaultName) nameInput.placeholder = defaultName;
|
|
173
176
|
const input = el('remote-relays-public-url-input');
|
|
174
177
|
if (input) {
|
|
175
178
|
input.placeholder = browserSelfUrl() || 'https://…';
|
|
@@ -590,9 +593,27 @@ export function resetRemoteRelaysSettingsForTests() {
|
|
|
590
593
|
addInFlight = false;
|
|
591
594
|
listLoadFailed = false;
|
|
592
595
|
listRenderDeferred = false;
|
|
596
|
+
lastSelfName = null;
|
|
593
597
|
rowBusy.clear();
|
|
594
598
|
}
|
|
595
599
|
|
|
600
|
+
// A rename on another device (the relay announces it with the list) or the
|
|
601
|
+
// Relays tab coming into view reads the name again; the field keeps what is
|
|
602
|
+
// being typed in it (pwa-install.js).
|
|
603
|
+
function refreshRelayNameOnChange(snapshot) {
|
|
604
|
+
const name = typeof snapshot?.self?.name === 'string' ? snapshot.self.name : null;
|
|
605
|
+
if (name === null) return;
|
|
606
|
+
const changed = lastSelfName !== null && name !== lastSelfName;
|
|
607
|
+
lastSelfName = name;
|
|
608
|
+
if (!changed) return;
|
|
609
|
+
// This page's own rename: its answer paints the field.
|
|
610
|
+
if (isPwaAppNameSaveInFlight()) return;
|
|
611
|
+
// A name this page already has (an empty one goes by the default).
|
|
612
|
+
const known = currentPwaAppName() || String(snapshot.self.defaultName || '');
|
|
613
|
+
if (name === known) return;
|
|
614
|
+
syncPwaAppNameInput();
|
|
615
|
+
}
|
|
616
|
+
|
|
596
617
|
// The relay's own settings ride along in the list payload's `self` (GET and
|
|
597
618
|
// remote_relays_updated alike), so a change made on another device shows here
|
|
598
619
|
// too — except while this page is saving the same field.
|
|
@@ -608,6 +629,7 @@ function applySelfSettingsFromSnapshot(snapshot) {
|
|
|
608
629
|
// Live: a remote_relays_updated event (or any other store change) repaints.
|
|
609
630
|
subscribeRemoteRelays((snapshot) => {
|
|
610
631
|
applySelfSettingsFromSnapshot(snapshot);
|
|
632
|
+
refreshRelayNameOnChange(snapshot);
|
|
611
633
|
renderRemoteRelaysSection();
|
|
612
634
|
});
|
|
613
635
|
|
|
@@ -615,4 +637,9 @@ if (typeof window !== 'undefined' && typeof window.addEventListener === 'functio
|
|
|
615
637
|
window.addEventListener(RELAY_NAME_CHANGED_EVENT, () => {
|
|
616
638
|
void refreshRemoteRelays();
|
|
617
639
|
});
|
|
640
|
+
window.addEventListener(SETTINGS_TAB_SHOWN_EVENT, (event) => {
|
|
641
|
+
if (event?.detail?.tab !== 'relays') return;
|
|
642
|
+
syncPwaAppNameInput();
|
|
643
|
+
void refreshRemoteRelays();
|
|
644
|
+
});
|
|
618
645
|
}
|