@oar-sh/oar 0.9.16 → 0.9.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
  2. package/CHANGELOG.md +220 -0
  3. package/README.md +12 -11
  4. package/package.json +2 -1
  5. package/server/claude-worker/claude-attachments.mjs +7 -4
  6. package/server/claude-worker/claude-sdk-adapter.mjs +167 -9
  7. package/server/claude-worker/claude-session-process.mjs +127 -29
  8. package/server/claude-worker/claude-session-worker-main.mjs +266 -0
  9. package/server/claude-worker/claude-session-worker.mjs +2 -253
  10. package/server/claude-worker/claude-turn-failure.mjs +8 -0
  11. package/server/claude-worker/claude-turn-publisher.mjs +282 -13
  12. package/server/claude-worker/claude-usage-totals.mjs +108 -0
  13. package/server/claude-worker/sdk-message-normalizer.mjs +55 -0
  14. package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
  15. package/server/copilot-worker/copilot-sdk-session-process.mjs +71 -5
  16. package/server/db-schema.mjs +63 -0
  17. package/server/deepseek-worker/deepseek-claude-backend.mjs +433 -0
  18. package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
  19. package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
  20. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
  21. package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
  22. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
  23. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
  24. package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
  25. package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
  26. package/server/public/app/api-client.js +53 -0
  27. package/server/public/app/bootstrap.js +101 -13
  28. package/server/public/app/context-tier-options.mjs +4 -0
  29. package/server/public/app/conversation-provider-indicator.mjs +7 -0
  30. package/server/public/app/conversation-view.js +2 -0
  31. package/server/public/app/deepseek-settings-ui.js +342 -0
  32. package/server/public/app/inbox-ui.js +14 -3
  33. package/server/public/app/journal-view.js +31 -3
  34. package/server/public/app/model-selector-options.mjs +11 -0
  35. package/server/public/app/model-vision-support.mjs +2 -0
  36. package/server/public/app/no-conversation-state.mjs +1 -1
  37. package/server/public/app/plan-usage-view.mjs +165 -13
  38. package/server/public/app/provider-readiness.mjs +4 -1
  39. package/server/public/app/relay-error-ctas.mjs +8 -0
  40. package/server/public/app/settings-modal.js +11 -0
  41. package/server/public/app/settings-tabs.js +1 -1
  42. package/server/public/app/socket-handlers.js +10 -0
  43. package/server/public/index.html +408 -33
  44. package/server/repositories/conversation-group-repository.mjs +13 -6
  45. package/server/routes/conversation-group-routes.mjs +2 -1
  46. package/server/routes/deepseek-usage-import-routes.mjs +72 -0
  47. package/server/routes/messages-routes.mjs +192 -15
  48. package/server/routes/sessions-routes.mjs +364 -55
  49. package/server/server-runtime.mjs +123 -10
  50. package/server/services/about-service.mjs +1 -0
  51. package/server/services/claude-session-leftovers.mjs +226 -0
  52. package/server/services/deepseek-balance-service.mjs +102 -0
  53. package/server/services/deepseek-settings-service.mjs +380 -0
  54. package/server/services/deepseek-usage-import.mjs +471 -0
  55. package/server/services/model-variant-catalog-service.mjs +1 -0
  56. package/server/services/plan-usage-contract.mjs +100 -5
  57. package/server/services/plan-usage-deepseek.mjs +931 -0
  58. package/server/services/plan-usage-service.mjs +431 -0
  59. package/server/services/provider-model-selection.mjs +1 -0
  60. package/server/services/provider-reasoning-effort.mjs +15 -2
  61. package/server/services/remote-relay-dispatcher.mjs +2 -1
  62. package/server/services/session-worker-launch-service.mjs +233 -82
  63. package/server/services/worker-secret-redaction.mjs +318 -0
  64. package/shared/context-window-fallbacks.mjs +3 -0
  65. package/shared/deepseek-api.mjs +137 -0
  66. package/shared/deepseek-models.mjs +114 -0
  67. package/shared/deepseek-pricing.mjs +146 -0
  68. package/shared/host-claude-session-env.mjs +101 -0
  69. package/shared/model-id.mjs +7 -0
  70. package/shared/preview-tool-core.mjs +1 -1
  71. package/shared/provider-routing.mjs +2 -2
  72. package/shared/remote-relay-contract.mjs +1 -1
  73. package/shared/steer-settle-failure.mjs +1 -0
  74. package/shared/worker-runtime/direct-openai-image.mjs +128 -0
  75. package/shared/worker-runtime/worker-log-file.mjs +60 -0
