@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.
@@ -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([
@@ -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
- data: part.data,
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
- load?: (threadId: string) => Promise<{ messages: AdkMessage[] }>;
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