feat(chat): render commercial actions and persist greetings

This commit is contained in:
Codex
2026-07-23 14:52:34 +08:00
parent 3790fb813f
commit 05f625dd0b
26 changed files with 548 additions and 1 deletions
+36
View File
@@ -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 (
<MobileShell>
<div
@@ -298,6 +332,8 @@ export function ChatScreen() {
onOpenImage={handleOpenImage}
onUserAvatarClick={handleOpenUserProfile}
onCharacterAvatarClick={handleOpenCharacterPrivateZone}
onCommercialAction={handleCommercialAction}
onCommercialActionDismiss={handleCommercialActionDismiss}
onLoadMoreHistory={() => {
chatDispatch({ type: "ChatLoadMoreHistoryRequested" });
}}
@@ -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(
<CommercialActionCard
action={{
actionId: "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",
}}
/>,
);
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"');
});
});
@@ -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));
+12
View File
@@ -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<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(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}
/>
),
);
@@ -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 (
<aside
className={styles.commercialAction}
aria-label={action.ctaLabel}
data-commercial-action={action.type}
>
<div className={styles.commercialActionHeader}>
<Icon size={18} aria-hidden="true" />
<button
type="button"
className={styles.commercialActionDismiss}
title="Dismiss offer"
aria-label="Dismiss offer"
onClick={() => {
setDismissed(true);
onDismiss?.(action);
}}
>
<X size={16} aria-hidden="true" />
</button>
</div>
<p className={styles.commercialActionCopy}>{action.copy}</p>
<button
type="button"
className={styles.commercialActionButton}
onClick={() => onActivate?.(action)}
>
<span>{action.ctaLabel}</span>
<ArrowRight size={17} aria-hidden="true" />
</button>
</aside>
);
}
+1
View File
@@ -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";
@@ -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}
/>
<div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
</div>
@@ -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 ? (
<CommercialActionCard
action={commercialAction}
onActivate={onCommercialAction}
onDismiss={onCommercialActionDismiss}
/>
) : null}
</div>
);
}
+16
View File
@@ -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<PaywallStatusPayload>;
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;
@@ -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));
}
/**
* 获取聊天历史
*/
@@ -72,6 +72,30 @@ function makeChatState(overrides: Partial<ChatState> = {}): 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({
@@ -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();
});
});
+14
View File
@@ -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,
@@ -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),
};
}
+2
View File
@@ -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<typeof UiMessageSchema>;