@assistant-ui/react-google-adk 0.0.20 → 0.0.21
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/AdkEventAccumulator.d.ts +1 -0
- package/dist/AdkEventAccumulator.d.ts.map +1 -1
- package/dist/AdkEventAccumulator.js +20 -4
- package/dist/AdkEventAccumulator.js.map +1 -1
- package/dist/AdkSessionAdapter.d.ts +4 -4
- package/dist/AdkSessionAdapter.d.ts.map +1 -1
- package/dist/AdkSessionAdapter.js +16 -3
- package/dist/AdkSessionAdapter.js.map +1 -1
- package/dist/convertAdkMessages.d.ts.map +1 -1
- package/dist/convertAdkMessages.js +18 -21
- package/dist/convertAdkMessages.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/types.d.ts +22 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/useAdkMessages.d.ts +2 -1
- package/dist/useAdkMessages.d.ts.map +1 -1
- package/dist/useAdkMessages.js +19 -1
- package/dist/useAdkMessages.js.map +1 -1
- package/dist/useAdkRuntime.d.ts +10 -4
- package/dist/useAdkRuntime.d.ts.map +1 -1
- package/dist/useAdkRuntime.js +68 -29
- package/dist/useAdkRuntime.js.map +1 -1
- package/package.json +7 -4
- package/src/AdkEventAccumulator.test.ts +127 -0
- package/src/AdkEventAccumulator.ts +31 -5
- package/src/AdkSessionAdapter.test.ts +43 -0
- package/src/AdkSessionAdapter.ts +21 -4
- package/src/convertAdkMessages.test.ts +40 -6
- package/src/convertAdkMessages.ts +16 -15
- package/src/index.ts +1 -0
- package/src/types.ts +21 -0
- package/src/useAdkMessages.ts +24 -1
- package/src/useAdkRuntime.refetch.test.tsx +336 -0
- package/src/useAdkRuntime.test.ts +161 -0
- package/src/useAdkRuntime.ts +127 -28
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
getPendingCancellations,
|
|
5
5
|
getPendingToolCalls,
|
|
6
6
|
} from "./useAdkRuntime";
|
|
7
|
+
import { convertAdkMessage } from "./convertAdkMessages";
|
|
7
8
|
import type { AppendMessage } from "@assistant-ui/core";
|
|
8
9
|
import type { AdkMessage } from "./types";
|
|
9
10
|
|
|
@@ -209,6 +210,89 @@ describe("getMessageContent", () => {
|
|
|
209
210
|
]);
|
|
210
211
|
});
|
|
211
212
|
|
|
213
|
+
it("emits a file_url part for file parts with sourceType url", () => {
|
|
214
|
+
const result = getMessageContent(
|
|
215
|
+
makeAppendMessage([
|
|
216
|
+
{
|
|
217
|
+
type: "file",
|
|
218
|
+
mimeType: "application/pdf",
|
|
219
|
+
data: "gs://bucket/report.pdf",
|
|
220
|
+
filename: "report.pdf",
|
|
221
|
+
sourceType: "url",
|
|
222
|
+
},
|
|
223
|
+
]),
|
|
224
|
+
);
|
|
225
|
+
expect(result).toEqual([
|
|
226
|
+
{
|
|
227
|
+
type: "file_url",
|
|
228
|
+
url: "gs://bucket/report.pdf",
|
|
229
|
+
mimeType: "application/pdf",
|
|
230
|
+
},
|
|
231
|
+
]);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("keeps file parts inline without sourceType", () => {
|
|
235
|
+
const result = getMessageContent(
|
|
236
|
+
makeAppendMessage([
|
|
237
|
+
{
|
|
238
|
+
type: "file",
|
|
239
|
+
mimeType: "application/pdf",
|
|
240
|
+
data: "gs://bucket/report.pdf",
|
|
241
|
+
},
|
|
242
|
+
]),
|
|
243
|
+
);
|
|
244
|
+
expect(result).toEqual([
|
|
245
|
+
{
|
|
246
|
+
type: "file",
|
|
247
|
+
mimeType: "application/pdf",
|
|
248
|
+
data: "gs://bucket/report.pdf",
|
|
249
|
+
},
|
|
250
|
+
]);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("round-trips a file_url part through convert and edit-resend", () => {
|
|
254
|
+
const converted = convertAdkMessage(
|
|
255
|
+
{
|
|
256
|
+
id: "m1",
|
|
257
|
+
type: "human",
|
|
258
|
+
content: [
|
|
259
|
+
{
|
|
260
|
+
type: "file_url",
|
|
261
|
+
url: "gs://bucket/report.pdf",
|
|
262
|
+
mimeType: "application/pdf",
|
|
263
|
+
},
|
|
264
|
+
],
|
|
265
|
+
},
|
|
266
|
+
{},
|
|
267
|
+
);
|
|
268
|
+
const content = (converted as { content: AppendMessage["content"] })
|
|
269
|
+
.content;
|
|
270
|
+
const result = getMessageContent(makeAppendMessage(content));
|
|
271
|
+
expect(result).toEqual([
|
|
272
|
+
{
|
|
273
|
+
type: "file_url",
|
|
274
|
+
url: "gs://bucket/report.pdf",
|
|
275
|
+
mimeType: "application/pdf",
|
|
276
|
+
},
|
|
277
|
+
]);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("ignores sourceType id on file parts", () => {
|
|
281
|
+
const result = getMessageContent(
|
|
282
|
+
makeAppendMessage([
|
|
283
|
+
{
|
|
284
|
+
type: "file",
|
|
285
|
+
mimeType: "application/pdf",
|
|
286
|
+
data: "file-abc123",
|
|
287
|
+
sourceType: "id",
|
|
288
|
+
},
|
|
289
|
+
]),
|
|
290
|
+
);
|
|
291
|
+
expect(result).toEqual([
|
|
292
|
+
{ type: "file", mimeType: "application/pdf", data: "file-abc123" },
|
|
293
|
+
]);
|
|
294
|
+
});
|
|
295
|
+
|
|
212
296
|
it("forwards an audio part as a file block with the format-derived mime type", () => {
|
|
213
297
|
const result = getMessageContent(
|
|
214
298
|
makeAppendMessage([
|
|
@@ -245,6 +329,83 @@ describe("getMessageContent", () => {
|
|
|
245
329
|
]);
|
|
246
330
|
});
|
|
247
331
|
|
|
332
|
+
it("strips a data URL envelope from file data", () => {
|
|
333
|
+
const result = getMessageContent(
|
|
334
|
+
makeAppendMessage([
|
|
335
|
+
{
|
|
336
|
+
type: "file",
|
|
337
|
+
data: "data:application/pdf;base64,QUJD",
|
|
338
|
+
mimeType: "application/pdf",
|
|
339
|
+
filename: "a.pdf",
|
|
340
|
+
},
|
|
341
|
+
]),
|
|
342
|
+
);
|
|
343
|
+
expect(result).toEqual([
|
|
344
|
+
{
|
|
345
|
+
type: "file",
|
|
346
|
+
mimeType: "application/pdf",
|
|
347
|
+
data: "QUJD",
|
|
348
|
+
filename: "a.pdf",
|
|
349
|
+
},
|
|
350
|
+
]);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("emits a file_url part for an unmarked http source", () => {
|
|
354
|
+
const result = getMessageContent(
|
|
355
|
+
makeAppendMessage([
|
|
356
|
+
{
|
|
357
|
+
type: "file",
|
|
358
|
+
data: "https://cdn.example.com/a.pdf",
|
|
359
|
+
mimeType: "application/pdf",
|
|
360
|
+
},
|
|
361
|
+
]),
|
|
362
|
+
);
|
|
363
|
+
expect(result).toEqual([
|
|
364
|
+
{
|
|
365
|
+
type: "file_url",
|
|
366
|
+
url: "https://cdn.example.com/a.pdf",
|
|
367
|
+
mimeType: "application/pdf",
|
|
368
|
+
},
|
|
369
|
+
]);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("leaves bare base64 file data untouched", () => {
|
|
373
|
+
const result = getMessageContent(
|
|
374
|
+
makeAppendMessage([
|
|
375
|
+
{ type: "file", data: "QUJD", mimeType: "application/pdf" },
|
|
376
|
+
]),
|
|
377
|
+
);
|
|
378
|
+
expect(result).toEqual([
|
|
379
|
+
{ type: "file", mimeType: "application/pdf", data: "QUJD" },
|
|
380
|
+
]);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("round-trips an audio file part through both converters", () => {
|
|
384
|
+
const outbound = getMessageContent(
|
|
385
|
+
makeAppendMessage([
|
|
386
|
+
{
|
|
387
|
+
type: "file",
|
|
388
|
+
data: "data:audio/mp3;base64,QUJD",
|
|
389
|
+
mimeType: "audio/mp3",
|
|
390
|
+
},
|
|
391
|
+
]),
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
expect(outbound).toEqual([
|
|
395
|
+
{ type: "file", mimeType: "audio/mp3", data: "QUJD" },
|
|
396
|
+
]);
|
|
397
|
+
|
|
398
|
+
const inbound = convertAdkMessage(
|
|
399
|
+
{ id: "m1", type: "human", content: outbound } as never,
|
|
400
|
+
{},
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
expect(inbound).toMatchObject({
|
|
404
|
+
role: "user",
|
|
405
|
+
content: [{ type: "file", data: "QUJD", mimeType: "audio/mp3" }],
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
248
409
|
it("skips data parts while keeping surrounding text", () => {
|
|
249
410
|
const result = getMessageContent(
|
|
250
411
|
makeAppendMessage([
|
package/src/useAdkRuntime.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import {
|
|
3
3
|
getExternalStoreMessages,
|
|
4
4
|
pickExternalStoreSharedOptions,
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
type ToolExecutionStatus,
|
|
14
14
|
generateId,
|
|
15
15
|
} from "@assistant-ui/core";
|
|
16
|
-
import { parseDataUrl } from "@assistant-ui/core/internal";
|
|
16
|
+
import { httpUrlPattern, parseDataUrl } from "@assistant-ui/core/internal";
|
|
17
17
|
import {
|
|
18
18
|
useCloudThreadListAdapter,
|
|
19
19
|
useRemoteThreadListRuntime,
|
|
@@ -25,6 +25,7 @@ import type { AssistantCloud } from "assistant-cloud";
|
|
|
25
25
|
import type { RemoteThreadListAdapter } from "@assistant-ui/core";
|
|
26
26
|
import type {
|
|
27
27
|
AdkMessage,
|
|
28
|
+
AdkThreadSnapshot,
|
|
28
29
|
AdkSendMessageConfig,
|
|
29
30
|
AdkStreamCallback,
|
|
30
31
|
OnAdkErrorCallback,
|
|
@@ -50,10 +51,19 @@ export const getMessageContent = (msg: AppendMessage) => {
|
|
|
50
51
|
case "image":
|
|
51
52
|
return { type: "image_url" as const, url: part.image };
|
|
52
53
|
case "file":
|
|
54
|
+
if (part.sourceType === "url" || httpUrlPattern.test(part.data)) {
|
|
55
|
+
return {
|
|
56
|
+
type: "file_url" as const,
|
|
57
|
+
url: part.data,
|
|
58
|
+
mimeType: part.mimeType,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
53
61
|
return {
|
|
54
62
|
type: "file" as const,
|
|
55
63
|
mimeType: part.mimeType,
|
|
56
|
-
|
|
64
|
+
// Lands in Gemini `inlineData.data`, which takes bare base64, so a
|
|
65
|
+
// data URL envelope is stripped rather than forwarded.
|
|
66
|
+
data: parseDataUrl(part.data)?.data ?? part.data,
|
|
57
67
|
...(part.filename != null && { filename: part.filename }),
|
|
58
68
|
};
|
|
59
69
|
case "audio": {
|
|
@@ -171,7 +181,16 @@ export type UseAdkRuntimeOptions = ExternalStoreSharedOptions & {
|
|
|
171
181
|
threadId: string,
|
|
172
182
|
parentMessages: AdkMessage[],
|
|
173
183
|
) => Promise<string | null>;
|
|
174
|
-
|
|
184
|
+
/**
|
|
185
|
+
* Loads a thread's stored state. Called when the thread opens, and again for
|
|
186
|
+
* `threads.reloadMainThread()`, which refetches in place rather than
|
|
187
|
+
* remounting the runtime; the signal aborts a load the runtime no longer
|
|
188
|
+
* needs.
|
|
189
|
+
*/
|
|
190
|
+
load?: (
|
|
191
|
+
threadId: string,
|
|
192
|
+
options?: { signal?: AbortSignal | undefined },
|
|
193
|
+
) => Promise<AdkThreadSnapshot>;
|
|
175
194
|
create?: () => Promise<{ externalId: string }>;
|
|
176
195
|
delete?: (threadId: string) => Promise<void>;
|
|
177
196
|
adapters?:
|
|
@@ -223,11 +242,26 @@ const useAdkRuntimeImpl = (options: UseAdkRuntimeOptions) => {
|
|
|
223
242
|
cancel,
|
|
224
243
|
setMessages,
|
|
225
244
|
replaceMessages,
|
|
245
|
+
applySnapshot,
|
|
226
246
|
} = useAdkMessages({
|
|
227
247
|
stream,
|
|
228
248
|
...(eventHandlers && { eventHandlers }),
|
|
229
249
|
});
|
|
230
250
|
|
|
251
|
+
const loadRef = useRef(load);
|
|
252
|
+
loadRef.current = load;
|
|
253
|
+
const loadControllerRef = useRef<{
|
|
254
|
+
controller: AbortController;
|
|
255
|
+
purpose: "initial" | "reload";
|
|
256
|
+
promise?: Promise<void> | undefined;
|
|
257
|
+
} | null>(null);
|
|
258
|
+
const messagesRef = useRef(messages);
|
|
259
|
+
messagesRef.current = messages;
|
|
260
|
+
const [isLoadingThread, setIsLoadingThread] = useState(
|
|
261
|
+
() =>
|
|
262
|
+
load !== undefined && aui.threadListItem.getState().externalId != null,
|
|
263
|
+
);
|
|
264
|
+
|
|
231
265
|
const [isRunning, setIsRunning] = useState(false);
|
|
232
266
|
const [toolStatuses, setToolStatuses] = useState<
|
|
233
267
|
Record<string, ToolExecutionStatus>
|
|
@@ -236,6 +270,8 @@ const useAdkRuntimeImpl = (options: UseAdkRuntimeOptions) => {
|
|
|
236
270
|
(s) => s?.type === "executing",
|
|
237
271
|
);
|
|
238
272
|
const effectiveIsRunning = isRunning || hasExecutingTools;
|
|
273
|
+
const isRunningRef = useRef(effectiveIsRunning);
|
|
274
|
+
isRunningRef.current = effectiveIsRunning;
|
|
239
275
|
|
|
240
276
|
const handleSendMessage = async (
|
|
241
277
|
msgs: AdkMessage[],
|
|
@@ -302,9 +338,93 @@ const useAdkRuntimeImpl = (options: UseAdkRuntimeOptions) => {
|
|
|
302
338
|
setMessages(nextMessages);
|
|
303
339
|
};
|
|
304
340
|
|
|
341
|
+
// The scoped client, not `aui` itself: useAui returns a render-bound
|
|
342
|
+
// instance, so depending on it would re-run the load on every render.
|
|
343
|
+
const threadListItem =
|
|
344
|
+
aui.threadListItem.source !== null ? aui.threadListItem : undefined;
|
|
345
|
+
|
|
346
|
+
const runLoad = useCallback(
|
|
347
|
+
(purpose: "initial" | "reload" = "initial") => {
|
|
348
|
+
const loadFn = loadRef.current;
|
|
349
|
+
if (!loadFn || !threadListItem) return Promise.resolve();
|
|
350
|
+
|
|
351
|
+
const externalId = threadListItem.getState().externalId;
|
|
352
|
+
if (externalId == null) return Promise.resolve();
|
|
353
|
+
|
|
354
|
+
// The initial load is already fetching what a refetch would ask for, and
|
|
355
|
+
// taking it over strands the thread's history if the refetch then fails.
|
|
356
|
+
if (
|
|
357
|
+
purpose === "reload" &&
|
|
358
|
+
loadControllerRef.current?.purpose === "initial"
|
|
359
|
+
)
|
|
360
|
+
return loadControllerRef.current.promise ?? Promise.resolve();
|
|
361
|
+
|
|
362
|
+
loadControllerRef.current?.controller.abort();
|
|
363
|
+
const controller = new AbortController();
|
|
364
|
+
const record: NonNullable<typeof loadControllerRef.current> = {
|
|
365
|
+
controller,
|
|
366
|
+
purpose,
|
|
367
|
+
};
|
|
368
|
+
loadControllerRef.current = record;
|
|
369
|
+
|
|
370
|
+
const messagesAtLoadStart = messagesRef.current;
|
|
371
|
+
if (purpose === "initial") setIsLoadingThread(true);
|
|
372
|
+
|
|
373
|
+
const task = loadFn(externalId, { signal: controller.signal })
|
|
374
|
+
.then((snapshot) => {
|
|
375
|
+
if (controller.signal.aborted) return;
|
|
376
|
+
// A snapshot the session assembled before a run cannot speak for what
|
|
377
|
+
// that run has since produced, and an ADK id cannot correlate a
|
|
378
|
+
// message sent optimistically with the one the session stored for it,
|
|
379
|
+
// so there is nothing here that could merge the two. A refetch that
|
|
380
|
+
// raced a run therefore defers to the run, whether the run started
|
|
381
|
+
// during the load or was already streaming when it began.
|
|
382
|
+
if (
|
|
383
|
+
purpose === "reload" &&
|
|
384
|
+
(isRunningRef.current ||
|
|
385
|
+
messagesRef.current !== messagesAtLoadStart)
|
|
386
|
+
)
|
|
387
|
+
return;
|
|
388
|
+
applySnapshot(snapshot);
|
|
389
|
+
})
|
|
390
|
+
.catch((error: unknown) => {
|
|
391
|
+
// Aborting a load the runtime no longer needs is not a failure.
|
|
392
|
+
if (controller.signal.aborted) return;
|
|
393
|
+
throw error;
|
|
394
|
+
})
|
|
395
|
+
.finally(() => {
|
|
396
|
+
if (loadControllerRef.current?.controller === controller) {
|
|
397
|
+
loadControllerRef.current = null;
|
|
398
|
+
}
|
|
399
|
+
if (controller.signal.aborted) return;
|
|
400
|
+
setIsLoadingThread(false);
|
|
401
|
+
});
|
|
402
|
+
record.promise = task;
|
|
403
|
+
|
|
404
|
+
// A refetch reports the failure to whoever awaited it; the initial load
|
|
405
|
+
// has no caller to tell.
|
|
406
|
+
if (purpose === "reload") return task;
|
|
407
|
+
return task.catch((e: unknown) => {
|
|
408
|
+
console.warn("Failed to load ADK session:", e);
|
|
409
|
+
});
|
|
410
|
+
},
|
|
411
|
+
[threadListItem, applySnapshot],
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
useEffect(() => {
|
|
415
|
+
runLoad();
|
|
416
|
+
return () => {
|
|
417
|
+
// Whatever is current, not this effect's own controller: a refetch swaps
|
|
418
|
+
// the ref, and one in flight at unmount must be aborted too.
|
|
419
|
+
loadControllerRef.current?.controller.abort();
|
|
420
|
+
setIsLoadingThread(false);
|
|
421
|
+
};
|
|
422
|
+
}, [runLoad]);
|
|
423
|
+
|
|
305
424
|
const runtime = useExternalStoreRuntime({
|
|
306
425
|
...pickExternalStoreSharedOptions(options),
|
|
307
426
|
isRunning: effectiveIsRunning,
|
|
427
|
+
isLoading: isLoadingThread,
|
|
308
428
|
messages: threadMessages,
|
|
309
429
|
unstable_enableToolInvocations: true,
|
|
310
430
|
setToolStatuses,
|
|
@@ -441,32 +561,11 @@ const useAdkRuntimeImpl = (options: UseAdkRuntimeOptions) => {
|
|
|
441
561
|
cancel();
|
|
442
562
|
}
|
|
443
563
|
: undefined,
|
|
564
|
+
...(load !== undefined && {
|
|
565
|
+
onRefetchThread: () => runLoad("reload"),
|
|
566
|
+
}),
|
|
444
567
|
});
|
|
445
568
|
|
|
446
|
-
{
|
|
447
|
-
const loadRef = useRef(load);
|
|
448
|
-
useEffect(() => {
|
|
449
|
-
loadRef.current = load;
|
|
450
|
-
});
|
|
451
|
-
|
|
452
|
-
useEffect(() => {
|
|
453
|
-
const loadFn = loadRef.current;
|
|
454
|
-
if (!loadFn) return;
|
|
455
|
-
|
|
456
|
-
const externalId = aui.threadListItem.getState().externalId;
|
|
457
|
-
if (externalId == null) return;
|
|
458
|
-
|
|
459
|
-
loadFn(externalId).then(
|
|
460
|
-
({ messages: msgs }) => {
|
|
461
|
-
replaceMessages(msgs);
|
|
462
|
-
},
|
|
463
|
-
(e) => {
|
|
464
|
-
console.warn("Failed to load ADK session:", e);
|
|
465
|
-
},
|
|
466
|
-
);
|
|
467
|
-
}, [aui, replaceMessages]);
|
|
468
|
-
}
|
|
469
|
-
|
|
470
569
|
return runtime;
|
|
471
570
|
};
|
|
472
571
|
|