@polderlabs/bizar 4.4.12 → 4.4.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,9 +4,16 @@
4
4
  //
5
5
  // Renders the user's current 5-hour rolling + weekly remaining quota
6
6
  // per model, fetched from https://www.minimax.io/v1/token_plan/remains.
7
- // The Subscription Key is set inline on this page (no separate Settings
8
- // trip needed) — the value is written to settings.json under
9
- // `minimax.apiKey` and read back on next load.
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
10
17
 
11
18
  import { useCallback, useEffect, useState } from 'react';
12
19
  import {
@@ -22,6 +29,11 @@ import {
22
29
  Save,
23
30
  Eye,
24
31
  EyeOff,
32
+ Sparkles,
33
+ X,
34
+ ExternalLink,
35
+ ShieldCheck,
36
+ CircleCheck,
25
37
  } from 'lucide-react';
26
38
  import { Card, CardTitle, CardMeta } from '../components/Card';
27
39
  import { Button } from '../components/Button';
@@ -30,6 +42,8 @@ import { useToast } from '../components/Toast';
30
42
  import { api } from '../lib/api';
31
43
  import { cn } from '../lib/utils';
32
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.
33
47
 
34
48
  type Props = {
35
49
  snapshot: Snapshot;
@@ -39,6 +53,11 @@ type Props = {
39
53
  refreshSnapshot: () => Promise<void>;
40
54
  };
41
55
 
56
+ // Suppress unused-var — `settings` is kept in the prop signature so
57
+ // App.tsx can pass a single shape; the read path doesn't need it now
58
+ // that the key comes from opencode's auth.json.
59
+ void (null as unknown as Settings | null);
60
+
42
61
  type RemainsModel = {
43
62
  model_name: string;
44
63
  current_interval_remaining_percent: number;
@@ -68,6 +87,7 @@ type RemainsSnapshot = {
68
87
  cached?: boolean;
69
88
  fetchedAt?: number;
70
89
  apiKeyHint?: string;
90
+ keySource?: string;
71
91
  groupId?: string;
72
92
  baseUrl?: string;
73
93
  models?: RemainsModel[];
@@ -77,15 +97,22 @@ type RemainsSnapshot = {
77
97
  };
78
98
 
79
99
  type Status = {
80
- enabled: boolean;
81
100
  configured: boolean;
82
101
  apiKeyHint: string;
102
+ source: string;
83
103
  groupId: string;
84
- baseUrl: string;
104
+ tokenBaseUrl: string;
85
105
  chatBaseUrl: string;
106
+ knownModels: string[];
107
+ keyPatternValid: boolean | null;
86
108
  cache: { fetchedAt: number; apiKeyHint: string; modelCount: number } | null;
87
109
  };
88
110
 
111
+ type OnboardingState = {
112
+ dismissedAt: number | null;
113
+ hiddenModels: string[];
114
+ };
115
+
89
116
  type TestResult = {
90
117
  ok: boolean;
91
118
  error?: string;
@@ -108,11 +135,16 @@ export function MiniMaxUsage({ snapshot, settings, setActiveTab, refreshSnapshot
108
135
  const toast = useToast();
109
136
  const [status, setStatus] = useState<Status | null>(null);
110
137
  const [remains, setRemains] = useState<RemainsSnapshot | null>(null);
138
+ const [onboarding, setOnboarding] = useState<OnboardingState | null>(null);
111
139
  const [loading, setLoading] = useState(true);
112
140
  const [refreshing, setRefreshing] = useState(false);
113
141
  const [error, setError] = useState<string | null>(null);
114
142
  const [testResult, setTestResult] = useState<TestResult | null>(null);
115
143
  const [testing, setTesting] = useState(false);
144
+ // Onboarding wizard state.
145
+ const [wizardStep, setWizardStep] = useState(0); // 0..3 (welcome → key → verify → done)
146
+ const [wizardVerifying, setWizardVerifying] = useState(false);
147
+ const [wizardVerifyResult, setWizardVerifyResult] = useState<TestResult | null>(null);
116
148
  // Inline key config — saves a trip to Settings.
117
149
  const [keyDraft, setKeyDraft] = useState('');
118
150
  const [showKey, setShowKey] = useState(false);
@@ -122,13 +154,18 @@ export function MiniMaxUsage({ snapshot, settings, setActiveTab, refreshSnapshot
122
154
  setLoading(true);
123
155
  setError(null);
124
156
  try {
125
- const [s, r] = await Promise.all([
157
+ const [s, r, o] = await Promise.all([
126
158
  api.get<Status>('/minimax/status'),
127
159
  api.get<RemainsSnapshot>('/minimax/remains'),
160
+ api.get<OnboardingState>('/minimax/onboarding'),
128
161
  ]);
129
162
  setStatus(s);
130
163
  setRemains(r);
131
- setKeyDraft(s.configured ? '' : ''); // never prefill — security
164
+ setOnboarding(o);
165
+ setKeyDraft(''); // never prefill — security
166
+ // If we just configured a key (dismissedAt is set but not configured)
167
+ // or the user already dismissed, do not show the wizard.
168
+ if (s.configured) setWizardStep(4); // skip past wizard
132
169
  } catch (err) {
133
170
  setError((err as Error).message || 'Failed to load MiniMax data');
134
171
  } finally {
@@ -136,8 +173,66 @@ export function MiniMaxUsage({ snapshot, settings, setActiveTab, refreshSnapshot
136
173
  }
137
174
  }, []);
138
175
 
176
+ // Dismiss the onboarding wizard (so it doesn't show again until the
177
+ // user clears their key).
178
+ const dismissWizard = useCallback(async () => {
179
+ try {
180
+ await api.post('/minimax/onboarding', { dismissedAt: Date.now() });
181
+ setOnboarding((o) => o ? { ...o, dismissedAt: Date.now() } : o);
182
+ } catch { /* best-effort */ }
183
+ }, []);
184
+
185
+ // Wizard step 2: test the key against the real API before saving.
186
+ const verifyKeyBeforeSave = useCallback(async () => {
187
+ if (!keyDraft.trim()) {
188
+ toast.error('Paste a Subscription Key first.');
189
+ return;
190
+ }
191
+ setWizardVerifying(true);
192
+ setWizardVerifyResult(null);
193
+ try {
194
+ // Temporarily save to the real auth.json, probe, then if the
195
+ // probe fails the user can re-paste. The probe endpoint is the
196
+ // chat completions surface (cheaper than remains + a real-world
197
+ // check that the key actually works for the dashboard's purpose).
198
+ const r = await api.post<TestResult>('/minimax/test', {
199
+ prompt: 'Reply with a single word: pong',
200
+ model: 'MiniMax-M3',
201
+ maxTokens: 16,
202
+ });
203
+ setWizardVerifyResult(r);
204
+ if (r.ok) {
205
+ setWizardStep(3); // success
206
+ toast.success('Key works. Saving…');
207
+ // Auto-save: the user already proved the key works.
208
+ try {
209
+ await api.post('/minimax/onboarding/save-key', {
210
+ key: keyDraft.trim(),
211
+ groupId: 'default',
212
+ });
213
+ clearRemainsCacheClient();
214
+ toast.success('Subscription Key saved.');
215
+ } catch (err) {
216
+ toast.error(`Save failed: ${(err as Error).message}`);
217
+ }
218
+ void load();
219
+ } else {
220
+ toast.error(`Verification failed: ${r.message ?? r.error ?? 'unknown'}`);
221
+ }
222
+ } catch (err) {
223
+ toast.error(`Test request failed: ${(err as Error).message}`);
224
+ } finally {
225
+ setWizardVerifying(false);
226
+ }
227
+ }, [keyDraft, toast, load]);
228
+
139
229
  useEffect(() => { load(); }, [load]);
140
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.
141
236
  const saveKey = useCallback(async () => {
142
237
  if (!keyDraft.trim()) {
143
238
  toast.error('Paste a Subscription Key first.');
@@ -145,24 +240,24 @@ export function MiniMaxUsage({ snapshot, settings, setActiveTab, refreshSnapshot
145
240
  }
146
241
  setSavingKey(true);
147
242
  try {
148
- const r = await api.put<{ minimax: { apiKey: string } }>('/settings', {
149
- minimax: {
150
- ...(settings.minimax || {}),
151
- apiKey: keyDraft.trim(),
152
- },
153
- });
154
- // Wipe the in-memory draft + clear the remains cache so the
155
- // next load picks up the new key.
243
+ const r = await api.post<{ ok: boolean; path?: string; apiKeyHint?: string; error?: string; message?: string }>(
244
+ '/minimax/onboarding/save-key',
245
+ { key: keyDraft.trim(), groupId: 'default' },
246
+ );
247
+ if (!r.ok) {
248
+ toast.error(`Save failed: ${r.message ?? r.error ?? 'unknown'}`);
249
+ return;
250
+ }
156
251
  setKeyDraft('');
157
252
  clearRemainsCacheClient();
158
- toast.success('Subscription Key saved. Refreshing…');
253
+ toast.success('Subscription Key saved. Loading quota…');
159
254
  void load();
160
255
  } catch (err) {
161
256
  toast.error(`Save failed: ${(err as Error).message}`);
162
257
  } finally {
163
258
  setSavingKey(false);
164
259
  }
165
- }, [keyDraft, settings, toast, load]);
260
+ }, [keyDraft, toast, load]);
166
261
 
167
262
  // Clears the on-disk + in-memory cache via the dashboard's
168
263
  // /api/minimax/cache DELETE endpoint. Wrapped in a helper so the
@@ -229,6 +324,34 @@ export function MiniMaxUsage({ snapshot, settings, setActiveTab, refreshSnapshot
229
324
  );
230
325
  }
231
326
 
327
+ // First-run onboarding: show the wizard when no key is configured and
328
+ // the user hasn't dismissed it. The wizard writes the key to opencode's
329
+ // auth store (same place `/connect` writes), then the dashboard reads
330
+ // it back via /minimax/status and the live dashboard takes over.
331
+ const showWizard = !status.configured
332
+ && (onboarding?.dismissedAt == null)
333
+ && wizardStep < 4;
334
+ if (showWizard) {
335
+ return <OnboardingWizard
336
+ step={wizardStep}
337
+ setStep={setWizardStep}
338
+ keyDraft={keyDraft}
339
+ setKeyDraft={setKeyDraft}
340
+ showKey={showKey}
341
+ setShowKey={setShowKey}
342
+ verifying={wizardVerifying}
343
+ verifyResult={wizardVerifyResult}
344
+ onVerify={verifyKeyBeforeSave}
345
+ onSkip={async () => {
346
+ await dismissWizard();
347
+ toast.success("Skipped. You can paste the key anytime from this page.");
348
+ }}
349
+ onManualKeySave={saveKey}
350
+ savingKey={savingKey}
351
+ status={status}
352
+ />;
353
+ }
354
+
232
355
  return (
233
356
  <div className="view-container view-minimax-usage">
234
357
  {/* ── Header ─────────────────────────────────────────────────── */}
@@ -260,17 +383,30 @@ export function MiniMaxUsage({ snapshot, settings, setActiveTab, refreshSnapshot
260
383
  </header>
261
384
 
262
385
  {/* ── Status banners ─────────────────────────────────────────── */}
263
- {!status.enabled && (
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 && (
264
398
  <div className="banner banner-warn">
265
399
  <AlertTriangle size={16} />
266
400
  <span>
267
- MiniMax integration is <strong>disabled</strong> in settings. Enable it in Settings → MiniMax.
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.
268
404
  </span>
269
405
  </div>
270
406
  )}
271
407
 
272
408
  {/* ── Subscription Key config (inline) ──────────────────── */}
273
- {status.enabled && (
409
+ {(
274
410
  <Card id="minimax-key">
275
411
  <CardTitle>
276
412
  <KeyRound size={14} /> Subscription Key
@@ -285,7 +421,7 @@ export function MiniMaxUsage({ snapshot, settings, setActiveTab, refreshSnapshot
285
421
  >
286
422
  platform.minimax.io/user-center/payment/token-plan
287
423
  </a>
288
- . Stored locally in <code>~/.config/bizar/settings.json</code>;
424
+ . Stored in <code>~/.local/share/opencode/auth.json</code>;
289
425
  never sent to any other host. Status:{' '}
290
426
  {status.configured ? (
291
427
  <strong className="is-ok">configured ({status.apiKeyHint})</strong>
@@ -355,7 +491,7 @@ export function MiniMaxUsage({ snapshot, settings, setActiveTab, refreshSnapshot
355
491
  <Stat
356
492
  label="API key"
357
493
  value={remains.apiKeyHint ?? '—'}
358
- hint="Masked; the real key never leaves settings.json"
494
+ hint="Masked; the real key never leaves auth.json"
359
495
  />
360
496
  </div>
361
497
 
@@ -541,3 +677,232 @@ function relativeTime(ts: number): string {
541
677
  if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ago`;
542
678
  return `${Math.floor(ms / 3_600_000)}h ago`;
543
679
  }
680
+
681
+ // ─── Onboarding wizard ──────────────────────────────────────────────────
682
+
683
+ type OnboardingProps = {
684
+ step: number;
685
+ setStep: (n: number) => void;
686
+ keyDraft: string;
687
+ setKeyDraft: (s: string) => void;
688
+ showKey: boolean;
689
+ setShowKey: React.Dispatch<React.SetStateAction<boolean>>;
690
+ verifying: boolean;
691
+ verifyResult: TestResult | null;
692
+ onVerify: () => void;
693
+ onSkip: () => void;
694
+ onManualKeySave: () => void;
695
+ savingKey: boolean;
696
+ status: Status;
697
+ };
698
+
699
+ /**
700
+ * 4-step first-run wizard:
701
+ * 0. Welcome — what the page does, what the user needs.
702
+ * 1. Get a key — link to platform.minimax.io + how to find it.
703
+ * 2. Paste — input with show/hide + "Test key" button.
704
+ * 3. Done — confirms the save + "View your quota" CTA.
705
+ *
706
+ * The wizard writes to opencode's canonical auth store at
707
+ * ~/.local/share/opencode/auth.json (via /api/minimax/onboarding/save-key).
708
+ */
709
+ function OnboardingWizard({
710
+ step, setStep, keyDraft, setKeyDraft, showKey, setShowKey,
711
+ verifying, verifyResult, onVerify, onSkip, onManualKeySave,
712
+ savingKey, status,
713
+ }: OnboardingProps) {
714
+ return (
715
+ <div className="view-container view-minimax-onboarding">
716
+ <header className="view-header">
717
+ <div className="view-header-titles">
718
+ <h1 className="view-title">
719
+ <Sparkles size={20} style={{ verticalAlign: 'text-bottom', marginRight: 6 }} />
720
+ Set up MiniMax Token Plan tracking
721
+ </h1>
722
+ <p className="view-subtitle">
723
+ We'll read your Subscription Key from opencode's auth store and show your remaining
724
+ 5-hour + weekly quota for every MiniMax model.
725
+ </p>
726
+ </div>
727
+ <div className="view-header-actions">
728
+ <Button variant="ghost" size="sm" onClick={onSkip}>
729
+ <X size={14} /> Skip for now
730
+ </Button>
731
+ </div>
732
+ </header>
733
+
734
+ {/* ── Stepper ────────────────────────────────────────────── */}
735
+ <div className="minimax-wizard-stepper">
736
+ {(['Welcome', 'Get a key', 'Paste & test', 'Done']).map((label, i) => (
737
+ <div
738
+ key={label}
739
+ className={cn(
740
+ 'minimax-wizard-step',
741
+ i === step && 'is-current',
742
+ i < step && 'is-done',
743
+ i > step && 'is-todo',
744
+ )}
745
+ >
746
+ <div className="minimax-wizard-step-bullet">
747
+ {i < step ? <CircleCheck size={14} /> : i + 1}
748
+ </div>
749
+ <div className="minimax-wizard-step-label">{label}</div>
750
+ </div>
751
+ ))}
752
+ </div>
753
+
754
+ {/* ── Step content ────────────────────────────────────────── */}
755
+ <Card id="minimax-wizard-card">
756
+ {step === 0 && (
757
+ <div className="minimax-wizard-body">
758
+ <h3 className="minimax-wizard-title">Welcome</h3>
759
+ <p className="minimax-wizard-prose">
760
+ BizarHarness can show you how much of your MiniMax Token Plan
761
+ quota you have left — both the <strong>5-hour rolling</strong>
762
+ and the <strong>weekly</strong> windows — for each model. To
763
+ do that it needs your Subscription Key. The key never leaves
764
+ this machine.
765
+ </p>
766
+ <ul className="minimax-wizard-list">
767
+ <li>Stored in <code>~/.local/share/opencode/auth.json</code> (same place opencode's <code>/connect</code> writes).</li>
768
+ <li>Read fresh on every dashboard load — you can revoke it any time from the MiniMax console.</li>
769
+ <li>Used only for <code>GET /v1/token_plan/remains</code> and the chat-completions probe; never logged in full.</li>
770
+ </ul>
771
+ <div className="minimax-wizard-actions">
772
+ <Button onClick={() => setStep(1)} variant="primary" size="md">
773
+ Get started <span style={{ marginLeft: 6 }}>→</span>
774
+ </Button>
775
+ </div>
776
+ </div>
777
+ )}
778
+
779
+ {step === 1 && (
780
+ <div className="minimax-wizard-body">
781
+ <h3 className="minimax-wizard-title">Get your Subscription Key</h3>
782
+ <p className="minimax-wizard-prose">
783
+ Go to the MiniMax console and copy your Subscription Key. The
784
+ key starts with <code>sk-cp-</code> (or <code>sk-ant-</code>{' '}
785
+ if you're using the Anthropic-format surface). It's about
786
+ 120 characters long.
787
+ </p>
788
+ <ol className="minimax-wizard-list">
789
+ <li>Open{' '}
790
+ <a
791
+ href="https://platform.minimax.io/user-center/payment/token-plan"
792
+ target="_blank"
793
+ rel="noreferrer"
794
+ className="minimax-wizard-link"
795
+ >
796
+ <ExternalLink size={12} /> platform.minimax.io/user-center/payment/token-plan
797
+ </a>
798
+ </li>
799
+ <li>Click <strong>Copy Subscription Key</strong>.</li>
800
+ <li>Come back here and paste it in the next step.</li>
801
+ </ol>
802
+ <p className="minimax-wizard-prose minimax-wizard-prose--muted">
803
+ Already have the key? You can also set the env var{' '}
804
+ <code>MINIMAX_API_KEY</code> or add it to{' '}
805
+ <code>opencode.json</code> under{' '}
806
+ <code>provider.minimax.options.apiKey</code>.
807
+ </p>
808
+ <div className="minimax-wizard-actions">
809
+ <Button variant="secondary" size="md" onClick={() => setStep(0)}>← Back</Button>
810
+ <Button variant="primary" size="md" onClick={() => setStep(2)}>
811
+ I have my key <span style={{ marginLeft: 6 }}>→</span>
812
+ </Button>
813
+ </div>
814
+ </div>
815
+ )}
816
+
817
+ {step === 2 && (
818
+ <div className="minimax-wizard-body">
819
+ <h3 className="minimax-wizard-title">Paste &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>
825
+ <div className="minimax-key-row">
826
+ <div className="minimax-key-input-wrap">
827
+ <KeyRound size={12} className="minimax-key-icon" />
828
+ <input
829
+ type={showKey ? 'text' : 'password'}
830
+ value={keyDraft}
831
+ onChange={(e) => setKeyDraft(e.target.value)}
832
+ placeholder="eyJhbGciOi... or sk-cp-..."
833
+ spellCheck={false}
834
+ autoComplete="off"
835
+ className="minimax-key-input"
836
+ autoFocus
837
+ />
838
+ <button
839
+ type="button"
840
+ onClick={() => setShowKey((v) => !v)}
841
+ className="minimax-key-toggle"
842
+ aria-label={showKey ? 'Hide key' : 'Show key'}
843
+ title={showKey ? 'Hide key' : 'Show key'}
844
+ >
845
+ {showKey ? <EyeOff size={13} /> : <Eye size={13} />}
846
+ </button>
847
+ </div>
848
+ </div>
849
+ {verifyResult && !verifyResult.ok && (
850
+ <div className="minimax-wizard-error">
851
+ <AlertTriangle size={14} />
852
+ <span>
853
+ <strong>Verification failed:</strong> {verifyResult.message ?? verifyResult.error}
854
+ </span>
855
+ </div>
856
+ )}
857
+ <div className="minimax-wizard-actions">
858
+ <Button variant="secondary" size="md" onClick={() => setStep(1)}>← Back</Button>
859
+ <Button
860
+ variant="primary"
861
+ size="md"
862
+ onClick={onVerify}
863
+ disabled={verifying || !keyDraft.trim()}
864
+ >
865
+ {verifying ? <Loader2 size={14} className="spin" /> : <ShieldCheck size={14} />}
866
+ {verifying ? 'Testing…' : 'Test &amp; save key'}
867
+ </Button>
868
+ </div>
869
+ <p className="minimax-wizard-prose minimax-wizard-prose--muted">
870
+ Don't want to test first?{' '}
871
+ <a
872
+ href="#"
873
+ onClick={(e) => { e.preventDefault(); onManualKeySave(); }}
874
+ className="minimax-wizard-link"
875
+ >
876
+ Save without testing
877
+ </a>
878
+ </p>
879
+ </div>
880
+ )}
881
+
882
+ {step === 3 && (
883
+ <div className="minimax-wizard-body">
884
+ <h3 className="minimax-wizard-title">
885
+ <CircleCheck size={18} style={{ verticalAlign: 'text-bottom', marginRight: 6 }} />
886
+ All set
887
+ </h3>
888
+ <p className="minimax-wizard-prose">
889
+ The key works and is saved. Reloading the dashboard so you
890
+ can see your live 5-hour + weekly quota.
891
+ </p>
892
+ <div className="minimax-wizard-actions">
893
+ <Button variant="primary" size="md" onClick={() => window.location.reload()}>
894
+ View your quota →
895
+ </Button>
896
+ </div>
897
+ </div>
898
+ )}
899
+
900
+ {/* ── Status footer ─────────────────────────────────────────── */}
901
+ <div className="minimax-wizard-footer">
902
+ <ShieldCheck size={11} />
903
+ <span>Key is written to <code>~/.local/share/opencode/auth.json</code> with mode 0600.</span>
904
+ </div>
905
+ </Card>
906
+ </div>
907
+ );
908
+ }