@polderlabs/bizar 5.1.0 → 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-BVojK0n5.js → MobileChat-TCputYzr.js} +1 -1
- package/bizar-dash/dist/assets/{MobileChat-BVojK0n5.js.map → MobileChat-TCputYzr.js.map} +1 -1
- package/bizar-dash/dist/assets/{MobileSettings-C85ApWKZ.js → MobileSettings-BxcbL5XT.js} +1 -1
- package/bizar-dash/dist/assets/{MobileSettings-C85ApWKZ.js.map → MobileSettings-BxcbL5XT.js.map} +1 -1
- package/bizar-dash/dist/assets/{icons-Clz0NR6Y.js → icons-DRDXfbBP.js} +154 -134
- 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/{mobile-D2pc-iNh.js → mobile--17fkfrl.js} +1 -1
- package/bizar-dash/dist/assets/{mobile-D2pc-iNh.js.map → mobile--17fkfrl.js.map} +1 -1
- package/bizar-dash/dist/assets/{mobile-IaZ47uKC.js → mobile-j3rOZK6v.js} +2 -2
- package/bizar-dash/dist/assets/{mobile-IaZ47uKC.js.map → mobile-j3rOZK6v.js.map} +1 -1
- package/bizar-dash/dist/assets/{useSlashCommands-DjEwHl4n.js → useSlashCommands-BG-DhEck.js} +2 -2
- package/bizar-dash/dist/assets/{useSlashCommands-DjEwHl4n.js.map → useSlashCommands-BG-DhEck.js.map} +1 -1
- package/bizar-dash/dist/index.html +5 -5
- package/bizar-dash/dist/mobile.html +3 -3
- 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/routes/plugins.mjs +11 -3
- 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/server.mjs +13 -0
- 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/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/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/cli-tailscale.test.mjs +113 -0
- package/bizar-dash/tests/components/plugin-permissions.test.tsx +95 -0
- package/bizar-dash/tests/eval-web-ui.test.tsx +220 -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/bizar-dash/dist/assets/icons-Clz0NR6Y.js.map +0 -1
- package/bizar-dash/dist/assets/main-8feQWXiF.js +0 -19
- package/bizar-dash/dist/assets/main-8feQWXiF.js.map +0 -1
package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"4.1.9","results":[[":tests/lib/utils.test.ts",{"duration":
|
|
1
|
+
{"version":"4.1.9","results":[[":tests/lib/utils.test.ts",{"duration":16.303165999999976,"failed":false}],[":tests/components/Button.test.tsx",{"duration":106.694435,"failed":false}],[":tests/components/Card.test.tsx",{"duration":19.81299100000001,"failed":false}],[":tests/hooks/useModal.test.tsx",{"duration":159.28117799999995,"failed":false}],[":tests/components/Toast.test.tsx",{"duration":196.709385,"failed":false}],[":tests/components/Modal.test.tsx",{"duration":196.62032399999998,"failed":false}],[":tests/lib/i18n.test.ts",{"duration":3.903647000000035,"failed":false}],[":tests/components/StatusBadge.test.tsx",{"duration":49.069886,"failed":false}],[":tests/hooks/useToast.test.tsx",{"duration":120.39812900000004,"failed":false}],[":tests/components/Spinner.test.tsx",{"duration":77.51955700000008,"failed":false}],[":tests/backup-restore.test.tsx",{"duration":205.62362600000006,"failed":false}],[":tests/a11y.test.tsx",{"duration":754.295168,"failed":false}],[":tests/lib/search-fuzzy.test.ts",{"duration":5.431500000000028,"failed":false}],[":tests/components/settings-search.test.tsx",{"duration":723.2007870000001,"failed":false}],[":tests/memory-graph-view.test.tsx",{"duration":47.14713799999993,"failed":false}],[":tests/components/workspace-selector.test.tsx",{"duration":152.31454200000007,"failed":false}],[":tests/components/screenshot-ocr.test.tsx",{"duration":54.37460299999998,"failed":false}],[":tests/voice-recorder.test.tsx",{"duration":138.534312,"failed":false}],[":tests/autosave.test.tsx",{"duration":268.9614950000001,"failed":false}],[":tests/settings-layout.test.tsx",{"duration":248.58253200000001,"failed":false}],[":tests/settings-nav.test.tsx",{"duration":311.78257699999995,"failed":false}],[":tests/components/doctor-panel.test.tsx",{"duration":82.92706099999998,"failed":false}],[":tests/chat-composer.test.tsx",{"duration":207.18291899999997,"failed":false}],[":tests/settings-mode-wiring.test.tsx",{"duration":221.4198530000001,"failed":false}],[":tests/minimax-bar.test.ts",{"duration":0,"failed":true}],[":tests/minimax-bar.test.tsx",{"duration":36.76302400000009,"failed":false}],[":tests/components/plugin-permissions.test.tsx",{"duration":104.179755,"failed":false}],[":tests/eval-web-ui.test.tsx",{"duration":152.385127,"failed":false}]]}
|
|
@@ -181,6 +181,10 @@ export async function createApiRouter({
|
|
|
181
181
|
// v5.0.0 — Screenshot OCR routes. Accepts images, extracts text.
|
|
182
182
|
const { createOcrRouter } = await import('./routes/ocr.mjs');
|
|
183
183
|
router.use(await createOcrRouter({ projectRoot }));
|
|
184
|
+
// v5.2 — Tailscale auth key integration. Registered before misc.mjs so
|
|
185
|
+
// /tailscale/* routes here take precedence over any overlapping misc routes.
|
|
186
|
+
const { createTailscaleRouter } = await import('./routes/tailscale.mjs');
|
|
187
|
+
router.use(createTailscaleRouter({}));
|
|
184
188
|
router.use(createMiscRouter({ state, broadcast }));
|
|
185
189
|
|
|
186
190
|
// /api/auth/* must be reachable WITHOUT the bearer token so a fresh
|
|
@@ -134,10 +134,18 @@ export function createPluginsRouter() {
|
|
|
134
134
|
userAgent: _req.headers?.['user-agent'],
|
|
135
135
|
});
|
|
136
136
|
const installed = store.listInstalled();
|
|
137
|
-
|
|
138
|
-
|
|
137
|
+
const enhanced = installed.map(p => ({
|
|
138
|
+
...p,
|
|
139
|
+
permissions: p.permissions || [],
|
|
140
|
+
config: p.config || {},
|
|
141
|
+
methodCount: p.methodCount || 0,
|
|
142
|
+
invocations: p.invocations || 0,
|
|
143
|
+
lastInvokedAt: p.lastInvokedAt || null,
|
|
144
|
+
}));
|
|
145
|
+
span.setAttribute('plugin.installed.count', enhanced.length);
|
|
146
|
+
res.json({ plugins: enhanced });
|
|
139
147
|
recordTrace('plugin.installed.list', {
|
|
140
|
-
count_bucket:
|
|
148
|
+
count_bucket: enhanced.length === 0 ? '0' : enhanced.length < 10 ? '1-9' : '10+',
|
|
141
149
|
});
|
|
142
150
|
})));
|
|
143
151
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/server/routes/tailscale.mjs
|
|
3
|
+
*
|
|
4
|
+
* v5.2 — Tailscale auth key integration endpoints.
|
|
5
|
+
*
|
|
6
|
+
* Endpoints:
|
|
7
|
+
* GET /api/tailscale/status — tailscale state (installed, authenticated, serve config)
|
|
8
|
+
* POST /api/tailscale/setup — authenticate + set up tailscale serve
|
|
9
|
+
* POST /api/tailscale/unserve — remove tailscale serve
|
|
10
|
+
*/
|
|
11
|
+
import { Router } from 'express';
|
|
12
|
+
import { tailscaleStore } from '../tailscale-store.mjs';
|
|
13
|
+
import { wrap } from './_shared.mjs';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {object} _deps
|
|
17
|
+
* @returns {import('express').Router}
|
|
18
|
+
*/
|
|
19
|
+
export function createTailscaleRouter(_deps) {
|
|
20
|
+
const router = Router();
|
|
21
|
+
|
|
22
|
+
// GET /api/tailscale/status
|
|
23
|
+
router.get('/status', wrap(async (_req, res) => {
|
|
24
|
+
res.json(await tailscaleStore.status());
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
// POST /api/tailscale/setup — authenticate + enable serve
|
|
28
|
+
// Body: { authKey?: string, port?: number, https?: boolean, hostname?: string }
|
|
29
|
+
router.post('/setup', wrap(async (req, res) => {
|
|
30
|
+
const { authKey, port = 4321, https = true, hostname = '' } = req.body || {};
|
|
31
|
+
// If authKey is provided, set it in env for tailscale-store to pick up
|
|
32
|
+
if (authKey) {
|
|
33
|
+
process.env.TAILSCALE_AUTHKEY = authKey;
|
|
34
|
+
}
|
|
35
|
+
// delegates to tailscaleStore.enable() which runs `tailscale serve ...`
|
|
36
|
+
const result = await tailscaleStore.enable({ port, https, hostname });
|
|
37
|
+
res.json(result);
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
// POST /api/tailscale/unserve — remove tailscale serve
|
|
41
|
+
router.post('/unserve', wrap(async (_req, res) => {
|
|
42
|
+
res.json(await tailscaleStore.disable());
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
return router;
|
|
46
|
+
}
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
* src/server/routes/voice.mjs
|
|
3
3
|
*
|
|
4
4
|
* v5.0.0 — Voice notes REST API.
|
|
5
|
+
* v5.2 — Upload now saves audio immediately and enqueues background
|
|
6
|
+
* transcription instead of blocking the request on Whisper.
|
|
5
7
|
*
|
|
6
8
|
* Endpoints:
|
|
7
|
-
* POST /api/voice/upload — multipart upload,
|
|
9
|
+
* POST /api/voice/upload — multipart upload, enqueue transcription
|
|
8
10
|
* GET /api/voice/list?vaultPath= — list notes (optionally filtered)
|
|
9
11
|
* GET /api/voice/:id — get note details
|
|
10
12
|
* DELETE /api/voice/:id — delete note + audio + transcript
|
|
@@ -16,7 +18,7 @@ import { createReadStream, existsSync } from 'node:fs';
|
|
|
16
18
|
import { join } from 'node:path';
|
|
17
19
|
import { info, warn, child } from '../logger.mjs';
|
|
18
20
|
import { saveVoiceNote, listVoiceNotes, getVoiceNote, deleteVoiceNote } from '../voice-store.mjs';
|
|
19
|
-
import {
|
|
21
|
+
import { enqueueTranscription } from '../workers/transcription-worker.mjs';
|
|
20
22
|
import { wrap } from './_shared.mjs';
|
|
21
23
|
import formidable from 'formidable';
|
|
22
24
|
|
|
@@ -29,6 +31,14 @@ const log = child({ module: 'voice-routes' });
|
|
|
29
31
|
export function createVoiceRouter(_deps) {
|
|
30
32
|
const router = Router();
|
|
31
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Whether Whisper is configured. When false, the worker is skipped
|
|
36
|
+
* entirely — uploads still succeed, just without transcripts.
|
|
37
|
+
*/
|
|
38
|
+
function whisperAvailable() {
|
|
39
|
+
return Boolean(process.env.OPENAI_API_KEY) || Boolean(process.env.BIZAR_WHISPER_ENDPOINT);
|
|
40
|
+
}
|
|
41
|
+
|
|
32
42
|
// POST /api/voice/upload — multipart: audio file + vaultPath field
|
|
33
43
|
router.post('/voice/upload', wrap(async (req, res) => {
|
|
34
44
|
const form = formidable({ maxFileSize: 50 * 1024 * 1024 }); // 50 MB limit
|
|
@@ -60,8 +70,11 @@ export function createVoiceRouter(_deps) {
|
|
|
60
70
|
return;
|
|
61
71
|
}
|
|
62
72
|
|
|
63
|
-
//
|
|
64
|
-
|
|
73
|
+
// v5.2 — Transcription is now asynchronous. Save the audio first;
|
|
74
|
+
// the worker fills in the transcript when Whisper (or a local
|
|
75
|
+
// proxy) responds. Clients can poll GET /api/voice/:id or watch
|
|
76
|
+
// the `voice:updated` WS event for the transcript to appear.
|
|
77
|
+
const transcript = null;
|
|
65
78
|
|
|
66
79
|
// Duration from audioFile (formidable provides size but not duration — we
|
|
67
80
|
// store null and the frontmatter will show '?')
|
|
@@ -77,8 +90,31 @@ export function createVoiceRouter(_deps) {
|
|
|
77
90
|
return;
|
|
78
91
|
}
|
|
79
92
|
|
|
80
|
-
|
|
81
|
-
|
|
93
|
+
// Kick the background worker ONLY when we have a place to send
|
|
94
|
+
// the audio. Without credentials the worker would just return
|
|
95
|
+
// null forever and waste an open file descriptor.
|
|
96
|
+
let transcriptionPending = false;
|
|
97
|
+
if (whisperAvailable()) {
|
|
98
|
+
try {
|
|
99
|
+
enqueueTranscription(saved.id);
|
|
100
|
+
transcriptionPending = true;
|
|
101
|
+
} catch (enqErr) {
|
|
102
|
+
warn('transcription enqueue failed', { id: saved.id, err: enqErr.message });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
log.info('voice note saved via upload', {
|
|
107
|
+
id: saved.id,
|
|
108
|
+
transcriptionPending,
|
|
109
|
+
transcriptLength: 0,
|
|
110
|
+
});
|
|
111
|
+
res.json({
|
|
112
|
+
notePath: saved.notePath,
|
|
113
|
+
audioPath: saved.audioPath,
|
|
114
|
+
id: saved.id,
|
|
115
|
+
transcription: null,
|
|
116
|
+
transcriptionPending,
|
|
117
|
+
});
|
|
82
118
|
});
|
|
83
119
|
}));
|
|
84
120
|
|
|
@@ -473,6 +473,19 @@ export async function createServer({
|
|
|
473
473
|
console.warn('[bizar-dash] headroom startup hook skipped:', err?.message || err);
|
|
474
474
|
}
|
|
475
475
|
|
|
476
|
+
// v5.2 — Background transcription worker for voice notes. Uploads
|
|
477
|
+
// save audio immediately and enqueue the noteId here; the worker
|
|
478
|
+
// drains the queue, calls Whisper (or BIZAR_WHISPER_ENDPOINT), and
|
|
479
|
+
// broadcasts a `voice:updated` event so the dashboard updates in
|
|
480
|
+
// place. Errors here MUST NOT block boot — the worker is best-effort
|
|
481
|
+
// and a missing transcribe just leaves the note with no transcript.
|
|
482
|
+
try {
|
|
483
|
+
const { startTranscriptionWorker } = await import('./workers/transcription-worker.mjs');
|
|
484
|
+
startTranscriptionWorker({ broadcast: localBroadcast });
|
|
485
|
+
} catch (err) {
|
|
486
|
+
console.warn('[bizar-dash] transcription worker failed to start:', err?.message || err);
|
|
487
|
+
}
|
|
488
|
+
|
|
476
489
|
// All /api/* routes go through apiRouter (after mod routes are checked).
|
|
477
490
|
// IMPORTANT: mount v2 router FIRST so `/api/v2/*` matches before
|
|
478
491
|
// apiRouter's internal 404 catch-all (api.mjs line ~109) can swallow it.
|
|
@@ -168,6 +168,33 @@ export function getVoiceNote(id) {
|
|
|
168
168
|
return loadIndex().find((n) => n.id === id);
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
/**
|
|
172
|
+
* v5.2 — Patch an existing note in place. Used by the background
|
|
173
|
+
* transcription worker to attach the transcript after Whisper returns.
|
|
174
|
+
*
|
|
175
|
+
* Atomic write via the same `atomicWriteJson` helper as `saveVoiceNote`.
|
|
176
|
+
* `updatedAtMs` is refreshed on every call so callers / the UI can see
|
|
177
|
+
* when the note last changed. `createdAtMs` is preserved.
|
|
178
|
+
*
|
|
179
|
+
* If the id is unknown, returns null without touching the index. Callers
|
|
180
|
+
* (e.g. the worker) treat null as "skip and log".
|
|
181
|
+
*
|
|
182
|
+
* @param {string} id
|
|
183
|
+
* @param {Partial<VoiceNote>} patch
|
|
184
|
+
* @returns {VoiceNote|null}
|
|
185
|
+
*/
|
|
186
|
+
export function updateVoiceNote(id, patch) {
|
|
187
|
+
if (!id || !patch || typeof patch !== 'object') return null;
|
|
188
|
+
const notes = loadIndex();
|
|
189
|
+
const note = notes.find((n) => n.id === id);
|
|
190
|
+
if (!note) return null;
|
|
191
|
+
|
|
192
|
+
Object.assign(note, patch, { updatedAtMs: Date.now() });
|
|
193
|
+
saveIndex(notes);
|
|
194
|
+
log.info('voice note updated', { id, fields: Object.keys(patch) });
|
|
195
|
+
return note;
|
|
196
|
+
}
|
|
197
|
+
|
|
171
198
|
/**
|
|
172
199
|
* @param {string} id
|
|
173
200
|
* @returns {boolean}
|
|
@@ -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';
|
|
@@ -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
|
+
}
|