@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.
Files changed (34) hide show
  1. package/app/app/api/update/route.ts +41 -0
  2. package/app/app/explore/page.tsx +12 -0
  3. package/app/components/ActivityBar.tsx +14 -7
  4. package/app/components/GuideCard.tsx +21 -7
  5. package/app/components/HomeContent.tsx +31 -97
  6. package/app/components/KeyboardShortcuts.tsx +102 -0
  7. package/app/components/Panel.tsx +12 -7
  8. package/app/components/SidebarLayout.tsx +21 -1
  9. package/app/components/UpdateBanner.tsx +19 -21
  10. package/app/components/explore/ExploreContent.tsx +100 -0
  11. package/app/components/explore/UseCaseCard.tsx +50 -0
  12. package/app/components/explore/use-cases.ts +30 -0
  13. package/app/components/panels/AgentsPanel.tsx +268 -131
  14. package/app/components/panels/PluginsPanel.tsx +87 -27
  15. package/app/components/settings/AiTab.tsx +5 -3
  16. package/app/components/settings/McpSkillsSection.tsx +12 -0
  17. package/app/components/settings/McpTab.tsx +28 -30
  18. package/app/components/settings/SettingsContent.tsx +5 -2
  19. package/app/components/settings/UpdateTab.tsx +195 -0
  20. package/app/components/settings/types.ts +1 -1
  21. package/app/components/walkthrough/WalkthroughOverlay.tsx +224 -0
  22. package/app/components/walkthrough/WalkthroughProvider.tsx +133 -0
  23. package/app/components/walkthrough/WalkthroughTooltip.tsx +129 -0
  24. package/app/components/walkthrough/index.ts +3 -0
  25. package/app/components/walkthrough/steps.ts +21 -0
  26. package/app/hooks/useMcpData.tsx +166 -0
  27. package/app/lib/i18n-en.ts +182 -5
  28. package/app/lib/i18n-zh.ts +181 -4
  29. package/app/lib/mcp-snippets.ts +103 -0
  30. package/app/lib/settings.ts +4 -0
  31. package/app/next-env.d.ts +1 -1
  32. package/app/package.json +1 -0
  33. package/package.json +1 -1
  34. 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="Plugins" maximized={maximized} onMaximize={onMaximize}>
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">No plugins registered</p>
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="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}
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
- <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>
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">Core</span>
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
- <Toggle
64
- checked={enabled}
65
- onChange={(v) => handleToggle(r.id, v)}
66
- size="sm"
67
- disabled={r.core}
68
- title={r.core ? 'Core plugin — cannot be disabled' : undefined}
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-[30px]">
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 + entry path */}
78
- <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">
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
- <button
86
- onClick={() => router.push(`/view/${r.entryPath!.split('/').map(encodeURIComponent).join('/')}`)}
87
- 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"
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
- </button>
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
- Plugins customize how files render. Core plugins cannot be disabled.
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="Display Mode" hint="Side panel stays docked on the right. Popup opens a floating dialog.">
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 { 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
  );
@@ -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)' },