feat(chat): send idempotent client message ids
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
|||||||
type ChatAction,
|
type ChatAction,
|
||||||
type CommercialAction,
|
type CommercialAction,
|
||||||
} from "@/data/schemas/chat";
|
} from "@/data/schemas/chat";
|
||||||
|
import { createClientMessageId } from "@/lib/chat/client_message_id";
|
||||||
|
|
||||||
const log = new Logger("ChatWebSocket");
|
const log = new Logger("ChatWebSocket");
|
||||||
const RECONNECT_DELAY_MS = 3000;
|
const RECONNECT_DELAY_MS = 3000;
|
||||||
@@ -101,8 +102,11 @@ export class ChatWebSocket {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sendMessage(content: string): boolean {
|
sendMessage(
|
||||||
const payload = { type: "message", content };
|
content: string,
|
||||||
|
clientMessageId: string = createClientMessageId(),
|
||||||
|
): boolean {
|
||||||
|
const payload = { type: "message", content, clientMessageId };
|
||||||
if (!this.isConnected) {
|
if (!this.isConnected) {
|
||||||
logWebSocketWarn("→ WS SEND SKIPPED: socket is not open", payload);
|
logWebSocketWarn("→ WS SEND SKIPPED: socket is not open", payload);
|
||||||
return false;
|
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";
|
} from "@/data/schemas/chat";
|
||||||
import type { ChatApi } from "@/data/services/api";
|
import type { ChatApi } from "@/data/services/api";
|
||||||
import { Result } from "@/utils/result";
|
import { Result } from "@/utils/result";
|
||||||
|
import { createClientMessageId } from "@/lib/chat/client_message_id";
|
||||||
|
|
||||||
export class ChatRemoteDataSource {
|
export class ChatRemoteDataSource {
|
||||||
constructor(private readonly api: ChatApi) {}
|
constructor(private readonly api: ChatApi) {}
|
||||||
@@ -29,6 +30,7 @@ export class ChatRemoteDataSource {
|
|||||||
return Result.wrap(async () => {
|
return Result.wrap(async () => {
|
||||||
const request = SendMessageRequestSchema.parse({
|
const request = SendMessageRequestSchema.parse({
|
||||||
characterId,
|
characterId,
|
||||||
|
clientMessageId: options?.clientMessageId ?? createClientMessageId(),
|
||||||
message,
|
message,
|
||||||
...(options?.imageId ? { imageId: options.imageId } : {}),
|
...(options?.imageId ? { imageId: options.imageId } : {}),
|
||||||
...(options?.imageThumbUrl
|
...(options?.imageThumbUrl
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ export interface ChatRequestOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatSendOptions extends ChatRequestOptions {
|
export interface ChatSendOptions extends ChatRequestOptions {
|
||||||
|
/** 同一次逻辑发送在重试或 HTTP/WS 切换时必须复用。 */
|
||||||
|
clientMessageId?: string;
|
||||||
imageId?: string;
|
imageId?: string;
|
||||||
imageThumbUrl?: string;
|
imageThumbUrl?: string;
|
||||||
imageMediumUrl?: string;
|
imageMediumUrl?: string;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { booleanOrFalse } from "../../nullable-defaults";
|
|||||||
export const SendMessageRequestSchema = z
|
export const SendMessageRequestSchema = z
|
||||||
.object({
|
.object({
|
||||||
characterId: z.string().min(1),
|
characterId: z.string().min(1),
|
||||||
|
clientMessageId: z.uuid().optional(),
|
||||||
message: z.string().max(4000).optional(),
|
message: z.string().max(4000).optional(),
|
||||||
imageId: z.string().min(1).optional(),
|
imageId: z.string().min(1).optional(),
|
||||||
imageThumbUrl: 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