refactor(chat): split screen orchestration
Docker Image / Build and Push Docker Image (push) Successful in 14m9s
Docker Image / Build and Push Docker Image (push) Successful in 14m9s
This commit is contained in:
@@ -59,6 +59,32 @@
|
||||
gap: var(--spacing-1, 4px);
|
||||
}
|
||||
|
||||
.bubbleInlineSpacer {
|
||||
flex: 0 0 var(--spacing-sm, 8px);
|
||||
width: var(--spacing-sm, 8px);
|
||||
}
|
||||
|
||||
.bubbleAvatarSpacer {
|
||||
flex: 0 0 var(--chat-avatar-size, 43px);
|
||||
width: var(--chat-avatar-size, 43px);
|
||||
}
|
||||
|
||||
.messageContent {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm, 8px);
|
||||
}
|
||||
|
||||
.messageContentAi {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.messageContentUser {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.bubbleAi {
|
||||
max-width: var(--chat-bubble-max-width, 75%);
|
||||
background: var(--color-bubble-ai, rgba(255, 255, 255, 0.08));
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* - 新消息到达时自动滚到底部
|
||||
* - 用户向上滚动时不强制拉回(保持阅读上下文)
|
||||
*/
|
||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef } from "react";
|
||||
|
||||
import type { UiMessage } from "@/data/dto/chat";
|
||||
|
||||
@@ -31,11 +31,14 @@ import styles from "./chat-area.module.css";
|
||||
const CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD = 96;
|
||||
|
||||
type RestoreState = "idle" | "checking" | "restoring" | "settlingBottom" | "done";
|
||||
type ChatMessageKeyResolver = (message: UiMessage) => string;
|
||||
type ChatRenderItem =
|
||||
| { type: "date"; date: string; key: string }
|
||||
| { type: "msg"; message: UiMessage; key: string };
|
||||
|
||||
export interface ChatAreaProps {
|
||||
messages: readonly UiMessage[];
|
||||
isReplyingAI: boolean;
|
||||
isGuest: boolean;
|
||||
isUnlockingMessage?: boolean;
|
||||
unlockingMessageId?: string | null;
|
||||
onUnlockPrivateMessage?: (messageId: string) => void;
|
||||
@@ -55,6 +58,7 @@ export function ChatArea({
|
||||
const restoredScrollRef = useRef(false);
|
||||
const restoreStateRef = useRef<RestoreState>("idle");
|
||||
const wasNearBottomRef = useRef(true);
|
||||
const getMessageKey = useMemo(() => createChatMessageKeyResolver(), []);
|
||||
|
||||
const saveCurrentScrollSnapshotNow = (anchorMessageId: string) => {
|
||||
const scrollNode = scrollRef.current;
|
||||
@@ -160,6 +164,7 @@ export function ChatArea({
|
||||
|
||||
{renderMessagesWithDateHeaders(
|
||||
messages,
|
||||
getMessageKey,
|
||||
isUnlockingMessage,
|
||||
unlockingMessageId,
|
||||
onUnlockPrivateMessage,
|
||||
@@ -221,21 +226,13 @@ function startStableScrollTask({
|
||||
/** 渲染消息列表(按日期分组插入分隔条) */
|
||||
function renderMessagesWithDateHeaders(
|
||||
messages: readonly UiMessage[],
|
||||
getMessageKey: (message: UiMessage) => string,
|
||||
isUnlockingMessage?: boolean,
|
||||
unlockingMessageId?: string | null,
|
||||
onUnlockPrivateMessage?: (messageId: string) => void,
|
||||
onUnlockVoiceMessage?: (messageId: string) => void,
|
||||
) {
|
||||
const items: Array<{ type: "date"; date: string; key: string } | { type: "msg"; message: UiMessage; key: string }> = [];
|
||||
|
||||
messages.forEach((m, i) => {
|
||||
if (i === 0 || m.date !== messages[i - 1].date) {
|
||||
items.push({ type: "date", date: m.date, key: `d-${i}-${m.date}` });
|
||||
}
|
||||
items.push({ type: "msg", message: m, key: `m-${i}` });
|
||||
});
|
||||
|
||||
return items.map((item) =>
|
||||
return buildChatRenderItems(messages, getMessageKey).map((item) =>
|
||||
item.type === "date" ? (
|
||||
<DateHeader key={item.key} date={item.date} />
|
||||
) : (
|
||||
@@ -261,3 +258,41 @@ function renderMessagesWithDateHeaders(
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function createChatMessageKeyResolver(): ChatMessageKeyResolver {
|
||||
const localMessageKeys = new WeakMap<UiMessage, string>();
|
||||
let nextLocalMessageKey = 0;
|
||||
|
||||
return (message) => {
|
||||
if (message.id && message.id.length > 0) return `msg-${message.id}`;
|
||||
|
||||
const existing = localMessageKeys.get(message);
|
||||
if (existing) return existing;
|
||||
|
||||
nextLocalMessageKey += 1;
|
||||
const next = `local-msg-${nextLocalMessageKey}`;
|
||||
localMessageKeys.set(message, next);
|
||||
return next;
|
||||
};
|
||||
}
|
||||
|
||||
export function buildChatRenderItems(
|
||||
messages: readonly UiMessage[],
|
||||
getMessageKey: ChatMessageKeyResolver,
|
||||
): ChatRenderItem[] {
|
||||
const items: ChatRenderItem[] = [];
|
||||
|
||||
messages.forEach((message, index) => {
|
||||
const messageKey = getMessageKey(message);
|
||||
if (index === 0 || message.date !== messages[index - 1].date) {
|
||||
items.push({
|
||||
type: "date",
|
||||
date: message.date,
|
||||
key: `date-${message.date}-${messageKey}`,
|
||||
});
|
||||
}
|
||||
items.push({ type: "msg", message, key: messageKey });
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
|
||||
import type { ChatUnlockPaywallRequest } from "@/stores/chat/chat-state";
|
||||
|
||||
import { HistoryUnlockDialog } from "./history-unlock-dialog";
|
||||
import { InsufficientCreditsDialog } from "./insufficient-credits-dialog";
|
||||
|
||||
export interface ChatUnlockDialogsProps {
|
||||
unlockPaywallRequest: ChatUnlockPaywallRequest | null;
|
||||
onCloseInsufficientCreditsDialog: () => void;
|
||||
onConfirmInsufficientCreditsDialog: () => void;
|
||||
}
|
||||
|
||||
export function ChatUnlockDialogs({
|
||||
unlockPaywallRequest,
|
||||
onCloseInsufficientCreditsDialog,
|
||||
onConfirmInsufficientCreditsDialog,
|
||||
}: ChatUnlockDialogsProps) {
|
||||
const chatState = useChatState();
|
||||
const chatDispatch = useChatDispatch();
|
||||
|
||||
return (
|
||||
<>
|
||||
<HistoryUnlockDialog
|
||||
open={chatState.unlockHistoryPromptVisible}
|
||||
lockedCount={chatState.lockedHistoryCount}
|
||||
isLoading={chatState.isUnlockingHistory}
|
||||
errorMessage={chatState.unlockHistoryError}
|
||||
onClose={() => chatDispatch({ type: "ChatUnlockHistoryDismissed" })}
|
||||
onConfirm={() => chatDispatch({ type: "ChatUnlockHistoryConfirmed" })}
|
||||
/>
|
||||
<InsufficientCreditsDialog
|
||||
open={unlockPaywallRequest !== null}
|
||||
creditBalance={unlockPaywallRequest?.creditBalance ?? 0}
|
||||
requiredCredits={unlockPaywallRequest?.requiredCredits ?? 0}
|
||||
shortfallCredits={unlockPaywallRequest?.shortfallCredits ?? 0}
|
||||
onClose={onCloseInsufficientCreditsDialog}
|
||||
onConfirm={onConfirmInsufficientCreditsDialog}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export * from "./chat-insufficient-credits-banner";
|
||||
export * from "./chat-input-bar";
|
||||
export * from "./chat-input-text-field";
|
||||
export * from "./chat-send-button";
|
||||
export * from "./chat-unlock-dialogs";
|
||||
export * from "./date-header";
|
||||
export * from "./external-browser-dialog";
|
||||
export * from "./first-recharge-offer-banner";
|
||||
|
||||
@@ -55,7 +55,7 @@ export function MessageBubble({
|
||||
aria-label="AI message"
|
||||
>
|
||||
<MessageAvatar isFromAI={true} />
|
||||
<div style={{ width: 8 }} />
|
||||
<div className={styles.bubbleInlineSpacer} aria-hidden="true" />
|
||||
<MessageContent
|
||||
content={content}
|
||||
imageUrl={imageUrl}
|
||||
@@ -71,7 +71,7 @@ export function MessageBubble({
|
||||
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
||||
/>
|
||||
<div style={{ width: 43 }} /> {/* 占位:保持与头像宽度一致 */}
|
||||
<div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -82,7 +82,7 @@ export function MessageBubble({
|
||||
data-chat-message-id={messageId}
|
||||
aria-label="User message"
|
||||
>
|
||||
<div style={{ width: 43 }} /> {/* 占位 */}
|
||||
<div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
|
||||
<MessageContent
|
||||
content={content}
|
||||
imageUrl={imageUrl}
|
||||
@@ -98,7 +98,7 @@ export function MessageBubble({
|
||||
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
||||
/>
|
||||
<div style={{ width: 8 }} />
|
||||
<div className={styles.bubbleInlineSpacer} aria-hidden="true" />
|
||||
<MessageAvatar isFromAI={false} userAvatarUrl={avatarUrl} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ImageBubble } from "./image-bubble";
|
||||
import { PrivateMessageCard } from "./private-message-card";
|
||||
import { TextBubble } from "./text-bubble";
|
||||
import { VoiceBubble } from "./voice-bubble";
|
||||
import styles from "./chat-area.module.css";
|
||||
|
||||
export interface MessageContentProps {
|
||||
content: string;
|
||||
@@ -55,16 +56,10 @@ export function MessageContent({
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
// 关键:在 row flex 父中撑开(不依赖 content 循环)—— 让 TextBubble 能拿到真实宽度
|
||||
flex: "1 1 0",
|
||||
// 防超长 content(图片 / 超长 url)撑爆父
|
||||
minWidth: 0,
|
||||
alignItems: isFromAI ? "flex-start" : "flex-end",
|
||||
gap: 8,
|
||||
}}
|
||||
className={[
|
||||
styles.messageContent,
|
||||
isFromAI ? styles.messageContentAi : styles.messageContentUser,
|
||||
].join(" ")}
|
||||
>
|
||||
{isLockedPrivateMessage ? (
|
||||
<PrivateMessageCard
|
||||
|
||||
Reference in New Issue
Block a user