This commit is contained in:
2023-03-14 21:00:07 +08:00
commit e86ac176d1
16 changed files with 1718 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?

43
README.md Normal file
View File

@@ -0,0 +1,43 @@
# ChatGPT API WEB
一个简单的网页,调用 OPENAI ChatGPT 进行对话。
与官方 ChatGPT 相比:
- 对话记录使用浏览器的 localStorage 保存在本地
- 可删除对话消息
- 可以设置 system message (如:"你是一个喵娘",参见官方 [API 文档](https://platform.openai.com/docs/guides/chat))
- 可以为不同对话设置不同 APIKEY
- 小(整个网页 30k 左右)
- 可以设置不同的 API Endpoint方便墙内人士使用反向代理转发 API 请求)
## 屏幕截图
![screenshot](./screenshot.webp)
~~发病.webp~~
## 使用
以下任意方式都可:
- 访问 github pages 部署
- 从 release 下载网页文件然后双击打开
- 自行编译构建网页
### 更改默认参数
- `key`: OPENAI API KEY 默认为空
- `sys`: system message 默认为 "你是一个猫娘,你要模仿猫娘的语气说话"
- `api`: API Endpoint 默认为 `https://api.openai.com/v1/chat/completions`
例如 `http://localhost:1234/?key=xxxx` 那么新创建的会话将会使用该默认 API
以上三个参数应用于单个对话,随时可在顶部更改
## 自行编译构建网页
```bash
yarn install
yarn build
```

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ChatGPT API Web</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

23
package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "chatgpt-api-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"autoprefixer": "^10.4.14",
"postcss": "^8.4.21",
"preact": "^10.11.3",
"sakura.css": "^1.4.1",
"tailwindcss": "^3.2.7"
},
"devDependencies": {
"@preact/preset-vite": "^2.5.0",
"typescript": "^4.9.3",
"vite": "^4.1.0"
}
}

6
postcss.config.cjs Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
screenshot.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

311
src/app.tsx Normal file
View File

