Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Switches to multilingual Elevenlabs #181

Open
wants to merge 22 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ RUN CI=true sh -c "cd /app && npm run start && rm -rf data"

COPY --from=build /app/build /app/public

LABEL org.opencontainers.image.source="https://github.com/cogentapps/chat-with-gpt"
LABEL org.opencontainers.image.source="https://github.com/jp-ipu/chat-with-gpt"
ENV PORT 3000

CMD ["npm", "run", "start"]
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@

Chat with GPT is an open-source, unofficial ChatGPT app with extra features and more ways to customize your experience. It connects ChatGPT with ElevenLabs to give ChatGPT a realistic human voice.

Try out the hosted version at: https://www.chatwithgpt.ai
This is a fork of from [cogent-apps](https://github.com/cogentapps/chat-with-gpt) that is no longer maintained.

Or [self-host with Docker](#running-on-your-own-computer).
You can [self-host with Docker](#running-on-your-own-computer).

Powered by the new ChatGPT API from OpenAI, this app has been developed using TypeScript + React. We welcome pull requests from the community!
You can build your own image, or use the ones [hosted in our public registry](https://hub.docker.com/r/jpipu/chat-with-gpt).

Powered by the new ChatGPT API from OpenAI, this app has been developed using TypeScript + React. We welcome pull requests from the community!
https://user-images.githubusercontent.com/127109874/223613258-0c4fef2e-1d05-43a1-ac38-e972dafc2f98.mp4


## Features

- 🚀 **Fast** response times.
Expand All @@ -18,6 +20,8 @@ https://user-images.githubusercontent.com/127109874/223613258-0c4fef2e-1d05-43a1
- 🌡 Adjust the **creativity and randomness** of responses by setting the Temperature setting. Higher temperature means more creativity.
- 💬 Give ChatGPT AI a **realistic human voice** by connecting your ElevenLabs text-to-speech account, or using your browser's built-in text-to-speech.
- 🎤 **Speech recognition** powered by OpenAI Whisper.
- :muscle: **Latest models** that are kept up-to-date with the releases from OpenAI.
- :camera: **Image** capabilities, so you can query GPT-4V about your pictures.
- ✉ **Share** your favorite chat sessions online using public share URLs.
- 📋 Easily **copy-and-paste** ChatGPT messages.
- ✏️ Edit your messages
Expand All @@ -44,7 +48,7 @@ Your API key is stored only on your device and never transmitted to anyone excep
To run on your own device, you can use Docker:

```
docker run -v $(pwd)/data:/app/data -p 3000:3000 ghcr.io/cogentapps/chat-with-gpt:release
docker run -v $(pwd)/data:/app/data -p 3000:3000 docker.io/jpipu/chat-with-gpt:release
```

Then navigate to http://localhost:3000 to view the app.
Expand All @@ -70,9 +74,17 @@ and restart the server. Login is required.
## Updating

```
docker pull ghcr.io/cogentapps/chat-with-gpt:release
docker pull docker.io/jpipu/chat-with-gpt:latest
```

## TODOs

- [ ] Save system prompt between browsers for a user.
- [ ] Support for Assistants.
- [ ] Specify custom API URL (e.g. [anyscale](https://docs.endpoints.anyscale.com/))
- [ ] Instructions on self-signing (See [1](https://github.com/cogentapps/chat-with-gpt/issues/132) and [2](https://github.com/cogentapps/chat-with-gpt/issues/170)) for running with secure connection.
- [ ] Change API key field so it's not auto-filled by password managers with password

## License

Chat with GPT is licensed under the MIT license. See the LICENSE file for more information.
2 changes: 1 addition & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
"@types/uuid": "^9.0.1",
"@vitejs/plugin-react": "^4.0.2",
"babel-plugin-formatjs": "^10.5.3",
"typescript": "^4.9.5",
"typescript": "^5",
"vite": "^4.4.1",
"vite-plugin-pwa": "^0.16.4",
"workbox-window": "^7.0.0"
Expand Down
98 changes: 93 additions & 5 deletions app/src/components/input.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import styled from '@emotion/styled';
import { Button, ActionIcon, Textarea, Loader, Popover } from '@mantine/core';
import { getHotkeyHandler, useHotkeys, useMediaQuery } from '@mantine/hooks';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAppContext } from '../core/context';
Expand All @@ -12,6 +12,7 @@ import { speechRecognition, supportsSpeechRecognition } from '../core/speech-rec
import { useWhisper } from '@chengsokdara/use-whisper';
import QuickSettings from './quick-settings';
import { useOption } from '../core/options/use-option';
import { set } from '../core/utils/idb';

const Container = styled.div`
background: #292933;
Expand Down Expand Up @@ -41,6 +42,10 @@ export default function MessageInput(props: MessageInputProps) {
const message = useAppSelector(selectMessage);
const [recording, setRecording] = useState(false);
const [speechError, setSpeechError] = useState<string | null>(null);
const [imageUrl, setImageUrl] = useState(null);
const [isImageUploading, setIsImageUploading] = useState(false);
const [uploadedImageName, setUploadedImageName] = useState('');
const [showImageNameDropdown, setShowImageNameDropdown] = useState(false);
const hasVerticalSpace = useMediaQuery('(min-height: 1000px)');
const [useOpenAIWhisper] = useOption<boolean>('speech-recognition', 'use-whisper');
const [openAIApiKey] = useOption<string>('openai', 'apiKey');
Expand All @@ -60,6 +65,7 @@ export default function MessageInput(props: MessageInputProps) {
const context = useAppContext();
const dispatch = useAppDispatch();
const intl = useIntl();
const fileInputRef = useRef(null);

const tab = useAppSelector(selectSettingsTab);

Expand All @@ -75,15 +81,16 @@ export default function MessageInput(props: MessageInputProps) {
const onSubmit = useCallback(async () => {
setSpeechError(null);

const id = await context.onNewMessage(message);
const id = await context.onNewMessage(message, imageUrl);

if (id) {
if (!window.location.pathname.includes(id)) {
navigate('/chat/' + id);
}
dispatch(setMessage(''));
setImageUrl(null);
}
}, [context, message, dispatch, navigate]);
}, [context, message, imageUrl, dispatch, navigate]);

const onSpeechError = useCallback((e: any) => {
console.error('speech recognition error', e);
Expand Down Expand Up @@ -195,6 +202,41 @@ export default function MessageInput(props: MessageInputProps) {
document.querySelector<HTMLTextAreaElement>('#message-input')?.blur();
}, []);

const onImageSelected = (event) => {
const file = event.target.files[0];
if (file) {
setIsImageUploading(true);
setUploadedImageName(file.name);
const reader = new FileReader();

reader.onload = (loadEvent) => {
const base64Image = loadEvent.target.result;
setImageUrl(base64Image); // Update the state with the base64 image data
setIsImageUploading(false);
console.log("Image uploaded: ", base64Image);
};

reader.onerror = (error) => {
// FIXME: Add error to UI
console.log('Error uploading image: ', error);
setIsImageUploading(false);
setUploadedImageName('');
}

reader.readAsDataURL(file);
}
};

function handleMouseEnter() {
if (imageUrl) {
setShowImageNameDropdown(true);
}
}

function handleMouseLeave() {
setShowImageNameDropdown(false);
}

const rightSection = useMemo(() => {
return (
<div style={{
Expand Down Expand Up @@ -243,15 +285,61 @@ export default function MessageInput(props: MessageInputProps) {
</div>
</Popover.Dropdown>
</Popover>}

<input
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={onImageSelected}
ref={fileInputRef}
/>

<div onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
<Popover width={200} position="bottom" withArrow shadow="md" opened={showImageNameDropdown}>
<Popover.Target>
<ActionIcon
size="xl"
onClick={() => fileInputRef.current.click()}
disabled={isImageUploading}
>
{isImageUploading ? (
<i className="fa fa-ellipsis-h" style={{ fontSize: '90%' }} />
) : uploadedImageName ? (
<i className="fa fa-check" style={{ fontSize: '90%' }} />
) : (
<i className="fa fa-camera" style={{ fontSize: '90%' }} />
)}
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
}}>
<p style={{
fontFamily: `"Work Sans", sans-serif`,
fontSize: '0.9rem',
textAlign: 'center',
marginBottom: '0.5rem',
}}>
{uploadedImageName}
</p>
</div>
</Popover.Dropdown>
</Popover>
</div>

<ActionIcon size="xl"
onClick={onSubmit}>
onClick={onSubmit}
disabled={isImageUploading}>
<i className="fa fa-paper-plane" style={{ fontSize: '90%' }} />
</ActionIcon>
</>
)}
</div>
);
}, [recording, transcribing, onSubmit, onSpeechStart, props.disabled, context.generating, speechError, onHideSpeechError, showMicrophoneButton]);
}, [recording, transcribing, isImageUploading, imageUrl, showImageNameDropdown, onSubmit, onSpeechStart, props.disabled, context.generating, speechError, onHideSpeechError, showMicrophoneButton]);

const disabled = context.generating;

Expand Down
4 changes: 2 additions & 2 deletions app/src/components/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,14 @@ export function Markdown(props: MarkdownProps) {
remarkPlugins.push(remarkMath);
rehypePlugins.push(rehypeKatex);
}

return <div className={classes.join(' ')}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={{
ol({ start, children }) {
return <ol start={start ?? 1} style={{ counterReset: `list-item ${(start || 1)}` }}>
return <ol start={start ?? 0} style={{ counterReset: `list-item ${(start || 0)}` }}>
{children}
</ol>;
},
Expand Down
17 changes: 12 additions & 5 deletions app/src/components/message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,10 @@ export default function MessageComponent(props: { message: Message, last: boolea
return null;
}

const imageElement = props.message.image_url ? (
<img src={props.message.image_url} alt="Message content" className="content-image" />
) : null;

return (
<Container className={"message by-" + props.message.role}>
<div className="inner">
Expand All @@ -257,7 +261,7 @@ export default function MessageComponent(props: { message: Message, last: boolea
{({ 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" />
{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>
)}
Expand Down Expand Up @@ -291,9 +295,12 @@ export default function MessageComponent(props: { message: Message, last: boolea
</Button>
)}
</div>
{!editing && <Markdown content={props.message.content}
katex={katex}
className={"content content-" + props.message.id} />}
{!editing && (
<div className={"content view-content-" + props.message.id}>
<Markdown content={props.message.content} katex={katex} className={"content content-" + props.message.id} />
{imageElement}
</div>
)}
{editing && (<Editor>
<Textarea value={content}
onChange={e => setContent(e.currentTarget.value)}
Expand All @@ -309,7 +316,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, tab]);
}, [props.last, props.share, editing, content, context, props.message, props.message.content, props.message.image_url, tab]);

return elem;
}
18 changes: 9 additions & 9 deletions app/src/core/chat/create-reply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import EventEmitter from "events";
import { createChatCompletion, createStreamingChatCompletion } from "./openai";
import { PluginContext } from "../plugins/plugin-context";
import { pluginRunner } from "../plugins/plugin-runner";
import { Chat, Message, OpenAIMessage, Parameters, getOpenAIMessageFromMessage } from "./types";
import { Chat, Message, OpenAIMessage, Parameters, getOpenAIMessageFromMessage, getTextContentFromOpenAIMessageContent } from "./types";
import { EventEmitterAsyncIterator } from "../utils/event-emitter-async-iterator";
import { YChat } from "./y-chat";
import { OptionsManager } from "../options";
Expand All @@ -17,11 +17,11 @@ export class ReplyRequest extends EventEmitter {
private cancelSSE: any;

constructor(private chat: Chat,
private yChat: YChat,
private messages: Message[],
private replyID: string,
private requestedParameters: Parameters,
private pluginOptions: OptionsManager) {
private yChat: YChat,
private messages: Message[],
private replyID: string,
private requestedParameters: Parameters,
private pluginOptions: OptionsManager) {
super();
this.mutatedMessages = [...messages];
this.mutatedMessages = messages.map(m => getOpenAIMessageFromMessage(m));
Expand Down Expand Up @@ -57,8 +57,8 @@ export class ReplyRequest extends EventEmitter {

this.timer = setInterval(() => {
const sinceLastChunk = Date.now() - this.lastChunkReceivedAt;
if (sinceLastChunk > 30000 && !this.done) {
this.onError('no response from OpenAI in the last 30 seconds');
if (sinceLastChunk > 60000 && !this.done) {
this.onError('no response from OpenAI in the last 60 seconds');
}
}, 2000);
}
Expand Down Expand Up @@ -122,7 +122,7 @@ export class ReplyRequest extends EventEmitter {
content: this.content,
}, this.mutatedMessages, this.mutatedParameters, false);

this.content = output.content;
this.content = getTextContentFromOpenAIMessageContent(output.content);
});

this.yChat.setPendingMessageContent(this.replyID, this.content);
Expand Down
Loading