@geminilight/mindos 0.5.1 → 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/setup/page.tsx +3 -2
- package/app/components/HomeContent.tsx +2 -0
- package/app/components/SettingsModal.tsx +9 -0
- package/app/components/SetupWizard.tsx +344 -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/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,
|
|
@@ -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,89 @@ 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
|
+
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
|
+
|
|
440
661
|
// ─── Step 6: Review ───────────────────────────────────────────────────────────
|
|
441
662
|
function Step6({
|
|
442
|
-
state, selectedAgents, error,
|
|
663
|
+
state, selectedAgents, agentStatuses, onRetryAgent, error, needsRestart, maskKey, s,
|
|
443
664
|
}: {
|
|
444
665
|
state: SetupState;
|
|
445
666
|
selectedAgents: Set<string>;
|
|
667
|
+
agentStatuses: Record<string, AgentInstallStatus>;
|
|
668
|
+
onRetryAgent: (key: string) => void;
|
|
446
669
|
error: string;
|
|
447
|
-
|
|
670
|
+
needsRestart: boolean;
|
|
448
671
|
maskKey: (key: string) => string;
|
|
449
672
|
s: ReturnType<typeof useLocale>['t']['setup'];
|
|
450
673
|
}) {
|
|
@@ -463,6 +686,8 @@ function Step6({
|
|
|
463
686
|
[s.agentToolsTitle, selectedAgents.size > 0 ? Array.from(selectedAgents).join(', ') : '—'],
|
|
464
687
|
];
|
|
465
688
|
|
|
689
|
+
const failedAgents = Object.entries(agentStatuses).filter(([, v]) => v.state === 'error');
|
|
690
|
+
|
|
466
691
|
return (
|
|
467
692
|
<div className="space-y-5">
|
|
468
693
|
<p className="text-sm" style={{ color: 'var(--muted-foreground)' }}>{s.reviewHint}</p>
|
|
@@ -478,23 +703,33 @@ function Step6({
|
|
|
478
703
|
</div>
|
|
479
704
|
))}
|
|
480
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
|
+
)}
|
|
481
727
|
{error && (
|
|
482
728
|
<div className="p-3 rounded-lg text-sm text-red-500" style={{ background: 'rgba(239,68,68,0.1)' }}>
|
|
483
729
|
{s.completeFailed}: {error}
|
|
484
730
|
</div>
|
|
485
731
|
)}
|
|
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
|
-
)}
|
|
732
|
+
{needsRestart && <RestartBlock s={s} newPort={state.webPort} />}
|
|
498
733
|
</div>
|
|
499
734
|
);
|
|
500
735
|
}
|
|
@@ -538,7 +773,7 @@ export default function SetupWizard() {
|
|
|
538
773
|
|
|
539
774
|
const [step, setStep] = useState(0);
|
|
540
775
|
const [state, setState] = useState<SetupState>({
|
|
541
|
-
mindRoot: '~/MindOS',
|
|
776
|
+
mindRoot: '~/MindOS/mind',
|
|
542
777
|
template: 'en',
|
|
543
778
|
provider: 'anthropic',
|
|
544
779
|
anthropicKey: '',
|
|
@@ -551,13 +786,15 @@ export default function SetupWizard() {
|
|
|
551
786
|
authToken: '',
|
|
552
787
|
webPassword: '',
|
|
553
788
|
});
|
|
789
|
+
const [homeDir, setHomeDir] = useState('~');
|
|
554
790
|
const [tokenCopied, setTokenCopied] = useState(false);
|
|
555
791
|
const [submitting, setSubmitting] = useState(false);
|
|
792
|
+
const [completed, setCompleted] = useState(false);
|
|
556
793
|
const [error, setError] = useState('');
|
|
557
|
-
const [
|
|
794
|
+
const [needsRestart, setNeedsRestart] = useState(false);
|
|
558
795
|
|
|
559
|
-
const [webPortStatus, setWebPortStatus] = useState<PortStatus>({ checking: false, available: null, suggestion: null });
|
|
560
|
-
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 });
|
|
561
798
|
|
|
562
799
|
const [agents, setAgents] = useState<AgentEntry[]>([]);
|
|
563
800
|
const [agentsLoading, setAgentsLoading] = useState(false);
|
|
@@ -566,12 +803,39 @@ export default function SetupWizard() {
|
|
|
566
803
|
const [agentScope, setAgentScope] = useState<'global' | 'project'>('global');
|
|
567
804
|
const [agentStatuses, setAgentStatuses] = useState<Record<string, AgentInstallStatus>>({});
|
|
568
805
|
|
|
569
|
-
//
|
|
806
|
+
// Load existing config as defaults on mount, generate token if none exists
|
|
570
807
|
useEffect(() => {
|
|
571
|
-
fetch('/api/setup
|
|
808
|
+
fetch('/api/setup')
|
|
572
809
|
.then(r => r.json())
|
|
573
|
-
.then(data => {
|
|
574
|
-
|
|
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
|
+
});
|
|
575
839
|
}, []);
|
|
576
840
|
|
|
577
841
|
// Auto-check ports when entering Step 3
|
|
@@ -627,7 +891,7 @@ export default function SetupWizard() {
|
|
|
627
891
|
const checkPort = useCallback(async (port: number, which: 'web' | 'mcp') => {
|
|
628
892
|
if (port < 1024 || port > 65535) return;
|
|
629
893
|
const setStatus = which === 'web' ? setWebPortStatus : setMcpPortStatus;
|
|
630
|
-
setStatus({ checking: true, available: null, suggestion: null });
|
|
894
|
+
setStatus({ checking: true, available: null, isSelf: false, suggestion: null });
|
|
631
895
|
try {
|
|
632
896
|
const res = await fetch('/api/setup/check-port', {
|
|
633
897
|
method: 'POST',
|
|
@@ -635,9 +899,9 @@ export default function SetupWizard() {
|
|
|
635
899
|
body: JSON.stringify({ port }),
|
|
636
900
|
});
|
|
637
901
|
const data = await res.json();
|
|
638
|
-
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 });
|
|
639
903
|
} catch {
|
|
640
|
-
setStatus({ checking: false, available: null, suggestion: null });
|
|
904
|
+
setStatus({ checking: false, available: null, isSelf: false, suggestion: null });
|
|
641
905
|
}
|
|
642
906
|
}, []);
|
|
643
907
|
|
|
@@ -666,7 +930,7 @@ export default function SetupWizard() {
|
|
|
666
930
|
const handleComplete = async () => {
|
|
667
931
|
setSubmitting(true);
|
|
668
932
|
setError('');
|
|
669
|
-
let
|
|
933
|
+
let restartNeeded = false;
|
|
670
934
|
|
|
671
935
|
// 1. Save setup config
|
|
672
936
|
try {
|
|
@@ -692,8 +956,8 @@ export default function SetupWizard() {
|
|
|
692
956
|
});
|
|
693
957
|
const data = await res.json();
|
|
694
958
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
695
|
-
|
|
696
|
-
if (
|
|
959
|
+
restartNeeded = !!data.needsRestart;
|
|
960
|
+
if (restartNeeded) setNeedsRestart(true);
|
|
697
961
|
} catch (e) {
|
|
698
962
|
setError(e instanceof Error ? e.message : String(e));
|
|
699
963
|
setSubmitting(false);
|
|
@@ -734,14 +998,38 @@ export default function SetupWizard() {
|
|
|
734
998
|
}
|
|
735
999
|
|
|
736
1000
|
setSubmitting(false);
|
|
1001
|
+
setCompleted(true);
|
|
737
1002
|
|
|
738
|
-
if (
|
|
739
|
-
//
|
|
1003
|
+
if (restartNeeded) {
|
|
1004
|
+
// Config changed requiring restart — stay on page, show restart block
|
|
740
1005
|
return;
|
|
741
1006
|
}
|
|
742
|
-
window.location.href = '
|
|
1007
|
+
window.location.href = '/?welcome=1';
|
|
743
1008
|
};
|
|
744
1009
|
|
|
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
|
+
}),
|
|
1022
|
+
});
|
|
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 } }));
|
|
1027
|
+
}
|
|
1028
|
+
} catch {
|
|
1029
|
+
setAgentStatuses(prev => ({ ...prev, [key]: { state: 'error' } }));
|
|
1030
|
+
}
|
|
1031
|
+
}, [agentScope, agentTransport, state.mcpPort, state.authToken]);
|
|
1032
|
+
|
|
745
1033
|
return (
|
|
746
1034
|
<div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto"
|
|
747
1035
|
style={{ background: 'var(--background)' }}>
|
|
@@ -763,7 +1051,7 @@ export default function SetupWizard() {
|
|
|
763
1051
|
{s.stepTitles[step]}
|
|
764
1052
|
</h2>
|
|
765
1053
|
|
|
766
|
-
{step === 0 && <Step1 state={state} update={update} t={t} />}
|
|
1054
|
+
{step === 0 && <Step1 state={state} update={update} t={t} homeDir={homeDir} />}
|
|
767
1055
|
{step === 1 && <Step2 state={state} update={update} s={s} />}
|
|
768
1056
|
{step === 2 && (
|
|
769
1057
|
<Step3
|
|
@@ -793,7 +1081,8 @@ export default function SetupWizard() {
|
|
|
793
1081
|
{step === 5 && (
|
|
794
1082
|
<Step6
|
|
795
1083
|
state={state} selectedAgents={selectedAgents}
|
|
796
|
-
|
|
1084
|
+
agentStatuses={agentStatuses} onRetryAgent={retryAgent}
|
|
1085
|
+
error={error} needsRestart={needsRestart}
|
|
797
1086
|
maskKey={maskKey} s={s}
|
|
798
1087
|
/>
|
|
799
1088
|
)}
|
|
@@ -816,14 +1105,23 @@ export default function SetupWizard() {
|
|
|
816
1105
|
style={{ background: 'var(--amber)', color: 'white' }}>
|
|
817
1106
|
{s.next} <ChevronRight size={14} />
|
|
818
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
|
|
819
1117
|
) : (
|
|
820
1118
|
<button
|
|
821
1119
|
onClick={handleComplete}
|
|
822
|
-
disabled={submitting
|
|
1120
|
+
disabled={submitting}
|
|
823
1121
|
className="flex items-center gap-1 px-5 py-2 text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
|
|
824
1122
|
style={{ background: 'var(--amber)', color: 'white' }}>
|
|
825
1123
|
{submitting && <Loader2 size={14} className="animate-spin" />}
|
|
826
|
-
{submitting ? s.completing :
|
|
1124
|
+
{submitting ? s.completing : s.complete}
|
|
827
1125
|
</button>
|
|
828
1126
|
)}
|
|
829
1127
|
</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
|
+
}
|