@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
|
@@ -555,6 +555,11 @@ export function createSdkMessageNormalizer() {
|
|
|
555
555
|
const thinking = state.thinking.get(index);
|
|
556
556
|
if (thinking) {
|
|
557
557
|
thinking.text = capThought(thinking.text + String(event?.delta?.thinking || ''));
|
|
558
|
+
// Gated as stream text: a delta can be one token, and every update
|
|
559
|
+
// carries the whole thought. Past the cap the text stops changing,
|
|
560
|
+
// so nothing more goes out until the block's final frame.
|
|
561
|
+
if (!shouldEmitStreamUpdate(thinking.text, thinking.lastEmittedText)) return actions;
|
|
562
|
+
thinking.lastEmittedText = thinking.text;
|
|
558
563
|
actions.push({
|
|
559
564
|
channel: 'thought',
|
|
560
565
|
payload: {
|
|
@@ -715,7 +720,7 @@ export function createSdkMessageNormalizer() {
|
|
|
715
720
|
const assistantError = lastAssistantError;
|
|
716
721
|
lastAssistantError = '';
|
|
717
722
|
const apiErrorStatus = Number(sdkMessage.api_error_status ?? NaN);
|
|
718
|
-
return [{
|
|
723
|
+
return [...flushOpenThoughts(), {
|
|
719
724
|
channel: 'result',
|
|
720
725
|
payload: {
|
|
721
726
|
text: String(sdkMessage.result || '').trim(),
|
|
@@ -745,6 +750,29 @@ export function createSdkMessageNormalizer() {
|
|
|
745
750
|
return [];
|
|
746
751
|
}
|
|
747
752
|
|
|
753
|
+
// A thinking block the turn left open (aborted, failed) never gets its
|
|
754
|
+
// final frame: what the delta gating held back goes out with the result,
|
|
755
|
+
// so the stored thought ends with the last delta.
|
|
756
|
+
function flushOpenThoughts() {
|
|
757
|
+
const actions = [];
|
|
758
|
+
for (const state of threads.values()) {
|
|
759
|
+
for (const thinking of state.thinking.values()) {
|
|
760
|
+
if (!thinking.text.trim() || thinking.text === thinking.lastEmittedText) continue;
|
|
761
|
+
thinking.lastEmittedText = thinking.text;
|
|
762
|
+
actions.push({
|
|
763
|
+
channel: 'thought',
|
|
764
|
+
payload: {
|
|
765
|
+
reasoningId: thinking.reasoningId,
|
|
766
|
+
text: thinking.text,
|
|
767
|
+
done: false,
|
|
768
|
+
subagentRunId: subagentRunIdFor(state.key === 'main' ? null : state.key),
|
|
769
|
+
},
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
return actions;
|
|
774
|
+
}
|
|
775
|
+
|
|
748
776
|
function finalStreamText() {
|
|
749
777
|
return threadState(null).streamText;
|
|
750
778
|
}
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
applyPreviewInstructions,
|
|
26
26
|
createPreviewInstructionsProvider,
|
|
27
27
|
} from '../../shared/preview-instructions.mjs';
|
|
28
|
+
import { applyMathInstructions } from '../../shared/math-instructions.mjs';
|
|
28
29
|
import { applyMediaEmbedInstructions } from '../../shared/media-embed-instructions.mjs';
|
|
29
30
|
import { buildModePrompt } from '../../shared/worker-runtime/prompt-context.mjs';
|
|
30
31
|
import { loadRelayInstructionsFromFile } from '../../shared/worker-runtime/config-loader.mjs';
|
|
@@ -84,7 +85,7 @@ export function createCopilotPromptContextBuilder({
|
|
|
84
85
|
.then(() => getPreviewInstructions({ conversationId: String(message?.conversationId || '') }))
|
|
85
86
|
.catch(() => '')
|
|
86
87
|
: '';
|
|
87
|
-
instructions = applyMediaEmbedInstructions(applyPreviewInstructions(toolInstructions, previewBlock));
|
|
88
|
+
instructions = applyMathInstructions(applyMediaEmbedInstructions(applyPreviewInstructions(toolInstructions, previewBlock)));
|
|
88
89
|
}
|
|
89
90
|
const prefix = buildModePrompt(relayMode, instructions, { includeInstructions });
|
|
90
91
|
return {
|
|
@@ -5005,10 +5005,12 @@ export function createCopilotSdkSessionRunner({
|
|
|
5005
5005
|
|
|
5006
5006
|
/**
|
|
5007
5007
|
* One image-model row (shared/worker-runtime/direct-openai-image.mjs). With
|
|
5008
|
-
* an image operation the relay's execute route publishes the reply
|
|
5009
|
-
* failure
|
|
5010
|
-
*
|
|
5011
|
-
*
|
|
5008
|
+
* an image operation the relay's execute route publishes the reply or the
|
|
5009
|
+
* failure itself; without one the shared helper publishes the reply, and
|
|
5010
|
+
* any failure of the request is published here as the row's failure, never
|
|
5011
|
+
* requeued: the relay already retried it once, and a second run is billed
|
|
5012
|
+
* again. Only an execute call that did not get through requeues the row
|
|
5013
|
+
* (the route refuses to send an operation's request twice).
|
|
5012
5014
|
*/
|
|
5013
5015
|
async function runDirectImageTurn(message) {
|
|
5014
5016
|
const id = String(message.id || '');
|
|
@@ -5025,11 +5027,11 @@ export function createCopilotSdkSessionRunner({
|
|
|
5025
5027
|
dbg('image turn', message.id, result.via, result.outcome);
|
|
5026
5028
|
} catch (error) {
|
|
5027
5029
|
if (isOpenAIImageTerminalError(error)) {
|
|
5028
|
-
//
|
|
5029
|
-
//
|
|
5030
|
-
|
|
5031
|
-
|
|
5032
|
-
dbg(
|
|
5030
|
+
// The relay's note and code (the ones its image operation uses for
|
|
5031
|
+
// the same cases); an older relay names no code.
|
|
5032
|
+
const code = error.imageCode
|
|
5033
|
+
|| (error.relayProcessing === true ? 'image-reply-unprocessed' : 'image-provider-rejected');
|
|
5034
|
+
dbg('image turn failed', message.id, code, error.message);
|
|
5033
5035
|
await publishResponse(message, {
|
|
5034
5036
|
text: error.message,
|
|
5035
5037
|
model: null,
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
cursorSubagentRosterFingerprint,
|
|
21
21
|
} from './cursor-subagent-roster.mjs';
|
|
22
22
|
import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
|
|
23
|
+
import { renderMathInstructionBlock } from '../../shared/math-instructions.mjs';
|
|
23
24
|
import { renderMediaEmbedInstructionBlock } from '../../shared/media-embed-instructions.mjs';
|
|
24
25
|
import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
|
|
25
26
|
import { resolveFallbackContextLimitTokens } from '../../shared/context-window-fallbacks.mjs';
|
|
@@ -586,7 +587,8 @@ export function createCursorTurnRunner({
|
|
|
586
587
|
await ensureAgentHandle(message, model);
|
|
587
588
|
const userMessage = buildUserMessageImpl(message);
|
|
588
589
|
const modeNudge = cursorModeNudge(message.relayMode, lastNudgedRelayMode);
|
|
589
|
-
|
|
590
|
+
// The formula notation rides with it, for the same reason.
|
|
591
|
+
const mediaGuidance = mediaGuidanceSent ? '' : `${renderMediaEmbedInstructionBlock()}\n\n${renderMathInstructionBlock()}`;
|
|
590
592
|
if (modeNudge || mediaGuidance) {
|
|
591
593
|
userMessage.text = [mediaGuidance, modeNudge, userMessage.text].filter(Boolean).join('\n\n');
|
|
592
594
|
}
|
|
@@ -170,6 +170,9 @@ export function createSdkMessageNormalizer() {
|
|
|
170
170
|
deltaOwnsThinking = true;
|
|
171
171
|
if (!openThought) openThought = { reasoningId: allocateThoughtId(), text: '' };
|
|
172
172
|
openThought.text = capThought(openThought.text + String(update?.text || ''));
|
|
173
|
+
// Gated as stream text; the thought's close sends the whole text.
|
|
174
|
+
if (!shouldEmitStreamUpdate(openThought.text, openThought.lastEmittedText)) return actions;
|
|
175
|
+
openThought.lastEmittedText = openThought.text;
|
|
173
176
|
actions.push({
|
|
174
177
|
channel: 'thought',
|
|
175
178
|
payload: {
|
|
@@ -201,6 +204,14 @@ export function createSdkMessageNormalizer() {
|
|
|
201
204
|
|
|
202
205
|
if (type === 'turn-ended') {
|
|
203
206
|
if (update?.usage && typeof update.usage === 'object') lastUsage = update.usage;
|
|
207
|
+
// A thought the turn left open: what the delta gating held back.
|
|
208
|
+
if (openThought?.text.trim() && openThought.text !== openThought.lastEmittedText) {
|
|
209
|
+
openThought.lastEmittedText = openThought.text;
|
|
210
|
+
actions.push({
|
|
211
|
+
channel: 'thought',
|
|
212
|
+
payload: { reasoningId: openThought.reasoningId, text: openThought.text, done: false, subagentRunId: null },
|
|
213
|
+
});
|
|
214
|
+
}
|
|
204
215
|
return actions;
|
|
205
216
|
}
|
|
206
217
|
|
|
@@ -231,6 +242,8 @@ export function createSdkMessageNormalizer() {
|
|
|
231
242
|
state.openThought = { reasoningId: `cursor-thought-${runId}-${state.thoughtIndex++}`, text: '' };
|
|
232
243
|
}
|
|
233
244
|
state.openThought.text = capThought(state.openThought.text + String(nested.text || ''));
|
|
245
|
+
if (!shouldEmitStreamUpdate(state.openThought.text, state.openThought.lastEmittedText)) return actions;
|
|
246
|
+
state.openThought.lastEmittedText = state.openThought.text;
|
|
234
247
|
actions.push({
|
|
235
248
|
channel: 'thought',
|
|
236
249
|
payload: {
|
package/server/db-schema.mjs
CHANGED
|
@@ -44,6 +44,8 @@ import { migrateConversationPlanOnly } from './migrations/0035-conversation-plan
|
|
|
44
44
|
import { migrateTokenUsage } from './migrations/0036-token-usage.mjs';
|
|
45
45
|
import { migrateRelayAsksQueueId } from './migrations/0037-relay-asks-queue-id.mjs';
|
|
46
46
|
import { migrateTurnSeq } from './migrations/0038-turn-seq.mjs';
|
|
47
|
+
import { migrateMediaIndex } from './migrations/0039-media-index.mjs';
|
|
48
|
+
import { migrateImageSessionDefaults } from './migrations/0040-image-session-defaults.mjs';
|
|
47
49
|
import { migrateImageConversationSchema } from './repositories/image-conversation-repository.mjs';
|
|
48
50
|
|
|
49
51
|
// Mirrors DEFAULT_RELAY_MODE in server-runtime.mjs; used only by the one-time
|
|
@@ -1096,6 +1098,23 @@ try {
|
|
|
1096
1098
|
console.warn(`[turn-seq] migration 0038 failed; retrying on next boot: ${error?.message || error}`);
|
|
1097
1099
|
}
|
|
1098
1100
|
|
|
1101
|
+
// The media gallery index: tables, the two queue triggers on messages and a
|
|
1102
|
+
// one-time seed of the queue. The gallery works without it (empty), so a
|
|
1103
|
+
// failure is logged and retried next boot.
|
|
1104
|
+
try {
|
|
1105
|
+
migrateMediaIndex(db);
|
|
1106
|
+
} catch (error) {
|
|
1107
|
+
console.warn(`[media-index] migration 0039 failed; retrying on next boot: ${error?.message || error}`);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// An image session's own size/quality/count defaults. Image turns run on the
|
|
1111
|
+
// built-in defaults without it, so a failure is logged and retried next boot.
|
|
1112
|
+
try {
|
|
1113
|
+
migrateImageSessionDefaults(db);
|
|
1114
|
+
} catch (error) {
|
|
1115
|
+
console.warn(`[image-sessions] migration 0040 failed; retrying on next boot: ${error?.message || error}`);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1099
1118
|
const conversationColumns = db.prepare(`PRAGMA table_info(conversations)`).all().map((c) => c.name);
|
|
1100
1119
|
if (!conversationColumns.includes('archived')) {
|
|
1101
1120
|
db.exec(`ALTER TABLE conversations ADD COLUMN archived INTEGER NOT NULL DEFAULT 0`);
|
|
@@ -10,6 +10,7 @@ import { extractGrokUsageFromPromptResult, normalizeGrokTurnUsage } from '../ser
|
|
|
10
10
|
import { OAR_MCP_SERVER_NAME, buildOarMcpServerLaunch, isOarMcpServerDisabled } from '../mcp/oar-mcp-server.mjs';
|
|
11
11
|
import { createPreviewInstructionsProvider } from '../../shared/preview-instructions.mjs';
|
|
12
12
|
import { fetchRemoteRelayInflight } from '../../shared/remote-relay-tool-core.mjs';
|
|
13
|
+
import { renderMathInstructionBlock } from '../../shared/math-instructions.mjs';
|
|
13
14
|
import { renderMediaEmbedInstructionBlock } from '../../shared/media-embed-instructions.mjs';
|
|
14
15
|
import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
|
|
15
16
|
import { PLAN_BOARD_ACTIONS } from '../../shared/plan-board-actions.mjs';
|
|
@@ -476,7 +477,8 @@ export function createGrokTurnRunner({
|
|
|
476
477
|
: await Promise.resolve()
|
|
477
478
|
.then(() => getPreviewInstructions?.({ conversationId: message.conversationId || sdkSessionId || '' }))
|
|
478
479
|
.catch(() => '');
|
|
479
|
-
|
|
480
|
+
// The formula notation rides with the media block, once per worker.
|
|
481
|
+
const mediaBlock = mediaInstructionsSent ? '' : `${renderMediaEmbedInstructionBlock()}\n\n${renderMathInstructionBlock()}`;
|
|
480
482
|
const promptText = [previewBlock, mediaBlock, modeNudge, userText].filter(Boolean).join('\n\n');
|
|
481
483
|
const pendingNudgedRelayMode = String(message.relayMode || 'agent').trim().toLowerCase();
|
|
482
484
|
|
|
@@ -148,6 +148,9 @@ export function createSdkMessageNormalizer() {
|
|
|
148
148
|
openThought = { reasoningId: allocateThoughtId(), text: '' };
|
|
149
149
|
}
|
|
150
150
|
openThought.text = capThought(openThought.text + chunk);
|
|
151
|
+
// Gated as stream text; the thought's close sends the whole text.
|
|
152
|
+
if (!shouldEmitStreamUpdate(openThought.text, openThought.lastEmittedText)) return actions;
|
|
153
|
+
openThought.lastEmittedText = openThought.text;
|
|
151
154
|
actions.push({
|
|
152
155
|
channel: 'thought',
|
|
153
156
|
payload: {
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Migration: the media gallery index.
|
|
5
|
+
*
|
|
6
|
+
* `media_items`: one row per picture, video or attached file of a message
|
|
7
|
+
* (services/media-extract.mjs decides what counts). Rows go with their
|
|
8
|
+
* message or conversation (foreign keys, ON DELETE CASCADE). `message_ts`
|
|
9
|
+
* copies the message time so the gallery pages by an index.
|
|
10
|
+
*
|
|
11
|
+
* `media_index_queue`: the ids of messages to (re)index. Two triggers on
|
|
12
|
+
* `messages` put a row id here on insert and when a column the index reads
|
|
13
|
+
* changes; they never parse anything, so the write path pays one small
|
|
14
|
+
* insert. The indexer (services/media-index-service.mjs) drains the queue in
|
|
15
|
+
* batches. `seq` grows on every re-queue, so a drain only removes the queue
|
|
16
|
+
* row it actually indexed.
|
|
17
|
+
*
|
|
18
|
+
* `media_index_meta`: the extractor version the queue was seeded for. When
|
|
19
|
+
* it is missing (first run) or older than MEDIA_EXTRACTOR_VERSION, every
|
|
20
|
+
* message is queued once and the version is stored; later boots seed nothing.
|
|
21
|
+
*
|
|
22
|
+
* Not in SCHEMA_SQL (db-schema.mjs): the triggers name columns that older
|
|
23
|
+
* migrations add. The triggers are checked on every boot and re-created when
|
|
24
|
+
* missing or different, like the FTS triggers.
|
|
25
|
+
*
|
|
26
|
+
* Additive and idempotent. Rolling back: drop the triggers first, then the
|
|
27
|
+
* tables (a trigger left pointing at a dropped table breaks message inserts):
|
|
28
|
+
* DROP TRIGGER IF EXISTS media_index_on_insert;
|
|
29
|
+
* DROP TRIGGER IF EXISTS media_index_on_update;
|
|
30
|
+
* DROP TABLE IF EXISTS media_index_queue; DROP TABLE IF EXISTS media_items;
|
|
31
|
+
* DROP TABLE IF EXISTS media_index_meta;
|
|
32
|
+
*
|
|
33
|
+
* Usage: node server/migrations/0039-media-index.mjs [path/to/oar.db]
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { fileURLToPath } from 'url';
|
|
37
|
+
import path from 'path';
|
|
38
|
+
import process from 'process';
|
|
39
|
+
import Database from 'better-sqlite3';
|
|
40
|
+
import { cliDbPath } from '../services/database-file.mjs';
|
|
41
|
+
import { MEDIA_EXTRACTOR_VERSION } from '../services/media-extract.mjs';
|
|
42
|
+
|
|
43
|
+
export const MEDIA_INDEX_TRIGGERS = Object.freeze(['media_index_on_insert', 'media_index_on_update']);
|
|
44
|
+
// Columns the index reads; a change to one re-queues the message.
|
|
45
|
+
const WATCHED_COLUMNS = Object.freeze(['text', 'attachments', 'timestamp', 'role', 'kind', 'origin_json', 'conversation_id']);
|
|
46
|
+
|
|
47
|
+
const QUEUE_UPSERT = `INSERT INTO media_index_queue (message_id, seq) VALUES (NEW.id, 0)
|
|
48
|
+
ON CONFLICT(message_id) DO UPDATE SET seq = seq + 1;`;
|
|
49
|
+
|
|
50
|
+
function tableExists(db, name) {
|
|
51
|
+
return !!db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`).get(name);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function columnsOf(db, table) {
|
|
55
|
+
return db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function mediaIndexTriggerSql(messageColumns) {
|
|
59
|
+
const watched = WATCHED_COLUMNS.filter((column) => messageColumns.includes(column));
|
|
60
|
+
return {
|
|
61
|
+
media_index_on_insert: `CREATE TRIGGER media_index_on_insert AFTER INSERT ON messages BEGIN
|
|
62
|
+
${QUEUE_UPSERT}
|
|
63
|
+
END`,
|
|
64
|
+
media_index_on_update: `CREATE TRIGGER media_index_on_update AFTER UPDATE OF ${watched.join(', ')} ON messages BEGIN
|
|
65
|
+
${QUEUE_UPSERT}
|
|
66
|
+
END`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function migrateMediaIndex(db) {
|
|
71
|
+
if (!tableExists(db, 'messages') || !tableExists(db, 'conversations')) {
|
|
72
|
+
return { applied: false, tablesCreated: 0, triggersCreated: 0, seeded: 0 };
|
|
73
|
+
}
|
|
74
|
+
let tablesCreated = 0;
|
|
75
|
+
let triggersCreated = 0;
|
|
76
|
+
let seeded = 0;
|
|
77
|
+
const run = db.transaction(() => {
|
|
78
|
+
if (!tableExists(db, 'media_items')) {
|
|
79
|
+
db.exec(`
|
|
80
|
+
CREATE TABLE media_items (
|
|
81
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
82
|
+
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
83
|
+
conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
|
84
|
+
ordinal INTEGER NOT NULL,
|
|
85
|
+
role TEXT NOT NULL,
|
|
86
|
+
sender TEXT NOT NULL,
|
|
87
|
+
origin TEXT NOT NULL,
|
|
88
|
+
locator_kind TEXT NOT NULL,
|
|
89
|
+
locator TEXT NOT NULL,
|
|
90
|
+
identity_key TEXT NOT NULL,
|
|
91
|
+
category TEXT NOT NULL,
|
|
92
|
+
media_kind TEXT NOT NULL,
|
|
93
|
+
mime TEXT,
|
|
94
|
+
name TEXT,
|
|
95
|
+
alt TEXT,
|
|
96
|
+
size_bytes INTEGER,
|
|
97
|
+
message_ts TEXT NOT NULL,
|
|
98
|
+
UNIQUE (message_id, ordinal)
|
|
99
|
+
);
|
|
100
|
+
CREATE INDEX idx_media_items_conv ON media_items(conversation_id, category, message_ts DESC, id DESC);
|
|
101
|
+
CREATE INDEX idx_media_items_all ON media_items(category, message_ts DESC, id DESC);
|
|
102
|
+
CREATE INDEX idx_media_items_identity ON media_items(identity_key);
|
|
103
|
+
`);
|
|
104
|
+
tablesCreated += 1;
|
|
105
|
+
}
|
|
106
|
+
if (!tableExists(db, 'media_index_queue')) {
|
|
107
|
+
db.exec(`
|
|
108
|
+
CREATE TABLE media_index_queue (
|
|
109
|
+
message_id TEXT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
|
|
110
|
+
seq INTEGER NOT NULL DEFAULT 0
|
|
111
|
+
)
|
|
112
|
+
`);
|
|
113
|
+
tablesCreated += 1;
|
|
114
|
+
}
|
|
115
|
+
if (!tableExists(db, 'media_index_meta')) {
|
|
116
|
+
db.exec(`CREATE TABLE media_index_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
|
|
117
|
+
tablesCreated += 1;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const expected = mediaIndexTriggerSql(columnsOf(db, 'messages'));
|
|
121
|
+
for (const name of MEDIA_INDEX_TRIGGERS) {
|
|
122
|
+
const row = db.prepare(`SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = ?`).get(name);
|
|
123
|
+
if (row?.sql === expected[name]) continue;
|
|
124
|
+
if (row) db.exec(`DROP TRIGGER ${name}`);
|
|
125
|
+
db.exec(expected[name]);
|
|
126
|
+
triggersCreated += 1;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const version = db.prepare(`SELECT value FROM media_index_meta WHERE key = 'extractor_version'`).get()?.value;
|
|
130
|
+
if (String(version || '') !== String(MEDIA_EXTRACTOR_VERSION)) {
|
|
131
|
+
seeded = db.prepare(`
|
|
132
|
+
INSERT INTO media_index_queue (message_id, seq)
|
|
133
|
+
SELECT id, 0 FROM messages WHERE true
|
|
134
|
+
ON CONFLICT(message_id) DO UPDATE SET seq = seq + 1
|
|
135
|
+
`).run().changes;
|
|
136
|
+
db.prepare(`INSERT INTO media_index_meta (key, value) VALUES ('extractor_version', ?)
|
|
137
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(String(MEDIA_EXTRACTOR_VERSION));
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
run();
|
|
141
|
+
return { applied: tablesCreated + triggersCreated + seeded > 0, tablesCreated, triggersCreated, seeded };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function migrate(dbPath, options = {}) {
|
|
145
|
+
const db = new Database(dbPath, options);
|
|
146
|
+
try {
|
|
147
|
+
return migrateMediaIndex(db);
|
|
148
|
+
} finally {
|
|
149
|
+
db.close();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
154
|
+
if (isMain) {
|
|
155
|
+
try {
|
|
156
|
+
const dbPath = cliDbPath(process.argv[2], new URL('..', import.meta.url));
|
|
157
|
+
const result = migrate(dbPath);
|
|
158
|
+
console.log(`Migration 0039 on ${dbPath}`);
|
|
159
|
+
console.log(result.applied
|
|
160
|
+
? ` media index: tables created ${result.tablesCreated}, triggers created ${result.triggersCreated}, messages queued ${result.seeded}`
|
|
161
|
+
: ' skipped: nothing to change');
|
|
162
|
+
process.exit(0);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
console.error(`Migration 0039 failed: ${err?.message || err}`);
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Migration: an image session's own defaults, and a turn's options.
|
|
5
|
+
*
|
|
6
|
+
* `image_sessions.defaults_json`: the size, quality, count, output format and
|
|
7
|
+
* background an image session's turns use unless a turn names its own
|
|
8
|
+
* (relay_tool update_session `image`, the conversation settings route).
|
|
9
|
+
* JSON as shared/openai-image-options.mjs validates it; NULL means none.
|
|
10
|
+
*
|
|
11
|
+
* `queue.image_options_json`: the options of one image turn as the send
|
|
12
|
+
* settled them, for a turn without an image operation (the worker sends them
|
|
13
|
+
* with its direct request). NULL for every other turn.
|
|
14
|
+
*
|
|
15
|
+
* The image tables are created by migrateImageConversationSchema
|
|
16
|
+
* (repositories/image-conversation-repository.mjs), which this migration
|
|
17
|
+
* calls first, additively, so a database whose tables are missing gets them
|
|
18
|
+
* before the column. A database without the queue table (no relay's) is
|
|
19
|
+
* left alone.
|
|
20
|
+
*
|
|
21
|
+
* Additive and idempotent. Safe to run on every boot. Rolling back needs no
|
|
22
|
+
* data change: older code ignores the column.
|
|
23
|
+
*
|
|
24
|
+
* Usage: node server/migrations/0040-image-session-defaults.mjs [path/to/oar.db]
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { fileURLToPath } from 'url';
|
|
28
|
+
import path from 'path';
|
|
29
|
+
import process from 'process';
|
|
30
|
+
import Database from 'better-sqlite3';
|
|
31
|
+
import { cliDbPath } from '../services/database-file.mjs';
|
|
32
|
+
import { migrateImageConversationSchema } from '../repositories/image-conversation-repository.mjs';
|
|
33
|
+
|
|
34
|
+
export function hasImageSessionDefaultsColumn(db) {
|
|
35
|
+
try {
|
|
36
|
+
return db.prepare(`PRAGMA table_info(image_sessions)`).all().some((column) => column.name === 'defaults_json');
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function tableExists(db, name) {
|
|
43
|
+
return !!db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`).get(name);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function hasQueueImageOptionsColumn(db) {
|
|
47
|
+
try {
|
|
48
|
+
return db.prepare(`PRAGMA table_info(queue)`).all().some((column) => column.name === 'image_options_json');
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function migrateImageSessionDefaults(db) {
|
|
55
|
+
// The image schema reads the queue; a database without one is no relay's.
|
|
56
|
+
if (!tableExists(db, 'queue')) return { applied: false, columnsAdded: 0 };
|
|
57
|
+
migrateImageConversationSchema(db);
|
|
58
|
+
let columnsAdded = 0;
|
|
59
|
+
if (!hasImageSessionDefaultsColumn(db)) {
|
|
60
|
+
db.exec(`ALTER TABLE image_sessions ADD COLUMN defaults_json TEXT`);
|
|
61
|
+
columnsAdded += 1;
|
|
62
|
+
}
|
|
63
|
+
if (!hasQueueImageOptionsColumn(db)) {
|
|
64
|
+
db.exec(`ALTER TABLE queue ADD COLUMN image_options_json TEXT`);
|
|
65
|
+
columnsAdded += 1;
|
|
66
|
+
}
|
|
67
|
+
return { applied: columnsAdded > 0, columnsAdded };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function migrate(dbPath, options = {}) {
|
|
71
|
+
const db = new Database(dbPath, options);
|
|
72
|
+
try {
|
|
73
|
+
return migrateImageSessionDefaults(db);
|
|
74
|
+
} finally {
|
|
75
|
+
db.close();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
80
|
+
if (isMain) {
|
|
81
|
+
try {
|
|
82
|
+
// The given path, else the database in server/data (oar.db, else the
|
|
83
|
+
// name before 0.9.20); never a new one.
|
|
84
|
+
const dbPath = cliDbPath(process.argv[2], new URL('..', import.meta.url));
|
|
85
|
+
const result = migrate(dbPath);
|
|
86
|
+
console.log(`Migration 0040 on ${dbPath}`);
|
|
87
|
+
console.log(result.applied ? ` image session defaults, turn options: columns added ${result.columnsAdded}` : ' skipped: nothing to change');
|
|
88
|
+
process.exit(0);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
console.error(`Migration 0040 failed: ${err?.message || err}`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BASE, TOKEN, CLIENT_ID, IS_SHARE_PREVIEW, SHARE_PREVIEW_CONVERSATION_ID, SHARED_CONVERSATION_TOKEN, authHeaders, updateWorkspaceRootHints, applyContextUsageBar, readContextUsageRatio, currentConvId, conversations, setCliOnline, setCloudflaredTunnelState, setActiveRuntimeSessionCount, setRuntimeSessionBindingCount, setContextIndicatorMode, setServerPlatform, setUpdateState } from './store.js';
|
|
2
2
|
import { liveActivityCap } from './live-activity-cap.mjs';
|
|
3
|
+
import { OPENAI_IMAGE_PRESETS_PATH, OPENAI_IMAGE_SETTINGS_FIELDS, OPENAI_IMAGE_USAGE_PATH } from './openai-image-spend.mjs';
|
|
3
4
|
|
|
4
5
|
// fetch() has no default timeout. Without one, a request issued while the app is
|
|
5
6
|
// being backgrounded can hang indefinitely on a half-open mobile connection and
|
|
@@ -543,6 +544,63 @@ export async function updateOpenAISettings({
|
|
|
543
544
|
return settingsRequest('/api/settings/openai', payload, 'Failed to update OpenAI settings');
|
|
544
545
|
}
|
|
545
546
|
|
|
547
|
+
/**
|
|
548
|
+
* The daily image cap and today's override (POST /api/settings/openai with
|
|
549
|
+
* only these fields). Throws with the relay's words: a paired relay's
|
|
550
|
+
* request is refused (403).
|
|
551
|
+
*/
|
|
552
|
+
export async function updateOpenAIImageSpendSettings({ imageDailyCapUsd = undefined, allowImagesToday = undefined } = {}) {
|
|
553
|
+
const payload = {};
|
|
554
|
+
if (imageDailyCapUsd !== undefined) payload[OPENAI_IMAGE_SETTINGS_FIELDS.dailyCapUsd] = Number(imageDailyCapUsd);
|
|
555
|
+
if (typeof allowImagesToday === 'boolean') payload[OPENAI_IMAGE_SETTINGS_FIELDS.allowToday] = allowImagesToday;
|
|
556
|
+
return settingsRequest('/api/settings/openai', payload, 'Failed to update the image cap');
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Today's image spend; with a conversation, that chat's cost too. Null on any failure. */
|
|
560
|
+
export async function loadOpenAIImageUsage({ conversationId = '' } = {}) {
|
|
561
|
+
const id = String(conversationId || '').trim();
|
|
562
|
+
return quietGet(id ? `${OPENAI_IMAGE_USAGE_PATH}?conversationId=${encodeURIComponent(id)}` : OPENAI_IMAGE_USAGE_PATH);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// The presets routes: the relay's `error` and `code` ride on the thrown Error.
|
|
566
|
+
async function imagePresetsRequest(method, path, body = null) {
|
|
567
|
+
if (!networkRequestsEnabled) return null;
|
|
568
|
+
let response;
|
|
569
|
+
try {
|
|
570
|
+
response = await fetch(`${BASE}${path}`, {
|
|
571
|
+
signal: requestTimeoutSignal(),
|
|
572
|
+
method,
|
|
573
|
+
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
|
574
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
575
|
+
});
|
|
576
|
+
} catch (error) {
|
|
577
|
+
noteFetchFailure(path, error);
|
|
578
|
+
throw Object.assign(new Error(`The relay could not be reached (${readableServerError(toErrorMessage(error))}).`), { status: 0, code: null });
|
|
579
|
+
}
|
|
580
|
+
noteFetchSuccess();
|
|
581
|
+
const result = await response.json().catch(() => null);
|
|
582
|
+
if (!response.ok) {
|
|
583
|
+
throw Object.assign(
|
|
584
|
+
new Error(readableServerError(result?.error || '') || `The relay answered HTTP ${response.status}.`),
|
|
585
|
+
{ status: response.status, code: result?.code || null },
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
return result;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** `{ presets }` (built-ins and own), or null when the relay has none to give. */
|
|
592
|
+
export async function loadOpenAIImagePresets() {
|
|
593
|
+
return quietGet(OPENAI_IMAGE_PRESETS_PATH);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
export async function saveOpenAIImagePreset(preset, { replace = false } = {}) {
|
|
597
|
+
return imagePresetsRequest('PUT', OPENAI_IMAGE_PRESETS_PATH, { preset, ...(replace ? { replace: true } : {}) });
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export async function deleteOpenAIImagePreset(name) {
|
|
601
|
+
return imagePresetsRequest('DELETE', `${OPENAI_IMAGE_PRESETS_PATH}/${encodeURIComponent(String(name || ''))}`);
|
|
602
|
+
}
|
|
603
|
+
|
|
546
604
|
export async function loadCursorSettings() {
|
|
547
605
|
return apiFetch('/api/settings/cursor');
|
|
548
606
|
}
|
|
@@ -131,7 +131,8 @@ export async function loadRelayQuestions(conversationId) {
|
|
|
131
131
|
// after a link the relay did not know: its notice keeps the screen empty
|
|
132
132
|
// until the reader opens something.
|
|
133
133
|
const readingAView = document.body?.classList?.contains('inbox-view-active')
|
|
134
|
-
|| document.body?.classList?.contains('status-view-active')
|
|
134
|
+
|| document.body?.classList?.contains('status-view-active')
|
|
135
|
+
|| document.body?.classList?.contains('media-view-active');
|
|
135
136
|
if (!conversationId && pending.length && !readingAView && !isConversationAutoOpenHeld()) {
|
|
136
137
|
const latest = pending
|
|
137
138
|
.slice()
|