@geminilight/mindos 0.6.7 → 0.6.12

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 (85) hide show
  1. package/README.md +2 -0
  2. package/README_zh.md +2 -0
  3. package/app/app/api/ask/route.ts +35 -2
  4. package/app/app/api/file/route.ts +27 -0
  5. package/app/app/api/mcp/install/route.ts +4 -1
  6. package/app/app/api/setup/check-path/route.ts +2 -7
  7. package/app/app/api/setup/check-port/route.ts +18 -13
  8. package/app/app/api/setup/ls/route.ts +3 -9
  9. package/app/app/api/setup/path-utils.ts +8 -0
  10. package/app/app/api/setup/route.ts +2 -7
  11. package/app/app/api/uninstall/route.ts +47 -0
  12. package/app/app/globals.css +11 -0
  13. package/app/components/ActivityBar.tsx +10 -3
  14. package/app/components/AskFab.tsx +7 -3
  15. package/app/components/CreateSpaceModal.tsx +1 -1
  16. package/app/components/DirView.tsx +1 -1
  17. package/app/components/FileTree.tsx +30 -23
  18. package/app/components/GuideCard.tsx +1 -1
  19. package/app/components/HomeContent.tsx +137 -109
  20. package/app/components/ImportModal.tsx +104 -60
  21. package/app/components/MarkdownView.tsx +3 -0
  22. package/app/components/OnboardingView.tsx +1 -1
  23. package/app/components/OrganizeToast.tsx +386 -0
  24. package/app/components/Panel.tsx +23 -2
  25. package/app/components/Sidebar.tsx +1 -1
  26. package/app/components/SidebarLayout.tsx +44 -1
  27. package/app/components/agents/AgentDetailContent.tsx +33 -12
  28. package/app/components/agents/AgentsMcpSection.tsx +1 -1
  29. package/app/components/agents/AgentsOverviewSection.tsx +3 -4
  30. package/app/components/agents/AgentsPrimitives.tsx +2 -2
  31. package/app/components/agents/AgentsSkillsSection.tsx +2 -2
  32. package/app/components/agents/SkillDetailPopover.tsx +24 -8
  33. package/app/components/ask/AskContent.tsx +124 -70
  34. package/app/components/ask/HighlightMatch.tsx +14 -0
  35. package/app/components/ask/MentionPopover.tsx +5 -3
  36. package/app/components/ask/MessageList.tsx +39 -11
  37. package/app/components/ask/SlashCommandPopover.tsx +4 -2
  38. package/app/components/changes/ChangesBanner.tsx +20 -2
  39. package/app/components/changes/ChangesContentPage.tsx +10 -2
  40. package/app/components/echo/EchoHero.tsx +1 -1
  41. package/app/components/echo/EchoInsightCollapsible.tsx +1 -1
  42. package/app/components/echo/EchoPageSections.tsx +1 -1
  43. package/app/components/explore/UseCaseCard.tsx +1 -1
  44. package/app/components/panels/DiscoverPanel.tsx +29 -25
  45. package/app/components/panels/ImportHistoryPanel.tsx +195 -0
  46. package/app/components/panels/PluginsPanel.tsx +2 -2
  47. package/app/components/settings/AiTab.tsx +24 -0
  48. package/app/components/settings/KnowledgeTab.tsx +1 -1
  49. package/app/components/settings/McpSkillCreateForm.tsx +1 -1
  50. package/app/components/settings/McpSkillRow.tsx +1 -1
  51. package/app/components/settings/McpSkillsSection.tsx +2 -2
  52. package/app/components/settings/McpTab.tsx +2 -2
  53. package/app/components/settings/PluginsTab.tsx +1 -1
  54. package/app/components/settings/Primitives.tsx +118 -6
  55. package/app/components/settings/SettingsContent.tsx +5 -2
  56. package/app/components/settings/UninstallTab.tsx +179 -0
  57. package/app/components/settings/UpdateTab.tsx +17 -5
  58. package/app/components/settings/types.ts +2 -1
  59. package/app/components/ui/dialog.tsx +1 -1
  60. package/app/hooks/useAiOrganize.ts +450 -0
  61. package/app/hooks/useFileImport.ts +39 -2
  62. package/app/hooks/useMention.ts +21 -3
  63. package/app/hooks/useSlashCommand.ts +18 -4
  64. package/app/lib/agent/reconnect.ts +40 -0
  65. package/app/lib/core/backlinks.ts +2 -2
  66. package/app/lib/core/git.ts +14 -10
  67. package/app/lib/fs.ts +2 -1
  68. package/app/lib/i18n-en.ts +85 -4
  69. package/app/lib/i18n-zh.ts +85 -4
  70. package/app/lib/organize-history.ts +74 -0
  71. package/app/lib/settings.ts +2 -0
  72. package/app/lib/types.ts +2 -0
  73. package/app/next-env.d.ts +1 -1
  74. package/app/next.config.ts +23 -5
  75. package/app/package.json +1 -1
  76. package/bin/cli.js +21 -18
  77. package/bin/lib/mcp-build.js +74 -0
  78. package/bin/lib/mcp-spawn.js +8 -5
  79. package/bin/lib/port.js +17 -2
  80. package/bin/lib/stop.js +12 -2
  81. package/mcp/dist/index.cjs +43 -43
  82. package/mcp/src/index.ts +58 -12
  83. package/package.json +1 -1
  84. package/scripts/release.sh +1 -1
  85. package/scripts/setup.js +2 -2
