Files
JoyD/Claw/client/wechat_app/pages/chat/chat.js
2026-04-21 13:46:20 +08:00

709 lines
19 KiB
JavaScript
Raw 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 { API, WebSocketManager, util, constants } = require('../../utils/api.js')
const { formatRelativeTime } = util
const { MESSAGE_TYPE, WS_MESSAGE_TYPE } = constants
Page({
data: {
messages: [],
inputValue: '',
sending: false,
isTyping: false,
showHistoryTip: false,
websocketManager: null,
lastMessageId: '',
isConnected: false,
currentPage: 1,
userInfo: null,
showUserInfoCard: false,
locationInfo: null,
deviceInfo: null,
networkType: '',
showAuthPrompt: false,
authType: 'userInfo'
},
onLoad() {
this._initWebSocket()
this.loadChatHistory()
this.addWelcomeMessage()
this.checkUserAuth()
// 通知 tab-bar 进入聊天模式
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
this.getTabBar().setData({
currentTab: 1,
showChatPanel: true
})
}
},
// 检查用户授权状态
checkUserAuth() {
const app = getApp()
const userInfo = app.globalData.userInfo
if (userInfo) {
this.setData({ userInfo })
this.showUserInfoCard()
this.getUserAdditionalInfo()
} else {
this.requestUserAuth()
}
},
// 请求用户授权
requestUserAuth() {
const app = getApp()
app.getUserInfo((userInfo) => {
if (userInfo) {
this.setData({ userInfo })
this.showUserInfoCard()
this.getUserAdditionalInfo()
} else {
// 未授权,显示授权提示组件
this.setData({
showAuthPrompt: true,
authType: 'userInfo'
})
}
})
},
// 获取用户附加信息
getUserAdditionalInfo() {
const app = getApp()
// 获取地理位置信息
app.getLocation((locationInfo) => {
if (locationInfo) {
this.setData({ locationInfo })
}
})
// 获取设备信息
const deviceInfo = app.getDeviceInfo()
if (deviceInfo) {
this.setData({ deviceInfo })
}
// 获取网络状态
app.getNetworkType((networkType) => {
if (networkType) {
this.setData({ networkType })
}
})
},
// 显示用户信息卡片
showUserInfoCard() {
const { userInfo, locationInfo, deviceInfo, networkType } = this.data
// 获取UnionID
let userId = this.getUnionId()
// 获取手机号
const phoneNumber = wx.getStorageSync('phoneNumber')
let userInfoContent = `你好,${userInfo.nickName}\n`
userInfoContent += `UnionID${userId}\n`
if (phoneNumber) {
userInfoContent += `手机号:${phoneNumber}\n`
}
if (userInfo.gender === 1) {
userInfoContent += '性别:男\n'
} else if (userInfo.gender === 2) {
userInfoContent += '性别:女\n'
}
if (userInfo.city) {
userInfoContent += `地区:${userInfo.country} ${userInfo.province} ${userInfo.city}\n`
}
if (locationInfo) {
userInfoContent += `位置:${locationInfo.latitude.toFixed(2)}, ${locationInfo.longitude.toFixed(2)}\n`
}
if (deviceInfo) {
userInfoContent += `设备:${deviceInfo.model}\n`
userInfoContent += `系统:${deviceInfo.system}\n`
}
if (networkType) {
userInfoContent += `网络:${networkType}`
}
const userInfoMessage = {
id: util.generateUniqueId(),
content: userInfoContent,
type: MESSAGE_TYPE.TEXT,
isMe: false,
nickname: '系统',
avatar: '/assets/images/system-avatar.png',
time: formatRelativeTime(new Date())
}
this.addMessage(userInfoMessage)
this.setData({ showUserInfoCard: true })
},
onShow() {
// 更新自定义 TabBar 选中态 & 强制聊天模式
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
this.getTabBar().setData({
currentTab: 1,
showChatPanel: true
})
}
// 每次显示页面时检查授权状态
this.checkUserAuth()
},
onUnload() {
if (this.wsManager) {
this.wsManager.disconnect()
this.wsManager = null
}
},
// 添加欢迎消息
addWelcomeMessage() {
const welcomeMessage = {
id: util.generateUniqueId(),
content: '你好我是智控未来的AI助手有什么可以帮助你的吗',
type: MESSAGE_TYPE.TEXT,
isMe: false,
nickname: '智控未来',
avatar: '/assets/images/ai-avatar.png',
time: formatRelativeTime(new Date())
}
this.addMessage(welcomeMessage)
},
// 初始化 WebSocket 连接(小程序 WebSocket
_initWebSocket() {
if (this.wsManager && this.wsManager.isConnected) return
const app = getApp()
const wsUrl = app.globalData.websocketUrl || 'wss://pactgo.cn/api/v1/ws/miniprogram'
this.wsManager = new WebSocketManager()
this.wsManager.connect(wsUrl)
this.setData({ isConnected: true })
},
// 加载聊天记录
async loadChatHistory() {
try {
// 模拟加载历史消息
const mockHistory = this.getMockHistoryMessages()
if (mockHistory.length > 0) {
this.setData({
messages: mockHistory,
showHistoryTip: true
})
this.scrollToBottom()
}
} catch (error) {
console.error('加载聊天记录失败:', error)
}
},
// 加载更多历史消息
loadMoreHistory() {
if (this.data.currentPage < 3) {
this.setData({ currentPage: this.data.currentPage + 1 })
const moreHistory = this.getMockHistoryMessages(this.data.currentPage)
if (moreHistory.length > 0) {
const updatedMessages = [...moreHistory, ...this.data.messages]
this.setData({ messages: updatedMessages })
}
}
},
// 模拟历史消息
getMockHistoryMessages(page = 1) {
const messages = []
if (page === 1) {
messages.push({
id: 'history-1',
content: '你好我是智控未来的AI助手有什么可以帮助你的吗',
type: MESSAGE_TYPE.TEXT,
isMe: false,
nickname: '智控未来',
avatar: '/assets/images/ai-avatar.png',
time: '昨天 14:30'
})
messages.push({
id: 'history-2',
content: '你能做什么?',
type: MESSAGE_TYPE.TEXT,
isMe: true,
nickname: '我',
avatar: '/assets/images/user-avatar.png',
time: '昨天 14:31'
})
messages.push({
id: 'history-3',
content: '我可以帮你解答问题、提供信息、生成内容等。请问你有什么具体需求吗?',
type: MESSAGE_TYPE.TEXT,
isMe: false,
nickname: '智控未来',
avatar: '/assets/images/ai-avatar.png',
time: '昨天 14:32'
})
} else if (page === 2) {
messages.push({
id: 'history-4',
content: '如何学习编程?',
type: MESSAGE_TYPE.TEXT,
isMe: true,
nickname: '我',
avatar: '/assets/images/user-avatar.png',
time: '昨天 15:00'
})
messages.push({
id: 'history-5',
content: '<p>学习编程的步骤:</p><ol><li>选择一门编程语言如Python、JavaScript等</li><li>学习基础语法和概念</li><li>动手项目,积累经验</li><li>参与社区,学习他人的代码</li></ol>',
type: MESSAGE_TYPE.richText,
isMe: false,
nickname: '智控未来',
avatar: '/assets/images/ai-avatar.png',
time: '昨天 15:01'
})
}
return messages
},
// 处理新消息
handleNewMessage(data) {
this.setData({ isTyping: false })
// 模拟不同类型的消息
if (data.content.includes('代码')) {
const codeMessage = {
id: data.id || util.generateUniqueId(),
content: 'def hello():\n print("Hello, World!")',
type: 'code',
language: 'Python',
isMe: false,
nickname: '智控未来',
avatar: '/assets/images/ai-avatar.png',
time: formatRelativeTime(new Date())
}
this.addMessage(codeMessage)
} else if (data.content.includes('卡片')) {
const cardMessage = {
id: data.id || util.generateUniqueId(),
type: 'buttonCard',
title: '选择一个选项',
buttons: [
{ id: '1', text: '选项一', action: 'option1' },
{ id: '2', text: '选项二', action: 'option2' }
],
isMe: false,
nickname: '智控未来',
avatar: '/assets/images/ai-avatar.png',
time: formatRelativeTime(new Date())
}
this.addMessage(cardMessage)
} else if (data.content.includes('富文本')) {
const richTextMessage = {
id: data.id || util.generateUniqueId(),
content: '<h3>标题</h3><p>这是一段<b>加粗</b>的文字,包含<ul><li>无序列表项</li><li>无序列表项</li></ul></p>',
type: 'richText',
isMe: false,
nickname: '智控未来',
avatar: '/assets/images/ai-avatar.png',
time: formatRelativeTime(new Date())
}
this.addMessage(richTextMessage)
} else {
const message = {
id: data.id || util.generateUniqueId(),
content: data.content,
type: data.type || MESSAGE_TYPE.TEXT,
isMe: false,
nickname: data.nickname || '智控未来',
avatar: data.avatar || '/assets/images/ai-avatar.png',
time: formatRelativeTime(new Date())
}
this.addMessage(message)
}
},
// 处理系统消息
handleSystemMessage(data) {
const message = {
id: data.id || util.generateUniqueId(),
content: data.content,
type: MESSAGE_TYPE.SYSTEM,
isMe: false,
nickname: '系统',
avatar: '/assets/images/system-avatar.png',
time: formatRelativeTime(new Date())
}
this.addMessage(message)
},
// 添加消息
addMessage(message) {
const messages = [...this.data.messages, message]
this.setData({
messages: messages,
lastMessageId: message.id
})
this.scrollToBottom()
},
// 滚动到底部
scrollToBottom() {
setTimeout(() => {
if (this.data.messages.length > 0) {
const lastMessage = this.data.messages[this.data.messages.length - 1]
this.setData({
lastMessageId: lastMessage.id
})
}
}, 100)
},
// 输入框变化
onInputChange(e) {
this.setData({
inputValue: e.detail.value
})
},
// 发送消息(走 SSE → Gateway → SmartClaw → LMStudio
async sendMessage(text) {
const content = text ? text.trim() : this.data.inputValue.trim()
if (!content || this.data.sending) return
this.setData({ sending: true, isTyping: true })
const userMessage = {
id: util.generateUniqueId(),
content: content,
type: MESSAGE_TYPE.TEXT,
isMe: true,
nickname: '我',
avatar: '/assets/images/user-avatar.png',
time: formatRelativeTime(new Date())
}
this.addMessage(userMessage)
this.setData({ inputValue: '' })
// 创建 AI 占位消息(打字机效果)
const aiMsgId = util.generateUniqueId()
const aiMsg = {
id: aiMsgId, content: '', type: MESSAGE_TYPE.TEXT,
isMe: false, nickname: '智控未来',
avatar: '/assets/images/ai-avatar.png',
time: formatRelativeTime(new Date()), isStreaming: true
}
this.addMessage(aiMsg)
try {
const app = getApp()
const gatewayUrl = app.globalData.gatewayUrl || 'http://localhost:8000'
// 构建 SSE 请求数据
const requestData = {
model: "qwen2.5-vl-7b-instruct",
messages: [
{
role: "user",
content: content
}
],
stream: true,
temperature: 0.7,
max_tokens: 500
}
// 发送 SSE 请求
const self = this
let fullResponse = ''
// 使用 wx.request 发送请求并处理 SSE 流
wx.request({
url: `${gatewayUrl}/v1/chat/completions`,
method: 'POST',
header: {
'Content-Type': 'application/json'
},
data: requestData,
responseType: 'text',
success: function(res) {
// 处理 SSE 响应
const lines = res.data.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.substring(6)
if (data === '[DONE]') {
// SSE 流结束
self._typewriterAppend(aiMsgId, fullResponse, true)
break
}
try {
const json = JSON.parse(data)
if (json.choices && json.choices[0] && json.choices[0].delta && json.choices[0].delta.content) {
const chunk = json.choices[0].delta.content
fullResponse += chunk
self._typewriterAppend(aiMsgId, fullResponse)
}
} catch (e) {
console.error('解析 SSE 数据失败:', e)
}
}
}
},
fail: function(err) {
console.error('SSE 请求失败:', err)
self._typewriterAppend(aiMsgId, self.getMockAIResponse(content), true)
}
})
// 8 秒超时:降级为模拟回复
setTimeout(() => {
const msgs = self.data.messages
const aiM = msgs.find(m => m.id === aiMsgId)
if (aiM && aiM.isStreaming) {
aiM.isStreaming = false
self._typewriterAppend(aiMsgId, self.getMockAIResponse(content), true)
}
}, 8000)
} catch (err) {
console.error('发送失败:', err)
const aiResponse = {
id: aiMsgId,
content: this.getMockAIResponse(content),
type: MESSAGE_TYPE.TEXT,
isMe: false,
nickname: '智控未来',
avatar: '/assets/images/ai-avatar.png',
time: formatRelativeTime(new Date())
}
this.handleNewMessage(aiResponse)
}
this.setData({ sending: false })
},
// 打字机效果
_typewriterAppend(msgId, fullContent, isFinal = false) {
const msgs = this.data.messages
const idx = msgs.findIndex(m => m.id === msgId)
if (idx === -1) return
const cur = msgs[idx].content || ''
if (isFinal || cur.length >= fullContent.length) {
msgs[idx] = { ...msgs[idx], content: fullContent, isStreaming: false }
this.setData({ messages: [...msgs], isTyping: false })
return
}
const next = fullContent[cur.length]
msgs[idx] = { ...msgs[idx], content: cur + next, isStreaming: true }
this.setData({ messages: [...msgs] })
setTimeout(() => { this._typewriterAppend(msgId, fullContent) }, 30)
},
// 模拟AI回复
getMockAIResponse(content) {
const responses = {
'你好': '你好,很高兴为你服务。',
'今天天气怎么样': '今天天气晴朗,适合户外活动。',
'如何学习编程': '学习编程需要持之以恒,建议从基础语法开始,多动手实践。',
'代码': 'def hello():\n print("Hello, World!")',
'卡片': '这是一个卡片消息。',
'富文本': '这是一段富文本消息,包含<b>加粗</b>和<u>下划线</u>。',
'今天是几月几号': '今天是 ' + new Date().toLocaleDateString('zh-CN'),
'你是谁': '我是智控未来的AI助手由LMStudio提供支持。'
}
return responses[content] || '感谢你的提问,我会为你提供准确的答案。'
},
// 长按消息
onMessageLongPress(e) {
const messageId = e.currentTarget.dataset.messageId
const message = this.data.messages.find(msg => msg.id === messageId)
if (message && message.type === MESSAGE_TYPE.TEXT) {
wx.showActionSheet({
itemList: ['复制消息', '删除消息'],
success: (res) => {
switch (res.tapIndex) {
case 0:
this.copyMessage(message.content)
break
case 1:
this.deleteMessage(messageId)
break
}
}
})
}
},
// 复制消息
copyMessage(content) {
wx.setClipboardData({
data: content,
success: () => {
wx.showToast({
title: '复制成功',
icon: 'success'
})
}
})
},
// 删除消息
deleteMessage(messageId) {
const updatedMessages = this.data.messages.filter(msg => msg.id !== messageId)
this.setData({ messages: updatedMessages })
wx.showToast({
title: '已删除',
icon: 'success'
})
},
// 复制代码
copyCode(e) {
const content = e.currentTarget.dataset.content
wx.setClipboardData({
data: content,
success: () => {
wx.showToast({
title: '代码已复制',
icon: 'success'
})
}
})
},
// 预览图片
previewImage(e) {
const url = e.currentTarget.dataset.url
wx.previewImage({
urls: [url]
})
},
// 卡片按钮点击
onCardButtonTap(e) {
const action = e.currentTarget.dataset.action
wx.showToast({
title: '你点击了' + action,
icon: 'none'
})
},
// 清空聊天记录
clearChatHistory() {
wx.showModal({
title: '清空聊天记录',
content: '确定要清空所有聊天记录吗?此操作不可恢复。',
confirmText: '清空',
confirmColor: '#dd524d',
success: (res) => {
if (res.confirm) {
this.setData({
messages: [],
lastMessageId: ''
})
wx.removeStorageSync('chatHistory')
wx.showToast({
title: '已清空',
icon: 'success'
})
}
}
})
},
// 授权成功回调
onAuthSuccess(e) {
const { authType, data } = e.detail
console.log('授权成功:', authType, data)
const app = getApp()
switch (authType) {
case 'userInfo':
app.globalData.userInfo = data
wx.setStorageSync('userInfo', data)
this.setData({ userInfo: data })
this.showUserInfoCard()
this.getUserAdditionalInfo()
break
case 'location':
const locationInfo = {
latitude: data.latitude,
longitude: data.longitude,
speed: data.speed,
accuracy: data.accuracy
}
wx.setStorageSync('locationInfo', locationInfo)
this.setData({ locationInfo })
break
}
},
// 授权失败回调
onAuthFail(e) {
const { authType, error } = e.detail
console.error('授权失败:', authType, error)
wx.showToast({
title: '授权失败,请重试',
icon: 'none'
})
},
// 取消授权回调
onAuthCancel(e) {
const { authType } = e.detail
console.log('取消授权:', authType)
// 如果是用户信息授权,使用默认信息
if (authType === 'userInfo') {
this.setData({
userInfo: {
nickName: '游客',
avatarUrl: '/assets/images/user-avatar.png',
gender: 0,
city: '',
province: '',
country: ''
}
})
this.showUserInfoCard()
}
},
// 打开设置回调
onSettingsOpen(e) {
const { authType, settings } = e.detail
console.log('打开设置:', authType, settings)
},
// 获取UnionID
getUnionId() {
// 从本地存储获取
let unionId = wx.getStorageSync('globalUserId')
if (!unionId) {
const app = getApp()
// 如果本地没有调用app方法获取
app.getGlobalUserId((id) => {
unionId = id
})
// 同时返回一个临时ID确保页面正常显示
unionId = '获取中...'
}
return unionId
}
})