@aleph-alpha/chat-kit 3.3.0 → 4.0.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 (98) hide show
  1. package/dist/{CkInput.vue_vue_type_script_setup_true_lang-BAcBFs6I.js → CkInput.vue_vue_type_script_setup_true_lang-DJN-nZ2-.js} +118 -71
  2. package/dist/adapters/responses-api.d.ts +11 -5
  3. package/dist/adapters/responses-api.d.ts.map +1 -1
  4. package/dist/adapters/responses-api.js +9 -3
  5. package/dist/chat-kit.css +8 -8
  6. package/dist/components/base/CkAgentMessage/types.d.ts +1 -1
  7. package/dist/components/base/{CkCopyMessageActionButton/CkCopyMessageActionButton.stories.d.ts → CkCopyMessageButton/CkCopyMessageButton.stories.d.ts} +3 -3
  8. package/dist/components/base/CkCopyMessageButton/CkCopyMessageButton.stories.d.ts.map +1 -0
  9. package/dist/components/base/{CkCopyMessageActionButton/CkCopyMessageActionButton.vue.d.ts → CkCopyMessageButton/CkCopyMessageButton.vue.d.ts} +4 -4
  10. package/dist/components/base/CkCopyMessageButton/CkCopyMessageButton.vue.d.ts.map +1 -0
  11. package/dist/components/base/CkCopyMessageButton/index.d.ts +3 -0
  12. package/dist/components/base/CkCopyMessageButton/index.d.ts.map +1 -0
  13. package/dist/components/base/{CkCopyMessageActionButton → CkCopyMessageButton}/types.d.ts +3 -3
  14. package/dist/components/base/CkCopyMessageButton/types.d.ts.map +1 -0
  15. package/dist/components/base/CkFeedbackButton/types.d.ts +1 -1
  16. package/dist/components/base/CkFeedbackDialog/types.d.ts +1 -1
  17. package/dist/components/base/{CkMessageActionButton/CkMessageActionButton.stories.d.ts → CkMessageButton/CkMessageButton.stories.d.ts} +3 -3
  18. package/dist/components/base/CkMessageButton/CkMessageButton.stories.d.ts.map +1 -0
  19. package/dist/components/base/{CkMessageActionButton/CkMessageActionButton.vue.d.ts → CkMessageButton/CkMessageButton.vue.d.ts} +3 -3
  20. package/dist/components/base/CkMessageButton/CkMessageButton.vue.d.ts.map +1 -0
  21. package/dist/components/base/CkMessageButton/index.d.ts +3 -0
  22. package/dist/components/base/CkMessageButton/index.d.ts.map +1 -0
  23. package/dist/components/base/{CkMessageActionButton → CkMessageButton}/types.d.ts +2 -2
  24. package/dist/components/base/CkMessageButton/types.d.ts.map +1 -0
  25. package/dist/components/base/CkRegenerateMessageButton/CkRegenerateMessageButton.stories.d.ts +16 -0
  26. package/dist/components/base/CkRegenerateMessageButton/CkRegenerateMessageButton.stories.d.ts.map +1 -0
  27. package/dist/components/base/CkRegenerateMessageButton/CkRegenerateMessageButton.vue.d.ts +12 -0
  28. package/dist/components/base/CkRegenerateMessageButton/CkRegenerateMessageButton.vue.d.ts.map +1 -0
  29. package/dist/components/base/CkRegenerateMessageButton/index.d.ts +3 -0
  30. package/dist/components/base/{CkCopyMessageActionButton → CkRegenerateMessageButton}/index.d.ts.map +1 -1
  31. package/dist/components/base/CkRegenerateMessageButton/types.d.ts +27 -0
  32. package/dist/components/base/CkRegenerateMessageButton/types.d.ts.map +1 -0
  33. package/dist/components/base/index.d.ts +3 -2
  34. package/dist/components/base/index.d.ts.map +1 -1
  35. package/dist/components/composed/CkConversation/CkConversation.stories.d.ts +1 -1
  36. package/dist/components/composed/CkConversation/types.d.ts +1 -1
  37. package/dist/components/index.d.ts +1 -1
  38. package/dist/components/index.d.ts.map +1 -1
  39. package/dist/components/index.js +7 -6
  40. package/dist/composables/index.js +1 -1
  41. package/dist/handlers/index.js +2 -2
  42. package/dist/handlers/useChatKitLabels.d.ts +2 -0
  43. package/dist/handlers/useChatKitLabels.d.ts.map +1 -1
  44. package/dist/handlers/useMessageHandler.d.ts +37 -0
  45. package/dist/handlers/useMessageHandler.d.ts.map +1 -1
  46. package/dist/helpers/conversationTree.d.ts +15 -0
  47. package/dist/helpers/conversationTree.d.ts.map +1 -1
  48. package/dist/{index-C6njJ8EB.js → index-CyrVnbS9.js} +1 -1
  49. package/dist/index.d.ts +1 -1
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +11 -10
  52. package/dist/{useChatKitLabels-BM2I7-9o.js → useChatKitLabels-zMFV7aEz.js} +1 -0
  53. package/dist/{useMessageHandler-CMdy-lm0.js → useMessageHandler-N50Yj-NU.js} +45 -7
  54. package/package.json +3 -3
  55. package/src/adapters/responses-api.spec.ts +52 -5
  56. package/src/adapters/responses-api.ts +26 -7
  57. package/src/components/base/CkAgentMessage/types.ts +1 -1
  58. package/src/components/base/{CkCopyMessageActionButton/CkCopyMessageActionButton.spec.ts → CkCopyMessageButton/CkCopyMessageButton.spec.ts} +6 -6
  59. package/src/components/base/{CkCopyMessageActionButton/CkCopyMessageActionButton.stories.ts → CkCopyMessageButton/CkCopyMessageButton.stories.ts} +8 -8
  60. package/src/components/base/{CkCopyMessageActionButton/CkCopyMessageActionButton.vue → CkCopyMessageButton/CkCopyMessageButton.vue} +6 -6
  61. package/src/components/base/CkCopyMessageButton/index.ts +2 -0
  62. package/src/components/base/{CkCopyMessageActionButton → CkCopyMessageButton}/types.ts +3 -3
  63. package/src/components/base/CkFeedbackButton/CkFeedbackButton.vue +5 -5
  64. package/src/components/base/CkFeedbackButton/types.ts +1 -1
  65. package/src/components/base/CkFeedbackDialog/types.ts +1 -1
  66. package/src/components/base/{CkMessageActionButton/CkMessageActionButton.spec.ts → CkMessageButton/CkMessageButton.spec.ts} +6 -6
  67. package/src/components/base/{CkMessageActionButton/CkMessageActionButton.stories.ts → CkMessageButton/CkMessageButton.stories.ts} +10 -10
  68. package/src/components/base/{CkMessageActionButton/CkMessageActionButton.vue → CkMessageButton/CkMessageButton.vue} +3 -3
  69. package/src/components/base/CkMessageButton/index.ts +2 -0
  70. package/src/components/base/{CkMessageActionButton → CkMessageButton}/types.ts +2 -2
  71. package/src/components/base/CkRegenerateMessageButton/CkRegenerateMessageButton.spec.ts +49 -0
  72. package/src/components/base/CkRegenerateMessageButton/CkRegenerateMessageButton.stories.ts +63 -0
  73. package/src/components/base/CkRegenerateMessageButton/CkRegenerateMessageButton.vue +43 -0
  74. package/src/components/base/CkRegenerateMessageButton/index.ts +2 -0
  75. package/src/components/base/CkRegenerateMessageButton/types.ts +27 -0
  76. package/src/components/base/index.ts +3 -2
  77. package/src/components/composed/CkConversation/CkConversation.stories.ts +6 -6
  78. package/src/components/composed/CkConversation/CkConversation.vue +2 -2
  79. package/src/components/composed/CkConversation/types.ts +1 -1
  80. package/src/components/index.ts +3 -2
  81. package/src/handlers/useChatKitLabels.spec.ts +1 -0
  82. package/src/handlers/useChatKitLabels.ts +3 -0
  83. package/src/handlers/useMessageHandler.spec.ts +79 -0
  84. package/src/handlers/useMessageHandler.ts +50 -2
  85. package/src/helpers/conversationTree.spec.ts +120 -0
  86. package/src/helpers/conversationTree.ts +57 -8
  87. package/src/index.ts +3 -2
  88. package/dist/components/base/CkCopyMessageActionButton/CkCopyMessageActionButton.stories.d.ts.map +0 -1
  89. package/dist/components/base/CkCopyMessageActionButton/CkCopyMessageActionButton.vue.d.ts.map +0 -1
  90. package/dist/components/base/CkCopyMessageActionButton/index.d.ts +0 -3
  91. package/dist/components/base/CkCopyMessageActionButton/types.d.ts.map +0 -1
  92. package/dist/components/base/CkMessageActionButton/CkMessageActionButton.stories.d.ts.map +0 -1
  93. package/dist/components/base/CkMessageActionButton/CkMessageActionButton.vue.d.ts.map +0 -1
  94. package/dist/components/base/CkMessageActionButton/index.d.ts +0 -3
  95. package/dist/components/base/CkMessageActionButton/index.d.ts.map +0 -1
  96. package/dist/components/base/CkMessageActionButton/types.d.ts.map +0 -1
  97. package/src/components/base/CkCopyMessageActionButton/index.ts +0 -2
  98. package/src/components/base/CkMessageActionButton/index.ts +0 -2
