@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.
- package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
- package/CHANGELOG.md +220 -0
- package/README.md +12 -11
- package/package.json +2 -1
- package/server/claude-worker/claude-attachments.mjs +7 -4
- package/server/claude-worker/claude-sdk-adapter.mjs +167 -9
- package/server/claude-worker/claude-session-process.mjs +127 -29
- package/server/claude-worker/claude-session-worker-main.mjs +266 -0
- package/server/claude-worker/claude-session-worker.mjs +2 -253
- package/server/claude-worker/claude-turn-failure.mjs +8 -0
- package/server/claude-worker/claude-turn-publisher.mjs +282 -13
- package/server/claude-worker/claude-usage-totals.mjs +108 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +55 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
- package/server/copilot-worker/copilot-sdk-session-process.mjs +71 -5
- package/server/db-schema.mjs +63 -0
- package/server/deepseek-worker/deepseek-claude-backend.mjs +433 -0
- package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
- package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
- package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
- package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
- package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
- package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
- package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
- package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
- package/server/public/app/api-client.js +53 -0
- package/server/public/app/bootstrap.js +101 -13
- package/server/public/app/context-tier-options.mjs +4 -0
- package/server/public/app/conversation-provider-indicator.mjs +7 -0
- package/server/public/app/conversation-view.js +2 -0
- package/server/public/app/deepseek-settings-ui.js +342 -0
- package/server/public/app/inbox-ui.js +14 -3
- package/server/public/app/journal-view.js +31 -3
- package/server/public/app/model-selector-options.mjs +11 -0
- package/server/public/app/model-vision-support.mjs +2 -0
- package/server/public/app/no-conversation-state.mjs +1 -1
- package/server/public/app/plan-usage-view.mjs +165 -13
- package/server/public/app/provider-readiness.mjs +4 -1
- package/server/public/app/relay-error-ctas.mjs +8 -0
- package/server/public/app/settings-modal.js +11 -0
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +10 -0
- package/server/public/index.html +408 -33
- package/server/repositories/conversation-group-repository.mjs +13 -6
- package/server/routes/conversation-group-routes.mjs +2 -1
- package/server/routes/deepseek-usage-import-routes.mjs +72 -0
- package/server/routes/messages-routes.mjs +192 -15
- package/server/routes/sessions-routes.mjs +364 -55
- package/server/server-runtime.mjs +123 -10
- package/server/services/about-service.mjs +1 -0
- package/server/services/claude-session-leftovers.mjs +226 -0
- package/server/services/deepseek-balance-service.mjs +102 -0
- package/server/services/deepseek-settings-service.mjs +380 -0
- package/server/services/deepseek-usage-import.mjs +471 -0
- package/server/services/model-variant-catalog-service.mjs +1 -0
- package/server/services/plan-usage-contract.mjs +100 -5
- package/server/services/plan-usage-deepseek.mjs +931 -0
- package/server/services/plan-usage-service.mjs +431 -0
- package/server/services/provider-model-selection.mjs +1 -0
- package/server/services/provider-reasoning-effort.mjs +15 -2
- package/server/services/remote-relay-dispatcher.mjs +2 -1
- package/server/services/session-worker-launch-service.mjs +233 -82
- package/server/services/worker-secret-redaction.mjs +318 -0
- package/shared/context-window-fallbacks.mjs +3 -0
- package/shared/deepseek-api.mjs +137 -0
- package/shared/deepseek-models.mjs +114 -0
- package/shared/deepseek-pricing.mjs +146 -0
- package/shared/host-claude-session-env.mjs +101 -0
- package/shared/model-id.mjs +7 -0
- package/shared/preview-tool-core.mjs +1 -1
- package/shared/provider-routing.mjs +2 -2
- package/shared/remote-relay-contract.mjs +1 -1
- package/shared/steer-settle-failure.mjs +1 -0
- package/shared/worker-runtime/direct-openai-image.mjs +128 -0
- 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
|
-
|
|
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
|
-
|
|
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 ')
|
|
@@ -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
|
|
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 }),
|