@geminilight/mindos 0.5.1 → 0.5.3
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/setup/page.tsx +3 -2
- package/app/components/HomeContent.tsx +2 -0
- package/app/components/SettingsModal.tsx +9 -0
- package/app/components/SetupWizard.tsx +343 -46
- 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/cli.js +3 -1
- package/bin/lib/gateway.js +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/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,
|
|
@@ -29,6 +29,7 @@ interface SetupState {
|
|
|
29
29
|
interface PortStatus {
|
|
30
30
|
checking: boolean;
|
|
31
31
|
available: boolean | null;
|
|
32
|
+
isSelf: boolean;
|
|
32
33
|
suggestion: number | null;
|
|
33
34
|
}
|
|
34
35
|
|
|
@@ -69,13 +70,15 @@ function Step4Inner({
|
|
|
69
70
|
webPassword: string;
|
|
70
71
|
onPasswordChange: (v: string) => void;
|
|
71
72
|
s: {
|
|
72
|
-
authToken: string; authTokenHint: string;
|
|
73
|
+
authToken: string; authTokenHint: string; authTokenUsage: string; authTokenUsageWhat: string;
|
|
74
|
+
authTokenSeed: string; authTokenSeedHint: string;
|
|
73
75
|
generateToken: string; copyToken: string; copiedToken: string;
|
|
74
76
|
webPassword: string; webPasswordHint: string;
|
|
75
77
|
};
|
|
76
78
|
}) {
|
|
77
79
|
const [seed, setSeed] = useState('');
|
|
78
80
|
const [showSeed, setShowSeed] = useState(false);
|
|
81
|
+
const [showUsage, setShowUsage] = useState(false);
|
|
79
82
|
return (
|
|
80
83
|
<div className="space-y-5">
|
|
81
84
|
<Field label={s.authToken} hint={s.authTokenHint}>
|
|
@@ -94,6 +97,18 @@ function Step4Inner({
|
|
|
94
97
|
</button>
|
|
95
98
|
</div>
|
|
96
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>
|
|
97
112
|
<div>
|
|
98
113
|
<button onClick={() => setShowSeed(!showSeed)} className="text-xs underline"
|
|
99
114
|
style={{ color: 'var(--muted-foreground)' }}>
|
|
@@ -125,7 +140,7 @@ function PortField({
|
|
|
125
140
|
onChange: (v: number) => void;
|
|
126
141
|
status: PortStatus;
|
|
127
142
|
onCheckPort: (port: number) => void;
|
|
128
|
-
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 };
|
|
129
144
|
}) {
|
|
130
145
|
return (
|
|
131
146
|
<Field label={label} hint={hint}>
|
|
@@ -160,7 +175,7 @@ function PortField({
|
|
|
160
175
|
)}
|
|
161
176
|
{!status.checking && status.available === true && (
|
|
162
177
|
<p className="text-xs flex items-center gap-1" style={{ color: '#22c55e' }}>
|
|
163
|
-
<CheckCircle2 size={11} /> {s.portAvailable}
|
|
178
|
+
<CheckCircle2 size={11} /> {status.isSelf ? s.portSelf : s.portAvailable}
|
|
164
179
|
</p>
|
|
165
180
|
)}
|
|
166
181
|
</div>
|
|
@@ -168,19 +183,161 @@ function PortField({
|
|
|
168
183
|
);
|
|
169
184
|
}
|
|
170
185
|
|
|
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
|
+
|
|
171
197
|
// ─── Step 1: Knowledge Base ───────────────────────────────────────────────────
|
|
172
198
|
function Step1({
|
|
173
|
-
state, update, t,
|
|
199
|
+
state, update, t, homeDir,
|
|
174
200
|
}: {
|
|
175
201
|
state: SetupState;
|
|
176
202
|
update: <K extends keyof SetupState>(key: K, val: SetupState[K]) => void;
|
|
177
203
|
t: ReturnType<typeof useLocale>['t'];
|
|
204
|
+
homeDir: string;
|
|
178
205
|
}) {
|
|
179
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
|
+
|
|
180
291
|
return (
|
|
181
292
|
<div className="space-y-6">
|
|
182
293
|
<Field label={s.kbPath} hint={s.kbPathHint}>
|
|
183
|
-
<
|
|
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
|
+
)}
|
|
184
341
|
</Field>
|
|
185
342
|
<div>
|
|
186
343
|
<label className="text-sm text-foreground font-medium mb-3 block">{s.template}</label>
|
|
@@ -291,14 +448,14 @@ function Step3({
|
|
|
291
448
|
<div className="space-y-5">
|
|
292
449
|
<PortField
|
|
293
450
|
label={s.webPort} hint={s.portHint} value={state.webPort}
|
|
294
|
-
onChange={v => { update('webPort', v); setWebPortStatus({ checking: false, available: null, suggestion: null }); }}
|
|
451
|
+
onChange={v => { update('webPort', v); setWebPortStatus({ checking: false, available: null, isSelf: false, suggestion: null }); }}
|
|
295
452
|
status={webPortStatus}
|
|
296
453
|
onCheckPort={port => checkPort(port, 'web')}
|
|
297
454
|
s={s}
|
|
298
455
|
/>
|
|
299
456
|
<PortField
|
|
300
457
|
label={s.mcpPort} hint={s.portHint} value={state.mcpPort}
|
|
301
|
-
onChange={v => { update('mcpPort', v); setMcpPortStatus({ checking: false, available: null, suggestion: null }); }}
|
|
458
|
+
onChange={v => { update('mcpPort', v); setMcpPortStatus({ checking: false, available: null, isSelf: false, suggestion: null }); }}
|
|
302
459
|
status={mcpPortStatus}
|
|
303
460
|
onCheckPort={port => checkPort(port, 'mcp')}
|
|
304
461
|
s={s}
|
|
@@ -428,23 +585,88 @@ function Step5({
|
|
|
428
585
|
</Select>
|
|
429
586
|
</Field>
|
|
430
587
|
</div>
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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>
|
|
434
595
|
</>
|
|
435
596
|
)}
|
|
436
597
|
</div>
|
|
437
598
|
);
|
|
438
599
|
}
|
|
439
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
|
+
if (r.status < 500) { clearInterval(poll); redirect(); return; }
|
|
619
|
+
} catch { /* not ready yet */ }
|
|
620
|
+
if (attempts >= 10) { clearInterval(poll); redirect(); }
|
|
621
|
+
}, 800);
|
|
622
|
+
} catch {
|
|
623
|
+
setRestarting(false);
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
if (done) {
|
|
628
|
+
return (
|
|
629
|
+
<div className="p-3 rounded-lg text-sm flex items-center gap-2"
|
|
630
|
+
style={{ background: 'rgba(34,197,94,0.1)', color: '#22c55e' }}>
|
|
631
|
+
<CheckCircle2 size={14} /> {s.restartDone}
|
|
632
|
+
</div>
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return (
|
|
637
|
+
<div className="space-y-3">
|
|
638
|
+
<div className="p-3 rounded-lg text-sm flex items-center gap-2"
|
|
639
|
+
style={{ background: 'rgba(200,135,30,0.1)', color: 'var(--amber)' }}>
|
|
640
|
+
<AlertTriangle size={14} /> {s.restartRequired}
|
|
641
|
+
</div>
|
|
642
|
+
<div className="flex items-center gap-3">
|
|
643
|
+
<button
|
|
644
|
+
type="button"
|
|
645
|
+
onClick={handleRestart}
|
|
646
|
+
disabled={restarting}
|
|
647
|
+
className="flex items-center gap-1.5 px-4 py-2 text-sm rounded-lg transition-colors disabled:opacity-50"
|
|
648
|
+
style={{ background: 'var(--amber)', color: 'white' }}>
|
|
649
|
+
{restarting ? <Loader2 size={13} className="animate-spin" /> : null}
|
|
650
|
+
{restarting ? s.restarting : s.restartNow}
|
|
651
|
+
</button>
|
|
652
|
+
<span className="text-xs" style={{ color: 'var(--muted-foreground)' }}>
|
|
653
|
+
{s.restartManual} <code className="font-mono">mindos start</code>
|
|
654
|
+
</span>
|
|
655
|
+
</div>
|
|
656
|
+
</div>
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
|
|
440
660
|
// ─── Step 6: Review ───────────────────────────────────────────────────────────
|
|
441
661
|
function Step6({
|
|
442
|
-
state, selectedAgents, error,
|
|
662
|
+
state, selectedAgents, agentStatuses, onRetryAgent, error, needsRestart, maskKey, s,
|
|
443
663
|
}: {
|
|
444
664
|
state: SetupState;
|
|
445
665
|
selectedAgents: Set<string>;
|
|
666
|
+
agentStatuses: Record<string, AgentInstallStatus>;
|
|
667
|
+
onRetryAgent: (key: string) => void;
|
|
446
668
|
error: string;
|
|
447
|
-
|
|
669
|
+
needsRestart: boolean;
|
|
448
670
|
maskKey: (key: string) => string;
|
|
449
671
|
s: ReturnType<typeof useLocale>['t']['setup'];
|
|
450
672
|
}) {
|
|
@@ -463,6 +685,8 @@ function Step6({
|
|
|
463
685
|
[s.agentToolsTitle, selectedAgents.size > 0 ? Array.from(selectedAgents).join(', ') : '—'],
|
|
464
686
|
];
|
|
465
687
|
|
|
688
|
+
const failedAgents = Object.entries(agentStatuses).filter(([, v]) => v.state === 'error');
|
|
689
|
+
|
|
466
690
|
return (
|
|
467
691
|
<div className="space-y-5">
|
|
468
692
|
<p className="text-sm" style={{ color: 'var(--muted-foreground)' }}>{s.reviewHint}</p>
|
|
@@ -478,23 +702,33 @@ function Step6({
|
|
|
478
702
|
</div>
|
|
479
703
|
))}
|
|
480
704
|
</div>
|
|
705
|
+
{failedAgents.length > 0 && (
|
|
706
|
+
<div className="p-3 rounded-lg space-y-2" style={{ background: 'rgba(239,68,68,0.08)' }}>
|
|
707
|
+
<p className="text-xs font-medium" style={{ color: '#ef4444' }}>{s.reviewInstallResults}</p>
|
|
708
|
+
{failedAgents.map(([key, st]) => (
|
|
709
|
+
<div key={key} className="flex items-center justify-between gap-2">
|
|
710
|
+
<span className="text-xs flex items-center gap-1" style={{ color: '#ef4444' }}>
|
|
711
|
+
<XCircle size={11} /> {key}{st.message ? ` — ${st.message}` : ''}
|
|
712
|
+
</span>
|
|
713
|
+
<button
|
|
714
|
+
type="button"
|
|
715
|
+
onClick={() => onRetryAgent(key)}
|
|
716
|
+
disabled={st.state === 'installing'}
|
|
717
|
+
className="text-xs px-2 py-0.5 rounded border transition-colors disabled:opacity-40"
|
|
718
|
+
style={{ borderColor: '#ef4444', color: '#ef4444' }}>
|
|
719
|
+
{st.state === 'installing' ? <Loader2 size={10} className="animate-spin inline" /> : s.retryAgent}
|
|
720
|
+
</button>
|
|
721
|
+
</div>
|
|
722
|
+
))}
|
|
723
|
+
<p className="text-xs" style={{ color: 'var(--muted-foreground)' }}>{s.agentFailureNote}</p>
|
|
724
|
+
</div>
|
|
725
|
+
)}
|
|
481
726
|
{error && (
|
|
482
727
|
<div className="p-3 rounded-lg text-sm text-red-500" style={{ background: 'rgba(239,68,68,0.1)' }}>
|
|
483
728
|
{s.completeFailed}: {error}
|
|
484
729
|
</div>
|
|
485
730
|
)}
|
|
486
|
-
{
|
|
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
|
-
)}
|
|
731
|
+
{needsRestart && <RestartBlock s={s} newPort={state.webPort} />}
|
|
498
732
|
</div>
|
|
499
733
|
);
|
|
500
734
|
}
|
|
@@ -538,7 +772,7 @@ export default function SetupWizard() {
|
|
|
538
772
|
|
|
539
773
|
const [step, setStep] = useState(0);
|
|
540
774
|
const [state, setState] = useState<SetupState>({
|
|
541
|
-
mindRoot: '~/MindOS',
|
|
775
|
+
mindRoot: '~/MindOS/mind',
|
|
542
776
|
template: 'en',
|
|
543
777
|
provider: 'anthropic',
|
|
544
778
|
anthropicKey: '',
|
|
@@ -551,13 +785,15 @@ export default function SetupWizard() {
|
|
|
551
785
|
authToken: '',
|
|
552
786
|
webPassword: '',
|
|
553
787
|
});
|
|
788
|
+
const [homeDir, setHomeDir] = useState('~');
|
|
554
789
|
const [tokenCopied, setTokenCopied] = useState(false);
|
|
555
790
|
const [submitting, setSubmitting] = useState(false);
|
|
791
|
+
const [completed, setCompleted] = useState(false);
|
|
556
792
|
const [error, setError] = useState('');
|
|
557
|
-
const [
|
|
793
|
+
const [needsRestart, setNeedsRestart] = useState(false);
|
|
558
794
|
|
|
559
|
-
const [webPortStatus, setWebPortStatus] = useState<PortStatus>({ checking: false, available: null, suggestion: null });
|
|
560
|
-
const [mcpPortStatus, setMcpPortStatus] = useState<PortStatus>({ checking: false, available: null, suggestion: null });
|
|
795
|
+
const [webPortStatus, setWebPortStatus] = useState<PortStatus>({ checking: false, available: null, isSelf: false, suggestion: null });
|
|
796
|
+
const [mcpPortStatus, setMcpPortStatus] = useState<PortStatus>({ checking: false, available: null, isSelf: false, suggestion: null });
|
|
561
797
|
|
|
562
798
|
const [agents, setAgents] = useState<AgentEntry[]>([]);
|
|
563
799
|
const [agentsLoading, setAgentsLoading] = useState(false);
|
|
@@ -566,12 +802,39 @@ export default function SetupWizard() {
|
|
|
566
802
|
const [agentScope, setAgentScope] = useState<'global' | 'project'>('global');
|
|
567
803
|
const [agentStatuses, setAgentStatuses] = useState<Record<string, AgentInstallStatus>>({});
|
|
568
804
|
|
|
569
|
-
//
|
|
805
|
+
// Load existing config as defaults on mount, generate token if none exists
|
|
570
806
|
useEffect(() => {
|
|
571
|
-
fetch('/api/setup
|
|
807
|
+
fetch('/api/setup')
|
|
572
808
|
.then(r => r.json())
|
|
573
|
-
.then(data => {
|
|
574
|
-
|
|
809
|
+
.then(data => {
|
|
810
|
+
if (data.homeDir) setHomeDir(data.homeDir);
|
|
811
|
+
setState(prev => ({
|
|
812
|
+
...prev,
|
|
813
|
+
mindRoot: data.mindRoot || prev.mindRoot,
|
|
814
|
+
webPort: typeof data.port === 'number' ? data.port : prev.webPort,
|
|
815
|
+
mcpPort: typeof data.mcpPort === 'number' ? data.mcpPort : prev.mcpPort,
|
|
816
|
+
authToken: data.authToken || prev.authToken,
|
|
817
|
+
webPassword: data.webPassword || prev.webPassword,
|
|
818
|
+
provider: (data.provider === 'anthropic' || data.provider === 'openai') ? data.provider : prev.provider,
|
|
819
|
+
anthropicModel: data.anthropicModel || prev.anthropicModel,
|
|
820
|
+
openaiModel: data.openaiModel || prev.openaiModel,
|
|
821
|
+
openaiBaseUrl: data.openaiBaseUrl ?? prev.openaiBaseUrl,
|
|
822
|
+
}));
|
|
823
|
+
// Generate a new token only if none exists yet
|
|
824
|
+
if (!data.authToken) {
|
|
825
|
+
fetch('/api/setup/generate-token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
|
|
826
|
+
.then(r => r.json())
|
|
827
|
+
.then(tokenData => { if (tokenData.token) setState(p => ({ ...p, authToken: tokenData.token })); })
|
|
828
|
+
.catch(() => {});
|
|
829
|
+
}
|
|
830
|
+
})
|
|
831
|
+
.catch(() => {
|
|
832
|
+
// Fallback: generate token on failure
|
|
833
|
+
fetch('/api/setup/generate-token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
|
|
834
|
+
.then(r => r.json())
|
|
835
|
+
.then(data => { if (data.token) setState(prev => ({ ...prev, authToken: data.token })); })
|
|
836
|
+
.catch(() => {});
|
|
837
|
+
});
|
|
575
838
|
}, []);
|
|
576
839
|
|
|
577
840
|
// Auto-check ports when entering Step 3
|
|
@@ -627,7 +890,7 @@ export default function SetupWizard() {
|
|
|
627
890
|
const checkPort = useCallback(async (port: number, which: 'web' | 'mcp') => {
|
|
628
891
|
if (port < 1024 || port > 65535) return;
|
|
629
892
|
const setStatus = which === 'web' ? setWebPortStatus : setMcpPortStatus;
|
|
630
|
-
setStatus({ checking: true, available: null, suggestion: null });
|
|
893
|
+
setStatus({ checking: true, available: null, isSelf: false, suggestion: null });
|
|
631
894
|
try {
|
|
632
895
|
const res = await fetch('/api/setup/check-port', {
|
|
633
896
|
method: 'POST',
|
|
@@ -635,9 +898,9 @@ export default function SetupWizard() {
|
|
|
635
898
|
body: JSON.stringify({ port }),
|
|
636
899
|
});
|
|
637
900
|
const data = await res.json();
|
|
638
|
-
setStatus({ checking: false, available: data.available ?? null, suggestion: data.suggestion ?? null });
|
|
901
|
+
setStatus({ checking: false, available: data.available ?? null, isSelf: !!data.isSelf, suggestion: data.suggestion ?? null });
|
|
639
902
|
} catch {
|
|
640
|
-
setStatus({ checking: false, available: null, suggestion: null });
|
|
903
|
+
setStatus({ checking: false, available: null, isSelf: false, suggestion: null });
|
|
641
904
|
}
|
|
642
905
|
}, []);
|
|
643
906
|
|
|
@@ -666,7 +929,7 @@ export default function SetupWizard() {
|
|
|
666
929
|
const handleComplete = async () => {
|
|
667
930
|
setSubmitting(true);
|
|
668
931
|
setError('');
|
|
669
|
-
let
|
|
932
|
+
let restartNeeded = false;
|
|
670
933
|
|
|
671
934
|
// 1. Save setup config
|
|
672
935
|
try {
|
|
@@ -692,8 +955,8 @@ export default function SetupWizard() {
|
|
|
692
955
|
});
|
|
693
956
|
const data = await res.json();
|
|
694
957
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
695
|
-
|
|
696
|
-
if (
|
|
958
|
+
restartNeeded = !!data.needsRestart;
|
|
959
|
+
if (restartNeeded) setNeedsRestart(true);
|
|
697
960
|
} catch (e) {
|
|
698
961
|
setError(e instanceof Error ? e.message : String(e));
|
|
699
962
|
setSubmitting(false);
|
|
@@ -734,14 +997,38 @@ export default function SetupWizard() {
|
|
|
734
997
|
}
|
|
735
998
|
|
|
736
999
|
setSubmitting(false);
|
|
1000
|
+
setCompleted(true);
|
|
737
1001
|
|
|
738
|
-
if (
|
|
739
|
-
//
|
|
1002
|
+
if (restartNeeded) {
|
|
1003
|
+
// Config changed requiring restart — stay on page, show restart block
|
|
740
1004
|
return;
|
|
741
1005
|
}
|
|
742
|
-
window.location.href = '
|
|
1006
|
+
window.location.href = '/?welcome=1';
|
|
743
1007
|
};
|
|
744
1008
|
|
|
1009
|
+
const retryAgent = useCallback(async (key: string) => {
|
|
1010
|
+
setAgentStatuses(prev => ({ ...prev, [key]: { state: 'installing' } }));
|
|
1011
|
+
try {
|
|
1012
|
+
const res = await fetch('/api/mcp/install', {
|
|
1013
|
+
method: 'POST',
|
|
1014
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1015
|
+
body: JSON.stringify({
|
|
1016
|
+
agents: [{ key, scope: agentScope }],
|
|
1017
|
+
transport: agentTransport,
|
|
1018
|
+
url: `http://localhost:${state.mcpPort}/mcp`,
|
|
1019
|
+
token: state.authToken || undefined,
|
|
1020
|
+
}),
|
|
1021
|
+
});
|
|
1022
|
+
const data = await res.json();
|
|
1023
|
+
if (data.results?.[0]) {
|
|
1024
|
+
const r = data.results[0] as { agent: string; status: string; message?: string };
|
|
1025
|
+
setAgentStatuses(prev => ({ ...prev, [key]: { state: r.status === 'ok' ? 'ok' : 'error', message: r.message } }));
|
|
1026
|
+
}
|
|
1027
|
+
} catch {
|
|
1028
|
+
setAgentStatuses(prev => ({ ...prev, [key]: { state: 'error' } }));
|
|
1029
|
+
}
|
|
1030
|
+
}, [agentScope, agentTransport, state.mcpPort, state.authToken]);
|
|
1031
|
+
|
|
745
1032
|
return (
|
|
746
1033
|
<div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto"
|
|
747
1034
|
style={{ background: 'var(--background)' }}>
|
|
@@ -763,7 +1050,7 @@ export default function SetupWizard() {
|
|
|
763
1050
|
{s.stepTitles[step]}
|
|
764
1051
|
</h2>
|
|
765
1052
|
|
|
766
|
-
{step === 0 && <Step1 state={state} update={update} t={t} />}
|
|
1053
|
+
{step === 0 && <Step1 state={state} update={update} t={t} homeDir={homeDir} />}
|
|
767
1054
|
{step === 1 && <Step2 state={state} update={update} s={s} />}
|
|
768
1055
|
{step === 2 && (
|
|
769
1056
|
<Step3
|
|
@@ -793,7 +1080,8 @@ export default function SetupWizard() {
|
|
|
793
1080
|
{step === 5 && (
|
|
794
1081
|
<Step6
|
|
795
1082
|
state={state} selectedAgents={selectedAgents}
|
|
796
|
-
|
|
1083
|
+
agentStatuses={agentStatuses} onRetryAgent={retryAgent}
|
|
1084
|
+
error={error} needsRestart={needsRestart}
|
|
797
1085
|
maskKey={maskKey} s={s}
|
|
798
1086
|
/>
|
|
799
1087
|
)}
|
|
@@ -816,14 +1104,23 @@ export default function SetupWizard() {
|
|
|
816
1104
|
style={{ background: 'var(--amber)', color: 'white' }}>
|
|
817
1105
|
{s.next} <ChevronRight size={14} />
|
|
818
1106
|
</button>
|
|
1107
|
+
) : completed ? (
|
|
1108
|
+
// After completing: show Done link (no restart needed) or nothing (RestartBlock handles it)
|
|
1109
|
+
!needsRestart ? (
|
|
1110
|
+
<a href="/?welcome=1"
|
|
1111
|
+
className="flex items-center gap-1 px-5 py-2 text-sm font-medium rounded-lg transition-colors"
|
|
1112
|
+
style={{ background: 'var(--amber)', color: 'white' }}>
|
|
1113
|
+
{s.completeDone} →
|
|
1114
|
+
</a>
|
|
1115
|
+
) : null
|
|
819
1116
|
) : (
|
|
820
1117
|
<button
|
|
821
1118
|
onClick={handleComplete}
|
|
822
|
-
disabled={submitting
|
|
1119
|
+
disabled={submitting}
|
|
823
1120
|
className="flex items-center gap-1 px-5 py-2 text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
|
|
824
1121
|
style={{ background: 'var(--amber)', color: 'white' }}>
|
|
825
1122
|
{submitting && <Loader2 size={14} className="animate-spin" />}
|
|
826
|
-
{submitting ? s.completing :
|
|
1123
|
+
{submitting ? s.completing : s.complete}
|
|
827
1124
|
</button>
|
|
828
1125
|
)}
|
|
829
1126
|
</div>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
import { X, Sparkles } from 'lucide-react';
|
|
5
|
+
import { useLocale } from '@/lib/LocaleContext';
|
|
6
|
+
|
|
7
|
+
export default function WelcomeBanner() {
|
|
8
|
+
const { t } = useLocale();
|
|
9
|
+
const s = t.setup;
|
|
10
|
+
const [visible, setVisible] = useState(false);
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
// Show banner if ?welcome=1 is in the URL
|
|
14
|
+
const params = new URLSearchParams(window.location.search);
|
|
15
|
+
if (params.get('welcome') === '1') {
|
|
16
|
+
setVisible(true);
|
|
17
|
+
// Remove ?welcome=1 from URL without reloading
|
|
18
|
+
const url = new URL(window.location.href);
|
|
19
|
+
url.searchParams.delete('welcome');
|
|
20
|
+
const newUrl = url.pathname + (url.searchParams.size > 0 ? '?' + url.searchParams.toString() : '');
|
|
21
|
+
window.history.replaceState({}, '', newUrl);
|
|
22
|
+
}
|
|
23
|
+
}, []);
|
|
24
|
+
|
|
25
|
+
if (!visible) return null;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div className="mb-6 rounded-xl border px-5 py-4 flex items-start gap-4"
|
|
29
|
+
style={{ background: 'var(--amber-subtle, rgba(200,135,30,0.08))', borderColor: 'var(--amber)' }}>
|
|
30
|
+
<Sparkles size={18} className="mt-0.5 shrink-0" style={{ color: 'var(--amber)' }} />
|
|
31
|
+
<div className="flex-1 min-w-0">
|
|
32
|
+
<p className="text-sm font-semibold mb-1" style={{ color: 'var(--foreground)' }}>
|
|
33
|
+
{s.welcomeTitle}
|
|
34
|
+
</p>
|
|
35
|
+
<p className="text-xs leading-relaxed mb-3" style={{ color: 'var(--muted-foreground)' }}>
|
|
36
|
+
{s.welcomeDesc}
|
|
37
|
+
</p>
|
|
38
|
+
<div className="flex flex-wrap gap-2">
|
|
39
|
+
<a href="/setup?force=1" className="text-xs px-3 py-1.5 rounded-lg border transition-colors"
|
|
40
|
+
style={{ borderColor: 'var(--amber)', color: 'var(--amber)' }}>
|
|
41
|
+
{s.welcomeLinkReconfigure}
|
|
42
|
+
</a>
|
|
43
|
+
<button onClick={() => window.dispatchEvent(new KeyboardEvent('keydown', { key: '/', metaKey: true, bubbles: true }))}
|
|
44
|
+
className="text-xs px-3 py-1.5 rounded-lg border transition-colors"
|
|
45
|
+
style={{ borderColor: 'var(--border)', color: 'var(--muted-foreground)' }}>
|
|
46
|
+
{s.welcomeLinkAskAI}
|
|
47
|
+
</button>
|
|
48
|
+
<button
|
|
49
|
+
onClick={() => window.dispatchEvent(new KeyboardEvent('keydown', { key: ',', metaKey: true, bubbles: true }))}
|
|
50
|
+
className="text-xs px-3 py-1.5 rounded-lg border transition-colors"
|
|
51
|
+
style={{ borderColor: 'var(--border)', color: 'var(--muted-foreground)' }}>
|
|
52
|
+
{s.welcomeLinkMCP}
|
|
53
|
+
</button>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
<button onClick={() => setVisible(false)}
|
|
57
|
+
className="p-1 rounded hover:bg-muted transition-colors shrink-0"
|
|
58
|
+
style={{ color: 'var(--muted-foreground)' }}>
|
|
59
|
+
<X size={14} />
|
|
60
|
+
</button>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|