@geminilight/mindos 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/app/app/api/mcp/agents/route.ts +72 -0
  2. package/app/app/api/mcp/install/route.ts +95 -0
  3. package/app/app/api/mcp/status/route.ts +47 -0
  4. package/app/app/api/setup/check-port/route.ts +41 -0
  5. package/app/app/api/skills/route.ts +208 -0
  6. package/app/app/api/sync/route.ts +54 -3
  7. package/app/app/api/update-check/route.ts +52 -0
  8. package/app/app/globals.css +12 -0
  9. package/app/app/layout.tsx +4 -2
  10. package/app/app/login/page.tsx +20 -13
  11. package/app/app/page.tsx +19 -2
  12. package/app/app/setup/page.tsx +2 -0
  13. package/app/app/view/[...path]/ViewPageClient.tsx +47 -21
  14. package/app/app/view/[...path]/loading.tsx +1 -1
  15. package/app/app/view/[...path]/not-found.tsx +101 -0
  16. package/app/components/AskFab.tsx +1 -1
  17. package/app/components/AskModal.tsx +1 -1
  18. package/app/components/Backlinks.tsx +1 -1
  19. package/app/components/Breadcrumb.tsx +13 -3
  20. package/app/components/CsvView.tsx +5 -6
  21. package/app/components/DirView.tsx +42 -21
  22. package/app/components/FindInPage.tsx +211 -0
  23. package/app/components/HomeContent.tsx +97 -44
  24. package/app/components/JsonView.tsx +1 -2
  25. package/app/components/MarkdownEditor.tsx +1 -2
  26. package/app/components/OnboardingView.tsx +6 -7
  27. package/app/components/SettingsModal.tsx +5 -2
  28. package/app/components/SetupWizard.tsx +499 -172
  29. package/app/components/Sidebar.tsx +1 -1
  30. package/app/components/UpdateBanner.tsx +101 -0
  31. package/app/components/renderers/{AgentInspectorRenderer.tsx → agent-inspector/AgentInspectorRenderer.tsx} +13 -11
  32. package/app/components/renderers/agent-inspector/manifest.ts +14 -0
  33. package/app/components/renderers/{BacklinksRenderer.tsx → backlinks/BacklinksRenderer.tsx} +6 -6
  34. package/app/components/renderers/backlinks/manifest.ts +14 -0
  35. package/app/components/renderers/config/manifest.ts +14 -0
  36. package/app/components/renderers/csv/BoardView.tsx +12 -12
  37. package/app/components/renderers/csv/ConfigPanel.tsx +7 -8
  38. package/app/components/renderers/{CsvRenderer.tsx → csv/CsvRenderer.tsx} +8 -9
  39. package/app/components/renderers/csv/GalleryView.tsx +3 -3
  40. package/app/components/renderers/csv/TableView.tsx +4 -5
  41. package/app/components/renderers/csv/manifest.ts +14 -0
  42. package/app/components/renderers/{DiffRenderer.tsx → diff/DiffRenderer.tsx} +10 -9
  43. package/app/components/renderers/diff/manifest.ts +14 -0
  44. package/app/components/renderers/{GraphRenderer.tsx → graph/GraphRenderer.tsx} +4 -5
  45. package/app/components/renderers/graph/manifest.ts +14 -0
  46. package/app/components/renderers/{SummaryRenderer.tsx → summary/SummaryRenderer.tsx} +6 -6
  47. package/app/components/renderers/summary/manifest.ts +14 -0
  48. package/app/components/renderers/{TimelineRenderer.tsx → timeline/TimelineRenderer.tsx} +6 -6
  49. package/app/components/renderers/timeline/manifest.ts +14 -0
  50. package/app/components/renderers/{TodoRenderer.tsx → todo/TodoRenderer.tsx} +2 -2
  51. package/app/components/renderers/todo/manifest.ts +14 -0
  52. package/app/components/renderers/{WorkflowRenderer.tsx → workflow/WorkflowRenderer.tsx} +13 -13
  53. package/app/components/renderers/workflow/manifest.ts +14 -0
  54. package/app/components/settings/McpTab.tsx +549 -0
  55. package/app/components/settings/SyncTab.tsx +139 -50
  56. package/app/components/settings/types.ts +1 -1
  57. package/app/data/pages/home.png +0 -0
  58. package/app/lib/i18n.ts +226 -19
  59. package/app/lib/renderers/index.ts +20 -89
  60. package/app/lib/renderers/registry.ts +4 -1
  61. package/app/lib/settings.ts +3 -0
  62. package/app/package.json +1 -0
  63. package/app/types/semver.d.ts +8 -0
  64. package/bin/cli.js +137 -24
  65. package/bin/lib/build.js +53 -18
  66. package/bin/lib/colors.js +3 -1
  67. package/bin/lib/config.js +4 -0
  68. package/bin/lib/constants.js +2 -0
  69. package/bin/lib/debug.js +10 -0
  70. package/bin/lib/mcp-install.js +4 -1
  71. package/bin/lib/port.js +8 -2
  72. package/bin/lib/startup.js +21 -20
  73. package/bin/lib/stop.js +41 -3
  74. package/bin/lib/sync.js +65 -53
  75. package/bin/lib/update-check.js +94 -0
  76. package/bin/lib/utils.js +2 -2
  77. package/package.json +1 -1
  78. package/scripts/gen-renderer-index.js +57 -0
  79. package/scripts/setup.js +205 -10
  80. /package/app/components/renderers/{ConfigRenderer.tsx → config/ConfigRenderer.tsx} +0 -0
