diff --git a/.env b/.env index 99abf66..0dd2849 100644 --- a/.env +++ b/.env @@ -8,6 +8,7 @@ NEXT_PUBLIC_PIGEON_API_URL=http://35.82.37.117:8082/pigeon # A18 服务 NEXT_PUBLIC_EDITOR_API_URL=http://54.223.196.180 +NEXT_PUBLIC_CHAT_API_URL=http://54.223.196.180 # 三方登录 NEXT_PUBLIC_DISCORD_CLIENT_ID=1448143535609217076 diff --git a/public/icons/expand.svg b/public/icons/expand.svg deleted file mode 100644 index 23e1f0d..0000000 --- a/public/icons/expand.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/public/icons/explore.svg b/public/icons/explore.svg index e3ff142..2e26875 100644 --- a/public/icons/explore.svg +++ b/public/icons/explore.svg @@ -1,13 +1,6 @@ - - - - - - - - - - - - + + + + + diff --git a/public/icons/explore_selected.svg b/public/icons/explore_selected.svg index 48d0f0c..5bcaac4 100644 --- a/public/icons/explore_selected.svg +++ b/public/icons/explore_selected.svg @@ -1,24 +1,17 @@ - - - - - - + + + + + + - - - + + + + + + + - - - - - - - - - - - diff --git a/src/app/(main)/character/[id]/ChatButton.tsx b/src/app/(main)/character/[id]/ChatButton.tsx new file mode 100644 index 0000000..a91a1bf --- /dev/null +++ b/src/app/(main)/character/[id]/ChatButton.tsx @@ -0,0 +1,22 @@ +'use client'; +import { useRouter } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { useAsyncFn } from '@/hooks/tools'; +import { useStreamChatStore } from '@/stores/stream-chat'; + +export default function ChatButton({ id }: { id: string }) { + const router = useRouter(); + const createChannel = useStreamChatStore((s) => s.createChannel); + + const { loading, run: createChannelAndPush } = useAsyncFn(async () => { + const channelId = await createChannel(id); + if (!channelId) return; + router.push(`/chat/${channelId}`); + }); + + return ( + + ); +} diff --git a/src/app/(main)/character/[id]/chat/MessageList.tsx b/src/app/(main)/character/[id]/chat/MessageList.tsx deleted file mode 100644 index 9b9d6ba..0000000 --- a/src/app/(main)/character/[id]/chat/MessageList.tsx +++ /dev/null @@ -1,45 +0,0 @@ -'use client' - -import CharacterHeader from './CharacterHeader' -import AIMessage from './AIMessage' -import UserMessage from './UserMessage' -import VirtualList from '@/components/ui/virtual-list' - -export default function MessageList() { - const itemList = [ - { - type: 'header', - }, - { - type: 'aiMessage', - data: { - text: '"Hello, how are you?", *Long long ago*, ^there was a beautiful princess...^ (She was the most beautiful princess in the world)', - }, - }, - { - type: 'userMessage', - data: { - text: '"Hello, how are you?", Long long ago, there was a beautiful princess... (She was the most beautiful princess in the world)', - }, - }, - ] - - const itemContent = (index: number, item: { type: string; data: any }) => { - switch (item.type) { - case 'aiMessage': - return - case 'userMessage': - return - case 'header': - return - default: - return null - } - } - - return ( -
- -
- ) -} diff --git a/src/app/(main)/character/[id]/chat/Sider/index.tsx b/src/app/(main)/character/[id]/chat/Sider/index.tsx deleted file mode 100644 index 0b1092f..0000000 --- a/src/app/(main)/character/[id]/chat/Sider/index.tsx +++ /dev/null @@ -1,38 +0,0 @@ -'use client' -import { useChatStore } from '../store' -import Profile from './Profile' -import Personal from './Personal' -import VoiceActor from './VoiceActor' -import Font from './Font' -import MaxToken from './MaxToken' -import Background from './Background' -import ChatModel from './ChatModel' -import { IconButton } from '@/components/ui/button' -import React from 'react' - -export const SiderHeader = React.memo(({ title }: { title: string }) => { - const setSideBar = useChatStore((store) => store.setSideBar) - return ( -
- setSideBar('profile')}> - - -
{title}
-
- ) -}) - -export default function Sider() { - const sideBar = useChatStore((store) => store.sideBar) - return ( -
- {sideBar === 'profile' && } - {sideBar === 'personal' && } - {sideBar === 'voice_actor' && } - {sideBar === 'font' && } - {sideBar === 'max_token' && } - {sideBar === 'background' && } - {sideBar === 'model' && } -
- ) -} diff --git a/src/app/(main)/character/[id]/page.tsx b/src/app/(main)/character/[id]/page.tsx index e8fb63d..322c0e4 100644 --- a/src/app/(main)/character/[id]/page.tsx +++ b/src/app/(main)/character/[id]/page.tsx @@ -1,6 +1,5 @@ -import { Button } from '@/components/ui/button'; import { AvatarImage, AvatarFallback, Avatar } from '@radix-ui/react-avatar'; -import Link from 'next/link'; +import ChatButton from './ChatButton'; export default async function Page({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; @@ -27,9 +26,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
- - - +
diff --git a/src/app/(main)/chat-history/page.tsx b/src/app/(main)/chat-history/page.tsx index 4f61f02..3cb26f1 100644 --- a/src/app/(main)/chat-history/page.tsx +++ b/src/app/(main)/chat-history/page.tsx @@ -1,9 +1,10 @@ 'use client'; +import ChatSidebar from '@/layout/components/ChatSidebar'; export default function ChatPage() { return ( -
-

Chat

+
+
); } diff --git a/src/app/(main)/character/[id]/chat/AIMessage.tsx b/src/app/(main)/chat/[id]/AIMessage.tsx similarity index 66% rename from src/app/(main)/character/[id]/chat/AIMessage.tsx rename to src/app/(main)/chat/[id]/AIMessage.tsx index 9fa09eb..8d86ea3 100644 --- a/src/app/(main)/character/[id]/chat/AIMessage.tsx +++ b/src/app/(main)/chat/[id]/AIMessage.tsx @@ -1,13 +1,13 @@ -'use client' +'use client'; -import AITextRender from '@/components/ui/text-md' +import AITextRender from '@/components/ui/text-md'; export default function AIMessage({ data }: { data: any }) { return (
- +
- ) + ); } diff --git a/src/app/(main)/character/[id]/chat/CharacterHeader.tsx b/src/app/(main)/chat/[id]/CharacterHeader.tsx similarity index 63% rename from src/app/(main)/character/[id]/chat/CharacterHeader.tsx rename to src/app/(main)/chat/[id]/CharacterHeader.tsx index 0a54e1d..db91d37 100644 --- a/src/app/(main)/character/[id]/chat/CharacterHeader.tsx +++ b/src/app/(main)/chat/[id]/CharacterHeader.tsx @@ -1,51 +1,48 @@ -'use client' +'use client'; -import { Tag } from '@/components/ui/tag' -import { useState, useRef, useEffect } from 'react' -import { cn } from '@/lib/utils' -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { Tag } from '@/components/ui/tag'; +import { useState, useRef, useEffect } from 'react'; +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 CrushLevelAvatar from './CrushLevelAvatar' -export const CharacterAvatorAndName = () => { - const { introduction, characterName, tagName } = { - introduction: 'introduction introduction introduction introduction introduction', - characterName: 'characterName', - tagName: 'tagName', - } +export const CharacterAvatorAndName = ({ name, avator }: { name: string; avator: string }) => { return (
- - {characterName?.slice(0, 1)} + + {name?.slice(0, 1)} -
Honey Snow
+
{name}
- ) -} + ); +}; const ChatMessageUserHeader = () => { - const [isFullIntroduction, setIsFullIntroduction] = useState(false) - const [shouldShowExpandButton, setShouldShowExpandButton] = useState(false) - const textRef = useRef(null) - const { introduction, characterName, tagName } = { + 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', - characterName: 'characterName', - tagName: 'tagName', - } + }; // 检测文本是否超过三行 useEffect(() => { if (textRef.current && introduction) { // 直接比较滚动高度和可见高度 // 如果内容的实际高度大于容器的可见高度,说明内容被截断了 - const isOverflowing = textRef.current.scrollHeight > textRef.current.clientHeight - setShouldShowExpandButton(isOverflowing) + const isOverflowing = textRef.current.scrollHeight > textRef.current.clientHeight; + setShouldShowExpandButton(isOverflowing); } - }, [introduction]) + }, [introduction]); return (
- +
{
- - {characterName} - - - {tagName} - + {character?.tags?.slice(0, 2)?.map((tag: any, index: number) => ( + + {tag.name} + + ))}
{shouldShowExpandButton && !isFullIntroduction && (
{
Content generated by AI
- ) -} + ); +}; -export default ChatMessageUserHeader +export default ChatMessageUserHeader; diff --git a/src/app/(main)/character/[id]/chat/Input.tsx b/src/app/(main)/chat/[id]/Input.tsx similarity index 93% rename from src/app/(main)/character/[id]/chat/Input.tsx rename to src/app/(main)/chat/[id]/Input.tsx index 4f4a197..93d606c 100644 --- a/src/app/(main)/character/[id]/chat/Input.tsx +++ b/src/app/(main)/chat/[id]/Input.tsx @@ -2,6 +2,7 @@ import { IconButton } from '@/components/ui/button'; import { cn } from '@/lib/utils'; +import { useStreamChatStore } from '@/stores/stream-chat'; import { useState, useRef, useEffect } from 'react'; const AuthHeightTextarea = (props: React.ComponentProps<'textarea'> & { maxHeight?: number }) => { @@ -59,6 +60,7 @@ const AuthHeightTextarea = (props: React.ComponentProps<'textarea'> & { maxHeigh export default function Input() { const [isRecording, setIsRecording] = useState(false); const [inputValue, setInputValue] = useState(''); + const sendMessage = useStreamChatStore((state) => state.sendMessage); return (
@@ -85,7 +87,7 @@ export default function Input() { {}} + onClick={() => null} className={cn('bg-surface-element-hover flex-shrink-0')} iconfont="icon-prompt" /> @@ -94,7 +96,7 @@ export default function Input() { size="large" loading={false} iconfont="icon-icon-send" - onClick={() => {}} + onClick={() => sendMessage(inputValue)} disabled={false} className="flex-shrink-0" /> diff --git a/src/app/(main)/chat/[id]/MessageList.tsx b/src/app/(main)/chat/[id]/MessageList.tsx new file mode 100644 index 0000000..7326a08 --- /dev/null +++ b/src/app/(main)/chat/[id]/MessageList.tsx @@ -0,0 +1,41 @@ +'use client'; + +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'; + +export default function MessageList() { + const messages = useStreamChatStore((s) => s.messages); + const itemList = [ + { + type: 'header', + }, + ...messages.map((i) => { + return { + type: i.role === 'user' ? 'user-message' : 'assistant-message', + data: i, + }; + }), + ]; + + const itemContent = (index: number, item: { type: string; data: any }) => { + switch (item.type) { + case 'user-message': + return ; + case 'assistant-message': + return ; + case 'header': + return ; + default: + return null; + } + }; + + return ( +
+ +
+ ); +} diff --git a/src/app/(main)/character/[id]/chat/Sider/Background.tsx b/src/app/(main)/chat/[id]/Sider/Background.tsx similarity index 72% rename from src/app/(main)/character/[id]/chat/Sider/Background.tsx rename to src/app/(main)/chat/[id]/Sider/Background.tsx index ead9f68..6ad80ad 100644 --- a/src/app/(main)/character/[id]/chat/Sider/Background.tsx +++ b/src/app/(main)/chat/[id]/Sider/Background.tsx @@ -1,21 +1,20 @@ -'use client' -import { SiderHeader } from '.' -import { useChatStore } from '../store' -import { Button, IconButton } from '@/components/ui/button' -import { cn } from '@/lib/utils' -import Image from 'next/image' -import { Checkbox } from '@/components/ui/checkbox' -import React, { useState } from 'react' -import { Tag } from '@/components/ui/tag' -import { ImageViewer } from '@/components/ui/image-viewer' -import { useImageViewer } from '@/hooks/useImageViewer' +'use client'; +import { useChatStore } from '../store'; +import { Button, IconButton } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import Image from 'next/image'; +import { Checkbox } from '@/components/ui/checkbox'; +import React, { useState } from 'react'; +import { Tag } from '@/components/ui/tag'; +import { ImageViewer } from '@/components/ui/image-viewer'; +import { useImageViewer } from '@/hooks/useImageViewer'; type BackgroundItem = { - backgroundId: number - imgUrl: string - isDefault: boolean - inUse?: boolean -} + backgroundId: number; + imgUrl: string; + isDefault: boolean; + inUse?: boolean; +}; const BackgroundImageViewerAction = ({ datas, @@ -23,18 +22,18 @@ const BackgroundImageViewerAction = ({ isSelected, onChange, }: { - datas: BackgroundItem[] - backgroundId: number - isSelected: boolean - onChange: (backgroundId: number) => void + datas: BackgroundItem[]; + backgroundId: number; + isSelected: boolean; + onChange: (backgroundId: number) => void; }) => { const handleSelect = () => { // 如果只有一张背景且当前已选中,不允许取消选中 if (datas.length === 1 && isSelected) { - return + return; } - onChange(backgroundId) - } + onChange(backgroundId); + }; return ( <> @@ -47,8 +46,8 @@ const BackgroundImageViewerAction = ({
Select
- ) -} + ); +}; const BackgroundItemCard = ({ item, @@ -58,20 +57,20 @@ const BackgroundItemCard = ({ onImagePreview, totalCount, }: { - item: BackgroundItem - selected: boolean - inUse: boolean - onClick: () => void - onImagePreview: () => void - totalCount: number + item: BackgroundItem; + selected: boolean; + inUse: boolean; + onClick: () => void; + onImagePreview: () => void; + totalCount: number; }) => { const handleClick = () => { // 如果只有一张背景且当前已选中,不允许取消选中 if (totalCount === 1 && selected) { - return + return; } - onClick() - } + onClick(); + }; return (
@@ -94,20 +93,20 @@ const BackgroundItemCard = ({ size="xs" variant="contrast" onClick={(e) => { - e.stopPropagation() - onImagePreview() + e.stopPropagation(); + onImagePreview(); }} >
- ) -} + ); +}; export default function Background() { - const setSideBar = useChatStore((store) => store.setSideBar) - const [selectId, setSelectId] = useState(1) - const [loading, setLoading] = useState(false) + const setSideBar = useChatStore((store) => store.setSideBar); + const [selectId, setSelectId] = useState(1); + const [loading, setLoading] = useState(false); // 静态数据:模拟背景图片列表 const backgroundList: BackgroundItem[] = [ @@ -132,13 +131,13 @@ export default function Background() { imgUrl: 'https://picsum.photos/400/600?random=4', isDefault: false, }, - ] + ]; // 当前使用的背景 - const currentBackgroundImg = backgroundList.find((item) => item.inUse)?.imgUrl + const currentBackgroundImg = backgroundList.find((item) => item.inUse)?.imgUrl; // 静态数据:是否解锁生成图片功能 - const isUnlock = true + const isUnlock = true; // 图片查看器 const { @@ -147,35 +146,33 @@ export default function Background() { openViewer, closeViewer, handleIndexChange, - } = useImageViewer() + } = useImageViewer(); const handleConfirm = async () => { - setLoading(true) + setLoading(true); try { // TODO: 调用实际的 API // await updateChatBackground({ aiId, backgroundId: selectId }) - console.log('Selected background:', selectId) + console.log('Selected background:', selectId); // 模拟延迟 - await new Promise((resolve) => setTimeout(resolve, 500)) + await new Promise((resolve) => setTimeout(resolve, 500)); - setSideBar('profile') + setSideBar('profile'); } catch (error) { - console.error(error) + console.error(error); } finally { - setLoading(false) + setLoading(false); } - } + }; const handleImagePreview = (index: number) => { - openViewer(backgroundList?.map((item) => item.imgUrl || '') || [], index) - } + openViewer(backgroundList?.map((item) => item.imgUrl || '') || [], index); + }; return ( <>
- -
{backgroundList?.map((item, index) => ( @@ -191,15 +188,6 @@ export default function Background() { ))}
- -
- - -
- ) + ); }} /> - ) + ); } diff --git a/src/app/(main)/character/[id]/chat/Sider/ChatModel.tsx b/src/app/(main)/chat/[id]/Sider/ChatModel.tsx similarity index 83% rename from src/app/(main)/character/[id]/chat/Sider/ChatModel.tsx rename to src/app/(main)/chat/[id]/Sider/ChatModel.tsx index cc3d8bc..22cdcb1 100644 --- a/src/app/(main)/character/[id]/chat/Sider/ChatModel.tsx +++ b/src/app/(main)/chat/[id]/Sider/ChatModel.tsx @@ -1,17 +1,15 @@ -'use client' -import { SiderHeader } from '.' -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' +'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) + const setSideBar = useChatStore((store) => store.setSideBar); return (
-
@@ -68,12 +66,6 @@ export default function ChatModel() {
Stay tuned for more models
-
- - -
- ) + ); } diff --git a/src/app/(main)/character/[id]/chat/Sider/Font.tsx b/src/app/(main)/chat/[id]/Sider/Font.tsx similarity index 58% rename from src/app/(main)/character/[id]/chat/Sider/Font.tsx rename to src/app/(main)/chat/[id]/Sider/Font.tsx index 758a2e4..f3a18f7 100644 --- a/src/app/(main)/character/[id]/chat/Sider/Font.tsx +++ b/src/app/(main)/chat/[id]/Sider/Font.tsx @@ -1,19 +1,18 @@ -'use client' -import { SiderHeader } from '.' -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' +'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'; type FontOption = { - value: number - label: string - isStandard?: boolean -} + value: number; + label: string; + isStandard?: boolean; +}; export default function Font() { - const setSideBar = useChatStore((store) => store.setSideBar) + const setSideBar = useChatStore((store) => store.setSideBar); // 字体大小选项 const fontOptions: FontOption[] = [ @@ -22,33 +21,31 @@ export default function Font() { { value: 16, label: 'A 16', isStandard: true }, { value: 18, label: 'A 18' }, { value: 20, label: 'A 20' }, - ] + ]; - const [selectedFont, setSelectedFont] = useState(16) - const [loading, setLoading] = useState(false) + const [selectedFont, setSelectedFont] = useState(16); + const [loading, setLoading] = useState(false); const handleConfirm = async () => { - setLoading(true) + setLoading(true); try { // TODO: 调用实际的 API 保存字体设置 // await updateFontSize({ fontSize: selectedFont }) - console.log('Selected font size:', selectedFont) + console.log('Selected font size:', selectedFont); // 模拟延迟 - await new Promise((resolve) => setTimeout(resolve, 500)) + await new Promise((resolve) => setTimeout(resolve, 500)); - setSideBar('profile') + setSideBar('profile'); } catch (error) { - console.error(error) + console.error(error); } finally { - setLoading(false) + setLoading(false); } - } + }; return (
- -
{fontOptions.map((option) => ( @@ -71,15 +68,6 @@ export default function Font() { ))}
- -
- - -
- ) + ); } diff --git a/src/app/(main)/chat/[id]/Sider/Language.tsx b/src/app/(main)/chat/[id]/Sider/Language.tsx new file mode 100644 index 0000000..1d84e31 --- /dev/null +++ b/src/app/(main)/chat/[id]/Sider/Language.tsx @@ -0,0 +1,58 @@ +'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)/character/[id]/chat/Sider/MaxToken.tsx b/src/app/(main)/chat/[id]/Sider/MaxToken.tsx similarity index 55% rename from src/app/(main)/character/[id]/chat/Sider/MaxToken.tsx rename to src/app/(main)/chat/[id]/Sider/MaxToken.tsx index 77963d6..572577c 100644 --- a/src/app/(main)/character/[id]/chat/Sider/MaxToken.tsx +++ b/src/app/(main)/chat/[id]/Sider/MaxToken.tsx @@ -1,18 +1,17 @@ -'use client' -import { SiderHeader } from '.' -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' +'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'; type TokenOption = { - value: number - label: string -} + value: number; + label: string; +}; export default function MaxToken() { - const setSideBar = useChatStore((store) => store.setSideBar) + const setSideBar = useChatStore((store) => store.setSideBar); // 最大回复数选项 const tokenOptions: TokenOption[] = [ @@ -20,33 +19,31 @@ export default function MaxToken() { { value: 1000, label: '1000' }, { value: 1200, label: '1200' }, { value: 1500, label: '1500' }, - ] + ]; - const [selectedToken, setSelectedToken] = useState(800) - const [loading, setLoading] = useState(false) + const [selectedToken, setSelectedToken] = useState(800); + const [loading, setLoading] = useState(false); const handleConfirm = async () => { - setLoading(true) + setLoading(true); try { // TODO: 调用实际的 API 保存最大回复数设置 // await updateMaxToken({ maxToken: selectedToken }) - console.log('Selected max token:', selectedToken) + console.log('Selected max token:', selectedToken); // 模拟延迟 - await new Promise((resolve) => setTimeout(resolve, 500)) + await new Promise((resolve) => setTimeout(resolve, 500)); - setSideBar('profile') + setSideBar('profile'); } catch (error) { - console.error(error) + console.error(error); } finally { - setLoading(false) + setLoading(false); } - } + }; return (
- -
{tokenOptions.map((option) => ( @@ -64,15 +61,6 @@ export default function MaxToken() { ))}
- -
- - -
- ) + ); } diff --git a/src/app/(main)/character/[id]/chat/Sider/Personal.tsx b/src/app/(main)/chat/[id]/Sider/Personal.tsx similarity index 84% rename from src/app/(main)/character/[id]/chat/Sider/Personal.tsx rename to src/app/(main)/chat/[id]/Sider/Personal.tsx index c472ccb..558e9f6 100644 --- a/src/app/(main)/character/[id]/chat/Sider/Personal.tsx +++ b/src/app/(main)/chat/[id]/Sider/Personal.tsx @@ -1,9 +1,8 @@ -'use client' -import { useEffect, useState, useCallback } from 'react' -import { SiderHeader } from '.' -import { useChatStore } from '../store' -import { z } from 'zod' -import dayjs from 'dayjs' +'use client'; +import { useEffect, useState, useCallback } from 'react'; +import { useChatStore } from '../store'; +import { z } from 'zod'; +import dayjs from 'dayjs'; import { Form, FormControl, @@ -11,22 +10,22 @@ import { FormItem, FormLabel, FormMessage, -} from '@/components/ui/form' -import { zodResolver } from '@hookform/resolvers/zod' -import { useForm } from 'react-hook-form' -import { Gender } from '@/types/user' -import { Input } from '@/components/ui/input' +} from '@/components/ui/form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { Gender } from '@/types/user'; +import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from '@/components/ui/select' -import { Label } from '@/components/ui/label' -import { calculateAge, getDaysInMonth } from '@/lib/utils' -import { Textarea } from '@/components/ui/textarea' -import { Button } from '@/components/ui/button' +} from '@/components/ui/select'; +import { Label } from '@/components/ui/label'; +import { calculateAge, getDaysInMonth } from '@/lib/utils'; +import { Textarea } from '@/components/ui/textarea'; +import { Button } from '@/components/ui/button'; import { AlertDialog, AlertDialogAction, @@ -36,12 +35,12 @@ import { AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, -} from '@/components/ui/alert-dialog' +} from '@/components/ui/alert-dialog'; -const currentYear = dayjs().year() -const years = Array.from({ length: currentYear - 1950 + 1 }, (_, i) => `${1950 + i}`) -const months = Array.from({ length: 12 }, (_, i) => `${i + 1}`.padStart(2, '0')) -const monthTexts = Array.from({ length: 12 }, (_, i) => dayjs().month(i).format('MMM')) +const currentYear = dayjs().year(); +const years = Array.from({ length: currentYear - 1950 + 1 }, (_, i) => `${1950 + i}`); +const months = Array.from({ length: 12 }, (_, i) => `${i + 1}`.padStart(2, '0')); +const monthTexts = Array.from({ length: 12 }, (_, i) => dayjs().month(i).format('MMM')); const characterFormSchema = z .object({ @@ -59,8 +58,8 @@ const characterFormSchema = z }) .refine( (data) => { - const age = calculateAge(data.year, data.month, data.day) - return age >= 18 + const age = calculateAge(data.year, data.month, data.day); + return age >= 18; }, { message: 'Character age must be at least 18 years old', @@ -71,22 +70,22 @@ const characterFormSchema = z (data) => { if (data.profile) { if (data.profile.trim().length > 300) { - return false + return false; } - return data.profile.trim().length >= 10 + return data.profile.trim().length >= 10; } - return true + return true; }, { message: 'At least 10 characters', path: ['profile'], } - ) + ); export default function Personal() { - const setSideBar = useChatStore((store) => store.setSideBar) - const [loading, setLoading] = useState(false) - const [showConfirmDialog, setShowConfirmDialog] = useState(false) + const setSideBar = useChatStore((store) => store.setSideBar); + const [loading, setLoading] = useState(false); + const [showConfirmDialog, setShowConfirmDialog] = useState(false); // 静态数据,模拟从接口获取的数据 const chatSettingData = { @@ -94,9 +93,9 @@ export default function Personal() { sex: Gender.MALE, birthday: dayjs('1995-06-15').valueOf(), whoAmI: 'A creative and passionate developer', - } + }; - const birthday = chatSettingData?.birthday ? dayjs(chatSettingData.birthday) : undefined + const birthday = chatSettingData?.birthday ? dayjs(chatSettingData.birthday) : undefined; const form = useForm>({ resolver: zodResolver(characterFormSchema), @@ -111,40 +110,40 @@ export default function Personal() { day: birthday?.date().toString().padStart(2, '0') || undefined, profile: chatSettingData?.whoAmI || '', }, - }) + }); // 处理返回的逻辑 const handleGoBack = useCallback(() => { if (form.formState.isDirty) { - setShowConfirmDialog(true) + setShowConfirmDialog(true); } else { - setSideBar('profile') + setSideBar('profile'); } - }, [form.formState.isDirty, setSideBar]) + }, [form.formState.isDirty, setSideBar]); // 确认放弃修改 const handleConfirmDiscard = useCallback(() => { - form.reset() - setShowConfirmDialog(false) - setSideBar('profile') - }, [form, setSideBar]) + form.reset(); + setShowConfirmDialog(false); + setSideBar('profile'); + }, [form, setSideBar]); async function onSubmit(data: z.infer) { if (!form.formState.isDirty) { - setSideBar('profile') - return + setSideBar('profile'); + return; } - setLoading(true) + setLoading(true); try { // TODO: 这里应该调用实际的 API // 模拟检查昵称是否存在 - const isExist = false // await checkNickname({ nickname: data.nickname.trim() }) + const isExist = false; // await checkNickname({ nickname: data.nickname.trim() }) if (isExist) { form.setError('nickname', { message: 'This nickname is already taken', - }) - return + }); + return; } // TODO: 这里应该调用实际的保存 API @@ -159,19 +158,19 @@ export default function Personal() { nickname: data.nickname, birthday: new Date(`${data.year}-${data.month}-${data.day}`).getTime(), whoAmI: data.profile || '', - }) + }); - setSideBar('profile') + setSideBar('profile'); } catch (error) { - console.error(error) + console.error(error); } finally { - setLoading(false) + setLoading(false); } } - const selectedYear = form.watch('year') - const selectedMonth = form.watch('month') - const days = selectedYear && selectedMonth ? getDaysInMonth(selectedYear, selectedMonth) : [] + const selectedYear = form.watch('year'); + const selectedMonth = form.watch('month'); + const days = selectedYear && selectedMonth ? getDaysInMonth(selectedYear, selectedMonth) : []; const genderTexts = [ { @@ -186,16 +185,14 @@ export default function Personal() { value: Gender.OTHER, label: 'Other', }, - ] + ]; - const gender = form.watch('sex') - const genderText = genderTexts.find((text) => text.value === gender)?.label + const gender = form.watch('sex'); + const genderText = genderTexts.find((text) => text.value === gender)?.label; return ( <>
- -
-
+ {/*
@@ -344,7 +341,7 @@ export default function Personal() { > Save -
+
*/}
{/* 确认放弃修改的对话框 */} @@ -368,5 +365,5 @@ export default function Personal() { - ) + ); } diff --git a/src/app/(main)/character/[id]/chat/Sider/Profile.tsx b/src/app/(main)/chat/[id]/Sider/Profile.tsx similarity index 54% rename from src/app/(main)/character/[id]/chat/Sider/Profile.tsx rename to src/app/(main)/chat/[id]/Sider/Profile.tsx index 1ba0b50..1971767 100644 --- a/src/app/(main)/character/[id]/chat/Sider/Profile.tsx +++ b/src/app/(main)/chat/[id]/Sider/Profile.tsx @@ -1,24 +1,26 @@ -'use client' +'use client'; -import { getAge } from '@/lib/utils' -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 { IconButton } from '@/components/ui/button' -import React from 'react' -import { Switch } from '@/components/ui/switch' +import { getAge } from '@/lib/utils'; +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'; const genderMap = { 0: '/icons/male.svg', 1: '/icons/female.svg', 2: '/icons/gender-neutral.svg', -} +}; -const ChatProfilePersona = React.memo(() => { - const setSideBar = useChatStore((store) => store.setSideBar) - const whoAmI = 'whoAmI' +const ChatProfilePersona = React.memo(({ onActiveTab }: ProfileProps) => { + const whoAmI = 'whoAmI'; return (
@@ -26,13 +28,13 @@ const ChatProfilePersona = React.memo(() => {
My Chat Persona
setSideBar('personal')} + onClick={() => onActiveTab('personal')} > Edit
-
+
Nickname
{''}
@@ -60,22 +62,37 @@ const ChatProfilePersona = React.memo(() => {
- ) -}) + ); +}); type SettingItem = { - onClick: () => void - label: string - value?: React.ReactNode -} + onClick: () => void; + label: string; + value?: React.ReactNode; +}; -export default function Profile() { - const setSideBar = useChatStore((store) => store.setSideBar) +type ProfileProps = { + onActiveTab: (tab: ActiveTabType) => void; +}; + +export default function Profile({ onActiveTab }: ProfileProps) { + const { id } = useParams<{ id: string }>(); + const { data: character = {} } = useCharacter(id.split('-')[2]); + + const preferenceItems: SettingItem[][] = [ + [ + { + onClick: () => onActiveTab('language'), + label: 'Language', + value: 'zh-CN', + }, + ], + ]; const chatSettingItems: SettingItem[][] = [ [ { - onClick: () => setSideBar('model'), + onClick: () => onActiveTab('model'), label: 'Chat Model', value: 'Role-Playing', }, @@ -87,34 +104,34 @@ export default function Profile() { ], [ { - onClick: () => setSideBar('max_token'), + onClick: () => onActiveTab('max_token'), label: 'Maximum Replies', value: '1200', }, ], [ { - onClick: () => setSideBar('font'), + onClick: () => onActiveTab('font'), label: 'Font', value: '17px', }, { - onClick: () => setSideBar('background'), + onClick: () => onActiveTab('background'), label: 'Chat Background', value: '17px', }, ], - ] + ]; const voiceSettingItems: SettingItem[][] = [ [ { - onClick: () => setSideBar('voice_actor'), + onClick: () => onActiveTab('voice_actor'), label: 'Voice Artist', value: 'Default', }, ], - ] + ]; const bundleRender = (title: string, items: SettingItem[][]) => { return ( @@ -122,7 +139,7 @@ export default function Profile() {
{title}
{items.map((list, index) => ( -
+
{list.map((item, itemIndex) => (
- ) - } + ); + }; return ( -
- +
+ {/*
+ setSideBar('profile')}> + + + setSideBar('profile')}> + + +
*/} +
+ - {/* Tags */} -
-
- - Gender -
{getAge(Number(24))}
-
- {'Sensibility'} - {'Romantic'} + {/* Tags */} +
+
+ + Gender +
{getAge(Number(24))}
+
+ {'Sensibility'} + {'Romantic'} +
+ + + + {bundleRender('Preference', preferenceItems)} + + {bundleRender('Chat Setting', chatSettingItems)} + + {bundleRender('Voice Setting', voiceSettingItems)} +
+
+ +
- - - - {bundleRender('Chat Setting', chatSettingItems)} - - {bundleRender('Voice Setting', voiceSettingItems)}
- ) + ); } diff --git a/src/app/(main)/character/[id]/chat/Sider/VoiceActor.tsx b/src/app/(main)/chat/[id]/Sider/VoiceActor.tsx similarity index 80% rename from src/app/(main)/character/[id]/chat/Sider/VoiceActor.tsx rename to src/app/(main)/chat/[id]/Sider/VoiceActor.tsx index 3974280..986f617 100644 --- a/src/app/(main)/character/[id]/chat/Sider/VoiceActor.tsx +++ b/src/app/(main)/chat/[id]/Sider/VoiceActor.tsx @@ -1,24 +1,23 @@ -'use client' -import { SiderHeader } from '.' -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' +'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'; -type VoiceGender = 'all' | 'male' | 'female' +type VoiceGender = 'all' | 'male' | 'female'; type VoiceActorItem = { - id: number - name: string - description: string - avatarUrl: string - gender: 'male' | 'female' -} + id: number; + name: string; + description: string; + avatarUrl: string; + gender: 'male' | 'female'; +}; export default function VoiceActor() { - const setSideBar = useChatStore((store) => store.setSideBar) + const setSideBar = useChatStore((store) => store.setSideBar); // 语音演员列表(静态数据) const voiceActors: VoiceActorItem[] = [ @@ -71,40 +70,38 @@ export default function VoiceActor() { avatarUrl: 'https://i.pravatar.cc/150?img=7', gender: 'male', }, - ] + ]; - const [selectedGender, setSelectedGender] = useState('all') - const [selectedActorId, setSelectedActorId] = useState(1) - const [loading, setLoading] = useState(false) + const [selectedGender, setSelectedGender] = useState('all'); + const [selectedActorId, setSelectedActorId] = useState(1); + const [loading, setLoading] = useState(false); // 根据性别过滤演员列表 const filteredActors = voiceActors.filter((actor) => { - if (selectedGender === 'all') return true - return actor.gender === selectedGender - }) + if (selectedGender === 'all') return true; + return actor.gender === selectedGender; + }); const handleConfirm = async () => { - setLoading(true) + setLoading(true); try { // TODO: 调用实际的 API 保存语音演员设置 // await updateVoiceActor({ voiceActorId: selectedActorId }) - console.log('Selected voice actor:', selectedActorId) + console.log('Selected voice actor:', selectedActorId); // 模拟延迟 - await new Promise((resolve) => setTimeout(resolve, 500)) + await new Promise((resolve) => setTimeout(resolve, 500)); - setSideBar('profile') + setSideBar('profile'); } catch (error) { - console.error(error) + console.error(error); } finally { - setLoading(false) + setLoading(false); } - } + }; return (
- - {/* Gender Tabs */}
{[ @@ -157,14 +154,14 @@ export default function VoiceActor() {
{/* Footer Buttons */} -
+ {/*
-
+
*/}
- ) + ); } diff --git a/src/app/(main)/chat/[id]/Sider/index.tsx b/src/app/(main)/chat/[id]/Sider/index.tsx new file mode 100644 index 0000000..da53ad4 --- /dev/null +++ b/src/app/(main)/chat/[id]/Sider/index.tsx @@ -0,0 +1,81 @@ +'use client'; +import { useChatStore } from '../store'; +import Profile from './Profile'; +import Personal from './Personal'; +import VoiceActor from './VoiceActor'; +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 { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; + +type SettingProps = { + open: boolean; + onOpenChange: (open: boolean) => void; +}; +export type ActiveTabType = + | 'profile' + | 'personal' + | 'history' + | 'voice_actor' + | 'font' + | 'max_token' + | 'background' + | 'model' + | 'language'; + +const titleMap = { + personal: 'Personal', + history: 'History', + voice_actor: 'Voice Actor', + font: 'Font', + max_token: 'Max Token', + background: 'Background', + model: 'Chat Model', + language: 'Language', +}; + +export default function SettingDialog({ open, onOpenChange }: SettingProps) { + const [activeTab, setActiveTab] = useState('profile'); + + return ( + + + + {activeTab === 'profile' ? ( + + ) : ( + titleMap[activeTab] + )} + {activeTab !== 'profile' && ( + setActiveTab('profile')}> + + + )} + +
+ {activeTab === 'profile' && } + {activeTab === 'personal' && } + {activeTab === 'voice_actor' && } + {activeTab === 'font' && } + {activeTab === 'max_token' && } + {activeTab === 'background' && } + {activeTab === 'model' && } + {activeTab === 'language' && } +
+
+
+ ); +} diff --git a/src/app/(main)/character/[id]/chat/UserMessage.tsx b/src/app/(main)/chat/[id]/UserMessage.tsx similarity index 85% rename from src/app/(main)/character/[id]/chat/UserMessage.tsx rename to src/app/(main)/chat/[id]/UserMessage.tsx index 880b310..be41629 100644 --- a/src/app/(main)/character/[id]/chat/UserMessage.tsx +++ b/src/app/(main)/chat/[id]/UserMessage.tsx @@ -1,10 +1,10 @@ -'use client' +'use client'; export default function UserMessage({ data }: { data: any }) { return (
- {data.text} + {data.content}
- ) + ); } diff --git a/src/app/(main)/character/[id]/chat/page.tsx b/src/app/(main)/chat/[id]/page.tsx similarity index 50% rename from src/app/(main)/character/[id]/chat/page.tsx rename to src/app/(main)/chat/[id]/page.tsx index bb911a1..219183f 100644 --- a/src/app/(main)/character/[id]/chat/page.tsx +++ b/src/app/(main)/chat/[id]/page.tsx @@ -3,12 +3,22 @@ import { IconButton } from '@/components/ui/button'; import Input from './Input'; import MessageList from './MessageList'; -import { useChatStore } from './store'; -import Sider from './Sider'; +import SettingDialog from './Sider'; +import { useStreamChatStore } from '@/stores/stream-chat'; +import { useParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; export default function ChatPage() { - const isSidebarOpen = useChatStore((store) => store.isSidebarOpen); - const setIsSidebarOpen = useChatStore((store) => store.setIsSidebarOpen); + const { id } = useParams(); + const [settingOpen, setSettingOpen] = useState(false); + const switchToChannel = useStreamChatStore((s) => s.switchToChannel); + const client = useStreamChatStore((s) => s.client); + + useEffect(() => { + if (id && client) { + switchToChannel(id as string); + } + }, [id, client]); return (
@@ -18,7 +28,7 @@ export default function ChatPage() {
setIsSidebarOpen(!isSidebarOpen)} + onClick={() => setSettingOpen(!settingOpen)} className="absolute top-1 right-1" variant="ghost" size="small" @@ -26,8 +36,7 @@ export default function ChatPage() {
- - {isSidebarOpen && } +
); } diff --git a/src/app/(main)/character/[id]/chat/store.ts b/src/app/(main)/chat/[id]/store.ts similarity index 66% rename from src/app/(main)/character/[id]/chat/store.ts rename to src/app/(main)/chat/[id]/store.ts index 46e8a63..240597f 100644 --- a/src/app/(main)/character/[id]/chat/store.ts +++ b/src/app/(main)/chat/[id]/store.ts @@ -1,4 +1,4 @@ -import { create } from 'zustand' +import { create } from 'zustand'; type SideBar = | 'profile' @@ -9,11 +9,12 @@ type SideBar = | 'max_token' | 'background' | 'model' + | 'language'; interface ChatStore { - isSidebarOpen: boolean - setIsSidebarOpen: (isSidebarOpen: boolean) => void - sideBar: SideBar - setSideBar: (sideBar: SideBar) => void + isSidebarOpen: boolean; + setIsSidebarOpen: (isSidebarOpen: boolean) => void; + sideBar: SideBar; + setSideBar: (sideBar: SideBar) => void; } export const useChatStore = create((set) => ({ @@ -21,4 +22,4 @@ export const useChatStore = create((set) => ({ setIsSidebarOpen: (isSidebarOpen: boolean) => set({ isSidebarOpen }), sideBar: 'profile', setSideBar: (sideBar: SideBar) => set({ sideBar }), -})) +})); diff --git a/src/app/(main)/crushcoin/components/CheckInGrid.tsx b/src/app/(main)/crushcoin/components/CheckInGrid.tsx index 3beefbf..4489007 100644 --- a/src/app/(main)/crushcoin/components/CheckInGrid.tsx +++ b/src/app/(main)/crushcoin/components/CheckInGrid.tsx @@ -1,52 +1,52 @@ -'use client' +'use client'; -import { useGetSevenDaysSignList, useSignIn } from '@/hooks/useHome' -import { SignInListOutput } from '@/services/home/types' -import { useQueryClient } from '@tanstack/react-query' -import { homeKeys } from '@/lib/query-keys' -import { CheckInCard } from './CheckInCard' -import { useEffect, useRef } from 'react' -import { toast } from 'sonner' +// import { useGetSevenDaysSignList, useSignIn } from '@/hooks/useHome' +// import { SignInListOutput } from '@/services/home/types' +import { useQueryClient } from '@tanstack/react-query'; +import { homeKeys } from '@/lib/query-keys'; +import { CheckInCard } from './CheckInCard'; +import { useEffect, useRef } from 'react'; +import { toast } from 'sonner'; export function CheckInGrid() { - const queryClient = useQueryClient() - const { data: signListData, isLoading } = useGetSevenDaysSignList() - const signInMutation = useSignIn() - const hasSignRef = useRef(false) + const queryClient = useQueryClient(); + // const { data: signListData, isLoading } = useGetSevenDaysSignList() + // const signInMutation = useSignIn() + const hasSignRef = useRef(false); useEffect(() => { const initializeCheckIn = async () => { - if (hasSignRef.current) return - hasSignRef.current = true + if (hasSignRef.current) return; + hasSignRef.current = true; try { // 先进行签到 - const resp = await signInMutation.mutateAsync() + const resp = await signInMutation.mutateAsync(); if (resp) { - toast.success('Check-in Successful!') + toast.success('Check-in Successful!'); } // 签到成功后再获取列表数据 await queryClient.invalidateQueries({ queryKey: homeKeys.getSevenDaysSignList(), - }) + }); await queryClient.invalidateQueries({ queryKey: ['wallet'], - }) + }); } catch (error) { - console.error('初始化签到失败:', error) + console.error('初始化签到失败:', error); // 即使签到失败,也要获取列表数据显示界面 queryClient.invalidateQueries({ queryKey: homeKeys.getSevenDaysSignList(), - }) + }); queryClient.invalidateQueries({ queryKey: ['wallet'], - }) + }); } - } + }; if (signListData) { - initializeCheckIn() + initializeCheckIn(); } - }, [signListData]) + }, [signListData]); if (isLoading) { return ( @@ -58,38 +58,38 @@ export function CheckInGrid() { /> ))}
- ) + ); } - const signList = signListData?.list || [] - const today = new Date() - const todayStr = today.toISOString().split('T')[0] // yyyy-MM-dd 格式 + const signList = signListData?.list || []; + const today = new Date(); + const todayStr = today.toISOString().split('T')[0]; // yyyy-MM-dd 格式 // 确保有7天的数据,如果不足则补充默认数据 const fullSignList: (SignInListOutput & { day: number })[] = Array.from( { length: 7 }, (_, index) => { - const day = index + 1 - const existingData = signList.find((item, itemIndex) => itemIndex === index) + const day = index + 1; + const existingData = signList.find((item, itemIndex) => itemIndex === index); return { day, coinNum: existingData?.coinNum || [5, 10, 15, 20, 30, 50, 80][index], dayStr: existingData?.dayStr || '', signIn: existingData?.signIn || false, - } + }; } - ) + ); // 找到今天应该签到的是第几天 const todayIndex = fullSignList.findIndex((item) => { - if (!item.dayStr) return false - return item.dayStr === todayStr - }) + if (!item.dayStr) return false; + return item.dayStr === todayStr; + }); // 如果没有找到今天的数据,假设是按顺序签到,找到第一个未签到的 const currentDayIndex = - todayIndex >= 0 ? todayIndex : fullSignList.findIndex((item) => !item.signIn) + todayIndex >= 0 ? todayIndex : fullSignList.findIndex((item) => !item.signIn); return (
@@ -105,7 +105,7 @@ export function CheckInGrid() { loading={signInMutation.isPending} className="col-span-1 row-span-2" /> - ) + ); } if (index < 3) { return ( @@ -118,7 +118,7 @@ export function CheckInGrid() { loading={signInMutation.isPending} className="col-span-1 row-span-1" /> - ) + ); } else { return ( - ) + ); } })}
- ) + ); } -export default CheckInGrid +export default CheckInGrid; diff --git a/src/app/(main)/home/components/Filter.tsx b/src/app/(main)/home/components/Filter.tsx index 7a3f720..9d6accf 100644 --- a/src/app/(main)/home/components/Filter.tsx +++ b/src/app/(main)/home/components/Filter.tsx @@ -6,13 +6,35 @@ import { Chip } from '@/components/ui/chip'; import { useHomeStore } from '../store'; import { useQuery } from '@tanstack/react-query'; import { fetchCharacterTags } from '@/services/editor'; +import { useRef } from 'react'; const Filter = () => { const tab = useHomeStore((state) => state.tab); const setTab = useHomeStore((state) => state.setTab); + const ref = useRef(null); const selectedTags = useHomeStore((state) => state.selectedTags); const setSelectedTags = useHomeStore((state) => state.setSelectedTags); + // useEffect(() => { + // const mainContent = document.getElementById('main-content'); + // if (!mainContent) { + // return; + // } + // const handleScroll = () => { + // const scrollTop = mainContent.scrollTop; + // console.log('scrollTop', scrollTop, ref.current); + // const className = 'absolute bg-bg-primary-normal'; + // if (scrollTop > 248) { + // ref.current?.classList.add('absolute bg-bg-primary-normal'); + // } else { + // } + // }; + // mainContent?.addEventListener('scroll', handleScroll); + // return () => { + // mainContent?.removeEventListener('scroll', handleScroll); + // }; + // }, []); + const { data: tags = [] } = useQuery({ queryKey: ['tags', tab], queryFn: async () => { @@ -39,8 +61,16 @@ const Filter = () => { }, ] as const; + const handleSelect = (tagId: string) => { + if (selectedTags.includes(tagId)) { + setSelectedTags(selectedTags.filter((id) => id !== tagId)); + } else { + setSelectedTags([...selectedTags, tagId]); + } + }; + return ( -
+
{tabs.map((item) => { const active = tab === item.value; @@ -71,7 +101,7 @@ const Filter = () => { size="small" className="px-4" state={selectedTags.includes(tag.id) ? 'active' : 'inactive'} - onClick={() => setSelectedTags([tag.id])} + onClick={() => handleSelect(tag.id)} > # {tag.name} diff --git a/src/app/(main)/home/components/Header.tsx b/src/app/(main)/home/components/Header.tsx index 4e286f6..665a6fd 100644 --- a/src/app/(main)/home/components/Header.tsx +++ b/src/app/(main)/home/components/Header.tsx @@ -8,61 +8,62 @@ import { useMedia } from '@/hooks/tools'; const Header = React.memo(() => { const response = useMedia(); + return ( -
-
- header-bg -

Spicyxx.ai

-

- A Different World -

- -
- Daily Free CrushCoins - - icon-crush-free + +
+
+ header-bg +
+
+ Check-in{' '} + header-bg +
+
+ + Daily Free crush coinsh + + +
- +
+ {response?.lg && ( + banner-header + )}
- {response?.sm && ( - banner-header - )} -
+ ); }); diff --git a/src/app/(main)/home/components/Story/index.tsx b/src/app/(main)/home/components/Story/index.tsx index cc4d885..91286bc 100644 --- a/src/app/(main)/home/components/Story/index.tsx +++ b/src/app/(main)/home/components/Story/index.tsx @@ -1,7 +1,7 @@ -'use client' +'use client'; const Story = () => { - return
Story
-} + return
Story
; +}; -export default Story +export default Story; diff --git a/src/app/(main)/home/page.tsx b/src/app/(main)/home/page.tsx index 03fc8a6..530c3b3 100644 --- a/src/app/(main)/home/page.tsx +++ b/src/app/(main)/home/page.tsx @@ -13,16 +13,16 @@ const HomePage = () => { const response = useMedia(); return ( -
-
-
+ <> +
+
{tab === 'story' ? : }
{response?.sm && } -
+ ); }; diff --git a/src/app/globals.css b/src/app/globals.css index 4ab72be..9594481 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -17,6 +17,25 @@ body { background: #888; } +.show-scrollbar { + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.3) transparent; +} + +.show-scrollbar::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.show-scrollbar::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.3); + border-radius: 999px; +} + +.show-scrollbar::-webkit-scrollbar-track { + background: transparent; +} + @utility text-gradient { background: linear-gradient( 135deg, diff --git a/src/atoms/chat.ts b/src/atoms/chat.ts deleted file mode 100644 index 13844c7..0000000 --- a/src/atoms/chat.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { atom } from 'jotai' - -/** - * 自动播放语音 - */ -export const playVoiceAtom = atom({ - voiceType: '', - dialogueSpeechRate: 0, - dialoguePitch: 0, - isAutoPlayVoice: false, -}) - -/** - * 抽屉状态类型 - */ -export interface DrawerState { - open: boolean - timestamp: number -} - -/** - * 创建抽屉打开状态的辅助函数 - */ -export const createDrawerOpenState = (open: boolean): DrawerState => ({ - open, - timestamp: Date.now(), -}) - -/** - * 是否打开发送礼物抽屉 - */ -export const isSendGiftsDrawerOpenAtom = atom({ open: false, timestamp: 0 }) - -/** - * 是否打开 crush level 抽屉 - */ -export const isCrushLevelDrawerOpenAtom = atom({ open: false, timestamp: 0 }) - -/** - * 是否打开 crush level 获取抽屉 - */ -export const isCrushLevelRetrieveDrawerOpenAtom = atom({ open: false, timestamp: 0 }) - -/** - * 是否打开 chat profile 抽屉 - */ -export const isChatProfileDrawerOpenAtom = atom({ open: false, timestamp: 0 }) - -/** - * 是否打开 chat profile 编辑抽屉 - */ -export const isChatProfileEditDrawerOpenAtom = atom({ open: false, timestamp: 0 }) - -/** - * 是否打开 chat model 抽屉 - */ -export const isChatModelDrawerOpenAtom = atom({ open: false, timestamp: 0 }) - -/** - * 是否打开 chat buttle 抽屉 - */ -export const isChatButtleDrawerOpenAtom = atom({ open: false, timestamp: 0 }) - -/** - * 是否打开 chat background 抽屉 - */ -export const isChatBackgroundDrawerOpenAtom = atom({ open: false, timestamp: 0 }) diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx index f2d0141..103f43c 100644 --- a/src/components/ui/alert-dialog.tsx +++ b/src/components/ui/alert-dialog.tsx @@ -1,28 +1,28 @@ -'use client' +'use client'; -import * as React from 'react' -import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog' +import * as React from 'react'; +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; -import { cn } from '@/lib/utils' -import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; // 关闭图标组件 const CloseIcon = ({ className }: { className?: string }) => ( -) +); function AlertDialog({ ...props }: React.ComponentProps) { - return + return ; } function AlertDialogTrigger({ ...props }: React.ComponentProps) { - return + return ; } function AlertDialogPortal({ ...props }: React.ComponentProps) { - return + return ; } function AlertDialogOverlay({ @@ -38,7 +38,7 @@ function AlertDialogOverlay({ )} {...props} /> - ) + ); } function AlertDialogContent({ @@ -47,8 +47,8 @@ function AlertDialogContent({ showOverlay = true, ...props }: React.ComponentProps & { - showCloseButton?: boolean - showOverlay?: boolean + showCloseButton?: boolean; + showOverlay?: boolean; }) { return ( @@ -71,7 +71,7 @@ function AlertDialogContent({ )} - ) + ); } function AlertDialogHeader({ className, ...props }: React.ComponentProps<'div'>) { @@ -81,7 +81,7 @@ function AlertDialogHeader({ className, ...props }: React.ComponentProps<'div'>) className={cn('mb-4 flex flex-col gap-4 text-left', className)} {...props} /> - ) + ); } function AlertDialogFooter({ @@ -89,7 +89,7 @@ function AlertDialogFooter({ variant = 'horizontal', ...props }: React.ComponentProps<'div'> & { - variant?: 'horizontal' | 'vertical' + variant?: 'horizontal' | 'vertical'; }) { return (
- ) + ); } function AlertDialogTitle({ @@ -109,14 +109,14 @@ function AlertDialogTitle({ ...props }: React.ComponentProps) { return ( -
+
- ) + ); } function AlertDialogDescription({ @@ -129,7 +129,7 @@ function AlertDialogDescription({ className={cn('txt-body-l text-txt-primary-normal w-full break-words', className)} {...props} /> - ) + ); } function AlertDialogIcon({ className, children, ...props }: React.ComponentProps<'div'>) { @@ -141,7 +141,7 @@ function AlertDialogIcon({ className, children, ...props }: React.ComponentProps >
{children}
- ) + ); } function AlertDialogAction({ @@ -150,14 +150,14 @@ function AlertDialogAction({ loading, ...props }: React.ComponentProps & { - variant?: 'primary' | 'secondary' | 'tertiary' | 'destructive' - loading?: boolean + variant?: 'primary' | 'secondary' | 'tertiary' | 'destructive'; + loading?: boolean; }) { return (
{/* 加载更多触发器 - 只在没有错误时显示 */} {hasNextPage && !hasError && ( -
+
{LoadingMore ? ( ) : ( diff --git a/src/components/ui/virtual-list.tsx b/src/components/ui/virtual-list.tsx index 1f9b64f..b8cf037 100644 --- a/src/components/ui/virtual-list.tsx +++ b/src/components/ui/virtual-list.tsx @@ -1,12 +1,12 @@ -'use client' -import { Virtuoso, VirtuosoHandle } from 'react-virtuoso' -import { useRef, useState } from 'react' +'use client'; +import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'; +import { useRef, useState } from 'react'; type VirtualListProps = { - data?: { type: string; data?: T }[] - itemContent?: (index: number, item: { type: string; data: T }) => React.ReactNode - virtuosoProps?: React.ComponentProps -} & React.HTMLAttributes + data?: { type: string; data?: T }[]; + itemContent?: (index: number, item: { type: string; data: T }) => React.ReactNode; + virtuosoProps?: React.ComponentProps; +} & React.HTMLAttributes; export default function VirtualList(props: VirtualListProps) { const { @@ -14,17 +14,17 @@ export default function VirtualList(props: VirtualListProps) { itemContent = (index) =>
{index}
, virtuosoProps, ...restProps - } = props - const virtuosoRef = useRef(null) - const [showScrollButton, setShowScrollButton] = useState(false) + } = props; + const virtuosoRef = useRef(null); + // const [showScrollButton, setShowScrollButton] = useState(false); - // 滚动到最新消息 - const scrollToBottom = () => { - virtuosoRef.current?.scrollToIndex({ - index: data.length - 1, - behavior: 'smooth', - }) - } + // // 滚动到最新消息 + // const scrollToBottom = () => { + // virtuosoRef.current?.scrollToIndex({ + // index: data.length - 1, + // behavior: 'smooth', + // }); + // }; return (
@@ -36,22 +36,22 @@ export default function VirtualList(props: VirtualListProps) { data={data} followOutput="smooth" initialTopMostItemIndex={data.length - 1} - atBottomStateChange={(atBottom) => { - // 当不在底部时显示按钮,在底部时隐藏 - setShowScrollButton(!atBottom) - }} + // atBottomStateChange={(atBottom) => { + // // 当不在底部时显示按钮,在底部时隐藏 + // setShowScrollButton(!atBottom); + // }} itemContent={(index, item) => itemContent(index, item as any)} /> {/* 回到底部按钮 */} - {showScrollButton && ( + {/* {showScrollButton && (
scroll
- )} + )} */}
- ) + ); } diff --git a/src/css/tailwindcss.css b/src/css/tailwindcss.css index 1c6f14e..e8e6f13 100644 --- a/src/css/tailwindcss.css +++ b/src/css/tailwindcss.css @@ -92,16 +92,16 @@ --glo-color-purple-70: #6e0098; --glo-color-purple-80: #520073; --glo-color-purple-90: #36004d; - --glo-color-magenta-0: #fbdeff; - --glo-color-magenta-10: #fdb6d3; - --glo-color-magenta-20: #f98dbc; - --glo-color-magenta-30: #f264a4; - --glo-color-magenta-40: rgb(107, 134, 255); - --glo-color-magenta-50: #d21f77; - --glo-color-magenta-60: #b80761; - --glo-color-magenta-70: #980050; - --glo-color-magenta-80: #73003e; - --glo-color-magenta-90: #4d002a; + --glo-color-magenta-0: #e5eaff; + --glo-color-magenta-10: #cbd5ff; + --glo-color-magenta-20: #b1c1ff; + --glo-color-magenta-30: #96acff; + --glo-color-magenta-40: #6b86ff; + --glo-color-magenta-50: #4861f5; + --glo-color-magenta-60: #3348dd; + --glo-color-magenta-70: #2536bf; + --glo-color-magenta-80: #1a2898; + --glo-color-magenta-90: #121d72; --glo-color-red-0: #ffdede; --glo-color-red-10: #ffbcbc; --glo-color-red-20: #ff9696; diff --git a/src/hooks/auth.ts b/src/hooks/auth.ts index 7f0a06f..b019152 100644 --- a/src/hooks/auth.ts +++ b/src/hooks/auth.ts @@ -20,7 +20,6 @@ export function useLogin() { return useMutation({ mutationFn: (data: LoginRequest): Promise => authService.login(data), onSuccess: (response: LoginResponse) => { - console.log('useLogin onSuccess save token', response.token); // 保存token到cookie tokenManager.setToken(response.token); // 刷新当前用户信息 diff --git a/src/hooks/services/character.ts b/src/hooks/services/character.ts new file mode 100644 index 0000000..3e3467f --- /dev/null +++ b/src/hooks/services/character.ts @@ -0,0 +1,10 @@ +import { fetchCharacter } from '@/services/editor'; +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +export function useCharacter(id?: string) { + return useQuery({ + queryKey: ['character', id], + queryFn: () => fetchCharacter({ id }), + enabled: !!id, + }); +} diff --git a/src/hooks/useInfiniteScroll.ts b/src/hooks/useInfiniteScroll.ts index 6ead2ae..68d949f 100644 --- a/src/hooks/useInfiniteScroll.ts +++ b/src/hooks/useInfiniteScroll.ts @@ -72,6 +72,7 @@ export function useInfiniteScroll({ observerRef.current = new IntersectionObserver((entries) => { const [entry] = entries; + if (entry.isIntersecting) { loadMore(); } @@ -87,7 +88,7 @@ export function useInfiniteScroll({ observerRef.current.unobserve(currentRef); } }; - }, [enabled, threshold, loadMore]); + }, [enabled, threshold, loadMore, !!loadMoreRef.current]); // 清理observer useEffect(() => { diff --git a/src/layout/BasicLayout.tsx b/src/layout/BasicLayout.tsx index e806ae8..b7ca733 100644 --- a/src/layout/BasicLayout.tsx +++ b/src/layout/BasicLayout.tsx @@ -11,20 +11,23 @@ import CreateReachedLimitDialog from '../components/features/create-reached-limi import { useMedia } from '@/hooks/tools'; import BottomBar from './BottomBar'; import { useStreamChatStore } from '@/stores/stream-chat'; -import { useLogin } from '@/hooks/auth'; +import { useCurrentUser } from '@/hooks/auth'; interface ConditionalLayoutProps { children: React.ReactNode; } const useInitChat = () => { - const { data } = useLogin(); + const { data } = useCurrentUser(); const connect = useStreamChatStore((state) => state.connect); const queryChannels = useStreamChatStore((state) => state.queryChannels); const initChat = async () => { if (data) { - await connect(data); + await connect({ + userId: data.userId + '', + userName: data.nickname, + }); await queryChannels({}); } }; @@ -58,7 +61,7 @@ export default function ConditionalLayout({ children }: ConditionalLayoutProps) {response?.sm && }
-
+
{children}
{response && !response.sm && } diff --git a/src/layout/Sidebar.tsx b/src/layout/Sidebar.tsx index 8c5d998..ee6b479 100644 --- a/src/layout/Sidebar.tsx +++ b/src/layout/Sidebar.tsx @@ -3,7 +3,7 @@ import { useEffect } from 'react'; import { MenuItem } from '@/types/global'; import Image from 'next/image'; import { cn } from '@/lib/utils'; -// import ChatSidebar from './components/ChatSidebar' +import ChatSidebar from './components/ChatSidebar'; import { Badge } from '../components/ui/badge'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; @@ -98,8 +98,11 @@ function Sidebar() { ); })}
- - {/* */} + {/* 分割线 */} +
+
+
+
diff --git a/src/layout/Topbar.tsx b/src/layout/Topbar.tsx index ff93681..d11389a 100644 --- a/src/layout/Topbar.tsx +++ b/src/layout/Topbar.tsx @@ -10,6 +10,8 @@ import { usePathname, useSearchParams, useRouter } from 'next/navigation'; import { useMedia } from '@/hooks/tools'; import { items } from './BottomBar'; +const mobileHidenMenus = ['/profile/edit', '/profile/account']; + function Topbar() { const [isBlur, setIsBlur] = useState(false); const { data: user } = useCurrentUser(); @@ -92,14 +94,13 @@ function Topbar() { ); }; + if (response && !response.sm && mobileHidenMenus.some((item) => item === pathname)) return null; + return (
{isBlur &&
}
diff --git a/src/layout/components/ChatSearchResults.tsx b/src/layout/components/ChatSearchResults.tsx index 947872a..b7e0908 100644 --- a/src/layout/components/ChatSearchResults.tsx +++ b/src/layout/components/ChatSearchResults.tsx @@ -1,228 +1,54 @@ -'use client' -import { useState, useMemo, useEffect } from 'react' -import { useAtomValue } from 'jotai' -import { conversationListAtom } from '@/atoms/im' -import { useInfiniteQuery } from '@tanstack/react-query' -import { imService } from '@/services/im' -import { imKeys } from '@/lib/query-keys' -import ChatSidebarItem from './ChatSidebarItem' -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' -import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' -import { InfiniteScrollList } from '@/components/ui/infinite-scroll-list' -import { getAge } from '@/lib/utils' -import { useRouter } from 'next/navigation' -import { HeartbeatRelationListOutput } from '@/services/im/types' -import Empty from '@/components/ui/empty' -import AIRelationTag from '@/components/features/AIRelationTag' -import Image from 'next/image' -import { IconButton } from '@/components/ui/button' -import { usePrefetchRoutes } from '@/hooks/useGlobalPrefetchRoutes' +'use client'; +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'; interface ChatSearchResultsProps { - searchKeyword: string - isExpanded: boolean - onCloseSearch: () => void + searchKeyword: string; + isExpanded: boolean; } -// 高亮搜索关键词的组件 -const HighlightText = ({ text, keyword }: { text: string; keyword: string }) => { - if (!keyword) return {text} - - const parts = text.split(new RegExp(`(${keyword})`, 'gi')) - return ( - - {parts.map((part, index) => - part.toLowerCase() === keyword.toLowerCase() ? ( - - {part} - - ) : ( - {part} - ) - )} - - ) -} - -// Person Tab中的关系列表项组件 -const PersonItem = ({ - person, - keyword, - onCloseSearch, -}: { - person: HeartbeatRelationListOutput - keyword: string - onCloseSearch: () => void -}) => { - const router = useRouter() - const chatHref = useMemo(() => (person.aiId ? `/chat/${person.aiId}` : null), [person.aiId]) - usePrefetchRoutes(chatHref ? [chatHref] : undefined) - - const handleClick = () => { - if (chatHref) { - router.push(chatHref) - onCloseSearch() - } - } - - return ( -
- {/* 头像 */} -
- - - - {(person.nickname || '').charAt(0)} - - - {/* 心动等级显示 */} -
- - {/* 用户信息 */} -
-
- - - - {person.heartbeatLevel && person.isShow && ( - - )} -
- - {/* 心动值和角色信息 */} -
- {/* 心动值 */} - {person.heartbeatVal !== undefined && ( - <> -
- Heart - {person.heartbeatVal}℃ -
- - {/* 分隔线 */} - {person.characterName &&
} - - )} - - {/* 角色信息 */} - {person.characterName && ( - - {[getAge(person.birthday as unknown as number), person.characterName, person.tagName] - .filter(Boolean) - .join(' · ')} - - )} -
-
- - -
- ) -} - -// Person列表加载骨架屏组件 -const PersonSkeleton = () => ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-) - -// Person空状态组件 -const PersonEmptyState = () => ( -
- -
-) - -const ChatSearchResults = ({ - searchKeyword, - isExpanded, - onCloseSearch, -}: ChatSearchResultsProps) => { - const conversationList = useAtomValue(conversationListAtom) - const [activeTab, setActiveTab] = useState('message') - const [debouncedSearchKeyword, setDebouncedSearchKeyword] = useState(searchKeyword) - - // 防抖处理搜索关键词,避免频繁调用API - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchKeyword(searchKeyword) - }, 500) // 500ms防抖延迟 - - return () => clearTimeout(timer) - }, [searchKeyword]) +const ChatSearchResults = ({ searchKeyword, isExpanded }: ChatSearchResultsProps) => { + const channels = useStreamChatStore((state) => state.channels); + const [activeTab, setActiveTab] = useState('message'); // 筛选Message搜索结果 - 对用户名或最后一条消息内容进行搜索 - const messageResults = useMemo(() => { - if (!searchKeyword) return [] + const { nameRes, messageRes } = useMemo(() => { + const nameRes: any[] = []; + const messageRes: any[] = []; + if (!searchKeyword) { + return { + nameRes, + messageRes, + }; + } - const conversations = Array.from(conversationList.values()) - return conversations.filter((conversation) => { - const { name } = conversation - const keyword = searchKeyword.toLowerCase() + channels.forEach((chanel) => { + const { name } = (chanel?.data as any) ?? {}; + const keyword = searchKeyword.toLowerCase(); // 搜索用户名 if (name?.toLowerCase().includes(keyword)) { - return true + nameRes.push(chanel); } + const messages = chanel?.state?.messages || []; + const lastMessage = messages[messages.length - 1]; + // 搜索最后一条消息内容 + if (lastMessage?.text?.toLowerCase().includes(keyword)) { + messageRes.push(chanel); + } + }); - // // 搜索最后一条消息内容 - // if (lastMessage?.text?.toLowerCase().includes(keyword)) { - // return true; - // } + return { + nameRes, + messageRes, + }; + }, [channels, searchKeyword]); - return false - }) - }, [conversationList, searchKeyword]) - - // 使用无限查询获取Person搜索结果 - const { - data: personData, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - isLoading: isPersonLoading, - } = useInfiniteQuery({ - queryKey: [...imKeys.heartbeatRelationList(debouncedSearchKeyword), 'infinite'], - queryFn: ({ pageParam = 1 }) => - imService.getHeartbeatRelationList({ - nickname: debouncedSearchKeyword, - page: { pn: pageParam, ps: 20 }, - }), - initialPageParam: 1, - getNextPageParam: (lastPage, allPages) => { - const currentPage = allPages.length - const totalPages = Math.ceil((lastPage.tc || 0) / 20) - return currentPage < totalPages ? currentPage + 1 : undefined - }, - enabled: !!debouncedSearchKeyword && activeTab === 'person', - }) - - const personResults = personData?.pages.flatMap((page) => page.datas || []) || [] - - // 判断是否正在等待防抖 - const isWaitingForDebounce = searchKeyword !== debouncedSearchKeyword - - // 如果没有搜索关键词,返回空状态 if (!searchKeyword) { - return
+ return
; } return ( @@ -232,25 +58,24 @@ const ChatSearchResults = ({ - Chats + Person
- My Crushes + Message
- {/* Message Tab 内容 */} - {messageResults.length > 0 ? ( - messageResults.map((conversation) => ( + {nameRes.length > 0 ? ( + nameRes.map((chanel: any) => ( - {/* Person Tab 内容 */} - {isPersonLoading || isWaitingForDebounce ? ( -
- {Array.from({ length: 6 }).map((_, index) => ( - - ))} -
+ {messageRes.length > 0 ? ( + messageRes.map((chanel: any) => ( + + )) ) : ( - - items={personResults} - renderItem={(person) => ( - - )} - getItemKey={(person) => person.aiId?.toString() || 'unknown'} - hasNextPage={!!hasNextPage} - isLoading={isPersonLoading || isFetchingNextPage} - fetchNextPage={fetchNextPage} - columns={1} - gap={1} - className="!grid-cols-1 space-y-1" // 强制单列布局 - LoadingSkeleton={PersonSkeleton} - EmptyComponent={PersonEmptyState} - hasError={false} - enabled={!!debouncedSearchKeyword} - /> +
+ +
)}
- ) -} + ); +}; -export default ChatSearchResults +export default ChatSearchResults; diff --git a/src/layout/components/ChatSidebar.tsx b/src/layout/components/ChatSidebar.tsx index 71aa469..fac8481 100644 --- a/src/layout/components/ChatSidebar.tsx +++ b/src/layout/components/ChatSidebar.tsx @@ -6,17 +6,15 @@ import { Input } from '@/components/ui/input'; import { useState, useEffect, useCallback } from 'react'; import { useStreamChatStore } from '@/stores/stream-chat'; import { useLayoutStore } from '@/stores'; +import { useParams } from 'next/navigation'; -const ChatSidebar = () => { +const ChatSidebar = ({ expand }: { expand?: boolean }) => { const isSidebarExpanded = useLayoutStore((s) => s.isSidebarExpanded); - const currentChannel = useStreamChatStore((state) => state.currentChannel); + const { id } = useParams<{ id: string }>(); const channels = useStreamChatStore((state) => state.channels); const [search, setSearch] = useState(''); const [inSearching, setIsSearching] = useState(false); - - const datas = Array.from(channels.values()).sort((a, b) => { - return false; - }); + // console.log('channels', channels); // 当侧边栏收缩时,取消搜索功能 useEffect(() => { @@ -34,111 +32,80 @@ const ChatSidebar = () => { // 如果有搜索关键词,显示搜索结果 const isShowingSearchResults = search.trim().length > 0; - if (!datas.length && !isShowingSearchResults) { + if (!channels.length && !isShowingSearchResults) { return
; } + const finalExpand = expand || isSidebarExpanded; + return ( - <> - {/* 分割线 */} -
-
+
+ {/* 聊天标题 */} +
+ Chats + {finalExpand && ( + setIsSearching(true)} + onCancelSearch={handleCloseSearch} + isSearchActive={inSearching} + /> + )}
-
- {/* 聊天标题 */} -
- {isSidebarExpanded ? ( - <> - Chats - setIsSearching(true)} - onCancelSearch={handleCloseSearch} - isSearchActive={inSearching} - /> - - ) : ( - Chats - )} -
- {/* 搜索框 - 根据设计稿实现 */} - {inSearching && isSidebarExpanded && ( -
-
- setSearch(e.target.value)} - placeholder="Search" - size="small" - autoFocus - maxLength={50} - prefixIcon={ - - } - className="rounded-full" - /> - {isShowingSearchResults && ( - setSearch('')} - size="mini" - variant="tertiary" - className="absolute top-1/2 right-3 shrink-0 -translate-y-1/2 transform" - > - - - )} -
- +
+ setSearch(e.target.value)} + placeholder="Search" size="small" - className="shrink-0" - > - - -
- )} - - {/* 根据搜索状态显示不同内容 */} - {inSearching ? ( - isShowingSearchResults ? ( - } + className="rounded-full" /> - ) : ( -
- ) + {isShowingSearchResults && ( + setSearch('')} + size="mini" + variant="tertiary" + className="absolute top-1/2 right-3 shrink-0 -translate-y-1/2 transform" + > + + + )} +
+ + + +
+ )} + + {/* 根据搜索状态显示不同内容 */} + {inSearching ? ( + isShowingSearchResults ? ( + ) : ( - <> - {/* 聊天项列表 */} -
-
-
- {datas.map((chat) => ( - - ))} -
- {/* 底部渐变遮罩 */} -
-
-
- - )} -
- +
+ ) + ) : ( +
+
+ {channels.map((chat) => ( + + ))} +
+
+ )} +
); }; diff --git a/src/layout/components/ChatSidebarItem.tsx b/src/layout/components/ChatSidebarItem.tsx index 5309bc7..47a938d 100644 --- a/src/layout/components/ChatSidebarItem.tsx +++ b/src/layout/components/ChatSidebarItem.tsx @@ -1,12 +1,9 @@ 'use client'; import { useMemo } from 'react'; -import AIRelationTag from '@/components/features/AIRelationTag'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { Badge } from '@/components/ui/badge'; -import { cn, durationText, getConversationTime } from '@/lib/utils'; -import { CustomMessageType } from '@/types/im'; -import Image from 'next/image'; +import { cn } from '@/lib/utils'; import { useRouter } from 'next/navigation'; +import { Channel } from 'stream-chat'; // 高亮搜索关键词的组件 const HighlightText = ({ text, keyword }: { text: string; keyword?: string }) => { @@ -30,7 +27,7 @@ const HighlightText = ({ text, keyword }: { text: string; keyword?: string }) => // 聊天项组件 export default function ChatSidebarItem({ - conversation, + chanel, isExpanded, isSelected = false, searchKeyword, @@ -38,29 +35,27 @@ export default function ChatSidebarItem({ isExpanded: boolean; isSelected?: boolean; searchKeyword?: string; + chanel: Channel; }) { - const { avatar, name, lastMessage, unreadCount, updateTime, serverExtension } = conversation; - const { text, attachment } = lastMessage || {}; + const { name, id, headPortrait } = (chanel?.data as any) ?? {}; const router = useRouter(); - const { heartbeatVal, heartbeatLevel, isShow } = JSON.parse(serverExtension || '{}') || {}; + const lastMessage = useMemo(() => { + const messages = chanel?.state?.messages || []; + if (!messages.length) return null; + return messages[messages.length - 1]; + }, [chanel]); const handleChat = () => { - router.push('/'); + router.push(`/chat/${id}`); }; const renderText = () => { - const { raw } = attachment || {}; - const customData = JSON.parse(raw || '{}'); - const { type, duration } = customData || {}; - if (type === CustomMessageType.CALL_CANCEL) { - return 'Call Canceled'; - } else if (type === CustomMessageType.CALL) { - return `Call duration ${durationText(duration)}`; - } else if (type == CustomMessageType.IMAGE) { - return '[Image]'; + if (!lastMessage) return ''; + if (searchKeyword && lastMessage.text?.includes(searchKeyword)) { + return ; } - return text; + return lastMessage.text || ''; }; return ( @@ -75,7 +70,7 @@ export default function ChatSidebarItem({
- + {name?.charAt(0)}
@@ -84,9 +79,9 @@ export default function ChatSidebarItem({
)} {/* 未读消息数量 */} - {!!unreadCount && unreadCount > 0 && ( + {/* {!!unreadCount && unreadCount > 0 && ( - )} + )} */}
{isExpanded && ( @@ -97,25 +92,28 @@ export default function ChatSidebarItem({
-
+ {/*
{heartbeatLevel && isShow && ( )} -
+
*/} +
+
+ {renderText()}
-
{renderText()}
- {getConversationTime(lastMessage?.messageRefer.createTime || updateTime)} + 16:27 + {/* {getConversationTime(lastMessage?.messageRefer.createTime || updateTime)} */}
- {!!heartbeatVal && heartbeatLevel && ( + {/* {!!heartbeatVal && heartbeatLevel && (
heart
{heartbeatVal}℃
- )} + )} */}
diff --git a/src/lib/client/index.ts b/src/lib/client/index.ts index 3e0c247..64296ca 100644 --- a/src/lib/client/index.ts +++ b/src/lib/client/index.ts @@ -1,3 +1,4 @@ -import createClient from './request' +import createClient from './request'; -export const editorRequest = createClient({ serviceName: 'editor' }) +export const editorRequest = createClient({ serviceName: 'editor' }); +export const chatRequest = createClient({ serviceName: 'chat' }); diff --git a/src/lib/client/request.ts b/src/lib/client/request.ts index f9fff4b..a71b75d 100644 --- a/src/lib/client/request.ts +++ b/src/lib/client/request.ts @@ -1,26 +1,27 @@ -import type { AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios' -import axios from 'axios' -import Cookies from 'js-cookie' -import { getToken, saveAuthInfo } from './auth' +import type { AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios'; +import axios from 'axios'; +import Cookies from 'js-cookie'; +import { getToken, saveAuthInfo } from './auth'; const endpoints = { editor: process.env.NEXT_PUBLIC_EDITOR_API_URL, -} + chat: process.env.NEXT_PUBLIC_CHAT_API_URL, +}; export default function createClient({ serviceName }: { serviceName: keyof typeof endpoints }) { - const baseURL = endpoints[serviceName] || '/' + const baseURL = endpoints[serviceName] || '/'; const instance = axios.create({ withCredentials: false, baseURL, validateStatus: (status) => { - return status >= 200 && status < 500 + return status >= 200 && status < 500; }, - }) + }); instance.interceptors.request.use(async (config: InternalAxiosRequestConfig) => { - const token = await getToken() + const token = await getToken(); if (token) { - config.headers.setAuthorization(`Bearer ${token}`) + config.headers.setAuthorization(`Bearer ${token}`); } // 从 cookie 中读取语言设置,并添加到请求头 @@ -33,8 +34,8 @@ export default function createClient({ serviceName }: { serviceName: keyof typeo // } // } - return config - }) + return config; + }); instance.interceptors.response.use( async (response: AxiosResponse): Promise => { @@ -57,12 +58,12 @@ export default function createClient({ serviceName }: { serviceName: keyof typeo // }); } - return response + return response; }, (error) => { - console.log('error', error) + console.log('error', error); if (axios.isCancel(error)) { - return Promise.resolve('请求取消') + return Promise.resolve('请求取消'); } // notification.error({ @@ -70,29 +71,29 @@ export default function createClient({ serviceName }: { serviceName: keyof typeo // description: error, // }); - return Promise.reject(error) + return Promise.reject(error); } - ) + ); type ResponseType = { - code: number - message: string - data: T - } + code: number; + message: string; + data: T; + }; return async function request( url: string, config?: AxiosRequestConfig ): Promise> { - let data: any + let data: any; if (config && config?.params) { - const { params } = config - data = Object.fromEntries(Object.entries(params).filter(([, value]) => value !== '')) + const { params } = config; + data = Object.fromEntries(Object.entries(params).filter(([, value]) => value !== '')); } const response = await instance>(url, { ...config, params: data, - }) - return response.data - } + }); + return response.data; + }; } diff --git a/src/services/editor/index.ts b/src/services/editor/index.ts index 3d574d6..d6b6627 100644 --- a/src/services/editor/index.ts +++ b/src/services/editor/index.ts @@ -1,4 +1,4 @@ -import { editorRequest } from '@/lib/client'; +import { chatRequest, editorRequest } from '@/lib/client'; export async function fetchCharacters({ index, limit, query }: any) { const { data } = await editorRequest('/api/character/list', { @@ -9,9 +9,31 @@ export async function fetchCharacters({ index, limit, query }: any) { } export async function fetchCharacter(params: any) { - return editorRequest('/api/character/detail', { method: 'POST', data: params }); + const { data } = await editorRequest('/api/character/detail', { method: 'POST', data: params }); + return data; } 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 }, + }); +} diff --git a/src/stores/stream-chat.ts b/src/stores/stream-chat.ts index 34b8234..1c04ae9 100644 --- a/src/stores/stream-chat.ts +++ b/src/stores/stream-chat.ts @@ -1,60 +1,138 @@ 'use client'; import { Channel, StreamChat } from 'stream-chat'; import { create } from 'zustand'; +import { getUserToken, createChannel } from '@/services/editor'; +import { parseSSEStream, parseData } from '@/utils/streamParser'; + +type Message = { + key: string; + role: string; + content: string; +}; interface StreamChatStore { + client: StreamChat | null; + user: { + userId: string; + userName: string; + }; + // 连接 StreamChat 客户端 connect: (user: any) => Promise; + // 频道 channels: Channel[]; currentChannel: Channel | null; + // 创建某个角色的聊天频道, 返回channelId + createChannel: (characterId: string) => Promise; switchToChannel: (id: string) => Promise; queryChannels: (filter: any) => Promise; deleteChannel: (id: string) => Promise; clearChannels: () => Promise; + getCurrentCharacter: () => any | null; + + // 消息列表 + messages: Message[]; + setMessages: (messages: Message[]) => void; + + // 发送消息 + sendMessage: (content: string) => Promise; + + // 清除通知 clearNotifications: () => Promise; } - -let client: StreamChat | null = null; export const useStreamChatStore = create((set, get) => ({ + client: null, + user: { + userId: '', + userName: '', + }, channels: [], + messages: [], + setMessages: (messages: any[]) => set({ messages }), currentChannel: null, + // 获取当前聊天频道中的角色id + getCurrentCharacter() { + const { currentChannel, user } = get(); + return ( + Object.values(currentChannel?.state?.members || {})?.find((i) => i.user?.id !== user?.userId) + ?.user?.id || null + ); + }, + // 创建某个角色的聊天频道 + async createChannel(characterId: string) { + const { user, client } = get(); + const { switchToChannel, queryChannels } = get(); + if (!client) { + return false; + } + const { data } = await createChannel({ + userId: user.userId, + userName: user.userName, + characterId, + }); + if (!data?.channelId) { + return false; + } + await queryChannels({}); + switchToChannel(data.channelId); + return data.channelId; + }, + async connect(user) { + const { client } = get(); + set({ user }); if (client) return; - console.log('connecting stream chat', user); const { data } = await getUserToken(user); - client = new StreamChat(process.env.NEXT_PUBLIC_STREAM_CHAT_API_KEY || ''); - await client.connectUser( + const streamClient = new StreamChat(process.env.NEXT_PUBLIC_STREAM_CHAT_API_KEY || ''); + const res = await streamClient.connectUser( { id: user.userId, name: user.userName, }, data ); + set({ client: streamClient }); }, + async switchToChannel(id: string) { - const { channels } = get(); - const channel = channels.find((ch) => ch.id === id); - if (channel) { - set({ currentChannel: channel }); - // 可选:监听该频道的消息 - await channel.watch(); - } else { - console.warn(`Channel with id ${id} not found in channels list`); - } + const { client, user } = get(); + const channel = client!.channel('messaging', id); + const result = await channel.query({ + messages: { limit: 100 }, + }); + const messages = result.messages.map((i) => ({ + key: i.id, + role: i.user?.id === user.userId ? 'user' : 'assistant', + content: i.text!, + })); + set({ currentChannel: channel, messages }); }, - async queryChannels(filter: any) { + + async queryChannels() { + const { user, client } = get(); if (!client) { console.error('StreamChat client is not connected'); return; } try { - const channels = await client.queryChannels(filter, { - last_message_at: -1, - }); + const channels = await client.queryChannels( + { + members: { + $in: [user.userId], + }, + }, + { + last_message_at: -1, + }, + { + message_limit: 1, // 返回最新的1条消息 + } + ); set({ channels }); } catch (error) { console.error('Failed to query channels:', error); } }, + async deleteChannel(id: string) { const { channels, currentChannel, queryChannels } = get(); const channel = channels.find((ch) => ch.id === id); @@ -65,11 +143,14 @@ export const useStreamChatStore = create((set, get) => ({ try { await channel.delete(); await queryChannels({}); - set({ currentChannel: null }); + if (currentChannel?.id === id) { + set({ currentChannel: null }); + } } catch (error) { console.error(`Failed to delete channel ${id}:`, error); } }, + async clearChannels() { const { channels } = get(); @@ -93,4 +174,48 @@ export const useStreamChatStore = create((set, get) => ({ } }, async clearNotifications() {}, + + // 发送消息 + sendMessage: async (content: any) => { + const { user, currentChannel, getCurrentCharacter, setMessages, messages } = get(); + // 过滤出用户和助手的消息 + const filteredMessages = messages.filter((i) => i.role === 'user' || i.role === 'assistant'); + let finalMessages = [ + ...filteredMessages, + { key: user.userId, role: 'user', content: content }, + { key: 'assistant', role: 'assistant', content: '' }, + ]; + setMessages(finalMessages); + + // 发送消息到服务器 + const response = await fetch( + `${process.env.NEXT_PUBLIC_CHAT_API_URL}/chat-api/chat/testPrompt`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + userId: user.userId, + channelId: currentChannel?.id || '', + message: content, + promptTemplateId: 'default', + characterId: getCurrentCharacter()?.id, + modelName: 'gpt-3.5-turbo', + }), + } + ); + + // 处理服务器返回的 SSE 流 + await parseSSEStream(response, (event: string, data: string) => { + if (event === 'chat-message') { + const d = parseData(data); + const lastMsg = finalMessages[finalMessages.length - 1]; + if (lastMsg.role === 'assistant') { + lastMsg.content = d.content || ''; + } + setMessages([...finalMessages]); + } + }); + }, })); diff --git a/src/utils/streamParser.ts b/src/utils/streamParser.ts new file mode 100644 index 0000000..5e19096 --- /dev/null +++ b/src/utils/streamParser.ts @@ -0,0 +1,76 @@ +export type SSEHandler = (event: string, data: string) => void; + +export const parseData = (data: string): any => { + try { + return JSON.parse(data); + } catch (error) { + return data; + } +}; + +/** + * 处理 SSE 流的通用函数 + * @param response fetch 返回的 Response 对象 + * @param onMessage 收到消息时的回调函数 + */ +export async function parseSSEStream(response: Response, onMessage: SSEHandler) { + if (!response.body) { + throw new Error('Response body is empty'); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + + // 1. 解码新收到的数据并追加到 buffer + if (value) { + buffer += decoder.decode(value, { stream: !done }); + } + + // 2. 按标准 SSE 分隔符 \n\n 切分消息 + const parts = buffer.split('\n\n'); + + // 3. 最后一个部分通常是不完整的,留到下一次处理 + // 但如果流已经结束(done=true),那么剩下的所有内容都必须强制处理 + buffer = parts.pop() || ''; + + if (done && buffer.trim()) { + parts.push(buffer); + buffer = ''; + } + + // 4. 解析切分出来的每一条完整消息 + for (const part of parts) { + if (!part.trim()) continue; + + const lines = part.split('\n'); + let event = ''; + let data = ''; + + for (const line of lines) { + if (line.startsWith('event:')) { + event = line.slice(6).trim(); + } else if (line.startsWith('data:')) { + const lineData = line.slice(5); + data = lineData; + } + } + + if (data) { + onMessage(event, data); + } + } + + if (done) break; + } + } catch (error) { + console.error('Stream parsing error:', error); + throw error; // 继续抛出,让调用者处理 + } finally { + reader.releaseLock(); + } +}