feat: shared chat

This commit is contained in:
BetaHuhn 2023-09-05 14:58:54 +02:00
parent 2ffee3bf64
commit 4976b4648c
16 changed files with 1485 additions and 52 deletions

6
.gitignore vendored
View file

@ -1,4 +1,4 @@
/node_modules
/.parcel-cache
/dist
node_modules
.parcel-cache
dist
notes.txt

View file

@ -10,3 +10,14 @@ micros:
commands:
- NODE_OPTIONS="--max-old-space-size=1024" npm run build
dev: npm run dev
public_routes:
- "/shared/*"
- "/assets/*"
- name: backend
src: ./backend
path: /api
engine: nodejs16
dev: npm run dev
public_routes:
- "/public/*"

38
backend/index.js Normal file
View file

@ -0,0 +1,38 @@
import express from 'express'
import { Deta } from 'deta'
const app = express()
const deta = Deta()
const chats = deta.Base('chats')
const messages = deta.Base('messages')
app.use(express.json())
app.get('/public/chats/:key', async (req, res) => {
const key = req.params.key
const chat = await chats.get(key)
if (!chat || !chat.shared) {
return res.status(404).json({ message: 'Chat not found' })
}
res.json(chat)
})
app.get('/public/chats/:key/messages', async (req, res) => {
const key = req.params.key
const chat = await chats.get(key)
if (!chat || !chat.shared) {
return res.status(404).json({ message: 'Chat not found' })
}
const items = await messages.fetch({ chatId: key }, { desc: false })
res.json(items)
})
const PORT = process.env.PORT || 3000
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`)
})

1118
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

18
backend/package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "backend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"type": "module",
"scripts": {
"dev": "node --watch index.js",
"start": "node index.js"
},
"keywords": [],
"author": "",
"license": "MIT",
"dependencies": {
"deta": "^2.0.0",
"express": "^4.18.2"
}
}

View file

@ -11,7 +11,7 @@
name="viewport"
content="width=device-width, height=device-height, initial-scale=1.0, user-scalable=0, minimum-scale=1.0, maximum-scale=1.0, interactive-widget=resizes-content"
/>
<link rel="icon" href="./favicon.png" />
<link rel="icon" href="/assets/favicon.png" />
</head>
<body>
<div id="root"></div>

View file

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

View file

@ -7,12 +7,15 @@ import { useHotkeys, useLocalStorage } from "@mantine/hooks";
import { Notifications } from "@mantine/notifications";
import {
createBrowserHistory,
Outlet,
ReactLocation,
Router,
} from "@tanstack/react-location";
import { ChatRoute } from "../routes/ChatRoute";
import { PublicChatRoute } from "../routes/PublicChatRoute";
import { IndexRoute } from "../routes/IndexRoute";
import { Layout } from "./Layout";
import { BaseLayout } from "../layouts/Base";
import { PublicLayout } from "../layouts/Public";
const history = createBrowserHistory();
const location = new ReactLocation({ history });
@ -35,8 +38,9 @@ export function App() {
<Router
location={location}
routes={[
{ path: "/", element: <IndexRoute /> },
{ path: "/chats/:chatId", element: <ChatRoute /> },
{ id: "root", path: "/", element: <BaseLayout><IndexRoute /></BaseLayout>},
{ id: "chat", path: "/chats/:chatId", element: <BaseLayout><ChatRoute /></BaseLayout> },
{ id: "public_chat", path: "/shared/chats/:chatId", element: <PublicLayout><PublicChatRoute /></PublicLayout> },
]}
>
<ColorSchemeProvider
@ -108,7 +112,9 @@ export function App() {
},
}}
>
<Layout />
{/* <BaseLayout /> */}
{/* <PublicLayout /> */}
<Outlet />
<Notifications position="bottom-right" style={{ marginBottom: '4rem' }} />
</MantineProvider>
</ColorSchemeProvider>

View file

@ -1,15 +1,19 @@
import { ActionIcon, Box, Header, TextInput, Tooltip } from "@mantine/core";
import { IconAdjustments } from "@tabler/icons-react";
import { ActionIcon, Box, Flex, Header, TextInput, Tooltip, useMantineTheme } from "@mantine/core";
import { IconAdjustments, IconInfoCircle } from "@tabler/icons-react";
import { EditChatModal } from "./EditChatModal";
import { Chat, detaDB } from "../db";
import { useEffect, useRef, useState } from "react";
import { useChat, useChats } from "../hooks/contexts";
import { useDebouncedValue } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import { ShareChatModal } from "./ShareChatModal";
import { LogoText } from "./Logo";
import { Link } from "@tanstack/react-location";
export function ChatHeader() {
export function ChatHeader({ readOnly = false }: { readOnly?: boolean}) {
const [title, setTitle] = useState('')
const [debounced] = useDebouncedValue(title, 800);
const theme = useMantineTheme()
const { chat, setChat } = useChat()
const { setChats } = useChats()
@ -52,7 +56,19 @@ export function ChatHeader() {
return (
<Header height={60} p="xs" className="app-region-drag">
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div></div>
<div>
{readOnly && (
<Link to="/" style={{ display: 'block' }}>
<LogoText
style={{
height: 22,
color: theme.colors[theme.primaryColor][6],
display: "block",
}}
/>
</Link>
)}
</div>
<TextInput
id="chat-name"
@ -65,23 +81,40 @@ export function ChatHeader() {
autoComplete="off"
data-lpignore="true"
data-form-type="other"
readOnly={readOnly}
/>
<EditChatModal chat={chat!}>
<Tooltip label="Chat Settings">
<ActionIcon
size="xl"
sx={(theme) => ({
[theme.fn.smallerThan('md')]: {
marginRight: '2.5rem',
paddingBottom: 3,
},
})}
>
<IconAdjustments size={20} />
</ActionIcon>
</Tooltip>
</EditChatModal>
<Flex
align="center"
sx={(theme) => ({
[theme.fn.smallerThan('md')]: {
marginRight: readOnly ? '' : '2.5rem',
paddingBottom: 3,
},
})}
>
<ShareChatModal chat={chat!} />
{!readOnly ? (
<>
<EditChatModal chat={chat!}>
<Tooltip label="Chat Settings">
<ActionIcon size="xl">
<IconAdjustments size={20} />
</ActionIcon>
</Tooltip>
</EditChatModal>
</>
) : (
<Tooltip label="Chat Settings">
<a href="https://deta.space/discovery" target="_blank">
<ActionIcon size="xl">
<IconInfoCircle size={20} />
</ActionIcon>
</a>
</Tooltip>
)}
</Flex>
</Box>
</Header>
)

View file

@ -23,7 +23,7 @@ import { CreatePromptModal } from "./CreatePromptModal";
import { LogoIcon } from "./Logo";
import { notifications } from "@mantine/notifications";
export function MessageItem({ message, onDeleted, handleUseMessage }: { message: Message, onDeleted: (key: string) => void, handleUseMessage: (key: string) => void }) {
export function MessageItem({ message, readOnly = false, onDeleted, handleUseMessage }: { message: Message, readOnly?: boolean, onDeleted?: (key: string) => void, handleUseMessage?: (key: string) => void }) {
const wordCount = useMemo(() => {
var matches = message.content.match(/[\w\d\\'-\(\)]+/gi);
return matches ? matches.length : 0;
@ -36,7 +36,7 @@ export function MessageItem({ message, onDeleted, handleUseMessage }: { message:
const handleDelete = async () => {
await detaDB.messages.delete(message.key);
onDeleted(message.key)
if (onDeleted) onDeleted(message.key)
setExpanded(false);
@ -101,7 +101,7 @@ export function MessageItem({ message, onDeleted, handleUseMessage }: { message:
/>
</Box>
</Flex>
<Collapse in={isExpanded}>
<Box style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 10 }}>
<Box>
@ -111,12 +111,17 @@ export function MessageItem({ message, onDeleted, handleUseMessage }: { message:
</Box>
<Box style={{ display: 'flex' }}>
<CreatePromptModal content={message.content || ''} />
<Tooltip label="Re-use Message" position="top">
<ActionIcon onClick={() => handleUseMessage(message.content)}>
<IconArrowForward opacity={0.5} size={20} />
</ActionIcon>
</Tooltip>
{!readOnly && handleUseMessage && (
<>
<CreatePromptModal content={message.content || ''} />
<Tooltip label="Re-use Message" position="top">
<ActionIcon onClick={() => handleUseMessage(message.content)}>
<IconArrowForward opacity={0.5} size={20} />
</ActionIcon>
</Tooltip>
</>
)}
<CopyButton value={message.content}>
{({ copied, copy }) => (
<Tooltip label={copied ? "Copied Message" : "Copy Message"} position="top">
@ -126,11 +131,14 @@ export function MessageItem({ message, onDeleted, handleUseMessage }: { message:
</Tooltip>
)}
</CopyButton>
<Tooltip label="Delete Message" position="top">
<ActionIcon onClick={handleDelete}>
<IconTrash opacity={0.5} size={20} />
</ActionIcon>
</Tooltip>
{!readOnly && (
<Tooltip label="Delete Message" position="top">
<ActionIcon onClick={handleDelete}>
<IconTrash opacity={0.5} size={20} />
</ActionIcon>
</Tooltip>
)}
</Box>
</Box>
</Collapse>

View file

@ -0,0 +1,65 @@
import { ActionIcon, Button, Input, Modal, Stack, Text, Tooltip } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import { IconShare2 } from "@tabler/icons-react";
import { useState } from "react";
import { Chat, detaDB } from "../db";
import { useChat } from "../hooks/contexts";
export function ShareChatModal({ chat }: { chat: Chat }) {
const [opened, { open, close }] = useDisclosure(false);
const [submitting, setSubmitting] = useState(false);
const [url, setUrl] = useState<string | null>(null);
const { setChat } = useChat()
const handleClick = async () => {
if (!chat.shared) {
setSubmitting(true)
await detaDB.chats.update({ shared: true }, chat.key)
setChat(current => ({ ...(current as unknown as Chat), shared: true }))
setSubmitting(false)
notifications.show({
title: "Shared!",
color: "green",
message: "Chat is now public.",
})
}
setUrl(`${window.location.origin}/shared/chats/${chat.key}`)
open()
}
const handleCopy = () => {
navigator.clipboard.writeText(url ?? '')
notifications.show({
title: "Copied!",
color: "green",
message: "URL copied to clipboard.",
})
close()
}
return (
<>
<Modal opened={opened} onClose={close} title="Share Chat" size="md">
<Stack>
<Text size="sm">Use this URL to share this chat with others:</Text>
<Input readOnly value={url ?? ''} />
<Button type="submit" onClick={handleCopy}>
Copy URL
</Button>
</Stack>
</Modal>
<Tooltip label="Share Chat">
<ActionIcon size="xl" onClick={handleClick} loading={submitting} loaderProps={{ size: 20 }}>
<IconShare2 size={20} />
</ActionIcon>
</Tooltip>
</>
);
}

View file

@ -14,6 +14,7 @@ export interface Chat {
writingFormat?: string | null;
model?: string | null;
private?: boolean;
shared?: boolean;
createdAt: string;
}

View file

@ -5,3 +5,9 @@ export function useChatId() {
const match = matchRoute({ to: "/chats/:chatId" });
return match?.chatId;
}
export function usePublicChatId() {
const matchRoute = useMatchRoute();
const match = matchRoute({ to: "/shared/chats/:chatId" });
return match?.chatId;
}

View file

@ -21,27 +21,29 @@ import {
IconSpyOff,
IconX,
} from "@tabler/icons-react";
import { Link, Outlet, useNavigate, useRouter } from "@tanstack/react-location";
import { Link, useNavigate, useRouter } from "@tanstack/react-location";
import { useEffect, useState } from "react";
import { Chat, detaDB, Prompt, Settings } from "../db";
import { useChatId } from "../hooks/useChatId";
import { Chats } from "./Chats";
import { CreatePromptModal } from "./CreatePromptModal";
import { LogoText } from "./Logo";
import { Prompts } from "./Prompts";
import { SettingsModal } from "./SettingsModal";
import { Chats } from "../components/Chats";
import { CreatePromptModal } from "../components/CreatePromptModal";
import { LogoText } from "../components/Logo";
import { Prompts } from "../components/Prompts";
import { SettingsModal } from "../components/SettingsModal";
import { config } from "../utils/config";
import { ChatContext, ChatsContext, IncognitoModeContext, PromptsContext, SettingsContext } from "../hooks/contexts";
import { ChatHeader } from "./ChatHeader";
import { ChatHeader } from "../components/ChatHeader";
import { useLocalStorage } from "@mantine/hooks";
import { CreateChatButton } from "./CreateChatButton";
import { DeleteChatsModal } from "./DeleteChatsModal";
import { CreateChatButton } from "../components/CreateChatButton";
import { DeleteChatsModal } from "../components/DeleteChatsModal";
export function Layout() {
export function BaseLayout({ children }: { children: React.ReactNode }) {
const theme = useMantineTheme();
const navigate = useNavigate();
const router = useRouter();
console.log('route', router)
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
const chatId = useChatId();
@ -312,7 +314,7 @@ export function Layout() {
sx={{ position: "fixed", top: 16, right: 16, zIndex: 100 }}
/>
</MediaQuery>
<Outlet />
{children}
</AppShell>
</IncognitoModeContext.Provider>
</PromptsContext.Provider>

74
src/layouts/Public.tsx Normal file
View file

@ -0,0 +1,74 @@
import {
AppShell,
Burger,
MediaQuery,
useMantineColorScheme,
useMantineTheme,
} from "@mantine/core";
import { useRouter } from "@tanstack/react-location";
import { useEffect, useState } from "react";
import { Chat } from "../db";
import { usePublicChatId } from "../hooks/useChatId";
import { ChatContext, IncognitoModeContext } from "../hooks/contexts";
import { ChatHeader } from "../components/ChatHeader";
import { useLocalStorage } from "@mantine/hooks";
export function PublicLayout({ children }: { children: React.ReactNode }) {
const theme = useMantineTheme();
const router = useRouter();
const { colorScheme } = useMantineColorScheme();
const chatId = usePublicChatId();
const [opened, setOpened] = useState(false);
const [incognitoMode, setIncognitoMode] = useLocalStorage({
key: 'incognito-mode', defaultValue: false, getInitialValueInEffect: false
});
const [chat, setChat] = useState<Chat | null>(null);
useEffect(() => {
const dataFetch = async () => {
try {
const res = await fetch(`/api/public/chats/${chatId}`)
const item = await res.json()
const fetchedChat = item as unknown as Chat
setChat(fetchedChat);
document.title = fetchedChat.description ? `${fetchedChat.description} | Chatpad AI` : 'Chatpad AI'
} catch (e) {
console.error(e)
}
};
if (chatId) {
dataFetch();
} else {
setChat(null);
}
}, [chatId]);
useEffect(() => {
setOpened(false);
}, [router.state.location]);
return (
<ChatContext.Provider value={{ chat: chat, setChat: setChat }}>
<IncognitoModeContext.Provider value={{ incognitoMode: incognitoMode, setIncognitoMode: setIncognitoMode }}>
<AppShell
className={`${colorScheme}-theme`}
header={
chat ? (
<ChatHeader readOnly />
) : undefined
}
layout="alt"
padding={0}
>
{children}
</AppShell>
</IncognitoModeContext.Provider>
</ChatContext.Provider>
);
}

View file

@ -0,0 +1,53 @@
import {
Card,
Container,
Skeleton,
Stack,
} from "@mantine/core";
import { useState, useEffect } from "react";
import { MessageItem } from "../components/MessageItem";
import { Message } from "../db";
import { usePublicChatId } from "../hooks/useChatId";
export function PublicChatRoute() {
const chatId = usePublicChatId();
const [loading, setLoading] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
useEffect(() => {
const dataFetch = async () => {
setLoading(true);
const res = await fetch(`/api/public/chats/${chatId}/messages`)
const { items } = await res.json()
setMessages(items as unknown as Message[]);
setLoading(false);
};
dataFetch();
}, [chatId]);
if (!chatId) return null;
return (
<>
<Container pt="xl" pb={100}>
<Stack spacing="xs" id="test">
{messages?.map((message) => (
<MessageItem key={message.key} message={message} readOnly />
))}
</Stack>
{loading && (
<Card withBorder mt="xs">
<Skeleton height={8} radius="xl" />
<Skeleton height={8} mt={6} radius="xl" />
<Skeleton height={8} mt={6} radius="xl" />
<Skeleton height={8} mt={6} radius="xl" />
<Skeleton height={8} mt={6} width="70%" radius="xl" />
</Card>
)}
</Container>
</>
);
}