@@ -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,158 @@ 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; // null = not yet checked
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
+ // Per-agent install tracking (live, in Step 5)
44
+ type AgentInstallState = 'pending' | 'installing' | 'ok' | 'error';
45
+ interface AgentInstallStatus {
46
+ state: AgentInstallState;
47
+ message?: string;
48
+ }
49
+
50
+ const TEMPLATES: Array<{ id: Template; icon: React.ReactNode; dirs: string[] }> = [
31
51
  { id: 'en', icon: <Globe size={18} />, dirs: ['Profile/', 'Connections/', 'Notes/', 'Workflows/', 'Resources/', 'Projects/'] },
32
52
  { id: 'zh', icon: <BookOpen size={18} />, dirs: ['画像/', '关系/', '笔记/', '流程/', '资源/', '项目/'] },
33
53
  { id: 'empty', icon: <FileText size={18} />, dirs: ['README.md', 'CONFIG.json', 'INSTRUCTION.md'] },
34
54
  ];
35
55
 
36
- const TOTAL_STEPS = 5;
56
+ const TOTAL_STEPS = 6;
57
+ const STEP_KB = 0;
58
+ const STEP_PORTS = 2;
59
+ const STEP_AGENTS = 4;
60
+
61
+ // -------------------------------------------------------------------
62
+ // Step4Inner — extracted so its local seed/showSeed state survives
63
+ // parent re-renders (declaring inside SetupWizard would remount it)
64
+ // -------------------------------------------------------------------
65
+ function Step4Inner({
66
+ authToken, tokenCopied, onCopy, onGenerate, webPassword, onPasswordChange, s,
67
+ }: {
68
+ authToken: string;
69
+ tokenCopied: boolean;
70
+ onCopy: () => void;
71
+ onGenerate: (seed?: string) => void;
72
+ webPassword: string;
73
+ onPasswordChange: (v: string) => void;
74
+ s: {
75
+ authToken: string; authTokenHint: string; authTokenSeed: string; authTokenSeedHint: string;
76
+ generateToken: string; copyToken: string; copiedToken: string;
77
+ webPassword: string; webPasswordHint: string;
78
+ };
79
+ }) {
80
+ const [seed, setSeed] = useState('');
81
+ const [showSeed, setShowSeed] = useState(false);
82
+ return (
83
+ <div className="space-y-5">
84
+ <Field label={s.authToken} hint={s.authTokenHint}>
85
+ <div className="flex gap-2">
86
+ <Input value={authToken} readOnly className="font-mono text-xs" />
87
+ <button onClick={onCopy}
88
+ className="flex items-center gap-1 px-3 py-2 text-xs rounded-lg border border-border hover:bg-muted transition-colors shrink-0"
89
+ style={{ color: 'var(--foreground)' }}>
90
+ {tokenCopied ? <Check size={14} /> : <Copy size={14} />}
91
+ {tokenCopied ? s.copiedToken : s.copyToken}
92
+ </button>
93
+ <button onClick={() => onGenerate()}
94
+ className="flex items-center gap-1 px-3 py-2 text-xs rounded-lg border border-border hover:bg-muted transition-colors shrink-0"
95
+ style={{ color: 'var(--foreground)' }}>
96
+ <RefreshCw size={14} />
97
+ </button>
98
+ </div>
99
+ </Field>
100
+ <div>
101
+ <button onClick={() => setShowSeed(!showSeed)} className="text-xs underline"
102
+ style={{ color: 'var(--muted-foreground)' }}>
103
+ {s.authTokenSeed}
104
+ </button>
105
+ {showSeed && (
106
+ <div className="mt-2 flex gap-2">
107
+ <Input value={seed} onChange={e => setSeed(e.target.value)} placeholder={s.authTokenSeedHint} />
108
+ <button onClick={() => { if (seed.trim()) onGenerate(seed); }}
109
+ className="px-3 py-2 text-xs rounded-lg border border-border hover:bg-muted transition-colors shrink-0"
110
+ style={{ color: 'var(--foreground)' }}>
111
+ {s.generateToken}
112
+ </button>
113
+ </div>
114
+ )}
115
+ </div>
116
+ <Field label={s.webPassword} hint={s.webPasswordHint}>
117
+ <Input type="password" value={webPassword} onChange={e => onPasswordChange(e.target.value)} placeholder="(optional)" />
118
+ </Field>
119
+ </div>
120
+ );
121
+ }
37
122
 
