@geminilight/mindos 0.5.0 → 0.5.2

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,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useCallback } from 'react';
3
+ import { useState, useEffect, useCallback, useRef } from 'react';
4
4
  import {
5
5
  Sparkles, Globe, BookOpen, FileText, Copy, Check, RefreshCw,
6
6
  Loader2, ChevronLeft, ChevronRight, AlertTriangle, CheckCircle2,
@@ -28,7 +28,8 @@ interface SetupState {
28
28
 
29
29
  interface PortStatus {
30
30
  checking: boolean;
31
- available: boolean | null; // null = not yet checked
31
+ available: boolean | null;
32
+ isSelf: boolean;
32
33
  suggestion: number | null;
33
34
  }
34
35
 
@@ -40,7 +41,6 @@ interface AgentEntry {
40
41
  hasGlobalScope: boolean;
41
42
  }
42
43
 
43
- // Per-agent install tracking (live, in Step 5)
44
44
  type AgentInstallState = 'pending' | 'installing' | 'ok' | 'error';
45
45
  interface AgentInstallStatus {
46
46
  state: AgentInstallState;
@@ -58,10 +58,8 @@ const STEP_KB = 0;
58
58
  const STEP_PORTS = 2;
59
59
  const STEP_AGENTS = 4;
60
60
 
61
- // -------------------------------------------------------------------
62
- // Step4Inner extracted so its local seed/showSeed state survives
63
- // parent re-renders (declaring inside SetupWizard would remount it)
64
- // -------------------------------------------------------------------
61
+ // ─── Step 4 (Security) ────────────────────────────────────────────────────────
62
+ // Extracted at module level so its local seed/showSeed state survives parent re-renders
65
63
  function Step4Inner({
66
64
  authToken, tokenCopied, onCopy, onGenerate, webPassword, onPasswordChange, s,
67
65
  }: {
@@ -72,13 +70,15 @@ function Step4Inner({
72
70
  webPassword: string;
73
71
  onPasswordChange: (v: string) => void;
74
72
  s: {
75
- authToken: string; authTokenHint: string; authTokenSeed: string; authTokenSeedHint: string;
73
+ authToken: string; authTokenHint: string; authTokenUsage: string; authTokenUsageWhat: string;
74
+ authTokenSeed: string; authTokenSeedHint: string;
76
75
  generateToken: string; copyToken: string; copiedToken: string;
77
76
  webPassword: string; webPasswordHint: string;
78
77
  };
79
78
  }) {
80
79
  const [seed, setSeed] = useState('');
81
80
  const [showSeed, setShowSeed] = useState(false);
81
+ const [showUsage, setShowUsage] = useState(false);
82
82
  return (
83
83
  <div className="space-y-5">
84
84
  <Field label={s.authToken} hint={s.authTokenHint}>
@@ -97,6 +97,18 @@ function Step4Inner({
97
97
  </button>
98
98
  </div>
99
99
  </Field>
100
+ <div className="space-y-1.5">
101
+ <button onClick={() => setShowUsage(!showUsage)} className="text-xs underline"
102
+ style={{ color: 'var(--muted-foreground)' }}>
103
+ {s.authTokenUsageWhat}
104
+ </button>
105
+ {showUsage && (
106
+ <p className="text-xs leading-relaxed px-3 py-2 rounded-lg"
107
+ style={{ background: 'var(--muted)', color: 'var(--muted-foreground)' }}>
108
+ {s.authTokenUsage}
109
+ </p>
110
+ )}
111
+ </div>
100
112
  <div>
101
113
  <button onClick={() => setShowSeed(!showSeed)} className="text-xs underline"
102
114
  style={{ color: 'var(--muted-foreground)' }}>
@@ -120,9 +132,7 @@ function Step4Inner({
120
132
  );
121
133
  }
122
134
 
123
- // -------------------------------------------------------------------
124
- // PortField — input + inline availability badge + suggestion button
125
- // -------------------------------------------------------------------
135
+ // ─── PortField ────────────────────────────────────────────────────────────────
126
136
  function PortField({
127
137
  label, hint, value, onChange, status, onCheckPort, s,
128
138
  }: {
@@ -130,7 +140,7 @@ function PortField({
130
140
  onChange: (v: number) => void;
131
141
  status: PortStatus;
132
142
  onCheckPort: (port: number) => void;
133
- s: { portChecking: string; portInUse: (p: number) => string; portSuggest: (p: number) => string; portAvailable: string };
143
+ s: { portChecking: string; portInUse: (p: number) => string; portSuggest: (p: number) => string; portAvailable: string; portSelf: string };
134
144
  }) {
135
145
  return (
136
146
  <Field label={label} hint={hint}>
@@ -165,7 +175,7 @@ function PortField({
165
175
  )}
166
176
  {!status.checking && status.available === true && (
167
177
  <p className="text-xs flex items-center gap-1" style={{ color: '#22c55e' }}>
168
- <CheckCircle2 size={11} /> {s.portAvailable}
178
+ <CheckCircle2 size={11} /> {status.isSelf ? s.portSelf : s.portAvailable}
169
179
  </p>
170
180
  )}
171
181
  </div>
@@ -173,16 +183,597 @@ function PortField({
173
183
  );
174
184
  }
175
185
 
176
- // -------------------------------------------------------------------
177
- // Main component
178
- // -------------------------------------------------------------------
186
+ // Derive parent dir from current input for ls — supports both / and \ separators
187
+ function getParentDir(p: string): string {
188
+ if (!p.trim()) return '';
189
+ const trimmed = p.trim();
190
+ // Already a directory (ends with separator)
191
+ if (trimmed.endsWith('/') || trimmed.endsWith('\\')) return trimmed;
192
+ // Find last separator (/ or \)
193
+ const lastSlash = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
194
+ return lastSlash >= 0 ? trimmed.slice(0, lastSlash + 1) : '';
195
+ }
196
+
197
+ // ─── Step 1: Knowledge Base ───────────────────────────────────────────────────
198
+ function Step1({
199
+ state, update, t, homeDir,
200
+ }: {
201
+ state: SetupState;
202
+ update: <K extends keyof SetupState>(key: K, val: SetupState[K]) => void;
203
+ t: ReturnType<typeof useLocale>['t'];
204
+ homeDir: string;
205
+ }) {
206
+ const s = t.setup;
207
+ // Build platform-aware placeholder, e.g. /Users/alice/MindOS/mind or C:\Users\alice\MindOS\mind
208
+ // Windows homedir always contains \, e.g. C:\Users\Alice — safe to detect by separator
209
+ const sep = homeDir.includes('\\') ? '\\' : '/';
210
+ const placeholder = homeDir !== '~' ? [homeDir, 'MindOS', 'mind'].join(sep) : s.kbPathDefault;
211
+ const [pathInfo, setPathInfo] = useState<{ exists: boolean; empty: boolean; count: number } | null>(null);
212
+ const [suggestions, setSuggestions] = useState<string[]>([]);
213
+ const [showSuggestions, setShowSuggestions] = useState(false);
214
+ const [activeSuggestion, setActiveSuggestion] = useState(-1);
215
+ const inputRef = useRef<HTMLInputElement>(null);
216
+
217
+ // Debounced autocomplete
218
+ useEffect(() => {
219
+ if (!state.mindRoot.trim()) { setSuggestions([]); return; }
220
+ const timer = setTimeout(() => {
221
+ const parent = getParentDir(state.mindRoot) || homeDir;
222
+ fetch('/api/setup/ls', {
223
+ method: 'POST',
224
+ headers: { 'Content-Type': 'application/json' },
225
+ body: JSON.stringify({ path: parent }),
226
+ })
227
+ .then(r => r.json())
228
+ .then(d => {
229
+ if (!d.dirs?.length) { setSuggestions([]); return; }
230
+ // Normalize parent to end with a separator (preserve existing / or \)
231
+ const endsWithSep = parent.endsWith('/') || parent.endsWith('\\');
232
+ const localSep = parent.includes('\\') ? '\\' : '/';
233
+ const parentNorm = endsWithSep ? parent : parent + localSep;
234
+ const typed = state.mindRoot.trim();
235
+ const full: string[] = (d.dirs as string[]).map((dir: string) => parentNorm + dir);
236
+ const endsWithAnySep = typed.endsWith('/') || typed.endsWith('\\');
237
+ const filtered = endsWithAnySep ? full : full.filter(f => f.startsWith(typed));
238
+ setSuggestions(filtered.slice(0, 8));
239
+ setShowSuggestions(filtered.length > 0);
240
+ setActiveSuggestion(-1);
241
+ })
242
+ .catch(() => setSuggestions([]));
243
+ }, 300);
244
+ return () => clearTimeout(timer);
245
+ }, [state.mindRoot, homeDir]);
246
+
247
+ // Debounced path check
248
+ useEffect(() => {
249
+ if (!state.mindRoot.trim()) { setPathInfo(null); return; }
250
+ const timer = setTimeout(() => {
251
+ fetch('/api/setup/check-path', {
252
+ method: 'POST',
253
+ headers: { 'Content-Type': 'application/json' },
254
+ body: JSON.stringify({ path: state.mindRoot }),
255
+ })
256
+ .then(r => r.json())
257
+ .then(d => setPathInfo(d))
258
+ .catch(() => setPathInfo(null));
259
+ }, 600);
260
+ return () => clearTimeout(timer);
261
+ }, [state.mindRoot]);
262
+
263
+ const hideSuggestions = () => {
264
+ setSuggestions([]);
265
+ setShowSuggestions(false);
266
+ setActiveSuggestion(-1);
267
+ };
268
+
269
+ const selectSuggestion = (val: string) => {
270
+ update('mindRoot', val);
271
+ hideSuggestions();
272
+ inputRef.current?.focus();
273
+ };
274
+
275
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
276
+ if (!showSuggestions || suggestions.length === 0) return;
277
+ if (e.key === 'ArrowDown') {
278
+ e.preventDefault();
279
+ setActiveSuggestion(i => Math.min(i + 1, suggestions.length - 1));
280
+ } else if (e.key === 'ArrowUp') {
281
+ e.preventDefault();
282
+ setActiveSuggestion(i => Math.max(i - 1, -1));
283
+ } else if (e.key === 'Enter' && activeSuggestion >= 0) {
284
+ e.preventDefault();
285
+ selectSuggestion(suggestions[activeSuggestion]);
286
+ } else if (e.key === 'Escape') {
287
+ setShowSuggestions(false);
288
+ }
289
+ };
290
+
291
+ return (
292
+ <div className="space-y-6">
293
+ <Field label={s.kbPath} hint={s.kbPathHint}>
294
+ <div className="relative">
295
+ <input
296
+ ref={inputRef}
297
+ value={state.mindRoot}
298
+ onChange={e => { update('mindRoot', e.target.value); setShowSuggestions(true); }}
299
+ onKeyDown={handleKeyDown}
300
+ onBlur={() => setTimeout(() => hideSuggestions(), 150)}
301
+ onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
302
+ placeholder={placeholder}
303
+ className="w-full px-3 py-2 text-sm rounded-lg border outline-none transition-colors"
304
+ style={{
305
+ background: 'var(--input, var(--card))',
306
+ borderColor: 'var(--border)',
307
+ color: 'var(--foreground)',
308
+ }}
309
+ />
310
+ {showSuggestions && suggestions.length > 0 && (
311
+ <div
312
+ className="absolute z-50 left-0 right-0 top-full mt-1 rounded-lg border overflow-auto"
313
+ style={{
314
+ background: 'var(--card)',
315
+ borderColor: 'var(--border)',
316
+ boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
317
+ maxHeight: '220px',
318
+ }}>
319
+ {suggestions.map((suggestion, i) => (
320
+ <button
321
+ key={suggestion}
322
+ type="button"
323
+ onMouseDown={() => selectSuggestion(suggestion)}
324
+ className="w-full text-left px-3 py-2 text-sm font-mono transition-colors"
325
+ style={{
326
+ background: i === activeSuggestion ? 'var(--muted)' : 'transparent',
327
+ color: 'var(--foreground)',
328
+ borderTop: i > 0 ? '1px solid var(--border)' : undefined,
329
+ }}>
330
+ {suggestion}
331
+ </button>
332
+ ))}
333
+ </div>
334
+ )}
335
+ </div>
336
+ {pathInfo?.exists && !pathInfo.empty && (
337
+ <p className="text-xs flex items-center gap-1 mt-1.5" style={{ color: 'var(--amber)' }}>
338
+ <AlertTriangle size={11} /> {s.kbPathExists(pathInfo.count)}
339
+ </p>
340
+ )}
341
+ </Field>
342
+ <div>
343
+ <label className="text-sm text-foreground font-medium mb-3 block">{s.template}</label>
344
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
345
+ {TEMPLATES.map(tpl => (
346
+ <button key={tpl.id} onClick={() => update('template', tpl.id)}
347
+ className="flex flex-col items-start gap-2 p-4 rounded-xl border text-left transition-all duration-150"
348
+ style={{
349
+ background: state.template === tpl.id ? 'var(--amber-subtle, rgba(200,135,30,0.08))' : 'var(--card)',
350
+ borderColor: state.template === tpl.id ? 'var(--amber)' : 'var(--border)',
351
+ }}>
352
+ <div className="flex items-center gap-2">
353
+ <span style={{ color: 'var(--amber)' }}>{tpl.icon}</span>
354
+ <span className="text-sm font-medium" style={{ color: 'var(--foreground)' }}>
355
+ {t.onboarding.templates[tpl.id as 'en' | 'zh' | 'empty'].title}
356
+ </span>
357
+ </div>
358
+ <div className="w-full rounded-lg px-2.5 py-1.5 text-[11px] leading-relaxed font-display"
359
+ style={{ background: 'var(--muted)', color: 'var(--muted-foreground)' }}>
360
+ {tpl.dirs.map(d => <div key={d}>{d}</div>)}
361
+ </div>
362
+ </button>
363
+ ))}
364
+ </div>
365
+ </div>
366
+ </div>
367
+ );
368
+ }
369
+
370
+ // ─── Step 2: AI Provider ──────────────────────────────────────────────────────
371
+ function Step2({
372
+ state, update, s,
373
+ }: {
374
+ state: SetupState;
375
+ update: <K extends keyof SetupState>(key: K, val: SetupState[K]) => void;
376
+ s: ReturnType<typeof useLocale>['t']['setup'];
377
+ }) {
378
+ const providers = [
379
+ { id: 'anthropic' as const, icon: <Brain size={18} />, label: 'Anthropic', desc: 'Claude — claude-sonnet-4-6' },
380
+ { id: 'openai' as const, icon: <Zap size={18} />, label: 'OpenAI', desc: 'GPT or any OpenAI-compatible API' },
381
+ { id: 'skip' as const, icon: <SkipForward size={18} />, label: s.aiSkipTitle, desc: s.aiSkipDesc },
382
+ ];
383
+ return (
384
+ <div className="space-y-5">
385
+ <div className="grid grid-cols-1 gap-3">
386
+ {providers.map(p => (
387
+ <button key={p.id} onClick={() => update('provider', p.id)}
388
+ className="flex items-start gap-3 p-4 rounded-xl border text-left transition-all duration-150"
389
+ style={{
390
+ background: state.provider === p.id ? 'var(--amber-subtle, rgba(200,135,30,0.08))' : 'var(--card)',
391
+ borderColor: state.provider === p.id ? 'var(--amber)' : 'var(--border)',
392
+ }}>
393
+ <span className="mt-0.5" style={{ color: state.provider === p.id ? 'var(--amber)' : 'var(--muted-foreground)' }}>
394
+ {p.icon}
395
+ </span>
396
+ <div>
397
+ <p className="text-sm font-medium" style={{ color: 'var(--foreground)' }}>{p.label}</p>
398
+ <p className="text-xs mt-0.5" style={{ color: 'var(--muted-foreground)' }}>{p.desc}</p>
399
+ </div>
400
+ {state.provider === p.id && (
401
+ <CheckCircle2 size={16} className="ml-auto mt-0.5 shrink-0" style={{ color: 'var(--amber)' }} />
402
+ )}
403
+ </button>
404
+ ))}
405
+ </div>
406
+ {state.provider !== 'skip' && (
407
+ <div className="space-y-4 pt-2">
408
+ <Field label={s.apiKey}>
409
+ <ApiKeyInput
410
+ value={state.provider === 'anthropic' ? state.anthropicKey : state.openaiKey}
411
+ onChange={v => update(state.provider === 'anthropic' ? 'anthropicKey' : 'openaiKey', v)}
412
+ placeholder={state.provider === 'anthropic' ? 'sk-ant-...' : 'sk-...'}
413
+ />
414
+ </Field>
415
+ <Field label={s.model}>
416
+ <Input
417
+ value={state.provider === 'anthropic' ? state.anthropicModel : state.openaiModel}
418
+ onChange={e => update(state.provider === 'anthropic' ? 'anthropicModel' : 'openaiModel', e.target.value)}
419
+ />
420
+ </Field>
421
+ {state.provider === 'openai' && (
422
+ <Field label={s.baseUrl} hint={s.baseUrlHint}>
423
+ <Input value={state.openaiBaseUrl} onChange={e => update('openaiBaseUrl', e.target.value)}
424
+ placeholder="https://api.openai.com/v1" />
425
+ </Field>
426
+ )}
427
+ </div>
428
+ )}
429
+ </div>
430
+ );
431
+ }
432
+
433
+ // ─── Step 3: Ports ────────────────────────────────────────────────────────────
434
+ function Step3({
435
+ state, update, webPortStatus, mcpPortStatus, setWebPortStatus, setMcpPortStatus, checkPort, portConflict, s,
436
+ }: {
437
+ state: SetupState;
438
+ update: <K extends keyof SetupState>(key: K, val: SetupState[K]) => void;
439
+ webPortStatus: PortStatus;
440
+ mcpPortStatus: PortStatus;
441
+ setWebPortStatus: (s: PortStatus) => void;
442
+ setMcpPortStatus: (s: PortStatus) => void;
443
+ checkPort: (port: number, which: 'web' | 'mcp') => void;
444
+ portConflict: boolean;
445
+ s: ReturnType<typeof useLocale>['t']['setup'];
446
+ }) {
447
+ return (
448
+ <div className="space-y-5">
449
+ <PortField
450
+ label={s.webPort} hint={s.portHint} value={state.webPort}
451
+ onChange={v => { update('webPort', v); setWebPortStatus({ checking: false, available: null, isSelf: false, suggestion: null }); }}
452
+ status={webPortStatus}
453
+ onCheckPort={port => checkPort(port, 'web')}
454
+ s={s}
455
+ />
456
+ <PortField
457
+ label={s.mcpPort} hint={s.portHint} value={state.mcpPort}
458
+ onChange={v => { update('mcpPort', v); setMcpPortStatus({ checking: false, available: null, isSelf: false, suggestion: null }); }}
459
+ status={mcpPortStatus}
460
+ onCheckPort={port => checkPort(port, 'mcp')}
461
+ s={s}
462
+ />
463
+ {portConflict && (
464
+ <p className="text-xs flex items-center gap-1.5" style={{ color: 'var(--amber)' }}>
465
+ <AlertTriangle size={12} /> {s.portConflict}
466
+ </p>
467
+ )}
468
+ {!portConflict && (webPortStatus.available === null || mcpPortStatus.available === null) && !webPortStatus.checking && !mcpPortStatus.checking && (
469
+ <p className="text-xs" style={{ color: 'var(--muted-foreground)' }}>{s.portVerifyHint}</p>
470
+ )}
471
+ <p className="text-xs flex items-center gap-1.5" style={{ color: 'var(--muted-foreground)' }}>
472
+ <AlertTriangle size={12} /> {s.portRestartWarning}
473
+ </p>
474
+ </div>
475
+ );
476
+ }
477
+
478
+ // ─── Step 5: Agent Tools ──────────────────────────────────────────────────────
479
+ function Step5({
480
+ agents, agentsLoading, selectedAgents, setSelectedAgents,
481
+ agentTransport, setAgentTransport, agentScope, setAgentScope,
482
+ agentStatuses, s, settingsMcp,
483
+ }: {
484
+ agents: AgentEntry[];
485
+ agentsLoading: boolean;
486
+ selectedAgents: Set<string>;
487
+ setSelectedAgents: React.Dispatch<React.SetStateAction<Set<string>>>;
488
+ agentTransport: 'stdio' | 'http';
489
+ setAgentTransport: (v: 'stdio' | 'http') => void;
490
+ agentScope: 'global' | 'project';
491
+ setAgentScope: (v: 'global' | 'project') => void;
492
+ agentStatuses: Record<string, AgentInstallStatus>;
493
+ s: ReturnType<typeof useLocale>['t']['setup'];
494
+ settingsMcp: ReturnType<typeof useLocale>['t']['settings']['mcp'];
495
+ }) {
496
+ const toggleAgent = (key: string) => {
497
+ setSelectedAgents(prev => {
498
+ const next = new Set(prev);
499
+ if (next.has(key)) next.delete(key); else next.add(key);
500
+ return next;
501
+ });
502
+ };
503
+
504
+ const getStatusBadge = (key: string, installed: boolean) => {
505
+ const st = agentStatuses[key];
506
+ if (st) {
507
+ if (st.state === 'installing') return (
508
+ <span className="flex items-center gap-1 text-[11px]" style={{ color: 'var(--muted-foreground)' }}>
509
+ <Loader2 size={10} className="animate-spin" /> {s.agentInstalling}
510
+ </span>
511
+ );
512
+ if (st.state === 'ok') return (
513
+ <span className="flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded"
514
+ style={{ background: 'rgba(34,197,94,0.12)', color: '#22c55e' }}>
515
+ <CheckCircle2 size={10} /> {s.agentStatusOk}
516
+ </span>
517
+ );
518
+ if (st.state === 'error') return (
519
+ <span className="flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded"
520
+ style={{ background: 'rgba(239,68,68,0.1)', color: '#ef4444' }}>
521
+ <XCircle size={10} /> {s.agentStatusError}
522
+ {st.message && <span className="ml-1 text-[10px]">({st.message})</span>}
523
+ </span>
524
+ );
525
+ }
526
+ if (installed) return (
527
+ <span className="text-[11px] px-1.5 py-0.5 rounded"
528
+ style={{ background: 'rgba(34,197,94,0.12)', color: '#22c55e' }}>
529
+ {settingsMcp.installed}
530
+ </span>
531
+ );
532
+ return (
533
+ <span className="text-[11px] px-1.5 py-0.5 rounded"
534
+ style={{ background: 'rgba(100,100,120,0.1)', color: 'var(--muted-foreground)' }}>
535
+ {s.agentNotInstalled}
536
+ </span>
537
+ );
538
+ };
539
+
540
+ return (
541
+ <div className="space-y-5">
542
+ <p className="text-sm" style={{ color: 'var(--muted-foreground)' }}>{s.agentToolsHint}</p>
543
+ {agentsLoading ? (
544
+ <div className="flex items-center gap-2 py-4" style={{ color: 'var(--muted-foreground)' }}>
545
+ <Loader2 size={14} className="animate-spin" />
546
+ <span className="text-sm">{s.agentToolsLoading}</span>
547
+ </div>
548
+ ) : agents.length === 0 ? (
549
+ <p className="text-sm py-4 text-center" style={{ color: 'var(--muted-foreground)' }}>
550
+ {s.agentToolsEmpty}
551
+ </p>
552
+ ) : (
553
+ <>
554
+ <div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border)' }}>
555
+ {agents.map((agent, i) => (
556
+ <label key={agent.key}
557
+ className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-muted/50 transition-colors"
558
+ style={{
559
+ background: i % 2 === 0 ? 'var(--card)' : 'transparent',
560
+ borderTop: i > 0 ? '1px solid var(--border)' : undefined,
561
+ }}>
562
+ <input
563
+ type="checkbox"
564
+ checked={selectedAgents.has(agent.key)}
565
+ onChange={() => toggleAgent(agent.key)}
566
+ className="accent-amber-500"
567
+ disabled={agentStatuses[agent.key]?.state === 'installing'}
568
+ />
569
+ <span className="text-sm flex-1" style={{ color: 'var(--foreground)' }}>{agent.name}</span>
570
+ {getStatusBadge(agent.key, agent.installed)}
571
+ </label>
572
+ ))}
573
+ </div>
574
+ <div className="grid grid-cols-2 gap-4">
575
+ <Field label={s.agentTransport}>
576
+ <Select value={agentTransport} onChange={e => setAgentTransport(e.target.value as 'stdio' | 'http')}>
577
+ <option value="stdio">{settingsMcp.transportStdio}</option>
578
+ <option value="http">{settingsMcp.transportHttp}</option>
579
+ </Select>
580
+ </Field>
581
+ <Field label={s.agentScope}>
582
+ <Select value={agentScope} onChange={e => setAgentScope(e.target.value as 'global' | 'project')}>
583
+ <option value="global">{settingsMcp.global}</option>
584
+ <option value="project">{settingsMcp.project}</option>
585
+ </Select>
586
+ </Field>
587
+ </div>
588
+ <button
589
+ type="button"
590
+ onClick={() => setSelectedAgents(new Set())}
591
+ className="text-xs underline mt-1"
592
+ style={{ color: 'var(--muted-foreground)' }}>
593
+ {s.agentSkipLater}
594
+ </button>
595
+ </>
596
+ )}
597
+ </div>
598
+ );
599
+ }
600
+
601
+ // ─── Restart Block ────────────────────────────────────────────────────────────
602
+ function RestartBlock({ s, newPort }: { s: ReturnType<typeof useLocale>['t']['setup']; newPort: number }) {
603
+ const [restarting, setRestarting] = useState(false);
604
+ const [done, setDone] = useState(false);
605
+
606
+ const handleRestart = async () => {
607
+ setRestarting(true);
608
+ try {
609
+ await fetch('/api/restart', { method: 'POST' });
610
+ setDone(true);
611
+ const redirect = () => { window.location.href = `http://localhost:${newPort}/?welcome=1`; };
612
+ // Poll the new port until ready, then redirect
613
+ let attempts = 0;
614
+ const poll = setInterval(async () => {
615
+ attempts++;
616
+ try {
617
+ const r = await fetch(`http://localhost:${newPort}/api/health`);
618
+ const d = await r.json();
619
+ if (d.service === 'mindos') { clearInterval(poll); redirect(); return; }
620
+ } catch { /* not ready yet */ }
621
+ if (attempts >= 10) { clearInterval(poll); redirect(); }
622
+ }, 800);
623
+ } catch {
624
+ setRestarting(false);
625
+ }
626
+ };
627
+
628
+ if (done) {
629
+ return (
630
+ <div className="p-3 rounded-lg text-sm flex items-center gap-2"
631
+ style={{ background: 'rgba(34,197,94,0.1)', color: '#22c55e' }}>
632
+ <CheckCircle2 size={14} /> {s.restartDone}
633
+ </div>
634
+ );
635
+ }
636
+
637
+ return (
638
+ <div className="space-y-3">
639
+ <div className="p-3 rounded-lg text-sm flex items-center gap-2"
640
+ style={{ background: 'rgba(200,135,30,0.1)', color: 'var(--amber)' }}>
641
+ <AlertTriangle size={14} /> {s.restartRequired}
642
+ </div>
643
+ <div className="flex items-center gap-3">
644
+ <button
645
+ type="button"
646
+ onClick={handleRestart}
647
+ disabled={restarting}
648
+ className="flex items-center gap-1.5 px-4 py-2 text-sm rounded-lg transition-colors disabled:opacity-50"
649
+ style={{ background: 'var(--amber)', color: 'white' }}>
650
+ {restarting ? <Loader2 size={13} className="animate-spin" /> : null}
651
+ {restarting ? s.restarting : s.restartNow}
652
+ </button>
653
+ <span className="text-xs" style={{ color: 'var(--muted-foreground)' }}>
654
+ {s.restartManual} <code className="font-mono">mindos start</code>
655
+ </span>
656
+ </div>
657
+ </div>
658
+ );
659
+ }
660
+
661
+ // ─── Step 6: Review ───────────────────────────────────────────────────────────
662
+ function Step6({
663
+ state, selectedAgents, agentStatuses, onRetryAgent, error, needsRestart, maskKey, s,
664
+ }: {
665
+ state: SetupState;
666
+ selectedAgents: Set<string>;
667
+ agentStatuses: Record<string, AgentInstallStatus>;
668
+ onRetryAgent: (key: string) => void;
669
+ error: string;
670
+ needsRestart: boolean;
671
+ maskKey: (key: string) => string;
672
+ s: ReturnType<typeof useLocale>['t']['setup'];
673
+ }) {
674
+ const rows: [string, string][] = [
675
+ [s.kbPath, state.mindRoot],
676
+ [s.template, state.template || '—'],
677
+ [s.aiProvider, state.provider === 'skip' ? s.aiSkipTitle : state.provider],
678
+ ...(state.provider !== 'skip' ? [
679
+ [s.apiKey, maskKey(state.provider === 'anthropic' ? state.anthropicKey : state.openaiKey)] as [string, string],
680
+ [s.model, state.provider === 'anthropic' ? state.anthropicModel : state.openaiModel] as [string, string],
681
+ ] : []),
682
+ [s.webPort, String(state.webPort)],
683
+ [s.mcpPort, String(state.mcpPort)],
684
+ [s.authToken, state.authToken || '—'],
685
+ [s.webPassword, state.webPassword ? '••••••••' : '(none)'],
686
+ [s.agentToolsTitle, selectedAgents.size > 0 ? Array.from(selectedAgents).join(', ') : '—'],
687
+ ];
688
+
689
+ const failedAgents = Object.entries(agentStatuses).filter(([, v]) => v.state === 'error');
690
+
691
+ return (
692
+ <div className="space-y-5">
693
+ <p className="text-sm" style={{ color: 'var(--muted-foreground)' }}>{s.reviewHint}</p>
694
+ <div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border)' }}>
695
+ {rows.map(([label, value], i) => (
696
+ <div key={i} className="flex items-center justify-between px-4 py-3 text-sm"
697
+ style={{
698
+ background: i % 2 === 0 ? 'var(--card)' : 'transparent',
699
+ borderTop: i > 0 ? '1px solid var(--border)' : undefined,
700
+ }}>
701
+ <span style={{ color: 'var(--muted-foreground)' }}>{label}</span>
702
+ <span className="font-mono text-xs" style={{ color: 'var(--foreground)' }}>{value}</span>
703
+ </div>
704
+ ))}
705
+ </div>
706
+ {failedAgents.length > 0 && (
707
+ <div className="p-3 rounded-lg space-y-2" style={{ background: 'rgba(239,68,68,0.08)' }}>
708
+ <p className="text-xs font-medium" style={{ color: '#ef4444' }}>{s.reviewInstallResults}</p>
709
+ {failedAgents.map(([key, st]) => (
710
+ <div key={key} className="flex items-center justify-between gap-2">
711
+ <span className="text-xs flex items-center gap-1" style={{ color: '#ef4444' }}>
712
+ <XCircle size={11} /> {key}{st.message ? ` — ${st.message}` : ''}
713
+ </span>
714
+ <button
715
+ type="button"
716
+ onClick={() => onRetryAgent(key)}
717
+ disabled={st.state === 'installing'}
718
+ className="text-xs px-2 py-0.5 rounded border transition-colors disabled:opacity-40"
719
+ style={{ borderColor: '#ef4444', color: '#ef4444' }}>
720
+ {st.state === 'installing' ? <Loader2 size={10} className="animate-spin inline" /> : s.retryAgent}
721
+ </button>
722
+ </div>
723
+ ))}
724
+ <p className="text-xs" style={{ color: 'var(--muted-foreground)' }}>{s.agentFailureNote}</p>
725
+ </div>
726
+ )}
727
+ {error && (
728
+ <div className="p-3 rounded-lg text-sm text-red-500" style={{ background: 'rgba(239,68,68,0.1)' }}>
729
+ {s.completeFailed}: {error}
730
+ </div>
731
+ )}
732
+ {needsRestart && <RestartBlock s={s} newPort={state.webPort} />}
733
+ </div>
734
+ );
735
+ }
736
+
737
+ // ─── Step dots ────────────────────────────────────────────────────────────────
738
+ function StepDots({ step, setStep, stepTitles }: {
739
+ step: number;
740
+ setStep: (s: number) => void;
741
+ stepTitles: readonly string[];
742
+ }) {
743
+ return (
744
+ <div className="flex items-center gap-2 mb-8">
745
+ {stepTitles.map((title: string, i: number) => (
746
+ <div key={i} className="flex items-center gap-2">
747
+ {i > 0 && <div className="w-8 h-px" style={{ background: i <= step ? 'var(--amber)' : 'var(--border)' }} />}
748
+ <button onClick={() => i < step && setStep(i)} className="flex items-center gap-1.5" disabled={i > step}>
749
+ <div
750
+ className="w-6 h-6 rounded-full text-xs font-medium flex items-center justify-center transition-colors"
751
+ style={{
752
+ background: i <= step ? 'var(--amber)' : 'var(--muted)',
753
+ color: i <= step ? 'white' : 'var(--muted-foreground)',
754
+ opacity: i <= step ? 1 : 0.5,
755
+ }}>
756
+ {i + 1}
757
+ </div>
758
+ <span className="text-xs hidden sm:inline"
759
+ style={{ color: i === step ? 'var(--foreground)' : 'var(--muted-foreground)', opacity: i <= step ? 1 : 0.5 }}>
760
+ {title}
761
+ </span>
762
+ </button>
763
+ </div>
764
+ ))}
765
+ </div>
766
+ );
767
+ }
768
+
769
+ // ─── Main component ───────────────────────────────────────────────────────────
179
770
  export default function SetupWizard() {
180
771
  const { t } = useLocale();
181
772
  const s = t.setup;
182
773
 
183
774
  const [step, setStep] = useState(0);
184
775
  const [state, setState] = useState<SetupState>({
185
- mindRoot: '~/MindOS',
776
+ mindRoot: '~/MindOS/mind',
186
777
  template: 'en',
187
778
  provider: 'anthropic',
188
779
  anthropicKey: '',
@@ -195,30 +786,56 @@ export default function SetupWizard() {
195
786
  authToken: '',
196
787
  webPassword: '',
197
788
  });
789
+ const [homeDir, setHomeDir] = useState('~');
198
790
  const [tokenCopied, setTokenCopied] = useState(false);
199
791
  const [submitting, setSubmitting] = useState(false);
792
+ const [completed, setCompleted] = useState(false);
200
793
  const [error, setError] = useState('');
201
- const [portChanged, setPortChanged] = useState(false);
794
+ const [needsRestart, setNeedsRestart] = useState(false);
202
795
 
203
- // Port availability
204
- const [webPortStatus, setWebPortStatus] = useState<PortStatus>({ checking: false, available: null, suggestion: null });
205
- const [mcpPortStatus, setMcpPortStatus] = useState<PortStatus>({ checking: false, available: null, suggestion: null });
796
+ const [webPortStatus, setWebPortStatus] = useState<PortStatus>({ checking: false, available: null, isSelf: false, suggestion: null });
797
+ const [mcpPortStatus, setMcpPortStatus] = useState<PortStatus>({ checking: false, available: null, isSelf: false, suggestion: null });
206
798
 
207
- // Agent Tools
208
799
  const [agents, setAgents] = useState<AgentEntry[]>([]);
209
800
  const [agentsLoading, setAgentsLoading] = useState(false);
210
801
  const [selectedAgents, setSelectedAgents] = useState<Set<string>>(new Set());
211
802
  const [agentTransport, setAgentTransport] = useState<'stdio' | 'http'>('stdio');
212
803
  const [agentScope, setAgentScope] = useState<'global' | 'project'>('global');
213
- // Live per-agent install status (shown inline in Step 5 during/after submit)
214
804
  const [agentStatuses, setAgentStatuses] = useState<Record<string, AgentInstallStatus>>({});
215
805
 
216
- // Generate token on mount
806
+ // Load existing config as defaults on mount, generate token if none exists
217
807
  useEffect(() => {
218
- fetch('/api/setup/generate-token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
808
+ fetch('/api/setup')
219
809
  .then(r => r.json())
220
- .then(data => { if (data.token) setState(prev => ({ ...prev, authToken: data.token })); })
221
- .catch(() => {});
810
+ .then(data => {
811
+ if (data.homeDir) setHomeDir(data.homeDir);
812
+ setState(prev => ({
813
+ ...prev,
814
+ mindRoot: data.mindRoot || prev.mindRoot,
815
+ webPort: typeof data.port === 'number' ? data.port : prev.webPort,
816
+ mcpPort: typeof data.mcpPort === 'number' ? data.mcpPort : prev.mcpPort,
817
+ authToken: data.authToken || prev.authToken,
818
+ webPassword: data.webPassword || prev.webPassword,
819
+ provider: (data.provider === 'anthropic' || data.provider === 'openai') ? data.provider : prev.provider,
820
+ anthropicModel: data.anthropicModel || prev.anthropicModel,
821
+ openaiModel: data.openaiModel || prev.openaiModel,
822
+ openaiBaseUrl: data.openaiBaseUrl ?? prev.openaiBaseUrl,
823
+ }));
824
+ // Generate a new token only if none exists yet
825
+ if (!data.authToken) {
826
+ fetch('/api/setup/generate-token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
827
+ .then(r => r.json())
828
+ .then(tokenData => { if (tokenData.token) setState(p => ({ ...p, authToken: tokenData.token })); })
829
+ .catch(() => {});
830
+ }
831
+ })
832
+ .catch(() => {
833
+ // Fallback: generate token on failure
834
+ fetch('/api/setup/generate-token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
835
+ .then(r => r.json())
836
+ .then(data => { if (data.token) setState(prev => ({ ...prev, authToken: data.token })); })
837
+ .catch(() => {});
838
+ });
222
839
  }, []);
223
840
 
224
841
  // Auto-check ports when entering Step 3
@@ -274,7 +891,7 @@ export default function SetupWizard() {
274
891
  const checkPort = useCallback(async (port: number, which: 'web' | 'mcp') => {
275
892
  if (port < 1024 || port > 65535) return;
276
893
  const setStatus = which === 'web' ? setWebPortStatus : setMcpPortStatus;
277
- setStatus({ checking: true, available: null, suggestion: null });
894
+ setStatus({ checking: true, available: null, isSelf: false, suggestion: null });
278
895
  try {
279
896
  const res = await fetch('/api/setup/check-port', {
280
897
  method: 'POST',
@@ -282,17 +899,40 @@ export default function SetupWizard() {
282
899
  body: JSON.stringify({ port }),
283
900
  });
284
901
  const data = await res.json();
285
- setStatus({ checking: false, available: data.available ?? null, suggestion: data.suggestion ?? null });
902
+ setStatus({ checking: false, available: data.available ?? null, isSelf: !!data.isSelf, suggestion: data.suggestion ?? null });
286
903
  } catch {
287
- setStatus({ checking: false, available: null, suggestion: null });
904
+ setStatus({ checking: false, available: null, isSelf: false, suggestion: null });
288
905
  }
289
906
  }, []);
290
907
 
908
+ const maskKey = (key: string) => {
909
+ if (!key) return '(not set)';
910
+ if (key.length <= 8) return '•••';
911
+ return key.slice(0, 6) + '•••' + key.slice(-3);
912
+ };
913
+
914
+ const portConflict = state.webPort === state.mcpPort;
915
+
916
+ const canNext = () => {
917
+ if (step === STEP_KB) return state.mindRoot.trim().length > 0;
918
+ if (step === STEP_PORTS) {
919
+ if (portConflict) return false;
920
+ if (webPortStatus.checking || mcpPortStatus.checking) return false;
921
+ if (webPortStatus.available !== true || mcpPortStatus.available !== true) return false;
922
+ return (
923
+ state.webPort >= 1024 && state.webPort <= 65535 &&
924
+ state.mcpPort >= 1024 && state.mcpPort <= 65535
925
+ );
926
+ }
927
+ return true;
928
+ };
929
+
291
930
  const handleComplete = async () => {
292
931
  setSubmitting(true);
293
932
  setError('');
933
+ let restartNeeded = false;
294
934
 
295
- // 1. Save setup config first
935
+ // 1. Save setup config
296
936
  try {
297
937
  const payload = {
298
938
  mindRoot: state.mindRoot,
@@ -316,16 +956,16 @@ export default function SetupWizard() {
316
956
  });
317
957
  const data = await res.json();
318
958
  if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
319
- if (data.portChanged) setPortChanged(true);
959
+ restartNeeded = !!data.needsRestart;
960
+ if (restartNeeded) setNeedsRestart(true);
320
961
  } catch (e) {
321
962
  setError(e instanceof Error ? e.message : String(e));
322
963
  setSubmitting(false);
323
964
  return;
324
965
  }
325
966
 
326
- // 2. Install agents after config saved — update statuses live
967
+ // 2. Install agents after config saved
327
968
  if (selectedAgents.size > 0) {
328
- // Mark all selected as "installing"
329
969
  const initialStatuses: Record<string, AgentInstallStatus> = {};
330
970
  for (const key of selectedAgents) initialStatuses[key] = { state: 'installing' };
331
971
  setAgentStatuses(initialStatuses);
@@ -346,15 +986,11 @@ export default function SetupWizard() {
346
986
  if (data.results) {
347
987
  const updated: Record<string, AgentInstallStatus> = {};
348
988
  for (const r of data.results as Array<{ agent: string; status: string; message?: string }>) {
349
- updated[r.agent] = {
350
- state: r.status === 'ok' ? 'ok' : 'error',
351
- message: r.message,
352
- };
989
+ updated[r.agent] = { state: r.status === 'ok' ? 'ok' : 'error', message: r.message };
353
990
  }
354
991
  setAgentStatuses(updated);
355
992
  }
356
993
  } catch {
357
- // Mark all as error
358
994
  const errStatuses: Record<string, AgentInstallStatus> = {};
359
995
  for (const key of selectedAgents) errStatuses[key] = { state: 'error' };
360
996
  setAgentStatuses(errStatuses);
@@ -362,396 +998,42 @@ export default function SetupWizard() {
362
998
  }
363
999
 
364
1000
  setSubmitting(false);
365
- if (!portChanged) window.location.href = '/';
366
- };
367
-
368
- const portConflict = state.webPort === state.mcpPort;
1001
+ setCompleted(true);
369
1002
 
370
- const canNext = () => {
371
- if (step === STEP_KB) return state.mindRoot.trim().length > 0;
372
- if (step === STEP_PORTS) {
373
- if (portConflict) return false;
374
- if (webPortStatus.checking || mcpPortStatus.checking) return false;
375
- if (webPortStatus.available !== true || mcpPortStatus.available !== true) return false;
376
- return (
377
- state.webPort >= 1024 && state.webPort <= 65535 &&
378
- state.mcpPort >= 1024 && state.mcpPort <= 65535
379
- );
1003
+ if (restartNeeded) {
1004
+ // Config changed requiring restart stay on page, show restart block
1005
+ return;
380
1006
  }
381
- return true;
1007
+ window.location.href = '/?welcome=1';
382
1008
  };
383
1009
 
384
- const maskKey = (key: string) => {
385
- if (!key) return '(not set)';
386
- if (key.length <= 8) return '•••';
387
- return key.slice(0, 6) + '•••' + key.slice(-3);
388
- };
389
-
390
- // ----------------------------------------------------------------
391
- // Step dots
392
- // ----------------------------------------------------------------
393
- const StepDots = () => (
394
- <div className="flex items-center gap-2 mb-8">
395
- {s.stepTitles.map((title: string, i: number) => (
396
- <div key={i} className="flex items-center gap-2">
397
- {i > 0 && <div className="w-8 h-px" style={{ background: i <= step ? 'var(--amber)' : 'var(--border)' }} />}
398
- <button onClick={() => i < step && setStep(i)} className="flex items-center gap-1.5" disabled={i > step}>
399
- <div
400
- className="w-6 h-6 rounded-full text-xs font-medium flex items-center justify-center transition-colors"
401
- style={{
402
- background: i <= step ? 'var(--amber)' : 'var(--muted)',
403
- color: i <= step ? 'white' : 'var(--muted-foreground)',
404
- opacity: i <= step ? 1 : 0.5,
405
- }}
406
- >
407
- {i + 1}
408
- </div>
409
- <span className="text-xs hidden sm:inline"
410
- style={{ color: i === step ? 'var(--foreground)' : 'var(--muted-foreground)', opacity: i <= step ? 1 : 0.5 }}>
411
- {title}
412
- </span>
413
- </button>
414
- </div>
415
- ))}
416
- </div>
417
- );
418
-
419
- // ----------------------------------------------------------------
420
- // Step 1: Knowledge Base
421
- // ----------------------------------------------------------------
422
- const Step1 = () => (
423
- <div className="space-y-6">
424
- <Field label={s.kbPath} hint={s.kbPathHint}>
425
- <Input value={state.mindRoot} onChange={e => update('mindRoot', e.target.value)} placeholder={s.kbPathDefault} />
426
- </Field>
427
- <div>
428
- <label className="text-sm text-foreground font-medium mb-3 block">{s.template}</label>
429
- <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
430
- {TEMPLATES.map(tpl => (
431
- <button key={tpl.id} onClick={() => update('template', tpl.id)}
432
- className="flex flex-col items-start gap-2 p-4 rounded-xl border text-left transition-all duration-150"
433
- style={{
434
- background: state.template === tpl.id ? 'var(--amber-subtle, rgba(200,135,30,0.08))' : 'var(--card)',
435
- borderColor: state.template === tpl.id ? 'var(--amber)' : 'var(--border)',
436
- }}>
437
- <div className="flex items-center gap-2">
438
- <span style={{ color: 'var(--amber)' }}>{tpl.icon}</span>
439
- <span className="text-sm font-medium" style={{ color: 'var(--foreground)' }}>
440
- {t.onboarding.templates[tpl.id as 'en' | 'zh' | 'empty'].title}
441
- </span>
442
- </div>
443
- <div className="w-full rounded-lg px-2.5 py-1.5 text-[11px] leading-relaxed font-display"
444
- style={{ background: 'var(--muted)', color: 'var(--muted-foreground)' }}>
445
- {tpl.dirs.map(d => <div key={d}>{d}</div>)}
446
- </div>
447
- </button>
448
- ))}
449
- </div>
450
- </div>
451
- </div>
452
- );
453
-
454
- // ----------------------------------------------------------------
455
- // Step 2: AI Provider — card-based selection including skip
456
- // ----------------------------------------------------------------
457
- const PROVIDERS = [
458
- {
459
- id: 'anthropic' as const,
460
- icon: <Brain size={18} />,
461
- label: 'Anthropic',
462
- desc: 'Claude — claude-sonnet-4-6',
463
- },
464
- {
465
- id: 'openai' as const,
466
- icon: <Zap size={18} />,
467
- label: 'OpenAI',
468
- desc: 'GPT or any OpenAI-compatible API',
469
- },
470
- {
471
- id: 'skip' as const,
472
- icon: <SkipForward size={18} />,
473
- label: s.aiSkipTitle,
474
- desc: s.aiSkipDesc,
475
- },
476
- ];
477
-
478
- const Step2 = () => (
479
- <div className="space-y-5">
480
- <div className="grid grid-cols-1 gap-3">
481
- {PROVIDERS.map(p => (
482
- <button key={p.id} onClick={() => update('provider', p.id)}
483
- className="flex items-start gap-3 p-4 rounded-xl border text-left transition-all duration-150"
484
- style={{
485
- background: state.provider === p.id ? 'var(--amber-subtle, rgba(200,135,30,0.08))' : 'var(--card)',
486
- borderColor: state.provider === p.id ? 'var(--amber)' : 'var(--border)',
487
- }}>
488
- <span className="mt-0.5" style={{ color: state.provider === p.id ? 'var(--amber)' : 'var(--muted-foreground)' }}>
489
- {p.icon}
490
- </span>
491
- <div>
492
- <p className="text-sm font-medium" style={{ color: 'var(--foreground)' }}>{p.label}</p>
493
- <p className="text-xs mt-0.5" style={{ color: 'var(--muted-foreground)' }}>{p.desc}</p>
494
- </div>
495
- {state.provider === p.id && (
496
- <CheckCircle2 size={16} className="ml-auto mt-0.5 shrink-0" style={{ color: 'var(--amber)' }} />
497
- )}
498
- </button>
499
- ))}
500
- </div>
501
-
502
- {state.provider !== 'skip' && (
503
- <div className="space-y-4 pt-2">
504
- <Field label={s.apiKey}>
505
- <ApiKeyInput
506
- value={state.provider === 'anthropic' ? state.anthropicKey : state.openaiKey}
507
- onChange={v => update(state.provider === 'anthropic' ? 'anthropicKey' : 'openaiKey', v)}
508
- placeholder={state.provider === 'anthropic' ? 'sk-ant-...' : 'sk-...'}
509
- />
510
- </Field>
511
- <Field label={s.model}>
512
- <Input
513
- value={state.provider === 'anthropic' ? state.anthropicModel : state.openaiModel}
514
- onChange={e => update(state.provider === 'anthropic' ? 'anthropicModel' : 'openaiModel', e.target.value)}
515
- />
516
- </Field>
517
- {state.provider === 'openai' && (
518
- <Field label={s.baseUrl} hint={s.baseUrlHint}>
519
- <Input value={state.openaiBaseUrl} onChange={e => update('openaiBaseUrl', e.target.value)}
520
- placeholder="https://api.openai.com/v1" />
521
- </Field>
522
- )}
523
- </div>
524
- )}
525
- </div>
526
- );
527
-
528
- // ----------------------------------------------------------------
529
- // Step 3: Ports
530
- // ----------------------------------------------------------------
531
- const Step3 = () => (
532
- <div className="space-y-5">
533
- <PortField
534
- label={s.webPort} hint={s.portHint} value={state.webPort}
535
- onChange={v => { update('webPort', v); setWebPortStatus({ checking: false, available: null, suggestion: null }); }}
536
- status={webPortStatus}
537
- onCheckPort={port => checkPort(port, 'web')}
538
- s={s}
539
- />
540
- <PortField
541
- label={s.mcpPort} hint={s.portHint} value={state.mcpPort}
542
- onChange={v => { update('mcpPort', v); setMcpPortStatus({ checking: false, available: null, suggestion: null }); }}
543
- status={mcpPortStatus}
544
- onCheckPort={port => checkPort(port, 'mcp')}
545
- s={s}
546
- />
547
- {portConflict && (
548
- <p className="text-xs flex items-center gap-1.5" style={{ color: 'var(--amber)' }}>
549
- <AlertTriangle size={12} /> {s.portConflict}
550
- </p>
551
- )}
552
- {!portConflict && (webPortStatus.available === null || mcpPortStatus.available === null) && !webPortStatus.checking && !mcpPortStatus.checking && (
553
- <p className="text-xs" style={{ color: 'var(--muted-foreground)' }}>{s.portVerifyHint}</p>
554
- )}
555
- <p className="text-xs flex items-center gap-1.5" style={{ color: 'var(--muted-foreground)' }}>
556
- <AlertTriangle size={12} /> {s.portRestartWarning}
557
- </p>
558
- </div>
559
- );
560
-
561
- // ----------------------------------------------------------------
562
- // Step 5: Agent Tools
563
- // ----------------------------------------------------------------
564
- const Step5 = () => {
565
- const toggleAgent = (key: string) => {
566
- setSelectedAgents(prev => {
567
- const next = new Set(prev);
568
- if (next.has(key)) next.delete(key); else next.add(key);
569
- return next;
1010
+ const retryAgent = useCallback(async (key: string) => {
1011
+ setAgentStatuses(prev => ({ ...prev, [key]: { state: 'installing' } }));
1012
+ try {
1013
+ const res = await fetch('/api/mcp/install', {
1014
+ method: 'POST',
1015
+ headers: { 'Content-Type': 'application/json' },
1016
+ body: JSON.stringify({
1017
+ agents: [{ key, scope: agentScope }],
1018
+ transport: agentTransport,
1019
+ url: `http://localhost:${state.mcpPort}/mcp`,
1020
+ token: state.authToken || undefined,
1021
+ }),
570
1022
  });
571
- };
572
-
573
- const getStatusBadge = (key: string, installed: boolean) => {
574
- const st = agentStatuses[key];
575
-
576
- // Show install result if we've run setup
577
- if (st) {
578
- if (st.state === 'installing') return (
579
- <span className="flex items-center gap-1 text-[11px]" style={{ color: 'var(--muted-foreground)' }}>
580
- <Loader2 size={10} className="animate-spin" /> {s.agentInstalling}
581
- </span>
582
- );
583
- if (st.state === 'ok') return (
584
- <span className="flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded"
585
- style={{ background: 'rgba(34,197,94,0.12)', color: '#22c55e' }}>
586
- <CheckCircle2 size={10} /> {s.agentStatusOk}
587
- </span>
588
- );
589
- if (st.state === 'error') return (
590
- <span className="flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded"
591
- style={{ background: 'rgba(239,68,68,0.1)', color: '#ef4444' }}>
592
- <XCircle size={10} /> {s.agentStatusError}
593
- {st.message && <span className="ml-1 text-[10px]">({st.message})</span>}
594
- </span>
595
- );
1023
+ const data = await res.json();
1024
+ if (data.results?.[0]) {
1025
+ const r = data.results[0] as { agent: string; status: string; message?: string };
1026
+ setAgentStatuses(prev => ({ ...prev, [key]: { state: r.status === 'ok' ? 'ok' : 'error', message: r.message } }));
596
1027
  }
597
-
598
- // Show app install status (before setup runs)
599
- if (installed) return (
600
- <span className="text-[11px] px-1.5 py-0.5 rounded"
601
- style={{ background: 'rgba(34,197,94,0.12)', color: '#22c55e' }}>
602
- {t.settings.mcp.installed}
603
- </span>
604
- );
605
- return (
606
- <span className="text-[11px] px-1.5 py-0.5 rounded"
607
- style={{ background: 'rgba(100,100,120,0.1)', color: 'var(--muted-foreground)' }}>
608
- {s.agentNotInstalled}
609
- </span>
610
- );
611
- };
612
-
613
- return (
614
- <div className="space-y-5">
615
- <p className="text-sm" style={{ color: 'var(--muted-foreground)' }}>{s.agentToolsHint}</p>
616
-
617
- {agentsLoading ? (
618
- <div className="flex items-center gap-2 py-4" style={{ color: 'var(--muted-foreground)' }}>
619
- <Loader2 size={14} className="animate-spin" />
620
- <span className="text-sm">{s.agentToolsLoading}</span>
621
- </div>
622
- ) : agents.length === 0 ? (
623
- <p className="text-sm py-4 text-center" style={{ color: 'var(--muted-foreground)' }}>
624
- {s.agentToolsEmpty}
625
- </p>
626
- ) : (
627
- <>
628
- <div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border)' }}>
629
- {agents.map((agent, i) => (
630
- <label key={agent.key}
631
- className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-muted/50 transition-colors"
632
- style={{
633
- background: i % 2 === 0 ? 'var(--card)' : 'transparent',
634
- borderTop: i > 0 ? '1px solid var(--border)' : undefined,
635
- }}>
636
- <input
637
- type="checkbox"
638
- checked={selectedAgents.has(agent.key)}
639
- onChange={() => toggleAgent(agent.key)}
640
- className="accent-amber-500"
641
- disabled={agentStatuses[agent.key]?.state === 'installing'}
642
- />
643
- <span className="text-sm flex-1" style={{ color: 'var(--foreground)' }}>{agent.name}</span>
644
- {getStatusBadge(agent.key, agent.installed)}
645
- </label>
646
- ))}
647
- </div>
648
-
649
- <div className="grid grid-cols-2 gap-4">
650
- <Field label={s.agentTransport}>
651
- <Select value={agentTransport} onChange={e => setAgentTransport(e.target.value as 'stdio' | 'http')}>
652
- <option value="stdio">{t.settings.mcp.transportStdio}</option>
653
- <option value="http">{t.settings.mcp.transportHttp}</option>
654
- </Select>
655
- </Field>
656
- <Field label={s.agentScope}>
657
- <Select value={agentScope} onChange={e => setAgentScope(e.target.value as 'global' | 'project')}>
658
- <option value="global">{t.settings.mcp.global}</option>
659
- <option value="project">{t.settings.mcp.project}</option>
660
- </Select>
661
- </Field>
662
- </div>
663
-
664
- {selectedAgents.size === 0 && (
665
- <p className="text-xs" style={{ color: 'var(--muted-foreground)' }}>{s.agentNoneSelected}</p>
666
- )}
667
- </>
668
- )}
669
- </div>
670
- );
671
- };
672
-
673
- // ----------------------------------------------------------------
674
- // Step 6: Review
675
- // ----------------------------------------------------------------
676
- const Step6 = () => {
677
- const rows: [string, string][] = [
678
- [s.kbPath, state.mindRoot],
679
- [s.template, state.template || '—'],
680
- [s.aiProvider, state.provider === 'skip' ? s.aiSkipTitle : state.provider],
681
- ...(state.provider !== 'skip' ? [
682
- [s.apiKey, maskKey(state.provider === 'anthropic' ? state.anthropicKey : state.openaiKey)] as [string, string],
683
- [s.model, state.provider === 'anthropic' ? state.anthropicModel : state.openaiModel] as [string, string],
684
- ] : []),
685
- [s.webPort, String(state.webPort)],
686
- [s.mcpPort, String(state.mcpPort)],
687
- [s.authToken, state.authToken || '—'],
688
- [s.webPassword, state.webPassword ? '••••••••' : '(none)'],
689
- [s.agentToolsTitle, selectedAgents.size > 0 ? Array.from(selectedAgents).join(', ') : '—'],
690
- ];
691
-
692
- return (
693
- <div className="space-y-5">
694
- <p className="text-sm" style={{ color: 'var(--muted-foreground)' }}>{s.reviewHint}</p>
695
- <div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border)' }}>
696
- {rows.map(([label, value], i) => (
697
- <div key={i} className="flex items-center justify-between px-4 py-3 text-sm"
698
- style={{
699
- background: i % 2 === 0 ? 'var(--card)' : 'transparent',
700
- borderTop: i > 0 ? '1px solid var(--border)' : undefined,
701
- }}>
702
- <span style={{ color: 'var(--muted-foreground)' }}>{label}</span>
703
- <span className="font-mono text-xs" style={{ color: 'var(--foreground)' }}>{value}</span>
704
- </div>
705
- ))}
706
- </div>
707
-
708
- {error && (
709
- <div className="p-3 rounded-lg text-sm text-red-500" style={{ background: 'rgba(239,68,68,0.1)' }}>
710
- {s.completeFailed}: {error}
711
- </div>
712
- )}
713
-
714
- {portChanged && (
715
- <div className="space-y-3">
716
- <div className="p-3 rounded-lg text-sm flex items-center gap-2"
717
- style={{ background: 'rgba(200,135,30,0.1)', color: 'var(--amber)' }}>
718
- <AlertTriangle size={14} /> {s.portChanged}
719
- </div>
720
- <a href="/" className="inline-flex items-center gap-1 px-4 py-2 text-sm rounded-lg transition-colors"
721
- style={{ background: 'var(--amber)', color: 'white' }}>
722
- {s.completeDone} &rarr;
723
- </a>
724
- </div>
725
- )}
726
- </div>
727
- );
728
- };
729
-
730
- const steps = [
731
- Step1,
732
- Step2,
733
- Step3,
734
- () => (
735
- <Step4Inner
736
- authToken={state.authToken}
737
- tokenCopied={tokenCopied}
738
- onCopy={copyToken}
739
- onGenerate={generateToken}
740
- webPassword={state.webPassword}
741
- onPasswordChange={v => update('webPassword', v)}
742
- s={s}
743
- />
744
- ),
745
- Step5,
746
- Step6,
747
- ];
748
- const CurrentStep = steps[step];
1028
+ } catch {
1029
+ setAgentStatuses(prev => ({ ...prev, [key]: { state: 'error' } }));
1030
+ }
1031
+ }, [agentScope, agentTransport, state.mcpPort, state.authToken]);
749
1032
 
750
1033
  return (
751
1034
  <div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto"
752
1035
  style={{ background: 'var(--background)' }}>
753
1036
  <div className="w-full max-w-xl mx-auto px-6 py-12">
754
- {/* Header */}
755
1037
  <div className="text-center mb-8">
756
1038
  <div className="inline-flex items-center gap-2 mb-2">
757
1039
  <Sparkles size={18} style={{ color: 'var(--amber)' }} />
@@ -762,14 +1044,48 @@ export default function SetupWizard() {
762
1044
  </div>
763
1045
 
764
1046
  <div className="flex justify-center">
765
- <StepDots />
1047
+ <StepDots step={step} setStep={setStep} stepTitles={s.stepTitles} />
766
1048
  </div>
767
1049
 
768
1050
  <h2 className="text-lg font-semibold mb-5" style={{ color: 'var(--foreground)' }}>
769
1051
  {s.stepTitles[step]}
770
1052
  </h2>
771
1053
 
772
- <CurrentStep />
1054
+ {step === 0 && <Step1 state={state} update={update} t={t} homeDir={homeDir} />}
1055
+ {step === 1 && <Step2 state={state} update={update} s={s} />}
1056
+ {step === 2 && (
1057
+ <Step3
1058
+ state={state} update={update}
1059
+ webPortStatus={webPortStatus} mcpPortStatus={mcpPortStatus}
1060
+ setWebPortStatus={setWebPortStatus} setMcpPortStatus={setMcpPortStatus}
1061
+ checkPort={checkPort} portConflict={portConflict} s={s}
1062
+ />
1063
+ )}
1064
+ {step === 3 && (
1065
+ <Step4Inner
1066
+ authToken={state.authToken} tokenCopied={tokenCopied}
1067
+ onCopy={copyToken} onGenerate={generateToken}
1068
+ webPassword={state.webPassword} onPasswordChange={v => update('webPassword', v)}
1069
+ s={s}
1070
+ />
1071
+ )}
1072
+ {step === 4 && (
1073
+ <Step5
1074
+ agents={agents} agentsLoading={agentsLoading}
1075
+ selectedAgents={selectedAgents} setSelectedAgents={setSelectedAgents}
1076
+ agentTransport={agentTransport} setAgentTransport={setAgentTransport}
1077
+ agentScope={agentScope} setAgentScope={setAgentScope}
1078
+ agentStatuses={agentStatuses} s={s} settingsMcp={t.settings.mcp}
1079
+ />
1080
+ )}
1081
+ {step === 5 && (
1082
+ <Step6
1083
+ state={state} selectedAgents={selectedAgents}
1084
+ agentStatuses={agentStatuses} onRetryAgent={retryAgent}
1085
+ error={error} needsRestart={needsRestart}
1086
+ maskKey={maskKey} s={s}
1087
+ />
1088
+ )}
773
1089
 
774
1090
  {/* Navigation */}
775
1091
  <div className="flex items-center justify-between mt-8 pt-6" style={{ borderTop: '1px solid var(--border)' }}>
@@ -789,14 +1105,23 @@ export default function SetupWizard() {
789
1105
  style={{ background: 'var(--amber)', color: 'white' }}>
790
1106
  {s.next} <ChevronRight size={14} />
791
1107
  </button>
1108
+ ) : completed ? (
1109
+ // After completing: show Done link (no restart needed) or nothing (RestartBlock handles it)
1110
+ !needsRestart ? (
1111
+ <a href="/?welcome=1"
1112
+ className="flex items-center gap-1 px-5 py-2 text-sm font-medium rounded-lg transition-colors"
1113
+ style={{ background: 'var(--amber)', color: 'white' }}>
1114
+ {s.completeDone} &rarr;
1115
+ </a>
1116
+ ) : null
792
1117
  ) : (
793
1118
  <button
794
1119
  onClick={handleComplete}
795
- disabled={submitting || portChanged}
1120
+ disabled={submitting}
796
1121
  className="flex items-center gap-1 px-5 py-2 text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
797
1122
  style={{ background: 'var(--amber)', color: 'white' }}>
798
1123
  {submitting && <Loader2 size={14} className="animate-spin" />}
799
- {submitting ? s.completing : portChanged ? s.completeDone : s.complete}
1124
+ {submitting ? s.completing : s.complete}
800
1125
  </button>
801
1126
  )}
802
1127
  </div>