@flexem/chat-box 1.0.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.
Files changed (46) hide show
  1. package/README.md +638 -0
  2. package/miniprogram_dist/TEST_CASES.md +256 -0
  3. package/miniprogram_dist/assets/icons/icon-arrow-down.svg +1 -0
  4. package/miniprogram_dist/assets/icons/icon-arrow-up.svg +1 -0
  5. package/miniprogram_dist/assets/icons/icon-avatar-default.svg +1 -0
  6. package/miniprogram_dist/assets/icons/icon-back.svg +1 -0
  7. package/miniprogram_dist/assets/icons/icon-camera.svg +1 -0
  8. package/miniprogram_dist/assets/icons/icon-close.svg +1 -0
  9. package/miniprogram_dist/assets/icons/icon-copy.svg +1 -0
  10. package/miniprogram_dist/assets/icons/icon-delete.svg +1 -0
  11. package/miniprogram_dist/assets/icons/icon-edit-msg.svg +1 -0
  12. package/miniprogram_dist/assets/icons/icon-edit.svg +1 -0
  13. package/miniprogram_dist/assets/icons/icon-file.svg +1 -0
  14. package/miniprogram_dist/assets/icons/icon-image.svg +1 -0
  15. package/miniprogram_dist/assets/icons/icon-keyboard.svg +1 -0
  16. package/miniprogram_dist/assets/icons/icon-menu.svg +1 -0
  17. package/miniprogram_dist/assets/icons/icon-play-voice.svg +1 -0
  18. package/miniprogram_dist/assets/icons/icon-plus.svg +1 -0
  19. package/miniprogram_dist/assets/icons/icon-regenerate.svg +1 -0
  20. package/miniprogram_dist/assets/icons/icon-thinking.svg +1 -0
  21. package/miniprogram_dist/assets/icons/icon-voice.svg +1 -0
  22. package/miniprogram_dist/components/attachment/index.js +169 -0
  23. package/miniprogram_dist/components/attachment/index.json +4 -0
  24. package/miniprogram_dist/components/attachment/index.wxml +40 -0
  25. package/miniprogram_dist/components/attachment/index.wxss +119 -0
  26. package/miniprogram_dist/components/input-bar/index.js +934 -0
  27. package/miniprogram_dist/components/input-bar/index.json +6 -0
  28. package/miniprogram_dist/components/input-bar/index.wxml +132 -0
  29. package/miniprogram_dist/components/input-bar/index.wxss +324 -0
  30. package/miniprogram_dist/components/message/index.js +988 -0
  31. package/miniprogram_dist/components/message/index.json +4 -0
  32. package/miniprogram_dist/components/message/index.wxml +285 -0
  33. package/miniprogram_dist/components/message/index.wxss +575 -0
  34. package/miniprogram_dist/components/sidebar/index.js +506 -0
  35. package/miniprogram_dist/components/sidebar/index.json +4 -0
  36. package/miniprogram_dist/components/sidebar/index.wxml +137 -0
  37. package/miniprogram_dist/components/sidebar/index.wxss +264 -0
  38. package/miniprogram_dist/index.js +1316 -0
  39. package/miniprogram_dist/index.json +8 -0
  40. package/miniprogram_dist/index.wxml +172 -0
  41. package/miniprogram_dist/index.wxss +291 -0
  42. package/miniprogram_dist/package.json +5 -0
  43. package/miniprogram_dist/utils/api.js +474 -0
  44. package/miniprogram_dist/utils/audio.js +860 -0
  45. package/miniprogram_dist/utils/storage.js +168 -0
  46. package/package.json +27 -0