@@ -0,0 +1,342 @@
1
+ // Settings → Providers → DeepSeek: the key (written, never read back), the
2
+ // switch, the default model, the commit attribution, the monthly budget for
3
+ // the usage card with its currency, the starting balance its balance bar
4
+ // measures from and, under Advanced, the base URL. The
5
+ // relay discovers the models when a key is saved. Below them, the import of
6
+ // DeepSeek's usage export (two CSV files) for the usage card.
7
+
8
+ import { importDeepSeekUsageExport as postDeepSeekUsageExport, loadDeepSeekSettings, updateDeepSeekSettings } from './api-client.js';
9
+ import { showTransientRelayNotice } from './store.js';
10
+
11
+ const DEFAULT_MODEL = 'deepseek-flash';
12
+ const DEFAULT_BASE_URL = 'https://api.deepseek.com';
13
+
14
+ let updateInFlight = false;
15
+ let inputsDirty = false;
16
+ // The starting balance field was typed in since the tab was filled.
17
+ let startingBalanceEdited = false;
18
+ let state = {
19
+ configured: false,
20
+ enabled: false,
21
+ model: DEFAULT_MODEL,
22
+ availableModels: [DEFAULT_MODEL, 'deepseek-v4-pro'],
23
+ baseUrl: DEFAULT_BASE_URL,
24
+ attribution: 'oar',
25
+ monthlyBudget: null,
26
+ // 'USD' or 'CNY'; null for a budget saved before the currency was stored
27
+ // (the card reads it in the balance's currency), or no budget.
28
+ monthlyBudgetCurrency: null,
29
+ startingBalance: null,
30
+ // What the relay says of the starting balance (for example that a later
31
+ // top-up has replaced it), or null.
32
+ startingBalanceNote: null,
33
+ };
34
+
35
+ const BUDGET_CURRENCIES = ['USD', 'CNY'];
36
+
37
+ const CONTROL_IDS = [
38
+ 'deepseek-api-key-input',
39
+ 'deepseek-model-select',
40
+ 'deepseek-attribution-select',
41
+ 'deepseek-monthly-budget-input',
42
+ 'deepseek-monthly-budget-currency-select',
43
+ 'deepseek-starting-balance-input',
44
+ 'deepseek-base-url-input',
45
+ 'deepseek-enabled-toggle',
46
+ 'deepseek-save-btn',
47
+ 'deepseek-remove-btn',
48
+ ];
49
+
50
+ function element(id) {
51
+ return document.getElementById(id);
52
+ }
53
+
54
+ function ensureInputTracking() {
55
+ for (const id of ['deepseek-api-key-input', 'deepseek-model-select', 'deepseek-attribution-select', 'deepseek-monthly-budget-input', 'deepseek-monthly-budget-currency-select', 'deepseek-starting-balance-input', 'deepseek-base-url-input']) {
56
+ const input = element(id);
57
+ if (!input || input.dataset.deepseekDirtyTracking === '1') continue;
58
+ input.dataset.deepseekDirtyTracking = '1';
59
+ input.addEventListener(input.tagName === 'SELECT' ? 'change' : 'input', () => {
60
+ inputsDirty = true;
61
+ if (id === 'deepseek-starting-balance-input') startingBalanceEdited = true;
62
+ });
63
+ }
64
+ }
65
+
66
+ function setControlsDisabled(disabled) {
67
+ for (const id of CONTROL_IDS) {
68
+ const control = element(id);
69
+ if (control) control.disabled = disabled;
70
+ }
71
+ }
72
+
73
+ function fillModelSelect(select) {
74
+ const models = Array.from(new Set([state.model, ...state.availableModels].filter(Boolean)));
75
+ select.innerHTML = '';
76
+ for (const model of models) {
77
+ const option = document.createElement('option');
78
+ option.value = model;
79
+ option.textContent = model;
80
+ select.appendChild(option);
81
+ }
82
+ select.value = state.model;
83
+ }
84
+
85
+ export function applyDeepSeekSettingsState(settings = {}, { resetInputs = false } = {}) {
86
+ ensureInputTracking();
87
+ const available = Array.isArray(settings?.availableModels)
88
+ ? settings.availableModels.map((value) => String(value || '').trim()).filter(Boolean)
89
+ : state.availableModels;
90
+ state = {
91
+ configured: settings?.configured === true,
92
+ enabled: settings?.configured === true && settings?.enabled === true,
93
+ model: String(settings?.model || state.model || DEFAULT_MODEL).trim() || DEFAULT_MODEL,
94
+ availableModels: available.length ? available : state.availableModels,
95
+ baseUrl: String(settings?.baseUrl || DEFAULT_BASE_URL).trim() || DEFAULT_BASE_URL,
96
+ attribution: settings?.attribution === 'off' ? 'off' : 'oar',
97
+ monthlyBudget: Number.isFinite(Number(settings?.monthlyBudget)) && Number(settings.monthlyBudget) > 0 ? Number(settings.monthlyBudget) : null,
98
+ monthlyBudgetCurrency: BUDGET_CURRENCIES.includes(String(settings?.monthlyBudgetCurrency || '').toUpperCase())
99
+ ? String(settings.monthlyBudgetCurrency).toUpperCase()
100
+ : null,
101
+ startingBalance: Number.isFinite(Number(settings?.startingBalance)) && Number(settings.startingBalance) > 0 ? Number(settings.startingBalance) : null,
102
+ startingBalanceNote: String(settings?.startingBalanceNote || '').trim() || null,
103
+ };
104
+ const keepInputs = inputsDirty && !resetInputs;
105
+ const keyInput = element('deepseek-api-key-input');
106
+ if (keyInput && !keepInputs) {
107
+ keyInput.value = '';
108
+ keyInput.placeholder = state.configured ? 'Saved API key (enter to replace)' : 'DeepSeek API key…';
109
+ }
110
+ const modelSelect = element('deepseek-model-select');
111
+ if (modelSelect && !keepInputs) fillModelSelect(modelSelect);
112
+ const attributionSelect = element('deepseek-attribution-select');
113
+ if (attributionSelect && !keepInputs) attributionSelect.value = state.attribution;
114
+ const budgetInput = element('deepseek-monthly-budget-input');
115
+ if (budgetInput && !keepInputs) budgetInput.value = state.monthlyBudget === null ? '' : String(state.monthlyBudget);
116
+ const budgetCurrencySelect = element('deepseek-monthly-budget-currency-select');
117
+ // A new budget is in dollars unless chosen otherwise; a budget saved
118
+ // without a currency stays in the balance's until one is chosen.
119
+ if (budgetCurrencySelect && !keepInputs) budgetCurrencySelect.value = state.monthlyBudgetCurrency || (state.monthlyBudget === null ? 'USD' : '');
120
+ const startingInput = element('deepseek-starting-balance-input');
121
+ if (startingInput && !keepInputs) startingInput.value = state.startingBalance === null ? '' : String(state.startingBalance);
122
+ const startingStatus = element('deepseek-starting-balance-status');
123
+ if (startingStatus) {
124
+ startingStatus.textContent = state.startingBalanceNote || '';
125
+ startingStatus.hidden = !state.startingBalanceNote;
126
+ }
127
+ const baseUrlInput = element('deepseek-base-url-input');
128
+ if (baseUrlInput && !keepInputs) baseUrlInput.value = state.baseUrl === DEFAULT_BASE_URL ? '' : state.baseUrl;
129
+ if (resetInputs) {
130
+ inputsDirty = false;
131
+ startingBalanceEdited = false;
132
+ }
133
+ const toggle = element('deepseek-enabled-toggle');
134
+ if (toggle) {
135
+ toggle.checked = state.enabled;
136
+ toggle.disabled = updateInFlight || !state.configured;
137
+ }
138
+ const removeButton = element('deepseek-remove-btn');
139
+ if (removeButton) removeButton.disabled = updateInFlight || !state.configured;
140
+ const status = element('deepseek-settings-status');
141
+ if (status) {
142
+ status.textContent = state.enabled
143
+ ? 'DeepSeek is enabled. Select DeepSeek in New Chat to use it.'
144
+ : (state.configured
145
+ ? 'API key saved but currently disabled. Enable it to allow DeepSeek selection in New Chat.'
146
+ : 'Not configured. New conversations use GitHub Copilot.');
147
+ status.dataset.state = state.enabled ? 'active' : (state.configured ? 'saved' : 'unconfigured');
148
+ }
149
+ window.syncAutoModelAvailability?.();
150
+ return state;
151
+ }
152
+
153
+ export async function refreshDeepSeekSettingsState() {
154
+ const settings = await loadDeepSeekSettings();
155
+ if (!settings) return null;
156
+ return applyDeepSeekSettingsState(settings);
157
+ }
158
+
159
+ /** Called when the Settings modal opens. */
160
+ export async function syncDeepSeekSettingsInputs() {
161
+ inputsDirty = false;
162
+ startingBalanceEdited = false;
163
+ ensureInputTracking();
164
+ const status = element('deepseek-settings-status');
165
+ const settings = await refreshDeepSeekSettingsState();
166
+ if (!settings && status) {
167
+ status.textContent = 'Unable to load DeepSeek settings.';
168
+ status.dataset.state = 'error';
169
+ }
170
+ }
171
+
172
+ async function runUpdate(payload, { successText, failureText, resetInputs = false }) {
173
+ if (updateInFlight) return null;
174
+ updateInFlight = true;
175
+ setControlsDisabled(true);
176
+ try {
177
+ const result = await updateDeepSeekSettings(payload);
178
+ if (!result) throw new Error(failureText);
179
+ applyDeepSeekSettingsState(result, { resetInputs });
180
+ const text = typeof successText === 'function' ? successText(result) : successText;
181
+ showTransientRelayNotice(result.warning ? `${text} ${result.warning}` : text, result.warning ? 8000 : 4000);
182
+ return result;
183
+ } catch (error) {
184
+ // A refused key removal (409) names the conversations in its message.
185
+ alert(error?.message || failureText);
186
+ return null;
187
+ } finally {
188
+ updateInFlight = false;
189
+ setControlsDisabled(false);
190
+ applyDeepSeekSettingsState(state);
191
+ }
192
+ }
193
+
194
+ export async function saveDeepSeekSettings() {
195
+ const apiKey = String(element('deepseek-api-key-input')?.value || '').trim();
196
+ if (!apiKey && !state.configured) {
197
+ alert('Enter a DeepSeek API key.');
198
+ return;
199
+ }
200
+ const baseUrlText = String(element('deepseek-base-url-input')?.value || '').trim();
201
+ const budgetText = String(element('deepseek-monthly-budget-input')?.value || '').trim();
202
+ const monthlyBudget = budgetText ? Number(budgetText) : null;
203
+ if (budgetText && !(Number.isFinite(monthlyBudget) && monthlyBudget >= 0)) {
204
+ alert('The monthly budget must be an amount of zero or more, or empty for no budget bar.');
205
+ return;
206
+ }
207
+ const startingText = String(element('deepseek-starting-balance-input')?.value || '').trim();
208
+ const startingBalance = startingText ? Number(startingText) : null;
209
+ if (startingText && !(Number.isFinite(startingBalance) && startingBalance >= 0)) {
210
+ alert('The starting balance must be an amount of zero or more, or empty to measure from the balance after the last top-up.');
211
+ return;
212
+ }
213
+ await runUpdate({
214
+ apiKey,
215
+ model: String(element('deepseek-model-select')?.value || state.model || DEFAULT_MODEL),
216
+ attribution: String(element('deepseek-attribution-select')?.value || state.attribution),
217
+ // Empty is DeepSeek's own endpoint.
218
+ baseUrl: baseUrlText || null,
219
+ // Empty (or zero) is no budget bar on the usage card. The currency goes
220
+ // with the amount; empty is the balance's currency.
221
+ monthlyBudget,
222
+ monthlyBudgetCurrency: String(element('deepseek-monthly-budget-currency-select')?.value || '').trim() || null,
223
+ // Empty (or zero): the balance bar measures from the balance after the
224
+ // last top-up the relay has seen (before one, the highest balance read).
225
+ startingBalance,
226
+ // Typed in since the tab was filled: only then does the relay set a
227
+ // starting balance a top-up has replaced anew (the same amount too). The
228
+ // relay decides "replaced" itself, so a tab opened before a top-up was
229
+ // seen does not bring the old amount back when another field is saved.
230
+ startingBalanceEdited,
231
+ enabled: state.configured ? state.enabled : true,
232
+ }, {
233
+ successText: (result) => `DeepSeek settings saved for ${result.model}.`,
234
+ failureText: 'Failed to save DeepSeek settings.',
235
+ resetInputs: true,
236
+ });
237
+ }
238
+
239
+ export async function toggleDeepSeekProvider(enabled) {
240
+ if (enabled && !state.configured) {
241
+ applyDeepSeekSettingsState(state);
242
+ alert('Save a DeepSeek API key before enabling DeepSeek.');
243
+ return;
244
+ }
245
+ await runUpdate({ enabled: enabled === true }, {
246
+ successText: (result) => (result.enabled ? 'DeepSeek enabled.' : 'DeepSeek disabled.'),
247
+ failureText: 'Failed to update the DeepSeek provider.',
248
+ });
249
+ }
250
+
251
+ export async function removeDeepSeekSettings() {
252
+ if (!state.configured) return;
253
+ if (!confirm('Remove the saved DeepSeek API key?')) return;
254
+ await runUpdate({ remove: true }, {
255
+ successText: 'DeepSeek API key removed. New conversations use GitHub Copilot.',
256
+ failureText: 'Failed to remove DeepSeek settings.',
257
+ resetInputs: true,
258
+ });
259
+ }
260
+
261
+ // The export's times keep its offset: "2026-10-26T00:00:00+01:00" reads
262
+ // "2026-10-26 00:00 (UTC+01:00)".
263
+ function exportTimeLabel(value) {
264
+ const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})(?::\d{2}(?:\.\d+)?)?(Z|[+-]\d{2}:\d{2})?$/.exec(String(value || ''));
265
+ if (!match) return String(value || '');
266
+ const zone = !match[3] || match[3] === 'Z' ? 'UTC' : `UTC${match[3]}`;
267
+ return `${match[1]} ${match[2]} (${zone})`;
268
+ }
269
+
270
+ function listText(items) {
271
+ if (items.length <= 1) return items.join('');
272
+ return `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
273
+ }
274
+
275
+ /** The import's answer, in the panel. */
276
+ export function deepseekUsageImportSummaryText(summary = {}) {
277
+ const days = Array.isArray(summary.days) ? summary.days : [];
278
+ const models = Array.isArray(summary.models) ? summary.models : [];
279
+ const keys = Array.isArray(summary.keys) ? summary.keys : [];
280
+ const rows = Number(summary.rows) || 0;
281
+ const parts = [];
282
+ if (!rows) {
283
+ parts.push('Nothing to import: the export has no period that has ended.');
284
+ } else {
285
+ const span = days.length > 1 ? `${days[0]} to ${days.at(-1)}` : (days[0] || '');
286
+ parts.push(`Imported ${rows} row${rows === 1 ? '' : 's'}${span ? ` for ${span}` : ''}${models.length ? `: ${listText(models)}` : ''}.`);
287
+ }
288
+ const skipped = Array.isArray(summary.skippedPeriods) ? summary.skippedPeriods.length : 0;
289
+ if (skipped) parts.push(`${skipped} period${skipped === 1 ? '' : 's'} not ended yet left out.`);
290
+ const currencies = Array.isArray(summary.currencies) ? summary.currencies : [];
291
+ const cardCurrency = summary.cardCurrency || '';
292
+ const exportCurrency = currencies.length ? listText(currencies) : '';
293
+ if (rows && summary.savedKeyMatched && summary.currencyMatched === false) {
294
+ parts.push(`The export is in ${exportCurrency || 'another currency'}, the usage card in ${cardCurrency} (the balance's): the card does not use these figures and stays estimated.`);
295
+ } else if (rows && summary.savedKeyMatched) {
296
+ const inCurrency = exportCurrency ? ` (${exportCurrency})` : '';
297
+ const cardNote = !cardCurrency && exportCurrency ? ` It uses them while the balance is in ${exportCurrency}.` : '';
298
+ parts.push(`The usage card shows the saved key's figures${inCurrency} on the export's days through ${exportTimeLabel(summary.through)}, estimated on the others.${cardNote}`);
299
+ } else if (rows && summary.savedKeyFp) {
300
+ parts.push(`The saved key (${summary.savedKeyFp}) is not in this export${keys.length ? `, only ${listText(keys.map((key) => key.keyFp))}` : ''}; the usage card stays estimated.`);
301
+ } else if (rows) {
302
+ parts.push('No DeepSeek key is saved: save the key this export belongs to and the usage card shows its figures.');
303
+ }
304
+ return parts.join(' ');
305
+ }
306
+
307
+ let importInFlight = false;
308
+
309
+ function setImportStatus(text, stateName) {
310
+ const status = element('deepseek-usage-import-status');
311
+ if (!status) return;
312
+ status.textContent = text;
313
+ status.dataset.state = stateName;
314
+ }
315
+
316
+ export async function importDeepSeekUsageExport() {
317
+ if (importInFlight) return null;
318
+ const amountFile = element('deepseek-usage-amount-input')?.files?.[0] || null;
319
+ const costFile = element('deepseek-usage-cost-input')?.files?.[0] || null;
320
+ if (!amountFile || !costFile) {
321
+ setImportStatus('Choose both files of the export: the amount file and the cost file.', 'error');
322
+ return null;
323
+ }
324
+ const button = element('deepseek-usage-import-btn');
325
+ importInFlight = true;
326
+ if (button) button.disabled = true;
327
+ setImportStatus('Importing…', 'saved');
328
+ try {
329
+ const [amount, cost] = await Promise.all([amountFile.text(), costFile.text()]);
330
+ const result = await postDeepSeekUsageExport({ amount, cost });
331
+ if (!result?.ok) throw new Error('The relay could not be reached.');
332
+ const used = result.summary?.savedKeyMatched && result.summary?.currencyMatched !== false;
333
+ setImportStatus(deepseekUsageImportSummaryText(result.summary), used ? 'active' : 'saved');
334
+ return result;
335
+ } catch (error) {
336
+ setImportStatus(String(error?.message || 'The import failed.'), 'error');
337
+ return null;
338
+ } finally {
339
+ importInFlight = false;
340
+ if (button) button.disabled = false;
341
+ }
342
+ }
@@ -41,6 +41,10 @@ const drafts = new Map();
41
41
  // replaces it; the socket updates single asks in between).
