@geminilight/mindos 0.5.20 → 0.5.21
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 +308 -172
- package/app/components/SettingsModal.tsx +52 -58
- 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/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/lib/agent/context.ts +151 -87
- package/app/lib/agent/index.ts +4 -3
- package/app/lib/agent/model.ts +76 -10
- 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/i18n-en.ts +480 -0
- package/app/lib/i18n-zh.ts +505 -0
- package/app/lib/i18n.ts +4 -963
- package/app/next-env.d.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
|
@@ -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 } 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,7 +12,6 @@ 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';
|
|
18
17
|
|
|
@@ -28,6 +27,8 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
|
|
|
28
27
|
const [saving, setSaving] = useState(false);
|
|
29
28
|
const [status, setStatus] = useState<'idle' | 'saved' | 'error' | 'load-error'>('idle');
|
|
30
29
|
const { t, locale, setLocale } = useLocale();
|
|
30
|
+
const saveTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
31
|
+
const dataLoaded = useRef(false);
|
|
31
32
|
|
|
32
33
|
// Appearance state (localStorage-based)
|
|
33
34
|
const [font, setFont] = useState('lora');
|
|
@@ -37,8 +38,8 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
|
|
|
37
38
|
const [pluginStates, setPluginStates] = useState<Record<string, boolean>>({});
|
|
38
39
|
|
|
39
40
|
useEffect(() => {
|
|
40
|
-
if (!open) return;
|
|
41
|
-
apiFetch<SettingsData>('/api/settings').then(setData).catch(() => setStatus('load-error'));
|
|
41
|
+
if (!open) { dataLoaded.current = false; return; }
|
|
42
|
+
apiFetch<SettingsData>('/api/settings').then(d => { setData(d); dataLoaded.current = true; }).catch(() => setStatus('load-error'));
|
|
42
43
|
setFont(localStorage.getItem('prose-font') ?? 'lora');
|
|
43
44
|
setContentWidth(localStorage.getItem('content-width') ?? '780px');
|
|
44
45
|
const stored = localStorage.getItem('theme');
|
|
@@ -81,14 +82,14 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
|
|
|
81
82
|
return () => window.removeEventListener('keydown', handler);
|
|
82
83
|
}, [open, onClose]);
|
|
83
84
|
|
|
84
|
-
|
|
85
|
-
|
|
85
|
+
// Auto-save with debounce when data changes
|
|
86
|
+
const doSave = useCallback(async (d: SettingsData) => {
|
|
86
87
|
setSaving(true);
|
|
87
88
|
try {
|
|
88
89
|
await apiFetch('/api/settings', {
|
|
89
90
|
method: 'POST',
|
|
90
91
|
headers: { 'Content-Type': 'application/json' },
|
|
91
|
-
body: JSON.stringify({ ai:
|
|
92
|
+
body: JSON.stringify({ ai: d.ai, agent: d.agent, mindRoot: d.mindRoot, webPassword: d.webPassword, authToken: d.authToken }),
|
|
92
93
|
});
|
|
93
94
|
setStatus('saved');
|
|
94
95
|
setTimeout(() => setStatus('idle'), 2500);
|
|
@@ -98,7 +99,14 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
|
|
|
98
99
|
} finally {
|
|
99
100
|
setSaving(false);
|
|
100
101
|
}
|
|
101
|
-
}, [
|
|
102
|
+
}, []);
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (!data || !dataLoaded.current) return;
|
|
106
|
+
clearTimeout(saveTimer.current);
|
|
107
|
+
saveTimer.current = setTimeout(() => doSave(data), 800);
|
|
108
|
+
return () => clearTimeout(saveTimer.current);
|
|
109
|
+
}, [data, doSave]);
|
|
102
110
|
|
|
103
111
|
const updateAi = useCallback((patch: Partial<AiSettings>) => {
|
|
104
112
|
setData(d => d ? { ...d, ai: { ...d.ai, ...patch } } : d);
|
|
@@ -117,36 +125,28 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
|
|
|
117
125
|
openai: { apiKey: '', model: '', baseUrl: '' },
|
|
118
126
|
},
|
|
119
127
|
};
|
|
128
|
+
// Set defaults — auto-save will persist them
|
|
120
129
|
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);
|
|
130
|
+
// 🟢 MINOR #4: Refetch after auto-save completes (800ms debounce + 500ms save operation)
|
|
131
|
+
// Rather than magic 1200ms, wait for save to finish before refetching env-resolved values
|
|
132
|
+
const DEBOUNCE_DELAY = 800;
|
|
133
|
+
const SAVE_OPERATION_TIME = 500;
|
|
134
|
+
setTimeout(() => {
|
|
135
|
+
apiFetch<SettingsData>('/api/settings').then(d => { setData(d); }).catch(() => setStatus('error'));
|
|
136
|
+
}, DEBOUNCE_DELAY + SAVE_OPERATION_TIME);
|
|
136
137
|
}, [data]);
|
|
137
138
|
|
|
138
139
|
if (!open) return null;
|
|
139
140
|
|
|
140
141
|
const env = data?.envOverrides ?? {};
|
|
141
142
|
|
|
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: 'shortcuts', label: t.settings.tabs.shortcuts },
|
|
143
|
+
const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
|
144
|
+
{ id: 'ai', label: t.settings.tabs.ai, icon: <Sparkles size={13} /> },
|
|
145
|
+
{ id: 'appearance', label: t.settings.tabs.appearance, icon: <Palette size={13} /> },
|
|
146
|
+
{ id: 'knowledge', label: t.settings.tabs.knowledge, icon: <Database size={13} /> },
|
|
147
|
+
{ id: 'sync', label: t.settings.tabs.sync ?? 'Sync', icon: <RefreshCw size={13} /> },
|
|
148
|
+
{ id: 'mcp', label: t.settings.tabs.mcp ?? 'MCP', icon: <Plug size={13} /> },
|
|
149
|
+
{ id: 'plugins', label: t.settings.tabs.plugins, icon: <Puzzle size={13} /> },
|
|
150
150
|
];
|
|
151
151
|
|
|
152
152
|
return (
|
|
@@ -177,12 +177,13 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
|
|
|
177
177
|
<button
|
|
178
178
|
key={t.id}
|
|
179
179
|
onClick={() => setTab(t.id)}
|
|
180
|
-
className={`px-3 py-2.5 text-xs font-medium transition-colors border-b-2 -mb-px whitespace-nowrap ${
|
|
180
|
+
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
181
|
tab === t.id
|
|
182
182
|
? 'border-amber-500 text-foreground'
|
|
183
183
|
: 'border-transparent text-muted-foreground hover:text-foreground'
|
|
184
184
|
}`}
|
|
185
185
|
>
|
|
186
|
+
{t.icon}
|
|
186
187
|
{t.label}
|
|
187
188
|
</button>
|
|
188
189
|
))}
|
|
@@ -196,7 +197,7 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
|
|
|
196
197
|
<p className="text-sm text-destructive font-medium">Failed to load settings</p>
|
|
197
198
|
<p className="text-xs text-muted-foreground">Check that the server is running and AUTH_TOKEN is configured correctly.</p>
|
|
198
199
|
</div>
|
|
199
|
-
) : !data && tab !== '
|
|
200
|
+
) : !data && tab !== 'appearance' && tab !== 'mcp' && tab !== 'sync' ? (
|
|
200
201
|
<div className="flex justify-center py-8">
|
|
201
202
|
<Loader2 size={18} className="animate-spin text-muted-foreground" />
|
|
202
203
|
</div>
|
|
@@ -206,54 +207,47 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
|
|
|
206
207
|
{tab === 'appearance' && <AppearanceTab font={font} setFont={setFont} contentWidth={contentWidth} setContentWidth={setContentWidth} dark={dark} setDark={setDark} locale={locale} setLocale={setLocale} t={t} />}
|
|
207
208
|
{tab === 'knowledge' && data && <KnowledgeTab data={data} setData={setData} t={t} />}
|
|
208
209
|
{tab === 'plugins' && <PluginsTab pluginStates={pluginStates} setPluginStates={setPluginStates} t={t} />}
|
|
209
|
-
{tab === 'shortcuts' && <ShortcutsTab t={t} />}
|
|
210
210
|
{tab === 'sync' && <SyncTab t={t} />}
|
|
211
211
|
{tab === 'mcp' && <McpTab t={t} />}
|
|
212
212
|
</>
|
|
213
213
|
)}
|
|
214
214
|
</div>
|
|
215
215
|
|
|
216
|
-
{/* Footer */}
|
|
216
|
+
{/* Footer — status bar + contextual actions */}
|
|
217
217
|
{(tab === 'ai' || tab === 'knowledge') && (
|
|
218
|
-
<div className="px-5 py-
|
|
218
|
+
<div className="px-5 py-2.5 border-t border-border shrink-0 flex items-center justify-between">
|
|
219
219
|
<div className="flex items-center gap-3">
|
|
220
220
|
{tab === 'ai' && Object.values(env).some(Boolean) && (
|
|
221
221
|
<button
|
|
222
222
|
onClick={restoreFromEnv}
|
|
223
223
|
disabled={saving || !data}
|
|
224
|
-
className="flex items-center gap-1.5 px-
|
|
224
|
+
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
225
|
>
|
|
226
|
-
<RotateCcw size={
|
|
226
|
+
<RotateCcw size={12} />
|
|
227
227
|
{t.settings.ai.restoreFromEnv}
|
|
228
228
|
</button>
|
|
229
229
|
)}
|
|
230
230
|
{tab === 'knowledge' && (
|
|
231
231
|
<a
|
|
232
232
|
href="/setup?force=1"
|
|
233
|
-
className="flex items-center gap-1.5 px-
|
|
233
|
+
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
234
|
>
|
|
235
|
-
<RotateCcw size={
|
|
235
|
+
<RotateCcw size={12} />
|
|
236
236
|
{t.settings.reconfigure}
|
|
237
237
|
</a>
|
|
238
238
|
)}
|
|
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
239
|
</div>
|
|
248
|
-
<
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
{
|
|
256
|
-
|
|
240
|
+
<div className="flex items-center gap-1.5 text-xs" role="status" aria-live="polite">
|
|
241
|
+
{saving && (
|
|
242
|
+
<><Loader2 size={12} className="animate-spin text-muted-foreground" /><span className="text-muted-foreground">{t.settings.save}...</span></>
|
|
243
|
+
)}
|
|
244
|
+
{status === 'saved' && (
|
|
245
|
+
<><CheckCircle2 size={12} className="text-success" /><span className="text-success">{t.settings.saved}</span></>
|
|
246
|
+
)}
|
|
247
|
+
{status === 'error' && (
|
|
248
|
+
<><AlertCircle size={12} className="text-destructive" /><span className="text-destructive">{t.settings.saveFailed}</span></>
|
|
249
|
+
)}
|
|
250
|
+
</div>
|
|
257
251
|
</div>
|
|
258
252
|
)}
|
|
259
253
|
</div>
|
|
@@ -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 && (
|
|
@@ -1,22 +1,14 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { ChevronDown, ChevronRight } from 'lucide-react';
|
|
3
5
|
import { Locale } from '@/lib/i18n';
|
|
4
|
-
import { CONTENT_WIDTHS, FONTS } from './types';
|
|
6
|
+
import { CONTENT_WIDTHS, FONTS, AppearanceTabProps } from './types';
|
|
5
7
|
import { Field, Select } from './Primitives';
|
|
6
8
|
|
|
7
|
-
interface AppearanceTabProps {
|
|
8
|
-
font: string;
|
|
9
|
-
setFont: (v: string) => void;
|
|
10
|
-
contentWidth: string;
|
|
11
|
-
setContentWidth: (v: string) => void;
|
|
12
|
-
dark: boolean;
|
|
13
|
-
setDark: (v: boolean) => void;
|
|
14
|
-
locale: Locale;
|
|
15
|
-
setLocale: (v: Locale) => void;
|
|
16
|
-
t: any;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
9
|
export function AppearanceTab({ font, setFont, contentWidth, setContentWidth, dark, setDark, locale, setLocale, t }: AppearanceTabProps) {
|
|
10
|
+
const [showShortcuts, setShowShortcuts] = useState(false);
|
|
11
|
+
|
|
20
12
|
return (
|
|
21
13
|
<div className="space-y-5">
|
|
22
14
|
<Field label={t.settings.appearance.readingFont}>
|
|
@@ -96,6 +88,32 @@ export function AppearanceTab({ font, setFont, contentWidth, setContentWidth, da
|
|
|
96
88
|
</Field>
|
|
97
89
|
|
|
98
90
|
<p className="text-xs text-muted-foreground">{t.settings.appearance.browserNote}</p>
|
|
91
|
+
|
|
92
|
+
{/* Keyboard Shortcuts */}
|
|
93
|
+
<div className="border-t border-border pt-4">
|
|
94
|
+
<button
|
|
95
|
+
type="button"
|
|
96
|
+
onClick={() => setShowShortcuts(!showShortcuts)}
|
|
97
|
+
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
|
|
98
|
+
>
|
|
99
|
+
{showShortcuts ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
|
100
|
+
{t.settings.tabs.shortcuts}
|
|
101
|
+
</button>
|
|
102
|
+
{showShortcuts && (
|
|
103
|
+
<div className="mt-3 space-y-1">
|
|
104
|
+
{t.shortcuts.map((s: { readonly description: string; readonly keys: readonly string[] }, i: number) => (
|
|
105
|
+
<div key={i} className="flex items-center justify-between py-2 border-b border-border last:border-0">
|
|
106
|
+
<span className="text-sm text-foreground">{s.description}</span>
|
|
107
|
+
<div className="flex items-center gap-1">
|
|
108
|
+
{s.keys.map((k: string, j: number) => (
|
|
109
|
+
<kbd key={j} className="px-2 py-0.5 text-xs font-mono bg-muted border border-border rounded text-foreground">{k}</kbd>
|
|
110
|
+
))}
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
))}
|
|
114
|
+
</div>
|
|
115
|
+
)}
|
|
116
|
+
</div>
|
|
99
117
|
</div>
|
|
100
118
|
);
|
|
101
119
|
}
|
|
@@ -2,16 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useCallback, useSyncExternalStore } from 'react';
|
|
4
4
|
import { Copy, Check, RefreshCw, Trash2, Sparkles } from 'lucide-react';
|
|
5
|
-
import type {
|
|
6
|
-
import { Field, Input, EnvBadge, SectionLabel } from './Primitives';
|
|
5
|
+
import type { KnowledgeTabProps } from './types';
|
|
6
|
+
import { Field, Input, EnvBadge, SectionLabel, Toggle } from './Primitives';
|
|
7
7
|
import { apiFetch } from '@/lib/api';
|
|
8
8
|
|
|
9
|
-
interface KnowledgeTabProps {
|
|
10
|
-
data: SettingsData;
|
|
11
|
-
setData: React.Dispatch<React.SetStateAction<SettingsData | null>>;
|
|
12
|
-
t: any;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
9
|
export function KnowledgeTab({ data, setData, t }: KnowledgeTabProps) {
|
|
16
10
|
const env = data.envOverrides ?? {};
|
|
17
11
|
const k = t.settings.knowledge;
|
|
@@ -21,8 +15,8 @@ export function KnowledgeTab({ data, setData, t }: KnowledgeTabProps) {
|
|
|
21
15
|
const [guideDismissed, setGuideDismissed] = useState(false);
|
|
22
16
|
|
|
23
17
|
useEffect(() => {
|
|
24
|
-
fetch
|
|
25
|
-
|
|
18
|
+
// 🟢 MINOR #5: Use apiFetch instead of raw fetch for consistency
|
|
19
|
+
apiFetch<{ guideState?: { active: boolean; dismissed: boolean } }>('/api/setup')
|
|
26
20
|
.then(d => {
|
|
27
21
|
const gs = d.guideState;
|
|
28
22
|
if (gs) {
|
|
@@ -30,7 +24,9 @@ export function KnowledgeTab({ data, setData, t }: KnowledgeTabProps) {
|
|
|
30
24
|
setGuideDismissed(!!gs.dismissed);
|
|
31
25
|
}
|
|
32
26
|
})
|
|
33
|
-
.catch(
|
|
27
|
+
.catch(err => {
|
|
28
|
+
console.error('Failed to fetch guide state:', err);
|
|
29
|
+
});
|
|
34
30
|
}, []);
|
|
35
31
|
|
|
36
32
|
const handleGuideToggle = useCallback(() => {
|
|
@@ -39,13 +35,16 @@ export function KnowledgeTab({ data, setData, t }: KnowledgeTabProps) {
|
|
|
39
35
|
// If re-enabling, also ensure active is true
|
|
40
36
|
const patch: Record<string, boolean> = { dismissed: newDismissed };
|
|
41
37
|
if (!newDismissed) patch.active = true;
|
|
42
|
-
|
|
38
|
+
apiFetch('/api/setup', {
|
|
43
39
|
method: 'PATCH',
|
|
44
40
|
headers: { 'Content-Type': 'application/json' },
|
|
45
41
|
body: JSON.stringify({ guideState: patch }),
|
|
46
42
|
})
|
|
47
43
|
.then(() => window.dispatchEvent(new Event('guide-state-updated')))
|
|
48
|
-
.catch(
|
|
44
|
+
.catch(err => {
|
|
45
|
+
console.error('Failed to update guide state:', err);
|
|
46
|
+
setGuideDismissed(!newDismissed); // rollback on failure
|
|
47
|
+
});
|
|
49
48
|
}, [guideDismissed]);
|
|
50
49
|
|
|
51
50
|
const origin = useSyncExternalStore(
|
|
@@ -202,21 +201,7 @@ export function KnowledgeTab({ data, setData, t }: KnowledgeTabProps) {
|
|
|
202
201
|
<div className="text-sm text-foreground">{t.guide?.showGuide ?? 'Show getting started guide'}</div>
|
|
203
202
|
</div>
|
|
204
203
|
</div>
|
|
205
|
-
<
|
|
206
|
-
type="button"
|
|
207
|
-
role="switch"
|
|
208
|
-
aria-checked={!guideDismissed}
|
|
209
|
-
onClick={handleGuideToggle}
|
|
210
|
-
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 ${
|
|
211
|
-
!guideDismissed ? 'bg-amber-500' : 'bg-muted'
|
|
212
|
-
}`}
|
|
213
|
-
>
|
|
214
|
-
<span
|
|
215
|
-
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${
|
|
216
|
-
!guideDismissed ? 'translate-x-4' : 'translate-x-0'
|
|
217
|
-
}`}
|
|
218
|
-
/>
|
|
219
|
-
</button>
|
|
204
|
+
<Toggle checked={!guideDismissed} onChange={() => handleGuideToggle()} />
|
|
220
205
|
</div>
|
|
221
206
|
</div>
|
|
222
207
|
)}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
|
|
5
|
+
import { apiFetch } from '@/lib/api';
|
|
6
|
+
import type { AgentInfo, McpAgentInstallProps } from './types';
|
|
7
|
+
|
|
8
|
+
/* ── Agent Install ─────────────────────────────────────────────── */
|
|
9
|
+
|
|
10
|
+
export default function AgentInstall({ agents, t, onRefresh }: McpAgentInstallProps) {
|
|
11
|
+
const m = t.settings?.mcp;
|
|
12
|
+
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
13
|
+
const [transport, setTransport] = useState<'auto' | 'stdio' | 'http'>('auto');
|
|
14
|
+
const [httpUrl, setHttpUrl] = useState('http://localhost:8787/mcp');
|
|
15
|
+
const [httpToken, setHttpToken] = useState('');
|
|
16
|
+
const [scopes, setScopes] = useState<Record<string, 'project' | 'global'>>({});
|
|
17
|
+
const [installing, setInstalling] = useState(false);
|
|
18
|
+
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
|
19
|
+
|
|
20
|
+
const getEffectiveTransport = (agent: AgentInfo) => {
|
|
21
|
+
if (transport === 'auto') return agent.preferredTransport;
|
|
22
|
+
return transport;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const toggle = (key: string) => {
|
|
26
|
+
setSelected(prev => {
|
|
27
|
+
const next = new Set(prev);
|
|
28
|
+
if (next.has(key)) next.delete(key); else next.add(key);
|
|
29
|
+
return next;
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const handleInstall = async () => {
|
|
34
|
+
if (selected.size === 0) return;
|
|
35
|
+
setInstalling(true);
|
|
36
|
+
setMessage(null);
|
|
37
|
+
try {
|
|
38
|
+
const payload = {
|
|
39
|
+
agents: [...selected].map(key => {
|
|
40
|
+
const agent = agents.find(a => a.key === key);
|
|
41
|
+
const effectiveTransport = transport === 'auto'
|
|
42
|
+
? (agent?.preferredTransport || 'stdio')
|
|
43
|
+
: transport;
|
|
44
|
+
return {
|
|
45
|
+
key,
|
|
46
|
+
scope: scopes[key] || (agent?.hasProjectScope ? 'project' : 'global'),
|
|
47
|
+
transport: effectiveTransport,
|
|
48
|
+
};
|
|
49
|
+
}),
|
|
50
|
+
transport,
|
|
51
|
+
...(transport === 'http' ? { url: httpUrl, token: httpToken } : {}),
|
|
52
|
+
// For auto mode, pass http settings for agents that need it
|
|
53
|
+
...(transport === 'auto' ? { url: httpUrl, token: httpToken } : {}),
|
|
54
|
+
};
|
|
55
|
+
const res = await apiFetch<{ results: Array<{ agent: string; status: string; message?: string }> }>('/api/mcp/install', {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: { 'Content-Type': 'application/json' },
|
|
58
|
+
body: JSON.stringify(payload),
|
|
59
|
+
});
|
|
60
|
+
const ok = res.results.filter(r => r.status === 'ok').length;
|
|
61
|
+
const fail = res.results.filter(r => r.status === 'error');
|
|
62
|
+
if (fail.length > 0) {
|
|
63
|
+
setMessage({ type: 'error', text: fail.map(f => `${f.agent}: ${f.message}`).join('; ') });
|
|
64
|
+
} else {
|
|
65
|
+
setMessage({ type: 'success', text: m?.installSuccess ? m.installSuccess(ok) : `${ok} agent(s) configured` });
|
|
66
|
+
}
|
|
67
|
+
setSelected(new Set());
|
|
68
|
+
onRefresh();
|
|
69
|
+
} catch {
|
|
70
|
+
setMessage({ type: 'error', text: m?.installFailed ?? 'Install failed' });
|
|
71
|
+
} finally {
|
|
72
|
+
setInstalling(false);
|
|
73
|
+
setTimeout(() => setMessage(null), 4000);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Show http fields if transport is 'http', or 'auto' with any http-preferred agent selected
|
|
78
|
+
const showHttpFields = transport === 'http' || (transport === 'auto' && [...selected].some(key => {
|
|
79
|
+
const agent = agents.find(a => a.key === key);
|
|
80
|
+
return agent?.preferredTransport === 'http';
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className="space-y-3 pt-2">
|
|
85
|
+
{/* Agent list */}
|
|
86
|
+
<div className="space-y-1">
|
|
87
|
+
{agents.map(agent => (
|
|
88
|
+
<div key={agent.key} className="flex items-center gap-3 py-1.5 text-sm">
|
|
89
|
+
<input
|
|
90
|
+
type="checkbox"
|
|
91
|
+
checked={selected.has(agent.key)}
|
|
92
|
+
onChange={() => toggle(agent.key)}
|
|
93
|
+
className="rounded border-border"
|
|
94
|
+
style={{ accentColor: 'var(--amber)' }}
|
|
95
|
+
/>
|
|
96
|
+
<span className="w-28 shrink-0 text-xs">{agent.name}</span>
|
|
97
|
+
<span className="text-2xs px-1.5 py-0.5 rounded font-mono"
|
|
98
|
+
style={{ background: 'rgba(100,100,120,0.08)' }}>
|
|
99
|
+
{getEffectiveTransport(agent)}
|
|
100
|
+
</span>
|
|
101
|
+
{agent.installed ? (
|
|
102
|
+
<>
|
|
103
|
+
<span className="text-2xs px-1.5 py-0.5 rounded bg-success/15 text-success font-mono">
|
|
104
|
+
{agent.transport}
|
|
105
|
+
</span>
|
|
106
|
+
<span className="text-2xs text-muted-foreground">{agent.scope}</span>
|
|
107
|
+
</>
|
|
108
|
+
) : (
|
|
109
|
+
<span className="text-2xs text-muted-foreground">
|
|
110
|
+
{agent.present ? (m?.detected ?? 'Detected') : (m?.notFound ?? 'Not found')}
|
|
111
|
+
</span>
|
|
112
|
+
)}
|
|
113
|
+
{/* Scope selector */}
|
|
114
|
+
{selected.has(agent.key) && agent.hasProjectScope && agent.hasGlobalScope && (
|
|
115
|
+
<select
|
|
116
|
+
value={scopes[agent.key] || 'project'}
|
|
117
|
+
onChange={e => setScopes({ ...scopes, [agent.key]: e.target.value as 'project' | 'global' })}
|
|
118
|
+
className="ml-auto text-2xs px-1.5 py-0.5 rounded border border-border bg-background text-foreground"
|
|
119
|
+
>
|
|
120
|
+
<option value="project">{m?.project ?? 'Project'}</option>
|
|
121
|
+
<option value="global">{m?.global ?? 'Global'}</option>
|
|
122
|
+
</select>
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
))}
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
{/* Select detected / Clear buttons */}
|
|
129
|
+
<div className="flex gap-2 text-xs pt-1">
|
|
130
|
+
<button type="button"
|
|
131
|
+
onClick={() => setSelected(new Set(
|
|
132
|
+
agents.filter(a => !a.installed && a.present).map(a => a.key)
|
|
133
|
+
))}
|
|
134
|
+
className="px-2.5 py-1 rounded-md border transition-colors hover:bg-muted/50"
|
|
135
|
+
style={{ borderColor: 'var(--amber)', color: 'var(--amber)' }}>
|
|
136
|
+
{m?.selectDetected ?? 'Select Detected'}
|
|
137
|
+
</button>
|
|
138
|
+
<button type="button"
|
|
139
|
+
onClick={() => setSelected(new Set())}
|
|
140
|
+
className="px-2.5 py-1 rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground">
|
|
141
|
+
{m?.clearSelection ?? 'Clear'}
|
|
142
|
+
</button>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
{/* Transport selector */}
|
|
146
|
+
<div className="flex items-center gap-4 text-xs pt-1">
|
|
147
|
+
<label className="flex items-center gap-1.5 cursor-pointer">
|
|
148
|
+
<input
|
|
149
|
+
type="radio"
|
|
150
|
+
name="transport"
|
|
151
|
+
checked={transport === 'auto'}
|
|
152
|
+
onChange={() => setTransport('auto')}
|
|
153
|
+
style={{ accentColor: 'var(--amber)' }}
|
|
154
|
+
/>
|
|
155
|
+
{m?.transportAuto ?? 'auto (recommended)'}
|
|
156
|
+
</label>
|
|
157
|
+
<label className="flex items-center gap-1.5 cursor-pointer">
|
|
158
|
+
<input
|
|
159
|
+
type="radio"
|
|
160
|
+
name="transport"
|
|
161
|
+
checked={transport === 'stdio'}
|
|
162
|
+
onChange={() => setTransport('stdio')}
|
|
163
|
+
style={{ accentColor: 'var(--amber)' }}
|
|
164
|
+
/>
|
|
165
|
+
{m?.transportStdio ?? 'stdio'}
|
|
166
|
+
</label>
|
|
167
|
+
<label className="flex items-center gap-1.5 cursor-pointer">
|
|
168
|
+
<input
|
|
169
|
+
type="radio"
|
|
170
|
+
name="transport"
|
|
171
|
+
checked={transport === 'http'}
|
|
172
|
+
onChange={() => setTransport('http')}
|
|
173
|
+
style={{ accentColor: 'var(--amber)' }}
|
|
174
|
+
/>
|
|
175
|
+
{m?.transportHttp ?? 'http'}
|
|
176
|
+
</label>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
{/* HTTP settings */}
|
|
180
|
+
{showHttpFields && (
|
|
181
|
+
<div className="space-y-2 pl-5 text-xs">
|
|
182
|
+
<div className="space-y-1">
|
|
183
|
+
<label className="text-muted-foreground">{m?.httpUrl ?? 'MCP URL'}</label>
|
|
184
|
+
<input
|
|
185
|
+
type="text"
|
|
186
|
+
value={httpUrl}
|
|
187
|
+
onChange={e => setHttpUrl(e.target.value)}
|
|
188
|
+
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"
|
|
189
|
+
/>
|
|
190
|
+
</div>
|
|
191
|
+
<div className="space-y-1">
|
|
192
|
+
<label className="text-muted-foreground">{m?.httpToken ?? 'Auth Token'}</label>
|
|
193
|
+
<input
|
|
194
|
+
type="password"
|
|
195
|
+
value={httpToken}
|
|
196
|
+
onChange={e => setHttpToken(e.target.value)}
|
|
197
|
+
placeholder="Bearer token"
|
|
198
|
+
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"
|
|
199
|
+
/>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
203
|
+
|
|
204
|
+
{/* Install button */}
|
|
205
|
+
<button
|
|
206
|
+
onClick={handleInstall}
|
|
207
|
+
disabled={selected.size === 0 || installing}
|
|
208
|
+
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"
|
|
209
|
+
style={{ background: 'var(--amber)', color: 'var(--amber-foreground)' }}
|
|
210
|
+
>
|
|
211
|
+
{installing && <Loader2 size={12} className="animate-spin" />}
|
|
212
|
+
{installing ? (m?.installing ?? 'Installing...') : (m?.installSelected ?? 'Install Selected')}
|
|
213
|
+
</button>
|
|
214
|
+
|
|
215
|
+
{/* Message */}
|
|
216
|
+
{message && (
|
|
217
|
+
<div className="flex items-center gap-1.5 text-xs" role="status">
|
|
218
|
+
{message.type === 'success' ? (
|
|
219
|
+
<><CheckCircle2 size={12} className="text-success" /><span className="text-success">{message.text}</span></>
|
|
220
|
+
) : (
|
|
221
|
+
<><AlertCircle size={12} className="text-destructive" /><span className="text-destructive">{message.text}</span></>
|
|
222
|
+
)}
|
|
223
|
+
</div>
|
|
224
|
+
)}
|
|
225
|
+
</div>
|
|
226
|
+
);
|
|
227
|
+
}
|