@geminilight/mindos 0.5.9 → 0.5.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +1 -1
  2. package/app/app/api/settings/test-key/route.ts +111 -0
  3. package/app/app/api/skills/route.ts +1 -1
  4. package/app/app/api/sync/route.ts +16 -31
  5. package/app/app/globals.css +10 -2
  6. package/app/app/login/page.tsx +1 -1
  7. package/app/app/view/[...path]/ViewPageClient.tsx +6 -1
  8. package/app/app/view/[...path]/not-found.tsx +1 -1
  9. package/app/components/AskModal.tsx +4 -4
  10. package/app/components/Breadcrumb.tsx +2 -2
  11. package/app/components/DirView.tsx +6 -6
  12. package/app/components/FileTree.tsx +2 -2
  13. package/app/components/HomeContent.tsx +7 -7
  14. package/app/components/OnboardingView.tsx +1 -1
  15. package/app/components/SearchModal.tsx +1 -1
  16. package/app/components/SettingsModal.tsx +2 -2
  17. package/app/components/SetupWizard.tsx +1 -1400
  18. package/app/components/Sidebar.tsx +4 -4
  19. package/app/components/SidebarLayout.tsx +9 -0
  20. package/app/components/SyncStatusBar.tsx +3 -3
  21. package/app/components/TableOfContents.tsx +1 -1
  22. package/app/components/UpdateBanner.tsx +1 -1
  23. package/app/components/ask/FileChip.tsx +1 -1
  24. package/app/components/ask/MentionPopover.tsx +4 -4
  25. package/app/components/ask/MessageList.tsx +1 -1
  26. package/app/components/ask/SessionHistory.tsx +2 -2
  27. package/app/components/renderers/config/ConfigRenderer.tsx +1 -1
  28. package/app/components/renderers/csv/BoardView.tsx +2 -2
  29. package/app/components/renderers/csv/ConfigPanel.tsx +5 -5
  30. package/app/components/renderers/csv/GalleryView.tsx +1 -1
  31. package/app/components/renderers/graph/GraphRenderer.tsx +1 -1
  32. package/app/components/renderers/summary/SummaryRenderer.tsx +1 -1
  33. package/app/components/renderers/workflow/WorkflowRenderer.tsx +2 -2
  34. package/app/components/settings/AiTab.tsx +120 -2
  35. package/app/components/settings/KnowledgeTab.tsx +1 -1
  36. package/app/components/settings/McpTab.tsx +27 -23
  37. package/app/components/settings/PluginsTab.tsx +4 -4
  38. package/app/components/settings/Primitives.tsx +1 -1
  39. package/app/components/settings/SyncTab.tsx +8 -8
  40. package/app/components/setup/StepAI.tsx +67 -0
  41. package/app/components/setup/StepAgents.tsx +237 -0
  42. package/app/components/setup/StepDots.tsx +39 -0
  43. package/app/components/setup/StepKB.tsx +237 -0
  44. package/app/components/setup/StepPorts.tsx +121 -0
  45. package/app/components/setup/StepReview.tsx +211 -0
  46. package/app/components/setup/StepSecurity.tsx +78 -0
  47. package/app/components/setup/constants.tsx +13 -0
  48. package/app/components/setup/index.tsx +464 -0
  49. package/app/components/setup/types.ts +53 -0
  50. package/app/instrumentation.ts +19 -0
  51. package/app/lib/i18n.ts +22 -4
  52. package/app/next.config.ts +1 -1
  53. package/bin/cli.js +8 -1
  54. package/bin/lib/sync.js +61 -11
  55. package/package.json +4 -2
  56. package/skills/project-wiki/SKILL.md +92 -63
  57. package/assets/images/demo-flow-dark.png +0 -0
  58. package/assets/images/demo-flow-light.png +0 -0
  59. package/assets/images/demo-flow-zh-dark.png +0 -0
  60. package/assets/images/demo-flow-zh-light.png +0 -0
  61. package/assets/images/gui-sync-cv.png +0 -0
  62. package/assets/images/wechat-qr.png +0 -0
  63. package/mcp/package-lock.json +0 -1717
