@geminilight/mindos 0.5.29 → 0.5.32
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/components/HomeContent.tsx +20 -97
- package/app/components/SidebarLayout.tsx +3 -0
- package/app/components/panels/AgentsPanel.tsx +238 -92
- package/app/components/panels/PluginsPanel.tsx +79 -22
- package/app/components/settings/McpSkillsSection.tsx +12 -0
- package/app/components/settings/McpTab.tsx +28 -30
- package/app/hooks/useMcpData.tsx +166 -0
- package/app/lib/api.ts +21 -4
- package/app/lib/i18n-en.ts +18 -0
- package/app/lib/i18n-zh.ts +18 -0
- package/app/lib/mcp-snippets.ts +103 -0
- package/package.json +1 -1
- package/scripts/release.sh +15 -8
- package/scripts/setup.js +10 -6
- package/app/components/settings/McpServerStatus.tsx +0 -274
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, useEffect } from 'react';
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
4
4
|
import { useRouter } from 'next/navigation';
|
|
5
5
|
import { getAllRenderers, isRendererEnabled, setRendererEnabled, loadDisabledState } from '@/lib/renderers/registry';
|
|
6
6
|
import { Toggle } from '../settings/Primitives';
|
|
@@ -16,6 +16,7 @@ interface PluginsPanelProps {
|
|
|
16
16
|
export default function PluginsPanel({ active, maximized, onMaximize }: PluginsPanelProps) {
|
|
17
17
|
const [mounted, setMounted] = useState(false);
|
|
18
18
|
const [, forceUpdate] = useState(0);
|
|
19
|
+
const [existingFiles, setExistingFiles] = useState<Set<string>>(new Set());
|
|
19
20
|
const router = useRouter();
|
|
20
21
|
const { t } = useLocale();
|
|
21
22
|
const p = t.panels.plugins;
|
|
@@ -26,14 +27,38 @@ export default function PluginsPanel({ active, maximized, onMaximize }: PluginsP
|
|
|
26
27
|
setMounted(true);
|
|
27
28
|
}, []);
|
|
28
29
|
|
|
30
|
+
// Check which entry files exist (once on mount + when active)
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!mounted || !active) return;
|
|
33
|
+
const entryPaths = getAllRenderers()
|
|
34
|
+
.map(r => r.entryPath)
|
|
35
|
+
.filter((p): p is string => !!p);
|
|
36
|
+
if (entryPaths.length === 0) return;
|
|
37
|
+
|
|
38
|
+
// Check each file via HEAD-like GET — lightweight
|
|
39
|
+
Promise.all(
|
|
40
|
+
entryPaths.map(path =>
|
|
41
|
+
fetch(`/api/file?path=${encodeURIComponent(path)}`, { method: 'GET' })
|
|
42
|
+
.then(r => r.ok ? path : null)
|
|
43
|
+
.catch(() => null)
|
|
44
|
+
)
|
|
45
|
+
).then(results => {
|
|
46
|
+
setExistingFiles(new Set(results.filter((p): p is string => p !== null)));
|
|
47
|
+
});
|
|
48
|
+
}, [mounted, active]);
|
|
49
|
+
|
|
29
50
|
const renderers = mounted ? getAllRenderers() : [];
|
|
30
51
|
const enabledCount = mounted ? renderers.filter(r => isRendererEnabled(r.id)).length : 0;
|
|
31
52
|
|
|
32
|
-
const handleToggle = (id: string, enabled: boolean) => {
|
|
53
|
+
const handleToggle = useCallback((id: string, enabled: boolean) => {
|
|
33
54
|
setRendererEnabled(id, enabled);
|
|
34
55
|
forceUpdate(n => n + 1);
|
|
35
56
|
window.dispatchEvent(new Event('renderer-state-changed'));
|
|
36
|
-
};
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
const handleOpen = useCallback((entryPath: string) => {
|
|
60
|
+
router.push(`/view/${entryPath.split('/').map(encodeURIComponent).join('/')}`);
|
|
61
|
+
}, [router]);
|
|
37
62
|
|
|
38
63
|
return (
|
|
39
64
|
<div className={`flex flex-col h-full ${active ? '' : 'hidden'}`}>
|
|
@@ -49,48 +74,80 @@ export default function PluginsPanel({ active, maximized, onMaximize }: PluginsP
|
|
|
49
74
|
)}
|
|
50
75
|
{renderers.map(r => {
|
|
51
76
|
const enabled = isRendererEnabled(r.id);
|
|
77
|
+
const fileExists = r.entryPath ? existingFiles.has(r.entryPath) : false;
|
|
78
|
+
const canOpen = enabled && r.entryPath && fileExists;
|
|
79
|
+
|
|
52
80
|
return (
|
|
53
81
|
<div
|
|
54
82
|
key={r.id}
|
|
55
|
-
className=
|
|
83
|
+
className={`
|
|
84
|
+
px-4 py-3 border-b border-border/50 transition-colors
|
|
85
|
+
${canOpen ? 'cursor-pointer hover:bg-muted/40' : 'hover:bg-muted/20'}
|
|
86
|
+
${!enabled ? 'opacity-50' : ''}
|
|
87
|
+
`}
|
|
88
|
+
onClick={canOpen ? () => handleOpen(r.entryPath!) : undefined}
|
|
89
|
+
role={canOpen ? 'link' : undefined}
|
|
56
90
|
>
|
|
57
|
-
{/* Top row: icon + name + toggle */}
|
|
91
|
+
{/* Top row: status dot + icon + name + toggle */}
|
|
58
92
|
<div className="flex items-center justify-between gap-2">
|
|
59
93
|
<div className="flex items-center gap-2.5 min-w-0">
|
|
60
|
-
|
|
94
|
+
{/* Status dot */}
|
|
95
|
+
<span
|
|
96
|
+
className="w-1.5 h-1.5 rounded-full shrink-0"
|
|
97
|
+
style={{
|
|
98
|
+
background: !enabled
|
|
99
|
+
? 'var(--muted-foreground)'
|
|
100
|
+
: canOpen
|
|
101
|
+
? 'var(--success)'
|
|
102
|
+
: 'var(--border)',
|
|
103
|
+
}}
|
|
104
|
+
title={
|
|
105
|
+
!enabled
|
|
106
|
+
? p.disabled ?? 'Disabled'
|
|
107
|
+
: canOpen
|
|
108
|
+
? p.ready ?? 'Ready'
|
|
109
|
+
: p.noFile ?? 'File not found'
|
|
110
|
+
}
|
|
111
|
+
/>
|
|
112
|
+
<span className="text-base shrink-0" suppressHydrationWarning>{r.icon}</span>
|
|
61
113
|
<span className="text-sm font-medium text-foreground truncate">{r.name}</span>
|
|
62
114
|
{r.core && (
|
|
63
115
|
<span className="text-2xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground shrink-0">{p.core}</span>
|
|
64
116
|
)}
|
|
65
117
|
</div>
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
118
|
+
{/* Toggle — stop propagation to prevent row click */}
|
|
119
|
+
<div onClick={e => e.stopPropagation()}>
|
|
120
|
+
<Toggle
|
|
121
|
+
checked={enabled}
|
|
122
|
+
onChange={(v) => handleToggle(r.id, v)}
|
|
123
|
+
size="sm"
|
|
124
|
+
disabled={r.core}
|
|
125
|
+
title={r.core ? p.coreDisabled : undefined}
|
|
126
|
+
/>
|
|
127
|
+
</div>
|
|
73
128
|
</div>
|
|
74
129
|
|
|
75
130
|
{/* Description */}
|
|
76
|
-
<p className="mt-1 text-xs text-muted-foreground leading-relaxed pl-[
|
|
131
|
+
<p className="mt-1 text-xs text-muted-foreground leading-relaxed pl-[34px]">
|
|
77
132
|
{r.description}
|
|
78
133
|
</p>
|
|
79
134
|
|
|
80
|
-
{/* Tags +
|
|
81
|
-
<div className="mt-1.5 flex items-center gap-1.5 pl-[
|
|
135
|
+
{/* Tags + status hint */}
|
|
136
|
+
<div className="mt-1.5 flex items-center gap-1.5 pl-[34px] flex-wrap">
|
|
82
137
|
{r.tags.slice(0, 3).map(tag => (
|
|
83
138
|
<span key={tag} className="text-2xs px-1.5 py-0.5 rounded-full bg-muted/60 text-muted-foreground">
|
|
84
139
|
{tag}
|
|
85
140
|
</span>
|
|
86
141
|
))}
|
|
87
|
-
{r.entryPath && enabled && (
|
|
88
|
-
<
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
142
|
+
{r.entryPath && enabled && !fileExists && (
|
|
143
|
+
<span className="text-2xs" style={{ color: 'var(--amber)' }}>
|
|
144
|
+
{(p.createFile ?? 'Create {file}').replace('{file}', r.entryPath)}
|
|
145
|
+
</span>
|
|
146
|
+
)}
|
|
147
|
+
{canOpen && (
|
|
148
|
+
<span className="text-2xs" style={{ color: 'var(--amber)' }}>
|
|
92
149
|
→ {r.entryPath}
|
|
93
|
-
</
|
|
150
|
+
</span>
|
|
94
151
|
)}
|
|
95
152
|
</div>
|
|
96
153
|
</div>
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
Trash2, Plus, X, Search, Pencil,
|
|
7
7
|
} from 'lucide-react';
|
|
8
8
|
import { apiFetch } from '@/lib/api';
|
|
9
|
+
import { useMcpDataOptional } from '@/hooks/useMcpData';
|
|
9
10
|
import { Toggle } from './Primitives';
|
|
10
11
|
import dynamic from 'next/dynamic';
|
|
11
12
|
import type { SkillInfo, McpSkillsSectionProps } from './types';
|
|
@@ -83,6 +84,7 @@ const SKILL_TEMPLATES: Record<string, (name: string) => string> = {
|
|
|
83
84
|
|
|
84
85
|
export default function SkillsSection({ t }: McpSkillsSectionProps) {
|
|
85
86
|
const m = t.settings?.mcp;
|
|
87
|
+
const mcp = useMcpDataOptional();
|
|
86
88
|
const [skills, setSkills] = useState<SkillInfo[]>([]);
|
|
87
89
|
const [loading, setLoading] = useState(true);
|
|
88
90
|
const [expanded, setExpanded] = useState<string | null>(null);
|
|
@@ -131,6 +133,13 @@ export default function SkillsSection({ t }: McpSkillsSectionProps) {
|
|
|
131
133
|
const builtinSkills = useMemo(() => filtered.filter(s => s.source === 'builtin'), [filtered]);
|
|
132
134
|
|
|
133
135
|
const handleToggle = async (name: string, enabled: boolean) => {
|
|
136
|
+
// Delegate to McpProvider when available — single API call, no event storm
|
|
137
|
+
if (mcp) {
|
|
138
|
+
await mcp.toggleSkill(name, enabled);
|
|
139
|
+
setSkills(prev => prev.map(s => s.name === name ? { ...s, enabled } : s));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
// Fallback: direct API call (no McpProvider context)
|
|
134
143
|
try {
|
|
135
144
|
await apiFetch('/api/skills', {
|
|
136
145
|
method: 'POST',
|
|
@@ -160,6 +169,7 @@ export default function SkillsSection({ t }: McpSkillsSectionProps) {
|
|
|
160
169
|
if (expanded === name) setExpanded(null);
|
|
161
170
|
setLoadErrors(prev => { const next = { ...prev }; delete next[name]; return next; });
|
|
162
171
|
fetchSkills();
|
|
172
|
+
window.dispatchEvent(new Event('mindos:skills-changed'));
|
|
163
173
|
} catch (err) {
|
|
164
174
|
const msg = err instanceof Error ? err.message : 'Failed to delete skill';
|
|
165
175
|
console.error('handleDelete error:', msg);
|
|
@@ -212,6 +222,7 @@ export default function SkillsSection({ t }: McpSkillsSectionProps) {
|
|
|
212
222
|
setFullContent(prev => ({ ...prev, [name]: editContent }));
|
|
213
223
|
setEditing(null);
|
|
214
224
|
fetchSkills(); // refresh description from updated frontmatter
|
|
225
|
+
window.dispatchEvent(new Event('mindos:skills-changed'));
|
|
215
226
|
} catch (err: unknown) {
|
|
216
227
|
setEditError(err instanceof Error ? err.message : 'Failed to save skill');
|
|
217
228
|
} finally {
|
|
@@ -247,6 +258,7 @@ export default function SkillsSection({ t }: McpSkillsSectionProps) {
|
|
|
247
258
|
setNewName('');
|
|
248
259
|
setNewContent('');
|
|
249
260
|
fetchSkills();
|
|
261
|
+
window.dispatchEvent(new Event('mindos:skills-changed'));
|
|
250
262
|
} catch (err: unknown) {
|
|
251
263
|
setCreateError(err instanceof Error ? err.message : 'Failed to create skill');
|
|
252
264
|
} finally {
|
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import { useState, useEffect, useCallback } from 'react';
|
|
2
1
|
import { Loader2 } from 'lucide-react';
|
|
3
|
-
import {
|
|
4
|
-
import type {
|
|
5
|
-
import ServerStatus from './McpServerStatus';
|
|
2
|
+
import { useMcpDataOptional } from '@/hooks/useMcpData';
|
|
3
|
+
import type { McpTabProps } from './types';
|
|
6
4
|
import AgentInstall from './McpAgentInstall';
|
|
7
5
|
import SkillsSection from './McpSkillsSection';
|
|
8
6
|
|
|
@@ -12,25 +10,10 @@ export type { McpStatus, AgentInfo, SkillInfo, McpTabProps } from './types';
|
|
|
12
10
|
/* ── Main McpTab ───────────────────────────────────────────────── */
|
|
13
11
|
|
|
14
12
|
export function McpTab({ t }: McpTabProps) {
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
const [loading, setLoading] = useState(true);
|
|
18
|
-
|
|
19
|
-
const fetchAll = useCallback(async () => {
|
|
20
|
-
try {
|
|
21
|
-
const [statusData, agentsData] = await Promise.all([
|
|
22
|
-
apiFetch<McpStatus>('/api/mcp/status'),
|
|
23
|
-
apiFetch<{ agents: AgentInfo[] }>('/api/mcp/agents'),
|
|
24
|
-
]);
|
|
25
|
-
setMcpStatus(statusData);
|
|
26
|
-
setAgents(agentsData.agents);
|
|
27
|
-
} catch { /* ignore */ }
|
|
28
|
-
setLoading(false);
|
|
29
|
-
}, []);
|
|
30
|
-
|
|
31
|
-
useEffect(() => { fetchAll(); }, [fetchAll]);
|
|
13
|
+
const mcp = useMcpDataOptional();
|
|
14
|
+
const m = t.settings?.mcp;
|
|
32
15
|
|
|
33
|
-
if (loading) {
|
|
16
|
+
if (!mcp || mcp.loading) {
|
|
34
17
|
return (
|
|
35
18
|
<div className="flex justify-center py-8">
|
|
36
19
|
<Loader2 size={18} className="animate-spin text-muted-foreground" />
|
|
@@ -38,23 +21,38 @@ export function McpTab({ t }: McpTabProps) {
|
|
|
38
21
|
);
|
|
39
22
|
}
|
|
40
23
|
|
|
41
|
-
const m = t.settings?.mcp;
|
|
42
|
-
|
|
43
24
|
return (
|
|
44
25
|
<div className="space-y-6">
|
|
45
|
-
{/*
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
26
|
+
{/* Server status summary (minimal — full status is in sidebar AgentsPanel) */}
|
|
27
|
+
{mcp.status && (
|
|
28
|
+
<div className="rounded-xl border p-4 space-y-2" style={{ borderColor: 'var(--border)', background: 'var(--card)' }}>
|
|
29
|
+
<div className="flex items-center gap-2.5 text-xs">
|
|
30
|
+
<span className={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${mcp.status.running ? 'bg-success' : 'bg-muted-foreground'}`} />
|
|
31
|
+
<span className="text-foreground font-medium">
|
|
32
|
+
{mcp.status.running ? (m?.running ?? 'Running') : (m?.stopped ?? 'Stopped')}
|
|
33
|
+
</span>
|
|
34
|
+
{mcp.status.running && (
|
|
35
|
+
<>
|
|
36
|
+
<span className="text-muted-foreground">·</span>
|
|
37
|
+
<span className="font-mono text-muted-foreground">{mcp.status.endpoint}</span>
|
|
38
|
+
<span className="text-muted-foreground">·</span>
|
|
39
|
+
<span className="text-muted-foreground">{mcp.status.toolCount} tools</span>
|
|
40
|
+
</>
|
|
41
|
+
)}
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
)}
|
|
45
|
+
|
|
46
|
+
{/* Skills (full CRUD — search, edit, delete, create, language switch) */}
|
|
49
47
|
<div>
|
|
50
48
|
<h3 className="text-sm font-medium text-foreground mb-3">{m?.skillsTitle ?? 'Skills'}</h3>
|
|
51
49
|
<SkillsSection t={t} />
|
|
52
50
|
</div>
|
|
53
51
|
|
|
54
|
-
{/* Agent
|
|
52
|
+
{/* Batch Agent Install */}
|
|
55
53
|
<div>
|
|
56
54
|
<h3 className="text-sm font-medium text-foreground mb-3">{m?.agentsTitle ?? 'Agent Configuration'}</h3>
|
|
57
|
-
<AgentInstall agents={agents} t={t} onRefresh={
|
|
55
|
+
<AgentInstall agents={mcp.agents} t={t} onRefresh={mcp.refresh} />
|
|
58
56
|
</div>
|
|
59
57
|
</div>
|
|
60
58
|
);
|
|
@@ -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
|
+
}
|
package/app/lib/api.ts
CHANGED
|
@@ -29,14 +29,30 @@ export async function apiFetch<T>(url: string, opts: ApiFetchOptions = {}): Prom
|
|
|
29
29
|
|
|
30
30
|
let controller: AbortController | undefined;
|
|
31
31
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
32
|
+
let removeExternalAbortListener: (() => void) | undefined;
|
|
32
33
|
|
|
33
|
-
if (timeout > 0) {
|
|
34
|
+
if (timeout > 0 || externalSignal) {
|
|
34
35
|
controller = new AbortController();
|
|
35
|
-
timeoutId = setTimeout(() => controller!.abort(), timeout);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
if (timeout > 0 && controller) {
|
|
39
|
+
timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Bridge caller-provided AbortSignal so both timeout and external cancel work.
|
|
43
|
+
if (externalSignal && controller) {
|
|
44
|
+
if (externalSignal.aborted) {
|
|
45
|
+
controller.abort();
|
|
46
|
+
} else {
|
|
47
|
+
const onAbort = () => controller?.abort();
|
|
48
|
+
externalSignal.addEventListener('abort', onAbort, { once: true });
|
|
49
|
+
removeExternalAbortListener = () => {
|
|
50
|
+
externalSignal.removeEventListener('abort', onAbort);
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const signal = controller?.signal ?? externalSignal;
|
|
40
56
|
|
|
41
57
|
try {
|
|
42
58
|
const res = await fetch(url, { ...fetchOpts, signal });
|
|
@@ -60,5 +76,6 @@ export async function apiFetch<T>(url: string, opts: ApiFetchOptions = {}): Prom
|
|
|
60
76
|
return (await res.json()) as T;
|
|
61
77
|
} finally {
|
|
62
78
|
if (timeoutId) clearTimeout(timeoutId);
|
|
79
|
+
if (removeExternalAbortListener) removeExternalAbortListener();
|
|
63
80
|
}
|
|
64
81
|
}
|
package/app/lib/i18n-en.ts
CHANGED
|
@@ -105,6 +105,20 @@ export const en = {
|
|
|
105
105
|
connect: 'Connect',
|
|
106
106
|
installing: 'Installing...',
|
|
107
107
|
install: (name: string) => `Install ${name}`,
|
|
108
|
+
// Snippet section
|
|
109
|
+
copyConfig: 'Copy Config',
|
|
110
|
+
copied: 'Copied!',
|
|
111
|
+
transportLocal: 'Local',
|
|
112
|
+
transportRemote: 'Remote',
|
|
113
|
+
configPath: 'Config path',
|
|
114
|
+
noAuthWarning: 'No auth token — set in Advanced Config',
|
|
115
|
+
// Skills section
|
|
116
|
+
skillsTitle: 'Skills',
|
|
117
|
+
skillsActive: 'active',
|
|
118
|
+
builtinSkills: 'Built-in',
|
|
119
|
+
newSkill: '+ New',
|
|
120
|
+
// Footer
|
|
121
|
+
advancedConfig: 'Advanced Config →',
|
|
108
122
|
},
|
|
109
123
|
plugins: {
|
|
110
124
|
title: 'Plugins',
|
|
@@ -113,6 +127,10 @@ export const en = {
|
|
|
113
127
|
core: 'Core',
|
|
114
128
|
coreDisabled: 'Core plugin — cannot be disabled',
|
|
115
129
|
footer: 'Plugins customize how files render. Core plugins cannot be disabled.',
|
|
130
|
+
ready: 'Ready — click to open',
|
|
131
|
+
disabled: 'Disabled',
|
|
132
|
+
noFile: 'Entry file not found',
|
|
133
|
+
createFile: 'Create {file} to activate',
|
|
116
134
|
},
|
|
117
135
|
},
|
|
118
136
|
shortcutPanel: {
|
package/app/lib/i18n-zh.ts
CHANGED
|
@@ -130,6 +130,20 @@ export const zh = {
|
|
|
130
130
|
connect: '连接',
|
|
131
131
|
installing: '安装中...',
|
|
132
132
|
install: (name: string) => `安装 ${name}`,
|
|
133
|
+
// Snippet section
|
|
134
|
+
copyConfig: '复制配置',
|
|
135
|
+
copied: '已复制!',
|
|
136
|
+
transportLocal: '本地',
|
|
137
|
+
transportRemote: '远程',
|
|
138
|
+
configPath: '配置路径',
|
|
139
|
+
noAuthWarning: '未设置 Token — 请在高级配置中设置',
|
|
140
|
+
// Skills section
|
|
141
|
+
skillsTitle: 'Skills',
|
|
142
|
+
skillsActive: '已启用',
|
|
143
|
+
builtinSkills: '内置',
|
|
144
|
+
newSkill: '+ 新建',
|
|
145
|
+
// Footer
|
|
146
|
+
advancedConfig: '高级配置 →',
|
|
133
147
|
},
|
|
134
148
|
plugins: {
|
|
135
149
|
title: '插件',
|
|
@@ -138,6 +152,10 @@ export const zh = {
|
|
|
138
152
|
core: '核心',
|
|
139
153
|
coreDisabled: '核心插件 — 不可禁用',
|
|
140
154
|
footer: '插件用于自定义文件渲染方式。核心插件不可禁用。',
|
|
155
|
+
ready: '就绪 — 点击打开',
|
|
156
|
+
disabled: '已禁用',
|
|
157
|
+
noFile: '入口文件不存在',
|
|
158
|
+
createFile: '创建 {file} 以激活',
|
|
141
159
|
},
|
|
142
160
|
},
|
|
143
161
|
shortcutPanel: {
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared MCP config snippet generation utilities.
|
|
3
|
+
* Extracted from McpServerStatus.tsx for reuse in AgentsPanel.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { AgentInfo, McpStatus } from '@/components/settings/types';
|
|
7
|
+
|
|
8
|
+
export interface ConfigSnippet {
|
|
9
|
+
/** Snippet with full token — for clipboard copy */
|
|
10
|
+
snippet: string;
|
|
11
|
+
/** Snippet with masked token — for display in UI */
|
|
12
|
+
displaySnippet: string;
|
|
13
|
+
/** Target config file path */
|
|
14
|
+
path: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function generateStdioSnippet(agent: AgentInfo): ConfigSnippet {
|
|
18
|
+
const stdioEntry: Record<string, unknown> = { type: 'stdio', command: 'mindos', args: ['mcp'] };
|
|
19
|
+
|
|
20
|
+
if (agent.format === 'toml') {
|
|
21
|
+
const lines = [
|
|
22
|
+
`[${agent.configKey}.mindos]`,
|
|
23
|
+
`command = "mindos"`,
|
|
24
|
+
`args = ["mcp"]`,
|
|
25
|
+
'',
|
|
26
|
+
`[${agent.configKey}.mindos.env]`,
|
|
27
|
+
`MCP_TRANSPORT = "stdio"`,
|
|
28
|
+
];
|
|
29
|
+
const s = lines.join('\n');
|
|
30
|
+
return { snippet: s, displaySnippet: s, path: agent.globalPath };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (agent.globalNestedKey) {
|
|
34
|
+
const s = JSON.stringify({ [agent.configKey]: { mindos: stdioEntry } }, null, 2);
|
|
35
|
+
return { snippet: s, displaySnippet: s, path: agent.projectPath ?? agent.globalPath };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const s = JSON.stringify({ [agent.configKey]: { mindos: stdioEntry } }, null, 2);
|
|
39
|
+
return { snippet: s, displaySnippet: s, path: agent.globalPath };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function generateHttpSnippet(
|
|
43
|
+
agent: AgentInfo,
|
|
44
|
+
endpoint: string,
|
|
45
|
+
token?: string,
|
|
46
|
+
maskedToken?: string,
|
|
47
|
+
): ConfigSnippet {
|
|
48
|
+
// Full token for copy
|
|
49
|
+
const httpEntry: Record<string, unknown> = { url: endpoint };
|
|
50
|
+
if (token) httpEntry.headers = { Authorization: `Bearer ${token}` };
|
|
51
|
+
|
|
52
|
+
// Masked token for display
|
|
53
|
+
const displayEntry: Record<string, unknown> = { url: endpoint };
|
|
54
|
+
if (maskedToken) displayEntry.headers = { Authorization: `Bearer ${maskedToken}` };
|
|
55
|
+
|
|
56
|
+
const buildSnippet = (entry: Record<string, unknown>) => {
|
|
57
|
+
if (agent.format === 'toml') {
|
|
58
|
+
const lines = [
|
|
59
|
+
`[${agent.configKey}.mindos]`,
|
|
60
|
+
`type = "http"`,
|
|
61
|
+
`url = "${endpoint}"`,
|
|
62
|
+
];
|
|
63
|
+
const authVal = (entry.headers as Record<string, string>)?.Authorization;
|
|
64
|
+
if (authVal) {
|
|
65
|
+
lines.push('');
|
|
66
|
+
lines.push(`[${agent.configKey}.mindos.headers]`);
|
|
67
|
+
lines.push(`Authorization = "${authVal}"`);
|
|
68
|
+
}
|
|
69
|
+
return lines.join('\n');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (agent.globalNestedKey) {
|
|
73
|
+
return JSON.stringify({ [agent.configKey]: { mindos: entry } }, null, 2);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return JSON.stringify({ [agent.configKey]: { mindos: entry } }, null, 2);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
snippet: buildSnippet(httpEntry),
|
|
81
|
+
displaySnippet: buildSnippet(token ? displayEntry : httpEntry),
|
|
82
|
+
path: agent.format === 'toml'
|
|
83
|
+
? agent.globalPath
|
|
84
|
+
: (agent.globalNestedKey ? (agent.projectPath ?? agent.globalPath) : agent.globalPath),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Generate snippet based on transport mode */
|
|
89
|
+
export function generateSnippet(
|
|
90
|
+
agent: AgentInfo,
|
|
91
|
+
status: McpStatus | null,
|
|
92
|
+
transport: 'stdio' | 'http',
|
|
93
|
+
): ConfigSnippet {
|
|
94
|
+
if (transport === 'stdio') {
|
|
95
|
+
return generateStdioSnippet(agent);
|
|
96
|
+
}
|
|
97
|
+
return generateHttpSnippet(
|
|
98
|
+
agent,
|
|
99
|
+
status?.endpoint ?? 'http://127.0.0.1:8781/mcp',
|
|
100
|
+
status?.authToken,
|
|
101
|
+
status?.maskedToken,
|
|
102
|
+
);
|
|
103
|
+
}
|
package/package.json
CHANGED