@polderlabs/bizar 4.4.13 → 4.5.0

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 (90) hide show
  1. package/bizar-dash/CHANGELOG.md +37 -276
  2. package/bizar-dash/dist/assets/main-CDFKHzBg.css +1 -0
  3. package/bizar-dash/dist/assets/main-NYFpS2wY.js +312 -0
  4. package/bizar-dash/dist/assets/main-NYFpS2wY.js.map +1 -0
  5. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js → mobile--0FBIKX3.js} +2 -2
  6. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js.map → mobile--0FBIKX3.js.map} +1 -1
  7. package/bizar-dash/dist/assets/mobile-OgRp8VIb.js +352 -0
  8. package/bizar-dash/dist/assets/mobile-OgRp8VIb.js.map +1 -0
  9. package/bizar-dash/dist/index.html +3 -3
  10. package/bizar-dash/dist/mobile.html +2 -2
  11. package/bizar-dash/skills/agent-baseline/SKILL.md +80 -0
  12. package/bizar-dash/skills/bizar/SKILL.md +96 -0
  13. package/bizar-dash/skills/chat/SKILL.md +74 -0
  14. package/bizar-dash/skills/lightrag/SKILL.md +75 -0
  15. package/bizar-dash/skills/minimax/SKILL.md +80 -0
  16. package/bizar-dash/skills/obsidian/SKILL.md +55 -0
  17. package/bizar-dash/skills/providers/SKILL.md +75 -0
  18. package/bizar-dash/skills/sdk/SKILL.md +138 -0
  19. package/bizar-dash/skills/self-improvement/SKILL.md +53 -0
  20. package/bizar-dash/skills/skills-cli/SKILL.md +94 -0
  21. package/bizar-dash/skills/usage/SKILL.md +62 -0
  22. package/bizar-dash/src/server/api.mjs +12 -0
  23. package/bizar-dash/src/server/memory-lightrag.mjs +5 -2
  24. package/bizar-dash/src/server/memory-store.mjs +38 -0
  25. package/bizar-dash/src/server/minimax-usage-store.mjs +372 -0
  26. package/bizar-dash/src/server/minimax.mjs +196 -5
  27. package/bizar-dash/src/server/providers-store.mjs +956 -0
  28. package/bizar-dash/src/server/routes/config.mjs +52 -1
  29. package/bizar-dash/src/server/routes/env-vars.mjs +165 -0
  30. package/bizar-dash/src/server/routes/lightrag.mjs +154 -0
  31. package/bizar-dash/src/server/routes/memory.mjs +241 -1
  32. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +14 -29
  33. package/bizar-dash/src/server/routes/opencode-sessions.mjs +205 -3
  34. package/bizar-dash/src/server/routes/providers.mjs +266 -5
  35. package/bizar-dash/src/server/routes/skills.mjs +32 -43
  36. package/bizar-dash/src/server/routes/update.mjs +340 -0
  37. package/bizar-dash/src/server/routes/usage.mjs +136 -0
  38. package/bizar-dash/src/server/serve-info.mjs +135 -4
  39. package/bizar-dash/src/server/server.mjs +4 -0
  40. package/bizar-dash/src/server/skills-store.mjs +152 -262
  41. package/bizar-dash/src/web/App.tsx +118 -29
  42. package/bizar-dash/src/web/components/EnvVarManager.tsx +247 -0
  43. package/bizar-dash/src/web/components/SettingsSearch.tsx +213 -0
  44. package/bizar-dash/src/web/components/Topbar.tsx +0 -1
  45. package/bizar-dash/src/web/components/UsageChart.tsx +250 -0
  46. package/bizar-dash/src/web/components/UsageTable.tsx +90 -0
  47. package/bizar-dash/src/web/components/chat/ChatComposer.tsx +21 -25
  48. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +199 -37
  49. package/bizar-dash/src/web/components/chat/ChatThread.tsx +29 -17
  50. package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +7 -1
  51. package/bizar-dash/src/web/components/chat/InfoPanel.tsx +71 -6
  52. package/bizar-dash/src/web/components/chat/useChat.ts +751 -257
  53. package/bizar-dash/src/web/lib/api.ts +43 -0
  54. package/bizar-dash/src/web/main.tsx +1 -0
  55. package/bizar-dash/src/web/mobile/views/MobileChat.tsx +110 -35
  56. package/bizar-dash/src/web/styles/chat.css +135 -1
  57. package/bizar-dash/src/web/styles/main.css +46 -0
  58. package/bizar-dash/src/web/styles/minimax-usage.css +335 -0
  59. package/bizar-dash/src/web/styles/settings.css +418 -0
  60. package/bizar-dash/src/web/styles/skills.css +302 -0
  61. package/bizar-dash/src/web/styles/tasks.css +288 -0
  62. package/bizar-dash/src/web/views/Chat.tsx +276 -48
  63. package/bizar-dash/src/web/views/Config.tsx +3 -2065
  64. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +476 -461
  65. package/bizar-dash/src/web/views/Settings.tsx +6 -0
  66. package/bizar-dash/src/web/views/Skills.tsx +208 -260
  67. package/bizar-dash/src/web/views/Tasks.tsx +348 -1119
  68. package/bizar-dash/tests/chat-session-create.test.mjs +391 -0
  69. package/bizar-dash/tests/chat-session-stream.test.mjs +308 -0
  70. package/bizar-dash/tests/env-vars-store.test.mjs +216 -0
  71. package/bizar-dash/tests/lightrag-defaults.node.test.mjs +118 -0
  72. package/bizar-dash/tests/minimax-chat-usage.test.mjs +178 -0
  73. package/bizar-dash/tests/minimax-usage-store.node.test.mjs +293 -0
  74. package/bizar-dash/tests/opencode-sessions-detail.test.mjs +12 -9
  75. package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +479 -3
  76. package/bizar-dash/tests/providers-store-search.node.test.mjs +166 -0
  77. package/bizar-dash/tests/skills-list.test.mjs +232 -0
  78. package/bizar-dash/tests/skills-search.test.mjs +222 -0
  79. package/bizar-dash/tests/tasks-create.test.mjs +187 -0
  80. package/bizar-dash/tests/update-check.test.mjs +127 -0
  81. package/bizar-dash/tests/update-run.test.mjs +266 -0
  82. package/cli/bin.mjs +82 -1
  83. package/cli/provision.mjs +118 -4
  84. package/config/agents/_shared/SKILLS.md +109 -0
  85. package/package.json +1 -1
  86. package/bizar-dash/dist/assets/main-BB5mJurD.js +0 -352
  87. package/bizar-dash/dist/assets/main-BB5mJurD.js.map +0 -1
  88. package/bizar-dash/dist/assets/main-BsnQLXdh.css +0 -1
  89. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js +0 -354
  90. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js.map +0 -1
