feat(chat): render commercial actions and persist greetings
Docker Image / Build and Push Docker Image (push) Successful in 1m56s

This commit is contained in:
Codex
2026-07-23 14:52:34 +08:00
parent 79397af739
commit 02f6964484
26 changed files with 548 additions and 1 deletions
@@ -7,6 +7,8 @@ import {
ChatHistoryResponse,
ChatPreviewsResponse,
ChatSendResponse,
OpeningMessageRequestSchema,
OpeningMessageResponse,
SendMessageRequestSchema,
UnlockHistoryRequestSchema,
UnlockHistoryResponse,
@@ -50,6 +52,19 @@ export class ChatRemoteDataSource {
});
}
async saveOpeningMessage(
characterId: string,
openingMessage: string,
options?: ChatRequestOptions,
): Promise<Result<OpeningMessageResponse>> {
return Result.wrap(() =>
this.api.saveOpeningMessage(
OpeningMessageRequestSchema.parse({ characterId, openingMessage }),
options,
),
);
}
async getPreviews(
options?: ChatRequestOptions,
): Promise<Result<ChatPreviewsResponse>> {
+13
View File
@@ -12,6 +12,7 @@ import type {
ChatPreviewsResponse,
ChatMessage,
ChatSendResponse,
OpeningMessageResponse,
UnlockHistoryResponse,
UnlockPrivateResponse,
} from "@/data/schemas/chat";
@@ -46,6 +47,18 @@ export class ChatRepository implements IChatRepository {
return this.remote.sendMessage(characterId, message, options);
}
async saveOpeningMessage(
characterId: string,
openingMessage: string,
options?: ChatRequestOptions,
): Promise<Result<OpeningMessageResponse>> {
return this.remote.saveOpeningMessage(
characterId,
openingMessage,
options,
);
}
/** 获取聊天历史,分页参数默认 limit=50, offset=0。 */
async getHistory(
characterId: string,
@@ -13,6 +13,7 @@ import type {
ChatMediaKind,
ChatMessage,
ChatSendResponse,
OpeningMessageResponse,
UnlockHistoryResponse,
UnlockPrivateResponse,
} from "@/data/schemas/chat";
@@ -65,6 +66,13 @@ export interface IChatRepository {
options?: ChatSendOptions,
): Promise<Result<ChatSendResponse>>;
/** 幂等保存角色首次开场白。 */
saveOpeningMessage(
characterId: string,
openingMessage: string,
options?: ChatRequestOptions,
): Promise<Result<OpeningMessageResponse>>;
/** 获取聊天历史,分页参数默认 limit=50, offset=0。 */
getHistory(
characterId: string,
+1
View File
@@ -5,6 +5,7 @@
export * from "./chat_lock_type";
export * from "./chat_media";
export * from "./chat_message";
export * from "./opening_message";
export * from "./chat_payloads";
export * from "./request/send_message_request";
export * from "./request/unlock_history_request";
+24
View File
@@ -0,0 +1,24 @@
import { z } from "zod";
export const OpeningMessageRequestSchema = z
.object({
characterId: z.string().min(1).max(100),
openingMessage: z.string().min(1).max(1000),
})
.readonly();
export const OpeningMessageResponseSchema = z
.object({
messageId: z.string().min(1),
created: z.boolean(),
openingMessage: z.string().min(1),
createdAt: z.string().min(1),
})
.readonly();
export type OpeningMessageRequest = z.output<
typeof OpeningMessageRequestSchema
>;
export type OpeningMessageResponse = z.output<
typeof OpeningMessageResponseSchema
>;
@@ -12,6 +12,19 @@ import {
} from "../../nullable-defaults";
import { ChatImageSchema, ChatLockDetailSchema } from "../chat_payloads";
export const CommercialActionSchema = z
.object({
actionId: z.string().min(1),
type: z.enum(["giftOffer", "privateAlbumOffer"]),
copy: z.string().min(1),
ctaLabel: z.string().min(1),
target: z.enum(["giftCatalog", "privateZone"]),
ruleId: z.string().min(1),
})
.readonly();
export type CommercialAction = z.output<typeof CommercialActionSchema>;
export const ChatSendResponseSchema = z
.object({
reply: stringOrEmpty,
@@ -27,6 +40,7 @@ export const ChatSendResponseSchema = z
creditsCharged: numberOrZero,
requiredCredits: numberOrZero,
shortfallCredits: numberOrZero,
commercialAction: CommercialActionSchema.nullish().default(null),
})
.readonly();
@@ -60,6 +60,34 @@ describe("multi-character API contract", () => {
});
});
it("persists the character opening message with the canonical contract", async () => {
httpClientMock.mockResolvedValue({
success: true,
data: {
messageId: "opening-message-1",
created: true,
openingMessage: "Hello from Elio",
createdAt: "2026-07-23T00:00:00Z",
},
});
await new ChatApi().saveOpeningMessage({
characterId: CHARACTER_ID,
openingMessage: "Hello from Elio",
});
expect(httpClientMock).toHaveBeenCalledWith(
"/api/chat/opening-message",
{
method: "POST",
body: {
characterId: CHARACTER_ID,
openingMessage: "Hello from Elio",
},
},
);
});
it("loads the chat character catalog", async () => {
httpClientMock.mockResolvedValue({
success: true,
+1
View File
@@ -18,6 +18,7 @@
"paymentGiftProducts": { "method": "get", "path": "/api/payment/gift-products" },
"paymentTipMessage": { "method": "post", "path": "/api/payment/tip-message" },
"chatSend": { "method": "post", "path": "/api/chat/send" },
"chatOpeningMessage": { "method": "post", "path": "/api/chat/opening-message" },
"chatHistory": { "method": "get", "path": "/api/chat/history" },
"chatUnlockPrivate": { "method": "post", "path": "/api/chat/unlock-private" },
"chatUnlockHistory": { "method": "post", "path": "/api/chat/unlock-history" },
+3
View File
@@ -70,6 +70,9 @@ export class ApiPath {
/** 发送消息 */
static readonly chatSend = apiContract.chatSend.path;
/** 幂等保存角色开场白 */
static readonly chatOpeningMessage = apiContract.chatOpeningMessage.path;
/** 获取聊天历史 */
static readonly chatHistory = apiContract.chatHistory.path;
+19
View File
@@ -11,6 +11,9 @@ import {
ChatPreviewsResponseSchema,
ChatSendResponse,
ChatSendResponseSchema,
OpeningMessageRequest,
OpeningMessageResponse,
OpeningMessageResponseSchema,
SendMessageRequest,
UnlockHistoryRequest,
UnlockHistoryResponse,
@@ -39,6 +42,22 @@ export class ChatApi {
return ChatSendResponseSchema.parse(unwrap(env) as Record<string, unknown>);
}
/** 幂等保存当前角色的首次开场白。 */
async saveOpeningMessage(
body: OpeningMessageRequest,
options?: { signal?: AbortSignal },
): Promise<OpeningMessageResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.chatOpeningMessage,
{
method: "POST",
body,
...(options?.signal ? { signal: options.signal } : {}),
},
);
return OpeningMessageResponseSchema.parse(unwrap(env));
}
/**
* 获取聊天历史
*/