@geminilight/mindos 0.5.18 → 0.5.20

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 (38) hide show
  1. package/app/app/api/ask/route.ts +5 -4
  2. package/app/app/api/file/route.ts +35 -11
  3. package/app/app/api/setup/route.ts +64 -1
  4. package/app/app/api/skills/route.ts +22 -3
  5. package/app/app/globals.css +1 -0
  6. package/app/components/AskFab.tsx +49 -3
  7. package/app/components/AskModal.tsx +11 -2
  8. package/app/components/GuideCard.tsx +361 -0
  9. package/app/components/HomeContent.tsx +2 -2
  10. package/app/components/Sidebar.tsx +21 -1
  11. package/app/components/ask/ToolCallBlock.tsx +2 -1
  12. package/app/components/settings/KnowledgeTab.tsx +64 -2
  13. package/app/components/settings/McpTab.tsx +286 -56
  14. package/app/components/setup/StepAI.tsx +9 -1
  15. package/app/components/setup/index.tsx +4 -0
  16. package/app/components/setup/types.ts +2 -0
  17. package/app/hooks/useAskModal.ts +46 -0
  18. package/app/lib/agent/stream-consumer.ts +4 -2
  19. package/app/lib/agent/tools.ts +26 -12
  20. package/app/lib/fs.ts +9 -1
  21. package/app/lib/i18n.ts +16 -0
  22. package/app/lib/settings.ts +29 -0
  23. package/app/next-env.d.ts +1 -1
  24. package/app/next.config.ts +7 -0
  25. package/bin/cli.js +135 -9
  26. package/bin/lib/build.js +2 -7
  27. package/bin/lib/mcp-spawn.js +2 -13
  28. package/bin/lib/utils.js +23 -0
  29. package/package.json +1 -1
  30. package/scripts/setup.js +13 -0
  31. package/skills/mindos/SKILL.md +10 -168
  32. package/skills/mindos-zh/SKILL.md +14 -172
  33. package/skills/project-wiki/SKILL.md +80 -74
  34. package/skills/project-wiki/references/file-reference.md +6 -2
  35. package/templates/skill-rules/en/skill-rules.md +222 -0
  36. package/templates/skill-rules/en/user-rules.md +20 -0
  37. package/templates/skill-rules/zh/skill-rules.md +222 -0
  38. package/templates/skill-rules/zh/user-rules.md +20 -0
@@ -57,11 +57,12 @@ function convertToModelMessages(messages: FrontendMessage[]): ModelMessage[] {
57
57
  type: 'tool-call',
58
58
  toolCallId: part.toolCallId,
59
59
  toolName: part.toolName,
60
- input: part.input,
60
+ input: part.input ?? {},
61
61
  });
62
- if (part.state === 'done' || part.state === 'error') {
63
- completedToolCalls.push(part);
64
- }
62
+ // Always emit a tool result for every tool call. Orphaned tool calls
63
+ // (running/pending from interrupted streams) get an empty result;
64
+ // without one the API rejects the request.
65
+ completedToolCalls.push(part);
65
66
  }
66
67
  // 'reasoning' parts are display-only; not sent back to model
67
68
  }
@@ -1,5 +1,6 @@
1
1
  export const dynamic = 'force-dynamic';
2
2
  import { NextRequest, NextResponse } from 'next/server';
3
+ import { revalidatePath } from 'next/cache';
3
4
  import {
4
5
  getFileContent,
5
6
  saveFileContent,
@@ -37,6 +38,9 @@ export async function GET(req: NextRequest) {
37
38
  }
38
39
  }
39
40
 
41
+ // Ops that change file tree structure (sidebar needs refresh)
42
+ const TREE_CHANGING_OPS = new Set(['create_file', 'delete_file', 'rename_file', 'move_file']);
43
+
40
44
  // POST /api/file body: { op, path, ...params }
