@polderlabs/bizar 4.7.0 → 4.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bizar-dash/dist/assets/main-DHZmbnxQ.js +361 -0
- package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +1 -0
- package/bizar-dash/dist/assets/main-DX_Jh8Wc.css +1 -0
- package/bizar-dash/dist/assets/{mobile-CWqPoGaT.js → mobile-BK8-ythT.js} +2 -2
- package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +1 -0
- package/bizar-dash/dist/assets/{mobile-i4Uv9eW8.js → mobile-Chvf9u_B.js} +1 -1
- package/bizar-dash/dist/assets/{mobile-i4Uv9eW8.js.map → mobile-Chvf9u_B.js.map} +1 -1
- package/bizar-dash/dist/index.html +3 -3
- package/bizar-dash/dist/mobile.html +2 -2
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/skills/publishing/SKILL.md +146 -0
- package/bizar-dash/src/server/api.mjs +8 -0
- package/bizar-dash/src/server/backup-store.mjs +525 -0
- package/bizar-dash/src/server/digest-store.mjs +558 -0
- package/bizar-dash/src/server/lib/rate-limit.mjs +122 -0
- package/bizar-dash/src/server/routes/backup.mjs +112 -0
- package/bizar-dash/src/server/routes/chat.mjs +14 -0
- package/bizar-dash/src/server/routes/digests.mjs +82 -0
- package/bizar-dash/src/server/routes-v2/events.mjs +14 -0
- package/bizar-dash/src/server/schedules-runner.mjs +126 -0
- package/bizar-dash/src/server/server.mjs +17 -0
- package/bizar-dash/src/web/App.tsx +8 -1
- package/bizar-dash/src/web/components/BackupRestore.tsx +330 -0
- package/bizar-dash/src/web/components/SearchModal.tsx +3 -1
- package/bizar-dash/src/web/components/chat/Composer.tsx +2 -0
- package/bizar-dash/src/web/styles/main.css +70 -8
- package/bizar-dash/src/web/views/Activity.tsx +11 -1
- package/bizar-dash/src/web/views/Agents.tsx +57 -42
- package/bizar-dash/src/web/views/Artifacts.tsx +38 -25
- package/bizar-dash/src/web/views/Chat.tsx +8 -0
- package/bizar-dash/src/web/views/History.tsx +4 -1
- package/bizar-dash/src/web/views/MiniMaxUsage.tsx +21 -4
- package/bizar-dash/src/web/views/Mods.tsx +30 -17
- package/bizar-dash/src/web/views/Overview.tsx +8 -1
- package/bizar-dash/src/web/views/Providers.tsx +16 -16
- package/bizar-dash/src/web/views/Schedules.tsx +33 -15
- package/bizar-dash/src/web/views/Settings.tsx +97 -1751
- package/bizar-dash/src/web/views/Skills.tsx +4 -1
- package/bizar-dash/src/web/views/Tasks.tsx +11 -2
- package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +8 -2
- package/bizar-dash/src/web/views/memory/LightragPanel.tsx +3 -0
- package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +12 -4
- package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +3 -0
- package/bizar-dash/src/web/views/settings/ActivitySection.tsx +205 -0
- package/bizar-dash/src/web/views/settings/AgentSection.tsx +294 -0
- package/bizar-dash/src/web/views/settings/AuthSection.tsx +159 -0
- package/bizar-dash/src/web/views/settings/BackupSection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/EnvVarsSection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/GeneralSection.tsx +105 -0
- package/bizar-dash/src/web/views/settings/HeadroomSection.tsx +39 -0
- package/bizar-dash/src/web/views/settings/MemorySection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/NetworkSection.tsx +87 -0
- package/bizar-dash/src/web/views/settings/NotificationsSection.tsx +34 -0
- package/bizar-dash/src/web/views/settings/ProvidersSection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/SkillsSection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/SystemLlmSection.tsx +81 -0
- package/bizar-dash/src/web/views/settings/ThemeSection.tsx +168 -0
- package/bizar-dash/src/web/views/settings/UpdatesSection.tsx +256 -0
- package/bizar-dash/tests/a11y.test.tsx +206 -0
- package/bizar-dash/tests/backup-restore.test.mjs +217 -0
- package/bizar-dash/tests/backup-restore.test.tsx +123 -0
- package/bizar-dash/tests/backup-store.test.mjs +300 -0
- package/bizar-dash/tests/cli-error-visibility.test.mjs +153 -0
- package/bizar-dash/tests/digest-generation.test.mjs +191 -0
- package/bizar-dash/tests/digest-store.test.mjs +264 -0
- package/bizar-dash/tests/rate-limit.test.mjs +298 -0
- package/cli/bin.mjs +96 -2
- package/cli/commands/minimax.mjs +20 -0
- package/cli/commands/util.mjs +154 -1
- package/cli/digest.mjs +149 -0
- package/package.json +1 -1
- package/bizar-dash/dist/assets/main-DAlLdW8I.css +0 -1
- package/bizar-dash/dist/assets/main-DGGq-iZI.js +0 -361
- package/bizar-dash/dist/assets/main-DGGq-iZI.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-CWqPoGaT.js.map +0 -1
|
@@ -1,52 +1,27 @@
|
|
|
1
|
-
// src/views/Settings.tsx —
|
|
2
|
-
import React, { useEffect, useState
|
|
3
|
-
import {
|
|
4
|
-
Sliders,
|
|
5
|
-
Save,
|
|
6
|
-
RefreshCw,
|
|
7
|
-
Sun,
|
|
8
|
-
Moon,
|
|
9
|
-
Monitor,
|
|
10
|
-
Info,
|
|
11
|
-
Globe,
|
|
12
|
-
Palette,
|
|
13
|
-
Layout as LayoutIcon,
|
|
14
|
-
Server as ServerIcon,
|
|
15
|
-
RotateCcw,
|
|
16
|
-
Plug,
|
|
17
|
-
Download,
|
|
18
|
-
AlertTriangle,
|
|
19
|
-
QrCode,
|
|
20
|
-
Smartphone,
|
|
21
|
-
CheckCircle,
|
|
22
|
-
AlertCircle,
|
|
23
|
-
Shield,
|
|
24
|
-
Copy,
|
|
25
|
-
KeyRound,
|
|
26
|
-
Activity,
|
|
27
|
-
EyeOff,
|
|
28
|
-
Eye,
|
|
29
|
-
Trash2,
|
|
30
|
-
Search,
|
|
31
|
-
Sparkles,
|
|
32
|
-
} from 'lucide-react';
|
|
33
|
-
import { QRCodeSVG } from 'qrcode.react';
|
|
1
|
+
// src/views/Settings.tsx — v4 settings shell: routes to focused sub-components.
|
|
2
|
+
import React, { useEffect, useState } from 'react';
|
|
3
|
+
import { Sliders, Save, RefreshCw, RotateCcw } from 'lucide-react';
|
|
34
4
|
import { Button } from '../components/Button';
|
|
35
|
-
import { Card, CardTitle, CardMeta } from '../components/Card';
|
|
36
5
|
import { useToast } from '../components/Toast';
|
|
37
6
|
import { api } from '../lib/api';
|
|
38
7
|
import { cn } from '../lib/utils';
|
|
39
|
-
import {
|
|
40
|
-
|
|
41
|
-
import {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
} from '
|
|
8
|
+
import { applyTheme, applyThemeTokens, type Settings, type SettingsResponse, type Snapshot, type TailscaleStatus } from '../lib/types';
|
|
9
|
+
|
|
10
|
+
import { ThemeSection } from './settings/ThemeSection';
|
|
11
|
+
import { UpdatesSection } from './settings/UpdatesSection';
|
|
12
|
+
import { GeneralSection } from './settings/GeneralSection';
|
|
13
|
+
import { NetworkSection } from './settings/NetworkSection';
|
|
14
|
+
import { NotificationsSection } from './settings/NotificationsSection';
|
|
15
|
+
import { AuthSection } from './settings/AuthSection';
|
|
16
|
+
import { AgentSection } from './settings/AgentSection';
|
|
17
|
+
import { SystemLlmSection } from './settings/SystemLlmSection';
|
|
18
|
+
import { HeadroomSection } from './settings/HeadroomSection';
|
|
19
|
+
import { ActivitySection } from './settings/ActivitySection';
|
|
20
|
+
import { EnvVarsSection } from './settings/EnvVarsSection';
|
|
21
|
+
import { ProvidersSection } from './settings/ProvidersSection';
|
|
22
|
+
import { MemorySection } from './settings/MemorySection';
|
|
23
|
+
import { SkillsSection } from './settings/SkillsSection';
|
|
24
|
+
import { BackupSection } from './settings/BackupSection';
|
|
50
25
|
|
|
51
26
|
type Props = {
|
|
52
27
|
snapshot: Snapshot;
|
|
@@ -56,435 +31,28 @@ type Props = {
|
|
|
56
31
|
refreshSnapshot: () => Promise<void>;
|
|
57
32
|
};
|
|
58
33
|
|
|
59
|
-
const
|
|
60
|
-
{ id: '
|
|
61
|
-
{ id: '
|
|
62
|
-
{ id: '
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
{
|
|
68
|
-
{
|
|
69
|
-
{
|
|
70
|
-
{
|
|
71
|
-
{
|
|
72
|
-
{
|
|
73
|
-
{
|
|
74
|
-
{
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
enabled: true,
|
|
80
|
-
autoInstall: true,
|
|
81
|
-
port: 8787,
|
|
82
|
-
host: '127.0.0.1',
|
|
83
|
-
outputShaper: false,
|
|
84
|
-
telemetry: false,
|
|
85
|
-
budget: 0,
|
|
86
|
-
backend: 'anthropic',
|
|
87
|
-
autoStart: true,
|
|
88
|
-
autoWrap: true,
|
|
89
|
-
routeAllProviders: true,
|
|
90
|
-
};
|
|
91
|
-
|
|
92
|
-
const FONT_FAMILIES = [
|
|
93
|
-
'Inter',
|
|
94
|
-
'system-ui',
|
|
95
|
-
'Segoe UI',
|
|
96
|
-
'Roboto',
|
|
97
|
-
'JetBrains Mono',
|
|
98
|
-
'SF Mono',
|
|
99
|
-
'Cascadia Code',
|
|
100
|
-
];
|
|
101
|
-
|
|
102
|
-
// v3.5.2 — Pair-with-mobile companion card.
|
|
103
|
-
type PairSession = {
|
|
104
|
-
token: string;
|
|
105
|
-
qrPayload: string;
|
|
106
|
-
publicUrl: string;
|
|
107
|
-
expiresAt: number;
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
function formatCountdown(ms: number): string {
|
|
111
|
-
if (ms <= 0) return 'expired';
|
|
112
|
-
const s = Math.floor(ms / 1000);
|
|
113
|
-
const m = Math.floor(s / 60);
|
|
114
|
-
const r = s % 60;
|
|
115
|
-
return `${m}:${String(r).padStart(2, '0')}`;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function PairDeviceCard() {
|
|
119
|
-
const toast = useToast();
|
|
120
|
-
const [pair, setPair] = useState<PairSession | null>(null);
|
|
121
|
-
const [pairing, setPairing] = useState(false);
|
|
122
|
-
const [pairError, setPairError] = useState<string | null>(null);
|
|
123
|
-
const [now, setNow] = useState(Date.now());
|
|
124
|
-
|
|
125
|
-
const start = useCallback(async () => {
|
|
126
|
-
setPairing(true);
|
|
127
|
-
setPairError(null);
|
|
128
|
-
try {
|
|
129
|
-
const res = await api.post<PairSession>('/pair/start');
|
|
130
|
-
setPair(res);
|
|
131
|
-
} catch (err) {
|
|
132
|
-
setPairError((err as Error)?.message || 'Failed to start pairing');
|
|
133
|
-
toast.error('Pairing failed.');
|
|
134
|
-
} finally {
|
|
135
|
-
setPairing(false);
|
|
136
|
-
}
|
|
137
|
-
}, [toast]);
|
|
138
|
-
|
|
139
|
-
useEffect(() => {
|
|
140
|
-
if (!pair) return;
|
|
141
|
-
const t = setInterval(() => setNow(Date.now()), 1000);
|
|
142
|
-
return () => clearInterval(t);
|
|
143
|
-
}, [pair]);
|
|
144
|
-
|
|
145
|
-
const remaining = pair ? pair.expiresAt - now : 0;
|
|
146
|
-
const expired = pair != null && remaining <= 0;
|
|
147
|
-
|
|
148
|
-
return (
|
|
149
|
-
<Card id="settings-updates" data-section="updates">
|
|
150
|
-
<CardTitle>
|
|
151
|
-
<Smartphone size={14} /> Companion App
|
|
152
|
-
</CardTitle>
|
|
153
|
-
<CardMeta>
|
|
154
|
-
Scan the QR with <a href="https://github.com/DrB0rk/BizarHarness" target="_blank" rel="noopener noreferrer">Bizar Companion</a> to pair.
|
|
155
|
-
Tokens expire after 5 minutes.
|
|
156
|
-
</CardMeta>
|
|
157
|
-
|
|
158
|
-
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
|
159
|
-
<div style={{ flex: '0 0 auto' }}>
|
|
160
|
-
{pair && !expired ? (
|
|
161
|
-
<div style={{ background: '#fff', padding: 12, borderRadius: 12 }}>
|
|
162
|
-
<QRCodeSVG value={pair.qrPayload} size={192} level="M" includeMargin={false} />
|
|
163
|
-
</div>
|
|
164
|
-
) : (
|
|
165
|
-
<div
|
|
166
|
-
style={{
|
|
167
|
-
width: 216,
|
|
168
|
-
height: 216,
|
|
169
|
-
borderRadius: 12,
|
|
170
|
-
background: 'var(--surface-2, #161b22)',
|
|
171
|
-
display: 'flex',
|
|
172
|
-
alignItems: 'center',
|
|
173
|
-
justifyContent: 'center',
|
|
174
|
-
color: 'var(--text-muted, #8b949e)',
|
|
175
|
-
fontSize: 12,
|
|
176
|
-
textAlign: 'center',
|
|
177
|
-
padding: 16,
|
|
178
|
-
border: '1px dashed var(--border, #30363d)',
|
|
179
|
-
}}
|
|
180
|
-
>
|
|
181
|
-
{pair && expired ? 'QR expired' : 'No QR generated yet'}
|
|
182
|
-
</div>
|
|
183
|
-
)}
|
|
184
|
-
</div>
|
|
185
|
-
|
|
186
|
-
<div style={{ flex: '1 1 240px', minWidth: 220 }}>
|
|
187
|
-
{!pair && (
|
|
188
|
-
<Button variant="primary" onClick={start} disabled={pairing}>
|
|
189
|
-
<QrCode size={14} /> {pairing ? 'Generating…' : 'Generate QR Code'}
|
|
190
|
-
</Button>
|
|
191
|
-
)}
|
|
192
|
-
|
|
193
|
-
{pair && !expired && (
|
|
194
|
-
<>
|
|
195
|
-
<div style={{ marginBottom: 10 }}>
|
|
196
|
-
<strong>Expires in</strong> <span className="mono">{formatCountdown(remaining)}</span>
|
|
197
|
-
</div>
|
|
198
|
-
<div style={{ marginBottom: 6 }}>
|
|
199
|
-
<strong>URL:</strong> <span className="mono" style={{ wordBreak: 'break-all' }}>{pair.publicUrl}</span>
|
|
200
|
-
</div>
|
|
201
|
-
<div style={{ marginBottom: 12 }}>
|
|
202
|
-
<strong>Token:</strong>{' '}
|
|
203
|
-
<span className="mono" style={{ wordBreak: 'break-all', fontSize: 12 }}>
|
|
204
|
-
{pair.token.slice(0, 16)}…{pair.token.slice(-6)}
|
|
205
|
-
</span>
|
|
206
|
-
</div>
|
|
207
|
-
<Button variant="secondary" onClick={start} disabled={pairing}>
|
|
208
|
-
<RefreshCw size={14} /> Regenerate
|
|
209
|
-
</Button>
|
|
210
|
-
</>
|
|
211
|
-
)}
|
|
212
|
-
|
|
213
|
-
{pair && expired && (
|
|
214
|
-
<>
|
|
215
|
-
<div style={{ marginBottom: 12, color: 'var(--error, #f85149)' }}>
|
|
216
|
-
Token expired — generate a fresh QR to pair again.
|
|
217
|
-
</div>
|
|
218
|
-
<Button variant="primary" onClick={() => { setPair(null); start(); }}>
|
|
219
|
-
<RefreshCw size={14} /> Generate new QR
|
|
220
|
-
</Button>
|
|
221
|
-
</>
|
|
222
|
-
)}
|
|
223
|
-
|
|
224
|
-
{pairError && (
|
|
225
|
-
<div style={{ marginTop: 10, color: 'var(--error, #f85149)', fontSize: 12 }}>
|
|
226
|
-
{pairError}
|
|
227
|
-
</div>
|
|
228
|
-
)}
|
|
229
|
-
</div>
|
|
230
|
-
</div>
|
|
231
|
-
</Card>
|
|
232
|
-
);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const LAYOUTS = [
|
|
236
|
-
{ id: 'topnav', label: 'Top nav' },
|
|
237
|
-
{ id: 'sidebar', label: 'Sidebar' },
|
|
238
|
-
{ id: 'both', label: 'Both' },
|
|
34
|
+
const SECTION_LINKS = [
|
|
35
|
+
{ id: 'theme', label: 'Theme' },
|
|
36
|
+
{ id: 'updates', label: 'Updates' },
|
|
37
|
+
{ id: 'layout', label: 'Layout' },
|
|
38
|
+
{ id: 'general', label: 'General' },
|
|
39
|
+
{ id: 'network', label: 'Network' },
|
|
40
|
+
{ id: 'notifications', label: 'Notifications' },
|
|
41
|
+
{ id: 'auth', label: 'Auth' },
|
|
42
|
+
{ id: 'agents', label: 'Agents' },
|
|
43
|
+
{ id: 'dashboard', label: 'Dashboard' },
|
|
44
|
+
{ id: 'background', label: 'Background' },
|
|
45
|
+
{ id: 'system-llm', label: 'System LLM' },
|
|
46
|
+
{ id: 'headroom', label: 'Headroom' },
|
|
47
|
+
{ id: 'activity-log', label: 'Activity' },
|
|
48
|
+
{ id: 'about', label: 'About' },
|
|
49
|
+
{ id: 'env-vars', label: 'Env Vars' },
|
|
50
|
+
{ id: 'providers', label: 'Providers' },
|
|
51
|
+
{ id: 'memory', label: 'Memory' },
|
|
52
|
+
{ id: 'skills', label: 'Skills' },
|
|
53
|
+
{ id: 'backup', label: 'Backup' },
|
|
239
54
|
] as const;
|
|
240
55
|
|
|
241
|
-
// v3.5.3 — Updates card with per-package selection, live progress, and auto-restart
|
|
242
|
-
type PkgStatus = {
|
|
243
|
-
status: 'idle' | 'starting' | 'installing' | 'done' | 'error';
|
|
244
|
-
error?: string;
|
|
245
|
-
newVersion?: string;
|
|
246
|
-
logs: string[];
|
|
247
|
-
};
|
|
248
|
-
|
|
249
|
-
function UpdatesCard() {
|
|
250
|
-
const toast = useToast();
|
|
251
|
-
const [status, setStatus] = useState<{
|
|
252
|
-
current: Record<string, string | null>;
|
|
253
|
-
latest: Record<string, string | null> | null;
|
|
254
|
-
checking: boolean;
|
|
255
|
-
updating: boolean;
|
|
256
|
-
hasUpdates: boolean;
|
|
257
|
-
requiresRestart: boolean;
|
|
258
|
-
perPackage: Record<string, PkgStatus>;
|
|
259
|
-
error?: string;
|
|
260
|
-
}>({
|
|
261
|
-
current: {},
|
|
262
|
-
latest: null,
|
|
263
|
-
checking: false,
|
|
264
|
-
updating: false,
|
|
265
|
-
hasUpdates: false,
|
|
266
|
-
requiresRestart: false,
|
|
267
|
-
perPackage: {},
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
// WebSocket subscription for live update progress (auto-reconnect via Ws class)
|
|
271
|
-
useEffect(() => {
|
|
272
|
-
const ws = new Ws();
|
|
273
|
-
ws.on((msg: Record<string, unknown>) => {
|
|
274
|
-
if (msg.type === 'update:progress' || msg.type === 'update:log' || msg.type === 'update:complete') {
|
|
275
|
-
setStatus((s) => {
|
|
276
|
-
if (msg.type === 'update:complete') {
|
|
277
|
-
return {
|
|
278
|
-
...s,
|
|
279
|
-
updating: false,
|
|
280
|
-
requiresRestart: Boolean((msg as { requiresRestart?: boolean }).requiresRestart),
|
|
281
|
-
};
|
|
282
|
-
}
|
|
283
|
-
if (msg.type === 'update:log') {
|
|
284
|
-
const m = msg as { pkg: string; line: string };
|
|
285
|
-
const existing = s.perPackage[m.pkg] || { logs: [] };
|
|
286
|
-
return {
|
|
287
|
-
...s,
|
|
288
|
-
perPackage: {
|
|
289
|
-
...s.perPackage,
|
|
290
|
-
[m.pkg]: {
|
|
291
|
-
...existing,
|
|
292
|
-
logs: [...(existing.logs || []).slice(-50), m.line],
|
|
293
|
-
},
|
|
294
|
-
},
|
|
295
|
-
};
|
|
296
|
-
}
|
|
297
|
-
// update:progress
|
|
298
|
-
const m = msg as { pkg: string; status: PkgStatus['status']; error?: string; newVersion?: string };
|
|
299
|
-
return {
|
|
300
|
-
...s,
|
|
301
|
-
perPackage: {
|
|
302
|
-
...s.perPackage,
|
|
303
|
-
[m.pkg]: {
|
|
304
|
-
...s.perPackage[m.pkg],
|
|
305
|
-
status: m.status,
|
|
306
|
-
error: m.error,
|
|
307
|
-
newVersion: m.newVersion,
|
|
308
|
-
},
|
|
309
|
-
},
|
|
310
|
-
};
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
});
|
|
314
|
-
return () => ws.close();
|
|
315
|
-
}, []);
|
|
316
|
-
|
|
317
|
-
// Load current versions on mount
|
|
318
|
-
useEffect(() => {
|
|
319
|
-
api.get<{ current: Record<string, string | null> }>('/updates/status')
|
|
320
|
-
.then((r) => setStatus((s) => ({ ...s, current: r.current })))
|
|
321
|
-
.catch((e) => setStatus((s) => ({ ...s, error: e.message })));
|
|
322
|
-
}, []);
|
|
323
|
-
|
|
324
|
-
const check = async () => {
|
|
325
|
-
setStatus((s) => ({ ...s, checking: true, error: undefined }));
|
|
326
|
-
try {
|
|
327
|
-
const r = await api.get<{
|
|
328
|
-
current: Record<string, string | null>;
|
|
329
|
-
latest: Record<string, string | null>;
|
|
330
|
-
hasUpdates: boolean;
|
|
331
|
-
}>('/updates/check');
|
|
332
|
-
setStatus((s) => ({
|
|
333
|
-
...s,
|
|
334
|
-
checking: false,
|
|
335
|
-
current: r.current,
|
|
336
|
-
latest: r.latest,
|
|
337
|
-
hasUpdates: r.hasUpdates,
|
|
338
|
-
}));
|
|
339
|
-
} catch (err) {
|
|
340
|
-
setStatus((s) => ({ ...s, checking: false, error: (err as Error).message }));
|
|
341
|
-
}
|
|
342
|
-
};
|
|
343
|
-
|
|
344
|
-
const applyUpdate = async () => {
|
|
345
|
-
if (!confirm('Update Bizar packages? The dashboard will restart automatically.')) return;
|
|
346
|
-
setStatus((s) => ({
|
|
347
|
-
...s,
|
|
348
|
-
updating: true,
|
|
349
|
-
requiresRestart: false,
|
|
350
|
-
perPackage: {},
|
|
351
|
-
error: undefined,
|
|
352
|
-
}));
|
|
353
|
-
try {
|
|
354
|
-
await api.post('/updates/apply', { packages: ['bizar', 'bizar-dash', 'bizar-plugin'] });
|
|
355
|
-
// Progress streams via WebSocket
|
|
356
|
-
} catch (err) {
|
|
357
|
-
setStatus((s) => ({ ...s, updating: false, error: (err as Error).message }));
|
|
358
|
-
}
|
|
359
|
-
};
|
|
360
|
-
|
|
361
|
-
const restart = async () => {
|
|
362
|
-
if (!confirm('Restart the dashboard? You will be disconnected briefly.')) return;
|
|
363
|
-
try {
|
|
364
|
-
await api.post('/restart');
|
|
365
|
-
toast.info('Restarting…', 3000);
|
|
366
|
-
setTimeout(() => window.location.reload(), 3000);
|
|
367
|
-
} catch {
|
|
368
|
-
toast.error('Restart failed');
|
|
369
|
-
}
|
|
370
|
-
};
|
|
371
|
-
|
|
372
|
-
const packages = [
|
|
373
|
-
{ id: 'bizar', name: 'Bizar CLI' },
|
|
374
|
-
{ id: 'bizar-dash', name: 'Dashboard' },
|
|
375
|
-
{ id: 'bizar-plugin', name: 'Opencode Plugin' },
|
|
376
|
-
];
|
|
377
|
-
|
|
378
|
-
const isBusy = status.checking || status.updating;
|
|
379
|
-
|
|
380
|
-
return (
|
|
381
|
-
<Card id="settings-updates" data-section="updates">
|
|
382
|
-
<CardTitle><Download size={14} /> Updates</CardTitle>
|
|
383
|
-
<CardMeta>Check installed Bizar packages and apply dashboard updates.</CardMeta>
|
|
384
|
-
{/* Current versions */}
|
|
385
|
-
<div className="updates-current">
|
|
386
|
-
<h4>Installed versions</h4>
|
|
387
|
-
<ul>
|
|
388
|
-
{packages.map((p) => (
|
|
389
|
-
<li key={p.id}>
|
|
390
|
-
<span>{p.name}</span>
|
|
391
|
-
<code className="mono">{status.current[p.id] || '—'}</code>
|
|
392
|
-
</li>
|
|
393
|
-
))}
|
|
394
|
-
</ul>
|
|
395
|
-
</div>
|
|
396
|
-
|
|
397
|
-
{/* Latest + per-package status */}
|
|
398
|
-
{status.latest && (
|
|
399
|
-
<div className="updates-latest">
|
|
400
|
-
<h4>Latest available</h4>
|
|
401
|
-
<ul>
|
|
402
|
-
{packages.map((p) => {
|
|
403
|
-
const cur = status.current[p.id];
|
|
404
|
-
const lat = status.latest?.[p.id];
|
|
405
|
-
const isOutdated = cur && lat && cur !== lat;
|
|
406
|
-
return (
|
|
407
|
-
<li key={p.id} className={isOutdated ? 'updates-outdated' : 'updates-current-version'}>
|
|
408
|
-
<span>{p.name}</span>
|
|
409
|
-
<code className="mono">
|
|
410
|
-
{lat || '—'}
|
|
411
|
-
{isOutdated && <span className="updates-badge">update available</span>}
|
|
412
|
-
</code>
|
|
413
|
-
</li>
|
|
414
|
-
);
|
|
415
|
-
})}
|
|
416
|
-
</ul>
|
|
417
|
-
</div>
|
|
418
|
-
)}
|
|
419
|
-
|
|
420
|
-
{/* Per-package progress rows (shown while updating) */}
|
|
421
|
-
{status.updating && (
|
|
422
|
-
<div className="updates-progress-rows">
|
|
423
|
-
{packages.map((p) => {
|
|
424
|
-
const pkgStatus = status.perPackage[p.id] || { status: 'idle', logs: [] as string[] };
|
|
425
|
-
return (
|
|
426
|
-
<div key={p.id} className="updates-pkg-row">
|
|
427
|
-
<div className="updates-pkg-row-header">
|
|
428
|
-
<span className="updates-pkg-name">{p.name}</span>
|
|
429
|
-
<div className="updates-pkg-status">
|
|
430
|
-
{pkgStatus.status === 'starting' && <span className="btn-spinner" />}
|
|
431
|
-
{pkgStatus.status === 'installing' && <span className="btn-spinner" />}
|
|
432
|
-
{pkgStatus.status === 'done' && <CheckCircle size={14} className="icon-success" />}
|
|
433
|
-
{pkgStatus.status === 'error' && <AlertCircle size={14} className="icon-error" />}
|
|
434
|
-
<span>{pkgStatus.status}</span>
|
|
435
|
-
{pkgStatus.newVersion && (
|
|
436
|
-
<code className="mono" style={{ fontSize: 11 }}>→ {pkgStatus.newVersion}</code>
|
|
437
|
-
)}
|
|
438
|
-
</div>
|
|
439
|
-
</div>
|
|
440
|
-
{pkgStatus.logs.length > 0 && (
|
|
441
|
-
<details className="updates-pkg-logs">
|
|
442
|
-
<summary>npm output ({pkgStatus.logs.length} lines)</summary>
|
|
443
|
-
<pre>{pkgStatus.logs.join('\n')}</pre>
|
|
444
|
-
</details>
|
|
445
|
-
)}
|
|
446
|
-
{pkgStatus.status === 'error' && pkgStatus.error && (
|
|
447
|
-
<div className="updates-pkg-error">
|
|
448
|
-
<AlertTriangle size={12} /> {pkgStatus.error}
|
|
449
|
-
</div>
|
|
450
|
-
)}
|
|
451
|
-
</div>
|
|
452
|
-
);
|
|
453
|
-
})}
|
|
454
|
-
</div>
|
|
455
|
-
)}
|
|
456
|
-
|
|
457
|
-
{/* Actions */}
|
|
458
|
-
<div className="updates-actions">
|
|
459
|
-
<Button onClick={check} disabled={isBusy}>
|
|
460
|
-
{status.checking ? <span className="btn-spinner" /> : <RefreshCw size={14} />}
|
|
461
|
-
Check for updates
|
|
462
|
-
</Button>
|
|
463
|
-
<Button
|
|
464
|
-
variant="primary"
|
|
465
|
-
onClick={applyUpdate}
|
|
466
|
-
disabled={!status.hasUpdates || status.updating}
|
|
467
|
-
>
|
|
468
|
-
{status.updating ? <span className="btn-spinner" /> : <Download size={14} />}
|
|
469
|
-
{status.updating ? 'Updating…' : (status.hasUpdates ? 'Update now' : 'Up to date')}
|
|
470
|
-
</Button>
|
|
471
|
-
{status.requiresRestart && (
|
|
472
|
-
<Button variant="danger" onClick={restart}>
|
|
473
|
-
<RefreshCw size={14} /> Restart Dashboard
|
|
474
|
-
</Button>
|
|
475
|
-
)}
|
|
476
|
-
</div>
|
|
477
|
-
|
|
478
|
-
{status.error && (
|
|
479
|
-
<div className="updates-error">
|
|
480
|
-
<AlertTriangle size={14} />
|
|
481
|
-
<span>{status.error}</span>
|
|
482
|
-
</div>
|
|
483
|
-
)}
|
|
484
|
-
</Card>
|
|
485
|
-
);
|
|
486
|
-
}
|
|
487
|
-
|
|
488
56
|
function SettingsViewInner({ settings: initial, refreshSnapshot }: Props) {
|
|
489
57
|
const toast = useToast();
|
|
490
58
|
const [settings, setSettings] = useState<Settings>(initial);
|
|
@@ -492,1339 +60,117 @@ function SettingsViewInner({ settings: initial, refreshSnapshot }: Props) {
|
|
|
492
60
|
const [saving, setSaving] = useState(false);
|
|
493
61
|
const [tailscale, setTailscale] = useState<TailscaleStatus | null>(null);
|
|
494
62
|
const [tailscaleDraft, setTailscaleDraft] = useState({ port: 4321, https: true, hostname: '' });
|
|
495
|
-
const [
|
|
496
|
-
|
|
497
|
-
useEffect(() => {
|
|
498
|
-
setSettings(initial);
|
|
499
|
-
setDirty(false);
|
|
500
|
-
if (initial.theme) applyThemeTokens(initial.theme);
|
|
501
|
-
}, [initial]);
|
|
502
|
-
|
|
503
|
-
useEffect(() => {
|
|
504
|
-
if (!tailscale) return;
|
|
505
|
-
setTailscaleDraft({
|
|
506
|
-
port: tailscale.settings.port,
|
|
507
|
-
https: tailscale.settings.https !== false,
|
|
508
|
-
hostname: tailscale.settings.hostname || '',
|
|
509
|
-
});
|
|
510
|
-
}, [tailscale]);
|
|
511
|
-
|
|
512
|
-
useEffect(() => {
|
|
513
|
-
api.get<TailscaleStatus>('/tailscale/status').then(setTailscale).catch(() => undefined);
|
|
514
|
-
}, []);
|
|
515
|
-
|
|
516
|
-
useEffect(() => {
|
|
517
|
-
api.get<Record<string, number>>('/settings/plugin-options')
|
|
518
|
-
.then(setPluginOptions)
|
|
519
|
-
.catch(() => { /* not persisted yet — use defaults */ });
|
|
520
|
-
}, []);
|
|
521
|
-
|
|
522
|
-
const patchTheme = (patch: Partial<Settings['theme']>) => {
|
|
523
|
-
setSettings((cur) => {
|
|
524
|
-
const next = { ...cur, theme: { ...cur.theme, ...patch } };
|
|
525
|
-
applyThemeTokens(next.theme);
|
|
526
|
-
return next;
|
|
527
|
-
});
|
|
528
|
-
setDirty(true);
|
|
529
|
-
};
|
|
530
|
-
|
|
531
|
-
const patchUi = (patch: Partial<Settings['ui']>) => {
|
|
532
|
-
setSettings((cur) => ({ ...cur, ui: { ...cur.ui, ...patch } }));
|
|
533
|
-
setDirty(true);
|
|
534
|
-
};
|
|
63
|
+
const [authStatus, setAuthStatus] = useState<{ required: boolean; loopback: boolean; peer: string } | null>(null);
|
|
535
64
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
};
|
|
65
|
+
useEffect(() => { setSettings(initial); setDirty(false); if (initial.theme) applyThemeTokens(initial.theme); }, [initial]);
|
|
66
|
+
useEffect(() => { if (!tailscale) return; setTailscaleDraft({ port: tailscale.settings.port, https: tailscale.settings.https !== false, hostname: tailscale.settings.hostname || '' }); }, [tailscale]);
|
|
67
|
+
useEffect(() => { api.get<TailscaleStatus>('/tailscale/status').then(setTailscale).catch(() => undefined); }, []);
|
|
540
68
|
|
|
541
|
-
const
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
};
|
|
545
|
-
|
|
546
|
-
const
|
|
547
|
-
setSettings((cur) => ({ ...cur, agents: { ...cur.agents, ...patch } }));
|
|
548
|
-
setDirty(true);
|
|
549
|
-
};
|
|
69
|
+
const patchTheme = (patch: Partial<Settings['theme']>) => { setSettings((cur) => { const next = { ...cur, theme: { ...cur.theme, ...patch } }; applyThemeTokens(next.theme); return next; }); setDirty(true); };
|
|
70
|
+
const patchUi = (patch: Partial<Settings['ui']>) => { setSettings((cur) => ({ ...cur, ui: { ...cur.ui, ...patch } })); setDirty(true); };
|
|
71
|
+
const patchTop = <K extends keyof Settings>(key: K, value: Settings[K]) => { setSettings((cur) => ({ ...cur, [key]: value })); setDirty(true); };
|
|
72
|
+
const patchNotifications = (patch: Partial<Settings['notifications']>) => { setSettings((cur) => ({ ...cur, notifications: { ...cur.notifications, ...patch } })); setDirty(true); };
|
|
73
|
+
const patchAgents = (patch: Partial<Settings['agents']>) => { setSettings((cur) => ({ ...cur, agents: { ...cur.agents, ...patch } })); setDirty(true); };
|
|
74
|
+
const patchDashboard = (patch: Partial<Settings['dashboard']>) => { setSettings((cur) => ({ ...cur, dashboard: { ...cur.dashboard, ...patch } })); setDirty(true); };
|
|
550
75
|
|
|
551
|
-
// v3.17.0 — Settings subnav as a filter (not just scroll-to).
|
|
552
|
-
// Clicking a subnav button shows ONLY that section; clicking "All"
|
|
553
|
-
// restores the full layout. Anchors still work for deep-links but
|
|
554
|
-
// the visible content is filtered, not just scrolled.
|
|
555
|
-
//
|
|
556
|
-
// v3.18.0 — IDs use the section NAME (not the `settings-X` element id)
|
|
557
|
-
// because the inline-style wrap compares against the section name, not
|
|
558
|
-
// the prefixed DOM id.
|
|
559
|
-
const SECTION_LINKS: Array<{ id: string; label: string }> = [
|
|
560
|
-
{ id: 'theme', label: 'Theme' },
|
|
561
|
-
{ id: 'layout', label: 'Layout' },
|
|
562
|
-
{ id: 'general', label: 'General' },
|
|
563
|
-
{ id: 'service', label: 'Service' },
|
|
564
|
-
{ id: 'tailscale', label: 'Tailscale' },
|
|
565
|
-
{ id: 'notifications', label: 'Notifications' },
|
|
566
|
-
{ id: 'auth', label: 'Auth' },
|
|
567
|
-
{ id: 'agents', label: 'Agents' },
|
|
568
|
-
{ id: 'dashboard', label: 'Dashboard' },
|
|
569
|
-
{ id: 'background', label: 'Background' },
|
|
570
|
-
{ id: 'system-llm', label: 'System LLM' },
|
|
571
|
-
{ id: 'headroom', label: 'Headroom' },
|
|
572
|
-
{ id: 'updates', label: 'Updates' },
|
|
573
|
-
{ id: 'activity-log', label: 'Activity' },
|
|
574
|
-
{ id: 'about', label: 'About' },
|
|
575
|
-
];
|
|
576
76
|
const [activeSection, setActiveSection] = useState<string | null>(() => {
|
|
577
|
-
// Honour #settings-... deep-link on initial mount, otherwise null = All.
|
|
578
77
|
if (typeof window === 'undefined') return null;
|
|
579
78
|
const hash = window.location.hash.replace(/^#settings-/, '');
|
|
580
79
|
return SECTION_LINKS.some((s) => s.id === hash) ? hash : null;
|
|
581
80
|
});
|
|
81
|
+
|
|
582
82
|
const onJumpSection = (id: string | null) => {
|
|
583
83
|
setActiveSection(id);
|
|
584
84
|
try {
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
const el = document.getElementById(`settings-${id}`);
|
|
589
|
-
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
590
|
-
} else {
|
|
591
|
-
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
592
|
-
}
|
|
85
|
+
history.replaceState(null, '', id ? `#settings-${id}` : window.location.pathname);
|
|
86
|
+
if (id) { const el = document.getElementById(`settings-${id}`); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
|
|
87
|
+
else window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
593
88
|
} catch { /* ignore */ }
|
|
594
89
|
};
|
|
595
|
-
// The user picks a section via the subnav; their choice is respected.
|
|
596
|
-
// (v3.18.0 — Removed auto-scroll-based detection; with the inline-style
|
|
597
|
-
// filter approach, hidden sections still report bounding rects, so the
|
|
598
|
-
// detector would race the user's explicit selection and switch tabs back.)
|
|
599
90
|
|
|
600
|
-
const
|
|
601
|
-
|
|
602
|
-
|
|
91
|
+
const onTailscaleToggle = async () => {
|
|
92
|
+
try {
|
|
93
|
+
if (tailscale?.settings.enabled) { await api.post('/tailscale/disable'); toast.success('Tailscale serve disabled.'); }
|
|
94
|
+
else { await api.post('/tailscale/enable', { port: tailscaleDraft.port || 4321, https: tailscaleDraft.https, hostname: tailscaleDraft.hostname || '' }); toast.success('Tailscale serve enabled.'); }
|
|
95
|
+
setTailscale(await api.get<TailscaleStatus>('/tailscale/status'));
|
|
96
|
+
} catch (err) { toast.error(`Tailscale failed: ${(err as Error).message}`); }
|
|
603
97
|
};
|
|
604
98
|
|
|
605
99
|
const onSave = async () => {
|
|
606
100
|
setSaving(true);
|
|
607
101
|
try {
|
|
608
102
|
const r = await api.put<SettingsResponse>('/settings', settings);
|
|
609
|
-
setSettings(r.data);
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
toast.success('Settings saved.');
|
|
614
|
-
await refreshSnapshot();
|
|
615
|
-
} catch (err) {
|
|
616
|
-
toast.error(`Save failed: ${(err as Error).message}`);
|
|
617
|
-
} finally {
|
|
618
|
-
setSaving(false);
|
|
619
|
-
}
|
|
103
|
+
setSettings(r.data); setDirty(false); applyTheme(r.data.theme); applyThemeTokens(r.data.theme);
|
|
104
|
+
toast.success('Settings saved.'); await refreshSnapshot();
|
|
105
|
+
} catch (err) { toast.error(`Save failed: ${(err as Error).message}`); }
|
|
106
|
+
finally { setSaving(false); }
|
|
620
107
|
};
|
|
621
108
|
|
|
622
109
|
const onReload = async () => {
|
|
623
110
|
try {
|
|
624
111
|
const r = await api.get<SettingsResponse>('/settings');
|
|
625
|
-
setSettings(r.data);
|
|
626
|
-
setDirty(false);
|
|
627
|
-
applyTheme(r.data.theme);
|
|
628
|
-
applyThemeTokens(r.data.theme);
|
|
112
|
+
setSettings(r.data); setDirty(false); applyTheme(r.data.theme); applyThemeTokens(r.data.theme);
|
|
629
113
|
toast.info('Settings reloaded.', 1500);
|
|
630
|
-
} catch (err) {
|
|
631
|
-
toast.error(`Reload failed: ${(err as Error).message}`);
|
|
632
|
-
}
|
|
114
|
+
} catch (err) { toast.error(`Reload failed: ${(err as Error).message}`); }
|
|
633
115
|
};
|
|
634
116
|
|
|
635
117
|
const onReset = async () => {
|
|
636
118
|
if (!confirm('Reset all settings to defaults?')) return;
|
|
637
119
|
try {
|
|
638
120
|
const r = await api.post<SettingsResponse>('/settings/reset');
|
|
639
|
-
setSettings(r.data);
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
applyThemeTokens(r.data.theme);
|
|
643
|
-
toast.success('Settings reset.');
|
|
644
|
-
await refreshSnapshot();
|
|
645
|
-
} catch (err) {
|
|
646
|
-
toast.error(`Reset failed: ${(err as Error).message}`);
|
|
647
|
-
}
|
|
121
|
+
setSettings(r.data); setDirty(false); applyTheme(r.data.theme); applyThemeTokens(r.data.theme);
|
|
122
|
+
toast.success('Settings reset.'); await refreshSnapshot();
|
|
123
|
+
} catch (err) { toast.error(`Reset failed: ${(err as Error).message}`); }
|
|
648
124
|
};
|
|
649
125
|
|
|
650
|
-
const
|
|
651
|
-
|
|
652
|
-
if (tailscale?.settings.enabled) {
|
|
653
|
-
await api.post('/tailscale/disable');
|
|
654
|
-
toast.success('Tailscale serve disabled.');
|
|
655
|
-
} else {
|
|
656
|
-
await api.post('/tailscale/enable', {
|
|
657
|
-
port: tailscaleDraft.port || 4321,
|
|
658
|
-
https: tailscaleDraft.https,
|
|
659
|
-
hostname: tailscaleDraft.hostname || '',
|
|
660
|
-
});
|
|
661
|
-
toast.success('Tailscale serve enabled.');
|
|
662
|
-
}
|
|
663
|
-
const r = await api.get<TailscaleStatus>('/tailscale/status');
|
|
664
|
-
setTailscale(r);
|
|
665
|
-
} catch (err) {
|
|
666
|
-
toast.error(`Tailscale failed: ${(err as Error).message}`);
|
|
667
|
-
}
|
|
668
|
-
};
|
|
669
|
-
|
|
670
|
-
const about = settings.about || {
|
|
671
|
-
version: '3.0.4',
|
|
672
|
-
homepage: 'https://github.com/DrB0rk/BizarHarness',
|
|
673
|
-
license: 'MIT',
|
|
674
|
-
};
|
|
675
|
-
|
|
676
|
-
// v3.6.0 — Auth token UI state. The Settings tab is the only place
|
|
677
|
-
// the operator can read the token back out. We default the input
|
|
678
|
-
// to whatever's already in localStorage so a page reload doesn't
|
|
679
|
-
// wipe the entry.
|
|
680
|
-
const [authToken, setAuthToken] = useState<string>(api.getToken());
|
|
681
|
-
const [authStatus, setAuthStatus] = useState<{ required: boolean; loopback: boolean; peer: string } | null>(null);
|
|
682
|
-
const [revealedToken, setRevealedToken] = useState<string>('');
|
|
126
|
+
const sp = { settings, patchTheme, patchUi, patchTop, patchNotifications, patchAgents, patchDashboard };
|
|
127
|
+
const about = settings.about || { version: '3.0.4', homepage: 'https://github.com/DrB0rk/BizarHarness', license: 'MIT' };
|
|
683
128
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
// we're using is bad — surface that distinctly.
|
|
687
|
-
useEffect(() => {
|
|
688
|
-
let cancelled = false;
|
|
689
|
-
(async () => {
|
|
690
|
-
try {
|
|
691
|
-
const r = await api.probeAuthStatus();
|
|
692
|
-
if (!cancelled) setAuthStatus(r);
|
|
693
|
-
} catch {
|
|
694
|
-
if (!cancelled) {
|
|
695
|
-
setAuthStatus({ required: true, loopback: false, peer: '' });
|
|
696
|
-
// Don't toast — the toast spam would be annoying on every
|
|
697
|
-
// Settings tab open. The Copy/Regenerate buttons themselves
|
|
698
|
-
// surface the real error if it happens there.
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
})();
|
|
702
|
-
return () => { cancelled = true; };
|
|
703
|
-
}, []);
|
|
704
|
-
|
|
705
|
-
const onCopyToken = async () => {
|
|
706
|
-
try {
|
|
707
|
-
const r = await api.get<{ token: string }>('/auth/reveal');
|
|
708
|
-
setRevealedToken(r.token);
|
|
709
|
-
setAuthToken(r.token);
|
|
710
|
-
api.setToken(r.token);
|
|
711
|
-
try {
|
|
712
|
-
await navigator.clipboard.writeText(r.token);
|
|
713
|
-
toast.success('Token copied to clipboard.');
|
|
714
|
-
} catch {
|
|
715
|
-
toast.success('Token revealed — copy from the field below.');
|
|
716
|
-
}
|
|
717
|
-
} catch (err) {
|
|
718
|
-
toast.error(`Reveal failed: ${(err as Error).message}`);
|
|
719
|
-
}
|
|
720
|
-
};
|
|
721
|
-
|
|
722
|
-
const onRegenerateToken = async () => {
|
|
723
|
-
if (!confirm('Regenerate the auth token? Anything still using the old token will start getting 401 errors immediately.')) {
|
|
724
|
-
return;
|
|
725
|
-
}
|
|
726
|
-
try {
|
|
727
|
-
const r = await api.post<{ token: string }>('/auth/regenerate');
|
|
728
|
-
setRevealedToken(r.token);
|
|
729
|
-
setAuthToken(r.token);
|
|
730
|
-
api.setToken(r.token);
|
|
731
|
-
try {
|
|
732
|
-
await navigator.clipboard.writeText(r.token);
|
|
733
|
-
toast.success('New token generated and copied to clipboard.');
|
|
734
|
-
} catch {
|
|
735
|
-
toast.success('New token generated — copy from the field below. Old token is now invalid.');
|
|
736
|
-
}
|
|
737
|
-
} catch (err) {
|
|
738
|
-
toast.error(`Regenerate failed: ${(err as Error).message}`);
|
|
739
|
-
}
|
|
740
|
-
};
|
|
741
|
-
|
|
742
|
-
const onSaveToken = () => {
|
|
743
|
-
api.setToken(authToken.trim());
|
|
744
|
-
toast.success('Token saved. The dashboard will use it on the next request.');
|
|
745
|
-
};
|
|
129
|
+
const showAll = activeSection === null;
|
|
130
|
+
const sectionOf = (id: string) => activeSection === id;
|
|
746
131
|
|
|
747
132
|
return (
|
|
748
133
|
<div className="view view-settings">
|
|
749
134
|
<header className="view-header">
|
|
750
135
|
<div className="view-header-text">
|
|
751
|
-
<h2 className="view-title">
|
|
752
|
-
|
|
753
|
-
</h2>
|
|
754
|
-
<p className="view-subtitle">
|
|
755
|
-
Personal preferences. Changes are saved to{' '}
|
|
756
|
-
<code>~/.config/bizar/settings.json</code>.
|
|
757
|
-
</p>
|
|
136
|
+
<h2 className="view-title"><Sliders size={18} /> Settings</h2>
|
|
137
|
+
<p className="view-subtitle">Personal preferences. Changes are saved to <code>~/.config/bizar/settings.json</code>.</p>
|
|
758
138
|
</div>
|
|
759
139
|
<div className="view-actions">
|
|
760
|
-
<Button variant="ghost" size="sm" onClick={onReset}>
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
<RefreshCw size={14} /> Reload
|
|
765
|
-
</Button>
|
|
766
|
-
<Button
|
|
767
|
-
variant="primary"
|
|
768
|
-
size="sm"
|
|
769
|
-
disabled={!dirty || saving}
|
|
770
|
-
onClick={onSave}
|
|
771
|
-
>
|
|
772
|
-
{saving ? <span className="btn-spinner" /> : <Save size={14} />}
|
|
773
|
-
{saving ? 'Saving…' : 'Save'}
|
|
140
|
+
<Button variant="ghost" size="sm" onClick={onReset}><RotateCcw size={14} /> Reset</Button>
|
|
141
|
+
<Button variant="secondary" size="sm" onClick={onReload}><RefreshCw size={14} /> Reload</Button>
|
|
142
|
+
<Button variant="primary" size="sm" disabled={!dirty || saving} onClick={onSave}>
|
|
143
|
+
{saving ? <span className="btn-spinner" /> : <Save size={14} />}{saving ? 'Saving…' : 'Save'}
|
|
774
144
|
</Button>
|
|
775
145
|
</div>
|
|
776
146
|
</header>
|
|
777
147
|
|
|
778
148
|
<nav className="settings-subnav" aria-label="Settings sections">
|
|
779
|
-
<button
|
|
780
|
-
type="button"
|
|
781
|
-
className={cn('settings-subnav-button', 'settings-subnav-button-all', activeSection === null && 'settings-subnav-button-active')}
|
|
782
|
-
onClick={() => onJumpSection(null)}
|
|
783
|
-
title="Show all settings sections"
|
|
784
|
-
>
|
|
785
|
-
All
|
|
786
|
-
</button>
|
|
149
|
+
<button type="button" className={cn('settings-subnav-button', 'settings-subnav-button-all', showAll && 'settings-subnav-button-active')} onClick={() => onJumpSection(null)} title="Show all settings sections">All</button>
|
|
787
150
|
{SECTION_LINKS.map((s) => (
|
|
788
|
-
<button
|
|
789
|
-
key={s.id}
|
|
790
|
-
type="button"
|
|
791
|
-
className={cn('settings-subnav-button', activeSection === s.id && 'settings-subnav-button-active')}
|
|
792
|
-
onClick={() => onJumpSection(s.id)}
|
|
793
|
-
>
|
|
794
|
-
{s.label}
|
|
795
|
-
</button>
|
|
151
|
+
<button key={s.id} type="button" className={cn('settings-subnav-button', activeSection === s.id && 'settings-subnav-button-active')} onClick={() => onJumpSection(s.id)}>{s.label}</button>
|
|
796
152
|
))}
|
|
797
153
|
</nav>
|
|
798
154
|
|
|
799
|
-
<div
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
{
|
|
804
|
-
|
|
805
|
-
{
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
Show all sections
|
|
816
|
-
</button>
|
|
817
|
-
</div>
|
|
818
|
-
)}
|
|
819
|
-
<div data-section="theme" style={{display: (activeSection === null || activeSection === 'theme') ? 'block' : 'none'}}>
|
|
820
|
-
<Card id="settings-theme" data-section="theme">
|
|
821
|
-
<CardTitle><Palette size={14} /> Theme</CardTitle>
|
|
822
|
-
<CardMeta>Mode, accent, and colors. Live preview as you tweak.</CardMeta>
|
|
823
|
-
|
|
824
|
-
{/* v3.3.1 — Accent color presets */}
|
|
825
|
-
<div className="field" data-setting-id="theme.presets">
|
|
826
|
-
<label className="field-label">Accent presets</label>
|
|
827
|
-
<div className="theme-presets">
|
|
828
|
-
{PRESET_THEMES.map((t) => (
|
|
829
|
-
<button
|
|
830
|
-
key={t.name}
|
|
831
|
-
type="button"
|
|
832
|
-
className={cn(
|
|
833
|
-
'theme-preset',
|
|
834
|
-
settings.theme.accent === t.accent && 'theme-preset-active',
|
|
835
|
-
)}
|
|
836
|
-
onClick={() => patchTheme({ accent: t.accent })}
|
|
837
|
-
title={t.name}
|
|
838
|
-
>
|
|
839
|
-
<span
|
|
840
|
-
className="theme-preset-swatch"
|
|
841
|
-
style={{ background: t.accent }}
|
|
842
|
-
/>
|
|
843
|
-
<span className="theme-preset-name">{t.name}</span>
|
|
844
|
-
</button>
|
|
845
|
-
))}
|
|
846
|
-
</div>
|
|
847
|
-
</div>
|
|
848
|
-
|
|
849
|
-
<div className="field" data-setting-id="theme.mode">
|
|
850
|
-
<label className="field-label">Mode</label>
|
|
851
|
-
<div className="theme-row">
|
|
852
|
-
{THEMES.map(({ id, label, Icon }) => {
|
|
853
|
-
const active = settings.theme.mode === id;
|
|
854
|
-
return (
|
|
855
|
-
<button
|
|
856
|
-
key={id}
|
|
857
|
-
type="button"
|
|
858
|
-
className={cn('theme-card', active && 'theme-card-active')}
|
|
859
|
-
onClick={() => patchTheme({ mode: id })}
|
|
860
|
-
>
|
|
861
|
-
<Icon size={16} />
|
|
862
|
-
<span className="theme-card-label">{label}</span>
|
|
863
|
-
<span
|
|
864
|
-
className={cn(
|
|
865
|
-
'theme-card-swatch',
|
|
866
|
-
`theme-card-swatch-${id}`,
|
|
867
|
-
)}
|
|
868
|
-
/>
|
|
869
|
-
</button>
|
|
870
|
-
);
|
|
871
|
-
})}
|
|
872
|
-
</div>
|
|
873
|
-
</div>
|
|
874
|
-
|
|
875
|
-
<div className="theme-colors">
|
|
876
|
-
<div className="field" data-setting-id="theme.accent">
|
|
877
|
-
<label className="field-label">Accent</label>
|
|
878
|
-
<div className="color-row">
|
|
879
|
-
<input
|
|
880
|
-
type="color"
|
|
881
|
-
className="input color-input"
|
|
882
|
-
value={settings.theme.accent}
|
|
883
|
-
onChange={(e) => patchTheme({ accent: e.target.value })}
|
|
884
|
-
aria-label="Accent color"
|
|
885
|
-
/>
|
|
886
|
-
<input
|
|
887
|
-
type="text"
|
|
888
|
-
className="input"
|
|
889
|
-
value={settings.theme.accent}
|
|
890
|
-
onChange={(e) => patchTheme({ accent: e.target.value })}
|
|
891
|
-
aria-label="Accent color hex"
|
|
892
|
-
/>
|
|
893
|
-
</div>
|
|
894
|
-
</div>
|
|
895
|
-
<div className="field" data-setting-id="theme.success">
|
|
896
|
-
<label className="field-label">Success</label>
|
|
897
|
-
<div className="color-row">
|
|
898
|
-
<input
|
|
899
|
-
type="color"
|
|
900
|
-
className="input color-input"
|
|
901
|
-
value={settings.theme.success}
|
|
902
|
-
onChange={(e) => patchTheme({ success: e.target.value })}
|
|
903
|
-
aria-label="Success color"
|
|
904
|
-
/>
|
|
905
|
-
<input
|
|
906
|
-
type="text"
|
|
907
|
-
className="input"
|
|
908
|
-
value={settings.theme.success}
|
|
909
|
-
onChange={(e) => patchTheme({ success: e.target.value })}
|
|
910
|
-
aria-label="Success color hex"
|
|
911
|
-
/>
|
|
912
|
-
</div>
|
|
913
|
-
</div>
|
|
914
|
-
<div className="field" data-setting-id="theme.warning">
|
|
915
|
-
<label className="field-label">Warning</label>
|
|
916
|
-
<div className="color-row">
|
|
917
|
-
<input
|
|
918
|
-
type="color"
|
|
919
|
-
className="input color-input"
|
|
920
|
-
value={settings.theme.warning}
|
|
921
|
-
onChange={(e) => patchTheme({ warning: e.target.value })}
|
|
922
|
-
aria-label="Warning color"
|
|
923
|
-
/>
|
|
924
|
-
<input
|
|
925
|
-
type="text"
|
|
926
|
-
className="input"
|
|
927
|
-
value={settings.theme.warning}
|
|
928
|
-
onChange={(e) => patchTheme({ warning: e.target.value })}
|
|
929
|
-
aria-label="Warning color hex"
|
|
930
|
-
/>
|
|
931
|
-
</div>
|
|
932
|
-
</div>
|
|
933
|
-
<div className="field" data-setting-id="theme.error">
|
|
934
|
-
<label className="field-label">Error</label>
|
|
935
|
-
<div className="color-row">
|
|
936
|
-
<input
|
|
937
|
-
type="color"
|
|
938
|
-
className="input color-input"
|
|
939
|
-
value={settings.theme.error}
|
|
940
|
-
onChange={(e) => patchTheme({ error: e.target.value })}
|
|
941
|
-
aria-label="Error color"
|
|
942
|
-
/>
|
|
943
|
-
<input
|
|
944
|
-
type="text"
|
|
945
|
-
className="input"
|
|
946
|
-
value={settings.theme.error}
|
|
947
|
-
onChange={(e) => patchTheme({ error: e.target.value })}
|
|
948
|
-
aria-label="Error color hex"
|
|
949
|
-
/>
|
|
950
|
-
</div>
|
|
951
|
-
</div>
|
|
952
|
-
<div className="field" data-setting-id="theme.info">
|
|
953
|
-
<label className="field-label">Info</label>
|
|
954
|
-
<div className="color-row">
|
|
955
|
-
<input
|
|
956
|
-
type="color"
|
|
957
|
-
className="input color-input"
|
|
958
|
-
value={settings.theme.info}
|
|
959
|
-
onChange={(e) => patchTheme({ info: e.target.value })}
|
|
960
|
-
aria-label="Info color"
|
|
961
|
-
/>
|
|
962
|
-
<input
|
|
963
|
-
type="text"
|
|
964
|
-
className="input"
|
|
965
|
-
value={settings.theme.info}
|
|
966
|
-
onChange={(e) => patchTheme({ info: e.target.value })}
|
|
967
|
-
aria-label="Info color hex"
|
|
968
|
-
/>
|
|
969
|
-
</div>
|
|
970
|
-
</div>
|
|
971
|
-
</div>
|
|
972
|
-
|
|
973
|
-
<div className="field" data-setting-id="theme.fontFamily">
|
|
974
|
-
<label className="field-label">Font family</label>
|
|
975
|
-
<select
|
|
976
|
-
className="select"
|
|
977
|
-
value={settings.theme.fontFamily}
|
|
978
|
-
onChange={(e) => patchTheme({ fontFamily: e.target.value })}
|
|
979
|
-
>
|
|
980
|
-
{FONT_FAMILIES.map((f) => (
|
|
981
|
-
<option key={f} value={f}>{f}</option>
|
|
982
|
-
))}
|
|
983
|
-
</select>
|
|
984
|
-
</div>
|
|
985
|
-
<div className="field" data-setting-id="theme.fontSize">
|
|
986
|
-
<label className="field-label">Font size: {settings.theme.fontSize}px</label>
|
|
987
|
-
<input
|
|
988
|
-
type="range"
|
|
989
|
-
min={12}
|
|
990
|
-
max={20}
|
|
991
|
-
value={settings.theme.fontSize}
|
|
992
|
-
onChange={(e) => patchTheme({ fontSize: Number(e.target.value) })}
|
|
993
|
-
/>
|
|
994
|
-
</div>
|
|
995
|
-
<label className="checkbox-row" data-setting-id="theme.compactMode">
|
|
996
|
-
<input
|
|
997
|
-
type="checkbox"
|
|
998
|
-
checked={settings.theme.compactMode}
|
|
999
|
-
onChange={(e) => patchTheme({ compactMode: e.target.checked })}
|
|
1000
|
-
/>
|
|
1001
|
-
<span>Compact mode (denser UI)</span>
|
|
1002
|
-
</label>
|
|
1003
|
-
<label className="checkbox-row" data-setting-id="theme.animations">
|
|
1004
|
-
<input
|
|
1005
|
-
type="checkbox"
|
|
1006
|
-
checked={settings.theme.animations}
|
|
1007
|
-
onChange={(e) => patchTheme({ animations: e.target.checked })}
|
|
1008
|
-
/>
|
|
1009
|
-
<span>Enable animations</span>
|
|
1010
|
-
</label>
|
|
1011
|
-
</Card>
|
|
1012
|
-
</div>
|
|
1013
|
-
|
|
1014
|
-
<div data-section="updates" style={{display: (activeSection === null || activeSection === 'updates') ? 'block' : 'none'}}>
|
|
1015
|
-
<UpdatesCard />
|
|
1016
|
-
</div>
|
|
1017
|
-
|
|
1018
|
-
<div data-section="layout" style={{display: (activeSection === null || activeSection === 'layout') ? 'block' : 'none'}}>
|
|
1019
|
-
<Card id="settings-layout" data-section="layout">
|
|
1020
|
-
<CardTitle><LayoutIcon size={14} /> UI layout</CardTitle>
|
|
1021
|
-
<CardMeta>Choose how the dashboard's navigation is presented.</CardMeta>
|
|
1022
|
-
<div className="layout-row" data-setting-id="ui.layout">
|
|
1023
|
-
{LAYOUTS.map((l) => (
|
|
1024
|
-
<button
|
|
1025
|
-
key={l.id}
|
|
1026
|
-
type="button"
|
|
1027
|
-
className={cn('layout-card', settings.ui.layout === l.id && 'layout-card-active')}
|
|
1028
|
-
onClick={() => patchUi({ layout: l.id })}
|
|
1029
|
-
>
|
|
1030
|
-
<span className="layout-card-label">{l.label}</span>
|
|
1031
|
-
</button>
|
|
1032
|
-
))}
|
|
1033
|
-
</div>
|
|
1034
|
-
<label className="checkbox-row" data-setting-id="ui.showHeader">
|
|
1035
|
-
<input
|
|
1036
|
-
type="checkbox"
|
|
1037
|
-
checked={settings.ui.showHeader}
|
|
1038
|
-
onChange={(e) => patchUi({ showHeader: e.target.checked })}
|
|
1039
|
-
/>
|
|
1040
|
-
<span>Show header</span>
|
|
1041
|
-
</label>
|
|
1042
|
-
<label className="checkbox-row" data-setting-id="ui.showStatusBar">
|
|
1043
|
-
<input
|
|
1044
|
-
type="checkbox"
|
|
1045
|
-
checked={settings.ui.showStatusBar}
|
|
1046
|
-
onChange={(e) => patchUi({ showStatusBar: e.target.checked })}
|
|
1047
|
-
/>
|
|
1048
|
-
<span>Show status bar</span>
|
|
1049
|
-
</label>
|
|
1050
|
-
<div className="field" data-setting-id="ui.defaultTab">
|
|
1051
|
-
<label className="field-label">Default tab</label>
|
|
1052
|
-
<select
|
|
1053
|
-
className="select"
|
|
1054
|
-
value={settings.ui.defaultTab}
|
|
1055
|
-
onChange={(e) => patchUi({ defaultTab: e.target.value })}
|
|
1056
|
-
>
|
|
1057
|
-
<option value="overview">Overview</option>
|
|
1058
|
-
<option value="chat">Chat</option>
|
|
1059
|
-
<option value="agents">Agents</option>
|
|
1060
|
-
<option value="artifacts">Plans</option>
|
|
1061
|
-
<option value="projects">Projects</option>
|
|
1062
|
-
<option value="tasks">Tasks</option>
|
|
1063
|
-
<option value="config">Config</option>
|
|
1064
|
-
<option value="settings">Settings</option>
|
|
1065
|
-
<option value="mods">Mods</option>
|
|
1066
|
-
<option value="schedules">Schedules</option>
|
|
1067
|
-
</select>
|
|
1068
|
-
</div>
|
|
1069
|
-
</Card>
|
|
1070
|
-
</div>
|
|
1071
|
-
|
|
1072
|
-
<div data-section="general" style={{display: (activeSection === null || activeSection === 'general') ? 'block' : 'none'}}>
|
|
1073
|
-
<Card id="settings-general" data-section="general">
|
|
1074
|
-
<CardTitle>General</CardTitle>
|
|
1075
|
-
<CardMeta>Default agent + model override.</CardMeta>
|
|
1076
|
-
<div className="field" data-setting-id="defaultAgent">
|
|
1077
|
-
<label className="field-label" htmlFor="set-default-agent">Default agent</label>
|
|
1078
|
-
<input
|
|
1079
|
-
id="set-default-agent"
|
|
1080
|
-
className="input"
|
|
1081
|
-
type="text"
|
|
1082
|
-
placeholder="e.g. odin"
|
|
1083
|
-
value={settings.defaultAgent || ''}
|
|
1084
|
-
onChange={(e) => patchTop('defaultAgent', e.target.value)}
|
|
1085
|
-
/>
|
|
1086
|
-
</div>
|
|
1087
|
-
<div className="field" data-setting-id="defaultModel">
|
|
1088
|
-
<label className="field-label" htmlFor="set-default-model">Model override</label>
|
|
1089
|
-
<input
|
|
1090
|
-
id="set-default-model"
|
|
1091
|
-
className="input"
|
|
1092
|
-
type="text"
|
|
1093
|
-
placeholder="(leave empty for provider default)"
|
|
1094
|
-
value={settings.defaultModel || ''}
|
|
1095
|
-
onChange={(e) => patchTop('defaultModel', e.target.value)}
|
|
1096
|
-
/>
|
|
1097
|
-
</div>
|
|
1098
|
-
</Card>
|
|
1099
|
-
</div>
|
|
1100
|
-
|
|
1101
|
-
<div data-section="service" style={{display: (activeSection === null || activeSection === 'service') ? 'block' : 'none'}}>
|
|
1102
|
-
<Card id="settings-service" data-section="service">
|
|
1103
|
-
<CardTitle><ServerIcon size={14} /> Service</CardTitle>
|
|
1104
|
-
<CardMeta>Background daemon that runs schedules.</CardMeta>
|
|
1105
|
-
<div data-setting-id="service.enabled">
|
|
1106
|
-
{tailscale ? (
|
|
1107
|
-
<div className="service-card">
|
|
1108
|
-
<p>
|
|
1109
|
-
Status: <strong>{tailscale?.settings.enabled ? 'enabled' : 'disabled'}</strong>
|
|
1110
|
-
{' '}· Tailscale installed: <strong>{tailscale.installed ? 'yes' : 'no'}</strong>
|
|
1111
|
-
{' '}· authenticated: <strong>{tailscale.authenticated ? 'yes' : 'no'}</strong>
|
|
1112
|
-
</p>
|
|
1113
|
-
<p className="muted">
|
|
1114
|
-
Use <code>bizar service start</code> / <code>bizar service stop</code> in
|
|
1115
|
-
your terminal to control the daemon.
|
|
1116
|
-
</p>
|
|
1117
|
-
</div>
|
|
1118
|
-
) : (
|
|
1119
|
-
<p className="muted">Loading service status…</p>
|
|
1120
|
-
)}
|
|
1121
|
-
</div>
|
|
1122
|
-
</Card>
|
|
1123
|
-
</div>
|
|
1124
|
-
|
|
1125
|
-
<div data-section="tailscale" style={{display: (activeSection === null || activeSection === 'tailscale') ? 'block' : 'none'}}>
|
|
1126
|
-
<Card id="settings-tailscale" data-section="tailscale">
|
|
1127
|
-
<CardTitle><Plug size={14} /> Tailscale serve</CardTitle>
|
|
1128
|
-
<CardMeta>Expose the dashboard over your Tailscale network.</CardMeta>
|
|
1129
|
-
{tailscale ? (
|
|
1130
|
-
<>
|
|
1131
|
-
<p>
|
|
1132
|
-
Installed: <strong>{tailscale.installed ? 'yes' : 'no'}</strong>{' '}
|
|
1133
|
-
{tailscale.version && <span className="muted">({tailscale.version})</span>}
|
|
1134
|
-
</p>
|
|
1135
|
-
<p>
|
|
1136
|
-
Authenticated: <strong>{tailscale.authenticated ? 'yes' : 'no'}</strong>
|
|
1137
|
-
</p>
|
|
1138
|
-
<p>
|
|
1139
|
-
Serve enabled: <strong>{tailscale.settings.enabled ? 'yes' : 'no'}</strong>
|
|
1140
|
-
</p>
|
|
1141
|
-
<div className="task-form-row">
|
|
1142
|
-
<div className="task-form-field">
|
|
1143
|
-
<label className="field-label">Port</label>
|
|
1144
|
-
<input
|
|
1145
|
-
type="number"
|
|
1146
|
-
className="input"
|
|
1147
|
-
value={tailscaleDraft.port}
|
|
1148
|
-
onChange={(e) => setTailscaleDraft((cur) => ({ ...cur, port: Number(e.target.value) || 4321 }))}
|
|
1149
|
-
/>
|
|
1150
|
-
</div>
|
|
1151
|
-
<div className="task-form-field">
|
|
1152
|
-
<label className="field-label" htmlFor="tailscale-https">Use HTTPS</label>
|
|
1153
|
-
<input
|
|
1154
|
-
id="tailscale-https"
|
|
1155
|
-
type="checkbox"
|
|
1156
|
-
checked={tailscaleDraft.https}
|
|
1157
|
-
onChange={(e) => setTailscaleDraft((cur) => ({ ...cur, https: e.target.checked }))}
|
|
1158
|
-
/>
|
|
1159
|
-
</div>
|
|
1160
|
-
</div>
|
|
1161
|
-
<Button variant="primary" onClick={onTailscaleToggle}>
|
|
1162
|
-
{tailscale.settings.enabled ? 'Disable serve' : 'Enable serve'}
|
|
1163
|
-
</Button>
|
|
1164
|
-
</>
|
|
1165
|
-
) : (
|
|
1166
|
-
<p className="muted">Loading Tailscale status…</p>
|
|
1167
|
-
)}
|
|
1168
|
-
</Card>
|
|
1169
|
-
</div>
|
|
1170
|
-
|
|
1171
|
-
<div data-section="notifications" style={{display: (activeSection === null || activeSection === 'notifications') ? 'block' : 'none'}}>
|
|
1172
|
-
<Card id="settings-notifications" data-section="notifications">
|
|
1173
|
-
<CardTitle>Notifications</CardTitle>
|
|
1174
|
-
<CardMeta>Toast triggers inside the dashboard.</CardMeta>
|
|
1175
|
-
<label className="checkbox-row" data-setting-id="notifications.onAgentComplete">
|
|
1176
|
-
<input
|
|
1177
|
-
type="checkbox"
|
|
1178
|
-
checked={!!settings.notifications.onAgentComplete}
|
|
1179
|
-
onChange={(e) => patchNotifications({ onAgentComplete: e.target.checked })}
|
|
1180
|
-
/>
|
|
1181
|
-
<span>Notify when an agent invocation completes</span>
|
|
1182
|
-
</label>
|
|
1183
|
-
<label className="checkbox-row" data-setting-id="notifications.onPlanApproval">
|
|
1184
|
-
<input
|
|
1185
|
-
type="checkbox"
|
|
1186
|
-
checked={!!settings.notifications.onPlanApproval}
|
|
1187
|
-
onChange={(e) => patchNotifications({ onPlanApproval: e.target.checked })}
|
|
1188
|
-
/>
|
|
1189
|
-
<span>Notify when a plan needs approval</span>
|
|
1190
|
-
</label>
|
|
1191
|
-
</Card>
|
|
1192
|
-
</div>
|
|
1193
|
-
|
|
1194
|
-
{/* v3.6.0 — Auth token management. The Settings tab is the
|
|
1195
|
-
only place a human can read the token. The input + Save
|
|
1196
|
-
button lets the operator paste a token they got from
|
|
1197
|
-
server stderr or from another machine. Copy / Regenerate
|
|
1198
|
-
buttons act via the authed /api/auth/* endpoints. */}
|
|
1199
|
-
<div data-section="auth" style={{display: (activeSection === null || activeSection === 'auth') ? 'block' : 'none'}}>
|
|
1200
|
-
<Card id="settings-auth" data-section="auth">
|
|
1201
|
-
<CardTitle><Shield size={14} /> Authentication</CardTitle>
|
|
1202
|
-
<CardMeta>
|
|
1203
|
-
Localhost and Tailscale browser access are auto-trusted via loopback.
|
|
1204
|
-
A bearer token is still available for non-loopback clients and
|
|
1205
|
-
forced-auth mode.
|
|
1206
|
-
</CardMeta>
|
|
1207
|
-
<div className="field" data-setting-id="auth.status">
|
|
1208
|
-
<label className="field-label">Server status</label>
|
|
1209
|
-
<p style={{ margin: '4px 0' }}>
|
|
1210
|
-
Auth required:{' '}
|
|
1211
|
-
<strong>{authStatus ? (authStatus.required ? 'yes' : 'no') : 'probing…'}</strong>
|
|
1212
|
-
</p>
|
|
1213
|
-
<p style={{ margin: '4px 0' }}>
|
|
1214
|
-
Connection:{' '}
|
|
1215
|
-
<strong>
|
|
1216
|
-
{authStatus
|
|
1217
|
-
? (authStatus.loopback ? 'loopback (auto-trusted)' : 'remote')
|
|
1218
|
-
: 'probing…'}
|
|
1219
|
-
</strong>
|
|
1220
|
-
</p>
|
|
1221
|
-
<p style={{ margin: '4px 0' }}>
|
|
1222
|
-
Peer address:{' '}
|
|
1223
|
-
{authStatus?.peer ? <code>{authStatus.peer}</code> : <span className="muted">probing…</span>}
|
|
1224
|
-
</p>
|
|
1225
|
-
<p className="muted" style={{ fontSize: 12, margin: '4px 0' }}>
|
|
1226
|
-
Localhost and Tailscale browser access are auto-trusted because the
|
|
1227
|
-
dashboard sees a loopback peer. Paste a token only for non-loopback
|
|
1228
|
-
API clients/scripts, or if you force auth for every connection with{' '}
|
|
1229
|
-
<code>BIZAR_DASHBOARD_REQUIRE_AUTH=1</code>.
|
|
1230
|
-
</p>
|
|
1231
|
-
<p className="muted" style={{ fontSize: 12, margin: '4px 0' }}>
|
|
1232
|
-
Dashboard tokens are generated on first boot and saved to{' '}
|
|
1233
|
-
<code>~/.config/bizar/dashboard-secret</code> (mode 0600).
|
|
1234
|
-
</p>
|
|
1235
|
-
{/* v3.6.2 */}
|
|
1236
|
-
<p className="muted" style={{ fontSize: 12, margin: '4px 0' }}>
|
|
1237
|
-
For Tailscale Serve or any reverse-proxy access, paste this token
|
|
1238
|
-
once via the boot screen — it is saved per-origin and works for all
|
|
1239
|
-
subsequent visits.
|
|
1240
|
-
</p>
|
|
1241
|
-
</div>
|
|
1242
|
-
<div className="field" data-setting-id="auth.token">
|
|
1243
|
-
<label className="field-label">Token (this browser)</label>
|
|
1244
|
-
<input
|
|
1245
|
-
type="password"
|
|
1246
|
-
className="input mono"
|
|
1247
|
-
value={authToken}
|
|
1248
|
-
onChange={(e) => setAuthToken(e.target.value)}
|
|
1249
|
-
placeholder="Paste token from server stderr or another browser"
|
|
1250
|
-
spellCheck={false}
|
|
1251
|
-
autoComplete="off"
|
|
1252
|
-
/>
|
|
1253
|
-
<div className="task-form-row" style={{ marginTop: 8 }}>
|
|
1254
|
-
<Button variant="secondary" size="sm" onClick={onSaveToken}>
|
|
1255
|
-
<KeyRound size={14} /> Save token
|
|
1256
|
-
</Button>
|
|
1257
|
-
<Button variant="ghost" size="sm" onClick={onCopyToken}>
|
|
1258
|
-
<Copy size={14} /> Reveal & copy server token
|
|
1259
|
-
</Button>
|
|
1260
|
-
<Button variant="ghost" size="sm" onClick={onRegenerateToken}>
|
|
1261
|
-
<RotateCcw size={14} /> Regenerate
|
|
1262
|
-
</Button>
|
|
1263
|
-
</div>
|
|
1264
|
-
{revealedToken ? (
|
|
1265
|
-
<p className="muted" style={{ fontSize: 12, marginTop: 8 }}>
|
|
1266
|
-
Last revealed token (one-time): <code className="mono">{revealedToken}</code>
|
|
1267
|
-
</p>
|
|
1268
|
-
) : null}
|
|
1269
|
-
<p className="muted" style={{ fontSize: 12, marginTop: 8 }}>
|
|
1270
|
-
Regenerating invalidates the current token immediately. Anything
|
|
1271
|
-
still using the old token will see 401 until it's updated.
|
|
1272
|
-
</p>
|
|
1273
|
-
</div>
|
|
1274
|
-
</Card>
|
|
1275
|
-
</div>
|
|
1276
|
-
|
|
1277
|
-
<div data-section="agents" style={{display: (activeSection === null || activeSection === 'agents') ? 'block' : 'none'}}>
|
|
1278
|
-
<Card id="settings-agents" data-section="agents">
|
|
1279
|
-
<CardTitle><ServerIcon size={14} /> Agent Behavior</CardTitle>
|
|
1280
|
-
<CardMeta>Limits and timeouts for background agent dispatch.</CardMeta>
|
|
1281
|
-
<div className="form-row">
|
|
1282
|
-
<label htmlFor="agents-maxParallel">
|
|
1283
|
-
Max parallel agents
|
|
1284
|
-
<span className="meta-badge">default: 6</span>
|
|
1285
|
-
</label>
|
|
1286
|
-
<input
|
|
1287
|
-
id="agents-maxParallel"
|
|
1288
|
-
type="number"
|
|
1289
|
-
min={1}
|
|
1290
|
-
max={20}
|
|
1291
|
-
value={settings.agents?.maxParallel ?? 6}
|
|
1292
|
-
onChange={(e) => patchAgents({ maxParallel: Math.max(1, Math.min(20, parseInt(e.target.value, 10) || 6)) })}
|
|
1293
|
-
/>
|
|
1294
|
-
</div>
|
|
1295
|
-
<div className="form-row">
|
|
1296
|
-
<label htmlFor="agents-stuckThresholdMs">
|
|
1297
|
-
Stuck threshold (ms)
|
|
1298
|
-
<span className="meta-badge">default: 600000 (10 min)</span>
|
|
1299
|
-
</label>
|
|
1300
|
-
<input
|
|
1301
|
-
id="agents-stuckThresholdMs"
|
|
1302
|
-
type="number"
|
|
1303
|
-
min={60000}
|
|
1304
|
-
max={3600000}
|
|
1305
|
-
step={60000}
|
|
1306
|
-
value={settings.agents?.stuckThresholdMs ?? 600000}
|
|
1307
|
-
onChange={(e) => patchAgents({ stuckThresholdMs: Math.max(60000, Math.min(3600000, parseInt(e.target.value, 10) || 600000)) })}
|
|
1308
|
-
/>
|
|
1309
|
-
</div>
|
|
1310
|
-
<label className="checkbox-row" data-setting-id="agents.autoRestart">
|
|
1311
|
-
<input
|
|
1312
|
-
type="checkbox"
|
|
1313
|
-
checked={!!settings.agents?.autoRestart}
|
|
1314
|
-
onChange={(e) => patchAgents({ autoRestart: e.target.checked })}
|
|
1315
|
-
/>
|
|
1316
|
-
<span>Auto-restart stuck agents</span>
|
|
1317
|
-
</label>
|
|
1318
|
-
</Card>
|
|
1319
|
-
</div>
|
|
1320
|
-
|
|
1321
|
-
<div data-section="dashboard" style={{display: (activeSection === null || activeSection === 'dashboard') ? 'block' : 'none'}}>
|
|
1322
|
-
<Card id="settings-dashboard" data-section="dashboard">
|
|
1323
|
-
<CardTitle><Globe size={14} /> Dashboard</CardTitle>
|
|
1324
|
-
<CardMeta>Controls how <code>bizar</code> starts up.</CardMeta>
|
|
1325
|
-
<label className="checkbox-row" data-setting-id="dashboard.autoLaunchWeb">
|
|
1326
|
-
<input
|
|
1327
|
-
type="checkbox"
|
|
1328
|
-
checked={settings.dashboard.autoLaunchWeb !== false}
|
|
1329
|
-
onChange={(e) => patchDashboard({ autoLaunchWeb: e.target.checked })}
|
|
1330
|
-
/>
|
|
1331
|
-
<span>Auto-launch web UI alongside TUI</span>
|
|
1332
|
-
</label>
|
|
1333
|
-
|
|
1334
|
-
<div className="field" data-setting-id="dashboard.projectsDirectory" style={{ marginTop: 'var(--space-4)' }}>
|
|
1335
|
-
<label className="field-label" htmlFor="set-projects-directory">Projects directory</label>
|
|
1336
|
-
<input
|
|
1337
|
-
id="set-projects-directory"
|
|
1338
|
-
className="input"
|
|
1339
|
-
type="text"
|
|
1340
|
-
placeholder="/home/user/projects"
|
|
1341
|
-
value={settings.dashboard.projectsDirectory ?? ''}
|
|
1342
|
-
onChange={(e) => patchDashboard({ projectsDirectory: e.target.value })}
|
|
1343
|
-
/>
|
|
1344
|
-
<p className="field-help">
|
|
1345
|
-
New projects created via the dashboard will land here, and existing project
|
|
1346
|
-
directories inside this folder are auto-recognized on startup.
|
|
1347
|
-
</p>
|
|
1348
|
-
{settings.dashboard.projectsDirectory && (
|
|
1349
|
-
<>
|
|
1350
|
-
{!/^\/|^[A-Za-z]:/.test(settings.dashboard.projectsDirectory) && (
|
|
1351
|
-
<p style={{ color: 'var(--warning)', fontSize: 11, marginTop: 4 }}>
|
|
1352
|
-
Path should be absolute (start with / on Linux/Mac, or a drive letter on Windows).
|
|
1353
|
-
</p>
|
|
1354
|
-
)}
|
|
1355
|
-
{settings.dashboard.projectsDirectory.includes('..') && (
|
|
1356
|
-
<p style={{ color: 'var(--error)', fontSize: 11, marginTop: 4 }}>
|
|
1357
|
-
Path traversal not allowed — this will be rejected server-side.
|
|
1358
|
-
</p>
|
|
1359
|
-
)}
|
|
1360
|
-
</>
|
|
1361
|
-
)}
|
|
1362
|
-
</div>
|
|
1363
|
-
|
|
1364
|
-
{/* allowedRoots textarea */}
|
|
1365
|
-
<div className="field" data-setting-id="dashboard.allowedRoots" style={{ marginTop: 'var(--space-4)' }}>
|
|
1366
|
-
<label className="field-label" htmlFor="set-allowed-roots">
|
|
1367
|
-
Additional allowed roots <span className="muted">(advanced)</span>
|
|
1368
|
-
</label>
|
|
1369
|
-
<textarea
|
|
1370
|
-
id="set-allowed-roots"
|
|
1371
|
-
className="textarea"
|
|
1372
|
-
rows={4}
|
|
1373
|
-
placeholder="/workspace /srv/projects"
|
|
1374
|
-
value={(settings.dashboard.allowedRoots ?? []).join('\n')}
|
|
1375
|
-
onChange={(e) => {
|
|
1376
|
-
const lines = e.target.value
|
|
1377
|
-
.split('\n')
|
|
1378
|
-
.map((l) => l.trim())
|
|
1379
|
-
.filter(Boolean);
|
|
1380
|
-
patchDashboard({ allowedRoots: lines });
|
|
1381
|
-
}}
|
|
1382
|
-
/>
|
|
1383
|
-
<p className="field-help">
|
|
1384
|
-
Optional. Add filesystem roots beyond your home directory that the file
|
|
1385
|
-
browser and project scanner can access. Each path must be inside your
|
|
1386
|
-
home directory. One per line.
|
|
1387
|
-
</p>
|
|
1388
|
-
{(() => {
|
|
1389
|
-
const rawLines = (settings.dashboard.allowedRoots ?? []).join('\n').split('\n');
|
|
1390
|
-
const warnings: { key: string; msg: React.ReactNode }[] = [];
|
|
1391
|
-
rawLines.forEach((line, i) => {
|
|
1392
|
-
if (!line.trim()) return;
|
|
1393
|
-
if (!/^\/|^[A-Za-z]:/.test(line)) {
|
|
1394
|
-
warnings.push({
|
|
1395
|
-
key: `noabs-${i}`,
|
|
1396
|
-
msg: (
|
|
1397
|
-
<p style={{ color: 'var(--warning)', fontSize: 11, marginTop: 2 }}>
|
|
1398
|
-
Line {i + 1}: "{line}" — should be absolute (start with / or a drive letter).
|
|
1399
|
-
</p>
|
|
1400
|
-
),
|
|
1401
|
-
});
|
|
1402
|
-
}
|
|
1403
|
-
if (line.includes('..')) {
|
|
1404
|
-
warnings.push({
|
|
1405
|
-
key: `dots-${i}`,
|
|
1406
|
-
msg: (
|
|
1407
|
-
<p style={{ color: 'var(--error)', fontSize: 11, marginTop: 2 }}>
|
|
1408
|
-
Line {i + 1}: "{line}" — contains '..' (server will reject this).
|
|
1409
|
-
</p>
|
|
1410
|
-
),
|
|
1411
|
-
});
|
|
1412
|
-
}
|
|
1413
|
-
});
|
|
1414
|
-
return warnings.map((w) => w.msg);
|
|
1415
|
-
})()}
|
|
1416
|
-
</div>
|
|
1417
|
-
</Card>
|
|
1418
|
-
</div>
|
|
1419
|
-
|
|
1420
|
-
<div data-section="background" style={{display: (activeSection === null || activeSection === 'background') ? 'block' : 'none'}}>
|
|
1421
|
-
<Card id="settings-background" data-section="background">
|
|
1422
|
-
<CardTitle><ServerIcon size={14} /> Background Agents</CardTitle>
|
|
1423
|
-
<CardMeta>Tune plugin options. Changes take effect on next plugin restart.</CardMeta>
|
|
1424
|
-
|
|
1425
|
-
<div className="form-row">
|
|
1426
|
-
<label htmlFor="bg-maxConcurrent">
|
|
1427
|
-
Max concurrent instances
|
|
1428
|
-
<span className="meta-badge">default: 8</span>
|
|
1429
|
-
</label>
|
|
1430
|
-
<input
|
|
1431
|
-
id="bg-maxConcurrent"
|
|
1432
|
-
type="number"
|
|
1433
|
-
min={1}
|
|
1434
|
-
max={32}
|
|
1435
|
-
value={pluginOptions.maxConcurrentInstances ?? 8}
|
|
1436
|
-
onChange={(e) => setPluginOptions((cur) => ({ ...cur, maxConcurrentInstances: Math.max(1, Math.min(32, parseInt(e.target.value, 10) || 8)) }))}
|
|
1437
|
-
/>
|
|
1438
|
-
<small className="muted">Plugin option: <code>maxConcurrentInstances</code></small>
|
|
1439
|
-
</div>
|
|
1440
|
-
|
|
1441
|
-
<div className="form-row">
|
|
1442
|
-
<label htmlFor="bg-toolCallCap">
|
|
1443
|
-
Tool-call cap
|
|
1444
|
-
<span className="meta-badge">default: 500</span>
|
|
1445
|
-
</label>
|
|
1446
|
-
<input
|
|
1447
|
-
id="bg-toolCallCap"
|
|
1448
|
-
type="number"
|
|
1449
|
-
min={1}
|
|
1450
|
-
max={5000}
|
|
1451
|
-
value={pluginOptions.backgroundToolCallCap ?? 500}
|
|
1452
|
-
onChange={(e) => setPluginOptions((cur) => ({ ...cur, backgroundToolCallCap: Math.max(1, Math.min(5000, parseInt(e.target.value, 10) || 500)) }))}
|
|
1453
|
-
/>
|
|
1454
|
-
<small className="muted">Plugin option: <code>backgroundToolCallCap</code></small>
|
|
1455
|
-
</div>
|
|
1456
|
-
|
|
1457
|
-
<div className="form-row">
|
|
1458
|
-
<label htmlFor="bg-stallTimeout">
|
|
1459
|
-
Stall timeout (ms)
|
|
1460
|
-
<span className="meta-badge">default: 180000</span>
|
|
1461
|
-
</label>
|
|
1462
|
-
<input
|
|
1463
|
-
id="bg-stallTimeout"
|
|
1464
|
-
type="number"
|
|
1465
|
-
min={10000}
|
|
1466
|
-
max={600000}
|
|
1467
|
-
step={1000}
|
|
1468
|
-
value={pluginOptions.backgroundStallTimeoutMs ?? 180000}
|
|
1469
|
-
onChange={(e) => setPluginOptions((cur) => ({ ...cur, backgroundStallTimeoutMs: Math.max(10000, Math.min(600000, parseInt(e.target.value, 10) || 180000)) }))}
|
|
1470
|
-
/>
|
|
1471
|
-
<small className="muted">Plugin option: <code>backgroundStallTimeoutMs</code></small>
|
|
1472
|
-
</div>
|
|
1473
|
-
|
|
1474
|
-
<div className="form-row">
|
|
1475
|
-
<label htmlFor="bg-thinkingLoopTimeout">
|
|
1476
|
-
Thinking-loop timeout (ms)
|
|
1477
|
-
<span className="meta-badge">default: 300000</span>
|
|
1478
|
-
</label>
|
|
1479
|
-
<input
|
|
1480
|
-
id="bg-thinkingLoopTimeout"
|
|
1481
|
-
type="number"
|
|
1482
|
-
min={30000}
|
|
1483
|
-
max={900000}
|
|
1484
|
-
step={1000}
|
|
1485
|
-
value={pluginOptions.backgroundThinkingLoopTimeoutMs ?? 300000}
|
|
1486
|
-
onChange={(e) => setPluginOptions((cur) => ({ ...cur, backgroundThinkingLoopTimeoutMs: Math.max(30000, Math.min(900000, parseInt(e.target.value, 10) || 300000)) }))}
|
|
1487
|
-
/>
|
|
1488
|
-
<small className="muted">Plugin option: <code>backgroundThinkingLoopTimeoutMs</code></small>
|
|
1489
|
-
</div>
|
|
1490
|
-
|
|
1491
|
-
<div className="form-row">
|
|
1492
|
-
<label htmlFor="bg-maxInterventions">
|
|
1493
|
-
Max interventions
|
|
1494
|
-
<span className="meta-badge">default: 1</span>
|
|
1495
|
-
</label>
|
|
1496
|
-
<input
|
|
1497
|
-
id="bg-maxInterventions"
|
|
1498
|
-
type="number"
|
|
1499
|
-
min={1}
|
|
1500
|
-
max={3}
|
|
1501
|
-
value={pluginOptions.backgroundMaxInterventions ?? 1}
|
|
1502
|
-
onChange={(e) => setPluginOptions((cur) => ({ ...cur, backgroundMaxInterventions: Math.max(1, Math.min(3, parseInt(e.target.value, 10) || 1)) }))}
|
|
1503
|
-
/>
|
|
1504
|
-
<small className="muted">Plugin option: <code>backgroundMaxInterventions</code></small>
|
|
1505
|
-
</div>
|
|
1506
|
-
|
|
1507
|
-
<div className="form-row">
|
|
1508
|
-
<Button variant="secondary" size="sm" onClick={async () => {
|
|
1509
|
-
try {
|
|
1510
|
-
await api.put('/settings/plugin-options', pluginOptions);
|
|
1511
|
-
toast.success('Saved — restart opencode for changes to take effect.');
|
|
1512
|
-
} catch (err) {
|
|
1513
|
-
toast.error(`Save failed: ${(err as Error).message}`);
|
|
1514
|
-
}
|
|
1515
|
-
}}>
|
|
1516
|
-
<Save size={14} /> Save plugin options
|
|
1517
|
-
</Button>
|
|
1518
|
-
|
|
1519
|
-
<Button variant="secondary" size="sm" onClick={async () => {
|
|
1520
|
-
try {
|
|
1521
|
-
const r = await fetch('/api/background/cleanup', {
|
|
1522
|
-
method: 'POST',
|
|
1523
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1524
|
-
body: JSON.stringify({ maxAgeDays: 7 }),
|
|
1525
|
-
});
|
|
1526
|
-
const result = await r.json();
|
|
1527
|
-
toast.success(`Cleaned up ${result.deleted} old instances.`);
|
|
1528
|
-
} catch (err) {
|
|
1529
|
-
toast.error(`Cleanup failed: ${(err as Error).message}`);
|
|
1530
|
-
}
|
|
1531
|
-
}}>
|
|
1532
|
-
Cleanup old instances (>7 days)
|
|
1533
|
-
</Button>
|
|
1534
|
-
</div>
|
|
1535
|
-
|
|
1536
|
-
<div className="form-row">
|
|
1537
|
-
<small>Plugin options are read at startup. Save changes and run <code>bizar update</code> to apply.</small>
|
|
1538
|
-
</div>
|
|
1539
|
-
</Card>
|
|
1540
|
-
</div>
|
|
155
|
+
<div className={cn('settings-grid', activeSection && 'settings-grid-filtered')} data-active-section={activeSection || undefined}>
|
|
156
|
+
{(showAll || sectionOf('theme')) && <ThemeSection {...sp} />}
|
|
157
|
+
{(showAll || sectionOf('updates')) && <UpdatesSection />}
|
|
158
|
+
{(showAll || sectionOf('layout') || sectionOf('general')) && <GeneralSection {...sp} />}
|
|
159
|
+
{(showAll || sectionOf('network') || sectionOf('service') || sectionOf('tailscale')) && <NetworkSection tailscale={tailscale} tailscaleDraft={tailscaleDraft} setTailscaleDraft={setTailscaleDraft} onTailscaleToggle={onTailscaleToggle} />}
|
|
160
|
+
{(showAll || sectionOf('notifications')) && <NotificationsSection {...sp} />}
|
|
161
|
+
{(showAll || sectionOf('auth')) && <AuthSection settings={settings} authStatus={authStatus} setAuthStatus={setAuthStatus} />}
|
|
162
|
+
{(showAll || sectionOf('agents') || sectionOf('dashboard') || sectionOf('background')) && <AgentSection {...sp} />}
|
|
163
|
+
{(showAll || sectionOf('system-llm')) && <SystemLlmSection {...sp} />}
|
|
164
|
+
{(showAll || sectionOf('headroom')) && <HeadroomSection settings={settings} setSettings={setSettings} setDirty={setDirty} />}
|
|
165
|
+
{(showAll || sectionOf('activity-log') || sectionOf('about')) && <ActivitySection about={about} />}
|
|
166
|
+
{(showAll || sectionOf('env-vars')) && <EnvVarsSection />}
|
|
167
|
+
{(showAll || sectionOf('providers')) && <ProvidersSection />}
|
|
168
|
+
{(showAll || sectionOf('memory')) && <MemorySection />}
|
|
169
|
+
{(showAll || sectionOf('skills')) && <SkillsSection />}
|
|
170
|
+
{(showAll || sectionOf('backup')) && <BackupSection />}
|
|
1541
171
|
</div>
|
|
1542
|
-
|
|
1543
|
-
<div data-section="system-llm" style={{display: (activeSection === null || activeSection === 'system-llm') ? 'block' : 'none'}}>
|
|
1544
|
-
<Card id="settings-system-llm" data-section="system-llm">
|
|
1545
|
-
<CardTitle><Sparkles size={14} /> System LLM API</CardTitle>
|
|
1546
|
-
<CardMeta>
|
|
1547
|
-
Configures the LLM used for automatic title generation, prompt enhancement,
|
|
1548
|
-
summarization, and other system-level calls.
|
|
1549
|
-
</CardMeta>
|
|
1550
|
-
|
|
1551
|
-
<label className="checkbox-row" data-setting-id="systemLlm.enabled">
|
|
1552
|
-
<input
|
|
1553
|
-
type="checkbox"
|
|
1554
|
-
checked={!!settings.systemLlm?.enabled}
|
|
1555
|
-
onChange={(e) => {
|
|
1556
|
-
const cur = settings.systemLlm || { enabled: true, provider: 'opencode', model: 'opencode/deepseek-v4-flash-free' };
|
|
1557
|
-
patchTop('systemLlm', { ...cur, enabled: e.target.checked });
|
|
1558
|
-
}}
|
|
1559
|
-
/>
|
|
1560
|
-
<span>Enable system LLM calls</span>
|
|
1561
|
-
</label>
|
|
1562
|
-
|
|
1563
|
-
<div className="field" data-setting-id="systemLlm.provider" style={{ marginTop: 'var(--space-3)' }}>
|
|
1564
|
-
<label className="field-label" htmlFor="set-system-llm-provider">Provider</label>
|
|
1565
|
-
<select
|
|
1566
|
-
id="set-system-llm-provider"
|
|
1567
|
-
className="select"
|
|
1568
|
-
value={settings.systemLlm?.provider || 'opencode'}
|
|
1569
|
-
onChange={(e) => {
|
|
1570
|
-
const cur = settings.systemLlm || { enabled: true, provider: 'opencode', model: 'opencode/deepseek-v4-flash-free' };
|
|
1571
|
-
patchTop('systemLlm', { ...cur, provider: e.target.value });
|
|
1572
|
-
}}
|
|
1573
|
-
>
|
|
1574
|
-
<option value="opencode">Opencode</option>
|
|
1575
|
-
<option value="openrouter">OpenRouter</option>
|
|
1576
|
-
<option value="minimax">MiniMax</option>
|
|
1577
|
-
</select>
|
|
1578
|
-
</div>
|
|
1579
|
-
|
|
1580
|
-
<div className="field" data-setting-id="systemLlm.model">
|
|
1581
|
-
<label className="field-label" htmlFor="set-system-llm-model">Model</label>
|
|
1582
|
-
<input
|
|
1583
|
-
id="set-system-llm-model"
|
|
1584
|
-
className="input mono"
|
|
1585
|
-
type="text"
|
|
1586
|
-
placeholder="opencode/deepseek-v4-flash-free"
|
|
1587
|
-
value={settings.systemLlm?.model || 'opencode/deepseek-v4-flash-free'}
|
|
1588
|
-
onChange={(e) => {
|
|
1589
|
-
const cur = settings.systemLlm || { enabled: true, provider: 'opencode', model: 'opencode/deepseek-v4-flash-free' };
|
|
1590
|
-
patchTop('systemLlm', { ...cur, model: e.target.value });
|
|
1591
|
-
}}
|
|
1592
|
-
/>
|
|
1593
|
-
<p className="field-help">
|
|
1594
|
-
The API key is read from <code>auth.json</code> for the selected provider.
|
|
1595
|
-
Leave the default model for best results.
|
|
1596
|
-
</p>
|
|
1597
|
-
</div>
|
|
1598
|
-
|
|
1599
|
-
<div style={{ marginTop: 'var(--space-4)' }}>
|
|
1600
|
-
<h4 style={{ margin: '0 0 var(--space-2)' }}>Features using this API</h4>
|
|
1601
|
-
<ul className="settings-feature-list" style={{ margin: 0, paddingLeft: 'var(--space-4)', lineHeight: 1.8 }}>
|
|
1602
|
-
<li>Auto-title generation for new chat sessions</li>
|
|
1603
|
-
<li>Auto-title generation for new tasks</li>
|
|
1604
|
-
<li>"Enhance prompt" button in the task input</li>
|
|
1605
|
-
<li>"Enhance prompt" button in the chat composer</li>
|
|
1606
|
-
<li className="muted" style={{ fontSize: 12 }}>Future: summarization, name generation, and more</li>
|
|
1607
|
-
</ul>
|
|
1608
|
-
</div>
|
|
1609
|
-
</Card>
|
|
1610
|
-
</div>
|
|
1611
|
-
|
|
1612
|
-
<div data-section="headroom" style={{display: (activeSection === null || activeSection === 'headroom') ? 'block' : 'none'}}>
|
|
1613
|
-
<HeadroomSettingsCard
|
|
1614
|
-
settings={settings.headroom || DEFAULT_HEADROOM_SETTINGS}
|
|
1615
|
-
onPatch={(patch) => {
|
|
1616
|
-
setSettings((cur) => ({
|
|
1617
|
-
...cur,
|
|
1618
|
-
headroom: { ...(cur.headroom || DEFAULT_HEADROOM_SETTINGS), ...patch },
|
|
1619
|
-
}));
|
|
1620
|
-
setDirty(true);
|
|
1621
|
-
}}
|
|
1622
|
-
/>
|
|
1623
|
-
</div>
|
|
1624
|
-
|
|
1625
|
-
<PairDeviceCard />
|
|
1626
|
-
|
|
1627
|
-
<div style={{display: (activeSection === null || activeSection === 'activity-log') ? 'block' : 'none'}}>
|
|
1628
|
-
<div data-section="activity-log" style={{display: (activeSection === null || activeSection === 'activity-log') ? 'block' : 'none'}}>
|
|
1629
|
-
<section id="settings-activity-log" data-section="activity-log" className="settings-section-wrap">
|
|
1630
|
-
<ActivityLogCard />
|
|
1631
|
-
</section>
|
|
1632
|
-
</div>
|
|
1633
|
-
</div>
|
|
1634
|
-
|
|
1635
|
-
<div style={{display: (activeSection === null || activeSection === 'about') ? 'block' : 'none'}}>
|
|
1636
|
-
<div data-section="about" style={{display: (activeSection === null || activeSection === 'about') ? 'block' : 'none'}}>
|
|
1637
|
-
<Card id="settings-about" data-section="about">
|
|
1638
|
-
<CardTitle><Info size={14} /> About</CardTitle>
|
|
1639
|
-
<CardMeta>Build metadata.</CardMeta>
|
|
1640
|
-
<dl className="about-table">
|
|
1641
|
-
<dt>Version</dt>
|
|
1642
|
-
<dd className="mono">{about.version}</dd>
|
|
1643
|
-
<dt>Homepage</dt>
|
|
1644
|
-
<dd>
|
|
1645
|
-
<a href={about.homepage} target="_blank" rel="noopener noreferrer">
|
|
1646
|
-
{about.homepage}
|
|
1647
|
-
</a>
|
|
1648
|
-
</dd>
|
|
1649
|
-
<dt>License</dt>
|
|
1650
|
-
<dd>{about.license}</dd>
|
|
1651
|
-
</dl>
|
|
1652
|
-
</Card>
|
|
1653
|
-
</div>
|
|
1654
|
-
</div>
|
|
1655
172
|
</div>
|
|
1656
173
|
);
|
|
1657
174
|
}
|
|
1658
175
|
|
|
1659
|
-
/**
|
|
1660
|
-
* v3.15.0 — Full activity log + hidden set management.
|
|
1661
|
-
*
|
|
1662
|
-
* Two sources of truth:
|
|
1663
|
-
* - GET /activity — the full unfiltered log (incl. hidden entries)
|
|
1664
|
-
* - GET /activity/hidden — the list of keys the user chose to hide
|
|
1665
|
-
*
|
|
1666
|
-
* Hiding is non-destructive: hidden items still appear here so the
|
|
1667
|
-
* user can review / restore them.
|
|
1668
|
-
*/
|
|
1669
|
-
function ActivityLogCard() {
|
|
1670
|
-
const toast = useToast();
|
|
1671
|
-
const [items, setItems] = useState<Array<{ kind?: string; ts?: string; slug?: string; message?: string }>>([]);
|
|
1672
|
-
const [hidden, setHidden] = useState<Set<string>>(new Set());
|
|
1673
|
-
const [loading, setLoading] = useState(true);
|
|
1674
|
-
const [filter, setFilter] = useState('');
|
|
1675
|
-
const [showHidden, setShowHidden] = useState(true);
|
|
1676
|
-
|
|
1677
|
-
const refresh = useCallback(async () => {
|
|
1678
|
-
setLoading(true);
|
|
1679
|
-
try {
|
|
1680
|
-
const [log, hid] = await Promise.all([
|
|
1681
|
-
api.get<{ items: Array<{ kind?: string; ts?: string; slug?: string; message?: string }> }>('/activity'),
|
|
1682
|
-
api.get<{ hidden: string[] }>('/activity/hidden'),
|
|
1683
|
-
]);
|
|
1684
|
-
setItems(Array.isArray(log.items) ? log.items : []);
|
|
1685
|
-
setHidden(new Set(hid.hidden || []));
|
|
1686
|
-
} catch (err) {
|
|
1687
|
-
toast.error(`Load failed: ${(err as Error).message}`);
|
|
1688
|
-
} finally {
|
|
1689
|
-
setLoading(false);
|
|
1690
|
-
}
|
|
1691
|
-
}, [toast]);
|
|
1692
|
-
|
|
1693
|
-
useEffect(() => { refresh(); }, [refresh]);
|
|
1694
|
-
|
|
1695
|
-
const keyOf = (it: { kind?: string; ts?: string; slug?: string }, idx: number) => {
|
|
1696
|
-
const k = `${it.kind || ''}|${it.ts || ''}|${it.slug || ''}|${idx}`;
|
|
1697
|
-
let h = 0;
|
|
1698
|
-
for (let i = 0; i < k.length; i++) h = ((h << 5) - h + k.charCodeAt(i)) | 0;
|
|
1699
|
-
return Math.abs(h).toString(16).padStart(8, '0').slice(0, 16);
|
|
1700
|
-
};
|
|
1701
|
-
|
|
1702
|
-
const onRestoreAll = async () => {
|
|
1703
|
-
try {
|
|
1704
|
-
await api.del('/activity/hide');
|
|
1705
|
-
setHidden(new Set());
|
|
1706
|
-
toast.success('All hidden activity restored.');
|
|
1707
|
-
} catch (err) {
|
|
1708
|
-
toast.error(`Restore failed: ${(err as Error).message}`);
|
|
1709
|
-
}
|
|
1710
|
-
};
|
|
1711
|
-
|
|
1712
|
-
const onRestoreOne = async (key: string) => {
|
|
1713
|
-
try {
|
|
1714
|
-
await api.del(`/activity/hide/${encodeURIComponent(key)}`);
|
|
1715
|
-
const next = new Set(hidden);
|
|
1716
|
-
next.delete(key);
|
|
1717
|
-
setHidden(next);
|
|
1718
|
-
} catch (err) {
|
|
1719
|
-
toast.error(`Restore failed: ${(err as Error).message}`);
|
|
1720
|
-
}
|
|
1721
|
-
};
|
|
1722
|
-
|
|
1723
|
-
const filtered = items.filter((it, idx) => {
|
|
1724
|
-
const k = keyOf(it, idx);
|
|
1725
|
-
if (!showHidden && hidden.has(k)) return false;
|
|
1726
|
-
if (!filter) return true;
|
|
1727
|
-
const q = filter.toLowerCase();
|
|
1728
|
-
return [it.kind, it.slug, it.message].some((v) => typeof v === 'string' && v.toLowerCase().includes(q));
|
|
1729
|
-
});
|
|
1730
|
-
|
|
1731
|
-
return (
|
|
1732
|
-
<Card id="settings-diagnostics" data-section="diagnostics">
|
|
1733
|
-
<CardTitle>
|
|
1734
|
-
<Activity size={14} /> Activity log
|
|
1735
|
-
<span className="muted" style={{ fontWeight: 400, marginLeft: 8, fontSize: 12 }}>
|
|
1736
|
-
{items.length} total · {hidden.size} hidden
|
|
1737
|
-
</span>
|
|
1738
|
-
<Button variant="ghost" size="sm" style={{ marginLeft: 'auto' }} onClick={refresh} title="Reload">
|
|
1739
|
-
<RefreshCw size={12} />
|
|
1740
|
-
</Button>
|
|
1741
|
-
</CardTitle>
|
|
1742
|
-
<CardMeta>
|
|
1743
|
-
Full history from <code>~/.bizar/activity.log</code>. Hiding an item in the Overview only hides it there — the entry stays here.
|
|
1744
|
-
</CardMeta>
|
|
1745
|
-
|
|
1746
|
-
<div className="activity-log-toolbar">
|
|
1747
|
-
<div className="activity-log-search">
|
|
1748
|
-
<Search size={12} />
|
|
1749
|
-
<input
|
|
1750
|
-
type="text"
|
|
1751
|
-
className="input"
|
|
1752
|
-
placeholder="Filter by kind, slug, or message…"
|
|
1753
|
-
value={filter}
|
|
1754
|
-
onChange={(e) => setFilter(e.target.value)}
|
|
1755
|
-
/>
|
|
1756
|
-
</div>
|
|
1757
|
-
<label className="activity-log-toggle">
|
|
1758
|
-
<input
|
|
1759
|
-
type="checkbox"
|
|
1760
|
-
checked={showHidden}
|
|
1761
|
-
onChange={(e) => setShowHidden(e.target.checked)}
|
|
1762
|
-
/>
|
|
1763
|
-
Show hidden
|
|
1764
|
-
</label>
|
|
1765
|
-
<Button
|
|
1766
|
-
variant="ghost"
|
|
1767
|
-
size="sm"
|
|
1768
|
-
disabled={hidden.size === 0}
|
|
1769
|
-
onClick={onRestoreAll}
|
|
1770
|
-
title="Restore all hidden items to the Overview"
|
|
1771
|
-
>
|
|
1772
|
-
<Eye size={12} /> Restore all
|
|
1773
|
-
</Button>
|
|
1774
|
-
</div>
|
|
1775
|
-
|
|
1776
|
-
{loading ? (
|
|
1777
|
-
<div className="muted" style={{ padding: '12px 0', fontSize: 12 }}>Loading…</div>
|
|
1778
|
-
) : filtered.length === 0 ? (
|
|
1779
|
-
<div className="muted" style={{ padding: '12px 0', fontSize: 12 }}>
|
|
1780
|
-
{items.length === 0 ? 'No activity yet.' : 'No items match the current filter.'}
|
|
1781
|
-
</div>
|
|
1782
|
-
) : (
|
|
1783
|
-
<div className="activity-log-table-wrap">
|
|
1784
|
-
<table className="activity-log-table">
|
|
1785
|
-
<thead>
|
|
1786
|
-
<tr>
|
|
1787
|
-
<th>Kind</th>
|
|
1788
|
-
<th>Detail</th>
|
|
1789
|
-
<th>Time</th>
|
|
1790
|
-
<th></th>
|
|
1791
|
-
</tr>
|
|
1792
|
-
</thead>
|
|
1793
|
-
<tbody>
|
|
1794
|
-
{filtered.slice(0, 200).map((it, idx) => {
|
|
1795
|
-
const k = keyOf(it, idx);
|
|
1796
|
-
const isHidden = hidden.has(k);
|
|
1797
|
-
return (
|
|
1798
|
-
<tr key={`${it.ts}-${idx}`} className={cn(isHidden && 'activity-log-row-hidden')}>
|
|
1799
|
-
<td className="activity-log-kind">{it.kind || 'activity'}</td>
|
|
1800
|
-
<td className="activity-log-detail">
|
|
1801
|
-
{it.message || it.slug || '—'}
|
|
1802
|
-
</td>
|
|
1803
|
-
<td className="activity-log-time mono">
|
|
1804
|
-
{it.ts ? new Date(it.ts).toLocaleString() : '—'}
|
|
1805
|
-
</td>
|
|
1806
|
-
<td>
|
|
1807
|
-
{isHidden ? (
|
|
1808
|
-
<Button variant="ghost" size="sm" onClick={() => onRestoreOne(k)} title="Restore to Overview">
|
|
1809
|
-
<Eye size={12} />
|
|
1810
|
-
</Button>
|
|
1811
|
-
) : (
|
|
1812
|
-
<span className="activity-log-state-tag">shown</span>
|
|
1813
|
-
)}
|
|
1814
|
-
</td>
|
|
1815
|
-
</tr>
|
|
1816
|
-
);
|
|
1817
|
-
})}
|
|
1818
|
-
</tbody>
|
|
1819
|
-
</table>
|
|
1820
|
-
{filtered.length > 200 && (
|
|
1821
|
-
<div className="muted" style={{ fontSize: 11, padding: '8px 0' }}>
|
|
1822
|
-
Showing first 200 of {filtered.length}.
|
|
1823
|
-
</div>
|
|
1824
|
-
)}
|
|
1825
|
-
</div>
|
|
1826
|
-
)}
|
|
1827
|
-
</Card>
|
|
1828
|
-
);
|
|
1829
|
-
}
|
|
1830
176
|
export const SettingsView = React.memo(SettingsViewInner);
|