@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.
- package/app/app/api/health/route.ts +6 -0
- package/app/app/api/mcp/agents/route.ts +1 -49
- package/app/app/api/mcp/install/route.ts +1 -24
- package/app/app/api/restart/route.ts +22 -0
- package/app/app/api/setup/check-path/route.ts +35 -0
- package/app/app/api/setup/check-port/route.ts +21 -5
- package/app/app/api/setup/ls/route.ts +38 -0
- package/app/app/api/setup/route.ts +49 -0
- package/app/app/layout.tsx +4 -3
- package/app/app/setup/page.tsx +3 -2
- package/app/components/HomeContent.tsx +2 -0
- package/app/components/SettingsModal.tsx +9 -0
- package/app/components/SetupWizard.tsx +747 -422
- package/app/components/WelcomeBanner.tsx +63 -0
- package/app/lib/i18n.ts +40 -2
- package/app/lib/mcp-agents.ts +48 -0
- package/app/lib/settings.ts +1 -1
- package/bin/lib/mcp-agents.js +16 -0
- package/bin/lib/mcp-install.js +2 -11
- package/package.json +1 -1
- package/scripts/setup.js +195 -86
- package/skills/human-insights/SKILL.md +143 -0
- package/skills/mindos/SKILL.md +7 -6
- package/skills/mindos-zh/SKILL.md +7 -7
|
@@ -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;
|
|
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
|
-
//
|
|
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;
|
|
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
|
-
|
|
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 [
|
|
794
|
+
const [needsRestart, setNeedsRestart] = useState(false);
|
|
202
795
|
|
|
203
|
-
|
|
204
|
-
const [
|
|
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
|
-
//
|
|
806
|
+
// Load existing config as defaults on mount, generate token if none exists
|
|
217
807
|
useEffect(() => {
|
|
218
|
-
fetch('/api/setup
|
|
808
|
+
fetch('/api/setup')
|
|
219
809
|
.then(r => r.json())
|
|
220
|
-
.then(data => {
|
|
221
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
366
|
-
};
|
|
367
|
-
|
|
368
|
-
const portConflict = state.webPort === state.mcpPort;
|
|
1001
|
+
setCompleted(true);
|
|
369
1002
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
1007
|
+
window.location.href = '/?welcome=1';
|
|
382
1008
|
};
|
|
383
1009
|
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
574
|
-
|
|
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
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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} →
|
|
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
|
-
<
|
|
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} →
|
|
1115
|
+
</a>
|
|
1116
|
+
) : null
|
|
792
1117
|
) : (
|
|
793
1118
|
<button
|
|
794
1119
|
onClick={handleComplete}
|
|
795
|
-
disabled={submitting
|
|
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 :
|
|
1124
|
+
{submitting ? s.completing : s.complete}
|
|
800
1125
|
</button>
|
|
801
1126
|
)}
|
|
802
1127
|
</div>
|