@polderlabs/bizar 5.0.2 → 5.2.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/dist/assets/MobileChat-TCputYzr.js +1 -0
- package/bizar-dash/dist/assets/MobileChat-TCputYzr.js.map +1 -0
- package/bizar-dash/dist/assets/MobileSettings-BxcbL5XT.js +1 -0
- package/bizar-dash/dist/assets/MobileSettings-BxcbL5XT.js.map +1 -0
- package/bizar-dash/dist/assets/{icons-Bo0iH9EC.js → icons-DRDXfbBP.js} +151 -136
- package/bizar-dash/dist/assets/icons-DRDXfbBP.js.map +1 -0
- package/bizar-dash/dist/assets/main-D5Ditnrd.js +19 -0
- package/bizar-dash/dist/assets/main-D5Ditnrd.js.map +1 -0
- package/bizar-dash/dist/assets/{main-DTkNlLrw.css → main-xFpWMd32.css} +1 -1
- package/bizar-dash/dist/assets/markdown-C6mXtQxD.js +1 -0
- package/bizar-dash/dist/assets/markdown-C6mXtQxD.js.map +1 -0
- package/bizar-dash/dist/assets/mobile--17fkfrl.js +1 -0
- package/bizar-dash/dist/assets/{mobile-DD-FZrTC.js.map → mobile--17fkfrl.js.map} +1 -1
- package/bizar-dash/dist/assets/mobile-j3rOZK6v.js +2 -0
- package/bizar-dash/dist/assets/mobile-j3rOZK6v.js.map +1 -0
- package/bizar-dash/dist/assets/{react-vendor-DZRUXSPQ.js → react-vendor-Dn4wqh4Z.js} +8 -8
- package/bizar-dash/dist/assets/react-vendor-Dn4wqh4Z.js.map +1 -0
- package/bizar-dash/dist/assets/useSlashCommands-BG-DhEck.js +2 -0
- package/bizar-dash/dist/assets/useSlashCommands-BG-DhEck.js.map +1 -0
- package/bizar-dash/dist/assets/vendor-CeHGtduv.js +29 -0
- package/bizar-dash/dist/assets/vendor-CeHGtduv.js.map +1 -0
- package/bizar-dash/dist/index.html +8 -6
- package/bizar-dash/dist/mobile.html +4 -5
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/src/server/api.mjs +4 -0
- package/bizar-dash/src/server/eval.mjs +93 -3
- package/bizar-dash/src/server/metrics.mjs +75 -0
- package/bizar-dash/src/server/otel.mjs +141 -14
- package/bizar-dash/src/server/plugins/registry.mjs +145 -43
- package/bizar-dash/src/server/routes/chat.mjs +46 -9
- package/bizar-dash/src/server/routes/opencode-session-detail.mjs +120 -17
- package/bizar-dash/src/server/routes/plugins.mjs +131 -16
- package/bizar-dash/src/server/routes/tailscale.mjs +46 -0
- package/bizar-dash/src/server/routes/voice.mjs +42 -6
- package/bizar-dash/src/server/routes/workspaces.mjs +156 -61
- package/bizar-dash/src/server/server.mjs +57 -15
- package/bizar-dash/src/server/voice-store.mjs +27 -0
- package/bizar-dash/src/server/workers/transcription-worker.mjs +213 -0
- package/bizar-dash/src/web/App.tsx +9 -0
- package/bizar-dash/src/web/MobileApp.tsx +18 -10
- package/bizar-dash/src/web/components/EvalDiff.tsx +157 -0
- package/bizar-dash/src/web/components/EvalRunCard.tsx +78 -0
- package/bizar-dash/src/web/components/PluginCard.tsx +64 -0
- package/bizar-dash/src/web/components/PluginPermissions.tsx +36 -0
- package/bizar-dash/src/web/components/TailscaleSettings.tsx +161 -0
- package/bizar-dash/src/web/components/Toggle.tsx +31 -0
- package/bizar-dash/src/web/components/Topbar.tsx +6 -0
- package/bizar-dash/src/web/mobile/MobileChat.tsx +138 -0
- package/bizar-dash/src/web/mobile/MobileSettings.tsx +196 -0
- package/bizar-dash/src/web/styles/main.css +545 -0
- package/bizar-dash/src/web/views/Eval.tsx +172 -0
- package/bizar-dash/src/web/views/EvalReport.tsx +349 -0
- package/bizar-dash/src/web/views/Plugins.tsx +128 -0
- package/bizar-dash/src/web/views/Settings.tsx +2 -0
- package/bizar-dash/tests/bundle-analysis.test.mjs +5 -4
- package/bizar-dash/tests/cli-tailscale.test.mjs +113 -0
- package/bizar-dash/tests/components/plugin-permissions.test.tsx +95 -0
- package/bizar-dash/tests/eval/fixtures-extra.test.mjs +285 -0
- package/bizar-dash/tests/eval-web-ui.test.tsx +220 -0
- package/bizar-dash/tests/otel-spans.test.mjs +253 -0
- package/bizar-dash/tests/plugins-registry-fallback.test.mjs +259 -0
- package/bizar-dash/tests/voice-transcribe-worker.test.mjs +343 -0
- package/cli/bin.mjs +14 -0
- package/cli/commands/dash.mjs +19 -1
- package/cli/commands/service.mjs +7 -0
- package/cli/commands/tailscale.mjs +251 -0
- package/package.json +1 -1
- package/templates/eval-fixtures/citation.json +16 -0
- package/templates/eval-fixtures/code-review.json +16 -0
- package/templates/eval-fixtures/concise-output.json +16 -0
- package/templates/eval-fixtures/context-window.json +16 -0
- package/templates/eval-fixtures/error-recovery.json +16 -0
- package/templates/eval-fixtures/json-output.json +20 -0
- package/templates/eval-fixtures/multi-language.json +16 -0
- package/templates/eval-fixtures/safe-paths.json +16 -0
- package/templates/eval-fixtures/tool-call-multi-step.json +18 -0
- package/templates/eval-fixtures/unicode-handling.json +16 -0
- package/bizar-dash/dist/assets/icons-Bo0iH9EC.js.map +0 -1
- package/bizar-dash/dist/assets/index-DmpSFPJY.js +0 -9
- package/bizar-dash/dist/assets/index-DmpSFPJY.js.map +0 -1
- package/bizar-dash/dist/assets/main-C1cpttnv.js +0 -19
- package/bizar-dash/dist/assets/main-C1cpttnv.js.map +0 -1
- package/bizar-dash/dist/assets/markdown-DIquRulQ.js +0 -29
- package/bizar-dash/dist/assets/markdown-DIquRulQ.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-BtxQJftK.js +0 -2
- package/bizar-dash/dist/assets/mobile-BtxQJftK.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-DD-FZrTC.js +0 -1
- package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js.map +0 -1
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/server/workers/transcription-worker.mjs
|
|
3
|
+
*
|
|
4
|
+
* v5.2 — Background transcription worker for voice notes.
|
|
5
|
+
*
|
|
6
|
+
* When a user uploads a voice note, the audio is saved immediately and a
|
|
7
|
+
* reference (noteId) is enqueued here. The worker drains the queue in
|
|
8
|
+
* FIFO order: it reads the audio from disk, calls `transcribe()` (the
|
|
9
|
+
* Whisper adapter), persists the result via `updateVoiceNote()`, and
|
|
10
|
+
* pushes a `voice:updated` event on the dashboard WebSocket so the UI
|
|
11
|
+
* can replace the "transcribing…" placeholder as soon as the text is
|
|
12
|
+
* ready.
|
|
13
|
+
*
|
|
14
|
+
* Design choices:
|
|
15
|
+
* - In-memory queue (per-process). No cross-instance persistence; if
|
|
16
|
+
* the server dies with work in the queue, those notes remain
|
|
17
|
+
* un-transcribed (the audio file is still on disk and can be
|
|
18
|
+
* re-triggered manually if needed).
|
|
19
|
+
* - Single-threaded drain (`processing` flag). Multiple callers
|
|
20
|
+
* enqueueing during a drain are captured by the while-loop's
|
|
21
|
+
* `queue.length > 0` check on each iteration.
|
|
22
|
+
* - Errors inside `processOne()` are caught and logged by
|
|
23
|
+
* `processQueue()`; a failure on note X does NOT stop note X+1.
|
|
24
|
+
* - `broadcast()` is injected at startup so the worker doesn't take
|
|
25
|
+
* a hard dependency on server.mjs. In tests, a no-op stub is fine.
|
|
26
|
+
* - `setInterval(..., 5000)` is a safety net: any work left in the
|
|
27
|
+
* queue (e.g. from a processQueue race during teardown) gets a
|
|
28
|
+
* retry tick. `.unref()` keeps the interval from blocking process
|
|
29
|
+
* exit — the worker never closes the server by itself.
|
|
30
|
+
*
|
|
31
|
+
* Test injection: the four setters / helpers exposed on the bottom of
|
|
32
|
+
* this file (`setTranscribe`, `setBroadcast`, `drainQueue`, `reset`)
|
|
33
|
+
* exist so tests can stub the Whisper adapter and observe the queue
|
|
34
|
+
* without booting a server or hitting the network. They have no effect
|
|
35
|
+
* in production unless called.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
39
|
+
import { getVoiceNote, updateVoiceNote } from '../voice-store.mjs';
|
|
40
|
+
import { transcribe as defaultTranscribe } from '../voice-transcribe.mjs';
|
|
41
|
+
import { child } from '../logger.mjs';
|
|
42
|
+
|
|
43
|
+
const log = child({ module: 'transcription-worker' });
|
|
44
|
+
|
|
45
|
+
const POLL_INTERVAL_MS = 5000;
|
|
46
|
+
|
|
47
|
+
// ── Mutable worker state ────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/** @type {{ noteId: string, enqueuedAt: number }[]} */
|
|
50
|
+
const queue = [];
|
|
51
|
+
let processing = false;
|
|
52
|
+
|
|
53
|
+
/** Override hooks — defaults call through to the real Whisper adapter / no-op broadcast. */
|
|
54
|
+
let _transcribe = defaultTranscribe;
|
|
55
|
+
let _broadcast = () => {};
|
|
56
|
+
|
|
57
|
+
const idleWaiters = [];
|
|
58
|
+
|
|
59
|
+
// ── Core processing ─────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Process one note end-to-end. Returns a status object so tests (and
|
|
63
|
+
* the drain loop) can distinguish "skipped for a reason" from "ok".
|
|
64
|
+
*
|
|
65
|
+
* @param {string} noteId
|
|
66
|
+
* @returns {Promise<{ ok?: true, transcript?: string, skipped?: string }>}
|
|
67
|
+
*/
|
|
68
|
+
export async function processOne(noteId) {
|
|
69
|
+
const note = getVoiceNote(noteId);
|
|
70
|
+
if (!note) {
|
|
71
|
+
log.debug('note missing, skipping', { noteId });
|
|
72
|
+
return { skipped: 'missing' };
|
|
73
|
+
}
|
|
74
|
+
if (note.transcript) {
|
|
75
|
+
log.debug('already transcribed', { noteId });
|
|
76
|
+
return { skipped: 'already_transcribed' };
|
|
77
|
+
}
|
|
78
|
+
if (!existsSync(note.audioPath)) {
|
|
79
|
+
log.error('audio file missing on disk', { noteId, audioPath: note.audioPath });
|
|
80
|
+
return { skipped: 'audio_missing' };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let transcript;
|
|
84
|
+
try {
|
|
85
|
+
const audioBuffer = readFileSync(note.audioPath);
|
|
86
|
+
transcript = await _transcribe(audioBuffer, { mimeType: 'audio/webm' });
|
|
87
|
+
} catch (err) {
|
|
88
|
+
log.error('transcription threw', { noteId, err: err.message });
|
|
89
|
+
return { skipped: 'error', err: err.message };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!transcript) {
|
|
93
|
+
log.warn('transcription returned null', { noteId });
|
|
94
|
+
return { skipped: 'no_transcript' };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const updated = updateVoiceNote(noteId, { transcript });
|
|
98
|
+
if (!updated) {
|
|
99
|
+
log.error('updateVoiceNote returned null after successful transcription', { noteId });
|
|
100
|
+
return { skipped: 'update_failed' };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
_broadcast({ type: 'voice:updated', noteId, patch: { transcript } });
|
|
105
|
+
} catch (err) {
|
|
106
|
+
// Broadcast failure must not corrupt the on-disk state.
|
|
107
|
+
log.warn('broadcast threw', { noteId, err: err.message });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
log.info('transcription complete', { noteId, length: transcript.length });
|
|
111
|
+
return { ok: true, transcript };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Drain the queue. Guarded by the `processing` flag so concurrent
|
|
116
|
+
* callers don't double-drain. Errors inside `processOne` are logged
|
|
117
|
+
* and swallowed, NOT re-thrown — the next item must always get a
|
|
118
|
+
* chance.
|
|
119
|
+
*/
|
|
120
|
+
async function processQueue() {
|
|
121
|
+
if (processing) return;
|
|
122
|
+
processing = true;
|
|
123
|
+
try {
|
|
124
|
+
while (queue.length > 0) {
|
|
125
|
+
const { noteId } = queue.shift();
|
|
126
|
+
try {
|
|
127
|
+
await processOne(noteId);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
log.error('transcription failed', { noteId, err: err.message });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} finally {
|
|
133
|
+
processing = false;
|
|
134
|
+
if (queue.length === 0) {
|
|
135
|
+
const toResolve = idleWaiters.splice(0);
|
|
136
|
+
for (const r of toResolve) r();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Enqueue a note for background transcription.
|
|
145
|
+
*
|
|
146
|
+
* @param {string} noteId
|
|
147
|
+
*/
|
|
148
|
+
export function enqueueTranscription(noteId) {
|
|
149
|
+
if (!noteId || typeof noteId !== 'string') return;
|
|
150
|
+
queue.push({ noteId, enqueuedAt: Date.now() });
|
|
151
|
+
// Drain is fire-and-forget — callers don't await per-note work.
|
|
152
|
+
void processQueue();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Start the worker. Idempotent — calling twice clears the previous
|
|
157
|
+
* interval and starts a fresh one.
|
|
158
|
+
*
|
|
159
|
+
* @param {{ broadcast?: (msg: unknown) => void }} [opts]
|
|
160
|
+
*/
|
|
161
|
+
export function startTranscriptionWorker({ broadcast } = {}) {
|
|
162
|
+
if (typeof broadcast === 'function') _broadcast = broadcast;
|
|
163
|
+
// Replace any prior interval so tests + double-boot are safe.
|
|
164
|
+
if (startTranscriptionWorker._interval) {
|
|
165
|
+
clearInterval(startTranscriptionWorker._interval);
|
|
166
|
+
}
|
|
167
|
+
log.info('transcription worker started', { pollIntervalMs: POLL_INTERVAL_MS });
|
|
168
|
+
const handle = setInterval(() => void processQueue(), POLL_INTERVAL_MS);
|
|
169
|
+
if (typeof handle.unref === 'function') handle.unref();
|
|
170
|
+
startTranscriptionWorker._interval = handle;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Test hooks (no-op in production) ────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
/** @param {(buf: Buffer|Uint8Array, opts?: object) => Promise<string|null>} fn */
|
|
176
|
+
export function setTranscribe(fn) {
|
|
177
|
+
_transcribe = typeof fn === 'function' ? fn : defaultTranscribe;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** @param {(msg: unknown) => void} fn */
|
|
181
|
+
export function setBroadcast(fn) {
|
|
182
|
+
_broadcast = typeof fn === 'function' ? fn : () => {};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Number of items waiting or currently being processed. */
|
|
186
|
+
export function getQueueDepth() {
|
|
187
|
+
return queue.length + (processing ? 1 : 0);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Resolve once the queue is fully drained (no items + not processing).
|
|
192
|
+
* Returns a resolved promise immediately if already idle.
|
|
193
|
+
*/
|
|
194
|
+
export function drainQueue() {
|
|
195
|
+
if (queue.length === 0 && !processing) return Promise.resolve();
|
|
196
|
+
return new Promise((resolve) => {
|
|
197
|
+
idleWaiters.push(resolve);
|
|
198
|
+
void processQueue();
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Reset all mutable state. ONLY for tests. */
|
|
203
|
+
export function reset() {
|
|
204
|
+
queue.length = 0;
|
|
205
|
+
processing = false;
|
|
206
|
+
_transcribe = defaultTranscribe;
|
|
207
|
+
_broadcast = () => {};
|
|
208
|
+
idleWaiters.length = 0;
|
|
209
|
+
if (startTranscriptionWorker._interval) {
|
|
210
|
+
clearInterval(startTranscriptionWorker._interval);
|
|
211
|
+
startTranscriptionWorker._interval = null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -31,6 +31,7 @@ import { Activity } from './views/Activity';
|
|
|
31
31
|
import { Config } from './views/Config';
|
|
32
32
|
import { SettingsView } from './views/Settings';
|
|
33
33
|
import { Mods } from './views/Mods';
|
|
34
|
+
import { Plugins } from './views/Plugins';
|
|
34
35
|
import { ModView, type ModView as ModViewType } from './views/ModView';
|
|
35
36
|
import { Schedules } from './views/Schedules';
|
|
36
37
|
import { Skills } from './views/Skills';
|
|
@@ -39,6 +40,8 @@ import { History } from './views/History';
|
|
|
39
40
|
import { MiniMaxUsage } from './views/MiniMaxUsage';
|
|
40
41
|
import { BackgroundAgents } from './views/BackgroundAgents';
|
|
41
42
|
import { Doctor } from './views/Doctor';
|
|
43
|
+
import { Eval } from './views/Eval';
|
|
44
|
+
import { EvalReport } from './views/EvalReport';
|
|
42
45
|
import { SettingsNav } from './components/SettingsNav';
|
|
43
46
|
import { Spinner } from './components/Spinner';
|
|
44
47
|
import { Button } from './components/Button';
|
|
@@ -92,10 +95,16 @@ const VIEW_MAP: Record<string, (p: ViewProps) => React.ReactNode> = {
|
|
|
92
95
|
skills: Skills,
|
|
93
96
|
history: History,
|
|
94
97
|
minimax: MiniMaxUsage,
|
|
98
|
+
plugins: Plugins,
|
|
95
99
|
// v6.0.0 — Doctor page (system health + diagnostics surface).
|
|
96
100
|
// Registered here so the tab id 'doctor' from Topbar.tsx TABS
|
|
97
101
|
// resolves to a real view instead of falling back to Overview.
|
|
98
102
|
doctor: Doctor,
|
|
103
|
+
// v5.2.0 — Eval framework web UI. `eval` is the sidebar tab
|
|
104
|
+
// (recent runs summary), `evalReport` is reachable from the
|
|
105
|
+
// Eval view's "Open Eval Report" button (full report + diff).
|
|
106
|
+
eval: Eval,
|
|
107
|
+
evalReport: EvalReport,
|
|
99
108
|
};
|
|
100
109
|
|
|
101
110
|
const VERSION = 'v4.5.0';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/MobileApp.tsx — mobile root with state-based routing + stack navigation.
|
|
2
|
-
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import React, { useCallback, useEffect, useMemo, useState, Suspense } from 'react';
|
|
3
3
|
import {
|
|
4
4
|
Activity,
|
|
5
5
|
CheckSquare,
|
|
@@ -16,7 +16,6 @@ import { MobileBottomNav, type MobileTab } from './mobile/MobileBottomNav';
|
|
|
16
16
|
import { MobileTopbar } from './mobile/MobileTopbar';
|
|
17
17
|
import { MobileActivity } from './mobile/views/MobileActivity';
|
|
18
18
|
import { MobileAgents } from './mobile/views/MobileAgents';
|
|
19
|
-
import { MobileChat } from './mobile/views/MobileChat';
|
|
20
19
|
import { MobileConfig } from './mobile/views/MobileConfig';
|
|
21
20
|
import { MobileHistory } from './mobile/views/MobileHistory';
|
|
22
21
|
import { MobileMods } from './mobile/views/MobileMods';
|
|
@@ -25,10 +24,13 @@ import { MobileArtifactCanvas } from './mobile/views/MobileArtifactCanvas';
|
|
|
25
24
|
import { MobileArtifacts } from './mobile/views/MobileArtifacts';
|
|
26
25
|
import { MobileSchedules } from './mobile/views/MobileSchedules';
|
|
27
26
|
import { MobileSearchModal } from './mobile/views/MobileSearchModal';
|
|
28
|
-
import { MobileSettings } from './mobile/views/MobileSettings';
|
|
29
27
|
import { MobileSkills } from './mobile/views/MobileSkills';
|
|
30
28
|
import { MobileTasks } from './mobile/views/MobileTasks';
|
|
31
29
|
|
|
30
|
+
// Lazy-loaded mobile-specific variants (code-split from main bundle)
|
|
31
|
+
const MobileSettings = React.lazy(() => import('./mobile/MobileSettings').then(m => ({ default: m.MobileSettings })));
|
|
32
|
+
const MobileChat = React.lazy(() => import('./mobile/MobileChat').then(m => ({ default: m.MobileChat })));
|
|
33
|
+
|
|
32
34
|
type MainTabId = 'activity' | 'chat' | 'tasks' | 'settings' | 'more';
|
|
33
35
|
|
|
34
36
|
export type MobileView =
|
|
@@ -326,12 +328,14 @@ export function MobileApp() {
|
|
|
326
328
|
return <MobileActivity snapshot={snapshot} onRefresh={refreshSnapshot} />;
|
|
327
329
|
case 'chat':
|
|
328
330
|
return (
|
|
329
|
-
<
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
331
|
+
<Suspense fallback={<div className="mobile-loading"><Spinner size="lg" /></div>}>
|
|
332
|
+
<MobileChat
|
|
333
|
+
snapshot={snapshot}
|
|
334
|
+
settings={settings}
|
|
335
|
+
initialTaskId={pendingChatTaskId}
|
|
336
|
+
onClearTaskId={() => setPendingChatTaskId(null)}
|
|
337
|
+
/>
|
|
338
|
+
</Suspense>
|
|
335
339
|
);
|
|
336
340
|
case 'tasks':
|
|
337
341
|
return (
|
|
@@ -345,7 +349,11 @@ export function MobileApp() {
|
|
|
345
349
|
/>
|
|
346
350
|
);
|
|
347
351
|
case 'settings':
|
|
348
|
-
return
|
|
352
|
+
return (
|
|
353
|
+
<Suspense fallback={<div className="mobile-loading"><Spinner size="lg" /></div>}>
|
|
354
|
+
<MobileSettings settings={settings} snapshot={snapshot} onRefresh={refreshSnapshot} />
|
|
355
|
+
</Suspense>
|
|
356
|
+
);
|
|
349
357
|
case 'more':
|
|
350
358
|
return (
|
|
351
359
|
<MobileMore
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// src/components/EvalDiff.tsx — v5.2.0
|
|
2
|
+
//
|
|
3
|
+
// Side-by-side comparison of two eval runs. Categorizes each fixture
|
|
4
|
+
// as improved / regressed / unchanged and renders a summary block plus
|
|
5
|
+
// a list of changed fixtures.
|
|
6
|
+
//
|
|
7
|
+
// `runA` is the "before" run (typically an older baseline); `runB` is
|
|
8
|
+
// the "after" run (typically the newer run under review).
|
|
9
|
+
|
|
10
|
+
import type { ReactNode } from 'react';
|
|
11
|
+
import { cn } from '../lib/utils';
|
|
12
|
+
|
|
13
|
+
/** Per-fixture result row embedded in a run record. */
|
|
14
|
+
export type EvalFixtureResult = {
|
|
15
|
+
fixtureId: string;
|
|
16
|
+
ok: boolean;
|
|
17
|
+
checks?: { kind: string; ok: boolean; message?: string }[];
|
|
18
|
+
latencyMs?: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type EvalRunWithResults = {
|
|
22
|
+
id: string;
|
|
23
|
+
startedAt: string;
|
|
24
|
+
finishedAt?: string;
|
|
25
|
+
suitePath?: string;
|
|
26
|
+
total: number;
|
|
27
|
+
passed: number;
|
|
28
|
+
failed: number;
|
|
29
|
+
results: EvalFixtureResult[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type EvalDiffEntry = {
|
|
33
|
+
fixtureId: string;
|
|
34
|
+
/** The fixture's pass/fail status in the "before" run, or null if absent. */
|
|
35
|
+
before: boolean | null;
|
|
36
|
+
/** The fixture's pass/fail status in the "after" run, or null if absent. */
|
|
37
|
+
after: boolean | null;
|
|
38
|
+
/** Bucket label derived from before/after. */
|
|
39
|
+
change: 'same' | 'improved' | 'regressed' | 'added' | 'removed';
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type EvalDiffProps = {
|
|
43
|
+
runA: EvalRunWithResults;
|
|
44
|
+
runB: EvalRunWithResults;
|
|
45
|
+
/**
|
|
46
|
+
* Optional override for the section heading. Defaults to
|
|
47
|
+
* "Regression Analysis".
|
|
48
|
+
*/
|
|
49
|
+
title?: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Pure helper: compute the diff entries from two runs. Exported so
|
|
54
|
+
* tests can exercise the categorization logic without rendering.
|
|
55
|
+
*/
|
|
56
|
+
export function computeEvalDiff(
|
|
57
|
+
runA: EvalRunWithResults,
|
|
58
|
+
runB: EvalRunWithResults,
|
|
59
|
+
): EvalDiffEntry[] {
|
|
60
|
+
const mapA = new Map(runA.results.map((r) => [r.fixtureId, r]));
|
|
61
|
+
const mapB = new Map(runB.results.map((r) => [r.fixtureId, r]));
|
|
62
|
+
const ids = new Set<string>([...mapA.keys(), ...mapB.keys()]);
|
|
63
|
+
|
|
64
|
+
const out: EvalDiffEntry[] = [];
|
|
65
|
+
for (const id of ids) {
|
|
66
|
+
const a = mapA.get(id);
|
|
67
|
+
const b = mapB.get(id);
|
|
68
|
+
const before = a ? a.ok : null;
|
|
69
|
+
const after = b ? b.ok : null;
|
|
70
|
+
|
|
71
|
+
let change: EvalDiffEntry['change'];
|
|
72
|
+
if (a && b) {
|
|
73
|
+
if (before === after) change = 'same';
|
|
74
|
+
else if (before === false && after === true) change = 'improved';
|
|
75
|
+
else change = 'regressed';
|
|
76
|
+
} else if (b && !a) {
|
|
77
|
+
change = 'added';
|
|
78
|
+
} else {
|
|
79
|
+
change = 'removed';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
out.push({ fixtureId: id, before, after, change });
|
|
83
|
+
}
|
|
84
|
+
// Stable sort: regressions first, then improvements, then others.
|
|
85
|
+
const order: Record<EvalDiffEntry['change'], number> = {
|
|
86
|
+
regressed: 0,
|
|
87
|
+
improved: 1,
|
|
88
|
+
added: 2,
|
|
89
|
+
removed: 3,
|
|
90
|
+
same: 4,
|
|
91
|
+
};
|
|
92
|
+
out.sort((x, y) => {
|
|
93
|
+
const d = order[x.change] - order[y.change];
|
|
94
|
+
if (d !== 0) return d;
|
|
95
|
+
return x.fixtureId.localeCompare(y.fixtureId);
|
|
96
|
+
});
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function EvalDiff({ runA, runB, title = 'Regression Analysis' }: EvalDiffProps): ReactNode {
|
|
101
|
+
const diff = computeEvalDiff(runA, runB);
|
|
102
|
+
|
|
103
|
+
const improved = diff.filter((d) => d.change === 'improved').length;
|
|
104
|
+
const regressed = diff.filter((d) => d.change === 'regressed').length;
|
|
105
|
+
const unchanged = diff.filter((d) => d.change === 'same').length;
|
|
106
|
+
const changed = diff.filter((d) => d.change !== 'same');
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div className="eval-diff" data-testid="eval-diff">
|
|
110
|
+
<h3 className="eval-diff-title">{title}</h3>
|
|
111
|
+
<div className="eval-diff-subtitle muted">
|
|
112
|
+
Comparing <code className="mono">{runA.id}</code> →{' '}
|
|
113
|
+
<code className="mono">{runB.id}</code>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<div className="eval-diff-stats" aria-label="Diff summary">
|
|
117
|
+
<span className="eval-diff-stat improved">
|
|
118
|
+
<span className="eval-diff-stat-icon" aria-hidden>+</span>
|
|
119
|
+
{improved} improved
|
|
120
|
+
</span>
|
|
121
|
+
<span className="eval-diff-stat regressed">
|
|
122
|
+
<span className="eval-diff-stat-icon" aria-hidden>−</span>
|
|
123
|
+
{regressed} regressed
|
|
124
|
+
</span>
|
|
125
|
+
<span className="eval-diff-stat unchanged muted">
|
|
126
|
+
{unchanged} unchanged
|
|
127
|
+
</span>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
{changed.length === 0 ? (
|
|
131
|
+
<p className="eval-diff-empty muted">No fixture-level changes between these runs.</p>
|
|
132
|
+
) : (
|
|
133
|
+
<ul className="eval-diff-list" role="list">
|
|
134
|
+
{changed.map((d) => (
|
|
135
|
+
<li
|
|
136
|
+
key={d.fixtureId}
|
|
137
|
+
data-fixture-id={d.fixtureId}
|
|
138
|
+
className={cn('eval-diff-row', `is-${d.change}`)}
|
|
139
|
+
>
|
|
140
|
+
<span className="eval-diff-fixture mono">{d.fixtureId}</span>
|
|
141
|
+
<span className="eval-diff-arrow" aria-hidden>→</span>
|
|
142
|
+
<span className="eval-diff-states">
|
|
143
|
+
<span className={cn('eval-diff-state', d.before ? 'is-pass' : 'is-fail')}>
|
|
144
|
+
{d.before === null ? '—' : d.before ? 'pass' : 'fail'}
|
|
145
|
+
</span>
|
|
146
|
+
<span className="eval-diff-arrow-small" aria-hidden>→</span>
|
|
147
|
+
<span className={cn('eval-diff-state', d.after ? 'is-pass' : 'is-fail')}>
|
|
148
|
+
{d.after === null ? '—' : d.after ? 'pass' : 'fail'}
|
|
149
|
+
</span>
|
|
150
|
+
</span>
|
|
151
|
+
</li>
|
|
152
|
+
))}
|
|
153
|
+
</ul>
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// src/components/EvalRunCard.tsx — v5.2.0
|
|
2
|
+
//
|
|
3
|
+
// Compact card summarizing a single eval run. Used in the EvalReport's
|
|
4
|
+
// left rail to show the recent run history. Click selects the run.
|
|
5
|
+
//
|
|
6
|
+
// Status rules (per brief):
|
|
7
|
+
// failed === 0 → pass
|
|
8
|
+
// passed/total > 0.8 → warn
|
|
9
|
+
// otherwise → fail
|
|
10
|
+
|
|
11
|
+
import type { ReactNode } from 'react';
|
|
12
|
+
import { Card, CardTitle, CardMeta } from './Card';
|
|
13
|
+
import { cn, formatTime } from '../lib/utils';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Summary of an eval run. The fields here are the subset returned by
|
|
17
|
+
* GET /api/eval/runs (the list endpoint) — `results` lives on the full
|
|
18
|
+
* run record returned by GET /api/eval/runs/:id and is not present here.
|
|
19
|
+
*/
|
|
20
|
+
export type EvalRunSummary = {
|
|
21
|
+
id: string;
|
|
22
|
+
startedAt: string;
|
|
23
|
+
finishedAt?: string;
|
|
24
|
+
suitePath: string;
|
|
25
|
+
total: number;
|
|
26
|
+
passed: number;
|
|
27
|
+
failed: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type EvalRunCardProps = {
|
|
31
|
+
run: EvalRunSummary;
|
|
32
|
+
onClick?: () => void;
|
|
33
|
+
/** v5.2.0 — Marks the card as the currently selected run. */
|
|
34
|
+
selected?: boolean;
|
|
35
|
+
/** Optional aria-label override; falls back to the run id. */
|
|
36
|
+
ariaLabel?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function pickStatus(run: EvalRunSummary): 'pass' | 'warn' | 'fail' {
|
|
40
|
+
if (run.total <= 0) return 'fail';
|
|
41
|
+
if (run.failed === 0) return 'pass';
|
|
42
|
+
if (run.passed / run.total > 0.8) return 'warn';
|
|
43
|
+
return 'fail';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function EvalRunCard({ run, onClick, selected, ariaLabel }: EvalRunCardProps): ReactNode {
|
|
47
|
+
const status = pickStatus(run);
|
|
48
|
+
// Pass-rate as a fixed-percent string. toFixed guards against locale
|
|
49
|
+
// issues (e.g. some locales use "," as the decimal separator).
|
|
50
|
+
const passRate = run.total > 0
|
|
51
|
+
? ((run.passed / run.total) * 100).toFixed(1)
|
|
52
|
+
: '0.0';
|
|
53
|
+
const interactive = Boolean(onClick);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<Card
|
|
57
|
+
onClick={onClick}
|
|
58
|
+
interactive={interactive}
|
|
59
|
+
aria-label={ariaLabel ?? `Eval run ${run.id}`}
|
|
60
|
+
aria-pressed={interactive ? selected : undefined}
|
|
61
|
+
className={cn(
|
|
62
|
+
'eval-run-card',
|
|
63
|
+
selected && 'eval-run-card-selected',
|
|
64
|
+
`eval-run-${status}`,
|
|
65
|
+
)}
|
|
66
|
+
>
|
|
67
|
+
<div className="eval-run-head">
|
|
68
|
+
<CardTitle className="eval-run-title">{run.id}</CardTitle>
|
|
69
|
+
<span className={cn('eval-status', `is-${status}`)}>
|
|
70
|
+
{run.passed}/{run.total} ({passRate}%)
|
|
71
|
+
</span>
|
|
72
|
+
</div>
|
|
73
|
+
<CardMeta className="eval-run-meta">
|
|
74
|
+
{formatTime(run.startedAt)} · {run.suitePath}
|
|
75
|
+
</CardMeta>
|
|
76
|
+
</Card>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// src/web/components/PluginCard.tsx — card for one installed plugin with toggle, configure, and uninstall.
|
|
2
|
+
|
|
3
|
+
import { Card, CardTitle, CardMeta } from './Card';
|
|
4
|
+
import { Button } from './Button';
|
|
5
|
+
import { Toggle } from './Toggle';
|
|
6
|
+
import { PluginPermissions } from './PluginPermissions';
|
|
7
|
+
|
|
8
|
+
export type InstalledPlugin = {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
version: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
permissions?: string[];
|
|
14
|
+
config?: Record<string, unknown>;
|
|
15
|
+
methodCount?: number;
|
|
16
|
+
invocations?: number;
|
|
17
|
+
lastInvokedAt?: string | null;
|
|
18
|
+
enabled?: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type PluginCardProps = {
|
|
22
|
+
plugin: InstalledPlugin;
|
|
23
|
+
onToggle: (id: string) => void;
|
|
24
|
+
onUninstall: (id: string) => void;
|
|
25
|
+
onConfigure: (id: string) => void;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function PluginCard({ plugin, onToggle, onUninstall, onConfigure }: PluginCardProps) {
|
|
29
|
+
return (
|
|
30
|
+
<Card>
|
|
31
|
+
<div className="plugin-card-head">
|
|
32
|
+
<div>
|
|
33
|
+
<CardTitle>{plugin.name}</CardTitle>
|
|
34
|
+
<CardMeta>
|
|
35
|
+
v{plugin.version} · {plugin.id}
|
|
36
|
+
</CardMeta>
|
|
37
|
+
</div>
|
|
38
|
+
<Toggle
|
|
39
|
+
checked={plugin.enabled ?? true}
|
|
40
|
+
onChange={() => onToggle(plugin.id)}
|
|
41
|
+
aria-label={`Toggle ${plugin.name} enabled`}
|
|
42
|
+
/>
|
|
43
|
+
</div>
|
|
44
|
+
{plugin.description && (
|
|
45
|
+
<p className="plugin-description">{plugin.description}</p>
|
|
46
|
+
)}
|
|
47
|
+
<PluginPermissions permissions={plugin.permissions ?? []} />
|
|
48
|
+
<div className="plugin-card-actions">
|
|
49
|
+
<Button size="sm" onClick={() => onConfigure(plugin.id)}>
|
|
50
|
+
Configure
|
|
51
|
+
</Button>
|
|
52
|
+
<Button size="sm" variant="danger" onClick={() => onUninstall(plugin.id)}>
|
|
53
|
+
Uninstall
|
|
54
|
+
</Button>
|
|
55
|
+
</div>
|
|
56
|
+
<div className="plugin-card-stats">
|
|
57
|
+
{plugin.invocations ?? 0} invocations
|
|
58
|
+
{plugin.lastInvokedAt
|
|
59
|
+
? ` · last used ${plugin.lastInvokedAt}`
|
|
60
|
+
: ' · never used'}
|
|
61
|
+
</div>
|
|
62
|
+
</Card>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// src/web/components/PluginPermissions.tsx — renders permission chips for a plugin.
|
|
2
|
+
|
|
3
|
+
import { Globe, FileText, Settings, Terminal, Shield, type LucideIcon } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
const PERMISSION_LABELS: Record<string, { label: string; icon: LucideIcon; color: string; description: string }> = {
|
|
6
|
+
net: { label: 'Network access', icon: Globe, color: 'blue', description: 'Can make HTTP requests to external services' },
|
|
7
|
+
fs: { label: 'Filesystem', icon: FileText, color: 'yellow', description: 'Can read and write files' },
|
|
8
|
+
config: { label: 'Config access', icon: Settings, color: 'gray', description: 'Can read and write Bizar config' },
|
|
9
|
+
log: { label: 'Logging', icon: FileText, color: 'gray', description: 'Can write to the structured log' },
|
|
10
|
+
exec: { label: 'Shell execution', icon: Terminal, color: 'red', description: 'Can run shell commands' },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type PluginPermissionsProps = {
|
|
14
|
+
permissions: string[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function PluginPermissions({ permissions }: PluginPermissionsProps) {
|
|
18
|
+
return (
|
|
19
|
+
<div className="plugin-permissions">
|
|
20
|
+
{permissions.map((perm) => {
|
|
21
|
+
const info = PERMISSION_LABELS[perm] ?? { label: perm, icon: Shield, color: 'gray', description: '' };
|
|
22
|
+
const Icon = info.icon;
|
|
23
|
+
return (
|
|
24
|
+
<div
|
|
25
|
+
key={perm}
|
|
26
|
+
className={`permission-chip is-${info.color}`}
|
|
27
|
+
title={info.description}
|
|
28
|
+
>
|
|
29
|
+
<Icon size={12} aria-hidden />
|
|
30
|
+
<span>{info.label}</span>
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
})}
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|