@oar-sh/oar 0.9.23 → 0.9.24
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/CHANGELOG.md +165 -0
- package/README.md +9 -9
- package/bin/oar.js +54 -4
- package/package.json +9 -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 +5 -2
- package/server/claude-worker/claude-sdk-adapter.mjs +4 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +7 -4
- package/server/cursor-worker/cursor-sdk-adapter.mjs +5 -3
- package/server/public/app/agent-sdk-ui.js +349 -0
- package/server/public/app/api-client.js +91 -8
- package/server/public/app/bootstrap.js +204 -69
- package/server/public/app/model-refresh-ui.mjs +413 -0
- package/server/public/app/no-conversation-state.mjs +5 -2
- package/server/public/app/provider-readiness.mjs +67 -4
- package/server/public/app/settings-modal.js +167 -0
- package/server/public/app/socket-handlers.js +21 -0
- package/server/public/index.html +95 -2
- package/server/routes/agent-sdk-routes.mjs +73 -0
- package/server/routes/messages-routes.mjs +91 -17
- package/server/routes/sessions-routes.mjs +235 -109
- package/server/server-runtime.mjs +395 -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 +17 -1
- package/server/services/deepseek-settings-service.mjs +58 -20
- package/server/services/earlier-database-service.mjs +4 -2
- 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/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/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/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/model-refresh-schedule.mjs +120 -0
- package/shared/worker-runtime/direct-openai-image.mjs +8 -4
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
// New models and the background model check, the browser side: the Select
|
|
2
|
+
// Models switch row, "New" tags and fallback line, the toast a refresh that
|
|
3
|
+
// found new models shows, and the texts of Settings › General › Models.
|
|
4
|
+
// DOM-free so it is unit-testable; the relay side is
|
|
5
|
+
// server/services/provider-model-state.mjs and model-refresh-scheduler.mjs.
|
|
6
|
+
|
|
7
|
+
// Mirrors shared/model-refresh-schedule.mjs, which the browser cannot import —
|
|
8
|
+
// only server/public is served. The relay's answer carries the real choices.
|
|
9
|
+
export const DEFAULT_MODEL_REFRESH_INTERVAL_HOURS = 24;
|
|
10
|
+
export const MODEL_REFRESH_INTERVAL_CHOICES = Object.freeze([6, 12, 24, 72, 168]);
|
|
11
|
+
|
|
12
|
+
// The Select Models tab of each provider block in GET /api/model-variants.
|
|
13
|
+
// Copilot's state rides in `copilotModelState` instead.
|
|
14
|
+
const MODEL_TAB_BLOCKS = Object.freeze({
|
|
15
|
+
openai: 'openaiModels',
|
|
16
|
+
anthropic: 'claudeModels',
|
|
17
|
+
'claude-api': 'claudeApiModels',
|
|
18
|
+
cursor: 'cursorModels',
|
|
19
|
+
grok: 'grokModels',
|
|
20
|
+
deepseek: 'deepseekModels',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const MODEL_TAB_LABELS = Object.freeze({
|
|
24
|
+
copilot: 'Copilot',
|
|
25
|
+
openai: 'OpenAI',
|
|
26
|
+
anthropic: 'Claude',
|
|
27
|
+
'claude-api': 'Claude API',
|
|
28
|
+
cursor: 'Cursor',
|
|
29
|
+
grok: 'Grok',
|
|
30
|
+
deepseek: 'DeepSeek',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function escapeHtml(value) {
|
|
34
|
+
return String(value ?? '')
|
|
35
|
+
.replace(/&/g, '&')
|
|
36
|
+
.replace(/</g, '<')
|
|
37
|
+
.replace(/>/g, '>')
|
|
38
|
+
.replace(/"/g, '"');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function idList(value) {
|
|
42
|
+
return Array.isArray(value)
|
|
43
|
+
? value.map((entry) => String(entry || '').trim()).filter(Boolean)
|
|
44
|
+
: [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The new-model fields of one provider block (`autoEnableNew`, `unseen`,
|
|
49
|
+
* `fallback`). A relay from before them sends none: `autoEnableNew` stays
|
|
50
|
+
* null and the tab then shows no switch row.
|
|
51
|
+
*/
|
|
52
|
+
export function modelRefreshBlockFields(block) {
|
|
53
|
+
return {
|
|
54
|
+
autoEnableNew: typeof block?.autoEnableNew === 'boolean' ? block.autoEnableNew : null,
|
|
55
|
+
unseen: idList(block?.unseen),
|
|
56
|
+
fallback: block?.fallback === true,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `copilotModelState` of GET /api/model-variants; null from an older relay. */
|
|
61
|
+
export function normalizeCopilotModelState(value) {
|
|
62
|
+
if (!value || typeof value !== 'object') return null;
|
|
63
|
+
return {
|
|
64
|
+
autoEnableNew: typeof value.autoEnableNew === 'boolean' ? value.autoEnableNew : null,
|
|
65
|
+
unseen: idList(value.unseen),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function tabState(catalogState, tab) {
|
|
70
|
+
const key = String(tab || '').trim().toLowerCase();
|
|
71
|
+
if (key === 'copilot') return catalogState?.copilotModelState || null;
|
|
72
|
+
const blockKey = MODEL_TAB_BLOCKS[key];
|
|
73
|
+
return blockKey ? (catalogState?.[blockKey] || null) : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function modelTabLabel(tab) {
|
|
77
|
+
return MODEL_TAB_LABELS[String(tab || '').trim().toLowerCase()] || String(tab || '');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The tab's "Turn new models on automatically", or null when it has none. */
|
|
81
|
+
export function modelTabAutoEnableNew(catalogState, tab) {
|
|
82
|
+
const value = tabState(catalogState, tab)?.autoEnableNew;
|
|
83
|
+
return typeof value === 'boolean' ? value : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The default model the tab fell back to, or '' while it has not. */
|
|
87
|
+
export function modelTabFallbackModel(catalogState, tab) {
|
|
88
|
+
const state = tabState(catalogState, tab);
|
|
89
|
+
if (state?.fallback !== true) return '';
|
|
90
|
+
return String(state.defaultModel || '').trim();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The unseen ids of every tab, lowercased, added to `held`: the ids a modal
|
|
95
|
+
* already tagged stay tagged while it is open, although opening it marked
|
|
96
|
+
* them seen (POST /api/model-variants/seen re-renders it without them).
|
|
97
|
+
*/
|
|
98
|
+
export function mergeUnseenByTab(held, catalogState) {
|
|
99
|
+
const next = new Map();
|
|
100
|
+
for (const [tab, ids] of held instanceof Map ? held : []) next.set(tab, new Set(ids));
|
|
101
|
+
for (const tab of Object.keys(MODEL_TAB_LABELS)) {
|
|
102
|
+
const ids = idList(tabState(catalogState, tab)?.unseen).map((id) => id.toLowerCase());
|
|
103
|
+
if (!ids.length) continue;
|
|
104
|
+
if (!next.has(tab)) next.set(tab, new Set());
|
|
105
|
+
for (const id of ids) next.get(tab).add(id);
|
|
106
|
+
}
|
|
107
|
+
return next;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Whether a Select Models row gets the "New" tag: its model is unseen. A
|
|
112
|
+
* Copilot row is matched by its base model (every reasoning row of a new
|
|
113
|
+
* model is new), any other by its model id, which is its base id too.
|
|
114
|
+
*/
|
|
115
|
+
export function isUnseenModelRow(row, unseenIds) {
|
|
116
|
+
const id = String(row?.baseModelId || '').trim().toLowerCase();
|
|
117
|
+
return !!id && !!unseenIds?.has?.(id);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function renderNewModelChipHtml() {
|
|
121
|
+
return ' <span class="model-reasoning-chip model-reasoning-chip-new">New</span>';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The switch row at the top of a provider tab; Save sends its state. */
|
|
125
|
+
export function renderModelAutoEnableRowHtml(tab, checked) {
|
|
126
|
+
const key = String(tab || '').trim().toLowerCase();
|
|
127
|
+
const label = modelTabLabel(key);
|
|
128
|
+
return `
|
|
129
|
+
<label class="model-auto-enable-row">
|
|
130
|
+
<input class="model-auto-enable-toggle" type="checkbox" data-auto-enable-tab="${escapeHtml(key)}" ${checked ? 'checked' : ''}>
|
|
131
|
+
<span class="model-auto-enable-copy">
|
|
132
|
+
<span class="model-auto-enable-title">Turn new models on automatically</span>
|
|
133
|
+
<span class="model-auto-enable-help">While this is on, a ${escapeHtml(label)} model found from now on starts on. A model you turned off stays off either way.</span>
|
|
134
|
+
</span>
|
|
135
|
+
</label>
|
|
136
|
+
`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The model ids a Select Models save sends for one provider, read from its
|
|
141
|
+
* rendered checkboxes: `enabled` the ticked ones, `shown` all of them (the
|
|
142
|
+
* locked default model too). The relay turns only the shown ids on or off,
|
|
143
|
+
* so a model a refresh added after this render, already in the catalog state
|
|
144
|
+
* but not on screen, keeps its state. Reads `attribute` of each input, or its
|
|
145
|
+
* `value` without one.
|
|
146
|
+
*/
|
|
147
|
+
export function modelCheckboxSelection(inputs, attribute = '') {
|
|
148
|
+
const enabled = [];
|
|
149
|
+
const shown = [];
|
|
150
|
+
for (const input of Array.isArray(inputs) ? inputs : Array.from(inputs || [])) {
|
|
151
|
+
const raw = attribute && typeof input?.getAttribute === 'function'
|
|
152
|
+
? input.getAttribute(attribute)
|
|
153
|
+
: input?.value;
|
|
154
|
+
const id = String(raw || '').trim();
|
|
155
|
+
if (!id || shown.includes(id)) continue;
|
|
156
|
+
shown.push(id);
|
|
157
|
+
if (input.checked === true) enabled.push(id);
|
|
158
|
+
}
|
|
159
|
+
return { enabled, shown };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// The provider blocks whose rows Select Models renders, besides Copilot's.
|
|
163
|
+
const SELECTION_BLOCKS = Object.freeze(['claudeModels', 'cursorModels', 'grokModels', 'deepseekModels', 'claudeApiModels', 'openaiModels']);
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* What an open Select Models shows of the catalog state: every provider
|
|
167
|
+
* block's models, which are on, its switch, unseen ids and fallback, and the
|
|
168
|
+
* Copilot rows with which are on. A `models_updated` whose catalog reads the
|
|
169
|
+
* same leaves the open modal (and the user's unsaved ticks) alone.
|
|
170
|
+
*/
|
|
171
|
+
export function modelSelectionSignature(catalogState) {
|
|
172
|
+
const enabledVariants = new Set(idList(catalogState?.enabledVariantIds));
|
|
173
|
+
const copilotState = catalogState?.copilotModelState || null;
|
|
174
|
+
return JSON.stringify({
|
|
175
|
+
variants: (Array.isArray(catalogState?.variants) ? catalogState.variants : [])
|
|
176
|
+
.map((entry) => [String(entry?.variantId || ''), enabledVariants.has(String(entry?.variantId || ''))]),
|
|
177
|
+
copilot: copilotState
|
|
178
|
+
? { autoEnableNew: copilotState.autoEnableNew ?? null, unseen: idList(copilotState.unseen) }
|
|
179
|
+
: null,
|
|
180
|
+
...Object.fromEntries(SELECTION_BLOCKS.map((key) => {
|
|
181
|
+
const block = catalogState?.[key];
|
|
182
|
+
return [key, block
|
|
183
|
+
? {
|
|
184
|
+
defaultModel: String(block.defaultModel || ''),
|
|
185
|
+
availableModels: idList(block.availableModels),
|
|
186
|
+
enabledModels: idList(block.enabledModels),
|
|
187
|
+
autoEnableNew: typeof block.autoEnableNew === 'boolean' ? block.autoEnableNew : null,
|
|
188
|
+
unseen: idList(block.unseen),
|
|
189
|
+
fallback: block.fallback === true,
|
|
190
|
+
}
|
|
191
|
+
: null];
|
|
192
|
+
})),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function unsavedTickKey(input) {
|
|
197
|
+
const read = (name) => String((typeof input?.getAttribute === 'function' ? input.getAttribute(name) : '') || '').trim();
|
|
198
|
+
const variantId = read('data-variant-id');
|
|
199
|
+
if (variantId) return `variant:${variantId}`;
|
|
200
|
+
const tab = read('data-auto-enable-tab');
|
|
201
|
+
return tab ? `auto:${tab}` : '';
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The ticks of an open Select Models not saved yet: each checkbox (a model
|
|
206
|
+
* row or the switch) whose `checked` differs from what it was rendered with.
|
|
207
|
+
*/
|
|
208
|
+
export function collectUnsavedModelTicks(inputs) {
|
|
209
|
+
const ticks = new Map();
|
|
210
|
+
for (const input of Array.isArray(inputs) ? inputs : Array.from(inputs || [])) {
|
|
211
|
+
if (!input || input.disabled || input.checked === input.defaultChecked) continue;
|
|
212
|
+
const key = unsavedTickKey(input);
|
|
213
|
+
if (key) ticks.set(key, input.checked === true);
|
|
214
|
+
}
|
|
215
|
+
return ticks;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Puts the unsaved ticks back on the re-rendered checkboxes of the same row
|
|
220
|
+
* or switch; a row that is gone, or now locked, takes none. Answers how many
|
|
221
|
+
* it set.
|
|
222
|
+
*/
|
|
223
|
+
export function applyUnsavedModelTicks(inputs, ticks) {
|
|
224
|
+
if (!(ticks instanceof Map) || !ticks.size) return 0;
|
|
225
|
+
let applied = 0;
|
|
226
|
+
for (const input of Array.isArray(inputs) ? inputs : Array.from(inputs || [])) {
|
|
227
|
+
if (!input || input.disabled) continue;
|
|
228
|
+
const key = unsavedTickKey(input);
|
|
229
|
+
if (!key || !ticks.has(key)) continue;
|
|
230
|
+
input.checked = ticks.get(key);
|
|
231
|
+
applied += 1;
|
|
232
|
+
}
|
|
233
|
+
return applied;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The OpenAI tab's rows from `openaiModels`, one per offered model, like
|
|
238
|
+
* Cursor's: the default model is always on and cannot be unticked.
|
|
239
|
+
*/
|
|
240
|
+
export function buildOpenAIModelRows(openaiCatalog, labelFor = (id) => id) {
|
|
241
|
+
const enabledSet = new Set(idList(openaiCatalog?.enabledModels));
|
|
242
|
+
const defaultModel = String(openaiCatalog?.defaultModel || '').trim();
|
|
243
|
+
return idList(openaiCatalog?.availableModels).map((openaiModelId, index) => {
|
|
244
|
+
const isDefaultModel = openaiModelId === defaultModel;
|
|
245
|
+
return {
|
|
246
|
+
variantId: `${openaiModelId}--provider-openai-byok`,
|
|
247
|
+
baseModelId: openaiModelId,
|
|
248
|
+
provider: 'openai-byok',
|
|
249
|
+
label: labelFor(openaiModelId),
|
|
250
|
+
releaseStatus: null,
|
|
251
|
+
reasoningEffort: null,
|
|
252
|
+
// The default model always stays enabled, as for the other providers.
|
|
253
|
+
selectable: !isDefaultModel,
|
|
254
|
+
enabled: enabledSet.has(openaiModelId) || isDefaultModel,
|
|
255
|
+
openaiModelId,
|
|
256
|
+
sortOrder: index,
|
|
257
|
+
};
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// The row field and checkbox attribute of each provider with its own
|
|
262
|
+
// selection; a Copilot row has none and reads the enabled variant ids.
|
|
263
|
+
const SDK_MODEL_ROW_FIELDS = Object.freeze([
|
|
264
|
+
['claudeModelId', 'data-claude-model'],
|
|
265
|
+
['cursorModelId', 'data-cursor-model'],
|
|
266
|
+
['grokModelId', 'data-grok-model'],
|
|
267
|
+
['deepseekModelId', 'data-deepseek-model'],
|
|
268
|
+
['claudeApiModelId', 'data-claude-api-model'],
|
|
269
|
+
['openaiModelId', 'data-openai-model'],
|
|
270
|
+
]);
|
|
271
|
+
|
|
272
|
+
/** The provider model id of a Select Models row, '' for a Copilot row. */
|
|
273
|
+
export function modelRowSdkModelId(row) {
|
|
274
|
+
for (const [field] of SDK_MODEL_ROW_FIELDS) {
|
|
275
|
+
if (row?.[field]) return String(row[field]);
|
|
276
|
+
}
|
|
277
|
+
return '';
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The checkbox of a Select Models row. A provider row carries its model id
|
|
282
|
+
* (`data-cursor-model`, …) and its own on flag; a Copilot row is on when its
|
|
283
|
+
* variant is in `selectedVariantIds`. A row that is not selectable (the
|
|
284
|
+
* default model) is disabled.
|
|
285
|
+
*/
|
|
286
|
+
export function renderModelVariantCheckboxHtml(row, selectedVariantIds) {
|
|
287
|
+
const selectable = row?.selectable !== false;
|
|
288
|
+
const sdkField = SDK_MODEL_ROW_FIELDS.find(([field]) => row?.[field]);
|
|
289
|
+
const checked = sdkField
|
|
290
|
+
? row.enabled === true
|
|
291
|
+
: (selectable && !!selectedVariantIds?.has?.(row?.variantId));
|
|
292
|
+
const sdkModelAttr = sdkField ? ` ${sdkField[1]}="${escapeHtml(row[sdkField[0]])}"` : '';
|
|
293
|
+
return `<input class="model-variant-checkbox" type="checkbox" data-selectable="${selectable ? '1' : '0'}" data-variant-id="${escapeHtml(row?.variantId)}"${sdkModelAttr} ${checked ? 'checked' : ''} ${selectable ? '' : 'disabled'}>`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** The tab's warning while every model the user had on has vanished. */
|
|
297
|
+
export function renderModelFallbackWarningHtml(defaultModel) {
|
|
298
|
+
const model = String(defaultModel || '').trim();
|
|
299
|
+
if (!model) return '';
|
|
300
|
+
return `<div class="model-fallback-warning" style="font-size:0.78rem;color:var(--warn,#f7c873)">⚠️ None of the models you turned on are offered any more; ${escapeHtml(model)} is used until you pick others.</div>`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// The tab of a provider id whose tab is named differently; the others share it.
|
|
304
|
+
const PROVIDER_TABS = Object.freeze({ github: 'copilot', claude: 'anthropic' });
|
|
305
|
+
|
|
306
|
+
function newModelsEntries(newModels) {
|
|
307
|
+
return (Array.isArray(newModels) ? newModels : [])
|
|
308
|
+
.map((entry) => {
|
|
309
|
+
const provider = String(entry?.provider || '').trim().toLowerCase();
|
|
310
|
+
const tab = String(entry?.tab || '').trim().toLowerCase() || PROVIDER_TABS[provider] || provider;
|
|
311
|
+
return {
|
|
312
|
+
tab,
|
|
313
|
+
label: String(entry?.label || '').trim() || modelTabLabel(tab),
|
|
314
|
+
count: idList(entry?.ids).length,
|
|
315
|
+
on: entry?.on === true,
|
|
316
|
+
};
|
|
317
|
+
})
|
|
318
|
+
.filter((entry) => entry.count > 0 && entry.label);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The toast of a `models_updated` that found models never seen before:
|
|
323
|
+
* "2 new Cursor models (on)", several providers joined in one line. '' when
|
|
324
|
+
* the event found none.
|
|
325
|
+
*/
|
|
326
|
+
export function buildNewModelsToastText(newModels) {
|
|
327
|
+
return newModelsEntries(newModels)
|
|
328
|
+
.map(({ count, label, on }) => `${count} new ${label} ${count === 1 ? 'model' : 'models'} (${on ? 'on' : 'off'})`)
|
|
329
|
+
.join(', ');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** The Select Models tab the toast's Show opens: the first provider listed. */
|
|
333
|
+
export function newModelsToastTab(newModels) {
|
|
334
|
+
return newModelsEntries(newModels)[0]?.tab || 'copilot';
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The toast one `models_updated` event shows: `{ text, tab }` when it found
|
|
339
|
+
* new models, null otherwise and on a shared view (a viewer has no Select
|
|
340
|
+
* Models).
|
|
341
|
+
*/
|
|
342
|
+
export function newModelsToastFor(payload, { sharedView = false } = {}) {
|
|
343
|
+
if (sharedView) return null;
|
|
344
|
+
const text = buildNewModelsToastText(payload?.newModels);
|
|
345
|
+
return text ? { text, tab: newModelsToastTab(payload.newModels) } : null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** "Every 6 h" … "Every 7 days", for the interval select. */
|
|
349
|
+
export function formatModelRefreshIntervalLabel(hours) {
|
|
350
|
+
const value = Math.max(1, Math.round(Number(hours) || DEFAULT_MODEL_REFRESH_INTERVAL_HOURS));
|
|
351
|
+
if (value >= 48 && value % 24 === 0) return `Every ${value / 24} days`;
|
|
352
|
+
return `Every ${value} h`;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// The same steps as a share's age (share-format.mjs).
|
|
356
|
+
function age(iso, now) {
|
|
357
|
+
const at = Date.parse(String(iso || ''));
|
|
358
|
+
if (!Number.isFinite(at) || at <= 0) return '';
|
|
359
|
+
const minutes = Math.floor(Math.max(0, now - at) / 60_000);
|
|
360
|
+
if (minutes < 1) return 'just now';
|
|
361
|
+
if (minutes < 60) return `${minutes} min ago`;
|
|
362
|
+
const hours = Math.floor(minutes / 60);
|
|
363
|
+
if (hours < 48) return `${hours} h ago`;
|
|
364
|
+
return `${Math.floor(hours / 24)} d ago`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const LAST_ERROR_MAX_CHARS = 120;
|
|
368
|
+
|
|
369
|
+
/** A provider's last check, beside its checkbox: when, or why it failed. */
|
|
370
|
+
export function formatModelRefreshLastChecked(last, now = Date.now()) {
|
|
371
|
+
const when = age(last?.at, now);
|
|
372
|
+
if (!when) return 'Not checked yet';
|
|
373
|
+
if (last?.ok === false) {
|
|
374
|
+
const raw = String(last?.error || '').replace(/\s+/g, ' ').trim();
|
|
375
|
+
const error = raw.length > LAST_ERROR_MAX_CHARS ? `${raw.slice(0, LAST_ERROR_MAX_CHARS - 1)}…` : raw;
|
|
376
|
+
return error ? `Last check failed ${when}: ${error}` : `Last check failed ${when}`;
|
|
377
|
+
}
|
|
378
|
+
return `Last checked ${when}`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* GET/POST /api/model-refresh-settings, read defensively: an answer without
|
|
383
|
+
* a field keeps the relay's default for it.
|
|
384
|
+
*/
|
|
385
|
+
export function normalizeModelRefreshSettings(payload) {
|
|
386
|
+
const choices = Array.isArray(payload?.intervalChoices)
|
|
387
|
+
? payload.intervalChoices.map((value) => Math.round(Number(value))).filter((value) => Number.isFinite(value) && value > 0)
|
|
388
|
+
: [];
|
|
389
|
+
const intervalHours = Math.round(Number(payload?.intervalHours));
|
|
390
|
+
return {
|
|
391
|
+
auto: typeof payload?.auto === 'boolean' ? payload.auto : true,
|
|
392
|
+
intervalHours: Number.isFinite(intervalHours) && intervalHours > 0 ? intervalHours : DEFAULT_MODEL_REFRESH_INTERVAL_HOURS,
|
|
393
|
+
intervalChoices: choices.length ? choices : [...MODEL_REFRESH_INTERVAL_CHOICES],
|
|
394
|
+
excluded: idList(payload?.excluded),
|
|
395
|
+
providers: (Array.isArray(payload?.providers) ? payload.providers : [])
|
|
396
|
+
.map((entry) => ({
|
|
397
|
+
id: String(entry?.id || '').trim(),
|
|
398
|
+
label: String(entry?.label || '').trim() || String(entry?.id || '').trim(),
|
|
399
|
+
last: entry?.last && typeof entry.last === 'object' ? entry.last : null,
|
|
400
|
+
// Listed while enabled; a relay from before the field lists only
|
|
401
|
+
// providers that are set up.
|
|
402
|
+
setUp: entry?.setUp !== false,
|
|
403
|
+
}))
|
|
404
|
+
.filter((entry) => entry.id),
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** The excluded list after one provider's checkbox changed. */
|
|
409
|
+
export function nextModelRefreshExcluded(excluded, providerId, checked) {
|
|
410
|
+
const id = String(providerId || '').trim();
|
|
411
|
+
const list = idList(excluded).filter((entry) => entry !== id);
|
|
412
|
+
return checked ? list : [...list, id];
|
|
413
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// conversations yet, and `pick` when conversations exist but none is open.
|
|
8
8
|
// A readiness answer that is unknown counts as ready, as everywhere else.
|
|
9
9
|
|
|
10
|
-
import { isSetupRequired } from './provider-readiness.mjs';
|
|
10
|
+
import { blockingReadinessReasons, isSetupRequired } from './provider-readiness.mjs';
|
|
11
11
|
|
|
12
12
|
export const SETUP_HEADING = 'Set up a provider';
|
|
13
13
|
export const SETUP_TEXT = 'Turn on Claude, Cursor, OpenAI, Grok or DeepSeek in Settings → Providers, or sign in to the Copilot CLI on the relay host.';
|
|
@@ -27,11 +27,14 @@ const ACTIONS = Object.freeze({
|
|
|
27
27
|
*/
|
|
28
28
|
export function resolveNoConversationState({ readiness = null, conversationCount = 0, mobile = false } = {}) {
|
|
29
29
|
if (isSetupRequired(readiness)) {
|
|
30
|
+
// A provider that is on but whose SDK failed says why: "turn on Claude"
|
|
31
|
+
// alone would read as wrong to someone who did.
|
|
32
|
+
const reasons = blockingReadinessReasons(readiness);
|
|
30
33
|
return {
|
|
31
34
|
variant: 'setup',
|
|
32
35
|
icon: '⚙️',
|
|
33
36
|
heading: SETUP_HEADING,
|
|
34
|
-
text: SETUP_TEXT,
|
|
37
|
+
text: reasons.length ? `${SETUP_TEXT} ${reasons.map((text) => `${text.replace(/[.\s]+$/, '')}.`).join(' ')}` : SETUP_TEXT,
|
|
35
38
|
actions: [ACTIONS.openSettings(true), ACTIONS.newChat(false)],
|
|
36
39
|
};
|
|
37
40
|
}
|
|
@@ -14,9 +14,47 @@
|
|
|
14
14
|
// A payload that failed to load is `null`, and makes the whole answer
|
|
15
15
|
// `known: false`. Callers treat an unknown answer as ready, which is today's
|
|
16
16
|
// behaviour: a network blip must never show the setup wall.
|
|
17
|
+
//
|
|
18
|
+
// The agent SDK snapshot (GET /api/agent-sdks/status) is optional. With it, a
|
|
19
|
+
// provider whose SDK failed to install or has no build for this host is not
|
|
20
|
+
// ready, with the reason in `reasons`. A missing or installing SDK does not
|
|
21
|
+
// block: the relay installs it on the first launch and keeps the turn queued,
|
|
22
|
+
// so the provider stays ready and only gets a note.
|
|
17
23
|
|
|
18
24
|
export const SEED_CATALOG_SOURCE = 'bootstrap-seed';
|
|
19
25
|
|
|
26
|
+
// The SDK each provider's workers run on (session-worker-launch-service.mjs).
|
|
27
|
+
const PROVIDER_AGENT_SDKS = Object.freeze({
|
|
28
|
+
claude: 'claude',
|
|
29
|
+
'claude-api': 'claude',
|
|
30
|
+
deepseek: 'claude',
|
|
31
|
+
cursor: 'cursor',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Why a provider's SDK keeps it from running, or what it waits for: `null`
|
|
36
|
+
* when the SDK is ready or its state is not one we know.
|
|
37
|
+
* @returns {{ blocking: boolean, text: string } | null}
|
|
38
|
+
*/
|
|
39
|
+
export function agentSdkReadinessReason(status) {
|
|
40
|
+
if (!status || typeof status !== 'object') return null;
|
|
41
|
+
const label = String(status.label || '').trim() || 'The agent SDK';
|
|
42
|
+
switch (status.state) {
|
|
43
|
+
case 'missing':
|
|
44
|
+
return { blocking: false, text: `${label} not installed yet — it installs with the first chat or from Settings` };
|
|
45
|
+
case 'installing':
|
|
46
|
+
return { blocking: false, text: `${label} is installing — the first chat waits for it` };
|
|
47
|
+
case 'error': {
|
|
48
|
+
const error = String(status.error || '').trim();
|
|
49
|
+
return { blocking: true, text: error ? `${label} install failed: ${error}` : `${label} install failed` };
|
|
50
|
+
}
|
|
51
|
+
case 'unsupported':
|
|
52
|
+
return { blocking: true, text: `${label} not available on this platform` };
|
|
53
|
+
default:
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
20
58
|
const SETTINGS_PROVIDERS = Object.freeze([
|
|
21
59
|
['openai', 'openai'],
|
|
22
60
|
['claude', 'claude'],
|
|
@@ -40,21 +78,43 @@ const REQUIRED_INPUTS = Object.freeze(['openai', 'claude', 'cursor', 'grok', 'ca
|
|
|
40
78
|
* @param {object|null} [input.grok]
|
|
41
79
|
* @param {object|null} [input.deepseek] null on a relay without DeepSeek: not offered, not unknown
|
|
42
80
|
* @param {object|null} [input.catalog]
|
|
43
|
-
* @
|
|
81
|
+
* @param {object|null} [input.agentSdks] GET /api/agent-sdks/status; absent or null changes nothing
|
|
82
|
+
* @returns {{ known: boolean, anyReady: boolean, ready: string[], copilot: 'ready' | 'unknown', reasons?: Record<string, { blocking: boolean, text: string }> }}
|
|
83
|
+
* `reasons` only with an agent SDK snapshot: by enabled provider whose SDK is not ready
|
|
44
84
|
*/
|
|
45
85
|
export function resolveProviderReadiness(input = {}) {
|
|
46
86
|
const payloads = input && typeof input === 'object' ? input : {};
|
|
47
87
|
const known = REQUIRED_INPUTS.every((key) => payloads[key] && typeof payloads[key] === 'object');
|
|
88
|
+
const sdks = payloads.agentSdks && typeof payloads.agentSdks === 'object' && payloads.agentSdks.sdks && typeof payloads.agentSdks.sdks === 'object'
|
|
89
|
+
? payloads.agentSdks.sdks
|
|
90
|
+
: null;
|
|
48
91
|
const ready = [];
|
|
92
|
+
const reasons = {};
|
|
49
93
|
for (const [key, providerId] of SETTINGS_PROVIDERS) {
|
|
50
|
-
if (payloads[key]?.enabled
|
|
94
|
+
if (payloads[key]?.enabled !== true) continue;
|
|
95
|
+
const reason = sdks && PROVIDER_AGENT_SDKS[providerId]
|
|
96
|
+
? agentSdkReadinessReason(sdks[PROVIDER_AGENT_SDKS[providerId]])
|
|
97
|
+
: null;
|
|
98
|
+
if (reason) reasons[providerId] = reason;
|
|
99
|
+
if (!reason?.blocking) ready.push(providerId);
|
|
51
100
|
}
|
|
52
101
|
const source = String(payloads.catalog?.source || '').trim();
|
|
53
102
|
const copilot = payloads.catalog && typeof payloads.catalog === 'object' && source && source !== SEED_CATALOG_SOURCE
|
|
54
103
|
? 'ready'
|
|
55
104
|
: 'unknown';
|
|
56
105
|
if (copilot === 'ready') ready.push('github');
|
|
57
|
-
|
|
106
|
+
const answer = { known, anyReady: ready.length > 0, ready, copilot };
|
|
107
|
+
if (sdks) answer.reasons = reasons;
|
|
108
|
+
return answer;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The blocking reasons of a readiness answer, each once (three providers share Claude's SDK). */
|
|
112
|
+
export function blockingReadinessReasons(readiness) {
|
|
113
|
+
const texts = Object.values(readiness?.reasons || {})
|
|
114
|
+
.filter((reason) => reason?.blocking === true)
|
|
115
|
+
.map((reason) => String(reason.text || '').trim())
|
|
116
|
+
.filter(Boolean);
|
|
117
|
+
return [...new Set(texts)];
|
|
58
118
|
}
|
|
59
119
|
|
|
60
120
|
/** The one question the empty screen, New Chat and the composer ask. */
|
|
@@ -76,12 +136,15 @@ const inputs = {
|
|
|
76
136
|
grok: undefined,
|
|
77
137
|
deepseek: undefined,
|
|
78
138
|
catalog: undefined,
|
|
139
|
+
agentSdks: undefined,
|
|
79
140
|
};
|
|
80
141
|
let current = resolveProviderReadiness(inputs);
|
|
81
142
|
const listeners = new Set();
|
|
82
143
|
|
|
83
144
|
function readinessKey(readiness) {
|
|
84
|
-
|
|
145
|
+
// Log flushes of an install leave the reasons as they were: no event.
|
|
146
|
+
const reasons = readiness.reasons ? JSON.stringify(readiness.reasons) : '';
|
|
147
|
+
return `${readiness.known ? 1 : 0}|${readiness.anyReady ? 1 : 0}|${readiness.ready.join(',')}|${readiness.copilot}|${reasons}`;
|
|
85
148
|
}
|
|
86
149
|
|
|
87
150
|
/**
|