41
45
  export async function POST(req: NextRequest) {
42
46
  let body: Record<string, unknown>;
@@ -47,20 +51,24 @@ export async function POST(req: NextRequest) {
47
51
  if (!filePath || typeof filePath !== 'string') return err('missing path');
48
52
 
49
53
  try {
54
+ let resp: NextResponse;
55
+
50
56
  switch (op) {
51
57
 
52
58
  case 'save_file': {
53
59
  const { content } = params as { content: string };
54
60
  if (typeof content !== 'string') return err('missing content');
55
61
  saveFileContent(filePath, content);
56
- return NextResponse.json({ ok: true });
62
+ resp = NextResponse.json({ ok: true });
63
+ break;
57
64
  }
58
65
 
59
66
  case 'append_to_file': {
60
67
  const { content } = params as { content: string };
61
68
  if (typeof content !== 'string') return err('missing content');
62
69
  appendToFile(filePath, content);
63
- return NextResponse.json({ ok: true });
70
+ resp = NextResponse.json({ ok: true });
71
+ break;
64
72
  }
65
73
 
66
74
  case 'insert_lines': {
@@ -68,7 +76,8 @@ export async function POST(req: NextRequest) {
68
76
  if (typeof after_index !== 'number') return err('missing after_index');
69
77
  if (!Array.isArray(lines)) return err('lines must be array');
70
78
  insertLines(filePath, after_index, lines);
71
- return NextResponse.json({ ok: true });
79
+ resp = NextResponse.json({ ok: true });
80
+ break;
72
81
  }
73
82
 
74
83
  case 'update_lines': {
@@ -78,7 +87,8 @@ export async function POST(req: NextRequest) {
78
87
  if (start < 0 || end < 0) return err('start/end must be >= 0');
79
88
  if (start > end) return err('start must be <= end');
80
89
  updateLines(filePath, start, end, lines);
81
- return NextResponse.json({ ok: true });
90
+ resp = NextResponse.json({ ok: true });
91
+ break;
82
92
  }
83
93
 
84
94
  case 'insert_after_heading': {
@@ -86,7 +96,8 @@ export async function POST(req: NextRequest) {
86
96
  if (typeof heading !== 'string') return err('missing heading');
87
97
  if (typeof content !== 'string') return err('missing content');
88
98
  insertAfterHeading(filePath, heading, content);
89
- return NextResponse.json({ ok: true });
99
+ resp = NextResponse.json({ ok: true });
100
+ break;
90
101
  }
91
102
 
92
103
  case 'update_section': {
@@ -94,44 +105,57 @@ export async function POST(req: NextRequest) {
94
105
  if (typeof heading !== 'string') return err('missing heading');
95
106
  if (typeof content !== 'string') return err('missing content');
96
107
  updateSection(filePath, heading, content);
97
- return NextResponse.json({ ok: true });
108
+ resp = NextResponse.json({ ok: true });
109
+ break;
98
110
  }
99
111
 
100
112
  case 'delete_file': {
101
113
  deleteFile(filePath);
102
- return NextResponse.json({ ok: true });
114
+ resp = NextResponse.json({ ok: true });
115
+ break;
103
116
  }
104
117
 
105
118
  case 'rename_file': {
106
119
  const { new_name } = params as { new_name: string };
107
120
  if (typeof new_name !== 'string' || !new_name) return err('missing new_name');
108
121
  const newPath = renameFile(filePath, new_name);
109
- return NextResponse.json({ ok: true, newPath });
122
+ resp = NextResponse.json({ ok: true, newPath });
123
+ break;
110
124
  }
111
125
 
112
126
  case 'create_file': {
113
127
  const { content } = params as { content?: string };
114
128
  createFile(filePath, typeof content === 'string' ? content : '');
115
- return NextResponse.json({ ok: true });
129
+ resp = NextResponse.json({ ok: true });
130
+ break;
116
131
  }
117
132
 
118
133
  case 'move_file': {
119
134
  const { to_path } = params as { to_path: string };
120
135
  if (typeof to_path !== 'string' || !to_path) return err('missing to_path');
121
136
  const result = moveFile(filePath, to_path);
122
- return NextResponse.json({ ok: true, ...result });
137
+ resp = NextResponse.json({ ok: true, ...result });
138
+ break;
123
139
  }
124
140
 
125
141
  case 'append_csv': {
126
142
  const { row } = params as { row: string[] };
127
143
  if (!Array.isArray(row) || row.length === 0) return err('row must be non-empty array');
128
144
  const result = appendCsvRow(filePath, row);
129
- return NextResponse.json({ ok: true, ...result });
145
+ resp = NextResponse.json({ ok: true, ...result });
146
+ break;
130
147
  }
131
148
 
132
149
  default:
133
150
  return err(`unknown op: ${op}`);
134
151
  }
152
+
153
+ // Invalidate Next.js router cache so sidebar file tree updates
154
+ if (TREE_CHANGING_OPS.has(op)) {
155
+ try { revalidatePath('/', 'layout'); } catch { /* noop in test env */ }
156
+ }
157
+
158
+ return resp;
135
159
  } catch (e) {
136
160
  return err((e as Error).message, 500);
137
161
  }
@@ -30,6 +30,7 @@ export async function GET() {
30
30
  openaiApiKey: maskApiKey(s.ai.providers.openai.apiKey),
31
31
  openaiModel: s.ai.providers.openai.model,
32
32
  openaiBaseUrl: s.ai.providers.openai.baseUrl ?? '',
33
+ guideState: s.guideState ?? null,
33
34
  });
34
35
  } catch (e) {
35
36
  return NextResponse.json(
@@ -95,9 +96,33 @@ export async function POST(req: NextRequest) {
95
96
  );
96
97
 
97
98
  // Build config
99
+ // Merge AI config: empty apiKey means "keep existing" — never overwrite a
100
+ // configured key with blank just because the user didn't re-enter it.
101
+ let mergedAi = current.ai;
102
+ if (ai) {
103
+ const inAnthropicKey = ai.providers?.anthropic?.apiKey;
104
+ const inOpenaiKey = ai.providers?.openai?.apiKey;
105
+ mergedAi = {
106
+ provider: ai.provider ?? current.ai.provider,
107
+ providers: {
108
+ anthropic: {
109
+ apiKey: inAnthropicKey || current.ai.providers.anthropic.apiKey,
110
+ model: ai.providers?.anthropic?.model || current.ai.providers.anthropic.model,
111
+ },
112
+ openai: {
113
+ apiKey: inOpenaiKey || current.ai.providers.openai.apiKey,
114
+ model: ai.providers?.openai?.model || current.ai.providers.openai.model,
115
+ baseUrl: ai.providers?.openai?.baseUrl ?? current.ai.providers.openai.baseUrl ?? '',
116
+ },
117
+ },
118
+ };
119
+ }
120
+
98
121
  const disabledSkills = body.template === 'zh' ? ['mindos'] : ['mindos-zh'];
122
+ // Determine guide template from setup template
123
+ const guideTemplate = body.template === 'zh' ? 'zh' : body.template === 'empty' ? 'empty' : 'en';
99
124
  const config: ServerSettings = {
100
- ai: ai ?? current.ai,
125
+ ai: mergedAi,
101
126
  mindRoot: resolvedRoot,
102
127
  port: webPort,
103
128
  mcpPort: mcpPortNum,
@@ -106,6 +131,14 @@ export async function POST(req: NextRequest) {
106
131
  startMode: current.startMode,
107
132
  setupPending: false, // clear the flag
108
133
  disabledSkills,
134
+ guideState: {
135
+ active: true,
136
+ dismissed: false,
137
+ template: guideTemplate as 'en' | 'zh' | 'empty',
138
+ step1Done: false,
139
+ askedAI: false,
140
+ nextStepIndex: 0,
141
+ },
109
142
  };
110
143
 
111
144
  writeSettings(config);
@@ -124,3 +157,33 @@ export async function POST(req: NextRequest) {
124
157
  );
125
158
  }
126
159
  }
160
+
161
+ export async function PATCH(req: NextRequest) {
162
+ try {
163
+ const body = await req.json();
164
+ const { guideState: patch } = body;
165
+ if (!patch || typeof patch !== 'object') {
166
+ return NextResponse.json({ error: 'guideState object required' }, { status: 400 });
167
+ }
168
+ const current = readSettings();
169
+ const existing = current.guideState ?? {
170
+ active: false, dismissed: false, template: 'en' as const,
171
+ step1Done: false, askedAI: false, nextStepIndex: 0,
172
+ };
173
+ // Merge only known fields
174
+ const updated = { ...existing };
175
+ if (typeof patch.dismissed === 'boolean') updated.dismissed = patch.dismissed;
176
+ if (typeof patch.step1Done === 'boolean') updated.step1Done = patch.step1Done;
177
+ if (typeof patch.askedAI === 'boolean') updated.askedAI = patch.askedAI;
178
+ if (typeof patch.nextStepIndex === 'number' && patch.nextStepIndex >= 0) updated.nextStepIndex = patch.nextStepIndex;
179
+ if (typeof patch.active === 'boolean') updated.active = patch.active;
180
+
181
+ writeSettings({ ...current, guideState: updated });
182
+ return NextResponse.json({ ok: true, guideState: updated });
183
+ } catch (e) {
184
+ return NextResponse.json(
185
+ { error: e instanceof Error ? e.message : String(e) },
186
+ { status: 500 },
187
+ );
188
+ }
189
+ }
@@ -129,7 +129,7 @@ export async function POST(req: NextRequest) {
129
129
  try {
130
130
  const body = await req.json();
131
131
  const { action, name, description, content, enabled } = body as {
132
- action: 'create' | 'update' | 'delete' | 'toggle';
132
+ action: 'create' | 'update' | 'delete' | 'toggle' | 'read';
133
133
  name?: string;
134
134
  description?: string;
135
135
  content?: string;
@@ -172,8 +172,11 @@ export async function POST(req: NextRequest) {
172
172
  return NextResponse.json({ error: 'A skill with this name already exists' }, { status: 409 });
173
173
  }
174
174
  fs.mkdirSync(skillDir, { recursive: true });
175
- const frontmatter = `---\nname: ${name}\ndescription: ${description || name}\n---\n\n${content || ''}`;
176
- fs.writeFileSync(path.join(skillDir, 'SKILL.md'), frontmatter, 'utf-8');
175
+ // If content already has frontmatter, use it as-is; otherwise build frontmatter
176
+ const fileContent = content && content.trimStart().startsWith('---')
177
+ ? content
178
+ : `---\nname: ${name}\ndescription: ${description || name}\n---\n\n${content || ''}`;
179
+ fs.writeFileSync(path.join(skillDir, 'SKILL.md'), fileContent, 'utf-8');
177
180
  return NextResponse.json({ ok: true });
178
181
  }
179
182
 
@@ -199,6 +202,22 @@ export async function POST(req: NextRequest) {
199
202
  return NextResponse.json({ ok: true });
200
203
  }
201
204
 
205
+ case 'read': {
206
+ if (!name) return NextResponse.json({ error: 'name required' }, { status: 400 });
207
+ const dirs = [
208
+ path.join(PROJECT_ROOT, 'app', 'data', 'skills', name),
209
+ path.join(PROJECT_ROOT, 'skills', name),
210
+ path.join(userSkillsDir, name),
211
+ ];
212
+ for (const dir of dirs) {
213
+ const file = path.join(dir, 'SKILL.md');
214
+ if (fs.existsSync(file)) {
215
+ return NextResponse.json({ content: fs.readFileSync(file, 'utf-8') });
216
+ }
217
+ }
218
+ return NextResponse.json({ error: 'Skill not found' }, { status: 404 });
219
+ }
220
+
202
221
  default:
203
222
  return NextResponse.json({ error: `Unknown action: ${action}` }, { status: 400 });
204
223
  }
@@ -288,6 +288,7 @@ body {
288
288
  /* Micro type scale: text-2xs = 10px (between nothing and text-xs 12px) */
289
289
  @layer utilities {
290
290
  .text-2xs { font-size: 10px; line-height: 1.4; }
291
+ .animate-spin-slow { animation: spin 3s linear infinite; }
291
292
  }
292
293
 
293
294
  /* Hide scrollbar but keep scroll functionality */
@@ -1,9 +1,10 @@
1
1
  'use client';
2
2
 
3
- import { useState } from 'react';
3
+ import { useState, useEffect, useCallback } from 'react';
4
4
  import { usePathname } from 'next/navigation';
5
5
  import { Sparkles } from 'lucide-react';
6
6
  import AskModal from './AskModal';
7
+ import { useAskModal } from '@/hooks/useAskModal';
7
8
 
8
9
  export default function AskFab() {
9
10
  const [open, setOpen] = useState(false);
@@ -12,10 +13,49 @@ export default function AskFab() {
12
13
  ? pathname.slice('/view/'.length).split('/').map(decodeURIComponent).join('/')
13
14
  : undefined;
14
15
 
16
+ // Listen to useAskModal store for cross-component open requests (e.g. from GuideCard)
17
+ const askModal = useAskModal();
18
+ const [initialMessage, setInitialMessage] = useState('');
19
+ const [openSource, setOpenSource] = useState<'user' | 'guide' | 'guide-next'>('user');
20
+
21
+ useEffect(() => {
22
+ if (askModal.open) {
23
+ setInitialMessage(askModal.initialMessage);
24
+ setOpenSource(askModal.source);
25
+ setOpen(true);
26
+ askModal.close(); // Reset store state after consuming
27
+ }
28
+ }, [askModal.open, askModal.initialMessage, askModal.source, askModal.close]);
29
+
30
+ const handleClose = useCallback(() => {
31
+ setOpen(false);
32
+ setInitialMessage('');
33
+ setOpenSource('user');
34
+ }, []);
35
+
36
+ // Dispatch correct PATCH based on how the modal was opened
37
+ const handleFirstMessage = useCallback(() => {
38
+ const notifyGuide = () => window.dispatchEvent(new Event('guide-state-updated'));
39
+
40
+ if (openSource === 'guide') {
41
+ // Task ② completion: mark askedAI
42
+ fetch('/api/setup', {
43
+ method: 'PATCH',
44
+ headers: { 'Content-Type': 'application/json' },
45
+ body: JSON.stringify({ guideState: { askedAI: true } }),
46
+ }).then(notifyGuide).catch(() => {});
47
+ } else if (openSource === 'guide-next') {
48
+ // Next-step advancement: GuideCard already PATCHed nextStepIndex optimistically.
49
+ // Just notify GuideCard to re-fetch for consistency; no additional PATCH needed.
50
+ notifyGuide();
51
+ }
52
+ // For 'user' source: no guide action needed
53
+ }, [openSource]);
54
+
15
55
  return (
16
56
  <>
17
57
  <button
18
- onClick={() => setOpen(true)}
58
+ onClick={() => { setInitialMessage(''); setOpenSource('user'); setOpen(true); }}
19
59
  className="
20
60
  group
21
61
  fixed z-40
@@ -53,7 +93,13 @@ export default function AskFab() {
53
93
  </span>
54
94
  </button>
55
95
 
56
- <AskModal open={open} onClose={() => setOpen(false)} currentFile={currentFile} />
96
+ <AskModal
97
+ open={open}
98
+ onClose={handleClose}
99
+ currentFile={currentFile}
100
+ initialMessage={initialMessage}
101
+ onFirstMessage={handleFirstMessage}
102
+ />
57
103
  </>
58
104
  );
59
105
  }
@@ -17,11 +17,14 @@ interface AskModalProps {
17
17
  open: boolean;
18
18
  onClose: () => void;
19
19
  currentFile?: string;
20
+ initialMessage?: string;
21
+ onFirstMessage?: () => void;
20
22
  }
21
23
 
22
- export default function AskModal({ open, onClose, currentFile }: AskModalProps) {
24
+ export default function AskModal({ open, onClose, currentFile, initialMessage, onFirstMessage }: AskModalProps) {
23
25
  const inputRef = useRef<HTMLInputElement>(null);
24
26
  const abortRef = useRef<AbortController | null>(null);
27
+ const firstMessageFired = useRef(false);
25
28
  const { t } = useLocale();
26
29
 
27
30
  const [input, setInput] = useState('');
@@ -43,7 +46,8 @@ export default function AskModal({ open, onClose, currentFile }: AskModalProps)
43
46
  if (cancelled) return;
44
47
  await session.initSessions();
45
48
  })();
46
- setInput('');
49
+ setInput(initialMessage || '');
50
+ firstMessageFired.current = false;
47
51
  setAttachedFiles(currentFile ? [currentFile] : []);
48
52
  upload.clearAttachments();
49
53
  mention.resetMention();
@@ -119,6 +123,11 @@ export default function AskModal({ open, onClose, currentFile }: AskModalProps)
119
123
  const requestMessages = [...session.messages, userMsg];
120
124
  session.setMessages([...requestMessages, { role: 'assistant', content: '' }]);
121
125
  setInput('');
126
+ // Notify guide card on first user message (ref prevents duplicate fires during re-render)
127
+ if (onFirstMessage && !firstMessageFired.current) {
128
+ firstMessageFired.current = true;
129
+ onFirstMessage();
130
+ }
122
131
  setAttachedFiles(currentFile ? [currentFile] : []);
123
132
  setIsLoading(true);
124
133
  setLoadingPhase('connecting');