@@ -0,0 +1,237 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef } from 'react';
4
+ import { Field } from '@/components/settings/Primitives';
5
+ import type { Messages } from '@/lib/i18n';
6
+ import type { SetupState } from './types';
7
+ import { TEMPLATES } from './constants';
8
+
9
+ // Derive parent dir from current input for ls — supports both / and \ separators
10
+ function getParentDir(p: string): string {
11
+ if (!p.trim()) return '';
12
+ const trimmed = p.trim();
13
+ // Already a directory (ends with separator)
14
+ if (trimmed.endsWith('/') || trimmed.endsWith('\\')) return trimmed;
15
+ // Find last separator (/ or \)
16
+ const lastSlash = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
17
+ return lastSlash >= 0 ? trimmed.slice(0, lastSlash + 1) : '';
18
+ }
19
+
20
+ export interface StepKBProps {
21
+ state: SetupState;
22
+ update: <K extends keyof SetupState>(key: K, val: SetupState[K]) => void;
23
+ t: Messages;
24
+ homeDir: string;
25
+ }
26
+
27
+ export default function StepKB({ state, update, t, homeDir }: StepKBProps) {
28
+ const s = t.setup;
29
+ // Build platform-aware placeholder, e.g. /Users/alice/MindOS/mind or C:\Users\alice\MindOS\mind
30
+ // Windows homedir always contains \, e.g. C:\Users\Alice — safe to detect by separator
31
+ const sep = homeDir.includes('\\') ? '\\' : '/';
32
+ const placeholder = homeDir !== '~' ? [homeDir, 'MindOS', 'mind'].join(sep) : s.kbPathDefault;
33
+ const [pathInfo, setPathInfo] = useState<{ exists: boolean; empty: boolean; count: number } | null>(null);
34
+ const [suggestions, setSuggestions] = useState<string[]>([]);
35
+ const [showSuggestions, setShowSuggestions] = useState(false);
36
+ const [activeSuggestion, setActiveSuggestion] = useState(-1);
37
+ const [showTemplatePickerAnyway, setShowTemplatePickerAnyway] = useState(false);
38
+ const inputRef = useRef<HTMLInputElement>(null);
39
+ const justSelectedRef = useRef(false);
40
+
41
+ // Debounced autocomplete
42
+ useEffect(() => {
43
+ // Skip when a suggestion was just selected — prevents dropdown flicker
44
+ if (justSelectedRef.current) { justSelectedRef.current = false; return; }
45
+ if (!state.mindRoot.trim()) { setSuggestions([]); return; }
46
+ const timer = setTimeout(() => {
47
+ const parent = getParentDir(state.mindRoot) || homeDir;
48
+ fetch('/api/setup/ls', {
49
+ method: 'POST',
50
+ headers: { 'Content-Type': 'application/json' },
51
+ body: JSON.stringify({ path: parent }),
52
+ })
53
+ .then(r => r.json())
54
+ .then(d => {
55
+ if (!d.dirs?.length) { setSuggestions([]); return; }
56
+ // Normalize parent to end with a separator (preserve existing / or \)
57
+ const endsWithSep = parent.endsWith('/') || parent.endsWith('\\');
58
+ const localSep = parent.includes('\\') ? '\\' : '/';
59
+ const parentNorm = endsWithSep ? parent : parent + localSep;
60
+ const typed = state.mindRoot.trim();
61
+ const full: string[] = (d.dirs as string[]).map((dir: string) => parentNorm + dir);
62
+ const endsWithAnySep = typed.endsWith('/') || typed.endsWith('\\');
63
+ const filtered = endsWithAnySep ? full : full.filter(f => f.startsWith(typed));
64
+ setSuggestions(filtered.slice(0, 8));
65
+ setShowSuggestions(filtered.length > 0);
66
+ setActiveSuggestion(-1);
67
+ })
68
+ .catch(e => { console.warn('[SetupWizard] autocomplete fetch failed:', e); setSuggestions([]); });
69
+ }, 300);
70
+ return () => clearTimeout(timer);
71
+ }, [state.mindRoot, homeDir]);
72
+
73
+ // Debounced path check
74
+ useEffect(() => {
75
+ if (!state.mindRoot.trim()) { setPathInfo(null); return; }
76
+ const timer = setTimeout(() => {
77
+ fetch('/api/setup/check-path', {
78
+ method: 'POST',
79
+ headers: { 'Content-Type': 'application/json' },
80
+ body: JSON.stringify({ path: state.mindRoot }),
81
+ })
82
+ .then(r => r.json())
83
+ .then(d => {
84
+ setPathInfo(d);
85
+ setShowTemplatePickerAnyway(false);
86
+ // Non-empty directory: default to skip template (user can opt-in to merge)
87
+ if (d?.exists && !d.empty) update('template', '');
88
+ })
89
+ .catch(e => { console.warn('[SetupWizard] check-path failed:', e); setPathInfo(null); });
90
+ }, 600);
91
+ return () => clearTimeout(timer);
92
+ }, [state.mindRoot, update]);
93
+
94
+ const hideSuggestions = () => {
95
+ setSuggestions([]);
96
+ setShowSuggestions(false);
97
+ setActiveSuggestion(-1);
98
+ };
99
+
100
+ const selectSuggestion = (val: string) => {
101
+ justSelectedRef.current = true;
102
+ update('mindRoot', val);
103
+ hideSuggestions();
104
+ inputRef.current?.focus();
105
+ };
106
+
107
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
108
+ if (!showSuggestions || suggestions.length === 0) return;
109
+ if (e.key === 'ArrowDown') {
110
+ e.preventDefault();
111
+ setActiveSuggestion(i => Math.min(i + 1, suggestions.length - 1));
112
+ } else if (e.key === 'ArrowUp') {
113
+ e.preventDefault();
114
+ setActiveSuggestion(i => Math.max(i - 1, -1));
115
+ } else if (e.key === 'Enter' && activeSuggestion >= 0) {
116
+ e.preventDefault();
117
+ selectSuggestion(suggestions[activeSuggestion]);
118
+ } else if (e.key === 'Escape') {
119
+ setShowSuggestions(false);
120
+ }
121
+ };
122
+
123
+ return (
124
+ <div className="space-y-6">
125
+ <Field label={s.kbPath} hint={s.kbPathHint}>
126
+ <div className="relative">
127
+ <input
128
+ ref={inputRef}
129
+ value={state.mindRoot}
130
+ onChange={e => { update('mindRoot', e.target.value); setShowSuggestions(true); }}
131
+ onKeyDown={handleKeyDown}
132
+ onBlur={() => setTimeout(() => hideSuggestions(), 150)}
133
+ onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
134
+ placeholder={placeholder}
135
+ className="w-full px-3 py-2 text-sm rounded-lg border outline-none transition-colors focus-visible:ring-1 focus-visible:ring-ring"
136
+ style={{
137
+ background: 'var(--input, var(--card))',
138
+ borderColor: 'var(--border)',
139
+ color: 'var(--foreground)',
140
+ }}
141
+ />
142
+ {showSuggestions && suggestions.length > 0 && (
143
+ <div
144
+ role="listbox"
145
+ className="absolute z-50 left-0 right-0 top-full mt-1 rounded-lg border overflow-auto"
146
+ style={{
147
+ background: 'var(--card)',
148
+ borderColor: 'var(--border)',
149
+ boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
150
+ maxHeight: '220px',
151
+ }}>
152
+ {suggestions.map((suggestion, i) => (
153
+ <button
154
+ key={suggestion}
155
+ type="button"
156
+ role="option"
157
+ aria-selected={i === activeSuggestion}
158
+ onMouseDown={() => selectSuggestion(suggestion)}
159
+ className="w-full text-left px-3 py-2 text-sm font-mono transition-colors"
160
+ style={{
161
+ background: i === activeSuggestion ? 'var(--muted)' : 'transparent',
162
+ color: 'var(--foreground)',
163
+ borderTop: i > 0 ? '1px solid var(--border)' : undefined,
164
+ }}>
165
+ {suggestion}
166
+ </button>
167
+ ))}
168
+ </div>
169
+ )}
170
+ </div>
171
+ {/* Recommended default — one-click accept */}
172
+ {state.mindRoot !== placeholder && placeholder !== s.kbPathDefault && (
173
+ <button type="button"
174
+ onClick={() => update('mindRoot', placeholder)}
175
+ className="mt-1.5 px-2.5 py-1 text-xs rounded-md border transition-colors hover:bg-muted/50"
176
+ style={{ borderColor: 'var(--amber)', color: 'var(--amber)' }}>
177
+ {s.kbPathUseDefault(placeholder)}
178
+ </button>
179
+ )}
180
+ </Field>
181
+ {/* Template selection — conditional on directory state */}
182
+ {pathInfo && pathInfo.exists && !pathInfo.empty && !showTemplatePickerAnyway ? (
183
+ <div>
184
+ <label className="text-sm text-foreground font-medium mb-3 block">{s.template}</label>
185
+ <div className="rounded-lg border p-3 text-sm" style={{ borderColor: 'var(--amber)', background: 'color-mix(in srgb, var(--amber) 6%, transparent)' }}>
186
+ <p style={{ color: 'var(--amber)' }}>
187
+ {s.kbPathHasFiles(pathInfo.count)}
188
+ </p>
189
+ <div className="flex gap-2 mt-2">
190
+ <button type="button"
191
+ onClick={() => update('template', '')}
192
+ className="px-2.5 py-1 text-xs rounded-md border transition-colors"
193
+ style={{
194
+ borderColor: 'var(--amber)',
195
+ color: state.template === '' ? 'var(--background)' : 'var(--amber)',
196
+ background: state.template === '' ? 'var(--amber)' : 'transparent',
197
+ }}>
198
+ {state.template === '' ? <>{s.kbTemplateSkip} ✓</> : s.kbTemplateSkip}
199
+ </button>
200
+ <button type="button"
201
+ onClick={() => setShowTemplatePickerAnyway(true)}
202
+ className="px-2.5 py-1 text-xs rounded-md border transition-colors hover:bg-muted/50"
203
+ style={{ borderColor: 'var(--border)', color: 'var(--muted-foreground)' }}>
204
+ {s.kbTemplateMerge}
205
+ </button>
206
+ </div>
207
+ </div>
208
+ </div>
209
+ ) : (
210
+ <div>
211
+ <label className="text-sm text-foreground font-medium mb-3 block">{s.template}</label>
212
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
213
+ {TEMPLATES.map(tpl => (
214
+ <button key={tpl.id} onClick={() => update('template', tpl.id)}
215
+ className="flex flex-col items-start gap-2 p-4 rounded-xl border text-left transition-all duration-150"
216
+ style={{
217
+ background: state.template === tpl.id ? 'var(--amber-dim)' : 'var(--card)',
218
+ borderColor: state.template === tpl.id ? 'var(--amber)' : 'var(--border)',
219
+ }}>
220
+ <div className="flex items-center gap-2">
221
+ <span style={{ color: 'var(--amber)' }}>{tpl.icon}</span>
222
+ <span className="text-sm font-medium" style={{ color: 'var(--foreground)' }}>
223
+ {t.onboarding.templates[tpl.id as 'en' | 'zh' | 'empty'].title}
224
+ </span>
225
+ </div>
226
+ <div className="w-full rounded-lg px-2.5 py-1.5 text-xs leading-relaxed font-display"
227
+ style={{ background: 'var(--muted)', color: 'var(--muted-foreground)' }}>
228
+ {tpl.dirs.map(d => <div key={d}>{d}</div>)}
229
+ </div>
230
+ </button>
231
+ ))}
232
+ </div>
233
+ </div>
234
+ )}
235
+ </div>
236
+ );
237
+ }
@@ -0,0 +1,121 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef } from 'react';
4
+ import { Loader2, AlertTriangle, CheckCircle2, Info } from 'lucide-react';
5
+ import { Field, Input } from '@/components/settings/Primitives';
6
+ import type { SetupState, PortStatus, SetupMessages } from './types';
7
+
8
+ // ─── PortField ────────────────────────────────────────────────────────────────
9
+ function PortField({
10
+ label, hint, value, onChange, status, onCheckPort, s,
11
+ }: {
12
+ label: string; hint: string; value: number;
13
+ onChange: (v: number) => void;
14
+ status: PortStatus;
15
+ onCheckPort: (port: number) => void;
16
+ s: SetupMessages;
17
+ }) {
18
+ // Debounce auto-check on input change (500ms)
19
+ const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
20
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
21
+ const v = parseInt(e.target.value, 10) || value;
22
+ onChange(v);
23
+ clearTimeout(timerRef.current);
24
+ if (v >= 1024 && v <= 65535) {
25
+ timerRef.current = setTimeout(() => onCheckPort(v), 500);
26
+ }
27
+ };
28
+ const handleBlur = () => {
29
+ // Cancel pending debounce — onBlur fires the check immediately
30
+ clearTimeout(timerRef.current);
31
+ onCheckPort(value);
32
+ };
33
+ useEffect(() => () => clearTimeout(timerRef.current), []);
34
+
35
+ return (
36
+ <Field label={label} hint={hint}>
37
+ <div className="space-y-1.5">
38
+ <Input
39
+ type="number" min={1024} max={65535} value={value}
40
+ onChange={handleChange}
41
+ onBlur={handleBlur}
42
+ />
43
+ {status.checking && (
44
+ <p className="text-xs flex items-center gap-1" role="status" style={{ color: 'var(--muted-foreground)' }}>
45
+ <Loader2 size={11} className="animate-spin" /> {s.portChecking}
46
+ </p>
47
+ )}
48
+ {!status.checking && status.available === false && (
49
+ <div className="flex items-center gap-2" role="alert">
50
+ <p className="text-xs flex items-center gap-1" style={{ color: 'var(--amber)' }}>
51
+ <AlertTriangle size={11} /> {s.portInUse(value)}
52
+ </p>
53
+ {status.suggestion !== null && (
54
+ <button type="button"
55
+ onClick={() => {
56
+ onChange(status.suggestion!);
57
+ setTimeout(() => onCheckPort(status.suggestion!), 0);
58
+ }}
59
+ className="text-xs px-2 py-0.5 rounded border transition-colors"
60
+ style={{ borderColor: 'var(--amber)', color: 'var(--amber)' }}>
61
+ {s.portSuggest(status.suggestion)}
62
+ </button>
63
+ )}
64
+ </div>
65
+ )}
66
+ {!status.checking && status.available === true && (
67
+ <p className="text-xs flex items-center gap-1" style={{ color: 'var(--success)' }}>
68
+ <CheckCircle2 size={11} /> {status.isSelf ? s.portSelf : s.portAvailable}
69
+ </p>
70
+ )}
71
+ </div>
72
+ </Field>
73
+ );
74
+ }
75
+
76
+ // ─── Step 3: Ports ────────────────────────────────────────────────────────────
77
+ export interface StepPortsProps {
78
+ state: SetupState;
79
+ update: <K extends keyof SetupState>(key: K, val: SetupState[K]) => void;
80
+ webPortStatus: PortStatus;
81
+ mcpPortStatus: PortStatus;
82
+ setWebPortStatus: (s: PortStatus) => void;
83
+ setMcpPortStatus: (s: PortStatus) => void;
84
+ checkPort: (port: number, which: 'web' | 'mcp') => void;
85
+ portConflict: boolean;
86
+ s: SetupMessages;
87
+ }
88
+
89
+ export default function StepPorts({
90
+ state, update, webPortStatus, mcpPortStatus, setWebPortStatus, setMcpPortStatus, checkPort, portConflict, s,
91
+ }: StepPortsProps) {
92
+ return (
93
+ <div className="space-y-5">
94
+ <PortField
95
+ label={s.webPort} hint={s.portHint} value={state.webPort}
96
+ onChange={v => { update('webPort', v); setWebPortStatus({ checking: false, available: null, isSelf: false, suggestion: null }); }}
97
+ status={webPortStatus}
98
+ onCheckPort={port => checkPort(port, 'web')}
99
+ s={s}
100
+ />
101
+ <PortField
102
+ label={s.mcpPort} hint={s.portHint} value={state.mcpPort}
103
+ onChange={v => { update('mcpPort', v); setMcpPortStatus({ checking: false, available: null, isSelf: false, suggestion: null }); }}
104
+ status={mcpPortStatus}
105
+ onCheckPort={port => checkPort(port, 'mcp')}
106
+ s={s}
107
+ />
108
+ {portConflict && (
109
+ <p className="text-xs flex items-center gap-1.5" role="alert" style={{ color: 'var(--amber)' }}>
110
+ <AlertTriangle size={12} /> {s.portConflict}
111
+ </p>
112
+ )}
113
+ {!portConflict && (webPortStatus.available === null || mcpPortStatus.available === null) && !webPortStatus.checking && !mcpPortStatus.checking && (
114
+ <p className="text-xs" style={{ color: 'var(--muted-foreground)' }}>{s.portVerifyHint}</p>
115
+ )}
116
+ <p className="text-xs flex items-center gap-1.5" style={{ color: 'var(--muted-foreground)' }}>
117
+ <Info size={12} /> {s.portRestartWarning}
118
+ </p>
119
+ </div>
120
+ );
121
+ }
@@ -0,0 +1,211 @@
1
+ 'use client';
2
+
3
+ import { useState, useRef, useEffect } from 'react';
4
+ import {
5
+ Loader2, AlertTriangle, CheckCircle2, XCircle,
6
+ } from 'lucide-react';
7
+ import type { SetupState, SetupMessages, AgentInstallStatus } from './types';
8
+
9
+ // ─── Restart Block ────────────────────────────────────────────────────────────
10
+ function RestartBlock({ s, newPort }: { s: SetupMessages; newPort: number }) {
11
+ const [restarting, setRestarting] = useState(false);
12
+ const [done, setDone] = useState(false);
13
+ const pollRef = useRef<ReturnType<typeof setInterval>>(undefined);
14
+
15
+ // Cleanup polling interval on unmount
16
+ useEffect(() => () => { clearInterval(pollRef.current); }, []);
17
+
18
+ const handleRestart = async () => {
19
+ setRestarting(true);
20
+ try {
21
+ await fetch('/api/restart', { method: 'POST' });
22
+ setDone(true);
23
+ const redirect = () => { window.location.href = `http://localhost:${newPort}/?welcome=1`; };
24
+ // Poll the new port until ready, then redirect
25
+ let attempts = 0;
26
+ clearInterval(pollRef.current);
27
+ pollRef.current = setInterval(async () => {
28
+ attempts++;
29
+ try {
30
+ const r = await fetch(`http://localhost:${newPort}/api/health`);
31
+ if (r.status < 500) { clearInterval(pollRef.current); redirect(); return; }
32
+ } catch { /* not ready yet */ }
33
+ if (attempts >= 10) { clearInterval(pollRef.current); redirect(); }
34
+ }, 800);
35
+ } catch (e) {
36
+ console.warn('[SetupWizard] restart request failed:', e);
37
+ setRestarting(false);
38
+ }
39
+ };
40
+
41
+ if (done) {
42
+ return (
43
+ <div className="p-3 rounded-lg text-sm flex items-center gap-2"
44
+ style={{ background: 'color-mix(in srgb, var(--success) 10%, transparent)', color: 'var(--success)' }}>
45
+ <CheckCircle2 size={14} /> {s.restartDone}
46
+ </div>
47
+ );
48
+ }
49
+
50
+ return (
51
+ <div className="space-y-3">
52
+ <div className="p-3 rounded-lg text-sm flex items-center gap-2"
53
+ style={{ background: 'color-mix(in srgb, var(--amber) 10%, transparent)', color: 'var(--amber)' }}>
54
+ <AlertTriangle size={14} /> {s.restartRequired}
55
+ </div>
56
+ <div className="flex items-center gap-3">
57
+ <button
58
+ type="button"
59
+ onClick={handleRestart}
60
+ disabled={restarting}
61
+ className="flex items-center gap-1.5 px-4 py-2 text-sm rounded-lg transition-colors disabled:opacity-50"
62
+ style={{ background: 'var(--amber)', color: 'var(--amber-foreground)' }}>
63
+ {restarting ? <Loader2 size={13} className="animate-spin" /> : null}
64
+ {restarting ? s.restarting : s.restartNow}
65
+ </button>
66
+ <span className="text-xs" style={{ color: 'var(--muted-foreground)' }}>
67
+ {s.restartManual} <code className="font-mono">mindos start</code>
68
+ </span>
69
+ </div>
70
+ </div>
71
+ );
72
+ }
73
+
74
+ // ─── Step 6: Review ───────────────────────────────────────────────────────────
75
+ export interface StepReviewProps {
76
+ state: SetupState;
77
+ selectedAgents: Set<string>;
78
+ agentStatuses: Record<string, AgentInstallStatus>;
79
+ onRetryAgent: (key: string) => void;
80
+ error: string;
81
+ needsRestart: boolean;
82
+ s: SetupMessages;
83
+ skillInstallResult: { ok?: boolean; skill?: string; error?: string } | null;
84
+ setupPhase: 'review' | 'saving' | 'agents' | 'skill' | 'done';
85
+ }
86
+
87
+ export default function StepReview({
88
+ state, selectedAgents, agentStatuses, onRetryAgent, error, needsRestart, s,
89
+ skillInstallResult, setupPhase,
90
+ }: StepReviewProps) {
91
+ const failedAgents = Object.entries(agentStatuses).filter(([, v]) => v.state === 'error');
92
+
93
+ // Compact config summary (only key info)
94
+ const summaryRows: [string, string][] = [
95
+ [s.kbPath, state.mindRoot],
96
+ [s.webPort, `${state.webPort} / ${state.mcpPort}`],
97
+ [s.agentToolsTitle, selectedAgents.size > 0 ? s.agentCountSummary(selectedAgents.size) : '—'],
98
+ ];
99
+
100
+ // Progress stepper phases
101
+ type Phase = typeof setupPhase;
102
+ const phases: { key: Phase; label: string }[] = [
103
+ { key: 'saving', label: s.phaseSaving },
104
+ { key: 'agents', label: s.phaseAgents },
105
+ { key: 'skill', label: s.phaseSkill },
106
+ { key: 'done', label: s.phaseDone },
107
+ ];
108
+ const phaseOrder: Phase[] = ['saving', 'agents', 'skill', 'done'];
109
+ const currentIdx = phaseOrder.indexOf(setupPhase);
110
+
111
+ return (
112
+ <div className="space-y-5">
113
+ {/* Compact config summary */}
114
+ <div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border)' }}>
115
+ {summaryRows.map(([label, value], i) => (
116
+ <div key={i} className="flex items-center justify-between px-4 py-2.5 text-sm"
117
+ style={{
118
+ background: i % 2 === 0 ? 'var(--card)' : 'transparent',
119
+ borderTop: i > 0 ? '1px solid var(--border)' : undefined,
120
+ }}>
121
+ <span style={{ color: 'var(--muted-foreground)' }}>{label}</span>
122
+ <span className="font-mono text-xs truncate ml-4" style={{ color: 'var(--foreground)' }}>{value}</span>
123
+ </div>
124
+ ))}
125
+ </div>
126
+
127
+ {/* Before submit: review hint */}
128
+ {setupPhase === 'review' && (
129
+ <p className="text-sm" style={{ color: 'var(--muted-foreground)' }}>{s.reviewHint}</p>
130
+ )}
131
+
132
+ {/* Progress stepper — visible during/after setup */}
133
+ {setupPhase !== 'review' && (
134
+ <div className="space-y-2 py-2">
135
+ {phases.map(({ key, label }, i) => {
136
+ const idx = phaseOrder.indexOf(key);
137
+ const isDone = currentIdx > idx || (key === 'done' && setupPhase === 'done');
138
+ const isActive = setupPhase === key && key !== 'done';
139
+ const isPending = currentIdx < idx;
140
+ return (
141
+ <div key={key} className="flex items-center gap-3">
142
+ <div className="w-5 h-5 rounded-full flex items-center justify-center shrink-0 text-2xs"
143
+ style={{
144
+ background: isDone ? 'color-mix(in srgb, var(--success) 15%, transparent)' : isActive ? 'color-mix(in srgb, var(--amber) 15%, transparent)' : 'var(--muted)',
145
+ color: isDone ? 'var(--success)' : isActive ? 'var(--amber)' : 'var(--muted-foreground)',
146
+ }}>
147
+ {isDone ? <CheckCircle2 size={12} /> : isActive ? <Loader2 size={12} className="animate-spin" /> : (i + 1)}
148
+ </div>
149
+ <span className="text-sm" style={{
150
+ color: isDone ? 'var(--success)' : isActive ? 'var(--foreground)' : 'var(--muted-foreground)',
151
+ fontWeight: isActive ? 500 : 400,
152
+ opacity: isPending ? 0.5 : 1,
153
+ }}>
154
+ {label}
155
+ </span>
156
+ </div>
157
+ );
158
+ })}
159
+ </div>
160
+ )}
161
+
162
+ {/* Agent failures — expandable */}
163
+ {failedAgents.length > 0 && setupPhase === 'done' && (
164
+ <div className="p-3 rounded-lg space-y-2" style={{ background: 'color-mix(in srgb, var(--error) 8%, transparent)' }}>
165
+ <p className="text-xs font-medium" style={{ color: 'var(--error)' }}>
166
+ {s.agentFailedCount(failedAgents.length)}
167
+ </p>
168
+ {failedAgents.map(([key, st]) => (
169
+ <div key={key} className="flex items-center justify-between gap-2">
170
+ <span className="text-xs flex items-center gap-1" style={{ color: 'var(--error)' }}>
171
+ <XCircle size={11} /> {key}{st.message ? ` — ${st.message}` : ''}
172
+ </span>
173
+ {/* Retry button — no disabled guard needed: once clicked, state becomes
174
+ 'installing' and this entry is filtered out of failedAgents */}
175
+ <button
176
+ type="button"
177
+ onClick={() => onRetryAgent(key)}
178
+ className="text-xs px-2 py-0.5 rounded border transition-colors"
179
+ style={{ borderColor: 'var(--error)', color: 'var(--error)' }}>
180
+ {s.retryAgent}
181
+ </button>
182
+ </div>
183
+ ))}
184
+ <p className="text-xs" style={{ color: 'var(--muted-foreground)' }}>{s.agentFailureNote}</p>
185
+ </div>
186
+ )}
187
+
188
+ {/* Skill result — compact */}
189
+ {skillInstallResult && setupPhase === 'done' && (
190
+ <div className="flex items-center gap-2 text-xs px-3 py-2 rounded-lg" style={{
191
+ background: skillInstallResult.ok ? 'color-mix(in srgb, var(--success) 6%, transparent)' : 'color-mix(in srgb, var(--error) 6%, transparent)',
192
+ }}>
193
+ {skillInstallResult.ok ? (
194
+ <><CheckCircle2 size={11} className="text-success shrink-0" />
195
+ <span style={{ color: 'var(--foreground)' }}>{s.skillInstalled} — {skillInstallResult.skill}</span></>
196
+ ) : (
197
+ <><XCircle size={11} className="text-error shrink-0" />
198
+ <span style={{ color: 'var(--error)' }}>{s.skillFailed}{skillInstallResult.error ? `: ${skillInstallResult.error}` : ''}</span></>
199
+ )}
200
+ </div>
201
+ )}
202
+
203
+ {error && (
204
+ <div className="p-3 rounded-lg text-sm text-error" style={{ background: 'color-mix(in srgb, var(--error) 10%, transparent)' }}>
205
+ {s.completeFailed}: {error}
206
+ </div>
207
+ )}
208
+ {needsRestart && setupPhase === 'done' && <RestartBlock s={s} newPort={state.webPort} />}
209
+ </div>
210
+ );
211
+ }
@@ -0,0 +1,78 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { Copy, Check, RefreshCw } from 'lucide-react';
5
+ import { Field, Input } from '@/components/settings/Primitives';
6
+ import type { SetupMessages } from './types';
7
+
8
+ export interface StepSecurityProps {
9
+ authToken: string;
10
+ tokenCopied: boolean;
11
+ onCopy: () => void;
12
+ onGenerate: (seed?: string) => void;
13
+ webPassword: string;
14
+ onPasswordChange: (v: string) => void;
15
+ s: SetupMessages;
16
+ }
17
+
18
+ export default function StepSecurity({
19
+ authToken, tokenCopied, onCopy, onGenerate, webPassword, onPasswordChange, s,
20
+ }: StepSecurityProps) {
21
+ const [seed, setSeed] = useState('');
22
+ const [showSeed, setShowSeed] = useState(false);
23
+ const [showUsage, setShowUsage] = useState(false);
24
+ return (
25
+ <div className="space-y-5">
26
+ <Field label={s.authToken} hint={s.authTokenHint}>
27
+ <div className="flex gap-2">
28
+ <Input value={authToken} readOnly className="font-mono text-xs" />
29
+ <button onClick={onCopy}
30
+ className="flex items-center gap-1 px-3 py-2 text-xs rounded-lg border border-border hover:bg-muted transition-colors shrink-0"
31
+ style={{ color: 'var(--foreground)' }}>
32
+ {tokenCopied ? <Check size={14} /> : <Copy size={14} />}
33
+ {tokenCopied ? s.copiedToken : s.copyToken}
34
+ </button>
35
+ <button onClick={() => onGenerate()}
36
+ aria-label={s.generateToken}
37
+ className="flex items-center gap-1 px-3 py-2 text-xs rounded-lg border border-border hover:bg-muted transition-colors shrink-0"
38
+ style={{ color: 'var(--foreground)' }}>
39
+ <RefreshCw size={14} />
40
+ </button>
41
+ </div>
42
+ </Field>
43
+ <div className="space-y-1.5">
44
+ <button onClick={() => setShowUsage(!showUsage)} className="text-xs underline"
45
+ aria-expanded={showUsage}
46
+ style={{ color: 'var(--muted-foreground)' }}>
47
+ {s.authTokenUsageWhat}
48
+ </button>
49
+ {showUsage && (
50
+ <p className="text-xs leading-relaxed px-3 py-2 rounded-lg"
51
+ style={{ background: 'var(--muted)', color: 'var(--muted-foreground)' }}>
52
+ {s.authTokenUsage}
53
+ </p>
54
+ )}
55
+ </div>
56
+ <div>
57
+ <button onClick={() => setShowSeed(!showSeed)} className="text-xs underline"
58
+ aria-expanded={showSeed}
59
+ style={{ color: 'var(--muted-foreground)' }}>
60
+ {s.authTokenSeed}
61
+ </button>
62
+ {showSeed && (
63
+ <div className="mt-2 flex gap-2">
64
+ <Input value={seed} onChange={e => setSeed(e.target.value)} placeholder={s.authTokenSeedHint} />
65
+ <button onClick={() => { if (seed.trim()) onGenerate(seed); }}
66
+ className="px-3 py-2 text-xs rounded-lg border border-border hover:bg-muted transition-colors shrink-0"
67
+ style={{ color: 'var(--foreground)' }}>
68
+ {s.generateToken}
69
+ </button>
70
+ </div>
71
+ )}
72
+ </div>
73
+ <Field label={s.webPassword} hint={s.webPasswordHint}>
74
+ <Input type="password" value={webPassword} onChange={e => onPasswordChange(e.target.value)} placeholder="(optional)" />
75
+ </Field>
76
+ </div>
77
+ );
78
+ }
@@ -0,0 +1,13 @@
1
+ import { Globe, BookOpen, FileText } from 'lucide-react';
2
+ import type { Template } from './types';
3
+
4
+ export const TEMPLATES: Array<{ id: Template; icon: React.ReactNode; dirs: string[] }> = [
5
+ { id: 'en', icon: <Globe size={18} />, dirs: ['Profile/', 'Connections/', 'Notes/', 'Workflows/', 'Resources/', 'Projects/'] },
6
+ { id: 'zh', icon: <BookOpen size={18} />, dirs: ['画像/', '关系/', '笔记/', '流程/', '资源/', '项目/'] },
7
+ { id: 'empty', icon: <FileText size={18} />, dirs: ['README.md', 'CONFIG.json', 'INSTRUCTION.md'] },
8
+ ];
9
+
10
+ export const TOTAL_STEPS = 6;
11
+ export const STEP_KB = 0;
12
+ export const STEP_PORTS = 2;
13
+ export const STEP_AGENTS = 4;