v0.2.0
This commit is contained in:
100
app/src/components/auth/modals.tsx
Normal file
100
app/src/components/auth/modals.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import styled from "@emotion/styled";
|
||||
import { Button, Modal, PasswordInput, TextInput } from "@mantine/core";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useAppDispatch, useAppSelector } from "../../store";
|
||||
import { closeModals, openLoginModal, openSignupModal, selectModal } from "../../store/ui";
|
||||
|
||||
const Container = styled.form`
|
||||
* {
|
||||
font-family: "Work Sans", sans-serif;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.mantine-TextInput-root, .mantine-PasswordInput-root {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.mantine-TextInput-root + .mantine-Button-root,
|
||||
.mantine-PasswordInput-root + .mantine-Button-root {
|
||||
margin-top: 1.618rem;
|
||||
}
|
||||
|
||||
.mantine-Button-root {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
label {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export function LoginModal(props: any) {
|
||||
const modal = useAppSelector(selectModal);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const onClose = useCallback(() => dispatch(closeModals()), [dispatch]);
|
||||
const onCreateAccountClick = useCallback(() => dispatch(openSignupModal()), [dispatch]);
|
||||
|
||||
return <Modal opened={modal === 'login'} onClose={onClose} withCloseButton={false}>
|
||||
<Container action="/chatapi/login" method="post">
|
||||
<h2>
|
||||
Sign in
|
||||
</h2>
|
||||
<input type="hidden" name="redirect_url" value={window.location.href} />
|
||||
<TextInput label="Email address"
|
||||
name="username"
|
||||
placeholder="Enter your email address"
|
||||
type="email"
|
||||
required />
|
||||
<PasswordInput label="Password"
|
||||
name="password"
|
||||
placeholder="Enter your password"
|
||||
maxLength={500}
|
||||
required />
|
||||
<Button fullWidth type="submit">
|
||||
Sign in
|
||||
</Button>
|
||||
<Button fullWidth variant="subtle" onClick={onCreateAccountClick}>
|
||||
Or create an account
|
||||
</Button>
|
||||
</Container>
|
||||
</Modal>
|
||||
}
|
||||
|
||||
export function CreateAccountModal(props: any) {
|
||||
const modal = useAppSelector(selectModal);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const onClose = useCallback(() => dispatch(closeModals()), [dispatch]);
|
||||
const onSignInClick = useCallback(() => dispatch(openLoginModal()), [dispatch]);
|
||||
|
||||
return <Modal opened={modal === 'signup'} onClose={onClose} withCloseButton={false}>
|
||||
<Container action="/chatapi/register" method="post">
|
||||
<h2>
|
||||
Create an account
|
||||
</h2>
|
||||
<input type="hidden" name="redirect_url" value={window.location.href} />
|
||||
<TextInput label="Email address"
|
||||
name="username"
|
||||
placeholder="Enter your email address"
|
||||
type="email"
|
||||
required />
|
||||
<PasswordInput label="Password"
|
||||
name="password"
|
||||
placeholder="Enter your password"
|
||||
minLength={6}
|
||||
maxLength={500}
|
||||
required />
|
||||
<Button fullWidth type="submit">
|
||||
Sign up
|
||||
</Button>
|
||||
<Button fullWidth variant="subtle" onClick={onSignInClick}>
|
||||
Or sign in to an existing account
|
||||
</Button>
|
||||
</Container>
|
||||
</Modal>
|
||||
}
|
215
app/src/components/header.tsx
Normal file
215
app/src/components/header.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import styled from '@emotion/styled';
|
||||
import Helmet from 'react-helmet';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import { useSpotlight } from '@mantine/spotlight';
|
||||
import { Burger, Button, ButtonProps } from '@mantine/core';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAppContext } from '../context';
|
||||
import { backend } from '../backend';
|
||||
import { MenuItem, secondaryMenu } from '../menus';
|
||||
import { useAppDispatch, useAppSelector } from '../store';
|
||||
import { selectOpenAIApiKey } from '../store/api-keys';
|
||||
import { setTab } from '../store/settings-ui';
|
||||
import { selectSidebarOpen, toggleSidebar } from '../store/sidebar';
|
||||
import { openLoginModal } from '../store/ui';
|
||||
|
||||
const HeaderContainer = styled.div`
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
min-height: 2.618rem;
|
||||
background: rgba(0, 0, 0, 0.0);
|
||||
font-family: "Work Sans", sans-serif;
|
||||
|
||||
&.shaded {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
h1 {
|
||||
@media (max-width: 40em) {
|
||||
width: 100%;
|
||||
order: -1;
|
||||
}
|
||||
|
||||
font-family: "Work Sans", sans-serif;
|
||||
font-size: 1rem;
|
||||
line-height: 1.3;
|
||||
|
||||
animation: fadein 0.5s;
|
||||
animation-fill-mode: forwards;
|
||||
|
||||
strong {
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
span {
|
||||
display: block;
|
||||
font-size: 70%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@keyframes fadein {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 90%;
|
||||
}
|
||||
|
||||
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;
|
||||
opacity: 0.7;
|
||||
margin: 0.5rem 0.5rem 0 0.5rem;
|
||||
|
||||
.spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
a {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.fa + span {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: -9999px;
|
||||
}
|
||||
`;
|
||||
|
||||
function HeaderButton(props: ButtonProps & { icon?: string, onClick?: any, children?: any }) {
|
||||
return (
|
||||
<Button size='xs'
|
||||
variant={props.variant || 'subtle'}
|
||||
onClick={props.onClick}>
|
||||
{props.icon && <i className={'fa fa-' + props.icon} />}
|
||||
{props.children && <span>
|
||||
{props.children}
|
||||
</span>}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
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();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const openAIApiKey = useAppSelector(selectOpenAIApiKey);
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const sidebarOpen = useAppSelector(selectSidebarOpen);
|
||||
const onBurgerClick = useCallback(() => dispatch(toggleSidebar()), [dispatch]);
|
||||
|
||||
const burgerLabel = sidebarOpen
|
||||
? intl.formatMessage({ defaultMessage: "Close sidebar" })
|
||||
: intl.formatMessage({ defaultMessage: "Open sidebar" });
|
||||
|
||||
const onNewChat = useCallback(async () => {
|
||||
setLoading(true);
|
||||
navigate(`/`);
|
||||
setLoading(false);
|
||||
}, [navigate]);
|
||||
|
||||
const openSettings = useCallback(() => {
|
||||
dispatch(setTab(openAIApiKey ? 'options' : 'user'));
|
||||
}, [openAIApiKey, dispatch]);
|
||||
|
||||
const header = useMemo(() => (
|
||||
<HeaderContainer className={context.isHome ? 'shaded' : ''}>
|
||||
<Helmet>
|
||||
<title>
|
||||
{props.title ? `${props.title} - ` : ''}
|
||||
{intl.formatMessage({ defaultMessage: "Chat with GPT - Unofficial ChatGPT app" })}
|
||||
</title>
|
||||
</Helmet>
|
||||
{!sidebarOpen && <Burger opened={sidebarOpen} onClick={onBurgerClick} aria-label={burgerLabel} transitionDuration={0} />}
|
||||
{context.isHome && <h2>{intl.formatMessage({ defaultMessage: "Chat with GPT" })}</h2>}
|
||||
<div className="spacer" />
|
||||
<HeaderButton icon="search" onClick={spotlight.openSpotlight} />
|
||||
<HeaderButton icon="gear" onClick={openSettings} />
|
||||
{backend.current && !props.share && props.canShare && typeof navigator.share !== 'undefined' && <HeaderButton icon="share" onClick={props.onShare}>
|
||||
<FormattedMessage defaultMessage="Share" />
|
||||
</HeaderButton>}
|
||||
{backend.current && !context.authenticated && (
|
||||
<HeaderButton onClick={() => {
|
||||
if (process.env.REACT_APP_AUTH_PROVIDER !== 'local') {
|
||||
backend.current?.signIn();
|
||||
} else {
|
||||
dispatch(openLoginModal());
|
||||
}
|
||||
}}>
|
||||
<FormattedMessage defaultMessage="Sign in <h>to sync</h>"
|
||||
values={{
|
||||
h: (chunks: any) => <span className="hide-on-mobile">{chunks}</span>
|
||||
}} />
|
||||
</HeaderButton>
|
||||
)}
|
||||
<HeaderButton icon="plus" onClick={onNewChat} loading={loading} variant="light">
|
||||
<FormattedMessage defaultMessage="New Chat" />
|
||||
</HeaderButton>
|
||||
</HeaderContainer>
|
||||
), [sidebarOpen, onBurgerClick, props.title, props.share, props.canShare, props.onShare, openSettings, onNewChat, loading, context.authenticated, context.isHome, context.isShare, spotlight.openSpotlight]);
|
||||
|
||||
return header;
|
||||
}
|
||||
|
||||
function SubHeaderMenuItem(props: { item: MenuItem }) {
|
||||
return (
|
||||
<Button variant="subtle" size="sm" compact component={Link} to={props.item.link} target="_blank" 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) {
|
||||
const elem = useMemo(() => (
|
||||
<SubHeaderContainer>
|
||||
<div className="spacer" />
|
||||
{secondaryMenu.map(item => <SubHeaderMenuItem item={item} key={item.link} />)}
|
||||
</SubHeaderContainer>
|
||||
), []);
|
||||
|
||||
return elem;
|
||||
}
|
138
app/src/components/input.tsx
Normal file
138
app/src/components/input.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { Button, ActionIcon, Textarea, Loader } from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useAppContext } from '../context';
|
||||
import { useAppDispatch, useAppSelector } from '../store';
|
||||
import { selectMessage, setMessage } from '../store/message';
|
||||
import { selectTemperature } from '../store/parameters';
|
||||
import { openSystemPromptPanel, openTemperaturePanel } from '../store/settings-ui';
|
||||
|
||||
const Container = styled.div`
|
||||
background: #292933;
|
||||
border-top: thin solid #393933;
|
||||
padding: 1rem 1rem 0 1rem;
|
||||
|
||||
.inner {
|
||||
max-width: 50rem;
|
||||
margin: auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.settings-button {
|
||||
margin: 0.5rem -0.4rem 0.5rem 1rem;
|
||||
font-size: 0.7rem;
|
||||
color: #999;
|
||||
}
|
||||
`;
|
||||
|
||||
export declare type OnSubmit = (name?: string) => Promise<boolean>;
|
||||
|
||||
export interface MessageInputProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function MessageInput(props: MessageInputProps) {
|
||||
const temperature = useAppSelector(selectTemperature);
|
||||
const message = useAppSelector(selectMessage);
|
||||
|
||||
const hasVerticalSpace = useMediaQuery('(min-height: 1000px)');
|
||||
|
||||
const context = useAppContext();
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const onCustomizeSystemPromptClick = useCallback(() => dispatch(openSystemPromptPanel()), [dispatch]);
|
||||
const onTemperatureClick = useCallback(() => dispatch(openTemperaturePanel()), [dispatch]);
|
||||
const onChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
dispatch(setMessage(e.target.value));
|
||||
}, [dispatch]);
|
||||
|
||||
const pathname = useLocation().pathname;
|
||||
|
||||
const onSubmit = useCallback(async () => {
|
||||
if (await context.onNewMessage(message)) {
|
||||
dispatch(setMessage(''));
|
||||
}
|
||||
}, [context, message, dispatch]);
|
||||
|
||||
const onKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && e.shiftKey === false && !props.disabled) {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
}, [onSubmit, props.disabled]);
|
||||
|
||||
const rightSection = useMemo(() => {
|
||||
return (
|
||||
<div style={{
|
||||
opacity: '0.8',
|
||||
paddingRight: '0.5rem',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
}}>
|
||||
{context.generating && (<>
|
||||
<Button variant="subtle" size="xs" compact onClick={() => {
|
||||
context.chat.cancelReply(context.currentChat.leaf!.id);
|
||||
}}>
|
||||
<FormattedMessage defaultMessage={"Cancel"} />
|
||||
</Button>
|
||||
<Loader size="xs" style={{ padding: '0 0.8rem 0 0.5rem' }} />
|
||||
</>)}
|
||||
{!context.generating && (
|
||||
<ActionIcon size="xl"
|
||||
onClick={onSubmit}>
|
||||
<i className="fa fa-paper-plane" style={{ fontSize: '90%' }} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}, [onSubmit, props.disabled, context.generating]);
|
||||
|
||||
const disabled = context.generating;
|
||||
|
||||
const isLandingPage = pathname === '/';
|
||||
if (context.isShare || (!isLandingPage && !context.id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Container>
|
||||
<div className="inner">
|
||||
<Textarea disabled={props.disabled || disabled}
|
||||
autosize
|
||||
minRows={(hasVerticalSpace || context.isHome) ? 3 : 2}
|
||||
maxRows={12}
|
||||
placeholder={intl.formatMessage({ defaultMessage: "Enter a message here..." })}
|
||||
value={message}
|
||||
onChange={onChange}
|
||||
rightSection={rightSection}
|
||||
rightSectionWidth={context.generating ? 100 : 55}
|
||||
onKeyDown={onKeyDown} />
|
||||
<div>
|
||||
<Button variant="subtle"
|
||||
className="settings-button"
|
||||
size="xs"
|
||||
compact
|
||||
onClick={onCustomizeSystemPromptClick}>
|
||||
<span>
|
||||
<FormattedMessage defaultMessage={"Customize system prompt"} />
|
||||
</span>
|
||||
</Button>
|
||||
<Button variant="subtle"
|
||||
className="settings-button"
|
||||
size="xs"
|
||||
compact
|
||||
onClick={onTemperatureClick}>
|
||||
<span>
|
||||
<FormattedMessage defaultMessage="Temperature: {temperature, number, ::.0}"
|
||||
values={{ temperature }} />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Container>;
|
||||
}
|
65
app/src/components/markdown.tsx
Normal file
65
app/src/components/markdown.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
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';
|
||||
import { useMemo } from 'react';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
export interface MarkdownProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Markdown(props: MarkdownProps) {
|
||||
const intl = useIntl();
|
||||
|
||||
const classes = useMemo(() => {
|
||||
const classes = ['prose', 'dark:prose-invert'];
|
||||
|
||||
if (props.className) {
|
||||
classes.push(props.className);
|
||||
}
|
||||
|
||||
return classes;
|
||||
}, [props.className])
|
||||
|
||||
const elem = useMemo(() => (
|
||||
<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 ? <FormattedMessage defaultMessage="Copied" /> : <FormattedMessage defaultMessage="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>
|
||||
), [props.content, classes, intl]);
|
||||
|
||||
return elem;
|
||||
}
|
295
app/src/components/message.tsx
Normal file
295
app/src/components/message.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { Button, CopyButton, Loader, Textarea } from '@mantine/core';
|
||||
|
||||
import { Message } from "../types";
|
||||
import { share } from '../utils';
|
||||
import { ElevenLabsReaderButton } from '../tts/elevenlabs';
|
||||
import { Markdown } from './markdown';
|
||||
import { useAppContext } from '../context';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
// hide for everyone but screen readers
|
||||
const SROnly = styled.span`
|
||||
position: fixed;
|
||||
left: -9999px;
|
||||
top: -9999px;
|
||||
`;
|
||||
|
||||
const Container = styled.div`
|
||||
&.by-user {
|
||||
background: #22232b;
|
||||
}
|
||||
|
||||
&.by-assistant {
|
||||
background: #292933;
|
||||
}
|
||||
|
||||
&.by-assistant + &.by-assistant, &.by-user + &.by-user {
|
||||
border-top: 0.2rem dotted rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&.by-assistant {
|
||||
border-bottom: 0.2rem solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
position: relative;
|
||||
padding: 1.618rem;
|
||||
|
||||
@media (max-width: 40em) {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.inner {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.content {
|
||||
font-family: "Open Sans", sans-serif;
|
||||
margin-top: 0rem;
|
||||
max-width: 100%;
|
||||
|
||||
* {
|
||||
color: white;
|
||||
}
|
||||
|
||||
p, ol, ul, li, h1, h2, h3, h4, h5, h6, img, blockquote, &>pre {
|
||||
max-width: 50rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
max-width: 50rem;
|
||||
|
||||
@media (max-width: 50rem) {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
ol {
|
||||
counter-reset: list-item;
|
||||
|
||||
li {
|
||||
counter-increment: list-item;
|
||||
}
|
||||
}
|
||||
|
||||
em, i {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
code {
|
||||
&, * {
|
||||
font-family: "Fira Code", monospace !important;
|
||||
}
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
margin-top: 1.618rem;
|
||||
border-spacing: 0px;
|
||||
border-collapse: collapse;
|
||||
border: thin solid rgba(255, 255, 255, 0.1);
|
||||
width: 100%;
|
||||
max-width: 55rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
td + td, th + th {
|
||||
border-left: thin solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
tr {
|
||||
border-top: thin solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
table td,
|
||||
table th {
|
||||
padding: 0.618rem 1rem;
|
||||
}
|
||||
th {
|
||||
font-weight: 600;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.metadata {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
font-family: "Work Sans", sans-serif;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
opacity: 0.6;
|
||||
max-width: 50rem;
|
||||
margin-bottom: 0.0rem;
|
||||
margin-right: -0.5rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
span + span {
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
.fa {
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
.fa + span {
|
||||
margin-left: 0.2em;
|
||||
}
|
||||
|
||||
.mantine-Button-root {
|
||||
color: #ccc;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
|
||||
.mantine-Button-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fa {
|
||||
margin-right: 0.5em;
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
`;
|
||||
|
||||
const EndOfChatMarker = styled.div`
|
||||
position: absolute;
|
||||
bottom: calc(-1.618rem - 0.5rem);
|
||||
left: 50%;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
margin-left: -0.25rem;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
`;
|
||||
|
||||
const Editor = styled.div`
|
||||
max-width: 50rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin-top: 0.5rem;
|
||||
|
||||
.mantine-Button-root {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
`;
|
||||
|
||||
function InlineLoader() {
|
||||
return (
|
||||
<Loader variant="dots" size="xs" style={{
|
||||
marginLeft: '1rem',
|
||||
position: 'relative',
|
||||
top: '-0.2rem',
|
||||
}} />
|
||||
);
|
||||
}
|
||||
|
||||
export default function MessageComponent(props: { message: Message, last: boolean, share?: boolean }) {
|
||||
const context = useAppContext();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [content, setContent] = useState('');
|
||||
const intl = useIntl();
|
||||
|
||||
const getRoleName = useCallback((role: string, share = false) => {
|
||||
switch (role) {
|
||||
case 'user':
|
||||
if (share) {
|
||||
return intl.formatMessage({ id: 'role-user-formal', defaultMessage: 'User' });
|
||||
} else {
|
||||
return intl.formatMessage({ id: 'role-user', defaultMessage: 'You' });
|
||||
}
|
||||
break;
|
||||
case 'assistant':
|
||||
return intl.formatMessage({ id: 'role-chatgpt', defaultMessage: 'ChatGPT' });
|
||||
case 'system':
|
||||
return intl.formatMessage({ id: 'role-system', defaultMessage: 'System' });
|
||||
default:
|
||||
return role;
|
||||
}
|
||||
}, [intl]);
|
||||
|
||||
const elem = useMemo(() => {
|
||||
if (props.message.role === 'system') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className={"message by-" + props.message.role}>
|
||||
<div className="inner">
|
||||
<div className="metadata">
|
||||
<span>
|
||||
<strong>
|
||||
{getRoleName(props.message.role, props.share)}<SROnly>:</SROnly>
|
||||
</strong>
|
||||
{props.message.role === 'assistant' && props.last && !props.message.done && <InlineLoader />}
|
||||
</span>
|
||||
{props.message.done && <ElevenLabsReaderButton selector={'.content-' + props.message.id} />}
|
||||
<div style={{ flexGrow: 1 }} />
|
||||
<CopyButton value={props.message.content}>
|
||||
{({ copy, copied }) => (
|
||||
<Button variant="subtle" size="sm" compact onClick={copy} style={{ marginLeft: '1rem' }}>
|
||||
<i className="fa fa-clipboard" />
|
||||
<span>{copied ? <FormattedMessage defaultMessage="Copied" /> : <FormattedMessage defaultMessage="Copy" />}</span>
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
{typeof navigator.share !== 'undefined' && (
|
||||
<Button variant="subtle" size="sm" compact onClick={() => share(props.message.content)}>
|
||||
<i className="fa fa-share" />
|
||||
<span>
|
||||
<FormattedMessage defaultMessage="Share" />
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{!context.isShare && props.message.role === 'user' && (
|
||||
<Button variant="subtle" size="sm" compact onClick={() => {
|
||||
setContent(props.message.content);
|
||||
setEditing(v => !v);
|
||||
}}>
|
||||
<i className="fa fa-edit" />
|
||||
<span>{editing ? <FormattedMessage defaultMessage="Cancel" /> : <FormattedMessage defaultMessage="Edit" />}</span>
|
||||
</Button>
|
||||
)}
|
||||
{!context.isShare && props.message.role === 'assistant' && (
|
||||
<Button variant="subtle" size="sm" compact onClick={() => context.regenerateMessage(props.message)}>
|
||||
<i className="fa fa-refresh" />
|
||||
<span>
|
||||
<FormattedMessage defaultMessage="Regenerate" />
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!editing && <Markdown content={props.message.content} className={"content content-" + props.message.id} />}
|
||||
{editing && (<Editor>
|
||||
<Textarea value={content}
|
||||
onChange={e => setContent(e.currentTarget.value)}
|
||||
autosize={true} />
|
||||
<Button variant="light" onClick={() => context.editMessage(props.message, content)}>
|
||||
<FormattedMessage defaultMessage="Save changes" />
|
||||
</Button>
|
||||
<Button variant="subtle" onClick={() => setEditing(false)}>
|
||||
<FormattedMessage defaultMessage="Cancel" />
|
||||
</Button>
|
||||
</Editor>)}
|
||||
</div>
|
||||
{props.last && <EndOfChatMarker />}
|
||||
</Container>
|
||||
)
|
||||
}, [props.last, props.share, editing, content, context, props.message, props.message.content]);
|
||||
|
||||
return elem;
|
||||
}
|
90
app/src/components/page.tsx
Normal file
90
app/src/components/page.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { SpotlightProvider } from '@mantine/spotlight';
|
||||
import { useChatSpotlightProps } from '../spotlight';
|
||||
import { LoginModal, CreateAccountModal } from './auth/modals';
|
||||
import Header, { HeaderProps, SubHeader } from './header';
|
||||
import MessageInput from './input';
|
||||
import SettingsDrawer from './settings';
|
||||
import Sidebar from './sidebar';
|
||||
|
||||
const Container = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: #292933;
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow: hidden;
|
||||
|
||||
.sidebar {
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
background: #303038;
|
||||
flex-shrink: 0;
|
||||
|
||||
@media (min-width: 40em) {
|
||||
transition: width 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
&.opened {
|
||||
width: 33.33%;
|
||||
|
||||
@media (max-width: 40em) {
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 50em) {
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
@media (min-width: 60em) {
|
||||
width: 20%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 40em) {
|
||||
.sidebar.opened + div {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Main = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
export function Page(props: {
|
||||
id: string;
|
||||
headerProps?: HeaderProps;
|
||||
showSubHeader?: boolean;
|
||||
children: any;
|
||||
}) {
|
||||
const spotlightProps = useChatSpotlightProps();
|
||||
|
||||
return <SpotlightProvider {...spotlightProps}>
|
||||
<Container>
|
||||
<Sidebar />
|
||||
<Main 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 key={localStorage.getItem('openai-api-key')} />
|
||||
<SettingsDrawer />
|
||||
<LoginModal />
|
||||
<CreateAccountModal />
|
||||
</Main>
|
||||
</Container>
|
||||
</SpotlightProvider>;
|
||||
}
|
||||
|
79
app/src/components/pages/about.tsx
Normal file
79
app/src/components/pages/about.tsx
Normal file
@@ -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>;
|
||||
}
|
100
app/src/components/pages/chat.tsx
Normal file
100
app/src/components/pages/chat.tsx
Normal file
@@ -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, props.share]);
|
||||
|
||||
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.current?.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>;
|
||||
}
|
39
app/src/components/pages/landing.tsx
Normal file
39
app/src/components/pages/landing.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { Button } from '@mantine/core';
|
||||
import { useCallback } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { useAppDispatch, useAppSelector } from '../../store';
|
||||
import { selectOpenAIApiKey } from '../../store/api-keys';
|
||||
import { openOpenAIApiKeyPanel } from '../../store/settings-ui';
|
||||
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 openAIApiKey = useAppSelector(selectOpenAIApiKey);
|
||||
const dispatch = useAppDispatch();
|
||||
const onConnectButtonClick = useCallback(() => dispatch(openOpenAIApiKeyPanel()), [dispatch]);
|
||||
|
||||
return <Page id={'landing'} showSubHeader={true}>
|
||||
<Container>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage={'Hello, how can I help you today?'} />
|
||||
</p>
|
||||
{!openAIApiKey && (
|
||||
<Button size="xs" variant="light" compact onClick={onConnectButtonClick}>
|
||||
<FormattedMessage defaultMessage={'Connect your OpenAI account to get started'} />
|
||||
</Button>
|
||||
)}
|
||||
</Container>
|
||||
</Page>;
|
||||
}
|
111
app/src/components/settings/index.tsx
Normal file
111
app/src/components/settings/index.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { Button, Drawer, Tabs } from "@mantine/core";
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { useCallback } from 'react';
|
||||
import UserOptionsTab from './user';
|
||||
import GenerationOptionsTab from './options';
|
||||
import { useAppDispatch, useAppSelector } from '../../store';
|
||||
import { closeSettingsUI, selectSettingsTab, setTab } from '../../store/settings-ui';
|
||||
import SpeechOptionsTab from './speech';
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export interface SettingsDrawerProps {
|
||||
}
|
||||
|
||||
export default function SettingsDrawer(props: SettingsDrawerProps) {
|
||||
const tab = useAppSelector(selectSettingsTab);
|
||||
const small = useMediaQuery('(max-width: 40em)');
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const close = useCallback(() => dispatch(closeSettingsUI()), [dispatch]);
|
||||
const onTabChange = useCallback((tab: string) => dispatch(setTab(tab)), [dispatch]);
|
||||
|
||||
return (
|
||||
<Drawer size="50rem"
|
||||
position='right'
|
||||
opened={!!tab}
|
||||
onClose={close}
|
||||
transition="slide-left"
|
||||
transitionDuration={200}
|
||||
withCloseButton={false}>
|
||||
<Container>
|
||||
<Tabs value={tab} onTabChange={onTabChange} 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>
|
||||
<UserOptionsTab />
|
||||
<GenerationOptionsTab />
|
||||
<SpeechOptionsTab />
|
||||
</Tabs>
|
||||
<div id="save">
|
||||
<Button variant="light" fullWidth size="md" onClick={close}>
|
||||
Save and Close
|
||||
</Button>
|
||||
</div>
|
||||
</Container>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
13
app/src/components/settings/option.tsx
Normal file
13
app/src/components/settings/option.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
export default function SettingsOption(props: {
|
||||
focused?: boolean;
|
||||
heading?: string;
|
||||
children?: any;
|
||||
span?: number;
|
||||
}) {
|
||||
return (
|
||||
<section className={props.focused ? 'focused' : ''}>
|
||||
{props.heading && <h3>{props.heading}</h3>}
|
||||
{props.children}
|
||||
</section>
|
||||
);
|
||||
}
|
63
app/src/components/settings/options.tsx
Normal file
63
app/src/components/settings/options.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import SettingsTab from "./tab";
|
||||
import SettingsOption from "./option";
|
||||
import { Button, Slider, Textarea } from "@mantine/core";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { defaultSystemPrompt } from "../../openai";
|
||||
import { useAppDispatch, useAppSelector } from "../../store";
|
||||
import { resetSystemPrompt, selectSystemPrompt, selectTemperature, setSystemPrompt, setTemperature } from "../../store/parameters";
|
||||
import { selectSettingsOption } from "../../store/settings-ui";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
|
||||
export default function GenerationOptionsTab(props: any) {
|
||||
const intl = useIntl();
|
||||
|
||||
const option = useAppSelector(selectSettingsOption);
|
||||
const initialSystemPrompt = useAppSelector(selectSystemPrompt);
|
||||
const temperature = useAppSelector(selectTemperature);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const onSystemPromptChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => dispatch(setSystemPrompt(event.target.value)), [dispatch]);
|
||||
const onResetSystemPrompt = useCallback(() => dispatch(resetSystemPrompt()), [dispatch]);
|
||||
const onTemperatureChange = useCallback((value: number) => dispatch(setTemperature(value)), [dispatch]);
|
||||
|
||||
const resettable = initialSystemPrompt
|
||||
&& (initialSystemPrompt?.trim() !== defaultSystemPrompt.trim());
|
||||
|
||||
const systemPromptOption = useMemo(() => (
|
||||
<SettingsOption heading={intl.formatMessage({ defaultMessage: "System Prompt" })}
|
||||
focused={option === 'system-prompt'}>
|
||||
<Textarea
|
||||
value={initialSystemPrompt || defaultSystemPrompt}
|
||||
onChange={onSystemPromptChange}
|
||||
minRows={5}
|
||||
maxRows={10}
|
||||
autosize />
|
||||
<p style={{ marginBottom: '0.7rem' }}>
|
||||
<FormattedMessage defaultMessage="The System Prompt is shown to ChatGPT by the "System" before your first message. The <code>'{{ datetime }}'</code> tag is automatically replaced by the current date and time."
|
||||
values={{ code: chunk => <code style={{ whiteSpace: 'nowrap' }}>{chunk}</code> }} />
|
||||
</p>
|
||||
{resettable && <Button size="xs" compact variant="light" onClick={onResetSystemPrompt}>
|
||||
<FormattedMessage defaultMessage="Reset to default" />
|
||||
</Button>}
|
||||
</SettingsOption>
|
||||
), [option, initialSystemPrompt, resettable, onSystemPromptChange, onResetSystemPrompt]);
|
||||
|
||||
const temperatureOption = useMemo(() => (
|
||||
<SettingsOption heading={intl.formatMessage({ defaultMessage: "Temperature: {temperature, number, ::.0}", }, { temperature })}
|
||||
focused={option === 'temperature'}>
|
||||
<Slider value={temperature} onChange={onTemperatureChange} step={0.1} min={0} max={1} precision={3} />
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="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>
|
||||
</SettingsOption>
|
||||
), [temperature, option, onTemperatureChange]);
|
||||
|
||||
const elem = useMemo(() => (
|
||||
<SettingsTab name="options">
|
||||
{systemPromptOption}
|
||||
{temperatureOption}
|
||||
</SettingsTab>
|
||||
), [systemPromptOption, temperatureOption]);
|
||||
|
||||
return elem;
|
||||
}
|
81
app/src/components/settings/speech.tsx
Normal file
81
app/src/components/settings/speech.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import SettingsTab from "./tab";
|
||||
import SettingsOption from "./option";
|
||||
import { Button, Select, TextInput } from "@mantine/core";
|
||||
import { useAppDispatch, useAppSelector } from "../../store";
|
||||
import { selectElevenLabsApiKey, setElevenLabsApiKey } from "../../store/api-keys";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { selectVoice, setVoice } from "../../store/voices";
|
||||
import { getVoices } from "../../tts/elevenlabs";
|
||||
import { selectSettingsOption } from "../../store/settings-ui";
|
||||
import { defaultVoiceList } from "../../tts/defaults";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
|
||||
export default function SpeechOptionsTab() {
|
||||
const intl = useIntl();
|
||||
|
||||
const option = useAppSelector(selectSettingsOption);
|
||||
const elevenLabsApiKey = useAppSelector(selectElevenLabsApiKey);
|
||||
const voice = useAppSelector(selectVoice);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const onElevenLabsApiKeyChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => dispatch(setElevenLabsApiKey(event.target.value)), [dispatch]);
|
||||
const onVoiceChange = useCallback((value: string) => dispatch(setVoice(value)), [dispatch]);
|
||||
|
||||
const [voices, setVoices] = useState<any[]>(defaultVoiceList);
|
||||
useEffect(() => {
|
||||
if (elevenLabsApiKey) {
|
||||
getVoices().then(data => {
|
||||
if (data?.voices?.length) {
|
||||
setVoices(data.voices);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [elevenLabsApiKey]);
|
||||
|
||||
const apiKeyOption = useMemo(() => (
|
||||
<SettingsOption heading={intl.formatMessage({ defaultMessage: 'Your ElevenLabs Text-to-Speech API Key (optional)' })}
|
||||
focused={option === 'elevenlabs-api-key'}>
|
||||
<TextInput placeholder={intl.formatMessage({ defaultMessage: "Paste your API key here" })}
|
||||
value={elevenLabsApiKey || ''} onChange={onElevenLabsApiKeyChange} />
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Give ChatGPT a realisic human voice by connecting your ElevenLabs account (preview the available voices below). <a>Click here to sign up.</a>"
|
||||
values={{
|
||||
a: (chunks: any) => <a href="https://beta.elevenlabs.io" target="_blank" rel="noreferrer">{chunks}</a>
|
||||
}} />
|
||||
</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="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>
|
||||
</SettingsOption>
|
||||
), [option, elevenLabsApiKey, onElevenLabsApiKeyChange]);
|
||||
|
||||
const voiceOption = useMemo(() => (
|
||||
<SettingsOption heading={intl.formatMessage({ defaultMessage: 'Voice' })}
|
||||
focused={option === 'elevenlabs-voice'}>
|
||||
<Select
|
||||
value={voice}
|
||||
onChange={onVoiceChange}
|
||||
data={[
|
||||
...voices.map(v => ({ label: v.name, value: v.voice_id })),
|
||||
]} />
|
||||
<audio controls style={{ display: 'none' }} id="voice-preview" key={voice}>
|
||||
<source src={voices.find(v => v.voice_id === voice)?.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>
|
||||
<FormattedMessage defaultMessage="Preview voice" />
|
||||
</span>
|
||||
</Button>
|
||||
</SettingsOption>
|
||||
), [option, voice, voices, onVoiceChange]);
|
||||
|
||||
const elem = useMemo(() => (
|
||||
<SettingsTab name="speech">
|
||||
{apiKeyOption}
|
||||
{voices.length > 0 && voiceOption}
|
||||
</SettingsTab>
|
||||
), [apiKeyOption, voiceOption, voices.length]);
|
||||
|
||||
return elem;
|
||||
}
|
64
app/src/components/settings/tab.tsx
Normal file
64
app/src/components/settings/tab.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import styled from "@emotion/styled";
|
||||
import { Tabs } from "@mantine/core";
|
||||
|
||||
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 default function SettingsTab(props: {
|
||||
name: string;
|
||||
children?: any;
|
||||
}) {
|
||||
return (
|
||||
<Tabs.Panel value={props.name}>
|
||||
<Settings>
|
||||
{props.children}
|
||||
</Settings>
|
||||
</Tabs.Panel>
|
||||
);
|
||||
}
|
42
app/src/components/settings/user.tsx
Normal file
42
app/src/components/settings/user.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import SettingsTab from "./tab";
|
||||
import SettingsOption from "./option";
|
||||
import { TextInput } from "@mantine/core";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useAppDispatch, useAppSelector } from "../../store";
|
||||
import { selectOpenAIApiKey, setOpenAIApiKeyFromEvent } from "../../store/api-keys";
|
||||
import { selectSettingsOption } from "../../store/settings-ui";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
|
||||
export default function UserOptionsTab(props: any) {
|
||||
const option = useAppSelector(selectSettingsOption);
|
||||
const openaiApiKey = useAppSelector(selectOpenAIApiKey);
|
||||
const intl = useIntl()
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const onOpenAIApiKeyChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => dispatch(setOpenAIApiKeyFromEvent(event)), [dispatch]);
|
||||
|
||||
const elem = useMemo(() => (
|
||||
<SettingsTab name="user">
|
||||
<SettingsOption heading={intl.formatMessage({ defaultMessage: "Your OpenAI API Key" })}
|
||||
focused={option === 'openai-api-key'}>
|
||||
<TextInput
|
||||
placeholder={intl.formatMessage({ defaultMessage: "Paste your API key here" })}
|
||||
value={openaiApiKey || ''}
|
||||
onChange={onOpenAIApiKeyChange} />
|
||||
<p>
|
||||
<a href="https://platform.openai.com/account/api-keys" target="_blank" rel="noreferrer">
|
||||
<FormattedMessage defaultMessage="Find your API key here." />
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="Your API key is stored only on this device and never transmitted to anyone except OpenAI." />
|
||||
</p>
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="OpenAI API key usage is billed at a pay-as-you-go rate, separate from your ChatGPT subscription." />
|
||||
</p>
|
||||
</SettingsOption>
|
||||
</SettingsTab>
|
||||
), [option, openaiApiKey, onOpenAIApiKeyChange]);
|
||||
|
||||
return elem;
|
||||
}
|
159
app/src/components/sidebar/index.tsx
Normal file
159
app/src/components/sidebar/index.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { ActionIcon, Avatar, Burger, Button, Menu } from '@mantine/core';
|
||||
import { useElementSize } from '@mantine/hooks';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { backend } from '../../backend';
|
||||
import { useAppContext } from '../../context';
|
||||
import { useAppDispatch, useAppSelector } from '../../store';
|
||||
import { setTab } from '../../store/settings-ui';
|
||||
import { selectSidebarOpen, toggleSidebar } from '../../store/sidebar';
|
||||
import RecentChats from './recent-chats';
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
font-family: "Work Sans", sans-serif;
|
||||
box-shadow: 0px 0px 1rem 0.2rem rgb(0 0 0 / 5%);
|
||||
|
||||
.sidebar-header {
|
||||
padding: 0.5rem 1rem 0.5rem 1.618rem;
|
||||
min-height: 2.618rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
flex-grow: 1;
|
||||
overflow-y: scroll;
|
||||
|
||||
/* hide scrollbars */
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
|
||||
min-width: 20vw;
|
||||
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
border-top: thin solid rgba(255, 255, 255, 0.1);
|
||||
padding: 0.5rem 1.118rem;
|
||||
padding-left: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
|
||||
.user-info {
|
||||
max-width: calc(100% - 1.618rem * 2 - 2.5rem);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
strong, span {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 100;
|
||||
}
|
||||
|
||||
.mantine-Avatar-root {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
min-width: 0;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
margin: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
export default function Sidebar(props: {
|
||||
className?: string;
|
||||
}) {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const sidebarOpen = useAppSelector(selectSidebarOpen);
|
||||
const onBurgerClick = useCallback(() => dispatch(toggleSidebar()), [dispatch]);
|
||||
const { ref, width } = useElementSize();
|
||||
|
||||
const burgerLabel = sidebarOpen
|
||||
? intl.formatMessage({ defaultMessage: "Close sidebar" })
|
||||
: intl.formatMessage({ defaultMessage: "Open sidebar" });
|
||||
|
||||
const elem = useMemo(() => (
|
||||
<Container className={"sidebar " + (sidebarOpen ? 'opened' : 'closed')} ref={ref}>
|
||||
<div className="sidebar-header">
|
||||
<h2>Chat History</h2>
|
||||
<Burger opened={sidebarOpen} onClick={onBurgerClick} aria-label={burgerLabel} transitionDuration={0} />
|
||||
</div>
|
||||
<div className="sidebar-content">
|
||||
<RecentChats />
|
||||
</div>
|
||||
{backend.current && backend.current.isAuthenticated && (
|
||||
<Menu width={width - 20}>
|
||||
<Menu.Target>
|
||||
<div className="sidebar-footer">
|
||||
<Avatar size="lg" src={backend.current!.user!.avatar} />
|
||||
<div className="user-info">
|
||||
<strong>{backend.current!.user!.name || backend.current!.user!.email}</strong>
|
||||
{!!backend.current!.user!.name && <span>{backend.current.user!.email}</span>}
|
||||
</div>
|
||||
<div className="spacer" />
|
||||
|
||||
<ActionIcon variant="subtle">
|
||||
<i className="fas fa-ellipsis" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={() => {
|
||||
dispatch(setTab('user'));
|
||||
}} icon={<i className="fas fa-gear" />}>
|
||||
User settings
|
||||
</Menu.Item>
|
||||
{/*
|
||||
<Menu.Divider />
|
||||
<Menu.Item color="red" onClick={() => backend.current?.logout()} icon={<i className="fas fa-sign-out-alt" />}>
|
||||
Sign out
|
||||
</Menu.Item>
|
||||
*/}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</Container>
|
||||
), [sidebarOpen, width, ref, burgerLabel, onBurgerClick, dispatch]);
|
||||
|
||||
return elem;
|
||||
}
|
95
app/src/components/sidebar/recent-chats.tsx
Normal file
95
app/src/components/sidebar/recent-chats.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAppContext } from '../../context';
|
||||
import { useAppDispatch } from '../../store';
|
||||
import { toggleSidebar } from '../../store/sidebar';
|
||||
|
||||
const Container = styled.div`
|
||||
margin: calc(1.618rem - 1rem);
|
||||
margin-top: -0.218rem;
|
||||
`;
|
||||
|
||||
const Empty = styled.p`
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
padding: 2rem;
|
||||
`;
|
||||
|
||||
const ChatList = styled.div``;
|
||||
|
||||
const ChatListItem = styled(Link)`
|
||||
display: block;
|
||||
padding: 0.4rem 1rem;
|
||||
margin: 0.218rem 0;
|
||||
line-height: 1.7;
|
||||
text-decoration: none;
|
||||
border-radius: 0.25rem;
|
||||
|
||||
&:hover, &:focus, &:active {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: #2b3d54;
|
||||
}
|
||||
|
||||
&, * {
|
||||
color: white;
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
font-weight: 400;
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 200;
|
||||
opacity: 0.8;
|
||||
}
|
||||
`;
|
||||
|
||||
export default function RecentChats(props: any) {
|
||||
const context = useAppContext();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const currentChatID = context.currentChat.chat?.id;
|
||||
const recentChats = context.chat.search.query('');
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
if (window.matchMedia('(max-width: 40em)').matches) {
|
||||
dispatch(toggleSidebar());
|
||||
}
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentChatID) {
|
||||
const el = document.querySelector(`[data-chat-id="${currentChatID}"]`);
|
||||
if (el) {
|
||||
el.scrollIntoView();
|
||||
}
|
||||
}
|
||||
}, [currentChatID]);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{recentChats.length > 0 && <ChatList>
|
||||
{recentChats.map(c => (
|
||||
<ChatListItem key={c.chatID}
|
||||
to={'/chat/' + c.chatID}
|
||||
onClick={onClick}
|
||||
data-chat-id={c.chatID}
|
||||
className={c.chatID === currentChatID ? 'selected' : ''}>
|
||||
<strong>{c.title || 'Untitled'}</strong>
|
||||
</ChatListItem>
|
||||
))}
|
||||
</ChatList>}
|
||||
{recentChats.length === 0 && <Empty>
|
||||
No chats yet.
|
||||
</Empty>}
|
||||
</Container>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user