@tencentcloud/ai-desk-customer-vue 1.7.1 → 1.7.2

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/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## 1.7.2 @2026.3.30
2
+
3
+ ### Features
4
+ - 优化流式消息展示效果
5
+
6
+ ### Fixed
7
+ - 修复已知问题
8
+
1
9
  ## 1.7.1 @2026.3.17
2
10
 
3
11
  ### Features
@@ -403,5 +403,8 @@ export default {
403
403
  }
404
404
  .dialog-title {
405
405
  gap: 10px;
406
+ display: flex;
407
+ justify-content: space-between;
408
+ padding: 20px;
406
409
  }
407
410
  </style>
@@ -1,8 +1,8 @@
1
1
  <template>
2
- <div :class="['tui-chat', !isPC && 'tui-chat-h5']" :key="currentLanguage">
2
+ <div :class="['tui-chat', !isPC && 'tui-chat-h5','ai-desk-customer']" :key="currentLanguage">
3
3
  <div
4
4
  v-if="currentConversationID && (!props.showQueuePage || queueNumber < 0 || queueNumber === undefined || hasLeftQueue)"
5
- :class="['tui-chat', !isPC && 'tui-chat-h5']"
5
+ :class="['tui-chat', !isPC && 'tui-chat-h5','ai-desk-customer']"
6
6
  >
7
7
  <ChatHeader
8
8
  :class="[
@@ -32,7 +32,7 @@ export const marked = new Marked(
32
32
  },
33
33
  link(this: any, href: string | null, title: string | null, text: string) {
34
34
  if (href) {
35
- return getStyledATagFromText(href, "message-marked_link", undefined, title || '');
35
+ return getStyledATagFromText(href, "message-marked_link", undefined, title || text || '');
36
36
  }
37
37
  return text;
38
38
  },
