finai/miniapp/pages/chat/chat.js

408 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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' })
}
})
}
})