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
@@ -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>;