@aleph-alpha/chat-kit 1.11.1 → 1.12.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/adapters/responses-api.d.ts +54 -5
- package/dist/adapters/responses-api.d.ts.map +1 -1
- package/dist/adapters/responses-api.js +43 -9
- package/dist/composables/index.js +1 -1
- package/dist/handlers/index.js +1 -1
- package/dist/handlers/useMessageHandler.d.ts +6 -2
- package/dist/handlers/useMessageHandler.d.ts.map +1 -1
- package/dist/helpers/conversationTree.d.ts +10 -1
- package/dist/helpers/conversationTree.d.ts.map +1 -1
- package/dist/{index-BLq6_Lt2.js → index-Ct5hD214.js} +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/types.d.ts +14 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/{useMessageHandler-ZRC-gIOJ.js → useMessageHandler-CMdy-lm0.js} +36 -9
- package/package.json +1 -1
- package/src/adapters/responses-api.spec.ts +70 -0
- package/src/adapters/responses-api.ts +123 -12
- package/src/handlers/useMessageHandler.spec.ts +214 -0
- package/src/handlers/useMessageHandler.ts +28 -3
- package/src/helpers/conversationTree.spec.ts +134 -0
- package/src/helpers/conversationTree.ts +32 -1
- package/src/index.ts +1 -0
- package/src/types.ts +15 -0
|
@@ -23,6 +23,7 @@ import { generateId } from '../helpers';
|
|
|
23
23
|
import type {
|
|
24
24
|
ConversationTree,
|
|
25
25
|
MessageContent,
|
|
26
|
+
MessageMetadata,
|
|
26
27
|
MessageRole,
|
|
27
28
|
TreeMessage,
|
|
28
29
|
} from '../types';
|
|
@@ -213,6 +214,22 @@ export interface ResponseAttachment {
|
|
|
213
214
|
searchStoreId: string;
|
|
214
215
|
}
|
|
215
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Render-side attachment shape produced by `responsesToTree`. Stored under
|
|
219
|
+
* `metadata.attachments` on the user `TreeMessage` (chat-kit's `BaseMessage`
|
|
220
|
+
* /`TreeMessage` carry `metadata` as an opaque `Record<string, unknown>`).
|
|
221
|
+
*
|
|
222
|
+
* The shape is intentionally minimal — a human-readable `name` plus the
|
|
223
|
+
* optional ids needed to resolve a public URL for the file. Consumers
|
|
224
|
+
* using a different attachment shape can either skip this adapter or
|
|
225
|
+
* project this onto their own type at the read site.
|
|
226
|
+
*/
|
|
227
|
+
export interface ResponseMessageAttachment {
|
|
228
|
+
name: string;
|
|
229
|
+
fileId?: string;
|
|
230
|
+
searchStoreId?: string;
|
|
231
|
+
}
|
|
232
|
+
|
|
216
233
|
/**
|
|
217
234
|
* One stored response from the OpenAI-style Responses API, as returned by
|
|
218
235
|
* `GET /conversations/:id/responses`. `previous_response_id` chains
|
|
@@ -230,7 +247,7 @@ export interface StoredResponse {
|
|
|
230
247
|
output: ResponseOutput[];
|
|
231
248
|
request_input: Record<string, unknown>[];
|
|
232
249
|
metadata?: Record<string, unknown> & {
|
|
233
|
-
attachments?:
|
|
250
|
+
attachments?: string;
|
|
234
251
|
};
|
|
235
252
|
conversation?: { id: string };
|
|
236
253
|
ancestor_ids?: string[];
|
|
@@ -252,11 +269,47 @@ export interface ResponseList {
|
|
|
252
269
|
// Conversion: StoredResponse[] → ConversationTree
|
|
253
270
|
// ---------------------------------------------------------------------------
|
|
254
271
|
|
|
272
|
+
/**
|
|
273
|
+
* Per-node metadata produced by `responsesToTree`. The adapter is the
|
|
274
|
+
* single point that knows about the Responses-API wire format, so it
|
|
275
|
+
* stamps the originating `responseId` onto every node it emits. Anything
|
|
276
|
+
* downstream that reads it (typically a host app linking a tree message
|
|
277
|
+
* back to its server-side response) does so via this narrower metadata
|
|
278
|
+
* shape — chat-kit core stays oblivious to the field and treats it as
|
|
279
|
+
* opaque metadata.
|
|
280
|
+
*/
|
|
281
|
+
export type ResponseTreeMessageMetadata = MessageMetadata & {
|
|
282
|
+
responseId: string;
|
|
283
|
+
attachments?: ResponseMessageAttachment[];
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Tree node emitted by `responsesToTree`. Structurally a `TreeMessage`,
|
|
288
|
+
* but with `metadata` narrowed to {@link ResponseTreeMessageMetadata} and
|
|
289
|
+
* promoted from optional to required: every node carries at least the
|
|
290
|
+
* originating `responseId`. Assignable to `TreeMessage` so the resulting
|
|
291
|
+
* tree can be passed directly to `useMessageHandler().setTree(...)`.
|
|
292
|
+
*/
|
|
293
|
+
export type ResponseTreeMessage = Omit<TreeMessage, 'metadata'> & {
|
|
294
|
+
metadata: ResponseTreeMessageMetadata;
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* `ConversationTree` returned by `responsesToTree`. Same shape as the
|
|
299
|
+
* core `ConversationTree`, with every node typed as a
|
|
300
|
+
* {@link ResponseTreeMessage} so callers can read `metadata.responseId`
|
|
301
|
+
* without re-narrowing. Structurally assignable to `ConversationTree`.
|
|
302
|
+
*/
|
|
303
|
+
export interface ResponseConversationTree extends ConversationTree {
|
|
304
|
+
nodes: Record<string, ResponseTreeMessage>;
|
|
305
|
+
}
|
|
306
|
+
|
|
255
307
|
interface ResponseMessageNode {
|
|
256
308
|
id: string;
|
|
257
309
|
role: MessageRole;
|
|
258
310
|
content: MessageContent[];
|
|
259
311
|
createdAt: Date;
|
|
312
|
+
metadata: ResponseTreeMessageMetadata;
|
|
260
313
|
}
|
|
261
314
|
|
|
262
315
|
function extractMessageText(parts: ResponseOutputTextPart[]): string {
|
|
@@ -373,6 +426,38 @@ function extractInputText(rawContent: unknown): string {
|
|
|
373
426
|
return text.trim();
|
|
374
427
|
}
|
|
375
428
|
|
|
429
|
+
// Parse `resp.metadata.attachments` defensively. The Responses API
|
|
430
|
+
// serialises the value as a JSON-encoded string (transports that persist
|
|
431
|
+
// metadata flatten nested objects, so the array is stringified on the
|
|
432
|
+
// wire). Returns `null` when the payload is missing, empty, or malformed —
|
|
433
|
+
// callers treat that as "no attachments on this response". Malformed
|
|
434
|
+
// payloads are logged via `console.warn` so consumers can spot upstream
|
|
435
|
+
// serialisation issues.
|
|
436
|
+
function parseResponseAttachments(
|
|
437
|
+
resp: StoredResponse,
|
|
438
|
+
): ResponseAttachment[] | null {
|
|
439
|
+
const raw = resp.metadata?.attachments;
|
|
440
|
+
if (!raw) return null;
|
|
441
|
+
|
|
442
|
+
let attachments: unknown;
|
|
443
|
+
try {
|
|
444
|
+
attachments = JSON.parse(raw);
|
|
445
|
+
} catch {
|
|
446
|
+
// eslint-disable-next-line no-console
|
|
447
|
+
console.warn(
|
|
448
|
+
'[chat-kit] Failed to parse metadata.attachments as JSON; skipping.',
|
|
449
|
+
raw,
|
|
450
|
+
);
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (!Array.isArray(attachments) || attachments.length === 0) {
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return attachments as ResponseAttachment[];
|
|
459
|
+
}
|
|
460
|
+
|
|
376
461
|
// Within a single response, the visible messages chain linearly: every user
|
|
377
462
|
// input is followed by exactly one assistant turn (whose reasoning, tool,
|
|
378
463
|
// and text outputs are merged into one node). Across responses, branching
|
|
@@ -380,21 +465,41 @@ function extractInputText(rawContent: unknown): string {
|
|
|
380
465
|
// same parent and form sibling subtrees.
|
|
381
466
|
function responseToMessageNodes(resp: StoredResponse): ResponseMessageNode[] {
|
|
382
467
|
const nodes: ResponseMessageNode[] = [];
|
|
468
|
+
// Attachments are stored at the response level but logically belong to
|
|
469
|
+
// the user turn that started the response. We bind them to the first
|
|
470
|
+
// user `request_input` (mirroring the existing wire convention).
|
|
471
|
+
const responseAttachments = parseResponseAttachments(resp);
|
|
472
|
+
let userNodesPushed = 0;
|
|
383
473
|
if (resp.request_input) {
|
|
384
474
|
for (const [idx, input] of resp.request_input.entries()) {
|
|
385
475
|
const role = (input as { role?: string }).role;
|
|
386
476
|
const rawContent = (input as { content?: unknown }).content;
|
|
387
477
|
const text = extractInputText(rawContent);
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
478
|
+
const userContent = text || responseAttachments?.length;
|
|
479
|
+
if (role !== 'user' || !userContent) continue;
|
|
480
|
+
|
|
481
|
+
const metadata: ResponseTreeMessageMetadata = { responseId: resp.id };
|
|
482
|
+
if (responseAttachments && userNodesPushed === 0) {
|
|
483
|
+
metadata.attachments = responseAttachments.map(
|
|
484
|
+
(a): ResponseMessageAttachment => ({
|
|
485
|
+
name: a.fileName,
|
|
486
|
+
fileId: a.fileId,
|
|
487
|
+
searchStoreId: a.searchStoreId,
|
|
488
|
+
}),
|
|
489
|
+
);
|
|
395
490
|
}
|
|
491
|
+
|
|
492
|
+
nodes.push({
|
|
493
|
+
id: `user-${resp.id}-${idx}`,
|
|
494
|
+
role: 'user',
|
|
495
|
+
content: [textPart(text)],
|
|
496
|
+
createdAt: new Date(resp.created_at * 1000),
|
|
497
|
+
metadata,
|
|
498
|
+
});
|
|
499
|
+
userNodesPushed += 1;
|
|
396
500
|
}
|
|
397
501
|
}
|
|
502
|
+
|
|
398
503
|
const assistantParts = toAssistantParts(resp);
|
|
399
504
|
if (assistantParts.length > 0) {
|
|
400
505
|
nodes.push({
|
|
@@ -402,6 +507,7 @@ function responseToMessageNodes(resp: StoredResponse): ResponseMessageNode[] {
|
|
|
402
507
|
role: 'assistant',
|
|
403
508
|
content: assistantParts,
|
|
404
509
|
createdAt: new Date((resp.completed_at ?? resp.created_at) * 1000),
|
|
510
|
+
metadata: { responseId: resp.id },
|
|
405
511
|
});
|
|
406
512
|
}
|
|
407
513
|
return nodes;
|
|
@@ -417,15 +523,19 @@ function responseToMessageNodes(resp: StoredResponse): ResponseMessageNode[] {
|
|
|
417
523
|
* last message immediately. Sibling responses (same `previous_response_id`)
|
|
418
524
|
* surface as branches; the latest one wins as the active branch.
|
|
419
525
|
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
526
|
+
* Every emitted node carries `metadata.responseId` pointing back to the
|
|
527
|
+
* originating `StoredResponse.id` (see {@link ResponseTreeMessage}). The
|
|
528
|
+
* return type is assignable to `ConversationTree`, so you can pipe it
|
|
529
|
+
* straight into `useMessageHandler().setTree(...)`.
|
|
422
530
|
*/
|
|
423
|
-
export function responsesToTree(
|
|
531
|
+
export function responsesToTree(
|
|
532
|
+
responses: StoredResponse[],
|
|
533
|
+
): ResponseConversationTree {
|
|
424
534
|
if (responses.length === 0) {
|
|
425
535
|
return { nodes: {}, rootId: null };
|
|
426
536
|
}
|
|
427
537
|
|
|
428
|
-
const treeNodes: Record<string,
|
|
538
|
+
const treeNodes: Record<string, ResponseTreeMessage> = {};
|
|
429
539
|
// For each response, the id of its last visible message — that's the
|
|
430
540
|
// attachment point for any future child response that chains onto it.
|
|
431
541
|
// Empty responses fall back to their parent's attachment point so the
|
|
@@ -443,6 +553,7 @@ export function responsesToTree(responses: StoredResponse[]): ConversationTree {
|
|
|
443
553
|
content: node.content,
|
|
444
554
|
status: 'completed',
|
|
445
555
|
createdAt: node.createdAt,
|
|
556
|
+
metadata: node.metadata,
|
|
446
557
|
};
|
|
447
558
|
if (parentId !== null) {
|
|
448
559
|
const parent = treeNodes[parentId];
|
|
@@ -118,6 +118,34 @@ describe('useMessageHandler', () => {
|
|
|
118
118
|
handler.setCurrentUserMessage(' ');
|
|
119
119
|
expect(() => handler.commitUserMessage()).toThrow('No message');
|
|
120
120
|
});
|
|
121
|
+
|
|
122
|
+
it('attaches metadata when supplied and exposes it on the message', () => {
|
|
123
|
+
const handler = useMessageHandler();
|
|
124
|
+
handler.setCurrentUserMessage('Hello');
|
|
125
|
+
const msg = handler.commitUserMessage({
|
|
126
|
+
metadata: {
|
|
127
|
+
attachments: [
|
|
128
|
+
{ name: 'report.pdf', fileId: 'f1', searchStoreId: 's1' },
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(msg.metadata).toEqual({
|
|
134
|
+
attachments: [
|
|
135
|
+
{ name: 'report.pdf', fileId: 'f1', searchStoreId: 's1' },
|
|
136
|
+
],
|
|
137
|
+
});
|
|
138
|
+
expect(handler.messages.value[0].metadata).toEqual(msg.metadata);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('omits metadata on the returned message when none is supplied', () => {
|
|
142
|
+
const handler = useMessageHandler();
|
|
143
|
+
handler.setCurrentUserMessage('Hello');
|
|
144
|
+
const msg = handler.commitUserMessage();
|
|
145
|
+
|
|
146
|
+
expect('metadata' in msg).toBe(false);
|
|
147
|
+
expect('metadata' in handler.messages.value[0]).toBe(false);
|
|
148
|
+
});
|
|
121
149
|
});
|
|
122
150
|
|
|
123
151
|
describe('updateMessage', () => {
|
|
@@ -232,6 +260,61 @@ describe('useMessageHandler', () => {
|
|
|
232
260
|
expect(getText(handler.messages.value[1].content)).toBe('Regenerated');
|
|
233
261
|
});
|
|
234
262
|
|
|
263
|
+
it('preserves existing metadata when the update omits it', () => {
|
|
264
|
+
const handler = useMessageHandler();
|
|
265
|
+
handler.setCurrentUserMessage('Hello');
|
|
266
|
+
const msg = handler.commitUserMessage({
|
|
267
|
+
metadata: { attachments: [{ name: 'report.pdf' }] },
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
handler.updateMessage({
|
|
271
|
+
id: msg.id,
|
|
272
|
+
content: [text('Edited')],
|
|
273
|
+
role: 'user',
|
|
274
|
+
status: 'completed',
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
expect(handler.getMessageById(msg.id)?.metadata).toEqual({
|
|
278
|
+
attachments: [{ name: 'report.pdf' }],
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('overwrites metadata when explicitly supplied on update', () => {
|
|
283
|
+
const handler = useMessageHandler();
|
|
284
|
+
handler.setCurrentUserMessage('Hello');
|
|
285
|
+
const msg = handler.commitUserMessage({
|
|
286
|
+
metadata: { attachments: [{ name: 'old.pdf' }] },
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
handler.updateMessage({
|
|
290
|
+
id: msg.id,
|
|
291
|
+
content: [text('Hello')],
|
|
292
|
+
role: 'user',
|
|
293
|
+
status: 'completed',
|
|
294
|
+
metadata: { attachments: [{ name: 'new.pdf' }] },
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
expect(handler.getMessageById(msg.id)?.metadata).toEqual({
|
|
298
|
+
attachments: [{ name: 'new.pdf' }],
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('upserts a new message with metadata when supplied', () => {
|
|
303
|
+
const handler = useMessageHandler();
|
|
304
|
+
handler.updateMessage({
|
|
305
|
+
id: 'u-new',
|
|
306
|
+
content: [text('Hello')],
|
|
307
|
+
role: 'user',
|
|
308
|
+
status: 'completed',
|
|
309
|
+
metadata: { attachments: [{ name: 'fresh.pdf' }] },
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const stored = handler.getMessageById('u-new');
|
|
313
|
+
expect(stored?.metadata).toEqual({
|
|
314
|
+
attachments: [{ name: 'fresh.pdf' }],
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
235
318
|
it('does not truncate when updating the last message', () => {
|
|
236
319
|
const handler = useMessageHandler();
|
|
237
320
|
handler.setTree(
|
|
@@ -551,6 +634,137 @@ describe('useMessageHandler', () => {
|
|
|
551
634
|
});
|
|
552
635
|
});
|
|
553
636
|
|
|
637
|
+
describe('cutBranchAfter', () => {
|
|
638
|
+
it('removes every message that comes after the target on the active path', () => {
|
|
639
|
+
const handler = useMessageHandler();
|
|
640
|
+
handler.setTree(
|
|
641
|
+
makeTree(
|
|
642
|
+
{
|
|
643
|
+
u1: makeTreeMessage({ id: 'u1', content: [text('Hello')] }),
|
|
644
|
+
a1: makeTreeMessage({
|
|
645
|
+
id: 'a1',
|
|
646
|
+
parentId: 'u1',
|
|
647
|
+
role: 'assistant',
|
|
648
|
+
content: [text('Hi')],
|
|
649
|
+
}),
|
|
650
|
+
u2: makeTreeMessage({
|
|
651
|
+
id: 'u2',
|
|
652
|
+
parentId: 'a1',
|
|
653
|
+
content: [text('Follow-up')],
|
|
654
|
+
}),
|
|
655
|
+
a2: makeTreeMessage({
|
|
656
|
+
id: 'a2',
|
|
657
|
+
parentId: 'u2',
|
|
658
|
+
role: 'assistant',
|
|
659
|
+
content: [text('Answer')],
|
|
660
|
+
}),
|
|
661
|
+
},
|
|
662
|
+
'u1',
|
|
663
|
+
),
|
|
664
|
+
);
|
|
665
|
+
|
|
666
|
+
handler.cutBranchAfter('u2');
|
|
667
|
+
|
|
668
|
+
expect(handler.messages.value.map((m) => m.id)).toEqual([
|
|
669
|
+
'u1',
|
|
670
|
+
'a1',
|
|
671
|
+
'u2',
|
|
672
|
+
]);
|
|
673
|
+
expect(handler.getMessageById('a2')).toBeUndefined();
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it('keeps the target message and makes it the new leaf', () => {
|
|
677
|
+
const handler = useMessageHandler();
|
|
678
|
+
handler.setTree(
|
|
679
|
+
makeTree(
|
|
680
|
+
{
|
|
681
|
+
u1: makeTreeMessage({ id: 'u1', content: [text('Hello')] }),
|
|
682
|
+
a1: makeTreeMessage({
|
|
683
|
+
id: 'a1',
|
|
684
|
+
parentId: 'u1',
|
|
685
|
+
role: 'assistant',
|
|
686
|
+
content: [text('Hi')],
|
|
687
|
+
}),
|
|
688
|
+
u2: makeTreeMessage({
|
|
689
|
+
id: 'u2',
|
|
690
|
+
parentId: 'a1',
|
|
691
|
+
content: [text('Follow-up')],
|
|
692
|
+
}),
|
|
693
|
+
},
|
|
694
|
+
'u1',
|
|
695
|
+
),
|
|
696
|
+
);
|
|
697
|
+
|
|
698
|
+
handler.cutBranchAfter('a1');
|
|
699
|
+
|
|
700
|
+
expect(handler.getMessageById('a1')).toBeDefined();
|
|
701
|
+
expect(handler.currentLeafId.value).toBe('a1');
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it('drops sibling branches that share the cut ancestor', () => {
|
|
705
|
+
const handler = useMessageHandler();
|
|
706
|
+
handler.setTree(
|
|
707
|
+
makeTree(
|
|
708
|
+
{
|
|
709
|
+
u1: makeTreeMessage({ id: 'u1', content: [text('Hello')] }),
|
|
710
|
+
a1: makeTreeMessage({
|
|
711
|
+
id: 'a1',
|
|
712
|
+
parentId: 'u1',
|
|
713
|
+
role: 'assistant',
|
|
714
|
+
content: [text('Hi')],
|
|
715
|
+
}),
|
|
716
|
+
a2: makeTreeMessage({
|
|
717
|
+
id: 'a2',
|
|
718
|
+
parentId: 'u1',
|
|
719
|
+
role: 'assistant',
|
|
720
|
+
content: [text('Hey')],
|
|
721
|
+
}),
|
|
722
|
+
u2: makeTreeMessage({
|
|
723
|
+
id: 'u2',
|
|
724
|
+
parentId: 'a2',
|
|
725
|
+
content: [text('Follow-up')],
|
|
726
|
+
}),
|
|
727
|
+
},
|
|
728
|
+
'u1',
|
|
729
|
+
),
|
|
730
|
+
);
|
|
731
|
+
|
|
732
|
+
handler.cutBranchAfter('u1');
|
|
733
|
+
|
|
734
|
+
expect(handler.getMessageById('a1')).toBeUndefined();
|
|
735
|
+
expect(handler.getMessageById('a2')).toBeUndefined();
|
|
736
|
+
expect(handler.getMessageById('u2')).toBeUndefined();
|
|
737
|
+
expect(handler.messages.value.map((m) => m.id)).toEqual(['u1']);
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
it('is a no-op when the target has no descendants', () => {
|
|
741
|
+
const handler = useMessageHandler();
|
|
742
|
+
handler.setCurrentUserMessage('Hello');
|
|
743
|
+
const msg = handler.commitUserMessage();
|
|
744
|
+
|
|
745
|
+
handler.cutBranchAfter(msg.id);
|
|
746
|
+
|
|
747
|
+
expect(handler.messages.value).toHaveLength(1);
|
|
748
|
+
expect(handler.messages.value[0].id).toBe(msg.id);
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
it('is a no-op for a nonexistent id', () => {
|
|
752
|
+
const handler = useMessageHandler();
|
|
753
|
+
handler.setCurrentUserMessage('Hello');
|
|
754
|
+
handler.commitUserMessage();
|
|
755
|
+
handler.updateMessage({
|
|
756
|
+
id: 'a1',
|
|
757
|
+
content: [text('Reply')],
|
|
758
|
+
role: 'assistant',
|
|
759
|
+
status: 'completed',
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
handler.cutBranchAfter('nonexistent');
|
|
763
|
+
|
|
764
|
+
expect(handler.messages.value).toHaveLength(2);
|
|
765
|
+
});
|
|
766
|
+
});
|
|
767
|
+
|
|
554
768
|
describe('currentLeafId', () => {
|
|
555
769
|
it('is null when there are no messages', () => {
|
|
556
770
|
const handler = useMessageHandler();
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
generateId,
|
|
3
3
|
addNode as treeAddNode,
|
|
4
4
|
createEmptyTree,
|
|
5
|
+
cutBranchAfter as treeCutBranchAfter,
|
|
5
6
|
getLinearPath,
|
|
6
7
|
navigate as treeNavigate,
|
|
7
8
|
updateNode,
|
|
@@ -11,6 +12,7 @@ import type {
|
|
|
11
12
|
BaseMessage,
|
|
12
13
|
ConversationTree,
|
|
13
14
|
MessageContent,
|
|
15
|
+
MessageMetadata,
|
|
14
16
|
MessageRole,
|
|
15
17
|
MessageStatus,
|
|
16
18
|
TreeMessage,
|
|
@@ -36,7 +38,9 @@ export const useMessageHandler = () => {
|
|
|
36
38
|
currentUserMessage.value = '';
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
function commitUserMessage(
|
|
41
|
+
function commitUserMessage(opts?: {
|
|
42
|
+
metadata?: MessageMetadata;
|
|
43
|
+
}): BaseMessage {
|
|
40
44
|
const id = generateId();
|
|
41
45
|
const text = currentUserMessage.value.trim();
|
|
42
46
|
if (!text) throw new Error('No message');
|
|
@@ -54,18 +58,21 @@ export const useMessageHandler = () => {
|
|
|
54
58
|
content,
|
|
55
59
|
status: 'completed',
|
|
56
60
|
createdAt: new Date(),
|
|
61
|
+
...(opts?.metadata !== undefined ? { metadata: opts.metadata } : {}),
|
|
57
62
|
};
|
|
58
63
|
|
|
59
64
|
treeAddNode(tree, node);
|
|
60
65
|
clearCurrentUserMessage();
|
|
61
66
|
|
|
62
|
-
|
|
67
|
+
const base: BaseMessage = {
|
|
63
68
|
id: node.id,
|
|
64
69
|
role: node.role,
|
|
65
70
|
content: node.content,
|
|
66
71
|
createdAt: node.createdAt,
|
|
67
72
|
status: node.status,
|
|
68
73
|
};
|
|
74
|
+
if (node.metadata !== undefined) base.metadata = node.metadata;
|
|
75
|
+
return base;
|
|
69
76
|
}
|
|
70
77
|
|
|
71
78
|
function updateMessage(message: {
|
|
@@ -73,6 +80,7 @@ export const useMessageHandler = () => {
|
|
|
73
80
|
content: MessageContent[];
|
|
74
81
|
role: MessageRole;
|
|
75
82
|
status: MessageStatus;
|
|
83
|
+
metadata?: MessageMetadata;
|
|
76
84
|
}) {
|
|
77
85
|
const existing = tree.nodes[message.id];
|
|
78
86
|
|
|
@@ -81,6 +89,9 @@ export const useMessageHandler = () => {
|
|
|
81
89
|
content: message.content,
|
|
82
90
|
role: message.role,
|
|
83
91
|
status: message.status,
|
|
92
|
+
...(message.metadata !== undefined
|
|
93
|
+
? { metadata: message.metadata }
|
|
94
|
+
: {}),
|
|
84
95
|
});
|
|
85
96
|
} else {
|
|
86
97
|
const node: TreeMessage = {
|
|
@@ -92,6 +103,9 @@ export const useMessageHandler = () => {
|
|
|
92
103
|
content: message.content,
|
|
93
104
|
status: message.status,
|
|
94
105
|
createdAt: new Date(),
|
|
106
|
+
...(message.metadata !== undefined
|
|
107
|
+
? { metadata: message.metadata }
|
|
108
|
+
: {}),
|
|
95
109
|
};
|
|
96
110
|
treeAddNode(tree, node);
|
|
97
111
|
}
|
|
@@ -100,13 +114,15 @@ export const useMessageHandler = () => {
|
|
|
100
114
|
function getMessageById(id: string): BaseMessage | undefined {
|
|
101
115
|
const node = tree.nodes[id];
|
|
102
116
|
if (!node) return undefined;
|
|
103
|
-
|
|
117
|
+
const base: BaseMessage = {
|
|
104
118
|
id: node.id,
|
|
105
119
|
role: node.role,
|
|
106
120
|
content: node.content,
|
|
107
121
|
createdAt: node.createdAt,
|
|
108
122
|
status: node.status,
|
|
123
|
+
...(node.metadata !== undefined ? { metadata: node.metadata } : {}),
|
|
109
124
|
};
|
|
125
|
+
return base;
|
|
110
126
|
}
|
|
111
127
|
|
|
112
128
|
function updateMessageId(oldId: string, newId: string) {
|
|
@@ -135,6 +151,14 @@ export const useMessageHandler = () => {
|
|
|
135
151
|
treeNavigate(tree, targetId);
|
|
136
152
|
}
|
|
137
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Drop every message that comes after `messageId` on every branch beneath
|
|
156
|
+
* it. The target message is kept and becomes the new leaf of its branch.
|
|
157
|
+
*/
|
|
158
|
+
function cutBranchAfter(messageId: string) {
|
|
159
|
+
treeCutBranchAfter(tree, messageId);
|
|
160
|
+
}
|
|
161
|
+
|
|
138
162
|
return {
|
|
139
163
|
messages: messages as Ref<BaseMessage[]>,
|
|
140
164
|
currentUserMessage: readonly(currentUserMessage),
|
|
@@ -148,5 +172,6 @@ export const useMessageHandler = () => {
|
|
|
148
172
|
clearMessages,
|
|
149
173
|
setTree,
|
|
150
174
|
navigate,
|
|
175
|
+
cutBranchAfter,
|
|
151
176
|
};
|
|
152
177
|
};
|