@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.
Files changed (118) hide show
  1. package/dist/{CkInput.vue_vue_type_script_setup_true_lang-744ZHsQU.js → CkInput.vue_vue_type_script_setup_true_lang-DpYyyCgU.js} +23 -68
  2. package/dist/components/base/CkHistoryItem/CkHistoryItem.vue.d.ts +3 -4
  3. package/dist/components/base/CkHistoryItem/CkHistoryItem.vue.d.ts.map +1 -1
  4. package/dist/components/base/CkHistoryItem/types.d.ts +3 -2
  5. package/dist/components/base/CkHistoryItem/types.d.ts.map +1 -1
  6. package/dist/components/base/CkPromptStopButton/CkPromptStopButton.vue.d.ts.map +1 -1
  7. package/dist/components/index.js +1 -1
  8. package/dist/index.js +1 -1
  9. package/package.json +5 -5
  10. package/src/components/base/CkAgentMessage/CkAgentMessage.spec.ts +91 -0
  11. package/src/components/base/CkAgentMessage/CkAgentMessage.stories.ts +215 -0
  12. package/src/components/base/CkAgentMessage/CkAgentMessage.vue +25 -0
  13. package/src/components/base/CkAgentMessage/index.ts +2 -0
  14. package/src/components/base/CkAgentMessage/types.ts +11 -0
  15. package/src/components/base/CkConversationLayout/CkConversationLayout.spec.ts +129 -0
  16. package/src/components/base/CkConversationLayout/CkConversationLayout.stories.ts +251 -0
  17. package/src/components/base/CkConversationLayout/CkConversationLayout.vue +206 -0
  18. package/src/components/base/CkConversationLayout/index.ts +2 -0
  19. package/src/components/base/CkConversationLayout/types.ts +18 -0
  20. package/src/components/base/CkCopyMessageActionButton/CkCopyMessageActionButton.spec.ts +89 -0
  21. package/src/components/base/CkCopyMessageActionButton/CkCopyMessageActionButton.stories.ts +70 -0
  22. package/src/components/base/CkCopyMessageActionButton/CkCopyMessageActionButton.vue +110 -0
  23. package/src/components/base/CkCopyMessageActionButton/index.ts +2 -0
  24. package/src/components/base/CkCopyMessageActionButton/types.ts +43 -0
  25. package/src/components/base/CkHistoryItem/CkHistoryItem.spec.ts +42 -0
  26. package/src/components/base/CkHistoryItem/CkHistoryItem.stories.ts +230 -0
  27. package/src/components/base/CkHistoryItem/CkHistoryItem.vue +285 -0
  28. package/src/components/base/CkHistoryItem/activeIndicator.ts +15 -0
  29. package/src/components/base/CkHistoryItem/index.ts +2 -0
  30. package/src/components/base/CkHistoryItem/types.ts +111 -0
  31. package/src/components/base/CkHistoryWrapper/CkHistoryWrapper.spec.ts +26 -0
  32. package/src/components/base/CkHistoryWrapper/CkHistoryWrapper.stories.ts +149 -0
  33. package/src/components/base/CkHistoryWrapper/CkHistoryWrapper.vue +36 -0
  34. package/src/components/base/CkHistoryWrapper/index.ts +2 -0
  35. package/src/components/base/CkHistoryWrapper/types.ts +16 -0
  36. package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.spec.ts +348 -0
  37. package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.ts +382 -0
  38. package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.vue +89 -0
  39. package/src/components/base/CkMarkdownRenderer/defaultMarkdownClasses.ts +19 -0
  40. package/src/components/base/CkMarkdownRenderer/index.ts +5 -0
  41. package/src/components/base/CkMarkdownRenderer/types.ts +53 -0
  42. package/src/components/base/CkMessageActionButton/CkMessageActionButton.spec.ts +40 -0
  43. package/src/components/base/CkMessageActionButton/CkMessageActionButton.stories.ts +56 -0
  44. package/src/components/base/CkMessageActionButton/CkMessageActionButton.vue +23 -0
  45. package/src/components/base/CkMessageActionButton/index.ts +2 -0
  46. package/src/components/base/CkMessageActionButton/types.ts +21 -0
  47. package/src/components/base/CkPromptActionButton/CkPromptActionButton.spec.ts +52 -0
  48. package/src/components/base/CkPromptActionButton/CkPromptActionButton.stories.ts +126 -0
  49. package/src/components/base/CkPromptActionButton/CkPromptActionButton.vue +25 -0
  50. package/src/components/base/CkPromptActionButton/index.ts +2 -0
  51. package/src/components/base/CkPromptActionButton/types.ts +32 -0
  52. package/src/components/base/CkPromptInput/CkPromptInput.spec.ts +181 -0
  53. package/src/components/base/CkPromptInput/CkPromptInput.stories.ts +224 -0
  54. package/src/components/base/CkPromptInput/CkPromptInput.vue +104 -0
  55. package/src/components/base/CkPromptInput/index.ts +2 -0
  56. package/src/components/base/CkPromptInput/types.ts +39 -0
  57. package/src/components/base/CkPromptInputDisclaimer/CkPromptInputDisclaimer.spec.ts +40 -0
  58. package/src/components/base/CkPromptInputDisclaimer/CkPromptInputDisclaimer.stories.ts +103 -0
  59. package/src/components/base/CkPromptInputDisclaimer/CkPromptInputDisclaimer.vue +16 -0
  60. package/src/components/base/CkPromptInputDisclaimer/index.ts +2 -0
  61. package/src/components/base/CkPromptInputDisclaimer/types.ts +17 -0
  62. package/src/components/base/CkPromptStopButton/CkPromptStopButton.spec.ts +64 -0
  63. package/src/components/base/CkPromptStopButton/CkPromptStopButton.stories.ts +81 -0
  64. package/src/components/base/CkPromptStopButton/CkPromptStopButton.vue +27 -0
  65. package/src/components/base/CkPromptStopButton/index.ts +2 -0
  66. package/src/components/base/CkPromptStopButton/types.ts +28 -0
  67. package/src/components/base/CkUserMessage/CkUserMessage.spec.ts +20 -0
  68. package/src/components/base/CkUserMessage/CkUserMessage.stories.ts +112 -0
  69. package/src/components/base/CkUserMessage/CkUserMessage.vue +19 -0
  70. package/src/components/base/CkUserMessage/index.ts +2 -0
  71. package/src/components/base/CkUserMessage/types.ts +14 -0
  72. package/src/components/base/icons/CkSendIcon.vue +22 -0
  73. package/src/components/base/icons/CkStopIcon.vue +18 -0
  74. package/src/components/base/icons/index.ts +2 -0
  75. package/src/components/base/index.ts +21 -0
  76. package/src/components/composed/CkConversation/CkConversation.spec.ts +439 -0
  77. package/src/components/composed/CkConversation/CkConversation.stories.ts +642 -0
  78. package/src/components/composed/CkConversation/CkConversation.vue +146 -0
  79. package/src/components/composed/CkConversation/index.ts +2 -0
  80. package/src/components/composed/CkConversation/types.ts +37 -0
  81. package/src/components/composed/CkHistory/CkHistory.spec.ts +327 -0
  82. package/src/components/composed/CkHistory/CkHistory.stories.ts +373 -0
  83. package/src/components/composed/CkHistory/CkHistory.vue +126 -0
  84. package/src/components/composed/CkHistory/index.ts +2 -0
  85. package/src/components/composed/CkHistory/types.ts +72 -0
  86. package/src/components/composed/CkInput/CkInput.spec.ts +191 -0
  87. package/src/components/composed/CkInput/CkInput.stories.ts +438 -0
  88. package/src/components/composed/CkInput/CkInput.vue +104 -0
  89. package/src/components/composed/CkInput/index.ts +2 -0
  90. package/src/components/composed/CkInput/types.ts +60 -0
  91. package/src/components/composed/index.ts +5 -0
  92. package/src/components/index.ts +34 -0
  93. package/src/composables/index.ts +9 -0
  94. package/src/composables/types.ts +36 -0
  95. package/src/composables/useChat.ts +18 -0
  96. package/src/composables/useConversations.spec.ts +557 -0
  97. package/src/composables/useConversations.ts +321 -0
  98. package/src/handlers/index.ts +9 -0
  99. package/src/handlers/useChatKitLabels.spec.ts +290 -0
  100. package/src/handlers/useChatKitLabels.ts +96 -0
  101. package/src/handlers/useMarkdownParser.spec.ts +112 -0
  102. package/src/handlers/useMarkdownParser.ts +47 -0
  103. package/src/handlers/useMessageHandler.spec.ts +535 -0
  104. package/src/handlers/useMessageHandler.ts +138 -0
  105. package/src/helpers/conversationTree.spec.ts +423 -0
  106. package/src/helpers/conversationTree.ts +117 -0
  107. package/src/helpers/generateId.ts +5 -0
  108. package/src/helpers/index.ts +4 -0
  109. package/src/helpers/secondsToDate.ts +7 -0
  110. package/src/helpers/toError.ts +8 -0
  111. package/src/index.ts +79 -0
  112. package/src/services/ChatService.ts +60 -0
  113. package/src/services/MockChatService.ts +47 -0
  114. package/src/services/index.ts +12 -0
  115. package/src/services/types.ts +92 -0
  116. package/src/test-setup.ts +12 -0
  117. package/src/test-utils/createTextPlugin.ts +9 -0
  118. package/src/types.ts +47 -0
