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
+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",