@oar-sh/oar 0.9.23 → 0.9.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/extensions/web-relay/skills/reasoning-stream.mjs +3 -1
- package/CHANGELOG.md +307 -0
- package/README.md +12 -11
- package/bin/oar.js +54 -4
- package/package.json +11 -7
- package/server/agent-sdk-loader.mjs +116 -0
- package/server/agent-sdks/claude/entry.mjs +4 -0
- package/server/agent-sdks/claude/package-lock.json +1438 -0
- package/server/agent-sdks/claude/package.json +13 -0
- package/server/agent-sdks/cursor/entry-sqlite.mjs +2 -0
- package/server/agent-sdks/cursor/entry.mjs +3 -0
- package/server/agent-sdks/cursor/package-lock.json +171 -0
- package/server/agent-sdks/cursor/package.json +11 -0
- package/server/agent-sdks/koffi/entry.cjs +2 -0
- package/server/agent-sdks/koffi/package-lock.json +368 -0
- package/server/agent-sdks/koffi/package.json +10 -0
- package/server/claude-worker/claude-preview-tool.mjs +5 -2
- package/server/claude-worker/claude-remote-relay-tool.mjs +20 -2
- package/server/claude-worker/claude-sdk-adapter.mjs +4 -1
- package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +13 -8
- package/server/cursor-worker/cursor-sdk-adapter.mjs +5 -3
- package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/sdk-message-normalizer.mjs +3 -0
- package/server/migrations/0039-media-index.mjs +167 -0
- package/server/migrations/0040-image-session-defaults.mjs +93 -0
- package/server/public/app/agent-sdk-ui.js +349 -0
- package/server/public/app/api-client.js +149 -8
- package/server/public/app/ask-user-view.js +2 -1
- package/server/public/app/attachments-view.js +66 -9
- package/server/public/app/bootstrap.js +289 -94
- package/server/public/app/chat-actions-menu-state.mjs +1 -0
- package/server/public/app/context-media-tab.js +143 -0
- package/server/public/app/conversation-view.js +90 -5
- package/server/public/app/image-composer-ui.js +518 -0
- package/server/public/app/image-turn-placeholder.mjs +233 -0
- package/server/public/app/journal-view.js +12 -2
- package/server/public/app/media-gallery-model.mjs +202 -0
- package/server/public/app/media-gallery-ui.js +524 -0
- package/server/public/app/media-view.js +79 -0
- package/server/public/app/model-refresh-ui.mjs +413 -0
- package/server/public/app/no-conversation-state.mjs +5 -2
- package/server/public/app/openai-image-composer.mjs +492 -0
- package/server/public/app/openai-image-cost-ui.js +426 -0
- package/server/public/app/openai-image-spend.mjs +443 -0
- package/server/public/app/plan-usage-view.mjs +38 -6
- package/server/public/app/provider-readiness.mjs +67 -4
- package/server/public/app/relay-error-ctas.mjs +5 -0
- package/server/public/app/session-history-ui.js +2 -0
- package/server/public/app/settings-modal.js +171 -0
- package/server/public/app/socket-handlers.js +47 -0
- package/server/public/app/tab-strip-fade.mjs +1 -1
- package/server/public/app/viewer-gallery.mjs +23 -1
- package/server/public/index.html +478 -9
- package/server/relay-tools.md +1 -0
- package/server/repositories/image-conversation-repository.mjs +69 -0
- package/server/repositories/media-repository.mjs +305 -0
- package/server/routes/agent-sdk-routes.mjs +73 -0
- package/server/routes/media-routes.mjs +361 -0
- package/server/routes/messages-routes.mjs +1467 -343
- package/server/routes/sessions-routes.mjs +596 -123
- package/server/server-runtime.mjs +414 -81
- package/server/services/about-service.mjs +26 -6
- package/server/services/agent-sdk-launch-gate.mjs +124 -0
- package/server/services/agent-sdk-paths.mjs +307 -0
- package/server/services/agent-sdk-store.mjs +1211 -0
- package/server/services/claude-api-settings-service.mjs +54 -17
- package/server/services/cli-install-service.mjs +3 -2
- package/server/services/copilot-model-discovery-service.mjs +7 -1
- package/server/services/database-file.mjs +23 -5
- package/server/services/deepseek-settings-service.mjs +58 -20
- package/server/services/earlier-database-service.mjs +4 -2
- package/server/services/generated-image-cleanup-service.mjs +2 -1
- package/server/services/generated-image-files.mjs +110 -0
- package/server/services/image-input-paths.mjs +389 -0
- package/server/services/image-operation-service.mjs +6 -2
- package/server/services/media-extract.mjs +653 -0
- package/server/services/media-index-service.mjs +221 -0
- package/server/services/model-refresh-scheduler.mjs +153 -0
- package/server/services/model-variant-catalog-service.mjs +141 -9
- package/server/services/oar-cli-helpers.mjs +72 -9
- package/server/services/oar-state-migration-service.mjs +2 -12
- package/server/services/oar-state-root.mjs +17 -0
- package/server/services/openai-image-preview-turns.mjs +330 -0
- package/server/services/openai-image-spend-service.mjs +516 -0
- package/server/services/openai-image-stream.mjs +170 -0
- package/server/services/plan-usage-openai-image.mjs +148 -0
- package/server/services/provider-model-refresh.mjs +326 -0
- package/server/services/provider-model-state.mjs +395 -0
- package/server/services/relay-singleton-guard.mjs +72 -10
- package/server/services/remote-relay-caller-context.mjs +6 -0
- package/server/services/remote-relay-dispatcher.mjs +386 -22
- package/server/services/remote-relay-registry-service.mjs +3 -1
- package/server/services/session-worker-launch-service.mjs +13 -0
- package/server/services/session-worker-supervisor-service.mjs +19 -1
- package/server/services/session-worker-websocket-service.mjs +15 -0
- package/server/services/token-usage-service.mjs +15 -0
- package/server/services/update-install-service.mjs +2 -2
- package/server/services/worker-launch-note-service.mjs +14 -2
- package/server/services/worker-recycle-service.mjs +145 -11
- package/shared/image-header.mjs +90 -0
- package/shared/model-refresh-schedule.mjs +120 -0
- package/shared/openai-image-options.mjs +299 -0
- package/shared/openai-image-presets.mjs +175 -0
- package/shared/openai-image-previews.mjs +40 -0
- package/shared/openai-image-prices.mjs +291 -0
- package/shared/openai-image-spend.mjs +90 -0
- package/shared/remote-relay-contract.mjs +126 -4
- package/shared/worker-runtime/direct-openai-image.mjs +98 -48
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// The placeholder of a running OpenAI image turn, inside the live bubble
|
|
2
|
+
// (#thinking-indicator): an elapsed timer, the size, quality, count and
|
|
3
|
+
// model, and the newest partial of each image as the relay streams them
|
|
4
|
+
// (image_started, image_preview, image_preview_reset; owner pages only).
|
|
5
|
+
//
|
|
6
|
+
// It lives in the live bubble, so it goes with it: never stored, never in a
|
|
7
|
+
// share view (the relay sends shares no such event, and IS_SHARED_VIEW
|
|
8
|
+
// ignores them all the same), and never in the media gallery (which reads
|
|
9
|
+
// `.msg-bubble img`). The image area is sized by the size's aspect ratio from
|
|
10
|
+
// the start, so a partial arriving does not move the transcript.
|
|
11
|
+
|
|
12
|
+
import { BASE, IS_SHARED_VIEW, currentConvId } from './store.js';
|
|
13
|
+
import {
|
|
14
|
+
formatImageElapsed,
|
|
15
|
+
imageAspectRatio,
|
|
16
|
+
imagePlaceholderDetails,
|
|
17
|
+
reduceImageTurnState,
|
|
18
|
+
} from './openai-image-composer.mjs';
|
|
19
|
+
|
|
20
|
+
const LIVE_BUBBLE_ID = 'thinking-indicator';
|
|
21
|
+
const TICK_MS = 1000;
|
|
22
|
+
|
|
23
|
+
// The open conversation's image turn, or null.
|
|
24
|
+
let turn = null;
|
|
25
|
+
let timer = null;
|
|
26
|
+
|
|
27
|
+
function openConversationId() {
|
|
28
|
+
return String(currentConvId || '').trim();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function liveBubbleFor(state) {
|
|
32
|
+
if (typeof document === 'undefined' || !state) return null;
|
|
33
|
+
const bubble = document.getElementById(LIVE_BUBBLE_ID);
|
|
34
|
+
if (!bubble) return null;
|
|
35
|
+
if (String(bubble.dataset.conversationId || '') !== state.conversationId) return null;
|
|
36
|
+
const bubbleMessageId = String(bubble.dataset.messageId || '').trim();
|
|
37
|
+
if (bubbleMessageId && bubbleMessageId !== state.messageId) return null;
|
|
38
|
+
return bubble;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function stopTimer() {
|
|
42
|
+
if (timer) clearInterval(timer);
|
|
43
|
+
timer = null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function ensureTimer() {
|
|
47
|
+
if (timer || typeof setInterval !== 'function') return;
|
|
48
|
+
timer = setInterval(tick, TICK_MS);
|
|
49
|
+
timer?.unref?.();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function tick() {
|
|
53
|
+
const node = typeof document === 'undefined' ? null : document.querySelector('[data-image-placeholder]');
|
|
54
|
+
if (!node || !turn) {
|
|
55
|
+
stopTimer();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const clock = node.querySelector('[data-image-placeholder-timer]');
|
|
59
|
+
const text = formatImageElapsed(Date.now() - (turn.startedAtMs ?? Date.now()));
|
|
60
|
+
if (clock && clock.textContent !== text) clock.textContent = text;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function removePlaceholderNodes() {
|
|
64
|
+
if (typeof document === 'undefined') return;
|
|
65
|
+
for (const node of document.querySelectorAll('[data-image-placeholder]')) node.remove();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildPlaceholder(state) {
|
|
69
|
+
const root = document.createElement('div');
|
|
70
|
+
root.className = 'image-turn-placeholder';
|
|
71
|
+
root.setAttribute('data-image-placeholder', '');
|
|
72
|
+
root.setAttribute('role', 'status');
|
|
73
|
+
root.innerHTML = `
|
|
74
|
+
<div class="image-turn-placeholder-meta">
|
|
75
|
+
<span class="image-turn-placeholder-label">Making the image</span>
|
|
76
|
+
<span class="image-turn-placeholder-timer" data-image-placeholder-timer>0:00</span>
|
|
77
|
+
<span class="image-turn-placeholder-retry" data-image-placeholder-retry hidden>trying again</span>
|
|
78
|
+
</div>
|
|
79
|
+
<div class="image-turn-placeholder-details" data-image-placeholder-details></div>
|
|
80
|
+
<div class="image-turn-placeholder-grid" data-image-placeholder-grid></div>`;
|
|
81
|
+
root.dataset.messageId = state.messageId;
|
|
82
|
+
return root;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function syncGrid(grid, state) {
|
|
86
|
+
const count = Math.max(1, Number(state.count) || 1);
|
|
87
|
+
grid.dataset.count = String(count);
|
|
88
|
+
const ratio = imageAspectRatio(state.size);
|
|
89
|
+
while (grid.children.length > count) grid.lastElementChild.remove();
|
|
90
|
+
for (let index = 0; index < count; index += 1) {
|
|
91
|
+
let tile = grid.children[index];
|
|
92
|
+
if (!tile) {
|
|
93
|
+
tile = document.createElement('div');
|
|
94
|
+
tile.className = 'image-turn-placeholder-tile';
|
|
95
|
+
tile.dataset.imageIndex = String(index);
|
|
96
|
+
grid.appendChild(tile);
|
|
97
|
+
}
|
|
98
|
+
if (tile.style.aspectRatio !== ratio) tile.style.aspectRatio = ratio;
|
|
99
|
+
const partial = state.partials?.[index] || null;
|
|
100
|
+
let img = tile.querySelector('img[data-image-placeholder-preview]');
|
|
101
|
+
if (!partial) {
|
|
102
|
+
img?.remove();
|
|
103
|
+
tile.classList.remove('has-preview');
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (!img) {
|
|
107
|
+
img = document.createElement('img');
|
|
108
|
+
img.setAttribute('data-image-placeholder-preview', '');
|
|
109
|
+
img.dataset.imageIndex = String(index);
|
|
110
|
+
img.alt = count > 1 ? `Preview of image ${index + 1}` : 'Preview of the image';
|
|
111
|
+
img.decoding = 'async';
|
|
112
|
+
tile.appendChild(img);
|
|
113
|
+
}
|
|
114
|
+
if (img.getAttribute('src') !== partial.url) img.setAttribute('src', partial.url);
|
|
115
|
+
img.dataset.partialIndex = String(partial.partialIndex);
|
|
116
|
+
tile.classList.add('has-preview');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Draws the open conversation's image turn into its live bubble, or takes
|
|
122
|
+
* the placeholder away when there is none (or the bubble is another turn's).
|
|
123
|
+
* Called after each event and whenever the live bubble is (re)built.
|
|
124
|
+
*/
|
|
125
|
+
export function renderImageTurnPlaceholder() {
|
|
126
|
+
if (typeof document === 'undefined') return;
|
|
127
|
+
const state = turn && turn.conversationId === openConversationId() ? turn : null;
|
|
128
|
+
const bubble = liveBubbleFor(state);
|
|
129
|
+
if (!state || !bubble) {
|
|
130
|
+
removePlaceholderNodes();
|
|
131
|
+
if (!state) stopTimer();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const host = bubble.querySelector('.thinking-bubble') || bubble;
|
|
135
|
+
let node = host.querySelector('[data-image-placeholder]');
|
|
136
|
+
if (node && node.dataset.messageId !== state.messageId) {
|
|
137
|
+
node.remove();
|
|
138
|
+
node = null;
|
|
139
|
+
}
|
|
140
|
+
if (!node) {
|
|
141
|
+
node = buildPlaceholder(state);
|
|
142
|
+
const header = host.querySelector('.thinking-bubble-header');
|
|
143
|
+
if (header) header.after(node);
|
|
144
|
+
else host.prepend(node);
|
|
145
|
+
}
|
|
146
|
+
node.dataset.attempt = String(state.attempt);
|
|
147
|
+
const details = node.querySelector('[data-image-placeholder-details]');
|
|
148
|
+
const detailText = imagePlaceholderDetails(state);
|
|
149
|
+
if (details && details.textContent !== detailText) details.textContent = detailText;
|
|
150
|
+
const retry = node.querySelector('[data-image-placeholder-retry]');
|
|
151
|
+
if (retry) retry.hidden = !state.retrying;
|
|
152
|
+
const grid = node.querySelector('[data-image-placeholder-grid]');
|
|
153
|
+
if (grid) syncGrid(grid, state);
|
|
154
|
+
tick();
|
|
155
|
+
ensureTimer();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function accept(payload) {
|
|
159
|
+
if (IS_SHARED_VIEW || !payload || typeof payload !== 'object') return false;
|
|
160
|
+
const conversationId = String(payload.conversationId || '').trim();
|
|
161
|
+
return !!conversationId && conversationId === openConversationId();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function apply(type, payload) {
|
|
165
|
+
if (!accept(payload)) return;
|
|
166
|
+
// Another turn of the open conversation replaces the one held.
|
|
167
|
+
const held = turn && turn.conversationId === payload.conversationId ? turn : null;
|
|
168
|
+
turn = reduceImageTurnState(held, type, payload, { base: BASE });
|
|
169
|
+
renderImageTurnPlaceholder();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** `image_started` { conversationId, messageId, attempt, size, quality, count, model, startedAt }. */
|
|
173
|
+
export function handleImageStarted(payload) {
|
|
174
|
+
apply('started', payload);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** `image_preview` { conversationId, messageId, attempt, imageIndex, partialIndex, contentUrl, … }. */
|
|
178
|
+
export function handleImagePreview(payload) {
|
|
179
|
+
apply('preview', payload);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** `image_preview_reset` { conversationId, messageId, attempt, reason }. */
|
|
183
|
+
export function handleImagePreviewReset(payload) {
|
|
184
|
+
if (!accept(payload)) return;
|
|
185
|
+
if (!turn || turn.messageId !== String(payload.messageId || '').trim()) return;
|
|
186
|
+
apply('reset', payload);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* A running image turn the page learns of from the conversation's in-flight
|
|
191
|
+
* snapshot (opened or reconnected mid-turn): the timer runs from the turn's
|
|
192
|
+
* own start. What the relay sent live for the same turn is kept; `image`
|
|
193
|
+
* (optional) carries the turn's size, quality, count, model, attempt and
|
|
194
|
+
* startedAt, and `previews` its newest partials, when the snapshot has them.
|
|
195
|
+
*/
|
|
196
|
+
export function noteInFlightImageTurn({ conversationId, messageId, startedAt, model = '', image = null } = {}) {
|
|
197
|
+
if (IS_SHARED_VIEW) return;
|
|
198
|
+
const convId = String(conversationId || '').trim();
|
|
199
|
+
const msgId = String(messageId || '').trim();
|
|
200
|
+
if (!convId || !msgId || convId !== openConversationId()) return;
|
|
201
|
+
const held = turn && turn.conversationId === convId && turn.messageId === msgId ? turn : null;
|
|
202
|
+
const info = image && typeof image === 'object' ? image : {};
|
|
203
|
+
// What the live events said wins over the snapshot.
|
|
204
|
+
let next = held || reduceImageTurnState(null, 'started', {
|
|
205
|
+
conversationId: convId,
|
|
206
|
+
messageId: msgId,
|
|
207
|
+
attempt: info.attempt,
|
|
208
|
+
size: info.size,
|
|
209
|
+
quality: info.quality,
|
|
210
|
+
count: info.count,
|
|
211
|
+
model: info.model || model,
|
|
212
|
+
startedAt: info.startedAt || startedAt,
|
|
213
|
+
}, { base: BASE });
|
|
214
|
+
for (const preview of Array.isArray(info.previews) ? info.previews : []) {
|
|
215
|
+
next = reduceImageTurnState(next, 'preview', { ...preview, conversationId: convId, messageId: msgId }, { base: BASE });
|
|
216
|
+
}
|
|
217
|
+
turn = next;
|
|
218
|
+
renderImageTurnPlaceholder();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The turn ended or the page left it: the placeholder goes. */
|
|
222
|
+
export function clearImageTurnPlaceholder(messageId = '') {
|
|
223
|
+
const id = String(messageId || '').trim();
|
|
224
|
+
if (id && turn && turn.messageId !== id) return;
|
|
225
|
+
turn = null;
|
|
226
|
+
removePlaceholderNodes();
|
|
227
|
+
stopTimer();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** For tests: the held turn state. */
|
|
231
|
+
export function imageTurnPlaceholderState() {
|
|
232
|
+
return turn;
|
|
233
|
+
}
|
|
@@ -126,12 +126,14 @@ import {
|
|
|
126
126
|
} from './new-conversation-model-choice.mjs';
|
|
127
127
|
import { buildCatalogModelOptions } from './model-selector-options.mjs';
|
|
128
128
|
import { isReasoningOffUnsupported, reasoningEffortOptionLabel, reasoningEffortOptionTitle } from './reasoning-effort-labels.mjs';
|
|
129
|
+
import { rememberImageComposerValues } from './image-composer-ui.js';
|
|
129
130
|
import {
|
|
130
131
|
conversationProviderIndicatorKey,
|
|
131
132
|
conversationProviderIndicatorLabel,
|
|
132
133
|
} from './conversation-provider-indicator.mjs';
|
|
133
134
|
import { leaveStatusView } from './status-view.mjs';
|
|
134
135
|
import { leaveInboxView } from './inbox-ui.js';
|
|
136
|
+
import { leaveMediaView } from './media-view.js';
|
|
135
137
|
import {
|
|
136
138
|
CLAUDE_CLOUD_PROVIDER,
|
|
137
139
|
buildCloudSourceWarnings,
|
|
@@ -1330,7 +1332,8 @@ function showConversationUnknown(id, { lastOpenedBefore = '' } = {}) {
|
|
|
1330
1332
|
|
|
1331
1333
|
export async function openConversation(id, options = {}) {
|
|
1332
1334
|
// The Inbox owns the transcript like the status view does.
|
|
1333
|
-
const
|
|
1335
|
+
const didLeaveMediaView = leaveMediaView();
|
|
1336
|
+
const didLeaveInboxView = leaveInboxView() || didLeaveMediaView;
|
|
1334
1337
|
const didLeaveStatusView = leaveStatusView() || didLeaveInboxView;
|
|
1335
1338
|
document.getElementById('input-area')?.removeAttribute('hidden');
|
|
1336
1339
|
const previousConversationId = String(currentConvId || '').trim();
|
|
@@ -1844,9 +1847,14 @@ async function populateNewConversationModelSelect(providerType = 'github') {
|
|
|
1844
1847
|
const cloudDefault = claudeCloudDefaultModel(newConversationClaudeCloudSettingsCache, eligibleModels);
|
|
1845
1848
|
if (cloudDefault) target.value = cloudDefault;
|
|
1846
1849
|
} else {
|
|
1850
|
+
// An OpenAI Image chat starts on the relay's image default (its OpenAI
|
|
1851
|
+
// settings name it) unless the last model used is an image one it offers.
|
|
1852
|
+
const providerDefault = normalizedProvider === 'claude-api'
|
|
1853
|
+
? newConversationClaudeApiSettingsCache?.model
|
|
1854
|
+
: (normalizedProvider === 'openai-image' ? newConversationOpenAISettingsCache?.imageDefaultModel : '');
|
|
1847
1855
|
const preselected = preselectNewConversationModel(Array.from(target.options, (option) => option.value), {
|
|
1848
1856
|
storedModel,
|
|
1849
|
-
providerDefault
|
|
1857
|
+
providerDefault,
|
|
1850
1858
|
allowAuto: normalizedProvider !== 'openai-image',
|
|
1851
1859
|
});
|
|
1852
1860
|
if (preselected) target.value = preselected;
|
|
@@ -2561,6 +2569,8 @@ async function openBootstrappedConversation({
|
|
|
2561
2569
|
// default model and has no effort, so it must not become what the next
|
|
2562
2570
|
// local chat starts with.
|
|
2563
2571
|
if (selectedProvider !== CLAUDE_CLOUD_PROVIDER) rememberBootstrappedSelection(payload);
|
|
2572
|
+
// The chosen size becomes the new chat's own, read when its composer is built.
|
|
2573
|
+
if (selectedProvider === 'openai-image' && selectedSize) rememberImageComposerValues(conversationId, { size: selectedSize });
|
|
2564
2574
|
await refreshConversations();
|
|
2565
2575
|
await openConversation(conversationId);
|
|
2566
2576
|
if (selectedProvider === 'openai-image') {
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// The media gallery's logic without the DOM: the query a view asks
|
|
2
|
+
// GET /api/media with, month sections, how a gallery item becomes an item of
|
|
3
|
+
// the file viewer, what the viewer says about it, and which buttons it gets.
|
|
4
|
+
|
|
5
|
+
export const MEDIA_TEXT = Object.freeze({
|
|
6
|
+
title: 'Media',
|
|
7
|
+
tabMedia: 'Media',
|
|
8
|
+
tabFiles: 'Files',
|
|
9
|
+
tabContext: 'Context',
|
|
10
|
+
sourceAll: 'All',
|
|
11
|
+
sourceMine: 'Mine',
|
|
12
|
+
sourceAgent: 'Agent',
|
|
13
|
+
sourceGenerated: 'Generated',
|
|
14
|
+
allProviders: 'All providers',
|
|
15
|
+
allSessions: 'All sessions',
|
|
16
|
+
hideArchived: 'Hide archived',
|
|
17
|
+
search: 'Search name, alt text, session',
|
|
18
|
+
emptyMedia: 'No pictures or videos yet.',
|
|
19
|
+
emptyFiles: 'No files yet.',
|
|
20
|
+
emptyFiltered: 'Nothing matches these filters.',
|
|
21
|
+
loading: 'Loading…',
|
|
22
|
+
loadMore: 'Load more',
|
|
23
|
+
failed: 'Could not load the media.',
|
|
24
|
+
indexing: (count) => `Indexing older messages… (${count >= 10001 ? '10,000+' : count} left)`,
|
|
25
|
+
missing: (count) => `${count} missing ${count === 1 ? 'file is' : 'files are'} not shown.`,
|
|
26
|
+
sourceLabel: 'Source',
|
|
27
|
+
providerLabel: 'Provider',
|
|
28
|
+
sessionLabel: 'Session',
|
|
29
|
+
untitled: 'picture',
|
|
30
|
+
copied: 'Copied.',
|
|
31
|
+
copyFailed: 'Could not copy.',
|
|
32
|
+
openInSession: 'Open in session',
|
|
33
|
+
copyPath: 'Copy path',
|
|
34
|
+
copyLink: 'Copy link',
|
|
35
|
+
fromYou: '👤 You',
|
|
36
|
+
fromAgent: '🤖 Agent',
|
|
37
|
+
generated: '✨ Generated',
|
|
38
|
+
remote: '🌐',
|
|
39
|
+
archived: 'archived',
|
|
40
|
+
inMessages: (count) => `in ${count} messages`,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export const MEDIA_PAGE_SIZE = 60;
|
|
44
|
+
|
|
45
|
+
/** The query string of one page of a gallery view. */
|
|
46
|
+
export function mediaQuery({
|
|
47
|
+
category = 'media',
|
|
48
|
+
conversationId = '',
|
|
49
|
+
source = 'all',
|
|
50
|
+
provider = '',
|
|
51
|
+
hideArchived = false,
|
|
52
|
+
q = '',
|
|
53
|
+
cursor = '',
|
|
54
|
+
limit = MEDIA_PAGE_SIZE,
|
|
55
|
+
} = {}) {
|
|
56
|
+
const params = new URLSearchParams();
|
|
57
|
+
params.set('category', category === 'file' ? 'file' : 'media');
|
|
58
|
+
if (conversationId) params.set('conversationId', conversationId);
|
|
59
|
+
if (source && source !== 'all') params.set('source', source);
|
|
60
|
+
if (provider) params.set('provider', provider);
|
|
61
|
+
if (hideArchived) params.set('archived', 'hide');
|
|
62
|
+
const text = String(q || '').trim();
|
|
63
|
+
if (text) params.set('q', text);
|
|
64
|
+
if (cursor) params.set('cursor', cursor);
|
|
65
|
+
params.set('limit', String(limit));
|
|
66
|
+
return params.toString();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Items grouped by the month of their message, newest first as they come: `[{ key, label, items }]`. */
|
|
70
|
+
export function groupByMonth(items, { locale, timeZone } = {}) {
|
|
71
|
+
const sections = [];
|
|
72
|
+
for (const item of Array.isArray(items) ? items : []) {
|
|
73
|
+
const date = new Date(item?.timestamp);
|
|
74
|
+
const valid = !Number.isNaN(date.getTime());
|
|
75
|
+
const parts = valid
|
|
76
|
+
? new Intl.DateTimeFormat('en-CA', { year: 'numeric', month: '2-digit', timeZone }).formatToParts(date)
|
|
77
|
+
: [];
|
|
78
|
+
const year = parts.find((part) => part.type === 'year')?.value || '';
|
|
79
|
+
const month = parts.find((part) => part.type === 'month')?.value || '';
|
|
80
|
+
const key = valid ? `${year}-${month}` : 'unknown';
|
|
81
|
+
let section = sections[sections.length - 1];
|
|
82
|
+
if (!section || section.key !== key) {
|
|
83
|
+
const label = valid ? date.toLocaleDateString(locale, { year: 'numeric', month: 'long', timeZone }) : '';
|
|
84
|
+
section = { key, label, items: [] };
|
|
85
|
+
sections.push(section);
|
|
86
|
+
}
|
|
87
|
+
section.items.push(item);
|
|
88
|
+
}
|
|
89
|
+
return sections;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function formatMediaSize(bytes) {
|
|
93
|
+
const value = Number(bytes);
|
|
94
|
+
if (!Number.isFinite(value) || value <= 0) return '';
|
|
95
|
+
if (value < 1024) return `${value} B`;
|
|
96
|
+
if (value < 1024 * 1024) return `${(value / 1024).toFixed(value < 10 * 1024 ? 1 : 0)} KB`;
|
|
97
|
+
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The icon of a row of the Files tab. */
|
|
101
|
+
export function mediaFileIcon(item) {
|
|
102
|
+
if (item?.mediaKind === 'pdf') return '📄';
|
|
103
|
+
if (item?.mediaKind === 'audio') return '🎵';
|
|
104
|
+
return '📎';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The badge of an item: who it came from. */
|
|
108
|
+
export function mediaSourceBadge(item) {
|
|
109
|
+
if (item?.origin === 'generated') return MEDIA_TEXT.generated;
|
|
110
|
+
return item?.sender === 'me' ? MEDIA_TEXT.fromYou : MEDIA_TEXT.fromAgent;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The address the viewer loads an item from (the relay's route, or the remote picture itself). */
|
|
114
|
+
export function mediaHref(item, base = '') {
|
|
115
|
+
if (item?.contentUrl) return `${base}${item.contentUrl}`;
|
|
116
|
+
// Only a web address is loaded from elsewhere (the server sends no other kind).
|
|
117
|
+
const remote = String(item?.remoteUrl || '');
|
|
118
|
+
return /^https?:\/\//i.test(remote) ? remote : '';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A file viewer gallery item for a media item. */
|
|
122
|
+
export function mediaViewerItem(item, base = '') {
|
|
123
|
+
return {
|
|
124
|
+
key: `media:${item.id}`,
|
|
125
|
+
source: 'upload',
|
|
126
|
+
href: mediaHref(item, base),
|
|
127
|
+
name: String(item?.name || item?.alt || MEDIA_TEXT.untitled),
|
|
128
|
+
mime: String(item?.mime || (item?.mediaKind === 'video' ? 'video/mp4' : item?.mediaKind === 'image' ? 'image/png' : '')),
|
|
129
|
+
size: Number.isFinite(item?.sizeBytes) ? item.sizeBytes : 0,
|
|
130
|
+
media: item,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function formatMediaDate(timestamp, locale = undefined) {
|
|
135
|
+
const date = new Date(timestamp);
|
|
136
|
+
if (Number.isNaN(date.getTime())) return '';
|
|
137
|
+
return date.toLocaleString(locale, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The caption line: session title · date · who · how often · archived. */
|
|
141
|
+
export function mediaCaption(item, { locale } = {}) {
|
|
142
|
+
if (!item) return '';
|
|
143
|
+
const parts = [
|
|
144
|
+
String(item.conversationTitle || '').trim(),
|
|
145
|
+
formatMediaDate(item.timestamp, locale),
|
|
146
|
+
mediaSourceBadge(item) + (item.remoteUrl ? ` ${MEDIA_TEXT.remote}` : ''),
|
|
147
|
+
];
|
|
148
|
+
if (Number(item.occurrences) > 1) parts.push(MEDIA_TEXT.inMessages(Number(item.occurrences)));
|
|
149
|
+
if (item.archived) parts.push(MEDIA_TEXT.archived);
|
|
150
|
+
return parts.filter(Boolean).join(' · ');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** What "Copy path / Copy link" copies, or '' when there is nothing useful (uploads, data images). */
|
|
154
|
+
export function mediaCopyTarget(item) {
|
|
155
|
+
if (item?.path) return { label: MEDIA_TEXT.copyPath, text: String(item.path) };
|
|
156
|
+
if (item?.remoteUrl) return { label: MEDIA_TEXT.copyLink, text: String(item.remoteUrl) };
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The viewer gallery for a list of media items, open at `index`. `loadMore`
|
|
162
|
+
* resolves the next page of media items (or null); `actions` carries what
|
|
163
|
+
* the page does for "Open in session" and copying.
|
|
164
|
+
*/
|
|
165
|
+
export function mediaViewerGallery(items, index, {
|
|
166
|
+
base = '',
|
|
167
|
+
hasMore = false,
|
|
168
|
+
loadMore = null,
|
|
169
|
+
openInSession = null,
|
|
170
|
+
copyText = null,
|
|
171
|
+
locale,
|
|
172
|
+
} = {}) {
|
|
173
|
+
const viewerItems = items.map((item) => mediaViewerItem(item, base));
|
|
174
|
+
return {
|
|
175
|
+
source: 'media',
|
|
176
|
+
items: viewerItems,
|
|
177
|
+
index,
|
|
178
|
+
hasMore: Boolean(hasMore && loadMore),
|
|
179
|
+
loadMore: loadMore
|
|
180
|
+
? async () => {
|
|
181
|
+
const page = await loadMore();
|
|
182
|
+
return {
|
|
183
|
+
items: (page?.items || []).map((item) => mediaViewerItem(item, base)),
|
|
184
|
+
hasMore: Boolean(page?.hasMore),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
: null,
|
|
188
|
+
describe(viewerItem) {
|
|
189
|
+
const item = viewerItem?.media;
|
|
190
|
+
if (!item) return null;
|
|
191
|
+
const actions = [];
|
|
192
|
+
if (typeof openInSession === 'function' && item.conversationId && item.messageId) {
|
|
193
|
+
actions.push({ id: 'open-in-session', label: MEDIA_TEXT.openInSession, run: () => openInSession(item) });
|
|
194
|
+
}
|
|
195
|
+
const copy = mediaCopyTarget(item);
|
|
196
|
+
if (copy && typeof copyText === 'function') {
|
|
197
|
+
actions.push({ id: 'copy-location', label: copy.label, run: () => copyText(copy.text) });
|
|
198
|
+
}
|
|
199
|
+
return { caption: mediaCaption(item, { locale }), actions };
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|