@aleph-alpha/chat-kit 3.1.3 → 3.3.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-BAcBFs6I.js} +302 -71
- package/dist/adapters/responses-api.d.ts.map +1 -1
- package/dist/adapters/responses-api.js +63 -37
- package/dist/chat-kit.css +6 -0
- package/dist/components/base/CkAgentMessage/CkAgentMessage.stories.d.ts +7 -0
- package/dist/components/base/CkAgentMessage/CkAgentMessage.stories.d.ts.map +1 -1
- package/dist/components/base/CkCitation/CkCitation.stories.d.ts +47 -0
- package/dist/components/base/CkCitation/CkCitation.stories.d.ts.map +1 -0
- package/dist/components/base/CkCitation/CkCitation.vue.d.ts +15 -0
- package/dist/components/base/CkCitation/CkCitation.vue.d.ts.map +1 -0
- package/dist/components/base/CkCitation/index.d.ts +3 -0
- package/dist/components/base/CkCitation/index.d.ts.map +1 -0
- package/dist/components/base/CkCitation/types.d.ts +79 -0
- package/dist/components/base/CkCitation/types.d.ts.map +1 -0
- package/dist/components/base/CkCitationCard/CkCitationCard.stories.d.ts +51 -0
- package/dist/components/base/CkCitationCard/CkCitationCard.stories.d.ts.map +1 -0
- package/dist/components/base/CkCitationCard/CkCitationCard.vue.d.ts +13 -0
- package/dist/components/base/CkCitationCard/CkCitationCard.vue.d.ts.map +1 -0
- package/dist/components/base/CkCitationCard/index.d.ts +3 -0
- package/dist/components/base/CkCitationCard/index.d.ts.map +1 -0
- package/dist/components/base/CkCitationCard/types.d.ts +51 -0
- package/dist/components/base/CkCitationCard/types.d.ts.map +1 -0
- package/dist/components/base/CkDropZoneOverlay/CkDropZoneOverlay.vue.d.ts +1 -1
- package/dist/components/base/CkFeedbackDialog/CkFeedbackDialog.vue.d.ts +2 -2
- package/dist/components/base/CkFileItemsList/CkFileItemsList.vue.d.ts +1 -1
- package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.d.ts +15 -0
- package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.d.ts.map +1 -1
- package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.vue.d.ts +16 -2
- package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.vue.d.ts.map +1 -1
- package/dist/components/base/CkMarkdownRenderer/index.d.ts +1 -1
- package/dist/components/base/CkMarkdownRenderer/index.d.ts.map +1 -1
- package/dist/components/base/CkMarkdownRenderer/types.d.ts +25 -0
- package/dist/components/base/CkMarkdownRenderer/types.d.ts.map +1 -1
- package/dist/components/base/CkMarkdownRenderer/useCitationRendering.d.ts +17 -0
- package/dist/components/base/CkMarkdownRenderer/useCitationRendering.d.ts.map +1 -0
- package/dist/components/base/CkPromptSendButton/CkPromptSendButton.vue.d.ts +1 -1
- package/dist/components/base/index.d.ts +5 -1
- package/dist/components/base/index.d.ts.map +1 -1
- package/dist/components/composed/CkInput/CkInput.vue.d.ts +1 -1
- package/dist/components/index.d.ts +2 -2
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +25 -23
- 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 +3 -0
- package/dist/handlers/index.d.ts.map +1 -1
- package/dist/handlers/index.js +4 -1
- package/dist/handlers/useChatKitLabels.d.ts +11 -0
- package/dist/handlers/useChatKitLabels.d.ts.map +1 -1
- package/dist/handlers/useMarkdownParser.d.ts.map +1 -1
- package/dist/helpers/unified/remarkCitations.d.ts +17 -0
- package/dist/helpers/unified/remarkCitations.d.ts.map +1 -0
- 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-C6njJ8EB.js} +96 -49
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -26
- package/dist/{useChatKitLabels-Ctz1dHpx.js → useChatKitLabels-BM2I7-9o.js} +126 -3
- package/package.json +6 -3
- package/src/adapters/responses-api.spec.ts +156 -0
- package/src/adapters/responses-api.ts +125 -58
- package/src/components/base/CkAgentMessage/CkAgentMessage.stories.ts +112 -0
- package/src/components/base/CkCitation/CkCitation.spec.ts +79 -0
- package/src/components/base/CkCitation/CkCitation.stories.ts +136 -0
- package/src/components/base/CkCitation/CkCitation.vue +145 -0
- package/src/components/base/CkCitation/index.ts +2 -0
- package/src/components/base/CkCitation/types.ts +80 -0
- package/src/components/base/CkCitationCard/CkCitationCard.spec.ts +77 -0
- package/src/components/base/CkCitationCard/CkCitationCard.stories.ts +144 -0
- package/src/components/base/CkCitationCard/CkCitationCard.vue +76 -0
- package/src/components/base/CkCitationCard/index.ts +2 -0
- package/src/components/base/CkCitationCard/types.ts +51 -0
- package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.spec.ts +124 -0
- package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.ts +118 -0
- package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.vue +70 -28
- package/src/components/base/CkMarkdownRenderer/index.ts +1 -0
- package/src/components/base/CkMarkdownRenderer/types.ts +26 -0
- package/src/components/base/CkMarkdownRenderer/useCitationRendering.spec.ts +94 -0
- package/src/components/base/CkMarkdownRenderer/useCitationRendering.ts +52 -0
- package/src/components/base/index.ts +12 -0
- package/src/components/index.ts +8 -0
- package/src/composables/fileUpload/types.ts +6 -0
- package/src/composables/fileUpload/useFileUpload.spec.ts +2 -61
- package/src/composables/fileUpload/useFileUpload.ts +9 -10
- package/src/composables/fileUpload/useFileUploadWrapper.spec.ts +241 -10
- package/src/composables/fileUpload/useFileUploadWrapper.ts +126 -53
- package/src/handlers/index.ts +6 -0
- package/src/handlers/useChatKitLabels.ts +13 -0
- package/src/handlers/useMarkdownParser.spec.ts +34 -0
- package/src/handlers/useMarkdownParser.ts +15 -3
- package/src/helpers/unified/remarkCitations.spec.ts +112 -0
- package/src/helpers/unified/remarkCitations.ts +55 -0
- package/src/helpers/unified/remarkNormalizeMath.spec.ts +84 -0
- package/src/helpers/unified/remarkNormalizeMath.ts +134 -0
- package/src/index.ts +8 -0
|
@@ -28468,6 +28468,100 @@ function rehypeStringify(options) {
|
|
|
28468
28468
|
return toHtml(tree, settings);
|
|
28469
28469
|
}
|
|
28470
28470
|
}
|
|
28471
|
+
const PLACEHOLDER_PREFIX = "PH_";
|
|
28472
|
+
const PLACEHOLDER_SUFFIX = "";
|
|
28473
|
+
const PLACEHOLDER_RE = /\uE000PH_(\d+)\uE000/g;
|
|
28474
|
+
const FENCE_START_RE = /^[ \t]{0,3}(`{3,}|~{3,})/;
|
|
28475
|
+
const INLINE_CODE_RE = /`[^`\n]+`/g;
|
|
28476
|
+
const LATEX_BLOCK_RE = /\\\[([\s\S]+?)\\\]/g;
|
|
28477
|
+
const LATEX_INLINE_RE = /\\\(([^\n]+?)\\\)/g;
|
|
28478
|
+
const SINGLE_LINE_DOUBLE_DOLLAR_RE = /\$\$([^\n$]+?)\$\$/g;
|
|
28479
|
+
function isClosingFence(line, fenceChar, minLen) {
|
|
28480
|
+
let i = 0;
|
|
28481
|
+
let indent2 = 0;
|
|
28482
|
+
while (i < line.length && (line[i] === " " || line[i] === " ") && indent2 < 4) {
|
|
28483
|
+
indent2++;
|
|
28484
|
+
i++;
|
|
28485
|
+
}
|
|
28486
|
+
if (indent2 >= 4) return false;
|
|
28487
|
+
let runLen = 0;
|
|
28488
|
+
while (i < line.length && line[i] === fenceChar) {
|
|
28489
|
+
runLen++;
|
|
28490
|
+
i++;
|
|
28491
|
+
}
|
|
28492
|
+
if (runLen < minLen) return false;
|
|
28493
|
+
while (i < line.length) {
|
|
28494
|
+
if (line[i] !== " " && line[i] !== " ") return false;
|
|
28495
|
+
i++;
|
|
28496
|
+
}
|
|
28497
|
+
return true;
|
|
28498
|
+
}
|
|
28499
|
+
function protectFencedCode(input, stash) {
|
|
28500
|
+
const lines = input.split("\n");
|
|
28501
|
+
const out = [];
|
|
28502
|
+
let i = 0;
|
|
28503
|
+
while (i < lines.length) {
|
|
28504
|
+
const match = FENCE_START_RE.exec(lines[i]);
|
|
28505
|
+
if (match) {
|
|
28506
|
+
const fence = match[1];
|
|
28507
|
+
const fenceChar = fence[0];
|
|
28508
|
+
const minLen = fence.length;
|
|
28509
|
+
const block = [lines[i]];
|
|
28510
|
+
i++;
|
|
28511
|
+
while (i < lines.length) {
|
|
28512
|
+
const current = lines[i];
|
|
28513
|
+
block.push(current);
|
|
28514
|
+
i++;
|
|
28515
|
+
if (isClosingFence(current, fenceChar, minLen)) break;
|
|
28516
|
+
}
|
|
28517
|
+
out.push(stash(block.join("\n")));
|
|
28518
|
+
} else {
|
|
28519
|
+
out.push(lines[i]);
|
|
28520
|
+
i++;
|
|
28521
|
+
}
|
|
28522
|
+
}
|
|
28523
|
+
return out.join("\n");
|
|
28524
|
+
}
|
|
28525
|
+
function normalizeMathDelimiters(input) {
|
|
28526
|
+
if (!input) return input;
|
|
28527
|
+
const placeholders = [];
|
|
28528
|
+
const stash = (value) => {
|
|
28529
|
+
const id = placeholders.length;
|
|
28530
|
+
placeholders.push(value);
|
|
28531
|
+
return `${PLACEHOLDER_PREFIX}${id}${PLACEHOLDER_SUFFIX}`;
|
|
28532
|
+
};
|
|
28533
|
+
let s2 = protectFencedCode(input, stash);
|
|
28534
|
+
s2 = s2.replace(INLINE_CODE_RE, stash);
|
|
28535
|
+
s2 = s2.replace(
|
|
28536
|
+
LATEX_BLOCK_RE,
|
|
28537
|
+
(_m, body2) => `
|
|
28538
|
+
|
|
28539
|
+
$$
|
|
28540
|
+
${body2.trim()}
|
|
28541
|
+
$$
|
|
28542
|
+
|
|
28543
|
+
`
|
|
28544
|
+
);
|
|
28545
|
+
s2 = s2.replace(LATEX_INLINE_RE, (_m, body2) => `$${body2.trim()}$`);
|
|
28546
|
+
s2 = s2.replace(
|
|
28547
|
+
SINGLE_LINE_DOUBLE_DOLLAR_RE,
|
|
28548
|
+
(_m, body2) => `
|
|
28549
|
+
|
|
28550
|
+
$$
|
|
28551
|
+
${body2.trim()}
|
|
28552
|
+
$$
|
|
28553
|
+
|
|
28554
|
+
`
|
|
28555
|
+
);
|
|
28556
|
+
s2 = s2.replace(PLACEHOLDER_RE, (_m, id) => placeholders[Number(id)]);
|
|
28557
|
+
return s2;
|
|
28558
|
+
}
|
|
28559
|
+
const remarkNormalizeMath = function() {
|
|
28560
|
+
const processor = this;
|
|
28561
|
+
const originalParser = processor.parser;
|
|
28562
|
+
if (typeof originalParser !== "function") return;
|
|
28563
|
+
processor.parser = (document2, file) => originalParser(normalizeMathDelimiters(document2), file);
|
|
28564
|
+
};
|
|
28471
28565
|
const KATEX_MATHML_TAGS = [
|
|
28472
28566
|
"math",
|
|
28473
28567
|
"semantics",
|
|
@@ -28527,9 +28621,9 @@ const SANITIZE_CONFIG = {
|
|
|
28527
28621
|
ADD_ATTR: [...KATEX_MATHML_ATTRS]
|
|
28528
28622
|
};
|
|
28529
28623
|
function buildProcessor(plugins) {
|
|
28530
|
-
let proc = unified().use(remarkParse).use(remarkGfm).use(remarkMath);
|
|
28531
|
-
|
|
28532
|
-
proc = proc.use(
|
|
28624
|
+
let proc = unified().use(remarkParse).use(remarkNormalizeMath).use(remarkGfm).use(remarkMath);
|
|
28625
|
+
if (plugins.length > 0) {
|
|
28626
|
+
proc = proc.use(plugins);
|
|
28533
28627
|
}
|
|
28534
28628
|
return proc.use(remarkRehype).use(rehypeKatex).use(rehypeStringify);
|
|
28535
28629
|
}
|
|
@@ -28556,6 +28650,30 @@ function useMarkdownParser(source, plugins) {
|
|
|
28556
28650
|
});
|
|
28557
28651
|
return { html: html2 };
|
|
28558
28652
|
}
|
|
28653
|
+
const CITATION_RE = /\[(\d+)\]/g;
|
|
28654
|
+
const remarkCitations = (options = {}) => {
|
|
28655
|
+
const { indices } = options;
|
|
28656
|
+
return (tree) => {
|
|
28657
|
+
findAndReplace(tree, [
|
|
28658
|
+
CITATION_RE,
|
|
28659
|
+
(_match, digits) => {
|
|
28660
|
+
const index2 = Number(digits);
|
|
28661
|
+
if (index2 < 1) return false;
|
|
28662
|
+
if (indices && !indices.has(index2)) return false;
|
|
28663
|
+
return {
|
|
28664
|
+
type: "citationReference",
|
|
28665
|
+
data: {
|
|
28666
|
+
hName: "span",
|
|
28667
|
+
// Use the literal attribute name so the placeholder contract matches
|
|
28668
|
+
// the renderer's `getAttribute('data-citation-index')` lookup exactly.
|
|
28669
|
+
hProperties: { "data-citation-index": index2 },
|
|
28670
|
+
hChildren: [{ type: "text", value: String(index2) }]
|
|
28671
|
+
}
|
|
28672
|
+
};
|
|
28673
|
+
}
|
|
28674
|
+
]);
|
|
28675
|
+
};
|
|
28676
|
+
};
|
|
28559
28677
|
const defaultChatKitLabels = {
|
|
28560
28678
|
agentAvatarAlt: "Agent avatar",
|
|
28561
28679
|
agentLoadingAvatarAlt: "Agent loading",
|
|
@@ -28580,6 +28698,8 @@ const defaultChatKitLabels = {
|
|
|
28580
28698
|
modelSelectorListAriaLabel: "Models",
|
|
28581
28699
|
modelSelectorPlaceholder: "Select model",
|
|
28582
28700
|
markdownTableLabel: "Table",
|
|
28701
|
+
citationOpenSource: "Open source",
|
|
28702
|
+
citationSourceLabel: "Source",
|
|
28583
28703
|
historyItemUntitled: "Untitled conversation",
|
|
28584
28704
|
historyItemActionAriaLabel: "Conversation options",
|
|
28585
28705
|
historyItemEditAction: "Edit",
|
|
@@ -28602,7 +28722,10 @@ function useChatKitLabels() {
|
|
|
28602
28722
|
export {
|
|
28603
28723
|
CHAT_KIT_LABELS as C,
|
|
28604
28724
|
useMarkdownParser as a,
|
|
28725
|
+
remarkNormalizeMath as b,
|
|
28605
28726
|
defaultChatKitLabels as d,
|
|
28727
|
+
normalizeMathDelimiters as n,
|
|
28606
28728
|
provideChatKitLabels as p,
|
|
28729
|
+
remarkCitations as r,
|
|
28607
28730
|
useChatKitLabels as u
|
|
28608
28731
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aleph-alpha/chat-kit",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"description": "Chat kit package for building chat interfaces with Vue",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
"@aleph-alpha/ui-library": "^2.8.3",
|
|
69
69
|
"dompurify": "^3.3.2",
|
|
70
70
|
"katex": "^0.17.0",
|
|
71
|
+
"mdast-util-find-and-replace": "^3.0.2",
|
|
71
72
|
"rehype-katex": "^7.0.1",
|
|
72
73
|
"rehype-stringify": "^10.0.1",
|
|
73
74
|
"remark-gfm": "^4.0.1",
|
|
@@ -77,7 +78,6 @@
|
|
|
77
78
|
"unified": "^11.0.5"
|
|
78
79
|
},
|
|
79
80
|
"devDependencies": {
|
|
80
|
-
"@aleph-alpha/config-css": "^1.2.0",
|
|
81
81
|
"@storybook/addon-a11y": "10.2.17",
|
|
82
82
|
"@storybook/addon-docs": "10.2.17",
|
|
83
83
|
"@storybook/addon-themes": "10.2.17",
|
|
@@ -85,6 +85,7 @@
|
|
|
85
85
|
"@testing-library/jest-dom": "6.9.1",
|
|
86
86
|
"@testing-library/vue": "8.1.0",
|
|
87
87
|
"@types/dompurify": "^3.2.0",
|
|
88
|
+
"@types/mdast": "^4.0.4",
|
|
88
89
|
"@types/node": "^25.1.0",
|
|
89
90
|
"@unocss/preset-wind4": "^66.6.2",
|
|
90
91
|
"@unocss/reset": "^66.6.2",
|
|
@@ -101,7 +102,9 @@
|
|
|
101
102
|
"vitest": "3.2.6",
|
|
102
103
|
"vue": "^3.5.27",
|
|
103
104
|
"vue-tsc": "^3.2.4",
|
|
104
|
-
"@aleph-alpha/tsconfig-frontend": "0.7.0"
|
|
105
|
+
"@aleph-alpha/tsconfig-frontend": "0.7.0",
|
|
106
|
+
"@aleph-alpha/prettier-config-frontend": "2.1.0",
|
|
107
|
+
"@aleph-alpha/eslint-config-frontend": "0.7.0"
|
|
105
108
|
},
|
|
106
109
|
"scripts": {
|
|
107
110
|
"build": "vite build && vue-tsc --project tsconfig.build.json --declaration --emitDeclarationOnly --outDir dist",
|
|
@@ -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
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import CkAgentMessage from './CkAgentMessage.vue';
|
|
2
|
+
import { CkMarkdownRenderer } from '../CkMarkdownRenderer';
|
|
3
|
+
import type { CkCitationSource } from '../CkCitation';
|
|
2
4
|
import type { Meta, StoryObj } from '@storybook/vue3-vite';
|
|
3
5
|
|
|
4
6
|
const meta: Meta<typeof CkAgentMessage> = {
|
|
@@ -213,3 +215,113 @@ export const WithoutIcon: Story = {
|
|
|
213
215
|
}),
|
|
214
216
|
parameters: { docs: { source: { code: withoutIconTemplateSource } } },
|
|
215
217
|
};
|
|
218
|
+
|
|
219
|
+
const CITATIONS: CkCitationSource[] = [
|
|
220
|
+
{
|
|
221
|
+
index: 1,
|
|
222
|
+
fileName: 'Q2_Business_Plan.pdf',
|
|
223
|
+
snippet:
|
|
224
|
+
'Departments that received customized training saw a 25% higher adoption rate of the new tools compared to those with generic training.',
|
|
225
|
+
locationLabel: 'Page 3',
|
|
226
|
+
url: '/api/v1/search-stores/ss_abc/files/file_123/public-urls',
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
index: 2,
|
|
230
|
+
fileName: 'Support-Metrics-Q1.csv',
|
|
231
|
+
snippet:
|
|
232
|
+
'Ticket volume fell 18% quarter-over-quarter for the tailored-training cohorts versus the control group.',
|
|
233
|
+
locationLabel: 'Row 42',
|
|
234
|
+
url: '/api/v1/search-stores/ss_abc/files/file_456/public-urls',
|
|
235
|
+
},
|
|
236
|
+
];
|
|
237
|
+
|
|
238
|
+
const CITED_MARKDOWN = [
|
|
239
|
+
'Based on the internal documents, customized training drove measurably better outcomes.',
|
|
240
|
+
'',
|
|
241
|
+
'Departments with tailored sessions saw a **25% higher adoption rate** [1],',
|
|
242
|
+
'and reported markedly fewer support tickets in the first quarter [2].',
|
|
243
|
+
].join('\n');
|
|
244
|
+
|
|
245
|
+
const withCitationsTemplateSource = `<script setup lang="ts">
|
|
246
|
+
import { CkAgentMessage, CkMarkdownRenderer } from '@aleph-alpha/chat-kit';
|
|
247
|
+
import type { CkCitationSource } from '@aleph-alpha/chat-kit';
|
|
248
|
+
|
|
249
|
+
// The reply body is markdown that contains inline [n] references.
|
|
250
|
+
const content = [
|
|
251
|
+
'Based on the internal documents, customized training drove measurably better outcomes.',
|
|
252
|
+
'',
|
|
253
|
+
'Departments with tailored sessions saw a **25% higher adoption rate** [1],',
|
|
254
|
+
'and reported markedly fewer support tickets in the first quarter [2].',
|
|
255
|
+
].join('\\n');
|
|
256
|
+
|
|
257
|
+
// The numbered sources, mapped from the backend citations manifest.
|
|
258
|
+
const citations: CkCitationSource[] = [
|
|
259
|
+
{
|
|
260
|
+
index: 1,
|
|
261
|
+
fileName: 'Q2_Business_Plan.pdf',
|
|
262
|
+
snippet: 'Departments that received customized training saw a 25% higher adoption rate…',
|
|
263
|
+
locationLabel: 'Page 3',
|
|
264
|
+
url: '/api/v1/search-stores/ss_abc/files/file_123/public-urls',
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
index: 2,
|
|
268
|
+
fileName: 'Support-Metrics-Q1.csv',
|
|
269
|
+
snippet: 'Ticket volume fell 18% quarter-over-quarter for the tailored-training cohorts.',
|
|
270
|
+
locationLabel: 'Row 42',
|
|
271
|
+
url: '/api/v1/search-stores/ss_abc/files/file_456/public-urls',
|
|
272
|
+
},
|
|
273
|
+
];
|
|
274
|
+
|
|
275
|
+
function openSource(url: string) {
|
|
276
|
+
// Source URLs are auth-proxied; the host performs the navigation.
|
|
277
|
+
window.open(url, '_blank', 'noopener');
|
|
278
|
+
}
|
|
279
|
+
</script>
|
|
280
|
+
|
|
281
|
+
<template>
|
|
282
|
+
<CkAgentMessage>
|
|
283
|
+
<template #icon>
|
|
284
|
+
<div class="bg-core-bg-tertiary flex size-6 items-center justify-center rounded-full text-xs">
|
|
285
|
+
AI
|
|
286
|
+
</div>
|
|
287
|
+
</template>
|
|
288
|
+
<CkMarkdownRenderer
|
|
289
|
+
:content="content"
|
|
290
|
+
:citations="citations"
|
|
291
|
+
@open-source="openSource"
|
|
292
|
+
/>
|
|
293
|
+
</CkAgentMessage>
|
|
294
|
+
</template>`;
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* An assistant reply whose body is markdown containing inline `[n]` citations,
|
|
298
|
+
* rendered through `CkMarkdownRenderer` inside the message. Each `[n]` becomes
|
|
299
|
+
* an interactive `CkCitation` — hover a marker to preview its source. This is
|
|
300
|
+
* the end-to-end shape of a cited answer in a conversation.
|
|
301
|
+
*/
|
|
302
|
+
export const WithCitations: Story = {
|
|
303
|
+
render: () => ({
|
|
304
|
+
components: { CkAgentMessage, CkMarkdownRenderer },
|
|
305
|
+
setup() {
|
|
306
|
+
function openSource(url: string) {
|
|
307
|
+
window.open(url, '_blank', 'noopener');
|
|
308
|
+
}
|
|
309
|
+
return { content: CITED_MARKDOWN, citations: CITATIONS, openSource };
|
|
310
|
+
},
|
|
311
|
+
template: `
|
|
312
|
+
<CkAgentMessage>
|
|
313
|
+
<template #icon>
|
|
314
|
+
<div class="bg-core-bg-tertiary flex size-6 items-center justify-center rounded-full text-xs">
|
|
315
|
+
AI
|
|
316
|
+
</div>
|
|
317
|
+
</template>
|
|
318
|
+
<CkMarkdownRenderer
|
|
319
|
+
:content="content"
|
|
320
|
+
:citations="citations"
|
|
321
|
+
@open-source="openSource"
|
|
322
|
+
/>
|
|
323
|
+
</CkAgentMessage>
|
|
324
|
+
`,
|
|
325
|
+
}),
|
|
326
|
+
parameters: { docs: { source: { code: withCitationsTemplateSource } } },
|
|
327
|
+
};
|