@tencentcloud/ai-desk-customer-vue 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## [1.1.0] (2025-4-23)
2
+
3
+ ### Features
4
+ - 新增 `initWithProfile` 接口,支持用户端带昵称(nickName)和头像(avatar)登录,提升人工客服与用户交互体验。
5
+ - 优化流式消息和 markdown 图文混排的产品体验。
6
+ - 优化发消息体验。
7
+ - 优化组件样式。
8
+
1
9
  ## [1.0.1] (2025-4-16)
2
10
 
3
11
  ### Features
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1C66E5"><path d="M440-320h80v-168l64 64 56-56-160-160-160 160 56 56 64-64v168Zm40 240q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Z"/></svg>
@@ -18,6 +18,7 @@
18
18
  @sendMessage="sendMessage"
19
19
  @onTyping="onTyping"
20
20
  @blurToolAndEmojiH5="blurToolAndEmojiH5"
21
+ @isInputNotEmpty="isInputNotEmpty"
21
22
  />
22
23
  <MessageInputButton
23
24
  v-if="!props.isMuted && isPC"
@@ -26,9 +27,12 @@
26
27
  <div v-if="isH5" class="emoji-icon" id="emoji-icon-h5" @click="emojiShow">
27
28
  <Icon :file="emojiIcon" width="24px" height="24px"/>
28
29
  </div>
29
- <div v-if="isH5" class="tool-icon" @click="toolShow">
30
+ <div v-show="isH5 && !showSendButton" class="tool-icon" @click="toolShow">
30
31
  <Icon :file="toolIcon" width="24px" height="24px"/>
31
32
  </div>
33
+ <div v-show="isH5 && showSendButton" class="send-button-h5" @click="sendMessage">
34
+ <Icon :file="sendButtonIcon" width="34px" height="34px"/>
35
+ </div>
32
36
  </div>
33
37
 
34
38
  </div>
@@ -50,6 +54,7 @@ import { isPC,isH5 } from '../../../utils/env';
50
54
  import Icon from '../../common/Icon.vue';
51
55
  import emojiIcon from '../../../assets/emoji.png';
52
56
  import toolIcon from '../../../assets/more_tools.png';
57
+ import sendButtonIcon from '../../../assets/send_button_h5.svg';
53
58
  const { ref,onMounted,onBeforeUnmount } = vue;
54
59
 
