@assistant-ui/react 0.15.28 → 0.15.30
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/adapters/OpenAIRealtimeVoiceAdapter.d.ts +22 -0
- package/dist/adapters/OpenAIRealtimeVoiceAdapter.d.ts.map +1 -0
- package/dist/adapters/OpenAIRealtimeVoiceAdapter.js +297 -0
- package/dist/adapters/OpenAIRealtimeVoiceAdapter.js.map +1 -0
- package/dist/context/stores/ThreadViewport.d.ts +8 -0
- package/dist/context/stores/ThreadViewport.d.ts.map +1 -1
- package/dist/context/stores/ThreadViewport.js +24 -8
- package/dist/context/stores/ThreadViewport.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/legacy-runtime/runtime-cores/utils/MessageRepository.js +1 -1
- package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
- package/dist/mcp-apps/McpAppRenderer.js +5 -1
- package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
- package/dist/model-context/frame/useAssistantFrameHost.d.ts.map +1 -1
- package/dist/model-context/frame/useAssistantFrameHost.js +67 -46
- package/dist/model-context/frame/useAssistantFrameHost.js.map +1 -1
- package/dist/model-context/makeAssistantVisible.js +1 -1
- package/dist/primitives/composer/ComposerAddAttachment.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerAddAttachment.js +28 -10
- package/dist/primitives/composer/ComposerAddAttachment.js.map +1 -1
- package/dist/primitives/composer/ComposerInput.d.ts +4 -1
- package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerInput.js +15 -2
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/composer/ComposerResume.d.ts +23 -0
- package/dist/primitives/composer/ComposerResume.d.ts.map +1 -0
- package/dist/primitives/composer/ComposerResume.js +34 -0
- package/dist/primitives/composer/ComposerResume.js.map +1 -0
- package/dist/primitives/composer/trigger/TriggerPopoverAction.js +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +1 -1
- package/dist/primitives/composer/trigger/triggerSelectionResource.d.ts +6 -2
- package/dist/primitives/composer/trigger/triggerSelectionResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerSelectionResource.js +4 -1
- package/dist/primitives/composer/trigger/triggerSelectionResource.js.map +1 -1
- package/dist/primitives/composer.d.ts +1 -0
- package/dist/primitives/composer.d.ts.map +1 -1
- package/dist/primitives/composer.js +3 -1
- package/dist/primitives/composer.js.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.js +32 -292
- package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
- package/dist/unstable/useMentionAdapter.js +1 -1
- package/dist/utils/Primitive.d.ts +14 -14
- package/dist/utils/hooks/useOnResizeContent.js +1 -1
- package/package.json +5 -5
- package/src/adapters/OpenAIRealtimeVoiceAdapter.test.ts +748 -0
- package/src/adapters/OpenAIRealtimeVoiceAdapter.ts +388 -0
- package/src/context/stores/ThreadViewport.ts +36 -8
- package/src/index.ts +6 -0
- package/src/mcp-apps/McpAppRenderer.test.tsx +65 -0
- package/src/mcp-apps/McpAppRenderer.tsx +13 -2
- package/src/model-context/frame/useAssistantFrameHost.detach.test.tsx +145 -0
- package/src/model-context/frame/useAssistantFrameHost.reload.test.tsx +106 -0
- package/src/model-context/frame/useAssistantFrameHost.test.tsx +3 -1
- package/src/model-context/frame/useAssistantFrameHost.ts +72 -24
- package/src/model-context/makeAssistantVisible.test.tsx +25 -37
- package/src/primitives/composer/ComposerAddAttachment.test.tsx +1 -0
- package/src/primitives/composer/ComposerAddAttachment.thread-scope.test.tsx +202 -0
- package/src/primitives/composer/ComposerAddAttachment.ts +11 -1
- package/src/primitives/composer/ComposerInput.focus-on-run-start.test.tsx +130 -0
- package/src/primitives/composer/ComposerInput.tsx +30 -4
- package/src/primitives/composer/ComposerResume.ts +36 -0
- package/src/primitives/composer/trigger/triggerSelectionResource.test.ts +24 -0
- package/src/primitives/composer/trigger/triggerSelectionResource.ts +14 -3
- package/src/primitives/composer/useAuiState.composer.test.tsx +84 -0
- package/src/primitives/composer.ts +1 -0
- package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +90 -0
- package/src/primitives/thread/useThreadViewportAutoScroll.ts +48 -486
- package/src/model-context/frame/AssistantFrame.test.ts +0 -400
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createVoiceSession,
|
|
3
|
+
type RealtimeVoiceAdapter,
|
|
4
|
+
} from "@assistant-ui/core";
|
|
5
|
+
|
|
6
|
+
/** Options for a browser WebRTC connection to OpenAI Realtime. */
|
|
7
|
+
export type OpenAIRealtimeVoiceAdapterOptions = {
|
|
8
|
+
/** Exchange the browser's SDP offer for an answer through your authenticated server. Keep the API key and session configuration on the server. */
|
|
9
|
+
exchangeSdp: (
|
|
10
|
+
sdp: string,
|
|
11
|
+
options: { signal: AbortSignal },
|
|
12
|
+
) => Promise<string>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const asRecord = (value: unknown): Record<string, unknown> | undefined =>
|
|
16
|
+
value !== null && typeof value === "object" && !Array.isArray(value)
|
|
17
|
+
? (value as Record<string, unknown>)
|
|
18
|
+
: undefined;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Connects browser microphone audio directly to OpenAI Realtime over WebRTC.
|
|
22
|
+
* Configure audio output, input transcription, and automatic turn detection on
|
|
23
|
+
* the server. Each connection starts a new conversation; existing thread history
|
|
24
|
+
* and assistant-ui tools are not sent to the voice model.
|
|
25
|
+
*/
|
|
26
|
+
export class OpenAIRealtimeVoiceAdapter implements RealtimeVoiceAdapter {
|
|
27
|
+
private readonly options: OpenAIRealtimeVoiceAdapterOptions;
|
|
28
|
+
|
|
29
|
+
constructor(options: OpenAIRealtimeVoiceAdapterOptions) {
|
|
30
|
+
this.options = options;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
connect(options: {
|
|
34
|
+
abortSignal?: AbortSignal;
|
|
35
|
+
}): RealtimeVoiceAdapter.Session {
|
|
36
|
+
return createVoiceSession(options, async (session) => {
|
|
37
|
+
if (
|
|
38
|
+
typeof RTCPeerConnection === "undefined" ||
|
|
39
|
+
typeof document === "undefined" ||
|
|
40
|
+
typeof navigator === "undefined" ||
|
|
41
|
+
!navigator.mediaDevices?.getUserMedia
|
|
42
|
+
) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
"OpenAI Realtime voice requires a browser with WebRTC and microphone access.",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const controller = new AbortController();
|
|
49
|
+
const peer = new RTCPeerConnection();
|
|
50
|
+
const channel = peer.createDataChannel("oai-events");
|
|
51
|
+
const audio = document.createElement("audio");
|
|
52
|
+
audio.autoplay = true;
|
|
53
|
+
let microphone: MediaStream | undefined;
|
|
54
|
+
let muted = false;
|
|
55
|
+
let closed = false;
|
|
56
|
+
let generating = false;
|
|
57
|
+
let speaking = false;
|
|
58
|
+
let transcribing = false;
|
|
59
|
+
let volumeTimer: ReturnType<typeof setInterval> | undefined;
|
|
60
|
+
let connectionTimer: ReturnType<typeof setTimeout> | undefined;
|
|
61
|
+
type Transcript = {
|
|
62
|
+
ready: boolean;
|
|
63
|
+
value: RealtimeVoiceAdapter.TranscriptItem | undefined;
|
|
64
|
+
};
|
|
65
|
+
let assistantTranscript: (Transcript & { id: string }) | undefined;
|
|
66
|
+
const transcriptQueue: Transcript[] = [];
|
|
67
|
+
const pendingTranscripts = new Map<string, Transcript>();
|
|
68
|
+
const flushTranscripts = () => {
|
|
69
|
+
while (transcriptQueue[0]?.ready) {
|
|
70
|
+
const item = transcriptQueue[0];
|
|
71
|
+
item.ready = false;
|
|
72
|
+
if (item.value?.isFinal !== false) transcriptQueue.shift();
|
|
73
|
+
if (item.value) session.emitTranscript(item.value);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
const finishUserTranscript = (
|
|
77
|
+
id: unknown,
|
|
78
|
+
value?: RealtimeVoiceAdapter.TranscriptItem,
|
|
79
|
+
) => {
|
|
80
|
+
const item =
|
|
81
|
+
typeof id === "string" ? pendingTranscripts.get(id) : undefined;
|
|
82
|
+
if (item) {
|
|
83
|
+
item.ready = true;
|
|
84
|
+
item.value = value;
|
|
85
|
+
pendingTranscripts.delete(id as string);
|
|
86
|
+
} else if (value) transcriptQueue.push({ ready: true, value });
|
|
87
|
+
flushTranscripts();
|
|
88
|
+
};
|
|
89
|
+
const pending = new Map<
|
|
90
|
+
string,
|
|
91
|
+
{
|
|
92
|
+
resolve: () => void;
|
|
93
|
+
reject: (error: Error) => void;
|
|
94
|
+
timer: ReturnType<typeof setTimeout>;
|
|
95
|
+
}
|
|
96
|
+
>();
|
|
97
|
+
|
|
98
|
+
const disconnect = () => {
|
|
99
|
+
if (closed) return;
|
|
100
|
+
closed = true;
|
|
101
|
+
controller.abort();
|
|
102
|
+
clearInterval(volumeTimer);
|
|
103
|
+
clearTimeout(connectionTimer);
|
|
104
|
+
for (const id of pending.keys())
|
|
105
|
+
settle(
|
|
106
|
+
id,
|
|
107
|
+
new Error(
|
|
108
|
+
"OpenAI Realtime session disconnected before accepting the message.",
|
|
109
|
+
),
|
|
110
|
+
);
|
|
111
|
+
pendingTranscripts.clear();
|
|
112
|
+
transcriptQueue.length = 0;
|
|
113
|
+
channel.onopen =
|
|
114
|
+
channel.onmessage =
|
|
115
|
+
channel.onclose =
|
|
116
|
+
channel.onerror =
|
|
117
|
+
null;
|
|
118
|
+
peer.ontrack = peer.onconnectionstatechange = null;
|
|
119
|
+
for (const track of microphone?.getTracks() ?? []) track.stop();
|
|
120
|
+
audio.pause();
|
|
121
|
+
audio.srcObject = null;
|
|
122
|
+
channel.close();
|
|
123
|
+
peer.close();
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const end = (reason: "finished" | "error", error?: unknown) => {
|
|
127
|
+
session.end(reason, error);
|
|
128
|
+
disconnect();
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const send = (event: Record<string, unknown>) => {
|
|
132
|
+
if (closed || channel.readyState !== "open")
|
|
133
|
+
throw new Error("OpenAI Realtime session is not connected.");
|
|
134
|
+
channel.send(JSON.stringify(event));
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const request = (id: string, event: Record<string, unknown>) =>
|
|
138
|
+
new Promise<void>((resolve, reject) => {
|
|
139
|
+
const timer = setTimeout(() => {
|
|
140
|
+
reject(
|
|
141
|
+
new Error("OpenAI Realtime did not acknowledge the message."),
|
|
142
|
+
);
|
|
143
|
+
}, 10_000);
|
|
144
|
+
pending.set(id, { resolve, reject, timer });
|
|
145
|
+
send({ ...event, event_id: id });
|
|
146
|
+
}).finally(() => settle(id));
|
|
147
|
+
|
|
148
|
+
const settle = (id: unknown, error?: Error) => {
|
|
149
|
+
if (typeof id !== "string") return false;
|
|
150
|
+
const message = pending.get(id);
|
|
151
|
+
if (!message) return false;
|
|
152
|
+
clearTimeout(message.timer);
|
|
153
|
+
pending.delete(id);
|
|
154
|
+
if (error) message.reject(error);
|
|
155
|
+
else message.resolve();
|
|
156
|
+
return true;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const setSpeaking = (value: boolean) => {
|
|
160
|
+
speaking = value;
|
|
161
|
+
session.emitMode(value ? "speaking" : "listening");
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
channel.onmessage = ({ data }) => {
|
|
165
|
+
if (closed) return;
|
|
166
|
+
let event: Record<string, unknown> | undefined;
|
|
167
|
+
try {
|
|
168
|
+
event = asRecord(JSON.parse(data));
|
|
169
|
+
} catch {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (!event) return;
|
|
173
|
+
switch (event.type) {
|
|
174
|
+
case "session.created":
|
|
175
|
+
case "session.updated": {
|
|
176
|
+
const audio = asRecord(asRecord(event.session)?.audio);
|
|
177
|
+
const input = asRecord(audio?.input);
|
|
178
|
+
transcribing = asRecord(input?.transcription) !== undefined;
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
case "input_audio_buffer.committed":
|
|
182
|
+
if (
|
|
183
|
+
transcribing &&
|
|
184
|
+
typeof event.item_id === "string" &&
|
|
185
|
+
!pendingTranscripts.has(event.item_id)
|
|
186
|
+
) {
|
|
187
|
+
const item = { ready: false, value: undefined };
|
|
188
|
+
pendingTranscripts.set(event.item_id, item);
|
|
189
|
+
transcriptQueue.push(item);
|
|
190
|
+
}
|
|
191
|
+
break;
|
|
192
|
+
case "conversation.item.input_audio_transcription.completed":
|
|
193
|
+
if (typeof event.transcript === "string")
|
|
194
|
+
finishUserTranscript(event.item_id, {
|
|
195
|
+
role: "user",
|
|
196
|
+
text: event.transcript,
|
|
197
|
+
isFinal: true,
|
|
198
|
+
});
|
|
199
|
+
break;
|
|
200
|
+
case "response.output_audio_transcript.delta":
|
|
201
|
+
case "response.output_audio_transcript.done": {
|
|
202
|
+
if (typeof event.item_id !== "string") break;
|
|
203
|
+
const final =
|
|
204
|
+
event.type === "response.output_audio_transcript.done";
|
|
205
|
+
const text = final ? event.transcript : event.delta;
|
|
206
|
+
if (typeof text !== "string") break;
|
|
207
|
+
if (assistantTranscript?.id !== event.item_id) {
|
|
208
|
+
assistantTranscript = {
|
|
209
|
+
id: event.item_id,
|
|
210
|
+
ready: false,
|
|
211
|
+
value: undefined,
|
|
212
|
+
};
|
|
213
|
+
transcriptQueue.push(assistantTranscript);
|
|
214
|
+
}
|
|
215
|
+
assistantTranscript.ready = true;
|
|
216
|
+
assistantTranscript.value = {
|
|
217
|
+
role: "assistant",
|
|
218
|
+
text: final
|
|
219
|
+
? text
|
|
220
|
+
: (assistantTranscript.value?.text ?? "") + text,
|
|
221
|
+
isFinal: final,
|
|
222
|
+
};
|
|
223
|
+
flushTranscripts();
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
case "response.created":
|
|
227
|
+
generating = true;
|
|
228
|
+
settle(
|
|
229
|
+
asRecord(asRecord(event.response)?.metadata)?.aui_request_id,
|
|
230
|
+
);
|
|
231
|
+
break;
|
|
232
|
+
case "response.done": {
|
|
233
|
+
generating = false;
|
|
234
|
+
const response = asRecord(event.response);
|
|
235
|
+
if (assistantTranscript?.value?.isFinal === false) {
|
|
236
|
+
assistantTranscript.value = {
|
|
237
|
+
...assistantTranscript.value,
|
|
238
|
+
isFinal: true,
|
|
239
|
+
};
|
|
240
|
+
assistantTranscript.ready = true;
|
|
241
|
+
flushTranscripts();
|
|
242
|
+
}
|
|
243
|
+
if (response?.status === "failed")
|
|
244
|
+
console.warn(
|
|
245
|
+
"OpenAI Realtime response failed.",
|
|
246
|
+
response.status_details,
|
|
247
|
+
);
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
case "output_audio_buffer.started":
|
|
251
|
+
setSpeaking(true);
|
|
252
|
+
break;
|
|
253
|
+
case "output_audio_buffer.stopped":
|
|
254
|
+
case "output_audio_buffer.cleared":
|
|
255
|
+
setSpeaking(false);
|
|
256
|
+
break;
|
|
257
|
+
case "conversation.item.added":
|
|
258
|
+
settle(asRecord(event.item)?.id);
|
|
259
|
+
break;
|
|
260
|
+
case "conversation.item.input_audio_transcription.failed":
|
|
261
|
+
finishUserTranscript(event.item_id);
|
|
262
|
+
console.warn("OpenAI Realtime transcription failed.", event.error);
|
|
263
|
+
break;
|
|
264
|
+
case "error": {
|
|
265
|
+
const error = asRecord(event.error);
|
|
266
|
+
if (error?.code === "response_cancel_not_active") break;
|
|
267
|
+
if (
|
|
268
|
+
!settle(
|
|
269
|
+
error?.event_id,
|
|
270
|
+
new Error(
|
|
271
|
+
typeof error?.message === "string"
|
|
272
|
+
? error.message
|
|
273
|
+
: "OpenAI Realtime rejected the message.",
|
|
274
|
+
),
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
console.warn("OpenAI Realtime error.", event.error);
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
channel.onopen = () => {
|
|
283
|
+
if (closed) return;
|
|
284
|
+
clearTimeout(connectionTimer);
|
|
285
|
+
session.setStatus({ type: "running" });
|
|
286
|
+
if (closed) return;
|
|
287
|
+
session.emitMode("listening");
|
|
288
|
+
if (closed) return;
|
|
289
|
+
volumeTimer = setInterval(() => {
|
|
290
|
+
void peer
|
|
291
|
+
.getStats()
|
|
292
|
+
.then((report) => {
|
|
293
|
+
if (closed) return;
|
|
294
|
+
let volume = 0;
|
|
295
|
+
report.forEach((stat) => {
|
|
296
|
+
if (
|
|
297
|
+
(stat.type === "inbound-rtp" ||
|
|
298
|
+
(!muted && stat.type === "media-source")) &&
|
|
299
|
+
typeof stat.audioLevel === "number"
|
|
300
|
+
)
|
|
301
|
+
volume = Math.max(volume, stat.audioLevel);
|
|
302
|
+
});
|
|
303
|
+
session.emitVolume(Math.min(1, volume));
|
|
304
|
+
})
|
|
305
|
+
.catch(() => {});
|
|
306
|
+
}, 100);
|
|
307
|
+
};
|
|
308
|
+
channel.onclose = () => end("finished");
|
|
309
|
+
channel.onerror = () =>
|
|
310
|
+
end("error", new Error("OpenAI Realtime data channel failed."));
|
|
311
|
+
peer.onconnectionstatechange = () => {
|
|
312
|
+
if (peer.connectionState === "failed") {
|
|
313
|
+
end("error", new Error("OpenAI Realtime connection failed."));
|
|
314
|
+
} else if (peer.connectionState === "closed") {
|
|
315
|
+
end("finished");
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
peer.ontrack = ({ track }) => {
|
|
319
|
+
if (closed || track.kind !== "audio") return;
|
|
320
|
+
audio.srcObject = new MediaStream([track]);
|
|
321
|
+
void audio.play().catch((error: unknown) => end("error", error));
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const start = async () => {
|
|
325
|
+
const stream = await navigator.mediaDevices.getUserMedia({
|
|
326
|
+
audio: true,
|
|
327
|
+
});
|
|
328
|
+
if (closed || session.isDisposed()) {
|
|
329
|
+
for (const track of stream.getTracks()) track.stop();
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
microphone = stream;
|
|
333
|
+
for (const track of stream.getAudioTracks()) {
|
|
334
|
+
track.enabled = !muted;
|
|
335
|
+
peer.addTrack(track, stream);
|
|
336
|
+
}
|
|
337
|
+
connectionTimer = setTimeout(
|
|
338
|
+
() =>
|
|
339
|
+
end("error", new Error("OpenAI Realtime connection timed out.")),
|
|
340
|
+
30_000,
|
|
341
|
+
);
|
|
342
|
+
const offer = await peer.createOffer();
|
|
343
|
+
if (closed) return;
|
|
344
|
+
await peer.setLocalDescription(offer);
|
|
345
|
+
if (closed) return;
|
|
346
|
+
const answer = await this.options.exchangeSdp(offer.sdp!, {
|
|
347
|
+
signal: controller.signal,
|
|
348
|
+
});
|
|
349
|
+
if (closed) return;
|
|
350
|
+
await peer.setRemoteDescription({ type: "answer", sdp: answer });
|
|
351
|
+
};
|
|
352
|
+
void start().catch((error: unknown) => end("error", error));
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
disconnect,
|
|
356
|
+
mute: () => {
|
|
357
|
+
muted = true;
|
|
358
|
+
for (const track of microphone?.getAudioTracks() ?? [])
|
|
359
|
+
track.enabled = false;
|
|
360
|
+
},
|
|
361
|
+
unmute: () => {
|
|
362
|
+
muted = false;
|
|
363
|
+
for (const track of microphone?.getAudioTracks() ?? [])
|
|
364
|
+
track.enabled = true;
|
|
365
|
+
},
|
|
366
|
+
sendText: async (text) => {
|
|
367
|
+
if (generating) send({ type: "response.cancel" });
|
|
368
|
+
if (speaking) send({ type: "output_audio_buffer.clear" });
|
|
369
|
+
const id = crypto.randomUUID().replaceAll("-", "");
|
|
370
|
+
await request(id, {
|
|
371
|
+
type: "conversation.item.create",
|
|
372
|
+
item: {
|
|
373
|
+
id,
|
|
374
|
+
type: "message",
|
|
375
|
+
role: "user",
|
|
376
|
+
content: [{ type: "input_text", text }],
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
const responseId = crypto.randomUUID().replaceAll("-", "");
|
|
380
|
+
await request(responseId, {
|
|
381
|
+
type: "response.create",
|
|
382
|
+
response: { metadata: { aui_request_id: responseId } },
|
|
383
|
+
});
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
import { create } from "zustand";
|
|
4
4
|
import type { Unsubscribe } from "@assistant-ui/core";
|
|
5
5
|
import { notifyEventListeners } from "@assistant-ui/core/internal";
|
|
6
|
+
import {
|
|
7
|
+
createThreadViewportAutoScroll,
|
|
8
|
+
type ThreadViewportAutoScroll,
|
|
9
|
+
type ThreadViewportAutoScrollOptions,
|
|
10
|
+
} from "@assistant-ui/store/client";
|
|
11
|
+
import type { ReadonlyStore } from "../ReadonlyStore";
|
|
6
12
|
|
|
7
13
|
export type SizeHandle = {
|
|
8
14
|
/** Update the height */
|
|
@@ -137,6 +143,20 @@ export type ThreadViewportStoreOptions = {
|
|
|
137
143
|
| undefined;
|
|
138
144
|
};
|
|
139
145
|
|
|
146
|
+
type ViewportAutoScroll = {
|
|
147
|
+
controller: ThreadViewportAutoScroll;
|
|
148
|
+
getOptions: () => ThreadViewportAutoScrollOptions;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const viewportAutoScroll = new WeakMap<
|
|
152
|
+
ReadonlyStore<ThreadViewportState>,
|
|
153
|
+
ViewportAutoScroll
|
|
154
|
+
>();
|
|
155
|
+
|
|
156
|
+
export const getThreadViewportAutoScroll = (
|
|
157
|
+
store: ReadonlyStore<ThreadViewportState>,
|
|
158
|
+
) => viewportAutoScroll.get(store)!;
|
|
159
|
+
|
|
140
160
|
export const makeThreadViewportStore = (
|
|
141
161
|
options: ThreadViewportStoreOptions = {},
|
|
142
162
|
) => {
|
|
@@ -152,14 +172,21 @@ export const makeThreadViewportStore = (
|
|
|
152
172
|
},
|
|
153
173
|
});
|
|
154
174
|
});
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
175
|
+
const autoScroll: ViewportAutoScroll = {
|
|
176
|
+
getOptions: () => ({
|
|
177
|
+
autoScroll: options.turnAnchor !== "top",
|
|
178
|
+
scrollToBottomOnInitialize: true,
|
|
179
|
+
scrollToBottomOnRunStart: options.turnAnchor !== "top",
|
|
180
|
+
scrollToBottomOnThreadSwitch: true,
|
|
181
|
+
}),
|
|
182
|
+
controller: createThreadViewportAutoScroll({
|
|
183
|
+
getOptions: () => autoScroll.getOptions(),
|
|
184
|
+
onAtBottomChange: (isAtBottom) => store.setState({ isAtBottom }),
|
|
185
|
+
onContentInsetChange: (inset) => {
|
|
186
|
+
store.setState({ height: { ...store.getState().height, inset } });
|
|
160
187
|
},
|
|
161
|
-
})
|
|
162
|
-
}
|
|
188
|
+
}),
|
|
189
|
+
};
|
|
163
190
|
const registerElementSlot = (
|
|
164
191
|
key: "viewport" | "anchor",
|
|
165
192
|
element: HTMLElement | null,
|
|
@@ -217,7 +244,7 @@ export const makeThreadViewportStore = (
|
|
|
217
244
|
topAnchorTurn: null,
|
|
218
245
|
|
|
219
246
|
registerViewport: viewportRegistry.register,
|
|
220
|
-
registerContentInset:
|
|
247
|
+
registerContentInset: autoScroll.controller.registerContentInset,
|
|
221
248
|
registerViewportElement: (element) =>
|
|
222
249
|
registerElementSlot("viewport", element),
|
|
223
250
|
registerAnchorElement: (element) => registerElementSlot("anchor", element),
|
|
@@ -246,5 +273,6 @@ export const makeThreadViewportStore = (
|
|
|
246
273
|
},
|
|
247
274
|
}));
|
|
248
275
|
|
|
276
|
+
viewportAutoScroll.set(store, autoScroll);
|
|
249
277
|
return store;
|
|
250
278
|
};
|
package/src/index.ts
CHANGED
|
@@ -65,6 +65,10 @@ export { CloudFileAttachmentAdapter } from "./legacy-runtime/runtime-cores/adapt
|
|
|
65
65
|
// --- adapters/voice ---
|
|
66
66
|
export type { RealtimeVoiceAdapter } from "@assistant-ui/core";
|
|
67
67
|
export { createVoiceSession } from "@assistant-ui/core";
|
|
68
|
+
export {
|
|
69
|
+
OpenAIRealtimeVoiceAdapter,
|
|
70
|
+
type OpenAIRealtimeVoiceAdapterOptions,
|
|
71
|
+
} from "./adapters/OpenAIRealtimeVoiceAdapter";
|
|
68
72
|
export type {
|
|
69
73
|
VoiceSessionControls,
|
|
70
74
|
VoiceSessionHelpers,
|
|
@@ -270,7 +274,9 @@ export {
|
|
|
270
274
|
|
|
271
275
|
export type {
|
|
272
276
|
ModelContext,
|
|
277
|
+
ModelContextOptions,
|
|
273
278
|
ModelContextProvider,
|
|
279
|
+
SystemPart,
|
|
274
280
|
LanguageModelConfig,
|
|
275
281
|
LanguageModelV1CallSettings,
|
|
276
282
|
} from "@assistant-ui/core";
|
|
@@ -725,6 +725,71 @@ describe("McpAppRenderer", () => {
|
|
|
725
725
|
expect(appendMock).toHaveBeenCalledTimes(2);
|
|
726
726
|
});
|
|
727
727
|
|
|
728
|
+
it("extracts sparse message content without traversing holes", async () => {
|
|
729
|
+
render(<Harness host={loadingHost()} />);
|
|
730
|
+
await waitFor(() => expect(framePropsMock).toHaveBeenCalled());
|
|
731
|
+
const handlers = framePropsMock.mock.lastCall?.[0]
|
|
732
|
+
.handlers as McpAppBridgeHandlers;
|
|
733
|
+
const content: unknown[] = new Array(50_000_000);
|
|
734
|
+
content[0] = { type: "text", text: "sparse prompt" };
|
|
735
|
+
|
|
736
|
+
const lastIndex = String(content.length - 1);
|
|
737
|
+
const arrayPrototypeLength = Array.prototype.length;
|
|
738
|
+
const visitedHole = vi.fn();
|
|
739
|
+
Object.defineProperty(Array.prototype, lastIndex, {
|
|
740
|
+
configurable: true,
|
|
741
|
+
get() {
|
|
742
|
+
visitedHole();
|
|
743
|
+
return undefined;
|
|
744
|
+
},
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
try {
|
|
748
|
+
const clonedContent = structuredClone(content);
|
|
749
|
+
expect(
|
|
750
|
+
handlers.sendMessage?.({ role: "user", content: clonedContent }),
|
|
751
|
+
).toEqual({ ok: true });
|
|
752
|
+
} finally {
|
|
753
|
+
delete (Array.prototype as unknown[])[Number(lastIndex)];
|
|
754
|
+
Array.prototype.length = arrayPrototypeLength;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
expect(visitedHole).not.toHaveBeenCalled();
|
|
758
|
+
expect(appendMock).toHaveBeenCalledWith({
|
|
759
|
+
content: [{ type: "text", text: "sparse prompt" }],
|
|
760
|
+
});
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
it("ignores non-index message content properties after structured clone", async () => {
|
|
764
|
+
render(<Harness host={loadingHost()} />);
|
|
765
|
+
await waitFor(() => expect(framePropsMock).toHaveBeenCalled());
|
|
766
|
+
const handlers = framePropsMock.mock.lastCall?.[0]
|
|
767
|
+
.handlers as McpAppBridgeHandlers;
|
|
768
|
+
|
|
769
|
+
for (const property of ["filter", "constructor"] as const) {
|
|
770
|
+
const content: unknown[] = [{ type: "text", text: "message text" }];
|
|
771
|
+
Object.defineProperty(content, property, {
|
|
772
|
+
configurable: true,
|
|
773
|
+
enumerable: true,
|
|
774
|
+
value: { type: "text", text: "not an array element" },
|
|
775
|
+
});
|
|
776
|
+
const clonedContent = structuredClone(content);
|
|
777
|
+
|
|
778
|
+
expect(Object.hasOwn(clonedContent, property)).toBe(true);
|
|
779
|
+
expect(
|
|
780
|
+
handlers.sendMessage?.({ role: "user", content: clonedContent }),
|
|
781
|
+
).toEqual({ ok: true });
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
expect(appendMock).toHaveBeenCalledTimes(2);
|
|
785
|
+
expect(appendMock).toHaveBeenNthCalledWith(1, {
|
|
786
|
+
content: [{ type: "text", text: "message text" }],
|
|
787
|
+
});
|
|
788
|
+
expect(appendMock).toHaveBeenNthCalledWith(2, {
|
|
789
|
+
content: [{ type: "text", text: "message text" }],
|
|
790
|
+
});
|
|
791
|
+
});
|
|
792
|
+
|
|
728
793
|
it("uses caller UI handlers and keeps data-plane handlers on the host", async () => {
|
|
729
794
|
const host: McpAppsHost = {
|
|
730
795
|
loadResource: vi.fn(async ({ uri }) => ({
|
|
@@ -142,8 +142,19 @@ function extractSendMessageTexts(params: unknown): string[] | undefined {
|
|
|
142
142
|
if (typeof obj["prompt"] === "string") return [obj["prompt"]];
|
|
143
143
|
if (typeof obj["text"] === "string") return [obj["text"]];
|
|
144
144
|
if (typeof obj["message"] === "string") return [obj["message"]];
|
|
145
|
-
|
|
146
|
-
|
|
145
|
+
const content = obj["content"];
|
|
146
|
+
if (Array.isArray(content)) {
|
|
147
|
+
return Object.keys(content)
|
|
148
|
+
.filter((key) => {
|
|
149
|
+
const index = Number(key);
|
|
150
|
+
return (
|
|
151
|
+
Number.isInteger(index) &&
|
|
152
|
+
index >= 0 &&
|
|
153
|
+
index < content.length &&
|
|
154
|
+
String(index) === key
|
|
155
|
+
);
|
|
156
|
+
})
|
|
157
|
+
.map((key) => content[Number(key)])
|
|
147
158
|
.filter(
|
|
148
159
|
(block): block is { type: "text"; text: string } =>
|
|
149
160
|
isRecord(block) &&
|