const { request, uploadFile, streamRequest } = require('../../utils/request') const { splitMarkdownImages } = require('../../utils/parse-image') 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 const merged = Object.assign({}, list[i], patch) if (patch && typeof patch.text === 'string') { merged.contentSegments = splitMarkdownImages(merged.text) } list[i] = merged 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 const newText = (list[i].text || '') + delta list[i] = Object.assign({}, list[i], { text: newText, contentSegments: splitMarkdownImages(newText) }) 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 const ansText = m.answer || '' msgs.push({ key: 'k' + this._msgSeq, role: 'assistant', text: ansText, contentSegments: splitMarkdownImages(ansText), 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] }) } }, // 点击参考来源标题, 折叠/展开链接和摘要 toggleRef(e) { const mkey = e.currentTarget.dataset.mkey const idx = Number(e.currentTarget.dataset.idx) const list = this.data.messages.slice() const i = list.findIndex((m) => m.key === mkey) if (i === -1) return const refs = (list[i].references || []).slice() if (!refs[idx]) return refs[idx] = Object.assign({}, refs[idx], { expanded: !refs[idx].expanded }) list[i] = Object.assign({}, list[i], { references: refs }) this.setData({ messages: list }) }, // 参考文档带 source_url 时(共享文档)点击复制链接到剪贴板。 // 小程序无法直接打开任意外链, 复制后由用户粘贴到浏览器。 onRefLinkTap(e) { const url = e.currentTarget.dataset.url if (!url) return wx.setClipboardData({ data: url, success: () => wx.showToast({ title: '链接已复制', icon: 'success' }), fail: () => wx.showToast({ title: '复制失败', icon: 'none' }) }) }, // 长按聊天记录里的图片 (自己发的 or AI 回答里的 markdown 图片) -> 弹菜单 onImageLongPress(e) { const src = e.currentTarget.dataset.src if (!src) return wx.showActionSheet({ itemList: ['预览大图', '引用此图追问', '保存到相册'], success: (r) => { if (r.tapIndex === 0) wx.previewImage({ current: src, urls: [src] }) else if (r.tapIndex === 1) this._quoteImage(src) else if (r.tapIndex === 2) this._saveImageToAlbum(src) } }) }, async _quoteImage(src) { if (this.data.streaming) { wx.showToast({ title: '当前对话生成中', icon: 'none' }) return } if (this.data.pendingImages.length >= 4) { wx.showToast({ title: '最多 4 张图', icon: 'none' }) return } const localPath = await this._materializeToLocal(src) if (!localPath) return await this._uploadTempImage({ tempFilePath: localPath }) }, async _saveImageToAlbum(src) { const path = await this._materializeToLocal(src) if (!path) return wx.saveImageToPhotosAlbum({ filePath: path, success: () => wx.showToast({ title: '已保存到相册' }), fail: (err) => wx.showToast({ title: '保存失败:' + (err.errMsg || ''), icon: 'none' }) }) }, // 把任意来源 (远端 URL / data URL / 本地临时路径) 归一化成本地文件路径, // 后续能直接喂给 wx.uploadFile / wx.saveImageToPhotosAlbum。失败时返回 null 并已给用户提示。 _materializeToLocal(src) { if (!src) return Promise.resolve(null) if (src.indexOf('data:') === 0) return this._dataUrlToTempFile(src) if (/^https?:\/\//.test(src)) return this._downloadRemote(src) return Promise.resolve(src) }, _dataUrlToTempFile(dataUrl) { return new Promise((resolve) => { const m = /^data:([^;]+);base64,(.+)$/.exec(dataUrl) if (!m) { wx.showToast({ title: '图片格式无法识别', icon: 'none' }) resolve(null) return } const ext = (m[1].split('/')[1] || 'png').replace(/[^a-z0-9]/gi, '') || 'png' const path = `${wx.env.USER_DATA_PATH}/quote_${Date.now()}.${ext}` wx.getFileSystemManager().writeFile({ filePath: path, data: m[2], encoding: 'base64', success: () => resolve(path), fail: (err) => { wx.showToast({ title: '写临时文件失败: ' + (err.errMsg || ''), icon: 'none' }) resolve(null) } }) }) }, _downloadRemote(url) { wx.showLoading({ title: '拉取图片...', mask: true }) return new Promise((resolve) => { wx.downloadFile({ url, success: (r) => { wx.hideLoading() if (r.statusCode === 200) { resolve(r.tempFilePath) } else { wx.showToast({ title: '下载失败 HTTP ' + r.statusCode, icon: 'none' }) resolve(null) } }, fail: (err) => { wx.hideLoading() wx.showModal({ title: '拉图失败', content: (err.errMsg || '') + '\n\n若提示"域名不在合法列表",请在微信公众平台 -> 开发 -> 服务器域名 里把该图片来源域名加进 downloadFile 合法域名。', showCancel: false }) resolve(null) } }) }) }, // ---------------- 语音 ---------------- 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' }) } }) } })