89fc42819d
- 新增 AI 场景路由控制器和管理接口 - 新增 ASR 语音识别服务及前后端集成 - 同步 AI Runtime 客户端到 Web/小程序/Life-Script - 完善 AI 配置测试修复和管理后台路由配置 - 新增数据库迁移脚本 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
374 lines
10 KiB
TypeScript
374 lines
10 KiB
TypeScript
import { Client, IMessage } from '@stomp/stompjs'
|
||
import SockJS from 'sockjs-client'
|
||
import { envConfig } from '@/config/env'
|
||
|
||
// WebSocket消息类型
|
||
export interface WebSocketMessage {
|
||
messageId?: string
|
||
conversationId?: string
|
||
type: 'TEXT' | 'TYPING' | 'SYSTEM' | 'ERROR' | 'HEARTBEAT' | 'CONNECTION' | 'AI_THINKING' | 'AI_STREAM_START' | 'AI_STREAM_DELTA' | 'AI_STREAM_DONE' | 'AI_STREAM_ERROR' | 'AI_STREAM_EVENT'
|
||
content: string
|
||
senderId: string
|
||
senderType: 'USER' | 'GUEST' | 'AI' | 'SYSTEM'
|
||
status?: 'SENDING' | 'SENT' | 'DELIVERED' | 'READ' | 'FAILED'
|
||
createTime?: string
|
||
timestamp?: number
|
||
data?: any
|
||
}
|
||
|
||
// 聊天请求类型 - 完全匹配后端ChatRequest
|
||
export interface ChatRequest {
|
||
content: string
|
||
senderId: string
|
||
senderType: 'USER' | 'GUEST' | 'AI' | 'SYSTEM'
|
||
messageType: 'TEXT' | 'IMAGE' | 'FILE' | 'SYSTEM' | 'HEARTBEAT'
|
||
conversationId?: string
|
||
timestamp?: number
|
||
}
|
||
|
||
// 连接状态
|
||
export type ConnectionStatus = 'CONNECTING' | 'CONNECTED' | 'DISCONNECTED' | 'ERROR'
|
||
|
||
// 事件回调类型
|
||
export interface WebSocketCallbacks {
|
||
onMessage?: (message: WebSocketMessage) => void
|
||
onConnect?: () => void
|
||
onDisconnect?: () => void
|
||
onError?: (error: any) => void
|
||
onStatusChange?: (status: ConnectionStatus) => void
|
||
}
|
||
|
||
/**
|
||
* STOMP WebSocket服务类
|
||
* 使用STOMP协议与后端Spring WebSocket通信
|
||
*/
|
||
export class StompWebSocketService {
|
||
private client: Client | null = null
|
||
private callbacks: WebSocketCallbacks = {}
|
||
private status: ConnectionStatus = 'DISCONNECTED'
|
||
private reconnectAttempts = 0
|
||
private maxReconnectAttempts = 5
|
||
private reconnectInterval = 3000
|
||
private userId: string | null = null
|
||
private conversationId: string | null = null
|
||
|
||
constructor() {
|
||
// 构建WebSocket URL
|
||
const wsUrl = `${envConfig.apiBaseUrl}/ws/chat`
|
||
console.log('STOMP WebSocket URL:', wsUrl)
|
||
}
|
||
|
||
/**
|
||
* 连接WebSocket
|
||
*/
|
||
connect(userId?: string, callbacks?: WebSocketCallbacks): Promise<void> {
|
||
return new Promise((resolve, reject) => {
|
||
try {
|
||
this.callbacks = { ...callbacks }
|
||
|
||
// 设置用户ID和类型
|
||
if (userId) {
|
||
this.userId = userId
|
||
console.log('🔌 使用登录用户ID:', userId)
|
||
} else {
|
||
this.userId = `guest_${Date.now()}`
|
||
console.log('🔌 使用访客ID:', this.userId)
|
||
}
|
||
|
||
this.setStatus('CONNECTING')
|
||
|
||
// 创建STOMP客户端
|
||
this.client = new Client({
|
||
webSocketFactory: () => {
|
||
const wsUrl = `${envConfig.apiBaseUrl}/ws/chat`
|
||
return new SockJS(wsUrl)
|
||
},
|
||
|
||
// 连接头信息
|
||
connectHeaders: this.getConnectHeaders(),
|
||
|
||
// 调试信息
|
||
debug: (str) => {
|
||
console.log('STOMP Debug:', str)
|
||
},
|
||
|
||
// 重连配置
|
||
reconnectDelay: this.reconnectInterval,
|
||
heartbeatIncoming: 4000,
|
||
heartbeatOutgoing: 4000,
|
||
})
|
||
|
||
// 连接成功回调
|
||
this.client.onConnect = (frame) => {
|
||
console.log('✅ STOMP WebSocket连接成功:', frame)
|
||
this.setStatus('CONNECTED')
|
||
this.reconnectAttempts = 0
|
||
|
||
// 订阅消息
|
||
this.subscribeToMessages()
|
||
|
||
// 发送连接消息
|
||
this.sendConnectMessage()
|
||
|
||
this.callbacks.onConnect?.()
|
||
resolve()
|
||
}
|
||
|
||
// 连接错误回调
|
||
this.client.onStompError = (frame) => {
|
||
console.error('❌ STOMP连接错误:', frame)
|
||
this.setStatus('ERROR')
|
||
this.callbacks.onError?.(frame)
|
||
reject(new Error(`STOMP连接错误: ${frame.headers['message']}`))
|
||
}
|
||
|
||
// WebSocket错误回调
|
||
this.client.onWebSocketError = (error) => {
|
||
console.error('❌ WebSocket错误:', error)
|
||
this.setStatus('ERROR')
|
||
this.callbacks.onError?.(error)
|
||
}
|
||
|
||
// 连接关闭回调
|
||
this.client.onWebSocketClose = (event) => {
|
||
console.log('🔌 WebSocket连接关闭:', event)
|
||
this.setStatus('DISCONNECTED')
|
||
this.callbacks.onDisconnect?.()
|
||
|
||
// 自动重连
|
||
if (!event.wasClean && this.reconnectAttempts < this.maxReconnectAttempts) {
|
||
this.scheduleReconnect()
|
||
}
|
||
}
|
||
|
||
// 激活连接
|
||
this.client.activate()
|
||
|
||
} catch (error) {
|
||
console.error('❌ STOMP连接异常:', error)
|
||
this.setStatus('ERROR')
|
||
this.callbacks.onError?.(error)
|
||
reject(error)
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 断开连接
|
||
*/
|
||
disconnect(): void {
|
||
if (this.client) {
|
||
this.client.deactivate()
|
||
this.client = null
|
||
}
|
||
this.userId = null
|
||
this.conversationId = null
|
||
this.setStatus('DISCONNECTED')
|
||
}
|
||
|
||
/**
|
||
* 发送聊天消息
|
||
*/
|
||
sendChatMessage(content: string, conversationId?: string): void {
|
||
if (!this.client || !this.client.connected) {
|
||
const error = new Error('STOMP客户端未连接,无法发送消息')
|
||
console.error('STOMP未连接')
|
||
this.callbacks.onError?.({ userMessage: '连接已断开,请等待重连后再试', originalError: error })
|
||
return
|
||
}
|
||
|
||
if (!content.trim()) {
|
||
const error = new Error('消息内容不能为空')
|
||
this.callbacks.onError?.({ userMessage: '消息内容不能为空', originalError: error })
|
||
return
|
||
}
|
||
|
||
// 判断用户类型
|
||
const isGuest = !this.userId || this.userId.startsWith('guest_')
|
||
const senderType = isGuest ? 'GUEST' : 'USER'
|
||
|
||
console.log('📤 发送STOMP聊天消息,用户信息:', {
|
||
userId: this.userId,
|
||
senderType,
|
||
isGuest,
|
||
content: content.trim()
|
||
})
|
||
|
||
// 创建聊天请求 - 匹配后端ChatRequest格式
|
||
const chatRequest: ChatRequest = {
|
||
content: content.trim(),
|
||
senderId: this.userId!,
|
||
senderType,
|
||
messageType: 'TEXT',
|
||
conversationId: conversationId || this.conversationId || undefined,
|
||
timestamp: Date.now()
|
||
}
|
||
|
||
console.log('📤 准备发送的聊天请求:', chatRequest)
|
||
|
||
try {
|
||
// 发送到后端的/app/chat/send端点(对应 @MessageMapping("/chat") + @MessageMapping("/send"))
|
||
this.client.publish({
|
||
destination: '/app/chat/send',
|
||
body: JSON.stringify(chatRequest)
|
||
})
|
||
console.log('✅ STOMP聊天消息发送成功:', chatRequest)
|
||
} catch (error) {
|
||
console.error('❌ STOMP消息发送失败:', error)
|
||
this.callbacks.onError?.({
|
||
userMessage: '消息发送失败,请重试',
|
||
originalError: error
|
||
})
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 设置会话ID
|
||
*/
|
||
setConversationId(conversationId: string): void {
|
||
this.conversationId = conversationId
|
||
console.log('设置会话ID:', conversationId)
|
||
}
|
||
|
||
/**
|
||
* 获取连接状态
|
||
*/
|
||
getStatus(): ConnectionStatus {
|
||
return this.status
|
||
}
|
||
|
||
/**
|
||
* 是否已连接
|
||
*/
|
||
isConnected(): boolean {
|
||
return this.status === 'CONNECTED' && this.client?.connected === true
|
||
}
|
||
|
||
/**
|
||
* 获取连接头信息
|
||
*/
|
||
private getConnectHeaders(): Record<string, string> {
|
||
const headers: Record<string, string> = {}
|
||
|
||
// 添加用户ID
|
||
if (this.userId) {
|
||
headers['X-User-Id'] = this.userId
|
||
}
|
||
|
||
// 添加JWT token - 修复:使用正确的localStorage key
|
||
const token = localStorage.getItem('access_token')
|
||
if (token) {
|
||
headers['Authorization'] = `Bearer ${token}`
|
||
console.log('🔐 添加Authorization头到STOMP连接,token预览:', token.substring(0, 20) + '...')
|
||
} else {
|
||
console.warn('🔐 未找到access_token,WebSocket将以访客身份连接')
|
||
}
|
||
|
||
return headers
|
||
}
|
||
|
||
/**
|
||
* 订阅消息
|
||
*/
|
||
private subscribeToMessages(): void {
|
||
if (!this.client?.connected) return
|
||
|
||
// 订阅用户私有消息 - 这是主要的消息接收频道
|
||
if (this.userId) {
|
||
const userQueuePath = `/user/${this.userId}/queue/messages`
|
||
console.log('📨 订阅用户私有队列:', userQueuePath)
|
||
|
||
this.client.subscribe(userQueuePath, (message: IMessage) => {
|
||
this.handleMessage(message)
|
||
})
|
||
}
|
||
|
||
// 订阅广播消息(系统通知等)
|
||
this.client.subscribe('/topic/broadcast', (message: IMessage) => {
|
||
this.handleMessage(message)
|
||
})
|
||
|
||
// 注释掉会话特定消息订阅,避免重复接收消息
|
||
// 所有消息都通过用户私有队列接收,确保消息不重复
|
||
// if (this.conversationId) {
|
||
// this.client.subscribe(`/topic/conversation/${this.conversationId}`, (message: IMessage) => {
|
||
// this.handleMessage(message)
|
||
// })
|
||
// }
|
||
}
|
||
|
||
/**
|
||
* 处理收到的消息
|
||
*/
|
||
private handleMessage(message: IMessage): void {
|
||
try {
|
||
const wsMessage: WebSocketMessage = JSON.parse(message.body)
|
||
console.log('📨 收到STOMP消息:', wsMessage)
|
||
this.callbacks.onMessage?.(wsMessage)
|
||
} catch (error) {
|
||
console.error('❌ 解析STOMP消息失败:', error, message.body)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 发送连接消息
|
||
*/
|
||
private sendConnectMessage(): void {
|
||
if (!this.client?.connected) return
|
||
|
||
const connectRequest = {
|
||
userId: this.userId,
|
||
clientType: 'web',
|
||
clientVersion: '1.0.0',
|
||
timestamp: Date.now()
|
||
}
|
||
|
||
try {
|
||
this.client.publish({
|
||
destination: '/app/chat/connect',
|
||
body: JSON.stringify(connectRequest)
|
||
})
|
||
console.log('✅ STOMP连接消息发送成功:', connectRequest)
|
||
} catch (error) {
|
||
console.error('❌ STOMP连接消息发送失败:', error)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 设置连接状态
|
||
*/
|
||
private setStatus(status: ConnectionStatus): void {
|
||
this.status = status
|
||
this.callbacks.onStatusChange?.(status)
|
||
}
|
||
|
||
/**
|
||
* 安排重连
|
||
*/
|
||
private scheduleReconnect(): void {
|
||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||
console.error('达到最大重连次数,停止重连')
|
||
return
|
||
}
|
||
|
||
this.reconnectAttempts++
|
||
console.log(`${this.reconnectInterval}ms后尝试第${this.reconnectAttempts}次重连`)
|
||
|
||
setTimeout(() => {
|
||
if (this.status !== 'CONNECTED') {
|
||
this.connect(this.userId!, this.callbacks).catch(() => {
|
||
// 重连失败会自动安排下次重连
|
||
})
|
||
}
|
||
}, this.reconnectInterval)
|
||
|
||
// 递增重连间隔
|
||
this.reconnectInterval = Math.min(this.reconnectInterval * 1.5, 30000)
|
||
}
|
||
}
|
||
|
||
// 创建STOMP WebSocket服务实例
|
||
export const stompWebSocketService = new StompWebSocketService()
|
||
|
||
export default stompWebSocketService
|