@geminilight/mindos 0.5.22 → 0.5.24

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 (45) hide show
  1. package/app/app/api/ask/route.ts +7 -14
  2. package/app/app/api/bootstrap/route.ts +1 -0
  3. package/app/app/globals.css +14 -0
  4. package/app/app/setup/page.tsx +3 -2
  5. package/app/components/ActivityBar.tsx +183 -0
  6. package/app/components/AskFab.tsx +39 -97
  7. package/app/components/AskModal.tsx +13 -371
  8. package/app/components/Breadcrumb.tsx +4 -4
  9. package/app/components/FileTree.tsx +21 -4
  10. package/app/components/Logo.tsx +39 -0
  11. package/app/components/Panel.tsx +152 -0
  12. package/app/components/RightAskPanel.tsx +72 -0
  13. package/app/components/SettingsModal.tsx +9 -241
  14. package/app/components/SidebarLayout.tsx +426 -12
  15. package/app/components/SyncStatusBar.tsx +74 -53
  16. package/app/components/TableOfContents.tsx +4 -2
  17. package/app/components/ask/AskContent.tsx +418 -0
  18. package/app/components/ask/MessageList.tsx +2 -2
  19. package/app/components/panels/AgentsPanel.tsx +231 -0
  20. package/app/components/panels/PanelHeader.tsx +35 -0
  21. package/app/components/panels/PluginsPanel.tsx +106 -0
  22. package/app/components/panels/SearchPanel.tsx +178 -0
  23. package/app/components/panels/SyncPopover.tsx +105 -0
  24. package/app/components/renderers/csv/TableView.tsx +4 -4
  25. package/app/components/settings/AiTab.tsx +39 -1
  26. package/app/components/settings/KnowledgeTab.tsx +116 -2
  27. package/app/components/settings/McpTab.tsx +6 -6
  28. package/app/components/settings/SettingsContent.tsx +343 -0
  29. package/app/components/settings/types.ts +1 -1
  30. package/app/components/setup/index.tsx +2 -23
  31. package/app/hooks/useResizeDrag.ts +78 -0
  32. package/app/lib/agent/index.ts +0 -1
  33. package/app/lib/agent/model.ts +33 -10
  34. package/app/lib/format.ts +19 -0
  35. package/app/lib/i18n-en.ts +6 -6
  36. package/app/lib/i18n-zh.ts +5 -5
  37. package/app/next-env.d.ts +1 -1
  38. package/app/next.config.ts +1 -1
  39. package/bin/cli.js +27 -97
  40. package/package.json +4 -2
  41. package/scripts/setup.js +2 -12
  42. package/skills/mindos/SKILL.md +226 -8
  43. package/skills/mindos-zh/SKILL.md +226 -8
  44. package/app/lib/agent/skill-rules.ts +0 -70
  45. package/app/package-lock.json +0 -15736
