Merge branch 'main' into liren/add-model-version

This commit is contained in:
Liren Tu
2023-04-17 15:54:55 -07:00
committed by GitHub
95 changed files with 3727 additions and 840 deletions

View File

@@ -1,17 +0,0 @@
import md5 from "spark-md5";
export function getAccessCodes(): Set<string> {
const code = process.env.CODE;
try {
const codes = (code?.split(",") ?? [])
.filter((v) => !!v)
.map((v) => md5.hash(v.trim()));
return new Set(codes);
} catch (e) {
return new Set();
}
}
export const ACCESS_CODES = getAccessCodes();
export const IS_IN_DOCKER = process.env.DOCKER;

View File

@@ -40,7 +40,7 @@ async function createStream(req: NextRequest) {
const parser = createParser(onParse);
for await (const chunk of res.body as any) {
parser.feed(decoder.decode(chunk));
parser.feed(decoder.decode(chunk, { stream: true }));
}
},
});
@@ -53,6 +53,9 @@ export async function POST(req: NextRequest) {
return new Response(stream);
} catch (error) {
console.error("[Chat Stream]", error);
return new Response(
["```json\n", JSON.stringify(error, null, " "), "\n```"].join(""),
);
}
}

View File

@@ -9,9 +9,16 @@ export async function requestOpenai(req: NextRequest) {
const apiKey = req.headers.get("token");
const openaiPath = req.headers.get("path");
console.log("[Proxy] ", openaiPath);
let baseUrl = BASE_URL;
return fetch(`${PROTOCOL}://${BASE_URL}/${openaiPath}`, {
if (!baseUrl.startsWith("http")) {
baseUrl = `${PROTOCOL}://${baseUrl}`;
}
console.log("[Proxy] ", openaiPath);
console.log("[Base Url]", baseUrl);
return fetch(`${baseUrl}/${openaiPath}`, {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,

21
app/api/config/route.ts Normal file
View File

@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSideConfig } from "../../config/server";
const serverConfig = getServerSideConfig();
// Danger! Don not write any secret value here!
// 警告!不要在这里写入任何敏感信息!
const DANGER_CONFIG = {
needCode: serverConfig.needCode,
};
declare global {
type DangerConfig = typeof DANGER_CONFIG;
}
export async function POST(req: NextRequest) {
return NextResponse.json({
needCode: serverConfig.needCode,
});
}

View File

@@ -6,6 +6,7 @@ async function makeRequest(req: NextRequest) {
const api = await requestOpenai(req);
const res = new NextResponse(api.body);
res.headers.set("Content-Type", "application/json");
res.headers.set("Cache-Control", "no-cache");
return res;
} catch (e) {
console.error("[OpenAI] ", req.body, e);
@@ -16,7 +17,7 @@ async function makeRequest(req: NextRequest) {
},
{
status: 500,
},
}
);
}
}
@@ -28,3 +29,7 @@ export async function POST(req: NextRequest) {
export async function GET(req: NextRequest) {
return makeRequest(req);
}
export const config = {
runtime: "edge",
};

View File

@@ -4,4 +4,4 @@ import type {
} from "openai";
export type ChatRequest = CreateChatCompletionRequest;
export type ChatReponse = CreateChatCompletionResponse;
export type ChatResponse = CreateChatCompletionResponse;

View File

@@ -10,6 +10,14 @@
transition: all 0.3s ease;
overflow: hidden;
user-select: none;
outline: none;
border: none;
color: var(--black);
&[disabled] {
cursor: not-allowed;
opacity: 0.5;
}
}
.shadow {
@@ -41,4 +49,7 @@
.icon-button-text {
margin-left: 5px;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -11,9 +11,10 @@ export function IconButton(props: {
noDark?: boolean;
className?: string;
title?: string;
disabled?: boolean;
}) {
return (
<div
<button
className={
styles["icon-button"] +
` ${props.bordered && styles.border} ${props.shadow && styles.shadow} ${
@@ -22,6 +23,7 @@ export function IconButton(props: {
}
onClick={props.onClick}
title={props.title}
disabled={props.disabled}
role="button"
>
<div
@@ -32,6 +34,6 @@ export function IconButton(props: {
{props.text && (
<div className={styles["icon-button-text"]}>{props.text}</div>
)}
</div>
</button>
);
}

View File

@@ -1,14 +1,13 @@
import { useState, useRef, useEffect, useLayoutEffect } from "react";
import DeleteIcon from "../icons/delete.svg";
import styles from "./home.module.scss";
import {
Message,
SubmitKey,
useChatStore,
ChatSession,
BOT_HELLO,
} from "../store";
DragDropContext,
Droppable,
Draggable,
OnDragEndResponder,
} from "@hello-pangea/dnd";
import { useChatStore } from "../store";
import Locale from "../locales";
import { isMobileScreen } from "../utils";
@@ -20,54 +19,90 @@ export function ChatItem(props: {
count: number;
time: string;
selected: boolean;
id: number;
index: number;
}) {
return (
<div
className={`${styles["chat-item"]} ${
props.selected && styles["chat-item-selected"]
}`}
onClick={props.onClick}
>
<div className={styles["chat-item-title"]}>{props.title}</div>
<div className={styles["chat-item-info"]}>
<div className={styles["chat-item-count"]}>
{Locale.ChatItem.ChatItemCount(props.count)}
<Draggable draggableId={`${props.id}`} index={props.index}>
{(provided) => (
<div
className={`${styles["chat-item"]} ${
props.selected && styles["chat-item-selected"]
}`}
onClick={props.onClick}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<div className={styles["chat-item-title"]}>{props.title}</div>
<div className={styles["chat-item-info"]}>
<div className={styles["chat-item-count"]}>
{Locale.ChatItem.ChatItemCount(props.count)}
</div>
<div className={styles["chat-item-date"]}>{props.time}</div>
</div>
<div className={styles["chat-item-delete"]} onClick={props.onDelete}>
<DeleteIcon />
</div>
</div>
<div className={styles["chat-item-date"]}>{props.time}</div>
</div>
<div className={styles["chat-item-delete"]} onClick={props.onDelete}>
<DeleteIcon />
</div>
</div>
)}
</Draggable>
);
}
export function ChatList() {
const [sessions, selectedIndex, selectSession, removeSession] = useChatStore(
(state) => [
const [sessions, selectedIndex, selectSession, removeSession, moveSession] =
useChatStore((state) => [
state.sessions,
state.currentSessionIndex,
state.selectSession,
state.removeSession,
],
);
state.moveSession,
]);
const chatStore = useChatStore();
const onDragEnd: OnDragEndResponder = (result) => {
const { destination, source } = result;
if (!destination) {
return;
}
if (
destination.droppableId === source.droppableId &&
destination.index === source.index
) {
return;
}
moveSession(source.index, destination.index);
};
return (
<div className={styles["chat-list"]}>
{sessions.map((item, i) => (
<ChatItem
title={item.topic}
time={item.lastUpdate}
count={item.messages.length}
key={i}
selected={i === selectedIndex}
onClick={() => selectSession(i)}
onDelete={() =>
(!isMobileScreen() || confirm(Locale.Home.DeleteChat)) &&
removeSession(i)
}
/>
))}
</div>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="chat-list">
{(provided) => (
<div
className={styles["chat-list"]}
ref={provided.innerRef}
{...provided.droppableProps}
>
{sessions.map((item, i) => (
<ChatItem
title={item.topic}
time={item.lastUpdate}
count={item.messages.length}
key={item.id}
id={item.id}
index={i}
selected={i === selectedIndex}
onClick={() => selectSession(i)}
onDelete={() => chatStore.deleteSession(i)}
/>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
);
}

View File

@@ -1,5 +1,29 @@
@import "../styles/animation.scss";
.chat-input-actions {
display: flex;
flex-wrap: wrap;
.chat-input-action {
display: inline-flex;
border-radius: 20px;
font-size: 12px;
background-color: var(--white);
color: var(--black);
border: var(--border-in-light);
padding: 4px 10px;
animation: slide-in ease 0.3s;
box-shadow: var(--card-shadow);
transition: all ease 0.3s;
margin-bottom: 10px;
align-items: center;
&:not(:last-child) {
margin-right: 5px;
}
}
}
.prompt-toast {
position: absolute;
bottom: -50px;
@@ -63,6 +87,14 @@
font-size: 12px;
font-weight: bold;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
.memory-prompt-action {
display: flex;
align-items: center;
}
}
.memory-prompt-content {

View File

@@ -1,24 +1,44 @@
import { useDebouncedCallback } from "use-debounce";
import { useState, useRef, useEffect, useLayoutEffect } from "react";
import { useDebounce, useDebouncedCallback } from "use-debounce";
import { memo, useState, useRef, useEffect, useLayoutEffect } from "react";
import SendWhiteIcon from "../icons/send-white.svg";
import BrainIcon from "../icons/brain.svg";
import ExportIcon from "../icons/export.svg";
import MenuIcon from "../icons/menu.svg";
import RenameIcon from "../icons/rename.svg";
import ExportIcon from "../icons/share.svg";
import ReturnIcon from "../icons/return.svg";
import CopyIcon from "../icons/copy.svg";
import DownloadIcon from "../icons/download.svg";
import LoadingIcon from "../icons/three-dots.svg";
import BotIcon from "../icons/bot.svg";
import AddIcon from "../icons/add.svg";
import DeleteIcon from "../icons/delete.svg";
import MaxIcon from "../icons/max.svg";
import MinIcon from "../icons/min.svg";
import { Message, SubmitKey, useChatStore, BOT_HELLO, ROLES } from "../store";
import LightIcon from "../icons/light.svg";
import DarkIcon from "../icons/dark.svg";
import AutoIcon from "../icons/auto.svg";
import BottomIcon from "../icons/bottom.svg";
import StopIcon from "../icons/pause.svg";
import {
Message,
SubmitKey,
useChatStore,
BOT_HELLO,
ROLES,
createMessage,
useAccessStore,
Theme,
} from "../store";
import {
copyToClipboard,
downloadAs,
getEmojiUrl,
isMobileScreen,
selectOrCopy,
autoGrowTextArea,
} from "../utils";
import dynamic from "next/dynamic";
@@ -31,11 +51,14 @@ import { IconButton } from "./button";
import styles from "./home.module.scss";
import chatStyle from "./chat.module.scss";
import { Modal, showModal, showToast } from "./ui-lib";
import { Input, Modal, showModal } from "./ui-lib";
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
loading: () => <LoadingIcon />,
});
const Markdown = dynamic(
async () => memo((await import("./markdown")).Markdown),
{
loading: () => <LoadingIcon />,
},
);
const Emoji = dynamic(async () => (await import("emoji-picker-react")).Emoji, {
loading: () => <LoadingIcon />,
@@ -45,12 +68,16 @@ export function Avatar(props: { role: Message["role"] }) {
const config = useChatStore((state) => state.config);
if (props.role !== "user") {
return <BotIcon className={styles["user-avtar"]} />;
return (
<div className="no-dark">
<BotIcon className={styles["user-avtar"]} />
</div>
);
}
return (
<div className={styles["user-avtar"]}>
<Emoji unified={config.avatar} size={18} />
<Emoji unified={config.avatar} size={18} getEmojiUrl={getEmojiUrl} />
</div>
);
}
@@ -60,7 +87,9 @@ function exportMessages(messages: Message[], topic: string) {
`# ${topic}\n\n` +
messages
.map((m) => {
return m.role === "user" ? `## ${m.content}` : m.content.trim();
return m.role === "user"
? `## ${Locale.Export.MessageFromYou}:\n${m.content}`
: `## ${Locale.Export.MessageFromChatGPT}:\n${m.content.trim()}`;
})
.join("\n\n");
const filename = `${topic}.md`;
@@ -138,6 +167,16 @@ function PromptToast(props: {
title={Locale.Context.Edit}
onClose={() => props.setShowModal(false)}
actions={[
<IconButton
key="reset"
icon={<CopyIcon />}
bordered
text={Locale.Memory.Reset}
onClick={() =>
confirm(Locale.Memory.ResetConfirm) &&
chatStore.resetSession()
}
/>,
<IconButton
key="copy"
icon={<CopyIcon />}
@@ -148,7 +187,6 @@ function PromptToast(props: {
]}
>
<>
{" "}
<div className={chatStyle["context-prompt"]}>
{context.map((c, i) => (
<div className={chatStyle["context-prompt-row"]} key={i}>
@@ -168,17 +206,18 @@ function PromptToast(props: {
</option>
))}
</select>
<input
<Input
value={c.content}
type="text"
className={chatStyle["context-content"]}
onChange={(e) =>
rows={1}
onInput={(e) =>
updateContextPrompt(i, {
...c,
content: e.target.value as any,
content: e.currentTarget.value as any,
})
}
></input>
/>
<IconButton
icon={<DeleteIcon />}
className={chatStyle["context-delete-button"]}
@@ -206,8 +245,24 @@ function PromptToast(props: {
</div>
<div className={chatStyle["memory-prompt"]}>
<div className={chatStyle["memory-prompt-title"]}>
{Locale.Memory.Title} ({session.lastSummarizeIndex} of{" "}
{session.messages.length})
<span>
{Locale.Memory.Title} ({session.lastSummarizeIndex} of{" "}
{session.messages.length})
</span>
<label className={chatStyle["memory-prompt-action"]}>
{Locale.Memory.Send}
<input
type="checkbox"
checked={session.sendMemory}
onChange={() =>
chatStore.updateCurrentSession(
(session) =>
(session.sendMemory = !session.sendMemory),
)
}
></input>
</label>
</div>
<div className={chatStyle["memory-prompt-content"]}>
{session.memoryPrompt || Locale.Memory.EmptyContent}
@@ -273,22 +328,90 @@ function useScrollToBottom() {
// for auto-scroll
const scrollRef = useRef<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState(true);
const scrollToBottom = () => {
const dom = scrollRef.current;
if (dom) {
setTimeout(() => (dom.scrollTop = dom.scrollHeight), 1);
}
};
// auto scroll
useLayoutEffect(() => {
const dom = scrollRef.current;
if (dom && autoScroll) {
setTimeout(() => (dom.scrollTop = dom.scrollHeight), 1);
}
autoScroll && scrollToBottom();
});
return {
scrollRef,
autoScroll,
setAutoScroll,
scrollToBottom,
};
}
export function ChatActions(props: {
showPromptModal: () => void;
scrollToBottom: () => void;
hitBottom: boolean;
}) {
const chatStore = useChatStore();
// switch themes
const theme = chatStore.config.theme;
function nextTheme() {
const themes = [Theme.Auto, Theme.Light, Theme.Dark];
const themeIndex = themes.indexOf(theme);
const nextIndex = (themeIndex + 1) % themes.length;
const nextTheme = themes[nextIndex];
chatStore.updateConfig((config) => (config.theme = nextTheme));
}
// stop all responses
const couldStop = ControllerPool.hasPending();
const stopAll = () => ControllerPool.stopAll();
return (
<div className={chatStyle["chat-input-actions"]}>
{couldStop && (
<div
className={`${chatStyle["chat-input-action"]} clickable`}
onClick={stopAll}
>
<StopIcon />
</div>
)}
{!props.hitBottom && (
<div
className={`${chatStyle["chat-input-action"]} clickable`}
onClick={props.scrollToBottom}
>
<BottomIcon />
</div>
)}
{props.hitBottom && (
<div
className={`${chatStyle["chat-input-action"]} clickable`}
onClick={props.showPromptModal}
>
<BrainIcon />
</div>
)}
<div
className={`${chatStyle["chat-input-action"]} clickable`}
onClick={nextTheme}
>
{theme === Theme.Auto ? (
<AutoIcon />
) : theme === Theme.Light ? (
<LightIcon />
) : theme === Theme.Dark ? (
<DarkIcon />
) : null}
</div>
</div>
);
}
export function Chat(props: {
showSideBar?: () => void;
sideBarShowing?: boolean;
@@ -304,9 +427,10 @@ export function Chat(props: {
const inputRef = useRef<HTMLTextAreaElement>(null);
const [userInput, setUserInput] = useState("");
const [beforeInput, setBeforeInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const { submitKey, shouldSubmit } = useSubmitHandler();
const { scrollRef, setAutoScroll } = useScrollToBottom();
const { scrollRef, setAutoScroll, scrollToBottom } = useScrollToBottom();
const [hitBottom, setHitBottom] = useState(false);
const onChatBodyScroll = (e: HTMLElement) => {
@@ -331,20 +455,30 @@ export function Chat(props: {
inputRef.current?.focus();
};
const scrollInput = () => {
const dom = inputRef.current;
if (!dom) return;
const paddingBottomNum: number = parseInt(
window.getComputedStyle(dom).paddingBottom,
10,
);
dom.scrollTop = dom.scrollHeight - dom.offsetHeight + paddingBottomNum;
};
// auto grow input
const [inputRows, setInputRows] = useState(2);
const measure = useDebouncedCallback(
() => {
const rows = inputRef.current ? autoGrowTextArea(inputRef.current) : 1;
const inputRows = Math.min(
5,
Math.max(2 + Number(!isMobileScreen()), rows),
);
setInputRows(inputRows);
},
100,
{
leading: true,
trailing: true,
},
);
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(measure, [userInput]);
// only search prompts when user input is short
const SEARCH_TEXT_LIMIT = 30;
const onInput = (text: string) => {
scrollInput();
setUserInput(text);
const n = text.trim().length;
@@ -355,9 +489,6 @@ export function Chat(props: {
// check if need to trigger auto completion
if (text.startsWith("/")) {
let searchText = text.slice(1);
if (searchText.length === 0) {
searchText = " ";
}
onSearch(searchText);
}
}
@@ -368,6 +499,7 @@ export function Chat(props: {
if (userInput.length <= 0) return;
setIsLoading(true);
chatStore.onUserInput(userInput).then(() => setIsLoading(false));
setBeforeInput(userInput);
setUserInput("");
setPromptHints([]);
if (!isMobileScreen()) inputRef.current?.focus();
@@ -375,12 +507,18 @@ export function Chat(props: {
};
// stop response
const onUserStop = (messageIndex: number) => {
ControllerPool.stop(sessionIndex, messageIndex);
const onUserStop = (messageId: number) => {
ControllerPool.stop(sessionIndex, messageId);
};
// check if should send message
const onInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// if ArrowUp and no userInput
if (e.key === "ArrowUp" && userInput.length <= 0) {
setUserInput(beforeInput);
e.preventDefault();
return;
}
if (shouldSubmit(e)) {
onUserSubmit();
e.preventDefault();
@@ -398,29 +536,61 @@ export function Chat(props: {
}
};
const onResend = (botIndex: number) => {
const findLastUesrIndex = (messageId: number) => {
// find last user input message and resend
for (let i = botIndex; i >= 0; i -= 1) {
if (messages[i].role === "user") {
setIsLoading(true);
chatStore
.onUserInput(messages[i].content)
.then(() => setIsLoading(false));
inputRef.current?.focus();
return;
let lastUserMessageIndex: number | null = null;
for (let i = 0; i < session.messages.length; i += 1) {
const message = session.messages[i];
if (message.id === messageId) {
break;
}
if (message.role === "user") {
lastUserMessageIndex = i;
}
}
return lastUserMessageIndex;
};
const deleteMessage = (userIndex: number) => {
chatStore.updateCurrentSession((session) =>
session.messages.splice(userIndex, 2),
);
};
const onDelete = (botMessageId: number) => {
const userIndex = findLastUesrIndex(botMessageId);
if (userIndex === null) return;
deleteMessage(userIndex);
};
const onResend = (botMessageId: number) => {
// find last user input message and resend
const userIndex = findLastUesrIndex(botMessageId);
if (userIndex === null) return;
setIsLoading(true);
const content = session.messages[userIndex].content;
deleteMessage(userIndex);
chatStore.onUserInput(content).then(() => setIsLoading(false));
inputRef.current?.focus();
};
const config = useChatStore((state) => state.config);
const context: RenderMessage[] = session.context.slice();
const accessStore = useAccessStore();
if (
context.length === 0 &&
session.messages.at(0)?.content !== BOT_HELLO.content
) {
context.push(BOT_HELLO);
const copiedHello = Object.assign({}, BOT_HELLO);
if (!accessStore.isAuthorized()) {
copiedHello.content = Locale.Error.Unauthorized;
}
context.push(copiedHello);
}
// preview messages
@@ -430,9 +600,10 @@ export function Chat(props: {
isLoading
? [
{
role: "assistant",
content: "……",
date: new Date().toLocaleString(),
...createMessage({
role: "assistant",
content: "……",
}),
preview: true,
},
]
@@ -442,9 +613,10 @@ export function Chat(props: {
userInput.length > 0 && config.sendPreviewBubble
? [
{
role: "user",
content: userInput,
date: new Date().toLocaleString(),
...createMessage({
role: "user",
content: userInput,
}),
preview: true,
},
]
@@ -453,29 +625,27 @@ export function Chat(props: {
const [showPromptModal, setShowPromptModal] = useState(false);
const renameSession = () => {
const newTopic = prompt(Locale.Chat.Rename, session.topic);
if (newTopic && newTopic !== session.topic) {
chatStore.updateCurrentSession((session) => (session.topic = newTopic!));
}
};
// Auto focus
useEffect(() => {
if (props.sideBarShowing && isMobileScreen()) return;
inputRef.current?.focus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className={styles.chat} key={session.id}>
<div className={styles["window-header"]}>
<div
className={styles["window-header-title"]}
onClick={props?.showSideBar}
>
<div className={styles["window-header-title"]}>
<div
className={`${styles["window-header-main-title"]} ${styles["chat-body-title"]}`}
onClick={() => {
const newTopic = prompt(Locale.Chat.Rename, session.topic);
if (newTopic && newTopic !== session.topic) {
chatStore.updateCurrentSession(
(session) => (session.topic = newTopic!),
);
}
}}
onClickCapture={renameSession}
>
{session.topic}
</div>
@@ -486,7 +656,7 @@ export function Chat(props: {
<div className={styles["window-actions"]}>
<div className={styles["window-action-button"] + " " + styles.mobile}>
<IconButton
icon={<MenuIcon />}
icon={<ReturnIcon />}
bordered
title={Locale.Chat.Actions.ChatList}
onClick={props?.showSideBar}
@@ -494,12 +664,9 @@ export function Chat(props: {
</div>
<div className={styles["window-action-button"]}>
<IconButton
icon={<BrainIcon />}
icon={<RenameIcon />}
bordered
title={Locale.Chat.Actions.CompressedHistory}
onClick={() => {
setShowPromptModal(true);
}}
onClick={renameSession}
/>
</div>
<div className={styles["window-action-button"]}>
@@ -515,6 +682,19 @@ export function Chat(props: {
}}
/>
</div>
{!isMobileScreen() && (
<div className={styles["window-action-button"]}>
<IconButton
icon={chatStore.config.tightBorder ? <MinIcon /> : <MaxIcon />}
bordered
onClick={() => {
chatStore.updateConfig(
(config) => (config.tightBorder = !config.tightBorder),
);
}}
/>
</div>
)}
</div>
<PromptToast
@@ -528,7 +708,7 @@ export function Chat(props: {
className={styles["chat-body"]}
ref={scrollRef}
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
onWheel={() => setAutoScroll(false)}
onWheel={(e) => setAutoScroll(hitBottom && e.deltaY > 0)}
onTouchStart={() => {
inputRef.current?.blur();
setAutoScroll(false);
@@ -560,17 +740,25 @@ export function Chat(props: {
{message.streaming ? (
<div
className={styles["chat-message-top-action"]}
onClick={() => onUserStop(i)}
onClick={() => onUserStop(message.id ?? i)}
>
{Locale.Chat.Actions.Stop}
</div>
) : (
<div
className={styles["chat-message-top-action"]}
onClick={() => onResend(i)}
>
{Locale.Chat.Actions.Retry}
</div>
<>
<div
className={styles["chat-message-top-action"]}
onClick={() => onDelete(message.id ?? i)}
>
{Locale.Chat.Actions.Delete}
</div>
<div
className={styles["chat-message-top-action"]}
onClick={() => onResend(message.id ?? i)}
>
{Locale.Chat.Actions.Retry}
</div>
</>
)}
<div
@@ -581,22 +769,20 @@ export function Chat(props: {
</div>
</div>
)}
{(message.preview || message.content.length === 0) &&
!isUser ? (
<LoadingIcon />
) : (
<div
className="markdown-body"
style={{ fontSize: `${fontSize}px` }}
onContextMenu={(e) => onRightClick(e, message)}
onDoubleClickCapture={() => {
if (!isMobileScreen()) return;
setUserInput(message.content);
}}
>
<Markdown content={message.content} />
</div>
)}
<Markdown
content={message.content}
loading={
(message.preview || message.content.length === 0) &&
!isUser
}
onContextMenu={(e) => onRightClick(e, message)}
onDoubleClickCapture={() => {
if (!isMobileScreen()) return;
setUserInput(message.content);
}}
fontSize={fontSize}
parentRef={scrollRef}
/>
</div>
{!isUser && !message.preview && (
<div className={styles["chat-message-actions"]}>
@@ -618,12 +804,17 @@ export function Chat(props: {
<div className={styles["chat-input-panel"]}>
<PromptHints prompts={promptHints} onPromptSelect={onPromptSelect} />
<ChatActions
showPromptModal={() => setShowPromptModal(true)}
scrollToBottom={scrollToBottom}
hitBottom={hitBottom}
/>
<div className={styles["chat-input-panel-inner"]}>
<textarea
ref={inputRef}
className={styles["chat-input"]}
placeholder={Locale.Chat.Input(submitKey)}
rows={2}
onInput={(e) => onInput(e.currentTarget.value)}
value={userInput}
onKeyDown={onInputKeyDown}
@@ -633,6 +824,7 @@ export function Chat(props: {
setTimeout(() => setPromptHints([]), 500);
}}
autoFocus={!props?.sideBarShowing}
rows={inputRows}
/>
<IconButton
icon={<SendWhiteIcon />}

View File

@@ -10,7 +10,7 @@
background-color: var(--white);
min-width: 600px;
min-height: 480px;
max-width: 900px;
max-width: 1200px;
display: flex;
overflow: hidden;
@@ -36,6 +36,7 @@
max-height: var(--full-height);
border-radius: 0;
border: 0;
}
}
@@ -48,6 +49,27 @@
display: flex;
flex-direction: column;
box-shadow: inset -2px 0px 2px 0px rgb(0, 0, 0, 0.05);
position: relative;
transition: width ease 0.1s;
}
.sidebar-drag {
$width: 10px;
position: absolute;
top: 0;
right: 0;
height: 100%;
width: $width;
background-color: var(--black);
cursor: ew-resize;
opacity: 0;
transition: all ease 0.3s;
&:hover,
&:active {
opacity: 0.2;
}
}
.window-content {
@@ -125,7 +147,7 @@
border-radius: 10px;
margin-bottom: 10px;
box-shadow: var(--card-shadow);
transition: all 0.3s ease;
transition: background-color 0.3s ease;
cursor: pointer;
user-select: none;
border: 2px solid transparent;
@@ -157,6 +179,7 @@
right: -20px;
transition: all ease 0.3s;
opacity: 0;
cursor: pointer;
}
.chat-item:hover > .chat-item-delete {
@@ -176,10 +199,11 @@
margin-top: 8px;
}
.chat-item-count {
}
.chat-item-count,
.chat-item-date {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-tail {
@@ -207,6 +231,7 @@
flex: 1;
overflow: auto;
padding: 20px;
padding-bottom: 40px;
position: relative;
}
@@ -333,11 +358,16 @@
}
.chat-input-panel {
position: relative;
width: 100%;
padding: 20px;
padding-top: 5px;
padding-top: 10px;
box-sizing: border-box;
flex-direction: column;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
border-top: var(--border-in-light);
box-shadow: var(--card-shadow);
}
@mixin single-line {
@@ -407,17 +437,11 @@
background-color: var(--white);
color: var(--black);
font-family: inherit;
padding: 10px 14px 50px;
padding: 10px 90px 10px 14px;
resize: none;
outline: none;
}
@media only screen and (max-width: 600px) {
.chat-input {
font-size: 16px;
}
}
.chat-input:focus {
border: 1px solid var(--primary);
}
@@ -428,11 +452,22 @@
position: absolute;
right: 30px;
bottom: 30px;
bottom: 32px;
}
@media only screen and (max-width: 600px) {
.chat-input {
font-size: 16px;
}
.chat-input-send {
bottom: 30px;
}
}
.export-content {
white-space: break-spaces;
padding: 10px !important;
}
.loading-content {

View File

@@ -2,7 +2,7 @@
require("../polyfill");
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { IconButton } from "./button";
import styles from "./home.module.scss";
@@ -17,9 +17,8 @@ import LoadingIcon from "../icons/three-dots.svg";
import CloseIcon from "../icons/close.svg";
import { useChatStore } from "../store";
import { isMobileScreen } from "../utils";
import { getCSSVar, isMobileScreen } from "../utils";
import Locale from "../locales";
import { ChatList } from "./chat-list";
import { Chat } from "./chat";
import dynamic from "next/dynamic";
@@ -39,6 +38,10 @@ const Settings = dynamic(async () => (await import("./settings")).Settings, {
loading: () => <Loading noLogo />,
});
const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, {
loading: () => <Loading noLogo />,
});
function useSwitchTheme() {
const config = useChatStore((state) => state.config);
@@ -52,14 +55,71 @@ function useSwitchTheme() {
document.body.classList.add("light");
}
const themeColor = getComputedStyle(document.body)
.getPropertyValue("--theme-color")
.trim();
const metaDescription = document.querySelector('meta[name="theme-color"]');
metaDescription?.setAttribute("content", themeColor);
const metaDescriptionDark = document.querySelector(
'meta[name="theme-color"][media]',
);
const metaDescriptionLight = document.querySelector(
'meta[name="theme-color"]:not([media])',
);
if (config.theme === "auto") {
metaDescriptionDark?.setAttribute("content", "#151515");
metaDescriptionLight?.setAttribute("content", "#fafafa");
} else {
const themeColor = getCSSVar("--themeColor");
metaDescriptionDark?.setAttribute("content", themeColor);
metaDescriptionLight?.setAttribute("content", themeColor);
}
}, [config.theme]);
}
function useDragSideBar() {
const limit = (x: number) => Math.min(500, Math.max(220, x));
const chatStore = useChatStore();
const startX = useRef(0);
const startDragWidth = useRef(chatStore.config.sidebarWidth ?? 300);
const lastUpdateTime = useRef(Date.now());
const handleMouseMove = useRef((e: MouseEvent) => {
if (Date.now() < lastUpdateTime.current + 100) {
return;
}
lastUpdateTime.current = Date.now();
const d = e.clientX - startX.current;
const nextWidth = limit(startDragWidth.current + d);
chatStore.updateConfig((config) => (config.sidebarWidth = nextWidth));
});
const handleMouseUp = useRef(() => {
startDragWidth.current = chatStore.config.sidebarWidth ?? 300;
window.removeEventListener("mousemove", handleMouseMove.current);
window.removeEventListener("mouseup", handleMouseUp.current);
});
const onDragMouseDown = (e: MouseEvent) => {
startX.current = e.clientX;
window.addEventListener("mousemove", handleMouseMove.current);
window.addEventListener("mouseup", handleMouseUp.current);
};
useEffect(() => {
if (isMobileScreen()) {
return;
}
document.documentElement.style.setProperty(
"--sidebar-width",
`${limit(chatStore.config.sidebarWidth ?? 300)}px`,
);
}, [chatStore.config.sidebarWidth]);
return {
onDragMouseDown,
};
}
const useHasHydrated = () => {
const [hasHydrated, setHasHydrated] = useState<boolean>(false);
@@ -78,6 +138,7 @@ function _Home() {
state.removeSession,
],
);
const chatStore = useChatStore();
const loading = !useHasHydrated();
const [showSideBar, setShowSideBar] = useState(true);
@@ -85,6 +146,9 @@ function _Home() {
const [openSettings, setOpenSettings] = useState(false);
const config = useChatStore((state) => state.config);
// drag side bar
const { onDragMouseDown } = useDragSideBar();
useSwitchTheme();
if (loading) {
@@ -127,11 +191,7 @@ function _Home() {
<div className={styles["sidebar-action"] + " " + styles.mobile}>
<IconButton
icon={<CloseIcon />}
onClick={() => {
if (confirm(Locale.Home.DeleteChat)) {
removeSession(currentIndex);
}
}}
onClick={chatStore.deleteSession}
/>
</div>
<div className={styles["sidebar-action"]}>
@@ -162,6 +222,11 @@ function _Home() {
/>
</div>
</div>
<div
className={styles["sidebar-drag"]}
onMouseDown={(e) => onDragMouseDown(e as any)}
></div>
</div>
<div className={styles["window-content"]}>

View File

@@ -0,0 +1,7 @@
.input-range {
border: var(--border-in-light);
border-radius: 10px;
padding: 5px 15px 5px 10px;
font-size: 12px;
display: flex;
}

View File

@@ -0,0 +1,37 @@
import * as React from "react";
import styles from "./input-range.module.scss";
interface InputRangeProps {
onChange: React.ChangeEventHandler<HTMLInputElement>;
title?: string;
value: number | string;
className?: string;
min: string;
max: string;
step: string;
}
export function InputRange({
onChange,
title,
value,
className,
min,
max,
step,
}: InputRangeProps) {
return (
<div className={styles["input-range"] + ` ${className ?? ""}`}>
{title || value}
<input
type="range"
title={title}
value={value}
min={min}
max={max}
step={step}
onChange={onChange}
></input>
</div>
);
}

View File

@@ -8,6 +8,8 @@ import RehypeHighlight from "rehype-highlight";
import { useRef, useState, RefObject, useEffect } from "react";
import { copyToClipboard } from "../utils";
import LoadingIcon from "../icons/three-dots.svg";
export function PreCode(props: { children: any }) {
const ref = useRef<HTMLPreElement>(null);
@@ -27,48 +29,78 @@ export function PreCode(props: { children: any }) {
);
}
const useLazyLoad = (ref: RefObject<Element>): boolean => {
const [isIntersecting, setIntersecting] = useState<boolean>(false);
export function Markdown(
props: {
content: string;
loading?: boolean;
fontSize?: number;
parentRef: RefObject<HTMLDivElement>;
} & React.DOMAttributes<HTMLDivElement>,
) {
const mdRef = useRef<HTMLDivElement>(null);
const parent = props.parentRef.current;
const md = mdRef.current;
const rendered = useRef(true); // disable lazy loading for bad ux
const [counter, setCounter] = useState(0);
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
setIntersecting(true);
observer.disconnect();
// to triggr rerender
setCounter(counter + 1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.loading]);
const inView =
rendered.current ||
(() => {
if (parent && md) {
const parentBounds = parent.getBoundingClientRect();
const mdBounds = md.getBoundingClientRect();
const isInRange = (x: number) =>
x <= parentBounds.bottom && x >= parentBounds.top;
const inView = isInRange(mdBounds.top) || isInRange(mdBounds.bottom);
if (inView) {
rendered.current = true;
}
return inView;
}
});
})();
if (ref.current) {
observer.observe(ref.current);
}
const shouldLoading = props.loading || !inView;
return () => {
observer.disconnect();
};
}, [ref]);
return isIntersecting;
};
export function Markdown(props: { content: string }) {
return (
<ReactMarkdown
remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
rehypePlugins={[
RehypeKatex,
[
RehypeHighlight,
{
detect: false,
ignoreMissing: true,
},
],
]}
components={{
pre: PreCode,
}}
<div
className="markdown-body"
style={{ fontSize: `${props.fontSize ?? 14}px` }}
ref={mdRef}
onContextMenu={props.onContextMenu}
onDoubleClickCapture={props.onDoubleClickCapture}
>
{props.content}
</ReactMarkdown>
{shouldLoading ? (
<LoadingIcon />
) : (
<ReactMarkdown
remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
rehypePlugins={[
RehypeKatex,
[
RehypeHighlight,
{
detect: false,
ignoreMissing: true,
},
],
]}
components={{
pre: PreCode,
}}
linkTarget={"_blank"}
>
{props.content}
</ReactMarkdown>
)}
</div>
);
}

View File

@@ -18,3 +18,77 @@
.avatar {
cursor: pointer;
}
.password-input-container {
max-width: 50%;
display: flex;
justify-content: flex-end;
.password-eye {
margin-right: 4px;
}
.password-input {
min-width: 80%;
}
}
.user-prompt-modal {
min-height: 40vh;
.user-prompt-search {
width: 100%;
max-width: 100%;
margin-bottom: 10px;
background-color: var(--gray);
}
.user-prompt-list {
padding: 10px 0;
.user-prompt-item {
margin-bottom: 10px;
widows: 100%;
.user-prompt-header {
display: flex;
widows: 100%;
margin-bottom: 5px;
.user-prompt-title {
flex-grow: 1;
max-width: 100%;
margin-right: 5px;
padding: 5px;
font-size: 12px;
text-align: left;
}
.user-prompt-buttons {
display: flex;
align-items: center;
.user-prompt-button {
height: 100%;
&:not(:last-child) {
margin-right: 5px;
}
}
}
}
.user-prompt-content {
width: 100%;
box-sizing: border-box;
padding: 5px;
margin-right: 10px;
font-size: 12px;
flex-grow: 1;
}
}
}
.user-prompt-actions {
}
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useMemo } from "react";
import { useState, useEffect, useMemo, HTMLProps, useRef } from "react";
import EmojiPicker, { Theme as EmojiTheme } from "emoji-picker-react";
@@ -6,10 +6,13 @@ import styles from "./settings.module.scss";
import ResetIcon from "../icons/reload.svg";
import CloseIcon from "../icons/close.svg";
import CopyIcon from "../icons/copy.svg";
import ClearIcon from "../icons/clear.svg";
import EditIcon from "../icons/edit.svg";
import EyeIcon from "../icons/eye.svg";
import EyeOffIcon from "../icons/eye-off.svg";
import { List, ListItem, Popover, showToast } from "./ui-lib";
import { Input, List, ListItem, Modal, Popover } from "./ui-lib";
import { IconButton } from "./button";
import {
@@ -19,15 +22,118 @@ import {
ALL_MODELS,
useUpdateStore,
useAccessStore,
ModalConfigValidator,
} from "../store";
import { Avatar } from "./chat";
import Locale, { AllLangs, changeLang, getLang } from "../locales";
import { getCurrentVersion } from "../utils";
import { copyToClipboard, getEmojiUrl } from "../utils";
import Link from "next/link";
import { UPDATE_URL } from "../constant";
import { SearchService, usePromptStore } from "../store/prompt";
import { requestUsage } from "../requests";
import { Prompt, SearchService, usePromptStore } from "../store/prompt";
import { ErrorBoundary } from "./error";
import { InputRange } from "./input-range";
function UserPromptModal(props: { onClose?: () => void }) {
const promptStore = usePromptStore();
const userPrompts = promptStore.getUserPrompts();
const builtinPrompts = SearchService.builtinPrompts;
const allPrompts = userPrompts.concat(builtinPrompts);
const [searchInput, setSearchInput] = useState("");
const [searchPrompts, setSearchPrompts] = useState<Prompt[]>([]);
const prompts = searchInput.length > 0 ? searchPrompts : allPrompts;
useEffect(() => {
if (searchInput.length > 0) {
const searchResult = SearchService.search(searchInput);
setSearchPrompts(searchResult);
} else {
setSearchPrompts([]);
}
}, [searchInput]);
return (
<div className="modal-mask">
<Modal
title={Locale.Settings.Prompt.Modal.Title}
onClose={() => props.onClose?.()}
actions={[
<IconButton
key="add"
onClick={() => promptStore.add({ title: "", content: "" })}
icon={<ClearIcon />}
bordered
text={Locale.Settings.Prompt.Modal.Add}
/>,
]}
>
<div className={styles["user-prompt-modal"]}>
<input
type="text"
className={styles["user-prompt-search"]}
placeholder={Locale.Settings.Prompt.Modal.Search}
value={searchInput}
onInput={(e) => setSearchInput(e.currentTarget.value)}
></input>
<div className={styles["user-prompt-list"]}>
{prompts.map((v, _) => (
<div className={styles["user-prompt-item"]} key={v.id ?? v.title}>
<div className={styles["user-prompt-header"]}>
<input
type="text"
className={styles["user-prompt-title"]}
value={v.title}
readOnly={!v.isUser}
onChange={(e) => {
if (v.isUser) {
promptStore.updateUserPrompts(
v.id!,
(prompt) => (prompt.title = e.currentTarget.value),
);
}
}}
></input>
<div className={styles["user-prompt-buttons"]}>
{v.isUser && (
<IconButton
icon={<ClearIcon />}
bordered
className={styles["user-prompt-button"]}
onClick={() => promptStore.remove(v.id!)}
/>
)}
<IconButton
icon={<CopyIcon />}
bordered
className={styles["user-prompt-button"]}
onClick={() => copyToClipboard(v.content)}
/>
</div>
</div>
<Input
rows={2}
value={v.content}
className={styles["user-prompt-content"]}
readOnly={!v.isUser}
onChange={(e) => {
if (v.isUser) {
promptStore.updateUserPrompts(
v.id!,
(prompt) => (prompt.content = e.currentTarget.value),
);
}
}}
/>
</div>
))}
</div>
</div>
</Modal>
</div>
);
}
function SettingItem(props: {
title: string;
@@ -47,6 +153,29 @@ function SettingItem(props: {
);
}
function PasswordInput(props: HTMLProps<HTMLInputElement>) {
const [visible, setVisible] = useState(false);
function changeVisibility() {
setVisible(!visible);
}
return (
<div className={styles["password-input-container"]}>
<IconButton
icon={visible ? <EyeIcon /> : <EyeOffIcon />}
onClick={changeVisibility}
className={styles["password-eye"]}
/>
<input
{...props}
type={visible ? "text" : "password"}
className={styles["password-input"]}
/>
</div>
);
}
export function Settings(props: { closeSettings: () => void }) {
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const [config, updateConfig, resetConfig, clearAllData, clearSessions] =
@@ -60,51 +189,64 @@ export function Settings(props: { closeSettings: () => void }) {
const updateStore = useUpdateStore();
const [checkingUpdate, setCheckingUpdate] = useState(false);
const currentId = getCurrentVersion();
const remoteId = updateStore.remoteId;
const hasNewVersion = currentId !== remoteId;
const currentVersion = updateStore.version;
const remoteId = updateStore.remoteVersion;
const hasNewVersion = currentVersion !== remoteId;
function checkUpdate(force = false) {
setCheckingUpdate(true);
updateStore.getLatestCommitId(force).then(() => {
updateStore.getLatestVersion(force).then(() => {
setCheckingUpdate(false);
});
}
const [usage, setUsage] = useState<{
used?: number;
}>();
const usage = {
used: updateStore.used,
subscription: updateStore.subscription,
};
const [loadingUsage, setLoadingUsage] = useState(false);
function checkUsage() {
setLoadingUsage(true);
requestUsage()
.then((res) =>
setUsage({
used: res,
}),
)
.finally(() => {
setLoadingUsage(false);
});
updateStore.updateUsage().finally(() => {
setLoadingUsage(false);
});
}
useEffect(() => {
checkUpdate();
checkUsage();
}, []);
const accessStore = useAccessStore();
const enabledAccessControl = useMemo(
() => accessStore.enabledAccessControl(),
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const promptStore = usePromptStore();
const builtinCount = SearchService.count.builtin;
const customCount = promptStore.prompts.size ?? 0;
const customCount = promptStore.getUserPrompts().length ?? 0;
const [shouldShowPromptModal, setShowPromptModal] = useState(false);
const showUsage = accessStore.isAuthorized();
useEffect(() => {
// checks per minutes
checkUpdate();
showUsage && checkUsage();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const keydownEvent = (e: KeyboardEvent) => {
if (e.key === "Escape") {
props.closeSettings();
}
};
document.addEventListener("keydown", keydownEvent);
return () => {
document.removeEventListener("keydown", keydownEvent);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<ErrorBoundary>
<div className={styles["window-header"]}>
<div className={styles["window-header-title"]}>
<div className={styles["window-header-main-title"]}>
@@ -118,7 +260,14 @@ export function Settings(props: { closeSettings: () => void }) {
<div className={styles["window-action-button"]}>
<IconButton
icon={<ClearIcon />}
onClick={clearSessions}
onClick={() => {
const confirmed = window.confirm(
`${Locale.Settings.Actions.ConfirmClearAll.Confirm}`,
);
if (confirmed) {
clearSessions();
}
}}
bordered
title={Locale.Settings.Actions.ClearAll}
/>
@@ -126,7 +275,14 @@ export function Settings(props: { closeSettings: () => void }) {
<div className={styles["window-action-button"]}>
<IconButton
icon={<ResetIcon />}
onClick={resetConfig}
onClick={() => {
const confirmed = window.confirm(
`${Locale.Settings.Actions.ConfirmResetAll.Confirm}`,
);
if (confirmed) {
resetConfig();
}
}}
bordered
title={Locale.Settings.Actions.ResetAll}
/>
@@ -150,6 +306,7 @@ export function Settings(props: { closeSettings: () => void }) {
<EmojiPicker
lazyLoadEmojis
theme={EmojiTheme.AUTO}
getEmojiUrl={getEmojiUrl}
onEmojiClick={(e) => {
updateConfig((config) => (config.avatar = e.unified));
setShowEmojiPicker(false);
@@ -168,7 +325,7 @@ export function Settings(props: { closeSettings: () => void }) {
</SettingItem>
<SettingItem
title={Locale.Settings.Update.Version(currentId)}
title={Locale.Settings.Update.Version(currentVersion ?? "unknown")}
subTitle={
checkingUpdate
? Locale.Settings.Update.IsChecking
@@ -249,8 +406,7 @@ export function Settings(props: { closeSettings: () => void }) {
title={Locale.Settings.FontSize.Title}
subTitle={Locale.Settings.FontSize.SubTitle}
>
<input
type="range"
<InputRange
title={`${config.fontSize ?? 14}px`}
value={config.fontSize}
min="12"
@@ -262,7 +418,7 @@ export function Settings(props: { closeSettings: () => void }) {
(config.fontSize = Number.parseInt(e.currentTarget.value)),
)
}
></input>
></InputRange>
</SettingItem>
<SettingItem title={Locale.Settings.TightBorder}>
@@ -290,6 +446,103 @@ export function Settings(props: { closeSettings: () => void }) {
></input>
</SettingItem>
</List>
<List>
{enabledAccessControl ? (
<SettingItem
title={Locale.Settings.AccessCode.Title}
subTitle={Locale.Settings.AccessCode.SubTitle}
>
<PasswordInput
value={accessStore.accessCode}
type="text"
placeholder={Locale.Settings.AccessCode.Placeholder}
onChange={(e) => {
accessStore.updateCode(e.currentTarget.value);
}}
/>
</SettingItem>
) : (
<></>
)}
<SettingItem
title={Locale.Settings.Token.Title}
subTitle={Locale.Settings.Token.SubTitle}
>
<PasswordInput
value={accessStore.token}
type="text"
placeholder={Locale.Settings.Token.Placeholder}
onChange={(e) => {
accessStore.updateToken(e.currentTarget.value);
}}
/>
</SettingItem>
<SettingItem
title={Locale.Settings.Usage.Title}
subTitle={
showUsage
? loadingUsage
? Locale.Settings.Usage.IsChecking
: Locale.Settings.Usage.SubTitle(
usage?.used ?? "[?]",
usage?.subscription ?? "[?]",
)
: Locale.Settings.Usage.NoAccess
}
>
{!showUsage || loadingUsage ? (
<div />
) : (
<IconButton
icon={<ResetIcon></ResetIcon>}
text={Locale.Settings.Usage.Check}
onClick={checkUsage}
/>
)}
</SettingItem>
<SettingItem
title={Locale.Settings.HistoryCount.Title}
subTitle={Locale.Settings.HistoryCount.SubTitle}
>
<InputRange
title={config.historyMessageCount.toString()}
value={config.historyMessageCount}
min="0"
max="25"
step="1"
onChange={(e) =>
updateConfig(
(config) =>
(config.historyMessageCount = e.target.valueAsNumber),
)
}
></InputRange>
</SettingItem>
<SettingItem
title={Locale.Settings.CompressThreshold.Title}
subTitle={Locale.Settings.CompressThreshold.SubTitle}
>
<input
type="number"
min={500}
max={4000}
value={config.compressMessageLengthThreshold}
onChange={(e) =>
updateConfig(
(config) =>
(config.compressMessageLengthThreshold =
e.currentTarget.valueAsNumber),
)
}
></input>
</SettingItem>
</List>
<List>
<SettingItem
title={Locale.Settings.Prompt.Disable.Title}
@@ -317,101 +570,10 @@ export function Settings(props: { closeSettings: () => void }) {
<IconButton
icon={<EditIcon />}
text={Locale.Settings.Prompt.Edit}
onClick={() => showToast(Locale.WIP)}
onClick={() => setShowPromptModal(true)}
/>
</SettingItem>
</List>
<List>
{enabledAccessControl ? (
<SettingItem
title={Locale.Settings.AccessCode.Title}
subTitle={Locale.Settings.AccessCode.SubTitle}
>
<input
value={accessStore.accessCode}
type="text"
placeholder={Locale.Settings.AccessCode.Placeholder}
onChange={(e) => {
accessStore.updateCode(e.currentTarget.value);
}}
></input>
</SettingItem>
) : (
<></>
)}
<SettingItem
title={Locale.Settings.Token.Title}
subTitle={Locale.Settings.Token.SubTitle}
>
<input
value={accessStore.token}
type="text"
placeholder={Locale.Settings.Token.Placeholder}
onChange={(e) => {
accessStore.updateToken(e.currentTarget.value);
}}
></input>
</SettingItem>
<SettingItem
title={Locale.Settings.Usage.Title}
subTitle={
loadingUsage
? Locale.Settings.Usage.IsChecking
: Locale.Settings.Usage.SubTitle(usage?.used ?? "[?]")
}
>
{loadingUsage ? (
<div />
) : (
<IconButton
icon={<ResetIcon></ResetIcon>}
text={Locale.Settings.Usage.Check}
onClick={checkUsage}
/>
)}
</SettingItem>
<SettingItem
title={Locale.Settings.HistoryCount.Title}
subTitle={Locale.Settings.HistoryCount.SubTitle}
>
<input
type="range"
title={config.historyMessageCount.toString()}
value={config.historyMessageCount}
min="0"
max="25"
step="2"
onChange={(e) =>
updateConfig(
(config) =>
(config.historyMessageCount = e.target.valueAsNumber),
)
}
></input>
</SettingItem>
<SettingItem
title={Locale.Settings.CompressThreshold.Title}
subTitle={Locale.Settings.CompressThreshold.SubTitle}
>
<input
type="number"
min={500}
max={4000}
value={config.compressMessageLengthThreshold}
onChange={(e) =>
updateConfig(
(config) =>
(config.compressMessageLengthThreshold =
e.currentTarget.valueAsNumber),
)
}
></input>
</SettingItem>
</List>
<List>
<SettingItem title={Locale.Settings.Model}>
@@ -420,7 +582,9 @@ export function Settings(props: { closeSettings: () => void }) {
onChange={(e) => {
updateConfig(
(config) =>
(config.modelConfig.model = e.currentTarget.value),
(config.modelConfig.model = ModalConfigValidator.model(
e.currentTarget.value,
)),
);
}}
>
@@ -435,9 +599,8 @@ export function Settings(props: { closeSettings: () => void }) {
title={Locale.Settings.Temperature.Title}
subTitle={Locale.Settings.Temperature.SubTitle}
>
<input
type="range"
value={config.modelConfig.temperature.toFixed(1)}
<InputRange
value={config.modelConfig.temperature?.toFixed(1)}
min="0"
max="2"
step="0.1"
@@ -445,10 +608,12 @@ export function Settings(props: { closeSettings: () => void }) {
updateConfig(
(config) =>
(config.modelConfig.temperature =
e.currentTarget.valueAsNumber),
ModalConfigValidator.temperature(
e.currentTarget.valueAsNumber,
)),
);
}}
></input>
></InputRange>
</SettingItem>
<SettingItem
title={Locale.Settings.MaxTokens.Title}
@@ -457,13 +622,15 @@ export function Settings(props: { closeSettings: () => void }) {
<input
type="number"
min={100}
max={4096}
max={32000}
value={config.modelConfig.max_tokens}
onChange={(e) =>
updateConfig(
(config) =>
(config.modelConfig.max_tokens =
e.currentTarget.valueAsNumber),
ModalConfigValidator.max_tokens(
e.currentTarget.valueAsNumber,
)),
)
}
></input>
@@ -472,9 +639,8 @@ export function Settings(props: { closeSettings: () => void }) {
title={Locale.Settings.PresencePenlty.Title}
subTitle={Locale.Settings.PresencePenlty.SubTitle}
>
<input
type="range"
value={config.modelConfig.presence_penalty.toFixed(1)}
<InputRange
value={config.modelConfig.presence_penalty?.toFixed(1)}
min="-2"
max="2"
step="0.5"
@@ -482,13 +648,19 @@ export function Settings(props: { closeSettings: () => void }) {
updateConfig(
(config) =>
(config.modelConfig.presence_penalty =
e.currentTarget.valueAsNumber),
ModalConfigValidator.presence_penalty(
e.currentTarget.valueAsNumber,
)),
);
}}
></input>
></InputRange>
</SettingItem>
</List>
{shouldShowPromptModal && (
<UserPromptModal onClose={() => setShowPromptModal(false)} />
)}
</div>
</>
</ErrorBoundary>
);
}

View File

@@ -9,6 +9,7 @@
.popover {
position: relative;
z-index: 2;
}
.popover-content {
@@ -52,7 +53,7 @@
box-shadow: var(--card-shadow);
background-color: var(--white);
border-radius: 12px;
width: 50vw;
width: 60vw;
animation: slide-in ease 0.3s;
--modal-padding: 20px;
@@ -126,6 +127,7 @@
width: 100vw;
display: flex;
justify-content: center;
pointer-events: none;
.toast-content {
max-width: 80vw;
@@ -135,12 +137,40 @@
box-shadow: var(--card-shadow);
border: var(--border-in-light);
color: var(--black);
padding: 10px 30px;
padding: 10px 20px;
border-radius: 50px;
margin-bottom: 20px;
display: flex;
align-items: center;
pointer-events: all;
.toast-action {
padding-left: 20px;
color: var(--primary);
opacity: 0.8;
border: 0;
background: none;
cursor: pointer;
font-family: inherit;
&:hover {
opacity: 1;
}
}
}
}
.input {
border: var(--border-in-light);
border-radius: 10px;
padding: 10px;
font-family: inherit;
background-color: var(--white);
color: var(--black);
resize: none;
min-width: 50px;
}
@media only screen and (max-width: 600px) {
.modal-container {
width: 90vw;

View File

@@ -2,6 +2,7 @@ import styles from "./ui-lib.module.scss";
import LoadingIcon from "../icons/three-dots.svg";
import CloseIcon from "../icons/close.svg";
import { createRoot } from "react-dom/client";
import React from "react";
export function Popover(props: {
children: JSX.Element;
@@ -109,17 +110,37 @@ export function showModal(props: ModalProps) {
root.render(<Modal {...props} onClose={closeModal}></Modal>);
}
export type ToastProps = { content: string };
export type ToastProps = {
content: string;
action?: {
text: string;
onClick: () => void;
};
};
export function Toast(props: ToastProps) {
return (
<div className={styles["toast-container"]}>
<div className={styles["toast-content"]}>{props.content}</div>
<div className={styles["toast-content"]}>
<span>{props.content}</span>
{props.action && (
<button
onClick={props.action.onClick}
className={styles["toast-action"]}
>
{props.action.text}
</button>
)}
</div>
</div>
);
}
export function showToast(content: string, delay = 3000) {
export function showToast(
content: string,
action?: ToastProps["action"],
delay = 3000,
) {
const div = document.createElement("div");
div.className = styles.show;
document.body.appendChild(div);
@@ -138,5 +159,19 @@ export function showToast(content: string, delay = 3000) {
close();
}, delay);
root.render(<Toast content={content} />);
root.render(<Toast content={content} action={action} />);
}
export type InputProps = React.HTMLProps<HTMLTextAreaElement> & {
autoHeight?: boolean;
rows?: number;
};
export function Input(props: InputProps) {
return (
<textarea
{...props}
className={`${styles["input"]} ${props.className}`}
></textarea>
);
}

View File

@@ -10,6 +10,7 @@
.window-header-title {
max-width: calc(100% - 100px);
overflow: hidden;
.window-header-main-title {
font-size: 20px;

27
app/config/build.ts Normal file
View File

@@ -0,0 +1,27 @@
const COMMIT_ID: string = (() => {
try {
const childProcess = require("child_process");
return (
childProcess
// .execSync("git describe --tags --abbrev=0")
.execSync("git rev-parse --short HEAD")
.toString()
.trim()
);
} catch (e) {
console.error("[Build Config] No git or not from git repo.");
return "unknown";
}
})();
export const getBuildConfig = () => {
if (typeof process === "undefined") {
throw Error(
"[Server Config] you are importing a nodejs-only module outside of nodejs",
);
}
return {
commitId: COMMIT_ID,
};
};

42
app/config/server.ts Normal file
View File

@@ -0,0 +1,42 @@
import md5 from "spark-md5";
declare global {
namespace NodeJS {
interface ProcessEnv {
OPENAI_API_KEY?: string;
CODE?: string;
PROXY_URL?: string;
VERCEL?: string;
}
}
}
const ACCESS_CODES = (function getAccessCodes(): Set<string> {
const code = process.env.CODE;
try {
const codes = (code?.split(",") ?? [])
.filter((v) => !!v)
.map((v) => md5.hash(v.trim()));
return new Set(codes);
} catch (e) {
return new Set();
}
})();
export const getServerSideConfig = () => {
if (typeof process === "undefined") {
throw Error(
"[Server Config] you are importing a nodejs-only module outside of nodejs",
);
}
return {
apiKey: process.env.OPENAI_API_KEY,
code: process.env.CODE,
codes: ACCESS_CODES,
needCode: ACCESS_CODES.size > 0,
proxyUrl: process.env.PROXY_URL,
isVercel: !!process.env.VERCEL,
};
};

View File

@@ -2,6 +2,7 @@ export const OWNER = "Yidadaa";
export const REPO = "ChatGPT-Next-Web";
export const REPO_URL = `https://github.com/${OWNER}/${REPO}`;
export const ISSUE_URL = `https://github.com/${OWNER}/${REPO}/issues`;
export const UPDATE_URL = `${REPO_URL}#%E4%BF%9D%E6%8C%81%E6%9B%B4%E6%96%B0-keep-updated`;
export const UPDATE_URL = `${REPO_URL}#keep-updated`;
export const FETCH_COMMIT_URL = `https://api.github.com/repos/${OWNER}/${REPO}/commits?per_page=1`;
export const FETCH_TAG_URL = `https://api.github.com/repos/${OWNER}/${REPO}/tags?per_page=1`;
export const RUNTIME_CONFIG_DOM = "danger-runtime-config";

11
app/global.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
declare module "*.jpg";
declare module "*.png";
declare module "*.woff2";
declare module "*.woff";
declare module "*.ttf";
declare module "*.scss" {
const content: Record<string, string>;
export default content;
}
declare module "*.svg";

1
app/icons/auto.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path id="分组 1" style="stroke:#333333; stroke-width:1; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(5.666666666666666 5.333333333333333) rotate(0 2.333750009536743 2.6666666666666665)" d="M0 5.33667L0.73 3.66667 M4.6675 5.33667L3.9375 3.66667 M0.729167 3.67L2.32917 0L3.93917 3.67 M0.729167 3.66667L3.93917 3.66667 " /><path id="路径 5" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(1.3333333333333333 1.3333333333333333) rotate(0 6.533316666666666 2.6666666666666665)" d="M13.07,5.33C12.45,2.29 9.76,0 6.53,0C3.31,0 0.62,2.29 0,5.33L2,4.67 " /><path id="路径 6" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(1.3333333333333333 9.333333333333332) rotate(0 6.533316666666666 2.6666666666666665)" d="M0,0C0.62,3.04 3.31,5.33 6.53,5.33C9.76,5.33 12.45,3.04 13.07,0L11.33,0.67 " /></g></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
app/icons/bottom.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path id="路径 1" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(4 4) rotate(0 4 2)" d="M8,0L4,4L0,0 " /><path id="路径 2" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(4 8) rotate(0 4 2)" d="M8,0L4,4L0,0 " /></g></g></svg>

After

Width:  |  Height:  |  Size: 736 B

1
app/icons/dark.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path id="路径 1" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(1.3333333333333333 1.333333333333485) rotate(0 6.666666666666666 6.666666666666666)" d="M6.67,0L4.91,1.76L1.76,1.76L1.76,4.91L0,6.67L1.76,8.42L1.76,11.58L4.91,11.58L6.67,13.33L8.42,11.58L11.58,11.58L11.58,8.42L13.33,6.67L11.58,4.91L11.58,1.76L8.42,1.76L6.67,0Z " /><path id="路径 2" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(5.666666666666666 5.44771525016904) rotate(0 2.4732087011352872 2.442809041582063)" d="M4,0.55C2.17,-0.78 0,0.55 0,1.89C1.67,1.89 3.33,2.22 3.33,4.89C4.67,4.89 5.83,1.89 4,0.55Z " /></g></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

4
app/icons/eye-off.svg Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.7071 5.70711C20.0976 5.31658 20.0976 4.68342 19.7071 4.29289C19.3166 3.90237 18.6834 3.90237 18.2929 4.29289L14.032 8.55382C13.4365 8.20193 12.7418 8 12 8C9.79086 8 8 9.79086 8 12C8 12.7418 8.20193 13.4365 8.55382 14.032L4.29289 18.2929C3.90237 18.6834 3.90237 19.3166 4.29289 19.7071C4.68342 20.0976 5.31658 20.0976 5.70711 19.7071L9.96803 15.4462C10.5635 15.7981 11.2582 16 12 16C14.2091 16 16 14.2091 16 12C16 11.2582 15.7981 10.5635 15.4462 9.96803L19.7071 5.70711ZM12.518 10.0677C12.3528 10.0236 12.1792 10 12 10C10.8954 10 10 10.8954 10 12C10 12.1792 10.0236 12.3528 10.0677 12.518L12.518 10.0677ZM11.482 13.9323L13.9323 11.482C13.9764 11.6472 14 11.8208 14 12C14 13.1046 13.1046 14 12 14C11.8208 14 11.6472 13.9764 11.482 13.9323ZM15.7651 4.8207C14.6287 4.32049 13.3675 4 12 4C9.14754 4 6.75717 5.39462 4.99812 6.90595C3.23268 8.42276 2.00757 10.1376 1.46387 10.9698C1.05306 11.5985 1.05306 12.4015 1.46387 13.0302C1.92276 13.7326 2.86706 15.0637 4.21194 16.3739L5.62626 14.9596C4.4555 13.8229 3.61144 12.6531 3.18002 12C3.6904 11.2274 4.77832 9.73158 6.30147 8.42294C7.87402 7.07185 9.81574 6 12 6C12.7719 6 13.5135 6.13385 14.2193 6.36658L15.7651 4.8207ZM12 18C11.2282 18 10.4866 17.8661 9.78083 17.6334L8.23496 19.1793C9.37136 19.6795 10.6326 20 12 20C14.8525 20 17.2429 18.6054 19.002 17.0941C20.7674 15.5772 21.9925 13.8624 22.5362 13.0302C22.947 12.4015 22.947 11.5985 22.5362 10.9698C22.0773 10.2674 21.133 8.93627 19.7881 7.62611L18.3738 9.04043C19.5446 10.1771 20.3887 11.3469 20.8201 12C20.3097 12.7726 19.2218 14.2684 17.6986 15.5771C16.1261 16.9282 14.1843 18 12 18Z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

4
app/icons/eye.svg Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.30147 15.5771C4.77832 14.2684 3.6904 12.7726 3.18002 12C3.6904 11.2274 4.77832 9.73158 6.30147 8.42294C7.87402 7.07185 9.81574 6 12 6C14.1843 6 16.1261 7.07185 17.6986 8.42294C19.2218 9.73158 20.3097 11.2274 20.8201 12C20.3097 12.7726 19.2218 14.2684 17.6986 15.5771C16.1261 16.9282 14.1843 18 12 18C9.81574 18 7.87402 16.9282 6.30147 15.5771ZM12 4C9.14754 4 6.75717 5.39462 4.99812 6.90595C3.23268 8.42276 2.00757 10.1376 1.46387 10.9698C1.05306 11.5985 1.05306 12.4015 1.46387 13.0302C2.00757 13.8624 3.23268 15.5772 4.99812 17.0941C6.75717 18.6054 9.14754 20 12 20C14.8525 20 17.2429 18.6054 19.002 17.0941C20.7674 15.5772 21.9925 13.8624 22.5362 13.0302C22.947 12.4015 22.947 11.5985 22.5362 10.9698C21.9925 10.1376 20.7674 8.42276 19.002 6.90595C17.2429 5.39462 14.8525 4 12 4ZM10 12C10 10.8954 10.8955 10 12 10C13.1046 10 14 10.8954 14 12C14 13.1046 13.1046 14 12 14C10.8955 14 10 13.1046 10 12ZM12 8C9.7909 8 8.00004 9.79086 8.00004 12C8.00004 14.2091 9.7909 16 12 16C14.2092 16 16 14.2091 16 12C16 9.79086 14.2092 8 12 8Z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
app/icons/light.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path id="路径 1" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(4.340166666666667 4.21550000000002) rotate(0 3.6666666666666665 3.666666666666666)" d="M0,3.67C0,5.69 1.64,7.33 3.67,7.33C5.69,7.33 7.33,5.69 7.33,3.67C7.33,1.64 5.69,0 3.67,0C1.64,0 0,1.64 0,3.67Z " /><path id="路径 2" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(12.166666666666666 12.1719333333333) rotate(0 0.4100499999999994 0.41240499999999997)" d="M0.82,0.82L0,0 " /><path id="路径 3" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(3.0068366666666666 3.0654333333332033) rotate(0 0.3411483333333332 0.34309999999999974)" d="M0.68,0.69L0,0 " /><path id="路径 4" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(8 1.2155666666667457) rotate(0 0 0.5)" d="M0,1L0,0 " /><path id="路径 5" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(13.333266666666667 8.21550000000002) rotate(0 0.6666666666666666 0)" d="M1.33,0L0,0 " /><path id="路径 6" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(12.5108 3.065499999999929) rotate(0 0.41123333333333295 0.41123333333333295)" d="M0,0.82L0.82,0 " /><path id="路径 7" fill-rule="evenodd" style="fill:#333333" transform="translate(5.673499999999999 5.5488333333332776) rotate(0 1.1666666666666665 2.333333333333333)" opacity="1" d="M2.33,0C1.04,0 0,1.04 0,2.33C0,3.62 1.04,4.67 2.33,4.67L2.33,0Z " /><path id="路径 8" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(1.3333966666666666 7.8821666666667625) rotate(0 0.6666666666666666 0)" d="M0,0L1.33,0 " /><path id="路径 9" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(3.348133333333333 12.3125) rotate(0 0.3421333333333335 0.3421266666666665)" d="M0,0.68L0.68,0 " /><path id="路径 10" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(8 13.548763333333454) rotate(0 0 0.6666666666666666)" d="M0,1.33L0,0 " /></g></g></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

41
app/icons/max.svg Normal file
View File

@@ -0,0 +1,41 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16"
height="16" viewBox="0 0 16 16" fill="none">
<defs>
<rect id="path_0" x="0" y="0" width="16" height="16" />
</defs>
<g opacity="1" transform="translate(0 0) rotate(0 8 8)">
<mask id="bg-mask-0" fill="white">
<use xlink:href="#path_0"></use>
</mask>
<g mask="url(#bg-mask-0)">
<path id="路径 1"
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
transform="translate(2 2) rotate(0 1.6666666666666665 1.6499166666666665)"
d="M0,0L3.33,3.3 " />
<path id="路径 2"
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
transform="translate(2 10.666666666666666) rotate(0 1.6666666666666665 1.6499166666666671)"
d="M0,3.3L3.33,0 " />
<path id="路径 3"
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
transform="translate(10.700199999999999 10.666666666666666) rotate(0 1.6499166666666671 1.6499166666666671)"
d="M3.3,3.3L0,0 " />
<path id="路径 4"
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
transform="translate(10.666666666666666 2) rotate(0 1.6499166666666671 1.6499166666666665)"
d="M3.3,0L0,3.3 " />
<path id="路径 5"
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
transform="translate(11 2) rotate(0 1.5 1.5)" d="M0,0L3,0L3,3 " />
<path id="路径 6"
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
transform="translate(11 11) rotate(0 1.5 1.5)" d="M3,0L3,3L0,3 " />
<path id="路径 7"
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
transform="translate(2 11) rotate(0 1.5 1.5)" d="M3,3L0,3L0,0 " />
<path id="路径 8"
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
transform="translate(2 2) rotate(0 1.5 1.5)" d="M0,3L0,0L3,0 " />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

45
app/icons/min.svg Normal file
View File

@@ -0,0 +1,45 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16"
height="16" viewBox="0 0 16 16" fill="none">
<defs>
<rect id="path_0" x="0" y="0" width="16" height="16" />
</defs>
<g opacity="1" transform="translate(0 0) rotate(0 8 8)">
<mask id="bg-mask-0" fill="white">
<use xlink:href="#path_0"></use>
</mask>
<g mask="url(#bg-mask-0)">
<path id="路径 1"
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
transform="translate(2 2) rotate(0 1.6666666666666665 1.6499166666666665)"
d="M0,0L3.33,3.3 " />
<path id="路径 2"
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
transform="translate(2 10.666666666666666) rotate(0 1.6666666666666665 1.6499166666666671)"
d="M0,3.3L3.33,0 " />
<path id="路径 3"
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
transform="translate(10.700199999999999 10.666666666666666) rotate(0 1.6499166666666671 1.6499166666666671)"
d="M3.3,3.3L0,0 " />
<path id="路径 4"
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
transform="translate(10.666666666666666 2) rotate(0 1.6499166666666671 1.6499166666666665)"
d="M3.3,0L0,3.3 " />
<path id="路径 5"
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
transform="translate(10.666666666666666 2.333333333333333) rotate(0 1.5 1.5)"
d="M0,0L0,3L3,3 " />
<path id="路径 6"
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
transform="translate(2.333333333333333 2.333333333333333) rotate(0 1.5 1.5)"
d="M3,0L3,3L0,3 " />
<path id="路径 7"
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
transform="translate(2.333333333333333 10.666666666666666) rotate(0 1.5 1.5)"
d="M3,3L3,0L0,0 " />
<path id="路径 8"
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
transform="translate(10.666666666666666 10.666666666666666) rotate(0 1.4832500000000004 1.5)"
d="M0,3L0,0L2.97,0 " />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

1
app/icons/pause.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path id="路径 1" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(1.3333333333333333 1.3333333333333333) rotate(0 6.666666666666666 6.666666666666666)" d="M13.33,6.67C13.33,2.98 10.35,0 6.67,0C2.98,0 0,2.98 0,6.67C0,10.35 2.98,13.33 6.67,13.33C10.35,13.33 13.33,10.35 13.33,6.67Z " /><path id="路径 2" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(6.333333333333333 6) rotate(0 0 2)" d="M0,0L0,4 " /><path id="路径 3" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(9.666666666666666 6) rotate(0 0 2)" d="M0,0L0,4 " /></g></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

1
app/icons/rename.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path id="路径 1" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(1.774903333333333 1.3006199999999999) rotate(0 6.599664999999999 6.599656666666666)" d="M2.83,13.2L13.2,2.83L10.37,0L0,10.37L0,13.2L2.83,13.2Z " /><path id="路径 2" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(9.317366666666667 4.129066666666667) rotate(0 1.4142166666666658 1.4142166666666665)" d="M0,0L2.83,2.83 " /></g></g></svg>

After

Width:  |  Height:  |  Size: 911 B

21
app/icons/return.svg Normal file
View File

@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16"
height="16" viewBox="0 0 16 16" fill="none">
<defs>
<rect id="path_0" x="0" y="0" width="16" height="16" />
</defs>
<g opacity="1" transform="translate(0 0) rotate(0 8 8)">
<mask id="bg-mask-0" fill="white">
<use xlink:href="#path_0"></use>
</mask>
<g mask="url(#bg-mask-0)">
<path id="路径 1"
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
transform="translate(2 2.6666666666666665) rotate(0 1.1666333333333334 2.1666666666666665)"
d="M2.33,0L0,2L2.33,4.33 " />
<path id="路径 2"
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
transform="translate(2 4.666666666666666) rotate(0 6.000006859869576 4.333333333333333)"
d="M0,0L7.66,0C9.96,0 11.91,1.87 12,4.17C12.09,6.59 10.09,8.67 7.66,8.67L2,8.67 " />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1013 B

17
app/icons/share.svg Normal file
View File

@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16"
height="16" viewBox="0 0 16 16" fill="none">
<defs>
<rect id="path_0" x="0" y="0" width="16" height="16" />
</defs>
<g opacity="1" transform="translate(0 0) rotate(0 8 8)">
<mask id="bg-mask-0" fill="white">
<use xlink:href="#path_0"></use>
</mask>
<g mask="url(#bg-mask-0)">
<path id="路径 1"
style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0"
transform="translate(2 1.3333333333333333) rotate(0 6.333333333333333 6.5)"
d="M6.67,3.67C1.67,3.67 0,7.33 0,13C0,13 2,8 6.67,8L6.67,11.67L12.67,6L6.67,0L6.67,3.67Z " />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 740 B

View File

@@ -2,45 +2,20 @@
import "./styles/globals.scss";
import "./styles/markdown.scss";
import "./styles/highlight.scss";
import process from "child_process";
import { ACCESS_CODES, IS_IN_DOCKER } from "./api/access";
import { getBuildConfig } from "./config/build";
let COMMIT_ID: string | undefined;
try {
COMMIT_ID = process
// .execSync("git describe --tags --abbrev=0")
.execSync("git rev-parse --short HEAD")
.toString()
.trim();
} catch (e) {
console.error("No git or not from git repo.");
}
const buildConfig = getBuildConfig();
export const metadata = {
title: "ChatGPT Next Web",
description: "Your personal ChatGPT Chat Bot.",
appleWebApp: {
title: "ChatGPT Next Web",
statusBarStyle: "black-translucent",
statusBarStyle: "default",
},
themeColor: "#fafafa",
};
function Meta() {
const metas = {
version: COMMIT_ID ?? "unknown",
access: ACCESS_CODES.size > 0 || IS_IN_DOCKER ? "enabled" : "disabled",
};
return (
<>
{Object.entries(metas).map(([k, v]) => (
<meta name={k} content={v} key={k} />
))}
</>
);
}
export default function RootLayout({
children,
}: {
@@ -53,7 +28,12 @@ export default function RootLayout({
name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"
/>
<Meta />
<meta
name="theme-color"
content="#151515"
media="(prefers-color-scheme: dark)"
/>
<meta name="version" content={buildConfig.commitId} />
<link rel="manifest" href="/site.webmanifest"></link>
<link rel="preconnect" href="https://fonts.googleapis.com"></link>
<link rel="preconnect" href="https://fonts.gstatic.com"></link>

View File

@@ -3,7 +3,7 @@ import { SubmitKey } from "../store/app";
const cn = {
WIP: "该功能仍在开发中……",
Error: {
Unauthorized: "现在是未授权状态,请在设置页填写授权码。",
Unauthorized: "现在是未授权状态,请点击左下角设置按钮输入访问密码。",
},
ChatItem: {
ChatItemCount: (count: number) => `${count} 条对话`,
@@ -17,15 +17,16 @@ const cn = {
Copy: "复制",
Stop: "停止",
Retry: "重试",
Delete: "删除",
},
Rename: "重命名对话",
Typing: "正在输入…",
Input: (submitKey: string) => {
var inputHints = `输入消息,${submitKey} 发送`;
var inputHints = `${submitKey} 发送`;
if (submitKey === String(SubmitKey.Enter)) {
inputHints += "Shift + Enter 换行";
}
return inputHints;
return inputHints + "/ 触发补全";
},
Send: "发送",
},
@@ -33,15 +34,22 @@ const cn = {
Title: "导出聊天记录为 Markdown",
Copy: "全部复制",
Download: "下载文件",
MessageFromYou: "来自你的消息",
MessageFromChatGPT: "来自 ChatGPT 的消息",
},
Memory: {
Title: "历史记忆",
EmptyContent: "尚未记忆",
Copy: "全部复制",
Send: "发送记忆",
Copy: "复制记忆",
Reset: "重置对话",
ResetConfirm: "重置后将清空当前对话记录以及历史记忆,确认重置?",
},
Home: {
NewChat: "新的聊天",
DeleteChat: "确认删除选中的对话?",
DeleteToast: "已删除会话",
Revert: "撤销",
},
Settings: {
Title: "设置",
@@ -50,6 +58,12 @@ const cn = {
ClearAll: "清除所有数据",
ResetAll: "重置所有选项",
Close: "关闭",
ConfirmResetAll: {
Confirm: "确认清除所有配置?",
},
ConfirmClearAll: {
Confirm: "确认清除所有聊天记录?",
},
},
Lang: {
Name: "Language",
@@ -59,6 +73,9 @@ const cn = {
tw: "繁體中文",
es: "Español",
it: "Italiano",
tr: "Türkçe",
jp: "日本語",
de: "Deutsch",
},
},
Avatar: "头像",
@@ -77,7 +94,7 @@ const cn = {
},
SendKey: "发送键",
Theme: "主题",
TightBorder: "紧凑边框",
TightBorder: "无边框模式",
SendPreviewBubble: "发送预览气泡",
Prompt: {
Disable: {
@@ -88,6 +105,11 @@ const cn = {
ListCount: (builtin: number, custom: number) =>
`内置 ${builtin} 条,用户定义 ${custom}`,
Edit: "编辑",
Modal: {
Title: "提示词列表",
Add: "增加一条",
Search: "搜尋提示詞",
},
},
HistoryCount: {
Title: "附带历史消息数",
@@ -99,26 +121,27 @@ const cn = {
},
Token: {
Title: "API Key",
SubTitle: "使用自己的 Key 可绕过受控访问限制",
SubTitle: "使用自己的 Key 可绕过密码访问限制",
Placeholder: "OpenAI API Key",
},
Usage: {
Title: "账户余额",
SubTitle(used: any) {
return `本月已使用 $${used}`;
Title: "余额查询",
SubTitle(used: any, total: any) {
return `本月已使用 $${used},订阅总额 $${total}`;
},
IsChecking: "正在检查…",
Check: "重新检查",
NoAccess: "输入 API Key 或访问密码查看余额",
},
AccessCode: {
Title: "访问码",
SubTitle: "现在是受控访问状态",
Placeholder: "请输入访问码",
Title: "访问码",
SubTitle: "已开启加密访问",
Placeholder: "请输入访问码",
},
Model: "模型 (model)",
Temperature: {
Title: "随机性 (temperature)",
SubTitle: "值越大,回复越随机",
SubTitle: "值越大,回复越随机,大于 1 的值可能会导致乱码",
},
MaxTokens: {
Title: "单次回复限制 (max_tokens)",

189
app/locales/de.ts Normal file
View File

@@ -0,0 +1,189 @@
import { SubmitKey } from "../store/app";
import type { LocaleType } from "./index";
const de: LocaleType = {
WIP: "In Bearbeitung...",
Error: {
Unauthorized:
"Unbefugter Zugriff, bitte geben Sie den Zugangscode auf der Einstellungsseite ein.",
},
ChatItem: {
ChatItemCount: (count: number) => `${count} Nachrichten`,
},
Chat: {
SubTitle: (count: number) => `${count} Nachrichten mit ChatGPT`,
Actions: {
ChatList: "Zur Chat-Liste gehen",
CompressedHistory: "Komprimierter Gedächtnis-Prompt",
Export: "Alle Nachrichten als Markdown exportieren",
Copy: "Kopieren",
Stop: "Stop",
Retry: "Wiederholen",
Delete: "Delete",
},
Rename: "Chat umbenennen",
Typing: "Tippen...",
Input: (submitKey: string) => {
var inputHints = `${submitKey} um zu Senden`;
if (submitKey === String(SubmitKey.Enter)) {
inputHints += ", Umschalt + Eingabe für Zeilenumbruch";
}
return inputHints + ", / zum Durchsuchen von Prompts";
},
Send: "Senden",
},
Export: {
Title: "Alle Nachrichten",
Copy: "Alles kopieren",
Download: "Herunterladen",
MessageFromYou: "Deine Nachricht",
MessageFromChatGPT: "Nachricht von ChatGPT",
},
Memory: {
Title: "Gedächtnis-Prompt",
EmptyContent: "Noch nichts.",
Send: "Gedächtnis senden",
Copy: "Gedächtnis kopieren",
Reset: "Sitzung zurücksetzen",
ResetConfirm:
"Das Zurücksetzen löscht den aktuellen Gesprächsverlauf und das Langzeit-Gedächtnis. Möchten Sie wirklich zurücksetzen?",
},
Home: {
NewChat: "Neuer Chat",
DeleteChat: "Bestätigen Sie, um das ausgewählte Gespräch zu löschen?",
DeleteToast: "Chat gelöscht",
Revert: "Zurücksetzen",
},
Settings: {
Title: "Einstellungen",
SubTitle: "Alle Einstellungen",
Actions: {
ClearAll: "Alle Daten löschen",
ResetAll: "Alle Einstellungen zurücksetzen",
Close: "Schließen",
ConfirmResetAll: {
Confirm: "Möchten Sie wirklich alle Konfigurationen zurücksetzen?",
},
ConfirmClearAll: {
Confirm: "Möchten Sie wirklich alle Chats zurücksetzen?",
},
},
Lang: {
Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language`
Options: {
cn: "简体中文",
en: "English",
tw: "繁體中文",
es: "Español",
it: "Italiano",
tr: "Türkçe",
jp: "日本語",
de: "Deutsch",
},
},
Avatar: "Avatar",
FontSize: {
Title: "Schriftgröße",
SubTitle: "Schriftgröße des Chat-Inhalts anpassen",
},
Update: {
Version: (x: string) => `Version: ${x}`,
IsLatest: "Neueste Version",
CheckUpdate: "Update prüfen",
IsChecking: "Update wird geprüft...",
FoundUpdate: (x: string) => `Neue Version gefunden: ${x}`,
GoToUpdate: "Aktualisieren",
},
SendKey: "Senden-Taste",
Theme: "Erscheinungsbild",
TightBorder: "Enger Rahmen",
SendPreviewBubble: "Vorschau-Bubble senden",
Prompt: {
Disable: {
Title: "Autovervollständigung deaktivieren",
SubTitle: "Autovervollständigung mit / starten",
},
List: "Prompt-Liste",
ListCount: (builtin: number, custom: number) =>
`${builtin} integriert, ${custom} benutzerdefiniert`,
Edit: "Bearbeiten",
Modal: {
Title: "Prompt List",
Add: "Add One",
Search: "Search Prompts",
},
},
HistoryCount: {
Title: "Anzahl der angehängten Nachrichten",
SubTitle: "Anzahl der pro Anfrage angehängten gesendeten Nachrichten",
},
CompressThreshold: {
Title: "Schwellenwert für Verlaufskomprimierung",
SubTitle:
"Komprimierung, wenn die Länge der unkomprimierten Nachrichten den Wert überschreitet",
},
Token: {
Title: "API-Schlüssel",
SubTitle:
"Verwenden Sie Ihren Schlüssel, um das Zugangscode-Limit zu ignorieren",
Placeholder: "OpenAI API-Schlüssel",
},
Usage: {
Title: "Kontostand",
SubTitle(used: any, total: any) {
return `Diesen Monat ausgegeben $${used}, Abonnement $${total}`;
},
IsChecking: "Wird überprüft...",
Check: "Erneut prüfen",
NoAccess: "API-Schlüssel eingeben, um den Kontostand zu überprüfen",
},
AccessCode: {
Title: "Zugangscode",
SubTitle: "Zugangskontrolle aktiviert",
Placeholder: "Zugangscode erforderlich",
},
Model: "Modell",
Temperature: {
Title: "Temperature", //Temperatur
SubTitle: "Ein größerer Wert führt zu zufälligeren Antworten",
},
MaxTokens: {
Title: "Max Tokens", //Maximale Token
SubTitle: "Maximale Anzahl der Anfrage- plus Antwort-Token",
},
PresencePenlty: {
Title: "Presence Penalty", //Anwesenheitsstrafe
SubTitle:
"Ein größerer Wert erhöht die Wahrscheinlichkeit, dass über neue Themen gesprochen wird",
},
},
Store: {
DefaultTopic: "Neues Gespräch",
BotHello: "Hallo! Wie kann ich Ihnen heute helfen?",
Error:
"Etwas ist schief gelaufen, bitte versuchen Sie es später noch einmal.",
Prompt: {
History: (content: string) =>
"Dies ist eine Zusammenfassung des Chatverlaufs zwischen dem KI und dem Benutzer als Rückblick: " +
content,
Topic:
"Bitte erstellen Sie einen vier- bis fünfwörtigen Titel, der unser Gespräch zusammenfasst, ohne Einleitung, Zeichensetzung, Anführungszeichen, Punkte, Symbole oder zusätzlichen Text. Entfernen Sie Anführungszeichen.",
Summarize:
"Fassen Sie unsere Diskussion kurz in 200 Wörtern oder weniger zusammen, um sie als Pronpt für zukünftige Gespräche zu verwenden.",
},
ConfirmClearAll:
"Bestätigen Sie, um alle Chat- und Einstellungsdaten zu löschen?",
},
Copy: {
Success: "In die Zwischenablage kopiert",
Failed:
"Kopieren fehlgeschlagen, bitte geben Sie die Berechtigung zum Zugriff auf die Zwischenablage frei",
},
Context: {
Toast: (x: any) => `Mit ${x} Kontext-Prompts`,
Edit: "Kontext- und Gedächtnis-Prompts",
Add: "Hinzufügen",
},
};
export default de;

View File

@@ -19,15 +19,16 @@ const en: LocaleType = {
Copy: "Copy",
Stop: "Stop",
Retry: "Retry",
Delete: "Delete",
},
Rename: "Rename Chat",
Typing: "Typing…",
Input: (submitKey: string) => {
var inputHints = `Type something and press ${submitKey} to send`;
var inputHints = `${submitKey} to send`;
if (submitKey === String(SubmitKey.Enter)) {
inputHints += ", press Shift + Enter to newline";
inputHints += ", Shift + Enter to wrap";
}
return inputHints;
return inputHints + ", / to search prompts";
},
Send: "Send",
},
@@ -35,15 +36,23 @@ const en: LocaleType = {
Title: "All Messages",
Copy: "Copy All",
Download: "Download",
MessageFromYou: "Message From You",
MessageFromChatGPT: "Message From ChatGPT",
},
Memory: {
Title: "Memory Prompt",
EmptyContent: "Nothing yet.",
Copy: "Copy All",
Send: "Send Memory",
Copy: "Copy Memory",
Reset: "Reset Session",
ResetConfirm:
"Resetting will clear the current conversation history and historical memory. Are you sure you want to reset?",
},
Home: {
NewChat: "New Chat",
DeleteChat: "Confirm to delete the selected conversation?",
DeleteToast: "Chat Deleted",
Revert: "Revert",
},
Settings: {
Title: "Settings",
@@ -52,6 +61,12 @@ const en: LocaleType = {
ClearAll: "Clear All Data",
ResetAll: "Reset All Settings",
Close: "Close",
ConfirmResetAll: {
Confirm: "Are you sure you want to reset all configurations?",
},
ConfirmClearAll: {
Confirm: "Are you sure you want to reset all chat?",
},
},
Lang: {
Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language`
@@ -61,6 +76,9 @@ const en: LocaleType = {
tw: "繁體中文",
es: "Español",
it: "Italiano",
tr: "Türkçe",
jp: "日本語",
de: "Deutsch",
},
},
Avatar: "Avatar",
@@ -89,6 +107,11 @@ const en: LocaleType = {
ListCount: (builtin: number, custom: number) =>
`${builtin} built-in, ${custom} user-defined`,
Edit: "Edit",
Modal: {
Title: "Prompt List",
Add: "Add One",
Search: "Search Prompts",
},
},
HistoryCount: {
Title: "Attached Messages Count",
@@ -106,11 +129,12 @@ const en: LocaleType = {
},
Usage: {
Title: "Account Balance",
SubTitle(used: any) {
return `Used this month $${used}`;
SubTitle(used: any, total: any) {
return `Used this month $${used}, subscription $${total}`;
},
IsChecking: "Checking...",
Check: "Check Again",
Check: "Check",
NoAccess: "Enter API Key to check balance",
},
AccessCode: {
Title: "Access Code",

View File

@@ -19,6 +19,7 @@ const es: LocaleType = {
Copy: "Copiar",
Stop: "Detener",
Retry: "Reintentar",
Delete: "Delete",
},
Rename: "Renombrar chat",
Typing: "Escribiendo...",
@@ -35,15 +36,23 @@ const es: LocaleType = {
Title: "Todos los mensajes",
Copy: "Copiar todo",
Download: "Descargar",
MessageFromYou: "Mensaje de ti",
MessageFromChatGPT: "Mensaje de ChatGPT",
},
Memory: {
Title: "Historial de memoria",
EmptyContent: "Aún no hay nada.",
Copy: "Copiar todo",
Send: "Send Memory",
Reset: "Reset Session",
ResetConfirm:
"Resetting will clear the current conversation history and historical memory. Are you sure you want to reset?",
},
Home: {
NewChat: "Nuevo chat",
DeleteChat: "¿Confirmar eliminación de la conversación seleccionada?",
DeleteToast: "Chat Deleted",
Revert: "Revert",
},
Settings: {
Title: "Configuración",
@@ -52,6 +61,12 @@ const es: LocaleType = {
ClearAll: "Borrar todos los datos",
ResetAll: "Restablecer todas las configuraciones",
Close: "Cerrar",
ConfirmResetAll: {
Confirm: "Are you sure you want to reset all configurations?",
},
ConfirmClearAll: {
Confirm: "Are you sure you want to reset all chat?",
},
},
Lang: {
Name: "Language",
@@ -61,6 +76,9 @@ const es: LocaleType = {
tw: "繁體中文",
es: "Español",
it: "Italiano",
tr: "Türkçe",
jp: "日本語",
de: "Deutsch",
},
},
Avatar: "Avatar",
@@ -89,6 +107,11 @@ const es: LocaleType = {
ListCount: (builtin: number, custom: number) =>
`${builtin} incorporado, ${custom} definido por el usuario`,
Edit: "Editar",
Modal: {
Title: "Prompt List",
Add: "Add One",
Search: "Search Prompts",
},
},
HistoryCount: {
Title: "Cantidad de mensajes adjuntos",
@@ -106,11 +129,12 @@ const es: LocaleType = {
},
Usage: {
Title: "Saldo de la cuenta",
SubTitle(used: any) {
return `Usado $${used}`;
SubTitle(used: any, total: any) {
return `Usado $${used}, subscription $${total}`;
},
IsChecking: "Comprobando...",
Check: "Comprobar de nuevo",
NoAccess: "Introduzca la clave API para comprobar el saldo",
},
AccessCode: {
Title: "Código de acceso",

View File

@@ -3,10 +3,22 @@ import EN from "./en";
import TW from "./tw";
import ES from "./es";
import IT from "./it";
import TR from "./tr";
import JP from "./jp";
import DE from "./de";
export type { LocaleType } from "./cn";
export const AllLangs = ["en", "cn", "tw", "es", "it"] as const;
export const AllLangs = [
"en",
"cn",
"tw",
"es",
"it",
"tr",
"jp",
"de",
] as const;
type Lang = (typeof AllLangs)[number];
const LANG_KEY = "lang";
@@ -42,17 +54,13 @@ export function getLang(): Lang {
const lang = getLanguage();
if (lang.includes("zh") || lang.includes("cn")) {
return "cn";
} else if (lang.includes("tw")) {
return "tw";
} else if (lang.includes("es")) {
return "es";
} else if (lang.includes("it")) {
return "it";
} else {
return "en";
for (const option of AllLangs) {
if (lang.includes(option)) {
return option;
}
}
return "en";
}
export function changeLang(lang: Lang) {
@@ -60,4 +68,13 @@ export function changeLang(lang: Lang) {
location.reload();
}
export default { en: EN, cn: CN, tw: TW, es: ES, it: IT }[getLang()];
export default {
en: EN,
cn: CN,
tw: TW,
es: ES,
it: IT,
tr: TR,
jp: JP,
de: DE,
}[getLang()] as typeof CN;

View File

@@ -19,6 +19,7 @@ const it: LocaleType = {
Copy: "Copia",
Stop: "Stop",
Retry: "Riprova",
Delete: "Delete",
},
Rename: "Rinomina Chat",
Typing: "Typing…",
@@ -35,15 +36,23 @@ const it: LocaleType = {
Title: "Tutti i messaggi",
Copy: "Copia tutto",
Download: "Scarica",
MessageFromYou: "Messaggio da te",
MessageFromChatGPT: "Messaggio da ChatGPT",
},
Memory: {
Title: "Prompt di memoria",
EmptyContent: "Vuoto.",
Copy: "Copia tutto",
Send: "Send Memory",
Reset: "Reset Session",
ResetConfirm:
"Ripristinare cancellerà la conversazione corrente e la cronologia di memoria. Sei sicuro che vuoi riavviare?",
},
Home: {
NewChat: "Nuova Chat",
DeleteChat: "Confermare la cancellazione della conversazione selezionata?",
DeleteToast: "Chat Cancellata",
Revert: "Revert",
},
Settings: {
Title: "Impostazioni",
@@ -52,6 +61,12 @@ const it: LocaleType = {
ClearAll: "Cancella tutti i dati",
ResetAll: "Resetta tutte le impostazioni",
Close: "Chiudi",
ConfirmResetAll: {
Confirm: "Sei sicuro vuoi cancellare tutte le impostazioni?",
},
ConfirmClearAll: {
Confirm: "Sei sicuro vuoi cancellare tutte le chat?",
},
},
Lang: {
Name: "Lingue",
@@ -61,6 +76,9 @@ const it: LocaleType = {
tw: "繁體中文",
es: "Español",
it: "Italiano",
tr: "Türkçe",
jp: "日本語",
de: "Deutsch",
},
},
Avatar: "Avatar",
@@ -77,9 +95,9 @@ const it: LocaleType = {
GoToUpdate: "Aggiorna",
},
SendKey: "Tasto invia",
Theme: "tema",
TightBorder: "Bordi stretti",
SendPreviewBubble: "Invia l'anteprima della bolla",
Theme: "Tema",
TightBorder: "Schermo intero",
SendPreviewBubble: "Anteprima di digitazione",
Prompt: {
Disable: {
Title: "Disabilita l'auto completamento",
@@ -89,6 +107,11 @@ const it: LocaleType = {
ListCount: (builtin: number, custom: number) =>
`${builtin} built-in, ${custom} user-defined`,
Edit: "Modifica",
Modal: {
Title: "Prompt List",
Add: "Add One",
Search: "Search Prompts",
},
},
HistoryCount: {
Title: "Conteggio dei messaggi allegati",
@@ -100,18 +123,19 @@ const it: LocaleType = {
"Comprimerà se la lunghezza dei messaggi non compressi supera il valore",
},
Token: {
Title: "Chiave API",
Title: "API Key",
SubTitle:
"Utilizzare la chiave per ignorare il limite del codice di accesso",
Placeholder: "OpenAI API Key",
},
Usage: {
Title: "Bilancio Account",
SubTitle(used: any) {
return `Usato in questo mese $${used}`;
SubTitle(used: any, total: any) {
return `Attualmente usato in questo mese $${used}, soglia massima $${total}`;
},
IsChecking: "Controllando...",
Check: "Controlla ancora",
NoAccess: "Inserire la chiave API per controllare il saldo",
},
AccessCode: {
Title: "Codice d'accesso",

187
app/locales/jp.ts Normal file
View File

@@ -0,0 +1,187 @@
import { SubmitKey } from "../store/app";
const jp = {
WIP: "この機能は開発中です……",
Error: {
Unauthorized:
"現在は未承認状態です。左下の設定ボタンをクリックし、アクセスパスワードを入力してください。",
},
ChatItem: {
ChatItemCount: (count: number) => `${count} 通のチャット`,
},
Chat: {
SubTitle: (count: number) => `ChatGPTとの ${count} 通のチャット`,
Actions: {
ChatList: "メッセージリストを表示",
CompressedHistory: "圧縮された履歴プロンプトを表示",
Export: "チャット履歴をエクスポート",
Copy: "コピー",
Stop: "停止",
Retry: "リトライ",
Delete: "Delete",
},
Rename: "チャットの名前を変更",
Typing: "入力中…",
Input: (submitKey: string) => {
var inputHints = `${submitKey} で送信`;
if (submitKey === String(SubmitKey.Enter)) {
inputHints += "Shift + Enter で改行";
}
return inputHints + "/ で自動補完をトリガー";
},
Send: "送信",
},
Export: {
Title: "チャット履歴をMarkdown形式でエクスポート",
Copy: "すべてコピー",
Download: "ファイルをダウンロード",
MessageFromYou: "あなたからのメッセージ",
MessageFromChatGPT: "ChatGPTからのメッセージ",
},
Memory: {
Title: "履歴メモリ",
EmptyContent: "まだ記憶されていません",
Send: "メモリを送信",
Copy: "メモリをコピー",
Reset: "チャットをリセット",
ResetConfirm:
"リセット後、現在のチャット履歴と過去のメモリがクリアされます。リセットしてもよろしいですか?",
},
Home: {
NewChat: "新しいチャット",
DeleteChat: "選択したチャットを削除してもよろしいですか?",
DeleteToast: "チャットが削除されました",
Revert: "元に戻す",
},
Settings: {
Title: "設定",
SubTitle: "設定オプション",
Actions: {
ClearAll: "すべてのデータをクリア",
ResetAll: "すべてのオプションをリセット",
Close: "閉じる",
ConfirmResetAll: {
Confirm: "すべての設定をリセットしてもよろしいですか?",
},
ConfirmClearAll: {
Confirm: "すべてのチャットをリセットしてもよろしいですか?",
},
},
Lang: {
Name: "Language",
Options: {
cn: "简体中文",
en: "English",
tw: "繁體中文",
es: "Español",
it: "Italiano",
tr: "Türkçe",
jp: "日本語",
de: "Deutsch",
},
},
Avatar: "アバター",
FontSize: {
Title: "フォントサイズ",
SubTitle: "チャット内容のフォントサイズ",
},
Update: {
Version: (x: string) => `現在のバージョン:${x}`,
IsLatest: "最新バージョンです",
CheckUpdate: "アップデートを確認",
IsChecking: "アップデートを確認しています...",
FoundUpdate: (x: string) => `新しいバージョンが見つかりました:${x}`,
GoToUpdate: "更新する",
},
SendKey: "送信キー",
Theme: "テーマ",
TightBorder: "ボーダーレスモード",
SendPreviewBubble: "プレビューバブルの送信",
Prompt: {
Disable: {
Title: "プロンプトの自動補完を無効にする",
SubTitle:
"入力フィールドの先頭に / を入力すると、自動補完がトリガーされます。",
},
List: "カスタムプロンプトリスト",
ListCount: (builtin: number, custom: number) =>
`組み込み ${builtin} 件、ユーザー定義 ${custom}`,
Edit: "編集",
Modal: {
Title: "提示词列表",
Add: "增加一条",
Search: "搜尋提示詞",
},
},
HistoryCount: {
Title: "履歴メッセージ数を添付",
SubTitle: "リクエストごとに添付する履歴メッセージ数",
},
CompressThreshold: {
Title: "履歴メッセージの長さ圧縮しきい値",
SubTitle:
"圧縮されていない履歴メッセージがこの値を超えた場合、圧縮が行われます。",
},
Token: {
Title: "APIキー",
SubTitle: "自分のキーを使用してパスワードアクセス制限を迂回する",
Placeholder: "OpenAI APIキー",
},
Usage: {
Title: "残高照会",
SubTitle(used: any, total: any) {
return `今月は $${used} を使用しました。総額は $${total} です。`;
},
IsChecking: "確認中...",
Check: "再確認",
NoAccess: "APIキーまたはアクセスパスワードを入力して残高を表示",
},
AccessCode: {
Title: "アクセスパスワード",
SubTitle: "暗号化アクセスが有効になっています",
Placeholder: "アクセスパスワードを入力してください",
},
Model: "モデル (model)",
Temperature: {
Title: "ランダム性 (temperature)",
SubTitle:
"値が大きいほど、回答がランダムになります。1以上の値には文字化けが含まれる可能性があります。",
},
MaxTokens: {
Title: "シングルレスポンス制限 (max_tokens)",
SubTitle: "1回のインタラクションで使用される最大トークン数",
},
PresencePenlty: {
Title: "トピックの新鮮度 (presence_penalty)",
SubTitle: "値が大きいほど、新しいトピックへの展開が可能になります。",
},
},
Store: {
DefaultTopic: "新しいチャット",
BotHello: "何かお手伝いできることはありますか",
Error: "エラーが発生しました。しばらくしてからやり直してください。",
Prompt: {
History: (content: string) =>
"これは、AI とユーザの過去のチャットを要約した前提となるストーリーです:" +
content,
Topic:
"45文字でこの文章の簡潔な主題を返してください。説明、句読点、感嘆詞、余分なテキストは無しで。もし主題がない場合は、「おしゃべり」を返してください",
Summarize:
"あなたとユーザの会話を簡潔にまとめて、後続のコンテキストプロンプトとして使ってください。200字以内に抑えてください。",
},
ConfirmClearAll:
"すべてのチャット、設定データをクリアしてもよろしいですか?",
},
Copy: {
Success: "クリップボードに書き込みました",
Failed: "コピーに失敗しました。クリップボード許可を与えてください。",
},
Context: {
Toast: (x: any) => `前置コンテキストが ${x} 件設定されました`,
Edit: "前置コンテキストと履歴メモリ",
Add: "新規追加",
},
};
export default jp;

188
app/locales/tr.ts Normal file
View File

@@ -0,0 +1,188 @@
import { SubmitKey } from "../store/app";
import type { LocaleType } from "./index";
const tr: LocaleType = {
WIP: "Çalışma devam ediyor...",
Error: {
Unauthorized:
"Yetkisiz erişim, lütfen erişim kodunu ayarlar sayfasından giriniz.",
},
ChatItem: {
ChatItemCount: (count: number) => `${count} mesaj`,
},
Chat: {
SubTitle: (count: number) => `ChatGPT tarafından ${count} mesaj`,
Actions: {
ChatList: "Sohbet Listesine Git",
CompressedHistory: "Sıkıştırılmış Geçmiş Bellek Komutu",
Export: "Tüm Mesajları Markdown Olarak Dışa Aktar",
Copy: "Kopyala",
Stop: "Durdur",
Retry: "Tekrar Dene",
Delete: "Delete",
},
Rename: "Sohbeti Yeniden Adlandır",
Typing: "Yazıyor…",
Input: (submitKey: string) => {
var inputHints = `Göndermek için ${submitKey}`;
if (submitKey === String(SubmitKey.Enter)) {
inputHints += ", kaydırmak için Shift + Enter";
}
return inputHints + ", komutları aramak için / (eğik çizgi)";
},
Send: "Gönder",
},
Export: {
Title: "Tüm Mesajlar",
Copy: "Tümünü Kopyala",
Download: "İndir",
MessageFromYou: "Sizin Mesajınız",
MessageFromChatGPT: "ChatGPT'nin Mesajı",
},
Memory: {
Title: "Bellek Komutları",
EmptyContent: "Henüz değil.",
Send: "Belleği Gönder",
Copy: "Belleği Kopyala",
Reset: "Oturumu Sıfırla",
ResetConfirm:
"Sıfırlama, geçerli görüşme geçmişini ve geçmiş belleği siler. Sıfırlamak istediğinizden emin misiniz?",
},
Home: {
NewChat: "Yeni Sohbet",
DeleteChat: "Seçili sohbeti silmeyi onaylıyor musunuz?",
DeleteToast: "Sohbet Silindi",
Revert: "Geri Al",
},
Settings: {
Title: "Ayarlar",
SubTitle: "Tüm Ayarlar",
Actions: {
ClearAll: "Tüm Verileri Temizle",
ResetAll: "Tüm Ayarları Sıfırla",
Close: "Kapat",
ConfirmResetAll: {
Confirm: "Tüm ayarları sıfırlamak istediğinizden emin misiniz?",
},
ConfirmClearAll: {
Confirm: "Tüm sohbeti sıfırlamak istediğinizden emin misiniz?",
},
},
Lang: {
Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language`
Options: {
cn: "简体中文",
en: "English",
tw: "繁體中文",
es: "Español",
it: "Italiano",
tr: "Türkçe",
jp: "日本語",
de: "Deutsch",
},
},
Avatar: "Avatar",
FontSize: {
Title: "Yazı Boyutu",
SubTitle: "Sohbet içeriğinin yazı boyutunu ayarlayın",
},
Update: {
Version: (x: string) => `Sürüm: ${x}`,
IsLatest: "En son sürüm",
CheckUpdate: "Güncellemeyi Kontrol Et",
IsChecking: "Güncelleme kontrol ediliyor...",
FoundUpdate: (x: string) => `Yeni sürüm bulundu: ${x}`,
GoToUpdate: "Güncelle",
},
SendKey: "Gönder Tuşu",
Theme: "Tema",
TightBorder: "Tam Ekran",
SendPreviewBubble: "Mesaj Önizleme Balonu",
Prompt: {
Disable: {
Title: "Otomatik tamamlamayı devre dışı bırak",
SubTitle: "Otomatik tamamlamayı kullanmak için / (eğik çizgi) girin",
},
List: "Komut Listesi",
ListCount: (builtin: number, custom: number) =>
`${builtin} yerleşik, ${custom} kullanıcı tanımlı`,
Edit: "Düzenle",
Modal: {
Title: "Prompt List",
Add: "Add One",
Search: "Search Prompts",
},
},
HistoryCount: {
Title: "Ekli Mesaj Sayısı",
SubTitle: "İstek başına ekli gönderilen mesaj sayısı",
},
CompressThreshold: {
Title: "Geçmiş Sıkıştırma Eşiği",
SubTitle:
"Sıkıştırılmamış mesajların uzunluğu bu değeri aşarsa sıkıştırılır",
},
Token: {
Title: "API Anahtarı",
SubTitle: "Erişim kodu sınırını yoksaymak için anahtarınızı kullanın",
Placeholder: "OpenAI API Anahtarı",
},
Usage: {
Title: "Hesap Bakiyesi",
SubTitle(used: any, total: any) {
return `Bu ay kullanılan $${used}, abonelik $${total}`;
},
IsChecking: "Kontrol ediliyor...",
Check: "Tekrar Kontrol Et",
NoAccess: "Bakiyeyi kontrol etmek için API anahtarını girin",
},
AccessCode: {
Title: "Erişim Kodu",
SubTitle: "Erişim kontrolü etkinleştirme",
Placeholder: "Erişim Kodu Gerekiyor",
},
Model: "Model",
Temperature: {
Title: "Gerçeklik",
SubTitle:
"Daha büyük bir değer girildiğinde gerçeklik oranı düşer ve daha rastgele çıktılar üretir",
},
MaxTokens: {
Title: "Maksimum Belirteç",
SubTitle:
"Girdi belirteçlerinin ve oluşturulan belirteçlerin maksimum uzunluğu",
},
PresencePenlty: {
Title: "Varlık Cezası",
SubTitle:
"Daha büyük bir değer, yeni konular hakkında konuşma olasılığını artırır",
},
},
Store: {
DefaultTopic: "Yeni Konuşma",
BotHello: "Merhaba! Size bugün nasıl yardımcı olabilirim?",
Error: "Bir şeyler yanlış gitti. Lütfen daha sonra tekrar deneyiniz.",
Prompt: {
History: (content: string) =>
"Bu, yapay zeka ile kullanıcı arasındaki sohbet geçmişinin bir özetidir: " +
content,
Topic:
"Lütfen herhangi bir giriş, noktalama işareti, tırnak işareti, nokta, sembol veya ek metin olmadan konuşmamızı özetleyen dört ila beş kelimelik bir başlık oluşturun. Çevreleyen tırnak işaretlerini kaldırın.",
Summarize:
"Gelecekteki bağlam için bir bilgi istemi olarak kullanmak üzere tartışmamızı en fazla 200 kelimeyle özetleyin.",
},
ConfirmClearAll:
"Tüm sohbet ve ayar verilerini temizlemeyi onaylıyor musunuz?",
},
Copy: {
Success: "Panoya kopyalandı",
Failed: "Kopyalama başarısız oldu, lütfen panoya erişim izni verin",
},
Context: {
Toast: (x: any) => `${x} bağlamsal bellek komutu`,
Edit: "Bağlamsal ve Bellek Komutları",
Add: "Yeni Ekle",
},
};
export default tr;

View File

@@ -4,7 +4,7 @@ import type { LocaleType } from "./index";
const tw: LocaleType = {
WIP: "該功能仍在開發中……",
Error: {
Unauthorized: "目前您的狀態是未授權,請前往設定頁面填寫授權碼。",
Unauthorized: "目前您的狀態是未授權,請前往設定頁面輸入授權碼。",
},
ChatItem: {
ChatItemCount: (count: number) => `${count} 條對話`,
@@ -18,6 +18,7 @@ const tw: LocaleType = {
Copy: "複製",
Stop: "停止",
Retry: "重試",
Delete: "刪除",
},
Rename: "重命名對話",
Typing: "正在輸入…",
@@ -34,15 +35,22 @@ const tw: LocaleType = {
Title: "匯出聊天記錄為 Markdown",
Copy: "複製全部",
Download: "下載檔案",
MessageFromYou: "來自你的訊息",
MessageFromChatGPT: "來自 ChatGPT 的訊息",
},
Memory: {
Title: "上下文記憶 Prompt",
EmptyContent: "尚未記憶",
Copy: "複製全部",
Send: "發送記憶",
Reset: "重置對話",
ResetConfirm: "重置後將清空當前對話記錄以及歷史記憶,確認重置?",
},
Home: {
NewChat: "新的對話",
DeleteChat: "確定要刪除選取的對話嗎?",
DeleteToast: "已刪除對話",
Revert: "撤銷",
},
Settings: {
Title: "設定",
@@ -51,6 +59,12 @@ const tw: LocaleType = {
ClearAll: "清除所有數據",
ResetAll: "重置所有設定",
Close: "關閉",
ConfirmResetAll: {
Confirm: "Are you sure you want to reset all configurations?",
},
ConfirmClearAll: {
Confirm: "Are you sure you want to reset all chat?",
},
},
Lang: {
Name: "Language",
@@ -60,6 +74,9 @@ const tw: LocaleType = {
tw: "繁體中文",
es: "Español",
it: "Italiano",
tr: "Türkçe",
jp: "日本語",
de: "Deutsch",
},
},
Avatar: "大頭貼",
@@ -88,6 +105,11 @@ const tw: LocaleType = {
ListCount: (builtin: number, custom: number) =>
`內置 ${builtin} 條,用戶定義 ${custom}`,
Edit: "編輯",
Modal: {
Title: "提示詞列表",
Add: "增加一條",
Search: "搜索提示词",
},
},
HistoryCount: {
Title: "附帶歷史訊息數",
@@ -99,21 +121,22 @@ const tw: LocaleType = {
},
Token: {
Title: "API Key",
SubTitle: "使用自己的 Key 可規避受控訪問限制",
SubTitle: "使用自己的 Key 可規避授權訪問限制",
Placeholder: "OpenAI API Key",
},
Usage: {
Title: "帳戶餘額",
SubTitle(used: any) {
return `本月已使用 $${used}`;
SubTitle(used: any, total: any) {
return `本月已使用 $${used},订阅总额 $${total}`;
},
IsChecking: "正在檢查…",
Check: "重新檢查",
NoAccess: "輸入API Key查看餘額",
},
AccessCode: {
Title: "訪問碼",
SubTitle: "現在是受控訪問狀態",
Placeholder: "請輸入訪問碼",
Title: "授權碼",
SubTitle: "現在是未授權訪問狀態",
Placeholder: "請輸入授權碼",
},
Model: "模型 (model)",
Temperature: {
@@ -136,9 +159,10 @@ const tw: LocaleType = {
Prompt: {
History: (content: string) =>
"這是 AI 與用戶的歷史聊天總結,作為前情提要:" + content,
Topic: "直接返回這句話的簡要主題,無須解釋,若無主題,請直接返回「閒聊」",
Topic:
"Summarise the conversation in a short and concise eye-catching title that instantly conveys the main topic. Use as few words as possible. Use the language used in the enquiry, e.g. use English for English enquiry, use zh-hant for traditional chinese enquiry. Don't use quotation marks at the beginning and the end.",
Summarize:
"簡要總結一下你和用戶的對話,作為後續的上下文提示 prompt且字數控制在 200 字以內",
"Summarise the conversation in at most 250 tokens for continuing the conversation in future. Use the language used in the conversation, e.g. use English for English conversation, use zh-hant for traditional chinese conversation.",
},
ConfirmClearAll: "確認清除所有對話、設定數據?",
},

View File

@@ -2,11 +2,15 @@ import { Analytics } from "@vercel/analytics/react";
import { Home } from "./components/home";
export default function App() {
import { getServerSideConfig } from "./config/server";
const serverConfig = getServerSideConfig();
export default async function App() {
return (
<>
<Home />
<Analytics />
{serverConfig?.isVercel && <Analytics />}
</>
);
}

View File

@@ -1,9 +1,8 @@
import type { ChatRequest, ChatReponse } from "./api/openai/typing";
import { filterConfig, Message, ModelConfig, useAccessStore } from "./store";
import Locale from "./locales";
import type { ChatRequest, ChatResponse } from "./api/openai/typing";
import { Message, ModelConfig, useAccessStore, useChatStore } from "./store";
import { showToast } from "./components/ui-lib";
const TIME_OUT_MS = 30000;
const TIME_OUT_MS = 60000;
const makeRequestParam = (
messages: Message[],
@@ -21,10 +20,16 @@ const makeRequestParam = (
sendMessages = sendMessages.filter((m) => m.role !== "assistant");
}
const modelConfig = { ...useChatStore.getState().config.modelConfig };
// @yidadaa: wont send max_tokens, because it is nonsense for Muggles
// @ts-expect-error
delete modelConfig.max_tokens;
return {
model: "gpt-3.5-turbo",
messages: sendMessages,
stream: options?.stream,
...modelConfig,
};
};
@@ -45,11 +50,10 @@ function getHeaders() {
export function requestOpenaiClient(path: string) {
return (body: any, method = "POST") =>
fetch("/api/openai", {
fetch("/api/openai?_vercel_no_cache=1", {
method,
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-cache",
path,
...getHeaders(),
},
@@ -63,7 +67,7 @@ export async function requestChat(messages: Message[]) {
const res = await requestOpenaiClient("v1/chat/completions")(req);
try {
const response = (await res.json()) as ChatReponse;
const response = (await res.json()) as ChatResponse;
return response;
} catch (error) {
console.error("[Request Chat] ", error, res.body);
@@ -76,36 +80,48 @@ export async function requestUsage() {
.getDate()
.toString()
.padStart(2, "0")}`;
const ONE_DAY = 24 * 60 * 60 * 1000;
const ONE_DAY = 2 * 24 * 60 * 60 * 1000;
const now = new Date(Date.now() + ONE_DAY);
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const startDate = formatDate(startOfMonth);
const endDate = formatDate(now);
const res = await requestOpenaiClient(
`dashboard/billing/usage?start_date=${startDate}&end_date=${endDate}`,
)(null, "GET");
try {
const response = (await res.json()) as {
total_usage: number;
error?: {
type: string;
message: string;
};
const [used, subs] = await Promise.all([
requestOpenaiClient(
`dashboard/billing/usage?start_date=${startDate}&end_date=${endDate}`,
)(null, "GET"),
requestOpenaiClient("dashboard/billing/subscription")(null, "GET"),
]);
const response = (await used.json()) as {
total_usage?: number;
error?: {
type: string;
message: string;
};
};
if (response.error && response.error.type) {
showToast(response.error.message);
return;
}
const total = (await subs.json()) as {
hard_limit_usd?: number;
};
if (response.total_usage) {
response.total_usage = Math.round(response.total_usage) / 100;
}
return response.total_usage;
} catch (error) {
console.error("[Request usage] ", error, res.body);
if (response.error && response.error.type) {
showToast(response.error.message);
return;
}
if (response.total_usage) {
response.total_usage = Math.round(response.total_usage) / 100;
}
if (total.hard_limit_usd) {
total.hard_limit_usd = Math.round(total.hard_limit_usd * 100) / 100;
}
return {
used: response.total_usage,
subscription: total.hard_limit_usd,
};
}
export async function requestChatStream(
@@ -123,11 +139,6 @@ export async function requestChatStream(
filterBot: options?.filterBot,
});
// valid and assign model config
if (options?.modelConfig) {
Object.assign(req, filterConfig(options.modelConfig));
}
console.log("[Request] ", req);
const controller = new AbortController();
@@ -160,14 +171,18 @@ export async function requestChatStream(
options?.onController?.(controller);
while (true) {
// handle time out, will stop if no response in 10 secs
const resTimeoutId = setTimeout(() => finish(), TIME_OUT_MS);
const content = await reader?.read();
clearTimeout(resTimeoutId);
const text = decoder.decode(content?.value);
if (!content || !content.value) {
break;
}
const text = decoder.decode(content.value, { stream: true });
responseText += text;
const done = !content || content.done;
const done = content.done;
options?.onMessage(responseText, false);
if (done) {
@@ -177,8 +192,8 @@ export async function requestChatStream(
finish();
} else if (res.status === 401) {
console.error("Anauthorized");
options?.onError(new Error("Anauthorized"), res.status);
console.error("Unauthorized");
options?.onError(new Error("Unauthorized"), res.status);
} else {
console.error("Stream Error", res.body);
options?.onError(new Error("Stream Error"), res.status);
@@ -209,23 +224,30 @@ export const ControllerPool = {
addController(
sessionIndex: number,
messageIndex: number,
messageId: number,
controller: AbortController,
) {
const key = this.key(sessionIndex, messageIndex);
const key = this.key(sessionIndex, messageId);
this.controllers[key] = controller;
return key;
},
stop(sessionIndex: number, messageIndex: number) {
const key = this.key(sessionIndex, messageIndex);
stop(sessionIndex: number, messageId: number) {
const key = this.key(sessionIndex, messageId);
const controller = this.controllers[key];
console.log(controller);
controller?.abort();
},
remove(sessionIndex: number, messageIndex: number) {
const key = this.key(sessionIndex, messageIndex);
stopAll() {
Object.values(this.controllers).forEach((v) => v.abort());
},
hasPending() {
return Object.values(this.controllers).length > 0;
},
remove(sessionIndex: number, messageId: number) {
const key = this.key(sessionIndex, messageId);
delete this.controllers[key];
},

View File

@@ -1,25 +1,33 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { queryMeta } from "../utils";
export interface AccessControlStore {
accessCode: string;
token: string;
needCode: boolean;
updateToken: (_: string) => void;
updateCode: (_: string) => void;
enabledAccessControl: () => boolean;
isAuthorized: () => boolean;
fetch: () => void;
}
export const ACCESS_KEY = "access-control";
let fetchState = 0; // 0 not fetch, 1 fetching, 2 done
export const useAccessStore = create<AccessControlStore>()(
persist(
(set, get) => ({
token: "",
accessCode: "",
needCode: true,
enabledAccessControl() {
return queryMeta("access") === "enabled";
get().fetch();
return get().needCode;
},
updateCode(code: string) {
set((state) => ({ accessCode: code }));
@@ -27,10 +35,35 @@ export const useAccessStore = create<AccessControlStore>()(
updateToken(token: string) {
set((state) => ({ token }));
},
isAuthorized() {
// has token or has code or disabled access control
return (
!!get().token || !!get().accessCode || !get().enabledAccessControl()
);
},
fetch() {
if (fetchState > 0) return;
fetchState = 1;
fetch("/api/config", {
method: "post",
body: null,
})
.then((res) => res.json())
.then((res: DangerConfig) => {
console.log("[Config] got config from server", res);
set(() => ({ ...res }));
})
.catch(() => {
console.error("[Config] failed to fetch config");
})
.finally(() => {
fetchState = 2;
});
},
}),
{
name: ACCESS_KEY,
version: 1,
}
)
},
),
);

View File

@@ -7,17 +7,29 @@ import {
requestChatStream,
requestWithPrompt,
} from "../requests";
import { trimTopic } from "../utils";
import { isMobileScreen, trimTopic } from "../utils";
import Locale from "../locales";
import { showToast } from "../components/ui-lib";
export type Message = ChatCompletionResponseMessage & {
date: string;
streaming?: boolean;
isError?: boolean;
model?: string;
id?: number;
};
export function createMessage(override: Partial<Message>): Message {
return {
id: Date.now(),
date: new Date().toLocaleString(),
role: "user",
content: "",
...override,
};
}
export enum SubmitKey {
Enter = "Enter",
CtrlEnter = "Ctrl + Enter",
@@ -42,6 +54,7 @@ export interface ChatConfig {
theme: Theme;
tightBorder: boolean;
sendPreviewBubble: boolean;
sidebarWidth: number;
disablePromptHint: boolean;
@@ -86,43 +99,39 @@ export const ALL_MODELS = [
},
];
export function isValidModel(name: string) {
return ALL_MODELS.some((m) => m.name === name && m.available);
export function limitNumber(
x: number,
min: number,
max: number,
defaultValue: number,
) {
if (typeof x !== "number" || isNaN(x)) {
return defaultValue;
}
return Math.min(max, Math.max(min, x));
}
export function isValidNumber(x: number, min: number, max: number) {
return typeof x === "number" && x <= max && x >= min;
export function limitModel(name: string) {
return ALL_MODELS.some((m) => m.name === name && m.available)
? name
: ALL_MODELS[4].name;
}
export function filterConfig(oldConfig: ModelConfig): Partial<ModelConfig> {
const config = Object.assign({}, oldConfig);
const validator: {
[k in keyof ModelConfig]: (x: ModelConfig[keyof ModelConfig]) => boolean;
} = {
model(x) {
return isValidModel(x as string);
},
max_tokens(x) {
return isValidNumber(x as number, 100, 32000);
},
presence_penalty(x) {
return isValidNumber(x as number, -2, 2);
},
temperature(x) {
return isValidNumber(x as number, 0, 2);
},
};
Object.keys(validator).forEach((k) => {
const key = k as keyof ModelConfig;
if (!validator[key](config[key])) {
delete config[key];
}
});
return config;
}
export const ModalConfigValidator = {
model(x: string) {
return limitModel(x);
},
max_tokens(x: number) {
return limitNumber(x, 0, 32000, 2000);
},
presence_penalty(x: number) {
return limitNumber(x, -2, 2, 0);
},
temperature(x: number) {
return limitNumber(x, 0, 2, 1);
},
};
const DEFAULT_CONFIG: ChatConfig = {
historyMessageCount: 4,
@@ -134,6 +143,7 @@ const DEFAULT_CONFIG: ChatConfig = {
theme: Theme.Auto as Theme,
tightBorder: false,
sendPreviewBubble: true,
sidebarWidth: 300,
disablePromptHint: false,
@@ -154,6 +164,7 @@ export interface ChatStat {
export interface ChatSession {
id: number;
topic: string;
sendMemory: boolean;
memoryPrompt: string;
context: Message[];
messages: Message[];
@@ -163,11 +174,10 @@ export interface ChatSession {
}
const DEFAULT_TOPIC = Locale.Store.DefaultTopic;
export const BOT_HELLO: Message = {
export const BOT_HELLO: Message = createMessage({
role: "assistant",
content: Locale.Store.BotHello,
date: "",
};
});
function createEmptySession(): ChatSession {
const createDate = new Date().toLocaleString();
@@ -175,6 +185,7 @@ function createEmptySession(): ChatSession {
return {
id: Date.now(),
topic: DEFAULT_TOPIC,
sendMemory: true,
memoryPrompt: "",
context: [],
messages: [],
@@ -194,8 +205,10 @@ interface ChatStore {
currentSessionIndex: number;
clearSessions: () => void;
removeSession: (index: number) => void;
moveSession: (from: number, to: number) => void;
selectSession: (index: number) => void;
newSession: () => void;
deleteSession: (index?: number) => void;
currentSession: () => ChatSession;
onNewMessage: (message: Message) => void;
onUserInput: (content: string) => Promise<void>;
@@ -207,6 +220,7 @@ interface ChatStore {
messageIndex: number,
updater: (message?: Message) => void,
) => void;
resetSession: () => void;
getMessagesWithMemory: () => Message[];
getMemoryPrompt: () => Message;
@@ -283,6 +297,31 @@ export const useChatStore = create<ChatStore>()(
});
},
moveSession(from: number, to: number) {
set((state) => {
const { sessions, currentSessionIndex: oldIndex } = state;
// move the session
const newSessions = [...sessions];
const session = newSessions[from];
newSessions.splice(from, 1);
newSessions.splice(to, 0, session);
// modify current session id
let newIndex = oldIndex === from ? to : oldIndex;
if (oldIndex > from && oldIndex <= to) {
newIndex -= 1;
} else if (oldIndex < from && oldIndex >= to) {
newIndex += 1;
}
return {
currentSessionIndex: newIndex,
sessions: newSessions,
};
});
},
newSession() {
set((state) => ({
currentSessionIndex: 0,
@@ -290,6 +329,33 @@ export const useChatStore = create<ChatStore>()(
}));
},
deleteSession(i?: number) {
const deletedSession = get().currentSession();
const index = i ?? get().currentSessionIndex;
const isLastSession = get().sessions.length === 1;
if (!isMobileScreen() || confirm(Locale.Home.DeleteChat)) {
get().removeSession(index);
showToast(
Locale.Home.DeleteToast,
{
text: Locale.Home.Revert,
onClick() {
set((state) => ({
sessions: state.sessions
.slice(0, index)
.concat([deletedSession])
.concat(
state.sessions.slice(index + Number(isLastSession)),
),
}));
},
},
5000,
);
}
},
currentSession() {
let index = get().currentSessionIndex;
const sessions = get().sessions;
@@ -313,19 +379,17 @@ export const useChatStore = create<ChatStore>()(
},
async onUserInput(content) {
const userMessage: Message = {
const userMessage: Message = createMessage({
role: "user",
content,
date: new Date().toLocaleString(),
};
});
const botMessage: Message = {
content: "",
const botMessage: Message = createMessage({
role: "assistant",
date: new Date().toLocaleString(),
streaming: true,
model: get().config.modelConfig.model,
};
id: userMessage.id! + 1,
});
// get recent messages
const recentMessages = get().getMessagesWithMemory();
@@ -348,7 +412,10 @@ export const useChatStore = create<ChatStore>()(
botMessage.streaming = false;
botMessage.content = content;
get().onNewMessage(botMessage);
ControllerPool.remove(sessionIndex, messageIndex);
ControllerPool.remove(
sessionIndex,
botMessage.id ?? messageIndex,
);
} else {
botMessage.content = content;
set(() => ({}));
@@ -357,20 +424,20 @@ export const useChatStore = create<ChatStore>()(
onError(error, statusCode) {
if (statusCode === 401) {
botMessage.content = Locale.Error.Unauthorized;
} else {
} else if (!error.message.includes("aborted")) {
botMessage.content += "\n\n" + Locale.Store.Error;
}
botMessage.streaming = false;
userMessage.isError = true;
botMessage.isError = true;
set(() => ({}));
ControllerPool.remove(sessionIndex, messageIndex);
ControllerPool.remove(sessionIndex, botMessage.id ?? messageIndex);
},
onController(controller) {
// collect controller for stop/retry
ControllerPool.addController(
sessionIndex,
messageIndex,
botMessage.id ?? messageIndex,
controller,
);
},
@@ -397,7 +464,11 @@ export const useChatStore = create<ChatStore>()(
const context = session.context.slice();
if (session.memoryPrompt && session.memoryPrompt.length > 0) {
if (
session.sendMemory &&
session.memoryPrompt &&
session.memoryPrompt.length > 0
) {
const memoryPrompt = get().getMemoryPrompt();
context.push(memoryPrompt);
}
@@ -421,6 +492,13 @@ export const useChatStore = create<ChatStore>()(
set(() => ({ sessions }));
},
resetSession() {
get().updateCurrentSession((session) => {
session.messages = [];
session.memoryPrompt = "";
});
},
summarizeSession() {
const session = get().currentSession();
@@ -433,7 +511,8 @@ export const useChatStore = create<ChatStore>()(
requestWithPrompt(session.messages, Locale.Store.Prompt.Topic).then(
(res) => {
get().updateCurrentSession(
(session) => (session.topic = trimTopic(res)),
(session) =>
(session.topic = res ? trimTopic(res) : DEFAULT_TOPIC),
);
},
);
@@ -512,7 +591,7 @@ export const useChatStore = create<ChatStore>()(
}),
{
name: LOCAL_KEY,
version: 1.1,
version: 1.2,
migrate(persistedState, version) {
const state = persistedState as ChatStore;
@@ -520,6 +599,10 @@ export const useChatStore = create<ChatStore>()(
state.sessions.forEach((s) => (s.context = []));
}
if (version < 1.2) {
state.sessions.forEach((s) => (s.sendMemory = true));
}
return state;
},
},

View File

@@ -1,63 +1,78 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import Fuse from "fuse.js";
import { getLang } from "../locales";
export interface Prompt {
id?: number;
isUser?: boolean;
title: string;
content: string;
}
export interface PromptStore {
counter: number;
latestId: number;
prompts: Map<number, Prompt>;
prompts: Record<number, Prompt>;
add: (prompt: Prompt) => number;
remove: (id: number) => void;
search: (text: string) => Prompt[];
getUserPrompts: () => Prompt[];
updateUserPrompts: (id: number, updater: (prompt: Prompt) => void) => void;
}
export const PROMPT_KEY = "prompt-store";
export const SearchService = {
ready: false,
engine: new Fuse<Prompt>([], { keys: ["title"] }),
builtinEngine: new Fuse<Prompt>([], { keys: ["title"] }),
userEngine: new Fuse<Prompt>([], { keys: ["title"] }),
count: {
builtin: 0,
},
allPrompts: [] as Prompt[],
builtinPrompts: [] as Prompt[],
init(prompts: Prompt[]) {
init(builtinPrompts: Prompt[], userPrompts: Prompt[]) {
if (this.ready) {
return;
}
this.engine.setCollection(prompts);
this.allPrompts = userPrompts.concat(builtinPrompts);
this.builtinPrompts = builtinPrompts.slice();
this.builtinEngine.setCollection(builtinPrompts);
this.userEngine.setCollection(userPrompts);
this.ready = true;
},
remove(id: number) {
this.engine.remove((doc) => doc.id === id);
this.userEngine.remove((doc) => doc.id === id);
},
add(prompt: Prompt) {
this.engine.add(prompt);
this.userEngine.add(prompt);
},
search(text: string) {
const results = this.engine.search(text);
return results.map((v) => v.item);
const userResults = this.userEngine.search(text);
const builtinResults = this.builtinEngine.search(text);
return userResults.concat(builtinResults).map((v) => v.item);
},
};
export const usePromptStore = create<PromptStore>()(
persist(
(set, get) => ({
counter: 0,
latestId: 0,
prompts: new Map(),
prompts: {},
add(prompt) {
const prompts = get().prompts;
prompt.id = get().latestId + 1;
prompts.set(prompt.id, prompt);
prompt.isUser = true;
prompts[prompt.id] = prompt;
set(() => ({
latestId: prompt.id!,
@@ -69,15 +84,41 @@ export const usePromptStore = create<PromptStore>()(
remove(id) {
const prompts = get().prompts;
prompts.delete(id);
delete prompts[id];
SearchService.remove(id);
set(() => ({
prompts,
counter: get().counter + 1,
}));
},
getUserPrompts() {
const userPrompts = Object.values(get().prompts ?? {});
userPrompts.sort((a, b) => (b.id && a.id ? b.id - a.id : 0));
return userPrompts;
},
updateUserPrompts(id: number, updater) {
const prompt = get().prompts[id] ?? {
title: "",
content: "",
id,
};
SearchService.remove(id);
updater(prompt);
const prompts = get().prompts;
prompts[id] = prompt;
set(() => ({ prompts }));
SearchService.add(prompt);
},
search(text) {
if (text.length === 0) {
// return all rompts
return SearchService.allPrompts.concat([...get().getUserPrompts()]);
}
return SearchService.search(text) as Prompt[];
},
}),
@@ -92,24 +133,31 @@ export const usePromptStore = create<PromptStore>()(
fetch(PROMPT_URL)
.then((res) => res.json())
.then((res) => {
const builtinPrompts = [res.en, res.cn]
.map((promptList: PromptList) => {
let fetchPrompts = [res.en, res.cn];
if (getLang() === "cn") {
fetchPrompts = fetchPrompts.reverse();
}
const builtinPrompts = fetchPrompts.map(
(promptList: PromptList) => {
return promptList.map(
([title, content]) =>
({
id: Math.random(),
title,
content,
} as Prompt),
);
})
.concat([...(state?.prompts?.values() ?? [])]);
const allPromptsForSearch = builtinPrompts.reduce(
(pre, cur) => pre.concat(cur),
[],
},
);
const userPrompts =
usePromptStore.getState().getUserPrompts() ?? [];
const allPromptsForSearch = builtinPrompts
.reduce((pre, cur) => pre.concat(cur), [])
.filter((v) => !!v.title && !!v.content);
SearchService.count.builtin = res.en.length + res.cn.length;
SearchService.init(allPromptsForSearch);
SearchService.init(allPromptsForSearch, userPrompts);
});
},
},

View File

@@ -1,29 +1,58 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { FETCH_COMMIT_URL, FETCH_TAG_URL } from "../constant";
import { getCurrentVersion } from "../utils";
import { requestUsage } from "../requests";
export interface UpdateStore {
lastUpdate: number;
remoteId: string;
remoteVersion: string;
getLatestCommitId: (force: boolean) => Promise<string>;
used?: number;
subscription?: number;
lastUpdateUsage: number;
version: string;
getLatestVersion: (force?: boolean) => Promise<void>;
updateUsage: (force?: boolean) => Promise<void>;
}
export const UPDATE_KEY = "chat-update";
function queryMeta(key: string, defaultValue?: string): string {
let ret: string;
if (document) {
const meta = document.head.querySelector(
`meta[name='${key}']`,
) as HTMLMetaElement;
ret = meta?.content ?? "";
} else {
ret = defaultValue ?? "";
}
return ret;
}
const ONE_MINUTE = 60 * 1000;
export const useUpdateStore = create<UpdateStore>()(
persist(
(set, get) => ({
lastUpdate: 0,
remoteId: "",
remoteVersion: "",
async getLatestCommitId(force = false) {
const overTenMins = Date.now() - get().lastUpdate > 10 * 60 * 1000;
const shouldFetch = force || overTenMins;
if (!shouldFetch) {
return getCurrentVersion();
}
lastUpdateUsage: 0,
version: "unknown",
async getLatestVersion(force = false) {
set(() => ({ version: queryMeta("version") ?? "unknown" }));
const overTenMins = Date.now() - get().lastUpdate > 10 * ONE_MINUTE;
if (!force && !overTenMins) return;
set(() => ({
lastUpdate: Date.now(),
}));
try {
// const data = await (await fetch(FETCH_TAG_URL)).json();
@@ -31,14 +60,26 @@ export const useUpdateStore = create<UpdateStore>()(
const data = await (await fetch(FETCH_COMMIT_URL)).json();
const remoteId = (data[0].sha as string).substring(0, 7);
set(() => ({
lastUpdate: Date.now(),
remoteId,
remoteVersion: remoteId,
}));
console.log("[Got Upstream] ", remoteId);
return remoteId;
} catch (error) {
console.error("[Fetch Upstream Commit Id]", error);
return getCurrentVersion();
}
},
async updateUsage(force = false) {
const overOneMinute = Date.now() - get().lastUpdateUsage >= ONE_MINUTE;
if (!overOneMinute && !force) return;
set(() => ({
lastUpdateUsage: Date.now(),
}));
const usage = await requestUsage();
if (usage) {
set(() => usage);
}
},
}),

View File

@@ -1,4 +1,6 @@
@mixin light {
--theme: light;
/* color */
--white: white;
--black: rgb(48, 48, 48);
@@ -18,6 +20,8 @@
}
@mixin dark {
--theme: dark;
/* color */
--white: rgb(30, 30, 30);
--black: rgb(187, 187, 187);
@@ -31,6 +35,10 @@
--border-in-light: 1px solid rgba(255, 255, 255, 0.192);
--theme-color: var(--gray);
div:not(.no-dark) > svg {
filter: invert(0.5);
}
}
.light {
@@ -126,8 +134,13 @@ select {
text-align: center;
}
label {
cursor: pointer;
}
input {
text-align: center;
font-family: inherit;
}
input[type="checkbox"] {
@@ -155,19 +168,11 @@ input[type="checkbox"]:checked::after {
input[type="range"] {
appearance: none;
border: var(--border-in-light);
border-radius: 10px;
padding: 5px 15px 5px 10px;
background-color: var(--white);
color: var(--black);
&::before {
content: attr(value);
font-size: 12px;
}
}
input[type="range"]::-webkit-slider-thumb {
@mixin thumb() {
appearance: none;
height: 8px;
width: 20px;
@@ -176,15 +181,41 @@ input[type="range"]::-webkit-slider-thumb {
cursor: pointer;
transition: all ease 0.3s;
margin-left: 5px;
border: none;
}
input[type="range"]::-webkit-slider-thumb:hover {
input[type="range"]::-webkit-slider-thumb {
@include thumb();
}
input[type="range"]::-moz-range-thumb {
@include thumb();
}
input[type="range"]::-ms-thumb {
@include thumb();
}
@mixin thumbHover() {
transform: scaleY(1.2);
width: 24px;
}
input[type="range"]::-webkit-slider-thumb:hover {
@include thumbHover();
}
input[type="range"]::-moz-range-thumb:hover {
@include thumbHover();
}
input[type="range"]::-ms-thumb:hover {
@include thumbHover();
}
input[type="number"],
input[type="text"] {
input[type="text"],
input[type="password"] {
appearance: none;
border-radius: 10px;
border: var(--border-in-light);
@@ -194,6 +225,7 @@ input[type="text"] {
color: var(--black);
padding: 0 10px;
max-width: 50%;
font-family: inherit;
}
div.math {
@@ -260,10 +292,6 @@ pre {
.clickable {
cursor: pointer;
div:not(.no-dark) > svg {
filter: invert(0.5);
}
&:hover {
filter: brightness(0.9);
}

View File

@@ -1,22 +1,28 @@
import { EmojiStyle } from "emoji-picker-react";
import { showToast } from "./components/ui-lib";
import Locale from "./locales";
export function trimTopic(topic: string) {
return topic.replace(/[,。!?、,.!?]*$/, "");
return topic.replace(/[,。!?”“"、,.!?]*$/, "");
}
export async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
} catch (error) {
const textarea = document.createElement("textarea");
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
} finally {
showToast(Locale.Copy.Success);
} catch (error) {
const textArea = document.createElement("textarea");
textArea.value = text;
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand("copy");
showToast(Locale.Copy.Success);
} catch (error) {
showToast(Locale.Copy.Failed);
}
document.body.removeChild(textArea);
}
}
@@ -45,6 +51,12 @@ export function isMobileScreen() {
return window.innerWidth <= 600;
}
export function isFirefox() {
return (
typeof navigator !== "undefined" && /firefox/i.test(navigator.userAgent)
);
}
export function selectOrCopy(el: HTMLElement, content: string) {
const currentSelection = window.getSelection();
@@ -57,27 +69,58 @@ export function selectOrCopy(el: HTMLElement, content: string) {
return true;
}
export function queryMeta(key: string, defaultValue?: string): string {
let ret: string;
if (document) {
const meta = document.head.querySelector(
`meta[name='${key}']`,
) as HTMLMetaElement;
ret = meta?.content ?? "";
} else {
ret = defaultValue ?? "";
}
return ret;
export function getEmojiUrl(unified: string, style: EmojiStyle) {
return `https://cdn.staticfile.org/emoji-datasource-apple/14.0.0/img/${style}/64/${unified}.png`;
}
let currentId: string;
export function getCurrentVersion() {
if (currentId) {
return currentId;
function getDomContentWidth(dom: HTMLElement) {
const style = window.getComputedStyle(dom);
const paddingWidth =
parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
const width = dom.clientWidth - paddingWidth;
return width;
}
function getOrCreateMeasureDom(id: string, init?: (dom: HTMLElement) => void) {
let dom = document.getElementById(id);
if (!dom) {
dom = document.createElement("span");
dom.style.position = "absolute";
dom.style.wordBreak = "break-word";
dom.style.fontSize = "14px";
dom.style.transform = "translateY(-200vh)";
dom.style.pointerEvents = "none";
dom.style.opacity = "0";
dom.id = id;
document.body.appendChild(dom);
init?.(dom);
}
currentId = queryMeta("version");
return currentId;
return dom!;
}
export function autoGrowTextArea(dom: HTMLTextAreaElement) {
const measureDom = getOrCreateMeasureDom("__measure");
const singleLineDom = getOrCreateMeasureDom("__single_measure", (dom) => {
dom.innerText = "TEXT_FOR_MEASURE";
});
const width = getDomContentWidth(dom);
measureDom.style.width = width + "px";
measureDom.innerHTML = dom.value.trim().length > 0 ? dom.value : "1";
const lineWrapCount = Math.max(0, dom.value.split("\n").length - 1);
const height = parseFloat(window.getComputedStyle(measureDom).height);
const singleLineHeight = parseFloat(
window.getComputedStyle(singleLineDom).height,
);
const rows = Math.round(height / singleLineHeight) + lineWrapCount;
return rows;
}
export function getCSSVar(varName: string) {
return getComputedStyle(document.body).getPropertyValue(varName).trim();
}