fix(chat): stabilize message identities

This commit is contained in:
2026-07-20 18:30:21 +08:00
parent 159e8bfd59
commit 1e13f94b5d
33 changed files with 718 additions and 212 deletions
@@ -265,19 +265,19 @@ function createTouchEvent(
return event;
}
function createMessage(id: string): UiMessage {
function createMessage(displayId: string): UiMessage {
return {
id,
content: `Message ${id}`,
displayId,
content: `Message ${displayId}`,
isFromAI: true,
date: "2026-07-15",
};
}
function createUserMessage(id: string): UiMessage {
function createUserMessage(displayId: string): UiMessage {
return {
id,
content: `Message ${id}`,
displayId,
content: `Message ${displayId}`,
isFromAI: false,
date: "2026-07-15",
};
@@ -128,7 +128,8 @@ describe("chat Tailwind components", () => {
const openableHtml = renderToStaticMarkup(
<ImageBubble
characterId="elio"
messageId="message-1"
displayMessageId="server:message-1:assistant"
remoteMessageId="message-1"
imageUrl="/chat-image.png"
onOpenImage={() => undefined}
/>,
@@ -136,6 +137,7 @@ describe("chat Tailwind components", () => {
const paywalledHtml = renderToStaticMarkup(
<ImageBubble
characterId="elio"
displayMessageId="server:message-2:assistant"
imageUrl="/locked-image.png"
imagePaywalled
/>,
+16 -10
View File
@@ -54,13 +54,18 @@ export interface ChatAreaProps {
isLoadingMoreHistory?: boolean;
isUnlockingMessage?: boolean;
unlockingMessageId?: string | null;
onUnlockPrivateMessage?: (messageId: string) => void;
onUnlockVoiceMessage?: (messageId: string) => void;
onUnlockImageMessage?: (messageId: string) => void;
onOpenImage?: (messageId: string) => void;
onUnlockPrivateMessage?: ChatMessageAction;
onUnlockVoiceMessage?: ChatMessageAction;
onUnlockImageMessage?: ChatMessageAction;
onOpenImage?: (displayMessageId: string) => void;
onLoadMoreHistory?: () => void;
}
type ChatMessageAction = (
displayMessageId: string,
remoteMessageId?: string,
) => void;
export function ChatArea({
characterId,
messages,
@@ -278,10 +283,10 @@ function renderMessagesWithDateHeaders(
getMessageKey: ChatMessageKeyResolver,
isUnlockingMessage?: boolean,
unlockingMessageId?: string | null,
onUnlockPrivateMessage?: (messageId: string) => void,
onUnlockVoiceMessage?: (messageId: string) => void,
onUnlockImageMessage?: (messageId: string) => void,
onOpenImage?: (messageId: string) => void,
onUnlockPrivateMessage?: ChatMessageAction,
onUnlockVoiceMessage?: ChatMessageAction,
onUnlockImageMessage?: ChatMessageAction,
onOpenImage?: (displayMessageId: string) => void,
) {
return buildChatRenderItems(messages, getMessageKey).map((item) =>
item.type === "date" ? (
@@ -290,7 +295,8 @@ function renderMessagesWithDateHeaders(
<MessageBubble
characterId={characterId}
key={item.key}
messageId={item.message.id}
displayMessageId={item.message.displayId}
remoteMessageId={item.message.remoteId}
content={item.message.content}
imageUrl={item.message.imageUrl}
imagePaywalled={item.message.imagePaywalled}
@@ -302,7 +308,7 @@ function renderMessagesWithDateHeaders(
privateMessageHint={item.message.privateMessageHint}
isUnlockingMessage={
isUnlockingMessage === true &&
item.message.id === unlockingMessageId
item.message.displayId === unlockingMessageId
}
onUnlockPrivateMessage={onUnlockPrivateMessage}
onUnlockVoiceMessage={onUnlockVoiceMessage}
+3 -3
View File
@@ -13,7 +13,7 @@ import { useCachedChatMediaUrl } from "@/lib/chat/use_cached_chat_media_url";
export interface ChatMediaImageProps {
characterId: string;
messageId?: string;
remoteMessageId?: string;
remoteUrl: string;
alt?: string;
className?: string;
@@ -30,7 +30,7 @@ export interface ChatMediaImageProps {
export function ChatMediaImage({
characterId,
messageId,
remoteMessageId,
remoteUrl,
alt = "",
className,
@@ -48,7 +48,7 @@ export function ChatMediaImage({
const { mediaUrl, isUsingCachedMedia, reportMediaError } =
useCachedChatMediaUrl({
characterId,
messageId,
messageId: remoteMessageId,
remoteUrl,
kind: "image",
});
@@ -19,7 +19,7 @@ import styles from "./fullscreen-image-viewer.module.css";
export interface FullscreenImageViewerProps {
characterId: string;
messageId?: string;
remoteMessageId?: string;
imageUrl: string;
imagePaywalled?: boolean;
isUnlockingImagePaywall?: boolean;
@@ -29,7 +29,7 @@ export interface FullscreenImageViewerProps {
export function FullscreenImageViewer({
characterId,
messageId,
remoteMessageId,
imageUrl,
imagePaywalled = false,
isUnlockingImagePaywall = false,
@@ -54,7 +54,7 @@ export function FullscreenImageViewer({
>
<ChatMediaImage
characterId={characterId}
messageId={messageId}
remoteMessageId={remoteMessageId}
remoteUrl={imageUrl}
className={styles.paywallImage}
nativeClassName={`${styles.nativePaywallImage} ${styles.paywallImage}`}
@@ -104,7 +104,7 @@ export function FullscreenImageViewer({
/>
<ChatMediaImage
characterId={characterId}
messageId={messageId}
remoteMessageId={remoteMessageId}
remoteUrl={imageUrl}
className={styles.viewerImage}
errorClassName="error"
+9 -7
View File
@@ -12,20 +12,22 @@ import { ChatMediaImage } from "./chat-media-image";
export interface ImageBubbleProps {
characterId: string;
messageId?: string;
displayMessageId: string;
remoteMessageId?: string;
imageUrl: string; // base64 data URI 或 URL
imagePaywalled?: boolean;
onOpenImage?: (messageId: string) => void;
onOpenImage?: (displayMessageId: string) => void;
}
export function ImageBubble({
characterId,
messageId,
displayMessageId,
remoteMessageId,
imageUrl,
imagePaywalled = false,
onOpenImage,
}: ImageBubbleProps) {
const canOpen = Boolean(messageId && onOpenImage);
const canOpen = Boolean(onOpenImage);
const imageClassName = [
"block h-auto w-full object-cover",
imagePaywalled ? "scale-104 blur-sm" : "",
@@ -34,8 +36,8 @@ export function ImageBubble({
.join(" ");
const openImage = () => {
if (!messageId || !onOpenImage) return;
onOpenImage(messageId);
if (!onOpenImage) return;
onOpenImage(displayMessageId);
};
return (
@@ -55,7 +57,7 @@ export function ImageBubble({
>
<ChatMediaImage
characterId={characterId}
messageId={messageId}
remoteMessageId={remoteMessageId}
remoteUrl={imageUrl}
className={imageClassName}
errorClassName="flex size-(--chat-media-size,220px) items-center justify-center bg-(--color-bubble-background,#fff) text-(length:--icon-size-xl,24px) text-(--color-text-secondary,#9e9e9e)"
+19 -10
View File
@@ -16,7 +16,8 @@ import styles from "./chat-area.module.css";
export interface MessageBubbleProps {
characterId: string;
messageId?: string;
displayMessageId: string;
remoteMessageId?: string;
content: string;
imageUrl?: string | null;
imagePaywalled?: boolean;
@@ -27,15 +28,21 @@ export interface MessageBubbleProps {
lockedPrivate?: boolean | null;
privateMessageHint?: string | null;
isUnlockingMessage?: boolean;
onUnlockPrivateMessage?: (messageId: string) => void;
onUnlockVoiceMessage?: (messageId: string) => void;
onUnlockImageMessage?: (messageId: string) => void;
onOpenImage?: (messageId: string) => void;
onUnlockPrivateMessage?: ChatMessageAction;
onUnlockVoiceMessage?: ChatMessageAction;
onUnlockImageMessage?: ChatMessageAction;
onOpenImage?: (displayMessageId: string) => void;
}
type ChatMessageAction = (
displayMessageId: string,
remoteMessageId?: string,
) => void;
export function MessageBubble({
characterId,
messageId,
displayMessageId,
remoteMessageId,
content,
imageUrl,
imagePaywalled,
@@ -57,18 +64,19 @@ export function MessageBubble({
return (
<div
className={styles.bubbleRowAi}
data-chat-message-id={messageId}
data-chat-message-id={displayMessageId}
aria-label="AI message"
>
<MessageAvatar isFromAI={true} />
<div className={styles.bubbleInlineSpacer} aria-hidden="true" />
<MessageContent
characterId={characterId}
displayMessageId={displayMessageId}
remoteMessageId={remoteMessageId}
content={content}
imageUrl={imageUrl}
imagePaywalled={imagePaywalled}
audioUrl={audioUrl}
messageId={messageId}
isFromAI={true}
locked={locked}
lockReason={lockReason}
@@ -88,17 +96,18 @@ export function MessageBubble({
return (
<div
className={styles.bubbleRowUser}
data-chat-message-id={messageId}
data-chat-message-id={displayMessageId}
aria-label="User message"
>
<div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
<MessageContent
characterId={characterId}
displayMessageId={displayMessageId}
remoteMessageId={remoteMessageId}
content={content}
imageUrl={imageUrl}
imagePaywalled={imagePaywalled}
audioUrl={audioUrl}
messageId={messageId}
isFromAI={false}
locked={locked}
lockReason={lockReason}
+23 -14
View File
@@ -8,32 +8,39 @@ import styles from "./chat-area.module.css";
export interface MessageContentProps {
characterId: string;
displayMessageId: string;
remoteMessageId?: string;
content: string;
imageUrl?: string | null;
imagePaywalled?: boolean;
audioUrl?: string | null;
messageId?: string;
isFromAI: boolean;
locked?: boolean | null;
lockReason?: string | null;
lockedPrivate?: boolean | null;
privateMessageHint?: string | null;
isUnlockingMessage?: boolean;
onUnlockPrivateMessage?: (messageId: string) => void;
onUnlockVoiceMessage?: (messageId: string) => void;
onUnlockImageMessage?: (messageId: string) => void;
onOpenImage?: (messageId: string) => void;
onUnlockPrivateMessage?: ChatMessageAction;
onUnlockVoiceMessage?: ChatMessageAction;
onUnlockImageMessage?: ChatMessageAction;
onOpenImage?: (displayMessageId: string) => void;
}
type ChatMessageAction = (
displayMessageId: string,
remoteMessageId?: string,
) => void;
const IMAGE_PLACEHOLDER = "[图片]";
export function MessageContent({
characterId,
displayMessageId,
remoteMessageId,
content,
imageUrl,
imagePaywalled,
audioUrl,
messageId,
isFromAI,
locked,
lockReason,
@@ -57,16 +64,17 @@ export function MessageContent({
(lockReason === "image_paywall" || lockReason === "image");
const shouldRenderVoiceMessage = hasAudio || isLockedVoiceMessage;
const handleUnlockPrivateMessage =
messageId && onUnlockPrivateMessage
? () => onUnlockPrivateMessage(messageId)
onUnlockPrivateMessage
? () =>
onUnlockPrivateMessage(displayMessageId, remoteMessageId)
: undefined;
const handleUnlockVoiceMessage =
isLockedVoiceMessage && messageId && onUnlockVoiceMessage
? () => onUnlockVoiceMessage(messageId)
isLockedVoiceMessage && onUnlockVoiceMessage
? () => onUnlockVoiceMessage(displayMessageId, remoteMessageId)
: undefined;
const handleUnlockImageMessage =
isLockedImageMessage && messageId && onUnlockImageMessage
? () => onUnlockImageMessage(messageId)
isLockedImageMessage && onUnlockImageMessage
? () => onUnlockImageMessage(displayMessageId, remoteMessageId)
: undefined;
return (
@@ -93,7 +101,8 @@ export function MessageContent({
{hasImage && imageUrl && (
<ImageBubble
characterId={characterId}
messageId={messageId}
displayMessageId={displayMessageId}
remoteMessageId={remoteMessageId}
imageUrl={imageUrl}
imagePaywalled={imagePaywalled}
onOpenImage={onOpenImage}
@@ -102,7 +111,7 @@ export function MessageContent({
{shouldRenderVoiceMessage ? (
<VoiceBubble
characterId={characterId}
messageId={messageId}
remoteMessageId={remoteMessageId}
audioUrl={audioUrl}
isFromAI={isFromAI}
locked={isLockedVoiceMessage}
+3 -3
View File
@@ -9,7 +9,7 @@ import styles from "./voice-bubble.module.css";
export interface VoiceBubbleProps {
characterId: string;
messageId?: string;
remoteMessageId?: string;
audioUrl?: string | null;
isFromAI: boolean;
locked?: boolean;
@@ -20,7 +20,7 @@ export interface VoiceBubbleProps {
export function VoiceBubble({
characterId,
messageId,
remoteMessageId,
audioUrl,
isFromAI,
locked = false,
@@ -36,7 +36,7 @@ export function VoiceBubble({
const { mediaUrl, isUsingCachedMedia, reportMediaError } =
useCachedChatMediaUrl({
characterId,
messageId,
messageId: remoteMessageId,
remoteUrl: audioUrl,
kind: "audio",
});