408 lines
12 KiB
JavaScript
408 lines
12 KiB
JavaScript
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' })
|
||
}
|
||
})
|
||
}
|
||
})
|