@oar-sh/oar 0.9.16 → 0.9.18
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/.github/extensions/web-relay/skills/tool-activity.mjs +5 -4
- package/CHANGELOG.md +412 -0
- package/README.md +15 -13
- package/package.json +2 -1
- package/server/claude-api-worker/claude-api-backend.mjs +345 -0
- package/server/claude-api-worker/claude-api-session-worker.mjs +29 -0
- package/server/claude-worker/claude-attachments.mjs +7 -4
- package/server/claude-worker/claude-cli-pinned-env.mjs +150 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +3 -3
- package/server/claude-worker/claude-sdk-adapter.mjs +199 -15
- package/server/claude-worker/claude-session-process.mjs +276 -33
- package/server/claude-worker/claude-session-worker-main.mjs +269 -0
- package/server/claude-worker/claude-session-worker.mjs +2 -253
- package/server/claude-worker/claude-subscription-key-check.mjs +64 -0
- package/server/claude-worker/claude-turn-failure.mjs +8 -0
- package/server/claude-worker/claude-turn-publisher.mjs +394 -13
- package/server/claude-worker/claude-usage-totals.mjs +161 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +56 -1
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +2 -2
- package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +1 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +80 -14
- package/server/cursor-worker/cursor-remote-relay-tool.mjs +3 -3
- package/server/cursor-worker/cursor-turn-runner.mjs +4 -4
- package/server/cursor-worker/sdk-message-normalizer.mjs +2 -2
- package/server/db-schema.mjs +96 -1
- package/server/deepseek-worker/deepseek-claude-backend.mjs +317 -0
- package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
- package/server/grok-worker/acp-client.mjs +1 -1
- package/server/grok-worker/grok-turn-runner.mjs +4 -4
- package/server/grok-worker/sdk-message-normalizer.mjs +1 -1
- package/server/mcp/oar-mcp-server.mjs +11 -5
- 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/migrations/0031-queue-provider.mjs +143 -0
- package/server/migrations/0032-runtime-session-billing-switch.mjs +94 -0
- package/server/migrations/0033-claude-api-usage-checkpoints.mjs +138 -0
- package/server/migrations/0034-claude-api-usage-totals.mjs +112 -0
- package/server/public/app/activity-replay-state.mjs +4 -3
- package/server/public/app/agent-sessions-settings-ui.js +1 -1
- package/server/public/app/agent-sessions-settings.mjs +1 -1
- package/server/public/app/api-client.js +105 -0
- package/server/public/app/bootstrap.js +243 -29
- package/server/public/app/claude-api-settings-ui.js +362 -0
- package/server/public/app/claude-billing-toggle-ui.js +224 -0
- package/server/public/app/claude-billing-toggle.mjs +112 -0
- package/server/public/app/context-tier-options.mjs +10 -2
- package/server/public/app/context-usage-view.mjs +1 -1
- package/server/public/app/conversation-provider-indicator.mjs +18 -0
- package/server/public/app/conversation-view.js +31 -3
- package/server/public/app/deepseek-settings-ui.js +342 -0
- package/server/public/app/inbox-ui.js +14 -3
- package/server/public/app/inbox-view.mjs +1 -1
- package/server/public/app/journal-view.js +156 -11
- package/server/public/app/model-selector-options.mjs +28 -1
- package/server/public/app/model-vision-support.mjs +2 -0
- package/server/public/app/new-conversation-model-choice.mjs +21 -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 +8 -2
- package/server/public/app/relay-error-ctas.mjs +58 -0
- package/server/public/app/relay-schedules-ui.js +1 -1
- package/server/public/app/settings-modal.js +22 -0
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +28 -1
- package/server/public/app/tool-activity-emoji.mjs +4 -3
- package/server/public/app/usage-limit-ui.js +136 -5
- package/server/public/index.html +596 -35
- package/server/relay-tools.md +4 -4
- package/server/repositories/conversation-group-repository.mjs +14 -7
- package/server/repositories/message-repository.mjs +7 -0
- package/server/repositories/session-repository.mjs +8 -1
- package/server/routes/ask-user-routes.mjs +1 -1
- package/server/routes/conversation-group-routes.mjs +3 -2
- package/server/routes/deepseek-usage-import-routes.mjs +72 -0
- package/server/routes/messages-routes.mjs +831 -36
- package/server/routes/relay-ask-routes.mjs +3 -3
- package/server/routes/relay-event-routes.mjs +2 -2
- package/server/routes/relay-schedule-routes.mjs +1 -1
- package/server/routes/remote-relay-routes.mjs +3 -3
- package/server/routes/sessions-routes.mjs +670 -63
- package/server/server-runtime.mjs +476 -26
- package/server/services/about-service.mjs +2 -0
- package/server/services/background-work-view.mjs +2 -1
- package/server/services/claude-api-settings-service.mjs +575 -0
- package/server/services/claude-api-usage-service.mjs +361 -0
- package/server/services/claude-billing-swap-service.mjs +274 -0
- package/server/services/claude-billing-switch-service.mjs +343 -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-claude-api.mjs +445 -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 +436 -0
- package/server/services/provider-model-selection.mjs +4 -2
- package/server/services/provider-reasoning-effort.mjs +24 -2
- package/server/services/relay-ask-service.mjs +1 -1
- package/server/services/relay-event-service.mjs +3 -2
- package/server/services/relay-schedule-service.mjs +1 -1
- package/server/services/remote-relay-caller-context.mjs +7 -0
- package/server/services/remote-relay-dispatcher.mjs +84 -14
- package/server/services/remote-relay-registry-service.mjs +1 -1
- package/server/services/session-worker-launch-service.mjs +294 -84
- package/server/services/session-worker-registry-service.mjs +28 -1
- package/server/services/session-worker-websocket-service.mjs +17 -0
- package/server/services/usage-limit-pause-service.mjs +55 -4
- package/server/services/worker-secret-redaction.mjs +320 -0
- package/shared/anthropic-api.mjs +202 -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 +5 -3
- package/shared/relay-asks.mjs +11 -5
- package/shared/relay-events.mjs +2 -2
- package/shared/relay-schedules.mjs +2 -2
- package/shared/remote-relay-contract.mjs +29 -9
- package/shared/remote-relay-tool-core.mjs +23 -6
- package/shared/steer-settle-failure.mjs +2 -0
- package/shared/worker-runtime/api-client.mjs +2 -2
- package/shared/worker-runtime/direct-openai-image.mjs +128 -0
- package/shared/worker-runtime/heartbeat.mjs +5 -0
- package/shared/worker-runtime/worker-log-file.mjs +60 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +6 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// The composer's billing toggle for a Claude chat: Subscription | API key.
|
|
2
|
+
// Pure and DOM-free: what the toggle shows, from the chat's provider, what
|
|
3
|
+
// the relay said of its billing (`billing`, GET /api/conversation and the
|
|
4
|
+
// `conversation_billing` socket event) and the Claude API tab's state.
|
|
5
|
+
//
|
|
6
|
+
// A switch applies from the chat's next turn; the worker is swapped between
|
|
7
|
+
// turns, and while it waits the relay's `billing.pending` says for what.
|
|
8
|
+
|
|
9
|
+
export const CLAUDE_BILLING_TOGGLE_OPTIONS = Object.freeze([
|
|
10
|
+
Object.freeze({ value: 'claude', label: 'Subscription' }),
|
|
11
|
+
Object.freeze({ value: 'claude-api', label: 'API key' }),
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
export const CLAUDE_BILLING_CACHE_WARNING = 'The first turn on the API key has no prompt cache and costs more.';
|
|
15
|
+
// A subscription chat while Claude API's monthly cap is reached: said on the
|
|
16
|
+
// toggle itself, not only when the unavailable side is pressed.
|
|
17
|
+
export const CLAUDE_BILLING_CAP_TEXT = "This month's Claude API cap is reached: the chat stays on the subscription.";
|
|
18
|
+
|
|
19
|
+
function normalizeBilling(value) {
|
|
20
|
+
const billing = String(value || '').trim().toLowerCase();
|
|
21
|
+
return billing === 'claude' || billing === 'claude-api' ? billing : '';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** "the API key" / "the subscription", for running text. */
|
|
25
|
+
export function claudeBillingPlace(billing) {
|
|
26
|
+
return normalizeBilling(billing) === 'claude-api' ? 'the API key' : 'the subscription';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What the toggle says while the worker swap waits (`billing.pending`), or ''. */
|
|
30
|
+
export function claudeBillingPendingText(pending) {
|
|
31
|
+
if (!pending || typeof pending !== 'object') return '';
|
|
32
|
+
const place = claudeBillingPlace(pending.to);
|
|
33
|
+
if (pending.swapping === true) return `Restarting on ${place}…`;
|
|
34
|
+
const waitingFor = String(pending.waitingFor || '').trim();
|
|
35
|
+
if (waitingFor === 'turn') return `Moves to ${place} after the running turn.`;
|
|
36
|
+
if (waitingFor === 'question') return `Moves to ${place} once the open question is answered.`;
|
|
37
|
+
if (waitingFor === 'compaction') return `Moves to ${place} after the compaction.`;
|
|
38
|
+
if (waitingFor === 'background') {
|
|
39
|
+
const count = Number(pending.backgroundTaskCount) || 0;
|
|
40
|
+
return count > 0
|
|
41
|
+
? `Moves to ${place} once ${count} background task${count === 1 ? '' : 's'} finish${count === 1 ? 'es' : ''}.`
|
|
42
|
+
: `Moves to ${place} once the background tasks finish.`;
|
|
43
|
+
}
|
|
44
|
+
if (waitingFor === 'stop') return `Moving to ${place}: the old Claude process has not stopped yet, retrying.`;
|
|
45
|
+
return `Moving to ${place}…`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The toggle for one chat:
|
|
50
|
+
* `{ visible, current, options: [{ value, label, pressed, available, reason }],
|
|
51
|
+
* status, statusKind }`.
|
|
52
|
+
*
|
|
53
|
+
* Shown in a Claude SDK chat (either billing) whose relay said its billing,
|
|
54
|
+
* when Claude API is on with a saved key; a chat on the key keeps it after
|
|
55
|
+
* Claude API is switched off, so it can move back. `inFlight` is a switch
|
|
56
|
+
* this page sent and has no answer for yet; `warnCache` the chat this page
|
|
57
|
+
* just moved to the key.
|
|
58
|
+
*/
|
|
59
|
+
export function claudeBillingToggleModel({
|
|
60
|
+
providerType = '',
|
|
61
|
+
billing = null,
|
|
62
|
+
claudeApi = null,
|
|
63
|
+
inFlight = false,
|
|
64
|
+
warnCache = false,
|
|
65
|
+
} = {}) {
|
|
66
|
+
const hidden = { visible: false, current: '', options: [], status: '', statusKind: '' };
|
|
67
|
+
const chatProvider = normalizeBilling(providerType);
|
|
68
|
+
if (!chatProvider || !billing || typeof billing !== 'object') return hidden;
|
|
69
|
+
const current = normalizeBilling(billing.provider) || chatProvider;
|
|
70
|
+
const claudeApiReady = claudeApi?.configured === true && claudeApi?.enabled === true;
|
|
71
|
+
if (current === 'claude' && !claudeApiReady) return hidden;
|
|
72
|
+
const switchable = billing.switchable && typeof billing.switchable === 'object' ? billing.switchable : {};
|
|
73
|
+
const target = normalizeBilling(switchable.to);
|
|
74
|
+
// The chat's billing was read while Claude API was off; the tab has since
|
|
75
|
+
// switched it on. The relay checks the switch again when it is pressed;
|
|
76
|
+
// a refusal it gave this page since (`refused`) stands.
|
|
77
|
+
const allowed = switchable.allowed === true
|
|
78
|
+
|| (target === 'claude-api' && claudeApiReady && switchable.code === 'CLAUDE_API_NOT_CONFIGURED'
|
|
79
|
+
&& switchable.refused !== true);
|
|
80
|
+
const options = CLAUDE_BILLING_TOGGLE_OPTIONS.map((option) => {
|
|
81
|
+
const pressed = option.value === current;
|
|
82
|
+
const available = pressed || (option.value === target && allowed && !inFlight);
|
|
83
|
+
const reason = pressed || available
|
|
84
|
+
? ''
|
|
85
|
+
: (inFlight ? 'A switch is on its way.' : (String(switchable.reason || '').trim() || 'The relay does not allow this switch now.'));
|
|
86
|
+
return { value: option.value, label: option.label, pressed, available, reason };
|
|
87
|
+
});
|
|
88
|
+
let status = '';
|
|
89
|
+
let statusKind = '';
|
|
90
|
+
const pendingText = claudeBillingPendingText(billing.pending);
|
|
91
|
+
if (inFlight) {
|
|
92
|
+
status = 'Switching…';
|
|
93
|
+
statusKind = 'pending';
|
|
94
|
+
} else if (pendingText) {
|
|
95
|
+
status = pendingText;
|
|
96
|
+
statusKind = 'pending';
|
|
97
|
+
} else if (warnCache && current === 'claude-api') {
|
|
98
|
+
status = CLAUDE_BILLING_CACHE_WARNING;
|
|
99
|
+
statusKind = 'warning';
|
|
100
|
+
} else if (current === 'claude' && target === 'claude-api' && !allowed && switchable.code === 'CLAUDE_API_CAP_REACHED') {
|
|
101
|
+
status = CLAUDE_BILLING_CAP_TEXT;
|
|
102
|
+
statusKind = 'warning';
|
|
103
|
+
}
|
|
104
|
+
return { visible: true, current, options, status, statusKind };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The notice after a switch the relay accepted. */
|
|
108
|
+
export function claudeBillingSwitchedText(to) {
|
|
109
|
+
return normalizeBilling(to) === 'claude-api'
|
|
110
|
+
? `This chat runs on the API key from the next turn. ${CLAUDE_BILLING_CACHE_WARNING}`
|
|
111
|
+
: 'This chat runs on the subscription from the next turn.';
|
|
112
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Mirrors shared/model-id.mjs, which the browser cannot import (only
|
|
2
2
|
// server/public is served).
|
|
3
3
|
const CLAUDE_LONG_CONTEXT_LIMIT_TOKENS = 1000000;
|
|
4
|
+
// Mirrors shared/deepseek-models.mjs for the same reason.
|
|
5
|
+
const DEEPSEEK_CONTEXT_WINDOW_TOKENS = 1048576;
|
|
4
6
|
export const UNKNOWN_WINDOW_LABEL = '—';
|
|
5
7
|
|
|
6
8
|
// Shared by the context chip and both model pickers so a window reads the same
|
|
@@ -44,7 +46,9 @@ function claudeTiersForModel(modelId, claudeTiers) {
|
|
|
44
46
|
* windows on a Claude conversation. For the Claude provider the offered tiers
|
|
45
47
|
* are exactly the enabled catalog ids the server folded into
|
|
46
48
|
* claudeContextTiersByModel: base id → default, "[1m]" id → long_context, and a
|
|
47
|
-
* model may legitimately have only one of the two.
|
|
49
|
+
* model may legitimately have only one of the two. Claude API (`claude-api`)
|
|
50
|
+
* serves the same ids on an API key, with its own folded list
|
|
51
|
+
* (claudeApiContextTiersByModel), which the caller passes as `claudeTiers`.
|
|
48
52
|
*/
|
|
49
53
|
export function buildContextTierOptions({
|
|
50
54
|
modelId = '',
|
|
@@ -59,7 +63,11 @@ export function buildContextTierOptions({
|
|
|
59
63
|
// metadata either (that number is Copilot's): one blank default, as for a
|
|
60
64
|
// Claude model whose window is unknown.
|
|
61
65
|
if (provider === 'claude-cloud') return [{ value: 'default', label: UNKNOWN_WINDOW_LABEL }];
|
|
62
|
-
|
|
66
|
+
// DeepSeek's models have one window, 1M: no tier to choose.
|
|
67
|
+
if (provider === 'deepseek') return [{ value: 'default', label: tokenLabel(DEEPSEEK_CONTEXT_WINDOW_TOKENS) }];
|
|
68
|
+
const claudeTierList = provider === 'claude' || provider === 'claude-api'
|
|
69
|
+
? claudeTiersForModel(modelId, claudeTiers)
|
|
70
|
+
: null;
|
|
63
71
|
if (claudeTierList) {
|
|
64
72
|
return claudeTierList
|
|
65
73
|
.map((tier) => String(tier?.value || '').trim().toLowerCase())
|
|
@@ -352,7 +352,7 @@ ${renderThinkingButtons('display', THINKING_DISPLAY_CHOICES, displayKey)}
|
|
|
352
352
|
|
|
353
353
|
/**
|
|
354
354
|
* The owner's switch for a session an agent started: may its agents start
|
|
355
|
-
* sessions themselves (the
|
|
355
|
+
* sessions themselves (the relay_tool's one-level rule lifted for it)?
|
|
356
356
|
* Only the owner sets it; no agent can.
|
|
357
357
|
*/
|
|
358
358
|
export function renderAgentSessionsControlHtml({ conversationId = '', mayCreateSessions = false } = {}) {
|
|
@@ -23,6 +23,16 @@ export function isConversationUsingGrokProvider(conversation = null) {
|
|
|
23
23
|
return resolveConversationProviderType(conversation) === 'grok';
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
export function isConversationUsingDeepSeekProvider(conversation = null) {
|
|
27
|
+
return resolveConversationProviderType(conversation) === 'deepseek';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// A Claude chat on the saved Claude Console key: the same Claude worker as
|
|
31
|
+
// `claude`, on another bill. A chat can switch between the two.
|
|
32
|
+
export function isConversationUsingClaudeApiProvider(conversation = null) {
|
|
33
|
+
return resolveConversationProviderType(conversation) === 'claude-api';
|
|
34
|
+
}
|
|
35
|
+
|
|
26
36
|
// Its own provider, never a flavour of `claude`: the session runs in a sandbox
|
|
27
37
|
// at Anthropic, so nothing that is true of a local Claude worker (modes,
|
|
28
38
|
// effort, steering, a working directory) may be assumed for it.
|
|
@@ -36,9 +46,11 @@ const CLAUDE_CLOUD_MARKER = '☁';
|
|
|
36
46
|
const PROVIDER_NAMES = Object.freeze({
|
|
37
47
|
openai: 'OpenAI',
|
|
38
48
|
claude: 'Claude',
|
|
49
|
+
'claude-api': 'Claude API',
|
|
39
50
|
'claude-cloud': 'Claude Cloud',
|
|
40
51
|
cursor: 'Cursor',
|
|
41
52
|
grok: 'Grok',
|
|
53
|
+
deepseek: 'DeepSeek',
|
|
42
54
|
github: 'Copilot',
|
|
43
55
|
'github-copilot': 'Copilot',
|
|
44
56
|
});
|
|
@@ -74,8 +86,12 @@ const SESSION_LOCK_LABELS = {
|
|
|
74
86
|
openai: 'OpenAI',
|
|
75
87
|
'openai-image': 'OpenAI Image',
|
|
76
88
|
claude: 'Claude SDK',
|
|
89
|
+
// The same Claude SDK models as `claude`, named for the bill, as the pill
|
|
90
|
+
// and New Chat name it.
|
|
91
|
+
'claude-api': 'Claude API',
|
|
77
92
|
cursor: 'Cursor SDK',
|
|
78
93
|
grok: 'Grok',
|
|
94
|
+
deepseek: 'DeepSeek',
|
|
79
95
|
'claude-cloud': `${CLAUDE_CLOUD_MARKER} Claude Cloud`,
|
|
80
96
|
};
|
|
81
97
|
|
|
@@ -93,9 +109,11 @@ export function sessionLockProviderKey({ providerType = '', model = '' } = {}) {
|
|
|
93
109
|
}
|
|
94
110
|
if (normalized === 'openai-image' || normalized === 'openai-image-byok') return 'openai-image';
|
|
95
111
|
if (normalized === 'claude' || normalized === 'claude-agent-sdk' || normalized === 'anthropic') return 'claude';
|
|
112
|
+
if (normalized === 'claude-api') return 'claude-api';
|
|
96
113
|
if (normalized === 'claude-cloud') return 'claude-cloud';
|
|
97
114
|
if (normalized === 'cursor') return 'cursor';
|
|
98
115
|
if (normalized === 'grok' || normalized === 'xai' || normalized === 'xai-grok') return 'grok';
|
|
116
|
+
if (normalized === 'deepseek') return 'deepseek';
|
|
99
117
|
if (normalized === 'github' || normalized === 'github-copilot') return 'github';
|
|
100
118
|
return '';
|
|
101
119
|
}
|
|
@@ -81,6 +81,9 @@ import { stripRemoteRelayDisplayArtifacts } from './relay-prompt-sanitizer.mjs';
|
|
|
81
81
|
import { evaluateUnknownCommandGuard } from './slash-commands.mjs';
|
|
82
82
|
import { attachCodeCopyButtons } from './code-copy.mjs';
|
|
83
83
|
import { relayErrorCtaActions } from './relay-error-ctas.mjs';
|
|
84
|
+
import { switchClaudeBilling } from './claude-billing-toggle-ui.js';
|
|
85
|
+
import { CLAUDE_API_BILLING_URL, CLAUDE_API_LIMITS_URL, focusClaudeApiCapInput } from './claude-api-settings-ui.js';
|
|
86
|
+
import { knownConversationBilling, noteConversationBilling } from './usage-limit-ui.js';
|
|
84
87
|
import { confirmCliInstall } from './cli-install-ui.js';
|
|
85
88
|
import { startGrokSignIn } from './grok-auth-ui.js';
|
|
86
89
|
import { openSettingsModal } from './settings-modal.js';
|
|
@@ -3508,8 +3511,9 @@ async function toggleMessageShareVisibility(conversationId, messageId, hiddenFro
|
|
|
3508
3511
|
|
|
3509
3512
|
// Each CTA lands the user where the state actually lives: the provider panel
|
|
3510
3513
|
// shows the install log / the device code, so the settings modal is opened
|
|
3511
|
-
// first and the action then runs on top of it.
|
|
3512
|
-
|
|
3514
|
+
// first and the action then runs on top of it. New Chat runs them too, for a
|
|
3515
|
+
// refusal of its own (journal-view.js).
|
|
3516
|
+
export function runRelayErrorCta(cta) {
|
|
3513
3517
|
if (cta === 'install-grok-cli') {
|
|
3514
3518
|
openSettingsModal('providers', 'grok');
|
|
3515
3519
|
// The confirm sheet — never a bare curl | bash from a chat bubble.
|
|
@@ -3523,6 +3527,17 @@ function runRelayErrorCta(cta) {
|
|
|
3523
3527
|
}
|
|
3524
3528
|
if (cta === 'open-grok-settings') openSettingsModal('providers', 'grok');
|
|
3525
3529
|
if (cta === 'open-claude-settings') openSettingsModal('providers', 'claude');
|
|
3530
|
+
if (cta === 'open-deepseek-settings') openSettingsModal('providers', 'deepseek');
|
|
3531
|
+
if (cta === 'open-deepseek-top-up') window.open('https://platform.deepseek.com/top_up', '_blank', 'noopener');
|
|
3532
|
+
if (cta === 'open-claude-api-settings') openSettingsModal('providers', 'claude-api');
|
|
3533
|
+
if (cta === 'open-claude-api-cap') {
|
|
3534
|
+
openSettingsModal('providers', 'claude-api');
|
|
3535
|
+
focusClaudeApiCapInput();
|
|
3536
|
+
}
|
|
3537
|
+
if (cta === 'open-claude-api-billing') window.open(CLAUDE_API_BILLING_URL, '_blank', 'noopener');
|
|
3538
|
+
if (cta === 'open-claude-api-limits') window.open(CLAUDE_API_LIMITS_URL, '_blank', 'noopener');
|
|
3539
|
+
// The chat this note is in, not a chat opened since: the bubble is on screen.
|
|
3540
|
+
if (cta === 'switch-claude-billing-to-subscription' && currentConvId) void switchClaudeBilling(currentConvId, 'claude');
|
|
3526
3541
|
}
|
|
3527
3542
|
|
|
3528
3543
|
function handleBubbleActionClick(event) {
|
|
@@ -4613,6 +4628,10 @@ export async function sendMessage() {
|
|
|
4613
4628
|
relayMode: selectedMode,
|
|
4614
4629
|
conversationId: targetConversationId || undefined,
|
|
4615
4630
|
newConversation: isNew || undefined,
|
|
4631
|
+
// The billing this page shows for a Claude chat: the relay refuses the
|
|
4632
|
+
// send when the chat has moved to the other one since, so nobody's
|
|
4633
|
+
// turn runs on a bill they did not see.
|
|
4634
|
+
billing: (!isNew && targetConversationId && knownConversationBilling(targetConversationId)) || undefined,
|
|
4616
4635
|
attachments,
|
|
4617
4636
|
imageTarget: draftImageEditTarget
|
|
4618
4637
|
? {
|
|
@@ -4626,7 +4645,16 @@ export async function sendMessage() {
|
|
|
4626
4645
|
// A refusal the user can act on (the conversation's model left the
|
|
4627
4646
|
// catalog) is shown as the relay words it.
|
|
4628
4647
|
let refusal = '';
|
|
4629
|
-
const r = await sendMessageApi(body, {
|
|
4648
|
+
const r = await sendMessageApi(body, {
|
|
4649
|
+
onRefused: (reason, payload) => {
|
|
4650
|
+
refusal = reason;
|
|
4651
|
+
// The chat's billing changed under this page: it learns the new one,
|
|
4652
|
+
// so sending again goes through.
|
|
4653
|
+
if (payload?.code === 'CLAUDE_BILLING_CHANGED' && targetConversationId) {
|
|
4654
|
+
noteConversationBilling(targetConversationId, payload.billing);
|
|
4655
|
+
}
|
|
4656
|
+
},
|
|
4657
|
+
});
|
|
4630
4658
|
if (!r) {
|
|
4631
4659
|
// Offline: park the send in the durable outbox instead of bouncing it
|
|
4632
4660
|
// back into the composer. The client-generated messageId makes a replay
|
|
@@ -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();
|