feat(chat): log websocket traffic

This commit is contained in:
2026-06-24 10:48:36 +08:00
parent a571867620
commit f80b5215a9
6 changed files with 101 additions and 16 deletions
@@ -4,6 +4,7 @@
max-width: 220px; max-width: 220px;
max-height: 220px; max-height: 220px;
border-radius: var(--radius-lg, 12px); border-radius: var(--radius-lg, 12px);
border-top-left-radius: 0;
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
background: var(--color-bubble-background, #fff); background: var(--color-bubble-background, #fff);
-2
View File
@@ -2,8 +2,6 @@
/** /**
* ImageBubble 图片气泡 * ImageBubble 图片气泡
* *
* 原始 Dart: lib/ui/chat/widgets/image_bubble.dart63 行)
*
* 支持: * 支持:
* - base64 data URI 解码(`data:image/png;base64,...` * - base64 data URI 解码(`data:image/png;base64,...`
* - 点击 → 打开全屏查看器 * - 点击 → 打开全屏查看器
@@ -2,8 +2,6 @@
/** /**
* MessageContent 消息内容容器 * MessageContent 消息内容容器
* *
* 原始 Dart: lib/ui/chat/widgets/message_content.dart43 行)
*
* 决定渲染 ImageBubble 还是 TextBubble * 决定渲染 ImageBubble 还是 TextBubble
* - 有 imageUrl:渲染 ImageBubble(图片) * - 有 imageUrl:渲染 ImageBubble(图片)
* - 有 content 且非 "[图片]" 占位符:渲染 TextBubble(文字) * - 有 content 且非 "[图片]" 占位符:渲染 TextBubble(文字)
@@ -3,6 +3,7 @@
padding: 14px; padding: 14px;
border: 1px solid rgba(246, 87, 160, 0.2); border: 1px solid rgba(246, 87, 160, 0.2);
border-radius: 18px; border-radius: 18px;
border-top-left-radius: 0;
background: background:
linear-gradient(180deg, rgba(255, 244, 248, 0.95), rgba(255, 255, 255, 0.95)), linear-gradient(180deg, rgba(255, 244, 248, 0.95), rgba(255, 255, 255, 0.95)),
#ffffff; #ffffff;
+99 -9
View File
@@ -11,7 +11,10 @@
* - onError(errorMessage) * - onError(errorMessage)
*/ */
import { getApiConfig } from "@/core/net/config/api_config"; import { getApiConfig } from "@/core/net/config/api_config";
import { AppEnvUtil } from "@/utils"; import { AppEnvUtil, Logger } from "@/utils";
const log = new Logger("ChatWebSocket");
const RECONNECT_DELAY_MS = 3000;
export interface SentencePayload { export interface SentencePayload {
index: number; index: number;
@@ -36,6 +39,7 @@ export interface PaywallStatusPayload {
export class ChatWebSocket { export class ChatWebSocket {
private ws: WebSocket | null = null; private ws: WebSocket | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null; private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private connectionUrl: string | null = null;
private disposed = false; private disposed = false;
onConnected: ((userId: string) => void) | null = null; onConnected: ((userId: string) => void) | null = null;
@@ -62,25 +66,53 @@ export class ChatWebSocket {
if (this.ws && this.ws.readyState !== WebSocket.CLOSED) return; if (this.ws && this.ws.readyState !== WebSocket.CLOSED) return;
try { try {
const url = buildChatWebSocketUrl(this.serverUrl, this.token); const url = buildChatWebSocketUrl(this.serverUrl, this.token);
this.connectionUrl = url;
logWebSocketDebug(`↔ WS CONNECT ${redactWebSocketUrl(url)}`);
this.ws = new WebSocket(url); this.ws = new WebSocket(url);
this.ws.onopen = () => { this.ws.onopen = () => {
logWebSocketDebug(`↔ WS OPEN ${redactWebSocketUrl(url)}`);
// 连接成功;userId 由后端在第一条消息中下发 // 连接成功;userId 由后端在第一条消息中下发
}; };
this.ws.onmessage = (e) => this.handleMessage(e.data); this.ws.onmessage = (e) => {
this.ws.onerror = () => this.onError?.("WebSocket error"); logWebSocketFrame("← WS MESSAGE", url, e.data);
this.ws.onclose = () => { this.handleMessage(e.data);
};
this.ws.onerror = () => {
logWebSocketError(`✕ WS ERROR ${redactWebSocketUrl(url)}`);
this.onError?.("WebSocket error");
};
this.ws.onclose = (event) => {
logWebSocketDebug(`↔ WS CLOSE ${redactWebSocketUrl(url)}`, {
code: event.code,
reason: event.reason,
wasClean: event.wasClean,
reconnectInMs: this.disposed ? null : RECONNECT_DELAY_MS,
});
if (!this.disposed) { if (!this.disposed) {
this.reconnectTimer = setTimeout(() => this.connect(), 3000); this.reconnectTimer = setTimeout(
() => this.connect(),
RECONNECT_DELAY_MS,
);
} }
}; };
} catch (e) { } catch (e) {
logWebSocketError("✕ WS CONNECT FAILED", e);
this.onError?.(e instanceof Error ? e.message : String(e)); this.onError?.(e instanceof Error ? e.message : String(e));
} }
} }
sendMessage(content: string): boolean { sendMessage(content: string): boolean {
if (!this.isConnected) return false; const payload = { type: "message", content };
this.ws?.send(JSON.stringify({ type: "message", content })); if (!this.isConnected) {
logWebSocketWarn("→ WS SEND SKIPPED: socket is not open", payload);
return false;
}
logWebSocketFrame(
"→ WS SEND",
this.connectionUrl ?? this.serverUrl,
payload,
);
this.ws?.send(JSON.stringify(payload));
return true; return true;
} }
@@ -92,10 +124,14 @@ export class ChatWebSocket {
} }
this.ws?.close(); this.ws?.close();
this.ws = null; this.ws = null;
this.connectionUrl = null;
} }
private handleMessage(data: unknown): void { private handleMessage(data: unknown): void {
if (typeof data !== "string") return; if (typeof data !== "string") {
logWebSocketWarn("← WS MESSAGE IGNORED: non-string payload", data);
return;
}
let payload: { let payload: {
type?: string; type?: string;
userId?: string; userId?: string;
@@ -119,7 +155,8 @@ export class ChatWebSocket {
}; };
try { try {
payload = JSON.parse(data); payload = JSON.parse(data);
} catch { } catch (e) {
logWebSocketError("← WS MESSAGE PARSE FAILED", e);
return; return;
} }
switch (payload.type) { switch (payload.type) {
@@ -175,6 +212,59 @@ function buildChatWebSocketUrl(serverUrl: string, token: string): string {
return `${serverUrl}?token=${encodeURIComponent(token)}`; return `${serverUrl}?token=${encodeURIComponent(token)}`;
} }
function redactWebSocketUrl(url: string): string {
try {
const parsed = new URL(url);
if (parsed.searchParams.has("token")) {
parsed.searchParams.set("token", "<redacted>");
}
return parsed.toString();
} catch {
return url.replace(/([?&]token=)[^&]+/i, "$1<redacted>");
}
}
function logWebSocketFrame(
label: string,
url: string,
payload: unknown,
): void {
if (AppEnvUtil.isProduction()) return;
const body = formatWebSocketLogValue(payload);
const bodyLabel = label.startsWith("→") ? "request body" : "response body";
log.debug(
`${label} ${redactWebSocketUrl(url)}${body ? `\n${bodyLabel}:\n${body}` : ""}`,
);
}
function logWebSocketDebug(message: string, payload?: unknown): void {
if (AppEnvUtil.isProduction()) return;
const body = formatWebSocketLogValue(payload);
log.debug(`${message}${body ? `\nbody:\n${body}` : ""}`);
}
function logWebSocketWarn(message: string, payload?: unknown): void {
if (AppEnvUtil.isProduction()) return;
const body = formatWebSocketLogValue(payload);
log.warn(`${message}${body ? `\nbody:\n${body}` : ""}`);
}
function logWebSocketError(message: string, payload?: unknown): void {
if (AppEnvUtil.isProduction()) return;
const body = formatWebSocketLogValue(payload);
log.error(`${message}${body ? `\nbody:\n${body}` : ""}`);
}
function formatWebSocketLogValue(payload: unknown): string {
if (payload instanceof Error) {
return Logger.formatValue({
message: payload.message,
stack: payload.stack,
});
}
return Logger.formatValue(payload);
}
/** 默认创建实例:从 env 读取 WS URL。 */ /** 默认创建实例:从 env 读取 WS URL。 */
export function createChatWebSocket(token: string): ChatWebSocket { export function createChatWebSocket(token: string): ChatWebSocket {
const base = getApiConfig().wsUrl; const base = getApiConfig().wsUrl;
-3
View File
@@ -162,9 +162,6 @@ export function applyHttpSendOutput(
}; };
} }
// 本地游客消息额度读取 / 映射逻辑已停用:
// 消息数量限制统一由后端接口返回 blocked/daily_limit,前端不再读取 ChatStorage quota。
// ============================================================ // ============================================================
// Init 任务 2history 3 步流(local → network → save)—— `loadHistoryActor` 调 // Init 任务 2history 3 步流(local → network → save)—— `loadHistoryActor` 调
// ============================================================ // ============================================================