@geminilight/mindos 0.5.21 → 0.5.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/app/api/ask/route.ts +38 -9
- package/app/app/api/monitoring/route.ts +95 -0
- package/app/components/SettingsModal.tsx +8 -2
- package/app/components/settings/AgentsTab.tsx +240 -0
- package/app/components/settings/MonitoringTab.tsx +202 -0
- package/app/components/settings/types.ts +1 -1
- package/app/instrumentation.ts +7 -2
- package/app/lib/agent/index.ts +1 -0
- package/app/lib/agent/log.ts +1 -0
- package/app/lib/agent/skill-rules.ts +70 -0
- package/app/lib/api.ts +12 -3
- package/app/lib/core/csv.ts +2 -1
- package/app/lib/core/fs-ops.ts +7 -6
- package/app/lib/core/index.ts +1 -1
- package/app/lib/core/lines.ts +7 -6
- package/app/lib/core/search-index.ts +174 -0
- package/app/lib/core/search.ts +30 -1
- package/app/lib/core/security.ts +6 -3
- package/app/lib/errors.ts +108 -0
- package/app/lib/fs.ts +6 -3
- package/app/lib/i18n-en.ts +44 -1
- package/app/lib/i18n-zh.ts +44 -1
- package/app/lib/metrics.ts +81 -0
- package/app/next.config.ts +1 -1
- package/package.json +1 -1
package/app/app/api/ask/route.ts
CHANGED
|
@@ -13,7 +13,10 @@ import {
|
|
|
13
13
|
createTransformContext,
|
|
14
14
|
} from '@/lib/agent/context';
|
|
15
15
|
import { logAgentOp } from '@/lib/agent/log';
|
|
16
|
+
import { loadSkillRules } from '@/lib/agent/skill-rules';
|
|
16
17
|
import { readSettings } from '@/lib/settings';
|
|
18
|
+
import { MindOSError, apiError, ErrorCodes } from '@/lib/errors';
|
|
19
|
+
import { metrics } from '@/lib/metrics';
|
|
17
20
|
import { assertNotProtected } from '@/lib/core';
|
|
18
21
|
import type { Message as FrontendMessage } from '@/lib/types';
|
|
19
22
|
|
|
@@ -160,7 +163,7 @@ export async function POST(req: NextRequest) {
|
|
|
160
163
|
try {
|
|
161
164
|
body = await req.json();
|
|
162
165
|
} catch {
|
|
163
|
-
return
|
|
166
|
+
return apiError(ErrorCodes.INVALID_REQUEST, 'Invalid JSON body', 400);
|
|
164
167
|
}
|
|
165
168
|
|
|
166
169
|
const { messages, currentFile, attachedFiles, uploadedFiles } = body;
|
|
@@ -176,12 +179,18 @@ export async function POST(req: NextRequest) {
|
|
|
176
179
|
const contextStrategy = agentConfig.contextStrategy ?? 'auto';
|
|
177
180
|
|
|
178
181
|
// Auto-load skill + bootstrap context for each request.
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
const
|
|
182
|
+
// 1. SKILL.md — static trigger + protocol (always loaded)
|
|
183
|
+
// 2. skill-rules.md — user's knowledge base operating rules (if exists)
|
|
184
|
+
// 3. user-rules.md — user's personalized rules (if exists)
|
|
185
|
+
const isZh = serverSettings.disabledSkills?.includes('mindos') ?? false;
|
|
186
|
+
const skillDirName = isZh ? 'mindos-zh' : 'mindos';
|
|
187
|
+
const skillPath = path.resolve(process.cwd(), `data/skills/${skillDirName}/SKILL.md`);
|
|
183
188
|
const skill = readAbsoluteFile(skillPath);
|
|
184
189
|
|
|
190
|
+
// Progressive skill loading: read skill-rules + user-rules from knowledge base
|
|
191
|
+
const mindRoot = getMindRoot();
|
|
192
|
+
const { skillRules, userRules } = loadSkillRules(mindRoot, skillDirName);
|
|
193
|
+
|
|
185
194
|
const targetDir = dirnameOf(currentFile);
|
|
186
195
|
const bootstrap = {
|
|
187
196
|
instruction: readKnowledgeFile('INSTRUCTION.md'),
|
|
@@ -199,6 +208,8 @@ export async function POST(req: NextRequest) {
|
|
|
199
208
|
const truncationWarnings: string[] = [];
|
|
200
209
|
if (!skill.ok) initFailures.push(`skill.mindos: failed (${skill.error})`);
|
|
201
210
|
if (skill.ok && skill.truncated) truncationWarnings.push('skill.mindos was truncated');
|
|
211
|
+
if (skillRules.ok && skillRules.truncated) truncationWarnings.push('skill-rules.md was truncated');
|
|
212
|
+
if (userRules.ok && userRules.truncated) truncationWarnings.push('user-rules.md was truncated');
|
|
202
213
|
if (!bootstrap.instruction.ok) initFailures.push(`bootstrap.instruction: failed (${bootstrap.instruction.error})`);
|
|
203
214
|
if (bootstrap.instruction.ok && bootstrap.instruction.truncated) truncationWarnings.push('bootstrap.instruction was truncated');
|
|
204
215
|
if (!bootstrap.index.ok) initFailures.push(`bootstrap.index: failed (${bootstrap.index.error})`);
|
|
@@ -222,6 +233,13 @@ export async function POST(req: NextRequest) {
|
|
|
222
233
|
|
|
223
234
|
const initContextBlocks: string[] = [];
|
|
224
235
|
if (skill.ok) initContextBlocks.push(`## mindos_skill_md\n\n${skill.content}`);
|
|
236
|
+
// Progressive skill loading: inject skill-rules and user-rules after SKILL.md
|
|
237
|
+
if (skillRules.ok && !skillRules.empty) {
|
|
238
|
+
initContextBlocks.push(`## skill_rules\n\nOperating rules loaded from knowledge base (.agents/skills/${skillDirName}/skill-rules.md):\n\n${skillRules.content}`);
|
|
239
|
+
}
|
|
240
|
+
if (userRules.ok && !userRules.empty) {
|
|
241
|
+
initContextBlocks.push(`## user_rules\n\nUser personalization rules (.agents/skills/${skillDirName}/user-rules.md):\n\n${userRules.content}`);
|
|
242
|
+
}
|
|
225
243
|
if (bootstrap.instruction.ok) initContextBlocks.push(`## bootstrap_instruction\n\n${bootstrap.instruction.content}`);
|
|
226
244
|
if (bootstrap.index.ok) initContextBlocks.push(`## bootstrap_index\n\n${bootstrap.index.content}`);
|
|
227
245
|
if (bootstrap.config_json.ok) initContextBlocks.push(`## bootstrap_config_json\n\n${bootstrap.config_json.content}`);
|
|
@@ -381,6 +399,7 @@ export async function POST(req: NextRequest) {
|
|
|
381
399
|
|
|
382
400
|
// ── SSE Stream ──
|
|
383
401
|
const encoder = new TextEncoder();
|
|
402
|
+
const requestStartTime = Date.now();
|
|
384
403
|
const stream = new ReadableStream({
|
|
385
404
|
start(controller) {
|
|
386
405
|
function send(event: MindOSSSEvent) {
|
|
@@ -404,6 +423,7 @@ export async function POST(req: NextRequest) {
|
|
|
404
423
|
});
|
|
405
424
|
} else if (isToolExecutionEndEvent(event)) {
|
|
406
425
|
const { toolCallId, output, isError } = getToolExecutionEnd(event);
|
|
426
|
+
metrics.recordToolExecution();
|
|
407
427
|
send({
|
|
408
428
|
type: 'tool_end',
|
|
409
429
|
toolCallId,
|
|
@@ -413,6 +433,12 @@ export async function POST(req: NextRequest) {
|
|
|
413
433
|
} else if (isTurnEndEvent(event)) {
|
|
414
434
|
stepCount++;
|
|
415
435
|
|
|
436
|
+
// Record token usage if available from the turn
|
|
437
|
+
const turnUsage = (event as any).usage;
|
|
438
|
+
if (turnUsage && typeof turnUsage.inputTokens === 'number') {
|
|
439
|
+
metrics.recordTokens(turnUsage.inputTokens, turnUsage.outputTokens ?? 0);
|
|
440
|
+
}
|
|
441
|
+
|
|
416
442
|
// Track tool calls for loop detection (lock-free batch update).
|
|
417
443
|
// Deterministic JSON.stringify ensures consistent input comparison.
|
|
418
444
|
const { toolResults } = getTurnEndData(event);
|
|
@@ -452,9 +478,12 @@ export async function POST(req: NextRequest) {
|
|
|
452
478
|
});
|
|
453
479
|
|
|
454
480
|
agent.prompt(lastUserContent).then(() => {
|
|
481
|
+
metrics.recordRequest(Date.now() - requestStartTime);
|
|
455
482
|
send({ type: 'done' });
|
|
456
483
|
controller.close();
|
|
457
484
|
}).catch((err) => {
|
|
485
|
+
metrics.recordRequest(Date.now() - requestStartTime);
|
|
486
|
+
metrics.recordError();
|
|
458
487
|
send({ type: 'error', message: err instanceof Error ? err.message : String(err) });
|
|
459
488
|
controller.close();
|
|
460
489
|
});
|
|
@@ -471,9 +500,9 @@ export async function POST(req: NextRequest) {
|
|
|
471
500
|
});
|
|
472
501
|
} catch (err) {
|
|
473
502
|
console.error('[ask] Failed to initialize model:', err);
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
);
|
|
503
|
+
if (err instanceof MindOSError) {
|
|
504
|
+
return apiError(err.code, err.message);
|
|
505
|
+
}
|
|
506
|
+
return apiError(ErrorCodes.MODEL_INIT_FAILED, err instanceof Error ? err.message : 'Failed to initialize AI model', 500);
|
|
478
507
|
}
|
|
479
508
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export const dynamic = 'force-dynamic';
|
|
2
|
+
import { NextResponse } from 'next/server';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { getMindRoot } from '@/lib/fs';
|
|
6
|
+
import { metrics } from '@/lib/metrics';
|
|
7
|
+
|
|
8
|
+
// Aligned with IGNORED_DIRS in lib/fs.ts and lib/core/tree.ts
|
|
9
|
+
const IGNORED_DIRS = new Set(['.git', 'node_modules', 'app', '.next', '.DS_Store', 'mcp']);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Recursively count files and sum their sizes under a directory.
|
|
13
|
+
* Skips directories in IGNORED_DIRS (aligned with the rest of the codebase).
|
|
14
|
+
*/
|
|
15
|
+
function walkStats(dir: string): { fileCount: number; totalSizeBytes: number } {
|
|
16
|
+
let fileCount = 0;
|
|
17
|
+
let totalSizeBytes = 0;
|
|
18
|
+
|
|
19
|
+
function walk(current: string) {
|
|
20
|
+
let entries: fs.Dirent[];
|
|
21
|
+
try {
|
|
22
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
23
|
+
} catch {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
for (const entry of entries) {
|
|
27
|
+
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
28
|
+
const fullPath = path.join(current, entry.name);
|
|
29
|
+
if (entry.isDirectory()) {
|
|
30
|
+
walk(fullPath);
|
|
31
|
+
} else if (entry.isFile()) {
|
|
32
|
+
try {
|
|
33
|
+
const stat = fs.statSync(fullPath);
|
|
34
|
+
fileCount++;
|
|
35
|
+
totalSizeBytes += stat.size;
|
|
36
|
+
} catch { /* skip unreadable files */ }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
walk(dir);
|
|
42
|
+
return { fileCount, totalSizeBytes };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── TTL cache for walkStats (avoid blocking event loop every 5s poll) ──
|
|
46
|
+
let cachedKbStats: { fileCount: number; totalSizeBytes: number } | null = null;
|
|
47
|
+
let cachedKbStatsTs = 0;
|
|
48
|
+
const KB_STATS_TTL = 30_000; // 30s
|
|
49
|
+
|
|
50
|
+
function getCachedKbStats(mindRoot: string): { fileCount: number; totalSizeBytes: number } {
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
if (cachedKbStats && now - cachedKbStatsTs < KB_STATS_TTL) return cachedKbStats;
|
|
53
|
+
cachedKbStats = walkStats(mindRoot);
|
|
54
|
+
cachedKbStatsTs = now;
|
|
55
|
+
return cachedKbStats;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function GET() {
|
|
59
|
+
const snap = metrics.getSnapshot();
|
|
60
|
+
const mem = process.memoryUsage();
|
|
61
|
+
const mindRoot = getMindRoot();
|
|
62
|
+
|
|
63
|
+
const kbStats = getCachedKbStats(mindRoot);
|
|
64
|
+
|
|
65
|
+
// Detect MCP status from environment / config
|
|
66
|
+
const mcpPort = Number(process.env.MCP_PORT) || 3457;
|
|
67
|
+
|
|
68
|
+
return NextResponse.json({
|
|
69
|
+
system: {
|
|
70
|
+
uptimeMs: Date.now() - snap.processStartTime,
|
|
71
|
+
memory: {
|
|
72
|
+
heapUsed: mem.heapUsed,
|
|
73
|
+
heapTotal: mem.heapTotal,
|
|
74
|
+
rss: mem.rss,
|
|
75
|
+
},
|
|
76
|
+
nodeVersion: process.version,
|
|
77
|
+
},
|
|
78
|
+
application: {
|
|
79
|
+
agentRequests: snap.agentRequests,
|
|
80
|
+
toolExecutions: snap.toolExecutions,
|
|
81
|
+
totalTokens: snap.totalTokens,
|
|
82
|
+
avgResponseTimeMs: snap.avgResponseTimeMs,
|
|
83
|
+
errors: snap.errors,
|
|
84
|
+
},
|
|
85
|
+
knowledgeBase: {
|
|
86
|
+
root: mindRoot,
|
|
87
|
+
fileCount: kbStats.fileCount,
|
|
88
|
+
totalSizeBytes: kbStats.totalSizeBytes,
|
|
89
|
+
},
|
|
90
|
+
mcp: {
|
|
91
|
+
running: true, // If this endpoint responds, the server is running
|
|
92
|
+
port: mcpPort,
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
|
4
|
-
import { X, Settings, Loader2, AlertCircle, CheckCircle2, RotateCcw, Sparkles, Palette, Database, RefreshCw, Plug, Puzzle } from 'lucide-react';
|
|
4
|
+
import { X, Settings, Loader2, AlertCircle, CheckCircle2, RotateCcw, Sparkles, Palette, Database, RefreshCw, Plug, Puzzle, Activity, Users } from 'lucide-react';
|
|
5
5
|
import { useLocale } from '@/lib/LocaleContext';
|
|
6
6
|
import { getAllRenderers, loadDisabledState, isRendererEnabled } from '@/lib/renderers/registry';
|
|
7
7
|
import { apiFetch } from '@/lib/api';
|
|
@@ -14,6 +14,8 @@ import { KnowledgeTab } from './settings/KnowledgeTab';
|
|
|
14
14
|
import { PluginsTab } from './settings/PluginsTab';
|
|
15
15
|
import { SyncTab } from './settings/SyncTab';
|
|
16
16
|
import { McpTab } from './settings/McpTab';
|
|
17
|
+
import { MonitoringTab } from './settings/MonitoringTab';
|
|
18
|
+
import { AgentsTab } from './settings/AgentsTab';
|
|
17
19
|
|
|
18
20
|
interface SettingsModalProps {
|
|
19
21
|
open: boolean;
|
|
@@ -147,6 +149,8 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
|
|
|
147
149
|
{ id: 'sync', label: t.settings.tabs.sync ?? 'Sync', icon: <RefreshCw size={13} /> },
|
|
148
150
|
{ id: 'mcp', label: t.settings.tabs.mcp ?? 'MCP', icon: <Plug size={13} /> },
|
|
149
151
|
{ id: 'plugins', label: t.settings.tabs.plugins, icon: <Puzzle size={13} /> },
|
|
152
|
+
{ id: 'monitoring', label: t.settings.tabs.monitoring, icon: <Activity size={13} /> },
|
|
153
|
+
{ id: 'agents', label: t.settings.tabs.agents ?? 'Agents', icon: <Users size={13} /> },
|
|
150
154
|
];
|
|
151
155
|
|
|
152
156
|
return (
|
|
@@ -197,7 +201,7 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
|
|
|
197
201
|
<p className="text-sm text-destructive font-medium">Failed to load settings</p>
|
|
198
202
|
<p className="text-xs text-muted-foreground">Check that the server is running and AUTH_TOKEN is configured correctly.</p>
|
|
199
203
|
</div>
|
|
200
|
-
) : !data && tab !== 'appearance' && tab !== 'mcp' && tab !== 'sync' ? (
|
|
204
|
+
) : !data && tab !== 'appearance' && tab !== 'mcp' && tab !== 'sync' && tab !== 'agents' ? (
|
|
201
205
|
<div className="flex justify-center py-8">
|
|
202
206
|
<Loader2 size={18} className="animate-spin text-muted-foreground" />
|
|
203
207
|
</div>
|
|
@@ -209,6 +213,8 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
|
|
|
209
213
|
{tab === 'plugins' && <PluginsTab pluginStates={pluginStates} setPluginStates={setPluginStates} t={t} />}
|
|
210
214
|
{tab === 'sync' && <SyncTab t={t} />}
|
|
211
215
|
{tab === 'mcp' && <McpTab t={t} />}
|
|
216
|
+
{tab === 'monitoring' && <MonitoringTab t={t} />}
|
|
217
|
+
{tab === 'agents' && <AgentsTab t={t} />}
|
|
212
218
|
</>
|
|
213
219
|
)}
|
|
214
220
|
</div>
|
|
@@ -0,0 +1,240 @@
|
|
|
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
|
+
}
|