Skip to content

Commit 08bce08

Browse files
committed
refactor(bubble): improve content handling and cleanup logic
- Simplified the content extraction in `useBubbleBoxRenderer` by removing unnecessary type imports and ensuring consistent content resolution. - Enhanced `useCopyCleanup` to utilize `onCleanup` for better event listener management, improving performance and preventing memory leaks. - Updated `Markdown.vue` to sanitize rendered content after processing, ensuring security against XSS attacks. - Added HTML escaping in `Tool.vue` to prevent XSS vulnerabilities when rendering JSON content. - Refined `unwrapProxy` function in `utils.ts` to use a `WeakMap` for better handling of circular references and shared objects. - Improved error handling in `useMessage` by ensuring `onError` is only called if defined, enhancing plugin robustness. - Adjusted `fallbackRolePlugin` to correctly map messages, ensuring fallback roles are applied consistently.
1 parent 70d5e0c commit 08bce08

File tree

7 files changed

+46
-39
lines changed

7 files changed

+46
-39
lines changed

packages/components/src/bubble/composables/useBubbleBoxRenderer.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
BUBBLE_BOX_PROP_FALLBACK_RENDERER_KEY,
66
BUBBLE_BOX_RENDERER_MATCHES_KEY,
77
} from '../constants'
8-
import type { BubbleBoxRendererMatch, BubbleMessage, ChatMessageContentItem } from '../index.type'
8+
import type { BubbleBoxRendererMatch, BubbleMessage } from '../index.type'
99
import { defaultBoxRendererMatches, defaultFallbackBoxRenderer } from '../renderers/defaultRenderers'
1010
import { useContentResolver } from './useContentResolver'
1111

