@oar-sh/oar 0.9.23 → 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.
- package/.github/extensions/web-relay/skills/reasoning-stream.mjs +3 -1
- package/CHANGELOG.md +307 -0
- package/README.md +12 -11
- package/bin/oar.js +54 -4
- package/package.json +11 -7
- package/server/agent-sdk-loader.mjs +116 -0
- package/server/agent-sdks/claude/entry.mjs +4 -0
- package/server/agent-sdks/claude/package-lock.json +1438 -0
- package/server/agent-sdks/claude/package.json +13 -0
- package/server/agent-sdks/cursor/entry-sqlite.mjs +2 -0
- package/server/agent-sdks/cursor/entry.mjs +3 -0
- package/server/agent-sdks/cursor/package-lock.json +171 -0
- package/server/agent-sdks/cursor/package.json +11 -0
- package/server/agent-sdks/koffi/entry.cjs +2 -0
- package/server/agent-sdks/koffi/package-lock.json +368 -0
- package/server/agent-sdks/koffi/package.json +10 -0
- package/server/claude-worker/claude-preview-tool.mjs +5 -2
- package/server/claude-worker/claude-remote-relay-tool.mjs +20 -2
- package/server/claude-worker/claude-sdk-adapter.mjs +4 -1
- package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +13 -8
- package/server/cursor-worker/cursor-sdk-adapter.mjs +5 -3
- package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/sdk-message-normalizer.mjs +3 -0
- package/server/migrations/0039-media-index.mjs +167 -0
- package/server/migrations/0040-image-session-defaults.mjs +93 -0
- package/server/public/app/agent-sdk-ui.js +349 -0
- package/server/public/app/api-client.js +149 -8
- package/server/public/app/ask-user-view.js +2 -1
- package/server/public/app/attachments-view.js +66 -9
- package/server/public/app/bootstrap.js +289 -94
- package/server/public/app/chat-actions-menu-state.mjs +1 -0
- package/server/public/app/context-media-tab.js +143 -0
- package/server/public/app/conversation-view.js +90 -5
- package/server/public/app/image-composer-ui.js +518 -0
- package/server/public/app/image-turn-placeholder.mjs +233 -0
- package/server/public/app/journal-view.js +12 -2
- package/server/public/app/media-gallery-model.mjs +202 -0
- package/server/public/app/media-gallery-ui.js +524 -0
- package/server/public/app/media-view.js +79 -0
- package/server/public/app/model-refresh-ui.mjs +413 -0
- package/server/public/app/no-conversation-state.mjs +5 -2
- package/server/public/app/openai-image-composer.mjs +492 -0
- package/server/public/app/openai-image-cost-ui.js +426 -0
- package/server/public/app/openai-image-spend.mjs +443 -0
- package/server/public/app/plan-usage-view.mjs +38 -6
- package/server/public/app/provider-readiness.mjs +67 -4
- package/server/public/app/relay-error-ctas.mjs +5 -0
- package/server/public/app/session-history-ui.js +2 -0
- package/server/public/app/settings-modal.js +171 -0
- package/server/public/app/socket-handlers.js +47 -0
- package/server/public/app/tab-strip-fade.mjs +1 -1
- package/server/public/app/viewer-gallery.mjs +23 -1
- package/server/public/index.html +478 -9
- package/server/relay-tools.md +1 -0
- package/server/repositories/image-conversation-repository.mjs +69 -0
- package/server/repositories/media-repository.mjs +305 -0
- package/server/routes/agent-sdk-routes.mjs +73 -0
- package/server/routes/media-routes.mjs +361 -0
- package/server/routes/messages-routes.mjs +1467 -343
- package/server/routes/sessions-routes.mjs +596 -123
- package/server/server-runtime.mjs +414 -81
- package/server/services/about-service.mjs +26 -6
- package/server/services/agent-sdk-launch-gate.mjs +124 -0
- package/server/services/agent-sdk-paths.mjs +307 -0
- package/server/services/agent-sdk-store.mjs +1211 -0
- package/server/services/claude-api-settings-service.mjs +54 -17
- package/server/services/cli-install-service.mjs +3 -2
- package/server/services/copilot-model-discovery-service.mjs +7 -1
- package/server/services/database-file.mjs +23 -5
- package/server/services/deepseek-settings-service.mjs +58 -20
- package/server/services/earlier-database-service.mjs +4 -2
- package/server/services/generated-image-cleanup-service.mjs +2 -1
- package/server/services/generated-image-files.mjs +110 -0
- package/server/services/image-input-paths.mjs +389 -0
- package/server/services/image-operation-service.mjs +6 -2
- package/server/services/media-extract.mjs +653 -0
- package/server/services/media-index-service.mjs +221 -0
- package/server/services/model-refresh-scheduler.mjs +153 -0
- package/server/services/model-variant-catalog-service.mjs +141 -9
- package/server/services/oar-cli-helpers.mjs +72 -9
- package/server/services/oar-state-migration-service.mjs +2 -12
- package/server/services/oar-state-root.mjs +17 -0
- package/server/services/openai-image-preview-turns.mjs +330 -0
- package/server/services/openai-image-spend-service.mjs +516 -0
- package/server/services/openai-image-stream.mjs +170 -0
- package/server/services/plan-usage-openai-image.mjs +148 -0
- package/server/services/provider-model-refresh.mjs +326 -0
- package/server/services/provider-model-state.mjs +395 -0
- package/server/services/relay-singleton-guard.mjs +72 -10
- package/server/services/remote-relay-caller-context.mjs +6 -0
- package/server/services/remote-relay-dispatcher.mjs +386 -22
- package/server/services/remote-relay-registry-service.mjs +3 -1
- package/server/services/session-worker-launch-service.mjs +13 -0
- package/server/services/session-worker-supervisor-service.mjs +19 -1
- package/server/services/session-worker-websocket-service.mjs +15 -0
- package/server/services/token-usage-service.mjs +15 -0
- package/server/services/update-install-service.mjs +2 -2
- package/server/services/worker-launch-note-service.mjs +14 -2
- package/server/services/worker-recycle-service.mjs +145 -11
- package/shared/image-header.mjs +90 -0
- package/shared/model-refresh-schedule.mjs +120 -0
- package/shared/openai-image-options.mjs +299 -0
- package/shared/openai-image-presets.mjs +175 -0
- package/shared/openai-image-previews.mjs +40 -0
- package/shared/openai-image-prices.mjs +291 -0
- package/shared/openai-image-spend.mjs +90 -0
- package/shared/remote-relay-contract.mjs +126 -4
- package/shared/worker-runtime/direct-openai-image.mjs +98 -48
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// "Check for new models" (Settings → General → Models): the relay asks each
|
|
2
|
+
// enabled provider for its model list on a timer, skipping a provider whose
|
|
3
|
+
// list was refreshed recently by anything (the Refresh button, a settings
|
|
4
|
+
// save, a login, an install, the boot). Relay-wide app_settings keys:
|
|
5
|
+
//
|
|
6
|
+
// model_refresh_auto 'true' / 'false'; absent = on
|
|
7
|
+
// model_refresh_interval_hours 6 / 12 / 24 / 72 / 168; absent = 24
|
|
8
|
+
// model_refresh_excluded JSON list of provider ids left out; absent = none
|
|
9
|
+
// model_refresh_last JSON { <provider>: { at, ok, error, lastOkAt, lastFailureAt } }
|
|
10
|
+
|
|
11
|
+
export const MODEL_REFRESH_AUTO_SETTING_KEY = 'model_refresh_auto';
|
|
12
|
+
export const MODEL_REFRESH_INTERVAL_HOURS_SETTING_KEY = 'model_refresh_interval_hours';
|
|
13
|
+
export const MODEL_REFRESH_EXCLUDED_SETTING_KEY = 'model_refresh_excluded';
|
|
14
|
+
export const MODEL_REFRESH_LAST_SETTING_KEY = 'model_refresh_last';
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_MODEL_REFRESH_AUTO = true;
|
|
17
|
+
export const MODEL_REFRESH_INTERVAL_HOURS_CHOICES = Object.freeze([6, 12, 24, 72, 168]);
|
|
18
|
+
export const DEFAULT_MODEL_REFRESH_INTERVAL_HOURS = 24;
|
|
19
|
+
// A failed check is tried again at most this often, whatever the interval.
|
|
20
|
+
export const MODEL_REFRESH_FAILURE_RETRY_MS = 60 * 60 * 1000;
|
|
21
|
+
// The first look a minute after boot (overdue providers are checked then),
|
|
22
|
+
// then every quarter of an hour.
|
|
23
|
+
export const MODEL_REFRESH_FIRST_TICK_MS = 60 * 1000;
|
|
24
|
+
export const MODEL_REFRESH_TICK_MS = 15 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
// The providers in the order Settings and Select Models list them; `github`
|
|
27
|
+
// is Copilot. `tab` is the provider's Select Models tab.
|
|
28
|
+
export const MODEL_REFRESH_PROVIDERS = Object.freeze([
|
|
29
|
+
Object.freeze({ id: 'github', label: 'Copilot', tab: 'copilot' }),
|
|
30
|
+
Object.freeze({ id: 'openai', label: 'OpenAI', tab: 'openai' }),
|
|
31
|
+
Object.freeze({ id: 'claude', label: 'Claude', tab: 'anthropic' }),
|
|
32
|
+
Object.freeze({ id: 'claude-api', label: 'Claude API', tab: 'claude-api' }),
|
|
33
|
+
Object.freeze({ id: 'cursor', label: 'Cursor', tab: 'cursor' }),
|
|
34
|
+
Object.freeze({ id: 'grok', label: 'Grok', tab: 'grok' }),
|
|
35
|
+
Object.freeze({ id: 'deepseek', label: 'DeepSeek', tab: 'deepseek' }),
|
|
36
|
+
]);
|
|
37
|
+
const PROVIDER_IDS = new Set(MODEL_REFRESH_PROVIDERS.map((provider) => provider.id));
|
|
38
|
+
|
|
39
|
+
export function modelRefreshProvider(id) {
|
|
40
|
+
return MODEL_REFRESH_PROVIDERS.find((provider) => provider.id === String(id || '').trim().toLowerCase()) || null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** An unset or unreadable setting is on. */
|
|
44
|
+
export function readModelRefreshAutoSetting(storedValue) {
|
|
45
|
+
const text = String(storedValue ?? '').trim().toLowerCase();
|
|
46
|
+
if (text === 'false') return false;
|
|
47
|
+
if (text === 'true') return true;
|
|
48
|
+
return DEFAULT_MODEL_REFRESH_AUTO;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function parseModelRefreshAutoUpdate(value) {
|
|
52
|
+
if (typeof value !== 'boolean') return { ok: false, error: 'auto must be true or false' };
|
|
53
|
+
return { ok: true, auto: value };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** One of the choices; an unset or unknown value is the default. */
|
|
57
|
+
export function readModelRefreshIntervalHoursSetting(storedValue) {
|
|
58
|
+
const hours = Number(String(storedValue ?? '').trim());
|
|
59
|
+
return MODEL_REFRESH_INTERVAL_HOURS_CHOICES.includes(hours) ? hours : DEFAULT_MODEL_REFRESH_INTERVAL_HOURS;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function parseModelRefreshIntervalHoursUpdate(value) {
|
|
63
|
+
const hours = typeof value === 'number' ? value : (typeof value === 'string' && value.trim() ? Number(value.trim()) : NaN);
|
|
64
|
+
if (!MODEL_REFRESH_INTERVAL_HOURS_CHOICES.includes(hours)) {
|
|
65
|
+
return { ok: false, error: `intervalHours must be one of ${MODEL_REFRESH_INTERVAL_HOURS_CHOICES.join(', ')}` };
|
|
66
|
+
}
|
|
67
|
+
return { ok: true, intervalHours: hours };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function knownProviderIds(values) {
|
|
71
|
+
return [...new Set((Array.isArray(values) ? values : [])
|
|
72
|
+
.map((value) => String(value || '').trim().toLowerCase())
|
|
73
|
+
.filter((value) => PROVIDER_IDS.has(value)))];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The providers left out of the check; unknown ids are dropped. */
|
|
77
|
+
export function readModelRefreshExcludedSetting(storedValue) {
|
|
78
|
+
try {
|
|
79
|
+
return knownProviderIds(JSON.parse(String(storedValue || '[]')));
|
|
80
|
+
} catch {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function parseModelRefreshExcludedUpdate(value) {
|
|
86
|
+
if (!Array.isArray(value)) return { ok: false, error: 'excluded must be a list of provider ids' };
|
|
87
|
+
const unknown = value.map((entry) => String(entry || '').trim().toLowerCase()).filter((entry) => entry && !PROVIDER_IDS.has(entry));
|
|
88
|
+
if (unknown.length) return { ok: false, error: `Unknown provider: ${unknown[0]}` };
|
|
89
|
+
return { ok: true, excluded: knownProviderIds(value) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isoOrNull(value) {
|
|
93
|
+
const text = String(value ?? '').trim();
|
|
94
|
+
return text && Number.isFinite(Date.parse(text)) ? new Date(text).toISOString() : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The last refresh of each provider; entries that cannot be read are dropped. */
|
|
98
|
+
export function readModelRefreshLastSetting(storedValue) {
|
|
99
|
+
let parsed;
|
|
100
|
+
try {
|
|
101
|
+
parsed = JSON.parse(String(storedValue || '{}'));
|
|
102
|
+
} catch {
|
|
103
|
+
return {};
|
|
104
|
+
}
|
|
105
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
|
106
|
+
const out = {};
|
|
107
|
+
for (const [id, entry] of Object.entries(parsed)) {
|
|
108
|
+
if (!PROVIDER_IDS.has(id) || !entry || typeof entry !== 'object') continue;
|
|
109
|
+
const at = isoOrNull(entry.at);
|
|
110
|
+
if (!at) continue;
|
|
111
|
+
out[id] = {
|
|
112
|
+
at,
|
|
113
|
+
ok: entry.ok === true,
|
|
114
|
+
error: entry.ok === true ? null : (String(entry.error || '').trim().slice(0, 300) || null),
|
|
115
|
+
lastOkAt: isoOrNull(entry.lastOkAt),
|
|
116
|
+
lastFailureAt: isoOrNull(entry.lastFailureAt),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// The options of an OpenAI image turn (gpt-image-*, dall-e-*): size, quality,
|
|
2
|
+
// count, output format and background, checked in one place for the
|
|
3
|
+
// relay_tool contract, the relay's routes and the composer.
|
|
4
|
+
//
|
|
5
|
+
// SDK-free and side-effect free: imported by the server, the session workers
|
|
6
|
+
// and the browser.
|
|
7
|
+
|
|
8
|
+
// The models a new image session gets when the caller names none, in this
|
|
9
|
+
// order, the first the relay's OpenAI list has; else the first gpt-image id
|
|
10
|
+
// listed. A newer model the list offers (2.6 and up) is never picked by
|
|
11
|
+
// itself: a session gets one only when it is named.
|
|
12
|
+
export const OPENAI_IMAGE_DEFAULT_MODELS = Object.freeze([
|
|
13
|
+
'gpt-image-2.5-flare',
|
|
14
|
+
'gpt-image-2.5-flare-2026-09-08',
|
|
15
|
+
'gpt-image-2',
|
|
16
|
+
]);
|
|
17
|
+
export const OPENAI_IMAGE_DEFAULT_MODEL = OPENAI_IMAGE_DEFAULT_MODELS[0];
|
|
18
|
+
// An image session asked for without a model on a relay whose OpenAI list
|
|
19
|
+
// has no gpt-image model at all.
|
|
20
|
+
export const OPENAI_IMAGE_NO_MODEL_CODE = 'OPENAI_IMAGE_NO_MODEL';
|
|
21
|
+
|
|
22
|
+
// Images one turn may produce (count) and attach (images): one number for both.
|
|
23
|
+
export const OPENAI_IMAGE_PER_TURN_MAX = 4;
|
|
24
|
+
|
|
25
|
+
// POST /api/message refuses an image path, a file that is no image, a model
|
|
26
|
+
// that makes no images or an option that model does not take with this code
|
|
27
|
+
// (400); nothing is queued then.
|
|
28
|
+
export const IMAGE_INPUT_REFUSED_CODE = 'IMAGE_INPUT_REFUSED';
|
|
29
|
+
|
|
30
|
+
export const OPENAI_IMAGE_LIMITS = Object.freeze({
|
|
31
|
+
countMax: OPENAI_IMAGE_PER_TURN_MAX,
|
|
32
|
+
inputImagesMax: OPENAI_IMAGE_PER_TURN_MAX,
|
|
33
|
+
// One attached image, read from disk on the target relay.
|
|
34
|
+
inputImageBytesMax: 20 * 1024 * 1024,
|
|
35
|
+
// gpt-image sizes: both edges a multiple of sizeStep, the longer at most
|
|
36
|
+
// edgeMax, the area at most pixelsMax (3840x2160), the long edge at most
|
|
37
|
+
// aspectMax times the short one.
|
|
38
|
+
sizeStep: 16,
|
|
39
|
+
edgeMax: 3840,
|
|
40
|
+
pixelsMax: 3840 * 2160,
|
|
41
|
+
aspectMax: 3,
|
|
42
|
+
// The fewest pixels gpt-image-2 and the 2.5 models take ("The total pixel
|
|
43
|
+
// count must be between 655,360 and 8,294,400", OpenAI's image-generation
|
|
44
|
+
// guide, "Size and quality options" and the gpt-image-2 settings, read
|
|
45
|
+
// 2026-11-08). It goes by family: gpt-image-2 and every later 2.x model
|
|
46
|
+
// (2.6 included); earlier models keep their sizes.
|
|
47
|
+
pixelsMin: 655_360,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export const OPENAI_IMAGE_OPTION_KEYS = Object.freeze(['size', 'quality', 'count', 'output_format', 'background']);
|
|
51
|
+
|
|
52
|
+
export const OPENAI_IMAGE_OUTPUT_FORMATS = Object.freeze(['png', 'webp', 'jpeg']);
|
|
53
|
+
export const OPENAI_IMAGE_BACKGROUNDS = Object.freeze(['auto', 'transparent', 'opaque']);
|
|
54
|
+
export const OPENAI_IMAGE_MIME_BY_FORMAT = Object.freeze({ png: 'image/png', webp: 'image/webp', jpeg: 'image/jpeg' });
|
|
55
|
+
|
|
56
|
+
// What each model family takes. dall-e keep their fixed size lists; `auto`
|
|
57
|
+
// size or quality on a dall-e model means "not sent" (the API's own default).
|
|
58
|
+
export const OPENAI_IMAGE_FAMILIES = Object.freeze({
|
|
59
|
+
'gpt-image': Object.freeze({
|
|
60
|
+
sizes: null,
|
|
61
|
+
// Every gpt-image model takes these; the 2.5 and 2.6 models take
|
|
62
|
+
// OPENAI_IMAGE_EXTENDED_QUALITIES too (openAIImageModelQualities).
|
|
63
|
+
qualities: Object.freeze(['auto', 'low', 'medium', 'high']),
|
|
64
|
+
countMax: OPENAI_IMAGE_LIMITS.countMax,
|
|
65
|
+
formats: true,
|
|
66
|
+
}),
|
|
67
|
+
'dall-e-3': Object.freeze({
|
|
68
|
+
sizes: Object.freeze(['1024x1024', '1792x1024', '1024x1792']),
|
|
69
|
+
qualities: Object.freeze(['auto', 'standard', 'hd']),
|
|
70
|
+
countMax: 1,
|
|
71
|
+
formats: false,
|
|
72
|
+
}),
|
|
73
|
+
'dall-e-2': Object.freeze({
|
|
74
|
+
sizes: Object.freeze(['256x256', '512x512', '1024x1024']),
|
|
75
|
+
qualities: Object.freeze(['auto', 'standard']),
|
|
76
|
+
countMax: OPENAI_IMAGE_LIMITS.countMax,
|
|
77
|
+
formats: false,
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// "gpt-image-2.5-sunburst and gpt-image-2.5-flare add xhigh and max quality
|
|
82
|
+
// settings … Earlier GPT Image models support quality settings up to high",
|
|
83
|
+
// and the calculator's note: "For GPT Image 2.6 and 2.5 Sunburst and Flare,
|
|
84
|
+
// the quality options are low, medium, high, xhigh, and max. For
|
|
85
|
+
// gpt-image-2, the options are low, medium, and high." (OpenAI's
|
|
86
|
+
// image-generation guide, "Size and quality options" and "Calculating
|
|
87
|
+
// costs", read 2026-11-08). A gpt-image id the relay does not know gets the
|
|
88
|
+
// narrower set.
|
|
89
|
+
export const OPENAI_IMAGE_EXTENDED_QUALITIES = Object.freeze(['xhigh', 'max']);
|
|
90
|
+
const EXTENDED_QUALITY_MODEL = /^gpt-image-2\.[56](?:-|$)/;
|
|
91
|
+
// gpt-image-2 and later 2.x ids: the pixel floor applies (pixelsMin).
|
|
92
|
+
const PIXEL_FLOOR_MODEL = /^gpt-image-2(?:$|[.-])/;
|
|
93
|
+
|
|
94
|
+
const ALL_QUALITIES = Object.freeze([
|
|
95
|
+
...new Set([...Object.values(OPENAI_IMAGE_FAMILIES).flatMap((family) => family.qualities), ...OPENAI_IMAGE_EXTENDED_QUALITIES]),
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
function bareModelId(model) {
|
|
99
|
+
return String(model || '').trim().toLowerCase().replace(/^openai\//, '');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function isOpenAIImageModelId(model = '') {
|
|
103
|
+
const id = bareModelId(model);
|
|
104
|
+
return id.startsWith('gpt-image-') || id.startsWith('dall-e-');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Copilot lists image ids its runtime cannot serve: a Copilot chat on one is
|
|
108
|
+
// refused with this code (creation, model switch, send), pointing to an
|
|
109
|
+
// OpenAI Image chat.
|
|
110
|
+
export const OPENAI_IMAGE_MODEL_NEEDS_OPENAI_CODE = 'IMAGE_MODEL_NEEDS_OPENAI';
|
|
111
|
+
|
|
112
|
+
export function openAIImageModelNeedsOpenAIText(model = '') {
|
|
113
|
+
return `"${String(model || '').trim().slice(0, 120)}" makes images: start an OpenAI Image chat (provider openai) for it.`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The model a new image session gets when the caller names none, from the
|
|
118
|
+
* ids the relay's OpenAI provider lists: the first of
|
|
119
|
+
* OPENAI_IMAGE_DEFAULT_MODELS listed, else the first gpt-image id in the
|
|
120
|
+
* list's order, else '' (the caller refuses with OPENAI_IMAGE_NO_MODEL_CODE).
|
|
121
|
+
* Returned in the list's own casing.
|
|
122
|
+
*/
|
|
123
|
+
export function pickOpenAIImageDefaultModel(models = []) {
|
|
124
|
+
const ids = (Array.isArray(models) ? models : []).map((value) => String(value || '').trim()).filter(Boolean);
|
|
125
|
+
for (const wanted of OPENAI_IMAGE_DEFAULT_MODELS) {
|
|
126
|
+
const listed = ids.find((id) => bareModelId(id) === wanted);
|
|
127
|
+
if (listed) return listed;
|
|
128
|
+
}
|
|
129
|
+
return ids.find((id) => bareModelId(id).startsWith('gpt-image-')) || '';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function openAIImageNoModelText(where = 'this relay') {
|
|
133
|
+
return `The OpenAI model list on ${where} has no gpt-image model, so an image session has none to start on: `
|
|
134
|
+
+ 'name a model the list has, or turn a gpt-image model on in Settings → Providers → OpenAI.';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The qualities `model` takes: its family's, and xhigh and max for the
|
|
139
|
+
* gpt-image 2.5 and 2.6 models. Without a model, every value any model takes.
|
|
140
|
+
*/
|
|
141
|
+
export function openAIImageModelQualities(model = '') {
|
|
142
|
+
const family = openAIImageModelFamily(model);
|
|
143
|
+
if (!family) return [...ALL_QUALITIES];
|
|
144
|
+
const base = OPENAI_IMAGE_FAMILIES[family].qualities;
|
|
145
|
+
return family === 'gpt-image' && EXTENDED_QUALITY_MODEL.test(bareModelId(model)) ? [...base, ...OPENAI_IMAGE_EXTENDED_QUALITIES] : [...base];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Whether `model`'s sizes have the pixel floor (gpt-image-2 and later 2.x). */
|
|
149
|
+
export function openAIImageModelHasPixelFloor(model = '') {
|
|
150
|
+
return PIXEL_FLOOR_MODEL.test(bareModelId(model));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 'gpt-image', 'dall-e-3', 'dall-e-2', or null for a model that makes no images. */
|
|
154
|
+
export function openAIImageModelFamily(model = '') {
|
|
155
|
+
const id = bareModelId(model);
|
|
156
|
+
if (id.startsWith('gpt-image-')) return 'gpt-image';
|
|
157
|
+
if (id.startsWith('dall-e-3')) return 'dall-e-3';
|
|
158
|
+
if (id.startsWith('dall-e-')) return 'dall-e-2';
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function invalid(error) {
|
|
163
|
+
return { ok: false, error };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** `{ width, height }` of a "WxH" text (also "W×H", "W X H"), or null. */
|
|
167
|
+
export function parseOpenAIImageSize(value) {
|
|
168
|
+
const match = /^\s*(\d{1,5})\s*[x×]\s*(\d{1,5})\s*$/i.exec(String(value ?? ''));
|
|
169
|
+
if (!match) return null;
|
|
170
|
+
const width = Number(match[1]);
|
|
171
|
+
const height = Number(match[2]);
|
|
172
|
+
return width > 0 && height > 0 ? { width, height } : null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* A size as the API takes it ("auto" or "WxH"), or `{ error }`. Without a
|
|
177
|
+
* family the gpt-image rules apply, or a dall-e list entry passes as well
|
|
178
|
+
* (the contract does not know the model; the relay checks again with it).
|
|
179
|
+
* `model` adds the pixel floor of gpt-image-2 and later.
|
|
180
|
+
*/
|
|
181
|
+
export function checkOpenAIImageSize(value, family = null, { model = '' } = {}) {
|
|
182
|
+
const text = String(value ?? '').trim().toLowerCase();
|
|
183
|
+
if (text === 'auto') return { size: 'auto' };
|
|
184
|
+
const parsed = parseOpenAIImageSize(text);
|
|
185
|
+
if (!parsed) return { error: 'size must be "auto" or WIDTHxHEIGHT in pixels, e.g. 1536x1024' };
|
|
186
|
+
const size = `${parsed.width}x${parsed.height}`;
|
|
187
|
+
const fixed = family ? OPENAI_IMAGE_FAMILIES[family]?.sizes : null;
|
|
188
|
+
if (fixed) {
|
|
189
|
+
return fixed.includes(size) ? { size } : { error: `size must be one of: ${fixed.join(', ')} for ${family}` };
|
|
190
|
+
}
|
|
191
|
+
if (!family && Object.values(OPENAI_IMAGE_FAMILIES).some((entry) => entry.sizes?.includes(size))) return { size };
|
|
192
|
+
const { sizeStep, edgeMax, pixelsMax, aspectMax } = OPENAI_IMAGE_LIMITS;
|
|
193
|
+
const { width, height } = parsed;
|
|
194
|
+
if (width % sizeStep || height % sizeStep) return { error: `size ${size}: both edges must be multiples of ${sizeStep}` };
|
|
195
|
+
if (Math.max(width, height) > edgeMax) return { error: `size ${size}: the longer edge may be at most ${edgeMax}` };
|
|
196
|
+
if (width * height > pixelsMax) return { error: `size ${size}: at most ${pixelsMax} pixels (3840x2160)` };
|
|
197
|
+
if (model && openAIImageModelHasPixelFloor(model) && width * height < OPENAI_IMAGE_LIMITS.pixelsMin) {
|
|
198
|
+
return { error: `size ${size}: ${bareModelId(model)} takes ${OPENAI_IMAGE_LIMITS.pixelsMin} to ${pixelsMax} pixels, this is ${width * height}` };
|
|
199
|
+
}
|
|
200
|
+
if (Math.max(width, height) > aspectMax * Math.min(width, height)) {
|
|
201
|
+
return { error: `size ${size}: the aspect ratio must lie between 1:${aspectMax} and ${aspectMax}:1` };
|
|
202
|
+
}
|
|
203
|
+
return { size };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Checks the image options of one call or one session's defaults. `input` is
|
|
208
|
+
* an object with any of OPENAI_IMAGE_OPTION_KEYS; empty values (null, '')
|
|
209
|
+
* are left out. `model` names the image model when it is known (the relay);
|
|
210
|
+
* without it every family's values pass that one of them takes. Returns
|
|
211
|
+
* `{ ok:true, values }` (only the keys named, normalised) or `{ ok:false, error }`.
|
|
212
|
+
* `field` prefixes the messages (the contract's "image").
|
|
213
|
+
*/
|
|
214
|
+
export function validateOpenAIImageOptions(input, { model = '', field = 'image' } = {}) {
|
|
215
|
+
if (input === undefined || input === null) return { ok: true, values: {} };
|
|
216
|
+
if (typeof input !== 'object' || Array.isArray(input)) {
|
|
217
|
+
return invalid(`${field} must be an object with any of: ${OPENAI_IMAGE_OPTION_KEYS.join(', ')}`);
|
|
218
|
+
}
|
|
219
|
+
const unknown = Object.keys(input).filter((key) => !OPENAI_IMAGE_OPTION_KEYS.includes(key) && !isEmpty(input[key]));
|
|
220
|
+
if (unknown.length) {
|
|
221
|
+
return invalid(`${field} does not take: ${unknown.slice(0, 10).join(', ')}. It takes: ${OPENAI_IMAGE_OPTION_KEYS.join(', ')}`);
|
|
222
|
+
}
|
|
223
|
+
const family = model ? openAIImageModelFamily(model) : null;
|
|
224
|
+
if (model && !family) return invalid(`${field} options go with an OpenAI image model (gpt-image-*, dall-e-*), not ${String(model).slice(0, 80)}`);
|
|
225
|
+
const rules = family ? OPENAI_IMAGE_FAMILIES[family] : null;
|
|
226
|
+
const values = {};
|
|
227
|
+
|
|
228
|
+
if (!isEmpty(input.size)) {
|
|
229
|
+
const checked = checkOpenAIImageSize(input.size, family, { model });
|
|
230
|
+
if (checked.error) return invalid(`${field}.${checked.error}`);
|
|
231
|
+
values.size = checked.size;
|
|
232
|
+
}
|
|
233
|
+
if (!isEmpty(input.quality)) {
|
|
234
|
+
const quality = String(input.quality).trim().toLowerCase();
|
|
235
|
+
const allowed = openAIImageModelQualities(model);
|
|
236
|
+
if (!allowed.includes(quality)) {
|
|
237
|
+
const extendedNote = family === 'gpt-image' && OPENAI_IMAGE_EXTENDED_QUALITIES.includes(quality)
|
|
238
|
+
? ` (${OPENAI_IMAGE_EXTENDED_QUALITIES.join(' and ')}: the gpt-image 2.5 and 2.6 models only)`
|
|
239
|
+
: '';
|
|
240
|
+
return invalid(`${field}.quality must be one of: ${allowed.join(', ')}${model ? ` for ${bareModelId(model)}` : ''}${extendedNote}`);
|
|
241
|
+
}
|
|
242
|
+
values.quality = quality;
|
|
243
|
+
}
|
|
244
|
+
if (!isEmpty(input.count)) {
|
|
245
|
+
const count = Number(input.count);
|
|
246
|
+
const max = rules ? rules.countMax : OPENAI_IMAGE_LIMITS.countMax;
|
|
247
|
+
if (typeof input.count === 'boolean' || !Number.isInteger(count) || count < 1 || count > max) {
|
|
248
|
+
return invalid(`${field}.count must be a whole number from 1 to ${max}${family ? ` for ${family}` : ''}`);
|
|
249
|
+
}
|
|
250
|
+
values.count = count;
|
|
251
|
+
}
|
|
252
|
+
for (const [key, allowed] of [['output_format', OPENAI_IMAGE_OUTPUT_FORMATS], ['background', OPENAI_IMAGE_BACKGROUNDS]]) {
|
|
253
|
+
if (isEmpty(input[key])) continue;
|
|
254
|
+
const value = String(input[key]).trim().toLowerCase();
|
|
255
|
+
if (rules && !rules.formats) return invalid(`${field}.${key} goes with gpt-image models only, not ${family}`);
|
|
256
|
+
if (!allowed.includes(value)) return invalid(`${field}.${key} must be one of: ${allowed.join(', ')}`);
|
|
257
|
+
values[key] = value;
|
|
258
|
+
}
|
|
259
|
+
if (values.background === 'transparent' && values.output_format === 'jpeg') {
|
|
260
|
+
return invalid(`${field}.background "transparent" needs output_format png or webp`);
|
|
261
|
+
}
|
|
262
|
+
return { ok: true, values };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The options of one turn: the session's defaults with the turn's own values
|
|
267
|
+
* over them, and count 1 when neither names one.
|
|
268
|
+
*/
|
|
269
|
+
export function mergeOpenAIImageOptions(defaults, override) {
|
|
270
|
+
const merged = {};
|
|
271
|
+
for (const source of [defaults, override]) {
|
|
272
|
+
if (!source || typeof source !== 'object') continue;
|
|
273
|
+
for (const key of OPENAI_IMAGE_OPTION_KEYS) {
|
|
274
|
+
if (!isEmpty(source[key])) merged[key] = source[key];
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (!merged.count) merged.count = 1;
|
|
278
|
+
return merged;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The reply line of an image turn, for agents and the transcript alike when
|
|
283
|
+
* the model wrote no text: "2 images · 1536x1024 · gpt-image-2 · ~USD 0.08".
|
|
284
|
+
* `images` are `{ width, height }` entries; the cost part only when known.
|
|
285
|
+
*/
|
|
286
|
+
export function formatOpenAIImageSummary({ images = [], model = '', costUsd = null } = {}) {
|
|
287
|
+
const list = Array.isArray(images) ? images : [];
|
|
288
|
+
const parts = [`${list.length} image${list.length === 1 ? '' : 's'}`];
|
|
289
|
+
const sizes = [...new Set(list.filter((image) => image?.width > 0 && image?.height > 0).map((image) => `${image.width}x${image.height}`))];
|
|
290
|
+
if (sizes.length) parts.push(sizes.join(', '));
|
|
291
|
+
const id = String(model || '').trim();
|
|
292
|
+
if (id) parts.push(id);
|
|
293
|
+
if (Number.isFinite(costUsd) && costUsd >= 0) parts.push(`~USD ${costUsd < 0.01 && costUsd > 0 ? costUsd.toFixed(3) : costUsd.toFixed(2)}`);
|
|
294
|
+
return parts.join(' · ');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function isEmpty(value) {
|
|
298
|
+
return value === undefined || value === null || (typeof value === 'string' && value.trim() === '');
|
|
299
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Image presets: named bundles of image options (size, quality, count,
|
|
2
|
+
// output format, background). A built-in set, and the owner's own, stored
|
|
3
|
+
// relay-wide in app_settings. A turn's options are layered: the session's
|
|
4
|
+
// defaults, then a preset, then the turn's own fields; the merged result is
|
|
5
|
+
// what the validator checks (validateOpenAIImageOptions with the model).
|
|
6
|
+
//
|
|
7
|
+
// SDK-free and side-effect free: imported by the server and the session
|
|
8
|
+
// workers (the browser keeps a tested copy, as for the options).
|
|
9
|
+
|
|
10
|
+
import { OPENAI_IMAGE_OPTION_KEYS, validateOpenAIImageOptions } from './openai-image-options.mjs';
|
|
11
|
+
|
|
12
|
+
export const OPENAI_IMAGE_PRESETS_SETTING_KEY = 'openai_image_presets';
|
|
13
|
+
|
|
14
|
+
export const OPENAI_IMAGE_PRESET_LIMITS = Object.freeze({
|
|
15
|
+
// Own presets one relay keeps, a name's characters, and the stored JSON.
|
|
16
|
+
ownMax: 30,
|
|
17
|
+
nameChars: 40,
|
|
18
|
+
storedBytesMax: 8 * 1024,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// The relay refuses a preset name it does not know, or an own preset that
|
|
22
|
+
// breaks the rules (a built-in's name, too many, too long), with this code.
|
|
23
|
+
export const OPENAI_IMAGE_PRESET_REFUSED_CODE = 'OPENAI_IMAGE_PRESET_REFUSED';
|
|
24
|
+
|
|
25
|
+
export const OPENAI_IMAGE_BUILTIN_PRESETS = Object.freeze([
|
|
26
|
+
Object.freeze({ name: 'Draft square', size: '1024x1024', quality: 'low' }),
|
|
27
|
+
Object.freeze({ name: 'Photo landscape', size: '1536x1024', quality: 'medium' }),
|
|
28
|
+
Object.freeze({ name: 'Portrait', size: '1024x1536', quality: 'medium' }),
|
|
29
|
+
Object.freeze({ name: 'Wallpaper 4K', size: '3840x2160', quality: 'high' }),
|
|
30
|
+
Object.freeze({ name: 'Sticker', size: '1024x1024', quality: 'medium', output_format: 'png', background: 'transparent' }),
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
// Control characters, and the invisible ones that make two names look alike.
|
|
34
|
+
const HIDDEN_CHARACTERS = /[\u0000-\u001f\u007f-\u009f----]/u;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A preset name as shown: NFC, trimmed, runs of white space as one space;
|
|
38
|
+
* or `{ error }` for an empty, too long or hidden-character name.
|
|
39
|
+
*/
|
|
40
|
+
export function cleanOpenAIImagePresetName(value) {
|
|
41
|
+
if (typeof value !== 'string') return { error: 'a preset name must be text' };
|
|
42
|
+
const normalized = value.normalize('NFC');
|
|
43
|
+
if (HIDDEN_CHARACTERS.test(normalized)) return { error: 'a preset name may not hold control or invisible characters' };
|
|
44
|
+
const name = normalized.trim().replace(/\s+/gu, ' ');
|
|
45
|
+
if (!name) return { error: 'a preset name may not be empty' };
|
|
46
|
+
// "." and ".." would be path steps in the preset's URL (DELETE …/:name).
|
|
47
|
+
if (/^\.+$/.test(name)) return { error: 'a preset name may not be dots alone' };
|
|
48
|
+
if ([...name].length > OPENAI_IMAGE_PRESET_LIMITS.nameChars) {
|
|
49
|
+
return { error: `a preset name has at most ${OPENAI_IMAGE_PRESET_LIMITS.nameChars} characters` };
|
|
50
|
+
}
|
|
51
|
+
return { name };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The one key a name is saved and looked up by: the clean name, lower-cased. */
|
|
55
|
+
export function openAIImagePresetKey(value) {
|
|
56
|
+
const cleaned = cleanOpenAIImagePresetName(value);
|
|
57
|
+
return cleaned.error ? '' : cleaned.name.toLowerCase();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function presetValues(preset) {
|
|
61
|
+
const values = {};
|
|
62
|
+
for (const key of OPENAI_IMAGE_OPTION_KEYS) {
|
|
63
|
+
if (preset?.[key] !== undefined && preset?.[key] !== null && preset?.[key] !== '') values[key] = preset[key];
|
|
64
|
+
}
|
|
65
|
+
return values;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* An own preset as the owner saves it, checked without a model (a preset
|
|
70
|
+
* serves any image session; its use is checked against the session's
|
|
71
|
+
* model): `{ ok:true, preset:{ name, ...values } }` or `{ ok:false, error }`.
|
|
72
|
+
*/
|
|
73
|
+
export function validateOwnOpenAIImagePreset(input) {
|
|
74
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return { ok: false, error: 'a preset is an object with a name and image options' };
|
|
75
|
+
const cleaned = cleanOpenAIImagePresetName(input.name);
|
|
76
|
+
if (cleaned.error) return { ok: false, error: cleaned.error };
|
|
77
|
+
const key = cleaned.name.toLowerCase();
|
|
78
|
+
if (OPENAI_IMAGE_BUILTIN_PRESETS.some((preset) => preset.name.toLowerCase() === key)) {
|
|
79
|
+
return { ok: false, error: `"${cleaned.name}" is a built-in preset; pick another name` };
|
|
80
|
+
}
|
|
81
|
+
const { name: _name, ...rest } = input;
|
|
82
|
+
const checked = validateOpenAIImageOptions(rest, { field: 'preset' });
|
|
83
|
+
if (!checked.ok) return { ok: false, error: checked.error };
|
|
84
|
+
if (!Object.keys(checked.values).length) return { ok: false, error: `a preset needs at least one of: ${OPENAI_IMAGE_OPTION_KEYS.join(', ')}` };
|
|
85
|
+
return { ok: true, preset: { name: cleaned.name, ...checked.values } };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The owner's presets as stored (the app_settings JSON), the ones that still
|
|
90
|
+
* pass the rules, in their order; anything else is dropped.
|
|
91
|
+
*/
|
|
92
|
+
export function parseOwnOpenAIImagePresets(stored) {
|
|
93
|
+
let list = stored;
|
|
94
|
+
if (typeof stored === 'string') {
|
|
95
|
+
try { list = JSON.parse(stored); } catch { return []; }
|
|
96
|
+
}
|
|
97
|
+
if (!Array.isArray(list)) return [];
|
|
98
|
+
const seen = new Set();
|
|
99
|
+
const presets = [];
|
|
100
|
+
for (const entry of list) {
|
|
101
|
+
const checked = validateOwnOpenAIImagePreset(entry);
|
|
102
|
+
if (!checked.ok) continue;
|
|
103
|
+
const key = checked.preset.name.toLowerCase();
|
|
104
|
+
if (seen.has(key)) continue;
|
|
105
|
+
seen.add(key);
|
|
106
|
+
presets.push(checked.preset);
|
|
107
|
+
if (presets.length >= OPENAI_IMAGE_PRESET_LIMITS.ownMax) break;
|
|
108
|
+
}
|
|
109
|
+
return presets;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Built-ins first (`builtIn: true`), then the owner's. */
|
|
113
|
+
export function listOpenAIImagePresets(ownPresets = []) {
|
|
114
|
+
return [
|
|
115
|
+
...OPENAI_IMAGE_BUILTIN_PRESETS.map((preset) => ({ ...preset, builtIn: true })),
|
|
116
|
+
...(Array.isArray(ownPresets) ? ownPresets : []).map((preset) => ({ ...preset, builtIn: false })),
|
|
117
|
+
];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The preset a turn names, by its key: `{ ok:true, values }` (the options
|
|
122
|
+
* alone), `{ ok:true, values:{} }` for no name, or `{ ok:false, error }` for
|
|
123
|
+
* a name the relay does not know, with the names it has.
|
|
124
|
+
*/
|
|
125
|
+
export function findOpenAIImagePreset(name, ownPresets = []) {
|
|
126
|
+
if (name === undefined || name === null || (typeof name === 'string' && !name.trim())) return { ok: true, values: {} };
|
|
127
|
+
const key = openAIImagePresetKey(name);
|
|
128
|
+
const all = listOpenAIImagePresets(ownPresets);
|
|
129
|
+
const found = key ? all.find((preset) => preset.name.toLowerCase() === key) : null;
|
|
130
|
+
if (!found) {
|
|
131
|
+
return { ok: false, error: `no image preset "${String(name).slice(0, 60)}" here; presets: ${all.map((preset) => preset.name).join(', ')}` };
|
|
132
|
+
}
|
|
133
|
+
return { ok: true, values: presetValues(found), name: found.name };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Splits a turn's `image` into the preset it names and its own fields (the
|
|
138
|
+
* validator takes no `preset` key): `{ preset, own }`.
|
|
139
|
+
*/
|
|
140
|
+
export function splitOpenAIImagePreset(image) {
|
|
141
|
+
if (!image || typeof image !== 'object' || Array.isArray(image)) return { preset: null, own: image };
|
|
142
|
+
const { preset = null, ...own } = image;
|
|
143
|
+
return { preset, own };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The stored list after one change, for the server to write in the same
|
|
148
|
+
* transaction it read in: `{ ok:true, presets }` or `{ ok:false, error }`.
|
|
149
|
+
* `op` is 'put' (add, or replace one of that name only with `replace`) or
|
|
150
|
+
* 'delete'.
|
|
151
|
+
*/
|
|
152
|
+
export function applyOwnOpenAIImagePresetChange(ownPresets, { op, preset = null, name = '', replace = false } = {}) {
|
|
153
|
+
const list = Array.isArray(ownPresets) ? [...ownPresets] : [];
|
|
154
|
+
if (op === 'delete') {
|
|
155
|
+
const key = openAIImagePresetKey(name);
|
|
156
|
+
if (OPENAI_IMAGE_BUILTIN_PRESETS.some((entry) => entry.name.toLowerCase() === key)) return { ok: false, error: 'a built-in preset cannot be deleted' };
|
|
157
|
+
const index = list.findIndex((entry) => entry.name.toLowerCase() === key);
|
|
158
|
+
if (index < 0) return { ok: false, error: `no own preset "${String(name).slice(0, 60)}"` };
|
|
159
|
+
list.splice(index, 1);
|
|
160
|
+
return { ok: true, presets: list };
|
|
161
|
+
}
|
|
162
|
+
if (op !== 'put') return { ok: false, error: 'op must be put or delete' };
|
|
163
|
+
const checked = validateOwnOpenAIImagePreset(preset);
|
|
164
|
+
if (!checked.ok) return checked;
|
|
165
|
+
const key = checked.preset.name.toLowerCase();
|
|
166
|
+
const index = list.findIndex((entry) => entry.name.toLowerCase() === key);
|
|
167
|
+
if (index >= 0 && !replace) return { ok: false, error: `a preset "${list[index].name}" exists; save with replace to change it` };
|
|
168
|
+
if (index >= 0) list[index] = checked.preset;
|
|
169
|
+
else if (list.length >= OPENAI_IMAGE_PRESET_LIMITS.ownMax) return { ok: false, error: `at most ${OPENAI_IMAGE_PRESET_LIMITS.ownMax} own presets` };
|
|
170
|
+
else list.push(checked.preset);
|
|
171
|
+
if (new TextEncoder().encode(JSON.stringify(list)).length > OPENAI_IMAGE_PRESET_LIMITS.storedBytesMax) {
|
|
172
|
+
return { ok: false, error: `the presets would take more than ${OPENAI_IMAGE_PRESET_LIMITS.storedBytesMax} bytes` };
|
|
173
|
+
}
|
|
174
|
+
return { ok: true, presets: list };
|
|
175
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Partial previews of an OpenAI image turn: what the relay asks OpenAI to
|
|
2
|
+
// stream, where the partial files live and the socket events the owner's
|
|
3
|
+
// open clients get while the image is made. Agents never see previews: their
|
|
4
|
+
// replies carry the final images only.
|
|
5
|
+
//
|
|
6
|
+
// SDK-free and side-effect free: imported by the server and the browser.
|
|
7
|
+
|
|
8
|
+
// Partial images per image OpenAI streams before the final one (0 to 3; 0
|
|
9
|
+
// turns streaming off).
|
|
10
|
+
export const OPENAI_IMAGE_PARTIAL_IMAGES = 2;
|
|
11
|
+
|
|
12
|
+
// The folder next to a turn's final images that holds its partials; emptied
|
|
13
|
+
// and removed when the turn ends, well or not.
|
|
14
|
+
export const OPENAI_IMAGE_PARTIALS_DIR = 'partials';
|
|
15
|
+
|
|
16
|
+
// Socket events (owner clients only, never shared views):
|
|
17
|
+
//
|
|
18
|
+
// `image_started` — a try of an image turn went to OpenAI:
|
|
19
|
+
// { conversationId, messageId, attempt, size, quality, count, model, startedAt }
|
|
20
|
+
// The client shows a placeholder with an elapsed timer from `startedAt`
|
|
21
|
+
// and the size and quality; a client that opens the chat later sees the
|
|
22
|
+
// running turn without it (its timer starts from the turn's own time).
|
|
23
|
+
//
|
|
24
|
+
// `image_preview` — a partial image arrived:
|
|
25
|
+
// { conversationId, messageId, attempt, imageIndex, partialIndex,
|
|
26
|
+
// contentUrl, mime, size, quality, model, startedAt }
|
|
27
|
+
// `messageId` is the user message (queue row) the turn answers; `attempt`
|
|
28
|
+
// counts the tries of that turn (1, then 2 after a retry); `imageIndex`
|
|
29
|
+
// the image of a count > 1 turn (0-based); `partialIndex` OpenAI's
|
|
30
|
+
// partial_image_index; `startedAt` the ISO time the first try began, for
|
|
31
|
+
// the elapsed timer.
|
|
32
|
+
//
|
|
33
|
+
// `image_preview_reset` — drop the previews shown so far for that turn:
|
|
34
|
+
// { conversationId, messageId, attempt, reason }
|
|
35
|
+
// `reason` is 'retry' (a second try starts: the first try's partials go),
|
|
36
|
+
// 'done' (the final message is there) or 'failed'.
|
|
37
|
+
export const OPENAI_IMAGE_STARTED_EVENT = 'image_started';
|
|
38
|
+
export const OPENAI_IMAGE_PREVIEW_EVENT = 'image_preview';
|
|
39
|
+
export const OPENAI_IMAGE_PREVIEW_RESET_EVENT = 'image_preview_reset';
|
|
40
|
+
export const OPENAI_IMAGE_PREVIEW_RESET_REASONS = Object.freeze(['retry', 'done', 'failed']);
|