@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.
- package/bizar-dash/CHANGELOG.md +37 -276
- package/bizar-dash/dist/assets/main-CDFKHzBg.css +1 -0
- package/bizar-dash/dist/assets/main-NYFpS2wY.js +312 -0
- package/bizar-dash/dist/assets/main-NYFpS2wY.js.map +1 -0
- package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js → mobile--0FBIKX3.js} +2 -2
- package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js.map → mobile--0FBIKX3.js.map} +1 -1
- package/bizar-dash/dist/assets/mobile-OgRp8VIb.js +352 -0
- package/bizar-dash/dist/assets/mobile-OgRp8VIb.js.map +1 -0
- package/bizar-dash/dist/index.html +3 -3
- package/bizar-dash/dist/mobile.html +2 -2
- package/bizar-dash/skills/agent-baseline/SKILL.md +80 -0
- package/bizar-dash/skills/bizar/SKILL.md +96 -0
- package/bizar-dash/skills/chat/SKILL.md +74 -0
- package/bizar-dash/skills/lightrag/SKILL.md +75 -0
- package/bizar-dash/skills/minimax/SKILL.md +80 -0
- package/bizar-dash/skills/obsidian/SKILL.md +55 -0
- package/bizar-dash/skills/providers/SKILL.md +75 -0
- package/bizar-dash/skills/sdk/SKILL.md +138 -0
- package/bizar-dash/skills/self-improvement/SKILL.md +53 -0
- package/bizar-dash/skills/skills-cli/SKILL.md +94 -0
- package/bizar-dash/skills/usage/SKILL.md +62 -0
- package/bizar-dash/src/server/api.mjs +12 -0
- package/bizar-dash/src/server/memory-lightrag.mjs +5 -2
- package/bizar-dash/src/server/memory-store.mjs +38 -0
- package/bizar-dash/src/server/minimax-usage-store.mjs +372 -0
- package/bizar-dash/src/server/minimax.mjs +196 -5
- package/bizar-dash/src/server/providers-store.mjs +956 -0
- package/bizar-dash/src/server/routes/config.mjs +52 -1
- package/bizar-dash/src/server/routes/env-vars.mjs +165 -0
- package/bizar-dash/src/server/routes/lightrag.mjs +154 -0
- package/bizar-dash/src/server/routes/memory.mjs +241 -1
- package/bizar-dash/src/server/routes/opencode-session-detail.mjs +14 -29
- package/bizar-dash/src/server/routes/opencode-sessions.mjs +205 -3
- package/bizar-dash/src/server/routes/providers.mjs +266 -5
- package/bizar-dash/src/server/routes/skills.mjs +32 -43
- package/bizar-dash/src/server/routes/update.mjs +340 -0
- package/bizar-dash/src/server/routes/usage.mjs +136 -0
- package/bizar-dash/src/server/serve-info.mjs +135 -4
- package/bizar-dash/src/server/server.mjs +4 -0
- package/bizar-dash/src/server/skills-store.mjs +152 -262
- package/bizar-dash/src/web/App.tsx +118 -29
- package/bizar-dash/src/web/components/EnvVarManager.tsx +247 -0
- package/bizar-dash/src/web/components/SettingsSearch.tsx +213 -0
- package/bizar-dash/src/web/components/Topbar.tsx +0 -1
- package/bizar-dash/src/web/components/UsageChart.tsx +250 -0
- package/bizar-dash/src/web/components/UsageTable.tsx +90 -0
- package/bizar-dash/src/web/components/chat/ChatComposer.tsx +21 -25
- package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +199 -37
- package/bizar-dash/src/web/components/chat/ChatThread.tsx +29 -17
- package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +7 -1
- package/bizar-dash/src/web/components/chat/InfoPanel.tsx +71 -6
- package/bizar-dash/src/web/components/chat/useChat.ts +751 -257
- package/bizar-dash/src/web/lib/api.ts +43 -0
- package/bizar-dash/src/web/main.tsx +1 -0
- package/bizar-dash/src/web/mobile/views/MobileChat.tsx +110 -35
- package/bizar-dash/src/web/styles/chat.css +135 -1
- package/bizar-dash/src/web/styles/main.css +46 -0
- package/bizar-dash/src/web/styles/minimax-usage.css +335 -0
- package/bizar-dash/src/web/styles/settings.css +418 -0
- package/bizar-dash/src/web/styles/skills.css +302 -0
- package/bizar-dash/src/web/styles/tasks.css +288 -0
- package/bizar-dash/src/web/views/Chat.tsx +276 -48
- package/bizar-dash/src/web/views/Config.tsx +3 -2065
- package/bizar-dash/src/web/views/MiniMaxUsage.tsx +476 -461
- package/bizar-dash/src/web/views/Settings.tsx +6 -0
- package/bizar-dash/src/web/views/Skills.tsx +208 -260
- package/bizar-dash/src/web/views/Tasks.tsx +348 -1119
- package/bizar-dash/tests/chat-session-create.test.mjs +391 -0
- package/bizar-dash/tests/chat-session-stream.test.mjs +308 -0
- package/bizar-dash/tests/env-vars-store.test.mjs +216 -0
- package/bizar-dash/tests/lightrag-defaults.node.test.mjs +118 -0
- package/bizar-dash/tests/minimax-chat-usage.test.mjs +178 -0
- package/bizar-dash/tests/minimax-usage-store.node.test.mjs +293 -0
- package/bizar-dash/tests/opencode-sessions-detail.test.mjs +12 -9
- package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +479 -3
- package/bizar-dash/tests/providers-store-search.node.test.mjs +166 -0
- package/bizar-dash/tests/skills-list.test.mjs +232 -0
- package/bizar-dash/tests/skills-search.test.mjs +222 -0
- package/bizar-dash/tests/tasks-create.test.mjs +187 -0
- package/bizar-dash/tests/update-check.test.mjs +127 -0
- package/bizar-dash/tests/update-run.test.mjs +266 -0
- package/cli/bin.mjs +82 -1
- package/cli/provision.mjs +118 -4
- package/config/agents/_shared/SKILLS.md +109 -0
- package/package.json +1 -1
- package/bizar-dash/dist/assets/main-BB5mJurD.js +0 -352
- package/bizar-dash/dist/assets/main-BB5mJurD.js.map +0 -1
- package/bizar-dash/dist/assets/main-BsnQLXdh.css +0 -1
- package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js +0 -354
- package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js.map +0 -1
|
@@ -7,8 +7,8 @@ import { ModalProvider, useModal } from './components/Modal';
|
|
|
7
7
|
import { ToastProvider, useToast } from './components/Toast';
|
|
8
8
|
import { SearchModal } from './components/SearchModal';
|
|
9
9
|
import { Notifications } from './components/Notifications';
|
|
10
|
+
import { api, ApiError } from './lib/api';
|
|
10
11
|
import { CommandDialog, type DialogDescriptor } from './components/CommandDialog';
|
|
11
|
-
import { api } from './lib/api';
|
|
12
12
|
import { Ws } from './lib/ws';
|
|
13
13
|
import {
|
|
14
14
|
applyTheme,
|
|
@@ -79,7 +79,7 @@ const VIEW_MAP: Record<string, (p: ViewProps) => React.ReactNode> = {
|
|
|
79
79
|
minimax: MiniMaxUsage,
|
|
80
80
|
};
|
|
81
81
|
|
|
82
|
-
const VERSION = '
|
|
82
|
+
const VERSION = 'v4.5.0';
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
85
|
* Render the active view. If `activeTab` matches a built-in tab id,
|
|
@@ -113,6 +113,67 @@ function renderActiveView(
|
|
|
113
113
|
return <V {...viewProps} />;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
// v3.6.2 — Inline token-entry form shown on the boot-error screen when
|
|
117
|
+
// the server returns 401 (reverse-proxy / Tailscale Serve scenario).
|
|
118
|
+
// The user pastes the token, it is saved to localStorage, and boot is
|
|
119
|
+
// retried. The form closes on Escape or on successful save.
|
|
120
|
+
function TokenEntryForm({ onSaved }: { onSaved: () => void }) {
|
|
121
|
+
const [value, setValue] = useState('');
|
|
122
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
123
|
+
|
|
124
|
+
// Auto-focus the input on mount
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
inputRef.current?.focus();
|
|
127
|
+
}, []);
|
|
128
|
+
|
|
129
|
+
const submit = () => {
|
|
130
|
+
const tok = value.trim();
|
|
131
|
+
if (!tok) return;
|
|
132
|
+
api.setToken(tok);
|
|
133
|
+
onSaved();
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
137
|
+
if (e.key === 'Escape') {
|
|
138
|
+
e.preventDefault();
|
|
139
|
+
onSaved();
|
|
140
|
+
}
|
|
141
|
+
if (e.key === 'Enter') {
|
|
142
|
+
e.preventDefault();
|
|
143
|
+
submit();
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<form
|
|
149
|
+
className="token-entry-form"
|
|
150
|
+
onSubmit={(e) => { e.preventDefault(); submit(); }}
|
|
151
|
+
>
|
|
152
|
+
<div className="token-entry-row">
|
|
153
|
+
<input
|
|
154
|
+
ref={inputRef}
|
|
155
|
+
type="password"
|
|
156
|
+
className="input mono"
|
|
157
|
+
value={value}
|
|
158
|
+
onChange={(e) => setValue(e.target.value)}
|
|
159
|
+
placeholder="Paste auth token"
|
|
160
|
+
spellCheck={false}
|
|
161
|
+
autoComplete="off"
|
|
162
|
+
onKeyDown={handleKeyDown}
|
|
163
|
+
/>
|
|
164
|
+
<Button type="submit" variant="primary" size="sm" disabled={!value.trim()}>
|
|
165
|
+
Save & retry
|
|
166
|
+
</Button>
|
|
167
|
+
</div>
|
|
168
|
+
<p className="token-entry-hint">
|
|
169
|
+
Where do I find this token? Check the file{' '}
|
|
170
|
+
<code>~/.config/bizar/dashboard-secret</code> on the machine
|
|
171
|
+
running the dashboard, or look for it in the server output.
|
|
172
|
+
</p>
|
|
173
|
+
</form>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
116
177
|
export function App() {
|
|
117
178
|
return (
|
|
118
179
|
<ToastProvider>
|
|
@@ -133,6 +194,7 @@ function Shell() {
|
|
|
133
194
|
const [settings, setSettings] = useState<Settings | null>(null);
|
|
134
195
|
const [wsStatus, setWsStatus] = useState<WsStatus>('connecting');
|
|
135
196
|
const [bootError, setBootError] = useState<string | null>(null);
|
|
197
|
+
const [authPromptOpen, setAuthPromptOpen] = useState(false);
|
|
136
198
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
137
199
|
const [stuckAgents, setStuckAgents] = useState<{ name: string }[]>([]);
|
|
138
200
|
const [stuckBannerDismissed, setStuckBannerDismissed] = useState(false);
|
|
@@ -196,9 +258,11 @@ function Shell() {
|
|
|
196
258
|
return () => mql.removeEventListener('change', handler);
|
|
197
259
|
}, [settings?.theme?.mode]);
|
|
198
260
|
|
|
199
|
-
//
|
|
200
|
-
|
|
261
|
+
// v3.6.2 — Boot logic extracted to retryBoot so it can be re-invoked
|
|
262
|
+
// after the user pastes a token in the inline recovery form.
|
|
263
|
+
const retryBoot = useCallback(async () => {
|
|
201
264
|
let cancelled = false;
|
|
265
|
+
|
|
202
266
|
const loadBootData = () =>
|
|
203
267
|
Promise.all([
|
|
204
268
|
api.get<Snapshot>('/snapshot').catch(() => null),
|
|
@@ -216,42 +280,59 @@ function Shell() {
|
|
|
216
280
|
if (stuck?.stuck) setStuckAgents(stuck.stuck);
|
|
217
281
|
};
|
|
218
282
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
if (cancelled) return;
|
|
283
|
+
try {
|
|
284
|
+
const auth = await api.probeAuthStatus();
|
|
285
|
+
const [snap, set, stuck] = await loadBootData();
|
|
286
|
+
if (cancelled) return;
|
|
224
287
|
|
|
225
|
-
|
|
226
|
-
|
|
288
|
+
applyBootData(snap, set, stuck);
|
|
289
|
+
if (snap || set) {
|
|
290
|
+
setBootError(null);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
227
293
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
294
|
+
if (!auth.loopback) {
|
|
295
|
+
setBootError('Dashboard server unreachable.');
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
232
298
|
|
|
233
|
-
|
|
234
|
-
|
|
299
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
300
|
+
if (cancelled) return;
|
|
235
301
|
|
|
236
|
-
|
|
237
|
-
|
|
302
|
+
const [retrySnap, retrySet, retryStuck] = await loadBootData();
|
|
303
|
+
if (cancelled) return;
|
|
238
304
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
305
|
+
applyBootData(retrySnap, retrySet, retryStuck);
|
|
306
|
+
if (!retrySnap && !retrySet) {
|
|
307
|
+
setBootError('Dashboard server unreachable.');
|
|
308
|
+
}
|
|
309
|
+
} catch (err) {
|
|
310
|
+
if (cancelled) return;
|
|
311
|
+
const msg = (err as Error)?.message ?? 'unknown error';
|
|
312
|
+
// v3.6.2 — If we got a 401, the user needs to supply a token.
|
|
313
|
+
// Surface the form inline instead of just the error message.
|
|
314
|
+
if (err instanceof ApiError && err.status === 401) {
|
|
246
315
|
setBootError(msg);
|
|
247
|
-
|
|
316
|
+
setAuthPromptOpen(true);
|
|
317
|
+
toast.error(`Auth required: ${msg}`);
|
|
318
|
+
return;
|
|
248
319
|
}
|
|
249
|
-
|
|
320
|
+
setBootError(msg);
|
|
321
|
+
toast.error(`Failed to load: ${msg}`);
|
|
322
|
+
}
|
|
323
|
+
}, [toast]);
|
|
250
324
|
|
|
325
|
+
// Initial fetch — runs once on mount
|
|
326
|
+
useEffect(() => {
|
|
327
|
+
let cancelled = false;
|
|
328
|
+
const id = setTimeout(() => {
|
|
329
|
+
if (!cancelled) retryBoot();
|
|
330
|
+
}, 0);
|
|
251
331
|
return () => {
|
|
252
332
|
cancelled = true;
|
|
333
|
+
clearTimeout(id);
|
|
253
334
|
};
|
|
254
|
-
}, [
|
|
335
|
+
}, [retryBoot]);
|
|
255
336
|
|
|
256
337
|
// Apply defaultTab ONCE on mount only — must NOT re-fire on toast changes
|
|
257
338
|
useEffect(() => {
|
|
@@ -695,6 +776,14 @@ function Shell() {
|
|
|
695
776
|
<div className="boot-error">
|
|
696
777
|
<h2>Dashboard unavailable</h2>
|
|
697
778
|
<p>{bootError}</p>
|
|
779
|
+
{authPromptOpen && (
|
|
780
|
+
<TokenEntryForm
|
|
781
|
+
onSaved={() => {
|
|
782
|
+
setAuthPromptOpen(false);
|
|
783
|
+
retryBoot();
|
|
784
|
+
}}
|
|
785
|
+
/>
|
|
786
|
+
)}
|
|
698
787
|
<p className="boot-error-hint">
|
|
699
788
|
Make sure the Bizar dashboard server is running. Try{' '}
|
|
700
789
|
<code>bizar-dash start</code> in your terminal.
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// src/web/components/EnvVarManager.tsx — manage BIZAR_* env vars from ~/.config/bizar/env.json
|
|
2
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
3
|
+
import { Plus, Pencil, Trash2, Eye, EyeOff, RefreshCw, KeyRound, CheckCircle, X } from 'lucide-react';
|
|
4
|
+
import { Button } from './Button';
|
|
5
|
+
import { useToast } from './Toast';
|
|
6
|
+
import { api } from '../lib/api';
|
|
7
|
+
import { cn } from '../lib/utils';
|
|
8
|
+
|
|
9
|
+
export type EnvVarEntry = {
|
|
10
|
+
name: string;
|
|
11
|
+
value: string; // masked on GET, full on edit
|
|
12
|
+
createdAt: string;
|
|
13
|
+
source: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type Props = {
|
|
17
|
+
onError?: (msg: string) => void;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function EnvVarManager({ onError }: Props) {
|
|
21
|
+
const toast = useToast();
|
|
22
|
+
const [vars, setVars] = useState<EnvVarEntry[]>([]);
|
|
23
|
+
const [loading, setLoading] = useState(false);
|
|
24
|
+
const [saving, setSaving] = useState(false);
|
|
25
|
+
// Which rows have the value revealed (edit mode shows real value)
|
|
26
|
+
const [revealed, setRevealed] = useState<Set<string>>(new Set());
|
|
27
|
+
// Currently editing name (null = none)
|
|
28
|
+
const [editing, setEditing] = useState<string | null>(null);
|
|
29
|
+
const [editValue, setEditValue] = useState('');
|
|
30
|
+
// New var form
|
|
31
|
+
const [showAdd, setShowAdd] = useState(false);
|
|
32
|
+
const [newName, setNewName] = useState('');
|
|
33
|
+
const [newValue, setNewValue] = useState('');
|
|
34
|
+
|
|
35
|
+
const load = useCallback(async () => {
|
|
36
|
+
setLoading(true);
|
|
37
|
+
try {
|
|
38
|
+
const r = await api.get<EnvVarEntry[]>('/env-vars');
|
|
39
|
+
setVars(r);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
toast.error(`Load failed: ${(err as Error).message}`);
|
|
42
|
+
onError?.((err as Error).message);
|
|
43
|
+
} finally {
|
|
44
|
+
setLoading(false);
|
|
45
|
+
}
|
|
46
|
+
}, [toast, onError]);
|
|
47
|
+
|
|
48
|
+
useEffect(() => { load(); }, [load]);
|
|
49
|
+
|
|
50
|
+
const toggleReveal = (name: string) => {
|
|
51
|
+
setRevealed((prev) => {
|
|
52
|
+
const next = new Set(prev);
|
|
53
|
+
if (next.has(name)) next.delete(name);
|
|
54
|
+
else next.add(name);
|
|
55
|
+
return next;
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const handleAdd = async () => {
|
|
60
|
+
const name = newName.trim().toUpperCase();
|
|
61
|
+
const value = newValue.trim();
|
|
62
|
+
if (!name || !/^BIZAR_[A-Z0-9_]+$/.test(name)) {
|
|
63
|
+
toast.warning('Name must match BIZAR_<NAME> (e.g. BIZAR_MINIMAX_KEY)');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (!value) {
|
|
67
|
+
toast.warning('Value is required.');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (vars.some((v) => v.name === name)) {
|
|
71
|
+
toast.warning(`${name} already exists.`);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
setSaving(true);
|
|
75
|
+
try {
|
|
76
|
+
await api.post('/env-vars', { name, value });
|
|
77
|
+
toast.success(`${name} created.`);
|
|
78
|
+
setShowAdd(false);
|
|
79
|
+
setNewName('');
|
|
80
|
+
setNewValue('');
|
|
81
|
+
await load();
|
|
82
|
+
} catch (err) {
|
|
83
|
+
toast.error(`Create failed: ${(err as Error).message}`);
|
|
84
|
+
} finally {
|
|
85
|
+
setSaving(false);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const startEdit = (v: EnvVarEntry) => {
|
|
90
|
+
setEditing(v.name);
|
|
91
|
+
setEditValue(v.value === v.value.replace(/\*/g, '') ? v.value : '');
|
|
92
|
+
setRevealed((prev) => { const next = new Set(prev); next.add(v.name); return next; });
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const handleUpdate = async (name: string) => {
|
|
96
|
+
if (!editValue.trim()) {
|
|
97
|
+
toast.warning('Value cannot be empty.');
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
setSaving(true);
|
|
101
|
+
try {
|
|
102
|
+
await api.put(`/env-vars/${encodeURIComponent(name)}`, { value: editValue.trim() });
|
|
103
|
+
toast.success(`${name} updated.`);
|
|
104
|
+
setEditing(null);
|
|
105
|
+
await load();
|
|
106
|
+
} catch (err) {
|
|
107
|
+
toast.error(`Update failed: ${(err as Error).message}`);
|
|
108
|
+
} finally {
|
|
109
|
+
setSaving(false);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const handleDelete = async (name: string) => {
|
|
114
|
+
if (!confirm(`Delete ${name}? This cannot be undone.`)) return;
|
|
115
|
+
try {
|
|
116
|
+
await api.del(`/env-vars/${encodeURIComponent(name)}`);
|
|
117
|
+
toast.success(`${name} deleted.`);
|
|
118
|
+
await load();
|
|
119
|
+
} catch (err) {
|
|
120
|
+
toast.error(`Delete failed: ${(err as Error).message}`);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
if (loading) {
|
|
125
|
+
return <div className="muted" style={{ padding: '16px 0', fontSize: 13 }}>Loading env vars…</div>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div className="env-var-manager">
|
|
130
|
+
<div className="env-var-toolbar">
|
|
131
|
+
<Button variant="primary" size="sm" onClick={() => setShowAdd(true)}>
|
|
132
|
+
<Plus size={12} /> New variable
|
|
133
|
+
</Button>
|
|
134
|
+
<Button variant="ghost" size="sm" onClick={load} title="Reload">
|
|
135
|
+
<RefreshCw size={12} />
|
|
136
|
+
</Button>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
{vars.length === 0 && !showAdd && (
|
|
140
|
+
<div className="env-var-empty">
|
|
141
|
+
<KeyRound size={20} />
|
|
142
|
+
<span>No BIZAR_* env vars yet.</span>
|
|
143
|
+
<span style={{ fontSize: 11 }}>Create one to store API keys securely.</span>
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
{/* New var form */}
|
|
148
|
+
{showAdd && (
|
|
149
|
+
<div className="env-var-add-form">
|
|
150
|
+
<div className="env-var-add-row">
|
|
151
|
+
<input
|
|
152
|
+
className="input mono"
|
|
153
|
+
style={{ width: 240 }}
|
|
154
|
+
placeholder="BIZAR_NAME"
|
|
155
|
+
value={newName}
|
|
156
|
+
onChange={(e) => setNewName(e.target.value.toUpperCase())}
|
|
157
|
+
onKeyDown={(e) => { if (e.key === 'Enter') handleAdd(); if (e.key === 'Escape') setShowAdd(false); }}
|
|
158
|
+
autoFocus
|
|
159
|
+
/>
|
|
160
|
+
<input
|
|
161
|
+
className="input mono"
|
|
162
|
+
type="password"
|
|
163
|
+
placeholder="value (API key, token, etc.)"
|
|
164
|
+
value={newValue}
|
|
165
|
+
onChange={(e) => setNewValue(e.target.value)}
|
|
166
|
+
onKeyDown={(e) => { if (e.key === 'Enter') handleAdd(); if (e.key === 'Escape') setShowAdd(false); }}
|
|
167
|
+
/>
|
|
168
|
+
<Button variant="primary" size="sm" onClick={handleAdd} disabled={saving}>
|
|
169
|
+
<CheckCircle size={12} /> Create
|
|
170
|
+
</Button>
|
|
171
|
+
<Button variant="ghost" size="sm" onClick={() => { setShowAdd(false); setNewName(''); setNewValue(''); }}>
|
|
172
|
+
<X size={12} />
|
|
173
|
+
</Button>
|
|
174
|
+
</div>
|
|
175
|
+
<p style={{ fontSize: 11, color: 'var(--text-dim)', margin: 0 }}>
|
|
176
|
+
Names must match <code>BIZAR_<var>NAME</var></code> (uppercase letters, digits, underscores).
|
|
177
|
+
</p>
|
|
178
|
+
</div>
|
|
179
|
+
)}
|
|
180
|
+
|
|
181
|
+
{/* Var list */}
|
|
182
|
+
{vars.length > 0 && (
|
|
183
|
+
<div className="env-var-list">
|
|
184
|
+
{vars.map((v) => {
|
|
185
|
+
const isEditing = editing === v.name;
|
|
186
|
+
const isRevealed = revealed.has(v.name);
|
|
187
|
+
return (
|
|
188
|
+
<div key={v.name} className={cn('env-var-row', isEditing && 'env-var-row-editing')}>
|
|
189
|
+
<span className="env-var-name mono">{v.name}</span>
|
|
190
|
+
{isEditing ? (
|
|
191
|
+
<input
|
|
192
|
+
className="input mono"
|
|
193
|
+
type="password"
|
|
194
|
+
style={{ flex: 1, minWidth: 200 }}
|
|
195
|
+
value={editValue}
|
|
196
|
+
onChange={(e) => setEditValue(e.target.value)}
|
|
197
|
+
onKeyDown={(e) => {
|
|
198
|
+
if (e.key === 'Enter') handleUpdate(v.name);
|
|
199
|
+
if (e.key === 'Escape') setEditing(null);
|
|
200
|
+
}}
|
|
201
|
+
autoFocus
|
|
202
|
+
/>
|
|
203
|
+
) : (
|
|
204
|
+
<span className={cn('env-var-value mono', !isRevealed && 'env-var-value-masked')}>
|
|
205
|
+
{isRevealed ? v.value : v.value}
|
|
206
|
+
</span>
|
|
207
|
+
)}
|
|
208
|
+
<span className="env-var-source muted">{v.source}</span>
|
|
209
|
+
<div className="env-var-actions">
|
|
210
|
+
{!isEditing && (
|
|
211
|
+
<button
|
|
212
|
+
type="button"
|
|
213
|
+
className="icon-btn"
|
|
214
|
+
title={isRevealed ? 'Hide' : 'Reveal'}
|
|
215
|
+
onClick={() => toggleReveal(v.name)}
|
|
216
|
+
>
|
|
217
|
+
{isRevealed ? <EyeOff size={12} /> : <Eye size={12} />}
|
|
218
|
+
</button>
|
|
219
|
+
)}
|
|
220
|
+
{isEditing ? (
|
|
221
|
+
<>
|
|
222
|
+
<Button variant="primary" size="sm" onClick={() => handleUpdate(v.name)} disabled={saving}>
|
|
223
|
+
Save
|
|
224
|
+
</Button>
|
|
225
|
+
<Button variant="ghost" size="sm" onClick={() => setEditing(null)}>
|
|
226
|
+
Cancel
|
|
227
|
+
</Button>
|
|
228
|
+
</>
|
|
229
|
+
) : (
|
|
230
|
+
<>
|
|
231
|
+
<button type="button" className="icon-btn" title="Edit" onClick={() => startEdit(v)}>
|
|
232
|
+
<Pencil size={12} />
|
|
233
|
+
</button>
|
|
234
|
+
<button type="button" className="icon-btn icon-btn-danger" title="Delete" onClick={() => handleDelete(v.name)}>
|
|
235
|
+
<Trash2 size={12} />
|
|
236
|
+
</button>
|
|
237
|
+
</>
|
|
238
|
+
)}
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
);
|
|
242
|
+
})}
|
|
243
|
+
</div>
|
|
244
|
+
)}
|
|
245
|
+
</div>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// src/web/components/SettingsSearch.tsx — fuzzy search across Settings sections with scroll-to highlight
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { Search, X } from 'lucide-react';
|
|
4
|
+
import Fuse from 'fuse.js';
|
|
5
|
+
import { cn } from '../lib/utils';
|
|
6
|
+
|
|
7
|
+
export type SettingsSection = {
|
|
8
|
+
id: string;
|
|
9
|
+
label: string;
|
|
10
|
+
icon?: string;
|
|
11
|
+
fields: SettingsField[];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type SettingsField = {
|
|
15
|
+
key: string; // e.g. "theme.accent"
|
|
16
|
+
label: string; // e.g. "Accent color"
|
|
17
|
+
value?: string; // current value (masked if secret)
|
|
18
|
+
section: string; // parent section id
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type Props = {
|
|
22
|
+
sections: SettingsSection[];
|
|
23
|
+
onJump: (sectionId: string, fieldKey?: string) => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type Match = {
|
|
27
|
+
item: SettingsField | SettingsSection;
|
|
28
|
+
score: number;
|
|
29
|
+
type: 'section' | 'field';
|
|
30
|
+
sectionId: string;
|
|
31
|
+
fieldKey?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function fuseSearch(items: SettingsSection[], query: string): Match[] {
|
|
35
|
+
if (!query.trim()) return [];
|
|
36
|
+
|
|
37
|
+
const sectionFuse = new Fuse(items, {
|
|
38
|
+
keys: ['label'],
|
|
39
|
+
threshold: 0.4,
|
|
40
|
+
includeScore: true,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const fieldFuse = new Fuse(
|
|
44
|
+
items.flatMap((s) => s.fields.map((f) => ({ ...f, _sectionId: s.id }))),
|
|
45
|
+
{ keys: ['label', 'key'], threshold: 0.4, includeScore: true },
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const results: Match[] = [];
|
|
49
|
+
|
|
50
|
+
for (const r of sectionFuse.search(query)) {
|
|
51
|
+
results.push({
|
|
52
|
+
item: r.item,
|
|
53
|
+
score: r.score ?? 1,
|
|
54
|
+
type: 'section',
|
|
55
|
+
sectionId: r.item.id,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const r of fieldFuse.search(query)) {
|
|
60
|
+
results.push({
|
|
61
|
+
item: r.item as SettingsField,
|
|
62
|
+
score: r.score ?? 1,
|
|
63
|
+
type: 'field',
|
|
64
|
+
sectionId: (r.item as SettingsField & { _sectionId: string })._sectionId,
|
|
65
|
+
fieldKey: r.item.key,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Deduplicate by sectionId+fieldKey, prefer lower score
|
|
70
|
+
const seen = new Set<string>();
|
|
71
|
+
return results
|
|
72
|
+
.filter((r) => {
|
|
73
|
+
const k = `${r.sectionId}::${r.fieldKey ?? ''}`;
|
|
74
|
+
if (seen.has(k)) return false;
|
|
75
|
+
seen.add(k);
|
|
76
|
+
return true;
|
|
77
|
+
})
|
|
78
|
+
.sort((a, b) => a.score - b.score)
|
|
79
|
+
.slice(0, 8);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function SettingsSearch({ sections, onJump }: Props) {
|
|
83
|
+
const [q, setQ] = useState('');
|
|
84
|
+
const [matches, setMatches] = useState<Match[]>([]);
|
|
85
|
+
const [activeIdx, setActiveIdx] = useState(0);
|
|
86
|
+
const [highlighted, setHighlighted] = useState<string | null>(null);
|
|
87
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
88
|
+
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
89
|
+
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
90
|
+
|
|
91
|
+
const search = useCallback(
|
|
92
|
+
(query: string) => {
|
|
93
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
94
|
+
if (!query.trim()) {
|
|
95
|
+
setMatches([]);
|
|
96
|
+
setActiveIdx(0);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
debounceRef.current = setTimeout(() => {
|
|
100
|
+
setMatches(fuseSearch(sections, query));
|
|
101
|
+
setActiveIdx(0);
|
|
102
|
+
}, 200);
|
|
103
|
+
},
|
|
104
|
+
[sections],
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
search(q);
|
|
109
|
+
}, [q, search]);
|
|
110
|
+
|
|
111
|
+
// Cleanup timers
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
return () => {
|
|
114
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
115
|
+
if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current);
|
|
116
|
+
};
|
|
117
|
+
}, []);
|
|
118
|
+
|
|
119
|
+
const highlightSection = (sectionId: string) => {
|
|
120
|
+
setHighlighted(sectionId);
|
|
121
|
+
if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current);
|
|
122
|
+
highlightTimerRef.current = setTimeout(() => setHighlighted(null), 1500);
|
|
123
|
+
const el = document.querySelector(`[data-section="${sectionId}"]`);
|
|
124
|
+
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const handleKey = (e: React.KeyboardEvent) => {
|
|
128
|
+
if (e.key === 'Escape') {
|
|
129
|
+
setQ('');
|
|
130
|
+
inputRef.current?.blur();
|
|
131
|
+
} else if (e.key === 'ArrowDown') {
|
|
132
|
+
e.preventDefault();
|
|
133
|
+
setActiveIdx((i) => Math.min(i + 1, matches.length - 1));
|
|
134
|
+
} else if (e.key === 'ArrowUp') {
|
|
135
|
+
e.preventDefault();
|
|
136
|
+
setActiveIdx((i) => Math.max(i - 1, 0));
|
|
137
|
+
} else if (e.key === 'Enter' && matches[activeIdx]) {
|
|
138
|
+
const m = matches[activeIdx];
|
|
139
|
+
highlightSection(m.sectionId);
|
|
140
|
+
onJump(m.sectionId, m.fieldKey);
|
|
141
|
+
setQ('');
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const handleSelect = (m: Match) => {
|
|
146
|
+
highlightSection(m.sectionId);
|
|
147
|
+
onJump(m.sectionId, m.fieldKey);
|
|
148
|
+
setQ('');
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<div className="settings-search">
|
|
153
|
+
<div className="settings-search-input-wrap">
|
|
154
|
+
<Search size={13} className="settings-search-icon" />
|
|
155
|
+
<input
|
|
156
|
+
ref={inputRef}
|
|
157
|
+
className="settings-search-input"
|
|
158
|
+
placeholder="Search settings…"
|
|
159
|
+
value={q}
|
|
160
|
+
onChange={(e) => setQ(e.target.value)}
|
|
161
|
+
onKeyDown={handleKey}
|
|
162
|
+
aria-label="Search settings"
|
|
163
|
+
/>
|
|
164
|
+
{q && (
|
|
165
|
+
<button
|
|
166
|
+
type="button"
|
|
167
|
+
className="icon-btn settings-search-clear"
|
|
168
|
+
onClick={() => setQ('')}
|
|
169
|
+
aria-label="Clear search"
|
|
170
|
+
>
|
|
171
|
+
<X size={12} />
|
|
172
|
+
</button>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
{q && matches.length > 0 && (
|
|
177
|
+
<div className="settings-search-results">
|
|
178
|
+
{matches.map((m, idx) => {
|
|
179
|
+
const label = m.type === 'section'
|
|
180
|
+
? (m.item as SettingsSection).label
|
|
181
|
+
: (m.item as SettingsField).label;
|
|
182
|
+
const key = m.type === 'section'
|
|
183
|
+
? (m.item as SettingsSection).id
|
|
184
|
+
: (m.item as SettingsField).key;
|
|
185
|
+
return (
|
|
186
|
+
<button
|
|
187
|
+
key={`${m.sectionId}::${key}`}
|
|
188
|
+
type="button"
|
|
189
|
+
className={cn('settings-search-result', idx === activeIdx && 'settings-search-result-active')}
|
|
190
|
+
onClick={() => handleSelect(m)}
|
|
191
|
+
onMouseEnter={() => setActiveIdx(idx)}
|
|
192
|
+
>
|
|
193
|
+
<span className={cn('settings-search-result-type', m.type === 'section' && 'settings-search-result-type-section')}>
|
|
194
|
+
{m.type === 'section' ? 'section' : 'field'}
|
|
195
|
+
</span>
|
|
196
|
+
<span className="settings-search-result-label">
|
|
197
|
+
{label}
|
|
198
|
+
</span>
|
|
199
|
+
{m.type === 'field' && (
|
|
200
|
+
<span className="settings-search-result-key mono muted">{key}</span>
|
|
201
|
+
)}
|
|
202
|
+
</button>
|
|
203
|
+
);
|
|
204
|
+
})}
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
|
|
208
|
+
{q && matches.length === 0 && (
|
|
209
|
+
<div className="settings-search-empty muted">No matching settings.</div>
|
|
210
|
+
)}
|
|
211
|
+
</div>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
@@ -50,7 +50,6 @@ export const TABS: TabDef[] = [
|
|
|
50
50
|
{ id: 'schedules', label: 'Schedules', icon: Clock },
|
|
51
51
|
{ id: 'history', label: 'History', icon: HistoryIcon },
|
|
52
52
|
{ id: 'minimax', label: 'Usage', icon: Coins },
|
|
53
|
-
{ id: 'config', label: 'Config', icon: Settings2 },
|
|
54
53
|
{ id: 'settings', label: 'Settings', icon: Sliders },
|
|
55
54
|
];
|
|
56
55
|
|