diff --git a/src/stores/chat/chat-machine.actors.ts b/src/stores/chat/chat-machine.actors.ts index c26c79c6..b09a9bc4 100644 --- a/src/stores/chat/chat-machine.actors.ts +++ b/src/stores/chat/chat-machine.actors.ts @@ -3,7 +3,7 @@ * * 从 `chat-machine.ts` 抽出的"Actors"段: * - `chatInitActor`(fromPromise,**仅**游客有意义) - * - `sendMessageHttpActor`(fromPromise,**已定义但**未 wire —— 消息**不**真发后端) + * - `sendMessageHttpActor`(fromPromise,**已** wire —— "一次发送 = 一次返回") * - `loadMoreHistoryActor`(fromPromise) * - `chatWebSocketActor`(fromCallback,长生命周期 —— **仅** userSession 期间 invoke) * @@ -24,6 +24,7 @@ import { chatRepo, localMessagesToUi, readInitData, + sendResponseToUiMessage, type InitResult, } from "./chat-machine.helpers"; import type { ChatEvent } from "./chat-events"; @@ -34,12 +35,13 @@ import type { ChatEvent } from "./chat-events"; export const chatInitActor = fromPromise(async () => readInitData()); /** - * HTTP 发送消息(**已定义但未** wire —— 消息**不**真发后端) - * - 任何 state 转移**未** invoke 此 actor - * - 保留全链日志以便排查 + * HTTP 发送消息(**一次发送 = 一次返回**) + * - 后端响应**就**是 AI 回复(`ChatSendResponse.reply: string`) + * - **不**再调 `getLocalMessages()`(**多此一举**) + * - 返回 `{ reply: UiMessage }` —— `sending.onDone` 把它**追加**到 `context.messages` */ export const sendMessageHttpActor = fromPromise< - { messages: UiMessage[] }, + { reply: UiMessage }, { content: string } >(async ({ input, self }) => { console.log("[chat-machine] sendMessageHttpActor ENTRY", { @@ -48,10 +50,6 @@ export const sendMessageHttpActor = fromPromise< selfId: self.id, selfPath: "", }); - console.warn( - "[chat-machine] sendMessageHttpActor invoked —— actor 已定义但**未** invoke(消息**不**真发后端)", - { contentLength: input.content.length, contentPreview: input.content.slice(0, 50) }, - ); console.log("[chat-machine] sendMessageHttpActor calling chatRepo.sendMessage"); const result = await chatRepo.sendMessage(input.content); console.log("[chat-machine] sendMessageHttpActor chatRepo.sendMessage DONE", { @@ -63,18 +61,18 @@ export const sendMessageHttpActor = fromPromise< throw result.error; } - console.log("[chat-machine] sendMessageHttpActor fetching local messages"); - const local = await chatRepo.getLocalMessages(); - console.log("[chat-machine] sendMessageHttpActor local messages DONE", { - success: local.success, - messagesCount: local.success && local.data ? local.data.length : 0, + // 一次发送 = 一次返回 —— 直接转 reply → UiMessage + console.log("[chat-machine] sendMessageHttpActor converting reply to UiMessage", { + replyLength: result.data.reply.length, + replyPreview: result.data.reply.slice(0, 50), + messageId: result.data.messageId, + timestamp: result.data.timestamp, }); - if (Result.isOk(local) && local.data) { - const messages = localMessagesToUi(local.data); - console.log("[chat-machine] sendMessageHttpActor done", { messagesCount: messages.length }); - return { messages }; - } - return { messages: [] }; + const reply = sendResponseToUiMessage(result.data); + console.log("[chat-machine] sendMessageHttpActor done", { + replyContentLength: reply.content.length, + }); + return { reply }; }); /** 翻历史(pagination) */ diff --git a/src/stores/chat/chat-machine.helpers.ts b/src/stores/chat/chat-machine.helpers.ts index e8fb26ad..042e304a 100644 --- a/src/stores/chat/chat-machine.helpers.ts +++ b/src/stores/chat/chat-machine.helpers.ts @@ -19,6 +19,8 @@ import { chatRepository } from "@/data/repositories/chat_repository"; import type { IChatRepository } from "@/data/repositories/interfaces"; import { ChatStorage } from "@/data/storage/chat/chat_storage"; import { AuthStorage } from "@/data/storage/auth/auth_storage"; +import { ChatSendResponse } from "@/data/dto/chat/chat_send_response"; +import { formatDate } from "@/utils/date"; import { Result } from "@/utils/result"; // ============================================================// Constants @@ -52,6 +54,19 @@ export function localMessagesToUi( })); } +/** + * ChatSendResponse → UiMessage(**纯函数**) + * - 业务事实:后端响应**就是** AI 的回复 + * - 用后端 `timestamp`(**不**用本地 time)—— 多设备/时区**一致** + */ +export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage { + return { + content: response.reply, + isFromAI: true, + date: formatDate(new Date(response.timestamp)), + }; +} + // ============================================================// Result → number 映射 // ============================================================ /** 日配额 Result → number(**纯函数**) */ diff --git a/src/stores/chat/chat-machine.ts b/src/stores/chat/chat-machine.ts index 9dbb7e96..dc1b8872 100644 --- a/src/stores/chat/chat-machine.ts +++ b/src/stores/chat/chat-machine.ts @@ -1,13 +1,6 @@ /** * Chat 状态机(XState v5) * - * 原始 Dart: lib/ui/chat/bloc/chat bloc + chat state + chat event - * - * 本轮迁移范围: - * ✅ HTTP 流程(init / send message / load more history) - * ✅ WebSocket 长生命周期(**由** chat 机器内 fromCallback actor 管) - * ✅ 上下文数据(messages / quota / 标志位) - * * 设计要点: * - 使用 XState v5 `setup({...}).createMachine({...})` 声明式 API * - HTTP 操作用 `fromPromise` actor(一次性 Promise) @@ -27,15 +20,17 @@ * - `guestSession`(**parent**):游客会话 —— **不** invoke WS * - `initializing`:invoke chatInit(拉本地 + 初始化配额) * - `ready`:用户聊天 + 翻历史 + * - `sending`:invoke sendMessageHttpActor(HTTP 发送消息) * - `loadingMore`:invoke loadMoreHistory(翻历史) * - `userSession`(**parent**):非游客会话 —— **invoke WS**(持久) * - `initializing`:invoke loadMoreHistory(拉服务器端首屏) * - `ready`:用户聊天 + 翻历史 + * - `sending`:invoke sendMessageHttpActor(HTTP fallback —— WS 真发**未** wire) * - `loadingMore`:invoke loadMoreHistory(翻历史) - * WS 在 `userSession` parent 状态 invoke —— 跨 initializing/ready/loadingMore 持续 + * WS 在 `userSession` parent 状态 invoke —— 跨 initializing/ready/sending/loadingMore 持续 * * **消息发送路径**(**业务事实**): - * - `wsConnected: true`(**仅** userSession 期间)→ **走 WS**,**不**消耗次数 + * - `wsConnected: true`(**仅** userSession 期间)→ **走 WS**,**不**消耗次数(**仅**配额规则) * - `wsConnected: false` → **走 HTTP**,消耗次数(**仅**游客有意义 —— 非游客 0-1=max(0,-1)=0 天然 no-op) * - `ChatWebSocketConnected` 事件 → `wsConnected = true` * - `userSession.exit` action → `wsConnected = false`(WS actor cleanup 时**也**会跑 exit) @@ -279,6 +274,7 @@ export const chatMachine = setup({ ChatSendMessage: { actions: ["logSendMessageReceived", "appendUserMessage"], guard: ({ event }) => event.content.trim().length > 0, + target: "sending", }, ChatSendImage: { actions: ["logSendImageReceived", "appendUserImage"], @@ -302,6 +298,44 @@ export const chatMachine = setup({ ChatQuotaExceeded: { actions: "incrementQuotaExceeded" }, }, }, + sending: { + entry: ({ event }) => + console.log("[chat-machine] → guestSession.sending", { + contentLength: event.type === "ChatSendMessage" ? event.content.length : 0, + contentPreview: event.type === "ChatSendMessage" ? event.content.slice(0, 50) : "", + }), + invoke: { + src: "sendMessageHttp", + input: ({ event }) => ({ + content: event.type === "ChatSendMessage" ? event.content : "", + }), + onDone: { + target: "ready", + actions: [ + assign(({ context, event }) => ({ + messages: [...context.messages, event.output.reply], + isReplyingAI: false, + })), + ({ context, event }) => + console.log("[chat-machine] guestSession.sending.onDone", { + newReplyContentLength: event.output.reply.content.length, + totalMessagesCount: context.messages.length + 1, + isReplyingAI: false, + }), + ], + }, + onError: { + target: "ready", + actions: [ + assign({ isReplyingAI: false }), + ({ event }) => + console.error("[chat-machine] guestSession.sending.onError", { + error: event.error instanceof Error ? event.error.message : String(event.error), + }), + ], + }, + }, + }, loadingMore: { entry: ({ context }) => console.log("[chat-machine] → guestSession.loadingMore", { offset: context.historyOffset }), @@ -357,7 +391,7 @@ export const chatMachine = setup({ onDone: { target: "ready", actions: [ - assign(({ event }) => ({ + assign(({ context, event }) => ({ messages: event.output.messages, isLoadingMore: false, hasMore: event.output.hasMore, @@ -388,6 +422,7 @@ export const chatMachine = setup({ ChatSendMessage: { actions: ["logSendMessageReceived", "appendUserMessage"], guard: ({ event }) => event.content.trim().length > 0, + target: "sending", }, ChatSendImage: { actions: ["logSendImageReceived", "appendUserImage"], @@ -411,6 +446,45 @@ export const chatMachine = setup({ ChatQuotaExceeded: { actions: "incrementQuotaExceeded" }, }, }, + sending: { + entry: ({ event }) => + console.log("[chat-machine] → userSession.sending", { + contentLength: event.type === "ChatSendMessage" ? event.content.length : 0, + contentPreview: event.type === "ChatSendMessage" ? event.content.slice(0, 50) : "", + wsConnected: true, + }), + invoke: { + src: "sendMessageHttp", + input: ({ event }) => ({ + content: event.type === "ChatSendMessage" ? event.content : "", + }), + onDone: { + target: "ready", + actions: [ + assign(({ context, event }) => ({ + messages: [...context.messages, event.output.reply], + isReplyingAI: false, + })), + ({ context, event }) => + console.log("[chat-machine] userSession.sending.onDone", { + newReplyContentLength: event.output.reply.content.length, + totalMessagesCount: context.messages.length + 1, + isReplyingAI: false, + }), + ], + }, + onError: { + target: "ready", + actions: [ + assign({ isReplyingAI: false }), + ({ event }) => + console.error("[chat-machine] userSession.sending.onError", { + error: event.error instanceof Error ? event.error.message : String(event.error), + }), + ], + }, + }, + }, loadingMore: { entry: ({ context }) => console.log("[chat-machine] → userSession.loadingMore", { offset: context.historyOffset }),