@@ -52,7 +52,7 @@ export const parseMarkdown = (text: string) => {
52
52
  if (typeof ret === 'string') {
53
53
  const purified = DOMPurify.sanitize(ret,{
54
54
  USE_PROFILES: { html: true,},
55
- ADD_ATTR: ['onclick'],
55
+ ADD_ATTR: ['onclick', 'target'],
56
56
  FORBID_TAGS: ['style']
57
57
  });
58
58
  return purified;
@@ -63,7 +63,7 @@ export default {
63
63
  }
64
64
  }
65
65
  </script>
66
- <style lang="scss">
66
+ <style lang="scss" scoped>
67
67
  input:disabled {
68
68
  background-color: #dbdbdb;
69
69
  opacity: 0.5;
@@ -197,7 +197,7 @@ export default {
197
197
  }
198
198
  }
199
199
  </script>
200
- <style lang="scss">
200
+ <style lang="scss" scoped>
201
201
  @import "../styles/common.scss";
202
202
 
203
203
  .edit-profile-container {
@@ -382,6 +382,7 @@ export default {
382
382
  .dialog-show-content {
383
383
  overflow-y: auto;
384
384
  max-height: 68vh;
385
+ padding: 0 20px;
385
386
  }
386
387
 
387
388
  .variable-value-container-mobile {
@@ -2,7 +2,7 @@
2
2
  <div>
3
3
  <div class="message-stream">
4
4
  <span v-if="isCursorBlinking" class="blinking-cursor">|</span>
5
- <pre ref="preRef" :class="['message-marked']" v-html="displayedContent" />
5
+ <div ref="contentRef" :class="['message-marked']"></div>
6
6
  </div>
7
7
  <div v-if="image" class="rich-image-previewer" @click="closeImage">
8
8
  <img
@@ -20,7 +20,7 @@ import { parseMarkdown } from './marked'
20
20
  import { TypeWriter } from "./type-writer";
21
21
  import { JSONToObject } from "../../../../../../utils";
22
22
 
23
- const { ref, computed, withDefaults, defineProps, watch, onMounted, onUnmounted } = vue;
23
+ const { ref, computed, watch, onMounted, onUnmounted } = vue;
24
24
 
25
25
  interface Props {
26
26
  payload: customerServicePayloadType;
@@ -38,20 +38,256 @@ const chunks = ref<string>('');
38
38
  const isFinished = ref<boolean>(true);
39
39
  const prevChunksLength = ref<number>(0);
40
40
  const streamContent = ref<string>('');
41
- const displayedContent = computed(() => {
42
- // @ts-ignore
43
- window.onMarkdownImageClicked = function(href:string) {
44
- image.value = !image.value;
45
- imageSrc.value = decodeURIComponent(href);
46
- }
47
- // @ts-ignore
48
- window.onMarkdownMediaLoad = function() {
49
- // empty implementation
50
- // 已经用 ResizeObserver 观测高度,这里不用重复通知高度变化
51
- }
52
- return parseMarkdown(streamContent.value);
41
+ const contentRef = ref<HTMLElement>(); // DOM 容器引用
42
+
43
+ // 设置全局回调函数(只需设置一次)
44
+ // @ts-ignore
45
+ window.onMarkdownImageClicked = function(href: string) {
46
+ image.value = !image.value;
47
+ imageSrc.value = decodeURIComponent(href);
48
+ }
49
+ // @ts-ignore
50
+ window.onMarkdownMediaLoad = function() {
51
+ // empty implementation
52
+ // 已经用 ResizeObserver 观测高度,这里不用重复通知高度变化
53
+ }
54
+
55
+ /**
56
+ * 智能 DOM 更新(innerHTML + 媒体元素保留):
57
+ * 1. 先把容器内已有的 img/video/a 元素"摘出来"保存
58
+ * 2. 用 innerHTML 全量替换(速度最快,浏览器原生实现)
59
+ * 3. 替换后,把新 DOM 中对应位置的 img/video/a 替换回旧的真实节点
60
+ * (避免媒体元素被销毁重建导致闪烁/重新加载)
61
+ */
62
+ function smartUpdate(container: HTMLElement, newHTML: string) {
63
+ // Step 1: 收集旧的媒体元素,以 src/href 为 key 保存真实 DOM 节点
64
+ const savedMedia = new Map<string, HTMLElement[]>();
65
+ container.querySelectorAll('img, video, a').forEach((el) => {
66
+ const key = el.getAttribute('src') || el.getAttribute('href') || '';
67
+ if (!key) {
68
+ return;
69
+ }
70
+ if (!savedMedia.has(key)) {
71
+ savedMedia.set(key, []);
72
+ }
73
+ savedMedia.get(key)!.push(el as HTMLElement);
74
+ });
75
+
76
+ // 没有需要保留的媒体元素,直接 innerHTML 替换即可
77
+ if (savedMedia.size === 0) {
78
+ container.innerHTML = newHTML;
79
+ return;
80
+ }
81
+
82
+ // Step 2: 先把旧媒体节点从 DOM 中摘出来(移到一个临时片段,防止被 innerHTML 销毁)
83
+ const fragment = document.createDocumentFragment();
84
+ savedMedia.forEach((els) => {
85
+ els.forEach((el) => fragment.appendChild(el));
86
+ });
87
+
88
+ // Step 3: innerHTML 全量替换(最快)
89
+ container.innerHTML = newHTML;
90
+
91
+ // Step 4: 遍历新 DOM 中的媒体元素,如果 src/href 匹配,用旧节点替换新节点
92
+ // 这样旧的 img/video 不会重新加载,a 标签保持原有状态
93
+ const usedCount = new Map<string, number>(); // 记录每个 key 已使用的次数(处理同 src 多次出现)
94
+ container.querySelectorAll('img, video, a').forEach((newEl) => {
95
+ const key = newEl.getAttribute('src') || newEl.getAttribute('href') || '';
96
+ if (!key || !savedMedia.has(key)) {
97
+ return;
98
+ }
99
+
100
+ const count = usedCount.get(key) || 0;
101
+ const oldEls = savedMedia.get(key)!;
102
+ if (count < oldEls.length) {
103
+ // 用保存的旧节点替换新创建的节点
104
+ newEl.parentNode?.replaceChild(oldEls[count], newEl);
105
+ usedCount.set(key, count + 1);
106
+ }
107
+ });
108
+ }
109
+
110
+ // ---- 媒体标记预提取 & 即时插入逻辑 ----
111
+ // 匹配完整的 Markdown 媒体标记:![alt](url) 图片 和 [text](url) 链接
112
+ const MEDIA_MARK_RE = /!?\[[^\]]*\]\([^)]*\)/g;
113
+
114
+ /**
115
+ * 媒体段信息:记录 chunks 中每个媒体标记的位置和内容
116
+ */
117
+ interface MediaSegment {
118
+ start: number; // 媒体标记在 chunks 中的起始位置
119
+ end: number; // 媒体标记在 chunks 中的结束位置
120
+ mark: string; // 完整的媒体标记文本,如 ![alt](url)
121
+ }
122
+
123
+ // 从 chunks 预扫描出的所有完整媒体标记
124
+ let mediaSegments: MediaSegment[] = [];
125
+ // 记录已经即时插入的媒体标记索引,避免重复插入
126
+ let insertedMediaIndex = 0;
127
+ // 纯文本累计长度(不含已插入的媒体标记),用于判断 TypeWriter 输出到哪了
128
+ let pureTextLength = 0;
129
+ // 记录 chunks 末尾被截留的不完整媒体标记文本(等下一批 chunks 补全)
130
+ let pendingTail = '';
131
+ // 记录上一次已处理(送入 TypeWriter + 截留)的 chunks 长度
132
+ let processedChunksLength = 0;
133
+
134
+ /**
135
+ * 从完整 chunks 中预扫描所有媒体标记的位置。
136
+ * 每次 chunks 更新时调用,会发现新补全的完整媒体标记。
137
+ */
138
+ function scanMediaSegments(fullText: string) {
139
+ mediaSegments = [];
140
+ MEDIA_MARK_RE.lastIndex = 0;
141
+ let match: RegExpExecArray | null;
142
+ while ((match = MEDIA_MARK_RE.exec(fullText)) !== null) {
143
+ mediaSegments.push({
144
+ start: match.index,
145
+ end: match.index + match[0].length,
146
+ mark: match[0],
147
+ });
148
+ }
149
+ }
150
+
151
+ /**
152
+ * 检测文本末尾是否有未闭合的 Markdown 媒体标记开头。
153
+ * 用于判断 chunks 末尾是否有被截断的媒体标记,需要等下一批补全。
154
+ * 返回未闭合部分的起始位置(相对于 text),如果没有则返回 -1。
155
+ *
156
+ * 例如:
157
+ * "一些文字![alt](https://xxx" → 返回 "![alt](https://xxx" 的起始位置
158
+ * "一些文字![alt" → 返回 "![alt" 的起始位置
159
+ * "一些文字完整内容" → 返回 -1
160
+ */
161
+ function findIncompleteTail(text: string): number {
162
+ // 只检查末尾一小段,避免长文本性能问题
163
+ const checkLen = 500;
164
+ const startPos = Math.max(0, text.length - checkLen);
165
+ const tail = text.slice(startPos);
166
+
167
+ // 从后往前找最后一个 ![ 或独立的 [(非 ![ 前缀的链接)
168
+ // 策略:找到最后一个可能的媒体标记起始位置,检查它是否闭合
169
+ let lastImgStart = tail.lastIndexOf('![');
170
+ let lastLinkStart = -1;
171
+
172
+ // 找最后一个 [ 但不是 ![ 的一部分(用于 [text](url) 链接)
173
+ for (let i = tail.length - 1; i >= 0; i--) {
174
+ if (tail[i] === '[' && (i === 0 || tail[i - 1] !== '!')) {
175
+ lastLinkStart = i;
176
+ break;
177
+ }
178
+ }
179
+
180
+ // 取最靠后的那个起始位置
181
+ const candidatePos = Math.max(lastImgStart, lastLinkStart);
182
+ if (candidatePos === -1) return -1;
183
+
184
+ // 从候选位置开始,检查是否有完整闭合的 ] 和 )
185
+ const candidate = tail.slice(candidatePos);
186
+
187
+ // 检查是否有完整的 [...](...) 结构
188
+ const bracketClose = candidate.indexOf(']');
189
+ if (bracketClose === -1) {
190
+ // [ 还没闭合
191
+ return startPos + candidatePos;
192
+ }
193
+
194
+ // ] 后面必须紧跟 (
195
+ if (bracketClose + 1 >= candidate.length || candidate[bracketClose + 1] !== '(') {
196
+ // ] 后面没有 (,可能不是媒体标记,不截留
197
+ return -1;
198
+ }
199
+
200
+ // 有 ]( 了,检查 ) 是否闭合
201
+ const parenClose = candidate.indexOf(')', bracketClose + 2);
202
+ if (parenClose === -1) {
203
+ // ]( 后面的 url 还没闭合
204
+ return startPos + candidatePos;
205
+ }
206
+
207
+ // 完整闭合了,不需要截留
208
+ return -1;
209
+ }
210
+
211
+ /**
212
+ * 将 chunks 中的新增内容拆分为:只保留纯文本部分(去掉已识别的完整媒体标记)。
213
+ * @param text - 要处理的文本
214
+ * @param offsetInChunks - 这段文本在完整 chunks 中的起始偏移
215
+ * @returns 去掉媒体标记后的纯文本
216
+ */
217
+ function stripMediaMarks(text: string, offsetInChunks: number): string {
218
+ let result = '';
219
+ let cursor = offsetInChunks;
220
+ const end = offsetInChunks + text.length;
221
+
222
+ for (const seg of mediaSegments) {
223
+ // 跳过已经在之前处理过的媒体标记
224
+ if (seg.end <= cursor) continue;
225
+ // 超出本次新增范围,后面的也不用看了
226
+ if (seg.start >= end) break;
227
+
228
+ // 媒体标记前面的纯文本
229
+ if (seg.start > cursor) {
230
+ result += text.substring(cursor - offsetInChunks, seg.start - offsetInChunks);
231
+ }
232
+ // 跳过媒体标记本身(不加入纯文本)
233
+ cursor = Math.min(seg.end, end);
234
+ }
235
+
236
+ // 剩余的纯文本
237
+ if (cursor < end) {
238
+ result += text.substring(cursor - offsetInChunks);
239
+ }
240
+
241
+ return result;
242
+ }
243
+
244
+ /**
245
+ * 检查 TypeWriter 的纯文本输出进度,当输出到某个媒体标记的位置时,
246
+ * 立即将完整的媒体标记插入 streamContent,不走逐字输出。
247
+ */
248
+ function tryInsertPendingMedia() {
249
+ while (insertedMediaIndex < mediaSegments.length) {
250
+ const seg = mediaSegments[insertedMediaIndex];
251
+ // 计算该媒体标记之前有多少纯文本字符
252
+ let prevMediaLen = 0;
253
+ for (let i = 0; i < insertedMediaIndex; i++) {
254
+ prevMediaLen += mediaSegments[i].mark.length;
255
+ }
256
+ const pureTextBeforeThisMark = seg.start - prevMediaLen;
257
+
258
+ // 当 TypeWriter 输出的纯文本长度达到该媒体标记的位置时,立即插入
259
+ if (pureTextLength >= pureTextBeforeThisMark) {
260
+ streamContent.value += seg.mark;
261
+ insertedMediaIndex++;
262
+ } else {
263
+ break;
264
+ }
265
+ }
266
+ }
267
+
268
+ /**
269
+ * 执行实际的 DOM 渲染
270
+ */
271
+ function doRender() {
272
+ if (!contentRef.value) {
273
+ return;
274
+ }
275
+ const html = parseMarkdown(streamContent.value);
276
+ smartUpdate(contentRef.value, html);
277
+ emits('heightChanged');
278
+ }
279
+
280
+ /**
281
+ * 监听 streamContent 变化,直接渲染(不再需要缓冲延迟)。
282
+ * 因为媒体标记是完整插入的,streamContent 中不会出现半截的 ![alt](url
283
+ */
284
+ watch(streamContent, () => {
285
+ if (!contentRef.value) {
286
+ return;
287
+ }
288
+ doRender();
53
289
  });
54
- const preRef = ref();
290
+
55
291
  const emits = defineEmits(['heightChanged']);
56
292
  // 不支持 ResizeObserver 的浏览器太老旧,默认不处理了
57
293
  const canIUseResizeObserver = typeof ResizeObserver === 'undefined' ? false : true;
@@ -61,11 +297,19 @@ let prevHeight;
61
297
 
62
298
  const typeWriter = new TypeWriter({
63
299
  onTyping: (item: string) => {
300
+ // TypeWriter 只输出纯文本,累计纯文本长度
301
+ pureTextLength += item.length;
64
302
  streamContent.value += item;
65
- // emits('onStreaming', item, streamContent.value);
303
+ // 检查是否到了某个媒体标记的位置,是则立即插入完整标记
304
+ tryInsertPendingMedia();
66
305
  },
67
306
  onComplete() {
68
307
  isStreaming.value = false;
308
+ // 完成时确保所有剩余媒体标记都已插入(防御边界情况)
309
+ while (insertedMediaIndex < mediaSegments.length) {
310
+ streamContent.value += mediaSegments[insertedMediaIndex].mark;
311
+ insertedMediaIndex++;
312
+ }
69
313
  },
70
314
  });
71
315
 
@@ -91,6 +335,10 @@ watch(() => props.payload, (newValue: string, oldValue: string) => {
91
335
  chunks.value = Array.isArray(_payloadObject.chunks) ? _payloadObject.chunks.join('') : _payloadObject.chunks;
92
336
  isFinished.value = _payloadObject.isFinished === 1;
93
337
 
338
+ // 每次 chunks 更新时,重新扫描所有完整的媒体标记
339
+ // (因为上一批截断的标记可能在这一批补全了)
340
+ scanMediaSegments(chunks.value);
341
+
94
342
  // hide blinking cursor
95
343
  if (chunks.value.length > 0) {
96
344
  isCursorBlinking.value = false;
@@ -98,14 +346,49 @@ watch(() => props.payload, (newValue: string, oldValue: string) => {
98
346
 
99
347
  if (newValue && !oldValue && isFinished.value) {
100
348
  // disable typeWriter style or history message first load
349
+ // 历史消息直接全量渲染,不需要拆分
101
350
  streamContent.value = chunks.value;
102
351
  prevChunksLength.value = chunks.value.length;
352
+ processedChunksLength = chunks.value.length;
353
+ pendingTail = '';
354
+ // 标记所有媒体都已插入
355
+ insertedMediaIndex = mediaSegments.length;
356
+ pureTextLength = chunks.value.length;
103
357
  } else {
104
358
  // 判断长度是为了防御编辑的内容回退和内容重复的异常 case
105
359
  if (chunks.value.length > prevChunksLength.value) {
106
- const _newChunksToAdd = chunks.value?.slice(prevChunksLength.value);
107
360
  prevChunksLength.value = chunks.value.length;
108
- startStreaming([_newChunksToAdd]);
361
+
362
+ // 本次要处理的文本 = 上次截留的不完整尾部 + 本次新增内容
363
+ // 从 processedChunksLength 开始取(而非 prevChunksLength),因为截留部分还没处理
364
+ const unprocessed = chunks.value.slice(processedChunksLength);
365
+
366
+ // 检测末尾是否有未闭合的媒体标记(被截断了,需要等下一批补全)
367
+ let safeText = unprocessed;
368
+ pendingTail = '';
369
+
370
+ if (!isFinished.value) {
371
+ // 流还没结束,检查末尾是否有截断的媒体标记
372
+ const incompletePos = findIncompleteTail(chunks.value);
373
+ if (incompletePos !== -1 && incompletePos >= processedChunksLength) {
374
+ // 截留末尾不完整的部分,只处理前面安全的部分
375
+ const safeEnd = incompletePos - processedChunksLength;
376
+ safeText = unprocessed.slice(0, safeEnd);
377
+ pendingTail = unprocessed.slice(safeEnd);
378
+ }
379
+ } else {
380
+ // 流已结束,不再截留,全部处理
381
+ pendingTail = '';
382
+ }
383
+
384
+ // 从安全文本中去掉已识别的完整媒体标记,只把纯文本送入 TypeWriter
385
+ if (safeText.length > 0) {
386
+ const pureText = stripMediaMarks(safeText, processedChunksLength);
387
+ processedChunksLength += safeText.length;
388
+ if (pureText.length > 0) {
389
+ startStreaming([pureText]);
390
+ }
391
+ }
109
392
  }
110
393
  }
111
394
  }, {
@@ -115,14 +398,20 @@ watch(() => props.payload, (newValue: string, oldValue: string) => {
115
398
  );
116
399
 
117
400
  onMounted(() => {
118
- if (canIUseResizeObserver) {
401
+ // 如果 streamContent 在 mounted 前已有值(如历史消息),需要在此时渲染
402
+ if (contentRef.value && streamContent.value) {
403
+ const html = parseMarkdown(streamContent.value);
404
+ smartUpdate(contentRef.value, html);
405
+ }
406
+
407
+ if (canIUseResizeObserver && contentRef.value) {
119
408
  observer = new ResizeObserver(entries => {
120
409
  for (let entry of entries) {
121
410
  observeHeightChanged(entry.contentRect.height);
122
411
  }
123
412
  });
124
413
  // 开始观察
125
- observer.observe(preRef.value);
414
+ observer.observe(contentRef.value);
126
415
  }
127
416
  });
128
417
 
@@ -158,6 +447,11 @@ const closeImage = () => {
158
447
  user-select: text;
159
448
  }
160
449
 
450
+ ::v-deep .message-marked li {
451
+ list-style: disc;
452
+ margin-left: 1.5em;
453
+ }
454
+
161
455
  .blinking-cursor {
162
456
  animation: blink 0.8s step-end infinite;
163
457
  color: #000;
@@ -73,110 +73,113 @@
73
73
  transform: translate(-50%, -50%);
74
74
  }
75
75
 
76
- .container {
77
- @include flex;
78
- position: fixed;
79
- width: 100vw;
80
- height: 100vh;
81
- top: 0;
82
- left: 0;
83
- z-index: 3;
84
- background: rgba(0, 0, 0, 0.3);
85
- overflow: hidden;
86
- .box {
76
+ .ai-desk-customer {
77
+ .container {
87
78
  @include flex;
88
- border-radius: 0.5rem;
89
- background: #fff;
79
+ position: fixed;
80
+ width: 100vw;
81
+ height: 100vh;
82
+ top: 0;
83
+ left: 0;
84
+ z-index: 3;
85
+ background: rgba(0, 0, 0, 0.3);
90
86
  overflow: hidden;
91
- color: #000;
92
- }
93
- .box-h5 {
94
- width: 100%;
95
- height: 100%;
96
- padding: 0px;
97
- border-radius: 0px;
98
- justify-content: flex-start;
99
- .title {
87
+ .box {
100
88
  @include flex;
101
- box-sizing: border-box;
89
+ border-radius: 0.5rem;
90
+ background: #fff;
91
+ overflow: hidden;
92
+ color: #000;
93
+ }
94
+ .box-h5 {
102
95
  width: 100%;
103
- padding: 15px 18px;
104
- position: relative;
105
- .title-back {
106
- position: absolute;
107
- left: 18px;
108
- }
109
- .title-name {
110
- font-size: 18px;
111
- font-weight: 500;
96
+ height: 100%;
97
+ padding: 0px;
98
+ border-radius: 0px;
99
+ justify-content: flex-start;
100
+ .title {
101
+ @include flex;
102
+ box-sizing: border-box;
103
+ width: 100%;
104
+ padding: 15px 18px;
105
+ position: relative;
106
+ .title-back {
107
+ position: absolute;
108
+ left: 18px;
109
+ }
110
+ .title-name {
111
+ font-size: 18px;
112
+ font-weight: 500;
113
+ }
112
114
  }
113
115
  }
114
116
  }
115
- }
116
117
 
117
- .message-marked {
118
- overflow: hidden;
119
- word-break: break-word;
120
- white-space: normal;
121
- display: flex;
122
- flex-direction: column;
123
- justify-content: flex-start;
124
- margin: 0;
125
- padding: 0;
126
- user-select: text;
127
-
128
- .message-marked_code-container {
118
+ .message-marked {
119
+ overflow: hidden;
120
+ word-break: break-word;
121
+ white-space: normal;
129
122
  display: flex;
130
123
  flex-direction: column;
131
124
  justify-content: flex-start;
132
- border-radius: 9px;
133
- margin: 0 0 10px;
134
- padding: 1em;
135
- overflow: hidden;
136
- }
125
+ margin: 0;
126
+ padding: 0;
127
+ user-select: text;
128
+
129
+ .message-marked_code-container {
130
+ display: flex;
131
+ flex-direction: column;
132
+ justify-content: flex-start;
133
+ border-radius: 9px;
134
+ margin: 0 0 10px;
135
+ padding: 1em;
136
+ overflow: hidden;
137
+ }
137
138
 
138
- .message-marked_code-header {
139
- display: flex;
140
- justify-content: space-between;
141
- }
139
+ .message-marked_code-header {
140
+ display: flex;
141
+ justify-content: space-between;
142
+ }
142
143
 
143
- .message-marked_code-content {
144
- overflow: auto;
145
- }
144
+ .message-marked_code-content {
145
+ overflow: auto;
146
+ }
146
147
 
147
- body, div, ul, ol, dt, dd, li, dl, h1, h2, h3, h4, p {
148
- margin: 0 0 1em;
149
- }
148
+ body, div, ul, ol, dt, dd, li, dl, h1, h2, h3, h4, p {
149
+ margin: 0 0 1em;
150
+ }
150
151
 
151
- ul,ol,li {
152
- list-style: disc;
153
- list-style-type: disc;
154
- }
152
+ ul,ol,li {
153
+ list-style: disc;
154
+ list-style-type: disc;
155
+ }
155
156
 
156
- ul,ol {
157
- padding-left: 40px;
158
- display: flex;
159
- flex-direction: column;
160
- justify-content: flex-start;
161
- }
157
+ ul,ol {
158
+ padding-left: 40px;
159
+ display: flex;
160
+ flex-direction: column;
161
+ justify-content: flex-start;
162
+ }
162
163
 
163
- li {
164
- padding: 0 0 5px;
165
- margin: 0;
166
- }
164
+ li {
165
+ padding: 0 0 5px;
166
+ margin: 0;
167
+ }
167
168
 
168
- img {
169
- overflow: hidden;
170
- object-fit: contain;
171
- max-width: 100%;
169
+ img {
170
+ overflow: hidden;
171
+ object-fit: contain;
172
+ max-width: 100%;
173
+ }
174
+
175
+ a {
176
+ color: #0052d9 !important;
177
+ cursor: pointer;
178
+ }
172
179
  }
173
180
 
174
- a {
175
- color: #0052d9;
176
- cursor: pointer;
181
+ .message-marked >*:last-child {
182
+ margin-bottom: 0;
177
183
  }
178
184
  }
179
185
 
180
- .message-marked >*:last-child {
181
- margin-bottom: 0;
182
- }
@@ -2,7 +2,7 @@
2
2
  <div>
3
3
  <div class="message-stream">
4
4
  <span v-if="isCursorBlinking" class="blinking-cursor">|</span>
5
- <pre ref="preRef" :class="['message-marked']" v-html="displayedContent" />
5
+ <div ref="preRef" :class="['message-marked']" v-html="displayedContent" />
6
6
  </div>
7
7
  <div v-if="image" class="rich-image-previewer" @click="closeImage">
8
8
  <img
@@ -157,6 +157,11 @@ const closeImage = () => {
157
157
  user-select: text;
158
158
  }
159
159
 
160
+ ::v-deep .message-marked li {
161
+ list-style: disc;
162
+ margin-left: 1.5em;
163
+ }
164
+
160
165
  .blinking-cursor {
161
166
  animation: blink 0.8s step-end infinite;
162
167
  color: #000;
@@ -1,63 +1,66 @@
1
- body, div, ul, ol, dt, dd, li, dl, h1, h2, h3, h4, p {
2
- margin:0;
3
- padding:0;
4
- font-style:normal;
5
-
6
- /* font:12px/22px"\5B8B\4F53",Arial,Helvetica,sans-serif; */
7
- }
8
-
9
- ol, ul, li {
10
- list-style:none;
11
- }
12
-
13
- img {
14
- border:0;
15
- vertical-align:middle;
16
- pointer-events:none;
17
- }
18
-
19
- body {
20
- color:#000;
21
- background:#FFF;
22
- }
23
-
24
- .clear {
25
- clear:both;
26
- height:1px;
27
- width:100%;
28
- overflow:hidden;
29
- margin-top:-1px;
30
- }
31
-
32
- a {
33
- color:#000;
34
- text-decoration:none;
35
- cursor: pointer;
36
- }
37
-
38
- a:hover {
39
- text-decoration:none;
40
- }
41
-
42
- input, textarea {
43
- user-select: auto;
44
- }
45
-
46
- input:focus, input:active, textarea:focus, textarea:active {
47
- outline: none;
48
- }
49
-
50
- .chat-aside {
51
- position: absolute;
52
- top: 50px;
53
- right: 0;
54
- box-sizing: border-box;
55
- width: 360px !important;
56
- border-radius: 8px 0 0 8px;
57
- z-index: 9999;
58
- max-height: calc(100% - 50px);
59
- }
60
-
61
- pre, button, input, select, textarea {
62
- font-family: inherit;
1
+ .ai-desk-customer {
2
+ :where(ul, ol, li) {
3
+ margin: 0;
4
+ padding: 0;
5
+ font-style: normal;
6
+ list-style: none;
7
+ }
8
+
9
+ :where(dt, dd, dl, h1, h2, h3, h4, p) {
10
+ margin: 0;
11
+ padding: 0;
12
+ font-style: normal;
13
+ }
14
+
15
+ :where(img) {
16
+ border:0;
17
+ vertical-align:middle;
18
+ pointer-events:none;
19
+ }
20
+
21
+ :where(body) {
22
+ color:#000;
23
+ background:#FFF;
24
+ }
25
+
26
+ .clear {
27
+ clear:both;
28
+ height:1px;
29
+ width:100%;
30
+ overflow:hidden;
31
+ margin-top:-1px;
32
+ }
33
+
34
+ :where(a) {
35
+ color:#000;
36
+ text-decoration:none;
37
+ cursor: pointer;
38
+ }
39
+
40
+ :where(a:hover) {
41
+ text-decoration:none;
42
+ }
43
+
44
+ :where(input, textarea) {
45
+ user-select: auto;
46
+ }
47
+
48
+ :where(input:focus, input:active, textarea:focus, textarea:active) {
49
+ outline: none;
50
+ }
51
+
52
+ .chat-aside {
53
+ position: absolute;
54
+ top: 50px;
55
+ right: 0;
56
+ box-sizing: border-box;
57
+ width: 360px !important;
58
+ border-radius: 8px 0 0 8px;
59
+ z-index: 9999;
60
+ max-height: calc(100% - 50px);
61
+ }
62
+
63
+ :where(pre, button, input, select, textarea) {
64
+ font-family: inherit;
65
+ }
63
66
  }
@@ -1,50 +1,52 @@
1
- body, div, ul, ol, dt, dd, li, dl, h1, h2, h3, h4, p {
2
- margin:0;
3
- padding:0;
4
- font-style:normal;
5
-
6
- /* font:12px/22px"\5B8B\4F53",Arial,Helvetica,sans-serif; */
7
- }
8
-
9
- ol, ul, li {
10
- list-style:none;
11
- }
12
-
13
- img {
14
- border:0;
15
- vertical-align:middle;
16
- pointer-events:none;
17
- }
18
-
19
- body {
20
- color:#000;
21
- background:#FFF;
22
- }
23
-
24
- .clear {
25
- clear:both;
26
- height:1px;
27
- width:100%;
28
- overflow:hidden;
29
- margin-top:-1px;
30
- }
31
-
32
- a {
33
- color:#000;
34
- text-decoration:none;
35
- cursor: pointer;
36
- }
37
-
38
- a:hover {
39
- text-decoration:none;
40
- }
41
-
42
- input, textarea {
43
- user-select: auto;
44
- }
45
-
46
- input:focus, input:active, textarea:focus, textarea:active {
47
- outline: none;
1
+ .ai-desk-customer {
2
+ :where(body, div, ul, ol, dt, dd, li, dl, h1, h2, h3, h4, p) {
3
+ margin:0;
4
+ padding:0;
5
+ font-style:normal;
6
+
7
+ /* font:12px/22px"\5B8B\4F53",Arial,Helvetica,sans-serif; */
8
+ }
9
+
10
+ :where(ol, ul, li) {
11
+ list-style:none;
12
+ }
13
+
14
+ :where(img) {
15
+ border:0;
16
+ vertical-align:middle;
17
+ pointer-events:none;
18
+ }
19
+
20
+ :where(body) {
21
+ color:#000;
22
+ background:#FFF;
23
+ }
24
+
25
+ .clear {
26
+ clear:both;
27
+ height:1px;
28
+ width:100%;
29
+ overflow:hidden;
30
+ margin-top:-1px;
31
+ }
32
+
33
+ :where(a) {
34
+ color:#000;
35
+ text-decoration:none;
36
+ cursor: pointer;
37
+ }
38
+
39
+ :where(a:hover) {
40
+ text-decoration:none;
41
+ }
42
+
43
+ :where(input, textarea) {
44
+ user-select: auto;
45
+ }
46
+
47
+ :where(input:focus, input:active, textarea:focus, textarea:active) {
48
+ outline: none;
49
+ }
48
50
  }
49
51
 
50
52
  .chat-aside {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tencentcloud/ai-desk-customer-vue",
3
- "version": "1.7.1",
3
+ "version": "1.7.2",
4
4
  "description": "Vue2/Vue3 UIKit for AI Desk",
5
5
  "main": "index",
6
6
  "keywords": [
package/utils/utils.ts CHANGED
@@ -439,13 +439,8 @@ export function getStyledATagFromText(text, className, linkColor, title) {
439
439
  return matchedUrl; // 不处理包含中文的URL
440
440
  }
441
441
 
442
- let isURLInText = false;
443
- if (matchedUrl !== preprocessedText) {
444
- // 如果 text 里包含 url,我们就用 matchedUrl 作为 a 标签的值;否则用 text 作为值
445
- isURLInText = true;
446
- }
447
442
  const encodedUrl = encodeURI(matchedUrl);
448
- return `<a target="_blank" rel="noreferrer noopener" class="${className}" style="color:${linkColor}; text-decoration:underline" href="${encodedUrl}" title="${title || ''}">${isURLInText ? matchedUrl : text}</a>`;
443
+ return `<a target="_blank" rel="noreferrer noopener" class="${className}" style="color:${linkColor}; text-decoration:underline" href="${encodedUrl}" title="${title || ''}">${title ? title : matchedUrl}</a>`;
449
444
  });
450
445
  return ret;
451
446
  }