42
42
  let sessionAsks = new Map();
43
43
  let sessionCardKey = '';
44
+ // Where the Inbox was scrolled when it was last left; the next opening
45
+ // starts there (the first one at the top). The transcript's own scroll
46
+ // height is the transcript's, never the Inbox's.
47
+ let inboxScrollTop = 0;
44
48
 
45
49
  function readFilter() {
46
50
  try {
@@ -90,12 +94,17 @@ function restoreDrafts(host, focusedKey) {
90
94
  }
91
95
  }
92
96
 
93
- function renderInboxNow() {
97
+ /**
98
+ * Draws the Inbox into the transcript's place. A re-render keeps the scroll
99
+ * height; the render that opens the view (`opening`) takes the height the
100
+ * Inbox had when it was last left, not the one the transcript showed.
101
+ */
102
+ function renderInboxNow({ opening = false } = {}) {
94
103
  if (!inboxActive) return;
95
104
  const host = document.getElementById('messages');
96
105
  if (!host) return;
97
106
  const focused = captureDrafts(host);
98
- const top = host.scrollTop;
107
+ const top = opening ? inboxScrollTop : host.scrollTop;
99
108
  host.replaceChildren(renderInbox(document, { relays, filter, now: Date.now(), loading: !loaded }));
100
109
  restoreDrafts(host, focused);
101
110
  host.scrollTop = top;
@@ -153,6 +162,8 @@ export function noteInboxQuestionsChanged() {
153
162
  export function leaveInboxView() {
154
163
  const wasActive = inboxActive;
155
164
  if (!wasActive) return false;
165
+ const host = document.getElementById('messages');
166
+ if (host) inboxScrollTop = Math.max(0, Number(host.scrollTop) || 0);
156
167
  inboxActive = false;
157
168
  document.body.classList.remove('inbox-view-active');
158
169
  document.getElementById('input-area')?.removeAttribute('hidden');
@@ -187,7 +198,7 @@ export async function toggleInboxView() {
187
198
  }
188
199
  }
189
200
  syncInboxBadge();
190
- renderInboxNow();
201
+ renderInboxNow({ opening: true });
191
202
  document.getElementById('sidebar')?.classList.remove('open');
192
203
  document.getElementById('sidebar-overlay')?.classList.remove('visible');
193
204
  await refreshInbox();
@@ -72,6 +72,7 @@ import {
72
72
  loadClaudeCloudBranches,
73
73
  loadClaudeCloudRepos,
74
74
  loadGrokSettings,
75
+ loadDeepSeekSettings,
75
76
  loadOpenAISettings,
76
77
  } from './api-client.js';
77
78
  import { isSetupRequired, updateProviderReadinessInputs } from './provider-readiness.mjs';
@@ -189,6 +190,7 @@ let newConversationOpenAISettingsCache = null;
189
190
  let newConversationClaudeSettingsCache = null;
190
191
  let newConversationCursorSettingsCache = null;
191
192
  let newConversationGrokSettingsCache = null;
193
+ let newConversationDeepSeekSettingsCache = null;
192
194
  let newConversationClaudeCloudSettingsCache = null;
193
195
  // Claude Cloud fields of the New Chat modal: what the selected folder's git
194
196
  // remote said, and what was written into Repository/Branch from it (so a later
@@ -1237,6 +1239,7 @@ function normalizeNewConversationProviderType(value = '') {
1237
1239
  if (normalized === CLAUDE_CLOUD_PROVIDER) return CLAUDE_CLOUD_PROVIDER;
1238
1240
  if (normalized === 'cursor') return 'cursor';
1239
1241
  if (normalized === 'grok') return 'grok';
1242
+ if (normalized === 'deepseek') return 'deepseek';
1240
1243
  return 'github';
1241
1244
  }
1242
1245
 
@@ -1274,6 +1277,7 @@ function modelMatchesNewConversationProvider(catalog = {}, modelId = '', provide
1274
1277
  const hasClaude = providers.includes('claude');
1275
1278
  const hasCursor = providers.includes('cursor');
1276
1279
  const hasGrok = providers.includes('grok');
1280
+ const hasDeepSeek = providers.includes('deepseek');
1277
1281
  if (wantsOpenAI) {
1278
1282
  if (hasOpenAIByok) return true;
1279
1283
  const settingsModel = String(newConversationOpenAISettingsCache?.model || '').trim();
@@ -1309,16 +1313,26 @@ function modelMatchesNewConversationProvider(catalog = {}, modelId = '', provide
1309
1313
  : [];
1310
1314
  return normalizedModelId === grokModel || grokModels.includes(normalizedModelId);
1311
1315
  }
1316
+ if (normalizedProvider === 'deepseek') {
1317
+ if (hasDeepSeek) return true;
1318
+ const deepseekModel = String(newConversationDeepSeekSettingsCache?.model || '').trim();
1319
+ const deepseekModels = Array.isArray(newConversationDeepSeekSettingsCache?.models)
1320
+ ? newConversationDeepSeekSettingsCache.models.map((entry) => String(entry || '').trim()).filter(Boolean)
1321
+ : [];
1322
+ return normalizedModelId === deepseekModel || deepseekModels.includes(normalizedModelId);
1323
+ }
1312
1324
  const claudeOnly = hasClaude && providers.every((provider) => provider === 'claude');
1313
1325
  if (claudeOnly) return false;
1314
1326
  const cursorOnly = hasCursor && providers.every((provider) => provider === 'cursor');
1315
1327
  if (cursorOnly) return false;
1316
1328
  const grokOnly = hasGrok && providers.every((provider) => provider === 'grok');
1317
1329
  if (grokOnly) return false;
1330
+ const deepseekOnly = hasDeepSeek && providers.every((provider) => provider === 'deepseek');
1331
+ if (deepseekOnly) return false;
1318
1332
  // Should a relay ever tag catalog rows for Claude Cloud, they are no more
1319
1333
  // Copilot's than the other runtimes' rows are.
1320
1334
  const exclusiveOnly = providers.length > 0 && providers.every((provider) => (
1321
- provider === 'claude' || provider === 'cursor' || provider === 'grok' || provider === CLAUDE_CLOUD_PROVIDER
1335
+ provider === 'claude' || provider === 'cursor' || provider === 'grok' || provider === 'deepseek' || provider === CLAUDE_CLOUD_PROVIDER
1322
1336
  ));
1323
1337
  if (exclusiveOnly) return false;
1324
1338
  return providers.some((provider) => (
@@ -1326,6 +1340,7 @@ function modelMatchesNewConversationProvider(catalog = {}, modelId = '', provide
1326
1340
  && provider !== 'claude'
1327
1341
  && provider !== 'cursor'
1328
1342
  && provider !== 'grok'
1343
+ && provider !== 'deepseek'
1329
1344
  && provider !== CLAUDE_CLOUD_PROVIDER
1330
1345
  )) || !hasOpenAIByok;
1331
1346
  }
@@ -1443,8 +1458,10 @@ async function populateNewConversationReasoningSelect(selectedModel = '') {
1443
1458
  if (optionTitle) option.title = optionTitle;
1444
1459
  select.appendChild(option);
1445
1460
  }
1461
+ // A DeepSeek chat with nothing remembered starts at DeepSeek's own default.
1462
+ const storedEffort = localStorage.getItem(REASONING_STORAGE_KEY);
1446
1463
  const preferred = resolvePreferredReasoningEffort(efforts, [
1447
- localStorage.getItem(REASONING_STORAGE_KEY),
1464
+ storedEffort || (provider === 'deepseek' ? 'high' : ''),
1448
1465
  ]);
1449
1466
  select.value = preferred || efforts[0];
1450
1467
  select.disabled = false;
@@ -1487,6 +1504,10 @@ function updateNewConversationProviderHelp(provider = 'github') {
1487
1504
  help.textContent = "Grok chats use the host machine's logged-in Grok credentials (grok login or XAI_API_KEY).";
1488
1505
  return;
1489
1506
  }
1507
+ if (normalizedProvider === 'deepseek') {
1508
+ help.textContent = "DeepSeek chats run Claude Code on the relay host against DeepSeek's API, billed to your saved DeepSeek key.";
1509
+ return;
1510
+ }
1490
1511
  help.textContent = 'Copilot models use your GitHub Copilot runtime.';
1491
1512
  }
1492
1513
 
@@ -2094,7 +2115,7 @@ function applyNewConversationSetupState(setupRequired) {
2094
2115
  }
2095
2116
 
2096
2117
  async function openNewConversationModelModal() {
2097
- const [catalog, settings, claudeSettings, cursorSettings, grokSettings, claudeCloudSettings] = await Promise.all([
2118
+ const [catalog, settings, claudeSettings, cursorSettings, grokSettings, claudeCloudSettings, deepseekSettings] = await Promise.all([
2098
2119
  loadModelCatalog(),
2099
2120
  loadOpenAISettings(),
2100
2121
  loadClaudeSettings(),
@@ -2102,6 +2123,8 @@ async function openNewConversationModelModal() {
2102
2123
  loadGrokSettings(),
2103
2124
  // null on a relay without Claude Cloud: the option is simply not offered.
2104
2125
  loadClaudeCloudSettings(),
2126
+ // The same for a relay without DeepSeek.
2127
+ loadDeepSeekSettings(),
2105
2128
  ]);
2106
2129
  newConversationCatalogCache = catalog || null;
2107
2130
  newConversationOpenAISettingsCache = settings || null;
@@ -2109,6 +2132,7 @@ async function openNewConversationModelModal() {
2109
2132
  newConversationCursorSettingsCache = cursorSettings || null;
2110
2133
  newConversationGrokSettingsCache = grokSettings || null;
2111
2134
  newConversationClaudeCloudSettingsCache = claudeCloudSettings || null;
2135
+ newConversationDeepSeekSettingsCache = deepseekSettings || null;
2112
2136
  // Fresh answers for the readiness question, shared with the empty screen
2113
2137
  // and the composer. With no provider known to be ready the modal opens in
2114
2138
  // its setup state: Settings is the main button, Start stays as "anyway".
@@ -2118,6 +2142,7 @@ async function openNewConversationModelModal() {
2118
2142
  claudeCloud: claudeCloudSettings || null,
2119
2143
  cursor: cursorSettings || null,
2120
2144
  grok: grokSettings || null,
2145
+ deepseek: deepseekSettings || null,
2121
2146
  catalog: catalog || null,
2122
2147
  });
2123
2148
  applyNewConversationSetupState(isSetupRequired(readiness));
@@ -2143,6 +2168,9 @@ async function openNewConversationModelModal() {
2143
2168
  if (grokSettings?.enabled === true) {
2144
2169
  options.push({ value: 'grok', label: 'Grok' });
2145
2170
  }
2171
+ if (deepseekSettings?.enabled === true) {
2172
+ options.push({ value: 'deepseek', label: 'DeepSeek' });
2173
+ }
2146
2174
  providerSelect.innerHTML = '';
2147
2175
  for (const option of options) {
2148
2176
  const entry = document.createElement('option');
@@ -16,11 +16,13 @@ export function composerPlaceholderFor({ modelId = '', providerType = '' } = {})
16
16
  if (provider === 'grok') return 'Message Grok…';
17
17
  if (provider === 'openai' || provider === 'openai-byok') return 'Message OpenAI…';
18
18
  if (provider === 'cursor') return 'Message Cursor…';
19
+ if (provider === 'deepseek') return 'Message DeepSeek…';
19
20
  return 'Message Copilot…';
20
21
  };
21
22
  if (!id || id === 'auto') return providerFallback();
22
23
  if (id.startsWith('claude-')) return 'Message Claude…';
23
24
  if (id.startsWith('grok-')) return 'Message Grok…';
25
+ if (id.startsWith('deepseek-')) return 'Message DeepSeek…';
24
26
  if (id.startsWith('gpt-')) return 'Message GPT…';
25
27
  if (id.startsWith('gemini-')) return 'Message Gemini…';
26
28
  // Composer is Cursor's house model family.
@@ -65,6 +67,15 @@ export function humanizeModelLabel(modelId = '') {
65
67
  const label = words.map((word) => word.text).join(' ');
66
68
  return suffixMatch ? `${label} [${suffixMatch[1]}]` : label;
67
69
  }
70
+ // "deepseek-flash" → "DeepSeek Flash", "deepseek-v4-pro" → "DeepSeek V4 Pro".
71
+ if (/^deepseek-/i.test(text)) {
72
+ return `DeepSeek ${text
73
+ .replace(/^deepseek-/i, '')
74
+ .split('-')
75
+ .filter(Boolean)
76
+ .map((part) => (/^v\d/i.test(part) ? part.toUpperCase() : part.charAt(0).toUpperCase() + part.slice(1)))
77
+ .join(' ')}`;
78
+ }
68
79
  if (/^gemini-/i.test(text)) {
69
80
  return text
70
81
  .replace(/^gemini-/i, 'Gemini ')
@@ -14,7 +14,9 @@ const KNOWN_TEXT_ONLY_PATTERNS = [
14
14
  /^code-davinci/,
15
15
  /^text-davinci/,
16
16
  /-instruct-text$/,
17
+ // DeepSeek's text-only models (deepseek-flash takes images).
17
18
  /^deepseek-coder/,
19
+ /^deepseek-v4-pro/,
18
20
  /^qwen[\w.-]*-coder/,
19
21
  ];
20
22
 
@@ -10,7 +10,7 @@
10
10
  import { isSetupRequired } from './provider-readiness.mjs';
11
11
 
12
12
  export const SETUP_HEADING = 'Set up a provider';
13
- export const SETUP_TEXT = 'Turn on Claude, Cursor, OpenAI or Grok in Settings → Providers, or sign in to the Copilot CLI on the relay host.';
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.';
14
14
 
15
15
  const ACTIONS = Object.freeze({
16
16
  openSettings: (primary) => ({ id: 'empty-state-open-settings', action: 'open-settings', label: primary ? 'Open Settings' : 'Settings', primary, link: !primary }),