Files
AI-CS/frontend/lib/websocket.ts
T
2026-04-02 14:55:06 +08:00

195 lines
6.0 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.
// WebSocket 客户端工具
// 用于连接后端 WebSocket 服务,接收实时消息
// WebSocket 消息类型
export interface WSMessage<T = unknown> {
type: string; // "new_message" | "conversation_update" 等
conversation_id: number;
data: T; // 消息内容(Message 对象)
}
// WebSocket 连接选项
export interface WSOptions<T = unknown> {
conversationId: number; // 对话ID
isVisitor?: boolean; // 是否是访客(默认为 true
agentId?: number; // 客服ID(如果是客服连接,需要传递)
wsToken?: string; // 客服 WS 令牌(登录后下发)
onMessage?: (message: WSMessage<T>) => void; // 收到消息时的回调
onError?: (error: Event) => void; // 连接错误时的回调
onClose?: () => void; // 连接关闭时的回调
}
// WebSocket 客户端类
export class WSClient<T = unknown> {
private ws: WebSocket | null = null;
private conversationId: number;
private isVisitor: boolean;
private agentId?: number; // 客服ID
private wsToken?: string; // 客服 WS 令牌
private onMessage?: (message: WSMessage<T>) => void;
private onError?: (error: Event) => void;
private onClose?: () => void;
private reconnectTimer: NodeJS.Timeout | null = null;
private reconnectAttempts = 0;
private reconnectDelay = 3000; // 初始 3 秒
private maxReconnectDelay = 30000; // 最长 30 秒
private manualDisconnect = false;
private logPrefix = "❌ WebSocket 错误";
constructor(options: WSOptions<T>) {
this.conversationId = options.conversationId;
this.isVisitor = options.isVisitor !== undefined ? options.isVisitor : true;
this.agentId = options.agentId;
this.wsToken = options.wsToken;
this.onMessage = options.onMessage;
this.onError = options.onError;
this.onClose = options.onClose;
}
// 连接 WebSocket
connect() {
this.manualDisconnect = false;
// 如果已经连接,先断开
if (this.ws && this.ws.readyState !== WebSocket.CLOSED) {
this.ws.close();
this.ws = null;
}
// 使用相对路径构建 WebSocket URL(自动适配当前域名和协议)
// 根据当前页面的协议自动选择 ws:// 或 wss://
const protocol = typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = typeof window !== 'undefined' ? window.location.host : '';
let wsUrl = `${protocol}//${host}/ws?conversation_id=${this.conversationId}&is_visitor=${this.isVisitor}`;
// 如果是客服连接,添加 agent_id 参数
if (!this.isVisitor && this.agentId) {
wsUrl += `&agent_id=${this.agentId}`;
if (this.wsToken) {
wsUrl += `&ws_token=${encodeURIComponent(this.wsToken)}`;
}
}
try {
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
this.reconnectAttempts = 0; // 重置重连次数
this.reconnectDelay = 3000; // 连接恢复后重置退避时间
};
this.ws.onmessage = (event) => {
try {
const message: WSMessage<T> = JSON.parse(event.data);
if (this.onMessage) {
this.onMessage(message);
}
} catch (error) {
console.error(
`❌ 解析 WebSocket 消息失败: 对话ID=${this.conversationId}`,
error
);
}
};
this.ws.onerror = (error) => {
const state = this.ws?.readyState;
// 主动断开或连接已进入关闭态时,浏览器仍可能触发 onerror,这属于预期行为,避免误报。
if (this.manualDisconnect || state === WebSocket.CLOSING || state === WebSocket.CLOSED) {
return;
}
const stateText =
state === WebSocket.CONNECTING
? "连接中"
: state === WebSocket.OPEN
? "已连接"
: state === WebSocket.CLOSING
? "关闭中"
: state === WebSocket.CLOSED
? "已关闭"
: "未知";
const url = this.ws?.url || wsUrl;
console.error(
`${this.logPrefix}: 对话ID=${this.conversationId}, 状态=${stateText}, URL=${url}`,
error
);
if (this.onError) {
this.onError(error);
}
};
this.ws.onclose = (event) => {
this.ws = null;
if (this.onClose) {
this.onClose();
}
// 除主动断开外,任意关闭都重连(代理空闲断开通常是 clean close)。
if (!this.manualDisconnect) {
this.attemptReconnect();
}
};
} catch (error) {
console.error(
`❌ 创建 WebSocket 连接失败: 对话ID=${this.conversationId}, URL=${wsUrl}`,
error
);
if (this.onError) {
// 创建一个错误事件对象
const errorEvent = new Event("error");
this.onError(errorEvent);
}
}
}
// 尝试重连
private attemptReconnect() {
this.reconnectAttempts++;
this.reconnectTimer = setTimeout(() => {
this.connect();
}, this.reconnectDelay);
// 指数退避,避免网络波动时过于频繁重连
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
}
// 断开连接
disconnect() {
this.manualDisconnect = true;
// 取消重连
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
// 关闭 WebSocket 连接
if (this.ws) {
// 关闭连接
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
this.ws.close();
}
this.ws = null;
}
}
// 检查是否已连接
isConnected(): boolean {
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
}
send(type: string, data?: unknown): boolean {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
return false;
}
try {
this.ws.send(
JSON.stringify({
type,
conversation_id: this.conversationId,
data: data ?? {},
})
);
return true;
} catch {
return false;
}
}
}