@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.
Files changed (110) hide show
  1. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +3 -1
  2. package/CHANGELOG.md +307 -0
  3. package/README.md +12 -11
  4. package/bin/oar.js +54 -4
  5. package/package.json +11 -7
  6. package/server/agent-sdk-loader.mjs +116 -0
  7. package/server/agent-sdks/claude/entry.mjs +4 -0
  8. package/server/agent-sdks/claude/package-lock.json +1438 -0
  9. package/server/agent-sdks/claude/package.json +13 -0
  10. package/server/agent-sdks/cursor/entry-sqlite.mjs +2 -0
  11. package/server/agent-sdks/cursor/entry.mjs +3 -0
  12. package/server/agent-sdks/cursor/package-lock.json +171 -0
  13. package/server/agent-sdks/cursor/package.json +11 -0
  14. package/server/agent-sdks/koffi/entry.cjs +2 -0
  15. package/server/agent-sdks/koffi/package-lock.json +368 -0
  16. package/server/agent-sdks/koffi/package.json +10 -0
  17. package/server/claude-worker/claude-preview-tool.mjs +5 -2
  18. package/server/claude-worker/claude-remote-relay-tool.mjs +20 -2
  19. package/server/claude-worker/claude-sdk-adapter.mjs +4 -1
  20. package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
  21. package/server/copilot-worker/copilot-sdk-session-process.mjs +13 -8
  22. package/server/cursor-worker/cursor-sdk-adapter.mjs +5 -3
  23. package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
  24. package/server/db-schema.mjs +19 -0
  25. package/server/grok-worker/sdk-message-normalizer.mjs +3 -0
  26. package/server/migrations/0039-media-index.mjs +167 -0
  27. package/server/migrations/0040-image-session-defaults.mjs +93 -0
  28. package/server/public/app/agent-sdk-ui.js +349 -0
  29. package/server/public/app/api-client.js +149 -8
  30. package/server/public/app/ask-user-view.js +2 -1
  31. package/server/public/app/attachments-view.js +66 -9
  32. package/server/public/app/bootstrap.js +289 -94
  33. package/server/public/app/chat-actions-menu-state.mjs +1 -0
  34. package/server/public/app/context-media-tab.js +143 -0
  35. package/server/public/app/conversation-view.js +90 -5
  36. package/server/public/app/image-composer-ui.js +518 -0
  37. package/server/public/app/image-turn-placeholder.mjs +233 -0
  38. package/server/public/app/journal-view.js +12 -2
  39. package/server/public/app/media-gallery-model.mjs +202 -0
  40. package/server/public/app/media-gallery-ui.js +524 -0
  41. package/server/public/app/media-view.js +79 -0
  42. package/server/public/app/model-refresh-ui.mjs +413 -0
  43. package/server/public/app/no-conversation-state.mjs +5 -2
  44. package/server/public/app/openai-image-composer.mjs +492 -0
  45. package/server/public/app/openai-image-cost-ui.js +426 -0
  46. package/server/public/app/openai-image-spend.mjs +443 -0
  47. package/server/public/app/plan-usage-view.mjs +38 -6
  48. package/server/public/app/provider-readiness.mjs +67 -4
  49. package/server/public/app/relay-error-ctas.mjs +5 -0
  50. package/server/public/app/session-history-ui.js +2 -0
  51. package/server/public/app/settings-modal.js +171 -0
  52. package/server/public/app/socket-handlers.js +47 -0
  53. package/server/public/app/tab-strip-fade.mjs +1 -1
  54. package/server/public/app/viewer-gallery.mjs +23 -1
  55. package/server/public/index.html +478 -9
  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/agent-sdk-routes.mjs +73 -0
  60. package/server/routes/media-routes.mjs +361 -0
  61. package/server/routes/messages-routes.mjs +1467 -343
  62. package/server/routes/sessions-routes.mjs +596 -123
  63. package/server/server-runtime.mjs +414 -81
  64. package/server/services/about-service.mjs +26 -6
  65. package/server/services/agent-sdk-launch-gate.mjs +124 -0
  66. package/server/services/agent-sdk-paths.mjs +307 -0
  67. package/server/services/agent-sdk-store.mjs +1211 -0
  68. package/server/services/claude-api-settings-service.mjs +54 -17
  69. package/server/services/cli-install-service.mjs +3 -2
  70. package/server/services/copilot-model-discovery-service.mjs +7 -1
  71. package/server/services/database-file.mjs +23 -5
  72. package/server/services/deepseek-settings-service.mjs +58 -20
  73. package/server/services/earlier-database-service.mjs +4 -2
  74. package/server/services/generated-image-cleanup-service.mjs +2 -1
  75. package/server/services/generated-image-files.mjs +110 -0
  76. package/server/services/image-input-paths.mjs +389 -0
  77. package/server/services/image-operation-service.mjs +6 -2
  78. package/server/services/media-extract.mjs +653 -0
  79. package/server/services/media-index-service.mjs +221 -0
  80. package/server/services/model-refresh-scheduler.mjs +153 -0
  81. package/server/services/model-variant-catalog-service.mjs +141 -9
  82. package/server/services/oar-cli-helpers.mjs +72 -9
  83. package/server/services/oar-state-migration-service.mjs +2 -12
  84. package/server/services/oar-state-root.mjs +17 -0
  85. package/server/services/openai-image-preview-turns.mjs +330 -0
  86. package/server/services/openai-image-spend-service.mjs +516 -0
  87. package/server/services/openai-image-stream.mjs +170 -0
  88. package/server/services/plan-usage-openai-image.mjs +148 -0
  89. package/server/services/provider-model-refresh.mjs +326 -0
  90. package/server/services/provider-model-state.mjs +395 -0
  91. package/server/services/relay-singleton-guard.mjs +72 -10
  92. package/server/services/remote-relay-caller-context.mjs +6 -0
  93. package/server/services/remote-relay-dispatcher.mjs +386 -22
  94. package/server/services/remote-relay-registry-service.mjs +3 -1
  95. package/server/services/session-worker-launch-service.mjs +13 -0
  96. package/server/services/session-worker-supervisor-service.mjs +19 -1
  97. package/server/services/session-worker-websocket-service.mjs +15 -0
  98. package/server/services/token-usage-service.mjs +15 -0
  99. package/server/services/update-install-service.mjs +2 -2
  100. package/server/services/worker-launch-note-service.mjs +14 -2
  101. package/server/services/worker-recycle-service.mjs +145 -11
  102. package/shared/image-header.mjs +90 -0
  103. package/shared/model-refresh-schedule.mjs +120 -0
  104. package/shared/openai-image-options.mjs +299 -0
  105. package/shared/openai-image-presets.mjs +175 -0
  106. package/shared/openai-image-previews.mjs +40 -0
  107. package/shared/openai-image-prices.mjs +291 -0
  108. package/shared/openai-image-spend.mjs +90 -0
  109. package/shared/remote-relay-contract.mjs +126 -4
  110. 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 allow, and
406
- * a global install has no package.json of its own to say so; npm 10 and 11
407
- * accept the flag and run the scripts as before. `npm-install-scripts.test.mjs`
408
- * holds this list to the lockfile. better-sqlite3 13 has no install script:
409
- * where it ships no binary, OAR builds it (native-addon-build-service.mjs).
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(['koffi']);
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 bundled @cursor/sdk npm
446
- * package and never invokes a cursor-agent binary.
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 function resolveOarRoot(env = process.env, platform = process.platform) {
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
+ }