Initial commit

This commit is contained in:
Niek van der Maas 2023-03-02 22:12:55 +04:00
commit d4e3c40df1
18 changed files with 2036 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

12
README.md Normal file
View File

@ -0,0 +1,12 @@
# ChatGPT-web
ChatGPT-web is a simple one-page web interface to the OpenAI ChatGPT model. To use it, you need to register for an API key at https://platform.openai.com/account/api-keys first. All messages are stored in your browser's local storage, so you can close the tab and come back later to continue the conversation.
## Development
To run the development server, run
```bash
npm ci
npm run dev # or: npm run publish
```

16
index.html Normal file
View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/assets/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ChatGTP-web</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1545
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "chatgpt-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@microsoft/fetch-event-source": "^2.0.1",
"@sveltejs/vite-plugin-svelte": "^2.0.2",
"@tsconfig/svelte": "^3.0.0",
"@types/marked": "^4.0.8",
"bulma": "^0.9.4",
"marked": "^4.2.12",
"sass": "^1.58.3",
"svelte": "^3.55.1",
"svelte-check": "^2.10.3",
"svelte-local-storage-store": "^0.4.0",
"tslib": "^2.5.0",
"typescript": "^4.9.3",
"vite": "^4.1.0"
}
}

102
src/App.svelte Normal file
View File

