@kortyx/react 0.4.1 → 0.4.3

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/index.js CHANGED
@@ -41,7 +41,11 @@ function buildAssistantMessage(args) {
41
41
  role: "assistant",
42
42
  content: plainTextContent,
43
43
  debug: args.debug,
44
- ...args.trace ? args.trace : {}
44
+ ...args.trace ? args.trace : {},
45
+ ...args.checkpoint ? {
46
+ checkpointId: args.checkpoint.id,
47
+ checkpointTurnIndex: args.checkpoint.turnIndex
48
+ } : {}
45
49
  };
46
50
  return args.pieces.length > 0 ? { ...base, contentPieces: args.pieces } : base;
47
51
  }
@@ -51,7 +55,9 @@ var DEFAULT_STORAGE_KEYS = {
51
55
  messages: "chat.messages.v1",
52
56
  includeHistory: "chat.includeHistory.v1",
53
57
  sessionId: "chat.sessionId",
54
- workflowId: "chat.workflowId"
58
+ workflowId: "chat.workflowId",
59
+ branches: "chat.branches.v1",
60
+ responseVariants: "chat.responseVariants.v1"
55
61
  };
56
62
  var getLocalStorage = () => {
57
63
  const candidate = globalThis;
@@ -78,13 +84,40 @@ function createBrowserChatStorage(options) {
78
84
  const workflowId = storage.getItem(keys.workflowId) ?? "";
79
85
  const includeHistoryRaw = storage.getItem(keys.includeHistory);
80
86
  const rawMessages = storage.getItem(keys.messages);
87
+ const rawBranches = storage.getItem(keys.branches);
88
+ const rawResponseVariants = storage.getItem(keys.responseVariants);
81
89
  const parsedMessages = rawMessages ? JSON.parse(rawMessages) : [];
82
90
  const messages = Array.isArray(parsedMessages) ? parsedMessages.map((item) => parseMessage(item)).filter((message) => message !== null) : [];
91
+ const parsedBranches = rawBranches ? JSON.parse(rawBranches) : void 0;
92
+ const branches = parsedBranches && typeof parsedBranches === "object" ? Object.fromEntries(
93
+ Object.entries(
94
+ parsedBranches
95
+ ).flatMap(([sessionId2, branch]) => {
96
+ if (typeof branch.sessionId !== "string") return [];
97
+ const rawBranchMessages = Array.isArray(branch.messages) ? branch.messages : [];
98
+ const branchMessages = rawBranchMessages.map((item) => parseMessage(item)).filter((message) => message !== null).slice(-maxMessages);
99
+ return [
100
+ [
101
+ sessionId2,
102
+ {
103
+ sessionId: branch.sessionId,
104
+ ...typeof branch.parentSessionId === "string" ? { parentSessionId: branch.parentSessionId } : {},
105
+ ...typeof branch.forkedFrom === "string" ? { forkedFrom: branch.forkedFrom } : {},
106
+ messages: branchMessages,
107
+ ...Array.isArray(branch.checkpoints) ? { checkpoints: branch.checkpoints } : {}
108
+ }
109
+ ]
110
+ ];
111
+ })
112
+ ) : void 0;
113
+ const parsedResponseVariants = rawResponseVariants ? JSON.parse(rawResponseVariants) : void 0;
83
114
  return {
84
115
  sessionId,
85
116
  workflowId,
86
117
  includeHistory: includeHistoryRaw !== "0",
87
- messages
118
+ messages,
119
+ ...branches ? { branches } : {},
120
+ ...Array.isArray(parsedResponseVariants) ? { responseVariants: parsedResponseVariants } : {}
88
121
  };
89
122
  } catch {
90
123
  return {};
@@ -103,6 +136,28 @@ function createBrowserChatStorage(options) {
103
136
  storage.setItem(keys.includeHistory, state.includeHistory ? "1" : "0");
104
137
  const trimmedMessages = state.messages.slice(-maxMessages).map((message) => serializeMessage(message));
105
138
  storage.setItem(keys.messages, JSON.stringify(trimmedMessages));
139
+ if (state.branches) {
140
+ const serializedBranches = Object.fromEntries(
141
+ Object.entries(state.branches).map(([sessionId, branch]) => [
142
+ sessionId,
143
+ {
144
+ ...branch,
145
+ messages: branch.messages.slice(-maxMessages).map((message) => serializeMessage(message))
146
+ }
147
+ ])
148
+ );
149
+ storage.setItem(keys.branches, JSON.stringify(serializedBranches));
150
+ } else {
151
+ storage.removeItem(keys.branches);
152
+ }
153
+ if (state.responseVariants) {
154
+ storage.setItem(
155
+ keys.responseVariants,
156
+ JSON.stringify(state.responseVariants)
157
+ );
158
+ } else {
159
+ storage.removeItem(keys.responseVariants);
160
+ }
106
161
  } catch {
107
162
  }
108
163
  },
@@ -111,6 +166,8 @@ function createBrowserChatStorage(options) {
111
166
  const storage = getLocalStorage();
112
167
  if (!storage) return;
113
168
  storage.removeItem(keys.messages);
169
+ storage.removeItem(keys.branches);
170
+ storage.removeItem(keys.responseVariants);
114
171
  } catch {
115
172
  }
116
173
  }
@@ -132,7 +189,7 @@ function createChatTransport(args) {
132
189
  };
133
190
  }
