@geminilight/mindos 0.5.28 → 0.5.30
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/update/route.ts +41 -0
- package/app/app/explore/page.tsx +12 -0
- package/app/components/ActivityBar.tsx +14 -7
- package/app/components/GuideCard.tsx +21 -7
- package/app/components/HomeContent.tsx +31 -97
- package/app/components/KeyboardShortcuts.tsx +102 -0
- package/app/components/Panel.tsx +12 -7
- package/app/components/SidebarLayout.tsx +21 -1
- package/app/components/UpdateBanner.tsx +19 -21
- package/app/components/explore/ExploreContent.tsx +100 -0
- package/app/components/explore/UseCaseCard.tsx +50 -0
- package/app/components/explore/use-cases.ts +30 -0
- package/app/components/panels/AgentsPanel.tsx +268 -131
- package/app/components/panels/PluginsPanel.tsx +87 -27
- package/app/components/settings/AiTab.tsx +5 -3
- package/app/components/settings/McpSkillsSection.tsx +12 -0
- package/app/components/settings/McpTab.tsx +28 -30
- package/app/components/settings/SettingsContent.tsx +5 -2
- package/app/components/settings/UpdateTab.tsx +195 -0
- package/app/components/settings/types.ts +1 -1
- package/app/components/walkthrough/WalkthroughOverlay.tsx +224 -0
- package/app/components/walkthrough/WalkthroughProvider.tsx +133 -0
- package/app/components/walkthrough/WalkthroughTooltip.tsx +129 -0
- package/app/components/walkthrough/index.ts +3 -0
- package/app/components/walkthrough/steps.ts +21 -0
- package/app/hooks/useMcpData.tsx +166 -0
- package/app/lib/i18n-en.ts +182 -5
- package/app/lib/i18n-zh.ts +181 -4
- package/app/lib/mcp-snippets.ts +103 -0
- package/app/lib/settings.ts +4 -0
- package/app/next-env.d.ts +1 -1
- package/app/package.json +1 -0
- package/package.json +1 -1
- package/app/components/settings/McpServerStatus.tsx +0 -274
|
@@ -1,10 +1,11 @@
|
|
|
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';
|
|
7
7
|
import PanelHeader from './PanelHeader';
|
|
8
|
+
import { useLocale } from '@/lib/LocaleContext';
|
|
8
9
|
|
|
9
10
|
interface PluginsPanelProps {
|
|
10
11
|
active: boolean;
|
|
@@ -15,7 +16,10 @@ interface PluginsPanelProps {
|
|
|
15
16
|
export default function PluginsPanel({ active, maximized, onMaximize }: PluginsPanelProps) {
|
|
16
17
|
const [mounted, setMounted] = useState(false);
|
|
17
18
|
const [, forceUpdate] = useState(0);
|
|
19
|
+
const [existingFiles, setExistingFiles] = useState<Set<string>>(new Set());
|
|
18
20
|
const router = useRouter();
|
|
21
|
+
const { t } = useLocale();
|
|
22
|
+
const p = t.panels.plugins;
|
|
19
23
|
|
|
20
24
|
// Defer renderer reads to client only — avoids hydration mismatch
|
|
21
25
|
useEffect(() => {
|
|
@@ -23,71 +27,127 @@ export default function PluginsPanel({ active, maximized, onMaximize }: PluginsP
|
|
|
23
27
|
setMounted(true);
|
|
24
28
|
}, []);
|
|
25
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
|
+
|
|
26
50
|
const renderers = mounted ? getAllRenderers() : [];
|
|
27
51
|
const enabledCount = mounted ? renderers.filter(r => isRendererEnabled(r.id)).length : 0;
|
|
28
52
|
|
|
29
|
-
const handleToggle = (id: string, enabled: boolean) => {
|
|
53
|
+
const handleToggle = useCallback((id: string, enabled: boolean) => {
|
|
30
54
|
setRendererEnabled(id, enabled);
|
|
31
55
|
forceUpdate(n => n + 1);
|
|
32
56
|
window.dispatchEvent(new Event('renderer-state-changed'));
|
|
33
|
-
};
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
const handleOpen = useCallback((entryPath: string) => {
|
|
60
|
+
router.push(`/view/${entryPath.split('/').map(encodeURIComponent).join('/')}`);
|
|
61
|
+
}, [router]);
|
|
34
62
|
|
|
35
63
|
return (
|
|
36
64
|
<div className={`flex flex-col h-full ${active ? '' : 'hidden'}`}>
|
|
37
65
|
{/* Header */}
|
|
38
|
-
<PanelHeader title=
|
|
39
|
-
<span className="text-2xs text-muted-foreground">{enabledCount}/{renderers.length} active</span>
|
|
66
|
+
<PanelHeader title={p.title} maximized={maximized} onMaximize={onMaximize}>
|
|
67
|
+
<span className="text-2xs text-muted-foreground">{enabledCount}/{renderers.length} {p.active}</span>
|
|
40
68
|
</PanelHeader>
|
|
41
69
|
|
|
42
70
|
{/* Plugin list */}
|
|
43
71
|
<div className="flex-1 overflow-y-auto min-h-0">
|
|
44
72
|
{mounted && renderers.length === 0 && (
|
|
45
|
-
<p className="px-4 py-8 text-sm text-muted-foreground text-center">
|
|
73
|
+
<p className="px-4 py-8 text-sm text-muted-foreground text-center">{p.noPlugins}</p>
|
|
46
74
|
)}
|
|
47
75
|
{renderers.map(r => {
|
|
48
76
|
const enabled = isRendererEnabled(r.id);
|
|
77
|
+
const fileExists = r.entryPath ? existingFiles.has(r.entryPath) : false;
|
|
78
|
+
const canOpen = enabled && r.entryPath && fileExists;
|
|
79
|
+
|
|
49
80
|
return (
|
|
50
81
|
<div
|
|
51
82
|
key={r.id}
|
|
52
|
-
className=
|
|
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}
|
|
53
90
|
>
|
|
54
|
-
{/* Top row: icon + name + toggle */}
|
|
91
|
+
{/* Top row: status dot + icon + name + toggle */}
|
|
55
92
|
<div className="flex items-center justify-between gap-2">
|
|
56
93
|
<div className="flex items-center gap-2.5 min-w-0">
|
|
57
|
-
|
|
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>
|
|
58
113
|
<span className="text-sm font-medium text-foreground truncate">{r.name}</span>
|
|
59
114
|
{r.core && (
|
|
60
|
-
<span className="text-2xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground shrink-0">
|
|
115
|
+
<span className="text-2xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground shrink-0">{p.core}</span>
|
|
61
116
|
)}
|
|
62
117
|
</div>
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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>
|
|
70
128
|
</div>
|
|
71
129
|
|
|
72
130
|
{/* Description */}
|
|
73
|
-
<p className="mt-1 text-xs text-muted-foreground leading-relaxed pl-[
|
|
131
|
+
<p className="mt-1 text-xs text-muted-foreground leading-relaxed pl-[34px]">
|
|
74
132
|
{r.description}
|
|
75
133
|
</p>
|
|
76
134
|
|
|
77
|
-
{/* Tags +
|
|
78
|
-
<div className="mt-1.5 flex items-center gap-1.5 pl-[
|
|
135
|
+
{/* Tags + status hint */}
|
|
136
|
+
<div className="mt-1.5 flex items-center gap-1.5 pl-[34px] flex-wrap">
|
|
79
137
|
{r.tags.slice(0, 3).map(tag => (
|
|
80
138
|
<span key={tag} className="text-2xs px-1.5 py-0.5 rounded-full bg-muted/60 text-muted-foreground">
|
|
81
139
|
{tag}
|
|
82
140
|
</span>
|
|
83
141
|
))}
|
|
84
|
-
{r.entryPath && enabled && (
|
|
85
|
-
<
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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)' }}>
|
|
89
149
|
→ {r.entryPath}
|
|
90
|
-
</
|
|
150
|
+
</span>
|
|
91
151
|
)}
|
|
92
152
|
</div>
|
|
93
153
|
</div>
|
|
@@ -98,7 +158,7 @@ export default function PluginsPanel({ active, maximized, onMaximize }: PluginsP
|
|
|
98
158
|
{/* Footer info */}
|
|
99
159
|
<div className="px-4 py-2 border-t border-border shrink-0">
|
|
100
160
|
<p className="text-2xs text-muted-foreground">
|
|
101
|
-
|
|
161
|
+
{p.footer}
|
|
102
162
|
</p>
|
|
103
163
|
</div>
|
|
104
164
|
</div>
|
|
@@ -4,6 +4,7 @@ import { useState, useRef, useCallback, useEffect } from 'react';
|
|
|
4
4
|
import { AlertCircle, Loader2 } from 'lucide-react';
|
|
5
5
|
import type { AiSettings, AgentSettings, ProviderConfig, SettingsData, AiTabProps } from './types';
|
|
6
6
|
import { Field, Select, Input, EnvBadge, ApiKeyInput, Toggle, SectionLabel } from './Primitives';
|
|
7
|
+
import { useLocale } from '@/lib/LocaleContext';
|
|
7
8
|
|
|
8
9
|
type TestState = 'idle' | 'testing' | 'ok' | 'error';
|
|
9
10
|
type ErrorCode = 'auth_error' | 'model_not_found' | 'rate_limited' | 'network_error' | 'unknown';
|
|
@@ -287,6 +288,7 @@ export function AiTab({ data, updateAi, updateAgent, t }: AiTabProps) {
|
|
|
287
288
|
/* ── Ask AI Display Mode (localStorage-based, no server roundtrip) ── */
|
|
288
289
|
|
|
289
290
|
function AskDisplayMode() {
|
|
291
|
+
const { t } = useLocale();
|
|
290
292
|
const [mode, setMode] = useState<'panel' | 'popup'>('panel');
|
|
291
293
|
|
|
292
294
|
useEffect(() => {
|
|
@@ -308,10 +310,10 @@ function AskDisplayMode() {
|
|
|
308
310
|
<div className="pt-3 border-t border-border">
|
|
309
311
|
<SectionLabel>MindOS Agent</SectionLabel>
|
|
310
312
|
<div className="space-y-4">
|
|
311
|
-
<Field label=
|
|
313
|
+
<Field label={t.settings.askDisplayMode?.label ?? 'Display Mode'} hint={t.settings.askDisplayMode?.hint ?? 'Side panel stays docked on the right. Popup opens a floating dialog.'}>
|
|
312
314
|
<Select value={mode} onChange={e => handleChange(e.target.value)}>
|
|
313
|
-
<option value="panel">Side Panel</option>
|
|
314
|
-
<option value="popup">Popup</option>
|
|
315
|
+
<option value="panel">{t.settings.askDisplayMode?.panel ?? 'Side Panel'}</option>
|
|
316
|
+
<option value="popup">{t.settings.askDisplayMode?.popup ?? 'Popup'}</option>
|
|
315
317
|
</Select>
|
|
316
318
|
</Field>
|
|
317
319
|
</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 {
|
|
4
|
-
import type {
|
|
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
|
|
16
|
-
const
|
|
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
|
-
{/*
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
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={
|
|
55
|
+
<AgentInstall agents={mcp.agents} t={t} onRefresh={mcp.refresh} />
|
|
58
56
|
</div>
|
|
59
57
|
</div>
|
|
60
58
|
);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
|
4
|
-
import { Settings, Loader2, AlertCircle, CheckCircle2, RotateCcw, Sparkles, Palette, Database, RefreshCw, Plug, X } from 'lucide-react';
|
|
4
|
+
import { Settings, Loader2, AlertCircle, CheckCircle2, RotateCcw, Sparkles, Palette, Database, RefreshCw, Plug, Download, X } from 'lucide-react';
|
|
5
5
|
import { useLocale } from '@/lib/LocaleContext';
|
|
6
6
|
import { apiFetch } from '@/lib/api';
|
|
7
7
|
import type { AiSettings, AgentSettings, SettingsData, Tab } from './types';
|
|
@@ -10,6 +10,7 @@ import { AppearanceTab } from './AppearanceTab';
|
|
|
10
10
|
import { KnowledgeTab } from './KnowledgeTab';
|
|
11
11
|
import { SyncTab } from './SyncTab';
|
|
12
12
|
import { McpTab } from './McpTab';
|
|
13
|
+
import { UpdateTab } from './UpdateTab';
|
|
13
14
|
|
|
14
15
|
interface SettingsContentProps {
|
|
15
16
|
visible: boolean;
|
|
@@ -136,6 +137,7 @@ export default function SettingsContent({ visible, initialTab, variant, onClose
|
|
|
136
137
|
{ id: 'knowledge', label: t.settings.tabs.knowledge, icon: <Settings size={iconSize} /> },
|
|
137
138
|
{ id: 'sync', label: t.settings.tabs.sync ?? 'Sync', icon: <RefreshCw size={iconSize} /> },
|
|
138
139
|
{ id: 'appearance', label: t.settings.tabs.appearance, icon: <Palette size={iconSize} /> },
|
|
140
|
+
{ id: 'update', label: t.settings.tabs.update ?? 'Update', icon: <Download size={iconSize} /> },
|
|
139
141
|
];
|
|
140
142
|
|
|
141
143
|
const activeTabLabel = TABS.find(t2 => t2.id === tab)?.label ?? '';
|
|
@@ -149,7 +151,7 @@ export default function SettingsContent({ visible, initialTab, variant, onClose
|
|
|
149
151
|
<p className={`${isPanel ? 'text-xs' : 'text-sm'} text-destructive font-medium`}>Failed to load settings</p>
|
|
150
152
|
{!isPanel && <p className="text-xs text-muted-foreground">Check that the server is running and AUTH_TOKEN is configured correctly.</p>}
|
|
151
153
|
</div>
|
|
152
|
-
) : !data && tab !== 'appearance' && tab !== 'mcp' && tab !== 'sync' ? (
|
|
154
|
+
) : !data && tab !== 'appearance' && tab !== 'mcp' && tab !== 'sync' && tab !== 'update' ? (
|
|
153
155
|
<div className="flex justify-center py-8">
|
|
154
156
|
<Loader2 size={isPanel ? 16 : 18} className="animate-spin text-muted-foreground" />
|
|
155
157
|
</div>
|
|
@@ -160,6 +162,7 @@ export default function SettingsContent({ visible, initialTab, variant, onClose
|
|
|
160
162
|
{tab === 'knowledge' && data && <KnowledgeTab data={data} setData={setData} t={t} />}
|
|
161
163
|
{tab === 'sync' && <SyncTab t={t} />}
|
|
162
164
|
{tab === 'mcp' && <McpTab t={t} />}
|
|
165
|
+
{tab === 'update' && <UpdateTab />}
|
|
163
166
|
</>
|
|
164
167
|
)}
|
|
165
168
|
</div>
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
4
|
+
import { Download, RefreshCw, CheckCircle2, AlertCircle, Loader2, ExternalLink } from 'lucide-react';
|
|
5
|
+
import { apiFetch } from '@/lib/api';
|
|
6
|
+
import { useLocale } from '@/lib/LocaleContext';
|
|
7
|
+
|
|
8
|
+
interface UpdateInfo {
|
|
9
|
+
current: string;
|
|
10
|
+
latest: string;
|
|
11
|
+
hasUpdate: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type UpdateState = 'idle' | 'checking' | 'updating' | 'updated' | 'error' | 'timeout';
|
|
15
|
+
|
|
16
|
+
const CHANGELOG_URL = 'https://github.com/GeminiLight/MindOS/releases';
|
|
17
|
+
const POLL_INTERVAL = 5_000;
|
|
18
|
+
const POLL_TIMEOUT = 4 * 60 * 1000; // 4 minutes
|
|
19
|
+
|
|
20
|
+
export function UpdateTab() {
|
|
21
|
+
const { t } = useLocale();
|
|
22
|
+
const u = t.settings.update;
|
|
23
|
+
const [info, setInfo] = useState<UpdateInfo | null>(null);
|
|
24
|
+
const [state, setState] = useState<UpdateState>('idle');
|
|
25
|
+
const [errorMsg, setErrorMsg] = useState('');
|
|
26
|
+
const pollRef = useRef<ReturnType<typeof setInterval>>(undefined);
|
|
27
|
+
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
28
|
+
const originalVersion = useRef<string>('');
|
|
29
|
+
|
|
30
|
+
const checkUpdate = useCallback(async () => {
|
|
31
|
+
setState('checking');
|
|
32
|
+
setErrorMsg('');
|
|
33
|
+
try {
|
|
34
|
+
const data = await apiFetch<UpdateInfo>('/api/update-check');
|
|
35
|
+
setInfo(data);
|
|
36
|
+
if (!originalVersion.current) originalVersion.current = data.current;
|
|
37
|
+
setState('idle');
|
|
38
|
+
} catch {
|
|
39
|
+
setState('error');
|
|
40
|
+
setErrorMsg(u?.error ?? 'Failed to check for updates.');
|
|
41
|
+
}
|
|
42
|
+
}, [u]);
|
|
43
|
+
|
|
44
|
+
useEffect(() => { checkUpdate(); }, [checkUpdate]);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
return () => {
|
|
48
|
+
clearInterval(pollRef.current);
|
|
49
|
+
clearTimeout(timeoutRef.current);
|
|
50
|
+
};
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
const handleUpdate = useCallback(async () => {
|
|
54
|
+
setState('updating');
|
|
55
|
+
setErrorMsg('');
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
await apiFetch('/api/update', { method: 'POST' });
|
|
59
|
+
} catch {
|
|
60
|
+
// Expected — server may die during update
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
pollRef.current = setInterval(async () => {
|
|
64
|
+
try {
|
|
65
|
+
const data = await apiFetch<UpdateInfo>('/api/update-check');
|
|
66
|
+
if (data.current !== originalVersion.current) {
|
|
67
|
+
clearInterval(pollRef.current);
|
|
68
|
+
clearTimeout(timeoutRef.current);
|
|
69
|
+
setInfo(data);
|
|
70
|
+
setState('updated');
|
|
71
|
+
setTimeout(() => window.location.reload(), 2000);
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
// Server still restarting
|
|
75
|
+
}
|
|
76
|
+
}, POLL_INTERVAL);
|
|
77
|
+
|
|
78
|
+
timeoutRef.current = setTimeout(() => {
|
|
79
|
+
clearInterval(pollRef.current);
|
|
80
|
+
setState('timeout');
|
|
81
|
+
}, POLL_TIMEOUT);
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className="space-y-5">
|
|
86
|
+
{/* Version Card */}
|
|
87
|
+
<div className="rounded-xl border border-border bg-card p-4 space-y-3">
|
|
88
|
+
<div className="flex items-center justify-between">
|
|
89
|
+
<span className="text-sm font-medium text-foreground">MindOS</span>
|
|
90
|
+
{info && (
|
|
91
|
+
<span className="text-xs font-mono text-muted-foreground">v{info.current}</span>
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
{state === 'checking' && (
|
|
96
|
+
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
97
|
+
<Loader2 size={13} className="animate-spin" />
|
|
98
|
+
{u?.checking ?? 'Checking for updates...'}
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
101
|
+
|
|
102
|
+
{state === 'idle' && info && !info.hasUpdate && (
|
|
103
|
+
<div className="flex items-center gap-2 text-xs text-emerald-600 dark:text-emerald-400">
|
|
104
|
+
<CheckCircle2 size={13} />
|
|
105
|
+
{u?.upToDate ?? "You're up to date"}
|
|
106
|
+
</div>
|
|
107
|
+
)}
|
|
108
|
+
|
|
109
|
+
{state === 'idle' && info?.hasUpdate && (
|
|
110
|
+
<div className="flex items-center gap-2 text-xs" style={{ color: 'var(--amber)' }}>
|
|
111
|
+
<Download size={13} />
|
|
112
|
+
{u?.available ? u.available(info.current, info.latest) : `Update available: v${info.current} → v${info.latest}`}
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
{state === 'updating' && (
|
|
117
|
+
<div className="space-y-2">
|
|
118
|
+
<div className="flex items-center gap-2 text-xs" style={{ color: 'var(--amber)' }}>
|
|
119
|
+
<Loader2 size={13} className="animate-spin" />
|
|
120
|
+
{u?.updating ?? 'Updating MindOS... The server will restart shortly.'}
|
|
121
|
+
</div>
|
|
122
|
+
<p className="text-2xs text-muted-foreground">
|
|
123
|
+
{u?.updatingHint ?? 'This may take 1–3 minutes. Do not close this page.'}
|
|
124
|
+
</p>
|
|
125
|
+
</div>
|
|
126
|
+
)}
|
|
127
|
+
|
|
128
|
+
{state === 'updated' && (
|
|
129
|
+
<div className="flex items-center gap-2 text-xs text-emerald-600 dark:text-emerald-400">
|
|
130
|
+
<CheckCircle2 size={13} />
|
|
131
|
+
{u?.updated ?? 'Updated successfully! Reloading...'}
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
|
|
135
|
+
{state === 'timeout' && (
|
|
136
|
+
<div className="space-y-1">
|
|
137
|
+
<div className="flex items-center gap-2 text-xs text-amber-600 dark:text-amber-400">
|
|
138
|
+
<AlertCircle size={13} />
|
|
139
|
+
{u?.timeout ?? 'Update may still be in progress.'}
|
|
140
|
+
</div>
|
|
141
|
+
<p className="text-2xs text-muted-foreground">
|
|
142
|
+
{u?.timeoutHint ?? 'Check your terminal:'} <code className="font-mono bg-muted px-1 py-0.5 rounded">mindos logs</code>
|
|
143
|
+
</p>
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
{state === 'error' && (
|
|
148
|
+
<div className="flex items-center gap-2 text-xs text-destructive">
|
|
149
|
+
<AlertCircle size={13} />
|
|
150
|
+
{errorMsg}
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
{/* Actions */}
|
|
156
|
+
<div className="flex items-center gap-2">
|
|
157
|
+
<button
|
|
158
|
+
onClick={checkUpdate}
|
|
159
|
+
disabled={state === 'checking' || state === 'updating'}
|
|
160
|
+
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
161
|
+
>
|
|
162
|
+
<RefreshCw size={12} className={state === 'checking' ? 'animate-spin' : ''} />
|
|
163
|
+
{u?.checkButton ?? 'Check for Updates'}
|
|
164
|
+
</button>
|
|
165
|
+
|
|
166
|
+
{info?.hasUpdate && state !== 'updating' && state !== 'updated' && (
|
|
167
|
+
<button
|
|
168
|
+
onClick={handleUpdate}
|
|
169
|
+
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg font-medium text-white transition-colors"
|
|
170
|
+
style={{ background: 'var(--amber)' }}
|
|
171
|
+
>
|
|
172
|
+
<Download size={12} />
|
|
173
|
+
{u?.updateButton ? u.updateButton(info.latest) : `Update to v${info.latest}`}
|
|
174
|
+
</button>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
{/* Info */}
|
|
179
|
+
<div className="border-t border-border pt-4 space-y-2">
|
|
180
|
+
<a
|
|
181
|
+
href={CHANGELOG_URL}
|
|
182
|
+
target="_blank"
|
|
183
|
+
rel="noopener noreferrer"
|
|
184
|
+
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
185
|
+
>
|
|
186
|
+
<ExternalLink size={12} />
|
|
187
|
+
{u?.releaseNotes ?? 'View release notes'}
|
|
188
|
+
</a>
|
|
189
|
+
<p className="text-2xs text-muted-foreground/60">
|
|
190
|
+
{u?.hint ?? 'Updates are installed via npm. Equivalent to running'} <code className="font-mono bg-muted px-1 py-0.5 rounded">mindos update</code> {u?.inTerminal ?? 'in your terminal.'}
|
|
191
|
+
</p>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
@@ -33,7 +33,7 @@ export interface SettingsData {
|
|
|
33
33
|
envValues?: Record<string, string>;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
export type Tab = 'ai' | 'appearance' | 'knowledge' | 'mcp' | 'sync';
|
|
36
|
+
export type Tab = 'ai' | 'appearance' | 'knowledge' | 'mcp' | 'sync' | 'update';
|
|
37
37
|
|
|
38
38
|
export const CONTENT_WIDTHS = [
|
|
39
39
|
{ value: '680px', label: 'Narrow (680px)' },
|