feat(chat): render commercial actions and persist greetings
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user