@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
|
@@ -7,6 +7,16 @@ import { randomBytes } from 'node:crypto';
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import os from 'node:os';
|
|
9
9
|
import path from 'node:path';
|
|
10
|
+
import {
|
|
11
|
+
AGENT_SDK_IDS,
|
|
12
|
+
agentSdkSupport,
|
|
13
|
+
agentSdkFromCheckout,
|
|
14
|
+
findReadyAgentSdkDir,
|
|
15
|
+
getAgentSdkDescriptor,
|
|
16
|
+
readAgentSdkMarker,
|
|
17
|
+
readAgentSdkPin,
|
|
18
|
+
resolveAgentSdkRoot,
|
|
19
|
+
} from './agent-sdk-paths.mjs';
|
|
10
20
|
import { formatMegabytes } from './db-backup-service.mjs';
|
|
11
21
|
|
|
12
22
|
export function generateAuthToken() {
|
|
@@ -87,6 +97,7 @@ const CLI_COMMANDS = {
|
|
|
87
97
|
uninstall: { flags: ['--purge'] },
|
|
88
98
|
update: { flags: ['--beta'], values: ['--to'] },
|
|
89
99
|
'build-native': { flags: ['--force'] },
|
|
100
|
+
sdk: { words: ['install', 'status'], words2: [...AGENT_SDK_IDS], flags: ['--reinstall'] },
|
|
90
101
|
copilot: { flags: ['--install-extension', '--no-install-extension'], values: ['--port', '--migrate-from'], forwards: true },
|
|
91
102
|
};
|
|
92
103
|
|
|
@@ -117,6 +128,9 @@ export function usageText() {
|
|
|
117
128
|
' oar backup [--reason <text>]',
|
|
118
129
|
' A copy of the database into the backup folder (safe while the relay runs)',
|
|
119
130
|
' oar update [--beta] [--to <version>]',
|
|
131
|
+
' oar sdk [status] The agent SDKs the relay installs on demand: folder, versions, state',
|
|
132
|
+
' oar sdk install claude | cursor | koffi [--reinstall]',
|
|
133
|
+
' Install one now, without the relay (koffi: Windows only)',
|
|
120
134
|
' oar build-native [--force]',
|
|
121
135
|
' Build the SQLite binding where no prebuilt one ships (Termux, 32-bit ARM);',
|
|
122
136
|
' --force builds where one ships too, which only tests the build tools',
|
|
@@ -180,6 +194,8 @@ export function parseCliArgs(argv = []) {
|
|
|
180
194
|
if (arg === name) index += 1;
|
|
181
195
|
} else if (spec.words?.includes(arg) && words === 0) {
|
|
182
196
|
words += 1;
|
|
197
|
+
} else if (spec.words2?.includes(arg) && words === 1) {
|
|
198
|
+
words += 1;
|
|
183
199
|
} else if (!spec.flags?.includes(arg)) {
|
|
184
200
|
return { error: arg.startsWith('-') ? `Unknown option for oar ${command}: ${arg}` : `Unexpected argument: ${arg}` };
|
|
185
201
|
}
|
|
@@ -402,14 +418,14 @@ export function buildServicePath({ nodeBin, envPath = '', homeDir = '' } = {}) {
|
|
|
402
418
|
|
|
403
419
|
/**
|
|
404
420
|
* The dependencies whose install scripts OAR needs (they fetch or build a
|
|
405
|
-
* native binary). npm 12 runs no install script it was not told to
|
|
406
|
-
* a
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
421
|
+
* native binary): none. npm 12 runs no install script it was not told to
|
|
422
|
+
* allow, so a package that gains one must be added here and passed to npm
|
|
423
|
+
* again; `npm-install-scripts.test.mjs` holds this list to the lockfile.
|
|
424
|
+
* better-sqlite3 13 has no install script (where it ships no binary, OAR
|
|
425
|
+
* builds it: native-addon-build-service.mjs), and koffi, whose script the
|
|
426
|
+
* list held, is installed on demand on Windows (agent-sdk-store.mjs).
|
|
410
427
|
*/
|
|
411
|
-
export const NPM_INSTALL_SCRIPT_PACKAGES = Object.freeze([
|
|
412
|
-
export const NPM_ALLOW_SCRIPTS_ARG = `--allow-scripts=${NPM_INSTALL_SCRIPT_PACKAGES.join(',')}`;
|
|
428
|
+
export const NPM_INSTALL_SCRIPT_PACKAGES = Object.freeze([]);
|
|
413
429
|
|
|
414
430
|
/**
|
|
415
431
|
* How to run `npm install -g` so the update lands in the install that is
|
|
@@ -442,8 +458,9 @@ export function resolveNpmInvocation({
|
|
|
442
458
|
|
|
443
459
|
/**
|
|
444
460
|
* Provider CLIs `oar doctor` probes, with the args that answer fast. Cursor is
|
|
445
|
-
* deliberately absent: the relay drives it through the
|
|
446
|
-
*
|
|
461
|
+
* deliberately absent: the relay drives it through the Cursor SDK it installs
|
|
462
|
+
* on demand (the agent SDK block of the report) and never invokes a
|
|
463
|
+
* cursor-agent binary.
|
|
447
464
|
*/
|
|
448
465
|
export const DOCTOR_PROBES = Object.freeze([
|
|
449
466
|
{ id: 'gh (Copilot)', binary: 'gh', args: ['--version'] },
|
|
@@ -487,6 +504,50 @@ function renderBackupLines(backups, now) {
|
|
|
487
504
|
return lines;
|
|
488
505
|
}
|
|
489
506
|
|
|
507
|
+
/**
|
|
508
|
+
* The agent SDKs as `oar doctor` and `oar sdk` show them, read from the disk
|
|
509
|
+
* alone (no lock, no spawn): the folder, and per SDK the pinned version, an
|
|
510
|
+
* installed one and its state. A checkout runs its devDependencies instead.
|
|
511
|
+
*/
|
|
512
|
+
export function describeAgentSdks({ env = process.env, platform = process.platform } = {}) {
|
|
513
|
+
// A checkout takes each SDK from its node_modules while it is installed there.
|
|
514
|
+
const fromCheckout = AGENT_SDK_IDS.every((id) => agentSdkFromCheckout(id, { env }) || getAgentSdkDescriptor(id).platforms?.includes(platform) === false);
|
|
515
|
+
const root = resolveAgentSdkRoot({ env, platform });
|
|
516
|
+
const rows = [];
|
|
517
|
+
for (const id of AGENT_SDK_IDS) {
|
|
518
|
+
const descriptor = getAgentSdkDescriptor(id);
|
|
519
|
+
let pin = null;
|
|
520
|
+
try { pin = readAgentSdkPin(id); } catch {}
|
|
521
|
+
if (!pin) {
|
|
522
|
+
rows.push({ id, label: descriptor.label, pinned: null, installed: null, state: 'pins missing from the package' });
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
const support = agentSdkSupport(pin);
|
|
526
|
+
if (descriptor.platforms && !descriptor.platforms.includes(platform)) continue;
|
|
527
|
+
if (agentSdkFromCheckout(id, { env })) {
|
|
528
|
+
rows.push({ id, label: descriptor.label, pinned: pin.version, installed: null, state: 'from the checkout (devDependency)' });
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
const ready = findReadyAgentSdkDir(id, { env, platform, root });
|
|
532
|
+
const older = ready ? null : findReadyAgentSdkDir(id, { env, platform, root, anyVersion: true });
|
|
533
|
+
const olderVersion = older ? readAgentSdkMarker(older)?.version || null : null;
|
|
534
|
+
const state = support === 'no'
|
|
535
|
+
? 'not available on this platform'
|
|
536
|
+
: ready ? `ready${support === 'cli' ? " (runs the host's Claude Code CLI)" : ''}`
|
|
537
|
+
: olderVersion ? `${olderVersion} installed; the relay installs ${pin.version} at its next start or first chat`
|
|
538
|
+
: 'not installed (installs on first setup or first chat)';
|
|
539
|
+
rows.push({ id, label: descriptor.label, pinned: pin.version, installed: ready ? pin.version : olderVersion, state, dir: ready || older || null });
|
|
540
|
+
}
|
|
541
|
+
return { fromCheckout, root, rows };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export function renderAgentSdkLines(agentSdks) {
|
|
545
|
+
if (!agentSdks) return [];
|
|
546
|
+
const lines = [` agent SDKs : ${agentSdks.fromCheckout ? "from the checkout's node_modules (OAR_AGENT_SDK_SOURCE=store uses the store)" : agentSdks.root}`];
|
|
547
|
+
for (const row of agentSdks.rows) lines.push(` ${row.id.padEnd(14)}: ${row.pinned ? `${row.pinned} ` : ''}${row.state}`);
|
|
548
|
+
return lines;
|
|
549
|
+
}
|
|
550
|
+
|
|
490
551
|
export function renderDoctorReport({
|
|
491
552
|
version,
|
|
492
553
|
nodeVersion,
|
|
@@ -502,6 +563,7 @@ export function renderDoctorReport({
|
|
|
502
563
|
probes = [],
|
|
503
564
|
warnings = [],
|
|
504
565
|
tunnel = '',
|
|
566
|
+
agentSdks = null,
|
|
505
567
|
}) {
|
|
506
568
|
const yesNo = (v) => (v ? 'yes' : 'no');
|
|
507
569
|
const lines = [
|
|
@@ -527,6 +589,7 @@ export function renderDoctorReport({
|
|
|
527
589
|
for (const probe of probes) {
|
|
528
590
|
lines.push(` ${probe.id.padEnd(14)}: ${probe.ok ? probe.version || 'installed' : 'not found'}`);
|
|
529
591
|
}
|
|
592
|
+
lines.push(...renderAgentSdkLines(agentSdks));
|
|
530
593
|
for (const warning of warnings) lines.push(` warning : ${warning}`);
|
|
531
594
|
return lines.join('\n');
|
|
532
595
|
}
|
|
@@ -21,25 +21,15 @@
|
|
|
21
21
|
|
|
22
22
|
import fs from 'node:fs';
|
|
23
23
|
import path from 'node:path';
|
|
24
|
-
import os from 'node:os';
|
|
25
24
|
|
|
26
25
|
import { DB_FILE_NAMES, LEGACY_DB_FILE_NAME, resolveDatabaseFile, sideFiles } from './database-file.mjs';
|
|
26
|
+
import { resolveOarRoot } from './oar-state-root.mjs';
|
|
27
27
|
import { createRelaySingletonGuard } from './relay-singleton-guard.mjs';
|
|
28
28
|
|
|
29
29
|
export const MIGRATION_MARKER = '.migrated';
|
|
30
30
|
const MAX_LOGS_BYTES = 50 * 1024 * 1024;
|
|
31
31
|
|
|
32
|
-
export
|
|
33
|
-
const override = String(env.OAR_STATE_ROOT || '').trim();
|
|
34
|
-
if (override) return path.resolve(override);
|
|
35
|
-
if (platform === 'win32') {
|
|
36
|
-
const appData = String(env.APPDATA || '').trim();
|
|
37
|
-
if (appData) return path.join(appData, 'oar');
|
|
38
|
-
const profile = String(env.USERPROFILE || '').trim();
|
|
39
|
-
return path.join(profile || os.homedir(), 'AppData', 'Roaming', 'oar');
|
|
40
|
-
}
|
|
41
|
-
return path.join(os.homedir(), '.oar');
|
|
42
|
-
}
|
|
32
|
+
export { resolveOarRoot };
|
|
43
33
|
|
|
44
34
|
function defaultIsProcessAlive(pid) {
|
|
45
35
|
try {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
// The OAR state root: ~/.oar, %APPDATA%\oar on Windows, OAR_STATE_ROOT when
|
|
5
|
+
// set. A module of its own, without imports of OAR's, so the relay's lock
|
|
6
|
+
// check can find the agent SDK folder beside it (agent-sdk-paths.mjs).
|
|
7
|
+
export function resolveOarRoot(env = process.env, platform = process.platform) {
|
|
8
|
+
const override = String(env.OAR_STATE_ROOT || '').trim();
|
|
9
|
+
if (override) return path.resolve(override);
|
|
10
|
+
if (platform === 'win32') {
|
|
11
|
+
const appData = String(env.APPDATA || '').trim();
|
|
12
|
+
if (appData) return path.join(appData, 'oar');
|
|
13
|
+
const profile = String(env.USERPROFILE || '').trim();
|
|
14
|
+
return path.join(profile || os.homedir(), 'AppData', 'Roaming', 'oar');
|
|
15
|
+
}
|
|
16
|
+
return path.join(os.homedir(), '.oar');
|
|
17
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The partial previews of the image turns this relay runs
|
|
4
|
+
// (shared/openai-image-previews.mjs has the contract): each partial OpenAI
|
|
5
|
+
// streams is written to
|
|
6
|
+
// <session-state root>/<session>/generated-images/<conversation>/<queue message>/partials/img-01-try1-p0.png
|
|
7
|
+
// served to the owner's clients by its content URL, and announced with a
|
|
8
|
+
// socket event. The final images of the turn go to the reply's own folder
|
|
9
|
+
// (keyed by the reply's id, made when the reply is stored); the partials sit
|
|
10
|
+
// beside it under the queue message the turn answers.
|
|
11
|
+
//
|
|
12
|
+
// Nothing of a preview outlives its turn: a retry drops the first try's
|
|
13
|
+
// files, a failed request drops them all, and the turn's end (the reply or
|
|
14
|
+
// the failure note stored) removes the folder and tells the clients. A turn
|
|
15
|
+
// whose end never comes (a worker that died after the request) is ended by
|
|
16
|
+
// a timer; a crash of the relay leaves at most the partials of the turns it
|
|
17
|
+
// ran, and the next image turn of that conversation to end sweeps them.
|
|
18
|
+
//
|
|
19
|
+
// Agents never see a preview: partials are no attachment of any message, and
|
|
20
|
+
// only the owner's sockets get the events (share views have no socket).
|
|
21
|
+
|
|
22
|
+
import fsDefault from 'fs';
|
|
23
|
+
import path from 'path';
|
|
24
|
+
|
|
25
|
+
import { readImageHeader } from '../../shared/image-header.mjs';
|
|
26
|
+
import { OPENAI_IMAGE_MIME_BY_FORMAT } from '../../shared/openai-image-options.mjs';
|
|
27
|
+
import {
|
|
28
|
+
OPENAI_IMAGE_PARTIAL_IMAGES,
|
|
29
|
+
OPENAI_IMAGE_PARTIALS_DIR,
|
|
30
|
+
OPENAI_IMAGE_PREVIEW_EVENT,
|
|
31
|
+
OPENAI_IMAGE_PREVIEW_RESET_EVENT,
|
|
32
|
+
OPENAI_IMAGE_PREVIEW_RESET_REASONS,
|
|
33
|
+
OPENAI_IMAGE_STARTED_EVENT,
|
|
34
|
+
} from '../../shared/openai-image-previews.mjs';
|
|
35
|
+
|
|
36
|
+
// How long the partials of a request that ended wait for the turn's end.
|
|
37
|
+
export const OPENAI_IMAGE_PREVIEW_LEFTOVER_MS = 10 * 60_000;
|
|
38
|
+
|
|
39
|
+
const EXTENSION_BY_MIME = Object.freeze({ 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp' });
|
|
40
|
+
|
|
41
|
+
function normalizeStorageSegment(value, fallback = 'item') {
|
|
42
|
+
const text = String(value || '').trim();
|
|
43
|
+
const compact = text.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
44
|
+
return compact.slice(0, 96) || fallback;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The URL a partial is served at (GET /api/generated-image/:c/:m/partials/:file). */
|
|
48
|
+
export function openAIImagePartialContentUrl(conversationId, messageId, fileName) {
|
|
49
|
+
return `/api/generated-image/${encodeURIComponent(conversationId)}/${encodeURIComponent(messageId)}/${OPENAI_IMAGE_PARTIALS_DIR}/${encodeURIComponent(fileName)}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `resolveSessionStateRoot()` is the session-state root (no root: nothing is
|
|
54
|
+
* written, the started and reset events still go out); `sessionIdFor(c)` the
|
|
55
|
+
* session folder of a conversation (its SDK session id, else its id, as the
|
|
56
|
+
* final images use); `emit(event, payload)` reaches the owner's clients.
|
|
57
|
+
*/
|
|
58
|
+
export function createOpenAIImagePreviewTurns({
|
|
59
|
+
resolveSessionStateRoot = null,
|
|
60
|
+
sessionIdFor = (conversationId) => conversationId,
|
|
61
|
+
emit = () => {},
|
|
62
|
+
logger = console,
|
|
63
|
+
fs = fsDefault,
|
|
64
|
+
leftoverMs = OPENAI_IMAGE_PREVIEW_LEFTOVER_MS,
|
|
65
|
+
now = () => Date.now(),
|
|
66
|
+
} = {}) {
|
|
67
|
+
const turns = new Map();
|
|
68
|
+
|
|
69
|
+
const send = (event, payload) => {
|
|
70
|
+
try {
|
|
71
|
+
emit(event, payload);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
logger?.warn?.(`IMAGE preview event ${event} not sent: ${error?.message || error}`);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const conversationDirOf = (conversationId) => {
|
|
78
|
+
let root = '';
|
|
79
|
+
try {
|
|
80
|
+
root = String(resolveSessionStateRoot?.() || '').trim();
|
|
81
|
+
} catch {}
|
|
82
|
+
if (!root) return '';
|
|
83
|
+
let sessionId = '';
|
|
84
|
+
try {
|
|
85
|
+
sessionId = String(sessionIdFor(conversationId) || '').trim();
|
|
86
|
+
} catch {}
|
|
87
|
+
const safeSession = normalizeStorageSegment(sessionId || conversationId || 'session', 'session');
|
|
88
|
+
const safeConversation = normalizeStorageSegment(conversationId || 'conversation', 'conversation');
|
|
89
|
+
return path.join(root, safeSession, 'generated-images', safeConversation);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const removeQuietly = (target, options = {}) => {
|
|
93
|
+
try {
|
|
94
|
+
fs.rmSync(target, { force: true, ...options });
|
|
95
|
+
} catch {}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const removeIfEmpty = (dir) => {
|
|
99
|
+
try {
|
|
100
|
+
if (!fs.readdirSync(dir).length) fs.rmdirSync(dir);
|
|
101
|
+
} catch {}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const removeFiles = (entry, { attempt = null } = {}) => {
|
|
105
|
+
for (const [fileName, file] of entry.files) {
|
|
106
|
+
if (attempt !== null && file.attempt !== attempt) continue;
|
|
107
|
+
removeQuietly(file.path);
|
|
108
|
+
entry.files.delete(fileName);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// Partials folders of this conversation that no running turn owns: the
|
|
113
|
+
// leftovers of a relay that went down mid-turn.
|
|
114
|
+
const sweepConversation = (conversationId) => {
|
|
115
|
+
const conversationDir = conversationDirOf(conversationId);
|
|
116
|
+
if (!conversationDir) return;
|
|
117
|
+
const owned = new Set();
|
|
118
|
+
for (const entry of turns.values()) {
|
|
119
|
+
if (entry.conversationId === conversationId) owned.add(entry.safeMessageId);
|
|
120
|
+
}
|
|
121
|
+
let names = [];
|
|
122
|
+
try {
|
|
123
|
+
names = fs.readdirSync(conversationDir, { withFileTypes: true });
|
|
124
|
+
} catch {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
for (const dirent of names) {
|
|
128
|
+
if (!dirent.isDirectory() || owned.has(dirent.name)) continue;
|
|
129
|
+
const messageDir = path.join(conversationDir, dirent.name);
|
|
130
|
+
const partialsDir = path.join(messageDir, OPENAI_IMAGE_PARTIALS_DIR);
|
|
131
|
+
if (!fs.existsSync(partialsDir)) continue;
|
|
132
|
+
removeQuietly(partialsDir, { recursive: true });
|
|
133
|
+
removeIfEmpty(messageDir);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
function finishEntry(entry, reason) {
|
|
138
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
139
|
+
entry.timer = null;
|
|
140
|
+
turns.delete(entry.messageId);
|
|
141
|
+
removeFiles(entry);
|
|
142
|
+
if (entry.partialsDir) {
|
|
143
|
+
removeQuietly(entry.partialsDir, { recursive: true });
|
|
144
|
+
removeIfEmpty(path.dirname(entry.partialsDir));
|
|
145
|
+
}
|
|
146
|
+
send(OPENAI_IMAGE_PREVIEW_RESET_EVENT, {
|
|
147
|
+
conversationId: entry.conversationId,
|
|
148
|
+
messageId: entry.messageId,
|
|
149
|
+
attempt: entry.attempt,
|
|
150
|
+
reason,
|
|
151
|
+
});
|
|
152
|
+
sweepConversation(entry.conversationId);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Ends the previews of a turn whose reply or failure note is stored:
|
|
157
|
+
* removes its partials and tells the clients (`reason` 'done' or 'failed').
|
|
158
|
+
* While the turn's request still runs the end waits for it. False for a
|
|
159
|
+
* message with no previews.
|
|
160
|
+
*/
|
|
161
|
+
function finish(messageId, reason = 'done') {
|
|
162
|
+
const entry = turns.get(String(messageId || ''));
|
|
163
|
+
if (!entry) return false;
|
|
164
|
+
const why = OPENAI_IMAGE_PREVIEW_RESET_REASONS.includes(reason) && reason !== 'retry' ? reason : 'failed';
|
|
165
|
+
if (entry.running) {
|
|
166
|
+
entry.finishRequested = why;
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
finishEntry(entry, why);
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The previews of one image turn: the queue message `messageId` of
|
|
175
|
+
* `conversationId`, with the request's size, quality, count and model.
|
|
176
|
+
*/
|
|
177
|
+
function begin({ conversationId, messageId, size = '', quality = '', count = 1, model = '' } = {}) {
|
|
178
|
+
const id = String(messageId || '').trim();
|
|
179
|
+
const conversation = String(conversationId || '').trim();
|
|
180
|
+
if (!id || !conversation) return null;
|
|
181
|
+
// A second run of the same message (never more than one is sent): the
|
|
182
|
+
// earlier one's previews go first.
|
|
183
|
+
const earlier = turns.get(id);
|
|
184
|
+
if (earlier) {
|
|
185
|
+
earlier.running = false;
|
|
186
|
+
finishEntry(earlier, 'failed');
|
|
187
|
+
}
|
|
188
|
+
const conversationDir = conversationDirOf(conversation);
|
|
189
|
+
const safeMessageId = normalizeStorageSegment(id, 'message');
|
|
190
|
+
const entry = {
|
|
191
|
+
conversationId: conversation,
|
|
192
|
+
messageId: id,
|
|
193
|
+
safeMessageId,
|
|
194
|
+
partialsDir: conversationDir ? path.join(conversationDir, safeMessageId, OPENAI_IMAGE_PARTIALS_DIR) : '',
|
|
195
|
+
size: String(size || ''),
|
|
196
|
+
quality: String(quality || ''),
|
|
197
|
+
count: Math.max(1, Math.trunc(Number(count) || 1)),
|
|
198
|
+
model: String(model || ''),
|
|
199
|
+
startedAt: new Date(now()).toISOString(),
|
|
200
|
+
attempt: 0,
|
|
201
|
+
files: new Map(),
|
|
202
|
+
running: true,
|
|
203
|
+
finishRequested: null,
|
|
204
|
+
timer: null,
|
|
205
|
+
};
|
|
206
|
+
turns.set(id, entry);
|
|
207
|
+
|
|
208
|
+
const current = () => turns.get(id) === entry;
|
|
209
|
+
const base = () => ({ conversationId: entry.conversationId, messageId: entry.messageId });
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
/** A try went to OpenAI (1, then 2 after a retry). */
|
|
213
|
+
started(attempt) {
|
|
214
|
+
if (!current()) return;
|
|
215
|
+
entry.attempt = Math.max(1, Math.trunc(Number(attempt) || 1));
|
|
216
|
+
send(OPENAI_IMAGE_STARTED_EVENT, {
|
|
217
|
+
...base(),
|
|
218
|
+
attempt: entry.attempt,
|
|
219
|
+
size: entry.size,
|
|
220
|
+
quality: entry.quality,
|
|
221
|
+
count: entry.count,
|
|
222
|
+
model: entry.model,
|
|
223
|
+
startedAt: entry.startedAt,
|
|
224
|
+
});
|
|
225
|
+
},
|
|
226
|
+
/**
|
|
227
|
+
* A second try is about to start: the first try's partials go, and the
|
|
228
|
+
* clients drop what they showed (`attempt` of the reset: the try whose
|
|
229
|
+
* previews went).
|
|
230
|
+
*/
|
|
231
|
+
retry() {
|
|
232
|
+
if (!current()) return;
|
|
233
|
+
removeFiles(entry, { attempt: entry.attempt });
|
|
234
|
+
send(OPENAI_IMAGE_PREVIEW_RESET_EVENT, { ...base(), attempt: entry.attempt, reason: 'retry' });
|
|
235
|
+
},
|
|
236
|
+
/**
|
|
237
|
+
* A partial image arrived: written, then announced. Resolves the event
|
|
238
|
+
* payload, or null when it was not written (no root, empty data, the
|
|
239
|
+
* try or the turn already over).
|
|
240
|
+
*/
|
|
241
|
+
async partial({ imageIndex = 0, partialIndex = 0, b64 = '', outputFormat = '' } = {}) {
|
|
242
|
+
if (!current() || !entry.partialsDir) return null;
|
|
243
|
+
const attempt = entry.attempt;
|
|
244
|
+
const bytes = Buffer.from(String(b64 || ''), 'base64');
|
|
245
|
+
if (!bytes.length) return null;
|
|
246
|
+
const format = String(outputFormat || '').trim().toLowerCase();
|
|
247
|
+
const mime = readImageHeader(bytes)?.mime || OPENAI_IMAGE_MIME_BY_FORMAT[format] || 'image/png';
|
|
248
|
+
const extension = EXTENSION_BY_MIME[mime] || 'png';
|
|
249
|
+
const image = Math.max(0, Math.trunc(Number(imageIndex) || 0));
|
|
250
|
+
const partial = Math.max(0, Math.trunc(Number(partialIndex) || 0));
|
|
251
|
+
// Never more files than the turn asked for: OPENAI_IMAGE_PARTIAL_IMAGES
|
|
252
|
+
// per image, for its count of images.
|
|
253
|
+
if (partial >= OPENAI_IMAGE_PARTIAL_IMAGES || image >= entry.count) return null;
|
|
254
|
+
const fileName = `img-${String(image + 1).padStart(2, '0')}-try${attempt}-p${partial}.${extension}`;
|
|
255
|
+
const filePath = path.join(entry.partialsDir, fileName);
|
|
256
|
+
const tmpPath = `${filePath}.${process.pid}.${now()}.tmp`;
|
|
257
|
+
try {
|
|
258
|
+
await fs.promises.mkdir(entry.partialsDir, { recursive: true });
|
|
259
|
+
await fs.promises.writeFile(tmpPath, bytes);
|
|
260
|
+
await fs.promises.rename(tmpPath, filePath);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
removeQuietly(tmpPath);
|
|
263
|
+
logger?.warn?.(`IMAGE partial ${fileName} of ${entry.messageId} not written: ${error?.message || error}`);
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
// The try ended (a retry, the turn's end) while the file was written.
|
|
267
|
+
if (!current() || entry.attempt !== attempt) {
|
|
268
|
+
removeQuietly(filePath);
|
|
269
|
+
if (!current()) removeIfEmpty(entry.partialsDir);
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
entry.files.set(fileName, { path: filePath, mime, attempt });
|
|
273
|
+
const payload = {
|
|
274
|
+
...base(),
|
|
275
|
+
attempt,
|
|
276
|
+
imageIndex: image,
|
|
277
|
+
partialIndex: partial,
|
|
278
|
+
contentUrl: openAIImagePartialContentUrl(entry.conversationId, entry.messageId, fileName),
|
|
279
|
+
mime,
|
|
280
|
+
size: entry.size,
|
|
281
|
+
quality: entry.quality,
|
|
282
|
+
model: entry.model,
|
|
283
|
+
startedAt: entry.startedAt,
|
|
284
|
+
};
|
|
285
|
+
send(OPENAI_IMAGE_PREVIEW_EVENT, payload);
|
|
286
|
+
return payload;
|
|
287
|
+
},
|
|
288
|
+
/**
|
|
289
|
+
* The request is over. A failed one drops its partials now (the turn's
|
|
290
|
+
* end tells the clients once the note is stored); a good one keeps them
|
|
291
|
+
* until the reply is. Either way a timer ends a turn whose end never
|
|
292
|
+
* comes.
|
|
293
|
+
*/
|
|
294
|
+
ended({ ok = false } = {}) {
|
|
295
|
+
if (!current()) return;
|
|
296
|
+
entry.running = false;
|
|
297
|
+
if (!ok) removeFiles(entry);
|
|
298
|
+
if (entry.finishRequested) {
|
|
299
|
+
finishEntry(entry, entry.finishRequested);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
entry.timer = setTimeout(() => {
|
|
303
|
+
if (current()) finishEntry(entry, ok ? 'done' : 'failed');
|
|
304
|
+
}, Math.max(0, Number(leftoverMs) || 0));
|
|
305
|
+
entry.timer.unref?.();
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The file of a partial of a running turn, for the owner's content route:
|
|
312
|
+
* `{ path, mime }` only for a file this turn wrote and still has, of the
|
|
313
|
+
* conversation named. Null for anything else (no path is ever built from
|
|
314
|
+
* the request).
|
|
315
|
+
*/
|
|
316
|
+
function partialFile({ conversationId, messageId, fileName } = {}) {
|
|
317
|
+
const entry = turns.get(String(messageId || ''));
|
|
318
|
+
if (!entry || entry.conversationId !== String(conversationId || '')) return null;
|
|
319
|
+
const file = entry.files.get(String(fileName || ''));
|
|
320
|
+
if (!file) return null;
|
|
321
|
+
return { path: file.path, mime: file.mime };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
begin,
|
|
326
|
+
finish,
|
|
327
|
+
partialFile,
|
|
328
|
+
has: (messageId) => turns.has(String(messageId || '')),
|
|
329
|
+
};
|
|
330
|
+
}
|