@oar-sh/oar 0.9.24 → 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 +142 -0
- package/README.md +12 -11
- package/package.json +3 -1
- package/server/claude-worker/claude-remote-relay-tool.mjs +15 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +11 -9
- 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/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/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/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/relay-error-ctas.mjs +5 -0
- package/server/public/app/session-history-ui.js +2 -0
- package/server/public/app/settings-modal.js +4 -0
- package/server/public/app/socket-handlers.js +26 -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 +383 -7
- 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/sessions-routes.mjs +361 -14
- package/server/server-runtime.mjs +19 -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/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/token-usage-service.mjs +15 -0
- package/shared/image-header.mjs +90 -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
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
import { apiFetch } from './api-client.js';
|
|
2
|
+
import {
|
|
3
|
+
MEDIA_PAGE_SIZE,
|
|
4
|
+
MEDIA_TEXT,
|
|
5
|
+
formatMediaDate,
|
|
6
|
+
formatMediaSize,
|
|
7
|
+
groupByMonth,
|
|
8
|
+
mediaFileIcon,
|
|
9
|
+
mediaHref,
|
|
10
|
+
mediaQuery,
|
|
11
|
+
mediaSourceBadge,
|
|
12
|
+
mediaViewerGallery,
|
|
13
|
+
} from './media-gallery-model.mjs';
|
|
14
|
+
import { BASE, showTransientRelayNotice } from './store.js';
|
|
15
|
+
|
|
16
|
+
// The media gallery in the style of a messenger's shared media: a Media tab
|
|
17
|
+
// (a square grid of pictures and videos under month headers) and a Files tab
|
|
18
|
+
// (a list). Mounted twice: in the 🧠 modal for one session, and as the global
|
|
19
|
+
// Media view with filters. A tap opens the file viewer over the whole
|
|
20
|
+
// gallery; swiping past the last loaded tile loads the next page.
|
|
21
|
+
|
|
22
|
+
const SEARCH_DEBOUNCE_MS = 300;
|
|
23
|
+
// Empty pages (all their files missing) asked for in a row before stopping.
|
|
24
|
+
const MAX_EMPTY_PAGES = 20;
|
|
25
|
+
// While the index fills for older messages: how often the count is asked.
|
|
26
|
+
const INDEXING_POLL_MS = 4000;
|
|
27
|
+
// Tiles that failed (the relay was away) loaded again at once after a reconnect.
|
|
28
|
+
const MAX_RETRY_IN_FLIGHT = 4;
|
|
29
|
+
|
|
30
|
+
function el(tag, attrs = {}, children = []) {
|
|
31
|
+
const node = document.createElement(tag);
|
|
32
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
33
|
+
if (value === undefined || value === null || value === false) continue;
|
|
34
|
+
if (key === 'class') node.className = value;
|
|
35
|
+
else if (key === 'text') node.textContent = value;
|
|
36
|
+
else if (key.startsWith('data-')) node.setAttribute(key, value === true ? '' : String(value));
|
|
37
|
+
else if (key in node && typeof value !== 'string') node[key] = value;
|
|
38
|
+
else node.setAttribute(key, value === true ? '' : String(value));
|
|
39
|
+
}
|
|
40
|
+
for (const child of children) if (child) node.append(child);
|
|
41
|
+
return node;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The viewer lies under the summary modal by default; opened from inside it,
|
|
45
|
+
// it is lifted (.over-summary) until it closes.
|
|
46
|
+
function raiseViewerUntilClosed() {
|
|
47
|
+
const modal = document.getElementById('file-preview-modal');
|
|
48
|
+
if (!modal || modal.classList.contains('over-summary')) return;
|
|
49
|
+
modal.classList.add('over-summary');
|
|
50
|
+
let seenOpen = false;
|
|
51
|
+
const observer = new MutationObserver(() => {
|
|
52
|
+
const open = modal.classList.contains('visible');
|
|
53
|
+
if (open) seenOpen = true;
|
|
54
|
+
else if (seenOpen) {
|
|
55
|
+
modal.classList.remove('over-summary');
|
|
56
|
+
observer.disconnect();
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
observer.observe(modal, { attributes: true, attributeFilter: ['class'] });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The address of a tile's picture or video asked for again: the relay's own
|
|
63
|
+
// routes get a retry number (a fresh address, so the browser fetches it
|
|
64
|
+
// again; the relay ignores it), anything else is set anew.
|
|
65
|
+
function retryAddress(url, attempt) {
|
|
66
|
+
const value = String(url || '');
|
|
67
|
+
if (!value.startsWith(`${BASE}/api/`)) return value;
|
|
68
|
+
const hash = value.indexOf('#');
|
|
69
|
+
const head = hash === -1 ? value : value.slice(0, hash);
|
|
70
|
+
const fragment = hash === -1 ? '' : value.slice(hash);
|
|
71
|
+
return `${head}${head.includes('?') ? '&' : '?'}retry=${attempt}${fragment}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function inView(node) {
|
|
75
|
+
const box = node.getBoundingClientRect();
|
|
76
|
+
return box.bottom > 0 && box.right > 0 && box.top < window.innerHeight && box.left < window.innerWidth && box.width > 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function tileFor(item, index) {
|
|
80
|
+
const href = mediaHref(item, BASE);
|
|
81
|
+
const title = [formatMediaDate(item.timestamp), item.conversationTitle].filter(Boolean).join(' · ');
|
|
82
|
+
const tile = el('button', {
|
|
83
|
+
type: 'button',
|
|
84
|
+
class: `media-tile${item.archived ? ' media-tile-archived' : ''}`,
|
|
85
|
+
'data-media-index': index,
|
|
86
|
+
title,
|
|
87
|
+
'aria-label': item.alt || item.name || MEDIA_TEXT.title,
|
|
88
|
+
});
|
|
89
|
+
if (item.mediaKind === 'video') {
|
|
90
|
+
// The address is set when the tile comes into view (videoObserver): a
|
|
91
|
+
// video's metadata is a request of its own, unlike a lazy picture.
|
|
92
|
+
tile.append(el('video', { 'data-src': `${href}#t=0.1`, preload: 'metadata', muted: true, playsInline: true, tabIndex: -1 }));
|
|
93
|
+
tile.append(el('span', { class: 'media-tile-play', 'aria-hidden': 'true', text: '▶' }));
|
|
94
|
+
} else {
|
|
95
|
+
const img = el('img', { src: href, alt: item.alt || '', loading: 'lazy', decoding: 'async' });
|
|
96
|
+
if (item.remoteUrl) img.referrerPolicy = 'no-referrer';
|
|
97
|
+
tile.append(img);
|
|
98
|
+
}
|
|
99
|
+
const badge = mediaSourceBadge(item).split(' ')[0] + (item.remoteUrl ? ` ${MEDIA_TEXT.remote}` : '');
|
|
100
|
+
tile.append(el('span', { class: 'media-tile-badge', 'aria-hidden': 'true', text: badge }));
|
|
101
|
+
return tile;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function fileRowFor(item, index) {
|
|
105
|
+
const meta = [formatMediaSize(item.sizeBytes), formatMediaDate(item.timestamp), item.conversationTitle].filter(Boolean).join(' · ');
|
|
106
|
+
return el('button', { type: 'button', class: 'media-file-row', 'data-media-index': index }, [
|
|
107
|
+
el('span', { class: 'media-file-icon', 'aria-hidden': 'true', text: mediaFileIcon(item) }),
|
|
108
|
+
el('span', { class: 'media-file-text' }, [
|
|
109
|
+
el('span', { class: 'media-file-name', text: item.name || item.alt || '' }),
|
|
110
|
+
el('span', { class: 'media-file-meta', text: meta }),
|
|
111
|
+
]),
|
|
112
|
+
]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Mounts a gallery into `host`. `conversationId` scopes it to one session
|
|
117
|
+
* (the 🧠 tab); `global` adds the filters. `onOpenInSession(item)` leaves the
|
|
118
|
+
* surrounding view before the conversation opens at the message.
|
|
119
|
+
*/
|
|
120
|
+
export function mountMediaGallery(host, {
|
|
121
|
+
conversationId = '',
|
|
122
|
+
global = false,
|
|
123
|
+
onOpenInSession = null,
|
|
124
|
+
fetchJson = (url) => apiFetch(url),
|
|
125
|
+
observe = typeof IntersectionObserver === 'function',
|
|
126
|
+
// The file viewer, loaded when a tile is first opened.
|
|
127
|
+
viewer = () => import('./attachments-view.js'),
|
|
128
|
+
// Opened from a modal (the 🧠 Media tab): the viewer goes above it.
|
|
129
|
+
raiseViewer = false,
|
|
130
|
+
} = {}) {
|
|
131
|
+
const state = {
|
|
132
|
+
category: 'media',
|
|
133
|
+
source: 'all',
|
|
134
|
+
provider: '',
|
|
135
|
+
sessionId: '',
|
|
136
|
+
hideArchived: false,
|
|
137
|
+
q: '',
|
|
138
|
+
items: [],
|
|
139
|
+
cursor: '',
|
|
140
|
+
hasMore: true,
|
|
141
|
+
loading: null,
|
|
142
|
+
generation: 0,
|
|
143
|
+
missing: 0,
|
|
144
|
+
pending: 0,
|
|
145
|
+
failed: false,
|
|
146
|
+
};
|
|
147
|
+
let observer = null;
|
|
148
|
+
let videoObserver = null;
|
|
149
|
+
let searchTimer = null;
|
|
150
|
+
let indexingTimer = null;
|
|
151
|
+
|
|
152
|
+
const root = el('div', { class: 'media-gallery', 'data-scope': global ? 'global' : 'session' });
|
|
153
|
+
const tabs = el('div', { class: 'media-gallery-tabs', role: 'tablist' });
|
|
154
|
+
for (const [category, label] of [['media', MEDIA_TEXT.tabMedia], ['file', MEDIA_TEXT.tabFiles]]) {
|
|
155
|
+
tabs.append(el('button', {
|
|
156
|
+
type: 'button',
|
|
157
|
+
class: 'media-gallery-tab',
|
|
158
|
+
role: 'tab',
|
|
159
|
+
'data-media-category': category,
|
|
160
|
+
'aria-selected': String(category === state.category),
|
|
161
|
+
text: label,
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
164
|
+
root.append(tabs);
|
|
165
|
+
|
|
166
|
+
let filters = null;
|
|
167
|
+
if (global) {
|
|
168
|
+
filters = el('div', { class: 'media-gallery-filters' });
|
|
169
|
+
const chips = el('div', { class: 'media-gallery-chips', role: 'group', 'aria-label': MEDIA_TEXT.sourceLabel });
|
|
170
|
+
for (const [source, label] of [['all', MEDIA_TEXT.sourceAll], ['mine', MEDIA_TEXT.sourceMine], ['agent', MEDIA_TEXT.sourceAgent], ['generated', MEDIA_TEXT.sourceGenerated]]) {
|
|
171
|
+
chips.append(el('button', { type: 'button', class: 'media-gallery-chip', 'data-media-source': source, 'aria-pressed': String(source === state.source), text: label }));
|
|
172
|
+
}
|
|
173
|
+
const provider = el('select', { class: 'media-gallery-select', 'data-media-filter': 'provider', 'aria-label': MEDIA_TEXT.providerLabel }, [el('option', { value: '', text: MEDIA_TEXT.allProviders })]);
|
|
174
|
+
const session = el('select', { class: 'media-gallery-select', 'data-media-filter': 'session', 'aria-label': MEDIA_TEXT.sessionLabel }, [el('option', { value: '', text: MEDIA_TEXT.allSessions })]);
|
|
175
|
+
const archived = el('label', { class: 'media-gallery-toggle' }, [
|
|
176
|
+
el('input', { type: 'checkbox', 'data-media-filter': 'archived' }),
|
|
177
|
+
el('span', { text: MEDIA_TEXT.hideArchived }),
|
|
178
|
+
]);
|
|
179
|
+
const search = el('input', { type: 'search', class: 'media-gallery-search', 'data-media-filter': 'q', placeholder: MEDIA_TEXT.search, 'aria-label': MEDIA_TEXT.search });
|
|
180
|
+
filters.append(chips, provider, session, archived, search);
|
|
181
|
+
root.append(filters);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const status = el('div', { class: 'media-gallery-status', 'aria-live': 'polite' });
|
|
185
|
+
const body = el('div', { class: 'media-gallery-body' });
|
|
186
|
+
const more = el('button', { type: 'button', class: 'media-gallery-more', hidden: true, text: MEDIA_TEXT.loadMore });
|
|
187
|
+
const sentinel = el('div', { class: 'media-gallery-sentinel', 'aria-hidden': 'true' });
|
|
188
|
+
root.append(status, body, more, sentinel);
|
|
189
|
+
host.replaceChildren(root);
|
|
190
|
+
|
|
191
|
+
function query(cursor = '') {
|
|
192
|
+
return mediaQuery({
|
|
193
|
+
category: state.category,
|
|
194
|
+
conversationId: conversationId || state.sessionId,
|
|
195
|
+
source: global ? state.source : 'all',
|
|
196
|
+
provider: global ? state.provider : '',
|
|
197
|
+
hideArchived: global && state.hideArchived,
|
|
198
|
+
q: global ? state.q : '',
|
|
199
|
+
cursor,
|
|
200
|
+
limit: MEDIA_PAGE_SIZE,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function filtered() {
|
|
205
|
+
return global && (state.source !== 'all' || state.provider || state.sessionId || state.hideArchived || state.q);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function renderStatus() {
|
|
209
|
+
const lines = [];
|
|
210
|
+
if (state.failed) lines.push(MEDIA_TEXT.failed);
|
|
211
|
+
else if (!state.items.length && state.loading) lines.push(MEDIA_TEXT.loading);
|
|
212
|
+
else if (!state.items.length && !state.hasMore) {
|
|
213
|
+
lines.push(filtered() ? MEDIA_TEXT.emptyFiltered : state.category === 'file' ? MEDIA_TEXT.emptyFiles : MEDIA_TEXT.emptyMedia);
|
|
214
|
+
}
|
|
215
|
+
if (state.pending > 0) lines.push(MEDIA_TEXT.indexing(state.pending));
|
|
216
|
+
if (state.missing > 0 && !state.hasMore) lines.push(MEDIA_TEXT.missing(state.missing));
|
|
217
|
+
status.replaceChildren(...lines.map((line) => el('p', { text: line })));
|
|
218
|
+
more.hidden = !state.hasMore || Boolean(state.loading) || !state.items.length;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Appends rows of the page that just arrived; a month already shown grows.
|
|
222
|
+
function appendItems(startIndex, items) {
|
|
223
|
+
if (state.category === 'file') {
|
|
224
|
+
let list = body.querySelector('.media-file-list');
|
|
225
|
+
if (!list) {
|
|
226
|
+
list = el('div', { class: 'media-file-list' });
|
|
227
|
+
body.append(list);
|
|
228
|
+
}
|
|
229
|
+
items.forEach((item, offset) => list.append(fileRowFor(item, startIndex + offset)));
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
let index = startIndex;
|
|
233
|
+
for (const section of groupByMonth(items)) {
|
|
234
|
+
let grid = body.lastElementChild?.dataset?.mediaMonth === section.key
|
|
235
|
+
? body.lastElementChild.querySelector('.media-grid')
|
|
236
|
+
: null;
|
|
237
|
+
if (!grid) {
|
|
238
|
+
grid = el('div', { class: 'media-grid' });
|
|
239
|
+
body.append(el('section', { class: 'media-month', 'data-media-month': section.key }, [
|
|
240
|
+
el('h3', { class: 'media-month-label', text: section.label }),
|
|
241
|
+
grid,
|
|
242
|
+
]));
|
|
243
|
+
}
|
|
244
|
+
for (const item of section.items) {
|
|
245
|
+
const tile = tileFor(item, index++);
|
|
246
|
+
grid.append(tile);
|
|
247
|
+
const video = tile.querySelector('video[data-src]');
|
|
248
|
+
if (video && videoObserver) videoObserver.observe(video);
|
|
249
|
+
else if (video) video.src = video.dataset.src;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Loads the next page; resolves `{ items, hasMore }` of that page. */
|
|
255
|
+
function loadNextPage() {
|
|
256
|
+
if (state.loading) return state.loading;
|
|
257
|
+
if (!state.hasMore) return Promise.resolve({ items: [], hasMore: false });
|
|
258
|
+
const generation = state.generation;
|
|
259
|
+
state.failed = false;
|
|
260
|
+
state.loading = (async () => {
|
|
261
|
+
// A page can come back empty while more follow (every file on it was
|
|
262
|
+
// missing): the next one is asked for at once, up to a bound, so a
|
|
263
|
+
// gallery whose newest files are all gone still shows the older ones.
|
|
264
|
+
for (let round = 0; round < MAX_EMPTY_PAGES; round += 1) {
|
|
265
|
+
let page = null;
|
|
266
|
+
try {
|
|
267
|
+
page = await fetchJson(`/api/media?${query(state.cursor)}`);
|
|
268
|
+
} catch {
|
|
269
|
+
page = null;
|
|
270
|
+
}
|
|
271
|
+
if (generation !== state.generation) return { items: [], hasMore: false };
|
|
272
|
+
if (!page || !Array.isArray(page.items)) {
|
|
273
|
+
state.loading = null;
|
|
274
|
+
state.failed = true;
|
|
275
|
+
renderStatus();
|
|
276
|
+
return { items: [], hasMore: false };
|
|
277
|
+
}
|
|
278
|
+
const start = state.items.length;
|
|
279
|
+
state.items.push(...page.items);
|
|
280
|
+
state.cursor = page.nextCursor || '';
|
|
281
|
+
state.hasMore = Boolean(page.nextCursor);
|
|
282
|
+
state.missing += Number(page.missingHidden) || 0;
|
|
283
|
+
state.pending = Number(page.indexing?.pending) || 0;
|
|
284
|
+
appendItems(start, page.items);
|
|
285
|
+
if (page.items.length || !state.hasMore) {
|
|
286
|
+
state.loading = null;
|
|
287
|
+
renderStatus();
|
|
288
|
+
watchIndexing();
|
|
289
|
+
return { items: page.items, hasMore: state.hasMore };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
state.loading = null;
|
|
293
|
+
renderStatus();
|
|
294
|
+
return { items: [], hasMore: state.hasMore };
|
|
295
|
+
})();
|
|
296
|
+
renderStatus();
|
|
297
|
+
return state.loading;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// While older messages are still being indexed: the count is asked again
|
|
301
|
+
// every few seconds (which moves the indexing on too), and the gallery loads
|
|
302
|
+
// again once the index is complete.
|
|
303
|
+
function watchIndexing() {
|
|
304
|
+
if (indexingTimer || state.pending <= 0) return;
|
|
305
|
+
const generation = state.generation;
|
|
306
|
+
indexingTimer = setTimeout(async () => {
|
|
307
|
+
indexingTimer = null;
|
|
308
|
+
if (generation !== state.generation) return;
|
|
309
|
+
const scope = conversationId ? `?conversationId=${encodeURIComponent(conversationId)}` : '';
|
|
310
|
+
const counts = await Promise.resolve(fetchJson(`/api/media/counts${scope}`)).catch(() => null);
|
|
311
|
+
if (generation !== state.generation) return;
|
|
312
|
+
const pending = Number(counts?.pending) || 0;
|
|
313
|
+
if (pending > 0) {
|
|
314
|
+
state.pending = pending;
|
|
315
|
+
renderStatus();
|
|
316
|
+
watchIndexing();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
state.pending = 0;
|
|
320
|
+
void reset();
|
|
321
|
+
}, INDEXING_POLL_MS);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function reset() {
|
|
325
|
+
state.generation += 1;
|
|
326
|
+
clearTimeout(indexingTimer);
|
|
327
|
+
indexingTimer = null;
|
|
328
|
+
state.loading = null;
|
|
329
|
+
state.items = [];
|
|
330
|
+
state.cursor = '';
|
|
331
|
+
state.hasMore = true;
|
|
332
|
+
state.missing = 0;
|
|
333
|
+
state.failed = false;
|
|
334
|
+
body.replaceChildren();
|
|
335
|
+
renderStatus();
|
|
336
|
+
return loadNextPage();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function copyText(text) {
|
|
340
|
+
try {
|
|
341
|
+
await navigator.clipboard.writeText(text);
|
|
342
|
+
showTransientRelayNotice(MEDIA_TEXT.copied, 1500);
|
|
343
|
+
} catch {
|
|
344
|
+
showTransientRelayNotice(MEDIA_TEXT.copyFailed, 2500);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function openItem(index) {
|
|
349
|
+
const items = state.items.slice();
|
|
350
|
+
const item = items[index];
|
|
351
|
+
if (!item) return;
|
|
352
|
+
const { closeFilePreview, openGalleryItem } = await viewer();
|
|
353
|
+
// The viewer gets what the grid has beyond its own list: the grid may have
|
|
354
|
+
// loaded the next page itself meanwhile (its end came into view).
|
|
355
|
+
let given = items.length;
|
|
356
|
+
const loadMore = async () => {
|
|
357
|
+
if (state.items.length <= given && state.hasMore) await loadNextPage();
|
|
358
|
+
const fresh = state.items.slice(given);
|
|
359
|
+
given = state.items.length;
|
|
360
|
+
return { items: fresh, hasMore: state.hasMore };
|
|
361
|
+
};
|
|
362
|
+
const gallery = mediaViewerGallery(items, index, {
|
|
363
|
+
base: BASE,
|
|
364
|
+
hasMore: state.hasMore || state.items.length > items.length,
|
|
365
|
+
loadMore,
|
|
366
|
+
openInSession: (media) => {
|
|
367
|
+
closeFilePreview();
|
|
368
|
+
onOpenInSession?.(media);
|
|
369
|
+
},
|
|
370
|
+
copyText,
|
|
371
|
+
});
|
|
372
|
+
if (raiseViewer) raiseViewerUntilClosed();
|
|
373
|
+
await openGalleryItem(gallery.items[index], gallery);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
root.addEventListener('click', (event) => {
|
|
377
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
378
|
+
const tab = target?.closest('[data-media-category]');
|
|
379
|
+
if (tab) {
|
|
380
|
+
const category = tab.getAttribute('data-media-category');
|
|
381
|
+
if (category === state.category) return;
|
|
382
|
+
state.category = category;
|
|
383
|
+
tabs.querySelectorAll('[data-media-category]').forEach((node) => {
|
|
384
|
+
node.setAttribute('aria-selected', String(node === tab));
|
|
385
|
+
});
|
|
386
|
+
void reset();
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const chip = target?.closest('[data-media-source]');
|
|
390
|
+
if (chip) {
|
|
391
|
+
state.source = chip.getAttribute('data-media-source');
|
|
392
|
+
filters.querySelectorAll('[data-media-source]').forEach((node) => node.setAttribute('aria-pressed', String(node === chip)));
|
|
393
|
+
void reset();
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (target?.closest('.media-gallery-more')) {
|
|
397
|
+
void loadNextPage();
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
const tile = target?.closest('[data-media-index]');
|
|
401
|
+
if (tile) void openItem(Number(tile.getAttribute('data-media-index')));
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
root.addEventListener('change', (event) => {
|
|
405
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
406
|
+
const filter = target?.getAttribute('data-media-filter');
|
|
407
|
+
if (filter === 'provider') state.provider = target.value;
|
|
408
|
+
else if (filter === 'session') state.sessionId = target.value;
|
|
409
|
+
else if (filter === 'archived') state.hideArchived = target.checked;
|
|
410
|
+
else return;
|
|
411
|
+
void reset();
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
root.addEventListener('input', (event) => {
|
|
415
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
416
|
+
if (target?.getAttribute('data-media-filter') !== 'q') return;
|
|
417
|
+
clearTimeout(searchTimer);
|
|
418
|
+
searchTimer = setTimeout(() => {
|
|
419
|
+
state.q = target.value;
|
|
420
|
+
void reset();
|
|
421
|
+
}, SEARCH_DEBOUNCE_MS);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// A picture or video that failed (the relay was away) marks its tile; after
|
|
425
|
+
// a reconnect the failed tiles in view are asked for once more, a few at a
|
|
426
|
+
// time. A tile that fails again waits for the next reconnect: no loop.
|
|
427
|
+
let retryAttempt = 0;
|
|
428
|
+
const onMediaEvent = (event) => {
|
|
429
|
+
const media = event.target;
|
|
430
|
+
if (media?.tagName !== 'IMG' && media?.tagName !== 'VIDEO') return;
|
|
431
|
+
const tile = media.closest('.media-tile');
|
|
432
|
+
if (!tile) return;
|
|
433
|
+
tile.classList.toggle('media-tile-failed', event.type === 'error');
|
|
434
|
+
};
|
|
435
|
+
for (const type of ['error', 'load', 'loadeddata']) root.addEventListener(type, onMediaEvent, true);
|
|
436
|
+
|
|
437
|
+
function retryFailedTiles() {
|
|
438
|
+
const failed = [...root.querySelectorAll('.media-tile-failed')].filter(inView);
|
|
439
|
+
if (!failed.length) return;
|
|
440
|
+
retryAttempt += 1;
|
|
441
|
+
// The whole pass is taken at once: a reconnect right after this one finds
|
|
442
|
+
// none of these tiles still marked, so passes never overlap.
|
|
443
|
+
for (const tile of failed) tile.classList.remove('media-tile-failed');
|
|
444
|
+
const generation = state.generation;
|
|
445
|
+
let next = 0;
|
|
446
|
+
let inFlight = 0;
|
|
447
|
+
const pump = () => {
|
|
448
|
+
// A gallery destroyed or reloaded since: its tiles are asked for no more.
|
|
449
|
+
if (generation !== state.generation) return;
|
|
450
|
+
while (inFlight < MAX_RETRY_IN_FLIGHT && next < failed.length) {
|
|
451
|
+
const media = failed[next].querySelector('img, video');
|
|
452
|
+
next += 1;
|
|
453
|
+
if (!media) continue;
|
|
454
|
+
inFlight += 1;
|
|
455
|
+
const settled = () => {
|
|
456
|
+
media.removeEventListener('load', settled);
|
|
457
|
+
media.removeEventListener('loadeddata', settled);
|
|
458
|
+
media.removeEventListener('error', settled);
|
|
459
|
+
inFlight -= 1;
|
|
460
|
+
pump();
|
|
461
|
+
};
|
|
462
|
+
media.addEventListener('load', settled);
|
|
463
|
+
media.addEventListener('loadeddata', settled);
|
|
464
|
+
media.addEventListener('error', settled);
|
|
465
|
+
const base = media.dataset.baseSrc || media.getAttribute('src') || media.dataset.src || '';
|
|
466
|
+
media.dataset.baseSrc = base;
|
|
467
|
+
media.removeAttribute('src');
|
|
468
|
+
media.setAttribute('src', retryAddress(base, retryAttempt));
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
pump();
|
|
472
|
+
}
|
|
473
|
+
window.addEventListener('oar:relay-connected', retryFailedTiles);
|
|
474
|
+
async function loadFacets() {
|
|
475
|
+
if (!global) return;
|
|
476
|
+
const facets = await fetchJson('/api/media/facets').catch(() => null);
|
|
477
|
+
if (!facets) return;
|
|
478
|
+
const provider = filters.querySelector('[data-media-filter="provider"]');
|
|
479
|
+
for (const name of facets.providers || []) provider.append(el('option', { value: name, text: name }));
|
|
480
|
+
const session = filters.querySelector('[data-media-filter="session"]');
|
|
481
|
+
for (const row of facets.sessions || []) {
|
|
482
|
+
session.append(el('option', { value: row.id, text: `${row.title || row.id}${row.archived ? ` (${MEDIA_TEXT.archived})` : ''} · ${row.count}` }));
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (observe) {
|
|
487
|
+
videoObserver = new IntersectionObserver((entries) => {
|
|
488
|
+
for (const entry of entries) {
|
|
489
|
+
if (!entry.isIntersecting) continue;
|
|
490
|
+
entry.target.src = entry.target.dataset.src;
|
|
491
|
+
entry.target.removeAttribute('data-src');
|
|
492
|
+
videoObserver.unobserve(entry.target);
|
|
493
|
+
}
|
|
494
|
+
}, { rootMargin: '200px 0px' });
|
|
495
|
+
observer = new IntersectionObserver((entries) => {
|
|
496
|
+
if (entries.some((entry) => entry.isIntersecting) && state.hasMore && !state.loading && state.items.length) {
|
|
497
|
+
void loadNextPage();
|
|
498
|
+
}
|
|
499
|
+
}, { rootMargin: '600px 0px' });
|
|
500
|
+
observer.observe(sentinel);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
void loadFacets();
|
|
504
|
+
const firstPage = reset();
|
|
505
|
+
|
|
506
|
+
return {
|
|
507
|
+
root,
|
|
508
|
+
firstPage,
|
|
509
|
+
openItem,
|
|
510
|
+
retryFailedTiles,
|
|
511
|
+
loadNextPage,
|
|
512
|
+
reload: reset,
|
|
513
|
+
state,
|
|
514
|
+
destroy() {
|
|
515
|
+
state.generation += 1;
|
|
516
|
+
observer?.disconnect();
|
|
517
|
+
videoObserver?.disconnect();
|
|
518
|
+
window.removeEventListener('oar:relay-connected', retryFailedTiles);
|
|
519
|
+
clearTimeout(searchTimer);
|
|
520
|
+
clearTimeout(indexingTimer);
|
|
521
|
+
indexingTimer = null;
|
|
522
|
+
},
|
|
523
|
+
};
|
|
524
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { mountMediaGallery } from './media-gallery-ui.js';
|
|
2
|
+
import { MEDIA_TEXT } from './media-gallery-model.mjs';
|
|
3
|
+
import { currentConvId, setCurrentConv } from './store.js';
|
|
4
|
+
|
|
5
|
+
// The global 🖼️ Media view: the media of every session of the relay, in the
|
|
6
|
+
// transcript's place like the Inbox (the sidebar stays on a desktop). Back,
|
|
7
|
+
// the menu entry again, or picking a session leaves it.
|
|
8
|
+
|
|
9
|
+
let active = false;
|
|
10
|
+
let gallery = null;
|
|
11
|
+
let previousConversationId = '';
|
|
12
|
+
|
|
13
|
+
export function isMediaViewActive() {
|
|
14
|
+
return active;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Leaves the view without opening anything (openConversation calls it first). */
|
|
18
|
+
export function leaveMediaView() {
|
|
19
|
+
if (!active) return false;
|
|
20
|
+
active = false;
|
|
21
|
+
gallery?.destroy();
|
|
22
|
+
gallery = null;
|
|
23
|
+
document.body.classList.remove('media-view-active');
|
|
24
|
+
document.getElementById('input-area')?.removeAttribute('hidden');
|
|
25
|
+
document.getElementById('pending-question-banner')?.removeAttribute('hidden');
|
|
26
|
+
window.renderConvList?.();
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function showNothingOpen() {
|
|
31
|
+
const { clearOpenConversation } = await import('./journal-view.js');
|
|
32
|
+
clearOpenConversation({ history: false });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function openInSession(media) {
|
|
36
|
+
previousConversationId = '';
|
|
37
|
+
void window.openConversation?.(media.conversationId, {
|
|
38
|
+
aroundMessageId: media.messageId,
|
|
39
|
+
focusMessageId: media.messageId,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Opens the view, or leaves it for the conversation it was opened from
|
|
45
|
+
* (`returnTo` when it stored none, as the Inbox does).
|
|
46
|
+
*/
|
|
47
|
+
export async function toggleMediaView({ returnTo = '', leaveOthers = () => {}, fetchJson } = {}) {
|
|
48
|
+
if (active) {
|
|
49
|
+
leaveMediaView();
|
|
50
|
+
const restoreId = previousConversationId || String(returnTo || '').trim();
|
|
51
|
+
previousConversationId = '';
|
|
52
|
+
if (restoreId) await window.openConversation?.(restoreId, { restoreScroll: true });
|
|
53
|
+
else await showNothingOpen();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
leaveOthers();
|
|
57
|
+
previousConversationId = String(currentConvId || '').trim();
|
|
58
|
+
active = true;
|
|
59
|
+
setCurrentConv(null);
|
|
60
|
+
window.renderConvList?.();
|
|
61
|
+
document.body.classList.add('media-view-active');
|
|
62
|
+
document.getElementById('pending-question-banner')?.setAttribute('hidden', '');
|
|
63
|
+
document.getElementById('input-area')?.setAttribute('hidden', '');
|
|
64
|
+
for (const id of ['chat-title-cwd', 'chat-title-session-usage']) {
|
|
65
|
+
const node = document.getElementById(id);
|
|
66
|
+
if (node) {
|
|
67
|
+
node.textContent = '';
|
|
68
|
+
node.hidden = true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const title = document.getElementById('chat-title');
|
|
72
|
+
if (title) title.textContent = `🖼️ ${MEDIA_TEXT.title}`;
|
|
73
|
+
document.getElementById('sidebar')?.classList.remove('open');
|
|
74
|
+
document.getElementById('sidebar-overlay')?.classList.remove('visible');
|
|
75
|
+
const host = document.getElementById('messages');
|
|
76
|
+
if (!host) return;
|
|
77
|
+
host.scrollTop = 0;
|
|
78
|
+
gallery = mountMediaGallery(host, { global: true, onOpenInSession: openInSession, ...(fetchJson ? { fetchJson } : {}) });
|
|
79
|
+
}
|