@ -0,0 +1,102 @@
<script lang="ts">
import logo from "./assets/logo.svg";
import Chat from "./lib/Chat.svelte";
import {
addChat,
apiKeyStorage,
chatsStorage,
clearChats,
} from "./lib/Storage.svelte";
$: sortedChats = $chatsStorage.sort((a, b) => b.id - a.id);
$: apiKey = $apiKeyStorage;
let activeChatId: number;
</script>
<nav class="navbar" aria-label="main navigation">
<div class="navbar-brand">
<a class="navbar-item" href="/">
<img src={logo} alt="ChatGPT-web" width="28" height="28" />
<p class="ml-2 is-size-4 has-text-weight-bold">ChatGPT-web</p>
</a>
</div>
</nav>
<!-- svelte-ignore a11y-missing-attribute a11y-click-events-have-key-events -->
<section class="section">
<div class="container is-fluid">
<div class="columns">
<div class="column is-one-fifth">
<article class="panel is-link">
<p class="panel-heading">Chats</p>
{#if sortedChats.length === 0 || !apiKey}
<a class="panel-block">No chats...</a>
{:else}
{#each sortedChats as chat}
<a class="panel-block" on:click={() => (activeChatId = chat.id)}
>Chat {chat.id}</a
>
{/each}
{/if}
</article>
<article class="panel is-link">
<a
class="panel-block {!apiKey ? 'is-disabled' : ''}"
on:click={() => {
activeChatId = addChat();
}}> New chat</a
>
<a
class="panel-block {!apiKey ? 'is-disabled' : ''}"
on:click={() => {
clearChats();
activeChatId = null;
}}>🗑️ Clear chats</a
>
<a
class="panel-block {!apiKey ? 'is-disabled' : ''}"
on:click={() => {
activeChatId = null;
}}>🔙 Back to home</a
>
</article>
</div>
<div class="column">
{#if activeChatId}
<Chat chatId={activeChatId} />
{:else}
<article class="message {!apiKey ? 'is-danger' : 'is-warning'}">
<div class="message-body">
Set your OpenAI API key below:
<input
type="text"
class="input {!apiKey ? 'is-danger' : ''}"
value={apiKey}
on:change={(event) => {
// @ts-ignore
apiKeyStorage.set(event.target.value);
}}
/>
{#if !apiKey}
<p class="help is-danger">
Please enter your OpenAI API key above to use ChatGPT-web
</p>
{/if}
</div>
</article>
<article class="message is-info">
<div class="message-body">
Select an existing chat on the sidebar, or <a
on:click={() => {
activeChatId = addChat();
}}>create a new chat</a
>
</div>
</article>
{/if}
</div>
</div>
</div>
</section>

18
src/app.css Normal file
View File

@ -0,0 +1,18 @@
@keyframes rotating {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.rotate {
animation: rotating 10s linear infinite;
}
a.is-disabled {
pointer-events: none;
cursor: default;
opacity: 0.5;
}

1
src/assets/logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.9 KiB

168
src/lib/Chat.svelte Normal file
View File

@ -0,0 +1,168 @@
<script lang="ts">
//import { fetchEventSource } from "@microsoft/fetch-event-source";
import {
apiKeyStorage,
chatsStorage,
addMessage,
clearMessages,
} from "./Storage.svelte";
import type { Message } from "./Types.svelte";
import { marked } from "marked";
import { afterUpdate, onMount } from "svelte";
export let chatId: number;
let updating: boolean = false;
let input: HTMLInputElement;
$: chat = $chatsStorage.find((chat) => chat.id === chatId);
const token_price = 0.000002; // $0.002 per 1000 tokens
// Focus the input on mount
onMount(() => input.focus());
// Scroll to the bottom of the chat on update
afterUpdate(() => {
// Scroll to the bottom of the page after any updates to the messages array
window.scrollTo(0, document.body.scrollHeight);
input.focus();
});
const send = async () => {
// Compose the input message
const inputMessage: Message = { role: "user", content: input.value };
addMessage(chatId, inputMessage);
// Clear the input value
input.value = "";
// Show updating bar
updating = true;
// Send API request
/*
await fetchEventSource("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
Authorization:
`Bearer ${$apiKeyStorage}`,
"Content-Type": "text/event-stream",
},
body: JSON.stringify({
model: "gpt-3.5-turbo",
messages, // Provide the previous messages as well for context
// temperature: 1
// top_p: 1
// n: 1
stream: false,
// stop: null
max_tokens: 4096,
}),
onmessage(ev) {
console.log(ev);
},
onerror(err) {
throw err;
},
});
*/
const response = await (
await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${$apiKeyStorage}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "gpt-3.5-turbo",
// Remove the usage property from all messages
messages: chat.messages.map((message): Message => {
const { usage, ...rest } = message;
return rest;
}),
// Provide the previous messages as well for context
// temperature: 1
// top_p: 1
// n: 1
//stream: false,
// stop: null
//max_tokens: 4096,
}),
})
).json();
console.log(response);
// Hide updating bar
updating = false;
if (response.error) {
addMessage(chatId, {
role: "system",
content: `Error: ${response.error.message}`,
});
} else {
response.choices.map((choice) => {
choice.message.usage = response.usage;
addMessage(chatId, choice.message);
});
}
};
</script>
<button
class="button is-danger is-pulled-right"
on:click={() => {
clearMessages(chatId);
}}>Clear messages</button
>
<p class="subtitle">Chat {chatId}</p>
{#each chat.messages as message}
{#if message.role === "user"}
<article class="message is-info has-text-right">
<div class="message-body">{@html marked(message.content)}</div>
</article>
{:else if message.role === "system"}
<article class="message is-danger">
<div class="message-body">{@html marked(message.content)}</div>
</article>
{:else}
<article class="message is-success">
<div class="message-body">
{@html marked(message.content)}
{#if message.usage}
<p class="is-size-7">
This message was generated using <span class="has-text-weight-bold"
>{message.usage.total_tokens}</span
>
tokens ~=
<span class="has-text-weight-bold"
>${(message.usage.total_tokens * token_price).toFixed(6)}</span
>
</p>
{/if}
</div>
</article>
{/if}
{/each}
{#if updating}
<progress class="progress is-small is-dark" max="100" />
{/if}
<form class="field has-addons has-addons-right" on:submit|preventDefault={send}>
<p class="control is-expanded">
<input
class="input is-info is-medium is-focused"
type="text"
placeholder="Type your message here..."
bind:this={input}
/>
</p>
<p class="control">
<button class="button is-info is-medium" type="submit">Send</button>
</p>
</form>

49
src/lib/Storage.svelte Normal file
View File

@ -0,0 +1,49 @@
<script context="module" lang="ts">
import { persisted } from "svelte-local-storage-store";
import { get } from "svelte/store";
import type { Chat, Message } from "./Types.svelte";
export const chatsStorage = persisted("chats", [] as Chat[]);
export const apiKeyStorage = persisted("apiKey", null as string);
export const addChat = (): number => {
const chats = get(chatsStorage);
// Find the max chatId
const chatId =
chats.reduce((maxId, chat) => Math.max(maxId, chat.id), 0) + 1;
// Add a new chat
chats.push({
id: chatId,
messages: [],
});
chatsStorage.set(chats);
return chatId;
};
export const clearChats = () => {
chatsStorage.set([]);
};
export const addMessage = (chatId: number, message: Message) => {
const chats = get(chatsStorage);
const chat = chats.find((chat) => chat.id === chatId);
chat.messages.push(message);
chatsStorage.set(chats);
};
export const clearMessages = (chatId: number) => {
const chats = get(chatsStorage);
const chat = chats.find((chat) => chat.id === chatId);
chat.messages = [];
chatsStorage.set(chats);
};
export const deleteChat = (chatId: number) => {
const chats = get(chatsStorage);
const chatIndex = chats.findIndex((chat) => chat.id === chatId);
chats.splice(chatIndex, 1);
chatsStorage.set(chats);
};
</script>

18
src/lib/Types.svelte Normal file
View File

@ -0,0 +1,18 @@
<script context="module" lang="ts">
export type Chat = {
id: number;
messages: Message[];
};
export type Message = {
role: "user" | "assistant" | "system";
content: string;
usage?: Usage;
};
export type Usage = {
completion_tokens: number;
prompt_tokens: number;
total_tokens: number;
};
</script>

9
src/main.ts Normal file
View File

@ -0,0 +1,9 @@
import "./app.css";
import "../node_modules/bulma/";
import App from "./App.svelte";
const app = new App({
target: document.getElementById("app"),
});
export default app;

2
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />

7
svelte.config.js Normal file
View File

@ -0,0 +1,7 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
export default {
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
// for more information about preprocessors
preprocess: vitePreprocess(),
}

20
tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"resolveJsonModule": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable checkJs if you'd like to use dynamic types in JS.
* Note that setting allowJs false does not prevent the use
* of JS in `.svelte` files.
*/
"allowJs": true,
"checkJs": true,
"isolatedModules": true
},
"include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
"references": [{ "path": "./tsconfig.node.json" }]
}

8
tsconfig.node.json Normal file
View File

@ -0,0 +1,8 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node"
},
"include": ["vite.config.ts"]
}

7
vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [svelte()],
})