@geminilight/mindos 0.2.1 → 0.3.0

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.
@@ -1,56 +1,22 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
- import fs from 'fs';
3
- import path from 'path';
4
2
  import { getMindRoot } from '@/lib/fs';
5
-
6
- function copyRecursive(src: string, dest: string) {
7
- const stat = fs.statSync(src);
8
- if (stat.isDirectory()) {
9
- if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
10
- for (const entry of fs.readdirSync(src)) {
11
- copyRecursive(path.join(src, entry), path.join(dest, entry));
12
- }
13
- } else {
14
- // Skip if file already exists
15
- if (fs.existsSync(dest)) return;
16
- // Ensure parent directory exists
17
- const dir = path.dirname(dest);
18
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
19
- fs.copyFileSync(src, dest);
20
- }
21
- }
3
+ import { applyTemplate } from '@/lib/template';
22
4
 
23
5
  export async function POST(req: NextRequest) {
24
6
  try {
25
7
  const body = await req.json();
26
8
  const template = body.template as string;
27
9
 
28
- if (!['en', 'zh', 'empty'].includes(template)) {
29
- return NextResponse.json({ error: 'Invalid template' }, { status: 400 });
30
- }
31
-
32
- // Resolve template source directory
33
- // templates/ is at the repo root (sibling of app/)
34
- const repoRoot = path.resolve(process.cwd(), '..');
35
- const templateDir = path.join(repoRoot, 'templates', template);
36
-
37
- if (!fs.existsSync(templateDir)) {
38
- return NextResponse.json({ error: `Template "${template}" not found` }, { status: 404 });
39
- }
40
-
41
10
  const mindRoot = getMindRoot();
42
- if (!fs.existsSync(mindRoot)) {
43
- fs.mkdirSync(mindRoot, { recursive: true });
44
- }
45
-
46
- copyRecursive(templateDir, mindRoot);
11
+ applyTemplate(template, mindRoot);
47
12
 
48
13
  return NextResponse.json({ ok: true, template });
49
14
  } catch (e) {
50
15
  console.error('[/api/init] Error:', e);
51
- return NextResponse.json(
52
- { error: e instanceof Error ? e.message : String(e) },
53
- { status: 500 },
54
- );
16
+ const msg = e instanceof Error ? e.message : String(e);
17
+ const status = msg.startsWith('Invalid template') ? 400
18
+ : msg.includes('not found') ? 404
19
+ : 500;
20
+ return NextResponse.json({ error: msg }, { status });
55
21
  }
56
22
  }
@@ -112,6 +112,9 @@ export async function POST(req: NextRequest) {
112
112
  mindRoot: body.mindRoot ?? current.mindRoot,
113
113
  webPassword: resolvedWebPassword,
114
114
  authToken: resolvedAuthToken,
115
+ port: typeof body.port === 'number' ? body.port : current.port,
116
+ mcpPort: typeof body.mcpPort === 'number' ? body.mcpPort : current.mcpPort,
117
+ startMode: body.startMode ?? current.startMode,
115
118
  };
116
119
 
117
120
  writeSettings(next);
@@ -0,0 +1,23 @@
1
+ export const dynamic = 'force-dynamic';
2
+ import { NextResponse } from 'next/server';
3
+ import { randomBytes, createHash } from 'crypto';
4
+
5
+ export async function POST(req: Request) {
6
+ try {
7
+ const { seed } = await req.json().catch(() => ({} as { seed?: string }));
8
+ let raw: string;
9
+ if (seed && typeof seed === 'string' && seed.trim()) {
10
+ raw = createHash('sha256').update(seed.trim()).digest('hex').slice(0, 24);
11
+ } else {
12
+ raw = randomBytes(12).toString('hex'); // 24 hex chars
13
+ }
14
+ // Format as xxxx-xxxx-xxxx-xxxx-xxxx-xxxx
15
+ const token = raw.match(/.{4}/g)!.join('-');
16
+ return NextResponse.json({ token });
17
+ } catch (e) {
18
+ return NextResponse.json(
19
+ { error: e instanceof Error ? e.message : String(e) },
20
+ { status: 500 },
21
+ );
22
+ }
23
+ }
@@ -0,0 +1,81 @@
1
+ export const dynamic = 'force-dynamic';
2
+ import { NextRequest, NextResponse } from 'next/server';
3
+ import fs from 'fs';
4
+ import os from 'os';
5
+ import { readSettings, writeSettings, ServerSettings } from '@/lib/settings';
6
+ import { applyTemplate } from '@/lib/template';
7
+
8
+ function expandHome(p: string): string {
9
+ if (p.startsWith('~/')) return p.replace('~', os.homedir());
10
+ if (p === '~') return os.homedir();
11
+ return p;
12
+ }
13
+
14
+ export async function POST(req: NextRequest) {
15
+ try {
16
+ const body = await req.json();
17
+ const { mindRoot, template, port, mcpPort, authToken, webPassword, ai } = body;
18
+
19
+ // Validate required fields
20
+ if (!mindRoot || typeof mindRoot !== 'string') {
21
+ return NextResponse.json({ error: 'mindRoot is required' }, { status: 400 });
22
+ }
23
+
24
+ const resolvedRoot = expandHome(mindRoot.trim());
25
+
26
+ // Validate ports
27
+ const webPort = typeof port === 'number' ? port : 3000;
28
+ const mcpPortNum = typeof mcpPort === 'number' ? mcpPort : 8787;
29
+ if (webPort < 1024 || webPort > 65535) {
30
+ return NextResponse.json({ error: `Invalid web port: ${webPort}` }, { status: 400 });
31
+ }
32
+ if (mcpPortNum < 1024 || mcpPortNum > 65535) {
33
+ return NextResponse.json({ error: `Invalid MCP port: ${mcpPortNum}` }, { status: 400 });
34
+ }
35
+
36
+ // Apply template if mindRoot doesn't exist or is empty
37
+ const dirExists = fs.existsSync(resolvedRoot);
38
+ let dirEmpty = true;
39
+ if (dirExists) {
40
+ try {
41
+ const entries = fs.readdirSync(resolvedRoot).filter(e => !e.startsWith('.'));
42
+ dirEmpty = entries.length === 0;
43
+ } catch { /* treat as empty */ }
44
+ }
45
+
46
+ if (template && (!dirExists || dirEmpty)) {
47
+ applyTemplate(template, resolvedRoot);
48
+ } else if (!dirExists) {
49
+ fs.mkdirSync(resolvedRoot, { recursive: true });
50
+ }
51
+
52
+ // Read current running port for portChanged detection
53
+ const current = readSettings();
54
+ const currentPort = current.port ?? 3000;
55
+
56
+ // Build config
57
+ const config: ServerSettings = {
58
+ ai: ai ?? current.ai,
59
+ mindRoot: resolvedRoot,
60
+ port: webPort,
61
+ mcpPort: mcpPortNum,
62
+ authToken: authToken ?? current.authToken,
63
+ webPassword: webPassword ?? '',
64
+ startMode: current.startMode,
65
+ setupPending: false, // clear the flag
66
+ };
67
+
68
+ writeSettings(config);
69
+
70
+ return NextResponse.json({
71
+ ok: true,
72
+ portChanged: webPort !== currentPort,
73
+ });
74
+ } catch (e) {
75
+ console.error('[/api/setup] Error:', e);
76
+ return NextResponse.json(
77
+ { error: e instanceof Error ? e.message : String(e) },
78
+ { status: 500 },
79
+ );
80
+ }
81
+ }
package/app/app/page.tsx CHANGED
@@ -1,7 +1,12 @@
1
+ import { redirect } from 'next/navigation';
2
+ import { readSettings } from '@/lib/settings';
1
3
  import { getRecentlyModified } from '@/lib/fs';
