@ai-sdk/langchain 2.0.214 → 2.0.216

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/src/types.ts CHANGED
@@ -28,6 +28,20 @@ export interface LangGraphEventState {
28
28
  currentStep: number | null;
29
29
  /** Maps tool call key (name:argsJson) to tool call ID for HITL interrupt handling */
30
30
  emittedToolCallsByKey: Map<string, string>;
31
+ /** Tracks source IDs already emitted to avoid duplicates across messages/values events */
32
+ emittedSourceIds: Set<string>;
33
+ }
34
+
35
+ /**
36
+ * A LangChain citation projected to the fields the AI SDK source parts can carry.
37
+ */
38
+ export interface NormalizedCitation {
39
+ url?: string;
40
+ title?: string;
41
+ source?: string;
42
+ citedText?: string;
43
+ startIndex?: number;
44
+ endIndex?: number;
31
45
  }
32
46
 
33
47
  /**
package/src/utils.ts CHANGED
@@ -14,15 +14,18 @@ import {
14
14
  type ToolResultPart,
15
15
  type AssistantContent,
16
16
  type UserContent,
17
+ type ProviderMetadata,
18
+ type JSONValue,
17
19
  } from 'ai';
18
20
 
19
- import type {
20
- LangGraphEventState,
21
- LangGraphMessageSeen,
22
- ReasoningContentBlock,
23
- ThinkingContentBlock,
24
- GPT5ReasoningOutput,
25
- ImageGenerationOutput,
21
+ import {
22
+ type LangGraphEventState,
23
+ type LangGraphMessageSeen,
24
+ type ReasoningContentBlock,
25
+ type ThinkingContentBlock,
26
+ type GPT5ReasoningOutput,
27
+ type ImageGenerationOutput,
28
+ type NormalizedCitation,
26
29
  } from './types';
27
30
 
28
31
  /**
@@ -394,6 +397,7 @@ export function processModelChunk(
394
397
  /** Track the ID used for text-start to ensure text-end uses the same ID */
395
398
  textMessageId?: string | null;
396
399
  emittedImages?: Set<string>;
400
+ emittedSourceIds?: Set<string>;
397
401
  },
398
402
  controller: ReadableStreamDefaultController<UIMessageChunk>,
399
403
  ): void {
@@ -404,6 +408,10 @@ export function processModelChunk(
404
408
  state.emittedImages = new Set<string>();
405
409
  }
406
410
 
411
+ if (!state.emittedSourceIds) {
412
+ state.emittedSourceIds = new Set<string>();
413
+ }
414
+
407
415
  /**
408
416
  * Get the message ID from the chunk if available
409
417
  */
@@ -507,6 +515,16 @@ export function processModelChunk(
507
515
  id: state.textMessageId ?? state.messageId,
508
516
  });
509
517
  }
518
+
519
+ const citations = extractCitationsFromContentBlocks(chunk);
520
+ if (citations.length > 0) {
521
+ emitSourceChunks(
522
+ citations,
523
+ state.messageId,
524
+ state.emittedSourceIds,
525
+ controller,
526
+ );
527
+ }
510
528
  }
511
529
 
