@aleph-alpha/chat-kit 3.1.3 → 3.2.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/{CkInput.vue_vue_type_script_setup_true_lang-LPrW7CSQ.js → CkInput.vue_vue_type_script_setup_true_lang-B-4xnH2c.js} +1 -1
- package/dist/adapters/responses-api.d.ts.map +1 -1
- package/dist/adapters/responses-api.js +63 -37
- package/dist/components/index.js +1 -1
- package/dist/composables/fileUpload/types.d.ts +3 -0
- package/dist/composables/fileUpload/types.d.ts.map +1 -1
- package/dist/composables/fileUpload/useFileUpload.d.ts.map +1 -1
- package/dist/composables/fileUpload/useFileUploadWrapper.d.ts.map +1 -1
- package/dist/composables/index.js +1 -1
- package/dist/handlers/index.d.ts +1 -0
- package/dist/handlers/index.d.ts.map +1 -1
- package/dist/handlers/index.js +3 -1
- package/dist/handlers/useMarkdownParser.d.ts.map +1 -1
- package/dist/helpers/unified/remarkNormalizeMath.d.ts +24 -0
- package/dist/helpers/unified/remarkNormalizeMath.d.ts.map +1 -0
- package/dist/{index-DVxCNAs3.js → index-DAmWGYNH.js} +37 -5
- package/dist/index.js +3 -3
- package/dist/{useChatKitLabels-Ctz1dHpx.js → useChatKitLabels-D5gNv8se.js} +97 -1
- package/package.json +4 -3
- package/src/adapters/responses-api.spec.ts +156 -0
- package/src/adapters/responses-api.ts +125 -58
- package/src/composables/fileUpload/types.ts +6 -0
- package/src/composables/fileUpload/useFileUpload.ts +7 -1
- package/src/composables/fileUpload/useFileUploadWrapper.spec.ts +74 -24
- package/src/composables/fileUpload/useFileUploadWrapper.ts +44 -4
- package/src/handlers/index.ts +4 -0
- package/src/handlers/useMarkdownParser.spec.ts +34 -0
- package/src/handlers/useMarkdownParser.ts +10 -1
- package/src/helpers/unified/remarkNormalizeMath.spec.ts +84 -0
- package/src/helpers/unified/remarkNormalizeMath.ts +134 -0
|
@@ -441,6 +441,162 @@ describe('responsesToTree', () => {
|
|
|
441
441
|
}
|
|
442
442
|
});
|
|
443
443
|
|
|
444
|
+
it('merges a tool-loop continuation (response with no user input) into the same assistant turn', () => {
|
|
445
|
+
// One user turn that runs a multi-step tool loop is persisted as several
|
|
446
|
+
// chained responses: the first carries the user input, each follow-up
|
|
447
|
+
// carries a tool result (no user input). They are one assistant turn and
|
|
448
|
+
// must render as one message with a single thinking trail (SPHER-5147).
|
|
449
|
+
const tree = responsesToTree([
|
|
450
|
+
makeResponse({
|
|
451
|
+
id: 'r1',
|
|
452
|
+
request_input: [userInput('Compare Tokyo and Delhi populations')],
|
|
453
|
+
output: [
|
|
454
|
+
reasoningOutput('rs-1', 'search for Tokyo'),
|
|
455
|
+
toolOutput({ id: 'fc-1', type: 'function_call', name: 'search' }),
|
|
456
|
+
],
|
|
457
|
+
}),
|
|
458
|
+
makeResponse({
|
|
459
|
+
id: 'r2',
|
|
460
|
+
previous_response_id: 'r1',
|
|
461
|
+
// Tool-result continuation — no user input on the wire.
|
|
462
|
+
request_input: [],
|
|
463
|
+
completed_at: 1_777_456_999,
|
|
464
|
+
output: [
|
|
465
|
+
reasoningOutput('rs-2', 'now search for Delhi'),
|
|
466
|
+
toolOutput({ id: 'fc-2', type: 'function_call', name: 'search' }),
|
|
467
|
+
assistantOutput('msg-final', 'Tokyo is larger'),
|
|
468
|
+
],
|
|
469
|
+
}),
|
|
470
|
+
]);
|
|
471
|
+
|
|
472
|
+
// Exactly one user node and one (merged) assistant node — not two
|
|
473
|
+
// assistant bubbles. r1 has no message output, so its assistant node id
|
|
474
|
+
// falls back to `assistant-r1`.
|
|
475
|
+
expect(Object.keys(tree.nodes)).toHaveLength(2);
|
|
476
|
+
expect(tree.nodes['msg-final']).toBeUndefined();
|
|
477
|
+
|
|
478
|
+
const assistant = tree.nodes['assistant-r1']!;
|
|
479
|
+
expect(assistant.role).toBe('assistant');
|
|
480
|
+
expect(tree.nodes['user-r1-0']!.childrenIds).toEqual(['assistant-r1']);
|
|
481
|
+
// Reasoning + tool from both round-trips, in wire order, then final text.
|
|
482
|
+
expect(assistant.content.map((c) => c.type)).toEqual([
|
|
483
|
+
'reasoning',
|
|
484
|
+
'tool',
|
|
485
|
+
'reasoning',
|
|
486
|
+
'tool',
|
|
487
|
+
'text',
|
|
488
|
+
]);
|
|
489
|
+
// responseId stays the originating round-trip (the one that opened the
|
|
490
|
+
// turn), preserving the documented "originating StoredResponse.id" stamp.
|
|
491
|
+
expect(assistant.metadata.responseId).toBe('r1');
|
|
492
|
+
// createdAt advances to the latest folded round-trip so it is never
|
|
493
|
+
// stamped earlier than content it now holds.
|
|
494
|
+
expect(assistant.createdAt).toEqual(new Date(1_777_456_999 * 1000));
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it('merges a tool-loop continuation in a later turn, leaving earlier turns intact', () => {
|
|
498
|
+
// The fix must work for any turn, not just the first: a simple turn,
|
|
499
|
+
// followed by a second turn that runs a tool loop. The second turn's
|
|
500
|
+
// continuation folds into its own assistant node; turn one is untouched.
|
|
501
|
+
const tree = responsesToTree([
|
|
502
|
+
makeResponse({
|
|
503
|
+
id: 'r1',
|
|
504
|
+
request_input: [userInput('Hi')],
|
|
505
|
+
output: [assistantOutput('msg-a1', 'Hello!')],
|
|
506
|
+
}),
|
|
507
|
+
makeResponse({
|
|
508
|
+
id: 'r2',
|
|
509
|
+
previous_response_id: 'r1',
|
|
510
|
+
request_input: [userInput('Now compare Tokyo and Delhi')],
|
|
511
|
+
output: [
|
|
512
|
+
reasoningOutput('rs-2', 'search Tokyo'),
|
|
513
|
+
toolOutput({ id: 'fc-2', type: 'function_call', name: 'search' }),
|
|
514
|
+
],
|
|
515
|
+
}),
|
|
516
|
+
makeResponse({
|
|
517
|
+
id: 'r3',
|
|
518
|
+
previous_response_id: 'r2',
|
|
519
|
+
request_input: [], // tool-result continuation of turn two
|
|
520
|
+
output: [
|
|
521
|
+
reasoningOutput('rs-3', 'search Delhi'),
|
|
522
|
+
toolOutput({ id: 'fc-3', type: 'function_call', name: 'search' }),
|
|
523
|
+
assistantOutput('msg-a3', 'Tokyo is larger'),
|
|
524
|
+
],
|
|
525
|
+
}),
|
|
526
|
+
]);
|
|
527
|
+
|
|
528
|
+
// Turn one: untouched, single reply.
|
|
529
|
+
const a1 = tree.nodes['msg-a1']!;
|
|
530
|
+
expect(getText(a1.content)).toBe('Hello!');
|
|
531
|
+
expect(a1.parentId).toBe('user-r1-0');
|
|
532
|
+
|
|
533
|
+
// Turn two: its continuation (r3) merged into turn two's assistant node
|
|
534
|
+
// (id falls back to `assistant-r2` since r2 has no message output).
|
|
535
|
+
expect(tree.nodes['msg-a3']).toBeUndefined();
|
|
536
|
+
const a2 = tree.nodes['assistant-r2']!;
|
|
537
|
+
expect(a2.parentId).toBe('user-r2-0');
|
|
538
|
+
expect(tree.nodes['user-r2-0']!.parentId).toBe('msg-a1');
|
|
539
|
+
expect(a2.content.map((c) => c.type)).toEqual([
|
|
540
|
+
'reasoning',
|
|
541
|
+
'tool',
|
|
542
|
+
'reasoning',
|
|
543
|
+
'tool',
|
|
544
|
+
'text',
|
|
545
|
+
]);
|
|
546
|
+
// Four message nodes total: two user, two assistant (one per turn).
|
|
547
|
+
expect(Object.keys(tree.nodes)).toHaveLength(4);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('still starts a new assistant turn when a follow-up response carries a user input', () => {
|
|
551
|
+
// Guard against over-merging: a genuine next turn (user input present)
|
|
552
|
+
// must remain its own message, not fold into the previous answer.
|
|
553
|
+
const tree = responsesToTree([
|
|
554
|
+
makeResponse({
|
|
555
|
+
id: 'r1',
|
|
556
|
+
request_input: [userInput('First')],
|
|
557
|
+
output: [assistantOutput('msg-a1', 'A1')],
|
|
558
|
+
}),
|
|
559
|
+
makeResponse({
|
|
560
|
+
id: 'r2',
|
|
561
|
+
previous_response_id: 'r1',
|
|
562
|
+
request_input: [userInput('Second')],
|
|
563
|
+
output: [assistantOutput('msg-a2', 'A2')],
|
|
564
|
+
}),
|
|
565
|
+
]);
|
|
566
|
+
|
|
567
|
+
expect(tree.nodes['user-r2-0']!.parentId).toBe('msg-a1');
|
|
568
|
+
expect(tree.nodes['msg-a2']!.parentId).toBe('user-r2-0');
|
|
569
|
+
expect(tree.nodes['msg-a2']!.metadata.responseId).toBe('r2');
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it('does not merge a response that carries a user input even when its content is empty', () => {
|
|
573
|
+
// A user-role input with empty content produces no user node, so a
|
|
574
|
+
// node-shape-only check would wrongly treat this as a continuation. The
|
|
575
|
+
// merge keys off `request_input` directly, so the assistant reply stays
|
|
576
|
+
// its own node rather than folding into the previous turn.
|
|
577
|
+
const tree = responsesToTree([
|
|
578
|
+
makeResponse({
|
|
579
|
+
id: 'r1',
|
|
580
|
+
request_input: [userInput('First')],
|
|
581
|
+
output: [assistantOutput('msg-a1', 'A1')],
|
|
582
|
+
}),
|
|
583
|
+
makeResponse({
|
|
584
|
+
id: 'r2',
|
|
585
|
+
previous_response_id: 'r1',
|
|
586
|
+
request_input: [{ role: 'user', content: '' }],
|
|
587
|
+
output: [assistantOutput('msg-a2', 'A2')],
|
|
588
|
+
}),
|
|
589
|
+
]);
|
|
590
|
+
|
|
591
|
+
const a1 = tree.nodes['msg-a1']!;
|
|
592
|
+
const a2 = tree.nodes['msg-a2']!;
|
|
593
|
+
expect(a2).toBeDefined();
|
|
594
|
+
expect(getText(a2.content)).toBe('A2');
|
|
595
|
+
// a2 is its own node, not merged into a1's content.
|
|
596
|
+
expect(getText(a1.content)).toBe('A1');
|
|
597
|
+
expect(a2.parentId).toBe('msg-a1');
|
|
598
|
+
});
|
|
599
|
+
|
|
444
600
|
it('chains the next response under a reasoning-only assistant turn', () => {
|
|
445
601
|
const tree = responsesToTree([
|
|
446
602
|
makeResponse({
|
|
@@ -513,6 +513,124 @@ function responseToMessageNodes(resp: StoredResponse): ResponseMessageNode[] {
|
|
|
513
513
|
return nodes;
|
|
514
514
|
}
|
|
515
515
|
|
|
516
|
+
// Insert a node and wire it under `parentId`; latest child wins as the active
|
|
517
|
+
// branch (surfaces the most recent sibling branch by default).
|
|
518
|
+
function appendNode(
|
|
519
|
+
treeNodes: Record<string, ResponseTreeMessage>,
|
|
520
|
+
node: ResponseMessageNode,
|
|
521
|
+
parentId: string | null,
|
|
522
|
+
): void {
|
|
523
|
+
treeNodes[node.id] = {
|
|
524
|
+
id: node.id,
|
|
525
|
+
parentId,
|
|
526
|
+
childrenIds: [],
|
|
527
|
+
activeChildIndex: 0,
|
|
528
|
+
role: node.role,
|
|
529
|
+
content: node.content,
|
|
530
|
+
status: 'completed',
|
|
531
|
+
createdAt: node.createdAt,
|
|
532
|
+
metadata: node.metadata,
|
|
533
|
+
};
|
|
534
|
+
if (parentId === null) return;
|
|
535
|
+
const parent = treeNodes[parentId];
|
|
536
|
+
if (!parent) return;
|
|
537
|
+
parent.childrenIds.push(node.id);
|
|
538
|
+
parent.activeChildIndex = parent.childrenIds.length - 1;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// A user-role input on the wire delimits a turn. Read from `request_input`
|
|
542
|
+
// directly: `responseToMessageNodes` drops empty user inputs, so a node-shape
|
|
543
|
+
// check could misread an empty new turn as a continuation.
|
|
544
|
+
function responseHasUserInput(resp: StoredResponse): boolean {
|
|
545
|
+
return (resp.request_input ?? []).some(
|
|
546
|
+
(input) => (input as { role?: string }).role === 'user',
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// True when `resp` continues the assistant turn anchored at `parentNode`: a
|
|
551
|
+
// tool-loop round-trip (assistant-only output, no new user input) rather than a
|
|
552
|
+
// new turn. The Responses API stores each round-trip as its own response, but
|
|
553
|
+
// they are one turn. New turns and regeneration branches carry a user input.
|
|
554
|
+
function isAssistantContinuation(
|
|
555
|
+
parentNode: ResponseTreeMessage,
|
|
556
|
+
messageNodes: ResponseMessageNode[],
|
|
557
|
+
resp: StoredResponse,
|
|
558
|
+
): boolean {
|
|
559
|
+
return (
|
|
560
|
+
parentNode.role === 'assistant' &&
|
|
561
|
+
messageNodes.length > 0 &&
|
|
562
|
+
messageNodes.every((node) => node.role === 'assistant') &&
|
|
563
|
+
!responseHasUserInput(resp)
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Fold a continuation round-trip's output onto the turn it extends, so the turn
|
|
568
|
+
// stays one message with one "thinking" trail — matching the live path;
|
|
569
|
+
// otherwise the trail splits on reload (SPHER-5147). `responseId` is left as the
|
|
570
|
+
// originating response, per ResponseTreeMessageMetadata's contract.
|
|
571
|
+
function foldContinuation(
|
|
572
|
+
parentNode: ResponseTreeMessage,
|
|
573
|
+
messageNodes: ResponseMessageNode[],
|
|
574
|
+
): void {
|
|
575
|
+
for (const node of messageNodes) {
|
|
576
|
+
parentNode.content.push(...node.content);
|
|
577
|
+
// Advance the turn's timestamp as later round-trips fold in, so it is
|
|
578
|
+
// never stamped earlier than content it now holds (consistent with the
|
|
579
|
+
// per-response `completed_at` stamp used elsewhere). Only moves forward.
|
|
580
|
+
if (node.createdAt.getTime() > parentNode.createdAt.getTime()) {
|
|
581
|
+
parentNode.createdAt = node.createdAt;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Mutable accumulators threaded through the single build pass.
|
|
587
|
+
interface TreeBuildState {
|
|
588
|
+
treeNodes: Record<string, ResponseTreeMessage>;
|
|
589
|
+
// For each response, the id of its last visible message — the attachment
|
|
590
|
+
// point for any future child response that chains onto it. Empty responses
|
|
591
|
+
// fall back to their parent's attachment point so the chain stays connected.
|
|
592
|
+
tailMessageByResponse: Map<string, string | null>;
|
|
593
|
+
rootMessageId: string | null;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Wire one response into the tree, extending `state` in place. Tool-loop
|
|
597
|
+
// continuations fold onto the turn they extend; everything else appends under
|
|
598
|
+
// the chain parent resolved from `previous_response_id`.
|
|
599
|
+
function linkResponse(state: TreeBuildState, resp: StoredResponse): void {
|
|
600
|
+
const { treeNodes, tailMessageByResponse } = state;
|
|
601
|
+
const parentResponseId =
|
|
602
|
+
resp.previous_response_id &&
|
|
603
|
+
tailMessageByResponse.has(resp.previous_response_id)
|
|
604
|
+
? resp.previous_response_id
|
|
605
|
+
: null;
|
|
606
|
+
let chainParent =
|
|
607
|
+
parentResponseId !== null
|
|
608
|
+
? (tailMessageByResponse.get(parentResponseId) ?? null)
|
|
609
|
+
: null;
|
|
610
|
+
|
|
611
|
+
const messageNodes = responseToMessageNodes(resp);
|
|
612
|
+
|
|
613
|
+
const parentNode = chainParent !== null ? treeNodes[chainParent] : null;
|
|
614
|
+
if (parentNode && isAssistantContinuation(parentNode, messageNodes, resp)) {
|
|
615
|
+
foldContinuation(parentNode, messageNodes);
|
|
616
|
+
tailMessageByResponse.set(resp.id, parentNode.id);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
let firstId: string | null = null;
|
|
621
|
+
for (const node of messageNodes) {
|
|
622
|
+
appendNode(treeNodes, node, chainParent);
|
|
623
|
+
if (firstId === null) firstId = node.id;
|
|
624
|
+
chainParent = node.id;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (state.rootMessageId === null && firstId !== null) {
|
|
628
|
+
state.rootMessageId = firstId;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
tailMessageByResponse.set(resp.id, chainParent);
|
|
632
|
+
}
|
|
633
|
+
|
|
516
634
|
/**
|
|
517
635
|
* Convert an OpenAI Responses API list (already ordered by `created_at`
|
|
518
636
|
* ascending) into a chat-kit `ConversationTree`.
|
|
@@ -535,66 +653,15 @@ export function responsesToTree(
|
|
|
535
653
|
return { nodes: {}, rootId: null };
|
|
536
654
|
}
|
|
537
655
|
|
|
538
|
-
const
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
const tailMessageByResponse = new Map<string, string | null>();
|
|
544
|
-
let rootMessageId: string | null = null;
|
|
545
|
-
|
|
546
|
-
function appendNode(node: ResponseMessageNode, parentId: string | null) {
|
|
547
|
-
treeNodes[node.id] = {
|
|
548
|
-
id: node.id,
|
|
549
|
-
parentId,
|
|
550
|
-
childrenIds: [],
|
|
551
|
-
activeChildIndex: 0,
|
|
552
|
-
role: node.role,
|
|
553
|
-
content: node.content,
|
|
554
|
-
status: 'completed',
|
|
555
|
-
createdAt: node.createdAt,
|
|
556
|
-
metadata: node.metadata,
|
|
557
|
-
};
|
|
558
|
-
if (parentId !== null) {
|
|
559
|
-
const parent = treeNodes[parentId];
|
|
560
|
-
if (parent) {
|
|
561
|
-
parent.childrenIds.push(node.id);
|
|
562
|
-
// Latest insertion wins as the active branch — within a single
|
|
563
|
-
// response there's only one push, so this is a no-op there; for
|
|
564
|
-
// sibling branches it surfaces the most recent one by default.
|
|
565
|
-
parent.activeChildIndex = parent.childrenIds.length - 1;
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
}
|
|
656
|
+
const state: TreeBuildState = {
|
|
657
|
+
treeNodes: {},
|
|
658
|
+
tailMessageByResponse: new Map<string, string | null>(),
|
|
659
|
+
rootMessageId: null,
|
|
660
|
+
};
|
|
569
661
|
|
|
570
662
|
for (const resp of responses) {
|
|
571
|
-
|
|
572
|
-
resp.previous_response_id &&
|
|
573
|
-
tailMessageByResponse.has(resp.previous_response_id)
|
|
574
|
-
? resp.previous_response_id
|
|
575
|
-
: null;
|
|
576
|
-
let chainParent =
|
|
577
|
-
parentResponseId !== null
|
|
578
|
-
? (tailMessageByResponse.get(parentResponseId) ?? null)
|
|
579
|
-
: null;
|
|
580
|
-
|
|
581
|
-
const messageNodes = responseToMessageNodes(resp);
|
|
582
|
-
let firstId: string | null = null;
|
|
583
|
-
for (const node of messageNodes) {
|
|
584
|
-
appendNode(node, chainParent);
|
|
585
|
-
if (firstId === null) firstId = node.id;
|
|
586
|
-
chainParent = node.id;
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
if (rootMessageId === null && firstId !== null) {
|
|
590
|
-
rootMessageId = firstId;
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
// Track the attachment point for descendants. If the response had no
|
|
594
|
-
// visible messages, pass through the parent's tail so children of this
|
|
595
|
-
// response still land in the right place.
|
|
596
|
-
tailMessageByResponse.set(resp.id, chainParent);
|
|
663
|
+
linkResponse(state, resp);
|
|
597
664
|
}
|
|
598
665
|
|
|
599
|
-
return { nodes: treeNodes, rootId: rootMessageId };
|
|
666
|
+
return { nodes: state.treeNodes, rootId: state.rootMessageId };
|
|
600
667
|
}
|
|
@@ -34,6 +34,8 @@ export interface EndpointResolver {
|
|
|
34
34
|
files: (storeId: string) => string;
|
|
35
35
|
file: (storeId: string, fileId: string) => string;
|
|
36
36
|
filePublicUrl: (storeId: string, fileId: string) => string;
|
|
37
|
+
workflows: (storeId: string) => string;
|
|
38
|
+
createWorkflow: (storeId: string) => string;
|
|
37
39
|
workflowRun: (storeId: string, workflowId: string) => string;
|
|
38
40
|
workflowRunEventStream: (
|
|
39
41
|
storeId: string,
|
|
@@ -47,6 +49,9 @@ export const defaultEndpoints: EndpointResolver = {
|
|
|
47
49
|
file: (storeId, fileId) => `search-stores/${storeId}/files/${fileId}`,
|
|
48
50
|
filePublicUrl: (storeId, fileId) =>
|
|
49
51
|
`search-stores/${storeId}/files/${fileId}/public-urls?ttl=1h`,
|
|
52
|
+
workflows: (storeId) =>
|
|
53
|
+
`search-stores/${storeId}/workflows?limit=20&offset=0&created_after=&created_before=`,
|
|
54
|
+
createWorkflow: (storeId) => `search-stores/${storeId}/workflows`,
|
|
50
55
|
workflowRun: (storeId, workflowId) =>
|
|
51
56
|
`search-stores/${storeId}/workflows/${workflowId}/runs`,
|
|
52
57
|
workflowRunEventStream: (storeId, workflowId, runId) =>
|
|
@@ -410,6 +415,7 @@ export interface UseFileUploadReturn {
|
|
|
410
415
|
updateFileMetadata: (id: string, meta: Record<string, unknown>) => void;
|
|
411
416
|
clearState: () => void;
|
|
412
417
|
setStoreId: (id: string) => void;
|
|
418
|
+
setWorkflowId: (id: string) => void;
|
|
413
419
|
getPublicUrl: (fileId: string) => Promise<string>;
|
|
414
420
|
|
|
415
421
|
scopeBy: (metadataKey: string, value: string) => ScopedFileView;
|
|
@@ -469,8 +469,9 @@ function deriveStateKey(
|
|
|
469
469
|
export function useFileUpload(
|
|
470
470
|
options: UseFileUploadOptions,
|
|
471
471
|
): UseFileUploadReturn {
|
|
472
|
-
const { client
|
|
472
|
+
const { client } = options;
|
|
473
473
|
let storeId = options.storeId ?? '';
|
|
474
|
+
let workflowId = options.workflowId;
|
|
474
475
|
const endpoints: EndpointResolver = options.endpoints ?? defaultEndpoints;
|
|
475
476
|
const retryPolicy: RetryPolicy = options.retryPolicy ?? DEFAULT_RETRY_POLICY;
|
|
476
477
|
const constraints: FileConstraints = {
|
|
@@ -1257,6 +1258,10 @@ export function useFileUpload(
|
|
|
1257
1258
|
storeId = id;
|
|
1258
1259
|
}
|
|
1259
1260
|
|
|
1261
|
+
function setWorkflowId(id: string) {
|
|
1262
|
+
workflowId = id;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1260
1265
|
async function getPublicUrl(fileId: string): Promise<string> {
|
|
1261
1266
|
const res = await client.post<{ url: string; expires_at: string }>(
|
|
1262
1267
|
endpoints.filePublicUrl(storeId, fileId),
|
|
@@ -1277,6 +1282,7 @@ export function useFileUpload(
|
|
|
1277
1282
|
updateFileMetadata,
|
|
1278
1283
|
clearState,
|
|
1279
1284
|
setStoreId,
|
|
1285
|
+
setWorkflowId,
|
|
1280
1286
|
getPublicUrl,
|
|
1281
1287
|
|
|
1282
1288
|
scopeBy,
|
|
@@ -1,6 +1,34 @@
|
|
|
1
|
+
import { flushPromises } from '@vue/test-utils';
|
|
2
|
+
import { ref } from 'vue';
|
|
1
3
|
import { createDocumentUploadProvider } from './useFileUploadWrapper';
|
|
2
4
|
import type { DocumentUploadConfig } from './types';
|
|
3
5
|
|
|
6
|
+
const uploadMock = vi.hoisted(() => ({
|
|
7
|
+
upload: vi.fn().mockResolvedValue({ results: [] }),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock('./useFileUpload', () => ({
|
|
11
|
+
useFileUpload: vi.fn((options) => {
|
|
12
|
+
const scopedFiles = ref([]);
|
|
13
|
+
return {
|
|
14
|
+
files: ref(new Map()),
|
|
15
|
+
getSupportedFileTypes: () => ({
|
|
16
|
+
maxFilesPerUpload: options.maxFilesPerUpload ?? 20,
|
|
17
|
+
allowedMimeTypes: ['application/pdf'],
|
|
18
|
+
allowedExtensions: ['.pdf'],
|
|
19
|
+
maxFileSizeBytes: 100 * 1024 * 1024,
|
|
20
|
+
}),
|
|
21
|
+
scopeBy: () => ({ files: scopedFiles }),
|
|
22
|
+
upload: uploadMock.upload,
|
|
23
|
+
runWorkflow: vi.fn().mockResolvedValue({ close: vi.fn() }),
|
|
24
|
+
setStoreId: vi.fn(),
|
|
25
|
+
setWorkflowId: vi.fn(),
|
|
26
|
+
updateFileMetadata: vi.fn(),
|
|
27
|
+
removeFile: vi.fn(),
|
|
28
|
+
};
|
|
29
|
+
}),
|
|
30
|
+
}));
|
|
31
|
+
|
|
4
32
|
function createFile(name: string, size = 100): File {
|
|
5
33
|
return new File(['x'.repeat(size)], name, { type: 'application/pdf' });
|
|
6
34
|
}
|
|
@@ -31,6 +59,7 @@ function buildConfig(
|
|
|
31
59
|
return {
|
|
32
60
|
baseUrl: 'https://test.example.com',
|
|
33
61
|
workflowId: 'wf-1',
|
|
62
|
+
persist: false,
|
|
34
63
|
searchStoreConfig: {
|
|
35
64
|
models: [
|
|
36
65
|
{
|
|
@@ -48,16 +77,35 @@ function buildConfig(
|
|
|
48
77
|
};
|
|
49
78
|
}
|
|
50
79
|
|
|
80
|
+
async function settleAsyncUploads() {
|
|
81
|
+
await flushPromises();
|
|
82
|
+
}
|
|
83
|
+
|
|
51
84
|
describe('useFileUploadWrapper handleFilesSelected', () => {
|
|
52
85
|
let fetchMock: ReturnType<typeof vi.fn>;
|
|
53
86
|
|
|
54
87
|
beforeEach(() => {
|
|
88
|
+
uploadMock.upload.mockClear();
|
|
89
|
+
uploadMock.upload.mockResolvedValue({ results: [] });
|
|
90
|
+
|
|
55
91
|
fetchMock = vi.fn();
|
|
56
92
|
vi.stubGlobal('fetch', fetchMock);
|
|
57
93
|
|
|
58
94
|
fetchMock.mockImplementation((url: string, init?: RequestInit) => {
|
|
59
95
|
const method = init?.method ?? 'GET';
|
|
60
96
|
|
|
97
|
+
if (method === 'GET' && url.includes('/workflows')) {
|
|
98
|
+
return createMockFetchResponse({
|
|
99
|
+
items: [
|
|
100
|
+
{
|
|
101
|
+
id: 'workflow-api-1',
|
|
102
|
+
name: 'wf-1',
|
|
103
|
+
created_at: '2024-01-01T00:00:00Z',
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
total: 1,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
61
109
|
if (method === 'GET' && url.includes('search-stores')) {
|
|
62
110
|
return createMockFetchResponse({
|
|
63
111
|
items: [
|
|
@@ -70,27 +118,12 @@ describe('useFileUploadWrapper handleFilesSelected', () => {
|
|
|
70
118
|
total: 1,
|
|
71
119
|
});
|
|
72
120
|
}
|
|
73
|
-
if (method === 'POST' && url.includes('/files')) {
|
|
74
|
-
return createMockFetchResponse([{ id: 'remote-1' }]);
|
|
75
|
-
}
|
|
76
|
-
if (method === 'POST' && url.includes('/workflows')) {
|
|
77
|
-
return createMockFetchResponse({ id: 'run-1' });
|
|
78
|
-
}
|
|
79
|
-
if (url.includes('/event-stream')) {
|
|
80
|
-
return createMockFetchResponse(null, 200).then((res) => ({
|
|
81
|
-
...res,
|
|
82
|
-
body: new ReadableStream({
|
|
83
|
-
start(controller) {
|
|
84
|
-
controller.close();
|
|
85
|
-
},
|
|
86
|
-
}),
|
|
87
|
-
}));
|
|
88
|
-
}
|
|
89
121
|
return createMockFetchResponse({});
|
|
90
122
|
});
|
|
91
123
|
});
|
|
92
124
|
|
|
93
|
-
afterEach(() => {
|
|
125
|
+
afterEach(async () => {
|
|
126
|
+
await settleAsyncUploads();
|
|
94
127
|
vi.unstubAllGlobals();
|
|
95
128
|
});
|
|
96
129
|
|
|
@@ -115,26 +148,31 @@ describe('useFileUploadWrapper handleFilesSelected', () => {
|
|
|
115
148
|
const { handleFilesSelected } = provider.useDocumentUpload('conv-1');
|
|
116
149
|
|
|
117
150
|
const onFilesExceeded = vi.fn();
|
|
118
|
-
const
|
|
151
|
+
const files = [createFile('a.pdf'), createFile('b.pdf')];
|
|
152
|
+
const event = makeInputEvent(files);
|
|
119
153
|
|
|
120
154
|
handleFilesSelected(event, { onFilesExceeded });
|
|
121
155
|
|
|
122
156
|
expect(onFilesExceeded).not.toHaveBeenCalled();
|
|
157
|
+
await settleAsyncUploads();
|
|
158
|
+
expect(uploadMock.upload).toHaveBeenCalledOnce();
|
|
159
|
+
expect(uploadMock.upload.mock.calls[0]?.[0].files).toEqual(files);
|
|
123
160
|
});
|
|
124
161
|
|
|
125
|
-
it('truncates files to available slots and calls onFilesExceeded', () => {
|
|
162
|
+
it('truncates files to available slots and calls onFilesExceeded', async () => {
|
|
126
163
|
const provider = createDocumentUploadProvider(
|
|
127
164
|
buildConfig({ maxFilesPerUpload: 2 }),
|
|
128
165
|
);
|
|
129
166
|
const { handleFilesSelected } = provider.useDocumentUpload('conv-1');
|
|
130
167
|
|
|
131
168
|
const onFilesExceeded = vi.fn();
|
|
132
|
-
const
|
|
169
|
+
const files = [
|
|
133
170
|
createFile('a.pdf'),
|
|
134
171
|
createFile('b.pdf'),
|
|
135
172
|
createFile('c.pdf'),
|
|
136
173
|
createFile('d.pdf'),
|
|
137
|
-
]
|
|
174
|
+
];
|
|
175
|
+
const event = makeInputEvent(files);
|
|
138
176
|
|
|
139
177
|
handleFilesSelected(event, { onFilesExceeded });
|
|
140
178
|
|
|
@@ -143,9 +181,13 @@ describe('useFileUploadWrapper handleFilesSelected', () => {
|
|
|
143
181
|
allowed: 2,
|
|
144
182
|
kept: 2,
|
|
145
183
|
});
|
|
184
|
+
await settleAsyncUploads();
|
|
185
|
+
expect(uploadMock.upload.mock.calls[0]?.[0].files).toEqual(
|
|
186
|
+
files.slice(0, 2),
|
|
187
|
+
);
|
|
146
188
|
});
|
|
147
189
|
|
|
148
|
-
it('resets input value after handling', () => {
|
|
190
|
+
it('resets input value after handling', async () => {
|
|
149
191
|
const provider = createDocumentUploadProvider(
|
|
150
192
|
buildConfig({ maxFilesPerUpload: 5 }),
|
|
151
193
|
);
|
|
@@ -159,6 +201,7 @@ describe('useFileUploadWrapper handleFilesSelected', () => {
|
|
|
159
201
|
handleFilesSelected(event);
|
|
160
202
|
|
|
161
203
|
expect(valueSetter).toHaveBeenCalledWith('');
|
|
204
|
+
await settleAsyncUploads();
|
|
162
205
|
});
|
|
163
206
|
|
|
164
207
|
it('does not upload when no slots are available', async () => {
|
|
@@ -177,9 +220,12 @@ describe('useFileUploadWrapper handleFilesSelected', () => {
|
|
|
177
220
|
allowed: 0,
|
|
178
221
|
kept: 0,
|
|
179
222
|
});
|
|
223
|
+
await settleAsyncUploads();
|
|
224
|
+
expect(uploadMock.upload).not.toHaveBeenCalled();
|
|
225
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
180
226
|
});
|
|
181
227
|
|
|
182
|
-
it('does not call onFilesExceeded when exactly at the limit', () => {
|
|
228
|
+
it('does not call onFilesExceeded when exactly at the limit', async () => {
|
|
183
229
|
const provider = createDocumentUploadProvider(
|
|
184
230
|
buildConfig({ maxFilesPerUpload: 3 }),
|
|
185
231
|
);
|
|
@@ -195,9 +241,11 @@ describe('useFileUploadWrapper handleFilesSelected', () => {
|
|
|
195
241
|
handleFilesSelected(event, { onFilesExceeded });
|
|
196
242
|
|
|
197
243
|
expect(onFilesExceeded).not.toHaveBeenCalled();
|
|
244
|
+
await settleAsyncUploads();
|
|
245
|
+
expect(uploadMock.upload).toHaveBeenCalledOnce();
|
|
198
246
|
});
|
|
199
247
|
|
|
200
|
-
it('works without onFilesExceeded callback (no crash)', () => {
|
|
248
|
+
it('works without onFilesExceeded callback (no crash)', async () => {
|
|
201
249
|
const provider = createDocumentUploadProvider(
|
|
202
250
|
buildConfig({ maxFilesPerUpload: 1 }),
|
|
203
251
|
);
|
|
@@ -206,5 +254,7 @@ describe('useFileUploadWrapper handleFilesSelected', () => {
|
|
|
206
254
|
const event = makeInputEvent([createFile('a.pdf'), createFile('b.pdf')]);
|
|
207
255
|
|
|
208
256
|
expect(() => handleFilesSelected(event)).not.toThrow();
|
|
257
|
+
await settleAsyncUploads();
|
|
258
|
+
expect(uploadMock.upload).toHaveBeenCalledOnce();
|
|
209
259
|
});
|
|
210
260
|
});
|