@polderlabs/bizar 4.9.0 → 5.0.1
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/icons-Bo0iH9EC.js +701 -0
- package/bizar-dash/dist/assets/icons-Bo0iH9EC.js.map +1 -0
- package/bizar-dash/dist/assets/{index-DU61awG3.js → index-DmpSFPJY.js} +1 -1
- package/bizar-dash/dist/assets/{index-DU61awG3.js.map → index-DmpSFPJY.js.map} +1 -1
- package/bizar-dash/dist/assets/main-C1cpttnv.js +19 -0
- package/bizar-dash/dist/assets/main-C1cpttnv.js.map +1 -0
- package/bizar-dash/dist/assets/main-DTkNlLrw.css +1 -0
- package/bizar-dash/dist/assets/markdown-DIquRulQ.js +29 -0
- package/bizar-dash/dist/assets/markdown-DIquRulQ.js.map +1 -0
- package/bizar-dash/dist/assets/mobile-BtxQJftK.js +2 -0
- package/bizar-dash/dist/assets/mobile-BtxQJftK.js.map +1 -0
- package/bizar-dash/dist/assets/mobile-DD-FZrTC.js +1 -0
- package/bizar-dash/dist/assets/{mobile-CL5uUQEC.js.map → mobile-DD-FZrTC.js.map} +1 -1
- package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js +40 -0
- package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js.map +1 -0
- package/bizar-dash/dist/index.html +6 -3
- package/bizar-dash/dist/mobile.html +5 -2
- package/bizar-dash/node_modules/.package-lock.json +6 -0
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/package-lock.json +6 -0
- package/bizar-dash/skills/eval/SKILL.md +237 -0
- package/bizar-dash/src/server/api.mjs +35 -0
- package/bizar-dash/src/server/auth.mjs +155 -1
- package/bizar-dash/src/server/diagnostics-store.mjs +452 -2
- package/bizar-dash/src/server/eval-store.mjs +226 -0
- package/bizar-dash/src/server/eval.mjs +347 -0
- package/bizar-dash/src/server/memory-store.mjs +46 -0
- package/bizar-dash/src/server/ocr.mjs +55 -0
- package/bizar-dash/src/server/plugins/registry.mjs +363 -0
- package/bizar-dash/src/server/plugins/sandbox.mjs +655 -0
- package/bizar-dash/src/server/plugins/store.mjs +659 -0
- package/bizar-dash/src/server/providers-store.mjs +11 -4
- package/bizar-dash/src/server/routes/_shared.mjs +2 -2
- package/bizar-dash/src/server/routes/clipboard.mjs +173 -0
- package/bizar-dash/src/server/routes/doctor.mjs +71 -0
- package/bizar-dash/src/server/routes/env-vars.mjs +67 -1
- package/bizar-dash/src/server/routes/eval.mjs +147 -0
- package/bizar-dash/src/server/routes/memory.mjs +3 -0
- package/bizar-dash/src/server/routes/ocr.mjs +182 -0
- package/bizar-dash/src/server/routes/opencode-session-detail.mjs +61 -42
- package/bizar-dash/src/server/routes/plugins.mjs +220 -0
- package/bizar-dash/src/server/routes/schedules.mjs +55 -0
- package/bizar-dash/src/server/routes/users.mjs +84 -0
- package/bizar-dash/src/server/routes/voice.mjs +131 -0
- package/bizar-dash/src/server/routes/workspaces.mjs +204 -0
- package/bizar-dash/src/server/serve-info.mjs +172 -0
- package/bizar-dash/src/server/voice-store.mjs +202 -0
- package/bizar-dash/src/server/voice-transcribe.mjs +72 -0
- package/bizar-dash/src/server/workspaces.mjs +626 -0
- package/bizar-dash/src/web/App.tsx +48 -11
- package/bizar-dash/src/web/components/AutosaveField.tsx +50 -0
- package/bizar-dash/src/web/components/DoctorPanel.tsx +160 -0
- package/bizar-dash/src/web/components/EnvVarManager.tsx +245 -62
- package/bizar-dash/src/web/components/InviteDialog.tsx +205 -0
- package/bizar-dash/src/web/components/ScheduleTemplateCard.tsx +71 -0
- package/bizar-dash/src/web/components/ScreenshotCapture.tsx +138 -0
- package/bizar-dash/src/web/components/ScreenshotOCR.tsx +42 -0
- package/bizar-dash/src/web/components/SettingsNav.tsx +101 -0
- package/bizar-dash/src/web/components/Sidebar.tsx +25 -1
- package/bizar-dash/src/web/components/StatusBadge.tsx +33 -2
- package/bizar-dash/src/web/components/Topbar.tsx +11 -0
- package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +182 -0
- package/bizar-dash/src/web/components/VoiceRecorder.tsx +104 -0
- package/bizar-dash/src/web/components/WorkspaceSelector.tsx +158 -0
- package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +64 -0
- package/bizar-dash/src/web/components/chat/Composer.tsx +1 -1
- package/bizar-dash/src/web/components/chat/useChat.ts +118 -1
- package/bizar-dash/src/web/hooks/useAutosave.ts +107 -0
- package/bizar-dash/src/web/lib/types.ts +134 -0
- package/bizar-dash/src/web/styles/chat.css +8 -5
- package/bizar-dash/src/web/styles/main.css +77 -2
- package/bizar-dash/src/web/styles/memory.css +82 -0
- package/bizar-dash/src/web/styles/settings.css +265 -0
- package/bizar-dash/src/web/views/Chat.tsx +15 -1
- package/bizar-dash/src/web/views/Doctor.tsx +317 -0
- package/bizar-dash/src/web/views/Memory.tsx +17 -2
- package/bizar-dash/src/web/views/MiniMaxUsage.tsx +1 -0
- package/bizar-dash/src/web/views/Schedules.tsx +94 -12
- package/bizar-dash/src/web/views/Settings.tsx +78 -45
- package/bizar-dash/src/web/views/Workspace.tsx +294 -0
- package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +114 -133
- package/bizar-dash/src/web/views/memory/FromScreenshotPanel.tsx +23 -0
- package/bizar-dash/src/web/views/memory/VaultFromClipboardPanel.tsx +101 -0
- package/bizar-dash/src/web/views/settings/AgentSection.tsx +23 -12
- package/bizar-dash/src/web/views/settings/EnvVarsSection.tsx +5 -6
- package/bizar-dash/src/web/views/settings/GeneralSection.tsx +38 -15
- package/bizar-dash/src/web/views/settings/MemorySection.tsx +92 -8
- package/bizar-dash/src/web/views/settings/WorkspacesSection.tsx +189 -0
- package/bizar-dash/tests/autosave.test.tsx +276 -0
- package/bizar-dash/tests/bundle-analysis.test.mjs +5 -2
- package/bizar-dash/tests/chat-composer.test.tsx +140 -0
- package/bizar-dash/tests/clipboard.test.mjs +147 -0
- package/bizar-dash/tests/components/doctor-panel.test.tsx +105 -0
- package/bizar-dash/tests/components/screenshot-ocr.test.tsx +75 -0
- package/bizar-dash/tests/components/workspace-selector.test.tsx +73 -0
- package/bizar-dash/tests/deploy-templates.test.mjs +100 -0
- package/bizar-dash/tests/diagnostics-store.test.mjs +206 -0
- package/bizar-dash/tests/env-vars-extended.test.mjs +190 -0
- package/bizar-dash/tests/eval/fixtures.test.mjs +141 -0
- package/bizar-dash/tests/eval/report.test.mjs +284 -0
- package/bizar-dash/tests/eval/runner.test.mjs +471 -0
- package/bizar-dash/tests/layout-spacing.test.mjs +101 -0
- package/bizar-dash/tests/memory-default-vault.test.mjs +98 -0
- package/bizar-dash/tests/minimax-bar.test.tsx +113 -0
- package/bizar-dash/tests/minimax-models.test.mjs +123 -0
- package/bizar-dash/tests/ocr.test.mjs +87 -0
- package/bizar-dash/tests/opencode-sessions-detail.test.mjs +158 -0
- package/bizar-dash/tests/plugins-registry.test.mjs +387 -0
- package/bizar-dash/tests/plugins-sandbox.test.mjs +374 -0
- package/bizar-dash/tests/plugins-store.test.mjs +455 -0
- package/bizar-dash/tests/routes-doctor.test.mjs +159 -0
- package/bizar-dash/tests/schedules-templates.test.mjs +144 -0
- package/bizar-dash/tests/settings-layout.test.tsx +129 -0
- package/bizar-dash/tests/settings-mode-wiring.test.tsx +151 -0
- package/bizar-dash/tests/settings-nav.test.tsx +126 -0
- package/bizar-dash/tests/users.test.mjs +108 -0
- package/bizar-dash/tests/voice-recorder.test.tsx +95 -0
- package/bizar-dash/tests/voice-store.test.mjs +148 -0
- package/bizar-dash/tests/voice-transcribe.test.mjs +87 -0
- package/bizar-dash/tests/workspaces.test.mjs +527 -0
- package/cli/bin.mjs +72 -2
- package/cli/commands/clip.mjs +146 -0
- package/cli/commands/deploy/cloudflare.mjs +250 -0
- package/cli/commands/deploy/docker.mjs +221 -0
- package/cli/commands/deploy/fly.mjs +161 -0
- package/cli/commands/deploy/vercel.mjs +225 -0
- package/cli/commands/deploy.mjs +240 -0
- package/cli/commands/eval.mjs +378 -0
- package/cli/commands/marketplace.mjs +64 -0
- package/cli/commands/minimax.mjs +5 -2
- package/cli/commands/ocr.mjs +165 -0
- package/cli/commands/plugin.mjs +358 -0
- package/cli/commands/voice.mjs +211 -0
- package/cli/commands/workspace.mjs +247 -0
- package/cli/tests/minimax-cli.test.mjs +79 -0
- package/config/agents/frigg.md +1 -1
- package/config/agents/heimdall.md +1 -1
- package/config/agents/mimir.md +1 -1
- package/config/agents/quick.md +1 -1
- package/config/agents/semble-search.md +1 -1
- package/config/agents/vor.md +1 -1
- package/config/opencode.json +28 -21
- package/config/opencode.json.template +8 -1
- package/package.json +12 -8
- package/plugins/bizar/index.ts +77 -0
- package/plugins/bizar/src/compaction.d.mts +48 -0
- package/plugins/bizar/src/compaction.mjs +192 -0
- package/plugins/bizar/tests/compaction.test.ts +264 -0
- package/templates/deploy/cloudflare/README.md +32 -0
- package/templates/deploy/cloudflare/functions-index.template.js +15 -0
- package/templates/deploy/cloudflare/wrangler.toml.template +9 -0
- package/templates/deploy/docker/.env.template +16 -0
- package/templates/deploy/docker/README.md +58 -0
- package/templates/deploy/docker/docker-compose.template.yml +23 -0
- package/templates/deploy/fly/README.md +35 -0
- package/templates/deploy/fly/fly.toml.template +28 -0
- package/templates/deploy/vercel/README.md +29 -0
- package/templates/deploy/vercel/api-index.template.js +18 -0
- package/templates/deploy/vercel/vercel.json.template +16 -0
- package/templates/eval-fixtures/README.md +58 -0
- package/templates/eval-fixtures/code-search-basic.json +28 -0
- package/templates/eval-fixtures/latency-bounds.json +16 -0
- package/templates/eval-fixtures/regression-suite.json +79 -0
- package/templates/eval-fixtures/response-format.json +30 -0
- package/templates/eval-fixtures/tool-call-correctness.json +24 -0
- package/templates/plugin-template/README.md +121 -0
- package/templates/plugin-template/index.js +66 -0
- package/templates/plugin-template/plugin.json +42 -0
- package/templates/plugin-template/tests/plugin.test.js +83 -0
- package/templates/schedules/daily-backup.json +12 -0
- package/templates/schedules/daily-cleanup.json +12 -0
- package/templates/schedules/hourly-health-check.json +12 -0
- package/templates/schedules/webhook-on-push.json +13 -0
- package/templates/schedules/weekly-digest.json +13 -0
- package/bizar-dash/dist/assets/main-DaC1Lc6q.js +0 -366
- package/bizar-dash/dist/assets/main-DaC1Lc6q.js.map +0 -1
- package/bizar-dash/dist/assets/main-DfmIfOUS.css +0 -1
- package/bizar-dash/dist/assets/mobile-CL5uUQEC.js +0 -1
- package/bizar-dash/dist/assets/mobile-D5WTWvuh.js +0 -338
- package/bizar-dash/dist/assets/mobile-D5WTWvuh.js.map +0 -1
- package/bizar-dash/src/web/views/settings/BackupSection.tsx +0 -16
- package/bizar-dash/src/web/views/settings/ProvidersSection.tsx +0 -16
- package/bizar-dash/src/web/views/settings/SkillsSection.tsx +0 -16
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/server/routes/workspaces.mjs
|
|
3
|
+
*
|
|
4
|
+
* v5.0.0 — Workspace management REST API.
|
|
5
|
+
*
|
|
6
|
+
* Endpoints:
|
|
7
|
+
* GET /api/workspaces — list user's workspaces
|
|
8
|
+
* POST /api/workspaces — create workspace
|
|
9
|
+
* GET /api/workspaces/:id — get workspace details
|
|
10
|
+
* DELETE /api/workspaces/:id — delete workspace (admin only)
|
|
11
|
+
* POST /api/workspaces/:id/invites — create invite
|
|
12
|
+
* GET /api/workspaces/:id/invites — list pending invites
|
|
13
|
+
* DELETE /api/workspaces/:id/invites/:token — revoke invite
|
|
14
|
+
* POST /api/workspaces/:id/members/:userId — update member role
|
|
15
|
+
* DELETE /api/workspaces/:id/members/:userId — remove member
|
|
16
|
+
* POST /api/invites/:token/accept — accept invite
|
|
17
|
+
*/
|
|
18
|
+
import { Router } from 'express';
|
|
19
|
+
import {
|
|
20
|
+
createWorkspace,
|
|
21
|
+
getWorkspace,
|
|
22
|
+
listWorkspacesWithRoles,
|
|
23
|
+
deleteWorkspace,
|
|
24
|
+
createInvite,
|
|
25
|
+
listInvites,
|
|
26
|
+
revokeInvite,
|
|
27
|
+
addMember,
|
|
28
|
+
removeMember,
|
|
29
|
+
updateMemberRole,
|
|
30
|
+
acceptInvite,
|
|
31
|
+
checkPermission,
|
|
32
|
+
listMembers,
|
|
33
|
+
ROLES,
|
|
34
|
+
} from '../workspaces.mjs';
|
|
35
|
+
import { getCurrentUserId, getCurrentWorkspaceId } from '../auth.mjs';
|
|
36
|
+
import { wrap } from './_shared.mjs';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @returns {import('express').Router}
|
|
40
|
+
*/
|
|
41
|
+
export function createWorkspacesRouter() {
|
|
42
|
+
const router = Router();
|
|
43
|
+
|
|
44
|
+
// GET /api/workspaces — list user's workspaces
|
|
45
|
+
router.get('/workspaces', wrap(async (req, res) => {
|
|
46
|
+
const userId = getCurrentUserId(req);
|
|
47
|
+
if (!userId) {
|
|
48
|
+
return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
|
49
|
+
}
|
|
50
|
+
const workspaces = await listWorkspacesWithRoles(userId);
|
|
51
|
+
res.json({ workspaces });
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
// POST /api/workspaces — create workspace
|
|
55
|
+
router.post('/workspaces', wrap(async (req, res) => {
|
|
56
|
+
const userId = getCurrentUserId(req);
|
|
57
|
+
if (!userId) {
|
|
58
|
+
return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
|
59
|
+
}
|
|
60
|
+
const { name } = req.body || {};
|
|
61
|
+
if (!name || typeof name !== 'string' || !name.trim()) {
|
|
62
|
+
return res.status(400).json({ error: 'bad_request', message: 'Workspace name is required' });
|
|
63
|
+
}
|
|
64
|
+
const workspace = await createWorkspace({ name: name.trim(), ownerId: userId });
|
|
65
|
+
res.status(201).json({ workspace });
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
// GET /api/workspaces/:id — get workspace details
|
|
69
|
+
router.get('/workspaces/:id', wrap(async (req, res) => {
|
|
70
|
+
const userId = getCurrentUserId(req);
|
|
71
|
+
if (!userId) {
|
|
72
|
+
return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
|
73
|
+
}
|
|
74
|
+
const { id } = req.params;
|
|
75
|
+
const workspace = await getWorkspace(id);
|
|
76
|
+
if (!workspace) {
|
|
77
|
+
return res.status(404).json({ error: 'not_found', message: 'Workspace not found' });
|
|
78
|
+
}
|
|
79
|
+
const hasAccess = await checkPermission(id, userId, ROLES.VIEWER);
|
|
80
|
+
if (!hasAccess) {
|
|
81
|
+
return res.status(403).json({ error: 'forbidden', message: 'Not a member of this workspace' });
|
|
82
|
+
}
|
|
83
|
+
const members = await listMembers(id);
|
|
84
|
+
res.json({ workspace, members });
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
// DELETE /api/workspaces/:id — delete workspace (admin only)
|
|
88
|
+
router.delete('/workspaces/:id', wrap(async (req, res) => {
|
|
89
|
+
const userId = getCurrentUserId(req);
|
|
90
|
+
if (!userId) {
|
|
91
|
+
return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
|
92
|
+
}
|
|
93
|
+
const { id } = req.params;
|
|
94
|
+
const canDelete = await checkPermission(id, userId, ROLES.ADMIN);
|
|
95
|
+
if (!canDelete) {
|
|
96
|
+
return res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
|
|
97
|
+
}
|
|
98
|
+
await deleteWorkspace(id);
|
|
99
|
+
res.json({ ok: true });
|
|
100
|
+
}));
|
|
101
|
+
|
|
102
|
+
// POST /api/workspaces/:id/invites — create invite
|
|
103
|
+
router.post('/workspaces/:id/invites', wrap(async (req, res) => {
|
|
104
|
+
const userId = getCurrentUserId(req);
|
|
105
|
+
if (!userId) {
|
|
106
|
+
return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
|
107
|
+
}
|
|
108
|
+
const { id } = req.params;
|
|
109
|
+
const { email, role } = req.body || {};
|
|
110
|
+
if (!email || typeof email !== 'string') {
|
|
111
|
+
return res.status(400).json({ error: 'bad_request', message: 'Email is required' });
|
|
112
|
+
}
|
|
113
|
+
if (!role || !Object.values(ROLES).includes(role)) {
|
|
114
|
+
return res.status(400).json({ error: 'bad_request', message: 'Valid role is required' });
|
|
115
|
+
}
|
|
116
|
+
const canInvite = await checkPermission(id, userId, ROLES.ADMIN);
|
|
117
|
+
if (!canInvite) {
|
|
118
|
+
return res.status(403).json({ error: 'forbidden', message: 'Admin role required to invite' });
|
|
119
|
+
}
|
|
120
|
+
const result = await createInvite(id, email, role, userId);
|
|
121
|
+
res.status(201).json(result);
|
|
122
|
+
}));
|
|
123
|
+
|
|
124
|
+
// GET /api/workspaces/:id/invites — list pending invites
|
|
125
|
+
router.get('/workspaces/:id/invites', wrap(async (req, res) => {
|
|
126
|
+
const userId = getCurrentUserId(req);
|
|
127
|
+
if (!userId) {
|
|
128
|
+
return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
|
129
|
+
}
|
|
130
|
+
const { id } = req.params;
|
|
131
|
+
const canView = await checkPermission(id, userId, ROLES.ADMIN);
|
|
132
|
+
if (!canView) {
|
|
133
|
+
return res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
|
|
134
|
+
}
|
|
135
|
+
const invites = await listInvites(id);
|
|
136
|
+
res.json({ invites });
|
|
137
|
+
}));
|
|
138
|
+
|
|
139
|
+
// DELETE /api/workspaces/:id/invites/:token — revoke invite
|
|
140
|
+
router.delete('/workspaces/:id/invites/:token', wrap(async (req, res) => {
|
|
141
|
+
const userId = getCurrentUserId(req);
|
|
142
|
+
if (!userId) {
|
|
143
|
+
return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
|
144
|
+
}
|
|
145
|
+
const { id, token } = req.params;
|
|
146
|
+
const canRevoke = await checkPermission(id, userId, ROLES.ADMIN);
|
|
147
|
+
if (!canRevoke) {
|
|
148
|
+
return res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
|
|
149
|
+
}
|
|
150
|
+
await revokeInvite(id, token);
|
|
151
|
+
res.json({ ok: true });
|
|
152
|
+
}));
|
|
153
|
+
|
|
154
|
+
// POST /api/workspaces/:id/members/:userId — update member role
|
|
155
|
+
router.post('/workspaces/:id/members/:userId', wrap(async (req, res) => {
|
|
156
|
+
const userId = getCurrentUserId(req);
|
|
157
|
+
if (!userId) {
|
|
158
|
+
return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
|
159
|
+
}
|
|
160
|
+
const { id, userId: targetUserId } = req.params;
|
|
161
|
+
const { role } = req.body || {};
|
|
162
|
+
if (!role || !Object.values(ROLES).includes(role)) {
|
|
163
|
+
return res.status(400).json({ error: 'bad_request', message: 'Valid role is required' });
|
|
164
|
+
}
|
|
165
|
+
const canUpdate = await checkPermission(id, userId, ROLES.ADMIN);
|
|
166
|
+
if (!canUpdate) {
|
|
167
|
+
return res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
|
|
168
|
+
}
|
|
169
|
+
await updateMemberRole(id, targetUserId, role);
|
|
170
|
+
res.json({ ok: true, role });
|
|
171
|
+
}));
|
|
172
|
+
|
|
173
|
+
// DELETE /api/workspaces/:id/members/:userId — remove member
|
|
174
|
+
router.delete('/workspaces/:id/members/:userId', wrap(async (req, res) => {
|
|
175
|
+
const userId = getCurrentUserId(req);
|
|
176
|
+
if (!userId) {
|
|
177
|
+
return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
|
178
|
+
}
|
|
179
|
+
const { id, userId: targetUserId } = req.params;
|
|
180
|
+
const canRemove = await checkPermission(id, userId, ROLES.ADMIN);
|
|
181
|
+
if (!canRemove) {
|
|
182
|
+
return res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
|
|
183
|
+
}
|
|
184
|
+
await removeMember(id, targetUserId);
|
|
185
|
+
res.json({ ok: true });
|
|
186
|
+
}));
|
|
187
|
+
|
|
188
|
+
// POST /api/invites/:token/accept — accept invite
|
|
189
|
+
router.post('/invites/:token/accept', wrap(async (req, res) => {
|
|
190
|
+
const userId = getCurrentUserId(req);
|
|
191
|
+
if (!userId) {
|
|
192
|
+
return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
|
193
|
+
}
|
|
194
|
+
const { token } = req.params;
|
|
195
|
+
const { email, name } = req.body || {};
|
|
196
|
+
if (!email || !name) {
|
|
197
|
+
return res.status(400).json({ error: 'bad_request', message: 'Email and name are required' });
|
|
198
|
+
}
|
|
199
|
+
const result = await acceptInvite(token, userId, email, name);
|
|
200
|
+
res.json({ ok: true, workspace: result.workspace, role: result.role });
|
|
201
|
+
}));
|
|
202
|
+
|
|
203
|
+
return router;
|
|
204
|
+
}
|
|
@@ -56,6 +56,7 @@ import { readFileSync, existsSync } from 'node:fs';
|
|
|
56
56
|
import { join } from 'node:path';
|
|
57
57
|
import { homedir } from 'node:os';
|
|
58
58
|
import { createConnection, isIP } from 'node:net';
|
|
59
|
+
import { warn as loggerWarn, error as loggerError } from './logger.mjs';
|
|
59
60
|
|
|
60
61
|
const HOME = homedir();
|
|
61
62
|
|
|
@@ -321,6 +322,147 @@ export async function listOpencodeSessions(info, timeoutMs = 5000) {
|
|
|
321
322
|
|
|
322
323
|
export const SERVE_INFO_FILE_PATHS = DEFAULT_SERVE_INFO_FILES;
|
|
323
324
|
|
|
325
|
+
// ── v5.0.0 — bug #4 — directory resolver (shared between route modules) ──
|
|
326
|
+
//
|
|
327
|
+
// Both `routes/opencode-sessions.mjs` and `routes/opencode-session-detail.mjs`
|
|
328
|
+
// need the same logic: given a sessionId and the plugin's serve-info, figure
|
|
329
|
+
// out which `directory` (worktree) to pass as a `?directory=…` query param
|
|
330
|
+
// to opencode's HTTP API. We previously had two copies of this resolver
|
|
331
|
+
// inline in each route; now there's one in serve-info.mjs so a fix here
|
|
332
|
+
// reaches both routes.
|
|
333
|
+
//
|
|
334
|
+
// Strategy (per issue #4 brief):
|
|
335
|
+
// 1. If serve.json has a `worktree` AND a fast probe (`GET
|
|
336
|
+
// /api/session/{id}?directory=<worktree>`) succeeds (HTTP 200), use
|
|
337
|
+
// the worktree — no need to list every opencode session.
|
|
338
|
+
// 2. Otherwise, list every session via `GET /api/session` and find the
|
|
339
|
+
// one whose id matches. Use its `location.directory` (or the legacy
|
|
340
|
+
// `worktree` field, if present) as the directory.
|
|
341
|
+
// 3. If neither yields a directory, return null — callers should 503
|
|
342
|
+
// so the UI can show "directory unknown" rather than sending the
|
|
343
|
+
// request with a blank `?directory=` (opencode 400s on that).
|
|
344
|
+
//
|
|
345
|
+
// `worktreeHasSession` is a small `GET /api/session/{id}?directory=...`
|
|
346
|
+
// probe used as a fast-path so we don't pay the cost of listing every
|
|
347
|
+
// session on every listMessages call. The endpoint returns 200 when the
|
|
348
|
+
// session exists in that worktree, 404 when it doesn't. We treat 2xx
|
|
349
|
+
// other than 200 as success (opencode has shipped both `200` and `204`
|
|
350
|
+
// on this endpoint across versions).
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Lightweight probe — does `sessionId` exist in `worktree`?
|
|
354
|
+
*
|
|
355
|
+
* Implemented as `GET /api/session/{id}?directory={worktree}`. Returns
|
|
356
|
+
* `true` on 2xx, `false` on 404, `null` on network / auth / other
|
|
357
|
+
* errors (the caller should fall back to the slower list path and
|
|
358
|
+
* trust THAT result).
|
|
359
|
+
*
|
|
360
|
+
* Never throws.
|
|
361
|
+
*
|
|
362
|
+
* @param {ServeInfo} info
|
|
363
|
+
* @param {string} sessionId
|
|
364
|
+
* @param {string} worktree
|
|
365
|
+
* @param {number} [timeoutMs]
|
|
366
|
+
* @returns {Promise<boolean|null>}
|
|
367
|
+
*/
|
|
368
|
+
async function worktreeHasSession(info, sessionId, worktree, timeoutMs = 2_500) {
|
|
369
|
+
if (!info || !sessionId || !worktree) return null;
|
|
370
|
+
const url = `${info.baseUrl}/api/session/${encodeURIComponent(sessionId)}?directory=${encodeURIComponent(worktree)}`;
|
|
371
|
+
const ac = new AbortController();
|
|
372
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
373
|
+
try {
|
|
374
|
+
const res = await fetch(url, {
|
|
375
|
+
method: 'GET',
|
|
376
|
+
headers: {
|
|
377
|
+
Authorization: buildAuthHeader(info),
|
|
378
|
+
Accept: 'application/json',
|
|
379
|
+
},
|
|
380
|
+
signal: ac.signal,
|
|
381
|
+
});
|
|
382
|
+
if (res.status >= 200 && res.status < 300) return true;
|
|
383
|
+
if (res.status === 404) return false;
|
|
384
|
+
// 401/403/5xx → don't trust the result, let the fallback path decide.
|
|
385
|
+
return null;
|
|
386
|
+
} catch {
|
|
387
|
+
return null;
|
|
388
|
+
} finally {
|
|
389
|
+
clearTimeout(timer);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Resolve the opencode `directory` (worktree) for a session.
|
|
395
|
+
*
|
|
396
|
+
* Per the brief:
|
|
397
|
+
* 1. If we have a worktree AND a fast probe confirms the session
|
|
398
|
+
* lives there, return it.
|
|
399
|
+
* 2. Otherwise, list every session and find the matching one; use
|
|
400
|
+
* its `location.directory` (or legacy `worktree`).
|
|
401
|
+
* 3. Return null if neither yields a directory.
|
|
402
|
+
*
|
|
403
|
+
* Signature is `(sessionId, serveInfo)` (positional, sessionId first)
|
|
404
|
+
* to match the brief. Callers that only have `serveInfo` can pass
|
|
405
|
+
* `null` for `sessionId` and the function will fall through to the
|
|
406
|
+
* list path.
|
|
407
|
+
*
|
|
408
|
+
* Never throws.
|
|
409
|
+
*
|
|
410
|
+
* @param {string|null|undefined} sessionId
|
|
411
|
+
* @param {ServeInfo|null|undefined} serveInfo
|
|
412
|
+
* @returns {Promise<string|null>}
|
|
413
|
+
*/
|
|
414
|
+
export async function resolveSessionDirectory(sessionId, serveInfo) {
|
|
415
|
+
if (!serveInfo) return null;
|
|
416
|
+
|
|
417
|
+
// Fast path: probe the recorded worktree first. If the session lives
|
|
418
|
+
// there, we save the round-trip to /api/session.
|
|
419
|
+
if (typeof serveInfo.worktree === 'string' && serveInfo.worktree.length > 0) {
|
|
420
|
+
const probe = await worktreeHasSession(serveInfo, sessionId || '', serveInfo.worktree);
|
|
421
|
+
if (probe === true) return serveInfo.worktree;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Slower fallback: list every session and find the match.
|
|
425
|
+
try {
|
|
426
|
+
const sessions = await listOpencodeSessions(serveInfo, 5_000);
|
|
427
|
+
if (Array.isArray(sessions)) {
|
|
428
|
+
const entry = sessionId
|
|
429
|
+
? sessions.find(
|
|
430
|
+
(s) =>
|
|
431
|
+
s && (s.id === sessionId || (typeof s.sessionId === 'string' && s.sessionId === sessionId)),
|
|
432
|
+
)
|
|
433
|
+
: null;
|
|
434
|
+
if (entry) {
|
|
435
|
+
const dir = entry?.location?.directory;
|
|
436
|
+
if (typeof dir === 'string' && dir.length > 0) return dir;
|
|
437
|
+
// Defensive: some opencode builds store the worktree on the
|
|
438
|
+
// session at the top level instead of under `location`.
|
|
439
|
+
const legacy = entry?.worktree;
|
|
440
|
+
if (typeof legacy === 'string' && legacy.length > 0) return legacy;
|
|
441
|
+
}
|
|
442
|
+
// No matching session, but we have a worktree from serve.json —
|
|
443
|
+
// last-chance fallback. This mirrors the pre-v5.0.0 inline
|
|
444
|
+
// resolver behaviour so an unmatched session still routes to
|
|
445
|
+
// the plugin's recorded cwd rather than 503'ing.
|
|
446
|
+
if (!entry && typeof serveInfo.worktree === 'string' && serveInfo.worktree.length > 0) {
|
|
447
|
+
return serveInfo.worktree;
|
|
448
|
+
}
|
|
449
|
+
} else if (typeof serveInfo.worktree === 'string' && serveInfo.worktree.length > 0) {
|
|
450
|
+
// listOpencodeSessions returned null (serve offline / auth fail).
|
|
451
|
+
// Fall back to the worktree so we at least try the same
|
|
452
|
+
// directory the plugin recorded — the upstream 404/502 will
|
|
453
|
+
// surface a clearer error than a 503 directory_unknown.
|
|
454
|
+
return serveInfo.worktree;
|
|
455
|
+
}
|
|
456
|
+
} catch (err) {
|
|
457
|
+
loggerWarn('resolveSessionDirectory: listOpencodeSessions failed', {
|
|
458
|
+
sessionId: sessionId || null,
|
|
459
|
+
err: err instanceof Error ? err.message : String(err),
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
|
|
324
466
|
// ── v3.5.4 (bug: dispatch stuck) — spawn helpers ─────────────────────────
|
|
325
467
|
//
|
|
326
468
|
// The dashboard's task delegator used to shell out to
|
|
@@ -473,8 +615,21 @@ export async function sendOpencodePrompt(info, opts, directory, timeoutMs = DEFA
|
|
|
473
615
|
} catch (err) {
|
|
474
616
|
const msg = err instanceof Error ? err.message : String(err);
|
|
475
617
|
const isAbort = err instanceof Error && err.name === 'AbortError';
|
|
618
|
+
let cause = 'unknown';
|
|
619
|
+
if (isAbort) cause = 'timeout';
|
|
620
|
+
else if (err && typeof err === 'object') {
|
|
621
|
+
const code = /** @type {any} */ (err).code;
|
|
622
|
+
if (typeof code === 'string' && code.length > 0) {
|
|
623
|
+
cause = code.startsWith('ECONN') || code.startsWith('UND_ERR') || code === 'ENOTFOUND'
|
|
624
|
+
? 'network'
|
|
625
|
+
: code;
|
|
626
|
+
} else {
|
|
627
|
+
cause = 'network';
|
|
628
|
+
}
|
|
629
|
+
}
|
|
476
630
|
return {
|
|
477
631
|
ok: false,
|
|
632
|
+
cause,
|
|
478
633
|
error: isAbort ? `sendPrompt timed out after ${timeoutMs}ms` : `sendPrompt network error: ${msg}`,
|
|
479
634
|
};
|
|
480
635
|
} finally {
|
|
@@ -543,8 +698,25 @@ export async function listOpencodeMessages(info, sessionId, directory, timeoutMs
|
|
|
543
698
|
} catch (err) {
|
|
544
699
|
const msg = err instanceof Error ? err.message : String(err);
|
|
545
700
|
const isAbort = err instanceof Error && err.name === 'AbortError';
|
|
701
|
+
// v5.0.0 — bug #4: surface a structured `cause` to the route so it
|
|
702
|
+
// can render a useful suggestion without substring-sniffing the
|
|
703
|
+
// error message. Prefers the underlying network error code
|
|
704
|
+
// (ECONNREFUSED, UND_ERR_SOCKET, …) when present.
|
|
705
|
+
let cause = 'unknown';
|
|
706
|
+
if (isAbort) cause = 'timeout';
|
|
707
|
+
else if (err && typeof err === 'object') {
|
|
708
|
+
const code = /** @type {any} */ (err).code;
|
|
709
|
+
if (typeof code === 'string' && code.length > 0) {
|
|
710
|
+
cause = code.startsWith('ECONN') || code.startsWith('UND_ERR') || code === 'ENOTFOUND'
|
|
711
|
+
? 'network'
|
|
712
|
+
: code;
|
|
713
|
+
} else {
|
|
714
|
+
cause = 'network';
|
|
715
|
+
}
|
|
716
|
+
}
|
|
546
717
|
return {
|
|
547
718
|
ok: false,
|
|
719
|
+
cause,
|
|
548
720
|
error: isAbort ? `listMessages timed out after ${timeoutMs}ms` : `listMessages network error: ${msg}`,
|
|
549
721
|
};
|
|
550
722
|
} finally {
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/server/voice-store.mjs
|
|
3
|
+
*
|
|
4
|
+
* v5.0.0 — Voice note persistence layer.
|
|
5
|
+
*
|
|
6
|
+
* Audio files are stored at:
|
|
7
|
+
* ~/.local/share/bizar/voice-notes/<id>.webm
|
|
8
|
+
*
|
|
9
|
+
* Markdown transcripts are written to the user's vault at:
|
|
10
|
+
* <vaultPath>/.obsidian/voice-notes/<id>.md
|
|
11
|
+
* or (if no vaultPath is set) to the default vault:
|
|
12
|
+
* ~/.local/share/bizar/voice-notes/transcripts/<id>.md
|
|
13
|
+
*
|
|
14
|
+
* Metadata index is at:
|
|
15
|
+
* ~/.local/share/bizar/voice-notes/index.json
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
existsSync,
|
|
20
|
+
readFileSync,
|
|
21
|
+
writeFileSync,
|
|
22
|
+
renameSync,
|
|
23
|
+
mkdirSync,
|
|
24
|
+
readdirSync,
|
|
25
|
+
unlinkSync,
|
|
26
|
+
} from 'node:fs';
|
|
27
|
+
import { join, dirname } from 'node:path';
|
|
28
|
+
import { homedir } from 'node:os';
|
|
29
|
+
import { randomBytes } from 'node:crypto';
|
|
30
|
+
import { info, warn, child } from './logger.mjs';
|
|
31
|
+
|
|
32
|
+
const log = child({ module: 'voice-store' });
|
|
33
|
+
|
|
34
|
+
const HOME = homedir();
|
|
35
|
+
const VOICE_DIR = join(HOME, '.local', 'share', 'bizar', 'voice-notes');
|
|
36
|
+
const INDEX_FILE = join(VOICE_DIR, 'index.json');
|
|
37
|
+
const TRANSCRIPT_DIR = join(VOICE_DIR, 'transcripts');
|
|
38
|
+
|
|
39
|
+
// ── Paths ───────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
function audioPath(id) {
|
|
42
|
+
return join(VOICE_DIR, `${id}.webm`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function transcriptPath(id, vaultPath) {
|
|
46
|
+
if (vaultPath) {
|
|
47
|
+
const dir = join(vaultPath, '.obsidian', 'voice-notes');
|
|
48
|
+
return join(dir, `${id}.md`);
|
|
49
|
+
}
|
|
50
|
+
return join(TRANSCRIPT_DIR, `${id}.md`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Atomic helpers ──────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
function atomicWriteJson(filePath, data) {
|
|
56
|
+
const tmp = `${filePath}.tmp.${process.pid}`;
|
|
57
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
58
|
+
renameSync(tmp, filePath);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Index ───────────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @typedef {Object} VoiceNote
|
|
65
|
+
* @property {string} id
|
|
66
|
+
* @property {string} audioPath
|
|
67
|
+
* @property {string|null} notePath - markdown transcript path (may not exist yet)
|
|
68
|
+
* @property {string|null} transcript
|
|
69
|
+
* @property {number|null} durationSec
|
|
70
|
+
* @property {string|null} vaultPath
|
|
71
|
+
* @property {number} createdAtMs
|
|
72
|
+
* @property {number} updatedAtMs
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
/** @returns {VoiceNote[]} */
|
|
76
|
+
function loadIndex() {
|
|
77
|
+
try {
|
|
78
|
+
if (!existsSync(INDEX_FILE)) return [];
|
|
79
|
+
const text = readFileSync(INDEX_FILE, 'utf8');
|
|
80
|
+
if (!text.trim()) return [];
|
|
81
|
+
return JSON.parse(text);
|
|
82
|
+
} catch {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function saveIndex(notes) {
|
|
88
|
+
mkdirSync(dirname(INDEX_FILE), { recursive: true });
|
|
89
|
+
atomicWriteJson(INDEX_FILE, notes);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Public API ───────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Save a voice note: write audio file + markdown transcript + update index.
|
|
96
|
+
*
|
|
97
|
+
* @param {{ audioBuffer: Buffer|Uint8Array, transcript: string|null, durationSec: number|null, vaultPath: string|null }} opts
|
|
98
|
+
* @returns {Promise<{ notePath: string, audioPath: string, id: string }>}
|
|
99
|
+
*/
|
|
100
|
+
export async function saveVoiceNote({ audioBuffer, transcript, durationSec, vaultPath }) {
|
|
101
|
+
const id = randomBytes(8).toString('hex');
|
|
102
|
+
const ap = audioPath(id);
|
|
103
|
+
|
|
104
|
+
// 1. Write audio (binary, no atomic needed for raw bytes — just ensure dir)
|
|
105
|
+
mkdirSync(dirname(ap), { recursive: true });
|
|
106
|
+
writeFileSync(ap, Buffer.from(audioBuffer));
|
|
107
|
+
|
|
108
|
+
// 2. Write markdown transcript if we have one
|
|
109
|
+
let notePath = null;
|
|
110
|
+
if (transcript) {
|
|
111
|
+
const tp = transcriptPath(id, vaultPath);
|
|
112
|
+
const tpDir = dirname(tp);
|
|
113
|
+
mkdirSync(tpDir, { recursive: true });
|
|
114
|
+
const md = [
|
|
115
|
+
'---',
|
|
116
|
+
`title: Voice Note ${new Date().toISOString().slice(0, 16).replace('T', ' ')}`,
|
|
117
|
+
`date: ${new Date().toISOString()}`,
|
|
118
|
+
`type: voice-note`,
|
|
119
|
+
`audio: ${id}.webm`,
|
|
120
|
+
`duration: ${durationSec ?? '?'}`,
|
|
121
|
+
'---',
|
|
122
|
+
'',
|
|
123
|
+
`# ${new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}`,
|
|
124
|
+
'',
|
|
125
|
+
transcript,
|
|
126
|
+
].join('\n');
|
|
127
|
+
writeFileSync(tp, md, 'utf8');
|
|
128
|
+
notePath = tp;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 3. Update index
|
|
132
|
+
const notes = loadIndex();
|
|
133
|
+
/** @type {VoiceNote} */
|
|
134
|
+
const entry = {
|
|
135
|
+
id,
|
|
136
|
+
audioPath: ap,
|
|
137
|
+
notePath,
|
|
138
|
+
transcript: transcript || null,
|
|
139
|
+
durationSec: durationSec || null,
|
|
140
|
+
vaultPath: vaultPath || null,
|
|
141
|
+
createdAtMs: Date.now(),
|
|
142
|
+
updatedAtMs: Date.now(),
|
|
143
|
+
};
|
|
144
|
+
notes.unshift(entry); // newest first
|
|
145
|
+
saveIndex(notes);
|
|
146
|
+
|
|
147
|
+
log.info('voice note saved', { id, notePath, audioPath: ap });
|
|
148
|
+
return { notePath, audioPath: ap, id };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {{ vaultPath?: string|null, limit?: number }} opts
|
|
153
|
+
* @returns {VoiceNote[]}
|
|
154
|
+
*/
|
|
155
|
+
export function listVoiceNotes({ vaultPath = null, limit = 50 } = {}) {
|
|
156
|
+
const notes = loadIndex();
|
|
157
|
+
const filtered = vaultPath
|
|
158
|
+
? notes.filter((n) => n.vaultPath === vaultPath)
|
|
159
|
+
: notes;
|
|
160
|
+
return filtered.slice(0, limit);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* @param {string} id
|
|
165
|
+
* @returns {VoiceNote|undefined}
|
|
166
|
+
*/
|
|
167
|
+
export function getVoiceNote(id) {
|
|
168
|
+
return loadIndex().find((n) => n.id === id);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* @param {string} id
|
|
173
|
+
* @returns {boolean}
|
|
174
|
+
*/
|
|
175
|
+
export function deleteVoiceNote(id) {
|
|
176
|
+
const notes = loadIndex();
|
|
177
|
+
const idx = notes.findIndex((n) => n.id === id);
|
|
178
|
+
if (idx === -1) return false;
|
|
179
|
+
|
|
180
|
+
const { audioPath: ap, notePath: tp } = notes[idx];
|
|
181
|
+
|
|
182
|
+
// Remove audio file
|
|
183
|
+
try {
|
|
184
|
+
if (existsSync(ap)) unlinkSync(ap);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
warn('failed to remove audio file', { id, ap, err: err.message });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Remove transcript file
|
|
190
|
+
if (tp) {
|
|
191
|
+
try {
|
|
192
|
+
if (existsSync(tp)) unlinkSync(tp);
|
|
193
|
+
} catch (err) {
|
|
194
|
+
warn('failed to remove transcript file', { id, tp, err: err.message });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
notes.splice(idx, 1);
|
|
199
|
+
saveIndex(notes);
|
|
200
|
+
log.info('voice note deleted', { id });
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/server/voice-transcribe.mjs
|
|
3
|
+
*
|
|
4
|
+
* v5.0.0 — Whisper transcription for voice notes.
|
|
5
|
+
*
|
|
6
|
+
* Uses OpenAI Whisper API (or a compatible BIZAR_WHISPER_ENDPOINT).
|
|
7
|
+
* Returns null on failure so callers can degrade gracefully.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { info, warn, child } from './logger.mjs';
|
|
11
|
+
|
|
12
|
+
const log = child({ module: 'voice-transcribe' });
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Transcribe an audio buffer via Whisper.
|
|
16
|
+
*
|
|
17
|
+
* @param {Buffer|Uint8Array} audioBuffer - raw audio bytes
|
|
18
|
+
* @param {{ mimeType?: string }} opts
|
|
19
|
+
* @returns {Promise<string|null>} transcript text or null
|
|
20
|
+
*/
|
|
21
|
+
export async function transcribe(audioBuffer, { mimeType = 'audio/webm' } = {}) {
|
|
22
|
+
if (!process.env.OPENAI_API_KEY && !process.env.BIZAR_WHISPER_ENDPOINT) {
|
|
23
|
+
log.debug('no whisper credentials configured, skipping transcription');
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const endpoint =
|
|
28
|
+
process.env.BIZAR_WHISPER_ENDPOINT || 'https://api.openai.com/v1/audio/transcriptions';
|
|
29
|
+
|
|
30
|
+
const body = new FormData();
|
|
31
|
+
body.append(
|
|
32
|
+
'file',
|
|
33
|
+
new Blob([audioBuffer], { type: mimeType }),
|
|
34
|
+
'audio.webm',
|
|
35
|
+
);
|
|
36
|
+
body.append('model', 'whisper-1');
|
|
37
|
+
|
|
38
|
+
const headers = {};
|
|
39
|
+
if (process.env.OPENAI_API_KEY) {
|
|
40
|
+
headers['Authorization'] = `Bearer ${process.env.OPENAI_API_KEY}`;
|
|
41
|
+
}
|
|
42
|
+
// BIZAR_WHISPER_ENDPOINT may be a local proxy that doesn't need auth
|
|
43
|
+
// but may also support Authorization header — only set if key exists
|
|
44
|
+
|
|
45
|
+
log.info('submitting audio to whisper', { endpoint, sizeBytes: audioBuffer.byteLength });
|
|
46
|
+
|
|
47
|
+
let resp;
|
|
48
|
+
try {
|
|
49
|
+
resp = await fetch(endpoint, { method: 'POST', body, headers });
|
|
50
|
+
} catch (err) {
|
|
51
|
+
warn('whisper fetch failed', { err: err.message });
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!resp.ok) {
|
|
56
|
+
const text = await resp.text().catch(() => '');
|
|
57
|
+
warn('whisper returned error', { status: resp.status, body: text });
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let data;
|
|
62
|
+
try {
|
|
63
|
+
data = await resp.json();
|
|
64
|
+
} catch {
|
|
65
|
+
warn('whisper returned non-JSON');
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const text = typeof data.text === 'string' ? data.text.trim() : null;
|
|
70
|
+
log.info('transcription complete', { textLength: text?.length ?? 0 });
|
|
71
|
+
return text;
|
|
72
|
+
}
|