const { request, uploadFile, streamRequest } = require('../../utils/request') const app = getApp() // 语音识别插件(微信同声传译 WechatSI) let speechPlugin = null try { speechPlugin = requirePlugin('WechatSI') } catch (e) { speechPlugin = null } Page({ data: { userName: '', messages: [], // [{ key, role, text, attachments?, references?, streaming? }] question: '', pendingImages: [], // [{ id, file_type, data_url }] conversationId: null, streaming: false, scrollAnchor: '', voiceMode: false, recording: false, drawerOpen: false, sessions: [], sessionsLoading: false, avatarChar: '?', userSub: '' }, _msgSeq: 0, _recorderManager: null, _recognizeManager: null, _recognizedText: '', _currentTask: null, onLoad(options) { const token = app.globalData.token || wx.getStorageSync('token') if (!token) { wx.redirectTo({ url: '/pages/login/login' }) return } const user = app.globalData.user || wx.getStorageSync('user') || {} const userName = user.name || user.phone || '' const userSub = user.name && user.phone ? user.phone : '' const avatarChar = (userName || '?').trim().charAt(0).toUpperCase() || '?' this.setData({ userName, userSub, avatarChar }) this._recorderManager = wx.getRecorderManager() this._setupSpeech() // 消费 home 页跳转带来的 pending 意图 const pending = app.globalData.chatPending || {} app.globalData.chatPending = null const sid = (options && options.sid) || pending.sid if (sid) { this._loadSessionMessages(Number(sid)) } else if (pending.question) { this.setData({ question: pending.question }) setTimeout(() => this.submit(), 0) } else if (pending.attach) { setTimeout(() => this.chooseImage(), 0) } else if (pending.voice) { this.setData({ voiceMode: true }) } }, onUnload() { if (this._currentTask && this._currentTask.abort) { try { this._currentTask.abort() } catch (e) {} } }, _nextKey() { this._msgSeq += 1 return 'k' + this._msgSeq }, _appendMessage(msg) { const list = this.data.messages.slice() const key = this._nextKey() list.push(Object.assign({ key }, msg)) this.setData({ messages: list, scrollAnchor: 'anchor-end' }) return key }, _updateAssistant(key, patch) { const list = this.data.messages.slice() const i = list.findIndex((m) => m.key === key) if (i === -1) return list[i] = Object.assign({}, list[i], patch) this.setData({ messages: list, scrollAnchor: 'anchor-end' }) }, _appendAssistantDelta(key, delta) { const list = this.data.messages.slice() const i = list.findIndex((m) => m.key === key) if (i === -1) return list[i] = Object.assign({}, list[i], { text: (list[i].text || '') + delta }) this.setData({ messages: list, scrollAnchor: 'anchor-end' }) }, onQuestionInput(e) { this.setData({ question: e.detail.value }) }, goBack() { const pages = getCurrentPages() if (pages && pages.length > 1) { wx.navigateBack() } else { wx.redirectTo({ url: '/pages/home/home' }) } }, // ---------------- 抽屉 ---------------- openDrawer() { this.setData({ drawerOpen: true }) this._loadSessions() }, closeDrawer() { this.setData({ drawerOpen: false }) }, async _loadSessions() { this.setData({ sessionsLoading: true }) try { const list = await request('/api/chat/sessions') const items = (list || []).map((s) => Object.assign({}, s, { _time: this._formatTime(s.last_message_at || s.created_at) })) this.setData({ sessions: items }) } catch (e) { wx.showToast({ title: '历史加载失败:' + e.message, icon: 'none' }) } finally { this.setData({ sessionsLoading: false }) } }, _formatTime(iso) { if (!iso) return '' const d = new Date(iso) if (isNaN(d.getTime())) return '' const pad = (n) => (n < 10 ? '0' + n : '' + n) const now = new Date() if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()) { return `${pad(d.getHours())}:${pad(d.getMinutes())}` } return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` }, newConversation() { if (this.data.streaming) { wx.showToast({ title: '当前对话生成中', icon: 'none' }) return } this.setData({ drawerOpen: false }) wx.redirectTo({ url: '/pages/home/home' }) }, onSelectSession(e) { if (this.data.streaming) { wx.showToast({ title: '当前对话生成中', icon: 'none' }) return } const id = e.detail && e.detail.id if (!id || id === this.data.conversationId) { this.setData({ drawerOpen: false }) return } this.setData({ drawerOpen: false }) this._loadSessionMessages(Number(id)) }, async _loadSessionMessages(id) { wx.showLoading({ title: '加载中...', mask: true }) try { const list = await request(`/api/chat/sessions/${id}/messages`) const msgs = [] ;(list || []).forEach((m) => { if (m.question) { this._msgSeq += 1 msgs.push({ key: 'k' + this._msgSeq, role: 'user', text: m.question, attachments: m.attachments || [] }) } if (m.answer || (m.references && m.references.length)) { this._msgSeq += 1 msgs.push({ key: 'k' + this._msgSeq, role: 'assistant', text: m.answer || '', references: m.references || [], streaming: false }) } }) this.setData({ messages: msgs, conversationId: id, scrollAnchor: 'anchor-end' }) } catch (err) { wx.showToast({ title: '加载失败:' + err.message, icon: 'none' }) } finally { wx.hideLoading() } }, // ---------------- 图片 ---------------- chooseImage() { if (this.data.streaming) return wx.chooseMedia({ count: 4 - this.data.pendingImages.length, mediaType: ['image'], sourceType: ['album', 'camera'], sizeType: ['compressed'], success: (res) => { res.tempFiles.forEach((tf) => this._uploadTempImage(tf)) } }) }, async _uploadTempImage(tempFile) { wx.showLoading({ title: '上传中...', mask: true }) try { const result = await uploadFile('/api/chat/upload-image', tempFile.tempFilePath, 'file') // 使用本地路径作为预览 const list = this.data.pendingImages.slice() list.push({ id: result.id, file_type: 'image/jpeg', data_url: tempFile.tempFilePath }) this.setData({ pendingImages: list }) } catch (e) { wx.showToast({ title: '上传失败:' + e.message, icon: 'none' }) } finally { wx.hideLoading() } }, removePending(e) { const idx = e.currentTarget.dataset.index const list = this.data.pendingImages.slice() list.splice(idx, 1) this.setData({ pendingImages: list }) }, previewImage(e) { const src = e.currentTarget.dataset.src if (src) { wx.previewImage({ current: src, urls: [src] }) } }, // ---------------- 语音 ---------------- toggleVoiceMode() { this.setData({ voiceMode: !this.data.voiceMode }) }, _setupSpeech() { if (!speechPlugin) return // 复用插件的 recognizer 通道;直接使用 speechPlugin.getRecordRecognitionManager this._recognizeManager = speechPlugin.getRecordRecognitionManager() this._recognizeManager.onStart = () => { this.setData({ recording: true }) } this._recognizeManager.onRecognize = (res) => { this._recognizedText = res.result || '' } this._recognizeManager.onStop = (res) => { this.setData({ recording: false }) const text = (res && res.result) || this._recognizedText || '' this._recognizedText = '' if (text) { // 识别到文本,直接作为问题发送 this.setData({ question: text }) this.submit() } else { wx.showToast({ title: '未识别到语音', icon: 'none' }) } } this._recognizeManager.onError = (err) => { this.setData({ recording: false }) wx.showToast({ title: '语音识别失败:' + (err.msg || err.errMsg || ''), icon: 'none' }) } }, startRecord() { if (this.data.streaming) return if (!speechPlugin || !this._recognizeManager) { wx.showModal({ title: '语音功能未启用', content: '请在开发者工具或后台为小程序添加「微信同声传译」插件(WechatSI)。', showCancel: false }) return } this._recognizedText = '' this._recognizeManager.start({ duration: 30000, lang: 'zh_CN' }) }, stopRecord() { if (!this._recognizeManager) return this._recognizeManager.stop() }, cancelRecord() { if (!this._recognizeManager) return this._recognizeManager.stop() this._recognizedText = '' }, // ---------------- 发送问题 ---------------- async submit() { if (this.data.streaming) return const text = (this.data.question || '').trim() const attachments = this.data.pendingImages.slice() if (!text && attachments.length === 0) return const attachmentIds = attachments.map((a) => a.id) // 追加用户消息 this._appendMessage({ role: 'user', text: text, attachments: attachments }) // 追加占位的 assistant 消息 const assistantKey = this._appendMessage({ role: 'assistant', text: '', references: [], streaming: true }) this.setData({ question: '', pendingImages: [], streaming: true }) const references = [] const seenRefs = {} this._currentTask = streamRequest( '/api/chat/stream', { question: text, conversation_id: this.data.conversationId, attachment_ids: attachmentIds }, (evt) => { const name = evt.event const data = evt.data if (name === 'answer.delta' && typeof data === 'string') { this._appendAssistantDelta(assistantKey, data) } else if (name === 'answer.reference') { const title = (data.source_title || '').trim() if (!title) return const k = title + '|' + ((data.snippet || '').trim()) if (seenRefs[k]) return seenRefs[k] = true references.push(data) this._updateAssistant(assistantKey, { references: references.slice() }) } else if (name === 'answer.error') { this._updateAssistant(assistantKey, { text: '出错了:' + (data && (data.message || data)), streaming: false }) } }, () => { this._updateAssistant(assistantKey, { streaming: false }) this.setData({ streaming: false }) this._currentTask = null }, (err) => { this._updateAssistant(assistantKey, { text: '请求失败:' + err.message, streaming: false }) this.setData({ streaming: false }) this._currentTask = null } ) }, // ---------------- 退出 ---------------- logout() { wx.showModal({ title: '提示', content: '确定退出登录?', success: (res) => { if (!res.confirm) return app.globalData.token = '' app.globalData.user = null wx.removeStorageSync('token') wx.removeStorageSync('user') wx.redirectTo({ url: '/pages/login/login' }) } }) } })