2
4
  import HomeContent from '@/components/HomeContent';
3
5
 
4
6
  export default function HomePage() {
7
+ const settings = readSettings();
8
+ if (settings.setupPending) redirect('/setup');
9
+
5
10
  let recent: { path: string; mtime: number }[] = [];
6
11
  try {
7
12
  recent = getRecentlyModified(15);
@@ -0,0 +1,9 @@
1
+ import { redirect } from 'next/navigation';
2
+ import { readSettings } from '@/lib/settings';
3
+ import SetupWizard from '@/components/SetupWizard';
4
+
5
+ export default function SetupPage() {
6
+ const settings = readSettings();
7
+ if (!settings.setupPending) redirect('/');
8
+ return <SetupWizard />;
9
+ }
@@ -0,0 +1,479 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback } from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+ import { Sparkles, Globe, BookOpen, FileText, Copy, Check, RefreshCw, Loader2, ChevronLeft, ChevronRight, AlertTriangle } from 'lucide-react';
6
+ import { useLocale } from '@/lib/LocaleContext';
7
+ import { Field, Input, Select, ApiKeyInput } from '@/components/settings/Primitives';
8
+
9
+ type Template = 'en' | 'zh' | 'empty' | '';
10
+
11
+ interface SetupState {
12
+ mindRoot: string;
13
+ template: Template;
14
+ provider: 'anthropic' | 'openai' | 'skip';
15
+ anthropicKey: string;
16
+ anthropicModel: string;
17
+ openaiKey: string;
18
+ openaiModel: string;
19
+ openaiBaseUrl: string;
20
+ webPort: number;
21
+ mcpPort: number;
22
+ authToken: string;
23
+ webPassword: string;
24
+ }
25
+
26
+ const TEMPLATES: Array<{
27
+ id: Template;
28
+ icon: React.ReactNode;
29
+ dirs: string[];
30
+ }> = [
31
+ { id: 'en', icon: <Globe size={18} />, dirs: ['Profile/', 'Connections/', 'Notes/', 'Workflows/', 'Resources/', 'Projects/'] },
32
+ { id: 'zh', icon: <BookOpen size={18} />, dirs: ['į”ŧ像/', 'å…ģįģŧ/', 'įŽ”čŪ°/', 'æĩįĻ‹/', 'čĩ„暐/', 'éĄđį›Ū/'] },
33
+ { id: 'empty', icon: <FileText size={18} />, dirs: ['README.md', 'CONFIG.json', 'INSTRUCTION.md'] },
34
+ ];
35
+
36
+ const TOTAL_STEPS = 5;
37
+
38
+ export default function SetupWizard() {
39
+ const { t } = useLocale();
40
+ const router = useRouter();
41
+ const s = t.setup;
42
+
43
+ const [step, setStep] = useState(0);
44
+ const [state, setState] = useState<SetupState>({
45
+ mindRoot: '~/MindOS',
46
+ template: 'en',
47
+ provider: 'anthropic',
48
+ anthropicKey: '',
49
+ anthropicModel: 'claude-sonnet-4-6',
50
+ openaiKey: '',
51
+ openaiModel: 'gpt-5.4',
52
+ openaiBaseUrl: '',
53
+ webPort: 3000,
54
+ mcpPort: 8787,
55
+ authToken: '',
56
+ webPassword: '',
57
+ });
58
+ const [tokenCopied, setTokenCopied] = useState(false);
59
+ const [submitting, setSubmitting] = useState(false);
60
+ const [error, setError] = useState('');
61
+ const [portChanged, setPortChanged] = useState(false);
62
+
63
+ // Generate token on mount
64
+ useEffect(() => {
65
+ fetch('/api/setup/generate-token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
66
+ .then(r => r.json())
67
+ .then(data => { if (data.token) setState(prev => ({ ...prev, authToken: data.token })); })
68
+ .catch(() => {});
69
+ }, []);
70
+
71
+ const update = useCallback(<K extends keyof SetupState>(key: K, val: SetupState[K]) => {
72
+ setState(prev => ({ ...prev, [key]: val }));
73
+ }, []);
74
+
75
+ const generateToken = async (seed?: string) => {
76
+ try {
77
+ const res = await fetch('/api/setup/generate-token', {
78
+ method: 'POST',
79
+ headers: { 'Content-Type': 'application/json' },
80
+ body: JSON.stringify({ seed: seed || undefined }),
81
+ });
82
+ const data = await res.json();
83
+ if (data.token) update('authToken', data.token);
84
+ } catch { /* ignore */ }
85
+ };
86
+
87
+ const copyToken = () => {
88
+ navigator.clipboard.writeText(state.authToken);
89
+ setTokenCopied(true);
90
+ setTimeout(() => setTokenCopied(false), 2000);
91
+ };
92
+
93
+ const handleComplete = async () => {
94
+ setSubmitting(true);
95
+ setError('');
96
+ try {
97
+ const payload = {
98
+ mindRoot: state.mindRoot.startsWith('~')
99
+ ? state.mindRoot // server will resolve
100
+ : state.mindRoot,
101
+ template: state.template || undefined,
102
+ port: state.webPort,
103
+ mcpPort: state.mcpPort,
104
+ authToken: state.authToken,
105
+ webPassword: state.webPassword,
106
+ ai: state.provider === 'skip' ? undefined : {
107
+ provider: state.provider,
108
+ providers: {
109
+ anthropic: { apiKey: state.anthropicKey, model: state.anthropicModel },
110
+ openai: { apiKey: state.openaiKey, model: state.openaiModel, baseUrl: state.openaiBaseUrl },
111
+ },
112
+ },
113
+ };
114
+ const res = await fetch('/api/setup', {
115
+ method: 'POST',
116
+ headers: { 'Content-Type': 'application/json' },
117
+ body: JSON.stringify(payload),
118
+ });
119
+ const data = await res.json();
120
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
121
+ if (data.portChanged) setPortChanged(true);
122
+ else router.push('/');
123
+ } catch (e) {
124
+ setError(e instanceof Error ? e.message : String(e));
125
+ } finally {
126
+ setSubmitting(false);
127
+ }
128
+ };
129
+
130
+ const canNext = () => {
131
+ if (step === 0) return state.mindRoot.trim().length > 0;
132
+ if (step === 2) return state.webPort >= 1024 && state.webPort <= 65535 && state.mcpPort >= 1024 && state.mcpPort <= 65535;
133
+ return true;
134
+ };
135
+
136
+ const maskKey = (key: string) => {
137
+ if (!key) return '(not set)';
138
+ if (key.length <= 8) return 'â€Ēâ€Ēâ€Ē';
139
+ return key.slice(0, 6) + 'â€Ēâ€Ēâ€Ē' + key.slice(-3);
140
+ };
141
+
142
+ // Step indicator dots
143
+ const StepDots = () => (
144
+ <div className="flex items-center gap-2 mb-8">
145
+ {s.stepTitles.map((title: string, i: number) => (
146
+ <div key={i} className="flex items-center gap-2">
147
+ {i > 0 && <div className="w-8 h-px" style={{ background: i <= step ? 'var(--amber)' : 'var(--border)' }} />}
148
+ <button
149
+ onClick={() => i < step && setStep(i)}
150
+ className="flex items-center gap-1.5"
151
+ disabled={i > step}
152
+ >
153
+ <div
154
+ className="w-6 h-6 rounded-full text-xs font-medium flex items-center justify-center transition-colors"
155
+ style={{
156
+ background: i === step ? 'var(--amber)' : i < step ? 'var(--amber)' : 'var(--muted)',
157
+ color: i <= step ? 'white' : 'var(--muted-foreground)',
158
+ opacity: i <= step ? 1 : 0.5,
159
+ }}
160
+ >
161
+ {i + 1}
162
+ </div>
163
+ <span
164
+ className="text-xs hidden sm:inline"
165
+ style={{ color: i === step ? 'var(--foreground)' : 'var(--muted-foreground)', opacity: i <= step ? 1 : 0.5 }}
166
+ >
167
+ {title}
168
+ </span>
169
+ </button>
170
+ </div>
171
+ ))}
172
+ </div>
173
+ );
174
+
175
+ // Step 1: Knowledge Base
176
+ const Step1 = () => (
177
+ <div className="space-y-6">
178
+ <Field label={s.kbPath} hint={s.kbPathHint}>
179
+ <Input
180
+ value={state.mindRoot}
181
+ onChange={e => update('mindRoot', e.target.value)}
182
+ placeholder={s.kbPathDefault}
183
+ />
184
+ </Field>
185
+ <div>
186
+ <label className="text-sm text-foreground font-medium mb-3 block">{s.template}</label>
187
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
188
+ {TEMPLATES.map(tpl => (
189
+ <button
190
+ key={tpl.id}
191
+ onClick={() => update('template', tpl.id)}
192
+ className="flex flex-col items-start gap-2 p-4 rounded-xl border text-left transition-all duration-150"
193
+ style={{
194
+ background: state.template === tpl.id ? 'var(--amber-subtle, rgba(200,135,30,0.08))' : 'var(--card)',
195
+ borderColor: state.template === tpl.id ? 'var(--amber)' : 'var(--border)',
196
+ }}
197
+ >
198
+ <div className="flex items-center gap-2">
199
+ <span style={{ color: 'var(--amber)' }}>{tpl.icon}</span>
200
+ <span className="text-sm font-medium" style={{ color: 'var(--foreground)' }}>
201
+ {t.onboarding.templates[tpl.id as 'en' | 'zh' | 'empty'].title}
202
+ </span>
203
+ </div>
204
+ <div
205
+ className="w-full rounded-lg px-2.5 py-1.5 text-[11px] leading-relaxed"
206
+ style={{ background: 'var(--muted)', fontFamily: "'IBM Plex Mono', monospace", color: 'var(--muted-foreground)' }}
207
+ >
208
+ {tpl.dirs.map(d => <div key={d}>{d}</div>)}
209
+ </div>
210
+ </button>
211
+ ))}
212
+ </div>
213
+ </div>
214
+ </div>
215
+ );
216
+
217
+ // Step 2: AI Provider
218
+ const Step2 = () => (
219
+ <div className="space-y-5">
220
+ <Field label={s.aiProvider} hint={s.aiProviderHint}>
221
+ <Select value={state.provider} onChange={e => update('provider', e.target.value as SetupState['provider'])}>
222
+ <option value="anthropic">Anthropic</option>
223
+ <option value="openai">OpenAI</option>
224
+ <option value="skip">{s.aiSkip}</option>
225
+ </Select>
226
+ </Field>
227
+ {state.provider !== 'skip' && (
228
+ <>
229
+ <Field label={s.apiKey}>
230
+ <ApiKeyInput
231
+ value={state.provider === 'anthropic' ? state.anthropicKey : state.openaiKey}
232
+ onChange={v => update(state.provider === 'anthropic' ? 'anthropicKey' : 'openaiKey', v)}
233
+ placeholder={state.provider === 'anthropic' ? 'sk-ant-...' : 'sk-...'}
234
+ />
235
+ </Field>
236
+ <Field label={s.model}>
237
+ <Input
238
+ value={state.provider === 'anthropic' ? state.anthropicModel : state.openaiModel}
239
+ onChange={e => update(state.provider === 'anthropic' ? 'anthropicModel' : 'openaiModel', e.target.value)}
240
+ />
241
+ </Field>
242
+ {state.provider === 'openai' && (
243
+ <Field label={s.baseUrl} hint={s.baseUrlHint}>
244
+ <Input
245
+ value={state.openaiBaseUrl}
246
+ onChange={e => update('openaiBaseUrl', e.target.value)}
247
+ placeholder="https://api.openai.com/v1"
248
+ />
249
+ </Field>
250
+ )}
251
+ </>
252
+ )}
253
+ </div>
254
+ );
255
+
256
+ // Step 3: Ports
257
+ const Step3 = () => (
258
+ <div className="space-y-5">
259
+ <Field label={s.webPort} hint={s.portHint}>
260
+ <Input
261
+ type="number"
262
+ min={1024}
263
+ max={65535}
264
+ value={state.webPort}
265
+ onChange={e => update('webPort', parseInt(e.target.value, 10) || 3000)}
266
+ />
267
+ </Field>
268
+ <Field label={s.mcpPort} hint={s.portHint}>
269
+ <Input
270
+ type="number"
271
+ min={1024}
272
+ max={65535}
273
+ value={state.mcpPort}
274
+ onChange={e => update('mcpPort', parseInt(e.target.value, 10) || 8787)}
275
+ />
276
+ </Field>
277
+ <p className="text-xs flex items-center gap-1.5" style={{ color: 'var(--muted-foreground)' }}>
278
+ <AlertTriangle size={12} />
279
+ {s.portRestartWarning}
280
+ </p>
281
+ </div>
282
+ );
283
+
284
+ // Step 4: Security
285
+ const Step4 = () => {
286
+ const [seed, setSeed] = useState('');
287
+ const [showSeed, setShowSeed] = useState(false);
288
+
289
+ return (
290
+ <div className="space-y-5">
291
+ <Field label={s.authToken} hint={s.authTokenHint}>
292
+ <div className="flex gap-2">
293
+ <Input value={state.authToken} readOnly className="font-mono text-xs" />
294
+ <button
295
+ onClick={copyToken}
296
+ className="flex items-center gap-1 px-3 py-2 text-xs rounded-lg border border-border hover:bg-muted transition-colors shrink-0"
297
+ style={{ color: 'var(--foreground)' }}
298
+ >
299
+ {tokenCopied ? <Check size={14} /> : <Copy size={14} />}
300
+ {tokenCopied ? s.copiedToken : s.copyToken}
301
+ </button>
302
+ <button
303
+ onClick={() => generateToken()}
304
+ className="flex items-center gap-1 px-3 py-2 text-xs rounded-lg border border-border hover:bg-muted transition-colors shrink-0"
305
+ style={{ color: 'var(--foreground)' }}
306
+ >
307
+ <RefreshCw size={14} />
308
+ </button>
309
+ </div>
310
+ </Field>
311
+
312
+ <div>
313
+ <button
314
+ onClick={() => setShowSeed(!showSeed)}
315
+ className="text-xs underline"
316
+ style={{ color: 'var(--muted-foreground)' }}
317
+ >
318
+ {s.authTokenSeed}
319
+ </button>
320
+ {showSeed && (
321
+ <div className="mt-2 flex gap-2">
322
+ <Input
323
+ value={seed}
324
+ onChange={e => setSeed(e.target.value)}
325
+ placeholder={s.authTokenSeedHint}
326
+ />
327
+ <button
328
+ onClick={() => { if (seed.trim()) generateToken(seed); }}
329
+ className="px-3 py-2 text-xs rounded-lg border border-border hover:bg-muted transition-colors shrink-0"
330
+ style={{ color: 'var(--foreground)' }}
331
+ >
332
+ {s.generateToken}
333
+ </button>
334
+ </div>
335
+ )}
336
+ </div>
337
+
338
+ <Field label={s.webPassword} hint={s.webPasswordHint}>
339
+ <Input
340
+ type="password"
341
+ value={state.webPassword}
342
+ onChange={e => update('webPassword', e.target.value)}
343
+ placeholder="(optional)"
344
+ />
345
+ </Field>
346
+ </div>
347
+ );
348
+ };
349
+
350
+ // Step 5: Review
351
+ const Step5 = () => {
352
+ const rows: [string, string][] = [
353
+ [s.kbPath, state.mindRoot],
354
+ [s.template, state.template || '—'],
355
+ [s.aiProvider, state.provider],
356
+ ...(state.provider !== 'skip' ? [
357
+ [s.apiKey, maskKey(state.provider === 'anthropic' ? state.anthropicKey : state.openaiKey)] as [string, string],
358
+ [s.model, state.provider === 'anthropic' ? state.anthropicModel : state.openaiModel] as [string, string],
359
+ ] : []),
360
+ [s.webPort, String(state.webPort)],
361
+ [s.mcpPort, String(state.mcpPort)],
362
+ [s.authToken, state.authToken || '—'],
363
+ [s.webPassword, state.webPassword ? 'â€Ēâ€Ēâ€Ēâ€Ēâ€Ēâ€Ēâ€Ēâ€Ē' : '(none)'],
364
+ ];
365
+
366
+ return (
367
+ <div className="space-y-5">
368
+ <p className="text-sm" style={{ color: 'var(--muted-foreground)' }}>{s.reviewHint}</p>
369
+ <div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border)' }}>
370
+ {rows.map(([label, value], i) => (
371
+ <div
372
+ key={i}
373
+ className="flex items-center justify-between px-4 py-3 text-sm"
374
+ style={{
375
+ background: i % 2 === 0 ? 'var(--card)' : 'transparent',
376
+ borderTop: i > 0 ? '1px solid var(--border)' : undefined,
377
+ }}
378
+ >
379
+ <span style={{ color: 'var(--muted-foreground)' }}>{label}</span>
380
+ <span className="font-mono text-xs" style={{ color: 'var(--foreground)' }}>{value}</span>
381
+ </div>
382
+ ))}
383
+ </div>
384
+
385
+ {error && (
386
+ <div className="p-3 rounded-lg text-sm text-red-500" style={{ background: 'rgba(239,68,68,0.1)' }}>
387
+ {s.completeFailed}: {error}
388
+ </div>
389
+ )}
390
+
391
+ {portChanged && (
392
+ <div className="space-y-3">
393
+ <div className="p-3 rounded-lg text-sm flex items-center gap-2" style={{ background: 'rgba(200,135,30,0.1)', color: 'var(--amber)' }}>
394
+ <AlertTriangle size={14} />
395
+ {s.portChanged}
396
+ </div>
397
+ <a
398
+ href="/"
399
+ className="inline-flex items-center gap-1 px-4 py-2 text-sm rounded-lg transition-colors"
400
+ style={{ background: 'var(--amber)', color: 'white' }}
401
+ >
402
+ {s.completeDone} &rarr;
403
+ </a>
404
+ </div>
405
+ )}
406
+ </div>
407
+ );
408
+ };
409
+
410
+ const steps = [Step1, Step2, Step3, Step4, Step5];
411
+ const CurrentStep = steps[step];
412
+
413
+ return (
414
+ <div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto" style={{ background: 'var(--background)' }}>
415
+ <div className="w-full max-w-xl mx-auto px-6 py-12">
416
+ {/* Header */}
417
+ <div className="text-center mb-8">
418
+ <div className="inline-flex items-center gap-2 mb-2">
419
+ <Sparkles size={18} style={{ color: 'var(--amber)' }} />
420
+ <h1
421
+ className="text-2xl font-semibold tracking-tight"
422
+ style={{ fontFamily: "'IBM Plex Mono', monospace", color: 'var(--foreground)' }}
423
+ >
424
+ MindOS
425
+ </h1>
426
+ </div>
427
+ </div>
428
+
429
+ {/* Step dots */}
430
+ <div className="flex justify-center">
431
+ <StepDots />
432
+ </div>
433
+
434
+ {/* Step title */}
435
+ <h2 className="text-lg font-semibold mb-5" style={{ color: 'var(--foreground)' }}>
436
+ {s.stepTitles[step]}
437
+ </h2>
438
+
439
+ {/* Step content */}
440
+ <CurrentStep />
441
+
442
+ {/* Navigation */}
443
+ <div className="flex items-center justify-between mt-8 pt-6" style={{ borderTop: '1px solid var(--border)' }}>
444
+ <button
445
+ onClick={() => setStep(step - 1)}
446
+ disabled={step === 0}
447
+ className="flex items-center gap-1 px-4 py-2 text-sm rounded-lg border border-border hover:bg-muted transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
448
+ style={{ color: 'var(--foreground)' }}
449
+ >
450
+ <ChevronLeft size={14} />
451
+ {s.back}
452
+ </button>
453
+
454
+ {step < TOTAL_STEPS - 1 ? (
455
+ <button
456
+ onClick={() => setStep(step + 1)}
457
+ disabled={!canNext()}
458
+ className="flex items-center gap-1 px-4 py-2 text-sm rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
459
+ style={{ background: 'var(--amber)', color: 'white' }}
460
+ >
461
+ {s.next}
462
+ <ChevronRight size={14} />
463
+ </button>
464
+ ) : (
465
+ <button
466
+ onClick={handleComplete}
467
+ disabled={submitting || portChanged}
468
+ className="flex items-center gap-1 px-5 py-2 text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
469
+ style={{ background: 'var(--amber)', color: 'white' }}
470
+ >
471
+ {submitting && <Loader2 size={14} className="animate-spin" />}
472
+ {submitting ? s.completing : portChanged ? s.completeDone : s.complete}
473
+ </button>
474
+ )}
475
+ </div>
476
+ </div>
477
+ </div>
478
+ );
479
+ }
package/app/lib/i18n.ts CHANGED
@@ -186,6 +186,52 @@ export const messages = {
186
186
  { keys: ['Esc'], description: 'Cancel edit / close modal' },
187
187
  { keys: ['@'], description: 'Attach file in Ask AI' },
188
188
  ],
189
+ setup: {
190
+ stepTitles: ['Knowledge Base', 'AI Provider', 'Ports', 'Security', 'Review'],
191
+ // Step 1
192
+ kbPath: 'Knowledge base path',
193
+ kbPathHint: 'Absolute path to your notes directory.',
194
+ kbPathDefault: '~/MindOS',
195
+ template: 'Starter template',
196
+ templateSkip: 'Skip (directory already has files)',
197
+ // Step 2
198
+ aiProvider: 'AI Provider',
199
+ aiProviderHint: 'Choose your preferred AI service.',
200
+ aiSkip: 'Skip — configure later',
201
+ apiKey: 'API Key',
202
+ model: 'Model',
203
+ baseUrl: 'Base URL',
204
+ baseUrlHint: 'Optional. For proxies or OpenAI-compatible APIs.',
205
+ // Step 3
206
+ webPort: 'Web UI port',
207
+ mcpPort: 'MCP server port',
208
+ portHint: 'Valid range: 1024–65535',
209
+ portRestartWarning: 'Port changes take effect after server restart.',
210
+ // Step 4
211
+ authToken: 'Auth Token',
212
+ authTokenHint: 'Bearer token for MCP / API clients. Auto-generated.',
213
+ authTokenSeed: 'Custom seed (optional)',
214
+ authTokenSeedHint: 'Enter a passphrase to derive a deterministic token.',
215
+ generateToken: 'Generate',
216
+ copyToken: 'Copy',
217
+ copiedToken: 'Copied!',
218
+ webPassword: 'Web UI Password',
219
+ webPasswordHint: 'Optional. Protect browser access with a password.',
220
+ // Step 5
221
+ reviewTitle: 'Review Configuration',
222
+ reviewHint: 'Verify your settings before completing setup.',
223
+ keyMasked: (key: string) => key.slice(0, 6) + 'â€Ēâ€Ēâ€Ē' + key.slice(-3),
224
+ portChanged: 'Port changed — please restart the server for it to take effect.',
225
+ // Buttons
226
+ back: 'Back',
227
+ next: 'Next',
228
+ complete: 'Complete Setup',
229
+ skip: 'Skip',
230
+ // Status
231
+ completing: 'Saving...',
232
+ completeDone: 'Setup complete!',
233
+ completeFailed: 'Setup failed. Please try again.',
234
+ },
189
235
  },
