feat(chat): send idempotent client message ids

This commit is contained in:
Codex
2026-07-27 10:58:51 +08:00
parent a44fc4860e
commit b21ef2f6ec
6 changed files with 92 additions and 2 deletions
+6 -2
View File
@@ -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;
@@ -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,
]);
});
});
@@ -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
@@ -49,6 +49,8 @@ export interface ChatRequestOptions {
}
export interface ChatSendOptions extends ChatRequestOptions {
/** 同一次逻辑发送在重试或 HTTP/WS 切换时必须复用。 */
clientMessageId?: string;
imageId?: string;
imageThumbUrl?: string;
imageMediumUrl?: string;
@@ -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(),
+24
View File
@@ -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("-");
}