vanilla-agent 1.21.0 → 1.23.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/README.md +87 -0
- package/dist/index.cjs +28 -24
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +469 -30
- package/dist/index.d.ts +469 -30
- package/dist/index.global.js +60 -56
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +28 -24
- package/dist/index.js.map +1 -1
- package/dist/install.global.js +1 -1
- package/dist/install.global.js.map +1 -1
- package/dist/widget.css +409 -0
- package/package.json +2 -2
- package/src/client.ts +392 -3
- package/src/components/feedback.ts +377 -0
- package/src/components/message-bubble.ts +208 -4
- package/src/components/messages.ts +10 -3
- package/src/defaults.ts +15 -0
- package/src/index.ts +23 -3
- package/src/install.ts +69 -7
- package/src/session.ts +132 -4
- package/src/styles/widget.css +409 -0
- package/src/types.ts +209 -0
- package/src/ui.ts +121 -4
- package/src/utils/message-id.ts +35 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feedback UI components for CSAT and NPS collection
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type CSATFeedbackOptions = {
|
|
6
|
+
/** Callback when user submits CSAT feedback */
|
|
7
|
+
onSubmit: (rating: number, comment?: string) => void | Promise<void>;
|
|
8
|
+
/** Callback when user dismisses the feedback form */
|
|
9
|
+
onDismiss?: () => void;
|
|
10
|
+
/** Title text */
|
|
11
|
+
title?: string;
|
|
12
|
+
/** Subtitle/question text */
|
|
13
|
+
subtitle?: string;
|
|
14
|
+
/** Placeholder for optional comment field */
|
|
15
|
+
commentPlaceholder?: string;
|
|
16
|
+
/** Submit button text */
|
|
17
|
+
submitText?: string;
|
|
18
|
+
/** Skip button text */
|
|
19
|
+
skipText?: string;
|
|
20
|
+
/** Show comment field */
|
|
21
|
+
showComment?: boolean;
|
|
22
|
+
/** Rating labels (5 items for ratings 1-5) */
|
|
23
|
+
ratingLabels?: [string, string, string, string, string];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type NPSFeedbackOptions = {
|
|
27
|
+
/** Callback when user submits NPS feedback */
|
|
28
|
+
onSubmit: (rating: number, comment?: string) => void | Promise<void>;
|
|
29
|
+
/** Callback when user dismisses the feedback form */
|
|
30
|
+
onDismiss?: () => void;
|
|
31
|
+
/** Title text */
|
|
32
|
+
title?: string;
|
|
33
|
+
/** Subtitle/question text */
|
|
34
|
+
subtitle?: string;
|
|
35
|
+
/** Placeholder for optional comment field */
|
|
36
|
+
commentPlaceholder?: string;
|
|
37
|
+
/** Submit button text */
|
|
38
|
+
submitText?: string;
|
|
39
|
+
/** Skip button text */
|
|
40
|
+
skipText?: string;
|
|
41
|
+
/** Show comment field */
|
|
42
|
+
showComment?: boolean;
|
|
43
|
+
/** Low label (left side) */
|
|
44
|
+
lowLabel?: string;
|
|
45
|
+
/** High label (right side) */
|
|
46
|
+
highLabel?: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const defaultCSATLabels: [string, string, string, string, string] = [
|
|
50
|
+
'Very dissatisfied',
|
|
51
|
+
'Dissatisfied',
|
|
52
|
+
'Neutral',
|
|
53
|
+
'Satisfied',
|
|
54
|
+
'Very satisfied'
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Create a CSAT (Customer Satisfaction) feedback form
|
|
59
|
+
* Rating scale: 1-5
|
|
60
|
+
*/
|
|
61
|
+
export function createCSATFeedback(options: CSATFeedbackOptions): HTMLElement {
|
|
62
|
+
const {
|
|
63
|
+
onSubmit,
|
|
64
|
+
onDismiss,
|
|
65
|
+
title = 'How satisfied are you?',
|
|
66
|
+
subtitle = 'Please rate your experience',
|
|
67
|
+
commentPlaceholder = 'Share your thoughts (optional)...',
|
|
68
|
+
submitText = 'Submit',
|
|
69
|
+
skipText = 'Skip',
|
|
70
|
+
showComment = true,
|
|
71
|
+
ratingLabels = defaultCSATLabels,
|
|
72
|
+
} = options;
|
|
73
|
+
|
|
74
|
+
const container = document.createElement('div');
|
|
75
|
+
container.className = 'tvw-feedback-container tvw-feedback-csat';
|
|
76
|
+
container.setAttribute('role', 'dialog');
|
|
77
|
+
container.setAttribute('aria-label', 'Customer satisfaction feedback');
|
|
78
|
+
|
|
79
|
+
let selectedRating: number | null = null;
|
|
80
|
+
|
|
81
|
+
// Create inner content
|
|
82
|
+
const content = document.createElement('div');
|
|
83
|
+
content.className = 'tvw-feedback-content';
|
|
84
|
+
|
|
85
|
+
// Header
|
|
86
|
+
const header = document.createElement('div');
|
|
87
|
+
header.className = 'tvw-feedback-header';
|
|
88
|
+
|
|
89
|
+
const titleEl = document.createElement('h3');
|
|
90
|
+
titleEl.className = 'tvw-feedback-title';
|
|
91
|
+
titleEl.textContent = title;
|
|
92
|
+
header.appendChild(titleEl);
|
|
93
|
+
|
|
94
|
+
const subtitleEl = document.createElement('p');
|
|
95
|
+
subtitleEl.className = 'tvw-feedback-subtitle';
|
|
96
|
+
subtitleEl.textContent = subtitle;
|
|
97
|
+
header.appendChild(subtitleEl);
|
|
98
|
+
|
|
99
|
+
content.appendChild(header);
|
|
100
|
+
|
|
101
|
+
// Rating buttons (1-5 stars or numbers)
|
|
102
|
+
const ratingContainer = document.createElement('div');
|
|
103
|
+
ratingContainer.className = 'tvw-feedback-rating tvw-feedback-rating-csat';
|
|
104
|
+
ratingContainer.setAttribute('role', 'radiogroup');
|
|
105
|
+
ratingContainer.setAttribute('aria-label', 'Satisfaction rating from 1 to 5');
|
|
106
|
+
|
|
107
|
+
const ratingButtons: HTMLButtonElement[] = [];
|
|
108
|
+
|
|
109
|
+
for (let i = 1; i <= 5; i++) {
|
|
110
|
+
const ratingButton = document.createElement('button');
|
|
111
|
+
ratingButton.type = 'button';
|
|
112
|
+
ratingButton.className = 'tvw-feedback-rating-btn tvw-feedback-star-btn';
|
|
113
|
+
ratingButton.setAttribute('role', 'radio');
|
|
114
|
+
ratingButton.setAttribute('aria-checked', 'false');
|
|
115
|
+
ratingButton.setAttribute('aria-label', `${i} star${i > 1 ? 's' : ''}: ${ratingLabels[i - 1]}`);
|
|
116
|
+
ratingButton.title = ratingLabels[i - 1];
|
|
117
|
+
ratingButton.dataset.rating = String(i);
|
|
118
|
+
|
|
119
|
+
// Star icon (filled when selected)
|
|
120
|
+
ratingButton.innerHTML = `
|
|
121
|
+
<svg class="tvw-feedback-star" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
122
|
+
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
|
|
123
|
+
</svg>
|
|
124
|
+
`;
|
|
125
|
+
|
|
126
|
+
ratingButton.addEventListener('click', () => {
|
|
127
|
+
selectedRating = i;
|
|
128
|
+
ratingButtons.forEach((btn, index) => {
|
|
129
|
+
const isSelected = index < i;
|
|
130
|
+
btn.classList.toggle('selected', isSelected);
|
|
131
|
+
btn.setAttribute('aria-checked', index === i - 1 ? 'true' : 'false');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
ratingButtons.push(ratingButton);
|
|
136
|
+
ratingContainer.appendChild(ratingButton);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
content.appendChild(ratingContainer);
|
|
140
|
+
|
|
141
|
+
// Comment field
|
|
142
|
+
let commentTextarea: HTMLTextAreaElement | null = null;
|
|
143
|
+
if (showComment) {
|
|
144
|
+
const commentContainer = document.createElement('div');
|
|
145
|
+
commentContainer.className = 'tvw-feedback-comment-container';
|
|
146
|
+
|
|
147
|
+
commentTextarea = document.createElement('textarea');
|
|
148
|
+
commentTextarea.className = 'tvw-feedback-comment';
|
|
149
|
+
commentTextarea.placeholder = commentPlaceholder;
|
|
150
|
+
commentTextarea.rows = 3;
|
|
151
|
+
commentTextarea.setAttribute('aria-label', 'Additional comments');
|
|
152
|
+
|
|
153
|
+
commentContainer.appendChild(commentTextarea);
|
|
154
|
+
content.appendChild(commentContainer);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Action buttons
|
|
158
|
+
const actions = document.createElement('div');
|
|
159
|
+
actions.className = 'tvw-feedback-actions';
|
|
160
|
+
|
|
161
|
+
const skipButton = document.createElement('button');
|
|
162
|
+
skipButton.type = 'button';
|
|
163
|
+
skipButton.className = 'tvw-feedback-btn tvw-feedback-btn-skip';
|
|
164
|
+
skipButton.textContent = skipText;
|
|
165
|
+
skipButton.addEventListener('click', () => {
|
|
166
|
+
onDismiss?.();
|
|
167
|
+
container.remove();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const submitButton = document.createElement('button');
|
|
171
|
+
submitButton.type = 'button';
|
|
172
|
+
submitButton.className = 'tvw-feedback-btn tvw-feedback-btn-submit';
|
|
173
|
+
submitButton.textContent = submitText;
|
|
174
|
+
submitButton.addEventListener('click', async () => {
|
|
175
|
+
if (selectedRating === null) {
|
|
176
|
+
// Shake the rating container to indicate selection required
|
|
177
|
+
ratingContainer.classList.add('tvw-feedback-shake');
|
|
178
|
+
setTimeout(() => ratingContainer.classList.remove('tvw-feedback-shake'), 500);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
submitButton.disabled = true;
|
|
183
|
+
submitButton.textContent = 'Submitting...';
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
const comment = commentTextarea?.value.trim() || undefined;
|
|
187
|
+
await onSubmit(selectedRating, comment);
|
|
188
|
+
container.remove();
|
|
189
|
+
} catch (error) {
|
|
190
|
+
submitButton.disabled = false;
|
|
191
|
+
submitButton.textContent = submitText;
|
|
192
|
+
// eslint-disable-next-line no-console
|
|
193
|
+
console.error('[CSAT Feedback] Failed to submit:', error);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
actions.appendChild(skipButton);
|
|
198
|
+
actions.appendChild(submitButton);
|
|
199
|
+
content.appendChild(actions);
|
|
200
|
+
|
|
201
|
+
container.appendChild(content);
|
|
202
|
+
|
|
203
|
+
return container;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Create an NPS (Net Promoter Score) feedback form
|
|
208
|
+
* Rating scale: 0-10
|
|
209
|
+
*/
|
|
210
|
+
export function createNPSFeedback(options: NPSFeedbackOptions): HTMLElement {
|
|
211
|
+
const {
|
|
212
|
+
onSubmit,
|
|
213
|
+
onDismiss,
|
|
214
|
+
title = 'How likely are you to recommend us?',
|
|
215
|
+
subtitle = 'On a scale of 0 to 10',
|
|
216
|
+
commentPlaceholder = 'What could we do better? (optional)...',
|
|
217
|
+
submitText = 'Submit',
|
|
218
|
+
skipText = 'Skip',
|
|
219
|
+
showComment = true,
|
|
220
|
+
lowLabel = 'Not likely',
|
|
221
|
+
highLabel = 'Very likely',
|
|
222
|
+
} = options;
|
|
223
|
+
|
|
224
|
+
const container = document.createElement('div');
|
|
225
|
+
container.className = 'tvw-feedback-container tvw-feedback-nps';
|
|
226
|
+
container.setAttribute('role', 'dialog');
|
|
227
|
+
container.setAttribute('aria-label', 'Net Promoter Score feedback');
|
|
228
|
+
|
|
229
|
+
let selectedRating: number | null = null;
|
|
230
|
+
|
|
231
|
+
// Create inner content
|
|
232
|
+
const content = document.createElement('div');
|
|
233
|
+
content.className = 'tvw-feedback-content';
|
|
234
|
+
|
|
235
|
+
// Header
|
|
236
|
+
const header = document.createElement('div');
|
|
237
|
+
header.className = 'tvw-feedback-header';
|
|
238
|
+
|
|
239
|
+
const titleEl = document.createElement('h3');
|
|
240
|
+
titleEl.className = 'tvw-feedback-title';
|
|
241
|
+
titleEl.textContent = title;
|
|
242
|
+
header.appendChild(titleEl);
|
|
243
|
+
|
|
244
|
+
const subtitleEl = document.createElement('p');
|
|
245
|
+
subtitleEl.className = 'tvw-feedback-subtitle';
|
|
246
|
+
subtitleEl.textContent = subtitle;
|
|
247
|
+
header.appendChild(subtitleEl);
|
|
248
|
+
|
|
249
|
+
content.appendChild(header);
|
|
250
|
+
|
|
251
|
+
// Rating buttons (0-10)
|
|
252
|
+
const ratingContainer = document.createElement('div');
|
|
253
|
+
ratingContainer.className = 'tvw-feedback-rating tvw-feedback-rating-nps';
|
|
254
|
+
ratingContainer.setAttribute('role', 'radiogroup');
|
|
255
|
+
ratingContainer.setAttribute('aria-label', 'Likelihood rating from 0 to 10');
|
|
256
|
+
|
|
257
|
+
// Labels row
|
|
258
|
+
const labelsRow = document.createElement('div');
|
|
259
|
+
labelsRow.className = 'tvw-feedback-labels';
|
|
260
|
+
|
|
261
|
+
const lowLabelEl = document.createElement('span');
|
|
262
|
+
lowLabelEl.className = 'tvw-feedback-label-low';
|
|
263
|
+
lowLabelEl.textContent = lowLabel;
|
|
264
|
+
|
|
265
|
+
const highLabelEl = document.createElement('span');
|
|
266
|
+
highLabelEl.className = 'tvw-feedback-label-high';
|
|
267
|
+
highLabelEl.textContent = highLabel;
|
|
268
|
+
|
|
269
|
+
labelsRow.appendChild(lowLabelEl);
|
|
270
|
+
labelsRow.appendChild(highLabelEl);
|
|
271
|
+
|
|
272
|
+
// Numbers row
|
|
273
|
+
const numbersRow = document.createElement('div');
|
|
274
|
+
numbersRow.className = 'tvw-feedback-numbers';
|
|
275
|
+
|
|
276
|
+
const ratingButtons: HTMLButtonElement[] = [];
|
|
277
|
+
|
|
278
|
+
for (let i = 0; i <= 10; i++) {
|
|
279
|
+
const ratingButton = document.createElement('button');
|
|
280
|
+
ratingButton.type = 'button';
|
|
281
|
+
ratingButton.className = 'tvw-feedback-rating-btn tvw-feedback-number-btn';
|
|
282
|
+
ratingButton.setAttribute('role', 'radio');
|
|
283
|
+
ratingButton.setAttribute('aria-checked', 'false');
|
|
284
|
+
ratingButton.setAttribute('aria-label', `Rating ${i} out of 10`);
|
|
285
|
+
ratingButton.textContent = String(i);
|
|
286
|
+
ratingButton.dataset.rating = String(i);
|
|
287
|
+
|
|
288
|
+
// Color coding: detractors (0-6), passives (7-8), promoters (9-10)
|
|
289
|
+
if (i <= 6) {
|
|
290
|
+
ratingButton.classList.add('tvw-feedback-detractor');
|
|
291
|
+
} else if (i <= 8) {
|
|
292
|
+
ratingButton.classList.add('tvw-feedback-passive');
|
|
293
|
+
} else {
|
|
294
|
+
ratingButton.classList.add('tvw-feedback-promoter');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
ratingButton.addEventListener('click', () => {
|
|
298
|
+
selectedRating = i;
|
|
299
|
+
ratingButtons.forEach((btn, index) => {
|
|
300
|
+
btn.classList.toggle('selected', index === i);
|
|
301
|
+
btn.setAttribute('aria-checked', index === i ? 'true' : 'false');
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
ratingButtons.push(ratingButton);
|
|
306
|
+
numbersRow.appendChild(ratingButton);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
ratingContainer.appendChild(labelsRow);
|
|
310
|
+
ratingContainer.appendChild(numbersRow);
|
|
311
|
+
content.appendChild(ratingContainer);
|
|
312
|
+
|
|
313
|
+
// Comment field
|
|
314
|
+
let commentTextarea: HTMLTextAreaElement | null = null;
|
|
315
|
+
if (showComment) {
|
|
316
|
+
const commentContainer = document.createElement('div');
|
|
317
|
+
commentContainer.className = 'tvw-feedback-comment-container';
|
|
318
|
+
|
|
319
|
+
commentTextarea = document.createElement('textarea');
|
|
320
|
+
commentTextarea.className = 'tvw-feedback-comment';
|
|
321
|
+
commentTextarea.placeholder = commentPlaceholder;
|
|
322
|
+
commentTextarea.rows = 3;
|
|
323
|
+
commentTextarea.setAttribute('aria-label', 'Additional comments');
|
|
324
|
+
|
|
325
|
+
commentContainer.appendChild(commentTextarea);
|
|
326
|
+
content.appendChild(commentContainer);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Action buttons
|
|
330
|
+
const actions = document.createElement('div');
|
|
331
|
+
actions.className = 'tvw-feedback-actions';
|
|
332
|
+
|
|
333
|
+
const skipButton = document.createElement('button');
|
|
334
|
+
skipButton.type = 'button';
|
|
335
|
+
skipButton.className = 'tvw-feedback-btn tvw-feedback-btn-skip';
|
|
336
|
+
skipButton.textContent = skipText;
|
|
337
|
+
skipButton.addEventListener('click', () => {
|
|
338
|
+
onDismiss?.();
|
|
339
|
+
container.remove();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
const submitButton = document.createElement('button');
|
|
343
|
+
submitButton.type = 'button';
|
|
344
|
+
submitButton.className = 'tvw-feedback-btn tvw-feedback-btn-submit';
|
|
345
|
+
submitButton.textContent = submitText;
|
|
346
|
+
submitButton.addEventListener('click', async () => {
|
|
347
|
+
if (selectedRating === null) {
|
|
348
|
+
// Shake the rating container to indicate selection required
|
|
349
|
+
numbersRow.classList.add('tvw-feedback-shake');
|
|
350
|
+
setTimeout(() => numbersRow.classList.remove('tvw-feedback-shake'), 500);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
submitButton.disabled = true;
|
|
355
|
+
submitButton.textContent = 'Submitting...';
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
const comment = commentTextarea?.value.trim() || undefined;
|
|
359
|
+
await onSubmit(selectedRating, comment);
|
|
360
|
+
container.remove();
|
|
361
|
+
} catch (error) {
|
|
362
|
+
submitButton.disabled = false;
|
|
363
|
+
submitButton.textContent = submitText;
|
|
364
|
+
// eslint-disable-next-line no-console
|
|
365
|
+
console.error('[NPS Feedback] Failed to submit:', error);
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
actions.appendChild(skipButton);
|
|
370
|
+
actions.appendChild(submitButton);
|
|
371
|
+
content.appendChild(actions);
|
|
372
|
+
|
|
373
|
+
container.appendChild(content);
|
|
374
|
+
|
|
375
|
+
return container;
|
|
376
|
+
}
|
|
377
|
+
|
|
@@ -3,8 +3,11 @@ import {
|
|
|
3
3
|
AgentWidgetMessage,
|
|
4
4
|
AgentWidgetMessageLayoutConfig,
|
|
5
5
|
AgentWidgetAvatarConfig,
|
|
6
|
-
AgentWidgetTimestampConfig
|
|
6
|
+
AgentWidgetTimestampConfig,
|
|
7
|
+
AgentWidgetMessageActionsConfig,
|
|
8
|
+
AgentWidgetMessageFeedback
|
|
7
9
|
} from "../types";
|
|
10
|
+
import { renderLucideIcon } from "../utils/icons";
|
|
8
11
|
|
|
9
12
|
export type MessageTransform = (context: {
|
|
10
13
|
text: string;
|
|
@@ -13,6 +16,11 @@ export type MessageTransform = (context: {
|
|
|
13
16
|
raw?: string;
|
|
14
17
|
}) => string;
|
|
15
18
|
|
|
19
|
+
export type MessageActionCallbacks = {
|
|
20
|
+
onCopy?: (message: AgentWidgetMessage) => void;
|
|
21
|
+
onFeedback?: (feedback: AgentWidgetMessageFeedback) => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
16
24
|
// Create typing indicator element
|
|
17
25
|
export const createTypingIndicator = (): HTMLElement => {
|
|
18
26
|
const container = document.createElement("div");
|
|
@@ -204,6 +212,185 @@ const getBubbleClasses = (
|
|
|
204
212
|
return baseClasses;
|
|
205
213
|
};
|
|
206
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Create message action buttons (copy, upvote, downvote)
|
|
217
|
+
*/
|
|
218
|
+
export const createMessageActions = (
|
|
219
|
+
message: AgentWidgetMessage,
|
|
220
|
+
actionsConfig: AgentWidgetMessageActionsConfig,
|
|
221
|
+
callbacks?: MessageActionCallbacks
|
|
222
|
+
): HTMLElement => {
|
|
223
|
+
const showCopy = actionsConfig.showCopy ?? true;
|
|
224
|
+
const showUpvote = actionsConfig.showUpvote ?? true;
|
|
225
|
+
const showDownvote = actionsConfig.showDownvote ?? true;
|
|
226
|
+
const visibility = actionsConfig.visibility ?? "hover";
|
|
227
|
+
const align = actionsConfig.align ?? "right";
|
|
228
|
+
const layout = actionsConfig.layout ?? "pill-inside";
|
|
229
|
+
|
|
230
|
+
// Map alignment to CSS class
|
|
231
|
+
const alignClass = {
|
|
232
|
+
left: "tvw-message-actions-left",
|
|
233
|
+
center: "tvw-message-actions-center",
|
|
234
|
+
right: "tvw-message-actions-right",
|
|
235
|
+
}[align];
|
|
236
|
+
|
|
237
|
+
// Map layout to CSS class
|
|
238
|
+
const layoutClass = {
|
|
239
|
+
"pill-inside": "tvw-message-actions-pill",
|
|
240
|
+
"row-inside": "tvw-message-actions-row",
|
|
241
|
+
}[layout];
|
|
242
|
+
|
|
243
|
+
const container = createElement(
|
|
244
|
+
"div",
|
|
245
|
+
`tvw-message-actions tvw-flex tvw-items-center tvw-gap-1 tvw-mt-2 ${alignClass} ${layoutClass} ${
|
|
246
|
+
visibility === "hover" ? "tvw-message-actions-hover" : ""
|
|
247
|
+
}`
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
// Track vote state for this message
|
|
251
|
+
let currentVote: "upvote" | "downvote" | null = null;
|
|
252
|
+
|
|
253
|
+
const createActionButton = (
|
|
254
|
+
iconName: string,
|
|
255
|
+
label: string,
|
|
256
|
+
onClick: () => void,
|
|
257
|
+
dataAction?: string
|
|
258
|
+
): HTMLButtonElement => {
|
|
259
|
+
const button = document.createElement("button");
|
|
260
|
+
button.className = "tvw-message-action-btn";
|
|
261
|
+
button.setAttribute("aria-label", label);
|
|
262
|
+
button.setAttribute("title", label);
|
|
263
|
+
if (dataAction) {
|
|
264
|
+
button.setAttribute("data-action", dataAction);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const icon = renderLucideIcon(iconName, 14, "currentColor", 2);
|
|
268
|
+
if (icon) {
|
|
269
|
+
button.appendChild(icon);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
button.addEventListener("click", (e) => {
|
|
273
|
+
e.preventDefault();
|
|
274
|
+
e.stopPropagation();
|
|
275
|
+
onClick();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
return button;
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// Copy button
|
|
282
|
+
if (showCopy) {
|
|
283
|
+
const copyButton = createActionButton("copy", "Copy message", () => {
|
|
284
|
+
// Copy to clipboard
|
|
285
|
+
const textToCopy = message.content || "";
|
|
286
|
+
navigator.clipboard.writeText(textToCopy).then(() => {
|
|
287
|
+
// Show success feedback - swap icon temporarily
|
|
288
|
+
copyButton.classList.add("tvw-message-action-success");
|
|
289
|
+
const checkIcon = renderLucideIcon("check", 14, "currentColor", 2);
|
|
290
|
+
if (checkIcon) {
|
|
291
|
+
copyButton.innerHTML = "";
|
|
292
|
+
copyButton.appendChild(checkIcon);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Restore original icon after 2 seconds
|
|
296
|
+
setTimeout(() => {
|
|
297
|
+
copyButton.classList.remove("tvw-message-action-success");
|
|
298
|
+
const originalIcon = renderLucideIcon("copy", 14, "currentColor", 2);
|
|
299
|
+
if (originalIcon) {
|
|
300
|
+
copyButton.innerHTML = "";
|
|
301
|
+
copyButton.appendChild(originalIcon);
|
|
302
|
+
}
|
|
303
|
+
}, 2000);
|
|
304
|
+
}).catch((err) => {
|
|
305
|
+
if (typeof console !== "undefined") {
|
|
306
|
+
console.error("[AgentWidget] Failed to copy message:", err);
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// Trigger callback
|
|
311
|
+
if (callbacks?.onCopy) {
|
|
312
|
+
callbacks.onCopy(message);
|
|
313
|
+
}
|
|
314
|
+
if (actionsConfig.onCopy) {
|
|
315
|
+
actionsConfig.onCopy(message);
|
|
316
|
+
}
|
|
317
|
+
}, "copy");
|
|
318
|
+
container.appendChild(copyButton);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Upvote button
|
|
322
|
+
if (showUpvote) {
|
|
323
|
+
const upvoteButton = createActionButton("thumbs-up", "Upvote", () => {
|
|
324
|
+
const wasActive = currentVote === "upvote";
|
|
325
|
+
|
|
326
|
+
// Toggle state
|
|
327
|
+
if (wasActive) {
|
|
328
|
+
currentVote = null;
|
|
329
|
+
upvoteButton.classList.remove("tvw-message-action-active");
|
|
330
|
+
} else {
|
|
331
|
+
// Remove downvote if active
|
|
332
|
+
const downvoteBtn = container.querySelector('[data-action="downvote"]');
|
|
333
|
+
if (downvoteBtn) {
|
|
334
|
+
downvoteBtn.classList.remove("tvw-message-action-active");
|
|
335
|
+
}
|
|
336
|
+
currentVote = "upvote";
|
|
337
|
+
upvoteButton.classList.add("tvw-message-action-active");
|
|
338
|
+
|
|
339
|
+
// Trigger feedback
|
|
340
|
+
const feedback: AgentWidgetMessageFeedback = {
|
|
341
|
+
type: "upvote",
|
|
342
|
+
messageId: message.id,
|
|
343
|
+
message
|
|
344
|
+
};
|
|
345
|
+
if (callbacks?.onFeedback) {
|
|
346
|
+
callbacks.onFeedback(feedback);
|
|
347
|
+
}
|
|
348
|
+
if (actionsConfig.onFeedback) {
|
|
349
|
+
actionsConfig.onFeedback(feedback);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}, "upvote");
|
|
353
|
+
container.appendChild(upvoteButton);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Downvote button
|
|
357
|
+
if (showDownvote) {
|
|
358
|
+
const downvoteButton = createActionButton("thumbs-down", "Downvote", () => {
|
|
359
|
+
const wasActive = currentVote === "downvote";
|
|
360
|
+
|
|
361
|
+
// Toggle state
|
|
362
|
+
if (wasActive) {
|
|
363
|
+
currentVote = null;
|
|
364
|
+
downvoteButton.classList.remove("tvw-message-action-active");
|
|
365
|
+
} else {
|
|
366
|
+
// Remove upvote if active
|
|
367
|
+
const upvoteBtn = container.querySelector('[data-action="upvote"]');
|
|
368
|
+
if (upvoteBtn) {
|
|
369
|
+
upvoteBtn.classList.remove("tvw-message-action-active");
|
|
370
|
+
}
|
|
371
|
+
currentVote = "downvote";
|
|
372
|
+
downvoteButton.classList.add("tvw-message-action-active");
|
|
373
|
+
|
|
374
|
+
// Trigger feedback
|
|
375
|
+
const feedback: AgentWidgetMessageFeedback = {
|
|
376
|
+
type: "downvote",
|
|
377
|
+
messageId: message.id,
|
|
378
|
+
message
|
|
379
|
+
};
|
|
380
|
+
if (callbacks?.onFeedback) {
|
|
381
|
+
callbacks.onFeedback(feedback);
|
|
382
|
+
}
|
|
383
|
+
if (actionsConfig.onFeedback) {
|
|
384
|
+
actionsConfig.onFeedback(feedback);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}, "downvote");
|
|
388
|
+
container.appendChild(downvoteButton);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return container;
|
|
392
|
+
};
|
|
393
|
+
|
|
207
394
|
/**
|
|
208
395
|
* Create standard message bubble
|
|
209
396
|
* Supports layout configuration for avatars, timestamps, and visual presets
|
|
@@ -211,7 +398,9 @@ const getBubbleClasses = (
|
|
|
211
398
|
export const createStandardBubble = (
|
|
212
399
|
message: AgentWidgetMessage,
|
|
213
400
|
transform: MessageTransform,
|
|
214
|
-
layoutConfig?: AgentWidgetMessageLayoutConfig
|
|
401
|
+
layoutConfig?: AgentWidgetMessageLayoutConfig,
|
|
402
|
+
actionsConfig?: AgentWidgetMessageActionsConfig,
|
|
403
|
+
actionCallbacks?: MessageActionCallbacks
|
|
215
404
|
): HTMLElement => {
|
|
216
405
|
const config = layoutConfig ?? {};
|
|
217
406
|
const layout = config.layout ?? "bubble";
|
|
@@ -259,6 +448,19 @@ export const createStandardBubble = (
|
|
|
259
448
|
}
|
|
260
449
|
}
|
|
261
450
|
|
|
451
|
+
// Add message actions for assistant messages (only when not streaming and has content)
|
|
452
|
+
const shouldShowActions =
|
|
453
|
+
message.role === "assistant" &&
|
|
454
|
+
!message.streaming &&
|
|
455
|
+
message.content &&
|
|
456
|
+
message.content.trim() &&
|
|
457
|
+
actionsConfig?.enabled !== false;
|
|
458
|
+
|
|
459
|
+
if (shouldShowActions && actionsConfig) {
|
|
460
|
+
const actions = createMessageActions(message, actionsConfig, actionCallbacks);
|
|
461
|
+
bubble.appendChild(actions);
|
|
462
|
+
}
|
|
463
|
+
|
|
262
464
|
// If no avatar needed, return bubble directly
|
|
263
465
|
if (!showAvatar || message.role === "system") {
|
|
264
466
|
return bubble;
|
|
@@ -292,7 +494,9 @@ export const createStandardBubble = (
|
|
|
292
494
|
export const createBubbleWithLayout = (
|
|
293
495
|
message: AgentWidgetMessage,
|
|
294
496
|
transform: MessageTransform,
|
|
295
|
-
layoutConfig?: AgentWidgetMessageLayoutConfig
|
|
497
|
+
layoutConfig?: AgentWidgetMessageLayoutConfig,
|
|
498
|
+
actionsConfig?: AgentWidgetMessageActionsConfig,
|
|
499
|
+
actionCallbacks?: MessageActionCallbacks
|
|
296
500
|
): HTMLElement => {
|
|
297
501
|
const config = layoutConfig ?? {};
|
|
298
502
|
|
|
@@ -314,5 +518,5 @@ export const createBubbleWithLayout = (
|
|
|
314
518
|
}
|
|
315
519
|
|
|
316
520
|
// Fall back to standard bubble
|
|
317
|
-
return createStandardBubble(message, transform, layoutConfig);
|
|
521
|
+
return createStandardBubble(message, transform, layoutConfig, actionsConfig, actionCallbacks);
|
|
318
522
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createElement, createFragment } from "../utils/dom";
|
|
2
2
|
import { AgentWidgetMessage, AgentWidgetConfig } from "../types";
|
|
3
|
-
import { MessageTransform } from "./message-bubble";
|
|
3
|
+
import { MessageTransform, MessageActionCallbacks } from "./message-bubble";
|
|
4
4
|
import { createStandardBubble } from "./message-bubble";
|
|
5
5
|
import { createReasoningBubble } from "./reasoning-bubble";
|
|
6
6
|
import { createToolBubble } from "./tool-bubble";
|
|
@@ -11,7 +11,8 @@ export const renderMessages = (
|
|
|
11
11
|
transform: MessageTransform,
|
|
12
12
|
showReasoning: boolean,
|
|
13
13
|
showToolCalls: boolean,
|
|
14
|
-
config?: AgentWidgetConfig
|
|
14
|
+
config?: AgentWidgetConfig,
|
|
15
|
+
actionCallbacks?: MessageActionCallbacks
|
|
15
16
|
) => {
|
|
16
17
|
container.innerHTML = "";
|
|
17
18
|
const fragment = createFragment();
|
|
@@ -25,7 +26,13 @@ export const renderMessages = (
|
|
|
25
26
|
if (!showToolCalls) return;
|
|
26
27
|
bubble = createToolBubble(message, config);
|
|
27
28
|
} else {
|
|
28
|
-
bubble = createStandardBubble(
|
|
29
|
+
bubble = createStandardBubble(
|
|
30
|
+
message,
|
|
31
|
+
transform,
|
|
32
|
+
config?.layout?.messages,
|
|
33
|
+
config?.messageActions,
|
|
34
|
+
actionCallbacks
|
|
35
|
+
);
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
const wrapper = createElement("div", "tvw-flex");
|
package/src/defaults.ts
CHANGED
|
@@ -6,6 +6,8 @@ import type { AgentWidgetConfig } from "./types";
|
|
|
6
6
|
*/
|
|
7
7
|
export const DEFAULT_WIDGET_CONFIG: Partial<AgentWidgetConfig> = {
|
|
8
8
|
apiUrl: "http://localhost:43111/api/chat/dispatch",
|
|
9
|
+
// Client token mode defaults (optional, only used when clientToken is set)
|
|
10
|
+
clientToken: undefined,
|
|
9
11
|
theme: {
|
|
10
12
|
primary: "#111827",
|
|
11
13
|
accent: "#1d4ed8",
|
|
@@ -168,6 +170,15 @@ export const DEFAULT_WIDGET_CONFIG: Partial<AgentWidgetConfig> = {
|
|
|
168
170
|
},
|
|
169
171
|
disableDefaultStyles: false,
|
|
170
172
|
},
|
|
173
|
+
messageActions: {
|
|
174
|
+
enabled: true,
|
|
175
|
+
showCopy: true,
|
|
176
|
+
showUpvote: false, // Requires backend - disabled by default
|
|
177
|
+
showDownvote: false, // Requires backend - disabled by default
|
|
178
|
+
visibility: "hover",
|
|
179
|
+
align: "right",
|
|
180
|
+
layout: "pill-inside",
|
|
181
|
+
},
|
|
171
182
|
debug: false,
|
|
172
183
|
};
|
|
173
184
|
|
|
@@ -252,5 +263,9 @@ export function mergeWithDefaults(
|
|
|
252
263
|
...config.markdown?.options,
|
|
253
264
|
},
|
|
254
265
|
},
|
|
266
|
+
messageActions: {
|
|
267
|
+
...DEFAULT_WIDGET_CONFIG.messageActions,
|
|
268
|
+
...config.messageActions,
|
|
269
|
+
},
|
|
255
270
|
};
|
|
256
271
|
}
|