@polderlabs/bizar 4.4.13 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/bizar-dash/CHANGELOG.md +37 -276
  2. package/bizar-dash/dist/assets/main-CDFKHzBg.css +1 -0
  3. package/bizar-dash/dist/assets/main-NYFpS2wY.js +312 -0
  4. package/bizar-dash/dist/assets/main-NYFpS2wY.js.map +1 -0
  5. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js → mobile--0FBIKX3.js} +2 -2
  6. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js.map → mobile--0FBIKX3.js.map} +1 -1
  7. package/bizar-dash/dist/assets/mobile-OgRp8VIb.js +352 -0
  8. package/bizar-dash/dist/assets/mobile-OgRp8VIb.js.map +1 -0
  9. package/bizar-dash/dist/index.html +3 -3
  10. package/bizar-dash/dist/mobile.html +2 -2
  11. package/bizar-dash/skills/agent-baseline/SKILL.md +80 -0
  12. package/bizar-dash/skills/bizar/SKILL.md +96 -0
  13. package/bizar-dash/skills/chat/SKILL.md +74 -0
  14. package/bizar-dash/skills/lightrag/SKILL.md +75 -0
  15. package/bizar-dash/skills/minimax/SKILL.md +80 -0
  16. package/bizar-dash/skills/obsidian/SKILL.md +55 -0
  17. package/bizar-dash/skills/providers/SKILL.md +75 -0
  18. package/bizar-dash/skills/sdk/SKILL.md +138 -0
  19. package/bizar-dash/skills/self-improvement/SKILL.md +53 -0
  20. package/bizar-dash/skills/skills-cli/SKILL.md +94 -0
  21. package/bizar-dash/skills/usage/SKILL.md +62 -0
  22. package/bizar-dash/src/server/api.mjs +12 -0
  23. package/bizar-dash/src/server/memory-lightrag.mjs +5 -2
  24. package/bizar-dash/src/server/memory-store.mjs +38 -0
  25. package/bizar-dash/src/server/minimax-usage-store.mjs +372 -0
  26. package/bizar-dash/src/server/minimax.mjs +196 -5
  27. package/bizar-dash/src/server/providers-store.mjs +956 -0
  28. package/bizar-dash/src/server/routes/config.mjs +52 -1
  29. package/bizar-dash/src/server/routes/env-vars.mjs +165 -0
  30. package/bizar-dash/src/server/routes/lightrag.mjs +154 -0
  31. package/bizar-dash/src/server/routes/memory.mjs +241 -1
  32. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +14 -29
  33. package/bizar-dash/src/server/routes/opencode-sessions.mjs +205 -3
  34. package/bizar-dash/src/server/routes/providers.mjs +266 -5
  35. package/bizar-dash/src/server/routes/skills.mjs +32 -43
  36. package/bizar-dash/src/server/routes/update.mjs +340 -0
  37. package/bizar-dash/src/server/routes/usage.mjs +136 -0
  38. package/bizar-dash/src/server/serve-info.mjs +135 -4
  39. package/bizar-dash/src/server/server.mjs +4 -0
  40. package/bizar-dash/src/server/skills-store.mjs +152 -262
  41. package/bizar-dash/src/web/App.tsx +118 -29
  42. package/bizar-dash/src/web/components/EnvVarManager.tsx +247 -0
  43. package/bizar-dash/src/web/components/SettingsSearch.tsx +213 -0
  44. package/bizar-dash/src/web/components/Topbar.tsx +0 -1
  45. package/bizar-dash/src/web/components/UsageChart.tsx +250 -0
  46. package/bizar-dash/src/web/components/UsageTable.tsx +90 -0
  47. package/bizar-dash/src/web/components/chat/ChatComposer.tsx +21 -25
  48. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +199 -37
  49. package/bizar-dash/src/web/components/chat/ChatThread.tsx +29 -17
  50. package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +7 -1
  51. package/bizar-dash/src/web/components/chat/InfoPanel.tsx +71 -6
  52. package/bizar-dash/src/web/components/chat/useChat.ts +751 -257
  53. package/bizar-dash/src/web/lib/api.ts +43 -0
  54. package/bizar-dash/src/web/main.tsx +1 -0
  55. package/bizar-dash/src/web/mobile/views/MobileChat.tsx +110 -35
  56. package/bizar-dash/src/web/styles/chat.css +135 -1
  57. package/bizar-dash/src/web/styles/main.css +46 -0
  58. package/bizar-dash/src/web/styles/minimax-usage.css +335 -0
  59. package/bizar-dash/src/web/styles/settings.css +418 -0
  60. package/bizar-dash/src/web/styles/skills.css +302 -0
  61. package/bizar-dash/src/web/styles/tasks.css +288 -0
  62. package/bizar-dash/src/web/views/Chat.tsx +276 -48
  63. package/bizar-dash/src/web/views/Config.tsx +3 -2065
  64. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +476 -461
  65. package/bizar-dash/src/web/views/Settings.tsx +6 -0
  66. package/bizar-dash/src/web/views/Skills.tsx +208 -260
  67. package/bizar-dash/src/web/views/Tasks.tsx +348 -1119
  68. package/bizar-dash/tests/chat-session-create.test.mjs +391 -0
  69. package/bizar-dash/tests/chat-session-stream.test.mjs +308 -0
  70. package/bizar-dash/tests/env-vars-store.test.mjs +216 -0
  71. package/bizar-dash/tests/lightrag-defaults.node.test.mjs +118 -0
  72. package/bizar-dash/tests/minimax-chat-usage.test.mjs +178 -0
  73. package/bizar-dash/tests/minimax-usage-store.node.test.mjs +293 -0
  74. package/bizar-dash/tests/opencode-sessions-detail.test.mjs +12 -9
  75. package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +479 -3
  76. package/bizar-dash/tests/providers-store-search.node.test.mjs +166 -0
  77. package/bizar-dash/tests/skills-list.test.mjs +232 -0
  78. package/bizar-dash/tests/skills-search.test.mjs +222 -0
  79. package/bizar-dash/tests/tasks-create.test.mjs +187 -0
  80. package/bizar-dash/tests/update-check.test.mjs +127 -0
  81. package/bizar-dash/tests/update-run.test.mjs +266 -0
  82. package/cli/bin.mjs +82 -1
  83. package/cli/provision.mjs +118 -4
  84. package/config/agents/_shared/SKILLS.md +109 -0
  85. package/package.json +1 -1
  86. package/bizar-dash/dist/assets/main-BB5mJurD.js +0 -352
  87. package/bizar-dash/dist/assets/main-BB5mJurD.js.map +0 -1
  88. package/bizar-dash/dist/assets/main-BsnQLXdh.css +0 -1
  89. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js +0 -354
  90. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js.map +0 -1