@@ -0,0 +1,49 @@
1
+ import CkRegenerateMessageButton from './CkRegenerateMessageButton.vue';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { render, screen } from '@testing-library/vue';
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ describe('CkRegenerateMessageButton', () => {
7
+ it('renders an accessible button with the default label', () => {
8
+ render(CkRegenerateMessageButton);
9
+
10
+ expect(
11
+ screen.getByRole('button', { name: 'Regenerate response' }),
12
+ ).toBeInTheDocument();
13
+ });
14
+
15
+ it('uses a custom aria-label when provided', () => {
16
+ render(CkRegenerateMessageButton, {
17
+ props: { ariaLabel: 'Try again' },
18
+ });
19
+
20
+ expect(
21
+ screen.getByRole('button', { name: 'Try again' }),
22
+ ).toBeInTheDocument();
23
+ });
24
+
25
+ it('emits regenerate when clicked', async () => {
26
+ const user = userEvent.setup();
27
+ const { emitted } = render(CkRegenerateMessageButton);
28
+
29
+ await user.click(
30
+ screen.getByRole('button', { name: 'Regenerate response' }),
31
+ );
32
+
33
+ expect(emitted('regenerate')).toEqual([[]]);
34
+ });
35
+
36
+ it('does not emit regenerate when disabled', async () => {
37
+ const user = userEvent.setup();
38
+ const { emitted } = render(CkRegenerateMessageButton, {
39
+ props: { disabled: true },
40
+ });
41
+
42
+ const button = screen.getByRole('button', { name: 'Regenerate response' });
43
+ expect(button).toBeDisabled();
44
+
45
+ await user.click(button);
46
+
47
+ expect(emitted('regenerate')).toBeUndefined();
48
+ });
49
+ });
@@ -0,0 +1,63 @@
1
+ import CkRegenerateMessageButton from './CkRegenerateMessageButton.vue';
2
+ import type { Meta, StoryObj } from '@storybook/vue3-vite';
3
+
4
+ const meta: Meta<typeof CkRegenerateMessageButton> = {
5
+ title: 'Base/CkRegenerateMessageButton',
6
+ component: CkRegenerateMessageButton,
7
+ tags: ['autodocs'],
8
+ argTypes: {
9
+ disabled: {
10
+ control: 'boolean',
11
+ description: 'Whether the button is disabled and ignores clicks.',
12
+ },
13
+ ariaLabel: {
14
+ control: 'text',
15
+ description: 'Accessible label and tooltip content for the button.',
16
+ },
17
+ },
18
+ decorators: [
19
+ () => ({
20
+ template:
21
+ '<div style="max-width: 640px; margin: 0 auto; padding: 24px;"><story /></div>',
22
+ }),
23
+ ],
24
+ };
25
+
26
+ export default meta;
27
+ type Story = StoryObj<typeof meta>;
28
+
29
+ const defaultTemplateSource = `<script setup lang="ts">
30
+ import { CkRegenerateMessageButton } from '@aleph-alpha/chat-kit';
31
+ </script>
32
+
33
+ <template>
34
+ <CkRegenerateMessageButton @regenerate="() => {}" />
35
+ </template>`;
36
+
37
+ const disabledTemplateSource = `<script setup lang="ts">
38
+ import { CkRegenerateMessageButton } from '@aleph-alpha/chat-kit';
39
+ </script>
40
+
41
+ <template>
42
+ <CkRegenerateMessageButton disabled />
43
+ </template>`;
44
+
45
+ /**
46
+ * Default regenerate action. Clicking emits a `regenerate` event so the host
47
+ * can request a new response for the message.
48
+ */
49
+ export const Default: Story = {
50
+ args: {},
51
+ parameters: { docs: { source: { code: defaultTemplateSource } } },
52
+ };
53
+
54
+ /**
55
+ * Disabled state — clicks are ignored and the button appears inactive.
56
+ * Use while a message is still streaming or in an error state.
57
+ */
58
+ export const Disabled: Story = {
59
+ args: {
60
+ disabled: true,
61
+ },
62
+ parameters: { docs: { source: { code: disabledTemplateSource } } },
63
+ };
@@ -0,0 +1,43 @@
1
+ <script setup lang="ts">
2
+ import { CkMessageButton } from '../CkMessageButton';
3
+ import type { CkRegenerateMessageButtonProps } from './types';
4
+ import { UiIcon, UiTooltip } from '@aleph-alpha/ui-library';
5
+
6
+ defineOptions({
7
+ name: 'CkRegenerateMessageButton',
8
+ });
9
+
10
+ const props = withDefaults(defineProps<CkRegenerateMessageButtonProps>(), {
11
+ disabled: false,
12
+ ariaLabel: 'Regenerate response',
13
+ });
14
+
15
+ const emit = defineEmits<{
16
+ regenerate: [];
17
+ }>();
18
+
19
+ function handleClick() {
20
+ if (props.disabled) {
21
+ return;
22
+ }
23
+
24
+ emit('regenerate');
25
+ }
26
+ </script>
27
+
28
+ <template>
29
+ <UiTooltip
30
+ :content="ariaLabel"
31
+ side="bottom"
32
+ align="center"
33
+ :delay-duration="0"
34
+ >
35
+ <CkMessageButton
36
+ v-bind="{ ariaLabel }"
37
+ :disabled="disabled"
38
+ @click="handleClick"
39
+ >
40
+ <UiIcon name="repeat" :size="16" aria-hidden="true" />
41
+ </CkMessageButton>
42
+ </UiTooltip>
43
+ </template>
@@ -0,0 +1,2 @@
1
+ export { default as CkRegenerateMessageButton } from './CkRegenerateMessageButton.vue';
2
+ export type * from './types';
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Regenerate action button for chat messages. Lets the user request a new
3
+ * response for an assistant message. Renders an icon-only button with an
4
+ * accessible label and tooltip.
5
+ *
6
+ * @category Actions
7
+ * @useCases regenerate assistant reply, retry message, request new response, chat message action
8
+ * @keywords regenerate, retry, repeat, action, message, button, icon
9
+ * @related CkMessageButton, CkCopyMessageButton, CkAgentMessage, CkConversation
10
+ */
11
+ export interface CkRegenerateMessageButtonProps {
12
+ /**
13
+ * Whether the button is disabled and ignores clicks.
14
+ * @default false
15
+ */
16
+ disabled?: boolean;
17
+ /**
18
+ * Accessible label and tooltip content for the button.
19
+ * @default 'Regenerate response'
20
+ */
21
+ ariaLabel?: string;
22
+ }
23
+
24
+ export interface CkRegenerateMessageButtonEmits {
25
+ /** Emitted when the button is pressed and not disabled. */
26
+ (e: 'regenerate'): void;
27
+ }
@@ -13,7 +13,7 @@ export type {
13
13
  } from './CkCitationCard';
