@geminilight/mindos 0.6.7 → 0.6.8
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/app/app/api/ask/route.ts +35 -2
- package/app/app/api/file/route.ts +27 -0
- package/app/app/api/setup/check-port/route.ts +18 -13
- package/app/components/ImportModal.tsx +566 -61
- package/app/components/ask/AskContent.tsx +6 -1
- package/app/hooks/useAiOrganize.ts +338 -0
- package/app/hooks/useFileImport.ts +39 -2
- package/app/lib/i18n-en.ts +39 -2
- package/app/lib/i18n-zh.ts +39 -2
- package/app/next-env.d.ts +1 -1
- package/app/package.json +1 -1
- package/bin/cli.js +15 -9
- package/package.json +1 -1
- package/scripts/release.sh +1 -1
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
4
4
|
import {
|
|
5
|
-
X, FolderInput, Sparkles, FileText, AlertCircle,
|
|
6
|
-
AlertTriangle, Loader2, Check,
|
|
5
|
+
X, FolderInput, FolderOpen, Sparkles, FileText, AlertCircle,
|
|
6
|
+
AlertTriangle, Loader2, Check, FilePlus, FileEdit, Undo2, ChevronDown,
|
|
7
7
|
} from 'lucide-react';
|
|
8
|
+
import ReactMarkdown from 'react-markdown';
|
|
9
|
+
import remarkGfm from 'remark-gfm';
|
|
8
10
|
import { useLocale } from '@/lib/LocaleContext';
|
|
9
11
|
import { useFileImport, type ImportIntent, type ConflictMode } from '@/hooks/useFileImport';
|
|
10
|
-
import {
|
|
12
|
+
import { useAiOrganize, stripThinkingTags } from '@/hooks/useAiOrganize';
|
|
13
|
+
import type { OrganizeStageHint } from '@/hooks/useAiOrganize';
|
|
11
14
|
import { ALLOWED_IMPORT_EXTENSIONS } from '@/lib/core/file-convert';
|
|
12
15
|
import type { LocalAttachment } from '@/lib/types';
|
|
13
16
|
|
|
@@ -20,14 +23,275 @@ interface ImportModalProps {
|
|
|
20
23
|
|
|
21
24
|
const ACCEPT = Array.from(ALLOWED_IMPORT_EXTENSIONS).join(',');
|
|
22
25
|
|
|
26
|
+
const THINKING_TIMEOUT_MS = 5000;
|
|
27
|
+
|
|
28
|
+
function stageHintText(
|
|
29
|
+
t: { fileImport: Record<string, unknown> },
|
|
30
|
+
hint: { stage: OrganizeStageHint; detail?: string } | null,
|
|
31
|
+
): string {
|
|
32
|
+
const fi = t.fileImport as {
|
|
33
|
+
organizeConnecting: string;
|
|
34
|
+
organizeAnalyzing: string;
|
|
35
|
+
organizeReading: (d?: string) => string;
|
|
36
|
+
organizeThinking: string;
|
|
37
|
+
organizeWriting: (d?: string) => string;
|
|
38
|
+
organizeProcessing: string;
|
|
39
|
+
};
|
|
40
|
+
if (!hint) return fi.organizeProcessing;
|
|
41
|
+
switch (hint.stage) {
|
|
42
|
+
case 'connecting': return fi.organizeConnecting;
|
|
43
|
+
case 'analyzing': return fi.organizeAnalyzing;
|
|
44
|
+
case 'reading': return fi.organizeReading(hint.detail);
|
|
45
|
+
case 'thinking': return fi.organizeThinking;
|
|
46
|
+
case 'writing': return fi.organizeWriting(hint.detail);
|
|
47
|
+
default: return fi.organizeProcessing;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Hook: elapsed timer + thinking-override for the organizing phase.
|
|
53
|
+
* Lifted to ImportModal level so both full modal and minimized bar share the same state.
|
|
54
|
+
*/
|
|
55
|
+
function useOrganizeTimer(isOrganizing: boolean, stageHint: ReturnType<typeof useAiOrganize>['stageHint']) {
|
|
56
|
+
const [elapsed, setElapsed] = useState(0);
|
|
57
|
+
const [thinkingOverride, setThinkingOverride] = useState(false);
|
|
58
|
+
const lastEventRef = useRef(Date.now());
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
lastEventRef.current = Date.now();
|
|
62
|
+
setThinkingOverride(false);
|
|
63
|
+
}, [stageHint]);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!isOrganizing) { setElapsed(0); setThinkingOverride(false); return; }
|
|
67
|
+
const timer = setInterval(() => {
|
|
68
|
+
setElapsed(e => e + 1);
|
|
69
|
+
if (Date.now() - lastEventRef.current >= THINKING_TIMEOUT_MS) {
|
|
70
|
+
setThinkingOverride(true);
|
|
71
|
+
}
|
|
72
|
+
}, 1000);
|
|
73
|
+
return () => clearInterval(timer);
|
|
74
|
+
}, [isOrganizing]);
|
|
75
|
+
|
|
76
|
+
const displayHint = thinkingOverride
|
|
77
|
+
? { stage: 'thinking' as const }
|
|
78
|
+
: stageHint;
|
|
79
|
+
|
|
80
|
+
return { elapsed, displayHint };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Full-size organizing progress view (shown inside the modal).
|
|
85
|
+
*/
|
|
86
|
+
function OrganizingProgress({
|
|
87
|
+
aiOrganize,
|
|
88
|
+
t,
|
|
89
|
+
elapsed,
|
|
90
|
+
displayHint,
|
|
91
|
+
onMinimize,
|
|
92
|
+
onCancel,
|
|
93
|
+
}: {
|
|
94
|
+
aiOrganize: ReturnType<typeof useAiOrganize>;
|
|
95
|
+
t: ReturnType<typeof useLocale>['t'];
|
|
96
|
+
elapsed: number;
|
|
97
|
+
displayHint: { stage: OrganizeStageHint; detail?: string } | null;
|
|
98
|
+
onMinimize: () => void;
|
|
99
|
+
onCancel: () => void;
|
|
100
|
+
}) {
|
|
101
|
+
const fi = t.fileImport as { organizeElapsed: (s: number) => string };
|
|
102
|
+
const summaryPreview = aiOrganize.summary ? stripThinkingTags(aiOrganize.summary).trim().slice(0, 200) : '';
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div className="mt-4 space-y-3">
|
|
106
|
+
{/* Status header */}
|
|
107
|
+
<div className="flex items-center gap-3">
|
|
108
|
+
<div className="relative shrink-0">
|
|
109
|
+
<Sparkles size={20} className="text-[var(--amber)]" />
|
|
110
|
+
<Loader2 size={12} className="absolute -bottom-0.5 -right-0.5 text-[var(--amber)] animate-spin" />
|
|
111
|
+
</div>
|
|
112
|
+
<div className="flex-1 min-w-0">
|
|
113
|
+
<p className="text-sm text-foreground font-medium truncate">
|
|
114
|
+
{stageHintText(t, displayHint)}
|
|
115
|
+
</p>
|
|
116
|
+
<span className="text-xs text-muted-foreground/60 tabular-nums">
|
|
117
|
+
{fi.organizeElapsed(elapsed)}
|
|
118
|
+
</span>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
{/* Live activity feed */}
|
|
123
|
+
<div className="bg-muted/30 rounded-lg border border-border/50 overflow-hidden">
|
|
124
|
+
<div className="max-h-[180px] overflow-y-auto p-3 space-y-2">
|
|
125
|
+
{/* Streaming AI text */}
|
|
126
|
+
{summaryPreview && (
|
|
127
|
+
<p className="text-xs text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
|
128
|
+
{summaryPreview}
|
|
129
|
+
{summaryPreview.length >= 200 ? '...' : ''}
|
|
130
|
+
</p>
|
|
131
|
+
)}
|
|
132
|
+
|
|
133
|
+
{/* Current tool being executed */}
|
|
134
|
+
{aiOrganize.currentTool && (
|
|
135
|
+
<div className="flex items-center gap-2 text-xs text-[var(--amber)] animate-pulse">
|
|
136
|
+
<Loader2 size={11} className="animate-spin shrink-0" />
|
|
137
|
+
<span className="truncate">
|
|
138
|
+
{aiOrganize.currentTool.name.startsWith('create')
|
|
139
|
+
? (t.fileImport as { organizeCreating: (p: string) => string }).organizeCreating(aiOrganize.currentTool.path)
|
|
140
|
+
: (t.fileImport as { organizeUpdating: (p: string) => string }).organizeUpdating(aiOrganize.currentTool.path)}
|
|
141
|
+
</span>
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
|
|
145
|
+
{/* Completed file operations */}
|
|
146
|
+
{aiOrganize.changes.map((c, idx) => (
|
|
147
|
+
<div key={`${c.path}-${idx}`} className="flex items-center gap-2 text-xs">
|
|
148
|
+
{c.action === 'create' ? (
|
|
149
|
+
<FilePlus size={12} className="text-success shrink-0" />
|
|
150
|
+
) : (
|
|
151
|
+
<FileEdit size={12} className="text-[var(--amber)] shrink-0" />
|
|
152
|
+
)}
|
|
153
|
+
<span className="truncate text-foreground/80">{c.path}</span>
|
|
154
|
+
<Check size={11} className="text-success shrink-0 ml-auto" />
|
|
155
|
+
</div>
|
|
156
|
+
))}
|
|
157
|
+
|
|
158
|
+
{/* Empty state — show pulsing dots */}
|
|
159
|
+
{!summaryPreview && !aiOrganize.currentTool && aiOrganize.changes.length === 0 && (
|
|
160
|
+
<div className="flex items-center justify-center gap-1 py-2">
|
|
161
|
+
<span className="w-1.5 h-1.5 rounded-full bg-[var(--amber)]/40 animate-pulse" />
|
|
162
|
+
<span className="w-1.5 h-1.5 rounded-full bg-[var(--amber)]/40 animate-pulse [animation-delay:150ms]" />
|
|
163
|
+
<span className="w-1.5 h-1.5 rounded-full bg-[var(--amber)]/40 animate-pulse [animation-delay:300ms]" />
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
{/* Actions */}
|
|
170
|
+
<div className="flex items-center justify-center gap-4">
|
|
171
|
+
<button
|
|
172
|
+
type="button"
|
|
173
|
+
onClick={onMinimize}
|
|
174
|
+
className="text-xs text-muted-foreground/70 hover:text-foreground transition-colors px-3 py-1.5"
|
|
175
|
+
>
|
|
176
|
+
{(t.fileImport as { organizeMinimize: string }).organizeMinimize}
|
|
177
|
+
</button>
|
|
178
|
+
<button
|
|
179
|
+
type="button"
|
|
180
|
+
onClick={onCancel}
|
|
181
|
+
className="text-xs text-muted-foreground/70 hover:text-muted-foreground transition-colors px-3 py-1.5"
|
|
182
|
+
>
|
|
183
|
+
{(t.fileImport as { organizeCancel: string }).organizeCancel}
|
|
184
|
+
</button>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const SUMMARY_PROSE = [
|
|
191
|
+
'prose prose-sm prose-panel dark:prose-invert max-w-none text-foreground',
|
|
192
|
+
'prose-p:my-1 prose-p:leading-relaxed',
|
|
193
|
+
'prose-headings:font-semibold prose-headings:my-2 prose-headings:text-[13px]',
|
|
194
|
+
'prose-ul:my-1 prose-li:my-0.5 prose-ol:my-1',
|
|
195
|
+
'prose-code:text-[0.8em] prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:before:content-none prose-code:after:content-none',
|
|
196
|
+
'prose-pre:bg-muted prose-pre:text-foreground prose-pre:text-xs',
|
|
197
|
+
'prose-blockquote:border-l-amber-400 prose-blockquote:text-muted-foreground',
|
|
198
|
+
'prose-a:text-amber-500 prose-a:no-underline hover:prose-a:underline',
|
|
199
|
+
'prose-strong:text-foreground prose-strong:font-semibold',
|
|
200
|
+
'prose-table:text-xs prose-th:py-1 prose-td:py-1',
|
|
201
|
+
].join(' ');
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Clean raw AI markdown for plain-text preview (progress view):
|
|
205
|
+
* strip heading markers, excess blank lines, truncate.
|
|
206
|
+
*/
|
|
207
|
+
function cleanSummaryForDisplay(raw: string): string {
|
|
208
|
+
return stripThinkingTags(raw)
|
|
209
|
+
.replace(/^#{1,4}\s+/gm, '')
|
|
210
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
211
|
+
.trim()
|
|
212
|
+
.slice(0, 500);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Clean raw AI markdown for rendered display:
|
|
217
|
+
* strip thinking tags & excess blank lines, keep markdown formatting.
|
|
218
|
+
*/
|
|
219
|
+
function cleanSummaryForMarkdown(raw: string): string {
|
|
220
|
+
return stripThinkingTags(raw)
|
|
221
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
222
|
+
.trim();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Organize result when no tracked file changes were detected.
|
|
227
|
+
* Two sub-states:
|
|
228
|
+
* 1. AI completed work + provided summary → show summary as primary content
|
|
229
|
+
* 2. No summary → brief "up to date" message
|
|
230
|
+
*/
|
|
231
|
+
function OrganizeNoChangesView({
|
|
232
|
+
summary,
|
|
233
|
+
toolCallCount,
|
|
234
|
+
t,
|
|
235
|
+
onDone,
|
|
236
|
+
}: {
|
|
237
|
+
summary: string;
|
|
238
|
+
toolCallCount: number;
|
|
239
|
+
t: ReturnType<typeof useLocale>['t'];
|
|
240
|
+
onDone: () => void;
|
|
241
|
+
}) {
|
|
242
|
+
const fi = t.fileImport as Record<string, unknown>;
|
|
243
|
+
const mdSummary = summary ? cleanSummaryForMarkdown(summary) : '';
|
|
244
|
+
const hasSubstance = !!mdSummary;
|
|
245
|
+
|
|
246
|
+
return (
|
|
247
|
+
<div className="flex flex-col gap-3 py-4">
|
|
248
|
+
{hasSubstance ? (
|
|
249
|
+
<>
|
|
250
|
+
<div className="flex items-start gap-2.5">
|
|
251
|
+
<Sparkles size={16} className="text-[var(--amber)] mt-0.5 shrink-0" />
|
|
252
|
+
<div className={SUMMARY_PROSE}>
|
|
253
|
+
<ReactMarkdown remarkPlugins={[remarkGfm]}>{mdSummary}</ReactMarkdown>
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
{toolCallCount > 0 && (
|
|
257
|
+
<p className="text-xs text-muted-foreground/50 text-center">
|
|
258
|
+
{(fi.organizeToolCallsInfo as ((n: number) => string) | undefined)?.(toolCallCount)}
|
|
259
|
+
</p>
|
|
260
|
+
)}
|
|
261
|
+
</>
|
|
262
|
+
) : (
|
|
263
|
+
<div className="flex flex-col items-center gap-2">
|
|
264
|
+
<Sparkles size={24} className="text-muted-foreground" />
|
|
265
|
+
<p className="text-sm text-muted-foreground">
|
|
266
|
+
{fi.organizeNoChanges as string}
|
|
267
|
+
</p>
|
|
268
|
+
</div>
|
|
269
|
+
)}
|
|
270
|
+
<div className="flex justify-center pt-1">
|
|
271
|
+
<button
|
|
272
|
+
onClick={onDone}
|
|
273
|
+
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--amber)] text-[var(--amber-foreground)] hover:opacity-90 transition-all duration-200"
|
|
274
|
+
>
|
|
275
|
+
{fi.organizeDone as string}
|
|
276
|
+
</button>
|
|
277
|
+
</div>
|
|
278
|
+
</div>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
23
282
|
export default function ImportModal({ open, onClose, defaultSpace, initialFiles }: ImportModalProps) {
|
|
24
283
|
const { t } = useLocale();
|
|
25
284
|
const im = useFileImport();
|
|
285
|
+
const aiOrganize = useAiOrganize();
|
|
26
286
|
const overlayRef = useRef<HTMLDivElement>(null);
|
|
27
287
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
28
288
|
const [spaces, setSpaces] = useState<Array<{ name: string; path: string }>>([]);
|
|
29
289
|
const [closing, setClosing] = useState(false);
|
|
30
290
|
const [showSuccess, setShowSuccess] = useState(false);
|
|
291
|
+
const [undoing, setUndoing] = useState(false);
|
|
292
|
+
const [conflictFiles, setConflictFiles] = useState<string[]>([]);
|
|
293
|
+
const [showConflictOptions, setShowConflictOptions] = useState(false);
|
|
294
|
+
const [minimized, setMinimized] = useState(false);
|
|
31
295
|
const initializedRef = useRef(false);
|
|
32
296
|
|
|
33
297
|
useEffect(() => {
|
|
@@ -38,6 +302,11 @@ export default function ImportModal({ open, onClose, defaultSpace, initialFiles
|
|
|
38
302
|
if (initializedRef.current) return;
|
|
39
303
|
initializedRef.current = true;
|
|
40
304
|
im.reset();
|
|
305
|
+
aiOrganize.reset();
|
|
306
|
+
setUndoing(false);
|
|
307
|
+
setConflictFiles([]);
|
|
308
|
+
setShowConflictOptions(false);
|
|
309
|
+
setMinimized(false);
|
|
41
310
|
if (defaultSpace) im.setTargetSpace(defaultSpace);
|
|
42
311
|
if (initialFiles && initialFiles.length > 0) {
|
|
43
312
|
im.addFiles(initialFiles);
|
|
@@ -49,12 +318,16 @@ export default function ImportModal({ open, onClose, defaultSpace, initialFiles
|
|
|
49
318
|
}, [open, defaultSpace, initialFiles, im]);
|
|
50
319
|
|
|
51
320
|
const handleClose = useCallback(() => {
|
|
52
|
-
if (im.
|
|
321
|
+
if (im.step === 'organizing') {
|
|
322
|
+
setMinimized(true);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (im.files.length > 0 && im.step !== 'done' && im.step !== 'organize_review') {
|
|
53
326
|
if (!confirm(t.fileImport.discardMessage(im.files.length))) return;
|
|
54
327
|
}
|
|
55
328
|
setClosing(true);
|
|
56
|
-
setTimeout(() => { setClosing(false); onClose(); im.reset(); }, 150);
|
|
57
|
-
}, [im, onClose, t]);
|
|
329
|
+
setTimeout(() => { setClosing(false); onClose(); im.reset(); aiOrganize.reset(); setUndoing(false); setConflictFiles([]); setShowConflictOptions(false); setMinimized(false); }, 150);
|
|
330
|
+
}, [im, onClose, t, aiOrganize]);
|
|
58
331
|
|
|
59
332
|
useEffect(() => {
|
|
60
333
|
if (!open) return;
|
|
@@ -65,30 +338,38 @@ export default function ImportModal({ open, onClose, defaultSpace, initialFiles
|
|
|
65
338
|
return () => window.removeEventListener('keydown', handler, true);
|
|
66
339
|
}, [open, handleClose]);
|
|
67
340
|
|
|
341
|
+
const checkConflicts = useCallback(async (fileNames: string[], space: string) => {
|
|
342
|
+
try {
|
|
343
|
+
const names = fileNames.map(encodeURIComponent).join(',');
|
|
344
|
+
const spaceParam = space ? `&space=${encodeURIComponent(space)}` : '';
|
|
345
|
+
const res = await fetch(`/api/file?op=check_conflicts&names=${names}${spaceParam}`);
|
|
346
|
+
if (res.ok) {
|
|
347
|
+
const data = await res.json();
|
|
348
|
+
setConflictFiles(data.conflicts ?? []);
|
|
349
|
+
setShowConflictOptions((data.conflicts ?? []).length > 0);
|
|
350
|
+
}
|
|
351
|
+
} catch { /* best-effort */ }
|
|
352
|
+
}, []);
|
|
353
|
+
|
|
68
354
|
const handleIntentSelect = useCallback((intent: ImportIntent) => {
|
|
69
355
|
im.setIntent(intent);
|
|
70
356
|
if (intent === 'archive') {
|
|
71
357
|
im.setStep('archive_config');
|
|
358
|
+
const names = im.validFiles.map(f => f.name);
|
|
359
|
+
checkConflicts(names, im.targetSpace);
|
|
72
360
|
} else {
|
|
73
361
|
const attachments: LocalAttachment[] = im.validFiles.map(f => ({
|
|
74
362
|
name: f.name,
|
|
75
363
|
content: f.content!,
|
|
76
364
|
}));
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}));
|
|
84
|
-
const prompt = attachments.length === 1
|
|
85
|
-
? t.fileImport.digestPromptSingle(attachments[0].name)
|
|
86
|
-
: t.fileImport.digestPromptMulti(attachments.length);
|
|
87
|
-
openAskModal(prompt);
|
|
88
|
-
im.reset();
|
|
89
|
-
}, 150);
|
|
365
|
+
const space = im.targetSpace || undefined;
|
|
366
|
+
const prompt = attachments.length === 1
|
|
367
|
+
? (t.fileImport.digestPromptSingle as (name: string, space?: string) => string)(attachments[0].name, space)
|
|
368
|
+
: (t.fileImport.digestPromptMulti as (n: number, space?: string) => string)(attachments.length, space);
|
|
369
|
+
im.setStep('organizing');
|
|
370
|
+
aiOrganize.start(attachments, prompt);
|
|
90
371
|
}
|
|
91
|
-
}, [im,
|
|
372
|
+
}, [im, t, aiOrganize]);
|
|
92
373
|
|
|
93
374
|
const handleArchiveSubmit = useCallback(async () => {
|
|
94
375
|
await im.doArchive();
|
|
@@ -107,6 +388,62 @@ export default function ImportModal({ open, onClose, defaultSpace, initialFiles
|
|
|
107
388
|
}
|
|
108
389
|
}, [im, onClose]);
|
|
109
390
|
|
|
391
|
+
useEffect(() => {
|
|
392
|
+
if (im.step === 'archive_config') {
|
|
393
|
+
const names = im.validFiles.map(f => f.name);
|
|
394
|
+
checkConflicts(names, im.targetSpace);
|
|
395
|
+
}
|
|
396
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
397
|
+
}, [im.targetSpace]);
|
|
398
|
+
|
|
399
|
+
useEffect(() => {
|
|
400
|
+
if (im.step === 'organizing' && (aiOrganize.phase === 'done' || aiOrganize.phase === 'error')) {
|
|
401
|
+
im.setStep('organize_review');
|
|
402
|
+
}
|
|
403
|
+
}, [im.step, aiOrganize.phase, im]);
|
|
404
|
+
|
|
405
|
+
const handleOrganizeDone = useCallback(() => {
|
|
406
|
+
setClosing(true);
|
|
407
|
+
setTimeout(() => {
|
|
408
|
+
setClosing(false);
|
|
409
|
+
onClose();
|
|
410
|
+
im.reset();
|
|
411
|
+
aiOrganize.reset();
|
|
412
|
+
setUndoing(false);
|
|
413
|
+
window.dispatchEvent(new Event('mindos:files-changed'));
|
|
414
|
+
}, 150);
|
|
415
|
+
}, [onClose, im, aiOrganize]);
|
|
416
|
+
|
|
417
|
+
const handleOrganizeUndo = useCallback(async () => {
|
|
418
|
+
setUndoing(true);
|
|
419
|
+
const reverted = await aiOrganize.undoAll();
|
|
420
|
+
setUndoing(false);
|
|
421
|
+
setClosing(true);
|
|
422
|
+
setTimeout(() => {
|
|
423
|
+
setClosing(false);
|
|
424
|
+
onClose();
|
|
425
|
+
im.reset();
|
|
426
|
+
aiOrganize.reset();
|
|
427
|
+
if (reverted > 0) {
|
|
428
|
+
window.dispatchEvent(new Event('mindos:files-changed'));
|
|
429
|
+
}
|
|
430
|
+
}, 150);
|
|
431
|
+
}, [onClose, im, aiOrganize]);
|
|
432
|
+
|
|
433
|
+
const handleOrganizeRetry = useCallback(() => {
|
|
434
|
+
const attachments: LocalAttachment[] = im.validFiles.map(f => ({
|
|
435
|
+
name: f.name,
|
|
436
|
+
content: f.content!,
|
|
437
|
+
}));
|
|
438
|
+
const space = im.targetSpace || undefined;
|
|
439
|
+
const prompt = attachments.length === 1
|
|
440
|
+
? (t.fileImport.digestPromptSingle as (name: string, space?: string) => string)(attachments[0].name, space)
|
|
441
|
+
: (t.fileImport.digestPromptMulti as (n: number, space?: string) => string)(attachments.length, space);
|
|
442
|
+
aiOrganize.reset();
|
|
443
|
+
im.setStep('organizing');
|
|
444
|
+
aiOrganize.start(attachments, prompt);
|
|
445
|
+
}, [im, t, aiOrganize]);
|
|
446
|
+
|
|
110
447
|
useEffect(() => {
|
|
111
448
|
if (im.step === 'done' && im.result) {
|
|
112
449
|
if (im.result.created.length > 0) {
|
|
@@ -126,12 +463,59 @@ export default function ImportModal({ open, onClose, defaultSpace, initialFiles
|
|
|
126
463
|
}
|
|
127
464
|
}, [im.step, im.result, onClose, im]);
|
|
128
465
|
|
|
129
|
-
if (!open && !closing) return null;
|
|
130
|
-
|
|
131
466
|
const hasFiles = im.files.length > 0;
|
|
132
467
|
const isSelectStep = im.step === 'select';
|
|
133
468
|
const isArchiveConfig = im.step === 'archive_config';
|
|
134
469
|
const isImporting = im.step === 'importing';
|
|
470
|
+
const isOrganizing = im.step === 'organizing';
|
|
471
|
+
const isOrganizeReview = im.step === 'organize_review';
|
|
472
|
+
|
|
473
|
+
const { elapsed, displayHint } = useOrganizeTimer(isOrganizing, aiOrganize.stageHint);
|
|
474
|
+
|
|
475
|
+
useEffect(() => {
|
|
476
|
+
if (minimized && im.step === 'organize_review') {
|
|
477
|
+
setMinimized(false);
|
|
478
|
+
}
|
|
479
|
+
}, [minimized, im.step]);
|
|
480
|
+
|
|
481
|
+
if (!open && !closing) return null;
|
|
482
|
+
|
|
483
|
+
const fi = t.fileImport as {
|
|
484
|
+
organizeElapsed: (s: number) => string;
|
|
485
|
+
organizeCancel: string;
|
|
486
|
+
organizeExpand: string;
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
if (minimized && isOrganizing) {
|
|
490
|
+
return (
|
|
491
|
+
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 bg-card border border-border rounded-xl shadow-lg px-4 py-3 max-w-sm">
|
|
492
|
+
<div className="relative shrink-0">
|
|
493
|
+
<Sparkles size={16} className="text-[var(--amber)]" />
|
|
494
|
+
<Loader2 size={10} className="absolute -bottom-0.5 -right-0.5 text-[var(--amber)] animate-spin" />
|
|
495
|
+
</div>
|
|
496
|
+
<span className="text-xs text-foreground truncate">
|
|
497
|
+
{stageHintText(t, displayHint)}
|
|
498
|
+
</span>
|
|
499
|
+
<span className="text-xs text-muted-foreground/60 tabular-nums shrink-0">
|
|
500
|
+
{fi.organizeElapsed(elapsed)}
|
|
501
|
+
</span>
|
|
502
|
+
<button
|
|
503
|
+
type="button"
|
|
504
|
+
onClick={() => setMinimized(false)}
|
|
505
|
+
className="text-xs font-medium text-[var(--amber)] hover:opacity-80 transition-colors shrink-0"
|
|
506
|
+
>
|
|
507
|
+
{fi.organizeExpand}
|
|
508
|
+
</button>
|
|
509
|
+
<button
|
|
510
|
+
type="button"
|
|
511
|
+
onClick={() => { aiOrganize.abort(); aiOrganize.reset(); im.setStep('select'); setMinimized(false); }}
|
|
512
|
+
className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors shrink-0"
|
|
513
|
+
>
|
|
514
|
+
<X size={14} />
|
|
515
|
+
</button>
|
|
516
|
+
</div>
|
|
517
|
+
);
|
|
518
|
+
}
|
|
135
519
|
|
|
136
520
|
return (
|
|
137
521
|
<>
|
|
@@ -141,13 +525,13 @@ export default function ImportModal({ open, onClose, defaultSpace, initialFiles
|
|
|
141
525
|
onClick={(e) => { if (e.target === overlayRef.current) handleClose(); }}
|
|
142
526
|
>
|
|
143
527
|
<div
|
|
144
|
-
className={`w-full max-w-lg bg-card rounded-xl shadow-xl border border-border transition-all duration-200 ${closing ? 'opacity-0 scale-[0.98]' : 'opacity-100 scale-100'}`}
|
|
528
|
+
className={`w-full max-w-lg max-h-[80vh] flex flex-col bg-card rounded-xl shadow-xl border border-border transition-all duration-200 ${closing ? 'opacity-0 scale-[0.98]' : 'opacity-100 scale-100'}`}
|
|
145
529
|
role="dialog"
|
|
146
530
|
aria-modal="true"
|
|
147
531
|
aria-label={t.fileImport.title}
|
|
148
532
|
>
|
|
149
533
|
{/* Header */}
|
|
150
|
-
<div className="flex items-start justify-between px-5 pt-5 pb-2">
|
|
534
|
+
<div className="flex items-start justify-between px-5 pt-5 pb-2 shrink-0">
|
|
151
535
|
<div>
|
|
152
536
|
{isArchiveConfig && (
|
|
153
537
|
<button
|
|
@@ -158,11 +542,20 @@ export default function ImportModal({ open, onClose, defaultSpace, initialFiles
|
|
|
158
542
|
</button>
|
|
159
543
|
)}
|
|
160
544
|
<h2 className="text-base font-semibold text-foreground">
|
|
161
|
-
{
|
|
545
|
+
{isOrganizing ? t.fileImport.organizeTitle
|
|
546
|
+
: isOrganizeReview
|
|
547
|
+
? (aiOrganize.phase === 'error' ? t.fileImport.organizeErrorTitle : t.fileImport.organizeReviewTitle)
|
|
548
|
+
: isArchiveConfig ? t.fileImport.archiveConfigTitle
|
|
549
|
+
: t.fileImport.title}
|
|
162
550
|
</h2>
|
|
163
551
|
{isSelectStep && (
|
|
164
552
|
<p className="text-xs text-muted-foreground mt-0.5">{t.fileImport.subtitle}</p>
|
|
165
553
|
)}
|
|
554
|
+
{isOrganizeReview && aiOrganize.phase === 'done' && aiOrganize.changes.length > 0 && (
|
|
555
|
+
<p className="text-xs text-muted-foreground mt-0.5">
|
|
556
|
+
{t.fileImport.organizeReviewDesc(aiOrganize.changes.filter(c => c.ok).length)}
|
|
557
|
+
</p>
|
|
558
|
+
)}
|
|
166
559
|
</div>
|
|
167
560
|
<button
|
|
168
561
|
onClick={handleClose}
|
|
@@ -173,7 +566,7 @@ export default function ImportModal({ open, onClose, defaultSpace, initialFiles
|
|
|
173
566
|
</button>
|
|
174
567
|
</div>
|
|
175
568
|
|
|
176
|
-
<div className="px-5 pb-5">
|
|
569
|
+
<div className="px-5 pb-5 overflow-y-auto min-h-0">
|
|
177
570
|
{/* DropZone */}
|
|
178
571
|
{isSelectStep && (
|
|
179
572
|
<div
|
|
@@ -226,7 +619,7 @@ export default function ImportModal({ open, onClose, defaultSpace, initialFiles
|
|
|
226
619
|
/>
|
|
227
620
|
|
|
228
621
|
{/* File list */}
|
|
229
|
-
{hasFiles && (
|
|
622
|
+
{hasFiles && !isOrganizing && !isOrganizeReview && (
|
|
230
623
|
<div className="mt-3">
|
|
231
624
|
<div className="flex items-center justify-between mb-1.5">
|
|
232
625
|
<span className="text-xs text-muted-foreground">{t.fileImport.fileCount(im.files.length)}</span>
|
|
@@ -289,9 +682,14 @@ export default function ImportModal({ open, onClose, defaultSpace, initialFiles
|
|
|
289
682
|
const stem = f.name.replace(/\.[^.]+$/, '');
|
|
290
683
|
const targetName = `${stem}.${targetExt}`;
|
|
291
684
|
const targetPath = im.targetSpace ? `${im.targetSpace}/${targetName}` : targetName;
|
|
685
|
+
const hasConflict = conflictFiles.includes(f.name);
|
|
292
686
|
return (
|
|
293
|
-
<div key={`preview-${idx}`} className="text-xs text-muted-foreground px-3">
|
|
294
|
-
|
|
687
|
+
<div key={`preview-${idx}`} className="flex items-center gap-1.5 text-xs text-muted-foreground px-3">
|
|
688
|
+
<span className="truncate">{f.name}</span>
|
|
689
|
+
<span className="text-muted-foreground/50 shrink-0">{t.fileImport.arrowTo}</span>
|
|
690
|
+
<FolderOpen size={12} className="text-muted-foreground/60 shrink-0" />
|
|
691
|
+
<span className={`truncate ${hasConflict ? 'text-[var(--amber)]' : ''}`}>{targetPath}</span>
|
|
692
|
+
{hasConflict && <AlertTriangle size={11} className="text-[var(--amber)] shrink-0" />}
|
|
295
693
|
</div>
|
|
296
694
|
);
|
|
297
695
|
})}
|
|
@@ -342,46 +740,58 @@ export default function ImportModal({ open, onClose, defaultSpace, initialFiles
|
|
|
342
740
|
</select>
|
|
343
741
|
</div>
|
|
344
742
|
|
|
345
|
-
{/* Conflict strategy */}
|
|
346
|
-
|
|
347
|
-
<
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
{
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
743
|
+
{/* Conflict strategy — progressive disclosure */}
|
|
744
|
+
{conflictFiles.length > 0 ? (
|
|
745
|
+
<div>
|
|
746
|
+
<button
|
|
747
|
+
type="button"
|
|
748
|
+
onClick={() => setShowConflictOptions(v => !v)}
|
|
749
|
+
className="flex items-center gap-1.5 text-xs font-medium text-[var(--amber)] hover:opacity-80 transition-colors"
|
|
750
|
+
>
|
|
751
|
+
<AlertTriangle size={12} className="shrink-0" />
|
|
752
|
+
{t.fileImport.conflictsFound(conflictFiles.length)}
|
|
753
|
+
<ChevronDown size={12} className={`shrink-0 transition-transform duration-200 ${showConflictOptions ? 'rotate-180' : ''}`} />
|
|
754
|
+
</button>
|
|
755
|
+
{showConflictOptions && (
|
|
756
|
+
<div className="flex flex-col gap-1.5 mt-2 pl-0.5">
|
|
757
|
+
{([
|
|
758
|
+
{ value: 'rename' as ConflictMode, label: t.fileImport.conflictRename },
|
|
759
|
+
{ value: 'skip' as ConflictMode, label: t.fileImport.conflictSkip },
|
|
760
|
+
{ value: 'overwrite' as ConflictMode, label: t.fileImport.conflictOverwrite },
|
|
761
|
+
]).map(opt => (
|
|
762
|
+
<label
|
|
763
|
+
key={opt.value}
|
|
764
|
+
className={`flex items-center gap-2 py-0.5 text-xs cursor-pointer ${
|
|
765
|
+
opt.value === 'overwrite' ? 'text-error' : 'text-foreground'
|
|
766
|
+
}`}
|
|
767
|
+
>
|
|
768
|
+
<input
|
|
769
|
+
type="radio"
|
|
770
|
+
name="conflict"
|
|
771
|
+
value={opt.value}
|
|
772
|
+
checked={im.conflict === opt.value}
|
|
773
|
+
onChange={() => im.setConflict(opt.value)}
|
|
774
|
+
className="accent-[var(--amber)]"
|
|
775
|
+
/>
|
|
776
|
+
{opt.label}
|
|
777
|
+
{opt.value === 'overwrite' && (
|
|
778
|
+
<AlertTriangle size={11} className="text-error shrink-0" />
|
|
779
|
+
)}
|
|
780
|
+
</label>
|
|
781
|
+
))}
|
|
782
|
+
{im.conflict === 'overwrite' && (
|
|
783
|
+
<p className="text-2xs text-error/80 pl-5">{t.fileImport.overwriteWarn}</p>
|
|
371
784
|
)}
|
|
372
|
-
</
|
|
373
|
-
))}
|
|
374
|
-
{im.conflict === 'overwrite' && (
|
|
375
|
-
<p className="text-2xs text-error/80 pl-6">{t.fileImport.overwriteWarn}</p>
|
|
785
|
+
</div>
|
|
376
786
|
)}
|
|
377
787
|
</div>
|
|
378
|
-
|
|
788
|
+
) : null}
|
|
379
789
|
|
|
380
790
|
{/* Actions */}
|
|
381
791
|
<div className="flex items-center justify-end gap-3 pt-2">
|
|
382
792
|
<button
|
|
383
793
|
onClick={handleClose}
|
|
384
|
-
className="text-
|
|
794
|
+
className="text-xs text-muted-foreground/70 hover:text-muted-foreground transition-colors px-2 py-1.5"
|
|
385
795
|
>
|
|
386
796
|
{t.fileImport.cancel}
|
|
387
797
|
</button>
|
|
@@ -407,6 +817,101 @@ export default function ImportModal({ open, onClose, defaultSpace, initialFiles
|
|
|
407
817
|
</div>
|
|
408
818
|
</div>
|
|
409
819
|
)}
|
|
820
|
+
|
|
821
|
+
{/* AI Organizing (progress) */}
|
|
822
|
+
{isOrganizing && (
|
|
823
|
+
<OrganizingProgress
|
|
824
|
+
aiOrganize={aiOrganize}
|
|
825
|
+
t={t}
|
|
826
|
+
elapsed={elapsed}
|
|
827
|
+
displayHint={displayHint}
|
|
828
|
+
onMinimize={() => setMinimized(true)}
|
|
829
|
+
onCancel={() => { aiOrganize.abort(); aiOrganize.reset(); im.setStep('select'); }}
|
|
830
|
+
/>
|
|
831
|
+
)}
|
|
832
|
+
|
|
833
|
+
{/* AI Organize review */}
|
|
834
|
+
{isOrganizeReview && (
|
|
835
|
+
<div className="mt-4 space-y-4">
|
|
836
|
+
{aiOrganize.phase === 'error' ? (
|
|
837
|
+
<div className="flex flex-col items-center gap-3 py-4">
|
|
838
|
+
<AlertCircle size={28} className="text-error" />
|
|
839
|
+
<p className="text-xs text-muted-foreground text-center max-w-[300px]">{aiOrganize.error}</p>
|
|
840
|
+
<div className="flex gap-3 mt-2">
|
|
841
|
+
<button
|
|
842
|
+
onClick={handleClose}
|
|
843
|
+
className="text-sm text-muted-foreground hover:text-foreground transition-colors px-3 py-2"
|
|
844
|
+
>
|
|
845
|
+
{t.fileImport.cancel}
|
|
846
|
+
</button>
|
|
847
|
+
<button
|
|
848
|
+
onClick={handleOrganizeRetry}
|
|
849
|
+
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium bg-[var(--amber)] text-[var(--amber-foreground)] hover:opacity-90 transition-all duration-200"
|
|
850
|
+
>
|
|
851
|
+
{t.fileImport.organizeRetry}
|
|
852
|
+
</button>
|
|
853
|
+
</div>
|
|
854
|
+
</div>
|
|
855
|
+
) : aiOrganize.changes.length === 0 ? (
|
|
856
|
+
<OrganizeNoChangesView
|
|
857
|
+
summary={aiOrganize.summary}
|
|
858
|
+
toolCallCount={aiOrganize.toolCallCount}
|
|
859
|
+
t={t}
|
|
860
|
+
onDone={handleOrganizeDone}
|
|
861
|
+
/>
|
|
862
|
+
) : (
|
|
863
|
+
<>
|
|
864
|
+
<div className="max-h-[200px] overflow-y-auto space-y-1">
|
|
865
|
+
{aiOrganize.changes.map((c, idx) => (
|
|
866
|
+
<div key={`${c.path}-${idx}`} className="flex items-center gap-2 px-3 py-2 bg-muted/50 rounded-md text-sm">
|
|
867
|
+
{c.action === 'create' ? (
|
|
868
|
+
<FilePlus size={14} className="text-success shrink-0" />
|
|
869
|
+
) : (
|
|
870
|
+
<FileEdit size={14} className="text-[var(--amber)] shrink-0" />
|
|
871
|
+
)}
|
|
872
|
+
<span className="truncate flex-1 text-foreground">{c.path}</span>
|
|
873
|
+
<span className={`text-xs shrink-0 ${c.ok ? 'text-muted-foreground' : 'text-error'}`}>
|
|
874
|
+
{!c.ok ? t.fileImport.organizeFailed
|
|
875
|
+
: c.action === 'create' ? t.fileImport.organizeCreated
|
|
876
|
+
: t.fileImport.organizeUpdated}
|
|
877
|
+
</span>
|
|
878
|
+
</div>
|
|
879
|
+
))}
|
|
880
|
+
</div>
|
|
881
|
+
{aiOrganize.summary?.trim() && (
|
|
882
|
+
<div className={SUMMARY_PROSE}>
|
|
883
|
+
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
|
884
|
+
{cleanSummaryForMarkdown(aiOrganize.summary)}
|
|
885
|
+
</ReactMarkdown>
|
|
886
|
+
</div>
|
|
887
|
+
)}
|
|
888
|
+
<div className="flex items-center justify-end gap-3 pt-2">
|
|
889
|
+
{aiOrganize.changes.some(c => c.action === 'create' && c.ok) && (
|
|
890
|
+
<button
|
|
891
|
+
onClick={handleOrganizeUndo}
|
|
892
|
+
disabled={undoing}
|
|
893
|
+
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors px-3 py-2 disabled:opacity-50"
|
|
894
|
+
>
|
|
895
|
+
{undoing ? (
|
|
896
|
+
<Loader2 size={14} className="animate-spin" />
|
|
897
|
+
) : (
|
|
898
|
+
<Undo2 size={14} />
|
|
899
|
+
)}
|
|
900
|
+
{t.fileImport.organizeUndoAll}
|
|
901
|
+
</button>
|
|
902
|
+
)}
|
|
903
|
+
<button
|
|
904
|
+
onClick={handleOrganizeDone}
|
|
905
|
+
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium bg-[var(--amber)] text-[var(--amber-foreground)] hover:opacity-90 transition-all duration-200"
|
|
906
|
+
>
|
|
907
|
+
<Check size={14} />
|
|
908
|
+
{t.fileImport.organizeDone}
|
|
909
|
+
</button>
|
|
910
|
+
</div>
|
|
911
|
+
</>
|
|
912
|
+
)}
|
|
913
|
+
</div>
|
|
914
|
+
)}
|
|
410
915
|
</div>
|
|
411
916
|
</div>
|
|
412
917
|
</div>
|