@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.
- package/bizar-dash/dist/assets/icons-CFqu2M-c.js +656 -0
- package/bizar-dash/dist/assets/icons-CFqu2M-c.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-Dl8yY5_H.js +16 -0
- package/bizar-dash/dist/assets/main-Dl8yY5_H.js.map +1 -0
- package/bizar-dash/dist/assets/{main-DfmIfOUS.css → main-ZAfGKENE.css} +1 -1
- 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-C2gysFOZ.js +2 -0
- package/bizar-dash/dist/assets/mobile-C2gysFOZ.js.map +1 -0
- package/bizar-dash/dist/assets/mobile-DHXXbn1A.js +1 -0
- package/bizar-dash/dist/assets/{mobile-CL5uUQEC.js.map → mobile-DHXXbn1A.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/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/skills/eval/SKILL.md +237 -0
- package/bizar-dash/src/server/api.mjs +28 -0
- package/bizar-dash/src/server/auth.mjs +155 -1
- package/bizar-dash/src/server/eval-store.mjs +226 -0
- package/bizar-dash/src/server/eval.mjs +347 -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/routes/clipboard.mjs +173 -0
- package/bizar-dash/src/server/routes/eval.mjs +147 -0
- package/bizar-dash/src/server/routes/ocr.mjs +182 -0
- package/bizar-dash/src/server/routes/plugins.mjs +220 -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/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/components/InviteDialog.tsx +205 -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/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/styles/memory.css +82 -0
- package/bizar-dash/src/web/views/Memory.tsx +17 -2
- package/bizar-dash/src/web/views/Settings.tsx +3 -0
- package/bizar-dash/src/web/views/Workspace.tsx +294 -0
- 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/WorkspacesSection.tsx +189 -0
- package/bizar-dash/tests/bundle-analysis.test.mjs +5 -2
- package/bizar-dash/tests/clipboard.test.mjs +147 -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/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/ocr.test.mjs +87 -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/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/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/package.json +12 -8
- 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/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/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
|
@@ -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
|
+
}
|