@geminilight/mindos 0.5.21 → 0.5.23
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 +31 -9
- package/app/app/api/bootstrap/route.ts +1 -0
- package/app/app/api/monitoring/route.ts +95 -0
- package/app/app/globals.css +14 -0
- package/app/app/setup/page.tsx +3 -2
- package/app/components/ActivityBar.tsx +183 -0
- package/app/components/AskFab.tsx +39 -97
- package/app/components/AskModal.tsx +13 -371
- package/app/components/Breadcrumb.tsx +4 -4
- package/app/components/FileTree.tsx +21 -4
- package/app/components/Logo.tsx +39 -0
- package/app/components/Panel.tsx +152 -0
- package/app/components/RightAskPanel.tsx +72 -0
- package/app/components/SettingsModal.tsx +9 -235
- package/app/components/SidebarLayout.tsx +426 -12
- package/app/components/SyncStatusBar.tsx +74 -53
- package/app/components/TableOfContents.tsx +4 -2
- package/app/components/ask/AskContent.tsx +418 -0
- package/app/components/ask/MessageList.tsx +2 -2
- package/app/components/panels/AgentsPanel.tsx +231 -0
- package/app/components/panels/PanelHeader.tsx +35 -0
- package/app/components/panels/PluginsPanel.tsx +106 -0
- package/app/components/panels/SearchPanel.tsx +178 -0
- package/app/components/panels/SyncPopover.tsx +105 -0
- package/app/components/renderers/csv/TableView.tsx +4 -4
- package/app/components/settings/AgentsTab.tsx +240 -0
- package/app/components/settings/AiTab.tsx +39 -1
- package/app/components/settings/KnowledgeTab.tsx +116 -2
- package/app/components/settings/McpTab.tsx +6 -6
- package/app/components/settings/MonitoringTab.tsx +202 -0
- package/app/components/settings/SettingsContent.tsx +343 -0
- package/app/components/settings/types.ts +1 -1
- package/app/components/setup/index.tsx +2 -23
- package/app/hooks/useResizeDrag.ts +78 -0
- package/app/instrumentation.ts +7 -2
- package/app/lib/agent/log.ts +1 -0
- package/app/lib/agent/model.ts +33 -10
- package/app/lib/api.ts +12 -3
- package/app/lib/core/csv.ts +2 -1
- package/app/lib/core/fs-ops.ts +7 -6
- package/app/lib/core/index.ts +1 -1
- package/app/lib/core/lines.ts +7 -6
- package/app/lib/core/search-index.ts +174 -0
- package/app/lib/core/search.ts +30 -1
- package/app/lib/core/security.ts +6 -3
- package/app/lib/errors.ts +108 -0
- package/app/lib/format.ts +19 -0
- package/app/lib/fs.ts +6 -3
- package/app/lib/i18n-en.ts +49 -6
- package/app/lib/i18n-zh.ts +48 -5
- package/app/lib/metrics.ts +81 -0
- package/app/next-env.d.ts +1 -1
- package/app/next.config.ts +1 -1
- package/app/package.json +2 -2
- package/bin/cli.js +27 -97
- package/package.json +4 -2
- package/scripts/setup.js +2 -12
- package/skills/mindos/SKILL.md +226 -8
- package/skills/mindos-zh/SKILL.md +226 -8
- package/app/package-lock.json +0 -15736
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, useCallback, useRef } from 'react';
|
|
4
|
+
import { Settings, Loader2, AlertCircle, CheckCircle2, RotateCcw, Sparkles, Palette, Database, RefreshCw, Plug, X } from 'lucide-react';
|
|
5
|
+
import { useLocale } from '@/lib/LocaleContext';
|
|
6
|
+
import { apiFetch } from '@/lib/api';
|
|
7
|
+
import type { AiSettings, AgentSettings, SettingsData, Tab } from './types';
|
|
8
|
+
import { AiTab } from './AiTab';
|
|
9
|
+
import { AppearanceTab } from './AppearanceTab';
|
|
10
|
+
import { KnowledgeTab } from './KnowledgeTab';
|
|
11
|
+
import { SyncTab } from './SyncTab';
|
|
12
|
+
import { McpTab } from './McpTab';
|
|
13
|
+
|
|
14
|
+
interface SettingsContentProps {
|
|
15
|
+
visible: boolean;
|
|
16
|
+
initialTab?: Tab;
|
|
17
|
+
variant: 'modal' | 'panel';
|
|
18
|
+
onClose?: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default function SettingsContent({ visible, initialTab, variant, onClose }: SettingsContentProps) {
|
|
22
|
+
const [tab, setTab] = useState<Tab>('ai');
|
|
23
|
+
const [data, setData] = useState<SettingsData | null>(null);
|
|
24
|
+
const [saving, setSaving] = useState(false);
|
|
25
|
+
const [status, setStatus] = useState<'idle' | 'saved' | 'error' | 'load-error'>('idle');
|
|
26
|
+
const { t, locale, setLocale } = useLocale();
|
|
27
|
+
const saveTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
28
|
+
const dataLoaded = useRef(false);
|
|
29
|
+
|
|
30
|
+
const [font, setFont] = useState('lora');
|
|
31
|
+
const [contentWidth, setContentWidth] = useState('780px');
|
|
32
|
+
const [dark, setDark] = useState(true);
|
|
33
|
+
|
|
34
|
+
const isPanel = variant === 'panel';
|
|
35
|
+
|
|
36
|
+
// Init data when becoming visible
|
|
37
|
+
const prevVisibleRef = useRef(false);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const justOpened = isPanel
|
|
40
|
+
? (visible && !prevVisibleRef.current)
|
|
41
|
+
: visible;
|
|
42
|
+
|
|
43
|
+
if (justOpened) {
|
|
44
|
+
apiFetch<SettingsData>('/api/settings').then(d => { setData(d); dataLoaded.current = true; }).catch(() => setStatus('load-error'));
|
|
45
|
+
setFont(localStorage.getItem('prose-font') ?? 'lora');
|
|
46
|
+
setContentWidth(localStorage.getItem('content-width') ?? '780px');
|
|
47
|
+
const stored = localStorage.getItem('theme');
|
|
48
|
+
setDark(stored ? stored === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches);
|
|
49
|
+
setStatus('idle');
|
|
50
|
+
}
|
|
51
|
+
if (!visible) { dataLoaded.current = false; }
|
|
52
|
+
prevVisibleRef.current = visible;
|
|
53
|
+
}, [visible, isPanel]);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (visible && initialTab) setTab(initialTab);
|
|
57
|
+
}, [visible, initialTab]);
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
const fontMap: Record<string, string> = {
|
|
61
|
+
'lora': "'Lora', Georgia, serif",
|
|
62
|
+
'ibm-plex-sans': "'IBM Plex Sans', sans-serif",
|
|
63
|
+
'geist': 'var(--font-geist-sans), sans-serif',
|
|
64
|
+
'ibm-plex-mono': "'IBM Plex Mono', monospace",
|
|
65
|
+
};
|
|
66
|
+
document.documentElement.style.setProperty('--prose-font-override', fontMap[font] ?? '');
|
|
67
|
+
localStorage.setItem('prose-font', font);
|
|
68
|
+
}, [font]);
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
document.documentElement.style.setProperty('--content-width-override', contentWidth);
|
|
72
|
+
localStorage.setItem('content-width', contentWidth);
|
|
73
|
+
}, [contentWidth]);
|
|
74
|
+
|
|
75
|
+
// Esc to close — modal only
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (variant !== 'modal' || !visible || !onClose) return;
|
|
78
|
+
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
|
79
|
+
window.addEventListener('keydown', handler);
|
|
80
|
+
return () => window.removeEventListener('keydown', handler);
|
|
81
|
+
}, [variant, visible, onClose]);
|
|
82
|
+
|
|
83
|
+
const doSave = useCallback(async (d: SettingsData) => {
|
|
84
|
+
setSaving(true);
|
|
85
|
+
try {
|
|
86
|
+
await apiFetch('/api/settings', {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: { 'Content-Type': 'application/json' },
|
|
89
|
+
body: JSON.stringify({ ai: d.ai, agent: d.agent, mindRoot: d.mindRoot, webPassword: d.webPassword, authToken: d.authToken }),
|
|
90
|
+
});
|
|
91
|
+
setStatus('saved');
|
|
92
|
+
setTimeout(() => setStatus('idle'), 2500);
|
|
93
|
+
} catch {
|
|
94
|
+
setStatus('error');
|
|
95
|
+
setTimeout(() => setStatus('idle'), 2500);
|
|
96
|
+
} finally {
|
|
97
|
+
setSaving(false);
|
|
98
|
+
}
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (!data || !dataLoaded.current) return;
|
|
103
|
+
clearTimeout(saveTimer.current);
|
|
104
|
+
saveTimer.current = setTimeout(() => doSave(data), 800);
|
|
105
|
+
return () => clearTimeout(saveTimer.current);
|
|
106
|
+
}, [data, doSave]);
|
|
107
|
+
|
|
108
|
+
const updateAi = useCallback((patch: Partial<AiSettings>) => {
|
|
109
|
+
setData(d => d ? { ...d, ai: { ...d.ai, ...patch } } : d);
|
|
110
|
+
}, []);
|
|
111
|
+
|
|
112
|
+
const updateAgent = useCallback((patch: Partial<AgentSettings>) => {
|
|
113
|
+
setData(d => d ? { ...d, agent: { ...(d.agent ?? {}), ...patch } } : d);
|
|
114
|
+
}, []);
|
|
115
|
+
|
|
116
|
+
const restoreFromEnv = useCallback(async () => {
|
|
117
|
+
if (!data) return;
|
|
118
|
+
const defaults: AiSettings = {
|
|
119
|
+
provider: 'anthropic',
|
|
120
|
+
providers: { anthropic: { apiKey: '', model: '' }, openai: { apiKey: '', model: '', baseUrl: '' } },
|
|
121
|
+
};
|
|
122
|
+
setData(d => d ? { ...d, ai: defaults } : d);
|
|
123
|
+
const DEBOUNCE_DELAY = 800;
|
|
124
|
+
const SAVE_OPERATION_TIME = 500;
|
|
125
|
+
setTimeout(() => {
|
|
126
|
+
apiFetch<SettingsData>('/api/settings').then(d => { setData(d); }).catch(() => setStatus('error'));
|
|
127
|
+
}, DEBOUNCE_DELAY + SAVE_OPERATION_TIME);
|
|
128
|
+
}, [data]);
|
|
129
|
+
|
|
130
|
+
const env = data?.envOverrides ?? {};
|
|
131
|
+
const iconSize = isPanel ? 12 : 13;
|
|
132
|
+
|
|
133
|
+
const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
|
134
|
+
{ id: 'ai', label: t.settings.tabs.ai, icon: <Sparkles size={iconSize} /> },
|
|
135
|
+
{ id: 'mcp', label: t.settings.tabs.mcp ?? 'MCP & Skills', icon: <Plug size={iconSize} /> },
|
|
136
|
+
{ id: 'knowledge', label: t.settings.tabs.knowledge, icon: <Settings size={iconSize} /> },
|
|
137
|
+
{ id: 'sync', label: t.settings.tabs.sync ?? 'Sync', icon: <RefreshCw size={iconSize} /> },
|
|
138
|
+
{ id: 'appearance', label: t.settings.tabs.appearance, icon: <Palette size={iconSize} /> },
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
const activeTabLabel = TABS.find(t2 => t2.id === tab)?.label ?? '';
|
|
142
|
+
|
|
143
|
+
/* ── Shared content & footer ── */
|
|
144
|
+
const renderContent = () => (
|
|
145
|
+
<div className={`flex-1 overflow-y-auto min-h-0 ${isPanel ? 'px-4 py-4 space-y-4' : 'px-5 py-5 space-y-5'}`}>
|
|
146
|
+
{status === 'load-error' && (tab === 'ai' || tab === 'knowledge') ? (
|
|
147
|
+
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
|
148
|
+
<AlertCircle size={isPanel ? 18 : 20} className="text-destructive" />
|
|
149
|
+
<p className={`${isPanel ? 'text-xs' : 'text-sm'} text-destructive font-medium`}>Failed to load settings</p>
|
|
150
|
+
{!isPanel && <p className="text-xs text-muted-foreground">Check that the server is running and AUTH_TOKEN is configured correctly.</p>}
|
|
151
|
+
</div>
|
|
152
|
+
) : !data && tab !== 'appearance' && tab !== 'mcp' && tab !== 'sync' ? (
|
|
153
|
+
<div className="flex justify-center py-8">
|
|
154
|
+
<Loader2 size={isPanel ? 16 : 18} className="animate-spin text-muted-foreground" />
|
|
155
|
+
</div>
|
|
156
|
+
) : (
|
|
157
|
+
<>
|
|
158
|
+
{tab === 'ai' && data?.ai && <AiTab data={data} updateAi={updateAi} updateAgent={updateAgent} t={t} />}
|
|
159
|
+
{tab === 'appearance' && <AppearanceTab font={font} setFont={setFont} contentWidth={contentWidth} setContentWidth={setContentWidth} dark={dark} setDark={setDark} locale={locale} setLocale={setLocale} t={t} />}
|
|
160
|
+
{tab === 'knowledge' && data && <KnowledgeTab data={data} setData={setData} t={t} />}
|
|
161
|
+
{tab === 'sync' && <SyncTab t={t} />}
|
|
162
|
+
{tab === 'mcp' && <McpTab t={t} />}
|
|
163
|
+
</>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const renderFooter = () => (
|
|
169
|
+
(tab === 'ai' || tab === 'knowledge') ? (
|
|
170
|
+
<div className={`${isPanel ? 'px-4 py-2' : 'px-5 py-2.5'} border-t border-border shrink-0 flex items-center justify-between`}>
|
|
171
|
+
<div className="flex items-center gap-3">
|
|
172
|
+
{tab === 'ai' && Object.values(env).some(Boolean) && (
|
|
173
|
+
<button
|
|
174
|
+
onClick={restoreFromEnv}
|
|
175
|
+
disabled={saving || !data}
|
|
176
|
+
className={`flex items-center gap-1.5 ${isPanel ? 'px-2.5 py-1 text-[11px] rounded-md' : '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`}
|
|
177
|
+
>
|
|
178
|
+
<RotateCcw size={isPanel ? 11 : 12} />
|
|
179
|
+
{t.settings.ai.restoreFromEnv}
|
|
180
|
+
</button>
|
|
181
|
+
)}
|
|
182
|
+
{tab === 'knowledge' && (
|
|
183
|
+
<a
|
|
184
|
+
href="/setup?force=1"
|
|
185
|
+
className={`flex items-center gap-1.5 ${isPanel ? 'px-2.5 py-1 text-[11px] rounded-md' : 'px-3 py-1 text-xs rounded-lg'} border border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors`}
|
|
186
|
+
>
|
|
187
|
+
<RotateCcw size={isPanel ? 11 : 12} />
|
|
188
|
+
{t.settings.reconfigure}
|
|
189
|
+
</a>
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
192
|
+
{!isPanel && (
|
|
193
|
+
<div className="flex items-center gap-1.5 text-xs" role="status" aria-live="polite">
|
|
194
|
+
{saving && <><Loader2 size={12} className="animate-spin text-muted-foreground" /><span className="text-muted-foreground">{t.settings.save}...</span></>}
|
|
195
|
+
{status === 'saved' && <><CheckCircle2 size={12} className="text-success" /><span className="text-success">{t.settings.saved}</span></>}
|
|
196
|
+
{status === 'error' && <><AlertCircle size={12} className="text-destructive" /><span className="text-destructive">{t.settings.saveFailed}</span></>}
|
|
197
|
+
</div>
|
|
198
|
+
)}
|
|
199
|
+
</div>
|
|
200
|
+
) : null
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
/* ── Panel variant: unchanged (horizontal tabs) ── */
|
|
204
|
+
if (isPanel) {
|
|
205
|
+
return (
|
|
206
|
+
<>
|
|
207
|
+
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
|
|
208
|
+
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
|
209
|
+
<Settings size={14} className="text-muted-foreground" />
|
|
210
|
+
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider font-display">Settings</span>
|
|
211
|
+
</div>
|
|
212
|
+
<div className="flex items-center gap-1.5">
|
|
213
|
+
<div className="flex items-center gap-1.5 text-[10px]" role="status" aria-live="polite">
|
|
214
|
+
{saving && <Loader2 size={10} className="animate-spin text-muted-foreground" />}
|
|
215
|
+
{status === 'saved' && <CheckCircle2 size={10} className="text-success" />}
|
|
216
|
+
{status === 'error' && <AlertCircle size={10} className="text-destructive" />}
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
<div className="flex border-b border-border px-3 shrink-0 overflow-x-auto scrollbar-none gap-0">
|
|
221
|
+
{TABS.map(tabItem => (
|
|
222
|
+
<button
|
|
223
|
+
key={tabItem.id}
|
|
224
|
+
onClick={() => setTab(tabItem.id)}
|
|
225
|
+
className={`flex items-center gap-1 px-2 py-2 text-[11px] font-medium transition-colors border-b-2 -mb-px whitespace-nowrap ${
|
|
226
|
+
tab === tabItem.id
|
|
227
|
+
? 'border-amber-500 text-foreground'
|
|
228
|
+
: 'border-transparent text-muted-foreground hover:text-foreground'
|
|
229
|
+
}`}
|
|
230
|
+
>
|
|
231
|
+
{tabItem.icon}
|
|
232
|
+
{tabItem.label}
|
|
233
|
+
</button>
|
|
234
|
+
))}
|
|
235
|
+
</div>
|
|
236
|
+
{renderContent()}
|
|
237
|
+
{renderFooter()}
|
|
238
|
+
</>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/* ── Modal variant ── */
|
|
243
|
+
return (
|
|
244
|
+
<>
|
|
245
|
+
{/* Mobile: original vertical layout */}
|
|
246
|
+
<div className="flex flex-col h-full md:hidden">
|
|
247
|
+
{/* Mobile header */}
|
|
248
|
+
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
|
|
249
|
+
<div className="flex justify-center pt-2 pb-0 absolute top-0 left-1/2 -translate-x-1/2">
|
|
250
|
+
<div className="w-8 h-1 rounded-full bg-muted-foreground/20" />
|
|
251
|
+
</div>
|
|
252
|
+
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
|
253
|
+
<Settings size={15} className="text-muted-foreground" />
|
|
254
|
+
<span className="font-display">{t.settings.title}</span>
|
|
255
|
+
</div>
|
|
256
|
+
<div className="flex items-center gap-1.5">
|
|
257
|
+
<div className="flex items-center gap-1.5 text-[10px]" role="status" aria-live="polite">
|
|
258
|
+
{saving && <Loader2 size={12} className="animate-spin text-muted-foreground" />}
|
|
259
|
+
{status === 'saved' && <><CheckCircle2 size={12} className="text-success" /><span className="text-success">{t.settings.saved}</span></>}
|
|
260
|
+
{status === 'error' && <><AlertCircle size={12} className="text-destructive" /><span className="text-destructive">{t.settings.saveFailed}</span></>}
|
|
261
|
+
</div>
|
|
262
|
+
{onClose && (
|
|
263
|
+
<button onClick={onClose} className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors">
|
|
264
|
+
<X size={15} />
|
|
265
|
+
</button>
|
|
266
|
+
)}
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
{/* Mobile horizontal tabs */}
|
|
270
|
+
<div className="flex border-b border-border px-4 shrink-0 overflow-x-auto scrollbar-none gap-0">
|
|
271
|
+
{TABS.map(tabItem => (
|
|
272
|
+
<button
|
|
273
|
+
key={tabItem.id}
|
|
274
|
+
onClick={() => setTab(tabItem.id)}
|
|
275
|
+
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 ${
|
|
276
|
+
tab === tabItem.id
|
|
277
|
+
? 'border-amber-500 text-foreground'
|
|
278
|
+
: 'border-transparent text-muted-foreground hover:text-foreground'
|
|
279
|
+
}`}
|
|
280
|
+
>
|
|
281
|
+
{tabItem.icon}
|
|
282
|
+
{tabItem.label}
|
|
283
|
+
</button>
|
|
284
|
+
))}
|
|
285
|
+
</div>
|
|
286
|
+
{renderContent()}
|
|
287
|
+
{renderFooter()}
|
|
288
|
+
</div>
|
|
289
|
+
|
|
290
|
+
{/* Desktop: left-right layout */}
|
|
291
|
+
<div className="hidden md:flex flex-row h-full min-h-0">
|
|
292
|
+
{/* Left sidebar — vertical tabs */}
|
|
293
|
+
<div className="w-[180px] shrink-0 border-r border-border flex flex-col">
|
|
294
|
+
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
|
|
295
|
+
<Settings size={15} className="text-muted-foreground" />
|
|
296
|
+
<span className="text-sm font-medium font-display text-foreground">{t.settings.title}</span>
|
|
297
|
+
</div>
|
|
298
|
+
<nav className="flex-1 overflow-y-auto py-1.5">
|
|
299
|
+
{TABS.map(tabItem => (
|
|
300
|
+
<button
|
|
301
|
+
key={tabItem.id}
|
|
302
|
+
onClick={() => setTab(tabItem.id)}
|
|
303
|
+
className={`flex items-center gap-2 w-full px-4 py-2 text-xs font-medium transition-colors relative ${
|
|
304
|
+
tab === tabItem.id
|
|
305
|
+
? 'text-foreground bg-muted'
|
|
306
|
+
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
|
307
|
+
}`}
|
|
308
|
+
>
|
|
309
|
+
{tab === tabItem.id && (
|
|
310
|
+
<div className="absolute left-0 top-1 bottom-1 w-[3px] rounded-r-full bg-amber-500" />
|
|
311
|
+
)}
|
|
312
|
+
{tabItem.icon}
|
|
313
|
+
{tabItem.label}
|
|
314
|
+
</button>
|
|
315
|
+
))}
|
|
316
|
+
</nav>
|
|
317
|
+
</div>
|
|
318
|
+
|
|
319
|
+
{/* Right content area */}
|
|
320
|
+
<div className="flex-1 flex flex-col min-w-0 min-h-0">
|
|
321
|
+
{/* Right header: tab title + status + close */}
|
|
322
|
+
<div className="flex items-center justify-between px-5 py-3 border-b border-border shrink-0">
|
|
323
|
+
<span className="text-sm font-medium text-foreground font-display">{activeTabLabel}</span>
|
|
324
|
+
<div className="flex items-center gap-1.5">
|
|
325
|
+
<div className="flex items-center gap-1.5 text-[10px]" role="status" aria-live="polite">
|
|
326
|
+
{saving && <Loader2 size={12} className="animate-spin text-muted-foreground" />}
|
|
327
|
+
{status === 'saved' && <><CheckCircle2 size={12} className="text-success" /><span className="text-success">{t.settings.saved}</span></>}
|
|
328
|
+
{status === 'error' && <><AlertCircle size={12} className="text-destructive" /><span className="text-destructive">{t.settings.saveFailed}</span></>}
|
|
329
|
+
</div>
|
|
330
|
+
{onClose && (
|
|
331
|
+
<button onClick={onClose} className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors">
|
|
332
|
+
<X size={15} />
|
|
333
|
+
</button>
|
|
334
|
+
)}
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
{renderContent()}
|
|
338
|
+
{renderFooter()}
|
|
339
|
+
</div>
|
|
340
|
+
</div>
|
|
341
|
+
</>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
@@ -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' | '
|
|
36
|
+
export type Tab = 'ai' | 'appearance' | 'knowledge' | 'mcp' | 'sync';
|
|
37
37
|
|
|
38
38
|
export const CONTENT_WIDTHS = [
|
|
39
39
|
{ value: '680px', label: 'Narrow (680px)' },
|
|
@@ -102,20 +102,6 @@ async function installAgents(
|
|
|
102
102
|
return updated;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
/** Phase 3: Install skill to agents. Returns result. */
|
|
106
|
-
async function installSkill(
|
|
107
|
-
template: string,
|
|
108
|
-
agentKeys: string[],
|
|
109
|
-
): Promise<{ ok?: boolean; skill?: string; error?: string }> {
|
|
110
|
-
const skillName = template === 'zh' ? 'mindos-zh' : 'mindos';
|
|
111
|
-
const res = await fetch('/api/mcp/install-skill', {
|
|
112
|
-
method: 'POST',
|
|
113
|
-
headers: { 'Content-Type': 'application/json' },
|
|
114
|
-
body: JSON.stringify({ skill: skillName, agents: agentKeys }),
|
|
115
|
-
});
|
|
116
|
-
return await res.json();
|
|
117
|
-
}
|
|
118
|
-
|
|
119
105
|
// ─── Component ───────────────────────────────────────────────────────────────
|
|
120
106
|
|
|
121
107
|
export default function SetupWizard() {
|
|
@@ -318,15 +304,8 @@ export default function SetupWizard() {
|
|
|
318
304
|
}
|
|
319
305
|
}
|
|
320
306
|
|
|
321
|
-
// Phase 3:
|
|
322
|
-
|
|
323
|
-
try {
|
|
324
|
-
const skillData = await installSkill(state.template, agentKeys);
|
|
325
|
-
setSkillInstallResult(skillData);
|
|
326
|
-
} catch (e) {
|
|
327
|
-
console.warn('[SetupWizard] skill install failed:', e);
|
|
328
|
-
setSkillInstallResult({ error: 'Failed to install skill' });
|
|
329
|
-
}
|
|
307
|
+
// Phase 3: Skill is now built into SKILL.md — no install needed.
|
|
308
|
+
// user-skill-rules.md will be created on first preference capture.
|
|
330
309
|
|
|
331
310
|
setSubmitting(false);
|
|
332
311
|
setCompleted(true);
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
export interface UseResizeDragOptions {
|
|
6
|
+
/** Current width */
|
|
7
|
+
width: number;
|
|
8
|
+
/** Min allowed width */
|
|
9
|
+
minWidth: number;
|
|
10
|
+
/** Max allowed width (absolute) */
|
|
11
|
+
maxWidth: number;
|
|
12
|
+
/** Max width as ratio of viewport */
|
|
13
|
+
maxWidthRatio: number;
|
|
14
|
+
/** 'right' = right-edge drag (mouse right → wider), 'left' = left-edge drag (mouse left → wider) */
|
|
15
|
+
direction: 'right' | 'left';
|
|
16
|
+
/** Skip if true (e.g. maximized state) */
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
/** Called on every mousemove with new width */
|
|
19
|
+
onResize: (width: number) => void;
|
|
20
|
+
/** Called on mouseup with final width */
|
|
21
|
+
onResizeEnd: (width: number) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Shared drag-resize logic for panel edges.
|
|
26
|
+
* Returns a mousedown handler for the resize handle element.
|
|
27
|
+
*/
|
|
28
|
+
export function useResizeDrag({
|
|
29
|
+
width,
|
|
30
|
+
minWidth,
|
|
31
|
+
maxWidth,
|
|
32
|
+
maxWidthRatio,
|
|
33
|
+
direction,
|
|
34
|
+
disabled,
|
|
35
|
+
onResize,
|
|
36
|
+
onResizeEnd,
|
|
37
|
+
}: UseResizeDragOptions) {
|
|
38
|
+
const dragging = useRef(false);
|
|
39
|
+
const startX = useRef(0);
|
|
40
|
+
const startWidth = useRef(0);
|
|
41
|
+
const latestWidthRef = useRef(width);
|
|
42
|
+
latestWidthRef.current = width;
|
|
43
|
+
|
|
44
|
+
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
45
|
+
if (disabled) return;
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
dragging.current = true;
|
|
48
|
+
startX.current = e.clientX;
|
|
49
|
+
startWidth.current = width;
|
|
50
|
+
|
|
51
|
+
document.body.classList.add('select-none');
|
|
52
|
+
document.body.style.cursor = 'col-resize';
|
|
53
|
+
|
|
54
|
+
const onMouseMove = (ev: MouseEvent) => {
|
|
55
|
+
if (!dragging.current) return;
|
|
56
|
+
const delta = direction === 'right'
|
|
57
|
+
? ev.clientX - startX.current
|
|
58
|
+
: startX.current - ev.clientX;
|
|
59
|
+
const maxW = Math.min(maxWidth, window.innerWidth * maxWidthRatio);
|
|
60
|
+
const newWidth = Math.round(Math.max(minWidth, Math.min(maxW, startWidth.current + delta)));
|
|
61
|
+
onResize(newWidth);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const onMouseUp = () => {
|
|
65
|
+
dragging.current = false;
|
|
66
|
+
document.body.classList.remove('select-none');
|
|
67
|
+
document.body.style.cursor = '';
|
|
68
|
+
onResizeEnd(latestWidthRef.current);
|
|
69
|
+
document.removeEventListener('mousemove', onMouseMove);
|
|
70
|
+
document.removeEventListener('mouseup', onMouseUp);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
document.addEventListener('mousemove', onMouseMove);
|
|
74
|
+
document.addEventListener('mouseup', onMouseUp);
|
|
75
|
+
}, [width, minWidth, maxWidth, maxWidthRatio, direction, disabled, onResize, onResizeEnd]);
|
|
76
|
+
|
|
77
|
+
return handleMouseDown;
|
|
78
|
+
}
|
package/app/instrumentation.ts
CHANGED
|
@@ -7,9 +7,14 @@ export async function register() {
|
|
|
7
7
|
const configPath = join(homedir(), '.mindos', 'config.json');
|
|
8
8
|
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
9
9
|
if (config.sync?.enabled && config.mindRoot) {
|
|
10
|
-
//
|
|
10
|
+
// Turbopack statically analyzes ALL forms of require/import — including
|
|
11
|
+
// createRequire() calls. The only way to load a runtime-computed path
|
|
12
|
+
// is to hide the require call inside a Function constructor, which is
|
|
13
|
+
// opaque to bundler static analysis.
|
|
11
14
|
const syncModule = resolve(process.cwd(), '..', 'bin', 'lib', 'sync.js');
|
|
12
|
-
|
|
15
|
+
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
|
16
|
+
const dynamicRequire = new Function('id', 'return require(id)') as (id: string) => any;
|
|
17
|
+
const { startSyncDaemon } = dynamicRequire(syncModule);
|
|
13
18
|
await startSyncDaemon(config.mindRoot);
|
|
14
19
|
}
|
|
15
20
|
} catch {
|
package/app/lib/agent/log.ts
CHANGED
package/app/lib/agent/model.ts
CHANGED
|
@@ -22,20 +22,28 @@ export function getModelConfig(): {
|
|
|
22
22
|
if (cfg.provider === 'openai') {
|
|
23
23
|
const modelName = cfg.openaiModel;
|
|
24
24
|
let model: Model<any>;
|
|
25
|
-
let apiVariant: string = 'openai-responses'; // Default to responses API
|
|
26
25
|
|
|
27
|
-
//
|
|
28
|
-
//
|
|
26
|
+
// API variant: 'openai-completions' = /chat/completions (widest compatibility),
|
|
27
|
+
// 'openai-responses' = /responses (OpenAI native). Custom proxies (baseUrl set)
|
|
28
|
+
// almost always only support chat completions, so default to that when baseUrl is set.
|
|
29
|
+
const hasCustomBase = !!cfg.openaiBaseUrl;
|
|
30
|
+
const defaultApi = hasCustomBase ? 'openai-completions' : 'openai-responses';
|
|
29
31
|
const customApiVariant = (cfg as any).openaiApiVariant; // May exist in extended config
|
|
30
32
|
|
|
31
33
|
try {
|
|
32
|
-
|
|
34
|
+
const resolved = piGetModel('openai', modelName as any);
|
|
35
|
+
if (!resolved) throw new Error('Model not in registry');
|
|
36
|
+
model = resolved;
|
|
37
|
+
// If user has a custom baseUrl, override API to completions for compatibility
|
|
38
|
+
if (hasCustomBase && !customApiVariant) {
|
|
39
|
+
model = { ...model, api: defaultApi };
|
|
40
|
+
}
|
|
33
41
|
} catch {
|
|
34
42
|
// Model not in pi-ai registry — construct manually for custom/proxy endpoints
|
|
35
43
|
model = {
|
|
36
44
|
id: modelName,
|
|
37
45
|
name: modelName,
|
|
38
|
-
api: (customApiVariant ??
|
|
46
|
+
api: (customApiVariant ?? defaultApi) as any,
|
|
39
47
|
provider: 'openai',
|
|
40
48
|
baseUrl: 'https://api.openai.com/v1',
|
|
41
49
|
reasoning: false,
|
|
@@ -46,10 +54,23 @@ export function getModelConfig(): {
|
|
|
46
54
|
};
|
|
47
55
|
}
|
|
48
56
|
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
57
|
+
// For custom proxy endpoints, set conservative compat flags.
|
|
58
|
+
// Most proxies (Azure, Bedrock relays, corporate gateways) only support
|
|
59
|
+
// a subset of OpenAI's features. These defaults prevent silent failures.
|
|
60
|
+
if (hasCustomBase) {
|
|
61
|
+
model = {
|
|
62
|
+
...model,
|
|
63
|
+
baseUrl: cfg.openaiBaseUrl,
|
|
64
|
+
compat: {
|
|
65
|
+
...(model as any).compat,
|
|
66
|
+
supportsStore: false,
|
|
67
|
+
supportsDeveloperRole: false,
|
|
68
|
+
supportsReasoningEffort: false,
|
|
69
|
+
supportsUsageInStreaming: false,
|
|
70
|
+
supportsStrictMode: false,
|
|
71
|
+
maxTokensField: 'max_tokens' as const,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
53
74
|
if (customApiVariant) {
|
|
54
75
|
model = { ...model, api: customApiVariant };
|
|
55
76
|
}
|
|
@@ -63,7 +84,9 @@ export function getModelConfig(): {
|
|
|
63
84
|
let model: Model<any>;
|
|
64
85
|
|
|
65
86
|
try {
|
|
66
|
-
|
|
87
|
+
const resolved = piGetModel('anthropic', modelName as any);
|
|
88
|
+
if (!resolved) throw new Error('Model not in registry');
|
|
89
|
+
model = resolved;
|
|
67
90
|
} catch {
|
|
68
91
|
// Unknown Anthropic model — construct manually
|
|
69
92
|
model = {
|
package/app/lib/api.ts
CHANGED
|
@@ -8,10 +8,12 @@
|
|
|
8
8
|
|
|
9
9
|
export class ApiError extends Error {
|
|
10
10
|
status: number;
|
|
11
|
-
|
|
11
|
+
code?: string;
|
|
12
|
+
constructor(message: string, status: number, code?: string) {
|
|
12
13
|
super(message);
|
|
13
14
|
this.name = 'ApiError';
|
|
14
15
|
this.status = status;
|
|
16
|
+
this.code = code;
|
|
15
17
|
}
|
|
16
18
|
}
|
|
17
19
|
|
|
@@ -41,11 +43,18 @@ export async function apiFetch<T>(url: string, opts: ApiFetchOptions = {}): Prom
|
|
|
41
43
|
|
|
42
44
|
if (!res.ok) {
|
|
43
45
|
let msg = `Request failed (${res.status})`;
|
|
46
|
+
let code: string | undefined;
|
|
44
47
|
try {
|
|
45
48
|
const body = await res.json();
|
|
46
|
-
|
|
49
|
+
// Support structured { ok: false, error: { code, message } } envelope
|
|
50
|
+
if (body?.error?.code && body?.error?.message) {
|
|
51
|
+
msg = body.error.message;
|
|
52
|
+
code = body.error.code;
|
|
53
|
+
} else if (body?.error) {
|
|
54
|
+
msg = typeof body.error === 'string' ? body.error : body.error.message ?? msg;
|
|
55
|
+
}
|
|
47
56
|
} catch { /* non-JSON error body */ }
|
|
48
|
-
throw new ApiError(msg, res.status);
|
|
57
|
+
throw new ApiError(msg, res.status, code);
|
|
49
58
|
}
|
|
50
59
|
|
|
51
60
|
return (await res.json()) as T;
|
package/app/lib/core/csv.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { resolveSafe } from './security';
|
|
4
|
+
import { MindOSError, ErrorCodes } from '@/lib/errors';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Appends a single row to a CSV file with RFC 4180 escaping.
|
|
@@ -9,7 +10,7 @@ import { resolveSafe } from './security';
|
|
|
9
10
|
*/
|
|
10
11
|
export function appendCsvRow(mindRoot: string, filePath: string, row: string[]): { newRowCount: number } {
|
|
11
12
|
const resolved = resolveSafe(mindRoot, filePath);
|
|
12
|
-
if (!filePath.endsWith('.csv')) throw new
|
|
13
|
+
if (!filePath.endsWith('.csv')) throw new MindOSError(ErrorCodes.INVALID_FILE_TYPE, 'Only .csv files support row append', { filePath });
|
|
13
14
|
|
|
14
15
|
const escaped = row.map((cell) => {
|
|
15
16
|
if (cell.includes(',') || cell.includes('"') || cell.includes('\n')) {
|