Compare commits

..

75 Commits

Author SHA1 Message Date
Yifei Zhang
5843303076 Update README.md 2023-04-03 03:26:30 +08:00
Yifei Zhang
e2c1475857 revert: fix mobile scroll 2023-04-02 19:24:29 +00:00
Yifei Zhang
8d34b0f454 chore: fix mobile scroll 2023-04-02 19:20:57 +00:00
Yifei Zhang
b44caeeefb fix: #289 #367 #353 #369 provide more error message info 2023-04-02 19:14:53 +00:00
Yifei Zhang
8d60a414f0 chore: fix usage display 2023-04-02 18:55:08 +00:00
Yifei Zhang
e8d71c815e chore: fix preview bubble 2023-04-02 18:51:37 +00:00
Yifei Zhang
1afca0b28a fix: mobile scroll problem 2023-04-02 18:41:25 +00:00
Yifei Zhang
f52bcc2a37 chore: update readme 2023-04-02 18:29:07 +00:00
Yifei Zhang
2647bdb4ed fixup: ux improve 2023-04-02 18:28:07 +00:00
Yifei Zhang
e8dd391ccf Merge pull request #378 from Yidadaa/prompt-edit
feat: add context prompt edit
2023-04-03 02:03:54 +08:00
Yifei Zhang
e3c3cd3d18 fixup: translation context 2023-04-02 18:02:03 +00:00
Yifei Zhang
b05b96e3a4 Merge pull request #376 from cyhhao/patch-2
translate SendPreviewBubble
2023-04-03 01:50:48 +08:00
Yifei Zhang
b85245e317 feat: #138 add context prompt, close #330 #321 2023-04-02 17:48:43 +00:00
Call White
a10f4d8abc translate SendPreviewBubble 2023-04-03 01:07:06 +08:00
Yifei Zhang
c978de2c10 fix: #253 #356 auto scroll ux 2023-04-02 15:19:36 +00:00
Yifei Zhang
6c1862797b refactor: split homt.tsx components 2023-04-02 15:05:54 +00:00
Yifei Zhang
4f0108b0ea fix: #289 use highlight.js instead of prism 2023-04-02 14:48:18 +00:00
Yifei Zhang
7b5af271d5 fix: #367 failed to fetch account usage 2023-04-02 14:22:06 +00:00
Yifei Zhang
37587f6f71 fix: #244 optimize polyfill 2023-04-02 13:56:34 +00:00
Yifei Zhang
328a903c24 Merge branch 'main' into prompt-edit 2023-04-02 13:50:50 +00:00
Yifei Zhang
fdbdd33e77 Merge pull request #355 from cesaryuan/patch-1
fix: fix history message count
2023-04-02 21:42:46 +08:00
Cesaryuan
a356ee857c Merge branch 'main' into patch-1 2023-04-02 21:39:03 +08:00
Yifei Zhang
cf6f09b7b8 Merge pull request #360 from MapleGu/main
🐞 fix(locales): Fix the missing SendPreviewBubble in ES configuration
2023-04-02 21:22:02 +08:00
MapleUncle
a90e646381 🐞 fix(locales): Fix the missing SendPreviewBubble in ES configuration 2023-04-02 20:38:14 +08:00
Yifei Zhang
16028795f9 fix: #203 pwa installation problem 2023-04-02 12:28:18 +00:00
Cesaryuan
12f342f015 fix: historyMessageCount 2023-04-02 20:23:56 +08:00
Yifei Zhang
e248e9196a Merge pull request #271 from RugerMcCarthy/feat/send_preview_option
feat: add switch of send preview bubble
2023-04-02 20:08:35 +08:00
Cesaryuan
fea4f561b4 fix: fix history message count
Bug: The length of `new Array(20).slice(20 - 24) ` is 4 which should be 24.
2023-04-02 19:43:11 +08:00
Yifei Zhang
d226090926 Merge pull request #346 from AprilNEA/reset
The Clear Data button on the Settings page is only clear for all dialog data.
2023-04-02 18:36:51 +08:00
Yifei Zhang
2d534bfdf4 Merge pull request #347 from SadPencil/patch-1
Fix typos
2023-04-02 18:34:16 +08:00
Sad Pencil
ed5cd11d6a Fix typos 2023-04-02 14:23:12 +08:00
AprilNEA
0a60a87c9f Merge branch 'main' into reset
# Conflicts:
#	app/components/settings.tsx
2023-04-02 13:45:34 +08:00
AprilNEA
506cdbc83c feat: clear session only 2023-04-02 13:42:47 +08:00
Yifei Zhang
a64c4384b1 Merge pull request #322 from quark-zju/wexin-compat
fix: 微信 Android 内置浏览器兼容性
2023-04-02 13:17:56 +08:00
Yifei Zhang
d54c983187 Merge pull request #333 from qirong77/main
fix:修复中文输入法下enter错误发送消息问题
2023-04-02 13:12:42 +08:00
Jun Wu
cd5f8f7407 app: polyfill Array.at
This fixes compatibility issue with older browsers like WeChat webview.
The summary feature now works as expected.
2023-04-01 11:38:52 -07:00
linqirong
00a282214e fix:修复中文输入法下enter错误发送消息问题 2023-04-02 00:12:31 +08:00
Yifei Zhang
44145f11db Merge pull request #324 from yorunning/main
ci: update sync action
2023-04-01 23:25:59 +08:00
Yorun
ad63b10aea ci: update sync action 2023-04-01 11:52:10 +00:00
Jun Wu
327ac765df utils: simplify trimTopic
Also avoid using Array.prototype.at, which does not seem to exist
in the Wexin builtin webview (Android Wexin 8.0.30).
2023-04-01 03:37:28 -07:00
Jun Wu
83cea2adb8 api: set Content-Type to json
This avoids issues in browsers like WeChat where the encoding is
incorrect and the summary feature does not work if it contains
zh-CN characters.
2023-04-01 03:37:09 -07:00
Yifei Zhang
0385f6ede9 fix: #305 disable double click to copy on pc 2023-04-01 15:46:34 +08:00
Yifei Zhang
45bf2c3d25 fix: remove scroll anchor height 2023-04-01 15:39:30 +08:00
Yifei Zhang
e6b64b0f2c Merge pull request #303 from leedom92/add-confirm-when-remove-chatitem
feat: add confirm tips when deleting conversation on pc
2023-04-01 13:08:06 +08:00
leedom
4dc1e025e1 feat: add confirm tips when deleting conversation on pc 2023-04-01 10:24:06 +08:00
Yifei Zhang
ba08b10de1 Merge pull request #285 from Dogtiti/main
修复在移动端高度被搜索栏占用导致无法完整显示一屏问题
2023-04-01 02:02:05 +08:00
Yifei Zhang
de35862cc5 Merge pull request #294 from hibobmaster/fix-deploy
Update docker.yml
2023-04-01 01:53:07 +08:00
hibobmaster
407c9fc9c3 Update docker.yml 2023-03-31 23:03:57 +08:00
Dogtiti
536358cb3c Merge branch 'Yidadaa:main' into main 2023-03-31 19:21:46 +08:00
Dogtiti
5f7a264e52 fix: 修复在手机浏览器高度样式问题 2023-03-31 19:21:11 +08:00
Yifei Zhang
c70c311989 Merge pull request #283 from Yidadaa/fix-credit-cache
fix: #277 no cache for credit query
2023-03-31 18:41:21 +08:00
Yifei Zhang
e5aa72af76 fix: #277 no cache for credit query 2023-03-31 18:33:26 +08:00
Yifei Zhang
eb586ba361 Merge pull request #259 from DanielRondonGarcia/main
Update: locales in spanish
2023-03-31 16:45:29 +08:00
RugerMc
1db210097c feat: add switch of send preview bubble 2023-03-31 13:16:12 +08:00
Daniel Gerardo Rondón García
7f16698f01 Update: language options to "Language".
- Update Name option in language files to display 'Language' consistently
- Fix locale issues in 'tw' and 'cn' files that were mistakenly changed
2023-03-30 23:32:56 -05:00
Yifei Zhang
61eb356fd9 Update README.md 2023-03-31 12:02:14 +08:00
Yifei Zhang
35a402c67e Merge pull request #262 from XiaoMiku01/main
add auto sync fork action daily
2023-03-31 11:19:50 +08:00
Yifei Zhang
5a910e0f29 Update README.md 2023-03-31 10:37:33 +08:00
XiaoMiku01
be8a35063c add auto sync fork action 2023-03-31 10:05:58 +08:00
Daniel Gerardo Rondón García
df75b9973a Update: locales in spanish 2023-03-30 19:48:57 -05:00
Yifei Zhang
2f2e0b6762 fix: commit id as version id 2023-03-30 18:15:49 +00:00
Yifei Zhang
83862eae44 Update docker.yml 2023-03-31 01:56:42 +08:00
Yifei Zhang
6e6faec398 Merge pull request #255 from Yidadaa/docker-fix
fix: docker build
2023-03-31 01:42:37 +08:00
Yifei Zhang
e7e39ba56e fix: docker build 2023-03-30 17:41:43 +00:00
Yifei Zhang
fe9dd88c3f Merge pull request #254 from Yidadaa/bugfix-0330
Bugfix 0330
2023-03-31 01:10:36 +08:00
Yifei Zhang
b3fdf3efec fix: #182 prompt cannot be selected 2023-03-30 17:08:50 +00:00
Yifei Zhang
802ea20ec4 fix: auto scroll on enter 2023-03-30 17:04:32 +00:00
Yifei Zhang
52a217883d Merge pull request #234 from GOWxx/feat_set_history_to_zero
feat: support history message count to zero
2023-03-31 00:56:57 +08:00
Yifei Zhang
7783545bff feat: use tag as version number 2023-03-30 16:46:17 +00:00
Yifei Zhang
1b140a1ed3 fix: tight border on mobile style 2023-03-30 16:20:47 +00:00
Yifei Zhang
bf50ebac94 fix: #229 disable light code theme 2023-03-30 15:50:08 +00:00
Yifei Zhang
7599ae385b fix: #244 better scroll ux 2023-03-30 15:42:54 +00:00
Yifei Zhang
7827b40f17 fix: #185 input and select align center 2023-03-30 15:25:38 +00:00
Yifei Zhang
dea3d26335 fix: crash caused by filter config 2023-03-30 15:20:19 +00:00
GOWxx
0c9add7988 feat: support history message count to zero 2023-03-30 18:50:58 +08:00
38 changed files with 1505 additions and 860 deletions

View File

@@ -28,7 +28,7 @@ jobs:
images: yidadaa/chatgpt-next-web
tags: |
type=raw,value=latest
type=semver,pattern={{version}}
type=ref,event=tag
-
name: Set up QEMU
@@ -43,10 +43,10 @@ jobs:
uses: docker/build-push-action@v4
with:
context: .
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