55
60
  const props = defineProps({
@@ -83,6 +88,7 @@ const emit = defineEmits(['sendMessage', 'resetReplyOrReference', 'onTyping','sc
83
88
  const editor = ref<InstanceType<typeof MessageInputEditor>>();
84
89
  const currentConversation = ref<IConversationModel>();
85
90
  const h5Dialog = ref<HTMLElement>();
91
+ const showSendButton = ref(false);
86
92
 
87
93
  onMounted(() => {
88
94
  // document.addEventListener('click', handleClick);
@@ -126,17 +132,23 @@ const sendMessage = async () => {
126
132
  await sendMessages(editorContentList, currentConversation.value);
127
133
  emit('sendMessage');
128
134
  editor.value?.resetEditor();
135
+ showSendButton.value = false;
129
136
  };
130
137
 
131
138
  const insertEmoji = (emoji: any) => {
132
139
  editor.value?.addEmoji(emoji);
140
+ showSendButton.value = true;
133
141
  };
134
142
 
135
143
  const reEdit = (content: any) => {
136
144
  editor.value?.resetEditor();
145
+ showSendButton.value = false;
137
146
  editor.value?.setEditorContent(content);
138
147
  };
139
148
 
149
+ const isInputNotEmpty = (show: boolean) => {
150
+ showSendButton.value = show;
151
+ }
140
152
 
141
153
  const emojiShow = () => {
142
154
  emit('emojiShow');
@@ -205,11 +217,8 @@ defineExpose({
205
217
  height: fit-content;
206
218
  background: #fff;
207
219
  }
208
- .send-btn-h5 {
209
- background-color: #1c66e5;
210
- color: #fff;
211
- padding: 10px 12px;
212
- margin-left: 5px;
213
- border-radius: 7px;
220
+ .send-button-h5 {
221
+ margin-right: 10px;
222
+ margin-left: 5px;
214
223
  }
215
224
  </style>
@@ -76,7 +76,7 @@ const props = defineProps({
76
76
  },
77
77
  });
78
78
 
79
- const emits = defineEmits(['sendMessage', 'onTyping','blurToolAndEmojiH5']);
79
+ const emits = defineEmits(['sendMessage', 'onTyping', 'blurToolAndEmojiH5', 'isInputNotEmpty']);
80
80
  const { placeholder, enableDragUpload, enableTyping } = toRefs(props);
81
81
  const isEditorEmpty = ref<boolean>(true);
82
82
  const isEditorBlur = ref<boolean>(true);
@@ -214,9 +214,12 @@ function handleEnter(e: any) {
214
214
  }
215
215
  }
216
216
 
217
- function handleH5Input() {
217
+ function handleH5Input(e: any) {
218
218
  if (isH5) {
219
219
  isEditorEmpty.value = editorDom.value?.childNodes ? false : true;
220
+ const hasText = e.target.innerText.trim().length > 0;
221
+ const hasImages = e.target.querySelectorAll('img.custom-image-emoji').length > 0;
222
+ emits('isInputNotEmpty', hasText || hasImages);
220
223
  }
221
224
  }
222
225
 
@@ -47,6 +47,7 @@
47
47
  @resendMessage="resendMessage"
48
48
  @handleToggleMessageItem="handleToggleMessageItem"
49
49
  @handleH5LongPress="handleH5LongPress"
50
+ @heightChanged="onHeightChanged"
50
51
  />
51
52
  <div
52
53
  v-else
@@ -572,6 +573,10 @@ const handleH5LongPress = (e: any, message: IMessageModel, type: string) => {
572
573
  }
573
574
  };
574
575
 
576
+ function onHeightChanged() {
577
+ scrollToLatestMessage();
578
+ }
579
+
575
580
  // re-edit message
576
581
  const handleEdit = (message: IMessageModel) => {
577
582
  emits('handleEditor', message, 'reedit');
@@ -38,7 +38,10 @@
38
38
  <MessageRichText :payload="payload" />
39
39
  </div>
40
40
  <div v-if="payload.src === CUSTOM_MESSAGE_SRC.STREAM_TEXT">
41
- <MessageStream :payload="payload" />
41
+ <MessageStream
42
+ :payload="payload"
43
+ @heightChanged="onHeightChanged"
44
+ />
42
45
  </div>
43
46
  <div v-if="payload.src === CUSTOM_MESSAGE_SRC.MULTI_BRANCH">
44
47
  <MessageMultiBranch :payload="payload" @sendMessage="sendTextMessage"/>
@@ -98,7 +101,7 @@ export default {
98
101
  default: () => ({}),
99
102
  },
100
103
  },
101
- emits: ['showFormPopup'],
104
+ emits: ['showFormPopup', 'heightChanged'],
102
105
  setup(props: Props, { emit }) {
103
106
  const payload = computed<customerServicePayloadType>(() => {
104
107
  return props.message && JSONToObject(props.message?.payload?.data);
@@ -111,7 +114,10 @@ export default {
111
114
  };
112
115
  const handleShowFormPopup = (data: boolean) => {
113
116
  emit('showFormPopup', data);
114
- }
117
+ };
118
+ const onHeightChanged = () => {
119
+ emit('heightChanged');
120
+ };
115
121
 
116
122
  return {
117
123
  payload,
@@ -119,7 +125,8 @@ export default {
119
125
  sendTextMessage,
120
126
  CUSTOM_MESSAGE_SRC,
121
127
  sendCustomMessage,
122
- handleShowFormPopup
128
+ handleShowFormPopup,
129
+ onHeightChanged,
123
130
  };
124
131
  },
125
132
  };
@@ -204,13 +204,14 @@ export default {
204
204
 
205
205
  .submit-button {
206
206
  height: 50px;
207
- background-color: #0365f9;
207
+ background-color: #1c66e5;
208
208
  font-size: 18px;
209
209
  font-weight: 400;
210
210
  color: white;
211
211
  border: 0;
212
212
  border-radius: 8px;
213
213
  cursor: pointer;
214
+ width: 62%;
214
215
  }
215
216
 
216
217
  .de-active {
@@ -225,13 +225,14 @@ export default {
225
225
 
226
226
  .submit-button {
227
227
  height: 35px;
228
- background-color: #0365f9;
228
+ background-color: #1c66e5;
229
229
  font-size: 14px;
230
230
  font-weight: 400;
231
231
  color: white;
232
232
  border: 0;
233
233
  border-radius: 8px;
234
234
  cursor: pointer;
235
+ width: 62%;
235
236
  }
236
237
 
237
238
  </style>
@@ -70,7 +70,7 @@ export default {
70
70
  z-index: 101;
71
71
  width: 100vw;
72
72
  height: 100vh;
73
- background: rgba(#000, 0.3);
73
+ background: rgba(#000, 0.8);
74
74
  top: 0;
75
75
  left: 0;
76
76
  display: flex;
@@ -81,14 +81,12 @@ export default {
81
81
  }
82
82
 
83
83
  .rich-image-preview {
84
- width: 80%;
85
- height:auto;
84
+ max-width: 90%;
85
+ height: auto;
86
86
  display: flex;
87
87
  align-items: center;
88
88
  justify-content: center;
89
89
  overflow: hidden;
90
- transition: transform 0.1s ease 0s;
91
90
  pointer-events: auto;
92
91
  }
93
-
94
92
  </style>
@@ -1,6 +1,12 @@
1
1
  <template>
2
2
  <div class="message-stream">
3
- <pre :class="['message-marked']" v-html="displayedContent" />
3
+ <pre ref="preRef" :class="['message-marked']" v-html="displayedContent" />
4
+ </div>
5
+ <div v-if="image" class="rich-image-previewer" @click="closeImage">
6
+ <img
7
+ class="rich-image-preview"
8
+ :src="imageSrc"
9
+ />
4
10
  </div>
5
11
  </template>
6
12
 
@@ -11,7 +17,7 @@ import { parseMarkdown } from './marked'
11
17
  import { TypeWriter } from "./type-writer";
12
18
  import { JSONToObject } from "../../../../../../utils";
13
19
 
14
- const { ref, computed, withDefaults, defineProps, watch } = vue;
20
+ const { ref, computed, withDefaults, defineProps, watch, onMounted, onUnmounted } = vue;
15
21
 
16
22
  interface Props {
17
23
  payload: customerServicePayloadType;
@@ -22,11 +28,27 @@ const props = withDefaults(defineProps<Props>(), {
22
28
  });
23
29
 
24
30
  const isStreaming = ref<boolean>(false);
31
+ const image = ref(false);
32
+ const imageSrc = ref('');
25
33
  const chunks = ref<string>('');
26
34
  const isFinished = ref<boolean>(true);
27
35
  const prevChunksLength = ref<number>(0);
28
36
  const streamContent = ref<string>('');
29
- const displayedContent = computed(() => parseMarkdown(streamContent.value));
37
+ const displayedContent = computed(() => {
38
+ // @ts-ignore
39
+ window.onMarkdownImageClicked = function(href:string) {
40
+ image.value = !image.value;
41
+ imageSrc.value = decodeURIComponent(href);
42
+ }
43
+ return parseMarkdown(streamContent.value);
44
+ });
45
+ const preRef = ref();
46
+ const emits = defineEmits(['heightChanged']);
47
+ // 不支持 ResizeObserver 的浏览器太老旧,默认不处理了
48
+ const canIUseResizeObserver = typeof ResizeObserver === 'undefined' ? false : true;
49
+
50
+ let observer;
51
+ let prevHeight;
30
52
 
31
53
  const typeWriter = new TypeWriter({
32
54
  onTyping: (item: string) => {
@@ -60,15 +82,52 @@ watch(() => props.payload, (newValue: string, oldValue: string) => {
60
82
  // disable typeWriter style or history message first load
61
83
  streamContent.value = chunks.value;
62
84
  } else {
63
- const _newChunksToAdd = chunks.value?.slice(prevChunksLength.value);
64
- startStreaming([_newChunksToAdd]);
85
+ // 判断长度是为了防御编辑的内容回退的异常 case
86
+ if (chunks.value.length > prevChunksLength.value) {
87
+ const _newChunksToAdd = chunks.value?.slice(prevChunksLength.value);
88
+ prevChunksLength.value = chunks.value.length;
89
+ startStreaming([_newChunksToAdd]);
90
+ }
65
91
  }
66
- prevChunksLength.value = chunks.value?.length;
67
92
  }, {
68
93
  deep: true,
69
94
  immediate: true,
70
95
  },
71
96
  );
97
+
98
+ onMounted(() => {
99
+ if (canIUseResizeObserver) {
100
+ observer = new ResizeObserver(entries => {
101
+ for (let entry of entries) {
102
+ observeHeightChanged(entry.contentRect.height);
103
+ }
104
+ });
105
+ // 开始观察
106
+ observer.observe(preRef.value);
107
+ }
108
+ });
109
+
110
+ onUnmounted(() => {
111
+ if (canIUseResizeObserver) {
112
+ if (observer) {
113
+ observer.disconnect();
114
+ observer = null;
115
+ }
116
+ }
117
+ });
118
+
119
+ const observeHeightChanged = (newHeight) => {
120
+ if (prevHeight !== newHeight) {
121
+ prevHeight = newHeight;
122
+ emits('heightChanged');
123
+ }
124
+ };
125
+
126
+ const closeImage = () => {
127
+ image.value = !image.value;
128
+ imageSrc.value = '';
129
+ };
130
+
72
131
  </script>
73
132
  <style lang="scss" scoped>
74
133
  .message-stream {
@@ -13,6 +13,7 @@
13
13
  <MessageCustomerService
14
14
  v-if="pluginMessageType.pluginType === 'customer'"
15
15
  :message="props.message"
16
+ @heightChanged="onHeightChanged"
16
17
  />
17
18
  </template>
18
19
  </MessagePluginLayout>
@@ -44,6 +45,7 @@ const emits = defineEmits([
44
45
  'resendMessage',
45
46
  'handleToggleMessageItem',
46
47
  'handleH5LongPress',
48
+ 'heightChanged',
47
49
  ]);
48
50
  const messageModel = computed(() => TUIStore.getMessageModel(props.message.ID));
49
51
 
@@ -76,6 +78,9 @@ const handleToggleMessageItem = (
76
78
  const handleH5LongPress = (e: any, message: IMessageModel, type: string) => {
77
79
  emits('handleH5LongPress', e, message, type);
78
80
  };
81
+ const onHeightChanged = () => {
82
+ emits('heightChanged');
83
+ };
79
84
  </script>
80
85
 
81
86
  <style lang="scss" scoped>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tencentcloud/ai-desk-customer-vue",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Vue UIKit for AI Desk",
5
5
  "main": "index",
6
6
  "keywords": [
package/server.ts CHANGED
@@ -12,22 +12,33 @@ import TUIChatEngine, {
12
12
  TUITranslateService,
13
13
  SendMessageParams,
14
14
  SendMessageOptions,
15
+ TUIUserService,
15
16
  } from '@tencentcloud/chat-uikit-engine';
16
17
  import Log from './utils/logger';
17
18
  import { version } from './package.json'
18
19
  import { Toast, TOAST_TYPE } from "./components/common/Toast/index-web";
19
20
 
21
+ interface IInitWithProfile {
22
+ SDKAppID: number,
23
+ userID: string,
24
+ userSig: string,
25
+ nickName?: string,
26
+ avatar?: string,
27
+ }
28
+
20
29
  export default class TUICustomerServer {
21
30
  private isLoggedIn: boolean;
22
31
  static instance: TUICustomerServer;
23
32
  private customerServiceAccounts: any[];
24
33
  private loggedInUserID: string;
34
+ private myProfile: object;
25
35
  constructor() {
26
36
  TUICore.registerService(TUIConstants.TUICustomerServicePlugin.SERVICE.NAME, this);
27
37
  TUICore.registerExtension(TUIConstants.TUIContact.EXTENSION.CONTACT_LIST.EXT_ID, this);
28
38
  this.customerServiceAccounts = ['@customer_service_account'];
29
39
  this.isLoggedIn = false;
30
40
  this.loggedInUserID = '';
41
+ this.myProfile = {};
31
42
  }
32
43
 
33
44
  static getInstance(): TUICustomerServer {
@@ -61,12 +72,8 @@ export default class TUICustomerServer {
61
72
  })
62
73
  }
63
74
 
64
- /**
65
- * init
66
- */
67
75
  public init(SDKAppID:number, userID:string, userSig:string) {
68
76
  Log.l(`TUICustomerServer.init version:${version} SDKAppID:${SDKAppID} userID:${userID} isLoggedIn:${this.isLoggedIn} loggedInUserID:${this.loggedInUserID}`);
69
- // Backward compatibility, the new version executes the init operation by default in index.ts
70
77
  if (this.isLoggedIn) {
71
78
  if (this.loggedInUserID === userID) {
72
79
  return;
@@ -76,11 +83,23 @@ export default class TUICustomerServer {
76
83
  this.loginCustomerUIKit(SDKAppID, userID, userSig);
77
84
  });
78
85
  } else {
79
- // Execute call server when native plugin TUICallKit exists
80
86
  this.loginCustomerUIKit(SDKAppID, userID, userSig);
81
87
  }
82
88
  }
83
89
 
90
+ public initWithProfile(options: IInitWithProfile) {
91
+ const { SDKAppID, userID, userSig, nickName, avatar } = options;
92
+ Log.l(`TUICustomerServer.initWithProfile version:${version}`);
93
+ if (nickName) {
94
+ // chat 个人资料的昵称是 nick
95
+ this.myProfile.nick = nickName;
96
+ }
97
+ if (avatar) {
98
+ this.myProfile.avatar = avatar;
99
+ }
100
+ this.init(SDKAppID, userID, userSig);
101
+ }
102
+
84
103
  public unInit() {
85
104
  return TUIChatEngine.logout();
86
105
  }
@@ -140,18 +159,31 @@ export default class TUICustomerServer {
140
159
  Log.l(`TUICustomerServer.onCall method:${method} params:`, params);
141
160
  if (method === TUIConstants.TUICustomerServicePlugin.SERVICE.METHOD.ACTIVE_CONVERSATION) {
142
161
  if (this.isCustomerConversation(params.conversationID)) {
143
- TUIChatService.sendCustomMessage({
144
- to: params.conversationID.slice(3),
145
- conversationType: TUIChatEngine.TYPES.CONV_C2C,
146
- payload: {
147
- data: JSON.stringify({
148
- src: '7',
149
- customerServicePlugin: 0,
150
- triggeredContent: typeof params.robotLang === 'undefined' ? undefined : { language: params.robotLang }
151
- }),
152
- },
153
- }, { onlineUserOnly: true });
162
+ // 如果有资料,确保资料更新完成(或失败)后再激活会话服务流
163
+ if (Object.keys(this.myProfile).length > 0) {
164
+ Log.l(`TUICustomerServer.onCall updateMyProfile:${JSON.stringify(this.myProfile)}`);
165
+ TUIUserService.updateMyProfile({...this.myProfile}).finally(() => {
166
+ this.activeServiceFlow(params);
167
+ });
168
+ } else {
169
+ this.activeServiceFlow(params);
170
+ }
154
171
  }
155
172
  }
156
173
  }
174
+
175
+ // 激活会话服务流
176
+ private activeServiceFlow(params: any) {
177
+ TUIChatService.sendCustomMessage({
178
+ to: params.conversationID.slice(3),
179
+ conversationType: TUIChatEngine.TYPES.CONV_C2C,
180
+ payload: {
181
+ data: JSON.stringify({
182
+ src: '7',
183
+ customerServicePlugin: 0,
184
+ triggeredContent: typeof params.robotLang === 'undefined' ? undefined : { language: params.robotLang }
185
+ }),
186
+ },
187
+ }, { onlineUserOnly: true });
188
+ }
157
189
  }