@@ -0,0 +1,35 @@
1
+ import { Maximize2, Minimize2 } from 'lucide-react';
2
+
3
+ /**
4
+ * Shared header bar for side panels (Files, Search, Plugins, etc.)
5
+ * Keeps the uppercase label + optional right content pattern consistent.
6
+ */
7
+ export default function PanelHeader({
8
+ title,
9
+ children,
10
+ maximized,
11
+ onMaximize,
12
+ }: {
13
+ title: string;
14
+ children?: React.ReactNode;
15
+ maximized?: boolean;
16
+ onMaximize?: () => void;
17
+ }) {
18
+ return (
19
+ <div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
20
+ <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider font-display">{title}</span>
21
+ <div className="flex items-center gap-1">
22
+ {children}
23
+ {onMaximize && (
24
+ <button
25
+ onClick={onMaximize}
26
+ className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
27
+ aria-label={maximized ? 'Restore panel' : 'Maximize panel'}
28
+ >
29
+ {maximized ? <Minimize2 size={13} /> : <Maximize2 size={13} />}
30
+ </button>
31
+ )}
32
+ </div>
33
+ </div>
34
+ );
35
+ }
@@ -0,0 +1,106 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+ import { getAllRenderers, isRendererEnabled, setRendererEnabled, loadDisabledState } from '@/lib/renderers/registry';
6
+ import { Toggle } from '../settings/Primitives';
7
+ import PanelHeader from './PanelHeader';
8
+
9
+ interface PluginsPanelProps {
10
+ active: boolean;
11
+ maximized?: boolean;
12
+ onMaximize?: () => void;
13
+ }
14
+
15
+ export default function PluginsPanel({ active, maximized, onMaximize }: PluginsPanelProps) {
16
+ const [mounted, setMounted] = useState(false);
17
+ const [, forceUpdate] = useState(0);
18
+ const router = useRouter();
19
+
20
+ // Defer renderer reads to client only — avoids hydration mismatch
21
+ useEffect(() => {
22
+ loadDisabledState();
23
+ setMounted(true);
24
+ }, []);
25
+
26
+ const renderers = mounted ? getAllRenderers() : [];
27
+ const enabledCount = mounted ? renderers.filter(r => isRendererEnabled(r.id)).length : 0;
28
+
29
+ const handleToggle = (id: string, enabled: boolean) => {
30
+ setRendererEnabled(id, enabled);
31
+ forceUpdate(n => n + 1);
32
+ window.dispatchEvent(new Event('renderer-state-changed'));
33
+ };
34
+
35
+ return (
36
+ <div className={`flex flex-col h-full ${active ? '' : 'hidden'}`}>
37
+ {/* Header */}
38
+ <PanelHeader title="Plugins" maximized={maximized} onMaximize={onMaximize}>
39
+ <span className="text-2xs text-muted-foreground">{enabledCount}/{renderers.length} active</span>
40
+ </PanelHeader>
41
+
42
+ {/* Plugin list */}
43
+ <div className="flex-1 overflow-y-auto min-h-0">
44
+ {mounted && renderers.length === 0 && (
45
+ <p className="px-4 py-8 text-sm text-muted-foreground text-center">No plugins registered</p>
46
+ )}
47
+ {renderers.map(r => {
48
+ const enabled = isRendererEnabled(r.id);
49
+ return (
50
+ <div
51
+ key={r.id}
52
+ className="px-4 py-3 border-b border-border/50 hover:bg-muted/30 transition-colors"
53
+ >
54
+ {/* Top row: icon + name + toggle */}
55
+ <div className="flex items-center justify-between gap-2">
56
+ <div className="flex items-center gap-2.5 min-w-0">
57
+ <span className="text-base shrink-0">{r.icon}</span>
58
+ <span className="text-sm font-medium text-foreground truncate">{r.name}</span>
59
+ {r.core && (
60
+ <span className="text-2xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground shrink-0">Core</span>
61
+ )}
62
+ </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
+ />
70
+ </div>
71
+
72
+ {/* Description */}
73
+ <p className="mt-1 text-xs text-muted-foreground leading-relaxed pl-[30px]">
74
+ {r.description}
75
+ </p>
76
+
77
+ {/* Tags + entry path */}
78
+ <div className="mt-1.5 flex items-center gap-1.5 pl-[30px] flex-wrap">
79
+ {r.tags.slice(0, 3).map(tag => (
80
+ <span key={tag} className="text-2xs px-1.5 py-0.5 rounded-full bg-muted/60 text-muted-foreground">
81
+ {tag}
82
+ </span>
83
+ ))}
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
+ >
89
+ → {r.entryPath}
90
+ </button>
91
+ )}
92
+ </div>
93
+ </div>
94
+ );
95
+ })}
96
+ </div>
97
+
98
+ {/* Footer info */}
99
+ <div className="px-4 py-2 border-t border-border shrink-0">
100
+ <p className="text-2xs text-muted-foreground">
101
+ Plugins customize how files render. Core plugins cannot be disabled.
102
+ </p>
103
+ </div>
104
+ </div>
105
+ );
106
+ }
@@ -0,0 +1,178 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback, useRef } from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+ import { Search, X, FileText, Table } from 'lucide-react';
6
+ import { SearchResult } from '@/lib/types';
7
+ import { encodePath } from '@/lib/utils';
8
+ import { apiFetch } from '@/lib/api';
9
+ import { useLocale } from '@/lib/LocaleContext';
10
+ import PanelHeader from './PanelHeader';
11
+
12
+ /** Highlight matched text fragments in a snippet based on the query */
13
+ function highlightSnippet(snippet: string, query: string): React.ReactNode {
14
+ if (!query.trim()) return snippet;
15
+ const words = query.trim().split(/\s+/).filter(Boolean);
16
+ const escaped = words.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
17
+ const pattern = new RegExp(`(${escaped.join('|')})`, 'gi');
18
+ const parts = snippet.split(pattern);
19
+ return parts.map((part, i) =>
20
+ pattern.test(part) ? <mark key={i} className="bg-yellow-300/40 text-foreground rounded-sm px-0.5">{part}</mark> : part
21
+ );
22
+ }
23
+
24
+ interface SearchPanelProps {
25
+ /** When true the panel is visible — triggers focus & reset */
26
+ active: boolean;
27
+ /** Called when user navigates to a result (panel host may want to close) */
28
+ onNavigate?: () => void;
29
+ maximized?: boolean;
30
+ onMaximize?: () => void;
31
+ }
32
+
33
+ export default function SearchPanel({ active, onNavigate, maximized, onMaximize }: SearchPanelProps) {
34
+ const [query, setQuery] = useState('');
35
+ const [results, setResults] = useState<SearchResult[]>([]);
36
+ const [loading, setLoading] = useState(false);
37
+ const [selectedIndex, setSelectedIndex] = useState(0);
38
+ const inputRef = useRef<HTMLInputElement>(null);
39
+ const router = useRouter();
40
+ const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
41
+ const { t } = useLocale();
42
+
43
+ // Focus input when panel becomes active
44
+ useEffect(() => {
45
+ if (active) {
46
+ setTimeout(() => inputRef.current?.focus(), 50);
47
+ }
48
+ }, [active]);
49
+
50
+ // Debounced search
51
+ const doSearch = useCallback((q: string) => {
52
+ if (debounceTimer.current) clearTimeout(debounceTimer.current);
53
+ if (!q.trim()) {
54
+ setResults([]);
55
+ setLoading(false);
56
+ return;
57
+ }
58
+ setLoading(true);
59
+ debounceTimer.current = setTimeout(async () => {
60
+ try {
61
+ const data = await apiFetch<SearchResult[]>(`/api/search?q=${encodeURIComponent(q)}`);
62
+ setResults(Array.isArray(data) ? data : []);
63
+ setSelectedIndex(0);
64
+ } catch {
65
+ setResults([]);
66
+ } finally {
67
+ setLoading(false);
68
+ }
69
+ }, 300);
70
+ }, []);
71
+
72
+ const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
73
+ const val = e.target.value;
74
+ setQuery(val);
75
+ doSearch(val);
76
+ }, [doSearch]);
77
+
78
+ const navigate = useCallback((result: SearchResult) => {
79
+ router.push(`/view/${encodePath(result.path)}`);
80
+ onNavigate?.();
81
+ }, [router, onNavigate]);
82
+
83
+ // Keyboard navigation within the panel
84
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
85
+ if (e.key === 'ArrowDown') {
86
+ e.preventDefault();
87
+ setSelectedIndex(i => Math.min(i + 1, results.length - 1));
88
+ } else if (e.key === 'ArrowUp') {
89
+ e.preventDefault();
90
+ setSelectedIndex(i => Math.max(i - 1, 0));
91
+ } else if (e.key === 'Enter') {
92
+ if (results[selectedIndex]) navigate(results[selectedIndex]);
93
+ }
94
+ }, [results, selectedIndex, navigate]);
95
+
96
+ return (
97
+ <>
98
+ {/* Header */}
99
+ <PanelHeader title="Search" maximized={maximized} onMaximize={onMaximize} />
100
+
101
+ {/* Search input */}
102
+ <div className="flex items-center gap-3 px-4 py-2.5 border-b border-border shrink-0">
103
+ <Search size={14} className="text-muted-foreground shrink-0" />
104
+ <input
105
+ ref={inputRef}
106
+ type="text"
107
+ value={query}
108
+ onChange={handleChange}
109
+ onKeyDown={handleKeyDown}
110
+ placeholder={t.search.placeholder}
111
+ className="flex-1 bg-transparent text-foreground placeholder:text-muted-foreground text-sm outline-none"
112
+ />
113
+ {loading && (
114
+ <div className="w-3.5 h-3.5 border-2 border-muted-foreground/40 border-t-foreground rounded-full animate-spin shrink-0" />
115
+ )}
116
+ {!loading && query && (
117
+ <button onClick={() => { setQuery(''); setResults([]); inputRef.current?.focus(); }}>
118
+ <X size={13} className="text-muted-foreground hover:text-foreground" />
119
+ </button>
120
+ )}
121
+ </div>
122
+
123
+ {/* Results */}
124
+ <div className="flex-1 overflow-y-auto min-h-0">
125
+ {results.length === 0 && query && !loading && (
126
+ <div className="px-4 py-8 text-center text-sm text-muted-foreground">{t.search.noResults}</div>
127
+ )}
128
+ {results.length === 0 && !query && (
129
+ <div className="px-4 py-8 text-center text-sm text-muted-foreground/60">{t.search.prompt}</div>
130
+ )}
131
+ {results.map((result, i) => {
132
+ const ext = result.path.endsWith('.csv') ? '.csv' : '.md';
133
+ const parts = result.path.split('/');
134
+ const fileName = parts[parts.length - 1];
135
+ const dirPath = parts.slice(0, -1).join('/');
136
+ return (
137
+ <button
138
+ key={result.path}
139
+ onClick={() => navigate(result)}
140
+ onMouseEnter={() => setSelectedIndex(i)}
141
+ className={`
142
+ w-full px-4 py-2.5 flex items-start gap-3 text-left transition-colors duration-75
143
+ ${i === selectedIndex ? 'bg-muted' : 'hover:bg-muted/50'}
144
+ ${i < results.length - 1 ? 'border-b border-border' : ''}
145
+ `}
146
+ >
147
+ {ext === '.csv'
148
+ ? <Table size={13} className="text-success shrink-0 mt-0.5" />
149
+ : <FileText size={13} className="text-muted-foreground shrink-0 mt-0.5" />
150
+ }
151
+ <div className="min-w-0 flex-1">
152
+ <div className="flex items-baseline gap-2 flex-wrap">
153
+ <span className="text-sm text-foreground font-medium truncate">{fileName}</span>
154
+ {dirPath && (
155
+ <span className="text-xs text-muted-foreground truncate">{dirPath}</span>
156
+ )}
157
+ </div>
158
+ {result.snippet && (
159
+ <p className="text-xs text-muted-foreground mt-0.5 line-clamp-2 leading-relaxed">
160
+ {highlightSnippet(result.snippet, query)}
161
+ </p>
162
+ )}
163
+ </div>
164
+ </button>
165
+ );
166
+ })}
167
+ </div>
168
+
169
+ {/* Footer hints */}
170
+ {results.length > 0 && (
171
+ <div className="px-4 py-2 border-t border-border flex items-center gap-3 text-xs text-muted-foreground/50 shrink-0">
172
+ <span><kbd className="font-mono">↑↓</kbd> {t.search.navigate}</span>
173
+ <span><kbd className="font-mono">↵</kbd> {t.search.open}</span>
174
+ </div>
175
+ )}
176
+ </>
177
+ );
178
+ }
@@ -0,0 +1,105 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef } from 'react';
4
+ import { RefreshCw, CheckCircle2, XCircle, X } from 'lucide-react';
5
+ import { DOT_COLORS, getStatusLevel, getSyncLabel, useSyncAction } from '../SyncStatusBar';
6
+ import type { SyncStatus } from '../settings/SyncTab';
7
+ import { PrimaryButton } from '../settings/Primitives';
8
+
9
+ interface SyncPopoverProps {
10
+ open: boolean;
11
+ onClose: () => void;
12
+ anchorRect: DOMRect | null;
13
+ railWidth: number;
14
+ onOpenSyncSettings: () => void;
15
+ syncStatus: SyncStatus | null;
16
+ onSyncStatusRefresh: () => Promise<void>;
17
+ }
18
+
19
+ export default function SyncPopover({ open, onClose, anchorRect, railWidth, onOpenSyncSettings, syncStatus, onSyncStatusRefresh }: SyncPopoverProps) {
20
+ const ref = useRef<HTMLDivElement>(null);
21
+ const onCloseRef = useRef(onClose);
22
+ onCloseRef.current = onClose;
23
+ const { syncing, syncResult, syncNow } = useSyncAction(onSyncStatusRefresh);
24
+
25
+ // Close on ESC
26
+ useEffect(() => {
27
+ if (!open) return;
28
+ const handler = (e: KeyboardEvent) => {
29
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onCloseRef.current(); }
30
+ };
31
+ window.addEventListener('keydown', handler);
32
+ return () => window.removeEventListener('keydown', handler);
33
+ }, [open]);
34
+
35
+ // Close on click outside
36
+ useEffect(() => {
37
+ if (!open) return;
38
+ const handler = (e: MouseEvent) => {
39
+ if (ref.current && !ref.current.contains(e.target as Node)) onCloseRef.current();
40
+ };
41
+ const id = setTimeout(() => window.addEventListener('mousedown', handler), 0);
42
+ return () => { clearTimeout(id); window.removeEventListener('mousedown', handler); };
43
+ }, [open]);
44
+
45
+ if (!open || !anchorRect) return null;
46
+
47
+ const level = getStatusLevel(syncStatus, syncing);
48
+ const { label: statusText } = getSyncLabel(level, syncStatus);
49
+
50
+ // Position: anchor near the button, avoid going off-screen top
51
+ const popoverTop = Math.max(8, anchorRect.bottom - 180);
52
+
53
+ return (
54
+ <div
55
+ ref={ref}
56
+ className="fixed z-40 w-[240px] border rounded-lg bg-card shadow-lg border-border animate-in fade-in slide-in-from-left-2 duration-150"
57
+ style={{
58
+ top: popoverTop,
59
+ left: railWidth,
60
+ }}
61
+ >
62
+ <div className="flex items-center justify-between px-3 py-2 border-b border-border">
63
+ <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Sync</span>
64
+ <button
65
+ onClick={onClose}
66
+ className="p-0.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors focus-visible:ring-2 focus-visible:ring-ring"
67
+ aria-label="Close"
68
+ >
69
+ <X size={14} />
70
+ </button>
71
+ </div>
72
+ <div className="p-3 space-y-3">
73
+ {/* Status */}
74
+ <div className="flex items-center gap-2">
75
+ <span className={`w-2.5 h-2.5 rounded-full shrink-0 ${DOT_COLORS[level]} ${
76
+ level === 'syncing' || level === 'conflicts' || level === 'error' ? 'animate-pulse' : ''
77
+ }`} />
78
+ <span className="text-sm text-foreground">{statusText}</span>
79
+ {syncResult === 'success' && <CheckCircle2 size={14} className="text-success shrink-0" />}
80
+ {syncResult === 'error' && <XCircle size={14} className="text-error shrink-0" />}
81
+ </div>
82
+
83
+ {/* Actions */}
84
+ <div className="flex items-center gap-2">
85
+ {level !== 'off' && (
86
+ <PrimaryButton
87
+ onClick={syncNow}
88
+ disabled={syncing}
89
+ className="text-xs px-3 py-1.5 flex items-center gap-1.5"
90
+ >
91
+ <RefreshCw size={12} className={syncing ? 'animate-spin' : ''} />
92
+ Sync Now
93
+ </PrimaryButton>
94
+ )}
95
+ <button
96
+ onClick={() => { onOpenSyncSettings(); onClose(); }}
97
+ className="text-xs text-muted-foreground hover:text-foreground transition-colors rounded px-1 py-0.5 focus-visible:ring-2 focus-visible:ring-ring"
98
+ >
99
+ Settings →
100
+ </button>
101
+ </div>
102
+ </div>
103
+ </div>
104
+ );
105
+ }
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useState, useMemo, useEffect } from 'react';
3
+ import React, { useState, useMemo, useEffect } from 'react';
4
4
  import { ChevronUp, ChevronDown, Plus, Trash2 } from 'lucide-react';
