@geminilight/mindos 0.5.17 → 0.5.19
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 +5 -4
- package/app/app/api/mcp/agents/route.ts +6 -0
- package/app/app/api/setup/route.ts +64 -1
- package/app/app/globals.css +1 -0
- package/app/components/AskFab.tsx +49 -3
- package/app/components/AskModal.tsx +11 -2
- package/app/components/GuideCard.tsx +361 -0
- package/app/components/HomeContent.tsx +2 -2
- package/app/components/ask/ToolCallBlock.tsx +2 -1
- package/app/components/settings/KnowledgeTab.tsx +64 -2
- package/app/components/settings/McpTab.tsx +109 -10
- package/app/components/setup/StepAI.tsx +9 -1
- package/app/components/setup/index.tsx +4 -0
- package/app/components/setup/types.ts +2 -0
- package/app/hooks/useAskModal.ts +46 -0
- package/app/lib/agent/stream-consumer.ts +4 -2
- package/app/lib/agent/tools.ts +26 -12
- package/app/lib/fs.ts +9 -1
- package/app/lib/i18n.ts +106 -0
- package/app/lib/mcp-agents.ts +29 -0
- package/app/lib/settings.ts +29 -0
- package/app/next-env.d.ts +1 -1
- package/bin/cli.js +2 -2
- package/bin/lib/build.js +2 -2
- package/bin/lib/mcp-spawn.js +2 -1
- package/bin/lib/utils.js +23 -0
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/skills/project-wiki/SKILL.md +80 -74
- package/skills/project-wiki/references/file-reference.md +6 -2
package/app/app/api/ask/route.ts
CHANGED
|
@@ -57,11 +57,12 @@ function convertToModelMessages(messages: FrontendMessage[]): ModelMessage[] {
|
|
|
57
57
|
type: 'tool-call',
|
|
58
58
|
toolCallId: part.toolCallId,
|
|
59
59
|
toolName: part.toolName,
|
|
60
|
-
input: part.input,
|
|
60
|
+
input: part.input ?? {},
|
|
61
61
|
});
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
// Always emit a tool result for every tool call. Orphaned tool calls
|
|
63
|
+
// (running/pending from interrupted streams) get an empty result;
|
|
64
|
+
// without one the API rejects the request.
|
|
65
|
+
completedToolCalls.push(part);
|
|
65
66
|
}
|
|
66
67
|
// 'reasoning' parts are display-only; not sent back to model
|
|
67
68
|
}
|
|
@@ -18,6 +18,12 @@ export async function GET() {
|
|
|
18
18
|
hasProjectScope: !!agent.project,
|
|
19
19
|
hasGlobalScope: !!agent.global,
|
|
20
20
|
preferredTransport: agent.preferredTransport,
|
|
21
|
+
// Snippet generation fields
|
|
22
|
+
format: agent.format ?? 'json',
|
|
23
|
+
configKey: agent.key,
|
|
24
|
+
globalNestedKey: agent.globalNestedKey,
|
|
25
|
+
globalPath: agent.global,
|
|
26
|
+
projectPath: agent.project,
|
|
21
27
|
};
|
|
22
28
|
});
|
|
23
29
|
|
|
@@ -30,6 +30,7 @@ export async function GET() {
|
|
|
30
30
|
openaiApiKey: maskApiKey(s.ai.providers.openai.apiKey),
|
|
31
31
|
openaiModel: s.ai.providers.openai.model,
|
|
32
32
|
openaiBaseUrl: s.ai.providers.openai.baseUrl ?? '',
|
|
33
|
+
guideState: s.guideState ?? null,
|
|
33
34
|
});
|
|
34
35
|
} catch (e) {
|
|
35
36
|
return NextResponse.json(
|
|
@@ -95,9 +96,33 @@ export async function POST(req: NextRequest) {
|
|
|
95
96
|
);
|
|
96
97
|
|
|
97
98
|
// Build config
|
|
99
|
+
// Merge AI config: empty apiKey means "keep existing" — never overwrite a
|
|
100
|
+
// configured key with blank just because the user didn't re-enter it.
|
|
101
|
+
let mergedAi = current.ai;
|
|
102
|
+
if (ai) {
|
|
103
|
+
const inAnthropicKey = ai.providers?.anthropic?.apiKey;
|
|
104
|
+
const inOpenaiKey = ai.providers?.openai?.apiKey;
|
|
105
|
+
mergedAi = {
|
|
106
|
+
provider: ai.provider ?? current.ai.provider,
|
|
107
|
+
providers: {
|
|
108
|
+
anthropic: {
|
|
109
|
+
apiKey: inAnthropicKey || current.ai.providers.anthropic.apiKey,
|
|
110
|
+
model: ai.providers?.anthropic?.model || current.ai.providers.anthropic.model,
|
|
111
|
+
},
|
|
112
|
+
openai: {
|
|
113
|
+
apiKey: inOpenaiKey || current.ai.providers.openai.apiKey,
|
|
114
|
+
model: ai.providers?.openai?.model || current.ai.providers.openai.model,
|
|
115
|
+
baseUrl: ai.providers?.openai?.baseUrl ?? current.ai.providers.openai.baseUrl ?? '',
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
98
121
|
const disabledSkills = body.template === 'zh' ? ['mindos'] : ['mindos-zh'];
|
|
122
|
+
// Determine guide template from setup template
|
|
123
|
+
const guideTemplate = body.template === 'zh' ? 'zh' : body.template === 'empty' ? 'empty' : 'en';
|
|
99
124
|
const config: ServerSettings = {
|
|
100
|
-
ai:
|
|
125
|
+
ai: mergedAi,
|
|
101
126
|
mindRoot: resolvedRoot,
|
|
102
127
|
port: webPort,
|
|
103
128
|
mcpPort: mcpPortNum,
|
|
@@ -106,6 +131,14 @@ export async function POST(req: NextRequest) {
|
|
|
106
131
|
startMode: current.startMode,
|
|
107
132
|
setupPending: false, // clear the flag
|
|
108
133
|
disabledSkills,
|
|
134
|
+
guideState: {
|
|
135
|
+
active: true,
|
|
136
|
+
dismissed: false,
|
|
137
|
+
template: guideTemplate as 'en' | 'zh' | 'empty',
|
|
138
|
+
step1Done: false,
|
|
139
|
+
askedAI: false,
|
|
140
|
+
nextStepIndex: 0,
|
|
141
|
+
},
|
|
109
142
|
};
|
|
110
143
|
|
|
111
144
|
writeSettings(config);
|
|
@@ -124,3 +157,33 @@ export async function POST(req: NextRequest) {
|
|
|
124
157
|
);
|
|
125
158
|
}
|
|
126
159
|
}
|
|
160
|
+
|
|
161
|
+
export async function PATCH(req: NextRequest) {
|
|
162
|
+
try {
|
|
163
|
+
const body = await req.json();
|
|
164
|
+
const { guideState: patch } = body;
|
|
165
|
+
if (!patch || typeof patch !== 'object') {
|
|
166
|
+
return NextResponse.json({ error: 'guideState object required' }, { status: 400 });
|
|
167
|
+
}
|
|
168
|
+
const current = readSettings();
|
|
169
|
+
const existing = current.guideState ?? {
|
|
170
|
+
active: false, dismissed: false, template: 'en' as const,
|
|
171
|
+
step1Done: false, askedAI: false, nextStepIndex: 0,
|
|
172
|
+
};
|
|
173
|
+
// Merge only known fields
|
|
174
|
+
const updated = { ...existing };
|
|
175
|
+
if (typeof patch.dismissed === 'boolean') updated.dismissed = patch.dismissed;
|
|
176
|
+
if (typeof patch.step1Done === 'boolean') updated.step1Done = patch.step1Done;
|
|
177
|
+
if (typeof patch.askedAI === 'boolean') updated.askedAI = patch.askedAI;
|
|
178
|
+
if (typeof patch.nextStepIndex === 'number' && patch.nextStepIndex >= 0) updated.nextStepIndex = patch.nextStepIndex;
|
|
179
|
+
if (typeof patch.active === 'boolean') updated.active = patch.active;
|
|
180
|
+
|
|
181
|
+
writeSettings({ ...current, guideState: updated });
|
|
182
|
+
return NextResponse.json({ ok: true, guideState: updated });
|
|
183
|
+
} catch (e) {
|
|
184
|
+
return NextResponse.json(
|
|
185
|
+
{ error: e instanceof Error ? e.message : String(e) },
|
|
186
|
+
{ status: 500 },
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
package/app/app/globals.css
CHANGED
|
@@ -288,6 +288,7 @@ body {
|
|
|
288
288
|
/* Micro type scale: text-2xs = 10px (between nothing and text-xs 12px) */
|
|
289
289
|
@layer utilities {
|
|
290
290
|
.text-2xs { font-size: 10px; line-height: 1.4; }
|
|
291
|
+
.animate-spin-slow { animation: spin 3s linear infinite; }
|
|
291
292
|
}
|
|
292
293
|
|
|
293
294
|
/* Hide scrollbar but keep scroll functionality */
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState } from 'react';
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
4
4
|
import { usePathname } from 'next/navigation';
|
|
5
5
|
import { Sparkles } from 'lucide-react';
|
|
6
6
|
import AskModal from './AskModal';
|
|
7
|
+
import { useAskModal } from '@/hooks/useAskModal';
|
|
7
8
|
|
|
8
9
|
export default function AskFab() {
|
|
9
10
|
const [open, setOpen] = useState(false);
|
|
@@ -12,10 +13,49 @@ export default function AskFab() {
|
|
|
12
13
|
? pathname.slice('/view/'.length).split('/').map(decodeURIComponent).join('/')
|
|
13
14
|
: undefined;
|
|
14
15
|
|
|
16
|
+
// Listen to useAskModal store for cross-component open requests (e.g. from GuideCard)
|
|
17
|
+
const askModal = useAskModal();
|
|
18
|
+
const [initialMessage, setInitialMessage] = useState('');
|
|
19
|
+
const [openSource, setOpenSource] = useState<'user' | 'guide' | 'guide-next'>('user');
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (askModal.open) {
|
|
23
|
+
setInitialMessage(askModal.initialMessage);
|
|
24
|
+
setOpenSource(askModal.source);
|
|
25
|
+
setOpen(true);
|
|
26
|
+
askModal.close(); // Reset store state after consuming
|
|
27
|
+
}
|
|
28
|
+
}, [askModal.open, askModal.initialMessage, askModal.source, askModal.close]);
|
|
29
|
+
|
|
30
|
+
const handleClose = useCallback(() => {
|
|
31
|
+
setOpen(false);
|
|
32
|
+
setInitialMessage('');
|
|
33
|
+
setOpenSource('user');
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
// Dispatch correct PATCH based on how the modal was opened
|
|
37
|
+
const handleFirstMessage = useCallback(() => {
|
|
38
|
+
const notifyGuide = () => window.dispatchEvent(new Event('guide-state-updated'));
|
|
39
|
+
|
|
40
|
+
if (openSource === 'guide') {
|
|
41
|
+
// Task ② completion: mark askedAI
|
|
42
|
+
fetch('/api/setup', {
|
|
43
|
+
method: 'PATCH',
|
|
44
|
+
headers: { 'Content-Type': 'application/json' },
|
|
45
|
+
body: JSON.stringify({ guideState: { askedAI: true } }),
|
|
46
|
+
}).then(notifyGuide).catch(() => {});
|
|
47
|
+
} else if (openSource === 'guide-next') {
|
|
48
|
+
// Next-step advancement: GuideCard already PATCHed nextStepIndex optimistically.
|
|
49
|
+
// Just notify GuideCard to re-fetch for consistency; no additional PATCH needed.
|
|
50
|
+
notifyGuide();
|
|
51
|
+
}
|
|
52
|
+
// For 'user' source: no guide action needed
|
|
53
|
+
}, [openSource]);
|
|
54
|
+
|
|
15
55
|
return (
|
|
16
56
|
<>
|
|
17
57
|
<button
|
|
18
|
-
onClick={() => setOpen(true)}
|
|
58
|
+
onClick={() => { setInitialMessage(''); setOpenSource('user'); setOpen(true); }}
|
|
19
59
|
className="
|
|
20
60
|
group
|
|
21
61
|
fixed z-40
|
|
@@ -53,7 +93,13 @@ export default function AskFab() {
|
|
|
53
93
|
</span>
|
|
54
94
|
</button>
|
|
55
95
|
|
|
56
|
-
<AskModal
|
|
96
|
+
<AskModal
|
|
97
|
+
open={open}
|
|
98
|
+
onClose={handleClose}
|
|
99
|
+
currentFile={currentFile}
|
|
100
|
+
initialMessage={initialMessage}
|
|
101
|
+
onFirstMessage={handleFirstMessage}
|
|
102
|
+
/>
|
|
57
103
|
</>
|
|
58
104
|
);
|
|
59
105
|
}
|
|
@@ -17,11 +17,14 @@ interface AskModalProps {
|
|
|
17
17
|
open: boolean;
|
|
18
18
|
onClose: () => void;
|
|
19
19
|
currentFile?: string;
|
|
20
|
+
initialMessage?: string;
|
|
21
|
+
onFirstMessage?: () => void;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
|
-
export default function AskModal({ open, onClose, currentFile }: AskModalProps) {
|
|
24
|
+
export default function AskModal({ open, onClose, currentFile, initialMessage, onFirstMessage }: AskModalProps) {
|
|
23
25
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
24
26
|
const abortRef = useRef<AbortController | null>(null);
|
|
27
|
+
const firstMessageFired = useRef(false);
|
|
25
28
|
const { t } = useLocale();
|
|
26
29
|
|
|
27
30
|
const [input, setInput] = useState('');
|
|
@@ -43,7 +46,8 @@ export default function AskModal({ open, onClose, currentFile }: AskModalProps)
|
|
|
43
46
|
if (cancelled) return;
|
|
44
47
|
await session.initSessions();
|
|
45
48
|
})();
|
|
46
|
-
setInput('');
|
|
49
|
+
setInput(initialMessage || '');
|
|
50
|
+
firstMessageFired.current = false;
|
|
47
51
|
setAttachedFiles(currentFile ? [currentFile] : []);
|
|
48
52
|
upload.clearAttachments();
|
|
49
53
|
mention.resetMention();
|
|
@@ -119,6 +123,11 @@ export default function AskModal({ open, onClose, currentFile }: AskModalProps)
|
|
|
119
123
|
const requestMessages = [...session.messages, userMsg];
|
|
120
124
|
session.setMessages([...requestMessages, { role: 'assistant', content: '' }]);
|
|
121
125
|
setInput('');
|
|
126
|
+
// Notify guide card on first user message (ref prevents duplicate fires during re-render)
|
|
127
|
+
if (onFirstMessage && !firstMessageFired.current) {
|
|
128
|
+
firstMessageFired.current = true;
|
|
129
|
+
onFirstMessage();
|
|
130
|
+
}
|
|
122
131
|
setAttachedFiles(currentFile ? [currentFile] : []);
|
|
123
132
|
setIsLoading(true);
|
|
124
133
|
setLoadingPhase('connecting');
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
4
|
+
import { X, Sparkles, FolderOpen, MessageCircle, RefreshCw, Check, ChevronRight } from 'lucide-react';
|
|
5
|
+
import { useLocale } from '@/lib/LocaleContext';
|
|
6
|
+
import { openAskModal } from '@/hooks/useAskModal';
|
|
7
|
+
import type { GuideState } from '@/lib/settings';
|
|
8
|
+
|
|
9
|
+
const DIR_ICONS: Record<string, string> = {
|
|
10
|
+
profile: '👤', notes: '📝', connections: '🔗',
|
|
11
|
+
workflows: '🔄', resources: '📚', projects: '🚀',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const EMPTY_FILES = ['INSTRUCTION.md', 'README.md', 'CONFIG.json'];
|
|
15
|
+
|
|
16
|
+
interface GuideCardProps {
|
|
17
|
+
/** Called when user clicks a file/dir to open it in FileView */
|
|
18
|
+
onNavigate?: (path: string) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default function GuideCard({ onNavigate }: GuideCardProps) {
|
|
22
|
+
const { t } = useLocale();
|
|
23
|
+
const g = t.guide;
|
|
24
|
+
|
|
25
|
+
const [guideState, setGuideState] = useState<GuideState | null>(null);
|
|
26
|
+
const [expanded, setExpanded] = useState<'kb' | 'ai' | 'sync' | null>(null);
|
|
27
|
+
const [isFirstVisit, setIsFirstVisit] = useState(false);
|
|
28
|
+
const [browsedCount, setBrowsedCount] = useState(0);
|
|
29
|
+
|
|
30
|
+
// Fetch guide state from backend
|
|
31
|
+
const fetchGuideState = useCallback(() => {
|
|
32
|
+
fetch('/api/setup')
|
|
33
|
+
.then(r => r.json())
|
|
34
|
+
.then(data => {
|
|
35
|
+
const gs = data.guideState;
|
|
36
|
+
if (gs?.active && !gs.dismissed) {
|
|
37
|
+
setGuideState(gs);
|
|
38
|
+
if (gs.step1Done) setBrowsedCount(1);
|
|
39
|
+
} else {
|
|
40
|
+
// Guide inactive or dismissed — clear local state
|
|
41
|
+
setGuideState(null);
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
.catch(() => {});
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
fetchGuideState();
|
|
49
|
+
|
|
50
|
+
// ?welcome=1 → first visit, auto-expand explore task
|
|
51
|
+
const params = new URLSearchParams(window.location.search);
|
|
52
|
+
if (params.get('welcome') === '1') {
|
|
53
|
+
setIsFirstVisit(true);
|
|
54
|
+
const url = new URL(window.location.href);
|
|
55
|
+
url.searchParams.delete('welcome');
|
|
56
|
+
window.history.replaceState({}, '', url.pathname + (url.search || ''));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Re-fetch when guide state is updated (e.g. after AskFab patches askedAI)
|
|
60
|
+
const handleGuideUpdate = () => fetchGuideState();
|
|
61
|
+
window.addEventListener('focus', handleGuideUpdate);
|
|
62
|
+
window.addEventListener('guide-state-updated', handleGuideUpdate);
|
|
63
|
+
return () => {
|
|
64
|
+
window.removeEventListener('focus', handleGuideUpdate);
|
|
65
|
+
window.removeEventListener('guide-state-updated', handleGuideUpdate);
|
|
66
|
+
};
|
|
67
|
+
}, [fetchGuideState]);
|
|
68
|
+
|
|
69
|
+
// Auto-expand KB task on first visit
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (isFirstVisit && guideState && !guideState.step1Done) {
|
|
72
|
+
setExpanded('kb');
|
|
73
|
+
}
|
|
74
|
+
}, [isFirstVisit, guideState]);
|
|
75
|
+
|
|
76
|
+
// Patch guideState to backend
|
|
77
|
+
const patchGuide = useCallback((patch: Partial<GuideState>) => {
|
|
78
|
+
setGuideState(prev => prev ? { ...prev, ...patch } : prev);
|
|
79
|
+
fetch('/api/setup', {
|
|
80
|
+
method: 'PATCH',
|
|
81
|
+
headers: { 'Content-Type': 'application/json' },
|
|
82
|
+
body: JSON.stringify({ guideState: patch }),
|
|
83
|
+
}).catch(() => {});
|
|
84
|
+
}, []);
|
|
85
|
+
|
|
86
|
+
const handleDismiss = useCallback(() => {
|
|
87
|
+
patchGuide({ dismissed: true });
|
|
88
|
+
setGuideState(null);
|
|
89
|
+
}, [patchGuide]);
|
|
90
|
+
|
|
91
|
+
const handleFileOpen = useCallback((path: string) => {
|
|
92
|
+
onNavigate?.(path);
|
|
93
|
+
if (browsedCount === 0) {
|
|
94
|
+
setBrowsedCount(1);
|
|
95
|
+
patchGuide({ step1Done: true });
|
|
96
|
+
// Collapse after a beat
|
|
97
|
+
setTimeout(() => setExpanded(null), 300);
|
|
98
|
+
}
|
|
99
|
+
}, [browsedCount, patchGuide, onNavigate]);
|
|
100
|
+
|
|
101
|
+
const handleSkipKB = useCallback(() => {
|
|
102
|
+
setBrowsedCount(1);
|
|
103
|
+
patchGuide({ step1Done: true });
|
|
104
|
+
setExpanded(null);
|
|
105
|
+
}, [patchGuide]);
|
|
106
|
+
|
|
107
|
+
const handleStartAI = useCallback(() => {
|
|
108
|
+
const gs = guideState;
|
|
109
|
+
const isEmpty = gs?.template === 'empty';
|
|
110
|
+
const prompt = isEmpty ? g.ai.promptEmpty : g.ai.prompt;
|
|
111
|
+
openAskModal(prompt, 'guide');
|
|
112
|
+
// Don't optimistically set askedAI here — wait until user actually sends a message
|
|
113
|
+
// AskFab.onFirstMessage will PATCH askedAI:true
|
|
114
|
+
}, [guideState, g]);
|
|
115
|
+
|
|
116
|
+
const handleNextStepClick = useCallback(() => {
|
|
117
|
+
if (!guideState) return;
|
|
118
|
+
const idx = guideState.nextStepIndex;
|
|
119
|
+
const steps = g.done.steps;
|
|
120
|
+
if (idx < steps.length) {
|
|
121
|
+
openAskModal(steps[idx].prompt, 'guide-next');
|
|
122
|
+
// Optimistic local update — AskFab will persist to backend on first message
|
|
123
|
+
patchGuide({ nextStepIndex: idx + 1 });
|
|
124
|
+
}
|
|
125
|
+
}, [guideState, g, patchGuide]);
|
|
126
|
+
|
|
127
|
+
const handleSyncClick = useCallback(() => {
|
|
128
|
+
// Dispatch ⌘, to open Settings modal
|
|
129
|
+
window.dispatchEvent(new KeyboardEvent('keydown', { key: ',', metaKey: true, bubbles: true }));
|
|
130
|
+
}, []);
|
|
131
|
+
|
|
132
|
+
// Auto-dismiss final state after 8 seconds
|
|
133
|
+
const autoDismissRef = useRef<ReturnType<typeof setTimeout>>(null);
|
|
134
|
+
const step1Done_ = guideState?.step1Done;
|
|
135
|
+
const step2Done_ = guideState?.askedAI;
|
|
136
|
+
const nextIdx_ = guideState?.nextStepIndex ?? 0;
|
|
137
|
+
const allDone_ = step1Done_ && step2Done_;
|
|
138
|
+
const allNextDone_ = allDone_ && nextIdx_ >= g.done.steps.length;
|
|
139
|
+
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
if (allNextDone_) {
|
|
142
|
+
autoDismissRef.current = setTimeout(() => handleDismiss(), 8000);
|
|
143
|
+
}
|
|
144
|
+
return () => { if (autoDismissRef.current) clearTimeout(autoDismissRef.current); };
|
|
145
|
+
}, [allNextDone_, handleDismiss]);
|
|
146
|
+
|
|
147
|
+
// Don't render if no active guide
|
|
148
|
+
if (!guideState) return null;
|
|
149
|
+
|
|
150
|
+
const step1Done = guideState.step1Done;
|
|
151
|
+
const step2Done = guideState.askedAI;
|
|
152
|
+
const allDone = step1Done && step2Done;
|
|
153
|
+
const nextIdx = guideState.nextStepIndex;
|
|
154
|
+
const nextSteps = g.done.steps;
|
|
155
|
+
const allNextDone = nextIdx >= nextSteps.length;
|
|
156
|
+
const isEmptyTemplate = guideState.template === 'empty';
|
|
157
|
+
|
|
158
|
+
// After all next-steps done → final state (auto-dismisses after 8s)
|
|
159
|
+
if (allDone && allNextDone) {
|
|
160
|
+
return (
|
|
161
|
+
<div className="mb-6 rounded-xl border px-5 py-4 flex items-center gap-3 animate-in fade-in duration-300"
|
|
162
|
+
style={{ background: 'var(--amber-subtle, rgba(200,135,30,0.08))', borderColor: 'var(--amber)' }}>
|
|
163
|
+
<Sparkles size={16} className="animate-spin-slow" style={{ color: 'var(--amber)' }} />
|
|
164
|
+
<span className="text-sm font-semibold flex-1" style={{ color: 'var(--foreground)' }}>
|
|
165
|
+
✨ {g.done.titleFinal}
|
|
166
|
+
</span>
|
|
167
|
+
<button onClick={handleDismiss} className="p-1 rounded hover:bg-muted transition-colors"
|
|
168
|
+
style={{ color: 'var(--muted-foreground)' }}>
|
|
169
|
+
<X size={14} />
|
|
170
|
+
</button>
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Collapsed done state with next-step prompts
|
|
176
|
+
if (allDone) {
|
|
177
|
+
const step = nextSteps[nextIdx];
|
|
178
|
+
return (
|
|
179
|
+
<div className="mb-6 rounded-xl border px-5 py-4 animate-in fade-in duration-300"
|
|
180
|
+
style={{ background: 'var(--amber-subtle, rgba(200,135,30,0.08))', borderColor: 'var(--amber)' }}>
|
|
181
|
+
<div className="flex items-center gap-3">
|
|
182
|
+
<Sparkles size={16} style={{ color: 'var(--amber)' }} />
|
|
183
|
+
<span className="text-sm font-semibold flex-1" style={{ color: 'var(--foreground)' }}>
|
|
184
|
+
🎉 {g.done.title}
|
|
185
|
+
</span>
|
|
186
|
+
<button onClick={handleDismiss} className="p-1 rounded hover:bg-muted transition-colors"
|
|
187
|
+
style={{ color: 'var(--muted-foreground)' }}>
|
|
188
|
+
<X size={14} />
|
|
189
|
+
</button>
|
|
190
|
+
</div>
|
|
191
|
+
{step && (
|
|
192
|
+
<button
|
|
193
|
+
onClick={handleNextStepClick}
|
|
194
|
+
className="mt-3 flex items-center gap-2 text-sm transition-colors hover:opacity-80 cursor-pointer animate-in fade-in slide-in-from-left-2 duration-300"
|
|
195
|
+
style={{ color: 'var(--amber)' }}
|
|
196
|
+
>
|
|
197
|
+
<ChevronRight size={14} />
|
|
198
|
+
<span>{step.hint}</span>
|
|
199
|
+
</button>
|
|
200
|
+
)}
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Main guide card with 3 tasks
|
|
206
|
+
return (
|
|
207
|
+
<div className="mb-6 rounded-xl border overflow-hidden"
|
|
208
|
+
style={{ background: 'var(--amber-subtle, rgba(200,135,30,0.08))', borderColor: 'var(--amber)' }}>
|
|
209
|
+
|
|
210
|
+
{/* Header */}
|
|
211
|
+
<div className="flex items-center gap-3 px-5 pt-4 pb-2">
|
|
212
|
+
<Sparkles size={16} style={{ color: 'var(--amber)' }} />
|
|
213
|
+
<span className="text-sm font-semibold flex-1 font-display" style={{ color: 'var(--foreground)' }}>
|
|
214
|
+
{g.title}
|
|
215
|
+
</span>
|
|
216
|
+
<button onClick={handleDismiss} className="p-1 rounded hover:bg-muted transition-colors"
|
|
217
|
+
style={{ color: 'var(--muted-foreground)' }}>
|
|
218
|
+
<X size={14} />
|
|
219
|
+
</button>
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
{/* Task cards */}
|
|
223
|
+
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 px-5 py-3">
|
|
224
|
+
{/* ① Explore KB */}
|
|
225
|
+
<TaskCard
|
|
226
|
+
icon={<FolderOpen size={16} />}
|
|
227
|
+
title={g.kb.title}
|
|
228
|
+
cta={g.kb.cta}
|
|
229
|
+
done={step1Done}
|
|
230
|
+
active={expanded === 'kb'}
|
|
231
|
+
onClick={() => step1Done ? null : setExpanded(expanded === 'kb' ? null : 'kb')}
|
|
232
|
+
/>
|
|
233
|
+
{/* ② Chat with AI */}
|
|
234
|
+
<TaskCard
|
|
235
|
+
icon={<MessageCircle size={16} />}
|
|
236
|
+
title={g.ai.title}
|
|
237
|
+
cta={g.ai.cta}
|
|
238
|
+
done={step2Done}
|
|
239
|
+
active={expanded === 'ai'}
|
|
240
|
+
onClick={() => {
|
|
241
|
+
if (!step2Done) handleStartAI();
|
|
242
|
+
}}
|
|
243
|
+
/>
|
|
244
|
+
{/* ③ Sync (optional) */}
|
|
245
|
+
<TaskCard
|
|
246
|
+
icon={<RefreshCw size={16} />}
|
|
247
|
+
title={g.sync.title}
|
|
248
|
+
cta={g.sync.cta}
|
|
249
|
+
done={false}
|
|
250
|
+
optional={g.sync.optional}
|
|
251
|
+
active={false}
|
|
252
|
+
onClick={handleSyncClick}
|
|
253
|
+
/>
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
{/* Expanded content: Explore KB */}
|
|
257
|
+
{expanded === 'kb' && !step1Done && (
|
|
258
|
+
<div className="px-5 pb-4 animate-in slide-in-from-top-2 duration-200">
|
|
259
|
+
<div className="rounded-lg border p-4" style={{ background: 'var(--card)', borderColor: 'var(--border)' }}>
|
|
260
|
+
<p className="text-xs mb-3" style={{ color: 'var(--muted-foreground)' }}>
|
|
261
|
+
{isEmptyTemplate ? g.kb.emptyDesc : g.kb.fullDesc}
|
|
262
|
+
</p>
|
|
263
|
+
|
|
264
|
+
{isEmptyTemplate ? (
|
|
265
|
+
<div className="flex flex-col gap-1.5">
|
|
266
|
+
{EMPTY_FILES.map(file => (
|
|
267
|
+
<button key={file} onClick={() => handleFileOpen(file)}
|
|
268
|
+
className="text-left text-xs px-3 py-2 rounded-lg border transition-colors hover:border-amber-500/30 hover:bg-muted/50"
|
|
269
|
+
style={{ borderColor: 'var(--border)', color: 'var(--foreground)' }}>
|
|
270
|
+
📄 {(g.kb.emptyFiles as Record<string, string>)[file.split('.')[0].toLowerCase()] || file}
|
|
271
|
+
</button>
|
|
272
|
+
))}
|
|
273
|
+
<p className="text-xs mt-2" style={{ color: 'var(--muted-foreground)', opacity: 0.7 }}>
|
|
274
|
+
{g.kb.emptyHint}
|
|
275
|
+
</p>
|
|
276
|
+
</div>
|
|
277
|
+
) : (
|
|
278
|
+
<>
|
|
279
|
+
<div className="grid grid-cols-2 sm:grid-cols-3 gap-1.5">
|
|
280
|
+
{Object.entries(DIR_ICONS).map(([key, icon]) => (
|
|
281
|
+
<button key={key} onClick={() => handleFileOpen(key.charAt(0).toUpperCase() + key.slice(1))}
|
|
282
|
+
className="text-left text-xs px-3 py-2 rounded-lg border transition-colors hover:border-amber-500/30 hover:bg-muted/50"
|
|
283
|
+
style={{ borderColor: 'var(--border)', color: 'var(--foreground)' }}>
|
|
284
|
+
<span className="mr-1.5">{icon}</span>
|
|
285
|
+
<span className="capitalize">{key}</span>
|
|
286
|
+
<span className="block text-2xs mt-0.5" style={{ color: 'var(--muted-foreground)' }}>
|
|
287
|
+
{(g.kb.dirs as Record<string, string>)[key]}
|
|
288
|
+
</span>
|
|
289
|
+
</button>
|
|
290
|
+
))}
|
|
291
|
+
</div>
|
|
292
|
+
<p className="text-xs mt-3" style={{ color: 'var(--amber)' }}>
|
|
293
|
+
💡 {g.kb.instructionHint}
|
|
294
|
+
</p>
|
|
295
|
+
</>
|
|
296
|
+
)}
|
|
297
|
+
|
|
298
|
+
<div className="flex items-center justify-between mt-3 pt-3" style={{ borderTop: '1px solid var(--border)' }}>
|
|
299
|
+
<span className="text-xs" style={{ color: 'var(--muted-foreground)' }}>
|
|
300
|
+
{g.kb.progress(browsedCount)}
|
|
301
|
+
</span>
|
|
302
|
+
<button onClick={handleSkipKB}
|
|
303
|
+
className="text-xs px-3 py-1 rounded-lg transition-colors hover:bg-muted"
|
|
304
|
+
style={{ color: 'var(--muted-foreground)' }}>
|
|
305
|
+
{g.skip}
|
|
306
|
+
</button>
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
</div>
|
|
310
|
+
)}
|
|
311
|
+
</div>
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Expose callback for AskModal integration
|
|
316
|
+
export { type GuideCardProps };
|
|
317
|
+
|
|
318
|
+
// Reusable sub-component
|
|
319
|
+
function TaskCard({ icon, title, cta, done, active, optional, onClick }: {
|
|
320
|
+
icon: React.ReactNode;
|
|
321
|
+
title: string;
|
|
322
|
+
cta: string;
|
|
323
|
+
done: boolean;
|
|
324
|
+
active: boolean;
|
|
325
|
+
optional?: string;
|
|
326
|
+
onClick: () => void;
|
|
327
|
+
}) {
|
|
328
|
+
return (
|
|
329
|
+
<button
|
|
330
|
+
onClick={onClick}
|
|
331
|
+
disabled={done}
|
|
332
|
+
className={`
|
|
333
|
+
flex flex-col items-center gap-1.5 px-3 py-3 rounded-lg border text-center
|
|
334
|
+
transition-all duration-150
|
|
335
|
+
${done ? 'opacity-60' : 'hover:border-amber-500/30 hover:bg-muted/50 cursor-pointer'}
|
|
336
|
+
${active ? 'border-amber-500/40 bg-muted/50' : ''}
|
|
337
|
+
`}
|
|
338
|
+
style={{ borderColor: done || active ? 'var(--amber)' : 'var(--border)' }}
|
|
339
|
+
>
|
|
340
|
+
<span
|
|
341
|
+
className={done ? 'animate-in zoom-in-50 duration-300' : ''}
|
|
342
|
+
style={{ color: done ? 'var(--success)' : 'var(--amber)' }}
|
|
343
|
+
>
|
|
344
|
+
{done ? <Check size={16} /> : icon}
|
|
345
|
+
</span>
|
|
346
|
+
<span className="text-xs font-medium" style={{ color: 'var(--foreground)' }}>
|
|
347
|
+
{title}
|
|
348
|
+
</span>
|
|
349
|
+
{optional && (
|
|
350
|
+
<span className="text-2xs px-1.5 py-0.5 rounded-full" style={{ background: 'var(--muted)', color: 'var(--muted-foreground)' }}>
|
|
351
|
+
{optional}
|
|
352
|
+
</span>
|
|
353
|
+
)}
|
|
354
|
+
{!done && !optional && (
|
|
355
|
+
<span className="text-2xs" style={{ color: 'var(--amber)' }}>
|
|
356
|
+
{cta} →
|
|
357
|
+
</span>
|
|
358
|
+
)}
|
|
359
|
+
</button>
|
|
360
|
+
);
|
|
361
|
+
}
|
|
@@ -8,7 +8,7 @@ import { encodePath, relativeTime } from '@/lib/utils';
|
|
|
8
8
|
import { getAllRenderers } from '@/lib/renderers/registry';
|
|
9
9
|
import '@/lib/renderers/index'; // registers all renderers
|
|
10
10
|
import OnboardingView from './OnboardingView';
|
|
11
|
-
import
|
|
11
|
+
import GuideCard from './GuideCard';
|
|
12
12
|
|
|
13
13
|
interface RecentFile {
|
|
14
14
|
path: string;
|
|
@@ -70,7 +70,7 @@ export default function HomeContent({ recent, existingFiles }: { recent: RecentF
|
|
|
70
70
|
|
|
71
71
|
return (
|
|
72
72
|
<div className="content-width px-4 md:px-6 py-8 md:py-12">
|
|
73
|
-
<
|
|
73
|
+
<GuideCard onNavigate={(path) => { window.location.href = `/view/${encodeURIComponent(path)}`; }} />
|
|
74
74
|
{/* Hero */}
|
|
75
75
|
<div className="mb-10">
|
|
76
76
|
<div className="flex items-center gap-2 mb-3">
|
|
@@ -41,7 +41,8 @@ function formatInput(input: unknown): string {
|
|
|
41
41
|
return parts.join(', ');
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
function truncateOutput(output: string, maxLen = 200): string {
|
|
44
|
+
function truncateOutput(output: string | undefined, maxLen = 200): string {
|
|
45
|
+
if (!output) return '';
|
|
45
46
|
if (output.length <= maxLen) return output;
|
|
46
47
|
return output.slice(0, maxLen) + '…';
|
|
47
48
|
}
|