@aleph-alpha/chat-kit 1.11.1 → 1.13.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 (52) hide show
  1. package/dist/{CkInput.vue_vue_type_script_setup_true_lang-C1Jnn1fK.js → CkInput.vue_vue_type_script_setup_true_lang-DCZTSiBj.js} +371 -135
  2. package/dist/adapters/responses-api.d.ts +54 -5
  3. package/dist/adapters/responses-api.d.ts.map +1 -1
  4. package/dist/adapters/responses-api.js +43 -9
  5. package/dist/chat-kit.css +19 -0
  6. package/dist/components/base/CkFeedbackDialog/CkFeedbackDialog.stories.d.ts +30 -0
  7. package/dist/components/base/CkFeedbackDialog/CkFeedbackDialog.stories.d.ts.map +1 -0
  8. package/dist/components/base/CkFeedbackDialog/CkFeedbackDialog.vue.d.ts +13 -0
  9. package/dist/components/base/CkFeedbackDialog/CkFeedbackDialog.vue.d.ts.map +1 -0
  10. package/dist/components/base/CkFeedbackDialog/index.d.ts +3 -0
  11. package/dist/components/base/CkFeedbackDialog/index.d.ts.map +1 -0
  12. package/dist/components/base/CkFeedbackDialog/types.d.ts +152 -0
  13. package/dist/components/base/CkFeedbackDialog/types.d.ts.map +1 -0
  14. package/dist/components/base/index.d.ts +2 -0
  15. package/dist/components/base/index.d.ts.map +1 -1
  16. package/dist/components/index.d.ts +2 -2
  17. package/dist/components/index.d.ts.map +1 -1
  18. package/dist/components/index.js +16 -15
  19. package/dist/composables/index.js +1 -1
  20. package/dist/handlers/index.js +2 -2
  21. package/dist/handlers/useChatKitLabels.d.ts +18 -0
  22. package/dist/handlers/useChatKitLabels.d.ts.map +1 -1
  23. package/dist/handlers/useMessageHandler.d.ts +6 -2
  24. package/dist/handlers/useMessageHandler.d.ts.map +1 -1
  25. package/dist/helpers/conversationTree.d.ts +10 -1
  26. package/dist/helpers/conversationTree.d.ts.map +1 -1
  27. package/dist/{index-BLq6_Lt2.js → index-Ct5hD214.js} +1 -1
  28. package/dist/index.d.ts +3 -3
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +19 -18
  31. package/dist/types.d.ts +14 -0
  32. package/dist/types.d.ts.map +1 -1
  33. package/dist/{useChatKitLabels-ftC52m7f.js → useChatKitLabels-DeHxZG2a.js} +9 -0
  34. package/dist/{useMessageHandler-ZRC-gIOJ.js → useMessageHandler-CMdy-lm0.js} +36 -9
  35. package/package.json +5 -5
  36. package/src/adapters/responses-api.spec.ts +70 -0
  37. package/src/adapters/responses-api.ts +123 -12
  38. package/src/components/base/CkFeedbackDialog/CkFeedbackDialog.spec.ts +177 -0
  39. package/src/components/base/CkFeedbackDialog/CkFeedbackDialog.stories.ts +258 -0
  40. package/src/components/base/CkFeedbackDialog/CkFeedbackDialog.vue +288 -0
  41. package/src/components/base/CkFeedbackDialog/index.ts +2 -0
  42. package/src/components/base/CkFeedbackDialog/types.ts +155 -0
  43. package/src/components/base/index.ts +8 -0
  44. package/src/components/index.ts +6 -0
  45. package/src/handlers/useChatKitLabels.spec.ts +11 -0
  46. package/src/handlers/useChatKitLabels.ts +29 -0
  47. package/src/handlers/useMessageHandler.spec.ts +214 -0
  48. package/src/handlers/useMessageHandler.ts +28 -3
  49. package/src/helpers/conversationTree.spec.ts +134 -0
  50. package/src/helpers/conversationTree.ts +32 -1
  51. package/src/index.ts +7 -0
  52. package/src/types.ts +15 -0
