@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,492 @@
1
+ // The composer's image options (Size with Custom…, Quality, Count, Format,
2
+ // Background) and the live placeholder of a running image turn: the pure
3
+ // part, no DOM.
4
+ //
5
+ // The first block mirrors shared/openai-image-options.mjs and the event names
6
+ // of shared/openai-image-previews.mjs, which the browser cannot import (only
7
+ // server/public is served). openai-image-composer.test.mjs asserts the two
8
+ // stay identical.
9
+
10
+ // ---- Mirror of shared/openai-image-options.mjs --------------------------
11
+
12
+ export const OPENAI_IMAGE_PER_TURN_MAX = 4;
13
+
14
+ export const OPENAI_IMAGE_LIMITS = Object.freeze({
15
+ countMax: OPENAI_IMAGE_PER_TURN_MAX,
16
+ inputImagesMax: OPENAI_IMAGE_PER_TURN_MAX,
17
+ inputImageBytesMax: 20 * 1024 * 1024,
18
+ sizeStep: 16,
19
+ edgeMax: 3840,
20
+ pixelsMax: 3840 * 2160,
21
+ aspectMax: 3,
22
+ pixelsMin: 655_360,
23
+ });
24
+
25
+ export const OPENAI_IMAGE_OPTION_KEYS = Object.freeze(['size', 'quality', 'count', 'output_format', 'background']);
26
+ export const OPENAI_IMAGE_OUTPUT_FORMATS = Object.freeze(['png', 'webp', 'jpeg']);
27
+ export const OPENAI_IMAGE_BACKGROUNDS = Object.freeze(['auto', 'transparent', 'opaque']);
28
+
29
+ export const OPENAI_IMAGE_FAMILIES = Object.freeze({
30
+ 'gpt-image': Object.freeze({
31
+ sizes: null,
32
+ qualities: Object.freeze(['auto', 'low', 'medium', 'high']),
33
+ countMax: OPENAI_IMAGE_LIMITS.countMax,
34
+ formats: true,
35
+ }),
36
+ 'dall-e-3': Object.freeze({
37
+ sizes: Object.freeze(['1024x1024', '1792x1024', '1024x1792']),
38
+ qualities: Object.freeze(['auto', 'standard', 'hd']),
39
+ countMax: 1,
40
+ formats: false,
41
+ }),
42
+ 'dall-e-2': Object.freeze({
43
+ sizes: Object.freeze(['256x256', '512x512', '1024x1024']),
44
+ qualities: Object.freeze(['auto', 'standard']),
45
+ countMax: OPENAI_IMAGE_LIMITS.countMax,
46
+ formats: false,
47
+ }),
48
+ });
49
+
50
+ export const OPENAI_IMAGE_EXTENDED_QUALITIES = Object.freeze(['xhigh', 'max']);
51
+ const EXTENDED_QUALITY_MODEL = /^gpt-image-2\.[56](?:-|$)/;
52
+ const PIXEL_FLOOR_MODEL = /^gpt-image-2(?:$|[.-])/;
53
+
54
+ const ALL_QUALITIES = Object.freeze([
55
+ ...new Set([...Object.values(OPENAI_IMAGE_FAMILIES).flatMap((family) => family.qualities), ...OPENAI_IMAGE_EXTENDED_QUALITIES]),
56
+ ]);
57
+
58
+ function bareModelId(model) {
59
+ return String(model || '').trim().toLowerCase().replace(/^openai\//, '');
60
+ }
61
+
62
+ export function openAIImageModelQualities(model = '') {
63
+ const family = openAIImageModelFamily(model);
64
+ if (!family) return [...ALL_QUALITIES];
65
+ const base = OPENAI_IMAGE_FAMILIES[family].qualities;
66
+ return family === 'gpt-image' && EXTENDED_QUALITY_MODEL.test(bareModelId(model)) ? [...base, ...OPENAI_IMAGE_EXTENDED_QUALITIES] : [...base];
67
+ }
68
+
69
+ export function openAIImageModelHasPixelFloor(model = '') {
70
+ return PIXEL_FLOOR_MODEL.test(bareModelId(model));
71
+ }
72
+
73
+ export function openAIImageModelFamily(model = '') {
74
+ const id = bareModelId(model);
75
+ if (id.startsWith('gpt-image-')) return 'gpt-image';
76
+ if (id.startsWith('dall-e-3')) return 'dall-e-3';
77
+ if (id.startsWith('dall-e-')) return 'dall-e-2';
78
+ return null;
79
+ }
80
+
81
+ function invalid(error) {
82
+ return { ok: false, error };
83
+ }
84
+
85
+ function isEmpty(value) {
86
+ return value === undefined || value === null || (typeof value === 'string' && value.trim() === '');
87
+ }
88
+
89
+ export function parseOpenAIImageSize(value) {
90
+ const match = /^\s*(\d{1,5})\s*[x×]\s*(\d{1,5})\s*$/i.exec(String(value ?? ''));
91
+ if (!match) return null;
92
+ const width = Number(match[1]);
93
+ const height = Number(match[2]);
94
+ return width > 0 && height > 0 ? { width, height } : null;
95
+ }
96
+
97
+ export function checkOpenAIImageSize(value, family = null, { model = '' } = {}) {
98
+ const text = String(value ?? '').trim().toLowerCase();
99
+ if (text === 'auto') return { size: 'auto' };
100
+ const parsed = parseOpenAIImageSize(text);
101
+ if (!parsed) return { error: 'size must be "auto" or WIDTHxHEIGHT in pixels, e.g. 1536x1024' };
102
+ const size = `${parsed.width}x${parsed.height}`;
103
+ const fixed = family ? OPENAI_IMAGE_FAMILIES[family]?.sizes : null;
104
+ if (fixed) {
105
+ return fixed.includes(size) ? { size } : { error: `size must be one of: ${fixed.join(', ')} for ${family}` };
106
+ }
107
+ if (!family && Object.values(OPENAI_IMAGE_FAMILIES).some((entry) => entry.sizes?.includes(size))) return { size };
108
+ const { sizeStep, edgeMax, pixelsMax, aspectMax } = OPENAI_IMAGE_LIMITS;
109
+ const { width, height } = parsed;
110
+ if (width % sizeStep || height % sizeStep) return { error: `size ${size}: both edges must be multiples of ${sizeStep}` };
111
+ if (Math.max(width, height) > edgeMax) return { error: `size ${size}: the longer edge may be at most ${edgeMax}` };
112
+ if (width * height > pixelsMax) return { error: `size ${size}: at most ${pixelsMax} pixels (3840x2160)` };
113
+ if (model && openAIImageModelHasPixelFloor(model) && width * height < OPENAI_IMAGE_LIMITS.pixelsMin) {
114
+ return { error: `size ${size}: ${bareModelId(model)} takes ${OPENAI_IMAGE_LIMITS.pixelsMin} to ${pixelsMax} pixels, this is ${width * height}` };
115
+ }
116
+ if (Math.max(width, height) > aspectMax * Math.min(width, height)) {
117
+ return { error: `size ${size}: the aspect ratio must lie between 1:${aspectMax} and ${aspectMax}:1` };
118
+ }
119
+ return { size };
120
+ }
121
+
122
+ export function validateOpenAIImageOptions(input, { model = '', field = 'image' } = {}) {
123
+ if (input === undefined || input === null) return { ok: true, values: {} };
124
+ if (typeof input !== 'object' || Array.isArray(input)) {
125
+ return invalid(`${field} must be an object with any of: ${OPENAI_IMAGE_OPTION_KEYS.join(', ')}`);
126
+ }
127
+ const unknown = Object.keys(input).filter((key) => !OPENAI_IMAGE_OPTION_KEYS.includes(key) && !isEmpty(input[key]));
128
+ if (unknown.length) {
129
+ return invalid(`${field} does not take: ${unknown.slice(0, 10).join(', ')}. It takes: ${OPENAI_IMAGE_OPTION_KEYS.join(', ')}`);
130
+ }
131
+ const family = model ? openAIImageModelFamily(model) : null;
132
+ if (model && !family) return invalid(`${field} options go with an OpenAI image model (gpt-image-*, dall-e-*), not ${String(model).slice(0, 80)}`);
133
+ const rules = family ? OPENAI_IMAGE_FAMILIES[family] : null;
134
+ const values = {};
135
+
136
+ if (!isEmpty(input.size)) {
137
+ const checked = checkOpenAIImageSize(input.size, family, { model });
138
+ if (checked.error) return invalid(`${field}.${checked.error}`);
139
+ values.size = checked.size;
140
+ }
141
+ if (!isEmpty(input.quality)) {
142
+ const quality = String(input.quality).trim().toLowerCase();
143
+ const allowed = openAIImageModelQualities(model);
144
+ if (!allowed.includes(quality)) {
145
+ const extendedNote = family === 'gpt-image' && OPENAI_IMAGE_EXTENDED_QUALITIES.includes(quality)
146
+ ? ` (${OPENAI_IMAGE_EXTENDED_QUALITIES.join(' and ')}: the gpt-image 2.5 and 2.6 models only)`
147
+ : '';
148
+ return invalid(`${field}.quality must be one of: ${allowed.join(', ')}${model ? ` for ${bareModelId(model)}` : ''}${extendedNote}`);
149
+ }
150
+ values.quality = quality;
151
+ }
152
+ if (!isEmpty(input.count)) {
153
+ const count = Number(input.count);
154
+ const max = rules ? rules.countMax : OPENAI_IMAGE_LIMITS.countMax;
155
+ if (typeof input.count === 'boolean' || !Number.isInteger(count) || count < 1 || count > max) {
156
+ return invalid(`${field}.count must be a whole number from 1 to ${max}${family ? ` for ${family}` : ''}`);
157
+ }
158
+ values.count = count;
159
+ }
160
+ for (const [key, allowed] of [['output_format', OPENAI_IMAGE_OUTPUT_FORMATS], ['background', OPENAI_IMAGE_BACKGROUNDS]]) {
161
+ if (isEmpty(input[key])) continue;
162
+ const value = String(input[key]).trim().toLowerCase();
163
+ if (rules && !rules.formats) return invalid(`${field}.${key} goes with gpt-image models only, not ${family}`);
164
+ if (!allowed.includes(value)) return invalid(`${field}.${key} must be one of: ${allowed.join(', ')}`);
165
+ values[key] = value;
166
+ }
167
+ if (values.background === 'transparent' && values.output_format === 'jpeg') {
168
+ return invalid(`${field}.background "transparent" needs output_format png or webp`);
169
+ }
170
+ return { ok: true, values };
171
+ }
172
+
173
+ // ---- Mirror of shared/openai-image-previews.mjs (event names) ------------
174
+
175
+ export const OPENAI_IMAGE_STARTED_EVENT = 'image_started';
176
+ export const OPENAI_IMAGE_PREVIEW_EVENT = 'image_preview';
177
+ export const OPENAI_IMAGE_PREVIEW_RESET_EVENT = 'image_preview_reset';
178
+ export const OPENAI_IMAGE_PREVIEW_RESET_REASONS = Object.freeze(['retry', 'done', 'failed']);
179
+
180
+ // ---- Composer choices ---------------------------------------------------
181
+
182
+ // The Size select's "Custom…" entry: chosen, it opens the W×H input. Never
183
+ // sent: a send while it is selected (no valid size typed) is refused.
184
+ export const IMAGE_SIZE_CUSTOM_VALUE = '__custom__';
185
+
186
+ // The presets the Size select lists for gpt-image models; dall-e models list
187
+ // their family's fixed sizes.
188
+ export const GPT_IMAGE_SIZE_PRESETS = Object.freeze(['auto', '1024x1024', '1536x1024', '1024x1536']);
189
+
190
+ /**
191
+ * What the composer offers for `model`: null for a model that makes no
192
+ * images. `sizes` are the select's fixed entries, `customSize` whether it
193
+ * gets "Custom…", `countMax` the Count select's top (hidden at 1),
194
+ * `formats`/`backgrounds` the Format and Background lists (null: hidden).
195
+ */
196
+ export function imageComposerChoices(model = '') {
197
+ const family = openAIImageModelFamily(model);
198
+ if (!family) return null;
199
+ const rules = OPENAI_IMAGE_FAMILIES[family];
200
+ return {
201
+ family,
202
+ sizes: rules.sizes ? [...rules.sizes] : [...GPT_IMAGE_SIZE_PRESETS],
203
+ customSize: !rules.sizes,
204
+ qualities: openAIImageModelQualities(model),
205
+ countMax: rules.countMax,
206
+ showCount: rules.countMax > 1,
207
+ formats: rules.formats ? [...OPENAI_IMAGE_OUTPUT_FORMATS] : null,
208
+ backgrounds: rules.formats ? [...OPENAI_IMAGE_BACKGROUNDS] : null,
209
+ };
210
+ }
211
+
212
+ /** The catalog's efforts for an image model, kept to what its family takes (all of them when none matches). */
213
+ export function imageQualityOptions(model = '', catalogEfforts = []) {
214
+ const family = openAIImageModelFamily(model);
215
+ const list = Array.isArray(catalogEfforts) ? catalogEfforts : [];
216
+ if (!family) return list;
217
+ const allowed = openAIImageModelQualities(model);
218
+ const kept = list.filter((effort) => allowed.includes(String(effort || '').trim().toLowerCase()));
219
+ if (!kept.length) return list;
220
+ // The catalog lists a gpt-image model's efforts up to high; xhigh and max
221
+ // come from the model's own rules (the 2.5 and 2.6 models).
222
+ const extra = allowed.filter((quality) => OPENAI_IMAGE_EXTENDED_QUALITIES.includes(quality) && !kept.includes(quality));
223
+ return [...kept, ...extra];
224
+ }
225
+
226
+ /**
227
+ * The reasoningEffort a send names for an image model's Quality: xhigh and
228
+ * max are no effort the catalog lists (the relay would refuse them as one),
229
+ * so the send names high there and `image.quality` carries the real value.
230
+ */
231
+ export function imageEffortForSend(model = '', quality = '') {
232
+ const text = String(quality || '').trim().toLowerCase();
233
+ return openAIImageModelFamily(model) === 'gpt-image' && OPENAI_IMAGE_EXTENDED_QUALITIES.includes(text) ? 'high' : text;
234
+ }
235
+
236
+ /**
237
+ * The W×H typed into the custom-size input, checked for the model's family:
238
+ * `{ size }` (normalised "WxH") or `{ error }` to show under the input.
239
+ */
240
+ export function checkCustomImageSize(text, model = '') {
241
+ const family = openAIImageModelFamily(model);
242
+ if (!family) return { error: 'Custom sizes go with an image model' };
243
+ if (OPENAI_IMAGE_FAMILIES[family].sizes) return { error: `${family} takes its listed sizes only` };
244
+ const raw = String(text ?? '').trim();
245
+ if (!raw) return { error: 'Enter WIDTHxHEIGHT, e.g. 2048x1152' };
246
+ if (raw.toLowerCase() === 'auto') return { error: 'Pick "auto" from the list; enter WIDTHxHEIGHT here' };
247
+ const checked = checkOpenAIImageSize(raw, family, { model });
248
+ if (checked.error) return { error: checked.error.replace(/^size /, 'Size ') };
249
+ return { size: checked.size };
250
+ }
251
+
252
+ /** Whether `size` is one of the select's fixed entries for `model`. */
253
+ export function isListedImageSize(size, model = '') {
254
+ const choices = imageComposerChoices(model);
255
+ return !!choices && choices.sizes.includes(String(size || '').trim().toLowerCase());
256
+ }
257
+
258
+ /**
259
+ * The composer's stored values for one conversation, kept to what `model`
260
+ * takes: a size its family refuses, a count above its top, a format on dall-e
261
+ * are dropped (the select's default shows then).
262
+ */
263
+ export function clampImageComposerValues(values = {}, model = '') {
264
+ const choices = imageComposerChoices(model);
265
+ if (!choices || !values || typeof values !== 'object') return {};
266
+ const out = {};
267
+ const size = String(values.size || '').trim().toLowerCase();
268
+ if (size) {
269
+ const checked = checkOpenAIImageSize(size, choices.family);
270
+ if (!checked.error) out.size = checked.size;
271
+ }
272
+ const count = Number(values.count);
273
+ if (Number.isInteger(count) && count >= 1 && count <= choices.countMax) out.count = count;
274
+ if (choices.formats) {
275
+ const format = String(values.output_format || '').trim().toLowerCase();
276
+ if (choices.formats.includes(format)) out.output_format = format;
277
+ const background = String(values.background || '').trim().toLowerCase();
278
+ if (choices.backgrounds.includes(background)) out.background = background;
279
+ }
280
+ return out;
281
+ }
282
+
283
+ /**
284
+ * The `image` field of POST /api/message for the composer's values, checked
285
+ * with validateOpenAIImageOptions for `model`, and the `contextTier` that goes
286
+ * with it (the size, for relays that read it there), and the preset's name
287
+ * as `image.preset` when one is picked. `{ ok:true, image,
288
+ * contextTier }` or `{ ok:false, error, field }` (field: 'size' | 'image').
289
+ * A quality the family does not take (a catalog value) is left to the
290
+ * effort field; Count, Format and Background only for families that take them.
291
+ * `customText` is what the custom size field holds while the select says
292
+ * Custom… (no valid size in it yet): its own reason is the refusal.
293
+ */
294
+ export function buildImageSendOptions({
295
+ model = '',
296
+ size = '',
297
+ customText = '',
298
+ quality = '',
299
+ count = 1,
300
+ outputFormat = '',
301
+ background = '',
302
+ preset = '',
303
+ } = {}) {
304
+ const choices = imageComposerChoices(model);
305
+ if (!choices) return { ok: false, field: 'image', error: 'Image options go with an image model' };
306
+ let sizeText = String(size || '').trim().toLowerCase();
307
+ if (!sizeText || sizeText === IMAGE_SIZE_CUSTOM_VALUE) {
308
+ const typed = String(customText || '').trim();
309
+ if (!typed) return { ok: false, field: 'size', error: 'Enter a custom image size (WIDTHxHEIGHT) or pick one from the list' };
310
+ const checked = checkCustomImageSize(typed, model);
311
+ if (checked.error) return { ok: false, field: 'size', error: checked.error };
312
+ sizeText = checked.size;
313
+ }
314
+ const input = { size: sizeText };
315
+ const qualityText = String(quality || '').trim().toLowerCase();
316
+ if (qualityText && choices.qualities.includes(qualityText)) input.quality = qualityText;
317
+ const countValue = Number(count);
318
+ input.count = Number.isInteger(countValue) && countValue >= 1 ? countValue : 1;
319
+ if (choices.formats) {
320
+ const format = String(outputFormat || '').trim().toLowerCase();
321
+ if (format) input.output_format = format;
322
+ const bg = String(background || '').trim().toLowerCase();
323
+ if (bg) input.background = bg;
324
+ }
325
+ const checked = validateOpenAIImageOptions(input, { model });
326
+ if (!checked.ok) {
327
+ return { ok: false, field: /^image\.size/.test(checked.error) ? 'size' : 'image', error: checked.error };
328
+ }
329
+ // The preset picked goes along by name; the fields above win over it.
330
+ const presetName = String(preset || '').trim().replace(/\s+/g, ' ');
331
+ const image = presetName ? { ...checked.values, preset: presetName } : checked.values;
332
+ return { ok: true, image, contextTier: checked.values.size };
333
+ }
334
+
335
+ // ---- Per-conversation storage (this device) ------------------------------
336
+
337
+ export const IMAGE_COMPOSER_STORAGE_KEY = 'oar_image_composer_options_v1';
338
+ export const IMAGE_COMPOSER_STORE_MAX = 200;
339
+
340
+ /** The stored map `{ [conversationId]: { size, count, output_format, background, preset, at } }` of a JSON text. */
341
+ export function parseImageComposerStore(text) {
342
+ try {
343
+ const parsed = JSON.parse(String(text || ''));
344
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
345
+ } catch {
346
+ return {};
347
+ }
348
+ }
349
+
350
+ /**
351
+ * The store with `values` merged into `conversationId`'s entry; the oldest
352
+ * entries go past IMAGE_COMPOSER_STORE_MAX. Keys outside size, count,
353
+ * output_format, background and preset (a preset's name) are not kept.
354
+ */
355
+ export function updateImageComposerStore(store, conversationId, values = {}, now = Date.now()) {
356
+ const id = String(conversationId || '').trim();
357
+ const next = { ...(store && typeof store === 'object' ? store : {}) };
358
+ if (!id) return next;
359
+ const entry = { ...(next[id] && typeof next[id] === 'object' ? next[id] : {}) };
360
+ for (const key of ['size', 'count', 'output_format', 'background', 'preset']) {
361
+ if (!Object.prototype.hasOwnProperty.call(values, key)) continue;
362
+ if (isEmpty(values[key])) delete entry[key];
363
+ else entry[key] = values[key];
364
+ }
365
+ entry.at = now;
366
+ next[id] = entry;
367
+ const ids = Object.keys(next);
368
+ if (ids.length > IMAGE_COMPOSER_STORE_MAX) {
369
+ ids.sort((a, b) => Number(next[a]?.at || 0) - Number(next[b]?.at || 0));
370
+ for (const old of ids.slice(0, ids.length - IMAGE_COMPOSER_STORE_MAX)) delete next[old];
371
+ }
372
+ return next;
373
+ }
374
+
375
+ // ---- The placeholder of a running image turn -----------------------------
376
+
377
+ /** "m:ss" of an elapsed time in milliseconds (negative counts as 0). */
378
+ export function formatImageElapsed(ms) {
379
+ const seconds = Math.max(0, Math.floor((Number(ms) || 0) / 1000));
380
+ const minutes = Math.floor(seconds / 60);
381
+ return `${minutes}:${String(seconds % 60).padStart(2, '0')}`;
382
+ }
383
+
384
+ /** The CSS aspect-ratio of a size ("1536 / 1024"); square for auto or an unreadable one. */
385
+ export function imageAspectRatio(size) {
386
+ const parsed = parseOpenAIImageSize(size);
387
+ return parsed ? `${parsed.width} / ${parsed.height}` : '1 / 1';
388
+ }
389
+
390
+ /** The placeholder's details line: "1536x1024 · high · 2 images · gpt-image-2". */
391
+ export function imagePlaceholderDetails({ size = '', quality = '', count = 1, model = '' } = {}) {
392
+ const parts = [];
393
+ const sizeText = String(size || '').trim();
394
+ if (sizeText) parts.push(sizeText === 'auto' ? 'auto size' : sizeText);
395
+ const qualityText = String(quality || '').trim();
396
+ if (qualityText) parts.push(qualityText === 'auto' ? 'auto quality' : qualityText);
397
+ const n = Number(count);
398
+ if (Number.isInteger(n) && n > 1) parts.push(`${n} images`);
399
+ const modelText = String(model || '').trim();
400
+ if (modelText) parts.push(modelText);
401
+ return parts.join(' · ');
402
+ }
403
+
404
+ /**
405
+ * The address a preview's `contentUrl` is loaded from: a relay path gets the
406
+ * app's base; http(s) addresses pass; anything else is refused ('').
407
+ */
408
+ export function resolveImagePreviewUrl(contentUrl, base = '') {
409
+ const value = String(contentUrl || '').trim();
410
+ if (!value) return '';
411
+ if (/^https?:\/\//i.test(value)) return value;
412
+ if (!value.startsWith('/') || value.startsWith('//')) return '';
413
+ const normalizedBase = String(base || '').trim().replace(/\/+$/, '');
414
+ if (normalizedBase && value.startsWith(`${normalizedBase}/`)) return value;
415
+ return `${normalizedBase}${value}`;
416
+ }
417
+
418
+ function startedAtMs(value) {
419
+ const ms = Date.parse(String(value || ''));
420
+ return Number.isFinite(ms) ? ms : null;
421
+ }
422
+
423
+ /**
424
+ * The placeholder state of one turn after a socket event, or null when the
425
+ * placeholder goes. `state` is the turn's current state (null for none):
426
+ * { conversationId, messageId, attempt, size, quality, count, model,
427
+ * startedAtMs, retrying, partials: { [imageIndex]: { partialIndex, url } } }
428
+ * `type` is 'started' | 'preview' | 'reset'. An event of an older attempt
429
+ * than the state's is ignored (the state is returned as it is); a preview
430
+ * older than the one shown for its image too.
431
+ */
432
+ export function reduceImageTurnState(state, type, payload = {}, { base = '', now = Date.now() } = {}) {
433
+ const messageId = String(payload?.messageId || '').trim();
434
+ const conversationId = String(payload?.conversationId || '').trim();
435
+ if (!messageId || !conversationId) return state || null;
436
+ const current = state && state.messageId === messageId && state.conversationId === conversationId ? state : null;
437
+ const attempt = Number.isInteger(Number(payload.attempt)) && Number(payload.attempt) > 0 ? Number(payload.attempt) : 1;
438
+ if (current && attempt < current.attempt) return current;
439
+ if (type === 'reset') {
440
+ const reason = String(payload.reason || '').trim();
441
+ if (reason === 'retry') {
442
+ if (!current) return null;
443
+ // `attempt` names the try whose previews went: a late partial of it
444
+ // stays away.
445
+ return { ...current, attempt: Math.max(current.attempt, attempt + 1), retrying: true, partials: {} };
446
+ }
447
+ return null;
448
+ }
449
+ const base0 = current || {
450
+ conversationId,
451
+ messageId,
452
+ attempt,
453
+ size: '',
454
+ quality: '',
455
+ count: 1,
456
+ model: '',
457
+ startedAtMs: null,
458
+ retrying: false,
459
+ partials: {},
460
+ };
461
+ const next = { ...base0, partials: { ...base0.partials } };
462
+ if (attempt > next.attempt) {
463
+ next.attempt = attempt;
464
+ next.partials = {};
465
+ }
466
+ for (const key of ['size', 'quality', 'model']) {
467
+ const value = String(payload[key] || '').trim();
468
+ if (value) next[key] = value;
469
+ }
470
+ const count = Number(payload.count);
471
+ if (Number.isInteger(count) && count >= 1) next.count = Math.min(count, OPENAI_IMAGE_PER_TURN_MAX);
472
+ const started = startedAtMs(payload.startedAt);
473
+ if (started !== null && (next.startedAtMs === null || type === 'started' || started < next.startedAtMs)) {
474
+ next.startedAtMs = started;
475
+ }
476
+ if (next.startedAtMs === null) next.startedAtMs = now;
477
+ // A new try that went out keeps the "trying again" hint until a partial shows.
478
+ if (type === 'started') return next;
479
+ if (type === 'preview') {
480
+ const url = resolveImagePreviewUrl(payload.contentUrl, base);
481
+ const imageIndex = Number(payload.imageIndex);
482
+ if (!url || !Number.isInteger(imageIndex) || imageIndex < 0 || imageIndex >= OPENAI_IMAGE_PER_TURN_MAX) return next;
483
+ const partialIndex = Number.isFinite(Number(payload.partialIndex)) ? Number(payload.partialIndex) : 0;
484
+ const shown = next.partials[imageIndex];
485
+ if (shown && shown.partialIndex > partialIndex) return next;
486
+ next.partials[imageIndex] = { partialIndex, url };
487
+ next.retrying = false;
488
+ if (imageIndex + 1 > next.count) next.count = imageIndex + 1;
489
+ return next;
490
+ }
491
+ return next;
492
+ }