@polderlabs/bizar 4.9.0 → 5.0.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.
Files changed (107) hide show
  1. package/bizar-dash/dist/assets/icons-CFqu2M-c.js +656 -0
  2. package/bizar-dash/dist/assets/icons-CFqu2M-c.js.map +1 -0
  3. package/bizar-dash/dist/assets/{index-DU61awG3.js → index-DmpSFPJY.js} +1 -1
  4. package/bizar-dash/dist/assets/{index-DU61awG3.js.map → index-DmpSFPJY.js.map} +1 -1
  5. package/bizar-dash/dist/assets/main-Dl8yY5_H.js +16 -0
  6. package/bizar-dash/dist/assets/main-Dl8yY5_H.js.map +1 -0
  7. package/bizar-dash/dist/assets/{main-DfmIfOUS.css → main-ZAfGKENE.css} +1 -1
  8. package/bizar-dash/dist/assets/markdown-DIquRulQ.js +29 -0
  9. package/bizar-dash/dist/assets/markdown-DIquRulQ.js.map +1 -0
  10. package/bizar-dash/dist/assets/mobile-C2gysFOZ.js +2 -0
  11. package/bizar-dash/dist/assets/mobile-C2gysFOZ.js.map +1 -0
  12. package/bizar-dash/dist/assets/mobile-DHXXbn1A.js +1 -0
  13. package/bizar-dash/dist/assets/{mobile-CL5uUQEC.js.map → mobile-DHXXbn1A.js.map} +1 -1
  14. package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js +40 -0
  15. package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js.map +1 -0
  16. package/bizar-dash/dist/index.html +6 -3
  17. package/bizar-dash/dist/mobile.html +5 -2
  18. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  19. package/bizar-dash/skills/eval/SKILL.md +237 -0
  20. package/bizar-dash/src/server/api.mjs +28 -0
  21. package/bizar-dash/src/server/auth.mjs +155 -1
  22. package/bizar-dash/src/server/eval-store.mjs +226 -0
  23. package/bizar-dash/src/server/eval.mjs +347 -0
  24. package/bizar-dash/src/server/ocr.mjs +55 -0
  25. package/bizar-dash/src/server/plugins/registry.mjs +363 -0
  26. package/bizar-dash/src/server/plugins/sandbox.mjs +655 -0
  27. package/bizar-dash/src/server/plugins/store.mjs +659 -0
  28. package/bizar-dash/src/server/routes/clipboard.mjs +173 -0
  29. package/bizar-dash/src/server/routes/eval.mjs +147 -0
  30. package/bizar-dash/src/server/routes/ocr.mjs +182 -0
  31. package/bizar-dash/src/server/routes/plugins.mjs +220 -0
  32. package/bizar-dash/src/server/routes/users.mjs +84 -0
  33. package/bizar-dash/src/server/routes/voice.mjs +131 -0
  34. package/bizar-dash/src/server/routes/workspaces.mjs +204 -0
  35. package/bizar-dash/src/server/voice-store.mjs +202 -0
  36. package/bizar-dash/src/server/voice-transcribe.mjs +72 -0
  37. package/bizar-dash/src/server/workspaces.mjs +626 -0
  38. package/bizar-dash/src/web/components/InviteDialog.tsx +205 -0
  39. package/bizar-dash/src/web/components/ScreenshotCapture.tsx +138 -0
  40. package/bizar-dash/src/web/components/ScreenshotOCR.tsx +42 -0
  41. package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +182 -0
  42. package/bizar-dash/src/web/components/VoiceRecorder.tsx +104 -0
  43. package/bizar-dash/src/web/components/WorkspaceSelector.tsx +158 -0
  44. package/bizar-dash/src/web/styles/memory.css +82 -0
  45. package/bizar-dash/src/web/views/Memory.tsx +17 -2
  46. package/bizar-dash/src/web/views/Settings.tsx +3 -0
  47. package/bizar-dash/src/web/views/Workspace.tsx +294 -0
  48. package/bizar-dash/src/web/views/memory/FromScreenshotPanel.tsx +23 -0
  49. package/bizar-dash/src/web/views/memory/VaultFromClipboardPanel.tsx +101 -0
  50. package/bizar-dash/src/web/views/settings/WorkspacesSection.tsx +189 -0
  51. package/bizar-dash/tests/bundle-analysis.test.mjs +5 -2
  52. package/bizar-dash/tests/clipboard.test.mjs +147 -0
  53. package/bizar-dash/tests/components/screenshot-ocr.test.tsx +75 -0
  54. package/bizar-dash/tests/components/workspace-selector.test.tsx +73 -0
  55. package/bizar-dash/tests/deploy-templates.test.mjs +100 -0
  56. package/bizar-dash/tests/eval/fixtures.test.mjs +141 -0
  57. package/bizar-dash/tests/eval/report.test.mjs +284 -0
  58. package/bizar-dash/tests/eval/runner.test.mjs +471 -0
  59. package/bizar-dash/tests/ocr.test.mjs +87 -0
  60. package/bizar-dash/tests/plugins-registry.test.mjs +387 -0
  61. package/bizar-dash/tests/plugins-sandbox.test.mjs +374 -0
  62. package/bizar-dash/tests/plugins-store.test.mjs +455 -0
  63. package/bizar-dash/tests/users.test.mjs +108 -0
  64. package/bizar-dash/tests/voice-recorder.test.tsx +95 -0
  65. package/bizar-dash/tests/voice-store.test.mjs +148 -0
  66. package/bizar-dash/tests/voice-transcribe.test.mjs +87 -0
  67. package/bizar-dash/tests/workspaces.test.mjs +527 -0
  68. package/cli/bin.mjs +72 -2
  69. package/cli/commands/clip.mjs +146 -0
  70. package/cli/commands/deploy/cloudflare.mjs +250 -0
  71. package/cli/commands/deploy/docker.mjs +221 -0
  72. package/cli/commands/deploy/fly.mjs +161 -0
  73. package/cli/commands/deploy/vercel.mjs +225 -0
  74. package/cli/commands/deploy.mjs +240 -0
  75. package/cli/commands/eval.mjs +378 -0
  76. package/cli/commands/marketplace.mjs +64 -0
  77. package/cli/commands/ocr.mjs +165 -0
  78. package/cli/commands/plugin.mjs +358 -0
  79. package/cli/commands/voice.mjs +211 -0
  80. package/cli/commands/workspace.mjs +247 -0
  81. package/package.json +12 -8
  82. package/templates/deploy/cloudflare/README.md +32 -0
  83. package/templates/deploy/cloudflare/functions-index.template.js +15 -0
  84. package/templates/deploy/cloudflare/wrangler.toml.template +9 -0
  85. package/templates/deploy/docker/.env.template +16 -0
  86. package/templates/deploy/docker/README.md +58 -0
  87. package/templates/deploy/docker/docker-compose.template.yml +23 -0
  88. package/templates/deploy/fly/README.md +35 -0
  89. package/templates/deploy/fly/fly.toml.template +28 -0
  90. package/templates/deploy/vercel/README.md +29 -0
  91. package/templates/deploy/vercel/api-index.template.js +18 -0
  92. package/templates/deploy/vercel/vercel.json.template +16 -0
  93. package/templates/eval-fixtures/README.md +58 -0
  94. package/templates/eval-fixtures/code-search-basic.json +28 -0
  95. package/templates/eval-fixtures/latency-bounds.json +16 -0
  96. package/templates/eval-fixtures/regression-suite.json +79 -0
  97. package/templates/eval-fixtures/response-format.json +30 -0
  98. package/templates/eval-fixtures/tool-call-correctness.json +24 -0
  99. package/templates/plugin-template/README.md +121 -0
  100. package/templates/plugin-template/index.js +66 -0
  101. package/templates/plugin-template/plugin.json +42 -0
  102. package/templates/plugin-template/tests/plugin.test.js +83 -0
  103. package/bizar-dash/dist/assets/main-DaC1Lc6q.js +0 -366
  104. package/bizar-dash/dist/assets/main-DaC1Lc6q.js.map +0 -1
  105. package/bizar-dash/dist/assets/mobile-CL5uUQEC.js +0 -1
  106. package/bizar-dash/dist/assets/mobile-D5WTWvuh.js +0 -338
  107. package/bizar-dash/dist/assets/mobile-D5WTWvuh.js.map +0 -1