123
+ // -------------------------------------------------------------------
124
+ // PortField — input + inline availability badge + suggestion button
125
+ // -------------------------------------------------------------------
126
+ function PortField({
127
+ label, hint, value, onChange, status, onCheckPort, s,
128
+ }: {
129
+ label: string; hint: string; value: number;
130
+ onChange: (v: number) => void;
131
+ status: PortStatus;
132
+ onCheckPort: (port: number) => void;
133
+ s: { portChecking: string; portInUse: (p: number) => string; portSuggest: (p: number) => string; portAvailable: string };
134
+ }) {
135
+ return (
136
+ <Field label={label} hint={hint}>
137
+ <div className="space-y-1.5">
138
+ <Input
139
+ type="number" min={1024} max={65535} value={value}
140
+ onChange={e => onChange(parseInt(e.target.value, 10) || value)}
141
+ onBlur={() => onCheckPort(value)}
142
+ />
143
+ {status.checking && (
144
+ <p className="text-xs flex items-center gap-1" style={{ color: 'var(--muted-foreground)' }}>
145
+ <Loader2 size={11} className="animate-spin" /> {s.portChecking}
146
+ </p>
147
+ )}
148
+ {!status.checking && status.available === false && (
149
+ <div className="flex items-center gap-2">
150
+ <p className="text-xs flex items-center gap-1" style={{ color: 'var(--amber)' }}>
151
+ <AlertTriangle size={11} /> {s.portInUse(value)}
152
+ </p>
153
+ {status.suggestion !== null && (
154
+ <button type="button"
155
+ onClick={() => {
156
+ onChange(status.suggestion!);
157
+ setTimeout(() => onCheckPort(status.suggestion!), 0);
158
+ }}
159
+ className="text-xs px-2 py-0.5 rounded border transition-colors"
160
+ style={{ borderColor: 'var(--amber)', color: 'var(--amber)' }}>
161
+ {s.portSuggest(status.suggestion)}
162
+ </button>
163
+ )}
164
+ </div>
165
+ )}
166
+ {!status.checking && status.available === true && (
167
+ <p className="text-xs flex items-center gap-1" style={{ color: '#22c55e' }}>
168
+ <CheckCircle2 size={11} /> {s.portAvailable}
169
+ </p>
170
+ )}
171
+ </div>
172
+ </Field>
173
+ );
174
+ }
175
+
176
+ // -------------------------------------------------------------------
177
+ // Main component
178
+ // -------------------------------------------------------------------
38
179
  export default function SetupWizard() {
39
180
  const { t } = useLocale();
40
- const router = useRouter();
41
181
  const s = t.setup;
42
182
 
43
183
  const [step, setStep] = useState(0);
@@ -60,6 +200,19 @@ export default function SetupWizard() {
60
200
  const [error, setError] = useState('');
61
201
  const [portChanged, setPortChanged] = useState(false);
62
202
 
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 });
206
+
207
+ // Agent Tools
208
+ const [agents, setAgents] = useState<AgentEntry[]>([]);
209
+ const [agentsLoading, setAgentsLoading] = useState(false);
210
+ const [selectedAgents, setSelectedAgents] = useState<Set<string>>(new Set());
211
+ const [agentTransport, setAgentTransport] = useState<'stdio' | 'http'>('stdio');
212
+ const [agentScope, setAgentScope] = useState<'global' | 'project'>('global');
213
+ // Live per-agent install status (shown inline in Step 5 during/after submit)
214
+ const [agentStatuses, setAgentStatuses] = useState<Record<string, AgentInstallStatus>>({});
215
+
63
216
  // Generate token on mount
