diff --git a/functions/api/chat.ts b/functions/api/chat.ts index 323ec4a..076fd47 100644 --- a/functions/api/chat.ts +++ b/functions/api/chat.ts @@ -25,7 +25,7 @@ export async function onRequestPost({ env, request }) { // 根据性格设置不同的系统提示语 let systemPrompt = ""; - systemPrompt = custom_prompt + "\n 注意重要:1、你在群里叫" + aiName + "认准自己的身份,你的输出内容不要加" + aiName + ":这种多余前缀;2、如果用户提出玩游戏,比如成语接龙等,严格按照游戏规则,不要说一大堆,要简短精炼; 3、不要重复别人的话!" + systemPrompt = custom_prompt + "\n 注意重要:1、你在群里叫" + aiName + "认准自己的身份; 2、你的输出内容不要加" + aiName + ":这种多余前缀;3、如果用户提出玩游戏,比如成语接龙等,严格按照游戏规则,不要说一大堆,要简短精炼; 4、不要重复别人的话!" // 构建完整的消息历史 const baseMessages = [ diff --git a/package-lock.json b/package-lock.json index d741afc..a791fab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-scroll-area": "^1.2.3", + "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.8", @@ -1303,9 +1304,31 @@ } } }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-separator/-/react-separator-1.1.2.tgz", + "integrity": "sha512-oZfHcaAp2Y6KFBX6I5P1u7CQoy4lheCGiYj+pGFrHy8E/VNRb5E39TkTr3JrV520csPBTZjkuKFdEsjS5EUNKQ==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" @@ -1350,7 +1373,7 @@ }, "node_modules/@radix-ui/react-tooltip": { "version": "1.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.8.tgz", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-tooltip/-/react-tooltip-1.1.8.tgz", "integrity": "sha512-YAA2cu48EkJZdAMHC0dqo9kialOcRStbtiY4nJPaht7Ptrhcvpo+eDChaM6BIs8kL6a8Z5l5poiqLnXcNduOkA==", "dependencies": { "@radix-ui/primitive": "1.1.1", @@ -2411,7 +2434,7 @@ }, "node_modules/class-variance-authority": { "version": "0.7.1", - "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "resolved": "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz", "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", "dependencies": { "clsx": "^2.1.1" @@ -3743,7 +3766,7 @@ }, "node_modules/lucide-react": { "version": "0.263.1", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.263.1.tgz", + "resolved": "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.263.1.tgz", "integrity": "sha512-keqxAx97PlaEN89PXZ6ki1N8nRjGWtDa4021GFYLNj0RgruM5odbpl8GHTExj0hhPq3sF6Up0gnxt6TSHu+ovw==", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0" diff --git a/package.json b/package.json index 6f58ab7..67f5c97 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-scroll-area": "^1.2.3", + "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.8", diff --git a/src/components/ChatUI.tsx b/src/components/ChatUI.tsx index a427fa7..56dea00 100644 --- a/src/components/ChatUI.tsx +++ b/src/components/ChatUI.tsx @@ -1,5 +1,5 @@ import React, { useState, useRef, useEffect } from 'react'; -import { Send, Menu, MoreHorizontal, UserPlus, UserMinus, Users2, Users, MoreVertical, Share2, Mic, MicOff, Settings2 } from 'lucide-react'; +import { Send, Share2, Settings2, ChevronLeft } from 'lucide-react'; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -25,9 +25,9 @@ import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm' import remarkMath from 'remark-math' import rehypeKatex from 'rehype-katex' -import html2canvas from 'html2canvas'; import { SharePoster } from '@/components/SharePoster'; import { MembersManagement } from '@/components/MembersManagement'; +import Sidebar from './Sidebar'; // 使用本地头像数据,避免外部依赖 const getAvatarData = (name: string) => { @@ -146,9 +146,15 @@ const KaTeXStyle = () => ( ); const ChatUI = () => { - const [group, setGroup] = useState(groups[1]); - const [isGroupDiscussionMode, setIsGroupDiscussionMode] = useState(false); - const groupAiCharacters = generateAICharacters(group.name).filter(character => group.members.includes(character.id)); + // 使用当前选中的群组在 groups 数组中的索引 + const [selectedGroupIndex, setSelectedGroupIndex] = useState(0); // 默认选中第1个群组 + const [group, setGroup] = useState(groups[selectedGroupIndex]); + const [isGroupDiscussionMode, setIsGroupDiscussionMode] = useState(group.isGroupDiscussionMode); + const groupAiCharacters = generateAICharacters(group.name) + .filter(character => group.members.includes(character.id)) + .sort((a, b) => { + return group.members.indexOf(a.id) - group.members.indexOf(b.id); + }); const [users, setUsers] = useState([ { id: 1, name: "我" }, ...groupAiCharacters @@ -258,7 +264,7 @@ const ChatUI = () => { history: messageHistory, index: i, aiName: selectedGroupAiCharacters[i].name, - custom_prompt: selectedGroupAiCharacters[i].custom_prompt + custom_prompt: selectedGroupAiCharacters[i].custom_prompt + "\n" + group.description }), }); @@ -377,220 +383,240 @@ const ChatUI = () => { setShowPoster(true); }; + const [sidebarOpen, setSidebarOpen] = useState(false); + + // 切换侧边栏状态的函数 + const toggleSidebar = () => { + setSidebarOpen(!sidebarOpen); + }; + + // 处理群组选择 + const handleSelectGroup = (index: number) => { + setSelectedGroupIndex(index); + const newGroup = groups[index]; + setGroup(newGroup); + + // 重新生成当前群组的 AI 角色,并按照 members 数组的顺序排序 + const newGroupAiCharacters = generateAICharacters(newGroup.name) + .filter(character => newGroup.members.includes(character.id)) + .sort((a, b) => { + return newGroup.members.indexOf(a.id) - newGroup.members.indexOf(b.id); + }); + + // 更新用户列表 + setUsers([ + { id: 1, name: "我" }, + ...newGroupAiCharacters + ]); + setIsGroupDiscussionMode(newGroup.isGroupDiscussionMode); + + // 重置消息 + setMessages([]); + + // 可选:关闭侧边栏(在移动设备上) + if (window.innerWidth < 768) { + setSidebarOpen(false); + } + }; + return ( <>
-
- {/* Header */} -
-
- {/* 左侧群组信息 */} -
-
-
- {users.length === 1 ? ( - - ) : users.length === 2 ? ( -
- {users.slice(0, 2).map((user, index) => ( - - ))} -
- ) : users.length === 3 ? ( -
-
- {users.slice(0, 2).map((user, index) => ( - - ))} -
-
- -
-
- ) : ( -
- {users.slice(0, 4).map((user, index) => ( - - ))} +
+ {/* 传递 selectedGroupIndex 和 onSelectGroup 回调给 Sidebar */} + + + {/* 聊天主界面 */} +
+ {/* Header */} +
+
+ {/* 左侧群组信息 */} +
+
+ +
+ +

{group.name}({users.length})

+
+ + {/* 右侧头像组和按钮 */} +
+
+ {users.slice(0, 4).map((user) => { + const avatarData = getAvatarData(user.name); + return ( + + + + + {'avatar' in user && user.avatar ? ( + + ) : ( + + {avatarData.text} + + )} + + + +

{user.name}

+
+
+
+ ); + })} + {users.length > 4 && ( +
+ +{users.length - 4}
)}
-
-
-
-

{group.name}

-

{users.length} 名成员

+
- - {/* 右侧头像组和按钮 */} -
-
- {users.slice(0, 4).map((user) => { - const avatarData = getAvatarData(user.name); - return ( - - - - - {'avatar' in user && user.avatar ? ( - - ) : ( - - {avatarData.text} - - )} - - - -

{user.name}

-
-
-
- ); - })} - {users.length > 4 && ( -
- +{users.length - 4} +
+ + {/* Main Chat Area */} +
+ +
+ {messages.map((message) => ( +
+ {message.sender.name !== "我" && ( + + {'avatar' in message.sender && message.sender.avatar ? ( + + ) : ( + + {message.sender.name[0]} + + )} + + )} +
+
{message.sender.name}
+
+ + {message.content} + + {message.isAI && isTyping && currentMessageRef.current === message.id && ( + + )} +
+
+ {message.sender.name === "我" && ( + + {'avatar' in message.sender && message.sender.avatar ? ( + + ) : ( + + {message.sender.name[0]} + + )} + + )}
- )} + ))} +
+ {/* 添加一个二维码 */} +
+ QR Code +

扫码体验AI群聊

+
-
+ + {/* Input Area */} +
+
+ {messages.length > 0 && ( + + + + + + +

分享聊天记录

+
+
+
+ )} + setInputMessage(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleSendMessage()} + /> +
-
- - {/* Main Chat Area */} -
- -
- {messages.map((message) => ( -
- {message.sender.name !== "我" && ( - - {'avatar' in message.sender && message.sender.avatar ? ( - - ) : ( - - {message.sender.name[0]} - - )} - - )} -
-
{message.sender.name}
-
- - {message.content} - - {message.isAI && isTyping && currentMessageRef.current === message.id && ( - - )} -
-
- {message.sender.name === "我" && ( - - {'avatar' in message.sender && message.sender.avatar ? ( - - ) : ( - - {message.sender.name[0]} - - )} - - )} -
- ))} -
- {/* 添加一个二维码 */} -
- QR Code -

扫码体验AI群聊

-
-
- -
- - {/* Input Area */} -
-
- {messages.length > 0 && ( - - - - - - -

分享聊天记录

-
-
-
- )} - setInputMessage(e.target.value)} - onKeyPress={(e) => e.key === 'Enter' && handleSendMessage()} - /> - -
diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index e714782..c417600 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -4,10 +4,11 @@ import Header from './Header'; const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => { return (
-
-
- {children} -
+
+
+ {children} +
+
); }; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx new file mode 100644 index 0000000..b960def --- /dev/null +++ b/src/components/Sidebar.tsx @@ -0,0 +1,166 @@ +import React from 'react'; +import { Button } from "@/components/ui/button"; +import { MessageSquareIcon, PlusCircleIcon, MenuIcon, PanelLeftCloseIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import GitHubButton from 'react-github-btn'; +import '@fontsource/audiowide'; +import { groups } from "@/config/groups"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +// 根据群组ID生成固定的随机颜色 +const getRandomColor = (index: number) => { + const colors = ['blue', 'green', 'yellow', 'purple', 'pink', 'indigo', 'red', 'orange', 'teal']; + //增加hash + const hashCode = index.toString().split('').reduce((acc, char) => { + return char.charCodeAt(0) + ((acc << 5) - acc); + }, 0); + return colors[hashCode % colors.length]; +}; + +interface SidebarProps { + isOpen: boolean; + toggleSidebar: () => void; + selectedGroupIndex?: number; + onSelectGroup?: (index: number) => void; +} + +const Sidebar = ({ isOpen, toggleSidebar, selectedGroupIndex = 0, onSelectGroup }: SidebarProps) => { + + return ( + <> + {/* 侧边栏 - 在移动设备上可以隐藏,在桌面上始终显示 */} +
+
+
+
+ + 群列表 + + +
+
+ + + + {/* GitHub Star Button - 只在侧边栏打开时显示,放在底部 */} +
+ {/* 标题移至底部 */} + + + {isOpen && ( +
+ + Star + +
+ )} +
+
+
+ + {/* 移动设备上的遮罩层,点击时关闭侧边栏 */} + {isOpen && ( +
+ )} + + ); +}; + +export default Sidebar; \ No newline at end of file diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx new file mode 100644 index 0000000..6d7f122 --- /dev/null +++ b/src/components/ui/separator.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@/lib/utils" + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref + ) => ( + + ) +) +Separator.displayName = SeparatorPrimitive.Root.displayName + +export { Separator } diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx new file mode 100644 index 0000000..dcf03e0 --- /dev/null +++ b/src/components/ui/sidebar.tsx @@ -0,0 +1,761 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { VariantProps, cva } from "class-variance-authority" +import { PanelLeft } from "lucide-react" + +import { useIsMobile } from "@/hooks/use-mobile" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Separator } from "@/components/ui/separator" +import { Sheet, SheetContent } from "@/components/ui/sheet" +import { Skeleton } from "@/components/ui/skeleton" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" + +const SIDEBAR_COOKIE_NAME = "sidebar_state" +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_WIDTH = "16rem" +const SIDEBAR_WIDTH_MOBILE = "18rem" +const SIDEBAR_WIDTH_ICON = "3rem" +const SIDEBAR_KEYBOARD_SHORTCUT = "b" + +type SidebarContext = { + state: "expanded" | "collapsed" + open: boolean + setOpen: (open: boolean) => void + openMobile: boolean + setOpenMobile: (open: boolean) => void + isMobile: boolean + toggleSidebar: () => void +} + +const SidebarContext = React.createContext(null) + +function useSidebar() { + const context = React.useContext(SidebarContext) + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider.") + } + + return context +} + +const SidebarProvider = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void + } +>( + ( + { + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props + }, + ref + ) => { + const isMobile = useIsMobile() + const [openMobile, setOpenMobile] = React.useState(false) + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen) + const open = openProp ?? _open + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value + if (setOpenProp) { + setOpenProp(openState) + } else { + _setOpen(openState) + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + }, + [setOpenProp, open] + ) + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile + ? setOpenMobile((open) => !open) + : setOpen((open) => !open) + }, [isMobile, setOpen, setOpenMobile]) + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === SIDEBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault() + toggleSidebar() + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [toggleSidebar]) + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed" + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ) + + return ( + + +
+ {children} +
+
+
+ ) + } +) +SidebarProvider.displayName = "SidebarProvider" + +const Sidebar = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + side?: "left" | "right" + variant?: "sidebar" | "floating" | "inset" + collapsible?: "offcanvas" | "icon" | "none" + } +>( + ( + { + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + ...props + }, + ref + ) => { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + + if (collapsible === "none") { + return ( +
+ {children} +
+ ) + } + + if (isMobile) { + return ( + + +
{children}
+
+
+ ) + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ) + } +) +Sidebar.displayName = "Sidebar" + +const SidebarTrigger = React.forwardRef< + React.ElementRef, + React.ComponentProps +>(({ className, onClick, ...props }, ref) => { + const { toggleSidebar } = useSidebar() + + return ( + + ) +}) +SidebarTrigger.displayName = "SidebarTrigger" + +const SidebarRail = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> +>(({ className, ...props }, ref) => { + const { toggleSidebar } = useSidebar() + + return ( +