@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.
Files changed (99) hide show
  1. package/dist/{CkInput.vue_vue_type_script_setup_true_lang-LPrW7CSQ.js → CkInput.vue_vue_type_script_setup_true_lang-BAcBFs6I.js} +302 -71
  2. package/dist/adapters/responses-api.d.ts.map +1 -1
  3. package/dist/adapters/responses-api.js +63 -37
  4. package/dist/chat-kit.css +6 -0
  5. package/dist/components/base/CkAgentMessage/CkAgentMessage.stories.d.ts +7 -0
  6. package/dist/components/base/CkAgentMessage/CkAgentMessage.stories.d.ts.map +1 -1
  7. package/dist/components/base/CkCitation/CkCitation.stories.d.ts +47 -0
  8. package/dist/components/base/CkCitation/CkCitation.stories.d.ts.map +1 -0
  9. package/dist/components/base/CkCitation/CkCitation.vue.d.ts +15 -0
  10. package/dist/components/base/CkCitation/CkCitation.vue.d.ts.map +1 -0
  11. package/dist/components/base/CkCitation/index.d.ts +3 -0
  12. package/dist/components/base/CkCitation/index.d.ts.map +1 -0
  13. package/dist/components/base/CkCitation/types.d.ts +79 -0
  14. package/dist/components/base/CkCitation/types.d.ts.map +1 -0
  15. package/dist/components/base/CkCitationCard/CkCitationCard.stories.d.ts +51 -0
  16. package/dist/components/base/CkCitationCard/CkCitationCard.stories.d.ts.map +1 -0
  17. package/dist/components/base/CkCitationCard/CkCitationCard.vue.d.ts +13 -0
  18. package/dist/components/base/CkCitationCard/CkCitationCard.vue.d.ts.map +1 -0
  19. package/dist/components/base/CkCitationCard/index.d.ts +3 -0
  20. package/dist/components/base/CkCitationCard/index.d.ts.map +1 -0
  21. package/dist/components/base/CkCitationCard/types.d.ts +51 -0
  22. package/dist/components/base/CkCitationCard/types.d.ts.map +1 -0
  23. package/dist/components/base/CkDropZoneOverlay/CkDropZoneOverlay.vue.d.ts +1 -1
  24. package/dist/components/base/CkFeedbackDialog/CkFeedbackDialog.vue.d.ts +2 -2
  25. package/dist/components/base/CkFileItemsList/CkFileItemsList.vue.d.ts +1 -1
  26. package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.d.ts +15 -0
  27. package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.d.ts.map +1 -1
  28. package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.vue.d.ts +16 -2
  29. package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.vue.d.ts.map +1 -1
  30. package/dist/components/base/CkMarkdownRenderer/index.d.ts +1 -1
  31. package/dist/components/base/CkMarkdownRenderer/index.d.ts.map +1 -1
  32. package/dist/components/base/CkMarkdownRenderer/types.d.ts +25 -0
  33. package/dist/components/base/CkMarkdownRenderer/types.d.ts.map +1 -1
  34. package/dist/components/base/CkMarkdownRenderer/useCitationRendering.d.ts +17 -0
  35. package/dist/components/base/CkMarkdownRenderer/useCitationRendering.d.ts.map +1 -0
  36. package/dist/components/base/CkPromptSendButton/CkPromptSendButton.vue.d.ts +1 -1
  37. package/dist/components/base/index.d.ts +5 -1
  38. package/dist/components/base/index.d.ts.map +1 -1
  39. package/dist/components/composed/CkInput/CkInput.vue.d.ts +1 -1
  40. package/dist/components/index.d.ts +2 -2
  41. package/dist/components/index.d.ts.map +1 -1
  42. package/dist/components/index.js +25 -23
  43. package/dist/composables/fileUpload/types.d.ts +3 -0
  44. package/dist/composables/fileUpload/types.d.ts.map +1 -1
  45. package/dist/composables/fileUpload/useFileUpload.d.ts.map +1 -1
  46. package/dist/composables/fileUpload/useFileUploadWrapper.d.ts.map +1 -1
  47. package/dist/composables/index.js +1 -1
  48. package/dist/handlers/index.d.ts +3 -0
  49. package/dist/handlers/index.d.ts.map +1 -1
  50. package/dist/handlers/index.js +4 -1
  51. package/dist/handlers/useChatKitLabels.d.ts +11 -0
  52. package/dist/handlers/useChatKitLabels.d.ts.map +1 -1
  53. package/dist/handlers/useMarkdownParser.d.ts.map +1 -1
  54. package/dist/helpers/unified/remarkCitations.d.ts +17 -0
  55. package/dist/helpers/unified/remarkCitations.d.ts.map +1 -0
  56. package/dist/helpers/unified/remarkNormalizeMath.d.ts +24 -0
  57. package/dist/helpers/unified/remarkNormalizeMath.d.ts.map +1 -0
  58. package/dist/{index-DVxCNAs3.js → index-C6njJ8EB.js} +96 -49
  59. package/dist/index.d.ts +2 -2
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +28 -26
  62. package/dist/{useChatKitLabels-Ctz1dHpx.js → useChatKitLabels-BM2I7-9o.js} +126 -3
  63. package/package.json +6 -3
  64. package/src/adapters/responses-api.spec.ts +156 -0
  65. package/src/adapters/responses-api.ts +125 -58
  66. package/src/components/base/CkAgentMessage/CkAgentMessage.stories.ts +112 -0
  67. package/src/components/base/CkCitation/CkCitation.spec.ts +79 -0
  68. package/src/components/base/CkCitation/CkCitation.stories.ts +136 -0
  69. package/src/components/base/CkCitation/CkCitation.vue +145 -0
  70. package/src/components/base/CkCitation/index.ts +2 -0
  71. package/src/components/base/CkCitation/types.ts +80 -0
  72. package/src/components/base/CkCitationCard/CkCitationCard.spec.ts +77 -0
  73. package/src/components/base/CkCitationCard/CkCitationCard.stories.ts +144 -0
  74. package/src/components/base/CkCitationCard/CkCitationCard.vue +76 -0
  75. package/src/components/base/CkCitationCard/index.ts +2 -0
  76. package/src/components/base/CkCitationCard/types.ts +51 -0
  77. package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.spec.ts +124 -0
  78. package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.ts +118 -0
  79. package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.vue +70 -28
  80. package/src/components/base/CkMarkdownRenderer/index.ts +1 -0
  81. package/src/components/base/CkMarkdownRenderer/types.ts +26 -0
  82. package/src/components/base/CkMarkdownRenderer/useCitationRendering.spec.ts +94 -0
  83. package/src/components/base/CkMarkdownRenderer/useCitationRendering.ts +52 -0
  84. package/src/components/base/index.ts +12 -0
  85. package/src/components/index.ts +8 -0
  86. package/src/composables/fileUpload/types.ts +6 -0
  87. package/src/composables/fileUpload/useFileUpload.spec.ts +2 -61
  88. package/src/composables/fileUpload/useFileUpload.ts +9 -10
  89. package/src/composables/fileUpload/useFileUploadWrapper.spec.ts +241 -10
  90. package/src/composables/fileUpload/useFileUploadWrapper.ts +126 -53
  91. package/src/handlers/index.ts +6 -0
  92. package/src/handlers/useChatKitLabels.ts +13 -0
  93. package/src/handlers/useMarkdownParser.spec.ts +34 -0
  94. package/src/handlers/useMarkdownParser.ts +15 -3
  95. package/src/helpers/unified/remarkCitations.spec.ts +112 -0
  96. package/src/helpers/unified/remarkCitations.ts +55 -0
  97. package/src/helpers/unified/remarkNormalizeMath.spec.ts +84 -0
  98. package/src/helpers/unified/remarkNormalizeMath.ts +134 -0
  99. 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
- for (const plugin of plugins) {
28532
- proc = proc.use(plugin);
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.1.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 treeNodes: Record<string, ResponseTreeMessage> = {};
539
- // For each response, the id of its last visible message — that's the
540
- // attachment point for any future child response that chains onto it.
541
- // Empty responses fall back to their parent's attachment point so the
542
- // chain stays connected.
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
- const parentResponseId =
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
+ };