@@ -61,7 +61,7 @@ export function useBubbleBoxRenderer(
6161
const resolvedContent = contentResolver(msgs.at(0)!)
6262
return {
6363
content: Array.isArray(resolvedContent)
64-
? (resolvedContent.at(contentIndex ?? 0) as ChatMessageContentItem)
64+
? resolvedContent.at(contentIndex ?? 0)!
6565
: { type: 'text', text: resolvedContent || '' },
6666
index: contentIndex ?? 0,
6767
}

packages/components/src/bubble/composables/useCopyCleanup.ts

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,17 @@
1-
import { watch, type Ref, type WatchHandle } from 'vue'
1+
import { watch, type Ref } from 'vue'
22

33
/**
44
* 设置复制事件处理器,清理复制文本中的多余换行
55
* @param elementRef - 需要处理复制事件的元素引用
66
*/
77
export function useCopyCleanup(elementRef: Ref<HTMLElement | null>) {
8-
let stopWatch: WatchHandle | null = null
9-
10-
stopWatch = watch(
8+
watch(
119
elementRef,
12-
(elem) => {
13-
// 清理之前的 watch 和事件监听器
14-
if (stopWatch) {
15-
stopWatch()
16-
stopWatch = null
17-
}
18-
10+
(elem, _prev, onCleanup) => {
1911
if (!elem) return
2012

2113
// 添加复制事件监听器
22-
elem.addEventListener('copy', (e) => {
14+
const handler = (e: ClipboardEvent) => {
2315
// 获取用户选中的内容
2416
const selection = window.getSelection()
2517
// 判断选区是否在当前元素内
@@ -30,7 +22,10 @@ export function useCopyCleanup(elementRef: Ref<HTMLElement | null>) {
3022
const cleaned = selection?.toString().replace(/\n{2,}/g, '\n') || ''
3123
// 写入剪贴板
3224
e.clipboardData?.setData('text/plain', cleaned)
33-
})
25+
}
26+
elem.addEventListener('copy', handler)
27+
// 使用 onCleanup 在元素变化或 watcher 停止时移除事件监听器
28+
onCleanup(() => elem.removeEventListener('copy', handler))
3429
},
3530
{ immediate: true, flush: 'post' },
3631
)

packages/components/src/bubble/renderers/Markdown.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ const markdownContent = ref('')
2727
watchEffect(() => {
2828
if (markdownItAndDompurify.value) {
2929
const { markdown, dompurify } = markdownItAndDompurify.value
30-
markdownContent.value = markdown(mdConfig || {}).render(content.value)
31-
dompurify.sanitize(markdownContent.value, dompurifyConfig)
30+
const rendered = markdown(mdConfig || {}).render(content.value)
31+
markdownContent.value = dompurify.sanitize(rendered, dompurifyConfig)
3232
}
3333
})
3434
</script>

packages/components/src/bubble/renderers/Tool.vue

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,22 @@ const prettyJSON = (json: unknown, space = 2) => {
4242
4343
const classes = useCssModule()
4444
45+
// Escape HTML entities to prevent XSS attacks when rendering with v-html
46+
const escapeHtml = (value: string) =>
47+
value
48+
.replace(/&/g, '&amp;')
49+
.replace(/</g, '&lt;')
50+
.replace(/>/g, '&gt;')
51+
.replace(/"/g, '&quot;')
52+
.replace(/'/g, '&#39;')
53+
4554
const highlightJSON = (json: string): string => {
4655
if (!json) {
4756
return ''
4857
}
4958
50-
let jsonStr = json
59+
// Escape HTML entities first to prevent XSS attacks
60+
let jsonStr = escapeHtml(json)
5161
5262
jsonStr = jsonStr.replace(
5363
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g,

packages/kit/src/storage/utils.ts

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ import { ChatMessage } from '../types'
77
* 同时移除不可序列化的内容(函数、Symbol 等)
88
*
99
* @param value - 要解包的值
10-
* @param visited - 用于检测循环引用的 WeakSet
10+
* @param visited - 用于检测循环引用和共享引用的 WeakMap,映射原始对象到其克隆对象
1111
* @returns 解包后的普通对象
1212
*/
13-
export function unwrapProxy<T>(value: T, visited: WeakSet<object> = new WeakSet()): T {
13+
export function unwrapProxy<T>(value: T, visited: WeakMap<object, any> = new WeakMap()): T {
1414
// 处理 null 和 undefined
1515
if (value === null || value === undefined) {
1616
return value
@@ -21,22 +21,23 @@ export function unwrapProxy<T>(value: T, visited: WeakSet<object> = new WeakSet(
2121
return value
2222
}
2323

24-
// 处理循环引用
25-
if (visited.has(value as object)) {
26-
// 遇到循环引用时返回空对象或空数组
27-
return (Array.isArray(value) ? [] : {}) as T
28-
}
29-
30-
// 标记为已访问
31-
visited.add(value as object)
32-
3324
try {
3425
// 使用 Vue 的 toRaw 解包响应式对象
3526
const rawValue: any = toRaw(value)
3627

28+
// 如果已经处理过该对象,返回之前创建的克隆对象(处理循环引用和共享引用)
29+
if (visited.has(rawValue)) {
30+
return visited.get(rawValue)
31+
}
32+
3733
// 处理数组
3834
if (Array.isArray(rawValue)) {
39-
return rawValue.map((item) => unwrapProxy(item, visited)) as T
35+
// 先创建空数组并存储到 visited,避免循环引用问题
36+
const arr: any[] = []
37+
visited.set(rawValue, arr)
38+
// 然后填充数组内容
39+
arr.push(...rawValue.map((item: any) => unwrapProxy(item, visited)))
40+
return arr as T
4041
}
4142

4243
// 处理 Date 对象
@@ -55,13 +56,12 @@ export function unwrapProxy<T>(value: T, visited: WeakSet<object> = new WeakSet(
5556
}
5657

5758
// 处理普通对象
59+
// 先创建空对象并存储到 visited,避免循环引用问题
5860
const result: any = {}
59-
for (const key in rawValue) {
60-
// 跳过 Symbol 键
61-
if (typeof key === 'symbol') {
62-
continue
63-
}
61+
visited.set(rawValue, result)
6462

63+
// 使用 Object.keys 而不是 for...in,确保只处理自有属性
64+
for (const key of Object.keys(rawValue)) {
6565
const descriptor = Object.getOwnPropertyDescriptor(rawValue, key)
6666
if (!descriptor) {
6767
continue

packages/kit/src/vue/message/plugins/fallbackRolePlugin.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ export const fallbackRolePlugin = (options: UseMessagePlugin & { fallbackRole?:
77
name: 'fallbackRole',
88
...restOptions,
99
onBeforeRequest(context) {
10-
const { requestBody, messages } = context
10+
const { requestBody } = context
1111
// 如果消息的 role 为空,则使用 fallbackRole
12-
requestBody.messages = messages.map((message) => {
12+
requestBody.messages = requestBody.messages.map((message) => {
1313
return {
1414
...message,
1515
role: message.role || fallbackRole,

packages/kit/src/vue/message/useMessage.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -276,8 +276,10 @@ export const useMessage = (options: UseMessageOptions): UseMessageReturn => {
276276

277277
const context = getBaseContext(ac.signal)
278278
for (const plugin of plugins.filter((plugin) => !isPluginDisabled(plugin, context))) {
279-
hasOnError = true
280-
plugin.onError?.({ ...context, error: err })
279+
if (plugin.onError) {
280+
hasOnError = true
281+
plugin.onError({ ...context, error: err })
282+
}
281283
}
282284

283285
// 如果没有任何插件实现了 onError 钩子,则抛出错误

0 commit comments

Comments
 (0)