v0.1.1
parent
1796c307d2
commit
27d0314648
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "chat-with-gpt",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"dependencies": {
|
||||
"@emotion/css": "^11.10.6",
|
||||
"@emotion/styled": "^11.10.6",
|
||||
|
|
|
@ -8,6 +8,7 @@ import { createStreamingChatCompletion } from './openai';
|
|||
import { createTitle } from './titles';
|
||||
import { ellipsize, sleep } from './utils';
|
||||
import * as idb from './idb';
|
||||
import { getTokenCountForMessages, selectMessagesToSendSafely } from './tokenizer';
|
||||
|
||||
export const channel = new BroadcastChannel('chats');
|
||||
|
||||
|
@ -103,8 +104,9 @@ export class ChatManager extends EventEmitter {
|
|||
: [];
|
||||
messages.push(newMessage);
|
||||
|
||||
const response = await createStreamingChatCompletion(messages.map(getOpenAIMessageFromMessage),
|
||||
message.requestedParameters);
|
||||
const messagesToSend = selectMessagesToSendSafely(messages.map(getOpenAIMessageFromMessage));
|
||||
|
||||
const response = await createStreamingChatCompletion(messagesToSend, message.requestedParameters);
|
||||
|
||||
response.on('error', () => {
|
||||
if (!reply.content) {
|
||||
|
|
|
@ -1,15 +1,17 @@
|
|||
import styled from '@emotion/styled';
|
||||
import Helmet from 'react-helmet';
|
||||
import { useSpotlight } from '@mantine/spotlight';
|
||||
import { Button, ButtonProps, TextInput } from '@mantine/core';
|
||||
import { Button, ButtonProps } from '@mantine/core';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { APP_NAME } from '../values';
|
||||
import { useAppContext } from '../context';
|
||||
import { backend } from '../backend';
|
||||
import { MenuItem, primaryMenu, secondaryMenu } from '../menus';
|
||||
|
||||
const Container = styled.div`
|
||||
const HeaderContainer = styled.div`
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
|
@ -56,7 +58,44 @@ const Container = styled.div`
|
|||
font-size: 90%;
|
||||
}
|
||||
|
||||
i + span {
|
||||
i + span, .mantine-Button-root span.hide-on-mobile {
|
||||
@media (max-width: 40em) {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: -9999px;
|
||||
}
|
||||
}
|
||||
|
||||
.mantine-Button-root {
|
||||
@media (max-width: 40em) {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const SubHeaderContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
font-family: "Work Sans", sans-serif;
|
||||
line-height: 1.7;
|
||||
font-size: 80%;
|
||||
opacity: 0.7;
|
||||
margin: 0.5rem 1rem 0 1rem;
|
||||
gap: 1rem;
|
||||
|
||||
.spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
a {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.fa {
|
||||
font-size: 90%;
|
||||
}
|
||||
|
||||
.fa + span {
|
||||
@media (max-width: 40em) {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
|
@ -78,7 +117,14 @@ function HeaderButton(props: ButtonProps & { icon?: string, onClick?: any, child
|
|||
)
|
||||
}
|
||||
|
||||
export default function Header(props: { title?: any, onShare?: () => void, share?: boolean, canShare?: boolean }) {
|
||||
export interface HeaderProps {
|
||||
title?: any;
|
||||
onShare?: () => void;
|
||||
share?: boolean;
|
||||
canShare?: boolean;
|
||||
}
|
||||
|
||||
export default function Header(props: HeaderProps) {
|
||||
const context = useAppContext();
|
||||
const navigate = useNavigate();
|
||||
const spotlight = useSpotlight();
|
||||
|
@ -94,7 +140,7 @@ export default function Header(props: { title?: any, onShare?: () => void, share
|
|||
context.settings.open(context.apiKeys.openai ? 'options' : 'user');
|
||||
}, [context, context.apiKeys.openai]);
|
||||
|
||||
return <Container>
|
||||
return <HeaderContainer>
|
||||
<Helmet>
|
||||
<title>{props.title ? `${props.title} - ` : ''}{APP_NAME} - Unofficial ChatGPT app</title>
|
||||
</Helmet>
|
||||
|
@ -112,10 +158,27 @@ export default function Header(props: { title?: any, onShare?: () => void, share
|
|||
Share
|
||||
</HeaderButton>}
|
||||
{backend && !context.authenticated && (
|
||||
<HeaderButton onClick={() => backend?.signIn()}>Sign in to sync</HeaderButton>
|
||||
<HeaderButton onClick={() => backend?.signIn()}>Sign in <span className="hide-on-mobile">to sync</span></HeaderButton>
|
||||
)}
|
||||
<HeaderButton icon="plus" onClick={onNewChat} loading={loading} variant="light">
|
||||
New Chat
|
||||
</HeaderButton>
|
||||
</Container>;
|
||||
</HeaderContainer>;
|
||||
}
|
||||
|
||||
function SubHeaderMenuItem(props: { item: MenuItem }) {
|
||||
return (
|
||||
<Button variant="light" size="xs" compact component={Link} to={props.item.link} key={props.item.link}>
|
||||
{props.item.icon && <i className={'fa fa-' + props.item.icon} />}
|
||||
<span>{props.item.label}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubHeader(props: any) {
|
||||
return <SubHeaderContainer>
|
||||
{primaryMenu.map(item => <SubHeaderMenuItem item={item} key={item.link} />)}
|
||||
<div className="spacer" />
|
||||
{secondaryMenu.map(item => <SubHeaderMenuItem item={item} key={item.link} />)}
|
||||
</SubHeaderContainer>;
|
||||
}
|
|
@ -1,17 +1,13 @@
|
|||
import styled from '@emotion/styled';
|
||||
import { Button, ActionIcon, Textarea } from '@mantine/core';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useAppContext } from '../context';
|
||||
import { Parameters } from '../types';
|
||||
|
||||
const Container = styled.div`
|
||||
background: #292933;
|
||||
border-top: thin solid #393933;
|
||||
padding: 1rem 1rem 0 1rem;
|
||||
position: absolute;
|
||||
bottom: 0rem;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
.inner {
|
||||
max-width: 50rem;
|
||||
|
@ -30,10 +26,10 @@ export declare type OnSubmit = (name?: string) => Promise<boolean>;
|
|||
|
||||
function PaperPlaneSubmitButton(props: { onSubmit: any, disabled?: boolean }) {
|
||||
return (
|
||||
<ActionIcon size="xs"
|
||||
<ActionIcon size="sm"
|
||||
disabled={props.disabled}
|
||||
loading={props.disabled}
|
||||
onClick={() => props.onSubmit()}>
|
||||
onClick={props.onSubmit}>
|
||||
<i className="fa fa-paper-plane" style={{ fontSize: '90%' }} />
|
||||
</ActionIcon>
|
||||
);
|
||||
|
@ -41,27 +37,24 @@ function PaperPlaneSubmitButton(props: { onSubmit: any, disabled?: boolean }) {
|
|||
|
||||
export interface MessageInputProps {
|
||||
disabled?: boolean;
|
||||
parameters: Parameters;
|
||||
onSubmit: OnSubmit;
|
||||
}
|
||||
|
||||
export default function MessageInput(props: MessageInputProps) {
|
||||
const context = useAppContext();
|
||||
|
||||
const [message, setMessage] = useState('');
|
||||
const pathname = useLocation().pathname;
|
||||
|
||||
const onChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setMessage(e.target.value);
|
||||
}, []);
|
||||
context.setMessage(e.target.value);
|
||||
}, [context.setMessage]);
|
||||
|
||||
const onSubmit = useCallback(async () => {
|
||||
if (await props.onSubmit(message)) {
|
||||
setMessage('');
|
||||
if (await context.onNewMessage(context.message)) {
|
||||
context.setMessage('');
|
||||
}
|
||||
}, [message, props.onSubmit]);
|
||||
}, [context.message, context.onNewMessage, context.setMessage]);
|
||||
|
||||
const onKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter'&& e.shiftKey === false && !props.disabled) {
|
||||
if (e.key === 'Enter' && e.shiftKey === false && !props.disabled) {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
|
@ -81,14 +74,24 @@ export default function MessageInput(props: MessageInputProps) {
|
|||
const openSystemPromptPanel = useCallback(() => context.settings.open('options', 'system-prompt'), []);
|
||||
const openTemperaturePanel = useCallback(() => context.settings.open('options', 'temperature'), []);
|
||||
|
||||
const messagesToDisplay = context.currentChat.messagesToDisplay;
|
||||
const disabled = context.generating
|
||||
|| messagesToDisplay[messagesToDisplay.length - 1]?.role === 'user'
|
||||
|| (messagesToDisplay.length > 0 && !messagesToDisplay[messagesToDisplay.length - 1]?.done);
|
||||
|
||||
const isLandingPage = pathname === '/';
|
||||
if (context.isShare || (!isLandingPage && !context.id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Container>
|
||||
<div className="inner">
|
||||
<Textarea disabled={props.disabled}
|
||||
<Textarea disabled={props.disabled || disabled}
|
||||
autosize
|
||||
minRows={3}
|
||||
maxRows={12}
|
||||
placeholder={"Enter a message here..."}
|
||||
value={message}
|
||||
value={context.message}
|
||||
onChange={onChange}
|
||||
rightSection={rightSection}
|
||||
onKeyDown={onKeyDown} />
|
||||
|
@ -105,7 +108,7 @@ export default function MessageInput(props: MessageInputProps) {
|
|||
size="xs"
|
||||
compact
|
||||
onClick={openTemperaturePanel}>
|
||||
<span>Temperature: {props.parameters.temperature.toFixed(1)}</span>
|
||||
<span>Temperature: {context.parameters.temperature.toFixed(1)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
import ReactMarkdown from 'react-markdown';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import { Button, CopyButton } from '@mantine/core';
|
||||
|
||||
export interface MarkdownProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Markdown(props: MarkdownProps) {
|
||||
const classes = ['prose', 'dark:prose-invert'];
|
||||
|
||||
if (props.className) {
|
||||
classes.push(props.className);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.join(' ')}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
components={{
|
||||
code({ node, inline, className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
return !inline ? (
|
||||
<div>
|
||||
<CopyButton value={String(children)}>
|
||||
{({ copy, copied }) => (
|
||||
<Button variant="subtle" size="sm" compact onClick={copy}>
|
||||
<i className="fa fa-clipboard" />
|
||||
<span>{copied ? 'Copied' : 'Copy'}</span>
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
<SyntaxHighlighter
|
||||
children={String(children).replace(/\n$/, '')}
|
||||
style={vscDarkPlus as any}
|
||||
language={match?.[1] || 'text'}
|
||||
PreTag="div"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
}}>{props.content}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,16 +1,10 @@
|
|||
import styled from '@emotion/styled';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { Button, CopyButton, Loader } from '@mantine/core';
|
||||
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
|
||||
import { Message } from "../types";
|
||||
import { share } from '../utils';
|
||||
import { ElevenLabsReaderButton } from '../elevenlabs';
|
||||
import { Markdown } from './markdown';
|
||||
|
||||
// hide for everyone but screen readers
|
||||
const SROnly = styled.span`
|
||||
|
@ -229,38 +223,7 @@ export default function MessageComponent(props: { message: Message, last: boolea
|
|||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className={"prose dark:prose-invert content content-" + props.message.id}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
components={{
|
||||
code({ node, inline, className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
return !inline ? (
|
||||
<div>
|
||||
<CopyButton value={String(children)}>
|
||||
{({ copy, copied }) => (
|
||||
<Button variant="subtle" size="sm" compact onClick={copy}>
|
||||
<i className="fa fa-clipboard" />
|
||||
<span>{copied ? 'Copied' : 'Copy'}</span>
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
<SyntaxHighlighter
|
||||
children={String(children).replace(/\n$/, '')}
|
||||
style={vscDarkPlus as any}
|
||||
language={match?.[1] || 'text'}
|
||||
PreTag="div"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
}}>{props.message.content}</ReactMarkdown>
|
||||
</div>
|
||||
<Markdown content={props.message.content} className={"content content-" + props.message.id} />
|
||||
</div>
|
||||
{props.last && <EndOfChatMarker />}
|
||||
</Container>
|
||||
|
|
|
@ -1,23 +1,9 @@
|
|||
import styled from '@emotion/styled';
|
||||
import slugify from 'slugify';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Button, Drawer, Loader } from '@mantine/core';
|
||||
import { SpotlightProvider } from '@mantine/spotlight';
|
||||
|
||||
import { Parameters } from '../types';
|
||||
import MessageInput from './input';
|
||||
import Header from './header';
|
||||
import SettingsScreen from './settings-screen';
|
||||
|
||||
import { useChatSpotlightProps } from '../spotlight';
|
||||
import { useChat } from '../use-chat';
|
||||
import Message from './message';
|
||||
import { loadParameters, saveParameters } from '../parameters';
|
||||
import { useAppContext } from '../context';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { APP_NAME } from '../values';
|
||||
import { backend } from '../backend';
|
||||
import Header, { HeaderProps, SubHeader } from './header';
|
||||
import MessageInput from './input';
|
||||
import SettingsDrawer from './settings';
|
||||
|
||||
const Container = styled.div`
|
||||
position: absolute;
|
||||
|
@ -27,174 +13,30 @@ const Container = styled.div`
|
|||
bottom: 0;
|
||||
background: #292933;
|
||||
color: white;
|
||||
`;
|
||||
|
||||
const Messages = styled.div`
|
||||
max-height: 100%;
|
||||
overflow-y: scroll;
|
||||
`;
|
||||
|
||||
const EmptyMessage = styled.div`
|
||||
min-height: 70vh;
|
||||
padding-bottom: 10vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-family: "Work Sans", sans-serif;
|
||||
line-height: 1.7;
|
||||
gap: 1rem;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
function Empty(props: { loading?: boolean }) {
|
||||
const context = useAppContext();
|
||||
return (
|
||||
<EmptyMessage>
|
||||
{props.loading && <Loader variant="dots" />}
|
||||
{!props.loading && <>
|
||||
<p>Hello, how can I help you today?</p>
|
||||
{!context.apiKeys.openai && (
|
||||
<Button size="xs"
|
||||
variant="light"
|
||||
compact
|
||||
onClick={() => context.settings.open('user', 'openai-api-key')}>
|
||||
Connect your OpenAI account to get started
|
||||
</Button>
|
||||
)}
|
||||
</>}
|
||||
</EmptyMessage>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChatPage(props: any) {
|
||||
const { id } = useParams();
|
||||
const context = useAppContext();
|
||||
export function Page(props: {
|
||||
id: string;
|
||||
headerProps?: HeaderProps;
|
||||
showSubHeader?: boolean;
|
||||
children: any;
|
||||
}) {
|
||||
const spotlightProps = useChatSpotlightProps();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { chat, messages, chatLoadedAt, leaf } = useChat(id, props.share);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
const [_parameters, setParameters] = useState<Parameters>(loadParameters(id));
|
||||
const [parameters] = useDebouncedValue(_parameters, 2000);
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
saveParameters(id, parameters);
|
||||
}
|
||||
saveParameters('', parameters);
|
||||
}, [id, parameters]);
|
||||
|
||||
const onNewMessage = useCallback(async (message?: string) => {
|
||||
if (props.share) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!message?.trim().length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!context.apiKeys.openai) {
|
||||
context.settings.open('user', 'openai-api-key');
|
||||
return false;
|
||||
}
|
||||
|
||||
setGenerating(true);
|
||||
|
||||
if (chat) {
|
||||
await context.chat.sendMessage({
|
||||
chatID: chat.id,
|
||||
content: message.trim(),
|
||||
requestedParameters: {
|
||||
...parameters,
|
||||
apiKey: context.apiKeys.openai,
|
||||
},
|
||||
parentID: leaf?.id,
|
||||
});
|
||||
} else if (props.landing) {
|
||||
const id = await context.chat.createChat();
|
||||
await context.chat.sendMessage({
|
||||
chatID: id,
|
||||
content: message.trim(),
|
||||
requestedParameters: {
|
||||
...parameters,
|
||||
apiKey: context.apiKeys.openai,
|
||||
},
|
||||
parentID: leaf?.id,
|
||||
});
|
||||
navigate('/chat/' + id);
|
||||
}
|
||||
|
||||
setTimeout(() => setGenerating(false), 4000);
|
||||
|
||||
return true;
|
||||
}, [chat, context.apiKeys.openai, leaf, parameters, props.landing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.share) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldScroll = (Date.now() - chatLoadedAt) > 5000;
|
||||
|
||||
if (!shouldScroll) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.querySelector('#messages') as HTMLElement;
|
||||
const messages = document.querySelectorAll('#messages .message');
|
||||
|
||||
if (messages.length) {
|
||||
const latest = messages[messages.length - 1] as HTMLElement;
|
||||
const offset = Math.max(0, latest.offsetTop - 100);
|
||||
setTimeout(() => {
|
||||
container?.scrollTo({ top: offset, behavior: 'smooth' });
|
||||
}, 500);
|
||||
}
|
||||
}, [chatLoadedAt, messages.length]);
|
||||
|
||||
const disabled = generating
|
||||
|| messages[messages.length - 1]?.role === 'user'
|
||||
|| (messages.length > 0 && !messages[messages.length - 1]?.done);
|
||||
|
||||
const shouldShowChat = id && chat && !!messages.length;
|
||||
|
||||
return <SpotlightProvider {...spotlightProps}>
|
||||
<Container key={chat?.id}>
|
||||
<Header share={props.share} canShare={messages.length > 1}
|
||||
title={(id && messages.length) ? chat?.title : null}
|
||||
onShare={async () => {
|
||||
if (chat) {
|
||||
const id = await backend?.shareChat(chat);
|
||||
if (id) {
|
||||
const slug = chat.title ? '/' + slugify(chat.title.toLocaleLowerCase()) : '';
|
||||
const url = window.location.origin + '/s/' + id + slug;
|
||||
navigator.share?.({
|
||||
title: chat.title || undefined,
|
||||
url,
|
||||
});
|
||||
}
|
||||
}
|
||||
}} />
|
||||
<Messages id="messages">
|
||||
{shouldShowChat && <div style={{ paddingBottom: '20rem' }}>
|
||||
{messages.map((message) => (
|
||||
<Message message={message}
|
||||
share={props.share}
|
||||
last={chat.messages.leafs.some(n => n.id === message.id)} />
|
||||
))}
|
||||
</div>}
|
||||
{!shouldShowChat && <Empty loading={(!props.landing && !chat) || props.share} />}
|
||||
</Messages>
|
||||
|
||||
{!props.share && <MessageInput disabled={disabled} onSubmit={onNewMessage} parameters={parameters} />}
|
||||
|
||||
<Drawer size="50rem"
|
||||
position='right'
|
||||
opened={!!context.settings.tab}
|
||||
onClose={() => context.settings.close()}
|
||||
withCloseButton={false}>
|
||||
<SettingsScreen parameters={_parameters} setParameters={setParameters} />
|
||||
</Drawer>
|
||||
<Container key={props.id}>
|
||||
<Header share={props.headerProps?.share}
|
||||
canShare={props.headerProps?.canShare}
|
||||
title={props.headerProps?.title}
|
||||
onShare={props.headerProps?.onShare} />
|
||||
{props.showSubHeader && <SubHeader />}
|
||||
{props.children}
|
||||
<MessageInput />
|
||||
<SettingsDrawer />
|
||||
</Container>
|
||||
</SpotlightProvider>;
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,79 @@
|
|||
import styled from "@emotion/styled";
|
||||
import { Markdown } from "../markdown";
|
||||
import { Page } from "../page";
|
||||
|
||||
const title = "Learn about Chat with GPT";
|
||||
|
||||
const content = `
|
||||
# About Chat with GPT
|
||||
|
||||
Chat with GPT is an open-source, unofficial ChatGPT app with extra features and more ways to customize your experience.
|
||||
|
||||
ChatGPT is an AI assistant developed by OpenAI. It's designed to understand natural language and generate human-like responses to a wide range of questions and prompts. ChatGPT has been trained on a massive dataset of text from the internet, which allows it to draw on a vast amount of knowledge and information to answer questions and engage in conversation. ChatGPT is constantly being improved. Feel free to ask it anything!
|
||||
|
||||
[Join the Discord.](https://discord.gg/mS5QvKykvv)
|
||||
|
||||
## Features
|
||||
|
||||
- 🚀 **Fast** response times.
|
||||
- 🔎 **Search** through your past chat conversations.
|
||||
- 📄 View and customize the System Prompt - the **secret prompt** the system shows the AI before your messages.
|
||||
- 🌡 Adjust the **creativity and randomness** of responses by setting the Temperature setting. Higher temperature means more creativity.
|
||||
- 💬 Give ChatGPT AI a **realistic human voice** by connecting your ElevenLabs text-to-speech account.
|
||||
- ✉ **Share** your favorite chat sessions online using public share URLs.
|
||||
- 📋 Easily **copy-and-paste** ChatGPT messages.
|
||||
- 🖼 **Full markdown support** including code, tables, and math.
|
||||
- 🫰 Pay for only what you use with the ChatGPT API.
|
||||
|
||||
## Bring your own API keys
|
||||
|
||||
### OpenAI
|
||||
|
||||
To get started with Chat with GPT, you will need to add your OpenAI API key on the settings screen. Click "Connect your OpenAI account to get started" on the home page to begin. Once you have added your API key, you can start chatting with ChatGPT.
|
||||
|
||||
Your API key is stored only on your device and is never transmitted to anyone except OpenAI. Please note that OpenAI API key usage is billed at a pay-as-you-go rate, separate from your ChatGPT subscription.
|
||||
|
||||
### ElevenLabs
|
||||
|
||||
To use the realistic AI text-to-speech feature, you will need to add your ElevenLabs API key by clicking "Play" next to any message.
|
||||
|
||||
Your API key is stored only on your device and never transmitted to anyone except ElevenLabs.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- Edit messages (coming soon)
|
||||
- Regenerate messages (coming soon)
|
||||
- [Suggest feature ideas on the Discord](https://discord.gg/mS5QvKykvv)
|
||||
`;
|
||||
|
||||
const Container = styled.div`
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
padding-top: 2rem;
|
||||
padding-bottom: 3rem;
|
||||
|
||||
.inner {
|
||||
max-width: 50rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
font-weight: "Work Sans", sans-serif;
|
||||
|
||||
* {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
h1, h2 {
|
||||
border-bottom: thin solid rgba(255, 255, 255, 0.2);
|
||||
padding-bottom: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default function AboutPage(props: any) {
|
||||
return <Page id={'about'} headerProps={{ title }}>
|
||||
<Container>
|
||||
<Markdown content={content} className='inner' />
|
||||
</Container>
|
||||
</Page>;
|
||||
}
|
|
@ -0,0 +1,100 @@
|
|||
import styled from '@emotion/styled';
|
||||
import slugify from 'slugify';
|
||||
import { useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Loader } from '@mantine/core';
|
||||
|
||||
import Message from '../message';
|
||||
import { useAppContext } from '../../context';
|
||||
import { backend } from '../../backend';
|
||||
import { Page } from '../page';
|
||||
|
||||
const Messages = styled.div`
|
||||
max-height: 100%;
|
||||
flex-grow: 1;
|
||||
overflow-y: scroll;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const EmptyMessage = styled.div`
|
||||
flex-grow: 1;
|
||||
padding-bottom: 5vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-family: "Work Sans", sans-serif;
|
||||
line-height: 1.7;
|
||||
gap: 1rem;
|
||||
`;
|
||||
|
||||
export default function ChatPage(props: any) {
|
||||
const { id } = useParams();
|
||||
const context = useAppContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (props.share || !context.currentChat.chatLoadedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldScroll = (Date.now() - context.currentChat.chatLoadedAt) > 5000;
|
||||
|
||||
if (!shouldScroll) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.querySelector('#messages') as HTMLElement;
|
||||
const messages = document.querySelectorAll('#messages .message');
|
||||
|
||||
if (messages.length) {
|
||||
const latest = messages[messages.length - 1] as HTMLElement;
|
||||
const offset = Math.max(0, latest.offsetTop - 100);
|
||||
setTimeout(() => {
|
||||
container?.scrollTo({ top: offset, behavior: 'smooth' });
|
||||
}, 500);
|
||||
}
|
||||
}, [context.currentChat?.chatLoadedAt, context.currentChat?.messagesToDisplay.length]);
|
||||
|
||||
const messagesToDisplay = context.currentChat.messagesToDisplay;
|
||||
|
||||
const shouldShowChat = id && context.currentChat.chat && !!messagesToDisplay.length;
|
||||
|
||||
return <Page id={id || 'landing'}
|
||||
headerProps={{
|
||||
share: context.isShare,
|
||||
canShare: messagesToDisplay.length > 1,
|
||||
title: (id && messagesToDisplay.length) ? context.currentChat.chat?.title : null,
|
||||
onShare: async () => {
|
||||
if (context.currentChat.chat) {
|
||||
const id = await backend?.shareChat(context.currentChat.chat);
|
||||
if (id) {
|
||||
const slug = context.currentChat.chat.title
|
||||
? '/' + slugify(context.currentChat.chat.title.toLocaleLowerCase())
|
||||
: '';
|
||||
const url = window.location.origin + '/s/' + id + slug;
|
||||
navigator.share?.({
|
||||
title: context.currentChat.chat.title || undefined,
|
||||
url,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
}}>
|
||||
<Messages id="messages">
|
||||
{shouldShowChat && (
|
||||
<div style={{ paddingBottom: '4.5rem' }}>
|
||||
{messagesToDisplay.map((message) => (
|
||||
<Message key={message.id}
|
||||
message={message}
|
||||
share={props.share}
|
||||
last={context.currentChat.chat!.messages.leafs.some(n => n.id === message.id)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!shouldShowChat && <EmptyMessage>
|
||||
<Loader variant="dots" />
|
||||
</EmptyMessage>}
|
||||
</Messages>
|
||||
</Page>;
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
import styled from '@emotion/styled';
|
||||
import { Button } from '@mantine/core';
|
||||
import MessageInput from '../input';
|
||||
import SettingsDrawer from '../settings';
|
||||
import { useAppContext } from '../../context';
|
||||
import { Page } from '../page';
|
||||
|
||||
const Container = styled.div`
|
||||
flex-grow: 1;
|
||||
padding-bottom: 5vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-family: "Work Sans", sans-serif;
|
||||
line-height: 1.7;
|
||||
gap: 1rem;
|
||||
`;
|
||||
|
||||
export default function LandingPage(props: any) {
|
||||
const context = useAppContext();
|
||||
|
||||
return <Page id={'landing'} showSubHeader={true}>
|
||||
<Container>
|
||||
<p>Hello, how can I help you today?</p>
|
||||
{!context.apiKeys.openai && (
|
||||
<Button size="xs" variant="light" compact onClick={() => context.settings.open('user', 'openai-api-key')}>
|
||||
Connect your OpenAI account to get started
|
||||
</Button>
|
||||
)}
|
||||
</Container>
|
||||
</Page>;
|
||||
}
|
|
@ -1,242 +0,0 @@
|
|||
import styled from '@emotion/styled';
|
||||
import { Button, Grid, Select, Slider, Tabs, Textarea, TextInput } from "@mantine/core";
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { defaultSystemPrompt } from '../openai';
|
||||
import { defaultVoiceList, getVoices } from '../elevenlabs';
|
||||
import { useAppContext } from '../context';
|
||||
|
||||
const Container = styled.div`
|
||||
padding: .4rem 1rem 1rem 1rem;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
|
||||
@media (max-width: 40em) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mantine-Tabs-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100% - 3rem);
|
||||
|
||||
@media (max-width: 40em) {
|
||||
height: calc(100% - 5rem);
|
||||
}
|
||||
}
|
||||
|
||||
.mantine-Tabs-tab {
|
||||
padding: 1.2rem 1.618rem 0.8rem 1.618rem;
|
||||
|
||||
@media (max-width: 40em) {
|
||||
padding: 1rem;
|
||||
span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mantine-Tabs-panel {
|
||||
flex-grow: 1;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
min-height: 0;
|
||||
margin-left: 0;
|
||||
padding: 1.2rem 0 3rem 0;
|
||||
|
||||
@media (max-width: 40em) {
|
||||
padding: 1.2rem 1rem 3rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
#save {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
opacity: 1;
|
||||
|
||||
.mantine-Button-root {
|
||||
height: 3rem;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Settings = styled.div`
|
||||
font-family: "Work Sans", sans-serif;
|
||||
color: white;
|
||||
|
||||
section {
|
||||
margin-bottom: .618rem;
|
||||
padding: 0.618rem;
|
||||
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.7;
|
||||
margin-top: 0.8rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: white;
|
||||
text-decoration : underline;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: "Fira Code", monospace;
|
||||
}
|
||||
}
|
||||
|
||||
.focused {
|
||||
border: thin solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 0.25rem;
|
||||
animation: flash 3s;
|
||||
}
|
||||
|
||||
@keyframes flash {
|
||||
0% {
|
||||
border-color: rgba(255, 0, 0, 0);
|
||||
}
|
||||
50% {
|
||||
border-color: rgba(255, 0, 0, 1);
|
||||
}
|
||||
100% {
|
||||
border-color: rgba(255, 255, 255, .1);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export interface SettingsScreenProps {
|
||||
parameters: any;
|
||||
setParameters: (parameters: any) => any;
|
||||
}
|
||||
|
||||
export default function SettingsScreen(props: SettingsScreenProps) {
|
||||
const context = useAppContext();
|
||||
const small = useMediaQuery('(max-width: 40em)');
|
||||
const { parameters, setParameters } = props;
|
||||
|
||||
const [voices, setVoices] = useState<any[]>(defaultVoiceList);
|
||||
useEffect(() => {
|
||||
if (context.apiKeys.elevenlabs) {
|
||||
getVoices().then(data => {
|
||||
if (data?.voices?.length) {
|
||||
setVoices(data.voices);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [context.apiKeys.elevenlabs]);
|
||||
|
||||
if (!context.settings.tab) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Tabs defaultValue={context.settings.tab} style={{ margin: '0rem' }}>
|
||||
<Tabs.List grow={small}>
|
||||
<Tabs.Tab value="options">Options</Tabs.Tab>
|
||||
<Tabs.Tab value="user">User</Tabs.Tab>
|
||||
<Tabs.Tab value="speech">Speech</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="user">
|
||||
<Settings>
|
||||
<Grid style={{ marginBottom: '1.618rem' }} gutter={24}>
|
||||
<Grid.Col span={12}>
|
||||
<section className={context.settings.option === 'openai-api-key' ? 'focused' : ''}>
|
||||
<h3>Your OpenAI API Key</h3>
|
||||
<TextInput
|
||||
placeholder="Paste your API key here"
|
||||
value={context.apiKeys.openai || ''}
|
||||
onChange={event => {
|
||||
setParameters({ ...parameters, apiKey: event.currentTarget.value });
|
||||
context.apiKeys.setOpenAIApiKey(event.currentTarget.value);
|
||||
}} />
|
||||
<p><a href="https://platform.openai.com/account/api-keys" target="_blank">Find your API key here.</a> Your API key is stored only on this device and never transmitted to anyone except OpenAI.</p>
|
||||
<p>OpenAI API key usage is billed at a pay-as-you-go rate, separate from your ChatGPT subscription.</p>
|
||||
</section>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Settings>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="options">
|
||||
<Settings>
|
||||
<Grid style={{ marginBottom: '1.618rem' }} gutter={24}>
|
||||
<Grid.Col span={12}>
|
||||
<section className={context.settings.option === 'system-prompt' ? 'focused' : ''}>
|
||||
<h3>System Prompt</h3>
|
||||
<Textarea
|
||||
value={parameters.initialSystemPrompt || defaultSystemPrompt}
|
||||
onChange={event => setParameters({ ...parameters, initialSystemPrompt: event.currentTarget.value })}
|
||||
minRows={5}
|
||||
maxRows={10}
|
||||
autosize />
|
||||
<p style={{ marginBottom: '0.7rem' }}>The System Prompt is shown to ChatGPT by the "System" before your first message. The <code style={{ whiteSpace: 'nowrap' }}>{'{{ datetime }}'}</code> tag is automatically replaced by the current date and time.</p>
|
||||
{(parameters.initialSystemPrompt?.trim() !== defaultSystemPrompt) && <Button size="xs" compact variant="light" onClick={() => setParameters({ ...parameters, initialSystemPrompt: defaultSystemPrompt })}>
|
||||
Reset to default
|
||||
</Button>}
|
||||
</section>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<section className={context.settings.option === 'temperature' ? 'focused' : ''}>
|
||||
<h3>Temperature ({parameters.temperature.toFixed(1)})</h3>
|
||||
<Slider value={parameters.temperature} onChange={value => setParameters({ ...parameters, temperature: value })} step={0.1} min={0} max={1} precision={3} />
|
||||
<p>The temperature parameter controls the randomness of the AI's responses. Lower values will make the AI more predictable, while higher values will make it more creative.</p>
|
||||
</section>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Settings>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="speech">
|
||||
<Settings>
|
||||
<Grid style={{ marginBottom: '1.618rem' }} gutter={24}>
|
||||
<Grid.Col span={12}>
|
||||
<section className={context.settings.option === 'elevenlabs-api-key' ? 'focused' : ''}>
|
||||
<h3>Your ElevenLabs Text-to-Speech API Key (optional)</h3>
|
||||
<TextInput placeholder="Paste your API key here" value={context.apiKeys.elevenlabs || ''} onChange={event => context.apiKeys.setElevenLabsApiKey(event.currentTarget.value)} />
|
||||
<p>Give ChatGPT a realisic human voice by connecting your ElevenLabs account (preview the available voices below). <a href="https://beta.elevenlabs.io" target="_blank">Click here to sign up.</a></p>
|
||||
<p>You can find your API key on the Profile tab of the ElevenLabs website. Your API key is stored only on this device and never transmitted to anyone except ElevenLabs.</p>
|
||||
</section>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<section className={context.settings.option === 'elevenlabs-voice' ? 'focused' : ''}>
|
||||
<h3>Voice</h3>
|
||||
<Select
|
||||
value={context.voice.id}
|
||||
onChange={v => context.voice.setVoiceID(v!)}
|
||||
data={voices.map(v => ({ label: v.name, value: v.voice_id }))} />
|
||||
<audio controls style={{ display: 'none' }} id="voice-preview" key={context.voice.id}>
|
||||
<source src={voices.find(v => v.voice_id === context.voice.id)?.preview_url} type="audio/mpeg" />
|
||||
</audio>
|
||||
<Button onClick={() => (document.getElementById('voice-preview') as HTMLMediaElement)?.play()} variant='light' compact style={{ marginTop: '1rem' }}>
|
||||
<i className='fa fa-headphones' />
|
||||
<span>Preview voice</span>
|
||||
</Button>
|
||||
</section>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Settings>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
<div id="save">
|
||||
<Button variant="light" fullWidth size="md" onClick={() => context.settings.close()}>
|
||||
Save and Close
|
||||
</Button>
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,246 @@
|
|||
import styled from '@emotion/styled';
|
||||
import { Button, Drawer, Grid, Select, Slider, Tabs, Textarea, TextInput } from "@mantine/core";
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { defaultSystemPrompt } from '../openai';
|
||||
import { defaultVoiceList, getVoices } from '../elevenlabs';
|
||||
import { useAppContext } from '../context';
|
||||
|
||||
const Container = styled.div`
|
||||
padding: .4rem 1rem 1rem 1rem;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
|
||||
@media (max-width: 40em) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mantine-Tabs-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100% - 3rem);
|
||||
|
||||
@media (max-width: 40em) {
|
||||
height: calc(100% - 5rem);
|
||||
}
|
||||
}
|
||||
|
||||
.mantine-Tabs-tab {
|
||||
padding: 1.2rem 1.618rem 0.8rem 1.618rem;
|
||||
|
||||
@media (max-width: 40em) {
|
||||
padding: 1rem;
|
||||
span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mantine-Tabs-panel {
|
||||
flex-grow: 1;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
min-height: 0;
|
||||
margin-left: 0;
|
||||
padding: 1.2rem 0 3rem 0;
|
||||
|
||||
@media (max-width: 40em) {
|
||||
padding: 1.2rem 1rem 3rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
#save {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
opacity: 1;
|
||||
|
||||
.mantine-Button-root {
|
||||
height: 3rem;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Settings = styled.div`
|
||||
font-family: "Work Sans", sans-serif;
|
||||
color: white;
|
||||
|
||||
section {
|
||||
margin-bottom: .618rem;
|
||||
padding: 0.618rem;
|
||||
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.7;
|
||||
margin-top: 0.8rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: white;
|
||||
text-decoration : underline;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: "Fira Code", monospace;
|
||||
}
|
||||
}
|
||||
|
||||
.focused {
|
||||
border: thin solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 0.25rem;
|
||||
animation: flash 3s;
|
||||
}
|
||||
|
||||
@keyframes flash {
|
||||
0% {
|
||||
border-color: rgba(255, 0, 0, 0);
|
||||
}
|
||||
50% {
|
||||
border-color: rgba(255, 0, 0, 1);
|
||||
}
|
||||
100% {
|
||||
border-color: rgba(255, 255, 255, .1);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export interface SettingsDrawerProps {
|
||||
}
|
||||
|
||||
export default function SettingsDrawer(props: SettingsDrawerProps) {
|
||||
const context = useAppContext();
|
||||
const small = useMediaQuery('(max-width: 40em)');
|
||||
const { parameters, setParameters } = context;
|
||||
|
||||
const [voices, setVoices] = useState<any[]>(defaultVoiceList);
|
||||
useEffect(() => {
|
||||
if (context.apiKeys.elevenlabs) {
|
||||
getVoices().then(data => {
|
||||
if (data?.voices?.length) {
|
||||
setVoices(data.voices);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [context.apiKeys.elevenlabs]);
|
||||
|
||||
if (!context.settings.tab) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer size="50rem"
|
||||
position='right'
|
||||
opened={!!context.settings.tab}
|
||||
onClose={() => context.settings.close()}
|
||||
withCloseButton={false}>
|
||||
<Container>
|
||||
<Tabs defaultValue={context.settings.tab} style={{ margin: '0rem' }}>
|
||||
<Tabs.List grow={small}>
|
||||
<Tabs.Tab value="options">Options</Tabs.Tab>
|
||||
<Tabs.Tab value="user">User</Tabs.Tab>
|
||||
<Tabs.Tab value="speech">Speech</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="user">
|
||||
<Settings>
|
||||
<Grid style={{ marginBottom: '1.618rem' }} gutter={24}>
|
||||
<Grid.Col span={12}>
|
||||
<section className={context.settings.option === 'openai-api-key' ? 'focused' : ''}>
|
||||
<h3>Your OpenAI API Key</h3>
|
||||
<TextInput
|
||||
placeholder="Paste your API key here"
|
||||
value={context.apiKeys.openai || ''}
|
||||
onChange={event => {
|
||||
setParameters({ ...parameters, apiKey: event.currentTarget.value });
|
||||
context.apiKeys.setOpenAIApiKey(event.currentTarget.value);
|
||||
}} />
|
||||
<p><a href="https://platform.openai.com/account/api-keys" target="_blank">Find your API key here.</a> Your API key is stored only on this device and never transmitted to anyone except OpenAI.</p>
|
||||
<p>OpenAI API key usage is billed at a pay-as-you-go rate, separate from your ChatGPT subscription.</p>
|
||||
</section>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Settings>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="options">
|
||||
<Settings>
|
||||
<Grid style={{ marginBottom: '1.618rem' }} gutter={24}>
|
||||
<Grid.Col span={12}>
|
||||
<section className={context.settings.option === 'system-prompt' ? 'focused' : ''}>
|
||||
<h3>System Prompt</h3>
|
||||
<Textarea
|
||||
value={parameters.initialSystemPrompt || defaultSystemPrompt}
|
||||
onChange={event => setParameters({ ...parameters, initialSystemPrompt: event.currentTarget.value })}
|
||||
minRows={5}
|
||||
maxRows={10}
|
||||
autosize />
|
||||
<p style={{ marginBottom: '0.7rem' }}>The System Prompt is shown to ChatGPT by the "System" before your first message. The <code style={{ whiteSpace: 'nowrap' }}>{'{{ datetime }}'}</code> tag is automatically replaced by the current date and time.</p>
|
||||
{parameters.initialSystemPrompt && (parameters.initialSystemPrompt?.trim() !== defaultSystemPrompt.trim()) && <Button size="xs" compact variant="light" onClick={() => setParameters({ ...parameters, initialSystemPrompt: defaultSystemPrompt })}>
|
||||
Reset to default
|
||||
</Button>}
|
||||
</section>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<section className={context.settings.option === 'temperature' ? 'focused' : ''}>
|
||||
<h3>Temperature ({parameters.temperature.toFixed(1)})</h3>
|
||||
<Slider value={parameters.temperature} onChange={value => setParameters({ ...parameters, temperature: value })} step={0.1} min={0} max={1} precision={3} />
|
||||
<p>The temperature parameter controls the randomness of the AI's responses. Lower values will make the AI more predictable, while higher values will make it more creative.</p>
|
||||
</section>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Settings>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="speech">
|
||||
<Settings>
|
||||
<Grid style={{ marginBottom: '1.618rem' }} gutter={24}>
|
||||
<Grid.Col span={12}>
|
||||
<section className={context.settings.option === 'elevenlabs-api-key' ? 'focused' : ''}>
|
||||
<h3>Your ElevenLabs Text-to-Speech API Key (optional)</h3>
|
||||
<TextInput placeholder="Paste your API key here" value={context.apiKeys.elevenlabs || ''} onChange={event => context.apiKeys.setElevenLabsApiKey(event.currentTarget.value)} />
|
||||
<p>Give ChatGPT a realisic human voice by connecting your ElevenLabs account (preview the available voices below). <a href="https://beta.elevenlabs.io" target="_blank">Click here to sign up.</a></p>
|
||||
<p>You can find your API key by clicking your avatar or initials in the top right of the ElevenLabs website, then clicking Profile. Your API key is stored only on this device and never transmitted to anyone except ElevenLabs.</p>
|
||||
</section>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<section className={context.settings.option === 'elevenlabs-voice' ? 'focused' : ''}>
|
||||
<h3>Voice</h3>
|
||||
<Select
|
||||
value={context.voice.id}
|
||||
onChange={v => context.voice.setVoiceID(v!)}
|
||||
data={voices.map(v => ({ label: v.name, value: v.voice_id }))} />
|
||||
<audio controls style={{ display: 'none' }} id="voice-preview" key={context.voice.id}>
|
||||
<source src={voices.find(v => v.voice_id === context.voice.id)?.preview_url} type="audio/mpeg" />
|
||||
</audio>
|
||||
<Button onClick={() => (document.getElementById('voice-preview') as HTMLMediaElement)?.play()} variant='light' compact style={{ marginTop: '1rem' }}>
|
||||
<i className='fa fa-headphones' />
|
||||
<span>Preview voice</span>
|
||||
</Button>
|
||||
</section>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Settings>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
<div id="save">
|
||||
<Button variant="light" fullWidth size="md" onClick={() => context.settings.close()}>
|
||||
Save and Close
|
||||
</Button>
|
||||
</div>
|
||||
</Container>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
105
src/context.tsx
105
src/context.tsx
|
@ -1,11 +1,19 @@
|
|||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import React, { useState, useRef, useMemo, useEffect, useCallback } from "react";
|
||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { backend } from "./backend";
|
||||
import ChatManagerInstance, { ChatManager } from "./chat-manager";
|
||||
import { defaultElevenLabsVoiceID } from "./elevenlabs";
|
||||
import { loadParameters, saveParameters } from "./parameters";
|
||||
import { Parameters } from "./types";
|
||||
import { useChat, UseChatResult } from "./use-chat";
|
||||
|
||||
export interface Context {
|
||||
authenticated: boolean;
|
||||
chat: ChatManager;
|
||||
id: string | undefined | null;
|
||||
currentChat: UseChatResult;
|
||||
isShare: boolean;
|
||||
apiKeys: {
|
||||
openai: string | undefined | null;
|
||||
setOpenAIApiKey: (apiKey: string | null) => void;
|
||||
|
@ -21,13 +29,25 @@ export interface Context {
|
|||
voice: {
|
||||
id: string;
|
||||
setVoiceID: (id: string) => void;
|
||||
}
|
||||
};
|
||||
generating: boolean;
|
||||
message: string;
|
||||
parameters: Parameters;
|
||||
setMessage: (message: string) => void;
|
||||
setParameters: (parameters: Parameters) => void;
|
||||
onNewMessage: (message?: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const AppContext = React.createContext<Context>({} as any);
|
||||
|
||||
export function useCreateAppContext(): Context {
|
||||
const chat = useRef(ChatManagerInstance);
|
||||
const { id } = useParams();
|
||||
const pathname = useLocation().pathname;
|
||||
const isShare = pathname.startsWith('/s/');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const chatManager = useRef(ChatManagerInstance);
|
||||
const currentChat = useChat(chatManager.current, id, isShare);
|
||||
const [authenticated, setAuthenticated] = useState(backend?.isAuthenticated || false);
|
||||
|
||||
const updateAuth = useCallback((authenticated: boolean) => setAuthenticated(authenticated), []);
|
||||
|
@ -47,11 +67,15 @@ export function useCreateAppContext(): Context {
|
|||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (openaiApiKey) {
|
||||
localStorage.setItem('openai-api-key', openaiApiKey || '');
|
||||
}
|
||||
}, [openaiApiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (elevenLabsApiKey) {
|
||||
localStorage.setItem('elevenlabs-api-key', elevenLabsApiKey || '');
|
||||
}
|
||||
}, [elevenLabsApiKey]);
|
||||
|
||||
const [settingsTab, setSettingsTab] = useState<string | null | undefined>();
|
||||
|
@ -63,9 +87,75 @@ export function useCreateAppContext(): Context {
|
|||
localStorage.setItem('voice-id', voiceID);
|
||||
}, [voiceID]);
|
||||
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const [_parameters, setParameters] = useState<Parameters>(loadParameters(id));
|
||||
useEffect(() => {
|
||||
setParameters(loadParameters(id));
|
||||
}, [id]);
|
||||
|
||||
const [parameters] = useDebouncedValue(_parameters, 2000);
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
saveParameters(id, parameters);
|
||||
}
|
||||
saveParameters('', parameters);
|
||||
}, [id, parameters]);
|
||||
|
||||
const onNewMessage = useCallback(async (message?: string) => {
|
||||
if (isShare) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!message?.trim().length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!openaiApiKey) {
|
||||
setSettingsTab('user');
|
||||
setOption('openai-api-key');
|
||||
return false;
|
||||
}
|
||||
|
||||
setGenerating(true);
|
||||
|
||||
if (id) {
|
||||
await chatManager.current.sendMessage({
|
||||
chatID: id,
|
||||
content: message.trim(),
|
||||
requestedParameters: {
|
||||
...parameters,
|
||||
apiKey: openaiApiKey,
|
||||
},
|
||||
parentID: currentChat.leaf?.id,
|
||||
});
|
||||
} else {
|
||||
const id = await chatManager.current.createChat();
|
||||
await chatManager.current.sendMessage({
|
||||
chatID: id,
|
||||
content: message.trim(),
|
||||
requestedParameters: {
|
||||
...parameters,
|
||||
apiKey: openaiApiKey,
|
||||
},
|
||||
parentID: currentChat.leaf?.id,
|
||||
});
|
||||
navigate('/chat/' + id);
|
||||
}
|
||||
|
||||
setTimeout(() => setGenerating(false), 4000);
|
||||
|
||||
return true;
|
||||
}, [chatManager, openaiApiKey, id, parameters, message, currentChat.leaf]);
|
||||
|
||||
const context = useMemo<Context>(() => ({
|
||||
authenticated,
|
||||
chat: chat.current,
|
||||
id,
|
||||
chat: chatManager.current,
|
||||
currentChat,
|
||||
isShare,
|
||||
apiKeys: {
|
||||
openai: openaiApiKey,
|
||||
elevenlabs: elevenLabsApiKey,
|
||||
|
@ -88,7 +178,14 @@ export function useCreateAppContext(): Context {
|
|||
id: voiceID,
|
||||
setVoiceID,
|
||||
},
|
||||
}), [chat, authenticated, openaiApiKey, elevenLabsApiKey, settingsTab, option, voiceID]);
|
||||
generating,
|
||||
message,
|
||||
parameters,
|
||||
setMessage,
|
||||
setParameters,
|
||||
onNewMessage,
|
||||
}), [chatManager, authenticated, openaiApiKey, elevenLabsApiKey, settingsTab, option, voiceID,
|
||||
generating, message, parameters, onNewMessage, currentChat]);
|
||||
|
||||
return context;
|
||||
}
|
||||
|
|
|
@ -6,26 +6,43 @@ import {
|
|||
} from "react-router-dom";
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { ModalsProvider } from '@mantine/modals';
|
||||
import ChatPage from './components/page';
|
||||
import { AppContextProvider } from './context';
|
||||
import LandingPage from './components/pages/landing';
|
||||
import ChatPage from './components/pages/chat';
|
||||
import AboutPage from './components/pages/about';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: <ChatPage landing={true} />,
|
||||
element: <AppContextProvider>
|
||||
<LandingPage landing={true} />
|
||||
</AppContextProvider>,
|
||||
},
|
||||
{
|
||||
path: "/chat/:id",
|
||||
element: <ChatPage />,
|
||||
element: <AppContextProvider>
|
||||
<ChatPage />
|
||||
</AppContextProvider>,
|
||||
},
|
||||
{
|
||||
path: "/s/:id",
|
||||
element: <ChatPage share={true} />,
|
||||
element: <AppContextProvider>
|
||||
<ChatPage share={true} />
|
||||
</AppContextProvider>,
|
||||
},
|
||||
{
|
||||
path: "/s/:id/*",
|
||||
element: <ChatPage share={true} />,
|
||||
element: <AppContextProvider>
|
||||
<ChatPage share={true} />
|
||||
</AppContextProvider>,
|
||||
},
|
||||
{
|
||||
path: "/about",
|
||||
element: <AppContextProvider>
|
||||
<AboutPage />
|
||||
</AppContextProvider>,
|
||||
},
|
||||
]);
|
||||
|
||||
|
@ -36,11 +53,9 @@ const root = ReactDOM.createRoot(
|
|||
root.render(
|
||||
<React.StrictMode>
|
||||
<MantineProvider theme={{ colorScheme: "dark" }}>
|
||||
<AppContextProvider>
|
||||
<ModalsProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ModalsProvider>
|
||||
</AppContextProvider>
|
||||
</MantineProvider>
|
||||
</React.StrictMode>
|
||||
);
|
|
@ -0,0 +1,30 @@
|
|||
export interface MenuItem {
|
||||
label: string;
|
||||
link: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export const primaryMenu: MenuItem[] = [
|
||||
{
|
||||
label: "About this app",
|
||||
link: "/about",
|
||||
},
|
||||
{
|
||||
label: "ChatGPT Prompts",
|
||||
link: "https://github.com/f/awesome-chatgpt-prompts",
|
||||
icon: "external-link-alt",
|
||||
},
|
||||
];
|
||||
|
||||
export const secondaryMenu: MenuItem[] = [
|
||||
{
|
||||
label: "Discord",
|
||||
link: "https://discord.gg/mS5QvKykvv",
|
||||
icon: "discord fab",
|
||||
},
|
||||
{
|
||||
label: "GitHub",
|
||||
link: "https://github.com/cogentapps/chat-with-gpt",
|
||||
icon: "github fab",
|
||||
},
|
||||
];
|
|
@ -1,10 +1,18 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { backend } from "./backend";
|
||||
import { ChatManager } from "./chat-manager";
|
||||
import { useAppContext } from "./context";
|
||||
import { Chat, Message } from './types';
|
||||
|
||||
export function useChat(id: string | undefined | null, share = false) {
|
||||
const context = useAppContext();
|
||||
export interface UseChatResult {
|
||||
chat: Chat | null | undefined;
|
||||
chatLoadedAt: number;
|
||||
messages: Message[];
|
||||
messagesToDisplay: Message[];
|
||||
leaf: Message | null | undefined;
|
||||
}
|
||||
|
||||
export function useChat(chatManager: ChatManager, id: string | undefined | null, share = false): UseChatResult {
|
||||
const [chat, setChat] = useState<Chat | null | undefined>(null);
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
|
@ -14,7 +22,7 @@ export function useChat(id: string | undefined | null, share = false) {
|
|||
const update = useCallback(async () => {
|
||||
if (id) {
|
||||
if (!share) {
|
||||
const c = context.chat.get(id);
|
||||
const c = chatManager.get(id);
|
||||
if (c) {
|
||||
setChat(c);
|
||||
setVersion(v => v + 1);
|
||||
|
@ -35,8 +43,8 @@ export function useChat(id: string | undefined | null, share = false) {
|
|||
useEffect(() => {
|
||||
if (id) {
|
||||
update();
|
||||
context.chat.on(id, update);
|
||||
setChat(context.chat.get(id));
|
||||
chatManager.on(id, update);
|
||||
setChat(chatManager.get(id));
|
||||
setLoadedAt(Date.now());
|
||||
} else {
|
||||
setChat(null);
|
||||
|
@ -44,7 +52,7 @@ export function useChat(id: string | undefined | null, share = false) {
|
|||
}
|
||||
return () => {
|
||||
if (id) {
|
||||
context.chat.off(id, update);
|
||||
chatManager.off(id, update);
|
||||
}
|
||||
};
|
||||
}, [id, update]);
|
||||
|
@ -52,16 +60,18 @@ export function useChat(id: string | undefined | null, share = false) {
|
|||
const leaf = chat?.messages.mostRecentLeaf();
|
||||
|
||||
let messages: Message[] = [];
|
||||
let messagesToDisplay: Message[] = [];
|
||||
|
||||
if (leaf) {
|
||||
messages = (chat?.messages.getMessageChainTo(leaf?.id) || [])
|
||||
.filter(m => ['user', 'assistant'].includes(m.role)) || [];
|
||||
messages = (chat?.messages.getMessageChainTo(leaf?.id) || []);
|
||||
messagesToDisplay = messages.filter(m => ['user', 'assistant'].includes(m.role)) || [];
|
||||
}
|
||||
|
||||
return {
|
||||
chat,
|
||||
chatLoadedAt,
|
||||
messages,
|
||||
messagesToDisplay,
|
||||
leaf,
|
||||
};
|
||||
}
|
Loading…
Reference in New Issue