feat(chat): render commercial actions and persist greetings
Docker Image / Build and Push Docker Image (push) Successful in 1m56s
Docker Image / Build and Push Docker Image (push) Successful in 1m56s
This commit is contained in:
@@ -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>> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取聊天历史
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user