@flexem/chat-box 1.0.3 → 1.0.4

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.
@@ -332,6 +332,9 @@ Component({
332
332
  * 语音按钮按下
333
333
  */
334
334
  onVoiceStart(e) {
335
+ // 通知父组件停止语音播放
336
+ this.triggerEvent('voicerecordstart');
337
+
335
338
  // 记录按下时间,用于判断是否为快速点击
336
339
  this._voiceTouchStartTime = Date.now();
337
340
  this._voiceTouchCancelled = false;
@@ -637,7 +637,13 @@ Component({
637
637
  const endPos = remaining.indexOf(marker, nearest.pos + 2);
638
638
  if (endPos !== -1) {
639
639
  const boldText = remaining.slice(nearest.pos + 2, endPos);
640
- parts.push({ type: 'text', text: boldText, bold: true });
640
+ // 递归解析粗体内容,支持内部的链接等
641
+ const boldParts = this.processInlineElements(boldText);
642
+ // 给所有解析出的部分添加 bold 标记
643
+ boldParts.forEach(part => {
644
+ part.bold = true;
645
+ parts.push(part);
646
+ });
641
647
  remaining = remaining.slice(endPos + 2);
642
648
  } else {
643
649
  // 没有闭合,当作普通文本
@@ -660,7 +666,13 @@ Component({
660
666
  }
661
667
  if (endPos !== -1) {
662
668
  const italicText = remaining.slice(nearest.pos + 1, endPos);
663
- parts.push({ type: 'text', text: italicText, italic: true, bold: false });
669
+ // 递归解析斜体内容,支持内部的链接等
670
+ const italicParts = this.processInlineElements(italicText);
671
+ // 给所有解析出的部分添加 italic 标记
672
+ italicParts.forEach(part => {
673
+ part.italic = true;
674
+ parts.push(part);
675
+ });
664
676
  remaining = remaining.slice(endPos + 1);
665
677
  } else {
666
678
  // 没有闭合,当作普通文本
@@ -964,6 +976,13 @@ Component({
964
976
  this.triggerEvent('playvoice', { id, content: message.content });
965
977
  },
966
978
 
979
+ /**
980
+ * 停止语音播放
981
+ */
982
+ onStopVoice() {
983
+ this.triggerEvent('stopvoice');
984
+ },
985
+
967
986
  /**
968
987
  * 重新生成
969
988
  */
@@ -1003,10 +1022,26 @@ Component({
1003
1022
  const touch = e.touches[0] || e.changedTouches[0];
1004
1023
  if (!touch) return;
1005
1024
 
1006
- // 计算气泡菜单位置(在触摸点上方)
1007
- const menuHeight = 120; // 气泡菜单大约高度
1025
+ // 获取屏幕宽度
1026
+ const systemInfo = wx.getWindowInfo();
1027
+ const screenWidth = systemInfo.windowWidth;
1028
+
1029
+ // 气泡菜单实际宽度:3个按钮(min-width 120rpx + padding 48rpx) + 外层 padding 16rpx = 约 520rpx
1030
+ // rpx 转 px: px = rpx / 750 * screenWidth
1031
+ const menuWidth = 520 / 750 * screenWidth;
1032
+ const menuHeight = 120;
1033
+
1034
+ // 计算气泡菜单位置(在触摸点上方,水平居中但不超出屏幕)
1008
1035
  const top = touch.clientY - menuHeight - 20;
1009
- const left = touch.clientX;
1036
+ let left = touch.clientX;
1037
+
1038
+ // 确保菜单不超出左右边界(菜单使用 translateX(-50%))
1039
+ const halfMenu = menuWidth / 2;
1040
+ const minLeft = halfMenu + 16;
1041
+ const maxLeft = screenWidth - halfMenu - 16;
1042
+
1043
+ // 限制 left 在有效范围内
1044
+ left = Math.max(minLeft, Math.min(maxLeft, left));
1010
1045
 
1011
1046
  this.setData({
1012
1047
  showBubbleMenu: true,
@@ -83,10 +83,10 @@
83
83
  <text class="code-lang">{{item.lang || 'code'}}</text>
84
84
  <text class="code-copy" bindtap="onCopyCode" data-code="{{item.text}}">复制</text>
85
85
  </view>
86
- <!-- 使用 view + CSS overflow 替代 scroll-view,避免内容更新时跳动 -->
87
- <view class="code-content-wrapper">
86
+ <!-- 使用 scroll-view 实现横向滚动 -->
87
+ <scroll-view class="code-content-wrapper" scroll-x="true" enhanced="true" show-scrollbar="true">
88
88
  <text class="code-text" user-select="{{true}}">{{item.text}}</text>
89
- </view>
89
+ </scroll-view>
90
90
  </view>
91
91
 
92
92
  <!-- 行内代码 -->
@@ -251,7 +251,7 @@
251
251
  <text class="bubble-menu-text">复制全文</text>
252
252
  </view>
253
253
  <view class="bubble-menu-item" bindtap="onSelectCopy">
254
- <image class="bubble-menu-icon" src="../../assets/icons/icon-edit-msg.svg" mode="aspectFit" />
254
+ <text class="bubble-menu-icon-text">[A]</text>
255
255
  <text class="bubble-menu-text">节选复制</text>
256
256
  </view>
257
257
  <view class="bubble-menu-item" bindtap="onBubblePlayVoice">
@@ -265,12 +265,12 @@
265
265
 
266
266
  <!-- 正在输入指示器 -->
267
267
  <view wx:if="{{message.isStreaming}}" class="typing-indicator">
268
- <!-- 语音播放中时显示播放动画 -->
269
- <block wx:if="{{isPlaying}}">
268
+ <!-- 语音播放中时显示播放动画,点击可停止 -->
269
+ <view wx:if="{{isPlaying}}" class="voice-playing-streaming" bindtap="onStopVoice">
270
270
  <view class="voice-bar"></view>
271
271
  <view class="voice-bar"></view>
272
272
  <view class="voice-bar"></view>
273
- </block>
273
+ </view>
274
274
  <!-- 未播放时显示打字动画 -->
275
275
  <block wx:else>
276
276
  <view class="typing-dot"></view>
@@ -15,6 +15,7 @@
15
15
 
16
16
  .ai-message {
17
17
  flex-direction: row;
18
+ overflow: visible;
18
19
  }
19
20
 
20
21
  /* 头像 */
@@ -46,6 +47,7 @@
46
47
  flex: 1;
47
48
  align-items: flex-start;
48
49
  max-width: calc(100%);
50
+ overflow: visible;
49
51
  }
50
52
 
51
53
  /* 消息气泡 */
@@ -67,7 +69,6 @@
67
69
  .ai-bubble {
68
70
  background-color: #fff;
69
71
  color: #333;
70
- border-bottom-left-radius: 8rpx;
71
72
  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
72
73
  }
73
74
 
@@ -132,8 +133,12 @@
132
133
  }
133
134
 
134
135
  .thinking-icon {
135
- width: 32rpx;
136
- height: 32rpx;
136
+ width: 32rpx !important;
137
+ height: 32rpx !important;
138
+ min-width: 32rpx !important;
139
+ min-height: 32rpx !important;
140
+ max-width: 32rpx !important;
141
+ max-height: 32rpx !important;
137
142
  }
138
143
 
139
144
  .thinking-title {
@@ -267,12 +272,11 @@
267
272
  overflow-x: auto;
268
273
  }
269
274
 
270
- /* 代码内容容器 - 使用纯 CSS overflow 替代 scroll-view,避免内容更新时跳动 */
275
+ /* 代码内容容器 - 使用 scroll-view 实现横向滚动 */
271
276
  .code-content-wrapper {
272
277
  padding: 20rpx;
273
- overflow-x: auto;
274
- overflow-y: hidden;
275
- -webkit-overflow-scrolling: touch;
278
+ width: 100%;
279
+ box-sizing: border-box;
276
280
  }
277
281
 
278
282
  .code-text {
@@ -282,7 +286,6 @@
282
286
  white-space: pre;
283
287
  word-break: normal;
284
288
  display: inline-block;
285
- min-width: 100%;
286
289
  }
287
290
 
288
291
  /* 行内代码 */
@@ -461,8 +464,12 @@
461
464
  }
462
465
 
463
466
  .action-icon-img {
464
- width: 40rpx;
465
- height: 40rpx;
467
+ width: 40rpx !important;
468
+ height: 40rpx !important;
469
+ min-width: 40rpx !important;
470
+ min-height: 40rpx !important;
471
+ max-width: 40rpx !important;
472
+ max-height: 40rpx !important;
466
473
  padding: 8rpx;
467
474
  }
468
475
 
@@ -482,6 +489,17 @@
482
489
  padding: 8rpx;
483
490
  }
484
491
 
492
+ /* 流式消息中的语音播放动画(可点击停止) */
493
+ .voice-playing-streaming {
494
+ display: flex;
495
+ align-items: center;
496
+ justify-content: center;
497
+ gap: 4rpx;
498
+ padding: 8rpx 16rpx;
499
+ background-color: rgba(0, 122, 255, 0.1);
500
+ border-radius: 20rpx;
501
+ }
502
+
485
503
  .voice-bar {
486
504
  width: 6rpx;
487
505
  height: 20rpx;
@@ -610,12 +628,25 @@
610
628
  }
611
629
 
612
630
  .bubble-menu-icon {
613
- width: 48rpx;
614
- height: 48rpx;
631
+ width: 48rpx !important;
632
+ height: 48rpx !important;
633
+ min-width: 48rpx !important;
634
+ min-height: 48rpx !important;
635
+ max-width: 48rpx !important;
636
+ max-height: 48rpx !important;
615
637
  margin-bottom: 8rpx;
616
638
  filter: brightness(0) invert(1);
617
639
  }
618
640
 
641
+ .bubble-menu-icon-text {
642
+ font-size: 36rpx;
643
+ font-weight: bold;
644
+ color: #fff;
645
+ margin-bottom: 8rpx;
646
+ height: 48rpx;
647
+ line-height: 48rpx;
648
+ }
649
+
619
650
  .bubble-menu-text {
620
651
  font-size: 24rpx;
621
652
  color: #fff;
@@ -82,6 +82,7 @@ Component({
82
82
  keyboardHeight: 0, // 键盘高度
83
83
  attachmentHeight: 0, // 附件预览区域高度
84
84
  inputBarHeight: 0, // 输入框高度增量(多行时相对于单行的额外高度,rpx转px)
85
+ safeAreaBottom: 0, // 底部安全区域高度
85
86
 
86
87
  // 会话相关
87
88
  currentSession: {}, // 当前会话
@@ -98,6 +99,7 @@ Component({
98
99
  // 加载状态
99
100
  isStreaming: false, // 是否正在流式输出
100
101
  lastScrollTime: 0, // 上次滚动时间(用于节流)
102
+ userScrolledUp: false, // 用户是否手动向上滚动(流式输出时暂停自动滚动)
101
103
  hasMoreHistory: false, // 是否有更多历史消息
102
104
  isLoadingHistory: false, // 是否正在加载历史消息
103
105
  historyPage: 1, // 历史消息页码
@@ -159,6 +161,20 @@ Component({
159
161
  }
160
162
  },
161
163
 
164
+ /**
165
+ * 页面生命周期(组件所在页面的生命周期)
166
+ */
167
+ pageLifetimes: {
168
+ hide() {
169
+ // 页面隐藏时停止语音播放(如用户点击小程序关闭按钮)
170
+ audio.stop();
171
+ this.setData({
172
+ playingMessageId: null,
173
+ synthesizingMessageId: null
174
+ });
175
+ }
176
+ },
177
+
162
178
  /**
163
179
  * 组件的方法列表
164
180
  */
@@ -179,11 +195,15 @@ Component({
179
195
  // 导航栏总高度 = 胶囊按钮底部 + 与顶部相同的边距
180
196
  const navBarHeight = menuButton.bottom + (menuButton.top - statusBarHeight);
181
197
 
198
+ // 计算底部安全区域高度
199
+ const safeAreaBottom = systemInfo.screenHeight - systemInfo.safeArea.bottom;
200
+
182
201
  this.setData({
183
202
  statusBarHeight,
184
203
  menuButtonTop,
185
204
  menuButtonHeight,
186
- navBarHeight
205
+ navBarHeight,
206
+ safeAreaBottom
187
207
  });
188
208
  },
189
209
 
@@ -340,6 +360,13 @@ Component({
340
360
  * 返回上一页
341
361
  */
342
362
  onBack() {
363
+ // 停止语音播放
364
+ audio.stop();
365
+ this.setData({
366
+ playingMessageId: null,
367
+ synthesizingMessageId: null
368
+ });
369
+
343
370
  // 触发 back 事件,让外部处理返回逻辑
344
371
  this.triggerEvent('back');
345
372
 
@@ -685,6 +712,12 @@ Component({
685
712
 
686
713
  if (!content && (!attachments || attachments.length === 0)) return;
687
714
 
715
+ // 停止正在播放的语音
716
+ if (this.data.playingMessageId) {
717
+ audio.stop();
718
+ this.setData({ playingMessageId: null, synthesizingMessageId: null });
719
+ }
720
+
688
721
  // 检查登录状态
689
722
  if (!this.getAiChatUrl() || !this.data.userToken) {
690
723
  this.triggerEvent('login');
@@ -764,6 +797,7 @@ Component({
764
797
 
765
798
  this.setData({
766
799
  isStreaming: true,
800
+ userScrolledUp: false, // 重置用户滚动状态
767
801
  streamingMessage: {
768
802
  id: aiMessageId, // 使用固定 ID,便于跟踪播放状态
769
803
  role: 'assistant',
@@ -989,6 +1023,12 @@ Component({
989
1023
  return;
990
1024
  }
991
1025
 
1026
+ // 停止正在播放的语音
1027
+ if (this.data.playingMessageId) {
1028
+ audio.stop();
1029
+ this.setData({ playingMessageId: null, synthesizingMessageId: null });
1030
+ }
1031
+
992
1032
  // 重新生成应该是针对用户消息的
993
1033
  if (message.role !== 'user') {
994
1034
  console.error('重新生成只能用于用户消息');
@@ -1056,6 +1096,12 @@ Component({
1056
1096
  return;
1057
1097
  }
1058
1098
 
1099
+ // 停止正在播放的语音
1100
+ if (this.data.playingMessageId) {
1101
+ audio.stop();
1102
+ this.setData({ playingMessageId: null, synthesizingMessageId: null });
1103
+ }
1104
+
1059
1105
  this.setData({
1060
1106
  editingContent: message.content,
1061
1107
  editingMessage: message
@@ -1176,6 +1222,30 @@ Component({
1176
1222
  }
1177
1223
  },
1178
1224
 
1225
+ /**
1226
+ * 停止语音播放
1227
+ */
1228
+ onStopVoice() {
1229
+ audio.stop();
1230
+ this.setData({
1231
+ playingMessageId: null,
1232
+ synthesizingMessageId: null
1233
+ });
1234
+ },
1235
+
1236
+ /**
1237
+ * 按住说话开始时停止语音播放
1238
+ */
1239
+ onVoiceRecordStart() {
1240
+ if (this.data.playingMessageId) {
1241
+ audio.stop();
1242
+ this.setData({
1243
+ playingMessageId: null,
1244
+ synthesizingMessageId: null
1245
+ });
1246
+ }
1247
+ },
1248
+
1179
1249
  /**
1180
1250
  * 播放消息语音
1181
1251
  */
@@ -1312,12 +1382,17 @@ Component({
1312
1382
 
1313
1383
  /**
1314
1384
  * 滚动到底部(带节流,流式输出时减少滚动频率)
1315
- * @param {boolean} force - 是否强制滚动(不受节流限制)
1385
+ * @param {boolean} force - 是否强制滚动(不受节流限制和用户滚动状态限制)
1316
1386
  */
1317
1387
  scrollToBottom(force = false) {
1318
1388
  const now = Date.now();
1319
1389
  const throttleInterval = 200; // 200ms 节流
1320
1390
 
1391
+ // 流式输出时,如果用户手动向上滚动了,不自动滚动(除非强制)
1392
+ if (!force && this.data.isStreaming && this.data.userScrolledUp) {
1393
+ return;
1394
+ }
1395
+
1321
1396
  // 非强制模式下,流式输出时进行节流
1322
1397
  if (!force && this.data.isStreaming) {
1323
1398
  if (now - this.data.lastScrollTime < throttleInterval) {
@@ -1350,6 +1425,39 @@ Component({
1350
1425
  }
1351
1426
  },
1352
1427
 
1428
+ /**
1429
+ * 滚动事件处理
1430
+ * 检测用户是否手动向上滚动
1431
+ */
1432
+ onScroll(e) {
1433
+ // 只在流式输出时检测
1434
+ if (!this.data.isStreaming) {
1435
+ return;
1436
+ }
1437
+
1438
+ const { deltaY } = e.detail;
1439
+
1440
+ // deltaY > 0 表示向上滚动(手指向下滑)
1441
+ // 如果用户向上滚动,标记为手动滚动
1442
+ if (deltaY > 0) {
1443
+ if (!this.data.userScrolledUp) {
1444
+ this.setData({ userScrolledUp: true });
1445
+ }
1446
+ }
1447
+ },
1448
+
1449
+ /**
1450
+ * 滚动到底部事件
1451
+ * 用户滚动到底部时,恢复自动滚动
1452
+ */
1453
+ onScrollToLower() {
1454
+ if (this.data.isStreaming && this.data.userScrolledUp) {
1455
+ this.setData({ userScrolledUp: false });
1456
+ // 恢复后立即触发一次滚动,确保跟上最新数据
1457
+ this.scrollToBottom(true);
1458
+ }
1459
+ },
1460
+
1353
1461
  /**
1354
1462
  * 格式化时间(完整格式:2025/09/18 17:41:22)
1355
1463
  */
@@ -87,12 +87,15 @@
87
87
  <!-- 消息列表区域 -->
88
88
  <scroll-view
89
89
  class="message-list"
90
- style="margin-top: {{navBarHeight}}px; padding-bottom: {{keyboardHeight > 0 ? (keyboardHeight + inputBarHeight + 40) : (94 + attachmentHeight + inputBarHeight)}}px;"
90
+ style="margin-top: {{navBarHeight}}px; padding-bottom: {{keyboardHeight > 0 ? (keyboardHeight + inputBarHeight + 40) : (50 + safeAreaBottom + attachmentHeight + inputBarHeight)}}px;"
91
91
  scroll-y="true"
92
92
  scroll-into-view="{{scrollToMessage}}"
93
93
  enhanced="true"
94
94
  show-scrollbar="false"
95
95
  bindscrolltoupper="onScrollToUpper"
96
+ bindscroll="onScroll"
97
+ lower-threshold="50"
98
+ bindscrolltolower="onScrollToLower"
96
99
  >
97
100
  <!-- 加载更多 -->
98
101
  <view wx:if="{{hasMoreHistory}}" class="load-more-history">
@@ -129,6 +132,7 @@
129
132
  isPlaying="{{playingMessageId === streamingMessage.id}}"
130
133
  isSynthesizing="{{synthesizingMessageId === streamingMessage.id}}"
131
134
  isStreaming="{{true}}"
135
+ bind:stopvoice="onStopVoice"
132
136
  />
133
137
  </view>
134
138
 
@@ -162,6 +166,7 @@
162
166
  bind:keyboardheight="onKeyboardHeight"
163
167
  bind:attachmentheight="onAttachmentHeight"
164
168
  bind:inputheight="onInputHeight"
169
+ bind:voicerecordstart="onVoiceRecordStart"
165
170
  />
166
171
 
167
172
  <!-- 免责声明(键盘弹出时隐藏) -->
@@ -216,7 +216,7 @@
216
216
  }
217
217
 
218
218
  .message-wrapper {
219
- padding-bottom: 20rpx;
219
+ padding-bottom: 40rpx;
220
220
  }
221
221
 
222
222
  /* 加载更多历史 */
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@flexem/chat-box",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "main": "index.js"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flexem/chat-box",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "chat box",
5
5
  "main": "miniprogram_dist/index.js",
6
6
  "miniprogram": "miniprogram_dist",