64
217
  useEffect(() => {
65
218
  fetch('/api/setup/generate-token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
@@ -68,11 +221,39 @@ export default function SetupWizard() {
68
221
  .catch(() => {});
69
222
  }, []);
70
223
 
224
+ // Auto-check ports when entering Step 3
225
+ useEffect(() => {
226
+ if (step === STEP_PORTS) {
227
+ checkPort(state.webPort, 'web');
228
+ checkPort(state.mcpPort, 'mcp');
229
+ }
230
+ // eslint-disable-next-line react-hooks/exhaustive-deps
231
+ }, [step]);
232
+
233
+ // Load agents when entering Step 5
234
+ useEffect(() => {
235
+ if (step === STEP_AGENTS && agents.length === 0 && !agentsLoading) {
236
+ setAgentsLoading(true);
237
+ fetch('/api/mcp/agents')
238
+ .then(r => r.json())
239
+ .then(data => {
240
+ if (data.agents) {
241
+ setAgents(data.agents);
242
+ setSelectedAgents(new Set(
243
+ (data.agents as AgentEntry[]).filter(a => a.installed).map(a => a.key)
244
+ ));
245
+ }
246
+ })
247
+ .catch(() => {})
248
+ .finally(() => setAgentsLoading(false));
249
+ }
250
+ }, [step, agents.length, agentsLoading]);
251
+
71
252
  const update = useCallback(<K extends keyof SetupState>(key: K, val: SetupState[K]) => {
72
253
  setState(prev => ({ ...prev, [key]: val }));
73
254
  }, []);
74
255
 
75
- const generateToken = async (seed?: string) => {
256
+ const generateToken = useCallback(async (seed?: string) => {
76
257
  try {
77
258
  const res = await fetch('/api/setup/generate-token', {
78
259
  method: 'POST',
@@ -80,24 +261,41 @@ export default function SetupWizard() {
80
261
  body: JSON.stringify({ seed: seed || undefined }),
81
262
  });
82
263
  const data = await res.json();
83
- if (data.token) update('authToken', data.token);
264
+ if (data.token) setState(prev => ({ ...prev, authToken: data.token }));
84
265
  } catch { /* ignore */ }
85
- };
266
+ }, []);
86
267
 
87
- const copyToken = () => {
88
- navigator.clipboard.writeText(state.authToken);
268
+ const copyToken = useCallback(() => {
269
+ setState(prev => { navigator.clipboard.writeText(prev.authToken); return prev; });
89
270
  setTokenCopied(true);
90
271
  setTimeout(() => setTokenCopied(false), 2000);
91
- };
272
+ }, []);
273
+
274
+ const checkPort = useCallback(async (port: number, which: 'web' | 'mcp') => {
275
+ if (port < 1024 || port > 65535) return;
276
+ const setStatus = which === 'web' ? setWebPortStatus : setMcpPortStatus;
277
+ setStatus({ checking: true, available: null, suggestion: null });
278
+ try {
279
+ const res = await fetch('/api/setup/check-port', {
280
+ method: 'POST',
281
+ headers: { 'Content-Type': 'application/json' },
282
+ body: JSON.stringify({ port }),
283
+ });
284
+ const data = await res.json();
285
+ setStatus({ checking: false, available: data.available ?? null, suggestion: data.suggestion ?? null });
286
+ } catch {
287
+ setStatus({ checking: false, available: null, suggestion: null });
288
+ }
289
+ }, []);
92
290
 
93
291
  const handleComplete = async () => {
94
292
  setSubmitting(true);
95
293
  setError('');
294
+
295
+ // 1. Save setup config first
96
296
  try {
97
297
  const payload = {
98
- mindRoot: state.mindRoot.startsWith('~')
99
- ? state.mindRoot // server will resolve
100
- : state.mindRoot,
298
+ mindRoot: state.mindRoot,
101
299
  template: state.template || undefined,
102
300
  port: state.webPort,
103
301
  mcpPort: state.mcpPort,
@@ -119,17 +317,67 @@ export default function SetupWizard() {
119
317
  const data = await res.json();
120
318
  if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
121
319
  if (data.portChanged) setPortChanged(true);
122
- else router.push('/');
123
320
  } catch (e) {
124
321
  setError(e instanceof Error ? e.message : String(e));
125
- } finally {
126
322
  setSubmitting(false);
323
+ return;
324
+ }
325
+
326
+ // 2. Install agents after config saved — update statuses live
327
+ if (selectedAgents.size > 0) {
328
+ // Mark all selected as "installing"
329
+ const initialStatuses: Record<string, AgentInstallStatus> = {};
330
+ for (const key of selectedAgents) initialStatuses[key] = { state: 'installing' };
331
+ setAgentStatuses(initialStatuses);
332
+
333
+ try {
334
+ const agentsPayload = Array.from(selectedAgents).map(key => ({ key, scope: agentScope }));
335
+ const res = await fetch('/api/mcp/install', {
336
+ method: 'POST',
337
+ headers: { 'Content-Type': 'application/json' },
338
+ body: JSON.stringify({
339
+ agents: agentsPayload,
340
+ transport: agentTransport,
341
+ url: `http://localhost:${state.mcpPort}/mcp`,
342
+ token: state.authToken || undefined,
343
+ }),
344
+ });
345
+ const data = await res.json();
346
+ if (data.results) {
347
+ const updated: Record<string, AgentInstallStatus> = {};
348
+ 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
+ };
353
+ }
354
+ setAgentStatuses(updated);
355
+ }
356
+ } catch {
357
+ // Mark all as error
358
+ const errStatuses: Record<string, AgentInstallStatus> = {};
359
+ for (const key of selectedAgents) errStatuses[key] = { state: 'error' };
360
+ setAgentStatuses(errStatuses);
361
+ }
127
362
  }
363
+
364
+ setSubmitting(false);
365
+ if (!portChanged) window.location.href = '/';
128
366
  };
129
367
 
368
+ const portConflict = state.webPort === state.mcpPort;
369
+
130
370
  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;
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
+ );
380
+ }
133
381
  return true;
134
382
  };
