refactor: move websocket and message queue to core/net
Reorganize project structure by relocating chat-websocket and message-queue modules from `src/integrations/` to `src/core/net/` to better reflect their networking purpose and improve code organization.
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 聊天 WebSocket 处理器
|
||||
*
|
||||
* 原始 Dart: lib/core/net/websocket_handler.dart
|
||||
*
|
||||
* 浏览器方案:使用原生 `WebSocket` API,封装连接管理 + 重连 + 事件回调。
|
||||
*
|
||||
* 事件:
|
||||
* - onConnected(userId)
|
||||
* - onTyping(isTyping)
|
||||
* - onSentence(index, text, total, done) ← AI 逐句推送
|
||||
* - onVoiceReady(index, audioUrl)
|
||||
* - onIntimacyUpdate(intimacyChange, newIntimacy, relationshipStage)
|
||||
* - onMoodUpdate(mood)
|
||||
* - onError(errorMessage)
|
||||
*/
|
||||
import { getApiConfig } from "@/core/net/config/api_config";
|
||||
|
||||
export interface SentencePayload {
|
||||
index: number;
|
||||
text: string;
|
||||
total: number;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
export interface IntimacyPayload {
|
||||
intimacyChange?: number;
|
||||
newIntimacy?: number;
|
||||
relationshipStage?: string;
|
||||
}
|
||||
|
||||
export class ChatWebSocket {
|
||||
private ws: WebSocket | null = null;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private disposed = false;
|
||||
|
||||
onConnected: ((userId: string) => void) | null = null;
|
||||
onTyping: ((isTyping: boolean) => void) | null = null;
|
||||
onSentence: ((payload: SentencePayload) => void) | null = null;
|
||||
onVoiceReady: ((index: number, audioUrl: string) => void) | null = null;
|
||||
onIntimacyUpdate: ((payload: IntimacyPayload) => void) | null = null;
|
||||
onMoodUpdate: ((mood: string) => void) | null = null;
|
||||
onError: ((errorMessage: string) => void) | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly serverUrl: string,
|
||||
private readonly token: string,
|
||||
) {}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this.ws?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
if (this.disposed) return;
|
||||
if (this.ws && this.ws.readyState !== WebSocket.CLOSED) return;
|
||||
try {
|
||||
const url = `${this.serverUrl}?token=${encodeURIComponent(this.token)}`;
|
||||
this.ws = new WebSocket(url);
|
||||
this.ws.onopen = () => {
|
||||
// 连接成功;userId 由后端在第一条消息中下发
|
||||
};
|
||||
this.ws.onmessage = (e) => this.handleMessage(e.data);
|
||||
this.ws.onerror = () => this.onError?.("WebSocket error");
|
||||
this.ws.onclose = () => {
|
||||
if (!this.disposed) {
|
||||
this.reconnectTimer = setTimeout(() => this.connect(), 3000);
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
this.onError?.(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage(content: string): boolean {
|
||||
if (!this.isConnected) return false;
|
||||
this.ws?.send(JSON.stringify({ type: "message", content }));
|
||||
return true;
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.disposed = true;
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
private handleMessage(data: unknown): void {
|
||||
if (typeof data !== "string") return;
|
||||
let payload: {
|
||||
type?: string;
|
||||
userId?: string;
|
||||
isTyping?: boolean;
|
||||
index?: number;
|
||||
text?: string;
|
||||
total?: number;
|
||||
done?: boolean;
|
||||
audioUrl?: string;
|
||||
intimacyChange?: number;
|
||||
newIntimacy?: number;
|
||||
relationshipStage?: string;
|
||||
mood?: string;
|
||||
error?: string;
|
||||
};
|
||||
try {
|
||||
payload = JSON.parse(data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
switch (payload.type) {
|
||||
case "connected":
|
||||
if (payload.userId) this.onConnected?.(payload.userId);
|
||||
break;
|
||||
case "typing":
|
||||
this.onTyping?.(payload.isTyping ?? false);
|
||||
break;
|
||||
case "sentence":
|
||||
this.onSentence?.({
|
||||
index: payload.index ?? 0,
|
||||
text: payload.text ?? "",
|
||||
total: payload.total ?? 0,
|
||||
done: payload.done ?? false,
|
||||
});
|
||||
break;
|
||||
case "voice_ready":
|
||||
this.onVoiceReady?.(payload.index ?? 0, payload.audioUrl ?? "");
|
||||
break;
|
||||
case "intimacy_update":
|
||||
this.onIntimacyUpdate?.({
|
||||
intimacyChange: payload.intimacyChange,
|
||||
newIntimacy: payload.newIntimacy,
|
||||
relationshipStage: payload.relationshipStage,
|
||||
});
|
||||
break;
|
||||
case "mood_update":
|
||||
if (payload.mood) this.onMoodUpdate?.(payload.mood);
|
||||
break;
|
||||
case "error":
|
||||
this.onError?.(payload.error ?? "Unknown error");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 默认创建实例:从 env 读取 WS URL。 */
|
||||
export function createChatWebSocket(token: string): ChatWebSocket {
|
||||
const base = getApiConfig().wsUrl;
|
||||
return new ChatWebSocket(base, token);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 消息发送队列(限流)
|
||||
*
|
||||
* 原始 Dart: lib/core/net/message_queue.dart
|
||||
*
|
||||
* 业务行为:
|
||||
* - 入队消息按 `intervalMs` 间隔逐条发送(默认 1000ms)
|
||||
* - 消费者可声明每条消息是否成功消费;不成功则重新入队
|
||||
* - 队列可在任意时刻清空(dispose)
|
||||
*/
|
||||
export type MessageConsumer = (content: string) => boolean | Promise<boolean>;
|
||||
|
||||
export class MessageQueue {
|
||||
private queue: string[] = [];
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private disposed = false;
|
||||
private consumer: MessageConsumer | null = null;
|
||||
|
||||
constructor(private readonly intervalMs = 1000) {}
|
||||
|
||||
/** 注册消费者(消费者必须能处理失败/重试)。 */
|
||||
setConsumer(consumer: MessageConsumer): void {
|
||||
this.consumer = consumer;
|
||||
}
|
||||
|
||||
enqueue(content: string): void {
|
||||
if (this.disposed) return;
|
||||
this.queue.push(content);
|
||||
if (!this.timer) this.scheduleNext();
|
||||
}
|
||||
|
||||
private async attemptSend(next: string): Promise<void> {
|
||||
if (!this.consumer) {
|
||||
this.queue.unshift(next);
|
||||
return;
|
||||
}
|
||||
let ok = false;
|
||||
try {
|
||||
ok = await this.consumer(next);
|
||||
} catch (e) {
|
||||
console.error("[MessageQueue] consumer error", e);
|
||||
ok = false;
|
||||
}
|
||||
if (!ok) {
|
||||
// 消费失败,放回队首等待下次
|
||||
this.queue.unshift(next);
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleNext(): void {
|
||||
if (this.disposed) return;
|
||||
const next = this.queue.shift();
|
||||
if (next === undefined) {
|
||||
this.timer = null;
|
||||
return;
|
||||
}
|
||||
this.timer = setTimeout(() => {
|
||||
void this.attemptSend(next).finally(() => this.scheduleNext());
|
||||
}, this.intervalMs);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.queue = [];
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.clear();
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user