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