From 05f625dd0b8b23cd3bd9a5442f75f755560203ac Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 23 Jul 2026 14:52:34 +0800 Subject: [PATCH] feat(chat): render commercial actions and persist greetings --- e2e/fixtures/api/chat.ts | 33 +++++++- .../chat/commercial-action-navigation.spec.ts | 31 +++++++ src/app/chat/chat-screen.tsx | 36 ++++++++ .../__tests__/commercial-action-card.test.tsx | 26 ++++++ src/app/chat/components/chat-area.module.css | 58 +++++++++++++ src/app/chat/components/chat-area.tsx | 12 +++ .../components/commercial-action-card.tsx | 58 +++++++++++++ src/app/chat/components/index.ts | 1 + src/app/chat/components/message-bubble.tsx | 10 +++ src/app/chat/components/message-content.tsx | 16 ++++ src/core/net/chat-websocket.ts | 16 ++++ .../repositories/chat_remote_data_source.ts | 15 ++++ src/data/repositories/chat_repository.ts | 13 +++ .../interfaces/ichat_repository.ts | 8 ++ src/data/schemas/chat/index.ts | 1 + src/data/schemas/chat/opening_message.ts | 24 ++++++ .../chat/response/chat_send_response.ts | 14 ++++ .../api/__tests__/multi_character_api.test.ts | 28 +++++++ src/data/services/api/api_contract.json | 1 + src/data/services/api/api_path.ts | 3 + src/data/services/api/chat_api.ts | 19 +++++ .../chat/__tests__/chat-helpers.test.ts | 24 ++++++ .../chat-opening-history-sync.test.ts | 83 +++++++++++++++++++ src/stores/chat/chat-history-sync.ts | 14 ++++ src/stores/chat/helper/message-mappers.ts | 3 + src/stores/chat/ui-message.ts | 2 + 26 files changed, 548 insertions(+), 1 deletion(-) create mode 100644 e2e/specs/mock/chat/commercial-action-navigation.spec.ts create mode 100644 src/app/chat/components/__tests__/commercial-action-card.test.tsx create mode 100644 src/app/chat/components/commercial-action-card.tsx create mode 100644 src/data/schemas/chat/opening_message.ts create mode 100644 src/stores/chat/__tests__/chat-opening-history-sync.test.ts diff --git a/e2e/fixtures/api/chat.ts b/e2e/fixtures/api/chat.ts index fb0f9389..38612ed7 100644 --- a/e2e/fixtures/api/chat.ts +++ b/e2e/fixtures/api/chat.ts @@ -20,6 +20,23 @@ export interface ChatMockState { export async function registerChatMocks(page: Page, options: ChatMockOptions, state: ChatMockState) { let sendCount = 0; + await page.route("**/api/chat/opening-message", async (route) => { + const body = route.request().postDataJSON() as { + openingMessage?: unknown; + } | undefined; + await route.fulfill({ + json: apiEnvelope({ + messageId: "mock-opening-message", + created: true, + openingMessage: + typeof body?.openingMessage === "string" + ? body.openingMessage + : "Hello", + createdAt: "2026-07-23T00:00:00Z", + }), + }); + }); + await page.route("**/api/chat/history**", async (route) => { const response = options.paidVoiceInsufficientCreditsFlow ? paidVoiceHistoryResponse @@ -44,7 +61,21 @@ export async function registerChatMocks(page: Page, options: ChatMockOptions, st await route.fulfill({ status: 401, json: { message: "expired" } }); return; } - const response = options.paidImageFlow && message.includes("给我发图片") + const response = message.includes("private album offer test") + ? { + ...chatSendResponse, + reply: "You always know how to make me smile.", + messageId: "commercial-action-message", + commercialAction: { + actionId: "commercial-action-1", + type: "privateAlbumOffer", + copy: "There are more private photos waiting for you.", + ctaLabel: "Open private zone", + target: "privateZone", + ruleId: "private_album_after_appearance_praise", + }, + } + : options.paidImageFlow && message.includes("给我发图片") ? paidImageChatSendResponse : options.chatLimitTriggerAt != null && sendCount >= options.chatLimitTriggerAt ? createWeeklyLimitChatSendResponse(sendCount, options.chatLimitTriggerAt) diff --git a/e2e/specs/mock/chat/commercial-action-navigation.spec.ts b/e2e/specs/mock/chat/commercial-action-navigation.spec.ts new file mode 100644 index 00000000..28876c6d --- /dev/null +++ b/e2e/specs/mock/chat/commercial-action-navigation.spec.ts @@ -0,0 +1,31 @@ +import { expect, test } from "@playwright/test"; + +import { mockCoreApis } from "@e2e/fixtures/api-mocks"; +import { + clearBrowserState, + enterChatFromSplash, +} from "@e2e/fixtures/test-helpers"; + +test.beforeEach(async ({ baseURL, context, page }) => { + await clearBrowserState(context, page, baseURL); + await mockCoreApis(page); +}); + +test("renders a backend commercial action and opens the private zone", async ({ + page, +}) => { + await enterChatFromSplash(page); + + const input = page.getByRole("textbox", { name: "Message" }); + await expect(input).toBeEnabled(); + await input.fill("private album offer test"); + await input.press("Enter"); + + const offer = page.getByLabel("Open private zone"); + await expect(offer).toContainText( + "There are more private photos waiting for you.", + ); + await offer.getByRole("button", { name: "Open private zone" }).click(); + + await expect(page).toHaveURL(/\/characters\/elio\/private-zone$/); +}); diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index 8a7dd985..86ad3cae 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -18,6 +18,8 @@ import { clearPendingChatNavigation } from "@/lib/navigation/chat_unlock_session import { buildGlobalPageUrl } from "@/router/global-route-context"; import { resolveAuthenticatedNavigation } from "@/router/navigation-resolver"; import { getCharacterRoutes, ROUTES } from "@/router/routes"; +import type { CommercialAction } from "@/data/schemas/chat"; +import { behaviorAnalytics } from "@/lib/analytics"; import { MobileShell } from "@/app/_components/core"; @@ -250,6 +252,38 @@ export function ChatScreen() { router.push(characterRoutes.privateZone); } + function handleCommercialAction(action: CommercialAction): void { + behaviorAnalytics.elementClick( + "chat.commercial_action.open", + action.ctaLabel, + { + actionId: action.actionId, + actionType: action.type, + characterId: state.characterId, + ruleId: action.ruleId, + target: action.target, + }, + ); + router.push( + action.target === "giftCatalog" + ? characterRoutes.tip + : characterRoutes.privateZone, + ); + } + + function handleCommercialActionDismiss(action: CommercialAction): void { + behaviorAnalytics.elementClick( + "chat.commercial_action.dismiss", + "Dismiss commercial action", + { + actionId: action.actionId, + actionType: action.type, + characterId: state.characterId, + ruleId: action.ruleId, + }, + ); + } + return (
{ chatDispatch({ type: "ChatLoadMoreHistoryRequested" }); }} diff --git a/src/app/chat/components/__tests__/commercial-action-card.test.tsx b/src/app/chat/components/__tests__/commercial-action-card.test.tsx new file mode 100644 index 00000000..93b06c81 --- /dev/null +++ b/src/app/chat/components/__tests__/commercial-action-card.test.tsx @@ -0,0 +1,26 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { CommercialActionCard } from "../commercial-action-card"; + +describe("CommercialActionCard", () => { + it("renders a private-zone offer with a usable CTA", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('data-commercial-action="privateAlbumOffer"'); + expect(html).toContain("There are more private photos waiting for you."); + expect(html).toContain("Open private zone"); + expect(html).toContain('aria-label="Dismiss offer"'); + }); +}); diff --git a/src/app/chat/components/chat-area.module.css b/src/app/chat/components/chat-area.module.css index 60fc7a2a..9cbf359a 100644 --- a/src/app/chat/components/chat-area.module.css +++ b/src/app/chat/components/chat-area.module.css @@ -131,6 +131,64 @@ align-items: flex-end; } +.commercialAction { + width: min(100%, 286px); + padding: 12px; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 8px; + background: rgba(25, 21, 31, 0.92); + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.2); +} + +.commercialActionHeader { + display: flex; + min-height: 24px; + align-items: center; + justify-content: space-between; + color: #f4b860; +} + +.commercialActionDismiss { + display: inline-flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 0; + background: transparent; + color: rgba(255, 255, 255, 0.66); + cursor: pointer; +} + +.commercialActionCopy { + margin: 7px 0 11px; + color: rgba(255, 255, 255, 0.9); + font-size: var(--responsive-caption, var(--font-size-sm, 13px)); + line-height: 1.45; +} + +.commercialActionButton { + display: inline-flex; + min-height: 38px; + max-width: 100%; + align-items: center; + justify-content: center; + gap: 7px; + padding: 8px 12px; + border: 0; + border-radius: 6px; + background: #f4b860; + color: #201710; + cursor: pointer; + font-size: var(--responsive-caption, var(--font-size-sm, 13px)); + font-weight: 700; +} + +.commercialActionButton span { + min-width: 0; + overflow-wrap: anywhere; +} + .bubbleAi { max-width: var(--chat-bubble-max-width, 75%); background: var(--color-bubble-ai, rgba(255, 255, 255, 0.08)); diff --git a/src/app/chat/components/chat-area.tsx b/src/app/chat/components/chat-area.tsx index 168370f5..39ada075 100644 --- a/src/app/chat/components/chat-area.tsx +++ b/src/app/chat/components/chat-area.tsx @@ -24,6 +24,7 @@ import { import { LoaderCircle } from "lucide-react"; import type { UiMessage } from "@/stores/chat/ui-message"; +import type { CommercialAction } from "@/data/schemas/chat"; import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; import { @@ -61,6 +62,8 @@ export interface ChatAreaProps { onLoadMoreHistory?: () => void; onUserAvatarClick?: () => void; onCharacterAvatarClick?: () => void; + onCommercialAction?: (action: CommercialAction) => void; + onCommercialActionDismiss?: (action: CommercialAction) => void; } type ChatMessageAction = ( @@ -85,6 +88,8 @@ export function ChatArea({ onLoadMoreHistory, onUserAvatarClick, onCharacterAvatarClick, + onCommercialAction, + onCommercialActionDismiss, }: ChatAreaProps) { const scrollRef = useRef(null); const contentRef = useRef(null); @@ -257,6 +262,8 @@ export function ChatArea({ onOpenImage, onUserAvatarClick, onCharacterAvatarClick, + onCommercialAction, + onCommercialActionDismiss, )} {isReplyingAI ? ( @@ -295,6 +302,8 @@ function renderMessagesWithDateHeaders( onOpenImage?: (displayMessageId: string) => void, onUserAvatarClick?: () => void, onCharacterAvatarClick?: () => void, + onCommercialAction?: (action: CommercialAction) => void, + onCommercialActionDismiss?: (action: CommercialAction) => void, ) { return buildChatRenderItems(messages, getMessageKey).map((item) => item.type === "date" ? ( @@ -314,6 +323,7 @@ function renderMessagesWithDateHeaders( lockReason={item.message.lockReason} lockedPrivate={item.message.lockedPrivate} privateMessageHint={item.message.privateMessageHint} + commercialAction={item.message.commercialAction} isUnlockingMessage={ isUnlockingMessage === true && item.message.displayId === unlockingMessageId @@ -324,6 +334,8 @@ function renderMessagesWithDateHeaders( onOpenImage={onOpenImage} onUserAvatarClick={onUserAvatarClick} onCharacterAvatarClick={onCharacterAvatarClick} + onCommercialAction={onCommercialAction} + onCommercialActionDismiss={onCommercialActionDismiss} /> ), ); diff --git a/src/app/chat/components/commercial-action-card.tsx b/src/app/chat/components/commercial-action-card.tsx new file mode 100644 index 00000000..9519196a --- /dev/null +++ b/src/app/chat/components/commercial-action-card.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useState } from "react"; +import { ArrowRight, Gift, Images, X } from "lucide-react"; + +import type { CommercialAction } from "@/data/schemas/chat"; + +import styles from "./chat-area.module.css"; + +export interface CommercialActionCardProps { + action: CommercialAction; + onActivate?: (action: CommercialAction) => void; + onDismiss?: (action: CommercialAction) => void; +} + +export function CommercialActionCard({ + action, + onActivate, + onDismiss, +}: CommercialActionCardProps) { + const [dismissed, setDismissed] = useState(false); + if (dismissed) return null; + + const Icon = action.type === "giftOffer" ? Gift : Images; + + return ( + + ); +} diff --git a/src/app/chat/components/index.ts b/src/app/chat/components/index.ts index 7b562782..bba812f6 100644 --- a/src/app/chat/components/index.ts +++ b/src/app/chat/components/index.ts @@ -5,6 +5,7 @@ export * from "./ai-disclosure-banner"; export * from "./browser-hint-overlay"; export * from "./chat-area"; +export * from "./commercial-action-card"; export * from "./chat-header"; export * from "./chat-insufficient-credits-banner"; export * from "./chat-input-bar"; diff --git a/src/app/chat/components/message-bubble.tsx b/src/app/chat/components/message-bubble.tsx index 18f20fee..639a1ab3 100644 --- a/src/app/chat/components/message-bubble.tsx +++ b/src/app/chat/components/message-bubble.tsx @@ -9,6 +9,7 @@ * - 用户:[占位 spacer] [Content] [Avatar] */ import { useUserSelector } from "@/stores/user/user-context"; +import type { CommercialAction } from "@/data/schemas/chat"; import { MessageAvatar } from "./message-avatar"; import { MessageContent } from "./message-content"; @@ -27,6 +28,7 @@ export interface MessageBubbleProps { lockReason?: string | null; lockedPrivate?: boolean | null; privateMessageHint?: string | null; + commercialAction?: CommercialAction | null; isUnlockingMessage?: boolean; onUnlockPrivateMessage?: ChatMessageAction; onUnlockVoiceMessage?: ChatMessageAction; @@ -34,6 +36,8 @@ export interface MessageBubbleProps { onOpenImage?: (displayMessageId: string) => void; onUserAvatarClick?: () => void; onCharacterAvatarClick?: () => void; + onCommercialAction?: (action: CommercialAction) => void; + onCommercialActionDismiss?: (action: CommercialAction) => void; } type ChatMessageAction = ( @@ -54,6 +58,7 @@ export function MessageBubble({ lockReason, lockedPrivate, privateMessageHint, + commercialAction, isUnlockingMessage, onUnlockPrivateMessage, onUnlockVoiceMessage, @@ -61,6 +66,8 @@ export function MessageBubble({ onOpenImage, onUserAvatarClick, onCharacterAvatarClick, + onCommercialAction, + onCommercialActionDismiss, }: MessageBubbleProps) { const avatarUrl = useUserSelector((state) => state.context.avatarUrl); @@ -89,11 +96,14 @@ export function MessageBubble({ lockReason={lockReason} lockedPrivate={lockedPrivate} privateMessageHint={privateMessageHint} + commercialAction={commercialAction} isUnlockingMessage={isUnlockingMessage} onUnlockPrivateMessage={onUnlockPrivateMessage} onUnlockVoiceMessage={onUnlockVoiceMessage} onUnlockImageMessage={onUnlockImageMessage} onOpenImage={onOpenImage} + onCommercialAction={onCommercialAction} + onCommercialActionDismiss={onCommercialActionDismiss} /> diff --git a/src/app/chat/components/message-content.tsx b/src/app/chat/components/message-content.tsx index 82e0ee5f..3157dab7 100644 --- a/src/app/chat/components/message-content.tsx +++ b/src/app/chat/components/message-content.tsx @@ -1,4 +1,7 @@ "use client"; +import type { CommercialAction } from "@/data/schemas/chat"; + +import { CommercialActionCard } from "./commercial-action-card"; import { ImageBubble } from "./image-bubble"; import { LockedImageMessageCard } from "./locked-image-message-card"; import { PrivateMessageCard } from "./private-message-card"; @@ -19,11 +22,14 @@ export interface MessageContentProps { lockReason?: string | null; lockedPrivate?: boolean | null; privateMessageHint?: string | null; + commercialAction?: CommercialAction | null; isUnlockingMessage?: boolean; onUnlockPrivateMessage?: ChatMessageAction; onUnlockVoiceMessage?: ChatMessageAction; onUnlockImageMessage?: ChatMessageAction; onOpenImage?: (displayMessageId: string) => void; + onCommercialAction?: (action: CommercialAction) => void; + onCommercialActionDismiss?: (action: CommercialAction) => void; } type ChatMessageAction = ( @@ -46,11 +52,14 @@ export function MessageContent({ lockReason, lockedPrivate, privateMessageHint, + commercialAction, isUnlockingMessage, onUnlockPrivateMessage, onUnlockVoiceMessage, onUnlockImageMessage, onOpenImage, + onCommercialAction, + onCommercialActionDismiss, }: MessageContentProps) { const hasImage = imageUrl != null && imageUrl.length > 0; const hasAudio = audioUrl != null && audioUrl.length > 0; @@ -125,6 +134,13 @@ export function MessageContent({ ) : null} )} + {isFromAI && commercialAction ? ( + + ) : null}
); } diff --git a/src/core/net/chat-websocket.ts b/src/core/net/chat-websocket.ts index 0cf03ff9..2303f0ee 100644 --- a/src/core/net/chat-websocket.ts +++ b/src/core/net/chat-websocket.ts @@ -12,6 +12,10 @@ import { getApiConfig } from "@/core/net/config/api_config"; import { ExceptionHandler } from "@/core/errors"; import { AppEnvUtil } from "@/utils/app-env"; import { Logger } from "@/utils/logger"; +import { + CommercialActionSchema, + type CommercialAction, +} from "@/data/schemas/chat"; const log = new Logger("ChatWebSocket"); const RECONNECT_DELAY_MS = 3000; @@ -40,6 +44,7 @@ export class ChatWebSocket { onVoiceReady: ((index: number, audioUrl: string) => void) | null = null; onImage: ((url: string) => void) | null = null; onPaywallStatus: ((payload: PaywallStatusPayload) => void) | null = null; + onCommercialAction: ((action: CommercialAction) => void) | null = null; onError: ((errorMessage: string) => void) | null = null; constructor( @@ -140,6 +145,12 @@ export class ChatWebSocket { url?: string | null; }; lockDetail?: Partial; + actionId?: string; + type?: string; + copy?: string; + ctaLabel?: string; + target?: string; + ruleId?: string; }; }; try { @@ -179,6 +190,11 @@ export class ChatWebSocket { }); break; } + case "commercial_action": { + const action = CommercialActionSchema.safeParse(payload.data); + if (action.success) this.onCommercialAction?.(action.data); + break; + } case "error": this.onError?.(payload.error ?? "Unknown error"); break; diff --git a/src/data/repositories/chat_remote_data_source.ts b/src/data/repositories/chat_remote_data_source.ts index 4555a64d..f3021712 100644 --- a/src/data/repositories/chat_remote_data_source.ts +++ b/src/data/repositories/chat_remote_data_source.ts @@ -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> { + return Result.wrap(() => + this.api.saveOpeningMessage( + OpeningMessageRequestSchema.parse({ characterId, openingMessage }), + options, + ), + ); + } + async getPreviews( options?: ChatRequestOptions, ): Promise> { diff --git a/src/data/repositories/chat_repository.ts b/src/data/repositories/chat_repository.ts index 9323911c..5ccc8dca 100644 --- a/src/data/repositories/chat_repository.ts +++ b/src/data/repositories/chat_repository.ts @@ -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> { + return this.remote.saveOpeningMessage( + characterId, + openingMessage, + options, + ); + } + /** 获取聊天历史,分页参数默认 limit=50, offset=0。 */ async getHistory( characterId: string, diff --git a/src/data/repositories/interfaces/ichat_repository.ts b/src/data/repositories/interfaces/ichat_repository.ts index 71feba2e..fcbc7d24 100644 --- a/src/data/repositories/interfaces/ichat_repository.ts +++ b/src/data/repositories/interfaces/ichat_repository.ts @@ -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>; + /** 幂等保存角色首次开场白。 */ + saveOpeningMessage( + characterId: string, + openingMessage: string, + options?: ChatRequestOptions, + ): Promise>; + /** 获取聊天历史,分页参数默认 limit=50, offset=0。 */ getHistory( characterId: string, diff --git a/src/data/schemas/chat/index.ts b/src/data/schemas/chat/index.ts index 9984d69f..e4bfa481 100644 --- a/src/data/schemas/chat/index.ts +++ b/src/data/schemas/chat/index.ts @@ -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"; diff --git a/src/data/schemas/chat/opening_message.ts b/src/data/schemas/chat/opening_message.ts new file mode 100644 index 00000000..2bc73482 --- /dev/null +++ b/src/data/schemas/chat/opening_message.ts @@ -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 +>; diff --git a/src/data/schemas/chat/response/chat_send_response.ts b/src/data/schemas/chat/response/chat_send_response.ts index 140893a0..44c6b417 100644 --- a/src/data/schemas/chat/response/chat_send_response.ts +++ b/src/data/schemas/chat/response/chat_send_response.ts @@ -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; + 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(); diff --git a/src/data/services/api/__tests__/multi_character_api.test.ts b/src/data/services/api/__tests__/multi_character_api.test.ts index 17c52c98..27a5f2a3 100644 --- a/src/data/services/api/__tests__/multi_character_api.test.ts +++ b/src/data/services/api/__tests__/multi_character_api.test.ts @@ -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, diff --git a/src/data/services/api/api_contract.json b/src/data/services/api/api_contract.json index 187a14df..e91b17a4 100644 --- a/src/data/services/api/api_contract.json +++ b/src/data/services/api/api_contract.json @@ -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" }, diff --git a/src/data/services/api/api_path.ts b/src/data/services/api/api_path.ts index b0541489..5e65ca4b 100644 --- a/src/data/services/api/api_path.ts +++ b/src/data/services/api/api_path.ts @@ -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; diff --git a/src/data/services/api/chat_api.ts b/src/data/services/api/chat_api.ts index 9dc93530..a5e37052 100644 --- a/src/data/services/api/chat_api.ts +++ b/src/data/services/api/chat_api.ts @@ -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); } + /** 幂等保存当前角色的首次开场白。 */ + async saveOpeningMessage( + body: OpeningMessageRequest, + options?: { signal?: AbortSignal }, + ): Promise { + const env = await httpClient>( + ApiPath.chatOpeningMessage, + { + method: "POST", + body, + ...(options?.signal ? { signal: options.signal } : {}), + }, + ); + return OpeningMessageResponseSchema.parse(unwrap(env)); + } + /** * 获取聊天历史 */ diff --git a/src/stores/chat/__tests__/chat-helpers.test.ts b/src/stores/chat/__tests__/chat-helpers.test.ts index 27ef12d6..4a0c9efe 100644 --- a/src/stores/chat/__tests__/chat-helpers.test.ts +++ b/src/stores/chat/__tests__/chat-helpers.test.ts @@ -72,6 +72,30 @@ function makeChatState(overrides: Partial = {}): ChatState { } describe("sendResponseToUiMessage", () => { + it("keeps a typed commercial action on the assistant message", () => { + const message = sendResponseToUiMessage( + makeResponse({ + commercialAction: { + actionId: "action-1", + type: "giftOffer", + copy: "Buy me a coffee?", + ctaLabel: "View gifts", + target: "giftCatalog", + ruleId: "coffee_support_question", + }, + }), + ); + + expect(message.commercialAction).toEqual({ + actionId: "action-1", + type: "giftOffer", + copy: "Buy me a coffee?", + ctaLabel: "View gifts", + target: "giftCatalog", + ruleId: "coffee_support_question", + }); + }); + it("maps locked voice messages without treating them as private text", () => { const message = sendResponseToUiMessage( makeResponse({ diff --git a/src/stores/chat/__tests__/chat-opening-history-sync.test.ts b/src/stores/chat/__tests__/chat-opening-history-sync.test.ts new file mode 100644 index 00000000..4e373df9 --- /dev/null +++ b/src/stores/chat/__tests__/chat-opening-history-sync.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const repository = vi.hoisted(() => ({ + saveOpeningMessage: vi.fn(), + getHistory: vi.fn(), + saveMessagesToLocal: vi.fn(), + prefetchMediaForMessages: vi.fn(), +})); + +vi.mock("@/data/repositories/chat_repository_loader", () => ({ + loadChatRepository: vi.fn(async () => repository), +})); + +import { syncNetworkHistory } from "@/stores/chat/chat-history-sync"; + +describe("chat opening-message history sync", () => { + beforeEach(() => { + vi.clearAllMocks(); + repository.saveOpeningMessage.mockResolvedValue({ + success: true, + data: { + messageId: "opening-1", + created: true, + openingMessage: "Hello from Elio", + createdAt: "2026-07-23T00:00:00Z", + }, + }); + repository.getHistory.mockResolvedValue({ + success: true, + data: { + messages: [ + { + id: "opening-1", + role: "assistant", + type: "text", + content: "Hello from Elio", + createdAt: "2026-07-23T00:00:00Z", + audioUrl: null, + image: { type: null, url: null }, + lockDetail: { locked: false, reason: null }, + }, + ], + total: 1, + limit: 50, + offset: 0, + }, + }); + repository.saveMessagesToLocal.mockResolvedValue({ + success: true, + data: undefined, + }); + repository.prefetchMediaForMessages.mockResolvedValue({ + success: true, + data: undefined, + }); + }); + + it("persists the greeting before loading authoritative history", async () => { + const output = await syncNetworkHistory( + "elio", + 0, + [], + "user:test::character:elio", + "Hello from Elio", + ); + + expect(repository.saveOpeningMessage).toHaveBeenCalledWith( + "elio", + "Hello from Elio", + { signal: undefined }, + ); + expect(repository.saveOpeningMessage.mock.invocationCallOrder[0]).toBeLessThan( + repository.getHistory.mock.invocationCallOrder[0] ?? Number.MAX_SAFE_INTEGER, + ); + expect(output?.messages).toMatchObject([ + { + remoteId: "opening-1", + content: "Hello from Elio", + }, + ]); + expect(output?.messages[0]?.isSynthetic).toBeUndefined(); + }); +}); diff --git a/src/stores/chat/chat-history-sync.ts b/src/stores/chat/chat-history-sync.ts index 96290267..6a677001 100644 --- a/src/stores/chat/chat-history-sync.ts +++ b/src/stores/chat/chat-history-sync.ts @@ -101,6 +101,20 @@ export async function syncNetworkHistory( emptyChatGreeting, ); + const openingResult = await chatRepo.saveOpeningMessage( + characterId, + emptyChatGreeting, + { signal }, + ); + if (Result.isErr(openingResult)) { + if (isAbortError(openingResult.error)) throw openingResult.error; + log.warn("[chat-machine] opening message persistence skipped", { + characterId, + error: openingResult.error, + }); + } + signal?.throwIfAborted(); + const networkResult = await chatRepo.getHistory( characterId, CHAT_HISTORY_LIMIT, diff --git a/src/stores/chat/helper/message-mappers.ts b/src/stores/chat/helper/message-mappers.ts index 367574c5..53772027 100644 --- a/src/stores/chat/helper/message-mappers.ts +++ b/src/stores/chat/helper/message-mappers.ts @@ -74,6 +74,9 @@ export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage { ...(response.audioUrl && !response.lockDetail.locked ? { audioUrl: response.audioUrl } : {}), + ...(response.commercialAction + ? { commercialAction: response.commercialAction } + : {}), ...deriveUiLockFields(response.lockDetail, response.image.url), }; } diff --git a/src/stores/chat/ui-message.ts b/src/stores/chat/ui-message.ts index ae8ef17d..cf34da80 100644 --- a/src/stores/chat/ui-message.ts +++ b/src/stores/chat/ui-message.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { CommercialActionSchema } from "@/data/schemas/chat"; export const UiMessageSchema = z.object({ displayId: z.string().min(1), @@ -16,6 +17,7 @@ export const UiMessageSchema = z.object({ isPrivate: z.boolean().nullable().optional(), lockedPrivate: z.boolean().nullable().optional(), privateMessageHint: z.string().nullable().optional(), + commercialAction: CommercialActionSchema.nullable().optional(), }); export type UiMessage = z.infer;