feat: 优化移动端体验
This commit is contained in:
parent
ddff5100b6
commit
e89f6ce94d
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M24 22C24.5523 22 25 22.4477 25 23C25 23.5523 24.5523 24 24 24H8C7.44772 24 7 23.5523 7 23C7 22.4477 7.44772 22 8 22H24ZM25 16L21 19V17H8C7.44772 17 7 16.5523 7 16C7 15.4477 7.44772 15 8 15H21V13L25 16ZM24 8C24.5523 8 25 8.44772 25 9C25 9.55228 24.5523 10 24 10H8C7.44772 10 7 9.55228 7 9C7 8.44772 7.44772 8 8 8H24Z" fill="#958E9E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 447 B |
|
|
@ -2,7 +2,7 @@
|
|||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAsyncFn } from '@/hooks/tools';
|
||||
import { useStreamChatStore } from '@/stores/stream-chat';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||
|
||||
export default function ChatButton({ id }: { id: string }) {
|
||||
const router = useRouter();
|
||||
|
|
|
|||
|
|
@ -1,28 +1,30 @@
|
|||
import { AvatarImage, AvatarFallback, Avatar } from '@radix-ui/react-avatar';
|
||||
import ChatButton from './ChatButton';
|
||||
import { fetchCharacter } from './service';
|
||||
import { Chip } from '@/components/ui/chip';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const user = {
|
||||
name: 'Honey Snow',
|
||||
headImage: 'https://picsum.photos/200/300',
|
||||
nickname: 'Crush',
|
||||
};
|
||||
const character = await fetchCharacter(id);
|
||||
|
||||
return (
|
||||
<div className="flex px-4 pt-10">
|
||||
<div className="mx-auto w-full max-w-[752px]">
|
||||
<header className="flex items-end justify-between">
|
||||
<header className="flex items-end gap-10 justify-between">
|
||||
<div className="flex gap-6 items-end">
|
||||
<Avatar className="size-32 rounded-full overflow-hidden cursor-pointer">
|
||||
<AvatarImage className="h-32 w-32 object-cover" src={user?.headImage} />
|
||||
<AvatarFallback className="!txt-headline-m">
|
||||
{user?.nickname?.slice(0, 1)}
|
||||
</AvatarFallback>
|
||||
<Avatar className="size-32">
|
||||
<AvatarImage src={character?.headPortrait} />
|
||||
<AvatarFallback>{character?.name?.slice(0, 2)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="txt-headline-s">{user?.name}</div>
|
||||
<div className="text-sm mt-4 text-text-300">{user?.nickname}</div>
|
||||
<div className="txt-headline-s">{character?.name}</div>
|
||||
<div className="flex flex-wrap mt-4 gap-2">
|
||||
{character?.tags?.map((tag: any) => (
|
||||
<Chip className="rounded-xs" key={tag.tagId}>
|
||||
{tag.name}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -31,11 +33,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
|
|||
</header>
|
||||
<div className="mt-12 rounded-2xl bg-white/10 p-6">
|
||||
<div className="txt-headline-s">Introduction</div>
|
||||
<div>
|
||||
She is a new and beautiful teacher and has just graduated. You are the most rebellious
|
||||
student of the whole school workers. She is a new and beautiful teacher and has just
|
||||
graduated. You are the most rebellious student of the whole school workers. In...
|
||||
</div>
|
||||
<div className="mt-4">{character?.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
import { serverRequest } from '@/lib/server/request';
|
||||
import { cache } from 'react';
|
||||
|
||||
export const fetchCharacter = cache(async (id: string) => {
|
||||
const { data } = await serverRequest('/api/character/detail', {
|
||||
data: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
});
|
||||
|
|
@ -1,13 +1,27 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import AITextRender from '@/components/ui/text-md';
|
||||
|
||||
export default function AIMessage({ data }: { data: any }) {
|
||||
const Loading = () => {
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="bg-txt-secondary-normal h-2 w-2 animate-bounce rounded-full [animation-delay:-0.3s]"></div>
|
||||
<div className="bg-txt-secondary-normal h-2 w-2 animate-bounce rounded-full [animation-delay:-0.15s]"></div>
|
||||
<div className="bg-txt-secondary-normal h-2 w-2 animate-bounce rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function AIMessage({ data }: { data: any }) {
|
||||
return (
|
||||
<div className="mb-8 max-w-[90%]">
|
||||
<div className="bg-surface-element-normal inline-block rounded-lg p-4 backdrop-blur-2xl">
|
||||
<AITextRender text={data.content} />
|
||||
{data.content ? <AITextRender text={data.content} /> : <Loading />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default React.memo(AIMessage);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { cn } from '@/lib/utils';
|
|||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useCharacter } from '@/hooks/services/character';
|
||||
import { useParams } from 'next/navigation';
|
||||
import React from 'react';
|
||||
// import CrushLevelAvatar from './CrushLevelAvatar'
|
||||
|
||||
export const CharacterAvatorAndName = ({ name, avator }: { name: string; avator: string }) => {
|
||||
|
|
@ -20,25 +21,22 @@ export const CharacterAvatorAndName = ({ name, avator }: { name: string; avator:
|
|||
);
|
||||
};
|
||||
|
||||
const ChatMessageUserHeader = () => {
|
||||
function ChatMessageUserHeader() {
|
||||
const [isFullIntroduction, setIsFullIntroduction] = useState(false);
|
||||
const [shouldShowExpandButton, setShouldShowExpandButton] = useState(false);
|
||||
const textRef = useRef<HTMLDivElement>(null);
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data: character = {} } = useCharacter(id.split('-')[2]);
|
||||
const { introduction } = {
|
||||
introduction: 'introduction introduction introduction introduction introduction',
|
||||
};
|
||||
|
||||
// 检测文本是否超过三行
|
||||
useEffect(() => {
|
||||
if (textRef.current && introduction) {
|
||||
if (textRef.current && character.description) {
|
||||
// 直接比较滚动高度和可见高度
|
||||
// 如果内容的实际高度大于容器的可见高度,说明内容被截断了
|
||||
const isOverflowing = textRef.current.scrollHeight > textRef.current.clientHeight;
|
||||
setShouldShowExpandButton(isOverflowing);
|
||||
}
|
||||
}, [introduction]);
|
||||
}, [character.description]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
|
|
@ -55,7 +53,7 @@ const ChatMessageUserHeader = () => {
|
|||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{introduction.repeat(10)}
|
||||
{character.description}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -85,6 +83,6 @@ const ChatMessageUserHeader = () => {
|
|||
<div className="txt-body-m text-txt-secondary-normal">Content generated by AI</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default ChatMessageUserHeader;
|
||||
export default React.memo(ChatMessageUserHeader);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
'use client';
|
||||
import { useChatStore } from '../store';
|
||||
import { Button, IconButton } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import Image from 'next/image';
|
||||
|
|
@ -104,9 +103,7 @@ const BackgroundItemCard = ({
|
|||
};
|
||||
|
||||
export default function Background() {
|
||||
const setSideBar = useChatStore((store) => store.setSideBar);
|
||||
const [selectId, setSelectId] = useState<number | undefined>(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 静态数据:模拟背景图片列表
|
||||
const backgroundList: BackgroundItem[] = [
|
||||
|
|
@ -148,23 +145,7 @@ export default function Background() {
|
|||
handleIndexChange,
|
||||
} = useImageViewer();
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// TODO: 调用实际的 API
|
||||
// await updateChatBackground({ aiId, backgroundId: selectId })
|
||||
console.log('Selected background:', selectId);
|
||||
|
||||
// 模拟延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
setSideBar('profile');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const handleConfirm = async () => {};
|
||||
|
||||
const handleImagePreview = (index: number) => {
|
||||
openViewer(backgroundList?.map((item) => item.imgUrl || '') || [], index);
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
'use client';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { IconButton } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import Image from 'next/image';
|
||||
import { useModels } from '@/hooks/services/chat';
|
||||
import { useStreamChatStore } from '../stream-chat';
|
||||
|
||||
export default function ChatModel() {
|
||||
const { data: models = [] } = useModels();
|
||||
const chatSetting = useStreamChatStore((store) => store.chatSetting);
|
||||
const setChatSetting = useStreamChatStore((store) => store.setChatSetting);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
{models.map((model: any) => (
|
||||
<div
|
||||
key={model}
|
||||
onClick={() => setChatSetting({ chatModel: model })}
|
||||
className="bg-surface-element-normal cursor-pointer overflow-hidden rounded-lg p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="txt-title-s">{model}</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconButton iconfont="icon-question" variant="tertiary" size="mini" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[300px]">
|
||||
<div className="space-y-2">
|
||||
<p className="break-words">
|
||||
Text Message Price: Refers to the cost of chatting with the character via
|
||||
text messages, including sending text, images, or gifts. Charged per
|
||||
message.
|
||||
</p>
|
||||
<p className="break-words">
|
||||
Voice Message Price: Refers to the cost of sending a voice message to the
|
||||
character or playing the character’s voice. Charged per use.
|
||||
</p>
|
||||
<p className="break-words">
|
||||
Voice Call Price: Refers to the cost of having a voice call with the
|
||||
character. Charged per minute.
|
||||
</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Checkbox checked={chatSetting.chatModel === model} shape="round" />
|
||||
</div>
|
||||
<div className="txt-body-m text-txt-secondary-normal mt-1">
|
||||
Role-play a conversation with AI
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-district-normal mt-3 rounded-sm p-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} />
|
||||
<span className="txt-label-m text-txt-primary-normal">1/Text Message</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-1">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
<Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} />
|
||||
<span className="txt-label-m text-txt-primary-normal">10/Send or play voice</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-1">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
<Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} />
|
||||
<span className="txt-label-m text-txt-primary-normal">20/min Voice call</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="txt-body-m text-txt-secondary-normal mt-6">Stay tuned for more models</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,9 +1,7 @@
|
|||
'use client';
|
||||
import { useChatStore } from '../store';
|
||||
import { useState } from 'react';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||
|
||||
type FontOption = {
|
||||
value: number;
|
||||
|
|
@ -12,9 +10,9 @@ type FontOption = {
|
|||
};
|
||||
|
||||
export default function Font() {
|
||||
const setSideBar = useChatStore((store) => store.setSideBar);
|
||||
const chatSetting = useStreamChatStore((store) => store.chatSetting);
|
||||
const setChatSetting = useStreamChatStore((store) => store.setChatSetting);
|
||||
|
||||
// 字体大小选项
|
||||
const fontOptions: FontOption[] = [
|
||||
{ value: 12, label: 'A 12' },
|
||||
{ value: 14, label: 'A 14' },
|
||||
|
|
@ -23,27 +21,6 @@ export default function Font() {
|
|||
{ value: 20, label: 'A 20' },
|
||||
];
|
||||
|
||||
const [selectedFont, setSelectedFont] = useState(16);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// TODO: 调用实际的 API 保存字体设置
|
||||
// await updateFontSize({ fontSize: selectedFont })
|
||||
console.log('Selected font size:', selectedFont);
|
||||
|
||||
// 模拟延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
setSideBar('profile');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex-1">
|
||||
|
|
@ -53,9 +30,9 @@ export default function Font() {
|
|||
key={option.value}
|
||||
className={cn(
|
||||
'bg-surface-element-normal flex h-12 cursor-pointer items-center justify-between rounded-lg px-5 transition-colors',
|
||||
selectedFont === option.value && 'bg-surface-element-hover'
|
||||
chatSetting.font === option.value && 'bg-surface-element-hover'
|
||||
)}
|
||||
onClick={() => setSelectedFont(option.value)}
|
||||
onClick={() => setChatSetting({ font: option.value })}
|
||||
>
|
||||
<div className="txt-title-s flex items-center gap-2">
|
||||
{option.label}
|
||||
|
|
@ -63,7 +40,7 @@ export default function Font() {
|
|||
<span className="txt-body-m text-txt-secondary-normal">(standard)</span>
|
||||
)}
|
||||
</div>
|
||||
<Checkbox shape="round" checked={selectedFont === option.value} />
|
||||
<Checkbox shape="round" checked={chatSetting.font === option.value} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
'use client';
|
||||
import { useChatStore } from '../store';
|
||||
import { useState } from 'react';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||
|
||||
type TokenOption = {
|
||||
value: number;
|
||||
|
|
@ -11,9 +11,9 @@ type TokenOption = {
|
|||
};
|
||||
|
||||
export default function MaxToken() {
|
||||
const setSideBar = useChatStore((store) => store.setSideBar);
|
||||
const chatSetting = useStreamChatStore((store) => store.chatSetting);
|
||||
const setChatSetting = useStreamChatStore((store) => store.setChatSetting);
|
||||
|
||||
// 最大回复数选项
|
||||
const tokenOptions: TokenOption[] = [
|
||||
{ value: 800, label: '800' },
|
||||
{ value: 1000, label: '1000' },
|
||||
|
|
@ -21,27 +21,6 @@ export default function MaxToken() {
|
|||
{ value: 1500, label: '1500' },
|
||||
];
|
||||
|
||||
const [selectedToken, setSelectedToken] = useState(800);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// TODO: 调用实际的 API 保存最大回复数设置
|
||||
// await updateMaxToken({ maxToken: selectedToken })
|
||||
console.log('Selected max token:', selectedToken);
|
||||
|
||||
// 模拟延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
setSideBar('profile');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex-1">
|
||||
|
|
@ -51,12 +30,12 @@ export default function MaxToken() {
|
|||
key={option.value}
|
||||
className={cn(
|
||||
'bg-surface-element-normal flex h-12 cursor-pointer items-center justify-between rounded-lg px-5 transition-colors',
|
||||
selectedToken === option.value && 'bg-surface-element-hover'
|
||||
chatSetting.maximumReplies === option.value && 'bg-surface-element-hover'
|
||||
)}
|
||||
onClick={() => setSelectedToken(option.value)}
|
||||
onClick={() => setChatSetting({ maximumReplies: option.value })}
|
||||
>
|
||||
<div className="txt-title-s">{option.label}</div>
|
||||
<Checkbox shape="round" checked={selectedToken === option.value} />
|
||||
<Checkbox shape="round" checked={chatSetting.maximumReplies === option.value} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
'use client';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useChatStore } from '../store';
|
||||
import { z } from 'zod';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
|
|
@ -83,10 +82,6 @@ const characterFormSchema = z
|
|||
);
|
||||
|
||||
export default function Personal() {
|
||||
const setSideBar = useChatStore((store) => store.setSideBar);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
|
||||
// 静态数据,模拟从接口获取的数据
|
||||
const chatSettingData = {
|
||||
nickname: 'John',
|
||||
|
|
@ -112,62 +107,6 @@ export default function Personal() {
|
|||
},
|
||||
});
|
||||
|
||||
// 处理返回的逻辑
|
||||
const handleGoBack = useCallback(() => {
|
||||
if (form.formState.isDirty) {
|
||||
setShowConfirmDialog(true);
|
||||
} else {
|
||||
setSideBar('profile');
|
||||
}
|
||||
}, [form.formState.isDirty, setSideBar]);
|
||||
|
||||
// 确认放弃修改
|
||||
const handleConfirmDiscard = useCallback(() => {
|
||||
form.reset();
|
||||
setShowConfirmDialog(false);
|
||||
setSideBar('profile');
|
||||
}, [form, setSideBar]);
|
||||
|
||||
async function onSubmit(data: z.infer<typeof characterFormSchema>) {
|
||||
if (!form.formState.isDirty) {
|
||||
setSideBar('profile');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
// TODO: 这里应该调用实际的 API
|
||||
// 模拟检查昵称是否存在
|
||||
const isExist = false; // await checkNickname({ nickname: data.nickname.trim() })
|
||||
|
||||
if (isExist) {
|
||||
form.setError('nickname', {
|
||||
message: 'This nickname is already taken',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: 这里应该调用实际的保存 API
|
||||
// await setMyChatSetting({
|
||||
// aiId,
|
||||
// nickname: data.nickname,
|
||||
// birthday: new Date(`${data.year}-${data.month}-${data.day}`).getTime(),
|
||||
// whoAmI: data.profile || '',
|
||||
// })
|
||||
|
||||
console.log('Saved data:', {
|
||||
nickname: data.nickname,
|
||||
birthday: new Date(`${data.year}-${data.month}-${data.day}`).getTime(),
|
||||
whoAmI: data.profile || '',
|
||||
});
|
||||
|
||||
setSideBar('profile');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedYear = form.watch('year');
|
||||
const selectedMonth = form.watch('month');
|
||||
const days = selectedYear && selectedMonth ? getDaysInMonth(selectedYear, selectedMonth) : [];
|
||||
|
|
@ -345,7 +284,7 @@ export default function Personal() {
|
|||
</div>
|
||||
|
||||
{/* 确认放弃修改的对话框 */}
|
||||
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||
{/* <AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Unsaved Edits</AlertDialogTitle>
|
||||
|
|
@ -363,7 +302,7 @@ export default function Personal() {
|
|||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</AlertDialog> */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,13 +5,16 @@ import { CharacterAvatorAndName } from '../CharacterHeader';
|
|||
import { Tag } from '@/components/ui/tag';
|
||||
import Image from 'next/image';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useChatStore } from '../store';
|
||||
import { Button, IconButton } from '@/components/ui/button';
|
||||
import React from 'react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useCharacter } from '@/hooks/services/character';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { ActiveTabType } from './index';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||
import { useAsyncFn } from '@/hooks/tools';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useModels } from '@/hooks/services/chat';
|
||||
|
||||
const genderMap = {
|
||||
0: '/icons/male.svg',
|
||||
|
|
@ -77,48 +80,67 @@ type ProfileProps = {
|
|||
|
||||
export default function Profile({ onActiveTab }: ProfileProps) {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data: character = {} } = useCharacter(id.split('-')[2]);
|
||||
const characterId = id.split('-')[2];
|
||||
const {} = useModels();
|
||||
const router = useRouter();
|
||||
const { data: character = {} } = useCharacter(characterId);
|
||||
const chatSetting = useStreamChatStore((s) => s.chatSetting);
|
||||
const setChatSetting = useStreamChatStore((s) => s.setChatSetting);
|
||||
const createChannel = useStreamChatStore((s) => s.createChannel);
|
||||
const deleteChannel = useStreamChatStore((s) => s.deleteChannel);
|
||||
|
||||
const preferenceItems: SettingItem[][] = [
|
||||
[
|
||||
{
|
||||
onClick: () => onActiveTab('language'),
|
||||
label: 'Language',
|
||||
value: 'zh-CN',
|
||||
},
|
||||
],
|
||||
];
|
||||
const { loading: creating, run: createChannelAndPush } = useAsyncFn(async () => {
|
||||
const channelId = await createChannel(characterId);
|
||||
if (!channelId) return;
|
||||
router.push(`/chat/${channelId}`);
|
||||
});
|
||||
|
||||
const { run: deleteChannelAsync, loading: deleting } = useAsyncFn(async () => {
|
||||
const { result, newChannels } = await deleteChannel([id]);
|
||||
if (result === 'ok') {
|
||||
if (newChannels?.length) {
|
||||
router.push(`/chat/${newChannels[0].id}`);
|
||||
} else {
|
||||
router.push(`/`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const chatSettingItems: SettingItem[][] = [
|
||||
[
|
||||
{
|
||||
onClick: () => onActiveTab('model'),
|
||||
label: 'Chat Model',
|
||||
value: 'Role-Playing',
|
||||
value: chatSetting?.chatModel,
|
||||
},
|
||||
{
|
||||
onClick: () => null,
|
||||
label: 'Long text',
|
||||
value: <Switch checked={true} />,
|
||||
value: (
|
||||
<Switch
|
||||
onCheckedChange={() => setChatSetting({ longText: chatSetting.longText ? 0 : 1 })}
|
||||
checked={chatSetting.longText === 0}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
onClick: () => onActiveTab('max_token'),
|
||||
label: 'Maximum Replies',
|
||||
value: '1200',
|
||||
value: String(chatSetting?.maximumReplies),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
onClick: () => onActiveTab('font'),
|
||||
label: 'Font',
|
||||
value: '17px',
|
||||
value: String(chatSetting?.font),
|
||||
},
|
||||
{
|
||||
onClick: () => onActiveTab('background'),
|
||||
label: 'Chat Background',
|
||||
value: '17px',
|
||||
value: String(chatSetting?.background),
|
||||
},
|
||||
],
|
||||
];
|
||||
|
|
@ -171,47 +193,39 @@ export default function Profile({ onActiveTab }: ProfileProps) {
|
|||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* <div className="flex justify-between">
|
||||
<IconButton variant="ghost" size="small" onClick={() => setSideBar('profile')}>
|
||||
<i className="iconfont-v2 iconv2-jiantou" />
|
||||
</IconButton>
|
||||
<IconButton variant="ghost" size="small" onClick={() => setSideBar('profile')}>
|
||||
<i className="iconfont-v2 iconv2-jiantou" />
|
||||
</IconButton>
|
||||
</div> */}
|
||||
<div className="flex flex-1 overflow-y-auto show-scrollbar flex-col gap-4">
|
||||
<CharacterAvatorAndName avator={character.headPortrait} name={character.name} />
|
||||
|
||||
{/* Tags */}
|
||||
<div className="flex w-full flex-col items-start justify-start gap-4">
|
||||
<div className="flex w-full flex-wrap items-start justify-center gap-2">
|
||||
<Tag>
|
||||
<Image
|
||||
src={genderMap[0 as keyof typeof genderMap]}
|
||||
alt="Gender"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
<div>{getAge(Number(24))}</div>
|
||||
</Tag>
|
||||
<Tag>{'Sensibility'}</Tag>
|
||||
<Tag>{'Romantic'}</Tag>
|
||||
{character.tags?.map((tag: any) => (
|
||||
<Tag key={tag.tagId}>{tag.name}</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChatProfilePersona onActiveTab={onActiveTab} />
|
||||
|
||||
{bundleRender('Preference', preferenceItems)}
|
||||
|
||||
{bundleRender('Chat Setting', chatSettingItems)}
|
||||
|
||||
{bundleRender('Voice Setting', voiceSettingItems)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 mt-2">
|
||||
<Button variant="tertiary" className="w-full">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className="w-full"
|
||||
loading={deleting}
|
||||
onClick={deleteChannelAsync}
|
||||
>
|
||||
Detele
|
||||
</Button>
|
||||
<Button variant="primary" className="w-full">
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={creating}
|
||||
onClick={createChannelAndPush}
|
||||
className="w-full"
|
||||
>
|
||||
+ New Chat
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
'use client';
|
||||
import { useChatStore } from '../store';
|
||||
import { useState } from 'react';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||
|
||||
type VoiceGender = 'all' | 'male' | 'female';
|
||||
|
||||
|
|
@ -17,8 +17,8 @@ type VoiceActorItem = {
|
|||
};
|
||||
|
||||
export default function VoiceActor() {
|
||||
const setSideBar = useChatStore((store) => store.setSideBar);
|
||||
|
||||
const chatSetting = useStreamChatStore((store) => store.chatSetting);
|
||||
const setChatSetting = useStreamChatStore((store) => store.setChatSetting);
|
||||
// 语音演员列表(静态数据)
|
||||
const voiceActors: VoiceActorItem[] = [
|
||||
{
|
||||
|
|
@ -74,7 +74,6 @@ export default function VoiceActor() {
|
|||
|
||||
const [selectedGender, setSelectedGender] = useState<VoiceGender>('all');
|
||||
const [selectedActorId, setSelectedActorId] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 根据性别过滤演员列表
|
||||
const filteredActors = voiceActors.filter((actor) => {
|
||||
|
|
@ -82,24 +81,6 @@ export default function VoiceActor() {
|
|||
return actor.gender === selectedGender;
|
||||
});
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// TODO: 调用实际的 API 保存语音演员设置
|
||||
// await updateVoiceActor({ voiceActorId: selectedActorId })
|
||||
console.log('Selected voice actor:', selectedActorId);
|
||||
|
||||
// 模拟延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
setSideBar('profile');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Gender Tabs */}
|
||||
|
|
@ -152,16 +133,6 @@ export default function VoiceActor() {
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Buttons */}
|
||||
{/* <div className="mt-6 flex justify-end gap-3">
|
||||
<Button variant="tertiary" size="large" onClick={() => setSideBar('profile')}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="large" variant="primary" loading={loading} onClick={handleConfirm}>
|
||||
Select
|
||||
</Button>
|
||||
</div> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
'use client';
|
||||
import { useChatStore } from '../store';
|
||||
import Profile from './Profile';
|
||||
import Personal from './Personal';
|
||||
import VoiceActor from './VoiceActor';
|
||||
|
|
@ -7,7 +6,6 @@ import Font from './Font';
|
|||
import MaxToken from './MaxToken';
|
||||
import Background from './Background';
|
||||
import ChatModel from './ChatModel';
|
||||
import Language from './Language';
|
||||
import { IconButton } from '@/components/ui/button';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
|
|
@ -20,6 +18,7 @@ import {
|
|||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { useStreamChatStore } from '../stream-chat';
|
||||
|
||||
type SettingProps = {
|
||||
open: boolean;
|
||||
|
|
@ -33,8 +32,7 @@ export type ActiveTabType =
|
|||
| 'font'
|
||||
| 'max_token'
|
||||
| 'background'
|
||||
| 'model'
|
||||
| 'language';
|
||||
| 'model';
|
||||
|
||||
const titleMap = {
|
||||
personal: 'Personal',
|
||||
|
|
@ -44,14 +42,21 @@ const titleMap = {
|
|||
max_token: 'Max Token',
|
||||
background: 'Background',
|
||||
model: 'Chat Model',
|
||||
language: 'Language',
|
||||
};
|
||||
|
||||
export default function SettingDialog({ open, onOpenChange }: SettingProps) {
|
||||
const [activeTab, setActiveTab] = useState<ActiveTabType>('profile');
|
||||
const updateUserChatSetting = useStreamChatStore((store) => store.updateUserChatSetting);
|
||||
|
||||
const handleChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
updateUserChatSetting();
|
||||
}
|
||||
onOpenChange(open);
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialog open={open} onOpenChange={handleChange}>
|
||||
<AlertDialogContent showCloseButton={activeTab === 'profile'}>
|
||||
<AlertDialogTitle className="flex justify-between">
|
||||
{activeTab === 'profile' ? (
|
||||
|
|
@ -73,7 +78,6 @@ export default function SettingDialog({ open, onOpenChange }: SettingProps) {
|
|||
{activeTab === 'max_token' && <MaxToken />}
|
||||
{activeTab === 'background' && <Background />}
|
||||
{activeTab === 'model' && <ChatModel />}
|
||||
{activeTab === 'language' && <Language />}
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { create } from 'zustand';
|
||||
import { ChatSettingType } from '@/app/(main)/chat/[id]/stream-chat';
|
||||
|
||||
interface ChatDrawerStore {
|
||||
setting: ChatSettingType;
|
||||
setSetting: (setting: Partial<ChatSettingType>) => void;
|
||||
}
|
||||
|
||||
export const useChatDrawerStore = create<ChatDrawerStore>((set, get) => ({
|
||||
setting: {
|
||||
chatModel: '',
|
||||
longText: 0,
|
||||
maximumReplies: 0,
|
||||
background: '',
|
||||
font: 16,
|
||||
voiceActor: '',
|
||||
},
|
||||
setSetting: (value: Partial<ChatSettingType>) => {
|
||||
const { setting } = get();
|
||||
set({ setting: { ...setting, ...value } });
|
||||
},
|
||||
}));
|
||||
|
|
@ -1,12 +1,18 @@
|
|||
'use client';
|
||||
|
||||
import { IconButton } from '@/components/ui/button';
|
||||
import { useAsyncFn } from '@/hooks/tools';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useStreamChatStore } from '@/stores/stream-chat';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
|
||||
const AuthHeightTextarea = (props: React.ComponentProps<'textarea'> & { maxHeight?: number }) => {
|
||||
const { maxHeight = 200, className, value, onChange, ...restProps } = props;
|
||||
const AuthHeightTextarea = (
|
||||
props: React.ComponentProps<'textarea'> & {
|
||||
maxHeight?: number;
|
||||
onSend?: (text: string) => void;
|
||||
}
|
||||
) => {
|
||||
const { maxHeight = 200, className, value, onChange, onSend, ...restProps } = props;
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// 调整高度的函数
|
||||
|
|
@ -28,6 +34,14 @@ const AuthHeightTextarea = (props: React.ComponentProps<'textarea'> & { maxHeigh
|
|||
textarea.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden';
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Enter 发送,Shift+Enter 换行
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault(); // 阻止默认的换行行为
|
||||
onSend?.(value as string);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听内容变化,自动调整高度
|
||||
useEffect(() => {
|
||||
adjustHeight();
|
||||
|
|
@ -52,6 +66,7 @@ const AuthHeightTextarea = (props: React.ComponentProps<'textarea'> & { maxHeigh
|
|||
height: '24px', // 初始高度
|
||||
overflow: 'hidden', // 初始隐藏滚动条
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...restProps}
|
||||
/>
|
||||
);
|
||||
|
|
@ -61,6 +76,14 @@ export default function Input() {
|
|||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const sendMessage = useStreamChatStore((state) => state.sendMessage);
|
||||
const { run: sendMessageAsync, loading } = useAsyncFn(sendMessage);
|
||||
|
||||
const handleSend = () => {
|
||||
if (inputValue.trim()) {
|
||||
sendMessageAsync(inputValue);
|
||||
setInputValue('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col mb-6 items-end gap-4">
|
||||
|
|
@ -81,6 +104,7 @@ export default function Input() {
|
|||
maxHeight={70}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onSend={handleSend}
|
||||
className="py-1"
|
||||
/>
|
||||
{/* 提示词提示按钮 */}
|
||||
|
|
@ -94,10 +118,10 @@ export default function Input() {
|
|||
</div>
|
||||
<IconButton
|
||||
size="large"
|
||||
loading={false}
|
||||
loading={loading}
|
||||
iconfont="icon-icon-send"
|
||||
onClick={() => sendMessage(inputValue)}
|
||||
disabled={false}
|
||||
onClick={handleSend}
|
||||
disabled={!inputValue.trim()}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import CharacterHeader from './CharacterHeader';
|
|||
import AIMessage from './AIMessage';
|
||||
import UserMessage from './UserMessage';
|
||||
import VirtualList from '@/components/ui/virtual-list';
|
||||
import { useStreamChatStore } from '@/stores/stream-chat';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||
|
||||
export default function MessageList() {
|
||||
const messages = useStreamChatStore((s) => s.messages);
|
||||
|
|
@ -34,7 +34,7 @@ export default function MessageList() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 replative">
|
||||
<div className="flex-1 min-h-0">
|
||||
<VirtualList className="h-full" data={itemList} itemContent={itemContent} />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,71 +0,0 @@
|
|||
'use client';
|
||||
import { useChatStore } from '../store';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Button, IconButton } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import Image from 'next/image';
|
||||
|
||||
export default function ChatModel() {
|
||||
const setSideBar = useChatStore((store) => store.setSideBar);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1">
|
||||
<div className="bg-surface-element-normal overflow-hidden rounded-lg p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="txt-title-s">Role-Playing Model</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconButton iconfont="icon-question" variant="tertiary" size="mini" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[300px]">
|
||||
<div className="space-y-2">
|
||||
<p className="break-words">
|
||||
Text Message Price: Refers to the cost of chatting with the character via text
|
||||
messages, including sending text, images, or gifts. Charged per message.
|
||||
</p>
|
||||
<p className="break-words">
|
||||
Voice Message Price: Refers to the cost of sending a voice message to the
|
||||
character or playing the character’s voice. Charged per use.
|
||||
</p>
|
||||
<p className="break-words">
|
||||
Voice Call Price: Refers to the cost of having a voice call with the
|
||||
character. Charged per minute.
|
||||
</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Checkbox checked={true} shape="round" />
|
||||
</div>
|
||||
<div className="txt-body-m text-txt-secondary-normal mt-1">
|
||||
Role-play a conversation with AI
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-district-normal mt-3 rounded-sm p-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} />
|
||||
<span className="txt-label-m text-txt-primary-normal">1/Text Message</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-1">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
<Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} />
|
||||
<span className="txt-label-m text-txt-primary-normal">10/Send or play voice</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-1">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
<Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} />
|
||||
<span className="txt-label-m text-txt-primary-normal">20/min Voice call</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="txt-body-m text-txt-secondary-normal mt-6">Stay tuned for more models</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
'use client';
|
||||
import { useChatStore } from '../store';
|
||||
import { useState } from 'react';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export default function Language() {
|
||||
const setSideBar = useChatStore((store) => store.setSideBar);
|
||||
|
||||
const tokenOptions = [
|
||||
{ value: 'zh-CN', label: 'Chinese' },
|
||||
{ value: 'en-US', label: 'English' },
|
||||
];
|
||||
|
||||
const [selectedToken, setSelectedToken] = useState('zh-CN');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// TODO: 调用实际的 API 保存最大回复数设置
|
||||
// await updateMaxToken({ maxToken: selectedToken })
|
||||
console.log('Selected max token:', selectedToken);
|
||||
|
||||
// 模拟延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
setSideBar('profile');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col gap-3">
|
||||
{tokenOptions.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={cn(
|
||||
'bg-surface-element-normal flex h-12 cursor-pointer items-center justify-between rounded-lg px-5 transition-colors',
|
||||
selectedToken === option.value && 'bg-surface-element-hover'
|
||||
)}
|
||||
onClick={() => setSelectedToken(option.value)}
|
||||
>
|
||||
<div className="txt-title-s">{option.label}</div>
|
||||
<Checkbox shape="round" checked={selectedToken === option.value} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
'use client';
|
||||
export default function UserMessage({ data }: { data: any }) {
|
||||
|
||||
import React from 'react';
|
||||
|
||||
function UserMessage({ data }: { data: any }) {
|
||||
return (
|
||||
<div className="mb-8 flex justify-end">
|
||||
<div className="bg-primary-normal/20 inline-block max-w-[90%] rounded-lg p-4 backdrop-blur-2xl">
|
||||
|
|
@ -8,3 +11,4 @@ export default function UserMessage({ data }: { data: any }) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
export default React.memo(UserMessage);
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
import { IconButton } from '@/components/ui/button';
|
||||
import Input from './Input';
|
||||
import MessageList from './MessageList';
|
||||
import SettingDialog from './Sider';
|
||||
import { useStreamChatStore } from '@/stores/stream-chat';
|
||||
import SettingDialog from './Drawer';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
import { create } from 'zustand';
|
||||
|
||||
type SideBar =
|
||||
| 'profile'
|
||||
| 'personal'
|
||||
| 'history'
|
||||
| 'voice_actor'
|
||||
| 'font'
|
||||
| 'max_token'
|
||||
| 'background'
|
||||
| 'model'
|
||||
| 'language';
|
||||
interface ChatStore {
|
||||
isSidebarOpen: boolean;
|
||||
setIsSidebarOpen: (isSidebarOpen: boolean) => void;
|
||||
sideBar: SideBar;
|
||||
setSideBar: (sideBar: SideBar) => void;
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatStore>((set) => ({
|
||||
isSidebarOpen: false,
|
||||
setIsSidebarOpen: (isSidebarOpen: boolean) => set({ isSidebarOpen }),
|
||||
sideBar: 'profile',
|
||||
setSideBar: (sideBar: SideBar) => set({ sideBar }),
|
||||
}));
|
||||
|
|
@ -1,8 +1,15 @@
|
|||
'use client';
|
||||
import { Channel, StreamChat } from 'stream-chat';
|
||||
import { create } from 'zustand';
|
||||
import { getUserToken, createChannel } from '@/services/editor';
|
||||
import {
|
||||
getUserToken,
|
||||
createChannel,
|
||||
deleteChannel,
|
||||
fetchUserChatSetting,
|
||||
updateUserChatSetting,
|
||||
} from '@/services/chat';
|
||||
import { parseSSEStream, parseData } from '@/utils/streamParser';
|
||||
import { protect } from '@/lib/protect';
|
||||
|
||||
type Message = {
|
||||
key: string;
|
||||
|
|
@ -10,23 +17,42 @@ type Message = {
|
|||
content: string;
|
||||
};
|
||||
|
||||
interface StreamChatStore {
|
||||
client: StreamChat | null;
|
||||
user: {
|
||||
export type ChatSettingType = {
|
||||
chatModel: string;
|
||||
longText: 0 | 1;
|
||||
maximumReplies: number;
|
||||
background: string;
|
||||
font: number;
|
||||
voiceActor: string;
|
||||
};
|
||||
|
||||
type UserType = {
|
||||
userId: string;
|
||||
userName: string;
|
||||
};
|
||||
|
||||
interface StreamChatStore {
|
||||
client: StreamChat | null;
|
||||
user: UserType;
|
||||
chatSetting: ChatSettingType;
|
||||
// 连接 StreamChat 客户端
|
||||
connect: (user: any) => Promise<void>;
|
||||
// 频道
|
||||
channels: Channel[];
|
||||
currentChannel: Channel | null;
|
||||
|
||||
// 用户聊天设置管理
|
||||
setChatSetting: (chatSetting: any) => void;
|
||||
fetchUserChatSetting: () => Promise<void>;
|
||||
updateUserChatSetting: () => Promise<void>;
|
||||
|
||||
// 创建某个角色的聊天频道, 返回channelId
|
||||
createChannel: (characterId: string) => Promise<string | false>;
|
||||
switchToChannel: (id: string) => Promise<void>;
|
||||
queryChannels: (filter: any) => Promise<void>;
|
||||
deleteChannel: (id: string) => Promise<void>;
|
||||
clearChannels: () => Promise<void>;
|
||||
queryChannels: (filter: any) => Promise<Channel[]>;
|
||||
deleteChannel: (
|
||||
id: string[]
|
||||
) => Promise<{ result: string; newChannels?: Channel[]; error?: unknown }>;
|
||||
getCurrentCharacter: () => any | null;
|
||||
|
||||
// 消息列表
|
||||
|
|
@ -38,6 +64,9 @@ interface StreamChatStore {
|
|||
|
||||
// 清除通知
|
||||
clearNotifications: () => Promise<void>;
|
||||
|
||||
// 推出登录,清除状态
|
||||
clearClient: () => void;
|
||||
}
|
||||
export const useStreamChatStore = create<StreamChatStore>((set, get) => ({
|
||||
client: null,
|
||||
|
|
@ -45,6 +74,14 @@ export const useStreamChatStore = create<StreamChatStore>((set, get) => ({
|
|||
userId: '',
|
||||
userName: '',
|
||||
},
|
||||
chatSetting: {
|
||||
chatModel: '',
|
||||
longText: 0,
|
||||
maximumReplies: 0,
|
||||
background: '',
|
||||
font: 16,
|
||||
voiceActor: '',
|
||||
},
|
||||
channels: [],
|
||||
messages: [],
|
||||
setMessages: (messages: any[]) => set({ messages }),
|
||||
|
|
@ -52,10 +89,9 @@ export const useStreamChatStore = create<StreamChatStore>((set, get) => ({
|
|||
// 获取当前聊天频道中的角色id
|
||||
getCurrentCharacter() {
|
||||
const { currentChannel, user } = get();
|
||||
return (
|
||||
Object.values(currentChannel?.state?.members || {})?.find((i) => i.user?.id !== user?.userId)
|
||||
?.user?.id || null
|
||||
);
|
||||
return Object.values(currentChannel?.state?.members || {})?.find((i) => {
|
||||
return i.user_id !== user?.userId;
|
||||
});
|
||||
},
|
||||
// 创建某个角色的聊天频道
|
||||
async createChannel(characterId: string) {
|
||||
|
|
@ -77,28 +113,58 @@ export const useStreamChatStore = create<StreamChatStore>((set, get) => ({
|
|||
return data.channelId;
|
||||
},
|
||||
|
||||
setChatSetting: (setting: any) => {
|
||||
const { chatSetting } = get();
|
||||
set({ chatSetting: { ...chatSetting, ...setting } });
|
||||
},
|
||||
|
||||
async fetchUserChatSetting() {
|
||||
const { user } = get();
|
||||
const { data } = await fetchUserChatSetting({
|
||||
userId: Number(user.userId),
|
||||
});
|
||||
if (data) {
|
||||
set({ chatSetting: data });
|
||||
}
|
||||
},
|
||||
|
||||
async updateUserChatSetting() {
|
||||
const { user, chatSetting, fetchUserChatSetting } = get();
|
||||
await updateUserChatSetting({
|
||||
...chatSetting,
|
||||
userId: user.userId,
|
||||
});
|
||||
fetchUserChatSetting();
|
||||
},
|
||||
|
||||
async connect(user) {
|
||||
const { client } = get();
|
||||
const { client, queryChannels, fetchUserChatSetting } = get();
|
||||
set({ user });
|
||||
if (client) return;
|
||||
const { data } = await getUserToken(user);
|
||||
const streamClient = new StreamChat(process.env.NEXT_PUBLIC_STREAM_CHAT_API_KEY || '');
|
||||
const res = await streamClient.connectUser(
|
||||
await protect(() =>
|
||||
streamClient.connectUser(
|
||||
{
|
||||
id: user.userId,
|
||||
name: user.userName,
|
||||
},
|
||||
data
|
||||
)
|
||||
);
|
||||
set({ client: streamClient });
|
||||
await queryChannels({});
|
||||
await fetchUserChatSetting();
|
||||
},
|
||||
|
||||
async switchToChannel(id: string) {
|
||||
const { client, user } = get();
|
||||
const channel = client!.channel('messaging', id);
|
||||
const result = await channel.query({
|
||||
const result = await protect(() =>
|
||||
channel.query({
|
||||
messages: { limit: 100 },
|
||||
});
|
||||
})
|
||||
);
|
||||
const messages = result.messages.map((i) => ({
|
||||
key: i.id,
|
||||
role: i.user?.id === user.userId ? 'user' : 'assistant',
|
||||
|
|
@ -111,10 +177,12 @@ export const useStreamChatStore = create<StreamChatStore>((set, get) => ({
|
|||
const { user, client } = get();
|
||||
if (!client) {
|
||||
console.error('StreamChat client is not connected');
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
let channels: Channel[] = [];
|
||||
try {
|
||||
const channels = await client.queryChannels(
|
||||
channels = await protect(() =>
|
||||
client.queryChannels(
|
||||
{
|
||||
members: {
|
||||
$in: [user.userId],
|
||||
|
|
@ -126,53 +194,35 @@ export const useStreamChatStore = create<StreamChatStore>((set, get) => ({
|
|||
{
|
||||
message_limit: 1, // 返回最新的1条消息
|
||||
}
|
||||
)
|
||||
);
|
||||
set({ channels });
|
||||
} catch (error) {
|
||||
console.error('Failed to query channels:', error);
|
||||
}
|
||||
return channels;
|
||||
},
|
||||
|
||||
async deleteChannel(id: string) {
|
||||
const { channels, currentChannel, queryChannels } = get();
|
||||
const channel = channels.find((ch) => ch.id === id);
|
||||
if (!channel) {
|
||||
console.warn(`Channel with id ${id} not found`);
|
||||
return;
|
||||
}
|
||||
async deleteChannel(ids: string[]) {
|
||||
const { channels, currentChannel, client, queryChannels } = get();
|
||||
const deleteChannels = channels.filter((ch) => ids.includes(ch.id!));
|
||||
try {
|
||||
await channel.delete();
|
||||
await queryChannels({});
|
||||
if (currentChannel?.id === id) {
|
||||
await Promise.all(
|
||||
deleteChannels.map((ch) => {
|
||||
return client?.channel('messaging', ch.id)?.delete();
|
||||
})
|
||||
);
|
||||
await deleteChannel(ids);
|
||||
const newChannels = await queryChannels({});
|
||||
if (currentChannel?.id && ids.includes(currentChannel.id)) {
|
||||
set({ currentChannel: null });
|
||||
}
|
||||
return { result: 'ok', newChannels };
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete channel ${id}:`, error);
|
||||
return { result: 'error', error: error };
|
||||
}
|
||||
},
|
||||
|
||||
async clearChannels() {
|
||||
const { channels } = get();
|
||||
|
||||
try {
|
||||
// 停止监听所有频道
|
||||
for (const channel of channels) {
|
||||
try {
|
||||
await channel.stopWatching();
|
||||
} catch (error) {
|
||||
console.warn(`Failed to stop watching channel ${channel.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 清空频道列表和当前频道
|
||||
set({
|
||||
channels: [],
|
||||
currentChannel: null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to clear channels:', error);
|
||||
}
|
||||
},
|
||||
async clearNotifications() {},
|
||||
|
||||
// 发送消息
|
||||
|
|
@ -189,7 +239,7 @@ export const useStreamChatStore = create<StreamChatStore>((set, get) => ({
|
|||
|
||||
// 发送消息到服务器
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_CHAT_API_URL}/chat-api/chat/testPrompt`,
|
||||
`${process.env.NEXT_PUBLIC_CHAT_API_URL}/chat-api/chat/ai/generateReply`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
|
@ -199,9 +249,8 @@ export const useStreamChatStore = create<StreamChatStore>((set, get) => ({
|
|||
userId: user.userId,
|
||||
channelId: currentChannel?.id || '',
|
||||
message: content,
|
||||
promptTemplateId: 'default',
|
||||
characterId: getCurrentCharacter()?.id,
|
||||
modelName: 'gpt-3.5-turbo',
|
||||
characterId: getCurrentCharacter()?.user_id,
|
||||
language: 'zh',
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
|
@ -210,12 +259,36 @@ export const useStreamChatStore = create<StreamChatStore>((set, get) => ({
|
|||
await parseSSEStream(response, (event: string, data: string) => {
|
||||
if (event === 'chat-message') {
|
||||
const d = parseData(data);
|
||||
const lastMsg = finalMessages[finalMessages.length - 1];
|
||||
// 重新赋值最后一项,改变引用
|
||||
const lastMsg = { ...finalMessages[finalMessages.length - 1] };
|
||||
if (lastMsg.role === 'assistant') {
|
||||
lastMsg.content = d.content || '';
|
||||
lastMsg.content = lastMsg.content + d.text || '';
|
||||
}
|
||||
|
||||
finalMessages[finalMessages.length - 1] = lastMsg;
|
||||
setMessages([...finalMessages]);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 推出登录,清除状态
|
||||
clearClient: async () => {
|
||||
const { client } = get();
|
||||
await client?.disconnectUser();
|
||||
set({
|
||||
client: null,
|
||||
user: { userId: '', userName: '' },
|
||||
chatSetting: {
|
||||
chatModel: '',
|
||||
longText: 0,
|
||||
maximumReplies: 0,
|
||||
background: '',
|
||||
font: 16,
|
||||
voiceActor: '',
|
||||
},
|
||||
channels: [],
|
||||
messages: [],
|
||||
currentChannel: null,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
|
@ -23,7 +23,10 @@ const Character = () => {
|
|||
<div className="mt-8">
|
||||
<InfiniteScrollList<any>
|
||||
items={dataSource}
|
||||
columns={(width) => Math.floor(width / 213)}
|
||||
columns={(width) => {
|
||||
const cardWidth = width > 1200 ? 256 : width > 588 ? 200 : 170;
|
||||
return Math.floor(width / cardWidth);
|
||||
}}
|
||||
renderItem={(character) => <AIStandardCard character={character} />}
|
||||
getItemKey={(character) => character.id}
|
||||
hasNextPage={!noMoreData}
|
||||
|
|
|
|||
|
|
@ -12,26 +12,24 @@ const Header = React.memo(() => {
|
|||
return (
|
||||
<Link href="/crushcoin">
|
||||
<div
|
||||
className="h-50 rounded-4xl px-6 mb-12 flex items-center justify-between"
|
||||
className="h-25 sm:h-50 rounded-2xl sm:rounded-4xl px-6 mb-12 flex items-center justify-between"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(90deg, rgba(255, 255, 255, 0.1) 0%, rgba(202, 153, 255, 0.2) 100%)',
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-3 items-center">
|
||||
<Image
|
||||
<img
|
||||
src="/images/home/icon-crush-free.png"
|
||||
className="h-30 w-30 object-cover"
|
||||
className="h-15 w-15 sm:h-30 sm:w-30 object-cover"
|
||||
alt="header-bg"
|
||||
width={120}
|
||||
height={120}
|
||||
/>
|
||||
<div>
|
||||
<div className="flex gap-5 txt-display-l">
|
||||
<div className="flex gap-5 txt-display-m sm:txt-display-l">
|
||||
Check-in{' '}
|
||||
<Image
|
||||
src="/images/home/left-star.png"
|
||||
className="h-12 w-12 object-cover"
|
||||
className="h-6 w-6 sm:h-12 sm:w-12 object-cover"
|
||||
alt="header-bg"
|
||||
width={48}
|
||||
height={48}
|
||||
|
|
@ -39,7 +37,7 @@ const Header = React.memo(() => {
|
|||
</div>
|
||||
<div className="flex gap-5 items-center">
|
||||
<span
|
||||
className="txt-headline-s bg-clip-text text-transparent"
|
||||
className="txt-body-m sm:txt-headline-s bg-clip-text text-transparent"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(109.2deg, rgba(211, 123, 235, 1) 37.08%, rgba(147, 123, 235, 1) 128.91%)',
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import { create } from 'zustand'
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface HomeStore {
|
||||
tab: 'story' | 'character'
|
||||
selectedTags: string[]
|
||||
setTab: (tab: 'story' | 'character') => void
|
||||
setSelectedTags: (selectedTags: string[]) => void
|
||||
tab: 'story' | 'character';
|
||||
selectedTags: string[];
|
||||
setTab: (tab: 'story' | 'character') => void;
|
||||
setSelectedTags: (selectedTags: string[]) => void;
|
||||
}
|
||||
|
||||
export const useHomeStore = create<HomeStore>((set) => ({
|
||||
tab: 'story',
|
||||
tab: 'character',
|
||||
setTab: (tab: 'story' | 'character') => set({ tab }),
|
||||
selectedTags: [],
|
||||
setSelectedTags: (selectedTags: string[]) => set({ selectedTags }),
|
||||
}))
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -9,20 +9,11 @@ import {
|
|||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { useLogout } from '@/hooks/auth';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import {
|
||||
conversationListAtom,
|
||||
msgListAtom,
|
||||
userListAtom,
|
||||
imSyncedAtom,
|
||||
imReconnectStatusAtom,
|
||||
IMReconnectStatus,
|
||||
selectedConversationIdAtom,
|
||||
} from '@/atoms/im';
|
||||
import { QueueMap } from '@/lib/queue';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { useLayoutStore } from '@/stores';
|
||||
import { useStreamChatStore } from '../../chat/[id]/stream-chat';
|
||||
import { useAsyncFn } from '@/hooks/tools';
|
||||
|
||||
const ProfileDropdownItem = ({
|
||||
icon,
|
||||
|
|
@ -49,65 +40,20 @@ const ProfileDropdownItem = ({
|
|||
|
||||
const ProfileDropdown = () => {
|
||||
const { mutateAsync: logout } = useLogout();
|
||||
// const { clearAllConversations } = useNimConversation();
|
||||
const [isLogoutDialogOpen, setIsLogoutDialogOpen] = useState(false);
|
||||
const [isLogoutDialogLoading, setIsLogoutDialogLoading] = useState(false);
|
||||
const { isSidebarExpanded, setSidebarExpanded } = useLayoutStore();
|
||||
const setSidebarExpanded = useLayoutStore((s) => s.setSidebarExpanded);
|
||||
const clearClient = useStreamChatStore((s) => s.clearClient);
|
||||
|
||||
// IM相关状态重置
|
||||
const setConversationList = useSetAtom(conversationListAtom);
|
||||
const setMsgList = useSetAtom(msgListAtom);
|
||||
const setUserList = useSetAtom(userListAtom);
|
||||
const setImSynced = useSetAtom(imSyncedAtom);
|
||||
const setImReconnectStatus = useSetAtom(imReconnectStatusAtom);
|
||||
const setSelectedConversationId = useSetAtom(selectedConversationIdAtom);
|
||||
|
||||
const handleLogout = async () => {
|
||||
const { run: handleLogout, loading } = useAsyncFn(async () => {
|
||||
try {
|
||||
setIsLogoutDialogLoading(true);
|
||||
|
||||
// 1. 断开IM连接
|
||||
try {
|
||||
console.log('开始断开IM连接...');
|
||||
// await nim.V2NIMLoginService.logout();
|
||||
console.log('IM连接已断开');
|
||||
} catch (imError) {
|
||||
console.error('断开IM连接失败:', imError);
|
||||
// 即使IM断开失败,也继续执行后续步骤
|
||||
}
|
||||
|
||||
// 2. 清除所有聊天数据
|
||||
try {
|
||||
console.log('开始清除聊天历史数据...');
|
||||
// await clearAllConversations();
|
||||
console.log('聊天历史数据已清除');
|
||||
} catch (clearError) {
|
||||
console.error('清除聊天数据失败:', clearError);
|
||||
// 即使清除失败,也继续执行后续步骤
|
||||
}
|
||||
|
||||
// 3. 重置所有IM相关的本地状态
|
||||
setConversationList(new Map());
|
||||
setMsgList(new QueueMap(20, 'rightToLeft'));
|
||||
setUserList(new Map());
|
||||
setImSynced(false);
|
||||
setImReconnectStatus(IMReconnectStatus.DISCONNECTED);
|
||||
setSelectedConversationId(null);
|
||||
|
||||
if (isSidebarExpanded) {
|
||||
await clearClient();
|
||||
setSidebarExpanded(false);
|
||||
}
|
||||
|
||||
// 4. 执行用户登出
|
||||
await logout();
|
||||
|
||||
setIsLogoutDialogOpen(false);
|
||||
setIsLogoutDialogLoading(false);
|
||||
} catch (error) {
|
||||
console.error('登出过程中发生错误:', error);
|
||||
setIsLogoutDialogLoading(false);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// 菜单项配置
|
||||
const items: Array<
|
||||
|
|
@ -199,11 +145,7 @@ const ProfileDropdown = () => {
|
|||
<AlertDialogDescription>Are you sure you want to log out?</AlertDialogDescription>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
loading={isLogoutDialogLoading}
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<AlertDialogAction variant="destructive" loading={loading} onClick={handleLogout}>
|
||||
Log out
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
|
|
|||
|
|
@ -1,57 +1,64 @@
|
|||
'use client';
|
||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
type ItemType<T = any> = {
|
||||
type: string;
|
||||
data?: T;
|
||||
};
|
||||
type VirtualListProps<T = any> = {
|
||||
data?: { type: string; data?: T }[];
|
||||
data?: ItemType<T>[];
|
||||
itemContent?: (index: number, item: { type: string; data: T }) => React.ReactNode;
|
||||
virtuosoProps?: React.ComponentProps<typeof Virtuoso>;
|
||||
} & React.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export default function VirtualList<T = any>(props: VirtualListProps<T>) {
|
||||
const {
|
||||
data = [],
|
||||
itemContent = (index) => <div>{index}</div>,
|
||||
virtuosoProps,
|
||||
...restProps
|
||||
} = props;
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
// const [showScrollButton, setShowScrollButton] = useState(false);
|
||||
const { data = [], itemContent = (index) => <div>{index}</div>, ...restProps } = props;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const previousData = useRef<ItemType[]>([]);
|
||||
const [isUserAtBottom, setIsUserAtBottom] = useState(true);
|
||||
|
||||
// // 滚动到最新消息
|
||||
// const scrollToBottom = () => {
|
||||
// virtuosoRef.current?.scrollToIndex({
|
||||
// index: data.length - 1,
|
||||
// behavior: 'smooth',
|
||||
// });
|
||||
// };
|
||||
// 检查用户是否滚动到底部附近(阈值为 50px)
|
||||
const checkIfAtBottom = () => {
|
||||
const element = ref.current;
|
||||
if (!element) return false;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = element;
|
||||
const threshold = 50; // 距离底部 50px 以内认为是在底部
|
||||
return scrollHeight - scrollTop - clientHeight < threshold;
|
||||
};
|
||||
|
||||
// 监听用户滚动事件
|
||||
const handleScroll = () => {
|
||||
const atBottom = checkIfAtBottom();
|
||||
setIsUserAtBottom(atBottom);
|
||||
};
|
||||
|
||||
// 当数据更新时,只有用户在底部才自动滚动
|
||||
useEffect(() => {
|
||||
const last = (list: ItemType[]) => (list?.length ? list[list.length - 1] : null);
|
||||
const lastChanged = last(previousData.current)?.data !== last(data)?.data;
|
||||
|
||||
if (lastChanged && isUserAtBottom) {
|
||||
ref.current?.scrollTo({
|
||||
top: ref.current?.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
|
||||
previousData.current = data;
|
||||
}, [data, isUserAtBottom]);
|
||||
|
||||
return (
|
||||
<div {...restProps}>
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
alignToBottom={false}
|
||||
{...virtuosoProps}
|
||||
style={{ height: '100%', ...virtuosoProps?.style }}
|
||||
data={data}
|
||||
followOutput="smooth"
|
||||
initialTopMostItemIndex={data.length - 1}
|
||||
// atBottomStateChange={(atBottom) => {
|
||||
// // 当不在底部时显示按钮,在底部时隐藏
|
||||
// setShowScrollButton(!atBottom);
|
||||
// }}
|
||||
itemContent={(index, item) => itemContent(index, item as any)}
|
||||
/>
|
||||
|
||||
{/* 回到底部按钮 */}
|
||||
{/* {showScrollButton && (
|
||||
<div
|
||||
onClick={scrollToBottom}
|
||||
className="absolute -right-1 bottom-0 z-10 flex h-10 w-10 items-center justify-center rounded-full hover:scale-110 hover:cursor-pointer"
|
||||
{...restProps}
|
||||
ref={ref}
|
||||
onScroll={handleScroll}
|
||||
className={cn('overflow-auto', restProps.className)}
|
||||
>
|
||||
scroll
|
||||
</div>
|
||||
)} */}
|
||||
{data.map((item, index) => (
|
||||
<div key={index}>{itemContent(index, item as any)}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
import { fetchModels } from '@/services/chat';
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
export function useModels() {
|
||||
return useQuery({
|
||||
queryKey: ['models'],
|
||||
queryFn: () => fetchModels(),
|
||||
});
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ import { cn } from '@/lib/utils';
|
|||
import CreateReachedLimitDialog from '../components/features/create-reached-limit-dialog';
|
||||
import { useMedia } from '@/hooks/tools';
|
||||
import BottomBar from './BottomBar';
|
||||
import { useStreamChatStore } from '@/stores/stream-chat';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||
import { useCurrentUser } from '@/hooks/auth';
|
||||
|
||||
interface ConditionalLayoutProps {
|
||||
|
|
@ -20,20 +20,14 @@ interface ConditionalLayoutProps {
|
|||
const useInitChat = () => {
|
||||
const { data } = useCurrentUser();
|
||||
const connect = useStreamChatStore((state) => state.connect);
|
||||
const queryChannels = useStreamChatStore((state) => state.queryChannels);
|
||||
|
||||
const initChat = async () => {
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
await connect({
|
||||
connect({
|
||||
userId: data.userId + '',
|
||||
userName: data.nickname,
|
||||
});
|
||||
await queryChannels({});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
initChat();
|
||||
}, [data]);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -8,8 +8,6 @@ import { Badge } from '../components/ui/badge';
|
|||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useLayoutStore } from '@/stores';
|
||||
import { useCurrentUser } from '@/hooks/auth';
|
||||
import Notice from './components/Notice';
|
||||
|
||||
// 菜单项接口
|
||||
interface IMenuItem {
|
||||
|
|
@ -27,7 +25,6 @@ function Sidebar() {
|
|||
const isSidebarExpanded = useLayoutStore((s) => s.isSidebarExpanded);
|
||||
const setSidebarExpanded = useLayoutStore((s) => s.setSidebarExpanded);
|
||||
const setHydrated = useLayoutStore((s) => s.setHydrated);
|
||||
const { data: user } = useCurrentUser();
|
||||
|
||||
// 在客户端挂载后,从 localStorage 恢复侧边栏状态
|
||||
useEffect(() => {
|
||||
|
|
@ -103,7 +100,6 @@ function Sidebar() {
|
|||
<div className="bg-outline-normal h-px" />
|
||||
</div>
|
||||
<ChatSidebar />
|
||||
<Notice actualIsExpanded={isSidebarExpanded} />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '../components/ui/avatar';
|
|||
import Link from 'next/link';
|
||||
import { usePathname, useSearchParams, useRouter } from 'next/navigation';
|
||||
import { useMedia } from '@/hooks/tools';
|
||||
import Notice from './components/Notice';
|
||||
import { items } from './BottomBar';
|
||||
|
||||
const mobileHidenMenus = ['/profile/edit', '/profile/account'];
|
||||
|
|
@ -79,6 +80,8 @@ function Topbar() {
|
|||
</Link>
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Notice />
|
||||
<Link href="/profile" prefetch>
|
||||
<Avatar className="size-8 cursor-pointer">
|
||||
<AvatarImage
|
||||
|
|
@ -91,6 +94,7 @@ function Topbar() {
|
|||
<AvatarFallback>{user.nickname?.slice(0, 1)}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useState, useMemo } from 'react';
|
|||
import ChatSidebarItem from './ChatSidebarItem';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import Empty from '@/components/ui/empty';
|
||||
import { useStreamChatStore } from '@/stores/stream-chat';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||
|
||||
interface ChatSearchResultsProps {
|
||||
searchKeyword: string;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import ChatSidebarAction from './ChatSidebarAction';
|
|||
import ChatSearchResults from './ChatSearchResults';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useStreamChatStore } from '@/stores/stream-chat';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||
import { useLayoutStore } from '@/stores';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import {
|
|||
} from '@/components/ui/alert-dialog';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { useStreamChatStore } from '@/stores/stream-chat';
|
||||
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
|
||||
import { useAsyncFn } from '@/hooks/tools';
|
||||
|
||||
interface ChatSidebarActionProps {
|
||||
|
|
@ -33,14 +33,17 @@ const ChatSidebarAction = ({
|
|||
isSearchActive = false,
|
||||
}: ChatSidebarActionProps) => {
|
||||
const clearNotifications = useStreamChatStore((state) => state.clearNotifications);
|
||||
const clearChannels = useStreamChatStore((state) => state.clearChannels);
|
||||
const clearChannels = useStreamChatStore((state) => state.deleteChannel);
|
||||
const channels = useStreamChatStore((state) => state.channels);
|
||||
const [isDeleteMessageDialogOpen, setIsDeleteMessageDialogOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const { run: handleClearChannels, loading } = useAsyncFn(async () => {
|
||||
await clearChannels();
|
||||
setIsDeleteMessageDialogOpen(false);
|
||||
const { result } = await clearChannels(channels.map((ch) => ch.id!));
|
||||
if (result === 'ok') {
|
||||
router.replace('/');
|
||||
setIsDeleteMessageDialogOpen(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
import { Badge } from '@/components/ui/badge'
|
||||
import { useCurrentUser, useUserNoticeStat } from '@/hooks/auth'
|
||||
import Image from 'next/image'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { userKeys } from '@/lib/query-keys'
|
||||
import NoticeDrawer from './NoticeDrawer'
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useCurrentUser, useUserNoticeStat } from '@/hooks/auth';
|
||||
import Image from 'next/image';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { userKeys } from '@/lib/query-keys';
|
||||
import NoticeDrawer from './NoticeDrawer';
|
||||
|
||||
const Notice = ({ actualIsExpanded }: { actualIsExpanded: boolean }) => {
|
||||
const { data: user } = useCurrentUser()
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
|
||||
const pathname = usePathname()
|
||||
const queryClient = useQueryClient()
|
||||
const Notice = () => {
|
||||
const { data: user } = useCurrentUser();
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data } = useUserNoticeStat()
|
||||
const { data } = useUserNoticeStat();
|
||||
|
||||
// 监听路径变化,刷新通知统计
|
||||
useEffect(() => {
|
||||
|
|
@ -21,28 +21,20 @@ const Notice = ({ actualIsExpanded }: { actualIsExpanded: boolean }) => {
|
|||
// 当路径变化时,无效化并重新获取通知统计数据
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: userKeys.noticeStat(),
|
||||
})
|
||||
});
|
||||
}
|
||||
}, [pathname])
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDrawerOpen && user) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: userKeys.noticeStat(),
|
||||
})
|
||||
});
|
||||
}
|
||||
}, [isDrawerOpen])
|
||||
}, [isDrawerOpen]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 分割线 */}
|
||||
{user && (
|
||||
<div className="mx-6 my-4">
|
||||
<div className="bg-outline-normal h-px" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底部通知 */}
|
||||
<>
|
||||
{user && (
|
||||
<div className="px-4">
|
||||
<div
|
||||
|
|
@ -53,17 +45,15 @@ const Notice = ({ actualIsExpanded }: { actualIsExpanded: boolean }) => {
|
|||
<Image src="/icons/notice.svg" alt="Notice" width={32} height={32} />
|
||||
<Badge count={data?.unRead} className="absolute -top-2 -right-2" />
|
||||
</div>
|
||||
{actualIsExpanded && (
|
||||
{/* {actualIsExpanded && (
|
||||
<span className="txt-label-m text-txt-primary-normal flex-1">Notice</span>
|
||||
)}
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 通知抽屉 */}
|
||||
<NoticeDrawer isOpen={isDrawerOpen} onOpenChange={setIsDrawerOpen} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Notice
|
||||
export default Notice;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
type ProtectOptions = {
|
||||
maxRetries?: number;
|
||||
delay?: number;
|
||||
onRetry?: (error: any, attempt: number) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 保护函数,用于包裹异步请求并在失败时自动重试
|
||||
* @param fn 需要执行的异步函数
|
||||
* @param options 配置项
|
||||
* @param options.maxRetries 最大重试次数,默认为 3
|
||||
* @param options.delay 重试间隔时间(毫秒),默认为 1000
|
||||
* @param options.onRetry 重试时的回调函数
|
||||
* @returns 返回函数执行结果
|
||||
*/
|
||||
export async function protect<T>(fn: () => Promise<T>, options: ProtectOptions = {}): Promise<T> {
|
||||
const { maxRetries = 3, delay = 1000, onRetry } = options;
|
||||
|
||||
let lastError: any;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
// 如果已经是最后一次尝试,直接抛出错误
|
||||
if (attempt === maxRetries) {
|
||||
console.error(`请求失败,已重试 ${maxRetries} 次:`, error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 执行重试回调
|
||||
if (onRetry) {
|
||||
onRetry(error, attempt);
|
||||
} else {
|
||||
console.warn(`请求失败,第 ${attempt} 次重试中...`, error);
|
||||
}
|
||||
|
||||
// 等待一段时间后重试
|
||||
await new Promise((resolve) => setTimeout(resolve, delay * attempt));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个带保护的函数包装器
|
||||
* @param fn 需要包装的异步函数
|
||||
* @param options 配置项
|
||||
* @returns 返回包装后的函数
|
||||
*/
|
||||
export function createProtected<T extends (...args: any[]) => Promise<any>>(
|
||||
fn: T,
|
||||
options: ProtectOptions = {}
|
||||
): T {
|
||||
return (async (...args: any[]) => {
|
||||
return protect(() => fn(...args), options);
|
||||
}) as T;
|
||||
}
|
||||
|
|
@ -18,14 +18,7 @@ export async function fetchServerRequest<T = any>(
|
|||
tags?: string[]; // 缓存标签,用于手动刷新
|
||||
}
|
||||
): Promise<ResponseType<T>> {
|
||||
const {
|
||||
method = 'GET',
|
||||
params,
|
||||
data,
|
||||
requireAuth = false,
|
||||
revalidate,
|
||||
tags,
|
||||
} = options || {};
|
||||
const { method = 'POST', params, data, requireAuth = false, revalidate, tags } = options || {};
|
||||
|
||||
// 获取 token(如果需要)
|
||||
let token: string | null = null;
|
||||
|
|
@ -51,7 +44,7 @@ export async function fetchServerRequest<T = any>(
|
|||
};
|
||||
|
||||
// 构建完整 URL(服务端必须用完整 URL)
|
||||
const baseURL = 'http://54.223.196.180:8091';
|
||||
const baseURL = 'http://54.223.196.180';
|
||||
const fullURL = new URL(url, baseURL);
|
||||
|
||||
// 处理查询参数
|
||||
|
|
@ -66,9 +59,7 @@ export async function fetchServerRequest<T = any>(
|
|||
// 处理 body 数据
|
||||
if (data) {
|
||||
fetchOptions.body = JSON.stringify(
|
||||
Object.fromEntries(
|
||||
Object.entries(data).filter(([, value]) => value !== '')
|
||||
)
|
||||
Object.fromEntries(Object.entries(data).filter(([, value]) => value !== ''))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
import { chatRequest } from '@/lib/client';
|
||||
|
||||
export async function getUserToken(data: { userId: string; userName: string }) {
|
||||
return await chatRequest('/chat-api/v1/im/user/createOrGet', {
|
||||
method: 'post',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createChannel(data: any) {
|
||||
return await chatRequest('/chat-api/v1/im/user/conversation/create', {
|
||||
method: 'post',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteChannel(chanelIds: string[]) {
|
||||
return await chatRequest('/chat-api/v1/im/user/conversation/delete', {
|
||||
method: 'post',
|
||||
data: { chanelIds },
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchUserChatSetting(params: any) {
|
||||
return await chatRequest('/chat-api/user/setting/chat/select', {
|
||||
method: 'post',
|
||||
data: params,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateUserChatSetting(params: any) {
|
||||
return await chatRequest('/chat-api/user/setting/chat/createOrUpdate', {
|
||||
method: 'post',
|
||||
data: params,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchModels() {
|
||||
const { data } = await chatRequest('/chat-api/user/setting/model/list', {
|
||||
method: 'post',
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { chatRequest, editorRequest } from '@/lib/client';
|
||||
import { editorRequest } from '@/lib/client';
|
||||
|
||||
export async function fetchCharacters({ index, limit, query }: any) {
|
||||
const { data } = await editorRequest('/api/character/list', {
|
||||
|
|
@ -16,24 +16,3 @@ export async function fetchCharacter(params: any) {
|
|||
export async function fetchCharacterTags(params: any = {}) {
|
||||
return editorRequest('/api/tag/list', { method: 'POST', data: params });
|
||||
}
|
||||
|
||||
export async function getUserToken(data: { userId: string; userName: string }) {
|
||||
return await chatRequest('/chat-api/v1/im/user/createOrGet', {
|
||||
method: 'post',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createChannel(data: any) {
|
||||
return await chatRequest('/chat-api/v1/im/user/conversation/create', {
|
||||
method: 'post',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteChannel(chanelId: string) {
|
||||
return await chatRequest('/chat-api/v1/im/user/conversation/delete', {
|
||||
method: 'post',
|
||||
data: { chanelId },
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue