From e89f6ce94d598e8e058ff85c0669d0ba34c0de5b Mon Sep 17 00:00:00 2001 From: liuyonghe0111 <1763195287@qq.com> Date: Thu, 18 Dec 2025 18:14:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E7=A7=BB=E5=8A=A8?= =?UTF-8?q?=E7=AB=AF=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/icons/expand.svg | 3 + src/app/(main)/character/[id]/ChatButton.tsx | 2 +- src/app/(main)/character/[id]/page.tsx | 36 ++- src/app/(main)/character/[id]/service.ts | 11 + src/app/(main)/chat/[id]/AIMessage.tsx | 18 +- src/app/(main)/chat/[id]/CharacterHeader.tsx | 16 +- .../[id]/{Sider => Drawer}/Background.tsx | 21 +- src/app/(main)/chat/[id]/Drawer/ChatModel.tsx | 81 +++++++ .../chat/[id]/{Sider => Drawer}/Font.tsx | 35 +-- .../chat/[id]/{Sider => Drawer}/MaxToken.tsx | 33 +-- .../chat/[id]/{Sider => Drawer}/Personal.tsx | 65 +----- .../chat/[id]/{Sider => Drawer}/Profile.tsx | 92 ++++---- .../[id]/{Sider => Drawer}/VoiceActor.tsx | 35 +-- .../chat/[id]/{Sider => Drawer}/index.tsx | 18 +- src/app/(main)/chat/[id]/Drawer/store.ts | 22 ++ src/app/(main)/chat/[id]/Input.tsx | 36 ++- src/app/(main)/chat/[id]/MessageList.tsx | 4 +- src/app/(main)/chat/[id]/Sider/ChatModel.tsx | 71 ------ src/app/(main)/chat/[id]/Sider/Language.tsx | 58 ----- src/app/(main)/chat/[id]/UserMessage.tsx | 6 +- src/app/(main)/chat/[id]/page.tsx | 4 +- src/app/(main)/chat/[id]/store.ts | 25 -- .../(main)/chat/[id]}/stream-chat.ts | 219 ++++++++++++------ .../home/components/Character/index.tsx | 5 +- src/app/(main)/home/components/Header.tsx | 14 +- src/app/(main)/home/store.ts | 14 +- .../profile/components/ProfileDropdown.tsx | 76 +----- src/components/ui/virtual-list.tsx | 95 ++++---- src/hooks/services/chat.ts | 9 + src/layout/BasicLayout.tsx | 12 +- src/layout/Sidebar.tsx | 4 - src/layout/Topbar.tsx | 28 ++- src/layout/components/ChatSearchResults.tsx | 2 +- src/layout/components/ChatSidebar.tsx | 2 +- src/layout/components/ChatSidebarAction.tsx | 13 +- src/layout/components/Notice.tsx | 60 ++--- src/lib/protect.ts | 61 +++++ src/lib/server/request.ts | 15 +- src/services/chat/index.ts | 43 ++++ src/services/editor/index.ts | 23 +- 40 files changed, 673 insertions(+), 714 deletions(-) create mode 100644 public/icons/expand.svg create mode 100644 src/app/(main)/character/[id]/service.ts rename src/app/(main)/chat/[id]/{Sider => Drawer}/Background.tsx (89%) create mode 100644 src/app/(main)/chat/[id]/Drawer/ChatModel.tsx rename src/app/(main)/chat/[id]/{Sider => Drawer}/Font.tsx (55%) rename src/app/(main)/chat/[id]/{Sider => Drawer}/MaxToken.tsx (52%) rename src/app/(main)/chat/[id]/{Sider => Drawer}/Personal.tsx (84%) rename src/app/(main)/chat/[id]/{Sider => Drawer}/Profile.tsx (74%) rename src/app/(main)/chat/[id]/{Sider => Drawer}/VoiceActor.tsx (81%) rename src/app/(main)/chat/[id]/{Sider => Drawer}/index.tsx (86%) create mode 100644 src/app/(main)/chat/[id]/Drawer/store.ts delete mode 100644 src/app/(main)/chat/[id]/Sider/ChatModel.tsx delete mode 100644 src/app/(main)/chat/[id]/Sider/Language.tsx delete mode 100644 src/app/(main)/chat/[id]/store.ts rename src/{stores => app/(main)/chat/[id]}/stream-chat.ts (51%) create mode 100644 src/hooks/services/chat.ts create mode 100644 src/lib/protect.ts create mode 100644 src/services/chat/index.ts diff --git a/public/icons/expand.svg b/public/icons/expand.svg new file mode 100644 index 0000000..23e1f0d --- /dev/null +++ b/public/icons/expand.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/app/(main)/character/[id]/ChatButton.tsx b/src/app/(main)/character/[id]/ChatButton.tsx index a91a1bf..0383863 100644 --- a/src/app/(main)/character/[id]/ChatButton.tsx +++ b/src/app/(main)/character/[id]/ChatButton.tsx @@ -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(); diff --git a/src/app/(main)/character/[id]/page.tsx b/src/app/(main)/character/[id]/page.tsx index 322c0e4..8a4afb4 100644 --- a/src/app/(main)/character/[id]/page.tsx +++ b/src/app/(main)/character/[id]/page.tsx @@ -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 (
-
+
- - - - {user?.nickname?.slice(0, 1)} - + + + {character?.name?.slice(0, 2)}
-
{user?.name}
-
{user?.nickname}
+
{character?.name}
+
+ {character?.tags?.map((tag: any) => ( + + {tag.name} + + ))} +
@@ -31,11 +33,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
Introduction
-
- 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... -
+
{character?.description}
diff --git a/src/app/(main)/character/[id]/service.ts b/src/app/(main)/character/[id]/service.ts new file mode 100644 index 0000000..a5bd74e --- /dev/null +++ b/src/app/(main)/character/[id]/service.ts @@ -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; +}); diff --git a/src/app/(main)/chat/[id]/AIMessage.tsx b/src/app/(main)/chat/[id]/AIMessage.tsx index 8d86ea3..0727bdc 100644 --- a/src/app/(main)/chat/[id]/AIMessage.tsx +++ b/src/app/(main)/chat/[id]/AIMessage.tsx @@ -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 ( +
+
+
+
+
+
+
+ ); +}; + +function AIMessage({ data }: { data: any }) { return (
- + {data.content ? : }
); } +export default React.memo(AIMessage); diff --git a/src/app/(main)/chat/[id]/CharacterHeader.tsx b/src/app/(main)/chat/[id]/CharacterHeader.tsx index db91d37..a4d5df4 100644 --- a/src/app/(main)/chat/[id]/CharacterHeader.tsx +++ b/src/app/(main)/chat/[id]/CharacterHeader.tsx @@ -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(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 (
@@ -55,7 +53,7 @@ const ChatMessageUserHeader = () => { wordBreak: 'break-word', }} > - {introduction.repeat(10)} + {character.description}
@@ -85,6 +83,6 @@ const ChatMessageUserHeader = () => {
Content generated by AI
); -}; +} -export default ChatMessageUserHeader; +export default React.memo(ChatMessageUserHeader); diff --git a/src/app/(main)/chat/[id]/Sider/Background.tsx b/src/app/(main)/chat/[id]/Drawer/Background.tsx similarity index 89% rename from src/app/(main)/chat/[id]/Sider/Background.tsx rename to src/app/(main)/chat/[id]/Drawer/Background.tsx index 6ad80ad..fd6be2e 100644 --- a/src/app/(main)/chat/[id]/Sider/Background.tsx +++ b/src/app/(main)/chat/[id]/Drawer/Background.tsx @@ -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(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); diff --git a/src/app/(main)/chat/[id]/Drawer/ChatModel.tsx b/src/app/(main)/chat/[id]/Drawer/ChatModel.tsx new file mode 100644 index 0000000..d5bf240 --- /dev/null +++ b/src/app/(main)/chat/[id]/Drawer/ChatModel.tsx @@ -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 ( +
+
+ {models.map((model: any) => ( +
setChatSetting({ chatModel: model })} + className="bg-surface-element-normal cursor-pointer overflow-hidden rounded-lg p-4" + > +
+
+
{model}
+ + + + + +
+

+ Text Message Price: Refers to the cost of chatting with the character via + text messages, including sending text, images, or gifts. Charged per + message. +

+

+ Voice Message Price: Refers to the cost of sending a voice message to the + character or playing the character’s voice. Charged per use. +

+

+ Voice Call Price: Refers to the cost of having a voice call with the + character. Charged per minute. +

+
+
+
+
+ +
+
+ Role-play a conversation with AI +
+ +
+
+ diamond + 1/Text Message +
+ +
+
+ diamond + 10/Send or play voice +
+
+ +
+
+ diamond + 20/min Voice call +
+
+
+
+ ))} +
Stay tuned for more models
+
+
+ ); +} diff --git a/src/app/(main)/chat/[id]/Sider/Font.tsx b/src/app/(main)/chat/[id]/Drawer/Font.tsx similarity index 55% rename from src/app/(main)/chat/[id]/Sider/Font.tsx rename to src/app/(main)/chat/[id]/Drawer/Font.tsx index f3a18f7..87bf773 100644 --- a/src/app/(main)/chat/[id]/Sider/Font.tsx +++ b/src/app/(main)/chat/[id]/Drawer/Font.tsx @@ -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 (
@@ -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 })} >
{option.label} @@ -63,7 +40,7 @@ export default function Font() { (standard) )}
- +
))}
diff --git a/src/app/(main)/chat/[id]/Sider/MaxToken.tsx b/src/app/(main)/chat/[id]/Drawer/MaxToken.tsx similarity index 52% rename from src/app/(main)/chat/[id]/Sider/MaxToken.tsx rename to src/app/(main)/chat/[id]/Drawer/MaxToken.tsx index 572577c..00e25a9 100644 --- a/src/app/(main)/chat/[id]/Sider/MaxToken.tsx +++ b/src/app/(main)/chat/[id]/Drawer/MaxToken.tsx @@ -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 (
@@ -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 })} >
{option.label}
- +
))}
diff --git a/src/app/(main)/chat/[id]/Sider/Personal.tsx b/src/app/(main)/chat/[id]/Drawer/Personal.tsx similarity index 84% rename from src/app/(main)/chat/[id]/Sider/Personal.tsx rename to src/app/(main)/chat/[id]/Drawer/Personal.tsx index 558e9f6..5f4efcb 100644 --- a/src/app/(main)/chat/[id]/Sider/Personal.tsx +++ b/src/app/(main)/chat/[id]/Drawer/Personal.tsx @@ -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) { - 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() { {/* 确认放弃修改的对话框 */} - + {/* Unsaved Edits @@ -363,7 +302,7 @@ export default function Personal() { - + */} ); } diff --git a/src/app/(main)/chat/[id]/Sider/Profile.tsx b/src/app/(main)/chat/[id]/Drawer/Profile.tsx similarity index 74% rename from src/app/(main)/chat/[id]/Sider/Profile.tsx rename to src/app/(main)/chat/[id]/Drawer/Profile.tsx index 1971767..7585b2f 100644 --- a/src/app/(main)/chat/[id]/Sider/Profile.tsx +++ b/src/app/(main)/chat/[id]/Drawer/Profile.tsx @@ -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: , + value: ( + 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 (
- {/*
- setSideBar('profile')}> - - - setSideBar('profile')}> - - -
*/}
{/* Tags */}
- - Gender -
{getAge(Number(24))}
-
- {'Sensibility'} - {'Romantic'} + {character.tags?.map((tag: any) => ( + {tag.name} + ))}
- {bundleRender('Preference', preferenceItems)} - {bundleRender('Chat Setting', chatSettingItems)} {bundleRender('Voice Setting', voiceSettingItems)}
- -
diff --git a/src/app/(main)/chat/[id]/Sider/VoiceActor.tsx b/src/app/(main)/chat/[id]/Drawer/VoiceActor.tsx similarity index 81% rename from src/app/(main)/chat/[id]/Sider/VoiceActor.tsx rename to src/app/(main)/chat/[id]/Drawer/VoiceActor.tsx index 986f617..2ceff87 100644 --- a/src/app/(main)/chat/[id]/Sider/VoiceActor.tsx +++ b/src/app/(main)/chat/[id]/Drawer/VoiceActor.tsx @@ -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('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 (
{/* Gender Tabs */} @@ -152,16 +133,6 @@ export default function VoiceActor() { ))}
- - {/* Footer Buttons */} - {/*
- - -
*/} ); } diff --git a/src/app/(main)/chat/[id]/Sider/index.tsx b/src/app/(main)/chat/[id]/Drawer/index.tsx similarity index 86% rename from src/app/(main)/chat/[id]/Sider/index.tsx rename to src/app/(main)/chat/[id]/Drawer/index.tsx index da53ad4..e5e21bd 100644 --- a/src/app/(main)/chat/[id]/Sider/index.tsx +++ b/src/app/(main)/chat/[id]/Drawer/index.tsx @@ -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('profile'); + const updateUserChatSetting = useStreamChatStore((store) => store.updateUserChatSetting); + + const handleChange = (open: boolean) => { + if (!open) { + updateUserChatSetting(); + } + onOpenChange(open); + }; return ( - + {activeTab === 'profile' ? ( @@ -73,7 +78,6 @@ export default function SettingDialog({ open, onOpenChange }: SettingProps) { {activeTab === 'max_token' && } {activeTab === 'background' && } {activeTab === 'model' && } - {activeTab === 'language' && } diff --git a/src/app/(main)/chat/[id]/Drawer/store.ts b/src/app/(main)/chat/[id]/Drawer/store.ts new file mode 100644 index 0000000..5edd522 --- /dev/null +++ b/src/app/(main)/chat/[id]/Drawer/store.ts @@ -0,0 +1,22 @@ +import { create } from 'zustand'; +import { ChatSettingType } from '@/app/(main)/chat/[id]/stream-chat'; + +interface ChatDrawerStore { + setting: ChatSettingType; + setSetting: (setting: Partial) => void; +} + +export const useChatDrawerStore = create((set, get) => ({ + setting: { + chatModel: '', + longText: 0, + maximumReplies: 0, + background: '', + font: 16, + voiceActor: '', + }, + setSetting: (value: Partial) => { + const { setting } = get(); + set({ setting: { ...setting, ...value } }); + }, +})); diff --git a/src/app/(main)/chat/[id]/Input.tsx b/src/app/(main)/chat/[id]/Input.tsx index 93d606c..2e7833e 100644 --- a/src/app/(main)/chat/[id]/Input.tsx +++ b/src/app/(main)/chat/[id]/Input.tsx @@ -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(null); // 调整高度的函数 @@ -28,6 +34,14 @@ const AuthHeightTextarea = (props: React.ComponentProps<'textarea'> & { maxHeigh textarea.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden'; }; + const handleKeyDown = (e: React.KeyboardEvent) => { + // 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 (
@@ -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() {
sendMessage(inputValue)} - disabled={false} + onClick={handleSend} + disabled={!inputValue.trim()} className="flex-shrink-0" /> diff --git a/src/app/(main)/chat/[id]/MessageList.tsx b/src/app/(main)/chat/[id]/MessageList.tsx index 7326a08..c4462c2 100644 --- a/src/app/(main)/chat/[id]/MessageList.tsx +++ b/src/app/(main)/chat/[id]/MessageList.tsx @@ -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 ( -
+
); diff --git a/src/app/(main)/chat/[id]/Sider/ChatModel.tsx b/src/app/(main)/chat/[id]/Sider/ChatModel.tsx deleted file mode 100644 index 22cdcb1..0000000 --- a/src/app/(main)/chat/[id]/Sider/ChatModel.tsx +++ /dev/null @@ -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 ( -
-
-
-
-
-
Role-Playing Model
- - - - - -
-

- Text Message Price: Refers to the cost of chatting with the character via text - messages, including sending text, images, or gifts. Charged per message. -

-

- Voice Message Price: Refers to the cost of sending a voice message to the - character or playing the character’s voice. Charged per use. -

-

- Voice Call Price: Refers to the cost of having a voice call with the - character. Charged per minute. -

-
-
-
-
- -
-
- Role-play a conversation with AI -
- -
-
- diamond - 1/Text Message -
- -
-
- diamond - 10/Send or play voice -
-
- -
-
- diamond - 20/min Voice call -
-
-
-
-
Stay tuned for more models
-
-
- ); -} diff --git a/src/app/(main)/chat/[id]/Sider/Language.tsx b/src/app/(main)/chat/[id]/Sider/Language.tsx deleted file mode 100644 index 1d84e31..0000000 --- a/src/app/(main)/chat/[id]/Sider/Language.tsx +++ /dev/null @@ -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 ( -
-
-
- {tokenOptions.map((option) => ( -
setSelectedToken(option.value)} - > -
{option.label}
- -
- ))} -
-
-
- ); -} diff --git a/src/app/(main)/chat/[id]/UserMessage.tsx b/src/app/(main)/chat/[id]/UserMessage.tsx index be41629..fc9b943 100644 --- a/src/app/(main)/chat/[id]/UserMessage.tsx +++ b/src/app/(main)/chat/[id]/UserMessage.tsx @@ -1,5 +1,8 @@ 'use client'; -export default function UserMessage({ data }: { data: any }) { + +import React from 'react'; + +function UserMessage({ data }: { data: any }) { return (
@@ -8,3 +11,4 @@ export default function UserMessage({ data }: { data: any }) {
); } +export default React.memo(UserMessage); diff --git a/src/app/(main)/chat/[id]/page.tsx b/src/app/(main)/chat/[id]/page.tsx index 219183f..624ca39 100644 --- a/src/app/(main)/chat/[id]/page.tsx +++ b/src/app/(main)/chat/[id]/page.tsx @@ -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'; diff --git a/src/app/(main)/chat/[id]/store.ts b/src/app/(main)/chat/[id]/store.ts deleted file mode 100644 index 240597f..0000000 --- a/src/app/(main)/chat/[id]/store.ts +++ /dev/null @@ -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((set) => ({ - isSidebarOpen: false, - setIsSidebarOpen: (isSidebarOpen: boolean) => set({ isSidebarOpen }), - sideBar: 'profile', - setSideBar: (sideBar: SideBar) => set({ sideBar }), -})); diff --git a/src/stores/stream-chat.ts b/src/app/(main)/chat/[id]/stream-chat.ts similarity index 51% rename from src/stores/stream-chat.ts rename to src/app/(main)/chat/[id]/stream-chat.ts index 1c04ae9..44b0bef 100644 --- a/src/stores/stream-chat.ts +++ b/src/app/(main)/chat/[id]/stream-chat.ts @@ -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; }; +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: { - userId: string; - userName: string; - }; + user: UserType; + chatSetting: ChatSettingType; // 连接 StreamChat 客户端 connect: (user: any) => Promise; // 频道 channels: Channel[]; currentChannel: Channel | null; + + // 用户聊天设置管理 + setChatSetting: (chatSetting: any) => void; + fetchUserChatSetting: () => Promise; + updateUserChatSetting: () => Promise; + // 创建某个角色的聊天频道, 返回channelId createChannel: (characterId: string) => Promise; switchToChannel: (id: string) => Promise; - queryChannels: (filter: any) => Promise; - deleteChannel: (id: string) => Promise; - clearChannels: () => Promise; + queryChannels: (filter: any) => Promise; + deleteChannel: ( + id: string[] + ) => Promise<{ result: string; newChannels?: Channel[]; error?: unknown }>; getCurrentCharacter: () => any | null; // 消息列表 @@ -38,6 +64,9 @@ interface StreamChatStore { // 清除通知 clearNotifications: () => Promise; + + // 推出登录,清除状态 + clearClient: () => void; } export const useStreamChatStore = create((set, get) => ({ client: null, @@ -45,6 +74,14 @@ export const useStreamChatStore = create((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((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((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( - { - id: user.userId, - name: user.userName, - }, - data + 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({ - messages: { limit: 100 }, - }); + 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,68 +177,52 @@ export const useStreamChatStore = create((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( - { - members: { - $in: [user.userId], + channels = await protect(() => + client.queryChannels( + { + members: { + $in: [user.userId], + }, }, - }, - { - last_message_at: -1, - }, - { - message_limit: 1, // 返回最新的1条消息 - } + { + last_message_at: -1, + }, + { + 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((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((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((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, + }); + }, })); diff --git a/src/app/(main)/home/components/Character/index.tsx b/src/app/(main)/home/components/Character/index.tsx index 94e96a3..32cc9bd 100644 --- a/src/app/(main)/home/components/Character/index.tsx +++ b/src/app/(main)/home/components/Character/index.tsx @@ -23,7 +23,10 @@ const Character = () => {
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) => } getItemKey={(character) => character.id} hasNextPage={!noMoreData} diff --git a/src/app/(main)/home/components/Header.tsx b/src/app/(main)/home/components/Header.tsx index 665a6fd..c08e42e 100644 --- a/src/app/(main)/home/components/Header.tsx +++ b/src/app/(main)/home/components/Header.tsx @@ -12,26 +12,24 @@ const Header = React.memo(() => { return (
- header-bg
-
+
Check-in{' '} header-bg {
void - setSelectedTags: (selectedTags: string[]) => void + tab: 'story' | 'character'; + selectedTags: string[]; + setTab: (tab: 'story' | 'character') => void; + setSelectedTags: (selectedTags: string[]) => void; } export const useHomeStore = create((set) => ({ - tab: 'story', + tab: 'character', setTab: (tab: 'story' | 'character') => set({ tab }), selectedTags: [], setSelectedTags: (selectedTags: string[]) => set({ selectedTags }), -})) +})); diff --git a/src/app/(main)/profile/components/ProfileDropdown.tsx b/src/app/(main)/profile/components/ProfileDropdown.tsx index 31d8ddb..8f0b5a4 100644 --- a/src/app/(main)/profile/components/ProfileDropdown.tsx +++ b/src/app/(main)/profile/components/ProfileDropdown.tsx @@ -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) { - setSidebarExpanded(false); - } - - // 4. 执行用户登出 + await clearClient(); + setSidebarExpanded(false); await logout(); - setIsLogoutDialogOpen(false); - setIsLogoutDialogLoading(false); } catch (error) { console.error('登出过程中发生错误:', error); - setIsLogoutDialogLoading(false); } - }; + }); // 菜单项配置 const items: Array< @@ -199,11 +145,7 @@ const ProfileDropdown = () => { Are you sure you want to log out? Cancel - + Log out diff --git a/src/components/ui/virtual-list.tsx b/src/components/ui/virtual-list.tsx index b8cf037..3bd74a0 100644 --- a/src/components/ui/virtual-list.tsx +++ b/src/components/ui/virtual-list.tsx @@ -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 = { + type: string; + data?: T; +}; type VirtualListProps = { - data?: { type: string; data?: T }[]; + data?: ItemType[]; itemContent?: (index: number, item: { type: string; data: T }) => React.ReactNode; - virtuosoProps?: React.ComponentProps; } & React.HTMLAttributes; export default function VirtualList(props: VirtualListProps) { - const { - data = [], - itemContent = (index) =>
{index}
, - virtuosoProps, - ...restProps - } = props; - const virtuosoRef = useRef(null); - // const [showScrollButton, setShowScrollButton] = useState(false); + const { data = [], itemContent = (index) =>
{index}
, ...restProps } = props; + const ref = useRef(null); + const previousData = useRef([]); + 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 ( -
- { - // // 当不在底部时显示按钮,在底部时隐藏 - // setShowScrollButton(!atBottom); - // }} - itemContent={(index, item) => itemContent(index, item as any)} - /> - - {/* 回到底部按钮 */} - {/* {showScrollButton && ( -
- scroll -
- )} */} +
+ {data.map((item, index) => ( +
{itemContent(index, item as any)}
+ ))}
); } diff --git a/src/hooks/services/chat.ts b/src/hooks/services/chat.ts new file mode 100644 index 0000000..aaf8270 --- /dev/null +++ b/src/hooks/services/chat.ts @@ -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(), + }); +} diff --git a/src/layout/BasicLayout.tsx b/src/layout/BasicLayout.tsx index b7ca733..42ef571 100644 --- a/src/layout/BasicLayout.tsx +++ b/src/layout/BasicLayout.tsx @@ -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]); }; diff --git a/src/layout/Sidebar.tsx b/src/layout/Sidebar.tsx index ee6b479..c8d3ba2 100644 --- a/src/layout/Sidebar.tsx +++ b/src/layout/Sidebar.tsx @@ -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() {
-
); diff --git a/src/layout/Topbar.tsx b/src/layout/Topbar.tsx index d11389a..9906beb 100644 --- a/src/layout/Topbar.tsx +++ b/src/layout/Topbar.tsx @@ -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,18 +80,21 @@ function Topbar() { ); return ( - - - - {user.nickname?.slice(0, 1)} - - +
+ + + + + {user.nickname?.slice(0, 1)} + + +
); }; diff --git a/src/layout/components/ChatSearchResults.tsx b/src/layout/components/ChatSearchResults.tsx index b7e0908..b000fe4 100644 --- a/src/layout/components/ChatSearchResults.tsx +++ b/src/layout/components/ChatSearchResults.tsx @@ -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; diff --git a/src/layout/components/ChatSidebar.tsx b/src/layout/components/ChatSidebar.tsx index fac8481..c46c078 100644 --- a/src/layout/components/ChatSidebar.tsx +++ b/src/layout/components/ChatSidebar.tsx @@ -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'; diff --git a/src/layout/components/ChatSidebarAction.tsx b/src/layout/components/ChatSidebarAction.tsx index 7e6d082..16fd799 100644 --- a/src/layout/components/ChatSidebarAction.tsx +++ b/src/layout/components/ChatSidebarAction.tsx @@ -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); - router.replace('/'); + const { result } = await clearChannels(channels.map((ch) => ch.id!)); + if (result === 'ok') { + router.replace('/'); + setIsDeleteMessageDialogOpen(false); + } }); return ( diff --git a/src/layout/components/Notice.tsx b/src/layout/components/Notice.tsx index bdb5b8e..319f609 100644 --- a/src/layout/components/Notice.tsx +++ b/src/layout/components/Notice.tsx @@ -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 ( -
- {/* 分割线 */} - {user && ( -
-
-
- )} - - {/* 底部通知 */} + <> {user && (
{ Notice
- {actualIsExpanded && ( + {/* {actualIsExpanded && ( Notice - )} + )} */}
)} - - {/* 通知抽屉 */} -
- ) -} + + ); +}; -export default Notice +export default Notice; diff --git a/src/lib/protect.ts b/src/lib/protect.ts new file mode 100644 index 0000000..af48a5e --- /dev/null +++ b/src/lib/protect.ts @@ -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(fn: () => Promise, options: ProtectOptions = {}): Promise { + 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 Promise>( + fn: T, + options: ProtectOptions = {} +): T { + return (async (...args: any[]) => { + return protect(() => fn(...args), options); + }) as T; +} diff --git a/src/lib/server/request.ts b/src/lib/server/request.ts index 33a84cf..b2d7dcf 100644 --- a/src/lib/server/request.ts +++ b/src/lib/server/request.ts @@ -18,14 +18,7 @@ export async function fetchServerRequest( tags?: string[]; // 缓存标签,用于手动刷新 } ): Promise> { - 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( }; // 构建完整 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( // 处理 body 数据 if (data) { fetchOptions.body = JSON.stringify( - Object.fromEntries( - Object.entries(data).filter(([, value]) => value !== '') - ) + Object.fromEntries(Object.entries(data).filter(([, value]) => value !== '')) ); } diff --git a/src/services/chat/index.ts b/src/services/chat/index.ts new file mode 100644 index 0000000..8ea5942 --- /dev/null +++ b/src/services/chat/index.ts @@ -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; +} diff --git a/src/services/editor/index.ts b/src/services/editor/index.ts index d6b6627..e26c8a7 100644 --- a/src/services/editor/index.ts +++ b/src/services/editor/index.ts @@ -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 }, - }); -}