feat(characters): support character-scoped conversations

This commit is contained in:
2026-07-17 11:42:31 +08:00
parent 93efcb6604
commit 2796010971
85 changed files with 1645 additions and 251 deletions
+3
View File
@@ -52,6 +52,7 @@ export function ChatScreen() {
const authState = useAuthState();
const authDispatch = useAuthDispatch();
useSplashLatestMessageSync({
characterId: state.characterId,
historyLoaded: state.historyLoaded,
loginStatus: authState.loginStatus,
messages: state.historyMessages,
@@ -182,6 +183,7 @@ export function ChatScreen() {
/>
<ChatArea
characterId={state.characterId}
messages={visibleMessages}
isReplyingAI={state.isReplyingAI}
scrollToBottomSignal={state.outgoingMessageRevision}
@@ -220,6 +222,7 @@ export function ChatScreen() {
{selectedImageMessage?.imageUrl ? (
<FullscreenImageViewer
characterId={state.characterId}
messageId={selectedImageMessage.id}
imageUrl={selectedImageMessage.imageUrl}
imagePaywalled={selectedImageMessage.imagePaywalled === true}
+6
View File
@@ -24,6 +24,7 @@ import {
import { LoaderCircle } from "lucide-react";
import type { UiMessage } from "@/data/dto/chat";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import {
@@ -45,6 +46,7 @@ const ReplyingAnimation = lazy(() =>
);
export interface ChatAreaProps {
characterId?: string;
messages: readonly UiMessage[];
isReplyingAI: boolean;
scrollToBottomSignal?: number;
@@ -61,6 +63,7 @@ export interface ChatAreaProps {
}
export function ChatArea({
characterId = DEFAULT_CHARACTER_ID,
messages,
isReplyingAI,
scrollToBottomSignal = 0,
@@ -235,6 +238,7 @@ export function ChatArea({
<AiDisclosureBanner />
{renderMessagesWithDateHeaders(
characterId,
messages,
getMessageKey,
isUnlockingMessage,
@@ -270,6 +274,7 @@ function isNearBottom(scrollNode: HTMLElement): boolean {
/** 渲染消息列表(按日期分组插入分隔条) */
function renderMessagesWithDateHeaders(
characterId: string,
messages: readonly UiMessage[],
getMessageKey: ChatMessageKeyResolver,
isUnlockingMessage?: boolean,
@@ -284,6 +289,7 @@ function renderMessagesWithDateHeaders(
<DateHeader key={item.key} date={item.date} />
) : (
<MessageBubble
characterId={characterId}
key={item.key}
messageId={item.message.id}
content={item.message.content}
@@ -12,6 +12,7 @@ import {
import { useCachedChatMediaUrl } from "@/lib/chat/use_cached_chat_media_url";
export interface ChatMediaImageProps {
characterId: string;
messageId?: string;
remoteUrl: string;
alt?: string;
@@ -28,6 +29,7 @@ export interface ChatMediaImageProps {
}
export function ChatMediaImage({
characterId,
messageId,
remoteUrl,
alt = "",
@@ -45,6 +47,7 @@ export function ChatMediaImage({
const [errorSrc, setErrorSrc] = useState<string | null>(null);
const { mediaUrl, isUsingCachedMedia, reportMediaError } =
useCachedChatMediaUrl({
characterId,
messageId,
remoteUrl,
kind: "image",
@@ -18,6 +18,7 @@ import { ChatMediaImage } from "./chat-media-image";
import styles from "./fullscreen-image-viewer.module.css";
export interface FullscreenImageViewerProps {
characterId: string;
messageId?: string;
imageUrl: string;
imagePaywalled?: boolean;
@@ -27,6 +28,7 @@ export interface FullscreenImageViewerProps {
}
export function FullscreenImageViewer({
characterId,
messageId,
imageUrl,
imagePaywalled = false,
@@ -51,6 +53,7 @@ export function FullscreenImageViewer({
aria-label="Locked fullscreen image"
>
<ChatMediaImage
characterId={characterId}
messageId={messageId}
remoteUrl={imageUrl}
className={styles.paywallImage}
@@ -100,6 +103,7 @@ export function FullscreenImageViewer({
variant="unstyled"
/>
<ChatMediaImage
characterId={characterId}
messageId={messageId}
remoteUrl={imageUrl}
className={styles.viewerImage}
+4
View File
@@ -9,8 +9,10 @@
*/
import { ChatMediaImage } from "./chat-media-image";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
export interface ImageBubbleProps {
characterId?: string;
messageId?: string;
imageUrl: string; // base64 data URI 或 URL
imagePaywalled?: boolean;
@@ -18,6 +20,7 @@ export interface ImageBubbleProps {
}
export function ImageBubble({
characterId = DEFAULT_CHARACTER_ID,
messageId,
imageUrl,
imagePaywalled = false,
@@ -52,6 +55,7 @@ export function ImageBubble({
aria-label={canOpen ? "Open image in fullscreen" : undefined}
>
<ChatMediaImage
characterId={characterId}
messageId={messageId}
remoteUrl={imageUrl}
className={imageClassName}
@@ -15,6 +15,7 @@ import { MessageContent } from "./message-content";
import styles from "./chat-area.module.css";
export interface MessageBubbleProps {
characterId: string;
messageId?: string;
content: string;
imageUrl?: string | null;
@@ -33,6 +34,7 @@ export interface MessageBubbleProps {
}
export function MessageBubble({
characterId,
messageId,
content,
imageUrl,
@@ -61,6 +63,7 @@ export function MessageBubble({
<MessageAvatar isFromAI={true} />
<div className={styles.bubbleInlineSpacer} aria-hidden="true" />
<MessageContent
characterId={characterId}
content={content}
imageUrl={imageUrl}
imagePaywalled={imagePaywalled}
@@ -90,6 +93,7 @@ export function MessageBubble({
>
<div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
<MessageContent
characterId={characterId}
content={content}
imageUrl={imageUrl}
imagePaywalled={imagePaywalled}
@@ -7,6 +7,7 @@ import { VoiceBubble } from "./voice-bubble";
import styles from "./chat-area.module.css";
export interface MessageContentProps {
characterId: string;
content: string;
imageUrl?: string | null;
imagePaywalled?: boolean;
@@ -27,6 +28,7 @@ export interface MessageContentProps {
const IMAGE_PLACEHOLDER = "[图片]";
export function MessageContent({
characterId,
content,
imageUrl,
imagePaywalled,
@@ -90,6 +92,7 @@ export function MessageContent({
<>
{hasImage && imageUrl && (
<ImageBubble
characterId={characterId}
messageId={messageId}
imageUrl={imageUrl}
imagePaywalled={imagePaywalled}
@@ -98,6 +101,7 @@ export function MessageContent({
)}
{shouldRenderVoiceMessage ? (
<VoiceBubble
characterId={characterId}
messageId={messageId}
audioUrl={audioUrl}
isFromAI={isFromAI}
+3
View File
@@ -7,6 +7,7 @@ import { useCachedChatMediaUrl } from "@/lib/chat/use_cached_chat_media_url";
import styles from "./voice-bubble.module.css";
export interface VoiceBubbleProps {
characterId: string;
messageId?: string;
audioUrl?: string | null;
isFromAI: boolean;
@@ -17,6 +18,7 @@ export interface VoiceBubbleProps {
}
export function VoiceBubble({
characterId,
messageId,
audioUrl,
isFromAI,
@@ -31,6 +33,7 @@ export function VoiceBubble({
const hasAudio = audioUrl != null && audioUrl.length > 0;
const { mediaUrl, isUsingCachedMedia, reportMediaError } =
useCachedChatMediaUrl({
characterId,
messageId,
remoteUrl: audioUrl,
kind: "audio",
@@ -9,12 +9,14 @@ import { getLatestSplashMessagePreview } from "@/lib/chat/splash_latest_message_
import { useSplashLatestMessageCache } from "@/providers/splash-latest-message-provider";
export interface UseSplashLatestMessageSyncInput {
characterId: string;
historyLoaded: boolean;
loginStatus: LoginStatus;
messages: readonly UiMessage[];
}
export function useSplashLatestMessageSync({
characterId,
historyLoaded,
loginStatus,
messages,
@@ -26,7 +28,7 @@ export function useSplashLatestMessageSync({
useEffect(() => {
hasObservedInitialSnapshotRef.current = false;
identityPromiseRef.current = null;
}, [loginStatus]);
}, [characterId, loginStatus]);
useEffect(() => {
if (!historyLoaded) return;
@@ -39,7 +41,8 @@ export function useSplashLatestMessageSync({
const preview = getLatestSplashMessagePreview(messages);
let cancelled = false;
identityPromiseRef.current ??= resolveSplashLatestMessageCacheIdentity();
identityPromiseRef.current ??=
resolveSplashLatestMessageCacheIdentity(characterId);
void identityPromiseRef.current.then((identity) => {
if (!cancelled && identity) {
cache.set(identity, preview);
@@ -49,5 +52,5 @@ export function useSplashLatestMessageSync({
return () => {
cancelled = true;
};
}, [cache, historyLoaded, messages]);
}, [cache, characterId, historyLoaded, messages]);
}
+2
View File
@@ -0,0 +1,2 @@
export const DEFAULT_CHARACTER_ID = "character_elio";
export const DEFAULT_CHARACTER_SLUG = "elio";
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { Character } from "@/data/dto/character";
describe("Character", () => {
it("keeps only the four public character fields", () => {
const character = Character.fromJson({
id: "character_elio",
slug: "elio",
displayName: " Elio Silvestri ",
avatarUrl: "https://cdn.example.com/elio.jpg",
enabled: true,
sortOrder: 1,
});
expect(character.toJson()).toEqual({
id: "character_elio",
slug: "elio",
displayName: "Elio Silvestri",
avatarUrl: "https://cdn.example.com/elio.jpg",
});
});
it("rejects unsafe slugs and non-HTTPS avatars", () => {
expect(() =>
Character.from({
id: "character_aria",
slug: "Aria Profile",
displayName: "Aria",
avatarUrl: "https://cdn.example.com/aria.jpg",
}),
).toThrow();
expect(() =>
Character.from({
id: "character_aria",
slug: "aria",
displayName: "Aria",
avatarUrl: "http://cdn.example.com/aria.jpg",
}),
).toThrow();
});
});
+29
View File
@@ -0,0 +1,29 @@
import {
CharacterSchema,
type CharacterData,
type CharacterInput,
} from "@/data/schemas/character";
export class Character {
declare readonly id: string;
declare readonly slug: string;
declare readonly displayName: string;
declare readonly avatarUrl: string;
private constructor(input: CharacterInput) {
Object.assign(this, CharacterSchema.parse(input));
Object.freeze(this);
}
static from(input: CharacterInput): Character {
return new Character(input);
}
static fromJson(json: unknown): Character {
return Character.from(json as CharacterInput);
}
toJson(): CharacterData {
return CharacterSchema.parse(this);
}
}
@@ -0,0 +1,33 @@
import {
CharactersResponseSchema,
type CharactersResponseData,
type CharactersResponseInput,
} from "@/data/schemas/character";
import { Character } from "./character";
export class CharactersResponse {
declare readonly items: Character[];
private constructor(input: CharactersResponseInput) {
const data = CharactersResponseSchema.parse(input);
Object.assign(this, {
items: data.items.map((item) => Character.from(item)),
});
Object.freeze(this);
}
static from(input: CharactersResponseInput): CharactersResponse {
return new CharactersResponse(input);
}
static fromJson(json: unknown): CharactersResponse {
return CharactersResponse.from(json as CharactersResponseInput);
}
toJson(): CharactersResponseData {
return CharactersResponseSchema.parse({
items: this.items.map((item) => item.toJson()),
});
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./character";
export * from "./characters_response";
@@ -1,21 +1,30 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { UnlockPrivateRequest } from "@/data/dto/chat";
describe("UnlockPrivateRequest", () => {
it("serializes an existing backend message unlock", () => {
expect(
UnlockPrivateRequest.from({ messageId: "message-1" }).toJson(),
).toEqual({ messageId: "message-1" });
UnlockPrivateRequest.from({
characterId: DEFAULT_CHARACTER_ID,
messageId: "message-1",
}).toJson(),
).toEqual({
characterId: DEFAULT_CHARACTER_ID,
messageId: "message-1",
});
});
it("serializes a temporary promotion lock without a fake message id", () => {
expect(
UnlockPrivateRequest.from({
characterId: DEFAULT_CHARACTER_ID,
lockType: "voice_message",
clientLockId: "promotion-1",
}).toJson(),
).toEqual({
characterId: DEFAULT_CHARACTER_ID,
lockType: "voice_message",
clientLockId: "promotion-1",
});
+1
View File
@@ -1,2 +1,3 @@
export * from "./send_message_request";
export * from "./unlock_private_request";
export * from "./unlock_history_request";
@@ -8,6 +8,7 @@ import {
} from "@/data/schemas/chat/request/send_message_request";
export class SendMessageRequest {
declare readonly characterId: string;
declare readonly message: string;
declare readonly image: string;
declare readonly imageId: string;
@@ -0,0 +1,22 @@
import {
UnlockHistoryRequestSchema,
type UnlockHistoryRequestData,
type UnlockHistoryRequestInput,
} from "@/data/schemas/chat/request/unlock_history_request";
export class UnlockHistoryRequest {
declare readonly characterId: string;
private constructor(input: UnlockHistoryRequestInput) {
Object.assign(this, UnlockHistoryRequestSchema.parse(input));
Object.freeze(this);
}
static from(input: UnlockHistoryRequestInput): UnlockHistoryRequest {
return new UnlockHistoryRequest(input);
}
toJson(): UnlockHistoryRequestData {
return UnlockHistoryRequestSchema.parse(this);
}
}
@@ -8,6 +8,7 @@ import {
} from "@/data/schemas/chat/request/unlock_private_request";
export class UnlockPrivateRequest {
declare readonly characterId: string;
declare readonly messageId?: string;
declare readonly lockType?: UnlockPrivateRequestData["lockType"];
declare readonly clientLockId?: string;
@@ -1,4 +1,5 @@
{
"characterId": "character_elio",
"message": "Look at this sunset.",
"image": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD...",
"imageId": "img_mock_001",
@@ -1,4 +1,5 @@
{
"characterId": "character_elio",
"message": "I missed you today.",
"image": "",
"imageId": "",
@@ -1,3 +1,4 @@
{
"characterId": "character_elio",
"messageId": "msg_private_locked_001"
}
@@ -3,7 +3,10 @@ import { describe, expect, it, vi } from "vitest";
import { LoginStatus } from "@/data/dto/auth";
import { Result } from "@/utils/result";
import { resolveChatCacheOwnerKey } from "../chat_cache_identity";
import {
buildChatConversationKey,
resolveChatCacheOwnerKey,
} from "../chat_cache_identity";
function createStorageState(input: {
provider: (typeof LoginStatus)[keyof typeof LoginStatus] | null;
@@ -22,6 +25,15 @@ function createStorageState(input: {
}
describe("chat cache identity", () => {
it("isolates the same owner by character", () => {
const elio = buildChatConversationKey("user:account-1", "character_elio");
const aria = buildChatConversationKey("user:account-1", "character_aria");
expect(elio).toBe("user:account-1::character:character_elio");
expect(aria).toBe("user:account-1::character:character_aria");
expect(elio).not.toBe(aria);
});
it("uses the current user id for authenticated and guest sessions", async () => {
const authenticated = createStorageState({
provider: LoginStatus.Email,
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { Result } from "@/utils/result";
import { ChatMediaCacheCoordinator } from "../chat_media_cache_coordinator";
@@ -22,13 +23,43 @@ describe("ChatMediaCacheCoordinator identity isolation", () => {
);
const result = await coordinator.getCachedMedia({
characterId: DEFAULT_CHARACTER_ID,
messageId: "message-1",
kind: "image",
});
expect(result).toEqual(Result.ok(null));
expect(getMedia).toHaveBeenCalledTimes(1);
expect(getMedia).toHaveBeenCalledWith("user:account-a:message-1:image");
expect(getMedia).toHaveBeenCalledWith(
"user:account-a::character:character_elio:message-1:image",
);
});
it("does not share a media key between characters", async () => {
const getMedia = vi.fn(async () => Result.ok(null));
const coordinator = new ChatMediaCacheCoordinator(
{
getMedia,
saveMedia: vi.fn(async () => Result.ok(undefined)),
},
async () => Result.ok("user:account-a"),
);
await coordinator.getCachedMedia({
characterId: "character_elio",
messageId: "shared-message-id",
kind: "image",
});
await coordinator.getCachedMedia({
characterId: "character_aria",
messageId: "shared-message-id",
kind: "image",
});
expect(getMedia.mock.calls).toEqual([
["user:account-a::character:character_elio:shared-message-id:image"],
["user:account-a::character:character_aria:shared-message-id:image"],
]);
});
it("does not fall back to an anonymous cache without an identity", async () => {
@@ -45,6 +76,7 @@ describe("ChatMediaCacheCoordinator identity isolation", () => {
);
const result = await coordinator.getCachedMedia({
characterId: DEFAULT_CHARACTER_ID,
messageId: "message-1",
kind: "audio",
});
@@ -75,11 +107,12 @@ describe("ChatMediaCacheCoordinator identity isolation", () => {
const result = await coordinator.cacheRemoteMedia(
{
characterId: DEFAULT_CHARACTER_ID,
messageId: "message-expired",
kind: "image",
remoteUrl: "https://media.example/expired.jpg",
},
"user:account-a",
"user:account-a::character:character_elio",
);
expect(Result.isErr(result)).toBe(true);
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { ChatMessage, ChatSendResponse } from "@/data/dto/chat";
import {
@@ -64,14 +65,17 @@ describe("chat media cache helpers", () => {
audioUrl: "https://example.com/a.mp3",
image: { type: "jpg", url: "https://example.com/a.jpg" },
}),
DEFAULT_CHARACTER_ID,
),
).toEqual([
{
characterId: DEFAULT_CHARACTER_ID,
messageId: "msg-1",
kind: "image",
remoteUrl: "https://example.com/a.jpg",
},
{
characterId: DEFAULT_CHARACTER_ID,
messageId: "msg-1",
kind: "audio",
remoteUrl: "https://example.com/a.mp3",
@@ -87,6 +91,7 @@ describe("chat media cache helpers", () => {
audioUrl: "https://example.com/a.mp3",
image: { type: "png", url: "data:image/png;base64,abc" },
}),
DEFAULT_CHARACTER_ID,
),
).toEqual([]);
});
@@ -98,6 +103,7 @@ describe("chat media cache helpers", () => {
audioUrl: "https://example.com/locked.mp3",
lockDetail: { locked: true, reason: "voice_message" },
}),
DEFAULT_CHARACTER_ID,
),
).toEqual([]);
});
@@ -108,9 +114,11 @@ describe("chat media cache helpers", () => {
makeResponse({
audioUrl: "https://example.com/unlocked.mp3",
}),
DEFAULT_CHARACTER_ID,
),
).toEqual([
{
characterId: DEFAULT_CHARACTER_ID,
messageId: "msg-1",
kind: "audio",
remoteUrl: "https://example.com/unlocked.mp3",
@@ -121,13 +129,38 @@ describe("chat media cache helpers", () => {
it("deduplicates media targets", () => {
expect(
uniqueMediaTargets([
{ messageId: "msg-1", kind: "image", remoteUrl: "https://x/a.jpg" },
{ messageId: "msg-1", kind: "image", remoteUrl: "https://x/a.jpg" },
{ messageId: "msg-1", kind: "audio", remoteUrl: "https://x/a.jpg" },
{
characterId: DEFAULT_CHARACTER_ID,
messageId: "msg-1",
kind: "image",
remoteUrl: "https://x/a.jpg",
},
{
characterId: DEFAULT_CHARACTER_ID,
messageId: "msg-1",
kind: "image",
remoteUrl: "https://x/a.jpg",
},
{
characterId: DEFAULT_CHARACTER_ID,
messageId: "msg-1",
kind: "audio",
remoteUrl: "https://x/a.jpg",
},
]),
).toEqual([
{ messageId: "msg-1", kind: "image", remoteUrl: "https://x/a.jpg" },
{ messageId: "msg-1", kind: "audio", remoteUrl: "https://x/a.jpg" },
{
characterId: DEFAULT_CHARACTER_ID,
messageId: "msg-1",
kind: "image",
remoteUrl: "https://x/a.jpg",
},
{
characterId: DEFAULT_CHARACTER_ID,
messageId: "msg-1",
kind: "audio",
remoteUrl: "https://x/a.jpg",
},
]);
});
@@ -0,0 +1,18 @@
import type { CharactersResponse } from "@/data/dto/character";
import type { ICharacterRepository } from "@/data/repositories/interfaces";
import { CharacterApi, characterApi } from "@/data/services/api/character_api";
import { Result } from "@/utils/result";
import { createLazySingleton } from "./lazy_singleton";
export class CharacterRepository implements ICharacterRepository {
constructor(private readonly api: CharacterApi) {}
getCharacters(): Promise<Result<CharactersResponse>> {
return Result.wrap(() => this.api.getCharacters());
}
}
export const getCharacterRepository = createLazySingleton<ICharacterRepository>(
() => new CharacterRepository(characterApi),
);
@@ -4,6 +4,9 @@ import { UserStorage, type IUserStorage } from "@/data/storage/user";
import { Result, type Result as ResultT } from "@/utils/result";
export type ChatCacheIdentityResolver = () => Promise<ResultT<string>>;
export type ChatConversationKeyResolver = (
characterId: string,
) => Promise<ResultT<string>>;
type ChatCacheAuthStorage = Pick<
IAuthStorage,
@@ -49,3 +52,21 @@ export async function resolveChatCacheOwnerKey(
return Result.err(new Error("Chat cache identity is unavailable."));
}
export function buildChatConversationKey(
ownerKey: string,
characterId: string,
): string {
if (ownerKey.trim().length === 0 || characterId.trim().length === 0) {
throw new Error("Chat owner and character identities must not be empty.");
}
return `${ownerKey}::character:${encodeURIComponent(characterId)}`;
}
export async function resolveChatConversationKey(
characterId: string,
): Promise<ResultT<string>> {
const ownerResult = await resolveChatCacheOwnerKey();
if (Result.isErr(ownerResult)) return ownerResult;
return Result.ok(buildChatConversationKey(ownerResult.data, characterId));
}
@@ -20,6 +20,7 @@ import {
uniqueMediaTargets,
} from "./chat_media_cache_helpers";
import {
buildChatConversationKey,
resolveChatCacheOwnerKey,
type ChatCacheIdentityResolver,
} from "./chat_cache_identity";
@@ -59,7 +60,7 @@ export class ChatMediaCacheCoordinator {
input: ChatMediaLookupInput,
): Promise<Result<LocalChatMediaRow | null>> {
return Result.wrap(async () => {
const ownerKey = await this._requireIdentity();
const ownerKey = await this._requireIdentity(input.characterId);
const cacheKey = this._buildMediaCacheKey(ownerKey, input);
const result = await this.mediaStorage.getMedia(cacheKey);
if (Result.isErr(result)) throw result.error;
@@ -73,7 +74,10 @@ export class ChatMediaCacheCoordinator {
): Promise<Result<LocalChatMediaRow>> {
return Result.wrap(
async () => {
const ownerKey = await this._requireIdentity(identity);
const ownerKey = await this._requireIdentity(
input.characterId,
identity,
);
return this._cacheRemoteMediaOrThrow(input, ownerKey);
},
{
@@ -85,32 +89,39 @@ export class ChatMediaCacheCoordinator {
async prefetchMediaForMessages(
messages: readonly ChatMessage[],
characterId: string,
identity?: string,
): Promise<Result<void>> {
return this._prefetchMediaTargets(
messages.flatMap((message) => getMessageMediaTargets(message)),
messages.flatMap((message) =>
getMessageMediaTargets(message, characterId),
),
characterId,
identity,
);
}
async prefetchMediaForSendResponse(
response: ChatSendResponse,
characterId: string,
identity?: string,
): Promise<Result<void>> {
return this._prefetchMediaTargets(
getSendResponseMediaTargets(response),
getSendResponseMediaTargets(response, characterId),
characterId,
identity,
);
}
private async _prefetchMediaTargets(
targets: readonly CacheRemoteChatMediaInput[],
characterId: string,
identity?: string,
): Promise<Result<void>> {
return Result.wrap(async () => {
const uniqueTargets = uniqueMediaTargets(targets);
if (uniqueTargets.length === 0) return;
const ownerKey = await this._requireIdentity(identity);
const ownerKey = await this._requireIdentity(characterId, identity);
for (const target of uniqueTargets) {
try {
await this._cacheRemoteMediaOrThrow(target, ownerKey);
@@ -199,7 +210,10 @@ export class ChatMediaCacheCoordinator {
return savedResult.data;
}
private async _requireIdentity(identity?: string): Promise<string> {
private async _requireIdentity(
characterId: string,
identity?: string,
): Promise<string> {
if (identity !== undefined) {
if (identity.length === 0) {
throw new Error("Chat cache identity must not be empty.");
@@ -211,7 +225,7 @@ export class ChatMediaCacheCoordinator {
if (result.data.length === 0) {
throw new Error("Chat cache identity must not be empty.");
}
return result.data;
return buildChatConversationKey(result.data, characterId);
}
private _buildMediaCacheKey(
@@ -18,14 +18,17 @@ export function buildChatMediaCacheKey(input: {
export function getMessageMediaTargets(
message: ChatMessage,
characterId: string,
): CacheRemoteChatMediaInput[] {
const targets: CacheRemoteChatMediaInput[] = [];
pushMediaTarget(targets, {
characterId,
messageId: message.id,
kind: "image",
remoteUrl: message.image.url,
});
pushMediaTarget(targets, {
characterId,
messageId: message.id,
kind: "audio",
remoteUrl: message.audioUrl,
@@ -35,15 +38,18 @@ export function getMessageMediaTargets(
export function getSendResponseMediaTargets(
response: ChatSendResponse,
characterId: string,
): CacheRemoteChatMediaInput[] {
const targets: CacheRemoteChatMediaInput[] = [];
pushMediaTarget(targets, {
characterId,
messageId: response.messageId,
kind: "image",
remoteUrl: response.image.url,
});
if (!response.lockDetail.locked) {
pushMediaTarget(targets, {
characterId,
messageId: response.messageId,
kind: "audio",
remoteUrl: response.audioUrl,
@@ -61,7 +67,7 @@ export function uniqueMediaTargets(
): CacheRemoteChatMediaInput[] {
const seen = new Set<string>();
return targets.filter((target) => {
const key = `${target.messageId}:${target.kind}:${target.remoteUrl}`;
const key = `${target.characterId}:${target.messageId}:${target.kind}:${target.remoteUrl}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
@@ -71,6 +77,7 @@ export function uniqueMediaTargets(
function pushMediaTarget(
targets: CacheRemoteChatMediaInput[],
input: {
characterId: string;
messageId: string;
kind: ChatMediaKind;
remoteUrl: string | null;
@@ -84,6 +91,7 @@ function pushMediaTarget(
return;
}
targets.push({
characterId: input.characterId,
messageId: input.messageId,
kind: input.kind,
remoteUrl: input.remoteUrl,
@@ -2,6 +2,7 @@ import {
ChatHistoryResponse,
ChatSendResponse,
SendMessageRequest,
UnlockHistoryRequest,
UnlockHistoryResponse,
UnlockPrivateRequest,
UnlockPrivateResponse,
@@ -14,11 +15,13 @@ export class ChatRemoteDataSource {
constructor(private readonly api: ChatApi) {}
async sendMessage(
characterId: string,
message: string,
options?: { image?: string; useWebSocket?: boolean },
): Promise<Result<ChatSendResponse>> {
return Result.wrap(async () => {
const request = SendMessageRequest.from({
characterId,
message,
image: options?.image ?? "",
useWebSocket: options?.useWebSocket ?? false,
@@ -28,10 +31,11 @@ export class ChatRemoteDataSource {
}
async getHistory(
characterId: string,
limit = 50,
offset = 0,
): Promise<Result<ChatHistoryResponse>> {
return Result.wrap(() => this.api.getHistory(limit, offset));
return Result.wrap(() => this.api.getHistory(characterId, limit, offset));
}
async unlockPrivateMessage(
@@ -42,7 +46,11 @@ export class ChatRemoteDataSource {
);
}
async unlockHistory(): Promise<Result<UnlockHistoryResponse>> {
return Result.wrap(() => this.api.unlockHistory());
async unlockHistory(
characterId: string,
): Promise<Result<UnlockHistoryResponse>> {
return Result.wrap(() =>
this.api.unlockHistory(UnlockHistoryRequest.from({ characterId })),
);
}
}
+14 -5
View File
@@ -36,18 +36,20 @@ export class ChatRepository implements IChatRepository {
/** 发送一条消息。 */
async sendMessage(
characterId: string,
message: string,
options?: { image?: string; useWebSocket?: boolean },
): Promise<Result<ChatSendResponse>> {
return this.remote.sendMessage(message, options);
return this.remote.sendMessage(characterId, message, options);
}
/** 获取聊天历史,分页参数默认 limit=50, offset=0。 */
async getHistory(
characterId: string,
limit = 50,
offset = 0,
): Promise<Result<ChatHistoryResponse>> {
return this.remote.getHistory(limit, offset);
return this.remote.getHistory(characterId, limit, offset);
}
/** 解锁单条历史付费 / 私密消息。 */
@@ -58,8 +60,8 @@ export class ChatRepository implements IChatRepository {
}
/** 一键解锁历史锁定消息。 */
async unlockHistory(): Promise<Result<UnlockHistoryResponse>> {
return this.remote.unlockHistory();
async unlockHistory(characterId: string): Promise<Result<UnlockHistoryResponse>> {
return this.remote.unlockHistory(characterId);
}
/** 把本地缓存中的单条锁定消息标记为已解锁。 */
@@ -133,17 +135,24 @@ export class ChatRepository implements IChatRepository {
async prefetchMediaForMessages(
messages: readonly ChatMessage[],
characterId: string,
cacheIdentity?: string,
): Promise<Result<void>> {
return this.mediaCache.prefetchMediaForMessages(messages, cacheIdentity);
return this.mediaCache.prefetchMediaForMessages(
messages,
characterId,
cacheIdentity,
);
}
async prefetchMediaForSendResponse(
response: ChatSendResponse,
characterId: string,
cacheIdentity?: string,
): Promise<Result<void>> {
return this.mediaCache.prefetchMediaForSendResponse(
response,
characterId,
cacheIdentity,
);
}
+2
View File
@@ -4,6 +4,7 @@
export * from "./auth_repository";
export * from "./chat_repository";
export * from "./character_repository";
export * from "./feedback_repository";
export * from "./metrics_repository";
export * from "./payment_repository";
@@ -11,6 +12,7 @@ export * from "./private_room_repository";
export * from "./user_repository";
export * from "./interfaces/iauth_repository";
export * from "./interfaces/ichat_repository";
export * from "./interfaces/icharacter_repository";
export * from "./interfaces/ifeedback_repository";
export * from "./interfaces/imetrics_repository";
export * from "./interfaces/ipayment_repository";
@@ -0,0 +1,6 @@
import type { CharactersResponse } from "@/data/dto/character";
import type { Result } from "@/utils/result";
export interface ICharacterRepository {
getCharacters(): Promise<Result<CharactersResponse>>;
}
@@ -18,6 +18,7 @@ import type { ChatImageData, ChatLockType } from "@/data/schemas/chat";
import type { LocalChatMediaRow } from "@/data/storage/chat";
export interface ChatMediaLookupInput {
characterId: string;
messageId: string;
kind: ChatMediaKind;
}
@@ -34,6 +35,7 @@ export interface UnlockedPrivateMessageLocalPatch {
}
export interface UnlockPrivateMessageInput {
characterId: string;
messageId?: string;
lockType?: ChatLockType;
clientLockId?: string;
@@ -42,12 +44,17 @@ export interface UnlockPrivateMessageInput {
export interface IChatRepository {
/** 发送一条消息。 */
sendMessage(
characterId: string,
message: string,
options?: { image?: string; useWebSocket?: boolean },
): Promise<Result<ChatSendResponse>>;
/** 获取聊天历史,分页参数默认 limit=50, offset=0。 */
getHistory(limit?: number, offset?: number): Promise<Result<ChatHistoryResponse>>;
getHistory(
characterId: string,
limit?: number,
offset?: number,
): Promise<Result<ChatHistoryResponse>>;
/** 解锁单条历史付费 / 私密消息。 */
unlockPrivateMessage(
@@ -55,7 +62,7 @@ export interface IChatRepository {
): Promise<Result<UnlockPrivateResponse>>;
/** 一键解锁历史锁定消息。 */
unlockHistory(): Promise<Result<UnlockHistoryResponse>>;
unlockHistory(characterId: string): Promise<Result<UnlockHistoryResponse>>;
/** 把本地缓存中的单条锁定消息标记为已解锁。 */
markPrivateMessageUnlockedInLocal(
@@ -99,12 +106,14 @@ export interface IChatRepository {
/** 后台预缓存历史消息中的图片 / 音频。失败不影响聊天主流程。 */
prefetchMediaForMessages(
messages: readonly ChatMessage[],
characterId: string,
cacheIdentity?: string,
): Promise<Result<void>>;
/** 后台预缓存发送响应中的图片 / 音频。失败不影响聊天主流程。 */
prefetchMediaForSendResponse(
response: ChatSendResponse,
characterId: string,
cacheIdentity?: string,
): Promise<Result<void>>;
}
@@ -4,6 +4,7 @@
export * from "./iauth_repository";
export * from "./ichat_repository";
export * from "./icharacter_repository";
export * from "./ifeedback_repository";
export * from "./imetrics_repository";
export * from "./ipayment_repository";
@@ -5,7 +5,7 @@ import type {
import type { Result } from "@/utils/result";
export interface GetPrivateAlbumsInput {
character?: string;
characterId?: string;
limit?: number;
}
+13
View File
@@ -0,0 +1,13 @@
import { z } from "zod";
export const CharacterSchema = z.object({
id: z.string().min(1).max(64),
slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
displayName: z.string().trim().min(1).max(80),
avatarUrl: z.url().refine((url) => url.startsWith("https://"), {
message: "avatarUrl must use HTTPS",
}),
});
export type CharacterInput = z.input<typeof CharacterSchema>;
export type CharacterData = z.output<typeof CharacterSchema>;
@@ -0,0 +1,10 @@
import { z } from "zod";
import { CharacterSchema } from "./character";
export const CharactersResponseSchema = z.object({
items: z.array(CharacterSchema).default([]),
});
export type CharactersResponseInput = z.input<typeof CharactersResponseSchema>;
export type CharactersResponseData = z.output<typeof CharactersResponseSchema>;
+2
View File
@@ -0,0 +1,2 @@
export * from "./character";
export * from "./characters_response";
+1
View File
@@ -4,3 +4,4 @@
export * from "./send_message_request";
export * from "./unlock_private_request";
export * from "./unlock_history_request";
@@ -11,6 +11,7 @@ import {
} from "../../nullable-defaults";
export const SendMessageRequestSchema = z.object({
characterId: z.string().min(1),
message: stringOrEmpty,
image: stringOrEmpty,
imageId: stringOrEmpty,
@@ -0,0 +1,12 @@
import { z } from "zod";
export const UnlockHistoryRequestSchema = z.object({
characterId: z.string().min(1),
});
export type UnlockHistoryRequestInput = z.input<
typeof UnlockHistoryRequestSchema
>;
export type UnlockHistoryRequestData = z.output<
typeof UnlockHistoryRequestSchema
>;
@@ -7,6 +7,7 @@ import { ChatLockTypeSchema } from "../chat_lock_type";
export const UnlockPrivateRequestSchema = z
.object({
characterId: z.string().min(1),
messageId: z.string().min(1).optional(),
lockType: ChatLockTypeSchema.optional(),
clientLockId: z.string().min(1).optional(),
@@ -0,0 +1,45 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const httpClientMock = vi.hoisted(() => vi.fn());
vi.mock("../http_client", () => ({
httpClient: httpClientMock,
}));
import { CharacterApi } from "../character_api";
describe("CharacterApi", () => {
beforeEach(() => {
httpClientMock.mockReset();
});
it("loads characters and preserves the backend order", async () => {
httpClientMock.mockResolvedValue({
success: true,
data: {
items: [
{
id: "character_aria",
slug: "aria",
displayName: "Aria",
avatarUrl: "https://cdn.example.com/aria.jpg",
},
{
id: "character_elio",
slug: "elio",
displayName: "Elio Silvestri",
avatarUrl: "https://cdn.example.com/elio.jpg",
},
],
},
});
const response = await new CharacterApi().getCharacters();
expect(httpClientMock).toHaveBeenCalledWith("/api/characters");
expect(response.items.map((character) => character.id)).toEqual([
"character_aria",
"character_elio",
]);
});
});
@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
SendMessageRequest,
UnlockHistoryRequest,
UnlockPrivateRequest,
} from "@/data/dto/chat";
const httpClientMock = vi.hoisted(() => vi.fn());
vi.mock("../http_client", () => ({
httpClient: httpClientMock,
}));
import { ChatApi } from "../chat_api";
import { PrivateRoomApi } from "../private_room_api";
const CHARACTER_ID = "character_elio";
describe("multi-character API contract", () => {
beforeEach(() => {
httpClientMock.mockReset();
});
it("sends characterId with chat messages", async () => {
httpClientMock.mockResolvedValue({
success: true,
data: {
reply: "Hello",
messageId: "message-1",
image: { type: null, url: null },
lockDetail: { locked: false, reason: null },
},
});
await new ChatApi().sendMessage(
SendMessageRequest.from({
characterId: CHARACTER_ID,
message: "Hello",
}),
);
expect(httpClientMock).toHaveBeenCalledWith("/api/chat/send", {
method: "POST",
body: expect.objectContaining({ characterId: CHARACTER_ID }),
});
});
it("scopes history to characterId", async () => {
httpClientMock.mockResolvedValue({
success: true,
data: { messages: [], total: 0, limit: 50, offset: 0 },
});
await new ChatApi().getHistory(CHARACTER_ID, 50, 10);
expect(httpClientMock).toHaveBeenCalledWith("/api/chat/history", {
query: { characterId: CHARACTER_ID, limit: 50, offset: 10 },
});
});
it("sends characterId with private and history unlocks", async () => {
const api = new ChatApi();
httpClientMock
.mockResolvedValueOnce({
success: true,
data: {
unlocked: false,
reason: "not_found",
image: { type: null, url: null },
},
})
.mockResolvedValueOnce({
success: true,
data: { unlocked: false, reason: "no_locked_messages" },
});
await api.unlockPrivateMessage(
UnlockPrivateRequest.from({
characterId: CHARACTER_ID,
messageId: "message-1",
}),
);
await api.unlockHistory(
UnlockHistoryRequest.from({ characterId: CHARACTER_ID }),
);
expect(httpClientMock).toHaveBeenNthCalledWith(
1,
"/api/chat/unlock-private",
{
method: "POST",
body: expect.objectContaining({ characterId: CHARACTER_ID }),
},
);
expect(httpClientMock).toHaveBeenNthCalledWith(
2,
"/api/chat/unlock-history",
{
method: "POST",
body: { characterId: CHARACTER_ID },
},
);
});
it("scopes private-room albums to characterId", async () => {
httpClientMock.mockResolvedValue({
success: true,
data: { items: [], creditBalance: 0 },
});
await new PrivateRoomApi().getAlbums({
characterId: CHARACTER_ID,
limit: 20,
});
expect(httpClientMock).toHaveBeenCalledWith("/api/private-room/albums", {
query: { characterId: CHARACTER_ID, limit: 20 },
});
});
});
+2 -1
View File
@@ -24,5 +24,6 @@
"privateRoomAlbumUnlock": { "method": "post", "path": "/api/private-room/albums/{albumId}/unlock" },
"metricsPwaEvent": { "method": "post", "path": "/api/metrics/pwa/event" },
"reportUserInfo": { "method": "post", "path": "/api/data/report-user-info" },
"feedback": { "method": "post", "path": "/api/feedback" }
"feedback": { "method": "post", "path": "/api/feedback" },
"characters": { "method": "get", "path": "/api/characters" }
}
+3
View File
@@ -98,4 +98,7 @@ export class ApiPath {
// ============ 用户反馈相关 ============
static readonly feedback = apiContract.feedback.path;
// ============ 角色相关 ============
static readonly characters = apiContract.characters.path;
}
+14
View File
@@ -0,0 +1,14 @@
import { CharactersResponse } from "@/data/dto/character";
import { ApiPath } from "./api_path";
import { httpClient } from "./http_client";
import { type ApiEnvelope, unwrap } from "./response_helper";
export class CharacterApi {
async getCharacters(): Promise<CharactersResponse> {
const envelope = await httpClient<ApiEnvelope<unknown>>(ApiPath.characters);
return CharactersResponse.fromJson(unwrap(envelope));
}
}
export const characterApi = new CharacterApi();
+11 -3
View File
@@ -11,6 +11,7 @@ import {
ChatHistoryResponse,
ChatSendResponse,
SendMessageRequest,
UnlockHistoryRequest,
UnlockHistoryResponse,
UnlockPrivateRequest,
UnlockPrivateResponse,
@@ -31,9 +32,13 @@ export class ChatApi {
/**
* 获取聊天历史
*/
async getHistory(limit = 50, offset = 0): Promise<ChatHistoryResponse> {
async getHistory(
characterId: string,
limit = 50,
offset = 0,
): Promise<ChatHistoryResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatHistory, {
query: { limit, offset },
query: { characterId, limit, offset },
});
return ChatHistoryResponse.fromJson(
unwrap(env) as Record<string, unknown>
@@ -61,11 +66,14 @@ export class ChatApi {
/**
* 一键解锁历史锁定消息
*/
async unlockHistory(): Promise<UnlockHistoryResponse> {
async unlockHistory(
body: UnlockHistoryRequest,
): Promise<UnlockHistoryResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.chatUnlockHistory,
{
method: "POST",
body: body.toJson(),
},
);
return UnlockHistoryResponse.fromJson(
+1
View File
@@ -6,6 +6,7 @@ export * from "./api_path";
export * from "./api_result";
export * from "./auth_api";
export * from "./chat_api";
export * from "./character_api";
export * from "./feedback_api";
export * from "./http_client";
export * from "./metrics_api";
+3 -2
View File
@@ -3,13 +3,14 @@ import {
PrivateAlbumUnlockResponse,
UnlockPrivateAlbumRequest,
} from "@/data/dto/private-room";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { ApiPath } from "./api_path";
import { httpClient } from "./http_client";
import { type ApiEnvelope, unwrap } from "./response_helper";
export interface GetPrivateAlbumsInput {
character?: string;
characterId?: string;
limit?: number;
}
@@ -21,7 +22,7 @@ export class PrivateRoomApi {
ApiPath.privateRoomAlbums,
{
query: {
character: input.character ?? "elio",
characterId: input.characterId ?? DEFAULT_CHARACTER_ID,
limit: input.limit ?? 20,
},
},
@@ -4,8 +4,8 @@ import { Result } from "@/utils/result";
const getHistoryMock = vi.hoisted(() => vi.fn());
vi.mock("@/data/repositories/chat_repository", () => ({
getChatRepository: () => ({ getHistory: getHistoryMock }),
vi.mock("@/data/repositories/chat_repository_loader", () => ({
loadChatRepository: async () => ({ getHistory: getHistoryMock }),
}));
import {
@@ -31,10 +31,22 @@ describe("fetchSplashLatestMessagePreview", () => {
const result = await fetchSplashLatestMessagePreview();
expect(getHistoryMock).toHaveBeenCalledWith(1, 0);
expect(getHistoryMock).toHaveBeenCalledWith(
"character_elio",
1,
0,
);
expect(Result.isOk(result) && result.data).toBe("Latest message");
});
it("requests the selected character preview", async () => {
getHistoryMock.mockResolvedValue(Result.ok({ messages: [] }));
await fetchSplashLatestMessagePreview("character_aria");
expect(getHistoryMock).toHaveBeenCalledWith("character_aria", 1, 0);
});
it("returns null when history is empty", async () => {
getHistoryMock.mockResolvedValue(Result.ok({ messages: [] }));
+1
View File
@@ -7,6 +7,7 @@ import { Result, type Result as ResultT } from "@/utils/result";
import { localChatMediaRowToBlob } from "./chat_media_blob";
export interface ResolveCachedChatMediaBlobInput {
characterId: string;
messageId: string;
kind: ChatMediaKind;
}
+15 -12
View File
@@ -1,8 +1,9 @@
import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
import {
resolveChatCacheOwnerKey,
resolveChatConversationKey,
type ChatCacheIdentityResolver,
} from "@/data/repositories/chat_cache_identity";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { Result, type Result as ResultT } from "@/utils/result";
import type { SplashLatestMessageCache } from "./splash_latest_message_cache";
@@ -10,21 +11,23 @@ import { getLatestSplashMessagePreview } from "./splash_latest_message_preview";
export interface LoadSplashLatestMessagePreviewInput {
cache: SplashLatestMessageCache;
fetchPreview?: () => Promise<ResultT<string | null>>;
characterId?: string;
fetchPreview?: (characterId: string) => Promise<ResultT<string | null>>;
resolveIdentity?: ChatCacheIdentityResolver;
}
export async function resolveSplashLatestMessageCacheIdentity(): Promise<
string | null
> {
const result = await resolveChatCacheOwnerKey();
export async function resolveSplashLatestMessageCacheIdentity(
characterId = DEFAULT_CHARACTER_ID,
): Promise<string | null> {
const result = await resolveChatConversationKey(characterId);
return Result.isOk(result) ? result.data : null;
}
export async function loadSplashLatestMessagePreview({
cache,
characterId = DEFAULT_CHARACTER_ID,
fetchPreview = fetchSplashLatestMessagePreview,
resolveIdentity = resolveChatCacheOwnerKey,
resolveIdentity = () => resolveChatConversationKey(characterId),
}: LoadSplashLatestMessagePreviewInput): Promise<ResultT<string | null>> {
const identityResult = await resolveIdentity();
const identity = Result.isOk(identityResult) ? identityResult.data : null;
@@ -34,18 +37,18 @@ export async function loadSplashLatestMessagePreview({
if (cached) return Result.ok(cached.message);
}
const result = await fetchPreview();
const result = await fetchPreview(characterId);
if (identity && Result.isOk(result)) {
cache.set(identity, result.data);
}
return result;
}
export async function fetchSplashLatestMessagePreview(): Promise<
ResultT<string | null>
> {
export async function fetchSplashLatestMessagePreview(
characterId = DEFAULT_CHARACTER_ID,
): Promise<ResultT<string | null>> {
const repository = await loadChatRepository();
const result = await repository.getHistory(1, 0);
const result = await repository.getHistory(characterId, 1, 0);
if (Result.isErr(result)) return Result.err(result.error);
return Result.ok(getLatestSplashMessagePreview(result.data.messages));
+8 -2
View File
@@ -17,6 +17,7 @@ import { isCacheableRemoteChatMediaUrl } from "./chat_media_url";
const log = new Logger("LibChatUseCachedChatMediaUrl");
export interface UseCachedChatMediaUrlInput {
characterId: string;
messageId?: string | null;
remoteUrl?: string | null;
kind: ChatMediaKind;
@@ -36,6 +37,7 @@ interface CachedMediaUrlState {
}
export function useCachedChatMediaUrl({
characterId,
messageId,
remoteUrl,
kind,
@@ -47,7 +49,9 @@ export function useCachedChatMediaUrl({
const [failedCacheKey, setFailedCacheKey] = useState<string | null>(null);
const fallbackUrl = remoteUrl ?? "";
const stateKey =
messageId && remoteUrl ? `${messageId}:${kind}:${remoteUrl}` : "";
messageId && remoteUrl
? `${characterId}:${messageId}:${kind}:${remoteUrl}`
: "";
useEffect(() => {
if (
@@ -80,6 +84,7 @@ export function useCachedChatMediaUrl({
const resolveCachedMedia = async () => {
const cachedResult = await resolveCachedChatMediaBlob({
characterId,
messageId,
kind,
});
@@ -91,6 +96,7 @@ export function useCachedChatMediaUrl({
}
const cacheResult = await cacheRemoteChatMediaBlob({
characterId,
messageId,
kind,
remoteUrl,
@@ -114,7 +120,7 @@ export function useCachedChatMediaUrl({
return () => {
cancelled = true;
};
}, [failedCacheKey, kind, messageId, remoteUrl, stateKey]);
}, [characterId, failedCacheKey, kind, messageId, remoteUrl, stateKey]);
useEffect(() => {
return () => {
+11 -2
View File
@@ -2,17 +2,26 @@
import type { ReactNode } from "react";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { ChatProvider } from "@/stores/chat/chat-context";
import { PaymentProvider } from "@/stores/payment/payment-context";
import { ChatAuthSync } from "@/stores/sync/chat-auth-sync";
import { ChatPaymentSuccessSync } from "@/stores/sync/chat-payment-success-sync";
import { PaymentSuccessSync } from "@/stores/sync/payment-success-sync";
export function ChatRouteProviders({ children }: { children: ReactNode }) {
export interface ChatRouteProvidersProps {
children: ReactNode;
characterId?: string;
}
export function ChatRouteProviders({
children,
characterId = DEFAULT_CHARACTER_ID,
}: ChatRouteProvidersProps) {
return (
<PaymentProvider>
<PaymentSuccessSync />
<ChatProvider>
<ChatProvider characterId={characterId}>
<ChatAuthSync />
<ChatPaymentSuccessSync />
{children}
+13 -4
View File
@@ -2,12 +2,21 @@
import type { ReactNode } from "react";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { PrivateRoomProvider } from "@/stores/private-room";
export interface PrivateRoomRouteProviderProps {
children: ReactNode;
characterId?: string;
}
export function PrivateRoomRouteProvider({
children,
}: {
children: ReactNode;
}) {
return <PrivateRoomProvider>{children}</PrivateRoomProvider>;
characterId = DEFAULT_CHARACTER_ID,
}: PrivateRoomRouteProviderProps) {
return (
<PrivateRoomProvider characterId={characterId}>
{children}
</PrivateRoomProvider>
);
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { ChatSendResponse } from "@/data/dto/chat";
import type { ChatState } from "@/stores/chat/chat-state";
import {
@@ -29,6 +30,7 @@ function makeResponse(
function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
return {
characterId: DEFAULT_CHARACTER_ID,
messages: [],
promotion: null,
outgoingMessageRevision: 0,
@@ -9,11 +9,14 @@ import type { LoadMoreHistoryActorEvent } from "@/stores/chat/machine/actors/his
import {
createLoadHistoryCallback,
createTestChatMachine,
TEST_CHAT_MACHINE_INPUT,
} from "./chat-machine.test-utils";
describe("chat history flow", () => {
it("enters user ready after history is loaded", async () => {
const actor = createActor(createTestChatMachine()).start();
const actor = createActor(createTestChatMachine(), {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
@@ -45,7 +48,10 @@ describe("chat history flow", () => {
};
const machine = chatMachine.provide({
actors: {
loadHistory: fromCallback<ChatEvent>(({ sendBack }) => {
loadHistory: fromCallback<
ChatEvent,
{ characterId: string }
>(({ sendBack }) => {
sendBack({
type: "ChatLocalHistoryLoaded",
output: {
@@ -70,7 +76,9 @@ describe("chat history flow", () => {
}),
},
});
const actor = createActor(machine).start();
const actor = createActor(machine, {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
@@ -109,7 +117,10 @@ describe("chat history flow", () => {
[latestMessage],
{ total: 120, limit: 50 },
),
loadMoreHistory: fromCallback<LoadMoreHistoryActorEvent>(
loadMoreHistory: fromCallback<
LoadMoreHistoryActorEvent,
{ characterId: string }
>(
({ receive, sendBack }) => {
receive((event) => {
requests.push(event);
@@ -143,7 +154,9 @@ describe("chat history flow", () => {
),
},
});
const actor = createActor(machine).start();
const actor = createActor(machine, {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
@@ -183,7 +196,10 @@ describe("chat history flow", () => {
total: 75,
limit: 50,
}),
loadMoreHistory: fromCallback<LoadMoreHistoryActorEvent>(
loadMoreHistory: fromCallback<
LoadMoreHistoryActorEvent,
{ characterId: string }
>(
({ receive, sendBack }) => {
receive((event) => {
requestCount += 1;
@@ -209,7 +225,9 @@ describe("chat history flow", () => {
),
},
});
const actor = createActor(machine).start();
const actor = createActor(machine, {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatGuestLogin" });
await waitFor(actor, (snapshot) =>
@@ -240,7 +258,10 @@ describe("chat history flow", () => {
total: 50,
limit: 50,
}),
loadMoreHistory: fromCallback<LoadMoreHistoryActorEvent>(
loadMoreHistory: fromCallback<
LoadMoreHistoryActorEvent,
{ characterId: string }
>(
({ receive }) => {
receive(() => {
requested = true;
@@ -250,7 +271,9 @@ describe("chat history flow", () => {
),
},
});
const actor = createActor(machine).start();
const actor = createActor(machine, {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
@@ -5,12 +5,14 @@ import {
UnlockPrivateResponse,
type UiMessage,
} from "@/data/dto/chat";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { chatMachine } from "@/stores/chat/chat-machine";
import type { ChatEvent } from "@/stores/chat/chat-events";
import type {
UnlockMessageOutput as MachineUnlockMessageOutput,
UnlockMessageRequest,
} from "@/stores/chat/helper/unlock";
import type { ChatHistoryActorInput } from "@/stores/chat/machine/actors/history";
export interface SendMessageHttpOutput {
response: ChatSendResponse;
@@ -29,6 +31,10 @@ export interface TestUnlockMessageOutput {
response: UnlockPrivateResponse;
}
export const TEST_CHAT_MACHINE_INPUT = {
characterId: DEFAULT_CHARACTER_ID,
};
export function makeChatSendResponse(): ChatSendResponse {
return ChatSendResponse.from({
reply: "",
@@ -74,7 +80,7 @@ export function createTestChatMachine(
loadHistory: createLoadHistoryCallback(options.historyMessages ?? []),
sendMessageHttp: fromPromise<
SendMessageHttpOutput,
{ content: string }
{ characterId: string; content: string }
>(async () => {
if (options.sendMessageHttpError) {
throw options.sendMessageHttpError;
@@ -84,21 +90,26 @@ export function createTestChatMachine(
reply: null,
};
}),
httpMessageQueue: fromCallback<ChatEvent>(({ receive, sendBack }) => {
receive((event) => {
if (event.type !== "ChatSendMessage") return;
sendBack({ type: "ChatQueuedSendStarted" });
sendBack({
type: "ChatQueuedHttpDone",
output: {
response: makeChatSendResponse(),
reply: null,
},
httpMessageQueue: fromCallback<ChatEvent, { characterId: string }>(
({ receive, sendBack }) => {
receive((event) => {
if (event.type !== "ChatSendMessage") return;
sendBack({ type: "ChatQueuedSendStarted" });
sendBack({
type: "ChatQueuedHttpDone",
output: {
response: makeChatSendResponse(),
reply: null,
},
});
});
});
return () => undefined;
}),
unlockHistory: fromPromise<UnlockHistoryOutput>(async () => {
return () => undefined;
},
),
unlockHistory: fromPromise<
UnlockHistoryOutput,
{ characterId: string }
>(async () => {
if (options.unlockHistoryError) {
throw options.unlockHistoryError;
}
@@ -112,7 +123,7 @@ export function createTestChatMachine(
}),
unlockMessage: fromPromise<
MachineUnlockMessageOutput,
UnlockMessageRequest
UnlockMessageRequest & { characterId: string }
>(async ({ input }) => {
const output = options.unlockMessageOutput ?? {
messageId: input.messageId ?? input.displayMessageId,
@@ -136,7 +147,7 @@ export function createLoadHistoryCallback(
limit: 50,
},
) {
return fromCallback<ChatEvent>(({ sendBack }) => {
return fromCallback<ChatEvent, ChatHistoryActorInput>(({ sendBack }) => {
sendBack({
type: "ChatLocalHistoryLoaded",
output: {
@@ -8,13 +8,16 @@ import {
createLoadHistoryCallback,
createTestChatMachine,
makeChatSendResponse,
TEST_CHAT_MACHINE_INPUT,
type SendMessageHttpOutput,
type UnlockHistoryOutput,
} from "./chat-machine.test-utils";
describe("chat send flow", () => {
it("allows multiple messages to be queued without leaving ready state", async () => {
const actor = createActor(createTestChatMachine()).start();
const actor = createActor(createTestChatMachine(), {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
@@ -44,7 +47,9 @@ describe("chat send flow", () => {
});
it("applies direct HTTP actor output with a type-bound action", async () => {
const actor = createActor(createTestChatMachine()).start();
const actor = createActor(createTestChatMachine(), {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatGuestLogin" });
await waitFor(actor, (snapshot) =>
@@ -70,7 +75,9 @@ describe("chat send flow", () => {
});
it("increments the outgoing revision for a user image", async () => {
const actor = createActor(createTestChatMachine()).start();
const actor = createActor(createTestChatMachine(), {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
@@ -95,6 +102,7 @@ describe("chat send flow", () => {
createTestChatMachine({
sendMessageHttpError: new Error("send failed"),
}),
{ input: TEST_CHAT_MACHINE_INPUT },
).start();
actor.send({ type: "ChatGuestLogin" });
@@ -121,29 +129,34 @@ describe("chat send flow", () => {
loadHistory: createLoadHistoryCallback([]),
sendMessageHttp: fromPromise<
SendMessageHttpOutput,
{ content: string }
{ characterId: string; content: string }
>(async () => ({
response: makeChatSendResponse(),
reply: null,
})),
httpMessageQueue: fromCallback<ChatEvent>(({ receive, sendBack }) => {
const pending: string[] = [];
receive((event) => {
if (event.type !== "ChatSendMessage") return;
pending.push(event.content);
if (pending.length !== 2) return;
sendBack({ type: "ChatQueuedSendStarted" });
sendBack({
type: "ChatQueuedHttpDone",
output: {
response: makeChatSendResponse(),
reply: null,
},
httpMessageQueue: fromCallback<ChatEvent, { characterId: string }>(
({ receive, sendBack }) => {
const pending: string[] = [];
receive((event) => {
if (event.type !== "ChatSendMessage") return;
pending.push(event.content);
if (pending.length !== 2) return;
sendBack({ type: "ChatQueuedSendStarted" });
sendBack({
type: "ChatQueuedHttpDone",
output: {
response: makeChatSendResponse(),
reply: null,
},
});
});
});
return () => undefined;
}),
unlockHistory: fromPromise<UnlockHistoryOutput>(async () => ({
return () => undefined;
},
),
unlockHistory: fromPromise<
UnlockHistoryOutput,
{ characterId: string }
>(async () => ({
unlocked: true,
reason: "ok",
shortfallCredits: 0,
@@ -151,7 +164,9 @@ describe("chat send flow", () => {
})),
},
});
const actor = createActor(machine).start();
const actor = createActor(machine, {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
@@ -1,11 +1,25 @@
import { describe, expect, it } from "vitest";
import { createActor, waitFor } from "xstate";
import { createTestChatMachine } from "./chat-machine.test-utils";
import {
createTestChatMachine,
TEST_CHAT_MACHINE_INPUT,
} from "./chat-machine.test-utils";
describe("chat session flow", () => {
it("initializes the conversation from the supplied character", () => {
const actor = createActor(createTestChatMachine(), {
input: { characterId: "character_aria" },
}).start();
expect(actor.getSnapshot().context.characterId).toBe("character_aria");
actor.stop();
});
it("keeps guest logout disabled and supports user to guest transition", async () => {
const actor = createActor(createTestChatMachine()).start();
const actor = createActor(createTestChatMachine(), {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatGuestLogin" });
await waitFor(actor, (snapshot) =>
@@ -29,7 +43,9 @@ describe("chat session flow", () => {
});
it("allows explicit logout from user session", async () => {
const actor = createActor(createTestChatMachine()).start();
const actor = createActor(createTestChatMachine(), {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
@@ -54,6 +70,7 @@ describe("chat session flow", () => {
},
],
}),
{ input: TEST_CHAT_MACHINE_INPUT },
).start();
actor.send({ type: "ChatUserLogin", token: "token" });
@@ -82,7 +99,9 @@ describe("chat session flow", () => {
});
it("switches to guest session when guest login arrives during user session", async () => {
const actor = createActor(createTestChatMachine()).start();
const actor = createActor(createTestChatMachine(), {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
@@ -98,7 +117,9 @@ describe("chat session flow", () => {
});
it("ignores repeated user login while an authenticated user session is active", async () => {
const actor = createActor(createTestChatMachine()).start();
const actor = createActor(createTestChatMachine(), {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
@@ -10,6 +10,7 @@ import type {
import {
createTestChatMachine,
makeUnlockPrivateResponse,
TEST_CHAT_MACHINE_INPUT,
} from "./chat-machine.test-utils";
describe("chat unlock flow", () => {
@@ -33,6 +34,7 @@ describe("chat unlock flow", () => {
},
],
}),
{ input: TEST_CHAT_MACHINE_INPUT },
).start();
actor.send({ type: "ChatUserLogin", token: "token" });
@@ -80,6 +82,7 @@ describe("chat unlock flow", () => {
],
},
}),
{ input: TEST_CHAT_MACHINE_INPUT },
).start();
actor.send({ type: "ChatUserLogin", token: "token" });
@@ -137,6 +140,7 @@ describe("chat unlock flow", () => {
],
},
}),
{ input: TEST_CHAT_MACHINE_INPUT },
).start();
actor.send({ type: "ChatUserLogin", token: "token" });
@@ -179,6 +183,7 @@ describe("chat unlock flow", () => {
],
unlockHistoryError: new Error("unlock failed"),
}),
{ input: TEST_CHAT_MACHINE_INPUT },
).start();
actor.send({ type: "ChatUserLogin", token: "token" });
@@ -222,6 +227,7 @@ describe("chat unlock flow", () => {
}),
},
}),
{ input: TEST_CHAT_MACHINE_INPUT },
).start();
actor.send({ type: "ChatUserLogin", token: "token" });
@@ -283,6 +289,7 @@ describe("chat unlock flow", () => {
}),
},
}),
{ input: TEST_CHAT_MACHINE_INPUT },
).start();
actor.send({ type: "ChatUserLogin", token: "token" });
@@ -342,6 +349,7 @@ describe("chat unlock flow", () => {
}),
},
}),
{ input: TEST_CHAT_MACHINE_INPUT },
).start();
actor.send({ type: "ChatUserLogin", token: "token" });
@@ -395,6 +403,7 @@ describe("chat unlock flow", () => {
}),
},
}),
{ input: TEST_CHAT_MACHINE_INPUT },
).start();
actor.send({ type: "ChatUserLogin", token: "token" });
@@ -453,6 +462,7 @@ describe("chat unlock flow", () => {
}),
},
}),
{ input: TEST_CHAT_MACHINE_INPUT },
).start();
actor.send({ type: "ChatUserLogin", token: "token" });
@@ -520,6 +530,7 @@ describe("chat unlock flow", () => {
}),
},
}),
{ input: TEST_CHAT_MACHINE_INPUT },
).start();
actor.send({ type: "ChatUserLogin", token: "token" });
@@ -575,14 +586,16 @@ describe("chat unlock flow", () => {
actors: {
unlockMessage: fromPromise<
UnlockMessageOutput,
UnlockMessageRequest
UnlockMessageRequest & { characterId: string }
>(async ({ input }) => {
capturedRequest = input;
return unlockDeferred;
}),
},
});
const actor = createActor(machine).start();
const actor = createActor(machine, {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
@@ -641,7 +654,9 @@ describe("chat unlock flow", () => {
});
it("clears the insufficient credits prompt after payment succeeds", async () => {
const actor = createActor(createTestChatMachine()).start();
const actor = createActor(createTestChatMachine(), {
input: TEST_CHAT_MACHINE_INPUT,
}).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
+14 -2
View File
@@ -4,6 +4,8 @@ import { type Dispatch, type ReactNode, useMemo } from "react";
import { createActorContext, shallowEqual } from "@xstate/react";
import type { SnapshotFrom } from "xstate";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { chatMachine } from "./chat-machine";
import type { ChatEvent, ChatState as MachineContext } from "./chat-machine";
import { appendPromotionMessage } from "./helper/promotion";
@@ -15,6 +17,7 @@ import { appendPromotionMessage } from "./helper/promotion";
* 消费方(chat-screen)通过 `useAuthState()` 取 loginStatus,本地派生 isGuest。
*/
interface ChatState {
characterId: string;
messages: MachineContext["messages"];
historyMessages: MachineContext["messages"];
promotion: MachineContext["promotion"];
@@ -43,10 +46,18 @@ const ChatActorContext = createActorContext(chatMachine);
export interface ChatProviderProps {
children: ReactNode;
characterId?: string;
}
export function ChatProvider({ children }: ChatProviderProps) {
return <ChatActorContext.Provider>{children}</ChatActorContext.Provider>;
export function ChatProvider({
children,
characterId = DEFAULT_CHARACTER_ID,
}: ChatProviderProps) {
return (
<ChatActorContext.Provider options={{ input: { characterId } }}>
{children}
</ChatActorContext.Provider>
);
}
export function useChatState(): ChatState {
@@ -73,6 +84,7 @@ type SelectedChatState = Omit<ChatState, "messages">;
function selectChatState(state: ChatSnapshot): SelectedChatState {
return {
characterId: state.context.characterId,
historyMessages: state.context.messages,
promotion: state.context.promotion,
outgoingMessageRevision: state.context.outgoingMessageRevision,
+17 -6
View File
@@ -1,5 +1,5 @@
import type { UiMessage } from "@/data/dto/chat";
import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity";
import { resolveChatConversationKey } from "@/data/repositories/chat_cache_identity";
import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
import { Logger } from "@/utils/logger";
import { Result } from "@/utils/result";
@@ -41,8 +41,10 @@ export function createGreetingMessage(): UiMessage {
};
}
export async function resolveHistoryCacheIdentity(): Promise<string | null> {
const result = await resolveChatCacheOwnerKey();
export async function resolveHistoryCacheIdentity(
characterId: string,
): Promise<string | null> {
const result = await resolveChatConversationKey(characterId);
return Result.isOk(result) ? result.data : null;
}
@@ -76,13 +78,18 @@ export async function readLocalHistorySnapshot(
}
export async function syncNetworkHistory(
characterId: string,
localCount: number,
cacheIdentity: string | null,
): Promise<NetworkHistorySyncOutput | null> {
const chatRepo = await loadChatRepository();
const greetingMessage = createGreetingMessage();
const networkResult = await chatRepo.getHistory(CHAT_HISTORY_LIMIT, 0);
const networkResult = await chatRepo.getHistory(
characterId,
CHAT_HISTORY_LIMIT,
0,
);
if (Result.isErr(networkResult)) {
log.error("[chat-machine] loadHistory NETWORK FAILED", {
error: networkResult.error,
@@ -97,6 +104,7 @@ export async function syncNetworkHistory(
if (cacheIdentity) {
void chatRepo.prefetchMediaForMessages(
networkResult.data.messages,
characterId,
cacheIdentity,
);
}
@@ -134,10 +142,13 @@ export async function syncNetworkHistory(
* 2. Read network history as the authoritative source.
* 3. Overwrite local history with network data.
*/
export async function readAndSyncHistory(): Promise<ReadAndSyncHistoryOutput> {
const cacheIdentity = await resolveHistoryCacheIdentity();
export async function readAndSyncHistory(
characterId: string,
): Promise<ReadAndSyncHistoryOutput> {
const cacheIdentity = await resolveHistoryCacheIdentity(characterId);
const localSnapshot = await readLocalHistorySnapshot(cacheIdentity);
const networkSnapshot = await syncNetworkHistory(
characterId,
localSnapshot.localCount,
cacheIdentity,
);
+5 -2
View File
@@ -2,7 +2,9 @@ import {
chatMachineSetup,
chatRootStateConfig,
} from "./machine/session-flow";
import { initialState } from "./chat-state";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { createInitialChatState } from "./chat-state";
export type { ChatState } from "./chat-state";
export { initialState } from "./chat-state";
@@ -10,7 +12,8 @@ export type { ChatEvent } from "./chat-events";
export const chatMachine = chatMachineSetup.createMachine({
id: "chat",
context: initialState,
context: ({ input }) =>
createInitialChatState(input?.characterId ?? DEFAULT_CHARACTER_ID),
...chatRootStateConfig,
});
+35 -26
View File
@@ -2,6 +2,7 @@ import type { UiMessage } from "@/data/dto/chat";
import type { PendingChatUnlockKind } from "@/lib/navigation/chat_unlock_session";
import type { ChatLockType } from "@/data/schemas/chat";
import type { PendingChatPromotion } from "@/data/storage/navigation";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import type { ChatPromotionState } from "./helper/promotion";
export type ChatUpgradeReason = "insufficient_credits";
@@ -24,6 +25,7 @@ export interface ChatUnlockPaywallRequest
}
export interface ChatState {
characterId: string;
messages: UiMessage[];
promotion: ChatPromotionState | null;
outgoingMessageRevision: number;
@@ -51,29 +53,36 @@ export interface ChatState {
unlockPaywallRequest: ChatUnlockPaywallRequest | null;
}
export const initialState: ChatState = {
messages: [],
promotion: null,
outgoingMessageRevision: 0,
isReplyingAI: false,
pendingReplyCount: 0,
upgradePromptVisible: false,
upgradeReason: null,
canSendMessage: true,
creditBalance: 0,
creditsCharged: 0,
requiredCredits: 0,
shortfallCredits: 0,
historyLoaded: false,
historyTotal: 0,
historyLimit: 50,
nextHistoryOffset: 0,
isLoadingMoreHistory: false,
paymentUnlockPending: false,
unlockHistoryPromptVisible: false,
lockedHistoryCount: 0,
unlockHistoryError: null,
unlockingMessage: null,
unlockMessageError: null,
unlockPaywallRequest: null,
};
export function createInitialChatState(
characterId: string = DEFAULT_CHARACTER_ID,
): ChatState {
return {
characterId,
messages: [],
promotion: null,
outgoingMessageRevision: 0,
isReplyingAI: false,
pendingReplyCount: 0,
upgradePromptVisible: false,
upgradeReason: null,
canSendMessage: true,
creditBalance: 0,
creditsCharged: 0,
requiredCredits: 0,
shortfallCredits: 0,
historyLoaded: false,
historyTotal: 0,
historyLimit: 50,
nextHistoryOffset: 0,
isLoadingMoreHistory: false,
paymentUnlockPending: false,
unlockHistoryPromptVisible: false,
lockedHistoryCount: 0,
unlockHistoryError: null,
unlockingMessage: null,
unlockMessageError: null,
unlockPaywallRequest: null,
};
}
export const initialState: ChatState = createInitialChatState();
+60 -46
View File
@@ -22,6 +22,10 @@ export interface LoadMoreHistoryActorEvent {
limit: number;
}
export interface ChatHistoryActorInput {
characterId: string;
}
export interface LoadMoreHistoryOutput {
messages: UiMessage[];
offset: number;
@@ -29,11 +33,14 @@ export interface LoadMoreHistoryOutput {
limit: number;
}
export const loadHistoryActor = fromCallback<ChatEvent>(({ sendBack }) => {
export const loadHistoryActor = fromCallback<
ChatEvent,
ChatHistoryActorInput
>(({ input, sendBack }) => {
let cancelled = false;
void (async () => {
const cacheIdentity = await resolveHistoryCacheIdentity();
const cacheIdentity = await resolveHistoryCacheIdentity(input.characterId);
const localSnapshot = await readLocalHistorySnapshot(cacheIdentity);
if (cancelled) return;
sendBack({
@@ -42,6 +49,7 @@ export const loadHistoryActor = fromCallback<ChatEvent>(({ sendBack }) => {
});
const networkSnapshot = await syncNetworkHistory(
input.characterId,
localSnapshot.localCount,
cacheIdentity,
);
@@ -62,52 +70,58 @@ export const loadHistoryActor = fromCallback<ChatEvent>(({ sendBack }) => {
});
export const loadMoreHistoryActor =
fromCallback<LoadMoreHistoryActorEvent>(({ receive, sendBack }) => {
let cancelled = false;
let running = false;
fromCallback<LoadMoreHistoryActorEvent, ChatHistoryActorInput>(
({ input, receive, sendBack }) => {
let cancelled = false;
let running = false;
receive((event) => {
if (running || cancelled) return;
running = true;
receive((event) => {
if (running || cancelled) return;
running = true;
void (async () => {
const chatRepo = await loadChatRepository();
const historyResult = await chatRepo.getHistory(
event.limit,
event.offset,
);
if (Result.isErr(historyResult)) throw historyResult.error;
if (cancelled) return;
const response = historyResult.data;
sendBack({
type: "ChatOlderHistoryLoaded",
output: {
messages: localMessagesToUi(response.messages),
offset: event.offset,
total: response.total,
limit: normalizeHistoryLimit(response.limit, event.limit),
} satisfies LoadMoreHistoryOutput,
});
void resolveHistoryCacheIdentity().then((cacheIdentity) => {
if (!cacheIdentity) return;
void chatRepo.prefetchMediaForMessages(
response.messages,
cacheIdentity,
void (async () => {
const chatRepo = await loadChatRepository();
const historyResult = await chatRepo.getHistory(
input.characterId,
event.limit,
event.offset,
);
});
})()
.catch((error: unknown) => {
if (Result.isErr(historyResult)) throw historyResult.error;
if (cancelled) return;
log.warn("[chat-machine] loadMoreHistoryActor failed", { error });
sendBack({ type: "ChatOlderHistoryLoadFailed", error });
})
.finally(() => {
running = false;
});
});
return () => {
cancelled = true;
};
});
const response = historyResult.data;
sendBack({
type: "ChatOlderHistoryLoaded",
output: {
messages: localMessagesToUi(response.messages),
offset: event.offset,
total: response.total,
limit: normalizeHistoryLimit(response.limit, event.limit),
} satisfies LoadMoreHistoryOutput,
});
void resolveHistoryCacheIdentity(input.characterId).then(
(cacheIdentity) => {
if (!cacheIdentity) return;
void chatRepo.prefetchMediaForMessages(
response.messages,
input.characterId,
cacheIdentity,
);
},
);
})()
.catch((error: unknown) => {
if (cancelled) return;
log.warn("[chat-machine] loadMoreHistoryActor failed", { error });
sendBack({ type: "ChatOlderHistoryLoadFailed", error });
})
.finally(() => {
running = false;
});
});
return () => {
cancelled = true;
};
},
);
+15 -10
View File
@@ -4,7 +4,7 @@ import { ExceptionHandler } from "@/core/errors";
import { MessageQueue } from "@/core/net/message-queue";
import type { ChatSendResponse } from "@/data/dto/chat";
import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity";
import { resolveChatConversationKey } from "@/data/repositories/chat_cache_identity";
import { Logger } from "@/utils/logger";
import { Result } from "@/utils/result";
@@ -17,18 +17,22 @@ type UiMessage = import("@/data/dto/chat").UiMessage;
export const sendMessageHttpActor = fromPromise<
{ response: ChatSendResponse; reply: UiMessage | null },
{ content: string }
{ characterId: string; content: string }
>(async ({ input }) => {
return sendMessageViaHttp(input.content);
return sendMessageViaHttp(input.characterId, input.content);
});
export const httpMessageQueueActor = fromCallback<ChatEvent>(
({ sendBack, receive }) => {
return createMessageQueueActor(sendBack, receive);
export const httpMessageQueueActor = fromCallback<
ChatEvent,
{ characterId: string }
>(
({ input, sendBack, receive }) => {
return createMessageQueueActor(input.characterId, sendBack, receive);
},
);
function createMessageQueueActor(
characterId: string,
sendBack: (event: ChatEvent) => void,
receive: (listener: (event: ChatEvent) => void) => void,
): () => void {
@@ -37,7 +41,7 @@ function createMessageQueueActor(
queue.setConsumer(async (content) => {
sendBack({ type: "ChatQueuedSendStarted" });
try {
const output = await sendMessageViaHttp(content);
const output = await sendMessageViaHttp(characterId, content);
sendBack({ type: "ChatQueuedHttpDone", output });
} catch (error) {
const errorMessage = ExceptionHandler.message(error);
@@ -62,13 +66,13 @@ function createMessageQueueActor(
return () => queue.dispose();
}
async function sendMessageViaHttp(content: string): Promise<{
async function sendMessageViaHttp(characterId: string, content: string): Promise<{
response: ChatSendResponse;
reply: UiMessage | null;
}> {
const chatRepo = await loadChatRepository();
const cacheIdentityResult = await resolveChatCacheOwnerKey();
const result = await chatRepo.sendMessage(content);
const cacheIdentityResult = await resolveChatConversationKey(characterId);
const result = await chatRepo.sendMessage(characterId, content);
if (Result.isErr(result)) {
log.error("[chat-machine] sendMessageHttpActor failed", {
error: result.error,
@@ -78,6 +82,7 @@ async function sendMessageViaHttp(content: string): Promise<{
if (Result.isOk(cacheIdentityResult)) {
void chatRepo.prefetchMediaForSendResponse(
result.data,
characterId,
cacheIdentityResult.data,
);
}
+12 -6
View File
@@ -1,7 +1,7 @@
import { fromPromise } from "xstate";
import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity";
import { resolveChatConversationKey } from "@/data/repositories/chat_cache_identity";
import { Logger } from "@/utils/logger";
import { Result } from "@/utils/result";
@@ -22,9 +22,12 @@ export interface UnlockHistoryOutput {
messages: UiMessage[];
}
export const unlockHistoryActor = fromPromise<UnlockHistoryOutput>(async () => {
export const unlockHistoryActor = fromPromise<
UnlockHistoryOutput,
{ characterId: string }
>(async ({ input }) => {
const chatRepo = await loadChatRepository();
const unlockResult = await chatRepo.unlockHistory();
const unlockResult = await chatRepo.unlockHistory(input.characterId);
if (Result.isErr(unlockResult)) {
log.error("[chat-machine] unlockHistoryActor failed", {
error: unlockResult.error,
@@ -32,7 +35,7 @@ export const unlockHistoryActor = fromPromise<UnlockHistoryOutput>(async () => {
throw unlockResult.error;
}
const history = await readAndSyncHistory();
const history = await readAndSyncHistory(input.characterId);
return {
unlocked: unlockResult.data.unlocked,
reason: unlockResult.data.reason,
@@ -43,11 +46,14 @@ export const unlockHistoryActor = fromPromise<UnlockHistoryOutput>(async () => {
export const unlockMessageActor = fromPromise<
UnlockMessageOutput,
UnlockMessageRequest
UnlockMessageRequest & { characterId: string }
>(async ({ input }) => {
const chatRepo = await loadChatRepository();
const cacheIdentityResult = await resolveChatCacheOwnerKey();
const cacheIdentityResult = await resolveChatConversationKey(
input.characterId,
);
const unlockResult = await chatRepo.unlockPrivateMessage({
characterId: input.characterId,
...(input.messageId ? { messageId: input.messageId } : {}),
...(input.lockType ? { lockType: input.lockType } : {}),
...(input.clientLockId ? { clientLockId: input.clientLockId } : {}),
+4 -2
View File
@@ -205,7 +205,8 @@ export const guestReadyState = sendMachineSetup.createStateConfig({
export const guestSendingState = sendMachineSetup.createStateConfig({
invoke: {
src: "sendMessageHttp",
input: ({ event }) => ({
input: ({ context, event }) => ({
characterId: context.characterId,
content: event.type === "ChatSendMessage" ? event.content : "",
}),
onDone: {
@@ -222,7 +223,8 @@ export const guestSendingState = sendMachineSetup.createStateConfig({
export const userSendingViaHttpState = sendMachineSetup.createStateConfig({
invoke: {
src: "sendMessageHttp",
input: ({ event }) => ({
input: ({ context, event }) => ({
characterId: context.characterId,
content: event.type === "ChatSendMessage" ? event.content : "",
}),
onDone: {
+11 -5
View File
@@ -1,6 +1,6 @@
import { createChatPromotionState } from "../helper/promotion";
import { shouldPromptUnlockHistory } from "../helper/unlock";
import { initialState } from "../chat-state";
import { createInitialChatState } from "../chat-state";
import {
guestInitializingState,
historyPaginationTransitions,
@@ -19,20 +19,20 @@ import {
const startGuestSessionAction = unlockMachineSetup.assign(
({ context }) => ({
...initialState,
...createInitialChatState(context.characterId),
promotion: context.promotion,
}),
);
const startUserSessionAction = unlockMachineSetup.assign(
({ context }) => ({
...initialState,
...createInitialChatState(context.characterId),
promotion: context.promotion,
}),
);
const clearChatSessionAction = unlockMachineSetup.assign(() => ({
...initialState,
const clearChatSessionAction = unlockMachineSetup.assign(({ context }) => ({
...createInitialChatState(context.characterId),
}));
const injectPromotionAction = unlockMachineSetup.assign(({ event }) => {
@@ -75,14 +75,17 @@ const guestSessionState = chatMachineSetup.createStateConfig({
{
id: "messageQueue",
src: "httpMessageQueue",
input: ({ context }) => ({ characterId: context.characterId }),
},
{
id: "loadHistory",
src: "loadHistory",
input: ({ context }) => ({ characterId: context.characterId }),
},
{
id: "loadMoreHistory",
src: "loadMoreHistory",
input: ({ context }) => ({ characterId: context.characterId }),
},
],
on: {
@@ -154,14 +157,17 @@ const userSessionState = chatMachineSetup.createStateConfig({
{
id: "messageQueue",
src: "httpMessageQueue",
input: ({ context }) => ({ characterId: context.characterId }),
},
{
id: "loadHistory",
src: "loadHistory",
input: ({ context }) => ({ characterId: context.characterId }),
},
{
id: "loadMoreHistory",
src: "loadMoreHistory",
input: ({ context }) => ({ characterId: context.characterId }),
},
],
on: {
+5
View File
@@ -15,10 +15,15 @@ import {
} from "./actors/unlock";
import type { ChatState } from "../chat-state";
export interface ChatMachineInput {
characterId?: string;
}
export const baseChatMachineSetup = setup({
types: {
context: {} as ChatState,
events: {} as ChatEvent,
input: {} as ChatMachineInput | undefined,
},
actors: {
loadHistory: loadHistoryActor,
+7 -4
View File
@@ -188,6 +188,7 @@ export const unlockingHistoryState = unlockMachineSetup.createStateConfig({
invoke: {
id: "unlockHistory",
src: "unlockHistory",
input: ({ context }) => ({ characterId: context.characterId }),
onDone: {
target: "ready",
actions: applyUnlockHistoryOutputAction,
@@ -203,11 +204,13 @@ export const unlockingMessageState = unlockMachineSetup.createStateConfig({
invoke: {
id: "unlockMessage",
src: "unlockMessage",
input: ({ context }) =>
context.unlockingMessage ?? {
input: ({ context }) => ({
characterId: context.characterId,
...(context.unlockingMessage ?? {
displayMessageId: "",
kind: "private",
},
kind: "private" as const,
}),
}),
onDone: [
{
guard: ({ event }) => event.output.response.unlocked,
@@ -6,9 +6,29 @@ import {
loadPrivateRoom,
makeAlbum,
makeAlbumsResponse,
TEST_PRIVATE_ROOM_INPUT,
} from "./private-room-machine.test-utils";
describe("private room album flow", () => {
it("loads albums for the supplied character", async () => {
let loadedCharacterId: string | null = null;
const actor = createActor(
createTestPrivateRoomMachine({
onLoad: ({ characterId }) => {
loadedCharacterId = characterId;
},
}),
{ input: { characterId: "character_aria" } },
).start();
actor.send({ type: "PrivateRoomInit" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
expect(actor.getSnapshot().context.characterId).toBe("character_aria");
expect(loadedCharacterId).toBe("character_aria");
actor.stop();
});
it("loads only the first non-paginated album page", async () => {
const actor = await loadPrivateRoom({
loadResponse: makeAlbumsResponse({
@@ -26,6 +46,7 @@ describe("private room album flow", () => {
createTestPrivateRoomMachine({
loadError: new Error("albums unavailable"),
}),
{ input: TEST_PRIVATE_ROOM_INPUT },
).start();
actor.send({ type: "PrivateRoomInit" });
await waitFor(actor, (snapshot) => snapshot.matches("failed"));
@@ -4,11 +4,15 @@ import {
PrivateAlbumsResponse,
PrivateAlbumUnlockResponse,
} from "@/data/dto/private-room";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { privateRoomMachine } from "@/stores/private-room/private-room-machine";
export const ALBUM_ID = "a1b2c3d4-0000-0000-0000-000000000000";
export const COVER_URL =
"https://dbapi.banlv-ai.com/storage/v1/object/public/elio-schedules/01.jpg";
export const TEST_PRIVATE_ROOM_INPUT = {
characterId: DEFAULT_CHARACTER_ID,
};
export function makeAlbum(index = 0) {
return {
@@ -62,12 +66,15 @@ export function createTestPrivateRoomMachine(options: {
unlockResponse?: PrivateAlbumUnlockResponse;
loadError?: Error;
unlockError?: Error;
onLoad?: () => void;
onLoad?: (input: { characterId: string }) => void;
} = {}) {
return privateRoomMachine.provide({
actors: {
loadAlbums: fromPromise(async () => {
options.onLoad?.();
loadAlbums: fromPromise<
PrivateAlbumsResponse,
{ characterId: string }
>(async ({ input }) => {
options.onLoad?.(input);
if (options.loadError) throw options.loadError;
return options.loadResponse ?? makeAlbumsResponse();
}),
@@ -82,7 +89,9 @@ export function createTestPrivateRoomMachine(options: {
export async function loadPrivateRoom(
options: Parameters<typeof createTestPrivateRoomMachine>[0] = {},
) {
const actor = createActor(createTestPrivateRoomMachine(options)).start();
const actor = createActor(createTestPrivateRoomMachine(options), {
input: TEST_PRIVATE_ROOM_INPUT,
}).start();
actor.send({ type: "PrivateRoomInit" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
return actor;
-1
View File
@@ -6,7 +6,6 @@ import {
import type { PrivateRoomState } from "../private-room-state";
export const PRIVATE_ROOM_CHARACTER = "elio";
export const PRIVATE_ROOM_PAGE_SIZE = 20;
export function applyAlbumsResponse(
@@ -7,15 +7,14 @@ import type {
import { getPrivateRoomRepository } from "@/data/repositories/private_room_repository";
import { Result } from "@/utils/result";
import {
PRIVATE_ROOM_CHARACTER,
PRIVATE_ROOM_PAGE_SIZE,
} from "../../helper/albums";
import { PRIVATE_ROOM_PAGE_SIZE } from "../../helper/albums";
export const loadPrivateAlbumsActor =
fromPromise<PrivateAlbumsResponse>(async () => {
fromPromise<PrivateAlbumsResponse, { characterId: string }>(async ({
input,
}) => {
const result = await getPrivateRoomRepository().getAlbums({
character: PRIVATE_ROOM_CHARACTER,
characterId: input.characterId,
limit: PRIVATE_ROOM_PAGE_SIZE,
});
if (Result.isErr(result)) throw result.error;
@@ -45,6 +45,7 @@ export const loadingState = albumMachineSetup.createStateConfig({
entry: "clearAlbumLoadState",
invoke: {
src: "loadAlbums",
input: ({ context }) => ({ characterId: context.characterId }),
onDone: {
target: "ready",
actions: applyAlbumsResponseAction,
+5
View File
@@ -8,10 +8,15 @@ import {
unlockPrivateAlbumActor,
} from "./actors/albums";
export interface PrivateRoomMachineInput {
characterId?: string;
}
export const basePrivateRoomMachineSetup = setup({
types: {
context: {} as PrivateRoomState,
events: {} as PrivateRoomEvent,
input: {} as PrivateRoomMachineInput,
},
actors: {
loadAlbums: loadPrivateAlbumsActor,
@@ -4,6 +4,8 @@ import type { Dispatch, ReactNode } from "react";
import { createActorContext, shallowEqual } from "@xstate/react";
import type { SnapshotFrom } from "xstate";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { privateRoomMachine } from "./private-room-machine";
import type {
PrivateRoomEvent,
@@ -11,6 +13,7 @@ import type {
} from "./private-room-machine";
export interface PrivateRoomContextState {
characterId: string;
status: string;
items: MachineContext["items"];
creditBalance: number;
@@ -31,11 +34,17 @@ const PrivateRoomActorContext = createActorContext(privateRoomMachine);
export interface PrivateRoomProviderProps {
children: ReactNode;
characterId?: string;
}
export function PrivateRoomProvider({ children }: PrivateRoomProviderProps) {
export function PrivateRoomProvider({
children,
characterId = DEFAULT_CHARACTER_ID,
}: PrivateRoomProviderProps) {
return (
<PrivateRoomActorContext.Provider>{children}</PrivateRoomActorContext.Provider>
<PrivateRoomActorContext.Provider options={{ input: { characterId } }}>
{children}
</PrivateRoomActorContext.Provider>
);
}
@@ -58,6 +67,7 @@ function selectPrivateRoomState(
state: PrivateRoomSnapshot,
): PrivateRoomContextState {
return {
characterId: state.context.characterId,
status: String(state.value),
items: state.context.items,
creditBalance: state.context.creditBalance,
@@ -2,7 +2,9 @@ import {
privateRoomMachineSetup,
privateRoomRootStateConfig,
} from "./machine/unlock-flow";
import { initialState } from "./private-room-state";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { createInitialPrivateRoomState } from "./private-room-state";
export type { PrivateRoomEvent } from "./private-room-events";
export type { PrivateRoomState } from "./private-room-state";
@@ -10,7 +12,10 @@ export { initialState } from "./private-room-state";
export const privateRoomMachine = privateRoomMachineSetup.createMachine({
id: "privateRoom",
context: initialState,
context: ({ input }) =>
createInitialPrivateRoomState(
input?.characterId ?? DEFAULT_CHARACTER_ID,
),
...privateRoomRootStateConfig,
});
+19 -10
View File
@@ -1,4 +1,5 @@
import type { PrivateAlbum } from "@/data/dto/private-room";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
export interface PrivateRoomUnlockPaywallRequest {
albumId: string;
@@ -9,6 +10,7 @@ export interface PrivateRoomUnlockPaywallRequest {
}
export interface PrivateRoomState {
characterId: string;
items: PrivateAlbum[];
creditBalance: number;
errorMessage: string | null;
@@ -19,13 +21,20 @@ export interface PrivateRoomState {
unlockSuccessNonce: number;
}
export const initialState: PrivateRoomState = {
items: [],
creditBalance: 0,
errorMessage: null,
unlockingAlbumId: null,
unlockErrorMessage: null,
pendingConfirmAlbumId: null,
unlockPaywallRequest: null,
unlockSuccessNonce: 0,
};
export function createInitialPrivateRoomState(
characterId = DEFAULT_CHARACTER_ID,
): PrivateRoomState {
return {
characterId,
items: [],
creditBalance: 0,
errorMessage: null,
unlockingAlbumId: null,
unlockErrorMessage: null,
pendingConfirmAlbumId: null,
unlockPaywallRequest: null,
unlockSuccessNonce: 0,
};
}
export const initialState = createInitialPrivateRoomState();