@geminilight/mindos 0.5.20 → 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 +343 -178
- package/app/app/api/monitoring/route.ts +95 -0
- package/app/components/SettingsModal.tsx +58 -58
- package/app/components/settings/AgentsTab.tsx +240 -0
- package/app/components/settings/AiTab.tsx +4 -25
- package/app/components/settings/AppearanceTab.tsx +31 -13
- package/app/components/settings/KnowledgeTab.tsx +13 -28
- package/app/components/settings/McpAgentInstall.tsx +227 -0
- package/app/components/settings/McpServerStatus.tsx +172 -0
- package/app/components/settings/McpSkillsSection.tsx +583 -0
- package/app/components/settings/McpTab.tsx +17 -959
- package/app/components/settings/MonitoringTab.tsx +202 -0
- package/app/components/settings/PluginsTab.tsx +4 -27
- package/app/components/settings/Primitives.tsx +69 -0
- package/app/components/settings/ShortcutsTab.tsx +2 -4
- package/app/components/settings/SyncTab.tsx +8 -24
- package/app/components/settings/types.ts +116 -2
- package/app/instrumentation.ts +7 -2
- package/app/lib/agent/context.ts +151 -87
- package/app/lib/agent/index.ts +5 -3
- package/app/lib/agent/log.ts +1 -0
- package/app/lib/agent/model.ts +76 -10
- package/app/lib/agent/skill-rules.ts +70 -0
- package/app/lib/agent/stream-consumer.ts +73 -77
- package/app/lib/agent/to-agent-messages.ts +106 -0
- package/app/lib/agent/tools.ts +260 -266
- 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 +523 -0
- package/app/lib/i18n-zh.ts +548 -0
- package/app/lib/i18n.ts +4 -963
- package/app/lib/metrics.ts +81 -0
- package/app/next-env.d.ts +1 -1
- package/app/next.config.ts +1 -1
- package/app/package-lock.json +3258 -3093
- package/app/package.json +6 -3
- package/bin/cli.js +7 -4
- package/package.json +4 -1
|
@@ -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
|
-
import { useEffect, useState, useCallback } from 'react';
|
|
4
|
-
import { X, Settings,
|
|
3
|
+
import { useEffect, useState, useCallback, useRef } from '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';
|
|
@@ -12,9 +12,10 @@ import { AiTab } from './settings/AiTab';
|
|
|
12
12
|
import { AppearanceTab } from './settings/AppearanceTab';
|
|
13
13
|
import { KnowledgeTab } from './settings/KnowledgeTab';
|
|
14
14
|
import { PluginsTab } from './settings/PluginsTab';
|
|
15
|
-
import { ShortcutsTab } from './settings/ShortcutsTab';
|
|
16
15
|
import { SyncTab } from './settings/SyncTab';
|
|
17
16
|
import { McpTab } from './settings/McpTab';
|
|
17
|
+
import { MonitoringTab } from './settings/MonitoringTab';
|
|
18
|
+
import { AgentsTab } from './settings/AgentsTab';
|
|
18
19
|
|
|
19
20
|
interface SettingsModalProps {
|
|
20
21
|
open: boolean;
|
|
@@ -28,6 +29,8 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
|
|
|
28
29
|
const [saving, setSaving] = useState(false);
|
|
29
30
|
const [status, setStatus] = useState<'idle' | 'saved' | 'error' | 'load-error'>('idle');
|
|
30
31
|
const { t, locale, setLocale } = useLocale();
|
|
32
|
+
const saveTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
33
|
+
const dataLoaded = useRef(false);
|
|
31
34
|
|
|
32
35
|
// Appearance state (localStorage-based)
|
|
33
36
|
const [font, setFont] = useState('lora');
|
|
@@ -37,8 +40,8 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
|
|
|
37
40
|
const [pluginStates, setPluginStates] = useState<Record<string, boolean>>({});
|
|
38
41
|
|
|
39
42
|
useEffect(() => {
|
|
40
|
-
if (!open) return;
|
|
41
|
-
apiFetch<SettingsData>('/api/settings').then(setData).catch(() => setStatus('load-error'));
|
|
43
|
+
if (!open) { dataLoaded.current = false; return; }
|
|
44
|
+
apiFetch<SettingsData>('/api/settings').then(d => { setData(d); dataLoaded.current = true; }).catch(() => setStatus('load-error'));
|
|
42
45
|
setFont(localStorage.getItem('prose-font') ?? 'lora');
|
|
43
46
|
setContentWidth(localStorage.getItem('content-width') ?? '780px');
|
|
44
47
|
const stored = localStorage.getItem('theme');
|
|
@@ -81,14 +84,14 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
|
|
|
81
84
|
return () => window.removeEventListener('keydown', handler);
|
|
82
85
|
}, [open, onClose]);
|
|
83
86
|
|
|
84
|
-
|
|
85
|
-
|
|
87
|
+
// Auto-save with debounce when data changes
|
|
88
|
+
const doSave = useCallback(async (d: SettingsData) => {
|
|
86
89
|
setSaving(true);
|
|
87
90
|
try {
|
|
88
91
|
await apiFetch('/api/settings', {
|
|
89
92
|
method: 'POST',
|
|
90
93
|
headers: { 'Content-Type': 'application/json' },
|
|
91
|
-
body: JSON.stringify({ ai:
|
|
94
|
+
body: JSON.stringify({ ai: d.ai, agent: d.agent, mindRoot: d.mindRoot, webPassword: d.webPassword, authToken: d.authToken }),
|
|
92
95
|
});
|
|
93
96
|
setStatus('saved');
|
|
94
97
|
setTimeout(() => setStatus('idle'), 2500);
|
|
@@ -98,7 +101,14 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
|
|
|
98
101
|
} finally {
|
|
99
102
|
setSaving(false);
|
|
100
103
|
}
|
|
101
|
-
}, [
|
|
104
|
+
}, []);
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (!data || !dataLoaded.current) return;
|
|
108
|
+
clearTimeout(saveTimer.current);
|
|
109
|
+
saveTimer.current = setTimeout(() => doSave(data), 800);
|
|
110
|
+
return () => clearTimeout(saveTimer.current);
|
|
111
|
+
}, [data, doSave]);
|
|
102
112
|
|
|
103
113
|
const updateAi = useCallback((patch: Partial<AiSettings>) => {
|
|
104
114
|
setData(d => d ? { ...d, ai: { ...d.ai, ...patch } } : d);
|
|
@@ -117,36 +127,30 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
|
|
|
117
127
|
openai: { apiKey: '', model: '', baseUrl: '' },
|
|
118
128
|
},
|
|
119
129
|
};
|
|
130
|
+
// Set defaults — auto-save will persist them
|
|
120
131
|
setData(d => d ? { ...d, ai: defaults } : d);
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
setStatus('saved');
|
|
129
|
-
} catch {
|
|
130
|
-
setStatus('error');
|
|
131
|
-
} finally {
|
|
132
|
-
setSaving(false);
|
|
133
|
-
}
|
|
134
|
-
apiFetch<SettingsData>('/api/settings').then(setData).catch(() => setStatus('error'));
|
|
135
|
-
setTimeout(() => setStatus('idle'), 2500);
|
|
132
|
+
// 🟢 MINOR #4: Refetch after auto-save completes (800ms debounce + 500ms save operation)
|
|
133
|
+
// Rather than magic 1200ms, wait for save to finish before refetching env-resolved values
|
|
134
|
+
const DEBOUNCE_DELAY = 800;
|
|
135
|
+
const SAVE_OPERATION_TIME = 500;
|
|
136
|
+
setTimeout(() => {
|
|
137
|
+
apiFetch<SettingsData>('/api/settings').then(d => { setData(d); }).catch(() => setStatus('error'));
|
|
138
|
+
}, DEBOUNCE_DELAY + SAVE_OPERATION_TIME);
|
|
136
139
|
}, [data]);
|
|
137
140
|
|
|
138
141
|
if (!open) return null;
|
|
139
142
|
|
|
140
143
|
const env = data?.envOverrides ?? {};
|
|
141
144
|
|
|
142
|
-
const TABS: { id: Tab; label: string }[] = [
|
|
143
|
-
{ id: 'ai', label: t.settings.tabs.ai },
|
|
144
|
-
{ id: 'appearance', label: t.settings.tabs.appearance },
|
|
145
|
-
{ id: 'knowledge', label: t.settings.tabs.knowledge },
|
|
146
|
-
{ id: 'sync', label: t.settings.tabs.sync ?? 'Sync' },
|
|
147
|
-
{ id: 'mcp', label: t.settings.tabs.mcp ?? 'MCP' },
|
|
148
|
-
{ id: 'plugins', label: t.settings.tabs.plugins },
|
|
149
|
-
{ id: '
|
|
145
|
+
const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
|
146
|
+
{ id: 'ai', label: t.settings.tabs.ai, icon: <Sparkles size={13} /> },
|
|
147
|
+
{ id: 'appearance', label: t.settings.tabs.appearance, icon: <Palette size={13} /> },
|
|
148
|
+
{ id: 'knowledge', label: t.settings.tabs.knowledge, icon: <Database size={13} /> },
|
|
149
|
+
{ id: 'sync', label: t.settings.tabs.sync ?? 'Sync', icon: <RefreshCw size={13} /> },
|
|
150
|
+
{ id: 'mcp', label: t.settings.tabs.mcp ?? 'MCP', icon: <Plug size={13} /> },
|
|
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 (
|
|
@@ -177,12 +181,13 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
|
|
|
177
181
|
<button
|
|
178
182
|
key={t.id}
|
|
179
183
|
onClick={() => setTab(t.id)}
|
|
180
|
-
className={`px-3 py-2.5 text-xs font-medium transition-colors border-b-2 -mb-px whitespace-nowrap ${
|
|
184
|
+
className={`flex items-center gap-1.5 px-3 py-2.5 text-xs font-medium transition-colors border-b-2 -mb-px whitespace-nowrap ${
|
|
181
185
|
tab === t.id
|
|
182
186
|
? 'border-amber-500 text-foreground'
|
|
183
187
|
: 'border-transparent text-muted-foreground hover:text-foreground'
|
|
184
188
|
}`}
|
|
185
189
|
>
|
|
190
|
+
{t.icon}
|
|
186
191
|
{t.label}
|
|
187
192
|
</button>
|
|
188
193
|
))}
|
|
@@ -196,7 +201,7 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
|
|
|
196
201
|
<p className="text-sm text-destructive font-medium">Failed to load settings</p>
|
|
197
202
|
<p className="text-xs text-muted-foreground">Check that the server is running and AUTH_TOKEN is configured correctly.</p>
|
|
198
203
|
</div>
|
|
199
|
-
) : !data && tab !== '
|
|
204
|
+
) : !data && tab !== 'appearance' && tab !== 'mcp' && tab !== 'sync' && tab !== 'agents' ? (
|
|
200
205
|
<div className="flex justify-center py-8">
|
|
201
206
|
<Loader2 size={18} className="animate-spin text-muted-foreground" />
|
|
202
207
|
</div>
|
|
@@ -206,54 +211,49 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
|
|
|
206
211
|
{tab === 'appearance' && <AppearanceTab font={font} setFont={setFont} contentWidth={contentWidth} setContentWidth={setContentWidth} dark={dark} setDark={setDark} locale={locale} setLocale={setLocale} t={t} />}
|
|
207
212
|
{tab === 'knowledge' && data && <KnowledgeTab data={data} setData={setData} t={t} />}
|
|
208
213
|
{tab === 'plugins' && <PluginsTab pluginStates={pluginStates} setPluginStates={setPluginStates} t={t} />}
|
|
209
|
-
{tab === 'shortcuts' && <ShortcutsTab 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>
|
|
215
221
|
|
|
216
|
-
{/* Footer */}
|
|
222
|
+
{/* Footer — status bar + contextual actions */}
|
|
217
223
|
{(tab === 'ai' || tab === 'knowledge') && (
|
|
218
|
-
<div className="px-5 py-
|
|
224
|
+
<div className="px-5 py-2.5 border-t border-border shrink-0 flex items-center justify-between">
|
|
219
225
|
<div className="flex items-center gap-3">
|
|
220
226
|
{tab === 'ai' && Object.values(env).some(Boolean) && (
|
|
221
227
|
<button
|
|
222
228
|
onClick={restoreFromEnv}
|
|
223
229
|
disabled={saving || !data}
|
|
224
|
-
className="flex items-center gap-1.5 px-
|
|
230
|
+
className="flex items-center gap-1.5 px-3 py-1 text-xs rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
225
231
|
>
|
|
226
|
-
<RotateCcw size={
|
|
232
|
+
<RotateCcw size={12} />
|
|
227
233
|
{t.settings.ai.restoreFromEnv}
|
|
228
234
|
</button>
|
|
229
235
|
)}
|
|
230
236
|
{tab === 'knowledge' && (
|
|
231
237
|
<a
|
|
232
238
|
href="/setup?force=1"
|
|
233
|
-
className="flex items-center gap-1.5 px-
|
|
239
|
+
className="flex items-center gap-1.5 px-3 py-1 text-xs rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
234
240
|
>
|
|
235
|
-
<RotateCcw size={
|
|
241
|
+
<RotateCcw size={12} />
|
|
236
242
|
{t.settings.reconfigure}
|
|
237
243
|
</a>
|
|
238
244
|
)}
|
|
239
|
-
<div className="flex items-center gap-1.5 text-xs">
|
|
240
|
-
{status === 'saved' && (
|
|
241
|
-
<><CheckCircle2 size={13} className="text-success" /><span className="text-success">{t.settings.saved}</span></>
|
|
242
|
-
)}
|
|
243
|
-
{status === 'error' && (
|
|
244
|
-
<><AlertCircle size={13} className="text-destructive" /><span className="text-destructive">{t.settings.saveFailed}</span></>
|
|
245
|
-
)}
|
|
246
|
-
</div>
|
|
247
245
|
</div>
|
|
248
|
-
<
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
{
|
|
256
|
-
|
|
246
|
+
<div className="flex items-center gap-1.5 text-xs" role="status" aria-live="polite">
|
|
247
|
+
{saving && (
|
|
248
|
+
<><Loader2 size={12} className="animate-spin text-muted-foreground" /><span className="text-muted-foreground">{t.settings.save}...</span></>
|
|
249
|
+
)}
|
|
250
|
+
{status === 'saved' && (
|
|
251
|
+
<><CheckCircle2 size={12} className="text-success" /><span className="text-success">{t.settings.saved}</span></>
|
|
252
|
+
)}
|
|
253
|
+
{status === 'error' && (
|
|
254
|
+
<><AlertCircle size={12} className="text-destructive" /><span className="text-destructive">{t.settings.saveFailed}</span></>
|
|
255
|
+
)}
|
|
256
|
+
</div>
|
|
257
257
|
</div>
|
|
258
258
|
)}
|
|
259
259
|
</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
|
+
}
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
4
4
|
import { AlertCircle, Loader2 } from 'lucide-react';
|
|
5
|
-
import type { AiSettings, AgentSettings, ProviderConfig, SettingsData } from './types';
|
|
6
|
-
import { Field, Select, Input, EnvBadge, ApiKeyInput } from './Primitives';
|
|
5
|
+
import type { AiSettings, AgentSettings, ProviderConfig, SettingsData, AiTabProps } from './types';
|
|
6
|
+
import { Field, Select, Input, EnvBadge, ApiKeyInput, Toggle } from './Primitives';
|
|
7
7
|
|
|
8
8
|
type TestState = 'idle' | 'testing' | 'ok' | 'error';
|
|
9
9
|
type ErrorCode = 'auth_error' | 'model_not_found' | 'rate_limited' | 'network_error' | 'unknown';
|
|
@@ -15,7 +15,7 @@ interface TestResult {
|
|
|
15
15
|
code?: ErrorCode;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
function errorMessage(t:
|
|
18
|
+
function errorMessage(t: AiTabProps['t'], code?: ErrorCode): string {
|
|
19
19
|
switch (code) {
|
|
20
20
|
case 'auth_error': return t.settings.ai.testKeyAuthError;
|
|
21
21
|
case 'model_not_found': return t.settings.ai.testKeyModelNotFound;
|
|
@@ -25,13 +25,6 @@ function errorMessage(t: any, code?: ErrorCode): string {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
interface AiTabProps {
|
|
29
|
-
data: SettingsData;
|
|
30
|
-
updateAi: (patch: Partial<AiSettings>) => void;
|
|
31
|
-
updateAgent: (patch: Partial<AgentSettings>) => void;
|
|
32
|
-
t: any;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
28
|
export function AiTab({ data, updateAi, updateAgent, t }: AiTabProps) {
|
|
36
29
|
const env = data.envOverrides ?? {};
|
|
37
30
|
const envVal = data.envValues ?? {};
|
|
@@ -262,21 +255,7 @@ export function AiTab({ data, updateAi, updateAgent, t }: AiTabProps) {
|
|
|
262
255
|
<div className="text-sm text-foreground">{t.settings.agent.thinking}</div>
|
|
263
256
|
<div className="text-xs text-muted-foreground mt-0.5">{t.settings.agent.thinkingHint}</div>
|
|
264
257
|
</div>
|
|
265
|
-
<
|
|
266
|
-
type="button"
|
|
267
|
-
role="switch"
|
|
268
|
-
aria-checked={data.agent?.enableThinking ?? false}
|
|
269
|
-
onClick={() => updateAgent({ enableThinking: !(data.agent?.enableThinking ?? false) })}
|
|
270
|
-
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
|
|
271
|
-
data.agent?.enableThinking ? 'bg-amber-500' : 'bg-muted'
|
|
272
|
-
}`}
|
|
273
|
-
>
|
|
274
|
-
<span
|
|
275
|
-
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${
|
|
276
|
-
data.agent?.enableThinking ? 'translate-x-4' : 'translate-x-0'
|
|
277
|
-
}`}
|
|
278
|
-
/>
|
|
279
|
-
</button>
|
|
258
|
+
<Toggle checked={data.agent?.enableThinking ?? false} onChange={() => updateAgent({ enableThinking: !(data.agent?.enableThinking ?? false) })} />
|
|
280
259
|
</div>
|
|
281
260
|
|
|
282
261
|
{data.agent?.enableThinking && (
|