295 lines
9.0 KiB
TypeScript
295 lines
9.0 KiB
TypeScript
import styled from '@emotion/styled';
|
|
import { Button, CopyButton, Loader, Textarea } from '@mantine/core';
|
|
|
|
import { Message } from "../types";
|
|
import { share } from '../utils';
|
|
import { ElevenLabsReaderButton } from '../tts/elevenlabs';
|
|
import { Markdown } from './markdown';
|
|
import { useAppContext } from '../context';
|
|
import { useCallback, useMemo, useState } from 'react';
|
|
import { FormattedMessage, useIntl } from 'react-intl';
|
|
|
|
// hide for everyone but screen readers
|
|
const SROnly = styled.span`
|
|
position: fixed;
|
|
left: -9999px;
|
|
top: -9999px;
|
|
`;
|
|
|
|
const Container = styled.div`
|
|
&.by-user {
|
|
background: #22232b;
|
|
}
|
|
|
|
&.by-assistant {
|
|
background: #292933;
|
|
}
|
|
|
|
&.by-assistant + &.by-assistant, &.by-user + &.by-user {
|
|
border-top: 0.2rem dotted rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
&.by-assistant {
|
|
border-bottom: 0.2rem solid rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
position: relative;
|
|
padding: 1.618rem;
|
|
|
|
@media (max-width: 40em) {
|
|
padding: 1rem;
|
|
}
|
|
|
|
.inner {
|
|
margin: auto;
|
|
}
|
|
|
|
.content {
|
|
font-family: "Open Sans", sans-serif;
|
|
margin-top: 0rem;
|
|
max-width: 100%;
|
|
|
|
* {
|
|
color: white;
|
|
}
|
|
|
|
p, ol, ul, li, h1, h2, h3, h4, h5, h6, img, blockquote, &>pre {
|
|
max-width: 50rem;
|
|
margin-left: auto;
|
|
margin-right: auto;
|
|
}
|
|
|
|
img {
|
|
display: block;
|
|
max-width: 50rem;
|
|
|
|
@media (max-width: 50rem) {
|
|
max-width: 100%;
|
|
}
|
|
}
|
|
|
|
ol {
|
|
counter-reset: list-item;
|
|
|
|
li {
|
|
counter-increment: list-item;
|
|
}
|
|
}
|
|
|
|
em, i {
|
|
font-style: italic;
|
|
}
|
|
|
|
code {
|
|
&, * {
|
|
font-family: "Fira Code", monospace !important;
|
|
}
|
|
vertical-align: bottom;
|
|
}
|
|
|
|
/* Tables */
|
|
table {
|
|
margin-top: 1.618rem;
|
|
border-spacing: 0px;
|
|
border-collapse: collapse;
|
|
border: thin solid rgba(255, 255, 255, 0.1);
|
|
width: 100%;
|
|
max-width: 55rem;
|
|
margin-left: auto;
|
|
margin-right: auto;
|
|
}
|
|
td + td, th + th {
|
|
border-left: thin solid rgba(255, 255, 255, 0.1);
|
|
}
|
|
tr {
|
|
border-top: thin solid rgba(255, 255, 255, 0.1);
|
|
}
|
|
table td,
|
|
table th {
|
|
padding: 0.618rem 1rem;
|
|
}
|
|
th {
|
|
font-weight: 600;
|
|
background: rgba(255, 255, 255, 0.1);
|
|
}
|
|
}
|
|
|
|
.metadata {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
font-family: "Work Sans", sans-serif;
|
|
font-size: 0.8rem;
|
|
font-weight: 400;
|
|
opacity: 0.6;
|
|
max-width: 50rem;
|
|
margin-bottom: 0.0rem;
|
|
margin-right: -0.5rem;
|
|
margin-left: auto;
|
|
margin-right: auto;
|
|
|
|
span + span {
|
|
margin-left: 1em;
|
|
}
|
|
|
|
.fa {
|
|
font-size: 85%;
|
|
}
|
|
|
|
.fa + span {
|
|
margin-left: 0.2em;
|
|
}
|
|
|
|
.mantine-Button-root {
|
|
color: #ccc;
|
|
font-size: 0.8rem;
|
|
font-weight: 400;
|
|
|
|
.mantine-Button-label {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
}
|
|
}
|
|
|
|
.fa {
|
|
margin-right: 0.5em;
|
|
font-size: 85%;
|
|
}
|
|
|
|
.buttons {
|
|
text-align: right;
|
|
}
|
|
|
|
strong {
|
|
font-weight: bold;
|
|
}
|
|
`;
|
|
|
|
const EndOfChatMarker = styled.div`
|
|
position: absolute;
|
|
bottom: calc(-1.618rem - 0.5rem);
|
|
left: 50%;
|
|
width: 0.5rem;
|
|
height: 0.5rem;
|
|
margin-left: -0.25rem;
|
|
border-radius: 50%;
|
|
background: rgba(255, 255, 255, 0.1);
|
|
`;
|
|
|
|
const Editor = styled.div`
|
|
max-width: 50rem;
|
|
margin-left: auto;
|
|
margin-right: auto;
|
|
margin-top: 0.5rem;
|
|
|
|
.mantine-Button-root {
|
|
margin-top: 1rem;
|
|
}
|
|
`;
|
|
|
|
function InlineLoader() {
|
|
return (
|
|
<Loader variant="dots" size="xs" style={{
|
|
marginLeft: '1rem',
|
|
position: 'relative',
|
|
top: '-0.2rem',
|
|
}} />
|
|
);
|
|
}
|
|
|
|
export default function MessageComponent(props: { message: Message, last: boolean, share?: boolean }) {
|
|
const context = useAppContext();
|
|
const [editing, setEditing] = useState(false);
|
|
const [content, setContent] = useState('');
|
|
const intl = useIntl();
|
|
|
|
const getRoleName = useCallback((role: string, share = false) => {
|
|
switch (role) {
|
|
case 'user':
|
|
if (share) {
|
|
return intl.formatMessage({ id: 'role-user-formal', defaultMessage: 'User' });
|
|
} else {
|
|
return intl.formatMessage({ id: 'role-user', defaultMessage: 'You' });
|
|
}
|
|
break;
|
|
case 'assistant':
|
|
return intl.formatMessage({ id: 'role-chatgpt', defaultMessage: 'ChatGPT' });
|
|
case 'system':
|
|
return intl.formatMessage({ id: 'role-system', defaultMessage: 'System' });
|
|
default:
|
|
return role;
|
|
}
|
|
}, [intl]);
|
|
|
|
const elem = useMemo(() => {
|
|
if (props.message.role === 'system') {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Container className={"message by-" + props.message.role}>
|
|
<div className="inner">
|
|
<div className="metadata">
|
|
<span>
|
|
<strong>
|
|
{getRoleName(props.message.role, props.share)}<SROnly>:</SROnly>
|
|
</strong>
|
|
{props.message.role === 'assistant' && props.last && !props.message.done && <InlineLoader />}
|
|
</span>
|
|
{props.message.done && <ElevenLabsReaderButton selector={'.content-' + props.message.id} />}
|
|
<div style={{ flexGrow: 1 }} />
|
|
<CopyButton value={props.message.content}>
|
|
{({ copy, copied }) => (
|
|
<Button variant="subtle" size="sm" compact onClick={copy} style={{ marginLeft: '1rem' }}>
|
|
<i className="fa fa-clipboard" />
|
|
<span>{copied ? <FormattedMessage defaultMessage="Copied" /> : <FormattedMessage defaultMessage="Copy" />}</span>
|
|
</Button>
|
|
)}
|
|
</CopyButton>
|
|
{typeof navigator.share !== 'undefined' && (
|
|
<Button variant="subtle" size="sm" compact onClick={() => share(props.message.content)}>
|
|
<i className="fa fa-share" />
|
|
<span>
|
|
<FormattedMessage defaultMessage="Share" />
|
|
</span>
|
|
</Button>
|
|
)}
|
|
{!context.isShare && props.message.role === 'user' && (
|
|
<Button variant="subtle" size="sm" compact onClick={() => {
|
|
setContent(props.message.content);
|
|
setEditing(v => !v);
|
|
}}>
|
|
<i className="fa fa-edit" />
|
|
<span>{editing ? <FormattedMessage defaultMessage="Cancel" /> : <FormattedMessage defaultMessage="Edit" />}</span>
|
|
</Button>
|
|
)}
|
|
{!context.isShare && props.message.role === 'assistant' && (
|
|
<Button variant="subtle" size="sm" compact onClick={() => context.regenerateMessage(props.message)}>
|
|
<i className="fa fa-refresh" />
|
|
<span>
|
|
<FormattedMessage defaultMessage="Regenerate" />
|
|
</span>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{!editing && <Markdown content={props.message.content} className={"content content-" + props.message.id} />}
|
|
{editing && (<Editor>
|
|
<Textarea value={content}
|
|
onChange={e => setContent(e.currentTarget.value)}
|
|
autosize={true} />
|
|
<Button variant="light" onClick={() => context.editMessage(props.message, content)}>
|
|
<FormattedMessage defaultMessage="Save changes" />
|
|
</Button>
|
|
<Button variant="subtle" onClick={() => setEditing(false)}>
|
|
<FormattedMessage defaultMessage="Cancel" />
|
|
</Button>
|
|
</Editor>)}
|
|
</div>
|
|
{props.last && <EndOfChatMarker />}
|
|
</Container>
|
|
)
|
|
}, [props.last, props.share, editing, content, context, props.message, props.message.content]);
|
|
|
|
return elem;
|
|
} |