@@ -0,0 +1,205 @@
1
+ // src/web/components/InviteDialog.tsx — v5.0.0 — modal to create and manage workspace invites.
2
+ import { useState, useEffect } from 'react';
3
+ import { Copy, Check, Trash2, Mail, Link2 } from 'lucide-react';
4
+ import { useModal } from './Modal';
5
+ import { Button } from './Button';
6
+ import { api } from '../lib/api';
7
+ import { cn } from '../lib/utils';
8
+
9
+ export type InviteInfo = {
10
+ token: string;
11
+ email: string;
12
+ role: 'admin' | 'editor' | 'viewer';
13
+ invitedBy: string;
14
+ expiresAt: string;
15
+ };
16
+
17
+ type Props = {
18
+ workspaceId: string;
19
+ onInviteCreated?: () => void;
20
+ };
21
+
22
+ export function InviteDialog({ workspaceId, onInviteCreated }: Props) {
23
+ const modal = useModal();
24
+ const [email, setEmail] = useState('');
25
+ const [role, setRole] = useState<'admin' | 'editor' | 'viewer'>('editor');
26
+ const [invites, setInvites] = useState<InviteInfo[]>([]);
27
+ const [loading, setLoading] = useState(false);
28
+ const [creating, setCreating] = useState(false);
29
+ const [copied, setCopied] = useState<string | null>(null);
30
+ const [error, setError] = useState<string | null>(null);
31
+
32
+ useEffect(() => {
33
+ loadInvites();
34
+ }, [workspaceId]);
35
+
36
+ async function loadInvites() {
37
+ setLoading(true);
38
+ try {
39
+ const r = await api.get<{ invites: InviteInfo[] }>(`/workspaces/${workspaceId}/invites`);
40
+ setInvites(r.invites || []);
41
+ } catch {
42
+ // Ignore load errors
43
+ } finally {
44
+ setLoading(false);
45
+ }
46
+ }
47
+
48
+ async function handleCreate(e: React.FormEvent) {
49
+ e.preventDefault();
50
+ if (!email.trim()) return;
51
+ setCreating(true);
52
+ setError(null);
53
+ try {
54
+ const result = await api.post<{ token: string; url: string }>(`/workspaces/${workspaceId}/invites`, {
55
+ email: email.trim(),
56
+ role,
57
+ });
58
+ setEmail('');
59
+ await loadInvites();
60
+ onInviteCreated?.();
61
+
62
+ // Show the invite URL in a copy dialog
63
+ modal.open({
64
+ title: 'Invite Link Created',
65
+ children: (
66
+ <div className="invite-link-dialog">
67
+ <p>Share this link with <strong>{email}</strong> to invite them as <strong>{role}</strong>:</p>
68
+ <div className="invite-url-box">
69
+ <Link2 size={14} />
70
+ <input
71
+ type="text"
72
+ readOnly
73
+ value={result.url}
74
+ onClick={(e) => (e.target as HTMLInputElement).select()}
75
+ />
76
+ <button
77
+ type="button"
78
+ className="invite-copy-btn"
79
+ onClick={() => {
80
+ navigator.clipboard.writeText(result.url).catch(() => undefined);
81
+ setCopied(result.token);
82
+ setTimeout(() => setCopied(null), 2000);
83
+ }}
84
+ >
85
+ {copied === result.token ? <Check size={14} /> : <Copy size={14} />}
86
+ </button>
87
+ </div>
88
+ <p className="invite-url-note">This link expires in 7 days.</p>
89
+ </div>
90
+ ),
91
+ footer: (
92
+ <Button variant="primary" size="sm" onClick={() => modal.close()}>
93
+ Done
94
+ </Button>
95
+ ),
96
+ });
97
+ } catch (err: unknown) {
98
+ setError((err as Error).message || 'Failed to create invite');
99
+ } finally {
100
+ setCreating(false);
101
+ }
102
+ }
103
+
104
+ async function handleRevoke(token: string) {
105
+ if (!confirm('Revoke this invite?')) return;
106
+ try {
107
+ await api.del(`/workspaces/${workspaceId}/invites/${token}`);
108
+ await loadInvites();
109
+ } catch (err: unknown) {
110
+ setError((err as Error).message || 'Failed to revoke invite');
111
+ }
112
+ }
113
+
114
+ function copyToClipboard(text: string, key: string) {
115
+ navigator.clipboard.writeText(text).catch(() => undefined);
116
+ setCopied(key);
117
+ setTimeout(() => setCopied(null), 2000);
118
+ }
119
+
120
+ const roleColors: Record<string, string> = {
121
+ admin: 'role-admin',
122
+ editor: 'role-editor',
123
+ viewer: 'role-viewer',
124
+ };
125
+
126
+ return (
127
+ <div className="invite-dialog">
128
+ <form className="invite-form" onSubmit={handleCreate}>
129
+ <div className="invite-form-row">
130
+ <div className="invite-form-field">
131
+ <label htmlFor="invite-email">Email address</label>
132
+ <input
133
+ id="invite-email"
134
+ type="email"
135
+ value={email}
136
+ onChange={(e) => setEmail(e.target.value)}
137
+ placeholder="colleague@example.com"
138
+ required
139
+ />
140
+ </div>
141
+ <div className="invite-form-field invite-form-field-role">
142
+ <label htmlFor="invite-role">Role</label>
143
+ <select
144
+ id="invite-role"
145
+ value={role}
146
+ onChange={(e) => setRole(e.target.value as 'admin' | 'editor' | 'viewer')}
147
+ >
148
+ <option value="viewer">Viewer</option>
149
+ <option value="editor">Editor</option>
150
+ <option value="admin">Admin</option>
151
+ </select>
152
+ </div>
153
+ <Button
154
+ type="submit"
155
+ variant="primary"
156
+ size="sm"
157
+ loading={creating}
158
+ disabled={!email.trim()}
159
+ >
160
+ <Mail size={13} />
161
+ Invite
162
+ </Button>
163
+ </div>
164
+ {error && <p className="invite-error">{error}</p>}
165
+ </form>
166
+
167
+ <div className="invite-list">
168
+ <h4 className="invite-list-title">Pending Invites</h4>
169
+ {loading && <p className="invite-loading">Loading...</p>}
170
+ {!loading && invites.length === 0 && (
171
+ <p className="invite-empty">No pending invites</p>
172
+ )}
173
+ {invites.map((inv) => (
174
+ <div key={inv.token} className="invite-item">
175
+ <div className="invite-item-info">
176
+ <span className="invite-item-email">{inv.email}</span>
177
+ <span className={cn('invite-item-role', roleColors[inv.role])}>{inv.role}</span>
178
+ </div>
179
+ <div className="invite-item-actions">
180
+ <button
181
+ type="button"
182
+ className="invite-action-btn"
183
+ title="Copy invite link"
184
+ onClick={() => {
185
+ const url = `${window.location.origin}/accept-invite?token=${inv.token}`;
186
+ copyToClipboard(url, inv.token);
187
+ }}
188
+ >
189
+ {copied === inv.token ? <Check size={13} /> : <Copy size={13} />}
190
+ </button>
191
+ <button
192
+ type="button"
193
+ className="invite-action-btn invite-action-revoke"
194
+ title="Revoke invite"
195
+ onClick={() => handleRevoke(inv.token)}
196
+ >
197
+ <Trash2 size={13} />
198
+ </button>
199
+ </div>
200
+ </div>
201
+ ))}
202
+ </div>
203
+ </div>
204
+ );
205
+ }
@@ -0,0 +1,138 @@
1
+ // src/web/components/ScreenshotCapture.tsx — v5.0.0
2
+ //
3
+ // Uses browser's getDisplayMedia() to capture a screen, window, or browser tab.
4
+ // Returns a blob URL for preview and uploads to /api/ocr/process for text extraction.
5
+
6
+ import { useState, useCallback } from 'react';
7
+ import { Camera, Loader2 } from 'lucide-react';
8
+ import { Button } from './Button';
9
+ import { Card, CardTitle } from './Card';
10
+ import { useToast } from './Toast';
11
+ import { api } from '../lib/api';
12
+
13
+ export type ScreenshotCaptureProps = {
14
+ onTextExtracted?: (text: string, notePath: string) => void;
15
+ };
16
+
17
+ export function ScreenshotCapture({ onTextExtracted }: ScreenshotCaptureProps) {
18
+ const toast = useToast();
19
+ const [imageUrl, setImageUrl] = useState<string | null>(null);
20
+ const [processing, setProcessing] = useState(false);
21
+ const [capturing, setCapturing] = useState(false);
22
+
23
+ const capture = useCallback(async () => {
24
+ setCapturing(true);
25
+ try {
26
+ const stream = await navigator.mediaDevices.getDisplayMedia({
27
+ video: { mediaSource: 'screen' } as MediaTrackConstraints,
28
+ });
29
+ const video = document.createElement('video');
30
+ video.srcObject = stream;
31
+ await video.play();
32
+
33
+ const canvas = document.createElement('canvas');
34
+ canvas.width = video.videoWidth;
35
+ canvas.height = video.videoHeight;
36
+ const ctx = canvas.getContext('2d');
37
+ if (!ctx) {
38
+ toast.error('Failed to get canvas context');
39
+ return;
40
+ }
41
+ ctx.drawImage(video, 0, 0);
42
+
43
+ // Stop all tracks
44
+ stream.getTracks().forEach((t) => t.stop());
45
+ video.remove();
46
+
47
+ canvas.toBlob(async (blob) => {
48
+ if (!blob) {
49
+ toast.error('Failed to capture screenshot');
50
+ return;
51
+ }
52
+ const url = URL.createObjectURL(blob);
53
+ setImageUrl(url);
54
+ await processImage(blob);
55
+ }, 'image/png');
56
+ } catch (err) {
57
+ if ((err as Error).name !== 'NotAllowedError') {
58
+ toast.error(`Capture failed: ${(err as Error).message}`);
59
+ }
60
+ // NotAllowedError = user cancelled the picker — no toast needed
61
+ } finally {
62
+ setCapturing(false);
63
+ }
64
+ }, [toast]);
65
+
66
+ const processImage = useCallback(async (blob: Blob) => {
67
+ setProcessing(true);
68
+ try {
69
+ const reader = new FileReader();
70
+ const base64 = await new Promise<string>((resolve, reject) => {
71
+ reader.onload = () => {
72
+ const result = reader.result as string;
73
+ // Strip data:image/png;base64, prefix for API
74
+ const b64 = result.includes(',') ? result.split(',')[1] : result;
75
+ resolve(b64);
76
+ };
77
+ reader.onerror = () => reject(new Error('FileReader failed'));
78
+ reader.readAsDataURL(blob);
79
+ });
80
+
81
+ const r = await api.post<{ ok: boolean; text: string; notePath: string }>('/ocr/process', {
82
+ image: base64,
83
+ lang: 'eng',
84
+ });
85
+
86
+ if (r.ok) {
87
+ toast.success('Text extracted from screenshot');
88
+ onTextExtracted?.(r.text, r.notePath);
89
+ }
90
+ } catch (err) {
91
+ toast.error(`OCR failed: ${(err as Error).message}`);
92
+ } finally {
93
+ setProcessing(false);
94
+ }
95
+ }, [toast, onTextExtracted]);
96
+
97
+ const dismiss = useCallback(() => {
98
+ setImageUrl(null);
99
+ }, []);
100
+
101
+ return (
102
+ <Card variant="outlined" className="screenshot-capture-card">
103
+ <CardTitle><Camera size={14} /> Screenshot Capture</CardTitle>
104
+
105
+ <div className="screenshot-capture-body">
106
+ {!imageUrl ? (
107
+ <div className="screenshot-capture-empty">
108
+ <Camera size={32} className="muted" />
109
+ <p className="muted text-sm">Capture your screen to extract text via OCR.</p>
110
+ <Button
111
+ variant="primary"
112
+ size="sm"
113
+ onClick={capture}
114
+ disabled={capturing}
115
+ >
116
+ {capturing ? <Loader2 size={12} className="spinner" /> : <Camera size={12} />}
117
+ {capturing ? 'Selecting…' : 'Capture Screen'}
118
+ </Button>
119
+ </div>
120
+ ) : (
121
+ <div className="screenshot-capture-preview">
122
+ <img src={imageUrl} alt="Captured screenshot" className="screenshot-capture-img" />
123
+ <div className="screenshot-capture-actions">
124
+ {processing && (
125
+ <span className="text-sm muted">
126
+ <Loader2 size={12} className="spinner" /> Extracting text…
127
+ </span>
128
+ )}
129
+ <Button variant="ghost" size="sm" onClick={dismiss} disabled={processing}>
130
+ Dismiss
131
+ </Button>
132
+ </div>
133
+ </div>
134
+ )}
135
+ </div>
136
+ </Card>
137
+ );
138
+ }
@@ -0,0 +1,42 @@
1
+ // src/web/components/ScreenshotOCR.tsx — v5.0.0
2
+ //
3
+ // Combines ScreenshotCapture + extracted text display in one panel.
4
+ // Shows the screenshot preview and the OCR-extracted text side by side.
5
+
6
+ import { useState } from 'react';
7
+ import { FileText } from 'lucide-react';
8
+ import { ScreenshotCapture } from './ScreenshotCapture';
9
+ import { Card, CardTitle, CardMeta } from './Card';
10
+
11
+ export function ScreenshotOCR() {
12
+ const [ocrText, setOcrText] = useState<string | null>(null);
13
+ const [notePath, setNotePath] = useState<string | null>(null);
14
+
15
+ const handleTextExtracted = (text: string, path: string) => {
16
+ setOcrText(text);
17
+ setNotePath(path);
18
+ };
19
+
20
+ return (
21
+ <div className="screenshot-ocr-panel">
22
+ <ScreenshotCapture onTextExtracted={handleTextExtracted} />
23
+
24
+ {ocrText !== null && (
25
+ <Card variant="outlined" className="screenshot-ocr-result-card">
26
+ <CardTitle>
27
+ <FileText size={14} /> Extracted Text
28
+ </CardTitle>
29
+ {notePath && <CardMeta>Saved to: {notePath}</CardMeta>}
30
+
31
+ <div className="screenshot-ocr-text">
32
+ {ocrText ? (
33
+ <pre className="screenshot-ocr-pre mono text-sm">{ocrText}</pre>
34
+ ) : (
35
+ <p className="muted text-sm">No text was found in the captured image.</p>
36
+ )}
37
+ </div>
38
+ </Card>
39
+ )}
40
+ </div>
41
+ );
42
+ }
@@ -0,0 +1,182 @@
1
+ // src/web/components/VoiceNotesPanel.tsx — v5.0.0
2
+ //
3
+ // List + record + playback for voice notes.
4
+ // Wire into Memory tab as the "Voice Notes" source panel.
5
+
6
+ import { useEffect, useState } from 'react';
7
+ import { Play, Pause, Trash2, Search as SearchIcon, Mic } from 'lucide-react';
8
+ import { Card, CardTitle, CardMeta } from './Card';
9
+ import { VoiceRecorder } from './VoiceRecorder';
10
+ import { Button } from './Button';
11
+
12
+ type VoiceNote = {
13
+ id: string;
14
+ audioPath: string;
15
+ notePath: string | null;
16
+ transcript: string | null;
17
+ durationSec: number | null;
18
+ vaultPath: string | null;
19
+ createdAtMs: number;
20
+ updatedAtMs: number;
21
+ };
22
+
23
+ type Props = {
24
+ /** Vault path to scope the panel to */
25
+ vaultPath?: string;
26
+ /** Key to force reload when parent refreshes */
27
+ refreshKey?: number;
28
+ };
29
+
30
+ function NoteCard({ note, onDelete }: { note: VoiceNote; onDelete: (id: string) => void }) {
31
+ const [playing, setPlaying] = useState(false);
32
+ const [audio] = useState(() => {
33
+ if (!note.audioPath) return null;
34
+ const a = new Audio(`/api/voice/${note.id}/audio`);
35
+ a.onended = () => setPlaying(false);
36
+ return a;
37
+ });
38
+
39
+ const togglePlay = () => {
40
+ if (!audio) return;
41
+ if (playing) {
42
+ audio.pause();
43
+ audio.currentTime = 0;
44
+ setPlaying(false);
45
+ } else {
46
+ audio.play();
47
+ setPlaying(true);
48
+ }
49
+ };
50
+
51
+ const created = new Date(note.createdAtMs).toLocaleDateString('en-US', {
52
+ month: 'short',
53
+ day: 'numeric',
54
+ hour: '2-digit',
55
+ minute: '2-digit',
56
+ });
57
+
58
+ return (
59
+ <div className="voice-note-card">
60
+ <div className="voice-note-card-head">
61
+ <div className="voice-note-card-meta">
62
+ <span className="voice-note-date">{created}</span>
63
+ {note.durationSec && (
64
+ <span className="voice-note-duration">{Math.round(note.durationSec)}s</span>
65
+ )}
66
+ </div>
67
+ <div className="voice-note-card-actions">
68
+ {audio && (
69
+ <button
70
+ type="button"
71
+ className="icon-btn"
72
+ onClick={togglePlay}
73
+ aria-label={playing ? 'Pause' : 'Play'}
74
+ title={playing ? 'Pause' : 'Play'}
75
+ >
76
+ {playing ? <Pause size={13} /> : <Play size={13} />}
77
+ </button>
78
+ )}
79
+ <button
80
+ type="button"
81
+ className="icon-btn"
82
+ onClick={() => onDelete(note.id)}
83
+ aria-label="Delete note"
84
+ title="Delete note"
85
+ >
86
+ <Trash2 size={13} />
87
+ </button>
88
+ </div>
89
+ </div>
90
+
91
+ {note.transcript && (
92
+ <p className="voice-note-transcript">{note.transcript}</p>
93
+ )}
94
+
95
+ {!note.transcript && (
96
+ <p className="voice-note-no-transcript muted text-sm">No transcription available</p>
97
+ )}
98
+ </div>
99
+ );
100
+ }
101
+
102
+ export function VoiceNotesPanel({ vaultPath, refreshKey = 0 }: Props) {
103
+ const [notes, setNotes] = useState<VoiceNote[]>([]);
104
+ const [search, setSearch] = useState('');
105
+ const [loading, setLoading] = useState(true);
106
+
107
+ const loadNotes = async () => {
108
+ setLoading(true);
109
+ try {
110
+ const vaultParam = vaultPath ? `?vaultPath=${encodeURIComponent(vaultPath)}` : '';
111
+ const r = await fetch(`/api/voice/list${vaultParam}`);
112
+ const data = await r.json();
113
+ setNotes(data.notes || []);
114
+ } catch (err) {
115
+ console.error('[VoiceNotesPanel] failed to load notes:', err);
116
+ } finally {
117
+ setLoading(false);
118
+ }
119
+ };
120
+
121
+ useEffect(() => {
122
+ loadNotes();
123
+ }, [vaultPath, refreshKey]);
124
+
125
+ const handleDelete = async (id: string) => {
126
+ try {
127
+ const r = await fetch(`/api/voice/${id}`, { method: 'DELETE' });
128
+ if (r.ok || r.status === 204) {
129
+ setNotes((prev) => prev.filter((n) => n.id !== id));
130
+ }
131
+ } catch (err) {
132
+ console.error('[VoiceNotesPanel] delete failed:', err);
133
+ }
134
+ };
135
+
136
+ const filtered = notes.filter(
137
+ (n) =>
138
+ !search ||
139
+ (n.transcript?.toLowerCase().includes(search.toLowerCase()) ?? false),
140
+ );
141
+
142
+ return (
143
+ <Card>
144
+ <CardTitle>
145
+ <Mic size={14} /> Voice Notes
146
+ </CardTitle>
147
+ <CardMeta>Record and transcribe audio notes into your vault</CardMeta>
148
+
149
+ {/* Search */}
150
+ <div className="memory-search-row">
151
+ <input
152
+ type="text"
153
+ className="input search-input"
154
+ value={search}
155
+ onChange={(e) => setSearch(e.target.value)}
156
+ placeholder="Search transcripts…"
157
+ aria-label="Search voice notes"
158
+ />
159
+ </div>
160
+
161
+ {/* Recorder */}
162
+ <div className="voice-recorder-row">
163
+ <VoiceRecorder vaultPath={vaultPath} onSaved={loadNotes} />
164
+ </div>
165
+
166
+ {/* Note list */}
167
+ {loading ? (
168
+ <p className="muted text-sm">Loading notes…</p>
169
+ ) : filtered.length === 0 ? (
170
+ <p className="muted text-sm">
171
+ {search ? 'No notes match your search.' : 'No voice notes yet. Hit Record to add one.'}
172
+ </p>
173
+ ) : (
174
+ <div className="voice-note-list">
175
+ {filtered.map((n) => (
176
+ <NoteCard key={n.id} note={n} onDelete={handleDelete} />
177
+ ))}
178
+ </div>
179
+ )}
180
+ </Card>
181
+ );
182
+ }
@@ -0,0 +1,104 @@
1
+ // src/web/components/VoiceRecorder.tsx — v5.0.0
2
+ //
3
+ // MediaRecorder wrapper: records audio in webm/opus, uploads to /api/voice/upload
4
+ // on stop, then calls onSaved(notePath, transcription) so the parent can refresh
5
+ // the note list.
6
+
7
+ import { useState, useRef } from 'react';
8
+ import { Mic, Square, Loader2 } from 'lucide-react';
9
+ import { Button } from './Button';
10
+
11
+ export type VoiceRecorderProps = {
12
+ /** Vault path to store the transcript under */
13
+ vaultPath?: string;
14
+ /** Called after the note is saved with the note path and transcription text */
15
+ onSaved?: (notePath: string, transcription: string | null) => void;
16
+ };
17
+
18
+ export function VoiceRecorder({ vaultPath, onSaved }: VoiceRecorderProps) {
19
+ const [recording, setRecording] = useState(false);
20
+ const [transcribing, setTranscribing] = useState(false);
21
+ const mediaRecorderRef = useRef<MediaRecorder | null>(null);
22
+ const chunksRef = useRef<Blob[]>([]);
23
+
24
+ const startRecording = async () => {
25
+ try {
26
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
27
+ // Prefer webm/opus; fall back to whatever the browser offers
28
+ const mimeType =
29
+ MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
30
+ ? 'audio/webm;codecs=opus'
31
+ : 'audio/webm';
32
+ const recorder = new MediaRecorder(stream, { mimeType });
33
+
34
+ recorder.ondataavailable = (e) => {
35
+ if (e.data && e.data.size > 0) {
36
+ chunksRef.current.push(e.data);
37
+ }
38
+ };
39
+
40
+ recorder.onstop = async () => {
41
+ const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
42
+ chunksRef.current = [];
43
+ // Stop all tracks to release the microphone
44
+ stream.getTracks().forEach((t) => t.stop());
45
+ await uploadAndTranscribe(blob);
46
+ };
47
+
48
+ recorder.start(1000); // timeslice=1s for chunk delivery
49
+ mediaRecorderRef.current = recorder;
50
+ setRecording(true);
51
+ } catch (err) {
52
+ // getUserMedia not available or permission denied
53
+ console.error('[VoiceRecorder] failed to start recording:', err);
54
+ }
55
+ };
56
+
57
+ const stopRecording = () => {
58
+ const rec = mediaRecorderRef.current;
59
+ if (!rec) return;
60
+ rec.stop();
61
+ setRecording(false);
62
+ setTranscribing(true);
63
+ };
64
+
65
+ const uploadAndTranscribe = async (blob: Blob) => {
66
+ try {
67
+ const form = new FormData();
68
+ form.append('audio', blob, 'voice-note.webm');
69
+ form.append('vaultPath', vaultPath || '');
70
+ const res = await fetch('/api/voice/upload', { method: 'POST', body: form });
71
+ if (!res.ok) {
72
+ console.error('[VoiceRecorder] upload failed:', res.status, await res.text());
73
+ setTranscribing(false);
74
+ return;
75
+ }
76
+ const { notePath, transcription } = await res.json();
77
+ onSaved?.(notePath, transcription);
78
+ } catch (err) {
79
+ console.error('[VoiceRecorder] upload error:', err);
80
+ } finally {
81
+ setTranscribing(false);
82
+ }
83
+ };
84
+
85
+ return (
86
+ <div className="voice-recorder">
87
+ {!recording && !transcribing && (
88
+ <Button variant="accent" size="sm" onClick={startRecording} aria-label="Start recording">
89
+ <Mic size={14} /> Record
90
+ </Button>
91
+ )}
92
+ {recording && (
93
+ <Button variant="danger" size="sm" onClick={stopRecording} aria-label="Stop recording">
94
+ <Square size={14} /> Stop
95
+ </Button>
96
+ )}
97
+ {transcribing && (
98
+ <Button variant="secondary" size="sm" disabled>
99
+ <Loader2 size={14} className="spinning" /> Transcribing…
100
+ </Button>
101
+ )}
102
+ </div>
103
+ );
104
+ }