@oar-sh/oar 0.9.24 → 0.9.25

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 (66) hide show
  1. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +3 -1
  2. package/CHANGELOG.md +142 -0
  3. package/README.md +12 -11
  4. package/package.json +3 -1
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +15 -0
  6. package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
  7. package/server/copilot-worker/copilot-sdk-session-process.mjs +11 -9
  8. package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
  9. package/server/db-schema.mjs +19 -0
  10. package/server/grok-worker/sdk-message-normalizer.mjs +3 -0
  11. package/server/migrations/0039-media-index.mjs +167 -0
  12. package/server/migrations/0040-image-session-defaults.mjs +93 -0
  13. package/server/public/app/api-client.js +58 -0
  14. package/server/public/app/ask-user-view.js +2 -1
  15. package/server/public/app/attachments-view.js +66 -9
  16. package/server/public/app/bootstrap.js +85 -25
  17. package/server/public/app/chat-actions-menu-state.mjs +1 -0
  18. package/server/public/app/context-media-tab.js +143 -0
  19. package/server/public/app/conversation-view.js +90 -5
  20. package/server/public/app/image-composer-ui.js +518 -0
  21. package/server/public/app/image-turn-placeholder.mjs +233 -0
  22. package/server/public/app/journal-view.js +12 -2
  23. package/server/public/app/media-gallery-model.mjs +202 -0
  24. package/server/public/app/media-gallery-ui.js +524 -0
  25. package/server/public/app/media-view.js +79 -0
  26. package/server/public/app/openai-image-composer.mjs +492 -0
  27. package/server/public/app/openai-image-cost-ui.js +426 -0
  28. package/server/public/app/openai-image-spend.mjs +443 -0
  29. package/server/public/app/plan-usage-view.mjs +38 -6
  30. package/server/public/app/relay-error-ctas.mjs +5 -0
  31. package/server/public/app/session-history-ui.js +2 -0
  32. package/server/public/app/settings-modal.js +4 -0
  33. package/server/public/app/socket-handlers.js +26 -0
  34. package/server/public/app/tab-strip-fade.mjs +1 -1
  35. package/server/public/app/viewer-gallery.mjs +23 -1
  36. package/server/public/index.html +383 -7
  37. package/server/relay-tools.md +1 -0
  38. package/server/repositories/image-conversation-repository.mjs +69 -0
  39. package/server/repositories/media-repository.mjs +305 -0
  40. package/server/routes/media-routes.mjs +361 -0
  41. package/server/routes/messages-routes.mjs +1410 -360
  42. package/server/routes/sessions-routes.mjs +361 -14
  43. package/server/server-runtime.mjs +19 -0
  44. package/server/services/database-file.mjs +6 -4
  45. package/server/services/generated-image-cleanup-service.mjs +2 -1
  46. package/server/services/generated-image-files.mjs +110 -0
  47. package/server/services/image-input-paths.mjs +389 -0
  48. package/server/services/image-operation-service.mjs +6 -2
  49. package/server/services/media-extract.mjs +653 -0
  50. package/server/services/media-index-service.mjs +221 -0
  51. package/server/services/openai-image-preview-turns.mjs +330 -0
  52. package/server/services/openai-image-spend-service.mjs +516 -0
  53. package/server/services/openai-image-stream.mjs +170 -0
  54. package/server/services/plan-usage-openai-image.mjs +148 -0
  55. package/server/services/remote-relay-caller-context.mjs +6 -0
  56. package/server/services/remote-relay-dispatcher.mjs +386 -22
  57. package/server/services/remote-relay-registry-service.mjs +3 -1
  58. package/server/services/token-usage-service.mjs +15 -0
  59. package/shared/image-header.mjs +90 -0
  60. package/shared/openai-image-options.mjs +299 -0
  61. package/shared/openai-image-presets.mjs +175 -0
  62. package/shared/openai-image-previews.mjs +40 -0
  63. package/shared/openai-image-prices.mjs +291 -0
  64. package/shared/openai-image-spend.mjs +90 -0
  65. package/shared/remote-relay-contract.mjs +126 -4
  66. package/shared/worker-runtime/direct-openai-image.mjs +96 -50