134
191
  function createRouteChatTransport(args) {
135
- return createChatTransport({
192
+ const transport = createChatTransport({
136
193
  stream: (context) => {
137
194
  const body = args.createBody?.(context) ?? {
138
195
  sessionId: context.sessionId,
@@ -153,6 +210,41 @@ function createRouteChatTransport(args) {
153
210
  });
154
211
  }
155
212
  });
213
+ if (!args.checkpointEndpoint) return transport;
214
+ const fetchJson = async (body) => {
215
+ const fetchImpl = args.fetchImpl ?? fetch;
216
+ const response = await fetchImpl(args.checkpointEndpoint, {
217
+ method: "POST",
218
+ headers: {
219
+ "content-type": "application/json",
220
+ ...args.headers ?? {}
221
+ },
222
+ body: JSON.stringify(body)
223
+ });
224
+ if (!response.ok) {
225
+ let message = `Checkpoint request failed with status ${response.status}`;
226
+ try {
227
+ const parsed = await response.json();
228
+ if (typeof parsed.error === "string") message = parsed.error;
229
+ } catch {
230
+ }
231
+ throw new Error(message);
232
+ }
233
+ return await response.json();
234
+ };
235
+ return {
236
+ ...transport,
237
+ listCheckpoints: (sessionId) => fetchJson({ action: "list", sessionId }),
238
+ rollbackTo: (checkpointId) => fetchJson({
239
+ action: "rollback",
240
+ checkpointId
241
+ }),
242
+ fork: (checkpointId, options) => fetchJson({
243
+ action: "fork",
244
+ checkpointId,
245
+ ...options?.newSessionId ? { newSessionId: options.newSessionId } : {}
246
+ })
247
+ };
156
248
  }
157
249
 
158
250
  // src/create-live-chat-pieces.ts
@@ -437,6 +529,17 @@ function useStructuredStreams(options) {
437
529
  const get = (0, import_react2.useCallback)((streamId) => {
438
530
  return itemsRef.current.find((item) => item.streamId === streamId);
439
531
  }, []);
532
+ const deleteStream = (0, import_react2.useCallback)(
533
+ (streamId) => {
534
+ const deleted = accumulatorRef.current.delete(streamId);
535
+ if (!deleted) return false;
536
+ setNextItems(
537
+ itemsRef.current.filter((item) => item.streamId !== streamId)
538
+ );
539
+ return true;
540
+ },
541
+ [setNextItems]
542
+ );
440
543
  const byStreamId = (0, import_react2.useMemo)(
441
544
  () => Object.fromEntries(
442
545
  items.map((item) => [item.streamId, item.state])
@@ -448,7 +551,8 @@ function useStructuredStreams(options) {
448
551
  byStreamId,
449
552
  applyStreamChunk,
450
553
  clear,
451
- get
554
+ get,
555
+ delete: deleteStream
452
556
  };
453
557
  }
454
558
 
@@ -481,6 +585,62 @@ function findActiveTextInterrupt(args) {
481
585
  (piece) => piece.type === "interrupt" && piece.kind === "text"
482
586
  );
483
587
  }
588
+ function trimMessagesToCheckpoint(args) {
589
+ const hasCheckpointMetadata = args.messages.some(
590
+ (message) => typeof message.checkpointTurnIndex === "number"
591
+ );
592
+ if (!hasCheckpointMetadata && args.turnIndex > 0) return args.messages;
593
+ const keepThroughIndex = args.messages.reduce((lastIndex, message, index) => {
594
+ return typeof message.checkpointTurnIndex === "number" && message.checkpointTurnIndex <= args.turnIndex ? index : lastIndex;
595
+ }, -1);
596
+ return keepThroughIndex >= 0 ? args.messages.slice(0, keepThroughIndex + 1) : [];
597
+ }
598
+ function applyForkedPendingRequests(args) {
599
+ if (args.pendingRequests.length === 0) return args.messages;
600
+ const interruptPositions = args.messages.flatMap(
601
+ (message, messageIndex) => (message.contentPieces ?? []).flatMap(
602
+ (piece, pieceIndex) => piece.type === "interrupt" ? [{ messageIndex, pieceIndex }] : []
603
+ )
604
+ );
605
+ const positionsToPatch = interruptPositions.slice(
606
+ -args.pendingRequests.length
607
+ );
608
+ if (positionsToPatch.length === 0) return args.messages;
609
+ return args.messages.map((message, messageIndex) => {
610
+ if (!message.contentPieces) return message;
611
+ let changed = false;
612
+ const contentPieces = message.contentPieces.map((piece, pieceIndex) => {
613
+ if (piece.type !== "interrupt") return piece;
614
+ const patchIndex = positionsToPatch.findIndex(
615
+ (position) => position.messageIndex === messageIndex && position.pieceIndex === pieceIndex
616
+ );
617
+ const pending = args.pendingRequests[patchIndex];
618
+ if (!pending) return piece;
619
+ changed = true;
620
+ return {
621
+ ...piece,
622
+ resumeToken: pending.token,
623
+ requestId: pending.requestId,
624
+ ...pending.schema?.kind ? { kind: pending.schema.kind } : {},
625
+ ...typeof pending.schema?.multiple === "boolean" ? { multiple: pending.schema.multiple } : {},
626
+ ...typeof pending.schema?.question === "string" ? { question: pending.schema.question } : {},
627
+ ...typeof pending.schema?.schemaId === "string" ? { schemaId: pending.schema.schemaId } : {},
628
+ ...typeof pending.schema?.schemaVersion === "string" ? { schemaVersion: pending.schema.schemaVersion } : {},
629
+ ...typeof pending.schema?.id === "string" ? { interruptId: pending.schema.id } : {},
630
+ ...pending.schema?.meta ? { meta: pending.schema.meta } : {},
631
+ ...pending.options ? { options: pending.options } : {}
632
+ };
633
+ });
634
+ return changed ? { ...message, contentPieces } : message;
635
+ });
636
+ }
637
+ function latestAssistantMessageId(messages) {
638
+ for (let index = messages.length - 1; index >= 0; index--) {
639
+ const message = messages[index];
640
+ if (message?.role === "assistant") return message.id;
641
+ }
642
+ return null;
643
+ }
484
644
  function useChat(options) {
485
645
  const resolvedStorage = options.storage ?? defaultStorage;
486
646
  const transportRef = useLatestRef(options.transport);
@@ -495,13 +655,20 @@ function useChat(options) {
495
655
  const [error, setError] = (0, import_react3.useState)(null);
496
656
  const [streamContentPieces, setStreamContentPieces] = (0, import_react3.useState)([]);
497
657
  const [lastAssistantId, setLastAssistantId] = (0, import_react3.useState)(null);
658
+ const [checkpoints, setCheckpoints] = (0, import_react3.useState)([]);
498
659
  const [includeHistory, setIncludeHistory] = (0, import_react3.useState)(true);
499
660
  const [workflowId, setWorkflowId] = (0, import_react3.useState)("");
500
661
  const [sessionId, setSessionId] = (0, import_react3.useState)(null);
662
+ const [branches, setBranches] = (0, import_react3.useState)({});
663
+ const [responseVariants, setResponseVariants] = (0, import_react3.useState)([]);
501
664
  const [didHydrateStorage, setDidHydrateStorage] = (0, import_react3.useState)(false);
502
665
  const abortControllerRef = (0, import_react3.useRef)(null);
503
666
  const { streamDebug, clearStreamDebug, createRecorder } = useChatStreamDebug();
504
- const { applyStreamChunk, clear: clearStructuredStreams } = useStructuredStreams({
667
+ const {
668
+ applyStreamChunk,
669
+ clear: clearStructuredStreams,
670
+ delete: deleteStructuredStream
671
+ } = useStructuredStreams({
505
672
  createId
506
673
  });
507
674
  (0, import_react3.useEffect)(() => {
@@ -521,12 +688,28 @@ function useChat(options) {
521
688
  if (Array.isArray(storedState.messages) && storedState.messages.length > 0) {
522
689
  setMessages(storedState.messages);
523
690
  }
691
+ if (storedState.branches && typeof storedState.branches === "object") {
692
+ setBranches(
693
+ storedState.branches
694
+ );
695
+ }
696
+ if (Array.isArray(storedState.responseVariants)) {
697
+ setResponseVariants(
698
+ storedState.responseVariants
699
+ );
700
+ }
524
701
  setDidHydrateStorage(true);
525
702
  });
526
703
  return () => {
527
704
  cancelled = true;
528
705
  };
529
706
  }, [resolvedStorage]);
707
+ (0, import_react3.useEffect)(() => {
708
+ const nextLastAssistantId = latestAssistantMessageId(messages);
709
+ if (lastAssistantId !== nextLastAssistantId) {
710
+ setLastAssistantId(nextLastAssistantId);
711
+ }
712
+ }, [lastAssistantId, messages]);
530
713
  (0, import_react3.useEffect)(() => {
531
714
  if (!didHydrateStorage) return;
532
715
  const storage = storageRef.current;
@@ -534,21 +717,64 @@ function useChat(options) {
534
717
  sessionId,
535
718
  workflowId,
536
719
  includeHistory,
537
- messages
720
+ messages,
721
+ branches,
722
+ responseVariants
538
723
  });
539
724
  }, [
725
+ branches,
540
726
  didHydrateStorage,
541
727
  includeHistory,
542
728
  messages,
729
+ responseVariants,
543
730
  sessionId,
544
731
  storageRef,
545
732
  workflowId
546
733
  ]);
734
+ (0, import_react3.useEffect)(() => {
735
+ if (!didHydrateStorage || !sessionId) return;
736
+ setBranches((current) => ({
737
+ ...current,
738
+ [sessionId]: {
739
+ ...current[sessionId] ?? {},
740
+ sessionId,
741
+ messages,
742
+ checkpoints
743
+ }
744
+ }));
745
+ }, [checkpoints, didHydrateStorage, messages, sessionId]);
746
+ (0, import_react3.useEffect)(() => {
747
+ if (!didHydrateStorage || !sessionId) return;
748
+ setResponseVariants(
749
+ (current) => current.map((group) => {
750
+ const selectedVariant = group.variants.find(
751
+ (variant) => variant.id === group.selectedVariantId && variant.sessionId === sessionId
752
+ );
753
+ if (!selectedVariant) return group;
754
+ if (selectedVariant.messages === messages && selectedVariant.checkpoints === checkpoints) {
755
+ return group;
756
+ }
757
+ return {
758
+ ...group,
759
+ variants: group.variants.map(
760
+ (variant) => variant.id === selectedVariant.id ? {
761
+ ...variant,
762
+ messages,
763
+ checkpoints
764
+ } : variant
765
+ )
766
+ };
767
+ })
768
+ );
769
+ }, [checkpoints, didHydrateStorage, messages, sessionId]);
547
770
  const clearActiveStream = () => {
548
771
  abortControllerRef.current?.abort();
549
772
  abortControllerRef.current = null;
550
773
  setIsStreaming(false);
551
774
  setMessages([]);
775
+ setCheckpoints([]);
776
+ setBranches({});
777
+ setResponseVariants([]);
552
778
  setStreamContentPieces([]);
553
779
  clearStreamDebug();
554
780
  clearStructuredStreams();
@@ -560,6 +786,7 @@ function useChat(options) {
560
786
  };
561
787
  const resetSession = () => {
562
788
  setSessionId(null);
789
+ setCheckpoints([]);
563
790
  };
564
791
  const resetChat = () => {
565
792
  clearActiveStream();
@@ -577,7 +804,12 @@ function useChat(options) {
577
804
  const persistSessionId = (sid) => {
578
805
  setSessionId(sid);
579
806
  };
580
- const resolveSessionId = () => {
807
+ const resolveSessionId = (preferredSessionId) => {
808
+ if (preferredSessionId) {
809
+ if (sessionId !== preferredSessionId)
810
+ persistSessionId(preferredSessionId);
811
+ return preferredSessionId;
812
+ }
581
813
  const sid = sessionId ?? createId();
582
814
  if (!sessionId) persistSessionId(sid);
583
815
  return sid;
@@ -587,13 +819,14 @@ function useChat(options) {
587
819
  content: message.content
588
820
  });
589
821
  const prepareMessagesToSend = async (args) => {
822
+ const baseMessages = args.baseMessages ?? messages;
590
823
  const prepared = options.prepareContextMessages ? await options.prepareContextMessages({
591
- messages,
824
+ messages: baseMessages,
592
825
  sessionId: args.sid,
593
826
  workflowId,
594
827
  reason: args.reason,
595
828
  context: requestContext
596
- }) : includeHistory ? messages.map(toOutgoingHistoryMessage) : [];
829
+ }) : includeHistory ? baseMessages.map(toOutgoingHistoryMessage) : [];
597
830
  return [...prepared, args.outgoingMessage];
598
831
  };
599
832
  const streamAssistantResponse = async (args) => {
@@ -602,6 +835,7 @@ function useChat(options) {
602
835
  const debug = createRecorder(`${args.debugLabel} sessionId=${args.sid}`);
603
836
  let currentTrace;
604
837
  let completedTrace;
838
+ let currentCheckpoint;
605
839
  const pieces = createLiveChatPieces({
606
840
  createId,
607
841
  onChange: setStreamContentPieces,
@@ -637,6 +871,31 @@ function useChat(options) {
637
871
  }
638
872
  return;
639
873
  }
874
+ if (chunk.type === "checkpoint") {
875
+ currentCheckpoint = {
876
+ id: chunk.id,
877
+ turnIndex: chunk.turnIndex
878
+ };
879
+ setCheckpoints((prev) => {
880
+ const next = {
881
+ id: chunk.id,
882
+ sessionId: chunk.sessionId,
883
+ turnIndex: chunk.turnIndex,
884
+ createdAt: Date.now(),
885
+ nodes: [],
886
+ workflow: workflowId,
887
+ ...chunk.label ? { label: chunk.label } : {}
888
+ };
889
+ return [...prev.filter((item) => item.id !== chunk.id), next].sort(
890
+ (a, b) => a.turnIndex - b.turnIndex
891
+ );
892
+ });
893
+ return;
894
+ }
895
+ if (chunk.type === "structured-data-invalidated") {
896
+ deleteStructuredStream(chunk.streamId);
897
+ return;
898
+ }
640
899
  if (chunk.type === "error") {
641
900
  setError(new Error(chunk.message));
642
901
  }
@@ -649,36 +908,54 @@ function useChat(options) {
649
908
  });
650
909
  }
651
910
  });
652
- if (args.signal.aborted) return;
911
+ if (args.signal.aborted) return void 0;
653
912
  const assistant = buildAssistantMessage({
654
913
  createId,
655
914
  pieces: pieces.getPieces(),
656
915
  debug: debug.getAll(),
657
- trace: completedTrace ?? currentTrace
916
+ trace: completedTrace ?? currentTrace,
917
+ checkpoint: currentCheckpoint
658
918
  });
659
- setMessages((prev) => [...prev, assistant]);
919
+ setMessages(
920
+ (prev) => args.messagesBeforeAssistant ? [...args.messagesBeforeAssistant, assistant] : [...prev, assistant]
921
+ );
660
922
  setLastAssistantId(assistant.id);
661
923
  clearStructuredStreams();
662
924
  setStreamContentPieces([]);
663
925
  clearStreamDebug();
926
+ return assistant;
664
927
  };
665
928
  const isAbortError = (value, signal) => {
666
929
  if (signal.aborted) return true;
667
930
  if (!(value instanceof Error)) return false;
668
931
  return value.name === "AbortError";
669
932
  };
670
- const respondToHumanInput = async ({
933
+ const respondToHumanInputFromBase = async ({
671
934
  resumeToken,
672
935
  requestId,
673
936
  selected,
674
- text
937
+ text,
938
+ baseMessages,
939
+ sid
675
940
  }) => {
676
- if (isStreaming) return;
941
+ if (isStreaming) return void 0;
677
942
  setError(null);
678
943
  const label = typeof text === "string" && text.length > 0 ? text : selected.length > 0 ? selected.length === 1 ? selected[0] : selected.join(", ") : "(selection)";
679
944
  const userId = createId();
680
- const userMsg = { id: userId, role: "user", content: label };
681
- setMessages((prev) => [...prev, userMsg]);
945
+ const userMsg = {
946
+ id: userId,
947
+ role: "user",
948
+ content: label,
949
+ source: {
950
+ type: "interrupt-response",
951
+ resumeToken,
952
+ requestId,
953
+ selected,
954
+ ...text !== void 0 ? { text } : {}
955
+ }
956
+ };
957
+ const nextMessages = [...baseMessages ?? messages, userMsg];
958
+ setMessages(nextMessages);
682
959
  setIsStreaming(true);
683
960
  setStreamContentPieces([]);
684
961
  clearStreamDebug();
@@ -694,18 +971,25 @@ function useChat(options) {
694
971
  content: label,
695
972
  metadata: resumePayload
696
973
  };
697
- const sid = resolveSessionId();
974
+ const resolvedSessionId = resolveSessionId(sid);
698
975
  const messagesToSend = await prepareMessagesToSend({
699
- sid,
976
+ sid: resolvedSessionId,
700
977
  outgoingMessage: outgoing,
701
- reason: "resume"
978
+ reason: "resume",
979
+ baseMessages
702
980
  });
703
- await streamAssistantResponse({
704
- sid,
981
+ const assistantMessage = await streamAssistantResponse({
982
+ sid: resolvedSessionId,
705
983
  messagesToSend,
984
+ messagesBeforeAssistant: nextMessages,
706
985
  debugLabel: "runChat (resume)",
707
986
  signal: abortController.signal
708
987
  });
988
+ return {
989
+ userMessage: userMsg,
990
+ ...assistantMessage ? { assistantMessage } : {},
991
+ messages: assistantMessage ? [...nextMessages, assistantMessage] : nextMessages
992
+ };
709
993
  } catch (err) {
710
994
  if (isAbortError(err, abortController.signal)) return;
711
995
  const nextError = err instanceof Error ? err : new Error(String(err));
@@ -718,36 +1002,45 @@ function useChat(options) {
718
1002
  setIsStreaming(false);
719
1003
  }
720
1004
  };
1005
+ const respondToHumanInput = async (args) => {
1006
+ await respondToHumanInputFromBase(args);
1007
+ };
721
1008
  const respondToInterrupt = async (piece, response) => {
722
1009
  const text = response?.text;
723
1010
  const selected = response?.selected ?? (typeof text === "string" && text.length > 0 ? [text] : []);
724
- return respondToHumanInput({
1011
+ await respondToHumanInput({
725
1012
  resumeToken: piece.resumeToken,
726
1013
  requestId: piece.requestId,
727
1014
  selected,
728
1015
  ...text !== void 0 ? { text } : {}
729
1016
  });
730
1017
  };
731
- const send = async (text) => {
1018
+ const sendFromBase = async (text, baseMessages, sid) => {
732
1019
  const content = text.trim();
733
- if (!content || isStreaming) return;
1020
+ if (!content || isStreaming) return void 0;
734
1021
  setError(null);
735
1022
  const activeTextInterrupt = findActiveTextInterrupt({
736
- messages,
1023
+ messages: baseMessages ?? messages,
737
1024
  streamContentPieces
738
1025
  });
739
1026
  if (activeTextInterrupt) {
740
- await respondToHumanInput({
1027
+ return respondToHumanInputFromBase({
741
1028
  resumeToken: activeTextInterrupt.resumeToken,
742
1029
  requestId: activeTextInterrupt.requestId,
743
1030
  selected: [content],
744
- text: content
1031
+ text: content,
1032
+ baseMessages,
1033
+ sid
745
1034
  });
746
- return;
747
1035
  }
748
1036
  const userId = createId();
749
- const userMsg = { id: userId, role: "user", content };
750
- setMessages((prev) => [...prev, userMsg]);
1037
+ const userMsg = {
1038
+ id: userId,
1039
+ role: "user",
1040
+ content
1041
+ };
1042
+ const nextMessages = [...baseMessages ?? messages, userMsg];
1043
+ setMessages(nextMessages);
751
1044
  setIsStreaming(true);
752
1045
  setStreamContentPieces([]);
753
1046
  clearStreamDebug();
@@ -755,19 +1048,26 @@ function useChat(options) {
755
1048
  const abortController = new AbortController();
756
1049
  abortControllerRef.current = abortController;
757
1050
  try {
758
- const sid = resolveSessionId();
1051
+ const resolvedSessionId = resolveSessionId(sid);
759
1052
  const messagesToSend = await prepareMessagesToSend({
760
- sid,
1053
+ sid: resolvedSessionId,
761
1054
  outgoingMessage: { role: "user", content },
762
- reason: "send"
1055
+ reason: "send",
1056
+ baseMessages
763
1057
  });
764
- await streamAssistantResponse({
765
- sid,
1058
+ const assistantMessage = await streamAssistantResponse({
1059
+ sid: resolvedSessionId,
766
1060
  messagesToSend,
1061
+ messagesBeforeAssistant: nextMessages,
767
1062
  debugLabel: "runChat (send)",
768
1063
  openDebugOnInterrupt: true,
769
1064
  signal: abortController.signal
770
1065
  });
1066
+ return {
1067
+ userMessage: userMsg,
1068
+ ...assistantMessage ? { assistantMessage } : {},
1069
+ messages: assistantMessage ? [...nextMessages, assistantMessage] : nextMessages
1070
+ };
771
1071
  } catch (err) {
772
1072
  if (isAbortError(err, abortController.signal)) return;
773
1073
  const nextError = err instanceof Error ? err : new Error(String(err));
@@ -780,6 +1080,375 @@ function useChat(options) {
780
1080
  setIsStreaming(false);
781
1081
  }
782
1082
  };
1083
+ const send = async (text) => {
1084
+ await sendFromBase(text);
1085
+ };
1086
+ const requireCheckpointTransport = () => {
1087
+ const transport = transportRef.current;
1088
+ if (!transport.listCheckpoints || !transport.rollbackTo || !transport.fork) {
1089
+ throw new Error(
1090
+ "Checkpoint helpers require a transport with checkpoint methods."
1091
+ );
1092
+ }
1093
+ return transport;
1094
+ };
1095
+ const refreshCheckpoints = async (sid) => {
1096
+ const transport = requireCheckpointTransport();
1097
+ const next = await transport.listCheckpoints(sid);
1098
+ setCheckpoints(next);
1099
+ return next;
1100
+ };
1101
+ const applyStructuredInvalidations = (streamIds) => {
1102
+ for (const streamId of streamIds) {
1103
+ deleteStructuredStream(streamId);
1104
+ }
1105
+ };
1106
+ const rollbackTo = async (id) => {
1107
+ if (isStreaming) {
1108
+ throw new Error("Cannot rollback while a stream is active.");
1109
+ }
1110
+ const transport = requireCheckpointTransport();
1111
+ const result = await transport.rollbackTo(id);
1112
+ applyStructuredInvalidations(result.invalidatedStructuredStreamIds);
1113
+ const refreshedCheckpoints = await refreshCheckpoints(result.sessionId);
1114
+ const target = refreshedCheckpoints.find(
1115
+ (checkpoint) => checkpoint.id === result.head
1116
+ );
1117
+ if (target) {
1118
+ setMessages((current) => {
1119
+ return trimMessagesToCheckpoint({
1120
+ messages: current,
1121
+ turnIndex: target.turnIndex
1122
+ });
1123
+ });
1124
+ }
1125
+ return result;
1126
+ };
1127
+ const fork = async (id, options2) => {
1128
+ if (isStreaming) {
1129
+ throw new Error("Cannot fork while a stream is active.");
1130
+ }
1131
+ const transport = requireCheckpointTransport();
1132
+ const sid = resolveSessionId();
1133
+ let availableCheckpoints = checkpoints.length > 0 ? checkpoints : await refreshCheckpoints(sid);
1134
+ let target = availableCheckpoints.find(
1135
+ (checkpoint) => checkpoint.id === id
1136
+ );
1137
+ if (!target && checkpoints.length > 0) {
1138
+ availableCheckpoints = await refreshCheckpoints(sid);
1139
+ target = availableCheckpoints.find((checkpoint) => checkpoint.id === id);
1140
+ }
1141
+ const result = await transport.fork(id, options2);
1142
+ setSessionId(result.sessionId);
1143
+ const childCheckpoints = await refreshCheckpoints(result.sessionId);
1144
+ const childHead = childCheckpoints.at(-1);
1145
+ const forkTurnIndex = target?.turnIndex ?? childHead?.turnIndex;
1146
+ if (typeof forkTurnIndex === "number") {
1147
+ setMessages(
1148
+ (current) => applyForkedPendingRequests({
1149
+ messages: trimMessagesToCheckpoint({
1150
+ messages: current,
1151
+ turnIndex: forkTurnIndex
1152
+ }),
1153
+ pendingRequests: result.checkpoint?.activePendingRequests ?? []
1154
+ })
1155
+ );
1156
+ }
1157
+ setStreamContentPieces([]);
1158
+ clearStructuredStreams();
1159
+ return result;
1160
+ };
1161
+ const checkpointForMessage = (messageId) => {
1162
+ return messages.find((message) => message.id === messageId)?.checkpointId ?? null;
1163
+ };
1164
+ const variantForMessage = (messageId) => {
1165
+ return responseVariants.find(
1166
+ (group) => group.sourceAssistantMessageId === messageId || group.variants.some(
1167
+ (variant) => variant.assistantMessageId === messageId
1168
+ )
1169
+ ) ?? null;
1170
+ };
1171
+ const findResumeOverrideForFork = (baseMessages, forkedBaseMessages, userMessage) => {
1172
+ if (userMessage.source?.type !== "interrupt-response") return void 0;
1173
+ const userSource = userMessage.source;
1174
+ const interruptPositions = baseMessages.flatMap(
1175
+ (message, messageIndex) => (message.contentPieces ?? []).flatMap(
1176
+ (piece, pieceIndex) => piece.type === "interrupt" ? [{ messageIndex, pieceIndex, piece }] : []
1177
+ )
1178
+ );
1179
+ const matchedPosition = [...interruptPositions].reverse().find(
1180
+ ({ piece }) => piece.resumeToken === userSource.resumeToken || piece.requestId === userSource.requestId
1181
+ );
1182
+ const forkedPiece = typeof matchedPosition?.messageIndex === "number" ? forkedBaseMessages[matchedPosition.messageIndex]?.contentPieces?.[matchedPosition.pieceIndex] : void 0;
1183
+ if (forkedPiece?.type === "interrupt") {
1184
+ return {
1185
+ resumeToken: forkedPiece.resumeToken,
1186
+ requestId: forkedPiece.requestId
1187
+ };
1188
+ }
1189
+ const latestForkedInterrupt = forkedBaseMessages.flatMap((message) => message.contentPieces ?? []).filter((piece) => piece.type === "interrupt").at(-1);
1190
+ return latestForkedInterrupt ? {
1191
+ resumeToken: latestForkedInterrupt.resumeToken,
1192
+ requestId: latestForkedInterrupt.requestId
1193
+ } : void 0;
1194
+ };
1195
+ const resolveRegenerateTarget = async (assistantMessageId) => {
1196
+ const assistantIndex = messages.findIndex(
1197
+ (message) => message.id === assistantMessageId
1198
+ );
1199
+ if (assistantIndex < 0) {
1200
+ throw new Error(`Assistant message "${assistantMessageId}" not found.`);
1201
+ }
1202
+ const assistant = messages[assistantIndex];
1203
+ if (!assistant) {
1204
+ throw new Error(`Assistant message "${assistantMessageId}" not found.`);
1205
+ }
1206
+ if (assistant.role !== "assistant") {
1207
+ throw new Error("Regenerate expects an assistant message id.");
1208
+ }
1209
+ let userIndex = -1;
1210
+ for (let index = assistantIndex - 1; index >= 0; index--) {
1211
+ if (messages[index]?.role === "user") {
1212
+ userIndex = index;
1213
+ break;
1214
+ }
1215
+ }
1216
+ if (userIndex < 0) {
1217
+ throw new Error("No preceding user message found for regenerate.");
1218
+ }
1219
+ const previousUser = messages[userIndex];
1220
+ const sid = resolveSessionId();
1221
+ let availableCheckpoints = checkpoints.length > 0 ? checkpoints : await refreshCheckpoints(sid);
1222
+ const assistantTurn = assistant.checkpointTurnIndex;
1223
+ const targetTurn = typeof assistantTurn === "number" ? assistantTurn - 1 : void 0;
1224
+ let target = targetTurn !== void 0 ? availableCheckpoints.find(
1225
+ (checkpoint) => checkpoint.turnIndex === targetTurn
1226
+ ) : availableCheckpoints.filter(
1227
+ (checkpoint) => checkpoint.turnIndex < Number.MAX_SAFE_INTEGER
1228
+ ).at(-2);
1229
+ if (!target && checkpoints.length > 0) {
1230
+ availableCheckpoints = await refreshCheckpoints(sid);
1231
+ target = targetTurn !== void 0 ? availableCheckpoints.find(
1232
+ (checkpoint) => checkpoint.turnIndex === targetTurn
1233
+ ) : availableCheckpoints.filter(
1234
+ (checkpoint) => checkpoint.turnIndex < Number.MAX_SAFE_INTEGER
1235
+ ).at(-2);
1236
+ }
1237
+ if (!target) {
1238
+ throw new Error("No rollback checkpoint found for regenerate.");
1239
+ }
1240
+ const baseMessages = trimMessagesToCheckpoint({
1241
+ messages,
1242
+ turnIndex: target.turnIndex
1243
+ });
1244
+ return { target, assistant, previousUser, baseMessages };
1245
+ };
1246
+ const replayUserMessage = async (userMessage, baseMessages, editedContent, options2) => {
1247
+ const content = editedContent ?? userMessage.content;
1248
+ if (userMessage.source?.type === "interrupt-response") {
1249
+ return respondToHumanInputFromBase({
1250
+ resumeToken: options2?.resumeOverride?.resumeToken ?? userMessage.source.resumeToken,
1251
+ requestId: options2?.resumeOverride?.requestId ?? userMessage.source.requestId,
1252
+ selected: editedContent !== void 0 ? [editedContent] : userMessage.source.selected,
1253
+ baseMessages,
1254
+ sid: options2?.sid,
1255
+ ...editedContent !== void 0 ? { text: editedContent } : userMessage.source.text !== void 0 ? { text: userMessage.source.text } : {}
1256
+ });
1257
+ }
1258
+ return sendFromBase(content, baseMessages, options2?.sid);
1259
+ };
1260
+ const buildVariant = (args) => ({
1261
+ id: args.id,
1262
+ sessionId: args.sessionId,
1263
+ assistantMessageId: args.assistantMessage.id,
1264
+ createdAt: Date.now(),
1265
+ messages: args.messages,
1266
+ checkpoints: args.checkpoints,
1267
+ ...args.assistantMessage.checkpointId ? { checkpointId: args.assistantMessage.checkpointId } : {},
1268
+ ...typeof args.assistantMessage.checkpointTurnIndex === "number" ? { checkpointTurnIndex: args.assistantMessage.checkpointTurnIndex } : {},
1269
+ ...args.label ? { label: args.label } : {}
1270
+ });
1271
+ const updateVariantSnapshot = (args) => ({
1272
+ ...args.variant,
1273
+ sessionId: args.sessionId,
1274
+ assistantMessageId: args.assistantMessage.id,
1275
+ messages: args.messages,
1276
+ checkpoints: args.checkpoints,
1277
+ ...args.assistantMessage.checkpointId ? { checkpointId: args.assistantMessage.checkpointId } : {},
1278
+ ...typeof args.assistantMessage.checkpointTurnIndex === "number" ? { checkpointTurnIndex: args.assistantMessage.checkpointTurnIndex } : {}
1279
+ });
1280
+ const replaceResponseVariantGroup = (nextGroup) => {
1281
+ setResponseVariants((current) => [
1282
+ ...current.filter((group) => group.id !== nextGroup.id),
1283
+ nextGroup
1284
+ ]);
1285
+ };
1286
+ const regenerateVariant = async (assistantMessageId) => {
1287
+ if (isStreaming) {
1288
+ throw new Error("Cannot regenerate a variant while a stream is active.");
1289
+ }
1290
+ const { target, assistant, previousUser, baseMessages } = await resolveRegenerateTarget(assistantMessageId);
1291
+ const transport = requireCheckpointTransport();
1292
+ const parentSessionId = resolveSessionId();
1293
+ const existingGroup = variantForMessage(assistantMessageId);
1294
+ const sourceAssistantMessageId = existingGroup?.sourceAssistantMessageId ?? assistantMessageId;
1295
+ const existingVariants = existingGroup ? existingGroup.variants.map(
1296
+ (variant) => variant.id === existingGroup.selectedVariantId || variant.assistantMessageId === assistantMessageId ? updateVariantSnapshot({
1297
+ variant,
1298
+ sessionId: parentSessionId,
1299
+ assistantMessage: assistant,
1300
+ messages,
1301
+ checkpoints
1302
+ }) : variant
1303
+ ) : void 0;
1304
+ const originalVariant = existingVariants?.[0] ?? buildVariant({
1305
+ id: `${sourceAssistantMessageId}:original`,
1306
+ sessionId: parentSessionId,
1307
+ assistantMessage: assistant,
1308
+ messages,
1309
+ checkpoints,
1310
+ label: "Original"
1311
+ });
1312
+ const forkResult = await transport.fork(target.id);
1313
+ const forkedSessionId = forkResult.sessionId;
1314
+ setSessionId(forkedSessionId);
1315
+ const childCheckpointsBeforeReplay = await refreshCheckpoints(forkedSessionId);
1316
+ const forkedBaseMessages = applyForkedPendingRequests({
1317
+ messages: baseMessages,
1318
+ pendingRequests: forkResult.checkpoint?.activePendingRequests ?? []
1319
+ });
1320
+ const resumeOverride = findResumeOverrideForFork(
1321
+ baseMessages,
1322
+ forkedBaseMessages,
1323
+ previousUser
1324
+ );
1325
+ setMessages(forkedBaseMessages);
1326
+ setStreamContentPieces([]);
1327
+ clearStructuredStreams();
1328
+ const replay = await replayUserMessage(
1329
+ previousUser,
1330
+ forkedBaseMessages,
1331
+ void 0,
1332
+ {
1333
+ sid: forkedSessionId,
1334
+ resumeOverride
1335
+ }
1336
+ );
1337
+ const childCheckpointsAfterReplay = await refreshCheckpoints(forkedSessionId);
1338
+ const variantMessages = replay?.messages ?? forkedBaseMessages;
1339
+ const variantAssistant = replay?.assistantMessage;
1340
+ if (!variantAssistant) {
1341
+ throw new Error("Regenerate variant did not produce an assistant reply.");
1342
+ }
1343
+ const nextVariant = buildVariant({
1344
+ id: createId(),
1345
+ sessionId: forkedSessionId,
1346
+ assistantMessage: variantAssistant,
1347
+ messages: variantMessages,
1348
+ checkpoints: childCheckpointsAfterReplay.length > 0 ? childCheckpointsAfterReplay : childCheckpointsBeforeReplay,
1349
+ label: `Variant ${(existingGroup?.variants.length ?? 1) + 1}`
1350
+ });
1351
+ const nextVariants = existingVariants ? [...existingVariants, nextVariant] : [originalVariant, nextVariant];
1352
+ const nextGroup = {
1353
+ id: existingGroup?.id ?? createId(),
1354
+ sourceAssistantMessageId,
1355
+ checkpointId: existingGroup?.checkpointId ?? target.id,
1356
+ checkpointTurnIndex: existingGroup?.checkpointTurnIndex ?? target.turnIndex,
1357
+ selectedVariantId: nextVariant.id,
1358
+ variants: nextVariants
1359
+ };
1360
+ replaceResponseVariantGroup(nextGroup);
1361
+ setBranches((current) => ({
1362
+ ...current,
1363
+ [parentSessionId]: {
1364
+ ...current[parentSessionId] ?? {},
1365
+ sessionId: parentSessionId,
1366
+ messages,
1367
+ checkpoints
1368
+ },
1369
+ [forkedSessionId]: {
1370
+ ...current[forkedSessionId] ?? {},
1371
+ sessionId: forkedSessionId,
1372
+ parentSessionId: forkResult.parentSessionId,
1373
+ forkedFrom: forkResult.forkedFrom,
1374
+ messages: variantMessages,
1375
+ checkpoints: nextVariant.checkpoints
1376
+ }
1377
+ }));
1378
+ return nextGroup;
1379
+ };
1380
+ const selectVariant = async (assistantMessageId, variantId) => {
1381
+ const group = variantForMessage(assistantMessageId);
1382
+ if (!group) {
1383
+ throw new Error(
1384
+ `No response variants found for "${assistantMessageId}".`
1385
+ );
1386
+ }
1387
+ const selectedVariant = group.variants.find(
1388
+ (variant) => variant.id === variantId
1389
+ );
1390
+ if (!selectedVariant) {
1391
+ throw new Error(`Response variant "${variantId}" not found.`);
1392
+ }
1393
+ setSessionId(selectedVariant.sessionId);
1394
+ setMessages(selectedVariant.messages);
1395
+ setCheckpoints(selectedVariant.checkpoints);
1396
+ setStreamContentPieces([]);
1397
+ clearStructuredStreams();
1398
+ setBranches((current) => ({
1399
+ ...current,
1400
+ [selectedVariant.sessionId]: {
1401
+ ...current[selectedVariant.sessionId] ?? {},
1402
+ sessionId: selectedVariant.sessionId,
1403
+ messages: selectedVariant.messages,
1404
+ checkpoints: selectedVariant.checkpoints
1405
+ }
1406
+ }));
1407
+ const nextGroup = {
1408
+ ...group,
1409
+ selectedVariantId: selectedVariant.id
1410
+ };
1411
+ replaceResponseVariantGroup(nextGroup);
1412
+ };
1413
+ const regenerate = async (assistantMessageId) => {
1414
+ const { target, previousUser, baseMessages } = await resolveRegenerateTarget(assistantMessageId);
1415
+ await rollbackTo(target.id);
1416
+ setMessages(baseMessages);
1417
+ await replayUserMessage(previousUser, baseMessages);
1418
+ };
1419
+ const regenerateFromCheckpoint = async (checkpointId) => {
1420
+ const sid = resolveSessionId();
1421
+ let availableCheckpoints = checkpoints.length > 0 ? checkpoints : await refreshCheckpoints(sid);
1422
+ let target = availableCheckpoints.find(
1423
+ (checkpoint) => checkpoint.id === checkpointId
1424
+ );
1425
+ if (!target && checkpoints.length > 0) {
1426
+ availableCheckpoints = await refreshCheckpoints(sid);
1427
+ target = availableCheckpoints.find(
1428
+ (checkpoint) => checkpoint.id === checkpointId
1429
+ );
1430
+ }
1431
+ if (!target) {
1432
+ throw new Error(`Checkpoint "${checkpointId}" not found.`);
1433
+ }
1434
+ const baseMessages = trimMessagesToCheckpoint({
1435
+ messages,
1436
+ turnIndex: target.turnIndex
1437
+ });
1438
+ const previousUser = messages.slice(baseMessages.length).find((message) => message.role === "user");
1439
+ if (!previousUser) {
1440
+ throw new Error("No user message found after checkpoint for regenerate.");
1441
+ }
1442
+ await rollbackTo(target.id);
1443
+ setMessages(baseMessages);
1444
+ await replayUserMessage(previousUser, baseMessages);
1445
+ };
1446
+ const retryWithEdit = async (assistantMessageId, newUserContent) => {
1447
+ const { target, previousUser, baseMessages } = await resolveRegenerateTarget(assistantMessageId);
1448
+ await rollbackTo(target.id);
1449
+ setMessages(baseMessages);
1450
+ await replayUserMessage(previousUser, baseMessages, newUserContent);
1451
+ };
783
1452
  return {
784
1453
  messages,
785
1454
  isStreaming,
@@ -788,12 +1457,25 @@ function useChat(options) {
788
1457
  streamContentPieces,
789
1458
  streamDebug,
790
1459
  lastAssistantId,
1460
+ checkpoints,
1461
+ activeSessionId: sessionId,
1462
+ branches,
1463
+ responseVariants,
791
1464
  workflowId,
792
1465
  setWorkflowId,
793
1466
  send,
794
1467
  respondToHumanInput,
795
1468
  respondToInterrupt,
796
1469
  setMessages,
1470
+ checkpointForMessage,
1471
+ rollbackTo,
1472
+ fork,
1473
+ regenerate,
1474
+ regenerateVariant,
1475
+ selectVariant,
1476
+ variantForMessage,
1477
+ regenerateFromCheckpoint,
1478
+ retryWithEdit,
797
1479
  clearMessages,
798
1480
  resetSession,
799
1481
  resetChat,