diff --git a/src/data/repositories/chat_local_message_store.ts b/src/data/repositories/chat_local_message_store.ts new file mode 100644 index 00000000..4dc96611 --- /dev/null +++ b/src/data/repositories/chat_local_message_store.ts @@ -0,0 +1,108 @@ +"use client"; + +import type { ChatLockDetailData } from "@/data/schemas/chat"; +import { ChatMessage } from "@/data/dto/chat"; +import { LocalChatStorage, LocalMessage } from "@/data/storage/chat"; +import { Result } from "@/utils"; + +export class ChatLocalMessageStore { + constructor(private readonly localStorage: LocalChatStorage) {} + + async markMessageUnlocked( + messageId: string, + content: string, + lockDetail?: ChatLockDetailData, + ): Promise> { + return Result.wrap(async () => { + const localResult = await this.getMessages(); + if (Result.isErr(localResult)) { + throw localResult.error; + } + + let changed = false; + const updatedMessages = localResult.data.map((message) => { + if (message.id !== messageId) return message; + changed = true; + return ChatMessage.from({ + ...message.toJson(), + content, + lockDetail: lockDetail ?? { + ...message.lockDetail, + locked: false, + showContent: true, + showUpgrade: false, + reason: null, + hint: null, + detail: null, + }, + }); + }); + + if (!changed) return; + + const saveResult = await this.saveMessages(updatedMessages); + if (Result.isErr(saveResult)) { + throw saveResult.error; + } + }); + } + + async saveMessage(message: ChatMessage): Promise> { + return this.localStorage.saveMessage(this._chatToLocal(message)); + } + + async saveMessages( + messages: readonly ChatMessage[], + ): Promise> { + const cleared = await this.localStorage.clearAll(); + if (!cleared.success) { + return Result.err(cleared.error); + } + return this.localStorage.saveMessages( + messages.map((message) => this._chatToLocal(message)), + ); + } + + async getMessages(): Promise> { + const result = await this.localStorage.getAllMessages(); + if (!result.success) { + return Result.err(result.error); + } + return Result.ok(result.data.map((message) => this._localToChat(message))); + } + + async clearMessages(): Promise> { + return this.localStorage.clearAll(); + } + + async getMessageCount(): Promise> { + return this.localStorage.getMessageCount(); + } + + private _chatToLocal(message: ChatMessage): LocalMessage { + return LocalMessage.from({ + id: message.id, + role: message.role, + type: message.type, + content: message.content, + createdAt: message.createdAt, + audioUrl: message.audioUrl, + image: message.image, + lockDetail: message.lockDetail, + sessionId: "", + }); + } + + private _localToChat(local: LocalMessage): ChatMessage { + return ChatMessage.from({ + id: local.id, + role: local.role, + type: local.type, + content: local.content, + createdAt: local.createdAt, + audioUrl: local.audioUrl, + image: local.image, + lockDetail: local.lockDetail, + }); + } +} diff --git a/src/data/repositories/chat_media_cache_coordinator.ts b/src/data/repositories/chat_media_cache_coordinator.ts new file mode 100644 index 00000000..132a7c04 --- /dev/null +++ b/src/data/repositories/chat_media_cache_coordinator.ts @@ -0,0 +1,169 @@ +"use client"; + +import type { ChatMessage, ChatSendResponse } from "@/data/dto/chat"; +import { + LocalChatMediaStorage, + type LocalChatMediaRow, +} from "@/data/storage/chat"; +import { AuthStorage } from "@/data/storage/auth"; +import { UserStorage } from "@/data/storage/user/user_storage"; +import { requestBrowserPersistentStorageOnce } from "@/lib/browser/persistent_storage"; +import { Logger, Result } from "@/utils"; +import type { + CacheRemoteChatMediaInput, + ChatMediaLookupInput, +} from "@/data/repositories/interfaces"; + +import { + buildChatMediaCacheKey, + fallbackChatMediaMimeType, + getMessageMediaTargets, + getSendResponseMediaTargets, + isCacheableRemoteChatMediaUrl, + uniqueMediaTargets, +} from "./chat_media_cache_helpers"; + +const log = new Logger("DataRepositoriesChatMediaCacheCoordinator"); + +export class ChatMediaCacheCoordinator { + constructor(private readonly mediaStorage: LocalChatMediaStorage) {} + + async getCachedMedia( + input: ChatMediaLookupInput, + ): Promise> { + return Result.wrap(async () => { + const ownerKeys = await this._resolveMediaOwnerKeys(); + for (const ownerKey of ownerKeys) { + const cacheKey = this._buildMediaCacheKey(ownerKey, input); + const result = await this.mediaStorage.getMedia(cacheKey); + if (Result.isErr(result)) throw result.error; + if (result.data) return result.data; + } + return null; + }); + } + + async cacheRemoteMedia( + input: CacheRemoteChatMediaInput, + ): Promise> { + return Result.wrap(async () => { + if (!isCacheableRemoteChatMediaUrl(input.remoteUrl)) { + throw new Error(`Unsupported media url: ${input.remoteUrl}`); + } + + const ownerKeys = await this._resolveMediaOwnerKeys(); + const ownerKey = ownerKeys[0] ?? "anonymous"; + const cacheKey = this._buildMediaCacheKey(ownerKey, input); + for (const lookupOwnerKey of ownerKeys) { + const lookupCacheKey = this._buildMediaCacheKey(lookupOwnerKey, input); + const existingResult = await this.mediaStorage.getMedia(lookupCacheKey); + if (Result.isErr(existingResult)) throw existingResult.error; + if (existingResult.data) return existingResult.data; + } + + const response = await fetch(input.remoteUrl, { + method: "GET", + cache: "force-cache", + }); + if (!response.ok) { + throw new Error( + `Media download failed: ${response.status} ${response.statusText}`, + ); + } + + const blob = await response.blob(); + if (blob.size <= 0) { + throw new Error("Media download returned an empty blob."); + } + + const saveResult = await this.mediaStorage.saveMedia({ + cacheKey, + ownerKey, + messageId: input.messageId, + kind: input.kind, + remoteUrl: input.remoteUrl, + blob, + mimeType: + blob.type || + response.headers.get("content-type") || + fallbackChatMediaMimeType(input.kind), + }); + if (Result.isErr(saveResult)) throw saveResult.error; + void requestBrowserPersistentStorageOnce(); + + const savedResult = await this.mediaStorage.getMedia(cacheKey); + if (Result.isErr(savedResult)) throw savedResult.error; + if (!savedResult.data) { + throw new Error("Cached media could not be read after save."); + } + return savedResult.data; + }); + } + + async prefetchMediaForMessages( + messages: readonly ChatMessage[], + ): Promise> { + return this._prefetchMediaTargets( + messages.flatMap((message) => getMessageMediaTargets(message)), + ); + } + + async prefetchMediaForSendResponse( + response: ChatSendResponse, + ): Promise> { + return this._prefetchMediaTargets(getSendResponseMediaTargets(response)); + } + + private async _prefetchMediaTargets( + targets: readonly CacheRemoteChatMediaInput[], + ): Promise> { + return Result.wrap(async () => { + for (const target of uniqueMediaTargets(targets)) { + const result = await this.cacheRemoteMedia(target); + if (Result.isErr(result)) { + log.warn("[chat-media] prefetch failed", { + messageId: target.messageId, + kind: target.kind, + remoteUrl: target.remoteUrl, + error: result.error, + }); + } + } + }); + } + + private async _resolveMediaOwnerKeys(): Promise { + const ownerKeys: string[] = []; + const userIdResult = await UserStorage.getInstance().getUserId(); + if ( + Result.isOk(userIdResult) && + userIdResult.data !== null && + userIdResult.data.length > 0 + ) { + ownerKeys.push(`user:${userIdResult.data}`); + } + + const deviceIdResult = await AuthStorage.getInstance().getDeviceId(); + if ( + Result.isOk(deviceIdResult) && + deviceIdResult.data !== null && + deviceIdResult.data.length > 0 + ) { + ownerKeys.push(`device:${deviceIdResult.data}`); + } + + ownerKeys.push("anonymous"); + return Array.from(new Set(ownerKeys)); + } + + private _buildMediaCacheKey( + ownerKey: string, + input: ChatMediaLookupInput, + ): string { + return buildChatMediaCacheKey({ + ownerKey, + messageId: input.messageId, + kind: input.kind, + }); + } +} diff --git a/src/data/repositories/chat_remote_data_source.ts b/src/data/repositories/chat_remote_data_source.ts new file mode 100644 index 00000000..b047adc4 --- /dev/null +++ b/src/data/repositories/chat_remote_data_source.ts @@ -0,0 +1,49 @@ +"use client"; + +import { + ChatHistoryResponse, + ChatSendResponse, + SendMessageRequest, + UnlockHistoryResponse, + UnlockPrivateRequest, + UnlockPrivateResponse, +} from "@/data/dto/chat"; +import type { ChatApi } from "@/data/services/api"; +import { Result } from "@/utils"; + +export class ChatRemoteDataSource { + constructor(private readonly api: ChatApi) {} + + async sendMessage( + message: string, + options?: { image?: string; useWebSocket?: boolean }, + ): Promise> { + return Result.wrap(async () => { + const request = SendMessageRequest.from({ + message, + image: options?.image ?? "", + useWebSocket: options?.useWebSocket ?? false, + }); + return await this.api.sendMessage(request); + }); + } + + async getHistory( + limit = 50, + offset = 0, + ): Promise> { + return Result.wrap(() => this.api.getHistory(limit, offset)); + } + + async unlockPrivateMessage( + messageId: string, + ): Promise> { + return Result.wrap(() => + this.api.unlockPrivateMessage(UnlockPrivateRequest.from({ messageId })), + ); + } + + async unlockHistory(): Promise> { + return Result.wrap(() => this.api.unlockHistory()); + } +} diff --git a/src/data/repositories/chat_repository.ts b/src/data/repositories/chat_repository.ts index 9f585500..0d011a2c 100644 --- a/src/data/repositories/chat_repository.ts +++ b/src/data/repositories/chat_repository.ts @@ -1,48 +1,36 @@ "use client"; -import { ChatApi, chatApi } from "@/data/services/api"; -import { +import type { ChatHistoryResponse, ChatMessage, ChatSendResponse, - SendMessageRequest, UnlockHistoryResponse, - UnlockPrivateRequest, UnlockPrivateResponse, } from "@/data/dto/chat"; import type { ChatLockDetailData } from "@/data/schemas/chat"; -import { AuthStorage } from "@/data/storage/auth"; -import { UserStorage } from "@/data/storage/user/user_storage"; -import { Logger, Result } from "@/utils"; +import { chatApi } from "@/data/services/api"; +import { + LocalChatMediaStorage, + LocalChatStorage, + type LocalChatMediaRow, +} from "@/data/storage/chat"; import type { CacheRemoteChatMediaInput, ChatMediaLookupInput, IChatRepository, } from "@/data/repositories/interfaces"; -import { - LocalChatMediaStorage, - LocalChatStorage, - LocalMessage, - type LocalChatMediaRow, -} from "@/data/storage/chat"; -import { - buildChatMediaCacheKey, - fallbackChatMediaMimeType, - getMessageMediaTargets, - getSendResponseMediaTargets, - isCacheableRemoteChatMediaUrl, - uniqueMediaTargets, -} from "./chat_media_cache_helpers"; -import { createLazySingleton } from "./lazy_singleton"; -import { requestBrowserPersistentStorageOnce } from "@/lib/browser/persistent_storage"; +import type { Result } from "@/utils"; -const log = new Logger("DataRepositoriesChatRepository"); +import { ChatLocalMessageStore } from "./chat_local_message_store"; +import { ChatMediaCacheCoordinator } from "./chat_media_cache_coordinator"; +import { ChatRemoteDataSource } from "./chat_remote_data_source"; +import { createLazySingleton } from "./lazy_singleton"; export class ChatRepository implements IChatRepository { constructor( - private readonly api: ChatApi, - private readonly localStorage: LocalChatStorage, - private readonly mediaStorage: LocalChatMediaStorage, + private readonly remote: ChatRemoteDataSource, + private readonly localMessages: ChatLocalMessageStore, + private readonly mediaCache: ChatMediaCacheCoordinator, ) {} // ============ 远程操作 ============ @@ -52,14 +40,7 @@ export class ChatRepository implements IChatRepository { message: string, options?: { image?: string; useWebSocket?: boolean }, ): Promise> { - return Result.wrap(async () => { - const request = SendMessageRequest.from({ - message, - image: options?.image ?? "", - useWebSocket: options?.useWebSocket ?? false, - }); - return await this.api.sendMessage(request); - }); + return this.remote.sendMessage(message, options); } /** 获取聊天历史,分页参数默认 limit=50, offset=0。 */ @@ -67,21 +48,19 @@ export class ChatRepository implements IChatRepository { limit = 50, offset = 0, ): Promise> { - return Result.wrap(() => this.api.getHistory(limit, offset)); + return this.remote.getHistory(limit, offset); } /** 解锁单条历史付费 / 私密消息。 */ async unlockPrivateMessage( messageId: string, ): Promise> { - return Result.wrap(() => - this.api.unlockPrivateMessage(UnlockPrivateRequest.from({ messageId })), - ); + return this.remote.unlockPrivateMessage(messageId); } /** 一键解锁历史锁定消息。 */ async unlockHistory(): Promise> { - return Result.wrap(() => this.api.unlockHistory()); + return this.remote.unlockHistory(); } /** 把本地缓存中的单条锁定消息标记为已解锁。 */ @@ -90,47 +69,18 @@ export class ChatRepository implements IChatRepository { content: string, lockDetail?: ChatLockDetailData, ): Promise> { - return Result.wrap(async () => { - const localResult = await this.getLocalMessages(); - if (Result.isErr(localResult)) { - throw localResult.error; - } - - let changed = false; - const updatedMessages = localResult.data.map((message) => { - if (message.id !== messageId) return message; - changed = true; - return ChatMessage.from({ - ...message.toJson(), - content, - lockDetail: lockDetail ?? { - ...message.lockDetail, - locked: false, - showContent: true, - showUpgrade: false, - reason: null, - hint: null, - detail: null, - }, - }); - }); - - if (!changed) return; - - const saveResult = await this.saveMessagesToLocal(updatedMessages); - if (Result.isErr(saveResult)) { - throw saveResult.error; - } - }); + return this.localMessages.markMessageUnlocked( + messageId, + content, + lockDetail, + ); } // ============ 本地(Dexie)操作 ============ /** 把一条消息写入本地存储。 */ async saveMessageToLocal(message: ChatMessage): Promise> { - return this._mapLocalStorageResult( - this.localStorage.saveMessage(this._chatToLocal(message)), - ); + return this.localMessages.saveMessage(message); } /** @@ -140,27 +90,17 @@ export class ChatRepository implements IChatRepository { async saveMessagesToLocal( messages: readonly ChatMessage[], ): Promise> { - const cleared = await this.localStorage.clearAll(); - if (!cleared.success) { - return Result.err(cleared.error); - } - return this._mapLocalStorageResult( - this.localStorage.saveMessages(messages.map((m) => this._chatToLocal(m))), - ); + return this.localMessages.saveMessages(messages); } /** 读取所有本地消息,按 dbId 升序。 */ async getLocalMessages(): Promise> { - const result = await this.localStorage.getAllMessages(); - if (!result.success) { - return Result.err(result.error); - } - return Result.ok(result.data.map((lm) => this._localToChat(lm))); + return this.localMessages.getMessages(); } /** 清空本地消息。 */ async clearLocalMessages(): Promise> { - return this._mapLocalStorageResult(this.localStorage.clearAll()); + return this.localMessages.clearMessages(); } /** @@ -168,7 +108,7 @@ export class ChatRepository implements IChatRepository { * 替代 Dart 的同步 `int get localMessageCount`——Dexie 异步 API 决定必须 async。 */ async getLocalMessageCount(): Promise> { - return this._mapLocalStorageResult(this.localStorage.getMessageCount()); + return this.localMessages.getMessageCount(); } // ============ 本地媒体缓存 ============ @@ -176,182 +116,25 @@ export class ChatRepository implements IChatRepository { async getCachedMedia( input: ChatMediaLookupInput, ): Promise> { - return Result.wrap(async () => { - const ownerKeys = await this._resolveMediaOwnerKeys(); - for (const ownerKey of ownerKeys) { - const cacheKey = this._buildMediaCacheKey(ownerKey, input); - const result = await this.mediaStorage.getMedia(cacheKey); - if (Result.isErr(result)) throw result.error; - if (result.data) return result.data; - } - return null; - }); + return this.mediaCache.getCachedMedia(input); } async cacheRemoteMedia( input: CacheRemoteChatMediaInput, ): Promise> { - return Result.wrap(async () => { - if (!isCacheableRemoteChatMediaUrl(input.remoteUrl)) { - throw new Error(`Unsupported media url: ${input.remoteUrl}`); - } - - const ownerKeys = await this._resolveMediaOwnerKeys(); - const ownerKey = ownerKeys[0] ?? "anonymous"; - const cacheKey = this._buildMediaCacheKey(ownerKey, input); - for (const lookupOwnerKey of ownerKeys) { - const lookupCacheKey = this._buildMediaCacheKey(lookupOwnerKey, input); - const existingResult = await this.mediaStorage.getMedia(lookupCacheKey); - if (Result.isErr(existingResult)) throw existingResult.error; - if (existingResult.data) return existingResult.data; - } - - const response = await fetch(input.remoteUrl, { - method: "GET", - cache: "force-cache", - }); - if (!response.ok) { - throw new Error( - `Media download failed: ${response.status} ${response.statusText}`, - ); - } - - const blob = await response.blob(); - if (blob.size <= 0) { - throw new Error("Media download returned an empty blob."); - } - - const saveResult = await this.mediaStorage.saveMedia({ - cacheKey, - ownerKey, - messageId: input.messageId, - kind: input.kind, - remoteUrl: input.remoteUrl, - blob, - mimeType: - blob.type || - response.headers.get("content-type") || - fallbackChatMediaMimeType(input.kind), - }); - if (Result.isErr(saveResult)) throw saveResult.error; - void requestBrowserPersistentStorageOnce(); - - const savedResult = await this.mediaStorage.getMedia(cacheKey); - if (Result.isErr(savedResult)) throw savedResult.error; - if (!savedResult.data) { - throw new Error("Cached media could not be read after save."); - } - return savedResult.data; - }); + return this.mediaCache.cacheRemoteMedia(input); } async prefetchMediaForMessages( messages: readonly ChatMessage[], ): Promise> { - return this._prefetchMediaTargets( - messages.flatMap((message) => getMessageMediaTargets(message)), - ); + return this.mediaCache.prefetchMediaForMessages(messages); } async prefetchMediaForSendResponse( response: ChatSendResponse, ): Promise> { - return this._prefetchMediaTargets(getSendResponseMediaTargets(response)); - } - - // ============ 私有助手 ============ - - /** ChatMessage → LocalMessage:丢弃 wire 字段,注入 storage 层的 sessionId=""。 */ - private _chatToLocal(message: ChatMessage): LocalMessage { - return LocalMessage.from({ - id: message.id, - role: message.role, - type: message.type, - content: message.content, - createdAt: message.createdAt, - audioUrl: message.audioUrl, - image: message.image, - lockDetail: message.lockDetail, - sessionId: "", - }); - } - - /** LocalMessage → ChatMessage:丢弃 storage 层的 sessionId。 */ - private _localToChat(local: LocalMessage): ChatMessage { - return ChatMessage.from({ - id: local.id, - role: local.role, - type: local.type, - content: local.content, - createdAt: local.createdAt, - audioUrl: local.audioUrl, - image: local.image, - lockDetail: local.lockDetail, - }); - } - - /** - * 把 storage 层 `Result` 透传:仓库与 storage 现在共享同一全局 - * `Result`(来自 `@/utils/result`),不需要任何形状转换。 - * 该方法保留仅为未来万一又出现差异化时方便快速插入适配。 - */ - private async _mapLocalStorageResult( - promise: Promise>, - ): Promise> { - return await promise; - } - - private async _prefetchMediaTargets( - targets: readonly CacheRemoteChatMediaInput[], - ): Promise> { - return Result.wrap(async () => { - for (const target of uniqueMediaTargets(targets)) { - const result = await this.cacheRemoteMedia(target); - if (Result.isErr(result)) { - log.warn("[chat-media] prefetch failed", { - messageId: target.messageId, - kind: target.kind, - remoteUrl: target.remoteUrl, - error: result.error, - }); - } - } - }); - } - - private async _resolveMediaOwnerKeys(): Promise { - const ownerKeys: string[] = []; - const userIdResult = await UserStorage.getInstance().getUserId(); - if ( - Result.isOk(userIdResult) && - userIdResult.data !== null && - userIdResult.data.length > 0 - ) { - ownerKeys.push(`user:${userIdResult.data}`); - } - - const deviceIdResult = await AuthStorage.getInstance().getDeviceId(); - if ( - Result.isOk(deviceIdResult) && - deviceIdResult.data !== null && - deviceIdResult.data.length > 0 - ) { - ownerKeys.push(`device:${deviceIdResult.data}`); - } - - ownerKeys.push("anonymous"); - return Array.from(new Set(ownerKeys)); - } - - private _buildMediaCacheKey( - ownerKey: string, - input: ChatMediaLookupInput, - ): string { - return buildChatMediaCacheKey({ - ownerKey, - messageId: input.messageId, - kind: input.kind, - }); + return this.mediaCache.prefetchMediaForSendResponse(response); } } @@ -359,8 +142,8 @@ export class ChatRepository implements IChatRepository { export const getChatRepository = createLazySingleton( () => new ChatRepository( - chatApi, - LocalChatStorage.getInstance(), - LocalChatMediaStorage.getInstance(), + new ChatRemoteDataSource(chatApi), + new ChatLocalMessageStore(LocalChatStorage.getInstance()), + new ChatMediaCacheCoordinator(LocalChatMediaStorage.getInstance()), ), ); diff --git a/src/stores/chat/chat-flow-actions.ts b/src/stores/chat/chat-flow-actions.ts new file mode 100644 index 00000000..e83aef44 --- /dev/null +++ b/src/stores/chat/chat-flow-actions.ts @@ -0,0 +1,13 @@ +import { assign as xAssign } from "xstate"; + +import type { ChatEvent } from "./chat-events"; +import type { ChatState } from "./chat-state"; + +export type ChatActionArgs = { context: ChatState; event: ChatEvent }; +export type ChatContextArgs = { context: ChatState }; + +export const chatAssign = ( + assignment: Parameters< + typeof xAssign + >[0], +) => xAssign(assignment); diff --git a/src/stores/chat/chat-history-flow.ts b/src/stores/chat/chat-history-flow.ts new file mode 100644 index 00000000..99a3db85 --- /dev/null +++ b/src/stores/chat/chat-history-flow.ts @@ -0,0 +1,51 @@ +import { fromPromise } from "xstate"; + +import { getChatRepository } from "@/data/repositories/chat_repository"; +import { Logger, Result } from "@/utils"; + +import { + PAGE_SIZE, + localMessagesToUi, + readAndSyncHistory, +} from "./chat-machine.helpers"; + +const log = new Logger("StoresChatChatHistoryFlow"); + +type UiMessage = import("@/data/dto/chat").UiMessage; + +export const chatHistoryActors = { + loadHistory: fromPromise<{ + messages: UiMessage[]; + hasMore: boolean; + newOffset: number; + localOverwritten: boolean; + localCount: number; + networkCount: number; + }>(async () => { + return readAndSyncHistory(); + }), + + loadMoreHistory: fromPromise< + { messages: UiMessage[]; hasMore: boolean; newOffset: number }, + { offset: number } + >(async ({ input }) => { + const chatRepo = getChatRepository(); + const result = await chatRepo.getHistory(PAGE_SIZE, input.offset); + if (Result.isErr(result)) { + log.error("[chat-machine] loadMoreHistoryActor failed", { + error: result.error, + }); + throw result.error; + } + const page = localMessagesToUi(result.data.messages); + void chatRepo.prefetchMediaForMessages(result.data.messages); + return { + messages: page, + hasMore: page.length >= PAGE_SIZE, + newOffset: input.offset + page.length, + }; + }), +}; + +export const loadHistoryActor = chatHistoryActors.loadHistory; +export const loadMoreHistoryActor = chatHistoryActors.loadMoreHistory; diff --git a/src/stores/chat/chat-machine.actors.ts b/src/stores/chat/chat-machine.actors.ts index b8858553..7e57e31a 100644 --- a/src/stores/chat/chat-machine.actors.ts +++ b/src/stores/chat/chat-machine.actors.ts @@ -1,227 +1,24 @@ /** - * Chat 状态机:history / send / queue actors + * Backward-compatible actor exports. + * + * Concrete implementations live in capability flow files: + * - chat-history-flow + * - chat-send-flow + * - chat-unlock-flow */ - -import { fromPromise, fromCallback } from "xstate"; - -import { MessageQueue } from "@/core/net/message-queue"; -import type { ChatLockDetailData } from "@/data/schemas/chat"; -import type { ChatSendResponse, UnlockPrivateResponse } from "@/data/dto/chat"; -import { getChatRepository } from "@/data/repositories/chat_repository"; -import { Result, Logger } from "@/utils"; - -import { - PAGE_SIZE, - localMessagesToUi, - readAndSyncHistory, - sendResponseToUiMessage, -} from "./chat-machine.helpers"; -import type { ChatEvent } from "./chat-events"; - -const log = new Logger("StoresChatChatMachineActors"); - -// ============================================================ -// Init 任务 2:拉 history 3 步流(fromPromise,返回最终结果) -// ============================================================ -/** - * local → network → save network to local 3 步流(返回 network 最终 messages) - * - 选方案 A(fromPromise):UI 不会看到 local 中间态,直接显示 network 结果 - * - 内部 3 步走 helper `readAndSyncHistory`(日志全在 helper 里) - * - 失败返 local(退而求其次) - */ -export const loadHistoryActor = fromPromise<{ - messages: UiMessage[]; - hasMore: boolean; - newOffset: number; - localOverwritten: boolean; - localCount: number; - networkCount: number; -}>(async () => { - return readAndSyncHistory(); -}); - -// ============================================================ -// HTTP: one-shot Promise -// ============================================================ -/** - * HTTP 发送消息(一次发送 = 一次返回) - * - 后端响应就是 AI 回复(`ChatSendResponse.reply: string`) - * - 不再调 `getLocalMessages()`(多此一举) - * - 返回完整 response + 可追加的 reply,方便处理后端 lockDetail 响应 - */ -export const sendMessageHttpActor = fromPromise< - { response: ChatSendResponse; reply: UiMessage | null }, - { content: string } ->(async ({ input }) => { - return sendMessageViaHttp(input.content); -}); - -/** 翻历史(pagination)—— 不走 local-first,纯 server fetch */ -export const loadMoreHistoryActor = fromPromise< - { messages: UiMessage[]; hasMore: boolean; newOffset: number }, - { offset: number } ->(async ({ input }) => { - const chatRepo = getChatRepository(); - const result = await chatRepo.getHistory(PAGE_SIZE, input.offset); - if (Result.isErr(result)) { - log.error("[chat-machine] loadMoreHistoryActor failed", { error: result.error }); - throw result.error; - } - const page = localMessagesToUi(result.data.messages); - void chatRepo.prefetchMediaForMessages(result.data.messages); - return { - messages: page, - hasMore: page.length >= PAGE_SIZE, - newOffset: input.offset + page.length, - }; -}); - -export const unlockHistoryActor = fromPromise<{ - unlocked: boolean; - reason: string; - shortfallCredits: number; - messages: UiMessage[]; - hasMore: boolean; - newOffset: number; -}>(async () => { - const chatRepo = getChatRepository(); - const unlockResult = await chatRepo.unlockHistory(); - if (Result.isErr(unlockResult)) { - log.error("[chat-machine] unlockHistoryActor failed", { - error: unlockResult.error, - }); - throw unlockResult.error; - } - - const history = await readAndSyncHistory(); - return { - unlocked: unlockResult.data.unlocked, - reason: unlockResult.data.reason, - shortfallCredits: unlockResult.data.shortfallCredits, - messages: history.messages, - hasMore: history.hasMore, - newOffset: history.newOffset, - }; -}); - -export interface UnlockMessageOutput { - messageId: string; - response: UnlockPrivateResponse; -} - -export const unlockMessageActor = fromPromise< - UnlockMessageOutput, - { messageId: string } ->(async ({ input }) => { - const chatRepo = getChatRepository(); - const unlockResult = await chatRepo.unlockPrivateMessage(input.messageId); - if (Result.isErr(unlockResult)) { - log.error("[chat-machine] unlockMessageActor failed", { - messageId: input.messageId, - error: unlockResult.error, - }); - throw unlockResult.error; - } - - if (unlockResult.data.unlocked) { - const markResult = await chatRepo.markPrivateMessageUnlockedInLocal( - input.messageId, - unlockResult.data.content, - unlockResult.data.lockDetail as ChatLockDetailData, - ); - if (Result.isErr(markResult)) { - log.warn("[chat-machine] mark unlocked local message failed", { - messageId: input.messageId, - error: markResult.error, - }); - } - } - - return { - messageId: input.messageId, - response: unlockResult.data, - }; -}); - -export const httpMessageQueueActor = fromCallback( - ({ sendBack, receive }) => { - return createMessageQueueActor(sendBack, receive); - }, -); - -function createMessageQueueActor( - sendBack: (event: ChatEvent) => void, - receive: (listener: (event: ChatEvent) => void) => void, -): () => void { - const queue = new MessageQueue(); - - queue.setConsumer(async (content) => { - sendBack({ type: "ChatQueuedSendStarted" }); - try { - const output = await sendMessageViaHttp(content); - sendBack({ type: "ChatQueuedHttpDone", output }); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "Message send failed"; - log.error("[chat-machine] message queue send failed", { - error, - }); - sendBack({ - type: "ChatQueuedSendError", - content, - errorMessage, - }); - } - }); - - receive((event) => { - if (event.type !== "ChatSendMessage") return; - const content = event.content.trim(); - if (content.length === 0) return; - queue.enqueue(content); - }); - - return () => queue.dispose(); -} - -// Re-export `UiMessage` type for the chat-machine.ts public API -type UiMessage = import("@/data/dto/chat").UiMessage; - -async function sendMessageViaHttp(content: string): Promise<{ - response: ChatSendResponse; - reply: UiMessage | null; -}> { - const chatRepo = getChatRepository(); - const result = await chatRepo.sendMessage(content); - if (Result.isErr(result)) { - log.error("[chat-machine] sendMessageHttpActor failed", { error: result.error }); - throw result.error; - } - void chatRepo.prefetchMediaForSendResponse(result.data); - const isMessageLimit = - result.data.lockDetail.locked && - result.data.lockDetail.showUpgrade && - result.data.lockDetail.reason === "weekly_limit"; - const isInsufficientCredits = - result.data.canSendMessage === false && - result.data.cannotSendReason === "insufficient_credits" && - !hasRenderableSendResponse(result.data); - return { - response: result.data, - reply: - isMessageLimit || isInsufficientCredits - ? null - : sendResponseToUiMessage(result.data), - }; -} - -function hasRenderableSendResponse(response: ChatSendResponse): boolean { - return ( - response.reply.trim().length > 0 || - response.audioUrl.trim().length > 0 || - Boolean(response.image.url) || - response.lockDetail.reason === "private_message" || - response.lockDetail.reason === "voice_message" || - response.lockDetail.reason === "image_paywall" - ); -} +export { + chatHistoryActors, + loadHistoryActor, + loadMoreHistoryActor, +} from "./chat-history-flow"; +export { + chatSendActors, + httpMessageQueueActor, + sendMessageHttpActor, +} from "./chat-send-flow"; +export { + chatUnlockActors, + unlockHistoryActor, + unlockMessageActor, + type UnlockMessageOutput, +} from "./chat-unlock-flow"; diff --git a/src/stores/chat/chat-machine.helpers.ts b/src/stores/chat/chat-machine.helpers.ts index 478c7580..2906bfcb 100644 --- a/src/stores/chat/chat-machine.helpers.ts +++ b/src/stores/chat/chat-machine.helpers.ts @@ -1,5 +1,4 @@ -import type { UiMessage } from "@/data/dto/chat"; -import type { UnlockMessageOutput } from "./chat-machine.actors"; +import type { UiMessage, UnlockPrivateResponse } from "@/data/dto/chat"; import { getChatRepository } from "@/data/repositories/chat_repository"; import { ChatSendResponse } from "@/data/dto/chat/chat_send_response"; import { todayString, Result, Logger } from "@/utils"; @@ -143,6 +142,11 @@ export type HttpSendOutput = { reply: UiMessage | null; }; +export interface UnlockMessageOutput { + messageId: string; + response: UnlockPrivateResponse; +} + export function applySingleUnlockOutput( messages: readonly UiMessage[], output: UnlockMessageOutput, diff --git a/src/stores/chat/chat-machine.ts b/src/stores/chat/chat-machine.ts index fb602f0c..673015af 100644 --- a/src/stores/chat/chat-machine.ts +++ b/src/stores/chat/chat-machine.ts @@ -29,31 +29,19 @@ import { setup, assign, sendTo } from "xstate"; -import { todayString, Logger } from "@/utils"; - import { ChatState, initialState } from "./chat-state"; import type { ChatEvent } from "./chat-events"; import { applyHttpSendOutput, applyHistoryLoadedOutput, - applySingleUnlockOutput, - beginPendingReply, countLockedHistoryMessages, - finishPendingReply, shouldAutoUnlockHistory, shouldPromptUnlockHistory, } from "./chat-machine.helpers"; -import { - loadHistoryActor, - sendMessageHttpActor, - loadMoreHistoryActor, - httpMessageQueueActor, - unlockHistoryActor, - unlockMessageActor, - type UnlockMessageOutput, -} from "./chat-machine.actors"; - -const log = new Logger("StoresChatChatMachine"); +import { chatHistoryActors } from "./chat-history-flow"; +import { chatMediaActions } from "./chat-media-flow"; +import { chatSendActions, chatSendActors } from "./chat-send-flow"; +import { chatUnlockActions, chatUnlockActors } from "./chat-unlock-flow"; // 重新导出 State / Event 类型,保持 machine 文件的公共 API 不变 export type { ChatState } from "./chat-state"; @@ -69,14 +57,14 @@ export const chatMachine = setup({ events: {} as ChatEvent, }, actors: { - loadHistory: loadHistoryActor, - sendMessageHttp: sendMessageHttpActor, - loadMoreHistory: loadMoreHistoryActor, - httpMessageQueue: httpMessageQueueActor, - unlockHistory: unlockHistoryActor, - unlockMessage: unlockMessageActor, + ...chatHistoryActors, + ...chatSendActors, + ...chatUnlockActors, }, actions: { + ...chatSendActions, + ...chatMediaActions, + ...chatUnlockActions, enqueueMessage: sendTo("messageQueue", ({ event }) => event), startGuestSession: assign(() => ({ @@ -90,234 +78,6 @@ export const chatMachine = setup({ clearChatSession: assign(() => ({ ...initialState, })), - - appendGuestUserMessage: assign(({ context, event }) => { - if (event.type !== "ChatSendMessage") return {}; - - const today = todayString(); - log.debug("[chat-machine] appendGuestUserMessage", { - contentLength: event.content.length, - contentPreview: event.content.slice(0, 50), - quotaMode: "server controlled", - isReplyingAI: context.isReplyingAI, - }); - - return { - messages: [ - ...context.messages, - { - content: event.content, - isFromAI: false, - date: today, - }, - ], - upgradePromptVisible: false, - upgradeReason: null, - }; - }), - - appendUserMessage: assign(({ context, event }) => { - if (event.type !== "ChatSendMessage") return {}; - - const today = todayString(); - log.debug("[chat-machine] appendUserMessage", { - contentLength: event.content.length, - contentPreview: event.content.slice(0, 50), - isReplyingAI: context.isReplyingAI, - }); - - return { - messages: [ - ...context.messages, - { - content: event.content, - isFromAI: false, - date: today, - }, - ], - upgradePromptVisible: false, - upgradeReason: null, - }; - }), - - appendGuestUserImage: assign(({ context, event }) => { - if (event.type !== "ChatSendImage") return {}; - - const today = todayString(); - log.debug("[chat-machine] appendGuestUserImage", { - oldMessagesCount: context.messages.length, - quotaMode: "server controlled", - }); - return { - messages: [ - ...context.messages, - { - content: "[Image]", - isFromAI: false, - date: today, - imageUrl: event.imageBase64, - }, - ], - ...beginPendingReply(context), - upgradePromptVisible: false, - upgradeReason: null, - }; - }), - - appendUserImage: assign(({ context, event }) => { - if (event.type !== "ChatSendImage") return {}; - - const today = todayString(); - log.debug("[chat-machine] appendUserImage", { - oldMessagesCount: context.messages.length, - }); - - return { - messages: [ - ...context.messages, - { - content: "[Image]", - isFromAI: false, - date: today, - imageUrl: event.imageBase64, - }, - ], - ...beginPendingReply(context), - upgradePromptVisible: false, - upgradeReason: null, - }; - }), - - appendQueuedSendErrorMessage: assign(({ context }) => { - const messages = [ - ...context.messages, - { - content: "Something went wrong. Try sending again?", - isFromAI: true, - date: todayString(), - }, - ]; - return { messages, ...finishPendingReply(context) }; - }), - - markQueuedSendStarted: assign(({ context }) => beginPendingReply(context)), - - applyQueuedHttpOutput: assign(({ context, event }) => { - if (event.type !== "ChatQueuedHttpDone") return {}; - return applyHttpSendOutput(context, event.output); - }), - - clearUpgradePrompt: assign(() => ({ - upgradePromptVisible: false, - upgradeReason: null, - canSendMessage: true, - cannotSendReason: null, - requiredCredits: 0, - shortfallCredits: 0, - })), - - markPaymentUnlockPending: assign(() => ({ - paymentUnlockPending: true, - unlockHistoryError: null, - })), - - showUnlockHistoryPrompt: assign(({ context }) => ({ - paymentUnlockPending: false, - unlockHistoryPromptVisible: true, - lockedHistoryCount: countLockedHistoryMessages(context.messages), - unlockHistoryError: null, - })), - - dismissUnlockHistoryPrompt: assign(() => ({ - paymentUnlockPending: false, - unlockHistoryPromptVisible: false, - unlockHistoryError: null, - })), - - markUnlockHistoryStarted: assign(() => ({ - paymentUnlockPending: false, - unlockHistoryPromptVisible: false, - isUnlockingHistory: true, - unlockHistoryError: null, - })), - - markUnlockHistoryFailed: assign(() => ({ - isUnlockingHistory: false, - unlockHistoryPromptVisible: true, - unlockHistoryError: "Failed to unlock messages. Please try again.", - })), - - markUnlockMessageStarted: assign(({ event }) => { - if (event.type !== "ChatUnlockMessageRequested") return {}; - return { - isUnlockingMessage: true, - unlockingMessageId: event.messageId, - unlockingMessageKind: event.kind, - unlockMessageError: null, - unlockPaywallRequest: null, - }; - }), - - applyUnlockMessageSucceeded: assign(({ context, event }) => { - const output = (event as { output?: UnlockMessageOutput }).output; - if (!output) return {}; - return { - messages: applySingleUnlockOutput(context.messages, output), - isUnlockingMessage: false, - unlockingMessageId: null, - unlockingMessageKind: null, - unlockMessageError: null, - unlockPaywallRequest: null, - creditBalance: output.response.creditBalance, - creditsCharged: output.response.creditsCharged, - requiredCredits: 0, - shortfallCredits: 0, - }; - }), - - requestUnlockPaymentFromOutput: assign(({ context, event }) => { - const output = (event as { output?: UnlockMessageOutput }).output; - if (!output) return {}; - return { - isUnlockingMessage: false, - unlockingMessageId: null, - unlockingMessageKind: null, - unlockMessageError: output.response.reason, - unlockPaywallRequest: { - messageId: output.messageId, - kind: context.unlockingMessageKind ?? "private", - reason: output.response.reason, - creditBalance: output.response.creditBalance, - requiredCredits: output.response.requiredCredits, - shortfallCredits: output.response.shortfallCredits, - }, - creditBalance: output.response.creditBalance, - requiredCredits: output.response.requiredCredits, - shortfallCredits: output.response.shortfallCredits, - }; - }), - - requestUnlockPaymentFromError: assign(({ context }) => ({ - isUnlockingMessage: false, - unlockMessageError: "unlock_failed", - unlockPaywallRequest: - context.unlockingMessageId && context.unlockingMessageKind - ? { - messageId: context.unlockingMessageId, - kind: context.unlockingMessageKind, - reason: "unlock_failed", - creditBalance: context.creditBalance, - requiredCredits: context.requiredCredits, - shortfallCredits: context.shortfallCredits, - } - : null, - unlockingMessageId: null, - unlockingMessageKind: null, - })), - - clearUnlockPaywallRequest: assign(() => ({ - unlockPaywallRequest: null, - })), }, }).createMachine({ id: "chat", diff --git a/src/stores/chat/chat-media-flow.ts b/src/stores/chat/chat-media-flow.ts new file mode 100644 index 00000000..7a76cb06 --- /dev/null +++ b/src/stores/chat/chat-media-flow.ts @@ -0,0 +1,56 @@ +import { Logger, todayString } from "@/utils"; + +import { chatAssign, type ChatActionArgs } from "./chat-flow-actions"; +import { beginPendingReply } from "./chat-machine.helpers"; + +const log = new Logger("StoresChatChatMediaFlow"); + +export const chatMediaActions = { + appendGuestUserImage: chatAssign(({ context, event }: ChatActionArgs) => { + if (event.type !== "ChatSendImage") return {}; + + const today = todayString(); + log.debug("[chat-machine] appendGuestUserImage", { + oldMessagesCount: context.messages.length, + quotaMode: "server controlled", + }); + return { + messages: [ + ...context.messages, + { + content: "[Image]", + isFromAI: false, + date: today, + imageUrl: event.imageBase64, + }, + ], + ...beginPendingReply(context), + upgradePromptVisible: false, + upgradeReason: null, + }; + }), + + appendUserImage: chatAssign(({ context, event }: ChatActionArgs) => { + if (event.type !== "ChatSendImage") return {}; + + const today = todayString(); + log.debug("[chat-machine] appendUserImage", { + oldMessagesCount: context.messages.length, + }); + + return { + messages: [ + ...context.messages, + { + content: "[Image]", + isFromAI: false, + date: today, + imageUrl: event.imageBase64, + }, + ], + ...beginPendingReply(context), + upgradePromptVisible: false, + upgradeReason: null, + }; + }), +}; diff --git a/src/stores/chat/chat-send-flow.ts b/src/stores/chat/chat-send-flow.ts new file mode 100644 index 00000000..a54244d8 --- /dev/null +++ b/src/stores/chat/chat-send-flow.ts @@ -0,0 +1,196 @@ +import { fromCallback, fromPromise } from "xstate"; + +import { MessageQueue } from "@/core/net/message-queue"; +import type { ChatSendResponse } from "@/data/dto/chat"; +import { getChatRepository } from "@/data/repositories/chat_repository"; +import { Logger, Result, todayString } from "@/utils"; + +import type { ChatEvent } from "./chat-events"; +import { + chatAssign, + type ChatActionArgs, + type ChatContextArgs, +} from "./chat-flow-actions"; +import { + applyHttpSendOutput, + beginPendingReply, + finishPendingReply, + sendResponseToUiMessage, +} from "./chat-machine.helpers"; + +const log = new Logger("StoresChatChatSendFlow"); + +type UiMessage = import("@/data/dto/chat").UiMessage; + +export const chatSendActors = { + sendMessageHttp: fromPromise< + { response: ChatSendResponse; reply: UiMessage | null }, + { content: string } + >(async ({ input }) => { + return sendMessageViaHttp(input.content); + }), + + httpMessageQueue: fromCallback(({ sendBack, receive }) => { + return createMessageQueueActor(sendBack, receive); + }), +}; + +export const chatSendActions = { + appendGuestUserMessage: chatAssign(({ context, event }: ChatActionArgs) => { + if (event.type !== "ChatSendMessage") return {}; + + const today = todayString(); + log.debug("[chat-machine] appendGuestUserMessage", { + contentLength: event.content.length, + contentPreview: event.content.slice(0, 50), + quotaMode: "server controlled", + isReplyingAI: context.isReplyingAI, + }); + + return { + messages: [ + ...context.messages, + { + content: event.content, + isFromAI: false, + date: today, + }, + ], + upgradePromptVisible: false, + upgradeReason: null, + }; + }), + + appendUserMessage: chatAssign(({ context, event }: ChatActionArgs) => { + if (event.type !== "ChatSendMessage") return {}; + + const today = todayString(); + log.debug("[chat-machine] appendUserMessage", { + contentLength: event.content.length, + contentPreview: event.content.slice(0, 50), + isReplyingAI: context.isReplyingAI, + }); + + return { + messages: [ + ...context.messages, + { + content: event.content, + isFromAI: false, + date: today, + }, + ], + upgradePromptVisible: false, + upgradeReason: null, + }; + }), + + appendQueuedSendErrorMessage: chatAssign(({ context }: ChatContextArgs) => { + const messages = [ + ...context.messages, + { + content: "Something went wrong. Try sending again?", + isFromAI: true, + date: todayString(), + }, + ]; + return { messages, ...finishPendingReply(context) }; + }), + + markQueuedSendStarted: chatAssign(({ context }: ChatContextArgs) => + beginPendingReply(context), + ), + + applyQueuedHttpOutput: chatAssign(({ context, event }: ChatActionArgs) => { + if (event.type !== "ChatQueuedHttpDone") return {}; + return applyHttpSendOutput(context, event.output); + }), + + clearUpgradePrompt: chatAssign(() => ({ + upgradePromptVisible: false, + upgradeReason: null, + canSendMessage: true, + cannotSendReason: null, + requiredCredits: 0, + shortfallCredits: 0, + })), +}; + +export const sendMessageHttpActor = chatSendActors.sendMessageHttp; +export const httpMessageQueueActor = chatSendActors.httpMessageQueue; + +function createMessageQueueActor( + sendBack: (event: ChatEvent) => void, + receive: (listener: (event: ChatEvent) => void) => void, +): () => void { + const queue = new MessageQueue(); + + queue.setConsumer(async (content) => { + sendBack({ type: "ChatQueuedSendStarted" }); + try { + const output = await sendMessageViaHttp(content); + sendBack({ type: "ChatQueuedHttpDone", output }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Message send failed"; + log.error("[chat-machine] message queue send failed", { + error, + }); + sendBack({ + type: "ChatQueuedSendError", + content, + errorMessage, + }); + } + }); + + receive((event) => { + if (event.type !== "ChatSendMessage") return; + const content = event.content.trim(); + if (content.length === 0) return; + queue.enqueue(content); + }); + + return () => queue.dispose(); +} + +async function sendMessageViaHttp(content: string): Promise<{ + response: ChatSendResponse; + reply: UiMessage | null; +}> { + const chatRepo = getChatRepository(); + const result = await chatRepo.sendMessage(content); + if (Result.isErr(result)) { + log.error("[chat-machine] sendMessageHttpActor failed", { + error: result.error, + }); + throw result.error; + } + void chatRepo.prefetchMediaForSendResponse(result.data); + const isMessageLimit = + result.data.lockDetail.locked && + result.data.lockDetail.showUpgrade && + result.data.lockDetail.reason === "weekly_limit"; + const isInsufficientCredits = + result.data.canSendMessage === false && + result.data.cannotSendReason === "insufficient_credits" && + !hasRenderableSendResponse(result.data); + return { + response: result.data, + reply: + isMessageLimit || isInsufficientCredits + ? null + : sendResponseToUiMessage(result.data), + }; +} + +function hasRenderableSendResponse(response: ChatSendResponse): boolean { + return ( + response.reply.trim().length > 0 || + response.audioUrl.trim().length > 0 || + Boolean(response.image.url) || + response.lockDetail.reason === "private_message" || + response.lockDetail.reason === "voice_message" || + response.lockDetail.reason === "image_paywall" + ); +} diff --git a/src/stores/chat/chat-unlock-flow.ts b/src/stores/chat/chat-unlock-flow.ts new file mode 100644 index 00000000..e1d3ebc3 --- /dev/null +++ b/src/stores/chat/chat-unlock-flow.ts @@ -0,0 +1,192 @@ +import { fromPromise } from "xstate"; + +import { getChatRepository } from "@/data/repositories/chat_repository"; +import { Logger, Result } from "@/utils"; + +import { + chatAssign, + type ChatActionArgs, + type ChatContextArgs, +} from "./chat-flow-actions"; +import { + applySingleUnlockOutput, + countLockedHistoryMessages, + readAndSyncHistory, + type UnlockMessageOutput, +} from "./chat-machine.helpers"; + +const log = new Logger("StoresChatChatUnlockFlow"); + +type UiMessage = import("@/data/dto/chat").UiMessage; + +export const chatUnlockActors = { + unlockHistory: fromPromise<{ + unlocked: boolean; + reason: string; + shortfallCredits: number; + messages: UiMessage[]; + hasMore: boolean; + newOffset: number; + }>(async () => { + const chatRepo = getChatRepository(); + const unlockResult = await chatRepo.unlockHistory(); + if (Result.isErr(unlockResult)) { + log.error("[chat-machine] unlockHistoryActor failed", { + error: unlockResult.error, + }); + throw unlockResult.error; + } + + const history = await readAndSyncHistory(); + return { + unlocked: unlockResult.data.unlocked, + reason: unlockResult.data.reason, + shortfallCredits: unlockResult.data.shortfallCredits, + messages: history.messages, + hasMore: history.hasMore, + newOffset: history.newOffset, + }; + }), + + unlockMessage: fromPromise( + async ({ input }) => { + const chatRepo = getChatRepository(); + const unlockResult = await chatRepo.unlockPrivateMessage(input.messageId); + if (Result.isErr(unlockResult)) { + log.error("[chat-machine] unlockMessageActor failed", { + messageId: input.messageId, + error: unlockResult.error, + }); + throw unlockResult.error; + } + + if (unlockResult.data.unlocked) { + const markResult = await chatRepo.markPrivateMessageUnlockedInLocal( + input.messageId, + unlockResult.data.content, + unlockResult.data.lockDetail, + ); + if (Result.isErr(markResult)) { + log.warn("[chat-machine] mark unlocked local message failed", { + messageId: input.messageId, + error: markResult.error, + }); + } + } + + return { + messageId: input.messageId, + response: unlockResult.data, + }; + }, + ), +}; + +export const chatUnlockActions = { + markPaymentUnlockPending: chatAssign(() => ({ + paymentUnlockPending: true, + unlockHistoryError: null, + })), + + showUnlockHistoryPrompt: chatAssign(({ context }: ChatContextArgs) => ({ + paymentUnlockPending: false, + unlockHistoryPromptVisible: true, + lockedHistoryCount: countLockedHistoryMessages(context.messages), + unlockHistoryError: null, + })), + + dismissUnlockHistoryPrompt: chatAssign(() => ({ + paymentUnlockPending: false, + unlockHistoryPromptVisible: false, + unlockHistoryError: null, + })), + + markUnlockHistoryStarted: chatAssign(() => ({ + paymentUnlockPending: false, + unlockHistoryPromptVisible: false, + isUnlockingHistory: true, + unlockHistoryError: null, + })), + + markUnlockHistoryFailed: chatAssign(() => ({ + isUnlockingHistory: false, + unlockHistoryPromptVisible: true, + unlockHistoryError: "Failed to unlock messages. Please try again.", + })), + + markUnlockMessageStarted: chatAssign(({ event }: ChatActionArgs) => { + if (event.type !== "ChatUnlockMessageRequested") return {}; + return { + isUnlockingMessage: true, + unlockingMessageId: event.messageId, + unlockingMessageKind: event.kind, + unlockMessageError: null, + unlockPaywallRequest: null, + }; + }), + + applyUnlockMessageSucceeded: chatAssign(({ context, event }: ChatActionArgs) => { + const output = (event as { output?: UnlockMessageOutput }).output; + if (!output) return {}; + return { + messages: applySingleUnlockOutput(context.messages, output), + isUnlockingMessage: false, + unlockingMessageId: null, + unlockingMessageKind: null, + unlockMessageError: null, + unlockPaywallRequest: null, + creditBalance: output.response.creditBalance, + creditsCharged: output.response.creditsCharged, + requiredCredits: 0, + shortfallCredits: 0, + }; + }), + + requestUnlockPaymentFromOutput: chatAssign(({ context, event }: ChatActionArgs) => { + const output = (event as { output?: UnlockMessageOutput }).output; + if (!output) return {}; + return { + isUnlockingMessage: false, + unlockingMessageId: null, + unlockingMessageKind: null, + unlockMessageError: output.response.reason, + unlockPaywallRequest: { + messageId: output.messageId, + kind: context.unlockingMessageKind ?? "private", + reason: output.response.reason, + creditBalance: output.response.creditBalance, + requiredCredits: output.response.requiredCredits, + shortfallCredits: output.response.shortfallCredits, + }, + creditBalance: output.response.creditBalance, + requiredCredits: output.response.requiredCredits, + shortfallCredits: output.response.shortfallCredits, + }; + }), + + requestUnlockPaymentFromError: chatAssign(({ context }: ChatContextArgs) => ({ + isUnlockingMessage: false, + unlockMessageError: "unlock_failed", + unlockPaywallRequest: + context.unlockingMessageId && context.unlockingMessageKind + ? { + messageId: context.unlockingMessageId, + kind: context.unlockingMessageKind, + reason: "unlock_failed", + creditBalance: context.creditBalance, + requiredCredits: context.requiredCredits, + shortfallCredits: context.shortfallCredits, + } + : null, + unlockingMessageId: null, + unlockingMessageKind: null, + })), + + clearUnlockPaywallRequest: chatAssign(() => ({ + unlockPaywallRequest: null, + })), +}; + +export const unlockHistoryActor = chatUnlockActors.unlockHistory; +export const unlockMessageActor = chatUnlockActors.unlockMessage; +export type { UnlockMessageOutput } from "./chat-machine.helpers";