feat(characters): support character-scoped conversations
This commit is contained in:
@@ -52,6 +52,7 @@ export function ChatScreen() {
|
||||
const authState = useAuthState();
|
||||
const authDispatch = useAuthDispatch();
|
||||
useSplashLatestMessageSync({
|
||||
characterId: state.characterId,
|
||||
historyLoaded: state.historyLoaded,
|
||||
loginStatus: authState.loginStatus,
|
||||
messages: state.historyMessages,
|
||||
@@ -182,6 +183,7 @@ export function ChatScreen() {
|
||||
/>
|
||||
|
||||
<ChatArea
|
||||
characterId={state.characterId}
|
||||
messages={visibleMessages}
|
||||
isReplyingAI={state.isReplyingAI}
|
||||
scrollToBottomSignal={state.outgoingMessageRevision}
|
||||
@@ -220,6 +222,7 @@ export function ChatScreen() {
|
||||
|
||||
{selectedImageMessage?.imageUrl ? (
|
||||
<FullscreenImageViewer
|
||||
characterId={state.characterId}
|
||||
messageId={selectedImageMessage.id}
|
||||
imageUrl={selectedImageMessage.imageUrl}
|
||||
imagePaywalled={selectedImageMessage.imagePaywalled === true}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
|
||||
import type { UiMessage } from "@/data/dto/chat";
|
||||
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
|
||||
import {
|
||||
@@ -45,6 +46,7 @@ const ReplyingAnimation = lazy(() =>
|
||||
);
|
||||
|
||||
export interface ChatAreaProps {
|
||||
characterId?: string;
|
||||
messages: readonly UiMessage[];
|
||||
isReplyingAI: boolean;
|
||||
scrollToBottomSignal?: number;
|
||||
@@ -61,6 +63,7 @@ export interface ChatAreaProps {
|
||||
}
|
||||
|
||||
export function ChatArea({
|
||||
characterId = DEFAULT_CHARACTER_ID,
|
||||
messages,
|
||||
isReplyingAI,
|
||||
scrollToBottomSignal = 0,
|
||||
@@ -235,6 +238,7 @@ export function ChatArea({
|
||||
<AiDisclosureBanner />
|
||||
|
||||
{renderMessagesWithDateHeaders(
|
||||
characterId,
|
||||
messages,
|
||||
getMessageKey,
|
||||
isUnlockingMessage,
|
||||
@@ -270,6 +274,7 @@ function isNearBottom(scrollNode: HTMLElement): boolean {
|
||||
|
||||
/** 渲染消息列表(按日期分组插入分隔条) */
|
||||
function renderMessagesWithDateHeaders(
|
||||
characterId: string,
|
||||
messages: readonly UiMessage[],
|
||||
getMessageKey: ChatMessageKeyResolver,
|
||||
isUnlockingMessage?: boolean,
|
||||
@@ -284,6 +289,7 @@ function renderMessagesWithDateHeaders(
|
||||
<DateHeader key={item.key} date={item.date} />
|
||||
) : (
|
||||
<MessageBubble
|
||||
characterId={characterId}
|
||||
key={item.key}
|
||||
messageId={item.message.id}
|
||||
content={item.message.content}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { useCachedChatMediaUrl } from "@/lib/chat/use_cached_chat_media_url";
|
||||
|
||||
export interface ChatMediaImageProps {
|
||||
characterId: string;
|
||||
messageId?: string;
|
||||
remoteUrl: string;
|
||||
alt?: string;
|
||||
@@ -28,6 +29,7 @@ export interface ChatMediaImageProps {
|
||||
}
|
||||
|
||||
export function ChatMediaImage({
|
||||
characterId,
|
||||
messageId,
|
||||
remoteUrl,
|
||||
alt = "",
|
||||
@@ -45,6 +47,7 @@ export function ChatMediaImage({
|
||||
const [errorSrc, setErrorSrc] = useState<string | null>(null);
|
||||
const { mediaUrl, isUsingCachedMedia, reportMediaError } =
|
||||
useCachedChatMediaUrl({
|
||||
characterId,
|
||||
messageId,
|
||||
remoteUrl,
|
||||
kind: "image",
|
||||
|
||||
@@ -18,6 +18,7 @@ import { ChatMediaImage } from "./chat-media-image";
|
||||
import styles from "./fullscreen-image-viewer.module.css";
|
||||
|
||||
export interface FullscreenImageViewerProps {
|
||||
characterId: string;
|
||||
messageId?: string;
|
||||
imageUrl: string;
|
||||
imagePaywalled?: boolean;
|
||||
@@ -27,6 +28,7 @@ export interface FullscreenImageViewerProps {
|
||||
}
|
||||
|
||||
export function FullscreenImageViewer({
|
||||
characterId,
|
||||
messageId,
|
||||
imageUrl,
|
||||
imagePaywalled = false,
|
||||
@@ -51,6 +53,7 @@ export function FullscreenImageViewer({
|
||||
aria-label="Locked fullscreen image"
|
||||
>
|
||||
<ChatMediaImage
|
||||
characterId={characterId}
|
||||
messageId={messageId}
|
||||
remoteUrl={imageUrl}
|
||||
className={styles.paywallImage}
|
||||
@@ -100,6 +103,7 @@ export function FullscreenImageViewer({
|
||||
variant="unstyled"
|
||||
/>
|
||||
<ChatMediaImage
|
||||
characterId={characterId}
|
||||
messageId={messageId}
|
||||
remoteUrl={imageUrl}
|
||||
className={styles.viewerImage}
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
*/
|
||||
|
||||
import { ChatMediaImage } from "./chat-media-image";
|
||||
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||
|
||||
export interface ImageBubbleProps {
|
||||
characterId?: string;
|
||||
messageId?: string;
|
||||
imageUrl: string; // base64 data URI 或 URL
|
||||
imagePaywalled?: boolean;
|
||||
@@ -18,6 +20,7 @@ export interface ImageBubbleProps {
|
||||
}
|
||||
|
||||
export function ImageBubble({
|
||||
characterId = DEFAULT_CHARACTER_ID,
|
||||
messageId,
|
||||
imageUrl,
|
||||
imagePaywalled = false,
|
||||
@@ -52,6 +55,7 @@ export function ImageBubble({
|
||||
aria-label={canOpen ? "Open image in fullscreen" : undefined}
|
||||
>
|
||||
<ChatMediaImage
|
||||
characterId={characterId}
|
||||
messageId={messageId}
|
||||
remoteUrl={imageUrl}
|
||||
className={imageClassName}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { MessageContent } from "./message-content";
|
||||
import styles from "./chat-area.module.css";
|
||||
|
||||
export interface MessageBubbleProps {
|
||||
characterId: string;
|
||||
messageId?: string;
|
||||
content: string;
|
||||
imageUrl?: string | null;
|
||||
@@ -33,6 +34,7 @@ export interface MessageBubbleProps {
|
||||
}
|
||||
|
||||
export function MessageBubble({
|
||||
characterId,
|
||||
messageId,
|
||||
content,
|
||||
imageUrl,
|
||||
@@ -61,6 +63,7 @@ export function MessageBubble({
|
||||
<MessageAvatar isFromAI={true} />
|
||||
<div className={styles.bubbleInlineSpacer} aria-hidden="true" />
|
||||
<MessageContent
|
||||
characterId={characterId}
|
||||
content={content}
|
||||
imageUrl={imageUrl}
|
||||
imagePaywalled={imagePaywalled}
|
||||
@@ -90,6 +93,7 @@ export function MessageBubble({
|
||||
>
|
||||
<div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
|
||||
<MessageContent
|
||||
characterId={characterId}
|
||||
content={content}
|
||||
imageUrl={imageUrl}
|
||||
imagePaywalled={imagePaywalled}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { VoiceBubble } from "./voice-bubble";
|
||||
import styles from "./chat-area.module.css";
|
||||
|
||||
export interface MessageContentProps {
|
||||
characterId: string;
|
||||
content: string;
|
||||
imageUrl?: string | null;
|
||||
imagePaywalled?: boolean;
|
||||
@@ -27,6 +28,7 @@ export interface MessageContentProps {
|
||||
const IMAGE_PLACEHOLDER = "[图片]";
|
||||
|
||||
export function MessageContent({
|
||||
characterId,
|
||||
content,
|
||||
imageUrl,
|
||||
imagePaywalled,
|
||||
@@ -90,6 +92,7 @@ export function MessageContent({
|
||||
<>
|
||||
{hasImage && imageUrl && (
|
||||
<ImageBubble
|
||||
characterId={characterId}
|
||||
messageId={messageId}
|
||||
imageUrl={imageUrl}
|
||||
imagePaywalled={imagePaywalled}
|
||||
@@ -98,6 +101,7 @@ export function MessageContent({
|
||||
)}
|
||||
{shouldRenderVoiceMessage ? (
|
||||
<VoiceBubble
|
||||
characterId={characterId}
|
||||
messageId={messageId}
|
||||
audioUrl={audioUrl}
|
||||
isFromAI={isFromAI}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useCachedChatMediaUrl } from "@/lib/chat/use_cached_chat_media_url";
|
||||
import styles from "./voice-bubble.module.css";
|
||||
|
||||
export interface VoiceBubbleProps {
|
||||
characterId: string;
|
||||
messageId?: string;
|
||||
audioUrl?: string | null;
|
||||
isFromAI: boolean;
|
||||
@@ -17,6 +18,7 @@ export interface VoiceBubbleProps {
|
||||
}
|
||||
|
||||
export function VoiceBubble({
|
||||
characterId,
|
||||
messageId,
|
||||
audioUrl,
|
||||
isFromAI,
|
||||
@@ -31,6 +33,7 @@ export function VoiceBubble({
|
||||
const hasAudio = audioUrl != null && audioUrl.length > 0;
|
||||
const { mediaUrl, isUsingCachedMedia, reportMediaError } =
|
||||
useCachedChatMediaUrl({
|
||||
characterId,
|
||||
messageId,
|
||||
remoteUrl: audioUrl,
|
||||
kind: "audio",
|
||||
|
||||
@@ -9,12 +9,14 @@ import { getLatestSplashMessagePreview } from "@/lib/chat/splash_latest_message_
|
||||
import { useSplashLatestMessageCache } from "@/providers/splash-latest-message-provider";
|
||||
|
||||
export interface UseSplashLatestMessageSyncInput {
|
||||
characterId: string;
|
||||
historyLoaded: boolean;
|
||||
loginStatus: LoginStatus;
|
||||
messages: readonly UiMessage[];
|
||||
}
|
||||
|
||||
export function useSplashLatestMessageSync({
|
||||
characterId,
|
||||
historyLoaded,
|
||||
loginStatus,
|
||||
messages,
|
||||
@@ -26,7 +28,7 @@ export function useSplashLatestMessageSync({
|
||||
useEffect(() => {
|
||||
hasObservedInitialSnapshotRef.current = false;
|
||||
identityPromiseRef.current = null;
|
||||
}, [loginStatus]);
|
||||
}, [characterId, loginStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!historyLoaded) return;
|
||||
@@ -39,7 +41,8 @@ export function useSplashLatestMessageSync({
|
||||
const preview = getLatestSplashMessagePreview(messages);
|
||||
let cancelled = false;
|
||||
|
||||
identityPromiseRef.current ??= resolveSplashLatestMessageCacheIdentity();
|
||||
identityPromiseRef.current ??=
|
||||
resolveSplashLatestMessageCacheIdentity(characterId);
|
||||
void identityPromiseRef.current.then((identity) => {
|
||||
if (!cancelled && identity) {
|
||||
cache.set(identity, preview);
|
||||
@@ -49,5 +52,5 @@ export function useSplashLatestMessageSync({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [cache, historyLoaded, messages]);
|
||||
}, [cache, characterId, historyLoaded, messages]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user