@aleph-alpha/chat-kit 1.0.0 → 1.1.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-744ZHsQU.js → CkInput.vue_vue_type_script_setup_true_lang-DpYyyCgU.js} +23 -68
- package/dist/components/base/CkHistoryItem/CkHistoryItem.vue.d.ts +3 -4
- package/dist/components/base/CkHistoryItem/CkHistoryItem.vue.d.ts.map +1 -1
- package/dist/components/base/CkHistoryItem/types.d.ts +3 -2
- package/dist/components/base/CkHistoryItem/types.d.ts.map +1 -1
- package/dist/components/base/CkPromptStopButton/CkPromptStopButton.vue.d.ts.map +1 -1
- package/dist/components/index.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +5 -5
- package/src/components/base/CkAgentMessage/CkAgentMessage.spec.ts +91 -0
- package/src/components/base/CkAgentMessage/CkAgentMessage.stories.ts +215 -0
- package/src/components/base/CkAgentMessage/CkAgentMessage.vue +25 -0
- package/src/components/base/CkAgentMessage/index.ts +2 -0
- package/src/components/base/CkAgentMessage/types.ts +11 -0
- package/src/components/base/CkConversationLayout/CkConversationLayout.spec.ts +129 -0
- package/src/components/base/CkConversationLayout/CkConversationLayout.stories.ts +251 -0
- package/src/components/base/CkConversationLayout/CkConversationLayout.vue +206 -0
- package/src/components/base/CkConversationLayout/index.ts +2 -0
- package/src/components/base/CkConversationLayout/types.ts +18 -0
- package/src/components/base/CkCopyMessageActionButton/CkCopyMessageActionButton.spec.ts +89 -0
- package/src/components/base/CkCopyMessageActionButton/CkCopyMessageActionButton.stories.ts +70 -0
- package/src/components/base/CkCopyMessageActionButton/CkCopyMessageActionButton.vue +110 -0
- package/src/components/base/CkCopyMessageActionButton/index.ts +2 -0
- package/src/components/base/CkCopyMessageActionButton/types.ts +43 -0
- package/src/components/base/CkHistoryItem/CkHistoryItem.spec.ts +42 -0
- package/src/components/base/CkHistoryItem/CkHistoryItem.stories.ts +230 -0
- package/src/components/base/CkHistoryItem/CkHistoryItem.vue +285 -0
- package/src/components/base/CkHistoryItem/activeIndicator.ts +15 -0
- package/src/components/base/CkHistoryItem/index.ts +2 -0
- package/src/components/base/CkHistoryItem/types.ts +111 -0
- package/src/components/base/CkHistoryWrapper/CkHistoryWrapper.spec.ts +26 -0
- package/src/components/base/CkHistoryWrapper/CkHistoryWrapper.stories.ts +149 -0
- package/src/components/base/CkHistoryWrapper/CkHistoryWrapper.vue +36 -0
- package/src/components/base/CkHistoryWrapper/index.ts +2 -0
- package/src/components/base/CkHistoryWrapper/types.ts +16 -0
- package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.spec.ts +348 -0
- package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.ts +382 -0
- package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.vue +89 -0
- package/src/components/base/CkMarkdownRenderer/defaultMarkdownClasses.ts +19 -0
- package/src/components/base/CkMarkdownRenderer/index.ts +5 -0
- package/src/components/base/CkMarkdownRenderer/types.ts +53 -0
- package/src/components/base/CkMessageActionButton/CkMessageActionButton.spec.ts +40 -0
- package/src/components/base/CkMessageActionButton/CkMessageActionButton.stories.ts +56 -0
- package/src/components/base/CkMessageActionButton/CkMessageActionButton.vue +23 -0
- package/src/components/base/CkMessageActionButton/index.ts +2 -0
- package/src/components/base/CkMessageActionButton/types.ts +21 -0
- package/src/components/base/CkPromptActionButton/CkPromptActionButton.spec.ts +52 -0
- package/src/components/base/CkPromptActionButton/CkPromptActionButton.stories.ts +126 -0
- package/src/components/base/CkPromptActionButton/CkPromptActionButton.vue +25 -0
- package/src/components/base/CkPromptActionButton/index.ts +2 -0
- package/src/components/base/CkPromptActionButton/types.ts +32 -0
- package/src/components/base/CkPromptInput/CkPromptInput.spec.ts +181 -0
- package/src/components/base/CkPromptInput/CkPromptInput.stories.ts +224 -0
- package/src/components/base/CkPromptInput/CkPromptInput.vue +104 -0
- package/src/components/base/CkPromptInput/index.ts +2 -0
- package/src/components/base/CkPromptInput/types.ts +39 -0
- package/src/components/base/CkPromptInputDisclaimer/CkPromptInputDisclaimer.spec.ts +40 -0
- package/src/components/base/CkPromptInputDisclaimer/CkPromptInputDisclaimer.stories.ts +103 -0
- package/src/components/base/CkPromptInputDisclaimer/CkPromptInputDisclaimer.vue +16 -0
- package/src/components/base/CkPromptInputDisclaimer/index.ts +2 -0
- package/src/components/base/CkPromptInputDisclaimer/types.ts +17 -0
- package/src/components/base/CkPromptStopButton/CkPromptStopButton.spec.ts +64 -0
- package/src/components/base/CkPromptStopButton/CkPromptStopButton.stories.ts +81 -0
- package/src/components/base/CkPromptStopButton/CkPromptStopButton.vue +27 -0
- package/src/components/base/CkPromptStopButton/index.ts +2 -0
- package/src/components/base/CkPromptStopButton/types.ts +28 -0
- package/src/components/base/CkUserMessage/CkUserMessage.spec.ts +20 -0
- package/src/components/base/CkUserMessage/CkUserMessage.stories.ts +112 -0
- package/src/components/base/CkUserMessage/CkUserMessage.vue +19 -0
- package/src/components/base/CkUserMessage/index.ts +2 -0
- package/src/components/base/CkUserMessage/types.ts +14 -0
- package/src/components/base/icons/CkSendIcon.vue +22 -0
- package/src/components/base/icons/CkStopIcon.vue +18 -0
- package/src/components/base/icons/index.ts +2 -0
- package/src/components/base/index.ts +21 -0
- package/src/components/composed/CkConversation/CkConversation.spec.ts +439 -0
- package/src/components/composed/CkConversation/CkConversation.stories.ts +642 -0
- package/src/components/composed/CkConversation/CkConversation.vue +146 -0
- package/src/components/composed/CkConversation/index.ts +2 -0
- package/src/components/composed/CkConversation/types.ts +37 -0
- package/src/components/composed/CkHistory/CkHistory.spec.ts +327 -0
- package/src/components/composed/CkHistory/CkHistory.stories.ts +373 -0
- package/src/components/composed/CkHistory/CkHistory.vue +126 -0
- package/src/components/composed/CkHistory/index.ts +2 -0
- package/src/components/composed/CkHistory/types.ts +72 -0
- package/src/components/composed/CkInput/CkInput.spec.ts +191 -0
- package/src/components/composed/CkInput/CkInput.stories.ts +438 -0
- package/src/components/composed/CkInput/CkInput.vue +104 -0
- package/src/components/composed/CkInput/index.ts +2 -0
- package/src/components/composed/CkInput/types.ts +60 -0
- package/src/components/composed/index.ts +5 -0
- package/src/components/index.ts +34 -0
- package/src/composables/index.ts +9 -0
- package/src/composables/types.ts +36 -0
- package/src/composables/useChat.ts +18 -0
- package/src/composables/useConversations.spec.ts +557 -0
- package/src/composables/useConversations.ts +321 -0
- package/src/handlers/index.ts +9 -0
- package/src/handlers/useChatKitLabels.spec.ts +290 -0
- package/src/handlers/useChatKitLabels.ts +96 -0
- package/src/handlers/useMarkdownParser.spec.ts +112 -0
- package/src/handlers/useMarkdownParser.ts +47 -0
- package/src/handlers/useMessageHandler.spec.ts +535 -0
- package/src/handlers/useMessageHandler.ts +138 -0
- package/src/helpers/conversationTree.spec.ts +423 -0
- package/src/helpers/conversationTree.ts +117 -0
- package/src/helpers/generateId.ts +5 -0
- package/src/helpers/index.ts +4 -0
- package/src/helpers/secondsToDate.ts +7 -0
- package/src/helpers/toError.ts +8 -0
- package/src/index.ts +79 -0
- package/src/services/ChatService.ts +60 -0
- package/src/services/MockChatService.ts +47 -0
- package/src/services/index.ts +12 -0
- package/src/services/types.ts +92 -0
- package/src/test-setup.ts +12 -0
- package/src/test-utils/createTextPlugin.ts +9 -0
- package/src/types.ts +47 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import CkConversationLayout from './CkConversationLayout.vue';
|
|
2
|
+
import { render, screen } from '@testing-library/vue';
|
|
3
|
+
import { type Component, defineComponent, h } from 'vue';
|
|
4
|
+
|
|
5
|
+
const messages = [
|
|
6
|
+
{ role: 'user', content: 'Hello there' },
|
|
7
|
+
{ role: 'agent', content: 'Hi! How can I help?' },
|
|
8
|
+
{ role: 'user', content: 'Tell me a joke' },
|
|
9
|
+
{ role: 'agent', content: 'Why did the chicken cross the road?' },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Helper to render CkConversationLayout with standard slot markup.
|
|
14
|
+
* Each message is rendered as a <p> with its content as text and a test-id
|
|
15
|
+
* indicating the slot it was routed through (user-msg or agent-msg).
|
|
16
|
+
*/
|
|
17
|
+
function renderLayout(
|
|
18
|
+
msgs: Array<{ role: string; content: string }> = messages,
|
|
19
|
+
) {
|
|
20
|
+
// eslint-disable-next-line vue/one-component-per-file
|
|
21
|
+
const Wrapper = defineComponent({
|
|
22
|
+
setup() {
|
|
23
|
+
return () =>
|
|
24
|
+
h(
|
|
25
|
+
CkConversationLayout as unknown as Component,
|
|
26
|
+
{ messages: msgs },
|
|
27
|
+
{
|
|
28
|
+
'user-message': ({ message }: { message: (typeof msgs)[0] }) =>
|
|
29
|
+
h('p', { 'data-testid': 'user-msg' }, message.content),
|
|
30
|
+
'agent-message': ({ message }: { message: (typeof msgs)[0] }) =>
|
|
31
|
+
h('p', { 'data-testid': 'agent-msg' }, message.content),
|
|
32
|
+
},
|
|
33
|
+
);
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return render(Wrapper);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('CkConversationLayout', () => {
|
|
41
|
+
describe('message display', () => {
|
|
42
|
+
it('displays all provided messages', () => {
|
|
43
|
+
renderLayout();
|
|
44
|
+
|
|
45
|
+
for (const msg of messages) {
|
|
46
|
+
expect(screen.getByText(msg.content)).toBeInTheDocument();
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('shows no messages when the conversation is empty', () => {
|
|
51
|
+
renderLayout([]);
|
|
52
|
+
|
|
53
|
+
expect(screen.queryAllByTestId('user-msg')).toHaveLength(0);
|
|
54
|
+
expect(screen.queryAllByTestId('agent-msg')).toHaveLength(0);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('message routing', () => {
|
|
59
|
+
it('renders user messages through the user-message slot', () => {
|
|
60
|
+
renderLayout();
|
|
61
|
+
|
|
62
|
+
const userMessages = screen.getAllByTestId('user-msg');
|
|
63
|
+
expect(userMessages).toHaveLength(2);
|
|
64
|
+
expect(userMessages[0]).toHaveTextContent('Hello there');
|
|
65
|
+
expect(userMessages[1]).toHaveTextContent('Tell me a joke');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('renders agent messages through the agent-message slot', () => {
|
|
69
|
+
renderLayout();
|
|
70
|
+
|
|
71
|
+
const agentMessages = screen.getAllByTestId('agent-msg');
|
|
72
|
+
expect(agentMessages).toHaveLength(2);
|
|
73
|
+
expect(agentMessages[0]).toHaveTextContent('Hi! How can I help?');
|
|
74
|
+
expect(agentMessages[1]).toHaveTextContent(
|
|
75
|
+
'Why did the chicken cross the road?',
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('message order', () => {
|
|
81
|
+
it('preserves the order of messages from the input array', () => {
|
|
82
|
+
renderLayout();
|
|
83
|
+
|
|
84
|
+
const allMessages = screen.getAllByTestId(/msg$/);
|
|
85
|
+
expect(allMessages).toHaveLength(4);
|
|
86
|
+
expect(allMessages[0]).toHaveTextContent('Hello there');
|
|
87
|
+
expect(allMessages[1]).toHaveTextContent('Hi! How can I help?');
|
|
88
|
+
expect(allMessages[2]).toHaveTextContent('Tell me a joke');
|
|
89
|
+
expect(allMessages[3]).toHaveTextContent(
|
|
90
|
+
'Why did the chicken cross the road?',
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('slot data', () => {
|
|
96
|
+
const customMessages = [
|
|
97
|
+
{ role: 'user', content: 'Hi', id: '42', timestamp: '2026-02-13' },
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
function renderCustomSlotWrapper() {
|
|
101
|
+
return h(
|
|
102
|
+
CkConversationLayout as unknown as Component,
|
|
103
|
+
{ messages: customMessages },
|
|
104
|
+
{
|
|
105
|
+
'user-message': ({
|
|
106
|
+
message,
|
|
107
|
+
}: {
|
|
108
|
+
message: (typeof customMessages)[0];
|
|
109
|
+
}) =>
|
|
110
|
+
h(
|
|
111
|
+
'p',
|
|
112
|
+
null,
|
|
113
|
+
`${message.id}|${message.timestamp}|${message.content}`,
|
|
114
|
+
),
|
|
115
|
+
'agent-message': () => null,
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
it('provides the full message object including custom fields to scoped slots', () => {
|
|
121
|
+
// eslint-disable-next-line vue/one-component-per-file
|
|
122
|
+
const Wrapper = defineComponent({ setup: () => renderCustomSlotWrapper });
|
|
123
|
+
|
|
124
|
+
render(Wrapper);
|
|
125
|
+
|
|
126
|
+
expect(screen.getByText('42|2026-02-13|Hi')).toBeInTheDocument();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import CkConversationLayoutComponent from './CkConversationLayout.vue';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/vue3-vite';
|
|
3
|
+
import type { DefineComponent } from 'vue';
|
|
4
|
+
import { ref } from 'vue';
|
|
5
|
+
|
|
6
|
+
// Generic Vue components need a type assertion for Storybook compatibility
|
|
7
|
+
const CkConversationLayout =
|
|
8
|
+
CkConversationLayoutComponent as unknown as DefineComponent;
|
|
9
|
+
|
|
10
|
+
const sampleMessages = [
|
|
11
|
+
{ role: 'user', content: 'Hello! Can you help me with something?' },
|
|
12
|
+
{
|
|
13
|
+
role: 'agent',
|
|
14
|
+
content: 'Of course! I am here to help. What would you like to know?',
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
role: 'user',
|
|
18
|
+
content:
|
|
19
|
+
"Find the lessons learned and best practices from the 'Digital Transformation Phase 1' project.",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
role: 'agent',
|
|
23
|
+
content:
|
|
24
|
+
'Based on a review of internal documents, here are the key lessons learned and best practices.',
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const meta: Meta<typeof CkConversationLayout> = {
|
|
29
|
+
title: 'Base/CkConversationLayout',
|
|
30
|
+
component: CkConversationLayout,
|
|
31
|
+
tags: ['autodocs'],
|
|
32
|
+
decorators: [
|
|
33
|
+
() => ({
|
|
34
|
+
template:
|
|
35
|
+
'<div style="height: 400px; border: 1px solid #e0e0e0; border-radius: 8px; overflow: hidden;"><story /></div>',
|
|
36
|
+
}),
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export default meta;
|
|
41
|
+
type Story = StoryObj<typeof meta>;
|
|
42
|
+
|
|
43
|
+
const defaultTemplateSource = `<script setup lang="ts">
|
|
44
|
+
import { CkConversationLayout } from '@aleph-alpha/chat-kit';
|
|
45
|
+
|
|
46
|
+
interface Message { role: string; content: string }
|
|
47
|
+
const messages: Message[] = [
|
|
48
|
+
{ role: 'user', content: 'Hello! Can you help me with something?' },
|
|
49
|
+
{ role: 'agent', content: 'Of course! What would you like to know?' },
|
|
50
|
+
];
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<template>
|
|
54
|
+
<CkConversationLayout :messages="messages" style="height: 400px;">
|
|
55
|
+
<template #user-message="{ message }">
|
|
56
|
+
<div class="bg-core-bg-secondary rounded-md px-3 py-2 max-w-[400px]">{{ message.content }}</div>
|
|
57
|
+
</template>
|
|
58
|
+
<template #agent-message="{ message }">
|
|
59
|
+
<div class="px-3 py-2 max-w-[400px]">{{ message.content }}</div>
|
|
60
|
+
</template>
|
|
61
|
+
</CkConversationLayout>
|
|
62
|
+
</template>`;
|
|
63
|
+
|
|
64
|
+
const streamingSimulationTemplateSource = `<script setup lang="ts">
|
|
65
|
+
import { CkConversationLayout } from '@aleph-alpha/chat-kit';
|
|
66
|
+
import { ref } from 'vue';
|
|
67
|
+
|
|
68
|
+
interface Message { role: string; content: string }
|
|
69
|
+
const messages = ref<Message[]>([]);
|
|
70
|
+
|
|
71
|
+
function streamAgentReply(): void {
|
|
72
|
+
messages.value = [
|
|
73
|
+
...messages.value,
|
|
74
|
+
{ role: 'user', content: 'Tell me about Digital Transformation Phase 1.' },
|
|
75
|
+
];
|
|
76
|
+
const text = 'Key lessons learned include stakeholder involvement, user training, and centralized feedback.';
|
|
77
|
+
let i = 0;
|
|
78
|
+
messages.value = [...messages.value, { role: 'agent', content: '' }];
|
|
79
|
+
const interval = setInterval(() => {
|
|
80
|
+
i = Math.min(i + 3, text.length);
|
|
81
|
+
const last = messages.value.length - 1;
|
|
82
|
+
messages.value[last] = { ...messages.value[last], content: text.slice(0, i) };
|
|
83
|
+
if (i >= text.length) clearInterval(interval);
|
|
84
|
+
}, 30);
|
|
85
|
+
}
|
|
86
|
+
</script>
|
|
87
|
+
|
|
88
|
+
<template>
|
|
89
|
+
<div class="flex h-[400px] flex-col">
|
|
90
|
+
<CkConversationLayout :messages="messages" class="min-h-0 flex-1">
|
|
91
|
+
<template #user-message="{ message }">
|
|
92
|
+
<div class="bg-core-bg-secondary rounded-md px-3 py-2 max-w-[400px]">{{ message.content }}</div>
|
|
93
|
+
</template>
|
|
94
|
+
<template #agent-message="{ message }">
|
|
95
|
+
<div class="whitespace-pre-wrap px-3 py-2 max-w-[400px]">{{ message.content }}</div>
|
|
96
|
+
</template>
|
|
97
|
+
</CkConversationLayout>
|
|
98
|
+
<button @click="streamAgentReply">Send message + simulate stream</button>
|
|
99
|
+
</div>
|
|
100
|
+
</template>`;
|
|
101
|
+
|
|
102
|
+
const emptyTemplateSource = `<script setup lang="ts">
|
|
103
|
+
import { CkConversationLayout } from '@aleph-alpha/chat-kit';
|
|
104
|
+
|
|
105
|
+
interface Message { role: string; content: string }
|
|
106
|
+
const messages: Message[] = [];
|
|
107
|
+
</script>
|
|
108
|
+
|
|
109
|
+
<template>
|
|
110
|
+
<CkConversationLayout :messages="messages" style="height: 400px;">
|
|
111
|
+
<template #user-message="{ message }"><div>{{ message.content }}</div></template>
|
|
112
|
+
<template #agent-message="{ message }"><div>{{ message.content }}</div></template>
|
|
113
|
+
</CkConversationLayout>
|
|
114
|
+
</template>`;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Default layout renders alternating user and agent messages with sticky-to-bottom
|
|
118
|
+
* scrolling. Use for most chat viewports where you need automatic follow-behavior.
|
|
119
|
+
*/
|
|
120
|
+
export const Default: Story = {
|
|
121
|
+
render: () => ({
|
|
122
|
+
components: { CkConversationLayout },
|
|
123
|
+
setup() {
|
|
124
|
+
return { messages: sampleMessages };
|
|
125
|
+
},
|
|
126
|
+
template: `
|
|
127
|
+
<CkConversationLayout :messages="messages" style="height: 100%;">
|
|
128
|
+
<template #user-message="{ message }">
|
|
129
|
+
<div style="background: #f0f0f0; padding: 8px 12px; border-radius: 12px 0 12px 12px; max-width: 400px;">
|
|
130
|
+
{{ message.content }}
|
|
131
|
+
</div>
|
|
132
|
+
</template>
|
|
133
|
+
<template #agent-message="{ message }">
|
|
134
|
+
<div style="padding: 8px 12px; max-width: 400px;">
|
|
135
|
+
{{ message.content }}
|
|
136
|
+
</div>
|
|
137
|
+
</template>
|
|
138
|
+
</CkConversationLayout>
|
|
139
|
+
`,
|
|
140
|
+
}),
|
|
141
|
+
parameters: { docs: { source: { code: defaultTemplateSource } } },
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Demonstrates the layout's streaming behavior: the bottom spacer shrinks as
|
|
146
|
+
* the agent response grows so the user's last message stays pinned to the top.
|
|
147
|
+
* Use when streaming tokens from an LLM into the last agent message.
|
|
148
|
+
*/
|
|
149
|
+
export const StreamingSimulation: Story = {
|
|
150
|
+
render: () => ({
|
|
151
|
+
components: { CkConversationLayout },
|
|
152
|
+
setup() {
|
|
153
|
+
const messages = ref([
|
|
154
|
+
{ role: 'user', content: 'Hello!' },
|
|
155
|
+
{ role: 'agent', content: 'Hi there!' },
|
|
156
|
+
]);
|
|
157
|
+
|
|
158
|
+
const streamingText =
|
|
159
|
+
'Based on a review of internal documents, here are the key lessons learned and best practices from the Digital Transformation Phase 1 project, broken down by category:\n\n' +
|
|
160
|
+
'Key Lessons Learned:\n' +
|
|
161
|
+
'• Stakeholder Involvement: Early and continuous stakeholder involvement from all affected departments.\n' +
|
|
162
|
+
'• User Training: Tailored, hands-on training was far more effective than generic sessions.\n' +
|
|
163
|
+
'• Centralized Feedback: Establishing a centralized feedback loop was crucial.\n\n' +
|
|
164
|
+
'Key Best Practices & Successes:\n' +
|
|
165
|
+
'• Phased Rollout: Minimized disruption and provided opportunities to refine processes.';
|
|
166
|
+
|
|
167
|
+
let streamInterval: ReturnType<typeof setInterval> | null = null;
|
|
168
|
+
let charIndex = 0;
|
|
169
|
+
|
|
170
|
+
function streamNextChunk() {
|
|
171
|
+
if (charIndex >= streamingText.length) {
|
|
172
|
+
if (streamInterval) clearInterval(streamInterval);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
// eslint-disable-next-line sonarjs/pseudo-random
|
|
176
|
+
const chunkSize = Math.floor(Math.random() * 5) + 3;
|
|
177
|
+
charIndex = Math.min(charIndex + chunkSize, streamingText.length);
|
|
178
|
+
const lastIdx = messages.value.length - 1;
|
|
179
|
+
messages.value[lastIdx] = {
|
|
180
|
+
...messages.value[lastIdx],
|
|
181
|
+
content: streamingText.slice(0, charIndex),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function startStreaming() {
|
|
186
|
+
messages.value = [...messages.value, { role: 'agent', content: '' }];
|
|
187
|
+
charIndex = 0;
|
|
188
|
+
streamInterval = setInterval(streamNextChunk, 30);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function simulateStream() {
|
|
192
|
+
messages.value = [
|
|
193
|
+
...messages.value,
|
|
194
|
+
{
|
|
195
|
+
role: 'user',
|
|
196
|
+
content:
|
|
197
|
+
'Tell me about Digital Transformation Phase 1 lessons learned.',
|
|
198
|
+
},
|
|
199
|
+
];
|
|
200
|
+
|
|
201
|
+
// After a short delay, start streaming the agent response
|
|
202
|
+
setTimeout(startStreaming, 500);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return { messages, simulateStream };
|
|
206
|
+
},
|
|
207
|
+
template: `
|
|
208
|
+
<div style="display: flex; flex-direction: column; height: 100%;">
|
|
209
|
+
<CkConversationLayout :messages="messages" style="flex: 1; min-height: 0;">
|
|
210
|
+
<template #user-message="{ message }">
|
|
211
|
+
<div style="background: #f0f0f0; padding: 8px 12px; border-radius: 12px 0 12px 12px; max-width: 400px;">
|
|
212
|
+
{{ message.content }}
|
|
213
|
+
</div>
|
|
214
|
+
</template>
|
|
215
|
+
<template #agent-message="{ message }">
|
|
216
|
+
<div style="padding: 8px 12px; max-width: 400px; white-space: pre-wrap;">
|
|
217
|
+
{{ message.content }}
|
|
218
|
+
</div>
|
|
219
|
+
</template>
|
|
220
|
+
</CkConversationLayout>
|
|
221
|
+
<div style="padding: 8px; border-top: 1px solid #e0e0e0;">
|
|
222
|
+
<button @click="simulateStream" style="padding: 6px 16px; border-radius: 6px; background: #333; color: white; cursor: pointer;">
|
|
223
|
+
Send message + simulate stream
|
|
224
|
+
</button>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
`,
|
|
228
|
+
}),
|
|
229
|
+
parameters: { docs: { source: { code: streamingSimulationTemplateSource } } },
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Empty state for when there are no messages. Use as an initial state before
|
|
234
|
+
* the first user turn, or when history has been cleared.
|
|
235
|
+
*/
|
|
236
|
+
export const Empty: Story = {
|
|
237
|
+
render: () => ({
|
|
238
|
+
components: { CkConversationLayout },
|
|
239
|
+
template: `
|
|
240
|
+
<CkConversationLayout :messages="[]" style="height: 100%;">
|
|
241
|
+
<template #user-message="{ message }">
|
|
242
|
+
<div>{{ message.content }}</div>
|
|
243
|
+
</template>
|
|
244
|
+
<template #agent-message="{ message }">
|
|
245
|
+
<div>{{ message.content }}</div>
|
|
246
|
+
</template>
|
|
247
|
+
</CkConversationLayout>
|
|
248
|
+
`,
|
|
249
|
+
}),
|
|
250
|
+
parameters: { docs: { source: { code: emptyTemplateSource } } },
|
|
251
|
+
};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
<script setup lang="ts" generic="T extends { id: string; role: string }">
|
|
2
|
+
import { ref, watch, nextTick, onMounted, onUnmounted } from 'vue';
|
|
3
|
+
|
|
4
|
+
defineOptions({ name: 'CkConversationLayout' });
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
messages: T[];
|
|
8
|
+
}>();
|
|
9
|
+
|
|
10
|
+
defineSlots<{
|
|
11
|
+
'user-message': (props: { message: T }) => void;
|
|
12
|
+
'agent-message': (props: { message: T }) => void;
|
|
13
|
+
}>();
|
|
14
|
+
|
|
15
|
+
const scrollContainerRef = ref<HTMLElement | null>(null);
|
|
16
|
+
const containerHeight = ref(0);
|
|
17
|
+
const isAutoScrollEnabled = ref(true);
|
|
18
|
+
const hasBottomSpacer = ref(false);
|
|
19
|
+
const spacerHeight = ref(0);
|
|
20
|
+
|
|
21
|
+
let resizeObserver: ResizeObserver | null = null;
|
|
22
|
+
|
|
23
|
+
onMounted(() => {
|
|
24
|
+
const container = scrollContainerRef.value;
|
|
25
|
+
if (container) {
|
|
26
|
+
containerHeight.value = container.clientHeight;
|
|
27
|
+
resizeObserver = new ResizeObserver((entries) => {
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
containerHeight.value = entry.contentRect.height;
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
resizeObserver.observe(container);
|
|
33
|
+
|
|
34
|
+
// Initially scroll to the bottom of the conversation without any spacer
|
|
35
|
+
nextTick(() => {
|
|
36
|
+
container.scrollTop = container.scrollHeight;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
onUnmounted(() => {
|
|
42
|
+
resizeObserver?.disconnect();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Check if the viewport is currently showing the bottom of the last message.
|
|
47
|
+
* When the user manually scrolls up past the last message, auto-scroll is disabled.
|
|
48
|
+
*/
|
|
49
|
+
function isFollowingContent(): boolean {
|
|
50
|
+
const container = scrollContainerRef.value;
|
|
51
|
+
if (!container) return true;
|
|
52
|
+
const rows = container.querySelectorAll<HTMLElement>('[data-message-row]');
|
|
53
|
+
const lastRow = rows[rows.length - 1];
|
|
54
|
+
if (!lastRow) return true;
|
|
55
|
+
|
|
56
|
+
const lastRowBottom = lastRow.offsetTop + lastRow.offsetHeight;
|
|
57
|
+
const viewportBottom = container.scrollTop + container.clientHeight;
|
|
58
|
+
return viewportBottom >= lastRowBottom - 10;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function handleScroll(): void {
|
|
62
|
+
isAutoScrollEnabled.value = isFollowingContent();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Recalculate the bottom spacer height so that content below the last user
|
|
67
|
+
* message plus the spacer always equals one viewport height.
|
|
68
|
+
* As the agent message grows the spacer shrinks by the same amount,
|
|
69
|
+
* keeping the total scrollable height stable and the viewport still.
|
|
70
|
+
*/
|
|
71
|
+
function updateSpacerHeight(): void {
|
|
72
|
+
const container = scrollContainerRef.value;
|
|
73
|
+
if (!container || !hasBottomSpacer.value) {
|
|
74
|
+
spacerHeight.value = 0;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const userRows = container.querySelectorAll<HTMLElement>(
|
|
79
|
+
'[data-message-row="user"]',
|
|
80
|
+
);
|
|
81
|
+
const lastUserRow = userRows[userRows.length - 1];
|
|
82
|
+
if (!lastUserRow) {
|
|
83
|
+
spacerHeight.value = 0;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const rows = container.querySelectorAll<HTMLElement>('[data-message-row]');
|
|
88
|
+
const lastRow = rows[rows.length - 1];
|
|
89
|
+
if (!lastRow) {
|
|
90
|
+
spacerHeight.value = 0;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const lastUserTop = lastUserRow.offsetTop;
|
|
95
|
+
const lastRowBottom = lastRow.offsetTop + lastRow.offsetHeight;
|
|
96
|
+
const contentFromUserToEnd = lastRowBottom - lastUserTop;
|
|
97
|
+
const gap = parseFloat(getComputedStyle(container).rowGap) || 0;
|
|
98
|
+
|
|
99
|
+
spacerHeight.value = Math.max(
|
|
100
|
+
0,
|
|
101
|
+
containerHeight.value - contentFromUserToEnd - gap,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Scroll so the last user message is at the top of the viewport.
|
|
107
|
+
* The bottom spacer ensures enough scroll room.
|
|
108
|
+
*/
|
|
109
|
+
function scrollUserMessageToTop(): void {
|
|
110
|
+
const container = scrollContainerRef.value;
|
|
111
|
+
if (!container) return;
|
|
112
|
+
|
|
113
|
+
hasBottomSpacer.value = true;
|
|
114
|
+
spacerHeight.value = containerHeight.value;
|
|
115
|
+
|
|
116
|
+
nextTick(() => {
|
|
117
|
+
const userRows = container.querySelectorAll<HTMLElement>(
|
|
118
|
+
'[data-message-row="user"]',
|
|
119
|
+
);
|
|
120
|
+
const lastUserRow = userRows[userRows.length - 1];
|
|
121
|
+
if (lastUserRow) {
|
|
122
|
+
lastUserRow.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
123
|
+
}
|
|
124
|
+
isAutoScrollEnabled.value = true;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Scroll only when the bottom of the last message overflows past the viewport.
|
|
130
|
+
* This keeps the viewport still while the agent message is short,
|
|
131
|
+
* and starts following once it grows past the visible area.
|
|
132
|
+
*/
|
|
133
|
+
function scrollToEndOfContent(): void {
|
|
134
|
+
const container = scrollContainerRef.value;
|
|
135
|
+
if (!container || !isAutoScrollEnabled.value) return;
|
|
136
|
+
|
|
137
|
+
const rows = container.querySelectorAll<HTMLElement>('[data-message-row]');
|
|
138
|
+
const lastRow = rows[rows.length - 1];
|
|
139
|
+
if (!lastRow) return;
|
|
140
|
+
|
|
141
|
+
const lastRowBottom = lastRow.offsetTop + lastRow.offsetHeight;
|
|
142
|
+
const viewportBottom = container.scrollTop + container.clientHeight;
|
|
143
|
+
|
|
144
|
+
if (lastRowBottom > viewportBottom) {
|
|
145
|
+
container.scrollTop = lastRowBottom - container.clientHeight;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// When a new user message is added, scroll it to the top of the viewport
|
|
150
|
+
watch(
|
|
151
|
+
() => props.messages.length,
|
|
152
|
+
(newLen, oldLen) => {
|
|
153
|
+
if (newLen <= (oldLen ?? 0)) return;
|
|
154
|
+
const lastMessage = props.messages[newLen - 1];
|
|
155
|
+
if (lastMessage?.role === 'user') {
|
|
156
|
+
nextTick(() => scrollUserMessageToTop());
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// Follow agent content: handles both new agent messages and streaming updates.
|
|
162
|
+
// First shrinks the spacer to match the growing content, then scrolls when it overflows.
|
|
163
|
+
watch(
|
|
164
|
+
() => props.messages,
|
|
165
|
+
() => {
|
|
166
|
+
if (props.messages.length === 0) return;
|
|
167
|
+
const lastMessage = props.messages[props.messages.length - 1];
|
|
168
|
+
if (lastMessage && lastMessage.role !== 'user') {
|
|
169
|
+
nextTick(() => {
|
|
170
|
+
updateSpacerHeight();
|
|
171
|
+
if (isAutoScrollEnabled.value) {
|
|
172
|
+
scrollToEndOfContent();
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
{ deep: true },
|
|
178
|
+
);
|
|
179
|
+
</script>
|
|
180
|
+
|
|
181
|
+
<template>
|
|
182
|
+
<div
|
|
183
|
+
ref="scrollContainerRef"
|
|
184
|
+
class="relative flex flex-col gap-4 overflow-y-auto"
|
|
185
|
+
@scroll="handleScroll"
|
|
186
|
+
>
|
|
187
|
+
<div
|
|
188
|
+
v-for="message in messages"
|
|
189
|
+
:key="message.id"
|
|
190
|
+
:data-message-row="message.role"
|
|
191
|
+
>
|
|
192
|
+
<div v-if="message.role === 'user'" class="flex justify-end">
|
|
193
|
+
<slot name="user-message" :message="message" />
|
|
194
|
+
</div>
|
|
195
|
+
<div v-else class="flex justify-start">
|
|
196
|
+
<slot name="agent-message" :message="message" />
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
<!-- Bottom spacer: shrinks as agent content grows to keep viewport stable -->
|
|
200
|
+
<div
|
|
201
|
+
v-if="hasBottomSpacer"
|
|
202
|
+
aria-hidden="true"
|
|
203
|
+
:style="{ minHeight: `${spacerHeight}px` }"
|
|
204
|
+
/>
|
|
205
|
+
</div>
|
|
206
|
+
</template>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scroll-aware container that renders a list of messages with sticky-to-bottom
|
|
3
|
+
* behavior, a shrinking spacer so streaming agent replies don't jitter the viewport,
|
|
4
|
+
* and slots for rendering user/agent messages.
|
|
5
|
+
*
|
|
6
|
+
* @category Layout
|
|
7
|
+
* @useCases conversation scroll container, streaming chat layout, chat viewport with auto-scroll, follow-the-bottom chat list
|
|
8
|
+
* @keywords conversation, layout, scroll, auto-scroll, streaming, chat, container
|
|
9
|
+
* @related CkConversation, CkAgentMessage, CkUserMessage
|
|
10
|
+
*
|
|
11
|
+
* @typeParam T - Message type; must have at least a `role` string property.
|
|
12
|
+
*/
|
|
13
|
+
export interface CkConversationLayoutProps<
|
|
14
|
+
T extends { role: string } = { role: string },
|
|
15
|
+
> {
|
|
16
|
+
/** Array of messages to render in the conversation layout */
|
|
17
|
+
messages: T[];
|
|
18
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import CkCopyMessageActionButton from './CkCopyMessageActionButton.vue';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { render, screen, waitFor } from '@testing-library/vue';
|
|
4
|
+
import { flushPromises } from '@vue/test-utils';
|
|
5
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
|
|
7
|
+
describe('CkCopyMessageActionButton', () => {
|
|
8
|
+
const writeText = vi.fn((_: string) => Promise.resolve());
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
writeText.mockReset();
|
|
12
|
+
writeText.mockResolvedValue(undefined);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('emits copy with success when clipboard succeeds', async () => {
|
|
16
|
+
const user = userEvent.setup();
|
|
17
|
+
const { emitted } = render(CkCopyMessageActionButton, {
|
|
18
|
+
props: { text: 'x', writeToClipboard: writeText },
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
await user.click(screen.getByRole('button', { name: 'Copy message' }));
|
|
22
|
+
await flushPromises();
|
|
23
|
+
|
|
24
|
+
expect(writeText).toHaveBeenCalledWith('x');
|
|
25
|
+
expect(emitted('copy')).toEqual([[{ success: true }]]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('copies text and updates the accessible name', async () => {
|
|
29
|
+
const user = userEvent.setup();
|
|
30
|
+
render(CkCopyMessageActionButton, {
|
|
31
|
+
props: { text: 'Hello, world', writeToClipboard: writeText },
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
await user.click(screen.getByRole('button', { name: 'Copy message' }));
|
|
35
|
+
await flushPromises();
|
|
36
|
+
|
|
37
|
+
expect(writeText).toHaveBeenCalledWith('Hello, world');
|
|
38
|
+
await waitFor(() => {
|
|
39
|
+
expect(
|
|
40
|
+
screen.getByRole('button', { name: 'Copied' }),
|
|
41
|
+
).toBeInTheDocument();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('when clipboard rejects', () => {
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
writeText.mockReset();
|
|
48
|
+
writeText.mockRejectedValue(new Error('denied'));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('emits copy with success false', async () => {
|
|
52
|
+
const user = userEvent.setup();
|
|
53
|
+
const { emitted } = render(CkCopyMessageActionButton, {
|
|
54
|
+
props: { text: 'x', writeToClipboard: writeText },
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await user.click(screen.getByRole('button', { name: 'Copy message' }));
|
|
58
|
+
await flushPromises();
|
|
59
|
+
|
|
60
|
+
expect(emitted('copy')).toEqual([[{ success: false }]]);
|
|
61
|
+
expect(
|
|
62
|
+
screen.getByRole('button', { name: 'Copy message' }),
|
|
63
|
+
).toBeInTheDocument();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('reverts label after durationMs', async () => {
|
|
68
|
+
vi.useFakeTimers();
|
|
69
|
+
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
70
|
+
render(CkCopyMessageActionButton, {
|
|
71
|
+
props: { text: 'x', durationMs: 1500, writeToClipboard: writeText },
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
await user.click(screen.getByRole('button', { name: 'Copy message' }));
|
|
75
|
+
|
|
76
|
+
await waitFor(() => {
|
|
77
|
+
expect(
|
|
78
|
+
screen.getByRole('button', { name: 'Copied' }),
|
|
79
|
+
).toBeInTheDocument();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
await vi.advanceTimersByTimeAsync(1500);
|
|
83
|
+
|
|
84
|
+
expect(
|
|
85
|
+
screen.getByRole('button', { name: 'Copy message' }),
|
|
86
|
+
).toBeInTheDocument();
|
|
87
|
+
vi.useRealTimers();
|
|
88
|
+
});
|
|
89
|
+
});
|