@@ -1,2065 +1,3 @@
1
- // src/views/Config.tsx — v3.4.0: sidebar nav + fully editable providers/MCPs.
2
- import { useEffect, useMemo, useRef, useState } from 'react';
3
- import {
4
- Settings2,
5
- RefreshCw,
6
- Save,
7
- FileCode2,
8
- Stethoscope,
9
- Server as ServerIcon,
10
- Plug,
11
- Plus,
12
- Trash2,
13
- Pencil,
14
- Download,
15
- Database,
16
- History as HistoryIcon,
17
- X,
18
- ShieldCheck,
19
- AlertTriangle,
20
- ChevronDown,
21
- ChevronRight,
22
- } from 'lucide-react';
23
- import { Spinner } from '../components/Spinner';
24
- import { Button } from '../components/Button';
25
- import { Card, CardTitle, CardMeta } from '../components/Card';
26
- import { useToast } from '../components/Toast';
27
- import { useModal } from '../components/Modal';
28
- import { api } from '../lib/api';
29
- import { cn, debounce, hashText } from '../lib/utils';
30
- import type {
31
- ConfigResponse,
32
- Diagnostics,
33
- McpServer,
34
- Provider,
35
- Settings,
36
- Snapshot,
37
- } from '../lib/types';
38
-
39
- type LightragStatus = {
40
- running: boolean;
41
- pid: number | null;
42
- host: string;
43
- port: number;
44
- llmBinding: string;
45
- embeddingBinding: string;
46
- llmBindingHost: string;
47
- embeddingBindingHost: string;
48
- llmModel: string;
49
- embeddingModel: string;
50
- lastError: string | null;
51
- logTail: string[];
52
- };
53
-
54
- type Props = {
55
- snapshot: Snapshot;
56
- settings: Settings;
57
- activeTab: string;
58
- setActiveTab: (id: string) => void;
59
- refreshSnapshot: () => Promise<void>;
60
- };
61
-
62
- type NavId = 'opencode' | 'providers' | 'mcps' | 'diagnostics' | 'memory' | 'export';
63
-
64
- const NAV_ITEMS: { id: NavId; label: string; icon: typeof Settings2; desc: string }[] = [
65
- { id: 'opencode', label: 'OpenCode config', icon: FileCode2, desc: 'Edit opencode.json directly' },
66
- { id: 'providers', label: 'Providers', icon: ServerIcon, desc: 'AI providers + API keys' },
67
- { id: 'mcps', label: 'MCPs', icon: Plug, desc: 'Model Context Protocol servers' },
68
- { id: 'diagnostics', label: 'Diagnostics', icon: Stethoscope, desc: 'Service health + counts' },
69
- { id: 'memory', label: 'Memory & LightRAG', icon: Database, desc: 'Configure Bizar Memory Service and the LightRAG embedding server.' },
70
- { id: 'export', label: 'Export / Import', icon: Download, desc: 'Download diagnostics bundle' },
71
- ];
72
-
73
- export function Config({ snapshot, refreshSnapshot }: Props) {
74
- const toast = useToast();
75
- const modal = useModal();
76
- const initial: ConfigResponse | undefined = snapshot.config;
77
- const [activeNav, setActiveNav] = useState<NavId>('opencode');
78
-
79
- // OpenCode editor state
80
- const [original, setOriginal] = useState<string>(
81
- initial?.raw || (initial?.data ? JSON.stringify(initial.data, null, 2) : ''),
82
- );
83
- const [parsed, setParsed] = useState({
84
- raw: initial?.raw || '',
85
- data: initial?.data ?? null,
86
- error: null as string | null,
87
- });
88
- const [dirty, setDirty] = useState(false);
89
- const [saving, setSaving] = useState(false);
90
- const [path, setPath] = useState<string>(initial?.path || '');
91
- const taRef = useRef<HTMLTextAreaElement>(null);
92
-
93
- // Diagnostics state
94
- const [diagnostics, setDiagnostics] = useState<Diagnostics | null>(null);
95
-
96
- // Memory & LightRAG state
97
- const [lightragStatus, setLightragStatus] = useState<LightragStatus | null>(null);
98
- const [lightragLog, setLightragLog] = useState<string[]>([]);
99
-
100
- // Providers + MCPs state — re-fetch when nav switches so we always have fresh data.
101
- const [providers, setProviders] = useState<Provider[]>(snapshot.providers || []);
102
- const [mcps, setMcps] = useState<McpServer[]>(snapshot.mcps || []);
103
-
104
- const originalHash = useMemo(() => hashText(original), [original]);
105
-
106
- useEffect(() => {
107
- if (snapshot.providers) setProviders(snapshot.providers);
108
- if (snapshot.mcps) setMcps(snapshot.mcps);
109
- }, [snapshot.providers, snapshot.mcps]);
110
-
111
- const reloadConfig = async () => {
112
- try {
113
- const d = await api.post<ConfigResponse>('/config/reload');
114
- const raw = d.raw || (d.data ? JSON.stringify(d.data, null, 2) : '');
115
- setOriginal(raw);
116
- setParsed({ raw, data: d.data ?? null, error: null });
117
- setDirty(false);
118
- setPath(d.path || '');
119
- toast.info('Config reloaded.', 1500);
120
- } catch (err) {
121
- toast.error(`Reload failed: ${(err as Error).message}`);
122
- }
123
- };
124
-
125
- const reloadProviders = async () => {
126
- try {
127
- const r = await api.get<{ providers: Provider[] }>('/config/providers');
128
- setProviders(r.providers || []);
129
- } catch (err) {
130
- toast.error(`Providers load failed: ${(err as Error).message}`);
131
- }
132
- };
133
-
134
- const reloadMcps = async () => {
135
- try {
136
- const r = await api.get<{ mcps: McpServer[] }>('/config/mcps');
137
- setMcps(r.mcps || []);
138
- } catch (err) {
139
- toast.error(`MCPs load failed: ${(err as Error).message}`);
140
- }
141
- };
142
-
143
- // Reload data when nav changes so each panel shows fresh server data.
144
- useEffect(() => {
145
- if (activeNav === 'providers' && providers.length === 0) reloadProviders();
146
- if (activeNav === 'mcps' && mcps.length === 0) reloadMcps();
147
- if (activeNav === 'diagnostics' && !diagnostics) loadDiagnostics();
148
- if (activeNav === 'memory') loadLightragStatus();
149
- // eslint-disable-next-line react-hooks/exhaustive-deps
150
- }, [activeNav]);
151
-
152
- const applyEdit = (val: string) => {
153
- setParsed((cur) => {
154
- const next: typeof parsed = { raw: val, data: cur.data, error: null };
155
- try {
156
- next.data = JSON.parse(val);
157
- next.error = null;
158
- } catch (e) {
159
- next.data = null;
160
- next.error = (e as Error).message;
161
- }
162
- return next;
163
- });
164
- onChangeDebounced(val);
165
- };
166
-
167
- const onChangeDebounced = useMemo(
168
- () =>
169
- debounce((val: string) => {
170
- setDirty(hashText(val) !== originalHash);
171
- }, 100),
172
- [originalHash],
173
- );
174
-
175
- const save = async () => {
176
- if (!parsed.data || parsed.error || saving) return;
177
- setSaving(true);
178
- try {
179
- const result = await api.put<ConfigResponse>('/config', parsed.data);
180
- const raw = result.raw || JSON.stringify(result.data, null, 2);
181
- setOriginal(raw);
182
- setParsed({ raw, data: result.data ?? null, error: null });
183
- setDirty(false);
184
- toast.success('Config saved.');
185
- await refreshSnapshot();
186
- // Re-fetch providers + MCPs because config may have changed.
187
- await reloadProviders();
188
- await reloadMcps();
189
- } catch (err) {
190
- toast.error(`Save failed: ${(err as Error).message}`);
191
- } finally {
192
- setSaving(false);
193
- }
194
- };
195
-
196
- const loadDiagnostics = async () => {
197
- try {
198
- const d = await api.get<Diagnostics>('/diagnostics');
199
- setDiagnostics(d);
200
- } catch (err) {
201
- toast.error(`Diagnostics load failed: ${(err as Error).message}`);
202
- }
203
- };
204
-
205
- const loadLightragStatus = async () => {
206
- try {
207
- const d = await api.get<LightragStatus>('/memory/lightrag/status');
208
- setLightragStatus(d);
209
- } catch (err) {
210
- toast.error(`LightRAG status failed: ${(err as Error).message}`);
211
- }
212
- };
213
-
214
- const loadLightragLog = async () => {
215
- try {
216
- const d = await api.get<{ lines: string[] }>('/memory/lightrag/log');
217
- setLightragLog(d.lines || []);
218
- } catch {
219
- setLightragLog([]);
220
- }
221
- };
222
-
223
- const onDownloadDiagnostics = () => {
224
- const bundle = {
225
- generatedAt: new Date().toISOString(),
226
- diagnostics,
227
- config: parsed.data,
228
- opencodeJsonPath: path,
229
- providers,
230
- mcps,
231
- };
232
- const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' });
233
- const url = URL.createObjectURL(blob);
234
- const a = document.createElement('a');
235
- a.href = url;
236
- a.download = `bizar-diagnostics-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
237
- a.click();
238
- URL.revokeObjectURL(url);
239
- toast.success('Bundle downloaded.');
240
- };
241
-
242
- return (
243
- <div className="view view-config">
244
- <header className="view-header">
245
- <div className="view-header-text">
246
- <h2 className="view-title">
247
- <Settings2 size={18} /> Config
248
- </h2>
249
- <p className="view-subtitle">
250
- Manage OpenCode config, providers, MCPs, and diagnostics.
251
- </p>
252
- </div>
253
- </header>
254
-
255
- <div className="config-layout">
256
- <nav className="config-nav" role="navigation" aria-label="Config sections">
257
- {NAV_ITEMS.map((n) => {
258
- const Icon = n.icon;
259
- return (
260
- <button
261
- key={n.id}
262
- type="button"
263
- className={cn('config-nav-item', activeNav === n.id && 'config-nav-item-active')}
264
- onClick={() => setActiveNav(n.id)}
265
- aria-current={activeNav === n.id ? 'page' : undefined}
266
- >
267
- <Icon size={14} />
268
- <div style={{ display: 'flex', flexDirection: 'column', minWidth: 0, flex: 1 }}>
269
- <span>{n.label}</span>
270
- <span style={{ fontSize: 10, color: 'var(--text-dim)', fontWeight: 400 }}>
271
- {n.desc}
272
- </span>
273
- </div>
274
- </button>
275
- );
276
- })}
277
- </nav>
278
-
279
- <div className="config-content">
280
- {activeNav === 'opencode' && (
281
- <ConfigEditorPanel
282
- parsed={parsed}
283
- dirty={dirty}
284
- saving={saving}
285
- path={path}
286
- onReload={reloadConfig}
287
- onSave={save}
288
- onChange={applyEdit}
289
- textareaRef={taRef}
290
- />
291
- )}
292
-
293
- {activeNav === 'providers' && (
294
- <ProvidersPanel providers={providers} onChange={setProviders} onReload={reloadProviders} />
295
- )}
296
-
297
- {activeNav === 'mcps' && (
298
- <McpsPanel mcps={mcps} onChange={setMcps} onReload={reloadMcps} />
299
- )}
300
-
301
- {activeNav === 'diagnostics' && (
302
- <DiagnosticsPanel diagnostics={diagnostics} loading={!diagnostics} onReload={loadDiagnostics} />
303
- )}
304
-
305
- {activeNav === 'memory' && (
306
- <MemoryLightragPanel
307
- status={lightragStatus}
308
- onReload={loadLightragStatus}
309
- logLines={lightragLog}
310
- onReloadLog={loadLightragLog}
311
- />
312
- )}
313
-
314
- {activeNav === 'export' && (
315
- <ExportPanel onDownload={onDownloadDiagnostics} />
316
- )}
317
- </div>
318
- </div>
319
- </div>
320
- );
321
- }
322
-
323
- // ─── OpenCode config form (v3.5.2) ─────────────────────────────────
324
- // Structured UI for opencode.json sections: Model, Plugins, Tools,
325
- // Permissions, Hooks. Raw JSON available via "Advanced" toggle.
326
-
327
- type OcSection = 'model' | 'plugins' | 'tools' | 'permissions' | 'hooks' | 'advanced';
328
-
329
- const OC_SECTIONS: { id: OcSection; label: string }[] = [
330
- { id: 'model', label: 'Model' },
331
- { id: 'plugins', label: 'Plugins' },
332
- { id: 'tools', label: 'Tools' },
333
- { id: 'permissions', label: 'Permissions' },
334
- { id: 'hooks', label: 'Hooks' },
335
- { id: 'advanced', label: 'Advanced' },
336
- ];
337
-
338
- function ConfigEditorPanel({
339
- parsed,
340
- dirty,
341
- saving,
342
- path,
343
- onReload,
344
- onSave,
345
- onChange,
346
- textareaRef,
347
- }: {
348
- parsed: { raw: string; data: unknown; error: string | null };
349
- dirty: boolean;
350
- saving: boolean;
351
- path: string;
352
- onReload: () => void;
353
- onSave: () => void;
354
- onChange: (val: string) => void;
355
- textareaRef: React.RefObject<HTMLTextAreaElement>;
356
- }) {
357
- const [section, setSection] = useState<OcSection>('model');
358
-
359
- const cfg = parsed.data as Record<string, unknown> | null;
360
-
361
- const apply = (patch: Record<string, unknown>) => {
362
- if (!cfg) return;
363
- const next = { ...cfg, ...patch };
364
- onChange(JSON.stringify(next, null, 2));
365
- };
366
-
367
- return (
368
- <Card>
369
- <CardTitle>
370
- <FileCode2 size={14} /> OpenCode config
371
- </CardTitle>
372
- <CardMeta>
373
- <code className="mono ellipsis" style={{ fontSize: 11 }}>
374
- {path || '~/.config/opencode/opencode.json'}
375
- </code>
376
- </CardMeta>
377
- <div className="view-actions" style={{ marginBottom: 12 }}>
378
- <Button variant="secondary" size="sm" onClick={onReload}>
379
- <RefreshCw size={14} /> Reload
380
- </Button>
381
- <Button
382
- variant="primary"
383
- size="sm"
384
- disabled={!parsed.data || !!parsed.error || !dirty}
385
- onClick={onSave}
386
- >
387
- {saving ? <span className="btn-spinner" /> : <Save size={14} />}
388
- Save
389
- </Button>
390
- </div>
391
-
392
- {/* Section tabs */}
393
- <div className="config-section-tabs" style={{ display: 'flex', gap: 4, marginBottom: 16, flexWrap: 'wrap' }}>
394
- {OC_SECTIONS.map((s) => (
395
- <button
396
- key={s.id}
397
- type="button"
398
- className={cn('tab-btn', section === s.id && 'active')}
399
- onClick={() => setSection(s.id)}
400
- >
401
- {s.label}
402
- </button>
403
- ))}
404
- </div>
405
-
406
- {/* Model section */}
407
- {section === 'model' && (
408
- <ModelForm cfg={cfg} apply={apply} />
409
- )}
410
-
411
- {/* Plugins section */}
412
- {section === 'plugins' && (
413
- <PluginsForm cfg={cfg} apply={apply} />
414
- )}
415
-
416
- {/* Tools section */}
417
- {section === 'tools' && (
418
- <ToolsForm cfg={cfg} apply={apply} />
419
- )}
420
-
421
- {/* Permissions section */}
422
- {section === 'permissions' && (
423
- <PermissionsForm cfg={cfg} apply={apply} />
424
- )}
425
-
426
- {/* Hooks section */}
427
- {section === 'hooks' && (
428
- <HooksForm cfg={cfg} apply={apply} />
429
- )}
430
-
431
- {/* Advanced: raw JSON editor */}
432
- {section === 'advanced' && (
433
- <div>
434
- <div className="config-grid-label" style={{ marginBottom: 8 }}>
435
- {parsed.error ? (
436
- <span className="text-error">{parsed.error}</span>
437
- ) : (
438
- <span className="muted">Raw JSON — edit with care</span>
439
- )}
440
- </div>
441
- <textarea
442
- ref={textareaRef}
443
- className={cn('textarea config-textarea', parsed.error && 'invalid')}
444
- spellCheck={false}
445
- value={parsed.raw}
446
- onChange={(e) => onChange(e.target.value)}
447
- style={{ minHeight: 300 }}
448
- />
449
- </div>
450
- )}
451
- </Card>
452
- );
453
- }
454
-
455
- // ── Sub-forms ─────────────────────────────────────────────────────────
456
-
457
- function ModelForm({ cfg, apply }: { cfg: Record<string, unknown> | null; apply: (p: Record<string, unknown>) => void }) {
458
- const model = (cfg?.model || {}) as Record<string, unknown>;
459
- return (
460
- <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
461
- <p className="muted text-sm">Configure the default model used by OpenCode agents.</p>
462
- <div className="field-row">
463
- <label className="field-label">Provider</label>
464
- <input
465
- className="input"
466
- value={String(model.provider || '')}
467
- onChange={(e) => apply({ model: { ...model, provider: e.target.value } })}
468
- placeholder="e.g. openai, anthropic, google"
469
- />
470
- </div>
471
- <div className="field-row">
472
- <label className="field-label">Model ID</label>
473
- <input
474
- className="input"
475
- value={String(model.model || '')}
476
- onChange={(e) => apply({ model: { ...model, model: e.target.value } })}
477
- placeholder="e.g. gpt-4o, claude-sonnet-4-20250514"
478
- />
479
- </div>
480
- {!!model.apiKey && (
481
- <div className="field-row">
482
- <label className="field-label">API Key</label>
483
- <input
484
- className="input"
485
- type="password"
486
- value={String(model.apiKey || '')}
487
- onChange={(e) => apply({ model: { ...model, apiKey: e.target.value } })}
488
- placeholder="sk-..."
489
- />
490
- </div>
491
- )}
492
- {!!model.baseURL && (
493
- <div className="field-row">
494
- <label className="field-label">Base URL</label>
495
- <input
496
- className="input"
497
- value={String(model.baseURL || '')}
498
- onChange={(e) => apply({ model: { ...model, baseURL: e.target.value } })}
499
- placeholder="https://api.openai.com/v1"
500
- />
501
- </div>
502
- )}
503
- </div>
504
- );
505
- }
506
-
507
- function PluginsForm({ cfg, apply }: { cfg: Record<string, unknown> | null; apply: (p: Record<string, unknown>) => void }) {
508
- const plugins = Array.isArray(cfg?.plugins) ? (cfg.plugins as Record<string, unknown>[]) : [];
509
- const [editing, setEditing] = useState<number | null>(null);
510
- const [draft, setDraft] = useState<Record<string, unknown>>({});
511
-
512
- const addPlugin = () => {
513
- const next = [...plugins, { name: '', enabled: true }];
514
- apply({ plugins: next });
515
- };
516
-
517
- const removePlugin = (i: number) => {
518
- const next = plugins.filter((_, idx) => idx !== i);
519
- apply({ plugins: next });
520
- };
521
-
522
- const updatePlugin = (i: number, patch: Record<string, unknown>) => {
523
- const next = plugins.map((p, idx) => (idx === i ? { ...p, ...patch } : p));
524
- apply({ plugins: next });
525
- setEditing(null);
526
- };
527
-
528
- return (
529
- <div>
530
- <p className="muted text-sm" style={{ marginBottom: 12 }}>Manage enabled plugins.</p>
531
- <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
532
- {plugins.length === 0 && <p className="muted text-sm">No plugins configured.</p>}
533
- {plugins.map((p, i) => (
534
- <div key={i} className="config-list-row">
535
- {editing === i ? (
536
- <div style={{ display: 'flex', gap: 8, flex: 1, alignItems: 'center' }}>
537
- <input
538
- className="input"
539
- value={String(draft.name || '')}
540
- onChange={(e) => setDraft({ ...draft, name: e.target.value })}
541
- placeholder="plugin-name"
542
- />
543
- <label style={{ display: 'flex', gap: 4, fontSize: 12 }}>
544
- <input
545
- type="checkbox"
546
- checked={!!draft.enabled}
547
- onChange={(e) => setDraft({ ...draft, enabled: e.target.checked })}
548
- />
549
- enabled
550
- </label>
551
- <Button variant="primary" size="sm" onClick={() => updatePlugin(i, draft)}>Apply</Button>
552
- <Button variant="ghost" size="sm" onClick={() => setEditing(null)}><X size={12} /></Button>
553
- </div>
554
- ) : (
555
- <>
556
- <span style={{ fontFamily: 'var(--font-mono)', fontSize: 13 }}>
557
- {(p as { name?: string }).name || 'unnamed'}
558
- </span>
559
- <span className={cn('tag', (p as { enabled?: boolean }).enabled !== false ? 'tag-success' : 'tag-neutral')}>
560
- {(p as { enabled?: boolean }).enabled !== false ? 'enabled' : 'disabled'}
561
- </span>
562
- <div style={{ marginLeft: 'auto', display: 'flex', gap: 4 }}>
563
- <button type="button" className="icon-btn" onClick={() => { setEditing(i); setDraft(p as Record<string, unknown>); }} title="Edit">
564
- <Pencil size={12} />
565
- </button>
566
- <button type="button" className="icon-btn text-error" onClick={() => removePlugin(i)} title="Remove">
567
- <Trash2 size={12} />
568
- </button>
569
- </div>
570
- </>
571
- )}
572
- </div>
573
- ))}
574
- </div>
575
- <Button variant="secondary" size="sm" onClick={addPlugin} style={{ marginTop: 8 }}>
576
- <Plus size={12} /> Add plugin
577
- </Button>
578
- </div>
579
- );
580
- }
581
-
582
- function ToolsForm({ cfg, apply }: { cfg: Record<string, unknown> | null; apply: (p: Record<string, unknown>) => void }) {
583
- const tools = (cfg?.tools as Record<string, boolean> | null) || {};
584
-
585
- const toggle = (name: string) => {
586
- apply({ tools: { ...tools, [name]: !tools[name] } });
587
- };
588
-
589
- const addTool = (name: string) => {
590
- if (!name || tools[name] !== undefined) return;
591
- apply({ tools: { ...tools, [name]: true } });
592
- };
593
-
594
- const removeTool = (name: string) => {
595
- const next = { ...tools };
596
- delete next[name];
597
- apply({ tools: next });
598
- };
599
-
600
- return (
601
- <div>
602
- <p className="muted text-sm" style={{ marginBottom: 12 }}>Enable or disable built-in tools.</p>
603
- <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
604
- {Object.keys(tools).length === 0 && <p className="muted text-sm">No tools configured.</p>}
605
- {Object.entries(tools).map(([name, enabled]) => (
606
- <div key={name} className="config-list-row">
607
- <span style={{ fontFamily: 'var(--font-mono)', fontSize: 13 }}>{name}</span>
608
- <label style={{ display: 'flex', gap: 4, alignItems: 'center', fontSize: 12 }}>
609
- <input type="checkbox" checked={!!enabled} onChange={() => toggle(name)} />
610
- enabled
611
- </label>
612
- <button type="button" className="icon-btn text-error" style={{ marginLeft: 'auto' }} onClick={() => removeTool(name)} title="Remove">
613
- <Trash2 size={12} />
614
- </button>
615
- </div>
616
- ))}
617
- </div>
618
- <div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
619
- <input
620
- className="input"
621
- placeholder="tool-name"
622
- onKeyDown={(e) => {
623
- if (e.key === 'Enter') addTool((e.target as HTMLInputElement).value.trim());
624
- }}
625
- />
626
- <Button variant="secondary" size="sm"
627
- onClick={(e) => addTool((e.currentTarget.closest('div')?.querySelector('input') as HTMLInputElement)?.value.trim() || '')}
628
- >
629
- <Plus size={12} /> Add
630
- </Button>
631
- </div>
632
- </div>
633
- );
634
- }
635
-
636
- function PermissionsForm({ cfg, apply }: { cfg: Record<string, unknown> | null; apply: (p: Record<string, unknown>) => void }) {
637
- const permissions = (cfg?.permissions as Record<string, unknown> | null) || {};
638
- const [tab, setTab] = useState<'allow' | 'deny'>('allow');
639
- const allow = Array.isArray(permissions.allow) ? (permissions.allow as string[]) : [];
640
- const deny = Array.isArray(permissions.deny) ? (permissions.deny as string[]) : [];
641
-
642
- const addRule = (list: string[], key: 'allow' | 'deny') => (name: string) => {
643
- if (!name) return;
644
- apply({ permissions: { ...permissions, [key]: [...list, name] } });
645
- };
646
-
647
- const removeRule = (key: 'allow' | 'deny', list: string[], i: number) => {
648
- apply({ permissions: { ...permissions, [key]: list.filter((_, idx) => idx !== i) } });
649
- };
650
-
651
- const list = tab === 'allow' ? allow : deny;
652
-
653
- return (
654
- <div>
655
- <p className="muted text-sm" style={{ marginBottom: 12 }}>Allow or deny tool/scope rules.</p>
656
- <div style={{ display: 'flex', gap: 4, marginBottom: 12 }}>
657
- <button type="button" className={cn('tab-btn', tab === 'allow' && 'active')} onClick={() => setTab('allow')}>Allow ({allow.length})</button>
658
- <button type="button" className={cn('tab-btn', tab === 'deny' && 'active')} onClick={() => setTab('deny')}>Deny ({deny.length})</button>
659
- </div>
660
- <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
661
- {list.length === 0 && <p className="muted text-sm">No {tab} rules.</p>}
662
- {list.map((rule, i) => (
663
- <div key={i} className="config-list-row">
664
- <span style={{ fontFamily: 'var(--font-mono)', fontSize: 13 }}>{rule}</span>
665
- <button type="button" className="icon-btn text-error" style={{ marginLeft: 'auto' }} onClick={() => removeRule(tab, list, i)}>
666
- <Trash2 size={12} />
667
- </button>
668
- </div>
669
- ))}
670
- </div>
671
- <div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
672
- <input
673
- className="input"
674
- placeholder={`${tab} rule (e.g. tool:read, scope:filesystem)`}
675
- onKeyDown={(e) => {
676
- if (e.key === 'Enter') {
677
- const v = (e.target as HTMLInputElement).value.trim();
678
- if (v) { addRule(list, tab)(v); (e.target as HTMLInputElement).value = ''; }
679
- }
680
- }}
681
- />
682
- <Button variant="secondary" size="sm" onClick={(e) => {
683
- const inp = e.currentTarget.closest('div')?.querySelector('input') as HTMLInputElement;
684
- const v = inp?.value.trim();
685
- if (v) { addRule(list, tab)(v); inp.value = ''; }
686
- }}>
687
- <Plus size={12} /> Add
688
- </Button>
689
- </div>
690
- </div>
691
- );
692
- }
693
-
694
- function HooksForm({ cfg, apply }: { cfg: Record<string, unknown> | null; apply: (p: Record<string, unknown>) => void }) {
695
- const hooks = (cfg?.hooks as Record<string, unknown> | null) || {};
696
-
697
- const updateHook = (name: string, value: unknown) => {
698
- apply({ hooks: { ...hooks, [name]: value } });
699
- };
700
-
701
- return (
702
- <div>
703
- <p className="muted text-sm" style={{ marginBottom: 12 }}>Configure pre/post tool hooks.</p>
704
- {Object.keys(hooks).length === 0 && <p className="muted text-sm">No hooks configured.</p>}
705
- <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
706
- {(['preTool', 'postTool', 'preAgent', 'postAgent'] as const).map((hookName) => {
707
- const hook = hooks[hookName];
708
- return (
709
- <div key={hookName} className="field-row" style={{ alignItems: 'flex-start' }}>
710
- <label className="field-label" style={{ minWidth: 100 }}>{hookName}</label>
711
- <div style={{ display: 'flex', flexDirection: 'column', gap: 6, flex: 1 }}>
712
- <input
713
- className="input"
714
- value={typeof hook === 'object' && hook !== null && 'command' in hook ? String((hook as { command: string }).command || '') : ''}
715
- onChange={(e) => updateHook(hookName, { command: e.target.value })}
716
- placeholder={`${hookName} command (e.g. node /path/to/hook.js)`}
717
- />
718
- </div>
719
- </div>
720
- );
721
- })}
722
- </div>
723
- </div>
724
- );
725
- }
726
-
727
- function DiagnosticsPanel({
728
- diagnostics,
729
- loading,
730
- onReload,
731
- }: {
732
- diagnostics: Diagnostics | null;
733
- loading: boolean;
734
- onReload: () => void;
735
- }) {
736
- return (
737
- <Card>
738
- <CardTitle>
739
- <Stethoscope size={14} /> Diagnostics
740
- </CardTitle>
741
- <CardMeta>
742
- System health, file counts, recent errors.{' '}
743
- <button type="button" className="link-btn" onClick={onReload}>Refresh</button>
744
- </CardMeta>
745
- {loading && <p className="muted">Loading diagnostics…</p>}
746
- {diagnostics && (
747
- <>
748
- <div className="diagnostics-grid">
749
- <div className="diagnostic-tile">
750
- <div className="diagnostic-tile-label">Version</div>
751
- <div className="diagnostic-tile-value mono">{diagnostics.version}</div>
752
- </div>
753
- <div className="diagnostic-tile">
754
- <div className="diagnostic-tile-label">Uptime</div>
755
- <div className="diagnostic-tile-value mono">{diagnostics.uptime}s</div>
756
- </div>
757
- <div className="diagnostic-tile">
758
- <div className="diagnostic-tile-label">Node</div>
759
- <div className="diagnostic-tile-value mono">{diagnostics.nodeVersion}</div>
760
- </div>
761
- <div className="diagnostic-tile">
762
- <div className="diagnostic-tile-label">Platform</div>
763
- <div className="diagnostic-tile-value mono">{diagnostics.platform}</div>
764
- </div>
765
- <div className="diagnostic-tile">
766
- <div className="diagnostic-tile-label">Memory (heap)</div>
767
- <div className="diagnostic-tile-value mono">
768
- {Math.round(diagnostics.memory.heapUsed / 1024 / 1024)}MB
769
- </div>
770
- </div>
771
- <div className="diagnostic-tile">
772
- <div className="diagnostic-tile-label">Memory (RSS)</div>
773
- <div className="diagnostic-tile-value mono">
774
- {Math.round(diagnostics.memory.rss / 1024 / 1024)}MB
775
- </div>
776
- </div>
777
- <div className="diagnostic-tile">
778
- <div className="diagnostic-tile-label">Service</div>
779
- <div className="diagnostic-tile-value">
780
- {diagnostics.service.running ? (
781
- <span className="tag tag-success">running (pid {diagnostics.service.pid})</span>
782
- ) : (
783
- <span className="tag tag-neutral">stopped</span>
784
- )}
785
- </div>
786
- </div>
787
- <div className="diagnostic-tile">
788
- <div className="diagnostic-tile-label">Active project</div>
789
- <div className="diagnostic-tile-value mono">
790
- {diagnostics.counts.activeProject || '—'}
791
- </div>
792
- </div>
793
- </div>
794
- <div className="diagnostic-counts">
795
- <span>agents: <strong>{diagnostics.counts.agents}</strong></span>
796
- <span>projects: <strong>{diagnostics.counts.projects}</strong></span>
797
- <span>mods: <strong>{diagnostics.counts.mods}</strong></span>
798
- <span>schedules: <strong>{diagnostics.counts.schedules}</strong></span>
799
- <span>tasks: <strong>{diagnostics.counts.tasks}</strong></span>
800
- <span>providers: <strong>{diagnostics.counts.providers}</strong></span>
801
- <span>mcps: <strong>{diagnostics.counts.mcps}</strong></span>
802
- </div>
803
- <div className="diagnostic-errors">
804
- <div className="muted">Last {diagnostics.errors.length} errors from service.log</div>
805
- {diagnostics.errors.length === 0 ? (
806
- <p className="muted">No errors recorded.</p>
807
- ) : (
808
- <ul>
809
- {diagnostics.errors.map((e, i) => (
810
- <li key={i} className="mono">
811
- {e.ts && <span className="muted">[{e.ts}] </span>}
812
- {e.line}
813
- </li>
814
- ))}
815
- </ul>
816
- )}
817
- </div>
818
- </>
819
- )}
820
- </Card>
821
- );
822
- }
823
-
824
- /* ──────────────────────────────────────────────────────────────
825
- Providers Panel — fully editable with proper modal forms.
826
- ────────────────────────────────────────────────────────────── */
827
-
828
- type ProviderDraft = {
829
- id: string;
830
- name: string;
831
- baseURL: string;
832
- apiKey: string;
833
- models: string[];
834
- enabled: boolean;
835
- };
836
-
837
- function ProvidersPanel({
838
- providers,
839
- onChange,
840
- onReload,
841
- }: {
842
- providers: Provider[];
843
- onChange: (p: Provider[]) => void;
844
- onReload: () => Promise<void>;
845
- }) {
846
- const toast = useToast();
847
- const modal = useModal();
848
-
849
- const openProviderModal = (existing?: Provider) => {
850
- let idEl: HTMLInputElement | null = null;
851
- let nameEl: HTMLInputElement | null = null;
852
- let baseEl: HTMLInputElement | null = null;
853
- let keyEl: HTMLInputElement | null = null;
854
- let modelsEl: HTMLTextAreaElement | null = null;
855
- let enabledEl: HTMLInputElement | null = null;
856
-
857
- const isEdit = !!existing;
858
- const initialId = existing?.id || '';
859
- const initialName = existing?.name || '';
860
- const initialBase = existing?.baseURL || '';
861
- // Show masked key as-is in edit mode (so user can see what's stored).
862
- const initialKey = existing?.apiKey || '';
863
- const initialModels = (existing?.models || []).join(', ');
864
- const initialEnabled = existing?.enabled !== false;
865
-
866
- modal.open({
867
- title: isEdit ? `Edit provider "${existing!.id}"` : 'Add provider',
868
- children: (
869
- <div>
870
- <div className="modal-form-row">
871
- <label>ID (a-z, 0-9, dashes)</label>
872
- <input
873
- ref={(el) => (idEl = el)}
874
- className="input"
875
- type="text"
876
- defaultValue={initialId}
877
- placeholder="anthropic"
878
- disabled={isEdit}
879
- />
880
- </div>
881
- <div className="modal-form-row">
882
- <label>Display name</label>
883
- <input
884
- ref={(el) => (nameEl = el)}
885
- className="input"
886
- type="text"
887
- defaultValue={initialName}
888
- placeholder="Anthropic"
889
- />
890
- </div>
891
- <div className="modal-form-row">
892
- <label>Base URL</label>
893
- <input
894
- ref={(el) => (baseEl = el)}
895
- className="input"
896
- type="text"
897
- defaultValue={initialBase}
898
- placeholder="https://api.anthropic.com"
899
- />
900
- </div>
901
- <div className="modal-form-row">
902
- <label>API key{isEdit && ' (leave masked value unchanged to keep)'}</label>
903
- <input
904
- ref={(el) => (keyEl = el)}
905
- className="input"
906
- type="password"
907
- defaultValue={initialKey}
908
- placeholder="sk-..."
909
- autoComplete="off"
910
- />
911
- </div>
912
- <div className="modal-form-row">
913
- <label>Models (comma-separated)</label>
914
- <textarea
915
- ref={(el) => (modelsEl = el)}
916
- className="textarea"
917
- defaultValue={initialModels}
918
- placeholder="claude-sonnet-4-5, claude-opus-4-1"
919
- rows={3}
920
- />
921
- </div>
922
- <div className="modal-form-row">
923
- <label className="checkbox-row">
924
- <input
925
- ref={(el) => (enabledEl = el)}
926
- type="checkbox"
927
- defaultChecked={initialEnabled}
928
- />
929
- Enabled
930
- </label>
931
- </div>
932
- </div>
933
- ),
934
- footer: (
935
- <div className="modal-footer-actions">
936
- <Button variant="ghost" onClick={() => modal.close()}>Cancel</Button>
937
- <Button
938
- variant="primary"
939
- onClick={async () => {
940
- const id = (idEl?.value || '').trim();
941
- const name = (nameEl?.value || '').trim();
942
- const baseURL = (baseEl?.value || '').trim();
943
- const apiKey = keyEl?.value || '';
944
- const modelsRaw = modelsEl?.value || '';
945
- const models = modelsRaw.split(/[,\n]/).map((m) => m.trim()).filter(Boolean);
946
- const enabled = !!enabledEl?.checked;
947
-
948
- if (!id) {
949
- toast.warning('ID is required.');
950
- return;
951
- }
952
- if (!/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(id)) {
953
- toast.warning('ID must be a-z, 0-9, dashes.');
954
- return;
955
- }
956
- try {
957
- const payload: ProviderDraft & { id: string } = {
958
- id,
959
- name: name || id,
960
- baseURL,
961
- apiKey,
962
- models,
963
- enabled,
964
- };
965
- if (isEdit) {
966
- const updated = await api.put<Provider>(
967
- `/config/providers/${encodeURIComponent(id)}`,
968
- payload,
969
- );
970
- onChange(providers.map((p) => (p.id === id ? updated : p)));
971
- toast.success(`Provider "${id}" updated.`);
972
- } else {
973
- const created = await api.post<Provider>('/config/providers', payload);
974
- onChange([...providers, created]);
975
- toast.success(`Provider "${id}" added.`);
976
- }
977
- modal.close();
978
- } catch (err) {
979
- toast.error(`Save failed: ${(err as Error).message}`);
980
- }
981
- }}
982
- >
983
- {isEdit ? 'Update' : 'Add'} provider
984
- </Button>
985
- </div>
986
- ),
987
- });
988
- };
989
-
990
- const onRemove = async (id: string) => {
991
- if (!confirm(`Remove provider "${id}"?`)) return;
992
- try {
993
- await api.del(`/config/providers/${encodeURIComponent(id)}`);
994
- onChange(providers.filter((p) => p.id !== id));
995
- toast.success('Provider removed.');
996
- } catch (err) {
997
- toast.error(`Remove failed: ${(err as Error).message}`);
998
- }
999
- };
1000
-
1001
- return (
1002
- <Card>
1003
- <CardTitle>
1004
- <ServerIcon size={14} /> Providers ({providers.length})
1005
- </CardTitle>
1006
- <CardMeta>
1007
- AI providers configured under <code>opencode.json#provider</code>.
1008
- Each provider has a base URL, API key, and a list of model IDs.
1009
- </CardMeta>
1010
- <AutoDetectBanner onAdd={(id, name, baseURL, key) => {
1011
- openProviderModal({ id, name, baseURL, apiKey: key } as Provider);
1012
- }} />
1013
- <div className="view-actions" style={{ marginBottom: 12 }}>
1014
- <Button variant="primary" size="sm" onClick={() => openProviderModal()}>
1015
- <Plus size={14} /> Add provider
1016
- </Button>
1017
- <Button variant="ghost" size="sm" onClick={onReload}>
1018
- <RefreshCw size={14} /> Refresh
1019
- </Button>
1020
- </div>
1021
- {providers.length === 0 ? (
1022
- <EmptyProviders onAdd={() => openProviderModal()} />
1023
- ) : (
1024
- <div className="provider-list">
1025
- {providers.map((p) => (
1026
- <Card key={p.id} className="provider-row">
1027
- <div className="provider-row-head">
1028
- <div style={{ minWidth: 0, flex: 1 }}>
1029
- <div className="provider-name">{p.name || p.id}</div>
1030
- <div className="provider-id">{p.id}</div>
1031
- </div>
1032
- <div className="provider-actions">
1033
- <label className="toggle-row" title="Enabled">
1034
- <input
1035
- type="checkbox"
1036
- checked={p.enabled !== false}
1037
- onChange={async (e) => {
1038
- try {
1039
- const updated = await api.put<Provider>(
1040
- `/config/providers/${encodeURIComponent(p.id)}`,
1041
- { enabled: e.target.checked },
1042
- );
1043
- onChange(providers.map((x) => (x.id === p.id ? updated : x)));
1044
- } catch (err) {
1045
- toast.error(`Toggle failed: ${(err as Error).message}`);
1046
- }
1047
- }}
1048
- />
1049
- {p.enabled !== false ? 'on' : 'off'}
1050
- </label>
1051
- <button
1052
- type="button"
1053
- className="icon-btn"
1054
- aria-label="Edit"
1055
- title="Edit"
1056
- onClick={() => openProviderModal(p)}
1057
- >
1058
- <Pencil size={12} />
1059
- </button>
1060
- <button
1061
- type="button"
1062
- className="icon-btn icon-btn-danger"
1063
- aria-label="Remove"
1064
- title="Remove"
1065
- onClick={() => onRemove(p.id)}
1066
- >
1067
- <Trash2 size={12} />
1068
- </button>
1069
- </div>
1070
- </div>
1071
- <div className="provider-meta">
1072
- <div><span className="muted">Base URL:</span> <code>{p.baseURL || '—'}</code></div>
1073
- <div><span className="muted">API key:</span> <code>{p.apiKey || '—'}</code></div>
1074
- <div><span className="muted">Models:</span> {(p.models || []).join(', ') || '—'}</div>
1075
- </div>
1076
- </Card>
1077
- ))}
1078
- </div>
1079
- )}
1080
- </Card>
1081
- );
1082
- }
1083
-
1084
- function EmptyProviders({ onAdd }: { onAdd: () => void }) {
1085
- return (
1086
- <div style={{
1087
- padding: '32px 16px',
1088
- textAlign: 'center',
1089
- color: 'var(--text-dim)',
1090
- border: '1px dashed var(--border)',
1091
- borderRadius: 'var(--radius-md)',
1092
- display: 'flex',
1093
- flexDirection: 'column',
1094
- gap: 8,
1095
- alignItems: 'center',
1096
- }}>
1097
- <ServerIcon size={28} />
1098
- <div>No providers configured.</div>
1099
- <div style={{ fontSize: 12 }}>Add a provider to connect an AI model.</div>
1100
- <Button variant="primary" size="sm" onClick={onAdd}>
1101
- <Plus size={14} /> Add your first provider
1102
- </Button>
1103
- </div>
1104
- );
1105
- }
1106
-
1107
- /* ──────────────────────────────────────────────────────────────
1108
- MCPs Panel — fully editable with proper modal forms.
1109
- ────────────────────────────────────────────────────────────── */
1110
-
1111
- type McpDraft = {
1112
- id: string;
1113
- type: 'local' | 'remote';
1114
- command: string;
1115
- args: string[];
1116
- env: Record<string, string>;
1117
- url: string;
1118
- headers: Record<string, string>;
1119
- oauth: boolean;
1120
- enabled: boolean;
1121
- };
1122
-
1123
- function parseEnv(s: string): Record<string, string> {
1124
- const out: Record<string, string> = {};
1125
- for (const line of s.split(/\r?\n/)) {
1126
- const trimmed = line.trim();
1127
- if (!trimmed || trimmed.startsWith('#')) continue;
1128
- const eq = trimmed.indexOf('=');
1129
- if (eq <= 0) continue;
1130
- const k = trimmed.slice(0, eq).trim();
1131
- const v = trimmed.slice(eq + 1).trim();
1132
- if (k) out[k] = v;
1133
- }
1134
- return out;
1135
- }
1136
-
1137
- function parseHeaders(s: string): Record<string, string> {
1138
- const out: Record<string, string> = {};
1139
- for (const line of s.split(/\r?\n/)) {
1140
- const trimmed = line.trim();
1141
- if (!trimmed || trimmed.startsWith('#')) continue;
1142
- const sep = trimmed.indexOf(':');
1143
- if (sep <= 0) continue;
1144
- const k = trimmed.slice(0, sep).trim();
1145
- const v = trimmed.slice(sep + 1).trim();
1146
- if (k) out[k] = v;
1147
- }
1148
- return out;
1149
- }
1150
-
1151
- function McpsPanel({
1152
- mcps,
1153
- onChange,
1154
- onReload,
1155
- }: {
1156
- mcps: McpServer[];
1157
- onChange: (m: McpServer[]) => void;
1158
- onReload: () => Promise<void>;
1159
- }) {
1160
- const toast = useToast();
1161
- const modal = useModal();
1162
-
1163
- const openMcpModal = (existing?: McpServer) => {
1164
- let idEl: HTMLInputElement | null = null;
1165
- let typeLocalEl: HTMLInputElement | null = null;
1166
- let typeRemoteEl: HTMLInputElement | null = null;
1167
- let commandEl: HTMLInputElement | null = null;
1168
- let argsEl: HTMLInputElement | null = null;
1169
- let envEl: HTMLTextAreaElement | null = null;
1170
- let urlEl: HTMLInputElement | null = null;
1171
- let headersEl: HTMLTextAreaElement | null = null;
1172
- let oauthEl: HTMLInputElement | null = null;
1173
- let enabledEl: HTMLInputElement | null = null;
1174
-
1175
- const isEdit = !!existing;
1176
- const isRemote = existing?.type === 'remote' || (!existing && false);
1177
- const initialType: 'local' | 'remote' = existing?.type === 'remote' ? 'remote' : 'local';
1178
- const initialCommand = existing?.command || '';
1179
- const initialArgs = (existing?.args || []).join(' ');
1180
- const initialEnv = existing?.env ? Object.entries(existing.env).map(([k, v]) => `${k}=${v}`).join('\n') : '';
1181
- const initialUrl = existing?.url || '';
1182
- const initialHeaders = existing?.headers ? Object.entries(existing.headers).map(([k, v]) => `${k}: ${v}`).join('\n') : '';
1183
- const initialOauth = !!existing?.oauth;
1184
- const initialEnabled = existing?.enabled !== false;
1185
-
1186
- modal.open({
1187
- title: isEdit ? `Edit MCP "${existing!.id}"` : 'Add MCP',
1188
- children: (
1189
- <div>
1190
- <div className="modal-form-row">
1191
- <label>ID (a-z, 0-9, dashes)</label>
1192
- <input
1193
- ref={(el) => (idEl = el)}
1194
- className="input"
1195
- type="text"
1196
- defaultValue={existing?.id || ''}
1197
- placeholder="supabase"
1198
- disabled={isEdit}
1199
- />
1200
- </div>
1201
- <div className="modal-form-row">
1202
- <label>Type</label>
1203
- <div style={{ display: 'flex', gap: 12 }}>
1204
- <label className="radio-label">
1205
- <input
1206
- ref={(el) => (typeLocalEl = el)}
1207
- type="radio"
1208
- name="mcp-type"
1209
- value="local"
1210
- defaultChecked={initialType === 'local'}
1211
- />
1212
- Local (stdio)
1213
- </label>
1214
- <label className="radio-label">
1215
- <input
1216
- ref={(el) => (typeRemoteEl = el)}
1217
- type="radio"
1218
- name="mcp-type"
1219
- value="remote"
1220
- defaultChecked={initialType === 'remote'}
1221
- />
1222
- Remote (HTTP)
1223
- </label>
1224
- </div>
1225
- </div>
1226
-
1227
- <div data-mcp-section="local">
1228
- <div className="modal-form-row">
1229
- <label>Command (binary to invoke)</label>
1230
- <input
1231
- ref={(el) => (commandEl = el)}
1232
- className="input"
1233
- type="text"
1234
- defaultValue={initialCommand}
1235
- placeholder="uvx --from semble[mcp] semble"
1236
- />
1237
- </div>
1238
- <div className="modal-form-row">
1239
- <label>Extra args (space-separated)</label>
1240
- <input
1241
- ref={(el) => (argsEl = el)}
1242
- className="input"
1243
- type="text"
1244
- defaultValue={initialArgs}
1245
- placeholder="--port 8080"
1246
- />
1247
- <div className="field-help">These are appended after the command.</div>
1248
- </div>
1249
- <div className="modal-form-row">
1250
- <label>Environment variables</label>
1251
- <textarea
1252
- ref={(el) => (envEl = el)}
1253
- className="textarea"
1254
- defaultValue={initialEnv}
1255
- placeholder="API_KEY=xxx&#10;DEBUG=true"
1256
- rows={3}
1257
- />
1258
- <div className="field-help">One KEY=VALUE per line.</div>
1259
- </div>
1260
- </div>
1261
-
1262
- <div data-mcp-section="remote">
1263
- <div className="modal-form-row">
1264
- <label>URL</label>
1265
- <input
1266
- ref={(el) => (urlEl = el)}
1267
- className="input"
1268
- type="text"
1269
- defaultValue={initialUrl}
1270
- placeholder="https://mcp.example.com/mcp"
1271
- />
1272
- </div>
1273
- <div className="modal-form-row">
1274
- <label>Headers</label>
1275
- <textarea
1276
- ref={(el) => (headersEl = el)}
1277
- className="textarea"
1278
- defaultValue={initialHeaders}
1279
- placeholder="Authorization: Bearer xxx&#10;Content-Type: application/json"
1280
- rows={3}
1281
- />
1282
- <div className="field-help">One "Key: Value" per line.</div>
1283
- </div>
1284
- <div className="modal-form-row">
1285
- <label className="checkbox-row">
1286
- <input
1287
- ref={(el) => (oauthEl = el)}
1288
- type="checkbox"
1289
- defaultChecked={initialOauth}
1290
- />
1291
- Use OAuth (browser-based auth flow)
1292
- </label>
1293
- </div>
1294
- </div>
1295
-
1296
- <div className="modal-form-row">
1297
- <label className="checkbox-row">
1298
- <input
1299
- ref={(el) => (enabledEl = el)}
1300
- type="checkbox"
1301
- defaultChecked={initialEnabled}
1302
- />
1303
- Enabled
1304
- </label>
1305
- </div>
1306
- </div>
1307
- ),
1308
- footer: (
1309
- <div className="modal-footer-actions">
1310
- <Button variant="ghost" onClick={() => modal.close()}>Cancel</Button>
1311
- <Button
1312
- variant="primary"
1313
- onClick={async () => {
1314
- const id = (idEl?.value || '').trim();
1315
- const isRemote = !!typeRemoteEl?.checked;
1316
- const command = (commandEl?.value || '').trim();
1317
- const argsStr = (argsEl?.value || '').trim();
1318
- const args = argsStr ? argsStr.split(/\s+/) : [];
1319
- const env = parseEnv(envEl?.value || '');
1320
- const url = (urlEl?.value || '').trim();
1321
- const headers = parseHeaders(headersEl?.value || '');
1322
- const oauth = !!oauthEl?.checked;
1323
- const enabled = !!enabledEl?.checked;
1324
-
1325
- if (!id) {
1326
- toast.warning('ID is required.');
1327
- return;
1328
- }
1329
- if (!/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(id)) {
1330
- toast.warning('ID must be a-z, 0-9, dashes.');
1331
- return;
1332
- }
1333
- try {
1334
- const payload: McpDraft = {
1335
- id,
1336
- type: isRemote ? 'remote' : 'local',
1337
- command,
1338
- args,
1339
- env,
1340
- url,
1341
- headers,
1342
- oauth,
1343
- enabled,
1344
- };
1345
- if (isEdit) {
1346
- const updated = await api.put<McpServer>(
1347
- `/config/mcps/${encodeURIComponent(id)}`,
1348
- payload,
1349
- );
1350
- onChange(mcps.map((m) => (m.id === id ? updated : m)));
1351
- toast.success(`MCP "${id}" updated.`);
1352
- } else {
1353
- const created = await api.post<McpServer>('/config/mcps', payload);
1354
- onChange([...mcps, created]);
1355
- toast.success(`MCP "${id}" added.`);
1356
- }
1357
- modal.close();
1358
- } catch (err) {
1359
- toast.error(`Save failed: ${(err as Error).message}`);
1360
- }
1361
- }}
1362
- >
1363
- {isEdit ? 'Update' : 'Add'} MCP
1364
- </Button>
1365
- </div>
1366
- ),
1367
- });
1368
- };
1369
-
1370
- const onRemove = async (id: string) => {
1371
- if (!confirm(`Remove MCP "${id}"?`)) return;
1372
- try {
1373
- await api.del(`/config/mcps/${encodeURIComponent(id)}`);
1374
- onChange(mcps.filter((m) => m.id !== id));
1375
- toast.success('MCP removed.');
1376
- } catch (err) {
1377
- toast.error(`Remove failed: ${(err as Error).message}`);
1378
- }
1379
- };
1380
-
1381
- return (
1382
- <Card>
1383
- <CardTitle>
1384
- <Plug size={14} /> MCPs ({mcps.length})
1385
- </CardTitle>
1386
- <CardMeta>
1387
- Model Context Protocol servers under <code>opencode.json#mcp</code>.
1388
- Local MCPs run as stdio subprocesses; remote MCPs are HTTP endpoints.
1389
- </CardMeta>
1390
- <div className="view-actions" style={{ marginBottom: 12 }}>
1391
- <Button variant="primary" size="sm" onClick={() => openMcpModal()}>
1392
- <Plus size={14} /> Add MCP
1393
- </Button>
1394
- <Button variant="ghost" size="sm" onClick={onReload}>
1395
- <RefreshCw size={14} /> Refresh
1396
- </Button>
1397
- </div>
1398
- {mcps.length === 0 ? (
1399
- <div style={{
1400
- padding: '32px 16px',
1401
- textAlign: 'center',
1402
- color: 'var(--text-dim)',
1403
- border: '1px dashed var(--border)',
1404
- borderRadius: 'var(--radius-md)',
1405
- display: 'flex',
1406
- flexDirection: 'column',
1407
- gap: 8,
1408
- alignItems: 'center',
1409
- }}>
1410
- <Plug size={28} />
1411
- <div>No MCPs configured.</div>
1412
- <Button variant="primary" size="sm" onClick={() => openMcpModal()}>
1413
- <Plus size={14} /> Add your first MCP
1414
- </Button>
1415
- </div>
1416
- ) : (
1417
- <div className="mcp-list">
1418
- {mcps.map((m) => (
1419
- <Card key={m.id} className="mcp-row">
1420
- <div className="mcp-row-head">
1421
- <div style={{ minWidth: 0, flex: 1 }}>
1422
- <div className="mcp-name">{m.id}</div>
1423
- <div className="mcp-id">
1424
- {m.type === 'remote' ? 'remote' : 'local'} {m.enabled !== false ? '· enabled' : '· disabled'}
1425
- </div>
1426
- </div>
1427
- <div className="mcp-actions">
1428
- <label className="toggle-row" title="Enabled">
1429
- <input
1430
- type="checkbox"
1431
- checked={m.enabled !== false}
1432
- onChange={async (e) => {
1433
- try {
1434
- const updated = await api.put<McpServer>(
1435
- `/config/mcps/${encodeURIComponent(m.id)}`,
1436
- { enabled: e.target.checked },
1437
- );
1438
- onChange(mcps.map((x) => (x.id === m.id ? updated : x)));
1439
- } catch (err) {
1440
- toast.error(`Toggle failed: ${(err as Error).message}`);
1441
- }
1442
- }}
1443
- />
1444
- {m.enabled !== false ? 'on' : 'off'}
1445
- </label>
1446
- <button
1447
- type="button"
1448
- className="icon-btn"
1449
- aria-label="Edit"
1450
- title="Edit"
1451
- onClick={() => openMcpModal(m)}
1452
- >
1453
- <Pencil size={12} />
1454
- </button>
1455
- <button
1456
- type="button"
1457
- className="icon-btn icon-btn-danger"
1458
- aria-label="Remove"
1459
- title="Remove"
1460
- onClick={() => onRemove(m.id)}
1461
- >
1462
- <Trash2 size={12} />
1463
- </button>
1464
- </div>
1465
- </div>
1466
- <div className="mcp-meta">
1467
- {m.type === 'remote' ? (
1468
- <>
1469
- <div><span className="muted">URL:</span> <code>{m.url || '—'}</code></div>
1470
- {Object.keys(m.headers || {}).length > 0 && (
1471
- <div><span className="muted">Headers:</span> <code>{Object.keys(m.headers || {}).length} header(s)</code></div>
1472
- )}
1473
- {m.oauth && <div><span className="muted">Auth:</span> <code>OAuth</code></div>}
1474
- </>
1475
- ) : (
1476
- <>
1477
- <div><span className="muted">Command:</span> <code>{m.command || '—'}</code></div>
1478
- {m.args && m.args.length > 0 && (
1479
- <div><span className="muted">Args:</span> <code>{m.args.join(' ')}</code></div>
1480
- )}
1481
- {m.env && Object.keys(m.env).length > 0 && (
1482
- <div><span className="muted">Env:</span> <code>{Object.keys(m.env).length} var(s)</code></div>
1483
- )}
1484
- </>
1485
- )}
1486
- </div>
1487
- </Card>
1488
- ))}
1489
- </div>
1490
- )}
1491
- </Card>
1492
- );
1493
- }
1494
-
1495
- function ExportPanel({ onDownload }: { onDownload: () => void }) {
1496
- return (
1497
- <Card>
1498
- <CardTitle>
1499
- <Download size={14} /> Export / Import
1500
- </CardTitle>
1501
- <CardMeta>
1502
- Download a JSON bundle of diagnostics, configuration, providers, and MCPs
1503
- for support requests or backup. Server-side imports are not yet implemented.
1504
- </CardMeta>
1505
- <div className="view-actions" style={{ marginTop: 12 }}>
1506
- <Button variant="primary" onClick={onDownload}>
1507
- <Download size={14} /> Download diagnostics bundle
1508
- </Button>
1509
- </div>
1510
- <div style={{ marginTop: 16, fontSize: 12, color: 'var(--text-dim)' }}>
1511
- <Database size={12} style={{ display: 'inline', verticalAlign: -2 }} /> Bundles include:
1512
- opencode.json snapshot, providers list (with masked API keys), MCP list, recent
1513
- service log errors, and active project metadata.
1514
- </div>
1515
- </Card>
1516
- );
1517
- }
1518
-
1519
- /* ──────────────────────────────────────────────────────────────
1520
- Memory & LightRAG Panel
1521
- ────────────────────────────────────────────────────────────── */
1522
-
1523
- type LightragDraft = {
1524
- enabled: boolean;
1525
- host: string;
1526
- port: number | '';
1527
- workingDir: string;
1528
- llmBinding: string;
1529
- embeddingBinding: string;
1530
- llmBindingHost: string;
1531
- embeddingBindingHost: string;
1532
- llmModel: string;
1533
- embeddingModel: string;
1534
- apiKeySource: 'env' | 'file';
1535
- apiKey: string;
1536
- };
1537
-
1538
- const LLM_BINDING_OPTIONS = ['ollama', 'openai', 'lollms', 'azure_openai', 'bedrock', 'gemini'];
1539
- const EMBEDDING_BINDING_OPTIONS = ['ollama', 'openai', 'azure_openai', 'bedrock', 'jina', 'gemini', 'voyageai'];
1540
-
1541
- function MemoryLightragPanel({
1542
- status,
1543
- onReload,
1544
- logLines,
1545
- onReloadLog,
1546
- }: {
1547
- status: LightragStatus | null;
1548
- onReload: () => void;
1549
- logLines: string[];
1550
- onReloadLog: () => void;
1551
- }) {
1552
- const toast = useToast();
1553
- const [saving, setSaving] = useState(false);
1554
- const [starting, setStarting] = useState(false);
1555
- const [stopping, setStopping] = useState(false);
1556
- const [loadingLog, setLoadingLog] = useState(false);
1557
- const [showLog, setShowLog] = useState(false);
1558
-
1559
- // Draft form state — initialise from status once loaded
1560
- const [draft, setDraft] = useState<LightragDraft>({
1561
- enabled: true,
1562
- host: '127.0.0.1',
1563
- port: 9621,
1564
- workingDir: '',
1565
- llmBinding: 'ollama',
1566
- embeddingBinding: 'ollama',
1567
- llmBindingHost: '',
1568
- embeddingBindingHost: '',
1569
- llmModel: 'minimax/MiniMax-M3',
1570
- embeddingModel: 'text-embedding-3-small',
1571
- apiKeySource: 'env',
1572
- apiKey: '',
1573
- });
1574
- const [dirty, setDirty] = useState(false);
1575
-
1576
- // Sync draft from status when status loads
1577
- useEffect(() => {
1578
- if (!status) return;
1579
- setDraft((d) => ({
1580
- ...d,
1581
- enabled: true,
1582
- host: status.host,
1583
- port: status.port,
1584
- llmBinding: status.llmBinding,
1585
- embeddingBinding: status.embeddingBinding,
1586
- llmBindingHost: status.llmBindingHost || '',
1587
- embeddingBindingHost: status.embeddingBindingHost || '',
1588
- llmModel: status.llmModel,
1589
- embeddingModel: status.embeddingModel,
1590
- apiKeySource: 'env',
1591
- apiKey: '',
1592
- }));
1593
- setDirty(false);
1594
- // eslint-disable-next-line react-hooks/exhaustive-deps
1595
- }, [status?.running]);
1596
-
1597
- const set = <K extends keyof LightragDraft>(key: K, value: LightragDraft[K]) => {
1598
- setDraft((d) => ({ ...d, [key]: value }));
1599
- setDirty(true);
1600
- };
1601
-
1602
- const handleSave = async () => {
1603
- if (saving) return;
1604
- setSaving(true);
1605
- try {
1606
- // Build patch payload
1607
- const patch: Record<string, unknown> = {
1608
- enabled: draft.enabled,
1609
- host: draft.host,
1610
- port: draft.port === '' ? 9621 : Number(draft.port),
1611
- workingDir: draft.workingDir,
1612
- llmBinding: draft.llmBinding,
1613
- embeddingBinding: draft.embeddingBinding,
1614
- llmBindingHost: draft.llmBindingHost || null,
1615
- embeddingBindingHost: draft.embeddingBindingHost || null,
1616
- llmModel: draft.llmModel,
1617
- embeddingModel: draft.embeddingModel,
1618
- apiKeySource: draft.apiKeySource,
1619
- };
1620
- if (draft.apiKeySource === 'file' && draft.apiKey !== '') {
1621
- patch.apiKey = draft.apiKey;
1622
- }
1623
- if (draft.apiKeySource === 'env') {
1624
- patch.apiKey = '<empty>';
1625
- }
1626
-
1627
- await api.post('/memory/config', { patch: true, lightrag: patch });
1628
- toast.success('LightRAG config saved.');
1629
- setDirty(false);
1630
- onReload();
1631
- } catch (err) {
1632
- toast.error(`Save failed: ${(err as Error).message}`);
1633
- } finally {
1634
- setSaving(false);
1635
- }
1636
- };
1637
-
1638
- const handleStart = async () => {
1639
- if (starting) return;
1640
- setStarting(true);
1641
- try {
1642
- const r = await api.post<{ ok: boolean; error?: string; pid?: number }>('/memory/lightrag/start');
1643
- if (r.ok) {
1644
- toast.success(`LightRAG started (pid ${r.pid}).`);
1645
- } else {
1646
- toast.error(`Start failed: ${r.error}`);
1647
- }
1648
- onReload();
1649
- } catch (err) {
1650
- toast.error(`Start failed: ${(err as Error).message}`);
1651
- } finally {
1652
- setStarting(false);
1653
- }
1654
- };
1655
-
1656
- const handleStop = async () => {
1657
- if (stopping) return;
1658
- setStopping(true);
1659
- try {
1660
- await api.post('/memory/lightrag/stop');
1661
- toast.success('LightRAG stopped.');
1662
- onReload();
1663
- } catch (err) {
1664
- toast.error(`Stop failed: ${(err as Error).message}`);
1665
- } finally {
1666
- setStopping(false);
1667
- }
1668
- };
1669
-
1670
- const handleRestart = async () => {
1671
- await handleStop();
1672
- await handleStart();
1673
- };
1674
-
1675
- const handleShowLog = async () => {
1676
- if (!showLog) {
1677
- setLoadingLog(true);
1678
- await onReloadLog();
1679
- setLoadingLog(false);
1680
- }
1681
- setShowLog((v) => !v);
1682
- };
1683
-
1684
- const running = status?.running ?? false;
1685
-
1686
- return (
1687
- <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
1688
- {/* Status card */}
1689
- <Card>
1690
- <CardTitle>
1691
- <Database size={14} /> LightRAG Server
1692
- </CardTitle>
1693
- <CardMeta>
1694
- In-process embedding server for semantic memory search.{' '}
1695
- <button type="button" className="link-btn" onClick={onReload}>Refresh</button>
1696
- </CardMeta>
1697
- <div style={{ display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap', alignItems: 'center' }}>
1698
- {running ? (
1699
- <span className="tag tag-success">
1700
- running {status?.pid ? `(pid ${status.pid})` : ''}
1701
- </span>
1702
- ) : (
1703
- <span className="tag tag-neutral">stopped</span>
1704
- )}
1705
- {status && (
1706
- <span className="mono text-sm muted">
1707
- {status.host}:{status.port} · {status.llmBinding} · {status.embeddingModel}
1708
- </span>
1709
- )}
1710
- {status?.lastError && (
1711
- <span className="text-error text-sm">⚠ {status.lastError}</span>
1712
- )}
1713
- </div>
1714
- <div style={{ display: 'flex', gap: 8, marginTop: 12, flexWrap: 'wrap' }}>
1715
- {!running ? (
1716
- <Button variant="primary" size="sm" onClick={handleStart} disabled={starting}>
1717
- {starting ? <Spinner size="sm" /> : <RefreshCw size={12} />}
1718
- {starting ? 'Starting…' : 'Start'}
1719
- </Button>
1720
- ) : (
1721
- <>
1722
- <Button variant="secondary" size="sm" onClick={handleRestart} disabled={stopping || starting}>
1723
- <RefreshCw size={12} /> Restart
1724
- </Button>
1725
- <Button variant="ghost" size="sm" onClick={handleStop} disabled={stopping}>
1726
- {stopping ? <Spinner size="sm" /> : null}
1727
- {stopping ? 'Stopping…' : 'Stop'}
1728
- </Button>
1729
- </>
1730
- )}
1731
- <Button variant="ghost" size="sm" onClick={handleShowLog}>
1732
- {showLog ? 'Hide' : 'Show'} log
1733
- </Button>
1734
- </div>
1735
- {showLog && (
1736
- <div style={{ marginTop: 12 }}>
1737
- <div className="field-help" style={{ marginBottom: 6 }}>
1738
- Last {logLines.length} line(s)
1739
- <button type="button" className="link-btn" style={{ marginLeft: 8 }} onClick={onReloadLog}>
1740
- Refresh
1741
- </button>
1742
- </div>
1743
- {loadingLog ? (
1744
- <p className="muted text-sm">Loading…</p>
1745
- ) : logLines.length === 0 ? (
1746
- <p className="muted text-sm">No log output yet.</p>
1747
- ) : (
1748
- <pre className="log-pre">{logLines.join('\n')}</pre>
1749
- )}
1750
- </div>
1751
- )}
1752
- </Card>
1753
-
1754
- {/* Binding card */}
1755
- <Card>
1756
- <CardTitle>
1757
- <ShieldCheck size={14} /> Bindings
1758
- </CardTitle>
1759
- <CardMeta>Choose the LLM and embedding provider bindings.</CardMeta>
1760
- <div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 12 }}>
1761
- <div className="field-row">
1762
- <label className="field-label">LLM Binding</label>
1763
- <select
1764
- className="input"
1765
- value={draft.llmBinding}
1766
- onChange={(e) => set('llmBinding', e.target.value)}
1767
- >
1768
- {LLM_BINDING_OPTIONS.map((b) => (
1769
- <option key={b} value={b}>{b}</option>
1770
- ))}
1771
- </select>
1772
- </div>
1773
- <div className="field-row">
1774
- <label className="field-label">Embedding Binding</label>
1775
- <select
1776
- className="input"
1777
- value={draft.embeddingBinding}
1778
- onChange={(e) => set('embeddingBinding', e.target.value)}
1779
- >
1780
- {EMBEDDING_BINDING_OPTIONS.map((b) => (
1781
- <option key={b} value={b}>{b}</option>
1782
- ))}
1783
- </select>
1784
- </div>
1785
- {draft.llmBinding !== 'ollama' && (
1786
- <div className="field-row">
1787
- <label className="field-label">LLM Binding Host</label>
1788
- <input
1789
- className="input"
1790
- type="text"
1791
- value={draft.llmBindingHost}
1792
- onChange={(e) => set('llmBindingHost', e.target.value)}
1793
- placeholder="https://api.minimax.chat/v1"
1794
- />
1795
- </div>
1796
- )}
1797
- {draft.embeddingBinding !== 'ollama' && (
1798
- <div className="field-row">
1799
- <label className="field-label">Embedding Binding Host</label>
1800
- <input
1801
- className="input"
1802
- type="text"
1803
- value={draft.embeddingBindingHost}
1804
- onChange={(e) => set('embeddingBindingHost', e.target.value)}
1805
- placeholder="https://api.minimax.chat/v1"
1806
- />
1807
- </div>
1808
- )}
1809
- </div>
1810
- </Card>
1811
-
1812
- {/* Models card */}
1813
- <Card>
1814
- <CardTitle>
1815
- <ServerIcon size={14} /> Models
1816
- </CardTitle>
1817
- <CardMeta>Model IDs for the LLM and embedding engine.</CardMeta>
1818
- <div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 12 }}>
1819
- <div className="field-row">
1820
- <label className="field-label">LLM Model</label>
1821
- <input
1822
- className="input"
1823
- type="text"
1824
- value={draft.llmModel}
1825
- onChange={(e) => set('llmModel', e.target.value)}
1826
- placeholder="minimax/MiniMax-M3"
1827
- />
1828
- </div>
1829
- <div className="field-row">
1830
- <label className="field-label">Embedding Model</label>
1831
- <input
1832
- className="input"
1833
- type="text"
1834
- value={draft.embeddingModel}
1835
- onChange={(e) => set('embeddingModel', e.target.value)}
1836
- placeholder="text-embedding-3-small"
1837
- />
1838
- </div>
1839
- </div>
1840
- </Card>
1841
-
1842
- {/* Connection card */}
1843
- <Card>
1844
- <CardTitle>
1845
- <Plug size={14} /> Connection
1846
- </CardTitle>
1847
- <CardMeta>Host, port, and working directory for the LightRAG server.</CardMeta>
1848
- <div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 12 }}>
1849
- <div className="field-row">
1850
- <label className="field-label">Host</label>
1851
- <input
1852
- className="input"
1853
- type="text"
1854
- value={draft.host}
1855
- onChange={(e) => set('host', e.target.value)}
1856
- placeholder="127.0.0.1"
1857
- />
1858
- </div>
1859
- <div className="field-row">
1860
- <label className="field-label">Port</label>
1861
- <input
1862
- className="input"
1863
- type="number"
1864
- min={1}
1865
- max={65535}
1866
- value={draft.port}
1867
- onChange={(e) => set('port', e.target.value === '' ? '' : Number(e.target.value))}
1868
- placeholder="9621"
1869
- />
1870
- </div>
1871
- <div className="field-row">
1872
- <label className="field-label">Working Dir</label>
1873
- <input
1874
- className="input"
1875
- type="text"
1876
- value={draft.workingDir}
1877
- onChange={(e) => set('workingDir', e.target.value)}
1878
- placeholder=".bizar/lightrag (default)"
1879
- />
1880
- </div>
1881
- </div>
1882
- </Card>
1883
-
1884
- {/* Credentials card */}
1885
- <Card>
1886
- <CardTitle>
1887
- <ShieldCheck size={14} /> Credentials
1888
- </CardTitle>
1889
- <CardMeta>API key source — set in your shell env, or stored in .bizar/memory.json.</CardMeta>
1890
- <div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 12 }}>
1891
- <div className="field-row">
1892
- <label className="field-label">Source</label>
1893
- <select
1894
- className="input"
1895
- value={draft.apiKeySource}
1896
- onChange={(e) => set('apiKeySource', e.target.value as 'env' | 'file')}
1897
- >
1898
- <option value="env">Environment variable (recommended)</option>
1899
- <option value="file">Stored in .bizar/memory.json</option>
1900
- </select>
1901
- </div>
1902
- {draft.apiKeySource === 'env' ? (
1903
- <p className="muted text-sm" style={{ padding: '8px 0' }}>
1904
- Set <code>OPENAI_API_KEY</code> (or provider-specific env var) in your shell before
1905
- running reindex or queries.
1906
- </p>
1907
- ) : (
1908
- <div className="field-row">
1909
- <label className="field-label">API Key</label>
1910
- <input
1911
- className="input"
1912
- type="password"
1913
- value={draft.apiKey}
1914
- onChange={(e) => set('apiKey', e.target.value)}
1915
- placeholder={status?.running ? '(unchanged — leave blank)' : 'sk-…'}
1916
- autoComplete="off"
1917
- />
1918
- </div>
1919
- )}
1920
- </div>
1921
- </Card>
1922
-
1923
- <div className="view-actions">
1924
- <Button variant="primary" disabled={!dirty || saving} onClick={handleSave}>
1925
- {saving ? <Spinner size="sm" /> : <Save size={14} />}
1926
- {saving ? 'Saving…' : 'Save config'}
1927
- </Button>
1928
- </div>
1929
- </div>
1930
- );
1931
- }
1932
-
1933
- // ─── AutoDetectBanner (v3.16.0) ────────────────────────────────────
1934
- // Scans env vars + opencode.json for known provider API keys
1935
- // (Anthropic, OpenAI, Google, Mistral, Groq, Cohere, OpenRouter,
1936
- // DeepSeek, MiniMax). Surfaces status: configured / unknown / no-key.
1937
- type AutoDetectResult = {
1938
- id: string;
1939
- name: string;
1940
- baseURL?: string;
1941
- status: 'configured' | 'unknown' | 'no-key';
1942
- keySource: string;
1943
- hasKey: boolean;
1944
- probed?: { ok: boolean; status?: number; reason?: string; modelCount?: number } | null;
1945
- };
1946
-
1947
- function AutoDetectBanner({
1948
- onAdd,
1949
- }: {
1950
- onAdd: (id: string, name: string, baseURL: string, apiKey?: string) => void;
1951
- }) {
1952
- const toast = useToast();
1953
- const [results, setResults] = useState<AutoDetectResult[] | null>(null);
1954
- const [loading, setLoading] = useState(false);
1955
- const [expanded, setExpanded] = useState(false);
1956
-
1957
- const run = async (probe: boolean) => {
1958
- setLoading(true);
1959
- try {
1960
- const r = await api.get<{ providers: AutoDetectResult[] }>(
1961
- `/providers/auto-detect${probe ? '' : '?probe=0'}`,
1962
- );
1963
- setResults(r.providers || []);
1964
- const configured = (r.providers || []).filter((p) => p.status === 'configured');
1965
- if (configured.length > 0) {
1966
- toast.success(`Detected ${configured.length} configured provider${configured.length === 1 ? '' : 's'}.`);
1967
- } else {
1968
- toast.info('No configured providers detected in env or config.');
1969
- }
1970
- } catch (err) {
1971
- toast.error(`Auto-detect failed: ${(err as Error).message}`);
1972
- } finally {
1973
- setLoading(false);
1974
- }
1975
- };
1976
-
1977
- const statusIcon = (s: string) => {
1978
- if (s === 'configured') return <ShieldCheck size={12} style={{ color: 'var(--success)' }} />;
1979
- if (s === 'unknown') return <AlertTriangle size={12} style={{ color: 'var(--warning)' }} />;
1980
- return <X size={12} style={{ color: 'var(--text-dim)' }} />;
1981
- };
1982
-
1983
- const configured = (results || []).filter((p) => p.status === 'configured');
1984
- const other = (results || []).filter((p) => p.status !== 'configured');
1985
-
1986
- return (
1987
- <Card className="autodetect-banner">
1988
- <div className="autodetect-head" onClick={() => setExpanded((v) => !v)}>
1989
- <ShieldCheck size={14} />
1990
- <span className="autodetect-title">Auto-detect providers</span>
1991
- <span className="muted" style={{ fontSize: 11 }}>
1992
- {results
1993
- ? `${configured.length} configured · ${other.length} other`
1994
- : 'scan env vars + opencode.json for known keys'}
1995
- </span>
1996
- <span className="autodetect-spacer" />
1997
- <Button
1998
- variant="ghost"
1999
- size="sm"
2000
- onClick={(e) => {
2001
- e.stopPropagation();
2002
- run(true);
2003
- }}
2004
- disabled={loading}
2005
- title="Probe each provider's /models endpoint"
2006
- >
2007
- {loading ? <Spinner size="sm" /> : <RefreshCw size={12} />} Detect
2008
- </Button>
2009
- {expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
2010
- </div>
2011
- {expanded && (
2012
- <div className="autodetect-body">
2013
- {!results ? (
2014
- <div className="muted" style={{ padding: 12, fontSize: 12 }}>
2015
- Click <strong>Detect</strong> to scan env vars and opencode.json for known provider keys.
2016
- Probes each provider's <code>/models</code> endpoint with a 1.5s timeout.
2017
- </div>
2018
- ) : (
2019
- <table className="autodetect-table">
2020
- <thead>
2021
- <tr>
2022
- <th>Status</th>
2023
- <th>Provider</th>
2024
- <th>Source</th>
2025
- <th>Probe</th>
2026
- <th></th>
2027
- </tr>
2028
- </thead>
2029
- <tbody>
2030
- {results.map((r) => (
2031
- <tr key={r.id}>
2032
- <td>{statusIcon(r.status)} <span className="autodetect-status">{r.status}</span></td>
2033
- <td><strong>{r.name}</strong> <span className="muted mono">{r.id}</span></td>
2034
- <td className="muted">{r.keySource || '—'}</td>
2035
- <td className="muted">
2036
- {r.probed?.modelCount != null
2037
- ? `${r.probed.modelCount} models`
2038
- : r.probed?.reason
2039
- ? r.probed.reason
2040
- : r.probed?.ok
2041
- ? 'ok'
2042
- : '—'}
2043
- </td>
2044
- <td>
2045
- {r.status === 'configured' && (
2046
- <Button
2047
- variant="ghost"
2048
- size="sm"
2049
- onClick={() => onAdd(r.id, r.name, r.baseURL || '')}
2050
- title="Add this provider to opencode.json"
2051
- >
2052
- <Plus size={10} /> Add
2053
- </Button>
2054
- )}
2055
- </td>
2056
- </tr>
2057
- ))}
2058
- </tbody>
2059
- </table>
2060
- )}
2061
- </div>
2062
- )}
2063
- </Card>
2064
- );
2065
- }
1
+ // src/views/Config.tsx — DEPRECATED: config is now merged into Settings.
2
+ // The 'config' tab has been removed from the sidebar/topbar.
3
+ export { SettingsView as Config } from './Settings';