@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.
- package/README.md +2 -0
- package/README_zh.md +2 -0
- package/app/app/api/ask/route.ts +35 -2
- package/app/app/api/file/route.ts +27 -0
- package/app/app/api/mcp/install/route.ts +4 -1
- package/app/app/api/setup/check-path/route.ts +2 -7
- package/app/app/api/setup/check-port/route.ts +18 -13
- package/app/app/api/setup/ls/route.ts +3 -9
- package/app/app/api/setup/path-utils.ts +8 -0
- package/app/app/api/setup/route.ts +2 -7
- package/app/app/api/uninstall/route.ts +47 -0
- package/app/app/globals.css +11 -0
- package/app/components/ActivityBar.tsx +10 -3
- package/app/components/AskFab.tsx +7 -3
- package/app/components/CreateSpaceModal.tsx +1 -1
- package/app/components/DirView.tsx +1 -1
- package/app/components/FileTree.tsx +30 -23
- package/app/components/GuideCard.tsx +1 -1
- package/app/components/HomeContent.tsx +137 -109
- package/app/components/ImportModal.tsx +104 -60
- package/app/components/MarkdownView.tsx +3 -0
- package/app/components/OnboardingView.tsx +1 -1
- package/app/components/OrganizeToast.tsx +386 -0
- package/app/components/Panel.tsx +23 -2
- package/app/components/Sidebar.tsx +1 -1
- package/app/components/SidebarLayout.tsx +44 -1
- package/app/components/agents/AgentDetailContent.tsx +33 -12
- package/app/components/agents/AgentsMcpSection.tsx +1 -1
- package/app/components/agents/AgentsOverviewSection.tsx +3 -4
- package/app/components/agents/AgentsPrimitives.tsx +2 -2
- package/app/components/agents/AgentsSkillsSection.tsx +2 -2
- package/app/components/agents/SkillDetailPopover.tsx +24 -8
- package/app/components/ask/AskContent.tsx +124 -70
- package/app/components/ask/HighlightMatch.tsx +14 -0
- package/app/components/ask/MentionPopover.tsx +5 -3
- package/app/components/ask/MessageList.tsx +39 -11
- package/app/components/ask/SlashCommandPopover.tsx +4 -2
- package/app/components/changes/ChangesBanner.tsx +20 -2
- package/app/components/changes/ChangesContentPage.tsx +10 -2
- package/app/components/echo/EchoHero.tsx +1 -1
- package/app/components/echo/EchoInsightCollapsible.tsx +1 -1
- package/app/components/echo/EchoPageSections.tsx +1 -1
- package/app/components/explore/UseCaseCard.tsx +1 -1
- package/app/components/panels/DiscoverPanel.tsx +29 -25
- package/app/components/panels/ImportHistoryPanel.tsx +195 -0
- package/app/components/panels/PluginsPanel.tsx +2 -2
- package/app/components/settings/AiTab.tsx +24 -0
- package/app/components/settings/KnowledgeTab.tsx +1 -1
- package/app/components/settings/McpSkillCreateForm.tsx +1 -1
- package/app/components/settings/McpSkillRow.tsx +1 -1
- package/app/components/settings/McpSkillsSection.tsx +2 -2
- package/app/components/settings/McpTab.tsx +2 -2
- package/app/components/settings/PluginsTab.tsx +1 -1
- package/app/components/settings/Primitives.tsx +118 -6
- package/app/components/settings/SettingsContent.tsx +5 -2
- package/app/components/settings/UninstallTab.tsx +179 -0
- package/app/components/settings/UpdateTab.tsx +17 -5
- package/app/components/settings/types.ts +2 -1
- package/app/components/ui/dialog.tsx +1 -1
- package/app/hooks/useAiOrganize.ts +450 -0
- package/app/hooks/useFileImport.ts +39 -2
- package/app/hooks/useMention.ts +21 -3
- package/app/hooks/useSlashCommand.ts +18 -4
- package/app/lib/agent/reconnect.ts +40 -0
- package/app/lib/core/backlinks.ts +2 -2
- package/app/lib/core/git.ts +14 -10
- package/app/lib/fs.ts +2 -1
- package/app/lib/i18n-en.ts +85 -4
- package/app/lib/i18n-zh.ts +85 -4
- package/app/lib/organize-history.ts +74 -0
- package/app/lib/settings.ts +2 -0
- package/app/lib/types.ts +2 -0
- package/app/next-env.d.ts +1 -1
- package/app/next.config.ts +23 -5
- package/app/package.json +1 -1
- package/bin/cli.js +21 -18
- package/bin/lib/mcp-build.js +74 -0
- package/bin/lib/mcp-spawn.js +8 -5
- package/bin/lib/port.js +17 -2
- package/bin/lib/stop.js +12 -2
- package/mcp/dist/index.cjs +43 -43
- package/mcp/src/index.ts +58 -12
- package/package.json +1 -1
- package/scripts/release.sh +1 -1
- 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
|
|
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 }
|
package/app/hooks/useMention.ts
CHANGED
|
@@ -47,15 +47,33 @@ export function useMention() {
|
|
|
47
47
|
return;
|
|
48
48
|
}
|
|
49
49
|
const q = query.toLowerCase();
|
|
50
|
-
|
|
51
|
-
|
|
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(
|
|
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[] =
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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, '\\$&');
|
package/app/lib/core/git.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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 =
|
|
21
|
-
|
|
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 =
|
|
37
|
-
|
|
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
|
|
42
|
-
|
|
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
|
|
47
|
-
|
|
49
|
+
return execFileSync(
|
|
50
|
+
'git',
|
|
51
|
+
['show', `${commitHash}:${relFromGitRoot}`],
|
|
48
52
|
{ cwd: mindRoot, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
49
53
|
);
|
|
50
54
|
}
|