@@ -0,0 +1,311 @@
import { useEffect, useState } from "preact/hooks";
import "./global.css";
import ChatGPT, { Message } from "./chatgpt";
import { createRef } from "preact";
export interface ChatStore {
systemMessageContent: string;
history: Message[];
postBeginIndex: number;
tokenMargin: number;
totalTokens: number;
maxTokens: number;
apiKey: string;
apiEndpoint: string;
}
const defaultAPIKEY = () => {
const queryParameters = new URLSearchParams(window.location.search);
const key = queryParameters.get("key");
return key;
};
const defaultSysMessage = () => {
const queryParameters = new URLSearchParams(window.location.search);
const sys = queryParameters.get("sys");
return sys;
};
const defaultAPIEndpoint = () => {
const queryParameters = new URLSearchParams(window.location.search);
const sys = queryParameters.get("api");
return sys;
};
const _defaultAPIEndpoint = "https://api.openai.com/v1/chat/completions";
export const newChatStore = (
apiKey = "",
systemMessageContent = "你是一个猫娘,你要模仿猫娘的语气说话",
apiEndpoint = _defaultAPIEndpoint
): ChatStore => {
return {
systemMessageContent: defaultSysMessage() || systemMessageContent,
history: [],
postBeginIndex: 0,
tokenMargin: 1024,
totalTokens: 0,
maxTokens: 4096,
apiKey: defaultAPIKEY() || apiKey,
apiEndpoint: defaultAPIEndpoint() || apiEndpoint,
};
};
const STORAGE_NAME = "chatgpt-api-web";
export function App() {
const initAllChatStore: ChatStore[] = JSON.parse(
localStorage.getItem(STORAGE_NAME) || "[]"
);
if (initAllChatStore.length === 0) {
initAllChatStore.push(newChatStore());
localStorage.setItem(STORAGE_NAME, JSON.stringify(initAllChatStore));
}
const [allChatStore, setAllChatStore] = useState(initAllChatStore);
const [selectedChatIndex, setSelectedChatIndex] = useState(0);
const chatStore = allChatStore[selectedChatIndex];
const setChatStore = (cs: ChatStore) => {
allChatStore[selectedChatIndex] = cs;
setAllChatStore([...allChatStore]);
};
useEffect(() => {
console.log("saved", allChatStore);
localStorage.setItem(STORAGE_NAME, JSON.stringify(allChatStore));
}, [allChatStore]);
const [inputMsg, setInputMsg] = useState("");
const [showGenerating, setShowGenerating] = useState(false);
const client = new ChatGPT(chatStore.apiKey);
const _complete = async () => {
client.apiEndpoint = chatStore.apiEndpoint;
client.sysMessageContent = chatStore.systemMessageContent;
client.messages = chatStore.history.slice(chatStore.postBeginIndex);
const response = await client.complete();
chatStore.history.push({ role: "assistant", content: response });
chatStore.maxTokens = client.max_tokens;
chatStore.tokenMargin = client.tokens_margin;
chatStore.totalTokens = client.total_tokens;
chatStore.postBeginIndex =
chatStore.history.length - client.messages.length;
console.log("postBeginIndex", chatStore.postBeginIndex);
setChatStore({ ...chatStore });
};
const complete = async () => {
try {
setShowGenerating(true);
await _complete();
} catch (error) {
alert(error);
} finally {
setShowGenerating(false);
}
};
const send = async () => {
if (!inputMsg) {
console.log("empty message");
return;
}
chatStore.history.push({ role: "user", content: inputMsg.trim() });
setChatStore({ ...chatStore });
setInputMsg("");
await complete();
setChatStore({ ...chatStore });
};
const changAPIKEY = () => {
const newAPIKEY = prompt(`Current API KEY: ${chatStore.apiKey}`);
if (!newAPIKEY) return;
chatStore.apiKey = newAPIKEY;
setChatStore({ ...chatStore });
};
return (
<div className="flex text-sm h-screen bg-slate-200">
<div className="flex flex-col h-full p-4 border-r-indigo-500 border-2">
<div className="grow overflow-scroll">
<button
className="bg-violet-300 p-1 rounded hover:bg-violet-400"
onClick={() => {
allChatStore.push(
newChatStore(
allChatStore[selectedChatIndex].apiKey,
allChatStore[selectedChatIndex].systemMessageContent,
allChatStore[selectedChatIndex].apiEndpoint
)
);
setAllChatStore([...allChatStore]);
setSelectedChatIndex(allChatStore.length - 1);
}}
>
NEW
</button>
<ul>
{allChatStore.map((cs, i) => (
<li>
<button
className={`w-full my-1 p-1 rounded hover:bg-blue-300 ${
i === selectedChatIndex ? "bg-blue-500" : "bg-blue-200"
}`}
onClick={() => {
setSelectedChatIndex(i);
}}
>
{i}
</button>
</li>
))}
</ul>
</div>
<button
className="rounded bg-rose-400 p-1 my-1 w-full"
onClick={() => {
if (!confirm("Are you sure you want to delete this chat history?"))
return;
const oldAPIkey = allChatStore[selectedChatIndex].apiKey;
allChatStore.splice(selectedChatIndex, 1);
if (allChatStore.length === 0) {
allChatStore.push(
newChatStore(
oldAPIkey,
allChatStore[selectedChatIndex].systemMessageContent,
allChatStore[selectedChatIndex].apiEndpoint
)
);
setSelectedChatIndex(0);
} else {
setSelectedChatIndex(selectedChatIndex - 1);
}
setAllChatStore([...allChatStore]);
}}
>
DEL
</button>
</div>
<div className="grow flex flex-col p-4">
<p>
<div>
<button
className="underline"
onClick={() => {
const newSysMsgContent = prompt(
"Change system message content"
);
if (newSysMsgContent === null) return;
chatStore.systemMessageContent = newSysMsgContent;
setChatStore({ ...chatStore });
}}
>
{chatStore.systemMessageContent}
</button>{" "}
<button className="underline" onClick={changAPIKEY}>
KEY
</button>{" "}
<button
className="underline"
onClick={() => {
const newEndpoint = prompt(
`Enter new API endpoint\n(current: ${chatStore.apiEndpoint})\n(default: ${_defaultAPIEndpoint})`
);
if (!newEndpoint) return;
chatStore.apiEndpoint = newEndpoint;
setChatStore({ ...chatStore });
}}
>
ENDPOINT
</button>
</div>
<div className="text-xs">
<span>Total: {chatStore.totalTokens}</span>{" "}
<span>Max: {chatStore.maxTokens}</span>{" "}
<span>Margin: {chatStore.tokenMargin}</span>{" "}
<span>
Message: {chatStore.history.length - chatStore.postBeginIndex}
</span>{" "}
<span>Cut: {chatStore.postBeginIndex}</span>
</div>
</p>
<div className="grow overflow-scroll">
{chatStore.history.length === 0 && (
<p className="opacity-60 p-6 rounded bg-white my-3 text-left">
(OPENAI) API KEY
</p>
)}
{chatStore.history.map((chat, i) => {
const pClassName =
chat.role === "assistant"
? "p-2 rounded relative bg-white my-2 text-left"
: "p-2 rounded relative bg-green-400 my-2 text-right";
const iconClassName =
chat.role === "user"
? "absolute bottom-0 left-0"
: "absolute bottom-0 right-0";
const DeleteIcon = () => (
<button
className={iconClassName}
onClick={() => {
if (
confirm(
`Are you sure to delete this message?\n${chat.content.slice(
0,
39
)}...`
)
) {
chatStore.history.splice(i, 1);
setChatStore({ ...chatStore });
}
}}
>
🗑
</button>
);
return (
<p className={pClassName}>
{chat.content
.split("\n")
.filter((line) => line)
.map((line) => (
<p className="my-1">{line}</p>
))}
<DeleteIcon />
</p>
);
})}
{showGenerating && (
<p className="animate-pulse">Generating... please wait...</p>
)}
</div>
<div className="flex justify-between">
<textarea
value={inputMsg}
onChange={(event: any) => setInputMsg(event.target.value)}
onKeyPress={(event: any) => {
console.log(event);
if (event.ctrlKey && event.code === "Enter") {
send();
setInputMsg("");
return;
}
setInputMsg(event.target.value);
}}
className="rounded grow m-1 p-1 border-2 border-gray-400 w-0"
placeholder="Type here..."
></textarea>
<button
className="rounded m-1 p-1 border-2 border-rose-400 bg-cyan-400 hover:bg-cyan-600"
disabled={showGenerating || !chatStore.apiKey}
onClick={() => {
send();
}}
>
Send
</button>
</div>
</div>
</div>
);
}