190
236
  zh: {
191
237
  common: {
@@ -372,6 +418,52 @@ export const messages = {
372
418
  { keys: ['Esc'], description: '取æķˆįž–čū‘ / å…ģ闭åžđįŠ—' },
373
419
  { keys: ['@'], description: 'åœĻ AI åŊđčŊäļ­æ·ŧ加附äŧķ' },
374
420
  ],
421
+ setup: {
422
+ stepTitles: ['įŸĨčŊ†åš“', 'AI æœåŠĄå•†', 'įŦŊåĢ', 'åŪ‰å…Ļ', 'įĄŪčŪĪ'],
423
+ // Step 1
424
+ kbPath: 'įŸĨčŊ†åš“č·Ŋåū„',
425
+ kbPathHint: 'įŽ”čŪ°į›Ūå―•įš„įŧåŊđč·Ŋåū„。',
426
+ kbPathDefault: '~/MindOS',
427
+ template: '初始æĻĄæŋ',
428
+ templateSkip: 'č·ģčŋ‡ïžˆį›Ūå―•å·ē有文äŧķ',
429
+ // Step 2
430
+ aiProvider: 'AI æœåŠĄå•†',
431
+ aiProviderHint: '选æ‹Đä― ååĨ―įš„ AI æœåŠĄã€‚',
432
+ aiSkip: 'č·ģčŋ‡ — įĻåŽé…į―Ū',
433
+ apiKey: 'API åŊ†é’Ĩ',
434
+ model: 'æĻĄåž‹',
435
+ baseUrl: 'æŽĨåĢ地址',
436
+ baseUrlHint: 'åŊ选。į”Ļ䚎äŧĢį†æˆ– OpenAI å…žåŪđ API。',
437
+ // Step 3
438
+ webPort: 'Web UI įŦŊåĢ',
439
+ mcpPort: 'MCP æœåŠĄįŦŊåĢ',
440
+ portHint: 'æœ‰æ•ˆčŒƒå›īïžš1024–65535',
441
+ portRestartWarning: 'įŦŊåĢäŋŪæ”đ需重åŊæœåŠĄåŽį”Ÿæ•ˆã€‚',
442
+ // Step 4
443
+ authToken: 'Auth Token',
444
+ authTokenHint: 'MCP / API åŪĒæˆ·įŦŊä―ŋį”Ļįš„ Bearer TokenïžŒč‡ŠåŠĻį”Ÿæˆã€‚',
445
+ authTokenSeed: '臩åۚäđ‰į§å­ïžˆåŊ选',
446
+ authTokenSeedHint: 'čū“å…ĨåĢäŧĪ៭čŊ­į”ŸæˆįĄŪåŪšæ€§ Token。',
447
+ generateToken: 'į”Ÿæˆ',
448
+ copyToken: 'åĪåˆķ',
449
+ copiedToken: 'å·ēåĪåˆķ',
450
+ webPassword: 'į―‘éĄĩčŪŋé—ŪåŊ†į ',
451
+ webPasswordHint: 'åŊ选。čŪūį―Ū后æĩč§ˆå™ĻčŪŋé—Ū需č́į™ŧå―•ã€‚',
452
+ // Step 5
453
+ reviewTitle: 'įĄŪčŪĪ配į―Ū',
454
+ reviewHint: 'åŪŒæˆčŪūį―Ū前čŊ·įĄŪčŪĪäŧĨäļ‹äŋĄæŊ。',
455
+ keyMasked: (key: string) => key.slice(0, 6) + 'â€Ēâ€Ēâ€Ē' + key.slice(-3),
456
+ portChanged: 'įŦŊåĢå·ē变æ›ī — čŊ·é‡åŊæœåŠĄäŧĨä―ŋå…ķį”Ÿæ•ˆã€‚',
457
+ // Buttons
458
+ back: 'äļŠä­Ĩ',
459
+ next: 'äļ‹ä­Ĩ',
460
+ complete: 'åŪŒæˆčŪūį―Ū',
461
+ skip: 'č·ģčŋ‡',
462
+ // Status
463
+ completing: 'äŋå­˜äļ­...',
464
+ completeDone: 'čŪūį―ŪåŪŒæˆïž',
465
+ completeFailed: 'čŪūį―ŪåĪąčīĨčŊ·é‡čŊ•。',
466
+ },
375
467
  },
376
468
  } as const;
