607 lines
21 KiB
TypeScript
607 lines
21 KiB
TypeScript
import structuredClone from "@ungap/structured-clone";
|
||
import { createRef } from "preact";
|
||
import { StateUpdater, useEffect, useState } from "preact/hooks";
|
||
import { ChatStore, STORAGE_NAME_TEMPLATE, addTotalCost } from "./app";
|
||
import ChatGPT, {
|
||
calculate_token_length,
|
||
ChunkMessage,
|
||
FetchResponse,
|
||
} from "./chatgpt";
|
||
import Message from "./message";
|
||
import models from "./models";
|
||
import Settings from "./settings";
|
||
import getDefaultParams from "./getDefaultParam";
|
||
|
||
export interface TemplateChatStore extends ChatStore {
|
||
name: string;
|
||
}
|
||
|
||
export default function ChatBOX(props: {
|
||
chatStore: ChatStore;
|
||
setChatStore: (cs: ChatStore) => void;
|
||
selectedChatIndex: number;
|
||
setSelectedChatIndex: StateUpdater<number>;
|
||
}) {
|
||
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 update_total_tokens = () => {
|
||
// manually estimate token
|
||
client.total_tokens = calculate_token_length(
|
||
chatStore.systemMessageContent
|
||
);
|
||
for (const msg of chatStore.history
|
||
.filter(({ hide }) => !hide)
|
||
.slice(chatStore.postBeginIndex)) {
|
||
client.total_tokens += msg.token;
|
||
}
|
||
chatStore.totalTokens = client.total_tokens;
|
||
};
|
||
|
||
const _completeWithStreamMode = async (response: Response) => {
|
||
let responseTokenCount = 0;
|
||
chatStore.streamMode = true;
|
||
// call api, return reponse text
|
||
console.log("response", response);
|
||
const reader = response.body?.getReader();
|
||
const allChunkMessage: string[] = [];
|
||
new ReadableStream({
|
||
async start() {
|
||
let lastText = "";
|
||
while (true) {
|
||
let responseDone = false;
|
||
let state = await reader?.read();
|
||
let done = state?.done;
|
||
let value = state?.value;
|
||
if (done) break;
|
||
let text = lastText + 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]" || i === "data:[DONE]") {
|
||
responseDone = true;
|
||
responseTokenCount += 1;
|
||
return false;
|
||
}
|
||
return true;
|
||
});
|
||
responseTokenCount += lines.length;
|
||
console.log("lines", lines);
|
||
const jsons: ChunkMessage[] = lines
|
||
.map((line) => {
|
||
try {
|
||
const ret = JSON.parse(line.trim().slice("data:".length));
|
||
lastText = "";
|
||
return ret;
|
||
} catch (e) {
|
||
console.log(`Chunk parse error at: ${line}`);
|
||
lastText = line;
|
||
return null;
|
||
}
|
||
})
|
||
.filter((i) => i);
|
||
console.log("jsons", jsons);
|
||
for (const { model } of jsons) {
|
||
if (model) chatStore.responseModelName = model;
|
||
}
|
||
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);
|
||
const content = allChunkMessage.join("");
|
||
|
||
// estimate cost
|
||
let cost = 0;
|
||
if (chatStore.responseModelName) {
|
||
cost +=
|
||
responseTokenCount *
|
||
(models[chatStore.responseModelName]?.price?.completion ?? 0);
|
||
let sum = 0;
|
||
for (const msg of chatStore.history
|
||
.filter(({ hide }) => !hide)
|
||
.slice(chatStore.postBeginIndex)) {
|
||
sum += msg.token;
|
||
}
|
||
cost +=
|
||
sum * (models[chatStore.responseModelName]?.price?.prompt ?? 0);
|
||
}
|
||
chatStore.cost += cost;
|
||
addTotalCost(cost);
|
||
|
||
chatStore.history.push({
|
||
role: "assistant",
|
||
content,
|
||
hide: false,
|
||
token: responseTokenCount,
|
||
example: false,
|
||
});
|
||
// manually copy status from client to chatStore
|
||
chatStore.maxTokens = client.max_tokens;
|
||
chatStore.tokenMargin = client.tokens_margin;
|
||
update_total_tokens();
|
||
setChatStore({ ...chatStore });
|
||
setGeneratingMessage("");
|
||
setShowGenerating(false);
|
||
},
|
||
});
|
||
};
|
||
|
||
const _completeWithFetchMode = async (response: Response) => {
|
||
chatStore.streamMode = false;
|
||
const data = (await response.json()) as FetchResponse;
|
||
chatStore.responseModelName = data.model ?? "";
|
||
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 content = 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,
|
||
hide: false,
|
||
token: data.usage.completion_tokens ?? calculate_token_length(content),
|
||
example: false,
|
||
});
|
||
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.tokens_margin = chatStore.tokenMargin;
|
||
client.temperature = chatStore.temperature;
|
||
client.top_p = chatStore.top_p;
|
||
client.frequency_penalty = chatStore.frequency_penalty;
|
||
client.presence_penalty = chatStore.presence_penalty;
|
||
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 }) => {
|
||
if (example) {
|
||
return {
|
||
content,
|
||
role: "system",
|
||
name: role === "assistant" ? "example_assistant" : "example_user",
|
||
};
|
||
}
|
||
return {
|
||
content,
|
||
role,
|
||
};
|
||
});
|
||
client.model = chatStore.model;
|
||
client.max_tokens = chatStore.maxTokens;
|
||
|
||
try {
|
||
setShowGenerating(true);
|
||
const response = await client._fetch(chatStore.streamMode);
|
||
const contentType = response.headers.get("content-type");
|
||
if (contentType?.startsWith("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;
|
||
|
||
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 = "") => {
|
||
const inputMsg = msg;
|
||
if (!inputMsg) {
|
||
console.log("empty message");
|
||
return;
|
||
}
|
||
chatStore.responseModelName = "";
|
||
chatStore.history.push({
|
||
role: "user",
|
||
content: inputMsg.trim(),
|
||
hide: false,
|
||
token: calculate_token_length(inputMsg.trim()),
|
||
example: false,
|
||
});
|
||
// manually calculate token length
|
||
chatStore.totalTokens += client.calculate_token_length(inputMsg.trim());
|
||
client.total_tokens += client.calculate_token_length(inputMsg.trim());
|
||
setChatStore({ ...chatStore });
|
||
setInputMsg("");
|
||
await complete();
|
||
};
|
||
|
||
const [showSettings, setShowSettings] = useState(false);
|
||
|
||
const [templates, _setTemplates] = useState(
|
||
JSON.parse(
|
||
localStorage.getItem(STORAGE_NAME_TEMPLATE) || "[]"
|
||
) as TemplateChatStore[]
|
||
);
|
||
const setTemplates = (templates: TemplateChatStore[]) => {
|
||
localStorage.setItem(STORAGE_NAME_TEMPLATE, JSON.stringify(templates));
|
||
_setTemplates(templates);
|
||
};
|
||
|
||
return (
|
||
<div className="grow flex flex-col p-2 dark:text-black">
|
||
{showSettings && (
|
||
<Settings
|
||
chatStore={chatStore}
|
||
setChatStore={setChatStore}
|
||
setShow={setShowSettings}
|
||
selectedChatStoreIndex={props.selectedChatIndex}
|
||
templates={templates}
|
||
setTemplates={setTemplates}
|
||
/>
|
||
)}
|
||
<p
|
||
className="cursor-pointer rounded bg-cyan-300 dark:text-white p-1 dark:bg-cyan-800"
|
||
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 className="underline">{chatStore.model}</span>{" "}
|
||
<span>
|
||
Tokens:{" "}
|
||
<span className="underline">
|
||
{chatStore.totalTokens}/{chatStore.maxTokens}
|
||
</span>
|
||
</span>{" "}
|
||
<span>
|
||
Cut:{" "}
|
||
<span className="underline">
|
||
{chatStore.postBeginIndex}/
|
||
{chatStore.history.filter(({ hide }) => !hide).length}
|
||
</span>{" "}
|
||
</span>{" "}
|
||
<span>
|
||
Cost:{" "}
|
||
<span className="underline">${chatStore.cost.toFixed(4)}</span>
|
||
</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>
|
||
)}
|
||
{templates.length > 0 &&
|
||
chatStore.history.filter((msg) => !msg.example).length == 0 && (
|
||
<p className="break-all opacity-80 p-3 rounded bg-white my-3 text-left dark:text-black">
|
||
<h2>Templates</h2>
|
||
<hr className="my-2" />
|
||
<div className="flex flex-wrap">
|
||
{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
|
||
);
|
||
}
|
||
newChatStore.cost = 0;
|
||
setChatStore({ ...newChatStore });
|
||
}}
|
||
>
|
||
<span className="w-full text-center">{t.name}</span>
|
||
<hr className="mt-2" />
|
||
<span className="flex justify-between">
|
||
<button
|
||
onClick={() => {
|
||
const name = prompt("Give template a name");
|
||
if (!name) {
|
||
return;
|
||
}
|
||
t.name = name;
|
||
setTemplates(structuredClone(templates));
|
||
}}
|
||
>
|
||
🖋
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
if (
|
||
!confirm("Are you sure to delete this template?")
|
||
) {
|
||
return;
|
||
}
|
||
templates.splice(index, 1);
|
||
setTemplates(structuredClone(templates));
|
||
}}
|
||
>
|
||
❌
|
||
</button>
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</p>
|
||
)}
|
||
{chatStore.history.length === 0 && (
|
||
<p className="break-all opacity-60 p-6 rounded bg-white my-3 text-left dark:text-black">
|
||
暂无历史对话记录
|
||
<br />
|
||
⚙Model: {chatStore.model}
|
||
<br />
|
||
⚙Key: {chatStore.apiKey}
|
||
<br />
|
||
⚙Endpoint: {chatStore.apiEndpoint}
|
||
<br />
|
||
⬆点击上方更改此对话的参数(请勿泄漏)
|
||
<br />
|
||
↖点击左上角 NEW 新建对话
|
||
<br />
|
||
请注意,使用 ChatGPT API
|
||
的生成文本质量和速度会受到会话上下文的影响,同时历史上下文过长会被裁切。API
|
||
会根据发送的上下文总量进行计费,因此建议您为不相关的问题或者不需要上文的问题创建新的对话,以避免不必要的计费。
|
||
<br />
|
||
⚠所有历史对话与参数储存在浏览器本地
|
||
<br />
|
||
⚠详细文档与源代码:{" "}
|
||
<a
|
||
className="underline"
|
||
href="https://github.com/heimoshuiyu/chatgpt-api-web"
|
||
target="_blank"
|
||
>
|
||
github.com/heimoshuiyu/chatgpt-api-web
|
||
</a>
|
||
</p>
|
||
)}
|
||
{chatStore.history.map((_, messageIndex) => (
|
||
<Message
|
||
chatStore={chatStore}
|
||
setChatStore={setChatStore}
|
||
messageIndex={messageIndex}
|
||
update_total_tokens={update_total_tokens}
|
||
/>
|
||
))}
|
||
{showGenerating && (
|
||
<p className="p-2 my-2 animate-pulse dark:text-white message-content">
|
||
{generatingMessage || "生成中,最长可能需要一分钟,请保持网络稳定"}
|
||
...
|
||
</p>
|
||
)}
|
||
<p className="text-center">
|
||
{chatStore.history.length > 0 && (
|
||
<button
|
||
className="disabled:line-through disabled:bg-slate-500 rounded m-2 p-2 border-2 bg-teal-500 hover:bg-teal-600"
|
||
disabled={showGenerating || !chatStore.apiKey}
|
||
onClick={async () => {
|
||
const messageIndex = chatStore.history.length - 1;
|
||
if (chatStore.history[messageIndex].role === "assistant") {
|
||
chatStore.history[messageIndex].hide = true;
|
||
}
|
||
|
||
//chatStore.totalTokens =
|
||
update_total_tokens();
|
||
setChatStore({ ...chatStore });
|
||
|
||
await complete();
|
||
}}
|
||
>
|
||
Re-Generate
|
||
</button>
|
||
)}
|
||
{chatStore.develop_mode && chatStore.history.length > 0 && (
|
||
<button
|
||
className="disabled:line-through disabled:bg-slate-500 rounded m-2 p-2 border-2 bg-yellow-500 hover:bg-yellow-600"
|
||
disabled={showGenerating || !chatStore.apiKey}
|
||
onClick={async () => {
|
||
await complete();
|
||
}}
|
||
>
|
||
Completion
|
||
</button>
|
||
)}
|
||
</p>
|
||
<p className="p-2 my-2 text-center opacity-50 dark:text-white">
|
||
{chatStore.responseModelName && (
|
||
<>Generated by {chatStore.responseModelName}</>
|
||
)}
|
||
{chatStore.postBeginIndex !== 0 && (
|
||
<>
|
||
<br />
|
||
提示:会话过长,已裁切前 {chatStore.postBeginIndex} 条消息
|
||
</>
|
||
)}
|
||
</p>
|
||
{chatStore.chatgpt_api_web_version < "v1.3.0" && (
|
||
<p className="p-2 my-2 text-center dark:text-white">
|
||
<br />
|
||
提示:当前会话版本 {chatStore.chatgpt_api_web_version}。
|
||
<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 />
|
||
提示:当前会话版本 {chatStore.chatgpt_api_web_version} {"< v1.4.0"}
|
||
。
|
||
<br />
|
||
v1.4.0 增加了更多参数,继续使用旧版可能因参数确实导致未定义的行为
|
||
<br />
|
||
请在左上角创建新会话:)
|
||
</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>
|
||
{chatStore.develop_mode && (
|
||
<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={() => {
|
||
chatStore.history.push({
|
||
role: "assistant",
|
||
content: inputMsg,
|
||
token: calculate_token_length(inputMsg),
|
||
hide: false,
|
||
example: false,
|
||
});
|
||
update_total_tokens();
|
||
setInputMsg("");
|
||
setChatStore({ ...chatStore });
|
||
}}
|
||
>
|
||
Assistant
|
||
</button>
|
||
)}
|
||
{chatStore.develop_mode && (
|
||
<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={() => {
|
||
chatStore.history.push({
|
||
role: "user",
|
||
content: inputMsg,
|
||
token: calculate_token_length(inputMsg),
|
||
hide: false,
|
||
example: false,
|
||
});
|
||
update_total_tokens();
|
||
setInputMsg("");
|
||
setChatStore({ ...chatStore });
|
||
}}
|
||
>
|
||
User
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|