@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.
Files changed (46) hide show
  1. package/app/app/api/ask/route.ts +343 -178
  2. package/app/app/api/monitoring/route.ts +95 -0
  3. package/app/components/SettingsModal.tsx +58 -58
  4. package/app/components/settings/AgentsTab.tsx +240 -0
  5. package/app/components/settings/AiTab.tsx +4 -25
  6. package/app/components/settings/AppearanceTab.tsx +31 -13
  7. package/app/components/settings/KnowledgeTab.tsx +13 -28
  8. package/app/components/settings/McpAgentInstall.tsx +227 -0
  9. package/app/components/settings/McpServerStatus.tsx +172 -0
  10. package/app/components/settings/McpSkillsSection.tsx +583 -0
  11. package/app/components/settings/McpTab.tsx +17 -959
  12. package/app/components/settings/MonitoringTab.tsx +202 -0
  13. package/app/components/settings/PluginsTab.tsx +4 -27
  14. package/app/components/settings/Primitives.tsx +69 -0
  15. package/app/components/settings/ShortcutsTab.tsx +2 -4
  16. package/app/components/settings/SyncTab.tsx +8 -24
  17. package/app/components/settings/types.ts +116 -2
  18. package/app/instrumentation.ts +7 -2
  19. package/app/lib/agent/context.ts +151 -87
  20. package/app/lib/agent/index.ts +5 -3
  21. package/app/lib/agent/log.ts +1 -0
  22. package/app/lib/agent/model.ts +76 -10
  23. package/app/lib/agent/skill-rules.ts +70 -0
  24. package/app/lib/agent/stream-consumer.ts +73 -77
  25. package/app/lib/agent/to-agent-messages.ts +106 -0
  26. package/app/lib/agent/tools.ts +260 -266
  27. package/app/lib/api.ts +12 -3
  28. package/app/lib/core/csv.ts +2 -1
  29. package/app/lib/core/fs-ops.ts +7 -6
  30. package/app/lib/core/index.ts +1 -1
  31. package/app/lib/core/lines.ts +7 -6
  32. package/app/lib/core/search-index.ts +174 -0
  33. package/app/lib/core/search.ts +30 -1
  34. package/app/lib/core/security.ts +6 -3
  35. package/app/lib/errors.ts +108 -0
  36. package/app/lib/fs.ts +6 -3
  37. package/app/lib/i18n-en.ts +523 -0
  38. package/app/lib/i18n-zh.ts +548 -0
  39. package/app/lib/i18n.ts +4 -963
  40. package/app/lib/metrics.ts +81 -0
  41. package/app/next-env.d.ts +1 -1
  42. package/app/next.config.ts +1 -1
  43. package/app/package-lock.json +3258 -3093
  44. package/app/package.json +6 -3
  45. package/bin/cli.js +7 -4
  46. package/package.json +4 -1
@@ -1,929 +1,13 @@
1
- 'use client';
2
-
3
- import { useState, useEffect, useCallback, useMemo } from 'react';
4
- import {
5
- Plug, CheckCircle2, AlertCircle, Loader2, Copy, Check,
6
- ChevronDown, ChevronRight, Trash2, Plus, X, Search, Pencil,
7
- } from 'lucide-react';
1
+ import { useState, useEffect, useCallback } from 'react';
2
+ import { Loader2 } from 'lucide-react';
8
3
  import { apiFetch } from '@/lib/api';
