cursor-buddy 0.0.2 → 0.0.4
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/dist/client-DJRU6dKB.d.mts +462 -0
- package/dist/client-DJRU6dKB.d.mts.map +1 -0
- package/dist/client-UXGQt-7f.mjs +2193 -0
- package/dist/client-UXGQt-7f.mjs.map +1 -0
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +1 -1
- package/dist/react/index.d.mts +39 -24
- package/dist/react/index.d.mts.map +1 -1
- package/dist/react/index.mjs +108 -88
- package/dist/react/index.mjs.map +1 -1
- package/dist/server/adapters/next.d.mts +1 -1
- package/dist/server/index.d.mts +3 -3
- package/dist/server/index.mjs +38 -14
- package/dist/server/index.mjs.map +1 -1
- package/dist/{types-L97cq8UK.d.mts → types-BxBhjZju.d.mts} +12 -5
- package/dist/types-BxBhjZju.d.mts.map +1 -0
- package/package.json +1 -1
- package/README.md +0 -344
- package/dist/client-Bd33JD8T.mjs +0 -890
- package/dist/client-Bd33JD8T.mjs.map +0 -1
- package/dist/client-DKZY5bI1.d.mts +0 -314
- package/dist/client-DKZY5bI1.d.mts.map +0 -1
- package/dist/types-L97cq8UK.d.mts.map +0 -1
package/dist/react/index.mjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { a as $buddyScale, i as $buddyRotation, n as $audioLevel, o as $cursorPosition, r as $buddyPosition, s as $pointingTarget, t as CursorBuddyClient } from "../client-
|
|
2
|
+
import { a as $buddyScale, i as $buddyRotation, n as $audioLevel, o as $cursorPosition, r as $buddyPosition, s as $pointingTarget, t as CursorBuddyClient } from "../client-UXGQt-7f.mjs";
|
|
3
|
+
import { useStore } from "@nanostores/react";
|
|
3
4
|
import { createContext, useCallback, useContext, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
4
5
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
6
|
import { createPortal } from "react-dom";
|
|
6
|
-
import { useStore } from "@nanostores/react";
|
|
7
7
|
//#region src/react/styles.css?inline
|
|
8
|
-
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, -
|
|
8
|
+
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, -4px);\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";
|
|
9
9
|
//#endregion
|
|
10
10
|
//#region src/react/utils/inject-styles.ts
|
|
11
11
|
const STYLE_ID = "cursor-buddy-styles";
|
|
@@ -36,13 +36,15 @@ const CursorBuddyContext = createContext(null);
|
|
|
36
36
|
/**
|
|
37
37
|
* Provider for cursor buddy. Creates and manages the client instance.
|
|
38
38
|
*/
|
|
39
|
-
function CursorBuddyProvider({ endpoint, children, onTranscript, onResponse, onPoint, onStateChange, onError }) {
|
|
39
|
+
function CursorBuddyProvider({ endpoint, transcription, speech, children, onTranscript, onResponse, onPoint, onStateChange, onError }) {
|
|
40
40
|
const [client] = useState(() => new CursorBuddyClient(endpoint, {
|
|
41
41
|
onTranscript,
|
|
42
42
|
onResponse,
|
|
43
43
|
onPoint,
|
|
44
44
|
onStateChange,
|
|
45
|
-
onError
|
|
45
|
+
onError,
|
|
46
|
+
speech,
|
|
47
|
+
transcription
|
|
46
48
|
}));
|
|
47
49
|
useEffect(() => {
|
|
48
50
|
injectStyles();
|
|
@@ -95,6 +97,81 @@ function useCursorBuddy() {
|
|
|
95
97
|
};
|
|
96
98
|
}
|
|
97
99
|
//#endregion
|
|
100
|
+
//#region src/react/use-hotkey.ts
|
|
101
|
+
/**
|
|
102
|
+
* Parse a hotkey string like "ctrl+alt" into modifier flags
|
|
103
|
+
*/
|
|
104
|
+
function parseHotkey(hotkey) {
|
|
105
|
+
const parts = hotkey.toLowerCase().split("+");
|
|
106
|
+
return {
|
|
107
|
+
ctrl: parts.includes("ctrl") || parts.includes("control"),
|
|
108
|
+
alt: parts.includes("alt") || parts.includes("option"),
|
|
109
|
+
shift: parts.includes("shift"),
|
|
110
|
+
meta: parts.includes("meta") || parts.includes("cmd") || parts.includes("command")
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Check if a keyboard event matches the required modifiers
|
|
115
|
+
*/
|
|
116
|
+
function matchesHotkey(event, modifiers) {
|
|
117
|
+
return event.ctrlKey === modifiers.ctrl && event.altKey === modifiers.alt && event.shiftKey === modifiers.shift && event.metaKey === modifiers.meta;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Hook for detecting push-to-talk hotkey press/release.
|
|
121
|
+
*
|
|
122
|
+
* @param hotkey - Hotkey string like "ctrl+alt" or "ctrl+shift"
|
|
123
|
+
* @param onPress - Called when hotkey is pressed
|
|
124
|
+
* @param onRelease - Called when hotkey is released
|
|
125
|
+
* @param enabled - Whether the hotkey listener is active (default: true)
|
|
126
|
+
*/
|
|
127
|
+
function useHotkey(hotkey, onPress, onRelease, enabled = true) {
|
|
128
|
+
const isPressedRef = useRef(false);
|
|
129
|
+
const modifiersRef = useRef(parseHotkey(hotkey));
|
|
130
|
+
const onPressRef = useRef(onPress);
|
|
131
|
+
const onReleaseRef = useRef(onRelease);
|
|
132
|
+
onPressRef.current = onPress;
|
|
133
|
+
onReleaseRef.current = onRelease;
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
modifiersRef.current = parseHotkey(hotkey);
|
|
136
|
+
}, [hotkey]);
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
if (!enabled) {
|
|
139
|
+
if (isPressedRef.current) {
|
|
140
|
+
isPressedRef.current = false;
|
|
141
|
+
onReleaseRef.current();
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
function handleKeyDown(event) {
|
|
146
|
+
if (matchesHotkey(event, modifiersRef.current) && !isPressedRef.current) {
|
|
147
|
+
isPressedRef.current = true;
|
|
148
|
+
event.preventDefault();
|
|
149
|
+
onPressRef.current();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function handleKeyUp(event) {
|
|
153
|
+
if (isPressedRef.current && !matchesHotkey(event, modifiersRef.current)) {
|
|
154
|
+
isPressedRef.current = false;
|
|
155
|
+
onReleaseRef.current();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function handleBlur() {
|
|
159
|
+
if (isPressedRef.current) {
|
|
160
|
+
isPressedRef.current = false;
|
|
161
|
+
onReleaseRef.current();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
165
|
+
window.addEventListener("keyup", handleKeyUp);
|
|
166
|
+
window.addEventListener("blur", handleBlur);
|
|
167
|
+
return () => {
|
|
168
|
+
window.removeEventListener("keydown", handleKeyDown);
|
|
169
|
+
window.removeEventListener("keyup", handleKeyUp);
|
|
170
|
+
window.removeEventListener("blur", handleBlur);
|
|
171
|
+
};
|
|
172
|
+
}, [enabled]);
|
|
173
|
+
}
|
|
174
|
+
//#endregion
|
|
98
175
|
//#region src/react/components/Cursor.tsx
|
|
99
176
|
const BASE_ROTATION = -Math.PI / 6;
|
|
100
177
|
/**
|
|
@@ -107,7 +184,10 @@ function DefaultCursor({ state, rotation, scale }) {
|
|
|
107
184
|
height: "32",
|
|
108
185
|
viewBox: "0 0 32 32",
|
|
109
186
|
className: `cursor-buddy-cursor ${`cursor-buddy-cursor--${state}`}`,
|
|
110
|
-
style: {
|
|
187
|
+
style: {
|
|
188
|
+
transform: `rotate(${BASE_ROTATION + rotation}rad) scale(${scale})`,
|
|
189
|
+
transformOrigin: "16px 4px"
|
|
190
|
+
},
|
|
111
191
|
children: /* @__PURE__ */ jsx("polygon", { points: "16,4 28,28 16,22 4,28" })
|
|
112
192
|
});
|
|
113
193
|
}
|
|
@@ -135,21 +215,33 @@ function DefaultSpeechBubble({ text, isVisible, onClick }) {
|
|
|
135
215
|
}
|
|
136
216
|
//#endregion
|
|
137
217
|
//#region src/react/components/Waveform.tsx
|
|
138
|
-
const
|
|
218
|
+
const EMPTY_BARS = Array.from({ length: 12 }, () => 0);
|
|
139
219
|
/**
|
|
140
220
|
* Default waveform component.
|
|
141
221
|
* Shows audio level visualization during recording.
|
|
142
222
|
*/
|
|
143
223
|
function DefaultWaveform({ audioLevel, isListening }) {
|
|
224
|
+
const [bars, setBars] = useState(EMPTY_BARS);
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
if (!isListening) {
|
|
227
|
+
setBars(EMPTY_BARS);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
setBars((previousBars) => {
|
|
231
|
+
const nextBars = previousBars.slice(1);
|
|
232
|
+
nextBars.push(audioLevel);
|
|
233
|
+
return nextBars;
|
|
234
|
+
});
|
|
235
|
+
}, [audioLevel, isListening]);
|
|
144
236
|
if (!isListening) return null;
|
|
145
237
|
return /* @__PURE__ */ jsx("div", {
|
|
146
238
|
className: "cursor-buddy-waveform",
|
|
147
|
-
children:
|
|
239
|
+
children: bars.map((level) => Math.pow(level, .65)).map((level, i) => {
|
|
148
240
|
const baseHeight = 4;
|
|
149
|
-
const variance =
|
|
241
|
+
const variance = .75 + (i + 1) % 3 * .12;
|
|
150
242
|
return /* @__PURE__ */ jsx("div", {
|
|
151
243
|
className: "cursor-buddy-waveform-bar",
|
|
152
|
-
style: { height: `${baseHeight +
|
|
244
|
+
style: { height: `${baseHeight + level * 20 * variance}px` }
|
|
153
245
|
}, i);
|
|
154
246
|
})
|
|
155
247
|
});
|
|
@@ -209,81 +301,6 @@ function Overlay({ cursor, speechBubble, waveform, container }) {
|
|
|
209
301
|
return createPortal(overlayContent, portalContainer);
|
|
210
302
|
}
|
|
211
303
|
//#endregion
|
|
212
|
-
//#region src/react/use-hotkey.ts
|
|
213
|
-
/**
|
|
214
|
-
* Parse a hotkey string like "ctrl+alt" into modifier flags
|
|
215
|
-
*/
|
|
216
|
-
function parseHotkey(hotkey) {
|
|
217
|
-
const parts = hotkey.toLowerCase().split("+");
|
|
218
|
-
return {
|
|
219
|
-
ctrl: parts.includes("ctrl") || parts.includes("control"),
|
|
220
|
-
alt: parts.includes("alt") || parts.includes("option"),
|
|
221
|
-
shift: parts.includes("shift"),
|
|
222
|
-
meta: parts.includes("meta") || parts.includes("cmd") || parts.includes("command")
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
/**
|
|
226
|
-
* Check if a keyboard event matches the required modifiers
|
|
227
|
-
*/
|
|
228
|
-
function matchesHotkey(event, modifiers) {
|
|
229
|
-
return event.ctrlKey === modifiers.ctrl && event.altKey === modifiers.alt && event.shiftKey === modifiers.shift && event.metaKey === modifiers.meta;
|
|
230
|
-
}
|
|
231
|
-
/**
|
|
232
|
-
* Hook for detecting push-to-talk hotkey press/release.
|
|
233
|
-
*
|
|
234
|
-
* @param hotkey - Hotkey string like "ctrl+alt" or "ctrl+shift"
|
|
235
|
-
* @param onPress - Called when hotkey is pressed
|
|
236
|
-
* @param onRelease - Called when hotkey is released
|
|
237
|
-
* @param enabled - Whether the hotkey listener is active (default: true)
|
|
238
|
-
*/
|
|
239
|
-
function useHotkey(hotkey, onPress, onRelease, enabled = true) {
|
|
240
|
-
const isPressedRef = useRef(false);
|
|
241
|
-
const modifiersRef = useRef(parseHotkey(hotkey));
|
|
242
|
-
const onPressRef = useRef(onPress);
|
|
243
|
-
const onReleaseRef = useRef(onRelease);
|
|
244
|
-
onPressRef.current = onPress;
|
|
245
|
-
onReleaseRef.current = onRelease;
|
|
246
|
-
useEffect(() => {
|
|
247
|
-
modifiersRef.current = parseHotkey(hotkey);
|
|
248
|
-
}, [hotkey]);
|
|
249
|
-
useEffect(() => {
|
|
250
|
-
if (!enabled) {
|
|
251
|
-
if (isPressedRef.current) {
|
|
252
|
-
isPressedRef.current = false;
|
|
253
|
-
onReleaseRef.current();
|
|
254
|
-
}
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
function handleKeyDown(event) {
|
|
258
|
-
if (matchesHotkey(event, modifiersRef.current) && !isPressedRef.current) {
|
|
259
|
-
isPressedRef.current = true;
|
|
260
|
-
event.preventDefault();
|
|
261
|
-
onPressRef.current();
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
function handleKeyUp(event) {
|
|
265
|
-
if (isPressedRef.current && !matchesHotkey(event, modifiersRef.current)) {
|
|
266
|
-
isPressedRef.current = false;
|
|
267
|
-
onReleaseRef.current();
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
function handleBlur() {
|
|
271
|
-
if (isPressedRef.current) {
|
|
272
|
-
isPressedRef.current = false;
|
|
273
|
-
onReleaseRef.current();
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
window.addEventListener("keydown", handleKeyDown);
|
|
277
|
-
window.addEventListener("keyup", handleKeyUp);
|
|
278
|
-
window.addEventListener("blur", handleBlur);
|
|
279
|
-
return () => {
|
|
280
|
-
window.removeEventListener("keydown", handleKeyDown);
|
|
281
|
-
window.removeEventListener("keyup", handleKeyUp);
|
|
282
|
-
window.removeEventListener("blur", handleBlur);
|
|
283
|
-
};
|
|
284
|
-
}, [enabled]);
|
|
285
|
-
}
|
|
286
|
-
//#endregion
|
|
287
304
|
//#region src/react/components/CursorBuddy.tsx
|
|
288
305
|
/**
|
|
289
306
|
* Internal component that sets up hotkey handling
|
|
@@ -303,8 +320,9 @@ function CursorBuddyInner({ hotkey = "ctrl+alt", cursor, speechBubble, waveform,
|
|
|
303
320
|
*
|
|
304
321
|
* Adds an AI-powered cursor companion to your app. Users hold the hotkey
|
|
305
322
|
* (default: Ctrl+Alt) to speak. The SDK captures a screenshot, transcribes
|
|
306
|
-
* the
|
|
307
|
-
*
|
|
323
|
+
* speech in the browser or on the server based on the configured mode, sends
|
|
324
|
+
* it to the AI, speaks the response in the browser or on the server based on
|
|
325
|
+
* the configured mode, and can point at elements on screen.
|
|
308
326
|
*
|
|
309
327
|
* @example
|
|
310
328
|
* ```tsx
|
|
@@ -320,9 +338,11 @@ function CursorBuddyInner({ hotkey = "ctrl+alt", cursor, speechBubble, waveform,
|
|
|
320
338
|
* }
|
|
321
339
|
* ```
|
|
322
340
|
*/
|
|
323
|
-
function CursorBuddy({ endpoint, hotkey, container, cursor, speechBubble, waveform, onTranscript, onResponse, onPoint, onStateChange, onError }) {
|
|
341
|
+
function CursorBuddy({ endpoint, hotkey, container, speech, transcription, cursor, speechBubble, waveform, onTranscript, onResponse, onPoint, onStateChange, onError }) {
|
|
324
342
|
return /* @__PURE__ */ jsx(CursorBuddyProvider, {
|
|
325
343
|
endpoint,
|
|
344
|
+
speech,
|
|
345
|
+
transcription,
|
|
326
346
|
onTranscript,
|
|
327
347
|
onResponse,
|
|
328
348
|
onPoint,
|
package/dist/react/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":["styles"],"sources":["../../src/react/styles.css?inline","../../src/react/utils/inject-styles.ts","../../src/react/provider.tsx","../../src/react/hooks.ts","../../src/react/components/Cursor.tsx","../../src/react/components/SpeechBubble.tsx","../../src/react/components/Waveform.tsx","../../src/react/components/Overlay.tsx","../../src/react/use-hotkey.ts","../../src/react/components/CursorBuddy.tsx"],"sourcesContent":["export 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\";","// Import CSS as string - need to configure bundler for this\nimport styles from \"../styles.css?inline\"\n\nconst STYLE_ID = \"cursor-buddy-styles\"\n\nlet injected = false\n\n/**\n * Inject cursor buddy styles into the document head.\n * Safe to call multiple times - will only inject once.\n * No-op during SSR.\n */\nexport function injectStyles(): void {\n // Skip on server\n if (typeof document === \"undefined\") return\n\n // Skip if already injected\n if (injected) return\n\n // Check if style tag already exists (e.g., from a previous mount)\n if (document.getElementById(STYLE_ID)) {\n injected = true\n return\n }\n\n const head = document.head || document.getElementsByTagName(\"head\")[0]\n const style = document.createElement(\"style\")\n style.id = STYLE_ID\n style.textContent = styles\n\n // Insert at the beginning so user styles can override\n if (head.firstChild) {\n head.insertBefore(style, head.firstChild)\n } else {\n head.appendChild(style)\n }\n\n injected = true\n}\n","\"use client\"\n\nimport { createContext, useContext, useState, useEffect } from \"react\"\nimport { CursorBuddyClient } from \"../core/client\"\nimport { $cursorPosition } from \"../core/atoms\"\nimport { injectStyles } from \"./utils/inject-styles\"\nimport type { CursorBuddyClientOptions } from \"../core/types\"\n\nconst CursorBuddyContext = createContext<CursorBuddyClient | null>(null)\n\nexport interface CursorBuddyProviderProps extends CursorBuddyClientOptions {\n /** API endpoint for cursor buddy server */\n endpoint: string\n /** Children */\n children: React.ReactNode\n}\n\n/**\n * Provider for cursor buddy. Creates and manages the client instance.\n */\nexport function CursorBuddyProvider({\n endpoint,\n children,\n onTranscript,\n onResponse,\n onPoint,\n onStateChange,\n onError,\n}: CursorBuddyProviderProps) {\n const [client] = useState(\n () =>\n new CursorBuddyClient(endpoint, {\n onTranscript,\n onResponse,\n onPoint,\n onStateChange,\n onError,\n })\n )\n\n // Inject styles on mount\n useEffect(() => {\n injectStyles()\n }, [])\n\n // Track cursor position\n useEffect(() => {\n function handleMouseMove(event: MouseEvent) {\n $cursorPosition.set({ x: event.clientX, y: event.clientY })\n client.updateCursorPosition()\n }\n\n window.addEventListener(\"mousemove\", handleMouseMove)\n return () => window.removeEventListener(\"mousemove\", handleMouseMove)\n }, [client])\n\n return (\n <CursorBuddyContext.Provider value={client}>\n {children}\n </CursorBuddyContext.Provider>\n )\n}\n\n/**\n * Get the cursor buddy client from context.\n * @internal\n */\nexport function useClient(): CursorBuddyClient {\n const client = useContext(CursorBuddyContext)\n if (!client) {\n throw new Error(\"useCursorBuddy must be used within CursorBuddyProvider\")\n }\n return client\n}\n","\"use client\"\n\nimport { useSyncExternalStore, useCallback } from \"react\"\nimport { useStore } from \"@nanostores/react\"\nimport { $audioLevel } from \"../core/atoms\"\nimport { useClient } from \"./provider\"\nimport type { VoiceState } from \"../core/types\"\n\nexport interface UseCursorBuddyReturn {\n /** Current voice state */\n state: VoiceState\n /** Latest transcribed user speech */\n transcript: string\n /** Latest AI response (stripped of POINT tags) */\n response: string\n /** Current audio level (0-1) */\n audioLevel: number\n /** Whether the buddy is enabled */\n isEnabled: boolean\n /** Whether currently engaged with a pointing target */\n isPointing: boolean\n /** Current error (null if none) */\n error: Error | null\n\n /** Start listening (called automatically by hotkey) */\n startListening: () => void\n /** Stop listening and process (called automatically by hotkey release) */\n stopListening: () => void\n /** Enable or disable the buddy */\n setEnabled: (enabled: boolean) => void\n /** Manually point at coordinates */\n pointAt: (x: number, y: number, label: string) => void\n /** Dismiss the current pointing target */\n dismissPointing: () => void\n /** Reset to idle state */\n reset: () => void\n}\n\n/**\n * Hook to access cursor buddy state and actions.\n */\nexport function useCursorBuddy(): UseCursorBuddyReturn {\n const client = useClient()\n\n const subscribe = useCallback(\n (listener: () => void) => client.subscribe(listener),\n [client]\n )\n const getSnapshot = useCallback(() => client.getSnapshot(), [client])\n\n const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)\n\n const audioLevel = useStore($audioLevel)\n\n return {\n ...snapshot,\n audioLevel,\n startListening: useCallback(() => client.startListening(), [client]),\n stopListening: useCallback(() => client.stopListening(), [client]),\n setEnabled: useCallback(\n (enabled: boolean) => client.setEnabled(enabled),\n [client]\n ),\n pointAt: useCallback(\n (x: number, y: number, label: string) => client.pointAt(x, y, label),\n [client]\n ),\n dismissPointing: useCallback(() => client.dismissPointing(), [client]),\n reset: useCallback(() => client.reset(), [client]),\n }\n}\n","import type { CursorRenderProps } from \"../../core/types\";\n\n// -30 degrees ≈ -0.52 radians (standard cursor tilt)\nconst BASE_ROTATION = -Math.PI / 6;\n\n/**\n * Default cursor component - a colored triangle pointer.\n * Color and animations change based on voice state via CSS classes.\n */\nexport function DefaultCursor({ state, rotation, scale }: CursorRenderProps) {\n const stateClass = `cursor-buddy-cursor--${state}`;\n\n return (\n <svg\n width=\"32\"\n height=\"32\"\n viewBox=\"0 0 32 32\"\n className={`cursor-buddy-cursor ${stateClass}`}\n style={{\n transform: `rotate(${BASE_ROTATION + rotation}rad) scale(${scale})`,\n }}\n >\n <polygon points=\"16,4 28,28 16,22 4,28\" />\n </svg>\n );\n}\n","import type { SpeechBubbleRenderProps } from \"../../core/types\"\n\n/**\n * Default speech bubble component.\n * Displays pointing label or response text next to the cursor.\n */\nexport function DefaultSpeechBubble({\n text,\n isVisible,\n onClick,\n}: SpeechBubbleRenderProps) {\n if (!isVisible || !text) return null\n\n return (\n <div\n className=\"cursor-buddy-bubble\"\n onClick={onClick}\n onKeyDown={(event) => {\n if (event.key === \"Enter\" || event.key === \" \") {\n event.preventDefault()\n onClick?.()\n }\n }}\n role=\"button\"\n tabIndex={0}\n >\n {text}\n </div>\n )\n}\n","import type { WaveformRenderProps } from \"../../core/types\"\n\nconst BAR_COUNT = 5\n\n/**\n * Default waveform component.\n * Shows audio level visualization during recording.\n */\nexport function DefaultWaveform({\n audioLevel,\n isListening,\n}: WaveformRenderProps) {\n if (!isListening) return null\n\n return (\n <div className=\"cursor-buddy-waveform\">\n {Array.from({ length: BAR_COUNT }).map((_, i) => {\n // Create varied heights based on audio level and bar position\n const baseHeight = 4\n const variance = Math.sin((i / BAR_COUNT) * Math.PI) * 0.5 + 0.5\n const height = baseHeight + audioLevel * 16 * variance\n\n return (\n <div\n key={i}\n className=\"cursor-buddy-waveform-bar\"\n style={{ height: `${height}px` }}\n />\n )\n })}\n </div>\n )\n}\n","\"use client\"\n\nimport { useState, useEffect } from \"react\"\nimport { createPortal } from \"react-dom\"\nimport { useStore } from \"@nanostores/react\"\nimport {\n $buddyPosition,\n $buddyRotation,\n $buddyScale,\n $audioLevel,\n $pointingTarget,\n} from \"../../core/atoms\"\nimport { useCursorBuddy } from \"../hooks\"\nimport { DefaultCursor } from \"./Cursor\"\nimport { DefaultSpeechBubble } from \"./SpeechBubble\"\nimport { DefaultWaveform } from \"./Waveform\"\nimport type {\n CursorRenderProps,\n SpeechBubbleRenderProps,\n WaveformRenderProps,\n} from \"../../core/types\"\n\nexport interface OverlayProps {\n /** Custom cursor renderer */\n cursor?: React.ReactNode | ((props: CursorRenderProps) => React.ReactNode)\n /** Custom speech bubble renderer */\n speechBubble?: (props: SpeechBubbleRenderProps) => React.ReactNode\n /** Custom waveform renderer */\n waveform?: (props: WaveformRenderProps) => React.ReactNode\n /** Container element for portal (defaults to document.body) */\n container?: HTMLElement | null\n}\n\n/**\n * Overlay component that renders the cursor, speech bubble, and waveform.\n * Uses React portal to render at the document body level.\n */\nexport function Overlay({\n cursor,\n speechBubble,\n waveform,\n container,\n}: OverlayProps) {\n // Only render after mount to avoid hydration mismatch\n const [isMounted, setIsMounted] = useState(false)\n useEffect(() => setIsMounted(true), [])\n\n const { state, isPointing, isEnabled, dismissPointing } = useCursorBuddy()\n\n const buddyPosition = useStore($buddyPosition)\n const buddyRotation = useStore($buddyRotation)\n const buddyScale = useStore($buddyScale)\n const audioLevel = useStore($audioLevel)\n const pointingTarget = useStore($pointingTarget)\n\n // Don't render on server or when disabled\n if (!isMounted || !isEnabled) return null\n\n const cursorProps: CursorRenderProps = {\n state,\n isPointing,\n rotation: buddyRotation,\n scale: buddyScale,\n }\n\n const speechBubbleProps: SpeechBubbleRenderProps = {\n text: pointingTarget?.label ?? \"\",\n isVisible: isPointing && !!pointingTarget,\n onClick: dismissPointing,\n }\n\n const waveformProps: WaveformRenderProps = {\n audioLevel,\n isListening: state === \"listening\",\n }\n\n // Render cursor element\n const cursorElement =\n typeof cursor === \"function\" ? (\n cursor(cursorProps)\n ) : cursor ? (\n cursor\n ) : (\n <DefaultCursor {...cursorProps} />\n )\n\n // Render speech bubble element\n const speechBubbleElement = speechBubble ? (\n speechBubble(speechBubbleProps)\n ) : (\n <DefaultSpeechBubble {...speechBubbleProps} />\n )\n\n // Render waveform element\n const waveformElement = waveform ? (\n waveform(waveformProps)\n ) : (\n <DefaultWaveform {...waveformProps} />\n )\n\n const overlayContent = (\n <div className=\"cursor-buddy-overlay\" data-cursor-buddy-overlay>\n <div\n className=\"cursor-buddy-container\"\n style={{\n left: buddyPosition.x,\n top: buddyPosition.y,\n }}\n >\n {cursorElement}\n {state === \"listening\" && waveformElement}\n {isPointing && speechBubbleElement}\n </div>\n </div>\n )\n\n const portalContainer =\n container ?? (typeof document !== \"undefined\" ? document.body : null)\n\n if (!portalContainer) return null\n\n return createPortal(overlayContent, portalContainer)\n}\n","\"use client\"\n\nimport { useEffect, useRef } from \"react\"\n\ninterface HotkeyModifiers {\n ctrl: boolean\n alt: boolean\n shift: boolean\n meta: boolean\n}\n\n/**\n * Parse a hotkey string like \"ctrl+alt\" into modifier flags\n */\nfunction parseHotkey(hotkey: string): HotkeyModifiers {\n const parts = hotkey.toLowerCase().split(\"+\")\n return {\n ctrl: parts.includes(\"ctrl\") || parts.includes(\"control\"),\n alt: parts.includes(\"alt\") || parts.includes(\"option\"),\n shift: parts.includes(\"shift\"),\n meta:\n parts.includes(\"meta\") ||\n parts.includes(\"cmd\") ||\n parts.includes(\"command\"),\n }\n}\n\n/**\n * Check if a keyboard event matches the required modifiers\n */\nfunction matchesHotkey(\n event: KeyboardEvent,\n modifiers: HotkeyModifiers\n): boolean {\n return (\n event.ctrlKey === modifiers.ctrl &&\n event.altKey === modifiers.alt &&\n event.shiftKey === modifiers.shift &&\n event.metaKey === modifiers.meta\n )\n}\n\n/**\n * Hook for detecting push-to-talk hotkey press/release.\n *\n * @param hotkey - Hotkey string like \"ctrl+alt\" or \"ctrl+shift\"\n * @param onPress - Called when hotkey is pressed\n * @param onRelease - Called when hotkey is released\n * @param enabled - Whether the hotkey listener is active (default: true)\n */\nexport function useHotkey(\n hotkey: string,\n onPress: () => void,\n onRelease: () => void,\n enabled: boolean = true\n): void {\n const isPressedRef = useRef(false)\n const modifiersRef = useRef<HotkeyModifiers>(parseHotkey(hotkey))\n\n // Use refs for callbacks to avoid stale closures in event handlers\n const onPressRef = useRef(onPress)\n const onReleaseRef = useRef(onRelease)\n onPressRef.current = onPress\n onReleaseRef.current = onRelease\n\n // Update modifiers when hotkey changes\n useEffect(() => {\n modifiersRef.current = parseHotkey(hotkey)\n }, [hotkey])\n\n useEffect(() => {\n if (!enabled) {\n // If disabled while pressed, trigger release\n if (isPressedRef.current) {\n isPressedRef.current = false\n onReleaseRef.current()\n }\n return\n }\n\n function handleKeyDown(event: KeyboardEvent) {\n if (matchesHotkey(event, modifiersRef.current) && !isPressedRef.current) {\n isPressedRef.current = true\n event.preventDefault()\n onPressRef.current()\n }\n }\n\n function handleKeyUp(event: KeyboardEvent) {\n // Release when any required modifier is released\n if (isPressedRef.current && !matchesHotkey(event, modifiersRef.current)) {\n isPressedRef.current = false\n onReleaseRef.current()\n }\n }\n\n function handleBlur() {\n // Release if window loses focus while hotkey is pressed\n if (isPressedRef.current) {\n isPressedRef.current = false\n onReleaseRef.current()\n }\n }\n\n window.addEventListener(\"keydown\", handleKeyDown)\n window.addEventListener(\"keyup\", handleKeyUp)\n window.addEventListener(\"blur\", handleBlur)\n\n return () => {\n window.removeEventListener(\"keydown\", handleKeyDown)\n window.removeEventListener(\"keyup\", handleKeyUp)\n window.removeEventListener(\"blur\", handleBlur)\n }\n }, [enabled])\n}\n","\"use client\"\n\nimport {\n CursorBuddyProvider,\n type CursorBuddyProviderProps,\n} from \"../provider\"\nimport { Overlay, type OverlayProps } from \"./Overlay\"\nimport { useHotkey } from \"../use-hotkey\"\nimport { useCursorBuddy } from \"../hooks\"\nimport type { PointingTarget, VoiceState } from \"../../core/types\"\n\nexport interface CursorBuddyProps\n extends Pick<OverlayProps, \"cursor\" | \"speechBubble\" | \"waveform\"> {\n /** API endpoint for cursor buddy server */\n endpoint: string\n /** Hotkey for push-to-talk (default: \"ctrl+alt\") */\n hotkey?: string\n /** Container element for portal (defaults to document.body) */\n container?: HTMLElement | null\n /** Callback when transcript is ready */\n onTranscript?: (text: string) => void\n /** Callback when AI responds */\n onResponse?: (text: string) => void\n /** Callback when pointing at element */\n onPoint?: (target: PointingTarget) => void\n /** Callback when state changes */\n onStateChange?: (state: VoiceState) => void\n /** Callback when error occurs */\n onError?: (error: Error) => void\n}\n\n/**\n * Internal component that sets up hotkey handling\n */\nfunction CursorBuddyInner({\n hotkey = \"ctrl+alt\",\n cursor,\n speechBubble,\n waveform,\n container,\n}: Pick<\n CursorBuddyProps,\n \"hotkey\" | \"cursor\" | \"speechBubble\" | \"waveform\" | \"container\"\n>) {\n const { startListening, stopListening, isEnabled } = useCursorBuddy()\n\n // Set up hotkey\n useHotkey(hotkey, startListening, stopListening, isEnabled)\n\n return (\n <Overlay\n cursor={cursor}\n speechBubble={speechBubble}\n waveform={waveform}\n container={container}\n />\n )\n}\n\n/**\n * Drop-in cursor buddy component.\n *\n * Adds an AI-powered cursor companion to your app. Users hold the hotkey\n * (default: Ctrl+Alt) to speak. The SDK captures a screenshot, transcribes\n * the speech, sends it to the AI, speaks the response, and can point at\n * elements on screen.\n *\n * @example\n * ```tsx\n * import { CursorBuddy } from \"cursor-buddy/react\"\n *\n * function App() {\n * return (\n * <>\n * <YourApp />\n * <CursorBuddy endpoint=\"/api/cursor-buddy\" />\n * </>\n * )\n * }\n * ```\n */\nexport function CursorBuddy({\n endpoint,\n hotkey,\n container,\n cursor,\n speechBubble,\n waveform,\n onTranscript,\n onResponse,\n onPoint,\n onStateChange,\n onError,\n}: CursorBuddyProps) {\n return (\n <CursorBuddyProvider\n endpoint={endpoint}\n onTranscript={onTranscript}\n onResponse={onResponse}\n onPoint={onPoint}\n onStateChange={onStateChange}\n onError={onError}\n >\n <CursorBuddyInner\n hotkey={hotkey}\n cursor={cursor}\n speechBubble={speechBubble}\n waveform={waveform}\n container={container}\n />\n </CursorBuddyProvider>\n )\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":["styles"],"sources":["../../src/react/styles.css?inline","../../src/react/utils/inject-styles.ts","../../src/react/provider.tsx","../../src/react/hooks.ts","../../src/react/use-hotkey.ts","../../src/react/components/Cursor.tsx","../../src/react/components/SpeechBubble.tsx","../../src/react/components/Waveform.tsx","../../src/react/components/Overlay.tsx","../../src/react/components/CursorBuddy.tsx"],"sourcesContent":["export 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, -4px);\\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\";","// Import CSS as string - need to configure bundler for this\nimport styles from \"../styles.css?inline\"\n\nconst STYLE_ID = \"cursor-buddy-styles\"\n\nlet injected = false\n\n/**\n * Inject cursor buddy styles into the document head.\n * Safe to call multiple times - will only inject once.\n * No-op during SSR.\n */\nexport function injectStyles(): void {\n // Skip on server\n if (typeof document === \"undefined\") return\n\n // Skip if already injected\n if (injected) return\n\n // Check if style tag already exists (e.g., from a previous mount)\n if (document.getElementById(STYLE_ID)) {\n injected = true\n return\n }\n\n const head = document.head || document.getElementsByTagName(\"head\")[0]\n const style = document.createElement(\"style\")\n style.id = STYLE_ID\n style.textContent = styles\n\n // Insert at the beginning so user styles can override\n if (head.firstChild) {\n head.insertBefore(style, head.firstChild)\n } else {\n head.appendChild(style)\n }\n\n injected = true\n}\n","\"use client\"\n\nimport { createContext, useContext, useEffect, useState } from \"react\"\nimport { $cursorPosition } from \"../core/atoms\"\nimport { CursorBuddyClient } from \"../core/client\"\nimport type {\n CursorBuddyClientOptions,\n CursorBuddySpeechConfig,\n CursorBuddyTranscriptionConfig,\n} from \"../core/types\"\nimport { injectStyles } from \"./utils/inject-styles\"\n\nconst CursorBuddyContext = createContext<CursorBuddyClient | null>(null)\n\nexport interface CursorBuddyProviderProps extends CursorBuddyClientOptions {\n /** API endpoint for cursor buddy server */\n endpoint: string\n /** Transcription configuration */\n transcription?: CursorBuddyTranscriptionConfig\n /** Speech configuration */\n speech?: CursorBuddySpeechConfig\n /** Children */\n children: React.ReactNode\n}\n\n/**\n * Provider for cursor buddy. Creates and manages the client instance.\n */\nexport function CursorBuddyProvider({\n endpoint,\n transcription,\n speech,\n children,\n onTranscript,\n onResponse,\n onPoint,\n onStateChange,\n onError,\n}: CursorBuddyProviderProps) {\n const [client] = useState(\n () =>\n new CursorBuddyClient(endpoint, {\n onTranscript,\n onResponse,\n onPoint,\n onStateChange,\n onError,\n speech,\n transcription,\n }),\n )\n\n // Inject styles on mount\n useEffect(() => {\n injectStyles()\n }, [])\n\n // Track cursor position\n useEffect(() => {\n function handleMouseMove(event: MouseEvent) {\n $cursorPosition.set({ x: event.clientX, y: event.clientY })\n client.updateCursorPosition()\n }\n\n window.addEventListener(\"mousemove\", handleMouseMove)\n return () => window.removeEventListener(\"mousemove\", handleMouseMove)\n }, [client])\n\n return (\n <CursorBuddyContext.Provider value={client}>\n {children}\n </CursorBuddyContext.Provider>\n )\n}\n\n/**\n * Get the cursor buddy client from context.\n * @internal\n */\nexport function useClient(): CursorBuddyClient {\n const client = useContext(CursorBuddyContext)\n if (!client) {\n throw new Error(\"useCursorBuddy must be used within CursorBuddyProvider\")\n }\n return client\n}\n","\"use client\"\n\nimport { useStore } from \"@nanostores/react\"\nimport { useCallback, useSyncExternalStore } from \"react\"\nimport { $audioLevel } from \"../core/atoms\"\nimport type { VoiceState } from \"../core/types\"\nimport { useClient } from \"./provider\"\n\nexport interface UseCursorBuddyReturn {\n /** Current voice state */\n state: VoiceState\n /** In-progress transcript while browser transcription is listening */\n liveTranscript: string\n /** Latest transcribed user speech */\n transcript: string\n /** Latest AI response (stripped of POINT tags) */\n response: string\n /** Current audio level (0-1) */\n audioLevel: number\n /** Whether the buddy is enabled */\n isEnabled: boolean\n /** Whether currently engaged with a pointing target */\n isPointing: boolean\n /** Current error (null if none) */\n error: Error | null\n\n /** Start listening (called automatically by hotkey) */\n startListening: () => void\n /** Stop listening and process (called automatically by hotkey release) */\n stopListening: () => void\n /** Enable or disable the buddy */\n setEnabled: (enabled: boolean) => void\n /** Manually point at coordinates */\n pointAt: (x: number, y: number, label: string) => void\n /** Dismiss the current pointing target */\n dismissPointing: () => void\n /** Reset to idle state */\n reset: () => void\n}\n\n/**\n * Hook to access cursor buddy state and actions.\n */\nexport function useCursorBuddy(): UseCursorBuddyReturn {\n const client = useClient()\n\n const subscribe = useCallback(\n (listener: () => void) => client.subscribe(listener),\n [client],\n )\n const getSnapshot = useCallback(() => client.getSnapshot(), [client])\n\n const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)\n\n const audioLevel = useStore($audioLevel)\n\n return {\n ...snapshot,\n audioLevel,\n startListening: useCallback(() => client.startListening(), [client]),\n stopListening: useCallback(() => client.stopListening(), [client]),\n setEnabled: useCallback(\n (enabled: boolean) => client.setEnabled(enabled),\n [client],\n ),\n pointAt: useCallback(\n (x: number, y: number, label: string) => client.pointAt(x, y, label),\n [client],\n ),\n dismissPointing: useCallback(() => client.dismissPointing(), [client]),\n reset: useCallback(() => client.reset(), [client]),\n }\n}\n","\"use client\"\n\nimport { useEffect, useRef } from \"react\"\n\ninterface HotkeyModifiers {\n ctrl: boolean\n alt: boolean\n shift: boolean\n meta: boolean\n}\n\n/**\n * Parse a hotkey string like \"ctrl+alt\" into modifier flags\n */\nfunction parseHotkey(hotkey: string): HotkeyModifiers {\n const parts = hotkey.toLowerCase().split(\"+\")\n return {\n ctrl: parts.includes(\"ctrl\") || parts.includes(\"control\"),\n alt: parts.includes(\"alt\") || parts.includes(\"option\"),\n shift: parts.includes(\"shift\"),\n meta:\n parts.includes(\"meta\") ||\n parts.includes(\"cmd\") ||\n parts.includes(\"command\"),\n }\n}\n\n/**\n * Check if a keyboard event matches the required modifiers\n */\nfunction matchesHotkey(\n event: KeyboardEvent,\n modifiers: HotkeyModifiers,\n): boolean {\n return (\n event.ctrlKey === modifiers.ctrl &&\n event.altKey === modifiers.alt &&\n event.shiftKey === modifiers.shift &&\n event.metaKey === modifiers.meta\n )\n}\n\n/**\n * Hook for detecting push-to-talk hotkey press/release.\n *\n * @param hotkey - Hotkey string like \"ctrl+alt\" or \"ctrl+shift\"\n * @param onPress - Called when hotkey is pressed\n * @param onRelease - Called when hotkey is released\n * @param enabled - Whether the hotkey listener is active (default: true)\n */\nexport function useHotkey(\n hotkey: string,\n onPress: () => void,\n onRelease: () => void,\n enabled: boolean = true,\n): void {\n const isPressedRef = useRef(false)\n const modifiersRef = useRef<HotkeyModifiers>(parseHotkey(hotkey))\n\n // Use refs for callbacks to avoid stale closures in event handlers\n const onPressRef = useRef(onPress)\n const onReleaseRef = useRef(onRelease)\n onPressRef.current = onPress\n onReleaseRef.current = onRelease\n\n // Update modifiers when hotkey changes\n useEffect(() => {\n modifiersRef.current = parseHotkey(hotkey)\n }, [hotkey])\n\n useEffect(() => {\n if (!enabled) {\n // If disabled while pressed, trigger release\n if (isPressedRef.current) {\n isPressedRef.current = false\n onReleaseRef.current()\n }\n return\n }\n\n function handleKeyDown(event: KeyboardEvent) {\n if (matchesHotkey(event, modifiersRef.current) && !isPressedRef.current) {\n isPressedRef.current = true\n event.preventDefault()\n onPressRef.current()\n }\n }\n\n function handleKeyUp(event: KeyboardEvent) {\n // Release when any required modifier is released\n if (isPressedRef.current && !matchesHotkey(event, modifiersRef.current)) {\n isPressedRef.current = false\n onReleaseRef.current()\n }\n }\n\n function handleBlur() {\n // Release if window loses focus while hotkey is pressed\n if (isPressedRef.current) {\n isPressedRef.current = false\n onReleaseRef.current()\n }\n }\n\n window.addEventListener(\"keydown\", handleKeyDown)\n window.addEventListener(\"keyup\", handleKeyUp)\n window.addEventListener(\"blur\", handleBlur)\n\n return () => {\n window.removeEventListener(\"keydown\", handleKeyDown)\n window.removeEventListener(\"keyup\", handleKeyUp)\n window.removeEventListener(\"blur\", handleBlur)\n }\n }, [enabled])\n}\n","import type { CursorRenderProps } from \"../../core/types\"\n\n// -30 degrees ≈ -0.52 radians (standard cursor tilt)\nconst BASE_ROTATION = -Math.PI / 6\n\n/**\n * Default cursor component - a colored triangle pointer.\n * Color and animations change based on voice state via CSS classes.\n */\nexport function DefaultCursor({ state, rotation, scale }: CursorRenderProps) {\n const stateClass = `cursor-buddy-cursor--${state}`\n\n return (\n <svg\n width=\"32\"\n height=\"32\"\n viewBox=\"0 0 32 32\"\n className={`cursor-buddy-cursor ${stateClass}`}\n style={{\n transform: `rotate(${BASE_ROTATION + rotation}rad) scale(${scale})`,\n transformOrigin: \"16px 4px\",\n }}\n >\n <polygon points=\"16,4 28,28 16,22 4,28\" />\n </svg>\n )\n}\n","import type { SpeechBubbleRenderProps } from \"../../core/types\"\n\n/**\n * Default speech bubble component.\n * Displays pointing label or response text next to the cursor.\n */\nexport function DefaultSpeechBubble({\n text,\n isVisible,\n onClick,\n}: SpeechBubbleRenderProps) {\n if (!isVisible || !text) return null\n\n return (\n <div\n className=\"cursor-buddy-bubble\"\n onClick={onClick}\n onKeyDown={(event) => {\n if (event.key === \"Enter\" || event.key === \" \") {\n event.preventDefault()\n onClick?.()\n }\n }}\n role=\"button\"\n tabIndex={0}\n >\n {text}\n </div>\n )\n}\n","import { useEffect, useState } from \"react\"\nimport type { WaveformRenderProps } from \"../../core/types\"\n\nconst BAR_COUNT = 12\nconst EMPTY_BARS = Array.from({ length: BAR_COUNT }, () => 0)\n\n/**\n * Default waveform component.\n * Shows audio level visualization during recording.\n */\nexport function DefaultWaveform({\n audioLevel,\n isListening,\n}: WaveformRenderProps) {\n const [bars, setBars] = useState<number[]>(EMPTY_BARS)\n\n useEffect(() => {\n if (!isListening) {\n setBars(EMPTY_BARS)\n return\n }\n\n setBars((previousBars) => {\n const nextBars = previousBars.slice(1)\n nextBars.push(audioLevel)\n return nextBars\n })\n }, [audioLevel, isListening])\n\n if (!isListening) return null\n\n const displayBars = bars.map((level) => Math.pow(level, 0.65))\n\n return (\n <div className=\"cursor-buddy-waveform\">\n {displayBars.map((level, i) => {\n const baseHeight = 4\n const variance = 0.75 + ((i + 1) % 3) * 0.12\n const height = baseHeight + level * 20 * variance\n\n return (\n <div\n key={i}\n className=\"cursor-buddy-waveform-bar\"\n style={{ height: `${height}px` }}\n />\n )\n })}\n </div>\n )\n}\n","\"use client\"\n\nimport { useStore } from \"@nanostores/react\"\nimport { useEffect, useState } from \"react\"\nimport { createPortal } from \"react-dom\"\nimport {\n $audioLevel,\n $buddyPosition,\n $buddyRotation,\n $buddyScale,\n $pointingTarget,\n} from \"../../core/atoms\"\nimport type {\n CursorRenderProps,\n SpeechBubbleRenderProps,\n WaveformRenderProps,\n} from \"../../core/types\"\nimport { useCursorBuddy } from \"../hooks\"\nimport { DefaultCursor } from \"./Cursor\"\nimport { DefaultSpeechBubble } from \"./SpeechBubble\"\nimport { DefaultWaveform } from \"./Waveform\"\n\nexport interface OverlayProps {\n /** Custom cursor renderer */\n cursor?: React.ReactNode | ((props: CursorRenderProps) => React.ReactNode)\n /** Custom speech bubble renderer */\n speechBubble?: (props: SpeechBubbleRenderProps) => React.ReactNode\n /** Custom waveform renderer */\n waveform?: (props: WaveformRenderProps) => React.ReactNode\n /** Container element for portal (defaults to document.body) */\n container?: HTMLElement | null\n}\n\n/**\n * Overlay component that renders the cursor, speech bubble, and waveform.\n * Uses React portal to render at the document body level.\n */\nexport function Overlay({\n cursor,\n speechBubble,\n waveform,\n container,\n}: OverlayProps) {\n // Only render after mount to avoid hydration mismatch\n const [isMounted, setIsMounted] = useState(false)\n useEffect(() => setIsMounted(true), [])\n\n const { state, isPointing, isEnabled, dismissPointing } = useCursorBuddy()\n\n const buddyPosition = useStore($buddyPosition)\n const buddyRotation = useStore($buddyRotation)\n const buddyScale = useStore($buddyScale)\n const audioLevel = useStore($audioLevel)\n const pointingTarget = useStore($pointingTarget)\n\n // Don't render on server or when disabled\n if (!isMounted || !isEnabled) return null\n\n const cursorProps: CursorRenderProps = {\n state,\n isPointing,\n rotation: buddyRotation,\n scale: buddyScale,\n }\n\n const speechBubbleProps: SpeechBubbleRenderProps = {\n text: pointingTarget?.label ?? \"\",\n isVisible: isPointing && !!pointingTarget,\n onClick: dismissPointing,\n }\n\n const waveformProps: WaveformRenderProps = {\n audioLevel,\n isListening: state === \"listening\",\n }\n\n // Render cursor element\n const cursorElement =\n typeof cursor === \"function\" ? (\n cursor(cursorProps)\n ) : cursor ? (\n cursor\n ) : (\n <DefaultCursor {...cursorProps} />\n )\n\n // Render speech bubble element\n const speechBubbleElement = speechBubble ? (\n speechBubble(speechBubbleProps)\n ) : (\n <DefaultSpeechBubble {...speechBubbleProps} />\n )\n\n // Render waveform element\n const waveformElement = waveform ? (\n waveform(waveformProps)\n ) : (\n <DefaultWaveform {...waveformProps} />\n )\n\n const overlayContent = (\n <div className=\"cursor-buddy-overlay\" data-cursor-buddy-overlay>\n <div\n className=\"cursor-buddy-container\"\n style={{\n left: buddyPosition.x,\n top: buddyPosition.y,\n }}\n >\n {cursorElement}\n {state === \"listening\" && waveformElement}\n {isPointing && speechBubbleElement}\n </div>\n </div>\n )\n\n const portalContainer =\n container ?? (typeof document !== \"undefined\" ? document.body : null)\n\n if (!portalContainer) return null\n\n return createPortal(overlayContent, portalContainer)\n}\n","\"use client\"\n\nimport type {\n CursorBuddySpeechConfig,\n CursorBuddyTranscriptionConfig,\n PointingTarget,\n VoiceState,\n} from \"../../core/types\"\nimport { useCursorBuddy } from \"../hooks\"\nimport { CursorBuddyProvider } from \"../provider\"\nimport { useHotkey } from \"../use-hotkey\"\nimport { Overlay, type OverlayProps } from \"./Overlay\"\n\nexport interface CursorBuddyProps\n extends Pick<OverlayProps, \"cursor\" | \"speechBubble\" | \"waveform\"> {\n /** API endpoint for cursor buddy server */\n endpoint: string\n /** Hotkey for push-to-talk (default: \"ctrl+alt\") */\n hotkey?: string\n /** Container element for portal (defaults to document.body) */\n container?: HTMLElement | null\n /** Transcription configuration */\n transcription?: CursorBuddyTranscriptionConfig\n /** Speech configuration */\n speech?: CursorBuddySpeechConfig\n /** Callback when transcript is ready */\n onTranscript?: (text: string) => void\n /** Callback when AI responds */\n onResponse?: (text: string) => void\n /** Callback when pointing at element */\n onPoint?: (target: PointingTarget) => void\n /** Callback when state changes */\n onStateChange?: (state: VoiceState) => void\n /** Callback when error occurs */\n onError?: (error: Error) => void\n}\n\n/**\n * Internal component that sets up hotkey handling\n */\nfunction CursorBuddyInner({\n hotkey = \"ctrl+alt\",\n cursor,\n speechBubble,\n waveform,\n container,\n}: Pick<\n CursorBuddyProps,\n \"hotkey\" | \"cursor\" | \"speechBubble\" | \"waveform\" | \"container\"\n>) {\n const { startListening, stopListening, isEnabled } = useCursorBuddy()\n\n // Set up hotkey\n useHotkey(hotkey, startListening, stopListening, isEnabled)\n\n return (\n <Overlay\n cursor={cursor}\n speechBubble={speechBubble}\n waveform={waveform}\n container={container}\n />\n )\n}\n\n/**\n * Drop-in cursor buddy component.\n *\n * Adds an AI-powered cursor companion to your app. Users hold the hotkey\n * (default: Ctrl+Alt) to speak. The SDK captures a screenshot, transcribes\n * speech in the browser or on the server based on the configured mode, sends\n * it to the AI, speaks the response in the browser or on the server based on\n * the configured mode, and can point at elements on screen.\n *\n * @example\n * ```tsx\n * import { CursorBuddy } from \"cursor-buddy/react\"\n *\n * function App() {\n * return (\n * <>\n * <YourApp />\n * <CursorBuddy endpoint=\"/api/cursor-buddy\" />\n * </>\n * )\n * }\n * ```\n */\nexport function CursorBuddy({\n endpoint,\n hotkey,\n container,\n speech,\n transcription,\n cursor,\n speechBubble,\n waveform,\n onTranscript,\n onResponse,\n onPoint,\n onStateChange,\n onError,\n}: CursorBuddyProps) {\n return (\n <CursorBuddyProvider\n endpoint={endpoint}\n speech={speech}\n transcription={transcription}\n onTranscript={onTranscript}\n onResponse={onResponse}\n onPoint={onPoint}\n onStateChange={onStateChange}\n onError={onError}\n >\n <CursorBuddyInner\n hotkey={hotkey}\n cursor={cursor}\n speechBubble={speechBubble}\n waveform={waveform}\n container={container}\n />\n </CursorBuddyProvider>\n )\n}\n"],"mappings":"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"}
|
package/dist/server/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as CursorBuddyHandlerConfig, t as CursorBuddyHandler } from "../types-
|
|
1
|
+
import { n as CursorBuddyHandlerConfig, t as CursorBuddyHandler } from "../types-BxBhjZju.mjs";
|
|
2
2
|
|
|
3
3
|
//#region src/server/handler.d.ts
|
|
4
4
|
/**
|
|
@@ -16,7 +16,7 @@ import { n as CursorBuddyHandlerConfig, t as CursorBuddyHandler } from "../types
|
|
|
16
16
|
*
|
|
17
17
|
* const cursorBuddy = createCursorBuddyHandler({
|
|
18
18
|
* model: openai("gpt-4o"),
|
|
19
|
-
* speechModel: openai.speech("tts-1"),
|
|
19
|
+
* speechModel: openai.speech("tts-1"), // optional for browser-only speech
|
|
20
20
|
* transcriptionModel: openai.transcription("whisper-1"),
|
|
21
21
|
* })
|
|
22
22
|
* ```
|
|
@@ -28,7 +28,7 @@ declare function createCursorBuddyHandler(config: CursorBuddyHandlerConfig): Cur
|
|
|
28
28
|
* Default system prompt for the cursor buddy AI.
|
|
29
29
|
* Instructs the model on how to respond conversationally and use POINT tags.
|
|
30
30
|
*/
|
|
31
|
-
declare const DEFAULT_SYSTEM_PROMPT = "You are a helpful AI assistant that lives inside a web page as a cursor companion.\n\nYou can see screenshots of the user's viewport and hear their voice. Respond conversationally \u2014 your responses will be spoken aloud via text-to-speech, so keep them concise and natural.\n\n## Pointing at Elements\n\nWhen you want to direct the user's attention to something on screen, add a pointing tag at the END of your response:\n\n[POINT:x,y:label]\n\nWhere
|
|
31
|
+
declare const DEFAULT_SYSTEM_PROMPT = "You are a helpful AI assistant that lives inside a web page as a cursor companion.\n\nYou can see screenshots of the user's viewport and hear their voice. Respond conversationally \u2014 your responses will be spoken aloud via text-to-speech, so keep them concise and natural.\n\n## Pointing at Elements\n\nWhen you want to direct the user's attention to something on screen, add a pointing tag at the END of your response. Only ONE pointing tag is allowed per response.\n\n### Interactive Elements (Preferred)\nThe screenshot has numbered markers on interactive elements (buttons, links, inputs, etc.). Use the marker number to point at these:\n\n[POINT:marker_number:label]\n\nExample: \"Click this button right here. [POINT:5:Submit]\"\n\nThis is the most accurate pointing method \u2014 always prefer it when pointing at interactive elements.\n\n### Anywhere Else (Fallback)\nFor non-interactive content (text, images, areas without markers), use pixel coordinates:\n\n[POINT:x,y:label]\n\nWhere x,y are coordinates in screenshot image pixels (top-left origin).\n\nExample: \"The error message is shown here. [POINT:450,320:Error text]\"\n\n### Guidelines\n- Prefer marker-based pointing when the element has a visible number\n- Only use coordinates when pointing at unmarked content\n- Only point when it genuinely helps\n- Use natural descriptions (\"this button\", \"over here\", \"right there\")\n- Coordinates should be the CENTER of the element you're pointing at\n- Keep labels short (2-4 words)\n\n## Response Style\n\n- Be concise \u2014 aim for 1-3 sentences\n- Sound natural when spoken aloud\n- Avoid technical jargon unless the user is technical\n- If you can't see something clearly, say so\n- Never mention that you're looking at a \"screenshot\" \u2014 say \"I can see...\" or \"Looking at your screen...\"\n";
|
|
32
32
|
//#endregion
|
|
33
33
|
export { type CursorBuddyHandler, type CursorBuddyHandlerConfig, DEFAULT_SYSTEM_PROMPT, createCursorBuddyHandler };
|
|
34
34
|
//# sourceMappingURL=index.d.mts.map
|
package/dist/server/index.mjs
CHANGED
|
@@ -10,20 +10,31 @@ You can see screenshots of the user's viewport and hear their voice. Respond con
|
|
|
10
10
|
|
|
11
11
|
## Pointing at Elements
|
|
12
12
|
|
|
13
|
-
When you want to direct the user's attention to something on screen, add a pointing tag at the END of your response
|
|
13
|
+
When you want to direct the user's attention to something on screen, add a pointing tag at the END of your response. Only ONE pointing tag is allowed per response.
|
|
14
|
+
|
|
15
|
+
### Interactive Elements (Preferred)
|
|
16
|
+
The screenshot has numbered markers on interactive elements (buttons, links, inputs, etc.). Use the marker number to point at these:
|
|
17
|
+
|
|
18
|
+
[POINT:marker_number:label]
|
|
19
|
+
|
|
20
|
+
Example: "Click this button right here. [POINT:5:Submit]"
|
|
21
|
+
|
|
22
|
+
This is the most accurate pointing method — always prefer it when pointing at interactive elements.
|
|
23
|
+
|
|
24
|
+
### Anywhere Else (Fallback)
|
|
25
|
+
For non-interactive content (text, images, areas without markers), use pixel coordinates:
|
|
14
26
|
|
|
15
27
|
[POINT:x,y:label]
|
|
16
28
|
|
|
17
|
-
Where
|
|
18
|
-
- x,y are coordinates in screenshot image pixels (top-left origin)
|
|
19
|
-
- label is a brief description shown in a speech bubble
|
|
29
|
+
Where x,y are coordinates in screenshot image pixels (top-left origin).
|
|
20
30
|
|
|
21
|
-
Example: "The
|
|
31
|
+
Example: "The error message is shown here. [POINT:450,320:Error text]"
|
|
22
32
|
|
|
23
|
-
Guidelines
|
|
24
|
-
-
|
|
33
|
+
### Guidelines
|
|
34
|
+
- Prefer marker-based pointing when the element has a visible number
|
|
35
|
+
- Only use coordinates when pointing at unmarked content
|
|
36
|
+
- Only point when it genuinely helps
|
|
25
37
|
- Use natural descriptions ("this button", "over here", "right there")
|
|
26
|
-
- If the screenshot image size is provided in text, always point in that screenshot image pixel space.
|
|
27
38
|
- Coordinates should be the CENTER of the element you're pointing at
|
|
28
39
|
- Keep labels short (2-4 words)
|
|
29
40
|
|
|
@@ -41,12 +52,14 @@ Guidelines:
|
|
|
41
52
|
* Handle chat requests: screenshot + transcript → AI SSE stream
|
|
42
53
|
*/
|
|
43
54
|
async function handleChat(request, config) {
|
|
44
|
-
const { screenshot, transcript, history, capture } = await request.json();
|
|
55
|
+
const { screenshot, transcript, history, capture, markerContext } = await request.json();
|
|
45
56
|
const systemPrompt = typeof config.system === "function" ? config.system({ defaultPrompt: DEFAULT_SYSTEM_PROMPT }) : config.system ?? DEFAULT_SYSTEM_PROMPT;
|
|
46
57
|
const maxMessages = (config.maxHistory ?? 10) * 2;
|
|
47
58
|
const trimmedHistory = history.slice(-maxMessages);
|
|
48
|
-
const
|
|
49
|
-
|
|
59
|
+
const captureContextParts = [];
|
|
60
|
+
if (capture) captureContextParts.push(`Screenshot size: ${capture.width}x${capture.height} pixels.`);
|
|
61
|
+
if (markerContext) captureContextParts.push("", markerContext);
|
|
62
|
+
const captureContext = captureContextParts.length > 0 ? captureContextParts.join("\n") : null;
|
|
50
63
|
const messages = [...trimmedHistory.map((msg) => ({
|
|
51
64
|
role: msg.role,
|
|
52
65
|
content: msg.content
|
|
@@ -70,6 +83,7 @@ If you include a [POINT:x,y:label] tag, x and y MUST use that screenshot image p
|
|
|
70
83
|
return streamText({
|
|
71
84
|
model: config.model,
|
|
72
85
|
system: systemPrompt,
|
|
86
|
+
providerOptions: config?.modelProviderMetadata,
|
|
73
87
|
messages,
|
|
74
88
|
tools: config.tools
|
|
75
89
|
}).toTextStreamResponse();
|
|
@@ -80,6 +94,10 @@ If you include a [POINT:x,y:label] tag, x and y MUST use that screenshot image p
|
|
|
80
94
|
* Handle transcription requests: audio file → text
|
|
81
95
|
*/
|
|
82
96
|
async function handleTranscribe(request, config) {
|
|
97
|
+
if (!config.transcriptionModel) return new Response(JSON.stringify({ error: "Server transcription is not configured. Provide a transcriptionModel or use browser transcription only." }), {
|
|
98
|
+
status: 501,
|
|
99
|
+
headers: { "Content-Type": "application/json" }
|
|
100
|
+
});
|
|
83
101
|
const audioFile = (await request.formData()).get("audio");
|
|
84
102
|
if (!audioFile || !(audioFile instanceof File)) return new Response(JSON.stringify({ error: "No audio file provided" }), {
|
|
85
103
|
status: 400,
|
|
@@ -98,6 +116,11 @@ async function handleTranscribe(request, config) {
|
|
|
98
116
|
* Handle TTS requests: text → audio
|
|
99
117
|
*/
|
|
100
118
|
async function handleTTS(request, config) {
|
|
119
|
+
if (!config.speechModel) return new Response(JSON.stringify({ error: "Server speech is not configured. Provide a speechModel or use browser speech only." }), {
|
|
120
|
+
status: 501,
|
|
121
|
+
headers: { "Content-Type": "application/json" }
|
|
122
|
+
});
|
|
123
|
+
const outputFormat = "wav";
|
|
101
124
|
const { text } = await request.json();
|
|
102
125
|
if (!text) return new Response(JSON.stringify({ error: "No text provided" }), {
|
|
103
126
|
status: 400,
|
|
@@ -105,10 +128,11 @@ async function handleTTS(request, config) {
|
|
|
105
128
|
});
|
|
106
129
|
const result = await experimental_generateSpeech({
|
|
107
130
|
model: config.speechModel,
|
|
108
|
-
text
|
|
131
|
+
text,
|
|
132
|
+
outputFormat
|
|
109
133
|
});
|
|
110
134
|
const audioData = new Uint8Array(result.audio.uint8Array);
|
|
111
|
-
return new Response(audioData, { headers: { "Content-Type": "audio/
|
|
135
|
+
return new Response(audioData, { headers: { "Content-Type": "audio/wav" } });
|
|
112
136
|
}
|
|
113
137
|
//#endregion
|
|
114
138
|
//#region src/server/handler.ts
|
|
@@ -127,7 +151,7 @@ async function handleTTS(request, config) {
|
|
|
127
151
|
*
|
|
128
152
|
* const cursorBuddy = createCursorBuddyHandler({
|
|
129
153
|
* model: openai("gpt-4o"),
|
|
130
|
-
* speechModel: openai.speech("tts-1"),
|
|
154
|
+
* speechModel: openai.speech("tts-1"), // optional for browser-only speech
|
|
131
155
|
* transcriptionModel: openai.transcription("whisper-1"),
|
|
132
156
|
* })
|
|
133
157
|
* ```
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":["transcribe","generateSpeech"],"sources":["../../src/server/system-prompt.ts","../../src/server/routes/chat.ts","../../src/server/routes/transcribe.ts","../../src/server/routes/tts.ts","../../src/server/handler.ts"],"sourcesContent":["/**\n * Default system prompt for the cursor buddy AI.\n * Instructs the model on how to respond conversationally and use POINT tags.\n */\nexport const DEFAULT_SYSTEM_PROMPT = `You are a helpful AI assistant that lives inside a web page as a cursor companion.\n\nYou can see screenshots of the user's viewport and hear their voice. Respond conversationally — your responses will be spoken aloud via text-to-speech, so keep them concise and natural.\n\n## Pointing at Elements\n\nWhen you want to direct the user's attention to something on screen, add a pointing tag at the END of your response:\n\n[POINT:x,y:label]\n\nWhere:\n- x,y are coordinates in screenshot image pixels (top-left origin)\n- label is a brief description shown in a speech bubble\n\nExample: \"The submit button is right here. [POINT:450,320:Submit button]\"\n\nGuidelines:\n- Only point when it genuinely helps (showing a specific button, field, or element)\n- Use natural descriptions (\"this button\", \"over here\", \"right there\")\n- If the screenshot image size is provided in text, always point in that screenshot image pixel space.\n- Coordinates should be the CENTER of the element you're pointing at\n- Keep labels short (2-4 words)\n\n## Response Style\n\n- Be concise — aim for 1-3 sentences\n- Sound natural when spoken aloud\n- Avoid technical jargon unless the user is technical\n- If you can't see something clearly, say so\n- Never mention that you're looking at a \"screenshot\" — say \"I can see...\" or \"Looking at your screen...\"\n`\n","import { streamText } from \"ai\"\nimport type { CursorBuddyHandlerConfig, ChatRequestBody } from \"../types\"\nimport { DEFAULT_SYSTEM_PROMPT } from \"../system-prompt\"\n\n/**\n * Handle chat requests: screenshot + transcript → AI SSE stream\n */\nexport async function handleChat(\n request: Request,\n config: CursorBuddyHandlerConfig\n): Promise<Response> {\n const body = (await request.json()) as ChatRequestBody\n const { screenshot, transcript, history, capture } = body\n\n // Resolve system prompt (string or function)\n const systemPrompt =\n typeof config.system === \"function\"\n ? config.system({ defaultPrompt: DEFAULT_SYSTEM_PROMPT })\n : config.system ?? DEFAULT_SYSTEM_PROMPT\n\n // Trim history to maxHistory (default 10 exchanges = 20 messages)\n const maxMessages = (config.maxHistory ?? 10) * 2\n const trimmedHistory = history.slice(-maxMessages)\n\n const captureContext = capture\n ? `The screenshot image size is ${capture.width}x${capture.height} pixels.\nIf you include a [POINT:x,y:label] tag, x and y MUST use that screenshot image pixel space.`\n : null\n\n // Build messages array with vision content\n const messages = [\n ...trimmedHistory.map((msg) => ({\n role: msg.role as \"user\" | \"assistant\",\n content: msg.content,\n })),\n {\n role: \"user\" as const,\n content: [\n ...(captureContext\n ? [\n {\n type: \"text\" as const,\n text: captureContext,\n },\n ]\n : []),\n {\n type: \"image\" as const,\n image: screenshot,\n },\n {\n type: \"text\" as const,\n text: transcript,\n },\n ],\n },\n ]\n\n const result = streamText({\n model: config.model,\n system: systemPrompt,\n messages,\n tools: config.tools,\n })\n\n return result.toTextStreamResponse()\n}\n","import { experimental_transcribe as transcribe } from \"ai\"\nimport type { CursorBuddyHandlerConfig, TranscribeResponse } from \"../types\"\n\n/**\n * Handle transcription requests: audio file → text\n */\nexport async function handleTranscribe(\n request: Request,\n config: CursorBuddyHandlerConfig\n): Promise<Response> {\n const formData = await request.formData()\n const audioFile = formData.get(\"audio\")\n\n if (!audioFile || !(audioFile instanceof File)) {\n return new Response(JSON.stringify({ error: \"No audio file provided\" }), {\n status: 400,\n headers: { \"Content-Type\": \"application/json\" },\n })\n }\n\n const audioBuffer = await audioFile.arrayBuffer()\n\n const result = await transcribe({\n model: config.transcriptionModel,\n audio: new Uint8Array(audioBuffer),\n })\n\n const response: TranscribeResponse = { text: result.text }\n\n return new Response(JSON.stringify(response), {\n headers: { \"Content-Type\": \"application/json\" },\n })\n}\n","import { experimental_generateSpeech as generateSpeech } from \"ai\"\nimport type { CursorBuddyHandlerConfig, TTSRequestBody } from \"../types\"\n\n/**\n * Handle TTS requests: text → audio\n */\nexport async function handleTTS(\n request: Request,\n config: CursorBuddyHandlerConfig\n): Promise<Response> {\n const body = (await request.json()) as TTSRequestBody\n const { text } = body\n\n if (!text) {\n return new Response(JSON.stringify({ error: \"No text provided\" }), {\n status: 400,\n headers: { \"Content-Type\": \"application/json\" },\n })\n }\n\n const result = await generateSpeech({\n model: config.speechModel,\n text,\n })\n\n // Create a new ArrayBuffer copy to satisfy TypeScript's strict typing\n const audioData = new Uint8Array(result.audio.uint8Array)\n\n return new Response(audioData, {\n headers: {\n \"Content-Type\": \"audio/mpeg\",\n },\n })\n}\n","import type { CursorBuddyHandlerConfig, CursorBuddyHandler } from \"./types\"\nimport { handleChat } from \"./routes/chat\"\nimport { handleTranscribe } from \"./routes/transcribe\"\nimport { handleTTS } from \"./routes/tts\"\n\n/**\n * Create a cursor buddy request handler.\n *\n * The handler responds to three routes based on the last path segment:\n * - /chat - Screenshot + transcript → AI SSE stream\n * - /transcribe - Audio → text\n * - /tts - Text → audio\n *\n * @example\n * ```ts\n * import { createCursorBuddyHandler } from \"cursor-buddy/server\"\n * import { openai } from \"@ai-sdk/openai\"\n *\n * const cursorBuddy = createCursorBuddyHandler({\n * model: openai(\"gpt-4o\"),\n * speechModel: openai.speech(\"tts-1\"),\n * transcriptionModel: openai.transcription(\"whisper-1\"),\n * })\n * ```\n */\nexport function createCursorBuddyHandler(\n config: CursorBuddyHandlerConfig\n): CursorBuddyHandler {\n const handler = async (request: Request): Promise<Response> => {\n const url = new URL(request.url)\n const pathSegments = url.pathname.split(\"/\").filter(Boolean)\n const route = pathSegments[pathSegments.length - 1]\n\n switch (route) {\n case \"chat\":\n return handleChat(request, config)\n\n case \"transcribe\":\n return handleTranscribe(request, config)\n\n case \"tts\":\n return handleTTS(request, config)\n\n default:\n return new Response(\n JSON.stringify({\n error: \"Not found\",\n availableRoutes: [\"/chat\", \"/transcribe\", \"/tts\"],\n }),\n {\n status: 404,\n headers: { \"Content-Type\": \"application/json\" },\n }\n )\n }\n }\n\n return { handler, config }\n}\n"],"mappings":";;;;;;AAIA,MAAa,wBAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;ACGrC,eAAsB,WACpB,SACA,QACmB;CAEnB,MAAM,EAAE,YAAY,YAAY,SAAS,YAD3B,MAAM,QAAQ,MAAM;CAIlC,MAAM,eACJ,OAAO,OAAO,WAAW,aACrB,OAAO,OAAO,EAAE,eAAe,uBAAuB,CAAC,GACvD,OAAO,UAAU;CAGvB,MAAM,eAAe,OAAO,cAAc,MAAM;CAChD,MAAM,iBAAiB,QAAQ,MAAM,CAAC,YAAY;CAElD,MAAM,iBAAiB,UACnB,gCAAgC,QAAQ,MAAM,GAAG,QAAQ,OAAO;+FAEhE;CAGJ,MAAM,WAAW,CACf,GAAG,eAAe,KAAK,SAAS;EAC9B,MAAM,IAAI;EACV,SAAS,IAAI;EACd,EAAE,EACH;EACE,MAAM;EACN,SAAS;GACP,GAAI,iBACA,CACE;IACE,MAAM;IACN,MAAM;IACP,CACF,GACD,EAAE;GACN;IACE,MAAM;IACN,OAAO;IACR;GACD;IACE,MAAM;IACN,MAAM;IACP;GACF;EACF,CACF;AASD,QAPe,WAAW;EACxB,OAAO,OAAO;EACd,QAAQ;EACR;EACA,OAAO,OAAO;EACf,CAAC,CAEY,sBAAsB;;;;;;;AC3DtC,eAAsB,iBACpB,SACA,QACmB;CAEnB,MAAM,aADW,MAAM,QAAQ,UAAU,EACd,IAAI,QAAQ;AAEvC,KAAI,CAAC,aAAa,EAAE,qBAAqB,MACvC,QAAO,IAAI,SAAS,KAAK,UAAU,EAAE,OAAO,0BAA0B,CAAC,EAAE;EACvE,QAAQ;EACR,SAAS,EAAE,gBAAgB,oBAAoB;EAChD,CAAC;CAGJ,MAAM,cAAc,MAAM,UAAU,aAAa;CAOjD,MAAM,WAA+B,EAAE,OALxB,MAAMA,wBAAW;EAC9B,OAAO,OAAO;EACd,OAAO,IAAI,WAAW,YAAY;EACnC,CAAC,EAEkD,MAAM;AAE1D,QAAO,IAAI,SAAS,KAAK,UAAU,SAAS,EAAE,EAC5C,SAAS,EAAE,gBAAgB,oBAAoB,EAChD,CAAC;;;;;;;ACzBJ,eAAsB,UACpB,SACA,QACmB;CAEnB,MAAM,EAAE,SADM,MAAM,QAAQ,MAAM;AAGlC,KAAI,CAAC,KACH,QAAO,IAAI,SAAS,KAAK,UAAU,EAAE,OAAO,oBAAoB,CAAC,EAAE;EACjE,QAAQ;EACR,SAAS,EAAE,gBAAgB,oBAAoB;EAChD,CAAC;CAGJ,MAAM,SAAS,MAAMC,4BAAe;EAClC,OAAO,OAAO;EACd;EACD,CAAC;CAGF,MAAM,YAAY,IAAI,WAAW,OAAO,MAAM,WAAW;AAEzD,QAAO,IAAI,SAAS,WAAW,EAC7B,SAAS,EACP,gBAAgB,cACjB,EACF,CAAC;;;;;;;;;;;;;;;;;;;;;;;;ACPJ,SAAgB,yBACd,QACoB;CACpB,MAAM,UAAU,OAAO,YAAwC;EAE7D,MAAM,eADM,IAAI,IAAI,QAAQ,IAAI,CACP,SAAS,MAAM,IAAI,CAAC,OAAO,QAAQ;AAG5D,UAFc,aAAa,aAAa,SAAS,IAEjD;GACE,KAAK,OACH,QAAO,WAAW,SAAS,OAAO;GAEpC,KAAK,aACH,QAAO,iBAAiB,SAAS,OAAO;GAE1C,KAAK,MACH,QAAO,UAAU,SAAS,OAAO;GAEnC,QACE,QAAO,IAAI,SACT,KAAK,UAAU;IACb,OAAO;IACP,iBAAiB;KAAC;KAAS;KAAe;KAAO;IAClD,CAAC,EACF;IACE,QAAQ;IACR,SAAS,EAAE,gBAAgB,oBAAoB;IAChD,CACF;;;AAIP,QAAO;EAAE;EAAS;EAAQ"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":["transcribe","generateSpeech"],"sources":["../../src/server/system-prompt.ts","../../src/server/routes/chat.ts","../../src/server/routes/transcribe.ts","../../src/server/routes/tts.ts","../../src/server/handler.ts"],"sourcesContent":["/**\n * Default system prompt for the cursor buddy AI.\n * Instructs the model on how to respond conversationally and use POINT tags.\n */\nexport const DEFAULT_SYSTEM_PROMPT = `You are a helpful AI assistant that lives inside a web page as a cursor companion.\n\nYou can see screenshots of the user's viewport and hear their voice. Respond conversationally — your responses will be spoken aloud via text-to-speech, so keep them concise and natural.\n\n## Pointing at Elements\n\nWhen you want to direct the user's attention to something on screen, add a pointing tag at the END of your response. Only ONE pointing tag is allowed per response.\n\n### Interactive Elements (Preferred)\nThe screenshot has numbered markers on interactive elements (buttons, links, inputs, etc.). Use the marker number to point at these:\n\n[POINT:marker_number:label]\n\nExample: \"Click this button right here. [POINT:5:Submit]\"\n\nThis is the most accurate pointing method — always prefer it when pointing at interactive elements.\n\n### Anywhere Else (Fallback)\nFor non-interactive content (text, images, areas without markers), use pixel coordinates:\n\n[POINT:x,y:label]\n\nWhere x,y are coordinates in screenshot image pixels (top-left origin).\n\nExample: \"The error message is shown here. [POINT:450,320:Error text]\"\n\n### Guidelines\n- Prefer marker-based pointing when the element has a visible number\n- Only use coordinates when pointing at unmarked content\n- Only point when it genuinely helps\n- Use natural descriptions (\"this button\", \"over here\", \"right there\")\n- Coordinates should be the CENTER of the element you're pointing at\n- Keep labels short (2-4 words)\n\n## Response Style\n\n- Be concise — aim for 1-3 sentences\n- Sound natural when spoken aloud\n- Avoid technical jargon unless the user is technical\n- If you can't see something clearly, say so\n- Never mention that you're looking at a \"screenshot\" — say \"I can see...\" or \"Looking at your screen...\"\n`\n","import { streamText } from \"ai\"\nimport { DEFAULT_SYSTEM_PROMPT } from \"../system-prompt\"\nimport type { ChatRequestBody, CursorBuddyHandlerConfig } from \"../types\"\n\n/**\n * Handle chat requests: screenshot + transcript → AI SSE stream\n */\nexport async function handleChat(\n request: Request,\n config: CursorBuddyHandlerConfig,\n): Promise<Response> {\n const body = (await request.json()) as ChatRequestBody\n const { screenshot, transcript, history, capture, markerContext } = body\n\n // Resolve system prompt (string or function)\n const systemPrompt =\n typeof config.system === \"function\"\n ? config.system({ defaultPrompt: DEFAULT_SYSTEM_PROMPT })\n : (config.system ?? DEFAULT_SYSTEM_PROMPT)\n\n // Trim history to maxHistory (default 10 exchanges = 20 messages)\n const maxMessages = (config.maxHistory ?? 10) * 2\n const trimmedHistory = history.slice(-maxMessages)\n\n // Build capture context with marker information\n const captureContextParts: string[] = []\n\n if (capture) {\n captureContextParts.push(\n `Screenshot size: ${capture.width}x${capture.height} pixels.`,\n )\n }\n\n if (markerContext) {\n captureContextParts.push(\"\", markerContext)\n }\n\n const captureContext =\n captureContextParts.length > 0 ? captureContextParts.join(\"\\n\") : null\n\n // Build messages array with vision content\n const messages = [\n ...trimmedHistory.map((msg) => ({\n role: msg.role as \"user\" | \"assistant\",\n content: msg.content,\n })),\n {\n role: \"user\" as const,\n content: [\n ...(captureContext\n ? [\n {\n type: \"text\" as const,\n text: captureContext,\n },\n ]\n : []),\n {\n type: \"image\" as const,\n image: screenshot,\n },\n {\n type: \"text\" as const,\n text: transcript,\n },\n ],\n },\n ]\n\n const result = streamText({\n model: config.model,\n system: systemPrompt,\n providerOptions: config?.modelProviderMetadata,\n messages,\n tools: config.tools,\n })\n\n return result.toTextStreamResponse()\n}\n","import { experimental_transcribe as transcribe } from \"ai\"\nimport type { CursorBuddyHandlerConfig, TranscribeResponse } from \"../types\"\n\n/**\n * Handle transcription requests: audio file → text\n */\nexport async function handleTranscribe(\n request: Request,\n config: CursorBuddyHandlerConfig,\n): Promise<Response> {\n if (!config.transcriptionModel) {\n return new Response(\n JSON.stringify({\n error:\n \"Server transcription is not configured. Provide a transcriptionModel or use browser transcription only.\",\n }),\n {\n status: 501,\n headers: { \"Content-Type\": \"application/json\" },\n },\n )\n }\n\n const formData = await request.formData()\n const audioFile = formData.get(\"audio\")\n\n if (!audioFile || !(audioFile instanceof File)) {\n return new Response(JSON.stringify({ error: \"No audio file provided\" }), {\n status: 400,\n headers: { \"Content-Type\": \"application/json\" },\n })\n }\n\n const audioBuffer = await audioFile.arrayBuffer()\n\n const result = await transcribe({\n model: config.transcriptionModel,\n audio: new Uint8Array(audioBuffer),\n })\n\n const response: TranscribeResponse = { text: result.text }\n\n return new Response(JSON.stringify(response), {\n headers: { \"Content-Type\": \"application/json\" },\n })\n}\n","import { experimental_generateSpeech as generateSpeech } from \"ai\"\nimport type { CursorBuddyHandlerConfig, TTSRequestBody } from \"../types\"\n\n/**\n * Handle TTS requests: text → audio\n */\nexport async function handleTTS(\n request: Request,\n config: CursorBuddyHandlerConfig,\n): Promise<Response> {\n if (!config.speechModel) {\n return new Response(\n JSON.stringify({\n error:\n \"Server speech is not configured. Provide a speechModel or use browser speech only.\",\n }),\n {\n status: 501,\n headers: { \"Content-Type\": \"application/json\" },\n },\n )\n }\n\n const outputFormat = \"wav\"\n const body = (await request.json()) as TTSRequestBody\n const { text } = body\n\n if (!text) {\n return new Response(JSON.stringify({ error: \"No text provided\" }), {\n status: 400,\n headers: { \"Content-Type\": \"application/json\" },\n })\n }\n\n const result = await generateSpeech({\n model: config.speechModel,\n text,\n outputFormat,\n })\n\n // Create a new ArrayBuffer copy to satisfy TypeScript's strict typing\n const audioData = new Uint8Array(result.audio.uint8Array)\n\n return new Response(audioData, {\n headers: {\n \"Content-Type\": \"audio/wav\",\n },\n })\n}\n","import { handleChat } from \"./routes/chat\"\nimport { handleTranscribe } from \"./routes/transcribe\"\nimport { handleTTS } from \"./routes/tts\"\nimport type { CursorBuddyHandler, CursorBuddyHandlerConfig } from \"./types\"\n\n/**\n * Create a cursor buddy request handler.\n *\n * The handler responds to three routes based on the last path segment:\n * - /chat - Screenshot + transcript → AI SSE stream\n * - /transcribe - Audio → text\n * - /tts - Text → audio\n *\n * @example\n * ```ts\n * import { createCursorBuddyHandler } from \"cursor-buddy/server\"\n * import { openai } from \"@ai-sdk/openai\"\n *\n * const cursorBuddy = createCursorBuddyHandler({\n * model: openai(\"gpt-4o\"),\n * speechModel: openai.speech(\"tts-1\"), // optional for browser-only speech\n * transcriptionModel: openai.transcription(\"whisper-1\"),\n * })\n * ```\n */\nexport function createCursorBuddyHandler(\n config: CursorBuddyHandlerConfig,\n): CursorBuddyHandler {\n const handler = async (request: Request): Promise<Response> => {\n const url = new URL(request.url)\n const pathSegments = url.pathname.split(\"/\").filter(Boolean)\n const route = pathSegments[pathSegments.length - 1]\n\n switch (route) {\n case \"chat\":\n return handleChat(request, config)\n\n case \"transcribe\":\n return handleTranscribe(request, config)\n\n case \"tts\":\n return handleTTS(request, config)\n\n default:\n return new Response(\n JSON.stringify({\n error: \"Not found\",\n availableRoutes: [\"/chat\", \"/transcribe\", \"/tts\"],\n }),\n {\n status: 404,\n headers: { \"Content-Type\": \"application/json\" },\n },\n )\n }\n }\n\n return { handler, config }\n}\n"],"mappings":"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"}
|