Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
e1ef16015d
|
32
README.md
32
README.md
@@ -1,14 +1,10 @@
|
||||
> 前排提示:滥用 API 或在不支持的地区调用 API 有被封号的风险 <https://github.com/zhayujie/chatgpt-on-wechat/issues/423>
|
||||
>
|
||||
> 建议自行搭建代理中转 API 请求,然后更改对话设置中的 API Endpoint 参数使用中转
|
||||
>
|
||||
> 具体反向代理搭建教程请参阅此 [>>Wiki 页面<<](https://github.com/heimoshuiyu/chatgpt-api-web/wiki)
|
||||
|
||||
# ChatGPT API WEB
|
||||
|
||||
ChatGPT API WEB 是为 ChatGPT 的日常用户和 Prompt 工程师设计的项目。它让你方便地在 PC 和移动端浏览器上使用 ChatGPT,并根据需要调整系统 Prompt 和修改 OpenAI 接口参数。你还可以重复生成、编辑消息(包括用户消息与 AI 消息),以更好地与 ChatGPT 进行交互。
|
||||
> 灵车东西,做着玩儿的
|
||||
|
||||
无论你是 ChatGPT 的一般用户、想要定制 ChatGPT 的用户,还是 Prompt 工程师,这个项目都能满足你的需求。
|
||||
一个简单的网页,调用 OPENAI ChatGPT 进行对话。
|
||||
|
||||

|
||||
|
||||
@@ -16,19 +12,11 @@ ChatGPT API WEB 是为 ChatGPT 的日常用户和 Prompt 工程师设计的项
|
||||
|
||||
- API 调用速度更快更稳定
|
||||
- 对话记录、API 密钥等使用浏览器的 localStorage 保存在本地
|
||||
- 可编辑并删除对话消息
|
||||
- 可以导入/导出整个历史对话记录
|
||||
- 可以设置 system message (参见官方 [API 文档](https://platform.openai.com/docs/guides/chat)) 例如:
|
||||
- > You are a helpful assistant
|
||||
- > 你是一个专业英语翻译,把我说的话翻译成英语,为了保持通顺连贯可以适当修改内容。
|
||||
- > 根据我的要求撰写并修改商业文案
|
||||
- > ~~你是一个猫娘,你要用猫娘的语气说话~~
|
||||
- 可删除对话消息
|
||||
- 可以设置 system message (如:"你是一个猫娘" 或 "你是一个有用的助理" 或 "将我的话翻译成英语",参见官方 [API 文档](https://platform.openai.com/docs/guides/chat))
|
||||
- 可以为不同对话设置不同 APIKEY
|
||||
- 小(整个网页 30k 左右)
|
||||
- 可以设置不同的 API Endpoint(方便墙内人士使用反向代理转发 API 请求)
|
||||
- 支持 Whisper 语音转文字输入,将会使用历史对话记录和当前输入框内的文本作为 Prompt,提高专有名词识别率
|
||||
- 支持 TTS API
|
||||
- 支持 GPT-4v 图片输入
|
||||
|
||||
## 屏幕截图
|
||||
|
||||
@@ -42,22 +30,12 @@ ChatGPT API WEB 是为 ChatGPT 的日常用户和 Prompt 工程师设计的项
|
||||
- 从 [release](https://github.com/heimoshuiyu/chatgpt-api-web/releases) 下载网页文件,或在 [github pages](https://heimoshuiyu.github.io/chatgpt-api-web/) 按 `ctrl+s` 保存网页,然后双击打开
|
||||
- 自行编译构建网页
|
||||
|
||||
### 默认参数继承
|
||||
|
||||
新建会话将会使用 URL 中设置的默认参数。
|
||||
|
||||
如果 URL 没有设置该参数,则使用 **目前选中的会话** 的参数
|
||||
|
||||
### 更改默认参数
|
||||
|
||||
- `key`: OPENAI API KEY 默认为空
|
||||
- `sys`: system message 默认为 "你是一个猫娘,你要模仿猫娘的语气说话"
|
||||
- `api`: API Endpoint 默认为 `https://api.openai.com/v1/chat/completions`
|
||||
- `mode`: `fetch` 或 `stream` 模式,stream 模式下可以动态看到 api 返回的数据,但无法得知 token 数量,只能进行估算,在 token 数量过多时可能会裁切过多或过少历史消息
|
||||
- `dev`: true / false 开发模式,这个模式下可以看到并调整更多参数
|
||||
- `temp`: 温度,默认 0.7
|
||||
- `whisper-api`: Whisper 语音转文字服务 API, 只有设置了此值后才会显示语音转文字按钮
|
||||
- `whisper-key`: 用于 Whisper 服务的 key,如果留空则默认使用上方的 OPENAI API KEY
|
||||
|
||||
例如 `http://localhost:1234/?key=xxxx&api=xxxx` 那么 **新创建** 的会话将会使用该默认 API 和 API Endpoint
|
||||
|
||||
@@ -70,4 +48,4 @@ yarn install
|
||||
yarn build
|
||||
```
|
||||
|
||||
构建产物在 `dist` 文件夹中
|
||||
构建产物在 `dist` 文件夹中
|
||||
@@ -2,14 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<meta
|
||||
name="description"
|
||||
content="A simple API playground for OpenAI ChatGPT API"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ChatGPT API Web</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
19
package.json
19
package.json
@@ -9,19 +9,16 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/ungap__structured-clone": "^0.3.1",
|
||||
"@ungap/structured-clone": "^1.2.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"idb": "^7.1.1",
|
||||
"postcss": "^8.4.31",
|
||||
"preact": "^10.18.1",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"postcss": "^8.4.21",
|
||||
"preact": "^10.11.3",
|
||||
"preact-markdown": "^2.1.0",
|
||||
"sakura.css": "^1.5.0",
|
||||
"tailwindcss": "^3.3.4"
|
||||
"sakura.css": "^1.4.1",
|
||||
"tailwindcss": "^3.2.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "^2.6.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^4.5.0"
|
||||
"@preact/preset-vite": "^2.5.0",
|
||||
"typescript": "^4.9.3",
|
||||
"vite": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
const CHATGPT_API_WEB_VERSION = "v2.1.0";
|
||||
|
||||
export default CHATGPT_API_WEB_VERSION;
|
||||
324
src/addImage.tsx
324
src/addImage.tsx
@@ -1,324 +0,0 @@
|
||||
import { useState } from "preact/hooks";
|
||||
import { ChatStore } from "./app";
|
||||
import { MessageDetail } from "./chatgpt";
|
||||
import { Tr } from "./translate";
|
||||
|
||||
interface Props {
|
||||
chatStore: ChatStore;
|
||||
setChatStore: (cs: ChatStore) => void;
|
||||
images: MessageDetail[];
|
||||
setShowAddImage: (se: boolean) => void;
|
||||
setImages: (images: MessageDetail[]) => void;
|
||||
}
|
||||
interface ImageResponse {
|
||||
url?: string;
|
||||
b64_json?: string;
|
||||
revised_prompt: string;
|
||||
}
|
||||
export function AddImage({
|
||||
chatStore,
|
||||
setChatStore,
|
||||
setShowAddImage,
|
||||
setImages,
|
||||
images,
|
||||
}: Props) {
|
||||
const [enableHighResolution, setEnableHighResolution] = useState(true);
|
||||
const [imageGenPrompt, setImageGenPrompt] = useState("");
|
||||
const [imageGenModel, setImageGenModel] = useState("dall-e-3");
|
||||
const [imageGenN, setImageGenN] = useState(1);
|
||||
const [imageGenQuality, setImageGEnQuality] = useState("standard");
|
||||
const [imageGenResponseFormat, setImageGenResponseFormat] =
|
||||
useState("b64_json");
|
||||
const [imageGenSize, setImageGenSize] = useState("1024x1024");
|
||||
const [imageGenStyle, setImageGenStyle] = useState("vivid");
|
||||
const [imageGenGenerating, setImageGenGenerating] = useState(false);
|
||||
useState("b64_json");
|
||||
return (
|
||||
<div
|
||||
className="absolute z-10 bg-black bg-opacity-50 w-full h-full flex justify-center items-center left-0 top-0 overflow-scroll"
|
||||
onClick={() => {
|
||||
setShowAddImage(false);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded p-2 z-20"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<h2>Add Images</h2>
|
||||
<span>
|
||||
<button
|
||||
className="disabled:line-through disabled:bg-slate-500 rounded m-1 p-1 border-2 bg-cyan-400 hover:bg-cyan-600"
|
||||
onClick={() => {
|
||||
const image_url = prompt("Image URL");
|
||||
if (!image_url) {
|
||||
return;
|
||||
}
|
||||
setImages([
|
||||
...images,
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: image_url,
|
||||
detail: enableHighResolution ? "high" : "low",
|
||||
},
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
Add from URL
|
||||
</button>
|
||||
<button
|
||||
className="disabled:line-through disabled:bg-slate-500 rounded m-1 p-1 border-2 bg-cyan-400 hover:bg-cyan-600"
|
||||
onClick={() => {
|
||||
// select file and load it to base64 image URL format
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/*";
|
||||
input.onchange = (event) => {
|
||||
const file = (event.target as HTMLInputElement).files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onloadend = () => {
|
||||
const base64data = reader.result;
|
||||
setImages([
|
||||
...images,
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: String(base64data),
|
||||
detail: enableHighResolution ? "high" : "low",
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
};
|
||||
input.click();
|
||||
}}
|
||||
>
|
||||
Add from local file
|
||||
</button>
|
||||
<span
|
||||
onClick={() => {
|
||||
setEnableHighResolution(!enableHighResolution);
|
||||
}}
|
||||
>
|
||||
<label>High resolution</label>
|
||||
<input type="checkbox" checked={enableHighResolution} />
|
||||
</span>
|
||||
</span>
|
||||
{chatStore.image_gen_api && chatStore.image_gen_key && (
|
||||
<div className="flex flex-col">
|
||||
<hr className="my-2" />
|
||||
<h3>Generate Image</h3>
|
||||
<span className="flex flex-row justify-between m-1 p-1">
|
||||
<label>Prompt: </label>
|
||||
<textarea
|
||||
className="border rounded border-gray-400"
|
||||
value={imageGenPrompt}
|
||||
onChange={(e: any) => {
|
||||
setImageGenPrompt(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<span className="flex flex-row justify-between m-1 p-1">
|
||||
<label>Model: </label>
|
||||
<select
|
||||
value={imageGenModel}
|
||||
onChange={(e: any) => {
|
||||
setImageGenModel(e.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="dall-e-3">DALL-E 3</option>
|
||||
<option value="dall-e-2">DALL-E 2</option>
|
||||
</select>
|
||||
</span>
|
||||
<span className="flex flex-row justify-between m-1 p-1">
|
||||
<label>n: </label>
|
||||
<input
|
||||
value={imageGenN}
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
onChange={(e: any) => setImageGenN(parseInt(e.target.value))}
|
||||
/>
|
||||
</span>
|
||||
<span className="flex flex-row justify-between m-1 p-1">
|
||||
<label>Quality: </label>
|
||||
<select
|
||||
value={imageGenQuality}
|
||||
onChange={(e: any) => setImageGEnQuality(e.target.value)}
|
||||
>
|
||||
<option value="hd">HD</option>
|
||||
<option value="standard">Standard</option>
|
||||
</select>
|
||||
</span>
|
||||
<span className="flex flex-row justify-between m-1 p-1">
|
||||
<label>Response Format: </label>
|
||||
<select
|
||||
value={imageGenResponseFormat}
|
||||
onChange={(e: any) => setImageGenResponseFormat(e.target.value)}
|
||||
>
|
||||
<option value="b64_json">b64_json</option>
|
||||
<option value="url">url</option>
|
||||
</select>
|
||||
</span>
|
||||
<span className="flex flex-row justify-between m-1 p-1">
|
||||
<label>Size: </label>
|
||||
<select
|
||||
value={imageGenSize}
|
||||
onChange={(e: any) => setImageGenSize(e.target.value)}
|
||||
>
|
||||
<option value="256x256">256x256 (dall-e-2)</option>
|
||||
<option value="512x512">512x512 (dall-e-2)</option>
|
||||
<option value="1024x1024">1024x1024 (dall-e-2/3)</option>
|
||||
<option value="1792x1024">1792x1024 (dall-e-3)</option>
|
||||
<option value="1024x1792">1024x1792 (dall-e-3)</option>
|
||||
</select>
|
||||
</span>
|
||||
<span className="flex flex-row justify-between m-1 p-1">
|
||||
<label>Style (only dall-e-3): </label>
|
||||
<select
|
||||
value={imageGenStyle}
|
||||
onChange={(e: any) => setImageGenStyle(e.target.value)}
|
||||
>
|
||||
<option value="vivid">vivid</option>
|
||||
<option value="natural">natural</option>
|
||||
</select>
|
||||
</span>
|
||||
<span className="flex flex-row justify-between m-1 p-1">
|
||||
<button
|
||||
className="bg-sky-400 m-1 p-1 rounded disabled:bg-slate-500"
|
||||
disabled={imageGenGenerating}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setImageGenGenerating(true);
|
||||
const body: any = {
|
||||
prompt: imageGenPrompt,
|
||||
model: imageGenModel,
|
||||
n: imageGenN,
|
||||
quality: imageGenQuality,
|
||||
response_format: imageGenResponseFormat,
|
||||
size: imageGenSize,
|
||||
};
|
||||
if (imageGenModel === "dall-e-3") {
|
||||
body.style = imageGenStyle;
|
||||
}
|
||||
const resp: ImageResponse[] = (
|
||||
await fetch(chatStore.image_gen_api, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${chatStore.image_gen_key}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}).then((resp) => resp.json())
|
||||
).data;
|
||||
console.log("image gen resp", resp);
|
||||
|
||||
for (const image of resp) {
|
||||
let url = "";
|
||||
if (image.url) url = image.url;
|
||||
if (image.b64_json)
|
||||
url = "data:image/png;base64," + image.b64_json;
|
||||
if (!url) continue;
|
||||
|
||||
chatStore.history.push({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url,
|
||||
detail: "low",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: image.revised_prompt,
|
||||
},
|
||||
],
|
||||
hide: false,
|
||||
token: 65,
|
||||
example: false,
|
||||
audio: null,
|
||||
logprobs: null,
|
||||
});
|
||||
|
||||
setChatStore({ ...chatStore });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Failed to generate image: " + e);
|
||||
} finally {
|
||||
setImageGenGenerating(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{Tr("Generate")}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap">
|
||||
{images.map((image, index) => (
|
||||
<div className="flex flex-col">
|
||||
{image.type === "image_url" && (
|
||||
<img
|
||||
className="rounded m-1 p-1 border-2 border-gray-400 w-32"
|
||||
src={image.image_url?.url}
|
||||
/>
|
||||
)}
|
||||
<span className="flex justify-between">
|
||||
<button
|
||||
onClick={() => {
|
||||
const image_url = prompt("Image URL");
|
||||
if (!image_url) {
|
||||
return;
|
||||
}
|
||||
images[index].image_url = {
|
||||
url: image_url,
|
||||
detail: enableHighResolution ? "high" : "low",
|
||||
};
|
||||
setImages([...images]);
|
||||
}}
|
||||
>
|
||||
🖋
|
||||
</button>
|
||||
<span
|
||||
onClick={() => {
|
||||
if (image.image_url === undefined) return;
|
||||
image.image_url.detail =
|
||||
image.image_url?.detail === "low" ? "high" : "low";
|
||||
setImages([...images]);
|
||||
}}
|
||||
>
|
||||
<label>HiRes</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={image.image_url?.detail === "high"}
|
||||
/>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!confirm("Are you sure to delete this image?")) {
|
||||
return;
|
||||
}
|
||||
images.splice(index, 1);
|
||||
setImages([...images]);
|
||||
}}
|
||||
>
|
||||
❌
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
408
src/app.tsx
408
src/app.tsx
@@ -1,351 +1,124 @@
|
||||
import { IDBPDatabase, openDB } from "idb";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import "./global.css";
|
||||
|
||||
import { calculate_token_length, Logprobs, Message } from "./chatgpt";
|
||||
import { Message } from "./chatgpt";
|
||||
import getDefaultParams from "./getDefaultParam";
|
||||
import ChatBOX from "./chatbox";
|
||||
import models, { defaultModel } from "./models";
|
||||
import { Tr, langCodeContext, LANG_OPTIONS } from "./translate";
|
||||
|
||||
import CHATGPT_API_WEB_VERSION from "./CHATGPT_API_WEB_VERSION";
|
||||
|
||||
export interface ChatStoreMessage extends Message {
|
||||
hide: boolean;
|
||||
token: number;
|
||||
example: boolean;
|
||||
audio: Blob | null;
|
||||
logprobs: Logprobs | null;
|
||||
}
|
||||
|
||||
export interface TemplateAPI {
|
||||
name: string;
|
||||
key: string;
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
export interface TemplateTools {
|
||||
name: string;
|
||||
toolsString: string;
|
||||
}
|
||||
|
||||
export interface ChatStore {
|
||||
chatgpt_api_web_version: string;
|
||||
systemMessageContent: string;
|
||||
toolsString: string;
|
||||
history: ChatStoreMessage[];
|
||||
history: Message[];
|
||||
postBeginIndex: number;
|
||||
tokenMargin: number;
|
||||
totalTokens: number;
|
||||
maxTokens: number;
|
||||
maxGenTokens: number;
|
||||
maxGenTokens_enabled: boolean;
|
||||
apiKey: string;
|
||||
apiEndpoint: string;
|
||||
streamMode: boolean;
|
||||
model: string;
|
||||
responseModelName: string;
|
||||
cost: number;
|
||||
temperature: number;
|
||||
temperature_enabled: boolean;
|
||||
top_p: number;
|
||||
top_p_enabled: boolean;
|
||||
presence_penalty: number;
|
||||
frequency_penalty: number;
|
||||
develop_mode: boolean;
|
||||
whisper_api: string;
|
||||
whisper_key: string;
|
||||
tts_api: string;
|
||||
tts_key: string;
|
||||
tts_voice: string;
|
||||
tts_speed: number;
|
||||
tts_speed_enabled: boolean;
|
||||
tts_format: string;
|
||||
image_gen_api: string;
|
||||
image_gen_key: string;
|
||||
json_mode: boolean;
|
||||
logprobs: boolean;
|
||||
}
|
||||
|
||||
const _defaultAPIEndpoint = "/v1/chat/completions";
|
||||
export const newChatStore = (
|
||||
const _defaultAPIEndpoint = "https://api.openai.com/v1/chat/completions";
|
||||
const newChatStore = (
|
||||
apiKey = "",
|
||||
systemMessageContent = "",
|
||||
systemMessageContent = "你是一个有用的人工智能助理",
|
||||
apiEndpoint = _defaultAPIEndpoint,
|
||||
streamMode = true,
|
||||
model = defaultModel,
|
||||
temperature = 0.7,
|
||||
dev = false,
|
||||
whisper_api = "https://api.openai.com/v1/audio/transcriptions",
|
||||
whisper_key = "",
|
||||
tts_api = "https://api.openai.com/v1/audio/speech",
|
||||
tts_key = "",
|
||||
tts_speed = 1.0,
|
||||
tts_speed_enabled = false,
|
||||
tts_format = "mp3",
|
||||
toolsString = "",
|
||||
image_gen_api = "https://api.openai.com/v1/images/generations",
|
||||
image_gen_key = "",
|
||||
json_mode = false,
|
||||
logprobs = true
|
||||
streamMode = true
|
||||
): ChatStore => {
|
||||
return {
|
||||
chatgpt_api_web_version: CHATGPT_API_WEB_VERSION,
|
||||
systemMessageContent: getDefaultParams("sys", systemMessageContent),
|
||||
toolsString,
|
||||
history: [],
|
||||
postBeginIndex: 0,
|
||||
tokenMargin: 1024,
|
||||
totalTokens: 0,
|
||||
maxTokens: getDefaultParams(
|
||||
"max",
|
||||
models[getDefaultParams("model", model)]?.maxToken ?? 2048
|
||||
),
|
||||
maxGenTokens: 2048,
|
||||
maxGenTokens_enabled: true,
|
||||
maxTokens: 4096,
|
||||
apiKey: getDefaultParams("key", apiKey),
|
||||
apiEndpoint: getDefaultParams("api", apiEndpoint),
|
||||
streamMode: getDefaultParams("mode", streamMode),
|
||||
model: getDefaultParams("model", model),
|
||||
responseModelName: "",
|
||||
cost: 0,
|
||||
temperature: getDefaultParams("temp", temperature),
|
||||
temperature_enabled: true,
|
||||
top_p: 1,
|
||||
top_p_enabled: false,
|
||||
presence_penalty: 0,
|
||||
frequency_penalty: 0,
|
||||
develop_mode: getDefaultParams("dev", dev),
|
||||
whisper_api: getDefaultParams("whisper-api", whisper_api),
|
||||
whisper_key: getDefaultParams("whisper-key", whisper_key),
|
||||
tts_api: getDefaultParams("tts-api", tts_api),
|
||||
tts_key: getDefaultParams("tts-key", tts_key),
|
||||
tts_voice: "alloy",
|
||||
tts_speed: tts_speed,
|
||||
tts_speed_enabled: tts_speed_enabled,
|
||||
image_gen_api: image_gen_api,
|
||||
image_gen_key: image_gen_key,
|
||||
json_mode: json_mode,
|
||||
tts_format: tts_format,
|
||||
logprobs,
|
||||
};
|
||||
};
|
||||
|
||||
export const STORAGE_NAME = "chatgpt-api-web";
|
||||
const STORAGE_NAME = "chatgpt-api-web";
|
||||
const STORAGE_NAME_SELECTED = `${STORAGE_NAME}-selected`;
|
||||
const STORAGE_NAME_INDEXES = `${STORAGE_NAME}-indexes`;
|
||||
const STORAGE_NAME_TOTALCOST = `${STORAGE_NAME}-totalcost`;
|
||||
export const STORAGE_NAME_TEMPLATE = `${STORAGE_NAME}-template`;
|
||||
export const STORAGE_NAME_TEMPLATE_API = `${STORAGE_NAME_TEMPLATE}-api`;
|
||||
export const STORAGE_NAME_TEMPLATE_API_WHISPER = `${STORAGE_NAME_TEMPLATE}-api-whisper`;
|
||||
export const STORAGE_NAME_TEMPLATE_API_TTS = `${STORAGE_NAME_TEMPLATE}-api-tts`;
|
||||
export const STORAGE_NAME_TEMPLATE_API_IMAGE_GEN = `${STORAGE_NAME_TEMPLATE}-api-image-gen`;
|
||||
export const STORAGE_NAME_TEMPLATE_TOOLS = `${STORAGE_NAME_TEMPLATE}-tools`;
|
||||
|
||||
export function addTotalCost(cost: number) {
|
||||
let totalCost = getTotalCost();
|
||||
totalCost += cost;
|
||||
localStorage.setItem(STORAGE_NAME_TOTALCOST, `${totalCost}`);
|
||||
}
|
||||
|
||||
export function getTotalCost(): number {
|
||||
let totalCost = parseFloat(
|
||||
localStorage.getItem(STORAGE_NAME_TOTALCOST) ?? "0"
|
||||
);
|
||||
return totalCost;
|
||||
}
|
||||
|
||||
export function clearTotalCost() {
|
||||
localStorage.setItem(STORAGE_NAME_TOTALCOST, `0`);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
// init indexes
|
||||
const initAllChatStoreIndexes: number[] = JSON.parse(
|
||||
localStorage.getItem(STORAGE_NAME_INDEXES) ?? "[0]"
|
||||
);
|
||||
const [allChatStoreIndexes, setAllChatStoreIndexes] = useState(
|
||||
initAllChatStoreIndexes
|
||||
);
|
||||
useEffect(() => {
|
||||
if (allChatStoreIndexes.length === 0) allChatStoreIndexes.push(0);
|
||||
console.log("saved all chat store indexes", allChatStoreIndexes);
|
||||
localStorage.setItem(
|
||||
STORAGE_NAME_INDEXES,
|
||||
JSON.stringify(allChatStoreIndexes)
|
||||
);
|
||||
}, [allChatStoreIndexes]);
|
||||
|
||||
// init selected index
|
||||
const [selectedChatIndex, setSelectedChatIndex] = useState(
|
||||
parseInt(localStorage.getItem(STORAGE_NAME_SELECTED) ?? "1")
|
||||
parseInt(localStorage.getItem(STORAGE_NAME_SELECTED) ?? "0")
|
||||
);
|
||||
console.log("selectedChatIndex", selectedChatIndex);
|
||||
useEffect(() => {
|
||||
console.log("set selected chat index", selectedChatIndex);
|
||||
localStorage.setItem(STORAGE_NAME_SELECTED, `${selectedChatIndex}`);
|
||||
}, [selectedChatIndex]);
|
||||
|
||||
const db = openDB<ChatStore>(STORAGE_NAME, 1, {
|
||||
upgrade(db) {
|
||||
const store = db.createObjectStore(STORAGE_NAME, {
|
||||
autoIncrement: true,
|
||||
});
|
||||
|
||||
// copy from localStorage to indexedDB
|
||||
const allChatStoreIndexes: number[] = JSON.parse(
|
||||
localStorage.getItem(STORAGE_NAME_INDEXES) ?? "[]"
|
||||
);
|
||||
let keyCount = 0;
|
||||
for (const i of allChatStoreIndexes) {
|
||||
console.log("importing chatStore from localStorage", i);
|
||||
const key = `${STORAGE_NAME}-${i}`;
|
||||
const val = localStorage.getItem(key);
|
||||
if (val === null) continue;
|
||||
store.add(JSON.parse(val));
|
||||
keyCount += 1;
|
||||
}
|
||||
setSelectedChatIndex(keyCount);
|
||||
if (keyCount > 0) {
|
||||
alert(
|
||||
"v2.0.0 Update: Imported chat history from localStorage to indexedDB. 🎉"
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const getChatStoreByIndex = async (index: number): Promise<ChatStore> => {
|
||||
const ret: ChatStore = await (await db).get(STORAGE_NAME, index);
|
||||
if (ret === null || ret === undefined) return newChatStore();
|
||||
// handle read from old version chatstore
|
||||
if (ret.maxGenTokens === undefined) ret.maxGenTokens = 2048;
|
||||
if (ret.maxGenTokens_enabled === undefined) ret.maxGenTokens_enabled = true;
|
||||
if (ret.model === undefined) ret.model = defaultModel;
|
||||
if (ret.responseModelName === undefined) ret.responseModelName = "";
|
||||
if (ret.toolsString === undefined) ret.toolsString = "";
|
||||
if (ret.chatgpt_api_web_version === undefined)
|
||||
// this is from old version becasue it is undefined,
|
||||
// so no higher than v1.3.0
|
||||
ret.chatgpt_api_web_version = "v1.2.2";
|
||||
for (const message of ret.history) {
|
||||
if (message.hide === undefined) message.hide = false;
|
||||
if (message.token === undefined)
|
||||
message.token = calculate_token_length(message.content);
|
||||
}
|
||||
if (ret.cost === undefined) ret.cost = 0;
|
||||
return ret;
|
||||
const getChatStoreByIndex = (index: number): ChatStore => {
|
||||
const key = `${STORAGE_NAME}-${index}`;
|
||||
const val = localStorage.getItem(key);
|
||||
if (val === null) return newChatStore();
|
||||
return JSON.parse(val) as ChatStore;
|
||||
};
|
||||
|
||||
const [chatStore, _setChatStore] = useState(newChatStore());
|
||||
const setChatStore = async (chatStore: ChatStore) => {
|
||||
console.log("recalculate postBeginIndex");
|
||||
const max = chatStore.maxTokens - chatStore.tokenMargin;
|
||||
let sum = 0;
|
||||
chatStore.postBeginIndex = chatStore.history.filter(
|
||||
({ hide }) => !hide
|
||||
).length;
|
||||
for (const msg of chatStore.history
|
||||
.filter(({ hide }) => !hide)
|
||||
.slice()
|
||||
.reverse()) {
|
||||
if (sum + msg.token > max) break;
|
||||
sum += msg.token;
|
||||
chatStore.postBeginIndex -= 1;
|
||||
}
|
||||
chatStore.postBeginIndex =
|
||||
chatStore.postBeginIndex < 0 ? 0 : chatStore.postBeginIndex;
|
||||
|
||||
// manually estimate token
|
||||
chatStore.totalTokens = calculate_token_length(
|
||||
chatStore.systemMessageContent
|
||||
);
|
||||
for (const msg of chatStore.history
|
||||
.filter(({ hide }) => !hide)
|
||||
.slice(chatStore.postBeginIndex)) {
|
||||
chatStore.totalTokens += msg.token;
|
||||
}
|
||||
|
||||
const [chatStore, _setChatStore] = useState(
|
||||
getChatStoreByIndex(selectedChatIndex)
|
||||
);
|
||||
const setChatStore = (cs: ChatStore) => {
|
||||
console.log("saved chat", selectedChatIndex, chatStore);
|
||||
(await db).put(STORAGE_NAME, chatStore, selectedChatIndex);
|
||||
|
||||
_setChatStore(chatStore);
|
||||
localStorage.setItem(
|
||||
`${STORAGE_NAME}-${selectedChatIndex}`,
|
||||
JSON.stringify(cs)
|
||||
);
|
||||
_setChatStore(cs);
|
||||
};
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
_setChatStore(await getChatStoreByIndex(selectedChatIndex));
|
||||
};
|
||||
run();
|
||||
_setChatStore(getChatStoreByIndex(selectedChatIndex));
|
||||
}, [selectedChatIndex]);
|
||||
|
||||
// all chat store indexes
|
||||
const [allChatStoreIndexes, setAllChatStoreIndexes] = useState<IDBValidKey>(
|
||||
[]
|
||||
);
|
||||
|
||||
const handleNewChatStoreWithOldOne = async (chatStore: ChatStore) => {
|
||||
const newKey = await (
|
||||
await db
|
||||
).add(
|
||||
STORAGE_NAME,
|
||||
newChatStore(
|
||||
chatStore.apiKey,
|
||||
chatStore.systemMessageContent,
|
||||
chatStore.apiEndpoint,
|
||||
chatStore.streamMode,
|
||||
chatStore.model,
|
||||
chatStore.temperature,
|
||||
!!chatStore.develop_mode,
|
||||
chatStore.whisper_api,
|
||||
chatStore.whisper_key,
|
||||
chatStore.tts_api,
|
||||
chatStore.tts_key,
|
||||
chatStore.tts_speed,
|
||||
chatStore.tts_speed_enabled,
|
||||
chatStore.tts_format,
|
||||
chatStore.toolsString,
|
||||
chatStore.image_gen_api,
|
||||
chatStore.image_gen_key,
|
||||
chatStore.json_mode,
|
||||
chatStore.logprobs
|
||||
)
|
||||
);
|
||||
setSelectedChatIndex(newKey as number);
|
||||
setAllChatStoreIndexes(await (await db).getAllKeys(STORAGE_NAME));
|
||||
};
|
||||
const handleNewChatStore = async () => {
|
||||
return handleNewChatStoreWithOldOne(chatStore);
|
||||
};
|
||||
|
||||
// if there are any params in URL, create a new chatStore
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
const chatStore = await getChatStoreByIndex(selectedChatIndex);
|
||||
const api = getDefaultParams("api", "");
|
||||
const key = getDefaultParams("key", "");
|
||||
const sys = getDefaultParams("sys", "");
|
||||
const mode = getDefaultParams("mode", "");
|
||||
const model = getDefaultParams("model", "");
|
||||
const max = getDefaultParams("max", 0);
|
||||
console.log("max is", max, "chatStore.max is", chatStore.maxTokens);
|
||||
// only create new chatStore if the params in URL are NOT
|
||||
// equal to the current selected chatStore
|
||||
if (
|
||||
(api && api !== chatStore.apiEndpoint) ||
|
||||
(key && key !== chatStore.apiKey) ||
|
||||
(sys && sys !== chatStore.systemMessageContent) ||
|
||||
(mode && mode !== (chatStore.streamMode ? "stream" : "fetch")) ||
|
||||
(model && model !== chatStore.model) ||
|
||||
(max !== 0 && max !== chatStore.maxTokens)
|
||||
) {
|
||||
console.log("create new chatStore because of params in URL");
|
||||
handleNewChatStoreWithOldOne(chatStore);
|
||||
}
|
||||
await db;
|
||||
const allidx = await (await db).getAllKeys(STORAGE_NAME);
|
||||
if (allidx.length === 0) {
|
||||
handleNewChatStore();
|
||||
}
|
||||
setAllChatStoreIndexes(await (await db).getAllKeys(STORAGE_NAME));
|
||||
};
|
||||
run();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex text-sm h-full bg-slate-200 dark:bg-slate-800 dark:text-white">
|
||||
<div className="flex text-sm h-screen bg-slate-200 dark:bg-slate-800 dark:text-white">
|
||||
<div className="flex flex-col h-full p-2 border-r-indigo-500 border-2 dark:border-slate-800 dark:border-r-indigo-500 dark:text-black">
|
||||
<div className="grow overflow-scroll">
|
||||
<button
|
||||
className="bg-violet-300 p-1 rounded hover:bg-violet-400"
|
||||
onClick={handleNewChatStore}
|
||||
onClick={() => {
|
||||
const max = Math.max(...allChatStoreIndexes);
|
||||
const next = max + 1;
|
||||
console.log("save next chat", next);
|
||||
localStorage.setItem(
|
||||
`${STORAGE_NAME}-${next}`,
|
||||
JSON.stringify(
|
||||
newChatStore(
|
||||
chatStore.apiKey,
|
||||
chatStore.systemMessageContent,
|
||||
chatStore.apiEndpoint,
|
||||
chatStore.streamMode
|
||||
)
|
||||
)
|
||||
);
|
||||
allChatStoreIndexes.push(next);
|
||||
setAllChatStoreIndexes([...allChatStoreIndexes]);
|
||||
setSelectedChatIndex(next);
|
||||
}}
|
||||
>
|
||||
{Tr("NEW")}
|
||||
NEW
|
||||
</button>
|
||||
<ul>
|
||||
{(allChatStoreIndexes as number[])
|
||||
{allChatStoreIndexes
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((i) => {
|
||||
@@ -353,7 +126,7 @@ export function App() {
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
className={`w-full my-1 p-1 rounded hover:bg-blue-500 ${
|
||||
className={`w-full my-1 p-1 rounded hover:bg-blue-300 ${
|
||||
i === selectedChatIndex ? "bg-blue-500" : "bg-blue-200"
|
||||
}`}
|
||||
onClick={() => {
|
||||
@@ -369,57 +142,40 @@ export function App() {
|
||||
</div>
|
||||
<button
|
||||
className="rounded bg-rose-400 p-1 my-1 w-full"
|
||||
onClick={async () => {
|
||||
onClick={() => {
|
||||
if (!confirm("Are you sure you want to delete this chat history?"))
|
||||
return;
|
||||
console.log("remove item", `${STORAGE_NAME}-${selectedChatIndex}`);
|
||||
(await db).delete(STORAGE_NAME, selectedChatIndex);
|
||||
const newAllChatStoreIndexes = await (
|
||||
await db
|
||||
).getAllKeys(STORAGE_NAME);
|
||||
localStorage.removeItem(`${STORAGE_NAME}-${selectedChatIndex}`);
|
||||
const newAllChatStoreIndexes = [
|
||||
...allChatStoreIndexes.filter((v) => v !== selectedChatIndex),
|
||||
];
|
||||
|
||||
if (newAllChatStoreIndexes.length === 0) {
|
||||
handleNewChatStore();
|
||||
return;
|
||||
newAllChatStoreIndexes.push(0);
|
||||
setChatStore(
|
||||
newChatStore(
|
||||
chatStore.apiKey,
|
||||
chatStore.systemMessageContent,
|
||||
chatStore.apiEndpoint,
|
||||
chatStore.streamMode
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// find nex selected chat index
|
||||
const next =
|
||||
newAllChatStoreIndexes[newAllChatStoreIndexes.length - 1];
|
||||
console.log("next is", next);
|
||||
setSelectedChatIndex(next as number);
|
||||
setAllChatStoreIndexes(newAllChatStoreIndexes);
|
||||
setSelectedChatIndex(next);
|
||||
|
||||
setAllChatStoreIndexes([...newAllChatStoreIndexes]);
|
||||
}}
|
||||
>
|
||||
{Tr("DEL")}
|
||||
DEL
|
||||
</button>
|
||||
{chatStore.develop_mode && (
|
||||
<button
|
||||
className="rounded bg-rose-800 p-1 my-1 w-full text-white"
|
||||
onClick={async () => {
|
||||
if (
|
||||
!confirm(
|
||||
"Are you sure you want to delete **ALL** chat history?"
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
await (await db).clear(STORAGE_NAME);
|
||||
setAllChatStoreIndexes([]);
|
||||
setSelectedChatIndex(1);
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
{Tr("CLS")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ChatBOX
|
||||
chatStore={chatStore}
|
||||
setChatStore={setChatStore}
|
||||
selectedChatIndex={selectedChatIndex}
|
||||
setSelectedChatIndex={setSelectedChatIndex}
|
||||
/>
|
||||
<ChatBOX chatStore={chatStore} setChatStore={setChatStore} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
1072
src/chatbox.tsx
1072
src/chatbox.tsx
File diff suppressed because it is too large
Load Diff
325
src/chatgpt.ts
325
src/chatgpt.ts
@@ -1,87 +1,15 @@
|
||||
export interface ImageURL {
|
||||
url: string;
|
||||
detail: "low" | "high";
|
||||
}
|
||||
|
||||
export interface MessageDetail {
|
||||
type: "text" | "image_url";
|
||||
text?: string;
|
||||
image_url?: ImageURL;
|
||||
}
|
||||
export interface ToolCall {
|
||||
index: number;
|
||||
id?: string;
|
||||
type: string;
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
}
|
||||
export interface Message {
|
||||
role: "system" | "user" | "assistant" | "tool";
|
||||
content: string | MessageDetail[];
|
||||
name?: "example_user" | "example_assistant";
|
||||
tool_calls?: ToolCall[];
|
||||
tool_call_id?: string;
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface Delta {
|
||||
role?: string;
|
||||
content?: string;
|
||||
tool_calls?: ToolCall[];
|
||||
}
|
||||
|
||||
interface Choices {
|
||||
index: number;
|
||||
delta: Delta;
|
||||
finish_reason: string | null;
|
||||
logprobs: Logprobs | null;
|
||||
}
|
||||
|
||||
export interface Logprobs {
|
||||
content: LogprobsContent[];
|
||||
}
|
||||
|
||||
interface LogprobsContent {
|
||||
token: string;
|
||||
logprob: number;
|
||||
}
|
||||
|
||||
export interface StreamingResponseChunk {
|
||||
id: string;
|
||||
object: string;
|
||||
created: number;
|
||||
model: string;
|
||||
system_fingerprint: string;
|
||||
choices: Choices[];
|
||||
}
|
||||
export const getMessageText = (message: Message): string => {
|
||||
if (typeof message.content === "string") {
|
||||
// function call message
|
||||
if (message.tool_calls) {
|
||||
return message.tool_calls
|
||||
.map((tc) => {
|
||||
return `Tool Call ID: ${tc.id}\nType: ${tc.type}\nFunction: ${tc.function.name}\nArguments: ${tc.function.arguments}}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
return message.content;
|
||||
}
|
||||
return message.content
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => c?.text)
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
export interface ChunkMessage {
|
||||
model: string;
|
||||
choices: {
|
||||
delta: { role: "assitant" | undefined; content: string | undefined };
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface FetchResponse {
|
||||
error?: any;
|
||||
id: string;
|
||||
object: string;
|
||||
created: number;
|
||||
@@ -95,219 +23,69 @@ export interface FetchResponse {
|
||||
message: Message | undefined;
|
||||
finish_reason: "stop" | "length";
|
||||
index: number | undefined;
|
||||
logprobs: Logprobs | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
function calculate_token_length_from_text(text: string): number {
|
||||
const totalCount = text.length;
|
||||
const chineseCount = text.match(/[\u00ff-\uffff]|\S+/g)?.length ?? 0;
|
||||
const englishCount = totalCount - chineseCount;
|
||||
const tokenLength = englishCount / 4 + (chineseCount * 4) / 3;
|
||||
return ~~tokenLength;
|
||||
}
|
||||
// https://help.openai.com/en/articles/4936856-what-are-tokens-and-how-to-count-them
|
||||
export function calculate_token_length(
|
||||
content: string | MessageDetail[]
|
||||
): number {
|
||||
if (typeof content === "string") {
|
||||
return calculate_token_length_from_text(content);
|
||||
}
|
||||
let tokens = 0;
|
||||
for (const m of content) {
|
||||
if (m.type === "text") {
|
||||
tokens += calculate_token_length_from_text(m.text ?? "");
|
||||
}
|
||||
if (m.type === "image_url") {
|
||||
tokens += m.image_url?.detail === "high" ? 65 * 4 : 65;
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
class Chat {
|
||||
OPENAI_API_KEY: string;
|
||||
messages: Message[];
|
||||
sysMessageContent: string;
|
||||
toolsString: string;
|
||||
total_tokens: number;
|
||||
max_tokens: number;
|
||||
max_gen_tokens: number;
|
||||
enable_max_gen_tokens: boolean;
|
||||
tokens_margin: number;
|
||||
apiEndpoint: string;
|
||||
model: string;
|
||||
temperature: number;
|
||||
enable_temperature: boolean;
|
||||
top_p: number;
|
||||
enable_top_p: boolean;
|
||||
presence_penalty: number;
|
||||
frequency_penalty: number;
|
||||
json_mode: boolean;
|
||||
|
||||
constructor(
|
||||
OPENAI_API_KEY: string | undefined,
|
||||
{
|
||||
systemMessage = "",
|
||||
toolsString = "",
|
||||
systemMessage = "你是一个有用的人工智能助理",
|
||||
max_tokens = 4096,
|
||||
max_gen_tokens = 2048,
|
||||
enable_max_gen_tokens = true,
|
||||
tokens_margin = 1024,
|
||||
apiEndPoint = "https://api.openai.com/v1/chat/completions",
|
||||
model = "gpt-3.5-turbo",
|
||||
temperature = 0.7,
|
||||
enable_temperature = true,
|
||||
top_p = 1,
|
||||
enable_top_p = false,
|
||||
presence_penalty = 0,
|
||||
frequency_penalty = 0,
|
||||
json_mode = false,
|
||||
} = {}
|
||||
) {
|
||||
this.OPENAI_API_KEY = OPENAI_API_KEY ?? "";
|
||||
if (OPENAI_API_KEY === undefined) {
|
||||
throw "OPENAI_API_KEY is undefined";
|
||||
}
|
||||
this.OPENAI_API_KEY = OPENAI_API_KEY;
|
||||
this.messages = [];
|
||||
this.total_tokens = calculate_token_length(systemMessage);
|
||||
this.total_tokens = 0;
|
||||
this.max_tokens = max_tokens;
|
||||
this.max_gen_tokens = max_gen_tokens;
|
||||
this.enable_max_gen_tokens = enable_max_gen_tokens;
|
||||
this.tokens_margin = tokens_margin;
|
||||
this.sysMessageContent = systemMessage;
|
||||
this.toolsString = toolsString;
|
||||
this.apiEndpoint = apiEndPoint;
|
||||
this.model = model;
|
||||
this.temperature = temperature;
|
||||
this.enable_temperature = enable_temperature;
|
||||
this.top_p = top_p;
|
||||
this.enable_top_p = enable_top_p;
|
||||
this.presence_penalty = presence_penalty;
|
||||
this.frequency_penalty = frequency_penalty;
|
||||
this.json_mode = json_mode;
|
||||
}
|
||||
|
||||
_fetch(stream = false, logprobs = false) {
|
||||
// perform role type check
|
||||
let hasNonSystemMessage = false;
|
||||
for (const msg of this.messages) {
|
||||
if (msg.role === "system" && !hasNonSystemMessage) {
|
||||
continue;
|
||||
}
|
||||
if (!hasNonSystemMessage) {
|
||||
hasNonSystemMessage = true;
|
||||
continue;
|
||||
}
|
||||
if (msg.role === "system") {
|
||||
console.log(
|
||||
"Warning: detected system message in the middle of history"
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const msg of this.messages) {
|
||||
if (msg.name && msg.role !== "system") {
|
||||
console.log(
|
||||
"Warning: detected message where name field set but role is system"
|
||||
);
|
||||
}
|
||||
}
|
||||
const messages = [];
|
||||
if (this.sysMessageContent.trim()) {
|
||||
messages.push({ role: "system", content: this.sysMessageContent });
|
||||
}
|
||||
messages.push(...this.messages);
|
||||
|
||||
const body: any = {
|
||||
model: this.model,
|
||||
messages,
|
||||
stream,
|
||||
presence_penalty: this.presence_penalty,
|
||||
frequency_penalty: this.frequency_penalty,
|
||||
};
|
||||
if (this.enable_temperature) {
|
||||
body["temperature"] = this.temperature;
|
||||
}
|
||||
if (this.enable_top_p) {
|
||||
body["top_p"] = this.top_p;
|
||||
}
|
||||
if (this.enable_max_gen_tokens) {
|
||||
body["max_tokens"] = this.max_gen_tokens;
|
||||
}
|
||||
if (this.json_mode) {
|
||||
body["response_format"] = {
|
||||
type: "json_object",
|
||||
};
|
||||
}
|
||||
if (logprobs) {
|
||||
body["logprobs"] = true;
|
||||
}
|
||||
|
||||
// parse toolsString to function call format
|
||||
const ts = this.toolsString.trim();
|
||||
if (ts) {
|
||||
try {
|
||||
const fcList: any[] = JSON.parse(ts);
|
||||
body["tools"] = fcList;
|
||||
} catch (e) {
|
||||
console.log("toolsString parse error");
|
||||
throw (
|
||||
"Function call toolsString parse error, not a valied json list: " + e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (this.OPENAI_API_KEY) {
|
||||
headers["Authorization"] = `Bearer ${this.OPENAI_API_KEY}`;
|
||||
}
|
||||
_fetch(stream = false) {
|
||||
return fetch(this.apiEndpoint, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
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,
|
||||
],
|
||||
stream,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async *processStreamResponse(resp: Response) {
|
||||
const reader = resp?.body?.pipeThrough(new TextDecoderStream()).getReader();
|
||||
if (reader === undefined) {
|
||||
console.log("reader is undefined");
|
||||
return;
|
||||
}
|
||||
let receiving = true;
|
||||
let buffer = "";
|
||||
while (receiving) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += value;
|
||||
console.log("begin buffer", buffer);
|
||||
if (!buffer.includes("\n")) continue;
|
||||
const lines = buffer
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => line.trim());
|
||||
|
||||
buffer = "";
|
||||
|
||||
for (const line of lines) {
|
||||
console.log("line", line);
|
||||
try {
|
||||
const jsonStr = line.slice("data:".length).trim();
|
||||
const json = JSON.parse(jsonStr) as StreamingResponseChunk;
|
||||
yield json;
|
||||
} catch (e) {
|
||||
console.log(`Chunk parse error at: ${line}`);
|
||||
buffer += line;
|
||||
}
|
||||
}
|
||||
}
|
||||
async fetch(): Promise<FetchResponse> {
|
||||
const resp = await this._fetch();
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
processFetchResponse(resp: FetchResponse): Message {
|
||||
if (resp.error !== undefined) {
|
||||
throw JSON.stringify(resp.error);
|
||||
}
|
||||
async say(content: string): Promise<string> {
|
||||
this.messages.push({ role: "user", content });
|
||||
await this.complete();
|
||||
return this.messages.slice(-1)[0].content;
|
||||
}
|
||||
|
||||
processFetchResponse(resp: FetchResponse): string {
|
||||
this.total_tokens = resp?.usage?.total_tokens ?? 0;
|
||||
if (resp?.choices[0]?.message) {
|
||||
this.messages.push(resp?.choices[0]?.message);
|
||||
@@ -319,26 +97,33 @@ class Chat {
|
||||
this.forgetSomeMessages();
|
||||
}
|
||||
|
||||
let content = resp.choices[0].message?.content ?? "";
|
||||
if (
|
||||
!resp.choices[0]?.message?.content &&
|
||||
!resp.choices[0]?.message?.tool_calls
|
||||
) {
|
||||
content = `Unparsed response: ${JSON.stringify(resp)}`;
|
||||
}
|
||||
|
||||
return {
|
||||
role: "assistant",
|
||||
content,
|
||||
tool_calls: resp?.choices[0]?.message?.tool_calls,
|
||||
};
|
||||
return (
|
||||
resp?.choices[0]?.message?.content ?? `Error: ${JSON.stringify(resp)}`
|
||||
);
|
||||
}
|
||||
|
||||
calculate_token_length(content: string | MessageDetail[]): number {
|
||||
return calculate_token_length(content);
|
||||
async complete(): Promise<string> {
|
||||
const resp = await this.fetch();
|
||||
return this.processFetchResponse(resp);
|
||||
}
|
||||
|
||||
user(...messages: (string | MessageDetail[])[]) {
|
||||
completeWithSteam() {
|
||||
this.total_tokens = this.messages
|
||||
.map((msg) => this.calculate_token_length(msg.content) + 20)
|
||||
.reduce((a, v) => a + v);
|
||||
return this._fetch(true);
|
||||
}
|
||||
|
||||
// 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);
|
||||
@@ -346,7 +131,7 @@ class Chat {
|
||||
}
|
||||
}
|
||||
|
||||
assistant(...messages: (string | MessageDetail[])[]) {
|
||||
assistant(...messages: string[]) {
|
||||
for (const msg of messages) {
|
||||
this.messages.push({ role: "assistant", content: msg });
|
||||
this.total_tokens += this.calculate_token_length(msg);
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { Tr, langCodeContext, LANG_OPTIONS, tr } from "./translate";
|
||||
import { useState, useEffect, StateUpdater } from "preact/hooks";
|
||||
import { ChatStore, ChatStoreMessage } from "./app";
|
||||
import { calculate_token_length, getMessageText } from "./chatgpt";
|
||||
import { isVailedJSON } from "./message";
|
||||
import { EditMessageString } from "./editMessageString";
|
||||
import { EditMessageDetail } from "./editMessageDetail";
|
||||
|
||||
interface EditMessageProps {
|
||||
chat: ChatStoreMessage;
|
||||
chatStore: ChatStore;
|
||||
setShowEdit: StateUpdater<boolean>;
|
||||
setChatStore: (cs: ChatStore) => void;
|
||||
}
|
||||
export function EditMessage(props: EditMessageProps) {
|
||||
const { setShowEdit, chat, setChatStore, chatStore } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"absolute bg-black bg-opacity-50 w-full h-full top-0 left-0 rounded z-10 overflow-scroll"
|
||||
}
|
||||
onClick={() => setShowEdit(false)}
|
||||
>
|
||||
<div
|
||||
className="m-10 p-2 bg-white rounded"
|
||||
onClick={(event: any) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{typeof chat.content === "string" ? (
|
||||
<EditMessageString
|
||||
chat={chat}
|
||||
chatStore={chatStore}
|
||||
setChatStore={setChatStore}
|
||||
setShowEdit={setShowEdit}
|
||||
/>
|
||||
) : (
|
||||
<EditMessageDetail
|
||||
chat={chat}
|
||||
chatStore={chatStore}
|
||||
setChatStore={setChatStore}
|
||||
setShowEdit={setShowEdit}
|
||||
/>
|
||||
)}
|
||||
<div className={"w-full flex justify-center"}>
|
||||
{chatStore.develop_mode && <button
|
||||
className="w-full m-2 p-1 rounded bg-red-500"
|
||||
onClick={() => {
|
||||
if (typeof chat.content === "string") {
|
||||
chat.content = []
|
||||
} else {
|
||||
chat.content = ''
|
||||
}
|
||||
setChatStore({ ...chatStore })
|
||||
}}
|
||||
>Switch to {typeof chat.content === 'string' ? "media message" : "string message"}</button>}
|
||||
<button
|
||||
className={"w-full m-2 p-1 rounded bg-purple-500"}
|
||||
onClick={() => {
|
||||
setShowEdit(false);
|
||||
}}
|
||||
>
|
||||
{Tr("Close")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import { ChatStore, ChatStoreMessage } from "./app";
|
||||
import { calculate_token_length } from "./chatgpt";
|
||||
import { Tr } from "./translate";
|
||||
|
||||
interface Props {
|
||||
chat: ChatStoreMessage;
|
||||
chatStore: ChatStore;
|
||||
setChatStore: (cs: ChatStore) => void;
|
||||
setShowEdit: (se: boolean) => void;
|
||||
}
|
||||
export function EditMessageDetail({
|
||||
chat,
|
||||
chatStore,
|
||||
setChatStore,
|
||||
setShowEdit,
|
||||
}: Props) {
|
||||
if (typeof chat.content !== "object") return <div>error</div>;
|
||||
return (
|
||||
<div
|
||||
className={"w-full h-full flex flex-col overflow-scroll"}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{chat.content.map((mdt, index) => (
|
||||
<div className={"w-full p-2 px-4"}>
|
||||
<div className="flex justify-between">
|
||||
{mdt.type === "text" ? (
|
||||
<textarea
|
||||
className={"w-full"}
|
||||
value={mdt.text}
|
||||
onChange={(event: any) => {
|
||||
if (typeof chat.content === "string") return;
|
||||
chat.content[index].text = event.target.value;
|
||||
chat.token = calculate_token_length(chat.content);
|
||||
console.log("calculated token length", chat.token);
|
||||
setChatStore({ ...chatStore });
|
||||
}}
|
||||
onKeyPress={(event: any) => {
|
||||
if (event.keyCode == 27) {
|
||||
setShowEdit(false);
|
||||
}
|
||||
}}
|
||||
></textarea>
|
||||
) : (
|
||||
<>
|
||||
<img
|
||||
className="max-h-32 max-w-xs cursor-pointer"
|
||||
src={mdt.image_url?.url}
|
||||
onClick={() => {
|
||||
window.open(mdt.image_url?.url, "_blank");
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="bg-blue-300 p-1 rounded"
|
||||
onClick={() => {
|
||||
const image_url = prompt("image url", mdt.image_url?.url);
|
||||
if (image_url) {
|
||||
if (typeof chat.content === "string") return;
|
||||
const obj = chat.content[index].image_url;
|
||||
if (obj === undefined) return;
|
||||
obj.url = image_url;
|
||||
setChatStore({ ...chatStore });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{Tr("Edit URL")}
|
||||
</button>
|
||||
<button
|
||||
className="bg-blue-300 p-1 rounded"
|
||||
onClick={() => {
|
||||
// select file and load it to base64 image URL format
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/*";
|
||||
input.onchange = (event) => {
|
||||
const file = (event.target as HTMLInputElement)
|
||||
.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onloadend = () => {
|
||||
const base64data = reader.result;
|
||||
if (!base64data) return;
|
||||
if (typeof chat.content === "string") return;
|
||||
const obj = chat.content[index].image_url;
|
||||
if (obj === undefined) return;
|
||||
obj.url = String(base64data);
|
||||
setChatStore({ ...chatStore });
|
||||
};
|
||||
};
|
||||
input.click();
|
||||
}}
|
||||
>
|
||||
{Tr("Upload")}
|
||||
</button>
|
||||
<span
|
||||
className="bg-blue-300 p-1 rounded"
|
||||
onClick={() => {
|
||||
if (typeof chat.content === "string") return;
|
||||
const obj = chat.content[index].image_url;
|
||||
if (obj === undefined) return;
|
||||
obj.detail = obj.detail === "high" ? "low" : "high";
|
||||
chat.token = calculate_token_length(chat.content);
|
||||
setChatStore({ ...chatStore });
|
||||
}}
|
||||
>
|
||||
<label>High Resolution</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mdt.image_url?.detail === "high"}
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (typeof chat.content === "string") return;
|
||||
chat.content.splice(index, 1);
|
||||
chat.token = calculate_token_length(chat.content);
|
||||
setChatStore({ ...chatStore });
|
||||
}}
|
||||
>
|
||||
❌
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
className={"m-2 p-1 rounded bg-green-500"}
|
||||
onClick={() => {
|
||||
if (typeof chat.content === "string") return;
|
||||
chat.content.push({
|
||||
type: "text",
|
||||
text: "",
|
||||
});
|
||||
setChatStore({ ...chatStore });
|
||||
}}
|
||||
>
|
||||
{Tr("Add text")}
|
||||
</button>
|
||||
<button
|
||||
className={"m-2 p-1 rounded bg-green-500"}
|
||||
onClick={() => {
|
||||
if (typeof chat.content === "string") return;
|
||||
chat.content.push({
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: "",
|
||||
detail: "high",
|
||||
},
|
||||
});
|
||||
setChatStore({ ...chatStore });
|
||||
}}
|
||||
>
|
||||
{Tr("Add image")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { ChatStore, ChatStoreMessage } from "./app";
|
||||
import { isVailedJSON } from "./message";
|
||||
import { calculate_token_length } from "./chatgpt";
|
||||
import { Tr } from "./translate";
|
||||
|
||||
interface Props {
|
||||
chat: ChatStoreMessage;
|
||||
chatStore: ChatStore;
|
||||
setChatStore: (cs: ChatStore) => void;
|
||||
setShowEdit: (se: boolean) => void;
|
||||
}
|
||||
export function EditMessageString({
|
||||
chat,
|
||||
chatStore,
|
||||
setChatStore,
|
||||
setShowEdit,
|
||||
}: Props) {
|
||||
if (typeof chat.content !== "string") return <div>error</div>;
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{chat.tool_call_id && (
|
||||
<span className="my-2">
|
||||
<label>tool_call_id: </label>
|
||||
<input
|
||||
className="rounded border border-gray-400"
|
||||
value={chat.tool_call_id}
|
||||
onChange={(event: any) => {
|
||||
chat.tool_call_id = event.target.value;
|
||||
setChatStore({ ...chatStore });
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{chat.tool_calls &&
|
||||
chat.tool_calls.map((tool_call) => (
|
||||
<div className="flex flex-col w-full">
|
||||
<span className="my-2 w-full">
|
||||
<label>Tool Call ID: </label>
|
||||
<input
|
||||
value={tool_call.id}
|
||||
className="rounded border border-gray-400"
|
||||
/>
|
||||
</span>
|
||||
<span className="my-2 w-full">
|
||||
<label>Function: </label>
|
||||
<input
|
||||
value={tool_call.function.name}
|
||||
className="rounded border border-gray-400"
|
||||
/>
|
||||
</span>
|
||||
<span className="my-2">
|
||||
<label>Arguments: </label>
|
||||
<span className="underline">
|
||||
Vailed JSON:{" "}
|
||||
{isVailedJSON(tool_call.function.arguments) ? "🆗" : "❌"}
|
||||
</span>
|
||||
<textarea
|
||||
className="rounded border border-gray-400 w-full h-32 my-2"
|
||||
value={tool_call.function.arguments}
|
||||
onChange={(event: any) => {
|
||||
tool_call.function.arguments = event.target.value.trim();
|
||||
setChatStore({ ...chatStore });
|
||||
}}
|
||||
></textarea>
|
||||
</span>
|
||||
<span className="flex flex-col my-2 justify-between">
|
||||
<button
|
||||
className="bg-red-300 text-black p-1 rounded"
|
||||
onClick={() => {
|
||||
if (!chat.tool_calls) return;
|
||||
chat.tool_calls = chat.tool_calls.filter(
|
||||
(tc) => tc.id !== tool_call.id
|
||||
);
|
||||
setChatStore({ ...chatStore });
|
||||
}}
|
||||
>
|
||||
{Tr("Delete this tool call")}
|
||||
</button>
|
||||
</span>
|
||||
<hr className="my-2" />
|
||||
<span className="flex flex-col my-2 justify-between">
|
||||
<button
|
||||
className="bg-blue-300 text-black p-1 rounded"
|
||||
onClick={() => {
|
||||
if (!chat.tool_calls) return;
|
||||
chat.tool_calls.push({
|
||||
type: "function",
|
||||
index: chat.tool_calls.length,
|
||||
id: "",
|
||||
function: {
|
||||
name: "",
|
||||
arguments: "",
|
||||
},
|
||||
});
|
||||
setChatStore({ ...chatStore });
|
||||
}}
|
||||
>
|
||||
{Tr("Add a tool call")}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<textarea
|
||||
className="rounded border border-gray-400 w-full h-32 my-2"
|
||||
value={chat.content}
|
||||
onChange={(event: any) => {
|
||||
chat.content = event.target.value;
|
||||
chat.token = calculate_token_length(chat.content);
|
||||
setChatStore({ ...chatStore });
|
||||
}}
|
||||
onKeyPress={(event: any) => {
|
||||
if (event.keyCode == 27) {
|
||||
setShowEdit(false);
|
||||
}
|
||||
}}
|
||||
></textarea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,12 +7,10 @@ function getDefaultParams(param: any, val: any) {
|
||||
if (typeof val === "string") {
|
||||
return get ?? val;
|
||||
} else if (typeof val === "number") {
|
||||
return parseFloat(get ?? `${val}`);
|
||||
return parseInt(get ?? `${val}`);
|
||||
} else if (typeof val === "boolean") {
|
||||
if (get === "stream") return true;
|
||||
if (get === "fetch") return false;
|
||||
if (get === "true") return true;
|
||||
if (get === "false") return false;
|
||||
return val;
|
||||
}
|
||||
}
|
||||
|
||||
115
src/global.css
115
src/global.css
@@ -2,12 +2,6 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for webkit based browsers */
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
@@ -28,113 +22,6 @@ body::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
p.message-content {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.markup > h2 {
|
||||
padding-bottom: 0.3em;
|
||||
font-size: 1.5em;
|
||||
border-bottom: 1px solid var(--borderColor-muted, var(--color-border-muted));
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.markup > h2::after {
|
||||
content: "";
|
||||
display: block;
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background: gray;
|
||||
}
|
||||
|
||||
.markup > h1 {
|
||||
margin-top: 0;
|
||||
padding-bottom: 0.3em;
|
||||
font-size: 2em;
|
||||
border-bottom: 1px solid var(--borderColor-muted, var(--color-border-muted));
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.markup > h1::after {
|
||||
content: "";
|
||||
display: block;
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background: gray;
|
||||
}
|
||||
|
||||
.markup > p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markup > code {
|
||||
padding: 0.2em 0.4em;
|
||||
margin: 0;
|
||||
font-size: 85%;
|
||||
white-space: break-space;
|
||||
background-color: rgba(175, 184, 193, 0.2);
|
||||
border-radius: 6px;
|
||||
font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas,
|
||||
Liberation Mono, monospace;
|
||||
}
|
||||
|
||||
.markup > pre {
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
font-size: 85%;
|
||||
line-height: 1.45;
|
||||
color: #1f2328;
|
||||
background-color: #f6f8fa;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.markup table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
font-family: "Arial", sans-serif;
|
||||
color: #333;
|
||||
background-color: #f8f8f8;
|
||||
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.markup th,
|
||||
.markup td {
|
||||
padding: 12px 15px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.markup thead th {
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
border-bottom: 2px solid #ddd;
|
||||
}
|
||||
|
||||
.markup tbody tr:nth-child(even) {
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
|
||||
.markup tbody tr:hover {
|
||||
background-color: #e9e9e9;
|
||||
}
|
||||
|
||||
.markup tbody td {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.markup tbody td:hover::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #f5f5f5;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { ChatStore, TemplateAPI } from "./app";
|
||||
import { Tr } from "./translate";
|
||||
|
||||
interface Props {
|
||||
chatStore: ChatStore;
|
||||
setChatStore: (cs: ChatStore) => void;
|
||||
tmps: TemplateAPI[];
|
||||
setTmps: (tmps: TemplateAPI[]) => void;
|
||||
label: string;
|
||||
apiField: string;
|
||||
keyField: string;
|
||||
}
|
||||
export function ListAPIs({
|
||||
tmps,
|
||||
setTmps,
|
||||
chatStore,
|
||||
setChatStore,
|
||||
label,
|
||||
apiField,
|
||||
keyField,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="break-all opacity-80 p-3 rounded bg-white my-3 text-left dark:text-black">
|
||||
<h2>{Tr(`Saved ${label} templates`)}</h2>
|
||||
<hr className="my-2" />
|
||||
<div className="flex flex-wrap">
|
||||
{tmps.map((t, index) => (
|
||||
<div
|
||||
className={`cursor-pointer rounded ${
|
||||
// @ts-ignore
|
||||
chatStore[apiField] === t.endpoint &&
|
||||
// @ts-ignore
|
||||
chatStore[keyField] === t.key
|
||||
? "bg-red-600"
|
||||
: "bg-red-400"
|
||||
} w-fit p-2 m-1 flex flex-col`}
|
||||
onClick={() => {
|
||||
// @ts-ignore
|
||||
chatStore[apiField] = t.endpoint;
|
||||
// @ts-ignore
|
||||
chatStore[keyField] = t.key;
|
||||
setChatStore({ ...chatStore });
|
||||
}}
|
||||
>
|
||||
<span className="w-full text-center">{t.name}</span>
|
||||
<hr className="mt-2" />
|
||||
<span className="flex justify-between">
|
||||
<button
|
||||
onClick={() => {
|
||||
const name = prompt(`Give **${label}** template a name`);
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
t.name = name;
|
||||
setTmps(structuredClone(tmps));
|
||||
}}
|
||||
>
|
||||
🖋
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure to delete this **${label}** template?`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
tmps.splice(index, 1);
|
||||
setTmps(structuredClone(tmps));
|
||||
}}
|
||||
>
|
||||
❌
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { ChatStore, TemplateTools } from "./app";
|
||||
import { Tr } from "./translate";
|
||||
|
||||
interface Props {
|
||||
templateTools: TemplateTools[];
|
||||
setTemplateTools: (tmps: TemplateTools[]) => void;
|
||||
chatStore: ChatStore;
|
||||
setChatStore: (cs: ChatStore) => void;
|
||||
}
|
||||
export function ListToolsTempaltes({
|
||||
chatStore,
|
||||
templateTools,
|
||||
setTemplateTools,
|
||||
setChatStore,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="break-all opacity-80 p-3 rounded bg-white my-3 text-left dark:text-black">
|
||||
<h2>
|
||||
<span>{Tr(`Saved tools templates`)}</span>
|
||||
<button
|
||||
className="mx-2 underline cursor-pointer"
|
||||
onClick={() => {
|
||||
chatStore.toolsString = "";
|
||||
setChatStore({ ...chatStore });
|
||||
}}
|
||||
>
|
||||
{Tr(`Clear`)}
|
||||
</button>
|
||||
</h2>
|
||||
<hr className="my-2" />
|
||||
<div className="flex flex-wrap">
|
||||
{templateTools.map((t, index) => (
|
||||
<div
|
||||
className={`cursor-pointer rounded ${
|
||||
chatStore.toolsString === t.toolsString
|
||||
? "bg-red-600"
|
||||
: "bg-red-400"
|
||||
} w-fit p-2 m-1 flex flex-col`}
|
||||
onClick={() => {
|
||||
chatStore.toolsString = t.toolsString;
|
||||
setChatStore({ ...chatStore });
|
||||
}}
|
||||
>
|
||||
<span className="w-full text-center">{t.name}</span>
|
||||
<hr className="mt-2" />
|
||||
<span className="flex justify-between">
|
||||
<button
|
||||
onClick={() => {
|
||||
const name = prompt(`Give **tools** template a name`);
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
t.name = name;
|
||||
setTemplateTools(structuredClone(templateTools));
|
||||
}}
|
||||
>
|
||||
🖋
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (
|
||||
!confirm(`Are you sure to delete this **tools** template?`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
templateTools.splice(index, 1);
|
||||
setTemplateTools(structuredClone(templateTools));
|
||||
}}
|
||||
>
|
||||
❌
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
const logprobToColor = (logprob: number) => {
|
||||
// 将logprob转换为百分比
|
||||
const percent = Math.exp(logprob) * 100;
|
||||
|
||||
// 计算颜色值
|
||||
// 绿色的RGB值为(0, 255, 0),红色的RGB值为(255, 0, 0)
|
||||
const red = Math.round(255 * (1 - percent / 100));
|
||||
const green = Math.round(255 * (percent / 100));
|
||||
const color = `rgb(${red}, ${green}, 0)`;
|
||||
|
||||
return color;
|
||||
};
|
||||
|
||||
export default logprobToColor;
|
||||
54
src/main.tsx
54
src/main.tsx
@@ -1,52 +1,4 @@
|
||||
import { render } from "preact";
|
||||
import { App } from "./app";
|
||||
import { useState, useEffect } from "preact/hooks";
|
||||
import { Tr, langCodeContext, LANG_OPTIONS } from "./translate";
|
||||
import { render } from 'preact'
|
||||
import { App } from './app'
|
||||
|
||||
function Base() {
|
||||
const [langCode, _setLangCode] = useState("en-US");
|
||||
|
||||
const setLangCode = (langCode: string) => {
|
||||
_setLangCode(langCode)
|
||||
if (!localStorage) return
|
||||
|
||||
localStorage.setItem('chatgpt-api-web-lang', langCode)
|
||||
}
|
||||
|
||||
// select language
|
||||
useEffect(() => {
|
||||
// query localStorage
|
||||
if (localStorage) {
|
||||
const lang = localStorage.getItem('chatgpt-api-web-lang')
|
||||
if (lang) {
|
||||
console.log(`query langCode ${lang} from localStorage`)
|
||||
_setLangCode(lang)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const browserCode = window.navigator.language;
|
||||
for (const key in LANG_OPTIONS) {
|
||||
for (const i in LANG_OPTIONS[key].matches) {
|
||||
const code = LANG_OPTIONS[key].matches[i];
|
||||
if (code === browserCode) {
|
||||
console.log(`set langCode to "${code}"`);
|
||||
setLangCode(key);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// fallback to english
|
||||
console.log('fallback langCode to "en-US"');
|
||||
setLangCode("en-US");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
/* @ts-ignore */
|
||||
<langCodeContext.Provider value={{ langCode, setLangCode }}>
|
||||
<App />
|
||||
</langCodeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<Base />, document.getElementById("app") as HTMLElement);
|
||||
render(<App />, document.getElementById('app') as HTMLElement)
|
||||
|
||||
247
src/message.tsx
247
src/message.tsx
@@ -1,229 +1,72 @@
|
||||
import { Tr, langCodeContext, LANG_OPTIONS } from "./translate";
|
||||
import { useState, useEffect, StateUpdater } from "preact/hooks";
|
||||
import { ChatStore, ChatStoreMessage } from "./app";
|
||||
import { calculate_token_length, getMessageText } from "./chatgpt";
|
||||
import Markdown from "preact-markdown";
|
||||
import TTSButton, { TTSPlay } from "./tts";
|
||||
import { MessageHide } from "./messageHide";
|
||||
import { MessageDetail } from "./messageDetail";
|
||||
import { MessageToolCall } from "./messageToolCall";
|
||||
import { MessageToolResp } from "./messageToolResp";
|
||||
import { EditMessage } from "./editMessage";
|
||||
import logprobToColor from "./logprob";
|
||||
import { ChatStore } from "./app";
|
||||
|
||||
export const isVailedJSON = (str: string): boolean => {
|
||||
try {
|
||||
JSON.parse(str);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const Pre: React.FC<any> = ({ children, props }) => (
|
||||
<div class="rounded p-1 bg-black text-white" {...props}>{children}</div>
|
||||
);
|
||||
const Code: React.FC<any> = ({ children }) => <code className="overflow-scroll break-keep">{children}</code>;
|
||||
|
||||
interface Props {
|
||||
messageIndex: number;
|
||||
chatStore: ChatStore;
|
||||
setChatStore: (cs: ChatStore) => void;
|
||||
update_total_tokens: () => void;
|
||||
}
|
||||
|
||||
export default function Message(props: Props) {
|
||||
const { chatStore, messageIndex, setChatStore } = props;
|
||||
const chat = chatStore.history[messageIndex];
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const [showCopiedHint, setShowCopiedHint] = useState(false);
|
||||
const [renderMarkdown, setRenderWorkdown] = useState(false);
|
||||
const [renderColor, setRenderColor] = useState(false);
|
||||
const DeleteIcon = () => (
|
||||
<button
|
||||
className={`absolute bottom-0 ${
|
||||
chat.role === "user" ? "left-0" : "right-0"
|
||||
}`}
|
||||
onClick={() => {
|
||||
chatStore.history[messageIndex].hide =
|
||||
!chatStore.history[messageIndex].hide;
|
||||
|
||||
//chatStore.totalTokens =
|
||||
chatStore.totalTokens = 0;
|
||||
for (const i of chatStore.history
|
||||
.filter(({ hide }) => !hide)
|
||||
.slice(chatStore.postBeginIndex)
|
||||
.map(({ token }) => token)) {
|
||||
chatStore.totalTokens += i;
|
||||
if (
|
||||
confirm(
|
||||
`Are you sure to delete this message?\n${chat.content.slice(
|
||||
0,
|
||||
39
|
||||
)}...`
|
||||
)
|
||||
) {
|
||||
chatStore.history.splice(messageIndex, 1);
|
||||
chatStore.postBeginIndex = Math.max(chatStore.postBeginIndex - 1, 0);
|
||||
setChatStore({ ...chatStore });
|
||||
}
|
||||
setChatStore({ ...chatStore });
|
||||
}}
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
);
|
||||
const CopiedHint = () => (
|
||||
<span
|
||||
className={
|
||||
"bg-purple-400 p-1 rounded shadow-md absolute z-20 left-1/2 top-3/4 transform -translate-x-1/2 -translate-y-1/2"
|
||||
}
|
||||
>
|
||||
{Tr("Message copied to clipboard!")}
|
||||
</span>
|
||||
);
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setShowCopiedHint(true);
|
||||
setTimeout(() => setShowCopiedHint(false), 1000);
|
||||
};
|
||||
|
||||
const CopyIcon = ({ textToCopy }: { textToCopy: string }) => {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
copyToClipboard(textToCopy);
|
||||
}}
|
||||
>
|
||||
📋
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const codeMatches = chat.content.match(/(```([\s\S]*?)```$)/);
|
||||
const AnyMarkdown = Markdown as any;
|
||||
console.log("codeMatches", codeMatches);
|
||||
if (codeMatches) console.log("matches", codeMatches[0]);
|
||||
return (
|
||||
<>
|
||||
{chatStore.postBeginIndex !== 0 &&
|
||||
!chatStore.history[messageIndex].hide &&
|
||||
chatStore.postBeginIndex ===
|
||||
chatStore.history.slice(0, messageIndex).filter(({ hide }) => !hide)
|
||||
.length && (
|
||||
<div className="flex items-center relative justify-center">
|
||||
<hr className="w-full h-px my-4 border-0 bg-slate-800 dark:bg-white" />
|
||||
<span className="absolute px-3 bg-slate-800 text-white rounded p-1 dark:bg-white dark:text-black">
|
||||
Above messages are "forgotten"
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`flex ${
|
||||
chat.role === "assistant" ? "justify-start" : "justify-end"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex ${
|
||||
chat.role === "assistant" ? "justify-start" : "justify-end"
|
||||
className={`relative w-fit p-2 rounded my-2 ${
|
||||
chat.role === "assistant"
|
||||
? "bg-white dark:bg-gray-700 dark:text-white"
|
||||
: "bg-green-400"
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className={`w-fit p-2 rounded my-2 ${
|
||||
chat.role === "assistant"
|
||||
? "bg-white dark:bg-gray-700 dark:text-white"
|
||||
: "bg-green-400"
|
||||
} ${chat.hide ? "opacity-50" : ""}`}
|
||||
>
|
||||
{chat.hide ? (
|
||||
<MessageHide chat={chat} />
|
||||
) : typeof chat.content !== "string" ? (
|
||||
<MessageDetail chat={chat} renderMarkdown={renderMarkdown} />
|
||||
) : chat.tool_calls ? (
|
||||
<MessageToolCall chat={chat} copyToClipboard={copyToClipboard} />
|
||||
) : chat.role === "tool" ? (
|
||||
<MessageToolResp chat={chat} copyToClipboard={copyToClipboard} />
|
||||
) : renderMarkdown ? (
|
||||
// @ts-ignore
|
||||
<Markdown markdown={getMessageText(chat)} />
|
||||
) : (
|
||||
<div className="message-content">
|
||||
{
|
||||
// only show when content is string or list of message
|
||||
// this check is used to avoid rendering tool call
|
||||
chat.content &&
|
||||
(chat.logprobs && renderColor
|
||||
? chat.logprobs.content
|
||||
.filter((c) => c.token)
|
||||
.map((c) => (
|
||||
<div
|
||||
style={{
|
||||
color: logprobToColor(c.logprob),
|
||||
display: "inline",
|
||||
}}
|
||||
>
|
||||
{c.token}
|
||||
</div>
|
||||
))
|
||||
: getMessageText(chat))
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
<hr className="mt-2" />
|
||||
<TTSPlay chat={chat} />
|
||||
<div className="w-full flex justify-between">
|
||||
<DeleteIcon />
|
||||
<button onClick={() => setShowEdit(true)}>🖋</button>
|
||||
{chatStore.tts_api && chatStore.tts_key && (
|
||||
<TTSButton
|
||||
chatStore={chatStore}
|
||||
chat={chat}
|
||||
setChatStore={setChatStore}
|
||||
/>
|
||||
)}
|
||||
<CopyIcon textToCopy={getMessageText(chat)} />
|
||||
</div>
|
||||
</div>
|
||||
{showEdit && (
|
||||
<EditMessage
|
||||
setShowEdit={setShowEdit}
|
||||
chat={chat}
|
||||
chatStore={chatStore}
|
||||
setChatStore={setChatStore}
|
||||
/>
|
||||
)}
|
||||
{showCopiedHint && <CopiedHint />}
|
||||
{chatStore.develop_mode && (
|
||||
<div>
|
||||
<span className="dark:text-white">token</span>
|
||||
<input
|
||||
value={chat.token}
|
||||
className="w-20"
|
||||
onChange={(event: any) => {
|
||||
chat.token = parseInt(event.target.value);
|
||||
props.update_total_tokens();
|
||||
setChatStore({ ...chatStore });
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
chatStore.history.splice(messageIndex, 1);
|
||||
chatStore.postBeginIndex = Math.max(
|
||||
chatStore.postBeginIndex - 1,
|
||||
0
|
||||
);
|
||||
//chatStore.totalTokens =
|
||||
chatStore.totalTokens = 0;
|
||||
for (const i of chatStore.history
|
||||
.filter(({ hide }) => !hide)
|
||||
.slice(chatStore.postBeginIndex)
|
||||
.map(({ token }) => token)) {
|
||||
chatStore.totalTokens += i;
|
||||
}
|
||||
setChatStore({ ...chatStore });
|
||||
}}
|
||||
>
|
||||
❌
|
||||
</button>
|
||||
<span
|
||||
onClick={(event: any) => {
|
||||
chat.example = !chat.example;
|
||||
setChatStore({ ...chatStore });
|
||||
}}
|
||||
>
|
||||
<label className="dark:text-white">{Tr("example")}</label>
|
||||
<input type="checkbox" checked={chat.example} />
|
||||
</span>
|
||||
<span
|
||||
onClick={(event: any) => setRenderWorkdown(!renderMarkdown)}
|
||||
>
|
||||
<label className="dark:text-white">{Tr("render")}</label>
|
||||
<input type="checkbox" checked={renderMarkdown} />
|
||||
</span>
|
||||
<span onClick={(event: any) => setRenderColor(!renderColor)}>
|
||||
<label className="dark:text-white">{Tr("color")}</label>
|
||||
<input type="checkbox" checked={renderColor} />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="message-content">
|
||||
<AnyMarkdown
|
||||
markdown={chat.content}
|
||||
markupOpts={{
|
||||
components: {
|
||||
code: Code,
|
||||
pre: Pre,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<DeleteIcon />
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { ChatStoreMessage } from "./app";
|
||||
|
||||
interface Props {
|
||||
chat: ChatStoreMessage;
|
||||
renderMarkdown: boolean;
|
||||
}
|
||||
export function MessageDetail({ chat, renderMarkdown }: Props) {
|
||||
if (typeof chat.content === "string") {
|
||||
return <div></div>;
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{chat.content.map((mdt) =>
|
||||
mdt.type === "text" ? (
|
||||
chat.hide ? (
|
||||
mdt.text?.split("\n")[0].slice(0, 16) + "... (deleted)"
|
||||
) : renderMarkdown ? (
|
||||
// @ts-ignore
|
||||
<Markdown markdown={mdt.text} />
|
||||
) : (
|
||||
mdt.text
|
||||
)
|
||||
) : (
|
||||
<img
|
||||
className="cursor-pointer max-w-xs max-h-32 p-1"
|
||||
src={mdt.image_url?.url}
|
||||
onClick={() => {
|
||||
window.open(mdt.image_url?.url, "_blank");
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { ChatStoreMessage } from "./app";
|
||||
import { getMessageText } from "./chatgpt";
|
||||
|
||||
interface Props {
|
||||
chat: ChatStoreMessage;
|
||||
}
|
||||
|
||||
export function MessageHide({ chat }: Props) {
|
||||
return (
|
||||
<div>{getMessageText(chat).split("\n")[0].slice(0, 18)} ... (deleted)</div>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { ChatStoreMessage } from "./app";
|
||||
|
||||
interface Props {
|
||||
chat: ChatStoreMessage;
|
||||
copyToClipboard: (text: string) => void;
|
||||
}
|
||||
export function MessageToolCall({ chat, copyToClipboard }: Props) {
|
||||
return (
|
||||
<div className="message-content">
|
||||
{chat.tool_calls?.map((tool_call) => (
|
||||
<div className="bg-blue-300 dark:bg-blue-800 p-1 rounded my-1">
|
||||
<strong>
|
||||
Tool Call ID:{" "}
|
||||
<span
|
||||
className="p-1 m-1 rounded cursor-pointer hover:opacity-50 hover:underline"
|
||||
onClick={() => copyToClipboard(String(tool_call.id))}
|
||||
>
|
||||
{tool_call?.id}
|
||||
</span>
|
||||
</strong>
|
||||
<p>Type: {tool_call?.type}</p>
|
||||
<p>
|
||||
Function:
|
||||
<span
|
||||
className="p-1 m-1 rounded cursor-pointer hover:opacity-50 hover:underline"
|
||||
onClick={() => copyToClipboard(tool_call.function.name)}
|
||||
>
|
||||
{tool_call.function.name}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
Arguments:
|
||||
<span
|
||||
className="p-1 m-1 rounded cursor-pointer hover:opacity-50 hover:underline"
|
||||
onClick={() => copyToClipboard(tool_call.function.arguments)}
|
||||
>
|
||||
{tool_call.function.arguments}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{chat.content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { ChatStoreMessage } from "./app";
|
||||
|
||||
interface Props {
|
||||
chat: ChatStoreMessage;
|
||||
copyToClipboard: (text: string) => void;
|
||||
}
|
||||
export function MessageToolResp({ chat, copyToClipboard }: Props) {
|
||||
return (
|
||||
<div className="message-content">
|
||||
<div className="bg-blue-300 dark:bg-blue-800 p-1 rounded my-1">
|
||||
<strong>
|
||||
Tool Response ID:{" "}
|
||||
<span
|
||||
className="p-1 m-1 rounded cursor-pointer hover:opacity-50 hover:underline"
|
||||
onClick={() => copyToClipboard(String(chat.tool_call_id))}
|
||||
>
|
||||
{chat.tool_call_id}
|
||||
</span>
|
||||
</strong>
|
||||
<p>{chat.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
interface Model {
|
||||
maxToken: number;
|
||||
price: {
|
||||
prompt: number;
|
||||
completion: number;
|
||||
};
|
||||
}
|
||||
|
||||
const models: Record<string, Model> = {
|
||||
"gpt-3.5-turbo-0125": {
|
||||
maxToken: 16385,
|
||||
price: { prompt: 0.0005 / 1000, completion: 0.0015 / 1000 },
|
||||
},
|
||||
"gpt-3.5-turbo-1106": {
|
||||
maxToken: 16385,
|
||||
price: { prompt: 0.001 / 1000, completion: 0.002 / 1000 },
|
||||
},
|
||||
"gpt-4-0125-preview": {
|
||||
maxToken: 128000,
|
||||
price: { prompt: 0.01 / 1000, completion: 0.03 / 1000 },
|
||||
},
|
||||
};
|
||||
|
||||
export const defaultModel = "gpt-3.5-turbo-0125";
|
||||
|
||||
export default models;
|
||||
@@ -1,39 +0,0 @@
|
||||
import { TemplateAPI } from "./app";
|
||||
import { Tr } from "./translate";
|
||||
|
||||
interface Props {
|
||||
tmps: TemplateAPI[];
|
||||
setTmps: (tmps: TemplateAPI[]) => void;
|
||||
label: string;
|
||||
endpoint: string;
|
||||
APIkey: string;
|
||||
}
|
||||
export function SetAPIsTemplate({
|
||||
endpoint,
|
||||
APIkey,
|
||||
tmps,
|
||||
setTmps,
|
||||
label,
|
||||
}: Props) {
|
||||
return (
|
||||
<button
|
||||
className="p-1 m-1 rounded bg-blue-300"
|
||||
onClick={() => {
|
||||
const name = prompt(`Give this **${label}** template a name:`);
|
||||
if (!name) {
|
||||
alert("No template name specified");
|
||||
return;
|
||||
}
|
||||
const tmp: TemplateAPI = {
|
||||
name,
|
||||
endpoint,
|
||||
key: APIkey,
|
||||
};
|
||||
tmps.push(tmp);
|
||||
setTmps([...tmps]);
|
||||
}}
|
||||
>
|
||||
{Tr(`Save ${label}`)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
727
src/settings.tsx
727
src/settings.tsx
@@ -1,30 +1,5 @@
|
||||
import { createRef } from "preact";
|
||||
import { StateUpdater, useContext, useEffect, useState } from "preact/hooks";
|
||||
import {
|
||||
ChatStore,
|
||||
TemplateAPI,
|
||||
TemplateTools,
|
||||
clearTotalCost,
|
||||
getTotalCost,
|
||||
} from "./app";
|
||||
import models from "./models";
|
||||
import { TemplateChatStore } from "./chatbox";
|
||||
import { tr, Tr, langCodeContext, LANG_OPTIONS } from "./translate";
|
||||
import p from "preact-markdown";
|
||||
import { isVailedJSON } from "./message";
|
||||
import { SetAPIsTemplate } from "./setAPIsTemplate";
|
||||
import { autoHeight } from "./textarea";
|
||||
import getDefaultParams from "./getDefaultParam";
|
||||
|
||||
const TTS_VOICES: string[] = [
|
||||
"alloy",
|
||||
"echo",
|
||||
"fable",
|
||||
"onyx",
|
||||
"nova",
|
||||
"shimmer",
|
||||
];
|
||||
const TTS_FORMAT: string[] = ["mp3", "opus", "aac", "flac"];
|
||||
import { StateUpdater } from "preact/hooks";
|
||||
import { ChatStore } from "./app";
|
||||
|
||||
const Help = (props: { children: any; help: string }) => {
|
||||
return (
|
||||
@@ -42,116 +17,16 @@ const Help = (props: { children: any; help: string }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const SelectModel = (props: {
|
||||
chatStore: ChatStore;
|
||||
setChatStore: (cs: ChatStore) => void;
|
||||
help: string;
|
||||
}) => {
|
||||
let shouldIUseCustomModel: boolean = true;
|
||||
for (const model in models) {
|
||||
if (props.chatStore.model === model) {
|
||||
shouldIUseCustomModel = false;
|
||||
}
|
||||
}
|
||||
const [useCustomModel, setUseCustomModel] = useState(shouldIUseCustomModel);
|
||||
return (
|
||||
<Help help={props.help}>
|
||||
<label className="m-2 p-2">Model</label>
|
||||
<span
|
||||
onClick={() => {
|
||||
setUseCustomModel(!useCustomModel);
|
||||
}}
|
||||
className="m-2 p-2"
|
||||
>
|
||||
<label>{Tr("Custom")}</label>
|
||||
<input className="" type="checkbox" checked={useCustomModel} />
|
||||
</span>
|
||||
{useCustomModel ? (
|
||||
<input
|
||||
className="m-2 p-2 border rounded focus w-32 md:w-fit"
|
||||
value={props.chatStore.model}
|
||||
onChange={(event: any) => {
|
||||
const model = event.target.value as string;
|
||||
props.chatStore.model = model;
|
||||
props.setChatStore({ ...props.chatStore });
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<select
|
||||
className="m-2 p-2"
|
||||
value={props.chatStore.model}
|
||||
onChange={(event: any) => {
|
||||
const model = event.target.value as string;
|
||||
props.chatStore.model = model;
|
||||
props.chatStore.maxTokens = getDefaultParams(
|
||||
"max",
|
||||
models[model].maxToken
|
||||
);
|
||||
props.setChatStore({ ...props.chatStore });
|
||||
}}
|
||||
>
|
||||
{Object.keys(models).map((opt) => (
|
||||
<option value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Help>
|
||||
);
|
||||
};
|
||||
|
||||
const LongInput = (props: {
|
||||
chatStore: ChatStore;
|
||||
setChatStore: (cs: ChatStore) => void;
|
||||
field: "systemMessageContent" | "toolsString";
|
||||
help: string;
|
||||
}) => {
|
||||
return (
|
||||
<Help help={props.help}>
|
||||
<textarea
|
||||
className="m-2 p-2 border rounded focus w-full"
|
||||
value={props.chatStore[props.field]}
|
||||
onChange={(event: any) => {
|
||||
props.chatStore[props.field] = event.target.value;
|
||||
props.setChatStore({ ...props.chatStore });
|
||||
autoHeight(event.target);
|
||||
}}
|
||||
onKeyPress={(event: any) => {
|
||||
autoHeight(event.target);
|
||||
}}
|
||||
></textarea>
|
||||
</Help>
|
||||
);
|
||||
};
|
||||
|
||||
const Input = (props: {
|
||||
chatStore: ChatStore;
|
||||
setChatStore: (cs: ChatStore) => void;
|
||||
field:
|
||||
| "apiKey"
|
||||
| "apiEndpoint"
|
||||
| "whisper_api"
|
||||
| "whisper_key"
|
||||
| "tts_api"
|
||||
| "tts_key"
|
||||
| "image_gen_api"
|
||||
| "image_gen_key";
|
||||
field: "apiKey" | "systemMessageContent" | "apiEndpoint";
|
||||
help: string;
|
||||
}) => {
|
||||
const [hideInput, setHideInput] = useState(true);
|
||||
return (
|
||||
<Help help={props.help}>
|
||||
<label className="m-2 p-2">{props.field}</label>
|
||||
<button
|
||||
className="p-2"
|
||||
onClick={() => {
|
||||
setHideInput(!hideInput);
|
||||
console.log("clicked", hideInput);
|
||||
}}
|
||||
>
|
||||
{hideInput ? "👀" : "🙈"}
|
||||
</button>
|
||||
<input
|
||||
type={hideInput ? "password" : "text"}
|
||||
className="m-2 p-2 border rounded focus w-32 md:w-fit"
|
||||
value={props.chatStore[props.field]}
|
||||
onChange={(event: any) => {
|
||||
@@ -162,117 +37,24 @@ const Input = (props: {
|
||||
</Help>
|
||||
);
|
||||
};
|
||||
|
||||
const Slicer = (props: {
|
||||
chatStore: ChatStore;
|
||||
setChatStore: (cs: ChatStore) => void;
|
||||
field: "temperature" | "top_p" | "tts_speed";
|
||||
help: string;
|
||||
min: number;
|
||||
max: number;
|
||||
}) => {
|
||||
const enable_filed_name: "temperature_enabled" | "top_p_enabled" =
|
||||
`${props.field}_enabled` as any;
|
||||
|
||||
const enabled = props.chatStore[enable_filed_name];
|
||||
|
||||
if (enabled === null || enabled === undefined) {
|
||||
if (props.field === "temperature") {
|
||||
props.chatStore[enable_filed_name] = true;
|
||||
}
|
||||
if (props.field === "top_p") {
|
||||
props.chatStore[enable_filed_name] = false;
|
||||
}
|
||||
}
|
||||
|
||||
const setEnabled = (state: boolean) => {
|
||||
props.chatStore[enable_filed_name] = state;
|
||||
props.setChatStore({ ...props.chatStore });
|
||||
};
|
||||
return (
|
||||
<Help help={props.help}>
|
||||
<span>
|
||||
<label className="m-2 p-2">{props.field}</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.chatStore[enable_filed_name]}
|
||||
onClick={() => {
|
||||
setEnabled(!enabled);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<input
|
||||
disabled={!enabled}
|
||||
className="m-2 p-2 border rounded focus w-16"
|
||||
type="range"
|
||||
min={props.min}
|
||||
max={props.max}
|
||||
step="0.01"
|
||||
value={props.chatStore[props.field]}
|
||||
onChange={(event: any) => {
|
||||
const value = parseFloat(event.target.value);
|
||||
props.chatStore[props.field] = value;
|
||||
props.setChatStore({ ...props.chatStore });
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
disabled={!enabled}
|
||||
className="m-2 p-2 border rounded focus w-28"
|
||||
type="number"
|
||||
value={props.chatStore[props.field]}
|
||||
onChange={(event: any) => {
|
||||
const value = parseFloat(event.target.value);
|
||||
props.chatStore[props.field] = value;
|
||||
props.setChatStore({ ...props.chatStore });
|
||||
}}
|
||||
/>
|
||||
</Help>
|
||||
);
|
||||
};
|
||||
|
||||
const Number = (props: {
|
||||
chatStore: ChatStore;
|
||||
setChatStore: (cs: ChatStore) => void;
|
||||
field:
|
||||
| "totalTokens"
|
||||
| "maxTokens"
|
||||
| "maxGenTokens"
|
||||
| "tokenMargin"
|
||||
| "postBeginIndex"
|
||||
| "presence_penalty"
|
||||
| "frequency_penalty";
|
||||
field: "totalTokens" | "maxTokens" | "tokenMargin" | "postBeginIndex";
|
||||
readOnly: boolean;
|
||||
help: string;
|
||||
}) => {
|
||||
return (
|
||||
<Help help={props.help}>
|
||||
<span>
|
||||
<label className="m-2 p-2">{props.field}</label>
|
||||
{props.field === "maxGenTokens" && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.chatStore.maxGenTokens_enabled}
|
||||
onChange={() => {
|
||||
const newChatStore = { ...props.chatStore };
|
||||
newChatStore.maxGenTokens_enabled =
|
||||
!newChatStore.maxGenTokens_enabled;
|
||||
props.setChatStore({ ...newChatStore });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<label className="m-2 p-2">{props.field}</label>
|
||||
<input
|
||||
readOnly={props.readOnly}
|
||||
disabled={
|
||||
props.field === "maxGenTokens" &&
|
||||
!props.chatStore.maxGenTokens_enabled
|
||||
}
|
||||
type="number"
|
||||
className="m-2 p-2 border rounded focus w-28"
|
||||
value={props.chatStore[props.field]}
|
||||
onChange={(event: any) => {
|
||||
console.log("type", typeof event.target.value);
|
||||
let newNumber = parseFloat(event.target.value);
|
||||
let newNumber = parseInt(event.target.value);
|
||||
if (newNumber < 0) newNumber = 0;
|
||||
props.chatStore[props.field] = newNumber;
|
||||
props.setChatStore({ ...props.chatStore });
|
||||
@@ -284,7 +66,7 @@ const Number = (props: {
|
||||
const Choice = (props: {
|
||||
chatStore: ChatStore;
|
||||
setChatStore: (cs: ChatStore) => void;
|
||||
field: "streamMode" | "develop_mode" | "json_mode" | "logprobs";
|
||||
field: "streamMode";
|
||||
help: string;
|
||||
}) => {
|
||||
return (
|
||||
@@ -306,22 +88,11 @@ const Choice = (props: {
|
||||
export default (props: {
|
||||
chatStore: ChatStore;
|
||||
setChatStore: (cs: ChatStore) => void;
|
||||
show: boolean;
|
||||
setShow: StateUpdater<boolean>;
|
||||
selectedChatStoreIndex: number;
|
||||
templates: TemplateChatStore[];
|
||||
setTemplates: (templates: TemplateChatStore[]) => void;
|
||||
templateAPIs: TemplateAPI[];
|
||||
setTemplateAPIs: (templateAPIs: TemplateAPI[]) => void;
|
||||
templateAPIsWhisper: TemplateAPI[];
|
||||
setTemplateAPIsWhisper: (templateAPIs: TemplateAPI[]) => void;
|
||||
templateAPIsTTS: TemplateAPI[];
|
||||
setTemplateAPIsTTS: (templateAPIs: TemplateAPI[]) => void;
|
||||
templateAPIsImageGen: TemplateAPI[];
|
||||
setTemplateAPIsImageGen: (templateAPIs: TemplateAPI[]) => void;
|
||||
templateTools: TemplateTools[];
|
||||
setTemplateTools: (templateTools: TemplateTools[]) => void;
|
||||
}) => {
|
||||
let link =
|
||||
if (!props.show) return <div></div>;
|
||||
const link =
|
||||
location.protocol +
|
||||
"//" +
|
||||
location.host +
|
||||
@@ -330,146 +101,26 @@ export default (props: {
|
||||
props.chatStore.apiKey
|
||||
)}&api=${encodeURIComponent(props.chatStore.apiEndpoint)}&mode=${
|
||||
props.chatStore.streamMode ? "stream" : "fetch"
|
||||
}&model=${props.chatStore.model}&sys=${encodeURIComponent(
|
||||
props.chatStore.systemMessageContent
|
||||
)}`;
|
||||
if (props.chatStore.develop_mode) {
|
||||
link = link + `&dev=true`;
|
||||
}
|
||||
|
||||
const importFileRef = createRef();
|
||||
const [totalCost, setTotalCost] = useState(getTotalCost());
|
||||
// @ts-ignore
|
||||
const { langCode, setLangCode } = useContext(langCodeContext);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyPress = (event: any) => {
|
||||
if (event.keyCode === 27) {
|
||||
// keyCode for ESC key is 27
|
||||
props.setShow(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyPress);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyPress);
|
||||
};
|
||||
}, []); // The empty dependency array ensures that the effect runs only once
|
||||
|
||||
}&sys=${encodeURIComponent(props.chatStore.systemMessageContent)}`;
|
||||
return (
|
||||
<div
|
||||
onClick={() => props.setShow(false)}
|
||||
className="left-0 top-0 overflow-scroll flex justify-center absolute w-screen h-full bg-black bg-opacity-50 z-10"
|
||||
>
|
||||
<div
|
||||
onClick={(event: any) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
className="m-2 p-2 bg-white rounded-lg h-fit lg:w-2/3 z-20"
|
||||
>
|
||||
<h3 className="text-xl text-center flex justify-between">
|
||||
<span>{Tr("Settings")}</span>
|
||||
<select>
|
||||
{Object.keys(LANG_OPTIONS).map((opt) => (
|
||||
<option
|
||||
value={opt}
|
||||
selected={opt === (langCodeContext as any).langCode}
|
||||
onClick={(event: any) => {
|
||||
console.log("set lang code", event.target.value);
|
||||
setLangCode(event.target.value);
|
||||
}}
|
||||
>
|
||||
{LANG_OPTIONS[opt].name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</h3>
|
||||
<hr />
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
className="p-2 m-2 rounded bg-purple-600 text-white"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(link);
|
||||
alert(tr(`Copied link:`, langCode) + `${link}`);
|
||||
}}
|
||||
>
|
||||
{Tr("Copy Setting Link")}
|
||||
</button>
|
||||
<button
|
||||
className="p-2 m-2 rounded bg-rose-600 text-white"
|
||||
onClick={() => {
|
||||
if (!confirm(tr("Are you sure to clear all history?", langCode)))
|
||||
return;
|
||||
props.chatStore.history = props.chatStore.history.filter(
|
||||
(msg) => msg.example && !msg.hide
|
||||
);
|
||||
props.chatStore.postBeginIndex = 0;
|
||||
props.setChatStore({ ...props.chatStore });
|
||||
}}
|
||||
>
|
||||
{Tr("Clear History")}
|
||||
</button>
|
||||
<button
|
||||
className="p-2 m-2 rounded bg-cyan-600 text-white"
|
||||
onClick={() => {
|
||||
props.setShow(false);
|
||||
}}
|
||||
>
|
||||
{Tr("Close")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="m-2 p-2">
|
||||
{Tr("Total cost in this session")} ${props.chatStore.cost.toFixed(4)}
|
||||
</p>
|
||||
<div className="left-0 top-0 overflow-scroll flex justify-center absolute w-screen h-screen bg-black bg-opacity-50 z-10">
|
||||
<div className="m-2 p-2 bg-white rounded-lg h-fit">
|
||||
<h3 className="text-xl">Settings</h3>
|
||||
<hr />
|
||||
<div className="box">
|
||||
<LongInput
|
||||
<Input
|
||||
field="systemMessageContent"
|
||||
help="系统消息,用于指示ChatGPT的角色和一些前置条件,例如“你是一个有帮助的人工智能助理”,或者“你是一个专业英语翻译,把我的话全部翻译成英语”,详情参考 OPEAN AI API 文档"
|
||||
{...props}
|
||||
/>
|
||||
<span>
|
||||
Valied JSON:{" "}
|
||||
{isVailedJSON(props.chatStore.toolsString) ? "🆗" : "❌"}
|
||||
</span>
|
||||
<LongInput
|
||||
field="toolsString"
|
||||
help="function call tools, should be valied json format in list"
|
||||
<Input
|
||||
field="apiKey"
|
||||
help="OPEN AI API 密钥,请勿泄漏此密钥"
|
||||
{...props}
|
||||
/>
|
||||
<div className="relative border-slate-300 border rounded">
|
||||
<div className="flex justify-between">
|
||||
<strong className="p-1 m-1">Chat API</strong>
|
||||
<SetAPIsTemplate
|
||||
label="Chat API"
|
||||
endpoint={props.chatStore.apiEndpoint}
|
||||
APIkey={props.chatStore.apiKey}
|
||||
tmps={props.templateAPIs}
|
||||
setTmps={props.setTemplateAPIs}
|
||||
/>
|
||||
</div>
|
||||
<hr />
|
||||
<Input
|
||||
field="apiKey"
|
||||
help="OPEN AI API 密钥,请勿泄漏此密钥"
|
||||
{...props}
|
||||
/>
|
||||
<Input
|
||||
field="apiEndpoint"
|
||||
help="API 端点,方便在不支持的地区使用反向代理服务,默认为 https://api.openai.com/v1/chat/completions"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
<SelectModel
|
||||
help="模型,默认 3.5。不同模型性能和定价也不同,请参考 API 文档。"
|
||||
{...props}
|
||||
/>
|
||||
<Slicer
|
||||
field="temperature"
|
||||
min={0}
|
||||
max={2}
|
||||
help="温度,数值越大模型生成文字的随机性越高。"
|
||||
<Input
|
||||
field="apiEndpoint"
|
||||
help="API 端点,方便在不支持的地区使用反向代理服务,默认为 https://api.openai.com/v1/chat/completions"
|
||||
{...props}
|
||||
/>
|
||||
<Choice
|
||||
@@ -477,21 +128,9 @@ export default (props: {
|
||||
help="流模式,使用 stream mode 将可以动态看到生成内容,但无法准确计算 token 数量,在 token 数量过多时可能会裁切过多或过少历史消息"
|
||||
{...props}
|
||||
/>
|
||||
<Choice field="logprobs" help="返回每个Token的概率" {...props} />
|
||||
<Choice
|
||||
field="develop_mode"
|
||||
help="开发者模式,开启后会显示更多选项及功能"
|
||||
{...props}
|
||||
/>
|
||||
<Number
|
||||
field="maxTokens"
|
||||
help="最大上下文 token 数量。此值会根据选择的模型自动设置。"
|
||||
readOnly={false}
|
||||
{...props}
|
||||
/>
|
||||
<Number
|
||||
field="maxGenTokens"
|
||||
help="最大生成 Tokens 数量,可选值。"
|
||||
help="最大 token 数量,这个详情参考 OPENAI API 文档"
|
||||
readOnly={false}
|
||||
{...props}
|
||||
/>
|
||||
@@ -501,11 +140,10 @@ export default (props: {
|
||||
readOnly={false}
|
||||
{...props}
|
||||
/>
|
||||
<Choice field="json_mode" help="JSON Mode" {...props} />
|
||||
<Number
|
||||
field="postBeginIndex"
|
||||
help="指示发送 API 请求时要”忘记“多少历史消息"
|
||||
readOnly={true}
|
||||
readOnly={false}
|
||||
{...props}
|
||||
/>
|
||||
<Number
|
||||
@@ -514,291 +152,44 @@ export default (props: {
|
||||
readOnly={true}
|
||||
{...props}
|
||||
/>
|
||||
<Slicer
|
||||
field="top_p"
|
||||
min={0}
|
||||
max={1}
|
||||
help="Top P 采样方法。建议与温度采样方法二选一,不要同时开启。"
|
||||
{...props}
|
||||
/>
|
||||
<Number
|
||||
field="presence_penalty"
|
||||
help="存在惩罚度"
|
||||
readOnly={false}
|
||||
{...props}
|
||||
/>
|
||||
<Number
|
||||
field="frequency_penalty"
|
||||
help="频率惩罚度"
|
||||
readOnly={false}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
<div className="relative border-slate-300 border rounded">
|
||||
<div className="flex justify-between">
|
||||
<strong className="p-1 m-1">Whisper API</strong>
|
||||
<SetAPIsTemplate
|
||||
label="Whisper API"
|
||||
endpoint={props.chatStore.whisper_api}
|
||||
APIkey={props.chatStore.whisper_key}
|
||||
tmps={props.templateAPIsWhisper}
|
||||
setTmps={props.setTemplateAPIsWhisper}
|
||||
/>
|
||||
</div>
|
||||
<hr />
|
||||
<Input
|
||||
field="whisper_key"
|
||||
help="用于 Whisper 服务的 key,默认为 上方使用的OPENAI key,可在此单独配置专用key"
|
||||
{...props}
|
||||
/>
|
||||
<Input
|
||||
field="whisper_api"
|
||||
help="Whisper 语言转文字服务,填入此api才会开启,默认为 https://api.openai.com/v1/audio/transriptions"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative border-slate-300 border rounded mt-1">
|
||||
<div className="flex justify-between">
|
||||
<strong className="p-1 m-1">TTS API</strong>
|
||||
<SetAPIsTemplate
|
||||
label="TTS API"
|
||||
endpoint={props.chatStore.tts_api}
|
||||
APIkey={props.chatStore.tts_key}
|
||||
tmps={props.templateAPIsTTS}
|
||||
setTmps={props.setTemplateAPIsTTS}
|
||||
/>
|
||||
</div>
|
||||
<hr />
|
||||
<Input field="tts_key" help="tts service api key" {...props} />
|
||||
<Input
|
||||
field="tts_api"
|
||||
help="tts api, eg. https://api.openai.com/v1/audio/speech"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
<Help help="tts voice style">
|
||||
<label className="m-2 p-2">TTS Voice</label>
|
||||
<select
|
||||
className="m-2 p-2"
|
||||
value={props.chatStore.tts_voice}
|
||||
onChange={(event: any) => {
|
||||
const voice = event.target.value as string;
|
||||
props.chatStore.tts_voice = voice;
|
||||
props.setChatStore({ ...props.chatStore });
|
||||
}}
|
||||
>
|
||||
{TTS_VOICES.map((opt) => (
|
||||
<option value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</Help>
|
||||
<Slicer
|
||||
min={0.25}
|
||||
max={4.0}
|
||||
field="tts_speed"
|
||||
help={"TTS Speed"}
|
||||
{...props}
|
||||
/>
|
||||
<Help help="tts response format">
|
||||
<label className="m-2 p-2">TTS Format</label>
|
||||
<select
|
||||
className="m-2 p-2"
|
||||
value={props.chatStore.tts_format}
|
||||
onChange={(event: any) => {
|
||||
const format = event.target.value as string;
|
||||
props.chatStore.tts_format = format;
|
||||
props.setChatStore({ ...props.chatStore });
|
||||
}}
|
||||
>
|
||||
{TTS_FORMAT.map((opt) => (
|
||||
<option value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</Help>
|
||||
|
||||
<div className="relative border-slate-300 border rounded">
|
||||
<div className="flex justify-between">
|
||||
<strong className="p-1 m-1">Image Gen API</strong>
|
||||
<SetAPIsTemplate
|
||||
label="Image Gen API"
|
||||
endpoint={props.chatStore.image_gen_api}
|
||||
APIkey={props.chatStore.image_gen_key}
|
||||
tmps={props.templateAPIsImageGen}
|
||||
setTmps={props.setTemplateAPIsImageGen}
|
||||
/>
|
||||
</div>
|
||||
<hr />
|
||||
<Input
|
||||
field="image_gen_key"
|
||||
help="image generation service api key"
|
||||
{...props}
|
||||
/>
|
||||
<Input
|
||||
field="image_gen_api"
|
||||
help="DALL image gen key, eg. https://api.openai.com/v1/images/generations"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<p className="m-2 p-2">
|
||||
{Tr("Accumulated cost in all sessions")} ${totalCost.toFixed(4)}
|
||||
</p>
|
||||
<button
|
||||
className="p-2 m-2 rounded bg-emerald-500"
|
||||
onClick={() => {
|
||||
clearTotalCost();
|
||||
setTotalCost(getTotalCost());
|
||||
}}
|
||||
>
|
||||
{Tr("Reset")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-evenly flex-wrap">
|
||||
{props.chatStore.toolsString.trim() && (
|
||||
<button
|
||||
className="p-2 m-2 rounded bg-blue-300"
|
||||
onClick={() => {
|
||||
const name = prompt(`Give this **Tools** template a name:`);
|
||||
if (!name) {
|
||||
alert("No template name specified");
|
||||
return;
|
||||
}
|
||||
const newToolsTmp: TemplateTools = {
|
||||
name,
|
||||
toolsString: props.chatStore.toolsString,
|
||||
};
|
||||
props.templateTools.push(newToolsTmp);
|
||||
props.setTemplateTools([...props.templateTools]);
|
||||
}}
|
||||
>
|
||||
{Tr(`Save Tools`)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="flex justify-evenly">
|
||||
<button
|
||||
className="p-2 m-2 rounded bg-amber-500"
|
||||
onClick={() => {
|
||||
let dataStr =
|
||||
"data:text/json;charset=utf-8," +
|
||||
encodeURIComponent(
|
||||
JSON.stringify(props.chatStore, null, "\t")
|
||||
);
|
||||
let downloadAnchorNode = document.createElement("a");
|
||||
downloadAnchorNode.setAttribute("href", dataStr);
|
||||
downloadAnchorNode.setAttribute(
|
||||
"download",
|
||||
`chatgpt-api-web-${props.selectedChatStoreIndex}.json`
|
||||
);
|
||||
document.body.appendChild(downloadAnchorNode); // required for firefox
|
||||
downloadAnchorNode.click();
|
||||
downloadAnchorNode.remove();
|
||||
}}
|
||||
>
|
||||
{Tr("Export")}
|
||||
</button>
|
||||
<button
|
||||
className="p-2 m-2 rounded bg-amber-500"
|
||||
onClick={() => {
|
||||
const name = prompt(tr("Give this template a name:", langCode));
|
||||
if (!name) {
|
||||
alert(tr("No template name specified", langCode));
|
||||
return;
|
||||
}
|
||||
const tmp: ChatStore = structuredClone(props.chatStore);
|
||||
tmp.history = tmp.history.filter((h) => h.example);
|
||||
// clear api because it is stored in the API template
|
||||
tmp.apiEndpoint = "";
|
||||
tmp.apiKey = "";
|
||||
tmp.whisper_api = "";
|
||||
tmp.whisper_key = "";
|
||||
tmp.tts_api = "";
|
||||
tmp.tts_key = "";
|
||||
tmp.image_gen_api = "";
|
||||
tmp.image_gen_key = "";
|
||||
// @ts-ignore
|
||||
tmp.name = name;
|
||||
props.templates.push(tmp as TemplateChatStore);
|
||||
props.setTemplates([...props.templates]);
|
||||
}}
|
||||
>
|
||||
{Tr("As template")}
|
||||
</button>
|
||||
<button
|
||||
className="p-2 m-2 rounded bg-amber-500"
|
||||
onClick={() => {
|
||||
if (
|
||||
!confirm(
|
||||
tr(
|
||||
"This will OVERWRITE the current chat history! Continue?",
|
||||
langCode
|
||||
)
|
||||
)
|
||||
)
|
||||
return;
|
||||
console.log("importFileRef", importFileRef);
|
||||
importFileRef.current.click();
|
||||
}}
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
<input
|
||||
className="hidden"
|
||||
ref={importFileRef}
|
||||
type="file"
|
||||
onChange={() => {
|
||||
const file = importFileRef.current.files[0];
|
||||
console.log("file to import", file);
|
||||
if (!file || file.type !== "application/json") {
|
||||
alert(tr("Please select a json file", langCode));
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
console.log("import content", reader.result);
|
||||
if (!reader) {
|
||||
alert(tr("Empty file", langCode));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const newChatStore: ChatStore = JSON.parse(
|
||||
reader.result as string
|
||||
);
|
||||
if (!newChatStore.chatgpt_api_web_version) {
|
||||
throw tr(
|
||||
"This is not an exported chatgpt-api-web chatstore file. The key 'chatgpt_api_web_version' is missing!",
|
||||
langCode
|
||||
);
|
||||
}
|
||||
props.setChatStore({ ...newChatStore });
|
||||
} catch (e) {
|
||||
alert(
|
||||
tr(`Import error on parsing json:`, langCode) + `${e}`
|
||||
);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<p className="text-center m-2 p-2">
|
||||
chatgpt-api-web ChatStore {Tr("Version")}{" "}
|
||||
{props.chatStore.chatgpt_api_web_version}
|
||||
</p>
|
||||
<p>
|
||||
⚠{Tr("Documents and source code are avaliable here")}:{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href="https://github.com/heimoshuiyu/chatgpt-api-web"
|
||||
target="_blank"
|
||||
>
|
||||
github.com/heimoshuiyu/chatgpt-api-web
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<hr />
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
className="p-2 m-2 rounded bg-purple-600 text-white"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(link);
|
||||
alert(`Copied link: ${link}`);
|
||||
}}
|
||||
>
|
||||
Copy Link
|
||||
</button>
|
||||
<button
|
||||
className="p-2 m-2 rounded bg-rose-600 text-white"
|
||||
onClick={() => {
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure to clear all ${props.chatStore.history.length} messages?`
|
||||
)
|
||||
)
|
||||
return;
|
||||
props.chatStore.history = [];
|
||||
props.chatStore.postBeginIndex = 0;
|
||||
props.chatStore.totalTokens = 0;
|
||||
props.setChatStore({ ...props.chatStore });
|
||||
}}
|
||||
>
|
||||
Clear History
|
||||
</button>
|
||||
<button
|
||||
className="p-2 m-2 rounded bg-cyan-600 text-white"
|
||||
onClick={() => {
|
||||
props.setShow(false);
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
export const autoHeight = (target: any) => {
|
||||
target.style.height = "auto";
|
||||
// max 70% of screen height
|
||||
target.style.height = `${Math.min(
|
||||
target.scrollHeight,
|
||||
window.innerHeight * 0.7
|
||||
)}px`;
|
||||
console.log("set auto height", target.style.height);
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
import { createContext } from "preact";
|
||||
import MAP_zh_CN from "./zh_CN";
|
||||
|
||||
interface LangOption {
|
||||
name: string;
|
||||
langMap: Record<string, string>;
|
||||
matches: string[];
|
||||
}
|
||||
|
||||
const LANG_OPTIONS: Record<string, LangOption> = {
|
||||
"en-US": {
|
||||
name: "English",
|
||||
langMap: {},
|
||||
matches: ["en-US", "en"],
|
||||
},
|
||||
"zh-CN": {
|
||||
name: "中文(简体)",
|
||||
langMap: MAP_zh_CN,
|
||||
matches: ["zh-CN", "zh"],
|
||||
},
|
||||
};
|
||||
|
||||
const langCodeContext = createContext("en-US");
|
||||
|
||||
function tr(text: string, langCode: "en-US" | "zh-CN") {
|
||||
const option = LANG_OPTIONS[langCode];
|
||||
if (option === undefined) {
|
||||
return text;
|
||||
}
|
||||
const langMap = LANG_OPTIONS[langCode].langMap;
|
||||
|
||||
const translatedText = langMap[text.toLowerCase()];
|
||||
if (translatedText === undefined) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return translatedText;
|
||||
}
|
||||
|
||||
function Tr(text: string) {
|
||||
return (
|
||||
<langCodeContext.Consumer>
|
||||
{/* @ts-ignore */}
|
||||
{({ langCode }) => {
|
||||
return tr(text, langCode);
|
||||
}}
|
||||
</langCodeContext.Consumer>
|
||||
);
|
||||
}
|
||||
|
||||
export { tr, Tr, LANG_OPTIONS, langCodeContext };
|
||||
@@ -1,59 +0,0 @@
|
||||
const LANG_MAP: Record<string, string> = {
|
||||
settings: "设置",
|
||||
model: "模型",
|
||||
"copy setting link": "复制设置链接",
|
||||
"are you sure to clear all history?": "确定要清除所有历史记录吗?",
|
||||
"clear history": "清除历史记录",
|
||||
new: "新",
|
||||
del: "删",
|
||||
cut: "遗忘",
|
||||
"please click above to set": "请点击上方进行设置",
|
||||
cost: "消费",
|
||||
stream: "流式返回",
|
||||
fetch: "一次获取",
|
||||
"saved api templates": "已保存的 API 模板",
|
||||
"saved prompt templates": "已保存的提示模板",
|
||||
"no chat history here": "暂无历史对话记录",
|
||||
"click above to change the settings of this chat":
|
||||
"点击上方更改此对话的参数(请勿泄漏)",
|
||||
"click the NEW to create a new chat": "点击左上角 NEW 新建对话",
|
||||
"all chat history and settings are stored in the local browser":
|
||||
"所有历史对话与参数储存在浏览器本地",
|
||||
"documents and source code are avaliable here":
|
||||
"详细文档与源代码可在此处获取",
|
||||
"generating...": "生成中,请保持网络稳定...",
|
||||
"re-generate": "重新生成",
|
||||
completion: "补全",
|
||||
"generated by": "生成模型: ",
|
||||
"info: chat history is too long, forget messages":
|
||||
"提示:对话历史过长,遗忘消息数量",
|
||||
"warning: current chatstore version": "警告:当前会话版本",
|
||||
retry: "重试",
|
||||
send: "发送",
|
||||
assistant: "AI消息",
|
||||
user: "用户消息",
|
||||
close: "关闭",
|
||||
"message copied to clipboard": "消息已复制到剪贴板",
|
||||
"total cost in this session": "本次会话总消费",
|
||||
"accumulated cost in all sessions": "所有会话总消费",
|
||||
export: "导出",
|
||||
"give this template a name:": "给此模板命名:",
|
||||
"no template name specified": "未指定模板名称",
|
||||
"as template": "保存为会话模板",
|
||||
"as api template": "保存为 API 模板",
|
||||
"this will overwrite the current chat history! continue?":
|
||||
"此操作将覆盖当前会话历史!继续?",
|
||||
"please select a json file": "请选择一个 JSON 文件",
|
||||
"empty file": "警告: 空文件",
|
||||
"this is not an exported chatgpt-api-web chatstore file. the key 'chatgpt_api_web_version' is missing!":
|
||||
"此文件不是 chatgpt-api-web 导出的会话文件,缺少 chatgpt_api_web_version 键值!",
|
||||
"import error on parsing json": "JSON 解析错误",
|
||||
version: "版本",
|
||||
"copied link:": "已复制链接:",
|
||||
reset: "重置",
|
||||
example: "示例",
|
||||
render: "渲染",
|
||||
"reset current": "清空当前会话",
|
||||
};
|
||||
|
||||
export default LANG_MAP;
|
||||
84
src/tts.tsx
84
src/tts.tsx
@@ -1,84 +0,0 @@
|
||||
import { useMemo, useState } from "preact/hooks";
|
||||
import { ChatStore, ChatStoreMessage, addTotalCost } from "./app";
|
||||
import { Message, getMessageText } from "./chatgpt";
|
||||
|
||||
interface TTSProps {
|
||||
chatStore: ChatStore;
|
||||
chat: ChatStoreMessage;
|
||||
setChatStore: (cs: ChatStore) => void;
|
||||
}
|
||||
interface TTSPlayProps {
|
||||
chat: ChatStoreMessage;
|
||||
}
|
||||
export function TTSPlay(props: TTSPlayProps) {
|
||||
const src = useMemo(() => {
|
||||
if (props.chat.audio instanceof Blob) {
|
||||
return URL.createObjectURL(props.chat.audio);
|
||||
}
|
||||
return "";
|
||||
}, [props.chat.audio]);
|
||||
|
||||
if (props.chat.hide) {
|
||||
return <></>;
|
||||
}
|
||||
if (props.chat.audio instanceof Blob) {
|
||||
return <audio className="w-full" src={src} controls />;
|
||||
}
|
||||
return <></>;
|
||||
}
|
||||
export default function TTSButton(props: TTSProps) {
|
||||
const [generating, setGenerating] = useState(false);
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
const api = props.chatStore.tts_api;
|
||||
const api_key = props.chatStore.tts_key;
|
||||
const model = "tts-1";
|
||||
const input = getMessageText(props.chat);
|
||||
const voice = props.chatStore.tts_voice;
|
||||
|
||||
const body: Record<string, any> = {
|
||||
model,
|
||||
input,
|
||||
voice,
|
||||
response_format: props.chatStore.tts_format || "mp3",
|
||||
};
|
||||
if (props.chatStore.tts_speed_enabled) {
|
||||
body["speed"] = props.chatStore.tts_speed;
|
||||
}
|
||||
|
||||
setGenerating(true);
|
||||
|
||||
fetch(api, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${api_key}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
.then((response) => response.blob())
|
||||
.then((blob) => {
|
||||
// update price
|
||||
const cost = (input.length * 0.015) / 1000;
|
||||
props.chatStore.cost += cost;
|
||||
addTotalCost(cost);
|
||||
props.setChatStore({ ...props.chatStore });
|
||||
|
||||
// save blob
|
||||
props.chat.audio = blob;
|
||||
props.setChatStore({ ...props.chatStore });
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const audio = new Audio(url);
|
||||
audio.play();
|
||||
})
|
||||
.finally(() => {
|
||||
setGenerating(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{generating ? "🤔" : "🔈"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user