@agent-native/toolkit 0.8.3 → 0.9.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/README.md +42 -3
- package/agent-native.eject.json +33 -0
- package/dist/chat-history/ChatHistoryRail.d.ts +19 -0
- package/dist/chat-history/ChatHistoryRail.d.ts.map +1 -0
- package/dist/chat-history/ChatHistoryRail.js +22 -0
- package/dist/chat-history/ChatHistoryRail.js.map +1 -0
- package/dist/chat-history/ChatHistoryRail.spec.d.ts +2 -0
- package/dist/chat-history/ChatHistoryRail.spec.d.ts.map +1 -0
- package/dist/chat-history/ChatHistoryRail.spec.js +81 -0
- package/dist/chat-history/ChatHistoryRail.spec.js.map +1 -0
- package/dist/chat-history/index.d.ts +1 -0
- package/dist/chat-history/index.d.ts.map +1 -1
- package/dist/chat-history/index.js +1 -0
- package/dist/chat-history/index.js.map +1 -1
- package/dist/chat-history.css +59 -0
- package/dist/composer/RealtimeVoiceMode.d.ts +5 -1
- package/dist/composer/RealtimeVoiceMode.d.ts.map +1 -1
- package/dist/composer/RealtimeVoiceMode.js +27 -8
- package/dist/composer/RealtimeVoiceMode.js.map +1 -1
- package/dist/composer/RealtimeVoiceMode.spec.js +51 -11
- package/dist/composer/RealtimeVoiceMode.spec.js.map +1 -1
- package/dist/composer/VoiceButton.d.ts +2 -0
- package/dist/composer/VoiceButton.d.ts.map +1 -1
- package/dist/composer/VoiceButton.js +33 -1
- package/dist/composer/VoiceButton.js.map +1 -1
- package/dist/composer/VoiceButton.spec.js +9 -1
- package/dist/composer/VoiceButton.spec.js.map +1 -1
- package/dist/composer/realtime-voice-audio-level.d.ts.map +1 -1
- package/dist/composer/realtime-voice-audio-level.js +2 -1
- package/dist/composer/realtime-voice-audio-level.js.map +1 -1
- package/dist/composer/realtime-voice-audio-level.spec.js +7 -0
- package/dist/composer/realtime-voice-audio-level.spec.js.map +1 -1
- package/dist/composer/useRealtimeVoiceMode.d.ts.map +1 -1
- package/dist/composer/useRealtimeVoiceMode.js +4 -4
- package/dist/composer/useRealtimeVoiceMode.js.map +1 -1
- package/dist/dashboard/DataTable.d.ts +20 -0
- package/dist/dashboard/DataTable.d.ts.map +1 -0
- package/dist/dashboard/DataTable.js +87 -0
- package/dist/dashboard/DataTable.js.map +1 -0
- package/dist/dashboard/DateRangePicker.d.ts +15 -0
- package/dist/dashboard/DateRangePicker.d.ts.map +1 -0
- package/dist/dashboard/DateRangePicker.js +15 -0
- package/dist/dashboard/DateRangePicker.js.map +1 -0
- package/dist/dashboard/GenericChartPanel.d.ts +57 -0
- package/dist/dashboard/GenericChartPanel.d.ts.map +1 -0
- package/dist/dashboard/GenericChartPanel.js +119 -0
- package/dist/dashboard/GenericChartPanel.js.map +1 -0
- package/dist/dashboard/GenericChartPanel.spec.d.ts +2 -0
- package/dist/dashboard/GenericChartPanel.spec.d.ts.map +1 -0
- package/dist/dashboard/GenericChartPanel.spec.js +41 -0
- package/dist/dashboard/GenericChartPanel.spec.js.map +1 -0
- package/dist/dashboard/MetricCard.d.ts +14 -0
- package/dist/dashboard/MetricCard.d.ts.map +1 -0
- package/dist/dashboard/MetricCard.js +10 -0
- package/dist/dashboard/MetricCard.js.map +1 -0
- package/dist/dashboard/StatsCard.d.ts +16 -0
- package/dist/dashboard/StatsCard.d.ts.map +1 -0
- package/dist/dashboard/StatsCard.js +8 -0
- package/dist/dashboard/StatsCard.js.map +1 -0
- package/dist/dashboard/dashboard-layout.d.ts +49 -0
- package/dist/dashboard/dashboard-layout.d.ts.map +1 -0
- package/dist/dashboard/dashboard-layout.js +231 -0
- package/dist/dashboard/dashboard-layout.js.map +1 -0
- package/dist/dashboard/dashboard-layout.spec.d.ts +2 -0
- package/dist/dashboard/dashboard-layout.spec.d.ts.map +1 -0
- package/dist/dashboard/dashboard-layout.spec.js +52 -0
- package/dist/dashboard/dashboard-layout.spec.js.map +1 -0
- package/dist/dashboard/index.d.ts +7 -0
- package/dist/dashboard/index.d.ts.map +1 -0
- package/dist/dashboard/index.js +7 -0
- package/dist/dashboard/index.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/styles.css +35 -0
- package/package.json +15 -1
- package/src/chat-history/ChatHistoryRail.spec.tsx +138 -0
- package/src/chat-history/ChatHistoryRail.tsx +89 -0
- package/src/chat-history/index.ts +5 -0
- package/src/chat-history.css +59 -0
- package/src/composer/RealtimeVoiceMode.spec.tsx +116 -11
- package/src/composer/RealtimeVoiceMode.tsx +106 -36
- package/src/composer/VoiceButton.spec.ts +17 -1
- package/src/composer/VoiceButton.tsx +49 -0
- package/src/composer/realtime-voice-audio-level.spec.ts +15 -0
- package/src/composer/realtime-voice-audio-level.ts +2 -1
- package/src/composer/useRealtimeVoiceMode.tsx +5 -3
- package/src/dashboard/DataTable.tsx +273 -0
- package/src/dashboard/DateRangePicker.tsx +57 -0
- package/src/dashboard/GenericChartPanel.spec.tsx +53 -0
- package/src/dashboard/GenericChartPanel.tsx +461 -0
- package/src/dashboard/MetricCard.tsx +54 -0
- package/src/dashboard/StatsCard.tsx +49 -0
- package/src/dashboard/dashboard-layout.spec.ts +79 -0
- package/src/dashboard/dashboard-layout.ts +329 -0
- package/src/dashboard/index.ts +6 -0
- package/src/index.ts +1 -0
- package/src/styles.css +35 -0
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
|
|
15
15
|
const copy: RealtimeVoiceModeCopy = {
|
|
16
16
|
entryButtonLabel: "Use microphone",
|
|
17
|
-
promptTitle: "
|
|
17
|
+
promptTitle: "Use your voice",
|
|
18
18
|
promptDescription:
|
|
19
19
|
"Voice mode keeps listening while the agent navigates and takes actions.",
|
|
20
20
|
setupTitle: "Set up voice mode",
|
|
@@ -23,8 +23,9 @@ const copy: RealtimeVoiceModeCopy = {
|
|
|
23
23
|
connectBuilder: "Connect Builder.io",
|
|
24
24
|
useOpenAiKey: "Add your own keys",
|
|
25
25
|
startWithOpenAiKey: "Start with OpenAI key",
|
|
26
|
-
startVoiceMode: "
|
|
27
|
-
keepDictating: "
|
|
26
|
+
startVoiceMode: "Real-time voice",
|
|
27
|
+
keepDictating: "Dictate",
|
|
28
|
+
rememberPreference: "Remember my preference",
|
|
28
29
|
showChat: "Show chat",
|
|
29
30
|
hideChat: "Hide chat",
|
|
30
31
|
endVoiceMode: "End voice mode",
|
|
@@ -127,23 +128,27 @@ describe("RealtimeVoiceMode", () => {
|
|
|
127
128
|
|
|
128
129
|
act(() => microphone?.click());
|
|
129
130
|
|
|
130
|
-
expect(document.body.textContent).toContain("
|
|
131
|
+
expect(document.body.textContent).toContain("Use your voice");
|
|
131
132
|
const prompt = document.querySelector<HTMLElement>('[role="dialog"]');
|
|
132
133
|
expect(prompt?.className).toContain(
|
|
133
|
-
"w-[min(calc(100vw-2rem),var(--radix-popover-content-available-width,
|
|
134
|
+
"w-[min(calc(100vw-2rem),var(--radix-popover-content-available-width,18rem),18rem)]",
|
|
134
135
|
);
|
|
135
|
-
expect(document.body.textContent).toContain(
|
|
136
|
+
expect(document.body.textContent).not.toContain(
|
|
137
|
+
"Voice mode keeps listening while the agent navigates and takes actions.",
|
|
138
|
+
);
|
|
139
|
+
expect(document.body.textContent).toContain("Dictate");
|
|
140
|
+
expect(document.body.textContent).toContain("Remember my preference");
|
|
136
141
|
expect(onStartVoiceMode).not.toHaveBeenCalled();
|
|
137
142
|
expect(onKeepDictating).not.toHaveBeenCalled();
|
|
138
143
|
|
|
139
144
|
const startVoiceMode = Array.from(
|
|
140
145
|
document.querySelectorAll<HTMLButtonElement>("button"),
|
|
141
|
-
).find((button) => button.textContent?.includes("
|
|
146
|
+
).find((button) => button.textContent?.includes("Real-time voice"));
|
|
142
147
|
act(() => startVoiceMode?.click());
|
|
143
148
|
|
|
144
149
|
expect(onStartVoiceMode).toHaveBeenCalledOnce();
|
|
145
150
|
expect(onKeepDictating).not.toHaveBeenCalled();
|
|
146
|
-
expect(document.body.textContent).not.toContain("
|
|
151
|
+
expect(document.body.textContent).not.toContain("Use your voice");
|
|
147
152
|
});
|
|
148
153
|
|
|
149
154
|
it("does not start realtime voice while provider readiness is unresolved", () => {
|
|
@@ -162,14 +167,14 @@ describe("RealtimeVoiceMode", () => {
|
|
|
162
167
|
|
|
163
168
|
const startVoiceMode = Array.from(
|
|
164
169
|
document.querySelectorAll<HTMLButtonElement>("button"),
|
|
165
|
-
).find((button) => button.textContent?.includes("
|
|
170
|
+
).find((button) => button.textContent?.includes("Real-time voice"));
|
|
166
171
|
expect(startVoiceMode?.disabled).toBe(true);
|
|
167
172
|
act(() => startVoiceMode?.click());
|
|
168
173
|
expect(onStartVoiceMode).not.toHaveBeenCalled();
|
|
169
174
|
|
|
170
175
|
const keepDictating = Array.from(
|
|
171
176
|
document.querySelectorAll<HTMLButtonElement>("button"),
|
|
172
|
-
).find((button) => button.textContent === "
|
|
177
|
+
).find((button) => button.textContent === "Dictate");
|
|
173
178
|
expect(keepDictating?.disabled).toBe(false);
|
|
174
179
|
});
|
|
175
180
|
|
|
@@ -188,13 +193,83 @@ describe("RealtimeVoiceMode", () => {
|
|
|
188
193
|
|
|
189
194
|
const keepDictating = Array.from(
|
|
190
195
|
document.querySelectorAll<HTMLButtonElement>("button"),
|
|
191
|
-
).find((button) => button.textContent === "
|
|
196
|
+
).find((button) => button.textContent === "Dictate");
|
|
192
197
|
act(() => keepDictating?.click());
|
|
193
198
|
|
|
194
199
|
expect(onKeepDictating).toHaveBeenCalledOnce();
|
|
195
200
|
expect(onStartVoiceMode).not.toHaveBeenCalled();
|
|
196
201
|
});
|
|
197
202
|
|
|
203
|
+
it("remembers the selected input mode only when requested", () => {
|
|
204
|
+
const onStartVoiceMode = vi.fn();
|
|
205
|
+
const onRememberPreference = vi.fn();
|
|
206
|
+
|
|
207
|
+
render(
|
|
208
|
+
<RealtimeVoiceModeEntry
|
|
209
|
+
copy={copy}
|
|
210
|
+
open
|
|
211
|
+
onStartVoiceMode={onStartVoiceMode}
|
|
212
|
+
onKeepDictating={vi.fn()}
|
|
213
|
+
onRememberPreference={onRememberPreference}
|
|
214
|
+
/>,
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const remember = document.querySelector<HTMLElement>('[role="checkbox"]');
|
|
218
|
+
act(() => remember?.click());
|
|
219
|
+
const realtime = Array.from(
|
|
220
|
+
document.querySelectorAll<HTMLButtonElement>("button"),
|
|
221
|
+
).find((button) => button.textContent?.includes("Real-time voice"));
|
|
222
|
+
act(() => realtime?.click());
|
|
223
|
+
|
|
224
|
+
expect(onRememberPreference).toHaveBeenCalledWith("realtime");
|
|
225
|
+
expect(onStartVoiceMode).toHaveBeenCalledOnce();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("bypasses the chooser for a remembered input mode", () => {
|
|
229
|
+
const onStartVoiceMode = vi.fn();
|
|
230
|
+
const onKeepDictating = vi.fn();
|
|
231
|
+
|
|
232
|
+
render(
|
|
233
|
+
<RealtimeVoiceModeEntry
|
|
234
|
+
copy={copy}
|
|
235
|
+
preferredMode="dictation"
|
|
236
|
+
onStartVoiceMode={onStartVoiceMode}
|
|
237
|
+
onKeepDictating={onKeepDictating}
|
|
238
|
+
/>,
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
const microphone = document.querySelector<HTMLButtonElement>(
|
|
242
|
+
'button[aria-label="Use microphone"]',
|
|
243
|
+
);
|
|
244
|
+
act(() => microphone?.click());
|
|
245
|
+
|
|
246
|
+
expect(onKeepDictating).toHaveBeenCalledOnce();
|
|
247
|
+
expect(onStartVoiceMode).not.toHaveBeenCalled();
|
|
248
|
+
expect(document.body.textContent).not.toContain("Use your voice");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("shows setup instead of starting a remembered realtime mode without a provider", () => {
|
|
252
|
+
const onStartVoiceMode = vi.fn();
|
|
253
|
+
|
|
254
|
+
render(
|
|
255
|
+
<RealtimeVoiceModeEntry
|
|
256
|
+
copy={copy}
|
|
257
|
+
setupRequired
|
|
258
|
+
preferredMode="realtime"
|
|
259
|
+
onStartVoiceMode={onStartVoiceMode}
|
|
260
|
+
onKeepDictating={vi.fn()}
|
|
261
|
+
/>,
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const microphone = document.querySelector<HTMLButtonElement>(
|
|
265
|
+
'button[aria-label="Use microphone"]',
|
|
266
|
+
);
|
|
267
|
+
act(() => microphone?.click());
|
|
268
|
+
|
|
269
|
+
expect(onStartVoiceMode).not.toHaveBeenCalled();
|
|
270
|
+
expect(document.body.textContent).toContain("Set up voice mode");
|
|
271
|
+
});
|
|
272
|
+
|
|
198
273
|
it("makes Builder the primary setup action and own keys the secondary", () => {
|
|
199
274
|
const onConnectBuilder = vi.fn();
|
|
200
275
|
const onUseOpenAiKey = vi.fn();
|
|
@@ -653,6 +728,10 @@ describe("RealtimeVoiceMode", () => {
|
|
|
653
728
|
expect(
|
|
654
729
|
document.querySelector('[data-realtime-voice-waveform="true"]'),
|
|
655
730
|
).not.toBeNull();
|
|
731
|
+
const centerBar = document.querySelectorAll<HTMLElement>(
|
|
732
|
+
'[data-realtime-voice-waveform="true"] > span',
|
|
733
|
+
)[2];
|
|
734
|
+
expect(centerBar?.style.transform).toBe("scaleY(0.76)");
|
|
656
735
|
|
|
657
736
|
act(() => audioLevels.set({ input: 0, output: 0.8 }));
|
|
658
737
|
expect(
|
|
@@ -687,6 +766,32 @@ describe("RealtimeVoiceMode", () => {
|
|
|
687
766
|
},
|
|
688
767
|
);
|
|
689
768
|
|
|
769
|
+
it("shines only while the agent is working", () => {
|
|
770
|
+
render(
|
|
771
|
+
<RealtimeVoiceModeDock
|
|
772
|
+
state="working"
|
|
773
|
+
copy={copy}
|
|
774
|
+
chatVisible={false}
|
|
775
|
+
onToggleChat={vi.fn()}
|
|
776
|
+
onEndVoiceMode={vi.fn()}
|
|
777
|
+
/>,
|
|
778
|
+
);
|
|
779
|
+
expect(
|
|
780
|
+
document.querySelector(".agent-realtime-voice-working"),
|
|
781
|
+
).not.toBeNull();
|
|
782
|
+
|
|
783
|
+
render(
|
|
784
|
+
<RealtimeVoiceModeDock
|
|
785
|
+
state="listening"
|
|
786
|
+
copy={copy}
|
|
787
|
+
chatVisible={false}
|
|
788
|
+
onToggleChat={vi.fn()}
|
|
789
|
+
onEndVoiceMode={vi.fn()}
|
|
790
|
+
/>,
|
|
791
|
+
);
|
|
792
|
+
expect(document.querySelector(".agent-realtime-voice-working")).toBeNull();
|
|
793
|
+
});
|
|
794
|
+
|
|
690
795
|
it("shows an unmistakable loader and ignores early audio until connected", () => {
|
|
691
796
|
const audioLevels = createRealtimeVoiceAudioLevelStore();
|
|
692
797
|
audioLevels.set({ input: 0.8, output: 0 });
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Button } from "@agent-native/toolkit/ui/button";
|
|
2
|
+
import { Checkbox } from "@agent-native/toolkit/ui/checkbox";
|
|
2
3
|
import {
|
|
3
4
|
Select,
|
|
4
5
|
SelectContent,
|
|
@@ -62,6 +63,7 @@ export interface RealtimeVoiceModeCopy {
|
|
|
62
63
|
startWithOpenAiKey: string;
|
|
63
64
|
startVoiceMode: string;
|
|
64
65
|
keepDictating: string;
|
|
66
|
+
rememberPreference: string;
|
|
65
67
|
showChat: string;
|
|
66
68
|
hideChat: string;
|
|
67
69
|
endVoiceMode: string;
|
|
@@ -111,6 +113,8 @@ export interface RealtimeVoiceModeEntryProps {
|
|
|
111
113
|
onOpenChange?: (open: boolean) => void;
|
|
112
114
|
onStartVoiceMode: () => void;
|
|
113
115
|
onKeepDictating: () => void;
|
|
116
|
+
preferredMode?: RealtimeVoiceInputMode | null;
|
|
117
|
+
onRememberPreference?: (mode: RealtimeVoiceInputMode) => void;
|
|
114
118
|
setupRequired?: boolean;
|
|
115
119
|
openAiConfigured?: boolean;
|
|
116
120
|
connectingBuilder?: boolean;
|
|
@@ -119,6 +123,8 @@ export interface RealtimeVoiceModeEntryProps {
|
|
|
119
123
|
className?: string;
|
|
120
124
|
}
|
|
121
125
|
|
|
126
|
+
export type RealtimeVoiceInputMode = "realtime" | "dictation";
|
|
127
|
+
|
|
122
128
|
/**
|
|
123
129
|
* Composer mic entry point for apps that support a full-duplex voice session.
|
|
124
130
|
* The first click offers voice mode without silently changing the existing
|
|
@@ -132,6 +138,8 @@ export function RealtimeVoiceModeEntry({
|
|
|
132
138
|
onOpenChange,
|
|
133
139
|
onStartVoiceMode,
|
|
134
140
|
onKeepDictating,
|
|
141
|
+
preferredMode = null,
|
|
142
|
+
onRememberPreference,
|
|
135
143
|
setupRequired = false,
|
|
136
144
|
openAiConfigured = false,
|
|
137
145
|
connectingBuilder = false,
|
|
@@ -143,6 +151,8 @@ export function RealtimeVoiceModeEntry({
|
|
|
143
151
|
const open = controlledOpen ?? uncontrolledOpen;
|
|
144
152
|
const titleId = useId();
|
|
145
153
|
const descriptionId = useId();
|
|
154
|
+
const rememberPreferenceId = useId();
|
|
155
|
+
const [rememberPreference, setRememberPreference] = useState(false);
|
|
146
156
|
const [collisionBoundary, setCollisionBoundary] =
|
|
147
157
|
useState<HTMLElement | null>(null);
|
|
148
158
|
|
|
@@ -159,8 +169,9 @@ export function RealtimeVoiceModeEntry({
|
|
|
159
169
|
onOpenChange?.(nextOpen);
|
|
160
170
|
};
|
|
161
171
|
|
|
162
|
-
const choose = (callback: () => void) => {
|
|
172
|
+
const choose = (mode: RealtimeVoiceInputMode, callback: () => void) => {
|
|
163
173
|
setOpen(false);
|
|
174
|
+
if (rememberPreference) onRememberPreference?.(mode);
|
|
164
175
|
callback();
|
|
165
176
|
};
|
|
166
177
|
|
|
@@ -177,6 +188,23 @@ export function RealtimeVoiceModeEntry({
|
|
|
177
188
|
disabled={disabled}
|
|
178
189
|
aria-label={copy.entryButtonLabel}
|
|
179
190
|
aria-expanded={open}
|
|
191
|
+
onClick={(event) => {
|
|
192
|
+
if (!preferredMode) return;
|
|
193
|
+
if (
|
|
194
|
+
preferredMode === "realtime" &&
|
|
195
|
+
(providerStatusPending || setupRequired)
|
|
196
|
+
) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
event.preventDefault();
|
|
200
|
+
event.stopPropagation();
|
|
201
|
+
choose(
|
|
202
|
+
preferredMode,
|
|
203
|
+
preferredMode === "realtime"
|
|
204
|
+
? onStartVoiceMode
|
|
205
|
+
: onKeepDictating,
|
|
206
|
+
);
|
|
207
|
+
}}
|
|
180
208
|
className={cn(
|
|
181
209
|
"size-7 shrink-0 text-muted-foreground hover:text-foreground",
|
|
182
210
|
className,
|
|
@@ -197,43 +225,54 @@ export function RealtimeVoiceModeEntry({
|
|
|
197
225
|
collisionPadding={16}
|
|
198
226
|
data-collision-boundary={collisionBoundary ? "agent-panel" : "viewport"}
|
|
199
227
|
className={cn(
|
|
200
|
-
"p-4",
|
|
228
|
+
setupRequired ? "p-4" : "p-3",
|
|
201
229
|
setupRequired
|
|
202
230
|
? "w-[min(calc(100vw-2rem),var(--radix-popover-content-available-width,30rem),30rem)]"
|
|
203
|
-
: "w-[min(calc(100vw-2rem),var(--radix-popover-content-available-width,
|
|
231
|
+
: "w-[min(calc(100vw-2rem),var(--radix-popover-content-available-width,18rem),18rem)]",
|
|
204
232
|
)}
|
|
205
233
|
aria-labelledby={titleId}
|
|
206
|
-
aria-describedby={descriptionId}
|
|
234
|
+
aria-describedby={setupRequired ? descriptionId : undefined}
|
|
207
235
|
>
|
|
208
|
-
<div className="grid gap-3">
|
|
236
|
+
<div className={cn("grid", setupRequired ? "gap-3" : "gap-2.5")}>
|
|
209
237
|
<div className="grid gap-1">
|
|
210
238
|
<h2 id={titleId} className="text-sm font-semibold text-foreground">
|
|
211
239
|
{setupRequired ? copy.setupTitle : copy.promptTitle}
|
|
212
240
|
</h2>
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
241
|
+
{setupRequired ? (
|
|
242
|
+
<p
|
|
243
|
+
id={descriptionId}
|
|
244
|
+
className="text-sm leading-relaxed text-muted-foreground"
|
|
245
|
+
>
|
|
246
|
+
{copy.setupDescription}
|
|
247
|
+
</p>
|
|
248
|
+
) : null}
|
|
219
249
|
</div>
|
|
220
250
|
|
|
221
|
-
<div
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
</Button>
|
|
251
|
+
<div
|
|
252
|
+
className={cn(
|
|
253
|
+
"gap-2",
|
|
254
|
+
setupRequired
|
|
255
|
+
? "flex flex-col-reverse sm:flex-row sm:flex-wrap sm:justify-end"
|
|
256
|
+
: "grid",
|
|
257
|
+
)}
|
|
258
|
+
>
|
|
230
259
|
{setupRequired ? (
|
|
231
260
|
<>
|
|
261
|
+
<Button
|
|
262
|
+
type="button"
|
|
263
|
+
variant="ghost"
|
|
264
|
+
size="sm"
|
|
265
|
+
onClick={() => choose("dictation", onKeepDictating)}
|
|
266
|
+
>
|
|
267
|
+
{copy.keepDictating}
|
|
268
|
+
</Button>
|
|
232
269
|
<Button
|
|
233
270
|
type="button"
|
|
234
271
|
variant="outline"
|
|
235
272
|
size="sm"
|
|
236
|
-
onClick={() =>
|
|
273
|
+
onClick={() =>
|
|
274
|
+
choose("realtime", onUseOpenAiKey ?? onStartVoiceMode)
|
|
275
|
+
}
|
|
237
276
|
>
|
|
238
277
|
{openAiConfigured
|
|
239
278
|
? copy.startWithOpenAiKey
|
|
@@ -244,7 +283,9 @@ export function RealtimeVoiceModeEntry({
|
|
|
244
283
|
size="sm"
|
|
245
284
|
className="whitespace-nowrap"
|
|
246
285
|
disabled={connectingBuilder}
|
|
247
|
-
onClick={() =>
|
|
286
|
+
onClick={() =>
|
|
287
|
+
choose("realtime", onConnectBuilder ?? onStartVoiceMode)
|
|
288
|
+
}
|
|
248
289
|
>
|
|
249
290
|
{connectingBuilder ? (
|
|
250
291
|
<IconLoader2 className="animate-spin" />
|
|
@@ -253,21 +294,49 @@ export function RealtimeVoiceModeEntry({
|
|
|
253
294
|
</Button>
|
|
254
295
|
</>
|
|
255
296
|
) : (
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
297
|
+
<>
|
|
298
|
+
<Button
|
|
299
|
+
type="button"
|
|
300
|
+
size="sm"
|
|
301
|
+
className="w-full"
|
|
302
|
+
disabled={providerStatusPending}
|
|
303
|
+
onClick={() => choose("realtime", onStartVoiceMode)}
|
|
304
|
+
>
|
|
305
|
+
{providerStatusPending ? (
|
|
306
|
+
<IconLoader2 className="animate-spin" />
|
|
307
|
+
) : (
|
|
308
|
+
<IconMicrophone />
|
|
309
|
+
)}
|
|
310
|
+
{copy.startVoiceMode}
|
|
311
|
+
</Button>
|
|
312
|
+
<Button
|
|
313
|
+
type="button"
|
|
314
|
+
variant="outline"
|
|
315
|
+
size="sm"
|
|
316
|
+
className="w-full"
|
|
317
|
+
onClick={() => choose("dictation", onKeepDictating)}
|
|
318
|
+
>
|
|
319
|
+
{copy.keepDictating}
|
|
320
|
+
</Button>
|
|
321
|
+
</>
|
|
269
322
|
)}
|
|
270
323
|
</div>
|
|
324
|
+
|
|
325
|
+
{!setupRequired ? (
|
|
326
|
+
<label
|
|
327
|
+
htmlFor={rememberPreferenceId}
|
|
328
|
+
className="flex cursor-pointer items-center gap-2 text-xs text-muted-foreground"
|
|
329
|
+
>
|
|
330
|
+
<Checkbox
|
|
331
|
+
id={rememberPreferenceId}
|
|
332
|
+
checked={rememberPreference}
|
|
333
|
+
onCheckedChange={(checked) =>
|
|
334
|
+
setRememberPreference(checked === true)
|
|
335
|
+
}
|
|
336
|
+
/>
|
|
337
|
+
{copy.rememberPreference}
|
|
338
|
+
</label>
|
|
339
|
+
) : null}
|
|
271
340
|
</div>
|
|
272
341
|
</PopoverContent>
|
|
273
342
|
</Popover>
|
|
@@ -312,7 +381,7 @@ export interface RealtimeVoiceModeInlineSettings {
|
|
|
312
381
|
|
|
313
382
|
const SILENT_AUDIO_LEVELS = createRealtimeVoiceAudioLevelStore();
|
|
314
383
|
const WAVEFORM_WEIGHTS = [0.55, 0.82, 1, 0.82, 0.55];
|
|
315
|
-
const AUDIO_ACTIVITY_THRESHOLD = 0.
|
|
384
|
+
const AUDIO_ACTIVITY_THRESHOLD = 0.1;
|
|
316
385
|
|
|
317
386
|
function usePrefersReducedMotion(): boolean {
|
|
318
387
|
const [reduced, setReduced] = useState(false);
|
|
@@ -831,6 +900,7 @@ export function RealtimeVoiceModeDock({
|
|
|
831
900
|
className={cn(
|
|
832
901
|
"relative isolate size-16 overflow-visible rounded-full ring-1 backdrop-blur-xl transition-transform duration-150 ease-out focus-visible:ring-offset-2 active:scale-[0.97] motion-reduce:transition-none",
|
|
833
902
|
ORB_STATE_CLASSES[state],
|
|
903
|
+
state === "working" && "agent-realtime-voice-working",
|
|
834
904
|
)}
|
|
835
905
|
>
|
|
836
906
|
<span className="relative z-10 flex items-center justify-center">
|
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
isRealtimeVoiceSetupRequired,
|
|
5
|
+
normalizeVoiceInputPreference,
|
|
6
|
+
} from "./VoiceButton.js";
|
|
7
|
+
|
|
8
|
+
describe("normalizeVoiceInputPreference", () => {
|
|
9
|
+
it("accepts only persisted realtime and dictation choices", () => {
|
|
10
|
+
expect(normalizeVoiceInputPreference({ mode: "realtime" })).toBe(
|
|
11
|
+
"realtime",
|
|
12
|
+
);
|
|
13
|
+
expect(normalizeVoiceInputPreference({ mode: "dictation" })).toBe(
|
|
14
|
+
"dictation",
|
|
15
|
+
);
|
|
16
|
+
expect(normalizeVoiceInputPreference({ mode: "voice" })).toBeNull();
|
|
17
|
+
expect(normalizeVoiceInputPreference(null)).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
});
|
|
4
20
|
|
|
5
21
|
describe("isRealtimeVoiceSetupRequired", () => {
|
|
6
22
|
it("waits for the voice-specific provider status before prompting setup", () => {
|
|
@@ -14,9 +14,11 @@ import {
|
|
|
14
14
|
IconLoader2,
|
|
15
15
|
IconX,
|
|
16
16
|
} from "@tabler/icons-react";
|
|
17
|
+
import { useCallback, useEffect, useState } from "react";
|
|
17
18
|
|
|
18
19
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip.js";
|
|
19
20
|
import { RealtimeVoiceModeEntry } from "./RealtimeVoiceMode.js";
|
|
21
|
+
import type { RealtimeVoiceInputMode } from "./RealtimeVoiceMode.js";
|
|
20
22
|
import { useComposerRuntimeAdapters } from "./runtime-adapters.js";
|
|
21
23
|
import {
|
|
22
24
|
useRealtimeVoiceModeCopy,
|
|
@@ -24,6 +26,16 @@ import {
|
|
|
24
26
|
} from "./useRealtimeVoiceMode.js";
|
|
25
27
|
import type { VoiceDictationApi } from "./useVoiceDictation.js";
|
|
26
28
|
|
|
29
|
+
const VOICE_INPUT_PREFERENCE_KEY = "voice-input-preference";
|
|
30
|
+
|
|
31
|
+
export function normalizeVoiceInputPreference(
|
|
32
|
+
value: unknown,
|
|
33
|
+
): RealtimeVoiceInputMode | null {
|
|
34
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
35
|
+
const mode = (value as { mode?: unknown }).mode;
|
|
36
|
+
return mode === "realtime" || mode === "dictation" ? mode : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
27
39
|
function openOpenAiKeySettings(): void {
|
|
28
40
|
window.location.hash = "#secrets:OPENAI_API_KEY";
|
|
29
41
|
window.dispatchEvent(new Event("agent-panel:open"));
|
|
@@ -63,6 +75,41 @@ export function VoiceButton({ voice, isMac, disabled }: VoiceButtonProps) {
|
|
|
63
75
|
trackingFlow: "voice_transcription",
|
|
64
76
|
onConnected: () => voiceProviders.refresh(),
|
|
65
77
|
});
|
|
78
|
+
const [inputPreference, setInputPreference] = useState<{
|
|
79
|
+
ready: boolean;
|
|
80
|
+
mode: RealtimeVoiceInputMode | null;
|
|
81
|
+
}>({ ready: false, mode: null });
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
let cancelled = false;
|
|
85
|
+
void Promise.resolve(
|
|
86
|
+
adapters.voice!.readAppState!(VOICE_INPUT_PREFERENCE_KEY),
|
|
87
|
+
)
|
|
88
|
+
.then((value) => {
|
|
89
|
+
if (!cancelled) {
|
|
90
|
+
setInputPreference({
|
|
91
|
+
ready: true,
|
|
92
|
+
mode: normalizeVoiceInputPreference(value),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
.catch(() => {
|
|
97
|
+
if (!cancelled) setInputPreference({ ready: true, mode: null });
|
|
98
|
+
});
|
|
99
|
+
return () => {
|
|
100
|
+
cancelled = true;
|
|
101
|
+
};
|
|
102
|
+
}, [adapters]);
|
|
103
|
+
|
|
104
|
+
const rememberInputPreference = useCallback(
|
|
105
|
+
(mode: RealtimeVoiceInputMode) => {
|
|
106
|
+
setInputPreference({ ready: true, mode });
|
|
107
|
+
void Promise.resolve(
|
|
108
|
+
adapters.voice!.setAppState!(VOICE_INPUT_PREFERENCE_KEY, { mode }),
|
|
109
|
+
).catch(() => undefined);
|
|
110
|
+
},
|
|
111
|
+
[adapters],
|
|
112
|
+
);
|
|
66
113
|
|
|
67
114
|
if (!supported) return null;
|
|
68
115
|
|
|
@@ -90,6 +137,8 @@ export function VoiceButton({ voice, isMac, disabled }: VoiceButtonProps) {
|
|
|
90
137
|
}}
|
|
91
138
|
onStartVoiceMode={() => void realtimeVoice.start()}
|
|
92
139
|
onKeepDictating={() => void start()}
|
|
140
|
+
preferredMode={inputPreference.ready ? inputPreference.mode : null}
|
|
141
|
+
onRememberPreference={rememberInputPreference}
|
|
93
142
|
/>
|
|
94
143
|
);
|
|
95
144
|
}
|
|
@@ -12,6 +12,21 @@ describe("realtime voice audio levels", () => {
|
|
|
12
12
|
expect(normalizeRealtimeVoiceRms(new Uint8Array(32).fill(255))).toBe(1);
|
|
13
13
|
});
|
|
14
14
|
|
|
15
|
+
it("makes ordinary speech visibly responsive above the noise floor", () => {
|
|
16
|
+
const quietSpeech = Uint8Array.from({ length: 32 }, (_, index) =>
|
|
17
|
+
index % 2 === 0 ? 124 : 132,
|
|
18
|
+
);
|
|
19
|
+
const typicalSpeech = Uint8Array.from({ length: 32 }, (_, index) =>
|
|
20
|
+
index % 2 === 0 ? 120 : 136,
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
expect(normalizeRealtimeVoiceRms(quietSpeech)).toBeGreaterThan(0.3);
|
|
24
|
+
expect(normalizeRealtimeVoiceRms(typicalSpeech)).toBeGreaterThan(0.5);
|
|
25
|
+
expect(normalizeRealtimeVoiceRms(typicalSpeech)).toBeGreaterThan(
|
|
26
|
+
normalizeRealtimeVoiceRms(quietSpeech),
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
15
30
|
it("attacks faster than it decays", () => {
|
|
16
31
|
expect(smoothRealtimeVoiceLevel(0, 1)).toBe(0.55);
|
|
17
32
|
expect(smoothRealtimeVoiceLevel(1, 0)).toBe(0.8);
|
|
@@ -25,7 +25,8 @@ export function normalizeRealtimeVoiceRms(samples: Uint8Array): number {
|
|
|
25
25
|
sumSquares += normalized * normalized;
|
|
26
26
|
}
|
|
27
27
|
const rms = Math.sqrt(sumSquares / samples.length);
|
|
28
|
-
|
|
28
|
+
const activeLevel = clampLevel((rms - 0.012) / 0.18);
|
|
29
|
+
return Math.sqrt(activeLevel);
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
export function smoothRealtimeVoiceLevel(
|
|
@@ -1018,6 +1018,7 @@ function voiceCopy(t: (key: string) => string): RealtimeVoiceModeCopy {
|
|
|
1018
1018
|
startWithOpenAiKey: t("agentPanel.voiceMode.startWithOpenAiKey"),
|
|
1019
1019
|
startVoiceMode: t("agentPanel.voiceMode.start"),
|
|
1020
1020
|
keepDictating: t("agentPanel.voiceMode.keepDictating"),
|
|
1021
|
+
rememberPreference: t("agentPanel.voiceMode.rememberPreference"),
|
|
1021
1022
|
showChat: t("agentPanel.voiceMode.showChat"),
|
|
1022
1023
|
hideChat: t("agentPanel.voiceMode.hideChat"),
|
|
1023
1024
|
endVoiceMode: t("agentPanel.voiceMode.end"),
|
|
@@ -1353,7 +1354,7 @@ function useRealtimeVoiceModeController(
|
|
|
1353
1354
|
analyser.fftSize = 512;
|
|
1354
1355
|
const source = context.createMediaStreamSource(stream);
|
|
1355
1356
|
source.connect(analyser);
|
|
1356
|
-
const buffer = new Uint8Array(analyser.
|
|
1357
|
+
const buffer = new Uint8Array(analyser.fftSize);
|
|
1357
1358
|
if (channel === "input") {
|
|
1358
1359
|
inputSourceRef.current?.disconnect();
|
|
1359
1360
|
inputSourceRef.current = source;
|
|
@@ -1728,9 +1729,10 @@ function useRealtimeVoiceModeController(
|
|
|
1728
1729
|
audio.setAttribute("playsinline", "");
|
|
1729
1730
|
audioRef.current = audio;
|
|
1730
1731
|
peer.ontrack = (trackEvent) => {
|
|
1731
|
-
const remoteStream =
|
|
1732
|
+
const remoteStream =
|
|
1733
|
+
trackEvent.streams[0] ?? new MediaStream([trackEvent.track]);
|
|
1732
1734
|
audio.srcObject = remoteStream;
|
|
1733
|
-
|
|
1735
|
+
attachAudioMeter(remoteStream, "output");
|
|
1734
1736
|
void audio.play().catch(() => undefined);
|
|
1735
1737
|
};
|
|
1736
1738
|
|