Compare commits
1 Commits
626f406711
...
markdown
| Author | SHA1 | Date | |
|---|---|---|---|
|
e1ef16015d
|
@@ -1,27 +0,0 @@
|
|||||||
name: Build static content
|
|
||||||
|
|
||||||
on:
|
|
||||||
# Runs on pushes targeting the default branch
|
|
||||||
push:
|
|
||||||
branches: ["master"]
|
|
||||||
|
|
||||||
# Allows you to run this workflow manually from the Actions tab
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- name: Use Node.js 20.x
|
|
||||||
uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: 20.x
|
|
||||||
cache: 'npm'
|
|
||||||
- run: npm install
|
|
||||||
- run: npm run build
|
|
||||||
- name: Upload artifact
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: dist-files
|
|
||||||
path: './dist/'
|
|
||||||
32
README.md
32
README.md
@@ -1,14 +1,10 @@
|
|||||||
> 前排提示:滥用 API 或在不支持的地区调用 API 有被封号的风险 <https://github.com/zhayujie/chatgpt-on-wechat/issues/423>
|
> 前排提示:滥用 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 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 调用速度更快更稳定
|
||||||
- 对话记录、API 密钥等使用浏览器的 localStorage 保存在本地
|
- 对话记录、API 密钥等使用浏览器的 localStorage 保存在本地
|
||||||
- 可编辑并删除对话消息
|
- 可删除对话消息
|
||||||
- 可以导入/导出整个历史对话记录
|
- 可以设置 system message (如:"你是一个猫娘" 或 "你是一个有用的助理" 或 "将我的话翻译成英语",参见官方 [API 文档](https://platform.openai.com/docs/guides/chat))
|
||||||
- 可以设置 system message (参见官方 [API 文档](https://platform.openai.com/docs/guides/chat)) 例如:
|
|
||||||
- > You are a helpful assistant
|
|
||||||
- > 你是一个专业英语翻译,把我说的话翻译成英语,为了保持通顺连贯可以适当修改内容。
|
|
||||||
- > 根据我的要求撰写并修改商业文案
|
|
||||||
- > ~~你是一个猫娘,你要用猫娘的语气说话~~
|
|
||||||
- 可以为不同对话设置不同 APIKEY
|
- 可以为不同对话设置不同 APIKEY
|
||||||
- 小(整个网页 30k 左右)
|
- 小(整个网页 30k 左右)
|
||||||
- 可以设置不同的 API Endpoint(方便墙内人士使用反向代理转发 API 请求)
|
- 可以设置不同的 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` 保存网页,然后双击打开
|
- 从 [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 默认为空
|
- `key`: OPENAI API KEY 默认为空
|
||||||
- `sys`: system message 默认为 "你是一个猫娘,你要模仿猫娘的语气说话"
|
- `sys`: system message 默认为 "你是一个猫娘,你要模仿猫娘的语气说话"
|
||||||
- `api`: API Endpoint 默认为 `https://api.openai.com/v1/chat/completions`
|
- `api`: API Endpoint 默认为 `https://api.openai.com/v1/chat/completions`
|
||||||
- `mode`: `fetch` 或 `stream` 模式,stream 模式下可以动态看到 api 返回的数据,但无法得知 token 数量,只能进行估算,在 token 数量过多时可能会裁切过多或过少历史消息
|
- `mode`: `fetch` 或 `stream` 模式,stream 模式下可以动态看到 api 返回的数据,但无法得知 token 数量,只能进行估算,在 token 数量过多时可能会裁切过多或过少历史消息
|
||||||
- `dev`: true / false 开发模式,这个模式下可以看到并调整更多参数
|
|
||||||
- `temp`: 温度,默认 1
|
|
||||||
- `whisper-api`: Whisper 语音转文字服务 API, 只有设置了此值后才会显示语音转文字按钮
|
|
||||||
- `whisper-key`: 用于 Whisper 服务的 key,如果留空则默认使用上方的 OPENAI API KEY
|
|
||||||
|
|
||||||
例如 `http://localhost:1234/?key=xxxx&api=xxxx` 那么 **新创建** 的会话将会使用该默认 API 和 API Endpoint
|
例如 `http://localhost:1234/?key=xxxx&api=xxxx` 那么 **新创建** 的会话将会使用该默认 API 和 API Endpoint
|
||||||
|
|
||||||
@@ -70,4 +48,4 @@ yarn install
|
|||||||
yarn build
|
yarn build
|
||||||
```
|
```
|
||||||
|
|
||||||
构建产物在 `dist` 文件夹中
|
构建产物在 `dist` 文件夹中
|
||||||
11
index.html
11
index.html
@@ -1,15 +1,8 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html data-theme="cupcake" lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
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"
|
|
||||||
/>
|
|
||||||
<title>ChatGPT API Web</title>
|
<title>ChatGPT API Web</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
22
package.json
22
package.json
@@ -9,22 +9,16 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@heroicons/react": "^2.1.5",
|
"autoprefixer": "^10.4.14",
|
||||||
"@types/ungap__structured-clone": "^1.2.0",
|
"postcss": "^8.4.21",
|
||||||
"@ungap/structured-clone": "^1.2.0",
|
"preact": "^10.11.3",
|
||||||
"autoprefixer": "^10.4.20",
|
|
||||||
"idb": "^8.0.0",
|
|
||||||
"postcss": "^8.4.47",
|
|
||||||
"preact": "^10.24.3",
|
|
||||||
"preact-markdown": "^2.1.0",
|
"preact-markdown": "^2.1.0",
|
||||||
"sakura.css": "^1.5.0",
|
"sakura.css": "^1.4.1",
|
||||||
"tailwindcss": "^3.4.13"
|
"tailwindcss": "^3.2.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@preact/preset-vite": "^2.9.1",
|
"@preact/preset-vite": "^2.5.0",
|
||||||
"daisyui": "^4.12.13",
|
"typescript": "^4.9.3",
|
||||||
"theme-change": "^2.5.0",
|
"vite": "^4.1.0"
|
||||||
"typescript": "^5.6.3",
|
|
||||||
"vite": "^5.4.8"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
341
src/addImage.tsx
341
src/addImage.tsx
@@ -1,341 +0,0 @@
|
|||||||
import { useState } from "preact/hooks";
|
|
||||||
import { ChatStore } from "@/types/chatstore";
|
|
||||||
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-base-200 p-2 z-20"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-center p-1">
|
|
||||||
<h3>Add Images</h3>
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-neutral"
|
|
||||||
onClick={() => {
|
|
||||||
setShowAddImage(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Done
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<span className="">
|
|
||||||
<button
|
|
||||||
className="btn btn-secondary btn-sm disabled:btn-disabled"
|
|
||||||
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="btn btn-primary btn-sm disabled:btn-disabled"
|
|
||||||
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>
|
|
||||||
<div className="divider"></div>
|
|
||||||
{chatStore.image_gen_api && chatStore.image_gen_key && (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<h3>Generate Image</h3>
|
|
||||||
<span className="flex flex-col justify-between m-1 p-1">
|
|
||||||
<label>Prompt: </label>
|
|
||||||
<textarea
|
|
||||||
className="textarea textarea-sm textarea-bordered"
|
|
||||||
value={imageGenPrompt}
|
|
||||||
onChange={(e: any) => {
|
|
||||||
setImageGenPrompt(e.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span className="flex flex-row justify-between items-center m-1 p-1">
|
|
||||||
<label>Model: </label>
|
|
||||||
<select
|
|
||||||
className="select select-sm select-bordered"
|
|
||||||
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 items-center m-1 p-1">
|
|
||||||
<label>n: </label>
|
|
||||||
<input
|
|
||||||
className="input input-sm input-bordered"
|
|
||||||
value={imageGenN}
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={10}
|
|
||||||
onChange={(e: any) => setImageGenN(parseInt(e.target.value))}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span className="flex flex-row justify-between items-center m-1 p-1">
|
|
||||||
<label>Quality: </label>
|
|
||||||
<select
|
|
||||||
className="select select-sm select-bordered"
|
|
||||||
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 items-center m-1 p-1">
|
|
||||||
<label>Response Format: </label>
|
|
||||||
<select
|
|
||||||
className="select select-sm select-bordered"
|
|
||||||
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 items-center m-1 p-1">
|
|
||||||
<label>Size: </label>
|
|
||||||
<select
|
|
||||||
className="select select-sm select-bordered"
|
|
||||||
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 items-center m-1 p-1">
|
|
||||||
<label>Style (only dall-e-3): </label>
|
|
||||||
<select
|
|
||||||
className="select select-sm select-bordered"
|
|
||||||
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 items-center m-1 p-1">
|
|
||||||
<button
|
|
||||||
className="btn btn-primary btn-sm"
|
|
||||||
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,
|
|
||||||
response_model_name: imageGenModel,
|
|
||||||
});
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
181
src/app.tsx
Normal file
181
src/app.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { useEffect, useState } from "preact/hooks";
|
||||||
|
import "./global.css";
|
||||||
|
|
||||||
|
import { Message } from "./chatgpt";
|
||||||
|
import getDefaultParams from "./getDefaultParam";
|
||||||
|
import ChatBOX from "./chatbox";
|
||||||
|
|
||||||
|
export interface ChatStore {
|
||||||
|
systemMessageContent: string;
|
||||||
|
history: Message[];
|
||||||
|
postBeginIndex: number;
|
||||||
|
tokenMargin: number;
|
||||||
|
totalTokens: number;
|
||||||
|
maxTokens: number;
|
||||||
|
apiKey: string;
|
||||||
|
apiEndpoint: string;
|
||||||
|
streamMode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _defaultAPIEndpoint = "https://api.openai.com/v1/chat/completions";
|
||||||
|
const newChatStore = (
|
||||||
|
apiKey = "",
|
||||||
|
systemMessageContent = "你是一个有用的人工智能助理",
|
||||||
|
apiEndpoint = _defaultAPIEndpoint,
|
||||||
|
streamMode = true
|
||||||
|
): ChatStore => {
|
||||||
|
return {
|
||||||
|
systemMessageContent: getDefaultParams("sys", systemMessageContent),
|
||||||
|
history: [],
|
||||||
|
postBeginIndex: 0,
|
||||||
|
tokenMargin: 1024,
|
||||||
|
totalTokens: 0,
|
||||||
|
maxTokens: 4096,
|
||||||
|
apiKey: getDefaultParams("key", apiKey),
|
||||||
|
apiEndpoint: getDefaultParams("api", apiEndpoint),
|
||||||
|
streamMode: getDefaultParams("mode", streamMode),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_NAME = "chatgpt-api-web";
|
||||||
|
const STORAGE_NAME_SELECTED = `${STORAGE_NAME}-selected`;
|
||||||
|
const STORAGE_NAME_INDEXES = `${STORAGE_NAME}-indexes`;
|
||||||
|
|
||||||
|
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) ?? "0")
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("set selected chat index", selectedChatIndex);
|
||||||
|
localStorage.setItem(STORAGE_NAME_SELECTED, `${selectedChatIndex}`);
|
||||||
|
}, [selectedChatIndex]);
|
||||||
|
|
||||||
|
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(
|
||||||
|
getChatStoreByIndex(selectedChatIndex)
|
||||||
|
);
|
||||||
|
const setChatStore = (cs: ChatStore) => {
|
||||||
|
console.log("saved chat", selectedChatIndex, chatStore);
|
||||||
|
localStorage.setItem(
|
||||||
|
`${STORAGE_NAME}-${selectedChatIndex}`,
|
||||||
|
JSON.stringify(cs)
|
||||||
|
);
|
||||||
|
_setChatStore(cs);
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
_setChatStore(getChatStoreByIndex(selectedChatIndex));
|
||||||
|
}, [selectedChatIndex]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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={() => {
|
||||||
|
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);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
NEW
|
||||||
|
</button>
|
||||||
|
<ul>
|
||||||
|
{allChatStoreIndexes
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.map((i) => {
|
||||||
|
// reverse
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
className={`w-full my-1 p-1 rounded hover:bg-blue-300 ${
|
||||||
|
i === selectedChatIndex ? "bg-blue-500" : "bg-blue-200"
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedChatIndex(i);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{i}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="rounded bg-rose-400 p-1 my-1 w-full"
|
||||||
|
onClick={() => {
|
||||||
|
if (!confirm("Are you sure you want to delete this chat history?"))
|
||||||
|
return;
|
||||||
|
console.log("remove item", `${STORAGE_NAME}-${selectedChatIndex}`);
|
||||||
|
localStorage.removeItem(`${STORAGE_NAME}-${selectedChatIndex}`);
|
||||||
|
const newAllChatStoreIndexes = [
|
||||||
|
...allChatStoreIndexes.filter((v) => v !== selectedChatIndex),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (newAllChatStoreIndexes.length === 0) {
|
||||||
|
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);
|
||||||
|
|
||||||
|
setAllChatStoreIndexes([...newAllChatStoreIndexes]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
DEL
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ChatBOX chatStore={chatStore} setChatStore={setChatStore} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
254
src/chatbox.tsx
Normal file
254
src/chatbox.tsx
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import { createRef } from "preact";
|
||||||
|
import { useEffect, useState } from "preact/hooks";
|
||||||
|
import type { ChatStore } from "./app";
|
||||||
|
import ChatGPT, { ChunkMessage, FetchResponse } from "./chatgpt";
|
||||||
|
import Message from "./message";
|
||||||
|
import Settings from "./settings";
|
||||||
|
|
||||||
|
export default function ChatBOX(props: {
|
||||||
|
chatStore: ChatStore;
|
||||||
|
setChatStore: (cs: ChatStore) => void;
|
||||||
|
}) {
|
||||||
|
const { chatStore, setChatStore } = props;
|
||||||
|
// prevent error
|
||||||
|
if (chatStore === undefined) return <div></div>;
|
||||||
|
const [inputMsg, setInputMsg] = useState("");
|
||||||
|
const [showGenerating, setShowGenerating] = useState(false);
|
||||||
|
const [generatingMessage, setGeneratingMessage] = useState("");
|
||||||
|
const [showRetry, setShowRetry] = useState(false);
|
||||||
|
|
||||||
|
const messagesEndRef = createRef();
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("ref", messagesEndRef);
|
||||||
|
messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [showRetry, showGenerating, generatingMessage]);
|
||||||
|
|
||||||
|
const client = new ChatGPT(chatStore.apiKey);
|
||||||
|
|
||||||
|
const _completeWithStreamMode = async (response: Response) => {
|
||||||
|
// call api, return reponse text
|
||||||
|
console.log("response", response);
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
const allChunkMessage: string[] = [];
|
||||||
|
new ReadableStream({
|
||||||
|
async start() {
|
||||||
|
while (true) {
|
||||||
|
let responseDone = false;
|
||||||
|
let state = await reader?.read();
|
||||||
|
let done = state?.done;
|
||||||
|
let value = state?.value;
|
||||||
|
if (done) break;
|
||||||
|
let text = new TextDecoder().decode(value);
|
||||||
|
// console.log("text:", text);
|
||||||
|
const lines = text
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((i) => {
|
||||||
|
if (!i) return false;
|
||||||
|
if (i === "data: [DONE]") {
|
||||||
|
responseDone = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
console.log("lines", lines);
|
||||||
|
const jsons: ChunkMessage[] = lines
|
||||||
|
.map((line) => {
|
||||||
|
return JSON.parse(line.trim().slice("data: ".length));
|
||||||
|
})
|
||||||
|
.filter((i) => i);
|
||||||
|
// console.log("jsons", jsons);
|
||||||
|
const chunkText = jsons
|
||||||
|
.map((j) => j.choices[0].delta.content ?? "")
|
||||||
|
.join("");
|
||||||
|
// console.log("chunk text", chunkText);
|
||||||
|
allChunkMessage.push(chunkText);
|
||||||
|
setShowGenerating(true);
|
||||||
|
setGeneratingMessage(allChunkMessage.join(""));
|
||||||
|
if (responseDone) break;
|
||||||
|
}
|
||||||
|
setShowGenerating(false);
|
||||||
|
|
||||||
|
// console.log("push to history", allChunkMessage);
|
||||||
|
chatStore.history.push({
|
||||||
|
role: "assistant",
|
||||||
|
content: allChunkMessage.join(""),
|
||||||
|
});
|
||||||
|
// manually copy status from client to chatStore
|
||||||
|
chatStore.maxTokens = client.max_tokens;
|
||||||
|
chatStore.tokenMargin = client.tokens_margin;
|
||||||
|
chatStore.totalTokens =
|
||||||
|
client.total_tokens +
|
||||||
|
39 +
|
||||||
|
client.calculate_token_length(allChunkMessage.join(""));
|
||||||
|
setChatStore({ ...chatStore });
|
||||||
|
setGeneratingMessage("");
|
||||||
|
setShowGenerating(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const _completeWithFetchMode = async (response: Response) => {
|
||||||
|
const data = (await response.json()) as FetchResponse;
|
||||||
|
const content = client.processFetchResponse(data);
|
||||||
|
chatStore.history.push({ role: "assistant", content });
|
||||||
|
setShowGenerating(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// wrap the actuall complete api
|
||||||
|
const complete = async () => {
|
||||||
|
// manually copy status from chatStore to client
|
||||||
|
client.apiEndpoint = chatStore.apiEndpoint;
|
||||||
|
client.sysMessageContent = chatStore.systemMessageContent;
|
||||||
|
client.messages = chatStore.history.slice(chatStore.postBeginIndex);
|
||||||
|
try {
|
||||||
|
setShowGenerating(true);
|
||||||
|
const response = await client._fetch(chatStore.streamMode);
|
||||||
|
const contentType = response.headers.get("content-type");
|
||||||
|
if (contentType === "text/event-stream") {
|
||||||
|
await _completeWithStreamMode(response);
|
||||||
|
} else if (contentType === "application/json") {
|
||||||
|
await _completeWithFetchMode(response);
|
||||||
|
} else {
|
||||||
|
throw `unknown response content type ${contentType}`;
|
||||||
|
}
|
||||||
|
// manually copy status from client to chatStore
|
||||||
|
chatStore.maxTokens = client.max_tokens;
|
||||||
|
chatStore.tokenMargin = client.tokens_margin;
|
||||||
|
chatStore.totalTokens = client.total_tokens;
|
||||||
|
// when total token > max token - margin token:
|
||||||
|
// ChatGPT will "forgot" some historical message
|
||||||
|
// so client.message.length will be less than chatStore.history.length
|
||||||
|
chatStore.postBeginIndex =
|
||||||
|
chatStore.history.length - client.messages.length;
|
||||||
|
console.log("postBeginIndex", chatStore.postBeginIndex);
|
||||||
|
setChatStore({ ...chatStore });
|
||||||
|
} catch (error) {
|
||||||
|
setShowRetry(true);
|
||||||
|
alert(error);
|
||||||
|
} finally {
|
||||||
|
setShowGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// when user click the "send" button or ctrl+Enter in the textarea
|
||||||
|
const send = async (msg = "") => {
|
||||||
|
const inputMsg = msg;
|
||||||
|
if (!inputMsg) {
|
||||||
|
console.log("empty message");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chatStore.history.push({ role: "user", content: inputMsg.trim() });
|
||||||
|
setChatStore({ ...chatStore });
|
||||||
|
setInputMsg("");
|
||||||
|
await complete();
|
||||||
|
};
|
||||||
|
|
||||||
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
return (
|
||||||
|
<div className="grow flex flex-col p-2 dark:text-black">
|
||||||
|
<Settings
|
||||||
|
chatStore={chatStore}
|
||||||
|
setChatStore={setChatStore}
|
||||||
|
show={showSettings}
|
||||||
|
setShow={setShowSettings}
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
className="cursor-pointer dark:text-white"
|
||||||
|
onClick={() => setShowSettings(true)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<button className="underline">
|
||||||
|
{chatStore.systemMessageContent.length > 16
|
||||||
|
? chatStore.systemMessageContent.slice(0, 16) + ".."
|
||||||
|
: chatStore.systemMessageContent}
|
||||||
|
</button>{" "}
|
||||||
|
<button className="underline">
|
||||||
|
{chatStore.streamMode ? "STREAM" : "FETCH"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs">
|
||||||
|
<span>Total: {chatStore.totalTokens}</span>{" "}
|
||||||
|
<span>Max: {chatStore.maxTokens}</span>{" "}
|
||||||
|
<span>Margin: {chatStore.tokenMargin}</span>{" "}
|
||||||
|
<span>Message: {chatStore.history.length}</span>{" "}
|
||||||
|
<span>Cut: {chatStore.postBeginIndex}</span>
|
||||||
|
</div>
|
||||||
|
</p>
|
||||||
|
<div className="grow overflow-scroll">
|
||||||
|
{!chatStore.apiKey && (
|
||||||
|
<p className="opacity-60 p-6 rounded bg-white my-3 text-left dark:text-black">
|
||||||
|
请先在上方设置 (OPENAI) API KEY
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!chatStore.apiEndpoint && (
|
||||||
|
<p className="opacity-60 p-6 rounded bg-white my-3 text-left dark:text-black">
|
||||||
|
请先在上方设置 API Endpoint
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{chatStore.history.length === 0 && (
|
||||||
|
<p className="opacity-60 p-6 rounded bg-white my-3 text-left dark:text-black">
|
||||||
|
暂无历史对话记录
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{chatStore.history.map((_, messageIndex) => (
|
||||||
|
<Message
|
||||||
|
chatStore={chatStore}
|
||||||
|
setChatStore={setChatStore}
|
||||||
|
messageIndex={messageIndex}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{showGenerating && (
|
||||||
|
<p className="p-2 my-2 animate-pulse dark:text-white">
|
||||||
|
{generatingMessage
|
||||||
|
? generatingMessage.split("\n").map((line) => <p>{line}</p>)
|
||||||
|
: "生成中,请保持网络稳定"}
|
||||||
|
...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{showRetry && (
|
||||||
|
<p className="text-right p-2 my-2 dark:text-white">
|
||||||
|
<button
|
||||||
|
className="p-1 rounded bg-rose-500"
|
||||||
|
onClick={async () => {
|
||||||
|
setShowRetry(false);
|
||||||
|
await complete();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div ref={messagesEndRef}></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<textarea
|
||||||
|
rows={Math.min(10, (inputMsg.match(/\n/g) || []).length + 2)}
|
||||||
|
value={inputMsg}
|
||||||
|
onChange={(event: any) => setInputMsg(event.target.value)}
|
||||||
|
onKeyPress={(event: any) => {
|
||||||
|
console.log(event);
|
||||||
|
if (event.ctrlKey && event.code === "Enter") {
|
||||||
|
send(event.target.value);
|
||||||
|
setInputMsg("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setInputMsg(event.target.value);
|
||||||
|
}}
|
||||||
|
className="rounded grow m-1 p-1 border-2 border-gray-400 w-0"
|
||||||
|
placeholder="Type here..."
|
||||||
|
></textarea>
|
||||||
|
<button
|
||||||
|
className="disabled:line-through disabled:bg-slate-500 rounded m-1 p-1 border-2 bg-cyan-400 hover:bg-cyan-600"
|
||||||
|
disabled={showGenerating || !chatStore.apiKey}
|
||||||
|
onClick={() => {
|
||||||
|
send(inputMsg);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
329
src/chatgpt.ts
329
src/chatgpt.ts
@@ -1,89 +1,15 @@
|
|||||||
import { DefaultModel } from "@/const";
|
|
||||||
|
|
||||||
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 {
|
export interface Message {
|
||||||
role: "system" | "user" | "assistant" | "tool";
|
role: "system" | "user" | "assistant";
|
||||||
content: string | MessageDetail[];
|
content: string;
|
||||||
name?: "example_user" | "example_assistant";
|
|
||||||
tool_calls?: ToolCall[];
|
|
||||||
tool_call_id?: 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 {
|
export interface ChunkMessage {
|
||||||
model: string;
|
|
||||||
choices: {
|
choices: {
|
||||||
delta: { role: "assitant" | undefined; content: string | undefined };
|
delta: { role: "assitant" | undefined; content: string | undefined };
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FetchResponse {
|
export interface FetchResponse {
|
||||||
error?: any;
|
|
||||||
id: string;
|
id: string;
|
||||||
object: string;
|
object: string;
|
||||||
created: number;
|
created: number;
|
||||||
@@ -97,219 +23,69 @@ export interface FetchResponse {
|
|||||||
message: Message | undefined;
|
message: Message | undefined;
|
||||||
finish_reason: "stop" | "length";
|
finish_reason: "stop" | "length";
|
||||||
index: number | undefined;
|
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 {
|
class Chat {
|
||||||
OPENAI_API_KEY: string;
|
OPENAI_API_KEY: string;
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
sysMessageContent: string;
|
sysMessageContent: string;
|
||||||
toolsString: string;
|
|
||||||
total_tokens: number;
|
total_tokens: number;
|
||||||
max_tokens: number;
|
max_tokens: number;
|
||||||
max_gen_tokens: number;
|
|
||||||
enable_max_gen_tokens: boolean;
|
|
||||||
tokens_margin: number;
|
tokens_margin: number;
|
||||||
apiEndpoint: string;
|
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(
|
constructor(
|
||||||
OPENAI_API_KEY: string | undefined,
|
OPENAI_API_KEY: string | undefined,
|
||||||
{
|
{
|
||||||
systemMessage = "",
|
systemMessage = "你是一个有用的人工智能助理",
|
||||||
toolsString = "",
|
|
||||||
max_tokens = 4096,
|
max_tokens = 4096,
|
||||||
max_gen_tokens = 2048,
|
|
||||||
enable_max_gen_tokens = true,
|
|
||||||
tokens_margin = 1024,
|
tokens_margin = 1024,
|
||||||
apiEndPoint = "https://api.openai.com/v1/chat/completions",
|
apiEndPoint = "https://api.openai.com/v1/chat/completions",
|
||||||
model = DefaultModel,
|
} = {}
|
||||||
temperature = 1,
|
|
||||||
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.messages = [];
|
||||||
this.total_tokens = calculate_token_length(systemMessage);
|
this.total_tokens = 0;
|
||||||
this.max_tokens = max_tokens;
|
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.tokens_margin = tokens_margin;
|
||||||
this.sysMessageContent = systemMessage;
|
this.sysMessageContent = systemMessage;
|
||||||
this.toolsString = toolsString;
|
|
||||||
this.apiEndpoint = apiEndPoint;
|
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) {
|
_fetch(stream = 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}`;
|
|
||||||
}
|
|
||||||
return fetch(this.apiEndpoint, {
|
return fetch(this.apiEndpoint, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers: {
|
||||||
body: JSON.stringify(body),
|
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) {
|
async fetch(): Promise<FetchResponse> {
|
||||||
const reader = resp?.body?.pipeThrough(new TextDecoderStream()).getReader();
|
const resp = await this._fetch();
|
||||||
if (reader === undefined) {
|
return await resp.json();
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
processFetchResponse(resp: FetchResponse): Message {
|
async say(content: string): Promise<string> {
|
||||||
if (resp.error !== undefined) {
|
this.messages.push({ role: "user", content });
|
||||||
throw JSON.stringify(resp.error);
|
await this.complete();
|
||||||
}
|
return this.messages.slice(-1)[0].content;
|
||||||
|
}
|
||||||
|
|
||||||
|
processFetchResponse(resp: FetchResponse): string {
|
||||||
this.total_tokens = resp?.usage?.total_tokens ?? 0;
|
this.total_tokens = resp?.usage?.total_tokens ?? 0;
|
||||||
if (resp?.choices[0]?.message) {
|
if (resp?.choices[0]?.message) {
|
||||||
this.messages.push(resp?.choices[0]?.message);
|
this.messages.push(resp?.choices[0]?.message);
|
||||||
@@ -321,26 +97,33 @@ class Chat {
|
|||||||
this.forgetSomeMessages();
|
this.forgetSomeMessages();
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = resp.choices[0].message?.content ?? "";
|
return (
|
||||||
if (
|
resp?.choices[0]?.message?.content ?? `Error: ${JSON.stringify(resp)}`
|
||||||
!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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
calculate_token_length(content: string | MessageDetail[]): number {
|
async complete(): Promise<string> {
|
||||||
return calculate_token_length(content);
|
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) {
|
for (const msg of messages) {
|
||||||
this.messages.push({ role: "user", content: msg });
|
this.messages.push({ role: "user", content: msg });
|
||||||
this.total_tokens += this.calculate_token_length(msg);
|
this.total_tokens += this.calculate_token_length(msg);
|
||||||
@@ -348,7 +131,7 @@ class Chat {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assistant(...messages: (string | MessageDetail[])[]) {
|
assistant(...messages: string[]) {
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
this.messages.push({ role: "assistant", content: msg });
|
this.messages.push({ role: "assistant", content: msg });
|
||||||
this.total_tokens += this.calculate_token_length(msg);
|
this.total_tokens += this.calculate_token_length(msg);
|
||||||
|
|||||||
@@ -1,204 +0,0 @@
|
|||||||
import {
|
|
||||||
CubeIcon,
|
|
||||||
BanknotesIcon,
|
|
||||||
ChatBubbleLeftEllipsisIcon,
|
|
||||||
ScissorsIcon,
|
|
||||||
SwatchIcon,
|
|
||||||
SparklesIcon,
|
|
||||||
} from "@heroicons/react/24/outline";
|
|
||||||
|
|
||||||
import { ChatStore } from "@/types/chatstore";
|
|
||||||
import { models } from "@/types/models";
|
|
||||||
import { Tr } from "@/translate";
|
|
||||||
import { getTotalCost } from "@/utils/totalCost";
|
|
||||||
|
|
||||||
const StatusBar = (props: {
|
|
||||||
chatStore: ChatStore;
|
|
||||||
setShowSettings: (show: boolean) => void;
|
|
||||||
setShowSearch: (show: boolean) => void;
|
|
||||||
}) => {
|
|
||||||
const { chatStore, setShowSettings, setShowSearch } = props;
|
|
||||||
return (
|
|
||||||
<div className="navbar bg-base-100 p-0">
|
|
||||||
<div className="navbar-start">
|
|
||||||
<div className="dropdown lg:hidden">
|
|
||||||
<div tabindex={0} role="button" className="btn btn-ghost btn-circle">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className="h-6 w-6"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M4 6h16M4 12h16M4 18h7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<ul
|
|
||||||
tabindex={0}
|
|
||||||
className="menu menu-sm dropdown-content bg-base-100 rounded-box z-[1] mt-3 w-52 p-2 shadow"
|
|
||||||
>
|
|
||||||
<li>
|
|
||||||
<p>
|
|
||||||
<ChatBubbleLeftEllipsisIcon className="h-4 w-4" />
|
|
||||||
Tokens: {chatStore.totalTokens}/{chatStore.maxTokens}
|
|
||||||
</p>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<p>
|
|
||||||
<ScissorsIcon className="h-4 w-4" />
|
|
||||||
Cut:
|
|
||||||
{chatStore.postBeginIndex}/
|
|
||||||
{chatStore.history.filter(({ hide }) => !hide).length}
|
|
||||||
</p>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<p>
|
|
||||||
<BanknotesIcon className="h-4 w-4" />
|
|
||||||
Cost: ${chatStore.cost.toFixed(4)}
|
|
||||||
</p>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="navbar-center cursor-pointer py-1"
|
|
||||||
onClick={() => {
|
|
||||||
setShowSettings(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* the long staus bar */}
|
|
||||||
<div className="stats shadow hidden lg:inline-grid">
|
|
||||||
<div className="stat">
|
|
||||||
<div className="stat-figure text-secondary">
|
|
||||||
<CubeIcon className="h-10 w-10" />
|
|
||||||
</div>
|
|
||||||
<div className="stat-title">Model</div>
|
|
||||||
<div className="stat-value text-base">{chatStore.model}</div>
|
|
||||||
<div className="stat-desc">
|
|
||||||
{models[chatStore.model]?.price?.prompt * 1000 * 1000} $/M tokens
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat">
|
|
||||||
<div className="stat-figure text-secondary">
|
|
||||||
<SwatchIcon className="h-10 w-10" />
|
|
||||||
</div>
|
|
||||||
<div className="stat-title">Mode</div>
|
|
||||||
<div className="stat-value text-base">
|
|
||||||
{chatStore.streamMode ? Tr("STREAM") : Tr("FETCH")}
|
|
||||||
</div>
|
|
||||||
<div className="stat-desc">STREAM/FETCH</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="stat">
|
|
||||||
<div className="stat-figure text-secondary">
|
|
||||||
<ChatBubbleLeftEllipsisIcon className="h-10 w-10" />
|
|
||||||
</div>
|
|
||||||
<div className="stat-title">Tokens</div>
|
|
||||||
<div className="stat-value text-base">{chatStore.totalTokens}</div>
|
|
||||||
<div className="stat-desc">Max: {chatStore.maxTokens}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="stat">
|
|
||||||
<div className="stat-figure text-secondary">
|
|
||||||
<ScissorsIcon className="h-10 w-10" />
|
|
||||||
</div>
|
|
||||||
<div className="stat-title">Cut</div>
|
|
||||||
<div className="stat-value text-base">
|
|
||||||
{chatStore.postBeginIndex}
|
|
||||||
</div>
|
|
||||||
<div className="stat-desc">
|
|
||||||
Max: {chatStore.history.filter(({ hide }) => !hide).length}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="stat">
|
|
||||||
<div className="stat-figure text-secondary">
|
|
||||||
<BanknotesIcon className="h-10 w-10" />
|
|
||||||
</div>
|
|
||||||
<div className="stat-title">Cost</div>
|
|
||||||
<div className="stat-value text-base">
|
|
||||||
${chatStore.cost.toFixed(4)}
|
|
||||||
</div>
|
|
||||||
<div className="stat-desc">
|
|
||||||
Accumulated: ${getTotalCost().toFixed(2)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* the short status bar */}
|
|
||||||
<div className="indicator lg:hidden">
|
|
||||||
{chatStore.totalTokens !== 0 && (
|
|
||||||
<span className="indicator-item badge badge-primary">
|
|
||||||
Tokens: {chatStore.totalTokens}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<a className="btn btn-ghost text-base sm:text-xl p-0">
|
|
||||||
<SparklesIcon className="h-4 w-4 hidden sm:block" />
|
|
||||||
{chatStore.model}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="navbar-end">
|
|
||||||
<button
|
|
||||||
className="btn btn-ghost btn-circle"
|
|
||||||
onClick={(event) => {
|
|
||||||
// stop propagation to parent
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
setShowSearch(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className="h-6 w-6"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-ghost btn-circle hidden sm:block"
|
|
||||||
onClick={() => setShowSettings(true)}
|
|
||||||
>
|
|
||||||
<div className="indicator">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="1.5"
|
|
||||||
stroke="currentColor"
|
|
||||||
className="h-6 w-6"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<span className="badge badge-xs badge-primary indicator-item"></span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default StatusBar;
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { TemplateChatStore } from "@/types/chatstore";
|
|
||||||
import { ChatStore } from "@/types/chatstore";
|
|
||||||
import { getDefaultParams } from "@/utils/getDefaultParam";
|
|
||||||
|
|
||||||
const Templates = (props: {
|
|
||||||
templates: TemplateChatStore[];
|
|
||||||
chatStore: ChatStore;
|
|
||||||
setChatStore: (cs: ChatStore) => void;
|
|
||||||
setTemplates: (templates: TemplateChatStore[]) => void;
|
|
||||||
}) => {
|
|
||||||
const { templates, chatStore, setChatStore, setTemplates } = props;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{templates.map((t, index) => (
|
|
||||||
<div
|
|
||||||
className="cursor-pointer rounded bg-green-400 w-fit p-2 m-1 flex flex-col"
|
|
||||||
onClick={() => {
|
|
||||||
const newChatStore: ChatStore = structuredClone(t);
|
|
||||||
// @ts-ignore
|
|
||||||
delete newChatStore.name;
|
|
||||||
if (!newChatStore.apiEndpoint) {
|
|
||||||
newChatStore.apiEndpoint = getDefaultParams(
|
|
||||||
"api",
|
|
||||||
chatStore.apiEndpoint,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!newChatStore.apiKey) {
|
|
||||||
newChatStore.apiKey = getDefaultParams("key", chatStore.apiKey);
|
|
||||||
}
|
|
||||||
if (!newChatStore.whisper_api) {
|
|
||||||
newChatStore.whisper_api = getDefaultParams(
|
|
||||||
"whisper-api",
|
|
||||||
chatStore.whisper_api,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!newChatStore.whisper_key) {
|
|
||||||
newChatStore.whisper_key = getDefaultParams(
|
|
||||||
"whisper-key",
|
|
||||||
chatStore.whisper_key,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!newChatStore.tts_api) {
|
|
||||||
newChatStore.tts_api = getDefaultParams(
|
|
||||||
"tts-api",
|
|
||||||
chatStore.tts_api,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!newChatStore.tts_key) {
|
|
||||||
newChatStore.tts_key = getDefaultParams(
|
|
||||||
"tts-key",
|
|
||||||
chatStore.tts_key,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!newChatStore.image_gen_api) {
|
|
||||||
newChatStore.image_gen_api = getDefaultParams(
|
|
||||||
"image-gen-api",
|
|
||||||
chatStore.image_gen_api,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!newChatStore.image_gen_key) {
|
|
||||||
newChatStore.image_gen_key = getDefaultParams(
|
|
||||||
"image-gen-key",
|
|
||||||
chatStore.image_gen_key,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
newChatStore.cost = 0;
|
|
||||||
|
|
||||||
// manage undefined value because of version update
|
|
||||||
newChatStore.toolsString = newChatStore.toolsString || "";
|
|
||||||
|
|
||||||
setChatStore({ ...newChatStore });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="w-full text-center">{t.name}</span>
|
|
||||||
<hr className="mt-2" />
|
|
||||||
<span className="flex justify-between">
|
|
||||||
<button
|
|
||||||
onClick={(event) => {
|
|
||||||
// prevent triggert other event
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
const name = prompt("Give template a name");
|
|
||||||
if (!name) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
t.name = name;
|
|
||||||
setTemplates(structuredClone(templates));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
🖋
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(event) => {
|
|
||||||
// prevent triggert other event
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
if (!confirm("Are you sure to delete this template?")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
templates.splice(index, 1);
|
|
||||||
setTemplates(structuredClone(templates));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
❌
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Templates;
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { ChatStore } from "@/types/chatstore";
|
|
||||||
import { Tr } from "@/translate";
|
|
||||||
|
|
||||||
const VersionHint = (props: { chatStore: ChatStore }) => {
|
|
||||||
const { chatStore } = props;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{chatStore.chatgpt_api_web_version < "v1.3.0" && (
|
|
||||||
<p className="p-2 my-2 text-center dark:text-white">
|
|
||||||
<br />
|
|
||||||
{Tr("Warning: current chatStore version")}:{" "}
|
|
||||||
{chatStore.chatgpt_api_web_version} {"< v1.3.0"}
|
|
||||||
<br />
|
|
||||||
v1.3.0
|
|
||||||
引入与旧版不兼容的消息裁切算法。继续使用旧版可能会导致消息裁切过多或过少(表现为失去上下文或输出不完整)。
|
|
||||||
<br />
|
|
||||||
请在左上角创建新会话:)
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{chatStore.chatgpt_api_web_version < "v1.4.0" && (
|
|
||||||
<p className="p-2 my-2 text-center dark:text-white">
|
|
||||||
<br />
|
|
||||||
{Tr("Warning: current chatStore version")}:{" "}
|
|
||||||
{chatStore.chatgpt_api_web_version} {"< v1.4.0"}
|
|
||||||
<br />
|
|
||||||
v1.4.0 增加了更多参数,继续使用旧版可能因参数确实导致未定义的行为
|
|
||||||
<br />
|
|
||||||
请在左上角创建新会话:)
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{chatStore.chatgpt_api_web_version < "v1.6.0" && (
|
|
||||||
<p className="p-2 my-2 text-center dark:text-white">
|
|
||||||
<br />
|
|
||||||
提示:当前会话版本 {chatStore.chatgpt_api_web_version}
|
|
||||||
{Tr("Warning: current chatStore version")}:{" "}
|
|
||||||
{chatStore.chatgpt_api_web_version} {"< v1.6.0"}
|
|
||||||
。
|
|
||||||
<br />
|
|
||||||
v1.6.0 开始保存会话模板时会将 apiKey 和 apiEndpoint
|
|
||||||
设置为空,继续使用旧版可能在保存读取模板时出现问题
|
|
||||||
<br />
|
|
||||||
请在左上角创建新会话:)
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default VersionHint;
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import { createRef } from "preact";
|
|
||||||
|
|
||||||
import { ChatStore } from "@/types/chatstore";
|
|
||||||
import { StateUpdater, useEffect, useState, Dispatch } from "preact/hooks";
|
|
||||||
|
|
||||||
const WhisperButton = (props: {
|
|
||||||
chatStore: ChatStore;
|
|
||||||
inputMsg: string;
|
|
||||||
setInputMsg: Dispatch<StateUpdater<string>>;
|
|
||||||
}) => {
|
|
||||||
const { chatStore, inputMsg, setInputMsg } = props;
|
|
||||||
const mediaRef = createRef();
|
|
||||||
const [isRecording, setIsRecording] = useState("Mic");
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={`btn disabled:line-through disabled:btn-neutral disabled:text-white m-1 p-1 ${
|
|
||||||
isRecording === "Recording" ? "btn-error" : "btn-success"
|
|
||||||
} ${isRecording !== "Mic" ? "animate-pulse" : ""}`}
|
|
||||||
disabled={isRecording === "Transcribing"}
|
|
||||||
ref={mediaRef}
|
|
||||||
onClick={async () => {
|
|
||||||
if (isRecording === "Recording") {
|
|
||||||
// @ts-ignore
|
|
||||||
window.mediaRecorder.stop();
|
|
||||||
setIsRecording("Transcribing");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// build prompt
|
|
||||||
const prompt = [chatStore.systemMessageContent]
|
|
||||||
.concat(
|
|
||||||
chatStore.history
|
|
||||||
.filter(({ hide }) => !hide)
|
|
||||||
.slice(chatStore.postBeginIndex)
|
|
||||||
.map(({ content }) => {
|
|
||||||
if (typeof content === "string") {
|
|
||||||
return content;
|
|
||||||
} else {
|
|
||||||
return content.map((c) => c?.text).join(" ");
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.concat([inputMsg])
|
|
||||||
.join(" ");
|
|
||||||
console.log({ prompt });
|
|
||||||
|
|
||||||
setIsRecording("Recording");
|
|
||||||
console.log("start recording");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const mediaRecorder = new MediaRecorder(
|
|
||||||
await navigator.mediaDevices.getUserMedia({
|
|
||||||
audio: true,
|
|
||||||
}),
|
|
||||||
{ audioBitsPerSecond: 64 * 1000 },
|
|
||||||
);
|
|
||||||
|
|
||||||
// mount mediaRecorder to ref
|
|
||||||
// @ts-ignore
|
|
||||||
window.mediaRecorder = mediaRecorder;
|
|
||||||
|
|
||||||
mediaRecorder.start();
|
|
||||||
const audioChunks: Blob[] = [];
|
|
||||||
mediaRecorder.addEventListener("dataavailable", (event) => {
|
|
||||||
audioChunks.push(event.data);
|
|
||||||
});
|
|
||||||
mediaRecorder.addEventListener("stop", async () => {
|
|
||||||
// Stop the MediaRecorder
|
|
||||||
mediaRecorder.stop();
|
|
||||||
// Stop the media stream
|
|
||||||
mediaRecorder.stream.getTracks()[0].stop();
|
|
||||||
|
|
||||||
setIsRecording("Transcribing");
|
|
||||||
const audioBlob = new Blob(audioChunks);
|
|
||||||
const audioUrl = URL.createObjectURL(audioBlob);
|
|
||||||
console.log({ audioUrl });
|
|
||||||
const audio = new Audio(audioUrl);
|
|
||||||
// audio.play();
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.readAsDataURL(audioBlob);
|
|
||||||
|
|
||||||
// file-like object with mimetype
|
|
||||||
const blob = new Blob([audioBlob], {
|
|
||||||
type: "application/octet-stream",
|
|
||||||
});
|
|
||||||
|
|
||||||
reader.onloadend = async () => {
|
|
||||||
try {
|
|
||||||
const base64data = reader.result;
|
|
||||||
|
|
||||||
// post to openai whisper api
|
|
||||||
const formData = new FormData();
|
|
||||||
// append file
|
|
||||||
formData.append("file", blob, "audio.ogg");
|
|
||||||
formData.append("model", "whisper-1");
|
|
||||||
formData.append("response_format", "text");
|
|
||||||
formData.append("prompt", prompt);
|
|
||||||
|
|
||||||
const response = await fetch(chatStore.whisper_api, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${
|
|
||||||
chatStore.whisper_key || chatStore.apiKey
|
|
||||||
}`,
|
|
||||||
},
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
const text = await response.text();
|
|
||||||
|
|
||||||
setInputMsg(inputMsg ? inputMsg + " " + text : text);
|
|
||||||
} catch (error) {
|
|
||||||
alert(error);
|
|
||||||
console.log(error);
|
|
||||||
} finally {
|
|
||||||
setIsRecording("Mic");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
alert(error);
|
|
||||||
console.log(error);
|
|
||||||
setIsRecording("Mic");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isRecording}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default WhisperButton;
|
|
||||||
14
src/const.ts
14
src/const.ts
@@ -1,14 +0,0 @@
|
|||||||
export const DefaultAPIEndpoint = "https://api.openai.com/v1/chat/completions";
|
|
||||||
export const CHATGPT_API_WEB_VERSION = "v2.1.0";
|
|
||||||
export const DefaultModel = "gpt-4o-mini";
|
|
||||||
|
|
||||||
export const STORAGE_NAME = "chatgpt-api-web";
|
|
||||||
export const STORAGE_NAME_SELECTED = `${STORAGE_NAME}-selected`;
|
|
||||||
export const STORAGE_NAME_INDEXES = `${STORAGE_NAME}-indexes`;
|
|
||||||
export 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`;
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { useState, useEffect, StateUpdater, Dispatch } from "preact/hooks";
|
|
||||||
import { Tr, langCodeContext, LANG_OPTIONS, tr } from "@/translate";
|
|
||||||
import { ChatStore, ChatStoreMessage } from "@/types/chatstore";
|
|
||||||
import { EditMessageString } from "@/editMessageString";
|
|
||||||
import { EditMessageDetail } from "@/editMessageDetail";
|
|
||||||
|
|
||||||
interface EditMessageProps {
|
|
||||||
chat: ChatStoreMessage;
|
|
||||||
chatStore: ChatStore;
|
|
||||||
setShowEdit: Dispatch<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={() => {
|
|
||||||
const confirm = window.confirm(
|
|
||||||
"Change message type will clear the content, are you sure?",
|
|
||||||
);
|
|
||||||
if (!confirm) return;
|
|
||||||
|
|
||||||
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 "@/types/chatstore";
|
|
||||||
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-center">
|
|
||||||
{mdt.type === "text" ? (
|
|
||||||
<textarea
|
|
||||||
className={"w-full border p-1 rounded"}
|
|
||||||
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>
|
|
||||||
) : (
|
|
||||||
<div className="border p-1 rounded">
|
|
||||||
<img
|
|
||||||
className="max-h-32 max-w-xs cursor-pointer m-2"
|
|
||||||
src={mdt.image_url?.url}
|
|
||||||
onClick={() => {
|
|
||||||
window.open(mdt.image_url?.url, "_blank");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className="bg-blue-300 p-1 rounded m-1"
|
|
||||||
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 m-1"
|
|
||||||
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 m-1"
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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 "@/types/chatstore";
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
18
src/getDefaultParam.ts
Normal file
18
src/getDefaultParam.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
function getDefaultParams(param: string, val: string): string;
|
||||||
|
function getDefaultParams(param: string, val: number): number;
|
||||||
|
function getDefaultParams(param: string, val: boolean): boolean;
|
||||||
|
function getDefaultParams(param: any, val: any) {
|
||||||
|
const queryParameters = new URLSearchParams(window.location.search);
|
||||||
|
const get = queryParameters.get(param);
|
||||||
|
if (typeof val === "string") {
|
||||||
|
return get ?? val;
|
||||||
|
} else if (typeof val === "number") {
|
||||||
|
return parseInt(get ?? `${val}`);
|
||||||
|
} else if (typeof val === "boolean") {
|
||||||
|
if (get === "stream") return true;
|
||||||
|
if (get === "fetch") return false;
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default getDefaultParams;
|
||||||
126
src/global.css
126
src/global.css
@@ -2,12 +2,6 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
#app {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide scrollbar for webkit based browsers */
|
/* Hide scrollbar for webkit based browsers */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -28,124 +22,6 @@ body::-webkit-scrollbar {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-content {
|
p.message-content {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat {
|
|
||||||
padding: 0.39rem;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import { STORAGE_NAME } from "@/const";
|
|
||||||
import { ChatStore } from "@/types/chatstore";
|
|
||||||
import { IDBPDatabase, IDBPTransaction, StoreNames } from "idb";
|
|
||||||
import { upgradeV1 } from "@/indexedDB/v1";
|
|
||||||
import { upgradeV11 } from "./v11";
|
|
||||||
|
|
||||||
export async function upgrade(
|
|
||||||
db: IDBPDatabase<ChatStore>,
|
|
||||||
oldVersion: number,
|
|
||||||
newVersion: number,
|
|
||||||
transaction: IDBPTransaction<
|
|
||||||
ChatStore,
|
|
||||||
StoreNames<ChatStore>[],
|
|
||||||
"versionchange"
|
|
||||||
>,
|
|
||||||
) {
|
|
||||||
if (oldVersion < 1) {
|
|
||||||
upgradeV1(db, oldVersion, newVersion, transaction);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (oldVersion < 11) {
|
|
||||||
upgradeV11(db, oldVersion, newVersion, transaction);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { STORAGE_NAME, STORAGE_NAME_INDEXES } from "@/const";
|
|
||||||
import { ChatStore } from "@/types/chatstore";
|
|
||||||
import { IDBPDatabase, IDBPTransaction, StoreNames } from "idb";
|
|
||||||
|
|
||||||
export async function upgradeV1(
|
|
||||||
db: IDBPDatabase<ChatStore>,
|
|
||||||
oldVersion: number,
|
|
||||||
newVersion: number,
|
|
||||||
transaction: IDBPTransaction<
|
|
||||||
ChatStore,
|
|
||||||
StoreNames<ChatStore>[],
|
|
||||||
"versionchange"
|
|
||||||
>,
|
|
||||||
) {
|
|
||||||
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. 🎉",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { ChatStore } from "@/types/chatstore";
|
|
||||||
import { STORAGE_NAME } from "@/const";
|
|
||||||
import { IDBPDatabase, IDBPTransaction, StoreNames } from "idb";
|
|
||||||
|
|
||||||
export async function upgradeV11(
|
|
||||||
db: IDBPDatabase<ChatStore>,
|
|
||||||
oldVersion: number,
|
|
||||||
newVersion: number,
|
|
||||||
transaction: IDBPTransaction<
|
|
||||||
ChatStore,
|
|
||||||
StoreNames<ChatStore>[],
|
|
||||||
"versionchange"
|
|
||||||
>,
|
|
||||||
) {
|
|
||||||
if (oldVersion < 11 && oldVersion >= 1) {
|
|
||||||
alert("Start upgrading storage, just a sec... (Click OK to continue)");
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
transaction
|
|
||||||
.objectStore(STORAGE_NAME)
|
|
||||||
.indexNames.contains("contents_for_index")
|
|
||||||
) {
|
|
||||||
transaction.objectStore(STORAGE_NAME).deleteIndex("contents_for_index");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { ChatStore, TemplateAPI } from "@/types/chatstore";
|
|
||||||
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 base-200 my-3 text-left">
|
|
||||||
<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-info"
|
|
||||||
: "bg-base-300"
|
|
||||||
} 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>
|
|
||||||
<span className="flex justify-between gap-x-2">
|
|
||||||
<button
|
|
||||||
className="link"
|
|
||||||
onClick={() => {
|
|
||||||
const name = prompt(`Give **${label}** template a name`);
|
|
||||||
if (!name) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
t.name = name;
|
|
||||||
setTmps(structuredClone(tmps));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="link"
|
|
||||||
onClick={() => {
|
|
||||||
if (
|
|
||||||
!confirm(
|
|
||||||
`Are you sure to delete this **${label}** template?`,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
tmps.splice(index, 1);
|
|
||||||
setTmps(structuredClone(tmps));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import { ChatStore, TemplateTools } from "@/types/chatstore";
|
|
||||||
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-info"
|
|
||||||
: "bg-base-300"
|
|
||||||
} 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>
|
|
||||||
<span className="flex justify-between gap-x-2">
|
|
||||||
<button
|
|
||||||
className="link"
|
|
||||||
onClick={() => {
|
|
||||||
const name = prompt(`Give **tools** template a name`);
|
|
||||||
if (!name) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
t.name = name;
|
|
||||||
setTemplateTools(structuredClone(templateTools));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="link"
|
|
||||||
onClick={() => {
|
|
||||||
if (
|
|
||||||
!confirm(`Are you sure to delete this **tools** template?`)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
templateTools.splice(index, 1);
|
|
||||||
setTemplateTools(structuredClone(templateTools));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</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 = `rgba(${red}, ${green}, 0, 0.5)`;
|
|
||||||
|
|
||||||
return color;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default logprobToColor;
|
|
||||||
56
src/main.tsx
56
src/main.tsx
@@ -1,54 +1,4 @@
|
|||||||
import { themeChange } from "theme-change";
|
import { render } from 'preact'
|
||||||
import { render } from "preact";
|
import { App } from './app'
|
||||||
import { useState, useEffect } from "preact/hooks";
|
|
||||||
import { App } from "@/pages/App";
|
|
||||||
import { Tr, langCodeContext, LANG_OPTIONS } from "@/translate";
|
|
||||||
|
|
||||||
function Base() {
|
render(<App />, document.getElementById('app') as HTMLElement)
|
||||||
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(() => {
|
|
||||||
themeChange(false);
|
|
||||||
// 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);
|
|
||||||
|
|||||||
281
src/message.tsx
281
src/message.tsx
@@ -1,261 +1,72 @@
|
|||||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
|
||||||
import Markdown from "preact-markdown";
|
import Markdown from "preact-markdown";
|
||||||
import { useState, useEffect, StateUpdater } from "preact/hooks";
|
import { ChatStore } from "./app";
|
||||||
|
|
||||||
import { Tr, langCodeContext, LANG_OPTIONS } from "@/translate";
|
const Pre: React.FC<any> = ({ children, props }) => (
|
||||||
import { ChatStore, ChatStoreMessage } from "@/types/chatstore";
|
<div class="rounded p-1 bg-black text-white" {...props}>{children}</div>
|
||||||
import { calculate_token_length, getMessageText } from "@/chatgpt";
|
);
|
||||||
import TTSButton, { TTSPlay } from "@/tts";
|
const Code: React.FC<any> = ({ children }) => <code className="overflow-scroll break-keep">{children}</code>;
|
||||||
import { MessageHide } from "@/messageHide";
|
|
||||||
import { MessageDetail } from "@/messageDetail";
|
|
||||||
import { MessageToolCall } from "@/messageToolCall";
|
|
||||||
import { MessageToolResp } from "@/messageToolResp";
|
|
||||||
import { EditMessage } from "@/editMessage";
|
|
||||||
import logprobToColor from "@/logprob";
|
|
||||||
|
|
||||||
export const isVailedJSON = (str: string): boolean => {
|
|
||||||
try {
|
|
||||||
JSON.parse(str);
|
|
||||||
} catch (e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
messageIndex: number;
|
messageIndex: number;
|
||||||
chatStore: ChatStore;
|
chatStore: ChatStore;
|
||||||
setChatStore: (cs: ChatStore) => void;
|
setChatStore: (cs: ChatStore) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Message(props: Props) {
|
export default function Message(props: Props) {
|
||||||
const { chatStore, messageIndex, setChatStore } = props;
|
const { chatStore, messageIndex, setChatStore } = props;
|
||||||
const chat = chatStore.history[messageIndex];
|
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 = () => (
|
const DeleteIcon = () => (
|
||||||
<button
|
<button
|
||||||
|
className={`absolute bottom-0 ${
|
||||||
|
chat.role === "user" ? "left-0" : "right-0"
|
||||||
|
}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
chatStore.history[messageIndex].hide =
|
if (
|
||||||
!chatStore.history[messageIndex].hide;
|
confirm(
|
||||||
|
`Are you sure to delete this message?\n${chat.content.slice(
|
||||||
//chatStore.totalTokens =
|
0,
|
||||||
chatStore.totalTokens = 0;
|
39
|
||||||
for (const i of chatStore.history
|
)}...`
|
||||||
.filter(({ hide }) => !hide)
|
)
|
||||||
.slice(chatStore.postBeginIndex)
|
) {
|
||||||
.map(({ token }) => token)) {
|
chatStore.history.splice(messageIndex, 1);
|
||||||
chatStore.totalTokens += i;
|
chatStore.postBeginIndex = Math.max(chatStore.postBeginIndex - 1, 0);
|
||||||
|
setChatStore({ ...chatStore });
|
||||||
}
|
}
|
||||||
setChatStore({ ...chatStore });
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Delete
|
🗑️
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
const CopiedHint = () => (
|
const codeMatches = chat.content.match(/(```([\s\S]*?)```$)/);
|
||||||
<div role="alert" className="alert">
|
const AnyMarkdown = Markdown as any;
|
||||||
<svg
|
console.log("codeMatches", codeMatches);
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
if (codeMatches) console.log("matches", codeMatches[0]);
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
className="stroke-info h-6 w-6 shrink-0"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
<span>{Tr("Message copied to clipboard!")}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const copyToClipboard = (text: string) => {
|
|
||||||
navigator.clipboard.writeText(text);
|
|
||||||
setShowCopiedHint(true);
|
|
||||||
setTimeout(() => setShowCopiedHint(false), 1000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const CopyIcon = ({ textToCopy }: { textToCopy: string }) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
copyToClipboard(textToCopy);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Copy
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div
|
||||||
{chatStore.postBeginIndex !== 0 &&
|
className={`flex ${
|
||||||
!chatStore.history[messageIndex].hide &&
|
chat.role === "assistant" ? "justify-start" : "justify-end"
|
||||||
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" />
|
|
||||||
<span className="absolute px-3 rounded p-1">
|
|
||||||
Above messages are "forgotten"
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
<div
|
||||||
className={`flex ${
|
className={`relative w-fit p-2 rounded my-2 ${
|
||||||
chat.role === "assistant" ? "justify-start" : "justify-end"
|
chat.role === "assistant"
|
||||||
|
? "bg-white dark:bg-gray-700 dark:text-white"
|
||||||
|
: "bg-green-400"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className={`w-full`}>
|
<p className="message-content">
|
||||||
<div
|
<AnyMarkdown
|
||||||
className={`chat min-w-16 p-2 my-2 ${
|
markdown={chat.content}
|
||||||
chat.role === "assistant" ? "chat-start" : "chat-end"
|
markupOpts={{
|
||||||
} ${chat.hide ? "opacity-50" : ""}`}
|
components: {
|
||||||
>
|
code: Code,
|
||||||
<div
|
pre: Pre,
|
||||||
className={`chat-bubble max-w-full ${
|
},
|
||||||
chat.role === "assistant"
|
}}
|
||||||
? renderColor
|
/>
|
||||||
? "chat-bubble-neutral"
|
</p>
|
||||||
: "chat-bubble-secondary"
|
<DeleteIcon />
|
||||||
: "chat-bubble-primary"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{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={{
|
|
||||||
backgroundColor: logprobToColor(c.logprob),
|
|
||||||
display: "inline",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{c.token}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
: getMessageText(chat))
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="chat-footer opacity-50 flex gap-x-2">
|
|
||||||
<DeleteIcon />
|
|
||||||
<button onClick={() => setShowEdit(true)}>Edit</button>
|
|
||||||
<CopyIcon textToCopy={getMessageText(chat)} />
|
|
||||||
{chatStore.tts_api && chatStore.tts_key && (
|
|
||||||
<TTSButton
|
|
||||||
chatStore={chatStore}
|
|
||||||
chat={chat}
|
|
||||||
setChatStore={setChatStore}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<TTSPlay chat={chat} />
|
|
||||||
{chat.response_model_name && (
|
|
||||||
<>
|
|
||||||
<span className="opacity-50">{chat.response_model_name}</span>
|
|
||||||
<hr />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{showEdit && (
|
|
||||||
<EditMessage
|
|
||||||
setShowEdit={setShowEdit}
|
|
||||||
chat={chat}
|
|
||||||
chatStore={chatStore}
|
|
||||||
setChatStore={setChatStore}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{showCopiedHint && <CopiedHint />}
|
|
||||||
{chatStore.develop_mode && (
|
|
||||||
<div
|
|
||||||
className={`gap-1 chat-end flex ${
|
|
||||||
chat.role === "assistant" ? "justify-start" : "justify-end"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="">token</span>
|
|
||||||
<input
|
|
||||||
value={chat.token}
|
|
||||||
className="input input-bordered input-xs w-16"
|
|
||||||
onChange={(event: any) => {
|
|
||||||
chat.token = parseInt(event.target.value);
|
|
||||||
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 });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<XMarkIcon className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<span
|
|
||||||
onClick={(event: any) => {
|
|
||||||
chat.example = !chat.example;
|
|
||||||
setChatStore({ ...chatStore });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<label className="">{Tr("example")}</label>
|
|
||||||
<input type="checkbox" checked={chat.example} />
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
onClick={(event: any) => setRenderWorkdown(!renderMarkdown)}
|
|
||||||
>
|
|
||||||
<label className="">{Tr("render")}</label>
|
|
||||||
<input type="checkbox" checked={renderMarkdown} />
|
|
||||||
</span>
|
|
||||||
<span onClick={(event: any) => setRenderColor(!renderColor)}>
|
|
||||||
<label className="">{Tr("color")}</label>
|
|
||||||
<input type="checkbox" checked={renderColor} />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import { ChatStoreMessage } from "@/types/chatstore";
|
|
||||||
|
|
||||||
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) + " ..."
|
|
||||||
) : renderMarkdown ? (
|
|
||||||
// @ts-ignore
|
|
||||||
<Markdown markdown={mdt.text} />
|
|
||||||
) : (
|
|
||||||
mdt.text
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<img
|
|
||||||
className="my-2 rounded-md max-w-64 max-h-64"
|
|
||||||
src={mdt.image_url?.url}
|
|
||||||
onClick={() => {
|
|
||||||
window.open(mdt.image_url?.url, "_blank");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { ChatStoreMessage } from "@/types/chatstore";
|
|
||||||
import { getMessageText } from "@/chatgpt";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
chat: ChatStoreMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MessageHide({ chat }: Props) {
|
|
||||||
return <div>{getMessageText(chat).split("\n")[0].slice(0, 18)} ...</div>;
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { ChatStoreMessage } from "@/types/chatstore";
|
|
||||||
|
|
||||||
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 "@/types/chatstore";
|
|
||||||
|
|
||||||
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,93 +0,0 @@
|
|||||||
import { useState } from "preact/hooks";
|
|
||||||
import { Dispatch, StateUpdater } from "preact/hooks";
|
|
||||||
|
|
||||||
import { Tr } from "@/translate";
|
|
||||||
import { calculate_token_length } from "@/chatgpt";
|
|
||||||
import { ChatStore } from "@/types/chatstore";
|
|
||||||
|
|
||||||
const AddToolMsg = (props: {
|
|
||||||
setShowAddToolMsg: Dispatch<StateUpdater<boolean>>;
|
|
||||||
chatStore: ChatStore;
|
|
||||||
setChatStore: (cs: ChatStore) => void;
|
|
||||||
}) => {
|
|
||||||
const { setShowAddToolMsg, chatStore, setChatStore } = props;
|
|
||||||
|
|
||||||
const [newToolCallID, setNewToolCallID] = useState("");
|
|
||||||
const [newToolContent, setNewToolContent] = useState("");
|
|
||||||
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={() => {
|
|
||||||
setShowAddToolMsg(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="bg-white rounded p-2 z-20 flex flex-col"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h2>Add Tool Message</h2>
|
|
||||||
<hr className="my-2" />
|
|
||||||
<span>
|
|
||||||
<label>tool_call_id</label>
|
|
||||||
<input
|
|
||||||
className="rounded m-1 p-1 border-2 border-gray-400"
|
|
||||||
type="text"
|
|
||||||
value={newToolCallID}
|
|
||||||
onChange={(event: any) => setNewToolCallID(event.target.value)}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<label>Content</label>
|
|
||||||
<textarea
|
|
||||||
className="rounded m-1 p-1 border-2 border-gray-400"
|
|
||||||
rows={5}
|
|
||||||
value={newToolContent}
|
|
||||||
onChange={(event: any) => setNewToolContent(event.target.value)}
|
|
||||||
></textarea>
|
|
||||||
</span>
|
|
||||||
<span className={`flex justify-between p-2`}>
|
|
||||||
<button
|
|
||||||
className="btn btn-info m-1 p-1"
|
|
||||||
onClick={() => setShowAddToolMsg(false)}
|
|
||||||
>
|
|
||||||
{Tr("Cancle")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="rounded m-1 p-1 border-2 bg-cyan-400 hover:bg-cyan-600"
|
|
||||||
onClick={() => {
|
|
||||||
if (!newToolCallID.trim()) {
|
|
||||||
alert("tool_call_id is empty");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!newToolContent.trim()) {
|
|
||||||
alert("content is empty");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
chatStore.history.push({
|
|
||||||
role: "tool",
|
|
||||||
tool_call_id: newToolCallID.trim(),
|
|
||||||
content: newToolContent.trim(),
|
|
||||||
token: calculate_token_length(newToolContent),
|
|
||||||
hide: false,
|
|
||||||
example: false,
|
|
||||||
audio: null,
|
|
||||||
logprobs: null,
|
|
||||||
response_model_name: null,
|
|
||||||
});
|
|
||||||
setChatStore({ ...chatStore });
|
|
||||||
setNewToolCallID("");
|
|
||||||
setNewToolContent("");
|
|
||||||
setShowAddToolMsg(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Tr("Add")}
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AddToolMsg;
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
import { openDB } from "idb";
|
|
||||||
import { useEffect, useState } from "preact/hooks";
|
|
||||||
import "@/global.css";
|
|
||||||
|
|
||||||
import { calculate_token_length } from "@/chatgpt";
|
|
||||||
import { getDefaultParams } from "@/utils/getDefaultParam";
|
|
||||||
import ChatBOX from "@/pages/Chatbox";
|
|
||||||
import { DefaultModel } from "@/const";
|
|
||||||
import { Tr, langCodeContext, LANG_OPTIONS } from "@/translate";
|
|
||||||
import { ChatStore } from "@/types/chatstore";
|
|
||||||
import { newChatStore } from "@/types/newChatstore";
|
|
||||||
import { STORAGE_NAME, STORAGE_NAME_SELECTED } from "@/const";
|
|
||||||
import { upgrade } from "@/indexedDB/upgrade";
|
|
||||||
|
|
||||||
export function App() {
|
|
||||||
// init selected index
|
|
||||||
const [selectedChatIndex, setSelectedChatIndex] = useState(
|
|
||||||
parseInt(localStorage.getItem(STORAGE_NAME_SELECTED) ?? "1"),
|
|
||||||
);
|
|
||||||
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, 11, {
|
|
||||||
upgrade,
|
|
||||||
});
|
|
||||||
|
|
||||||
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.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 [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;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("saved chat", selectedChatIndex, chatStore);
|
|
||||||
(await db).put(STORAGE_NAME, chatStore, selectedChatIndex);
|
|
||||||
|
|
||||||
// update total tokens
|
|
||||||
chatStore.totalTokens = calculate_token_length(
|
|
||||||
chatStore.systemMessageContent,
|
|
||||||
);
|
|
||||||
for (const msg of chatStore.history
|
|
||||||
.filter(({ hide }) => !hide)
|
|
||||||
.slice(chatStore.postBeginIndex)) {
|
|
||||||
chatStore.totalTokens += msg.token;
|
|
||||||
}
|
|
||||||
|
|
||||||
_setChatStore(chatStore);
|
|
||||||
};
|
|
||||||
useEffect(() => {
|
|
||||||
const run = async () => {
|
|
||||||
_setChatStore(await getChatStoreByIndex(selectedChatIndex));
|
|
||||||
};
|
|
||||||
run();
|
|
||||||
}, [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));
|
|
||||||
setSelectedChatIndex(newKey as number);
|
|
||||||
setAllChatStoreIndexes(await (await db).getAllKeys(STORAGE_NAME));
|
|
||||||
};
|
|
||||||
const handleNewChatStore = async () => {
|
|
||||||
return handleNewChatStoreWithOldOne(chatStore);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDEL = async () => {
|
|
||||||
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);
|
|
||||||
|
|
||||||
if (newAllChatStoreIndexes.length === 0) {
|
|
||||||
handleNewChatStore();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// find nex selected chat index
|
|
||||||
const next = newAllChatStoreIndexes[newAllChatStoreIndexes.length - 1];
|
|
||||||
console.log("next is", next);
|
|
||||||
setSelectedChatIndex(next as number);
|
|
||||||
setAllChatStoreIndexes(newAllChatStoreIndexes);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCLS = 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();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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">
|
|
||||||
<div className="flex flex-col h-full p-2 bg-primary">
|
|
||||||
<div className="grow overflow-scroll">
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-info p-1 my-1 w-full"
|
|
||||||
onClick={handleNewChatStore}
|
|
||||||
>
|
|
||||||
{Tr("NEW")}
|
|
||||||
</button>
|
|
||||||
<ul className="pt-2">
|
|
||||||
{(allChatStoreIndexes as number[])
|
|
||||||
.slice()
|
|
||||||
.reverse()
|
|
||||||
.map((i) => {
|
|
||||||
// reverse
|
|
||||||
return (
|
|
||||||
<li>
|
|
||||||
<button
|
|
||||||
className={`w-full my-1 p-1 btn btn-sm ${
|
|
||||||
i === selectedChatIndex ? "btn-accent" : "btn-secondary"
|
|
||||||
}`}
|
|
||||||
onClick={() => setSelectedChatIndex(i)}
|
|
||||||
>
|
|
||||||
{i}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
className="btn btn-warning btn-sm p-1 my-1 w-full"
|
|
||||||
onClick={async () => handleDEL()}
|
|
||||||
>
|
|
||||||
{Tr("DEL")}
|
|
||||||
</button>
|
|
||||||
{chatStore.develop_mode && (
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-warning p-1 my-1 w-full"
|
|
||||||
onClick={async () => handleCLS()}
|
|
||||||
>
|
|
||||||
{Tr("CLS")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ChatBOX
|
|
||||||
db={db}
|
|
||||||
chatStore={chatStore}
|
|
||||||
setChatStore={setChatStore}
|
|
||||||
selectedChatIndex={selectedChatIndex}
|
|
||||||
setSelectedChatIndex={setSelectedChatIndex}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,795 +0,0 @@
|
|||||||
import { IDBPDatabase } from "idb";
|
|
||||||
import { createRef } from "preact";
|
|
||||||
import { StateUpdater, useEffect, useState, Dispatch } from "preact/hooks";
|
|
||||||
import { Tr, langCodeContext, LANG_OPTIONS } from "@/translate";
|
|
||||||
import {
|
|
||||||
STORAGE_NAME_TEMPLATE,
|
|
||||||
STORAGE_NAME_TEMPLATE_API,
|
|
||||||
STORAGE_NAME_TEMPLATE_API_IMAGE_GEN,
|
|
||||||
STORAGE_NAME_TEMPLATE_API_TTS,
|
|
||||||
STORAGE_NAME_TEMPLATE_API_WHISPER,
|
|
||||||
STORAGE_NAME_TEMPLATE_TOOLS,
|
|
||||||
} from "@/const";
|
|
||||||
import { addTotalCost, getTotalCost } from "@/utils/totalCost";
|
|
||||||
import ChatGPT, {
|
|
||||||
calculate_token_length,
|
|
||||||
FetchResponse,
|
|
||||||
Message as MessageType,
|
|
||||||
MessageDetail,
|
|
||||||
ToolCall,
|
|
||||||
Logprobs,
|
|
||||||
} from "@/chatgpt";
|
|
||||||
import {
|
|
||||||
ChatStore,
|
|
||||||
ChatStoreMessage,
|
|
||||||
TemplateChatStore,
|
|
||||||
TemplateAPI,
|
|
||||||
TemplateTools,
|
|
||||||
} from "../types/chatstore";
|
|
||||||
import Message from "@/message";
|
|
||||||
import { models } from "@/types/models";
|
|
||||||
import Settings from "@/settings";
|
|
||||||
import { AddImage } from "@/addImage";
|
|
||||||
import { ListAPIs } from "@/listAPIs";
|
|
||||||
import { ListToolsTempaltes } from "@/listToolsTemplates";
|
|
||||||
import { autoHeight } from "@/textarea";
|
|
||||||
import Search from "@/search";
|
|
||||||
import Templates from "@/components/Templates";
|
|
||||||
import VersionHint from "@/components/VersionHint";
|
|
||||||
import StatusBar from "@/components/StatusBar";
|
|
||||||
import WhisperButton from "@/components/WhisperButton";
|
|
||||||
import AddToolMsg from "./AddToolMsg";
|
|
||||||
|
|
||||||
export default function ChatBOX(props: {
|
|
||||||
db: Promise<IDBPDatabase<ChatStore>>;
|
|
||||||
chatStore: ChatStore;
|
|
||||||
setChatStore: (cs: ChatStore) => void;
|
|
||||||
selectedChatIndex: number;
|
|
||||||
setSelectedChatIndex: Dispatch<StateUpdater<number>>;
|
|
||||||
}) {
|
|
||||||
const { chatStore, setChatStore } = props;
|
|
||||||
// prevent error
|
|
||||||
if (chatStore === undefined) return <div></div>;
|
|
||||||
const [inputMsg, setInputMsg] = useState("");
|
|
||||||
const [images, setImages] = useState<MessageDetail[]>([]);
|
|
||||||
const [showAddImage, setShowAddImage] = useState(false);
|
|
||||||
const [showGenerating, setShowGenerating] = useState(false);
|
|
||||||
const [generatingMessage, setGeneratingMessage] = useState("");
|
|
||||||
const [showRetry, setShowRetry] = useState(false);
|
|
||||||
const [showAddToolMsg, setShowAddToolMsg] = useState(false);
|
|
||||||
const [showSearch, setShowSearch] = useState(false);
|
|
||||||
let default_follow = localStorage.getItem("follow");
|
|
||||||
if (default_follow === null) {
|
|
||||||
default_follow = "true";
|
|
||||||
}
|
|
||||||
const [follow, _setFollow] = useState(default_follow === "true");
|
|
||||||
|
|
||||||
const setFollow = (follow: boolean) => {
|
|
||||||
console.log("set follow", follow);
|
|
||||||
localStorage.setItem("follow", follow.toString());
|
|
||||||
_setFollow(follow);
|
|
||||||
};
|
|
||||||
|
|
||||||
const messagesEndRef = createRef();
|
|
||||||
useEffect(() => {
|
|
||||||
if (follow) {
|
|
||||||
messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
|
|
||||||
}
|
|
||||||
}, [showRetry, showGenerating, generatingMessage]);
|
|
||||||
|
|
||||||
const client = new ChatGPT(chatStore.apiKey);
|
|
||||||
|
|
||||||
const _completeWithStreamMode = async (response: Response) => {
|
|
||||||
let responseTokenCount = 0;
|
|
||||||
const allChunkMessage: string[] = [];
|
|
||||||
const allChunkTool: ToolCall[] = [];
|
|
||||||
setShowGenerating(true);
|
|
||||||
const logprobs: Logprobs = {
|
|
||||||
content: [],
|
|
||||||
};
|
|
||||||
let response_model_name: string | null = null;
|
|
||||||
for await (const i of client.processStreamResponse(response)) {
|
|
||||||
response_model_name = i.model;
|
|
||||||
responseTokenCount += 1;
|
|
||||||
|
|
||||||
const c = i.choices[0];
|
|
||||||
|
|
||||||
// skip if choice is empty (e.g. azure)
|
|
||||||
if (!c) continue;
|
|
||||||
|
|
||||||
const logprob = c?.logprobs?.content[0]?.logprob;
|
|
||||||
if (logprob !== undefined) {
|
|
||||||
logprobs.content.push({
|
|
||||||
token: c?.delta?.content ?? "",
|
|
||||||
logprob,
|
|
||||||
});
|
|
||||||
console.log(c?.delta?.content, logprob);
|
|
||||||
}
|
|
||||||
|
|
||||||
allChunkMessage.push(c?.delta?.content ?? "");
|
|
||||||
const tool_calls = c?.delta?.tool_calls;
|
|
||||||
if (tool_calls) {
|
|
||||||
for (const tool_call of tool_calls) {
|
|
||||||
// init
|
|
||||||
if (tool_call.id) {
|
|
||||||
allChunkTool.push({
|
|
||||||
id: tool_call.id,
|
|
||||||
type: tool_call.type,
|
|
||||||
index: tool_call.index,
|
|
||||||
function: {
|
|
||||||
name: tool_call.function.name,
|
|
||||||
arguments: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// update tool call arguments
|
|
||||||
const tool = allChunkTool.find(
|
|
||||||
(tool) => tool.index === tool_call.index,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!tool) {
|
|
||||||
console.log("tool (by index) not found", tool_call.index);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
tool.function.arguments += tool_call.function.arguments;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setGeneratingMessage(
|
|
||||||
allChunkMessage.join("") +
|
|
||||||
allChunkTool.map((tool) => {
|
|
||||||
return `Tool Call ID: ${tool.id}\nType: ${tool.type}\nFunction: ${tool.function.name}\nArguments: ${tool.function.arguments}`;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
setShowGenerating(false);
|
|
||||||
const content = allChunkMessage.join("");
|
|
||||||
|
|
||||||
// estimate cost
|
|
||||||
let cost = 0;
|
|
||||||
if (response_model_name) {
|
|
||||||
cost +=
|
|
||||||
responseTokenCount *
|
|
||||||
(models[response_model_name]?.price?.completion ?? 0);
|
|
||||||
let sum = 0;
|
|
||||||
for (const msg of chatStore.history
|
|
||||||
.filter(({ hide }) => !hide)
|
|
||||||
.slice(chatStore.postBeginIndex)) {
|
|
||||||
sum += msg.token;
|
|
||||||
}
|
|
||||||
cost += sum * (models[response_model_name]?.price?.prompt ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("cost", cost);
|
|
||||||
chatStore.cost += cost;
|
|
||||||
addTotalCost(cost);
|
|
||||||
|
|
||||||
console.log("save logprobs", logprobs);
|
|
||||||
const newMsg: ChatStoreMessage = {
|
|
||||||
role: "assistant",
|
|
||||||
content,
|
|
||||||
hide: false,
|
|
||||||
token: responseTokenCount,
|
|
||||||
example: false,
|
|
||||||
audio: null,
|
|
||||||
logprobs,
|
|
||||||
response_model_name,
|
|
||||||
};
|
|
||||||
if (allChunkTool.length > 0) newMsg.tool_calls = allChunkTool;
|
|
||||||
|
|
||||||
chatStore.history.push(newMsg);
|
|
||||||
// manually copy status from client to chatStore
|
|
||||||
chatStore.maxTokens = client.max_tokens;
|
|
||||||
chatStore.tokenMargin = client.tokens_margin;
|
|
||||||
setChatStore({ ...chatStore });
|
|
||||||
setGeneratingMessage("");
|
|
||||||
setShowGenerating(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const _completeWithFetchMode = async (response: Response) => {
|
|
||||||
const data = (await response.json()) as FetchResponse;
|
|
||||||
if (data.model) {
|
|
||||||
let cost = 0;
|
|
||||||
cost +=
|
|
||||||
(data.usage.prompt_tokens ?? 0) *
|
|
||||||
(models[data.model]?.price?.prompt ?? 0);
|
|
||||||
cost +=
|
|
||||||
(data.usage.completion_tokens ?? 0) *
|
|
||||||
(models[data.model]?.price?.completion ?? 0);
|
|
||||||
chatStore.cost += cost;
|
|
||||||
addTotalCost(cost);
|
|
||||||
}
|
|
||||||
const msg = client.processFetchResponse(data);
|
|
||||||
|
|
||||||
// estimate user's input message token
|
|
||||||
let aboveToken = 0;
|
|
||||||
for (const msg of chatStore.history
|
|
||||||
.filter(({ hide }) => !hide)
|
|
||||||
.slice(chatStore.postBeginIndex, -1)) {
|
|
||||||
aboveToken += msg.token;
|
|
||||||
}
|
|
||||||
if (data.usage.prompt_tokens) {
|
|
||||||
const userMessageToken = data.usage.prompt_tokens - aboveToken;
|
|
||||||
console.log("set user message token");
|
|
||||||
if (chatStore.history.filter((msg) => !msg.hide).length > 0) {
|
|
||||||
chatStore.history.filter((msg) => !msg.hide).slice(-1)[0].token =
|
|
||||||
userMessageToken;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
chatStore.history.push({
|
|
||||||
role: "assistant",
|
|
||||||
content: msg.content,
|
|
||||||
tool_calls: msg.tool_calls,
|
|
||||||
hide: false,
|
|
||||||
token:
|
|
||||||
data.usage.completion_tokens ?? calculate_token_length(msg.content),
|
|
||||||
example: false,
|
|
||||||
audio: null,
|
|
||||||
logprobs: data.choices[0]?.logprobs,
|
|
||||||
response_model_name: data.model,
|
|
||||||
});
|
|
||||||
setShowGenerating(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// wrap the actuall complete api
|
|
||||||
const complete = async () => {
|
|
||||||
// manually copy status from chatStore to client
|
|
||||||
client.apiEndpoint = chatStore.apiEndpoint;
|
|
||||||
client.sysMessageContent = chatStore.systemMessageContent;
|
|
||||||
client.toolsString = chatStore.toolsString;
|
|
||||||
client.tokens_margin = chatStore.tokenMargin;
|
|
||||||
client.temperature = chatStore.temperature;
|
|
||||||
client.enable_temperature = chatStore.temperature_enabled;
|
|
||||||
client.top_p = chatStore.top_p;
|
|
||||||
client.enable_top_p = chatStore.top_p_enabled;
|
|
||||||
client.frequency_penalty = chatStore.frequency_penalty;
|
|
||||||
client.presence_penalty = chatStore.presence_penalty;
|
|
||||||
client.json_mode = chatStore.json_mode;
|
|
||||||
client.messages = chatStore.history
|
|
||||||
// only copy non hidden message
|
|
||||||
.filter(({ hide }) => !hide)
|
|
||||||
.slice(chatStore.postBeginIndex)
|
|
||||||
// only copy content and role attribute to client for posting
|
|
||||||
.map(({ content, role, example, tool_call_id, tool_calls }) => {
|
|
||||||
const ret: MessageType = {
|
|
||||||
content,
|
|
||||||
role,
|
|
||||||
tool_calls,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (example) {
|
|
||||||
ret.name =
|
|
||||||
ret.role === "assistant" ? "example_assistant" : "example_user";
|
|
||||||
ret.role = "system";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tool_call_id) ret.tool_call_id = tool_call_id;
|
|
||||||
|
|
||||||
return ret;
|
|
||||||
});
|
|
||||||
client.model = chatStore.model;
|
|
||||||
client.max_tokens = chatStore.maxTokens;
|
|
||||||
client.max_gen_tokens = chatStore.maxGenTokens;
|
|
||||||
client.enable_max_gen_tokens = chatStore.maxGenTokens_enabled;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setShowGenerating(true);
|
|
||||||
const response = await client._fetch(
|
|
||||||
chatStore.streamMode,
|
|
||||||
chatStore.logprobs,
|
|
||||||
);
|
|
||||||
const contentType = response.headers.get("content-type");
|
|
||||||
if (contentType?.startsWith("text/event-stream")) {
|
|
||||||
await _completeWithStreamMode(response);
|
|
||||||
} else if (contentType?.startsWith("application/json")) {
|
|
||||||
await _completeWithFetchMode(response);
|
|
||||||
} else {
|
|
||||||
throw `unknown response content type ${contentType}`;
|
|
||||||
}
|
|
||||||
// manually copy status from client to chatStore
|
|
||||||
chatStore.maxTokens = client.max_tokens;
|
|
||||||
chatStore.tokenMargin = client.tokens_margin;
|
|
||||||
chatStore.totalTokens = client.total_tokens;
|
|
||||||
|
|
||||||
console.log("postBeginIndex", chatStore.postBeginIndex);
|
|
||||||
setShowRetry(false);
|
|
||||||
setChatStore({ ...chatStore });
|
|
||||||
} catch (error) {
|
|
||||||
setShowRetry(true);
|
|
||||||
alert(error);
|
|
||||||
} finally {
|
|
||||||
setShowGenerating(false);
|
|
||||||
props.setSelectedChatIndex(props.selectedChatIndex);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// when user click the "send" button or ctrl+Enter in the textarea
|
|
||||||
const send = async (msg = "", call_complete = true) => {
|
|
||||||
const inputMsg = msg.trim();
|
|
||||||
if (!inputMsg && images.length === 0) {
|
|
||||||
console.log("empty message");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let content: string | MessageDetail[] = inputMsg;
|
|
||||||
if (images.length > 0) {
|
|
||||||
content = images;
|
|
||||||
}
|
|
||||||
if (images.length > 0 && inputMsg.trim()) {
|
|
||||||
content = [{ type: "text", text: inputMsg }, ...images];
|
|
||||||
}
|
|
||||||
chatStore.history.push({
|
|
||||||
role: "user",
|
|
||||||
content,
|
|
||||||
hide: false,
|
|
||||||
token: calculate_token_length(inputMsg.trim()),
|
|
||||||
example: false,
|
|
||||||
audio: null,
|
|
||||||
logprobs: null,
|
|
||||||
response_model_name: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// manually calculate token length
|
|
||||||
chatStore.totalTokens +=
|
|
||||||
calculate_token_length(inputMsg.trim()) + calculate_token_length(images);
|
|
||||||
client.total_tokens = chatStore.totalTokens;
|
|
||||||
|
|
||||||
setChatStore({ ...chatStore });
|
|
||||||
setInputMsg("");
|
|
||||||
setImages([]);
|
|
||||||
if (call_complete) {
|
|
||||||
await complete();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
|
||||||
|
|
||||||
const [templates, _setTemplates] = useState(
|
|
||||||
JSON.parse(
|
|
||||||
localStorage.getItem(STORAGE_NAME_TEMPLATE) || "[]",
|
|
||||||
) as TemplateChatStore[],
|
|
||||||
);
|
|
||||||
const [templateAPIs, _setTemplateAPIs] = useState(
|
|
||||||
JSON.parse(
|
|
||||||
localStorage.getItem(STORAGE_NAME_TEMPLATE_API) || "[]",
|
|
||||||
) as TemplateAPI[],
|
|
||||||
);
|
|
||||||
const [templateAPIsWhisper, _setTemplateAPIsWhisper] = useState(
|
|
||||||
JSON.parse(
|
|
||||||
localStorage.getItem(STORAGE_NAME_TEMPLATE_API_WHISPER) || "[]",
|
|
||||||
) as TemplateAPI[],
|
|
||||||
);
|
|
||||||
const [templateAPIsTTS, _setTemplateAPIsTTS] = useState(
|
|
||||||
JSON.parse(
|
|
||||||
localStorage.getItem(STORAGE_NAME_TEMPLATE_API_TTS) || "[]",
|
|
||||||
) as TemplateAPI[],
|
|
||||||
);
|
|
||||||
const [templateAPIsImageGen, _setTemplateAPIsImageGen] = useState(
|
|
||||||
JSON.parse(
|
|
||||||
localStorage.getItem(STORAGE_NAME_TEMPLATE_API_IMAGE_GEN) || "[]",
|
|
||||||
) as TemplateAPI[],
|
|
||||||
);
|
|
||||||
const [toolsTemplates, _setToolsTemplates] = useState(
|
|
||||||
JSON.parse(
|
|
||||||
localStorage.getItem(STORAGE_NAME_TEMPLATE_TOOLS) || "[]",
|
|
||||||
) as TemplateTools[],
|
|
||||||
);
|
|
||||||
const setTemplates = (templates: TemplateChatStore[]) => {
|
|
||||||
localStorage.setItem(STORAGE_NAME_TEMPLATE, JSON.stringify(templates));
|
|
||||||
_setTemplates(templates);
|
|
||||||
};
|
|
||||||
const setTemplateAPIs = (templateAPIs: TemplateAPI[]) => {
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_NAME_TEMPLATE_API,
|
|
||||||
JSON.stringify(templateAPIs),
|
|
||||||
);
|
|
||||||
_setTemplateAPIs(templateAPIs);
|
|
||||||
};
|
|
||||||
const setTemplateAPIsWhisper = (templateAPIWhisper: TemplateAPI[]) => {
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_NAME_TEMPLATE_API_WHISPER,
|
|
||||||
JSON.stringify(templateAPIWhisper),
|
|
||||||
);
|
|
||||||
_setTemplateAPIsWhisper(templateAPIWhisper);
|
|
||||||
};
|
|
||||||
const setTemplateAPIsTTS = (templateAPITTS: TemplateAPI[]) => {
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_NAME_TEMPLATE_API_TTS,
|
|
||||||
JSON.stringify(templateAPITTS),
|
|
||||||
);
|
|
||||||
_setTemplateAPIsTTS(templateAPITTS);
|
|
||||||
};
|
|
||||||
const setTemplateAPIsImageGen = (templateAPIImageGen: TemplateAPI[]) => {
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_NAME_TEMPLATE_API_IMAGE_GEN,
|
|
||||||
JSON.stringify(templateAPIImageGen),
|
|
||||||
);
|
|
||||||
_setTemplateAPIsImageGen(templateAPIImageGen);
|
|
||||||
};
|
|
||||||
const setTemplateTools = (templateTools: TemplateTools[]) => {
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_NAME_TEMPLATE_TOOLS,
|
|
||||||
JSON.stringify(templateTools),
|
|
||||||
);
|
|
||||||
_setToolsTemplates(templateTools);
|
|
||||||
};
|
|
||||||
const userInputRef = createRef();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grow flex flex-col p-2 w-full">
|
|
||||||
{showSettings && (
|
|
||||||
<Settings
|
|
||||||
chatStore={chatStore}
|
|
||||||
setChatStore={setChatStore}
|
|
||||||
setShow={setShowSettings}
|
|
||||||
selectedChatStoreIndex={props.selectedChatIndex}
|
|
||||||
templates={templates}
|
|
||||||
setTemplates={setTemplates}
|
|
||||||
templateAPIs={templateAPIs}
|
|
||||||
setTemplateAPIs={setTemplateAPIs}
|
|
||||||
templateAPIsWhisper={templateAPIsWhisper}
|
|
||||||
setTemplateAPIsWhisper={setTemplateAPIsWhisper}
|
|
||||||
templateAPIsTTS={templateAPIsTTS}
|
|
||||||
setTemplateAPIsTTS={setTemplateAPIsTTS}
|
|
||||||
templateAPIsImageGen={templateAPIsImageGen}
|
|
||||||
setTemplateAPIsImageGen={setTemplateAPIsImageGen}
|
|
||||||
templateTools={toolsTemplates}
|
|
||||||
setTemplateTools={setTemplateTools}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{showSearch && (
|
|
||||||
<Search
|
|
||||||
setSelectedChatIndex={props.setSelectedChatIndex}
|
|
||||||
db={props.db}
|
|
||||||
chatStore={chatStore}
|
|
||||||
setShow={setShowSearch}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<StatusBar
|
|
||||||
chatStore={chatStore}
|
|
||||||
setShowSettings={setShowSettings}
|
|
||||||
setShowSearch={setShowSearch}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="grow overflow-scroll">
|
|
||||||
{!chatStore.apiKey && (
|
|
||||||
<p className="bg-base-200 p-6 rounded my-3 text-left">
|
|
||||||
{Tr("Please click above to set")} (OpenAI) API KEY
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{!chatStore.apiEndpoint && (
|
|
||||||
<p className="bg-base-200 p-6 rounded my-3 text-left">
|
|
||||||
{Tr("Please click above to set")} API Endpoint
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{templateAPIs.length > 0 && (
|
|
||||||
<ListAPIs
|
|
||||||
label="API"
|
|
||||||
tmps={templateAPIs}
|
|
||||||
setTmps={setTemplateAPIs}
|
|
||||||
chatStore={chatStore}
|
|
||||||
setChatStore={setChatStore}
|
|
||||||
apiField="apiEndpoint"
|
|
||||||
keyField="apiKey"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{templateAPIsWhisper.length > 0 && (
|
|
||||||
<ListAPIs
|
|
||||||
label="Whisper API"
|
|
||||||
tmps={templateAPIsWhisper}
|
|
||||||
setTmps={setTemplateAPIsWhisper}
|
|
||||||
chatStore={chatStore}
|
|
||||||
setChatStore={setChatStore}
|
|
||||||
apiField="whisper_api"
|
|
||||||
keyField="whisper_key"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{templateAPIsTTS.length > 0 && (
|
|
||||||
<ListAPIs
|
|
||||||
label="TTS API"
|
|
||||||
tmps={templateAPIsTTS}
|
|
||||||
setTmps={setTemplateAPIsTTS}
|
|
||||||
chatStore={chatStore}
|
|
||||||
setChatStore={setChatStore}
|
|
||||||
apiField="tts_api"
|
|
||||||
keyField="tts_key"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{templateAPIsImageGen.length > 0 && (
|
|
||||||
<ListAPIs
|
|
||||||
label="Image Gen API"
|
|
||||||
tmps={templateAPIsImageGen}
|
|
||||||
setTmps={setTemplateAPIsImageGen}
|
|
||||||
chatStore={chatStore}
|
|
||||||
setChatStore={setChatStore}
|
|
||||||
apiField="image_gen_api"
|
|
||||||
keyField="image_gen_key"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{toolsTemplates.length > 0 && (
|
|
||||||
<ListToolsTempaltes
|
|
||||||
templateTools={toolsTemplates}
|
|
||||||
setTemplateTools={setTemplateTools}
|
|
||||||
chatStore={chatStore}
|
|
||||||
setChatStore={setChatStore}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{chatStore.history.filter((msg) => !msg.example).length == 0 && (
|
|
||||||
<div className="bg-base-200 break-all p-3 my-3 text-left">
|
|
||||||
<h2>
|
|
||||||
<span>{Tr("Saved prompt templates")}</span>
|
|
||||||
<button
|
|
||||||
className="mx-2 underline cursor-pointer"
|
|
||||||
onClick={() => {
|
|
||||||
chatStore.systemMessageContent = "";
|
|
||||||
chatStore.toolsString = "";
|
|
||||||
chatStore.history = [];
|
|
||||||
setChatStore({ ...chatStore });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Tr("Reset Current")}
|
|
||||||
</button>
|
|
||||||
</h2>
|
|
||||||
<div className="divider"></div>
|
|
||||||
<div className="flex flex-wrap">
|
|
||||||
<Templates
|
|
||||||
templates={templates}
|
|
||||||
setTemplates={setTemplates}
|
|
||||||
chatStore={chatStore}
|
|
||||||
setChatStore={setChatStore}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{chatStore.history.length === 0 && (
|
|
||||||
<p className="break-all opacity-60 p-6 rounded bg-white my-3 text-left dark:text-black">
|
|
||||||
{Tr("No chat history here")}
|
|
||||||
<br />⚙{Tr("Model")}: {chatStore.model}
|
|
||||||
<br />⬆{Tr("Click above to change the settings of this chat")}
|
|
||||||
<br />↖{Tr("Click the conor to create a new chat")}
|
|
||||||
<br />⚠
|
|
||||||
{Tr(
|
|
||||||
"All chat history and settings are stored in the local browser",
|
|
||||||
)}
|
|
||||||
<br />
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{chatStore.systemMessageContent.trim() && (
|
|
||||||
<div className="chat chat-start">
|
|
||||||
<div className="chat-header">Prompt</div>
|
|
||||||
<div
|
|
||||||
className="chat-bubble chat-bubble-accent cursor-pointer message-content"
|
|
||||||
onClick={() => setShowSettings(true)}
|
|
||||||
>
|
|
||||||
{chatStore.systemMessageContent}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{chatStore.history.map((_, messageIndex) => (
|
|
||||||
<Message
|
|
||||||
chatStore={chatStore}
|
|
||||||
setChatStore={setChatStore}
|
|
||||||
messageIndex={messageIndex}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{showGenerating && (
|
|
||||||
<p className="p-2 my-2 animate-pulse message-content">
|
|
||||||
{generatingMessage || Tr("Generating...")}
|
|
||||||
...
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="text-center">
|
|
||||||
{chatStore.history.length > 0 && (
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-warning disabled:line-through disabled:btn-neutral disabled:text-white m-2 p-2"
|
|
||||||
disabled={showGenerating}
|
|
||||||
onClick={async () => {
|
|
||||||
const messageIndex = chatStore.history.length - 1;
|
|
||||||
if (chatStore.history[messageIndex].role === "assistant") {
|
|
||||||
chatStore.history[messageIndex].hide = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
//chatStore.totalTokens =
|
|
||||||
setChatStore({ ...chatStore });
|
|
||||||
|
|
||||||
await complete();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Tr("Re-Generate")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{chatStore.develop_mode && chatStore.history.length > 0 && (
|
|
||||||
<button
|
|
||||||
className="btn btn-outline btn-sm btn-warning disabled:line-through disabled:bg-neural"
|
|
||||||
disabled={showGenerating}
|
|
||||||
onClick={async () => {
|
|
||||||
await complete();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Tr("Completion")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<p className="p-2 my-2 text-center opacity-50 dark:text-white">
|
|
||||||
{chatStore.postBeginIndex !== 0 && (
|
|
||||||
<>
|
|
||||||
<br />
|
|
||||||
{Tr("Info: chat history is too long, forget messages")}:{" "}
|
|
||||||
{chatStore.postBeginIndex}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<VersionHint chatStore={chatStore} />
|
|
||||||
{showRetry && (
|
|
||||||
<p className="text-right p-2 my-2 dark:text-white">
|
|
||||||
<button
|
|
||||||
className="p-1 rounded bg-rose-500"
|
|
||||||
onClick={async () => {
|
|
||||||
setShowRetry(false);
|
|
||||||
await complete();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Tr("Retry")}
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div ref={messagesEndRef}></div>
|
|
||||||
</div>
|
|
||||||
{images.length > 0 && (
|
|
||||||
<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 max-h-32 max-w-xs"
|
|
||||||
src={image.image_url?.url}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{generatingMessage && (
|
|
||||||
<span
|
|
||||||
className="p-2 m-2 rounded bg-white dark:text-black dark:bg-white dark:bg-opacity-50"
|
|
||||||
style={{ textAlign: "right" }}
|
|
||||||
onClick={() => {
|
|
||||||
setFollow(!follow);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<label>Follow</label>
|
|
||||||
<input type="checkbox" checked={follow} />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-between my-1">
|
|
||||||
<button
|
|
||||||
className="btn btn-primary disabled:line-through disabled:text-white disabled:bg-neutral m-1 p-1"
|
|
||||||
disabled={showGenerating || !chatStore.apiKey}
|
|
||||||
onClick={() => {
|
|
||||||
setShowAddImage(!showAddImage);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Image
|
|
||||||
</button>
|
|
||||||
{showAddImage && (
|
|
||||||
<AddImage
|
|
||||||
chatStore={chatStore}
|
|
||||||
setChatStore={setChatStore}
|
|
||||||
setShowAddImage={setShowAddImage}
|
|
||||||
images={images}
|
|
||||||
setImages={setImages}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<textarea
|
|
||||||
autofocus
|
|
||||||
value={inputMsg}
|
|
||||||
ref={userInputRef}
|
|
||||||
onChange={(event: any) => {
|
|
||||||
setInputMsg(event.target.value);
|
|
||||||
autoHeight(event.target);
|
|
||||||
}}
|
|
||||||
onKeyPress={(event: any) => {
|
|
||||||
console.log(event);
|
|
||||||
if (event.ctrlKey && event.code === "Enter") {
|
|
||||||
send(event.target.value, true);
|
|
||||||
setInputMsg("");
|
|
||||||
event.target.value = "";
|
|
||||||
autoHeight(event.target);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
autoHeight(event.target);
|
|
||||||
setInputMsg(event.target.value);
|
|
||||||
}}
|
|
||||||
className="textarea textarea-bordered textarea-sm grow w-0"
|
|
||||||
style={{
|
|
||||||
lineHeight: "1.39",
|
|
||||||
}}
|
|
||||||
placeholder="Type here..."
|
|
||||||
></textarea>
|
|
||||||
<button
|
|
||||||
className="btn btn-primary disabled:btn-neutral disabled:line-through m-1 p-1"
|
|
||||||
disabled={showGenerating}
|
|
||||||
onClick={() => {
|
|
||||||
send(inputMsg, true);
|
|
||||||
userInputRef.current.value = "";
|
|
||||||
autoHeight(userInputRef.current);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Tr("Send")}
|
|
||||||
</button>
|
|
||||||
{chatStore.whisper_api && chatStore.whisper_key && (
|
|
||||||
<WhisperButton
|
|
||||||
chatStore={chatStore}
|
|
||||||
inputMsg={inputMsg}
|
|
||||||
setInputMsg={setInputMsg}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{chatStore.develop_mode && (
|
|
||||||
<button
|
|
||||||
className="btn disabled:line-through disabled:btn-neutral disabled:text-white m-1 p-1"
|
|
||||||
disabled={showGenerating || !chatStore.apiKey}
|
|
||||||
onClick={() => {
|
|
||||||
chatStore.history.push({
|
|
||||||
role: "assistant",
|
|
||||||
content: inputMsg,
|
|
||||||
token:
|
|
||||||
calculate_token_length(inputMsg) +
|
|
||||||
calculate_token_length(images),
|
|
||||||
hide: false,
|
|
||||||
example: false,
|
|
||||||
audio: null,
|
|
||||||
logprobs: null,
|
|
||||||
response_model_name: null,
|
|
||||||
});
|
|
||||||
setInputMsg("");
|
|
||||||
setChatStore({ ...chatStore });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Tr("AI")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{chatStore.develop_mode && (
|
|
||||||
<button
|
|
||||||
className="btn disabled:line-through disabled:btn-neutral disabled:text-white m-1 p-1"
|
|
||||||
disabled={showGenerating || !chatStore.apiKey}
|
|
||||||
onClick={() => {
|
|
||||||
send(inputMsg, false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Tr("User")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{chatStore.develop_mode && (
|
|
||||||
<button
|
|
||||||
className="btn disabled:line-through disabled:btn-neutral disabled:text-white m-1 p-1"
|
|
||||||
disabled={showGenerating || !chatStore.apiKey}
|
|
||||||
onClick={() => {
|
|
||||||
setShowAddToolMsg(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Tr("Tool")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{showAddToolMsg && (
|
|
||||||
<AddToolMsg
|
|
||||||
chatStore={chatStore}
|
|
||||||
setChatStore={setChatStore}
|
|
||||||
setShowAddToolMsg={setShowAddToolMsg}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
180
src/search.tsx
180
src/search.tsx
@@ -1,180 +0,0 @@
|
|||||||
import { IDBPDatabase } from "idb";
|
|
||||||
import { StateUpdater, useRef, useState, Dispatch } from "preact/hooks";
|
|
||||||
|
|
||||||
import { ChatStore } from "@/types/chatstore";
|
|
||||||
|
|
||||||
interface ChatStoreSearchResult {
|
|
||||||
key: IDBValidKey;
|
|
||||||
cs: ChatStore;
|
|
||||||
query: string;
|
|
||||||
preview: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Search(props: {
|
|
||||||
db: Promise<IDBPDatabase<ChatStore>>;
|
|
||||||
setSelectedChatIndex: Dispatch<StateUpdater<number>>;
|
|
||||||
chatStore: ChatStore;
|
|
||||||
setShow: (show: boolean) => void;
|
|
||||||
}) {
|
|
||||||
const [searchResult, setSearchResult] = useState<ChatStoreSearchResult[]>([]);
|
|
||||||
const [searching, setSearching] = useState<boolean>(false);
|
|
||||||
const [searchingNow, setSearchingNow] = useState<number>(0);
|
|
||||||
const [pageIndex, setPageIndex] = useState<number>(0);
|
|
||||||
const searchAbortRef = useRef<AbortController | null>(null);
|
|
||||||
|
|
||||||
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-base-300 rounded-lg h-fit w-2/3 z-20"
|
|
||||||
>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="m-1 p-1 font-bold">Search</span>
|
|
||||||
<button
|
|
||||||
className="m-1 p-1 btn btn-sm btn-secondary"
|
|
||||||
onClick={() => props.setShow(false)}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
className="input input-bordered w-full border"
|
|
||||||
type="text"
|
|
||||||
placeholder="Type Something..."
|
|
||||||
onInput={async (event: any) => {
|
|
||||||
const query = event.target.value.trim().toLowerCase();
|
|
||||||
if (!query) {
|
|
||||||
setSearchResult([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// abort previous search
|
|
||||||
if (searchAbortRef.current) {
|
|
||||||
searchAbortRef.current.abort();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a new AbortController for the new operation
|
|
||||||
const abortController = new AbortController();
|
|
||||||
searchAbortRef.current = abortController;
|
|
||||||
const signal = abortController.signal;
|
|
||||||
|
|
||||||
setSearching(true);
|
|
||||||
|
|
||||||
const db = await props.db;
|
|
||||||
const resultKeys = await db.getAllKeys("chatgpt-api-web");
|
|
||||||
|
|
||||||
const result: ChatStoreSearchResult[] = [];
|
|
||||||
for (const key of resultKeys) {
|
|
||||||
// abort the operation if the signal is set
|
|
||||||
if (signal.aborted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Math.floor(
|
|
||||||
(result.length / resultKeys.length) * 100,
|
|
||||||
);
|
|
||||||
if (now !== searchingNow) setSearchingNow(now);
|
|
||||||
|
|
||||||
const value: ChatStore = await db.get("chatgpt-api-web", key);
|
|
||||||
const content = value.contents_for_index
|
|
||||||
.join(" ")
|
|
||||||
.toLowerCase();
|
|
||||||
if (content.includes(query)) {
|
|
||||||
const beginIndex: number = content.indexOf(query);
|
|
||||||
const preview = content.slice(
|
|
||||||
Math.max(0, beginIndex - 100),
|
|
||||||
Math.min(content.length, beginIndex + 239),
|
|
||||||
);
|
|
||||||
result.push({
|
|
||||||
key,
|
|
||||||
cs: value,
|
|
||||||
query: query,
|
|
||||||
preview: preview,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sort by key desc
|
|
||||||
result.sort((a, b) => {
|
|
||||||
if (a.key < b.key) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
if (a.key > b.key) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
console.log(result);
|
|
||||||
|
|
||||||
setPageIndex(0);
|
|
||||||
setSearchResult(result);
|
|
||||||
setSearching(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{searching && <div>Searching {searchingNow}%...</div>}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{searchResult
|
|
||||||
.slice(pageIndex * 10, (pageIndex + 1) * 10)
|
|
||||||
.map((result: ChatStoreSearchResult) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex justify-start p-1 m-1 rounded border bg-base-200 cursor-pointer"
|
|
||||||
key={result.key}
|
|
||||||
onClick={() => {
|
|
||||||
props.setSelectedChatIndex(parseInt(result.key.toString()));
|
|
||||||
props.setShow(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="m-1 p-1 font-bold">{result.key}</div>
|
|
||||||
<div className="m-1 p-1">{result.preview}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{searchResult.length > 0 && (
|
|
||||||
<div className="flex justify-center my-2">
|
|
||||||
<div className="join">
|
|
||||||
<button
|
|
||||||
className="join-item btn btn-sm"
|
|
||||||
disabled={pageIndex === 0}
|
|
||||||
onClick={() => {
|
|
||||||
if (pageIndex === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPageIndex(pageIndex - 1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
«
|
|
||||||
</button>
|
|
||||||
<button className="join-item btn btn-sm">
|
|
||||||
Page {pageIndex + 1} /{" "}
|
|
||||||
{Math.floor(searchResult.length / 10) + 1}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="join-item btn btn-sm"
|
|
||||||
disabled={pageIndex === Math.floor(searchResult.length / 10)}
|
|
||||||
onClick={() => {
|
|
||||||
if (pageIndex === Math.floor(searchResult.length / 10)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPageIndex(pageIndex + 1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
»
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { TemplateAPI } from "@/types/chatstore";
|
|
||||||
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="btn btn-primary btn-sm mt-3"
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
1066
src/settings.tsx
1066
src/settings.tsx
File diff suppressed because it is too large
Load Diff
@@ -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 "@/translate/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;
|
|
||||||
91
src/tts.tsx
91
src/tts.tsx
@@ -1,91 +0,0 @@
|
|||||||
import { SpeakerWaveIcon } from "@heroicons/react/24/outline";
|
|
||||||
import { useMemo, useState } from "preact/hooks";
|
|
||||||
|
|
||||||
import { addTotalCost } from "@/utils/totalCost";
|
|
||||||
import { ChatStore, ChatStoreMessage } from "@/types/chatstore";
|
|
||||||
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 ? (
|
|
||||||
<span className="loading loading-dots loading-xs"></span>
|
|
||||||
) : (
|
|
||||||
<SpeakerWaveIcon className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { Logprobs, Message } from "@/chatgpt";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ChatStore is the main object of the chatgpt-api-web,
|
|
||||||
* stored in IndexedDB and passed across various components.
|
|
||||||
* It contains all the information needed for a conversation.
|
|
||||||
*/
|
|
||||||
export interface ChatStore {
|
|
||||||
chatgpt_api_web_version: string;
|
|
||||||
systemMessageContent: string;
|
|
||||||
toolsString: string;
|
|
||||||
history: ChatStoreMessage[];
|
|
||||||
postBeginIndex: number;
|
|
||||||
tokenMargin: number;
|
|
||||||
totalTokens: number;
|
|
||||||
maxTokens: number;
|
|
||||||
maxGenTokens: number;
|
|
||||||
maxGenTokens_enabled: boolean;
|
|
||||||
apiKey: string;
|
|
||||||
apiEndpoint: string;
|
|
||||||
streamMode: boolean;
|
|
||||||
model: 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;
|
|
||||||
contents_for_index: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TemplateChatStore extends ChatStore {
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TemplateAPI {
|
|
||||||
name: string;
|
|
||||||
key: string;
|
|
||||||
endpoint: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TemplateTools {
|
|
||||||
name: string;
|
|
||||||
toolsString: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ChatStoreMessage extends the Message type defined by OpenAI.
|
|
||||||
* It adds more fields to be stored within the ChatStore structure.
|
|
||||||
*/
|
|
||||||
export interface ChatStoreMessage extends Message {
|
|
||||||
hide: boolean;
|
|
||||||
token: number;
|
|
||||||
example: boolean;
|
|
||||||
audio: Blob | null;
|
|
||||||
logprobs: Logprobs | null;
|
|
||||||
response_model_name: string | null;
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
interface Model {
|
|
||||||
maxToken: number;
|
|
||||||
price: {
|
|
||||||
prompt: number;
|
|
||||||
completion: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const models: Record<string, Model> = {
|
|
||||||
"gpt-4o": {
|
|
||||||
maxToken: 128000,
|
|
||||||
price: { prompt: 0.005 / 1000, completion: 0.015 / 1000 },
|
|
||||||
},
|
|
||||||
"gpt-4o-2024-11-20": {
|
|
||||||
maxToken: 128000,
|
|
||||||
price: { prompt: 0.0025 / 1000, completion: 0.01 / 1000 },
|
|
||||||
},
|
|
||||||
"gpt-4o-2024-08-06": {
|
|
||||||
maxToken: 128000,
|
|
||||||
price: { prompt: 0.0025 / 1000, completion: 0.01 / 1000 },
|
|
||||||
},
|
|
||||||
"gpt-4o-2024-05-13": {
|
|
||||||
maxToken: 128000,
|
|
||||||
price: { prompt: 0.005 / 1000, completion: 0.015 / 1000 },
|
|
||||||
},
|
|
||||||
"gpt-4o-mini": {
|
|
||||||
maxToken: 128000,
|
|
||||||
price: { prompt: 0.15 / 1000 / 1000, completion: 0.6 / 1000 / 1000 },
|
|
||||||
},
|
|
||||||
"gpt-4o-mini-2024-07-18": {
|
|
||||||
maxToken: 128000,
|
|
||||||
price: { prompt: 0.15 / 1000 / 1000, completion: 0.6 / 1000 / 1000 },
|
|
||||||
},
|
|
||||||
"o1-preview": {
|
|
||||||
maxToken: 128000,
|
|
||||||
price: { prompt: 15 / 1000 / 1000, completion: 60 / 1000 / 1000 },
|
|
||||||
},
|
|
||||||
"o1-preview-2024-09-12": {
|
|
||||||
maxToken: 128000,
|
|
||||||
price: { prompt: 15 / 1000 / 1000, completion: 60 / 1000 / 1000 },
|
|
||||||
},
|
|
||||||
"o1-mini": {
|
|
||||||
maxToken: 128000,
|
|
||||||
price: { prompt: 3 / 1000 / 1000, completion: 12 / 1000 / 1000 },
|
|
||||||
},
|
|
||||||
"o1-mini-2024-09-12": {
|
|
||||||
maxToken: 128000,
|
|
||||||
price: { prompt: 3 / 1000 / 1000, completion: 12 / 1000 / 1000 },
|
|
||||||
},
|
|
||||||
"chatgpt-4o-latest": {
|
|
||||||
maxToken: 128000,
|
|
||||||
price: { prompt: 0.005 / 1000, completion: 0.015 / 1000 },
|
|
||||||
},
|
|
||||||
"gpt-4-turbo": {
|
|
||||||
maxToken: 128000,
|
|
||||||
price: { prompt: 0.01 / 1000, completion: 0.03 / 1000 },
|
|
||||||
},
|
|
||||||
"gpt-4-turbo-2024-04-09": {
|
|
||||||
maxToken: 128000,
|
|
||||||
price: { prompt: 0.01 / 1000, completion: 0.03 / 1000 },
|
|
||||||
},
|
|
||||||
"gpt-4": {
|
|
||||||
maxToken: 8192,
|
|
||||||
price: { prompt: 0.03 / 1000, completion: 0.06 / 1000 },
|
|
||||||
},
|
|
||||||
"gpt-4-32k": {
|
|
||||||
maxToken: 8192,
|
|
||||||
price: { prompt: 0.06 / 1000, completion: 0.12 / 1000 },
|
|
||||||
},
|
|
||||||
"gpt-4-0125-preview": {
|
|
||||||
maxToken: 128000,
|
|
||||||
price: { prompt: 0.01 / 1000, completion: 0.03 / 1000 },
|
|
||||||
},
|
|
||||||
"gpt-4-1106-preview": {
|
|
||||||
maxToken: 128000,
|
|
||||||
price: { prompt: 0.01 / 1000, completion: 0.03 / 1000 },
|
|
||||||
},
|
|
||||||
"gpt-4-vision-preview": {
|
|
||||||
maxToken: 128000,
|
|
||||||
price: { prompt: 0.01 / 1000, completion: 0.03 / 1000 },
|
|
||||||
},
|
|
||||||
"gpt-4-1106-vision-preview": {
|
|
||||||
maxToken: 128000,
|
|
||||||
price: { prompt: 0.01 / 1000, completion: 0.03 / 1000 },
|
|
||||||
},
|
|
||||||
"gpt-3.5-turbo": {
|
|
||||||
maxToken: 4096,
|
|
||||||
price: { prompt: 0.0015 / 1000, completion: 0.002 / 1000 },
|
|
||||||
},
|
|
||||||
"gpt-3.5-turbo-0125": {
|
|
||||||
maxToken: 16385,
|
|
||||||
price: { prompt: 0.0005 / 1000, completion: 0.0015 / 1000 },
|
|
||||||
},
|
|
||||||
"gpt-3.5-turbo-instruct": {
|
|
||||||
maxToken: 16385,
|
|
||||||
price: { prompt: 0.5 / 1000 / 1000, completion: 2 / 1000 / 1000 },
|
|
||||||
},
|
|
||||||
"gpt-3.5-turbo-1106": {
|
|
||||||
maxToken: 16385,
|
|
||||||
price: { prompt: 0.001 / 1000, completion: 0.002 / 1000 },
|
|
||||||
},
|
|
||||||
"gpt-3.5-turbo-0613": {
|
|
||||||
maxToken: 16385,
|
|
||||||
price: { prompt: 1.5 / 1000 / 1000, completion: 2 / 1000 / 1000 },
|
|
||||||
},
|
|
||||||
"gpt-3.5-turbo-16k-0613": {
|
|
||||||
maxToken: 16385,
|
|
||||||
price: { prompt: 0.003 / 1000, completion: 0.004 / 1000 },
|
|
||||||
},
|
|
||||||
"gpt-3.5-turbo-0301": {
|
|
||||||
maxToken: 16385,
|
|
||||||
price: { prompt: 1.5 / 1000 / 1000, completion: 2 / 1000 / 1000 },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import {
|
|
||||||
DefaultAPIEndpoint,
|
|
||||||
DefaultModel,
|
|
||||||
CHATGPT_API_WEB_VERSION,
|
|
||||||
} from "@/const";
|
|
||||||
import { getDefaultParams } from "@/utils/getDefaultParam";
|
|
||||||
import { ChatStore } from "@/types/chatstore";
|
|
||||||
import { models } from "@/types/models";
|
|
||||||
|
|
||||||
interface NewChatStoreOptions {
|
|
||||||
apiKey?: string;
|
|
||||||
systemMessageContent?: string;
|
|
||||||
apiEndpoint?: string;
|
|
||||||
streamMode?: boolean;
|
|
||||||
model?: string;
|
|
||||||
temperature?: number;
|
|
||||||
temperature_enabled?: boolean;
|
|
||||||
top_p?: number;
|
|
||||||
top_p_enabled?: boolean;
|
|
||||||
presence_penalty?: number;
|
|
||||||
frequency_penalty?: number;
|
|
||||||
dev?: 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;
|
|
||||||
toolsString?: string;
|
|
||||||
image_gen_api?: string;
|
|
||||||
image_gen_key?: string;
|
|
||||||
json_mode?: boolean;
|
|
||||||
logprobs?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const newChatStore = (options: NewChatStoreOptions): ChatStore => {
|
|
||||||
return {
|
|
||||||
chatgpt_api_web_version: CHATGPT_API_WEB_VERSION,
|
|
||||||
systemMessageContent: getDefaultParams(
|
|
||||||
"sys",
|
|
||||||
options.systemMessageContent ?? ""
|
|
||||||
),
|
|
||||||
toolsString: options.toolsString ?? "",
|
|
||||||
history: [],
|
|
||||||
postBeginIndex: 0,
|
|
||||||
tokenMargin: 1024,
|
|
||||||
totalTokens: 0,
|
|
||||||
maxTokens: getDefaultParams(
|
|
||||||
"max",
|
|
||||||
models[getDefaultParams("model", options.model ?? DefaultModel)]
|
|
||||||
?.maxToken ?? 2048
|
|
||||||
),
|
|
||||||
maxGenTokens: 2048,
|
|
||||||
maxGenTokens_enabled: false,
|
|
||||||
apiKey: getDefaultParams("key", options.apiKey ?? ""),
|
|
||||||
apiEndpoint: getDefaultParams(
|
|
||||||
"api",
|
|
||||||
options.apiEndpoint ?? DefaultAPIEndpoint
|
|
||||||
),
|
|
||||||
streamMode: getDefaultParams("mode", options.streamMode ?? true),
|
|
||||||
model: getDefaultParams("model", options.model ?? DefaultModel),
|
|
||||||
cost: 0,
|
|
||||||
temperature: getDefaultParams("temp", options.temperature ?? 1),
|
|
||||||
temperature_enabled: options.temperature_enabled ?? true,
|
|
||||||
top_p: options.top_p ?? 1,
|
|
||||||
top_p_enabled: options.top_p_enabled ?? false,
|
|
||||||
presence_penalty: options.presence_penalty ?? 0,
|
|
||||||
frequency_penalty: options.frequency_penalty ?? 0,
|
|
||||||
develop_mode: getDefaultParams("dev", options.dev ?? false),
|
|
||||||
whisper_api: getDefaultParams(
|
|
||||||
"whisper-api",
|
|
||||||
options.whisper_api ?? "https://api.openai.com/v1/audio/transcriptions"
|
|
||||||
),
|
|
||||||
whisper_key: getDefaultParams("whisper-key", options.whisper_key ?? ""),
|
|
||||||
tts_api: getDefaultParams(
|
|
||||||
"tts-api",
|
|
||||||
options.tts_api ?? "https://api.openai.com/v1/audio/speech"
|
|
||||||
),
|
|
||||||
tts_key: getDefaultParams("tts-key", options.tts_key ?? ""),
|
|
||||||
tts_voice: options.tts_voice ?? "alloy",
|
|
||||||
tts_speed: options.tts_speed ?? 1.0,
|
|
||||||
tts_speed_enabled: options.tts_speed_enabled ?? false,
|
|
||||||
image_gen_api:
|
|
||||||
options.image_gen_api ?? "https://api.openai.com/v1/images/generations",
|
|
||||||
image_gen_key: options.image_gen_key ?? "",
|
|
||||||
json_mode: options.json_mode ?? false,
|
|
||||||
tts_format: options.tts_format ?? "mp3",
|
|
||||||
logprobs: options.logprobs ?? false,
|
|
||||||
contents_for_index: [],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
export function getDefaultParams(param: string, val: string): string;
|
|
||||||
export function getDefaultParams(param: string, val: number): number;
|
|
||||||
export function getDefaultParams(param: string, val: boolean): boolean;
|
|
||||||
|
|
||||||
export function getDefaultParams(param: any, val: any) {
|
|
||||||
const queryParameters = new URLSearchParams(window.location.search);
|
|
||||||
const get = queryParameters.get(param);
|
|
||||||
if (typeof val === "string") {
|
|
||||||
return get ?? val;
|
|
||||||
} else if (typeof val === "number") {
|
|
||||||
return parseFloat(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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { STORAGE_NAME_TOTALCOST } from "@/const";
|
|
||||||
|
|
||||||
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`);
|
|
||||||
}
|
|
||||||
@@ -1,42 +1,8 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||||
daisyui: {
|
|
||||||
themes: ["light",
|
|
||||||
"dark",
|
|
||||||
"cupcake",
|
|
||||||
"bumblebee",
|
|
||||||
"emerald",
|
|
||||||
"corporate",
|
|
||||||
"synthwave",
|
|
||||||
"retro",
|
|
||||||
"cyberpunk",
|
|
||||||
"valentine",
|
|
||||||
"halloween",
|
|
||||||
"garden",
|
|
||||||
"forest",
|
|
||||||
"aqua",
|
|
||||||
"lofi",
|
|
||||||
"pastel",
|
|
||||||
"fantasy",
|
|
||||||
"wireframe",
|
|
||||||
"black",
|
|
||||||
"luxury",
|
|
||||||
"dracula",
|
|
||||||
"cmyk",
|
|
||||||
"autumn",
|
|
||||||
"business",
|
|
||||||
"acid",
|
|
||||||
"lemonade",
|
|
||||||
"night",
|
|
||||||
"coffee",
|
|
||||||
"winter",
|
|
||||||
"dim",
|
|
||||||
"nord",
|
|
||||||
"sunset",],
|
|
||||||
},
|
|
||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {},
|
||||||
},
|
},
|
||||||
plugins: [require('daisyui')],
|
plugins: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": "src",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["*"]
|
|
||||||
},
|
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import preact from '@preact/preset-vite'
|
import preact from '@preact/preset-vite'
|
||||||
import path from 'path'
|
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [preact()],
|
plugins: [preact()],
|
||||||
base: './',
|
base: './',
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@': path.resolve(__dirname, 'src')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user