148
src/chatgpt.ts Normal file
View File

@@ -0,0 +1,148 @@
export interface Message {
role: "system" | "user" | "assistant";
content: string;
}
class Chat {
OPENAI_API_KEY: string;
messages: Message[];
sysMessageContent: string;
total_tokens: number;
max_tokens: number;
tokens_margin: number;
apiEndpoint: string;
constructor(
OPENAI_API_KEY: string | undefined,
{
systemMessage = "你是一个有用的人工智能助理",
max_tokens = 4096,
tokens_margin = 1024,
apiEndPoint = "https://api.openai.com/v1/chat/completions",
} = {}
) {
if (OPENAI_API_KEY === undefined) {
throw "OPENAI_API_KEY is undefined";
}
this.OPENAI_API_KEY = OPENAI_API_KEY;
this.messages = [];
this.total_tokens = 0;
this.max_tokens = max_tokens;
this.tokens_margin = tokens_margin;
this.sysMessageContent = systemMessage;
this.apiEndpoint = apiEndPoint;
}
async fetch(): Promise<{
id: string;
object: string;
created: number;
model: string;
usage: {
prompt_tokens: number | undefined;
completion_tokens: number | undefined;
total_tokens: number | undefined;
};
choices: {
message: Message | undefined;
finish_reason: "stop" | "length";
index: number | undefined;
}[];
}> {
const resp = await fetch(this.apiEndpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${this.OPENAI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "gpt-3.5-turbo",
messages: [
{ role: "system", content: this.sysMessageContent },
...this.messages,
],
}),
}).then((resp) => resp.json());
return resp;
}
async say(content: string): Promise<string> {
this.messages.push({ role: "user", content });
await this.complete();
return this.messages.slice(-1)[0].content;
}
async complete(): Promise<string> {
const resp = await this.fetch();
this.total_tokens = resp?.usage?.total_tokens ?? 0;
if (resp?.choices[0]?.message) {
this.messages.push(resp?.choices[0]?.message);
}
if (resp.choices[0]?.finish_reason === "length") {
this.forceForgetSomeMessages();
} else {
this.forgetSomeMessages();
}
return (
resp?.choices[0]?.message?.content ?? `Error: ${JSON.stringify(resp)}`
);
}
// https://help.openai.com/en/articles/4936856-what-are-tokens-and-how-to-count-them
calculate_token_length(content: string): number {
const totalCount = content.length;
const chineseCount = content.match(/[\u00ff-\uffff]|\S+/g)?.length ?? 0;
const englishCount = totalCount - chineseCount;
const tokenLength = englishCount / 4 + (chineseCount * 4) / 3;
return ~~tokenLength;
}
user(...messages: string[]) {
for (const msg of messages) {
this.messages.push({ role: "user", content: msg });
this.total_tokens += this.calculate_token_length(msg);
this.forgetSomeMessages();
}
}
assistant(...messages: string[]) {
for (const msg of messages) {
this.messages.push({ role: "assistant", content: msg });
this.total_tokens += this.calculate_token_length(msg);
this.forgetSomeMessages();
}
}
forgetSomeMessages() {
// forget occur condition
if (this.total_tokens + this.tokens_margin >= this.max_tokens) {
this.forceForgetSomeMessages();
}
}
forceForgetSomeMessages() {
this.messages = [
...this.messages.slice(Math.max(~~(this.messages.length / 4), 2)),
];
}
forgetAllMessage() {
this.messages = [];
}
stats(): string {
return (
`total_tokens: ${this.total_tokens}` +
"\n" +
`max_tokens: ${this.max_tokens}` +
"\n" +
`tokens_margin: ${this.tokens_margin}` +
"\n" +
`messages.length: ${this.messages.length}`
);
}
}
export default Chat;

3
src/global.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

4
src/main.tsx Normal file
View File

@@ -0,0 +1,4 @@
import { render } from 'preact'
import { App } from './app'
render(<App />, document.getElementById('app') as HTMLElement)

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

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

8
tailwind.config.cjs Normal file
View File

@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {},
},
plugins: [],
};

22
tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"jsxImportSource": "preact"
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

9
tsconfig.node.json Normal file
View File

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

8
vite.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import preact from '@preact/preset-vite'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [preact()],
base: './',
})

1096
yarn.lock Normal file

File diff suppressed because it is too large Load Diff