@aleph-alpha/chat-kit 5.5.0 → 5.7.0

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.
Files changed (41) hide show
  1. package/dist/{CkHistory.vue_vue_type_script_setup_true_lang-C1busuyf.js → CkHistory.vue_vue_type_script_setup_true_lang-YHwE2DYx.js} +27 -9
  2. package/dist/adapters/responses-api.d.ts +3 -1
  3. package/dist/adapters/responses-api.d.ts.map +1 -1
  4. package/dist/adapters/responses-api.js +158 -3
  5. package/dist/chat-kit.css +1 -0
  6. package/dist/components/base/CkConversationLayout/CkConversationLayout.vue.d.ts +6 -0
  7. package/dist/components/base/CkConversationLayout/CkConversationLayout.vue.d.ts.map +1 -1
  8. package/dist/components/composed/CkConversation/CkConversation.vue.d.ts +6 -0
  9. package/dist/components/composed/CkConversation/CkConversation.vue.d.ts.map +1 -1
  10. package/dist/components/index.js +1 -1
  11. package/dist/composables/index.js +4 -4
  12. package/dist/handlers/index.js +4 -4
  13. package/dist/handlers/useMessageHandler.d.ts +7 -2
  14. package/dist/handlers/useMessageHandler.d.ts.map +1 -1
  15. package/dist/helpers/conversationTree.d.ts +44 -4
  16. package/dist/helpers/conversationTree.d.ts.map +1 -1
  17. package/dist/{index-CyrVnbS9.js → index-Dn4ctxk8.js} +7 -6
  18. package/dist/index.d.ts +2 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +11 -11
  21. package/dist/services/types.d.ts +7 -0
  22. package/dist/services/types.d.ts.map +1 -1
  23. package/dist/types.d.ts +38 -0
  24. package/dist/types.d.ts.map +1 -1
  25. package/dist/{useChatKitLabels-DLy0BFx8.js → useChatKitLabels-CMOr3Kjq.js} +2 -2
  26. package/dist/{useMessageHandler-N50Yj-NU.js → useMessageHandler-Tellov3w.js} +112 -30
  27. package/package.json +4 -2
  28. package/src/adapters/arena.spec.ts +86 -0
  29. package/src/adapters/responses-api.ts +276 -2
  30. package/src/components/base/CkConversationLayout/CkConversationLayout.vue +28 -2
  31. package/src/components/composed/CkConversation/CkConversation.vue +10 -0
  32. package/src/composables/useConversations.spec.ts +2 -0
  33. package/src/composables/useConversations.ts +1 -0
  34. package/src/handlers/useMessageHandler.ts +46 -6
  35. package/src/helpers/comparison.spec.ts +114 -0
  36. package/src/helpers/conversationTree.spec.ts +98 -0
  37. package/src/helpers/conversationTree.ts +211 -26
  38. package/src/index.ts +10 -0
  39. package/src/services/types.ts +7 -0
  40. package/src/types.ts +40 -0
  41. package/src/uno-virtual.d.ts +2 -0
@@ -7,9 +7,15 @@ const props = defineProps<{
7
7
  messages: T[];
8
8
  }>();
9
9
 
