@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.
- package/dist/{CkHistory.vue_vue_type_script_setup_true_lang-C1busuyf.js → CkHistory.vue_vue_type_script_setup_true_lang-YHwE2DYx.js} +27 -9
- 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/chat-kit.css +1 -0
- 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 +4 -4
- package/dist/handlers/index.js +4 -4
- 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-Dn4ctxk8.js} +7 -6
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -11
- 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/{useChatKitLabels-DLy0BFx8.js → useChatKitLabels-CMOr3Kjq.js} +2 -2
- package/dist/{useMessageHandler-N50Yj-NU.js → useMessageHandler-Tellov3w.js} +112 -30
- package/package.json +4 -2
- package/src/adapters/arena.spec.ts +86 -0
- package/src/adapters/responses-api.ts +276 -2
- package/src/components/base/CkConversationLayout/CkConversationLayout.vue +28 -2
- 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 +10 -0
- package/src/services/types.ts +7 -0
- package/src/types.ts +40 -0
- package/src/uno-virtual.d.ts +2 -0
|
@@ -3,47 +3,98 @@ import { reactive, ref, computed, readonly } from "vue";
|
|
|
3
3
|
function createEmptyTree() {
|
|
4
4
|
return { nodes: {}, rootId: null };
|
|
5
5
|
}
|
|
6
|
-
function
|
|
7
|
-
const
|
|
8
|
-
|
|
6
|
+
function toBaseMessage(nodes, node) {
|
|
7
|
+
const base = {
|
|
8
|
+
id: node.id,
|
|
9
|
+
role: node.role,
|
|
10
|
+
content: node.content,
|
|
11
|
+
createdAt: node.createdAt,
|
|
12
|
+
status: node.status,
|
|
13
|
+
...node.metadata !== void 0 ? { metadata: node.metadata } : {}
|
|
14
|
+
};
|
|
15
|
+
if (node.parentId !== null) {
|
|
16
|
+
const parent = nodes[node.parentId];
|
|
17
|
+
if (parent && parent.childrenIds.length > 1) {
|
|
18
|
+
base.siblingIds = [...parent.childrenIds];
|
|
19
|
+
base.siblingIndex = parent.childrenIds.indexOf(node.id);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return base;
|
|
23
|
+
}
|
|
24
|
+
function isActiveComparison(nodes, node) {
|
|
25
|
+
if (!node.comparing || node.childrenIds.length < 2) return false;
|
|
26
|
+
const winnerId = node.comparisonWinnerId;
|
|
27
|
+
if (winnerId == null) return true;
|
|
28
|
+
const winner = nodes[winnerId];
|
|
29
|
+
return !winner || winner.childrenIds.length === 0;
|
|
30
|
+
}
|
|
31
|
+
function laneWalk(nodes, headId) {
|
|
9
32
|
const path = [];
|
|
33
|
+
let current = headId;
|
|
34
|
+
while (current !== null) {
|
|
35
|
+
const node = nodes[current];
|
|
36
|
+
if (!node) break;
|
|
37
|
+
path.push(toBaseMessage(nodes, node));
|
|
38
|
+
if (node.childrenIds.length === 0) break;
|
|
39
|
+
current = node.childrenIds[node.activeChildIndex];
|
|
40
|
+
}
|
|
41
|
+
return path;
|
|
42
|
+
}
|
|
43
|
+
function projectActivePath(tree) {
|
|
44
|
+
const { nodes, rootId } = tree;
|
|
45
|
+
const trunk = [];
|
|
46
|
+
if (!rootId || !nodes[rootId]) return { trunk, activeComparison: null };
|
|
10
47
|
let current = rootId;
|
|
11
48
|
while (current !== null) {
|
|
12
49
|
const node = nodes[current];
|
|
13
50
|
if (!node) break;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
51
|
+
trunk.push(toBaseMessage(nodes, node));
|
|
52
|
+
if (isActiveComparison(nodes, node)) {
|
|
53
|
+
const lanes = node.childrenIds.map((branchId) => ({
|
|
54
|
+
branchId,
|
|
55
|
+
path: laneWalk(nodes, branchId)
|
|
56
|
+
}));
|
|
57
|
+
const winnerBranchId = node.comparisonWinnerId ?? null;
|
|
58
|
+
return {
|
|
59
|
+
trunk,
|
|
60
|
+
activeComparison: {
|
|
61
|
+
forkParentId: node.id,
|
|
62
|
+
resolved: winnerBranchId !== null,
|
|
63
|
+
winnerBranchId,
|
|
64
|
+
lanes
|
|
65
|
+
}
|
|
66
|
+
};
|
|
28
67
|
}
|
|
29
|
-
path.push(base);
|
|
30
68
|
if (node.childrenIds.length === 0) break;
|
|
31
69
|
current = node.childrenIds[node.activeChildIndex];
|
|
32
70
|
}
|
|
33
|
-
return
|
|
71
|
+
return { trunk, activeComparison: null };
|
|
34
72
|
}
|
|
35
|
-
function addNode(tree, node) {
|
|
73
|
+
function addNode(tree, node, opts = { activate: true }) {
|
|
36
74
|
tree.nodes[node.id] = node;
|
|
37
75
|
if (node.parentId !== null) {
|
|
38
76
|
const parent = tree.nodes[node.parentId];
|
|
39
77
|
if (parent && !parent.childrenIds.includes(node.id)) {
|
|
40
78
|
parent.childrenIds.push(node.id);
|
|
41
|
-
|
|
79
|
+
if (opts.activate)
|
|
80
|
+
parent.activeChildIndex = parent.childrenIds.length - 1;
|
|
42
81
|
}
|
|
43
82
|
} else if (tree.rootId === null) {
|
|
44
83
|
tree.rootId = node.id;
|
|
45
84
|
}
|
|
46
85
|
}
|
|
86
|
+
function markComparison(tree, parentId) {
|
|
87
|
+
const parent = tree.nodes[parentId];
|
|
88
|
+
if (parent) parent.comparing = true;
|
|
89
|
+
}
|
|
90
|
+
function voteComparison(tree, parentId, winnerBranchId) {
|
|
91
|
+
const parent = tree.nodes[parentId];
|
|
92
|
+
if (!parent) return;
|
|
93
|
+
const idx = parent.childrenIds.indexOf(winnerBranchId);
|
|
94
|
+
if (idx === -1) return;
|
|
95
|
+
parent.comparisonWinnerId = winnerBranchId;
|
|
96
|
+
parent.activeChildIndex = idx;
|
|
97
|
+
}
|
|
47
98
|
function updateNode(tree, id, updates) {
|
|
48
99
|
const node = tree.nodes[id];
|
|
49
100
|
if (!node) return;
|
|
@@ -81,12 +132,18 @@ function deleteSubtrees(tree, ids) {
|
|
|
81
132
|
delete tree.nodes[currentId];
|
|
82
133
|
}
|
|
83
134
|
}
|
|
84
|
-
function cutBranchAfter(tree, id) {
|
|
135
|
+
function cutBranchAfter(tree, id, keepChildId) {
|
|
85
136
|
const node = tree.nodes[id];
|
|
86
|
-
if (!node) return;
|
|
87
|
-
|
|
88
|
-
|
|
137
|
+
if (!node || !Object.prototype.hasOwnProperty.call(tree.nodes, id)) return;
|
|
138
|
+
const kept = keepChildId !== void 0 && node.childrenIds.includes(keepChildId) ? keepChildId : null;
|
|
139
|
+
deleteSubtrees(
|
|
140
|
+
tree,
|
|
141
|
+
kept === null ? node.childrenIds : node.childrenIds.filter((childId) => childId !== kept)
|
|
142
|
+
);
|
|
143
|
+
node.childrenIds = kept === null ? [] : [kept];
|
|
89
144
|
node.activeChildIndex = 0;
|
|
145
|
+
delete node.comparing;
|
|
146
|
+
delete node.comparisonWinnerId;
|
|
90
147
|
}
|
|
91
148
|
function cutBranchAt(tree, id) {
|
|
92
149
|
const node = tree.nodes[id];
|
|
@@ -106,6 +163,14 @@ function cutBranchAt(tree, id) {
|
|
|
106
163
|
if (parent.activeChildIndex >= parent.childrenIds.length) {
|
|
107
164
|
parent.activeChildIndex = Math.max(0, parent.childrenIds.length - 1);
|
|
108
165
|
}
|
|
166
|
+
if (parent.comparing) {
|
|
167
|
+
if (parent.childrenIds.length < 2) {
|
|
168
|
+
delete parent.comparing;
|
|
169
|
+
delete parent.comparisonWinnerId;
|
|
170
|
+
} else if (parent.comparisonWinnerId === id) {
|
|
171
|
+
delete parent.comparisonWinnerId;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
109
174
|
}
|
|
110
175
|
function navigate(tree, targetId) {
|
|
111
176
|
const node = tree.nodes[targetId];
|
|
@@ -119,9 +184,14 @@ function navigate(tree, targetId) {
|
|
|
119
184
|
const useMessageHandler = () => {
|
|
120
185
|
const tree = reactive(createEmptyTree());
|
|
121
186
|
const currentUserMessage = ref("");
|
|
122
|
-
const
|
|
187
|
+
const projection = computed(() => projectActivePath(tree));
|
|
188
|
+
const messages = computed(() => projection.value.trunk);
|
|
189
|
+
const activeComparison = computed(
|
|
190
|
+
() => projection.value.activeComparison
|
|
191
|
+
);
|
|
123
192
|
const currentLeafId = computed(() => {
|
|
124
|
-
|
|
193
|
+
if (projection.value.activeComparison) return null;
|
|
194
|
+
const list = projection.value.trunk;
|
|
125
195
|
return list.length > 0 ? list[list.length - 1].id : null;
|
|
126
196
|
});
|
|
127
197
|
function resolveParentId(parentId) {
|
|
@@ -171,6 +241,9 @@ const useMessageHandler = () => {
|
|
|
171
241
|
function updateMessage(message) {
|
|
172
242
|
const existing = tree.nodes[message.id];
|
|
173
243
|
if (existing) {
|
|
244
|
+
if (message.comparing && existing.parentId) {
|
|
245
|
+
markComparison(tree, existing.parentId);
|
|
246
|
+
}
|
|
174
247
|
updateNode(tree, message.id, {
|
|
175
248
|
content: message.content,
|
|
176
249
|
role: message.role,
|
|
@@ -178,9 +251,13 @@ const useMessageHandler = () => {
|
|
|
178
251
|
...message.metadata !== void 0 ? { metadata: message.metadata } : {}
|
|
179
252
|
});
|
|
180
253
|
} else {
|
|
254
|
+
const parentId = resolveParentId(message.parentId);
|
|
255
|
+
if (message.comparing && parentId) {
|
|
256
|
+
markComparison(tree, parentId);
|
|
257
|
+
}
|
|
181
258
|
const node = {
|
|
182
259
|
id: message.id,
|
|
183
|
-
parentId
|
|
260
|
+
parentId,
|
|
184
261
|
childrenIds: [],
|
|
185
262
|
activeChildIndex: 0,
|
|
186
263
|
role: message.role,
|
|
@@ -218,8 +295,8 @@ const useMessageHandler = () => {
|
|
|
218
295
|
function navigate$1(targetId) {
|
|
219
296
|
navigate(tree, targetId);
|
|
220
297
|
}
|
|
221
|
-
function cutBranchAfter$1(messageId) {
|
|
222
|
-
cutBranchAfter(tree, messageId);
|
|
298
|
+
function cutBranchAfter$1(messageId, keepChildId) {
|
|
299
|
+
cutBranchAfter(tree, messageId, keepChildId);
|
|
223
300
|
}
|
|
224
301
|
function cutBranchAt$1(messageId) {
|
|
225
302
|
cutBranchAt(tree, messageId);
|
|
@@ -227,8 +304,12 @@ const useMessageHandler = () => {
|
|
|
227
304
|
function getTree() {
|
|
228
305
|
return readonly(tree);
|
|
229
306
|
}
|
|
307
|
+
function voteComparison$1(parentId, winnerBranchId) {
|
|
308
|
+
voteComparison(tree, parentId, winnerBranchId);
|
|
309
|
+
}
|
|
230
310
|
return {
|
|
231
311
|
messages,
|
|
312
|
+
activeComparison,
|
|
232
313
|
currentUserMessage: readonly(currentUserMessage),
|
|
233
314
|
currentLeafId,
|
|
234
315
|
updateMessage,
|
|
@@ -242,6 +323,7 @@ const useMessageHandler = () => {
|
|
|
242
323
|
navigate: navigate$1,
|
|
243
324
|
cutBranchAfter: cutBranchAfter$1,
|
|
244
325
|
cutBranchAt: cutBranchAt$1,
|
|
326
|
+
voteComparison: voteComparison$1,
|
|
245
327
|
getTree
|
|
246
328
|
};
|
|
247
329
|
};
|
package/package.json
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aleph-alpha/chat-kit",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.7.0",
|
|
4
4
|
"description": "Chat kit package for building chat interfaces with Vue",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"module": "./dist/index.js",
|
|
8
8
|
"types": "./dist/index.d.ts",
|
|
9
|
+
"style": "./dist/chat-kit.css",
|
|
9
10
|
"exports": {
|
|
10
11
|
".": {
|
|
11
12
|
"import": "./dist/index.js",
|
|
12
13
|
"types": "./dist/index.d.ts"
|
|
13
14
|
},
|
|
15
|
+
"./style.css": "./dist/chat-kit.css",
|
|
14
16
|
"./services": {
|
|
15
17
|
"import": "./dist/services/index.js",
|
|
16
18
|
"types": "./dist/services/index.d.ts"
|
|
@@ -107,7 +109,7 @@
|
|
|
107
109
|
"wait-on": "9.0.3",
|
|
108
110
|
"@aleph-alpha/eslint-config-frontend": "0.7.0",
|
|
109
111
|
"@aleph-alpha/prettier-config-frontend": "2.1.0",
|
|
110
|
-
"@aleph-alpha/tsconfig-frontend": "0.7.
|
|
112
|
+
"@aleph-alpha/tsconfig-frontend": "0.7.1"
|
|
111
113
|
},
|
|
112
114
|
"scripts": {
|
|
113
115
|
"build": "vite build && vue-tsc --project tsconfig.build.json --declaration --emitDeclarationOnly --outDir dist",
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { responsesToTree, type StoredResponse } from './responses-api';
|
|
4
|
+
|
|
5
|
+
function arenaResponse(
|
|
6
|
+
id: string,
|
|
7
|
+
matchupId: string,
|
|
8
|
+
slot: 'a' | 'b',
|
|
9
|
+
modelId: string,
|
|
10
|
+
answer: string,
|
|
11
|
+
): StoredResponse {
|
|
12
|
+
return {
|
|
13
|
+
id,
|
|
14
|
+
object: 'response',
|
|
15
|
+
created_at: 1,
|
|
16
|
+
completed_at: 2,
|
|
17
|
+
status: 'completed',
|
|
18
|
+
model: modelId,
|
|
19
|
+
previous_response_id: null,
|
|
20
|
+
request_input: [{ role: 'user', content: 'prompt' }],
|
|
21
|
+
output: [
|
|
22
|
+
{
|
|
23
|
+
type: 'message',
|
|
24
|
+
id: `msg-${id}`,
|
|
25
|
+
status: 'completed',
|
|
26
|
+
role: 'assistant',
|
|
27
|
+
content: [{ type: 'output_text', text: answer }],
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
metadata: {
|
|
31
|
+
arena: JSON.stringify({
|
|
32
|
+
matchup_id: matchupId,
|
|
33
|
+
slot,
|
|
34
|
+
model_id: modelId,
|
|
35
|
+
variant: 'king-of-the-hill',
|
|
36
|
+
}),
|
|
37
|
+
},
|
|
38
|
+
} as StoredResponse;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('responsesToTree arena collapse', () => {
|
|
42
|
+
it('folds an unvoted pair into one comparison node with two lanes', () => {
|
|
43
|
+
const tree = responsesToTree([
|
|
44
|
+
arenaResponse('respA', 'm1', 'a', 'phoenix-latest', 'A'),
|
|
45
|
+
arenaResponse('respB', 'm1', 'b', 'kimi-k2.6', 'B'),
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
// Single root user node, marked comparing, with two assistant children.
|
|
49
|
+
const root = tree.nodes[tree.rootId!]!;
|
|
50
|
+
expect(root.role).toBe('user');
|
|
51
|
+
expect(root.comparing).toBe(true);
|
|
52
|
+
expect(root.comparisonWinnerId).toBeUndefined();
|
|
53
|
+
expect(root.childrenIds).toHaveLength(2);
|
|
54
|
+
|
|
55
|
+
const children = root.childrenIds.map((id) => tree.nodes[id]!);
|
|
56
|
+
expect(children.every((c) => c.role === 'assistant')).toBe(true);
|
|
57
|
+
// Model id stamped for lane labels; ordered slot a then b.
|
|
58
|
+
const slots = children.map(
|
|
59
|
+
(c) => (c.metadata.arena as { slot?: string }).slot,
|
|
60
|
+
);
|
|
61
|
+
expect(slots).toEqual(['a', 'b']);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('marks the recorded winner when arenaWinners is supplied', () => {
|
|
65
|
+
const tree = responsesToTree(
|
|
66
|
+
[
|
|
67
|
+
arenaResponse('respA', 'm1', 'a', 'phoenix-latest', 'A'),
|
|
68
|
+
arenaResponse('respB', 'm1', 'b', 'kimi-k2.6', 'B'),
|
|
69
|
+
],
|
|
70
|
+
{ arenaWinners: { m1: 'respB' } },
|
|
71
|
+
);
|
|
72
|
+
const root = tree.nodes[tree.rootId!]!;
|
|
73
|
+
// Winner is respB's assistant node (its wire message id).
|
|
74
|
+
expect(root.comparisonWinnerId).toBe('msg-respB');
|
|
75
|
+
expect(root.childrenIds[root.activeChildIndex]).toBe('msg-respB');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('leaves a single surviving response as an ordinary linear turn', () => {
|
|
79
|
+
// 7-Discard: after continue, the BE dropped the loser — one response left.
|
|
80
|
+
const tree = responsesToTree([
|
|
81
|
+
arenaResponse('respA', 'm1', 'a', 'phoenix-latest', 'A'),
|
|
82
|
+
]);
|
|
83
|
+
const root = tree.nodes[tree.rootId!]!;
|
|
84
|
+
expect(root.comparing).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -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
|
}
|