@aleph-alpha/chat-kit 5.4.0 → 5.6.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.
- package/dist/{CkHistory.vue_vue_type_script_setup_true_lang-D9oP6oEj.js → CkHistory.vue_vue_type_script_setup_true_lang-DHzZgEn-.js} +28 -10
- package/dist/adapters/responses-api.d.ts +3 -1
- package/dist/adapters/responses-api.d.ts.map +1 -1
- package/dist/adapters/responses-api.js +158 -3
- package/dist/components/base/CkConversationLayout/CkConversationLayout.vue.d.ts +6 -0
- package/dist/components/base/CkConversationLayout/CkConversationLayout.vue.d.ts.map +1 -1
- package/dist/components/composed/CkConversation/CkConversation.vue.d.ts +6 -0
- package/dist/components/composed/CkConversation/CkConversation.vue.d.ts.map +1 -1
- package/dist/components/index.js +1 -1
- package/dist/composables/index.js +1 -1
- package/dist/handlers/index.js +1 -1
- package/dist/handlers/useMessageHandler.d.ts +7 -2
- package/dist/handlers/useMessageHandler.d.ts.map +1 -1
- package/dist/helpers/conversationTree.d.ts +44 -4
- package/dist/helpers/conversationTree.d.ts.map +1 -1
- package/dist/{index-CyrVnbS9.js → index-Hcwv9Hby.js} +3 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/services/types.d.ts +7 -0
- package/dist/services/types.d.ts.map +1 -1
- package/dist/types.d.ts +38 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/{useMessageHandler-N50Yj-NU.js → useMessageHandler-Tellov3w.js} +112 -30
- package/package.json +2 -2
- package/src/adapters/arena.spec.ts +86 -0
- package/src/adapters/responses-api.ts +276 -2
- package/src/components/base/CkConversationLayout/CkConversationLayout.vue +30 -4
- package/src/components/composed/CkConversation/CkConversation.vue +10 -0
- package/src/composables/useConversations.spec.ts +2 -0
- package/src/composables/useConversations.ts +1 -0
- package/src/handlers/useMessageHandler.ts +46 -6
- package/src/helpers/comparison.spec.ts +114 -0
- package/src/helpers/conversationTree.spec.ts +98 -0
- package/src/helpers/conversationTree.ts +211 -26
- package/src/index.ts +2 -0
- package/src/services/types.ts +7 -0
- package/src/types.ts +40 -0
|
@@ -599,6 +599,10 @@ interface TreeBuildState {
|
|
|
599
599
|
// point for any future child response that chains onto it. Empty responses
|
|
600
600
|
// fall back to their parent's attachment point so the chain stays connected.
|
|
601
601
|
tailMessageByResponse: Map<string, string | null>;
|
|
602
|
+
// For each response, the id of its first user node (if any). Lets the arena
|
|
603
|
+
// collapse pass resolve a response's echoed user turn in O(1) instead of
|
|
604
|
+
// scanning every node; responses with no user input have no entry.
|
|
605
|
+
userMessageByResponse: Map<string, string>;
|
|
602
606
|
rootMessageId: string | null;
|
|
603
607
|
}
|
|
604
608
|
|
|
@@ -606,7 +610,7 @@ interface TreeBuildState {
|
|
|
606
610
|
// continuations fold onto the turn they extend; everything else appends under
|
|
607
611
|
// the chain parent resolved from `previous_response_id`.
|
|
608
612
|
function linkResponse(state: TreeBuildState, resp: StoredResponse): void {
|
|
609
|
-
const { treeNodes, tailMessageByResponse } = state;
|
|
613
|
+
const { treeNodes, tailMessageByResponse, userMessageByResponse } = state;
|
|
610
614
|
const parentResponseId =
|
|
611
615
|
resp.previous_response_id &&
|
|
612
616
|
tailMessageByResponse.has(resp.previous_response_id)
|
|
@@ -636,6 +640,11 @@ function linkResponse(state: TreeBuildState, resp: StoredResponse): void {
|
|
|
636
640
|
for (const node of messageNodes) {
|
|
637
641
|
appendNode(treeNodes, node, chainParent);
|
|
638
642
|
if (firstId === null) firstId = node.id;
|
|
643
|
+
// Record the first user node so the arena collapse pass can resolve this
|
|
644
|
+
// response's echoed user turn without scanning the node map.
|
|
645
|
+
if (node.role === 'user' && !userMessageByResponse.has(resp.id)) {
|
|
646
|
+
userMessageByResponse.set(resp.id, node.id);
|
|
647
|
+
}
|
|
639
648
|
chainParent = node.id;
|
|
640
649
|
}
|
|
641
650
|
|
|
@@ -667,6 +676,7 @@ function linkResponse(state: TreeBuildState, resp: StoredResponse): void {
|
|
|
667
676
|
*/
|
|
668
677
|
export function responsesToTree(
|
|
669
678
|
responses: StoredResponse[],
|
|
679
|
+
opts?: { arenaWinners?: Record<string, string> },
|
|
670
680
|
): ResponseConversationTree {
|
|
671
681
|
if (responses.length === 0) {
|
|
672
682
|
return { nodes: {}, rootId: null };
|
|
@@ -675,6 +685,7 @@ export function responsesToTree(
|
|
|
675
685
|
const state: TreeBuildState = {
|
|
676
686
|
treeNodes: {},
|
|
677
687
|
tailMessageByResponse: new Map<string, string | null>(),
|
|
688
|
+
userMessageByResponse: new Map<string, string>(),
|
|
678
689
|
rootMessageId: null,
|
|
679
690
|
};
|
|
680
691
|
|
|
@@ -682,5 +693,268 @@ export function responsesToTree(
|
|
|
682
693
|
linkResponse(state, resp);
|
|
683
694
|
}
|
|
684
695
|
|
|
685
|
-
|
|
696
|
+
const rootId = collapseArenaComparisons(
|
|
697
|
+
state.treeNodes,
|
|
698
|
+
responses,
|
|
699
|
+
opts?.arenaWinners ?? {},
|
|
700
|
+
state.rootMessageId,
|
|
701
|
+
state.userMessageByResponse,
|
|
702
|
+
);
|
|
703
|
+
|
|
704
|
+
return { nodes: state.treeNodes, rootId };
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
interface ParsedArena {
|
|
708
|
+
matchupId: string;
|
|
709
|
+
modelId?: string;
|
|
710
|
+
slot?: string;
|
|
711
|
+
slotSeed?: string;
|
|
712
|
+
variant?: string;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** Parse a stored response's `metadata.arena` (JSON string or dict). */
|
|
716
|
+
function parseArenaMeta(resp: StoredResponse): ParsedArena | null {
|
|
717
|
+
const raw = resp.metadata?.arena;
|
|
718
|
+
let arena: unknown = raw;
|
|
719
|
+
if (typeof raw === 'string') {
|
|
720
|
+
try {
|
|
721
|
+
arena = JSON.parse(raw);
|
|
722
|
+
} catch {
|
|
723
|
+
return null;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
if (!arena || typeof arena !== 'object') return null;
|
|
727
|
+
const a = arena as {
|
|
728
|
+
matchup_id?: unknown;
|
|
729
|
+
model_id?: unknown;
|
|
730
|
+
slot?: unknown;
|
|
731
|
+
slot_seed?: unknown;
|
|
732
|
+
variant?: unknown;
|
|
733
|
+
};
|
|
734
|
+
if (typeof a.matchup_id !== 'string') return null;
|
|
735
|
+
return {
|
|
736
|
+
matchupId: a.matchup_id,
|
|
737
|
+
modelId: typeof a.model_id === 'string' ? a.model_id : undefined,
|
|
738
|
+
slot: typeof a.slot === 'string' ? a.slot : undefined,
|
|
739
|
+
slotSeed: typeof a.slot_seed === 'string' ? a.slot_seed : undefined,
|
|
740
|
+
variant: typeof a.variant === 'string' ? a.variant : undefined,
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Fold arena sibling responses (same `metadata.arena.matchup_id`) into one
|
|
746
|
+
* comparison node: a single user turn with the pair's assistant answers as
|
|
747
|
+
* children, marked `comparing`. Each stored arena response carries its own
|
|
748
|
+
* echoed user input, so the naive tree has one user node per response — this
|
|
749
|
+
* collapses those duplicates so reload matches the live shape (one prompt, N
|
|
750
|
+
* lanes) that {@link projectActivePath} renders.
|
|
751
|
+
*
|
|
752
|
+
* Only pairs still present as ≥2 responses are folded: once a comparison is
|
|
753
|
+
* voted-and-continued the backend drops the loser (Decision 7-Discard), so a
|
|
754
|
+
* single surviving response is left as an ordinary linear turn. `arenaWinners`
|
|
755
|
+
* (matchup id → winning response id) marks the resolved winner so the reloaded
|
|
756
|
+
* comparison renders revealed rather than blind.
|
|
757
|
+
*
|
|
758
|
+
* Returns the (possibly updated) root id. Best-effort per group.
|
|
759
|
+
*/
|
|
760
|
+
function collapseArenaComparisons(
|
|
761
|
+
nodes: Record<string, ResponseTreeMessage>,
|
|
762
|
+
responses: StoredResponse[],
|
|
763
|
+
arenaWinners: Record<string, string>,
|
|
764
|
+
rootMessageId: string | null,
|
|
765
|
+
userMessageByResponse: Map<string, string>,
|
|
766
|
+
): string | null {
|
|
767
|
+
const userNodeFor = (responseId: string): ResponseTreeMessage | undefined => {
|
|
768
|
+
const id = userMessageByResponse.get(responseId);
|
|
769
|
+
return id ? nodes[id] : undefined;
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
let root = rootMessageId;
|
|
773
|
+
for (const [matchupId, group] of groupResponsesByMatchup(responses)) {
|
|
774
|
+
// Fewer than two survivors is no comparison: a lone response left after a
|
|
775
|
+
// vote-and-continue (7-Discard) stays an ordinary linear turn.
|
|
776
|
+
if (group.length < 2) continue;
|
|
777
|
+
try {
|
|
778
|
+
root = foldArenaGroup(
|
|
779
|
+
nodes,
|
|
780
|
+
group,
|
|
781
|
+
arenaWinners[matchupId],
|
|
782
|
+
userNodeFor,
|
|
783
|
+
root,
|
|
784
|
+
);
|
|
785
|
+
} catch {
|
|
786
|
+
// Leave this group as-is on any structural surprise.
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
return root;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/** Bucket the responses that carry arena metadata by their `matchup_id`. */
|
|
793
|
+
function groupResponsesByMatchup(
|
|
794
|
+
responses: StoredResponse[],
|
|
795
|
+
): Map<string, StoredResponse[]> {
|
|
796
|
+
const groups = new Map<string, StoredResponse[]>();
|
|
797
|
+
for (const resp of responses) {
|
|
798
|
+
const meta = parseArenaMeta(resp);
|
|
799
|
+
if (!meta) continue;
|
|
800
|
+
const group = groups.get(meta.matchupId) ?? [];
|
|
801
|
+
group.push(resp);
|
|
802
|
+
groups.set(meta.matchupId, group);
|
|
803
|
+
}
|
|
804
|
+
return groups;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/** The lane slot ('a' / 'b') stamped on an assistant node, or '' when absent. */
|
|
808
|
+
function laneSlot(node: ResponseTreeMessage | undefined): string {
|
|
809
|
+
return (node?.metadata.arena as { slot?: string } | undefined)?.slot ?? '';
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Order comparison lanes by slot ('a' before 'b') for stable column placement.
|
|
813
|
+
function compareLaneSlots(
|
|
814
|
+
nodes: Record<string, ResponseTreeMessage>,
|
|
815
|
+
x: string,
|
|
816
|
+
y: string,
|
|
817
|
+
): number {
|
|
818
|
+
const sx = laneSlot(nodes[x]);
|
|
819
|
+
const sy = laneSlot(nodes[y]);
|
|
820
|
+
if (sx < sy) return -1;
|
|
821
|
+
if (sx > sy) return 1;
|
|
822
|
+
return 0;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// Stamp a lane's model/slot onto its assistant node so the FE can resolve a
|
|
826
|
+
// column label. No-op when the assistant node or arena meta is missing.
|
|
827
|
+
function stampLaneAssistant(
|
|
828
|
+
nodes: Record<string, ResponseTreeMessage>,
|
|
829
|
+
resp: StoredResponse,
|
|
830
|
+
): void {
|
|
831
|
+
const meta = parseArenaMeta(resp);
|
|
832
|
+
const assistant = nodes[getAssistantNodeId(resp)];
|
|
833
|
+
if (assistant && meta) {
|
|
834
|
+
assistant.metadata.arena = { modelId: meta.modelId, slot: meta.slot };
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Re-parent a lane's assistant under the shared fork parent, without a duplicate
|
|
839
|
+
// child link.
|
|
840
|
+
function attachLane(
|
|
841
|
+
canonicalUser: ResponseTreeMessage,
|
|
842
|
+
assistant: ResponseTreeMessage,
|
|
843
|
+
): void {
|
|
844
|
+
assistant.parentId = canonicalUser.id;
|
|
845
|
+
if (!canonicalUser.childrenIds.includes(assistant.id)) {
|
|
846
|
+
canonicalUser.childrenIds.push(assistant.id);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// Drop a lane's duplicate echoed-user node, splicing it out of its original
|
|
851
|
+
// parent and clamping that parent's active index. Returns the root id, redirected
|
|
852
|
+
// onto the canonical user when the removed duplicate was itself the root.
|
|
853
|
+
function removeDuplicateUser(
|
|
854
|
+
nodes: Record<string, ResponseTreeMessage>,
|
|
855
|
+
dupUser: ResponseTreeMessage,
|
|
856
|
+
sharedParentId: string | null,
|
|
857
|
+
canonicalUserId: string,
|
|
858
|
+
root: string | null,
|
|
859
|
+
): string | null {
|
|
860
|
+
const parent = sharedParentId !== null ? nodes[sharedParentId] : undefined;
|
|
861
|
+
if (parent) {
|
|
862
|
+
parent.childrenIds = parent.childrenIds.filter((c) => c !== dupUser.id);
|
|
863
|
+
if (parent.activeChildIndex >= parent.childrenIds.length) {
|
|
864
|
+
parent.activeChildIndex = Math.max(0, parent.childrenIds.length - 1);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
delete nodes[dupUser.id];
|
|
868
|
+
return root === dupUser.id ? canonicalUserId : root;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// Put the collapsed comparison node on the active path: at the root it becomes
|
|
872
|
+
// the new root, otherwise its parent's active child points at it. Returns the
|
|
873
|
+
// (possibly updated) root id.
|
|
874
|
+
function activateForkNode(
|
|
875
|
+
nodes: Record<string, ResponseTreeMessage>,
|
|
876
|
+
canonicalUser: ResponseTreeMessage,
|
|
877
|
+
sharedParentId: string | null,
|
|
878
|
+
root: string | null,
|
|
879
|
+
): string | null {
|
|
880
|
+
if (sharedParentId === null) return canonicalUser.id;
|
|
881
|
+
const parent = nodes[sharedParentId];
|
|
882
|
+
if (parent) {
|
|
883
|
+
const idx = parent.childrenIds.indexOf(canonicalUser.id);
|
|
884
|
+
if (idx !== -1) parent.activeChildIndex = idx;
|
|
885
|
+
}
|
|
886
|
+
return root;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// Reveal the recorded winner lane: mark it and make it the active child. No-op
|
|
890
|
+
// when no winner is recorded or its assistant is not among the lanes.
|
|
891
|
+
function applyArenaWinner(
|
|
892
|
+
canonicalUser: ResponseTreeMessage,
|
|
893
|
+
group: StoredResponse[],
|
|
894
|
+
winnerRespId: string | undefined,
|
|
895
|
+
): void {
|
|
896
|
+
if (!winnerRespId) return;
|
|
897
|
+
const winnerResp = group.find((r) => r.id === winnerRespId);
|
|
898
|
+
if (!winnerResp) return;
|
|
899
|
+
const winnerAssistantId = getAssistantNodeId(winnerResp);
|
|
900
|
+
const idx = canonicalUser.childrenIds.indexOf(winnerAssistantId);
|
|
901
|
+
if (idx === -1) return;
|
|
902
|
+
canonicalUser.comparisonWinnerId = winnerAssistantId;
|
|
903
|
+
canonicalUser.activeChildIndex = idx;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// Fold one matchup's sibling responses onto a single comparison node: the first
|
|
907
|
+
// lane's user turn becomes the shared fork parent, every lane's assistant becomes
|
|
908
|
+
// a child, and the duplicate echoed-user turns are dropped. The first lane's
|
|
909
|
+
// assistant already sits under the canonical user, so only later lanes are
|
|
910
|
+
// re-parented. Returns the (possibly updated) root id.
|
|
911
|
+
function foldArenaGroup(
|
|
912
|
+
nodes: Record<string, ResponseTreeMessage>,
|
|
913
|
+
group: StoredResponse[],
|
|
914
|
+
winnerRespId: string | undefined,
|
|
915
|
+
userNodeFor: (responseId: string) => ResponseTreeMessage | undefined,
|
|
916
|
+
root: string | null,
|
|
917
|
+
): string | null {
|
|
918
|
+
const first = group[0];
|
|
919
|
+
if (!first) return root;
|
|
920
|
+
const canonicalUser = userNodeFor(first.id);
|
|
921
|
+
if (!canonicalUser) return root;
|
|
922
|
+
const sharedParentId = canonicalUser.parentId;
|
|
923
|
+
|
|
924
|
+
// Stamp matchup context on the fork parent so a reloaded comparison can be
|
|
925
|
+
// voted on (the host reconstructs the preference payload from here).
|
|
926
|
+
const forkMeta = parseArenaMeta(first);
|
|
927
|
+
if (forkMeta) {
|
|
928
|
+
canonicalUser.metadata.arena = {
|
|
929
|
+
matchupId: forkMeta.matchupId,
|
|
930
|
+
slotSeed: forkMeta.slotSeed,
|
|
931
|
+
variant: forkMeta.variant,
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
let newRoot = root;
|
|
936
|
+
for (const [i, resp] of group.entries()) {
|
|
937
|
+
stampLaneAssistant(nodes, resp);
|
|
938
|
+
if (i === 0) continue;
|
|
939
|
+
const assistant = nodes[getAssistantNodeId(resp)];
|
|
940
|
+
if (!assistant) continue;
|
|
941
|
+
attachLane(canonicalUser, assistant);
|
|
942
|
+
const dupUser = userNodeFor(resp.id);
|
|
943
|
+
if (dupUser && dupUser.id !== canonicalUser.id) {
|
|
944
|
+
newRoot = removeDuplicateUser(
|
|
945
|
+
nodes,
|
|
946
|
+
dupUser,
|
|
947
|
+
sharedParentId,
|
|
948
|
+
canonicalUser.id,
|
|
949
|
+
newRoot,
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
canonicalUser.childrenIds.sort((x, y) => compareLaneSlots(nodes, x, y));
|
|
955
|
+
|
|
956
|
+
canonicalUser.comparing = true;
|
|
957
|
+
newRoot = activateForkNode(nodes, canonicalUser, sharedParentId, newRoot);
|
|
958
|
+
applyArenaWinner(canonicalUser, group, winnerRespId);
|
|
959
|
+
return newRoot;
|
|
686
960
|
}
|
|
@@ -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
|
-
|
|
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) {
|
|
@@ -212,7 +229,7 @@ watch(
|
|
|
212
229
|
<!-- Top fade: hints that there is more content above the viewport -->
|
|
213
230
|
<div
|
|
214
231
|
aria-hidden="true"
|
|
215
|
-
class="pointer-events-none absolute inset-x-0 top-0 z-10 h-8 bg-gradient-to-b from-background-surface-
|
|
232
|
+
class="pointer-events-none absolute inset-x-0 top-0 z-10 h-8 bg-gradient-to-b from-background-surface-secondary to-transparent transition-opacity duration-200"
|
|
216
233
|
:class="showTopFade ? 'opacity-100' : 'opacity-0'"
|
|
217
234
|
/>
|
|
218
235
|
<div
|
|
@@ -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"
|
|
@@ -243,7 +269,7 @@ watch(
|
|
|
243
269
|
<!-- Bottom fade: hints that there is more content below the viewport -->
|
|
244
270
|
<div
|
|
245
271
|
aria-hidden="true"
|
|
246
|
-
class="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-8 bg-gradient-to-t from-background-surface-
|
|
272
|
+
class="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-8 bg-gradient-to-t from-background-surface-secondary to-transparent transition-opacity duration-200"
|
|
247
273
|
:class="showBottomFade ? 'opacity-100' : 'opacity-0'"
|
|
248
274
|
/>
|
|
249
275
|
</div>
|
|
@@ -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
|
|
|
@@ -4,12 +4,15 @@ import {
|
|
|
4
4
|
createEmptyTree,
|
|
5
5
|
cutBranchAfter as treeCutBranchAfter,
|
|
6
6
|
cutBranchAt as treeCutBranchAt,
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
});
|