10
- defineSlots<{
10
+ const slots = defineSlots<{
11
11
  'user-message': (props: { message: T }) => void;
12
12
  'agent-message': (props: { message: T }) => void;
13
+ /**
14
+ * Extra content appended after the last message, inside the scroll flow and
15
+ * aligned like an agent turn. Use for a trailing element that continues the
16
+ * thread but isn't a tree message (e.g. an in-progress preference comparison).
17
+ */
18
+ 'thread-footer': () => void;
13
19
  }>();
14
20
 
15
21
  const scrollContainerRef = ref<HTMLElement | null>(null);
@@ -188,12 +194,23 @@ watch(
188
194
 
189
195
  // Follow agent content: handles both new agent messages and streaming updates.
190
196
  // First shrinks the spacer to match the growing content, then scrolls when it overflows.
197
+ //
198
+ // The trailing content can also be the `thread-footer` slot (e.g. streaming
199
+ // preference-comparison lanes), which continues the thread after a trunk whose
200
+ // last message is the shared *user* prompt. That case must run the same
201
+ // spacer-shrink/follow pass — otherwise the full-viewport spacer set by
202
+ // `scrollUserMessageToTop` is never reduced, leaving a viewport-sized gap that
203
+ // pushes the prompt and answers out of view. `updateSpacerHeight` already
204
+ // measures the footer (it carries `data-message-row="agent"`, so it is the
205
+ // `lastRow`); it just needs to be invoked here. The watch fires on every tree
206
+ // mutation because the messages array is recreated per projection.
191
207
  watch(
192
208
  () => props.messages,
193
209
  () => {
194
210
  if (props.messages.length === 0) return;
195
211
  const lastMessage = props.messages[props.messages.length - 1];
196
- if (lastMessage && lastMessage.role !== 'user') {
212
+ const hasFooter = Boolean(slots['thread-footer']);
213
+ if ((lastMessage && lastMessage.role !== 'user') || hasFooter) {
197
214
  nextTick(() => {
198
215
  updateSpacerHeight();
199
216
  if (isAutoScrollEnabled.value) {
@@ -233,6 +250,15 @@ watch(
233
250
  <slot name="agent-message" :message="message" />
234
251
  </div>
235
252
  </div>
253
+ <!-- Trailing non-message content (e.g. an open comparison), rendered as
254
+ the continuation of the thread inside the scroll flow. -->
255
+ <div
256
+ v-if="$slots['thread-footer']"
257
+ data-message-row="agent"
258
+ class="flex min-w-0 justify-start"
259
+ >
260
+ <slot name="thread-footer" />
261
+ </div>
236
262
  <!-- Bottom spacer: shrinks as agent content grows to keep viewport stable -->
237
263
  <div
238
264
  v-if="hasBottomSpacer"
@@ -58,6 +58,12 @@ const slots = defineSlots<{
58
58
  code: string;
59
59
  language: string;
60
60
  }) => void;
61
+ /**
62
+ * Trailing content appended after the last message, inside the scroll flow
63
+ * and aligned like an agent turn — e.g. an in-progress preference comparison
64
+ * that continues the thread but isn't a tree message.
65
+ */
66
+ 'thread-footer': () => void;
61
67
  }>();
62
68
 
63
69
  const labels = useChatKitLabels();
@@ -229,5 +235,9 @@ function hasHeaderActivity(message: BaseMessage): boolean {
229
235
  </CkAgentMessage>
230
236
  </slot>
231
237
  </template>
238
+
239
+ <template v-if="$slots['thread-footer']" #thread-footer>
240
+ <slot name="thread-footer" />
241
+ </template>
232
242
  </CkConversationLayout>
233
243
  </template>
@@ -80,6 +80,7 @@ describe('useConversations', () => {
80
80
  title: 'hey again',
81
81
  createdAt: new Date(1_777_456_746 * 1000),
82
82
  updatedAt: new Date(1_777_456_750 * 1000),
83
+ metadata: { title: 'hey again' },
83
84
  });
84
85
  });
85
86
 
@@ -290,6 +291,7 @@ describe('useConversations', () => {
290
291
  title: 'far away',
291
292
  createdAt: new Date(1_777_456_700 * 1000),
292
293
  updatedAt: new Date(1_777_456_701 * 1000),
294
+ metadata: { title: 'far away' },
293
295
  });
294
296
  });
295
297
 
@@ -332,6 +332,7 @@ function mapConversation(raw: RawConversation): ConversationSummary {
332
332
  title: raw.metadata?.title ?? '',
333
333
  createdAt: secondsToDate(raw.created_at),
334
334
  updatedAt: secondsToDate(raw.updated_at),
335
+ metadata: raw.metadata ?? null,
335
336
  };
336
337
  }
337
338
 
@@ -4,12 +4,15 @@ import {
4
4
  createEmptyTree,
5
5
  cutBranchAfter as treeCutBranchAfter,
6
6
  cutBranchAt as treeCutBranchAt,
7
- getLinearPath,
7
+ markComparison as treeMarkComparison,
8
8
  navigate as treeNavigate,
9
+ projectActivePath,
9
10
  updateNode,
10
11
  updateNodeId,
12
+ voteComparison as treeVoteComparison,
11
13
  } from '../helpers';
12
14
  import type {
15
+ ActiveComparison,
13
16
  BaseMessage,
14
17
  ConversationTree,
15
18
  MessageContent,
@@ -24,10 +27,20 @@ export const useMessageHandler = () => {
24
27
  const tree = reactive<ConversationTree>(createEmptyTree());
25
28
  const currentUserMessage = ref('');
26
29
 
27
- const messages = computed<BaseMessage[]>(() => getLinearPath(tree));
30
+ // One walk produces the linear trunk and (at most) one active comparison, so
31
+ // the two projections can never drift.
32
+ const projection = computed(() => projectActivePath(tree));
33
+ const messages = computed<BaseMessage[]>(() => projection.value.trunk);
34
+ const activeComparison = computed<ActiveComparison | null>(
35
+ () => projection.value.activeComparison,
36
+ );
28
37
 
29
38
  const currentLeafId = computed<string | null>(() => {
30
- const list = messages.value;
39
+ // No single leaf while a comparison is open at the tail — sending is gated
40
+ // on `activeComparison` and "continue with winner" passes an explicit
41
+ // parentId (the winner branch).
42
+ if (projection.value.activeComparison) return null;
43
+ const list = projection.value.trunk;
31
44
  return list.length > 0 ? list[list.length - 1].id : null;
32
45
  });
33
46
 
@@ -114,10 +127,18 @@ export const useMessageHandler = () => {
114
127
  status: MessageStatus;
115
128
  metadata?: MessageMetadata;
116
129
  parentId?: string;
130
+ comparing?: boolean;
117
131
  }) {
118
132
  const existing = tree.nodes[message.id];
119
133
 
120
134
  if (existing) {
135
+ // `parentId` is ignored for an already-present node (it is never
136
+ // re-linked), so resolve it only to mark a comparison — and use the node's
137
+ // *actual* parent, so a stale/unknown `parentId` on the patch is a safe
138
+ // no-op rather than an `Unknown parent message id` throw.
139
+ if (message.comparing && existing.parentId) {
140
+ treeMarkComparison(tree, existing.parentId);
141
+ }
121
142
  updateNode(tree, message.id, {
122
143
  content: message.content,
123
144
  role: message.role,
@@ -127,9 +148,13 @@ export const useMessageHandler = () => {
127
148
  : {}),
128
149
  });
129
150
  } else {
151
+ const parentId = resolveParentId(message.parentId);
152
+ if (message.comparing && parentId) {
153
+ treeMarkComparison(tree, parentId);
154
+ }
130
155
  const node: TreeMessage = {
131
156
  id: message.id,
132
- parentId: resolveParentId(message.parentId),
157
+ parentId,
133
158
  childrenIds: [],
134
159
  activeChildIndex: 0,
135
160
  role: message.role,
@@ -187,9 +212,13 @@ export const useMessageHandler = () => {
187
212
  /**
188
213
  * Drop every message that comes after `messageId` on every branch beneath
189
214
  * it. The target message is kept and becomes the new leaf of its branch.
215
+ *
216
+ * Pass `keepChildId` (a direct child of `messageId`) to spare one branch: it
217
+ * survives as the target's sole, active child while its siblings are dropped
218
+ * — e.g. keeping a chosen comparison winner and discarding the rest.
190
219
  */
191
- function cutBranchAfter(messageId: string) {
192
- treeCutBranchAfter(tree, messageId);
220
+ function cutBranchAfter(messageId: string, keepChildId?: string) {
221
+ treeCutBranchAfter(tree, messageId, keepChildId);
193
222
  }
194
223
 
195
224
  /**
@@ -205,8 +234,18 @@ export const useMessageHandler = () => {
205
234
  return readonly(tree);
206
235
  }
207
236
 
237
+ /**
238
+ * Record the winner of the comparison rooted at `parentId` and reveal it.
239
+ * Does not collapse the comparison — it stays a labelled, revealed view
240
+ * until the user continues past the winner (appends a turn under it).
241
+ */
242
+ function voteComparison(parentId: string, winnerBranchId: string) {
243
+ treeVoteComparison(tree, parentId, winnerBranchId);
244
+ }
245
+
208
246
  return {
209
247
  messages: messages as Ref<BaseMessage[]>,
248
+ activeComparison,
210
249
  currentUserMessage: readonly(currentUserMessage),
211
250
  currentLeafId,
212
251
  updateMessage,
@@ -220,6 +259,7 @@ export const useMessageHandler = () => {
220
259
  navigate,
221
260
  cutBranchAfter,
222
261
  cutBranchAt,
262
+ voteComparison,
223
263
  getTree,
224
264
  };
225
265
  };
@@ -0,0 +1,114 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ addNode,
5
+ createEmptyTree,
6
+ markComparison,
7
+ projectActivePath,
8
+ voteComparison,
9
+ } from './conversationTree';
10
+ import type { ConversationTree, MessageContent, TreeMessage } from '../types';
11
+
12
+ function node(
13
+ id: string,
14
+ parentId: string | null,
15
+ role: TreeMessage['role'],
16
+ text = id,
17
+ ): TreeMessage {
18
+ const content: MessageContent[] = [
19
+ { type: 'text', id: `${id}-t`, text, status: 'completed' },
20
+ ];
21
+ return {
22
+ id,
23
+ parentId,
24
+ childrenIds: [],
25
+ activeChildIndex: 0,
26
+ role,
27
+ content,
28
+ status: 'completed',
29
+ createdAt: new Date(0),
30
+ };
31
+ }
32
+
33
+ /** user U with two assistant children A/B, marked as a comparison. */
34
+ function comparisonTree(): ConversationTree {
35
+ const tree = createEmptyTree();
36
+ addNode(tree, node('U', null, 'user', 'prompt'));
37
+ addNode(tree, node('A', 'U', 'assistant', 'answer A'), { activate: false });
38
+ addNode(tree, node('B', 'U', 'assistant', 'answer B'), { activate: false });
39
+ markComparison(tree, 'U');
40
+ return tree;
41
+ }
42
+
43
+ describe('projectActivePath', () => {
44
+ it('surfaces an unresolved comparison as blind lanes at the trunk tail', () => {
45
+ const { trunk, activeComparison } = projectActivePath(comparisonTree());
46
+
47
+ expect(trunk.map((m) => m.id)).toEqual(['U']);
48
+ expect(activeComparison).not.toBeNull();
49
+ expect(activeComparison!.resolved).toBe(false);
50
+ expect(activeComparison!.winnerBranchId).toBeNull();
51
+ expect(activeComparison!.lanes.map((l) => l.branchId)).toEqual(['A', 'B']);
52
+ expect(activeComparison!.lanes[0]!.path.map((m) => m.id)).toEqual(['A']);
53
+ });
54
+
55
+ it('keeps the comparison active + revealed after a vote (not yet continued)', () => {
56
+ const tree = comparisonTree();
57
+ voteComparison(tree, 'U', 'A');
58
+
59
+ const { trunk, activeComparison } = projectActivePath(tree);
60
+ expect(trunk.map((m) => m.id)).toEqual(['U']);
61
+ expect(activeComparison).not.toBeNull();
62
+ expect(activeComparison!.resolved).toBe(true);
63
+ expect(activeComparison!.winnerBranchId).toBe('A');
64
+ });
65
+
66
+ it('collapses to a linear winner chain once the winner is continued', () => {
67
+ const tree = comparisonTree();
68
+ voteComparison(tree, 'U', 'A');
69
+ // Continue past the winner: a follow-up turn under A.
70
+ addNode(tree, node('U2', 'A', 'user', 'follow up'));
71
+
72
+ const { trunk, activeComparison } = projectActivePath(tree);
73
+ expect(activeComparison).toBeNull();
74
+ // History: the fork is transparent, trunk flows through the winner.
75
+ expect(trunk.map((m) => m.id)).toEqual(['U', 'A', 'U2']);
76
+ });
77
+
78
+ it('does not treat ordinary regenerate siblings as a comparison', () => {
79
+ const tree = createEmptyTree();
80
+ addNode(tree, node('U', null, 'user'));
81
+ addNode(tree, node('A1', 'U', 'assistant'));
82
+ addNode(tree, node('A2', 'U', 'assistant')); // regenerate: latest active
83
+ const { trunk, activeComparison } = projectActivePath(tree);
84
+ expect(activeComparison).toBeNull();
85
+ expect(trunk.map((m) => m.id)).toEqual(['U', 'A2']);
86
+ });
87
+ });
88
+
89
+ describe('addNode activate flag', () => {
90
+ it('does not move activeChildIndex when activate:false', () => {
91
+ const tree = createEmptyTree();
92
+ addNode(tree, node('U', null, 'user'));
93
+ addNode(tree, node('A', 'U', 'assistant'), { activate: false });
94
+ addNode(tree, node('B', 'U', 'assistant'), { activate: false });
95
+ expect(tree.nodes['U']!.activeChildIndex).toBe(0);
96
+ expect(tree.nodes['U']!.childrenIds).toEqual(['A', 'B']);
97
+ });
98
+ });
99
+
100
+ describe('voteComparison', () => {
101
+ it('records the winner and points activeChildIndex at it', () => {
102
+ const tree = comparisonTree();
103
+ voteComparison(tree, 'U', 'B');
104
+ expect(tree.nodes['U']!.comparisonWinnerId).toBe('B');
105
+ expect(tree.nodes['U']!.activeChildIndex).toBe(1);
106
+ expect(tree.nodes['U']!.comparing).toBe(true); // stays a comparison
107
+ });
108
+
109
+ it('is a no-op for an unknown winner id', () => {
110
+ const tree = comparisonTree();
111
+ voteComparison(tree, 'U', 'nope');
112
+ expect(tree.nodes['U']!.comparisonWinnerId).toBeUndefined();
113
+ });
114
+ });
@@ -518,6 +518,67 @@ describe('cutBranchAfter', () => {
518
518
 
519
519
  expect(Object.keys(tree.nodes).sort()).toEqual(before);
520
520
  });
521
+
522
+ it('clears comparison state on the target — no children left to compare', () => {
523
+ const tree = makeBranchingTree();
524
+ tree.nodes.u2.comparing = true;
525
+ tree.nodes.u2.comparisonWinnerId = 'a2';
526
+
527
+ cutBranchAfter(tree, 'u2');
528
+
529
+ expect(tree.nodes.u2.comparing).toBeUndefined();
530
+ expect(tree.nodes.u2.comparisonWinnerId).toBeUndefined();
531
+ });
532
+
533
+ it('spares the exempted child and makes it the sole active child', () => {
534
+ const tree = makeBranchingTree();
535
+ // u2 has children [a2, a3]; keep a3.
536
+ cutBranchAfter(tree, 'u2', 'a3');
537
+
538
+ expect(tree.nodes.a2).toBeUndefined();
539
+ expect(tree.nodes.a3).toBeDefined();
540
+ expect(tree.nodes.u2.childrenIds).toEqual(['a3']);
541
+ expect(tree.nodes.u2.activeChildIndex).toBe(0);
542
+ expect(getLinearPath(tree).map((m) => m.id)).toEqual([
543
+ 'u1',
544
+ 'a1',
545
+ 'u2',
546
+ 'a3',
547
+ ]);
548
+ });
549
+
550
+ it('keeps the exempted child subtree intact', () => {
551
+ const tree = makeBranchingTree();
552
+ // Give a3 a follow-up so we can confirm the whole branch survives.
553
+ addNode(tree, makeTreeMessage({ id: 'u4', parentId: 'a3', role: 'user' }));
554
+
555
+ cutBranchAfter(tree, 'u2', 'a3');
556
+
557
+ expect(tree.nodes.a3).toBeDefined();
558
+ expect(tree.nodes.u4).toBeDefined();
559
+ });
560
+
561
+ it('clears comparison state even when a child is spared (single lane left)', () => {
562
+ const tree = makeBranchingTree();
563
+ tree.nodes.u2.comparing = true;
564
+ tree.nodes.u2.comparisonWinnerId = 'a3';
565
+
566
+ cutBranchAfter(tree, 'u2', 'a3');
567
+
568
+ expect(tree.nodes.u2.childrenIds).toEqual(['a3']);
569
+ expect(tree.nodes.u2.comparing).toBeUndefined();
570
+ expect(tree.nodes.u2.comparisonWinnerId).toBeUndefined();
571
+ });
572
+
573
+ it('ignores an unknown or non-child keepChildId and removes every branch', () => {
574
+ const tree = makeBranchingTree();
575
+
576
+ cutBranchAfter(tree, 'u2', 'nonexistent');
577
+
578
+ expect(tree.nodes.a2).toBeUndefined();
579
+ expect(tree.nodes.a3).toBeUndefined();
580
+ expect(tree.nodes.u2.childrenIds).toEqual([]);
581
+ });
521
582
  });
522
583
 
523
584
  describe('cutBranchAt', () => {
@@ -637,6 +698,43 @@ describe('cutBranchAt', () => {
637
698
 
638
699
  expect(Object.keys(tree.nodes).sort()).toEqual(before);
639
700
  });
701
+
702
+ it('clears the parent comparison when fewer than two lanes remain', () => {
703
+ const tree = makeThreeSiblingTree();
704
+ tree.nodes.p.comparing = true;
705
+ // Down to [cA] after removing cB and cC: nothing left to compare.
706
+ cutBranchAt(tree, 'cB');
707
+ cutBranchAt(tree, 'cC');
708
+
709
+ expect(tree.nodes.p.childrenIds).toEqual(['cA']);
710
+ expect(tree.nodes.p.comparing).toBeUndefined();
711
+ expect(tree.nodes.p.comparisonWinnerId).toBeUndefined();
712
+ });
713
+
714
+ it('drops the dangling winner when the winning lane is removed but others remain', () => {
715
+ const tree = makeThreeSiblingTree();
716
+ tree.nodes.p.comparing = true;
717
+ tree.nodes.p.comparisonWinnerId = 'cA';
718
+
719
+ cutBranchAt(tree, 'cA');
720
+
721
+ // Still a comparison (cB, cC remain) but the recorded vote is gone.
722
+ expect(tree.nodes.p.childrenIds).toEqual(['cB', 'cC']);
723
+ expect(tree.nodes.p.comparing).toBe(true);
724
+ expect(tree.nodes.p.comparisonWinnerId).toBeUndefined();
725
+ });
726
+
727
+ it('keeps the comparison and winner when a losing lane is removed', () => {
728
+ const tree = makeThreeSiblingTree();
729
+ tree.nodes.p.comparing = true;
730
+ tree.nodes.p.comparisonWinnerId = 'cA';
731
+
732
+ cutBranchAt(tree, 'cB');
733
+
734
+ expect(tree.nodes.p.childrenIds).toEqual(['cA', 'cC']);
735
+ expect(tree.nodes.p.comparing).toBe(true);
736
+ expect(tree.nodes.p.comparisonWinnerId).toBe('cA');
737
+ });
640
738
  });
641
739
 
642
740
  describe('integration: full branching scenario', () => {