9
- import dynamic from 'next/dynamic';
10
-
11
- const MarkdownView = dynamic(() => import('@/components/MarkdownView'), { ssr: false });
12
-
13
- /* ── Types ─────────────────────────────────────────────────────── */
14
-
15
- interface McpStatus {
16
- running: boolean;
17
- transport: string;
18
- endpoint: string;
19
- port: number;
20
- toolCount: number;
21
- authConfigured: boolean;
22
- }
23
-
24
- interface AgentInfo {
25
- key: string;
26
- name: string;
27
- present: boolean;
28
- installed: boolean;
29
- scope?: string;
30
- transport?: string;
31
- configPath?: string;
32
- hasProjectScope: boolean;
33
- hasGlobalScope: boolean;
34
- preferredTransport: 'stdio' | 'http';
35
- // Snippet generation fields
36
- format: 'json' | 'toml';
37
- configKey: string;
38
- globalNestedKey?: string;
39
- globalPath: string;
40
- projectPath?: string | null;
41
- }
42
-
43
- interface SkillInfo {
44
- name: string;
45
- description: string;
46
- path: string;
47
- source: 'builtin' | 'user';
48
- enabled: boolean;
49
- editable: boolean;
50
- }
51
-
52
- interface McpTabProps {
53
- t: any;
54
- }
55
-
56
- /* ── Helpers ───────────────────────────────────────────────────── */
57
-
58
- function CopyButton({ text, label }: { text: string; label: string }) {
59
- const [copied, setCopied] = useState(false);
60
- const handleCopy = async () => {
61
- try {
62
- await navigator.clipboard.writeText(text);
63
- setCopied(true);
64
- setTimeout(() => setCopied(false), 2000);
65
- } catch { /* clipboard unavailable */ }
66
- };
67
- return (
68
- <button
69
- onClick={handleCopy}
70
- className="flex items-center gap-1 px-2.5 py-1 text-xs rounded-md border border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
71
- >
72
- {copied ? <Check size={11} /> : <Copy size={11} />}
73
- {copied ? 'Copied!' : label}
74
- </button>
75
- );
76
- }
77
-
78
- /* ── Config Snippet Generator ─────────────────────────────────── */
79
-
80
- function generateConfigSnippet(
81
- agent: AgentInfo,
82
- status: McpStatus,
83
- token?: string,
84
- ): { snippet: string; path: string } {
85
- const isRunning = status.running;
86
-
87
- // Determine entry (stdio vs http)
88
- const stdioEntry: Record<string, unknown> = { type: 'stdio', command: 'mindos', args: ['mcp'] };
89
- const httpEntry: Record<string, unknown> = { url: status.endpoint };
90
- if (token) httpEntry.headers = { Authorization: `Bearer ${token}` };
91
- const entry = isRunning ? httpEntry : stdioEntry;
92
-
93
- // TOML format (Codex)
94
- if (agent.format === 'toml') {
95
- const lines: string[] = [`[${agent.configKey}.mindos]`];
96
- if (isRunning) {
97
- lines.push(`type = "http"`);
98
- lines.push(`url = "${status.endpoint}"`);
99
- if (token) {
100
- lines.push('');
101
- lines.push(`[${agent.configKey}.mindos.headers]`);
102
- lines.push(`Authorization = "Bearer ${token}"`);
103
- }
104
- } else {
105
- lines.push(`command = "mindos"`);
106
- lines.push(`args = ["mcp"]`);
107
- lines.push('');
108
- lines.push(`[${agent.configKey}.mindos.env]`);
109
- lines.push(`MCP_TRANSPORT = "stdio"`);
110
- }
111
- return { snippet: lines.join('\n'), path: agent.globalPath };
112
- }
113
-
114
- // JSON with globalNestedKey (VS Code project-level uses flat key)
115
- if (agent.globalNestedKey) {
116
- // project-level: flat key structure
117
- const projectSnippet = JSON.stringify({ [agent.configKey]: { mindos: entry } }, null, 2);
118
- return { snippet: projectSnippet, path: agent.projectPath ?? agent.globalPath };
119
- }
120
-
121
- // Standard JSON
122
- const snippet = JSON.stringify({ [agent.configKey]: { mindos: entry } }, null, 2);
123
- return { snippet, path: agent.globalPath };
124
- }
125
-
126
- /* ── MCP Server Status ─────────────────────────────────────────── */
127
-
128
- function ServerStatus({ status, agents, t }: { status: McpStatus | null; agents: AgentInfo[]; t: any }) {
129
- const m = t.settings?.mcp;
130
- const [selectedAgent, setSelectedAgent] = useState<string>('');
131
-
132
- // Auto-select first installed or first detected agent
133
- useEffect(() => {
134
- if (agents.length > 0 && !selectedAgent) {
135
- const first = agents.find(a => a.installed) ?? agents.find(a => a.present) ?? agents[0];
136
- if (first) setSelectedAgent(first.key);
137
- }
138
- }, [agents, selectedAgent]);
139
-
140
- if (!status) return null;
141
-
142
- const currentAgent = agents.find(a => a.key === selectedAgent);
143
- const snippetResult = currentAgent ? generateConfigSnippet(currentAgent, status) : null;
144
-
145
- return (
146
- <div className="space-y-3">
147
- <div className="flex items-center gap-3">
148
- <div className="w-8 h-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
149
- <Plug size={16} className="text-muted-foreground" />
150
- </div>
151
- <div>
152
- <h3 className="text-sm font-medium text-foreground">{m?.serverTitle ?? 'MindOS MCP Server'}</h3>
153
- </div>
154
- </div>
155
-
156
- <div className="space-y-1.5 text-sm pl-11">
157
- <div className="flex items-center gap-2">
158
- <span className="text-muted-foreground w-20 shrink-0 text-xs">{m?.status ?? 'Status'}</span>
159
- <span className={`text-xs flex items-center gap-1 ${status.running ? 'text-success' : 'text-muted-foreground'}`}>
160
- <span className={`inline-block w-1.5 h-1.5 rounded-full ${status.running ? 'bg-success' : 'bg-muted-foreground'}`} />
161
- {status.running ? (m?.running ?? 'Running') : (m?.stopped ?? 'Stopped')}
162
- </span>
163
- </div>
164
- <div className="flex items-center gap-2">
165
- <span className="text-muted-foreground w-20 shrink-0 text-xs">{m?.transport ?? 'Transport'}</span>
166
- <span className="text-xs font-mono">{status.transport.toUpperCase()}</span>
167
- </div>
168
- <div className="flex items-center gap-2">
169
- <span className="text-muted-foreground w-20 shrink-0 text-xs">{m?.endpoint ?? 'Endpoint'}</span>
170
- <span className="text-xs font-mono truncate">{status.endpoint}</span>
171
- </div>
172
- <div className="flex items-center gap-2">
173
- <span className="text-muted-foreground w-20 shrink-0 text-xs">{m?.tools ?? 'Tools'}</span>
174
- <span className="text-xs">{m?.toolsRegistered ? m.toolsRegistered(status.toolCount) : `${status.toolCount} registered`}</span>
175
- </div>
176
- <div className="flex items-center gap-2">
177
- <span className="text-muted-foreground w-20 shrink-0 text-xs">{m?.auth ?? 'Auth'}</span>
178
- <span className="text-xs">
179
- {status.authConfigured
180
- ? <span className="text-success">{m?.authSet ?? 'Token set'}</span>
181
- : <span className="text-muted-foreground">{m?.authNotSet ?? 'No token'}</span>}
182
- </span>
183
- </div>
184
- </div>
185
-
186
- <div className="flex items-center gap-2 pl-11">
187
- <CopyButton text={status.endpoint} label={m?.copyEndpoint ?? 'Copy Endpoint'} />
188
- </div>
189
-
190
- {/* Quick Setup — agent-specific config snippet */}
191
- {agents.length > 0 && (
192
- <div className="pl-11 pt-2 space-y-2.5">
193
- <div className="flex items-center gap-2">
194
- <span className="text-xs text-muted-foreground font-medium">
195
- ── {m?.quickSetup ?? 'Quick Setup'} ──
196
- </span>
197
- </div>
198
-
199
- <div className="flex items-center gap-2">
200
- <span className="text-xs text-muted-foreground shrink-0">{m?.configureFor ?? 'Configure for'}</span>
201
- <select
202
- value={selectedAgent}
203
- onChange={e => setSelectedAgent(e.target.value)}
204
- className="text-xs px-2 py-1 rounded-md border border-border bg-background text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring"
205
- >
206
- {agents.map(a => (
207
- <option key={a.key} value={a.key}>
208
- {a.name}{a.installed ? ` ✓` : a.present ? ` ·` : ''}
209
- </option>
210
- ))}
211
- </select>
212
- </div>
213
-
214
- {snippetResult && (
215
- <>
216
- <div className="flex items-center gap-2">
217
- <span className="text-xs text-muted-foreground shrink-0">{m?.configPath ?? 'Config path'}</span>
218
- <span className="text-xs font-mono text-foreground">{snippetResult.path}</span>
219
- </div>
220
-
221
- <pre className="text-xs font-mono bg-muted/50 border border-border rounded-lg p-3 overflow-x-auto whitespace-pre">
222
- {snippetResult.snippet}
223
- </pre>
224
-
225
- <CopyButton text={snippetResult.snippet} label={m?.copyConfig ?? 'Copy Config'} />
226
- </>
227
- )}
228
- </div>
229
- )}
230
- </div>
231
- );
232
- }
233
-
234
- /* ── Agent Install ─────────────────────────────────────────────── */
235
-
236
- function AgentInstall({ agents, t, onRefresh }: { agents: AgentInfo[]; t: any; onRefresh: () => void }) {
237
- const m = t.settings?.mcp;
238
- const [selected, setSelected] = useState<Set<string>>(new Set());
239
- const [transport, setTransport] = useState<'auto' | 'stdio' | 'http'>('auto');
240
- const [httpUrl, setHttpUrl] = useState('http://localhost:8787/mcp');
241
- const [httpToken, setHttpToken] = useState('');
242
- const [scopes, setScopes] = useState<Record<string, 'project' | 'global'>>({});
243
- const [installing, setInstalling] = useState(false);
244
- const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
245
-
246
- const getEffectiveTransport = (agent: AgentInfo) => {
247
- if (transport === 'auto') return agent.preferredTransport;
248
- return transport;
249
- };
250
-
251
- const toggle = (key: string) => {
252
- setSelected(prev => {
253
- const next = new Set(prev);
254
- if (next.has(key)) next.delete(key); else next.add(key);
255
- return next;
256
- });
257
- };
258
-
259
- const handleInstall = async () => {
260
- if (selected.size === 0) return;
261
- setInstalling(true);
262
- setMessage(null);
263
- try {
264
- const payload = {
265
- agents: [...selected].map(key => {
266
- const agent = agents.find(a => a.key === key);
267
- const effectiveTransport = transport === 'auto'
268
- ? (agent?.preferredTransport || 'stdio')
269
- : transport;
270
- return {
271
- key,
272
- scope: scopes[key] || (agents.find(a => a.key === key)?.hasProjectScope ? 'project' : 'global'),
273
- transport: effectiveTransport,
274
- };
275
- }),
276
- transport,
277
- ...(transport === 'http' ? { url: httpUrl, token: httpToken } : {}),
278
- // For auto mode, pass http settings for agents that need it
279
- ...(transport === 'auto' ? { url: httpUrl, token: httpToken } : {}),
280
- };
281
- const res = await apiFetch<{ results: Array<{ agent: string; status: string; message?: string }> }>('/api/mcp/install', {
282
- method: 'POST',
283
- headers: { 'Content-Type': 'application/json' },
284
- body: JSON.stringify(payload),
285
- });
286
- const ok = res.results.filter(r => r.status === 'ok').length;
287
- const fail = res.results.filter(r => r.status === 'error');
288
- if (fail.length > 0) {
289
- setMessage({ type: 'error', text: fail.map(f => `${f.agent}: ${f.message}`).join('; ') });
290
- } else {
291
- setMessage({ type: 'success', text: m?.installSuccess ? m.installSuccess(ok) : `${ok} agent(s) configured` });
292
- }
293
- setSelected(new Set());
294
- onRefresh();
295
- } catch {
296
- setMessage({ type: 'error', text: m?.installFailed ?? 'Install failed' });
297
- } finally {
298
- setInstalling(false);
299
- setTimeout(() => setMessage(null), 4000);
300
- }
301
- };
302
-
303
- // Show http fields if transport is 'http', or 'auto' with any http-preferred agent selected
304
- const showHttpFields = transport === 'http' || (transport === 'auto' && [...selected].some(key => {
305
- const agent = agents.find(a => a.key === key);
306
- return agent?.preferredTransport === 'http';
307
- }));
308
-
309
- return (
310
- <div className="space-y-3 pt-2">
311
- {/* Agent list */}
312
- <div className="space-y-1">
313
- {agents.map(agent => (
314
- <div key={agent.key} className="flex items-center gap-3 py-1.5 text-sm">
315
- <input
316
- type="checkbox"
317
- checked={selected.has(agent.key)}
318
- onChange={() => toggle(agent.key)}
319
- className="rounded border-border"
320
- style={{ accentColor: 'var(--amber)' }}
321
- />
322
- <span className="w-28 shrink-0 text-xs">{agent.name}</span>
323
- <span className="text-2xs px-1.5 py-0.5 rounded font-mono"
324
- style={{ background: 'rgba(100,100,120,0.08)' }}>
325
- {getEffectiveTransport(agent)}
326
- </span>
327
- {agent.installed ? (
328
- <>
329
- <span className="text-2xs px-1.5 py-0.5 rounded bg-success/15 text-success font-mono">
330
- {agent.transport}
331
- </span>
332
- <span className="text-2xs text-muted-foreground">{agent.scope}</span>
333
- </>
334
- ) : (
335
- <span className="text-2xs text-muted-foreground">
336
- {agent.present ? (m?.detected ?? 'Detected') : (m?.notFound ?? 'Not found')}
337
- </span>
338
- )}
339
- {/* Scope selector */}
340
- {selected.has(agent.key) && agent.hasProjectScope && agent.hasGlobalScope && (
341
- <select
342
- value={scopes[agent.key] || 'project'}
343
- onChange={e => setScopes({ ...scopes, [agent.key]: e.target.value as 'project' | 'global' })}
344
- className="ml-auto text-2xs px-1.5 py-0.5 rounded border border-border bg-background text-foreground"
345
- >
346
- <option value="project">{m?.project ?? 'Project'}</option>
347
- <option value="global">{m?.global ?? 'Global'}</option>
348
- </select>
349
- )}
350
- </div>
351
- ))}
352
- </div>
353
-
354
- {/* Select detected / Clear buttons */}
355
- <div className="flex gap-2 text-xs pt-1">
356
- <button type="button"
357
- onClick={() => setSelected(new Set(
358
- agents.filter(a => !a.installed && a.present).map(a => a.key)
359
- ))}
360
- className="px-2.5 py-1 rounded-md border transition-colors hover:bg-muted/50"
361
- style={{ borderColor: 'var(--amber)', color: 'var(--amber)' }}>
362
- {m?.selectDetected ?? 'Select Detected'}
363
- </button>
364
- <button type="button"
365
- onClick={() => setSelected(new Set())}
366
- className="px-2.5 py-1 rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground">
367
- {m?.clearSelection ?? 'Clear'}
368
- </button>
369
- </div>
370
-
371
- {/* Transport selector */}
372
- <div className="flex items-center gap-4 text-xs pt-1">
373
- <label className="flex items-center gap-1.5 cursor-pointer">
374
- <input
375
- type="radio"
376
- name="transport"
377
- checked={transport === 'auto'}
378
- onChange={() => setTransport('auto')}
379
- className=""
380
- style={{ accentColor: 'var(--amber)' }}
381
- />
382
- {m?.transportAuto ?? 'auto (recommended)'}
383
- </label>
384
- <label className="flex items-center gap-1.5 cursor-pointer">
385
- <input
386
- type="radio"
387
- name="transport"
388
- checked={transport === 'stdio'}
389
- onChange={() => setTransport('stdio')}
390
- className=""
391
- style={{ accentColor: 'var(--amber)' }}
392
- />
393
- {m?.transportStdio ?? 'stdio'}
394
- </label>
395
- <label className="flex items-center gap-1.5 cursor-pointer">
396
- <input
397
- type="radio"
398
- name="transport"
399
- checked={transport === 'http'}
400
- onChange={() => setTransport('http')}
401
- className=""
402
- style={{ accentColor: 'var(--amber)' }}
403
- />
404
- {m?.transportHttp ?? 'http'}
405
- </label>
406
- </div>
4
+ import type { McpStatus, AgentInfo, McpTabProps } from './types';
5
+ import ServerStatus from './McpServerStatus';
6
+ import AgentInstall from './McpAgentInstall';
7
+ import SkillsSection from './McpSkillsSection';
407
8
 
408
- {/* HTTP settings */}
409
- {showHttpFields && (
410
- <div className="space-y-2 pl-5 text-xs">
411
- <div className="space-y-1">
412
- <label className="text-muted-foreground">{m?.httpUrl ?? 'MCP URL'}</label>
413
- <input
414
- type="text"
415
- value={httpUrl}
416
- onChange={e => setHttpUrl(e.target.value)}
417
- className="w-full px-2.5 py-1.5 text-xs rounded-md border border-border bg-background font-mono text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring"
418
- />
419
- </div>
420
- <div className="space-y-1">
421
- <label className="text-muted-foreground">{m?.httpToken ?? 'Auth Token'}</label>
422
- <input
423
- type="password"
424
- value={httpToken}
425
- onChange={e => setHttpToken(e.target.value)}
426
- placeholder="Bearer token"
427
- className="w-full px-2.5 py-1.5 text-xs rounded-md border border-border bg-background font-mono text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring"
428
- />
429
- </div>
430
- </div>
431
- )}
432
-
433
- {/* Install button */}
434
- <button
435
- onClick={handleInstall}
436
- disabled={selected.size === 0 || installing}
437
- className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
438
- style={{ background: 'var(--amber)', color: 'var(--amber-foreground)' }}
439
- >
440
- {installing && <Loader2 size={12} className="animate-spin" />}
441
- {installing ? (m?.installing ?? 'Installing...') : (m?.installSelected ?? 'Install Selected')}
442
- </button>
443
-
444
- {/* Message */}
445
- {message && (
446
- <div className="flex items-center gap-1.5 text-xs" role="status">
447
- {message.type === 'success' ? (
448
- <><CheckCircle2 size={12} className="text-success" /><span className="text-success">{message.text}</span></>
449
- ) : (
450
- <><AlertCircle size={12} className="text-destructive" /><span className="text-destructive">{message.text}</span></>
451
- )}
452
- </div>
453
- )}
454
- </div>
455
- );
456
- }
457
-
458
- /* ── Skills Section ────────────────────────────────────────────── */
459
-
460
- const SKILL_TEMPLATES: Record<string, (name: string) => string> = {
461
- general: (n: string) => `---\nname: ${n}\ndescription: >\n Describe WHEN the agent should use this\n skill. Be specific about trigger conditions.\n---\n\n# Instructions\n\n## Context\n<!-- Background knowledge for the agent -->\n\n## Steps\n1. \n2. \n\n## Rules\n<!-- Constraints, edge cases, formats -->\n- `,
462
- 'tool-use': (n: string) => `---\nname: ${n}\ndescription: >\n Describe WHEN the agent should use this\n skill. Be specific about trigger conditions.\n---\n\n# Instructions\n\n## Available Tools\n<!-- List tools the agent can use -->\n- \n\n## When to Use\n<!-- Conditions that trigger this skill -->\n\n## Output Format\n<!-- Expected response structure -->\n`,
463
- workflow: (n: string) => `---\nname: ${n}\ndescription: >\n Describe WHEN the agent should use this\n skill. Be specific about trigger conditions.\n---\n\n# Instructions\n\n## Trigger\n<!-- What triggers this workflow -->\n\n## Steps\n1. \n2. \n\n## Validation\n<!-- How to verify success -->\n\n## Rollback\n<!-- What to do on failure -->\n`,
464
- };
465
-
466
- function SkillsSection({ t }: { t: any }) {
467
- const m = t.settings?.mcp;
468
- const [skills, setSkills] = useState<SkillInfo[]>([]);
469
- const [loading, setLoading] = useState(true);
470
- const [expanded, setExpanded] = useState<string | null>(null);
471
- const [adding, setAdding] = useState(false);
472
- const [newName, setNewName] = useState('');
473
- const [newContent, setNewContent] = useState('');
474
- const [saving, setSaving] = useState(false);
475
- const [error, setError] = useState('');
476
-
477
- // New state for search, grouping, full content, editing
478
- const [search, setSearch] = useState('');
479
- const [builtinCollapsed, setBuiltinCollapsed] = useState(true);
480
- const [editing, setEditing] = useState<string | null>(null);
481
- const [editContent, setEditContent] = useState('');
482
- const [fullContent, setFullContent] = useState<Record<string, string>>({});
483
- const [loadingContent, setLoadingContent] = useState<string | null>(null);
484
- const [selectedTemplate, setSelectedTemplate] = useState<'general' | 'tool-use' | 'workflow'>('general');
485
-
486
- const fetchSkills = useCallback(async () => {
487
- try {
488
- const data = await apiFetch<{ skills: SkillInfo[] }>('/api/skills');
489
- setSkills(data.skills);
490
- } catch { /* ignore */ }
491
- setLoading(false);
492
- }, []);
493
-
494
- useEffect(() => { fetchSkills(); }, [fetchSkills]);
495
-
496
- // Filtered + grouped
497
- const filtered = useMemo(() => {
498
- if (!search) return skills;
499
- const q = search.toLowerCase();
500
- return skills.filter(s => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q));
501
- }, [skills, search]);
502
-
503
- const customSkills = useMemo(() => filtered.filter(s => s.source === 'user'), [filtered]);
504
- const builtinSkills = useMemo(() => filtered.filter(s => s.source === 'builtin'), [filtered]);
505
-
506
- const handleToggle = async (name: string, enabled: boolean) => {
507
- try {
508
- await apiFetch('/api/skills', {
509
- method: 'POST',
510
- headers: { 'Content-Type': 'application/json' },
511
- body: JSON.stringify({ action: 'toggle', name, enabled }),
512
- });
513
- setSkills(prev => prev.map(s => s.name === name ? { ...s, enabled } : s));
514
- } catch { /* ignore */ }
515
- };
516
-
517
- const handleDelete = async (name: string) => {
518
- const confirmMsg = m?.skillDeleteConfirm ? m.skillDeleteConfirm(name) : `Delete skill "${name}"?`;
519
- if (!confirm(confirmMsg)) return;
520
- try {
521
- await apiFetch('/api/skills', {
522
- method: 'POST',
523
- headers: { 'Content-Type': 'application/json' },
524
- body: JSON.stringify({ action: 'delete', name }),
525
- });
526
- setFullContent(prev => { const n = { ...prev }; delete n[name]; return n; });
527
- if (editing === name) setEditing(null);
528
- if (expanded === name) setExpanded(null);
529
- fetchSkills();
530
- } catch { /* ignore */ }
531
- };
532
-
533
- const loadFullContent = async (name: string) => {
534
- if (fullContent[name]) return;
535
- setLoadingContent(name);
536
- try {
537
- const data = await apiFetch<{ content: string }>('/api/skills', {
538
- method: 'POST',
539
- headers: { 'Content-Type': 'application/json' },
540
- body: JSON.stringify({ action: 'read', name }),
541
- });
542
- setFullContent(prev => ({ ...prev, [name]: data.content }));
543
- } catch {
544
- // Store empty marker so UI shows "No description" rather than stuck loading
545
- setFullContent(prev => ({ ...prev, [name]: '' }));
546
- } finally {
547
- setLoadingContent(null);
548
- }
549
- };
550
-
551
- const handleExpand = (name: string) => {
552
- const next = expanded === name ? null : name;
553
- setExpanded(next);
554
- if (next) loadFullContent(name);
555
- if (editing && editing !== name) setEditing(null);
556
- };
557
-
558
- const handleEditStart = (name: string) => {
559
- setEditing(name);
560
- setEditContent(fullContent[name] || '');
561
- };
562
-
563
- const handleEditSave = async (name: string) => {
564
- setSaving(true);
565
- try {
566
- await apiFetch('/api/skills', {
567
- method: 'POST',
568
- headers: { 'Content-Type': 'application/json' },
569
- body: JSON.stringify({ action: 'update', name, content: editContent }),
570
- });
571
- setFullContent(prev => ({ ...prev, [name]: editContent }));
572
- setEditing(null);
573
- fetchSkills(); // refresh description from updated frontmatter
574
- } catch { /* ignore */ } finally {
575
- setSaving(false);
576
- }
577
- };
578
-
579
- const handleEditCancel = () => {
580
- setEditing(null);
581
- setEditContent('');
582
- };
583
-
584
- const getTemplate = (skillName: string, tmpl?: string) => {
585
- const key = tmpl || selectedTemplate;
586
- const fn = SKILL_TEMPLATES[key] || SKILL_TEMPLATES.general;
587
- return fn(skillName || 'my-skill');
588
- };
589
-
590
- const handleCreate = async () => {
591
- if (!newName.trim()) return;
592
- setSaving(true);
593
- setError('');
594
- try {
595
- // Content is the full SKILL.md (with frontmatter)
596
- const content = newContent || getTemplate(newName.trim());
597
- await apiFetch('/api/skills', {
598
- method: 'POST',
599
- headers: { 'Content-Type': 'application/json' },
600
- body: JSON.stringify({ action: 'create', name: newName.trim(), content }),
601
- });
602
- setAdding(false);
603
- setNewName('');
604
- setNewContent('');
605
- fetchSkills();
606
- } catch (err: unknown) {
607
- setError(err instanceof Error ? err.message : 'Failed to create skill');
608
- } finally {
609
- setSaving(false);
610
- }
611
- };
612
-
613
- // Sync template name when newName changes (only if content matches a template)
614
- const handleNameChange = (val: string) => {
615
- const cleaned = val.replace(/[^a-z0-9-]/g, '');
616
- const oldTemplate = getTemplate(newName || 'my-skill');
617
- if (!newContent || newContent === oldTemplate) {
618
- setNewContent(getTemplate(cleaned || 'my-skill'));
619
- }
620
- setNewName(cleaned);
621
- };
622
-
623
- const handleTemplateChange = (tmpl: 'general' | 'tool-use' | 'workflow') => {
624
- const oldTemplate = getTemplate(newName || 'my-skill', selectedTemplate);
625
- setSelectedTemplate(tmpl);
626
- // Only replace content if it matches the old template (user hasn't customized)
627
- if (!newContent || newContent === oldTemplate) {
628
- setNewContent(getTemplate(newName || 'my-skill', tmpl));
629
- }
630
- };
631
-
632
- if (loading) {
633
- return (
634
- <div className="flex justify-center py-4">
635
- <Loader2 size={16} className="animate-spin text-muted-foreground" />
636
- </div>
637
- );
638
- }
639
-
640
- const renderSkillRow = (skill: SkillInfo) => (
641
- <div key={skill.name} className="border border-border rounded-lg overflow-hidden">
642
- <div
643
- className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-muted/50 transition-colors"
644
- onClick={() => handleExpand(skill.name)}
645
- >
646
- {expanded === skill.name ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
647
- <span className="text-xs font-medium flex-1">{skill.name}</span>
648
- <span className={`text-2xs px-1.5 py-0.5 rounded ${
649
- skill.source === 'builtin' ? 'bg-blue-500/15 text-blue-500' : 'bg-purple-500/15 text-purple-500'
650
- }`}>
651
- {skill.source === 'builtin' ? (m?.skillBuiltin ?? 'Built-in') : (m?.skillUser ?? 'Custom')}
652
- </span>
653
- <button
654
- onClick={e => { e.stopPropagation(); handleToggle(skill.name, !skill.enabled); }}
655
- className={`relative inline-flex h-4 w-7 items-center rounded-full transition-colors ${
656
- skill.enabled ? 'bg-success' : 'bg-muted-foreground/30'
657
- }`}
658
- >
659
- <span className={`inline-block h-3 w-3 rounded-full bg-white transition-transform ${
660
- skill.enabled ? 'translate-x-3.5' : 'translate-x-0.5'
661
- }`} />
662
- </button>
663
- </div>
664
-
665
- {expanded === skill.name && (
666
- <div className="px-3 py-2 border-t border-border text-xs space-y-2 bg-muted/20">
667
- <p className="text-muted-foreground">{skill.description || 'No description'}</p>
668
- <p className="text-muted-foreground font-mono text-2xs">{skill.path}</p>
669
-
670
- {/* Full content display / edit */}
671
- {loadingContent === skill.name ? (
672
- <div className="flex items-center gap-1.5 text-muted-foreground">
673
- <Loader2 size={10} className="animate-spin" />
674
- <span className="text-2xs">Loading...</span>
675
- </div>
676
- ) : fullContent[skill.name] ? (
677
- <div className="space-y-1.5">
678
- <div className="flex items-center justify-between">
679
- <span className="text-2xs text-muted-foreground font-medium">{m?.skillContent ?? 'Content'}</span>
680
- <div className="flex items-center gap-2">
681
- {skill.editable && editing !== skill.name && (
682
- <button
683
- onClick={() => handleEditStart(skill.name)}
684
- className="flex items-center gap-1 text-2xs text-muted-foreground hover:text-foreground transition-colors"
685
- >
686
- <Pencil size={10} />
687
- {m?.editSkill ?? 'Edit'}
688
- </button>
689
- )}
690
- {skill.editable && (
691
- <button
692
- onClick={() => handleDelete(skill.name)}
693
- className="flex items-center gap-1 text-2xs text-destructive hover:underline"
694
- >
695
- <Trash2 size={10} />
696
- {m?.deleteSkill ?? 'Delete'}
697
- </button>
698
- )}
699
- </div>
700
- </div>
701
-
702
- {editing === skill.name ? (
703
- <div className="space-y-1.5">
704
- <textarea
705
- value={editContent}
706
- onChange={e => setEditContent(e.target.value)}
707
- rows={Math.min(20, (editContent.match(/\n/g) || []).length + 3)}
708
- className="w-full px-2.5 py-1.5 text-xs rounded-md border border-border bg-background text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring resize-y font-mono"
709
- />
710
- <div className="flex items-center gap-2">
711
- <button
712
- onClick={() => handleEditSave(skill.name)}
713
- disabled={saving}
714
- className="flex items-center gap-1 px-2.5 py-1 text-xs rounded-md disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
715
- style={{ background: 'var(--amber)', color: 'var(--amber-foreground)' }}
716
- >
717
- {saving && <Loader2 size={10} className="animate-spin" />}
718
- {m?.saveSkill ?? 'Save'}
719
- </button>
720
- <button
721
- onClick={handleEditCancel}
722
- className="px-2.5 py-1 text-xs rounded-md border border-border text-muted-foreground hover:text-foreground transition-colors"
723
- >
724
- {m?.cancelSkill ?? 'Cancel'}
725
- </button>
726
- </div>
727
- </div>
728
- ) : (
729
- <div className="w-full rounded-md border border-border bg-background/50 max-h-[300px] overflow-y-auto px-2.5 py-1.5 text-xs [&_.prose]:max-w-none [&_.prose]:text-xs [&_h1]:text-sm [&_h2]:text-xs [&_h3]:text-xs [&_pre]:text-2xs [&_code]:text-2xs">
730
- <MarkdownView content={fullContent[skill.name].replace(/^---\n[\s\S]*?\n---\n*/, '')} />
731
- </div>
732
- )}
733
- </div>
734
- ) : null}
735
- </div>
736
- )}
737
- </div>
738
- );
739
-
740
- return (
741
- <div className="space-y-3 pt-2">
742
- {/* Search */}
743
- <div className="relative">
744
- <Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
745
- <input
746
- type="text"
747
- value={search}
748
- onChange={e => setSearch(e.target.value)}
749
- placeholder={m?.searchSkills ?? 'Search skills...'}
750
- className="w-full pl-7 pr-2.5 py-1.5 text-xs rounded-md border border-border bg-background text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring"
751
- />
752
- {search && (
753
- <button
754
- onClick={() => setSearch('')}
755
- className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
756
- >
757
- <X size={10} />
758
- </button>
759
- )}
760
- </div>
761
-
762
- {/* Skill language switcher */}
763
- {(() => {
764
- const mindosEnabled = skills.find(s => s.name === 'mindos')?.enabled ?? true;
765
- const currentLang = mindosEnabled ? 'en' : 'zh';
766
- const handleLangSwitch = async (lang: 'en' | 'zh') => {
767
- if (lang === currentLang) return;
768
- if (lang === 'en') {
769
- await handleToggle('mindos', true);
770
- await handleToggle('mindos-zh', false);
771
- } else {
772
- await handleToggle('mindos-zh', true);
773
- await handleToggle('mindos', false);
774
- }
775
- };
776
- return (
777
- <div className="flex items-center gap-2 text-xs">
778
- <span className="text-muted-foreground">{m?.skillLanguage ?? 'Skill Language'}</span>
779
- <div className="flex rounded-md border border-border overflow-hidden">
780
- <button
781
- onClick={() => handleLangSwitch('en')}
782
- className={`px-2.5 py-1 text-xs transition-colors ${
783
- currentLang === 'en'
784
- ? 'bg-amber-500/15 text-amber-600 font-medium'
785
- : 'text-muted-foreground hover:bg-muted'
786
- }`}
787
- >
788
- {m?.skillLangEn ?? 'English'}
789
- </button>
790
- <button
791
- onClick={() => handleLangSwitch('zh')}
792
- className={`px-2.5 py-1 text-xs transition-colors border-l border-border ${
793
- currentLang === 'zh'
794
- ? 'bg-amber-500/15 text-amber-600 font-medium'
795
- : 'text-muted-foreground hover:bg-muted'
796
- }`}
797
- >
798
- {m?.skillLangZh ?? '中文'}
799
- </button>
800
- </div>
801
- </div>
802
- );
803
- })()}
804
-
805
- {/* Empty search result */}
806
- {filtered.length === 0 && search && (
807
- <p className="text-xs text-muted-foreground text-center py-3">
808
- {m?.noSkillsMatch ? m.noSkillsMatch(search) : `No skills match "${search}"`}
809
- </p>
810
- )}
811
-
812
- {/* Custom group — always open */}
813
- {customSkills.length > 0 && (
814
- <div className="space-y-1.5">
815
- <div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
816
- <span>{m?.customGroup ?? 'Custom'} ({customSkills.length})</span>
817
- </div>
818
- <div className="space-y-1.5">
819
- {customSkills.map(renderSkillRow)}
820
- </div>
821
- </div>
822
- )}
823
-
824
- {/* Built-in group — collapsible, default collapsed */}
825
- {builtinSkills.length > 0 && (
826
- <div className="space-y-1.5">
827
- <div
828
- className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
829
- onClick={() => setBuiltinCollapsed(!builtinCollapsed)}
830
- >
831
- {builtinCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
832
- <span>{m?.builtinGroup ?? 'Built-in'} ({builtinSkills.length})</span>
833
- </div>
834
- {!builtinCollapsed && (
835
- <div className="space-y-1.5">
836
- {builtinSkills.map(renderSkillRow)}
837
- </div>
838
- )}
839
- </div>
840
- )}
841
-
842
- {/* Add skill form — template-based */}
843
- {adding ? (
844
- <div className="border border-border rounded-lg p-3 space-y-2">
845
- <div className="flex items-center justify-between">
846
- <span className="text-xs font-medium">{m?.addSkill ?? '+ Add Skill'}</span>
847
- <button onClick={() => { setAdding(false); setNewName(''); setNewContent(''); setError(''); }} className="p-0.5 rounded hover:bg-muted text-muted-foreground">
848
- <X size={12} />
849
- </button>
850
- </div>
851
- <div className="space-y-1">
852
- <label className="text-2xs text-muted-foreground">{m?.skillName ?? 'Name'}</label>
853
- <input
854
- type="text"
855
- value={newName}
856
- onChange={e => handleNameChange(e.target.value)}
857
- placeholder="my-skill"
858
- className="w-full px-2.5 py-1.5 text-xs rounded-md border border-border bg-background font-mono text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring"
859
- />
860
- </div>
861
- <div className="space-y-1">
862
- <label className="text-2xs text-muted-foreground">{m?.skillTemplate ?? 'Template'}</label>
863
- <div className="flex rounded-md border border-border overflow-hidden w-fit">
864
- {(['general', 'tool-use', 'workflow'] as const).map((tmpl, i) => (
865
- <button
866
- key={tmpl}
867
- onClick={() => handleTemplateChange(tmpl)}
868
- className={`px-2.5 py-1 text-xs transition-colors ${i > 0 ? 'border-l border-border' : ''} ${
869
- selectedTemplate === tmpl
870
- ? 'bg-amber-500/15 text-amber-600 font-medium'
871
- : 'text-muted-foreground hover:bg-muted'
872
- }`}
873
- >
874
- {tmpl === 'general' ? (m?.skillTemplateGeneral ?? 'General')
875
- : tmpl === 'tool-use' ? (m?.skillTemplateToolUse ?? 'Tool-use')
876
- : (m?.skillTemplateWorkflow ?? 'Workflow')}
877
- </button>
878
- ))}
879
- </div>
880
- </div>
881
- <div className="space-y-1">
882
- <label className="text-2xs text-muted-foreground">{m?.skillContent ?? 'Content'}</label>
883
- <textarea
884
- value={newContent}
885
- onChange={e => setNewContent(e.target.value)}
886
- rows={16}
887
- placeholder="Skill instructions (markdown)..."
888
- className="w-full px-2.5 py-1.5 text-xs rounded-md border border-border bg-background text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring resize-y font-mono"
889
- />
890
- </div>
891
- {error && (
892
- <p className="text-2xs text-destructive flex items-center gap-1">
893
- <AlertCircle size={10} />
894
- {error}
895
- </p>
896
- )}
897
- <div className="flex items-center gap-2">
898
- <button
899
- onClick={handleCreate}
900
- disabled={!newName.trim() || saving}
901
- className="flex items-center gap-1 px-2.5 py-1 text-xs rounded-md disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
902
- style={{ background: 'var(--amber)', color: 'var(--amber-foreground)' }}
903
- >
904
- {saving && <Loader2 size={10} className="animate-spin" />}
905
- {m?.saveSkill ?? 'Save'}
906
- </button>
907
- <button
908
- onClick={() => { setAdding(false); setNewName(''); setNewContent(''); setError(''); }}
909
- className="px-2.5 py-1 text-xs rounded-md border border-border text-muted-foreground hover:text-foreground transition-colors"
910
- >
911
- {m?.cancelSkill ?? 'Cancel'}
912
- </button>
913
- </div>
914
- </div>
915
- ) : (
916
- <button
917
- onClick={() => { setAdding(true); setSelectedTemplate('general'); setNewContent(getTemplate('my-skill', 'general')); }}
918
- className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
919
- >
920
- <Plus size={12} />
921
- {m?.addSkill ?? '+ Add Skill'}
922
- </button>
923
- )}
924
- </div>
925
- );
926
- }
9
+ // Re-export types for backward compatibility
10
+ export type { McpStatus, AgentInfo, SkillInfo, McpTabProps } from './types';
927
11
 
928
12
  /* ── Main McpTab ───────────────────────────────────────────────── */
929
13
 
@@ -931,8 +15,6 @@ export function McpTab({ t }: McpTabProps) {
931
15
  const [mcpStatus, setMcpStatus] = useState<McpStatus | null>(null);
932
16
  const [agents, setAgents] = useState<AgentInfo[]>([]);
933
17
  const [loading, setLoading] = useState(true);
934
- const [showAgents, setShowAgents] = useState(false);
935
- const [showSkills, setShowSkills] = useState(false);
936
18
 
937
19
  const fetchAll = useCallback(async () => {
938
20
  try {
@@ -960,45 +42,21 @@ export function McpTab({ t }: McpTabProps) {
960
42
 
961
43
  return (
962
44
  <div className="space-y-6">
963
- {/* MCP Server Status — prominent card */}
45
+ {/* MCP Server Status — compact card */}
964
46
  <div className="rounded-xl border p-4" style={{ borderColor: 'var(--border)', background: 'var(--card)' }}>
965
47
  <ServerStatus status={mcpStatus} agents={agents} t={t} />
966
48
  </div>
967
49
 
968
- {/* Agent Install — collapsible */}
969
- <div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border)' }}>
970
- <button
971
- type="button"
972
- onClick={() => setShowAgents(!showAgents)}
973
- className="w-full flex items-center justify-between px-4 py-3 text-sm font-medium hover:bg-muted/50 transition-colors"
974
- style={{ color: 'var(--foreground)' }}
975
- >
976
- <span>{m?.agentsTitle ?? 'Agent Configuration'}</span>
977
- <ChevronDown size={14} className={`transition-transform text-muted-foreground ${showAgents ? 'rotate-180' : ''}`} />
978
- </button>
979
- {showAgents && (
980
- <div className="px-4 pb-4 border-t" style={{ borderColor: 'var(--border)' }}>
981
- <AgentInstall agents={agents} t={t} onRefresh={fetchAll} />
982
- </div>
983
- )}
50
+ {/* Agent Configuration */}
51
+ <div>
52
+ <h3 className="text-sm font-medium text-foreground mb-3">{m?.agentsTitle ?? 'Agent Configuration'}</h3>
53
+ <AgentInstall agents={agents} t={t} onRefresh={fetchAll} />
984
54
  </div>
985
55
 
986
- {/* Skills — collapsible */}
987
- <div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border)' }}>
988
- <button
989
- type="button"
990
- onClick={() => setShowSkills(!showSkills)}
991
- className="w-full flex items-center justify-between px-4 py-3 text-sm font-medium hover:bg-muted/50 transition-colors"
992
- style={{ color: 'var(--foreground)' }}
993
- >
994
- <span>{m?.skillsTitle ?? 'Skills'}</span>
995
- <ChevronDown size={14} className={`transition-transform text-muted-foreground ${showSkills ? 'rotate-180' : ''}`} />
996
- </button>
997
- {showSkills && (
998
- <div className="px-4 pb-4 border-t" style={{ borderColor: 'var(--border)' }}>
999
- <SkillsSection t={t} />
1000
- </div>
1001
- )}
56
+ {/* Skills */}
57
+ <div>
58
+ <h3 className="text-sm font-medium text-foreground mb-3">{m?.skillsTitle ?? 'Skills'}</h3>
59
+ <SkillsSection t={t} />
1002
60
  </div>
1003
61
  </div>
1004
62
  );