fix(chat): stabilize message identities
This commit is contained in:
@@ -72,8 +72,8 @@ describe("chat actor request cancellation", () => {
|
||||
);
|
||||
|
||||
const historyCall = historySync.syncNetworkHistory.mock.calls[0];
|
||||
expect(historyCall?.[3]).toBe("Hello from Elio");
|
||||
const signal = getSignal(historyCall?.[4]);
|
||||
expect(historyCall?.[4]).toBe("Hello from Elio");
|
||||
const signal = getSignal(historyCall?.[5]);
|
||||
expect(signal.aborted).toBe(false);
|
||||
actor.stop();
|
||||
expect(signal.aborted).toBe(true);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@/data/constants/character";
|
||||
import {
|
||||
ChatSendResponseSchema,
|
||||
UnlockPrivateResponseSchema,
|
||||
type ChatSendResponse,
|
||||
type ChatSendResponseInput,
|
||||
} from "@/data/schemas/chat";
|
||||
@@ -13,6 +14,7 @@ import type { ChatState } from "@/stores/chat/chat-state";
|
||||
import {
|
||||
applyHttpSendOutput,
|
||||
applyNetworkHistoryLoadedOutput,
|
||||
applySingleUnlockOutput,
|
||||
countLockedHistoryMessages,
|
||||
localMessagesToUi,
|
||||
sendResponseToUiMessage,
|
||||
@@ -178,6 +180,7 @@ describe("applyHttpSendOutput", () => {
|
||||
const context = makeChatState({
|
||||
messages: [
|
||||
{
|
||||
displayId: "client:message:failed",
|
||||
content: "hello",
|
||||
isFromAI: false,
|
||||
date: "2026-06-25",
|
||||
@@ -215,6 +218,7 @@ describe("applyHttpSendOutput", () => {
|
||||
const context = makeChatState({
|
||||
messages: [
|
||||
{
|
||||
displayId: "client:message:successful",
|
||||
content: "hello",
|
||||
isFromAI: false,
|
||||
date: "2026-06-25",
|
||||
@@ -257,6 +261,7 @@ describe("chat history pagination helpers", () => {
|
||||
it("falls back to the bounded history limit when the backend limit is zero", () => {
|
||||
const nextState = applyNetworkHistoryLoadedOutput(makeChatState(), {
|
||||
messages: [],
|
||||
localDisplayIds: [],
|
||||
localCount: 0,
|
||||
total: 120,
|
||||
limit: 0,
|
||||
@@ -266,9 +271,115 @@ describe("chat history pagination helpers", () => {
|
||||
expect(nextState.nextHistoryOffset).toBe(50);
|
||||
expect(nextState.historyTotal).toBe(120);
|
||||
});
|
||||
|
||||
it("preserves messages added after the local history snapshot", () => {
|
||||
const localDisplayId = "server:history-1:assistant";
|
||||
const optimisticMessage = {
|
||||
displayId: "client:message:optimistic-1",
|
||||
clientId: "optimistic-1",
|
||||
content: "Sent while refreshing",
|
||||
isFromAI: false,
|
||||
date: "2026-07-20",
|
||||
};
|
||||
const nextState = applyNetworkHistoryLoadedOutput(
|
||||
makeChatState({
|
||||
messages: [
|
||||
{
|
||||
displayId: localDisplayId,
|
||||
remoteId: "history-1",
|
||||
content: "Cached",
|
||||
isFromAI: true,
|
||||
date: "2026-07-20",
|
||||
},
|
||||
optimisticMessage,
|
||||
],
|
||||
}),
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
displayId: localDisplayId,
|
||||
remoteId: "history-1",
|
||||
content: "Fresh",
|
||||
isFromAI: true,
|
||||
date: "2026-07-20",
|
||||
},
|
||||
],
|
||||
localDisplayIds: [localDisplayId],
|
||||
localCount: 1,
|
||||
total: 1,
|
||||
limit: 50,
|
||||
},
|
||||
);
|
||||
|
||||
expect(nextState.messages).toEqual([
|
||||
expect.objectContaining({
|
||||
displayId: localDisplayId,
|
||||
content: "Fresh",
|
||||
}),
|
||||
optimisticMessage,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("localMessagesToUi", () => {
|
||||
it("uses distinct stable display ids for user and assistant records sharing a remote id", () => {
|
||||
const records = [
|
||||
{
|
||||
id: "shared-1",
|
||||
role: "user",
|
||||
content: "Hello",
|
||||
createdAt: "2026-06-25T12:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "shared-1",
|
||||
role: "assistant",
|
||||
content: "Hi",
|
||||
createdAt: "2026-06-25T12:00:01.000Z",
|
||||
},
|
||||
{
|
||||
id: "shared-1",
|
||||
role: "assistant",
|
||||
content: "A duplicated backend id",
|
||||
createdAt: "2026-06-25T12:00:02.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
const first = localMessagesToUi(records);
|
||||
const second = localMessagesToUi(records);
|
||||
|
||||
expect(first.map((message) => message.displayId)).toEqual([
|
||||
"server:shared-1:user",
|
||||
"server:shared-1:assistant",
|
||||
"server:shared-1:assistant:1",
|
||||
]);
|
||||
expect(first.map((message) => message.remoteId)).toEqual([
|
||||
"shared-1",
|
||||
"shared-1",
|
||||
"shared-1",
|
||||
]);
|
||||
expect(second.map((message) => message.displayId)).toEqual(
|
||||
first.map((message) => message.displayId),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a stable legacy display id when history has no remote id", () => {
|
||||
const records = [
|
||||
{
|
||||
role: "assistant",
|
||||
type: "text",
|
||||
content: "Legacy reply",
|
||||
createdAt: "2026-06-25T12:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
const first = localMessagesToUi(records)[0];
|
||||
const second = localMessagesToUi(records)[0];
|
||||
|
||||
expect(first?.displayId).toMatch(/^legacy:/);
|
||||
expect(second?.displayId).toBe(first?.displayId);
|
||||
expect(first?.remoteId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps locked voice messages from history", () => {
|
||||
const [message] = localMessagesToUi([
|
||||
{
|
||||
@@ -320,11 +431,53 @@ describe("localMessagesToUi", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("applySingleUnlockOutput", () => {
|
||||
it("preserves display identity while adding the backend id", () => {
|
||||
const displayId = "promotion:lock-1";
|
||||
const [message] = applySingleUnlockOutput(
|
||||
[
|
||||
{
|
||||
displayId,
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-07-20",
|
||||
locked: true,
|
||||
lockReason: "image_paywall",
|
||||
imagePaywalled: true,
|
||||
},
|
||||
],
|
||||
{
|
||||
displayMessageId: displayId,
|
||||
request: {
|
||||
displayMessageId: displayId,
|
||||
lockType: "image_paywall",
|
||||
clientLockId: "lock-1",
|
||||
},
|
||||
response: UnlockPrivateResponseSchema.parse({
|
||||
unlocked: true,
|
||||
messageId: "remote-1",
|
||||
image: {
|
||||
type: "promotion",
|
||||
url: "https://example.com/unlocked.jpg",
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(message).toMatchObject({
|
||||
displayId,
|
||||
remoteId: "remote-1",
|
||||
locked: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("countLockedHistoryMessages", () => {
|
||||
it("counts only unlockable locked AI messages", () => {
|
||||
expect(
|
||||
countLockedHistoryMessages([
|
||||
{
|
||||
displayId: "private-1",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -332,6 +485,7 @@ describe("countLockedHistoryMessages", () => {
|
||||
lockReason: "private_message",
|
||||
},
|
||||
{
|
||||
displayId: "voice-1",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -339,6 +493,7 @@ describe("countLockedHistoryMessages", () => {
|
||||
lockReason: "voice_message",
|
||||
},
|
||||
{
|
||||
displayId: "image-1",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -348,6 +503,7 @@ describe("countLockedHistoryMessages", () => {
|
||||
lockReason: "image",
|
||||
},
|
||||
{
|
||||
displayId: "other-1",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -355,6 +511,7 @@ describe("countLockedHistoryMessages", () => {
|
||||
lockReason: "insufficient_credits",
|
||||
},
|
||||
{
|
||||
displayId: "user-1",
|
||||
content: "user text",
|
||||
isFromAI: false,
|
||||
date: "2026-06-29",
|
||||
|
||||
@@ -38,13 +38,13 @@ describe("chat history flow", () => {
|
||||
resolveNetwork = resolve;
|
||||
});
|
||||
const localMessage: UiMessage = {
|
||||
id: "local-msg",
|
||||
displayId: "local-msg",
|
||||
content: "cached local message",
|
||||
isFromAI: true,
|
||||
date: "2026-07-02",
|
||||
};
|
||||
const networkMessage: UiMessage = {
|
||||
id: "network-msg",
|
||||
displayId: "network-msg",
|
||||
content: "fresh network message",
|
||||
isFromAI: true,
|
||||
date: "2026-07-02",
|
||||
@@ -67,6 +67,7 @@ describe("chat history flow", () => {
|
||||
type: "ChatNetworkHistoryLoaded",
|
||||
output: {
|
||||
messages: [networkMessage],
|
||||
localDisplayIds: [localMessage.displayId],
|
||||
localOverwritten: true,
|
||||
localCount: 1,
|
||||
networkCount: 1,
|
||||
@@ -89,17 +90,17 @@ describe("chat history flow", () => {
|
||||
);
|
||||
|
||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||
{ id: "local-msg", content: "cached local message" },
|
||||
{ displayId: "local-msg", content: "cached local message" },
|
||||
]);
|
||||
|
||||
resolveNetwork();
|
||||
await waitFor(
|
||||
actor,
|
||||
(snapshot) => snapshot.context.messages[0]?.id === "network-msg",
|
||||
(snapshot) => snapshot.context.messages[0]?.displayId === "network-msg",
|
||||
);
|
||||
|
||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||
{ id: "network-msg", content: "fresh network message" },
|
||||
{ displayId: "network-msg", content: "fresh network message" },
|
||||
]);
|
||||
|
||||
actor.stop();
|
||||
@@ -108,7 +109,7 @@ describe("chat history flow", () => {
|
||||
it("loads older pages until total is exhausted and keeps existing messages", async () => {
|
||||
const requests: LoadMoreHistoryActorEvent[] = [];
|
||||
const latestMessage: UiMessage = {
|
||||
id: "latest",
|
||||
displayId: "latest",
|
||||
content: "current unlocked message",
|
||||
isFromAI: true,
|
||||
date: "2026-07-15",
|
||||
@@ -173,8 +174,8 @@ describe("chat history flow", () => {
|
||||
);
|
||||
expect(requests[0]).toMatchObject({ offset: 50, limit: 50 });
|
||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||
{ id: "older-50" },
|
||||
{ id: "latest", content: "current unlocked message" },
|
||||
{ displayId: "older-50" },
|
||||
{ displayId: "latest", content: "current unlocked message" },
|
||||
]);
|
||||
|
||||
actor.send({ type: "ChatLoadMoreHistoryRequested" });
|
||||
@@ -290,10 +291,10 @@ describe("chat history flow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function createHistoryMessage(id: string): UiMessage {
|
||||
function createHistoryMessage(displayId: string): UiMessage {
|
||||
return {
|
||||
id,
|
||||
content: `Message ${id}`,
|
||||
displayId,
|
||||
content: `Message ${displayId}`,
|
||||
isFromAI: true,
|
||||
date: "2026-07-15",
|
||||
};
|
||||
|
||||
@@ -170,6 +170,7 @@ export function createLoadHistoryCallback(
|
||||
type: "ChatNetworkHistoryLoaded",
|
||||
output: {
|
||||
messages: networkMessages,
|
||||
localDisplayIds: messages.map((message) => message.displayId),
|
||||
localOverwritten: true,
|
||||
localCount: messages.length,
|
||||
networkCount: networkMessages.length,
|
||||
|
||||
@@ -22,7 +22,7 @@ describe("chat promotion", () => {
|
||||
const messages = appendPromotionMessage(
|
||||
[
|
||||
{
|
||||
id: "history-1",
|
||||
displayId: "history-1",
|
||||
content: "History",
|
||||
isFromAI: true,
|
||||
date: "2026-07-13",
|
||||
@@ -31,7 +31,7 @@ describe("chat promotion", () => {
|
||||
state,
|
||||
);
|
||||
|
||||
expect(messages.map((message) => message.id)).toEqual([
|
||||
expect(messages.map((message) => message.displayId)).toEqual([
|
||||
"history-1",
|
||||
"promotion:promotion-1",
|
||||
]);
|
||||
@@ -62,7 +62,8 @@ describe("chat promotion", () => {
|
||||
});
|
||||
|
||||
expect(next?.message).toMatchObject({
|
||||
id: "backend-1",
|
||||
displayId: "promotion:promotion-1",
|
||||
remoteId: "backend-1",
|
||||
imageUrl: "https://example.com/unlocked.jpg",
|
||||
imagePaywalled: false,
|
||||
locked: false,
|
||||
@@ -71,10 +72,19 @@ describe("chat promotion", () => {
|
||||
|
||||
it("keeps the promotion last and removes matching history duplicates", () => {
|
||||
const state = createChatPromotionState(promotion, "backend-1");
|
||||
const userMessage = {
|
||||
displayId: "server:backend-1:user",
|
||||
remoteId: "backend-1",
|
||||
content: "User message with the shared remote id",
|
||||
isFromAI: false,
|
||||
date: "2026-07-13",
|
||||
};
|
||||
const messages = appendPromotionMessage(
|
||||
[
|
||||
userMessage,
|
||||
{
|
||||
id: "backend-1",
|
||||
displayId: "server:backend-1:assistant",
|
||||
remoteId: "backend-1",
|
||||
content: "Stale history copy",
|
||||
isFromAI: true,
|
||||
date: "2026-07-13",
|
||||
@@ -83,6 +93,6 @@ describe("chat promotion", () => {
|
||||
state,
|
||||
);
|
||||
|
||||
expect(messages).toEqual([state.message]);
|
||||
expect(messages).toEqual([userMessage, state.message]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,10 @@ describe("chat send flow", () => {
|
||||
{ content: "hello", isFromAI: false },
|
||||
{ content: "still there?", isFromAI: false },
|
||||
]);
|
||||
for (const message of actor.getSnapshot().context.messages) {
|
||||
expect(message.displayId).toMatch(/^client:message:/);
|
||||
expect(message.clientId).toBeTruthy();
|
||||
}
|
||||
expect(actor.getSnapshot().context.outgoingMessageRevision).toBe(2);
|
||||
|
||||
actor.send({ type: "ChatSendMessage", content: " " });
|
||||
@@ -94,6 +98,9 @@ describe("chat send flow", () => {
|
||||
content: "[Image]",
|
||||
isFromAI: false,
|
||||
});
|
||||
expect(
|
||||
actor.getSnapshot().context.messages.at(-1)?.displayId,
|
||||
).toMatch(/^client:image:/);
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ describe("chat session flow", () => {
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
id: "history-1",
|
||||
displayId: "history-1",
|
||||
content: "Existing history",
|
||||
isFromAI: true,
|
||||
date: "2026-07-13",
|
||||
@@ -124,7 +124,7 @@ describe("chat session flow", () => {
|
||||
const context = actor.getSnapshot().context;
|
||||
expect(context.messages).toHaveLength(1);
|
||||
expect(context.promotion?.message).toMatchObject({
|
||||
id: "promotion:promotion-1",
|
||||
displayId: "promotion:promotion-1",
|
||||
locked: true,
|
||||
lockReason: "voice_message",
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ describe("chat unlock flow", () => {
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
displayId: "private-1",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -27,6 +28,7 @@ describe("chat unlock flow", () => {
|
||||
lockReason: "private_message",
|
||||
},
|
||||
{
|
||||
displayId: "voice-1",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -56,7 +58,7 @@ describe("chat unlock flow", () => {
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
id: "msg-image-locked",
|
||||
displayId: "msg-image-locked",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -72,7 +74,7 @@ describe("chat unlock flow", () => {
|
||||
shortfallCredits: 0,
|
||||
messages: [
|
||||
{
|
||||
id: "msg-image-locked",
|
||||
displayId: "msg-image-locked",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -97,7 +99,7 @@ describe("chat unlock flow", () => {
|
||||
expect(actor.getSnapshot().context.unlockHistoryPromptVisible).toBe(false);
|
||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||
{
|
||||
id: "msg-image-locked",
|
||||
displayId: "msg-image-locked",
|
||||
imageUrl: "https://example.com/locked.jpg",
|
||||
imagePaywalled: true,
|
||||
locked: true,
|
||||
@@ -112,6 +114,7 @@ describe("chat unlock flow", () => {
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
displayId: "private-1",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -119,6 +122,7 @@ describe("chat unlock flow", () => {
|
||||
lockReason: "private_message",
|
||||
},
|
||||
{
|
||||
displayId: "voice-1",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -132,6 +136,7 @@ describe("chat unlock flow", () => {
|
||||
shortfallCredits: 0,
|
||||
messages: [
|
||||
{
|
||||
displayId: "unlocked-1",
|
||||
content: "unlocked",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -168,6 +173,7 @@ describe("chat unlock flow", () => {
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
displayId: "private-1",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -175,6 +181,7 @@ describe("chat unlock flow", () => {
|
||||
lockReason: "private_message",
|
||||
},
|
||||
{
|
||||
displayId: "voice-1",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -211,7 +218,7 @@ describe("chat unlock flow", () => {
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
id: "msg-private-locked",
|
||||
displayId: "msg-private-locked",
|
||||
content: "Original private message content.",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -238,7 +245,8 @@ describe("chat unlock flow", () => {
|
||||
|
||||
actor.send({
|
||||
type: "ChatUnlockMessageRequested",
|
||||
messageId: "msg-private-locked",
|
||||
displayMessageId: "msg-private-locked",
|
||||
remoteMessageId: "msg-private-locked",
|
||||
kind: "private",
|
||||
});
|
||||
|
||||
@@ -250,7 +258,7 @@ describe("chat unlock flow", () => {
|
||||
expect(actor.getSnapshot().context.unlockingMessage).toBeNull();
|
||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||
{
|
||||
id: "msg-private-locked",
|
||||
displayId: "msg-private-locked",
|
||||
content: "Unlocked private message content.",
|
||||
locked: false,
|
||||
lockReason: null,
|
||||
@@ -267,13 +275,15 @@ describe("chat unlock flow", () => {
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
id: "msg-shared-id",
|
||||
displayId: "server:msg-shared-id:user",
|
||||
remoteId: "msg-shared-id",
|
||||
content: "User original question",
|
||||
isFromAI: false,
|
||||
date: "2026-06-29",
|
||||
},
|
||||
{
|
||||
id: "msg-shared-id",
|
||||
displayId: "server:msg-shared-id:assistant",
|
||||
remoteId: "msg-shared-id",
|
||||
content: "Original AI private message",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -300,7 +310,8 @@ describe("chat unlock flow", () => {
|
||||
|
||||
actor.send({
|
||||
type: "ChatUnlockMessageRequested",
|
||||
messageId: "msg-shared-id",
|
||||
displayMessageId: "server:msg-shared-id:assistant",
|
||||
remoteMessageId: "msg-shared-id",
|
||||
kind: "private",
|
||||
});
|
||||
|
||||
@@ -310,12 +321,14 @@ describe("chat unlock flow", () => {
|
||||
|
||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||
{
|
||||
id: "msg-shared-id",
|
||||
displayId: "server:msg-shared-id:user",
|
||||
remoteId: "msg-shared-id",
|
||||
content: "User original question",
|
||||
isFromAI: false,
|
||||
},
|
||||
{
|
||||
id: "msg-shared-id",
|
||||
displayId: "server:msg-shared-id:assistant",
|
||||
remoteId: "msg-shared-id",
|
||||
content: "Unlocked private AI message.",
|
||||
isFromAI: true,
|
||||
locked: false,
|
||||
@@ -333,7 +346,7 @@ describe("chat unlock flow", () => {
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
id: "msg-image-locked",
|
||||
displayId: "msg-image-locked",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -360,7 +373,8 @@ describe("chat unlock flow", () => {
|
||||
|
||||
actor.send({
|
||||
type: "ChatUnlockMessageRequested",
|
||||
messageId: "msg-image-locked",
|
||||
displayMessageId: "msg-image-locked",
|
||||
remoteMessageId: "msg-image-locked",
|
||||
kind: "image",
|
||||
});
|
||||
|
||||
@@ -370,7 +384,7 @@ describe("chat unlock flow", () => {
|
||||
|
||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||
{
|
||||
id: "msg-image-locked",
|
||||
displayId: "msg-image-locked",
|
||||
content: "",
|
||||
imageUrl: "https://example.com/locked.jpg",
|
||||
imagePaywalled: false,
|
||||
@@ -387,7 +401,7 @@ describe("chat unlock flow", () => {
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
id: "msg-voice-locked",
|
||||
displayId: "msg-voice-locked",
|
||||
content: "Original voice transcript.",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -414,7 +428,8 @@ describe("chat unlock flow", () => {
|
||||
|
||||
actor.send({
|
||||
type: "ChatUnlockMessageRequested",
|
||||
messageId: "msg-voice-locked",
|
||||
displayMessageId: "msg-voice-locked",
|
||||
remoteMessageId: "msg-voice-locked",
|
||||
kind: "voice",
|
||||
});
|
||||
|
||||
@@ -424,7 +439,7 @@ describe("chat unlock flow", () => {
|
||||
|
||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||
{
|
||||
id: "msg-voice-locked",
|
||||
displayId: "msg-voice-locked",
|
||||
content: "Original voice transcript.",
|
||||
audioUrl: "https://example.com/unlocked-voice.mp3",
|
||||
locked: false,
|
||||
@@ -441,7 +456,7 @@ describe("chat unlock flow", () => {
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
id: "msg-voice-locked",
|
||||
displayId: "msg-voice-locked",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -473,7 +488,8 @@ describe("chat unlock flow", () => {
|
||||
|
||||
actor.send({
|
||||
type: "ChatUnlockMessageRequested",
|
||||
messageId: "msg-voice-locked",
|
||||
displayMessageId: "msg-voice-locked",
|
||||
remoteMessageId: "msg-voice-locked",
|
||||
kind: "voice",
|
||||
});
|
||||
|
||||
@@ -493,7 +509,7 @@ describe("chat unlock flow", () => {
|
||||
});
|
||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||
{
|
||||
id: "msg-voice-locked",
|
||||
displayId: "msg-voice-locked",
|
||||
locked: true,
|
||||
lockReason: "voice_message",
|
||||
},
|
||||
@@ -509,7 +525,7 @@ describe("chat unlock flow", () => {
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
id: "msg-missing",
|
||||
displayId: "msg-missing",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
@@ -541,7 +557,8 @@ describe("chat unlock flow", () => {
|
||||
|
||||
actor.send({
|
||||
type: "ChatUnlockMessageRequested",
|
||||
messageId: "msg-missing",
|
||||
displayMessageId: "msg-missing",
|
||||
remoteMessageId: "msg-missing",
|
||||
kind: "private",
|
||||
});
|
||||
|
||||
@@ -557,7 +574,7 @@ describe("chat unlock flow", () => {
|
||||
});
|
||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||
{
|
||||
id: "msg-missing",
|
||||
displayId: "msg-missing",
|
||||
locked: true,
|
||||
lockReason: "private_message",
|
||||
},
|
||||
@@ -571,7 +588,7 @@ describe("chat unlock flow", () => {
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
id: "msg-mismatch",
|
||||
displayId: "msg-mismatch",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-07-20",
|
||||
@@ -592,7 +609,8 @@ describe("chat unlock flow", () => {
|
||||
);
|
||||
actor.send({
|
||||
type: "ChatUnlockMessageRequested",
|
||||
messageId: "msg-mismatch",
|
||||
displayMessageId: "msg-mismatch",
|
||||
remoteMessageId: "msg-mismatch",
|
||||
kind: "voice",
|
||||
});
|
||||
await waitFor(
|
||||
@@ -617,7 +635,7 @@ describe("chat unlock flow", () => {
|
||||
resolveUnlock = resolve;
|
||||
});
|
||||
const originalMessage = {
|
||||
id: "promotion:lock-1",
|
||||
displayId: "promotion:lock-1",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-07-16",
|
||||
@@ -648,7 +666,7 @@ describe("chat unlock flow", () => {
|
||||
);
|
||||
actor.send({
|
||||
type: "ChatUnlockMessageRequested",
|
||||
messageId: "promotion:lock-1",
|
||||
displayMessageId: "promotion:lock-1",
|
||||
remoteMessageId: "remote-1",
|
||||
kind: "image",
|
||||
lockType: "image_paywall",
|
||||
@@ -669,6 +687,7 @@ describe("chat unlock flow", () => {
|
||||
type: "ChatNetworkHistoryLoaded",
|
||||
output: {
|
||||
messages: [],
|
||||
localDisplayIds: [],
|
||||
localOverwritten: true,
|
||||
localCount: 0,
|
||||
networkCount: 0,
|
||||
|
||||
@@ -51,7 +51,7 @@ export type ChatEvent =
|
||||
| { type: "ChatPromotionCleared" }
|
||||
| {
|
||||
type: "ChatUnlockMessageRequested";
|
||||
messageId: string;
|
||||
displayMessageId: string;
|
||||
remoteMessageId?: string;
|
||||
kind: PendingChatUnlockKind;
|
||||
lockType?: ChatLockType;
|
||||
|
||||
@@ -15,6 +15,8 @@ const log = new Logger("StoresChatChatHistorySync");
|
||||
export type ReadAndSyncHistoryOutput = {
|
||||
/** Network-authoritative messages. Empty network history includes a UI greeting. */
|
||||
messages: UiMessage[];
|
||||
/** Display identities present in the local snapshot before the request. */
|
||||
localDisplayIds: readonly string[];
|
||||
/** True when network history was written back to local storage. */
|
||||
localOverwritten: boolean;
|
||||
localCount: number;
|
||||
@@ -31,8 +33,12 @@ export type LocalHistorySnapshotOutput = {
|
||||
|
||||
export type NetworkHistorySyncOutput = ReadAndSyncHistoryOutput;
|
||||
|
||||
export function createGreetingMessage(content: string): UiMessage {
|
||||
export function createGreetingMessage(
|
||||
characterId: string,
|
||||
content: string,
|
||||
): UiMessage {
|
||||
return {
|
||||
displayId: `greeting:${encodeURIComponent(characterId)}`,
|
||||
content,
|
||||
isFromAI: true,
|
||||
date: todayString(),
|
||||
@@ -49,10 +55,14 @@ export async function resolveHistoryCacheIdentity(
|
||||
|
||||
export async function readLocalHistorySnapshot(
|
||||
cacheIdentity: string | null,
|
||||
characterId: string,
|
||||
emptyChatGreeting: string,
|
||||
): Promise<LocalHistorySnapshotOutput> {
|
||||
const chatRepo = await loadChatRepository();
|
||||
const greetingMessage = createGreetingMessage(emptyChatGreeting);
|
||||
const greetingMessage = createGreetingMessage(
|
||||
characterId,
|
||||
emptyChatGreeting,
|
||||
);
|
||||
|
||||
const localResult = cacheIdentity
|
||||
? await chatRepo.getLocalMessages(cacheIdentity)
|
||||
@@ -80,12 +90,16 @@ export async function readLocalHistorySnapshot(
|
||||
export async function syncNetworkHistory(
|
||||
characterId: string,
|
||||
localCount: number,
|
||||
localDisplayIds: readonly string[],
|
||||
cacheIdentity: string | null,
|
||||
emptyChatGreeting: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<NetworkHistorySyncOutput | null> {
|
||||
const chatRepo = await loadChatRepository();
|
||||
const greetingMessage = createGreetingMessage(emptyChatGreeting);
|
||||
const greetingMessage = createGreetingMessage(
|
||||
characterId,
|
||||
emptyChatGreeting,
|
||||
);
|
||||
|
||||
const networkResult = await chatRepo.getHistory(
|
||||
characterId,
|
||||
@@ -134,6 +148,7 @@ export async function syncNetworkHistory(
|
||||
|
||||
return {
|
||||
messages: finalMessages,
|
||||
localDisplayIds,
|
||||
localOverwritten,
|
||||
localCount,
|
||||
networkCount: networkUi.length,
|
||||
@@ -156,11 +171,13 @@ export async function readAndSyncHistory(
|
||||
const cacheIdentity = await resolveHistoryCacheIdentity(characterId);
|
||||
const localSnapshot = await readLocalHistorySnapshot(
|
||||
cacheIdentity,
|
||||
characterId,
|
||||
emptyChatGreeting,
|
||||
);
|
||||
const networkSnapshot = await syncNetworkHistory(
|
||||
characterId,
|
||||
localSnapshot.localCount,
|
||||
localSnapshot.messages.map((message) => message.displayId),
|
||||
cacheIdentity,
|
||||
emptyChatGreeting,
|
||||
signal,
|
||||
@@ -169,6 +186,9 @@ export async function readAndSyncHistory(
|
||||
|
||||
return {
|
||||
messages: localSnapshot.messages,
|
||||
localDisplayIds: localSnapshot.messages.map(
|
||||
(message) => message.displayId,
|
||||
),
|
||||
localOverwritten: false,
|
||||
localCount: localSnapshot.localCount,
|
||||
networkCount: 0,
|
||||
|
||||
@@ -20,6 +20,7 @@ export function applyNetworkHistoryLoadedOutput(
|
||||
context: ChatState,
|
||||
output: {
|
||||
messages: UiMessage[];
|
||||
localDisplayIds: readonly string[];
|
||||
localCount: number;
|
||||
total: number;
|
||||
limit: number;
|
||||
@@ -33,8 +34,15 @@ export function applyNetworkHistoryLoadedOutput(
|
||||
| "nextHistoryOffset"
|
||||
| "isLoadingMoreHistory"
|
||||
> {
|
||||
const localSnapshotSize = output.localCount === 0 ? 1 : output.localCount;
|
||||
const optimisticTail = context.messages.slice(localSnapshotSize);
|
||||
const localDisplayIds = new Set(output.localDisplayIds);
|
||||
const networkDisplayIds = new Set(
|
||||
output.messages.map((message) => message.displayId),
|
||||
);
|
||||
const optimisticTail = context.messages.filter(
|
||||
(message) =>
|
||||
!localDisplayIds.has(message.displayId) &&
|
||||
!networkDisplayIds.has(message.displayId),
|
||||
);
|
||||
const historyLimit = normalizeHistoryLimit(output.limit);
|
||||
return {
|
||||
messages: [...output.messages, ...optimisticTail],
|
||||
@@ -95,13 +103,17 @@ export function prependUniqueHistoryMessages(
|
||||
olderMessages: readonly UiMessage[],
|
||||
): UiMessage[] {
|
||||
const existingIds = new Set(
|
||||
currentMessages.flatMap((message) => (message.id ? [message.id] : [])),
|
||||
currentMessages.map((message) => message.displayId),
|
||||
);
|
||||
const pageIds = new Set<string>();
|
||||
const uniqueOlderMessages = olderMessages.filter((message) => {
|
||||
if (!message.id) return true;
|
||||
if (existingIds.has(message.id) || pageIds.has(message.id)) return false;
|
||||
pageIds.add(message.id);
|
||||
if (
|
||||
existingIds.has(message.displayId) ||
|
||||
pageIds.has(message.displayId)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
pageIds.add(message.displayId);
|
||||
return true;
|
||||
});
|
||||
return [...uniqueOlderMessages, ...currentMessages];
|
||||
|
||||
@@ -2,7 +2,12 @@ import type {
|
||||
ChatLockDetailData,
|
||||
ChatSendResponse,
|
||||
} from "@/data/schemas/chat";
|
||||
import type { UiMessage } from "@/stores/chat/ui-message";
|
||||
import {
|
||||
createClientUiMessageIdentity,
|
||||
createLegacyUiMessageIdentity,
|
||||
createRemoteUiMessageIdentity,
|
||||
type UiMessage,
|
||||
} from "@/stores/chat/ui-message";
|
||||
import { todayString } from "@/utils/date";
|
||||
|
||||
/**
|
||||
@@ -21,20 +26,33 @@ export function localMessagesToUi(
|
||||
lockDetail?: ChatLockDetailData;
|
||||
}[],
|
||||
): UiMessage[] {
|
||||
return records.map((m) => ({
|
||||
...(m.id ? { id: m.id } : {}),
|
||||
content: getAiMessageDisplayContent({
|
||||
content: m.content,
|
||||
isFromAI: m.role === "assistant",
|
||||
hasImage: Boolean(m.image?.url),
|
||||
}),
|
||||
isFromAI: m.role === "assistant",
|
||||
date: messageDateFromCreatedAt(m.createdAt),
|
||||
...(m.audioUrl && m.lockDetail?.locked !== true
|
||||
? { audioUrl: m.audioUrl }
|
||||
: {}),
|
||||
...deriveUiLockFields(m.lockDetail, m.image?.url),
|
||||
}));
|
||||
const occurrences = new Map<string, number>();
|
||||
return records.map((m) => {
|
||||
const isFromAI = m.role === "assistant";
|
||||
const identityBase = m.id
|
||||
? `remote:${m.id}:${m.role}`
|
||||
: `legacy:${createLegacyFingerprint(m)}`;
|
||||
const occurrence = occurrences.get(identityBase) ?? 0;
|
||||
occurrences.set(identityBase, occurrence + 1);
|
||||
const identity = m.id
|
||||
? createRemoteUiMessageIdentity(m.id, isFromAI, occurrence)
|
||||
: createLegacyUiMessageIdentity(identityBase, occurrence);
|
||||
|
||||
return {
|
||||
...identity,
|
||||
content: getAiMessageDisplayContent({
|
||||
content: m.content,
|
||||
isFromAI,
|
||||
hasImage: Boolean(m.image?.url),
|
||||
}),
|
||||
isFromAI,
|
||||
date: messageDateFromCreatedAt(m.createdAt),
|
||||
...(m.audioUrl && m.lockDetail?.locked !== true
|
||||
? { audioUrl: m.audioUrl }
|
||||
: {}),
|
||||
...deriveUiLockFields(m.lockDetail, m.image?.url),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,7 +61,9 @@ export function localMessagesToUi(
|
||||
*/
|
||||
export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
|
||||
return {
|
||||
...(response.messageId ? { id: response.messageId } : {}),
|
||||
...(response.messageId
|
||||
? createRemoteUiMessageIdentity(response.messageId, true)
|
||||
: createClientUiMessageIdentity("reply")),
|
||||
content: getAiMessageDisplayContent({
|
||||
content: response.reply,
|
||||
isFromAI: true,
|
||||
@@ -58,6 +78,28 @@ export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
|
||||
};
|
||||
}
|
||||
|
||||
function createLegacyFingerprint(record: {
|
||||
type?: string;
|
||||
content: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
audioUrl?: string | null;
|
||||
image?: { type: string | null; url: string | null };
|
||||
lockDetail?: ChatLockDetailData;
|
||||
}): string {
|
||||
return JSON.stringify([
|
||||
record.role,
|
||||
record.type ?? "text",
|
||||
record.content,
|
||||
record.createdAt,
|
||||
record.audioUrl ?? null,
|
||||
record.image?.type ?? null,
|
||||
record.image?.url ?? null,
|
||||
record.lockDetail?.locked ?? null,
|
||||
record.lockDetail?.reason ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
function messageDateFromCreatedAt(createdAt: string): string {
|
||||
const parsed = new Date(createdAt);
|
||||
if (Number.isNaN(parsed.getTime())) return todayString();
|
||||
|
||||
@@ -28,7 +28,8 @@ export function createChatPromotionState(
|
||||
return {
|
||||
session,
|
||||
message: {
|
||||
id: messageId || `promotion:${session.clientLockId}`,
|
||||
displayId: `promotion:${session.clientLockId}`,
|
||||
...(messageId ? { remoteId: messageId } : {}),
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: todayString(),
|
||||
@@ -47,7 +48,13 @@ export function appendPromotionMessage(
|
||||
): UiMessage[] {
|
||||
if (!promotion) return [...messages];
|
||||
return [
|
||||
...messages.filter((message) => message.id !== promotion.message.id),
|
||||
...messages.filter(
|
||||
(message) =>
|
||||
message.displayId !== promotion.message.displayId &&
|
||||
(!promotion.message.remoteId ||
|
||||
!message.isFromAI ||
|
||||
message.remoteId !== promotion.message.remoteId),
|
||||
),
|
||||
promotion.message,
|
||||
];
|
||||
}
|
||||
@@ -56,7 +63,10 @@ export function applyPromotionUnlockOutput(
|
||||
promotion: ChatPromotionState | null,
|
||||
output: UnlockMessageOutput,
|
||||
): ChatPromotionState | null {
|
||||
if (!promotion || promotion.message.id !== output.displayMessageId) {
|
||||
if (
|
||||
!promotion ||
|
||||
promotion.message.displayId !== output.displayMessageId
|
||||
) {
|
||||
return promotion;
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -30,11 +30,11 @@ export function applySingleUnlockOutput(
|
||||
return message;
|
||||
}
|
||||
|
||||
const resolvedId = output.response.messageId || message.id;
|
||||
const remoteId = output.response.messageId || message.remoteId;
|
||||
if (!output.response.unlocked) {
|
||||
return {
|
||||
...message,
|
||||
id: resolvedId,
|
||||
...(remoteId ? { remoteId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export function applySingleUnlockOutput(
|
||||
|
||||
return {
|
||||
...message,
|
||||
id: resolvedId,
|
||||
...(remoteId ? { remoteId } : {}),
|
||||
content: resolvedContent,
|
||||
audioUrl: getUnlockedAudioUrl(message, output.response),
|
||||
imageUrl: resolvedImageUrl,
|
||||
@@ -69,8 +69,11 @@ function getUnlockedAudioUrl(
|
||||
return response.audioUrl;
|
||||
}
|
||||
|
||||
function shouldApplySingleUnlock(message: UiMessage, messageId: string): boolean {
|
||||
if (message.id !== messageId) return false;
|
||||
function shouldApplySingleUnlock(
|
||||
message: UiMessage,
|
||||
displayMessageId: string,
|
||||
): boolean {
|
||||
if (message.displayId !== displayMessageId) return false;
|
||||
if (!message.isFromAI) return false;
|
||||
if (message.locked !== true) return false;
|
||||
return (
|
||||
|
||||
@@ -46,6 +46,7 @@ export const loadHistoryActor = fromCallback<
|
||||
const cacheIdentity = await resolveHistoryCacheIdentity(input.characterId);
|
||||
const localSnapshot = await readLocalHistorySnapshot(
|
||||
cacheIdentity,
|
||||
input.characterId,
|
||||
input.emptyChatGreeting,
|
||||
);
|
||||
if (cancelled) return;
|
||||
@@ -57,6 +58,7 @@ export const loadHistoryActor = fromCallback<
|
||||
const networkSnapshot = await syncNetworkHistory(
|
||||
input.characterId,
|
||||
localSnapshot.localCount,
|
||||
localSnapshot.messages.map((message) => message.displayId),
|
||||
cacheIdentity,
|
||||
input.emptyChatGreeting,
|
||||
controller.signal,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
finishPendingReply,
|
||||
type HttpSendOutput,
|
||||
} from "../helper/send-state";
|
||||
import { createClientUiMessageIdentity } from "../ui-message";
|
||||
import { historyMachineSetup } from "./history-flow";
|
||||
import { createChatActorActionSetup } from "./setup";
|
||||
|
||||
@@ -36,6 +37,7 @@ const appendGuestUserMessageAction = historyMachineSetup.assign(
|
||||
messages: [
|
||||
...context.messages,
|
||||
{
|
||||
...createClientUiMessageIdentity("message"),
|
||||
content: event.content,
|
||||
isFromAI: false,
|
||||
date: today,
|
||||
@@ -63,6 +65,7 @@ const appendUserMessageAction = historyMachineSetup.assign(
|
||||
messages: [
|
||||
...context.messages,
|
||||
{
|
||||
...createClientUiMessageIdentity("message"),
|
||||
content: event.content,
|
||||
isFromAI: false,
|
||||
date: today,
|
||||
@@ -83,6 +86,7 @@ const appendQueuedSendErrorMessageAction = historyMachineSetup.assign(
|
||||
const messages = [
|
||||
...context.messages,
|
||||
{
|
||||
...createClientUiMessageIdentity("error"),
|
||||
content: unavailable
|
||||
? "This character is temporarily unavailable. Please try again shortly."
|
||||
: "Something went wrong. Try sending again?",
|
||||
@@ -135,6 +139,7 @@ const appendGuestUserImageAction = historyMachineSetup.assign(
|
||||
messages: [
|
||||
...context.messages,
|
||||
{
|
||||
...createClientUiMessageIdentity("image"),
|
||||
content: "[Image]",
|
||||
isFromAI: false,
|
||||
date: today,
|
||||
@@ -162,6 +167,7 @@ const appendUserImageAction = historyMachineSetup.assign(
|
||||
messages: [
|
||||
...context.messages,
|
||||
{
|
||||
...createClientUiMessageIdentity("image"),
|
||||
content: "[Image]",
|
||||
isFromAI: false,
|
||||
date: today,
|
||||
|
||||
@@ -157,7 +157,8 @@ const userReadyState = chatMachineSetup.createStateConfig({
|
||||
actions: "appendUserImage",
|
||||
},
|
||||
ChatUnlockMessageRequested: {
|
||||
guard: ({ event }) => event.messageId.trim().length > 0,
|
||||
guard: ({ event }) =>
|
||||
event.displayMessageId.trim().length > 0,
|
||||
target: "unlockingMessage",
|
||||
actions: "markUnlockMessageStarted",
|
||||
},
|
||||
|
||||
@@ -45,12 +45,12 @@ const markUnlockHistoryFailedAction = sendMachineSetup.assign(() => ({
|
||||
const markUnlockMessageStartedAction = sendMachineSetup.assign(
|
||||
({ event }) => {
|
||||
if (event.type !== "ChatUnlockMessageRequested") return {};
|
||||
const remoteMessageId =
|
||||
event.remoteMessageId ?? (event.lockType ? undefined : event.messageId);
|
||||
return {
|
||||
unlockingMessage: {
|
||||
displayMessageId: event.messageId,
|
||||
...(remoteMessageId ? { messageId: remoteMessageId } : {}),
|
||||
displayMessageId: event.displayMessageId,
|
||||
...(event.remoteMessageId
|
||||
? { messageId: event.remoteMessageId }
|
||||
: {}),
|
||||
kind: event.kind,
|
||||
...(event.lockType ? { lockType: event.lockType } : {}),
|
||||
...(event.clientLockId
|
||||
@@ -90,8 +90,7 @@ const requestUnlockPaymentFromOutputAction = sendMachineSetup.assign(
|
||||
unlockMessageError: output.response.reason,
|
||||
unlockPaywallRequest: shouldOpenPaywall
|
||||
? {
|
||||
displayMessageId:
|
||||
output.response.messageId || output.displayMessageId,
|
||||
displayMessageId: output.displayMessageId,
|
||||
...(output.response.messageId || output.request.messageId
|
||||
? {
|
||||
messageId:
|
||||
@@ -105,7 +104,8 @@ const requestUnlockPaymentFromOutputAction = sendMachineSetup.assign(
|
||||
...(output.request.clientLockId
|
||||
? { clientLockId: output.request.clientLockId }
|
||||
: {}),
|
||||
...(context.promotion?.message.id === output.displayMessageId
|
||||
...(context.promotion?.message.displayId ===
|
||||
output.displayMessageId
|
||||
? { promotion: context.promotion.session }
|
||||
: {}),
|
||||
reason: output.response.reason,
|
||||
@@ -130,7 +130,8 @@ const requestUnlockPaymentFromErrorAction = sendMachineSetup.assign(
|
||||
unlockPaywallRequest: request
|
||||
? {
|
||||
...request,
|
||||
...(context.promotion?.message.id === request.displayMessageId
|
||||
...(context.promotion?.message.displayId ===
|
||||
request.displayMessageId
|
||||
? { promotion: context.promotion.session }
|
||||
: {}),
|
||||
reason: "unlock_failed",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const UiMessageSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
displayId: z.string().min(1),
|
||||
remoteId: z.string().min(1).optional(),
|
||||
clientId: z.string().min(1).optional(),
|
||||
content: z.string(),
|
||||
isFromAI: z.boolean(),
|
||||
date: z.string(),
|
||||
@@ -18,6 +20,41 @@ export const UiMessageSchema = z.object({
|
||||
|
||||
export type UiMessage = z.infer<typeof UiMessageSchema>;
|
||||
|
||||
let fallbackClientId = 0;
|
||||
|
||||
export function createRemoteUiMessageIdentity(
|
||||
remoteId: string,
|
||||
isFromAI: boolean,
|
||||
occurrence = 0,
|
||||
): Pick<UiMessage, "displayId" | "remoteId"> {
|
||||
const role = isFromAI ? "assistant" : "user";
|
||||
const base = `server:${encodeURIComponent(remoteId)}:${role}`;
|
||||
return {
|
||||
displayId: occurrence > 0 ? `${base}:${occurrence}` : base,
|
||||
remoteId,
|
||||
};
|
||||
}
|
||||
|
||||
export function createLegacyUiMessageIdentity(
|
||||
fingerprint: string,
|
||||
occurrence = 0,
|
||||
): Pick<UiMessage, "displayId"> {
|
||||
const base = `legacy:${hashIdentity(fingerprint)}`;
|
||||
return {
|
||||
displayId: occurrence > 0 ? `${base}:${occurrence}` : base,
|
||||
};
|
||||
}
|
||||
|
||||
export function createClientUiMessageIdentity(
|
||||
kind: "message" | "image" | "reply" | "error",
|
||||
): Pick<UiMessage, "displayId" | "clientId"> {
|
||||
const clientId = createClientId();
|
||||
return {
|
||||
clientId,
|
||||
displayId: `client:${kind}:${clientId}`,
|
||||
};
|
||||
}
|
||||
|
||||
export const UiMessage = {
|
||||
create(input: Omit<UiMessage, "date"> & { date?: string }): UiMessage {
|
||||
return UiMessageSchema.parse({
|
||||
@@ -31,3 +68,20 @@ export const UiMessage = {
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
function createClientId(): string {
|
||||
if (typeof globalThis.crypto?.randomUUID === "function") {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
fallbackClientId += 1;
|
||||
return `${Date.now().toString(36)}-${fallbackClientId.toString(36)}`;
|
||||
}
|
||||
|
||||
function hashIdentity(value: string): string {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user