@@ -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;
@@ -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,
@@ -77,6 +77,8 @@ function overlayDescriptors(closers) {
77
77
  { name: 'inbox', find: () => document.body, isOpen: (el) => !!el?.classList?.contains('inbox-view-active'), close: backToSession(closers.closeInboxView) },
78
78
  // Status the same way.
79
79
  { name: 'status', find: () => document.body, isOpen: (el) => !!el?.classList?.contains('status-view-active'), close: backToSession(closers.closeStatusView) },
80
+ // And the global Media view.
81
+ { name: 'media', find: () => document.body, isOpen: (el) => !!el?.classList?.contains('media-view-active'), close: backToSession(closers.closeMediaView) },
80
82
  { name: 'composer-settings', find: byId('composer-settings'), isOpen: isShown, close: closers.closeComposerSettings },
81
83
  { name: 'chat-actions-menu', find: byId('chat-actions-menu'), isOpen: isShown, close: closers.closeChatActionsMenu },
82
84
  { name: 'summary-modal', find: byId('summary-modal'), isOpen: isVisible, close: closers.closeSummaryModal },
@@ -65,6 +65,7 @@ import { refreshClaudeAuthSection } from './claude-auth-ui.js';
65
65
  import { refreshGrokAuthSection } from './grok-auth-ui.js';
66
66
  import { syncDeepSeekSettingsInputs } from './deepseek-settings-ui.js';
67
67
  import { syncClaudeApiSettingsInputs } from './claude-api-settings-ui.js';
68
+ import { applyOpenAIImageSpendSettings, syncOpenAIImageSpendSettings } from './openai-image-cost-ui.js';
68
69
 
69
70
  export {
70
71
  applyDeepSeekSettingsState,
@@ -642,6 +643,8 @@ function setOpenAISettingsControlsDisabled(disabled) {
642
643
 
643
644
  export function applyOpenAISettingsState(settings = {}, { resetInputs = false } = {}) {
644
645
  ensureOpenAISettingsInputTracking();
646
+ // The daily image cap, when the answer names it (openai-image-cost-ui.js).
647
+ if (typeof document !== 'undefined') applyOpenAIImageSpendSettings(settings || {});
645
648
  openAISettingsState = {
646
649
  configured: settings?.configured === true,
647
650
  enabled: settings?.configured === true && settings?.enabled === true,
@@ -2120,6 +2123,7 @@ export function openSettingsModal(tab, providerTab) {
2120
2123
  openAISettingsInputsDirty = false;
2121
2124
  ensureOpenAISettingsInputTracking();
2122
2125
  void syncOpenAISettingsInputs();
2126
+ void syncOpenAIImageSpendSettings();
2123
2127
  claudeSettingsInputsDirty = false;
2124
2128
  ensureClaudeSettingsInputTracking();
2125
2129
  void syncClaudeSettingsInputs();
@@ -72,6 +72,8 @@ import { applyAgentSdkState } from './agent-sdk-ui.js';
72
72
  import { renderUpdateSection } from './update-ui.js';
73
73
  import { applyHostSuspendState, applyRelayShutdownState } from './host-suspend-ui.js';
74
74
  import { applyClaudeUsageLimit, applyConversationBilling, applyUsageLimitPause } from './usage-limit-ui.js';
75
+ import { applyOpenAIImagePresets, applyOpenAIImageSpendState } from './openai-image-cost-ui.js';
76
+ import { OPENAI_IMAGE_PRESETS_EVENT, OPENAI_IMAGE_USAGE_EVENT } from './openai-image-spend.mjs';
75
77
  import {
76
78
  showThinking,
77
79
  removeThinking,
@@ -111,6 +113,12 @@ import { mergeRelayThoughts } from './relay-thoughts.mjs';
111
113
  import { conversationPayloadCache } from './conversation-payload-cache.mjs';
112
114
  import { planOnlyAfterBroadcast } from './plan-board-shared.mjs';
113
115
  import { noteConversationGone } from './session-history-ui.js';
116
+ import { handleImagePreview, handleImagePreviewReset, handleImageStarted } from './image-turn-placeholder.mjs';
117
+ import {
118
+ OPENAI_IMAGE_PREVIEW_EVENT,
119
+ OPENAI_IMAGE_PREVIEW_RESET_EVENT,
120
+ OPENAI_IMAGE_STARTED_EVENT,
121
+ } from './openai-image-composer.mjs';
114
122
 
115
123
  // A live row of the conversation on screen joins its kept window, so a
116
124
  // switch away and back still shows it. Rows of other conversations do not:
@@ -370,6 +378,9 @@ export async function connectSocket(overrideDeps) {
370
378
  setCliOnline(true);
371
379
  renderConvList();
372
380
  resyncAfterConnect();
381
+ // For views that load from the relay on their own (the media gallery's
382
+ // tiles): what failed while the relay was away can be asked for again.
383
+ window.dispatchEvent(new CustomEvent('oar:relay-connected'));
373
384
  // Every loaded page again, not page one only: events missed while the
374
385
  // socket was down touched rows anywhere in the list.
375
386
  scheduleLoadedConversationsRefresh();
@@ -442,6 +453,15 @@ export async function connectSocket(overrideDeps) {
442
453
  .catch(() => {});
443
454
  }
444
455
  });
456
+ // Today's OpenAI image spend after every booking and every change of the
457
+ // cap or the override (the banner, Settings, Check Usage), and the image
458
+ // presets after every change (the composer's select, Settings).
459
+ socket.on(OPENAI_IMAGE_USAGE_EVENT, (payload) => {
460
+ applyOpenAIImageSpendState(payload);
461
+ });
462
+ socket.on(OPENAI_IMAGE_PRESETS_EVENT, (payload) => {
463
+ applyOpenAIImagePresets(payload);
464
+ });
445
465
  // No view refresh here: the engine switch rebinds nothing — it only changes
446
466
  // which worker the NEXT Copilot conversation spawns.
447
467
  socket.on('copilot_settings_updated', (payload) => {
@@ -654,6 +674,12 @@ export async function connectSocket(overrideDeps) {
654
674
  if (sourceMessageId) clearRelayStreamStateForMessage(sourceMessageId);
655
675
  refreshSessionWorkerStatus().catch(() => {});
656
676
  });
677
+ // A running image turn of the open conversation: its placeholder in the
678
+ // live bubble (timer, size, the newest partials). Other conversations'
679
+ // events are ignored there.
680
+ socket.on(OPENAI_IMAGE_STARTED_EVENT, (payload) => handleImageStarted(payload));
681
+ socket.on(OPENAI_IMAGE_PREVIEW_EVENT, (payload) => handleImagePreview(payload));
682
+ socket.on(OPENAI_IMAGE_PREVIEW_RESET_EVENT, (payload) => handleImagePreviewReset(payload));
657
683
  // A folder's commit attribution changed (any client): an open context modal
658
684
  // re-reads it so its row and effect text match.
659
685
  socket.on('workspace_root_attribution_updated', () => {
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { FONT_SCALE_CHANGE_EVENT } from './font-scaling.js';
8
8
 
9
- export const TAB_STRIP_SELECTOR = '.settings-tab-strip, .plan-usage-tab-strip';
9
+ export const TAB_STRIP_SELECTOR = '.settings-tab-strip, .plan-usage-tab-strip, .context-tab-strip, .media-gallery-tabs, .media-gallery-chips';
10
10
 
11
11
  // Sub-pixel scroll ends (a zoomed page) count as the end.
12
12
  const EDGE_SLACK_PX = 1;