@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,443 @@
1
+ // What OpenAI image turns cost, the daily cap and the image presets, as the
2
+ // web client shows them: the pure part, no DOM.
3
+ //
4
+ // The first two blocks mirror shared/openai-image-spend.mjs and
5
+ // shared/openai-image-presets.mjs, which the browser cannot import (only
6
+ // server/public is served). openai-image-spend.test.mjs asserts they stay
7
+ // identical.
8
+ //
9
+ // Money is always "USD 1.23": a `$` pair renders as maths in this client.
10
+
11
+ import { escapeHtml } from './context-usage-view.mjs';
12
+ import {
13
+ OPENAI_IMAGE_OPTION_KEYS,
14
+ imageComposerChoices,
15
+ validateOpenAIImageOptions,
16
+ } from './openai-image-composer.mjs';
17
+
18
+ // ---- Mirror of shared/openai-image-spend.mjs ------------------------------
19
+
20
+ export const OPENAI_IMAGE_DAILY_CAP_DEFAULT_USD = 5;
21
+ export const OPENAI_IMAGE_CAP_WARN_RATIO = 0.8;
22
+ export const OPENAI_IMAGE_DAILY_CAP_REACHED_CODE = 'OPENAI_IMAGE_DAILY_CAP_REACHED';
23
+ export const OPENAI_IMAGE_USAGE_PATH = '/api/image-usage';
24
+ export const OPENAI_IMAGE_USAGE_EVENT = 'image_usage_updated';
25
+ export const OPENAI_IMAGE_SETTINGS_FIELDS = Object.freeze({
26
+ dailyCapUsd: 'imageDailyCapUsd',
27
+ allowToday: 'allowImagesToday',
28
+ });
29
+ export const OPENAI_IMAGE_PRESETS_PATH = '/api/openai-image-presets';
30
+
31
+ // The relay's socket event with `{ presets }` after every preset change
32
+ // (sessions-routes.mjs), and the top of the cap POST /api/settings/openai
33
+ // takes.
34
+ export const OPENAI_IMAGE_PRESETS_EVENT = 'openai_image_presets_updated';
35
+ export const OPENAI_IMAGE_DAILY_CAP_MAX_USD = 10_000;
36
+
37
+ export function formatOpenAIImageUsd(value) {
38
+ const number = Number(value);
39
+ if (!Number.isFinite(number)) return 'USD ?';
40
+ return `USD ${number > 0 && number < 0.01 ? number.toFixed(3) : number.toFixed(2)}`;
41
+ }
42
+
43
+ // ---- Mirror of shared/openai-image-presets.mjs (names, built-ins) ---------
44
+
45
+ export const OPENAI_IMAGE_PRESET_LIMITS = Object.freeze({
46
+ ownMax: 30,
47
+ nameChars: 40,
48
+ storedBytesMax: 8 * 1024,
49
+ });
50
+
51
+ export const OPENAI_IMAGE_PRESET_REFUSED_CODE = 'OPENAI_IMAGE_PRESET_REFUSED';
52
+
53
+ export const OPENAI_IMAGE_BUILTIN_PRESETS = Object.freeze([
54
+ Object.freeze({ name: 'Draft square', size: '1024x1024', quality: 'low' }),
55
+ Object.freeze({ name: 'Photo landscape', size: '1536x1024', quality: 'medium' }),
56
+ Object.freeze({ name: 'Portrait', size: '1024x1536', quality: 'medium' }),
57
+ Object.freeze({ name: 'Wallpaper 4K', size: '3840x2160', quality: 'high' }),
58
+ Object.freeze({ name: 'Sticker', size: '1024x1024', quality: 'medium', output_format: 'png', background: 'transparent' }),
59
+ ]);
60
+
61
+ const HIDDEN_CHARACTERS = /[\u0000-\u001f\u007f-\u009f­؜᠎​-‏‪-‮⁠-⁤⁦-]/u;
62
+
63
+ export function cleanOpenAIImagePresetName(value) {
64
+ if (typeof value !== 'string') return { error: 'a preset name must be text' };
65
+ const normalized = value.normalize('NFC');
66
+ if (HIDDEN_CHARACTERS.test(normalized)) return { error: 'a preset name may not hold control or invisible characters' };
67
+ const name = normalized.trim().replace(/\s+/gu, ' ');
68
+ if (!name) return { error: 'a preset name may not be empty' };
69
+ // "." and ".." would be path steps in the preset's URL (DELETE …/:name).
70
+ if (/^\.+$/.test(name)) return { error: 'a preset name may not be dots alone' };
71
+ if ([...name].length > OPENAI_IMAGE_PRESET_LIMITS.nameChars) {
72
+ return { error: `a preset name has at most ${OPENAI_IMAGE_PRESET_LIMITS.nameChars} characters` };
73
+ }
74
+ return { name };
75
+ }
76
+
77
+ export function openAIImagePresetKey(value) {
78
+ const cleaned = cleanOpenAIImagePresetName(value);
79
+ return cleaned.error ? '' : cleaned.name.toLowerCase();
80
+ }
81
+
82
+ // ---- The spend state ------------------------------------------------------
83
+
84
+ function finite(value, fallback = 0) {
85
+ const number = Number(value);
86
+ return Number.isFinite(number) ? number : fallback;
87
+ }
88
+
89
+ /** "5.00": an amount without its unit, for "USD 1.20 of 5.00". */
90
+ function bareAmount(value) {
91
+ return formatOpenAIImageUsd(value).replace(/^USD /, '');
92
+ }
93
+
94
+ /**
95
+ * The spend state of GET OPENAI_IMAGE_USAGE_PATH or the socket event, with
96
+ * numbers that are numbers; null for anything that is not one.
97
+ */
98
+ export function normalizeImageSpendState(raw) {
99
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
100
+ const cap = raw.capUsd === null || raw.capUsd === undefined ? null : Number(raw.capUsd);
101
+ const capUsd = Number.isFinite(cap) && cap > 0 ? cap : null;
102
+ const spentUsd = Math.max(0, finite(raw.spentUsd));
103
+ const reservedUsd = Math.max(0, finite(raw.reservedUsd));
104
+ const ratio = raw.ratio === null || raw.ratio === undefined
105
+ ? (capUsd ? (spentUsd + reservedUsd) / capUsd : null)
106
+ : finite(raw.ratio, null);
107
+ const state = {
108
+ day: String(raw.day || ''),
109
+ capUsd,
110
+ spentUsd,
111
+ reservedUsd,
112
+ estimatedUsd: Math.max(0, finite(raw.estimatedUsd)),
113
+ unknownPriceUsd: Math.max(0, finite(raw.unknownPriceUsd)),
114
+ monthUsd: Math.max(0, finite(raw.monthUsd)),
115
+ ratio: capUsd ? ratio : null,
116
+ warning: capUsd ? raw.warning === true : false,
117
+ reached: capUsd ? raw.reached === true : false,
118
+ allowedToday: raw.allowedToday === true,
119
+ allowedAt: raw.allowedAt ? String(raw.allowedAt) : null,
120
+ };
121
+ const conversation = raw.conversation;
122
+ if (conversation && typeof conversation === 'object' && !Array.isArray(conversation)) {
123
+ state.conversation = {
124
+ costUsd: Math.max(0, finite(conversation.costUsd)),
125
+ estimatedUsd: Math.max(0, finite(conversation.estimatedUsd)),
126
+ images: Math.max(0, Math.floor(finite(conversation.images))),
127
+ tries: Math.max(0, Math.floor(finite(conversation.tries))),
128
+ };
129
+ }
130
+ return state;
131
+ }
132
+
133
+ /** Today's spend the cap counts: booked plus in flight. */
134
+ export function imageSpendTodayUsd(state) {
135
+ return finite(state?.spentUsd) + finite(state?.reservedUsd);
136
+ }
137
+
138
+ /** "USD 1.20 of 5.00", or "USD 1.20 (no cap)". */
139
+ function todayAgainstCap(state) {
140
+ const today = formatOpenAIImageUsd(imageSpendTodayUsd(state));
141
+ return state?.capUsd ? `${today} of ${bareAmount(state.capUsd)}` : `${today} (no cap)`;
142
+ }
143
+
144
+ /** The time of an ISO stamp in the reader's clock ("14:05"), or ''. */
145
+ function clockOf(iso) {
146
+ const ms = Date.parse(String(iso || ''));
147
+ if (!Number.isFinite(ms)) return '';
148
+ const date = new Date(ms);
149
+ return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
150
+ }
151
+
152
+ /**
153
+ * Settings → Providers → OpenAI's spend line:
154
+ * "Today USD 1.20 of 5.00 · this month USD 7.80", with what is in flight and
155
+ * the day's override when there are any.
156
+ */
157
+ export function imageSpendSettingsLine(state) {
158
+ if (!state) return '';
159
+ const parts = [`Today ${todayAgainstCap(state)}`];
160
+ if (state.reservedUsd > 0) parts.push(`${formatOpenAIImageUsd(state.reservedUsd)} of it in flight`);
161
+ parts.push(`this month ${formatOpenAIImageUsd(state.monthUsd)}`);
162
+ if (state.capUsd && state.allowedToday) {
163
+ const at = clockOf(state.allowedAt);
164
+ parts.push(at ? `allowed past the cap today (since ${at})` : 'allowed past the cap today');
165
+ } else if (state.reached) {
166
+ parts.push('cap reached');
167
+ }
168
+ return parts.join(' · ');
169
+ }
170
+
171
+ /**
172
+ * The image chat's banner: `{ kind: 'reached' | 'warning', text }`, or null
173
+ * when the spend is below the warning mark (or there is no cap).
174
+ */
175
+ export function imageSpendBanner(state) {
176
+ if (!state || !state.capUsd) return null;
177
+ if (state.reached && !state.allowedToday) {
178
+ return {
179
+ kind: 'reached',
180
+ text: 'Daily image cap reached: image turns are refused until tomorrow (relay time) or until you allow more today in Settings → Providers → OpenAI',
181
+ };
182
+ }
183
+ if (state.warning || state.reached) {
184
+ const allowed = state.allowedToday && state.reached ? ' (allowed past the cap today)' : '';
185
+ return { kind: 'warning', text: `Today's OpenAI image spend: ${todayAgainstCap(state)}${allowed}` };
186
+ }
187
+ return null;
188
+ }
189
+
190
+ /** The chat menu's line: "OpenAI images: USD 0.42 in this chat (3 images)". */
191
+ export function imageChatCostText(conversation) {
192
+ if (!conversation) return '';
193
+ const images = Math.max(0, Math.floor(finite(conversation.images)));
194
+ const parts = [images === 1 ? '1 image' : `${images} images`];
195
+ if (finite(conversation.estimatedUsd) > 0) parts.push(`${formatOpenAIImageUsd(conversation.estimatedUsd)} of it estimated`);
196
+ return `OpenAI images: ${formatOpenAIImageUsd(conversation.costUsd)} in this chat (${parts.join(', ')})`;
197
+ }
198
+
199
+ /**
200
+ * The Allow / Stop allowing buttons of the settings section: `{ allow:
201
+ * { shown, label }, stop: { shown } }`. Nothing without a cap; while allowed
202
+ * only Stop allowing.
203
+ */
204
+ export function imageSpendOverrideButtons(state) {
205
+ const none = { allow: { shown: false, label: '' }, stop: { shown: false } };
206
+ if (!state || !state.capUsd) return none;
207
+ if (state.allowedToday) return { allow: { shown: false, label: '' }, stop: { shown: true } };
208
+ return {
209
+ allow: { shown: true, label: state.reached ? 'Allow for today' : 'Allow past the cap for today' },
210
+ stop: { shown: false },
211
+ };
212
+ }
213
+
214
+ // ---- The cap field ---------------------------------------------------------
215
+
216
+ /**
217
+ * The cap as the field shows it, from GET /api/settings/openai's
218
+ * imageDailyCapUsd: the default (5) when the relay names none, "0" for no
219
+ * cap, else the amount ("12.5").
220
+ */
221
+ export function imageCapInputText(capUsd) {
222
+ if (capUsd === undefined) return String(OPENAI_IMAGE_DAILY_CAP_DEFAULT_USD);
223
+ if (capUsd === null) return '0';
224
+ const value = Number(capUsd);
225
+ if (!Number.isFinite(value) || value < 0) return String(OPENAI_IMAGE_DAILY_CAP_DEFAULT_USD);
226
+ return String(value);
227
+ }
228
+
229
+ /**
230
+ * The cap field's text as the relay takes it: `{ ok: true, capUsd }` (0: no
231
+ * cap) or `{ ok: false, error }`. The relay checks it again.
232
+ */
233
+ export function parseImageCapInput(text) {
234
+ const value = String(text ?? '').trim();
235
+ if (!value) return { ok: false, error: 'Enter the daily image cap in US dollars, or 0 for no cap.' };
236
+ if (!/^(?:0|[1-9]\d{0,5})(?:\.\d{1,2})?$/.test(value) || Number(value) > OPENAI_IMAGE_DAILY_CAP_MAX_USD) {
237
+ return { ok: false, error: `The daily image cap is an amount of US dollars up to ${OPENAI_IMAGE_DAILY_CAP_MAX_USD} (at most two decimals), or 0 for no cap.` };
238
+ }
239
+ return { ok: true, capUsd: Number(value) };
240
+ }
241
+
242
+ // ---- Presets ---------------------------------------------------------------
243
+
244
+ /** The option values a preset holds, in OPENAI_IMAGE_OPTION_KEYS order. */
245
+ export function imagePresetValues(preset) {
246
+ const values = {};
247
+ for (const key of OPENAI_IMAGE_OPTION_KEYS) {
248
+ const value = preset?.[key];
249
+ if (value !== undefined && value !== null && value !== '') values[key] = value;
250
+ }
251
+ return values;
252
+ }
253
+
254
+ const FORMAT_LABELS = Object.freeze({ png: 'PNG', webp: 'WebP', jpeg: 'JPEG' });
255
+
256
+ /** "1536x1024 · medium · 2 images · PNG · transparent background". */
257
+ export function describeImagePreset(preset) {
258
+ const values = imagePresetValues(preset);
259
+ const parts = [];
260
+ if (values.size) parts.push(values.size === 'auto' ? 'auto size' : String(values.size));
261
+ if (values.quality) parts.push(values.quality === 'auto' ? 'auto quality' : String(values.quality));
262
+ const count = Number(values.count);
263
+ if (Number.isInteger(count) && count >= 1) parts.push(count === 1 ? '1 image' : `${count} images`);
264
+ if (values.output_format) parts.push(FORMAT_LABELS[values.output_format] || String(values.output_format));
265
+ if (values.background) parts.push(`${values.background} background`);
266
+ return parts.join(' · ');
267
+ }
268
+
269
+ /**
270
+ * The presets list of GET/PUT/DELETE OPENAI_IMAGE_PRESETS_PATH, kept to
271
+ * entries with a clean name, built-ins first; the client's built-ins when
272
+ * the answer has none (an older relay, a failed read).
273
+ */
274
+ export function normalizeImagePresets(answer) {
275
+ const list = Array.isArray(answer?.presets) ? answer.presets : null;
276
+ if (!list) return OPENAI_IMAGE_BUILTIN_PRESETS.map((preset) => ({ ...preset, builtIn: true }));
277
+ const seen = new Set();
278
+ const presets = [];
279
+ for (const entry of list) {
280
+ if (!entry || typeof entry !== 'object') continue;
281
+ const cleaned = cleanOpenAIImagePresetName(entry.name);
282
+ if (cleaned.error) continue;
283
+ const key = cleaned.name.toLowerCase();
284
+ if (seen.has(key)) continue;
285
+ seen.add(key);
286
+ presets.push({ name: cleaned.name, ...imagePresetValues(entry), builtIn: entry.builtIn === true });
287
+ }
288
+ return [...presets.filter((preset) => preset.builtIn), ...presets.filter((preset) => !preset.builtIn)];
289
+ }
290
+
291
+ /** The preset of that name (any case, any spacing), or null. */
292
+ export function findImagePreset(presets, name) {
293
+ const key = openAIImagePresetKey(String(name ?? ''));
294
+ if (!key || !Array.isArray(presets)) return null;
295
+ return presets.find((preset) => String(preset?.name || '').toLowerCase() === key) || null;
296
+ }
297
+
298
+ /**
299
+ * What picking `preset` sets in the composer for `model`: `{ values, skipped }`.
300
+ * `values` holds what the model's selects take (quality only when
301
+ * `qualities` lists it: the Quality select's options); `skipped` names the
302
+ * preset's fields the model does not take ("size 1536x1024"), which keep
303
+ * what the composer shows.
304
+ */
305
+ export function imagePresetComposerValues(preset, model = '', { qualities = null, currentFormat = '' } = {}) {
306
+ const choices = imageComposerChoices(model);
307
+ const values = {};
308
+ const skipped = [];
309
+ if (!choices) return { values, skipped };
310
+ for (const [key, value] of Object.entries(imagePresetValues(preset))) {
311
+ const checked = validateOpenAIImageOptions({ [key]: value }, { model });
312
+ const clean = checked.ok ? checked.values[key] : undefined;
313
+ const offered = key !== 'quality' || !Array.isArray(qualities) || qualities.includes(clean);
314
+ if (clean === undefined || !offered) {
315
+ skipped.push(`${key === 'output_format' ? 'format' : key} ${value}`);
316
+ continue;
317
+ }
318
+ values[key] = clean;
319
+ }
320
+ // A transparent background needs PNG or WebP: with JPEG on screen and no
321
+ // format in the preset, PNG goes with it.
322
+ if (values.background === 'transparent' && !values.output_format && String(currentFormat || '').toLowerCase() === 'jpeg') {
323
+ values.output_format = 'png';
324
+ }
325
+ return { values, skipped };
326
+ }
327
+
328
+ /** A save refused because an own preset has that name (the relay's words). */
329
+ export function isImagePresetExistsRefusal(error) {
330
+ return /\bexists\b/i.test(String(error?.message ?? error ?? ''));
331
+ }
332
+
333
+ /**
334
+ * The preset a save would write from the composer's checked `image` (the
335
+ * send's values) under `name`: `{ ok: true, preset }` or `{ ok: false,
336
+ * error }` for a name the relay would refuse (a built-in's included).
337
+ */
338
+ export function buildImagePresetToSave(name, image = {}) {
339
+ const cleaned = cleanOpenAIImagePresetName(String(name ?? ''));
340
+ if (cleaned.error) return { ok: false, error: `${cleaned.error[0].toUpperCase()}${cleaned.error.slice(1)}.` };
341
+ const key = cleaned.name.toLowerCase();
342
+ if (OPENAI_IMAGE_BUILTIN_PRESETS.some((preset) => preset.name.toLowerCase() === key)) {
343
+ return { ok: false, error: `"${cleaned.name}" is a built-in preset; pick another name.` };
344
+ }
345
+ const values = imagePresetValues(image);
346
+ if (!Object.keys(values).length) return { ok: false, error: 'Nothing to save: pick a size or a quality first.' };
347
+ return { ok: true, preset: { name: cleaned.name, ...values } };
348
+ }
349
+
350
+ // ---- The Check Usage card ---------------------------------------------------
351
+ //
352
+ // The relay's "OpenAI images" card of GET /api/usage (provider 'openai')
353
+ // carries the spend state as `imageSpend`: plan-usage-view.mjs draws its
354
+ // meter, figures and badges from it with these, so a live state (the socket
355
+ // event) redraws the whole card.
356
+
357
+ function usageColor(ratio) {
358
+ if (ratio === null) return '#6e7681';
359
+ if (ratio >= 1) return '#f85149';
360
+ if (ratio >= OPENAI_IMAGE_CAP_WARN_RATIO) return '#e3b341';
361
+ if (ratio >= 0.5) return '#d29922';
362
+ return '#3fb950';
363
+ }
364
+
365
+ /** The figures of the card: `[{ id, label, value, hint? }]`. */
366
+ export function imageUsageCardFacts(state) {
367
+ if (!state) return [];
368
+ const facts = [
369
+ { id: 'openai-images-month', label: 'This month', value: formatOpenAIImageUsd(state.monthUsd) },
370
+ { id: 'openai-images-cap', label: 'Daily cap', value: state.capUsd ? formatOpenAIImageUsd(state.capUsd) : 'No cap' },
371
+ ];
372
+ if (state.reservedUsd > 0) {
373
+ facts.push({ id: 'openai-images-reserved', label: 'In progress', value: formatOpenAIImageUsd(state.reservedUsd), hint: 'tries running now, counted toward the cap' });
374
+ }
375
+ if (state.estimatedUsd > 0) {
376
+ facts.push({ id: 'openai-images-estimated', label: 'Estimated', value: formatOpenAIImageUsd(state.estimatedUsd), hint: 'part of today: tries that may have been billed without a usage report' });
377
+ }
378
+ if (state.unknownPriceUsd > 0) {
379
+ facts.push({ id: 'openai-images-unknown-price', label: 'Unknown price', value: formatOpenAIImageUsd(state.unknownPriceUsd), hint: 'part of today: a model the price table lacks, booked at a stand-in price' });
380
+ }
381
+ if (state.capUsd) {
382
+ const at = clockOf(state.allowedAt);
383
+ facts.push({
384
+ id: 'openai-images-override',
385
+ label: 'Past the cap today',
386
+ value: state.allowedToday ? (at ? `Allowed since ${at}` : 'Allowed') : 'Not allowed',
387
+ });
388
+ }
389
+ return facts;
390
+ }
391
+
392
+ /** The card's badges: the cap reached, or allowed past it today. */
393
+ export function imageUsageBadgesHtml(state) {
394
+ if (!state?.capUsd) return '';
395
+ if (state.allowedToday) return '<span class="plan-usage-badge" data-testid="openai-image-usage-allowed">Allowed today</span>';
396
+ if (state.reached) return '<span class="plan-usage-badge plan-usage-badge-status" data-testid="openai-image-usage-reached">Cap reached</span>';
397
+ return '';
398
+ }
399
+
400
+ /**
401
+ * The card's message, from the state: the cap reached (and whether today
402
+ * is allowed), the warning share, or no cap.
403
+ */
404
+ export function imageUsageCardMessage(state) {
405
+ if (!state) return '';
406
+ if (!state.capUsd) return 'No daily cap is set for OpenAI images on this relay.';
407
+ if (state.reached && state.allowedToday) return 'Today\'s image spend has reached the daily cap; image turns run anyway, allowed for today in Settings → Providers → OpenAI.';
408
+ const banner = imageSpendBanner(state);
409
+ return banner ? `${banner.text}.` : '';
410
+ }
411
+
412
+ /**
413
+ * Today against the cap: the bar with the 80 % mark, "USD 1.20 of 5.00 ·
414
+ * resets at midnight (relay time)"; without a cap the amount alone.
415
+ */
416
+ export function renderImageUsageMeterHtml(state) {
417
+ if (!state) return '';
418
+ const today = imageSpendTodayUsd(state);
419
+ const ratio = state.capUsd ? today / state.capUsd : null;
420
+ const percentLabel = ratio === null ? '—' : `${Math.round(ratio * 100)}%`;
421
+ const width = ratio === null ? 0 : Math.max(0, Math.min(100, ratio * 100));
422
+ const markLeft = Math.round(OPENAI_IMAGE_CAP_WARN_RATIO * 100);
423
+ const meta = state.capUsd
424
+ ? `${formatOpenAIImageUsd(today)} of ${bareAmount(state.capUsd)} · resets at midnight (relay time)`
425
+ : `${formatOpenAIImageUsd(today)} today · no cap`;
426
+ const flags = state.estimatedUsd > 0 ? '<span class="plan-usage-flag">estimated</span>' : '';
427
+ const bar = state.capUsd
428
+ ? `<div class="plan-usage-bar plan-usage-bar-marked" role="img" aria-label="${escapeHtml(`Today: ${percentLabel} of the daily cap`)}">
429
+ <span class="plan-usage-bar-fill" style="width:${width.toFixed(1)}%;background:${usageColor(ratio)}"></span>
430
+ <span class="plan-usage-bar-mark" style="left:${markLeft}%" title="${markLeft}%: the warning mark" aria-hidden="true"></span>
431
+ </div>`
432
+ : '';
433
+ return `
434
+ <div class="plan-usage-meter plan-usage-meter-primary" data-meter-id="openai-images-daily-cap" data-testid="openai-image-usage-meter">
435
+ <div class="plan-usage-meter-head">
436
+ <span class="plan-usage-meter-label">Today${flags}</span>
437
+ <span class="plan-usage-meter-pct">${escapeHtml(percentLabel)}</span>
438
+ </div>
439
+ ${bar}
440
+ <div class="plan-usage-meter-meta" data-testid="openai-image-usage-today">${escapeHtml(meta)}</div>
441
+ </div>
442
+ `;
443
+ }
@@ -9,6 +9,13 @@
9
9
  */
