@polderlabs/bizar 4.4.13 → 4.5.1
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/CHANGELOG.md +37 -276
- package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js +2 -0
- package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js.map +1 -0
- package/bizar-dash/dist/assets/main-eWZ4NlCL.css +1 -0
- package/bizar-dash/dist/assets/main-usWhlPWa.js +362 -0
- package/bizar-dash/dist/assets/main-usWhlPWa.js.map +1 -0
- package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js → mobile-DQLFCjwJ.js} +2 -2
- package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js.map → mobile-DQLFCjwJ.js.map} +1 -1
- package/bizar-dash/dist/assets/mobile-O6ANdD4W.js +352 -0
- package/bizar-dash/dist/assets/mobile-O6ANdD4W.js.map +1 -0
- package/bizar-dash/dist/index.html +3 -3
- package/bizar-dash/dist/mobile.html +2 -2
- package/bizar-dash/skills/agent-baseline/SKILL.md +80 -0
- package/bizar-dash/skills/bizar/SKILL.md +116 -0
- package/bizar-dash/skills/chat/SKILL.md +74 -0
- package/bizar-dash/skills/headroom/SKILL.md +94 -0
- package/bizar-dash/skills/lightrag/SKILL.md +86 -0
- package/bizar-dash/skills/minimax/SKILL.md +80 -0
- package/bizar-dash/skills/obsidian/SKILL.md +68 -0
- package/bizar-dash/skills/providers/SKILL.md +75 -0
- package/bizar-dash/skills/sdk/SKILL.md +138 -0
- package/bizar-dash/skills/self-improvement/SKILL.md +53 -0
- package/bizar-dash/skills/skills-cli/SKILL.md +94 -0
- package/bizar-dash/skills/usage/SKILL.md +62 -0
- package/bizar-dash/src/server/api.mjs +18 -0
- package/bizar-dash/src/server/headroom.mjs +645 -0
- package/bizar-dash/src/server/memory-lightrag.mjs +272 -2
- package/bizar-dash/src/server/memory-obsidian.mjs +230 -0
- package/bizar-dash/src/server/memory-store.mjs +189 -0
- package/bizar-dash/src/server/minimax-usage-store.mjs +372 -0
- package/bizar-dash/src/server/minimax.mjs +196 -5
- package/bizar-dash/src/server/providers-store.mjs +956 -0
- package/bizar-dash/src/server/routes/_shared.mjs +17 -0
- package/bizar-dash/src/server/routes/config.mjs +52 -1
- package/bizar-dash/src/server/routes/env-vars.mjs +165 -0
- package/bizar-dash/src/server/routes/headroom.mjs +126 -0
- package/bizar-dash/src/server/routes/lightrag.mjs +154 -0
- package/bizar-dash/src/server/routes/memory.mjs +668 -1
- package/bizar-dash/src/server/routes/opencode-session-detail.mjs +14 -29
- package/bizar-dash/src/server/routes/opencode-sessions.mjs +205 -3
- package/bizar-dash/src/server/routes/providers.mjs +266 -5
- package/bizar-dash/src/server/routes/skills.mjs +32 -43
- package/bizar-dash/src/server/routes/update.mjs +340 -0
- package/bizar-dash/src/server/routes/usage.mjs +136 -0
- package/bizar-dash/src/server/serve-info.mjs +135 -4
- package/bizar-dash/src/server/server.mjs +20 -0
- package/bizar-dash/src/server/skills-store.mjs +152 -262
- package/bizar-dash/src/web/App.tsx +120 -29
- package/bizar-dash/src/web/components/EnvVarManager.tsx +247 -0
- package/bizar-dash/src/web/components/HeadroomSettings.tsx +418 -0
- package/bizar-dash/src/web/components/HeadroomStatus.tsx +158 -0
- package/bizar-dash/src/web/components/SettingsSearch.tsx +213 -0
- package/bizar-dash/src/web/components/Topbar.tsx +2 -1
- package/bizar-dash/src/web/components/UsageChart.tsx +250 -0
- package/bizar-dash/src/web/components/UsageTable.tsx +90 -0
- package/bizar-dash/src/web/components/chat/ChatComposer.tsx +21 -25
- package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +199 -37
- package/bizar-dash/src/web/components/chat/ChatThread.tsx +29 -17
- package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +7 -1
- package/bizar-dash/src/web/components/chat/InfoPanel.tsx +71 -6
- package/bizar-dash/src/web/components/chat/useChat.ts +751 -257
- package/bizar-dash/src/web/lib/api.ts +43 -0
- package/bizar-dash/src/web/lib/types.ts +16 -0
- package/bizar-dash/src/web/main.tsx +2 -0
- package/bizar-dash/src/web/mobile/views/MobileChat.tsx +110 -35
- package/bizar-dash/src/web/styles/chat.css +135 -1
- package/bizar-dash/src/web/styles/main.css +46 -0
- package/bizar-dash/src/web/styles/memory.css +955 -0
- package/bizar-dash/src/web/styles/minimax-usage.css +335 -0
- package/bizar-dash/src/web/styles/settings.css +418 -0
- package/bizar-dash/src/web/styles/skills.css +302 -0
- package/bizar-dash/src/web/styles/tasks.css +288 -0
- package/bizar-dash/src/web/views/Chat.tsx +276 -48
- package/bizar-dash/src/web/views/Config.tsx +3 -2065
- package/bizar-dash/src/web/views/Memory.tsx +140 -0
- package/bizar-dash/src/web/views/MiniMaxUsage.tsx +476 -461
- package/bizar-dash/src/web/views/Overview.tsx +3 -0
- package/bizar-dash/src/web/views/Settings.tsx +36 -0
- package/bizar-dash/src/web/views/Skills.tsx +208 -260
- package/bizar-dash/src/web/views/Tasks.tsx +348 -1119
- package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +289 -0
- package/bizar-dash/src/web/views/memory/GitSyncPanel.tsx +220 -0
- package/bizar-dash/src/web/views/memory/LightragPanel.tsx +307 -0
- package/bizar-dash/src/web/views/memory/MemoryOverview.tsx +354 -0
- package/bizar-dash/src/web/views/memory/MemoryStatusCard.tsx +160 -0
- package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +642 -0
- package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +194 -0
- package/bizar-dash/tests/chat-session-create.test.mjs +391 -0
- package/bizar-dash/tests/chat-session-stream.test.mjs +308 -0
- package/bizar-dash/tests/env-vars-store.test.mjs +216 -0
- package/bizar-dash/tests/headroom-install.test.mjs +173 -0
- package/bizar-dash/tests/headroom-settings.test.mjs +126 -0
- package/bizar-dash/tests/headroom-status.test.mjs +117 -0
- package/bizar-dash/tests/lightrag-defaults.node.test.mjs +118 -0
- package/bizar-dash/tests/memory-lightrag-extended.test.mjs +162 -0
- package/bizar-dash/tests/memory-obsidian.test.mjs +269 -0
- package/bizar-dash/tests/memory-tab.test.mjs +322 -0
- package/bizar-dash/tests/minimax-chat-usage.test.mjs +178 -0
- package/bizar-dash/tests/minimax-usage-store.node.test.mjs +293 -0
- package/bizar-dash/tests/mod-upgrade.node.test.mjs +1 -1
- package/bizar-dash/tests/opencode-sessions-detail.test.mjs +12 -9
- package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +479 -3
- package/bizar-dash/tests/providers-store-search.node.test.mjs +166 -0
- package/bizar-dash/tests/skills-list.test.mjs +232 -0
- package/bizar-dash/tests/skills-search.test.mjs +222 -0
- package/bizar-dash/tests/submit-feedback.test.mjs +6 -6
- package/bizar-dash/tests/tasks-create.test.mjs +187 -0
- package/bizar-dash/tests/update-check.test.mjs +127 -0
- package/bizar-dash/tests/update-run.test.mjs +266 -0
- package/cli/bin.mjs +247 -1
- package/cli/provision.mjs +118 -4
- package/config/agents/_shared/SKILLS.md +109 -0
- package/package.json +1 -1
- package/bizar-dash/dist/assets/main-BB5mJurD.js +0 -352
- package/bizar-dash/dist/assets/main-BB5mJurD.js.map +0 -1
- package/bizar-dash/dist/assets/main-BsnQLXdh.css +0 -1
- package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js +0 -354
- package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js.map +0 -1
|
@@ -1,21 +1,14 @@
|
|
|
1
|
-
// src/views/MiniMaxUsage.tsx — v4.
|
|
1
|
+
// src/views/MiniMaxUsage.tsx — v4.6.0
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// Full rewrite with two tabs:
|
|
4
|
+
// 1. Token Plan — existing 5h + weekly remaining quota (unchanged)
|
|
5
|
+
// 2. Usage Analytics — charts, KPIs, per-model breakdown, per-key status
|
|
4
6
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// The
|
|
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
|
|
7
|
+
// The usage data comes from the JSONL store at ~/.local/share/bizar/usage.jsonl
|
|
8
|
+
// (written by minimax.mjs after every chatCompletion / fetchRemains call).
|
|
9
|
+
// The Usage Analytics tab is only shown when the key is configured.
|
|
17
10
|
|
|
18
|
-
import { useCallback, useEffect, useState } from 'react';
|
|
11
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
19
12
|
import {
|
|
20
13
|
Coins,
|
|
21
14
|
RefreshCw,
|
|
@@ -34,30 +27,27 @@ import {
|
|
|
34
27
|
ExternalLink,
|
|
35
28
|
ShieldCheck,
|
|
36
29
|
CircleCheck,
|
|
30
|
+
BarChart2,
|
|
37
31
|
} from 'lucide-react';
|
|
38
32
|
import { Card, CardTitle, CardMeta } from '../components/Card';
|
|
39
33
|
import { Button } from '../components/Button';
|
|
40
34
|
import { Spinner } from '../components/Spinner';
|
|
35
|
+
import { UsageChart } from '../components/UsageChart';
|
|
36
|
+
import { UsageTable, type PerModelRow } from '../components/UsageTable';
|
|
41
37
|
import { useToast } from '../components/Toast';
|
|
42
38
|
import { api } from '../lib/api';
|
|
43
39
|
import { cn } from '../lib/utils';
|
|
44
|
-
|
|
45
|
-
//
|
|
46
|
-
// even though the read path no longer needs it.
|
|
40
|
+
|
|
41
|
+
// ─── Types ───────────────────────────────────────────────────────────
|
|
47
42
|
|
|
48
43
|
type Props = {
|
|
49
|
-
snapshot:
|
|
50
|
-
settings:
|
|
44
|
+
snapshot: unknown;
|
|
45
|
+
settings: unknown;
|
|
51
46
|
activeTab: string;
|
|
52
47
|
setActiveTab: (id: string) => void;
|
|
53
48
|
refreshSnapshot: () => Promise<void>;
|
|
54
49
|
};
|
|
55
50
|
|
|
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
51
|
type RemainsModel = {
|
|
62
52
|
model_name: string;
|
|
63
53
|
current_interval_remaining_percent: number;
|
|
@@ -131,7 +121,116 @@ type TestResult = {
|
|
|
131
121
|
raw?: unknown;
|
|
132
122
|
};
|
|
133
123
|
|
|
134
|
-
|
|
124
|
+
// Usage analytics types.
|
|
125
|
+
type UsageTotals = {
|
|
126
|
+
requests: number;
|
|
127
|
+
errors: number;
|
|
128
|
+
promptTokens: number;
|
|
129
|
+
completionTokens: number;
|
|
130
|
+
totalTokens: number;
|
|
131
|
+
cachedTokens: number;
|
|
132
|
+
reasoningTokens: number;
|
|
133
|
+
avgLatencyMs: number;
|
|
134
|
+
p95LatencyMs: number;
|
|
135
|
+
costEstimate: number;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
type DailyBucket = {
|
|
139
|
+
date: string;
|
|
140
|
+
requests: number;
|
|
141
|
+
totalTokens: number;
|
|
142
|
+
promptTokens: number;
|
|
143
|
+
completionTokens: number;
|
|
144
|
+
errors: number;
|
|
145
|
+
avgLatencyMs: number;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
type PerModelBucket = {
|
|
149
|
+
providerId: string;
|
|
150
|
+
modelId: string;
|
|
151
|
+
requests: number;
|
|
152
|
+
totalTokens: number;
|
|
153
|
+
promptTokens: number;
|
|
154
|
+
completionTokens: number;
|
|
155
|
+
errors: number;
|
|
156
|
+
avgLatencyMs: number;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
type PerKeyBucket = {
|
|
160
|
+
keyEnvVar: string;
|
|
161
|
+
isBackup: boolean;
|
|
162
|
+
requests: number;
|
|
163
|
+
errors: number;
|
|
164
|
+
lastUsed: number | null;
|
|
165
|
+
status: 'active' | 'backup';
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
type UsageQueryResult = {
|
|
169
|
+
totals: UsageTotals;
|
|
170
|
+
daily: DailyBucket[];
|
|
171
|
+
perModel: PerModelBucket[];
|
|
172
|
+
perKey: PerKeyBucket[];
|
|
173
|
+
errors: { code: string; message: string; count: number; lastOccurred: number | null }[];
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
type RecentRecord = {
|
|
177
|
+
ts: number;
|
|
178
|
+
providerId: string;
|
|
179
|
+
modelId: string;
|
|
180
|
+
endpoint: string;
|
|
181
|
+
requestId: string;
|
|
182
|
+
promptTokens: number;
|
|
183
|
+
completionTokens: number;
|
|
184
|
+
totalTokens: number;
|
|
185
|
+
cachedTokens: number;
|
|
186
|
+
reasoningTokens: number;
|
|
187
|
+
latencyMs: number;
|
|
188
|
+
finishReason: string | null;
|
|
189
|
+
error: { code: string; message: string } | null;
|
|
190
|
+
keyEnvVar: string;
|
|
191
|
+
isBackup: boolean;
|
|
192
|
+
cached: boolean;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
type TimeRange = '24h' | '7d' | '30d' | 'custom';
|
|
196
|
+
|
|
197
|
+
// ─── Main component ────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
export function MiniMaxUsage({ activeTab, setActiveTab }: Props) {
|
|
200
|
+
// Share state between tabs.
|
|
201
|
+
const [view, setView] = useState<'quota' | 'analytics'>('quota');
|
|
202
|
+
const toast = useToast();
|
|
203
|
+
|
|
204
|
+
// Load based on active view.
|
|
205
|
+
return (
|
|
206
|
+
<div className="view-container view-minimax-usage">
|
|
207
|
+
{/* Sub-tab switcher */}
|
|
208
|
+
<div className="minimax-sub-tabs">
|
|
209
|
+
<button
|
|
210
|
+
className={cn('minimax-sub-tab', view === 'quota' && 'is-active')}
|
|
211
|
+
onClick={() => setView('quota')}
|
|
212
|
+
>
|
|
213
|
+
<Coins size={13} /> Token Plan
|
|
214
|
+
</button>
|
|
215
|
+
<button
|
|
216
|
+
className={cn('minimax-sub-tab', view === 'analytics' && 'is-active')}
|
|
217
|
+
onClick={() => setView('analytics')}
|
|
218
|
+
>
|
|
219
|
+
<BarChart2 size={13} /> Usage Analytics
|
|
220
|
+
</button>
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
{view === 'quota'
|
|
224
|
+
? <QuotaView activeTab={activeTab} setActiveTab={setActiveTab} />
|
|
225
|
+
: <AnalyticsView toast={toast} />
|
|
226
|
+
}
|
|
227
|
+
</div>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ─── Token Plan view (existing functionality) ──────────────────────────
|
|
232
|
+
|
|
233
|
+
function QuotaView({ activeTab, setActiveTab }: Pick<Props, 'activeTab' | 'setActiveTab'>) {
|
|
135
234
|
const toast = useToast();
|
|
136
235
|
const [status, setStatus] = useState<Status | null>(null);
|
|
137
236
|
const [remains, setRemains] = useState<RemainsSnapshot | null>(null);
|
|
@@ -141,11 +240,9 @@ export function MiniMaxUsage({ snapshot, settings, setActiveTab, refreshSnapshot
|
|
|
141
240
|
const [error, setError] = useState<string | null>(null);
|
|
142
241
|
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
|
143
242
|
const [testing, setTesting] = useState(false);
|
|
144
|
-
|
|
145
|
-
const [wizardStep, setWizardStep] = useState(0); // 0..3 (welcome → key → verify → done)
|
|
243
|
+
const [wizardStep, setWizardStep] = useState(0);
|
|
146
244
|
const [wizardVerifying, setWizardVerifying] = useState(false);
|
|
147
245
|
const [wizardVerifyResult, setWizardVerifyResult] = useState<TestResult | null>(null);
|
|
148
|
-
// Inline key config — saves a trip to Settings.
|
|
149
246
|
const [keyDraft, setKeyDraft] = useState('');
|
|
150
247
|
const [showKey, setShowKey] = useState(false);
|
|
151
248
|
const [savingKey, setSavingKey] = useState(false);
|
|
@@ -162,10 +259,7 @@ export function MiniMaxUsage({ snapshot, settings, setActiveTab, refreshSnapshot
|
|
|
162
259
|
setStatus(s);
|
|
163
260
|
setRemains(r);
|
|
164
261
|
setOnboarding(o);
|
|
165
|
-
|
|
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
|
|
262
|
+
if (s.configured) setWizardStep(4);
|
|
169
263
|
} catch (err) {
|
|
170
264
|
setError((err as Error).message || 'Failed to load MiniMax data');
|
|
171
265
|
} finally {
|
|
@@ -173,8 +267,8 @@ export function MiniMaxUsage({ snapshot, settings, setActiveTab, refreshSnapshot
|
|
|
173
267
|
}
|
|
174
268
|
}, []);
|
|
175
269
|
|
|
176
|
-
|
|
177
|
-
|
|
270
|
+
useEffect(() => { load(); }, [load]);
|
|
271
|
+
|
|
178
272
|
const dismissWizard = useCallback(async () => {
|
|
179
273
|
try {
|
|
180
274
|
await api.post('/minimax/onboarding', { dismissedAt: Date.now() });
|
|
@@ -182,19 +276,11 @@ export function MiniMaxUsage({ snapshot, settings, setActiveTab, refreshSnapshot
|
|
|
182
276
|
} catch { /* best-effort */ }
|
|
183
277
|
}, []);
|
|
184
278
|
|
|
185
|
-
// Wizard step 2: test the key against the real API before saving.
|
|
186
279
|
const verifyKeyBeforeSave = useCallback(async () => {
|
|
187
|
-
if (!keyDraft.trim()) {
|
|
188
|
-
toast.error('Paste a Subscription Key first.');
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
280
|
+
if (!keyDraft.trim()) { toast.error('Paste a Subscription Key first.'); return; }
|
|
191
281
|
setWizardVerifying(true);
|
|
192
282
|
setWizardVerifyResult(null);
|
|
193
283
|
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
284
|
const r = await api.post<TestResult>('/minimax/test', {
|
|
199
285
|
prompt: 'Reply with a single word: pong',
|
|
200
286
|
model: 'MiniMax-M3',
|
|
@@ -202,66 +288,37 @@ export function MiniMaxUsage({ snapshot, settings, setActiveTab, refreshSnapshot
|
|
|
202
288
|
});
|
|
203
289
|
setWizardVerifyResult(r);
|
|
204
290
|
if (r.ok) {
|
|
205
|
-
setWizardStep(3);
|
|
291
|
+
setWizardStep(3);
|
|
206
292
|
toast.success('Key works. Saving…');
|
|
207
|
-
// Auto-save: the user already proved the key works.
|
|
208
293
|
try {
|
|
209
|
-
await api.post('/minimax/onboarding/save-key', {
|
|
210
|
-
key: keyDraft.trim(),
|
|
211
|
-
groupId: 'default',
|
|
212
|
-
});
|
|
294
|
+
await api.post('/minimax/onboarding/save-key', { key: keyDraft.trim(), groupId: 'default' });
|
|
213
295
|
clearRemainsCacheClient();
|
|
214
296
|
toast.success('Subscription Key saved.');
|
|
215
|
-
} catch (err) {
|
|
216
|
-
toast.error(`Save failed: ${(err as Error).message}`);
|
|
217
|
-
}
|
|
297
|
+
} catch (err) { toast.error(`Save failed: ${(err as Error).message}`); }
|
|
218
298
|
void load();
|
|
219
299
|
} else {
|
|
220
300
|
toast.error(`Verification failed: ${r.message ?? r.error ?? 'unknown'}`);
|
|
221
301
|
}
|
|
222
|
-
} catch (err) {
|
|
223
|
-
|
|
224
|
-
} finally {
|
|
225
|
-
setWizardVerifying(false);
|
|
226
|
-
}
|
|
302
|
+
} catch (err) { toast.error(`Test request failed: ${(err as Error).message}`); }
|
|
303
|
+
finally { setWizardVerifying(false); }
|
|
227
304
|
}, [keyDraft, toast, load]);
|
|
228
305
|
|
|
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
306
|
const saveKey = useCallback(async () => {
|
|
237
|
-
if (!keyDraft.trim()) {
|
|
238
|
-
toast.error('Paste a Subscription Key first.');
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
307
|
+
if (!keyDraft.trim()) { toast.error('Paste a Subscription Key first.'); return; }
|
|
241
308
|
setSavingKey(true);
|
|
242
309
|
try {
|
|
243
310
|
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' },
|
|
311
|
+
'/minimax/onboarding/save-key', { key: keyDraft.trim(), groupId: 'default' },
|
|
246
312
|
);
|
|
247
|
-
if (!r.ok) {
|
|
248
|
-
toast.error(`Save failed: ${r.message ?? r.error ?? 'unknown'}`);
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
313
|
+
if (!r.ok) { toast.error(`Save failed: ${r.message ?? r.error ?? 'unknown'}`); return; }
|
|
251
314
|
setKeyDraft('');
|
|
252
315
|
clearRemainsCacheClient();
|
|
253
316
|
toast.success('Subscription Key saved. Loading quota…');
|
|
254
317
|
void load();
|
|
255
|
-
} catch (err) {
|
|
256
|
-
|
|
257
|
-
} finally {
|
|
258
|
-
setSavingKey(false);
|
|
259
|
-
}
|
|
318
|
+
} catch (err) { toast.error(`Save failed: ${(err as Error).message}`); }
|
|
319
|
+
finally { setSavingKey(false); }
|
|
260
320
|
}, [keyDraft, toast, load]);
|
|
261
321
|
|
|
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
322
|
const clearRemainsCacheClient = useCallback(async () => {
|
|
266
323
|
try { await api.del('/minimax/cache'); } catch { /* best-effort */ }
|
|
267
324
|
}, []);
|
|
@@ -273,11 +330,8 @@ export function MiniMaxUsage({ snapshot, settings, setActiveTab, refreshSnapshot
|
|
|
273
330
|
await api.post('/minimax/remains/refresh');
|
|
274
331
|
await load();
|
|
275
332
|
toast.success('Refreshed');
|
|
276
|
-
} catch (err) {
|
|
277
|
-
|
|
278
|
-
} finally {
|
|
279
|
-
setRefreshing(false);
|
|
280
|
-
}
|
|
333
|
+
} catch (err) { toast.error(`Refresh failed: ${(err as Error).message}`); }
|
|
334
|
+
finally { setRefreshing(false); }
|
|
281
335
|
}, [load, toast]);
|
|
282
336
|
|
|
283
337
|
const sendTest = useCallback(async () => {
|
|
@@ -290,71 +344,41 @@ export function MiniMaxUsage({ snapshot, settings, setActiveTab, refreshSnapshot
|
|
|
290
344
|
maxTokens: 16,
|
|
291
345
|
});
|
|
292
346
|
setTestResult(r);
|
|
293
|
-
if (r.ok) {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
}
|
|
298
|
-
} catch (err) {
|
|
299
|
-
toast.error(`Test request failed: ${(err as Error).message}`);
|
|
300
|
-
} finally {
|
|
301
|
-
setTesting(false);
|
|
302
|
-
}
|
|
347
|
+
if (r.ok) toast.success(`Test ok — used ${r.usage?.total_tokens ?? '?'} tokens`);
|
|
348
|
+
else toast.error(`Test failed: ${r.message ?? r.error ?? 'unknown'}`);
|
|
349
|
+
} catch (err) { toast.error(`Test request failed: ${(err as Error).message}`); }
|
|
350
|
+
finally { setTesting(false); }
|
|
303
351
|
}, [toast]);
|
|
304
352
|
|
|
305
353
|
if (loading && !status) {
|
|
306
|
-
return
|
|
307
|
-
<div className="view-container">
|
|
308
|
-
<Spinner /> Loading MiniMax data…
|
|
309
|
-
</div>
|
|
310
|
-
);
|
|
354
|
+
return <div className="view-container"><Spinner /> Loading MiniMax data…</div>;
|
|
311
355
|
}
|
|
312
|
-
|
|
313
356
|
if (!status) {
|
|
314
357
|
return (
|
|
315
358
|
<div className="view-container">
|
|
316
359
|
<div className="error-card">
|
|
317
360
|
<AlertTriangle size={20} />
|
|
318
|
-
<div>
|
|
319
|
-
<strong>Couldn't load MiniMax status</strong>
|
|
320
|
-
<pre>{error ?? 'unknown error'}</pre>
|
|
321
|
-
</div>
|
|
361
|
+
<div><strong>Couldn't load MiniMax status</strong><pre>{error ?? 'unknown error'}</pre></div>
|
|
322
362
|
</div>
|
|
323
363
|
</div>
|
|
324
364
|
);
|
|
325
365
|
}
|
|
326
366
|
|
|
327
|
-
|
|
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;
|
|
367
|
+
const showWizard = !status.configured && (onboarding?.dismissedAt == null) && wizardStep < 4;
|
|
334
368
|
if (showWizard) {
|
|
335
369
|
return <OnboardingWizard
|
|
336
|
-
step={wizardStep}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
showKey={showKey}
|
|
341
|
-
setShowKey={setShowKey}
|
|
342
|
-
verifying={wizardVerifying}
|
|
343
|
-
verifyResult={wizardVerifyResult}
|
|
370
|
+
step={wizardStep} setStep={setWizardStep}
|
|
371
|
+
keyDraft={keyDraft} setKeyDraft={setKeyDraft}
|
|
372
|
+
showKey={showKey} setShowKey={setShowKey}
|
|
373
|
+
verifying={wizardVerifying} verifyResult={wizardVerifyResult}
|
|
344
374
|
onVerify={verifyKeyBeforeSave}
|
|
345
|
-
onSkip={async () => {
|
|
346
|
-
|
|
347
|
-
toast.success("Skipped. You can paste the key anytime from this page.");
|
|
348
|
-
}}
|
|
349
|
-
onManualKeySave={saveKey}
|
|
350
|
-
savingKey={savingKey}
|
|
351
|
-
status={status}
|
|
375
|
+
onSkip={async () => { await dismissWizard(); toast.success("Skipped."); }}
|
|
376
|
+
onManualKeySave={saveKey} savingKey={savingKey} status={status}
|
|
352
377
|
/>;
|
|
353
378
|
}
|
|
354
379
|
|
|
355
380
|
return (
|
|
356
|
-
|
|
357
|
-
{/* ── Header ─────────────────────────────────────────────────── */}
|
|
381
|
+
<>
|
|
358
382
|
<header className="view-header">
|
|
359
383
|
<div className="view-header-titles">
|
|
360
384
|
<h1 className="view-title">
|
|
@@ -367,158 +391,43 @@ export function MiniMaxUsage({ snapshot, settings, setActiveTab, refreshSnapshot
|
|
|
367
391
|
</p>
|
|
368
392
|
</div>
|
|
369
393
|
<div className="view-header-actions">
|
|
370
|
-
<Button
|
|
371
|
-
variant="secondary"
|
|
372
|
-
size="sm"
|
|
373
|
-
onClick={() => setActiveTab('settings')}
|
|
374
|
-
title="Configure your Subscription Key"
|
|
375
|
-
>
|
|
394
|
+
<Button variant="secondary" size="sm" onClick={() => setActiveTab('settings')} title="Configure your Subscription Key">
|
|
376
395
|
<KeyRound size={14} /> {status.configured ? `Key ${status.apiKeyHint}` : 'Add key'}
|
|
377
396
|
</Button>
|
|
378
397
|
<Button variant="secondary" size="sm" onClick={refresh} disabled={refreshing}>
|
|
379
|
-
{refreshing ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
|
|
380
|
-
Refresh
|
|
398
|
+
{refreshing ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />} Refresh
|
|
381
399
|
</Button>
|
|
382
400
|
</div>
|
|
383
401
|
</header>
|
|
384
402
|
|
|
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
403
|
{remains && !remains.ok && (
|
|
464
404
|
<div className="banner banner-err">
|
|
465
405
|
<AlertTriangle size={16} />
|
|
466
|
-
<span>
|
|
467
|
-
<strong>Couldn't load quota</strong>: {remains.message ?? remains.error ?? 'unknown error'}
|
|
468
|
-
</span>
|
|
406
|
+
<span><strong>Couldn't load quota</strong>: {remains.message ?? remains.error ?? 'unknown error'}</span>
|
|
469
407
|
</div>
|
|
470
408
|
)}
|
|
471
409
|
|
|
472
|
-
{/* ── Summary stats ─────────────────────────────────────────── */}
|
|
473
410
|
{remains?.ok && remains.models && (
|
|
474
411
|
<>
|
|
475
412
|
<div className="minimax-stats-row">
|
|
476
|
-
<Stat
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
/>
|
|
413
|
+
<Stat label="Models tracked" value={String(remains.models.length)} hint="Distinct model quotas returned by the API" />
|
|
414
|
+
<Stat label="Last fetched" value={remains.fetchedAt ? relativeTime(remains.fetchedAt) : '—'} hint="Refreshed on demand + every 60s" />
|
|
415
|
+
<Stat label="Group" value={remains.groupId ?? '—'} hint="Usually 'default' for an individual team" />
|
|
416
|
+
<Stat label="API key" value={remains.apiKeyHint ?? '—'} hint="Masked; the real key never leaves auth.json" />
|
|
496
417
|
</div>
|
|
497
|
-
|
|
498
|
-
{/* ── Per-model quota cards ──────────────────────────────── */}
|
|
499
418
|
<div className="minimax-models-grid">
|
|
500
|
-
{remains.models.map((m) =>
|
|
501
|
-
<ModelQuotaCard key={m.model_name} model={m} />
|
|
502
|
-
))}
|
|
419
|
+
{remains.models.map((m) => <ModelQuotaCard key={m.model_name} model={m} />)}
|
|
503
420
|
</div>
|
|
504
421
|
</>
|
|
505
422
|
)}
|
|
506
423
|
|
|
507
|
-
{/* ── Test prompt card ─────────────────────────────────────── */}
|
|
508
424
|
{status.configured && (
|
|
509
425
|
<Card id="minimax-test">
|
|
510
|
-
<CardTitle>
|
|
511
|
-
|
|
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>
|
|
426
|
+
<CardTitle><Send size={14} /> Test the API key</CardTitle>
|
|
427
|
+
<CardMeta>One-shot chat completion to <code>{status.chatBaseUrl}/chat/completions</code>.</CardMeta>
|
|
518
428
|
<div className="minimax-test-actions">
|
|
519
429
|
<Button onClick={sendTest} disabled={testing} variant="primary" size="sm">
|
|
520
|
-
{testing ? <Loader2 size={14} className="spin" /> : <Send size={14} />}
|
|
521
|
-
Send test prompt
|
|
430
|
+
{testing ? <Loader2 size={14} className="spin" /> : <Send size={14} />} Send test prompt
|
|
522
431
|
</Button>
|
|
523
432
|
<code className="minimax-test-prompt">"Reply with a single word: pong"</code>
|
|
524
433
|
</div>
|
|
@@ -533,14 +442,11 @@ export function MiniMaxUsage({ snapshot, settings, setActiveTab, refreshSnapshot
|
|
|
533
442
|
<strong>finish:</strong> {testResult.finishReason ?? '—'}
|
|
534
443
|
</div>
|
|
535
444
|
{testResult.content && (
|
|
536
|
-
<div className="minimax-test-content">
|
|
537
|
-
<code>{testResult.content}</code>
|
|
538
|
-
</div>
|
|
445
|
+
<div className="minimax-test-content"><code>{testResult.content}</code></div>
|
|
539
446
|
)}
|
|
540
447
|
{testResult.usage && (
|
|
541
448
|
<div className="minimax-test-usage">
|
|
542
|
-
<strong>usage:</strong> total{' '}
|
|
543
|
-
<code>{testResult.usage.total_tokens ?? '?'}</code> · prompt{' '}
|
|
449
|
+
<strong>usage:</strong> total <code>{testResult.usage.total_tokens ?? '?'}</code> · prompt{' '}
|
|
544
450
|
<code>{testResult.usage.prompt_tokens ?? '?'}</code> · completion{' '}
|
|
545
451
|
<code>{testResult.usage.completion_tokens ?? '?'}</code>
|
|
546
452
|
{testResult.usage.prompt_tokens_details?.cached_tokens != null && (
|
|
@@ -557,11 +463,268 @@ export function MiniMaxUsage({ snapshot, settings, setActiveTab, refreshSnapshot
|
|
|
557
463
|
)}
|
|
558
464
|
</Card>
|
|
559
465
|
)}
|
|
466
|
+
</>
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ─── Analytics view ────────────────────────────────────────────────────
|
|
471
|
+
|
|
472
|
+
function AnalyticsView({ toast }: { toast: ReturnType<typeof useToast> }) {
|
|
473
|
+
const [range, setRange] = useState<TimeRange>('24h');
|
|
474
|
+
const [customFrom, setCustomFrom] = useState('');
|
|
475
|
+
const [customTo, setCustomTo] = useState('');
|
|
476
|
+
const [loading, setLoading] = useState(true);
|
|
477
|
+
const [usage, setUsage] = useState<UsageQueryResult | null>(null);
|
|
478
|
+
const [recent, setRecent] = useState<RecentRecord[]>([]);
|
|
479
|
+
const [sortKey, setSortKey] = useState('requests');
|
|
480
|
+
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
|
|
481
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
482
|
+
|
|
483
|
+
const load = useCallback(async (r: TimeRange, fromMs?: number, toMs?: number) => {
|
|
484
|
+
setLoading(true);
|
|
485
|
+
try {
|
|
486
|
+
const params = new URLSearchParams({ range: r });
|
|
487
|
+
if (r === 'custom' && fromMs && toMs) {
|
|
488
|
+
params.set('from', String(fromMs));
|
|
489
|
+
params.set('to', String(toMs));
|
|
490
|
+
}
|
|
491
|
+
const [u, rec] = await Promise.all([
|
|
492
|
+
api.get<UsageQueryResult>(`/usage?${params.toString()}`),
|
|
493
|
+
api.get<{ records: RecentRecord[]; total: number; returned: number }>('/usage/recent?limit=20'),
|
|
494
|
+
]);
|
|
495
|
+
setUsage(u);
|
|
496
|
+
setRecent(rec.records);
|
|
497
|
+
} catch (err) {
|
|
498
|
+
toast.error(`Failed to load usage data: ${(err as Error).message}`);
|
|
499
|
+
} finally {
|
|
500
|
+
setLoading(false);
|
|
501
|
+
}
|
|
502
|
+
}, [toast]);
|
|
503
|
+
|
|
504
|
+
useEffect(() => {
|
|
505
|
+
if (range === 'custom') {
|
|
506
|
+
const from = customFrom ? new Date(customFrom).getTime() : Date.now() - 7 * 86_400_000;
|
|
507
|
+
const to = customTo ? new Date(customTo).getTime() : Date.now();
|
|
508
|
+
void load('custom', from, to);
|
|
509
|
+
} else {
|
|
510
|
+
void load(range);
|
|
511
|
+
}
|
|
512
|
+
}, [range, load]);
|
|
513
|
+
|
|
514
|
+
const handleSort = useCallback((key: string) => {
|
|
515
|
+
if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
|
516
|
+
else { setSortKey(key); setSortDir('desc'); }
|
|
517
|
+
}, [sortKey]);
|
|
518
|
+
|
|
519
|
+
const sortedModels = useMemo(() => {
|
|
520
|
+
if (!usage?.perModel) return [];
|
|
521
|
+
return [...usage.perModel].sort((a, b) => {
|
|
522
|
+
const av = (a as Record<string, unknown>)[sortKey] ?? 0;
|
|
523
|
+
const bv = (b as Record<string, unknown>)[sortKey] ?? 0;
|
|
524
|
+
const cmp = typeof av === 'number' && typeof bv === 'number' ? av - bv : String(av).localeCompare(String(bv));
|
|
525
|
+
return sortDir === 'asc' ? cmp : -cmp;
|
|
526
|
+
});
|
|
527
|
+
}, [usage?.perModel, sortKey, sortDir]);
|
|
528
|
+
|
|
529
|
+
const chartSeries = useMemo(() => {
|
|
530
|
+
if (!usage?.daily) return { labels: [] as string[], requests: [] as number[], tokens: [] as number[] };
|
|
531
|
+
return {
|
|
532
|
+
labels: usage.daily.map(d => d.date.slice(5)), // "MM-DD"
|
|
533
|
+
requests: usage.daily.map(d => d.requests),
|
|
534
|
+
tokens: usage.daily.map(d => d.totalTokens),
|
|
535
|
+
};
|
|
536
|
+
}, [usage?.daily]);
|
|
537
|
+
|
|
538
|
+
const refresh = useCallback(async () => {
|
|
539
|
+
setRefreshing(true);
|
|
540
|
+
await load(range === 'custom' ? 'custom' : range,
|
|
541
|
+
range === 'custom' && customFrom ? new Date(customFrom).getTime() : undefined,
|
|
542
|
+
range === 'custom' && customTo ? new Date(customTo).getTime() : undefined);
|
|
543
|
+
setRefreshing(false);
|
|
544
|
+
}, [load, range, customFrom, customTo]);
|
|
545
|
+
|
|
546
|
+
if (loading && !usage) {
|
|
547
|
+
return <div className="view-container"><Spinner /> Loading usage data…</div>;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const t = usage?.totals;
|
|
551
|
+
const errors = usage?.errors ?? [];
|
|
552
|
+
const perKey = usage?.perKey ?? [];
|
|
553
|
+
|
|
554
|
+
return (
|
|
555
|
+
<>
|
|
556
|
+
{/* Header */}
|
|
557
|
+
<header className="view-header">
|
|
558
|
+
<div className="view-header-titles">
|
|
559
|
+
<h1 className="view-title">
|
|
560
|
+
<BarChart2 size={20} style={{ verticalAlign: 'text-bottom', marginRight: 6 }} />
|
|
561
|
+
Usage Analytics
|
|
562
|
+
</h1>
|
|
563
|
+
<p className="view-subtitle">
|
|
564
|
+
Aggregated from <code>~/.local/share/bizar/usage.jsonl</code> —
|
|
565
|
+
every chatCompletion and fetchRemains call.
|
|
566
|
+
</p>
|
|
567
|
+
</div>
|
|
568
|
+
<div className="view-header-actions">
|
|
569
|
+
<Button variant="secondary" size="sm" onClick={refresh} disabled={refreshing}>
|
|
570
|
+
{refreshing ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />} Refresh
|
|
571
|
+
</Button>
|
|
572
|
+
</div>
|
|
573
|
+
</header>
|
|
574
|
+
|
|
575
|
+
{/* Time range selector */}
|
|
576
|
+
<div className="usage-time-range">
|
|
577
|
+
{(['24h', '7d', '30d', 'custom'] as TimeRange[]).map(r => (
|
|
578
|
+
<button
|
|
579
|
+
key={r}
|
|
580
|
+
className={cn('usage-range-chip', range === r && 'is-active')}
|
|
581
|
+
onClick={() => setRange(r)}
|
|
582
|
+
>
|
|
583
|
+
{r === 'custom' ? 'Custom' : r.toUpperCase()}
|
|
584
|
+
</button>
|
|
585
|
+
))}
|
|
586
|
+
{range === 'custom' && (
|
|
587
|
+
<div className="usage-custom-dates">
|
|
588
|
+
<input
|
|
589
|
+
type="date"
|
|
590
|
+
className="usage-date-input"
|
|
591
|
+
value={customFrom}
|
|
592
|
+
onChange={e => setCustomFrom(e.target.value)}
|
|
593
|
+
/>
|
|
594
|
+
<span style={{ color: 'var(--text-muted)', fontSize: 12 }}>to</span>
|
|
595
|
+
<input
|
|
596
|
+
type="date"
|
|
597
|
+
className="usage-date-input"
|
|
598
|
+
value={customTo}
|
|
599
|
+
onChange={e => setCustomTo(e.target.value)}
|
|
600
|
+
/>
|
|
601
|
+
</div>
|
|
602
|
+
)}
|
|
603
|
+
</div>
|
|
604
|
+
|
|
605
|
+
{/* KPI row */}
|
|
606
|
+
<div className="usage-kpi-row">
|
|
607
|
+
<KpiCard
|
|
608
|
+
label="Total Requests"
|
|
609
|
+
value={t?.requests.toLocaleString() ?? '—'}
|
|
610
|
+
sub={t ? `${t.errors} errors` : undefined}
|
|
611
|
+
tone={t && t.errors > t.requests * 0.1 ? 'warn' : undefined}
|
|
612
|
+
/>
|
|
613
|
+
<KpiCard
|
|
614
|
+
label="Total Tokens"
|
|
615
|
+
value={t ? (t.totalTokens >= 1000 ? `${Math.round(t.totalTokens / 1000)}k` : String(t.totalTokens)) : '—'}
|
|
616
|
+
sub={t ? `${t.promptTokens.toLocaleString()} prompt · ${t.completionTokens.toLocaleString()} completion` : undefined}
|
|
617
|
+
/>
|
|
618
|
+
<KpiCard
|
|
619
|
+
label="Errors"
|
|
620
|
+
value={String(t?.errors ?? '—')}
|
|
621
|
+
sub={errors.length > 0 ? `${errors[0].code}: ${errors[0].count}` : undefined}
|
|
622
|
+
tone={t && t.errors > 0 ? 'err' : undefined}
|
|
623
|
+
/>
|
|
624
|
+
<KpiCard
|
|
625
|
+
label="Avg Latency"
|
|
626
|
+
value={t?.avgLatencyMs != null ? `${t.avgLatencyMs}ms` : '—'}
|
|
627
|
+
sub={t?.p95LatencyMs != null ? `p95 ${t.p95LatencyMs}ms` : undefined}
|
|
628
|
+
/>
|
|
629
|
+
<KpiCard
|
|
630
|
+
label="Est. Cost"
|
|
631
|
+
value={t?.costEstimate != null && t.costEstimate > 0 ? `$${t.costEstimate.toFixed(4)}` : '—'}
|
|
632
|
+
sub="approximate USD"
|
|
633
|
+
tone={undefined}
|
|
634
|
+
/>
|
|
635
|
+
</div>
|
|
636
|
+
|
|
637
|
+
{/* Chart */}
|
|
638
|
+
<Card className="usage-chart-card">
|
|
639
|
+
<div className="usage-chart-wrap">
|
|
640
|
+
<UsageChart series={chartSeries} height={280} />
|
|
641
|
+
</div>
|
|
642
|
+
<div className="usage-legend">
|
|
643
|
+
<div className="usage-legend-item">
|
|
644
|
+
<div className="usage-legend-bar" style={{ background: 'var(--accent)' }} />
|
|
645
|
+
<span>Requests (bar)</span>
|
|
646
|
+
</div>
|
|
647
|
+
<div className="usage-legend-item">
|
|
648
|
+
<div className="usage-legend-line" style={{ background: 'var(--warning, #d29922)' }} />
|
|
649
|
+
<span>Tokens (line)</span>
|
|
650
|
+
</div>
|
|
651
|
+
</div>
|
|
652
|
+
</Card>
|
|
653
|
+
|
|
654
|
+
{/* Per-model table */}
|
|
655
|
+
<Card>
|
|
656
|
+
<CardTitle>Per-model breakdown</CardTitle>
|
|
657
|
+
<UsageTable
|
|
658
|
+
rows={sortedModels as PerModelRow[]}
|
|
659
|
+
sortKey={sortKey}
|
|
660
|
+
sortDir={sortDir}
|
|
661
|
+
onSort={handleSort}
|
|
662
|
+
/>
|
|
663
|
+
</Card>
|
|
664
|
+
|
|
665
|
+
{/* Per-key status */}
|
|
666
|
+
{perKey.length > 0 && (
|
|
667
|
+
<Card>
|
|
668
|
+
<CardTitle>API Keys</CardTitle>
|
|
669
|
+
<div className="usage-key-row">
|
|
670
|
+
{perKey.map(k => (
|
|
671
|
+
<span
|
|
672
|
+
key={k.keyEnvVar}
|
|
673
|
+
className={cn('usage-key-badge', k.isBackup ? 'is-backup' : 'is-active')}
|
|
674
|
+
>
|
|
675
|
+
<span className="usage-key-dot" />
|
|
676
|
+
{k.keyEnvVar}
|
|
677
|
+
{k.isBackup ? ' (backup)' : ' (active)'}
|
|
678
|
+
{' · '}
|
|
679
|
+
{k.requests} req
|
|
680
|
+
</span>
|
|
681
|
+
))}
|
|
682
|
+
</div>
|
|
683
|
+
</Card>
|
|
684
|
+
)}
|
|
685
|
+
|
|
686
|
+
{/* Recent activity */}
|
|
687
|
+
{recent.length > 0 && (
|
|
688
|
+
<Card>
|
|
689
|
+
<CardTitle>Recent activity</CardTitle>
|
|
690
|
+
<div className="usage-recent-list">
|
|
691
|
+
{recent.map((r, i) => (
|
|
692
|
+
<div key={`${r.requestId}-${i}`} className={cn('usage-recent-item', r.error && 'is-err')}>
|
|
693
|
+
<span className="usage-recent-time">
|
|
694
|
+
{new Date(r.ts).toLocaleTimeString()}
|
|
695
|
+
</span>
|
|
696
|
+
<span className="usage-recent-model">
|
|
697
|
+
<code>{r.modelId}</code>
|
|
698
|
+
</span>
|
|
699
|
+
<span className="usage-recent-tokens">
|
|
700
|
+
{r.totalTokens.toLocaleString()} tok
|
|
701
|
+
</span>
|
|
702
|
+
<span className="usage-recent-latency">
|
|
703
|
+
{r.latencyMs}ms
|
|
704
|
+
</span>
|
|
705
|
+
<span className="usage-recent-endpoint">{r.endpoint}</span>
|
|
706
|
+
</div>
|
|
707
|
+
))}
|
|
708
|
+
</div>
|
|
709
|
+
</Card>
|
|
710
|
+
)}
|
|
711
|
+
</>
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// ─── Kpi card ──────────────────────────────────────────────────────────
|
|
716
|
+
|
|
717
|
+
function KpiCard({ label, value, sub, tone }: { label: string; value: string; sub?: string; tone?: 'warn' | 'err' | 'ok' }) {
|
|
718
|
+
return (
|
|
719
|
+
<div className="usage-kpi-card">
|
|
720
|
+
<div className="usage-kpi-label">{label}</div>
|
|
721
|
+
<div className={cn('usage-kpi-value', tone && `is-${tone}`)}>{value}</div>
|
|
722
|
+
{sub && <div className="usage-kpi-sub">{sub}</div>}
|
|
560
723
|
</div>
|
|
561
724
|
);
|
|
562
725
|
}
|
|
563
726
|
|
|
564
|
-
// ─── Sub-components
|
|
727
|
+
// ─── Sub-components (quota view) ───────────────────────────────────────
|
|
565
728
|
|
|
566
729
|
function Stat({ label, value, hint }: { label: string; value: string; hint?: string }) {
|
|
567
730
|
return (
|
|
@@ -582,85 +745,46 @@ function ModelQuotaCard({ model }: { model: RemainsModel }) {
|
|
|
582
745
|
<Card id={`minimax-model-${model.model_name}`} className="minimax-model-card">
|
|
583
746
|
<div className="minimax-model-head">
|
|
584
747
|
<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
|
-
>
|
|
748
|
+
<div className={cn('minimax-model-status', fivePct < 25 || weekPct < 25 ? 'is-warn' : 'is-ok')}>
|
|
591
749
|
{model.current_interval_status === 1 ? 'active' : 'idle'} ·{' '}
|
|
592
750
|
{model.current_weekly_status === 1 ? 'week active' : 'week idle'}
|
|
593
751
|
</div>
|
|
594
752
|
</div>
|
|
595
|
-
|
|
596
753
|
<div className="minimax-quota-rows">
|
|
597
754
|
<QuotaBar
|
|
598
|
-
icon={<Activity size={12} />}
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
used={model.current_interval_usage_count ?? 0}
|
|
603
|
-
total={model.current_interval_total_count ?? 0}
|
|
604
|
-
resetIn={model.intervalResetInHuman}
|
|
605
|
-
resetISO={model.endTimeISO}
|
|
755
|
+
icon={<Activity size={12} />} label="5-hour rolling"
|
|
756
|
+
remainingPct={fivePct} consumedPct={fiveConsumed}
|
|
757
|
+
used={model.current_interval_usage_count ?? 0} total={model.current_interval_total_count ?? 0}
|
|
758
|
+
resetIn={model.intervalResetInHuman} resetISO={model.endTimeISO}
|
|
606
759
|
/>
|
|
607
760
|
<QuotaBar
|
|
608
|
-
icon={<Calendar size={12} />}
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
used={model.current_weekly_usage_count ?? 0}
|
|
613
|
-
total={model.current_weekly_total_count ?? 0}
|
|
614
|
-
resetIn={model.weeklyResetInHuman}
|
|
615
|
-
resetISO={model.weeklyEndTimeISO}
|
|
761
|
+
icon={<Calendar size={12} />} label="Weekly"
|
|
762
|
+
remainingPct={weekPct} consumedPct={weekConsumed}
|
|
763
|
+
used={model.current_weekly_usage_count ?? 0} total={model.current_weekly_total_count ?? 0}
|
|
764
|
+
resetIn={model.weeklyResetInHuman} resetISO={model.weeklyEndTimeISO}
|
|
616
765
|
/>
|
|
617
766
|
</div>
|
|
618
767
|
</Card>
|
|
619
768
|
);
|
|
620
769
|
}
|
|
621
770
|
|
|
622
|
-
function QuotaBar({
|
|
623
|
-
icon
|
|
624
|
-
|
|
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;
|
|
771
|
+
function QuotaBar({ icon, label, remainingPct, consumedPct, used, total, resetIn, resetISO }: {
|
|
772
|
+
icon: React.ReactNode; label: string; remainingPct: number; consumedPct: number;
|
|
773
|
+
used: number; total: number; resetIn?: string; resetISO?: string;
|
|
640
774
|
}) {
|
|
641
775
|
const tone = remainingPct >= 75 ? 'good' : remainingPct >= 25 ? 'warn' : 'low';
|
|
642
776
|
return (
|
|
643
777
|
<div className={`minimax-quota-row is-${tone}`}>
|
|
644
778
|
<div className="minimax-quota-head">
|
|
645
|
-
<span className="minimax-quota-label">
|
|
646
|
-
{icon} {label}
|
|
647
|
-
</span>
|
|
779
|
+
<span className="minimax-quota-label">{icon} {label}</span>
|
|
648
780
|
<span className="minimax-quota-remaining">{remainingPct}% remaining</span>
|
|
649
781
|
</div>
|
|
650
782
|
<div className="minimax-bar">
|
|
651
|
-
<div
|
|
652
|
-
className="minimax-bar-fill"
|
|
653
|
-
style={{ width: `${consumedPct}%` }}
|
|
654
|
-
aria-label={`${consumedPct}% consumed`}
|
|
655
|
-
/>
|
|
783
|
+
<div className="minimax-bar-fill" style={{ width: `${consumedPct}%` }} aria-label={`${consumedPct}% consumed`} />
|
|
656
784
|
</div>
|
|
657
785
|
<div className="minimax-quota-meta">
|
|
658
|
-
<span>
|
|
659
|
-
|
|
660
|
-
</span>
|
|
661
|
-
<span title={resetISO}>
|
|
662
|
-
{resetIn ? `resets in ${resetIn}` : '—'}
|
|
663
|
-
</span>
|
|
786
|
+
<span><strong>{used}</strong> / {total || '—'} requests</span>
|
|
787
|
+
<span title={resetISO}>{resetIn ? `resets in ${resetIn}` : '—'}</span>
|
|
664
788
|
</div>
|
|
665
789
|
</div>
|
|
666
790
|
);
|
|
@@ -678,39 +802,18 @@ function relativeTime(ts: number): string {
|
|
|
678
802
|
return `${Math.floor(ms / 3_600_000)}h ago`;
|
|
679
803
|
}
|
|
680
804
|
|
|
681
|
-
// ─── Onboarding wizard
|
|
805
|
+
// ─── Onboarding wizard ─────────────────────────────────────────────────
|
|
682
806
|
|
|
683
807
|
type OnboardingProps = {
|
|
684
|
-
step: number;
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
verifying: boolean;
|
|
691
|
-
verifyResult: TestResult | null;
|
|
692
|
-
onVerify: () => void;
|
|
693
|
-
onSkip: () => void;
|
|
694
|
-
onManualKeySave: () => void;
|
|
695
|
-
savingKey: boolean;
|
|
696
|
-
status: Status;
|
|
808
|
+
step: number; setStep: (n: number) => void;
|
|
809
|
+
keyDraft: string; setKeyDraft: (s: string) => void;
|
|
810
|
+
showKey: boolean; setShowKey: React.Dispatch<React.SetStateAction<boolean>>;
|
|
811
|
+
verifying: boolean; verifyResult: TestResult | null;
|
|
812
|
+
onVerify: () => void; onSkip: () => void; onManualKeySave: () => void;
|
|
813
|
+
savingKey: boolean; status: Status;
|
|
697
814
|
};
|
|
698
815
|
|
|
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) {
|
|
816
|
+
function OnboardingWizard({ step, setStep, keyDraft, setKeyDraft, showKey, setShowKey, verifying, verifyResult, onVerify, onSkip, onManualKeySave, savingKey, status }: OnboardingProps) {
|
|
714
817
|
return (
|
|
715
818
|
<div className="view-container view-minimax-onboarding">
|
|
716
819
|
<header className="view-header">
|
|
@@ -719,59 +822,37 @@ function OnboardingWizard({
|
|
|
719
822
|
<Sparkles size={20} style={{ verticalAlign: 'text-bottom', marginRight: 6 }} />
|
|
720
823
|
Set up MiniMax Token Plan tracking
|
|
721
824
|
</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>
|
|
825
|
+
<p className="view-subtitle">We'll read your Subscription Key and show your remaining quota.</p>
|
|
726
826
|
</div>
|
|
727
827
|
<div className="view-header-actions">
|
|
728
|
-
<Button variant="ghost" size="sm" onClick={onSkip}>
|
|
729
|
-
<X size={14} /> Skip for now
|
|
730
|
-
</Button>
|
|
828
|
+
<Button variant="ghost" size="sm" onClick={onSkip}><X size={14} /> Skip for now</Button>
|
|
731
829
|
</div>
|
|
732
830
|
</header>
|
|
733
831
|
|
|
734
|
-
{/* ── Stepper ────────────────────────────────────────────── */}
|
|
735
832
|
<div className="minimax-wizard-stepper">
|
|
736
833
|
{(['Welcome', 'Get a key', 'Paste & test', 'Done']).map((label, i) => (
|
|
737
|
-
<div
|
|
738
|
-
|
|
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>
|
|
834
|
+
<div key={label} className={cn('minimax-wizard-step', i === step && 'is-current', i < step && 'is-done', i > step && 'is-todo')}>
|
|
835
|
+
<div className="minimax-wizard-step-bullet">{i < step ? <CircleCheck size={14} /> : i + 1}</div>
|
|
749
836
|
<div className="minimax-wizard-step-label">{label}</div>
|
|
750
837
|
</div>
|
|
751
838
|
))}
|
|
752
839
|
</div>
|
|
753
840
|
|
|
754
|
-
{/* ── Step content ────────────────────────────────────────── */}
|
|
755
841
|
<Card id="minimax-wizard-card">
|
|
756
842
|
{step === 0 && (
|
|
757
843
|
<div className="minimax-wizard-body">
|
|
758
844
|
<h3 className="minimax-wizard-title">Welcome</h3>
|
|
759
845
|
<p className="minimax-wizard-prose">
|
|
760
|
-
BizarHarness
|
|
761
|
-
|
|
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.
|
|
846
|
+
BizarHarness shows you how much of your MiniMax Token Plan quota you have left —
|
|
847
|
+
both the <strong>5-hour rolling</strong> and <strong>weekly</strong> windows.
|
|
765
848
|
</p>
|
|
766
849
|
<ul className="minimax-wizard-list">
|
|
767
|
-
<li>Stored in <code>~/.local/share/opencode/auth.json</code
|
|
768
|
-
<li>Read fresh on every dashboard load
|
|
769
|
-
<li>
|
|
850
|
+
<li>Stored in <code>~/.local/share/opencode/auth.json</code>.</li>
|
|
851
|
+
<li>Read fresh on every dashboard load.</li>
|
|
852
|
+
<li>Never logged in full.</li>
|
|
770
853
|
</ul>
|
|
771
854
|
<div className="minimax-wizard-actions">
|
|
772
|
-
<Button onClick={() => setStep(1)} variant="primary" size="md">
|
|
773
|
-
Get started <span style={{ marginLeft: 6 }}>→</span>
|
|
774
|
-
</Button>
|
|
855
|
+
<Button onClick={() => setStep(1)} variant="primary" size="md">Get started <span style={{ marginLeft: 6 }}>→</span></Button>
|
|
775
856
|
</div>
|
|
776
857
|
</div>
|
|
777
858
|
)}
|
|
@@ -779,37 +860,15 @@ function OnboardingWizard({
|
|
|
779
860
|
{step === 1 && (
|
|
780
861
|
<div className="minimax-wizard-body">
|
|
781
862
|
<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>
|
|
863
|
+
<p className="minimax-wizard-prose">Go to the MiniMax console and copy your Subscription Key.</p>
|
|
788
864
|
<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>
|
|
865
|
+
<li>Open <a href="https://platform.minimax.io/user-center/payment/token-plan" target="_blank" rel="noreferrer" className="minimax-wizard-link"><ExternalLink size={12} /> platform.minimax.io</a></li>
|
|
799
866
|
<li>Click <strong>Copy Subscription Key</strong>.</li>
|
|
800
|
-
<li>Come back here and paste it
|
|
867
|
+
<li>Come back here and paste it.</li>
|
|
801
868
|
</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
869
|
<div className="minimax-wizard-actions">
|
|
809
870
|
<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>
|
|
871
|
+
<Button variant="primary" size="md" onClick={() => setStep(2)}>I have my key <span style={{ marginLeft: 6 }}>→</span></Button>
|
|
813
872
|
</div>
|
|
814
873
|
</div>
|
|
815
874
|
)}
|
|
@@ -817,90 +876,46 @@ function OnboardingWizard({
|
|
|
817
876
|
{step === 2 && (
|
|
818
877
|
<div className="minimax-wizard-body">
|
|
819
878
|
<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>
|
|
879
|
+
<p className="minimax-wizard-prose">Paste your key below. We'll test it against the real API before saving.</p>
|
|
825
880
|
<div className="minimax-key-row">
|
|
826
881
|
<div className="minimax-key-input-wrap">
|
|
827
882
|
<KeyRound size={12} className="minimax-key-icon" />
|
|
828
|
-
<input
|
|
829
|
-
|
|
830
|
-
|
|
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
|
-
>
|
|
883
|
+
<input type={showKey ? 'text' : 'password'} value={keyDraft} onChange={e => setKeyDraft(e.target.value)}
|
|
884
|
+
placeholder="sk-cp-..." spellCheck={false} autoComplete="off" className="minimax-key-input" autoFocus />
|
|
885
|
+
<button type="button" onClick={() => setShowKey(v => !v)} className="minimax-key-toggle" aria-label={showKey ? 'Hide key' : 'Show key'}>
|
|
845
886
|
{showKey ? <EyeOff size={13} /> : <Eye size={13} />}
|
|
846
887
|
</button>
|
|
847
888
|
</div>
|
|
848
889
|
</div>
|
|
849
890
|
{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>
|
|
891
|
+
<div className="minimax-wizard-error"><AlertTriangle size={14} /><span><strong>Verification failed:</strong> {verifyResult.message ?? verifyResult.error}</span></div>
|
|
856
892
|
)}
|
|
857
893
|
<div className="minimax-wizard-actions">
|
|
858
894
|
<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
|
-
>
|
|
895
|
+
<Button variant="primary" size="md" onClick={onVerify} disabled={verifying || !keyDraft.trim()}>
|
|
865
896
|
{verifying ? <Loader2 size={14} className="spin" /> : <ShieldCheck size={14} />}
|
|
866
897
|
{verifying ? 'Testing…' : 'Test & save key'}
|
|
867
898
|
</Button>
|
|
868
899
|
</div>
|
|
869
900
|
<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>
|
|
901
|
+
Don't want to test first? <a href="#" onClick={e => { e.preventDefault(); onManualKeySave(); }} className="minimax-wizard-link">Save without testing</a>
|
|
878
902
|
</p>
|
|
879
903
|
</div>
|
|
880
904
|
)}
|
|
881
905
|
|
|
882
906
|
{step === 3 && (
|
|
883
907
|
<div className="minimax-wizard-body">
|
|
884
|
-
<h3 className="minimax-wizard-title">
|
|
885
|
-
|
|
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>
|
|
908
|
+
<h3 className="minimax-wizard-title"><CircleCheck size={18} style={{ verticalAlign: 'text-bottom', marginRight: 6 }} />All set</h3>
|
|
909
|
+
<p className="minimax-wizard-prose">The key works and is saved. Reloading so you can see your live quota.</p>
|
|
892
910
|
<div className="minimax-wizard-actions">
|
|
893
|
-
<Button variant="primary" size="md" onClick={() => window.location.reload()}>
|
|
894
|
-
View your quota →
|
|
895
|
-
</Button>
|
|
911
|
+
<Button variant="primary" size="md" onClick={() => window.location.reload()}>View your quota →</Button>
|
|
896
912
|
</div>
|
|
897
913
|
</div>
|
|
898
914
|
)}
|
|
899
915
|
|
|
900
|
-
{/* ── Status footer ─────────────────────────────────────────── */}
|
|
901
916
|
<div className="minimax-wizard-footer">
|
|
902
917
|
<ShieldCheck size={11} />
|
|
903
|
-
<span>Key
|
|
918
|
+
<span>Key written to <code>~/.local/share/opencode/auth.json</code> with mode 0600.</span>
|
|
904
919
|
</div>
|
|
905
920
|
</Card>
|
|
906
921
|
</div>
|