@@ -0,0 +1,1316 @@
1
+ /**
2
+ * @songkaige/chat-box
3
+ * 微信小程序 AI 聊天组件
4
+ * 参照「元宝」小程序 UI 设计
5
+ */
6
+
7
+ const api = require('./utils/api.js');
8
+ const audio = require('./utils/audio.js');
9
+ const storage = require('./utils/storage.js');
10
+
11
+ Component({
12
+ /**
13
+ * 组件的属性列表
14
+ *
15
+ * 外部传入配置示例:
16
+ * {
17
+ * "idsServiceUrl": "https://ids-dev.platform.flexem.net",
18
+ * "domain": "platform.flexem.net",
19
+ * "loginUrl": "https://idsweb-dev.platform.flexem.net/webapp/user/login",
20
+ * "aiChatWebUrl": "https://chatweb-dev.platform.flexem.net"
21
+ * }
22
+ */
23
+ properties: {
24
+ // IDS 服务地址(用于获取 serviceUrls)
25
+ idsServiceUrl: {
26
+ type: String,
27
+ value: ''
28
+ },
29
+ // 用户 Token
30
+ userToken: {
31
+ type: String,
32
+ value: ''
33
+ },
34
+ // 用户信息
35
+ userInfo: {
36
+ type: Object,
37
+ value: {
38
+ name: '',
39
+ avatar: ''
40
+ }
41
+ },
42
+ // 语音转文字函数
43
+ voiceToText: {
44
+ type: null,
45
+ value: null
46
+ },
47
+ // 语音识别管理器(WechatSI 的 RecordRecognitionManager,边录边识别)
48
+ speechRecognitionManager: {
49
+ type: null,
50
+ value: null
51
+ },
52
+ // 文字转语音函数(可选,不传则使用内置)
53
+ textToVoice: {
54
+ type: null,
55
+ value: null
56
+ }
57
+ },
58
+
59
+ /**
60
+ * 组件的初始数据
61
+ */
62
+ data: {
63
+ // 界面状态
64
+ showSidebar: false, // 侧边栏显示状态
65
+ showDropdown: false, // 下拉菜单显示状态
66
+ scrollToMessage: '', // 滚动到指定消息
67
+ scrollCounter: 0, // 滚动计数器,用于确保滚动触发
68
+
69
+ // 导航栏尺寸
70
+ navBarHeight: 0, // 导航栏总高度
71
+ statusBarHeight: 0, // 状态栏高度
72
+ menuButtonTop: 0, // 胶囊按钮 top
73
+ menuButtonHeight: 0, // 胶囊按钮高度
74
+ keyboardHeight: 0, // 键盘高度
75
+ attachmentHeight: 0, // 附件预览区域高度
76
+ inputBarHeight: 0, // 输入框高度增量(多行时相对于单行的额外高度,rpx转px)
77
+
78
+ // 会话相关
79
+ currentSession: {}, // 当前会话
80
+ messages: [], // 消息列表
81
+ streamingMessage: null, // 正在流式生成的消息
82
+
83
+ // 设置
84
+ settings: {
85
+ voiceAutoPlay: false, // 语音自动播放
86
+ deepThinking: false, // 深度思考
87
+ webSearch: false // 联网搜索
88
+ },
89
+
90
+ // 加载状态
91
+ isStreaming: false, // 是否正在流式输出
92
+ lastScrollTime: 0, // 上次滚动时间(用于节流)
93
+ hasMoreHistory: false, // 是否有更多历史消息
94
+ isLoadingHistory: false, // 是否正在加载历史消息
95
+ historyPage: 1, // 历史消息页码
96
+
97
+ // 语音播放
98
+ playingMessageId: null, // 正在播放语音的消息ID
99
+ synthesizingMessageId: null, // 正在合成语音的消息ID
100
+
101
+ // 编辑重发
102
+ editingContent: '', // 编辑中的内容
103
+ editingMessage: null, // 正在编辑的消息
104
+
105
+ // 服务配置(从 serviceUrls 获取)
106
+ serviceUrls: {
107
+ aiChatUrl: '', // AI 聊天 API 地址
108
+ ossServiceUrl: '', // OSS 服务地址
109
+ blobStorageDownloadServiceUrl: '' // 文件下载服务地址
110
+ },
111
+ isServiceUrlsLoaded: false // 服务配置是否已加载
112
+ },
113
+
114
+ /**
115
+ * 组件生命周期
116
+ */
117
+ lifetimes: {
118
+ attached() {
119
+ // 获取系统信息和胶囊按钮位置
120
+ this.initNavBarInfo();
121
+
122
+ // 加载本地设置
123
+ const settings = storage.getSettings();
124
+ this.setData({ settings });
125
+
126
+ // 设置音频播放状态回调
127
+ audio.setPlayStateCallback((state) => {
128
+ this.setData({
129
+ playingMessageId: state.playing ? state.id : null
130
+ });
131
+ });
132
+
133
+ // 加载服务配置
134
+ this.loadServiceUrls();
135
+ },
136
+ detached() {
137
+ // 销毁音频播放器
138
+ audio.destroy();
139
+
140
+ // 中止正在进行的请求
141
+ if (this.currentRequest) {
142
+ this.currentRequest.abort();
143
+ }
144
+ }
145
+ },
146
+
147
+ /**
148
+ * 组件的方法列表
149
+ */
150
+ methods: {
151
+ // ==================== 初始化 ====================
152
+
153
+ /**
154
+ * 初始化导航栏信息
155
+ */
156
+ initNavBarInfo() {
157
+ const systemInfo = wx.getWindowInfo();
158
+ const menuButton = wx.getMenuButtonBoundingClientRect();
159
+
160
+ // 计算导航栏高度:状态栏 + 胶囊按钮高度 + 上下边距
161
+ const statusBarHeight = systemInfo.statusBarHeight;
162
+ const menuButtonTop = menuButton.top;
163
+ const menuButtonHeight = menuButton.height;
164
+ // 导航栏总高度 = 胶囊按钮底部 + 与顶部相同的边距
165
+ const navBarHeight = menuButton.bottom + (menuButton.top - statusBarHeight);
166
+
167
+ this.setData({
168
+ statusBarHeight,
169
+ menuButtonTop,
170
+ menuButtonHeight,
171
+ navBarHeight
172
+ });
173
+ },
174
+
175
+ /**
176
+ * 键盘高度变化
177
+ */
178
+ onKeyboardHeight(e) {
179
+ const { height } = e.detail;
180
+ this.setData({ keyboardHeight: height });
181
+
182
+ // 键盘弹出时滚动到底部
183
+ if (height > 0) {
184
+ this.scrollToBottom();
185
+ }
186
+ },
187
+
188
+ /**
189
+ * 附件区域高度变化
190
+ */
191
+ onAttachmentHeight(e) {
192
+ const { hasAttachments, count } = e.detail;
193
+ // 附件预览区域高度:有附件时约 140px(120rpx 缩略图 + 20rpx 内边距),每行最多放多个
194
+ // 简化处理:有附件时增加固定高度
195
+ const attachmentHeight = hasAttachments ? 70 : 0;
196
+ this.setData({ attachmentHeight });
197
+
198
+ // 有附件时滚动到底部
199
+ if (hasAttachments) {
200
+ this.scrollToBottom();
201
+ }
202
+ },
203
+
204
+ /**
205
+ * 输入框高度变化(增量)
206
+ */
207
+ onInputHeight(e) {
208
+ const { height } = e.detail;
209
+ // height 是 rpx 增量值(多行相对于单行的额外高度),需要转换为 px
210
+ // 1rpx = screenWidth / 750 px
211
+ const systemInfo = wx.getSystemInfoSync();
212
+ const pxHeight = height * systemInfo.screenWidth / 750;
213
+ this.setData({ inputBarHeight: pxHeight });
214
+
215
+ // 输入框变高时滚动到底部
216
+ this.scrollToBottom();
217
+ },
218
+
219
+ // ==================== 服务配置 ====================
220
+
221
+ /**
222
+ * 加载服务配置
223
+ */
224
+ async loadServiceUrls() {
225
+ if (!this.properties.idsServiceUrl || !this.properties.userToken) {
226
+ return;
227
+ }
228
+
229
+ try {
230
+ const result = await api.getServiceUrls(
231
+ this.properties.idsServiceUrl,
232
+ this.properties.userToken
233
+ );
234
+
235
+ this.setData({
236
+ serviceUrls: {
237
+ aiChatUrl: result.aiChatUrl || '',
238
+ ossServiceUrl: result.ossServiceUrl || '',
239
+ blobStorageDownloadServiceUrl: result.blobStorageDownloadServiceUrl || ''
240
+ },
241
+ isServiceUrlsLoaded: true
242
+ });
243
+
244
+ // 恢复上次会话
245
+ const lastSessionId = storage.getCurrentSession();
246
+ if (lastSessionId) {
247
+ this.loadSession(lastSessionId);
248
+ }
249
+ } catch (error) {
250
+ console.error('加载服务配置失败', error);
251
+ if (error.code === 401) {
252
+ this.triggerEvent('login');
253
+ }
254
+ }
255
+ },
256
+
257
+ /**
258
+ * 获取 AI 聊天 API 地址
259
+ */
260
+ getAiChatUrl() {
261
+ return this.data.serviceUrls.aiChatUrl;
262
+ },
263
+
264
+ /**
265
+ * 获取 OSS 服务地址
266
+ */
267
+ getOssServiceUrl() {
268
+ return this.data.serviceUrls.ossServiceUrl;
269
+ },
270
+
271
+ /**
272
+ * 获取文件下载服务地址
273
+ */
274
+ getBlobStorageDownloadServiceUrl() {
275
+ return this.data.serviceUrls.blobStorageDownloadServiceUrl;
276
+ },
277
+
278
+ // ==================== 导航操作 ====================
279
+
280
+ /**
281
+ * 返回上一页
282
+ */
283
+ onBack() {
284
+ // 触发 back 事件,让外部处理返回逻辑
285
+ this.triggerEvent('back');
286
+
287
+ // 默认行为:返回上一页
288
+ wx.navigateBack({
289
+ fail: () => {
290
+ // 如果没有上一页,尝试跳转到首页
291
+ wx.switchTab({
292
+ url: '/pages/index/index',
293
+ fail: () => {
294
+ console.log('无法返回');
295
+ }
296
+ });
297
+ }
298
+ });
299
+ },
300
+
301
+ // ==================== 侧边栏操作 ====================
302
+
303
+ /**
304
+ * 打开侧边栏
305
+ */
306
+ openSidebar() {
307
+ // 如果正在流式输出,提示用户稍后操作
308
+ if (this.data.isStreaming) {
309
+ wx.showToast({
310
+ title: '回答输出中,请稍后操作',
311
+ icon: 'none'
312
+ });
313
+ return;
314
+ }
315
+
316
+ this.setData({ showSidebar: true });
317
+ // 刷新对话列表
318
+ const sidebar = this.selectComponent('#sidebar');
319
+ if (sidebar) {
320
+ sidebar.refresh();
321
+ }
322
+ },
323
+
324
+ /**
325
+ * 关闭侧边栏
326
+ */
327
+ closeSidebar() {
328
+ this.setData({ showSidebar: false });
329
+ },
330
+
331
+ /**
332
+ * 选择会话
333
+ */
334
+ onSelectSession(e) {
335
+ const { session } = e.detail;
336
+ this.loadSession(session.id);
337
+ storage.saveCurrentSession(session.id);
338
+ },
339
+
340
+ /**
341
+ * 删除会话
342
+ */
343
+ onDeleteSession(e) {
344
+ const { sessionId } = e.detail;
345
+ if (this.data.currentSession.id === sessionId) {
346
+ this.setData({
347
+ currentSession: {},
348
+ messages: []
349
+ });
350
+ storage.saveCurrentSession('');
351
+ }
352
+ },
353
+
354
+ // ==================== 下拉菜单操作 ====================
355
+
356
+ /**
357
+ * 切换下拉菜单
358
+ */
359
+ toggleDropdown() {
360
+ this.setData({ showDropdown: !this.data.showDropdown });
361
+ },
362
+
363
+ /**
364
+ * 关闭下拉菜单
365
+ */
366
+ closeDropdown() {
367
+ this.setData({ showDropdown: false });
368
+ },
369
+
370
+ /**
371
+ * 新建对话(仅重置状态,实际创建在发送第一条消息时进行)
372
+ */
373
+ onNewChat() {
374
+ this.closeDropdown();
375
+
376
+ // 重置到初始状态
377
+ this.setData({
378
+ currentSession: {},
379
+ messages: [],
380
+ streamingMessage: null,
381
+ hasMoreHistory: false,
382
+ historyPage: 1,
383
+ editingContent: ''
384
+ });
385
+
386
+ // 清空输入框
387
+ const inputBar = this.selectComponent('#input-bar');
388
+ if (inputBar) {
389
+ inputBar.setValue('');
390
+ }
391
+
392
+ // 清除存储的当前会话
393
+ storage.saveCurrentSession(null);
394
+ },
395
+
396
+ /**
397
+ * 切换语音自动播放
398
+ */
399
+ toggleVoiceAutoPlay() {
400
+ const settings = {
401
+ ...this.data.settings,
402
+ voiceAutoPlay: !this.data.settings.voiceAutoPlay
403
+ };
404
+ this.setData({ settings });
405
+ storage.saveSettings(settings);
406
+ },
407
+
408
+ /**
409
+ * 切换深度思考
410
+ */
411
+ toggleDeepThinking() {
412
+ const settings = {
413
+ ...this.data.settings,
414
+ deepThinking: !this.data.settings.deepThinking
415
+ };
416
+ this.setData({ settings });
417
+ storage.saveSettings(settings);
418
+ },
419
+
420
+ /**
421
+ * 切换联网搜索
422
+ */
423
+ toggleWebSearch() {
424
+ const settings = {
425
+ ...this.data.settings,
426
+ webSearch: !this.data.settings.webSearch
427
+ };
428
+ this.setData({ settings });
429
+ storage.saveSettings(settings);
430
+ },
431
+
432
+ // ==================== 消息操作 ====================
433
+
434
+ /**
435
+ * 加载会话
436
+ */
437
+ async loadSession(sessionId) {
438
+ if (!sessionId) return;
439
+
440
+ wx.showLoading({ title: '加载中...' });
441
+
442
+ try {
443
+ // 获取聊天历史
444
+ const result = await api.getChatHistories(
445
+ this.getAiChatUrl(),
446
+ this.properties.userToken,
447
+ sessionId,
448
+ 1,
449
+ 20
450
+ );
451
+
452
+ // 解析返回数据:{ items: [...], paginator: {...} }
453
+ const items = result.items || [];
454
+ const paginator = result.paginator || {};
455
+ const messages = this.processHistoryMessages(items);
456
+
457
+ // 找到会话标题
458
+ let sessionTitle = '对话';
459
+ if (items.length > 0 && items[0].session) {
460
+ sessionTitle = items[0].session.title || sessionTitle;
461
+ }
462
+
463
+ // 清除编辑状态和输入框内容
464
+ this.setData({
465
+ currentSession: {
466
+ id: sessionId,
467
+ title: sessionTitle
468
+ },
469
+ messages: messages,
470
+ hasMoreHistory: paginator.total_page > paginator.current_page,
471
+ historyPage: 2,
472
+ editingContent: '', // 清除编辑内容
473
+ editingMessage: null // 清除编辑消息
474
+ }, () => {
475
+ // 清除输入框内容
476
+ const inputBar = this.selectComponent('#input-bar');
477
+ if (inputBar) {
478
+ inputBar.clearInput();
479
+ }
480
+
481
+ // 在 setData 回调中滚动,确保数据已渲染
482
+ // 使用延迟确保 DOM 完成渲染
483
+ setTimeout(() => {
484
+ this.scrollToBottom(true);
485
+ }, 100);
486
+ });
487
+ } catch (error) {
488
+ console.error('加载会话失败', error);
489
+ if (error.code === 401) {
490
+ this.triggerEvent('login');
491
+ } else {
492
+ wx.showToast({
493
+ title: error.message || '加载失败',
494
+ icon: 'none'
495
+ });
496
+ }
497
+ } finally {
498
+ wx.hideLoading();
499
+ }
500
+ },
501
+
502
+ /**
503
+ * 加载更多历史消息
504
+ */
505
+ async loadMoreHistory() {
506
+ if (this.data.isLoadingHistory || !this.data.hasMoreHistory) return;
507
+
508
+ this.setData({ isLoadingHistory: true });
509
+
510
+ try {
511
+ const result = await api.getChatHistories(
512
+ this.getAiChatUrl(),
513
+ this.properties.userToken,
514
+ this.data.currentSession.id,
515
+ this.data.historyPage,
516
+ 20
517
+ );
518
+
519
+ // 解析返回数据:{ items: [...], paginator: {...} }
520
+ const items = result.items || [];
521
+ const paginator = result.paginator || {};
522
+ const newMessages = this.processHistoryMessages(items);
523
+
524
+ this.setData({
525
+ messages: [...newMessages, ...this.data.messages],
526
+ hasMoreHistory: paginator.total_page > paginator.current_page,
527
+ historyPage: this.data.historyPage + 1
528
+ });
529
+ } catch (error) {
530
+ console.error('加载更多历史失败', error);
531
+ wx.showToast({
532
+ title: error.message || '加载失败',
533
+ icon: 'none'
534
+ });
535
+ } finally {
536
+ this.setData({ isLoadingHistory: false });
537
+ }
538
+ },
539
+
540
+ /**
541
+ * 处理历史消息数据
542
+ * API 返回字段:
543
+ * - id: 消息ID
544
+ * - user_question: 用户问题
545
+ * - ai_response: AI 回复
546
+ * - ai_thinking_text: AI 思考过程
547
+ * - created_at: 创建时间
548
+ * - files: 附件列表
549
+ * - messages: 消息详情(包含 tool_calls 等)
550
+ */
551
+ processHistoryMessages(items) {
552
+ const messages = [];
553
+
554
+ // 图片文件扩展名列表
555
+ const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg'];
556
+
557
+ /**
558
+ * 判断文件是否为图片
559
+ * @param {string} fileName - 文件名
560
+ * @returns {boolean}
561
+ */
562
+ const isImageFile = (fileName) => {
563
+ if (!fileName) return false;
564
+ const ext = fileName.toLowerCase().substring(fileName.lastIndexOf('.'));
565
+ return imageExtensions.includes(ext);
566
+ };
567
+
568
+ /**
569
+ * 转换文件数据为组件需要的格式
570
+ * API 返回: { file_name, file_url, content }
571
+ * 组件需要: { name, url, type, content }
572
+ */
573
+ const convertFiles = (files) => {
574
+ if (!files || !Array.isArray(files)) return [];
575
+ return files.map(file => ({
576
+ name: file.file_name || file.name || '',
577
+ url: file.file_url || file.url || '',
578
+ type: isImageFile(file.file_name || file.name) ? 'image' : 'file',
579
+ content: file.content || ''
580
+ }));
581
+ };
582
+
583
+ items.forEach(item => {
584
+ const createTime = new Date(item.created_at);
585
+ const timestamp = createTime.getTime();
586
+
587
+ // 用户消息
588
+ if (item.user_question || (item.files && item.files.length > 0)) {
589
+ messages.push({
590
+ id: item.id + '_user',
591
+ role: 'user',
592
+ content: item.user_question || '',
593
+ createTime: createTime,
594
+ timestamp: timestamp,
595
+ timeText: this.formatTime(createTime),
596
+ attachments: convertFiles(item.files)
597
+ });
598
+ }
599
+
600
+ // AI 消息
601
+ if (item.ai_response) {
602
+ messages.push({
603
+ id: item.id + '_ai',
604
+ role: 'assistant',
605
+ content: item.ai_response,
606
+ thinkingText: item.ai_thinking_text || '',
607
+ createTime: createTime,
608
+ timestamp: timestamp,
609
+ timeText: this.formatTime(createTime),
610
+ chatHistoryTime: item.created_at
611
+ });
612
+ }
613
+ });
614
+
615
+ // 按时间戳排序
616
+ messages.sort((a, b) => a.timestamp - b.timestamp);
617
+
618
+ return messages;
619
+ },
620
+
621
+ /**
622
+ * 发送消息
623
+ */
624
+ async onSendMessage(e) {
625
+ const { content, attachments, isVoice } = e.detail;
626
+
627
+ if (!content && (!attachments || attachments.length === 0)) return;
628
+
629
+ // 检查登录状态
630
+ if (!this.getAiChatUrl() || !this.properties.userToken) {
631
+ this.triggerEvent('login');
632
+ return;
633
+ }
634
+
635
+ // 如果没有当前会话,先创建
636
+ if (!this.data.currentSession.id) {
637
+ await this.createNewSession(content);
638
+ }
639
+
640
+ // 清除编辑状态
641
+ if (this.data.editingMessage) {
642
+ this.clearEditState();
643
+ }
644
+
645
+ // 添加用户消息
646
+ const userMessage = {
647
+ id: 'user_' + Date.now(),
648
+ role: 'user',
649
+ content: content,
650
+ createTime: new Date(),
651
+ timeText: this.formatTime(new Date()),
652
+ attachments: attachments || [],
653
+ isVoice: isVoice
654
+ };
655
+
656
+ const messages = [...this.data.messages, userMessage];
657
+ this.setData({ messages });
658
+ this.scrollToBottom();
659
+
660
+ // 发送请求
661
+ this.sendChatRequest(content, attachments, isVoice);
662
+ },
663
+
664
+ /**
665
+ * 创建新会话
666
+ */
667
+ async createNewSession(firstMessage) {
668
+ try {
669
+ const result = await api.createChatSession(
670
+ this.getAiChatUrl(),
671
+ this.properties.userToken,
672
+ firstMessage.substring(0, 20) + (firstMessage.length > 20 ? '...' : ''),
673
+ {
674
+ deepThinking: this.data.settings.deepThinking,
675
+ webSearch: this.data.settings.webSearch
676
+ }
677
+ );
678
+
679
+ // 处理返回值:可能是字符串ID或包含id属性的对象
680
+ const sessionId = typeof result === 'string' ? result : (result.id || result);
681
+ const sessionTitle = typeof result === 'object' && result.title ? result.title : '新对话';
682
+
683
+ this.setData({
684
+ currentSession: {
685
+ id: sessionId,
686
+ title: sessionTitle
687
+ }
688
+ });
689
+
690
+ storage.saveCurrentSession(sessionId);
691
+ } catch (error) {
692
+ console.error('创建会话失败', error);
693
+ throw error;
694
+ }
695
+ },
696
+
697
+ /**
698
+ * 发送聊天请求
699
+ */
700
+ sendChatRequest(content, attachments, isVoice) {
701
+ // 为本次请求生成固定的消息 ID,用于跟踪播放状态
702
+ const aiMessageId = 'ai_' + Date.now();
703
+ // 标记是否已开始自动播放语音
704
+ let hasStartedVoicePlay = false;
705
+
706
+ this.setData({
707
+ isStreaming: true,
708
+ streamingMessage: {
709
+ id: aiMessageId, // 使用固定 ID,便于跟踪播放状态
710
+ role: 'assistant',
711
+ content: '',
712
+ thinkingText: '',
713
+ isStreaming: true,
714
+ createTime: new Date(),
715
+ timeText: this.formatTime(new Date())
716
+ }
717
+ });
718
+
719
+ const messagesForApi = [{
720
+ role: 'user',
721
+ content: content,
722
+ attachments: (attachments || []).map(att => ({
723
+ file_name: att.name,
724
+ file_url: att.url,
725
+ content: att.parsedContent || ''
726
+ }))
727
+ }];
728
+
729
+ this.currentRequest = api.sendChatMessage({
730
+ baseUrl: this.getAiChatUrl(),
731
+ token: this.properties.userToken,
732
+ sessionId: this.data.currentSession.id,
733
+ messages: messagesForApi,
734
+ settings: {
735
+ deepThinking: this.data.settings.deepThinking,
736
+ webSearch: this.data.settings.webSearch
737
+ },
738
+ onMessage: (data) => {
739
+ this.setData({
740
+ 'streamingMessage.content': data.content,
741
+ 'streamingMessage.thinkingText': data.thinkingText || ''
742
+ });
743
+ this.scrollToBottom();
744
+
745
+ // 语音自动播放:收到第一条数据时开始流式播放
746
+ // 条件:用户按住说话发送消息 且 语音播放设置打开
747
+ if (this.data.settings.voiceAutoPlay && isVoice && data.content) {
748
+ if (!hasStartedVoicePlay) {
749
+ // 首次收到数据,开始流式播放
750
+ hasStartedVoicePlay = true;
751
+ audio.startStreamingPlay(aiMessageId);
752
+ }
753
+ // 更新流式内容,audio 模块会自动分段并播放
754
+ audio.updateStreamingContent(aiMessageId, data.content);
755
+ }
756
+ },
757
+ onComplete: (data) => {
758
+ // 完成流式输出
759
+ const aiMessage = {
760
+ id: aiMessageId, // 使用相同的 ID,保持播放状态一致
761
+ role: 'assistant',
762
+ content: data.content,
763
+ thinkingText: data.thinkingText || '',
764
+ createTime: new Date(),
765
+ timeText: this.formatTime(new Date()),
766
+ msgId: data.msgId,
767
+ isVoice: isVoice
768
+ };
769
+
770
+ const messages = [...this.data.messages, aiMessage];
771
+ this.setData({
772
+ messages,
773
+ streamingMessage: null,
774
+ isStreaming: false
775
+ });
776
+
777
+ this.currentRequest = null;
778
+
779
+ // 强制滚动到底部(不受节流限制)
780
+ this.scrollToBottom(true);
781
+
782
+ // 更新会话标题(第一条消息时)
783
+ if (this.data.messages.length === 2) {
784
+ this.updateSessionTitle(content, data.content);
785
+ }
786
+
787
+ // 流式播放:通知播放完成,处理剩余内容
788
+ if (hasStartedVoicePlay) {
789
+ audio.finishStreamingContent(aiMessageId, data.content);
790
+ }
791
+ },
792
+ onError: (error) => {
793
+ // 检查是否是用户主动中止的请求
794
+ const isAborted = error.message && (
795
+ error.message.includes('abort') ||
796
+ error.message.includes('cancel')
797
+ );
798
+
799
+ // 中止请求不需要处理,由 onStopGeneration 处理
800
+ if (isAborted) {
801
+ return;
802
+ }
803
+
804
+ console.error('发送消息失败', error);
805
+
806
+ // 如果正在流式播放语音,停止播放
807
+ if (hasStartedVoicePlay) {
808
+ audio.stop();
809
+ this.setData({
810
+ playingMessageId: null,
811
+ synthesizingMessageId: null
812
+ });
813
+ }
814
+
815
+ // 创建错误消息显示在页面上
816
+ // 判断是否为网络断开错误
817
+ const isNetworkError = error.message && error.message.includes('ERR_INTERNET_DISCONNECTED');
818
+ const errorContent = isNetworkError
819
+ ? '抱歉,网络波动导致内容丢失,请稍后重试。'
820
+ : `抱歉,发送消息失败:${error.message || '未知错误'}`;
821
+
822
+ const errorMessage = {
823
+ id: 'error_' + Date.now(),
824
+ role: 'assistant',
825
+ content: errorContent,
826
+ createTime: new Date(),
827
+ timeText: this.formatTime(new Date()),
828
+ isError: true
829
+ };
830
+
831
+ const messages = [...this.data.messages, errorMessage];
832
+ this.setData({
833
+ messages,
834
+ streamingMessage: null,
835
+ isStreaming: false
836
+ });
837
+ this.currentRequest = null;
838
+
839
+ // 滚动到底部显示错误消息
840
+ this.scrollToBottom(true);
841
+
842
+ if (error.code === 401) {
843
+ this.triggerEvent('login');
844
+ }
845
+ }
846
+ });
847
+ },
848
+
849
+ /**
850
+ * 更新会话标题
851
+ */
852
+ async updateSessionTitle(userQuestion, aiAnswer) {
853
+ try {
854
+ const result = await api.updateSessionTitle(
855
+ this.getAiChatUrl(),
856
+ this.properties.userToken,
857
+ this.data.currentSession.id,
858
+ userQuestion,
859
+ aiAnswer
860
+ );
861
+
862
+ if (result && result.title) {
863
+ this.setData({
864
+ 'currentSession.title': result.title
865
+ });
866
+ }
867
+ } catch (error) {
868
+ console.error('更新标题失败', error);
869
+ }
870
+ },
871
+
872
+ /**
873
+ * 停止生成
874
+ */
875
+ onStopGeneration() {
876
+ if (this.currentRequest) {
877
+ this.currentRequest.abort();
878
+ this.currentRequest = null;
879
+ }
880
+
881
+ // 停止语音播放(无条件调用,确保流式播放队列也被清空)
882
+ audio.stop();
883
+
884
+ // 如果有正在生成的消息,将其保存到消息列表
885
+ if (this.data.streamingMessage && this.data.streamingMessage.content) {
886
+ const aiMessage = {
887
+ id: 'ai_' + Date.now(),
888
+ role: 'assistant',
889
+ content: this.data.streamingMessage.content,
890
+ thinkingText: this.data.streamingMessage.thinkingText || '',
891
+ createTime: new Date(),
892
+ timeText: this.formatTime(new Date()),
893
+ isInterrupted: true // 标记为被中断
894
+ };
895
+
896
+ const messages = [...this.data.messages, aiMessage];
897
+ this.setData({
898
+ messages,
899
+ streamingMessage: null,
900
+ isStreaming: false,
901
+ playingMessageId: null,
902
+ synthesizingMessageId: null
903
+ });
904
+ } else {
905
+ this.setData({
906
+ streamingMessage: null,
907
+ isStreaming: false,
908
+ playingMessageId: null,
909
+ synthesizingMessageId: null
910
+ });
911
+ }
912
+
913
+ wx.showToast({
914
+ title: '已停止生成',
915
+ icon: 'none'
916
+ });
917
+ },
918
+
919
+ /**
920
+ * 重新生成
921
+ */
922
+ async onRegenerate(e) {
923
+ const { message } = e.detail;
924
+
925
+ if (this.data.isStreaming) {
926
+ wx.showToast({
927
+ title: '请等待当前回复完成',
928
+ icon: 'none'
929
+ });
930
+ return;
931
+ }
932
+
933
+ // 重新生成应该是针对用户消息的
934
+ if (message.role !== 'user') {
935
+ console.error('重新生成只能用于用户消息');
936
+ return;
937
+ }
938
+
939
+ // 找到该用户消息的索引
940
+ const messageIndex = this.data.messages.findIndex(m => m.id === message.id);
941
+ if (messageIndex === -1) {
942
+ console.error('找不到要重新生成的消息');
943
+ return;
944
+ }
945
+
946
+ // 获取该消息的时间戳,用于删除后端的历史记录
947
+ // 注意:微信小程序中 Date 对象在数据传递时会变成空对象,所以使用 timestamp 数字时间戳
948
+ const chatHistoryTime = new Date(message.timestamp || Date.now()).toISOString();
949
+
950
+ // 删除后端的历史记录(从该消息开始的所有记录)
951
+ if (this.data.currentSession.id && chatHistoryTime) {
952
+ try {
953
+ await api.deleteChatHistoriesByTime(
954
+ this.getAiChatUrl(),
955
+ this.properties.userToken,
956
+ this.data.currentSession.id,
957
+ chatHistoryTime
958
+ );
959
+ } catch (error) {
960
+ console.error('删除历史记录失败', error);
961
+ // 即使删除失败也继续,因为可能是新对话还没有后端记录
962
+ }
963
+ }
964
+
965
+ // 从消息列表中移除该消息及其之后的所有消息
966
+ const messages = this.data.messages.slice(0, messageIndex);
967
+
968
+ // 重新创建用户消息并添加到列表
969
+ const userMessage = {
970
+ id: 'user_' + Date.now(),
971
+ role: 'user',
972
+ content: message.content,
973
+ createTime: new Date(),
974
+ timestamp: Date.now(),
975
+ timeText: this.formatTime(new Date()),
976
+ attachments: message.attachments || [],
977
+ isVoice: message.isVoice
978
+ };
979
+
980
+ this.setData({ messages: [...messages, userMessage] });
981
+
982
+ // 重新发送该用户消息
983
+ this.sendChatRequest(message.content, message.attachments, message.isVoice);
984
+ },
985
+
986
+ /**
987
+ * 编辑消息
988
+ */
989
+ onEditMessage(e) {
990
+ const { message } = e.detail;
991
+
992
+ if (this.data.isStreaming) {
993
+ wx.showToast({
994
+ title: '请等待当前回复完成',
995
+ icon: 'none'
996
+ });
997
+ return;
998
+ }
999
+
1000
+ this.setData({
1001
+ editingContent: message.content,
1002
+ editingMessage: message
1003
+ });
1004
+
1005
+ // 获取输入栏组件
1006
+ const inputBar = this.selectComponent('#input-bar');
1007
+ if (inputBar) {
1008
+ // 如果是语音模式,切换到文本模式
1009
+ if (inputBar.data.isVoiceMode) {
1010
+ inputBar.toggleInputMode();
1011
+ }
1012
+
1013
+ // 设置附件(如果有的话)
1014
+ if (message.attachments && message.attachments.length > 0) {
1015
+ inputBar.setAttachments(message.attachments);
1016
+ }
1017
+ }
1018
+ },
1019
+
1020
+ /**
1021
+ * 清除编辑状态
1022
+ */
1023
+ clearEditState() {
1024
+ this.setData({
1025
+ editingContent: '',
1026
+ editingMessage: null
1027
+ });
1028
+ },
1029
+
1030
+ /**
1031
+ * 快捷回复点击
1032
+ */
1033
+ onQuickReply(e) {
1034
+ const { content } = e.detail;
1035
+
1036
+ if (this.data.isStreaming) {
1037
+ wx.showToast({
1038
+ title: '请等待当前回复完成',
1039
+ icon: 'none'
1040
+ });
1041
+ return;
1042
+ }
1043
+
1044
+ if (content) {
1045
+ // 直接发送消息
1046
+ this.onSendMessage({
1047
+ detail: {
1048
+ content: content,
1049
+ attachments: [],
1050
+ isVoice: false
1051
+ }
1052
+ });
1053
+ }
1054
+ },
1055
+
1056
+ /**
1057
+ * 图片加载完成
1058
+ * 当消息中的图片加载完成后,重新滚动到底部
1059
+ */
1060
+ onImageLoad() {
1061
+ // 使用 nextTick 确保 DOM 更新后再滚动
1062
+ wx.nextTick(() => {
1063
+ this.scrollToBottom(true);
1064
+ });
1065
+ },
1066
+
1067
+ // ==================== 语音播放 ====================
1068
+
1069
+ /**
1070
+ * 播放语音
1071
+ */
1072
+ onPlayVoice(e) {
1073
+ const { id, content } = e.detail;
1074
+ // 优先通过 ID 查找,其次通过内容查找
1075
+ let message = this.data.messages.find(m => m.id === id);
1076
+ if (!message) {
1077
+ message = this.data.messages.find(m => m.content === content);
1078
+ }
1079
+
1080
+ if (message) {
1081
+ this.playMessageVoice(message);
1082
+ } else {
1083
+ console.error('找不到消息', id, content);
1084
+ wx.showToast({
1085
+ title: '播放失败',
1086
+ icon: 'none'
1087
+ });
1088
+ }
1089
+ },
1090
+
1091
+ /**
1092
+ * 播放消息语音
1093
+ */
1094
+ playMessageVoice(message) {
1095
+ const textToVoiceFn = this.properties.textToVoice;
1096
+
1097
+ // 如果正在播放同一条消息,停止播放
1098
+ if (this.data.playingMessageId === message.id) {
1099
+ audio.stop();
1100
+ this.setData({ playingMessageId: null, synthesizingMessageId: null });
1101
+ return;
1102
+ }
1103
+
1104
+ // 停止之前的播放
1105
+ if (this.data.playingMessageId) {
1106
+ audio.stop();
1107
+ }
1108
+
1109
+ // 清理 Markdown 文本,用于 TTS
1110
+ const cleanedContent = this.cleanTextForTTS(message.content);
1111
+
1112
+ // 如果清理后没有文本,提示用户
1113
+ if (!cleanedContent) {
1114
+ wx.showToast({
1115
+ title: '无可播放的文本',
1116
+ icon: 'none'
1117
+ });
1118
+ return;
1119
+ }
1120
+
1121
+ // 立即设置合成状态,清除播放状态
1122
+ this.setData({
1123
+ synthesizingMessageId: message.id,
1124
+ playingMessageId: null
1125
+ });
1126
+
1127
+ if (typeof textToVoiceFn === 'function') {
1128
+ // 使用外部 TTS 函数
1129
+ textToVoiceFn({
1130
+ content: cleanedContent,
1131
+ success: (res) => {
1132
+ // 合成完成,直接切换到播放状态(避免中间闪烁默认图标)
1133
+ this.setData({
1134
+ synthesizingMessageId: null,
1135
+ playingMessageId: message.id
1136
+ });
1137
+ audio.play(res.src, message.id);
1138
+ },
1139
+ fail: (err) => {
1140
+ // 清除合成状态
1141
+ this.setData({ synthesizingMessageId: null });
1142
+ wx.showToast({
1143
+ title: '语音播放失败',
1144
+ icon: 'none'
1145
+ });
1146
+ }
1147
+ });
1148
+ } else {
1149
+ // 使用内置 TTS
1150
+ audio.playText(cleanedContent, message.id, {
1151
+ onSynthesized: () => {
1152
+ // TTS 合成完成,直接切换到播放状态(避免中间闪烁默认图标)
1153
+ this.setData({
1154
+ synthesizingMessageId: null,
1155
+ playingMessageId: message.id
1156
+ });
1157
+ },
1158
+ onError: () => {
1159
+ // TTS 失败
1160
+ this.setData({ synthesizingMessageId: null });
1161
+ }
1162
+ });
1163
+ }
1164
+ },
1165
+
1166
+ // ==================== 工具方法 ====================
1167
+
1168
+ /**
1169
+ * 清理 Markdown 文本,用于 TTS 播放
1170
+ * 移除 Markdown 语法、URL、图片等,保留纯文本
1171
+ */
1172
+ cleanTextForTTS(text) {
1173
+ if (!text) return '';
1174
+
1175
+ let cleaned = text;
1176
+
1177
+ // 移除代码块
1178
+ cleaned = cleaned.replace(/```[\s\S]*?```/g, '');
1179
+
1180
+ // 移除行内代码
1181
+ cleaned = cleaned.replace(/`[^`]+`/g, '');
1182
+
1183
+ // 移除图片 ![alt](url)
1184
+ cleaned = cleaned.replace(/!\[[^\]]*\]\([^)]+\)/g, '');
1185
+
1186
+ // 移除链接,保留链接文本 [text](url) -> text
1187
+ cleaned = cleaned.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
1188
+
1189
+ // 移除 HTML 链接 <a href="...">text</a> -> text
1190
+ cleaned = cleaned.replace(/<a[^>]*>([^<]*)<\/a>/gi, '$1');
1191
+
1192
+ // 移除标题标记 ### -> 空
1193
+ cleaned = cleaned.replace(/^#{1,6}\s+/gm, '');
1194
+
1195
+ // 移除加粗 **text** 或 __text__ -> text
1196
+ cleaned = cleaned.replace(/\*\*([^*]+)\*\*/g, '$1');
1197
+ cleaned = cleaned.replace(/__([^_]+)__/g, '$1');
1198
+
1199
+ // 移除斜体 *text* 或 _text_ -> text(注意不要和列表标记冲突)
1200
+ cleaned = cleaned.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '$1');
1201
+ cleaned = cleaned.replace(/(?<!_)_([^_]+)_(?!_)/g, '$1');
1202
+
1203
+ // 移除列表标记 - * + 或数字.
1204
+ cleaned = cleaned.replace(/^\s*[-*+]\s+/gm, '');
1205
+ cleaned = cleaned.replace(/^\s*\d+\.\s+/gm, '');
1206
+
1207
+ // 移除引用标记 >
1208
+ cleaned = cleaned.replace(/^\s*>\s*/gm, '');
1209
+
1210
+ // 移除分割线 --- *** ___
1211
+ cleaned = cleaned.replace(/^[-*_]{3,}$/gm, '');
1212
+
1213
+ // 移除 URL(http/https 开头的)
1214
+ cleaned = cleaned.replace(/https?:\/\/[^\s)]+/g, '');
1215
+
1216
+ // 移除多余的空行
1217
+ cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
1218
+
1219
+ // 移除首尾空白
1220
+ cleaned = cleaned.trim();
1221
+
1222
+ return cleaned;
1223
+ },
1224
+
1225
+ /**
1226
+ * 滚动到底部(带节流,流式输出时减少滚动频率)
1227
+ * @param {boolean} force - 是否强制滚动(不受节流限制)
1228
+ */
1229
+ scrollToBottom(force = false) {
1230
+ const now = Date.now();
1231
+ const throttleInterval = 200; // 200ms 节流
1232
+
1233
+ // 非强制模式下,流式输出时进行节流
1234
+ if (!force && this.data.isStreaming) {
1235
+ if (now - this.data.lastScrollTime < throttleInterval) {
1236
+ // 设置延迟滚动,确保最终位置正确
1237
+ if (this.scrollTimer) {
1238
+ clearTimeout(this.scrollTimer);
1239
+ }
1240
+ this.scrollTimer = setTimeout(() => {
1241
+ this.scrollToBottom(true);
1242
+ }, throttleInterval);
1243
+ return;
1244
+ }
1245
+ }
1246
+
1247
+ // 使用计数器确保每次都能触发滚动
1248
+ const counter = this.data.scrollCounter + 1;
1249
+ this.setData({
1250
+ scrollToMessage: 'scroll-bottom',
1251
+ scrollCounter: counter,
1252
+ lastScrollTime: now
1253
+ });
1254
+ },
1255
+
1256
+ /**
1257
+ * 滚动到顶部时加载更多
1258
+ */
1259
+ onScrollToUpper() {
1260
+ if (this.data.hasMoreHistory && !this.data.isLoadingHistory) {
1261
+ this.loadMoreHistory();
1262
+ }
1263
+ },
1264
+
1265
+ /**
1266
+ * 格式化时间(完整格式:2025/09/18 17:41:22)
1267
+ */
1268
+ formatTime(date) {
1269
+ const year = date.getFullYear();
1270
+ const month = (date.getMonth() + 1).toString().padStart(2, '0');
1271
+ const day = date.getDate().toString().padStart(2, '0');
1272
+ const hours = date.getHours().toString().padStart(2, '0');
1273
+ const minutes = date.getMinutes().toString().padStart(2, '0');
1274
+ const seconds = date.getSeconds().toString().padStart(2, '0');
1275
+ return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`;
1276
+ },
1277
+
1278
+ /**
1279
+ * 需要登录
1280
+ */
1281
+ onNeedLogin() {
1282
+ this.triggerEvent('login');
1283
+ },
1284
+
1285
+ // ==================== 外部调用方法 ====================
1286
+
1287
+ /**
1288
+ * 刷新会话列表
1289
+ */
1290
+ refreshSessions() {
1291
+ const sidebar = this.selectComponent('.sidebar');
1292
+ if (sidebar) {
1293
+ sidebar.refresh();
1294
+ }
1295
+ },
1296
+
1297
+ /**
1298
+ * 设置 Token(登录后调用)
1299
+ */
1300
+ setToken(token) {
1301
+ // 通过属性传入即可,此方法保留兼容
1302
+ },
1303
+
1304
+ /**
1305
+ * 设置用户信息
1306
+ */
1307
+ setUserInfo(userInfo) {
1308
+ this.setData({
1309
+ userInfo: {
1310
+ name: userInfo.name || userInfo.nickName || '',
1311
+ avatar: userInfo.avatar || userInfo.avatarUrl || ''
1312
+ }
1313
+ });
1314
+ }
1315
+ }
1316
+ });