refactor(chat): split machine and repository flows
This commit is contained in:
@@ -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<Result<void>> {
|
||||||
|
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<Result<void>> {
|
||||||
|
return this.localStorage.saveMessage(this._chatToLocal(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveMessages(
|
||||||
|
messages: readonly ChatMessage[],
|
||||||
|
): Promise<Result<void>> {
|
||||||
|
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<Result<ChatMessage[]>> {
|
||||||
|
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<Result<void>> {
|
||||||
|
return this.localStorage.clearAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMessageCount(): Promise<Result<number>> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Result<LocalChatMediaRow | null>> {
|
||||||
|
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<Result<LocalChatMediaRow>> {
|
||||||
|
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<Result<void>> {
|
||||||
|
return this._prefetchMediaTargets(
|
||||||
|
messages.flatMap((message) => getMessageMediaTargets(message)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async prefetchMediaForSendResponse(
|
||||||
|
response: ChatSendResponse,
|
||||||
|
): Promise<Result<void>> {
|
||||||
|
return this._prefetchMediaTargets(getSendResponseMediaTargets(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _prefetchMediaTargets(
|
||||||
|
targets: readonly CacheRemoteChatMediaInput[],
|
||||||
|
): Promise<Result<void>> {
|
||||||
|
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<string[]> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Result<ChatSendResponse>> {
|
||||||
|
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<Result<ChatHistoryResponse>> {
|
||||||
|
return Result.wrap(() => this.api.getHistory(limit, offset));
|
||||||
|
}
|
||||||
|
|
||||||
|
async unlockPrivateMessage(
|
||||||
|
messageId: string,
|
||||||
|
): Promise<Result<UnlockPrivateResponse>> {
|
||||||
|
return Result.wrap(() =>
|
||||||
|
this.api.unlockPrivateMessage(UnlockPrivateRequest.from({ messageId })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async unlockHistory(): Promise<Result<UnlockHistoryResponse>> {
|
||||||
|
return Result.wrap(() => this.api.unlockHistory());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,48 +1,36 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChatApi, chatApi } from "@/data/services/api";
|
import type {
|
||||||
import {
|
|
||||||
ChatHistoryResponse,
|
ChatHistoryResponse,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
ChatSendResponse,
|
ChatSendResponse,
|
||||||
SendMessageRequest,
|
|
||||||
UnlockHistoryResponse,
|
UnlockHistoryResponse,
|
||||||
UnlockPrivateRequest,
|
|
||||||
UnlockPrivateResponse,
|
UnlockPrivateResponse,
|
||||||
} from "@/data/dto/chat";
|
} from "@/data/dto/chat";
|
||||||
import type { ChatLockDetailData } from "@/data/schemas/chat";
|
import type { ChatLockDetailData } from "@/data/schemas/chat";
|
||||||
import { AuthStorage } from "@/data/storage/auth";
|
import { chatApi } from "@/data/services/api";
|
||||||
import { UserStorage } from "@/data/storage/user/user_storage";
|
import {
|
||||||
import { Logger, Result } from "@/utils";
|
LocalChatMediaStorage,
|
||||||
|
LocalChatStorage,
|
||||||
|
type LocalChatMediaRow,
|
||||||
|
} from "@/data/storage/chat";
|
||||||
import type {
|
import type {
|
||||||
CacheRemoteChatMediaInput,
|
CacheRemoteChatMediaInput,
|
||||||
ChatMediaLookupInput,
|
ChatMediaLookupInput,
|
||||||
IChatRepository,
|
IChatRepository,
|
||||||
} from "@/data/repositories/interfaces";
|
} from "@/data/repositories/interfaces";
|
||||||
import {
|
import type { Result } from "@/utils";
|
||||||
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";
|
|
||||||
|
|
||||||
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 {
|
export class ChatRepository implements IChatRepository {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly api: ChatApi,
|
private readonly remote: ChatRemoteDataSource,
|
||||||
private readonly localStorage: LocalChatStorage,
|
private readonly localMessages: ChatLocalMessageStore,
|
||||||
private readonly mediaStorage: LocalChatMediaStorage,
|
private readonly mediaCache: ChatMediaCacheCoordinator,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ============ 远程操作 ============
|
// ============ 远程操作 ============
|
||||||
@@ -52,14 +40,7 @@ export class ChatRepository implements IChatRepository {
|
|||||||
message: string,
|
message: string,
|
||||||
options?: { image?: string; useWebSocket?: boolean },
|
options?: { image?: string; useWebSocket?: boolean },
|
||||||
): Promise<Result<ChatSendResponse>> {
|
): Promise<Result<ChatSendResponse>> {
|
||||||
return Result.wrap(async () => {
|
return this.remote.sendMessage(message, options);
|
||||||
const request = SendMessageRequest.from({
|
|
||||||
message,
|
|
||||||
image: options?.image ?? "",
|
|
||||||
useWebSocket: options?.useWebSocket ?? false,
|
|
||||||
});
|
|
||||||
return await this.api.sendMessage(request);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取聊天历史,分页参数默认 limit=50, offset=0。 */
|
/** 获取聊天历史,分页参数默认 limit=50, offset=0。 */
|
||||||
@@ -67,21 +48,19 @@ export class ChatRepository implements IChatRepository {
|
|||||||
limit = 50,
|
limit = 50,
|
||||||
offset = 0,
|
offset = 0,
|
||||||
): Promise<Result<ChatHistoryResponse>> {
|
): Promise<Result<ChatHistoryResponse>> {
|
||||||
return Result.wrap(() => this.api.getHistory(limit, offset));
|
return this.remote.getHistory(limit, offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 解锁单条历史付费 / 私密消息。 */
|
/** 解锁单条历史付费 / 私密消息。 */
|
||||||
async unlockPrivateMessage(
|
async unlockPrivateMessage(
|
||||||
messageId: string,
|
messageId: string,
|
||||||
): Promise<Result<UnlockPrivateResponse>> {
|
): Promise<Result<UnlockPrivateResponse>> {
|
||||||
return Result.wrap(() =>
|
return this.remote.unlockPrivateMessage(messageId);
|
||||||
this.api.unlockPrivateMessage(UnlockPrivateRequest.from({ messageId })),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 一键解锁历史锁定消息。 */
|
/** 一键解锁历史锁定消息。 */
|
||||||
async unlockHistory(): Promise<Result<UnlockHistoryResponse>> {
|
async unlockHistory(): Promise<Result<UnlockHistoryResponse>> {
|
||||||
return Result.wrap(() => this.api.unlockHistory());
|
return this.remote.unlockHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 把本地缓存中的单条锁定消息标记为已解锁。 */
|
/** 把本地缓存中的单条锁定消息标记为已解锁。 */
|
||||||
@@ -90,47 +69,18 @@ export class ChatRepository implements IChatRepository {
|
|||||||
content: string,
|
content: string,
|
||||||
lockDetail?: ChatLockDetailData,
|
lockDetail?: ChatLockDetailData,
|
||||||
): Promise<Result<void>> {
|
): Promise<Result<void>> {
|
||||||
return Result.wrap(async () => {
|
return this.localMessages.markMessageUnlocked(
|
||||||
const localResult = await this.getLocalMessages();
|
messageId,
|
||||||
if (Result.isErr(localResult)) {
|
content,
|
||||||
throw localResult.error;
|
lockDetail,
|
||||||
}
|
);
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ 本地(Dexie)操作 ============
|
// ============ 本地(Dexie)操作 ============
|
||||||
|
|
||||||
/** 把一条消息写入本地存储。 */
|
/** 把一条消息写入本地存储。 */
|
||||||
async saveMessageToLocal(message: ChatMessage): Promise<Result<void>> {
|
async saveMessageToLocal(message: ChatMessage): Promise<Result<void>> {
|
||||||
return this._mapLocalStorageResult(
|
return this.localMessages.saveMessage(message);
|
||||||
this.localStorage.saveMessage(this._chatToLocal(message)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -140,27 +90,17 @@ export class ChatRepository implements IChatRepository {
|
|||||||
async saveMessagesToLocal(
|
async saveMessagesToLocal(
|
||||||
messages: readonly ChatMessage[],
|
messages: readonly ChatMessage[],
|
||||||
): Promise<Result<void>> {
|
): Promise<Result<void>> {
|
||||||
const cleared = await this.localStorage.clearAll();
|
return this.localMessages.saveMessages(messages);
|
||||||
if (!cleared.success) {
|
|
||||||
return Result.err(cleared.error);
|
|
||||||
}
|
|
||||||
return this._mapLocalStorageResult(
|
|
||||||
this.localStorage.saveMessages(messages.map((m) => this._chatToLocal(m))),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 读取所有本地消息,按 dbId 升序。 */
|
/** 读取所有本地消息,按 dbId 升序。 */
|
||||||
async getLocalMessages(): Promise<Result<ChatMessage[]>> {
|
async getLocalMessages(): Promise<Result<ChatMessage[]>> {
|
||||||
const result = await this.localStorage.getAllMessages();
|
return this.localMessages.getMessages();
|
||||||
if (!result.success) {
|
|
||||||
return Result.err(result.error);
|
|
||||||
}
|
|
||||||
return Result.ok(result.data.map((lm) => this._localToChat(lm)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 清空本地消息。 */
|
/** 清空本地消息。 */
|
||||||
async clearLocalMessages(): Promise<Result<void>> {
|
async clearLocalMessages(): Promise<Result<void>> {
|
||||||
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。
|
* 替代 Dart 的同步 `int get localMessageCount`——Dexie 异步 API 决定必须 async。
|
||||||
*/
|
*/
|
||||||
async getLocalMessageCount(): Promise<Result<number>> {
|
async getLocalMessageCount(): Promise<Result<number>> {
|
||||||
return this._mapLocalStorageResult(this.localStorage.getMessageCount());
|
return this.localMessages.getMessageCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ 本地媒体缓存 ============
|
// ============ 本地媒体缓存 ============
|
||||||
@@ -176,182 +116,25 @@ export class ChatRepository implements IChatRepository {
|
|||||||
async getCachedMedia(
|
async getCachedMedia(
|
||||||
input: ChatMediaLookupInput,
|
input: ChatMediaLookupInput,
|
||||||
): Promise<Result<LocalChatMediaRow | null>> {
|
): Promise<Result<LocalChatMediaRow | null>> {
|
||||||
return Result.wrap(async () => {
|
return this.mediaCache.getCachedMedia(input);
|
||||||
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(
|
async cacheRemoteMedia(
|
||||||
input: CacheRemoteChatMediaInput,
|
input: CacheRemoteChatMediaInput,
|
||||||
): Promise<Result<LocalChatMediaRow>> {
|
): Promise<Result<LocalChatMediaRow>> {
|
||||||
return Result.wrap(async () => {
|
return this.mediaCache.cacheRemoteMedia(input);
|
||||||
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(
|
async prefetchMediaForMessages(
|
||||||
messages: readonly ChatMessage[],
|
messages: readonly ChatMessage[],
|
||||||
): Promise<Result<void>> {
|
): Promise<Result<void>> {
|
||||||
return this._prefetchMediaTargets(
|
return this.mediaCache.prefetchMediaForMessages(messages);
|
||||||
messages.flatMap((message) => getMessageMediaTargets(message)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async prefetchMediaForSendResponse(
|
async prefetchMediaForSendResponse(
|
||||||
response: ChatSendResponse,
|
response: ChatSendResponse,
|
||||||
): Promise<Result<void>> {
|
): Promise<Result<void>> {
|
||||||
return this._prefetchMediaTargets(getSendResponseMediaTargets(response));
|
return this.mediaCache.prefetchMediaForSendResponse(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<T>` 透传:仓库与 storage 现在共享同一全局
|
|
||||||
* `Result<T>`(来自 `@/utils/result`),不需要任何形状转换。
|
|
||||||
* 该方法保留仅为未来万一又出现差异化时方便快速插入适配。
|
|
||||||
*/
|
|
||||||
private async _mapLocalStorageResult<T>(
|
|
||||||
promise: Promise<Result<T>>,
|
|
||||||
): Promise<Result<T>> {
|
|
||||||
return await promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _prefetchMediaTargets(
|
|
||||||
targets: readonly CacheRemoteChatMediaInput[],
|
|
||||||
): Promise<Result<void>> {
|
|
||||||
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<string[]> {
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,8 +142,8 @@ export class ChatRepository implements IChatRepository {
|
|||||||
export const getChatRepository = createLazySingleton<IChatRepository>(
|
export const getChatRepository = createLazySingleton<IChatRepository>(
|
||||||
() =>
|
() =>
|
||||||
new ChatRepository(
|
new ChatRepository(
|
||||||
chatApi,
|
new ChatRemoteDataSource(chatApi),
|
||||||
LocalChatStorage.getInstance(),
|
new ChatLocalMessageStore(LocalChatStorage.getInstance()),
|
||||||
LocalChatMediaStorage.getInstance(),
|
new ChatMediaCacheCoordinator(LocalChatMediaStorage.getInstance()),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<ChatState, ChatEvent, undefined, ChatEvent, never>
|
||||||
|
>[0],
|
||||||
|
) => xAssign<ChatState, ChatEvent, undefined, ChatEvent, never>(assignment);
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
export {
|
||||||
import { fromPromise, fromCallback } from "xstate";
|
chatHistoryActors,
|
||||||
|
loadHistoryActor,
|
||||||
import { MessageQueue } from "@/core/net/message-queue";
|
loadMoreHistoryActor,
|
||||||
import type { ChatLockDetailData } from "@/data/schemas/chat";
|
} from "./chat-history-flow";
|
||||||
import type { ChatSendResponse, UnlockPrivateResponse } from "@/data/dto/chat";
|
export {
|
||||||
import { getChatRepository } from "@/data/repositories/chat_repository";
|
chatSendActors,
|
||||||
import { Result, Logger } from "@/utils";
|
httpMessageQueueActor,
|
||||||
|
sendMessageHttpActor,
|
||||||
import {
|
} from "./chat-send-flow";
|
||||||
PAGE_SIZE,
|
export {
|
||||||
localMessagesToUi,
|
chatUnlockActors,
|
||||||
readAndSyncHistory,
|
unlockHistoryActor,
|
||||||
sendResponseToUiMessage,
|
unlockMessageActor,
|
||||||
} from "./chat-machine.helpers";
|
type UnlockMessageOutput,
|
||||||
import type { ChatEvent } from "./chat-events";
|
} from "./chat-unlock-flow";
|
||||||
|
|
||||||
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<ChatEvent>(
|
|
||||||
({ 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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { UiMessage } from "@/data/dto/chat";
|
import type { UiMessage, UnlockPrivateResponse } from "@/data/dto/chat";
|
||||||
import type { UnlockMessageOutput } from "./chat-machine.actors";
|
|
||||||
import { getChatRepository } from "@/data/repositories/chat_repository";
|
import { getChatRepository } from "@/data/repositories/chat_repository";
|
||||||
import { ChatSendResponse } from "@/data/dto/chat/chat_send_response";
|
import { ChatSendResponse } from "@/data/dto/chat/chat_send_response";
|
||||||
import { todayString, Result, Logger } from "@/utils";
|
import { todayString, Result, Logger } from "@/utils";
|
||||||
@@ -143,6 +142,11 @@ export type HttpSendOutput = {
|
|||||||
reply: UiMessage | null;
|
reply: UiMessage | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface UnlockMessageOutput {
|
||||||
|
messageId: string;
|
||||||
|
response: UnlockPrivateResponse;
|
||||||
|
}
|
||||||
|
|
||||||
export function applySingleUnlockOutput(
|
export function applySingleUnlockOutput(
|
||||||
messages: readonly UiMessage[],
|
messages: readonly UiMessage[],
|
||||||
output: UnlockMessageOutput,
|
output: UnlockMessageOutput,
|
||||||
|
|||||||
+10
-250
@@ -29,31 +29,19 @@
|
|||||||
|
|
||||||
import { setup, assign, sendTo } from "xstate";
|
import { setup, assign, sendTo } from "xstate";
|
||||||
|
|
||||||
import { todayString, Logger } from "@/utils";
|
|
||||||
|
|
||||||
import { ChatState, initialState } from "./chat-state";
|
import { ChatState, initialState } from "./chat-state";
|
||||||
import type { ChatEvent } from "./chat-events";
|
import type { ChatEvent } from "./chat-events";
|
||||||
import {
|
import {
|
||||||
applyHttpSendOutput,
|
applyHttpSendOutput,
|
||||||
applyHistoryLoadedOutput,
|
applyHistoryLoadedOutput,
|
||||||
applySingleUnlockOutput,
|
|
||||||
beginPendingReply,
|
|
||||||
countLockedHistoryMessages,
|
countLockedHistoryMessages,
|
||||||
finishPendingReply,
|
|
||||||
shouldAutoUnlockHistory,
|
shouldAutoUnlockHistory,
|
||||||
shouldPromptUnlockHistory,
|
shouldPromptUnlockHistory,
|
||||||
} from "./chat-machine.helpers";
|
} from "./chat-machine.helpers";
|
||||||
import {
|
import { chatHistoryActors } from "./chat-history-flow";
|
||||||
loadHistoryActor,
|
import { chatMediaActions } from "./chat-media-flow";
|
||||||
sendMessageHttpActor,
|
import { chatSendActions, chatSendActors } from "./chat-send-flow";
|
||||||
loadMoreHistoryActor,
|
import { chatUnlockActions, chatUnlockActors } from "./chat-unlock-flow";
|
||||||
httpMessageQueueActor,
|
|
||||||
unlockHistoryActor,
|
|
||||||
unlockMessageActor,
|
|
||||||
type UnlockMessageOutput,
|
|
||||||
} from "./chat-machine.actors";
|
|
||||||
|
|
||||||
const log = new Logger("StoresChatChatMachine");
|
|
||||||
|
|
||||||
// 重新导出 State / Event 类型,保持 machine 文件的公共 API 不变
|
// 重新导出 State / Event 类型,保持 machine 文件的公共 API 不变
|
||||||
export type { ChatState } from "./chat-state";
|
export type { ChatState } from "./chat-state";
|
||||||
@@ -69,14 +57,14 @@ export const chatMachine = setup({
|
|||||||
events: {} as ChatEvent,
|
events: {} as ChatEvent,
|
||||||
},
|
},
|
||||||
actors: {
|
actors: {
|
||||||
loadHistory: loadHistoryActor,
|
...chatHistoryActors,
|
||||||
sendMessageHttp: sendMessageHttpActor,
|
...chatSendActors,
|
||||||
loadMoreHistory: loadMoreHistoryActor,
|
...chatUnlockActors,
|
||||||
httpMessageQueue: httpMessageQueueActor,
|
|
||||||
unlockHistory: unlockHistoryActor,
|
|
||||||
unlockMessage: unlockMessageActor,
|
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
|
...chatSendActions,
|
||||||
|
...chatMediaActions,
|
||||||
|
...chatUnlockActions,
|
||||||
enqueueMessage: sendTo("messageQueue", ({ event }) => event),
|
enqueueMessage: sendTo("messageQueue", ({ event }) => event),
|
||||||
|
|
||||||
startGuestSession: assign(() => ({
|
startGuestSession: assign(() => ({
|
||||||
@@ -90,234 +78,6 @@ export const chatMachine = setup({
|
|||||||
clearChatSession: assign(() => ({
|
clearChatSession: assign(() => ({
|
||||||
...initialState,
|
...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({
|
}).createMachine({
|
||||||
id: "chat",
|
id: "chat",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -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<ChatEvent>(({ 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<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,
|
||||||
|
);
|
||||||
|
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";
|
||||||
Reference in New Issue
Block a user