@cognigy/chat-components-vue 0.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/LICENSE +20 -0
- package/README.md +178 -0
- package/dist/assets/svg/ArrowBackIcon.vue.d.ts +9 -0
- package/dist/assets/svg/AudioPauseIcon.vue.d.ts +2 -0
- package/dist/assets/svg/AudioPlayIcon.vue.d.ts +2 -0
- package/dist/assets/svg/CloseIcon.vue.d.ts +2 -0
- package/dist/assets/svg/DownloadIcon.vue.d.ts +2 -0
- package/dist/assets/svg/VideoPlayIcon.vue.d.ts +9 -0
- package/dist/assets/svg/index.d.ts +7 -0
- package/dist/chat-components-vue.css +1 -0
- package/dist/chat-components-vue.js +9858 -0
- package/dist/components/Message.vue.d.ts +11 -0
- package/dist/components/common/ActionButton.vue.d.ts +59 -0
- package/dist/components/common/ActionButtons.vue.d.ts +36 -0
- package/dist/components/common/ChatBubble.vue.d.ts +22 -0
- package/dist/components/common/ChatEvent.vue.d.ts +20 -0
- package/dist/components/common/LinkIcon.vue.d.ts +2 -0
- package/dist/components/common/TypingIndicator.vue.d.ts +21 -0
- package/dist/components/common/Typography.vue.d.ts +38 -0
- package/dist/components/messages/AdaptiveCard.vue.d.ts +2 -0
- package/dist/components/messages/AudioMessage.vue.d.ts +5 -0
- package/dist/components/messages/DatePicker.vue.d.ts +2 -0
- package/dist/components/messages/FileMessage.vue.d.ts +2 -0
- package/dist/components/messages/Gallery.vue.d.ts +2 -0
- package/dist/components/messages/GalleryItem.vue.d.ts +7 -0
- package/dist/components/messages/ImageMessage.vue.d.ts +5 -0
- package/dist/components/messages/List.vue.d.ts +2 -0
- package/dist/components/messages/ListItem.vue.d.ts +16 -0
- package/dist/components/messages/TextMessage.vue.d.ts +15 -0
- package/dist/components/messages/TextWithButtons.vue.d.ts +2 -0
- package/dist/components/messages/VideoMessage.vue.d.ts +5 -0
- package/dist/composables/useChannelPayload.d.ts +47 -0
- package/dist/composables/useCollation.d.ts +47 -0
- package/dist/composables/useImageContext.d.ts +13 -0
- package/dist/composables/useMessageContext.d.ts +18 -0
- package/dist/composables/useSanitize.d.ts +8 -0
- package/dist/index.d.ts +33 -0
- package/dist/types/index.d.ts +275 -0
- package/dist/utils/helpers.d.ts +56 -0
- package/dist/utils/matcher.d.ts +20 -0
- package/dist/utils/sanitize.d.ts +28 -0
- package/dist/utils/theme.d.ts +18 -0
- package/package.json +94 -0
- package/src/assets/svg/ArrowBackIcon.vue +30 -0
- package/src/assets/svg/AudioPauseIcon.vue +20 -0
- package/src/assets/svg/AudioPlayIcon.vue +19 -0
- package/src/assets/svg/CloseIcon.vue +10 -0
- package/src/assets/svg/DownloadIcon.vue +10 -0
- package/src/assets/svg/VideoPlayIcon.vue +25 -0
- package/src/assets/svg/index.ts +7 -0
- package/src/components/Message.vue +152 -0
- package/src/components/common/ActionButton.vue +354 -0
- package/src/components/common/ActionButtons.vue +170 -0
- package/src/components/common/ChatBubble.vue +109 -0
- package/src/components/common/ChatEvent.vue +84 -0
- package/src/components/common/LinkIcon.vue +34 -0
- package/src/components/common/TypingIndicator.vue +202 -0
- package/src/components/common/Typography.vue +196 -0
- package/src/components/messages/AdaptiveCard.vue +292 -0
- package/src/components/messages/AudioMessage.vue +391 -0
- package/src/components/messages/DatePicker.vue +135 -0
- package/src/components/messages/FileMessage.vue +195 -0
- package/src/components/messages/Gallery.vue +296 -0
- package/src/components/messages/GalleryItem.vue +214 -0
- package/src/components/messages/ImageMessage.vue +368 -0
- package/src/components/messages/List.vue +149 -0
- package/src/components/messages/ListItem.vue +344 -0
- package/src/components/messages/TextMessage.vue +203 -0
- package/src/components/messages/TextWithButtons.vue +119 -0
- package/src/components/messages/VideoMessage.vue +343 -0
- package/src/composables/useChannelPayload.ts +101 -0
- package/src/composables/useCollation.ts +163 -0
- package/src/composables/useImageContext.ts +27 -0
- package/src/composables/useMessageContext.ts +41 -0
- package/src/composables/useSanitize.ts +25 -0
- package/src/index.ts +71 -0
- package/src/types/index.ts +373 -0
- package/src/utils/helpers.ts +164 -0
- package/src/utils/matcher.ts +283 -0
- package/src/utils/sanitize.ts +133 -0
- package/src/utils/theme.ts +58 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helper utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {IWebchatButton, IWebchatQuickReply} from '../types'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Gets the label for a button
|
|
9
|
+
* Returns "Call" for phone_number buttons without title
|
|
10
|
+
*/
|
|
11
|
+
export function getWebchatButtonLabel(
|
|
12
|
+
button: IWebchatButton | IWebchatQuickReply
|
|
13
|
+
): string | undefined {
|
|
14
|
+
const { title } = button
|
|
15
|
+
|
|
16
|
+
if (!title && 'type' in button && button.type === 'phone_number') {
|
|
17
|
+
return 'Call'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return title
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Interpolates a template string with replacements
|
|
25
|
+
* Example: interpolateString("{position} of {total}", { position: "1", total: "4" })
|
|
26
|
+
* Returns: "1 of 4"
|
|
27
|
+
*/
|
|
28
|
+
export function interpolateString(
|
|
29
|
+
template: string,
|
|
30
|
+
replacements: Record<string, string>
|
|
31
|
+
): string {
|
|
32
|
+
return template.replace(/{(\w+)}/g, (_, key) => {
|
|
33
|
+
return key in replacements ? replacements[key] : ''
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Generates a random ID with optional prefix
|
|
39
|
+
*/
|
|
40
|
+
export function getRandomId(prefix = ''): string {
|
|
41
|
+
const id = window?.crypto?.randomUUID?.() || Date.now().toString()
|
|
42
|
+
return prefix ? `${prefix}-${id}` : id.toString()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Move focus to the visually hidden focus target
|
|
47
|
+
* This prevents focus loss for keyboard users
|
|
48
|
+
*/
|
|
49
|
+
export function moveFocusToMessageFocusTarget(dataMessageId: string): void {
|
|
50
|
+
setTimeout(() => {
|
|
51
|
+
const focusElement = document.getElementById(`webchat-focus-target-${dataMessageId}`)
|
|
52
|
+
if (focusElement) {
|
|
53
|
+
focusElement.focus({ preventScroll: true })
|
|
54
|
+
}
|
|
55
|
+
}, 0)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Helper function that replaces URLs in a string with HTML anchor elements
|
|
60
|
+
* - Works with URLs starting with http/https, www., or just domain/subdomain
|
|
61
|
+
* - Will only match URLs at the beginning or following whitespace
|
|
62
|
+
* - Will not work with emails
|
|
63
|
+
*/
|
|
64
|
+
export function replaceUrlsWithHTMLanchorElem(text: string): string {
|
|
65
|
+
// Enhanced regex to capture URLs with parameters
|
|
66
|
+
const urlMatcherRegex =
|
|
67
|
+
/(^|\s)(\b(https?):\/\/([-A-Z0-9+&@$#/%?=~_|!:,.;\p{L}]*[-A-Z0-9+&$@#/%=~_|\p{L}]))/giu
|
|
68
|
+
|
|
69
|
+
return text.replace(urlMatcherRegex, (url) => {
|
|
70
|
+
return `<a href="${url}" target="_blank">${url}</a>`
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Sanitizes a URL for use in CSS background-image property
|
|
76
|
+
* Returns url("...") string or undefined if invalid
|
|
77
|
+
*/
|
|
78
|
+
export function getBackgroundImage(url: string): string | undefined {
|
|
79
|
+
if (!url) return undefined
|
|
80
|
+
|
|
81
|
+
// Remove control characters that could break CSS parsing
|
|
82
|
+
let sanitized = url.replace(/[\r\n\f]/g, '')
|
|
83
|
+
|
|
84
|
+
// If the string looks like an absolute URL (has a scheme), validate allowed protocols (http/https).
|
|
85
|
+
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(sanitized)) {
|
|
86
|
+
try {
|
|
87
|
+
const parsed = new URL(sanitized)
|
|
88
|
+
if (!/^https?:$/i.test(parsed.protocol)) {
|
|
89
|
+
return undefined
|
|
90
|
+
}
|
|
91
|
+
// Normalize absolute URLs
|
|
92
|
+
sanitized = parsed.href
|
|
93
|
+
} catch {
|
|
94
|
+
// URL constructor failed (invalid absolute URL). Reject.
|
|
95
|
+
return undefined
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Escape characters that could terminate or escape the quoted url("...") context.
|
|
100
|
+
sanitized = sanitized
|
|
101
|
+
.replace(/\\/g, '\\\\') // Escape backslashes first
|
|
102
|
+
.replace(/"/g, '\\"') // Escape double quotes
|
|
103
|
+
.replace(/\)/g, '\\)') // Escape closing parenthesis
|
|
104
|
+
|
|
105
|
+
return `url("${sanitized}")`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* File attachment helpers
|
|
110
|
+
*/
|
|
111
|
+
|
|
112
|
+
const ONE_MB = 1000000
|
|
113
|
+
const ONE_KB = 1000
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Extracts filename without extension
|
|
117
|
+
* Example: "document.pdf" → "document."
|
|
118
|
+
*/
|
|
119
|
+
export function getFileName(fileNameWithExtension: string): string {
|
|
120
|
+
const splitName = fileNameWithExtension.split('.')
|
|
121
|
+
if (splitName.length > 1) {
|
|
122
|
+
return `${splitName.slice(0, -1).join('.')}.`
|
|
123
|
+
} else {
|
|
124
|
+
// return full name here if it didn't have a file ending
|
|
125
|
+
return fileNameWithExtension
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Extracts file extension
|
|
131
|
+
* Example: "document.pdf" → "pdf"
|
|
132
|
+
*/
|
|
133
|
+
export function getFileExtension(fileNameWithExtension: string): string | null {
|
|
134
|
+
const splitName = fileNameWithExtension.split('.')
|
|
135
|
+
if (splitName.length > 1) {
|
|
136
|
+
return splitName.pop() || null
|
|
137
|
+
} else {
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Formats file size in MB or KB
|
|
144
|
+
* Example: 1500000 → "1.50 MB"
|
|
145
|
+
*/
|
|
146
|
+
export function getSizeLabel(size: number): string {
|
|
147
|
+
if (size > ONE_MB) {
|
|
148
|
+
return `${(size / ONE_MB).toFixed(2)} MB`
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return `${(size / ONE_KB).toFixed(2)} KB`
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Valid image MIME types for file attachments
|
|
156
|
+
*/
|
|
157
|
+
export const VALID_IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Checks if attachment is a valid image type
|
|
161
|
+
*/
|
|
162
|
+
export function isImageAttachment(mimeType: string): boolean {
|
|
163
|
+
return VALID_IMAGE_MIME_TYPES.includes(mimeType)
|
|
164
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message matcher system
|
|
3
|
+
* Determines which component to render based on message data structure
|
|
4
|
+
*
|
|
5
|
+
* This is the core of the data-driven architecture.
|
|
6
|
+
* The same matching rules as the React version.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { IMessage } from '@cognigy/socket-client'
|
|
10
|
+
import type { ChatConfig, MatchRule, MessagePlugin, MatchResult } from '../types'
|
|
11
|
+
import { isAdaptiveCardPayload } from '../types'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Check if message has channel payload
|
|
15
|
+
*/
|
|
16
|
+
export function getChannelPayload(message: IMessage, config?: ChatConfig) {
|
|
17
|
+
if (!message?.data?._cognigy) {
|
|
18
|
+
return undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const { _facebook, _webchat, _defaultPreview } = message.data._cognigy
|
|
22
|
+
|
|
23
|
+
// Check default preview first
|
|
24
|
+
const defaultPreviewEnabled = config?.settings?.widgetSettings?.enableDefaultPreview
|
|
25
|
+
if (defaultPreviewEnabled && _defaultPreview) {
|
|
26
|
+
return _defaultPreview
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Check messenger sync
|
|
30
|
+
const strictMessengerSync = config?.settings?.widgetSettings?.enableStrictMessengerSync
|
|
31
|
+
const shouldSyncWithFacebook = message.data._cognigy.syncWebchatWithFacebook
|
|
32
|
+
if (strictMessengerSync && shouldSyncWithFacebook && _facebook) {
|
|
33
|
+
return _facebook
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Return webchat or facebook as fallback
|
|
37
|
+
return _webchat || _facebook
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Check if text is only escape sequences (whitespace, newlines, etc.)
|
|
42
|
+
*/
|
|
43
|
+
function isOnlyEscapeSequence(text: string | null | undefined): boolean {
|
|
44
|
+
if (typeof text !== 'string') {
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const trimmed = text.trim()
|
|
49
|
+
return trimmed === '' || /^[\n\t\r\v\f\s]*$/.test(trimmed)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Default match rules for internal message types.
|
|
54
|
+
* These rules map message data structures to component names.
|
|
55
|
+
* Components are resolved by name lookup in Message.vue.
|
|
56
|
+
*/
|
|
57
|
+
export function createDefaultMatchRules(): MatchRule[] {
|
|
58
|
+
return [
|
|
59
|
+
// xApp submit
|
|
60
|
+
{
|
|
61
|
+
name: 'XAppSubmit',
|
|
62
|
+
match: (message) => {
|
|
63
|
+
return message?.data?._plugin?.type === 'x-app-submit'
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
// Webchat3Event
|
|
68
|
+
{
|
|
69
|
+
name: 'Webchat3Event',
|
|
70
|
+
match: (message) => {
|
|
71
|
+
return !!message?.data?._cognigy?._webchat3?.type
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
// Date picker
|
|
76
|
+
{
|
|
77
|
+
name: 'DatePicker',
|
|
78
|
+
match: (message) => {
|
|
79
|
+
return message?.data?._plugin?.type === 'date-picker'
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
// Text with buttons / Quick Replies
|
|
84
|
+
{
|
|
85
|
+
name: 'TextWithButtons',
|
|
86
|
+
match: (message, config) => {
|
|
87
|
+
const channelConfig = getChannelPayload(message, config)
|
|
88
|
+
if (!channelConfig) {
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const hasQuickReplies =
|
|
93
|
+
channelConfig.message?.quick_replies &&
|
|
94
|
+
channelConfig.message.quick_replies.length > 0
|
|
95
|
+
|
|
96
|
+
const isButtonTemplate =
|
|
97
|
+
channelConfig.message?.attachment?.payload?.template_type === 'button'
|
|
98
|
+
|
|
99
|
+
const hasMessengerText = !!channelConfig.message?.text
|
|
100
|
+
|
|
101
|
+
const isDefaultPreviewEnabled = config?.settings?.widgetSettings?.enableDefaultPreview
|
|
102
|
+
const hasDefaultPreview = !!message?.data?._cognigy?._defaultPreview
|
|
103
|
+
const shouldSkip = isDefaultPreviewEnabled && !hasDefaultPreview && message.text
|
|
104
|
+
|
|
105
|
+
return !shouldSkip && (hasQuickReplies || isButtonTemplate || hasMessengerText)
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
// Image
|
|
110
|
+
{
|
|
111
|
+
name: 'Image',
|
|
112
|
+
match: (message, config) => {
|
|
113
|
+
const channelConfig = getChannelPayload(message, config)
|
|
114
|
+
if (!channelConfig) {
|
|
115
|
+
return false
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return channelConfig.message?.attachment?.type === 'image'
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
// Video
|
|
123
|
+
{
|
|
124
|
+
name: 'Video',
|
|
125
|
+
match: (message, config) => {
|
|
126
|
+
const channelConfig = getChannelPayload(message, config)
|
|
127
|
+
if (!channelConfig) {
|
|
128
|
+
return false
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return channelConfig.message?.attachment?.type === 'video'
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
// Audio
|
|
136
|
+
{
|
|
137
|
+
name: 'Audio',
|
|
138
|
+
match: (message, config) => {
|
|
139
|
+
const channelConfig = getChannelPayload(message, config)
|
|
140
|
+
if (!channelConfig) {
|
|
141
|
+
return false
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return channelConfig.message?.attachment?.type === 'audio'
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
// File
|
|
149
|
+
{
|
|
150
|
+
name: 'File',
|
|
151
|
+
match: (message) => {
|
|
152
|
+
return !!message?.data?.attachments
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
// List
|
|
157
|
+
{
|
|
158
|
+
name: 'List',
|
|
159
|
+
match: (message, config) => {
|
|
160
|
+
const channelConfig = getChannelPayload(message, config)
|
|
161
|
+
if (!channelConfig) {
|
|
162
|
+
return false
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return channelConfig.message?.attachment?.payload?.template_type === 'list'
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
// Gallery
|
|
170
|
+
{
|
|
171
|
+
name: 'Gallery',
|
|
172
|
+
match: (message, config) => {
|
|
173
|
+
const channelConfig = getChannelPayload(message, config)
|
|
174
|
+
if (!channelConfig) {
|
|
175
|
+
return false
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return channelConfig.message?.attachment?.payload?.template_type === 'generic'
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
// Adaptive Card
|
|
183
|
+
{
|
|
184
|
+
name: 'AdaptiveCard',
|
|
185
|
+
match: (message, config) => {
|
|
186
|
+
const webchatPayload = message?.data?._cognigy?._webchat
|
|
187
|
+
const defaultPreviewPayload = message?.data?._cognigy?._defaultPreview
|
|
188
|
+
const hasWebchatAdaptiveCard = isAdaptiveCardPayload(webchatPayload)
|
|
189
|
+
const hasDefaultPreviewAdaptiveCard = isAdaptiveCardPayload(defaultPreviewPayload)
|
|
190
|
+
const isPluginAdaptiveCard = message?.data?._plugin?.type === 'adaptivecards'
|
|
191
|
+
const defaultPreviewEnabled = config?.settings?.widgetSettings?.enableDefaultPreview
|
|
192
|
+
|
|
193
|
+
// Skip if default preview has a message and is enabled
|
|
194
|
+
if (defaultPreviewPayload?.message && defaultPreviewEnabled) {
|
|
195
|
+
return false
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return (
|
|
199
|
+
(hasDefaultPreviewAdaptiveCard && defaultPreviewEnabled) ||
|
|
200
|
+
(hasWebchatAdaptiveCard && hasDefaultPreviewAdaptiveCard && !defaultPreviewEnabled) ||
|
|
201
|
+
hasWebchatAdaptiveCard ||
|
|
202
|
+
isPluginAdaptiveCard
|
|
203
|
+
)
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
// Text message (fallback)
|
|
208
|
+
{
|
|
209
|
+
name: 'Text',
|
|
210
|
+
match: (message, config) => {
|
|
211
|
+
// Don't render engagement messages unless configured
|
|
212
|
+
if (
|
|
213
|
+
message?.source === 'engagement' &&
|
|
214
|
+
!config?.settings?.layout?.showEngagementInChat
|
|
215
|
+
) {
|
|
216
|
+
return false
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Don't render if has file attachments
|
|
220
|
+
if (message?.data?.attachments) {
|
|
221
|
+
return false
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Handle message arrays (from streaming mode)
|
|
225
|
+
if (Array.isArray(message?.text)) {
|
|
226
|
+
return message.text.length > 0
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Handle messages with only escape sequences
|
|
230
|
+
if (
|
|
231
|
+
isOnlyEscapeSequence(message.text) &&
|
|
232
|
+
!config?.settings?.behavior?.collateStreamedOutputs
|
|
233
|
+
) {
|
|
234
|
+
return false
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return message?.text !== null &&
|
|
238
|
+
message?.text !== undefined &&
|
|
239
|
+
message?.text !== ''
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
]
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Match a message to component(s)
|
|
247
|
+
* @param message - The message to match
|
|
248
|
+
* @param config - Optional configuration
|
|
249
|
+
* @param externalPlugins - Custom plugins to check first (these provide their own components)
|
|
250
|
+
* @returns Array of matched rules/plugins
|
|
251
|
+
*/
|
|
252
|
+
export function match(
|
|
253
|
+
message: IMessage,
|
|
254
|
+
config?: ChatConfig,
|
|
255
|
+
externalPlugins: MessagePlugin[] = []
|
|
256
|
+
): MatchResult[] {
|
|
257
|
+
// Combine external plugins with default rules
|
|
258
|
+
// External plugins are checked first
|
|
259
|
+
const allRules: MatchResult[] = [...externalPlugins, ...createDefaultMatchRules()]
|
|
260
|
+
|
|
261
|
+
const matchedRules: MatchResult[] = []
|
|
262
|
+
|
|
263
|
+
for (const rule of allRules) {
|
|
264
|
+
try {
|
|
265
|
+
if (rule.match(message, config)) {
|
|
266
|
+
matchedRules.push(rule)
|
|
267
|
+
|
|
268
|
+
// Stop matching unless passthrough is enabled
|
|
269
|
+
if (!rule.options?.passthrough) {
|
|
270
|
+
break
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
} catch (error) {
|
|
274
|
+
console.error('Matcher: Error in match function', {
|
|
275
|
+
ruleName: rule.name,
|
|
276
|
+
error,
|
|
277
|
+
messageId: message.traceId,
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return matchedRules
|
|
283
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML sanitization utilities
|
|
3
|
+
* Uses DOMPurify to sanitize HTML content
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import DOMPurify from 'dompurify'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Default allowed HTML tags
|
|
10
|
+
* Same as React version
|
|
11
|
+
*/
|
|
12
|
+
export const allowedHtmlTags = [
|
|
13
|
+
'a', 'abbr', 'acronym', 'address', 'area', 'article', 'aside', 'audio',
|
|
14
|
+
'b', 'bdi', 'bdo', 'big', 'blockquote', 'br', 'button',
|
|
15
|
+
'canvas', 'caption', 'center', 'cite', 'code', 'col', 'colgroup',
|
|
16
|
+
'data', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'div', 'dl', 'dt',
|
|
17
|
+
'em', 'embed',
|
|
18
|
+
'fieldset', 'figcaption', 'figure', 'footer', 'form',
|
|
19
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hr',
|
|
20
|
+
'i', 'iframe', 'img', 'input', 'ins',
|
|
21
|
+
'kbd',
|
|
22
|
+
'label', 'legend', 'li', 'link',
|
|
23
|
+
'main', 'map', 'mark', 'meta', 'meter',
|
|
24
|
+
'nav',
|
|
25
|
+
'ol', 'optgroup', 'option', 'output',
|
|
26
|
+
'p', 'param', 'picture', 'pre', 'progress',
|
|
27
|
+
'q',
|
|
28
|
+
'rp', 'rt', 'ruby',
|
|
29
|
+
's', 'samp', 'section', 'select', 'small', 'source', 'span', 'strong', 'style', 'sub', 'summary', 'sup', 'svg',
|
|
30
|
+
'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'title', 'tr', 'track',
|
|
31
|
+
'u', 'ul',
|
|
32
|
+
'var', 'video',
|
|
33
|
+
'wbr',
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Default allowed HTML attributes
|
|
38
|
+
*/
|
|
39
|
+
export const allowedHtmlAttributes = [
|
|
40
|
+
'accept', 'accept-charset', 'accesskey', 'action', 'align', 'alt', 'autocomplete', 'autofocus', 'autoplay',
|
|
41
|
+
'bgcolor', 'border',
|
|
42
|
+
'charset', 'checked', 'cite', 'class', 'color', 'cols', 'colspan', 'content', 'contenteditable', 'controls', 'coords',
|
|
43
|
+
'data', 'data-*', 'datetime', 'default', 'dir', 'dirname', 'disabled', 'download', 'draggable', 'dropzone',
|
|
44
|
+
'enctype',
|
|
45
|
+
'for', 'form', 'formaction',
|
|
46
|
+
'headers', 'height', 'hidden', 'high', 'href', 'hreflang', 'http-equiv',
|
|
47
|
+
'id', 'ismap',
|
|
48
|
+
'kind',
|
|
49
|
+
'label', 'lang', 'list', 'loop', 'low',
|
|
50
|
+
'max', 'maxlength', 'media', 'method', 'min', 'multiple', 'muted',
|
|
51
|
+
'name', 'novalidate',
|
|
52
|
+
'open', 'optimum',
|
|
53
|
+
'pattern', 'placeholder', 'poster', 'preload',
|
|
54
|
+
'readonly', 'rel', 'required', 'reversed', 'rows', 'rowspan',
|
|
55
|
+
'sandbox', 'scope', 'selected', 'shape', 'size', 'sizes', 'span', 'spellcheck', 'src', 'srcdoc', 'srclang', 'srcset', 'start', 'step', 'style',
|
|
56
|
+
'tabindex', 'target', 'title', 'translate', 'type',
|
|
57
|
+
'usemap',
|
|
58
|
+
'value',
|
|
59
|
+
'width', 'wrap',
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Sanitize HTML with custom configuration
|
|
64
|
+
* @param text - HTML text to sanitize
|
|
65
|
+
* @param customAllowedHtmlTags - Optional custom allowed tags
|
|
66
|
+
* @returns Sanitized HTML string
|
|
67
|
+
*/
|
|
68
|
+
export function sanitizeHTMLWithConfig(
|
|
69
|
+
text: string,
|
|
70
|
+
customAllowedHtmlTags?: string[]
|
|
71
|
+
): string {
|
|
72
|
+
if (!text) {
|
|
73
|
+
return ''
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Handle orphan closing tags (from streaming LLMs)
|
|
77
|
+
if (text.startsWith('</')) {
|
|
78
|
+
return text.replace(/</g, '<').replace(/>/g, '>')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Configure DOMPurify
|
|
82
|
+
const config = {
|
|
83
|
+
ALLOWED_TAGS: customAllowedHtmlTags || allowedHtmlTags,
|
|
84
|
+
ALLOWED_ATTR: allowedHtmlAttributes,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Add hook for unknown elements
|
|
88
|
+
// Note: DOMPurify's hook callback types don't fully describe the node parameter
|
|
89
|
+
DOMPurify.addHook('beforeSanitizeElements', (node: any) => {
|
|
90
|
+
if (node instanceof HTMLUnknownElement) {
|
|
91
|
+
const unClosedTag = `<${node.tagName.toLowerCase()}>${node.innerHTML}`
|
|
92
|
+
const closedTag = `<${node.tagName.toLowerCase()}>${node.innerHTML}</${node.tagName.toLowerCase()}>`
|
|
93
|
+
node.replaceWith(unClosedTag === text ? unClosedTag : closedTag)
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const sanitized = DOMPurify.sanitize(text, config).toString()
|
|
99
|
+
return sanitized
|
|
100
|
+
} catch (error) {
|
|
101
|
+
console.error('sanitizeHTMLWithConfig: Sanitization failed', {
|
|
102
|
+
error,
|
|
103
|
+
textLength: text.length,
|
|
104
|
+
})
|
|
105
|
+
// Return escaped text as fallback
|
|
106
|
+
return text.replace(/</g, '<').replace(/>/g, '>')
|
|
107
|
+
} finally {
|
|
108
|
+
DOMPurify.removeAllHooks()
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Sanitize content if sanitization is enabled
|
|
114
|
+
* @param content - Content to sanitize
|
|
115
|
+
* @param isSanitizeEnabled - Whether sanitization is enabled
|
|
116
|
+
* @param customAllowedHtmlTags - Custom allowed tags
|
|
117
|
+
* @returns Sanitized or raw content
|
|
118
|
+
*/
|
|
119
|
+
export function sanitizeContent(
|
|
120
|
+
content: string | undefined,
|
|
121
|
+
isSanitizeEnabled: boolean,
|
|
122
|
+
customAllowedHtmlTags?: string[]
|
|
123
|
+
): string {
|
|
124
|
+
if (!content) {
|
|
125
|
+
return ''
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!isSanitizeEnabled) {
|
|
129
|
+
return content
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return sanitizeHTMLWithConfig(content, customAllowedHtmlTags)
|
|
133
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme utilities for config-driven CSS variable injection
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ChatSettings } from '../types'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Maps config.settings.colors to CSS custom properties.
|
|
9
|
+
* Returns an object suitable for use as an inline style that sets CSS variables.
|
|
10
|
+
*
|
|
11
|
+
* @param colors - The colors object from ChatSettings
|
|
12
|
+
* @returns A record of CSS variable names to their values
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```typescript
|
|
16
|
+
* const style = configColorsToCssVariables({
|
|
17
|
+
* primaryColor: '#0b3694',
|
|
18
|
+
* botMessageColor: '#f5f5f5',
|
|
19
|
+
* })
|
|
20
|
+
* // Returns: { '--cc-primary-color': '#0b3694', '--cc-background-bot-message': '#f5f5f5' }
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export function configColorsToCssVariables(
|
|
24
|
+
colors?: ChatSettings['colors']
|
|
25
|
+
): Record<string, string> {
|
|
26
|
+
if (!colors) return {}
|
|
27
|
+
|
|
28
|
+
const mapping: Record<string, string | undefined> = {
|
|
29
|
+
// Primary action colors
|
|
30
|
+
'--cc-primary-color': colors.primaryColor,
|
|
31
|
+
'--cc-primary-color-hover': colors.primaryColorHover,
|
|
32
|
+
'--cc-primary-color-focus': colors.primaryColorFocus,
|
|
33
|
+
'--cc-primary-color-disabled': colors.primaryColorDisabled,
|
|
34
|
+
'--cc-primary-contrast-color': colors.primaryContrastColor,
|
|
35
|
+
'--cc-secondary-color': colors.secondaryColor,
|
|
36
|
+
|
|
37
|
+
// Message bubble backgrounds
|
|
38
|
+
'--cc-background-bot-message': colors.botMessageColor,
|
|
39
|
+
'--cc-bot-message-contrast-color': colors.botMessageContrastColor,
|
|
40
|
+
'--cc-background-user-message': colors.userMessageColor,
|
|
41
|
+
'--cc-user-message-contrast-color': colors.userMessageContrastColor,
|
|
42
|
+
'--cc-background-agent-message': colors.agentMessageColor,
|
|
43
|
+
'--cc-agent-message-contrast-color': colors.agentMessageContrastColor,
|
|
44
|
+
|
|
45
|
+
// Message bubble borders
|
|
46
|
+
'--cc-border-bot-message': colors.borderBotMessage,
|
|
47
|
+
'--cc-border-user-message': colors.borderUserMessage,
|
|
48
|
+
'--cc-border-agent-message': colors.borderAgentMessage,
|
|
49
|
+
|
|
50
|
+
// Link color
|
|
51
|
+
'--cc-text-link-color': colors.textLinkColor,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Filter out undefined values and return only defined CSS variables
|
|
55
|
+
return Object.fromEntries(
|
|
56
|
+
Object.entries(mapping).filter(([, value]) => value !== undefined)
|
|
57
|
+
) as Record<string, string>
|
|
58
|
+
}
|