@geminilight/mindos 0.4.0 → 0.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.
@@ -1,8 +1,11 @@
1
1
  'use client';
2
2
 
3
3
  import { useState, useEffect, useCallback } from 'react';
4
- import { useRouter } from 'next/navigation';
5
- import { Sparkles, Globe, BookOpen, FileText, Copy, Check, RefreshCw, Loader2, ChevronLeft, ChevronRight, AlertTriangle } from 'lucide-react';
4
+ import {
5
+ Sparkles, Globe, BookOpen, FileText, Copy, Check, RefreshCw,
6
+ Loader2, ChevronLeft, ChevronRight, AlertTriangle, CheckCircle2,
7
+ XCircle, Zap, Brain, SkipForward,
8
+ } from 'lucide-react';
6
9
  import { useLocale } from '@/lib/LocaleContext';
7
10
  import { Field, Input, Select, ApiKeyInput } from '@/components/settings/Primitives';
8
11
 
@@ -23,21 +26,514 @@ interface SetupState {
23
26
  webPassword: string;
24
27
  }
25
28
 
26
- const TEMPLATES: Array<{
27
- id: Template;
28
- icon: React.ReactNode;
29
- dirs: string[];
30
- }> = [
29
+ interface PortStatus {
30
+ checking: boolean;
31
+ available: boolean | null;
32
+ suggestion: number | null;
33
+ }
34
+
35
+ interface AgentEntry {
36
+ key: string;
37
+ name: string;
38
+ installed: boolean;
39
+ hasProjectScope: boolean;
40
+ hasGlobalScope: boolean;
41
+ }
42
+
43
+ type AgentInstallState = 'pending' | 'installing' | 'ok' | 'error';
44
+ interface AgentInstallStatus {
45
+ state: AgentInstallState;
46
+ message?: string;
47
+ }
48
+
49
+ const TEMPLATES: Array<{ id: Template; icon: React.ReactNode; dirs: string[] }> = [
31
50
  { id: 'en', icon: <Globe size={18} />, dirs: ['Profile/', 'Connections/', 'Notes/', 'Workflows/', 'Resources/', 'Projects/'] },
32
51
  { id: 'zh', icon: <BookOpen size={18} />, dirs: ['画像/', '关系/', '笔记/', '流程/', '资源/', '项目/'] },
33
52
  { id: 'empty', icon: <FileText size={18} />, dirs: ['README.md', 'CONFIG.json', 'INSTRUCTION.md'] },
34
53
  ];
35
54
 
36
- const TOTAL_STEPS = 5;
55
+ const TOTAL_STEPS = 6;
56
+ const STEP_KB = 0;
57
+ const STEP_PORTS = 2;
58
+ const STEP_AGENTS = 4;
59
+
60
+ // ─── Step 4 (Security) ────────────────────────────────────────────────────────
61
+ // Extracted at module level so its local seed/showSeed state survives parent re-renders
62
+ function Step4Inner({
63
+ authToken, tokenCopied, onCopy, onGenerate, webPassword, onPasswordChange, s,
64
+ }: {
65
+ authToken: string;
66
+ tokenCopied: boolean;
67
+ onCopy: () => void;
68
+ onGenerate: (seed?: string) => void;
69
+ webPassword: string;
70
+ onPasswordChange: (v: string) => void;
71
+ s: {
72
+ authToken: string; authTokenHint: string; authTokenSeed: string; authTokenSeedHint: string;
73
+ generateToken: string; copyToken: string; copiedToken: string;
74
+ webPassword: string; webPasswordHint: string;
75
+ };
76
+ }) {
77
+ const [seed, setSeed] = useState('');
78
+ const [showSeed, setShowSeed] = useState(false);
79
+ return (
80
+ <div className="space-y-5">
81
+ <Field label={s.authToken} hint={s.authTokenHint}>
82
+ <div className="flex gap-2">
83
+ <Input value={authToken} readOnly className="font-mono text-xs" />
84
+ <button onClick={onCopy}
85
+ className="flex items-center gap-1 px-3 py-2 text-xs rounded-lg border border-border hover:bg-muted transition-colors shrink-0"
86
+ style={{ color: 'var(--foreground)' }}>
87
+ {tokenCopied ? <Check size={14} /> : <Copy size={14} />}
88
+ {tokenCopied ? s.copiedToken : s.copyToken}
89
+ </button>
90
+ <button onClick={() => onGenerate()}
91
+ className="flex items-center gap-1 px-3 py-2 text-xs rounded-lg border border-border hover:bg-muted transition-colors shrink-0"
92
+ style={{ color: 'var(--foreground)' }}>
93
+ <RefreshCw size={14} />
94
+ </button>
95
+ </div>
96
+ </Field>
97
+ <div>
98
+ <button onClick={() => setShowSeed(!showSeed)} className="text-xs underline"
99
+ style={{ color: 'var(--muted-foreground)' }}>
100
+ {s.authTokenSeed}
101
+ </button>
102
+ {showSeed && (
103
+ <div className="mt-2 flex gap-2">
104
+ <Input value={seed} onChange={e => setSeed(e.target.value)} placeholder={s.authTokenSeedHint} />
105
+ <button onClick={() => { if (seed.trim()) onGenerate(seed); }}
106
+ className="px-3 py-2 text-xs rounded-lg border border-border hover:bg-muted transition-colors shrink-0"
107
+ style={{ color: 'var(--foreground)' }}>
108
+ {s.generateToken}
109
+ </button>
110
+ </div>
111
+ )}
112
+ </div>
113
+ <Field label={s.webPassword} hint={s.webPasswordHint}>
114
+ <Input type="password" value={webPassword} onChange={e => onPasswordChange(e.target.value)} placeholder="(optional)" />
115
+ </Field>
116
+ </div>
117
+ );
118
+ }
119
+
120
+ // ─── PortField ────────────────────────────────────────────────────────────────
121
+ function PortField({
122
+ label, hint, value, onChange, status, onCheckPort, s,
123
+ }: {
124
+ label: string; hint: string; value: number;
125
+ onChange: (v: number) => void;
126
+ status: PortStatus;
127
+ onCheckPort: (port: number) => void;
128
+ s: { portChecking: string; portInUse: (p: number) => string; portSuggest: (p: number) => string; portAvailable: string };
129
+ }) {
130
+ return (
131
+ <Field label={label} hint={hint}>
132
+ <div className="space-y-1.5">
133
+ <Input
134
+ type="number" min={1024} max={65535} value={value}
135
+ onChange={e => onChange(parseInt(e.target.value, 10) || value)}
136
+ onBlur={() => onCheckPort(value)}
137
+ />
138
+ {status.checking && (
139
+ <p className="text-xs flex items-center gap-1" style={{ color: 'var(--muted-foreground)' }}>
140
+ <Loader2 size={11} className="animate-spin" /> {s.portChecking}
141
+ </p>
142
+ )}
143
+ {!status.checking && status.available === false && (
144
+ <div className="flex items-center gap-2">
145
+ <p className="text-xs flex items-center gap-1" style={{ color: 'var(--amber)' }}>
146
+ <AlertTriangle size={11} /> {s.portInUse(value)}
147
+ </p>
148
+ {status.suggestion !== null && (
149
+ <button type="button"
150
+ onClick={() => {
151
+ onChange(status.suggestion!);
152
+ setTimeout(() => onCheckPort(status.suggestion!), 0);
153
+ }}
154
+ className="text-xs px-2 py-0.5 rounded border transition-colors"
155
+ style={{ borderColor: 'var(--amber)', color: 'var(--amber)' }}>
156
+ {s.portSuggest(status.suggestion)}
157
+ </button>
158
+ )}
159
+ </div>
160
+ )}
161
+ {!status.checking && status.available === true && (
162
+ <p className="text-xs flex items-center gap-1" style={{ color: '#22c55e' }}>
163
+ <CheckCircle2 size={11} /> {s.portAvailable}
164
+ </p>
165
+ )}
166
+ </div>
167
+ </Field>
168
+ );
169
+ }
170
+
171
+ // ─── Step 1: Knowledge Base ───────────────────────────────────────────────────
172
+ function Step1({
173
+ state, update, t,
174
+ }: {
175
+ state: SetupState;
176
+ update: <K extends keyof SetupState>(key: K, val: SetupState[K]) => void;
177
+ t: ReturnType<typeof useLocale>['t'];
178
+ }) {
179
+ const s = t.setup;
180
+ return (
181
+ <div className="space-y-6">
182
+ <Field label={s.kbPath} hint={s.kbPathHint}>
183
+ <Input value={state.mindRoot} onChange={e => update('mindRoot', e.target.value)} placeholder={s.kbPathDefault} />
184
+ </Field>
185
+ <div>
186
+ <label className="text-sm text-foreground font-medium mb-3 block">{s.template}</label>
187
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
188
+ {TEMPLATES.map(tpl => (
189
+ <button key={tpl.id} onClick={() => update('template', tpl.id)}
190
+ className="flex flex-col items-start gap-2 p-4 rounded-xl border text-left transition-all duration-150"
191
+ style={{
192
+ background: state.template === tpl.id ? 'var(--amber-subtle, rgba(200,135,30,0.08))' : 'var(--card)',
193
+ borderColor: state.template === tpl.id ? 'var(--amber)' : 'var(--border)',
194
+ }}>
195
+ <div className="flex items-center gap-2">
196
+ <span style={{ color: 'var(--amber)' }}>{tpl.icon}</span>
197
+ <span className="text-sm font-medium" style={{ color: 'var(--foreground)' }}>
198
+ {t.onboarding.templates[tpl.id as 'en' | 'zh' | 'empty'].title}
199
+ </span>
200
+ </div>
201
+ <div className="w-full rounded-lg px-2.5 py-1.5 text-[11px] leading-relaxed font-display"
202
+ style={{ background: 'var(--muted)', color: 'var(--muted-foreground)' }}>
203
+ {tpl.dirs.map(d => <div key={d}>{d}</div>)}
204
+ </div>
205
+ </button>
206
+ ))}
207
+ </div>
208
+ </div>
209
+ </div>
210
+ );
211
+ }
212
+
213
+ // ─── Step 2: AI Provider ──────────────────────────────────────────────────────
214
+ function Step2({
215
+ state, update, s,
216
+ }: {
217
+ state: SetupState;
218
+ update: <K extends keyof SetupState>(key: K, val: SetupState[K]) => void;
219
+ s: ReturnType<typeof useLocale>['t']['setup'];
220
+ }) {
221
+ const providers = [
222
+ { id: 'anthropic' as const, icon: <Brain size={18} />, label: 'Anthropic', desc: 'Claude — claude-sonnet-4-6' },
223
+ { id: 'openai' as const, icon: <Zap size={18} />, label: 'OpenAI', desc: 'GPT or any OpenAI-compatible API' },
224
+ { id: 'skip' as const, icon: <SkipForward size={18} />, label: s.aiSkipTitle, desc: s.aiSkipDesc },
225
+ ];
226
+ return (
227
+ <div className="space-y-5">
228
+ <div className="grid grid-cols-1 gap-3">
229
+ {providers.map(p => (
230
+ <button key={p.id} onClick={() => update('provider', p.id)}
231
+ className="flex items-start gap-3 p-4 rounded-xl border text-left transition-all duration-150"
232
+ style={{
233
+ background: state.provider === p.id ? 'var(--amber-subtle, rgba(200,135,30,0.08))' : 'var(--card)',
234
+ borderColor: state.provider === p.id ? 'var(--amber)' : 'var(--border)',
235
+ }}>
236
+ <span className="mt-0.5" style={{ color: state.provider === p.id ? 'var(--amber)' : 'var(--muted-foreground)' }}>
237
+ {p.icon}
238
+ </span>
239
+ <div>
240
+ <p className="text-sm font-medium" style={{ color: 'var(--foreground)' }}>{p.label}</p>
241
+ <p className="text-xs mt-0.5" style={{ color: 'var(--muted-foreground)' }}>{p.desc}</p>
242
+ </div>
243
+ {state.provider === p.id && (
244
+ <CheckCircle2 size={16} className="ml-auto mt-0.5 shrink-0" style={{ color: 'var(--amber)' }} />
245
+ )}
246
+ </button>
247
+ ))}
248
+ </div>
249
+ {state.provider !== 'skip' && (
250
+ <div className="space-y-4 pt-2">
251
+ <Field label={s.apiKey}>
252
+ <ApiKeyInput
253
+ value={state.provider === 'anthropic' ? state.anthropicKey : state.openaiKey}
254
+ onChange={v => update(state.provider === 'anthropic' ? 'anthropicKey' : 'openaiKey', v)}
255
+ placeholder={state.provider === 'anthropic' ? 'sk-ant-...' : 'sk-...'}
256
+ />
257
+ </Field>
258
+ <Field label={s.model}>
259
+ <Input
260
+ value={state.provider === 'anthropic' ? state.anthropicModel : state.openaiModel}
261
+ onChange={e => update(state.provider === 'anthropic' ? 'anthropicModel' : 'openaiModel', e.target.value)}
262
+ />
263
+ </Field>
264
+ {state.provider === 'openai' && (
265
+ <Field label={s.baseUrl} hint={s.baseUrlHint}>
266
+ <Input value={state.openaiBaseUrl} onChange={e => update('openaiBaseUrl', e.target.value)}
267
+ placeholder="https://api.openai.com/v1" />
268
+ </Field>
269
+ )}
270
+ </div>
271
+ )}
272
+ </div>
273
+ );
274
+ }
275
+
276
+ // ─── Step 3: Ports ────────────────────────────────────────────────────────────
277
+ function Step3({
278
+ state, update, webPortStatus, mcpPortStatus, setWebPortStatus, setMcpPortStatus, checkPort, portConflict, s,
279
+ }: {
280
+ state: SetupState;
281
+ update: <K extends keyof SetupState>(key: K, val: SetupState[K]) => void;
282
+ webPortStatus: PortStatus;
283
+ mcpPortStatus: PortStatus;
284
+ setWebPortStatus: (s: PortStatus) => void;
285
+ setMcpPortStatus: (s: PortStatus) => void;
286
+ checkPort: (port: number, which: 'web' | 'mcp') => void;
287
+ portConflict: boolean;
288
+ s: ReturnType<typeof useLocale>['t']['setup'];
289
+ }) {
290
+ return (
291
+ <div className="space-y-5">
292
+ <PortField
293
+ label={s.webPort} hint={s.portHint} value={state.webPort}
294
+ onChange={v => { update('webPort', v); setWebPortStatus({ checking: false, available: null, suggestion: null }); }}
295
+ status={webPortStatus}
296
+ onCheckPort={port => checkPort(port, 'web')}
297
+ s={s}
298
+ />
299
+ <PortField
300
+ label={s.mcpPort} hint={s.portHint} value={state.mcpPort}
301
+ onChange={v => { update('mcpPort', v); setMcpPortStatus({ checking: false, available: null, suggestion: null }); }}
302
+ status={mcpPortStatus}
303
+ onCheckPort={port => checkPort(port, 'mcp')}
304
+ s={s}
305
+ />
306
+ {portConflict && (
307
+ <p className="text-xs flex items-center gap-1.5" style={{ color: 'var(--amber)' }}>
308
+ <AlertTriangle size={12} /> {s.portConflict}
309
+ </p>
310
+ )}
311
+ {!portConflict && (webPortStatus.available === null || mcpPortStatus.available === null) && !webPortStatus.checking && !mcpPortStatus.checking && (
312
+ <p className="text-xs" style={{ color: 'var(--muted-foreground)' }}>{s.portVerifyHint}</p>
313
+ )}
314
+ <p className="text-xs flex items-center gap-1.5" style={{ color: 'var(--muted-foreground)' }}>
315
+ <AlertTriangle size={12} /> {s.portRestartWarning}
316
+ </p>
317
+ </div>
318
+ );
319
+ }
320
+
321
+ // ─── Step 5: Agent Tools ──────────────────────────────────────────────────────
322
+ function Step5({
323
+ agents, agentsLoading, selectedAgents, setSelectedAgents,
324
+ agentTransport, setAgentTransport, agentScope, setAgentScope,
325
+ agentStatuses, s, settingsMcp,
326
+ }: {
327
+ agents: AgentEntry[];
328
+ agentsLoading: boolean;
329
+ selectedAgents: Set<string>;
330
+ setSelectedAgents: React.Dispatch<React.SetStateAction<Set<string>>>;
331
+ agentTransport: 'stdio' | 'http';
332
+ setAgentTransport: (v: 'stdio' | 'http') => void;
333
+ agentScope: 'global' | 'project';
334
+ setAgentScope: (v: 'global' | 'project') => void;
335
+ agentStatuses: Record<string, AgentInstallStatus>;
336
+ s: ReturnType<typeof useLocale>['t']['setup'];
337
+ settingsMcp: ReturnType<typeof useLocale>['t']['settings']['mcp'];
338
+ }) {
339
+ const toggleAgent = (key: string) => {
340
+ setSelectedAgents(prev => {
341
+ const next = new Set(prev);
342
+ if (next.has(key)) next.delete(key); else next.add(key);
343
+ return next;
344
+ });
345
+ };
346
+
347
+ const getStatusBadge = (key: string, installed: boolean) => {
348
+ const st = agentStatuses[key];
349
+ if (st) {
350
+ if (st.state === 'installing') return (
351
+ <span className="flex items-center gap-1 text-[11px]" style={{ color: 'var(--muted-foreground)' }}>
352
+ <Loader2 size={10} className="animate-spin" /> {s.agentInstalling}
353
+ </span>
354
+ );
355
+ if (st.state === 'ok') return (
356
+ <span className="flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded"
357
+ style={{ background: 'rgba(34,197,94,0.12)', color: '#22c55e' }}>
358
+ <CheckCircle2 size={10} /> {s.agentStatusOk}
359
+ </span>
360
+ );
361
+ if (st.state === 'error') return (
362
+ <span className="flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded"
363
+ style={{ background: 'rgba(239,68,68,0.1)', color: '#ef4444' }}>
364
+ <XCircle size={10} /> {s.agentStatusError}
365
+ {st.message && <span className="ml-1 text-[10px]">({st.message})</span>}
366
+ </span>
367
+ );
368
+ }
369
+ if (installed) return (
370
+ <span className="text-[11px] px-1.5 py-0.5 rounded"
371
+ style={{ background: 'rgba(34,197,94,0.12)', color: '#22c55e' }}>
372
+ {settingsMcp.installed}
373
+ </span>
374
+ );
375
+ return (
376
+ <span className="text-[11px] px-1.5 py-0.5 rounded"
377
+ style={{ background: 'rgba(100,100,120,0.1)', color: 'var(--muted-foreground)' }}>
378
+ {s.agentNotInstalled}
379
+ </span>
380
+ );
381
+ };
382
+
383
+ return (
384
+ <div className="space-y-5">
385
+ <p className="text-sm" style={{ color: 'var(--muted-foreground)' }}>{s.agentToolsHint}</p>
386
+ {agentsLoading ? (
387
+ <div className="flex items-center gap-2 py-4" style={{ color: 'var(--muted-foreground)' }}>
388
+ <Loader2 size={14} className="animate-spin" />
389
+ <span className="text-sm">{s.agentToolsLoading}</span>
390
+ </div>
391
+ ) : agents.length === 0 ? (
392
+ <p className="text-sm py-4 text-center" style={{ color: 'var(--muted-foreground)' }}>
393
+ {s.agentToolsEmpty}
394
+ </p>
395
+ ) : (
396
+ <>
397
+ <div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border)' }}>
398
+ {agents.map((agent, i) => (
399
+ <label key={agent.key}
400
+ className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-muted/50 transition-colors"
401
+ style={{
402
+ background: i % 2 === 0 ? 'var(--card)' : 'transparent',
403
+ borderTop: i > 0 ? '1px solid var(--border)' : undefined,
404
+ }}>
405
+ <input
406
+ type="checkbox"
407
+ checked={selectedAgents.has(agent.key)}
408
+ onChange={() => toggleAgent(agent.key)}
409
+ className="accent-amber-500"
410
+ disabled={agentStatuses[agent.key]?.state === 'installing'}
411
+ />
412
+ <span className="text-sm flex-1" style={{ color: 'var(--foreground)' }}>{agent.name}</span>
413
+ {getStatusBadge(agent.key, agent.installed)}
414
+ </label>
415
+ ))}
416
+ </div>
417
+ <div className="grid grid-cols-2 gap-4">
418
+ <Field label={s.agentTransport}>
419
+ <Select value={agentTransport} onChange={e => setAgentTransport(e.target.value as 'stdio' | 'http')}>
420
+ <option value="stdio">{settingsMcp.transportStdio}</option>
421
+ <option value="http">{settingsMcp.transportHttp}</option>
422
+ </Select>
423
+ </Field>
424
+ <Field label={s.agentScope}>
425
+ <Select value={agentScope} onChange={e => setAgentScope(e.target.value as 'global' | 'project')}>
426
+ <option value="global">{settingsMcp.global}</option>
427
+ <option value="project">{settingsMcp.project}</option>
428
+ </Select>
429
+ </Field>
430
+ </div>
431
+ {selectedAgents.size === 0 && (
432
+ <p className="text-xs" style={{ color: 'var(--muted-foreground)' }}>{s.agentNoneSelected}</p>
433
+ )}
434
+ </>
435
+ )}
436
+ </div>
437
+ );
438
+ }
439
+
440
+ // ─── Step 6: Review ───────────────────────────────────────────────────────────
441
+ function Step6({
442
+ state, selectedAgents, error, portChanged, maskKey, s,
443
+ }: {
444
+ state: SetupState;
445
+ selectedAgents: Set<string>;
446
+ error: string;
447
+ portChanged: boolean;
448
+ maskKey: (key: string) => string;
449
+ s: ReturnType<typeof useLocale>['t']['setup'];
450
+ }) {
451
+ const rows: [string, string][] = [
452
+ [s.kbPath, state.mindRoot],
453
+ [s.template, state.template || '—'],
454
+ [s.aiProvider, state.provider === 'skip' ? s.aiSkipTitle : state.provider],
455
+ ...(state.provider !== 'skip' ? [
456
+ [s.apiKey, maskKey(state.provider === 'anthropic' ? state.anthropicKey : state.openaiKey)] as [string, string],
457
+ [s.model, state.provider === 'anthropic' ? state.anthropicModel : state.openaiModel] as [string, string],
458
+ ] : []),
459
+ [s.webPort, String(state.webPort)],
460
+ [s.mcpPort, String(state.mcpPort)],
461
+ [s.authToken, state.authToken || '—'],
462
+ [s.webPassword, state.webPassword ? '••••••••' : '(none)'],
463
+ [s.agentToolsTitle, selectedAgents.size > 0 ? Array.from(selectedAgents).join(', ') : '—'],
464
+ ];
465
+
466
+ return (
467
+ <div className="space-y-5">
468
+ <p className="text-sm" style={{ color: 'var(--muted-foreground)' }}>{s.reviewHint}</p>
469
+ <div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border)' }}>
470
+ {rows.map(([label, value], i) => (
471
+ <div key={i} className="flex items-center justify-between px-4 py-3 text-sm"
472
+ style={{
473
+ background: i % 2 === 0 ? 'var(--card)' : 'transparent',
474
+ borderTop: i > 0 ? '1px solid var(--border)' : undefined,
475
+ }}>
476
+ <span style={{ color: 'var(--muted-foreground)' }}>{label}</span>
477
+ <span className="font-mono text-xs" style={{ color: 'var(--foreground)' }}>{value}</span>
478
+ </div>
479
+ ))}
480
+ </div>
481
+ {error && (
482
+ <div className="p-3 rounded-lg text-sm text-red-500" style={{ background: 'rgba(239,68,68,0.1)' }}>
483
+ {s.completeFailed}: {error}
484
+ </div>
485
+ )}
486
+ {portChanged && (
487
+ <div className="space-y-3">
488
+ <div className="p-3 rounded-lg text-sm flex items-center gap-2"
489
+ style={{ background: 'rgba(200,135,30,0.1)', color: 'var(--amber)' }}>
490
+ <AlertTriangle size={14} /> {s.portChanged}
491
+ </div>
492
+ <a href="/" className="inline-flex items-center gap-1 px-4 py-2 text-sm rounded-lg transition-colors"
493
+ style={{ background: 'var(--amber)', color: 'white' }}>
494
+ {s.completeDone} &rarr;
495
+ </a>
496
+ </div>
497
+ )}
498
+ </div>
499
+ );
500
+ }
501
+
502
+ // ─── Step dots ────────────────────────────────────────────────────────────────
503
+ function StepDots({ step, setStep, stepTitles }: {
504
+ step: number;
505
+ setStep: (s: number) => void;
506
+ stepTitles: readonly string[];
507
+ }) {
508
+ return (
509
+ <div className="flex items-center gap-2 mb-8">
510
+ {stepTitles.map((title: string, i: number) => (
511
+ <div key={i} className="flex items-center gap-2">
512
+ {i > 0 && <div className="w-8 h-px" style={{ background: i <= step ? 'var(--amber)' : 'var(--border)' }} />}
513
+ <button onClick={() => i < step && setStep(i)} className="flex items-center gap-1.5" disabled={i > step}>
514
+ <div
515
+ className="w-6 h-6 rounded-full text-xs font-medium flex items-center justify-center transition-colors"
516
+ style={{
517
+ background: i <= step ? 'var(--amber)' : 'var(--muted)',
518
+ color: i <= step ? 'white' : 'var(--muted-foreground)',
519
+ opacity: i <= step ? 1 : 0.5,
520
+ }}>
521
+ {i + 1}
522
+ </div>
523
+ <span className="text-xs hidden sm:inline"
524
+ style={{ color: i === step ? 'var(--foreground)' : 'var(--muted-foreground)', opacity: i <= step ? 1 : 0.5 }}>
525
+ {title}
526
+ </span>
527
+ </button>
528
+ </div>
529
+ ))}
530
+ </div>
531
+ );
532
+ }
37
533
 