135
383
 
@@ -139,31 +387,27 @@ export default function SetupWizard() {
139
387
  return key.slice(0, 6) + '•••' + key.slice(-3);
140
388
  };
141
389
 
142
- // Step indicator dots
390
+ // ----------------------------------------------------------------
391
+ // Step dots
392
+ // ----------------------------------------------------------------
143
393
  const StepDots = () => (
144
394
  <div className="flex items-center gap-2 mb-8">
145
395
  {s.stepTitles.map((title: string, i: number) => (
146
396
  <div key={i} className="flex items-center gap-2">
147
397
  {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
- >
398
+ <button onClick={() => i < step && setStep(i)} className="flex items-center gap-1.5" disabled={i > step}>
153
399
  <div
154
400
  className="w-6 h-6 rounded-full text-xs font-medium flex items-center justify-center transition-colors"
155
401
  style={{
156
- background: i === step ? 'var(--amber)' : i < step ? 'var(--amber)' : 'var(--muted)',
402
+ background: i <= step ? 'var(--amber)' : 'var(--muted)',
157
403
  color: i <= step ? 'white' : 'var(--muted-foreground)',
158
404
  opacity: i <= step ? 1 : 0.5,
159
405
  }}
160
406
  >
161
407
  {i + 1}
162
408
  </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
- >
409
+ <span className="text-xs hidden sm:inline"
410
+ style={{ color: i === step ? 'var(--foreground)' : 'var(--muted-foreground)', opacity: i <= step ? 1 : 0.5 }}>
167
411
  {title}
168
412
  </span>
169
413
  </button>
@@ -172,39 +416,32 @@ export default function SetupWizard() {
172
416
  </div>
173
417
  );
174
418
 
419
+ // ----------------------------------------------------------------
175
420
  // Step 1: Knowledge Base
421
+ // ----------------------------------------------------------------
176
422
  const Step1 = () => (
177
423
  <div className="space-y-6">
178
424
  <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
- />
425
+ <Input value={state.mindRoot} onChange={e => update('mindRoot', e.target.value)} placeholder={s.kbPathDefault} />
184
426
  </Field>
185
427
  <div>
186
428
  <label className="text-sm text-foreground font-medium mb-3 block">{s.template}</label>
187
429
  <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
188
430
  {TEMPLATES.map(tpl => (
189
- <button
190
- key={tpl.id}
191
- onClick={() => update('template', tpl.id)}
431
+ <button key={tpl.id} onClick={() => update('template', tpl.id)}
192
432
  className="flex flex-col items-start gap-2 p-4 rounded-xl border text-left transition-all duration-150"
193
433
  style={{
194
434
  background: state.template === tpl.id ? 'var(--amber-subtle, rgba(200,135,30,0.08))' : 'var(--card)',
195
435
  borderColor: state.template === tpl.id ? 'var(--amber)' : 'var(--border)',
196
- }}
197
- >
436
+ }}>
198
437
  <div className="flex items-center gap-2">
199
438
  <span style={{ color: 'var(--amber)' }}>{tpl.icon}</span>
200
439
  <span className="text-sm font-medium" style={{ color: 'var(--foreground)' }}>
201
440
  {t.onboarding.templates[tpl.id as 'en' | 'zh' | 'empty'].title}
202
441
  </span>
203
442
  </div>
204
- <div
205
- className="w-full rounded-lg px-2.5 py-1.5 text-[11px] leading-relaxed"
206
- style={{ background: 'var(--muted)', fontFamily: "'IBM Plex Mono', monospace", color: 'var(--muted-foreground)' }}
207
- >
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)' }}>
208
445
  {tpl.dirs.map(d => <div key={d}>{d}</div>)}
209
446
  </div>
210
447
  </button>
@@ -214,18 +451,56 @@ export default function SetupWizard() {
214
451
  </div>
215
452
  );
216
453
 
217
- // Step 2: AI Provider
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
+
218
478
  const Step2 = () => (
219
479
  <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>
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
+
227
502
  {state.provider !== 'skip' && (
228
- <>
503
+ <div className="space-y-4 pt-2">
229
504
  <Field label={s.apiKey}>
230
505
  <ApiKeyInput
231
506
  value={state.provider === 'anthropic' ? state.anthropicKey : state.openaiKey}
@@ -241,118 +516,168 @@ export default function SetupWizard() {
241
516
  </Field>
242
517
  {state.provider === 'openai' && (
243
518
  <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
- />
519
+ <Input value={state.openaiBaseUrl} onChange={e => update('openaiBaseUrl', e.target.value)}
520
+ placeholder="https://api.openai.com/v1" />
249
521
  </Field>
250
522
  )}
251
- </>
523
+ </div>
252
524
  )}
253
525
  </div>
254
526
  );
255
527
 
528
+ // ----------------------------------------------------------------
256
529
  // Step 3: Ports
530
+ // ----------------------------------------------------------------
257
531
  const Step3 = () => (
258
532
  <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>
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
+ )}
277
555
  <p className="text-xs flex items-center gap-1.5" style={{ color: 'var(--muted-foreground)' }}>
278
- <AlertTriangle size={12} />
279
- {s.portRestartWarning}
556
+ <AlertTriangle size={12} /> {s.portRestartWarning}
280
557
  </p>
281
558
  </div>
282
559
  );
283
560
 
284
- // Step 4: Security
285
- const Step4 = () => {
286
- const [seed, setSeed] = useState('');
287
- const [showSeed, setShowSeed] = useState(false);
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;
570
+ });
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
+ );
596
+ }
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
+ };
288
612
 
289
613
  return (
290
614
  <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>
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>
309
621
  </div>
310
- </Field>
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>
311
648
 
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>
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>
334
662
  </div>
335
- )}
336
- </div>
337
663
 
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>
664
+ {selectedAgents.size === 0 && (
665
+ <p className="text-xs" style={{ color: 'var(--muted-foreground)' }}>{s.agentNoneSelected}</p>
666
+ )}
667
+ </>
668
+ )}
346
669
  </div>