@@ -1,21 +1,14 @@
1
- // src/views/MiniMaxUsage.tsx — v4.5.0
1
+ // src/views/MiniMaxUsage.tsx — v4.6.0
2
2
  //
3
- // Token Plan usage tracking dashboard.
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
- // 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
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
- 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.
40
+
41
+ // ─── Types ───────────────────────────────────────────────────────────
47
42
 
48
43
  type Props = {
49
- snapshot: Snapshot;
50
- settings: 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
- export function MiniMaxUsage({ snapshot, settings, setActiveTab, refreshSnapshot }: Props) {
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
- // Onboarding wizard state.
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
- 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
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
- // Dismiss the onboarding wizard (so it doesn't show again until the
177
- // user clears their key).
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); // success
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
- toast.error(`Test request failed: ${(err as Error).message}`);
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
- toast.error(`Save failed: ${(err as Error).message}`);
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
- toast.error(`Refresh failed: ${(err as Error).message}`);
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
- 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
- }
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
- // 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;
367
+ const showWizard = !status.configured && (onboarding?.dismissedAt == null) && wizardStep < 4;
334
368
  if (showWizard) {
335
369
  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}
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
- 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}
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
- <div className="view-container view-minimax-usage">
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
- 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
- />
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
- <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>
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
- 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}
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
- 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}
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
- 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;
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
- <strong>{used}</strong> / {total || '—'} requests
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
- 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;
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
- 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>
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 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.
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> (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>
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 in the next step.</li>
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 &amp; 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
- 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
- >
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 &amp; 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
- <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>
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 is written to <code>~/.local/share/opencode/auth.json</code> with mode 0600.</span>
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>