feat(chat): support ai photo viewing

This commit is contained in:
2026-06-23 10:33:20 +08:00
parent cebc8f7443
commit d81abd6efd
16 changed files with 160 additions and 45 deletions
+8
View File
@@ -26,6 +26,14 @@ export type ChatEvent =
| { type: "ChatSendMessage"; content: string }
| { type: "ChatSendImage"; imageBase64: string }
| { type: "ChatLoadMoreHistory" }
| { type: "ChatImageReceived"; imageUrl: string }
| {
type: "ChatPaywallStatusReceived";
paywallTriggered: boolean;
showUpgrade: boolean;
imageType: string | null;
imageUrl: string | null;
}
// WebSocket / AI 推句(由 chat 机器内部 actor 派回 —— "机器自驱动"
| {
type: "ChatAISentenceReceived";
+12
View File
@@ -151,6 +151,18 @@ export const chatWebSocketActor = fromCallback<ChatEvent, { token: string }>(
done: p.done,
});
};
ws.onImage = (imageUrl) => {
sendBack({ type: "ChatImageReceived", imageUrl });
};
ws.onPaywallStatus = (p) => {
sendBack({
type: "ChatPaywallStatusReceived",
paywallTriggered: p.paywallTriggered,
showUpgrade: p.showUpgrade,
imageType: p.imageType,
imageUrl: p.imageUrl,
});
};
ws.onError = (msg) => {
log.error("[chat-machine] WS error", { errorMessage: msg });
sendBack({ type: "ChatWebSocketError", errorMessage: msg });
+13
View File
@@ -56,12 +56,14 @@ export function localMessagesToUi(
content: string;
role: string;
createdAt: string;
imageUrl?: string | null;
}>,
): UiMessage[] {
return records.map((m) => ({
content: m.content,
isFromAI: m.role === "assistant",
date: messageDateFromCreatedAt(m.createdAt),
...(m.imageUrl ? { imageUrl: m.imageUrl } : {}),
}));
}
@@ -81,6 +83,7 @@ export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
content: response.reply,
isFromAI: true,
date: todayString(new Date(response.timestamp)),
...(response.imageUrl ? { imageUrl: response.imageUrl } : {}),
};
}
@@ -120,6 +123,16 @@ export function applyHttpSendOutput(
};
}
if (response.showUpgrade) {
return {
messages: [...context.messages, reply],
isReplyingAI: false,
paywallTriggered: true,
paywallReason: "photo_paywall",
paywallDetail: null,
};
}
return {
messages: [...context.messages, reply],
isReplyingAI: false,
+50
View File
@@ -261,6 +261,54 @@ export const chatMachine = setup({
return { messages, isReplyingAI: false };
}),
appendAIImage: assign(({ context, event }) => {
if (event.type !== "ChatImageReceived") return {};
const messages = [...context.messages];
const last = messages[messages.length - 1];
if (last?.isFromAI && !last.imageUrl) {
messages[messages.length - 1] = {
...last,
imageUrl: event.imageUrl,
};
} else {
messages.push({
content: "",
isFromAI: true,
date: todayString(),
imageUrl: event.imageUrl,
});
}
log.debug("[chat-machine] appendAIImage", {
messagesCount: messages.length,
hasMergedIntoLastMessage: Boolean(last?.isFromAI && !last.imageUrl),
});
return {
messages,
paywallTriggered: false,
paywallReason: null,
paywallDetail: null,
};
}),
applyPaywallStatus: assign(({ event }) => {
if (event.type !== "ChatPaywallStatusReceived") return {};
if (!event.showUpgrade) {
return {
paywallTriggered: false,
paywallReason: null,
paywallDetail: null,
};
}
return {
paywallTriggered: true,
paywallReason: "photo_paywall",
paywallDetail: null,
};
}),
incrementQuotaExceeded: assign(({ context }) => ({
quotaExceededTrigger: context.quotaExceededTrigger + 1,
})),
@@ -546,6 +594,8 @@ export const chatMachine = setup({
actions: "startUserSession",
},
ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" },
ChatImageReceived: { actions: "appendAIImage" },
ChatPaywallStatusReceived: { actions: "applyPaywallStatus" },
ChatWebSocketError: { actions: "appendSocketErrorMessage" },
ChatWebSocketConnected: { actions: "setWsConnected" },
},
+1 -1
View File
@@ -25,7 +25,7 @@ export interface ChatState {
guestTotalQuota: number;
quotaExceededTrigger: number;
paywallTriggered: boolean;
paywallReason: "daily_limit" | null;
paywallReason: "daily_limit" | "photo_paywall" | null;
paywallDetail: { usedToday: number; limit: number } | null;
isLoadingMore: boolean;
hasMore: boolean;