@polderlabs/bizar 4.4.11 → 4.4.13
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/bizar-dash/dist/assets/main-BB5mJurD.js +352 -0
- package/bizar-dash/dist/assets/main-BB5mJurD.js.map +1 -0
- package/bizar-dash/dist/assets/{main-Nq8Dq3VR.css → main-BsnQLXdh.css} +1 -1
- package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js.map +1 -1
- package/bizar-dash/dist/index.html +2 -2
- package/bizar-dash/src/server/api.mjs +2 -0
- package/bizar-dash/src/server/minimax.mjs +478 -0
- package/bizar-dash/src/server/providers-store.mjs +10 -6
- package/bizar-dash/src/server/routes/_shared.mjs +15 -0
- package/bizar-dash/src/server/routes/minimax.mjs +133 -0
- package/bizar-dash/src/web/App.tsx +2 -0
- package/bizar-dash/src/web/components/Topbar.tsx +2 -0
- package/bizar-dash/src/web/lib/types.ts +12 -0
- package/bizar-dash/src/web/main.tsx +1 -0
- package/bizar-dash/src/web/styles/minimax-usage.css +507 -0
- package/bizar-dash/src/web/views/MiniMaxUsage.tsx +908 -0
- package/cli/bin.mjs +263 -3
- package/package.json +1 -1
- package/bizar-dash/dist/assets/main-CEazNxxy.js +0 -347
- package/bizar-dash/dist/assets/main-CEazNxxy.js.map +0 -1
|
@@ -0,0 +1,908 @@
|
|
|
1
|
+
// src/views/MiniMaxUsage.tsx — v4.5.0
|
|
2
|
+
//
|
|
3
|
+
// Token Plan usage tracking dashboard.
|
|
4
|
+
//
|
|
5
|
+
// Renders the user's current 5-hour rolling + weekly remaining quota
|
|
6
|
+
// per model, fetched from https://www.minimax.io/v1/token_plan/remains.
|
|
7
|
+
// The Subscription Key is read from opencode's canonical auth store
|
|
8
|
+
// (`~/.local/share/opencode/auth.json`) — same place opencode's
|
|
9
|
+
// `/connect` command writes to — so the user only enters the key once
|
|
10
|
+
// and both opencode + the Bizar dashboard pick it up.
|
|
11
|
+
//
|
|
12
|
+
// The view has three states:
|
|
13
|
+
// 1. Onboarding wizard (first time, no key configured) — guided flow
|
|
14
|
+
// that takes the user from "get a key" → paste → save → dashboard
|
|
15
|
+
// 2. Live dashboard (key configured) — per-model 5h + weekly usage
|
|
16
|
+
// 3. Banner only (key invalid or auth failure) — inline error
|
|
17
|
+
|
|
18
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
19
|
+
import {
|
|
20
|
+
Coins,
|
|
21
|
+
RefreshCw,
|
|
22
|
+
AlertTriangle,
|
|
23
|
+
CheckCircle2,
|
|
24
|
+
KeyRound,
|
|
25
|
+
Send,
|
|
26
|
+
Loader2,
|
|
27
|
+
Calendar,
|
|
28
|
+
Activity,
|
|
29
|
+
Save,
|
|
30
|
+
Eye,
|
|
31
|
+
EyeOff,
|
|
32
|
+
Sparkles,
|
|
33
|
+
X,
|
|
34
|
+
ExternalLink,
|
|
35
|
+
ShieldCheck,
|
|
36
|
+
CircleCheck,
|
|
37
|
+
} from 'lucide-react';
|
|
38
|
+
import { Card, CardTitle, CardMeta } from '../components/Card';
|
|
39
|
+
import { Button } from '../components/Button';
|
|
40
|
+
import { Spinner } from '../components/Spinner';
|
|
41
|
+
import { useToast } from '../components/Toast';
|
|
42
|
+
import { api } from '../lib/api';
|
|
43
|
+
import { cn } from '../lib/utils';
|
|
44
|
+
import type { Settings, Snapshot } from '../lib/types';
|
|
45
|
+
// Settings is passed for forward-compat with the App's view-props shape
|
|
46
|
+
// even though the read path no longer needs it.
|
|
47
|
+
|
|
48
|
+
type Props = {
|
|
49
|
+
snapshot: Snapshot;
|
|
50
|
+
settings: Settings;
|
|
51
|
+
activeTab: string;
|
|
52
|
+
setActiveTab: (id: string) => void;
|
|
53
|
+
refreshSnapshot: () => Promise<void>;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Suppress unused-var — `settings` is kept in the prop signature so
|
|
57
|
+
// App.tsx can pass a single shape; the read path doesn't need it now
|
|
58
|
+
// that the key comes from opencode's auth.json.
|
|
59
|
+
void (null as unknown as Settings | null);
|
|
60
|
+
|
|
61
|
+
type RemainsModel = {
|
|
62
|
+
model_name: string;
|
|
63
|
+
current_interval_remaining_percent: number;
|
|
64
|
+
current_weekly_remaining_percent: number;
|
|
65
|
+
current_interval_consumed_percent?: number;
|
|
66
|
+
weekly_consumed_percent?: number;
|
|
67
|
+
current_interval_status: number;
|
|
68
|
+
current_weekly_status: number;
|
|
69
|
+
current_interval_total_count: number;
|
|
70
|
+
current_interval_usage_count: number;
|
|
71
|
+
current_weekly_total_count: number;
|
|
72
|
+
current_weekly_usage_count: number;
|
|
73
|
+
endTimeISO?: string;
|
|
74
|
+
weeklyEndTimeISO?: string;
|
|
75
|
+
intervalResetInMs?: number;
|
|
76
|
+
weeklyResetInMs?: number;
|
|
77
|
+
intervalResetInHuman?: string;
|
|
78
|
+
weeklyResetInHuman?: string;
|
|
79
|
+
intervalUsed?: number;
|
|
80
|
+
intervalTotal?: number;
|
|
81
|
+
weeklyUsed?: number;
|
|
82
|
+
weeklyTotal?: number;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
type RemainsSnapshot = {
|
|
86
|
+
ok: boolean;
|
|
87
|
+
cached?: boolean;
|
|
88
|
+
fetchedAt?: number;
|
|
89
|
+
apiKeyHint?: string;
|
|
90
|
+
keySource?: string;
|
|
91
|
+
groupId?: string;
|
|
92
|
+
baseUrl?: string;
|
|
93
|
+
models?: RemainsModel[];
|
|
94
|
+
baseResp?: { status_code: number; status_msg: string };
|
|
95
|
+
error?: string;
|
|
96
|
+
message?: string;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
type Status = {
|
|
100
|
+
configured: boolean;
|
|
101
|
+
apiKeyHint: string;
|
|
102
|
+
source: string;
|
|
103
|
+
groupId: string;
|
|
104
|
+
tokenBaseUrl: string;
|
|
105
|
+
chatBaseUrl: string;
|
|
106
|
+
knownModels: string[];
|
|
107
|
+
keyPatternValid: boolean | null;
|
|
108
|
+
cache: { fetchedAt: number; apiKeyHint: string; modelCount: number } | null;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
type OnboardingState = {
|
|
112
|
+
dismissedAt: number | null;
|
|
113
|
+
hiddenModels: string[];
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
type TestResult = {
|
|
117
|
+
ok: boolean;
|
|
118
|
+
error?: string;
|
|
119
|
+
message?: string;
|
|
120
|
+
model?: string;
|
|
121
|
+
content?: string;
|
|
122
|
+
reasoning?: string;
|
|
123
|
+
finishReason?: string;
|
|
124
|
+
usage?: {
|
|
125
|
+
total_tokens?: number;
|
|
126
|
+
prompt_tokens?: number;
|
|
127
|
+
completion_tokens?: number;
|
|
128
|
+
prompt_tokens_details?: { cached_tokens?: number };
|
|
129
|
+
};
|
|
130
|
+
status?: number;
|
|
131
|
+
raw?: unknown;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export function MiniMaxUsage({ snapshot, settings, setActiveTab, refreshSnapshot }: Props) {
|
|
135
|
+
const toast = useToast();
|
|
136
|
+
const [status, setStatus] = useState<Status | null>(null);
|
|
137
|
+
const [remains, setRemains] = useState<RemainsSnapshot | null>(null);
|
|
138
|
+
const [onboarding, setOnboarding] = useState<OnboardingState | null>(null);
|
|
139
|
+
const [loading, setLoading] = useState(true);
|
|
140
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
141
|
+
const [error, setError] = useState<string | null>(null);
|
|
142
|
+
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
|
143
|
+
const [testing, setTesting] = useState(false);
|
|
144
|
+
// Onboarding wizard state.
|
|
145
|
+
const [wizardStep, setWizardStep] = useState(0); // 0..3 (welcome → key → verify → done)
|
|
146
|
+
const [wizardVerifying, setWizardVerifying] = useState(false);
|
|
147
|
+
const [wizardVerifyResult, setWizardVerifyResult] = useState<TestResult | null>(null);
|
|
148
|
+
// Inline key config — saves a trip to Settings.
|
|
149
|
+
const [keyDraft, setKeyDraft] = useState('');
|
|
150
|
+
const [showKey, setShowKey] = useState(false);
|
|
151
|
+
const [savingKey, setSavingKey] = useState(false);
|
|
152
|
+
|
|
153
|
+
const load = useCallback(async () => {
|
|
154
|
+
setLoading(true);
|
|
155
|
+
setError(null);
|
|
156
|
+
try {
|
|
157
|
+
const [s, r, o] = await Promise.all([
|
|
158
|
+
api.get<Status>('/minimax/status'),
|
|
159
|
+
api.get<RemainsSnapshot>('/minimax/remains'),
|
|
160
|
+
api.get<OnboardingState>('/minimax/onboarding'),
|
|
161
|
+
]);
|
|
162
|
+
setStatus(s);
|
|
163
|
+
setRemains(r);
|
|
164
|
+
setOnboarding(o);
|
|
165
|
+
setKeyDraft(''); // never prefill — security
|
|
166
|
+
// If we just configured a key (dismissedAt is set but not configured)
|
|
167
|
+
// or the user already dismissed, do not show the wizard.
|
|
168
|
+
if (s.configured) setWizardStep(4); // skip past wizard
|
|
169
|
+
} catch (err) {
|
|
170
|
+
setError((err as Error).message || 'Failed to load MiniMax data');
|
|
171
|
+
} finally {
|
|
172
|
+
setLoading(false);
|
|
173
|
+
}
|
|
174
|
+
}, []);
|
|
175
|
+
|
|
176
|
+
// Dismiss the onboarding wizard (so it doesn't show again until the
|
|
177
|
+
// user clears their key).
|
|
178
|
+
const dismissWizard = useCallback(async () => {
|
|
179
|
+
try {
|
|
180
|
+
await api.post('/minimax/onboarding', { dismissedAt: Date.now() });
|
|
181
|
+
setOnboarding((o) => o ? { ...o, dismissedAt: Date.now() } : o);
|
|
182
|
+
} catch { /* best-effort */ }
|
|
183
|
+
}, []);
|
|
184
|
+
|
|
185
|
+
// Wizard step 2: test the key against the real API before saving.
|
|
186
|
+
const verifyKeyBeforeSave = useCallback(async () => {
|
|
187
|
+
if (!keyDraft.trim()) {
|
|
188
|
+
toast.error('Paste a Subscription Key first.');
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
setWizardVerifying(true);
|
|
192
|
+
setWizardVerifyResult(null);
|
|
193
|
+
try {
|
|
194
|
+
// Temporarily save to the real auth.json, probe, then if the
|
|
195
|
+
// probe fails the user can re-paste. The probe endpoint is the
|
|
196
|
+
// chat completions surface (cheaper than remains + a real-world
|
|
197
|
+
// check that the key actually works for the dashboard's purpose).
|
|
198
|
+
const r = await api.post<TestResult>('/minimax/test', {
|
|
199
|
+
prompt: 'Reply with a single word: pong',
|
|
200
|
+
model: 'MiniMax-M3',
|
|
201
|
+
maxTokens: 16,
|
|
202
|
+
});
|
|
203
|
+
setWizardVerifyResult(r);
|
|
204
|
+
if (r.ok) {
|
|
205
|
+
setWizardStep(3); // success
|
|
206
|
+
toast.success('Key works. Saving…');
|
|
207
|
+
// Auto-save: the user already proved the key works.
|
|
208
|
+
try {
|
|
209
|
+
await api.post('/minimax/onboarding/save-key', {
|
|
210
|
+
key: keyDraft.trim(),
|
|
211
|
+
groupId: 'default',
|
|
212
|
+
});
|
|
213
|
+
clearRemainsCacheClient();
|
|
214
|
+
toast.success('Subscription Key saved.');
|
|
215
|
+
} catch (err) {
|
|
216
|
+
toast.error(`Save failed: ${(err as Error).message}`);
|
|
217
|
+
}
|
|
218
|
+
void load();
|
|
219
|
+
} else {
|
|
220
|
+
toast.error(`Verification failed: ${r.message ?? r.error ?? 'unknown'}`);
|
|
221
|
+
}
|
|
222
|
+
} catch (err) {
|
|
223
|
+
toast.error(`Test request failed: ${(err as Error).message}`);
|
|
224
|
+
} finally {
|
|
225
|
+
setWizardVerifying(false);
|
|
226
|
+
}
|
|
227
|
+
}, [keyDraft, toast, load]);
|
|
228
|
+
|
|
229
|
+
useEffect(() => { load(); }, [load]);
|
|
230
|
+
|
|
231
|
+
// Persist the key into opencode's auth.json. The server-side route
|
|
232
|
+
// `POST /api/minimax/onboarding/save-key` writes to
|
|
233
|
+
// `~/.local/share/opencode/auth.json` (the canonical store the
|
|
234
|
+
// opencode `/connect` command writes to). The dashboard reads it
|
|
235
|
+
// back on the next status fetch.
|
|
236
|
+
const saveKey = useCallback(async () => {
|
|
237
|
+
if (!keyDraft.trim()) {
|
|
238
|
+
toast.error('Paste a Subscription Key first.');
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
setSavingKey(true);
|
|
242
|
+
try {
|
|
243
|
+
const r = await api.post<{ ok: boolean; path?: string; apiKeyHint?: string; error?: string; message?: string }>(
|
|
244
|
+
'/minimax/onboarding/save-key',
|
|
245
|
+
{ key: keyDraft.trim(), groupId: 'default' },
|
|
246
|
+
);
|
|
247
|
+
if (!r.ok) {
|
|
248
|
+
toast.error(`Save failed: ${r.message ?? r.error ?? 'unknown'}`);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
setKeyDraft('');
|
|
252
|
+
clearRemainsCacheClient();
|
|
253
|
+
toast.success('Subscription Key saved. Loading quota…');
|
|
254
|
+
void load();
|
|
255
|
+
} catch (err) {
|
|
256
|
+
toast.error(`Save failed: ${(err as Error).message}`);
|
|
257
|
+
} finally {
|
|
258
|
+
setSavingKey(false);
|
|
259
|
+
}
|
|
260
|
+
}, [keyDraft, toast, load]);
|
|
261
|
+
|
|
262
|
+
// Clears the on-disk + in-memory cache via the dashboard's
|
|
263
|
+
// /api/minimax/cache DELETE endpoint. Wrapped in a helper so the
|
|
264
|
+
// success path can await the round-trip.
|
|
265
|
+
const clearRemainsCacheClient = useCallback(async () => {
|
|
266
|
+
try { await api.del('/minimax/cache'); } catch { /* best-effort */ }
|
|
267
|
+
}, []);
|
|
268
|
+
|
|
269
|
+
const refresh = useCallback(async () => {
|
|
270
|
+
setRefreshing(true);
|
|
271
|
+
setError(null);
|
|
272
|
+
try {
|
|
273
|
+
await api.post('/minimax/remains/refresh');
|
|
274
|
+
await load();
|
|
275
|
+
toast.success('Refreshed');
|
|
276
|
+
} catch (err) {
|
|
277
|
+
toast.error(`Refresh failed: ${(err as Error).message}`);
|
|
278
|
+
} finally {
|
|
279
|
+
setRefreshing(false);
|
|
280
|
+
}
|
|
281
|
+
}, [load, toast]);
|
|
282
|
+
|
|
283
|
+
const sendTest = useCallback(async () => {
|
|
284
|
+
setTesting(true);
|
|
285
|
+
setTestResult(null);
|
|
286
|
+
try {
|
|
287
|
+
const r = await api.post<TestResult>('/minimax/test', {
|
|
288
|
+
prompt: 'Reply with a single word: pong',
|
|
289
|
+
model: 'MiniMax-M3',
|
|
290
|
+
maxTokens: 16,
|
|
291
|
+
});
|
|
292
|
+
setTestResult(r);
|
|
293
|
+
if (r.ok) {
|
|
294
|
+
toast.success(`Test ok — used ${r.usage?.total_tokens ?? '?'} tokens`);
|
|
295
|
+
} else {
|
|
296
|
+
toast.error(`Test failed: ${r.message ?? r.error ?? 'unknown'}`);
|
|
297
|
+
}
|
|
298
|
+
} catch (err) {
|
|
299
|
+
toast.error(`Test request failed: ${(err as Error).message}`);
|
|
300
|
+
} finally {
|
|
301
|
+
setTesting(false);
|
|
302
|
+
}
|
|
303
|
+
}, [toast]);
|
|
304
|
+
|
|
305
|
+
if (loading && !status) {
|
|
306
|
+
return (
|
|
307
|
+
<div className="view-container">
|
|
308
|
+
<Spinner /> Loading MiniMax data…
|
|
309
|
+
</div>
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (!status) {
|
|
314
|
+
return (
|
|
315
|
+
<div className="view-container">
|
|
316
|
+
<div className="error-card">
|
|
317
|
+
<AlertTriangle size={20} />
|
|
318
|
+
<div>
|
|
319
|
+
<strong>Couldn't load MiniMax status</strong>
|
|
320
|
+
<pre>{error ?? 'unknown error'}</pre>
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// First-run onboarding: show the wizard when no key is configured and
|
|
328
|
+
// the user hasn't dismissed it. The wizard writes the key to opencode's
|
|
329
|
+
// auth store (same place `/connect` writes), then the dashboard reads
|
|
330
|
+
// it back via /minimax/status and the live dashboard takes over.
|
|
331
|
+
const showWizard = !status.configured
|
|
332
|
+
&& (onboarding?.dismissedAt == null)
|
|
333
|
+
&& wizardStep < 4;
|
|
334
|
+
if (showWizard) {
|
|
335
|
+
return <OnboardingWizard
|
|
336
|
+
step={wizardStep}
|
|
337
|
+
setStep={setWizardStep}
|
|
338
|
+
keyDraft={keyDraft}
|
|
339
|
+
setKeyDraft={setKeyDraft}
|
|
340
|
+
showKey={showKey}
|
|
341
|
+
setShowKey={setShowKey}
|
|
342
|
+
verifying={wizardVerifying}
|
|
343
|
+
verifyResult={wizardVerifyResult}
|
|
344
|
+
onVerify={verifyKeyBeforeSave}
|
|
345
|
+
onSkip={async () => {
|
|
346
|
+
await dismissWizard();
|
|
347
|
+
toast.success("Skipped. You can paste the key anytime from this page.");
|
|
348
|
+
}}
|
|
349
|
+
onManualKeySave={saveKey}
|
|
350
|
+
savingKey={savingKey}
|
|
351
|
+
status={status}
|
|
352
|
+
/>;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return (
|
|
356
|
+
<div className="view-container view-minimax-usage">
|
|
357
|
+
{/* ── Header ─────────────────────────────────────────────────── */}
|
|
358
|
+
<header className="view-header">
|
|
359
|
+
<div className="view-header-titles">
|
|
360
|
+
<h1 className="view-title">
|
|
361
|
+
<Coins size={20} style={{ verticalAlign: 'text-bottom', marginRight: 6 }} />
|
|
362
|
+
MiniMax Token Plan
|
|
363
|
+
</h1>
|
|
364
|
+
<p className="view-subtitle">
|
|
365
|
+
Remaining 5-hour + weekly quota per model. Fetches live from{' '}
|
|
366
|
+
<code>www.minimax.io/v1/token_plan/remains</code>.
|
|
367
|
+
</p>
|
|
368
|
+
</div>
|
|
369
|
+
<div className="view-header-actions">
|
|
370
|
+
<Button
|
|
371
|
+
variant="secondary"
|
|
372
|
+
size="sm"
|
|
373
|
+
onClick={() => setActiveTab('settings')}
|
|
374
|
+
title="Configure your Subscription Key"
|
|
375
|
+
>
|
|
376
|
+
<KeyRound size={14} /> {status.configured ? `Key ${status.apiKeyHint}` : 'Add key'}
|
|
377
|
+
</Button>
|
|
378
|
+
<Button variant="secondary" size="sm" onClick={refresh} disabled={refreshing}>
|
|
379
|
+
{refreshing ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
|
|
380
|
+
Refresh
|
|
381
|
+
</Button>
|
|
382
|
+
</div>
|
|
383
|
+
</header>
|
|
384
|
+
|
|
385
|
+
{/* ── Status banners ─────────────────────────────────────────── */}
|
|
386
|
+
{remains && !remains.ok && (
|
|
387
|
+
<div className="banner banner-err">
|
|
388
|
+
<AlertTriangle size={16} />
|
|
389
|
+
<span>
|
|
390
|
+
<strong>Couldn't load quota</strong>: {remains.message ?? remains.error ?? 'unknown error'}
|
|
391
|
+
{status.source && (
|
|
392
|
+
<> · source: <code>{status.source}</code></>
|
|
393
|
+
)}
|
|
394
|
+
</span>
|
|
395
|
+
</div>
|
|
396
|
+
)}
|
|
397
|
+
{status.configured && status.keyPatternValid === false && (
|
|
398
|
+
<div className="banner banner-warn">
|
|
399
|
+
<AlertTriangle size={16} />
|
|
400
|
+
<span>
|
|
401
|
+
The configured Subscription Key has an unexpected format (expected
|
|
402
|
+
<code> sk-cp-</code> / <code>sk-ant-</code> / <code>sk-or-</code> prefix).
|
|
403
|
+
Re-save it from this page.
|
|
404
|
+
</span>
|
|
405
|
+
</div>
|
|
406
|
+
)}
|
|
407
|
+
|
|
408
|
+
{/* ── Subscription Key config (inline) ──────────────────── */}
|
|
409
|
+
{(
|
|
410
|
+
<Card id="minimax-key">
|
|
411
|
+
<CardTitle>
|
|
412
|
+
<KeyRound size={14} /> Subscription Key
|
|
413
|
+
</CardTitle>
|
|
414
|
+
<CardMeta>
|
|
415
|
+
Get it from{' '}
|
|
416
|
+
<a
|
|
417
|
+
href="https://platform.minimax.io/user-center/payment/token-plan"
|
|
418
|
+
target="_blank"
|
|
419
|
+
rel="noreferrer"
|
|
420
|
+
className="minimax-link"
|
|
421
|
+
>
|
|
422
|
+
platform.minimax.io/user-center/payment/token-plan
|
|
423
|
+
</a>
|
|
424
|
+
. Stored in <code>~/.local/share/opencode/auth.json</code>;
|
|
425
|
+
never sent to any other host. Status:{' '}
|
|
426
|
+
{status.configured ? (
|
|
427
|
+
<strong className="is-ok">configured ({status.apiKeyHint})</strong>
|
|
428
|
+
) : (
|
|
429
|
+
<strong className="is-warn">not configured</strong>
|
|
430
|
+
)}
|
|
431
|
+
.
|
|
432
|
+
</CardMeta>
|
|
433
|
+
<div className="minimax-key-row">
|
|
434
|
+
<div className="minimax-key-input-wrap">
|
|
435
|
+
<KeyRound size={12} className="minimax-key-icon" />
|
|
436
|
+
<input
|
|
437
|
+
type={showKey ? 'text' : 'password'}
|
|
438
|
+
value={keyDraft}
|
|
439
|
+
onChange={(e) => setKeyDraft(e.target.value)}
|
|
440
|
+
placeholder={status.configured ? '•••• paste a new key to replace' : 'eyJhbGciOi...'}
|
|
441
|
+
spellCheck={false}
|
|
442
|
+
autoComplete="off"
|
|
443
|
+
className="minimax-key-input"
|
|
444
|
+
/>
|
|
445
|
+
<button
|
|
446
|
+
type="button"
|
|
447
|
+
onClick={() => setShowKey((v) => !v)}
|
|
448
|
+
className="minimax-key-toggle"
|
|
449
|
+
aria-label={showKey ? 'Hide key' : 'Show key'}
|
|
450
|
+
title={showKey ? 'Hide key' : 'Show key'}
|
|
451
|
+
>
|
|
452
|
+
{showKey ? <EyeOff size={13} /> : <Eye size={13} />}
|
|
453
|
+
</button>
|
|
454
|
+
</div>
|
|
455
|
+
<Button onClick={saveKey} disabled={savingKey || !keyDraft.trim()} size="sm">
|
|
456
|
+
{savingKey ? <Loader2 size={14} className="spin" /> : <Save size={14} />}
|
|
457
|
+
Save key
|
|
458
|
+
</Button>
|
|
459
|
+
</div>
|
|
460
|
+
</Card>
|
|
461
|
+
)}
|
|
462
|
+
|
|
463
|
+
{remains && !remains.ok && (
|
|
464
|
+
<div className="banner banner-err">
|
|
465
|
+
<AlertTriangle size={16} />
|
|
466
|
+
<span>
|
|
467
|
+
<strong>Couldn't load quota</strong>: {remains.message ?? remains.error ?? 'unknown error'}
|
|
468
|
+
</span>
|
|
469
|
+
</div>
|
|
470
|
+
)}
|
|
471
|
+
|
|
472
|
+
{/* ── Summary stats ─────────────────────────────────────────── */}
|
|
473
|
+
{remains?.ok && remains.models && (
|
|
474
|
+
<>
|
|
475
|
+
<div className="minimax-stats-row">
|
|
476
|
+
<Stat
|
|
477
|
+
label="Models tracked"
|
|
478
|
+
value={String(remains.models.length)}
|
|
479
|
+
hint="Distinct model quotas returned by the API"
|
|
480
|
+
/>
|
|
481
|
+
<Stat
|
|
482
|
+
label="Last fetched"
|
|
483
|
+
value={remains.fetchedAt ? relativeTime(remains.fetchedAt) : '—'}
|
|
484
|
+
hint="Refreshed on demand + every 60s"
|
|
485
|
+
/>
|
|
486
|
+
<Stat
|
|
487
|
+
label="Group"
|
|
488
|
+
value={remains.groupId ?? '—'}
|
|
489
|
+
hint="Usually 'default' for an individual team"
|
|
490
|
+
/>
|
|
491
|
+
<Stat
|
|
492
|
+
label="API key"
|
|
493
|
+
value={remains.apiKeyHint ?? '—'}
|
|
494
|
+
hint="Masked; the real key never leaves auth.json"
|
|
495
|
+
/>
|
|
496
|
+
</div>
|
|
497
|
+
|
|
498
|
+
{/* ── Per-model quota cards ──────────────────────────────── */}
|
|
499
|
+
<div className="minimax-models-grid">
|
|
500
|
+
{remains.models.map((m) => (
|
|
501
|
+
<ModelQuotaCard key={m.model_name} model={m} />
|
|
502
|
+
))}
|
|
503
|
+
</div>
|
|
504
|
+
</>
|
|
505
|
+
)}
|
|
506
|
+
|
|
507
|
+
{/* ── Test prompt card ─────────────────────────────────────── */}
|
|
508
|
+
{status.configured && (
|
|
509
|
+
<Card id="minimax-test">
|
|
510
|
+
<CardTitle>
|
|
511
|
+
<Send size={14} /> Test the API key
|
|
512
|
+
</CardTitle>
|
|
513
|
+
<CardMeta>
|
|
514
|
+
Sends a one-shot chat completion to{' '}
|
|
515
|
+
<code>{status.chatBaseUrl}/chat/completions</code> to verify the
|
|
516
|
+
key works and surface live token usage.
|
|
517
|
+
</CardMeta>
|
|
518
|
+
<div className="minimax-test-actions">
|
|
519
|
+
<Button onClick={sendTest} disabled={testing} variant="primary" size="sm">
|
|
520
|
+
{testing ? <Loader2 size={14} className="spin" /> : <Send size={14} />}
|
|
521
|
+
Send test prompt
|
|
522
|
+
</Button>
|
|
523
|
+
<code className="minimax-test-prompt">"Reply with a single word: pong"</code>
|
|
524
|
+
</div>
|
|
525
|
+
{testResult && (
|
|
526
|
+
<div className={cn('minimax-test-result', testResult.ok ? 'ok' : 'err')}>
|
|
527
|
+
{testResult.ok ? <CheckCircle2 size={14} /> : <AlertTriangle size={14} />}
|
|
528
|
+
<div className="minimax-test-body">
|
|
529
|
+
{testResult.ok ? (
|
|
530
|
+
<>
|
|
531
|
+
<div className="minimax-test-line">
|
|
532
|
+
<strong>model:</strong> <code>{testResult.model}</code> ·{' '}
|
|
533
|
+
<strong>finish:</strong> {testResult.finishReason ?? '—'}
|
|
534
|
+
</div>
|
|
535
|
+
{testResult.content && (
|
|
536
|
+
<div className="minimax-test-content">
|
|
537
|
+
<code>{testResult.content}</code>
|
|
538
|
+
</div>
|
|
539
|
+
)}
|
|
540
|
+
{testResult.usage && (
|
|
541
|
+
<div className="minimax-test-usage">
|
|
542
|
+
<strong>usage:</strong> total{' '}
|
|
543
|
+
<code>{testResult.usage.total_tokens ?? '?'}</code> · prompt{' '}
|
|
544
|
+
<code>{testResult.usage.prompt_tokens ?? '?'}</code> · completion{' '}
|
|
545
|
+
<code>{testResult.usage.completion_tokens ?? '?'}</code>
|
|
546
|
+
{testResult.usage.prompt_tokens_details?.cached_tokens != null && (
|
|
547
|
+
<> · cached <code>{testResult.usage.prompt_tokens_details.cached_tokens}</code></>
|
|
548
|
+
)}
|
|
549
|
+
</div>
|
|
550
|
+
)}
|
|
551
|
+
</>
|
|
552
|
+
) : (
|
|
553
|
+
<pre>{testResult.message ?? testResult.error ?? 'unknown error'}</pre>
|
|
554
|
+
)}
|
|
555
|
+
</div>
|
|
556
|
+
</div>
|
|
557
|
+
)}
|
|
558
|
+
</Card>
|
|
559
|
+
)}
|
|
560
|
+
</div>
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ─── Sub-components ────────────────────────────────────────────────────────
|
|
565
|
+
|
|
566
|
+
function Stat({ label, value, hint }: { label: string; value: string; hint?: string }) {
|
|
567
|
+
return (
|
|
568
|
+
<div className="stat">
|
|
569
|
+
<div className="stat-label">{label}</div>
|
|
570
|
+
<div className="stat-value">{value}</div>
|
|
571
|
+
{hint && <div className="stat-hint">{hint}</div>}
|
|
572
|
+
</div>
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function ModelQuotaCard({ model }: { model: RemainsModel }) {
|
|
577
|
+
const fivePct = clamp(model.current_interval_remaining_percent ?? 0, 0, 100);
|
|
578
|
+
const weekPct = clamp(model.current_weekly_remaining_percent ?? 0, 0, 100);
|
|
579
|
+
const fiveConsumed = clamp(100 - fivePct, 0, 100);
|
|
580
|
+
const weekConsumed = clamp(100 - weekPct, 0, 100);
|
|
581
|
+
return (
|
|
582
|
+
<Card id={`minimax-model-${model.model_name}`} className="minimax-model-card">
|
|
583
|
+
<div className="minimax-model-head">
|
|
584
|
+
<div className="minimax-model-name">{model.model_name}</div>
|
|
585
|
+
<div
|
|
586
|
+
className={cn(
|
|
587
|
+
'minimax-model-status',
|
|
588
|
+
fivePct < 25 || weekPct < 25 ? 'is-warn' : 'is-ok'
|
|
589
|
+
)}
|
|
590
|
+
>
|
|
591
|
+
{model.current_interval_status === 1 ? 'active' : 'idle'} ·{' '}
|
|
592
|
+
{model.current_weekly_status === 1 ? 'week active' : 'week idle'}
|
|
593
|
+
</div>
|
|
594
|
+
</div>
|
|
595
|
+
|
|
596
|
+
<div className="minimax-quota-rows">
|
|
597
|
+
<QuotaBar
|
|
598
|
+
icon={<Activity size={12} />}
|
|
599
|
+
label="5-hour rolling"
|
|
600
|
+
remainingPct={fivePct}
|
|
601
|
+
consumedPct={fiveConsumed}
|
|
602
|
+
used={model.current_interval_usage_count ?? 0}
|
|
603
|
+
total={model.current_interval_total_count ?? 0}
|
|
604
|
+
resetIn={model.intervalResetInHuman}
|
|
605
|
+
resetISO={model.endTimeISO}
|
|
606
|
+
/>
|
|
607
|
+
<QuotaBar
|
|
608
|
+
icon={<Calendar size={12} />}
|
|
609
|
+
label="Weekly"
|
|
610
|
+
remainingPct={weekPct}
|
|
611
|
+
consumedPct={weekConsumed}
|
|
612
|
+
used={model.current_weekly_usage_count ?? 0}
|
|
613
|
+
total={model.current_weekly_total_count ?? 0}
|
|
614
|
+
resetIn={model.weeklyResetInHuman}
|
|
615
|
+
resetISO={model.weeklyEndTimeISO}
|
|
616
|
+
/>
|
|
617
|
+
</div>
|
|
618
|
+
</Card>
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function QuotaBar({
|
|
623
|
+
icon,
|
|
624
|
+
label,
|
|
625
|
+
remainingPct,
|
|
626
|
+
consumedPct,
|
|
627
|
+
used,
|
|
628
|
+
total,
|
|
629
|
+
resetIn,
|
|
630
|
+
resetISO,
|
|
631
|
+
}: {
|
|
632
|
+
icon: React.ReactNode;
|
|
633
|
+
label: string;
|
|
634
|
+
remainingPct: number;
|
|
635
|
+
consumedPct: number;
|
|
636
|
+
used: number;
|
|
637
|
+
total: number;
|
|
638
|
+
resetIn?: string;
|
|
639
|
+
resetISO?: string;
|
|
640
|
+
}) {
|
|
641
|
+
const tone = remainingPct >= 75 ? 'good' : remainingPct >= 25 ? 'warn' : 'low';
|
|
642
|
+
return (
|
|
643
|
+
<div className={`minimax-quota-row is-${tone}`}>
|
|
644
|
+
<div className="minimax-quota-head">
|
|
645
|
+
<span className="minimax-quota-label">
|
|
646
|
+
{icon} {label}
|
|
647
|
+
</span>
|
|
648
|
+
<span className="minimax-quota-remaining">{remainingPct}% remaining</span>
|
|
649
|
+
</div>
|
|
650
|
+
<div className="minimax-bar">
|
|
651
|
+
<div
|
|
652
|
+
className="minimax-bar-fill"
|
|
653
|
+
style={{ width: `${consumedPct}%` }}
|
|
654
|
+
aria-label={`${consumedPct}% consumed`}
|
|
655
|
+
/>
|
|
656
|
+
</div>
|
|
657
|
+
<div className="minimax-quota-meta">
|
|
658
|
+
<span>
|
|
659
|
+
<strong>{used}</strong> / {total || '—'} requests
|
|
660
|
+
</span>
|
|
661
|
+
<span title={resetISO}>
|
|
662
|
+
{resetIn ? `resets in ${resetIn}` : '—'}
|
|
663
|
+
</span>
|
|
664
|
+
</div>
|
|
665
|
+
</div>
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function clamp(n: number, lo: number, hi: number) {
|
|
670
|
+
return Math.max(lo, Math.min(hi, n));
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function relativeTime(ts: number): string {
|
|
674
|
+
const ms = Date.now() - ts;
|
|
675
|
+
if (ms < 5_000) return 'just now';
|
|
676
|
+
if (ms < 60_000) return `${Math.floor(ms / 1000)}s ago`;
|
|
677
|
+
if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ago`;
|
|
678
|
+
return `${Math.floor(ms / 3_600_000)}h ago`;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// ─── Onboarding wizard ──────────────────────────────────────────────────
|
|
682
|
+
|
|
683
|
+
type OnboardingProps = {
|
|
684
|
+
step: number;
|
|
685
|
+
setStep: (n: number) => void;
|
|
686
|
+
keyDraft: string;
|
|
687
|
+
setKeyDraft: (s: string) => void;
|
|
688
|
+
showKey: boolean;
|
|
689
|
+
setShowKey: React.Dispatch<React.SetStateAction<boolean>>;
|
|
690
|
+
verifying: boolean;
|
|
691
|
+
verifyResult: TestResult | null;
|
|
692
|
+
onVerify: () => void;
|
|
693
|
+
onSkip: () => void;
|
|
694
|
+
onManualKeySave: () => void;
|
|
695
|
+
savingKey: boolean;
|
|
696
|
+
status: Status;
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* 4-step first-run wizard:
|
|
701
|
+
* 0. Welcome — what the page does, what the user needs.
|
|
702
|
+
* 1. Get a key — link to platform.minimax.io + how to find it.
|
|
703
|
+
* 2. Paste — input with show/hide + "Test key" button.
|
|
704
|
+
* 3. Done — confirms the save + "View your quota" CTA.
|
|
705
|
+
*
|
|
706
|
+
* The wizard writes to opencode's canonical auth store at
|
|
707
|
+
* ~/.local/share/opencode/auth.json (via /api/minimax/onboarding/save-key).
|
|
708
|
+
*/
|
|
709
|
+
function OnboardingWizard({
|
|
710
|
+
step, setStep, keyDraft, setKeyDraft, showKey, setShowKey,
|
|
711
|
+
verifying, verifyResult, onVerify, onSkip, onManualKeySave,
|
|
712
|
+
savingKey, status,
|
|
713
|
+
}: OnboardingProps) {
|
|
714
|
+
return (
|
|
715
|
+
<div className="view-container view-minimax-onboarding">
|
|
716
|
+
<header className="view-header">
|
|
717
|
+
<div className="view-header-titles">
|
|
718
|
+
<h1 className="view-title">
|
|
719
|
+
<Sparkles size={20} style={{ verticalAlign: 'text-bottom', marginRight: 6 }} />
|
|
720
|
+
Set up MiniMax Token Plan tracking
|
|
721
|
+
</h1>
|
|
722
|
+
<p className="view-subtitle">
|
|
723
|
+
We'll read your Subscription Key from opencode's auth store and show your remaining
|
|
724
|
+
5-hour + weekly quota for every MiniMax model.
|
|
725
|
+
</p>
|
|
726
|
+
</div>
|
|
727
|
+
<div className="view-header-actions">
|
|
728
|
+
<Button variant="ghost" size="sm" onClick={onSkip}>
|
|
729
|
+
<X size={14} /> Skip for now
|
|
730
|
+
</Button>
|
|
731
|
+
</div>
|
|
732
|
+
</header>
|
|
733
|
+
|
|
734
|
+
{/* ── Stepper ────────────────────────────────────────────── */}
|
|
735
|
+
<div className="minimax-wizard-stepper">
|
|
736
|
+
{(['Welcome', 'Get a key', 'Paste & test', 'Done']).map((label, i) => (
|
|
737
|
+
<div
|
|
738
|
+
key={label}
|
|
739
|
+
className={cn(
|
|
740
|
+
'minimax-wizard-step',
|
|
741
|
+
i === step && 'is-current',
|
|
742
|
+
i < step && 'is-done',
|
|
743
|
+
i > step && 'is-todo',
|
|
744
|
+
)}
|
|
745
|
+
>
|
|
746
|
+
<div className="minimax-wizard-step-bullet">
|
|
747
|
+
{i < step ? <CircleCheck size={14} /> : i + 1}
|
|
748
|
+
</div>
|
|
749
|
+
<div className="minimax-wizard-step-label">{label}</div>
|
|
750
|
+
</div>
|
|
751
|
+
))}
|
|
752
|
+
</div>
|
|
753
|
+
|
|
754
|
+
{/* ── Step content ────────────────────────────────────────── */}
|
|
755
|
+
<Card id="minimax-wizard-card">
|
|
756
|
+
{step === 0 && (
|
|
757
|
+
<div className="minimax-wizard-body">
|
|
758
|
+
<h3 className="minimax-wizard-title">Welcome</h3>
|
|
759
|
+
<p className="minimax-wizard-prose">
|
|
760
|
+
BizarHarness can show you how much of your MiniMax Token Plan
|
|
761
|
+
quota you have left — both the <strong>5-hour rolling</strong>
|
|
762
|
+
and the <strong>weekly</strong> windows — for each model. To
|
|
763
|
+
do that it needs your Subscription Key. The key never leaves
|
|
764
|
+
this machine.
|
|
765
|
+
</p>
|
|
766
|
+
<ul className="minimax-wizard-list">
|
|
767
|
+
<li>Stored in <code>~/.local/share/opencode/auth.json</code> (same place opencode's <code>/connect</code> writes).</li>
|
|
768
|
+
<li>Read fresh on every dashboard load — you can revoke it any time from the MiniMax console.</li>
|
|
769
|
+
<li>Used only for <code>GET /v1/token_plan/remains</code> and the chat-completions probe; never logged in full.</li>
|
|
770
|
+
</ul>
|
|
771
|
+
<div className="minimax-wizard-actions">
|
|
772
|
+
<Button onClick={() => setStep(1)} variant="primary" size="md">
|
|
773
|
+
Get started <span style={{ marginLeft: 6 }}>→</span>
|
|
774
|
+
</Button>
|
|
775
|
+
</div>
|
|
776
|
+
</div>
|
|
777
|
+
)}
|
|
778
|
+
|
|
779
|
+
{step === 1 && (
|
|
780
|
+
<div className="minimax-wizard-body">
|
|
781
|
+
<h3 className="minimax-wizard-title">Get your Subscription Key</h3>
|
|
782
|
+
<p className="minimax-wizard-prose">
|
|
783
|
+
Go to the MiniMax console and copy your Subscription Key. The
|
|
784
|
+
key starts with <code>sk-cp-</code> (or <code>sk-ant-</code>{' '}
|
|
785
|
+
if you're using the Anthropic-format surface). It's about
|
|
786
|
+
120 characters long.
|
|
787
|
+
</p>
|
|
788
|
+
<ol className="minimax-wizard-list">
|
|
789
|
+
<li>Open{' '}
|
|
790
|
+
<a
|
|
791
|
+
href="https://platform.minimax.io/user-center/payment/token-plan"
|
|
792
|
+
target="_blank"
|
|
793
|
+
rel="noreferrer"
|
|
794
|
+
className="minimax-wizard-link"
|
|
795
|
+
>
|
|
796
|
+
<ExternalLink size={12} /> platform.minimax.io/user-center/payment/token-plan
|
|
797
|
+
</a>
|
|
798
|
+
</li>
|
|
799
|
+
<li>Click <strong>Copy Subscription Key</strong>.</li>
|
|
800
|
+
<li>Come back here and paste it in the next step.</li>
|
|
801
|
+
</ol>
|
|
802
|
+
<p className="minimax-wizard-prose minimax-wizard-prose--muted">
|
|
803
|
+
Already have the key? You can also set the env var{' '}
|
|
804
|
+
<code>MINIMAX_API_KEY</code> or add it to{' '}
|
|
805
|
+
<code>opencode.json</code> under{' '}
|
|
806
|
+
<code>provider.minimax.options.apiKey</code>.
|
|
807
|
+
</p>
|
|
808
|
+
<div className="minimax-wizard-actions">
|
|
809
|
+
<Button variant="secondary" size="md" onClick={() => setStep(0)}>← Back</Button>
|
|
810
|
+
<Button variant="primary" size="md" onClick={() => setStep(2)}>
|
|
811
|
+
I have my key <span style={{ marginLeft: 6 }}>→</span>
|
|
812
|
+
</Button>
|
|
813
|
+
</div>
|
|
814
|
+
</div>
|
|
815
|
+
)}
|
|
816
|
+
|
|
817
|
+
{step === 2 && (
|
|
818
|
+
<div className="minimax-wizard-body">
|
|
819
|
+
<h3 className="minimax-wizard-title">Paste & test</h3>
|
|
820
|
+
<p className="minimax-wizard-prose">
|
|
821
|
+
Paste your Subscription Key below. We'll test it against the
|
|
822
|
+
real API (one tiny chat call) and then save it to opencode's
|
|
823
|
+
auth store. If the test fails you'll see exactly why.
|
|
824
|
+
</p>
|
|
825
|
+
<div className="minimax-key-row">
|
|
826
|
+
<div className="minimax-key-input-wrap">
|
|
827
|
+
<KeyRound size={12} className="minimax-key-icon" />
|
|
828
|
+
<input
|
|
829
|
+
type={showKey ? 'text' : 'password'}
|
|
830
|
+
value={keyDraft}
|
|
831
|
+
onChange={(e) => setKeyDraft(e.target.value)}
|
|
832
|
+
placeholder="eyJhbGciOi... or sk-cp-..."
|
|
833
|
+
spellCheck={false}
|
|
834
|
+
autoComplete="off"
|
|
835
|
+
className="minimax-key-input"
|
|
836
|
+
autoFocus
|
|
837
|
+
/>
|
|
838
|
+
<button
|
|
839
|
+
type="button"
|
|
840
|
+
onClick={() => setShowKey((v) => !v)}
|
|
841
|
+
className="minimax-key-toggle"
|
|
842
|
+
aria-label={showKey ? 'Hide key' : 'Show key'}
|
|
843
|
+
title={showKey ? 'Hide key' : 'Show key'}
|
|
844
|
+
>
|
|
845
|
+
{showKey ? <EyeOff size={13} /> : <Eye size={13} />}
|
|
846
|
+
</button>
|
|
847
|
+
</div>
|
|
848
|
+
</div>
|
|
849
|
+
{verifyResult && !verifyResult.ok && (
|
|
850
|
+
<div className="minimax-wizard-error">
|
|
851
|
+
<AlertTriangle size={14} />
|
|
852
|
+
<span>
|
|
853
|
+
<strong>Verification failed:</strong> {verifyResult.message ?? verifyResult.error}
|
|
854
|
+
</span>
|
|
855
|
+
</div>
|
|
856
|
+
)}
|
|
857
|
+
<div className="minimax-wizard-actions">
|
|
858
|
+
<Button variant="secondary" size="md" onClick={() => setStep(1)}>← Back</Button>
|
|
859
|
+
<Button
|
|
860
|
+
variant="primary"
|
|
861
|
+
size="md"
|
|
862
|
+
onClick={onVerify}
|
|
863
|
+
disabled={verifying || !keyDraft.trim()}
|
|
864
|
+
>
|
|
865
|
+
{verifying ? <Loader2 size={14} className="spin" /> : <ShieldCheck size={14} />}
|
|
866
|
+
{verifying ? 'Testing…' : 'Test & save key'}
|
|
867
|
+
</Button>
|
|
868
|
+
</div>
|
|
869
|
+
<p className="minimax-wizard-prose minimax-wizard-prose--muted">
|
|
870
|
+
Don't want to test first?{' '}
|
|
871
|
+
<a
|
|
872
|
+
href="#"
|
|
873
|
+
onClick={(e) => { e.preventDefault(); onManualKeySave(); }}
|
|
874
|
+
className="minimax-wizard-link"
|
|
875
|
+
>
|
|
876
|
+
Save without testing
|
|
877
|
+
</a>
|
|
878
|
+
</p>
|
|
879
|
+
</div>
|
|
880
|
+
)}
|
|
881
|
+
|
|
882
|
+
{step === 3 && (
|
|
883
|
+
<div className="minimax-wizard-body">
|
|
884
|
+
<h3 className="minimax-wizard-title">
|
|
885
|
+
<CircleCheck size={18} style={{ verticalAlign: 'text-bottom', marginRight: 6 }} />
|
|
886
|
+
All set
|
|
887
|
+
</h3>
|
|
888
|
+
<p className="minimax-wizard-prose">
|
|
889
|
+
The key works and is saved. Reloading the dashboard so you
|
|
890
|
+
can see your live 5-hour + weekly quota.
|
|
891
|
+
</p>
|
|
892
|
+
<div className="minimax-wizard-actions">
|
|
893
|
+
<Button variant="primary" size="md" onClick={() => window.location.reload()}>
|
|
894
|
+
View your quota →
|
|
895
|
+
</Button>
|
|
896
|
+
</div>
|
|
897
|
+
</div>
|
|
898
|
+
)}
|
|
899
|
+
|
|
900
|
+
{/* ── Status footer ─────────────────────────────────────────── */}
|
|
901
|
+
<div className="minimax-wizard-footer">
|
|
902
|
+
<ShieldCheck size={11} />
|
|
903
|
+
<span>Key is written to <code>~/.local/share/opencode/auth.json</code> with mode 0600.</span>
|
|
904
|
+
</div>
|
|
905
|
+
</Card>
|
|
906
|
+
</div>
|
|
907
|
+
);
|
|
908
|
+
}
|