@oar-sh/oar 0.9.24 → 0.9.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/extensions/web-relay/skills/reasoning-stream.mjs +3 -1
- package/CHANGELOG.md +284 -0
- package/README.md +12 -11
- package/package.json +5 -1
- package/server/claude-worker/claude-remote-relay-tool.mjs +15 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +8 -4
- package/server/claude-worker/claude-session-process.mjs +4 -0
- package/server/claude-worker/claude-turn-publisher.mjs +25 -11
- package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
- package/server/copilot-worker/copilot-prompt-context.mjs +2 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +11 -9
- package/server/cursor-worker/cursor-turn-runner.mjs +3 -1
- package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/grok-turn-runner.mjs +3 -1
- 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/api-client.js +58 -0
- 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 +85 -25
- 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/context-usage-view.mjs +2 -2
- 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/live-activity-cap.mjs +3 -3
- package/server/public/app/markdown-math.mjs +471 -0
- 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/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/pwa-install.js +25 -4
- package/server/public/app/relay-error-ctas.mjs +5 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +3 -0
- package/server/public/app/relay-restart-ui.js +2 -2
- package/server/public/app/remote-relays-settings.js +33 -6
- package/server/public/app/router.js +49 -237
- package/server/public/app/session-history-ui.js +2 -0
- package/server/public/app/settings-modal.js +4 -0
- package/server/public/app/settings-tabs.js +10 -1
- package/server/public/app/socket-handlers.js +26 -0
- package/server/public/app/store.js +2 -0
- package/server/public/app/stream-view-preference.mjs +1 -1
- package/server/public/app/tab-strip-fade.mjs +1 -1
- package/server/public/app/thoughts-preference.mjs +1 -1
- package/server/public/app/tmux-inspector-view.js +29 -16
- package/server/public/app/viewer-gallery.mjs +23 -1
- package/server/public/index.html +517 -123
- 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/media-routes.mjs +361 -0
- package/server/routes/messages-routes.mjs +1410 -360
- package/server/routes/relay-board-routes.mjs +46 -1
- package/server/routes/remote-relay-routes.mjs +1 -1
- package/server/routes/sessions-routes.mjs +361 -14
- package/server/server-runtime.mjs +21 -0
- package/server/services/database-file.mjs +6 -4
- 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/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/relay-prompt-sanitizer.mjs +7 -0
- package/server/services/relay-restart-queue.mjs +1 -1
- package/server/services/remote-relay-caller-context.mjs +6 -0
- package/server/services/remote-relay-dispatcher.mjs +409 -23
- package/server/services/remote-relay-registry-service.mjs +10 -2
- package/server/services/session-worker-stop-service.mjs +50 -18
- package/server/services/token-usage-service.mjs +15 -0
- package/shared/image-header.mjs +90 -0
- package/shared/math-instructions.mjs +30 -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 +96 -50
- package/server/public/app/math-dollars.mjs +0 -89
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
|
|
3
|
+
import { createMediaRepository } from '../repositories/media-repository.mjs';
|
|
4
|
+
import { extractMediaFromMessage } from './media-extract.mjs';
|
|
5
|
+
|
|
6
|
+
// Fills the media gallery index (migration 0039) from its queue. The
|
|
7
|
+
// triggers on `messages` only queue ids; this service reads each queued
|
|
8
|
+
// message, extracts its media and replaces the message's rows, a batch per
|
|
9
|
+
// transaction.
|
|
10
|
+
//
|
|
11
|
+
// Draining is synchronous (better-sqlite3), so a batch never interleaves with
|
|
12
|
+
// a turn's writes; a message written after it was read is re-queued (its seq
|
|
13
|
+
// grows) and indexed by a later batch.
|
|
14
|
+
//
|
|
15
|
+
// Scheduling: a background loop runs only while the queue has rows (the
|
|
16
|
+
// backfill after the migration, or what piled up since the last gallery
|
|
17
|
+
// request) and stops when it is empty, so an idle relay runs no timer for
|
|
18
|
+
// the gallery. Every gallery request first drains a bounded number of
|
|
19
|
+
// messages itself (`drainForRequest`) so new pictures show at once, and
|
|
20
|
+
// restarts the loop when rows are left.
|
|
21
|
+
|
|
22
|
+
export const MEDIA_INDEX_BATCH_SIZE = 10;
|
|
23
|
+
export const MEDIA_INDEX_REQUEST_DRAIN = 200;
|
|
24
|
+
// Time budgets per drain, so indexing never holds the event loop for long:
|
|
25
|
+
// the background loop indexes for ~20 ms every 500 ms in batches of ten
|
|
26
|
+
// messages (a slice ends with its batch's commit), a gallery request up to
|
|
27
|
+
// 100 ms; the rest waits for the loop.
|
|
28
|
+
export const MEDIA_INDEX_LOOP_BUDGET_MS = 20;
|
|
29
|
+
export const MEDIA_INDEX_REQUEST_BUDGET_MS = 100;
|
|
30
|
+
export const MEDIA_INDEX_INTERVAL_MS = 500;
|
|
31
|
+
// A message body above this is read in a slice of its own.
|
|
32
|
+
export const MEDIA_INDEX_LARGE_BODY_BYTES = 1024 * 1024;
|
|
33
|
+
|
|
34
|
+
export function createMediaIndexService({
|
|
35
|
+
db,
|
|
36
|
+
repository = createMediaRepository(db),
|
|
37
|
+
extract = extractMediaFromMessage,
|
|
38
|
+
platform = process.platform,
|
|
39
|
+
batchSize = MEDIA_INDEX_BATCH_SIZE,
|
|
40
|
+
intervalMs = MEDIA_INDEX_INTERVAL_MS,
|
|
41
|
+
setTimer = setTimeout,
|
|
42
|
+
clearTimer = clearTimeout,
|
|
43
|
+
logger = console,
|
|
44
|
+
now = () => performance.now(),
|
|
45
|
+
} = {}) {
|
|
46
|
+
let timer = null;
|
|
47
|
+
let stopped = true;
|
|
48
|
+
let warnedExtractError = false;
|
|
49
|
+
|
|
50
|
+
function indexRow(row) {
|
|
51
|
+
let items = [];
|
|
52
|
+
try {
|
|
53
|
+
items = extract({
|
|
54
|
+
role: row.role,
|
|
55
|
+
text: row.text,
|
|
56
|
+
attachments: row.attachments,
|
|
57
|
+
conversationId: row.conversationId,
|
|
58
|
+
messageId: row.messageId,
|
|
59
|
+
workspaceRoot: row.workspaceRoot || '',
|
|
60
|
+
platform,
|
|
61
|
+
});
|
|
62
|
+
} catch (error) {
|
|
63
|
+
// A message the extractor cannot read gets no media; its queue row
|
|
64
|
+
// still goes, so it cannot block the queue.
|
|
65
|
+
if (!warnedExtractError) {
|
|
66
|
+
warnedExtractError = true;
|
|
67
|
+
logger?.warn?.(`[media-index] could not read a message; it gets no media: ${error?.message || error}`);
|
|
68
|
+
}
|
|
69
|
+
items = [];
|
|
70
|
+
}
|
|
71
|
+
return repository.replaceMessageItems(row, items);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Indexes up to `maxMessages` queued messages now, within `budgetMs`; says how many are left. */
|
|
75
|
+
function drain(options = {}) {
|
|
76
|
+
const result = drainSlice(options);
|
|
77
|
+
return { ...result, pending: repository.hasPending() ? repository.pendingCount() : 0 };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function drainSlice({ maxMessages = batchSize, budgetMs = Infinity } = {}) {
|
|
81
|
+
let indexed = 0;
|
|
82
|
+
let items = 0;
|
|
83
|
+
if (!repository.available) return { indexed, items };
|
|
84
|
+
const started = now();
|
|
85
|
+
let outOfTime = false;
|
|
86
|
+
while (indexed < maxMessages && !outOfTime) {
|
|
87
|
+
const rows = repository.nextQueueBatch(Math.min(batchSize, maxMessages - indexed));
|
|
88
|
+
if (!rows.length) break;
|
|
89
|
+
// One commit per batch: a commit per message costs more than the indexing.
|
|
90
|
+
repository.inTransaction(() => {
|
|
91
|
+
for (const entry of rows) {
|
|
92
|
+
// A large body (a pasted data image) is read only at the start of a
|
|
93
|
+
// slice, so reading it never adds to work already done in the slice.
|
|
94
|
+
if (indexed > 0 && Number(entry.bytes) > MEDIA_INDEX_LARGE_BODY_BYTES) {
|
|
95
|
+
outOfTime = true;
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
const row = repository.loadQueuedMessage(entry);
|
|
99
|
+
if (row) items += indexRow(row);
|
|
100
|
+
indexed += 1;
|
|
101
|
+
if (now() - started >= budgetMs) {
|
|
102
|
+
outOfTime = true;
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
if (rows.length < batchSize) break;
|
|
108
|
+
}
|
|
109
|
+
return { indexed, items };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function schedule() {
|
|
113
|
+
if (stopped || timer || !repository.available) return;
|
|
114
|
+
timer = setTimer(tick, intervalMs);
|
|
115
|
+
timer?.unref?.();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function tick() {
|
|
119
|
+
timer = null;
|
|
120
|
+
if (stopped) return;
|
|
121
|
+
let more = false;
|
|
122
|
+
try {
|
|
123
|
+
drainSlice({ maxMessages: Infinity, budgetMs: MEDIA_INDEX_LOOP_BUDGET_MS });
|
|
124
|
+
more = repository.hasPending();
|
|
125
|
+
} catch (error) {
|
|
126
|
+
logger?.warn?.(`[media-index] indexing failed; retrying later: ${error?.message || error}`);
|
|
127
|
+
more = true;
|
|
128
|
+
}
|
|
129
|
+
if (more) schedule();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
get available() {
|
|
134
|
+
return repository.available;
|
|
135
|
+
},
|
|
136
|
+
drain,
|
|
137
|
+
/** Before answering a gallery request: index what is new, keep the rest for the loop. */
|
|
138
|
+
drainForRequest() {
|
|
139
|
+
const result = drain({ maxMessages: MEDIA_INDEX_REQUEST_DRAIN, budgetMs: MEDIA_INDEX_REQUEST_BUDGET_MS });
|
|
140
|
+
if (result.pending > 0) schedule();
|
|
141
|
+
return result;
|
|
142
|
+
},
|
|
143
|
+
start() {
|
|
144
|
+
if (!repository.available) return;
|
|
145
|
+
stopped = false;
|
|
146
|
+
try {
|
|
147
|
+
repository.removeOrphanQueueRows();
|
|
148
|
+
} catch (error) {
|
|
149
|
+
logger?.warn?.(`[media-index] could not clean the queue: ${error?.message || error}`);
|
|
150
|
+
}
|
|
151
|
+
if (repository.hasPending()) schedule();
|
|
152
|
+
},
|
|
153
|
+
stop() {
|
|
154
|
+
stopped = true;
|
|
155
|
+
if (timer) clearTimer(timer);
|
|
156
|
+
timer = null;
|
|
157
|
+
},
|
|
158
|
+
pendingCount: () => repository.pendingCount(),
|
|
159
|
+
isScheduled: () => timer !== null,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// --- whether a file is still there -------------------------------------------
|
|
164
|
+
|
|
165
|
+
export const FILE_PRESENCE_TTL_MS = 60_000;
|
|
166
|
+
export const FILE_PRESENCE_MAX_ENTRIES = 5000;
|
|
167
|
+
export const FILE_PRESENCE_TIMEOUT_MS = 1500;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* A short-lived cache of "is this a regular file" answers, for hiding gallery
|
|
171
|
+
* items whose file was deleted or moved. Async stat with a timeout: a mapped
|
|
172
|
+
* network drive that is offline must not hold a gallery page. A stat that
|
|
173
|
+
* times out counts as missing for this page and is not cached.
|
|
174
|
+
*/
|
|
175
|
+
export function createFilePresenceCache({
|
|
176
|
+
stat = (filePath) => fs.promises.stat(filePath),
|
|
177
|
+
ttlMs = FILE_PRESENCE_TTL_MS,
|
|
178
|
+
maxEntries = FILE_PRESENCE_MAX_ENTRIES,
|
|
179
|
+
timeoutMs = FILE_PRESENCE_TIMEOUT_MS,
|
|
180
|
+
now = () => Date.now(),
|
|
181
|
+
} = {}) {
|
|
182
|
+
const entries = new Map();
|
|
183
|
+
|
|
184
|
+
function remember(filePath, present) {
|
|
185
|
+
entries.delete(filePath);
|
|
186
|
+
entries.set(filePath, { present, at: now() });
|
|
187
|
+
while (entries.size > maxEntries) {
|
|
188
|
+
entries.delete(entries.keys().next().value);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function isFile(filePath) {
|
|
193
|
+
const key = String(filePath || '');
|
|
194
|
+
if (!key) return false;
|
|
195
|
+
const cached = entries.get(key);
|
|
196
|
+
if (cached && now() - cached.at < ttlMs) return cached.present;
|
|
197
|
+
let timeoutHandle = null;
|
|
198
|
+
const timeout = new Promise((resolve) => {
|
|
199
|
+
timeoutHandle = setTimeout(() => resolve('timeout'), timeoutMs);
|
|
200
|
+
timeoutHandle?.unref?.();
|
|
201
|
+
});
|
|
202
|
+
try {
|
|
203
|
+
const result = await Promise.race([
|
|
204
|
+
Promise.resolve().then(() => stat(key)).then((info) => info.isFile(), () => false),
|
|
205
|
+
timeout,
|
|
206
|
+
]);
|
|
207
|
+
if (result === 'timeout') return false;
|
|
208
|
+
remember(key, result === true);
|
|
209
|
+
return result === true;
|
|
210
|
+
} finally {
|
|
211
|
+
clearTimeout(timeoutHandle);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
isFile,
|
|
217
|
+
forget: (filePath) => entries.delete(String(filePath || '')),
|
|
218
|
+
clear: () => entries.clear(),
|
|
219
|
+
size: () => entries.size,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
@@ -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
|
+
}
|