@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,221 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
|
|
3
|
+
import { createMediaRepository } from '../repositories/media-repository.mjs';
|
|
4
|
+
import { extractMediaFromMessage } from './media-extract.mjs';
|
|
5
|
+
|
|
6
|
+
// Fills the media gallery index (migration 0039) from its queue. The
|
|
7
|
+
// triggers on `messages` only queue ids; this service reads each queued
|
|
8
|
+
// message, extracts its media and replaces the message's rows, a batch per
|
|
9
|
+
// transaction.
|
|
10
|
+
//
|
|
11
|
+
// Draining is synchronous (better-sqlite3), so a batch never interleaves with
|
|
12
|
+
// a turn's writes; a message written after it was read is re-queued (its seq
|
|
13
|
+
// grows) and indexed by a later batch.
|
|
14
|
+
//
|
|
15
|
+
// Scheduling: a background loop runs only while the queue has rows (the
|
|
16
|
+
// backfill after the migration, or what piled up since the last gallery
|
|
17
|
+
// request) and stops when it is empty, so an idle relay runs no timer for
|
|
18
|
+
// the gallery. Every gallery request first drains a bounded number of
|
|
19
|
+
// messages itself (`drainForRequest`) so new pictures show at once, and
|
|
20
|
+
// restarts the loop when rows are left.
|
|
21
|
+
|
|
22
|
+
export const MEDIA_INDEX_BATCH_SIZE = 10;
|
|
23
|
+
export const MEDIA_INDEX_REQUEST_DRAIN = 200;
|
|
24
|
+
// Time budgets per drain, so indexing never holds the event loop for long:
|
|
25
|
+
// the background loop indexes for ~20 ms every 500 ms in batches of ten
|
|
26
|
+
// messages (a slice ends with its batch's commit), a gallery request up to
|
|
27
|
+
// 100 ms; the rest waits for the loop.
|
|
28
|
+
export const MEDIA_INDEX_LOOP_BUDGET_MS = 20;
|
|
29
|
+
export const MEDIA_INDEX_REQUEST_BUDGET_MS = 100;
|
|
30
|
+
export const MEDIA_INDEX_INTERVAL_MS = 500;
|
|
31
|
+
// A message body above this is read in a slice of its own.
|
|
32
|
+
export const MEDIA_INDEX_LARGE_BODY_BYTES = 1024 * 1024;
|
|
33
|
+
|
|
34
|
+
export function createMediaIndexService({
|
|
35
|
+
db,
|
|
36
|
+
repository = createMediaRepository(db),
|
|
37
|
+
extract = extractMediaFromMessage,
|
|
38
|
+
platform = process.platform,
|
|
39
|
+
batchSize = MEDIA_INDEX_BATCH_SIZE,
|
|
40
|
+
intervalMs = MEDIA_INDEX_INTERVAL_MS,
|
|
41
|
+
setTimer = setTimeout,
|
|
42
|
+
clearTimer = clearTimeout,
|
|
43
|
+
logger = console,
|
|
44
|
+
now = () => performance.now(),
|
|
45
|
+
} = {}) {
|
|
46
|
+
let timer = null;
|
|
47
|
+
let stopped = true;
|
|
48
|
+
let warnedExtractError = false;
|
|
49
|
+
|
|
50
|
+
function indexRow(row) {
|
|
51
|
+
let items = [];
|
|
52
|
+
try {
|
|
53
|
+
items = extract({
|
|
54
|
+
role: row.role,
|
|
55
|
+
text: row.text,
|
|
56
|
+
attachments: row.attachments,
|
|
57
|
+
conversationId: row.conversationId,
|
|
58
|
+
messageId: row.messageId,
|
|
59
|
+
workspaceRoot: row.workspaceRoot || '',
|
|
60
|
+
platform,
|
|
61
|
+
});
|
|
62
|
+
} catch (error) {
|
|
63
|
+
// A message the extractor cannot read gets no media; its queue row
|
|
64
|
+
// still goes, so it cannot block the queue.
|
|
65
|
+
if (!warnedExtractError) {
|
|
66
|
+
warnedExtractError = true;
|
|
67
|
+
logger?.warn?.(`[media-index] could not read a message; it gets no media: ${error?.message || error}`);
|
|
68
|
+
}
|
|
69
|
+
items = [];
|
|
70
|
+
}
|
|
71
|
+
return repository.replaceMessageItems(row, items);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Indexes up to `maxMessages` queued messages now, within `budgetMs`; says how many are left. */
|
|
75
|
+
function drain(options = {}) {
|
|
76
|
+
const result = drainSlice(options);
|
|
77
|
+
return { ...result, pending: repository.hasPending() ? repository.pendingCount() : 0 };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function drainSlice({ maxMessages = batchSize, budgetMs = Infinity } = {}) {
|
|
81
|
+
let indexed = 0;
|
|
82
|
+
let items = 0;
|
|
83
|
+
if (!repository.available) return { indexed, items };
|
|
84
|
+
const started = now();
|
|
85
|
+
let outOfTime = false;
|
|
86
|
+
while (indexed < maxMessages && !outOfTime) {
|
|
87
|
+
const rows = repository.nextQueueBatch(Math.min(batchSize, maxMessages - indexed));
|
|
88
|
+
if (!rows.length) break;
|
|
89
|
+
// One commit per batch: a commit per message costs more than the indexing.
|
|
90
|
+
repository.inTransaction(() => {
|
|
91
|
+
for (const entry of rows) {
|
|
92
|
+
// A large body (a pasted data image) is read only at the start of a
|
|
93
|
+
// slice, so reading it never adds to work already done in the slice.
|
|
94
|
+
if (indexed > 0 && Number(entry.bytes) > MEDIA_INDEX_LARGE_BODY_BYTES) {
|
|
95
|
+
outOfTime = true;
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
const row = repository.loadQueuedMessage(entry);
|
|
99
|
+
if (row) items += indexRow(row);
|
|
100
|
+
indexed += 1;
|
|
101
|
+
if (now() - started >= budgetMs) {
|
|
102
|
+
outOfTime = true;
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
if (rows.length < batchSize) break;
|
|
108
|
+
}
|
|
109
|
+
return { indexed, items };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function schedule() {
|
|
113
|
+
if (stopped || timer || !repository.available) return;
|
|
114
|
+
timer = setTimer(tick, intervalMs);
|
|
115
|
+
timer?.unref?.();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function tick() {
|
|
119
|
+
timer = null;
|
|
120
|
+
if (stopped) return;
|
|
121
|
+
let more = false;
|
|
122
|
+
try {
|
|
123
|
+
drainSlice({ maxMessages: Infinity, budgetMs: MEDIA_INDEX_LOOP_BUDGET_MS });
|
|
124
|
+
more = repository.hasPending();
|
|
125
|
+
} catch (error) {
|
|
126
|
+
logger?.warn?.(`[media-index] indexing failed; retrying later: ${error?.message || error}`);
|
|
127
|
+
more = true;
|
|
128
|
+
}
|
|
129
|
+
if (more) schedule();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
get available() {
|
|
134
|
+
return repository.available;
|
|
135
|
+
},
|
|
136
|
+
drain,
|
|
137
|
+
/** Before answering a gallery request: index what is new, keep the rest for the loop. */
|
|
138
|
+
drainForRequest() {
|
|
139
|
+
const result = drain({ maxMessages: MEDIA_INDEX_REQUEST_DRAIN, budgetMs: MEDIA_INDEX_REQUEST_BUDGET_MS });
|
|
140
|
+
if (result.pending > 0) schedule();
|
|
141
|
+
return result;
|
|
142
|
+
},
|
|
143
|
+
start() {
|
|
144
|
+
if (!repository.available) return;
|
|
145
|
+
stopped = false;
|
|
146
|
+
try {
|
|
147
|
+
repository.removeOrphanQueueRows();
|
|
148
|
+
} catch (error) {
|
|
149
|
+
logger?.warn?.(`[media-index] could not clean the queue: ${error?.message || error}`);
|
|
150
|
+
}
|
|
151
|
+
if (repository.hasPending()) schedule();
|
|
152
|
+
},
|
|
153
|
+
stop() {
|
|
154
|
+
stopped = true;
|
|
155
|
+
if (timer) clearTimer(timer);
|
|
156
|
+
timer = null;
|
|
157
|
+
},
|
|
158
|
+
pendingCount: () => repository.pendingCount(),
|
|
159
|
+
isScheduled: () => timer !== null,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// --- whether a file is still there -------------------------------------------
|
|
164
|
+
|
|
165
|
+
export const FILE_PRESENCE_TTL_MS = 60_000;
|
|
166
|
+
export const FILE_PRESENCE_MAX_ENTRIES = 5000;
|
|
167
|
+
export const FILE_PRESENCE_TIMEOUT_MS = 1500;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* A short-lived cache of "is this a regular file" answers, for hiding gallery
|
|
171
|
+
* items whose file was deleted or moved. Async stat with a timeout: a mapped
|
|
172
|
+
* network drive that is offline must not hold a gallery page. A stat that
|
|
173
|
+
* times out counts as missing for this page and is not cached.
|
|
174
|
+
*/
|
|
175
|
+
export function createFilePresenceCache({
|
|
176
|
+
stat = (filePath) => fs.promises.stat(filePath),
|
|
177
|
+
ttlMs = FILE_PRESENCE_TTL_MS,
|
|
178
|
+
maxEntries = FILE_PRESENCE_MAX_ENTRIES,
|
|
179
|
+
timeoutMs = FILE_PRESENCE_TIMEOUT_MS,
|
|
180
|
+
now = () => Date.now(),
|
|
181
|
+
} = {}) {
|
|
182
|
+
const entries = new Map();
|
|
183
|
+
|
|
184
|
+
function remember(filePath, present) {
|
|
185
|
+
entries.delete(filePath);
|
|
186
|
+
entries.set(filePath, { present, at: now() });
|
|
187
|
+
while (entries.size > maxEntries) {
|
|
188
|
+
entries.delete(entries.keys().next().value);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function isFile(filePath) {
|
|
193
|
+
const key = String(filePath || '');
|
|
194
|
+
if (!key) return false;
|
|
195
|
+
const cached = entries.get(key);
|
|
196
|
+
if (cached && now() - cached.at < ttlMs) return cached.present;
|
|
197
|
+
let timeoutHandle = null;
|
|
198
|
+
const timeout = new Promise((resolve) => {
|
|
199
|
+
timeoutHandle = setTimeout(() => resolve('timeout'), timeoutMs);
|
|
200
|
+
timeoutHandle?.unref?.();
|
|
201
|
+
});
|
|
202
|
+
try {
|
|
203
|
+
const result = await Promise.race([
|
|
204
|
+
Promise.resolve().then(() => stat(key)).then((info) => info.isFile(), () => false),
|
|
205
|
+
timeout,
|
|
206
|
+
]);
|
|
207
|
+
if (result === 'timeout') return false;
|
|
208
|
+
remember(key, result === true);
|
|
209
|
+
return result === true;
|
|
210
|
+
} finally {
|
|
211
|
+
clearTimeout(timeoutHandle);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
isFile,
|
|
217
|
+
forget: (filePath) => entries.delete(String(filePath || '')),
|
|
218
|
+
clear: () => entries.clear(),
|
|
219
|
+
size: () => entries.size,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The background "Check for new models" (Settings → General → Models).
|
|
4
|
+
//
|
|
5
|
+
// A minute after boot, then every quarter of an hour, the scheduler asks
|
|
6
|
+
// which providers are due and refreshes those through the same helper as the
|
|
7
|
+
// Select Models Refresh button (provider-model-refresh.mjs), with reason
|
|
8
|
+
// `background`. A provider is due when the check is on, the provider is not
|
|
9
|
+
// left out, it is enabled and set up, its last successful refresh (by
|
|
10
|
+
// anything: the button, a save, a login, the boot) is at least the interval
|
|
11
|
+
// old, and its last failure at least an hour old. A provider never refreshed
|
|
12
|
+
// is due at the first tick, which is how an overdue provider is checked after
|
|
13
|
+
// boot. Time and timers are injected so a test can drive it with a fake clock.
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
MODEL_REFRESH_FAILURE_RETRY_MS,
|
|
17
|
+
MODEL_REFRESH_FIRST_TICK_MS,
|
|
18
|
+
MODEL_REFRESH_PROVIDERS,
|
|
19
|
+
MODEL_REFRESH_TICK_MS,
|
|
20
|
+
} from '../../shared/model-refresh-schedule.mjs';
|
|
21
|
+
import { modelRefreshErrorSummary } from './provider-model-refresh.mjs';
|
|
22
|
+
|
|
23
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
24
|
+
|
|
25
|
+
function timeOf(value) {
|
|
26
|
+
const ms = Date.parse(String(value || ''));
|
|
27
|
+
return Number.isFinite(ms) ? ms : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function notInFuture(ms, nowMs) {
|
|
31
|
+
return ms !== null && ms > nowMs ? null : ms;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The providers due at `nowMs`, in the canonical order. `settings` is
|
|
36
|
+
* `{ auto, intervalHours, excluded }`, `last` the ledger's entries, `table`
|
|
37
|
+
* the provider table (buildProviderRefreshTable).
|
|
38
|
+
*/
|
|
39
|
+
export function dueModelRefreshProviders({ settings = {}, last = {}, table = {}, nowMs = Date.now() } = {}) {
|
|
40
|
+
if (settings.auto === false) return [];
|
|
41
|
+
const intervalMs = Math.max(1, Number(settings.intervalHours) || 24) * HOUR_MS;
|
|
42
|
+
const excluded = new Set(Array.isArray(settings.excluded) ? settings.excluded : []);
|
|
43
|
+
const due = [];
|
|
44
|
+
for (const { id } of MODEL_REFRESH_PROVIDERS) {
|
|
45
|
+
const entry = table[id];
|
|
46
|
+
if (!entry || excluded.has(id) || typeof entry.refresh !== 'function') continue;
|
|
47
|
+
let setUp = false;
|
|
48
|
+
try {
|
|
49
|
+
setUp = entry.isSetUp?.() === true;
|
|
50
|
+
} catch {
|
|
51
|
+
setUp = false;
|
|
52
|
+
}
|
|
53
|
+
if (!setUp) continue;
|
|
54
|
+
// A stamp later than now (a clock set back, a hand edit) cannot be
|
|
55
|
+
// trusted: the provider is due rather than skipped until then.
|
|
56
|
+
const lastOk = notInFuture(timeOf(last[id]?.lastOkAt), nowMs);
|
|
57
|
+
const lastFailure = notInFuture(timeOf(last[id]?.lastFailureAt), nowMs);
|
|
58
|
+
if (lastOk !== null && nowMs - lastOk < intervalMs) continue;
|
|
59
|
+
if (lastFailure !== null && nowMs - lastFailure < MODEL_REFRESH_FAILURE_RETRY_MS) continue;
|
|
60
|
+
due.push(id);
|
|
61
|
+
}
|
|
62
|
+
return due;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createModelRefreshScheduler({
|
|
66
|
+
// () => { auto, intervalHours, excluded }
|
|
67
|
+
getSettings = () => ({ auto: true, intervalHours: 24, excluded: [] }),
|
|
68
|
+
// () => { <provider>: { lastOkAt, lastFailureAt } } (the ledger)
|
|
69
|
+
getLast = () => ({}),
|
|
70
|
+
// () => the provider table (buildProviderRefreshTable)
|
|
71
|
+
getTable = () => ({}),
|
|
72
|
+
// ({ providers, reason }) => Promise<{ results, changed }>
|
|
73
|
+
refreshProviders = async () => ({ results: {}, changed: false }),
|
|
74
|
+
now = () => Date.now(),
|
|
75
|
+
setTimeoutFn = setTimeout,
|
|
76
|
+
clearTimeoutFn = clearTimeout,
|
|
77
|
+
firstTickMs = MODEL_REFRESH_FIRST_TICK_MS,
|
|
78
|
+
tickMs = MODEL_REFRESH_TICK_MS,
|
|
79
|
+
logger = console,
|
|
80
|
+
} = {}) {
|
|
81
|
+
let timer = null;
|
|
82
|
+
let running = null;
|
|
83
|
+
let disposed = false;
|
|
84
|
+
// Set by the routes layer, which owns the models_updated payload.
|
|
85
|
+
let onModelsChanged = () => {};
|
|
86
|
+
|
|
87
|
+
function schedule(delayMs) {
|
|
88
|
+
if (disposed) return;
|
|
89
|
+
if (timer) clearTimeoutFn(timer);
|
|
90
|
+
timer = setTimeoutFn(() => {
|
|
91
|
+
timer = null;
|
|
92
|
+
void tick().finally(() => schedule(tickMs));
|
|
93
|
+
}, Math.max(0, Number(delayMs) || 0));
|
|
94
|
+
timer?.unref?.();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** One look: refreshes what is due. Never rejects; one run at a time. */
|
|
98
|
+
async function tick() {
|
|
99
|
+
if (disposed) return { providers: [], results: {}, changed: false };
|
|
100
|
+
if (running) return running;
|
|
101
|
+
// Cleared in a .finally of its own: a run with nothing due settles
|
|
102
|
+
// before the assignment would otherwise have happened.
|
|
103
|
+
running = (async () => {
|
|
104
|
+
let providers = [];
|
|
105
|
+
try {
|
|
106
|
+
providers = dueModelRefreshProviders({ settings: getSettings(), last: getLast(), table: getTable(), nowMs: now() });
|
|
107
|
+
if (!providers.length) return { providers, results: {}, changed: false };
|
|
108
|
+
logger.log?.(`[model-refresh] background model refresh: ${providers.join(', ')}`);
|
|
109
|
+
const { results = {}, changed = false } = await refreshProviders({ providers, reason: 'background' }) || {};
|
|
110
|
+
const failed = providers.filter((id) => results[id] && results[id].ok !== true && results[id].skipped !== true);
|
|
111
|
+
if (failed.length) {
|
|
112
|
+
// The provider and the kind of failure only: an answer's body may
|
|
113
|
+
// echo what was sent with the request.
|
|
114
|
+
logger.warn?.(`[model-refresh] background model refresh failed for ${failed.map((id) => `${id} (${modelRefreshErrorSummary(results[id].error)})`).join(', ')}`);
|
|
115
|
+
}
|
|
116
|
+
if (changed && !disposed) {
|
|
117
|
+
try {
|
|
118
|
+
onModelsChanged();
|
|
119
|
+
} catch (error) {
|
|
120
|
+
logger.warn?.(`[model-refresh] models-changed listener failed: ${error?.message || error}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { providers, results, changed };
|
|
124
|
+
} catch (error) {
|
|
125
|
+
logger.warn?.(`[model-refresh] background model refresh failed: ${modelRefreshErrorSummary(error?.message || error)}`);
|
|
126
|
+
return { providers, results: {}, changed: false };
|
|
127
|
+
}
|
|
128
|
+
})().finally(() => {
|
|
129
|
+
running = null;
|
|
130
|
+
});
|
|
131
|
+
return running;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function start() {
|
|
135
|
+
if (disposed || timer) return;
|
|
136
|
+
schedule(firstTickMs);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function setOnModelsChanged(listener) {
|
|
140
|
+
onModelsChanged = typeof listener === 'function' ? listener : () => {};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function dispose() {
|
|
144
|
+
disposed = true;
|
|
145
|
+
if (timer) {
|
|
146
|
+
clearTimeoutFn(timer);
|
|
147
|
+
timer = null;
|
|
148
|
+
}
|
|
149
|
+
try { await running; } catch { /* tick() never rejects */ }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { start, tick, dispose, setOnModelsChanged, isStarted: () => timer !== null };
|
|
153
|
+
}
|
|
@@ -28,7 +28,13 @@
|
|
|
28
28
|
// * curated ids only seed an EMPTY catalog; once an authoritative snapshot
|
|
29
29
|
// (a running engine or the server-side discovery) has listed the models,
|
|
30
30
|
// anything absent from it is marked `unavailable` (enabled rows stay
|
|
31
|
-
// listed so a picker can show them greyed; disabled ones are pruned)
|
|
31
|
+
// listed so a picker can show them greyed; disabled ones are pruned);
|
|
32
|
+
// * with a model map (`copilot_model_state`, provider-model-state.mjs) a row
|
|
33
|
+
// seen for the first time starts as the user would expect: off when the
|
|
34
|
+
// user turned that row off before (a pruned row is remembered), like the
|
|
35
|
+
// model's other rows for a new effort level, and as the "Turn new models
|
|
36
|
+
// on automatically" switch says for a whole new model, which is also
|
|
37
|
+
// flagged new. Without one (tests, older wiring) new rows start off.
|
|
32
38
|
import { humanizeModelLabel } from '../public/app/model-selector-options.mjs';
|
|
33
39
|
import { latestModelCatalogRefresh } from '../../shared/model-catalog-freshness.mjs';
|
|
34
40
|
import {
|
|
@@ -39,6 +45,7 @@ import {
|
|
|
39
45
|
import { KNOWN_REASONING_EFFORTS, MODEL_PICKER_CATEGORIES } from '../../shared/model-descriptors.mjs';
|
|
40
46
|
import { copilotCatalogIndexByModel, inferCopilotVendor } from '../../shared/copilot-model-order.mjs';
|
|
41
47
|
import { selectModelIdsForVariantRefresh } from '../../shared/model-refresh.mjs';
|
|
48
|
+
import { decideNewCopilotVariant, migrateCopilotModelState } from './provider-model-state.mjs';
|
|
42
49
|
|
|
43
50
|
export const SUPPORTED_REASONING_EFFORTS = [...KNOWN_REASONING_EFFORTS];
|
|
44
51
|
/**
|
|
@@ -471,6 +478,14 @@ export function createModelVariantCatalogService({
|
|
|
471
478
|
autoModelSentinel = 'auto',
|
|
472
479
|
// (args: string[]) => Promise<string>; the `copilot help` text fallback.
|
|
473
480
|
runCopilotCliCommand = async () => '',
|
|
481
|
+
// { readCopilot(), writeCopilot(state) } (provider-model-state.mjs); null
|
|
482
|
+
// keeps the old rule: every new row starts off.
|
|
483
|
+
modelStateStore = null,
|
|
484
|
+
// (baseModelIds, on) — whole new models were added (the toast).
|
|
485
|
+
onNewModels = () => {},
|
|
486
|
+
// ({ source }) — a snapshot that read the runtime's real list arrived
|
|
487
|
+
// (counts as a Copilot model refresh).
|
|
488
|
+
onAuthoritativeSnapshot = () => {},
|
|
474
489
|
} = {}) {
|
|
475
490
|
const DEFAULT_MODEL = String(defaultModel || 'gpt-5.4-mini').trim() || 'gpt-5.4-mini';
|
|
476
491
|
const AUTO_MODEL_SENTINEL = String(autoModelSentinel || 'auto');
|
|
@@ -511,6 +526,56 @@ export function createModelVariantCatalogService({
|
|
|
511
526
|
return modelSelectorSql.listEnabledVariants.all().map((row) => normalizeModelVariantRow(row)).sort(compareVariantRows);
|
|
512
527
|
}
|
|
513
528
|
|
|
529
|
+
/**
|
|
530
|
+
* The Copilot model map, built from the table the first time (every model
|
|
531
|
+
* there known, every disabled row remembered as off). Null without a store.
|
|
532
|
+
*/
|
|
533
|
+
function loadCopilotModelState(rows = null) {
|
|
534
|
+
if (!modelStateStore) return null;
|
|
535
|
+
try {
|
|
536
|
+
const stored = modelStateStore.readCopilot();
|
|
537
|
+
if (stored) return stored;
|
|
538
|
+
const migrated = migrateCopilotModelState(rows || listModelVariantRows());
|
|
539
|
+
modelStateStore.writeCopilot(migrated);
|
|
540
|
+
return migrated;
|
|
541
|
+
} catch {
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function saveCopilotModelState(state) {
|
|
547
|
+
try {
|
|
548
|
+
modelStateStore?.writeCopilot?.(state);
|
|
549
|
+
} catch {
|
|
550
|
+
/* the rows are written; the map catches up on the next change */
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** `{ autoEnableNew, unseen }` for Select Models and the menu dot. */
|
|
555
|
+
function getCopilotModelState() {
|
|
556
|
+
const state = loadCopilotModelState();
|
|
557
|
+
const bases = new Set(listModelVariantRows().map((row) => row.baseModelId));
|
|
558
|
+
return {
|
|
559
|
+
autoEnableNew: state ? state.autoEnableNew : false,
|
|
560
|
+
unseen: state ? state.unseen.filter((baseModelId) => bases.has(baseModelId)) : [],
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function setCopilotAutoEnableNew(value) {
|
|
565
|
+
const state = loadCopilotModelState();
|
|
566
|
+
if (!state || typeof value !== 'boolean') return getCopilotModelState();
|
|
567
|
+
saveCopilotModelState({ ...state, autoEnableNew: value });
|
|
568
|
+
return getCopilotModelState();
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Clears the "New" flags; true when one was set. */
|
|
572
|
+
function markCopilotModelsSeen() {
|
|
573
|
+
const state = loadCopilotModelState();
|
|
574
|
+
if (!state || !state.unseen.length) return false;
|
|
575
|
+
saveCopilotModelState({ ...state, unseen: [] });
|
|
576
|
+
return true;
|
|
577
|
+
}
|
|
578
|
+
|
|
514
579
|
/** Stored metadata per base model (first row wins; all rows carry the same). */
|
|
515
580
|
function storedMetadataByModel(rows = listModelVariantRows()) {
|
|
516
581
|
const byModel = {};
|
|
@@ -772,12 +837,21 @@ export function createModelVariantCatalogService({
|
|
|
772
837
|
const existingEnabled = new Map(existingRows.map((row) => [row.variantId, row.enabled ? 1 : 0]));
|
|
773
838
|
const incomingIds = new Set(normalizedEntries.map((entry) => entry.variantId));
|
|
774
839
|
const incomingBaseIds = new Set(normalizedEntries.map((entry) => entry.baseModelId));
|
|
840
|
+
const enabledBaseIds = new Set(existingRows.filter((row) => row.enabled).map((row) => row.baseModelId));
|
|
841
|
+
const modelState = loadCopilotModelState(existingRows);
|
|
842
|
+
const newBaseIds = new Set();
|
|
843
|
+
const prunedDisabledIds = new Set();
|
|
775
844
|
const nowIso = new Date().toISOString();
|
|
776
845
|
const tx = db.transaction(() => {
|
|
777
846
|
for (const entry of normalizedEntries) {
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
847
|
+
let enabled = entry.enabled ? 1 : 0;
|
|
848
|
+
if (preserveEnabled && existingEnabled.has(entry.variantId)) {
|
|
849
|
+
enabled = existingEnabled.get(entry.variantId);
|
|
850
|
+
} else if (modelState && !existingEnabled.has(entry.variantId)) {
|
|
851
|
+
const decision = decideNewCopilotVariant(modelState, entry, { siblingEnabled: enabledBaseIds.has(entry.baseModelId) });
|
|
852
|
+
enabled = decision.enabled ? 1 : 0;
|
|
853
|
+
if (decision.newModel) newBaseIds.add(entry.baseModelId);
|
|
854
|
+
}
|
|
781
855
|
runVariantUpsert(entry, enabled, nowIso);
|
|
782
856
|
}
|
|
783
857
|
const orphanedEnabledBases = new Set();
|
|
@@ -788,6 +862,7 @@ export function createModelVariantCatalogService({
|
|
|
788
862
|
// The runtime told us this model's exact levels; a row for a
|
|
789
863
|
// level it does not offer is stale, not "unavailable".
|
|
790
864
|
if (row.enabled) orphanedEnabledBases.add(row.baseModelId);
|
|
865
|
+
else prunedDisabledIds.add(row.variantId);
|
|
791
866
|
modelSelectorSql.deleteVariant.run(row.variantId);
|
|
792
867
|
} else {
|
|
793
868
|
runVariantUpsert({ ...row, releaseStatus: 'unavailable' }, row.enabled, nowIso);
|
|
@@ -801,6 +876,8 @@ export function createModelVariantCatalogService({
|
|
|
801
876
|
}
|
|
802
877
|
// Variants that are both disabled and fully absent from the incoming
|
|
803
878
|
// base set are no longer relevant; prune them so they do not linger.
|
|
879
|
+
// The model map remembers them as off for when they come back.
|
|
880
|
+
prunedDisabledIds.add(row.variantId);
|
|
804
881
|
modelSelectorSql.deleteVariant.run(row.variantId);
|
|
805
882
|
}
|
|
806
883
|
// A selection must not silently vanish because its effort level did:
|
|
@@ -822,6 +899,21 @@ export function createModelVariantCatalogService({
|
|
|
822
899
|
}
|
|
823
900
|
});
|
|
824
901
|
tx();
|
|
902
|
+
if (modelState) {
|
|
903
|
+
saveCopilotModelState({
|
|
904
|
+
...modelState,
|
|
905
|
+
knownModels: [...new Set([...modelState.knownModels, ...incomingBaseIds])],
|
|
906
|
+
disabledVariants: [...new Set([...modelState.disabledVariants, ...prunedDisabledIds])],
|
|
907
|
+
unseen: [...new Set([...modelState.unseen, ...newBaseIds])],
|
|
908
|
+
});
|
|
909
|
+
if (newBaseIds.size) {
|
|
910
|
+
try {
|
|
911
|
+
onNewModels([...newBaseIds], modelState.autoEnableNew);
|
|
912
|
+
} catch {
|
|
913
|
+
/* the toast is best-effort */
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
825
917
|
recomputeSortOrder();
|
|
826
918
|
return getModelVariantSelectorState();
|
|
827
919
|
}
|
|
@@ -839,23 +931,49 @@ export function createModelVariantCatalogService({
|
|
|
839
931
|
return reconcileModelVariantRows(entries, { source, error, preserveEnabled, absentBasePolicy: 'reconcile', knownEffortBases });
|
|
840
932
|
}
|
|
841
933
|
|
|
842
|
-
|
|
934
|
+
/**
|
|
935
|
+
* The Select Models save. With `shownVariantIds` (the rows the browser
|
|
936
|
+
* drew) only those rows are set, on when listed in `variantIds`; every
|
|
937
|
+
* other row keeps its state, so a row a refresh added after the modal was
|
|
938
|
+
* drawn is not turned off. Without it (an older page) the list is the whole
|
|
939
|
+
* enabled set, as before.
|
|
940
|
+
*/
|
|
941
|
+
function setEnabledModelVariants(variantIds = [], { shownVariantIds = null } = {}) {
|
|
843
942
|
if (!modelSelectorSql?.disableAllVariants?.run || !modelSelectorSql?.enableVariant?.run) {
|
|
844
943
|
return getModelVariantSelectorState();
|
|
845
944
|
}
|
|
846
945
|
const nextEnabled = new Set(
|
|
847
946
|
Array.isArray(variantIds) ? variantIds.map((value) => String(value || '').trim()).filter(Boolean) : [],
|
|
848
947
|
);
|
|
948
|
+
// Turning every row off would leave the pickers with the fallback model
|
|
949
|
+
// only; the route refuses it, and so does this.
|
|
950
|
+
if (!nextEnabled.size) return getModelVariantSelectorState();
|
|
849
951
|
const existingRows = listModelVariantRows();
|
|
952
|
+
const shown = Array.isArray(shownVariantIds)
|
|
953
|
+
? new Set(shownVariantIds.map((value) => String(value || '').trim()).filter(Boolean))
|
|
954
|
+
: null;
|
|
955
|
+
const touchedRows = shown ? existingRows.filter((row) => shown.has(row.variantId)) : existingRows;
|
|
956
|
+
const modelState = loadCopilotModelState(existingRows);
|
|
850
957
|
const nowIso = new Date().toISOString();
|
|
851
958
|
const tx = db.transaction(() => {
|
|
852
|
-
modelSelectorSql.disableAllVariants.run(nowIso);
|
|
853
|
-
for (const row of
|
|
854
|
-
if (
|
|
855
|
-
modelSelectorSql.
|
|
959
|
+
if (!shown) modelSelectorSql.disableAllVariants.run(nowIso);
|
|
960
|
+
for (const row of touchedRows) {
|
|
961
|
+
if (nextEnabled.has(row.variantId)) modelSelectorSql.enableVariant.run(nowIso, row.variantId);
|
|
962
|
+
else if (shown) modelSelectorSql.disableVariant.run(nowIso, row.variantId);
|
|
856
963
|
}
|
|
857
964
|
});
|
|
858
965
|
tx();
|
|
966
|
+
if (modelState) {
|
|
967
|
+
// A row turned on is forgotten as off; a row turned off is remembered.
|
|
968
|
+
const touchedIds = new Set(touchedRows.map((row) => row.variantId));
|
|
969
|
+
saveCopilotModelState({
|
|
970
|
+
...modelState,
|
|
971
|
+
disabledVariants: [...new Set([
|
|
972
|
+
...modelState.disabledVariants.filter((variantId) => !(touchedIds.has(variantId) && nextEnabled.has(variantId))),
|
|
973
|
+
...touchedRows.filter((row) => !nextEnabled.has(row.variantId)).map((row) => row.variantId),
|
|
974
|
+
])],
|
|
975
|
+
});
|
|
976
|
+
}
|
|
859
977
|
return getModelVariantSelectorState();
|
|
860
978
|
}
|
|
861
979
|
|
|
@@ -897,6 +1015,13 @@ export function createModelVariantCatalogService({
|
|
|
897
1015
|
refreshedAt: receivedMetadata ? new Date().toISOString() : modelCatalog.refreshedAt,
|
|
898
1016
|
error: snapshot.error ? String(snapshot.error).trim().slice(0, 300) : null,
|
|
899
1017
|
};
|
|
1018
|
+
if (authoritative) {
|
|
1019
|
+
try {
|
|
1020
|
+
onAuthoritativeSnapshot({ source });
|
|
1021
|
+
} catch {
|
|
1022
|
+
/* the refresh clock is best-effort */
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
900
1025
|
if (modelSelectorSql?.upsertVariant?.run) {
|
|
901
1026
|
const existingRows = listModelVariantRows();
|
|
902
1027
|
const stored = storedMetadataByModel(existingRows);
|
|
@@ -1074,6 +1199,7 @@ export function createModelVariantCatalogService({
|
|
|
1074
1199
|
updateSortOrderForBase: db.prepare(`UPDATE model_variants SET sort_order = ? WHERE base_model_id = ?`),
|
|
1075
1200
|
disableAllVariants: db.prepare(`UPDATE model_variants SET enabled = 0, updated_at = ?`),
|
|
1076
1201
|
enableVariant: db.prepare(`UPDATE model_variants SET enabled = 1, updated_at = ? WHERE variant_id = ?`),
|
|
1202
|
+
disableVariant: db.prepare(`UPDATE model_variants SET enabled = 0, updated_at = ? WHERE variant_id = ?`),
|
|
1077
1203
|
deleteVariant: db.prepare(`DELETE FROM model_variants WHERE variant_id = ?`),
|
|
1078
1204
|
getSelectorState: db.prepare(`SELECT source, refreshed_at, error, updated_at FROM model_selector_state WHERE id = 1 LIMIT 1`),
|
|
1079
1205
|
upsertSelectorState: db.prepare(`
|
|
@@ -1153,6 +1279,9 @@ export function createModelVariantCatalogService({
|
|
|
1153
1279
|
}
|
|
1154
1280
|
normalizeLegacyVariantIdsTx();
|
|
1155
1281
|
recomputeSortOrder();
|
|
1282
|
+
// The model map of an install from before it is built from the table as
|
|
1283
|
+
// it stands now, before any snapshot adds a row.
|
|
1284
|
+
loadCopilotModelState();
|
|
1156
1285
|
}
|
|
1157
1286
|
|
|
1158
1287
|
return {
|
|
@@ -1175,6 +1304,9 @@ export function createModelVariantCatalogService({
|
|
|
1175
1304
|
getModelVariantSelectorState,
|
|
1176
1305
|
upsertModelVariantCatalogEntries,
|
|
1177
1306
|
setEnabledModelVariants,
|
|
1307
|
+
getCopilotModelState,
|
|
1308
|
+
setCopilotAutoEnableNew,
|
|
1309
|
+
markCopilotModelsSeen,
|
|
1178
1310
|
refreshModelVariantCatalogFromCli,
|
|
1179
1311
|
parseModelsFromHelpConfigOutput,
|
|
1180
1312
|
parseReasoningEffortsFromHelpOutput,
|