5
5
  import type { TableConfig } from './types';
6
6
  import { serializeCSV } from './types';
@@ -102,8 +102,8 @@ export function TableView({ headers, rows, cfg, saveAction }: {
102
102
  </tr>
103
103
  </thead>
104
104
  <tbody>
105
- {sections.map(section => (
106
- <>
105
+ {sections.map((section, si) => (
106
+ <React.Fragment key={section.key ?? `section-${si}`}>
107
107
  {section.key !== null && (
108
108
  <tr key={`grp-${section.key}`}>
109
109
  <td colSpan={visibleIndices.length + 1} className="px-4 py-1.5"
@@ -137,7 +137,7 @@ export function TableView({ headers, rows, cfg, saveAction }: {
137
137
  </tr>
138
138
  );
139
139
  })}
140
- </>
140
+ </React.Fragment>
141
141
  ))}
142
142
  {showAdd && (
143
143
  <AddRowTr headers={headers} visibleIndices={visibleIndices} onAdd={addRow} onCancel={() => setShowAdd(false)} />
@@ -3,7 +3,7 @@
3
3
  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
- import { Field, Select, Input, EnvBadge, ApiKeyInput, Toggle } from './Primitives';
6
+ import { Field, Select, Input, EnvBadge, ApiKeyInput, Toggle, SectionLabel } 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';
@@ -277,6 +277,44 @@ export function AiTab({ data, updateAi, updateAgent, t }: AiTabProps) {
277
277
  )}
278
278
  </div>
279
279
  </div>
280
+
281
+ {/* Ask AI Display Mode */}
282
+ <AskDisplayMode />
283
+ </div>
284
+ );
285
+ }
286
+
287
+ /* ── Ask AI Display Mode (localStorage-based, no server roundtrip) ── */
288
+
289
+ function AskDisplayMode() {
290
+ const [mode, setMode] = useState<'panel' | 'popup'>('panel');
291
+
292
+ useEffect(() => {
293
+ try {
294
+ const stored = localStorage.getItem('ask-mode');
295
+ if (stored === 'popup') setMode('popup');
296
+ } catch {}
297
+ }, []);
298
+
299
+ const handleChange = (value: string) => {
300
+ const next = value as 'panel' | 'popup';
301
+ setMode(next);
302
+ try { localStorage.setItem('ask-mode', next); } catch {}
303
+ // Notify SidebarLayout to pick up the change
304
+ window.dispatchEvent(new StorageEvent('storage', { key: 'ask-mode', newValue: next }));
305
+ };
306
+
307
+ return (
308
+ <div className="pt-3 border-t border-border">
309
+ <SectionLabel>MindOS Agent</SectionLabel>
310
+ <div className="space-y-4">
311
+ <Field label="Display Mode" hint="Side panel stays docked on the right. Popup opens a floating dialog.">
312
+ <Select value={mode} onChange={e => handleChange(e.target.value)}>
313
+ <option value="panel">Side Panel</option>
314
+ <option value="popup">Popup</option>
315
+ </Select>
316
+ </Field>
317
+ </div>
280
318
  </div>
281
319
  );
282
320
  }
@@ -1,10 +1,11 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useCallback, useSyncExternalStore } from 'react';
4
- import { Copy, Check, RefreshCw, Trash2, Sparkles } from 'lucide-react';
3
+ import { useState, useEffect, useCallback, useSyncExternalStore, useRef } from 'react';
4
+ import { Copy, Check, RefreshCw, Trash2, Sparkles, ChevronDown, ChevronRight, Loader2, Cpu, Zap, Database as DatabaseIcon, HardDrive } from 'lucide-react';
5
5
  import type { KnowledgeTabProps } from './types';
6
6
  import { Field, Input, EnvBadge, SectionLabel, Toggle } from './Primitives';
7
7
  import { apiFetch } from '@/lib/api';
8
+ import { formatBytes, formatUptime } from '@/lib/format';
8
9
 
9
10
  export function KnowledgeTab({ data, setData, t }: KnowledgeTabProps) {
10
11
  const env = data.envOverrides ?? {};
@@ -205,6 +206,119 @@ export function KnowledgeTab({ data, setData, t }: KnowledgeTabProps) {
205
206
  </div>
206
207
  </div>
207
208
  )}
209
+
210
+ {/* System Monitoring — collapsible */}
211
+ <MonitoringSection />
212
+ </div>
213
+ );
214
+ }
215
+
216
+ /* ── Inline Monitoring Section ── */
217
+
218
+ interface MonitoringData {
219
+ system: {
220
+ uptimeMs: number;
221
+ memory: { heapUsed: number; heapTotal: number; rss: number };
222
+ nodeVersion: string;
223
+ };
224
+ application: {
225
+ agentRequests: number;
226
+ toolExecutions: number;
227
+ totalTokens: { input: number; output: number };
228
+ avgResponseTimeMs: number;
229
+ errors: number;
230
+ };
231
+ knowledgeBase: {
232
+ root: string;
233
+ fileCount: number;
234
+ totalSizeBytes: number;
235
+ };
236
+ mcp: { running: boolean; port: number };
237
+ }
238
+
239
+ function MonitoringSection() {
240
+ const [expanded, setExpanded] = useState(false);
241
+ const [data, setData] = useState<MonitoringData | null>(null);
242
+ const [loading, setLoading] = useState(false);
243
+
244
+ const fetchData = useCallback(async () => {
245
+ setLoading(true);
246
+ try {
247
+ const d = await apiFetch<MonitoringData>('/api/monitoring', { timeout: 5000 });
248
+ setData(d);
249
+ } catch { /* ignore */ }
250
+ setLoading(false);
251
+ }, []);
252
+
253
+ // Fetch on first expand, then refresh every 10s while expanded
254
+ const hasFetched = useRef(false);
255
+ useEffect(() => {
256
+ if (!expanded) { hasFetched.current = false; return; }
257
+ if (!hasFetched.current) { fetchData(); hasFetched.current = true; }
258
+ const id = setInterval(fetchData, 10_000);
259
+ return () => clearInterval(id);
260
+ }, [expanded, fetchData]);
261
+
262
+ return (
263
+ <div className="border-t border-border pt-5">
264
+ <button
265
+ onClick={() => setExpanded(v => !v)}
266
+ className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors w-full"
267
+ >
268
+ {expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
269
+ <Cpu size={12} />
270
+ System Monitoring
271
+ {loading && <Loader2 size={10} className="animate-spin ml-1" />}
272
+ </button>
273
+
274
+ {expanded && data && (
275
+ <div className="mt-3 grid grid-cols-2 gap-x-6 gap-y-2 text-xs">
276
+ <div>
277
+ <span className="text-muted-foreground">Heap</span>
278
+ <span className="ml-2 tabular-nums">{formatBytes(data.system.memory.heapUsed)} / {formatBytes(data.system.memory.heapTotal)}</span>
279
+ </div>
280
+ <div>
281
+ <span className="text-muted-foreground">RSS</span>
282
+ <span className="ml-2 tabular-nums">{formatBytes(data.system.memory.rss)}</span>
283
+ </div>
284
+ <div>
285
+ <span className="text-muted-foreground">Uptime</span>
286
+ <span className="ml-2 tabular-nums">{formatUptime(data.system.uptimeMs)}</span>
287
+ </div>
288
+ <div>
289
+ <span className="text-muted-foreground">Node</span>
290
+ <span className="ml-2">{data.system.nodeVersion}</span>
291
+ </div>
292
+ <div>
293
+ <span className="text-muted-foreground">Requests</span>
294
+ <span className="ml-2 tabular-nums">{data.application.agentRequests}</span>
295
+ </div>
296
+ <div>
297
+ <span className="text-muted-foreground">Tool Calls</span>
298
+ <span className="ml-2 tabular-nums">{data.application.toolExecutions}</span>
299
+ </div>
300
+ <div>
301
+ <span className="text-muted-foreground">Tokens</span>
302
+ <span className="ml-2 tabular-nums">{(data.application.totalTokens.input + data.application.totalTokens.output).toLocaleString()}</span>
303
+ </div>
304
+ <div>
305
+ <span className="text-muted-foreground">Files</span>
306
+ <span className="ml-2 tabular-nums">{data.knowledgeBase.fileCount} ({formatBytes(data.knowledgeBase.totalSizeBytes)})</span>
307
+ </div>
308
+ <div>
309
+ <span className="text-muted-foreground">MCP</span>
310
+ <span className="ml-2">{data.mcp.running ? `Running :${data.mcp.port}` : 'Stopped'}</span>
311
+ </div>
312
+ <div>
313
+ <span className="text-muted-foreground">Errors</span>
314
+ <span className="ml-2 tabular-nums">{data.application.errors}</span>
315
+ </div>
316
+ </div>
317
+ )}
318
+
319
+ {expanded && !data && !loading && (
320
+ <p className="mt-2 text-xs text-muted-foreground">Failed to load monitoring data</p>
321
+ )}
208
322
  </div>
209
323
  );
210
324
  }
@@ -47,17 +47,17 @@ export function McpTab({ t }: McpTabProps) {
47
47
  <ServerStatus status={mcpStatus} agents={agents} t={t} />
48
48
  </div>
49
49
 
50
- {/* Agent Configuration */}
51
- <div>
52
- <h3 className="text-sm font-medium text-foreground mb-3">{m?.agentsTitle ?? 'Agent Configuration'}</h3>
53
- <AgentInstall agents={agents} t={t} onRefresh={fetchAll} />
54
- </div>
55
-
56
50
  {/* Skills */}
57
51
  <div>
58
52
  <h3 className="text-sm font-medium text-foreground mb-3">{m?.skillsTitle ?? 'Skills'}</h3>
59
53
  <SkillsSection t={t} />
60
54
  </div>
55
+
56
+ {/* Agent Configuration */}
57
+ <div>
58
+ <h3 className="text-sm font-medium text-foreground mb-3">{m?.agentsTitle ?? 'Agent Configuration'}</h3>
59
+ <AgentInstall agents={agents} t={t} onRefresh={fetchAll} />
60
+ </div>
61
61
  </div>
62
62
  );
63
63
  }