347
670
  );
348
671
  };
349
672
 
350
- // Step 5: Review
351
- const Step5 = () => {
673
+ // ----------------------------------------------------------------
674
+ // Step 6: Review
675
+ // ----------------------------------------------------------------
676
+ const Step6 = () => {
352
677
  const rows: [string, string][] = [
353
678
  [s.kbPath, state.mindRoot],
354
679
  [s.template, state.template || '—'],
355
- [s.aiProvider, state.provider],
680
+ [s.aiProvider, state.provider === 'skip' ? s.aiSkipTitle : state.provider],
356
681
  ...(state.provider !== 'skip' ? [
357
682
  [s.apiKey, maskKey(state.provider === 'anthropic' ? state.anthropicKey : state.openaiKey)] as [string, string],
358
683
  [s.model, state.provider === 'anthropic' ? state.anthropicModel : state.openaiModel] as [string, string],
@@ -361,6 +686,7 @@ export default function SetupWizard() {
361
686
  [s.mcpPort, String(state.mcpPort)],
362
687
  [s.authToken, state.authToken || '—'],
363
688
  [s.webPassword, state.webPassword ? '••••••••' : '(none)'],
689
+ [s.agentToolsTitle, selectedAgents.size > 0 ? Array.from(selectedAgents).join(', ') : '—'],
364
690
  ];
365
691
 
366
692
  return (
@@ -368,14 +694,11 @@ export default function SetupWizard() {
368
694
  <p className="text-sm" style={{ color: 'var(--muted-foreground)' }}>{s.reviewHint}</p>
369
695
  <div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border)' }}>
370
696
  {rows.map(([label, value], i) => (
371
- <div
372
- key={i}
373
- className="flex items-center justify-between px-4 py-3 text-sm"
697
+ <div key={i} className="flex items-center justify-between px-4 py-3 text-sm"
374
698
  style={{
375
699
  background: i % 2 === 0 ? 'var(--card)' : 'transparent',
376
700
  borderTop: i > 0 ? '1px solid var(--border)' : undefined,
377
- }}
378
- >
701
+ }}>
379
702
  <span style={{ color: 'var(--muted-foreground)' }}>{label}</span>
380
703
  <span className="font-mono text-xs" style={{ color: 'var(--foreground)' }}>{value}</span>
381
704
  </div>
@@ -390,15 +713,12 @@ export default function SetupWizard() {
390
713
 
391
714
  {portChanged && (
392
715
  <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}
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}
396
719
  </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
- >
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' }}>
402
722
  {s.completeDone} &rarr;
403
723
  </a>
404
724
  </div>
@@ -407,36 +727,48 @@ export default function SetupWizard() {
407
727
  );
408
728
  };
409
729
 
410
- const steps = [Step1, Step2, Step3, Step4, Step5];
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
+ ];
411
748
  const CurrentStep = steps[step];
412
749
 
413
750
  return (
414
- <div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto" style={{ background: 'var(--background)' }}>
751
+ <div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto"
752
+ style={{ background: 'var(--background)' }}>
415
753
  <div className="w-full max-w-xl mx-auto px-6 py-12">
416
754
  {/* Header */}
417
755
  <div className="text-center mb-8">
418
756
  <div className="inline-flex items-center gap-2 mb-2">
419
757
  <Sparkles size={18} style={{ color: 'var(--amber)' }} />
420
- <h1
421
- className="text-2xl font-semibold tracking-tight"
422
- style={{ fontFamily: "'IBM Plex Mono', monospace", color: 'var(--foreground)' }}
423
- >
758
+ <h1 className="text-2xl font-semibold tracking-tight font-display" style={{ color: 'var(--foreground)' }}>
424
759
  MindOS
425
760
  </h1>
426
761
  </div>
427
762
  </div>
428
763
 
429
- {/* Step dots */}
430
764
  <div className="flex justify-center">
431
765
  <StepDots />
432
766
  </div>
433
767
 
434
- {/* Step title */}
435
768
  <h2 className="text-lg font-semibold mb-5" style={{ color: 'var(--foreground)' }}>
436
769
  {s.stepTitles[step]}
437
770
  </h2>
438
771
 
439
- {/* Step content */}
440
772
  <CurrentStep />
441
773
 
442
774
  {/* Navigation */}
@@ -445,10 +777,8 @@ export default function SetupWizard() {
445
777
  onClick={() => setStep(step - 1)}
446
778
  disabled={step === 0}
447
779
  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}
780
+ style={{ color: 'var(--foreground)' }}>
781
+ <ChevronLeft size={14} /> {s.back}
452
782
  </button>
453
783
 
454
784
  {step < TOTAL_STEPS - 1 ? (
@@ -456,18 +786,15 @@ export default function SetupWizard() {
456
786
  onClick={() => setStep(step + 1)}
457
787
  disabled={!canNext()}
458
788
  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} />
789
+ style={{ background: 'var(--amber)', color: 'white' }}>
790
+ {s.next} <ChevronRight size={14} />
463
791
  </button>
464
792
  ) : (
465
793
  <button
466
794
  onClick={handleComplete}
467
795
  disabled={submitting || portChanged}
468
796
  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
- >
797
+ style={{ background: 'var(--amber)', color: 'white' }}>
471
798
  {submitting && <Loader2 size={14} className="animate-spin" />}
472
799
  {submitting ? s.completing : portChanged ? s.completeDone : s.complete}
473
800
  </button>