@@ -0,0 +1,450 @@
1
+ 'use client';
2
+
3
+ import { useState, useCallback, useRef } from 'react';
4
+ import type { LocalAttachment, Message } from '@/lib/types';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Types
8
+ // ---------------------------------------------------------------------------
9
+
10
+ export type OrganizePhase = 'idle' | 'organizing' | 'done' | 'error';
11
+
12
+ export interface OrganizeFileChange {
13
+ action: 'create' | 'update' | 'unknown';
14
+ path: string;
15
+ toolCallId: string;
16
+ /** Whether the tool call completed successfully */
17
+ ok: boolean;
18
+ /** Marked true after user undoes this change */
19
+ undone?: boolean;
20
+ }
21
+
22
+ /** In-memory file snapshots captured before AI writes, keyed by path */
23
+ export type FileSnapshots = Map<string, string>;
24
+
25
+ /** User-facing stage hint derived from SSE events */
26
+ export type OrganizeStageHint =
27
+ | 'connecting'
28
+ | 'analyzing'
29
+ | 'reading'
30
+ | 'thinking'
31
+ | 'writing';
32
+
33
+ export interface AiOrganizeState {
34
+ phase: OrganizePhase;
35
+ changes: OrganizeFileChange[];
36
+ /** Current tool being executed (for live progress display) */
37
+ currentTool: { name: string; path: string } | null;
38
+ /** User-facing stage hint with optional context (e.g. file being read) */
39
+ stageHint: { stage: OrganizeStageHint; detail?: string } | null;
40
+ /** AI's text summary of what it did */
41
+ summary: string;
42
+ error: string | null;
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // SSE stream parser — extracts file operations from /api/ask stream
47
+ // ---------------------------------------------------------------------------
48
+
49
+ /**
50
+ * Strip model chain-of-thought tags that should never be shown to users.
51
+ * Covers <thinking>, <reasoning>, <scratchpad> per wiki/80-known-pitfalls.md.
52
+ * Handles both complete blocks and unclosed trailing tags (streaming).
53
+ */
54
+ export function stripThinkingTags(text: string): string {
55
+ let cleaned = text.replace(/<(thinking|reasoning|scratchpad)>[\s\S]*?<\/\1>/gi, '');
56
+ cleaned = cleaned.replace(/<(?:thinking|reasoning|scratchpad)>[\s\S]*$/gi, '');
57
+ return cleaned.trim();
58
+ }
59
+
60
+ export const CLIENT_TRUNCATE_CHARS = 20_000;
61
+
62
+ const FILE_WRITE_TOOLS = new Set([
63
+ 'create_file', 'write_file', 'batch_create_files',
64
+ 'append_to_file', 'insert_after_heading', 'update_section',
65
+ 'edit_lines', 'delete_file', 'rename_file', 'move_file', 'append_csv',
66
+ ]);
67
+
68
+ const FILE_READ_TOOLS = new Set([
69
+ 'read_file', 'read_lines', 'search', 'list_files',
70
+ ]);
71
+
72
+ /**
73
+ * Derive a user-facing stage hint from an SSE event.
74
+ * Returns null for events that don't change the stage (e.g. tool_end, done).
75
+ */
76
+ export function deriveStageHint(
77
+ eventType: string,
78
+ toolName: string | undefined,
79
+ args: unknown,
80
+ ): { stage: OrganizeStageHint; detail?: string } | null {
81
+ if (eventType === 'text_delta') {
82
+ return { stage: 'analyzing' };
83
+ }
84
+ if (eventType === 'tool_start' && toolName) {
85
+ if (FILE_WRITE_TOOLS.has(toolName)) {
86
+ return { stage: 'writing', detail: extractPathFromArgs(toolName, args) || undefined };
87
+ }
88
+ if (FILE_READ_TOOLS.has(toolName)) {
89
+ const detail = (args && typeof args === 'object' && 'path' in args && typeof (args as Record<string, unknown>).path === 'string')
90
+ ? (args as Record<string, unknown>).path as string
91
+ : undefined;
92
+ return { stage: 'reading', detail };
93
+ }
94
+ return { stage: 'analyzing' };
95
+ }
96
+ return null;
97
+ }
98
+
99
+ function extractPathFromArgs(toolName: string, args: unknown): string {
100
+ if (!args || typeof args !== 'object') return '';
101
+ const a = args as Record<string, unknown>;
102
+ if (typeof a.path === 'string') return a.path;
103
+ if (typeof a.from_path === 'string') return a.from_path;
104
+ if (toolName === 'batch_create_files' && Array.isArray(a.files)) {
105
+ return (a.files as Array<{ path?: string }>)
106
+ .map(f => f.path ?? '')
107
+ .filter(Boolean)
108
+ .join(', ');
109
+ }
110
+ return '';
111
+ }
112
+
113
+ /**
114
+ * Fetch a file's current content for snapshot (best-effort, never throws).
115
+ * Returns empty string on failure — undo will be unavailable for that file.
116
+ */
117
+ async function captureSnapshot(path: string): Promise<string> {
118
+ try {
119
+ const res = await fetch(`/api/file?path=${encodeURIComponent(path)}&op=read_file`);
120
+ if (!res.ok) return '';
121
+ const data = await res.json() as { content?: string };
122
+ return data.content ?? '';
123
+ } catch {
124
+ return '';
125
+ }
126
+ }
127
+
128
+ async function consumeOrganizeStream(
129
+ body: ReadableStream<Uint8Array>,
130
+ onProgress: (state: Partial<AiOrganizeState> & { summary?: string }) => void,
131
+ onSnapshot: (path: string, content: string) => void,
132
+ signal?: AbortSignal,
133
+ ): Promise<{ changes: OrganizeFileChange[]; summary: string; toolCallCount: number }> {
134
+ const reader = body.getReader();
135
+ const decoder = new TextDecoder();
136
+ let buffer = '';
137
+
138
+ const changes: OrganizeFileChange[] = [];
139
+ const pendingTools = new Map<string, { name: string; path: string; action: 'create' | 'update' | 'unknown' }>();
140
+ let summary = '';
141
+ let toolCallCount = 0;
142
+ const snapshotted = new Set<string>();
143
+
144
+ try {
145
+ while (true) {
146
+ if (signal?.aborted) break;
147
+ const { done, value } = await reader.read();
148
+ if (done) break;
149
+
150
+ buffer += decoder.decode(value, { stream: true });
151
+ const lines = buffer.split('\n');
152
+ buffer = lines.pop() ?? '';
153
+
154
+ for (const line of lines) {
155
+ const trimmed = line.trim();
156
+ if (!trimmed.startsWith('data:')) continue;
157
+ const jsonStr = trimmed.slice(5).trim();
158
+ if (!jsonStr) continue;
159
+
160
+ let event: Record<string, unknown>;
161
+ try { event = JSON.parse(jsonStr); } catch { continue; }
162
+
163
+ const type = event.type as string;
164
+
165
+ switch (type) {
166
+ case 'tool_start': {
167
+ const toolName = event.toolName as string;
168
+ const toolCallId = event.toolCallId as string;
169
+ const args = event.args;
170
+ toolCallCount++;
171
+
172
+ const hint = deriveStageHint(type, toolName, args);
173
+ if (hint) onProgress({ stageHint: hint });
174
+
175
+ if (FILE_WRITE_TOOLS.has(toolName)) {
176
+ const path = extractPathFromArgs(toolName, args);
177
+ let action: 'create' | 'update' | 'unknown' = 'update';
178
+ if (toolName === 'create_file' || toolName === 'batch_create_files') action = 'create';
179
+ else if (toolName === 'delete_file' || toolName === 'rename_file' || toolName === 'move_file') action = 'unknown';
180
+
181
+ // Capture snapshot for update operations (fire-and-forget)
182
+ if (action === 'update' && path && !snapshotted.has(path)) {
183
+ snapshotted.add(path);
184
+ captureSnapshot(path).then(content => {
185
+ if (content) onSnapshot(path, content);
186
+ });
187
+ }
188
+
189
+ pendingTools.set(toolCallId, { name: toolName, path, action });
190
+ onProgress({ currentTool: { name: toolName, path } });
191
+ }
192
+ break;
193
+ }
194
+
195
+ case 'tool_end': {
196
+ const toolCallId = event.toolCallId as string;
197
+ const isError = !!event.isError;
198
+ const pending = pendingTools.get(toolCallId);
199
+ if (pending) {
200
+ if (pending.name === 'batch_create_files') {
201
+ for (const p of pending.path.split(', ').filter(Boolean)) {
202
+ changes.push({ action: pending.action, path: p, toolCallId, ok: !isError });
203
+ }
204
+ } else {
205
+ changes.push({ action: pending.action, path: pending.path, toolCallId, ok: !isError });
206
+ }
207
+ pendingTools.delete(toolCallId);
208
+ onProgress({ changes: [...changes], currentTool: null });
209
+ }
210
+ break;
211
+ }
212
+
213
+ case 'text_delta': {
214
+ summary += (event.delta as string) ?? '';
215
+ onProgress({ stageHint: { stage: 'analyzing' }, summary: stripThinkingTags(summary) });
216
+ break;
217
+ }
218
+
219
+ case 'error': {
220
+ throw new Error((event.message as string) || 'AI organize failed');
221
+ }
222
+
223
+ case 'done':
224
+ break;
225
+ }
226
+ }
227
+ }
228
+ } finally {
229
+ reader.releaseLock();
230
+ }
231
+
232
+ return { changes, summary: stripThinkingTags(summary), toolCallCount };
233
+ }
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // Hook
237
+ // ---------------------------------------------------------------------------
238
+
239
+ export function useAiOrganize() {
240
+ const [phase, setPhase] = useState<OrganizePhase>('idle');
241
+ const [changes, setChanges] = useState<OrganizeFileChange[]>([]);
242
+ const [currentTool, setCurrentTool] = useState<{ name: string; path: string } | null>(null);
243
+ const [stageHint, setStageHint] = useState<{ stage: OrganizeStageHint; detail?: string } | null>(null);
244
+ const [summary, setSummary] = useState('');
245
+ const [error, setError] = useState<string | null>(null);
246
+ const [toolCallCount, setToolCallCount] = useState(0);
247
+ const abortRef = useRef<AbortController | null>(null);
248
+ const lastEventRef = useRef<number>(0);
249
+ const snapshotsRef = useRef<FileSnapshots>(new Map());
250
+ const [sourceFileNames, setSourceFileNames] = useState<string[]>([]);
251
+
252
+ const start = useCallback(async (files: LocalAttachment[], prompt: string) => {
253
+ setPhase('organizing');
254
+ setChanges([]);
255
+ setCurrentTool(null);
256
+ setStageHint({ stage: 'connecting' });
257
+ setSummary('');
258
+ setError(null);
259
+ setToolCallCount(0);
260
+ setSourceFileNames(files.map(f => f.name));
261
+ snapshotsRef.current = new Map();
262
+ lastEventRef.current = Date.now();
263
+
264
+ const controller = new AbortController();
265
+ abortRef.current = controller;
266
+
267
+ const messages: Message[] = [{ role: 'user', content: prompt }];
268
+
269
+ const truncatedFiles = files.map(f => ({
270
+ name: f.name,
271
+ content: f.content.length > CLIENT_TRUNCATE_CHARS
272
+ ? f.content.slice(0, CLIENT_TRUNCATE_CHARS) + '\n\n[...truncated to first ~20000 chars]'
273
+ : f.content,
274
+ }));
275
+
276
+ try {
277
+ const res = await fetch('/api/ask', {
278
+ method: 'POST',
279
+ headers: { 'Content-Type': 'application/json' },
280
+ body: JSON.stringify({
281
+ messages,
282
+ uploadedFiles: truncatedFiles,
283
+ maxSteps: 15,
284
+ }),
285
+ signal: controller.signal,
286
+ });
287
+
288
+ if (!res.ok) {
289
+ let errorMsg = `Request failed (${res.status})`;
290
+ try {
291
+ const errBody = await res.json() as { error?: { message?: string } | string; message?: string };
292
+ if (typeof errBody?.error === 'string') errorMsg = errBody.error;
293
+ else if (typeof errBody?.error === 'object' && errBody.error?.message) errorMsg = errBody.error.message;
294
+ else if (errBody?.message) errorMsg = errBody.message as string;
295
+ } catch {}
296
+ throw new Error(errorMsg);
297
+ }
298
+
299
+ if (!res.body) throw new Error('No response body');
300
+
301
+ const result = await consumeOrganizeStream(
302
+ res.body,
303
+ (partial) => {
304
+ lastEventRef.current = Date.now();
305
+ if (partial.changes) setChanges(partial.changes);
306
+ if (partial.currentTool !== undefined) setCurrentTool(partial.currentTool);
307
+ if (partial.stageHint) setStageHint(partial.stageHint);
308
+ if (partial.summary !== undefined) setSummary(partial.summary);
309
+ },
310
+ (path, content) => {
311
+ snapshotsRef.current.set(path, content);
312
+ },
313
+ controller.signal,
314
+ );
315
+
316
+ setChanges(result.changes);
317
+ setSummary(result.summary);
318
+ setToolCallCount(result.toolCallCount);
319
+ setCurrentTool(null);
320
+ setPhase('done');
321
+ } catch (err) {
322
+ if ((err as Error).name === 'AbortError') {
323
+ setPhase('idle');
324
+ } else {
325
+ setError((err as Error).message);
326
+ setPhase('error');
327
+ }
328
+ } finally {
329
+ abortRef.current = null;
330
+ }
331
+ }, []);
332
+
333
+ const abort = useCallback(() => {
334
+ abortRef.current?.abort();
335
+ }, []);
336
+
337
+ /** Undo a single file change — supports both create (delete) and update (restore snapshot) */
338
+ const undoOne = useCallback(async (path: string): Promise<boolean> => {
339
+ const target = changes.find(c => c.path === path && c.ok && !c.undone);
340
+ if (!target) return false;
341
+
342
+ try {
343
+ if (target.action === 'create') {
344
+ const res = await fetch('/api/file', {
345
+ method: 'POST',
346
+ headers: { 'Content-Type': 'application/json' },
347
+ body: JSON.stringify({ op: 'delete_file', path: target.path }),
348
+ });
349
+ if (!res.ok) return false;
350
+ } else if (target.action === 'update') {
351
+ const snapshot = snapshotsRef.current.get(path);
352
+ if (!snapshot) return false;
353
+ const res = await fetch('/api/file', {
354
+ method: 'POST',
355
+ headers: { 'Content-Type': 'application/json' },
356
+ body: JSON.stringify({ op: 'write_file', path: target.path, content: snapshot }),
357
+ });
358
+ if (!res.ok) return false;
359
+ } else {
360
+ return false;
361
+ }
362
+
363
+ setChanges(prev => prev.map(c =>
364
+ c.path === path && c.ok && !c.undone ? { ...c, undone: true } : c,
365
+ ));
366
+ return true;
367
+ } catch {
368
+ return false;
369
+ }
370
+ }, [changes]);
371
+
372
+ const undoAll = useCallback(async (): Promise<number> => {
373
+ const undoable = changes.filter(c => c.ok && !c.undone && (c.action === 'create' || (c.action === 'update' && snapshotsRef.current.has(c.path))));
374
+ let reverted = 0;
375
+ for (const file of undoable) {
376
+ try {
377
+ if (file.action === 'create') {
378
+ const res = await fetch('/api/file', {
379
+ method: 'POST',
380
+ headers: { 'Content-Type': 'application/json' },
381
+ body: JSON.stringify({ op: 'delete_file', path: file.path }),
382
+ });
383
+ if (res.ok) reverted++;
384
+ } else if (file.action === 'update') {
385
+ const snapshot = snapshotsRef.current.get(file.path);
386
+ if (snapshot) {
387
+ const res = await fetch('/api/file', {
388
+ method: 'POST',
389
+ headers: { 'Content-Type': 'application/json' },
390
+ body: JSON.stringify({ op: 'write_file', path: file.path, content: snapshot }),
391
+ });
392
+ if (res.ok) reverted++;
393
+ }
394
+ }
395
+ } catch {}
396
+ }
397
+ if (reverted > 0) {
398
+ setChanges(prev => prev.map(c => {
399
+ if (!c.ok || c.undone) return c;
400
+ if (c.action === 'create') return { ...c, undone: true };
401
+ if (c.action === 'update' && snapshotsRef.current.has(c.path)) return { ...c, undone: true };
402
+ return c;
403
+ }));
404
+ }
405
+ return reverted;
406
+ }, [changes]);
407
+
408
+ /** Check if a specific file can be undone */
409
+ const canUndo = useCallback((path: string): boolean => {
410
+ const c = changes.find(ch => ch.path === path && ch.ok && !ch.undone);
411
+ if (!c) return false;
412
+ if (c.action === 'create') return true;
413
+ if (c.action === 'update') return snapshotsRef.current.has(path);
414
+ return false;
415
+ }, [changes]);
416
+
417
+ const hasAnyUndoable = changes.some(c => c.ok && !c.undone && (c.action === 'create' || (c.action === 'update' && snapshotsRef.current.has(c.path))));
418
+
419
+ const reset = useCallback(() => {
420
+ setPhase('idle');
421
+ setChanges([]);
422
+ setCurrentTool(null);
423
+ setStageHint(null);
424
+ setSummary('');
425
+ setError(null);
426
+ setToolCallCount(0);
427
+ setSourceFileNames([]);
428
+ snapshotsRef.current = new Map();
429
+ lastEventRef.current = 0;
430
+ }, []);
431
+
432
+ return {
433
+ phase,
434
+ changes,
435
+ currentTool,
436
+ stageHint,
437
+ summary,
438
+ error,
439
+ toolCallCount,
440
+ sourceFileNames,
441
+ snapshots: snapshotsRef,
442
+ start,
443
+ abort,
444
+ undoOne,
445
+ undoAll,
446
+ canUndo,
447
+ hasAnyUndoable,
448
+ reset,
449
+ };
450
+ }
@@ -4,7 +4,7 @@ import { useState, useCallback, useRef } from 'react';
4
4
  import { ALLOWED_IMPORT_EXTENSIONS } from '@/lib/core/file-convert';
5
5
 
6
6
  export type ImportIntent = 'archive' | 'digest';
7
- export type ImportStep = 'select' | 'archive_config' | 'importing' | 'done';
7
+ export type ImportStep = 'select' | 'archive_config' | 'importing' | 'done' | 'organizing' | 'organize_review';
8
8
  export type ConflictMode = 'skip' | 'rename' | 'overwrite';
9
9
 
10
10
  export interface ImportFile {
@@ -31,6 +31,37 @@ function formatSize(bytes: number): string {
31
31
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
32
32
  }
33
33
 
34
+ function fileToBase64(file: File): Promise<string> {
35
+ return new Promise((resolve, reject) => {
36
+ const reader = new FileReader();
37
+ reader.onload = () => {
38
+ const dataUrl = reader.result as string;
39
+ const commaIdx = dataUrl.indexOf(',');
40
+ resolve(commaIdx >= 0 ? dataUrl.slice(commaIdx + 1) : '');
41
+ };
42
+ reader.onerror = () => reject(reader.error ?? new Error('FileReader error'));
43
+ reader.readAsDataURL(file);
44
+ });
45
+ }
46
+
47
+ async function extractPdfText(file: File): Promise<string> {
48
+ const base64 = await fileToBase64(file);
49
+ if (!base64) throw new Error('Empty PDF file');
50
+
51
+ const res = await fetch('/api/extract-pdf', {
52
+ method: 'POST',
53
+ headers: { 'Content-Type': 'application/json' },
54
+ body: JSON.stringify({ name: file.name, dataBase64: base64 }),
55
+ });
56
+ if (!res.ok) {
57
+ const err = await res.json().catch(() => ({}));
58
+ throw new Error((err as { error?: string }).error || 'PDF extraction failed');
59
+ }
60
+ const data = await res.json() as { text?: string; extracted?: boolean };
61
+ if (!data.extracted || !data.text) throw new Error('No text extracted from PDF');
62
+ return data.text;
63
+ }
64
+
34
65
  export function useFileImport() {
35
66
  const [files, setFiles] = useState<ImportFile[]>([]);
36
67
  const [step, setStep] = useState<ImportStep>('select');
@@ -85,7 +116,13 @@ export function useFileImport() {
85
116
  for (const f of newFiles) {
86
117
  if (f.error) continue;
87
118
  try {
88
- const text = await f.file.text();
119
+ const ext = getExt(f.name);
120
+ let text: string;
121
+ if (ext === '.pdf') {
122
+ text = await extractPdfText(f.file);
123
+ } else {
124
+ text = await f.file.text();
125
+ }
89
126
  setFiles(prev => prev.map(p =>
90
127
  p.name === f.name && p.size === f.size
91
128
  ? { ...p, content: text, loading: false }
@@ -47,15 +47,33 @@ export function useMention() {
47
47
  return;
48
48
  }
49
49
  const q = query.toLowerCase();
50
- const filtered = allFiles.filter((f) => f.toLowerCase().includes(q)).slice(0, 30);
51
- if (filtered.length === 0) {
50
+ if (!q) {
51
+ setMentionQuery(query);
52
+ setMentionResults(allFiles.slice(0, 30));
53
+ setMentionIndex(0);
54
+ return;
55
+ }
56
+ const scored = allFiles
57
+ .map((f) => {
58
+ const name = (f.split('/').pop() ?? f).toLowerCase();
59
+ const fl = f.toLowerCase();
60
+ let score = 0;
61
+ if (name.startsWith(q)) score = 100;
62
+ else if (name.includes(q)) score = 50;
63
+ else if (fl.includes(q)) score = 10;
64
+ return { path: f, score };
65
+ })
66
+ .filter((x) => x.score > 0)
67
+ .sort((a, b) => b.score - a.score)
68
+ .slice(0, 30);
69
+ if (scored.length === 0) {
52
70
  setMentionQuery(null);
53
71
  setMentionResults([]);
54
72
  setMentionIndex(0);
55
73
  return;
56
74
  }
57
75
  setMentionQuery(query);
58
- setMentionResults(filtered);
76
+ setMentionResults(scored.map((x) => x.path));
59
77
  setMentionIndex(0);
60
78
  },
61
79
  [allFiles],
@@ -66,10 +66,24 @@ export function useSlashCommand() {
66
66
  }
67
67
 
68
68
  const q = query.toLowerCase();
69
- const items: SlashItem[] = allSkills
70
- .filter((s) => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q))
71
- .slice(0, 20)
72
- .map((s) => ({ type: 'skill', name: s.name, description: s.description }));
69
+ const items: SlashItem[] = (q
70
+ ? allSkills
71
+ .map((s) => {
72
+ const nl = s.name.toLowerCase();
73
+ let score = 0;
74
+ if (nl.startsWith(q)) score = 100;
75
+ else if (nl.includes(q)) score = 50;
76
+ else if (s.description.toLowerCase().includes(q)) score = 10;
77
+ return { s, score };
78
+ })
79
+ .filter((x) => x.score > 0)
80
+ .sort((a, b) => b.score - a.score)
81
+ .slice(0, 20)
82
+ .map((x) => ({ type: 'skill' as const, name: x.s.name, description: x.s.description }))
83
+ : allSkills
84
+ .slice(0, 20)
85
+ .map((s) => ({ type: 'skill' as const, name: s.name, description: s.description }))
86
+ );
73
87
 
74
88
  if (items.length === 0) {
75
89
  setSlashQuery(null);
@@ -0,0 +1,40 @@
1
+ /** Auto-reconnect utilities for Ask AI streaming connections. */
2
+
3
+ const NON_RETRYABLE_STATUS = new Set([401, 403, 429]);
4
+
5
+ const NON_RETRYABLE_PATTERNS = [
6
+ /api.?key/i,
7
+ /model.*not.?found/i,
8
+ /authentication/i,
9
+ /unauthorized/i,
10
+ /forbidden/i,
11
+ ];
12
+
13
+ export function isRetryableError(err: unknown, httpStatus?: number): boolean {
14
+ if (err instanceof DOMException && err.name === 'AbortError') return false;
15
+ if (httpStatus && NON_RETRYABLE_STATUS.has(httpStatus)) return false;
16
+
17
+ if (err instanceof Error) {
18
+ const msg = err.message;
19
+ if (NON_RETRYABLE_PATTERNS.some(p => p.test(msg))) return false;
20
+ }
21
+
22
+ return true;
23
+ }
24
+
25
+ const BASE_DELAY = 1000;
26
+ const MAX_DELAY = 10_000;
27
+
28
+ /** Exponential backoff: 1s, 2s, 4s, 8s... capped at 10s */
29
+ export function retryDelay(attempt: number): number {
30
+ return Math.min(BASE_DELAY * 2 ** attempt, MAX_DELAY);
31
+ }
32
+
33
+ export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
34
+ return new Promise((resolve, reject) => {
35
+ const abortReason = () => signal?.reason ?? new DOMException('The operation was aborted.', 'AbortError');
36
+ if (signal?.aborted) { reject(abortReason()); return; }
37
+ const timer = setTimeout(resolve, ms);
38
+ signal?.addEventListener('abort', () => { clearTimeout(timer); reject(abortReason()); }, { once: true });
39
+ });
40
+ }
@@ -7,8 +7,8 @@ import type { BacklinkEntry } from './types';
7
7
  * Finds files that reference the given targetPath via wikilinks,
8
8
  * markdown links, or backtick references.
9
9
  */
10
- export function findBacklinks(mindRoot: string, targetPath: string): BacklinkEntry[] {
11
- const allFiles = collectAllFiles(mindRoot).filter(f => f.endsWith('.md') && f !== targetPath);
10
+ export function findBacklinks(mindRoot: string, targetPath: string, cachedFiles?: string[]): BacklinkEntry[] {
11
+ const allFiles = (cachedFiles ?? collectAllFiles(mindRoot)).filter(f => f.endsWith('.md') && f !== targetPath);
12
12
  const results: BacklinkEntry[] = [];
13
13
  const bname = path.basename(targetPath, '.md');
14
14
  const escapedTarget = targetPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -1,4 +1,4 @@
1
- import { execSync } from 'child_process';
1
+ import { execFileSync } from 'child_process';
2
2
  import { resolveSafe } from './security';
3
3
  import type { GitLogEntry } from './types';
4
4
 
@@ -7,7 +7,7 @@ import type { GitLogEntry } from './types';
7
7
  */
8
8
  export function isGitRepo(mindRoot: string): boolean {
9
9
  try {
10
- execSync('git rev-parse --is-inside-work-tree', { cwd: mindRoot, stdio: 'pipe' });
10
+ execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: mindRoot, stdio: 'pipe' });
11
11
  return true;
12
12
  } catch { return false; }
13
13
  }
@@ -17,8 +17,9 @@ export function isGitRepo(mindRoot: string): boolean {
17
17
  */
18
18
  export function gitLog(mindRoot: string, filePath: string, limit: number): GitLogEntry[] {
19
19
  const resolved = resolveSafe(mindRoot, filePath);
20
- const output = execSync(
21
- `git log --follow --format="%H%x00%aI%x00%s%x00%an" -n ${limit} -- "${resolved}"`,
20
+ const output = execFileSync(
21
+ 'git',
22
+ ['log', '--follow', '--format=%H%x00%aI%x00%s%x00%an', '-n', String(limit), '--', resolved],
22
23
  { cwd: mindRoot, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
23
24
  ).trim();
24
25
  if (!output) return [];
@@ -33,18 +34,21 @@ export function gitLog(mindRoot: string, filePath: string, limit: number): GitLo
33
34
  */
34
35
  export function gitShowFile(mindRoot: string, filePath: string, commitHash: string): string {
35
36
  const resolved = resolveSafe(mindRoot, filePath);
36
- const relFromGitRoot = execSync(
37
- `git ls-files --full-name "${resolved}"`,
37
+ const relFromGitRoot = execFileSync(
38
+ 'git',
39
+ ['ls-files', '--full-name', resolved],
38
40
  { cwd: mindRoot, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
39
41
  ).trim();
40
42
  if (!relFromGitRoot) {
41
- return execSync(
42
- `git show ${commitHash}:"${filePath}"`,
43
+ return execFileSync(
44
+ 'git',
45
+ ['show', `${commitHash}:${filePath}`],
43
46
  { cwd: mindRoot, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
44
47
  );
45
48
  }
46
- return execSync(
47
- `git show ${commitHash}:"${relFromGitRoot}"`,
49
+ return execFileSync(
50
+ 'git',
51
+ ['show', `${commitHash}:${relFromGitRoot}`],
48
52
  { cwd: mindRoot, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
49
53
  );
50
54
  }