v0.2.3
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import styled from "@emotion/styled";
|
||||
import { Button, Modal, PasswordInput, TextInput } from "@mantine/core";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { useAppDispatch, useAppSelector } from "../../store";
|
||||
import { closeModals, openLoginModal, openSignupModal, selectModal } from "../../store/ui";
|
||||
import { useAppDispatch, useAppSelector } from "../store";
|
||||
import { closeModals, openLoginModal, openSignupModal, selectModal } from "../store/ui";
|
||||
|
||||
const Container = styled.form`
|
||||
* {
|
@@ -5,14 +5,25 @@ 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 { useAppContext } from '../core/context';
|
||||
import { backend } from '../core/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 { openSignupModal } from '../store/ui';
|
||||
import { openLoginModal, openSignupModal } from '../store/ui';
|
||||
import { useOption } from '../core/options/use-option';
|
||||
import { useHotkeys } from '@mantine/hooks';
|
||||
|
||||
const Banner = styled.div`
|
||||
background: rgba(224, 49, 49, 0.2);
|
||||
color: white;
|
||||
text-align: center;
|
||||
font-family: "Work Sans", sans-serif;
|
||||
font-size: 80%;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const HeaderContainer = styled.div`
|
||||
display: flex;
|
||||
@@ -61,6 +72,7 @@ const HeaderContainer = styled.div`
|
||||
h2 {
|
||||
margin: 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
@@ -134,7 +146,7 @@ export default function Header(props: HeaderProps) {
|
||||
const navigate = useNavigate();
|
||||
const spotlight = useSpotlight();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const openAIApiKey = useAppSelector(selectOpenAIApiKey);
|
||||
const [openAIApiKey] = useOption<string>('openai', 'apiKey');
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
@@ -149,13 +161,37 @@ export default function Header(props: HeaderProps) {
|
||||
setLoading(true);
|
||||
navigate(`/`);
|
||||
setLoading(false);
|
||||
setTimeout(() => document.querySelector<HTMLTextAreaElement>('#message-input')?.focus(), 100);
|
||||
}, [navigate]);
|
||||
|
||||
const openSettings = useCallback(() => {
|
||||
dispatch(setTab(openAIApiKey ? 'options' : 'user'));
|
||||
dispatch(setTab(openAIApiKey ? 'chat' : 'user'));
|
||||
}, [openAIApiKey, dispatch]);
|
||||
|
||||
const header = useMemo(() => (
|
||||
const signIn = useCallback(() => {
|
||||
if ((window as any).AUTH_PROVIDER !== 'local') {
|
||||
backend.current?.signIn();
|
||||
} else {
|
||||
dispatch(openLoginModal());
|
||||
}
|
||||
}, [dispatch])
|
||||
|
||||
const signUp = useCallback(() => {
|
||||
if ((window as any).AUTH_PROVIDER !== 'local') {
|
||||
backend.current?.signIn();
|
||||
} else {
|
||||
dispatch(openSignupModal());
|
||||
}
|
||||
}, [dispatch])
|
||||
|
||||
useHotkeys([
|
||||
['c', onNewChat],
|
||||
]);
|
||||
|
||||
const header = useMemo(() => (<>
|
||||
{context.sessionExpired && <Banner onClick={signIn}>
|
||||
You have been signed out. Click here to sign back in.
|
||||
</Banner>}
|
||||
<HeaderContainer className={context.isHome ? 'shaded' : ''}>
|
||||
<Helmet>
|
||||
<title>
|
||||
@@ -172,15 +208,9 @@ export default function Header(props: HeaderProps) {
|
||||
<FormattedMessage defaultMessage="Share" description="Label for the button used to create a public share URL for a chat log" />
|
||||
</HeaderButton>}
|
||||
{backend.current && !context.authenticated && (
|
||||
<HeaderButton onClick={() => {
|
||||
if (process.env.REACT_APP_AUTH_PROVIDER !== 'local') {
|
||||
backend.current?.signIn();
|
||||
} else {
|
||||
dispatch(openSignupModal());
|
||||
}
|
||||
}}>
|
||||
<HeaderButton onClick={localStorage.getItem('registered') ? signIn : signUp}>
|
||||
<FormattedMessage defaultMessage="Sign in <h>to sync</h>"
|
||||
description="Label for sign in button, indicating the purpose of signing in is to sync your data between devices"
|
||||
description="Label for sign in button, which indicates that the purpose of signing in is to sync your data between devices. Less important text inside <h> tags is hidden on small screens."
|
||||
values={{
|
||||
h: (chunks: any) => <span className="hide-on-mobile">{chunks}</span>
|
||||
}} />
|
||||
@@ -190,7 +220,8 @@ export default function Header(props: HeaderProps) {
|
||||
<FormattedMessage defaultMessage="New Chat" description="Label for the button used to start a new chat session" />
|
||||
</HeaderButton>
|
||||
</HeaderContainer>
|
||||
), [sidebarOpen, onBurgerClick, props.title, props.share, props.canShare, props.onShare, openSettings, onNewChat, loading, context.authenticated, context.isHome, context.isShare, spotlight.openSpotlight]);
|
||||
</>), [sidebarOpen, onBurgerClick, props.title, props.share, props.canShare, props.onShare, openSettings, onNewChat,
|
||||
loading, context.authenticated, context.sessionExpired, context.isHome, context.isShare, spotlight.openSpotlight, signIn, signUp]);
|
||||
|
||||
return header;
|
||||
}
|
||||
|
@@ -1,17 +1,17 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { Button, ActionIcon, Textarea, Loader, Popover, Checkbox, Center, Group } from '@mantine/core';
|
||||
import { useLocalStorage, useMediaQuery } from '@mantine/hooks';
|
||||
import { Button, ActionIcon, Textarea, Loader, Popover } from '@mantine/core';
|
||||
import { getHotkeyHandler, useHotkeys, useMediaQuery } from '@mantine/hooks';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useAppContext } from '../context';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAppContext } from '../core/context';
|
||||
import { useAppDispatch, useAppSelector } from '../store';
|
||||
import { selectMessage, setMessage } from '../store/message';
|
||||
import { selectTemperature } from '../store/parameters';
|
||||
import { openOpenAIApiKeyPanel, openSystemPromptPanel, openTemperaturePanel } from '../store/settings-ui';
|
||||
import { speechRecognition, supportsSpeechRecognition } from '../speech-recognition-types'
|
||||
import { selectSettingsTab, openOpenAIApiKeyPanel } from '../store/settings-ui';
|
||||
import { speechRecognition, supportsSpeechRecognition } from '../core/speech-recognition-types'
|
||||
import { useWhisper } from '@chengsokdara/use-whisper';
|
||||
import { selectUseOpenAIWhisper, selectOpenAIApiKey } from '../store/api-keys';
|
||||
import QuickSettings from './quick-settings';
|
||||
import { useOption } from '../core/options/use-option';
|
||||
|
||||
const Container = styled.div`
|
||||
background: #292933;
|
||||
@@ -24,19 +24,8 @@ const Container = styled.div`
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.inner > .bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.inner > .bottom {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-button {
|
||||
margin: 0.5rem -0.4rem 0.5rem 1rem;
|
||||
font-size: 0.7rem;
|
||||
color: #999;
|
||||
}
|
||||
@@ -49,14 +38,12 @@ export interface MessageInputProps {
|
||||
}
|
||||
|
||||
export default function MessageInput(props: MessageInputProps) {
|
||||
const temperature = useAppSelector(selectTemperature);
|
||||
const message = useAppSelector(selectMessage);
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [speechError, setSpeechError] = useState<string | null>(null);
|
||||
const hasVerticalSpace = useMediaQuery('(min-height: 1000px)');
|
||||
const useOpenAIWhisper = useAppSelector(selectUseOpenAIWhisper);
|
||||
const openAIApiKey = useAppSelector(selectOpenAIApiKey);
|
||||
const [isEnterToSend, setIsEnterToSend] = useLocalStorage({ key: 'isEnterToSend', defaultValue: false})
|
||||
const [useOpenAIWhisper] = useOption<boolean>('speech-recognition', 'use-whisper');
|
||||
const [openAIApiKey] = useOption<string>('openai', 'apiKey');
|
||||
|
||||
const [initialMessage, setInitialMessage] = useState('');
|
||||
const {
|
||||
@@ -69,12 +56,16 @@ export default function MessageInput(props: MessageInputProps) {
|
||||
streaming: false,
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
const context = useAppContext();
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const onCustomizeSystemPromptClick = useCallback(() => dispatch(openSystemPromptPanel()), [dispatch]);
|
||||
const onTemperatureClick = useCallback(() => dispatch(openTemperaturePanel()), [dispatch]);
|
||||
const tab = useAppSelector(selectSettingsTab);
|
||||
|
||||
const [showMicrophoneButton] = useOption<boolean>('speech-recognition', 'show-microphone');
|
||||
const [submitOnEnter] = useOption<boolean>('input', 'submit-on-enter');
|
||||
|
||||
const onChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
dispatch(setMessage(e.target.value));
|
||||
}, [dispatch]);
|
||||
@@ -84,10 +75,15 @@ export default function MessageInput(props: MessageInputProps) {
|
||||
const onSubmit = useCallback(async () => {
|
||||
setSpeechError(null);
|
||||
|
||||
if (await context.onNewMessage(message)) {
|
||||
const id = await context.onNewMessage(message);
|
||||
|
||||
if (id) {
|
||||
if (!window.location.pathname.includes(id)) {
|
||||
navigate('/chat/' + id);
|
||||
}
|
||||
dispatch(setMessage(''));
|
||||
}
|
||||
}, [context, message, dispatch]);
|
||||
}, [context, message, dispatch, navigate]);
|
||||
|
||||
const onSpeechError = useCallback((e: any) => {
|
||||
console.error('speech recognition error', e);
|
||||
@@ -118,7 +114,7 @@ export default function MessageInput(props: MessageInputProps) {
|
||||
} else if (result.state == 'denied') {
|
||||
denied = true;
|
||||
}
|
||||
} catch (e) {}
|
||||
} catch (e) { }
|
||||
|
||||
if (!granted && !denied) {
|
||||
try {
|
||||
@@ -191,12 +187,13 @@ export default function MessageInput(props: MessageInputProps) {
|
||||
}
|
||||
}, [initialMessage, transcript, recording, transcribing, useOpenAIWhisper, dispatch]);
|
||||
|
||||
const onKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if(e.key === 'Enter' && e.shiftKey === false && !props.disabled) {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
}, [isEnterToSend, onSubmit, props.disabled]);
|
||||
useHotkeys([
|
||||
['n', () => document.querySelector<HTMLTextAreaElement>('#message-input')?.focus()]
|
||||
]);
|
||||
|
||||
const blur = useCallback(() => {
|
||||
document.querySelector<HTMLTextAreaElement>('#message-input')?.blur();
|
||||
}, []);
|
||||
|
||||
const rightSection = useMemo(() => {
|
||||
return (
|
||||
@@ -210,7 +207,7 @@ export default function MessageInput(props: MessageInputProps) {
|
||||
}}>
|
||||
{context.generating && (<>
|
||||
<Button variant="subtle" size="xs" compact onClick={() => {
|
||||
context.chat.cancelReply(context.currentChat.leaf!.id);
|
||||
context.chat.cancelReply(context.currentChat.chat?.id, context.currentChat.leaf!.id);
|
||||
}}>
|
||||
<FormattedMessage defaultMessage={"Cancel"} description="Label for the button that can be clicked while the AI is generating a response to cancel generation" />
|
||||
</Button>
|
||||
@@ -218,7 +215,7 @@ export default function MessageInput(props: MessageInputProps) {
|
||||
</>)}
|
||||
{!context.generating && (
|
||||
<>
|
||||
<Popover width={200} position="bottom" withArrow shadow="md" opened={speechError !== null}>
|
||||
{showMicrophoneButton && <Popover width={200} position="bottom" withArrow shadow="md" opened={speechError !== null}>
|
||||
<Popover.Target>
|
||||
<ActionIcon size="xl"
|
||||
onClick={onSpeechStart}>
|
||||
@@ -245,7 +242,7 @@ export default function MessageInput(props: MessageInputProps) {
|
||||
</Button>
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Popover>}
|
||||
<ActionIcon size="xl"
|
||||
onClick={onSubmit}>
|
||||
<i className="fa fa-paper-plane" style={{ fontSize: '90%' }} />
|
||||
@@ -254,7 +251,7 @@ export default function MessageInput(props: MessageInputProps) {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}, [recording, transcribing, onSubmit, onSpeechStart, props.disabled, context.generating, speechError, onHideSpeechError]);
|
||||
}, [recording, transcribing, onSubmit, onSpeechStart, props.disabled, context.generating, speechError, onHideSpeechError, showMicrophoneButton]);
|
||||
|
||||
const disabled = context.generating;
|
||||
|
||||
@@ -263,9 +260,21 @@ export default function MessageInput(props: MessageInputProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hotkeyHandler = useMemo(() => {
|
||||
const keys = [
|
||||
['Escape', blur, { preventDefault: true }],
|
||||
];
|
||||
if (submitOnEnter) {
|
||||
keys.unshift(['Enter', onSubmit, { preventDefault: true }]);
|
||||
}
|
||||
const handler = getHotkeyHandler(keys as any);
|
||||
return handler;
|
||||
}, [onSubmit, blur, submitOnEnter]);
|
||||
|
||||
return <Container>
|
||||
<div className="inner">
|
||||
<Textarea disabled={props.disabled || disabled}
|
||||
id="message-input"
|
||||
autosize
|
||||
minRows={(hasVerticalSpace || context.isHome) ? 3 : 2}
|
||||
maxRows={12}
|
||||
@@ -274,31 +283,8 @@ export default function MessageInput(props: MessageInputProps) {
|
||||
onChange={onChange}
|
||||
rightSection={rightSection}
|
||||
rightSectionWidth={context.generating ? 100 : 55}
|
||||
onKeyDown={onKeyDown} />
|
||||
<div className="bottom">
|
||||
<Group my="sm" spacing="xs">
|
||||
<Button variant="subtle"
|
||||
className="settings-button"
|
||||
size="xs"
|
||||
compact
|
||||
onClick={onCustomizeSystemPromptClick}>
|
||||
<span>
|
||||
<FormattedMessage defaultMessage={"Customize system prompt"} description="Label for the button that opens a modal for customizing the 'system prompt', a message used to customize and influence how the AI responds." />
|
||||
</span>
|
||||
</Button>
|
||||
<Button variant="subtle"
|
||||
className="settings-button"
|
||||
size="xs"
|
||||
compact
|
||||
onClick={onTemperatureClick}>
|
||||
<span>
|
||||
<FormattedMessage defaultMessage="Temperature: {temperature, number, ::.0}"
|
||||
description="Label for the button that opens a modal for setting the 'temperature' (randomness) of AI responses"
|
||||
values={{ temperature }} />
|
||||
</span>
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
onKeyDown={hotkeyHandler} />
|
||||
<QuickSettings key={tab} />
|
||||
</div>
|
||||
</Container>;
|
||||
}
|
||||
|
@@ -75,7 +75,7 @@ export function Markdown(props: MarkdownProps) {
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
components={{
|
||||
ol({ start, children }) {
|
||||
return <ol start={start ?? 1} style={{ counterReset: `list-item ${(start || 1) - 1}` }}>
|
||||
return <ol start={start ?? 1} style={{ counterReset: `list-item ${(start || 1)}` }}>
|
||||
{children}
|
||||
</ol>;
|
||||
},
|
||||
|
@@ -1,13 +1,15 @@
|
||||
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 { Message } from "../core/chat/types";
|
||||
import { share } from '../core/utils';
|
||||
import { TTSButton } from './tts-button';
|
||||
import { Markdown } from './markdown';
|
||||
import { useAppContext } from '../context';
|
||||
import { useAppContext } from '../core/context';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import { useAppSelector } from '../store';
|
||||
import { selectSettingsTab } from '../store/settings-ui';
|
||||
|
||||
// hide for everyone but screen readers
|
||||
const SROnly = styled.span`
|
||||
@@ -138,6 +140,10 @@ const Container = styled.div`
|
||||
|
||||
.fa + span {
|
||||
margin-left: 0.2em;
|
||||
|
||||
@media (max-width: 40em) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.mantine-Button-root {
|
||||
@@ -204,6 +210,8 @@ export default function MessageComponent(props: { message: Message, last: boolea
|
||||
const [content, setContent] = useState('');
|
||||
const intl = useIntl();
|
||||
|
||||
const tab = useAppSelector(selectSettingsTab);
|
||||
|
||||
const getRoleName = useCallback((role: string, share = false) => {
|
||||
switch (role) {
|
||||
case 'user':
|
||||
@@ -237,14 +245,17 @@ export default function MessageComponent(props: { message: Message, last: boolea
|
||||
</strong>
|
||||
{props.message.role === 'assistant' && props.last && !props.message.done && <InlineLoader />}
|
||||
</span>
|
||||
{props.message.done && <ElevenLabsReaderButton selector={'.content-' + props.message.id} />}
|
||||
<TTSButton id={props.message.id}
|
||||
selector={'.content-' + props.message.id}
|
||||
complete={!!props.message.done}
|
||||
autoplay={props.last && context.chat.lastReplyID === 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" />
|
||||
{copied ? <FormattedMessage defaultMessage="Copied" description="Label for copy-to-clipboard button after a successful copy" />
|
||||
: <FormattedMessage defaultMessage="Copy" description="Label for copy-to-clipboard button" />}
|
||||
{copied ? <FormattedMessage defaultMessage="Copied" description="Label for copy-to-clipboard button after a successful copy" />
|
||||
: <span><FormattedMessage defaultMessage="Copy" description="Label for copy-to-clipboard button" /></span>}
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
@@ -293,7 +304,7 @@ export default function MessageComponent(props: { message: Message, last: boolea
|
||||
{props.last && <EndOfChatMarker />}
|
||||
</Container>
|
||||
)
|
||||
}, [props.last, props.share, editing, content, context, props.message, props.message.content]);
|
||||
}, [props.last, props.share, editing, content, context, props.message, props.message.content, tab]);
|
||||
|
||||
return elem;
|
||||
}
|
@@ -1,11 +1,12 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { SpotlightProvider } from '@mantine/spotlight';
|
||||
import { useChatSpotlightProps } from '../spotlight';
|
||||
import { LoginModal, CreateAccountModal } from './auth/modals';
|
||||
import { LoginModal, CreateAccountModal } from './auth-modals';
|
||||
import Header, { HeaderProps, SubHeader } from './header';
|
||||
import MessageInput from './input';
|
||||
import SettingsDrawer from './settings';
|
||||
import Sidebar from './sidebar';
|
||||
import AudioControls from './tts-controls';
|
||||
|
||||
const Container = styled.div`
|
||||
position: absolute;
|
||||
@@ -13,12 +14,13 @@ const Container = styled.div`
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: #292933;
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow: hidden;
|
||||
|
||||
background: #292933;
|
||||
color: white;
|
||||
|
||||
.sidebar {
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
@@ -55,10 +57,14 @@ const Container = styled.div`
|
||||
`;
|
||||
|
||||
const Main = styled.div`
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
overflow: scroll;
|
||||
|
||||
@media (min-height: 30em) {
|
||||
overflow: hidden;
|
||||
}
|
||||
`;
|
||||
|
||||
export function Page(props: {
|
||||
@@ -79,6 +85,7 @@ export function Page(props: {
|
||||
onShare={props.headerProps?.onShare} />
|
||||
{props.showSubHeader && <SubHeader />}
|
||||
{props.children}
|
||||
<AudioControls />
|
||||
<MessageInput key={localStorage.getItem('openai-api-key')} />
|
||||
<SettingsDrawer />
|
||||
<LoginModal />
|
||||
@@ -86,5 +93,4 @@ export function Page(props: {
|
||||
</Main>
|
||||
</Container>
|
||||
</SpotlightProvider>;
|
||||
}
|
||||
|
||||
}
|
@@ -1,20 +1,23 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import React, { Suspense, useCallback } from 'react';
|
||||
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 { useAppContext } from '../../context';
|
||||
import { backend } from '../../backend';
|
||||
import { useAppContext } from '../../core/context';
|
||||
import { backend } from '../../core/backend';
|
||||
import { Page } from '../page';
|
||||
import { useOption } from '../../core/options/use-option';
|
||||
|
||||
const Message = React.lazy(() => import(/* webpackPreload: true */ '../message'));
|
||||
|
||||
const Messages = styled.div`
|
||||
max-height: 100%;
|
||||
flex-grow: 1;
|
||||
overflow-y: scroll;
|
||||
@media (min-height: 30em) {
|
||||
max-height: 100%;
|
||||
flex-grow: 1;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
@@ -29,20 +32,22 @@ const EmptyMessage = styled.div`
|
||||
font-family: "Work Sans", sans-serif;
|
||||
line-height: 1.7;
|
||||
gap: 1rem;
|
||||
min-height: 10rem;
|
||||
`;
|
||||
|
||||
export default function ChatPage(props: any) {
|
||||
const { id } = useParams();
|
||||
const context = useAppContext();
|
||||
|
||||
let firstLoad = true;
|
||||
const [autoScrollWhenOpeningChat] = useOption('auto-scroll', 'auto-scroll-when-opening-chat')
|
||||
const [autoScrollWhileGenerating] = useOption('auto-scroll', 'auto-scroll-while-generating');
|
||||
|
||||
useEffect(() => {
|
||||
if (props.share || !context.currentChat.chatLoadedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldScroll = (Date.now() - context.currentChat.chatLoadedAt) > 5000 || firstLoad;
|
||||
firstLoad = false;
|
||||
const shouldScroll = autoScrollWhenOpeningChat || (Date.now() - context.currentChat.chatLoadedAt) > 5000;
|
||||
|
||||
if (!shouldScroll) {
|
||||
return;
|
||||
@@ -56,9 +61,23 @@ export default function ChatPage(props: any) {
|
||||
const offset = Math.max(0, latest.offsetTop - 100);
|
||||
setTimeout(() => {
|
||||
container?.scrollTo({ top: offset, behavior: 'smooth' });
|
||||
}, 500);
|
||||
}, 100);
|
||||
}
|
||||
}, [context.currentChat?.chatLoadedAt, context.currentChat?.messagesToDisplay.length, props.share]);
|
||||
}, [context.currentChat?.chatLoadedAt, context.currentChat?.messagesToDisplay.length, props.share, autoScrollWhenOpeningChat]);
|
||||
|
||||
const autoScroll = useCallback(() => {
|
||||
if (context.generating && autoScrollWhileGenerating) {
|
||||
const container = document.querySelector('#messages') as HTMLElement;
|
||||
container?.scrollTo({ top: 999999, behavior: 'smooth' });
|
||||
container?.parentElement?.scrollTo({ top: 999999, behavior: 'smooth' });
|
||||
}
|
||||
}, [context.generating, autoScrollWhileGenerating]);
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => autoScroll(), 1000);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [autoScroll]);
|
||||
|
||||
const messagesToDisplay = context.currentChat.messagesToDisplay;
|
||||
|
||||
@@ -94,7 +113,7 @@ export default function ChatPage(props: any) {
|
||||
{shouldShowChat && (
|
||||
<div style={{ paddingBottom: '4.5rem' }}>
|
||||
{messagesToDisplay.map((message) => (
|
||||
<Message key={message.id}
|
||||
<Message key={id + ":" + message.id}
|
||||
message={message}
|
||||
share={props.share}
|
||||
last={context.currentChat.chat!.messages.leafs.some(n => n.id === message.id)} />
|
||||
|
@@ -2,10 +2,11 @@ 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 { useAppDispatch } from '../../store';
|
||||
import { openOpenAIApiKeyPanel } from '../../store/settings-ui';
|
||||
import { Page } from '../page';
|
||||
import { useOption } from '../../core/options/use-option';
|
||||
import { isProxySupported } from '../../core/chat/openai';
|
||||
|
||||
const Container = styled.div`
|
||||
flex-grow: 1;
|
||||
@@ -20,7 +21,7 @@ const Container = styled.div`
|
||||
`;
|
||||
|
||||
export default function LandingPage(props: any) {
|
||||
const openAIApiKey = useAppSelector(selectOpenAIApiKey);
|
||||
const [openAIApiKey] = useOption<string>('openai', 'apiKey');
|
||||
const dispatch = useAppDispatch();
|
||||
const onConnectButtonClick = useCallback(() => dispatch(openOpenAIApiKeyPanel()), [dispatch]);
|
||||
|
||||
@@ -30,16 +31,11 @@ export default function LandingPage(props: any) {
|
||||
<FormattedMessage defaultMessage={'Hello, how can I help you today?'}
|
||||
description="A friendly message that appears at the start of new chat sessions" />
|
||||
</p>
|
||||
{!openAIApiKey && (
|
||||
{!openAIApiKey && !isProxySupported() && (
|
||||
<Button size="xs" variant="light" compact onClick={onConnectButtonClick}>
|
||||
<FormattedMessage defaultMessage={'Connect your OpenAI account to get started'} />
|
||||
</Button>
|
||||
)}
|
||||
<p>
|
||||
<Button size="xs" variant="light" component="a" href="https://www.chatwithgpt.ai" target="_blank">
|
||||
Try the new beta app<i style={{ marginLeft: '0.5rem' }} className="fa fa-arrow-up-right-from-square" />
|
||||
</Button>
|
||||
</p>
|
||||
</Container>
|
||||
</Page>;
|
||||
}
|
||||
|
66
app/src/components/quick-settings.tsx
Normal file
66
app/src/components/quick-settings.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { useAppContext } from '../core/context';
|
||||
import { Option } from '../core/options/option';
|
||||
import { useOption } from '../core/options/use-option';
|
||||
import { Button } from '@mantine/core';
|
||||
import { useAppDispatch, useAppSelector } from '../store';
|
||||
import { useCallback } from 'react';
|
||||
import { setTabAndOption } from '../store/settings-ui';
|
||||
|
||||
const Container = styled.div`
|
||||
margin: 0.5rem -0.5rem;
|
||||
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
text-align: left;
|
||||
|
||||
justify-content: center;
|
||||
|
||||
@media (min-width: 40em) {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.mantine-Button-root {
|
||||
font-size: 0.7rem;
|
||||
color: #999;
|
||||
}
|
||||
`;
|
||||
|
||||
export function QuickSettingsButton(props: { groupID: string, option: Option }) {
|
||||
const context = useAppContext();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [value] = useOption(props.groupID, props.option.id, context.id || undefined);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
dispatch(setTabAndOption({ tab: props.option.displayOnSettingsScreen, option: props.option.id }));
|
||||
}, [props.groupID, props.option.id, dispatch]);
|
||||
|
||||
const labelBuilder = props.option.displayInQuickSettings?.label;
|
||||
let label = props.option.id;
|
||||
|
||||
if (labelBuilder) {
|
||||
label = typeof labelBuilder === 'string' ? labelBuilder : labelBuilder(value, context.chat.options, context);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="subtle" size="xs" compact onClick={onClick}>
|
||||
<span>
|
||||
{label}
|
||||
</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function QuickSettings(props: any) {
|
||||
const context = useAppContext();
|
||||
const options = context.chat.getQuickSettings();
|
||||
|
||||
if (!options.length) {
|
||||
return <div style={{ height: '1rem' }} />;
|
||||
}
|
||||
|
||||
return <Container>
|
||||
{options.map(o => <QuickSettingsButton groupID={o.groupID} option={o.option} key={o.groupID + "." + o.option.id} />)}
|
||||
</Container>;
|
||||
}
|
5
app/src/components/settings/chat.tsx
Normal file
5
app/src/components/settings/chat.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import SettingsTab from "./tab";
|
||||
|
||||
export default function ChatOptionsTab(props: any) {
|
||||
return <SettingsTab name="chat" />
|
||||
}
|
@@ -1,13 +1,14 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { Button, Drawer, Tabs } from "@mantine/core";
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import UserOptionsTab from './user';
|
||||
import GenerationOptionsTab from './options';
|
||||
import ChatOptionsTab from './chat';
|
||||
import { useAppDispatch, useAppSelector } from '../../store';
|
||||
import { closeSettingsUI, selectSettingsTab, setTab } from '../../store/settings-ui';
|
||||
import { closeSettingsUI, selectSettingsOption, selectSettingsTab, setTab } from '../../store/settings-ui';
|
||||
import SpeechOptionsTab from './speech';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import UIPreferencesTab from './ui-preferences';
|
||||
|
||||
const Container = styled.div`
|
||||
padding: .4rem 1rem 1rem 1rem;
|
||||
@@ -76,12 +77,19 @@ export interface SettingsDrawerProps {
|
||||
|
||||
export default function SettingsDrawer(props: SettingsDrawerProps) {
|
||||
const tab = useAppSelector(selectSettingsTab);
|
||||
const option = useAppSelector(selectSettingsOption);
|
||||
const small = useMediaQuery('(max-width: 40em)');
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const close = useCallback(() => dispatch(closeSettingsUI()), [dispatch]);
|
||||
const onTabChange = useCallback((tab: string) => dispatch(setTab(tab)), [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
document.querySelector('.focused')?.scrollIntoView();
|
||||
}, 1000);
|
||||
}, [tab, option]);
|
||||
|
||||
return (
|
||||
<Drawer size="50rem"
|
||||
position='right'
|
||||
@@ -93,13 +101,15 @@ export default function SettingsDrawer(props: SettingsDrawerProps) {
|
||||
<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="chat">Chat</Tabs.Tab>
|
||||
<Tabs.Tab value="speech">Speech</Tabs.Tab>
|
||||
<Tabs.Tab value="ui">UI</Tabs.Tab>
|
||||
<Tabs.Tab value="user">User</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<UserOptionsTab />
|
||||
<GenerationOptionsTab />
|
||||
<ChatOptionsTab />
|
||||
<SpeechOptionsTab />
|
||||
<UIPreferencesTab />
|
||||
<UserOptionsTab />
|
||||
</Tabs>
|
||||
<div id="save">
|
||||
<Button variant="light" fullWidth size="md" onClick={close}>
|
||||
|
@@ -1,12 +1,25 @@
|
||||
export default function SettingsOption(props: {
|
||||
focused?: boolean;
|
||||
heading?: string;
|
||||
description?: any;
|
||||
children?: any;
|
||||
span?: number;
|
||||
collapsed?: boolean;
|
||||
}) {
|
||||
if (!props.heading || props.collapsed) {
|
||||
return props.children;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={props.focused ? 'focused' : ''}>
|
||||
{props.heading && <h3>{props.heading}</h3>}
|
||||
{props.description && <div style={{
|
||||
fontSize: "90%",
|
||||
opacity: 0.9,
|
||||
marginTop: '-0.5rem',
|
||||
}}>
|
||||
{props.description}
|
||||
</div>}
|
||||
{props.children}
|
||||
</section>
|
||||
);
|
||||
|
@@ -1,101 +0,0 @@
|
||||
import SettingsTab from "./tab";
|
||||
import SettingsOption from "./option";
|
||||
import { Button, Select, Slider, Textarea } from "@mantine/core";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { defaultSystemPrompt, defaultModel } from "../../openai";
|
||||
import { useAppDispatch, useAppSelector } from "../../store";
|
||||
import { resetModel, setModel, selectModel, 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 model = useAppSelector(selectModel);
|
||||
const temperature = useAppSelector(selectTemperature);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const onSystemPromptChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => dispatch(setSystemPrompt(event.target.value)), [dispatch]);
|
||||
const onModelChange = useCallback((value: string) => dispatch(setModel(value)), [dispatch]);
|
||||
const onResetSystemPrompt = useCallback(() => dispatch(resetSystemPrompt()), [dispatch]);
|
||||
const onResetModel = useCallback(() => dispatch(resetModel()), [dispatch]);
|
||||
const onTemperatureChange = useCallback((value: number) => dispatch(setTemperature(value)), [dispatch]);
|
||||
|
||||
const resettableSystemPromopt = initialSystemPrompt
|
||||
&& (initialSystemPrompt?.trim() !== defaultSystemPrompt.trim());
|
||||
|
||||
const resettableModel = model
|
||||
&& (model?.trim() !== defaultModel.trim());
|
||||
|
||||
const systemPromptOption = useMemo(() => (
|
||||
<SettingsOption heading={intl.formatMessage({ defaultMessage: "System Prompt", description: "Heading for the setting that lets users customize the System Prompt, on the settings screen" })}
|
||||
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>
|
||||
{resettableSystemPromopt && <Button size="xs" compact variant="light" onClick={onResetSystemPrompt}>
|
||||
<FormattedMessage defaultMessage="Reset to default" />
|
||||
</Button>}
|
||||
</SettingsOption>
|
||||
), [option, initialSystemPrompt, resettableSystemPromopt, onSystemPromptChange, onResetSystemPrompt]);
|
||||
|
||||
const modelOption = useMemo(() => (
|
||||
<SettingsOption heading={intl.formatMessage({ defaultMessage: "Model", description: "Heading for the setting that lets users choose a model to interact with, on the settings screen" })}
|
||||
focused={option === 'model'}>
|
||||
<Select
|
||||
value={model || defaultModel}
|
||||
data={[
|
||||
{
|
||||
label: intl.formatMessage({ defaultMessage: "GPT 3.5 Turbo (default)" }),
|
||||
value: "gpt-3.5-turbo",
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({ defaultMessage: "GPT 4 (requires invite)" }),
|
||||
value: "gpt-4",
|
||||
},
|
||||
]}
|
||||
onChange={onModelChange} />
|
||||
{model === 'gpt-4' && (
|
||||
<p style={{ marginBottom: '0.7rem' }}>
|
||||
<FormattedMessage defaultMessage="Note: GPT-4 will only work if your OpenAI account has been granted access to the new model. <a>Request access here.</a>"
|
||||
values={{ a: chunk => <a href="https://openai.com/waitlist/gpt-4-api" target="_blank" rel="noreferer">{chunk}</a> }} />
|
||||
</p>
|
||||
)}
|
||||
{resettableModel && <Button size="xs" compact variant="light" onClick={onResetModel}>
|
||||
<FormattedMessage defaultMessage="Reset to default" />
|
||||
</Button>}
|
||||
</SettingsOption>
|
||||
), [option, model, resettableModel, onModelChange, onResetModel]);
|
||||
|
||||
const temperatureOption = useMemo(() => (
|
||||
<SettingsOption heading={intl.formatMessage({
|
||||
defaultMessage: "Temperature: {temperature, number, ::.0}",
|
||||
description: "Label for the button that opens a modal for setting the 'temperature' (randomness) of AI responses",
|
||||
}, { 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}
|
||||
{modelOption}
|
||||
{temperatureOption}
|
||||
</SettingsTab>
|
||||
), [systemPromptOption, modelOption, temperatureOption]);
|
||||
|
||||
return elem;
|
||||
}
|
@@ -1,81 +1,5 @@
|
||||
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)', description: "Heading for the ElevenLabs API key setting on the settings screen" })}
|
||||
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', description: 'Heading for the setting that lets users choose an ElevenLabs text-to-speech voice, on the settings screen' })}
|
||||
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" description="Label for the button that plays a preview of the selected ElevenLabs text-to-speech 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;
|
||||
return <SettingsTab name="speech" />
|
||||
}
|
@@ -1,5 +1,15 @@
|
||||
import styled from "@emotion/styled";
|
||||
import { Tabs } from "@mantine/core";
|
||||
import { Button, NumberInput, PasswordInput, Select, Slider, Switch, Tabs, Text, TextInput, Textarea } from "@mantine/core";
|
||||
import { Option } from "../../core/options/option";
|
||||
import SettingsOption from "./option";
|
||||
import { selectSettingsOption } from "../../store/settings-ui";
|
||||
import { useAppSelector } from "../../store";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { useOption } from "../../core/options/use-option";
|
||||
import { Context, useAppContext } from "../../core/context";
|
||||
import { pluginMetadata as pluginMetadata } from "../../core/plugins/metadata";
|
||||
import { globalOptions } from "../../global-options";
|
||||
import { useEffect } from "react";
|
||||
|
||||
const Settings = styled.div`
|
||||
font-family: "Work Sans", sans-serif;
|
||||
@@ -9,6 +19,11 @@ const Settings = styled.div`
|
||||
margin-bottom: .618rem;
|
||||
padding: 0.618rem;
|
||||
|
||||
section {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
@@ -29,6 +44,13 @@ const Settings = styled.div`
|
||||
code {
|
||||
font-family: "Fira Code", monospace;
|
||||
}
|
||||
|
||||
.mantine-NumberInput-root, .slider-wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.focused {
|
||||
@@ -50,14 +72,184 @@ const Settings = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const OptionWrapper = styled.div`
|
||||
& {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
* {
|
||||
font-family: "Work Sans", sans-serif;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export function PluginOptionWidget(props: { pluginID: string, option: Option, chatID?: string | null | undefined, context: Context }) {
|
||||
const requestedOption = useAppSelector(selectSettingsOption);
|
||||
|
||||
const option = props.option;
|
||||
|
||||
const [_value, setValue, renderProps] = useOption(props.pluginID, option.id, props.chatID || undefined);
|
||||
|
||||
const value = _value ?? option.defaultValue;
|
||||
|
||||
if (option.defaultValue && (typeof value === 'undefined' || value === null)) {
|
||||
console.warn(`expected option value for ${props.pluginID}.${option.id}, got:`, _value);
|
||||
}
|
||||
|
||||
if (renderProps.hidden) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let component: any;
|
||||
|
||||
switch (renderProps.type) {
|
||||
case "textarea":
|
||||
component = (
|
||||
<Textarea label={!option.displayAsSeparateSection ? renderProps.label : null}
|
||||
placeholder={renderProps.placeholder}
|
||||
disabled={renderProps.disabled}
|
||||
value={value || ''}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
minRows={5} />
|
||||
);
|
||||
break;
|
||||
case "select":
|
||||
component = (
|
||||
<Select label={!option.displayAsSeparateSection ? renderProps.label : null}
|
||||
placeholder={renderProps.placeholder}
|
||||
disabled={renderProps.disabled}
|
||||
value={value || ''}
|
||||
onChange={value => setValue(value)}
|
||||
data={renderProps.options ?? []}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "slider":
|
||||
component = (
|
||||
<div className="slider-wrapper">
|
||||
{!option.displayAsSeparateSection && <Text size='sm' weight={500}>{renderProps.label}:</Text>}
|
||||
<Slider label={value.toString()}
|
||||
disabled={renderProps.disabled}
|
||||
value={value}
|
||||
onChange={v => setValue(v)}
|
||||
min={renderProps.min}
|
||||
max={renderProps.max}
|
||||
step={renderProps.step}
|
||||
style={{
|
||||
minWidth: '10rem',
|
||||
flexGrow: 1,
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
break;
|
||||
case "number":
|
||||
component = (
|
||||
<NumberInput label={!option.displayAsSeparateSection ? (renderProps.label + ':') : null}
|
||||
disabled={renderProps.disabled}
|
||||
value={value ?? undefined}
|
||||
onChange={v => setValue(v)}
|
||||
min={renderProps.min}
|
||||
max={renderProps.max}
|
||||
step={renderProps.step} />
|
||||
);
|
||||
break;
|
||||
case "checkbox":
|
||||
component = (
|
||||
<Switch label={!option.displayAsSeparateSection ? renderProps.label : null}
|
||||
disabled={renderProps.disabled}
|
||||
checked={value}
|
||||
onChange={e => setValue(e.target.checked)} />
|
||||
);
|
||||
break;
|
||||
case "password":
|
||||
component = (
|
||||
<PasswordInput label={!option.displayAsSeparateSection ? renderProps.label : null}
|
||||
placeholder={renderProps.placeholder}
|
||||
disabled={renderProps.disabled}
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)} />
|
||||
);
|
||||
break;
|
||||
case "text":
|
||||
default:
|
||||
component = (
|
||||
<TextInput label={!option.displayAsSeparateSection ? renderProps.label : null}
|
||||
placeholder={renderProps.placeholder}
|
||||
disabled={renderProps.disabled}
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)} />
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
const focused = !!requestedOption && option.id === requestedOption;
|
||||
|
||||
const elem = <OptionWrapper className={(focused && !option.displayAsSeparateSection) ? 'focused' : ''}>
|
||||
{component}
|
||||
{typeof renderProps.description?.props === 'undefined' && <p style={{ marginBottom: '0.7rem' }}>{renderProps.description}</p>}
|
||||
{typeof renderProps.description?.props !== 'undefined' && renderProps.description}
|
||||
</OptionWrapper>;
|
||||
|
||||
if (option.displayAsSeparateSection) {
|
||||
return <SettingsOption heading={renderProps.label} focused={focused}>
|
||||
{elem}
|
||||
{option.resettable && <div style={{
|
||||
display: 'flex',
|
||||
gap: '1rem',
|
||||
marginTop: '1rem',
|
||||
}}>
|
||||
<Button size="xs" compact variant="light" onClick={() => setValue(option.defaultValue)}>
|
||||
<FormattedMessage defaultMessage="Reset to default" />
|
||||
</Button>
|
||||
</div>}
|
||||
</SettingsOption>;
|
||||
}
|
||||
|
||||
return elem;
|
||||
}
|
||||
|
||||
export default function SettingsTab(props: {
|
||||
name: string;
|
||||
children?: any;
|
||||
}) {
|
||||
const context = useAppContext();
|
||||
|
||||
const optionSets = [...globalOptions, ...pluginMetadata]
|
||||
.map(metadata => ({
|
||||
id: metadata.id,
|
||||
name: metadata.name,
|
||||
description: metadata.description,
|
||||
options: metadata.options.filter(o => o.displayOnSettingsScreen === props.name),
|
||||
resettable: metadata.options.filter(o => o.displayOnSettingsScreen === props.name && o.resettable && !o.displayAsSeparateSection).length > 0,
|
||||
collapsed: metadata.options.filter(o => o.displayOnSettingsScreen === props.name && o.displayAsSeparateSection).length > 0,
|
||||
hidden: typeof metadata.hidden === 'function' ? metadata.hidden(context.chat.options) : metadata.hidden,
|
||||
}))
|
||||
.filter(({ options, hidden }) => options.length && !hidden);
|
||||
|
||||
return (
|
||||
<Tabs.Panel value={props.name}>
|
||||
<Settings>
|
||||
{props.children}
|
||||
{optionSets.map(({ name, id, description, options, resettable, collapsed }) => <>
|
||||
<SettingsOption heading={name} description={description} collapsed={collapsed} key={id}>
|
||||
{options.map(o => <PluginOptionWidget
|
||||
pluginID={id}
|
||||
option={o}
|
||||
chatID={context.id}
|
||||
context={context}
|
||||
key={id + "." + o.id} />)}
|
||||
{resettable && <div style={{
|
||||
display: 'flex',
|
||||
gap: '1rem',
|
||||
marginTop: '1rem',
|
||||
}}>
|
||||
<Button size="xs" compact variant="light" onClick={() => context.chat.resetPluginOptions(id, context.id)}>
|
||||
<FormattedMessage defaultMessage="Reset to default" />
|
||||
</Button>
|
||||
</div>}
|
||||
</SettingsOption>
|
||||
</>)}
|
||||
</Settings>
|
||||
</Tabs.Panel>
|
||||
);
|
||||
|
5
app/src/components/settings/ui-preferences.tsx
Normal file
5
app/src/components/settings/ui-preferences.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import SettingsTab from "./tab";
|
||||
|
||||
export default function UIPreferencesTab(props: any) {
|
||||
return <SettingsTab name="ui" />
|
||||
}
|
@@ -1,34 +1,23 @@
|
||||
import SettingsTab from "./tab";
|
||||
import { Button, FileButton } from "@mantine/core";
|
||||
import { importChat } from "../../core/chat/chat-persistance";
|
||||
import { Chat, serializeChat } from "../../core/chat/types";
|
||||
import { useAppContext } from "../../core/context";
|
||||
import SettingsOption from "./option";
|
||||
import { Button, Checkbox, TextInput } from "@mantine/core";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useAppDispatch, useAppSelector } from "../../store";
|
||||
import { selectOpenAIApiKey, setOpenAIApiKeyFromEvent, selectUseOpenAIWhisper, setUseOpenAIWhisperFromEvent } from "../../store/api-keys";
|
||||
import { selectSettingsOption } from "../../store/settings-ui";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { supportsSpeechRecognition } from "../../speech-recognition-types";
|
||||
import { useAppContext } from "../../context";
|
||||
import { serializeChat } from "../../types";
|
||||
import SettingsTab from "./tab";
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
export default function UserOptionsTab(props: any) {
|
||||
const option = useAppSelector(selectSettingsOption);
|
||||
const openaiApiKey = useAppSelector(selectOpenAIApiKey);
|
||||
const useOpenAIWhisper = useAppSelector(selectUseOpenAIWhisper);
|
||||
const intl = useIntl()
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const onOpenAIApiKeyChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => dispatch(setOpenAIApiKeyFromEvent(event)), [dispatch]);
|
||||
const onUseOpenAIWhisperChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => dispatch(setUseOpenAIWhisperFromEvent(event)), [dispatch]);
|
||||
|
||||
const context = useAppContext();
|
||||
|
||||
const doc = context.chat.doc;
|
||||
const getData = useCallback(async () => {
|
||||
const chats = Array.from(context.chat.chats.values());
|
||||
return chats.map(chat => ({
|
||||
...chat,
|
||||
messages: chat.messages.serialize(),
|
||||
}));
|
||||
const chats = context.chat.all() as Chat[];
|
||||
return chats.map(chat => serializeChat(chat));
|
||||
}, [context.chat]);
|
||||
|
||||
const [importedChats, setImportedChats] = useState<number | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const handleExport = useCallback(async () => {
|
||||
const data = await getData();
|
||||
const json = JSON.stringify(data);
|
||||
@@ -40,42 +29,62 @@ export default function UserOptionsTab(props: any) {
|
||||
link.click();
|
||||
}, [getData]);
|
||||
|
||||
const elem = useMemo(() => (
|
||||
const handleImport = useCallback(
|
||||
async (file: File) => {
|
||||
try {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const json = e.target?.result as string;
|
||||
const data = JSON.parse(json) as Chat[];
|
||||
if (data.length > 0) {
|
||||
context.chat.doc.transact(() => {
|
||||
for (const chat of data) {
|
||||
try {
|
||||
importChat(doc, chat);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
setImportedChats(data.length);
|
||||
setErrorMessage(null);
|
||||
} else {
|
||||
setErrorMessage("The imported file does not contain any chat data.");
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
} catch (error) {
|
||||
setErrorMessage("Failed to import chat data.");
|
||||
}
|
||||
},
|
||||
[doc]
|
||||
);
|
||||
|
||||
const successMessage = importedChats ? (
|
||||
<div style={{ color: 'green' }}>
|
||||
<i className="fa fa-check-circle"></i>
|
||||
<span style={{ marginLeft: '0.5em' }}>Imported {importedChats} chat(s)</span>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const errorMessageElement = errorMessage ? (
|
||||
<div style={{ color: 'red' }}>{errorMessage}</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<SettingsTab name="user">
|
||||
<SettingsOption heading="Export">
|
||||
<SettingsOption heading="Import and Export">
|
||||
<div>
|
||||
<Button variant="light" onClick={handleExport} style={{
|
||||
marginRight: '1rem',
|
||||
}}>Export</Button>
|
||||
<FileButton onChange={handleImport} accept=".json">
|
||||
{(props) => <Button variant="light" {...props}>Import</Button>}
|
||||
</FileButton>
|
||||
</div>
|
||||
</SettingsOption>
|
||||
<SettingsOption heading={intl.formatMessage({ defaultMessage: "Your OpenAI API Key", description: "Heading for the OpenAI API key setting on the settings screen" })}
|
||||
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." description="Label for the link that takes the user to the page on the OpenAI website where they can find their API key." />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
{supportsSpeechRecognition && <Checkbox
|
||||
style={{ marginTop: '1rem' }}
|
||||
id="use-openai-whisper-api" checked={useOpenAIWhisper!} onChange={onUseOpenAIWhisperChange}
|
||||
label="Use the OpenAI Whisper API for speech recognition."
|
||||
/>}
|
||||
|
||||
<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>
|
||||
{successMessage}
|
||||
{errorMessageElement}
|
||||
</SettingsOption>
|
||||
</SettingsTab>
|
||||
), [option, openaiApiKey, useOpenAIWhisper, onOpenAIApiKeyChange]);
|
||||
|
||||
return elem;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
@@ -1,10 +1,10 @@
|
||||
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 { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import { backend } from '../../backend';
|
||||
import { useAppContext } from '../../context';
|
||||
import { backend } from '../../core/backend';
|
||||
import { useAppContext } from '../../core/context';
|
||||
import { useAppDispatch, useAppSelector } from '../../store';
|
||||
import { setTab } from '../../store/settings-ui';
|
||||
import { selectSidebarOpen, toggleSidebar } from '../../store/sidebar';
|
||||
@@ -109,6 +109,18 @@ export default function Sidebar(props: {
|
||||
const onBurgerClick = useCallback(() => dispatch(toggleSidebar()), [dispatch]);
|
||||
const { ref, width } = useElementSize();
|
||||
|
||||
const [version, setVersion] = useState(0);
|
||||
const update = useCallback(() => {
|
||||
setVersion(v => v + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
context.chat.on('update', update);
|
||||
return () => {
|
||||
context.chat.off('update', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const burgerLabel = sidebarOpen
|
||||
? intl.formatMessage({ defaultMessage: "Close sidebar" })
|
||||
: intl.formatMessage({ defaultMessage: "Open sidebar" });
|
||||
@@ -122,14 +134,14 @@ export default function Sidebar(props: {
|
||||
<div className="sidebar-content">
|
||||
<RecentChats />
|
||||
</div>
|
||||
{backend.current && backend.current.isAuthenticated && (
|
||||
{context.authenticated && (
|
||||
<Menu width={width - 20}>
|
||||
<Menu.Target>
|
||||
<div className="sidebar-footer">
|
||||
<Avatar size="lg" src={backend.current!.user!.avatar} />
|
||||
<Avatar size="lg" src={context.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>}
|
||||
<strong>{context.user!.name || context.user!.email}</strong>
|
||||
{!!context.user!.name && <span>{context.user!.email}</span>}
|
||||
</div>
|
||||
<div className="spacer" />
|
||||
|
||||
@@ -144,17 +156,17 @@ export default function Sidebar(props: {
|
||||
}} icon={<i className="fas fa-gear" />}>
|
||||
<FormattedMessage defaultMessage={"User settings"} description="Menu item that opens the user settings screen" />
|
||||
</Menu.Item>
|
||||
{/*
|
||||
|
||||
<Menu.Divider />
|
||||
<Menu.Item color="red" onClick={() => backend.current?.logout()} icon={<i className="fas fa-sign-out-alt" />}>
|
||||
<FormattedMessage defaultMessage={"Sign out"} />
|
||||
</Menu.Item>
|
||||
*/}
|
||||
</Menu.Item>
|
||||
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</Container>
|
||||
), [sidebarOpen, width, ref, burgerLabel, onBurgerClick, dispatch, context.chat.chats.size]);
|
||||
), [sidebarOpen, width, ref, burgerLabel, onBurgerClick, dispatch, version]);
|
||||
|
||||
return elem;
|
||||
}
|
@@ -1,13 +1,13 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAppContext } from '../../context';
|
||||
import { useAppContext } from '../../core/context';
|
||||
import { useAppDispatch } from '../../store';
|
||||
import { toggleSidebar } from '../../store/sidebar';
|
||||
import { ActionIcon, Menu } from '@mantine/core';
|
||||
import { ActionIcon, Button, Loader, Menu, TextInput, Textarea } from '@mantine/core';
|
||||
import { useModals } from '@mantine/modals';
|
||||
import { backend } from '../../backend';
|
||||
import { backend } from '../../core/backend';
|
||||
|
||||
const Container = styled.div`
|
||||
margin: calc(1.618rem - 1rem);
|
||||
@@ -56,9 +56,16 @@ const ChatListItemLink = styled(Link)`
|
||||
|
||||
.mantine-ActionIcon-root {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
right: 0.0rem;
|
||||
top: 50%;
|
||||
margin-top: -14px;
|
||||
margin-top: -22px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.mantine-ActionIcon-root {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -68,7 +75,10 @@ function ChatListItem(props: { chat: any, onClick: any, selected: boolean }) {
|
||||
const modals = useModals();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onDelete = useCallback(() => {
|
||||
const onDelete = useCallback((e?: React.MouseEvent) => {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
|
||||
modals.openConfirmModal({
|
||||
title: "Are you sure you want to delete this chat?",
|
||||
children: <p style={{ lineHeight: 1.7 }}>The chat "{c.title}" will be permanently deleted. This cannot be undone.</p>,
|
||||
@@ -93,33 +103,79 @@ function ChatListItem(props: { chat: any, onClick: any, selected: boolean }) {
|
||||
confirm: "Try again",
|
||||
cancel: "Cancel",
|
||||
},
|
||||
onConfirm: onDelete,
|
||||
onConfirm: () => onDelete(),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [c.chatID, c.title]);
|
||||
|
||||
const onRename = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Display a modal with a TextInput
|
||||
modals.openModal({
|
||||
title: "Rename chat",
|
||||
children: <div>
|
||||
<Textarea
|
||||
id="chat-title"
|
||||
defaultValue={c.title}
|
||||
maxLength={500}
|
||||
autosize
|
||||
required />
|
||||
<Button
|
||||
fullWidth
|
||||
variant="light"
|
||||
style={{ marginTop: '1rem' }}
|
||||
onClick={() => {
|
||||
const title = document.querySelector<HTMLInputElement>('#chat-title')?.value?.trim();
|
||||
const ychat = context.chat.doc.getYChat(c.chatID);
|
||||
if (ychat && title && title !== ychat?.title) {
|
||||
ychat.title = title;
|
||||
}
|
||||
modals.closeAll();
|
||||
}}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>,
|
||||
});
|
||||
}, [c.chatID, c.title]);
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
const toggleMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setMenuOpen(open => !open);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ChatListItemLink to={'/chat/' + c.chatID}
|
||||
onClick={props.onClick}
|
||||
data-chat-id={c.chatID}
|
||||
className={props.selected ? 'selected' : ''}>
|
||||
<strong>{c.title || <FormattedMessage defaultMessage={"Untitled"} description="default title for untitled chat sessions" />}</strong>
|
||||
{props.selected && (
|
||||
<Menu>
|
||||
<Menu.Target>
|
||||
<ActionIcon>
|
||||
<i className="fas fa-ellipsis" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={onDelete} color="red" icon={<i className="fa fa-trash" />}>
|
||||
<FormattedMessage defaultMessage={"Delete this chat"} />
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
<Menu opened={menuOpen}
|
||||
closeOnClickOutside={true}
|
||||
closeOnEscape={true}
|
||||
onClose={() => setMenuOpen(false)}>
|
||||
<Menu.Target>
|
||||
<ActionIcon size="xl" onClick={toggleMenu}>
|
||||
<i className="fas fa-ellipsis" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={onRename} icon={<i className="fa fa-edit" />}>
|
||||
<FormattedMessage defaultMessage={"Rename this chat"} />
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item onClick={onDelete} color="red" icon={<i className="fa fa-trash" />}>
|
||||
<FormattedMessage defaultMessage={"Delete this chat"} />
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</ChatListItemLink>
|
||||
);
|
||||
}
|
||||
@@ -129,7 +185,7 @@ export default function RecentChats(props: any) {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const currentChatID = context.currentChat.chat?.id;
|
||||
const recentChats = context.chat.search.query('');
|
||||
const recentChats = context.chat.searchChats('');
|
||||
|
||||
const onClick = useCallback((e: React.MouseEvent) => {
|
||||
if (e.currentTarget.closest('button')) {
|
||||
@@ -152,6 +208,8 @@ export default function RecentChats(props: any) {
|
||||
}
|
||||
}, [currentChatID]);
|
||||
|
||||
const synced = !backend.current || backend.current?.isSynced();
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{recentChats.length > 0 && <ChatList>
|
||||
@@ -159,7 +217,10 @@ export default function RecentChats(props: any) {
|
||||
<ChatListItem key={c.chatID} chat={c} onClick={onClick} selected={c.chatID === currentChatID} />
|
||||
))}
|
||||
</ChatList>}
|
||||
{recentChats.length === 0 && <Empty>
|
||||
{recentChats.length === 0 && !synced && <Empty>
|
||||
<Loader size="sm" variant="dots" />
|
||||
</Empty>}
|
||||
{recentChats.length === 0 && synced && <Empty>
|
||||
<FormattedMessage defaultMessage={"No chats yet."} description="Message shown on the Chat History screen for new users who haven't started their first chat session" />
|
||||
</Empty>}
|
||||
</Container>
|
||||
|
63
app/src/components/tts-button.tsx
Normal file
63
app/src/components/tts-button.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Button } from "@mantine/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { useTTS } from "../core/tts/use-tts";
|
||||
import { useAppDispatch } from "../store";
|
||||
import { setTabAndOption } from "../store/settings-ui";
|
||||
|
||||
const autoplayed = new Set<string>();
|
||||
|
||||
export function TTSButton(props: { id: string, selector: string, complete: boolean, autoplay?: boolean }) {
|
||||
const dispatch = useAppDispatch();
|
||||
const { key, state, voice, autoplayEnabled, play, pause, cancel, setSourceElement, setComplete } = useTTS();
|
||||
const [clicked, setClicked] = useState(false);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
setClicked(true);
|
||||
|
||||
if (!voice) {
|
||||
dispatch(setTabAndOption({ tab: 'speech', option: 'service' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state || key !== props.id) {
|
||||
setSourceElement(props.id, document.querySelector(props.selector)!);
|
||||
play();
|
||||
} else {
|
||||
cancel();
|
||||
}
|
||||
setComplete(props.complete);
|
||||
}, [state, key, props.id, props.selector, props.complete, voice]); //
|
||||
|
||||
useEffect(() => {
|
||||
if (key === props.id) {
|
||||
setComplete(props.complete);
|
||||
}
|
||||
}, [key, props.id, props.complete]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoplayEnabled && props.autoplay && key !== props.id && voice && !clicked && !autoplayed.has(props.id) && document.visibilityState === 'visible') {
|
||||
autoplayed.add(props.id);
|
||||
setSourceElement(props.id, document.querySelector(props.selector)!);
|
||||
play();
|
||||
}
|
||||
}, [clicked, key, voice, autoplayEnabled, props.id, props.selector, props.complete, props.autoplay]);
|
||||
|
||||
let active = state && key === props.id;
|
||||
|
||||
return (<>
|
||||
<Button variant="subtle" size="sm" compact onClickCapture={onClick} loading={active && state?.buffering}>
|
||||
{!active && <i className="fa fa-headphones" />}
|
||||
{!active && <span>
|
||||
<FormattedMessage defaultMessage="Play" description="Label for the button that starts text-to-speech playback" />
|
||||
</span>}
|
||||
{active && state?.buffering && <span>
|
||||
<FormattedMessage defaultMessage="Loading audio..." description="Message indicating that text-to-speech audio is buffering" />
|
||||
</span>}
|
||||
{active && !state?.buffering && <span>
|
||||
<FormattedMessage defaultMessage="Stop" description="Label for the button that stops text-to-speech playback" />
|
||||
</span>}
|
||||
</Button>
|
||||
{JSON.stringify(state)}
|
||||
</>);
|
||||
}
|
130
app/src/components/tts-controls.tsx
Normal file
130
app/src/components/tts-controls.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { ActionIcon, Button } from '@mantine/core';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useTTS } from '../core/tts/use-tts';
|
||||
import { useAppContext } from '../core/context';
|
||||
import { APP_NAME } from '../values';
|
||||
import { useHotkeys } from '@mantine/hooks';
|
||||
|
||||
const Container = styled.div`
|
||||
background: #292933;
|
||||
border-top: thin solid #393933;
|
||||
padding: 1rem;
|
||||
// padding-bottom: 0.6rem;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
text-align: center;
|
||||
|
||||
p {
|
||||
font-family: "Work Sans", sans-serif;
|
||||
font-size: 80%;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
|
||||
// .mantine-ActionIcon-root:disabled {
|
||||
// background: transparent;
|
||||
// border-color: transparent;
|
||||
// }
|
||||
}
|
||||
`;
|
||||
|
||||
export default function AudioControls(props: any) {
|
||||
const context = useAppContext();
|
||||
const { state, play, pause, cancel } = useTTS();
|
||||
|
||||
const handlePlayPause = useCallback(() => {
|
||||
if (state?.playing) {
|
||||
pause();
|
||||
} else {
|
||||
play();
|
||||
}
|
||||
}, [state, pause, play]);
|
||||
|
||||
const handlePrevious = useCallback(() => {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
play(state.index - 1);
|
||||
}, [state, play]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
play(state.index + 1);
|
||||
}, [state, play]);
|
||||
|
||||
const handleJumpToStart = useCallback(() => {
|
||||
play(0);
|
||||
}, [play]);
|
||||
|
||||
const handleJumpToEnd = useCallback(() => {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
play(state.length - 1);
|
||||
}, [state, play]);
|
||||
|
||||
useEffect(() => {
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: context.currentChat.chat?.title || APP_NAME,
|
||||
artist: APP_NAME,
|
||||
});
|
||||
|
||||
navigator.mediaSession.setActionHandler('play', handlePlayPause);
|
||||
navigator.mediaSession.setActionHandler('pause', handlePlayPause);
|
||||
navigator.mediaSession.setActionHandler('previoustrack', handlePrevious);
|
||||
navigator.mediaSession.setActionHandler('nexttrack', handleNext);
|
||||
}
|
||||
}, [context.currentChat.chat?.title, handlePlayPause, handlePrevious, handleNext]);
|
||||
|
||||
useEffect(() => {
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.playbackState = state?.playing ? 'playing' : 'paused';
|
||||
}
|
||||
}, [state?.playing]);
|
||||
|
||||
useHotkeys([
|
||||
['Space', handlePlayPause],
|
||||
]);
|
||||
|
||||
if (!state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="buttons">
|
||||
<ActionIcon onClick={handleJumpToStart} variant='light' color='blue'>
|
||||
<i className="fa fa-fast-backward" />
|
||||
</ActionIcon>
|
||||
<ActionIcon onClick={handlePrevious} variant='light' color='blue' disabled={state?.index === 0}>
|
||||
<i className="fa fa-step-backward" />
|
||||
</ActionIcon>
|
||||
<ActionIcon onClick={handlePlayPause} variant='light' color='blue'>
|
||||
<i className={state?.playing ? 'fa fa-pause' : 'fa fa-play'} />
|
||||
</ActionIcon>
|
||||
<ActionIcon onClick={handleNext} variant='light' color='blue' disabled={!state || (state.index === state.length - 1)}>
|
||||
<i className="fa fa-step-forward" />
|
||||
</ActionIcon>
|
||||
<ActionIcon onClick={handleJumpToEnd} variant='light' color='blue'>
|
||||
<i className="fa fa-fast-forward" />
|
||||
</ActionIcon>
|
||||
<ActionIcon onClick={cancel} variant='light' color='blue'>
|
||||
<i className="fa fa-close" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user