feat(chat): send idempotent client message ids
Docker Image / Build and Push Docker Image (push) Successful in 2m18s
Docker Image / Build and Push Docker Image (push) Successful in 2m18s
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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("-");
|
||||
}
|
||||
Reference in New Issue
Block a user