512
530
  /**
@@ -921,6 +939,152 @@ export function extractReasoningFromValuesMessage(
921
939
  return undefined;
922
940
  }
923
941
 
942
+ export function isCitationContentBlock(
943
+ obj: unknown,
944
+ ): obj is ContentBlock.Citation {
945
+ return (
946
+ obj != null &&
947
+ typeof obj === 'object' &&
948
+ 'type' in obj &&
949
+ (obj as { type: unknown }).type === 'citation'
950
+ );
951
+ }
952
+
953
+ /**
954
+ * LangChain Core standardizes citations as `{ type: 'citation', ... }` entries in
955
+ * the `annotations` array of a text content block. The text extractors elsewhere
956
+ * in this file only read the `text` field, so without this the citations would be
957
+ * dropped instead of surfaced as AI SDK source parts.
958
+ *
959
+ * Handles both class instances and serialized LangChain message objects.
960
+ */
961
+ export function extractCitationsFromContentBlocks(
962
+ msg: unknown,
963
+ ): NormalizedCitation[] {
964
+ if (msg == null || typeof msg !== 'object') return [];
965
+
966
+ const msgObj = msg as Record<string, unknown>;
967
+ const kwargs =
968
+ msgObj.kwargs && typeof msgObj.kwargs === 'object'
969
+ ? (msgObj.kwargs as Record<string, unknown>)
970
+ : msgObj;
971
+
972
+ // `contentBlocks` is the normalized view derived from `content`; reading both
973
+ // would double-count annotations, so prefer it and only fall back to `content`.
974
+ const contentBlocks = (kwargs as { contentBlocks?: unknown }).contentBlocks;
975
+ const content = (kwargs as { content?: unknown }).content;
976
+ const blockSources: unknown[] = Array.isArray(contentBlocks)
977
+ ? contentBlocks
978
+ : Array.isArray(content)
979
+ ? content
980
+ : [];
981
+
982
+ const citations: NormalizedCitation[] = [];
983
+
984
+ for (const block of blockSources) {
985
+ if (block == null || typeof block !== 'object') continue;
986
+
987
+ const annotations = (block as { annotations?: unknown }).annotations;
988
+ if (!Array.isArray(annotations)) continue;
989
+
990
+ for (const annotation of annotations) {
991
+ if (!isCitationContentBlock(annotation)) continue;
992
+
993
+ citations.push({
994
+ url: annotation.url,
995
+ title: annotation.title,
996
+ source: annotation.source,
997
+ citedText: annotation.citedText,
998
+ startIndex: annotation.startIndex,
999
+ endIndex: annotation.endIndex,
1000
+ });
1001
+ }
1002
+ }
1003
+
1004
+ return citations;
1005
+ }
1006
+
1007
+ function buildSourceProviderMetadata(
1008
+ citation: NormalizedCitation,
1009
+ ): ProviderMetadata | undefined {
1010
+ const langchain: Record<string, JSONValue> = {};
1011
+
1012
+ if (typeof citation.citedText === 'string') {
1013
+ langchain.citedText = citation.citedText;
1014
+ }
1015
+ if (typeof citation.startIndex === 'number') {
1016
+ langchain.startIndex = citation.startIndex;
1017
+ }
1018
+ if (typeof citation.endIndex === 'number') {
1019
+ langchain.endIndex = citation.endIndex;
1020
+ }
1021
+ if (typeof citation.source === 'string') {
1022
+ langchain.source = citation.source;
1023
+ }
1024
+
1025
+ if (Object.keys(langchain).length === 0) {
1026
+ return undefined;
1027
+ }
1028
+
1029
+ return { langchain };
1030
+ }
1031
+
1032
+ /**
1033
+ * Emits AI SDK source chunks (`source-url` / `source-document`) for the given
1034
+ * citations.
1035
+ *
1036
+ * Citations with a `url` become `source-url` parts keyed by that url; the rest
1037
+ * become `source-document` parts. Citation metadata that the source parts cannot
1038
+ * represent natively (`citedText`, `startIndex`, `endIndex`, `source`) is preserved
1039
+ * under `providerMetadata.langchain`.
1040
+ *
1041
+ * `emittedSourceIds` dedupes across the LangGraph messages and values events, which
1042
+ * can each surface the same citation. URL-less citations are keyed by their content
1043
+ * rather than position, so a differing citation subset/order between those two events
1044
+ * does not cause an id collision.
1045
+ */
1046
+ export function emitSourceChunks(
1047
+ citations: NormalizedCitation[],
1048
+ messageId: string,
1049
+ emittedSourceIds: Set<string>,
1050
+ controller: ReadableStreamDefaultController<UIMessageChunk>,
1051
+ ): void {
1052
+ for (const citation of citations) {
1053
+ if (citation.url) {
1054
+ if (emittedSourceIds.has(citation.url)) continue;
1055
+ emittedSourceIds.add(citation.url);
1056
+
1057
+ const providerMetadata = buildSourceProviderMetadata(citation);
1058
+ controller.enqueue({
1059
+ type: 'source-url',
1060
+ sourceId: citation.url,
1061
+ url: citation.url,
1062
+ ...(citation.title ? { title: citation.title } : {}),
1063
+ ...(providerMetadata ? { providerMetadata } : {}),
1064
+ });
1065
+ continue;
1066
+ }
1067
+
1068
+ // A citation with no url and no human-readable label carries no information a
1069
+ // UI could render, so skip it rather than emit a placeholder source.
1070
+ const title = citation.title ?? citation.source;
1071
+ if (!title) continue;
1072
+
1073
+ const sourceId = `${messageId}:${title}:${citation.citedText ?? ''}:${citation.startIndex ?? ''}:${citation.endIndex ?? ''}`;
1074
+ if (emittedSourceIds.has(sourceId)) continue;
1075
+ emittedSourceIds.add(sourceId);
1076
+
1077
+ const providerMetadata = buildSourceProviderMetadata(citation);
1078
+ controller.enqueue({
1079
+ type: 'source-document',
1080
+ sourceId,
1081
+ mediaType: 'text/plain',
1082
+ title,
1083
+ ...(providerMetadata ? { providerMetadata } : {}),
1084
+ });
1085
+ }
1086
+ }
1087
+
924
1088
  /**
925
1089
  * Checks if an object is an image generation output
926
1090
  *
@@ -1293,6 +1457,16 @@ export function processLangGraphEvent(
1293
1457
  id: msgId,
1294
1458
  });
1295
1459
  }
1460
+
1461
+ const citations = extractCitationsFromContentBlocks(msg);
1462
+ if (citations.length > 0) {
1463
+ emitSourceChunks(
1464
+ citations,
1465
+ msgId,
1466
+ state.emittedSourceIds,
1467
+ controller,
1468
+ );
1469
+ }
1296
1470
  } else if (isToolMessageType(msg)) {
1297
1471
  // Handle both direct properties and serialized messages (kwargs)
1298
1472
  const msgObj = msg as unknown as Record<string, unknown>;
@@ -1572,6 +1746,16 @@ export function processLangGraphEvent(
1572
1746
  emittedReasoningIds.add(reasoningId);
1573
1747
  }
1574
1748
  }
1749
+
1750
+ const valuesCitations = extractCitationsFromContentBlocks(msg);
1751
+ if (valuesCitations.length > 0) {
1752
+ emitSourceChunks(
1753
+ valuesCitations,
1754
+ msgId,
1755
+ state.emittedSourceIds,
1756
+ controller,
1757
+ );
1758
+ }
1575
1759
  }
1576
1760
  }
1577
1761
  }