534
+ // ─── Main component ───────────────────────────────────────────────────────────
38
535
  export default function SetupWizard() {
39
536
  const { t } = useLocale();
40
- const router = useRouter();
41
537
  const s = t.setup;
42
538
 
43
539
  const [step, setStep] = useState(0);
@@ -60,6 +556,16 @@ export default function SetupWizard() {
60
556
  const [error, setError] = useState('');
61
557
  const [portChanged, setPortChanged] = useState(false);
62
558
 
559
+ const [webPortStatus, setWebPortStatus] = useState<PortStatus>({ checking: false, available: null, suggestion: null });
560
+ const [mcpPortStatus, setMcpPortStatus] = useState<PortStatus>({ checking: false, available: null, suggestion: null });
561
+
562
+ const [agents, setAgents] = useState<AgentEntry[]>([]);
563
+ const [agentsLoading, setAgentsLoading] = useState(false);
564
+ const [selectedAgents, setSelectedAgents] = useState<Set<string>>(new Set());
565
+ const [agentTransport, setAgentTransport] = useState<'stdio' | 'http'>('stdio');
566
+ const [agentScope, setAgentScope] = useState<'global' | 'project'>('global');
567
+ const [agentStatuses, setAgentStatuses] = useState<Record<string, AgentInstallStatus>>({});
568
+
63
569
  // Generate token on mount
64
570
  useEffect(() => {
65
571
  fetch('/api/setup/generate-token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
@@ -68,11 +574,39 @@ export default function SetupWizard() {
68
574
  .catch(() => {});
69
575
  }, []);
70
576
 
577
+ // Auto-check ports when entering Step 3
578
+ useEffect(() => {
579
+ if (step === STEP_PORTS) {
580
+ checkPort(state.webPort, 'web');
581
+ checkPort(state.mcpPort, 'mcp');
582
+ }
583
+ // eslint-disable-next-line react-hooks/exhaustive-deps
584
+ }, [step]);
585
+
586
+ // Load agents when entering Step 5
587
+ useEffect(() => {
588
+ if (step === STEP_AGENTS && agents.length === 0 && !agentsLoading) {
589
+ setAgentsLoading(true);
590
+ fetch('/api/mcp/agents')
591
+ .then(r => r.json())
592
+ .then(data => {
593
+ if (data.agents) {
594
+ setAgents(data.agents);
595
+ setSelectedAgents(new Set(
596
+ (data.agents as AgentEntry[]).filter(a => a.installed).map(a => a.key)
597
+ ));
598
+ }
599
+ })
600
+ .catch(() => {})
601
+ .finally(() => setAgentsLoading(false));
602
+ }
603
+ }, [step, agents.length, agentsLoading]);
604
+
71
605
  const update = useCallback(<K extends keyof SetupState>(key: K, val: SetupState[K]) => {
72
606
  setState(prev => ({ ...prev, [key]: val }));
73
607
  }, []);
74
608
 
75
- const generateToken = async (seed?: string) => {
609
+ const generateToken = useCallback(async (seed?: string) => {
76
610
  try {
77
611
  const res = await fetch('/api/setup/generate-token', {
78
612
  method: 'POST',
@@ -80,24 +614,64 @@ export default function SetupWizard() {
80
614
  body: JSON.stringify({ seed: seed || undefined }),
81
615
  });
82
616
  const data = await res.json();
83
- if (data.token) update('authToken', data.token);
617
+ if (data.token) setState(prev => ({ ...prev, authToken: data.token }));
84
618
  } catch { /* ignore */ }
85
- };
619
+ }, []);
86
620
 
87
- const copyToken = () => {
88
- navigator.clipboard.writeText(state.authToken);
621
+ const copyToken = useCallback(() => {
622
+ setState(prev => { navigator.clipboard.writeText(prev.authToken); return prev; });
89
623
  setTokenCopied(true);
90
624
  setTimeout(() => setTokenCopied(false), 2000);
625
+ }, []);
626
+
627
+ const checkPort = useCallback(async (port: number, which: 'web' | 'mcp') => {
628
+ if (port < 1024 || port > 65535) return;
629
+ const setStatus = which === 'web' ? setWebPortStatus : setMcpPortStatus;
630
+ setStatus({ checking: true, available: null, suggestion: null });
631
+ try {
632
+ const res = await fetch('/api/setup/check-port', {
633
+ method: 'POST',
634
+ headers: { 'Content-Type': 'application/json' },
635
+ body: JSON.stringify({ port }),
636
+ });
637
+ const data = await res.json();
638
+ setStatus({ checking: false, available: data.available ?? null, suggestion: data.suggestion ?? null });
639
+ } catch {
640
+ setStatus({ checking: false, available: null, suggestion: null });
641
+ }
642
+ }, []);
643
+
644
+ const maskKey = (key: string) => {
645
+ if (!key) return '(not set)';
646
+ if (key.length <= 8) return '•••';
647
+ return key.slice(0, 6) + '•••' + key.slice(-3);
648
+ };
649
+
650
+ const portConflict = state.webPort === state.mcpPort;
651
+
652
+ const canNext = () => {
653
+ if (step === STEP_KB) return state.mindRoot.trim().length > 0;
654
+ if (step === STEP_PORTS) {
655
+ if (portConflict) return false;
656
+ if (webPortStatus.checking || mcpPortStatus.checking) return false;
657
+ if (webPortStatus.available !== true || mcpPortStatus.available !== true) return false;
658
+ return (
659
+ state.webPort >= 1024 && state.webPort <= 65535 &&
660
+ state.mcpPort >= 1024 && state.mcpPort <= 65535
661
+ );
662
+ }
663
+ return true;
91
664
  };
92
665
 
93
666
  const handleComplete = async () => {
94
667
  setSubmitting(true);
95
668
  setError('');
669
+ let didPortChange = false;
670
+
671
+ // 1. Save setup config
96
672
  try {
97
673
  const payload = {
98
- mindRoot: state.mindRoot.startsWith('~')
99
- ? state.mindRoot // server will resolve
100
- : state.mindRoot,
674
+ mindRoot: state.mindRoot,
101
675
  template: state.template || undefined,
102
676
  port: state.webPort,
103
677
  mcpPort: state.mcpPort,
@@ -118,326 +692,111 @@ export default function SetupWizard() {
118
692
  });
119
693
  const data = await res.json();
120
694
  if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
121
- if (data.portChanged) setPortChanged(true);
122
- else router.push('/');
695
+ didPortChange = !!data.portChanged;
696
+ if (didPortChange) setPortChanged(true);
123
697
  } catch (e) {
124
698
  setError(e instanceof Error ? e.message : String(e));
125
- } finally {
126
699
  setSubmitting(false);
700
+ return;
127
701
  }
128
- };
129
-
130
- const canNext = () => {
131
- if (step === 0) return state.mindRoot.trim().length > 0;
132
- if (step === 2) return state.webPort >= 1024 && state.webPort <= 65535 && state.mcpPort >= 1024 && state.mcpPort <= 65535;
133
- return true;
134
- };
135
-
136
- const maskKey = (key: string) => {
137
- if (!key) return '(not set)';
138
- if (key.length <= 8) return '•••';
139
- return key.slice(0, 6) + '•••' + key.slice(-3);
140
- };
141
-
142
- // Step indicator dots
143
- const StepDots = () => (
144
- <div className="flex items-center gap-2 mb-8">
145
- {s.stepTitles.map((title: string, i: number) => (
146
- <div key={i} className="flex items-center gap-2">
147
- {i > 0 && <div className="w-8 h-px" style={{ background: i <= step ? 'var(--amber)' : 'var(--border)' }} />}
148
- <button
149
- onClick={() => i < step && setStep(i)}
150
- className="flex items-center gap-1.5"
151
- disabled={i > step}
152
- >
153
- <div
154
- className="w-6 h-6 rounded-full text-xs font-medium flex items-center justify-center transition-colors"
155
- style={{
156
- background: i === step ? 'var(--amber)' : i < step ? 'var(--amber)' : 'var(--muted)',
157
- color: i <= step ? 'white' : 'var(--muted-foreground)',
158
- opacity: i <= step ? 1 : 0.5,
159
- }}
160
- >
161
- {i + 1}
162
- </div>
163
- <span
164
- className="text-xs hidden sm:inline"
165
- style={{ color: i === step ? 'var(--foreground)' : 'var(--muted-foreground)', opacity: i <= step ? 1 : 0.5 }}
166
- >
167
- {title}
168
- </span>
169
- </button>
170
- </div>
171
- ))}
172
- </div>
173
- );
174
-
175
- // Step 1: Knowledge Base
176
- const Step1 = () => (
177
- <div className="space-y-6">
178
- <Field label={s.kbPath} hint={s.kbPathHint}>
179
- <Input
180
- value={state.mindRoot}
181
- onChange={e => update('mindRoot', e.target.value)}
182
- placeholder={s.kbPathDefault}
183
- />
184
- </Field>
185
- <div>
186
- <label className="text-sm text-foreground font-medium mb-3 block">{s.template}</label>
187
- <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
188
- {TEMPLATES.map(tpl => (
189
- <button
190
- key={tpl.id}
191
- onClick={() => update('template', tpl.id)}
192
- className="flex flex-col items-start gap-2 p-4 rounded-xl border text-left transition-all duration-150"
193
- style={{
194
- background: state.template === tpl.id ? 'var(--amber-subtle, rgba(200,135,30,0.08))' : 'var(--card)',
195
- borderColor: state.template === tpl.id ? 'var(--amber)' : 'var(--border)',
196
- }}
197
- >
198
- <div className="flex items-center gap-2">
199
- <span style={{ color: 'var(--amber)' }}>{tpl.icon}</span>
200
- <span className="text-sm font-medium" style={{ color: 'var(--foreground)' }}>
201
- {t.onboarding.templates[tpl.id as 'en' | 'zh' | 'empty'].title}
202
- </span>
203
- </div>
204
- <div
205
- className="w-full rounded-lg px-2.5 py-1.5 text-[11px] leading-relaxed font-display"
206
- style={{ background: 'var(--muted)', color: 'var(--muted-foreground)' }}
207
- >
208
- {tpl.dirs.map(d => <div key={d}>{d}</div>)}
209
- </div>
210
- </button>
211
- ))}
212
- </div>
213
- </div>
214
- </div>
215
- );
216
-
217
- // Step 2: AI Provider
218
- const Step2 = () => (
219
- <div className="space-y-5">
220
- <Field label={s.aiProvider} hint={s.aiProviderHint}>
221
- <Select value={state.provider} onChange={e => update('provider', e.target.value as SetupState['provider'])}>
222
- <option value="anthropic">Anthropic</option>
223
- <option value="openai">OpenAI</option>
224
- <option value="skip">{s.aiSkip}</option>
225
- </Select>
226
- </Field>
227
- {state.provider !== 'skip' && (
228
- <>
229
- <Field label={s.apiKey}>
230
- <ApiKeyInput
231
- value={state.provider === 'anthropic' ? state.anthropicKey : state.openaiKey}
232
- onChange={v => update(state.provider === 'anthropic' ? 'anthropicKey' : 'openaiKey', v)}
233
- placeholder={state.provider === 'anthropic' ? 'sk-ant-...' : 'sk-...'}
234
- />
235
- </Field>
236
- <Field label={s.model}>
237
- <Input
238
- value={state.provider === 'anthropic' ? state.anthropicModel : state.openaiModel}
239
- onChange={e => update(state.provider === 'anthropic' ? 'anthropicModel' : 'openaiModel', e.target.value)}
240
- />
241
- </Field>
242
- {state.provider === 'openai' && (
243
- <Field label={s.baseUrl} hint={s.baseUrlHint}>
244
- <Input
245
- value={state.openaiBaseUrl}
246
- onChange={e => update('openaiBaseUrl', e.target.value)}
247
- placeholder="https://api.openai.com/v1"
248
- />
249
- </Field>
250
- )}
251
- </>
252
- )}
253
- </div>
254
- );
255
-
256
- // Step 3: Ports
257
- const Step3 = () => (
258
- <div className="space-y-5">
259
- <Field label={s.webPort} hint={s.portHint}>
260
- <Input
261
- type="number"
262
- min={1024}
263
- max={65535}
264
- value={state.webPort}
265
- onChange={e => update('webPort', parseInt(e.target.value, 10) || 3000)}
266
- />
267
- </Field>
268
- <Field label={s.mcpPort} hint={s.portHint}>
269
- <Input
270
- type="number"
271
- min={1024}
272
- max={65535}
273
- value={state.mcpPort}
274
- onChange={e => update('mcpPort', parseInt(e.target.value, 10) || 8787)}
275
- />
276
- </Field>
277
- <p className="text-xs flex items-center gap-1.5" style={{ color: 'var(--muted-foreground)' }}>
278
- <AlertTriangle size={12} />
279
- {s.portRestartWarning}
280
- </p>
281
- </div>
282
- );
283
-
284
- // Step 4: Security
285
- const Step4 = () => {
286
- const [seed, setSeed] = useState('');
287
- const [showSeed, setShowSeed] = useState(false);
288
-
289
- return (
290
- <div className="space-y-5">
291
- <Field label={s.authToken} hint={s.authTokenHint}>
292
- <div className="flex gap-2">
293
- <Input value={state.authToken} readOnly className="font-mono text-xs" />
294
- <button
295
- onClick={copyToken}
296
- className="flex items-center gap-1 px-3 py-2 text-xs rounded-lg border border-border hover:bg-muted transition-colors shrink-0"
297
- style={{ color: 'var(--foreground)' }}
298
- >
299
- {tokenCopied ? <Check size={14} /> : <Copy size={14} />}
300
- {tokenCopied ? s.copiedToken : s.copyToken}
301
- </button>
302
- <button
303
- onClick={() => generateToken()}
304
- className="flex items-center gap-1 px-3 py-2 text-xs rounded-lg border border-border hover:bg-muted transition-colors shrink-0"
305
- style={{ color: 'var(--foreground)' }}
306
- >
307
- <RefreshCw size={14} />
308
- </button>
309
- </div>
310
- </Field>
311
702
 
312
- <div>
313
- <button
314
- onClick={() => setShowSeed(!showSeed)}
315
- className="text-xs underline"
316
- style={{ color: 'var(--muted-foreground)' }}
317
- >
318
- {s.authTokenSeed}
319
- </button>
320
- {showSeed && (
321
- <div className="mt-2 flex gap-2">
322
- <Input
323
- value={seed}
324
- onChange={e => setSeed(e.target.value)}
325
- placeholder={s.authTokenSeedHint}
326
- />
327
- <button
328
- onClick={() => { if (seed.trim()) generateToken(seed); }}
329
- className="px-3 py-2 text-xs rounded-lg border border-border hover:bg-muted transition-colors shrink-0"
330
- style={{ color: 'var(--foreground)' }}
331
- >
332
- {s.generateToken}
333
- </button>
334
- </div>
335
- )}
336
- </div>
337
-
338
- <Field label={s.webPassword} hint={s.webPasswordHint}>
339
- <Input
340
- type="password"
341
- value={state.webPassword}
342
- onChange={e => update('webPassword', e.target.value)}
343
- placeholder="(optional)"
344
- />
345
- </Field>
346
- </div>
347
- );
348
- };
349
-
350
- // Step 5: Review
351
- const Step5 = () => {
352
- const rows: [string, string][] = [
353
- [s.kbPath, state.mindRoot],
354
- [s.template, state.template || '—'],
355
- [s.aiProvider, state.provider],
356
- ...(state.provider !== 'skip' ? [
357
- [s.apiKey, maskKey(state.provider === 'anthropic' ? state.anthropicKey : state.openaiKey)] as [string, string],
358
- [s.model, state.provider === 'anthropic' ? state.anthropicModel : state.openaiModel] as [string, string],
359
- ] : []),
360
- [s.webPort, String(state.webPort)],
361
- [s.mcpPort, String(state.mcpPort)],
362
- [s.authToken, state.authToken || '—'],
363
- [s.webPassword, state.webPassword ? '••••••••' : '(none)'],
364
- ];
365
-
366
- return (
367
- <div className="space-y-5">
368
- <p className="text-sm" style={{ color: 'var(--muted-foreground)' }}>{s.reviewHint}</p>
369
- <div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border)' }}>
370
- {rows.map(([label, value], i) => (
371
- <div
372
- key={i}
373
- className="flex items-center justify-between px-4 py-3 text-sm"
374
- style={{
375
- background: i % 2 === 0 ? 'var(--card)' : 'transparent',
376
- borderTop: i > 0 ? '1px solid var(--border)' : undefined,
377
- }}
378
- >
379
- <span style={{ color: 'var(--muted-foreground)' }}>{label}</span>
380
- <span className="font-mono text-xs" style={{ color: 'var(--foreground)' }}>{value}</span>
381
- </div>
382
- ))}
383
- </div>
703
+ // 2. Install agents after config saved
704
+ if (selectedAgents.size > 0) {
705
+ const initialStatuses: Record<string, AgentInstallStatus> = {};
706
+ for (const key of selectedAgents) initialStatuses[key] = { state: 'installing' };
707
+ setAgentStatuses(initialStatuses);
708
+
709
+ try {
710
+ const agentsPayload = Array.from(selectedAgents).map(key => ({ key, scope: agentScope }));
711
+ const res = await fetch('/api/mcp/install', {
712
+ method: 'POST',
713
+ headers: { 'Content-Type': 'application/json' },
714
+ body: JSON.stringify({
715
+ agents: agentsPayload,
716
+ transport: agentTransport,
717
+ url: `http://localhost:${state.mcpPort}/mcp`,
718
+ token: state.authToken || undefined,
719
+ }),
720
+ });
721
+ const data = await res.json();
722
+ if (data.results) {
723
+ const updated: Record<string, AgentInstallStatus> = {};
724
+ for (const r of data.results as Array<{ agent: string; status: string; message?: string }>) {
725
+ updated[r.agent] = { state: r.status === 'ok' ? 'ok' : 'error', message: r.message };
726
+ }
727
+ setAgentStatuses(updated);
728
+ }
729
+ } catch {
730
+ const errStatuses: Record<string, AgentInstallStatus> = {};
731
+ for (const key of selectedAgents) errStatuses[key] = { state: 'error' };
732
+ setAgentStatuses(errStatuses);
733
+ }
734
+ }
384
735
 
385
- {error && (
386
- <div className="p-3 rounded-lg text-sm text-red-500" style={{ background: 'rgba(239,68,68,0.1)' }}>
387
- {s.completeFailed}: {error}
388
- </div>
389
- )}
736
+ setSubmitting(false);
390
737
 
391
- {portChanged && (
392
- <div className="space-y-3">
393
- <div className="p-3 rounded-lg text-sm flex items-center gap-2" style={{ background: 'rgba(200,135,30,0.1)', color: 'var(--amber)' }}>
394
- <AlertTriangle size={14} />
395
- {s.portChanged}
396
- </div>
397
- <a
398
- href="/"
399
- className="inline-flex items-center gap-1 px-4 py-2 text-sm rounded-lg transition-colors"
400
- style={{ background: 'var(--amber)', color: 'white' }}
401
- >
402
- {s.completeDone} &rarr;
403
- </a>
404
- </div>
405
- )}
406
- </div>
407
- );
738
+ if (didPortChange) {
739
+ // Port changed — stay on page, show restart hint
740
+ return;
741
+ }
742
+ window.location.href = '/';
408
743
  };
409
744
 
410
- const steps = [Step1, Step2, Step3, Step4, Step5];
411
- const CurrentStep = steps[step];
412
-
413
745
  return (
414
- <div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto" style={{ background: 'var(--background)' }}>
746
+ <div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto"
747
+ style={{ background: 'var(--background)' }}>
415
748
  <div className="w-full max-w-xl mx-auto px-6 py-12">
416
- {/* Header */}
417
749
  <div className="text-center mb-8">
418
750
  <div className="inline-flex items-center gap-2 mb-2">
419
751
  <Sparkles size={18} style={{ color: 'var(--amber)' }} />
420
- <h1
421
- className="text-2xl font-semibold tracking-tight font-display"
422
- style={{ color: 'var(--foreground)' }}
423
- >
752
+ <h1 className="text-2xl font-semibold tracking-tight font-display" style={{ color: 'var(--foreground)' }}>
424
753
  MindOS
425
754
  </h1>
426
755
  </div>
427
756
  </div>
428
757
 
429
- {/* Step dots */}
430
758
  <div className="flex justify-center">
431
- <StepDots />
759
+ <StepDots step={step} setStep={setStep} stepTitles={s.stepTitles} />
432
760
  </div>
433
761
 
434
- {/* Step title */}
435
762
  <h2 className="text-lg font-semibold mb-5" style={{ color: 'var(--foreground)' }}>
436
763
  {s.stepTitles[step]}
437
764
  </h2>
438
765
 
439
- {/* Step content */}
440
- <CurrentStep />
766
+ {step === 0 && <Step1 state={state} update={update} t={t} />}
767
+ {step === 1 && <Step2 state={state} update={update} s={s} />}
768
+ {step === 2 && (
769
+ <Step3
770
+ state={state} update={update}
771
+ webPortStatus={webPortStatus} mcpPortStatus={mcpPortStatus}
772
+ setWebPortStatus={setWebPortStatus} setMcpPortStatus={setMcpPortStatus}
773
+ checkPort={checkPort} portConflict={portConflict} s={s}
774
+ />
775
+ )}
776
+ {step === 3 && (
777
+ <Step4Inner
778
+ authToken={state.authToken} tokenCopied={tokenCopied}
779
+ onCopy={copyToken} onGenerate={generateToken}
780
+ webPassword={state.webPassword} onPasswordChange={v => update('webPassword', v)}
781
+ s={s}
782
+ />
783
+ )}
784
+ {step === 4 && (
785
+ <Step5
786
+ agents={agents} agentsLoading={agentsLoading}
787
+ selectedAgents={selectedAgents} setSelectedAgents={setSelectedAgents}
788
+ agentTransport={agentTransport} setAgentTransport={setAgentTransport}
789
+ agentScope={agentScope} setAgentScope={setAgentScope}
790
+ agentStatuses={agentStatuses} s={s} settingsMcp={t.settings.mcp}
791
+ />
792
+ )}
793
+ {step === 5 && (
794
+ <Step6
795
+ state={state} selectedAgents={selectedAgents}
796
+ error={error} portChanged={portChanged}
797
+ maskKey={maskKey} s={s}
798
+ />
799
+ )}
441
800
 
442
801
  {/* Navigation */}
443
802
  <div className="flex items-center justify-between mt-8 pt-6" style={{ borderTop: '1px solid var(--border)' }}>
@@ -445,10 +804,8 @@ export default function SetupWizard() {
445
804
  onClick={() => setStep(step - 1)}
446
805
  disabled={step === 0}
447
806
  className="flex items-center gap-1 px-4 py-2 text-sm rounded-lg border border-border hover:bg-muted transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
448
- style={{ color: 'var(--foreground)' }}
449
- >
450
- <ChevronLeft size={14} />
451
- {s.back}
807
+ style={{ color: 'var(--foreground)' }}>
808
+ <ChevronLeft size={14} /> {s.back}
452
809
  </button>
453
810
 
454
811
  {step < TOTAL_STEPS - 1 ? (
@@ -456,18 +813,15 @@ export default function SetupWizard() {
456
813
  onClick={() => setStep(step + 1)}
457
814
  disabled={!canNext()}
458
815
  className="flex items-center gap-1 px-4 py-2 text-sm rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
459
- style={{ background: 'var(--amber)', color: 'white' }}
460
- >
461
- {s.next}
462
- <ChevronRight size={14} />
816
+ style={{ background: 'var(--amber)', color: 'white' }}>
817
+ {s.next} <ChevronRight size={14} />
463
818
  </button>
464
819
  ) : (
465
820
  <button
466
821
  onClick={handleComplete}
467
822
  disabled={submitting || portChanged}
468
823
  className="flex items-center gap-1 px-5 py-2 text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
469
- style={{ background: 'var(--amber)', color: 'white' }}
470
- >
824
+ style={{ background: 'var(--amber)', color: 'white' }}>
471
825
  {submitting && <Loader2 size={14} className="animate-spin" />}
472
826
  {submitting ? s.completing : portChanged ? s.completeDone : s.complete}
473
827
  </button>