feat(characters): support character-scoped conversations

This commit is contained in:
2026-07-17 11:42:31 +08:00
parent 93efcb6604
commit 2796010971
85 changed files with 1645 additions and 251 deletions
+3
View File
@@ -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}
+6
View File
@@ -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}
+4
View File
@@ -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}
+3
View File
@@ -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]);
}