@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.
Files changed (60) hide show
  1. package/app/app/api/ask/route.ts +31 -9
  2. package/app/app/api/bootstrap/route.ts +1 -0
  3. package/app/app/api/monitoring/route.ts +95 -0
  4. package/app/app/globals.css +14 -0
  5. package/app/app/setup/page.tsx +3 -2
  6. package/app/components/ActivityBar.tsx +183 -0
  7. package/app/components/AskFab.tsx +39 -97
  8. package/app/components/AskModal.tsx +13 -371
  9. package/app/components/Breadcrumb.tsx +4 -4
  10. package/app/components/FileTree.tsx +21 -4
  11. package/app/components/Logo.tsx +39 -0
  12. package/app/components/Panel.tsx +152 -0
  13. package/app/components/RightAskPanel.tsx +72 -0
  14. package/app/components/SettingsModal.tsx +9 -235
  15. package/app/components/SidebarLayout.tsx +426 -12
  16. package/app/components/SyncStatusBar.tsx +74 -53
  17. package/app/components/TableOfContents.tsx +4 -2
  18. package/app/components/ask/AskContent.tsx +418 -0
  19. package/app/components/ask/MessageList.tsx +2 -2
  20. package/app/components/panels/AgentsPanel.tsx +231 -0
  21. package/app/components/panels/PanelHeader.tsx +35 -0
  22. package/app/components/panels/PluginsPanel.tsx +106 -0
  23. package/app/components/panels/SearchPanel.tsx +178 -0
  24. package/app/components/panels/SyncPopover.tsx +105 -0
  25. package/app/components/renderers/csv/TableView.tsx +4 -4
  26. package/app/components/settings/AgentsTab.tsx +240 -0
  27. package/app/components/settings/AiTab.tsx +39 -1
  28. package/app/components/settings/KnowledgeTab.tsx +116 -2
  29. package/app/components/settings/McpTab.tsx +6 -6
  30. package/app/components/settings/MonitoringTab.tsx +202 -0
  31. package/app/components/settings/SettingsContent.tsx +343 -0
  32. package/app/components/settings/types.ts +1 -1
  33. package/app/components/setup/index.tsx +2 -23
  34. package/app/hooks/useResizeDrag.ts +78 -0
  35. package/app/instrumentation.ts +7 -2
  36. package/app/lib/agent/log.ts +1 -0
  37. package/app/lib/agent/model.ts +33 -10
  38. package/app/lib/api.ts +12 -3
  39. package/app/lib/core/csv.ts +2 -1
  40. package/app/lib/core/fs-ops.ts +7 -6
  41. package/app/lib/core/index.ts +1 -1
  42. package/app/lib/core/lines.ts +7 -6
  43. package/app/lib/core/search-index.ts +174 -0
  44. package/app/lib/core/search.ts +30 -1
  45. package/app/lib/core/security.ts +6 -3
  46. package/app/lib/errors.ts +108 -0
  47. package/app/lib/format.ts +19 -0
  48. package/app/lib/fs.ts +6 -3
  49. package/app/lib/i18n-en.ts +49 -6
  50. package/app/lib/i18n-zh.ts +48 -5
  51. package/app/lib/metrics.ts +81 -0
  52. package/app/next-env.d.ts +1 -1
  53. package/app/next.config.ts +1 -1
  54. package/app/package.json +2 -2
  55. package/bin/cli.js +27 -97
  56. package/package.json +4 -2
  57. package/scripts/setup.js +2 -12
  58. package/skills/mindos/SKILL.md +226 -8
  59. package/skills/mindos-zh/SKILL.md +226 -8
  60. 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' | 'plugins' | 'sync';
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: Install skill
322
- setSetupPhase('skill');
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
+ }
@@ -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
- // Resolve absolute path to avoid Turbopack bundling issues
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
- const { startSyncDaemon } = await import(/* webpackIgnore: true */ syncModule);
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 {
@@ -11,6 +11,7 @@ interface AgentOpEntry {
11
11
  params: Record<string, unknown>;
12
12
  result: 'ok' | 'error';
13
13
  message?: string;
14
+ durationMs?: number;
14
15
  }
15
16
 
16
17
  /**
@@ -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
- // Allow customization of API variant if using custom endpoint
28
- // Check if config specifies an alternative API type (for non-standard endpoints)
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
- model = piGetModel('openai', modelName as any);
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 ?? apiVariant) as any,
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
- // Override baseUrl if user configured a custom endpoint
50
- if (cfg.openaiBaseUrl) {
51
- model = { ...model, baseUrl: cfg.openaiBaseUrl };
52
- // Also allow API variant override for custom endpoints
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
- model = piGetModel('anthropic', modelName as any);
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
- constructor(message: string, status: number) {
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
- if (body?.error) msg = body.error;
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;
@@ -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 Error('Only .csv files support row append');
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')) {