@geminilight/mindos 0.5.50 → 0.5.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/README_zh.md +1 -1
- package/app/app/api/mcp/install/route.ts +8 -1
- package/app/app/api/mcp/restart/route.ts +88 -0
- package/app/components/panels/AgentsPanel.tsx +32 -122
- package/app/components/settings/McpTab.tsx +232 -20
- package/app/lib/i18n-en.ts +2 -0
- package/app/lib/i18n-zh.ts +2 -0
- package/app/lib/mcp-agents.ts +14 -4
- package/bin/lib/mcp-install.js +3 -3
- package/bin/lib/utils.js +12 -0
- package/package.json +1 -1
- package/scripts/setup.js +10 -3
- package/app/components/settings/AgentsTab.tsx +0 -240
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<h1 align="center">MindOS</h1>
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
|
-
<strong>Human Thinks Here,
|
|
8
|
+
<strong>Human Thinks Here, Agents Act There.</strong>
|
|
9
9
|
</p>
|
|
10
10
|
|
|
11
11
|
<p align="center">
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-6366f1.svg?style=for-the-badge" alt="MIT License"></a>
|
|
21
21
|
</p>
|
|
22
22
|
|
|
23
|
-
MindOS is
|
|
23
|
+
MindOS is where you think, and where your AI agents act — a local-first knowledge base shared between you and every AI you use. **Share your brain with every AI — every thought grows.**
|
|
24
24
|
|
|
25
25
|
---
|
|
26
26
|
|
package/README_zh.md
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-6366f1.svg?style=for-the-badge" alt="MIT License"></a>
|
|
21
21
|
</p>
|
|
22
22
|
|
|
23
|
-
MindOS
|
|
23
|
+
MindOS 是你思考的地方,也是 AI Agent 行动的起点——一个你和所有 AI 共享的本地知识库。**每次思考,都在生长。**
|
|
24
24
|
|
|
25
25
|
---
|
|
26
26
|
|
|
@@ -4,6 +4,13 @@ import fs from 'fs';
|
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import { MCP_AGENTS, expandHome } from '@/lib/mcp-agents';
|
|
6
6
|
|
|
7
|
+
/** Parse JSONC — strips single-line (//) and block comments before JSON.parse */
|
|
8
|
+
function parseJsonc(text: string): Record<string, unknown> {
|
|
9
|
+
let stripped = text.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*$)/gm, (m, g) => g ? '' : m);
|
|
10
|
+
stripped = stripped.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
11
|
+
return JSON.parse(stripped);
|
|
12
|
+
}
|
|
13
|
+
|
|
7
14
|
interface AgentInstallItem {
|
|
8
15
|
key: string;
|
|
9
16
|
scope: 'project' | 'global';
|
|
@@ -92,7 +99,7 @@ export async function POST(req: NextRequest) {
|
|
|
92
99
|
// Read existing config
|
|
93
100
|
let config: Record<string, unknown> = {};
|
|
94
101
|
if (fs.existsSync(absPath)) {
|
|
95
|
-
config =
|
|
102
|
+
config = parseJsonc(fs.readFileSync(absPath, 'utf-8'));
|
|
96
103
|
}
|
|
97
104
|
|
|
98
105
|
// Merge — only touch mcpServers.mindos
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
export const dynamic = 'force-dynamic';
|
|
2
|
+
import { NextResponse } from 'next/server';
|
|
3
|
+
import { execSync, spawn } from 'node:child_process';
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
|
|
8
|
+
const CONFIG_PATH = resolve(homedir(), '.mindos', 'config.json');
|
|
9
|
+
|
|
10
|
+
function readConfig(): Record<string, unknown> {
|
|
11
|
+
try { return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8')); }
|
|
12
|
+
catch { return {}; }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Kill process(es) listening on the given port.
|
|
17
|
+
* Tries lsof first, falls back to ss + manual kill.
|
|
18
|
+
*/
|
|
19
|
+
function killByPort(port: number) {
|
|
20
|
+
try {
|
|
21
|
+
execSync(`lsof -ti :${port} | xargs kill -9 2>/dev/null`, { stdio: 'ignore' });
|
|
22
|
+
return;
|
|
23
|
+
} catch { /* lsof not available */ }
|
|
24
|
+
try {
|
|
25
|
+
const output = execSync('ss -tlnp 2>/dev/null', { encoding: 'utf-8' });
|
|
26
|
+
const portRe = new RegExp(`:${port}(?!\\d)`);
|
|
27
|
+
for (const line of output.split('\n')) {
|
|
28
|
+
if (!portRe.test(line)) continue;
|
|
29
|
+
const pidMatch = line.match(/pid=(\d+)/g);
|
|
30
|
+
if (pidMatch) {
|
|
31
|
+
for (const m of pidMatch) {
|
|
32
|
+
const pid = Number(m.slice(4));
|
|
33
|
+
if (pid > 0) try { process.kill(pid, 'SIGKILL'); } catch {}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
} catch { /* no process to kill */ }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* POST /api/mcp/restart — kill the MCP server process and spawn a new one.
|
|
42
|
+
*
|
|
43
|
+
* Unlike /api/restart which restarts the entire MindOS (Web + MCP),
|
|
44
|
+
* this endpoint only restarts the MCP server. The Web UI stays up.
|
|
45
|
+
*/
|
|
46
|
+
export async function POST() {
|
|
47
|
+
try {
|
|
48
|
+
const cfg = readConfig();
|
|
49
|
+
const mcpPort = (cfg.mcpPort as number) ?? 8781;
|
|
50
|
+
const webPort = process.env.MINDOS_WEB_PORT || '3456';
|
|
51
|
+
const authToken = cfg.authToken as string | undefined;
|
|
52
|
+
|
|
53
|
+
// Step 1: Kill process on MCP port
|
|
54
|
+
killByPort(mcpPort);
|
|
55
|
+
|
|
56
|
+
// Step 2: Wait briefly for port to free
|
|
57
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
58
|
+
|
|
59
|
+
// Step 3: Spawn new MCP server
|
|
60
|
+
const root = resolve(process.cwd(), '..');
|
|
61
|
+
const mcpDir = resolve(root, 'mcp');
|
|
62
|
+
|
|
63
|
+
if (!existsSync(resolve(mcpDir, 'node_modules'))) {
|
|
64
|
+
return NextResponse.json({ error: 'MCP dependencies not installed' }, { status: 500 });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const env: NodeJS.ProcessEnv = {
|
|
68
|
+
...process.env,
|
|
69
|
+
MCP_PORT: String(mcpPort),
|
|
70
|
+
MCP_HOST: process.env.MCP_HOST || '0.0.0.0',
|
|
71
|
+
MINDOS_URL: process.env.MINDOS_URL || `http://127.0.0.1:${webPort}`,
|
|
72
|
+
...(authToken ? { AUTH_TOKEN: authToken } : {}),
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const child = spawn('npx', ['tsx', 'src/index.ts'], {
|
|
76
|
+
cwd: mcpDir,
|
|
77
|
+
detached: true,
|
|
78
|
+
stdio: 'ignore',
|
|
79
|
+
env,
|
|
80
|
+
});
|
|
81
|
+
child.unref();
|
|
82
|
+
|
|
83
|
+
return NextResponse.json({ ok: true, pid: child.pid, port: mcpPort });
|
|
84
|
+
} catch (err) {
|
|
85
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
86
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState
|
|
4
|
-
import { Loader2, RefreshCw, ChevronDown, ChevronRight, CheckCircle2, AlertCircle,
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { Loader2, RefreshCw, ChevronDown, ChevronRight, CheckCircle2, AlertCircle, Settings } from 'lucide-react';
|
|
5
5
|
import { useMcpData } from '@/hooks/useMcpData';
|
|
6
6
|
import { useLocale } from '@/lib/LocaleContext';
|
|
7
|
-
import { generateSnippet } from '@/lib/mcp-snippets';
|
|
8
|
-
import { copyToClipboard } from '@/lib/clipboard';
|
|
9
7
|
import { Toggle } from '../settings/Primitives';
|
|
10
|
-
import type { AgentInfo,
|
|
8
|
+
import type { AgentInfo, SkillInfo } from '../settings/types';
|
|
11
9
|
import PanelHeader from './PanelHeader';
|
|
12
10
|
|
|
13
11
|
interface AgentsPanelProps {
|
|
@@ -22,7 +20,6 @@ export default function AgentsPanel({ active, maximized, onMaximize }: AgentsPan
|
|
|
22
20
|
const mcp = useMcpData();
|
|
23
21
|
const [refreshing, setRefreshing] = useState(false);
|
|
24
22
|
const [showNotDetected, setShowNotDetected] = useState(false);
|
|
25
|
-
const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
|
|
26
23
|
const [showBuiltinSkills, setShowBuiltinSkills] = useState(false);
|
|
27
24
|
|
|
28
25
|
const handleRefresh = async () => {
|
|
@@ -31,10 +28,6 @@ export default function AgentsPanel({ active, maximized, onMaximize }: AgentsPan
|
|
|
31
28
|
setRefreshing(false);
|
|
32
29
|
};
|
|
33
30
|
|
|
34
|
-
const toggleAgent = (key: string) => {
|
|
35
|
-
setExpandedAgent(prev => prev === key ? null : key);
|
|
36
|
-
};
|
|
37
|
-
|
|
38
31
|
const connected = mcp.agents.filter(a => a.present && a.installed);
|
|
39
32
|
const detected = mcp.agents.filter(a => a.present && !a.installed);
|
|
40
33
|
const notFound = mcp.agents.filter(a => !a.present);
|
|
@@ -102,9 +95,6 @@ export default function AgentsPanel({ active, maximized, onMaximize }: AgentsPan
|
|
|
102
95
|
key={agent.key}
|
|
103
96
|
agent={agent}
|
|
104
97
|
agentStatus="connected"
|
|
105
|
-
mcpStatus={mcp.status}
|
|
106
|
-
expanded={expandedAgent === agent.key}
|
|
107
|
-
onToggle={() => toggleAgent(agent.key)}
|
|
108
98
|
onInstallAgent={mcp.installAgent}
|
|
109
99
|
t={p}
|
|
110
100
|
/>
|
|
@@ -123,9 +113,6 @@ export default function AgentsPanel({ active, maximized, onMaximize }: AgentsPan
|
|
|
123
113
|
key={agent.key}
|
|
124
114
|
agent={agent}
|
|
125
115
|
agentStatus="detected"
|
|
126
|
-
mcpStatus={mcp.status}
|
|
127
|
-
expanded={expandedAgent === agent.key}
|
|
128
|
-
onToggle={() => toggleAgent(agent.key)}
|
|
129
116
|
onInstallAgent={mcp.installAgent}
|
|
130
117
|
t={p}
|
|
131
118
|
/>
|
|
@@ -149,9 +136,6 @@ export default function AgentsPanel({ active, maximized, onMaximize }: AgentsPan
|
|
|
149
136
|
key={agent.key}
|
|
150
137
|
agent={agent}
|
|
151
138
|
agentStatus="notFound"
|
|
152
|
-
mcpStatus={mcp.status}
|
|
153
|
-
expanded={expandedAgent === agent.key}
|
|
154
|
-
onToggle={() => toggleAgent(agent.key)}
|
|
155
139
|
onInstallAgent={mcp.installAgent}
|
|
156
140
|
t={p}
|
|
157
141
|
/>
|
|
@@ -239,129 +223,55 @@ function SkillRow({ skill, onToggle }: { skill: SkillInfo; onToggle: (name: stri
|
|
|
239
223
|
);
|
|
240
224
|
}
|
|
241
225
|
|
|
242
|
-
/* ── Agent Card ── */
|
|
226
|
+
/* ── Agent Card (compact — no snippet, config viewing is in Settings) ── */
|
|
243
227
|
|
|
244
|
-
function AgentCard({ agent, agentStatus,
|
|
228
|
+
function AgentCard({ agent, agentStatus, onInstallAgent, t }: {
|
|
245
229
|
agent: AgentInfo;
|
|
246
230
|
agentStatus: 'connected' | 'detected' | 'notFound';
|
|
247
|
-
|
|
248
|
-
expanded: boolean;
|
|
249
|
-
onToggle: () => void;
|
|
250
|
-
onInstallAgent: (key: string, opts?: { scope?: string; transport?: string }) => Promise<boolean>;
|
|
231
|
+
onInstallAgent: (key: string) => Promise<boolean>;
|
|
251
232
|
t: Record<string, any>;
|
|
252
233
|
}) {
|
|
253
|
-
const [transport, setTransport] = useState<'stdio' | 'http'>('stdio');
|
|
254
|
-
const [copied, setCopied] = useState(false);
|
|
255
234
|
const [installing, setInstalling] = useState(false);
|
|
256
235
|
const [result, setResult] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
|
257
236
|
|
|
258
237
|
const dot = agentStatus === 'connected' ? 'bg-emerald-500' : agentStatus === 'detected' ? 'bg-amber-500' : 'bg-zinc-400';
|
|
259
238
|
|
|
260
|
-
const snippet = useMemo(() => generateSnippet(agent, mcpStatus, transport), [agent, mcpStatus, transport]);
|
|
261
|
-
|
|
262
|
-
const handleCopy = useCallback(async () => {
|
|
263
|
-
const ok = await copyToClipboard(snippet.snippet);
|
|
264
|
-
if (ok) {
|
|
265
|
-
setCopied(true);
|
|
266
|
-
setTimeout(() => setCopied(false), 2000);
|
|
267
|
-
}
|
|
268
|
-
}, [snippet.snippet]);
|
|
269
|
-
|
|
270
239
|
const handleInstall = async () => {
|
|
271
240
|
setInstalling(true);
|
|
272
241
|
setResult(null);
|
|
273
242
|
const ok = await onInstallAgent(agent.key);
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
setResult({ type: 'error', text: 'Install failed' });
|
|
278
|
-
}
|
|
243
|
+
setResult(ok
|
|
244
|
+
? { type: 'success', text: `${agent.name} ${t.connected}` }
|
|
245
|
+
: { type: 'error', text: 'Install failed' });
|
|
279
246
|
setInstalling(false);
|
|
280
247
|
};
|
|
281
248
|
|
|
282
249
|
return (
|
|
283
|
-
<div className="rounded-lg border border-border/60 bg-card/30
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
<span className="text-xs font-medium text-foreground truncate">{agent.name}</span>
|
|
292
|
-
{agentStatus === 'connected' && agent.transport && (
|
|
293
|
-
<span className="text-2xs px-1 py-0.5 rounded bg-muted text-muted-foreground shrink-0">{agent.transport}</span>
|
|
294
|
-
)}
|
|
295
|
-
</div>
|
|
296
|
-
{expanded ? <ChevronDown size={10} className="text-muted-foreground shrink-0" /> : <ChevronRight size={10} className="text-muted-foreground shrink-0" />}
|
|
297
|
-
</button>
|
|
298
|
-
|
|
299
|
-
{/* Expanded: snippet + actions */}
|
|
300
|
-
{expanded && (
|
|
301
|
-
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-2.5">
|
|
302
|
-
{/* Detected: Connect button */}
|
|
303
|
-
{agentStatus === 'detected' && (
|
|
304
|
-
<>
|
|
305
|
-
<button onClick={handleInstall} disabled={installing}
|
|
306
|
-
className="w-full flex items-center justify-center gap-1.5 px-2.5 py-1.5 text-2xs rounded-md font-medium text-white disabled:opacity-50 transition-colors"
|
|
307
|
-
style={{ background: 'var(--amber)' }}>
|
|
308
|
-
{installing ? <Loader2 size={11} className="animate-spin" /> : null}
|
|
309
|
-
{installing ? t.installing : t.install(agent.name)}
|
|
310
|
-
</button>
|
|
311
|
-
{result && (
|
|
312
|
-
<div className={`flex items-center gap-1.5 text-2xs ${result.type === 'success' ? 'text-emerald-600 dark:text-emerald-400' : 'text-destructive'}`}>
|
|
313
|
-
{result.type === 'success' ? <CheckCircle2 size={11} /> : <AlertCircle size={11} />}
|
|
314
|
-
{result.text}
|
|
315
|
-
</div>
|
|
316
|
-
)}
|
|
317
|
-
</>
|
|
318
|
-
)}
|
|
319
|
-
|
|
320
|
-
{/* Transport toggle */}
|
|
321
|
-
<div className="flex items-center rounded-md border border-border overflow-hidden w-fit">
|
|
322
|
-
<button
|
|
323
|
-
onClick={() => setTransport('stdio')}
|
|
324
|
-
className={`flex items-center gap-1 px-2 py-1 text-2xs transition-colors ${
|
|
325
|
-
transport === 'stdio' ? 'bg-muted text-foreground font-medium' : 'text-muted-foreground hover:text-foreground'
|
|
326
|
-
}`}
|
|
327
|
-
>
|
|
328
|
-
<Monitor size={10} />
|
|
329
|
-
{t.transportLocal}
|
|
330
|
-
</button>
|
|
331
|
-
<button
|
|
332
|
-
onClick={() => setTransport('http')}
|
|
333
|
-
className={`flex items-center gap-1 px-2 py-1 text-2xs transition-colors ${
|
|
334
|
-
transport === 'http' ? 'bg-muted text-foreground font-medium' : 'text-muted-foreground hover:text-foreground'
|
|
335
|
-
}`}
|
|
336
|
-
>
|
|
337
|
-
<Globe size={10} />
|
|
338
|
-
{t.transportRemote}
|
|
339
|
-
</button>
|
|
340
|
-
</div>
|
|
341
|
-
|
|
342
|
-
{/* No auth warning for HTTP */}
|
|
343
|
-
{transport === 'http' && mcpStatus && !mcpStatus.authConfigured && (
|
|
344
|
-
<p className="text-2xs" style={{ color: 'var(--amber)' }}>{t.noAuthWarning}</p>
|
|
345
|
-
)}
|
|
250
|
+
<div className="rounded-lg border border-border/60 bg-card/30 px-3 py-2 flex items-center justify-between gap-2">
|
|
251
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
252
|
+
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${dot}`} />
|
|
253
|
+
<span className="text-xs font-medium text-foreground truncate">{agent.name}</span>
|
|
254
|
+
{agentStatus === 'connected' && agent.transport && (
|
|
255
|
+
<span className="text-2xs px-1 py-0.5 rounded bg-muted text-muted-foreground shrink-0">{agent.transport}</span>
|
|
256
|
+
)}
|
|
257
|
+
</div>
|
|
346
258
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
259
|
+
{/* Detected: Install button */}
|
|
260
|
+
{agentStatus === 'detected' && (
|
|
261
|
+
<button onClick={handleInstall} disabled={installing}
|
|
262
|
+
className="flex items-center gap-1 px-2 py-1 text-2xs rounded-md font-medium text-white disabled:opacity-50 transition-colors shrink-0"
|
|
263
|
+
style={{ background: 'var(--amber)' }}>
|
|
264
|
+
{installing ? <Loader2 size={10} className="animate-spin" /> : null}
|
|
265
|
+
{installing ? t.installing : t.install(agent.name)}
|
|
266
|
+
</button>
|
|
267
|
+
)}
|
|
351
268
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
{copied ? <Check size={10} /> : <Copy size={10} />}
|
|
359
|
-
{copied ? t.copied : t.copyConfig}
|
|
360
|
-
</button>
|
|
361
|
-
<span className="text-muted-foreground">→</span>
|
|
362
|
-
<span className="font-mono text-muted-foreground truncate">{snippet.path}</span>
|
|
363
|
-
</div>
|
|
364
|
-
</div>
|
|
269
|
+
{/* Install result */}
|
|
270
|
+
{result && (
|
|
271
|
+
<span className={`flex items-center gap-1 text-2xs shrink-0 ${result.type === 'success' ? 'text-emerald-600 dark:text-emerald-400' : 'text-destructive'}`}>
|
|
272
|
+
{result.type === 'success' ? <CheckCircle2 size={10} /> : <AlertCircle size={10} />}
|
|
273
|
+
{result.text}
|
|
274
|
+
</span>
|
|
365
275
|
)}
|
|
366
276
|
</div>
|
|
367
277
|
);
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useState, useMemo, useRef, useEffect } from 'react';
|
|
2
|
+
import { Loader2, ChevronDown, Copy, Check, Monitor, Globe, AlertCircle, RotateCcw, RefreshCw } from 'lucide-react';
|
|
2
3
|
import { useMcpDataOptional } from '@/hooks/useMcpData';
|
|
3
|
-
import
|
|
4
|
+
import { generateSnippet } from '@/lib/mcp-snippets';
|
|
5
|
+
import { copyToClipboard } from '@/lib/clipboard';
|
|
6
|
+
import { apiFetch } from '@/lib/api';
|
|
7
|
+
import type { McpTabProps, McpStatus, AgentInfo } from './types';
|
|
4
8
|
import AgentInstall from './McpAgentInstall';
|
|
5
9
|
import SkillsSection from './McpSkillsSection';
|
|
6
10
|
|
|
@@ -13,6 +17,15 @@ export function McpTab({ t }: McpTabProps) {
|
|
|
13
17
|
const mcp = useMcpDataOptional();
|
|
14
18
|
const m = t.settings?.mcp;
|
|
15
19
|
|
|
20
|
+
const [restarting, setRestarting] = useState(false);
|
|
21
|
+
const [selectedAgent, setSelectedAgent] = useState('');
|
|
22
|
+
const [transport, setTransport] = useState<'stdio' | 'http'>('stdio');
|
|
23
|
+
const [copied, setCopied] = useState(false);
|
|
24
|
+
const restartPollRef = useRef<ReturnType<typeof setInterval>>(undefined);
|
|
25
|
+
|
|
26
|
+
// Cleanup restart poll on unmount
|
|
27
|
+
useEffect(() => () => clearInterval(restartPollRef.current), []);
|
|
28
|
+
|
|
16
29
|
if (!mcp || mcp.loading) {
|
|
17
30
|
return (
|
|
18
31
|
<div className="flex justify-center py-8">
|
|
@@ -21,29 +34,62 @@ export function McpTab({ t }: McpTabProps) {
|
|
|
21
34
|
);
|
|
22
35
|
}
|
|
23
36
|
|
|
37
|
+
const connectedAgents = mcp.agents.filter(a => a.present && a.installed);
|
|
38
|
+
const detectedAgents = mcp.agents.filter(a => a.present && !a.installed);
|
|
39
|
+
const notFoundAgents = mcp.agents.filter(a => !a.present);
|
|
40
|
+
|
|
41
|
+
// Auto-select first agent if none selected
|
|
42
|
+
const effectiveSelected = selectedAgent || (mcp.agents[0]?.key ?? '');
|
|
43
|
+
const currentAgent = mcp.agents.find(a => a.key === effectiveSelected);
|
|
44
|
+
|
|
24
45
|
return (
|
|
25
46
|
<div className="space-y-6">
|
|
26
|
-
{/* Server status
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
47
|
+
{/* Server status with restart */}
|
|
48
|
+
<McpStatusCard
|
|
49
|
+
status={mcp.status}
|
|
50
|
+
restarting={restarting}
|
|
51
|
+
onRestart={async () => {
|
|
52
|
+
setRestarting(true);
|
|
53
|
+
try { await apiFetch('/api/mcp/restart', { method: 'POST' }); } catch {}
|
|
54
|
+
const deadline = Date.now() + 60_000;
|
|
55
|
+
clearInterval(restartPollRef.current);
|
|
56
|
+
restartPollRef.current = setInterval(async () => {
|
|
57
|
+
if (Date.now() > deadline) { clearInterval(restartPollRef.current); setRestarting(false); return; }
|
|
58
|
+
try {
|
|
59
|
+
const s = await apiFetch<McpStatus>('/api/mcp/status', { timeout: 3000 });
|
|
60
|
+
if (s.running) { clearInterval(restartPollRef.current); setRestarting(false); mcp.refresh(); }
|
|
61
|
+
} catch {}
|
|
62
|
+
}, 3000);
|
|
63
|
+
}}
|
|
64
|
+
onRefresh={mcp.refresh}
|
|
65
|
+
m={m}
|
|
66
|
+
/>
|
|
67
|
+
|
|
68
|
+
{/* MCP Config Viewer */}
|
|
69
|
+
{mcp.agents.length > 0 && (
|
|
70
|
+
<div>
|
|
71
|
+
<h3 className="text-sm font-medium text-foreground mb-3">MCP</h3>
|
|
72
|
+
<AgentConfigViewer
|
|
73
|
+
connectedAgents={connectedAgents}
|
|
74
|
+
detectedAgents={detectedAgents}
|
|
75
|
+
notFoundAgents={notFoundAgents}
|
|
76
|
+
currentAgent={currentAgent ?? null}
|
|
77
|
+
mcpStatus={mcp.status}
|
|
78
|
+
selectedAgent={effectiveSelected}
|
|
79
|
+
onSelectAgent={(key) => setSelectedAgent(key)}
|
|
80
|
+
transport={transport}
|
|
81
|
+
onTransportChange={setTransport}
|
|
82
|
+
copied={copied}
|
|
83
|
+
onCopy={async (snippet) => {
|
|
84
|
+
const ok = await copyToClipboard(snippet);
|
|
85
|
+
if (ok) { setCopied(true); setTimeout(() => setCopied(false), 2000); }
|
|
86
|
+
}}
|
|
87
|
+
m={m}
|
|
88
|
+
/>
|
|
43
89
|
</div>
|
|
44
90
|
)}
|
|
45
91
|
|
|
46
|
-
{/* Skills
|
|
92
|
+
{/* Skills */}
|
|
47
93
|
<div>
|
|
48
94
|
<h3 className="text-sm font-medium text-foreground mb-3">{m?.skillsTitle ?? 'Skills'}</h3>
|
|
49
95
|
<SkillsSection t={t} />
|
|
@@ -57,3 +103,169 @@ export function McpTab({ t }: McpTabProps) {
|
|
|
57
103
|
</div>
|
|
58
104
|
);
|
|
59
105
|
}
|
|
106
|
+
|
|
107
|
+
/* ── MCP Status Card ── */
|
|
108
|
+
|
|
109
|
+
function McpStatusCard({ status, restarting, onRestart, onRefresh, m }: {
|
|
110
|
+
status: McpStatus | null;
|
|
111
|
+
restarting: boolean;
|
|
112
|
+
onRestart: () => void;
|
|
113
|
+
onRefresh: () => void;
|
|
114
|
+
m: Record<string, any> | undefined;
|
|
115
|
+
}) {
|
|
116
|
+
if (!status) return null;
|
|
117
|
+
return (
|
|
118
|
+
<div className="rounded-xl border border-border bg-card p-4 flex items-center justify-between">
|
|
119
|
+
<div className="flex items-center gap-2.5 text-xs">
|
|
120
|
+
{restarting ? (
|
|
121
|
+
<>
|
|
122
|
+
<Loader2 size={12} className="animate-spin" style={{ color: 'var(--amber)' }} />
|
|
123
|
+
<span style={{ color: 'var(--amber)' }}>{m?.restarting ?? 'Restarting...'}</span>
|
|
124
|
+
</>
|
|
125
|
+
) : (
|
|
126
|
+
<>
|
|
127
|
+
<span className={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${status.running ? 'bg-success' : 'bg-muted-foreground'}`} />
|
|
128
|
+
<span className="text-foreground font-medium">
|
|
129
|
+
{status.running ? (m?.running ?? 'Running') : (m?.stopped ?? 'Stopped')}
|
|
130
|
+
</span>
|
|
131
|
+
{status.running && (
|
|
132
|
+
<>
|
|
133
|
+
<span className="text-muted-foreground">·</span>
|
|
134
|
+
<span className="font-mono text-muted-foreground">{status.endpoint}</span>
|
|
135
|
+
<span className="text-muted-foreground">·</span>
|
|
136
|
+
<span className="text-muted-foreground">{status.toolCount} tools</span>
|
|
137
|
+
</>
|
|
138
|
+
)}
|
|
139
|
+
</>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
<div className="flex items-center gap-2">
|
|
143
|
+
{!status.running && !restarting && (
|
|
144
|
+
<button onClick={onRestart}
|
|
145
|
+
className="flex items-center gap-1.5 px-2.5 py-1 text-xs rounded-lg font-medium text-white transition-colors"
|
|
146
|
+
style={{ background: 'var(--amber)' }}>
|
|
147
|
+
<RotateCcw size={12} /> {m?.restart ?? 'Restart'}
|
|
148
|
+
</button>
|
|
149
|
+
)}
|
|
150
|
+
<button onClick={onRefresh}
|
|
151
|
+
className="p-1.5 rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors">
|
|
152
|
+
<RefreshCw size={12} />
|
|
153
|
+
</button>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/* ── Agent Config Viewer (dropdown + snippet) ── */
|
|
160
|
+
|
|
161
|
+
function AgentConfigViewer({ connectedAgents, detectedAgents, notFoundAgents, currentAgent, mcpStatus, selectedAgent, onSelectAgent, transport, onTransportChange, copied, onCopy, m }: {
|
|
162
|
+
connectedAgents: AgentInfo[];
|
|
163
|
+
detectedAgents: AgentInfo[];
|
|
164
|
+
notFoundAgents: AgentInfo[];
|
|
165
|
+
currentAgent: AgentInfo | null;
|
|
166
|
+
mcpStatus: McpStatus | null;
|
|
167
|
+
selectedAgent: string;
|
|
168
|
+
onSelectAgent: (key: string) => void;
|
|
169
|
+
transport: 'stdio' | 'http';
|
|
170
|
+
onTransportChange: (t: 'stdio' | 'http') => void;
|
|
171
|
+
copied: boolean;
|
|
172
|
+
onCopy: (snippet: string) => void;
|
|
173
|
+
m: Record<string, any> | undefined;
|
|
174
|
+
}) {
|
|
175
|
+
const snippet = useMemo(
|
|
176
|
+
() => currentAgent ? generateSnippet(currentAgent, mcpStatus, transport) : null,
|
|
177
|
+
[currentAgent, mcpStatus, transport]
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<div className="rounded-xl border border-border bg-card p-4 space-y-3">
|
|
182
|
+
{/* Agent selector */}
|
|
183
|
+
<div className="relative">
|
|
184
|
+
<select
|
|
185
|
+
value={selectedAgent}
|
|
186
|
+
onChange={(e) => onSelectAgent(e.target.value)}
|
|
187
|
+
className="w-full appearance-none px-3 py-2 pr-8 text-xs rounded-lg border border-border bg-background text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
188
|
+
>
|
|
189
|
+
{connectedAgents.length > 0 && (
|
|
190
|
+
<optgroup label={m?.connectedGroup ?? 'Connected'}>
|
|
191
|
+
{connectedAgents.map(a => (
|
|
192
|
+
<option key={a.key} value={a.key}>
|
|
193
|
+
✓ {a.name} — {a.transport ?? 'stdio'} · {a.scope ?? 'global'}
|
|
194
|
+
</option>
|
|
195
|
+
))}
|
|
196
|
+
</optgroup>
|
|
197
|
+
)}
|
|
198
|
+
{detectedAgents.length > 0 && (
|
|
199
|
+
<optgroup label={m?.detectedGroup ?? 'Detected (not configured)'}>
|
|
200
|
+
{detectedAgents.map(a => (
|
|
201
|
+
<option key={a.key} value={a.key}>
|
|
202
|
+
○ {a.name} — {m?.notConfigured ?? 'not configured'}
|
|
203
|
+
</option>
|
|
204
|
+
))}
|
|
205
|
+
</optgroup>
|
|
206
|
+
)}
|
|
207
|
+
{notFoundAgents.length > 0 && (
|
|
208
|
+
<optgroup label={m?.notFoundGroup ?? 'Not Installed'}>
|
|
209
|
+
{notFoundAgents.map(a => (
|
|
210
|
+
<option key={a.key} value={a.key}>
|
|
211
|
+
· {a.name}
|
|
212
|
+
</option>
|
|
213
|
+
))}
|
|
214
|
+
</optgroup>
|
|
215
|
+
)}
|
|
216
|
+
</select>
|
|
217
|
+
<ChevronDown size={14} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none" />
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
{currentAgent && (
|
|
221
|
+
<>
|
|
222
|
+
{/* Transport toggle */}
|
|
223
|
+
<div className="flex items-center rounded-lg border border-border overflow-hidden w-fit">
|
|
224
|
+
<button
|
|
225
|
+
onClick={() => onTransportChange('stdio')}
|
|
226
|
+
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs transition-colors ${
|
|
227
|
+
transport === 'stdio' ? 'bg-muted text-foreground font-medium' : 'text-muted-foreground hover:text-foreground'
|
|
228
|
+
}`}
|
|
229
|
+
>
|
|
230
|
+
<Monitor size={12} /> {m?.transportLocal ?? 'Local (stdio)'}
|
|
231
|
+
</button>
|
|
232
|
+
<button
|
|
233
|
+
onClick={() => onTransportChange('http')}
|
|
234
|
+
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs transition-colors ${
|
|
235
|
+
transport === 'http' ? 'bg-muted text-foreground font-medium' : 'text-muted-foreground hover:text-foreground'
|
|
236
|
+
}`}
|
|
237
|
+
>
|
|
238
|
+
<Globe size={12} /> {m?.transportRemote ?? 'Remote (HTTP)'}
|
|
239
|
+
</button>
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
{/* Auth warning */}
|
|
243
|
+
{transport === 'http' && mcpStatus && !mcpStatus.authConfigured && (
|
|
244
|
+
<p className="flex items-center gap-1.5 text-xs" style={{ color: 'var(--amber)' }}>
|
|
245
|
+
<AlertCircle size={12} />
|
|
246
|
+
{m?.noAuthWarning ?? 'Auth not configured. Run `mindos token` to set up.'}
|
|
247
|
+
</p>
|
|
248
|
+
)}
|
|
249
|
+
|
|
250
|
+
{/* Snippet */}
|
|
251
|
+
{snippet && (
|
|
252
|
+
<>
|
|
253
|
+
<pre className="text-[11px] font-mono bg-muted/50 border border-border rounded-lg p-3 overflow-x-auto whitespace-pre select-all max-h-[240px] overflow-y-auto">
|
|
254
|
+
{snippet.displaySnippet}
|
|
255
|
+
</pre>
|
|
256
|
+
<div className="flex items-center gap-3 text-xs">
|
|
257
|
+
<button onClick={() => onCopy(snippet.snippet)}
|
|
258
|
+
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0">
|
|
259
|
+
{copied ? <Check size={12} /> : <Copy size={12} />}
|
|
260
|
+
{copied ? (m?.copied ?? 'Copied!') : (m?.copyConfig ?? 'Copy config')}
|
|
261
|
+
</button>
|
|
262
|
+
<span className="text-muted-foreground">→</span>
|
|
263
|
+
<span className="font-mono text-muted-foreground truncate text-2xs">{snippet.path}</span>
|
|
264
|
+
</div>
|
|
265
|
+
</>
|
|
266
|
+
)}
|
|
267
|
+
</>
|
|
268
|
+
)}
|
|
269
|
+
</div>
|
|
270
|
+
);
|
|
271
|
+
}
|
package/app/lib/i18n-en.ts
CHANGED
|
@@ -415,6 +415,8 @@ export const en = {
|
|
|
415
415
|
mcpServer: 'MCP Server',
|
|
416
416
|
running: 'Running',
|
|
417
417
|
stopped: 'Not running',
|
|
418
|
+
restarting: 'Restarting...',
|
|
419
|
+
restart: 'Restart',
|
|
418
420
|
onPort: (port: number) => `on :${port}`,
|
|
419
421
|
refresh: 'Refresh',
|
|
420
422
|
refreshing: 'Refreshing...',
|
package/app/lib/i18n-zh.ts
CHANGED
package/app/lib/mcp-agents.ts
CHANGED
|
@@ -3,6 +3,16 @@ import path from 'path';
|
|
|
3
3
|
import os from 'os';
|
|
4
4
|
import { execSync } from 'child_process';
|
|
5
5
|
|
|
6
|
+
/** Parse JSONC — strips single-line (//) and block comments before JSON.parse */
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
8
|
+
function parseJsonc(text: string): any {
|
|
9
|
+
// Strip single-line comments (not inside strings)
|
|
10
|
+
let stripped = text.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*$)/gm, (m, g) => g ? '' : m);
|
|
11
|
+
// Strip block comments
|
|
12
|
+
stripped = stripped.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
13
|
+
return JSON.parse(stripped);
|
|
14
|
+
}
|
|
15
|
+
|
|
6
16
|
export function expandHome(p: string): string {
|
|
7
17
|
return p.startsWith('~/') ? path.resolve(os.homedir(), p.slice(2)) : p;
|
|
8
18
|
}
|
|
@@ -91,11 +101,11 @@ export const MCP_AGENTS: Record<string, AgentDef> = {
|
|
|
91
101
|
'codebuddy': {
|
|
92
102
|
name: 'CodeBuddy',
|
|
93
103
|
project: null,
|
|
94
|
-
global: '~/.
|
|
104
|
+
global: '~/.codebuddy/mcp.json',
|
|
95
105
|
key: 'mcpServers',
|
|
96
106
|
preferredTransport: 'stdio',
|
|
97
|
-
presenceCli: '
|
|
98
|
-
presenceDirs: ['~/.
|
|
107
|
+
presenceCli: 'codebuddy',
|
|
108
|
+
presenceDirs: ['~/.codebuddy/'],
|
|
99
109
|
},
|
|
100
110
|
'iflow-cli': {
|
|
101
111
|
name: 'iFlow CLI',
|
|
@@ -227,7 +237,7 @@ export function detectInstalled(agentKey: string): { installed: boolean; scope?:
|
|
|
227
237
|
}
|
|
228
238
|
} else {
|
|
229
239
|
// JSON format (default)
|
|
230
|
-
const config =
|
|
240
|
+
const config = parseJsonc(content);
|
|
231
241
|
const servers = config[agent.key];
|
|
232
242
|
if (servers?.mindos) {
|
|
233
243
|
const entry = servers.mindos;
|
package/bin/lib/mcp-install.js
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
import { CONFIG_PATH } from './constants.js';
|
|
4
4
|
import { bold, dim, cyan, green, red, yellow } from './colors.js';
|
|
5
|
-
import { expandHome } from './utils.js';
|
|
5
|
+
import { expandHome, parseJsonc } from './utils.js';
|
|
6
6
|
import { MCP_AGENTS, detectAgentPresence } from './mcp-agents.js';
|
|
7
7
|
|
|
8
8
|
export { MCP_AGENTS };
|
|
@@ -194,7 +194,7 @@ export async function mcpInstall() {
|
|
|
194
194
|
const abs = expandHome(cfgPath);
|
|
195
195
|
if (!existsSync(abs)) continue;
|
|
196
196
|
try {
|
|
197
|
-
const config =
|
|
197
|
+
const config = parseJsonc(readFileSync(abs, 'utf-8'));
|
|
198
198
|
if (config[agent.key]?.mindos) { installed = true; break; }
|
|
199
199
|
} catch {}
|
|
200
200
|
}
|
|
@@ -313,7 +313,7 @@ export async function mcpInstall() {
|
|
|
313
313
|
const absPath = expandHome(configPath);
|
|
314
314
|
let config = {};
|
|
315
315
|
if (existsSync(absPath)) {
|
|
316
|
-
try { config =
|
|
316
|
+
try { config = parseJsonc(readFileSync(absPath, 'utf-8')); } catch {
|
|
317
317
|
console.error(red(` Failed to parse existing config: ${absPath} — skipping.`));
|
|
318
318
|
continue;
|
|
319
319
|
}
|
package/bin/lib/utils.js
CHANGED
|
@@ -37,3 +37,15 @@ export function npmInstall(cwd, extraFlags = '') {
|
|
|
37
37
|
export function expandHome(p) {
|
|
38
38
|
return p.startsWith('~/') ? resolve(homedir(), p.slice(2)) : p;
|
|
39
39
|
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parse JSONC — strips single-line and block comments before JSON.parse.
|
|
43
|
+
* VS Code-based editors (Cursor, Windsurf, Cline) use JSONC for config files.
|
|
44
|
+
*/
|
|
45
|
+
export function parseJsonc(text) {
|
|
46
|
+
// Strip single-line comments (not inside quoted strings)
|
|
47
|
+
let stripped = text.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*$)/gm, (m, g) => g ? '' : m);
|
|
48
|
+
// Strip block comments
|
|
49
|
+
stripped = stripped.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
50
|
+
return JSON.parse(stripped);
|
|
51
|
+
}
|
package/package.json
CHANGED
package/scripts/setup.js
CHANGED
|
@@ -598,6 +598,13 @@ function expandHomePath(p) {
|
|
|
598
598
|
return p.startsWith('~/') ? resolve(homedir(), p.slice(2)) : p;
|
|
599
599
|
}
|
|
600
600
|
|
|
601
|
+
/** Parse JSONC (JSON with // and /* */ comments) — for VS Code-based editor configs */
|
|
602
|
+
function parseJsonc(text) {
|
|
603
|
+
let stripped = text.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*$)/gm, (m, g) => g ? '' : m);
|
|
604
|
+
stripped = stripped.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
605
|
+
return JSON.parse(stripped);
|
|
606
|
+
}
|
|
607
|
+
|
|
601
608
|
/** Detect if an agent already has mindos configured (for pre-selection). */
|
|
602
609
|
function isAgentInstalled(agentKey) {
|
|
603
610
|
const agent = MCP_AGENTS[agentKey];
|
|
@@ -607,7 +614,7 @@ function isAgentInstalled(agentKey) {
|
|
|
607
614
|
const abs = expandHomePath(cfgPath);
|
|
608
615
|
if (!existsSync(abs)) continue;
|
|
609
616
|
try {
|
|
610
|
-
const config =
|
|
617
|
+
const config = parseJsonc(readFileSync(abs, 'utf-8'));
|
|
611
618
|
if (config[agent.key]?.mindos) return true;
|
|
612
619
|
} catch { /* ignore */ }
|
|
613
620
|
}
|
|
@@ -720,7 +727,7 @@ async function runMcpInstallStep(mcpPort, authToken) {
|
|
|
720
727
|
const abs = expandHomePath(cfgPath);
|
|
721
728
|
try {
|
|
722
729
|
let config = {};
|
|
723
|
-
if (existsSync(abs)) config =
|
|
730
|
+
if (existsSync(abs)) config = parseJsonc(readFileSync(abs, 'utf-8'));
|
|
724
731
|
if (!config[agent.key]) config[agent.key] = {};
|
|
725
732
|
config[agent.key].mindos = entry;
|
|
726
733
|
const dir = resolve(abs, '..');
|
|
@@ -740,7 +747,7 @@ async function runMcpInstallStep(mcpPort, authToken) {
|
|
|
740
747
|
/* ── Skill auto-install ────────────────────────────────────────────────────── */
|
|
741
748
|
|
|
742
749
|
const UNIVERSAL_AGENTS = new Set([
|
|
743
|
-
'
|
|
750
|
+
'cline', 'codex', 'cursor', 'gemini-cli',
|
|
744
751
|
'github-copilot', 'kimi-cli', 'opencode', 'warp',
|
|
745
752
|
]);
|
|
746
753
|
const SKILL_UNSUPPORTED = new Set(['claude-desktop']);
|
|
@@ -1,240 +0,0 @@
|
|
|
1
|
-
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
2
|
-
import { Loader2, RefreshCw, ChevronDown, ChevronRight, ExternalLink } from 'lucide-react';
|
|
3
|
-
import { apiFetch } from '@/lib/api';
|
|
4
|
-
import type { McpStatus, AgentInfo } from './types';
|
|
5
|
-
import type { Messages } from '@/lib/i18n';
|
|
6
|
-
|
|
7
|
-
interface AgentsTabProps {
|
|
8
|
-
t: Messages;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function AgentsTab({ t }: AgentsTabProps) {
|
|
12
|
-
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
|
13
|
-
const [mcpStatus, setMcpStatus] = useState<McpStatus | null>(null);
|
|
14
|
-
const [loading, setLoading] = useState(true);
|
|
15
|
-
const [error, setError] = useState(false);
|
|
16
|
-
const [refreshing, setRefreshing] = useState(false);
|
|
17
|
-
const [showNotDetected, setShowNotDetected] = useState(false);
|
|
18
|
-
const intervalRef = useRef<ReturnType<typeof setInterval>>(undefined);
|
|
19
|
-
|
|
20
|
-
const a = t.settings?.agents as Record<string, unknown> | undefined;
|
|
21
|
-
|
|
22
|
-
// i18n helpers with fallbacks
|
|
23
|
-
const txt = (key: string, fallback: string) => (a?.[key] as string) ?? fallback;
|
|
24
|
-
const txtFn = <T,>(key: string, fallback: (v: T) => string) =>
|
|
25
|
-
(a?.[key] as ((v: T) => string) | undefined) ?? fallback;
|
|
26
|
-
|
|
27
|
-
const fetchAll = useCallback(async (silent = false) => {
|
|
28
|
-
if (!silent) setError(false);
|
|
29
|
-
try {
|
|
30
|
-
const [statusData, agentsData] = await Promise.all([
|
|
31
|
-
apiFetch<McpStatus>('/api/mcp/status'),
|
|
32
|
-
apiFetch<{ agents: AgentInfo[] }>('/api/mcp/agents'),
|
|
33
|
-
]);
|
|
34
|
-
setMcpStatus(statusData);
|
|
35
|
-
setAgents(agentsData.agents);
|
|
36
|
-
setError(false);
|
|
37
|
-
} catch {
|
|
38
|
-
if (!silent) setError(true);
|
|
39
|
-
}
|
|
40
|
-
setLoading(false);
|
|
41
|
-
setRefreshing(false);
|
|
42
|
-
}, []);
|
|
43
|
-
|
|
44
|
-
// Initial fetch + 30s auto-refresh
|
|
45
|
-
useEffect(() => {
|
|
46
|
-
fetchAll();
|
|
47
|
-
intervalRef.current = setInterval(() => fetchAll(true), 30_000);
|
|
48
|
-
return () => clearInterval(intervalRef.current);
|
|
49
|
-
}, [fetchAll]);
|
|
50
|
-
|
|
51
|
-
const handleRefresh = () => {
|
|
52
|
-
setRefreshing(true);
|
|
53
|
-
fetchAll();
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
if (loading) {
|
|
57
|
-
return (
|
|
58
|
-
<div className="flex justify-center py-8">
|
|
59
|
-
<Loader2 size={18} className="animate-spin text-muted-foreground" />
|
|
60
|
-
</div>
|
|
61
|
-
);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
if (error && agents.length === 0) {
|
|
65
|
-
return (
|
|
66
|
-
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
|
67
|
-
<p className="text-sm text-destructive">{txt('fetchError', 'Failed to load agent data')}</p>
|
|
68
|
-
<button
|
|
69
|
-
onClick={handleRefresh}
|
|
70
|
-
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
71
|
-
>
|
|
72
|
-
<RefreshCw size={12} />
|
|
73
|
-
{txt('refresh', 'Refresh')}
|
|
74
|
-
</button>
|
|
75
|
-
</div>
|
|
76
|
-
);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Group agents
|
|
80
|
-
const connected = agents.filter(a => a.present && a.installed);
|
|
81
|
-
const detected = agents.filter(a => a.present && !a.installed);
|
|
82
|
-
const notFound = agents.filter(a => !a.present);
|
|
83
|
-
|
|
84
|
-
return (
|
|
85
|
-
<div className="space-y-5">
|
|
86
|
-
{/* MCP Server Status */}
|
|
87
|
-
<div className="rounded-xl border border-border bg-card p-4 flex items-center justify-between">
|
|
88
|
-
<div className="flex items-center gap-3">
|
|
89
|
-
<span className="text-sm font-medium text-foreground">{txt('mcpServer', 'MCP Server')}</span>
|
|
90
|
-
{mcpStatus?.running ? (
|
|
91
|
-
<span className="flex items-center gap-1.5 text-xs">
|
|
92
|
-
<span className="w-2 h-2 rounded-full bg-emerald-500 inline-block" />
|
|
93
|
-
<span className="text-emerald-600 dark:text-emerald-400">
|
|
94
|
-
{txt('running', 'Running')} {txtFn<number>('onPort', (p) => `on :${p}`)(mcpStatus.port)}
|
|
95
|
-
</span>
|
|
96
|
-
</span>
|
|
97
|
-
) : (
|
|
98
|
-
<span className="flex items-center gap-1.5 text-xs">
|
|
99
|
-
<span className="w-2 h-2 rounded-full bg-zinc-400 inline-block" />
|
|
100
|
-
<span className="text-muted-foreground">{txt('stopped', 'Not running')}</span>
|
|
101
|
-
</span>
|
|
102
|
-
)}
|
|
103
|
-
</div>
|
|
104
|
-
<button
|
|
105
|
-
onClick={handleRefresh}
|
|
106
|
-
disabled={refreshing}
|
|
107
|
-
className="flex items-center gap-1.5 px-2.5 py-1 text-xs rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
|
|
108
|
-
>
|
|
109
|
-
<RefreshCw size={12} className={refreshing ? 'animate-spin' : ''} />
|
|
110
|
-
{refreshing ? txt('refreshing', 'Refreshing...') : txt('refresh', 'Refresh')}
|
|
111
|
-
</button>
|
|
112
|
-
</div>
|
|
113
|
-
|
|
114
|
-
{/* Connected Agents */}
|
|
115
|
-
{connected.length > 0 && (
|
|
116
|
-
<section>
|
|
117
|
-
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-3">
|
|
118
|
-
{txtFn<number>('connectedCount', (n) => `Connected (${n})`)(connected.length)}
|
|
119
|
-
</h3>
|
|
120
|
-
<div className="space-y-2">
|
|
121
|
-
{connected.map(agent => (
|
|
122
|
-
<AgentCard key={agent.key} agent={agent} status="connected" />
|
|
123
|
-
))}
|
|
124
|
-
</div>
|
|
125
|
-
</section>
|
|
126
|
-
)}
|
|
127
|
-
|
|
128
|
-
{/* Detected but not configured */}
|
|
129
|
-
{detected.length > 0 && (
|
|
130
|
-
<section>
|
|
131
|
-
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-3">
|
|
132
|
-
{txtFn<number>('detectedCount', (n) => `Detected but not configured (${n})`)(detected.length)}
|
|
133
|
-
</h3>
|
|
134
|
-
<div className="space-y-2">
|
|
135
|
-
{detected.map(agent => (
|
|
136
|
-
<AgentCard
|
|
137
|
-
key={agent.key}
|
|
138
|
-
agent={agent}
|
|
139
|
-
status="detected"
|
|
140
|
-
connectLabel={txt('connect', 'Connect')}
|
|
141
|
-
/>
|
|
142
|
-
))}
|
|
143
|
-
</div>
|
|
144
|
-
</section>
|
|
145
|
-
)}
|
|
146
|
-
|
|
147
|
-
{/* Not Detected */}
|
|
148
|
-
{notFound.length > 0 && (
|
|
149
|
-
<section>
|
|
150
|
-
<button
|
|
151
|
-
onClick={() => setShowNotDetected(!showNotDetected)}
|
|
152
|
-
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wider mb-3 hover:text-foreground transition-colors"
|
|
153
|
-
>
|
|
154
|
-
{showNotDetected ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
|
155
|
-
{txtFn<number>('notDetectedCount', (n) => `Not Detected (${n})`)(notFound.length)}
|
|
156
|
-
</button>
|
|
157
|
-
{showNotDetected && (
|
|
158
|
-
<div className="space-y-2">
|
|
159
|
-
{notFound.map(agent => (
|
|
160
|
-
<AgentCard key={agent.key} agent={agent} status="notFound" />
|
|
161
|
-
))}
|
|
162
|
-
</div>
|
|
163
|
-
)}
|
|
164
|
-
</section>
|
|
165
|
-
)}
|
|
166
|
-
|
|
167
|
-
{/* Empty state */}
|
|
168
|
-
{agents.length === 0 && (
|
|
169
|
-
<p className="text-sm text-muted-foreground text-center py-4">
|
|
170
|
-
{txt('noAgents', 'No agents detected on this machine.')}
|
|
171
|
-
</p>
|
|
172
|
-
)}
|
|
173
|
-
|
|
174
|
-
{/* Auto-refresh hint */}
|
|
175
|
-
<p className="text-[10px] text-muted-foreground/60 text-center">
|
|
176
|
-
{txt('autoRefresh', 'Auto-refresh every 30s')}
|
|
177
|
-
</p>
|
|
178
|
-
</div>
|
|
179
|
-
);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/* ── Agent Card ──────────────────────────────────────────────── */
|
|
183
|
-
|
|
184
|
-
function AgentCard({
|
|
185
|
-
agent,
|
|
186
|
-
status,
|
|
187
|
-
connectLabel,
|
|
188
|
-
}: {
|
|
189
|
-
agent: AgentInfo;
|
|
190
|
-
status: 'connected' | 'detected' | 'notFound';
|
|
191
|
-
connectLabel?: string;
|
|
192
|
-
}) {
|
|
193
|
-
const dot =
|
|
194
|
-
status === 'connected' ? 'bg-emerald-500' :
|
|
195
|
-
status === 'detected' ? 'bg-amber-500' :
|
|
196
|
-
'bg-zinc-400';
|
|
197
|
-
|
|
198
|
-
return (
|
|
199
|
-
<div className="rounded-lg border border-border bg-card/50 px-4 py-3 flex items-center justify-between gap-3">
|
|
200
|
-
<div className="flex items-center gap-3 min-w-0">
|
|
201
|
-
<span className={`w-2 h-2 rounded-full shrink-0 ${dot}`} />
|
|
202
|
-
<span className="text-sm font-medium text-foreground truncate">{agent.name}</span>
|
|
203
|
-
{status === 'connected' && (
|
|
204
|
-
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
|
205
|
-
<span className="px-1.5 py-0.5 rounded bg-muted">{agent.transport}</span>
|
|
206
|
-
<span className="px-1.5 py-0.5 rounded bg-muted">{agent.scope}</span>
|
|
207
|
-
{agent.configPath && (
|
|
208
|
-
<span className="truncate max-w-[200px]" title={agent.configPath}>
|
|
209
|
-
{agent.configPath.replace(/^.*[/\\]/, '')}
|
|
210
|
-
</span>
|
|
211
|
-
)}
|
|
212
|
-
</div>
|
|
213
|
-
)}
|
|
214
|
-
</div>
|
|
215
|
-
{status === 'detected' && (
|
|
216
|
-
<a
|
|
217
|
-
href="#"
|
|
218
|
-
onClick={(e) => {
|
|
219
|
-
e.preventDefault();
|
|
220
|
-
// Navigate to MCP tab by dispatching a custom event
|
|
221
|
-
const settingsModal = document.querySelector('[role="dialog"][aria-label="Settings"]');
|
|
222
|
-
if (settingsModal) {
|
|
223
|
-
const mcpBtn = settingsModal.querySelectorAll('button');
|
|
224
|
-
for (const btn of mcpBtn) {
|
|
225
|
-
if (btn.textContent?.trim() === 'MCP') {
|
|
226
|
-
btn.click();
|
|
227
|
-
break;
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
}}
|
|
232
|
-
className="flex items-center gap-1 px-2.5 py-1 text-xs rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400 hover:bg-amber-500/20 transition-colors shrink-0"
|
|
233
|
-
>
|
|
234
|
-
{connectLabel ?? 'Connect'}
|
|
235
|
-
<ExternalLink size={11} />
|
|
236
|
-
</a>
|
|
237
|
-
)}
|
|
238
|
-
</div>
|
|
239
|
-
);
|
|
240
|
-
}
|