14
14
  export { CkConversationLayout } from './CkConversationLayout';
15
15
  export type { CkConversationLayoutProps } from './CkConversationLayout';
16
- export { CkCopyMessageActionButton } from './CkCopyMessageActionButton';
16
+ export { CkCopyMessageButton } from './CkCopyMessageButton';
17
17
  export { CkDropZoneOverlay } from './CkDropZoneOverlay';
18
18
  export type {
19
19
  CkDropZoneOverlayProps,
@@ -50,7 +50,7 @@ export type {
50
50
  CkMarkdownRendererEmits,
51
51
  CkMarkdownElementClasses,
52
52
  } from './CkMarkdownRenderer';
53
- export { CkMessageActionButton } from './CkMessageActionButton';
53
+ export { CkMessageButton } from './CkMessageButton';
54
54
  export { CkModelSelector } from './CkModelSelector';
55
55
  export type {
56
56
  CkModelOption,
@@ -63,5 +63,6 @@ export type { CkPromptMenuButtonProps } from './CkPromptMenuButton';
63
63
  export { CkPromptStopButton } from './CkPromptStopButton';
64
64
  export { CkPromptInput } from './CkPromptInput';
65
65
  export { CkPromptInputDisclaimer } from './CkPromptInputDisclaimer';
66
+ export { CkRegenerateMessageButton } from './CkRegenerateMessageButton';
66
67
  export { CkUserMessage } from './CkUserMessage';
67
68
  export type { CkUserMessageProps } from './CkUserMessage';
@@ -1,4 +1,4 @@
1
- import { CkCopyMessageActionButton } from '../../base/CkCopyMessageActionButton';
1
+ import { CkCopyMessageButton } from '../../base/CkCopyMessageButton';
2
2
  import { CkFeedbackButton } from '../../base/CkFeedbackButton';
3
3
  import type { CkFeedbackValue } from '../../base/CkFeedbackButton';
4
4
  import type {
@@ -292,7 +292,7 @@ const withFooterActionsTemplateSource = `<script setup lang="ts">
292
292
  import { ref } from 'vue';
293
293
  import {
294
294
  CkConversation,
295
- CkCopyMessageActionButton,
295
+ CkCopyMessageButton,
296
296
  CkFeedbackButton,
297
297
  } from '@aleph-alpha/chat-kit';
298
298
  import type { BaseMessage, CkFeedbackValue } from '@aleph-alpha/chat-kit';
@@ -315,7 +315,7 @@ function onFeedback(messageId: string, value: CkFeedbackValue) {
315
315
  <template>
316
316
  <CkConversation :messages="messages" agent-avatar-fallback="AI" style="height: 100%;">
317
317
  <template #agent-message-footer="{ message }">
318
- <CkCopyMessageActionButton :text="getMessageText(message)" />
318
+ <CkCopyMessageButton :text="getMessageText(message)" />
319
319
  <CkFeedbackButton
320
320
  :value="feedback[message.id]?.value ?? null"
321
321
  :locked="feedback[message.id]?.locked ?? false"
@@ -590,7 +590,7 @@ export const WithCustomIcon: Story = {
590
590
 
591
591
  /**
592
592
  * Overrides `#agent-message-footer` to add real action buttons next to each
593
- * assistant reply: `CkCopyMessageActionButton` for clipboard copy and
593
+ * assistant reply: `CkCopyMessageButton` for clipboard copy and
594
594
  * `CkFeedbackButton` for binary thumbs up / thumbs down feedback. The
595
595
  * feedback state is owned by the host and locked once submitted.
596
596
  */
@@ -598,7 +598,7 @@ export const WithFooterActions: Story = {
598
598
  render: () => ({
599
599
  components: {
600
600
  CkConversation,
601
- CkCopyMessageActionButton,
601
+ CkCopyMessageButton,
602
602
  CkFeedbackButton,
603
603
  },
604
604
  setup() {
@@ -618,7 +618,7 @@ export const WithFooterActions: Story = {
618
618
  template: `
619
619
  <CkConversation :messages="messages" agent-avatar-fallback="AI" style="height: 100%;">
620
620
  <template #agent-message-footer="{ message }">
621
- <CkCopyMessageActionButton :text="getMessageText(message)" />
621
+ <CkCopyMessageButton :text="getMessageText(message)" />
622
622
  <CkFeedbackButton
623
623
  :value="feedback[message.id]?.value ?? null"
624
624
  :locked="feedback[message.id]?.locked ?? false"
@@ -3,7 +3,7 @@ import { useChatKitLabels } from '../../../handlers/useChatKitLabels';
3
3
  import type { BaseMessage, MessageTextContent } from '../../../types';
4
4
  import CkAgentMessage from '../../base/CkAgentMessage/CkAgentMessage.vue';
5
5
  import CkConversationLayout from '../../base/CkConversationLayout/CkConversationLayout.vue';
6
- import CkCopyMessageActionButton from '../../base/CkCopyMessageActionButton/CkCopyMessageActionButton.vue';
6
+ import CkCopyMessageButton from '../../base/CkCopyMessageButton/CkCopyMessageButton.vue';
7
7
  import CkMarkdownRenderer from '../../base/CkMarkdownRenderer/CkMarkdownRenderer.vue';
8
8
  import CkUserMessage from '../../base/CkUserMessage/CkUserMessage.vue';
9
9
  import type { CkConversationProps } from './types';
@@ -143,7 +143,7 @@ function getMessageText(message: BaseMessage): string {
143
143
 
144
144
  <template #footer>
145
145
  <slot name="agent-message-footer" :message="message">
146
- <CkCopyMessageActionButton
146
+ <CkCopyMessageButton
147
147
  v-if="getMessageText(message) && !isLoading(message)"
148
148
  :text="getMessageText(message)"
149
149
  :aria-label-copy="labels.copyMessageAction"
@@ -10,7 +10,7 @@ import type { CkMarkdownElementClasses } from '../../base/CkMarkdownRenderer';
10
10
  * @category Messages
11
11
  * @useCases complete chat conversation ui, streaming chat with markdown, assistant chat view, llm conversation component, drop-in chat surface
12
12
  * @keywords conversation, chat, messages, assistant, agent, user, markdown, streaming, avatar
13
- * @related CkConversationLayout, CkAgentMessage, CkUserMessage, CkMarkdownRenderer, CkCopyMessageActionButton, CkInput
13
+ * @related CkConversationLayout, CkAgentMessage, CkUserMessage, CkMarkdownRenderer, CkCopyMessageButton, CkInput
14
14
  */
15
15
  export interface CkConversationProps {
16
16
  /** List of messages to display, in chronological order. */
@@ -4,7 +4,7 @@ export {
4
4
  CkCitation,
5
5
  CkCitationCard,
6
6
  CkConversationLayout,
7
- CkCopyMessageActionButton,
7
+ CkCopyMessageButton,
8
8
  CkDropZoneOverlay,
9
9
  CkFeedbackButton,
10
10
  CkFeedbackDialog,
@@ -12,13 +12,14 @@ export {
12
12
  CkHistoryItem,
13
13
  CkHistoryWrapper,
14
14
  CkMarkdownRenderer,
15
- CkMessageActionButton,
15
+ CkMessageButton,
16
16
  CkModelSelector,
17
17
  CkPromptSendButton,
18
18
  CkPromptMenuButton,
19
19
  CkPromptStopButton,
20
20
  CkPromptInput,
21
21
  CkPromptInputDisclaimer,
22
+ CkRegenerateMessageButton,
22
23
  CkSendIcon,
23
24
  CkStopIcon,
24
25
  CkUserMessage,
@@ -102,6 +102,7 @@ describe('useChatKitLabels', () => {
102
102
  inputPlaceholder: 'Custom placeholder',
103
103
  copyMessageAction: 'Custom copy',
104
104
  copyMessageActionCopied: 'Custom copied',
105
+ regenerateMessageAction: 'Custom regenerate',
105
106
  feedbackThumbsUp: 'Custom up',
106
107
  feedbackThumbsDown: 'Custom down',
107
108
  feedbackGroup: 'Custom group',
@@ -29,6 +29,8 @@ export type ChatKitLabels = {
29
29
  copyMessageAction: string;
30
30
  /** Accessible label / tooltip for the copy-message action button after a successful copy */
31
31
  copyMessageActionCopied: string;
32
+ /** Accessible label / tooltip for the regenerate-message action button */
33
+ regenerateMessageAction: string;
32
34
  /**
33
35
  * Accessible label / tooltip for the thumbs-up button in `CkFeedbackButton`.
34
36
  * Optional for backward compatibility; falls back to
@@ -114,6 +116,7 @@ export const defaultChatKitLabels: ChatKitLabels = {
114
116
  inputPlaceholder: 'Type a message...',
115
117
  copyMessageAction: 'Copy response',
116
118
  copyMessageActionCopied: 'Copied!',
119
+ regenerateMessageAction: 'Regenerate response',
117
120
  feedbackThumbsUp: 'Good response',
118
121
  feedbackThumbsDown: 'Bad response',
119
122
  feedbackGroup: 'Rate this response',
@@ -146,6 +146,36 @@ describe('useMessageHandler', () => {
146
146
  expect('metadata' in msg).toBe(false);
147
147
  expect('metadata' in handler.messages.value[0]).toBe(false);
148
148
  });
149
+
150
+ it('branches off an explicit parentId, updating the parent and activating the new branch', () => {
151
+ const handler = useMessageHandler();
152
+
153
+ handler.setCurrentUserMessage('First');
154
+ const first = handler.commitUserMessage();
155
+ handler.setCurrentUserMessage('Second');
156
+ const second = handler.commitUserMessage();
157
+
158
+ // Branch a sibling of `second` off their shared parent `first`.
159
+ handler.setCurrentUserMessage('Sibling');
160
+ const sibling = handler.commitUserMessage({ parentId: first.id });
161
+
162
+ const parent = handler.getTree().nodes[first.id];
163
+ expect(parent.childrenIds).toEqual([second.id, sibling.id]);
164
+ // The freshly created branch becomes the active path.
165
+ expect(parent.activeChildIndex).toBe(1);
166
+ expect(handler.messages.value.map((m) => m.id)).toEqual([
167
+ first.id,
168
+ sibling.id,
169
+ ]);
170
+ });
171
+
172
+ it('throws when branching off an unknown parentId', () => {
173
+ const handler = useMessageHandler();
174
+ handler.setCurrentUserMessage('Hello');
175
+ expect(() =>
176
+ handler.commitUserMessage({ parentId: 'does-not-exist' }),
177
+ ).toThrow('Unknown parent message id: does-not-exist');
178
+ });
149
179
  });
150
180
 
151
181
  describe('updateMessage', () => {
@@ -342,6 +372,55 @@ describe('useMessageHandler', () => {
342
372
  expect(handler.messages.value).toHaveLength(2);
343
373
  expect(getText(handler.messages.value[1].content)).toBe('Streaming...');
344
374
  });
375
+
376
+ it('branches a new node off an explicit parentId on create', () => {
377
+ const handler = useMessageHandler();
378
+ handler.setTree(
379
+ makeTree(
380
+ {
381
+ u1: makeTreeMessage({ id: 'u1', content: [text('Hello')] }),
382
+ a1: makeTreeMessage({
383
+ id: 'a1',
384
+ parentId: 'u1',
385
+ role: 'assistant',
386
+ content: [text('Hi')],
387
+ }),
388
+ },
389
+ 'u1',
390
+ ),
391
+ );
392
+
393
+ // Regenerate: add a sibling assistant answer under the same user turn.
394
+ handler.updateMessage({
395
+ id: 'a2',
396
+ content: [text('Hi again')],
397
+ role: 'assistant',
398
+ status: 'completed',
399
+ parentId: 'u1',
400
+ });
401
+
402
+ const parent = handler.getTree().nodes.u1;
403
+ expect(parent.childrenIds).toEqual(['a1', 'a2']);
404
+ expect(parent.activeChildIndex).toBe(1);
405
+ expect(handler.messages.value.map((m) => m.id)).toEqual(['u1', 'a2']);
406
+ });
407
+
408
+ it('ignores parentId when the node already exists', () => {
409
+ const handler = useMessageHandler();
410
+ handler.setCurrentUserMessage('Hello');
411
+ const msg = handler.commitUserMessage();
412
+
413
+ handler.updateMessage({
414
+ id: msg.id,
415
+ content: [text('Updated')],
416
+ role: 'user',
417
+ status: 'completed',
418
+ parentId: 'does-not-exist',
419
+ });
420
+
421
+ expect(getText(handler.getMessageById(msg.id)!.content)).toBe('Updated');
422
+ expect(handler.getTree().nodes[msg.id].parentId).toBeNull();
423
+ });
345
424
  });
346
425
 
347
426
  describe('getMessageById', () => {
@@ -3,6 +3,7 @@ import {
3
3
  addNode as treeAddNode,
4
4
  createEmptyTree,
5
5
  cutBranchAfter as treeCutBranchAfter,
6
+ cutBranchAt as treeCutBranchAt,
6
7
  getLinearPath,
7
8
  navigate as treeNavigate,
8
9
  updateNode,
@@ -30,6 +31,19 @@ export const useMessageHandler = () => {
30
31
  return list.length > 0 ? list[list.length - 1].id : null;
31
32
  });
32
33
 
34
+ /**
35
+ * Resolve the parent a new node should hang off. When an explicit
36
+ * `parentId` is given it must reference an existing node; otherwise the
37
+ * current leaf is used so messages append to the active branch.
38
+ */
39
+ function resolveParentId(parentId?: string): string | null {
40
+ if (parentId === undefined) return currentLeafId.value;
41
+ if (!tree.nodes[parentId]) {
42
+ throw new Error(`Unknown parent message id: ${parentId}`);
43
+ }
44
+ return parentId;
45
+ }
46
+
33
47
  function setCurrentUserMessage(text: string) {
34
48
  currentUserMessage.value = text;
35
49
  }
@@ -38,20 +52,31 @@ export const useMessageHandler = () => {
38
52
  currentUserMessage.value = '';
39
53
  }
40
54
 
55
+ /**
56
+ * Commit the pending user message as a new node.
57
+ *
58
+ * By default the node is appended to the current leaf. Pass `parentId` to
59
+ * branch off an arbitrary message instead: the new node becomes a sibling
60
+ * of that parent's existing children, the parent's `childrenIds` gains the
61
+ * new id, and the branch is made active.
62
+ */
41
63
  function commitUserMessage(opts?: {
42
64
  metadata?: MessageMetadata;
65
+ parentId?: string;
43
66
  }): BaseMessage {
44
67
  const id = generateId();
45
68
  const text = currentUserMessage.value.trim();
46
69
  if (!text) throw new Error('No message');
47
70
 
71
+ const parentId = resolveParentId(opts?.parentId);
72
+
48
73
  const content: MessageContent[] = [
49
74
  { type: 'text', id: generateId(), text, status: 'completed' },
50
75
  ];
51
76
 
52
77
  const node: TreeMessage = {
53
78
  id,
54
- parentId: currentLeafId.value,
79
+ parentId,
55
80
  childrenIds: [],
56
81
  activeChildIndex: 0,
57
82
  role: 'user',
@@ -75,12 +100,20 @@ export const useMessageHandler = () => {
75
100
  return base;
76
101
  }
77
102
 
103
+ /**
104
+ * Upsert a message by id. When the node does not yet exist it is created;
105
+ * pass `parentId` to branch the new node off an arbitrary message instead
106
+ * of the current leaf (the parent's `childrenIds` is updated and the new
107
+ * branch is made active). `parentId` is ignored when the node already
108
+ * exists.
109
+ */
78
110
  function updateMessage(message: {
79
111
  id: string;
80
112
  content: MessageContent[];
81
113
  role: MessageRole;
82
114
  status: MessageStatus;
83
115
  metadata?: MessageMetadata;
116
+ parentId?: string;
84
117
  }) {
85
118
  const existing = tree.nodes[message.id];
86
119
 
@@ -96,7 +129,7 @@ export const useMessageHandler = () => {
96
129
  } else {
97
130
  const node: TreeMessage = {
98
131
  id: message.id,
99
- parentId: currentLeafId.value,
132
+ parentId: resolveParentId(message.parentId),
100
133
  childrenIds: [],
101
134
  activeChildIndex: 0,
102
135
  role: message.role,
@@ -159,6 +192,19 @@ export const useMessageHandler = () => {
159
192
  treeCutBranchAfter(tree, messageId);
160
193
  }
161
194
 
195
+ /**
196
+ * Drop the message with `messageId` and its whole subtree, leaving sibling
197
+ * branches intact (the parent's active branch is clamped to a survivor).
198
+ * Use to discard one answer among several without cropping the others.
199
+ */
200
+ function cutBranchAt(messageId: string) {
201
+ treeCutBranchAt(tree, messageId);
202
+ }
203
+
204
+ function getTree() {
205
+ return readonly(tree);
206
+ }
207
+
162
208
  return {
163
209
  messages: messages as Ref<BaseMessage[]>,
164
210
  currentUserMessage: readonly(currentUserMessage),
@@ -173,5 +219,7 @@ export const useMessageHandler = () => {
173
219
  setTree,
174
220
  navigate,
175
221
  cutBranchAfter,
222
+ cutBranchAt,
223
+ getTree,
176
224
  };
177
225
  };