@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,518 @@
1
+ // The composer's image controls for an OpenAI image model: the Size select
2
+ // (the context-tier select, which bootstrap.js hands over for image models)
3
+ // with "Custom…" and its W×H input, and the Count, Format and Background
4
+ // selects. Values come from openai-image-composer.mjs (the browser's copy of
5
+ // the shared validator); the send reads them through
6
+ // readImageComposerSendInput.
7
+ //
8
+ // Size, Count, Format and Background are kept per conversation on this
9
+ // device (localStorage); Quality stays the effort select, stored with the
10
+ // conversation's preferences as before.
11
+ //
12
+ // The Preset select above them lists the relay's image presets (built-in
13
+ // and own, openai-image-cost-ui.js): a pick fills the selects so the values
14
+ // show, and the send names the preset (image.preset) next to the fields,
15
+ // which win over it. "Save as preset…" keeps what the selects show under a
16
+ // name.
17
+
18
+ import { currentConvId } from './store.js';
19
+ import { scheduleComposerSettingsSync } from './composer-settings-ui.js';
20
+ import { saveOpenAIImagePreset } from './api-client.js';
21
+ import {
22
+ applyOpenAIImagePresets,
23
+ currentOpenAIImagePresets,
24
+ loadOpenAIImagePresetList,
25
+ onOpenAIImagePresetsChange,
26
+ } from './openai-image-cost-ui.js';
27
+ import {
28
+ IMAGE_COMPOSER_STORAGE_KEY,
29
+ IMAGE_SIZE_CUSTOM_VALUE,
30
+ buildImageSendOptions,
31
+ checkCustomImageSize,
32
+ clampImageComposerValues,
33
+ imageComposerChoices,
34
+ parseImageComposerStore,
35
+ updateImageComposerStore,
36
+ } from './openai-image-composer.mjs';
37
+ import {
38
+ buildImagePresetToSave,
39
+ describeImagePreset,
40
+ findImagePreset,
41
+ imagePresetComposerValues,
42
+ isImagePresetExistsRefusal,
43
+ } from './openai-image-spend.mjs';
44
+
45
+ const SIZE_SELECT_ID = 'context-tier-select';
46
+ const CUSTOM_ROW_ID = 'image-size-custom-row';
47
+ const CUSTOM_INPUT_ID = 'image-size-custom-input';
48
+ const CUSTOM_ERROR_ID = 'image-size-custom-error';
49
+ const OPTIONS_ROW_ID = 'image-options-row';
50
+ const COUNT_SELECT_ID = 'image-count-select';
51
+ const FORMAT_SELECT_ID = 'image-format-select';
52
+ const BACKGROUND_SELECT_ID = 'image-background-select';
53
+ const PRESET_FIELD_ID = 'image-preset-field';
54
+ const PRESET_SELECT_ID = 'image-preset-select';
55
+ const PRESET_SAVE_ID = 'image-preset-save-btn';
56
+ const PRESET_NOTE_ID = 'image-preset-note';
57
+
58
+ const FORMAT_LABELS = Object.freeze({ png: 'PNG', webp: 'WebP', jpeg: 'JPEG' });
59
+ const BACKGROUND_LABELS = Object.freeze({ auto: 'Auto', transparent: 'Transparent', opaque: 'Opaque' });
60
+
61
+ // The image model and conversation the controls were last built for.
62
+ let activeModel = '';
63
+ let activeConversationId = '';
64
+ let bound = false;
65
+
66
+ function el(id) {
67
+ return typeof document === 'undefined' ? null : document.getElementById(id);
68
+ }
69
+
70
+ function storage() {
71
+ try {
72
+ return typeof localStorage === 'undefined' ? null : localStorage;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ function readStore() {
79
+ return parseImageComposerStore(storage()?.getItem(IMAGE_COMPOSER_STORAGE_KEY) || '');
80
+ }
81
+
82
+ /** The stored Size, Count, Format and Background of a conversation (this device). */
83
+ export function storedImageComposerValues(conversationId) {
84
+ const id = String(conversationId || '').trim();
85
+ if (!id) return {};
86
+ const entry = readStore()[id];
87
+ return entry && typeof entry === 'object' ? entry : {};
88
+ }
89
+
90
+ /** Keeps `values` (any of size, count, output_format, background) for a conversation. */
91
+ export function rememberImageComposerValues(conversationId, values = {}) {
92
+ const id = String(conversationId || '').trim();
93
+ const store = storage();
94
+ if (!id || !store) return;
95
+ try {
96
+ store.setItem(IMAGE_COMPOSER_STORAGE_KEY, JSON.stringify(updateImageComposerStore(readStore(), id, values)));
97
+ } catch {}
98
+ }
99
+
100
+ function fillSelect(select, entries, value) {
101
+ if (!select) return;
102
+ const signature = JSON.stringify(entries);
103
+ if (select.dataset.imageOptions !== signature) {
104
+ select.replaceChildren(...entries.map((entry) => {
105
+ const option = document.createElement('option');
106
+ option.value = entry.value;
107
+ option.textContent = entry.label;
108
+ if (entry.custom) option.dataset.customSize = '1';
109
+ return option;
110
+ }));
111
+ select.dataset.imageOptions = signature;
112
+ }
113
+ if (Array.from(select.options).some((option) => option.value === value)) select.value = value;
114
+ else if (select.options.length) select.value = select.options[0].value;
115
+ }
116
+
117
+ function sizeEntries(choices, customSize) {
118
+ const entries = choices.sizes.map((size) => ({ value: size, label: size }));
119
+ if (!choices.customSize) return entries;
120
+ if (customSize && !choices.sizes.includes(customSize)) entries.push({ value: customSize, label: customSize, custom: true });
121
+ entries.push({ value: IMAGE_SIZE_CUSTOM_VALUE, label: 'Custom…' });
122
+ return entries;
123
+ }
124
+
125
+ function defaultSize(choices) {
126
+ if (choices.sizes.includes('auto')) return 'auto';
127
+ if (choices.sizes.includes('1024x1024')) return '1024x1024';
128
+ return choices.sizes[0] || '';
129
+ }
130
+
131
+ function showCustomRow(show) {
132
+ const row = el(CUSTOM_ROW_ID);
133
+ if (row && row.hidden === show) row.hidden = !show;
134
+ if (!show) setCustomError('');
135
+ }
136
+
137
+ function setCustomError(text) {
138
+ const error = el(CUSTOM_ERROR_ID);
139
+ const input = el(CUSTOM_INPUT_ID);
140
+ const message = String(text || '');
141
+ if (error) {
142
+ if (error.textContent !== message) error.textContent = message;
143
+ error.hidden = !message;
144
+ }
145
+ if (input) {
146
+ if (message) input.setAttribute('aria-invalid', 'true');
147
+ else input.removeAttribute('aria-invalid');
148
+ }
149
+ }
150
+
151
+ function selectedSizeIsCustom(select) {
152
+ const option = select?.options?.[select.selectedIndex];
153
+ return !!option && (option.value === IMAGE_SIZE_CUSTOM_VALUE || option.dataset.customSize === '1');
154
+ }
155
+
156
+ function syncBackgroundForFormat() {
157
+ const format = el(FORMAT_SELECT_ID);
158
+ const background = el(BACKGROUND_SELECT_ID);
159
+ if (!format || !background) return;
160
+ const jpeg = format.value === 'jpeg';
161
+ const transparent = Array.from(background.options).find((option) => option.value === 'transparent');
162
+ if (transparent) {
163
+ transparent.disabled = jpeg;
164
+ transparent.title = jpeg ? 'Transparent needs PNG or WebP' : '';
165
+ }
166
+ if (jpeg && background.value === 'transparent') background.value = 'auto';
167
+ }
168
+
169
+ function setPresetNote(text, state = '') {
170
+ const note = el(PRESET_NOTE_ID);
171
+ if (!note) return;
172
+ const message = String(text || '');
173
+ note.textContent = message;
174
+ note.hidden = !message;
175
+ if (state) note.dataset.state = state;
176
+ else delete note.dataset.state;
177
+ }
178
+
179
+ function presetOption(value, label, title = '') {
180
+ const option = document.createElement('option');
181
+ option.value = value;
182
+ option.textContent = label;
183
+ if (title) option.title = title;
184
+ return option;
185
+ }
186
+
187
+ /** The Preset select: None, the built-ins, the own presets; `selected` by name. */
188
+ function fillPresetSelect(selected) {
189
+ const select = el(PRESET_SELECT_ID);
190
+ if (!select) return;
191
+ const list = currentOpenAIImagePresets();
192
+ const signature = JSON.stringify(list.map((preset) => [preset.name, preset.builtIn, describeImagePreset(preset)]));
193
+ if (select.dataset.presets !== signature) {
194
+ const groups = [['Built-in', list.filter((preset) => preset.builtIn)], ['Own', list.filter((preset) => !preset.builtIn)]]
195
+ .filter(([, entries]) => entries.length)
196
+ .map(([label, entries]) => {
197
+ const group = document.createElement('optgroup');
198
+ group.label = label;
199
+ group.append(...entries.map((preset) => presetOption(preset.name, preset.name, describeImagePreset(preset))));
200
+ return group;
201
+ });
202
+ select.replaceChildren(presetOption('', 'None'), ...groups);
203
+ select.dataset.presets = signature;
204
+ }
205
+ select.value = findImagePreset(list, selected)?.name || '';
206
+ }
207
+
208
+ /**
209
+ * Builds the image controls for `modelId` (an image model) from the
210
+ * conversation's stored values, else what the selects show now, else the
211
+ * family's defaults. The Size select gets "Custom…" for gpt-image models.
212
+ */
213
+ export function syncImageComposerControls(modelId, conversationId = currentConvId) {
214
+ const choices = imageComposerChoices(modelId);
215
+ if (!choices) {
216
+ hideImageComposerControls();
217
+ return;
218
+ }
219
+ bindImageComposerControls();
220
+ const convId = String(conversationId || '').trim();
221
+ // A rebuild for the conversation and model on screen (a poll, a catalog
222
+ // refresh) keeps what the selects show, a Custom… being typed included;
223
+ // another conversation or model starts from its stored values, else the
224
+ // defaults (never the previous chat's choice).
225
+ const sameContext = activeModel === String(modelId || '') && activeConversationId === convId;
226
+ activeModel = String(modelId || '');
227
+ activeConversationId = convId;
228
+ const stored = clampImageComposerValues(storedImageComposerValues(convId), modelId);
229
+ const pick = (storedValue, currentValue, fallback) => (
230
+ sameContext ? (currentValue || storedValue || fallback) : (storedValue || fallback)
231
+ );
232
+ const sizeSelect = el(SIZE_SELECT_ID);
233
+ const countSelect = el(COUNT_SELECT_ID);
234
+ const formatSelect = el(FORMAT_SELECT_ID);
235
+ const backgroundSelect = el(BACKGROUND_SELECT_ID);
236
+
237
+ if (sizeSelect) {
238
+ const current = String(sizeSelect.value || '').trim().toLowerCase();
239
+ // Custom… with nothing valid typed yet only stays for the same chat.
240
+ const currentValid = current === IMAGE_SIZE_CUSTOM_VALUE
241
+ ? choices.customSize && sameContext
242
+ : !!current && !checkCustomImageSize(current, modelId).error;
243
+ const currentSize = choices.sizes.includes(current) || (choices.customSize && currentValid) ? current : '';
244
+ const size = pick(stored.size, currentSize, defaultSize(choices));
245
+ const customSize = size !== IMAGE_SIZE_CUSTOM_VALUE && !choices.sizes.includes(size) ? size : '';
246
+ fillSelect(sizeSelect, sizeEntries(choices, customSize), size);
247
+ sizeSelect.title = 'Image size';
248
+ showCustomRow(selectedSizeIsCustom(sizeSelect));
249
+ const input = el(CUSTOM_INPUT_ID);
250
+ if (input && customSize && document.activeElement !== input) input.value = customSize;
251
+ else if (input && !sameContext && !customSize) input.value = '';
252
+ }
253
+
254
+ const countEntries = Array.from({ length: choices.countMax }, (_, index) => ({ value: String(index + 1), label: String(index + 1) }));
255
+ fillSelect(countSelect, countEntries, String(pick(stored.count, countSelect?.value, 1)));
256
+ const countField = countSelect?.closest('.composer-settings-field');
257
+ if (countField) countField.hidden = !choices.showCount;
258
+
259
+ if (choices.formats) {
260
+ fillSelect(formatSelect, choices.formats.map((value) => ({ value, label: FORMAT_LABELS[value] || value })), pick(stored.output_format, formatSelect?.value, 'png'));
261
+ fillSelect(backgroundSelect, choices.backgrounds.map((value) => ({ value, label: BACKGROUND_LABELS[value] || value })), pick(stored.background, backgroundSelect?.value, 'auto'));
262
+ syncBackgroundForFormat();
263
+ }
264
+ for (const select of [formatSelect, backgroundSelect]) {
265
+ const field = select?.closest('.composer-settings-field');
266
+ if (field) field.hidden = !choices.formats;
267
+ }
268
+ const row = el(OPTIONS_ROW_ID);
269
+ if (row) row.hidden = !choices.showCount && !choices.formats;
270
+
271
+ const presetField = el(PRESET_FIELD_ID);
272
+ if (presetField) presetField.hidden = false;
273
+ const storedPreset = String(storedImageComposerValues(convId).preset || '');
274
+ const currentPreset = String(el(PRESET_SELECT_ID)?.value || '');
275
+ fillPresetSelect(sameContext ? (currentPreset || storedPreset) : storedPreset);
276
+ if (!sameContext) setPresetNote('');
277
+ void loadOpenAIImagePresetList();
278
+ }
279
+
280
+ /** A model that makes no images: the image controls go. */
281
+ export function hideImageComposerControls() {
282
+ activeModel = '';
283
+ activeConversationId = '';
284
+ const row = el(OPTIONS_ROW_ID);
285
+ if (row) row.hidden = true;
286
+ const presetField = el(PRESET_FIELD_ID);
287
+ if (presetField) presetField.hidden = true;
288
+ setPresetNote('');
289
+ showCustomRow(false);
290
+ const sizeSelect = el(SIZE_SELECT_ID);
291
+ if (sizeSelect) delete sizeSelect.dataset.imageOptions;
292
+ }
293
+
294
+ /**
295
+ * What the send needs from the controls: `{ size, customText, quality,
296
+ * count, outputFormat, background, preset }` (strings; size '__custom__'
297
+ * while no valid custom size is typed, `customText` then the typed text;
298
+ * preset '' for none). Hidden selects count as not given.
299
+ */
300
+ export function readImageComposerSendInput() {
301
+ // Hidden for this model: the field or the whole row (the panel itself is
302
+ // closed most of the time and does not count).
303
+ const visible = (select) => !!select
304
+ && !select.closest('.composer-settings-field')?.hidden
305
+ && !el(OPTIONS_ROW_ID)?.hidden;
306
+ const count = el(COUNT_SELECT_ID);
307
+ const format = el(FORMAT_SELECT_ID);
308
+ const background = el(BACKGROUND_SELECT_ID);
309
+ const presetField = el(PRESET_FIELD_ID);
310
+ const size = String(el(SIZE_SELECT_ID)?.value || '').trim().toLowerCase();
311
+ return {
312
+ size,
313
+ // While the select says Custom…, what is typed: its own reason refuses it.
314
+ customText: size === IMAGE_SIZE_CUSTOM_VALUE ? String(el(CUSTOM_INPUT_ID)?.value || '') : '',
315
+ quality: String(el('reasoning-effort-select')?.value || '').trim().toLowerCase(),
316
+ count: visible(count) ? Number(count.value) || 1 : 1,
317
+ outputFormat: visible(format) ? String(format.value || '') : '',
318
+ background: visible(background) ? String(background.value || '') : '',
319
+ preset: presetField && !presetField.hidden ? String(el(PRESET_SELECT_ID)?.value || '') : '',
320
+ };
321
+ }
322
+
323
+ /**
324
+ * A preset picked: the selects show its values (what the model takes; the
325
+ * note names what it does not), and the conversation keeps the pick.
326
+ */
327
+ export function applyImagePresetPick(name) {
328
+ if (!activeModel) return;
329
+ const choices = imageComposerChoices(activeModel);
330
+ const preset = findImagePreset(currentOpenAIImagePresets(), name);
331
+ if (!choices || !preset) {
332
+ rememberImageComposerValues(currentConvId, { preset: '' });
333
+ setPresetNote('');
334
+ notifyLabel();
335
+ return;
336
+ }
337
+ const effort = el('reasoning-effort-select');
338
+ const format = el(FORMAT_SELECT_ID);
339
+ const { values, skipped } = imagePresetComposerValues(preset, activeModel, {
340
+ qualities: effort ? Array.from(effort.options).map((option) => option.value) : null,
341
+ currentFormat: format?.value || '',
342
+ });
343
+ const remembered = { preset: preset.name };
344
+ const sizeSelect = el(SIZE_SELECT_ID);
345
+ if (values.size && sizeSelect) {
346
+ const customSize = choices.sizes.includes(values.size) ? '' : values.size;
347
+ fillSelect(sizeSelect, sizeEntries(choices, customSize), values.size);
348
+ showCustomRow(selectedSizeIsCustom(sizeSelect));
349
+ const input = el(CUSTOM_INPUT_ID);
350
+ if (input && customSize) input.value = customSize;
351
+ setCustomError('');
352
+ remembered.size = values.size;
353
+ }
354
+ const count = el(COUNT_SELECT_ID);
355
+ if (values.count && count) {
356
+ count.value = String(values.count);
357
+ remembered.count = values.count;
358
+ }
359
+ if (values.output_format && format) {
360
+ format.value = values.output_format;
361
+ remembered.output_format = values.output_format;
362
+ }
363
+ const background = el(BACKGROUND_SELECT_ID);
364
+ if (values.background && background) {
365
+ background.value = values.background;
366
+ remembered.background = values.background;
367
+ }
368
+ syncBackgroundForFormat();
369
+ rememberImageComposerValues(currentConvId, remembered);
370
+ // Quality is the effort select: its own change handler keeps it with the
371
+ // conversation's preferences.
372
+ if (values.quality && effort && effort.value !== values.quality) {
373
+ effort.value = values.quality;
374
+ effort.dispatchEvent(new Event('change', { bubbles: true }));
375
+ }
376
+ setPresetNote(skipped.length
377
+ ? `Not for ${activeModel}: ${skipped.join(', ')}. Those fields keep what they show.`
378
+ : `${preset.name}: ${describeImagePreset(preset)}`);
379
+ notifyLabel();
380
+ }
381
+
382
+ function askToReplacePreset(name) {
383
+ return confirm(`A preset "${name}" exists. Replace it with the options shown now?`);
384
+ }
385
+
386
+ /**
387
+ * "Save as preset…": what the selects show (checked for the model), under a
388
+ * name asked for; an own preset of that name is replaced only when the user
389
+ * says so. Refusals show under the Preset select.
390
+ */
391
+ export async function saveImageComposerPreset() {
392
+ if (!activeModel) return;
393
+ const built = buildImageSendOptions({ model: activeModel, ...readImageComposerSendInput(), preset: '' });
394
+ if (!built.ok) {
395
+ if (built.field === 'size') flagImageComposerSizeError(built.error);
396
+ setPresetNote(built.error, 'error');
397
+ return;
398
+ }
399
+ const picked = findImagePreset(currentOpenAIImagePresets(), el(PRESET_SELECT_ID)?.value || '');
400
+ const name = prompt('Name of the image preset', picked && !picked.builtIn ? picked.name : '');
401
+ if (name === null) return;
402
+ const checked = buildImagePresetToSave(name, built.image);
403
+ if (!checked.ok) {
404
+ setPresetNote(checked.error, 'error');
405
+ return;
406
+ }
407
+ const own = currentOpenAIImagePresets().filter((preset) => !preset.builtIn);
408
+ const existing = findImagePreset(own, checked.preset.name);
409
+ if (existing && !askToReplacePreset(existing.name)) return;
410
+ const button = el(PRESET_SAVE_ID);
411
+ if (button) button.disabled = true;
412
+ setPresetNote('');
413
+ try {
414
+ let answer;
415
+ try {
416
+ answer = await saveOpenAIImagePreset(checked.preset, { replace: !!existing });
417
+ } catch (error) {
418
+ // Saved from another page in the meantime: asked once more.
419
+ if (existing || !isImagePresetExistsRefusal(error)) throw error;
420
+ if (!askToReplacePreset(checked.preset.name)) return;
421
+ answer = await saveOpenAIImagePreset(checked.preset, { replace: true });
422
+ }
423
+ if (!answer) throw new Error('Could not save the preset: the relay is not reachable.');
424
+ applyOpenAIImagePresets(answer);
425
+ fillPresetSelect(checked.preset.name);
426
+ rememberImageComposerValues(currentConvId, { preset: checked.preset.name });
427
+ setPresetNote(`Saved as preset "${checked.preset.name}".`);
428
+ notifyLabel();
429
+ } catch (error) {
430
+ setPresetNote(error?.message || 'Could not save the preset.', 'error');
431
+ } finally {
432
+ if (button) button.disabled = false;
433
+ }
434
+ }
435
+
436
+ /** A send refused for its size: the custom input opens with the reason under it. */
437
+ export function flagImageComposerSizeError(text) {
438
+ const select = el(SIZE_SELECT_ID);
439
+ if (select && select.value !== IMAGE_SIZE_CUSTOM_VALUE && !selectedSizeIsCustom(select)) return;
440
+ showCustomRow(true);
441
+ setCustomError(text);
442
+ }
443
+
444
+ function applyCustomInput({ persist = true } = {}) {
445
+ const select = el(SIZE_SELECT_ID);
446
+ const input = el(CUSTOM_INPUT_ID);
447
+ if (!select || !input || !activeModel) return;
448
+ const checked = checkCustomImageSize(input.value, activeModel);
449
+ const choices = imageComposerChoices(activeModel);
450
+ if (!choices) return;
451
+ if (checked.error) {
452
+ // Nothing valid to send: the select says Custom… until the input is.
453
+ select.value = IMAGE_SIZE_CUSTOM_VALUE;
454
+ setCustomError(checked.error);
455
+ } else {
456
+ const customSize = choices.sizes.includes(checked.size) ? '' : checked.size;
457
+ fillSelect(select, sizeEntries(choices, customSize), checked.size);
458
+ setCustomError('');
459
+ if (persist) rememberImageComposerValues(currentConvId, { size: checked.size });
460
+ }
461
+ notifyLabel();
462
+ }
463
+
464
+ // The composer label reads the selects; a value set from the input fires no
465
+ // change event, so it is told here.
466
+ function notifyLabel() {
467
+ scheduleComposerSettingsSync();
468
+ }
469
+
470
+ function bindImageComposerControls() {
471
+ if (bound || typeof document === 'undefined') return;
472
+ const sizeSelect = el(SIZE_SELECT_ID);
473
+ const input = el(CUSTOM_INPUT_ID);
474
+ if (!sizeSelect || !input) return;
475
+ bound = true;
476
+ sizeSelect.addEventListener('change', () => {
477
+ if (!activeModel) return;
478
+ if (sizeSelect.value === IMAGE_SIZE_CUSTOM_VALUE) {
479
+ showCustomRow(true);
480
+ const previous = Array.from(sizeSelect.options).find((option) => option.dataset.customSize === '1')?.value || '';
481
+ if (!input.value && previous) input.value = previous;
482
+ if (input.value) applyCustomInput();
483
+ else setCustomError('Enter WIDTHxHEIGHT, e.g. 2048x1152');
484
+ try { input.focus({ preventScroll: true }); } catch {}
485
+ return;
486
+ }
487
+ showCustomRow(selectedSizeIsCustom(sizeSelect));
488
+ rememberImageComposerValues(currentConvId, { size: sizeSelect.value });
489
+ });
490
+ input.addEventListener('input', () => applyCustomInput());
491
+ input.addEventListener('keydown', (event) => {
492
+ // Enter in the size field must not send the message.
493
+ if (event.key === 'Enter') event.preventDefault();
494
+ });
495
+ el(COUNT_SELECT_ID)?.addEventListener('change', (event) => {
496
+ rememberImageComposerValues(currentConvId, { count: Number(event.target.value) || 1 });
497
+ notifyLabel();
498
+ });
499
+ el(FORMAT_SELECT_ID)?.addEventListener('change', (event) => {
500
+ syncBackgroundForFormat();
501
+ rememberImageComposerValues(currentConvId, {
502
+ output_format: event.target.value,
503
+ background: el(BACKGROUND_SELECT_ID)?.value || '',
504
+ });
505
+ notifyLabel();
506
+ });
507
+ el(BACKGROUND_SELECT_ID)?.addEventListener('change', (event) => {
508
+ rememberImageComposerValues(currentConvId, { background: event.target.value });
509
+ notifyLabel();
510
+ });
511
+ el(PRESET_SELECT_ID)?.addEventListener('change', (event) => applyImagePresetPick(event.target.value));
512
+ el(PRESET_SAVE_ID)?.addEventListener('click', () => { void saveImageComposerPreset(); });
513
+ // A list read or changed (here, in Settings, on another page's save):
514
+ // the select follows, keeping its pick.
515
+ onOpenAIImagePresetsChange(() => {
516
+ if (activeModel) fillPresetSelect(el(PRESET_SELECT_ID)?.value || storedImageComposerValues(activeConversationId).preset || '');
517
+ });
518
+ }