autopreso 0.1.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/LICENSE +21 -0
- package/README.md +147 -0
- package/package.json +59 -0
- package/public/app.js +1232 -0
- package/public/index.html +28 -0
- package/public/starter-elements.js +37 -0
- package/public/style.css +535 -0
- package/public/transcript-panel.js +10 -0
- package/src/agent-provider.js +106 -0
- package/src/cli-options.js +29 -0
- package/src/cli.js +96 -0
- package/src/codex-auth.js +135 -0
- package/src/moonshine-transcription.js +146 -0
- package/src/openai-transcription.js +186 -0
- package/src/server.js +996 -0
- package/src/settings-store.js +137 -0
- package/src/simulator-agent-provider.js +24 -0
- package/src/simulator-options.js +76 -0
- package/src/transcript-chunker.js +22 -0
- package/src/transcript-turn-queue.js +78 -0
- package/src/whiteboard-elements.js +74 -0
- package/src/whiteboard-session.js +235 -0
- package/src/whiteboard-tools.js +48 -0
package/public/app.js
ADDED
|
@@ -0,0 +1,1232 @@
|
|
|
1
|
+
import { Excalidraw, convertToExcalidrawElements, exportToBlob } from "@excalidraw/excalidraw";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
|
|
5
|
+
import { STARTER_ELEMENTS } from "./starter-elements.js";
|
|
6
|
+
|
|
7
|
+
const SAMPLE_RATE = 24000;
|
|
8
|
+
const REASONING_EFFORTS = ["none", "low", "medium", "high", "xhigh"];
|
|
9
|
+
const OPENAI_AGENT_MODELS = ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"];
|
|
10
|
+
const CODEX_AGENT_MODELS = ["gpt-5.5", "gpt-5.5-fast", "gpt-5.4"];
|
|
11
|
+
const OPENAI_TRANSCRIPTION_MODELS = ["gpt-realtime-whisper", "gpt-4o-transcribe", "gpt-4o-mini-transcribe", "whisper-1"];
|
|
12
|
+
const MOONSHINE_MODELS = ["tiny", "small", "medium"];
|
|
13
|
+
const MIC_STORAGE_KEY = "autopreso.mic";
|
|
14
|
+
|
|
15
|
+
const STARTER_STAGING_ELEMENTS = [];
|
|
16
|
+
// 1x1 transparent PNG used when the staging area is empty.
|
|
17
|
+
const PLACEHOLDER_IMAGE = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
|
|
18
|
+
|
|
19
|
+
function loadStoredMic() {
|
|
20
|
+
try {
|
|
21
|
+
const raw = localStorage.getItem(MIC_STORAGE_KEY);
|
|
22
|
+
return raw ? JSON.parse(raw) : { deviceId: "", label: "" };
|
|
23
|
+
} catch {
|
|
24
|
+
return { deviceId: "", label: "" };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function saveStoredMic(mic) {
|
|
29
|
+
localStorage.setItem(MIC_STORAGE_KEY, JSON.stringify(mic));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function App() {
|
|
33
|
+
const [api, setApi] = React.useState(null);
|
|
34
|
+
const [mode, setMode] = React.useState("staging");
|
|
35
|
+
const [listening, setListening] = React.useState(false);
|
|
36
|
+
const [starting, setStarting] = React.useState(false);
|
|
37
|
+
const [presoStarting, setPresoStarting] = React.useState(false);
|
|
38
|
+
const [agentStatus, setAgentStatus] = React.useState("idle");
|
|
39
|
+
const [transcriptionEngine, setTranscriptionEngine] = React.useState("loading");
|
|
40
|
+
const [settings, setSettings] = React.useState(null);
|
|
41
|
+
const [captionText, setCaptionText] = React.useState("");
|
|
42
|
+
const [error, setError] = React.useState("");
|
|
43
|
+
const [micError, setMicError] = React.useState(false);
|
|
44
|
+
const [agentError, setAgentError] = React.useState(false);
|
|
45
|
+
const [sttError, setSttError] = React.useState(false);
|
|
46
|
+
const [expandedRow, setExpandedRow] = React.useState(null);
|
|
47
|
+
const [mic, setMic] = React.useState(loadStoredMic);
|
|
48
|
+
const [analyser, setAnalyser] = React.useState(null);
|
|
49
|
+
const [resetConfirming, setResetConfirming] = React.useState(false);
|
|
50
|
+
const [resetting, setResetting] = React.useState(false);
|
|
51
|
+
// warmupState: { state: "idle"|"running"|"confirmed"|"exhausted"|"cancelled", attempt, maxAttempts }
|
|
52
|
+
const [warmupState, setWarmupState] = React.useState({ state: "idle", attempt: 0, maxAttempts: 8 });
|
|
53
|
+
const audioSessionRef = React.useRef(null);
|
|
54
|
+
const apiRef = React.useRef(null);
|
|
55
|
+
const wsRef = React.useRef(null);
|
|
56
|
+
const modeRef = React.useRef("staging");
|
|
57
|
+
const stagingSceneRef = React.useRef(null);
|
|
58
|
+
const screenshotTimerRef = React.useRef(null);
|
|
59
|
+
const captionTimerRef = React.useRef(null);
|
|
60
|
+
const resetConfirmTimerRef = React.useRef(null);
|
|
61
|
+
const canvasWrapRef = React.useRef(null);
|
|
62
|
+
const shellRef = React.useRef(null);
|
|
63
|
+
const userElementsSyncTimerRef = React.useRef(null);
|
|
64
|
+
const lastSyncedElementsHashRef = React.useRef("");
|
|
65
|
+
const listeningRef = React.useRef(false);
|
|
66
|
+
|
|
67
|
+
React.useEffect(() => { listeningRef.current = listening; }, [listening]);
|
|
68
|
+
const [isFullscreen, setIsFullscreen] = React.useState(false);
|
|
69
|
+
|
|
70
|
+
React.useEffect(() => {
|
|
71
|
+
const handler = () => setIsFullscreen(!!document.fullscreenElement);
|
|
72
|
+
document.addEventListener("fullscreenchange", handler);
|
|
73
|
+
return () => document.removeEventListener("fullscreenchange", handler);
|
|
74
|
+
}, []);
|
|
75
|
+
|
|
76
|
+
function toggleFullscreen() {
|
|
77
|
+
if (document.fullscreenElement) {
|
|
78
|
+
document.exitFullscreen?.();
|
|
79
|
+
} else {
|
|
80
|
+
shellRef.current?.requestFullscreen?.();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
React.useEffect(() => {
|
|
85
|
+
apiRef.current = api;
|
|
86
|
+
}, [api]);
|
|
87
|
+
|
|
88
|
+
React.useEffect(() => {
|
|
89
|
+
return () => {
|
|
90
|
+
clearTimeout(screenshotTimerRef.current);
|
|
91
|
+
clearTimeout(captionTimerRef.current);
|
|
92
|
+
clearTimeout(resetConfirmTimerRef.current);
|
|
93
|
+
clearTimeout(userElementsSyncTimerRef.current);
|
|
94
|
+
};
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
function handleExcalidrawChange(elements) {
|
|
98
|
+
// Only push user edits to the server while in live mode. In staging the
|
|
99
|
+
// canvas is a client-side scratchpad; the server doesn't need to know.
|
|
100
|
+
if (modeRef.current !== "live") return;
|
|
101
|
+
// Once listening starts, the agent owns the canvas. Echoing user-elements
|
|
102
|
+
// back creates an ID-rotation feedback loop: applyScene re-runs
|
|
103
|
+
// convertToExcalidrawElements, which assigns fresh IDs, which propagate
|
|
104
|
+
// back via onChange, which break the agent's cache prefix and confuse
|
|
105
|
+
// line-numbered references. Sync only during the pre-listen window.
|
|
106
|
+
if (listeningRef.current) return;
|
|
107
|
+
clearTimeout(userElementsSyncTimerRef.current);
|
|
108
|
+
userElementsSyncTimerRef.current = setTimeout(() => {
|
|
109
|
+
const ws = wsRef.current;
|
|
110
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
111
|
+
const cleaned = nativeElementsToSkeletonForSync(elements ?? []);
|
|
112
|
+
const hash = JSON.stringify(cleaned);
|
|
113
|
+
if (hash === lastSyncedElementsHashRef.current) return;
|
|
114
|
+
lastSyncedElementsHashRef.current = hash;
|
|
115
|
+
ws.send(JSON.stringify({ type: "whiteboard:user-elements", elements: cleaned }));
|
|
116
|
+
}, 500);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Persistent WebSocket connection for the lifetime of the app.
|
|
120
|
+
React.useEffect(() => {
|
|
121
|
+
const proto = location.protocol === "https:" ? "wss" : "ws";
|
|
122
|
+
const ws = new WebSocket(`${proto}://${location.host}/ws`);
|
|
123
|
+
wsRef.current = ws;
|
|
124
|
+
|
|
125
|
+
ws.addEventListener("message", (event) => {
|
|
126
|
+
const message = JSON.parse(event.data);
|
|
127
|
+
if (message.type === "config") setTranscriptionEngine(message.transcriptionEngine);
|
|
128
|
+
if (message.type === "settings") setSettings(message.settings);
|
|
129
|
+
if (message.type === "transcript:partial") {
|
|
130
|
+
const text = (message.text ?? "").trim();
|
|
131
|
+
if (text) {
|
|
132
|
+
clearTimeout(captionTimerRef.current);
|
|
133
|
+
setCaptionText(text);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (message.type === "transcript:committed") {
|
|
137
|
+
const text = (message.text ?? "").trim();
|
|
138
|
+
if (text) {
|
|
139
|
+
clearTimeout(captionTimerRef.current);
|
|
140
|
+
setCaptionText(text);
|
|
141
|
+
captionTimerRef.current = setTimeout(() => setCaptionText(""), 3500);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (message.type === "agent:status") {
|
|
145
|
+
setAgentStatus(message.status);
|
|
146
|
+
if (message.status === "thinking") setAgentError(false);
|
|
147
|
+
}
|
|
148
|
+
if (message.type === "warmup") {
|
|
149
|
+
setWarmupState({
|
|
150
|
+
state: message.state,
|
|
151
|
+
attempt: message.attempt ?? 0,
|
|
152
|
+
maxAttempts: message.maxAttempts ?? 8,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
if (message.type === "mode") {
|
|
156
|
+
const previousMode = modeRef.current;
|
|
157
|
+
modeRef.current = message.mode;
|
|
158
|
+
setMode(message.mode);
|
|
159
|
+
if (message.mode === "staging" && previousMode === "live") {
|
|
160
|
+
// Returning from live: restore the staged canvas the user was last working on.
|
|
161
|
+
applyScene(stagingSceneRef.current, { recenter: true });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (message.type === "whiteboard:update") {
|
|
165
|
+
// Recenter when the live canvas resets to a fresh starter (Start preso, Reset session).
|
|
166
|
+
const isFreshStarter = Array.isArray(message.elements) && message.elements.length <= STARTER_ELEMENTS.length + 1;
|
|
167
|
+
applyScene(message.elements, { recenter: isFreshStarter });
|
|
168
|
+
}
|
|
169
|
+
if (message.type === "whiteboard:viewport") applyWhiteboardViewportCommand(message);
|
|
170
|
+
if (message.type === "error") {
|
|
171
|
+
setError(message.message);
|
|
172
|
+
if (/agent/i.test(message.message)) setAgentError(true);
|
|
173
|
+
else setSttError(true);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
ws.addEventListener("close", () => {
|
|
178
|
+
setListening(false);
|
|
179
|
+
setStarting(false);
|
|
180
|
+
setAgentStatus("idle");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
ws.addEventListener("error", () => {
|
|
184
|
+
setError("Lost connection to the server.");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
return () => {
|
|
188
|
+
ws.close();
|
|
189
|
+
wsRef.current = null;
|
|
190
|
+
};
|
|
191
|
+
}, []);
|
|
192
|
+
|
|
193
|
+
// Seed the staging scene ref and the initial canvas once Excalidraw is ready.
|
|
194
|
+
React.useEffect(() => {
|
|
195
|
+
if (!api) return;
|
|
196
|
+
if (!stagingSceneRef.current) {
|
|
197
|
+
stagingSceneRef.current = convertToExcalidrawElements(STARTER_STAGING_ELEMENTS, { regenerateIds: false });
|
|
198
|
+
}
|
|
199
|
+
let cancelled = false;
|
|
200
|
+
const refresh = () => {
|
|
201
|
+
if (cancelled) return;
|
|
202
|
+
if (modeRef.current === "staging") applyScene(stagingSceneRef.current, { recenter: true });
|
|
203
|
+
};
|
|
204
|
+
const timer = setTimeout(refresh, 750);
|
|
205
|
+
document.fonts?.ready.then(refresh).catch(() => {});
|
|
206
|
+
return () => {
|
|
207
|
+
cancelled = true;
|
|
208
|
+
clearTimeout(timer);
|
|
209
|
+
};
|
|
210
|
+
}, [api]);
|
|
211
|
+
|
|
212
|
+
React.useEffect(() => {
|
|
213
|
+
fetch("/api/config")
|
|
214
|
+
.then((res) => res.json())
|
|
215
|
+
.then((config) => {
|
|
216
|
+
setTranscriptionEngine(config.transcriptionEngine);
|
|
217
|
+
if (config.settings) setSettings(config.settings);
|
|
218
|
+
})
|
|
219
|
+
.catch((err) => setError(err.message));
|
|
220
|
+
}, []);
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
async function saveSettings(patch) {
|
|
224
|
+
setError("");
|
|
225
|
+
const res = await fetch("/api/settings", {
|
|
226
|
+
method: "PUT",
|
|
227
|
+
headers: { "content-type": "application/json" },
|
|
228
|
+
body: JSON.stringify(patch),
|
|
229
|
+
});
|
|
230
|
+
const body = await res.json();
|
|
231
|
+
if (!res.ok) throw new Error(body.error || "Failed to save settings");
|
|
232
|
+
setSettings(body.settings);
|
|
233
|
+
setTranscriptionEngine(body.transcriptionEngine);
|
|
234
|
+
setSttError(false);
|
|
235
|
+
setAgentError(false);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function cancelWarmup() {
|
|
239
|
+
const ws = wsRef.current;
|
|
240
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
241
|
+
ws.send(JSON.stringify({ type: "warmup:cancel" }));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function startAnyway() {
|
|
246
|
+
// One-click: cancel the warmup loop and start listening right away. The
|
|
247
|
+
// first turn may be slower (cold cache), but the user explicitly opted in.
|
|
248
|
+
await cancelWarmup();
|
|
249
|
+
await startListening();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function startListening() {
|
|
253
|
+
if (listening || starting) return;
|
|
254
|
+
if (modeRef.current !== "live") return;
|
|
255
|
+
const ws = wsRef.current;
|
|
256
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
257
|
+
setError("Connection not ready yet.");
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
setError("");
|
|
261
|
+
setMicError(false);
|
|
262
|
+
setSttError(false);
|
|
263
|
+
setStarting(true);
|
|
264
|
+
|
|
265
|
+
let media = null;
|
|
266
|
+
let audio = null;
|
|
267
|
+
try {
|
|
268
|
+
const audioConstraints = {
|
|
269
|
+
echoCancellation: true,
|
|
270
|
+
noiseSuppression: true,
|
|
271
|
+
autoGainControl: true,
|
|
272
|
+
};
|
|
273
|
+
if (mic.deviceId) audioConstraints.deviceId = { exact: mic.deviceId };
|
|
274
|
+
media = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints });
|
|
275
|
+
|
|
276
|
+
audio = await createAudioStreamer(media, (audioBase64) => {
|
|
277
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
278
|
+
ws.send(JSON.stringify({ type: "audio", audio: audioBase64 }));
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
setAnalyser(audio.analyser);
|
|
282
|
+
audioSessionRef.current = { media, audio };
|
|
283
|
+
setListening(true);
|
|
284
|
+
setStarting(false);
|
|
285
|
+
} catch (err) {
|
|
286
|
+
setError(err.message);
|
|
287
|
+
setMicError(true);
|
|
288
|
+
setStarting(false);
|
|
289
|
+
media?.getTracks().forEach((track) => track.stop());
|
|
290
|
+
await audio?.close();
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function stopListening() {
|
|
295
|
+
const session = audioSessionRef.current;
|
|
296
|
+
audioSessionRef.current = null;
|
|
297
|
+
if (!session) return;
|
|
298
|
+
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
299
|
+
wsRef.current.send(JSON.stringify({ type: "stop" }));
|
|
300
|
+
}
|
|
301
|
+
session.media.getTracks().forEach((track) => track.stop());
|
|
302
|
+
await session.audio.close();
|
|
303
|
+
setAnalyser(null);
|
|
304
|
+
setListening(false);
|
|
305
|
+
setCaptionText("");
|
|
306
|
+
clearTimeout(captionTimerRef.current);
|
|
307
|
+
setAgentStatus("idle");
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function toggleListening() {
|
|
311
|
+
if (listening) stopListening();
|
|
312
|
+
else startListening();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function startPreso() {
|
|
316
|
+
if (presoStarting) return;
|
|
317
|
+
const excalidrawAPI = apiRef.current;
|
|
318
|
+
if (!excalidrawAPI) {
|
|
319
|
+
setError("Canvas isn't ready yet.");
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
setError("");
|
|
323
|
+
setPresoStarting(true);
|
|
324
|
+
try {
|
|
325
|
+
// Snapshot what the user has on the staging canvas right now.
|
|
326
|
+
const stagingNative = excalidrawAPI.getSceneElements().map((el) => ({ ...el }));
|
|
327
|
+
stagingSceneRef.current = stagingNative;
|
|
328
|
+
// Convert to the lean skeleton format before sending to the server. The
|
|
329
|
+
// primer JSON is part of the cached prefix, so trimming volatile fields
|
|
330
|
+
// (versionNonce, seed, internal binding details, etc.) shrinks the cold
|
|
331
|
+
// turn footprint substantially without hurting the agent's understanding
|
|
332
|
+
// of the staging layout.
|
|
333
|
+
const stagingSkeleton = nativeElementsToSkeletonForSync(stagingNative);
|
|
334
|
+
// Capture the full staging scene as an image so the primer carries it.
|
|
335
|
+
let stagingScreenshot;
|
|
336
|
+
try {
|
|
337
|
+
stagingScreenshot = await captureStagingSceneAsImage(excalidrawAPI, stagingNative);
|
|
338
|
+
} catch (err) {
|
|
339
|
+
console.warn("Failed to capture staging screenshot, sending text-only primer:", err);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const res = await fetch("/api/preso/start", {
|
|
343
|
+
method: "POST",
|
|
344
|
+
headers: { "content-type": "application/json" },
|
|
345
|
+
body: JSON.stringify({ stagingElements: stagingSkeleton, stagingScreenshot }),
|
|
346
|
+
});
|
|
347
|
+
if (!res.ok) {
|
|
348
|
+
const body = await res.json().catch(() => ({}));
|
|
349
|
+
throw new Error(body.error || `Start preso failed (${res.status})`);
|
|
350
|
+
}
|
|
351
|
+
// Server broadcasts mode=live and whiteboard:update; the WS handler swaps the canvas.
|
|
352
|
+
} catch (err) {
|
|
353
|
+
setError(err.message);
|
|
354
|
+
} finally {
|
|
355
|
+
setPresoStarting(false);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function backToStaging() {
|
|
360
|
+
setError("");
|
|
361
|
+
if (listening) await stopListening();
|
|
362
|
+
try {
|
|
363
|
+
const res = await fetch("/api/preso/back-to-staging", { method: "POST" });
|
|
364
|
+
if (!res.ok) throw new Error(`Back to staging failed (${res.status})`);
|
|
365
|
+
// Server broadcasts mode=staging; the WS handler restores the staged scene.
|
|
366
|
+
} catch (err) {
|
|
367
|
+
setError(err.message);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function handleResetClick() {
|
|
372
|
+
if (resetting) return;
|
|
373
|
+
if (!resetConfirming) {
|
|
374
|
+
setResetConfirming(true);
|
|
375
|
+
resetConfirmTimerRef.current = setTimeout(() => setResetConfirming(false), 3000);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
clearTimeout(resetConfirmTimerRef.current);
|
|
379
|
+
setResetConfirming(false);
|
|
380
|
+
resetSession();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function resetSession() {
|
|
384
|
+
setResetting(true);
|
|
385
|
+
setError("");
|
|
386
|
+
try {
|
|
387
|
+
if (modeRef.current === "staging") {
|
|
388
|
+
// Staging board lives on the client - just reload the starter content.
|
|
389
|
+
const fresh = convertToExcalidrawElements(STARTER_STAGING_ELEMENTS, { regenerateIds: false });
|
|
390
|
+
stagingSceneRef.current = fresh;
|
|
391
|
+
applyScene(fresh);
|
|
392
|
+
} else {
|
|
393
|
+
if (listening) await stopListening();
|
|
394
|
+
clearTimeout(captionTimerRef.current);
|
|
395
|
+
setCaptionText("");
|
|
396
|
+
const res = await fetch("/api/session/reset", { method: "POST" });
|
|
397
|
+
if (!res.ok) throw new Error(`Reset failed (${res.status})`);
|
|
398
|
+
}
|
|
399
|
+
} catch (err) {
|
|
400
|
+
setError(err.message);
|
|
401
|
+
} finally {
|
|
402
|
+
setResetting(false);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function applyScene(elements, { recenter = false } = {}) {
|
|
407
|
+
const excalidrawAPI = apiRef.current;
|
|
408
|
+
if (!excalidrawAPI || !Array.isArray(elements)) return;
|
|
409
|
+
const looksNative = elements.length > 0 && elements[0] && typeof elements[0].versionNonce === "number";
|
|
410
|
+
// CRITICAL: regenerateIds: false. Excalidraw's default is to throw away
|
|
411
|
+
// user-provided ids and assign fresh nanoids. The agent references its
|
|
412
|
+
// elements by stable ids (e.g. "openai-card") in whiteboard_viewport's
|
|
413
|
+
// focus_ids; if we let Excalidraw rewrite them, the frontend's
|
|
414
|
+
// scene.filter(el => focusIds.includes(el.id)) finds nothing and
|
|
415
|
+
// scrollToContent silently fits the full canvas instead.
|
|
416
|
+
const renderable = looksNative ? elements : convertToExcalidrawElements(elements, { regenerateIds: false });
|
|
417
|
+
excalidrawAPI.updateScene({
|
|
418
|
+
elements: renderable,
|
|
419
|
+
appState: { viewBackgroundColor: "#fffdf8" },
|
|
420
|
+
});
|
|
421
|
+
if (recenter && renderable.length > 0) {
|
|
422
|
+
// Defer so updateScene's commit is flushed before scrollToContent measures bounds.
|
|
423
|
+
requestAnimationFrame(() => excalidrawAPI.scrollToContent(undefined, { animate: false }));
|
|
424
|
+
}
|
|
425
|
+
scheduleWhiteboardScreenshot();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function applyWhiteboardViewportCommand(command) {
|
|
429
|
+
const excalidrawAPI = apiRef.current;
|
|
430
|
+
if (!excalidrawAPI) return;
|
|
431
|
+
|
|
432
|
+
const action = command.action;
|
|
433
|
+
if (action === "scroll_to_content") {
|
|
434
|
+
const focusIds = Array.isArray(command.focus_ids) ? command.focus_ids : null;
|
|
435
|
+
let target;
|
|
436
|
+
if (focusIds && focusIds.length > 0) {
|
|
437
|
+
const scene = excalidrawAPI.getSceneElements();
|
|
438
|
+
const matched = scene.filter((el) => focusIds.includes(el.id));
|
|
439
|
+
if (matched.length > 0) target = matched;
|
|
440
|
+
}
|
|
441
|
+
excalidrawAPI.scrollToContent(target, { animate: true });
|
|
442
|
+
}
|
|
443
|
+
if (action === "set_zoom") {
|
|
444
|
+
setWhiteboardZoom(command.zoom);
|
|
445
|
+
}
|
|
446
|
+
if (action === "zoom_in") {
|
|
447
|
+
setWhiteboardZoom(currentWhiteboardZoom() * 1.2);
|
|
448
|
+
}
|
|
449
|
+
if (action === "zoom_out") {
|
|
450
|
+
setWhiteboardZoom(currentWhiteboardZoom() / 1.2);
|
|
451
|
+
}
|
|
452
|
+
if (action === "reset_zoom") {
|
|
453
|
+
setWhiteboardZoom(1);
|
|
454
|
+
}
|
|
455
|
+
scheduleWhiteboardScreenshot();
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function currentWhiteboardZoom() {
|
|
459
|
+
return apiRef.current?.getAppState().zoom?.value ?? 1;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function setWhiteboardZoom(zoom) {
|
|
463
|
+
const zoomValue = Math.min(3, Math.max(0.1, Number(zoom) || 1));
|
|
464
|
+
apiRef.current?.updateScene({ appState: { zoom: { value: zoomValue } } });
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function scheduleWhiteboardScreenshot() {
|
|
468
|
+
clearTimeout(screenshotTimerRef.current);
|
|
469
|
+
screenshotTimerRef.current = setTimeout(sendWhiteboardScreenshot, 500);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function sendWhiteboardScreenshot() {
|
|
473
|
+
if (modeRef.current !== "live") return;
|
|
474
|
+
const ws = wsRef.current;
|
|
475
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
476
|
+
try {
|
|
477
|
+
const dataUrl = await captureCanvasDataUrl();
|
|
478
|
+
if (!dataUrl) return;
|
|
479
|
+
ws.send(JSON.stringify({ type: "whiteboard:screenshot", image: dataUrl }));
|
|
480
|
+
} catch (error) {
|
|
481
|
+
console.warn("Failed to export whiteboard screenshot:", error);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function captureCanvasDataUrl() {
|
|
486
|
+
const canvas = document.querySelector("canvas.excalidraw__canvas.static");
|
|
487
|
+
if (!canvas) return null;
|
|
488
|
+
const blob = await canvasToBlob(canvas);
|
|
489
|
+
return await blobToDataUrl(blob);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function captureStagingSceneAsImage(excalidrawAPI, elements) {
|
|
493
|
+
if (!Array.isArray(elements) || elements.length === 0) {
|
|
494
|
+
// Empty staging - no scene to render. Use a 1x1 placeholder so the agent
|
|
495
|
+
// still gets a valid image part in the primer.
|
|
496
|
+
return PLACEHOLDER_IMAGE;
|
|
497
|
+
}
|
|
498
|
+
try {
|
|
499
|
+
const appState = excalidrawAPI.getAppState();
|
|
500
|
+
const files = excalidrawAPI.getFiles?.() ?? {};
|
|
501
|
+
const blob = await exportToBlob({
|
|
502
|
+
elements,
|
|
503
|
+
appState: { ...appState, exportBackground: true, viewBackgroundColor: "#fffdf8" },
|
|
504
|
+
files,
|
|
505
|
+
mimeType: "image/png",
|
|
506
|
+
});
|
|
507
|
+
return await blobToDataUrl(blob);
|
|
508
|
+
} catch (error) {
|
|
509
|
+
console.warn("Failed to export staging scene, falling back to viewport canvas:", error);
|
|
510
|
+
return captureCanvasDataUrl();
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const isLive = mode === "live";
|
|
515
|
+
const micState = micError ? "error" : listening ? "active" : "idle";
|
|
516
|
+
const agentState = agentError ? "error" : agentStatus === "thinking" ? "active" : "idle";
|
|
517
|
+
const sttState = sttError ? "error" : listening ? "active" : "idle";
|
|
518
|
+
const agentLabel = settings ? agentModelLabel(settings) : "loading...";
|
|
519
|
+
const sttLabel = settings ? sttModelLabel(settings) : transcriptionEngine;
|
|
520
|
+
const micLabel = mic.label || "System default";
|
|
521
|
+
|
|
522
|
+
return React.createElement(
|
|
523
|
+
"main",
|
|
524
|
+
{ className: `shell mode-${mode}`, ref: shellRef },
|
|
525
|
+
React.createElement(
|
|
526
|
+
"section",
|
|
527
|
+
{ className: "canvas-wrap", ref: canvasWrapRef },
|
|
528
|
+
React.createElement(Excalidraw, {
|
|
529
|
+
excalidrawAPI: setApi,
|
|
530
|
+
initialData: {
|
|
531
|
+
elements: convertToExcalidrawElements(STARTER_STAGING_ELEMENTS, { regenerateIds: false }),
|
|
532
|
+
appState: { viewBackgroundColor: "#fffdf8" },
|
|
533
|
+
},
|
|
534
|
+
onChange: handleExcalidrawChange,
|
|
535
|
+
}),
|
|
536
|
+
React.createElement(
|
|
537
|
+
"div",
|
|
538
|
+
{ className: `stage-overlay ${(captionText || listening) && isLive ? "visible" : ""}`, "aria-hidden": "true" },
|
|
539
|
+
captionText
|
|
540
|
+
? React.createElement(
|
|
541
|
+
"div",
|
|
542
|
+
{ className: "caption-pill", role: "status", "aria-live": "polite" },
|
|
543
|
+
truncateCaption(captionText),
|
|
544
|
+
)
|
|
545
|
+
: null,
|
|
546
|
+
React.createElement(Waveform, { analyser, active: listening }),
|
|
547
|
+
),
|
|
548
|
+
),
|
|
549
|
+
React.createElement(
|
|
550
|
+
"aside",
|
|
551
|
+
{ className: "panel" },
|
|
552
|
+
isLive
|
|
553
|
+
? React.createElement(
|
|
554
|
+
"button",
|
|
555
|
+
{
|
|
556
|
+
className: "fullscreen-toggle",
|
|
557
|
+
onClick: toggleFullscreen,
|
|
558
|
+
title: isFullscreen ? "Exit fullscreen (Esc)" : "Fullscreen for screen sharing",
|
|
559
|
+
"aria-label": isFullscreen ? "Exit fullscreen" : "Enter fullscreen",
|
|
560
|
+
},
|
|
561
|
+
isFullscreen ? "⤓" : "⤢",
|
|
562
|
+
)
|
|
563
|
+
: null,
|
|
564
|
+
React.createElement(
|
|
565
|
+
"div",
|
|
566
|
+
{ className: "brand" },
|
|
567
|
+
React.createElement(
|
|
568
|
+
"div",
|
|
569
|
+
{ className: "brand-row" },
|
|
570
|
+
React.createElement("h1", null, "Auto Preso"),
|
|
571
|
+
React.createElement(
|
|
572
|
+
"div",
|
|
573
|
+
{
|
|
574
|
+
className: `mode-toggle mode-toggle-${mode}`,
|
|
575
|
+
role: "group",
|
|
576
|
+
"aria-label": "Mode",
|
|
577
|
+
},
|
|
578
|
+
React.createElement(
|
|
579
|
+
"button",
|
|
580
|
+
{
|
|
581
|
+
type: "button",
|
|
582
|
+
className: `mode-toggle-option ${mode === "staging" ? "active" : ""}`,
|
|
583
|
+
onClick: () => { if (mode !== "staging") backToStaging(); },
|
|
584
|
+
disabled: presoStarting,
|
|
585
|
+
title: "Staging mode",
|
|
586
|
+
"aria-pressed": mode === "staging",
|
|
587
|
+
},
|
|
588
|
+
"Staging",
|
|
589
|
+
),
|
|
590
|
+
React.createElement(
|
|
591
|
+
"button",
|
|
592
|
+
{
|
|
593
|
+
type: "button",
|
|
594
|
+
className: `mode-toggle-option ${mode === "live" ? "active" : ""}`,
|
|
595
|
+
onClick: () => { if (mode !== "live") startPreso(); },
|
|
596
|
+
disabled: presoStarting,
|
|
597
|
+
title: presoStarting ? "Starting..." : "Preso mode",
|
|
598
|
+
"aria-pressed": mode === "live",
|
|
599
|
+
},
|
|
600
|
+
presoStarting && mode === "staging" ? "..." : "Preso",
|
|
601
|
+
),
|
|
602
|
+
),
|
|
603
|
+
),
|
|
604
|
+
React.createElement(
|
|
605
|
+
"p",
|
|
606
|
+
null,
|
|
607
|
+
mode === "staging"
|
|
608
|
+
? "Drop reference material on the canvas, then start the preso."
|
|
609
|
+
: "Talk through an idea and let the whiteboard keep up.",
|
|
610
|
+
),
|
|
611
|
+
),
|
|
612
|
+
React.createElement(
|
|
613
|
+
"div",
|
|
614
|
+
{ className: "controls" },
|
|
615
|
+
mode === "staging"
|
|
616
|
+
? React.createElement(
|
|
617
|
+
"button",
|
|
618
|
+
{
|
|
619
|
+
className: "start-preso",
|
|
620
|
+
onClick: startPreso,
|
|
621
|
+
disabled: presoStarting,
|
|
622
|
+
},
|
|
623
|
+
presoStarting ? "Starting..." : "Start preso →",
|
|
624
|
+
)
|
|
625
|
+
: null,
|
|
626
|
+
isLive
|
|
627
|
+
? React.createElement(
|
|
628
|
+
"div",
|
|
629
|
+
{ className: "listen-controls" },
|
|
630
|
+
React.createElement(
|
|
631
|
+
"button",
|
|
632
|
+
{
|
|
633
|
+
className: `record-toggle ${listening ? "recording" : ""}`,
|
|
634
|
+
onClick: toggleListening,
|
|
635
|
+
disabled: starting || (warmupState.state === "running" && !listening),
|
|
636
|
+
title: warmupState.state === "running"
|
|
637
|
+
? "Waiting for prompt cache to warm up"
|
|
638
|
+
: warmupState.state === "exhausted"
|
|
639
|
+
? "Cache didn't fully prime; first turn may be slower"
|
|
640
|
+
: undefined,
|
|
641
|
+
},
|
|
642
|
+
React.createElement("span", { className: "record-icon" }, listening ? "■" : "●"),
|
|
643
|
+
" ",
|
|
644
|
+
listening
|
|
645
|
+
? "Stop"
|
|
646
|
+
: starting
|
|
647
|
+
? "Starting..."
|
|
648
|
+
: warmupState.state === "running"
|
|
649
|
+
? `Warming up... (${warmupState.attempt} / ${warmupState.maxAttempts})`
|
|
650
|
+
: "Start talking",
|
|
651
|
+
),
|
|
652
|
+
warmupState.state === "running" && !listening
|
|
653
|
+
? React.createElement(
|
|
654
|
+
"button",
|
|
655
|
+
{
|
|
656
|
+
className: "warmup-skip",
|
|
657
|
+
onClick: startAnyway,
|
|
658
|
+
title: "Skip warmup and start listening now. The first turn may be slower.",
|
|
659
|
+
},
|
|
660
|
+
"Start anyway →",
|
|
661
|
+
)
|
|
662
|
+
: null,
|
|
663
|
+
warmupState.state === "exhausted" && !listening
|
|
664
|
+
? React.createElement(
|
|
665
|
+
"div",
|
|
666
|
+
{ className: "warmup-warning" },
|
|
667
|
+
"Cache didn't fully prime. First turn may be slower.",
|
|
668
|
+
)
|
|
669
|
+
: null,
|
|
670
|
+
)
|
|
671
|
+
: null,
|
|
672
|
+
React.createElement(
|
|
673
|
+
"button",
|
|
674
|
+
{
|
|
675
|
+
className: `reset-session ${resetConfirming ? "confirming" : ""}`,
|
|
676
|
+
onClick: handleResetClick,
|
|
677
|
+
disabled: resetting,
|
|
678
|
+
title: mode === "staging"
|
|
679
|
+
? "Clear the staging area"
|
|
680
|
+
: "Clear the whiteboard and start a new session",
|
|
681
|
+
},
|
|
682
|
+
resetting ? "Resetting..." : resetConfirming
|
|
683
|
+
? "Click again to reset"
|
|
684
|
+
: mode === "staging" ? "Reset staging" : "Reset session",
|
|
685
|
+
),
|
|
686
|
+
),
|
|
687
|
+
React.createElement(
|
|
688
|
+
"div",
|
|
689
|
+
{ className: "status-card" },
|
|
690
|
+
statusRow({
|
|
691
|
+
dotState: micState,
|
|
692
|
+
label: "Mic",
|
|
693
|
+
value: micLabel,
|
|
694
|
+
expanded: expandedRow === "mic",
|
|
695
|
+
onToggle: () => setExpandedRow(expandedRow === "mic" ? null : "mic"),
|
|
696
|
+
editor: React.createElement(MicEditor, {
|
|
697
|
+
currentDeviceId: mic.deviceId,
|
|
698
|
+
onSave: (next) => {
|
|
699
|
+
setMic(next);
|
|
700
|
+
saveStoredMic(next);
|
|
701
|
+
setExpandedRow(null);
|
|
702
|
+
},
|
|
703
|
+
onCancel: () => setExpandedRow(null),
|
|
704
|
+
}),
|
|
705
|
+
}),
|
|
706
|
+
statusRow({
|
|
707
|
+
dotState: sttState,
|
|
708
|
+
label: "Voice",
|
|
709
|
+
value: sttLabel,
|
|
710
|
+
expanded: expandedRow === "stt",
|
|
711
|
+
onToggle: () => setExpandedRow(expandedRow === "stt" ? null : "stt"),
|
|
712
|
+
editor: settings ? React.createElement(TranscriptionEditor, {
|
|
713
|
+
settings,
|
|
714
|
+
onSave: async (patch) => {
|
|
715
|
+
await saveSettings(patch);
|
|
716
|
+
setExpandedRow(null);
|
|
717
|
+
},
|
|
718
|
+
onCancel: () => setExpandedRow(null),
|
|
719
|
+
}) : null,
|
|
720
|
+
}),
|
|
721
|
+
statusRow({
|
|
722
|
+
dotState: agentState,
|
|
723
|
+
label: "Agent",
|
|
724
|
+
value: agentLabel,
|
|
725
|
+
expanded: expandedRow === "agent",
|
|
726
|
+
onToggle: () => setExpandedRow(expandedRow === "agent" ? null : "agent"),
|
|
727
|
+
editor: settings ? React.createElement(AgentEditor, {
|
|
728
|
+
settings,
|
|
729
|
+
onSave: async (patch) => {
|
|
730
|
+
await saveSettings(patch);
|
|
731
|
+
setExpandedRow(null);
|
|
732
|
+
},
|
|
733
|
+
onCancel: () => setExpandedRow(null),
|
|
734
|
+
}) : null,
|
|
735
|
+
}),
|
|
736
|
+
),
|
|
737
|
+
error ? React.createElement("div", { className: "error" }, error) : null,
|
|
738
|
+
),
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const CAPTION_MAX_CHARS = 70;
|
|
743
|
+
|
|
744
|
+
function truncateCaption(text) {
|
|
745
|
+
if (!text || text.length <= CAPTION_MAX_CHARS) return text;
|
|
746
|
+
const tail = text.slice(-CAPTION_MAX_CHARS);
|
|
747
|
+
const space = tail.indexOf(" ");
|
|
748
|
+
return space >= 0 && space < tail.length - 1 ? tail.slice(space + 1) : tail;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function Waveform({ analyser, active }) {
|
|
752
|
+
const canvasRef = React.useRef(null);
|
|
753
|
+
|
|
754
|
+
React.useEffect(() => {
|
|
755
|
+
const canvas = canvasRef.current;
|
|
756
|
+
if (!canvas) return;
|
|
757
|
+
const ctx = canvas.getContext("2d");
|
|
758
|
+
const dpr = window.devicePixelRatio || 1;
|
|
759
|
+
|
|
760
|
+
let raf = 0;
|
|
761
|
+
let resizeObserver;
|
|
762
|
+
let lastWidth = 0;
|
|
763
|
+
let lastHeight = 0;
|
|
764
|
+
|
|
765
|
+
const resize = () => {
|
|
766
|
+
const rect = canvas.getBoundingClientRect();
|
|
767
|
+
const width = Math.max(1, Math.floor(rect.width));
|
|
768
|
+
const height = Math.max(1, Math.floor(rect.height));
|
|
769
|
+
if (width === lastWidth && height === lastHeight) return;
|
|
770
|
+
lastWidth = width;
|
|
771
|
+
lastHeight = height;
|
|
772
|
+
canvas.width = width * dpr;
|
|
773
|
+
canvas.height = height * dpr;
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
777
|
+
resizeObserver = new ResizeObserver(resize);
|
|
778
|
+
resizeObserver.observe(canvas);
|
|
779
|
+
}
|
|
780
|
+
resize();
|
|
781
|
+
|
|
782
|
+
if (!analyser || !active) {
|
|
783
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
784
|
+
return () => {
|
|
785
|
+
if (resizeObserver) resizeObserver.disconnect();
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
analyser.fftSize = 1024;
|
|
790
|
+
analyser.smoothingTimeConstant = 0.85;
|
|
791
|
+
const data = new Uint8Array(analyser.fftSize);
|
|
792
|
+
|
|
793
|
+
const draw = () => {
|
|
794
|
+
analyser.getByteTimeDomainData(data);
|
|
795
|
+
const w = canvas.width;
|
|
796
|
+
const h = canvas.height;
|
|
797
|
+
ctx.clearRect(0, 0, w, h);
|
|
798
|
+
|
|
799
|
+
const mid = h / 2;
|
|
800
|
+
const amplitude = mid * 0.85;
|
|
801
|
+
|
|
802
|
+
const gradient = ctx.createLinearGradient(0, 0, w, 0);
|
|
803
|
+
gradient.addColorStop(0, "rgba(56, 189, 248, 0)");
|
|
804
|
+
gradient.addColorStop(0.15, "rgba(56, 189, 248, 0.95)");
|
|
805
|
+
gradient.addColorStop(0.5, "rgba(168, 85, 247, 0.95)");
|
|
806
|
+
gradient.addColorStop(0.85, "rgba(56, 189, 248, 0.95)");
|
|
807
|
+
gradient.addColorStop(1, "rgba(56, 189, 248, 0)");
|
|
808
|
+
|
|
809
|
+
ctx.shadowColor = "rgba(56, 189, 248, 0.55)";
|
|
810
|
+
ctx.shadowBlur = 22 * dpr;
|
|
811
|
+
ctx.lineWidth = 2.4 * dpr;
|
|
812
|
+
ctx.lineCap = "round";
|
|
813
|
+
ctx.lineJoin = "round";
|
|
814
|
+
ctx.strokeStyle = gradient;
|
|
815
|
+
|
|
816
|
+
ctx.beginPath();
|
|
817
|
+
const step = w / data.length;
|
|
818
|
+
for (let i = 0; i < data.length; i += 1) {
|
|
819
|
+
const v = (data[i] - 128) / 128;
|
|
820
|
+
const x = i * step;
|
|
821
|
+
const y = mid + v * amplitude;
|
|
822
|
+
if (i === 0) ctx.moveTo(x, y);
|
|
823
|
+
else ctx.lineTo(x, y);
|
|
824
|
+
}
|
|
825
|
+
ctx.stroke();
|
|
826
|
+
|
|
827
|
+
raf = requestAnimationFrame(draw);
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
raf = requestAnimationFrame(draw);
|
|
831
|
+
|
|
832
|
+
return () => {
|
|
833
|
+
cancelAnimationFrame(raf);
|
|
834
|
+
if (resizeObserver) resizeObserver.disconnect();
|
|
835
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
836
|
+
};
|
|
837
|
+
}, [analyser, active]);
|
|
838
|
+
|
|
839
|
+
return React.createElement("canvas", { ref: canvasRef, className: "waveform-canvas" });
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function statusRow({ dotState, label, value, expanded = false, onToggle, editor }) {
|
|
843
|
+
const clickable = Boolean(onToggle);
|
|
844
|
+
return React.createElement(
|
|
845
|
+
"div",
|
|
846
|
+
{ className: `status-row-wrap ${expanded ? "expanded" : ""}` },
|
|
847
|
+
React.createElement(
|
|
848
|
+
"div",
|
|
849
|
+
{
|
|
850
|
+
className: `status-row ${clickable ? "clickable" : ""} ${expanded ? "open" : ""}`,
|
|
851
|
+
onClick: clickable ? onToggle : undefined,
|
|
852
|
+
role: clickable ? "button" : undefined,
|
|
853
|
+
tabIndex: clickable ? 0 : undefined,
|
|
854
|
+
onKeyDown: clickable ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onToggle(); } } : undefined,
|
|
855
|
+
},
|
|
856
|
+
React.createElement("span", { className: `dot ${dotState}`, "aria-hidden": "true" }),
|
|
857
|
+
React.createElement("span", { className: "label" }, label),
|
|
858
|
+
React.createElement("span", { className: "value", title: typeof value === "string" ? value : undefined }, value),
|
|
859
|
+
clickable ? React.createElement("span", { className: "chevron", "aria-hidden": "true" }, "›") : null,
|
|
860
|
+
),
|
|
861
|
+
expanded && editor ? React.createElement("div", { className: "editor" }, editor) : null,
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function agentModelLabel(settings) {
|
|
866
|
+
const provider = settings.agent.provider;
|
|
867
|
+
if (provider === "ollama") return settings.agent.ollama.model || "(unset)";
|
|
868
|
+
if (provider === "codex") return settings.agent.codex.model;
|
|
869
|
+
return settings.agent.openai.model;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function sttModelLabel(settings) {
|
|
873
|
+
if (settings.transcription.provider === "moonshine") return settings.transcription.moonshine.model;
|
|
874
|
+
return settings.transcription.openai.model;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function MicEditor({ currentDeviceId, onSave, onCancel }) {
|
|
878
|
+
const [devices, setDevices] = React.useState([]);
|
|
879
|
+
const [selected, setSelected] = React.useState(currentDeviceId);
|
|
880
|
+
const [needsPermission, setNeedsPermission] = React.useState(false);
|
|
881
|
+
const [busy, setBusy] = React.useState(false);
|
|
882
|
+
const [errorText, setErrorText] = React.useState("");
|
|
883
|
+
|
|
884
|
+
React.useEffect(() => {
|
|
885
|
+
let cancelled = false;
|
|
886
|
+
(async () => {
|
|
887
|
+
try {
|
|
888
|
+
const list = await navigator.mediaDevices.enumerateDevices();
|
|
889
|
+
const inputs = list.filter((d) => d.kind === "audioinput");
|
|
890
|
+
if (cancelled) return;
|
|
891
|
+
setDevices(inputs);
|
|
892
|
+
setNeedsPermission(inputs.length > 0 && inputs.every((d) => !d.label));
|
|
893
|
+
} catch (err) {
|
|
894
|
+
if (!cancelled) setErrorText(err.message);
|
|
895
|
+
}
|
|
896
|
+
})();
|
|
897
|
+
return () => { cancelled = true; };
|
|
898
|
+
}, []);
|
|
899
|
+
|
|
900
|
+
async function grantPermission() {
|
|
901
|
+
setBusy(true);
|
|
902
|
+
setErrorText("");
|
|
903
|
+
try {
|
|
904
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
905
|
+
stream.getTracks().forEach((track) => track.stop());
|
|
906
|
+
const list = await navigator.mediaDevices.enumerateDevices();
|
|
907
|
+
const inputs = list.filter((d) => d.kind === "audioinput");
|
|
908
|
+
setDevices(inputs);
|
|
909
|
+
setNeedsPermission(false);
|
|
910
|
+
} catch (err) {
|
|
911
|
+
setErrorText(err.message);
|
|
912
|
+
} finally {
|
|
913
|
+
setBusy(false);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function submit() {
|
|
918
|
+
const device = devices.find((d) => d.deviceId === selected);
|
|
919
|
+
onSave({ deviceId: selected || "", label: device?.label || "" });
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
return React.createElement(
|
|
923
|
+
"div",
|
|
924
|
+
{ className: "editor-grid" },
|
|
925
|
+
needsPermission ? React.createElement(
|
|
926
|
+
"div",
|
|
927
|
+
{ className: "editor-hint" },
|
|
928
|
+
"Grant microphone access to see device names.",
|
|
929
|
+
React.createElement("button", { className: "secondary", onClick: grantPermission, disabled: busy, style: { marginLeft: "8px" } }, busy ? "..." : "Grant"),
|
|
930
|
+
) : null,
|
|
931
|
+
field("Device", React.createElement(
|
|
932
|
+
"select",
|
|
933
|
+
{ value: selected, onChange: (e) => setSelected(e.target.value), disabled: busy },
|
|
934
|
+
React.createElement("option", { value: "" }, "System default"),
|
|
935
|
+
devices.map((d) => React.createElement(
|
|
936
|
+
"option",
|
|
937
|
+
{ key: d.deviceId, value: d.deviceId },
|
|
938
|
+
d.label || `Device ${d.deviceId.slice(0, 8)}`,
|
|
939
|
+
)),
|
|
940
|
+
)),
|
|
941
|
+
errorText ? React.createElement("div", { className: "editor-error" }, errorText) : null,
|
|
942
|
+
React.createElement(
|
|
943
|
+
"div",
|
|
944
|
+
{ className: "editor-actions" },
|
|
945
|
+
React.createElement("button", { className: "secondary", onClick: onCancel, disabled: busy }, "Cancel"),
|
|
946
|
+
React.createElement("button", { onClick: submit, disabled: busy }, "Save"),
|
|
947
|
+
),
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function AgentEditor({ settings, onSave, onCancel }) {
|
|
952
|
+
const [provider, setProvider] = React.useState(settings.agent.provider);
|
|
953
|
+
const [openaiModel, setOpenaiModel] = React.useState(settings.agent.openai.model);
|
|
954
|
+
const [reasoningEffort, setReasoningEffort] = React.useState(settings.agent.openai.reasoningEffort);
|
|
955
|
+
const [codexModel, setCodexModel] = React.useState(settings.agent.codex.model);
|
|
956
|
+
const [ollamaModel, setOllamaModel] = React.useState(settings.agent.ollama.model);
|
|
957
|
+
const [ollamaBaseURL, setOllamaBaseURL] = React.useState(settings.agent.ollama.baseURL);
|
|
958
|
+
const [openaiKey, setOpenaiKey] = React.useState("");
|
|
959
|
+
const [busy, setBusy] = React.useState(false);
|
|
960
|
+
const [errorText, setErrorText] = React.useState("");
|
|
961
|
+
|
|
962
|
+
const needsOpenAIKey = provider === "openai" && !settings.hasOpenAIKey && !openaiKey;
|
|
963
|
+
|
|
964
|
+
async function submit() {
|
|
965
|
+
setBusy(true);
|
|
966
|
+
setErrorText("");
|
|
967
|
+
const patch = { agent: { provider, openai: {}, codex: {}, ollama: {} } };
|
|
968
|
+
if (provider === "openai") {
|
|
969
|
+
patch.agent.openai.model = openaiModel;
|
|
970
|
+
patch.agent.openai.reasoningEffort = reasoningEffort;
|
|
971
|
+
} else if (provider === "codex") {
|
|
972
|
+
patch.agent.codex.model = codexModel;
|
|
973
|
+
} else {
|
|
974
|
+
patch.agent.ollama.model = ollamaModel;
|
|
975
|
+
patch.agent.ollama.baseURL = ollamaBaseURL;
|
|
976
|
+
}
|
|
977
|
+
if (openaiKey) patch.apiKeys = { openai: openaiKey };
|
|
978
|
+
try {
|
|
979
|
+
await onSave(patch);
|
|
980
|
+
} catch (error) {
|
|
981
|
+
setErrorText(error.message);
|
|
982
|
+
setBusy(false);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
return React.createElement(
|
|
987
|
+
"div",
|
|
988
|
+
{ className: "editor-grid" },
|
|
989
|
+
field("Provider", React.createElement(
|
|
990
|
+
"select",
|
|
991
|
+
{ value: provider, onChange: (e) => setProvider(e.target.value), disabled: busy },
|
|
992
|
+
React.createElement("option", { value: "openai" }, "OpenAI"),
|
|
993
|
+
React.createElement("option", { value: "codex" }, "Codex"),
|
|
994
|
+
React.createElement("option", { value: "ollama" }, "Ollama"),
|
|
995
|
+
)),
|
|
996
|
+
provider === "openai" ? field("Model", select(openaiModel, setOpenaiModel, OPENAI_AGENT_MODELS, busy)) : null,
|
|
997
|
+
provider === "openai" ? field("Reasoning", select(reasoningEffort, setReasoningEffort, REASONING_EFFORTS, busy)) : null,
|
|
998
|
+
provider === "codex" ? field("Model", select(codexModel, setCodexModel, CODEX_AGENT_MODELS, busy)) : null,
|
|
999
|
+
provider === "ollama" ? field("Model", React.createElement("input", {
|
|
1000
|
+
type: "text", value: ollamaModel, onChange: (e) => setOllamaModel(e.target.value), placeholder: "e.g. llama3.2", disabled: busy,
|
|
1001
|
+
})) : null,
|
|
1002
|
+
provider === "ollama" ? field("Base URL", React.createElement("input", {
|
|
1003
|
+
type: "text", value: ollamaBaseURL, onChange: (e) => setOllamaBaseURL(e.target.value), disabled: busy,
|
|
1004
|
+
})) : null,
|
|
1005
|
+
needsOpenAIKey ? field("OpenAI key", React.createElement("input", {
|
|
1006
|
+
type: "password", value: openaiKey, onChange: (e) => setOpenaiKey(e.target.value), placeholder: "sk-...", disabled: busy,
|
|
1007
|
+
})) : null,
|
|
1008
|
+
provider === "openai" && settings.hasOpenAIKey ? field("OpenAI key", React.createElement("input", {
|
|
1009
|
+
type: "password", value: openaiKey, onChange: (e) => setOpenaiKey(e.target.value), placeholder: "configured (enter to replace)", disabled: busy,
|
|
1010
|
+
})) : null,
|
|
1011
|
+
errorText ? React.createElement("div", { className: "editor-error" }, errorText) : null,
|
|
1012
|
+
React.createElement(
|
|
1013
|
+
"div",
|
|
1014
|
+
{ className: "editor-actions" },
|
|
1015
|
+
React.createElement("button", { className: "secondary", onClick: onCancel, disabled: busy }, "Cancel"),
|
|
1016
|
+
React.createElement("button", { onClick: submit, disabled: busy || needsOpenAIKey }, busy ? "Saving..." : "Save"),
|
|
1017
|
+
),
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
function TranscriptionEditor({ settings, onSave, onCancel }) {
|
|
1022
|
+
const [provider, setProvider] = React.useState(settings.transcription.provider);
|
|
1023
|
+
const [moonshineModel, setMoonshineModel] = React.useState(settings.transcription.moonshine.model);
|
|
1024
|
+
const [openaiModel, setOpenaiModel] = React.useState(settings.transcription.openai.model);
|
|
1025
|
+
const [openaiKey, setOpenaiKey] = React.useState("");
|
|
1026
|
+
const [busy, setBusy] = React.useState(false);
|
|
1027
|
+
const [errorText, setErrorText] = React.useState("");
|
|
1028
|
+
|
|
1029
|
+
const needsOpenAIKey = provider === "openai" && !settings.hasOpenAIKey && !openaiKey;
|
|
1030
|
+
|
|
1031
|
+
async function submit() {
|
|
1032
|
+
setBusy(true);
|
|
1033
|
+
setErrorText("");
|
|
1034
|
+
const patch = { transcription: { provider, moonshine: {}, openai: {} } };
|
|
1035
|
+
if (provider === "moonshine") patch.transcription.moonshine.model = moonshineModel;
|
|
1036
|
+
if (provider === "openai") patch.transcription.openai.model = openaiModel;
|
|
1037
|
+
if (openaiKey) patch.apiKeys = { openai: openaiKey };
|
|
1038
|
+
try {
|
|
1039
|
+
await onSave(patch);
|
|
1040
|
+
} catch (error) {
|
|
1041
|
+
setErrorText(error.message);
|
|
1042
|
+
setBusy(false);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
return React.createElement(
|
|
1047
|
+
"div",
|
|
1048
|
+
{ className: "editor-grid" },
|
|
1049
|
+
field("Provider", React.createElement(
|
|
1050
|
+
"select",
|
|
1051
|
+
{ value: provider, onChange: (e) => setProvider(e.target.value), disabled: busy },
|
|
1052
|
+
React.createElement("option", { value: "moonshine" }, "Moonshine (local)"),
|
|
1053
|
+
React.createElement("option", { value: "openai" }, "OpenAI Realtime"),
|
|
1054
|
+
)),
|
|
1055
|
+
provider === "moonshine" ? field("Model", select(moonshineModel, setMoonshineModel, MOONSHINE_MODELS, busy)) : null,
|
|
1056
|
+
provider === "openai" ? field("Model", select(openaiModel, setOpenaiModel, OPENAI_TRANSCRIPTION_MODELS, busy)) : null,
|
|
1057
|
+
needsOpenAIKey ? field("OpenAI key", React.createElement("input", {
|
|
1058
|
+
type: "password", value: openaiKey, onChange: (e) => setOpenaiKey(e.target.value), placeholder: "sk-...", disabled: busy,
|
|
1059
|
+
})) : null,
|
|
1060
|
+
provider === "openai" && settings.hasOpenAIKey ? field("OpenAI key", React.createElement("input", {
|
|
1061
|
+
type: "password", value: openaiKey, onChange: (e) => setOpenaiKey(e.target.value), placeholder: "configured (enter to replace)", disabled: busy,
|
|
1062
|
+
})) : null,
|
|
1063
|
+
errorText ? React.createElement("div", { className: "editor-error" }, errorText) : null,
|
|
1064
|
+
React.createElement(
|
|
1065
|
+
"div",
|
|
1066
|
+
{ className: "editor-actions" },
|
|
1067
|
+
React.createElement("button", { className: "secondary", onClick: onCancel, disabled: busy }, "Cancel"),
|
|
1068
|
+
React.createElement("button", { onClick: submit, disabled: busy || needsOpenAIKey }, busy ? "Saving..." : "Save"),
|
|
1069
|
+
),
|
|
1070
|
+
);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function field(label, control) {
|
|
1074
|
+
return React.createElement(
|
|
1075
|
+
"label",
|
|
1076
|
+
{ className: "field" },
|
|
1077
|
+
React.createElement("span", { className: "field-label" }, label),
|
|
1078
|
+
control,
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function select(value, onChange, options, disabled) {
|
|
1083
|
+
return React.createElement(
|
|
1084
|
+
"select",
|
|
1085
|
+
{ value, onChange: (e) => onChange(e.target.value), disabled },
|
|
1086
|
+
options.map((option) => React.createElement("option", { key: option, value: option }, option)),
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
async function createAudioStreamer(media, onChunk) {
|
|
1091
|
+
const context = new AudioContext();
|
|
1092
|
+
const source = context.createMediaStreamSource(media);
|
|
1093
|
+
const processor = context.createScriptProcessor(4096, 1, 1);
|
|
1094
|
+
const analyser = context.createAnalyser();
|
|
1095
|
+
analyser.fftSize = 1024;
|
|
1096
|
+
analyser.smoothingTimeConstant = 0.85;
|
|
1097
|
+
let carry = new Float32Array(0);
|
|
1098
|
+
|
|
1099
|
+
processor.onaudioprocess = (event) => {
|
|
1100
|
+
const input = event.inputBuffer.getChannelData(0);
|
|
1101
|
+
const resampled = resample(input, context.sampleRate, SAMPLE_RATE, carry);
|
|
1102
|
+
carry = resampled.carry;
|
|
1103
|
+
if (resampled.samples.length > 0) {
|
|
1104
|
+
onChunk(pcm16ToBase64(resampled.samples));
|
|
1105
|
+
}
|
|
1106
|
+
};
|
|
1107
|
+
|
|
1108
|
+
source.connect(analyser);
|
|
1109
|
+
source.connect(processor);
|
|
1110
|
+
processor.connect(context.destination);
|
|
1111
|
+
|
|
1112
|
+
return {
|
|
1113
|
+
analyser,
|
|
1114
|
+
close: async () => {
|
|
1115
|
+
processor.disconnect();
|
|
1116
|
+
source.disconnect();
|
|
1117
|
+
analyser.disconnect();
|
|
1118
|
+
await context.close();
|
|
1119
|
+
},
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
function resample(input, fromRate, toRate, carry) {
|
|
1124
|
+
const merged = new Float32Array(carry.length + input.length);
|
|
1125
|
+
merged.set(carry);
|
|
1126
|
+
merged.set(input, carry.length);
|
|
1127
|
+
|
|
1128
|
+
const ratio = fromRate / toRate;
|
|
1129
|
+
const outputLength = Math.floor((merged.length - 1) / ratio);
|
|
1130
|
+
const output = new Float32Array(outputLength);
|
|
1131
|
+
|
|
1132
|
+
for (let index = 0; index < outputLength; index += 1) {
|
|
1133
|
+
const sourceIndex = index * ratio;
|
|
1134
|
+
const left = Math.floor(sourceIndex);
|
|
1135
|
+
const right = Math.min(left + 1, merged.length - 1);
|
|
1136
|
+
const weight = sourceIndex - left;
|
|
1137
|
+
output[index] = merged[left] * (1 - weight) + merged[right] * weight;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
const consumed = Math.floor(outputLength * ratio);
|
|
1141
|
+
return { samples: output, carry: merged.slice(consumed) };
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function pcm16ToBase64(samples) {
|
|
1145
|
+
const pcm = new Int16Array(samples.length);
|
|
1146
|
+
for (let index = 0; index < samples.length; index += 1) {
|
|
1147
|
+
const sample = Math.max(-1, Math.min(1, samples[index]));
|
|
1148
|
+
pcm[index] = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
|
|
1149
|
+
}
|
|
1150
|
+
const bytes = new Uint8Array(pcm.buffer);
|
|
1151
|
+
let binary = "";
|
|
1152
|
+
for (let index = 0; index < bytes.length; index += 1) {
|
|
1153
|
+
binary += String.fromCharCode(bytes[index]);
|
|
1154
|
+
}
|
|
1155
|
+
return btoa(binary);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// Convert Excalidraw native elements back into the simple "skeleton" shape the
|
|
1159
|
+
// server stores. Critical: when the agent emits {rectangle, label: "X"},
|
|
1160
|
+
// convertToExcalidrawElements expands that into a rectangle PLUS a separate
|
|
1161
|
+
// bound text element. If we echo both back to the server verbatim, the server's
|
|
1162
|
+
// state.elements doubles up - on the next agent turn the rectangle has lost its
|
|
1163
|
+
// label, the agent re-adds it, Excalidraw creates ANOTHER bound text, and now
|
|
1164
|
+
// the canvas renders the same label twice. Folding bound text back into the
|
|
1165
|
+
// shape's label field on the way out keeps state.elements in the canonical form
|
|
1166
|
+
// the agent expects.
|
|
1167
|
+
function nativeElementsToSkeletonForSync(nativeElements) {
|
|
1168
|
+
const elements = nativeElements.filter((el) => el && !el.isDeleted);
|
|
1169
|
+
const byId = new Map(elements.map((el) => [el.id, el]));
|
|
1170
|
+
const consumedTextIds = new Set();
|
|
1171
|
+
const result = [];
|
|
1172
|
+
|
|
1173
|
+
for (const el of elements) {
|
|
1174
|
+
// Bound text whose parent shape is in the scene: skip - it'll be folded
|
|
1175
|
+
// into the parent's label below.
|
|
1176
|
+
if (el.type === "text" && el.containerId && byId.has(el.containerId)) {
|
|
1177
|
+
consumedTextIds.add(el.id);
|
|
1178
|
+
continue;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
const boundElements = Array.isArray(el.boundElements) ? el.boundElements : null;
|
|
1182
|
+
const textBinding = boundElements && boundElements.find((b) => b?.type === "text");
|
|
1183
|
+
const labelText = textBinding && byId.get(textBinding.id);
|
|
1184
|
+
|
|
1185
|
+
if (labelText) {
|
|
1186
|
+
consumedTextIds.add(labelText.id);
|
|
1187
|
+
result.push({
|
|
1188
|
+
...stripInternalFields(el),
|
|
1189
|
+
label: {
|
|
1190
|
+
text: labelText.text ?? "",
|
|
1191
|
+
fontSize: labelText.fontSize ?? 18,
|
|
1192
|
+
},
|
|
1193
|
+
});
|
|
1194
|
+
continue;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
result.push(stripInternalFields(el));
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
return result.filter((el) => !consumedTextIds.has(el.id));
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function stripInternalFields(el) {
|
|
1204
|
+
// Drop Excalidraw fields that change on every render (cache thrash) or that
|
|
1205
|
+
// we don't want the agent reasoning about (locking, grouping, etc.).
|
|
1206
|
+
const {
|
|
1207
|
+
versionNonce, version, updated, seed, index, link, locked, customData,
|
|
1208
|
+
frameId, groupIds, boundElements, containerId, isDeleted,
|
|
1209
|
+
...rest
|
|
1210
|
+
} = el;
|
|
1211
|
+
return rest;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
function blobToDataUrl(blob) {
|
|
1215
|
+
return new Promise((resolve, reject) => {
|
|
1216
|
+
const reader = new FileReader();
|
|
1217
|
+
reader.onload = () => resolve(reader.result);
|
|
1218
|
+
reader.onerror = () => reject(reader.error);
|
|
1219
|
+
reader.readAsDataURL(blob);
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
function canvasToBlob(canvas) {
|
|
1224
|
+
return new Promise((resolve, reject) => {
|
|
1225
|
+
canvas.toBlob((blob) => {
|
|
1226
|
+
if (blob) resolve(blob);
|
|
1227
|
+
else reject(new Error("Canvas screenshot export failed."));
|
|
1228
|
+
}, "image/png");
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
createRoot(document.getElementById("app")).render(React.createElement(App));
|