29
.github/workflows/sync.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: Upstream Sync
on:
schedule:
- cron: '0 */12 * * *' # every 12 hours
workflow_dispatch: # on button click
jobs:
sync_latest_from_upstream:
name: Sync latest commits from upstream repo
runs-on: ubuntu-latest
steps:
# Step 1: run a standard checkout action, provided by github
- name: Checkout target repo
uses: actions/checkout@v3
# Step 2: run the sync action
- name: Sync upstream changes
id: sync
uses: aormsby/Fork-Sync-With-Upstream-action@v3.4
with:
upstream_sync_repo: Yidadaa/ChatGPT-Next-Web
upstream_sync_branch: main
target_sync_branch: main
target_repo_token: ${{ secrets.GITHUB_TOKEN }} # automatically generated, no need to set
# Set test_mode true to run tests instead of the true action!!
test_mode: false

View File

@@ -6,13 +6,9 @@ RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json yarn.lock* package-lock.json* ./
COPY package.json yarn.lock ./
RUN \
if [ -f yarn.lock ]; then yarn install --frozen-lockfile --network-timeout 100000; \
elif [ -f package-lock.json ]; then npm ci; \
else echo "Lockfile not found." && exit 1; \
fi
RUN yarn install
FROM base AS builder

View File

@@ -35,12 +35,25 @@ One-Click to deploy your own ChatGPT web UI.
- Awesome prompts powered by [awesome-chatgpt-prompts-zh](https://github.com/PlexPt/awesome-chatgpt-prompts-zh) and [awesome-chatgpt-prompts](https://github.com/f/awesome-chatgpt-prompts)
- Automatically compresses chat history to support long conversations while also saving your tokens
- One-click export all chat history with full Markdown support
- I18n supported
## 使用
## 开发计划 Roadmap
- System Prompt: pin a user defined prompt as system prompt 为每个对话设置系统 Prompt [#138](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)
- User Prompt: user can edit and save custom prompts to prompt list 允许用户自行编辑内置 Prompt 列表
- Self-host Model: support llama, alpaca, ChatGLM, BELLE etc. 支持自部署的大语言模型
- Plugins: support network search, caculator, any other apis etc. 插件机制,支持联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
### 不会开发的功能 Not in Plan
- User login, accounts, cloud sync 用户登录、账号管理、消息云同步
- UI text customize 界面文字自定义
## 开始使用
1. 准备好你的 [OpenAI API Key](https://platform.openai.com/account/api-keys);
2. 点击右侧按钮开始部署:
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web),直接使用 Github 账号登即可,记得在环境变量页填入 API Key
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web),直接使用 Github 账号登即可,记得在环境变量页填入 API Key
3. 部署完毕后,即可开始使用;
4. (可选)[绑定自定义域名](https://vercel.com/docs/concepts/projects/domains/add-a-domain)Vercel 分配的域名 DNS 在某些区域被污染了,绑定自定义域名即可直连。
@@ -170,12 +183,6 @@ docker run -d -p 3000:3000 -e OPENAI_API_KEY="" -e CODE="" yidadaa/chatgpt-next-
![更多展示 More](./static/more.png)
## 捐赠 Donate USDT
> BNB Smart Chain (BEP 20)
```
0x67cD02c7EB62641De576a1fA3EdB32eA0c3ffD89
```
## 鸣谢 Special Thanks
### 捐赠者 Sponsor
@@ -191,4 +198,4 @@ docker run -d -p 3000:3000 -e OPENAI_API_KEY="" -e CODE="" yidadaa/chatgpt-next-
## LICENSE
- [Anti 996 License](https://github.com/kattgu7/Anti-996-License/blob/master/LICENSE_CN_EN)
[Anti 996 License](https://github.com/kattgu7/Anti-996-License/blob/master/LICENSE_CN_EN)

View File

@@ -8,6 +8,15 @@ async function createStream(req: NextRequest) {
const res = await requestOpenai(req);
const contentType = res.headers.get("Content-Type") ?? "";
if (!contentType.includes("stream")) {
const content = await (
await res.text()
).replace(/provided:.*. You/, "provided: ***. You");
console.log("[Stream] error ", content);
return "```json\n" + content + "```";
}
const stream = new ReadableStream({
async start(controller) {
function onParse(event: any) {

View File

@@ -3,8 +3,10 @@ import { requestOpenai } from "../common";
async function makeRequest(req: NextRequest) {
try {
const res = await requestOpenai(req);
return new Response(res.body);
const api = await requestOpenai(req);
const res = new NextResponse(api.body);
res.headers.set("Content-Type", "application/json");
return res;
} catch (e) {
console.error("[OpenAI] ", req.body, e);
return NextResponse.json(

View File

@@ -6,19 +6,21 @@
justify-content: center;
padding: 10px;
box-shadow: var(--card-shadow);
cursor: pointer;
transition: all 0.3s ease;
overflow: hidden;
user-select: none;
}
.shadow {
box-shadow: var(--card-shadow);
}
.border {
border: var(--border-in-light);
}
.icon-button:hover {
filter: brightness(0.9);
border-color: var(--primary);
}
@@ -36,25 +38,7 @@
}
}
@mixin dark-button {
div:not(:global(.no-dark))>.icon-button-icon {
filter: invert(0.5);
}
.icon-button:hover {
filter: brightness(1.2);
}
}
:global(.dark) {
@include dark-button;
}
@media (prefers-color-scheme: dark) {
@include dark-button;
}
.icon-button-text {
margin-left: 5px;
font-size: 12px;
}
}

View File

@@ -7,6 +7,8 @@ export function IconButton(props: {
icon: JSX.Element;
text?: string;
bordered?: boolean;
shadow?: boolean;
noDark?: boolean;
className?: string;
title?: string;
}) {
@@ -14,12 +16,19 @@ export function IconButton(props: {
<div
className={
styles["icon-button"] +
` ${props.bordered && styles.border} ${props.className ?? ""}`
` ${props.bordered && styles.border} ${props.shadow && styles.shadow} ${
props.className ?? ""
} clickable`
}
onClick={props.onClick}
title={props.title}
role="button"
>
<div className={styles["icon-button-icon"]}>{props.icon}</div>
<div
className={styles["icon-button-icon"] + ` ${props.noDark && "no-dark"}`}
>
{props.icon}
</div>
{props.text && (
<div className={styles["icon-button-text"]}>{props.text}</div>
)}

View File

@@ -0,0 +1,73 @@
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";
import Locale from "../locales";
import { isMobileScreen } from "../utils";
export function ChatItem(props: {
onClick?: () => void;
onDelete?: () => void;
title: string;
count: number;
time: string;
selected: boolean;
}) {
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)}
</div>
<div className={styles["chat-item-date"]}>{props.time}</div>
</div>
<div className={styles["chat-item-delete"]} onClick={props.onDelete}>
<DeleteIcon />
</div>
</div>
);
}
export function ChatList() {
const [sessions, selectedIndex, selectSession, removeSession] = useChatStore(
(state) => [
state.sessions,
state.currentSessionIndex,
state.selectSession,
state.removeSession,
],
);
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>
);
}

View File

@@ -0,0 +1,75 @@
@import "../styles/animation.scss";
.prompt-toast {
position: absolute;
bottom: -50px;
z-index: 999;
display: flex;
justify-content: center;
width: calc(100% - 40px);
.prompt-toast-inner {
display: flex;
justify-content: center;
align-items: center;
font-size: 12px;
background-color: var(--white);
color: var(--black);
border: var(--border-in-light);
box-shadow: var(--card-shadow);
padding: 10px 20px;
border-radius: 100px;
animation: slide-in-from-top ease 0.3s;
.prompt-toast-content {
margin-left: 10px;
}
}
}
.context-prompt {
.context-prompt-row {
display: flex;
justify-content: center;
width: 100%;
margin-bottom: 10px;
.context-role {
margin-right: 10px;
}
.context-content {
flex: 1;
max-width: 100%;
text-align: left;
}
.context-delete-button {
margin-left: 10px;
}
}
.context-prompt-button {
flex: 1;
}
}
.memory-prompt {
margin-top: 20px;
.memory-prompt-title {
font-size: 12px;
font-weight: bold;
margin-bottom: 10px;
}
.memory-prompt-content {
background-color: var(--gray);
border-radius: 6px;
padding: 10px;
font-size: 12px;
user-select: text;
}
}

642
app/components/chat.tsx Normal file
View File

@@ -0,0 +1,642 @@
import { useDebouncedCallback } from "use-debounce";
import { 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 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 {
Message,
SubmitKey,
useChatStore,
ChatSession,
BOT_HELLO,
ROLES,
} from "../store";
import {
copyToClipboard,
downloadAs,
isMobileScreen,
selectOrCopy,
} from "../utils";
import dynamic from "next/dynamic";
import { ControllerPool } from "../requests";
import { Prompt, usePromptStore } from "../store/prompt";
import Locale from "../locales";
import { IconButton } from "./button";
import styles from "./home.module.scss";
import chatStyle from "./chat.module.scss";
import { Modal, showModal, showToast } from "./ui-lib";
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
loading: () => <LoadingIcon />,
});
const Emoji = dynamic(async () => (await import("emoji-picker-react")).Emoji, {
loading: () => <LoadingIcon />,
});
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={styles["user-avtar"]}>
<Emoji unified={config.avatar} size={18} />
</div>
);
}
function exportMessages(messages: Message[], topic: string) {
const mdText =
`# ${topic}\n\n` +
messages
.map((m) => {
return m.role === "user" ? `## ${m.content}` : m.content.trim();
})
.join("\n\n");
const filename = `${topic}.md`;
showModal({
title: Locale.Export.Title,
children: (
<div className="markdown-body">
<pre className={styles["export-content"]}>{mdText}</pre>
</div>
),
actions: [
<IconButton
key="copy"
icon={<CopyIcon />}
bordered
text={Locale.Export.Copy}
onClick={() => copyToClipboard(mdText)}
/>,
<IconButton
key="download"
icon={<DownloadIcon />}
bordered
text={Locale.Export.Download}
onClick={() => downloadAs(mdText, filename)}
/>,
],
});
}
function PromptToast(props: {
showToast?: boolean;
showModal?: boolean;
setShowModal: (_: boolean) => void;
}) {
const chatStore = useChatStore();
const session = chatStore.currentSession();
const context = session.context;
const addContextPrompt = (prompt: Message) => {
chatStore.updateCurrentSession((session) => {
session.context.push(prompt);
});
};
const removeContextPrompt = (i: number) => {
chatStore.updateCurrentSession((session) => {
session.context.splice(i, 1);
});
};
const updateContextPrompt = (i: number, prompt: Message) => {
chatStore.updateCurrentSession((session) => {
session.context[i] = prompt;
});
};
return (
<div className={chatStyle["prompt-toast"]} key="prompt-toast">
{props.showToast && (
<div
className={chatStyle["prompt-toast-inner"] + " clickable"}
role="button"
onClick={() => props.setShowModal(true)}
>
<BrainIcon />
<span className={chatStyle["prompt-toast-content"]}>
{Locale.Context.Toast(context.length)}
</span>
</div>
)}
{props.showModal && (
<div className="modal-mask">
<Modal
title={Locale.Context.Edit}
onClose={() => props.setShowModal(false)}
actions={[
<IconButton
key="copy"
icon={<CopyIcon />}
bordered
text={Locale.Memory.Copy}
onClick={() => copyToClipboard(session.memoryPrompt)}
/>,
]}
>
<>
{" "}
<div className={chatStyle["context-prompt"]}>
{context.map((c, i) => (
<div className={chatStyle["context-prompt-row"]} key={i}>
<select
value={c.role}
className={chatStyle["context-role"]}
onChange={(e) =>
updateContextPrompt(i, {
...c,
role: e.target.value as any,
})
}
>
{ROLES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<input
value={c.content}
type="text"
className={chatStyle["context-content"]}
onChange={(e) =>
updateContextPrompt(i, {
...c,
content: e.target.value as any,
})
}
></input>
<IconButton
icon={<DeleteIcon />}
className={chatStyle["context-delete-button"]}
onClick={() => removeContextPrompt(i)}
bordered
/>
</div>
))}
<div className={chatStyle["context-prompt-row"]}>
<IconButton
icon={<AddIcon />}
text={Locale.Context.Add}
bordered
className={chatStyle["context-prompt-button"]}
onClick={() =>
addContextPrompt({
role: "system",
content: "",
date: "",
})
}
/>
</div>
</div>
<div className={chatStyle["memory-prompt"]}>
<div className={chatStyle["memory-prompt-title"]}>
{Locale.Memory.Title} ({session.lastSummarizeIndex} of{" "}
{session.messages.length})
</div>
<div className={chatStyle["memory-prompt-content"]}>
{session.memoryPrompt || Locale.Memory.EmptyContent}
</div>
</div>
</>
</Modal>
</div>
)}
</div>
);
}
function useSubmitHandler() {
const config = useChatStore((state) => state.config);
const submitKey = config.submitKey;
const shouldSubmit = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key !== "Enter") return false;
if (e.key === "Enter" && e.nativeEvent.isComposing) return false;
return (
(config.submitKey === SubmitKey.AltEnter && e.altKey) ||
(config.submitKey === SubmitKey.CtrlEnter && e.ctrlKey) ||
(config.submitKey === SubmitKey.ShiftEnter && e.shiftKey) ||
(config.submitKey === SubmitKey.MetaEnter && e.metaKey) ||
(config.submitKey === SubmitKey.Enter &&
!e.altKey &&
!e.ctrlKey &&
!e.shiftKey &&
!e.metaKey)
);
};
return {
submitKey,
shouldSubmit,
};
}
export function PromptHints(props: {
prompts: Prompt[];
onPromptSelect: (prompt: Prompt) => void;
}) {
if (props.prompts.length === 0) return null;
return (
<div className={styles["prompt-hints"]}>
{props.prompts.map((prompt, i) => (
<div
className={styles["prompt-hint"]}
key={prompt.title + i.toString()}
onClick={() => props.onPromptSelect(prompt)}
>
<div className={styles["hint-title"]}>{prompt.title}</div>
<div className={styles["hint-content"]}>{prompt.content}</div>
</div>
))}
</div>
);
}
function useScrollToBottom() {
// for auto-scroll
const scrollRef = useRef<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState(true);
// auto scroll
useLayoutEffect(() => {
const dom = scrollRef.current;
if (dom && autoScroll) {
setTimeout(() => (dom.scrollTop = dom.scrollHeight), 1);
}
});
return {
scrollRef,
autoScroll,
setAutoScroll,
};
}
export function Chat(props: {
showSideBar?: () => void;
sideBarShowing?: boolean;
}) {
type RenderMessage = Message & { preview?: boolean };
const chatStore = useChatStore();
const [session, sessionIndex] = useChatStore((state) => [
state.currentSession(),
state.currentSessionIndex,
]);
const fontSize = useChatStore((state) => state.config.fontSize);
const inputRef = useRef<HTMLTextAreaElement>(null);
const [userInput, setUserInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const { submitKey, shouldSubmit } = useSubmitHandler();
const { scrollRef, setAutoScroll } = useScrollToBottom();
const [hitBottom, setHitBottom] = useState(false);
const onChatBodyScroll = (e: HTMLElement) => {
const isTouchBottom = e.scrollTop + e.clientHeight >= e.scrollHeight - 20;
setHitBottom(isTouchBottom);
};
// prompt hints
const promptStore = usePromptStore();
const [promptHints, setPromptHints] = useState<Prompt[]>([]);
const onSearch = useDebouncedCallback(
(text: string) => {
setPromptHints(promptStore.search(text));
},
100,
{ leading: true, trailing: true },
);
const onPromptSelect = (prompt: Prompt) => {
setUserInput(prompt.content);
setPromptHints([]);
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;
};
// 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;
// clear search results
if (n === 0) {
setPromptHints([]);
} else if (!chatStore.config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
// check if need to trigger auto completion
if (text.startsWith("/")) {
let searchText = text.slice(1);
if (searchText.length === 0) {
searchText = " ";
}
onSearch(searchText);
}
}
};
// submit user input
const onUserSubmit = () => {
if (userInput.length <= 0) return;
setIsLoading(true);
chatStore.onUserInput(userInput).then(() => setIsLoading(false));
setUserInput("");
setPromptHints([]);
inputRef.current?.focus();
};
// stop response
const onUserStop = (messageIndex: number) => {
ControllerPool.stop(sessionIndex, messageIndex);
};
// check if should send message
const onInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (shouldSubmit(e)) {
onUserSubmit();
e.preventDefault();
}
};
const onRightClick = (e: any, message: Message) => {
// auto fill user input
if (message.role === "user") {
setUserInput(message.content);
}
// copy to clipboard
if (selectOrCopy(e.currentTarget, message.content)) {
e.preventDefault();
}
};
const onResend = (botIndex: 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;
}
}
};
const config = useChatStore((state) => state.config);
const context: RenderMessage[] = session.context.slice();
if (
context.length === 0 &&
session.messages.at(0)?.content !== BOT_HELLO.content
) {
context.push(BOT_HELLO);
}
// preview messages
const messages = context
.concat(session.messages as RenderMessage[])
.concat(
isLoading
? [
{
role: "assistant",
content: "……",
date: new Date().toLocaleString(),
preview: true,
},
]
: [],
)
.concat(
userInput.length > 0 && config.sendPreviewBubble
? [
{
role: "user",
content: userInput,
date: new Date().toLocaleString(),
preview: true,
},
]
: [],
);
const [showPromptModal, setShowPromptModal] = useState(false);
// Auto focus
useEffect(() => {
inputRef.current?.focus();
}, []);
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-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!),
);
}
}}
>
{session.topic}
</div>
<div className={styles["window-header-sub-title"]}>
{Locale.Chat.SubTitle(session.messages.length)}
</div>
</div>
<div className={styles["window-actions"]}>
<div className={styles["window-action-button"] + " " + styles.mobile}>
<IconButton
icon={<MenuIcon />}
bordered
title={Locale.Chat.Actions.ChatList}
onClick={props?.showSideBar}
/>
</div>
<div className={styles["window-action-button"]}>
<IconButton
icon={<BrainIcon />}
bordered
title={Locale.Chat.Actions.CompressedHistory}
onClick={() => {
setShowPromptModal(true);
}}
/>
</div>
<div className={styles["window-action-button"]}>
<IconButton
icon={<ExportIcon />}
bordered
title={Locale.Chat.Actions.Export}
onClick={() => {
exportMessages(session.messages, session.topic);
}}
/>
</div>
</div>
<PromptToast
showToast={!hitBottom}
showModal={showPromptModal}
setShowModal={setShowPromptModal}
/>
</div>
<div
className={styles["chat-body"]}
ref={scrollRef}
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
onMouseOver={() => inputRef.current?.blur()}
onTouchStart={() => inputRef.current?.blur()}
>
{messages.map((message, i) => {
const isUser = message.role === "user";
return (
<div
key={i}
className={
isUser ? styles["chat-message-user"] : styles["chat-message"]
}
>
<div className={styles["chat-message-container"]}>
<div className={styles["chat-message-avatar"]}>
<Avatar role={message.role} />
</div>
{(message.preview || message.streaming) && (
<div className={styles["chat-message-status"]}>
{Locale.Chat.Typing}
</div>
)}
<div className={styles["chat-message-item"]}>
{!isUser &&
!(message.preview || message.content.length === 0) && (
<div className={styles["chat-message-top-actions"]}>
{message.streaming ? (
<div
className={styles["chat-message-top-action"]}
onClick={() => onUserStop(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={() => copyToClipboard(message.content)}
>
{Locale.Chat.Actions.Copy}
</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>
)}
</div>
{!isUser && !message.preview && (
<div className={styles["chat-message-actions"]}>
<div className={styles["chat-message-action-date"]}>
{message.date.toLocaleString()}
</div>
</div>
)}
</div>
</div>
);
})}
</div>
<div className={styles["chat-input-panel"]}>
<PromptHints prompts={promptHints} onPromptSelect={onPromptSelect} />
<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}
onFocus={() => setAutoScroll(true)}
onBlur={() => {
setAutoScroll(false);
setTimeout(() => setPromptHints([]), 500);
}}
autoFocus={!props?.sideBarShowing}
/>
<IconButton
icon={<SendWhiteIcon />}
text={Locale.Chat.Send}
className={styles["chat-input-send"]}
noDark
onClick={onUserSubmit}
/>
</div>
</div>
</div>
);
}

View File

@@ -1,4 +1,5 @@
@import "./window.scss";
@import "../styles/animation.scss";
@mixin container {
background-color: var(--white);
@@ -26,13 +27,13 @@
@media only screen and (min-width: 600px) {
.tight-container {
--window-width: 100vw;
--window-height: 100vh;
--window-height: var(--full-height);
--window-content-width: calc(100% - var(--sidebar-width));
@include container();
max-width: 100vw;
max-height: 100vh;
max-height: var(--full-height);
border-radius: 0;
}
@@ -73,8 +74,8 @@
.sidebar {
position: absolute;
left: -100%;
z-index: 999;
height: 100vh;
z-index: 1000;
height: var(--full-height);
transition: all ease 0.3s;
box-shadow: none;
}
@@ -132,18 +133,6 @@
overflow: hidden;
}
@keyframes slide-in {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0px);
}
}
.chat-item:hover {
background-color: var(--hover-color);
}
@@ -218,7 +207,7 @@
flex: 1;
overflow: auto;
padding: 20px;
margin-bottom: 100px;
position: relative;
}
.chat-body-title {
@@ -342,11 +331,9 @@
}
.chat-input-panel {
position: absolute;
bottom: 0px;
display: flex;
width: 100%;
padding: 20px;
padding-top: 5px;
box-sizing: border-box;
flex-direction: column;
}