10
10
 
11
11
  import { escapeHtml } from './context-usage-view.mjs';
12
+ import {
13
+ imageUsageBadgesHtml,
14
+ imageUsageCardFacts,
15
+ imageUsageCardMessage,
16
+ normalizeImageSpendState,
17
+ renderImageUsageMeterHtml,
18
+ } from './openai-image-spend.mjs';
12
19
 
13
20
  // Null-prototype so an unrecognized `status`/`source` can only ever miss —
14
21
  // a plain literal would resolve keys like `constructor` off the prototype and
@@ -45,6 +52,8 @@ const PROVIDER_ALIASES = Object.freeze(Object.assign(Object.create(null), {
45
52
  copilot: 'github',
46
53
  'github-copilot': 'github',
47
54
  'copilot-github': 'github',
55
+ // An image chat's provider: its spend is the 'openai' card's.
56
+ 'openai-image': 'openai',
48
57
  }));
49
58
 
50
59
  /** @returns {string} the canonical usage-card id for a provider spelling. */
@@ -383,15 +392,28 @@ function renderAccountLine(account) {
383
392
  return `<span class="plan-usage-account">${parts.map(escapeHtml).join(' · ')}</span>`;
384
393
  }
385
394
 
395
+ // The figures of the OpenAI images card the spend state draws (the relay's
396
+ // own ones of the same things give way).
397
+ const IMAGE_SPEND_FACT_IDS = new Set(['openai-images-today', 'openai-images-month', 'openai-images-override']);
398
+
386
399
  function renderCard(card, now, { active = true, timeZone = undefined } = {}) {
387
- const meters = Array.isArray(card.meters) ? card.meters : [];
400
+ // OpenAI images: the spend state (`imageSpend`, live from the socket
401
+ // event too) draws the meter with the cap's 80 % mark, the figures, the
402
+ // badges and the message, in "USD 1.23".
403
+ const imageSpend = card.imageSpend ? normalizeImageSpendState(card.imageSpend) : null;
404
+ const meters = (Array.isArray(card.meters) ? card.meters : [])
405
+ .filter((meter) => !imageSpend || meter?.id !== 'openai-images-daily-cap');
388
406
  const charts = Array.isArray(card.charts) ? card.charts.filter(Boolean) : [];
389
407
  const details = Array.isArray(card.details) ? card.details : [];
390
- const allNotes = Array.isArray(card.notes) ? card.notes.filter(Boolean) : [];
408
+ const allNotes = (Array.isArray(card.notes) ? card.notes.filter(Boolean) : [])
409
+ .filter((note) => !imageSpend || !IMAGE_SPEND_FACT_IDS.has(note?.id));
391
410
  // Figures go in one list under the bars; sentences stay notes, and on a
392
411
  // card with figures they come last, after the tables, so the numbers read
393
412
  // first.
394
- const facts = allNotes.filter(isFactNote);
413
+ const facts = [
414
+ ...(imageSpend ? imageUsageCardFacts(imageSpend) : []),
415
+ ...allNotes.filter(isFactNote),
416
+ ];
395
417
  const notes = allNotes.filter((note) => !isFactNote(note));
396
418
  const notesHtml = notes.map((note) => renderNote(note, { now, timeZone })).join('');
397
419
  const detailsHtml = details.map(renderDetailSection).join('');
@@ -400,6 +422,7 @@ function renderCard(card, now, { active = true, timeZone = undefined } = {}) {
400
422
  const statusBadge = STATUS_BADGES[card.status];
401
423
  const sourceBadge = SOURCE_LABELS[card.source];
402
424
  const badges = [
425
+ imageSpend ? imageUsageBadgesHtml(imageSpend) : '',
403
426
  card.planName ? `<span class="plan-usage-badge plan-usage-badge-plan">${escapeHtml(card.planName)}</span>` : '',
404
427
  statusBadge ? `<span class="plan-usage-badge plan-usage-badge-status">${escapeHtml(statusBadge)}</span>` : '',
405
428
  sourceBadge ? `<span class="plan-usage-badge">${escapeHtml(sourceBadge)}</span>` : '',
@@ -408,13 +431,14 @@ function renderCard(card, now, { active = true, timeZone = undefined } = {}) {
408
431
  const primary = meters.filter((meter) => meter.emphasis !== 'secondary');
409
432
  const secondary = meters.filter((meter) => meter.emphasis === 'secondary');
410
433
 
411
- const metersHtml = meters.length
434
+ const metersHtml = `${imageSpend ? renderImageUsageMeterHtml(imageSpend) : ''}${meters.length
412
435
  ? `${primary.map((meter) => renderMeter(meter, now)).join('')}${
413
436
  secondary.length
414
437
  ? `<div class="plan-usage-secondary">${secondary.map((meter) => renderMeter(meter, now)).join('')}</div>`
415
438
  : ''
416
439
  }`
417
- : '';
440
+ : ''}`;
441
+ const message = imageSpend ? imageUsageCardMessage(imageSpend) : card.message;
418
442
 
419
443
  const capturedLabel = card.capturedAt
420
444
  ? `Updated ${escapeHtml(String(card.capturedAt).replace('T', ' ').slice(0, 16))} UTC`
@@ -430,7 +454,7 @@ function renderCard(card, now, { active = true, timeZone = undefined } = {}) {
430
454
  </span>
431
455
  <span class="plan-usage-badges">${badges}</span>
432
456
  </header>
433
- ${card.message ? `<div class="plan-usage-message">${escapeHtml(card.message)}</div>` : ''}
457
+ ${message ? `<div class="plan-usage-message">${escapeHtml(message)}</div>` : ''}
434
458
  ${metersHtml}
435
459
  ${facts.length ? `<dl class="plan-usage-facts">${facts.map(renderFact).join('')}</dl>` : ''}
436
460
  ${charts.map(renderChart).join('')}
@@ -443,6 +467,14 @@ function renderCard(card, now, { active = true, timeZone = undefined } = {}) {
443
467
  `;
444
468
  }
445
469
 
470
+ /**
471
+ * One card alone, for a live redraw in the open modal (the OpenAI images
472
+ * card on a new spend state).
473
+ */
474
+ export function renderPlanUsageCardHtml(card, { now = new Date(), active = true, timeZone = undefined } = {}) {
475
+ return card ? renderCard(card, now, { active, timeZone }) : '';
476
+ }
477
+
446
478
  function renderTab(card, activeProvider) {
447
479
  const id = normalizeUsageProvider(card.provider);
448
480
  const active = id === activeProvider;
@@ -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
- const result = await updatePwaAppNameSetting(rawValue);
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
- if (seq !== pwaAppNameRequestSeq) return; // a newer edit owns the UI now
366
- pwaAppName = result.appName;
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 } }));
@@ -61,6 +61,11 @@ const RELAY_ERROR_CTAS = Object.freeze({
61
61
  Object.freeze({ action: 'open-claude-api-cap', label: 'Claude API settings' }),
62
62
  SWITCH_TO_SUBSCRIPTION_CTA,
63
63
  ]),
64
+ // An image turn the daily OpenAI image cap stopped before its try: the
65
+ // cap field and "Allow for today" are on the OpenAI tab.
66
+ 'openai-image-daily-cap-reached': Object.freeze([
67
+ Object.freeze({ action: 'open-openai-image-cap', label: 'OpenAI image cap' }),
68
+ ]),
64
69
  // A turn the Claude API refused (claude-api-backend.mjs, describeClaudeApiFailure).
65
70
  'claude-api-key': CLAUDE_API_SETTINGS_CTA,
66
71
  'claude-api-key-source': CLAUDE_API_SETTINGS_CTA,
@@ -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 › General).'}</p>`);
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 › General: the maximum wait ────────────────────────────────────
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;