@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.
Files changed (92) hide show
  1. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +3 -1
  2. package/CHANGELOG.md +284 -0
  3. package/README.md +12 -11
  4. package/package.json +5 -1
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +15 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +8 -4
  7. package/server/claude-worker/claude-session-process.mjs +4 -0
  8. package/server/claude-worker/claude-turn-publisher.mjs +25 -11
  9. package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
  10. package/server/copilot-worker/copilot-prompt-context.mjs +2 -1
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +11 -9
  12. package/server/cursor-worker/cursor-turn-runner.mjs +3 -1
  13. package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
  14. package/server/db-schema.mjs +19 -0
  15. package/server/grok-worker/grok-turn-runner.mjs +3 -1
  16. package/server/grok-worker/sdk-message-normalizer.mjs +3 -0
  17. package/server/migrations/0039-media-index.mjs +167 -0
  18. package/server/migrations/0040-image-session-defaults.mjs +93 -0
  19. package/server/public/app/api-client.js +58 -0
  20. package/server/public/app/ask-user-view.js +2 -1
  21. package/server/public/app/attachments-view.js +66 -9
  22. package/server/public/app/bootstrap.js +85 -25
  23. package/server/public/app/chat-actions-menu-state.mjs +1 -0
  24. package/server/public/app/context-media-tab.js +143 -0
  25. package/server/public/app/context-usage-view.mjs +2 -2
  26. package/server/public/app/conversation-view.js +90 -5
  27. package/server/public/app/image-composer-ui.js +518 -0
  28. package/server/public/app/image-turn-placeholder.mjs +233 -0
  29. package/server/public/app/journal-view.js +12 -2
  30. package/server/public/app/live-activity-cap.mjs +3 -3
  31. package/server/public/app/markdown-math.mjs +471 -0
  32. package/server/public/app/media-gallery-model.mjs +202 -0
  33. package/server/public/app/media-gallery-ui.js +524 -0
  34. package/server/public/app/media-view.js +79 -0
  35. package/server/public/app/openai-image-composer.mjs +492 -0
  36. package/server/public/app/openai-image-cost-ui.js +426 -0
  37. package/server/public/app/openai-image-spend.mjs +443 -0
  38. package/server/public/app/plan-usage-view.mjs +38 -6
  39. package/server/public/app/pwa-install.js +25 -4
  40. package/server/public/app/relay-error-ctas.mjs +5 -0
  41. package/server/public/app/relay-prompt-sanitizer.mjs +3 -0
  42. package/server/public/app/relay-restart-ui.js +2 -2
  43. package/server/public/app/remote-relays-settings.js +33 -6
  44. package/server/public/app/router.js +49 -237
  45. package/server/public/app/session-history-ui.js +2 -0
  46. package/server/public/app/settings-modal.js +4 -0
  47. package/server/public/app/settings-tabs.js +10 -1
  48. package/server/public/app/socket-handlers.js +26 -0
  49. package/server/public/app/store.js +2 -0
  50. package/server/public/app/stream-view-preference.mjs +1 -1
  51. package/server/public/app/tab-strip-fade.mjs +1 -1
  52. package/server/public/app/thoughts-preference.mjs +1 -1
  53. package/server/public/app/tmux-inspector-view.js +29 -16
  54. package/server/public/app/viewer-gallery.mjs +23 -1
  55. package/server/public/index.html +517 -123
  56. package/server/relay-tools.md +1 -0
  57. package/server/repositories/image-conversation-repository.mjs +69 -0
  58. package/server/repositories/media-repository.mjs +305 -0
  59. package/server/routes/media-routes.mjs +361 -0
  60. package/server/routes/messages-routes.mjs +1410 -360
  61. package/server/routes/relay-board-routes.mjs +46 -1
  62. package/server/routes/remote-relay-routes.mjs +1 -1
  63. package/server/routes/sessions-routes.mjs +361 -14
  64. package/server/server-runtime.mjs +21 -0
  65. package/server/services/database-file.mjs +6 -4
  66. package/server/services/generated-image-cleanup-service.mjs +2 -1
  67. package/server/services/generated-image-files.mjs +110 -0
  68. package/server/services/image-input-paths.mjs +389 -0
  69. package/server/services/image-operation-service.mjs +6 -2
  70. package/server/services/media-extract.mjs +653 -0
  71. package/server/services/media-index-service.mjs +221 -0
  72. package/server/services/openai-image-preview-turns.mjs +330 -0
  73. package/server/services/openai-image-spend-service.mjs +516 -0
  74. package/server/services/openai-image-stream.mjs +170 -0
  75. package/server/services/plan-usage-openai-image.mjs +148 -0
  76. package/server/services/relay-prompt-sanitizer.mjs +7 -0
  77. package/server/services/relay-restart-queue.mjs +1 -1
  78. package/server/services/remote-relay-caller-context.mjs +6 -0
  79. package/server/services/remote-relay-dispatcher.mjs +409 -23
  80. package/server/services/remote-relay-registry-service.mjs +10 -2
  81. package/server/services/session-worker-stop-service.mjs +50 -18
  82. package/server/services/token-usage-service.mjs +15 -0
  83. package/shared/image-header.mjs +90 -0
  84. package/shared/math-instructions.mjs +30 -0
  85. package/shared/openai-image-options.mjs +299 -0
  86. package/shared/openai-image-presets.mjs +175 -0
  87. package/shared/openai-image-previews.mjs +40 -0
  88. package/shared/openai-image-prices.mjs +291 -0
  89. package/shared/openai-image-spend.mjs +90 -0
  90. package/shared/remote-relay-contract.mjs +126 -4
  91. package/shared/worker-runtime/direct-openai-image.mjs +96 -50
  92. package/server/public/app/math-dollars.mjs +0 -89
@@ -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 didLeaveInboxView = leaveInboxView();
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: normalizedProvider === 'claude-api' ? newConversationClaudeApiSettingsCache?.model : '',
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') {
@@ -1,6 +1,6 @@
1
- // "Tool lines shown while an agent works" (Settings › General ›
2
- // Appearance): how many of its newest tool lines each agent of a running
3
- // turn keeps in the live bubble, the main agent and every sub-agent apart.
1
+ // "Tool lines shown while an agent works" (Settings › Appearance): how many
2
+ // of its newest tool lines each agent of a running turn keeps in the live
3
+ // bubble, the main agent and every sub-agent apart.
4
4
  // A setting of this browser, kept in localStorage like the scroll-on-send
5
5
  // one. The slider runs from 10 to 500 by 10 and has one more step at the far
6
6
  // end, no limit (stored as '0'). The document and the storage are passed in