View File

@@ -1,7 +1,6 @@
"use client";
import { useState, useRef, useEffect, useLayoutEffect } from "react";
import { useDebouncedCallback } from "use-debounce";
import { IconButton } from "./button";
import styles from "./home.module.scss";
@@ -9,27 +8,31 @@ import styles from "./home.module.scss";
import SettingsIcon from "../icons/settings.svg";
import GithubIcon from "../icons/github.svg";
import ChatGptIcon from "../icons/chatgpt.svg";
import SendWhiteIcon from "../icons/send-white.svg";
import BrainIcon from "../icons/brain.svg";
import ExportIcon from "../icons/export.svg";
import BotIcon from "../icons/bot.svg";
import AddIcon from "../icons/add.svg";
import DeleteIcon from "../icons/delete.svg";
import LoadingIcon from "../icons/three-dots.svg";
import MenuIcon from "../icons/menu.svg";
import CloseIcon from "../icons/close.svg";
import CopyIcon from "../icons/copy.svg";
import DownloadIcon from "../icons/download.svg";
import { Message, SubmitKey, useChatStore, ChatSession } from "../store";
import { showModal, showToast } from "./ui-lib";
import { copyToClipboard, downloadAs, isIOS, selectOrCopy } from "../utils";
import {
Message,
SubmitKey,
useChatStore,
ChatSession,
BOT_HELLO,
} from "../store";
import {
copyToClipboard,
downloadAs,
isMobileScreen,
selectOrCopy,
} from "../utils";
import Locale from "../locales";
import { ChatList } from "./chat-list";
import { Chat } from "./chat";
import dynamic from "next/dynamic";
import { REPO_URL } from "../constant";
import { ControllerPool } from "../requests";
import { Prompt, usePromptStore } from "../store/prompt";
export function Loading(props: { noLogo?: boolean }) {
return (
@@ -40,457 +43,10 @@ export function Loading(props: { noLogo?: boolean }) {
);
}
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
loading: () => <LoadingIcon />,
});
const Settings = dynamic(async () => (await import("./settings")).Settings, {
loading: () => <Loading noLogo />,
});
const Emoji = dynamic(async () => (await import("emoji-picker-react")).Emoji, {
loading: () => <LoadingIcon />,
});
export function Avatar(props: { role: Message["role"] }) {
const config = useChatStore((state) => state.config);
if (props.role === "assistant") {
return <BotIcon className={styles["user-avtar"]} />;
}
return (
<div className={styles["user-avtar"]}>
<Emoji unified={config.avatar} size={18} />
</div>
);
}
export function ChatItem(props: {
onClick?: () => void;
onDelete?: () => void;
title: string;
count: number;
time: string;
selected: boolean;
}) {
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)}
</div>
<div className={styles["chat-item-date"]}>{props.time}</div>
</div>
<div className={styles["chat-item-delete"]} onClick={props.onDelete}>
<DeleteIcon />
</div>
</div>
);
}
export function ChatList() {
const [sessions, selectedIndex, selectSession, removeSession] = useChatStore(
(state) => [
state.sessions,
state.currentSessionIndex,
state.selectSession,
state.removeSession,
]
);
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={() => removeSession(i)}
/>
))}
</div>
);
}
function useSubmitHandler() {
const config = useChatStore((state) => state.config);
const submitKey = config.submitKey;
const shouldSubmit = (e: KeyboardEvent) => {
if (e.key !== "Enter") return false;
return (
(config.submitKey === SubmitKey.AltEnter && e.altKey) ||
(config.submitKey === SubmitKey.CtrlEnter && e.ctrlKey) ||
(config.submitKey === SubmitKey.ShiftEnter && e.shiftKey) ||
(config.submitKey === SubmitKey.MetaEnter && e.metaKey) ||
(config.submitKey === SubmitKey.Enter &&
!e.altKey &&
!e.ctrlKey &&
!e.shiftKey &&
!e.metaKey)
);
};
return {
submitKey,
shouldSubmit,
};
}
export function PromptHints(props: {
prompts: Prompt[];
onPromptSelect: (prompt: Prompt) => void;
}) {
if (props.prompts.length === 0) return null;
return (
<div className={styles["prompt-hints"]}>
{props.prompts.map((prompt, i) => (
<div
className={styles["prompt-hint"]}
key={prompt.title + i.toString()}
onClick={() => props.onPromptSelect(prompt)}
>
<div className={styles["hint-title"]}>{prompt.title}</div>
<div className={styles["hint-content"]}>{prompt.content}</div>
</div>
))}
</div>
);
}
export function Chat(props: {
showSideBar?: () => void;
sideBarShowing?: boolean;
}) {
type RenderMessage = Message & { preview?: boolean };
const chatStore = useChatStore();
const [session, sessionIndex] = useChatStore((state) => [
state.currentSession(),
state.currentSessionIndex,
]);
const fontSize = useChatStore((state) => state.config.fontSize);
const inputRef = useRef<HTMLTextAreaElement>(null);
const [userInput, setUserInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const { submitKey, shouldSubmit } = useSubmitHandler();
// prompt hints
const promptStore = usePromptStore();
const [promptHints, setPromptHints] = useState<Prompt[]>([]);
const onSearch = useDebouncedCallback(
(text: string) => {
setPromptHints(promptStore.search(text));
},
100,
{ leading: true, trailing: true }
);
const onPromptSelect = (prompt: Prompt) => {
setUserInput(prompt.content);
setPromptHints([]);
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;
};
// 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;
// clear search results
if (n === 0) {
setPromptHints([]);
} else if (!chatStore.config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
// check if need to trigger auto completion
if (text.startsWith("/") && text.length > 1) {
onSearch(text.slice(1));
}
}
};
// submit user input
const onUserSubmit = () => {
if (userInput.length <= 0) return;
setIsLoading(true);
chatStore.onUserInput(userInput).then(() => setIsLoading(false));
setUserInput("");
setPromptHints([]);
inputRef.current?.focus();
};
// stop response
const onUserStop = (messageIndex: number) => {
console.log(ControllerPool, sessionIndex, messageIndex);
ControllerPool.stop(sessionIndex, messageIndex);
};
// check if should send message
const onInputKeyDown = (e: KeyboardEvent) => {
if (shouldSubmit(e)) {
onUserSubmit();
e.preventDefault();
}
};
const onRightClick = (e: any, message: Message) => {
// auto fill user input
if (message.role === "user") {
setUserInput(message.content);
}
// copy to clipboard
if (selectOrCopy(e.currentTarget, message.content)) {
e.preventDefault();
}
};
const onResend = (botIndex: 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;
}
}
};
// for auto-scroll
const latestMessageRef = useRef<HTMLDivElement>(null);
// wont scroll while hovering messages
const [autoScroll, setAutoScroll] = useState(false);
// preview messages
const messages = (session.messages as RenderMessage[])
.concat(
isLoading
? [
{
role: "assistant",
content: "……",
date: new Date().toLocaleString(),
preview: true,
},
]
: []
)
.concat(
userInput.length > 0
? [
{
role: "user",
content: userInput,
date: new Date().toLocaleString(),
preview: true,
},
]
: []
);
// auto scroll
useLayoutEffect(() => {
setTimeout(() => {
const dom = latestMessageRef.current;
if (dom && !isIOS() && autoScroll) {
dom.scrollIntoView({
block: "end",
});
}
}, 500);
});
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-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!)
);
}
}}
>
{session.topic}
</div>
<div className={styles["window-header-sub-title"]}>
{Locale.Chat.SubTitle(session.messages.length)}
</div>
</div>
<div className={styles["window-actions"]}>
<div className={styles["window-action-button"] + " " + styles.mobile}>
<IconButton
icon={<MenuIcon />}
bordered
title={Locale.Chat.Actions.ChatList}
onClick={props?.showSideBar}
/>
</div>
<div className={styles["window-action-button"]}>
<IconButton
icon={<BrainIcon />}
bordered
title={Locale.Chat.Actions.CompressedHistory}
onClick={() => {
showMemoryPrompt(session);
}}
/>
</div>
<div className={styles["window-action-button"]}>
<IconButton
icon={<ExportIcon />}
bordered
title={Locale.Chat.Actions.Export}
onClick={() => {
exportMessages(session.messages, session.topic);
}}
/>
</div>
</div>
</div>
<div className={styles["chat-body"]}>
{messages.map((message, i) => {
const isUser = message.role === "user";
return (
<div
key={i}
className={
isUser ? styles["chat-message-user"] : styles["chat-message"]
}
>
<div className={styles["chat-message-container"]}>
<div className={styles["chat-message-avatar"]}>
<Avatar role={message.role} />
</div>
{(message.preview || message.streaming) && (
<div className={styles["chat-message-status"]}>
{Locale.Chat.Typing}
</div>
)}
<div className={styles["chat-message-item"]}>
{!isUser &&
!(message.preview || message.content.length === 0) && (
<div className={styles["chat-message-top-actions"]}>
{message.streaming ? (
<div
className={styles["chat-message-top-action"]}
onClick={() => onUserStop(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={() => copyToClipboard(message.content)}
>
{Locale.Chat.Actions.Copy}
</div>
</div>
)}
{(message.preview || message.content.length === 0) &&
!isUser ? (
<LoadingIcon />
) : (
<div
className="markdown-body"
style={{ fontSize: `${fontSize}px` }}
onContextMenu={(e) => onRightClick(e, message)}
onDoubleClickCapture={() => setUserInput(message.content)}
>
<Markdown content={message.content} />
</div>
)}
</div>
{!isUser && !message.preview && (
<div className={styles["chat-message-actions"]}>
<div className={styles["chat-message-action-date"]}>
{message.date.toLocaleString()}
</div>
</div>
)}
</div>
</div>
);
})}
<div ref={latestMessageRef} style={{ opacity: 0, height: "4em" }}>
-
</div>
</div>
<div className={styles["chat-input-panel"]}>
<PromptHints prompts={promptHints} onPromptSelect={onPromptSelect} />
<div className={styles["chat-input-panel-inner"]}>
<textarea
ref={inputRef}
className={styles["chat-input"]}
placeholder={Locale.Chat.Input(submitKey)}
rows={4}
onInput={(e) => onInput(e.currentTarget.value)}
value={userInput}
onKeyDown={(e) => onInputKeyDown(e as any)}
onFocus={() => setAutoScroll(true)}
onBlur={() => {
setAutoScroll(false);
setTimeout(() => setPromptHints([]), 100);
}}
autoFocus={!props?.sideBarShowing}
/>
<IconButton
icon={<SendWhiteIcon />}
text={Locale.Chat.Send}
className={styles["chat-input-send"] + " no-dark"}
onClick={onUserSubmit}
/>
</div>
</div>
</div>
);
}
function useSwitchTheme() {
const config = useChatStore((state) => state.config);
@@ -512,64 +68,6 @@ function useSwitchTheme() {
}, [config.theme]);
}
function exportMessages(messages: Message[], topic: string) {
const mdText =
`# ${topic}\n\n` +
messages
.map((m) => {
return m.role === "user" ? `## ${m.content}` : m.content.trim();
})
.join("\n\n");
const filename = `${topic}.md`;
showModal({
title: Locale.Export.Title,
children: (
<div className="markdown-body">
<pre className={styles["export-content"]}>{mdText}</pre>
</div>
),
actions: [
<IconButton
key="copy"
icon={<CopyIcon />}
bordered
text={Locale.Export.Copy}
onClick={() => copyToClipboard(mdText)}
/>,
<IconButton
key="download"
icon={<DownloadIcon />}
bordered
text={Locale.Export.Download}
onClick={() => downloadAs(mdText, filename)}
/>,
],
});
}
function showMemoryPrompt(session: ChatSession) {
showModal({
title: `${Locale.Memory.Title} (${session.lastSummarizeIndex} of ${session.messages.length})`,
children: (
<div className="markdown-body">
<pre className={styles["export-content"]}>
{session.memoryPrompt || Locale.Memory.EmptyContent}
</pre>
</div>
),
actions: [
<IconButton
key="copy"
icon={<CopyIcon />}
bordered
text={Locale.Memory.Copy}
onClick={() => copyToClipboard(session.memoryPrompt)}
/>,
],
});
}
const useHasHydrated = () => {
const [hasHydrated, setHasHydrated] = useState<boolean>(false);
@@ -586,7 +84,7 @@ export function Home() {
state.newSession,
state.currentSessionIndex,
state.removeSession,
]
],
);
const loading = !useHasHydrated();
const [showSideBar, setShowSideBar] = useState(true);
@@ -604,7 +102,9 @@ export function Home() {
return (
<div
className={`${
config.tightBorder ? styles["tight-container"] : styles.container
config.tightBorder && !isMobileScreen()
? styles["tight-container"]
: styles.container
}`}
>
<div
@@ -649,11 +149,12 @@ export function Home() {
setOpenSettings(true);
setShowSideBar(false);
}}
shadow
/>
</div>
<div className={styles["sidebar-action"]}>
<a href={REPO_URL} target="_blank">
<IconButton icon={<GithubIcon />} />
<IconButton icon={<GithubIcon />} shadow />
</a>
</div>
</div>
@@ -665,6 +166,7 @@ export function Home() {
createNewSession();
setShowSideBar(false);
}}
shadow
/>
</div>
</div>

