memowake-front/lib/websocket-util.ts
2025-08-07 18:31:43 +08:00

242 lines
8.1 KiB
TypeScript
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.

import Constants from 'expo-constants';
import * as SecureStore from 'expo-secure-store';
import { TFunction } from 'i18next';
import { Platform } from 'react-native';
// 从环境变量或默认值中定义 WebSocket 端点
export const WEBSOCKET_ENDPOINT = process.env.EXPO_PUBLIC_WEBSOCKET_ENDPOINT || Constants.expoConfig?.extra?.WEBSOCKET_ENDPOINT;
export type WebSocketStatus = 'connecting' | 'connected' | 'disconnected' | 'reconnecting';
type StatusListener = (status: WebSocketStatus) => void;
// 消息监听器类型
type MessageListener = (data: any) => void;
// 根据后端 Rust 定义的 WsMessage 枚举创建 TypeScript 类型
export type WsMessage =
| { type: 'Chat', session_id: string, message: string, image_material_ids?: string[], video_material_ids?: string[] }
| { type: 'ChatResponse', session_id: string, message: any, message_id?: string }
| { type: 'ChatStream', session_id: string, chunk: string }
| { type: 'ChatStreamEnd', session_id: string, message: any }
| { type: 'Error', code: string, message: string }
| { type: 'Ping' }
| { type: 'Pong' }
| { type: 'Connected', user_id: number };
class WebSocketManager {
private ws: WebSocket | null = null;
private status: WebSocketStatus = 'disconnected';
private messageListeners: Map<string, Set<(message: WsMessage) => void>> = new Map();
private statusListeners: Set<StatusListener> = new Set();
private reconnectAttempts = 0;
private readonly maxReconnectAttempts = 1;
private readonly reconnectInterval = 1000; // 初始重连间隔为1秒
private pingIntervalId: ReturnType<typeof setInterval> | null = null;
private readonly pingInterval = 30000; // 30秒发送一次心跳
constructor() {
// 这是一个单例类,连接通过调用 connect() 方法来启动
}
/**
* 获取当前 WebSocket 连接状态。
*/
public getStatus(): WebSocketStatus {
return this.status;
}
/**
* 启动 WebSocket 连接。
* 会自动获取并使用存储的认证 token。
*/
public async connect() {
if (this.ws && (this.status === 'connected' || this.status === 'connecting')) {
if (this.status === 'connected' || this.status === 'connecting') {
return;
}
}
this.setStatus('connecting');
let token = "";
if (Platform.OS === 'web') {
token = localStorage.getItem('token') || "";
} else {
token = await SecureStore.getItemAsync('token') || "";
}
if (!token) {
console.error('WebSocket: 未找到认证 token无法连接。');
this.setStatus('disconnected');
return;
} else {
console.log('WebSocket: 认证 token:', token);
}
const url = `${WEBSOCKET_ENDPOINT}?token=${token}`;
console.log('WebSocket: 连接 URL:', url);
this.ws = new WebSocket(url);
this.ws.onopen = () => {
console.log('WebSocket connected');
this.setStatus('connected');
this.reconnectAttempts = 0; // 重置重连尝试次数
this.startPing();
};
this.ws.onmessage = (event) => {
try {
const message: WsMessage = JSON.parse(event.data);
// console.log('WebSocket received message:', message)
// 根据消息类型分发
const eventListeners = this.messageListeners.get(message.type);
if (eventListeners) {
eventListeners.forEach(callback => callback(message));
}
// 可以在这里处理通用的消息,比如 Pong
if (message.type === 'Pong') {
// console.log('Received Pong');
}
} catch (error) {
console.error('处理 WebSocket 消息失败:', error);
}
};
this.ws.onerror = (error) => {
console.error('WebSocket 发生错误:', error);
};
this.ws.onclose = () => {
console.log('WebSocket disconnected');
this.ws = null;
this.stopPing();
// 只有在不是手动断开连接时才重连
if (this.status !== 'disconnected') {
this.setStatus('reconnecting');
this.handleReconnect();
}
};
}
/**
* 处理自动重连逻辑,使用指数退避策略。
*/
private handleReconnect() {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
const delay = this.reconnectInterval * Math.pow(2, this.reconnectAttempts - 1);
console.log(`${delay / 1000}秒后尝试重新连接 (第 ${this.reconnectAttempts} 次)...`);
setTimeout(() => {
this.connect();
}, delay);
} else {
console.error('WebSocket 重连失败,已达到最大尝试次数。');
this.setStatus('disconnected');
}
}
/**
* 发送消息到 WebSocket 服务器。
* @param message 要发送的消息对象,必须包含 type 字段。
*/
public send(message: WsMessage) {
if (this.status !== 'connected' || !this.ws) {
console.error('WebSocket 未连接,无法发送消息。');
return;
}
this.ws.send(JSON.stringify(message));
}
/**
* 订阅指定消息类型的消息。
* @param type 消息类型,例如 'ChatResponse'。
* @param callback 收到消息时的回调函数。
*/
public subscribe(type: WsMessage['type'], callback: (message: WsMessage) => void) {
if (!this.messageListeners.has(type)) {
this.messageListeners.set(type, new Set());
}
this.messageListeners.get(type)?.add(callback);
}
/**
* 取消订阅指定消息类型的消息。
* @param type 消息类型。
* @param callback 要移除的回调函数。
*/
public unsubscribe(type: WsMessage['type'], callback: (message: WsMessage) => void) {
const eventListeners = this.messageListeners.get(type);
if (eventListeners) {
eventListeners.delete(callback);
if (eventListeners.size === 0) {
this.messageListeners.delete(type);
}
}
}
/**
* 手动断开 WebSocket 连接。
*/
public disconnect() {
this.setStatus('disconnected');
if (this.ws) {
this.ws.close();
}
this.stopPing();
}
private setStatus(status: WebSocketStatus) {
if (this.status !== status) {
this.status = status;
this.statusListeners.forEach(listener => listener(status));
}
}
public subscribeStatus(listener: StatusListener) {
this.statusListeners.add(listener);
// Immediately invoke with current status
listener(this.status);
}
public unsubscribeStatus(listener: StatusListener) {
this.statusListeners.delete(listener);
}
/**
* 启动心跳机制。
*/
private startPing() {
this.stopPing(); // 先停止任何可能正在运行的计时器
this.pingIntervalId = setInterval(() => {
this.send({ type: 'Ping' });
}, this.pingInterval);
}
/**
* 停止心跳机制。
*/
private stopPing() {
if (this.pingIntervalId) {
clearInterval(this.pingIntervalId);
this.pingIntervalId = null;
}
}
}
// 导出一个单例,确保整个应用共享同一个 WebSocket 连接
export const webSocketManager = new WebSocketManager();
// webscoket 错误映射
export const getWebSocketErrorMessage = (key: string, t: TFunction) => {
const messages = {
'INSUFFICIENT_POINTS': t('ask:ask.insufficientPoints'),
'INVALID_TOKEN': t('ask:ask.invalidToken'),
'NOT_FOUND': t('ask:ask.notFound'),
'PERMISSION_DENIED': t('ask:ask.permissionDenied'),
'NOT_CONNECTED': t('ask:ask.notConnected'),
};
return messages[key as keyof typeof messages] || t('ask:ask.unknownError');
};