@@ -0,0 +1,321 @@
1
+ import { generateId, secondsToDate, toError } from '../helpers';
2
+ import type {
3
+ ConversationSummary,
4
+ HttpClient,
5
+ RawConversation,
6
+ RawConversationList,
7
+ } from '../services/types';
8
+ import { computed, readonly, ref, shallowRef, type Ref } from 'vue';
9
+
10
+ /**
11
+ * Internal mapped shape of a list response. Combines the OpenAI envelope's
12
+ * `has_more` + `last_id` into a single `nextCursor` (null when no more pages),
13
+ * which is all the composable's pagination loop needs. Not exported because
14
+ * consumers never see it — they interact via `conversations`, `hasMore`,
15
+ * and `fetchMore()`.
16
+ */
17
+ interface ConversationPage {
18
+ items: ConversationSummary[];
19
+ nextCursor: string | null;
20
+ }
21
+
22
+ export interface UseConversationsOptions {
23
+ httpClient: HttpClient;
24
+ /** REST path for the conversations resource, e.g. `/conversations`. */
25
+ path: string;
26
+ /** Default page size for `fetch` / `fetchMore`. Default: 20. */
27
+ pageSize?: number;
28
+ }
29
+
30
+ export interface CreateConversationInput {
31
+ title?: string;
32
+ /**
33
+ * Extra fields merged into the request body's `metadata` object. chat-kit
34
+ * does not bake in any default metadata; pass anything the server expects
35
+ * here (e.g. `{ application: 'tiger-chat' }`).
36
+ */
37
+ metadata?: Record<string, unknown>;
38
+ }
39
+
40
+ export interface MutationOptions {
41
+ /**
42
+ * If `true`, mutate the local list before the network call and roll back
43
+ * on failure. Default: `false`.
44
+ */
45
+ optimistic?: boolean;
46
+ }
47
+
48
+ export interface UseConversationsReturn {
49
+ /**
50
+ * Reactive list. Readonly so callers cannot mutate the array itself */
51
+ conversations: Readonly<Ref<readonly ConversationSummary[]>>;
52
+ /**
53
+ * True while any fetch or mutation is in flight. Backed by an internal
54
+ * counter, so it stays `true` for the duration of overlapping operations
55
+ * and only flips back to `false` once every in-progress request has settled.
56
+ */
57
+ isLoading: Readonly<Ref<boolean>>;
58
+ /** True when the server reported another cursor on the last page. */
59
+ hasMore: Readonly<Ref<boolean>>;
60
+ /** Last error from any operation, or `null`. Cleared on the next call. */
61
+ error: Readonly<Ref<Error | null>>;
62
+ /** Load the first page. Replaces the list and resets the cursor. */
63
+ fetch(options?: { limit?: number }): Promise<void>;
64
+ /** Load the next page using the cursor stored internally. No-op if `!hasMore`. */
65
+ fetchMore(): Promise<void>;
66
+ /**
67
+ * Fetch a single conversation by id from the server. Pure fetch: the list
68
+ * is not touched (no insert, no replace). Useful for resolving a
69
+ * conversation that isn't on the first page (e.g. deep-linked URL after a
70
+ * page reload) where the caller just needs its title or metadata.
71
+ */
72
+ fetchById(id: string): Promise<ConversationSummary>;
73
+ /** Create on the server and prepend to the list. */
74
+ create(
75
+ input?: CreateConversationInput,
76
+ options?: MutationOptions,
77
+ ): Promise<ConversationSummary>;
78
+ /** Rename on the server and update the list entry in place. */
79
+ rename(
80
+ id: string,
81
+ title: string,
82
+ options?: MutationOptions,
83
+ ): Promise<ConversationSummary>;
84
+ /** Delete on the server and remove from the list. */
85
+ remove(id: string, options?: MutationOptions): Promise<void>;
86
+ /** Local lookup. Does not fetch — only checks the in-memory list. */
87
+ getById(id: string): ConversationSummary | undefined;
88
+ }
89
+
90
+ const DEFAULT_PAGE_SIZE = 20;
91
+
92
+ /**
93
+ * Reactive list of past conversations backed by an OpenAI-style REST API.
94
+ *
95
+ * Owns its own pagination cursor and exposes `hasMore` / `fetchMore` so
96
+ * callers don't manage paging state themselves. The raw server response
97
+ * (nested `metadata.title`, unix-second timestamps, `has_more` / `last_id`
98
+ * envelope) is mapped to the simpler `ConversationSummary` shape inside this
99
+ * composable; consumers never see the raw response.
100
+ */
101
+ export function useConversations(
102
+ options: UseConversationsOptions,
103
+ ): UseConversationsReturn {
104
+ const http = options.httpClient;
105
+ const path = options.path;
106
+ const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE;
107
+
108
+ const conversations = ref<ConversationSummary[]>([]);
109
+ const inflightCount = shallowRef(0);
110
+ const isLoading = computed(() => inflightCount.value > 0);
111
+ const hasMore = shallowRef(false);
112
+ const error = shallowRef<Error | null>(null);
113
+ let nextCursor: string | null = null;
114
+
115
+ function beginRequest(): void {
116
+ inflightCount.value++;
117
+ error.value = null;
118
+ }
119
+
120
+ function endRequest(): void {
121
+ inflightCount.value--;
122
+ }
123
+
124
+ async function fetchPage(params: {
125
+ limit: number;
126
+ cursor: string | null;
127
+ }): Promise<ConversationPage> {
128
+ const query: Record<string, unknown> = {
129
+ limit: params.limit,
130
+ order: 'desc',
131
+ };
132
+ if (params.cursor) query.after = params.cursor;
133
+
134
+ const raw = await http.get<RawConversationList>(path, { params: query });
135
+ return mapConversationList(raw);
136
+ }
137
+
138
+ async function fetch(opts?: { limit?: number }): Promise<void> {
139
+ beginRequest();
140
+ try {
141
+ const page = await fetchPage({
142
+ limit: opts?.limit ?? pageSize,
143
+ cursor: null,
144
+ });
145
+ conversations.value = page.items;
146
+ nextCursor = page.nextCursor;
147
+ hasMore.value = page.nextCursor !== null;
148
+ } catch (e) {
149
+ error.value = toError(e);
150
+ throw error.value;
151
+ } finally {
152
+ endRequest();
153
+ }
154
+ }
155
+
156
+ async function fetchMore(): Promise<void> {
157
+ if (!hasMore.value || nextCursor === null) return;
158
+ beginRequest();
159
+ try {
160
+ const page = await fetchPage({ limit: pageSize, cursor: nextCursor });
161
+ conversations.value = [...conversations.value, ...page.items];
162
+ nextCursor = page.nextCursor;
163
+ hasMore.value = page.nextCursor !== null;
164
+ } catch (e) {
165
+ error.value = toError(e);
166
+ throw error.value;
167
+ } finally {
168
+ endRequest();
169
+ }
170
+ }
171
+
172
+ async function fetchById(id: string): Promise<ConversationSummary> {
173
+ beginRequest();
174
+ try {
175
+ const raw = await http.get<RawConversation>(`${path}/${id}`);
176
+ return mapConversation(raw);
177
+ } catch (e) {
178
+ error.value = toError(e);
179
+ throw error.value;
180
+ } finally {
181
+ endRequest();
182
+ }
183
+ }
184
+
185
+ async function create(
186
+ input?: CreateConversationInput,
187
+ opts?: MutationOptions,
188
+ ): Promise<ConversationSummary> {
189
+ const metadata: Record<string, unknown> = { ...(input?.metadata ?? {}) };
190
+ if (input?.title !== undefined) metadata.title = input.title;
191
+ const body = { metadata };
192
+
193
+ const previous = conversations.value;
194
+ let placeholder: ConversationSummary | null = null;
195
+
196
+ if (opts?.optimistic) {
197
+ placeholder = makePlaceholder(input?.title);
198
+ conversations.value = [placeholder, ...previous];
199
+ }
200
+
201
+ beginRequest();
202
+ try {
203
+ const raw = await http.post<RawConversation>(path, body);
204
+ const created = mapConversation(raw);
205
+
206
+ conversations.value = placeholder
207
+ ? conversations.value.map((c) =>
208
+ c.id === placeholder!.id ? created : c,
209
+ )
210
+ : [created, ...conversations.value];
211
+
212
+ return created;
213
+ } catch (e) {
214
+ if (placeholder) conversations.value = previous;
215
+ error.value = toError(e);
216
+ throw error.value;
217
+ } finally {
218
+ endRequest();
219
+ }
220
+ }
221
+
222
+ async function rename(
223
+ id: string,
224
+ title: string,
225
+ opts?: MutationOptions,
226
+ ): Promise<ConversationSummary> {
227
+ const previous = conversations.value;
228
+
229
+ if (opts?.optimistic) {
230
+ conversations.value = previous.map((c) =>
231
+ c.id === id ? { ...c, title, updatedAt: new Date() } : c,
232
+ );
233
+ }
234
+
235
+ beginRequest();
236
+ try {
237
+ const raw = await http.post<RawConversation>(`${path}/${id}`, {
238
+ metadata: { title },
239
+ });
240
+ const updated = mapConversation(raw);
241
+ conversations.value = conversations.value.map((c) =>
242
+ c.id === id ? updated : c,
243
+ );
244
+ return updated;
245
+ } catch (e) {
246
+ if (opts?.optimistic) conversations.value = previous;
247
+ error.value = toError(e);
248
+ throw error.value;
249
+ } finally {
250
+ endRequest();
251
+ }
252
+ }
253
+
254
+ async function remove(id: string, opts?: MutationOptions): Promise<void> {
255
+ const previous = conversations.value;
256
+
257
+ if (opts?.optimistic) {
258
+ conversations.value = previous.filter((c) => c.id !== id);
259
+ }
260
+
261
+ beginRequest();
262
+ try {
263
+ await http.delete(`${path}/${id}`);
264
+ if (!opts?.optimistic) {
265
+ conversations.value = previous.filter((c) => c.id !== id);
266
+ }
267
+ } catch (e) {
268
+ if (opts?.optimistic) conversations.value = previous;
269
+ error.value = toError(e);
270
+ throw error.value;
271
+ } finally {
272
+ endRequest();
273
+ }
274
+ }
275
+
276
+ function getById(id: string): ConversationSummary | undefined {
277
+ return conversations.value.find((c) => c.id === id);
278
+ }
279
+
280
+ return {
281
+ conversations: readonly(conversations) as Readonly<
282
+ Ref<readonly ConversationSummary[]>
283
+ >,
284
+ isLoading,
285
+ hasMore: readonly(hasMore),
286
+ error: readonly(error),
287
+ fetch,
288
+ fetchMore,
289
+ fetchById,
290
+ create,
291
+ rename,
292
+ remove,
293
+ getById,
294
+ };
295
+ }
296
+
297
+ function mapConversation(raw: RawConversation): ConversationSummary {
298
+ return {
299
+ id: raw.id,
300
+ title: raw.metadata?.title ?? '',
301
+ createdAt: secondsToDate(raw.created_at),
302
+ updatedAt: secondsToDate(raw.updated_at),
303
+ };
304
+ }
305
+
306
+ function mapConversationList(raw: RawConversationList): ConversationPage {
307
+ return {
308
+ items: raw.data.map(mapConversation),
309
+ nextCursor: raw.has_more ? (raw.last_id ?? null) : null,
310
+ };
311
+ }
312
+
313
+ function makePlaceholder(title: string | undefined): ConversationSummary {
314
+ const now = new Date();
315
+ return {
316
+ id: `pending-${generateId()}`,
317
+ title: title ?? '',
318
+ createdAt: now,
319
+ updatedAt: now,
320
+ };
321
+ }
@@ -0,0 +1,9 @@
1
+ export { useMessageHandler } from './useMessageHandler';
2
+ export { useMarkdownParser } from './useMarkdownParser';
3
+ export {
4
+ provideChatKitLabels,
5
+ useChatKitLabels,
6
+ defaultChatKitLabels,
7
+ CHAT_KIT_LABELS,
8
+ } from './useChatKitLabels';
9
+ export type { ChatKitLabels } from './useChatKitLabels';
@@ -0,0 +1,290 @@
1
+ /* eslint-disable vue/one-component-per-file */
2
+ import type { ChatKitLabels } from './useChatKitLabels';
3
+ import {
4
+ CHAT_KIT_LABELS,
5
+ defaultChatKitLabels,
6
+ provideChatKitLabels,
7
+ useChatKitLabels,
8
+ } from './useChatKitLabels';
9
+ import { render, screen } from '@testing-library/vue';
10
+ import {
11
+ type ComputedRef,
12
+ computed,
13
+ defineComponent,
14
+ h,
15
+ nextTick,
16
+ provide,
17
+ ref,
18
+ } from 'vue';
19
+
20
+ describe('useChatKitLabels', () => {
21
+ it('returns default labels when no provider exists', () => {
22
+ let result: ComputedRef<ChatKitLabels> | undefined;
23
+
24
+ const TestComponent = defineComponent({
25
+ setup() {
26
+ result = useChatKitLabels();
27
+ return () => h('div');
28
+ },
29
+ });
30
+
31
+ render(TestComponent);
32
+
33
+ expect(result!.value).toEqual(defaultChatKitLabels);
34
+ });
35
+
36
+ it('returns provided labels when a ref is used', () => {
37
+ const customLabels = ref<ChatKitLabels>({
38
+ agentAvatarAlt: 'Bot-Bild',
39
+ agentLoadingAvatarAlt: 'Bot lädt',
40
+ inputPlaceholder: 'Nachricht eingeben...',
41
+ copyMessageAction: 'Nachricht kopieren',
42
+ copyMessageActionCopied: 'Kopiert',
43
+ historyItemUntitled: 'Unbenannte Konversation',
44
+ historyItemActionAriaLabel: 'Konversationsoptionen',
45
+ historyItemEditAction: 'Bearbeiten',
46
+ historyItemDeleteAction: 'Löschen',
47
+ });
48
+
49
+ let result: ComputedRef<ChatKitLabels> | undefined;
50
+
51
+ const ChildComponent = defineComponent({
52
+ setup() {
53
+ result = useChatKitLabels();
54
+ return () => h('div');
55
+ },
56
+ });
57
+
58
+ const ParentComponent = defineComponent({
59
+ setup() {
60
+ provideChatKitLabels(customLabels);
61
+ return () => h(ChildComponent);
62
+ },
63
+ });
64
+
65
+ render(ParentComponent);
66
+
67
+ expect(result!.value).toEqual(customLabels.value);
68
+ });
69
+
70
+ it('merges partial labels with defaults', () => {
71
+ let result: ComputedRef<ChatKitLabels> | undefined;
72
+
73
+ const ChildComponent = defineComponent({
74
+ setup() {
75
+ result = useChatKitLabels();
76
+ return () => h('div');
77
+ },
78
+ });
79
+
80
+ const ParentComponent = defineComponent({
81
+ setup() {
82
+ provideChatKitLabels(() => ({ agentAvatarAlt: 'Agenten-Avatar' }));
83
+ return () => h(ChildComponent);
84
+ },
85
+ });
86
+
87
+ render(ParentComponent);
88
+
89
+ expect(result!.value).toEqual({
90
+ ...defaultChatKitLabels,
91
+ agentAvatarAlt: 'Agenten-Avatar',
92
+ });
93
+ });
94
+
95
+ it('uses the correct injection key', () => {
96
+ let result: ComputedRef<ChatKitLabels> | undefined;
97
+
98
+ const ChildComponent = defineComponent({
99
+ setup() {
100
+ result = useChatKitLabels();
101
+ return () => h('div');
102
+ },
103
+ });
104
+
105
+ const customLabels: ChatKitLabels = {
106
+ agentAvatarAlt: 'Custom alt',
107
+ agentLoadingAvatarAlt: 'Custom loading',
108
+ inputPlaceholder: 'Custom placeholder',
109
+ copyMessageAction: 'Custom copy',
110
+ copyMessageActionCopied: 'Custom copied',
111
+ historyItemUntitled: 'Custom untitled',
112
+ historyItemActionAriaLabel: 'Custom options',
113
+ historyItemEditAction: 'Custom edit',
114
+ historyItemDeleteAction: 'Custom delete',
115
+ };
116
+
117
+ const ParentComponent = defineComponent({
118
+ setup() {
119
+ provide(
120
+ CHAT_KIT_LABELS,
121
+ computed(() => customLabels),
122
+ );
123
+ return () => h(ChildComponent);
124
+ },
125
+ });
126
+
127
+ render(ParentComponent);
128
+
129
+ expect(result!.value).toEqual(customLabels);
130
+ });
131
+
132
+ it('reacts to label changes when a getter is provided', async () => {
133
+ let result: ComputedRef<ChatKitLabels> | undefined;
134
+
135
+ const locale = ref<'en' | 'de'>('en');
136
+
137
+ const translations = {
138
+ en: { agentAvatarAlt: 'Agent avatar' },
139
+ de: { agentAvatarAlt: 'Agenten-Avatar' },
140
+ };
141
+
142
+ const ChildComponent = defineComponent({
143
+ setup() {
144
+ result = useChatKitLabels();
145
+ return () => h('div');
146
+ },
147
+ });
148
+
149
+ const ParentComponent = defineComponent({
150
+ setup() {
151
+ provideChatKitLabels(() => translations[locale.value]);
152
+ return () => h(ChildComponent);
153
+ },
154
+ });
155
+
156
+ render(ParentComponent);
157
+
158
+ expect(result!.value.agentAvatarAlt).toBe('Agent avatar');
159
+
160
+ locale.value = 'de';
161
+ await nextTick();
162
+
163
+ expect(result!.value.agentAvatarAlt).toBe('Agenten-Avatar');
164
+ });
165
+
166
+ it('reacts to label changes when a ref is provided', async () => {
167
+ let result: ComputedRef<ChatKitLabels> | undefined;
168
+
169
+ const overrides = ref<Partial<ChatKitLabels>>({
170
+ inputPlaceholder: 'Type here...',
171
+ });
172
+
173
+ const ChildComponent = defineComponent({
174
+ setup() {
175
+ result = useChatKitLabels();
176
+ return () => h('div');
177
+ },
178
+ });
179
+
180
+ const ParentComponent = defineComponent({
181
+ setup() {
182
+ provideChatKitLabels(overrides);
183
+ return () => h(ChildComponent);
184
+ },
185
+ });
186
+
187
+ render(ParentComponent);
188
+
189
+ expect(result!.value.inputPlaceholder).toBe('Type here...');
190
+
191
+ overrides.value = { inputPlaceholder: 'Nachricht eingeben...' };
192
+ await nextTick();
193
+
194
+ expect(result!.value.inputPlaceholder).toBe('Nachricht eingeben...');
195
+ });
196
+
197
+ it('accepts a computed ref', () => {
198
+ let result: ComputedRef<ChatKitLabels> | undefined;
199
+
200
+ const locale = ref<'en' | 'de'>('en');
201
+ const labels = computed(() =>
202
+ locale.value === 'de'
203
+ ? { agentAvatarAlt: 'Agenten-Avatar' }
204
+ : { agentAvatarAlt: 'Agent avatar' },
205
+ );
206
+
207
+ const ChildComponent = defineComponent({
208
+ setup() {
209
+ result = useChatKitLabels();
210
+ return () => h('div');
211
+ },
212
+ });
213
+
214
+ const ParentComponent = defineComponent({
215
+ setup() {
216
+ provideChatKitLabels(labels);
217
+ return () => h(ChildComponent);
218
+ },
219
+ });
220
+
221
+ render(ParentComponent);
222
+
223
+ expect(result!.value.agentAvatarAlt).toBe('Agent avatar');
224
+ });
225
+
226
+ it('rejects plain objects at the type level', () => {
227
+ // @ts-expect-error — plain objects must not be accepted
228
+ provideChatKitLabels({ agentAvatarAlt: 'nope' });
229
+ expect(true).toBe(true);
230
+ });
231
+
232
+ it('rejects plain strings at the type level', () => {
233
+ // @ts-expect-error — strings must not be accepted
234
+ provideChatKitLabels('nope');
235
+ expect(true).toBe(true);
236
+ });
237
+ });
238
+
239
+ describe('CkConversation uses injected labels', () => {
240
+ it('uses default agentAvatarAlt when no labels are provided', async () => {
241
+ const { default: CkConversation } =
242
+ await import('../components/composed/CkConversation/CkConversation.vue');
243
+
244
+ render(CkConversation, {
245
+ props: {
246
+ messages: [
247
+ {
248
+ id: '1',
249
+ role: 'assistant',
250
+ content: 'Hi',
251
+ createdAt: new Date(),
252
+ status: 'completed',
253
+ },
254
+ ],
255
+ agentAvatarImage: 'bot.png',
256
+ },
257
+ });
258
+
259
+ expect(screen.getByAltText('Agent avatar')).toBeInTheDocument();
260
+ });
261
+
262
+ it('uses injected agentAvatarAlt from provideChatKitLabels', async () => {
263
+ const { default: CkConversation } =
264
+ await import('../components/composed/CkConversation/CkConversation.vue');
265
+
266
+ const Wrapper = defineComponent({
267
+ setup() {
268
+ provideChatKitLabels(() => ({ agentAvatarAlt: 'Bot-Bild' }));
269
+ return () =>
270
+ h(CkConversation, {
271
+ messages: [
272
+ {
273
+ id: '1',
274
+ role: 'assistant',
275
+ content: 'Hi',
276
+ createdAt: new Date(),
277
+ status: 'completed',
278
+ },
279
+ ],
280
+ agentAvatarImage: 'bot.png',
281
+ });
282
+ },
283
+ });
284
+
285
+ render(Wrapper);
286
+
287
+ expect(screen.getByAltText('Bot-Bild')).toBeInTheDocument();
288
+ expect(screen.queryByAltText('Agent avatar')).not.toBeInTheDocument();
289
+ });
290
+ });
@@ -0,0 +1,96 @@
1
+ import type { ComputedRef, InjectionKey, Ref } from 'vue';
2
+ import { computed, inject, provide, toValue } from 'vue';
3
+
4
+ /**
5
+ * All user-facing strings in ChatKit that may need translation.
6
+ *
7
+ * Consumers override these values by calling `provideChatKitLabels()`
8
+ * at the top of their component tree and passing translated strings.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * import { provideChatKitLabels } from '@aleph-alpha/chat-kit';
13
+ *
14
+ * const { t } = useI18n();
15
+ * provideChatKitLabels(() => ({
16
+ * agentAvatarAlt: t('chat.agentAvatarAlt'),
17
+ * inputPlaceholder: t('chat.inputPlaceholder'),
18
+ * }));
19
+ * ```
20
+ */
21
+ export type ChatKitLabels = {
22
+ /** Alt text for the agent avatar image */
23
+ agentAvatarAlt: string;
24
+ /** Alt text for the loading avatar image */
25
+ agentLoadingAvatarAlt: string;
26
+ /** Placeholder text for the chat input field */
27
+ inputPlaceholder: string;
28
+ /** Accessible label / tooltip for the copy-message action button in its idle state */
29
+ copyMessageAction: string;
30
+ /** Accessible label / tooltip for the copy-message action button after a successful copy */
31
+ copyMessageActionCopied: string;
32
+ /** Fallback title used by `CkHistory` when a conversation has no `title`. */
33
+ historyItemUntitled: string;
34
+ /** Accessible name for the trailing kebab trigger on each `CkHistory` row. */
35
+ historyItemActionAriaLabel: string;
36
+ /** Label for the "Edit" action in the `CkHistory` row popover menu. */
37
+ historyItemEditAction: string;
38
+ /** Label for the "Delete" action in the `CkHistory` row popover menu. */
39
+ historyItemDeleteAction: string;
40
+ };
41
+
42
+ /** English defaults used when no labels are provided by the consumer. */
43
+ export const defaultChatKitLabels: ChatKitLabels = {
44
+ agentAvatarAlt: 'Agent avatar',
45
+ agentLoadingAvatarAlt: 'Agent loading',
46
+ inputPlaceholder: 'Type a message...',
47
+ copyMessageAction: 'Copy response',
48
+ copyMessageActionCopied: 'Copied!',
49
+ historyItemUntitled: 'Untitled conversation',
50
+ historyItemActionAriaLabel: 'Conversation options',
51
+ historyItemEditAction: 'Edit',
52
+ historyItemDeleteAction: 'Delete',
53
+ };
54
+
55
+ /** Vue injection key for ChatKit labels. */
56
+ export const CHAT_KIT_LABELS: InjectionKey<ComputedRef<ChatKitLabels>> =
57
+ Symbol('chat-kit-labels');
58
+
59
+ type PartialLabels = Partial<ChatKitLabels>;
60
+
61
+ /** Only accepts `Ref`, `ComputedRef`, or a getter returning `Partial<ChatKitLabels>`. */
62
+ type ReactiveLabels =
63
+ | Ref<PartialLabels>
64
+ | ComputedRef<PartialLabels>
65
+ | (() => PartialLabels);
66
+
67
+ /**
68
+ * Provide ChatKit labels to all descendant components.
69
+ * Call this in a parent component's `setup()` to override default English strings.
70
+ *
71
+ * Only reactive sources are accepted (`Ref`, `ComputedRef`, or a getter function)
72
+ * so that labels update automatically when the consumer's locale changes.
73
+ *
74
+ * Partial overrides are merged with the defaults so consumers only need to
75
+ * supply the keys they want to translate.
76
+ */
77
+ export function provideChatKitLabels(labels: ReactiveLabels): void {
78
+ provide(
79
+ CHAT_KIT_LABELS,
80
+ computed(() => ({ ...defaultChatKitLabels, ...toValue(labels) })),
81
+ );
82
+ }
83
+
84
+ /**
85
+ * Retrieve ChatKit labels inside any ChatKit component.
86
+ * Falls back to `defaultChatKitLabels` when no ancestor has provided labels.
87
+ *
88
+ * Returns a `ComputedRef` so that template bindings update automatically
89
+ * when the provided labels change (e.g. on locale switch).
90
+ */
91
+ export function useChatKitLabels(): ComputedRef<ChatKitLabels> {
92
+ return inject(
93
+ CHAT_KIT_LABELS,
94
+ computed(() => defaultChatKitLabels),
95
+ );
96
+ }