@geminilight/mindos 0.5.28 → 0.5.30
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/update/route.ts +41 -0
- package/app/app/explore/page.tsx +12 -0
- package/app/components/ActivityBar.tsx +14 -7
- package/app/components/GuideCard.tsx +21 -7
- package/app/components/HomeContent.tsx +31 -97
- package/app/components/KeyboardShortcuts.tsx +102 -0
- package/app/components/Panel.tsx +12 -7
- package/app/components/SidebarLayout.tsx +21 -1
- package/app/components/UpdateBanner.tsx +19 -21
- package/app/components/explore/ExploreContent.tsx +100 -0
- package/app/components/explore/UseCaseCard.tsx +50 -0
- package/app/components/explore/use-cases.ts +30 -0
- package/app/components/panels/AgentsPanel.tsx +268 -131
- package/app/components/panels/PluginsPanel.tsx +87 -27
- package/app/components/settings/AiTab.tsx +5 -3
- package/app/components/settings/McpSkillsSection.tsx +12 -0
- package/app/components/settings/McpTab.tsx +28 -30
- package/app/components/settings/SettingsContent.tsx +5 -2
- package/app/components/settings/UpdateTab.tsx +195 -0
- package/app/components/settings/types.ts +1 -1
- package/app/components/walkthrough/WalkthroughOverlay.tsx +224 -0
- package/app/components/walkthrough/WalkthroughProvider.tsx +133 -0
- package/app/components/walkthrough/WalkthroughTooltip.tsx +129 -0
- package/app/components/walkthrough/index.ts +3 -0
- package/app/components/walkthrough/steps.ts +21 -0
- package/app/hooks/useMcpData.tsx +166 -0
- package/app/lib/i18n-en.ts +182 -5
- package/app/lib/i18n-zh.ts +181 -4
- package/app/lib/mcp-snippets.ts +103 -0
- package/app/lib/settings.ts +4 -0
- package/app/next-env.d.ts +1 -1
- package/app/package.json +1 -0
- package/package.json +1 -1
- package/app/components/settings/McpServerStatus.tsx +0 -274
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useId } from 'react';
|
|
4
|
+
import { useLocale } from '@/lib/LocaleContext';
|
|
5
|
+
import { useWalkthrough } from './WalkthroughProvider';
|
|
6
|
+
import { walkthroughSteps } from './steps';
|
|
7
|
+
import WalkthroughTooltip from './WalkthroughTooltip';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Full-screen overlay with SVG spotlight mask.
|
|
11
|
+
* Finds the target element via data-walkthrough attribute, measures it,
|
|
12
|
+
* and cuts a transparent rect into the semi-transparent overlay.
|
|
13
|
+
*/
|
|
14
|
+
export default function WalkthroughOverlay() {
|
|
15
|
+
const wt = useWalkthrough();
|
|
16
|
+
const [targetRect, setTargetRect] = useState<DOMRect | null>(null);
|
|
17
|
+
const [isMobile, setIsMobile] = useState(false);
|
|
18
|
+
const maskId = useId();
|
|
19
|
+
|
|
20
|
+
const step = wt ? walkthroughSteps[wt.currentStep] : null;
|
|
21
|
+
|
|
22
|
+
const measureTarget = useCallback(() => {
|
|
23
|
+
if (!step) return;
|
|
24
|
+
const el = document.querySelector(`[data-walkthrough="${step.anchor}"]`);
|
|
25
|
+
if (el) {
|
|
26
|
+
setTargetRect(el.getBoundingClientRect());
|
|
27
|
+
} else {
|
|
28
|
+
setTargetRect(null);
|
|
29
|
+
}
|
|
30
|
+
}, [step]);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
setIsMobile(window.innerWidth < 768);
|
|
34
|
+
measureTarget();
|
|
35
|
+
|
|
36
|
+
const handleResize = () => {
|
|
37
|
+
setIsMobile(window.innerWidth < 768);
|
|
38
|
+
measureTarget();
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
window.addEventListener('resize', handleResize);
|
|
42
|
+
window.addEventListener('scroll', measureTarget, true);
|
|
43
|
+
return () => {
|
|
44
|
+
window.removeEventListener('resize', handleResize);
|
|
45
|
+
window.removeEventListener('scroll', measureTarget, true);
|
|
46
|
+
};
|
|
47
|
+
}, [measureTarget]);
|
|
48
|
+
|
|
49
|
+
// Re-measure when step changes
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
measureTarget();
|
|
52
|
+
// Slight delay to account for animations
|
|
53
|
+
const timer = setTimeout(measureTarget, 100);
|
|
54
|
+
return () => clearTimeout(timer);
|
|
55
|
+
}, [wt?.currentStep, measureTarget]);
|
|
56
|
+
|
|
57
|
+
// ESC to dismiss — depend on skip only, not the entire context object
|
|
58
|
+
const skipFn = wt?.skip;
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (!skipFn) return;
|
|
61
|
+
const handler = (e: KeyboardEvent) => {
|
|
62
|
+
if (e.key === 'Escape') {
|
|
63
|
+
e.stopPropagation();
|
|
64
|
+
skipFn();
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
window.addEventListener('keydown', handler, true);
|
|
68
|
+
return () => window.removeEventListener('keydown', handler, true);
|
|
69
|
+
}, [skipFn]);
|
|
70
|
+
|
|
71
|
+
if (!wt || !step) return null;
|
|
72
|
+
|
|
73
|
+
// Mobile: bottom sheet instead of spotlight
|
|
74
|
+
if (isMobile) {
|
|
75
|
+
return <MobileWalkthroughSheet />;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const PAD = 6; // padding around spotlight rect
|
|
79
|
+
const RADIUS = 8;
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<>
|
|
83
|
+
{/* SVG overlay with spotlight hole */}
|
|
84
|
+
<svg
|
|
85
|
+
className="fixed inset-0 z-[100] pointer-events-auto"
|
|
86
|
+
width="100%"
|
|
87
|
+
height="100%"
|
|
88
|
+
onClick={(e) => {
|
|
89
|
+
// Click outside spotlight → skip
|
|
90
|
+
if (targetRect) {
|
|
91
|
+
const x = e.clientX;
|
|
92
|
+
const y = e.clientY;
|
|
93
|
+
const inSpotlight =
|
|
94
|
+
x >= targetRect.left - PAD &&
|
|
95
|
+
x <= targetRect.right + PAD &&
|
|
96
|
+
y >= targetRect.top - PAD &&
|
|
97
|
+
y <= targetRect.bottom + PAD;
|
|
98
|
+
if (!inSpotlight) {
|
|
99
|
+
wt.skip();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
<defs>
|
|
105
|
+
<mask id={maskId}>
|
|
106
|
+
<rect width="100%" height="100%" fill="white" />
|
|
107
|
+
{targetRect && (
|
|
108
|
+
<rect
|
|
109
|
+
x={targetRect.left - PAD}
|
|
110
|
+
y={targetRect.top - PAD}
|
|
111
|
+
width={targetRect.width + PAD * 2}
|
|
112
|
+
height={targetRect.height + PAD * 2}
|
|
113
|
+
rx={RADIUS}
|
|
114
|
+
ry={RADIUS}
|
|
115
|
+
fill="black"
|
|
116
|
+
/>
|
|
117
|
+
)}
|
|
118
|
+
</mask>
|
|
119
|
+
</defs>
|
|
120
|
+
<rect
|
|
121
|
+
width="100%"
|
|
122
|
+
height="100%"
|
|
123
|
+
fill="rgba(0, 0, 0, 0.5)"
|
|
124
|
+
mask={`url(#${maskId})`}
|
|
125
|
+
/>
|
|
126
|
+
</svg>
|
|
127
|
+
|
|
128
|
+
{/* Tooltip */}
|
|
129
|
+
{targetRect && (
|
|
130
|
+
<WalkthroughTooltip
|
|
131
|
+
stepIndex={wt.currentStep}
|
|
132
|
+
rect={targetRect}
|
|
133
|
+
position={step.position}
|
|
134
|
+
/>
|
|
135
|
+
)}
|
|
136
|
+
</>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Mobile fallback: bottom sheet card */
|
|
141
|
+
function MobileWalkthroughSheet() {
|
|
142
|
+
const wt = useWalkthrough();
|
|
143
|
+
const { t } = useLocale();
|
|
144
|
+
|
|
145
|
+
if (!wt) return null;
|
|
146
|
+
|
|
147
|
+
const stepData = t.walkthrough.steps[wt.currentStep] as { title: string; body: string } | undefined;
|
|
148
|
+
if (!stepData) return null;
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<>
|
|
152
|
+
{/* Backdrop */}
|
|
153
|
+
<div
|
|
154
|
+
className="fixed inset-0 z-[100] bg-black/40 backdrop-blur-sm"
|
|
155
|
+
onClick={wt.skip}
|
|
156
|
+
/>
|
|
157
|
+
{/* Bottom sheet */}
|
|
158
|
+
<div
|
|
159
|
+
className="fixed bottom-0 left-0 right-0 z-[101] rounded-t-2xl border-t shadow-lg p-5 pb-8 animate-in slide-in-from-bottom duration-300"
|
|
160
|
+
style={{ background: 'var(--card)', borderColor: 'var(--border)' }}
|
|
161
|
+
>
|
|
162
|
+
{/* Step counter */}
|
|
163
|
+
<div className="flex items-center gap-2 mb-3">
|
|
164
|
+
<span
|
|
165
|
+
className="text-2xs font-mono px-1.5 py-0.5 rounded-full"
|
|
166
|
+
style={{ background: 'var(--amber-dim)', color: 'var(--amber)' }}
|
|
167
|
+
>
|
|
168
|
+
{t.walkthrough.step(wt.currentStep + 1, wt.totalSteps)}
|
|
169
|
+
</span>
|
|
170
|
+
{/* Progress dots */}
|
|
171
|
+
<div className="flex items-center gap-1 ml-auto">
|
|
172
|
+
{Array.from({ length: wt.totalSteps }, (_, i) => (
|
|
173
|
+
<div
|
|
174
|
+
key={i}
|
|
175
|
+
className="w-1.5 h-1.5 rounded-full"
|
|
176
|
+
style={{
|
|
177
|
+
background: i === wt.currentStep ? 'var(--amber)' : 'var(--border)',
|
|
178
|
+
}}
|
|
179
|
+
/>
|
|
180
|
+
))}
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
<h3
|
|
185
|
+
className="text-base font-semibold font-display mb-1"
|
|
186
|
+
style={{ color: 'var(--foreground)' }}
|
|
187
|
+
>
|
|
188
|
+
{stepData.title}
|
|
189
|
+
</h3>
|
|
190
|
+
<p className="text-sm leading-relaxed mb-5" style={{ color: 'var(--muted-foreground)' }}>
|
|
191
|
+
{stepData.body}
|
|
192
|
+
</p>
|
|
193
|
+
|
|
194
|
+
<div className="flex items-center justify-between">
|
|
195
|
+
<button
|
|
196
|
+
onClick={wt.skip}
|
|
197
|
+
className="text-sm transition-colors hover:opacity-80"
|
|
198
|
+
style={{ color: 'var(--muted-foreground)' }}
|
|
199
|
+
>
|
|
200
|
+
{t.walkthrough.skip}
|
|
201
|
+
</button>
|
|
202
|
+
<div className="flex items-center gap-2">
|
|
203
|
+
{wt.currentStep > 0 && (
|
|
204
|
+
<button
|
|
205
|
+
onClick={wt.back}
|
|
206
|
+
className="text-sm px-3 py-2 rounded-lg transition-colors hover:bg-muted"
|
|
207
|
+
style={{ color: 'var(--muted-foreground)' }}
|
|
208
|
+
>
|
|
209
|
+
{t.walkthrough.back}
|
|
210
|
+
</button>
|
|
211
|
+
)}
|
|
212
|
+
<button
|
|
213
|
+
onClick={wt.next}
|
|
214
|
+
className="text-sm px-4 py-2 rounded-lg font-medium transition-all hover:opacity-90"
|
|
215
|
+
style={{ background: 'var(--amber)', color: 'var(--amber-foreground)' }}
|
|
216
|
+
>
|
|
217
|
+
{wt.currentStep === wt.totalSteps - 1 ? t.walkthrough.done : t.walkthrough.next}
|
|
218
|
+
</button>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
</>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react';
|
|
4
|
+
import { walkthroughSteps } from './steps';
|
|
5
|
+
import WalkthroughOverlay from './WalkthroughOverlay';
|
|
6
|
+
|
|
7
|
+
type WalkthroughStatus = 'idle' | 'active' | 'completed' | 'dismissed';
|
|
8
|
+
|
|
9
|
+
interface WalkthroughContextValue {
|
|
10
|
+
status: WalkthroughStatus;
|
|
11
|
+
currentStep: number;
|
|
12
|
+
totalSteps: number;
|
|
13
|
+
start: () => void;
|
|
14
|
+
next: () => void;
|
|
15
|
+
back: () => void;
|
|
16
|
+
skip: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const WalkthroughContext = createContext<WalkthroughContextValue | null>(null);
|
|
20
|
+
|
|
21
|
+
export function useWalkthrough() {
|
|
22
|
+
return useContext(WalkthroughContext);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface WalkthroughProviderProps {
|
|
26
|
+
children: ReactNode;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export default function WalkthroughProvider({ children }: WalkthroughProviderProps) {
|
|
30
|
+
const [status, setStatus] = useState<WalkthroughStatus>('idle');
|
|
31
|
+
const [currentStep, setCurrentStep] = useState(0);
|
|
32
|
+
const totalSteps = walkthroughSteps.length;
|
|
33
|
+
|
|
34
|
+
// Persist to backend
|
|
35
|
+
const persistStep = useCallback((step: number, dismissed: boolean) => {
|
|
36
|
+
fetch('/api/setup', {
|
|
37
|
+
method: 'PATCH',
|
|
38
|
+
headers: { 'Content-Type': 'application/json' },
|
|
39
|
+
body: JSON.stringify({
|
|
40
|
+
guideState: {
|
|
41
|
+
walkthroughStep: step,
|
|
42
|
+
walkthroughDismissed: dismissed,
|
|
43
|
+
},
|
|
44
|
+
}),
|
|
45
|
+
}).catch(() => {});
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
// Check for auto-start via ?welcome=1 or guideState
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
// Handle ?welcome=1 URL param — single owner, clean up immediately
|
|
51
|
+
const params = new URLSearchParams(window.location.search);
|
|
52
|
+
const isWelcome = params.get('welcome') === '1';
|
|
53
|
+
if (isWelcome) {
|
|
54
|
+
const url = new URL(window.location.href);
|
|
55
|
+
url.searchParams.delete('welcome');
|
|
56
|
+
window.history.replaceState({}, '', url.pathname + (url.search || ''));
|
|
57
|
+
// Notify GuideCard about first visit
|
|
58
|
+
window.dispatchEvent(new Event('mindos:first-visit'));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Only auto-start walkthrough on desktop
|
|
62
|
+
if (window.innerWidth < 768) return;
|
|
63
|
+
|
|
64
|
+
fetch('/api/setup')
|
|
65
|
+
.then(r => r.json())
|
|
66
|
+
.then(data => {
|
|
67
|
+
const gs = data.guideState;
|
|
68
|
+
if (!gs) return;
|
|
69
|
+
// Already dismissed or completed
|
|
70
|
+
if (gs.walkthroughDismissed) return;
|
|
71
|
+
// Check if walkthrough should start
|
|
72
|
+
if (gs.active && !gs.dismissed && gs.walkthroughStep === undefined) {
|
|
73
|
+
// First time — only start if ?welcome=1 was present
|
|
74
|
+
if (isWelcome) {
|
|
75
|
+
setStatus('active');
|
|
76
|
+
setCurrentStep(0);
|
|
77
|
+
}
|
|
78
|
+
} else if (typeof gs.walkthroughStep === 'number' && gs.walkthroughStep >= 0 && gs.walkthroughStep < totalSteps && !gs.walkthroughDismissed) {
|
|
79
|
+
// Resume walkthrough
|
|
80
|
+
setStatus('active');
|
|
81
|
+
setCurrentStep(gs.walkthroughStep);
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
.catch(() => {});
|
|
85
|
+
}, [totalSteps]);
|
|
86
|
+
|
|
87
|
+
const start = useCallback(() => {
|
|
88
|
+
setCurrentStep(0);
|
|
89
|
+
setStatus('active');
|
|
90
|
+
persistStep(0, false);
|
|
91
|
+
}, [persistStep]);
|
|
92
|
+
|
|
93
|
+
const next = useCallback(() => {
|
|
94
|
+
const nextStep = currentStep + 1;
|
|
95
|
+
if (nextStep >= totalSteps) {
|
|
96
|
+
setStatus('completed');
|
|
97
|
+
persistStep(totalSteps, false);
|
|
98
|
+
} else {
|
|
99
|
+
setCurrentStep(nextStep);
|
|
100
|
+
persistStep(nextStep, false);
|
|
101
|
+
}
|
|
102
|
+
}, [currentStep, totalSteps, persistStep]);
|
|
103
|
+
|
|
104
|
+
const back = useCallback(() => {
|
|
105
|
+
if (currentStep > 0) {
|
|
106
|
+
const prevStep = currentStep - 1;
|
|
107
|
+
setCurrentStep(prevStep);
|
|
108
|
+
persistStep(prevStep, false);
|
|
109
|
+
}
|
|
110
|
+
}, [currentStep, persistStep]);
|
|
111
|
+
|
|
112
|
+
const skip = useCallback(() => {
|
|
113
|
+
setStatus('dismissed');
|
|
114
|
+
persistStep(currentStep, true);
|
|
115
|
+
}, [currentStep, persistStep]);
|
|
116
|
+
|
|
117
|
+
const value: WalkthroughContextValue = {
|
|
118
|
+
status,
|
|
119
|
+
currentStep,
|
|
120
|
+
totalSteps,
|
|
121
|
+
start,
|
|
122
|
+
next,
|
|
123
|
+
back,
|
|
124
|
+
skip,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<WalkthroughContext.Provider value={value}>
|
|
129
|
+
{children}
|
|
130
|
+
{status === 'active' && <WalkthroughOverlay />}
|
|
131
|
+
</WalkthroughContext.Provider>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
import { useLocale } from '@/lib/LocaleContext';
|
|
5
|
+
import { useWalkthrough } from './WalkthroughProvider';
|
|
6
|
+
|
|
7
|
+
interface WalkthroughTooltipProps {
|
|
8
|
+
stepIndex: number;
|
|
9
|
+
rect: DOMRect;
|
|
10
|
+
position: 'right' | 'bottom';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default function WalkthroughTooltip({ stepIndex, rect, position }: WalkthroughTooltipProps) {
|
|
14
|
+
const wt = useWalkthrough();
|
|
15
|
+
const { t } = useLocale();
|
|
16
|
+
|
|
17
|
+
// Track viewport dimensions in state to avoid stale values and SSR hazard
|
|
18
|
+
const [viewport, setViewport] = useState({ width: 1024, height: 768 });
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const update = () => setViewport({ width: window.innerWidth, height: window.innerHeight });
|
|
21
|
+
update();
|
|
22
|
+
window.addEventListener('resize', update);
|
|
23
|
+
return () => window.removeEventListener('resize', update);
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
if (!wt) return null;
|
|
27
|
+
|
|
28
|
+
const stepData = t.walkthrough.steps[stepIndex] as { title: string; body: string } | undefined;
|
|
29
|
+
if (!stepData) return null;
|
|
30
|
+
|
|
31
|
+
// Calculate tooltip position
|
|
32
|
+
const GAP = 12;
|
|
33
|
+
let top: number;
|
|
34
|
+
let left: number;
|
|
35
|
+
|
|
36
|
+
if (position === 'right') {
|
|
37
|
+
top = rect.top + rect.height / 2;
|
|
38
|
+
left = rect.right + GAP;
|
|
39
|
+
} else {
|
|
40
|
+
top = rect.bottom + GAP;
|
|
41
|
+
left = rect.left + rect.width / 2;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Clamp to viewport
|
|
45
|
+
const maxLeft = viewport.width - 320;
|
|
46
|
+
const maxTop = viewport.height - 200;
|
|
47
|
+
left = Math.min(left, maxLeft);
|
|
48
|
+
top = Math.min(top, maxTop);
|
|
49
|
+
top = Math.max(top, 8);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
className="fixed z-[102] w-[280px] rounded-xl border shadow-lg animate-in fade-in slide-in-from-left-2 duration-200"
|
|
54
|
+
style={{
|
|
55
|
+
top: `${top}px`,
|
|
56
|
+
left: `${left}px`,
|
|
57
|
+
transform: position === 'right' ? 'translateY(-50%)' : 'translateX(-50%)',
|
|
58
|
+
background: 'var(--card)',
|
|
59
|
+
borderColor: 'var(--amber)',
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
<div className="p-4">
|
|
63
|
+
{/* Step indicator */}
|
|
64
|
+
<div className="flex items-center gap-2 mb-2">
|
|
65
|
+
<span
|
|
66
|
+
className="text-2xs font-mono px-1.5 py-0.5 rounded-full"
|
|
67
|
+
style={{ background: 'var(--amber-dim)', color: 'var(--amber)' }}
|
|
68
|
+
>
|
|
69
|
+
{t.walkthrough.step(stepIndex + 1, wt.totalSteps)}
|
|
70
|
+
</span>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
{/* Content */}
|
|
74
|
+
<h3
|
|
75
|
+
className="text-sm font-semibold font-display mb-1"
|
|
76
|
+
style={{ color: 'var(--foreground)' }}
|
|
77
|
+
>
|
|
78
|
+
{stepData.title}
|
|
79
|
+
</h3>
|
|
80
|
+
<p className="text-xs leading-relaxed" style={{ color: 'var(--muted-foreground)' }}>
|
|
81
|
+
{stepData.body}
|
|
82
|
+
</p>
|
|
83
|
+
|
|
84
|
+
{/* Actions */}
|
|
85
|
+
<div className="flex items-center justify-between mt-4 pt-3" style={{ borderTop: '1px solid var(--border)' }}>
|
|
86
|
+
<button
|
|
87
|
+
onClick={wt.skip}
|
|
88
|
+
className="text-xs transition-colors hover:opacity-80"
|
|
89
|
+
style={{ color: 'var(--muted-foreground)' }}
|
|
90
|
+
>
|
|
91
|
+
{t.walkthrough.skip}
|
|
92
|
+
</button>
|
|
93
|
+
<div className="flex items-center gap-2">
|
|
94
|
+
{stepIndex > 0 && (
|
|
95
|
+
<button
|
|
96
|
+
onClick={wt.back}
|
|
97
|
+
className="text-xs px-2.5 py-1 rounded-lg transition-colors hover:bg-muted"
|
|
98
|
+
style={{ color: 'var(--muted-foreground)' }}
|
|
99
|
+
>
|
|
100
|
+
{t.walkthrough.back}
|
|
101
|
+
</button>
|
|
102
|
+
)}
|
|
103
|
+
<button
|
|
104
|
+
onClick={wt.next}
|
|
105
|
+
className="text-xs px-3 py-1.5 rounded-lg font-medium transition-all hover:opacity-90"
|
|
106
|
+
style={{ background: 'var(--amber)', color: 'var(--amber-foreground)' }}
|
|
107
|
+
>
|
|
108
|
+
{stepIndex === wt.totalSteps - 1 ? t.walkthrough.done : t.walkthrough.next}
|
|
109
|
+
</button>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
{/* Progress dots */}
|
|
115
|
+
<div className="flex items-center justify-center gap-1.5 pb-3">
|
|
116
|
+
{Array.from({ length: wt.totalSteps }, (_, i) => (
|
|
117
|
+
<div
|
|
118
|
+
key={i}
|
|
119
|
+
className="w-1.5 h-1.5 rounded-full transition-all duration-200"
|
|
120
|
+
style={{
|
|
121
|
+
background: i === stepIndex ? 'var(--amber)' : 'var(--border)',
|
|
122
|
+
transform: i === stepIndex ? 'scale(1.3)' : undefined,
|
|
123
|
+
}}
|
|
124
|
+
/>
|
|
125
|
+
))}
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Walkthrough step anchors — these data-walkthrough attributes are added to target components */
|
|
2
|
+
export type WalkthroughAnchor =
|
|
3
|
+
| 'activity-bar'
|
|
4
|
+
| 'files-panel'
|
|
5
|
+
| 'ask-button'
|
|
6
|
+
| 'search-button'
|
|
7
|
+
| 'settings-button';
|
|
8
|
+
|
|
9
|
+
export interface WalkthroughStep {
|
|
10
|
+
anchor: WalkthroughAnchor;
|
|
11
|
+
/** Preferred tooltip position relative to anchor */
|
|
12
|
+
position: 'right' | 'bottom';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const walkthroughSteps: WalkthroughStep[] = [
|
|
16
|
+
{ anchor: 'activity-bar', position: 'right' },
|
|
17
|
+
{ anchor: 'files-panel', position: 'right' },
|
|
18
|
+
{ anchor: 'ask-button', position: 'right' },
|
|
19
|
+
{ anchor: 'search-button', position: 'right' },
|
|
20
|
+
{ anchor: 'settings-button', position: 'right' },
|
|
21
|
+
];
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef, type ReactNode } from 'react';
|
|
4
|
+
import { apiFetch } from '@/lib/api';
|
|
5
|
+
import type { McpStatus, AgentInfo, SkillInfo } from '@/components/settings/types';
|
|
6
|
+
|
|
7
|
+
/* ── Context shape ── */
|
|
8
|
+
|
|
9
|
+
export interface McpContextValue {
|
|
10
|
+
status: McpStatus | null;
|
|
11
|
+
agents: AgentInfo[];
|
|
12
|
+
skills: SkillInfo[];
|
|
13
|
+
loading: boolean;
|
|
14
|
+
refresh: () => Promise<void>;
|
|
15
|
+
toggleSkill: (name: string, enabled: boolean) => Promise<void>;
|
|
16
|
+
installAgent: (key: string, opts?: { scope?: string; transport?: string }) => Promise<boolean>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const McpContext = createContext<McpContextValue | null>(null);
|
|
20
|
+
|
|
21
|
+
export function useMcpData(): McpContextValue {
|
|
22
|
+
const ctx = useContext(McpContext);
|
|
23
|
+
if (!ctx) throw new Error('useMcpData must be used within McpProvider');
|
|
24
|
+
return ctx;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Optional hook that returns null outside provider (for components that may or may not be wrapped) */
|
|
28
|
+
export function useMcpDataOptional(): McpContextValue | null {
|
|
29
|
+
return useContext(McpContext);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* ── Provider ── */
|
|
33
|
+
|
|
34
|
+
const POLL_INTERVAL = 30_000;
|
|
35
|
+
|
|
36
|
+
export default function McpProvider({ children }: { children: ReactNode }) {
|
|
37
|
+
const [status, setStatus] = useState<McpStatus | null>(null);
|
|
38
|
+
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
|
39
|
+
const [skills, setSkills] = useState<SkillInfo[]>([]);
|
|
40
|
+
const [loading, setLoading] = useState(true);
|
|
41
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
42
|
+
// Ref for agents to avoid stale closure in installAgent
|
|
43
|
+
const agentsRef = useRef(agents);
|
|
44
|
+
agentsRef.current = agents;
|
|
45
|
+
|
|
46
|
+
const fetchAll = useCallback(async () => {
|
|
47
|
+
// Abort any in-flight request to prevent race conditions
|
|
48
|
+
abortRef.current?.abort();
|
|
49
|
+
const ac = new AbortController();
|
|
50
|
+
abortRef.current = ac;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const [statusData, agentsData, skillsData] = await Promise.all([
|
|
54
|
+
apiFetch<McpStatus>('/api/mcp/status', { signal: ac.signal }),
|
|
55
|
+
apiFetch<{ agents: AgentInfo[] }>('/api/mcp/agents', { signal: ac.signal }),
|
|
56
|
+
apiFetch<{ skills: SkillInfo[] }>('/api/skills', { signal: ac.signal }),
|
|
57
|
+
]);
|
|
58
|
+
if (!ac.signal.aborted) {
|
|
59
|
+
setStatus(statusData);
|
|
60
|
+
setAgents(agentsData.agents);
|
|
61
|
+
setSkills(skillsData.skills);
|
|
62
|
+
}
|
|
63
|
+
} catch (err: unknown) {
|
|
64
|
+
// Ignore abort errors
|
|
65
|
+
if (err instanceof DOMException && err.name === 'AbortError') return;
|
|
66
|
+
// On error, keep existing data
|
|
67
|
+
} finally {
|
|
68
|
+
if (!ac.signal.aborted) setLoading(false);
|
|
69
|
+
}
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
// Initial fetch
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
fetchAll();
|
|
75
|
+
return () => abortRef.current?.abort();
|
|
76
|
+
}, [fetchAll]);
|
|
77
|
+
|
|
78
|
+
// Listen for skill changes from SkillsSection (settings CRUD — create/delete/edit)
|
|
79
|
+
// Debounce to coalesce rapid mutations into a single refresh
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
82
|
+
const handler = () => {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
timer = setTimeout(() => fetchAll(), 500);
|
|
85
|
+
};
|
|
86
|
+
window.addEventListener('mindos:skills-changed', handler);
|
|
87
|
+
return () => {
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
window.removeEventListener('mindos:skills-changed', handler);
|
|
90
|
+
};
|
|
91
|
+
}, [fetchAll]);
|
|
92
|
+
|
|
93
|
+
// 30s polling when visible
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
96
|
+
|
|
97
|
+
const startPolling = () => {
|
|
98
|
+
timer = setInterval(() => {
|
|
99
|
+
if (document.visibilityState === 'visible') fetchAll();
|
|
100
|
+
}, POLL_INTERVAL);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
startPolling();
|
|
104
|
+
return () => clearInterval(timer);
|
|
105
|
+
}, [fetchAll]);
|
|
106
|
+
|
|
107
|
+
const refresh = useCallback(async () => {
|
|
108
|
+
await fetchAll();
|
|
109
|
+
}, [fetchAll]);
|
|
110
|
+
|
|
111
|
+
const toggleSkill = useCallback(async (name: string, enabled: boolean) => {
|
|
112
|
+
// Optimistic update
|
|
113
|
+
setSkills(prev => prev.map(s => s.name === name ? { ...s, enabled } : s));
|
|
114
|
+
try {
|
|
115
|
+
await apiFetch('/api/skills', {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: { 'Content-Type': 'application/json' },
|
|
118
|
+
body: JSON.stringify({ action: 'toggle', name, enabled }),
|
|
119
|
+
});
|
|
120
|
+
} catch {
|
|
121
|
+
// Revert on failure
|
|
122
|
+
setSkills(prev => prev.map(s => s.name === name ? { ...s, enabled: !enabled } : s));
|
|
123
|
+
}
|
|
124
|
+
}, []);
|
|
125
|
+
|
|
126
|
+
const installAgent = useCallback(async (key: string, opts?: { scope?: string; transport?: string }): Promise<boolean> => {
|
|
127
|
+
const agent = agentsRef.current.find(a => a.key === key);
|
|
128
|
+
if (!agent) return false;
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const res = await apiFetch<{ results: Array<{ key: string; ok: boolean; error?: string }> }>('/api/mcp/install', {
|
|
132
|
+
method: 'POST',
|
|
133
|
+
headers: { 'Content-Type': 'application/json' },
|
|
134
|
+
body: JSON.stringify({
|
|
135
|
+
agents: [{
|
|
136
|
+
key,
|
|
137
|
+
scope: opts?.scope ?? (agent.hasProjectScope ? 'project' : 'global'),
|
|
138
|
+
transport: opts?.transport ?? agent.preferredTransport,
|
|
139
|
+
}],
|
|
140
|
+
transport: 'auto',
|
|
141
|
+
}),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const ok = res.results?.[0]?.ok ?? false;
|
|
145
|
+
if (ok) {
|
|
146
|
+
// Refresh to pick up newly installed agent
|
|
147
|
+
await fetchAll();
|
|
148
|
+
}
|
|
149
|
+
return ok;
|
|
150
|
+
} catch {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}, [fetchAll]);
|
|
154
|
+
|
|
155
|
+
const value = useMemo<McpContextValue>(() => ({
|
|
156
|
+
status,
|
|
157
|
+
agents,
|
|
158
|
+
skills,
|
|
159
|
+
loading,
|
|
160
|
+
refresh,
|
|
161
|
+
toggleSkill,
|
|
162
|
+
installAgent,
|
|
163
|
+
}), [status, agents, skills, loading, refresh, toggleSkill, installAgent]);
|
|
164
|
+
|
|
165
|
+
return <McpContext.Provider value={value}>{children}</McpContext.Provider>;
|
|
166
|
+
}
|