View File

@@ -4,8 +4,8 @@ import RemarkMath from "remark-math";
import RemarkBreaks from "remark-breaks";
import RehypeKatex from "rehype-katex";
import RemarkGfm from "remark-gfm";
import RehypePrsim from "rehype-prism-plus";
import { useRef } from "react";
import RehypeHighlight from "rehype-highlight";
import { useRef, useState, RefObject, useEffect } from "react";
import { copyToClipboard } from "../utils";
export function PreCode(props: { children: any }) {
@@ -27,11 +27,43 @@ export function PreCode(props: { children: any }) {
);
}
const useLazyLoad = (ref: RefObject<Element>): boolean => {
const [isIntersecting, setIntersecting] = useState<boolean>(false);
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
setIntersecting(true);
observer.disconnect();
}
});
if (ref.current) {
observer.observe(ref.current);
}
return () => {
observer.disconnect();
};
}, [ref]);
return isIntersecting;
};
export function Markdown(props: { content: string }) {
return (
<ReactMarkdown
remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
rehypePlugins={[RehypeKatex, [RehypePrsim, { ignoreMissing: true }]]}
rehypePlugins={[
RehypeKatex,
[
RehypeHighlight,
{
detect: false,
ignoreMissing: true,
},
],
]}
components={{
pre: PreCode,
}}

View File

@@ -20,10 +20,10 @@ import {
useUpdateStore,
useAccessStore,
} from "../store";
import { Avatar, PromptHints } from "./home";
import { Avatar } from "./chat";
import Locale, { AllLangs, changeLang, getLang } from "../locales";
import { getCurrentCommitId } from "../utils";
import { getCurrentVersion } from "../utils";
import Link from "next/link";
import { UPDATE_URL } from "../constant";
import { SearchService, usePromptStore } from "../store/prompt";
@@ -49,18 +49,18 @@ function SettingItem(props: {
export function Settings(props: { closeSettings: () => void }) {
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const [config, updateConfig, resetConfig, clearAllData] = useChatStore(
(state) => [
const [config, updateConfig, resetConfig, clearAllData, clearSessions] =
useChatStore((state) => [
state.config,
state.updateConfig,
state.resetConfig,
state.clearAllData,
],
);
state.clearSessions,
]);
const updateStore = useUpdateStore();
const [checkingUpdate, setCheckingUpdate] = useState(false);
const currentId = getCurrentCommitId();
const currentId = getCurrentVersion();
const remoteId = updateStore.remoteId;
const hasNewVersion = currentId !== remoteId;
@@ -72,7 +72,6 @@ export function Settings(props: { closeSettings: () => void }) {
}
const [usage, setUsage] = useState<{
granted?: number;
used?: number;
}>();
const [loadingUsage, setLoadingUsage] = useState(false);
@@ -81,8 +80,7 @@ export function Settings(props: { closeSettings: () => void }) {
requestUsage()
.then((res) =>
setUsage({
granted: res?.total_granted,
used: res?.total_used,
used: res,
}),
)
.finally(() => {
@@ -120,7 +118,7 @@ export function Settings(props: { closeSettings: () => void }) {
<div className={styles["window-action-button"]}>
<IconButton
icon={<ClearIcon />}
onClick={clearAllData}
onClick={clearSessions}
bordered
title={Locale.Settings.Actions.ClearAll}
/>
@@ -267,19 +265,30 @@ export function Settings(props: { closeSettings: () => void }) {
></input>
</SettingItem>
<div className="no-mobile">
<SettingItem title={Locale.Settings.TightBorder}>
<input
type="checkbox"
checked={config.tightBorder}
onChange={(e) =>
updateConfig(
(config) => (config.tightBorder = e.currentTarget.checked),
)
}
></input>
</SettingItem>
</div>
<SettingItem title={Locale.Settings.TightBorder}>
<input
type="checkbox"
checked={config.tightBorder}
onChange={(e) =>
updateConfig(
(config) => (config.tightBorder = e.currentTarget.checked),
)
}
></input>
</SettingItem>
<SettingItem title={Locale.Settings.SendPreviewBubble}>
<input
type="checkbox"
checked={config.sendPreviewBubble}
onChange={(e) =>
updateConfig(
(config) =>
(config.sendPreviewBubble = e.currentTarget.checked),
)
}
></input>
</SettingItem>
</List>
<List>
<SettingItem
@@ -350,10 +359,7 @@ export function Settings(props: { closeSettings: () => void }) {
subTitle={
loadingUsage
? Locale.Settings.Usage.IsChecking
: Locale.Settings.Usage.SubTitle(
usage?.granted ?? "[?]",
usage?.used ?? "[?]",
)
: Locale.Settings.Usage.SubTitle(usage?.used ?? "[?]")
}
>
{loadingUsage ? (
@@ -375,7 +381,7 @@ export function Settings(props: { closeSettings: () => void }) {
type="range"
title={config.historyMessageCount.toString()}
value={config.historyMessageCount}
min="2"
min="0"
max="25"
step="2"
onChange={(e) =>

View File

@@ -1,3 +1,5 @@
@import "../styles/animation.scss";
.card {
background-color: var(--white);
border-radius: 10px;
@@ -24,18 +26,6 @@
height: 100vh;
}
@keyframes slide-in {
from {
transform: translateY(10px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.list-item {
display: flex;
justify-content: space-between;
@@ -138,6 +128,8 @@
justify-content: center;
.toast-content {
max-width: 80vw;
word-break: break-all;
font-size: 14px;
background-color: var(--white);
box-shadow: var(--card-shadow);

View File

@@ -1,6 +1,7 @@
.window-header {
padding: 14px 20px;
border-bottom: rgba(0, 0, 0, 0.1) 1px solid;
position: relative;
display: flex;
justify-content: space-between;
@@ -32,4 +33,4 @@
.window-action-button {
margin-left: 10px;
}
}

View File

@@ -3,3 +3,4 @@ export const REPO = "ChatGPT-Next-Web";
export const REPO_URL = `https://github.com/${OWNER}/${REPO}`;
export const UPDATE_URL = `${REPO_URL}#%E4%BF%9D%E6%8C%81%E6%9B%B4%E6%96%B0-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`;

View File

@@ -1,18 +1,19 @@
/* eslint-disable @next/next/no-page-custom-font */
import "./styles/globals.scss";
import "./styles/markdown.scss";
import "./styles/prism.scss";
import "./styles/highlight.scss";
import process from "child_process";
import { ACCESS_CODES, IS_IN_DOCKER } from "./api/access";
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.")
console.error("No git or not from git repo.");
}
export const metadata = {
@@ -22,13 +23,13 @@ export const metadata = {
title: "ChatGPT Next Web",
statusBarStyle: "black-translucent",
},
themeColor: "#fafafa"
themeColor: "#fafafa",
};
function Meta() {
const metas = {
version: COMMIT_ID ?? "unknown",
access: (ACCESS_CODES.size > 0 || IS_IN_DOCKER) ? "enabled" : "disabled",
access: ACCESS_CODES.size > 0 || IS_IN_DOCKER ? "enabled" : "disabled",
};
return (

View File

@@ -35,7 +35,7 @@ const cn = {
Download: "下载文件",
},
Memory: {
Title: "上下文记忆 Prompt",
Title: "历史记忆",
EmptyContent: "尚未记忆",
Copy: "全部复制",
},
@@ -57,6 +57,7 @@ const cn = {
cn: "简体中文",
en: "English",
tw: "繁體中文",
es: "Español",
},
},
Avatar: "头像",
@@ -76,6 +77,7 @@ const cn = {
SendKey: "发送键",
Theme: "主题",
TightBorder: "紧凑边框",
SendPreviewBubble: "发送预览气泡",
Prompt: {
Disable: {
Title: "禁用提示词自动补全",
@@ -101,8 +103,8 @@ const cn = {
},
Usage: {
Title: "账户余额",
SubTitle(granted: any, used: any) {
return `总共 $${granted}已使用 $${used}`;
SubTitle(used: any) {
return `本月已使用 $${used}`;
},
IsChecking: "正在检查…",
Check: "重新检查",
@@ -136,7 +138,7 @@ const cn = {
Topic:
"使用四到五个字直接返回这句话的简要主题,不要解释、不要标点、不要语气词、不要多余文本,如果没有主题,请直接返回“闲聊”",
Summarize:
"简要总结一下你和用户的对话,用作后续的上下文提示 prompt控制在 50 字以内",
"简要总结一下你和用户的对话,用作后续的上下文提示 prompt控制在 200 字以内",
},
ConfirmClearAll: "确认清除所有聊天、设置数据?",
},
@@ -144,6 +146,11 @@ const cn = {
Success: "已写入剪切板",
Failed: "复制失败,请赋予剪切板权限",
},
Context: {
Toast: (x: any) => `已设置 ${x} 条前置上下文`,
Edit: "前置上下文和历史记忆",
Add: "新增一条",
},
};
export type LocaleType = typeof cn;

View File

@@ -54,11 +54,12 @@ const en: LocaleType = {
Close: "Close",
},
Lang: {
Name: "语言",
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",
},
},
Avatar: "Avatar",
@@ -77,6 +78,7 @@ const en: LocaleType = {
SendKey: "Send Key",
Theme: "Theme",
TightBorder: "Tight Border",
SendPreviewBubble: "Send Preview Bubble",
Prompt: {
Disable: {
Title: "Disable auto-completion",
@@ -103,8 +105,8 @@ const en: LocaleType = {
},
Usage: {
Title: "Account Balance",
SubTitle(granted: any, used: any) {
return `Total $${granted}, Used $${used}`;
SubTitle(used: any) {
return `Used this month $${used}`;
},
IsChecking: "Checking...",
Check: "Check Again",
@@ -140,7 +142,7 @@ const en: LocaleType = {
Topic:
"Please generate a four to five word title summarizing our conversation without any lead-in, punctuation, quotation marks, periods, symbols, or additional text. Remove enclosing quotation marks.",
Summarize:
"Summarize our discussion briefly in 50 characters or less to use as a prompt for future context.",
"Summarize our discussion briefly in 200 words or less to use as a prompt for future context.",
},
ConfirmClearAll: "Confirm to clear all chat and setting data?",
},
@@ -148,6 +150,11 @@ const en: LocaleType = {
Success: "Copied to clipboard",
Failed: "Copy failed, please grant permission to access clipboard",
},
Context: {
Toast: (x: any) => `With ${x} contextual prompts`,
Edit: "Contextual and Memory Prompts",
Add: "Add One",
},
};
export default en;

162
app/locales/es.ts Normal file
View File

@@ -0,0 +1,162 @@
import { SubmitKey } from "../store/app";
import type { LocaleType } from "./index";
const es: LocaleType = {
WIP: "En construcción...",
Error: {
Unauthorized:
"Acceso no autorizado, por favor ingrese el código de acceso en la página de configuración.",
},
ChatItem: {
ChatItemCount: (count: number) => `${count} mensajes`,
},
Chat: {
SubTitle: (count: number) => `${count} mensajes con ChatGPT`,
Actions: {
ChatList: "Ir a la lista de chats",
CompressedHistory: "Historial de memoria comprimido",
Export: "Exportar todos los mensajes como Markdown",
Copy: "Copiar",
Stop: "Detener",
Retry: "Reintentar",
},
Rename: "Renombrar chat",
Typing: "Escribiendo...",
Input: (submitKey: string) => {
var inputHints = `Escribe algo y presiona ${submitKey} para enviar`;
if (submitKey === String(SubmitKey.Enter)) {
inputHints += ", presiona Shift + Enter para nueva línea";
}
return inputHints;
},
Send: "Enviar",
},
Export: {
Title: "Todos los mensajes",
Copy: "Copiar todo",
Download: "Descargar",
},
Memory: {
Title: "Historial de memoria",
EmptyContent: "Aún no hay nada.",
Copy: "Copiar todo",
},
Home: {
NewChat: "Nuevo chat",
DeleteChat: "¿Confirmar eliminación de la conversación seleccionada?",
},
Settings: {
Title: "Configuración",
SubTitle: "Todas las configuraciones",
Actions: {
ClearAll: "Borrar todos los datos",
ResetAll: "Restablecer todas las configuraciones",
Close: "Cerrar",
},
Lang: {
Name: "Language",
Options: {
cn: "简体中文",
en: "Inglés",
tw: "繁體中文",
es: "Español",
},
},
Avatar: "Avatar",
FontSize: {
Title: "Tamaño de fuente",
SubTitle: "Ajustar el tamaño de fuente del contenido del chat",
},
Update: {
Version: (x: string) => `Versión: ${x}`,
IsLatest: "Última versión",
CheckUpdate: "Buscar actualizaciones",
IsChecking: "Buscando actualizaciones...",
FoundUpdate: (x: string) => `Se encontró una nueva versión: ${x}`,
GoToUpdate: "Actualizar",
},
SendKey: "Tecla de envío",
Theme: "Tema",
TightBorder: "Borde ajustado",
SendPreviewBubble: "Enviar burbuja de vista previa",
Prompt: {
Disable: {
Title: "Desactivar autocompletado",
SubTitle: "Escribe / para activar el autocompletado",
},
List: "Lista de autocompletado",
ListCount: (builtin: number, custom: number) =>
`${builtin} incorporado, ${custom} definido por el usuario`,
Edit: "Editar",
},
HistoryCount: {
Title: "Cantidad de mensajes adjuntos",
SubTitle: "Número de mensajes enviados adjuntos por solicitud",
},
CompressThreshold: {
Title: "Umbral de compresión de historial",
SubTitle:
"Se comprimirán los mensajes si la longitud de los mensajes no comprimidos supera el valor",
},
Token: {
Title: "Clave de API",
SubTitle: "Utiliza tu clave para ignorar el límite de código de acceso",
Placeholder: "Clave de la API de OpenAI",
},
Usage: {
Title: "Saldo de la cuenta",
SubTitle(used: any) {
return `Usado $${used}`;
},
IsChecking: "Comprobando...",
Check: "Comprobar de nuevo",
},
AccessCode: {
Title: "Código de acceso",
SubTitle: "Control de acceso habilitado",
Placeholder: "Necesita código de acceso",
},
Model: "Modelo",
Temperature: {
Title: "Temperatura",
SubTitle: "Un valor mayor genera una salida más aleatoria",
},
MaxTokens: {
Title: "Máximo de tokens",
SubTitle: "Longitud máxima de tokens de entrada y tokens generados",
},
PresencePenlty: {
Title: "Penalización de presencia",
SubTitle:
"Un valor mayor aumenta la probabilidad de hablar sobre nuevos temas",
},
},
Store: {
DefaultTopic: "Nueva conversación",
BotHello: "¡Hola! ¿Cómo puedo ayudarte hoy?",
Error: "Algo salió mal, por favor intenta nuevamente más tarde.",
Prompt: {
History: (content: string) =>
"Este es un resumen del historial del chat entre la IA y el usuario como recapitulación: " +
content,
Topic:
"Por favor, genera un título de cuatro a cinco palabras que resuma nuestra conversación sin ningún inicio, puntuación, comillas, puntos, símbolos o texto adicional. Elimina las comillas que lo envuelven.",
Summarize:
"Resuma nuestra discusión brevemente en 200 caracteres o menos para usarlo como un recordatorio para futuros contextos.",
},
ConfirmClearAll:
"¿Confirmar para borrar todos los datos de chat y configuración?",
},
Copy: {
Success: "Copiado al portapapeles",
Failed:
"La copia falló, por favor concede permiso para acceder al portapapeles",
},
Context: {
Toast: (x: any) => `With ${x} contextual prompts`,
Edit: "Contextual and Memory Prompts",
Add: "Add One",
},
};
export default es;

View File

@@ -1,10 +1,11 @@
import CN from "./cn";
import EN from "./en";
import TW from "./tw";
import ES from "./es";
export type { LocaleType } from "./cn";
export const AllLangs = ["en", "cn", "tw"] as const;
export const AllLangs = ["cn", "tw", "en", "es"] as const;
type Lang = (typeof AllLangs)[number];
const LANG_KEY = "lang";
@@ -44,6 +45,8 @@ export function getLang(): Lang {
return "cn";
} else if (lang.includes("tw")) {
return "tw";
} else if (lang.includes("es")) {
return "es";
} else {
return "en";
}
@@ -54,4 +57,4 @@ export function changeLang(lang: Lang) {
location.reload();
}
export default { en: EN, cn: CN, tw: TW }[getLang()];
export default { en: EN, cn: CN, tw: TW, es: ES }[getLang()];

View File

@@ -53,11 +53,12 @@ const tw: LocaleType = {
Close: "關閉",
},
Lang: {
Name: "語言",
Name: "Language",
Options: {
cn: "简体中文",
en: "English",
tw: "繁體中文",
es: "Español",
},
},
Avatar: "大頭貼",
@@ -76,6 +77,7 @@ const tw: LocaleType = {
SendKey: "發送鍵",
Theme: "主題",
TightBorder: "緊湊邊框",
SendPreviewBubble: "發送預覽氣泡",
Prompt: {
Disable: {
Title: "停用提示詞自動補全",
@@ -101,8 +103,8 @@ const tw: LocaleType = {
},
Usage: {
Title: "帳戶餘額",
SubTitle(granted: any, used: any) {
return `總共 $${granted}已使用 $${used}`;
SubTitle(used: any) {
return `本月已使用 $${used}`;
},
IsChecking: "正在檢查…",
Check: "重新檢查",
@@ -135,7 +137,7 @@ const tw: LocaleType = {
"這是 AI 與用戶的歷史聊天總結,作為前情提要:" + content,
Topic: "直接返回這句話的簡要主題,無須解釋,若無主題,請直接返回「閒聊」",
Summarize:
"簡要總結一下你和用戶的對話,作為後續的上下文提示 prompt且字數控制在 50 字以內",
"簡要總結一下你和用戶的對話,作為後續的上下文提示 prompt且字數控制在 200 字以內",
},
ConfirmClearAll: "確認清除所有對話、設定數據?",
},
@@ -143,6 +145,11 @@ const tw: LocaleType = {
Success: "已複製到剪貼簿中",
Failed: "複製失敗,請賦予剪貼簿權限",
},
Context: {
Toast: (x: any) => `已設置 ${x} 條前置上下文`,
Edit: "前置上下文和歷史記憶",
Add: "新增壹條",
},
};
export default tw;

View File

@@ -1,4 +1,7 @@
import { Analytics } from "@vercel/analytics/react";
import "array.prototype.at";
import { Home } from "./components/home";
export default function App() {

View File

@@ -1,6 +1,7 @@
import type { ChatRequest, ChatReponse } from "./api/openai/typing";
import { filterConfig, Message, ModelConfig, useAccessStore } from "./store";
import Locale from "./locales";
import { showToast } from "./components/ui-lib";
const TIME_OUT_MS = 30000;
@@ -48,6 +49,7 @@ export function requestOpenaiClient(path: string) {
method,
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-cache",
path,
...getHeaders(),
},
@@ -69,18 +71,38 @@ export async function requestChat(messages: Message[]) {
}
export async function requestUsage() {
const res = await requestOpenaiClient("dashboard/billing/credit_grants")(
null,
"GET",
);
const formatDate = (d: Date) =>
`${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, "0")}-${d
.getDate()
.toString()
.padStart(2, "0")}`;
const ONE_DAY = 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_available: number;
total_granted: number;
total_used: number;
total_usage: number;
error?: {
type: string;
message: string;
};
};
return response;
if (response.error && response.error.type) {
showToast(response.error.message);
return;
}
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);
}

View File

@@ -39,6 +39,7 @@ export interface ChatConfig {
fontSize: number;
theme: Theme;
tightBorder: boolean;
sendPreviewBubble: boolean;
disablePromptHint: boolean;
@@ -52,6 +53,8 @@ export interface ChatConfig {
export type ModelConfig = ChatConfig["modelConfig"];
export const ROLES: Message["role"][] = ["system", "user", "assistant"];
const ENABLE_GPT4 = true;
export const ALL_MODELS = [
@@ -89,7 +92,9 @@ export function isValidNumber(x: number, min: number, max: number) {
return typeof x === "number" && x <= max && x >= min;
}
export function filterConfig(config: ModelConfig): Partial<ModelConfig> {
export function filterConfig(oldConfig: ModelConfig): Partial<ModelConfig> {
const config = Object.assign({}, oldConfig);
const validator: {
[k in keyof ModelConfig]: (x: ModelConfig[keyof ModelConfig]) => boolean;
} = {
@@ -103,7 +108,7 @@ export function filterConfig(config: ModelConfig): Partial<ModelConfig> {
return isValidNumber(x as number, -2, 2);
},
temperature(x) {
return isValidNumber(x as number, 0, 1);
return isValidNumber(x as number, 0, 2);
},
};
@@ -126,6 +131,7 @@ const DEFAULT_CONFIG: ChatConfig = {
fontSize: 14,
theme: Theme.Auto as Theme,
tightBorder: false,
sendPreviewBubble: true,
disablePromptHint: false,
@@ -147,6 +153,7 @@ export interface ChatSession {
id: number;
topic: string;
memoryPrompt: string;
context: Message[];
messages: Message[];
stat: ChatStat;
lastUpdate: string;
@@ -154,6 +161,11 @@ export interface ChatSession {
}
const DEFAULT_TOPIC = Locale.Store.DefaultTopic;
export const BOT_HELLO: Message = {
role: "assistant",
content: Locale.Store.BotHello,
date: "",
};
function createEmptySession(): ChatSession {
const createDate = new Date().toLocaleString();
@@ -162,13 +174,8 @@ function createEmptySession(): ChatSession {
id: Date.now(),
topic: DEFAULT_TOPIC,
memoryPrompt: "",
messages: [
{
role: "assistant",
content: Locale.Store.BotHello,
date: createDate,
},
],
context: [],
messages: [],
stat: {
tokenCount: 0,
wordCount: 0,
@@ -183,6 +190,7 @@ interface ChatStore {
config: ChatConfig;
sessions: ChatSession[];
currentSessionIndex: number;
clearSessions: () => void;
removeSession: (index: number) => void;
selectSession: (index: number) => void;
newSession: () => void;
@@ -221,6 +229,13 @@ export const useChatStore = create<ChatStore>()(
...DEFAULT_CONFIG,
},
clearSessions() {
set(() => ({
sessions: [createEmptySession()],
currentSessionIndex: 0,
}));
},
resetConfig() {
set(() => ({ config: { ...DEFAULT_CONFIG } }));
},
@@ -369,16 +384,18 @@ export const useChatStore = create<ChatStore>()(
const session = get().currentSession();
const config = get().config;
const n = session.messages.length;
const recentMessages = session.messages.slice(
n - config.historyMessageCount,
);
const memoryPrompt = get().getMemoryPrompt();
const context = session.context.slice();
if (session.memoryPrompt) {
recentMessages.unshift(memoryPrompt);
if (session.memoryPrompt && session.memoryPrompt.length > 0) {
const memoryPrompt = get().getMemoryPrompt();
context.push(memoryPrompt);
}
const recentMessages = context.concat(
session.messages.slice(Math.max(0, n - config.historyMessageCount)),
);
return recentMessages;
},
@@ -416,11 +433,13 @@ export const useChatStore = create<ChatStore>()(
let toBeSummarizedMsgs = session.messages.slice(
session.lastSummarizeIndex,
);
const historyMsgLength = countMessages(toBeSummarizedMsgs);
if (historyMsgLength > 4000) {
if (historyMsgLength > get().config?.modelConfig?.max_tokens ?? 4000) {
const n = toBeSummarizedMsgs.length;
toBeSummarizedMsgs = toBeSummarizedMsgs.slice(
-config.historyMessageCount,
Math.max(0, n - config.historyMessageCount),
);
}
@@ -483,7 +502,16 @@ export const useChatStore = create<ChatStore>()(
}),
{
name: LOCAL_KEY,
version: 1,
version: 1.1,
migrate(persistedState, version) {
const state = persistedState as ChatStore;
if (version === 1) {
state.sessions.forEach((s) => (s.context = []));
}
return state;
},
},
),
);

View File

@@ -99,19 +99,19 @@ export const usePromptStore = create<PromptStore>()(
({
title,
content,
} as Prompt)
} as Prompt),
);
})
.concat([...(state?.prompts?.values() ?? [])]);
const allPromptsForSearch = builtinPrompts.reduce(
(pre, cur) => pre.concat(cur),
[]
[],
);
SearchService.count.builtin = res.en.length + res.cn.length;
SearchService.init(allPromptsForSearch);
});
},
}
)
},
),
);

View File

@@ -1,7 +1,7 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { FETCH_COMMIT_URL } from "../constant";
import { getCurrentCommitId } from "../utils";
import { FETCH_COMMIT_URL, FETCH_TAG_URL } from "../constant";
import { getCurrentVersion } from "../utils";
export interface UpdateStore {
lastUpdate: number;
@@ -19,16 +19,17 @@ export const useUpdateStore = create<UpdateStore>()(
remoteId: "",
async getLatestCommitId(force = false) {
const overOneHour = Date.now() - get().lastUpdate > 3600 * 1000;
const shouldFetch = force || overOneHour;
const overTenMins = Date.now() - get().lastUpdate > 10 * 60 * 1000;
const shouldFetch = force || overTenMins;
if (!shouldFetch) {
return getCurrentCommitId();
return getCurrentVersion();
}
try {
// const data = await (await fetch(FETCH_TAG_URL)).json();
// const remoteId = data[0].name as string;
const data = await (await fetch(FETCH_COMMIT_URL)).json();
const sha = data[0].sha as string;
const remoteId = sha.substring(0, 7);
const remoteId = (data[0].sha as string).substring(0, 7);
set(() => ({
lastUpdate: Date.now(),
remoteId,
@@ -37,13 +38,13 @@ export const useUpdateStore = create<UpdateStore>()(
return remoteId;
} catch (error) {
console.error("[Fetch Upstream Commit Id]", error);
return getCurrentCommitId();
return getCurrentVersion();
}
},
}),
{
name: UPDATE_KEY,
version: 1,
}
)
},
),
);

23
app/styles/animation.scss Normal file
View File

@@ -0,0 +1,23 @@
@keyframes slide-in {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0px);
}
}
@keyframes slide-in-from-top {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0px);
}
}

View File

@@ -53,12 +53,13 @@
--sidebar-width: 300px;
--window-content-width: calc(100% - var(--sidebar-width));
--message-max-width: 80%;
--full-height: 100%;
}
@media only screen and (max-width: 600px) {
:root {
--window-width: 100vw;
--window-height: 100vh;
--window-height: var(--full-height);
--sidebar-width: 100vw;
--window-content-width: var(--window-width);
--message-max-width: 100%;
@@ -74,20 +75,23 @@
@include dark;
}
}
html {
height: var(--full-height);
}
body {
background-color: var(--gray);
color: var(--black);
margin: 0;
padding: 0;
height: 100vh;
height: var(--full-height);
width: 100vw;
display: flex;
justify-content: center;
align-items: center;
user-select: none;
font-family: "Noto Sans SC", "SF Pro SC", "SF Pro Text", "SF Pro Icons",
"PingFang SC", "Helvetica Neue", "Helvetica", "Arial", sans-serif;
"PingFang SC", "Helvetica Neue", "Helvetica", "Arial", sans-serif;
@media only screen and (max-width: 600px) {
background-color: var(--second);
@@ -113,12 +117,17 @@ body {
select {
border: var(--border-in-light);
padding: 8px 10px;
padding: 10px;
border-radius: 10px;
appearance: none;
cursor: pointer;
background-color: var(--white);
color: var(--black);
text-align: center;
}
input {
text-align: center;
}
input[type="checkbox"] {
@@ -179,7 +188,7 @@ input[type="text"] {
appearance: none;
border-radius: 10px;
border: var(--border-in-light);
height: 32px;
min-height: 36px;
box-sizing: border-box;
background: var(--white);
color: var(--black);
@@ -196,7 +205,7 @@ div.math {
position: fixed;
top: 0;
left: 0;
height: 100vh;
height: var(--full-height);
width: 100vw;
background-color: rgba($color: #000000, $alpha: 0.5);
display: flex;
@@ -226,6 +235,7 @@ pre {
.copy-code-button {
position: absolute;
right: 10px;
top: 1em;
cursor: pointer;
padding: 0px 5px;
background-color: var(--black);
@@ -246,3 +256,15 @@ pre {
}
}
}
.clickable {
cursor: pointer;
div:not(.no-dark) > svg {
filter: invert(0.5);
}
&:hover {
filter: brightness(0.9);
}
}

114
app/styles/highlight.scss Normal file
View File

@@ -0,0 +1,114 @@
.markdown-body {
pre {
padding: 0;
}
pre,
code {
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
}
pre code.hljs {
display: block;
overflow-x: auto;
padding: 1em;
}
code.hljs {
padding: 3px 5px;
}
/*!
Theme: Tokyo-night-Dark
origin: https://github.com/enkia/tokyo-night-vscode-theme
Description: Original highlight.js style
Author: (c) Henri Vandersleyen <hvandersleyen@gmail.com>
License: see project LICENSE
Touched: 2022
*/
.hljs-comment,
.hljs-meta {
color: #565f89;
}
.hljs-deletion,
.hljs-doctag,
.hljs-regexp,
.hljs-selector-attr,
.hljs-selector-class,
.hljs-selector-id,
.hljs-selector-pseudo,
.hljs-tag,
.hljs-template-tag,
.hljs-variable.language_ {
color: #f7768e;
}
.hljs-link,
.hljs-literal,
.hljs-number,
.hljs-params,
.hljs-template-variable,
.hljs-type,
.hljs-variable {
color: #ff9e64;
}
.hljs-attribute,
.hljs-built_in {
color: #e0af68;
}
.hljs-keyword,
.hljs-property,
.hljs-subst,
.hljs-title,
.hljs-title.class_,
.hljs-title.class_.inherited__,
.hljs-title.function_ {
color: #7dcfff;
}
.hljs-selector-tag {
color: #73daca;
}
.hljs-addition,
.hljs-bullet,
.hljs-quote,
.hljs-string,
.hljs-symbol {
color: #9ece6a;
}
.hljs-code,
.hljs-formula,
.hljs-section {
color: #7aa2f7;
}
.hljs-attr,
.hljs-char.escape_,
.hljs-keyword,
.hljs-name,
.hljs-operator {
color: #bb9af7;
}
.hljs-punctuation {
color: #c0caf5;
}
.hljs {
background: #1a1b26;
color: #9aa5ce;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: 700;
}
}

View File

@@ -1,152 +0,0 @@
.markdown-body {
pre {
background: #282a36;
color: #f8f8f2;
}
code[class*="language-"],
pre[class*="language-"] {
color: #f8f8f2;
background: none;
text-shadow: 0 1px rgba(0, 0, 0, 0.3);
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
/* Code blocks */
pre[class*="language-"] {
padding: 1em;
margin: 0.5em 0;
overflow: auto;
border-radius: 0.3em;
}
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
background: #282a36;
}
/* Inline code */
:not(pre) > code[class*="language-"] {
padding: 0.1em;
border-radius: 0.3em;
white-space: normal;
}
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: #6272a4;
}
.token.punctuation {
color: #f8f8f2;
}
.namespace {
opacity: 0.7;
}
.token.property,
.token.tag,
.token.constant,
.token.symbol,
.token.deleted {
color: #ff79c6;
}
.token.boolean,
.token.number {
color: #bd93f9;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
color: #50fa7b;
}
.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.style .token.string,
.token.variable {
color: #f8f8f2;
}
.token.atrule,
.token.attr-value,
.token.function,
.token.class-name {
color: #f1fa8c;
}
.token.keyword {
color: #8be9fd;
}
.token.regex,
.token.important {
color: #ffb86c;
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}
}
@mixin light {
.markdown-body pre {
filter: invert(1) hue-rotate(90deg) brightness(1.3);
}
}
@mixin dark {
.markdown-body pre {
filter: none;
}
}
:root {
@include light();
}
.light {
@include light();
}
.dark {
@include dark();
}
@media (prefers-color-scheme: dark) {
:root {
@include dark();
}
}

View File

@@ -2,15 +2,7 @@ import { showToast } from "./components/ui-lib";
import Locale from "./locales";
export function trimTopic(topic: string) {
const s = topic.split("");
let lastChar = s.at(-1); // 获取 s 的最后一个字符
let pattern = /[,。!?、,.!?]/; // 定义匹配中文和英文标点符号的正则表达式
while (lastChar && pattern.test(lastChar!)) {
s.pop();
lastChar = s.at(-1);
}
return s.join("");
return topic.replace(/[,。!?、,.!?]*$/, "");
}
export function copyToClipboard(text: string) {
@@ -45,6 +37,10 @@ export function isIOS() {
return /iphone|ipad|ipod/.test(userAgent);
}
export function isMobileScreen() {
return window.innerWidth <= 600;
}
export function selectOrCopy(el: HTMLElement, content: string) {
const currentSelection = window.getSelection();
@@ -72,7 +68,7 @@ export function queryMeta(key: string, defaultValue?: string): string {
}
let currentId: string;
export function getCurrentCommitId() {
export function getCurrentVersion() {
if (currentId) {
return currentId;
}

View File

@@ -14,6 +14,7 @@
"dependencies": {
"@svgr/webpack": "^6.5.1",
"@vercel/analytics": "^0.1.11",
"array.prototype.at": "^1.1.1",
"emoji-picker-react": "^4.4.7",
"eventsource-parser": "^0.1.0",
"fuse.js": "^6.6.2",
@@ -23,9 +24,9 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^8.0.5",
"remark-breaks": "^3.0.2",
"rehype-highlight": "^6.0.0",
"rehype-katex": "^6.0.2",
"rehype-prism-plus": "^1.5.1",
"remark-breaks": "^3.0.2",
"remark-gfm": "^3.0.1",
"remark-math": "^5.1.1",
"sass": "^1.59.2",

View File

@@ -11,3 +11,5 @@ self.addEventListener("install", function (event) {
}),
);
});
self.addEventListener("fetch", (e) => {});

View File

@@ -1,10 +1,14 @@
import fetch from "node-fetch";
import fs from "fs/promises";
const CN_URL =
const RAW_CN_URL =
"https://raw.githubusercontent.com/PlexPt/awesome-chatgpt-prompts-zh/main/prompts-zh.json";
const EN_URL =
const CN_URL =
"https://cdn.jsdelivr.net/gh/PlexPt/awesome-chatgpt-prompts-zh@main/prompts-zh.json";
const RAW_EN_URL =
"https://raw.githubusercontent.com/f/awesome-chatgpt-prompts/main/prompts.csv";
const EN_URL =
"https://cdn.jsdelivr.net/gh/f/awesome-chatgpt-prompts@main/prompts.csv";
const FILE = "./public/prompts.json";
async function fetchCN() {

View File

@@ -1570,6 +1570,16 @@ array-union@^2.1.0:
resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d"
integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==
array.prototype.at@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/array.prototype.at/-/array.prototype.at-1.1.1.tgz#6deda3cd3c704afa16361387ea344e0b8d8831b5"
integrity sha512-n/wYNLJy/fVEU9EGPt2ww920hy1XX3XB2yTREFy1QsxctBgQV/tZIwg1G8jVxELna4pLCzg/xvvS/DDXtI4NNg==
dependencies:
call-bind "^1.0.2"
define-properties "^1.1.4"
es-abstract "^1.20.4"
es-shim-unscopables "^1.0.0"
array.prototype.flat@^1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.3.1.tgz#ffc6576a7ca3efc2f46a143b9d1dda9b4b3cf5e2"
@@ -2538,6 +2548,13 @@ fastq@^1.6.0:
dependencies:
reusify "^1.0.4"
fault@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/fault/-/fault-2.0.1.tgz#d47ca9f37ca26e4bd38374a7c500b5a384755b6c"
integrity sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==
dependencies:
format "^0.2.0"
fetch-blob@^3.1.2, fetch-blob@^3.1.4:
version "3.2.0"
resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.2.0.tgz#f09b8d4bbd45adc6f0c20b7e787e793e309dcce9"
@@ -2602,6 +2619,11 @@ form-data@^4.0.0:
combined-stream "^1.0.8"
mime-types "^2.1.12"
format@^0.2.0:
version "0.2.2"
resolved "https://registry.yarnpkg.com/format/-/format-0.2.2.tgz#d6170107e9efdc4ed30c9dc39016df942b5cb58b"
integrity sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==
formdata-polyfill@^4.0.10:
version "4.0.10"
resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423"
@@ -2864,7 +2886,7 @@ hast-util-to-string@^2.0.0:
dependencies:
"@types/hast" "^2.0.0"
hast-util-to-text@^3.1.0:
hast-util-to-text@^3.0.0, hast-util-to-text@^3.1.0:
version "3.1.2"
resolved "https://registry.yarnpkg.com/hast-util-to-text/-/hast-util-to-text-3.1.2.tgz#ecf30c47141f41e91a5d32d0b1e1859fd2ac04f2"
integrity sha512-tcllLfp23dJJ+ju5wCCZHVpzsQQ43+moJbqVX3jNWPB7z/KFC4FyZD6R7y94cHL6MQ33YtMZL8Z0aIXXI4XFTw==
@@ -2890,6 +2912,11 @@ hastscript@^7.0.0:
property-information "^6.0.0"
space-separated-tokens "^2.0.0"
highlight.js@~11.7.0:
version "11.7.0"
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.7.0.tgz#3ff0165bc843f8c9bce1fd89e2fda9143d24b11e"
integrity sha512-1rRqesRFhMO/PRF+G86evnyJkCgaZFOI+Z6kdj15TA18funfoqJXvgPCLSf0SWq3SRfg1j3HlDs8o4s3EGq1oQ==
human-signals@^4.3.0:
version "4.3.1"
resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-4.3.1.tgz#ab7f811e851fca97ffbd2c1fe9a958964de321b2"
@@ -3375,6 +3402,15 @@ loose-envify@^1.1.0, loose-envify@^1.4.0:
dependencies:
js-tokens "^3.0.0 || ^4.0.0"
lowlight@^2.0.0:
version "2.8.1"
resolved "https://registry.yarnpkg.com/lowlight/-/lowlight-2.8.1.tgz#5f54016ebd1b2f66b3d0b94d10ef6dd5df4f2e42"
integrity sha512-HCaGL61RKc1MYzEYn3rFoGkK0yslzCVDFJEanR19rc2L0mb8i58XM55jSRbzp9jcQrFzschPlwooC0vuNitk8Q==
dependencies:
"@types/hast" "^2.0.0"
fault "^2.0.0"
highlight.js "~11.7.0"
lru-cache@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920"
@@ -4364,6 +4400,17 @@ regjsparser@^0.9.1:
dependencies:
jsesc "~0.5.0"
rehype-highlight@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/rehype-highlight/-/rehype-highlight-6.0.0.tgz#8097219d8813b51f4c2b6d92db27dac6cbc9a641"
integrity sha512-q7UtlFicLhetp7K48ZgZiJgchYscMma7XjzX7t23bqEJF8m6/s+viXQEe4oHjrATTIZpX7RG8CKD7BlNZoh9gw==
dependencies:
"@types/hast" "^2.0.0"
hast-util-to-text "^3.0.0"
lowlight "^2.0.0"
unified "^10.0.0"
unist-util-visit "^4.0.0"
rehype-katex@^6.0.2:
version "6.0.2"
resolved "https://registry.yarnpkg.com/rehype-katex/-/rehype-katex-6.0.2.tgz#20197bbc10bdf79f6b999bffa6689d7f17226c35"