cursor-buddy 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +314 -0
- package/dist/client/index.d.mts +151 -0
- package/dist/client/index.d.mts.map +1 -0
- package/dist/client/index.mjs +1123 -0
- package/dist/client/index.mjs.map +1 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +1 -0
- package/dist/server/adapters/next.d.mts +22 -0
- package/dist/server/adapters/next.d.mts.map +1 -0
- package/dist/server/adapters/next.mjs +24 -0
- package/dist/server/adapters/next.mjs.map +1 -0
- package/dist/server/index.d.mts +34 -0
- package/dist/server/index.d.mts.map +1 -0
- package/dist/server/index.mjs +163 -0
- package/dist/server/index.mjs.map +1 -0
- package/dist/types-B2GUdTzP.d.mts +37 -0
- package/dist/types-B2GUdTzP.d.mts.map +1 -0
- package/dist/types-b2KrNyuu.d.mts +59 -0
- package/dist/types-b2KrNyuu.d.mts.map +1 -0
- package/package.json +104 -0
|
@@ -0,0 +1,1123 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { useActor } from "@xstate/react";
|
|
4
|
+
import { useStore } from "@nanostores/react";
|
|
5
|
+
import { assign, setup } from "xstate";
|
|
6
|
+
import { atom } from "nanostores";
|
|
7
|
+
import html2canvas from "html2canvas-pro";
|
|
8
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
9
|
+
import { createPortal } from "react-dom";
|
|
10
|
+
//#region src/client/context.ts
|
|
11
|
+
const CursorBuddyContext = createContext(null);
|
|
12
|
+
//#endregion
|
|
13
|
+
//#region src/core/machine.ts
|
|
14
|
+
/**
|
|
15
|
+
* XState machine for the voice interaction flow.
|
|
16
|
+
*
|
|
17
|
+
* States: idle → listening → processing → responding → idle
|
|
18
|
+
*
|
|
19
|
+
* This enforces valid state transitions and provides hooks for
|
|
20
|
+
* actions (start/stop mic, capture screenshot, play TTS, etc.)
|
|
21
|
+
*/
|
|
22
|
+
const cursorBuddyMachine = setup({
|
|
23
|
+
types: {
|
|
24
|
+
context: {},
|
|
25
|
+
events: {}
|
|
26
|
+
},
|
|
27
|
+
actions: {
|
|
28
|
+
clearTranscript: assign({ transcript: "" }),
|
|
29
|
+
clearResponse: assign({ response: "" }),
|
|
30
|
+
clearError: assign({ error: null }),
|
|
31
|
+
setTranscript: assign(({ event }) => {
|
|
32
|
+
if (event.type === "TRANSCRIPTION_COMPLETE") return { transcript: event.transcript };
|
|
33
|
+
return {};
|
|
34
|
+
}),
|
|
35
|
+
setResponse: assign(({ event }) => {
|
|
36
|
+
if (event.type === "AI_RESPONSE_COMPLETE") return { response: event.response };
|
|
37
|
+
return {};
|
|
38
|
+
}),
|
|
39
|
+
appendResponseChunk: assign(({ context, event }) => {
|
|
40
|
+
if (event.type === "AI_RESPONSE_CHUNK") return { response: context.response + event.text };
|
|
41
|
+
return {};
|
|
42
|
+
}),
|
|
43
|
+
setError: assign(({ event }) => {
|
|
44
|
+
if (event.type === "ERROR") return { error: event.error };
|
|
45
|
+
return {};
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
}).createMachine({
|
|
49
|
+
id: "cursorBuddy",
|
|
50
|
+
initial: "idle",
|
|
51
|
+
context: {
|
|
52
|
+
transcript: "",
|
|
53
|
+
response: "",
|
|
54
|
+
error: null
|
|
55
|
+
},
|
|
56
|
+
states: {
|
|
57
|
+
idle: {
|
|
58
|
+
entry: ["clearError"],
|
|
59
|
+
on: { HOTKEY_PRESSED: {
|
|
60
|
+
target: "listening",
|
|
61
|
+
actions: ["clearTranscript", "clearResponse"]
|
|
62
|
+
} }
|
|
63
|
+
},
|
|
64
|
+
listening: { on: {
|
|
65
|
+
HOTKEY_RELEASED: { target: "processing" },
|
|
66
|
+
CANCEL: { target: "idle" },
|
|
67
|
+
ERROR: {
|
|
68
|
+
target: "idle",
|
|
69
|
+
actions: ["setError"]
|
|
70
|
+
}
|
|
71
|
+
} },
|
|
72
|
+
processing: { on: {
|
|
73
|
+
TRANSCRIPTION_COMPLETE: { actions: ["setTranscript"] },
|
|
74
|
+
AI_RESPONSE_CHUNK: { actions: ["appendResponseChunk"] },
|
|
75
|
+
AI_RESPONSE_COMPLETE: {
|
|
76
|
+
target: "responding",
|
|
77
|
+
actions: ["setResponse"]
|
|
78
|
+
},
|
|
79
|
+
ERROR: {
|
|
80
|
+
target: "idle",
|
|
81
|
+
actions: ["setError"]
|
|
82
|
+
},
|
|
83
|
+
CANCEL: { target: "idle" }
|
|
84
|
+
} },
|
|
85
|
+
responding: { on: {
|
|
86
|
+
TTS_COMPLETE: { target: "idle" },
|
|
87
|
+
POINTING_COMPLETE: {},
|
|
88
|
+
CANCEL: { target: "idle" },
|
|
89
|
+
ERROR: {
|
|
90
|
+
target: "idle",
|
|
91
|
+
actions: ["setError"]
|
|
92
|
+
}
|
|
93
|
+
} }
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
//#endregion
|
|
97
|
+
//#region src/core/atoms.ts
|
|
98
|
+
/**
|
|
99
|
+
* Nanostores atoms for reactive values that don't need state machine semantics.
|
|
100
|
+
* These update frequently (e.g., 60fps audio levels) and are framework-agnostic.
|
|
101
|
+
*/
|
|
102
|
+
const $audioLevel = atom(0);
|
|
103
|
+
const $cursorPosition = atom({
|
|
104
|
+
x: 0,
|
|
105
|
+
y: 0
|
|
106
|
+
});
|
|
107
|
+
const $buddyPosition = atom({
|
|
108
|
+
x: 0,
|
|
109
|
+
y: 0
|
|
110
|
+
});
|
|
111
|
+
const $buddyRotation = atom(0);
|
|
112
|
+
const $buddyScale = atom(1);
|
|
113
|
+
const $pointingTarget = atom(null);
|
|
114
|
+
const $isEnabled = atom(true);
|
|
115
|
+
const $isSpeaking = atom(false);
|
|
116
|
+
const $conversationHistory = atom([]);
|
|
117
|
+
//#endregion
|
|
118
|
+
//#region src/core/pointing.ts
|
|
119
|
+
/**
|
|
120
|
+
* Parses [POINT:x,y:label] tags from AI responses.
|
|
121
|
+
* Format matches the Swift Clicky app for consistency.
|
|
122
|
+
*/
|
|
123
|
+
const POINTING_TAG_REGEX = /\[POINT:(\d+),(\d+):([^\]]+)\]\s*$/;
|
|
124
|
+
/**
|
|
125
|
+
* Extract pointing target from response text.
|
|
126
|
+
* Returns null if no valid POINT tag is found at the end.
|
|
127
|
+
*/
|
|
128
|
+
function parsePointingTag(response) {
|
|
129
|
+
const match = response.match(POINTING_TAG_REGEX);
|
|
130
|
+
if (!match) return null;
|
|
131
|
+
return {
|
|
132
|
+
x: parseInt(match[1], 10),
|
|
133
|
+
y: parseInt(match[2], 10),
|
|
134
|
+
label: match[3].trim()
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Remove POINT tag from response text for display/TTS.
|
|
139
|
+
*/
|
|
140
|
+
function stripPointingTag(response) {
|
|
141
|
+
return response.replace(POINTING_TAG_REGEX, "").trim();
|
|
142
|
+
}
|
|
143
|
+
//#endregion
|
|
144
|
+
//#region src/core/bezier.ts
|
|
145
|
+
/**
|
|
146
|
+
* Bezier flight animation for cursor pointing.
|
|
147
|
+
*/
|
|
148
|
+
/**
|
|
149
|
+
* Quadratic bezier curve: B(t) = (1-t)²P₀ + 2(1-t)t·P₁ + t²P₂
|
|
150
|
+
*/
|
|
151
|
+
function quadraticBezier(p0, p1, p2, t) {
|
|
152
|
+
const oneMinusT = 1 - t;
|
|
153
|
+
return {
|
|
154
|
+
x: oneMinusT * oneMinusT * p0.x + 2 * oneMinusT * t * p1.x + t * t * p2.x,
|
|
155
|
+
y: oneMinusT * oneMinusT * p0.y + 2 * oneMinusT * t * p1.y + t * t * p2.y
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Bezier tangent (derivative): B'(t) = 2(1-t)(P₁-P₀) + 2t(P₂-P₁)
|
|
160
|
+
*/
|
|
161
|
+
function bezierTangent(p0, p1, p2, t) {
|
|
162
|
+
const oneMinusT = 1 - t;
|
|
163
|
+
return {
|
|
164
|
+
x: 2 * oneMinusT * (p1.x - p0.x) + 2 * t * (p2.x - p1.x),
|
|
165
|
+
y: 2 * oneMinusT * (p1.y - p0.y) + 2 * t * (p2.y - p1.y)
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Ease-in-out cubic for smooth acceleration/deceleration
|
|
170
|
+
*/
|
|
171
|
+
function easeInOutCubic(t) {
|
|
172
|
+
return t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Animate cursor along a parabolic bezier arc from start to end.
|
|
176
|
+
* Used when the AI points at a UI element.
|
|
177
|
+
*
|
|
178
|
+
* @param from - Starting position
|
|
179
|
+
* @param to - Target position
|
|
180
|
+
* @param durationMs - Flight duration in milliseconds
|
|
181
|
+
* @param callbacks - Frame and completion callbacks
|
|
182
|
+
* @returns Cancel function to stop the animation
|
|
183
|
+
*/
|
|
184
|
+
function animateBezierFlight(from, to, durationMs, callbacks) {
|
|
185
|
+
const startTime = performance.now();
|
|
186
|
+
const distance = Math.hypot(to.x - from.x, to.y - from.y);
|
|
187
|
+
const controlPoint = {
|
|
188
|
+
x: (from.x + to.x) / 2,
|
|
189
|
+
y: Math.min(from.y, to.y) - distance * .2
|
|
190
|
+
};
|
|
191
|
+
let animationFrameId;
|
|
192
|
+
function animate(now) {
|
|
193
|
+
const elapsed = now - startTime;
|
|
194
|
+
const linearProgress = Math.min(elapsed / durationMs, 1);
|
|
195
|
+
const easedProgress = easeInOutCubic(linearProgress);
|
|
196
|
+
const position = quadraticBezier(from, controlPoint, to, easedProgress);
|
|
197
|
+
const tangent = bezierTangent(from, controlPoint, to, easedProgress);
|
|
198
|
+
const rotation = Math.atan2(tangent.y, tangent.x);
|
|
199
|
+
const scale = 1 + Math.sin(linearProgress * Math.PI) * .3;
|
|
200
|
+
callbacks.onFrame(position, rotation, scale);
|
|
201
|
+
if (linearProgress < 1) animationFrameId = requestAnimationFrame(animate);
|
|
202
|
+
else callbacks.onComplete();
|
|
203
|
+
}
|
|
204
|
+
animationFrameId = requestAnimationFrame(animate);
|
|
205
|
+
return () => cancelAnimationFrame(animationFrameId);
|
|
206
|
+
}
|
|
207
|
+
//#endregion
|
|
208
|
+
//#region src/client/utils/audio-worklet.ts
|
|
209
|
+
/**
|
|
210
|
+
* AudioWorklet processor code for voice capture.
|
|
211
|
+
* Inlined as a blob URL to avoid separate file serving requirements.
|
|
212
|
+
*/
|
|
213
|
+
const workletCode = `
|
|
214
|
+
class AudioCaptureProcessor extends AudioWorkletProcessor {
|
|
215
|
+
constructor() {
|
|
216
|
+
super()
|
|
217
|
+
this.isRecording = true
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
process(inputs) {
|
|
221
|
+
if (!this.isRecording) return false
|
|
222
|
+
|
|
223
|
+
const input = inputs[0]
|
|
224
|
+
if (input && input.length > 0) {
|
|
225
|
+
const channelData = input[0]
|
|
226
|
+
|
|
227
|
+
// Send audio data to main thread
|
|
228
|
+
this.port.postMessage({
|
|
229
|
+
type: "audio",
|
|
230
|
+
data: new Float32Array(channelData)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
// Calculate RMS for audio level visualization
|
|
234
|
+
let sum = 0
|
|
235
|
+
for (let i = 0; i < channelData.length; i++) {
|
|
236
|
+
sum += channelData[i] * channelData[i]
|
|
237
|
+
}
|
|
238
|
+
const rms = Math.sqrt(sum / channelData.length)
|
|
239
|
+
this.port.postMessage({ type: "level", rms })
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return true
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
registerProcessor("audio-capture-processor", AudioCaptureProcessor)
|
|
247
|
+
`;
|
|
248
|
+
let cachedBlobURL = null;
|
|
249
|
+
/**
|
|
250
|
+
* Create a blob URL for the audio worklet processor.
|
|
251
|
+
* Caches the URL to avoid creating multiple blobs.
|
|
252
|
+
*/
|
|
253
|
+
function createWorkletBlobURL() {
|
|
254
|
+
if (!cachedBlobURL) {
|
|
255
|
+
const blob = new Blob([workletCode], { type: "application/javascript" });
|
|
256
|
+
cachedBlobURL = URL.createObjectURL(blob);
|
|
257
|
+
}
|
|
258
|
+
return cachedBlobURL;
|
|
259
|
+
}
|
|
260
|
+
//#endregion
|
|
261
|
+
//#region src/client/utils/audio.ts
|
|
262
|
+
/**
|
|
263
|
+
* Audio conversion utilities for voice capture.
|
|
264
|
+
* Converts Float32 audio data to WAV format for server transcription.
|
|
265
|
+
*/
|
|
266
|
+
/**
|
|
267
|
+
* Merge multiple Float32Array chunks into a single array
|
|
268
|
+
*/
|
|
269
|
+
function mergeAudioChunks(chunks) {
|
|
270
|
+
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
|
|
271
|
+
const result = new Float32Array(totalLength);
|
|
272
|
+
let offset = 0;
|
|
273
|
+
for (const chunk of chunks) {
|
|
274
|
+
result.set(chunk, offset);
|
|
275
|
+
offset += chunk.length;
|
|
276
|
+
}
|
|
277
|
+
return result;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Convert Float32 audio data to 16-bit PCM
|
|
281
|
+
*/
|
|
282
|
+
function floatTo16BitPCM(output, offset, input) {
|
|
283
|
+
for (let i = 0; i < input.length; i++, offset += 2) {
|
|
284
|
+
const sample = Math.max(-1, Math.min(1, input[i]));
|
|
285
|
+
output.setInt16(offset, sample < 0 ? sample * 32768 : sample * 32767, true);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Write a string to a DataView
|
|
290
|
+
*/
|
|
291
|
+
function writeString(view, offset, string) {
|
|
292
|
+
for (let i = 0; i < string.length; i++) view.setUint8(offset + i, string.charCodeAt(i));
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Encode Float32 audio data as a WAV file
|
|
296
|
+
*/
|
|
297
|
+
function encodeWAV(samples, sampleRate) {
|
|
298
|
+
const numChannels = 1;
|
|
299
|
+
const bitsPerSample = 16;
|
|
300
|
+
const bytesPerSample = bitsPerSample / 8;
|
|
301
|
+
const blockAlign = numChannels * bytesPerSample;
|
|
302
|
+
const dataLength = samples.length * bytesPerSample;
|
|
303
|
+
const buffer = new ArrayBuffer(44 + dataLength);
|
|
304
|
+
const view = new DataView(buffer);
|
|
305
|
+
writeString(view, 0, "RIFF");
|
|
306
|
+
view.setUint32(4, 36 + dataLength, true);
|
|
307
|
+
writeString(view, 8, "WAVE");
|
|
308
|
+
writeString(view, 12, "fmt ");
|
|
309
|
+
view.setUint32(16, 16, true);
|
|
310
|
+
view.setUint16(20, 1, true);
|
|
311
|
+
view.setUint16(22, numChannels, true);
|
|
312
|
+
view.setUint32(24, sampleRate, true);
|
|
313
|
+
view.setUint32(28, sampleRate * blockAlign, true);
|
|
314
|
+
view.setUint16(32, blockAlign, true);
|
|
315
|
+
view.setUint16(34, bitsPerSample, true);
|
|
316
|
+
writeString(view, 36, "data");
|
|
317
|
+
view.setUint32(40, dataLength, true);
|
|
318
|
+
floatTo16BitPCM(view, 44, samples);
|
|
319
|
+
return new Blob([buffer], { type: "audio/wav" });
|
|
320
|
+
}
|
|
321
|
+
//#endregion
|
|
322
|
+
//#region src/client/hooks/useVoiceCapture.ts
|
|
323
|
+
const SAMPLE_RATE = 16e3;
|
|
324
|
+
const AUDIO_LEVEL_BOOST = 10.2;
|
|
325
|
+
/**
|
|
326
|
+
* Hook for voice capture using AudioWorkletNode.
|
|
327
|
+
* Updates $audioLevel atom for waveform visualization.
|
|
328
|
+
*/
|
|
329
|
+
function useVoiceCapture() {
|
|
330
|
+
const [isRecording, setIsRecording] = useState(false);
|
|
331
|
+
const [error, setError] = useState(null);
|
|
332
|
+
const audioContextRef = useRef(null);
|
|
333
|
+
const workletNodeRef = useRef(null);
|
|
334
|
+
const streamRef = useRef(null);
|
|
335
|
+
const chunksRef = useRef([]);
|
|
336
|
+
return {
|
|
337
|
+
start: useCallback(async () => {
|
|
338
|
+
setError(null);
|
|
339
|
+
chunksRef.current = [];
|
|
340
|
+
try {
|
|
341
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: {
|
|
342
|
+
sampleRate: SAMPLE_RATE,
|
|
343
|
+
channelCount: 1,
|
|
344
|
+
echoCancellation: true,
|
|
345
|
+
noiseSuppression: true
|
|
346
|
+
} });
|
|
347
|
+
streamRef.current = stream;
|
|
348
|
+
const audioContext = new AudioContext({ sampleRate: SAMPLE_RATE });
|
|
349
|
+
audioContextRef.current = audioContext;
|
|
350
|
+
const workletURL = createWorkletBlobURL();
|
|
351
|
+
await audioContext.audioWorklet.addModule(workletURL);
|
|
352
|
+
const source = audioContext.createMediaStreamSource(stream);
|
|
353
|
+
const workletNode = new AudioWorkletNode(audioContext, "audio-capture-processor");
|
|
354
|
+
workletNodeRef.current = workletNode;
|
|
355
|
+
workletNode.port.onmessage = (event) => {
|
|
356
|
+
const { type, data, rms } = event.data;
|
|
357
|
+
if (type === "audio") chunksRef.current.push(data);
|
|
358
|
+
else if (type === "level") {
|
|
359
|
+
const boostedLevel = Math.min(rms * AUDIO_LEVEL_BOOST, 1);
|
|
360
|
+
$audioLevel.set(boostedLevel);
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
source.connect(workletNode);
|
|
364
|
+
setIsRecording(true);
|
|
365
|
+
} catch (err) {
|
|
366
|
+
const captureError = err instanceof Error ? err : /* @__PURE__ */ new Error("Microphone access failed");
|
|
367
|
+
setError(captureError);
|
|
368
|
+
throw captureError;
|
|
369
|
+
}
|
|
370
|
+
}, []),
|
|
371
|
+
stop: useCallback(async () => {
|
|
372
|
+
if (streamRef.current) {
|
|
373
|
+
streamRef.current.getTracks().forEach((track) => track.stop());
|
|
374
|
+
streamRef.current = null;
|
|
375
|
+
}
|
|
376
|
+
if (workletNodeRef.current) {
|
|
377
|
+
workletNodeRef.current.disconnect();
|
|
378
|
+
workletNodeRef.current = null;
|
|
379
|
+
}
|
|
380
|
+
if (audioContextRef.current) {
|
|
381
|
+
await audioContextRef.current.close();
|
|
382
|
+
audioContextRef.current = null;
|
|
383
|
+
}
|
|
384
|
+
$audioLevel.set(0);
|
|
385
|
+
const wavBlob = encodeWAV(mergeAudioChunks(chunksRef.current), SAMPLE_RATE);
|
|
386
|
+
chunksRef.current = [];
|
|
387
|
+
setIsRecording(false);
|
|
388
|
+
return wavBlob;
|
|
389
|
+
}, []),
|
|
390
|
+
isRecording,
|
|
391
|
+
error
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
//#endregion
|
|
395
|
+
//#region src/client/utils/screenshot.ts
|
|
396
|
+
const MAX_WIDTH = 1280;
|
|
397
|
+
function getCaptureMetrics() {
|
|
398
|
+
return {
|
|
399
|
+
viewportWidth: window.innerWidth,
|
|
400
|
+
viewportHeight: window.innerHeight
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Resize canvas to max width while maintaining aspect ratio
|
|
405
|
+
*/
|
|
406
|
+
function resizeCanvas(canvas, maxWidth) {
|
|
407
|
+
if (canvas.width <= maxWidth) return canvas;
|
|
408
|
+
const scale = maxWidth / canvas.width;
|
|
409
|
+
const resized = document.createElement("canvas");
|
|
410
|
+
resized.width = maxWidth;
|
|
411
|
+
resized.height = Math.round(canvas.height * scale);
|
|
412
|
+
const ctx = resized.getContext("2d");
|
|
413
|
+
if (ctx) ctx.drawImage(canvas, 0, 0, resized.width, resized.height);
|
|
414
|
+
return resized;
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Create a fallback canvas when screenshot capture fails.
|
|
418
|
+
* Returns a simple gray canvas with an error message.
|
|
419
|
+
*/
|
|
420
|
+
function createFallbackCanvas() {
|
|
421
|
+
const canvas = document.createElement("canvas");
|
|
422
|
+
canvas.width = Math.min(window.innerWidth, MAX_WIDTH);
|
|
423
|
+
canvas.height = Math.round(window.innerHeight / window.innerWidth * canvas.width);
|
|
424
|
+
const ctx = canvas.getContext("2d");
|
|
425
|
+
if (ctx) {
|
|
426
|
+
ctx.fillStyle = "#f0f0f0";
|
|
427
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
428
|
+
ctx.fillStyle = "#666";
|
|
429
|
+
ctx.font = "16px sans-serif";
|
|
430
|
+
ctx.textAlign = "center";
|
|
431
|
+
ctx.fillText("Screenshot unavailable", canvas.width / 2, canvas.height / 2);
|
|
432
|
+
}
|
|
433
|
+
return canvas;
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Capture a screenshot of the current viewport.
|
|
437
|
+
* Uses html2canvas to render the DOM to a canvas, then exports as JPEG.
|
|
438
|
+
* Falls back to a placeholder if capture fails (e.g., due to unsupported CSS).
|
|
439
|
+
*/
|
|
440
|
+
async function captureViewport() {
|
|
441
|
+
const captureMetrics = getCaptureMetrics();
|
|
442
|
+
let canvas;
|
|
443
|
+
try {
|
|
444
|
+
canvas = await html2canvas(document.body, {
|
|
445
|
+
scale: 1,
|
|
446
|
+
useCORS: true,
|
|
447
|
+
logging: false,
|
|
448
|
+
width: captureMetrics.viewportWidth,
|
|
449
|
+
height: captureMetrics.viewportHeight,
|
|
450
|
+
x: window.scrollX,
|
|
451
|
+
y: window.scrollY
|
|
452
|
+
});
|
|
453
|
+
} catch (err) {
|
|
454
|
+
canvas = createFallbackCanvas();
|
|
455
|
+
}
|
|
456
|
+
const resized = resizeCanvas(canvas, MAX_WIDTH);
|
|
457
|
+
return {
|
|
458
|
+
imageData: resized.toDataURL("image/jpeg", .8),
|
|
459
|
+
width: resized.width,
|
|
460
|
+
height: resized.height,
|
|
461
|
+
viewportWidth: captureMetrics.viewportWidth,
|
|
462
|
+
viewportHeight: captureMetrics.viewportHeight
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
//#endregion
|
|
466
|
+
//#region src/client/hooks/useScreenCapture.ts
|
|
467
|
+
/**
|
|
468
|
+
* Hook for capturing viewport screenshots.
|
|
469
|
+
*/
|
|
470
|
+
function useScreenCapture() {
|
|
471
|
+
const [isCapturing, setIsCapturing] = useState(false);
|
|
472
|
+
const [lastCapture, setLastCapture] = useState(null);
|
|
473
|
+
const [error, setError] = useState(null);
|
|
474
|
+
return {
|
|
475
|
+
capture: useCallback(async () => {
|
|
476
|
+
setIsCapturing(true);
|
|
477
|
+
setError(null);
|
|
478
|
+
try {
|
|
479
|
+
const result = await captureViewport();
|
|
480
|
+
setLastCapture(result);
|
|
481
|
+
return result;
|
|
482
|
+
} catch (err) {
|
|
483
|
+
const captureError = err instanceof Error ? err : /* @__PURE__ */ new Error("Screenshot capture failed");
|
|
484
|
+
setError(captureError);
|
|
485
|
+
throw captureError;
|
|
486
|
+
} finally {
|
|
487
|
+
setIsCapturing(false);
|
|
488
|
+
}
|
|
489
|
+
}, []),
|
|
490
|
+
isCapturing,
|
|
491
|
+
lastCapture,
|
|
492
|
+
error
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
//#endregion
|
|
496
|
+
//#region src/client/hooks/useCursorPosition.ts
|
|
497
|
+
/**
|
|
498
|
+
* Hook that tracks mouse cursor position and updates the $cursorPosition atom.
|
|
499
|
+
* Should be used once at the provider level.
|
|
500
|
+
*/
|
|
501
|
+
function useCursorPosition() {
|
|
502
|
+
useEffect(() => {
|
|
503
|
+
function handleMouseMove(event) {
|
|
504
|
+
$cursorPosition.set({
|
|
505
|
+
x: event.clientX,
|
|
506
|
+
y: event.clientY
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
window.addEventListener("mousemove", handleMouseMove);
|
|
510
|
+
return () => {
|
|
511
|
+
window.removeEventListener("mousemove", handleMouseMove);
|
|
512
|
+
};
|
|
513
|
+
}, []);
|
|
514
|
+
}
|
|
515
|
+
//#endregion
|
|
516
|
+
//#region src/client/styles.css?inline
|
|
517
|
+
var styles_default = "/**\n * Cursor Buddy Styles\n *\n * Customize by overriding CSS variables in your own stylesheet:\n *\n * :root {\n * --cursor-buddy-color-idle: #8b5cf6;\n * }\n */\n\n:root {\n /* Cursor colors by state */\n --cursor-buddy-color-idle: #3b82f6;\n --cursor-buddy-color-listening: #ef4444;\n --cursor-buddy-color-processing: #eab308;\n --cursor-buddy-color-responding: #22c55e;\n --cursor-buddy-cursor-stroke: #ffffff;\n --cursor-buddy-cursor-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);\n\n /* Speech bubble */\n --cursor-buddy-bubble-bg: #ffffff;\n --cursor-buddy-bubble-text: #1f2937;\n --cursor-buddy-bubble-radius: 8px;\n --cursor-buddy-bubble-padding: 8px 12px;\n --cursor-buddy-bubble-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);\n --cursor-buddy-bubble-max-width: 200px;\n --cursor-buddy-bubble-font-size: 14px;\n\n /* Waveform */\n --cursor-buddy-waveform-color: #ef4444;\n --cursor-buddy-waveform-bar-width: 4px;\n --cursor-buddy-waveform-bar-radius: 2px;\n --cursor-buddy-waveform-gap: 3px;\n\n /* Overlay */\n --cursor-buddy-z-index: 2147483647;\n\n /* Animation durations */\n --cursor-buddy-transition-fast: 0.1s;\n --cursor-buddy-transition-normal: 0.2s;\n --cursor-buddy-animation-duration: 0.3s;\n}\n\n/* Overlay container */\n.cursor-buddy-overlay {\n position: fixed;\n inset: 0;\n pointer-events: none;\n isolation: isolate;\n z-index: var(--cursor-buddy-z-index);\n}\n\n/* Buddy container (cursor + accessories) */\n.cursor-buddy-container {\n position: absolute;\n transform: translate(-16px, -16px);\n}\n\n/* Cursor SVG */\n.cursor-buddy-cursor {\n transition: transform var(--cursor-buddy-transition-fast) ease-out;\n filter: drop-shadow(var(--cursor-buddy-cursor-shadow));\n}\n\n.cursor-buddy-cursor polygon {\n stroke: var(--cursor-buddy-cursor-stroke);\n stroke-width: 2;\n transition: fill var(--cursor-buddy-transition-normal) ease-out;\n}\n\n.cursor-buddy-cursor--idle polygon {\n fill: var(--cursor-buddy-color-idle);\n}\n\n.cursor-buddy-cursor--listening polygon {\n fill: var(--cursor-buddy-color-listening);\n}\n\n.cursor-buddy-cursor--processing polygon {\n fill: var(--cursor-buddy-color-processing);\n}\n\n.cursor-buddy-cursor--responding polygon {\n fill: var(--cursor-buddy-color-responding);\n}\n\n/* Cursor pulse animation during listening */\n.cursor-buddy-cursor--listening {\n animation: cursor-buddy-pulse 1.5s ease-in-out infinite;\n}\n\n@keyframes cursor-buddy-pulse {\n 0%,\n 100% {\n filter: drop-shadow(var(--cursor-buddy-cursor-shadow));\n }\n 50% {\n filter: drop-shadow(0 0 8px var(--cursor-buddy-color-listening));\n }\n}\n\n/* Processing spinner effect */\n.cursor-buddy-cursor--processing {\n animation: cursor-buddy-spin-subtle 2s linear infinite;\n}\n\n@keyframes cursor-buddy-spin-subtle {\n 0% {\n filter: drop-shadow(var(--cursor-buddy-cursor-shadow)) hue-rotate(0deg);\n }\n 100% {\n filter: drop-shadow(var(--cursor-buddy-cursor-shadow)) hue-rotate(360deg);\n }\n}\n\n/* Speech bubble */\n.cursor-buddy-bubble {\n position: absolute;\n left: 40px;\n top: -8px;\n pointer-events: auto;\n cursor: pointer;\n max-width: var(--cursor-buddy-bubble-max-width);\n padding: var(--cursor-buddy-bubble-padding);\n background-color: var(--cursor-buddy-bubble-bg);\n color: var(--cursor-buddy-bubble-text);\n border-radius: var(--cursor-buddy-bubble-radius);\n box-shadow: var(--cursor-buddy-bubble-shadow);\n font-size: var(--cursor-buddy-bubble-font-size);\n line-height: 1.4;\n width: max-content;\n overflow-wrap: break-word;\n word-break: break-word;\n user-select: none;\n animation: cursor-buddy-fade-in var(--cursor-buddy-animation-duration)\n ease-out;\n}\n\n@keyframes cursor-buddy-fade-in {\n from {\n opacity: 0;\n transform: translateY(-4px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n}\n\n/* Waveform container */\n.cursor-buddy-waveform {\n position: absolute;\n left: 40px;\n top: 4px;\n display: flex;\n align-items: center;\n gap: var(--cursor-buddy-waveform-gap);\n height: 24px;\n animation: cursor-buddy-fade-in var(--cursor-buddy-animation-duration)\n ease-out;\n}\n\n/* Waveform bars */\n.cursor-buddy-waveform-bar {\n width: var(--cursor-buddy-waveform-bar-width);\n background-color: var(--cursor-buddy-waveform-color);\n border-radius: var(--cursor-buddy-waveform-bar-radius);\n transition: height 0.05s ease-out;\n}\n\n/* Fade out animation (applied via JS) */\n.cursor-buddy-fade-out {\n animation: cursor-buddy-fade-out var(--cursor-buddy-animation-duration)\n ease-out forwards;\n}\n\n@keyframes cursor-buddy-fade-out {\n from {\n opacity: 1;\n }\n to {\n opacity: 0;\n }\n}\n";
|
|
518
|
+
//#endregion
|
|
519
|
+
//#region src/client/utils/inject-styles.ts
|
|
520
|
+
const STYLE_ID = "cursor-buddy-styles";
|
|
521
|
+
let injected = false;
|
|
522
|
+
/**
|
|
523
|
+
* Inject cursor buddy styles into the document head.
|
|
524
|
+
* Safe to call multiple times - will only inject once.
|
|
525
|
+
* No-op during SSR.
|
|
526
|
+
*/
|
|
527
|
+
function injectStyles() {
|
|
528
|
+
if (typeof document === "undefined") return;
|
|
529
|
+
if (injected) return;
|
|
530
|
+
if (document.getElementById(STYLE_ID)) {
|
|
531
|
+
injected = true;
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
const head = document.head || document.getElementsByTagName("head")[0];
|
|
535
|
+
const style = document.createElement("style");
|
|
536
|
+
style.id = STYLE_ID;
|
|
537
|
+
style.textContent = styles_default;
|
|
538
|
+
if (head.firstChild) head.insertBefore(style, head.firstChild);
|
|
539
|
+
else head.appendChild(style);
|
|
540
|
+
injected = true;
|
|
541
|
+
}
|
|
542
|
+
//#endregion
|
|
543
|
+
//#region src/client/CursorBuddyProvider.tsx
|
|
544
|
+
const POINTING_LOCK_TIMEOUT_MS = 1e4;
|
|
545
|
+
function clamp(value, min, max) {
|
|
546
|
+
return Math.min(Math.max(value, min), max);
|
|
547
|
+
}
|
|
548
|
+
function mapPointToViewport(target, screenshot) {
|
|
549
|
+
if (screenshot.width <= 0 || screenshot.height <= 0) return target;
|
|
550
|
+
const scaleX = screenshot.viewportWidth / screenshot.width;
|
|
551
|
+
const scaleY = screenshot.viewportHeight / screenshot.height;
|
|
552
|
+
return {
|
|
553
|
+
...target,
|
|
554
|
+
x: clamp(Math.round(target.x * scaleX), 0, Math.max(screenshot.viewportWidth - 1, 0)),
|
|
555
|
+
y: clamp(Math.round(target.y * scaleY), 0, Math.max(screenshot.viewportHeight - 1, 0))
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
function CursorBuddyProvider({ endpoint, muted = false, onTranscript, onResponse, onPoint, onStateChange, onError, children }) {
|
|
559
|
+
const [snapshot, send] = useActor(cursorBuddyMachine);
|
|
560
|
+
const voiceCapture = useVoiceCapture();
|
|
561
|
+
const screenCapture = useScreenCapture();
|
|
562
|
+
useCursorPosition();
|
|
563
|
+
useEffect(() => {
|
|
564
|
+
injectStyles();
|
|
565
|
+
}, []);
|
|
566
|
+
const audioLevel = useStore($audioLevel);
|
|
567
|
+
const isEnabled = useStore($isEnabled);
|
|
568
|
+
const isSpeaking = useStore($isSpeaking);
|
|
569
|
+
useStore($pointingTarget);
|
|
570
|
+
const cursorPosition = useStore($cursorPosition);
|
|
571
|
+
const [pointerMode, setPointerMode] = useState("follow");
|
|
572
|
+
const audioRef = useRef(null);
|
|
573
|
+
const cancelPointingRef = useRef(null);
|
|
574
|
+
const dismissPointingTimeoutRef = useRef(null);
|
|
575
|
+
const isRecordingRef = useRef(false);
|
|
576
|
+
const state = snapshot.value;
|
|
577
|
+
const transcript = snapshot.context.transcript;
|
|
578
|
+
const response = snapshot.context.response;
|
|
579
|
+
const error = snapshot.context.error;
|
|
580
|
+
const isPointing = pointerMode !== "follow";
|
|
581
|
+
useEffect(() => {
|
|
582
|
+
onStateChange?.(state);
|
|
583
|
+
}, [state, onStateChange]);
|
|
584
|
+
useEffect(() => {
|
|
585
|
+
if (error) onError?.(error);
|
|
586
|
+
}, [error, onError]);
|
|
587
|
+
const clearPointingTimeout = useCallback(() => {
|
|
588
|
+
if (dismissPointingTimeoutRef.current !== null) {
|
|
589
|
+
window.clearTimeout(dismissPointingTimeoutRef.current);
|
|
590
|
+
dismissPointingTimeoutRef.current = null;
|
|
591
|
+
}
|
|
592
|
+
}, []);
|
|
593
|
+
const cancelPointingAnimation = useCallback(() => {
|
|
594
|
+
if (cancelPointingRef.current) {
|
|
595
|
+
cancelPointingRef.current();
|
|
596
|
+
cancelPointingRef.current = null;
|
|
597
|
+
}
|
|
598
|
+
}, []);
|
|
599
|
+
const releasePointingLock = useCallback(() => {
|
|
600
|
+
clearPointingTimeout();
|
|
601
|
+
cancelPointingAnimation();
|
|
602
|
+
setPointerMode("follow");
|
|
603
|
+
$pointingTarget.set(null);
|
|
604
|
+
$buddyPosition.set($cursorPosition.get());
|
|
605
|
+
$buddyRotation.set(0);
|
|
606
|
+
$buddyScale.set(1);
|
|
607
|
+
}, [cancelPointingAnimation, clearPointingTimeout]);
|
|
608
|
+
const schedulePointingRelease = useCallback(() => {
|
|
609
|
+
clearPointingTimeout();
|
|
610
|
+
dismissPointingTimeoutRef.current = window.setTimeout(() => {
|
|
611
|
+
dismissPointingTimeoutRef.current = null;
|
|
612
|
+
releasePointingLock();
|
|
613
|
+
}, POINTING_LOCK_TIMEOUT_MS);
|
|
614
|
+
}, [clearPointingTimeout, releasePointingLock]);
|
|
615
|
+
useEffect(() => {
|
|
616
|
+
if (pointerMode === "follow") {
|
|
617
|
+
$buddyPosition.set(cursorPosition);
|
|
618
|
+
$buddyRotation.set(0);
|
|
619
|
+
$buddyScale.set(1);
|
|
620
|
+
}
|
|
621
|
+
}, [pointerMode, cursorPosition]);
|
|
622
|
+
useEffect(() => {
|
|
623
|
+
return () => {
|
|
624
|
+
clearPointingTimeout();
|
|
625
|
+
cancelPointingAnimation();
|
|
626
|
+
};
|
|
627
|
+
}, [cancelPointingAnimation, clearPointingTimeout]);
|
|
628
|
+
const handlePointing = useCallback((target) => {
|
|
629
|
+
clearPointingTimeout();
|
|
630
|
+
cancelPointingAnimation();
|
|
631
|
+
$pointingTarget.set(target);
|
|
632
|
+
setPointerMode("flying");
|
|
633
|
+
schedulePointingRelease();
|
|
634
|
+
const startPos = $buddyPosition.get();
|
|
635
|
+
const endPos = {
|
|
636
|
+
x: target.x,
|
|
637
|
+
y: target.y
|
|
638
|
+
};
|
|
639
|
+
cancelPointingRef.current = animateBezierFlight(startPos, endPos, 800, {
|
|
640
|
+
onFrame: (position, rotation, scale) => {
|
|
641
|
+
$buddyPosition.set(position);
|
|
642
|
+
$buddyRotation.set(rotation);
|
|
643
|
+
$buddyScale.set(scale);
|
|
644
|
+
},
|
|
645
|
+
onComplete: () => {
|
|
646
|
+
cancelPointingRef.current = null;
|
|
647
|
+
setPointerMode("anchored");
|
|
648
|
+
$buddyPosition.set(endPos);
|
|
649
|
+
$buddyRotation.set(0);
|
|
650
|
+
$buddyScale.set(1);
|
|
651
|
+
send({ type: "POINTING_COMPLETE" });
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
}, [
|
|
655
|
+
cancelPointingAnimation,
|
|
656
|
+
clearPointingTimeout,
|
|
657
|
+
schedulePointingRelease,
|
|
658
|
+
send
|
|
659
|
+
]);
|
|
660
|
+
const startListening = useCallback(async () => {
|
|
661
|
+
if (!isEnabled || isRecordingRef.current) return;
|
|
662
|
+
try {
|
|
663
|
+
releasePointingLock();
|
|
664
|
+
isRecordingRef.current = true;
|
|
665
|
+
send({ type: "HOTKEY_PRESSED" });
|
|
666
|
+
await voiceCapture.start();
|
|
667
|
+
} catch (err) {
|
|
668
|
+
isRecordingRef.current = false;
|
|
669
|
+
send({
|
|
670
|
+
type: "ERROR",
|
|
671
|
+
error: err instanceof Error ? err : /* @__PURE__ */ new Error("Failed to start recording")
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
}, [
|
|
675
|
+
isEnabled,
|
|
676
|
+
releasePointingLock,
|
|
677
|
+
send,
|
|
678
|
+
voiceCapture
|
|
679
|
+
]);
|
|
680
|
+
const stopListening = useCallback(async () => {
|
|
681
|
+
if (!isRecordingRef.current) return;
|
|
682
|
+
isRecordingRef.current = false;
|
|
683
|
+
try {
|
|
684
|
+
send({ type: "HOTKEY_RELEASED" });
|
|
685
|
+
const audioBlob = await voiceCapture.stop();
|
|
686
|
+
const screenshot = await screenCapture.capture();
|
|
687
|
+
const formData = new FormData();
|
|
688
|
+
formData.append("audio", audioBlob, "recording.wav");
|
|
689
|
+
const transcribeResponse = await fetch(`${endpoint}/transcribe`, {
|
|
690
|
+
method: "POST",
|
|
691
|
+
body: formData
|
|
692
|
+
});
|
|
693
|
+
if (!transcribeResponse.ok) throw new Error("Transcription failed");
|
|
694
|
+
const { text: transcriptText } = await transcribeResponse.json();
|
|
695
|
+
send({
|
|
696
|
+
type: "TRANSCRIPTION_COMPLETE",
|
|
697
|
+
transcript: transcriptText
|
|
698
|
+
});
|
|
699
|
+
onTranscript?.(transcriptText);
|
|
700
|
+
const history = $conversationHistory.get();
|
|
701
|
+
const chatResponse = await fetch(`${endpoint}/chat`, {
|
|
702
|
+
method: "POST",
|
|
703
|
+
headers: { "Content-Type": "application/json" },
|
|
704
|
+
body: JSON.stringify({
|
|
705
|
+
screenshot: screenshot.imageData,
|
|
706
|
+
capture: {
|
|
707
|
+
width: screenshot.width,
|
|
708
|
+
height: screenshot.height
|
|
709
|
+
},
|
|
710
|
+
transcript: transcriptText,
|
|
711
|
+
history
|
|
712
|
+
})
|
|
713
|
+
});
|
|
714
|
+
if (!chatResponse.ok) throw new Error("Chat request failed");
|
|
715
|
+
const reader = chatResponse.body?.getReader();
|
|
716
|
+
if (!reader) throw new Error("No response body");
|
|
717
|
+
const decoder = new TextDecoder();
|
|
718
|
+
let fullResponse = "";
|
|
719
|
+
while (true) {
|
|
720
|
+
const { done, value } = await reader.read();
|
|
721
|
+
if (done) break;
|
|
722
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
723
|
+
fullResponse += chunk;
|
|
724
|
+
send({
|
|
725
|
+
type: "AI_RESPONSE_CHUNK",
|
|
726
|
+
text: chunk
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
const rawPointTarget = parsePointingTag(fullResponse);
|
|
730
|
+
const pointTarget = rawPointTarget ? mapPointToViewport(rawPointTarget, screenshot) : null;
|
|
731
|
+
const cleanResponse = stripPointingTag(fullResponse);
|
|
732
|
+
send({
|
|
733
|
+
type: "AI_RESPONSE_COMPLETE",
|
|
734
|
+
response: cleanResponse
|
|
735
|
+
});
|
|
736
|
+
onResponse?.(cleanResponse);
|
|
737
|
+
const newHistory = [
|
|
738
|
+
...history,
|
|
739
|
+
{
|
|
740
|
+
role: "user",
|
|
741
|
+
content: transcriptText
|
|
742
|
+
},
|
|
743
|
+
{
|
|
744
|
+
role: "assistant",
|
|
745
|
+
content: cleanResponse
|
|
746
|
+
}
|
|
747
|
+
];
|
|
748
|
+
$conversationHistory.set(newHistory);
|
|
749
|
+
if (pointTarget) {
|
|
750
|
+
onPoint?.(pointTarget);
|
|
751
|
+
handlePointing(pointTarget);
|
|
752
|
+
}
|
|
753
|
+
if (!muted && cleanResponse) await playTTS(cleanResponse);
|
|
754
|
+
send({ type: "TTS_COMPLETE" });
|
|
755
|
+
} catch (err) {
|
|
756
|
+
send({
|
|
757
|
+
type: "ERROR",
|
|
758
|
+
error: err instanceof Error ? err : /* @__PURE__ */ new Error("Processing failed")
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
}, [
|
|
762
|
+
send,
|
|
763
|
+
voiceCapture,
|
|
764
|
+
screenCapture,
|
|
765
|
+
endpoint,
|
|
766
|
+
muted,
|
|
767
|
+
onTranscript,
|
|
768
|
+
onResponse,
|
|
769
|
+
onPoint,
|
|
770
|
+
handlePointing
|
|
771
|
+
]);
|
|
772
|
+
const playTTS = useCallback(async (text) => {
|
|
773
|
+
$isSpeaking.set(true);
|
|
774
|
+
try {
|
|
775
|
+
const response = await fetch(`${endpoint}/tts`, {
|
|
776
|
+
method: "POST",
|
|
777
|
+
headers: { "Content-Type": "application/json" },
|
|
778
|
+
body: JSON.stringify({ text })
|
|
779
|
+
});
|
|
780
|
+
if (!response.ok) throw new Error("TTS request failed");
|
|
781
|
+
const audioBlob = await response.blob();
|
|
782
|
+
const audioUrl = URL.createObjectURL(audioBlob);
|
|
783
|
+
const audio = new Audio(audioUrl);
|
|
784
|
+
audioRef.current = audio;
|
|
785
|
+
await new Promise((resolve, reject) => {
|
|
786
|
+
audio.onended = () => {
|
|
787
|
+
URL.revokeObjectURL(audioUrl);
|
|
788
|
+
resolve();
|
|
789
|
+
};
|
|
790
|
+
audio.onerror = () => {
|
|
791
|
+
URL.revokeObjectURL(audioUrl);
|
|
792
|
+
reject(/* @__PURE__ */ new Error("Audio playback failed"));
|
|
793
|
+
};
|
|
794
|
+
audio.play();
|
|
795
|
+
});
|
|
796
|
+
} finally {
|
|
797
|
+
$isSpeaking.set(false);
|
|
798
|
+
audioRef.current = null;
|
|
799
|
+
}
|
|
800
|
+
}, [endpoint]);
|
|
801
|
+
const speak = useCallback(async (text) => {
|
|
802
|
+
if (muted) return;
|
|
803
|
+
await playTTS(text);
|
|
804
|
+
}, [muted, playTTS]);
|
|
805
|
+
const pointAt = useCallback((x, y, label) => {
|
|
806
|
+
handlePointing({
|
|
807
|
+
x,
|
|
808
|
+
y,
|
|
809
|
+
label
|
|
810
|
+
});
|
|
811
|
+
}, [handlePointing]);
|
|
812
|
+
const dismissPointing = useCallback(() => {
|
|
813
|
+
releasePointingLock();
|
|
814
|
+
}, [releasePointingLock]);
|
|
815
|
+
const setEnabled = useCallback((enabled) => {
|
|
816
|
+
$isEnabled.set(enabled);
|
|
817
|
+
}, []);
|
|
818
|
+
const reset = useCallback(() => {
|
|
819
|
+
if (audioRef.current) {
|
|
820
|
+
audioRef.current.pause();
|
|
821
|
+
audioRef.current = null;
|
|
822
|
+
}
|
|
823
|
+
isRecordingRef.current = false;
|
|
824
|
+
$isSpeaking.set(false);
|
|
825
|
+
releasePointingLock();
|
|
826
|
+
send({ type: "CANCEL" });
|
|
827
|
+
}, [releasePointingLock, send]);
|
|
828
|
+
const contextValue = useMemo(() => ({
|
|
829
|
+
state,
|
|
830
|
+
transcript,
|
|
831
|
+
response,
|
|
832
|
+
audioLevel,
|
|
833
|
+
isEnabled,
|
|
834
|
+
isSpeaking,
|
|
835
|
+
isPointing,
|
|
836
|
+
error,
|
|
837
|
+
startListening,
|
|
838
|
+
stopListening,
|
|
839
|
+
setEnabled,
|
|
840
|
+
speak,
|
|
841
|
+
pointAt,
|
|
842
|
+
dismissPointing,
|
|
843
|
+
reset
|
|
844
|
+
}), [
|
|
845
|
+
state,
|
|
846
|
+
transcript,
|
|
847
|
+
response,
|
|
848
|
+
audioLevel,
|
|
849
|
+
isEnabled,
|
|
850
|
+
isSpeaking,
|
|
851
|
+
isPointing,
|
|
852
|
+
error,
|
|
853
|
+
startListening,
|
|
854
|
+
stopListening,
|
|
855
|
+
setEnabled,
|
|
856
|
+
speak,
|
|
857
|
+
pointAt,
|
|
858
|
+
dismissPointing,
|
|
859
|
+
reset
|
|
860
|
+
]);
|
|
861
|
+
return /* @__PURE__ */ jsx(CursorBuddyContext.Provider, {
|
|
862
|
+
value: contextValue,
|
|
863
|
+
children
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
//#endregion
|
|
867
|
+
//#region src/client/hooks/useCursorBuddy.ts
|
|
868
|
+
/**
|
|
869
|
+
* Hook to access cursor buddy state and actions.
|
|
870
|
+
* Must be used within a CursorBuddyProvider.
|
|
871
|
+
*/
|
|
872
|
+
function useCursorBuddy() {
|
|
873
|
+
const context = useContext(CursorBuddyContext);
|
|
874
|
+
if (!context) throw new Error("useCursorBuddy must be used within a CursorBuddyProvider");
|
|
875
|
+
return context;
|
|
876
|
+
}
|
|
877
|
+
//#endregion
|
|
878
|
+
//#region src/client/components/Cursor.tsx
|
|
879
|
+
/**
|
|
880
|
+
* Default cursor component - a colored triangle pointer.
|
|
881
|
+
* Color and animations change based on voice state via CSS classes.
|
|
882
|
+
*/
|
|
883
|
+
function DefaultCursor({ state, rotation, scale }) {
|
|
884
|
+
return /* @__PURE__ */ jsx("svg", {
|
|
885
|
+
width: "32",
|
|
886
|
+
height: "32",
|
|
887
|
+
viewBox: "0 0 32 32",
|
|
888
|
+
className: `cursor-buddy-cursor ${`cursor-buddy-cursor--${state}`}`,
|
|
889
|
+
style: { transform: `rotate(${rotation}rad) scale(${scale})` },
|
|
890
|
+
children: /* @__PURE__ */ jsx("polygon", { points: "16,4 28,28 16,22 4,28" })
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
//#endregion
|
|
894
|
+
//#region src/client/components/SpeechBubble.tsx
|
|
895
|
+
/**
|
|
896
|
+
* Default speech bubble component.
|
|
897
|
+
* Displays pointing label or response text next to the cursor.
|
|
898
|
+
*/
|
|
899
|
+
function DefaultSpeechBubble({ text, isVisible, onClick }) {
|
|
900
|
+
if (!isVisible || !text) return null;
|
|
901
|
+
return /* @__PURE__ */ jsx("div", {
|
|
902
|
+
className: "cursor-buddy-bubble",
|
|
903
|
+
onClick,
|
|
904
|
+
onKeyDown: (event) => {
|
|
905
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
906
|
+
event.preventDefault();
|
|
907
|
+
onClick?.();
|
|
908
|
+
}
|
|
909
|
+
},
|
|
910
|
+
role: "button",
|
|
911
|
+
tabIndex: 0,
|
|
912
|
+
children: text
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
//#endregion
|
|
916
|
+
//#region src/client/components/Waveform.tsx
|
|
917
|
+
const BAR_COUNT = 5;
|
|
918
|
+
/**
|
|
919
|
+
* Default waveform component.
|
|
920
|
+
* Shows audio level visualization during recording.
|
|
921
|
+
*/
|
|
922
|
+
function DefaultWaveform({ audioLevel, isListening }) {
|
|
923
|
+
if (!isListening) return null;
|
|
924
|
+
return /* @__PURE__ */ jsx("div", {
|
|
925
|
+
className: "cursor-buddy-waveform",
|
|
926
|
+
children: Array.from({ length: BAR_COUNT }).map((_, i) => {
|
|
927
|
+
const baseHeight = 4;
|
|
928
|
+
const variance = Math.sin(i / BAR_COUNT * Math.PI) * .5 + .5;
|
|
929
|
+
return /* @__PURE__ */ jsx("div", {
|
|
930
|
+
className: "cursor-buddy-waveform-bar",
|
|
931
|
+
style: { height: `${baseHeight + audioLevel * 16 * variance}px` }
|
|
932
|
+
}, i);
|
|
933
|
+
})
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
//#endregion
|
|
937
|
+
//#region src/client/components/Overlay.tsx
|
|
938
|
+
/**
|
|
939
|
+
* Overlay component that renders the cursor, speech bubble, and waveform.
|
|
940
|
+
* Uses React portal to render at the document body level.
|
|
941
|
+
*/
|
|
942
|
+
function Overlay({ cursor, speechBubble, waveform, container }) {
|
|
943
|
+
const [isMounted, setIsMounted] = useState(false);
|
|
944
|
+
useEffect(() => setIsMounted(true), []);
|
|
945
|
+
const { state, isPointing, isEnabled, dismissPointing } = useCursorBuddy();
|
|
946
|
+
const buddyPosition = useStore($buddyPosition);
|
|
947
|
+
const buddyRotation = useStore($buddyRotation);
|
|
948
|
+
const buddyScale = useStore($buddyScale);
|
|
949
|
+
const audioLevel = useStore($audioLevel);
|
|
950
|
+
const pointingTarget = useStore($pointingTarget);
|
|
951
|
+
if (!isMounted || !isEnabled) return null;
|
|
952
|
+
const cursorProps = {
|
|
953
|
+
state,
|
|
954
|
+
isPointing,
|
|
955
|
+
rotation: buddyRotation,
|
|
956
|
+
scale: buddyScale
|
|
957
|
+
};
|
|
958
|
+
const speechBubbleProps = {
|
|
959
|
+
text: pointingTarget?.label ?? "",
|
|
960
|
+
isVisible: isPointing && !!pointingTarget,
|
|
961
|
+
onClick: dismissPointing
|
|
962
|
+
};
|
|
963
|
+
const waveformProps = {
|
|
964
|
+
audioLevel,
|
|
965
|
+
isListening: state === "listening"
|
|
966
|
+
};
|
|
967
|
+
const cursorElement = typeof cursor === "function" ? cursor(cursorProps) : cursor ? cursor : /* @__PURE__ */ jsx(DefaultCursor, { ...cursorProps });
|
|
968
|
+
const speechBubbleElement = speechBubble ? speechBubble(speechBubbleProps) : /* @__PURE__ */ jsx(DefaultSpeechBubble, { ...speechBubbleProps });
|
|
969
|
+
const waveformElement = waveform ? waveform(waveformProps) : /* @__PURE__ */ jsx(DefaultWaveform, { ...waveformProps });
|
|
970
|
+
const overlayContent = /* @__PURE__ */ jsx("div", {
|
|
971
|
+
className: "cursor-buddy-overlay",
|
|
972
|
+
"data-cursor-buddy-overlay": true,
|
|
973
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
974
|
+
className: "cursor-buddy-container",
|
|
975
|
+
style: {
|
|
976
|
+
left: buddyPosition.x,
|
|
977
|
+
top: buddyPosition.y
|
|
978
|
+
},
|
|
979
|
+
children: [
|
|
980
|
+
cursorElement,
|
|
981
|
+
state === "listening" && waveformElement,
|
|
982
|
+
isPointing && speechBubbleElement
|
|
983
|
+
]
|
|
984
|
+
})
|
|
985
|
+
});
|
|
986
|
+
const portalContainer = container ?? (typeof document !== "undefined" ? document.body : null);
|
|
987
|
+
if (!portalContainer) return null;
|
|
988
|
+
return createPortal(overlayContent, portalContainer);
|
|
989
|
+
}
|
|
990
|
+
//#endregion
|
|
991
|
+
//#region src/client/hooks/useHotkey.ts
|
|
992
|
+
/**
|
|
993
|
+
* Parse a hotkey string like "ctrl+alt" into modifier flags
|
|
994
|
+
*/
|
|
995
|
+
function parseHotkey(hotkey) {
|
|
996
|
+
const parts = hotkey.toLowerCase().split("+");
|
|
997
|
+
return {
|
|
998
|
+
ctrl: parts.includes("ctrl") || parts.includes("control"),
|
|
999
|
+
alt: parts.includes("alt") || parts.includes("option"),
|
|
1000
|
+
shift: parts.includes("shift"),
|
|
1001
|
+
meta: parts.includes("meta") || parts.includes("cmd") || parts.includes("command")
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Check if a keyboard event matches the required modifiers
|
|
1006
|
+
*/
|
|
1007
|
+
function matchesHotkey(event, modifiers) {
|
|
1008
|
+
return event.ctrlKey === modifiers.ctrl && event.altKey === modifiers.alt && event.shiftKey === modifiers.shift && event.metaKey === modifiers.meta;
|
|
1009
|
+
}
|
|
1010
|
+
/**
|
|
1011
|
+
* Hook for detecting push-to-talk hotkey press/release.
|
|
1012
|
+
*
|
|
1013
|
+
* @param hotkey - Hotkey string like "ctrl+alt" or "ctrl+shift"
|
|
1014
|
+
* @param onPress - Called when hotkey is pressed
|
|
1015
|
+
* @param onRelease - Called when hotkey is released
|
|
1016
|
+
* @param enabled - Whether the hotkey listener is active (default: true)
|
|
1017
|
+
*/
|
|
1018
|
+
function useHotkey(hotkey, onPress, onRelease, enabled = true) {
|
|
1019
|
+
const isPressedRef = useRef(false);
|
|
1020
|
+
const modifiersRef = useRef(parseHotkey(hotkey));
|
|
1021
|
+
const onPressRef = useRef(onPress);
|
|
1022
|
+
const onReleaseRef = useRef(onRelease);
|
|
1023
|
+
onPressRef.current = onPress;
|
|
1024
|
+
onReleaseRef.current = onRelease;
|
|
1025
|
+
useEffect(() => {
|
|
1026
|
+
modifiersRef.current = parseHotkey(hotkey);
|
|
1027
|
+
}, [hotkey]);
|
|
1028
|
+
useEffect(() => {
|
|
1029
|
+
if (!enabled) {
|
|
1030
|
+
if (isPressedRef.current) {
|
|
1031
|
+
isPressedRef.current = false;
|
|
1032
|
+
onReleaseRef.current();
|
|
1033
|
+
}
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
function handleKeyDown(event) {
|
|
1037
|
+
if (matchesHotkey(event, modifiersRef.current) && !isPressedRef.current) {
|
|
1038
|
+
isPressedRef.current = true;
|
|
1039
|
+
event.preventDefault();
|
|
1040
|
+
onPressRef.current();
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
function handleKeyUp(event) {
|
|
1044
|
+
if (isPressedRef.current && !matchesHotkey(event, modifiersRef.current)) {
|
|
1045
|
+
isPressedRef.current = false;
|
|
1046
|
+
onReleaseRef.current();
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
function handleBlur() {
|
|
1050
|
+
if (isPressedRef.current) {
|
|
1051
|
+
isPressedRef.current = false;
|
|
1052
|
+
onReleaseRef.current();
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
1056
|
+
window.addEventListener("keyup", handleKeyUp);
|
|
1057
|
+
window.addEventListener("blur", handleBlur);
|
|
1058
|
+
return () => {
|
|
1059
|
+
window.removeEventListener("keydown", handleKeyDown);
|
|
1060
|
+
window.removeEventListener("keyup", handleKeyUp);
|
|
1061
|
+
window.removeEventListener("blur", handleBlur);
|
|
1062
|
+
};
|
|
1063
|
+
}, [enabled]);
|
|
1064
|
+
}
|
|
1065
|
+
//#endregion
|
|
1066
|
+
//#region src/client/CursorBuddy.tsx
|
|
1067
|
+
/**
|
|
1068
|
+
* Internal component that sets up hotkey handling
|
|
1069
|
+
*/
|
|
1070
|
+
function CursorBuddyInner({ hotkey = "ctrl+alt", cursor, speechBubble, waveform, container }) {
|
|
1071
|
+
const { startListening, stopListening, isEnabled } = useCursorBuddy();
|
|
1072
|
+
useHotkey(hotkey, startListening, stopListening, isEnabled);
|
|
1073
|
+
return /* @__PURE__ */ jsx(Overlay, {
|
|
1074
|
+
cursor,
|
|
1075
|
+
speechBubble,
|
|
1076
|
+
waveform,
|
|
1077
|
+
container
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Drop-in cursor buddy component.
|
|
1082
|
+
*
|
|
1083
|
+
* Adds an AI-powered cursor companion to your app. Users hold the hotkey
|
|
1084
|
+
* (default: Ctrl+Alt) to speak. The SDK captures a screenshot, transcribes
|
|
1085
|
+
* the speech, sends it to the AI, speaks the response, and can point at
|
|
1086
|
+
* elements on screen.
|
|
1087
|
+
*
|
|
1088
|
+
* @example
|
|
1089
|
+
* ```tsx
|
|
1090
|
+
* import { CursorBuddy } from "cursor-buddy/client"
|
|
1091
|
+
*
|
|
1092
|
+
* function App() {
|
|
1093
|
+
* return (
|
|
1094
|
+
* <>
|
|
1095
|
+
* <YourApp />
|
|
1096
|
+
* <CursorBuddy endpoint="/api/cursor-buddy" />
|
|
1097
|
+
* </>
|
|
1098
|
+
* )
|
|
1099
|
+
* }
|
|
1100
|
+
* ```
|
|
1101
|
+
*/
|
|
1102
|
+
function CursorBuddy({ endpoint, hotkey, muted, container, cursor, speechBubble, waveform, onTranscript, onResponse, onPoint, onStateChange, onError }) {
|
|
1103
|
+
return /* @__PURE__ */ jsx(CursorBuddyProvider, {
|
|
1104
|
+
endpoint,
|
|
1105
|
+
muted,
|
|
1106
|
+
onTranscript,
|
|
1107
|
+
onResponse,
|
|
1108
|
+
onPoint,
|
|
1109
|
+
onStateChange,
|
|
1110
|
+
onError,
|
|
1111
|
+
children: /* @__PURE__ */ jsx(CursorBuddyInner, {
|
|
1112
|
+
hotkey,
|
|
1113
|
+
cursor,
|
|
1114
|
+
speechBubble,
|
|
1115
|
+
waveform,
|
|
1116
|
+
container
|
|
1117
|
+
})
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
//#endregion
|
|
1121
|
+
export { CursorBuddy, CursorBuddyProvider, useCursorBuddy };
|
|
1122
|
+
|
|
1123
|
+
//# sourceMappingURL=index.mjs.map
|