@@ -0,0 +1,177 @@
1
+ import { defaultChatKitLabels } from '../../../handlers/useChatKitLabels';
2
+ import CkFeedbackDialog from './CkFeedbackDialog.vue';
3
+ import type {
4
+ CkFeedbackDialogProps,
5
+ CkFeedbackDialogSubmitPayload,
6
+ CkFeedbackReason,
7
+ } from './types';
8
+ import userEvent from '@testing-library/user-event';
9
+ import { render, screen, waitFor } from '@testing-library/vue';
10
+ import { describe, expect, it } from 'vitest';
11
+
12
+ const SUBMIT = defaultChatKitLabels.feedbackDialogSubmitLabel!;
13
+ const CANCEL = defaultChatKitLabels.feedbackDialogCancelLabel!;
14
+ const TITLE_POSITIVE = defaultChatKitLabels.feedbackDialogTitlePositive!;
15
+ const TITLE_NEGATIVE = defaultChatKitLabels.feedbackDialogTitleNegative!;
16
+ const RESPONSE_HEADING = defaultChatKitLabels.feedbackDialogResponseHeading!;
17
+ const OTHER_LABEL = defaultChatKitLabels.feedbackDialogOtherLabel!;
18
+ // UiDialogContent's default close-button accessible name.
19
+ const CLOSE = 'Close dialog';
20
+
21
+ const REASONS: CkFeedbackReason[] = [
22
+ { code: 'accurate_or_well_sourced', label: 'Accurate / well-sourced' },
23
+ { code: 'clear_explanation', label: 'Clear explanation' },
24
+ { code: 'good_tone', label: 'Good tone' },
25
+ ];
26
+
27
+ function renderDialog(props: Partial<CkFeedbackDialogProps> = {}) {
28
+ return render(CkFeedbackDialog, {
29
+ props: {
30
+ open: true,
31
+ sentiment: 'positive',
32
+ reasons: REASONS,
33
+ responseText: 'A sample assistant response.',
34
+ ...props,
35
+ } as CkFeedbackDialogProps,
36
+ });
37
+ }
38
+
39
+ describe('CkFeedbackDialog', () => {
40
+ // AC (SPHER-4504): "After clicking thumbs up/down, a reason selector panel
41
+ // appears" rendering the labels fetched from the backend config.
42
+ it('renders the supplied reasons as toggle buttons (by label)', async () => {
43
+ renderDialog();
44
+
45
+ for (const reason of REASONS) {
46
+ expect(
47
+ await screen.findByRole('button', { name: reason.label }),
48
+ ).toBeInTheDocument();
49
+ }
50
+ });
51
+
52
+ // AC: title reflects the sentiment the dialog was opened for.
53
+ it('shows the positive title for positive sentiment', async () => {
54
+ renderDialog({ sentiment: 'positive' });
55
+ expect(await screen.findByText(TITLE_POSITIVE)).toBeInTheDocument();
56
+ });
57
+
58
+ it('shows the negative title for negative sentiment', async () => {
59
+ renderDialog({ sentiment: 'negative' });
60
+ expect(await screen.findByText(TITLE_NEGATIVE)).toBeInTheDocument();
61
+ });
62
+
63
+ // AC: "Submitting sends sentiment + reason + reason_text to the backend."
64
+ // The dialog emits the selected reason CODE (not the label) and the sentiment.
65
+ it('emits submit with the selected reason code and sentiment', async () => {
66
+ const user = userEvent.setup();
67
+ const { emitted } = renderDialog({ sentiment: 'negative' });
68
+
69
+ await user.click(
70
+ await screen.findByRole('button', { name: REASONS[0].label }),
71
+ );
72
+ await user.click(screen.getByRole('button', { name: SUBMIT }));
73
+
74
+ const submitEvents = emitted('submit') as [CkFeedbackDialogSubmitPayload][];
75
+ expect(submitEvents).toHaveLength(1);
76
+ expect(submitEvents[0][0]).toEqual({
77
+ sentiment: 'negative',
78
+ reason: REASONS[0].code,
79
+ reasonText: '',
80
+ });
81
+ });
82
+
83
+ // Single-select: choosing a second reason replaces the first (only one code
84
+ // is ever submitted).
85
+ it('keeps only one reason selected (single-select)', async () => {
86
+ const user = userEvent.setup();
87
+ const { emitted } = renderDialog();
88
+
89
+ await user.click(
90
+ await screen.findByRole('button', { name: REASONS[0].label }),
91
+ );
92
+ await user.click(screen.getByRole('button', { name: REASONS[1].label }));
93
+ await user.click(screen.getByRole('button', { name: SUBMIT }));
94
+
95
+ const submitEvents = emitted('submit') as [CkFeedbackDialogSubmitPayload][];
96
+ expect(submitEvents[0][0].reason).toBe(REASONS[1].code);
97
+ });
98
+
99
+ // Submit is gated until the user provides at least one signal (a reason or
100
+ // some free text), so we never POST an empty qualitative payload.
101
+ it('disables submit until a reason or free text is provided', async () => {
102
+ const user = userEvent.setup();
103
+ renderDialog();
104
+
105
+ const submit = await screen.findByRole('button', { name: SUBMIT });
106
+ expect(submit).toBeDisabled();
107
+
108
+ await user.click(screen.getByRole('button', { name: REASONS[1].label }));
109
+ await waitFor(() => expect(submit).not.toBeDisabled());
110
+ });
111
+
112
+ // Free text alone (no reason) is a valid submission; reason is null.
113
+ it('allows submitting with only free text', async () => {
114
+ const user = userEvent.setup();
115
+ const { emitted } = renderDialog();
116
+
117
+ await user.type(await screen.findByRole('textbox'), 'Needs more detail');
118
+ await user.click(screen.getByRole('button', { name: SUBMIT }));
119
+
120
+ const submitEvents = emitted('submit') as [CkFeedbackDialogSubmitPayload][];
121
+ expect(submitEvents).toHaveLength(1);
122
+ expect(submitEvents[0][0]).toMatchObject({
123
+ reason: null,
124
+ reasonText: 'Needs more detail',
125
+ });
126
+ });
127
+
128
+ // AC: "After clicking Cancel ... the user can click again" — cancel emits
129
+ // both `cancel` and `update:open(false)` without a `submit`.
130
+ it('emits cancel and closes without submitting', async () => {
131
+ const user = userEvent.setup();
132
+ const { emitted } = renderDialog();
133
+
134
+ await user.click(await screen.findByRole('button', { name: CANCEL }));
135
+
136
+ expect(emitted('cancel')).toHaveLength(1);
137
+ expect(emitted('update:open')).toContainEqual([false]);
138
+ expect(emitted('submit')).toBeUndefined();
139
+ });
140
+
141
+ // While submitting, both actions are frozen so the user can't double-submit
142
+ // or cancel mid-flight.
143
+ it('disables both actions while submitting', async () => {
144
+ renderDialog({ submitting: true });
145
+
146
+ expect(await screen.findByRole('button', { name: SUBMIT })).toBeDisabled();
147
+ expect(screen.getByRole('button', { name: CANCEL })).toBeDisabled();
148
+ });
149
+
150
+ // The free-text field must be programmatically associated with its label so
151
+ // screen readers announce it (label `for` ↔ textarea `id`).
152
+ it('associates the free-text input with the "Other" label', async () => {
153
+ renderDialog();
154
+ expect(
155
+ await screen.findByRole('textbox', { name: OTHER_LABEL }),
156
+ ).toBeInTheDocument();
157
+ });
158
+
159
+ // The close (X) button is hidden while submitting so there's no enabled
160
+ // control that does nothing; the non-interactive state stays consistent.
161
+ it('hides the close button while submitting', async () => {
162
+ renderDialog({ submitting: true });
163
+
164
+ await screen.findByRole('button', { name: SUBMIT });
165
+ expect(
166
+ screen.queryByRole('button', { name: CLOSE }),
167
+ ).not.toBeInTheDocument();
168
+ });
169
+
170
+ // When no response text is given the left preview column is omitted.
171
+ it('omits the response heading when responseText is empty', async () => {
172
+ renderDialog({ responseText: '' });
173
+
174
+ await screen.findByRole('button', { name: SUBMIT });
175
+ expect(screen.queryByText(RESPONSE_HEADING)).not.toBeInTheDocument();
176
+ });
177
+ });
@@ -0,0 +1,258 @@
1
+ import CkFeedbackDialog from './CkFeedbackDialog.vue';
2
+ import { CkFeedbackButton } from '../CkFeedbackButton';
3
+ import type {
4
+ CkFeedbackDialogSubmitPayload,
5
+ CkFeedbackReason,
6
+ CkFeedbackSentiment,
7
+ } from './types';
8
+ import type { Meta, StoryObj } from '@storybook/vue3-vite';
9
+ import type { CkFeedbackValue } from '../CkFeedbackButton';
10
+ import { ref } from 'vue';
11
+
12
+ // Reason catalogs mirror what the backend serves from `GET /feedback/config`
13
+ // (feedback-kit). In the real app the host fetches these; here they are inlined
14
+ // for the story. `code` is submitted; `label` is shown.
15
+ const POSITIVE_REASONS: CkFeedbackReason[] = [
16
+ { code: 'accurate_or_well_sourced', label: 'Accurate / well-sourced' },
17
+ { code: 'followed_instructions', label: 'Followed instructions' },
18
+ { code: 'right_level_of_detail', label: 'Right level of detail' },
19
+ { code: 'clear_explanation', label: 'Clear explanation' },
20
+ { code: 'good_tone', label: 'Good tone' },
21
+ { code: 'creative_or_insightful', label: 'Creative / insightful' },
22
+ { code: 'honest_about_uncertainty', label: 'Honest about uncertainty' },
23
+ { code: 'handled_hard_request', label: 'Handled hard request' },
24
+ ];
25
+
26
+ const NEGATIVE_REASONS: CkFeedbackReason[] = [
27
+ { code: 'factually_incorrect', label: 'Factually incorrect' },
28
+ { code: 'off_topic_or_irrelevant', label: 'Off-topic / irrelevant' },
29
+ { code: 'incomplete', label: 'Incomplete' },
30
+ { code: 'too_long_or_repetitive', label: 'Too long / repetitive' },
31
+ { code: 'didnt_follow_instructions', label: 'Didn’t follow instructions' },
32
+ { code: 'biased_or_unsafe', label: 'Biased or unsafe' },
33
+ { code: 'refused_unnecessarily', label: 'Refused unnecessarily' },
34
+ { code: 'bad_formatting', label: 'Bad formatting' },
35
+ { code: 'wrong_tone', label: 'Wrong tone' },
36
+ { code: 'wrong_language', label: 'Wrong language' },
37
+ ];
38
+
39
+ const SAMPLE_RESPONSE = `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:
40
+
41
+ Key Lessons Learned:
42
+
43
+ - **Stakeholder Involvement:** The most critical lesson was the need for early and **continuous stakeholder involvement** from all affected departments. A lack of initial engagement led to significant user resistance and process rework in the initial rollout.
44
+ - **User Training:** Tailored, hands-on training was found to be far more effective than generic sessions. Departments that received customized training saw a **25% higher adoption rate** of the new tools compared to those with generic training.`;
45
+
46
+ const meta: Meta<typeof CkFeedbackDialog> = {
47
+ title: 'Base/CkFeedbackDialog',
48
+ component: CkFeedbackDialog,
49
+ tags: ['autodocs'],
50
+ argTypes: {
51
+ open: { control: 'boolean' },
52
+ sentiment: {
53
+ control: 'select',
54
+ options: ['positive', 'negative'] satisfies CkFeedbackSentiment[],
55
+ },
56
+ responseText: { control: 'text' },
57
+ reasons: { control: 'object' },
58
+ maxFreeTextLength: { control: 'number' },
59
+ submitting: { control: 'boolean' },
60
+ disabled: { control: 'boolean' },
61
+ },
62
+ };
63
+
64
+ export default meta;
65
+ type Story = StoryObj<typeof meta>;
66
+
67
+ const integrationTemplate = `<script setup lang="ts">
68
+ import { ref } from 'vue';
69
+ import {
70
+ CkFeedbackButton,
71
+ CkFeedbackDialog,
72
+ } from '@aleph-alpha/chat-kit';
73
+ import type {
74
+ CkFeedbackDialogSubmitPayload,
75
+ CkFeedbackReason,
76
+ CkFeedbackSentiment,
77
+ CkFeedbackValue,
78
+ } from '@aleph-alpha/chat-kit';
79
+
80
+ const value = ref<CkFeedbackValue>(null);
81
+ const locked = ref(false);
82
+ const open = ref(false);
83
+ const sentiment = ref<CkFeedbackSentiment>('positive');
84
+ const submitting = ref(false);
85
+
86
+ // reasons come from GET /feedback/config (fetched + cached by the host)
87
+ const positiveReasons = ref<CkFeedbackReason[]>([]);
88
+ const negativeReasons = ref<CkFeedbackReason[]>([]);
89
+
90
+ function onUpdate(next: CkFeedbackValue) {
91
+ // Open the dialog on a thumb click but DO NOT lock yet — we lock only once
92
+ // the user submits the dialog form.
93
+ value.value = next;
94
+ if (next === 'up') {
95
+ sentiment.value = 'positive';
96
+ open.value = true;
97
+ } else if (next === 'down') {
98
+ sentiment.value = 'negative';
99
+ open.value = true;
100
+ }
101
+ }
102
+
103
+ async function onSubmit(payload: CkFeedbackDialogSubmitPayload) {
104
+ submitting.value = true;
105
+ try {
106
+ // Host POSTs to /feedback/ { response_id, sentiment, reason, reason_text }.
107
+ await new Promise((r) => setTimeout(r, 400));
108
+ locked.value = true;
109
+ open.value = false;
110
+ } finally {
111
+ submitting.value = false;
112
+ }
113
+ }
114
+
115
+ function onCancel() {
116
+ value.value = null;
117
+ }
118
+ </script>
119
+
120
+ <template>
121
+ <CkFeedbackButton :value="value" :locked="locked" @update:value="onUpdate" />
122
+ <CkFeedbackDialog
123
+ v-model:open="open"
124
+ :sentiment="sentiment"
125
+ :reasons="sentiment === 'positive' ? positiveReasons : negativeReasons"
126
+ :response-text="responseText"
127
+ :submitting="submitting"
128
+ @submit="onSubmit"
129
+ @cancel="onCancel"
130
+ />
131
+ </template>`;
132
+
133
+ /**
134
+ * Positive variant. Single-select reason chips on the right, the response on
135
+ * the left, optional free text underneath.
136
+ */
137
+ export const Positive: Story = {
138
+ args: {
139
+ open: true,
140
+ sentiment: 'positive',
141
+ reasons: POSITIVE_REASONS,
142
+ responseText: SAMPLE_RESPONSE,
143
+ },
144
+ };
145
+
146
+ /**
147
+ * Negative variant. Same shape, different reason set and title.
148
+ */
149
+ export const Negative: Story = {
150
+ args: {
151
+ open: true,
152
+ sentiment: 'negative',
153
+ reasons: NEGATIVE_REASONS,
154
+ responseText: SAMPLE_RESPONSE,
155
+ },
156
+ };
157
+
158
+ /**
159
+ * When no response text is provided, the left column collapses and the reasons
160
+ * + free-text input fill the dialog.
161
+ */
162
+ export const NoResponseColumn: Story = {
163
+ args: {
164
+ open: true,
165
+ sentiment: 'negative',
166
+ reasons: NEGATIVE_REASONS,
167
+ responseText: '',
168
+ },
169
+ };
170
+
171
+ /**
172
+ * Submit shows a busy state while the host persists the payload; both actions
173
+ * are non-interactive.
174
+ */
175
+ export const Submitting: Story = {
176
+ args: {
177
+ open: true,
178
+ sentiment: 'positive',
179
+ reasons: POSITIVE_REASONS,
180
+ responseText: SAMPLE_RESPONSE,
181
+ submitting: true,
182
+ },
183
+ };
184
+
185
+ /**
186
+ * Realistic end-to-end flow wired to `CkFeedbackButton`. Click a thumb to open
187
+ * the dialog; submit or cancel; the button locks only on submit.
188
+ */
189
+ export const WithFeedbackButton: Story = {
190
+ render: () => ({
191
+ components: { CkFeedbackButton, CkFeedbackDialog },
192
+ setup() {
193
+ const value = ref<CkFeedbackValue>(null);
194
+ const locked = ref(false);
195
+ const open = ref(false);
196
+ const sentiment = ref<CkFeedbackSentiment>('positive');
197
+ const submitting = ref(false);
198
+
199
+ function onUpdate(next: CkFeedbackValue) {
200
+ value.value = next;
201
+ if (next === 'up') {
202
+ sentiment.value = 'positive';
203
+ open.value = true;
204
+ } else if (next === 'down') {
205
+ sentiment.value = 'negative';
206
+ open.value = true;
207
+ }
208
+ }
209
+
210
+ async function onSubmit(payload: CkFeedbackDialogSubmitPayload) {
211
+ submitting.value = true;
212
+ await new Promise((r) => setTimeout(r, 400));
213
+ // eslint-disable-next-line no-console
214
+ console.info('CkFeedbackDialog submit', payload);
215
+ submitting.value = false;
216
+ locked.value = true;
217
+ open.value = false;
218
+ }
219
+
220
+ function onCancel() {
221
+ value.value = null;
222
+ }
223
+
224
+ return {
225
+ value,
226
+ locked,
227
+ open,
228
+ sentiment,
229
+ submitting,
230
+ positiveReasons: POSITIVE_REASONS,
231
+ negativeReasons: NEGATIVE_REASONS,
232
+ responseText: SAMPLE_RESPONSE,
233
+ onUpdate,
234
+ onSubmit,
235
+ onCancel,
236
+ };
237
+ },
238
+ template: `
239
+ <div class="flex flex-col gap-4">
240
+ <CkFeedbackButton
241
+ :value="value"
242
+ :locked="locked"
243
+ @update:value="onUpdate"
244
+ />
245
+ <CkFeedbackDialog
246
+ v-model:open="open"
247
+ :sentiment="sentiment"
248
+ :reasons="sentiment === 'positive' ? positiveReasons : negativeReasons"
249
+ :response-text="responseText"
250
+ :submitting="submitting"
251
+ @submit="onSubmit"
252
+ @cancel="onCancel"
253
+ />
254
+ </div>
255
+ `,
256
+ }),
257
+ parameters: { docs: { source: { code: integrationTemplate } } },
258
+ };