@geminilight/mindos 0.5.29 → 0.5.32

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