feat(chat): sync multi-role backend APIs

This commit is contained in:
2026-07-20 11:29:54 +08:00
parent 16b5c16e76
commit b6fdc912ae
84 changed files with 1488 additions and 439 deletions
@@ -0,0 +1,31 @@
import { z } from "zod";
import { arrayOrEmpty, booleanOrFalse, numberOrZero } from "../nullable-defaults";
export const CharacterCapabilitiesResponseSchema = z
.object({
chat: booleanOrFalse,
privateContent: booleanOrFalse,
})
.readonly();
export const CharacterListItemSchema = z
.object({
id: z.string().min(1),
displayName: z.string().min(1),
isActive: booleanOrFalse,
capabilities: CharacterCapabilitiesResponseSchema,
sortOrder: numberOrZero,
})
.readonly();
export const CharacterListResponseSchema = z
.object({
items: arrayOrEmpty(CharacterListItemSchema),
defaultCharacterId: z.string().min(1),
})
.readonly();
export type CharacterListItem = z.output<typeof CharacterListItemSchema>;
export type CharacterListResponseInput = z.input<typeof CharacterListResponseSchema>;
export type CharacterListResponse = z.output<typeof CharacterListResponseSchema>;
+1
View File
@@ -0,0 +1 @@
export * from "./character_list_response";
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import {
ChatPreviewsResponseSchema,
ChatSyncRequestSchema,
SendMessageRequestSchema,
} from "@/data/schemas/chat";
describe("multi-role chat schemas", () => {
it("accepts text or image sends and rejects empty or oversized text", () => {
expect(
SendMessageRequestSchema.parse({ characterId: "elio", message: "Hi" }),
).toMatchObject({ characterId: "elio", message: "Hi" });
expect(
SendMessageRequestSchema.parse({
characterId: "maya-tan",
imageId: "image-1",
}),
).toMatchObject({ characterId: "maya-tan", imageId: "image-1" });
expect(() => SendMessageRequestSchema.parse({ characterId: "elio" })).toThrow();
expect(() =>
SendMessageRequestSchema.parse({
characterId: "elio",
message: "x".repeat(4001),
}),
).toThrow();
});
it("parses nullable previews and immutable sync messages", () => {
const previews = ChatPreviewsResponseSchema.parse({
items: [{ characterId: "elio", message: null }],
});
const sync = ChatSyncRequestSchema.parse({
characterId: "elio",
messages: [
{
role: "assistant",
content: "Welcome back",
timestamp: "2026-07-20T00:00:00.000Z",
},
],
});
expect(previews.items[0]?.message).toBeNull();
expect(Object.isFrozen(previews.items)).toBe(true);
expect(Object.isFrozen(sync.messages)).toBe(true);
});
});
+2
View File
@@ -7,9 +7,11 @@ export * from "./chat_media";
export * from "./chat_message";
export * from "./chat_payloads";
export * from "./request/send_message_request";
export * from "./request/chat_sync_request";
export * from "./request/unlock_history_request";
export * from "./request/unlock_private_request";
export * from "./response/chat_history_response";
export * from "./response/chat_previews_response";
export * from "./response/chat_send_response";
export * from "./response/unlock_history_response";
export * from "./response/unlock_private_response";
@@ -0,0 +1,19 @@
import { z } from "zod";
export const ChatSyncMessageSchema = z
.object({
role: z.enum(["user", "assistant"]),
content: z.string(),
timestamp: z.string().min(1),
})
.readonly();
export const ChatSyncRequestSchema = z
.object({
characterId: z.string().min(1),
messages: z.array(ChatSyncMessageSchema).min(1).readonly(),
})
.readonly();
export type ChatSyncRequestInput = z.input<typeof ChatSyncRequestSchema>;
export type ChatSyncRequest = z.output<typeof ChatSyncRequestSchema>;
+1
View File
@@ -3,5 +3,6 @@
*/
export * from "./send_message_request";
export * from "./chat_sync_request";
export * from "./unlock_private_request";
export * from "./unlock_history_request";
@@ -4,25 +4,31 @@
*/
import { z } from "zod";
import {
booleanOrFalse,
numberOrZero,
stringOrEmpty,
} from "../../nullable-defaults";
import { booleanOrFalse } from "../../nullable-defaults";
export const SendMessageRequestSchema = z
.object({
characterId: z.string().min(1),
message: stringOrEmpty,
image: stringOrEmpty,
imageId: stringOrEmpty,
imageThumbUrl: stringOrEmpty,
imageMediumUrl: stringOrEmpty,
imageOriginalUrl: stringOrEmpty,
imageWidth: numberOrZero,
imageHeight: numberOrZero,
message: z.string().max(4000).optional(),
imageId: z.string().min(1).optional(),
imageThumbUrl: z.string().min(1).optional(),
imageMediumUrl: z.string().min(1).optional(),
imageOriginalUrl: z.string().min(1).optional(),
imageWidth: z.number().int().nonnegative().optional(),
imageHeight: z.number().int().nonnegative().optional(),
useWebSocket: booleanOrFalse,
})
.refine(
(value) =>
Boolean(value.message?.trim()) ||
Boolean(
value.imageId ||
value.imageThumbUrl ||
value.imageMediumUrl ||
value.imageOriginalUrl,
),
{ message: "message or image is required" },
)
.readonly();
export type SendMessageRequestInput = z.input<typeof SendMessageRequestSchema>;
@@ -0,0 +1,21 @@
import { z } from "zod";
import { arrayOrEmpty } from "../../nullable-defaults";
import { ChatMessageSchema } from "../chat_message";
export const ChatPreviewItemSchema = z
.object({
characterId: z.string().min(1),
message: ChatMessageSchema.nullable().default(null),
})
.readonly();
export const ChatPreviewsResponseSchema = z
.object({
items: arrayOrEmpty(ChatPreviewItemSchema),
})
.readonly();
export type ChatPreviewItem = z.output<typeof ChatPreviewItemSchema>;
export type ChatPreviewsResponseInput = z.input<typeof ChatPreviewsResponseSchema>;
export type ChatPreviewsResponse = z.output<typeof ChatPreviewsResponseSchema>;
+1
View File
@@ -3,6 +3,7 @@
*/
export * from "./chat_history_response";
export * from "./chat_previews_response";
export * from "./chat_send_response";
export * from "./unlock_history_response";
export * from "./unlock_private_response";