diff --git a/src/core/net/chat-websocket.ts b/src/core/net/chat-websocket.ts index 80231ef4..8eabf692 100644 --- a/src/core/net/chat-websocket.ts +++ b/src/core/net/chat-websocket.ts @@ -18,6 +18,7 @@ import { type ChatAction, type CommercialAction, } from "@/data/schemas/chat"; +import { createClientMessageId } from "@/lib/chat/client_message_id"; const log = new Logger("ChatWebSocket"); const RECONNECT_DELAY_MS = 3000; @@ -101,8 +102,11 @@ export class ChatWebSocket { } } - sendMessage(content: string): boolean { - const payload = { type: "message", content }; + sendMessage( + content: string, + clientMessageId: string = createClientMessageId(), + ): boolean { + const payload = { type: "message", content, clientMessageId }; if (!this.isConnected) { logWebSocketWarn("→ WS SEND SKIPPED: socket is not open", payload); return false; diff --git a/src/data/repositories/__tests__/chat_send_idempotency.test.ts b/src/data/repositories/__tests__/chat_send_idempotency.test.ts new file mode 100644 index 00000000..d45b495f --- /dev/null +++ b/src/data/repositories/__tests__/chat_send_idempotency.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from "vitest"; + +import { ChatRemoteDataSource } from "../chat_remote_data_source"; +import { SendMessageRequestSchema } from "@/data/schemas/chat"; + + +describe("chat send clientMessageId", () => { + it("keeps legacy requests compatible but validates an explicit UUID", () => { + expect( + SendMessageRequestSchema.parse({ characterId: "elio", message: "hello" }) + .clientMessageId, + ).toBeUndefined(); + expect(() => + SendMessageRequestSchema.parse({ + characterId: "elio", + message: "hello", + clientMessageId: "not-a-uuid", + }), + ).toThrow(); + }); + + it("adds one UUID before the HTTP client owns any retry", async () => { + const sendMessage = vi.fn().mockResolvedValue({ + reply: "hi", + messageId: "server-message-1", + image: { type: null, url: null }, + lockDetail: { locked: false, reason: null }, + }); + const source = new ChatRemoteDataSource({ sendMessage } as never); + + await source.sendMessage("elio", "hello"); + + const request = sendMessage.mock.calls[0][0]; + expect(request.clientMessageId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + }); + + it("reuses an explicitly supplied key for a protocol fallback", async () => { + const sendMessage = vi.fn().mockResolvedValue({ + reply: "hi", + messageId: "server-message-1", + image: { type: null, url: null }, + lockDetail: { locked: false, reason: null }, + }); + const source = new ChatRemoteDataSource({ sendMessage } as never); + const clientMessageId = "11111111-1111-4111-8111-111111111111"; + + await source.sendMessage("elio", "hello", { clientMessageId, useWebSocket: false }); + await source.sendMessage("elio", "hello", { clientMessageId, useWebSocket: true }); + + expect(sendMessage.mock.calls.map(([request]) => request.clientMessageId)).toEqual([ + clientMessageId, + clientMessageId, + ]); + }); +}); diff --git a/src/data/repositories/chat_remote_data_source.ts b/src/data/repositories/chat_remote_data_source.ts index f3021712..30cc056b 100644 --- a/src/data/repositories/chat_remote_data_source.ts +++ b/src/data/repositories/chat_remote_data_source.ts @@ -17,6 +17,7 @@ import { } from "@/data/schemas/chat"; import type { ChatApi } from "@/data/services/api"; import { Result } from "@/utils/result"; +import { createClientMessageId } from "@/lib/chat/client_message_id"; export class ChatRemoteDataSource { constructor(private readonly api: ChatApi) {} @@ -29,6 +30,7 @@ export class ChatRemoteDataSource { return Result.wrap(async () => { const request = SendMessageRequestSchema.parse({ characterId, + clientMessageId: options?.clientMessageId ?? createClientMessageId(), message, ...(options?.imageId ? { imageId: options.imageId } : {}), ...(options?.imageThumbUrl diff --git a/src/data/repositories/interfaces/ichat_repository.ts b/src/data/repositories/interfaces/ichat_repository.ts index fcbc7d24..2342b369 100644 --- a/src/data/repositories/interfaces/ichat_repository.ts +++ b/src/data/repositories/interfaces/ichat_repository.ts @@ -49,6 +49,8 @@ export interface ChatRequestOptions { } export interface ChatSendOptions extends ChatRequestOptions { + /** 同一次逻辑发送在重试或 HTTP/WS 切换时必须复用。 */ + clientMessageId?: string; imageId?: string; imageThumbUrl?: string; imageMediumUrl?: string; diff --git a/src/data/schemas/chat/request/send_message_request.ts b/src/data/schemas/chat/request/send_message_request.ts index 1695596b..c25fbfdc 100644 --- a/src/data/schemas/chat/request/send_message_request.ts +++ b/src/data/schemas/chat/request/send_message_request.ts @@ -9,6 +9,7 @@ import { booleanOrFalse } from "../../nullable-defaults"; export const SendMessageRequestSchema = z .object({ characterId: z.string().min(1), + clientMessageId: z.uuid().optional(), message: z.string().max(4000).optional(), imageId: z.string().min(1).optional(), imageThumbUrl: z.string().min(1).optional(), diff --git a/src/lib/chat/client_message_id.ts b/src/lib/chat/client_message_id.ts new file mode 100644 index 00000000..44d64bba --- /dev/null +++ b/src/lib/chat/client_message_id.ts @@ -0,0 +1,24 @@ +/** 为一次逻辑发送生成稳定 UUID;网络层重放同一请求体时会复用该值。 */ +export function createClientMessageId(): string { + if (typeof globalThis.crypto?.randomUUID === "function") { + return globalThis.crypto.randomUUID(); + } + const bytes = new Uint8Array(16); + if (typeof globalThis.crypto?.getRandomValues === "function") { + globalThis.crypto.getRandomValues(bytes); + } else { + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Math.floor(Math.random() * 256); + } + } + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, "0")); + return [ + hex.slice(0, 4).join(""), + hex.slice(4, 6).join(""), + hex.slice(6, 8).join(""), + hex.slice(8, 10).join(""), + hex.slice(10, 16).join(""), + ].join("-"); +}