commit
4a5e8c9e16
|
@ -9,6 +9,7 @@
|
|||
"@mantine/modals": "^5.10.5",
|
||||
"@mantine/notifications": "^5.10.5",
|
||||
"@mantine/spotlight": "^5.10.5",
|
||||
"@reduxjs/toolkit": "^1.9.3",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
|
@ -35,9 +36,11 @@
|
|||
"react-dom": "^18.2.0",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-markdown": "^8.0.5",
|
||||
"react-redux": "^8.0.5",
|
||||
"react-router-dom": "^6.8.2",
|
||||
"react-scripts": "5.0.1",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"redux-persist": "^6.0.0",
|
||||
"rehype-katex": "^6.0.2",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"remark-math": "^5.1.1",
|
||||
|
|
|
@ -2,12 +2,15 @@ import styled from '@emotion/styled';
|
|||
import Helmet from 'react-helmet';
|
||||
import { useSpotlight } from '@mantine/spotlight';
|
||||
import { Button, ButtonProps } from '@mantine/core';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
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';
|
||||
import { useAppDispatch, useAppSelector } from '../store';
|
||||
import { selectOpenAIApiKey } from '../store/api-keys';
|
||||
import { setTab } from '../store/settings-ui';
|
||||
|
||||
const HeaderContainer = styled.div`
|
||||
display: flex;
|
||||
|
@ -129,6 +132,8 @@ export default function Header(props: HeaderProps) {
|
|||
const navigate = useNavigate();
|
||||
const spotlight = useSpotlight();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const openAIApiKey = useAppSelector(selectOpenAIApiKey);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const onNewChat = useCallback(async () => {
|
||||
setLoading(true);
|
||||
|
@ -137,33 +142,37 @@ export default function Header(props: HeaderProps) {
|
|||
}, [navigate]);
|
||||
|
||||
const openSettings = useCallback(() => {
|
||||
context.settings.open(context.apiKeys.openai ? 'options' : 'user');
|
||||
}, [context]);
|
||||
dispatch(setTab(openAIApiKey ? 'options' : 'user'));
|
||||
}, [dispatch, openAIApiKey]);
|
||||
|
||||
return <HeaderContainer>
|
||||
<Helmet>
|
||||
<title>{props.title ? `${props.title} - ` : ''}{APP_NAME} - Unofficial ChatGPT app</title>
|
||||
</Helmet>
|
||||
{props.title && <h1>{props.title}</h1>}
|
||||
{!props.title && (<h1>
|
||||
<div>
|
||||
<strong>{APP_NAME}</strong><br />
|
||||
<span>An unofficial ChatGPT app</span>
|
||||
</div>
|
||||
</h1>)}
|
||||
<div className="spacer" />
|
||||
<HeaderButton icon="search" onClick={spotlight.openSpotlight} />
|
||||
<HeaderButton icon="gear" onClick={openSettings} />
|
||||
{backend && !props.share && props.canShare && typeof navigator.share !== 'undefined' && <HeaderButton icon="share" onClick={props.onShare}>
|
||||
Share
|
||||
</HeaderButton>}
|
||||
{backend && !context.authenticated && (
|
||||
<HeaderButton onClick={() => backend.current?.signIn()}>Sign in <span className="hide-on-mobile">to sync</span></HeaderButton>
|
||||
)}
|
||||
<HeaderButton icon="plus" onClick={onNewChat} loading={loading} variant="light">
|
||||
New Chat
|
||||
</HeaderButton>
|
||||
</HeaderContainer>;
|
||||
const header = useMemo(() => (
|
||||
<HeaderContainer>
|
||||
<Helmet>
|
||||
<title>{props.title ? `${props.title} - ` : ''}{APP_NAME} - Unofficial ChatGPT app</title>
|
||||
</Helmet>
|
||||
{props.title && <h1>{props.title}</h1>}
|
||||
{!props.title && (<h1>
|
||||
<div>
|
||||
<strong>{APP_NAME}</strong><br />
|
||||
<span>An unofficial ChatGPT app</span>
|
||||
</div>
|
||||
</h1>)}
|
||||
<div className="spacer" />
|
||||
<HeaderButton icon="search" onClick={spotlight.openSpotlight} />
|
||||
<HeaderButton icon="gear" onClick={openSettings} />
|
||||
{backend && !props.share && props.canShare && typeof navigator.share !== 'undefined' && <HeaderButton icon="share" onClick={props.onShare}>
|
||||
Share
|
||||
</HeaderButton>}
|
||||
{backend && !context.authenticated && (
|
||||
<HeaderButton onClick={() => backend.current?.signIn()}>Sign in <span className="hide-on-mobile">to sync</span></HeaderButton>
|
||||
)}
|
||||
<HeaderButton icon="plus" onClick={onNewChat} loading={loading} variant="light">
|
||||
New Chat
|
||||
</HeaderButton>
|
||||
</HeaderContainer>
|
||||
), [props.title, props.share, props.canShare, props.onShare, openSettings, onNewChat, loading, context.authenticated, spotlight.openSpotlight]);
|
||||
|
||||
return header;
|
||||
}
|
||||
|
||||
function SubHeaderMenuItem(props: { item: MenuItem }) {
|
||||
|
@ -176,9 +185,13 @@ function SubHeaderMenuItem(props: { item: MenuItem }) {
|
|||
}
|
||||
|
||||
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>;
|
||||
const elem = useMemo(() => (
|
||||
<SubHeaderContainer>
|
||||
{primaryMenu.map(item => <SubHeaderMenuItem item={item} key={item.link} />)}
|
||||
<div className="spacer" />
|
||||
{secondaryMenu.map(item => <SubHeaderMenuItem item={item} key={item.link} />)}
|
||||
</SubHeaderContainer>
|
||||
), []);
|
||||
|
||||
return elem;
|
||||
}
|
|
@ -3,6 +3,10 @@ import { Button, ActionIcon, Textarea } from '@mantine/core';
|
|||
import { useCallback, useMemo } from 'react';
|
||||
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;
|
||||
|
@ -40,18 +44,25 @@ export interface MessageInputProps {
|
|||
}
|
||||
|
||||
export default function MessageInput(props: MessageInputProps) {
|
||||
const temperature = useAppSelector(selectTemperature);
|
||||
const message = useAppSelector(selectMessage);
|
||||
|
||||
const context = useAppContext();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
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 onChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
context.setMessage(e.target.value);
|
||||
}, [context]);
|
||||
|
||||
const onSubmit = useCallback(async () => {
|
||||
if (await context.onNewMessage(context.message)) {
|
||||
context.setMessage('');
|
||||
if (await context.onNewMessage(message)) {
|
||||
dispatch(setMessage(''));
|
||||
}
|
||||
}, [context]);
|
||||
}, [context, message, dispatch]);
|
||||
|
||||
const onKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && e.shiftKey === false && !props.disabled) {
|
||||
|
@ -71,9 +82,6 @@ export default function MessageInput(props: MessageInputProps) {
|
|||
);
|
||||
}, [onSubmit, props.disabled]);
|
||||
|
||||
const openSystemPromptPanel = useCallback(() => context.settings.open('options', 'system-prompt'), [context.settings]);
|
||||
const openTemperaturePanel = useCallback(() => context.settings.open('options', 'temperature'), [context.settings]);
|
||||
|
||||
const messagesToDisplay = context.currentChat.messagesToDisplay;
|
||||
const disabled = context.generating
|
||||
|| messagesToDisplay[messagesToDisplay.length - 1]?.role === 'user'
|
||||
|
@ -83,7 +91,7 @@ export default function MessageInput(props: MessageInputProps) {
|
|||
if (context.isShare || (!isLandingPage && !context.id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return <Container>
|
||||
<div className="inner">
|
||||
<Textarea disabled={props.disabled || disabled}
|
||||
|
@ -91,7 +99,7 @@ export default function MessageInput(props: MessageInputProps) {
|
|||
minRows={3}
|
||||
maxRows={12}
|
||||
placeholder={"Enter a message here..."}
|
||||
value={context.message}
|
||||
value={message}
|
||||
onChange={onChange}
|
||||
rightSection={rightSection}
|
||||
onKeyDown={onKeyDown} />
|
||||
|
@ -100,15 +108,15 @@ export default function MessageInput(props: MessageInputProps) {
|
|||
className="settings-button"
|
||||
size="xs"
|
||||
compact
|
||||
onClick={openSystemPromptPanel}>
|
||||
onClick={onCustomizeSystemPromptClick}>
|
||||
<span>Customize system prompt</span>
|
||||
</Button>
|
||||
<Button variant="subtle"
|
||||
className="settings-button"
|
||||
size="xs"
|
||||
compact
|
||||
onClick={openTemperaturePanel}>
|
||||
<span>Temperature: {context.parameters.temperature.toFixed(1)}</span>
|
||||
onClick={onTemperatureClick}>
|
||||
<span>Temperature: {temperature.toFixed(1)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -5,6 +5,7 @@ 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';
|
||||
|
||||
export interface MarkdownProps {
|
||||
content: string;
|
||||
|
@ -12,13 +13,17 @@ export interface MarkdownProps {
|
|||
}
|
||||
|
||||
export function Markdown(props: MarkdownProps) {
|
||||
const classes = ['prose', 'dark:prose-invert'];
|
||||
|
||||
if (props.className) {
|
||||
classes.push(props.className);
|
||||
}
|
||||
const classes = useMemo(() => {
|
||||
const classes = ['prose', 'dark:prose-invert'];
|
||||
|
||||
return (
|
||||
if (props.className) {
|
||||
classes.push(props.className);
|
||||
}
|
||||
|
||||
return classes;
|
||||
}, [props.className])
|
||||
|
||||
const elem = useMemo(() => (
|
||||
<div className={classes.join(' ')}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
|
@ -51,5 +56,7 @@ export function Markdown(props: MarkdownProps) {
|
|||
}
|
||||
}}>{props.content}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
), [props.content, classes]);
|
||||
|
||||
return elem;
|
||||
}
|
|
@ -1,12 +1,12 @@
|
|||
import styled from '@emotion/styled';
|
||||
import { Button, CopyButton, Loader, Textarea } from '@mantine/core';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Message } from "../types";
|
||||
import { share } from '../utils';
|
||||
import { ElevenLabsReaderButton } from '../elevenlabs';
|
||||
import { ElevenLabsReaderButton } from '../tts/elevenlabs';
|
||||
import { Markdown } from './markdown';
|
||||
import { useAppContext } from '../context';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
// hide for everyone but screen readers
|
||||
const SROnly = styled.span`
|
||||
|
@ -210,60 +210,66 @@ export default function MessageComponent(props: { message: Message, last: boolea
|
|||
const [editing, setEditing] = useState(false);
|
||||
const [content, setContent] = useState('');
|
||||
|
||||
if (props.message.role === 'system') {
|
||||
return null;
|
||||
}
|
||||
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 ? 'Copied' : '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>Share</span>
|
||||
</Button>
|
||||
)}
|
||||
{!context.isShare && props.message.role === 'user' && (
|
||||
<Button variant="subtle" size="sm" compact onClick={() => {
|
||||
setContent(props.message.content);
|
||||
setEditing(true);
|
||||
}}>
|
||||
<i className="fa fa-edit" />
|
||||
<span>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>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)}>Save changes</Button>
|
||||
<Button variant="subtle" onClick={() => setEditing(false)}>Cancel</Button>
|
||||
</Editor>)}
|
||||
</div>
|
||||
{props.last && <EndOfChatMarker />}
|
||||
</Container>
|
||||
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 ? 'Copied' : '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>Share</span>
|
||||
</Button>
|
||||
)}
|
||||
{!context.isShare && props.message.role === 'user' && (
|
||||
<Button variant="subtle" size="sm" compact onClick={() => {
|
||||
setContent(props.message.content);
|
||||
setEditing(true);
|
||||
}}>
|
||||
<i className="fa fa-edit" />
|
||||
<span>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>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)}>Save changes</Button>
|
||||
<Button variant="subtle" onClick={() => setEditing(false)}>Cancel</Button>
|
||||
</Editor>)}
|
||||
</div>
|
||||
{props.last && <EndOfChatMarker />}
|
||||
</Container>
|
||||
)
|
||||
}, [props.last, props.share, editing, content, context, props.message]);
|
||||
|
||||
return elem;
|
||||
}
|
|
@ -34,7 +34,7 @@ export function Page(props: {
|
|||
onShare={props.headerProps?.onShare} />
|
||||
{props.showSubHeader && <SubHeader />}
|
||||
{props.children}
|
||||
<MessageInput />
|
||||
<MessageInput key={localStorage.getItem('openai-api-key')} />
|
||||
<SettingsDrawer />
|
||||
</Container>
|
||||
</SpotlightProvider>;
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
import styled from '@emotion/styled';
|
||||
import { Button } from '@mantine/core';
|
||||
import { useAppContext } from '../../context';
|
||||
import { useCallback } from 'react';
|
||||
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`
|
||||
|
@ -16,13 +19,15 @@ const Container = styled.div`
|
|||
`;
|
||||
|
||||
export default function LandingPage(props: any) {
|
||||
const context = useAppContext();
|
||||
const openAIApiKey = useAppSelector(selectOpenAIApiKey);
|
||||
const dispatch = useAppDispatch();
|
||||
const onConnectButtonClick = useCallback(() => dispatch(openOpenAIApiKeyPanel()), [dispatch]);
|
||||
|
||||
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')}>
|
||||
{!openAIApiKey && (
|
||||
<Button size="xs" variant="light" compact onClick={onConnectButtonClick}>
|
||||
Connect your OpenAI account to get started
|
||||
</Button>
|
||||
)}
|
||||
|
|
|
@ -1,246 +0,0 @@
|
|||
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" rel="noreferrer">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" rel="noreferrer">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>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,113 @@
|
|||
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]);
|
||||
|
||||
if (!tab) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer size="50rem"
|
||||
position='right'
|
||||
opened={!!tab}
|
||||
onClose={close}
|
||||
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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
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";
|
||||
|
||||
export default function GenerationOptionsTab(props: any) {
|
||||
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="System Prompt" focused={option === 'system-prompt'}>
|
||||
<Textarea
|
||||
value={initialSystemPrompt || defaultSystemPrompt}
|
||||
onChange={onSystemPromptChange}
|
||||
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>
|
||||
{resettable && <Button size="xs" compact variant="light" onClick={onResetSystemPrompt}>
|
||||
Reset to default
|
||||
</Button>}
|
||||
</SettingsOption>
|
||||
), [option, initialSystemPrompt, resettable, onSystemPromptChange, onResetSystemPrompt]);
|
||||
|
||||
const temperatureOption = useMemo(() => (
|
||||
<SettingsOption heading={`Temperature (${temperature.toFixed(1)})`} focused={option === 'temperature'}>
|
||||
<Slider value={temperature} onChange={onTemperatureChange} 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>
|
||||
</SettingsOption>
|
||||
), [temperature, option, onTemperatureChange]);
|
||||
|
||||
const elem = useMemo(() => (
|
||||
<SettingsTab name="options">
|
||||
{systemPromptOption}
|
||||
{temperatureOption}
|
||||
</SettingsTab>
|
||||
), [systemPromptOption, temperatureOption]);
|
||||
|
||||
return elem;
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
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";
|
||||
|
||||
export default function SpeechOptionsTab() {
|
||||
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='Your ElevenLabs Text-to-Speech API Key (optional)' focused={option === 'elevenlabs-api-key'}>
|
||||
<TextInput placeholder="Paste your API key here" value={elevenLabsApiKey || ''} onChange={onElevenLabsApiKeyChange} />
|
||||
<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" rel="noreferrer">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>
|
||||
</SettingsOption>
|
||||
), [option, elevenLabsApiKey, onElevenLabsApiKeyChange]);
|
||||
|
||||
const voiceOption = useMemo(() => (
|
||||
<SettingsOption heading='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>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;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
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";
|
||||
|
||||
export default function UserOptionsTab(props: any) {
|
||||
const option = useAppSelector(selectSettingsOption);
|
||||
const openaiApiKey = useAppSelector(selectOpenAIApiKey);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const onOpenAIApiKeyChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => dispatch(setOpenAIApiKeyFromEvent(event)), [dispatch]);
|
||||
|
||||
const elem = useMemo(() => (
|
||||
<SettingsTab name="user">
|
||||
<SettingsOption heading="Your OpenAI API Key" focused={option === 'openai-api-key'}>
|
||||
<TextInput
|
||||
placeholder="Paste your API key here"
|
||||
value={openaiApiKey || ''}
|
||||
onChange={onOpenAIApiKeyChange} />
|
||||
<p><a href="https://platform.openai.com/account/api-keys" target="_blank" rel="noreferrer">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>
|
||||
</SettingsOption>
|
||||
</SettingsTab>
|
||||
), [option, openaiApiKey, onOpenAIApiKeyChange]);
|
||||
|
||||
return elem;
|
||||
}
|
129
src/context.tsx
129
src/context.tsx
|
@ -1,11 +1,10 @@
|
|||
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 { Message, Parameters } from "./types";
|
||||
import store, { useAppDispatch } from "./store";
|
||||
import { openOpenAIApiKeyPanel } from "./store/settings-ui";
|
||||
import { Message } from "./types";
|
||||
import { useChat, UseChatResult } from "./use-chat";
|
||||
|
||||
export interface Context {
|
||||
|
@ -14,27 +13,7 @@ export interface Context {
|
|||
id: string | undefined | null;
|
||||
currentChat: UseChatResult;
|
||||
isShare: boolean;
|
||||
apiKeys: {
|
||||
openai: string | undefined | null;
|
||||
setOpenAIApiKey: (apiKey: string | null) => void;
|
||||
elevenlabs: string | undefined | null;
|
||||
setElevenLabsApiKey: (apiKey: string | null) => void;
|
||||
};
|
||||
settings: {
|
||||
tab: string | undefined | null;
|
||||
option: string | undefined | null;
|
||||
open: (tab: string, option?: string | undefined | null) => void;
|
||||
close: () => void;
|
||||
};
|
||||
voice: {
|
||||
id: string;
|
||||
setVoiceID: (id: string) => void;
|
||||
};
|
||||
generating: boolean;
|
||||
message: string;
|
||||
parameters: Parameters;
|
||||
setMessage: (message: string, parentID?: string) => void;
|
||||
setParameters: (parameters: Parameters) => void;
|
||||
onNewMessage: (message?: string) => Promise<boolean>;
|
||||
regenerateMessage: (message: Message) => Promise<boolean>;
|
||||
editMessage: (message: Message, content: string) => Promise<boolean>;
|
||||
|
@ -44,6 +23,8 @@ const AppContext = React.createContext<Context>({} as any);
|
|||
|
||||
export function useCreateAppContext(): Context {
|
||||
const { id } = useParams();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const pathname = useLocation().pathname;
|
||||
const isShare = pathname.startsWith('/s/');
|
||||
const navigate = useNavigate();
|
||||
|
@ -61,51 +42,8 @@ export function useCreateAppContext(): Context {
|
|||
};
|
||||
}, [updateAuth]);
|
||||
|
||||
const [openaiApiKey, setOpenAIApiKey] = useState<string | null>(
|
||||
localStorage.getItem('openai-api-key') || ''
|
||||
);
|
||||
const [elevenLabsApiKey, setElevenLabsApiKey] = useState<string | null>(
|
||||
localStorage.getItem('elevenlabs-api-key') || ''
|
||||
);
|
||||
|
||||
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>();
|
||||
const [option, setOption] = useState<string | null | undefined>();
|
||||
|
||||
const [voiceID, setVoiceID] = useState(localStorage.getItem('voice-id') || defaultElevenLabsVoiceID);
|
||||
|
||||
useEffect(() => {
|
||||
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;
|
||||
|
@ -115,14 +53,17 @@ export function useCreateAppContext(): Context {
|
|||
return false;
|
||||
}
|
||||
|
||||
const openaiApiKey = store.getState().apiKeys.openAIApiKey;
|
||||
|
||||
if (!openaiApiKey) {
|
||||
setSettingsTab('user');
|
||||
setOption('openai-api-key');
|
||||
dispatch(openOpenAIApiKeyPanel());
|
||||
return false;
|
||||
}
|
||||
|
||||
setGenerating(true);
|
||||
|
||||
const parameters = store.getState().parameters;
|
||||
|
||||
if (id) {
|
||||
await chatManager.current.sendMessage({
|
||||
chatID: id,
|
||||
|
@ -150,21 +91,24 @@ export function useCreateAppContext(): Context {
|
|||
setTimeout(() => setGenerating(false), 4000);
|
||||
|
||||
return true;
|
||||
}, [chatManager, openaiApiKey, id, parameters, currentChat.leaf, navigate, isShare]);
|
||||
}, [dispatch, chatManager, id, currentChat.leaf, navigate, isShare]);
|
||||
|
||||
const regenerateMessage = useCallback(async (message: Message) => {
|
||||
if (isShare) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const openaiApiKey = store.getState().apiKeys.openAIApiKey;
|
||||
|
||||
if (!openaiApiKey) {
|
||||
setSettingsTab('user');
|
||||
setOption('openai-api-key');
|
||||
dispatch(openOpenAIApiKeyPanel());
|
||||
return false;
|
||||
}
|
||||
|
||||
setGenerating(true);
|
||||
|
||||
const parameters = store.getState().parameters;
|
||||
|
||||
await chatManager.current.regenerate(message, {
|
||||
...parameters,
|
||||
apiKey: openaiApiKey,
|
||||
|
@ -173,7 +117,7 @@ export function useCreateAppContext(): Context {
|
|||
setTimeout(() => setGenerating(false), 4000);
|
||||
|
||||
return true;
|
||||
}, [chatManager, openaiApiKey, parameters, isShare]);
|
||||
}, [dispatch, chatManager, isShare]);
|
||||
|
||||
const editMessage = useCallback(async (message: Message, content: string) => {
|
||||
if (isShare) {
|
||||
|
@ -184,14 +128,17 @@ export function useCreateAppContext(): Context {
|
|||
return false;
|
||||
}
|
||||
|
||||
const openaiApiKey = store.getState().apiKeys.openAIApiKey;
|
||||
|
||||
if (!openaiApiKey) {
|
||||
setSettingsTab('user');
|
||||
setOption('openai-api-key');
|
||||
dispatch(openOpenAIApiKeyPanel());
|
||||
return false;
|
||||
}
|
||||
|
||||
setGenerating(true);
|
||||
|
||||
const parameters = store.getState().parameters;
|
||||
|
||||
if (id) {
|
||||
await chatManager.current.sendMessage({
|
||||
chatID: id,
|
||||
|
@ -219,7 +166,7 @@ export function useCreateAppContext(): Context {
|
|||
setTimeout(() => setGenerating(false), 4000);
|
||||
|
||||
return true;
|
||||
}, [chatManager, openaiApiKey, id, parameters, isShare, navigate]);
|
||||
}, [dispatch, chatManager, id, isShare, navigate]);
|
||||
|
||||
const context = useMemo<Context>(() => ({
|
||||
authenticated,
|
||||
|
@ -227,39 +174,11 @@ export function useCreateAppContext(): Context {
|
|||
chat: chatManager.current,
|
||||
currentChat,
|
||||
isShare,
|
||||
apiKeys: {
|
||||
openai: openaiApiKey,
|
||||
elevenlabs: elevenLabsApiKey,
|
||||
setOpenAIApiKey,
|
||||
setElevenLabsApiKey,
|
||||
},
|
||||
settings: {
|
||||
tab: settingsTab,
|
||||
option: option,
|
||||
open: (tab: string, option?: string | undefined | null) => {
|
||||
setSettingsTab(tab);
|
||||
setOption(option);
|
||||
},
|
||||
close: () => {
|
||||
setSettingsTab(null);
|
||||
setOption(null);
|
||||
},
|
||||
},
|
||||
voice: {
|
||||
id: voiceID,
|
||||
setVoiceID,
|
||||
},
|
||||
generating,
|
||||
message,
|
||||
parameters,
|
||||
setMessage,
|
||||
setParameters,
|
||||
onNewMessage,
|
||||
regenerateMessage,
|
||||
editMessage,
|
||||
}), [chatManager, authenticated, openaiApiKey, elevenLabsApiKey, settingsTab, option, voiceID,
|
||||
generating, message, parameters, onNewMessage, regenerateMessage, editMessage, currentChat,
|
||||
id, isShare]);
|
||||
}), [chatManager, authenticated, generating, onNewMessage, regenerateMessage, editMessage, currentChat, id, isShare]);
|
||||
|
||||
return context;
|
||||
}
|
||||
|
|
|
@ -6,7 +6,10 @@ import {
|
|||
} from "react-router-dom";
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { ModalsProvider } from '@mantine/modals';
|
||||
import { Provider } from 'react-redux';
|
||||
import { PersistGate } from 'redux-persist/integration/react';
|
||||
import { AppContextProvider } from './context';
|
||||
import store, { persistor } from './store';
|
||||
import LandingPage from './components/pages/landing';
|
||||
import ChatPage from './components/pages/chat';
|
||||
import AboutPage from './components/pages/about';
|
||||
|
@ -53,9 +56,13 @@ const root = ReactDOM.createRoot(
|
|||
root.render(
|
||||
<React.StrictMode>
|
||||
<MantineProvider theme={{ colorScheme: "dark" }}>
|
||||
<ModalsProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ModalsProvider>
|
||||
<Provider store={store}>
|
||||
<PersistGate loading={null} persistor={persistor}>
|
||||
<ModalsProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ModalsProvider>
|
||||
</PersistGate>
|
||||
</Provider>
|
||||
</MantineProvider>
|
||||
</React.StrictMode>
|
||||
);
|
|
@ -16,6 +16,10 @@ export function createNode(message: Message): Node {
|
|||
export class MessageTree {
|
||||
public nodes: Map<string, Node> = new Map();
|
||||
|
||||
constructor(messages: (Message | Node)[] = []) {
|
||||
this.addMessages(messages);
|
||||
}
|
||||
|
||||
public get roots(): Node[] {
|
||||
return Array.from(this.nodes.values())
|
||||
.filter((node) => node.parent === null);
|
||||
|
@ -67,6 +71,16 @@ export class MessageTree {
|
|||
}
|
||||
}
|
||||
|
||||
public addMessages(messages: Message[]) {
|
||||
for (const message of messages) {
|
||||
try {
|
||||
this.addMessage(message);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public updateMessage(message: Message) {
|
||||
const node = this.nodes.get(message.id);
|
||||
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import type { RootState } from '.';
|
||||
|
||||
const initialState: {
|
||||
openAIApiKey?: string | null | undefined;
|
||||
elevenLabsApiKey?: string | null | undefined;
|
||||
} = {
|
||||
openAIApiKey: localStorage.getItem('openai-api-key'),
|
||||
elevenLabsApiKey: localStorage.getItem('elevenlabs-api-key'),
|
||||
};
|
||||
|
||||
export const apiKeysSlice = createSlice({
|
||||
name: 'apiKeys',
|
||||
initialState,
|
||||
reducers: {
|
||||
setOpenAIApiKey: (state, action: PayloadAction<string>) => {
|
||||
state.openAIApiKey = action.payload;
|
||||
},
|
||||
setElevenLabsApiKey: (state, action: PayloadAction<string>) => {
|
||||
state.elevenLabsApiKey = action.payload;
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const { setOpenAIApiKey, setElevenLabsApiKey } = apiKeysSlice.actions;
|
||||
|
||||
export const setOpenAIApiKeyFromEvent = (event: React.ChangeEvent<HTMLInputElement>) => apiKeysSlice.actions.setOpenAIApiKey(event.target.value);
|
||||
export const setElevenLabsApiKeyFromEvent = (event: React.ChangeEvent<HTMLInputElement>) => apiKeysSlice.actions.setElevenLabsApiKey(event.target.value);
|
||||
|
||||
export const selectOpenAIApiKey = (state: RootState) => state.apiKeys.openAIApiKey;
|
||||
export const selectElevenLabsApiKey = (state: RootState) => state.apiKeys.elevenLabsApiKey;
|
||||
|
||||
export default apiKeysSlice.reducer;
|
|
@ -0,0 +1,35 @@
|
|||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
|
||||
import storage from 'redux-persist/lib/storage';
|
||||
import { persistReducer, persistStore } from 'redux-persist';
|
||||
import messageReducer from './message';
|
||||
import parametersReducer from './parameters';
|
||||
import apiKeysReducer from './api-keys';
|
||||
import voiceReducer from './voices';
|
||||
import settingsUIReducer from './settings-ui';
|
||||
|
||||
const persistConfig = {
|
||||
key: 'root',
|
||||
storage,
|
||||
}
|
||||
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
// auth: authReducer,
|
||||
apiKeys: persistReducer(persistConfig, apiKeysReducer),
|
||||
settingsUI: settingsUIReducer,
|
||||
voices: persistReducer(persistConfig, voiceReducer),
|
||||
parameters: persistReducer(persistConfig, parametersReducer),
|
||||
message: messageReducer,
|
||||
},
|
||||
})
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
|
||||
export const useAppDispatch: () => AppDispatch = useDispatch;
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||
|
||||
export const persistor = persistStore(store);
|
||||
|
||||
export default store;
|
|
@ -0,0 +1,22 @@
|
|||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import type { RootState } from '.';
|
||||
|
||||
const initialState = {
|
||||
message: '',
|
||||
};
|
||||
|
||||
export const messageSlice = createSlice({
|
||||
name: 'message',
|
||||
initialState,
|
||||
reducers: {
|
||||
setMessage: (state, action: PayloadAction<string>) => {
|
||||
state.message = action.payload;
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const { setMessage } = messageSlice.actions;
|
||||
|
||||
export const selectMessage = (state: RootState) => state.message.message;
|
||||
|
||||
export default messageSlice.reducer;
|
|
@ -0,0 +1,30 @@
|
|||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
import type { RootState } from '.';
|
||||
import { defaultSystemPrompt } from '../openai';
|
||||
import { defaultParameters } from '../parameters';
|
||||
import { Parameters } from '../types';
|
||||
|
||||
const initialState: Parameters = defaultParameters;
|
||||
|
||||
export const parametersSlice = createSlice({
|
||||
name: 'parameters',
|
||||
initialState,
|
||||
reducers: {
|
||||
setSystemPrompt: (state, action: PayloadAction<string>) => {
|
||||
state.initialSystemPrompt = action.payload;
|
||||
},
|
||||
resetSystemPrompt: (state) => {
|
||||
state.initialSystemPrompt = defaultSystemPrompt;
|
||||
},
|
||||
setTemperature: (state, action: PayloadAction<number>) => {
|
||||
state.temperature = action.payload;
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const { setSystemPrompt, setTemperature, resetSystemPrompt } = parametersSlice.actions;
|
||||
|
||||
export const selectSystemPrompt = (state: RootState) => state.parameters.initialSystemPrompt;
|
||||
export const selectTemperature = (state: RootState) => state.parameters.temperature;
|
||||
|
||||
export default parametersSlice.reducer;
|
|
@ -0,0 +1,41 @@
|
|||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
import type { RootState } from '.';
|
||||
|
||||
const initialState = {
|
||||
tab: '',
|
||||
option: '',
|
||||
};
|
||||
|
||||
export const settingsUISlice = createSlice({
|
||||
name: 'settingsUI',
|
||||
initialState,
|
||||
reducers: {
|
||||
setTab: (state, action: PayloadAction<string|null>) => {
|
||||
console.log('set tab', action);
|
||||
state.tab = action.payload || '';
|
||||
},
|
||||
setOption: (state, action: PayloadAction<string|null>) => {
|
||||
console.log('set option', action);
|
||||
state.option = action.payload || '';
|
||||
},
|
||||
setTabAndOption: (state, action: PayloadAction<{ tab: string | null, option: string | null }>) => {
|
||||
console.log('set tab and option', action);
|
||||
state.tab = action.payload.tab || '';
|
||||
state.option = action.payload.option || '';
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const { setTab, setOption, setTabAndOption } = settingsUISlice.actions;
|
||||
|
||||
export const closeSettingsUI = () => settingsUISlice.actions.setTabAndOption({ tab: '', option: '' });
|
||||
|
||||
export const selectSettingsTab = (state: RootState) => state.settingsUI.tab;
|
||||
export const selectSettingsOption = (state: RootState) => state.settingsUI.option;
|
||||
|
||||
export const openOpenAIApiKeyPanel = () => settingsUISlice.actions.setTabAndOption({ tab: 'user', option: 'openai-api-key' });
|
||||
export const openElevenLabsApiKeyPanel = () => settingsUISlice.actions.setTabAndOption({ tab: 'speech', option: 'elevenlabs-api-key' });
|
||||
export const openSystemPromptPanel = () => settingsUISlice.actions.setTabAndOption({ tab: 'options', option: 'system-prompt' });
|
||||
export const openTemperaturePanel = () => settingsUISlice.actions.setTabAndOption({ tab: 'options', option: 'temperature' });
|
||||
|
||||
export default settingsUISlice.reducer;
|
|
@ -0,0 +1,23 @@
|
|||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import type { RootState } from '.';
|
||||
import { defaultElevenLabsVoiceID } from '../tts/defaults';
|
||||
|
||||
const initialState = {
|
||||
voice: defaultElevenLabsVoiceID,
|
||||
};
|
||||
|
||||
export const voicesSlice = createSlice({
|
||||
name: 'voices',
|
||||
initialState,
|
||||
reducers: {
|
||||
setVoice: (state, action: PayloadAction<string|null>) => {
|
||||
state.voice = action.payload || '';
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const { setVoice } = voicesSlice.actions;
|
||||
|
||||
export const selectVoice = (state: RootState) => state.voices.voice;
|
||||
|
||||
export default voicesSlice.reducer;
|
|
@ -0,0 +1,49 @@
|
|||
export const defaultVoiceList = [
|
||||
{
|
||||
"voice_id": "21m00Tcm4TlvDq8ikWAM",
|
||||
"name": "Rachel",
|
||||
"preview_url": "https://storage.googleapis.com/eleven-public-prod/premade/voices/21m00Tcm4TlvDq8ikWAM/6edb9076-c3e4-420c-b6ab-11d43fe341c8.mp3",
|
||||
},
|
||||
{
|
||||
"voice_id": "AZnzlk1XvdvUeBnXmlld",
|
||||
"name": "Domi",
|
||||
"preview_url": "https://storage.googleapis.com/eleven-public-prod/premade/voices/AZnzlk1XvdvUeBnXmlld/69c5373f-0dc2-4efd-9232-a0140182c0a9.mp3",
|
||||
},
|
||||
{
|
||||
"voice_id": "EXAVITQu4vr4xnSDxMaL",
|
||||
"name": "Bella",
|
||||
"preview_url": "https://storage.googleapis.com/eleven-public-prod/premade/voices/EXAVITQu4vr4xnSDxMaL/04365bce-98cc-4e99-9f10-56b60680cda9.mp3",
|
||||
},
|
||||
{
|
||||
"voice_id": "ErXwobaYiN019PkySvjV",
|
||||
"name": "Antoni",
|
||||
"preview_url": "https://storage.googleapis.com/eleven-public-prod/premade/voices/ErXwobaYiN019PkySvjV/38d8f8f0-1122-4333-b323-0b87478d506a.mp3",
|
||||
},
|
||||
{
|
||||
"voice_id": "MF3mGyEYCl7XYWbV9V6O",
|
||||
"name": "Elli",
|
||||
"preview_url": "https://storage.googleapis.com/eleven-public-prod/premade/voices/MF3mGyEYCl7XYWbV9V6O/f9fd64c3-5d62-45cd-b0dc-ad722ee3284e.mp3",
|
||||
},
|
||||
{
|
||||
"voice_id": "TxGEqnHWrfWFTfGW9XjX",
|
||||
"name": "Josh",
|
||||
"preview_url": "https://storage.googleapis.com/eleven-public-prod/premade/voices/TxGEqnHWrfWFTfGW9XjX/c6c80dcd-5fe5-4a4c-a74c-b3fec4c62c67.mp3",
|
||||
},
|
||||
{
|
||||
"voice_id": "VR6AewLTigWG4xSOukaG",
|
||||
"name": "Arnold",
|
||||
"preview_url": "https://storage.googleapis.com/eleven-public-prod/premade/voices/VR6AewLTigWG4xSOukaG/66e83dc2-6543-4897-9283-e028ac5ae4aa.mp3",
|
||||
},
|
||||
{
|
||||
"voice_id": "pNInz6obpgDQGcFmaJgB",
|
||||
"name": "Adam",
|
||||
"preview_url": "https://storage.googleapis.com/eleven-public-prod/premade/voices/pNInz6obpgDQGcFmaJgB/e0b45450-78db-49b9-aaa4-d5358a6871bd.mp3",
|
||||
},
|
||||
{
|
||||
"voice_id": "yoZ06aMxZJJ28mfd3POQ",
|
||||
"name": "Sam",
|
||||
"preview_url": "https://storage.googleapis.com/eleven-public-prod/premade/voices/yoZ06aMxZJJ28mfd3POQ/1c4d417c-ba80-4de8-874a-a1c57987ea63.mp3",
|
||||
}
|
||||
];
|
||||
|
||||
export const defaultElevenLabsVoiceID = defaultVoiceList.find(voice => voice.name === "Bella")!.voice_id;
|
|
@ -2,62 +2,16 @@ import { Button } from "@mantine/core";
|
|||
import EventEmitter from "events";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { split } from 'sentence-splitter';
|
||||
import { cloneArrayBuffer, md5, sleep } from "./utils";
|
||||
import * as idb from './idb';
|
||||
import { useAppContext } from "./context";
|
||||
import { cloneArrayBuffer, md5, sleep } from "../utils";
|
||||
import * as idb from '../idb';
|
||||
import { useAppDispatch, useAppSelector } from "../store";
|
||||
import { selectElevenLabsApiKey } from "../store/api-keys";
|
||||
import { selectVoice } from "../store/voices";
|
||||
import { openElevenLabsApiKeyPanel } from "../store/settings-ui";
|
||||
import { defaultElevenLabsVoiceID } from "./defaults";
|
||||
|
||||
const endpoint = 'https://api.elevenlabs.io';
|
||||
|
||||
export const defaultVoiceList = [
|
||||
{
|
||||
"voice_id": "21m00Tcm4TlvDq8ikWAM",
|
||||
"name": "Rachel",
|
||||
"preview_url": "https://storage.googleapis.com/eleven-public-prod/premade/voices/21m00Tcm4TlvDq8ikWAM/6edb9076-c3e4-420c-b6ab-11d43fe341c8.mp3",
|
||||
},
|
||||
{
|
||||
"voice_id": "AZnzlk1XvdvUeBnXmlld",
|
||||
"name": "Domi",
|
||||
"preview_url": "https://storage.googleapis.com/eleven-public-prod/premade/voices/AZnzlk1XvdvUeBnXmlld/69c5373f-0dc2-4efd-9232-a0140182c0a9.mp3",
|
||||
},
|
||||
{
|
||||
"voice_id": "EXAVITQu4vr4xnSDxMaL",
|
||||
"name": "Bella",
|
||||
"preview_url": "https://storage.googleapis.com/eleven-public-prod/premade/voices/EXAVITQu4vr4xnSDxMaL/04365bce-98cc-4e99-9f10-56b60680cda9.mp3",
|
||||
},
|
||||
{
|
||||
"voice_id": "ErXwobaYiN019PkySvjV",
|
||||
"name": "Antoni",
|
||||
"preview_url": "https://storage.googleapis.com/eleven-public-prod/premade/voices/ErXwobaYiN019PkySvjV/38d8f8f0-1122-4333-b323-0b87478d506a.mp3",
|
||||
},
|
||||
{
|
||||
"voice_id": "MF3mGyEYCl7XYWbV9V6O",
|
||||
"name": "Elli",
|
||||
"preview_url": "https://storage.googleapis.com/eleven-public-prod/premade/voices/MF3mGyEYCl7XYWbV9V6O/f9fd64c3-5d62-45cd-b0dc-ad722ee3284e.mp3",
|
||||
},
|
||||
{
|
||||
"voice_id": "TxGEqnHWrfWFTfGW9XjX",
|
||||
"name": "Josh",
|
||||
"preview_url": "https://storage.googleapis.com/eleven-public-prod/premade/voices/TxGEqnHWrfWFTfGW9XjX/c6c80dcd-5fe5-4a4c-a74c-b3fec4c62c67.mp3",
|
||||
},
|
||||
{
|
||||
"voice_id": "VR6AewLTigWG4xSOukaG",
|
||||
"name": "Arnold",
|
||||
"preview_url": "https://storage.googleapis.com/eleven-public-prod/premade/voices/VR6AewLTigWG4xSOukaG/66e83dc2-6543-4897-9283-e028ac5ae4aa.mp3",
|
||||
},
|
||||
{
|
||||
"voice_id": "pNInz6obpgDQGcFmaJgB",
|
||||
"name": "Adam",
|
||||
"preview_url": "https://storage.googleapis.com/eleven-public-prod/premade/voices/pNInz6obpgDQGcFmaJgB/e0b45450-78db-49b9-aaa4-d5358a6871bd.mp3",
|
||||
},
|
||||
{
|
||||
"voice_id": "yoZ06aMxZJJ28mfd3POQ",
|
||||
"name": "Sam",
|
||||
"preview_url": "https://storage.googleapis.com/eleven-public-prod/premade/voices/yoZ06aMxZJJ28mfd3POQ/1c4d417c-ba80-4de8-874a-a1c57987ea63.mp3",
|
||||
}
|
||||
];
|
||||
|
||||
export const defaultElevenLabsVoiceID = defaultVoiceList.find(voice => voice.name === "Bella")!.voice_id;
|
||||
|
||||
let currentReader: ElevenLabsReader | null = null;
|
||||
|
||||
const cache = new Map<string, ArrayBuffer>();
|
||||
|
@ -271,7 +225,11 @@ export default class ElevenLabsReader extends EventEmitter {
|
|||
}
|
||||
|
||||
export function ElevenLabsReaderButton(props: { selector: string }) {
|
||||
const context = useAppContext();
|
||||
const elevenLabsApiKey = useAppSelector(selectElevenLabsApiKey);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const voice = useAppSelector(selectVoice);
|
||||
|
||||
const [status, setStatus] = useState<'idle' | 'init' | 'playing' | 'buffering'>('idle');
|
||||
// const [error, setError] = useState(false);
|
||||
const reader = useRef(new ElevenLabsReader());
|
||||
|
@ -296,18 +254,17 @@ export function ElevenLabsReaderButton(props: { selector: string }) {
|
|||
|
||||
const onClick = useCallback(() => {
|
||||
if (status === 'idle') {
|
||||
if (!context.apiKeys.elevenlabs?.length) {
|
||||
context.settings.open('speech', 'elevenlabs-api-key');
|
||||
if (!elevenLabsApiKey?.length) {
|
||||
dispatch(openElevenLabsApiKeyPanel());
|
||||
return;
|
||||
}
|
||||
|
||||
const voice = context.voice.id;
|
||||
audioContext.resume();
|
||||
reader.current.play(document.querySelector(props.selector)!, voice, context.apiKeys.elevenlabs);
|
||||
reader.current.play(document.querySelector(props.selector)!, voice, elevenLabsApiKey);
|
||||
} else {
|
||||
reader.current.stop();
|
||||
}
|
||||
}, [status, props.selector, context.apiKeys.elevenlabs, context.settings, context.voice.id]);
|
||||
}, [dispatch, status, props.selector, elevenLabsApiKey, voice]);
|
||||
|
||||
return (
|
||||
<Button variant="subtle" size="sm" compact onClickCapture={onClick} loading={status === 'init'}>
|
Loading…
Reference in New Issue