377
469
 
@@ -21,12 +21,12 @@ export interface AiConfig {
21
21
  export interface ServerSettings {
22
22
  ai: AiConfig;
23
23
  mindRoot: string; // empty = use env var / default
24
- // Fields managed by CLI only (not edited via GUI, preserved on write)
25
24
  port?: number;
26
25
  mcpPort?: number;
27
26
  authToken?: string;
28
27
  webPassword?: string;
29
28
  startMode?: 'dev' | 'start' | 'daemon';
29
+ setupPending?: boolean; // true → / redirects to /setup
30
30
  }
31
31
 
32
32
  const DEFAULTS: ServerSettings = {
@@ -108,6 +108,9 @@ export function readSettings(): ServerSettings {
108
108
  webPassword: typeof parsed.webPassword === 'string' ? parsed.webPassword : undefined,
109
109
  authToken: typeof parsed.authToken === 'string' ? parsed.authToken : undefined,
110
110
  mcpPort: typeof parsed.mcpPort === 'number' ? parsed.mcpPort : undefined,
111
+ port: typeof parsed.port === 'number' ? parsed.port : undefined,
112
+ startMode: typeof parsed.startMode === 'string' ? parsed.startMode as ServerSettings['startMode'] : undefined,
113
+ setupPending: parsed.setupPending === true ? true : undefined,
111
114
  };
112
115
  } catch {
113
116
  return { ...DEFAULTS, ai: { ...DEFAULTS.ai, providers: { ...DEFAULTS.ai.providers } } };
@@ -123,6 +126,14 @@ export function writeSettings(settings: ServerSettings): void {
123
126
  const merged: Record<string, unknown> = { ...existing, ai: settings.ai, mindRoot: settings.mindRoot };
124
127
  if (settings.webPassword !== undefined) merged.webPassword = settings.webPassword;
125
128
  if (settings.authToken !== undefined) merged.authToken = settings.authToken;
129
+ if (settings.port !== undefined) merged.port = settings.port;
130
+ if (settings.mcpPort !== undefined) merged.mcpPort = settings.mcpPort;
131
+ if (settings.startMode !== undefined) merged.startMode = settings.startMode;
132
+ // setupPending: false/undefined → remove the field (cleanup); true → set it
133
+ if ('setupPending' in settings) {
134
+ if (settings.setupPending) merged.setupPending = true;
135
+ else delete merged.setupPending;
136
+ }
126
137
  fs.writeFileSync(SETTINGS_PATH, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
127
138
  }
128
139
 
@@ -0,0 +1,45 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ /**
5
+ * Recursively copy `src` to `dest`, skipping files that already exist in dest.
6
+ */
7
+ export function copyRecursive(src: string, dest: string) {
8
+ const stat = fs.statSync(src);
9
+ if (stat.isDirectory()) {
10
+ if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
11
+ for (const entry of fs.readdirSync(src)) {
12
+ copyRecursive(path.join(src, entry), path.join(dest, entry));
13
+ }
14
+ } else {
15
+ // Skip if file already exists
16
+ if (fs.existsSync(dest)) return;
17
+ const dir = path.dirname(dest);
18
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
19
+ fs.copyFileSync(src, dest);
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Apply a built-in template (en / zh / empty) to the given directory.
25
+ * Returns true on success, throws on error.
26
+ */
27
+ export function applyTemplate(template: string, destDir: string): void {
28
+ if (!['en', 'zh', 'empty'].includes(template)) {
29
+ throw new Error(`Invalid template: ${template}`);
30
+ }
31
+
32
+ // templates/ is at the repo root (sibling of app/)
33
+ const repoRoot = path.resolve(process.cwd(), '..');
34
+ const templateDir = path.join(repoRoot, 'templates', template);
35
+
36
+ if (!fs.existsSync(templateDir)) {
37
+ throw new Error(`Template "${template}" not found at ${templateDir}`);
38
+ }
39
+
40
+ if (!fs.existsSync(destDir)) {
41
+ fs.mkdirSync(destDir, { recursive: true });
42
+ }
43
+
44
+ copyRecursive(templateDir, destDir);
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geminilight/mindos",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "MindOS — Human-Agent Collaborative Mind System. Local-first knowledge base that syncs your mind to all AI Agents via MCP.",
5
5
  "keywords": [
6
6
  "mindos",
package/scripts/setup.js CHANGED
@@ -26,7 +26,7 @@ import { homedir, tmpdir, networkInterfaces } from 'node:os';
26
26
  import { fileURLToPath } from 'node:url';
27
27
  import { createInterface } from 'node:readline';
28
28
  import { pipeline } from 'node:stream/promises';
29
- import { execSync } from 'node:child_process';
29
+ import { execSync, spawn } from 'node:child_process';
30
30
  import { randomBytes, createHash } from 'node:crypto';
31
31
  import { createConnection } from 'node:net';
32
32
 
@@ -41,6 +41,14 @@ const T = {
41
41
  title: { en: '🧠 MindOS Setup', zh: '🧠 MindOS 初始化' },
42
42
  langHint: { en: ' ← → switch language / 切æĒčŊ­čĻ€ ↑ ↓ navigate Enter confirm', zh: ' ← → switch language / 切æĒčŊ­čĻ€ ↑ ↓ äļŠäļ‹åˆ‡æĒ Enter įĄŪčŪĪ' },
43
43
 
44
+ // mode selection
45
+ modePrompt: { en: 'Setup mode', zh: '配į―Ūæ–đ垏' },
46
+ modeOpts: { en: ['CLI — terminal wizard', 'GUI — browser wizard (recommended)'], zh: ['CLI — įŧˆįŦŊ向åŊž', 'GUI — æĩč§ˆå™Ļ向åŊžïžˆæŽĻčïž‰'] },
47
+ modeVals: ['cli', 'gui'],
48
+ guiStarting: { en: 'âģ Starting server for GUI setup...', zh: 'âģ æ­ĢåœĻåŊåŠĻæœåŠĄ...' },
49
+ guiReady: { en: (url) => `🌐 Complete setup in browser: ${url}`, zh: (url) => `🌐 åœĻæĩč§ˆå™Ļäļ­åŪŒæˆé…į―Ū: ${url}` },
50
+ guiOpenFailed: { en: (url) => ` Could not open browser automatically. Open this URL manually:\n ${url}`, zh: (url) => ` 无æģ•臊åŠĻ打垀æĩč§ˆå™ĻčŊ·æ‰‹åŠĻčŪŋé—Ūïžš\n ${url}` },
51
+
44
52
  // step labels
45
53
  step: { en: (n, total) => `Step ${n}/${total}`, zh: (n, total) => `æ­ĨéŠĪ ${n}/${total}` },
46
54
  stepTitles: {
@@ -493,11 +501,95 @@ async function applyTemplate(tpl, mindDir) {
493
501
  }
494
502
  }
495
503
 
504
+ // ── GUI Setup ─────────────────────────────────────────────────────────────────
505
+
506
+ function openBrowser(url) {
507
+ try {
508
+ const platform = process.platform;
509
+ if (platform === 'darwin') {
510
+ execSync(`open "${url}"`, { stdio: 'ignore' });
511
+ } else if (platform === 'linux') {
512
+ // Check for WSL
513
+ const isWSL = existsSync('/proc/version') &&
514
+ readFileSync('/proc/version', 'utf-8').toLowerCase().includes('microsoft');
515
+ if (isWSL) {
516
+ execSync(`cmd.exe /c start "${url}"`, { stdio: 'ignore' });
517
+ } else {
518
+ execSync(`xdg-open "${url}"`, { stdio: 'ignore' });
519
+ }
520
+ } else {
521
+ execSync(`cmd.exe /c start "${url}"`, { stdio: 'ignore' });
522
+ }
523
+ return true;
524
+ } catch {
525
+ return false;
526
+ }
527
+ }
528
+
529
+ async function startGuiSetup() {
530
+ // Ensure ~/.mindos directory exists
531
+ mkdirSync(MINDOS_DIR, { recursive: true });
532
+
533
+ // Read or create config, set setupPending
534
+ let config = {};
535
+ try { config = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8')); } catch { /* ignore */ }
536
+ config.setupPending = true;
537
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
538
+
539
+ // Find a free port
540
+ const port = await findFreePort(3000);
541
+ if (config.port === undefined) {
542
+ config.port = port;
543
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
544
+ }
545
+ const usePort = config.port || port;
546
+
547
+ write(c.yellow(t('guiStarting') + '\n'));
548
+
549
+ // Start the server in the background
550
+ const cliPath = resolve(__dirname, '../bin/cli.js');
551
+ const child = spawn(process.execPath, [cliPath, 'start'], {
552
+ detached: true,
553
+ stdio: 'ignore',
554
+ env: { ...process.env, PORT: String(usePort) },
555
+ });
556
+ child.unref();
557
+
558
+ // Wait for the server to be ready
559
+ const { waitForHttp } = await import('../bin/lib/gateway.js');
560
+ const ready = await waitForHttp(usePort, { retries: 60, intervalMs: 1000, label: 'MindOS' });
561
+
562
+ if (!ready) {
563
+ write(c.red('\n✘ Server failed to start.\n'));
564
+ process.exit(1);
565
+ }
566
+
567
+ const url = `http://localhost:${usePort}/setup`;
568
+ console.log(`\n${c.green(tf('guiReady', url))}\n`);
569
+
570
+ const opened = openBrowser(url);
571
+ if (!opened) {
572
+ console.log(c.dim(tf('guiOpenFailed', url)));
573
+ }
574
+
575
+ process.exit(0);
576
+ }
577
+
496
578
  // ── Main ──────────────────────────────────────────────────────────────────────
497
579
 
498
580
  async function main() {
499
581
  console.log(`\n${c.bold(t('title'))}\n\n${c.dim(t('langHint'))}\n`);
500
582
 
583
+ // ── Mode selection: CLI or GUI ───────────────────────────────────────────
584
+ const mode = await select('modePrompt', 'modeOpts', 'modeVals');
585
+
586
+ if (mode === 'gui') {
587
+ await startGuiSetup();
588
+ return;
589
+ }
590
+
591
+ // ── CLI mode continues below ─────────────────────────────────────────────
592
+
501
593
  // ── Early overwrite check ─────────────────────────────────────────────────
502
594
  if (existsSync(CONFIG_PATH)) {
503
595
  let existing = {};